feat: Implement the initial IPSExtractor project structure, including application logic, GUI, core utilities, and documentation.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# core package
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Utilidades para preparar filas y encabezados CSV a partir de JSON extraído.
|
||||
|
||||
Extrae y aplana campos no-lista, detecta primera lista de objetos y genera
|
||||
filas combinando campos repetidos con cada item. Permite:
|
||||
- filtrar por `allowed_paths`
|
||||
- agregar columnas agregadas para listas específicas (path base)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def flatten_non_list_fields(obj: Any, prefix: str = "") -> Dict[str, Any]:
|
||||
flat: Dict[str, Any] = {}
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
path = f"{prefix}/{k}" if prefix else k
|
||||
if isinstance(v, dict):
|
||||
flat.update(flatten_non_list_fields(v, path))
|
||||
elif isinstance(v, list):
|
||||
# omit lists here; handled separately as items
|
||||
continue
|
||||
else:
|
||||
flat[path] = v
|
||||
else:
|
||||
flat[prefix or "value"] = obj
|
||||
return flat
|
||||
|
||||
|
||||
def _flatten_item_dict(it: Dict[str, Any], prefix: str = "") -> Dict[str, Any]:
|
||||
"""Aplana recursivamente un dict de ítem, usando '/' como separador.
|
||||
|
||||
Ejemplo:
|
||||
{"V": {"Unitario": 10}, "Codigo": "X"} -> {"V/Unitario": 10, "Codigo": "X"}
|
||||
"""
|
||||
flat: Dict[str, Any] = {}
|
||||
for k, v in it.items():
|
||||
base = f"{prefix}{k}" if not prefix else f"{prefix}{k}"
|
||||
if isinstance(v, dict):
|
||||
flat.update(_flatten_item_dict(v, base + "/"))
|
||||
else:
|
||||
flat[base] = v
|
||||
return flat
|
||||
|
||||
|
||||
def _find_first_items_list(obj: Any, exclude_paths: Optional[set[str]] = None, prefix: str = "") -> Tuple[str, List[Dict[str, Any]] | None]:
|
||||
exclude_paths = exclude_paths or set()
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
path = f"{prefix}/{k}" if prefix else k
|
||||
if isinstance(v, list) and v and isinstance(v[0], dict):
|
||||
if path not in exclude_paths:
|
||||
return path, v
|
||||
if isinstance(v, dict):
|
||||
sub_key, sub_list = _find_first_items_list(v, exclude_paths, path)
|
||||
if sub_list is not None:
|
||||
return sub_key, sub_list
|
||||
return "", None
|
||||
|
||||
|
||||
def _get_at_path(obj: Any, path: str) -> Any:
|
||||
parts = [p for p in path.split('/') if p]
|
||||
cur = obj
|
||||
for p in parts:
|
||||
if isinstance(cur, dict) and p in cur:
|
||||
cur = cur[p]
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
|
||||
def aggregate_list_fields(data: Dict[str, Any], aggregate_paths: List[str]) -> Dict[str, Any]:
|
||||
aggregated: Dict[str, Any] = {}
|
||||
for base_path in aggregate_paths:
|
||||
lst = _get_at_path(data, base_path)
|
||||
if not (isinstance(lst, list) and lst and isinstance(lst[0], dict)):
|
||||
continue
|
||||
subkeys = set()
|
||||
for it in lst:
|
||||
subkeys.update([k for k in it.keys()])
|
||||
for sk in subkeys:
|
||||
values = []
|
||||
for it in lst:
|
||||
val = it.get(sk)
|
||||
if val is None:
|
||||
continue
|
||||
values.append(str(val))
|
||||
key = f"{base_path}/*/{sk}"
|
||||
aggregated[key] = ", ".join(values)
|
||||
return aggregated
|
||||
|
||||
|
||||
def rows_from_data(
|
||||
data: Dict[str, Any],
|
||||
aggregate_list_paths: Optional[List[str]] = None,
|
||||
allowed_paths: Optional[List[str]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""Construye filas y encabezado para CSV desde un JSON estructurado.
|
||||
|
||||
- aggregate_list_paths: rutas base de listas a agregar como columnas unidas por coma.
|
||||
- allowed_paths: si se provee, limita las columnas a estas rutas (siempre incluye 'radicado').
|
||||
"""
|
||||
non_list_full = flatten_non_list_fields(data)
|
||||
agg_paths = aggregate_list_paths or []
|
||||
non_list = dict(non_list_full)
|
||||
if agg_paths:
|
||||
aggregated = aggregate_list_fields(data, agg_paths)
|
||||
if allowed_paths is not None:
|
||||
aggregated = {k: v for k, v in aggregated.items() if k in set(allowed_paths)}
|
||||
non_list.update(aggregated)
|
||||
|
||||
if allowed_paths is not None:
|
||||
allowed_set = set(allowed_paths)
|
||||
kept = {k: v for k, v in non_list.items() if (k in allowed_set or k == 'radicado')}
|
||||
non_list = kept
|
||||
|
||||
exclude = set(agg_paths) if agg_paths else set()
|
||||
items_key, items = _find_first_items_list(data, exclude_paths=exclude)
|
||||
if not items:
|
||||
header_nonlist: List[str] = []
|
||||
if 'radicado' in non_list:
|
||||
header_nonlist.append('radicado')
|
||||
header_nonlist.extend([k for k in non_list.keys() if k != 'radicado'])
|
||||
return [non_list], header_nonlist
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
header_nonlist: List[str] = []
|
||||
if 'radicado' in non_list:
|
||||
header_nonlist.append('radicado')
|
||||
header_nonlist.extend([k for k in non_list.keys() if k != 'radicado'])
|
||||
|
||||
item_keys = set()
|
||||
# allowed subrutas dentro de items, p. ej. "V/Unitario"
|
||||
allowed_item_subpaths: Optional[set[str]] = None
|
||||
if allowed_paths is not None:
|
||||
allowed_item_subpaths = set()
|
||||
prefix = f"{items_key}/*/"
|
||||
for p in allowed_paths:
|
||||
if p.startswith(prefix):
|
||||
allowed_item_subpaths.add(p[len(prefix):])
|
||||
|
||||
for it in items:
|
||||
flat_it = _flatten_item_dict(it)
|
||||
for subpath in flat_it.keys():
|
||||
if allowed_item_subpaths is not None and subpath not in allowed_item_subpaths:
|
||||
continue
|
||||
if '/' in items_key:
|
||||
item_keys.add(f"{items_key}/*/{subpath}")
|
||||
else:
|
||||
item_keys.add(subpath)
|
||||
|
||||
header = header_nonlist + sorted(item_keys)
|
||||
for it in items:
|
||||
row = dict(non_list)
|
||||
flat_it = _flatten_item_dict(it)
|
||||
if '/' in items_key:
|
||||
for subpath, val in flat_it.items():
|
||||
if allowed_item_subpaths is not None and subpath not in allowed_item_subpaths:
|
||||
continue
|
||||
row[f"{items_key}/*/{subpath}"] = val
|
||||
else:
|
||||
if allowed_item_subpaths is None:
|
||||
row.update(flat_it)
|
||||
else:
|
||||
for subpath, val in flat_it.items():
|
||||
if subpath in allowed_item_subpaths:
|
||||
row[subpath] = val
|
||||
rows.append(row)
|
||||
return rows, header
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Utilidades para convertir documentos (PDF/TIFF) a imágenes base64.
|
||||
|
||||
En Windows se requiere Poppler. Este proyecto incluye una ruta por defecto:
|
||||
`dependencies/poppler-24.08.0/Library/bin`. Ajuste si su instalación difiere.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import List, Tuple, Optional
|
||||
import time
|
||||
|
||||
from pdf2image import convert_from_path, pdfinfo_from_path
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
from application.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def pdf_to_images_bs64(pdf_path: str, validate: bool = True, dpi: int = 800, thread_count: int = 10) -> List[str]:
|
||||
"""Convierte un PDF a una lista de PNGs en base64.
|
||||
|
||||
Usa pdf2image + Poppler. Lanza ValueError si una imagen no valida.
|
||||
"""
|
||||
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
|
||||
t0 = time.time()
|
||||
logger.info("Convirtiendo PDF a imágenes", extra={"pdf": pdf_path, "dpi": dpi})
|
||||
images = convert_from_path(pdf_path, dpi=dpi, thread_count=thread_count, poppler_path=poppler_bin)
|
||||
resultados: List[str] = []
|
||||
for indice, imagen in enumerate(images):
|
||||
buffer = BytesIO()
|
||||
imagen.save(buffer, format="PNG")
|
||||
b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
if validate:
|
||||
try:
|
||||
raw = base64.b64decode(b64, validate=True)
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
im.verify()
|
||||
except Exception as error:
|
||||
raise ValueError(f"Imagen base64 inválida en la página {indice + 1}: {error}")
|
||||
resultados.append(b64)
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
logger.debug(f"PDF->imgs OK (pages={len(resultados)}, ms={elapsed_ms}, threads={thread_count})", extra={"pdf": pdf_path, "dpi": dpi})
|
||||
logger.info("PDF convertido", extra={"pdf": pdf_path, "dpi": dpi})
|
||||
return resultados
|
||||
|
||||
|
||||
def get_pdf_num_pages(pdf_path: str) -> int:
|
||||
"""Devuelve el número de páginas de un PDF usando pdfinfo_from_path."""
|
||||
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
|
||||
info = pdfinfo_from_path(pdf_path, poppler_path=poppler_bin)
|
||||
try:
|
||||
return int(info.get('Pages') or info.get('pages') or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def pdf_pages_to_images_bs64(
|
||||
pdf_path: str,
|
||||
first_page: int,
|
||||
last_page: int,
|
||||
*,
|
||||
dpi: int,
|
||||
grayscale: bool = True,
|
||||
validate: bool = False,
|
||||
thread_count: int = 10,
|
||||
) -> List[str]:
|
||||
"""Convierte un rango [first_page, last_page] (1-indexed, inclusivo) a PNGs base64.
|
||||
|
||||
Opcionalmente aplica escala de grises y omite validación para acelerar.
|
||||
"""
|
||||
poppler_bin = settings.imaging.poppler_path or 'dependencies/poppler-24.08.0/Library/bin'
|
||||
t0 = time.time()
|
||||
logger.debug(
|
||||
f"Convirtiendo rango PDF→imgs (first={first_page}, last={last_page}, dpi={dpi}, gray={grayscale})",
|
||||
extra={"pdf": pdf_path, "dpi": dpi},
|
||||
)
|
||||
images = convert_from_path(
|
||||
pdf_path,
|
||||
dpi=dpi,
|
||||
thread_count=thread_count,
|
||||
poppler_path=poppler_bin,
|
||||
first_page=first_page,
|
||||
last_page=last_page,
|
||||
)
|
||||
resultados: List[str] = []
|
||||
for idx, img in enumerate(images, start=first_page):
|
||||
if grayscale:
|
||||
img = img.convert('L')
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
if validate:
|
||||
try:
|
||||
raw = base64.b64decode(b64, validate=True)
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
im.verify()
|
||||
except Exception as error:
|
||||
raise ValueError(f"Imagen base64 inválida en la página {idx}: {error}")
|
||||
resultados.append(b64)
|
||||
logger.debug(
|
||||
f"Rango convertido (count={len(resultados)}, ms={int((time.time()-t0)*1000)})",
|
||||
extra={"pdf": pdf_path, "dpi": dpi},
|
||||
)
|
||||
return resultados
|
||||
|
||||
def tiff_to_images_bs64(tiff_path: str, validate: bool = True, pages_range: Optional[Tuple[int, int]] = (2, 3)) -> List[str]:
|
||||
"""Convierte un TIFF multipágina a PNGs en base64.
|
||||
|
||||
Por defecto, procesa un rango de páginas inclusivo cuando se especifica
|
||||
`pages_range` como `(inicio, fin)`, usando índices 1-based. Use None para
|
||||
procesar todas las páginas. Puede pasar `None` en uno de los extremos para
|
||||
indicar “sin inicio” o “sin fin”.
|
||||
"""
|
||||
resultados: List[str] = []
|
||||
t0 = time.time()
|
||||
logger.info("Convirtiendo TIFF a imágenes", extra={"pdf": tiff_path})
|
||||
with Image.open(tiff_path) as im:
|
||||
frames = []
|
||||
for idx, frame in enumerate(ImageSequence.Iterator(im), start=1):
|
||||
if pages_range is not None:
|
||||
start, end = pages_range
|
||||
# Permitir extremos None para significar “sin límite” por ese lado
|
||||
if start is not None and idx < start:
|
||||
continue
|
||||
if end is not None and idx > end:
|
||||
continue
|
||||
frames.append(frame.copy())
|
||||
for indice, frame in enumerate(frames):
|
||||
buffer = BytesIO()
|
||||
frame.convert("RGB").save(buffer, format="PNG")
|
||||
b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
if validate:
|
||||
try:
|
||||
raw = base64.b64decode(b64, validate=True)
|
||||
with Image.open(BytesIO(raw)) as imv:
|
||||
imv.verify()
|
||||
except Exception as error:
|
||||
raise ValueError(f"Imagen base64 inválida en la página TIFF {indice + 1}: {error}")
|
||||
resultados.append(b64)
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
logger.debug(f"TIFF->imgs OK (pages={len(resultados)}, ms={elapsed_ms})", extra={"pdf": tiff_path})
|
||||
logger.info("TIFF convertido", extra={"pdf": tiff_path})
|
||||
return resultados
|
||||
|
||||
def document_to_images_bs64(
|
||||
path: str,
|
||||
validate: bool = True,
|
||||
dpi: int = 600,
|
||||
thread_count: int = 10,
|
||||
tiff_pages: Optional[Tuple[int, int]] = (2, 3),
|
||||
tiff_page_range: Optional[Tuple[int, int]] = None,
|
||||
) -> List[str]:
|
||||
"""Puerta de entrada: decide conversión según la extensión (.pdf, .tif/.tiff).
|
||||
|
||||
`tiff_page_range` permite indicar un rango explícito (inicio, fin) 1-based
|
||||
para TIFF. Si es None, se usa `tiff_pages` como valor por defecto.
|
||||
"""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == ".pdf":
|
||||
return pdf_to_images_bs64(path, validate=validate, dpi=dpi, thread_count=thread_count)
|
||||
if ext in (".tif", ".tiff"):
|
||||
# Prioridad:
|
||||
# - (None, None) => procesar todas las páginas del TIFF (sin recorte).
|
||||
# - rango explícito (inicio, fin) => usar ese rango.
|
||||
# - None => usar el valor por defecto de configuración (`tiff_pages`).
|
||||
effective_range = tiff_pages
|
||||
if tiff_page_range is not None:
|
||||
if tiff_page_range == (None, None):
|
||||
effective_range = None
|
||||
else:
|
||||
effective_range = tiff_page_range
|
||||
return tiff_to_images_bs64(path, validate=validate, pages_range=effective_range)
|
||||
raise ValueError(f"Formato no soportado: {ext}")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Utilidades de texto para parseo de JSON embebido y construcción de schema."""
|
||||
import re
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
def extract_json_from_text(text: str) -> Optional[str]:
|
||||
"""Extrae el primer bloque JSON de un texto, con o sin fence ```json.
|
||||
|
||||
Devuelve la cadena JSON o None si no se encuentra.
|
||||
"""
|
||||
# 1. Try markdown fences
|
||||
match = re.search(r"```json\s*(.*?)\s*```", text, flags=re.DOTALL | re.IGNORECASE)
|
||||
if match:
|
||||
return _clean_json_string(match.group(1).strip())
|
||||
|
||||
match = re.search(r"```\s*(\{[\s\S]*?\})\s*```", text, flags=re.DOTALL)
|
||||
if match:
|
||||
return _clean_json_string(match.group(1).strip())
|
||||
|
||||
# 2. Try finding outer braces
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
candidate = text[start:end + 1].strip()
|
||||
return _clean_json_string(candidate)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _clean_json_string(json_str: str) -> str:
|
||||
"""Intenta limpiar errores comunes de JSON generado por LLM."""
|
||||
# Eliminar comas finales en objetos/listas: , } -> } y , ] -> ]
|
||||
# Nota: Esto es una heurística simple y puede fallar en strings que contienen esos patrones.
|
||||
# Usamos regex conservador.
|
||||
json_str = re.sub(r",\s*}", "}", json_str)
|
||||
json_str = re.sub(r",\s*]", "]", json_str)
|
||||
return json_str
|
||||
|
||||
|
||||
def build_schema(data: Dict[str, Any], prefix: str = "") -> Dict[str, str]:
|
||||
"""Construye un mapa ruta->tipo a partir de un dict anidado.
|
||||
|
||||
Para listas de dicts, usa la notación "/*/subcampo".
|
||||
"""
|
||||
schema: Dict[str, str] = {}
|
||||
for k, v in data.items():
|
||||
path = f"{prefix}/{k}" if prefix else k
|
||||
if isinstance(v, dict):
|
||||
schema.update(build_schema(v, path))
|
||||
elif isinstance(v, list) and v and isinstance(v[0], dict):
|
||||
for subkey, subval in v[0].items():
|
||||
schema[f"{path}/*/{subkey}"] = type(subval).__name__
|
||||
else:
|
||||
schema[path] = type(v).__name__
|
||||
return schema
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
"""Utilidades generales: parseo, descubrimiento de documentos y recolección.
|
||||
|
||||
Incluye heurísticas para elegir el documento a procesar por radicado,
|
||||
priorizando TIFF cuando existe y recurriendo a PDF/TIFF por keywords o patrones.
|
||||
"""
|
||||
import os
|
||||
import fnmatch
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
# Extensiones de documentos soportados para búsqueda por patrones
|
||||
ALLOWED_DOC_EXTS = (".pdf", ".tif", ".tiff")
|
||||
|
||||
|
||||
def parse_comma_separated(value: str) -> List[str]:
|
||||
"""Convierte una cadena coma-separada en lista normalizada (sin vacíos)."""
|
||||
return [v.strip() for v in value.split(',') if v and v.strip()]
|
||||
|
||||
|
||||
def _matches_pattern(filename: str, pattern: str) -> bool:
|
||||
"""Determina si `filename` coincide con `pattern` usando semántica tipo Windows.
|
||||
|
||||
- Admite comodines `*` y `?` en el patrón.
|
||||
- Si el patrón incluye una extensión válida (.pdf/.tif/.tiff), se compara contra
|
||||
el nombre completo (incluida la extensión).
|
||||
- Si el patrón no incluye una extensión válida, se compara solo contra el nombre
|
||||
sin extensión, pero siempre restringiendo a archivos con extensiones permitidas.
|
||||
- Si el patrón no tiene comodines, se interpreta como 'contiene' (case-insensitive)
|
||||
sobre el nombre completo, respetando igualmente las extensiones permitidas.
|
||||
"""
|
||||
if not isinstance(filename, str) or not isinstance(pattern, str):
|
||||
return False
|
||||
fname = filename.lower()
|
||||
pat = pattern.lower().strip()
|
||||
if not pat:
|
||||
return False
|
||||
|
||||
# Solo considerar archivos con extensión permitida
|
||||
stem, ext = os.path.splitext(fname)
|
||||
if ext not in ALLOWED_DOC_EXTS:
|
||||
return False
|
||||
|
||||
has_wildcards = "*" in pat or "?" in pat
|
||||
|
||||
if has_wildcards:
|
||||
p_stem, p_ext = os.path.splitext(pat)
|
||||
# Patrón con extensión explícita soportada: aplicar sobre nombre completo
|
||||
if p_ext in ALLOWED_DOC_EXTS:
|
||||
return fnmatch.fnmatch(fname, pat)
|
||||
# Patrón sin extensión explícita: aplicar sobre el nombre sin extensión
|
||||
return fnmatch.fnmatch(stem, pat)
|
||||
|
||||
# Sin comodines: interpretar como 'contiene' en el nombre completo
|
||||
return pat in fname
|
||||
|
||||
|
||||
def find_pdf_in_dir_by_keywords(dir_path: str, keywords: List[str]) -> str | None:
|
||||
"""Devuelve la primera ruta (PDF/TIFF) cuyo nombre coincide con alguno de los patrones.
|
||||
|
||||
- Acepta keywords simples (sin `*`/`?`) como 'contiene' insensible a mayúsculas.
|
||||
- Acepta patrones con `*` y `?` tipo Windows (p. ej. `*_1`, `*_OK?.pdf`). Solo
|
||||
se consideran archivos con extensiones en `ALLOWED_DOC_EXTS`.
|
||||
"""
|
||||
if not os.path.isdir(dir_path):
|
||||
return None
|
||||
files = [f for f in os.listdir(dir_path) if f.lower().endswith(ALLOWED_DOC_EXTS)]
|
||||
if not files or not keywords:
|
||||
return None
|
||||
for kw in keywords:
|
||||
matches = [f for f in files if _matches_pattern(f, kw)]
|
||||
if matches:
|
||||
matches.sort()
|
||||
return os.path.join(dir_path, matches[0])
|
||||
return None
|
||||
|
||||
|
||||
def find_first_tiff(dir_path: str) -> str | None:
|
||||
"""Devuelve la primera ruta a TIFF encontrada (orden lexicográfico)."""
|
||||
if not os.path.isdir(dir_path):
|
||||
return None
|
||||
for f in sorted(os.listdir(dir_path)):
|
||||
if f.lower().endswith(('.tif', '.tiff')):
|
||||
return os.path.join(dir_path, f)
|
||||
return None
|
||||
|
||||
|
||||
def collect_radicado_docs(root_path: str, keywords: List[str]) -> List[Dict[str, str]]:
|
||||
"""Para cada radicado, prioriza TIFF; si no hay, busca PDF/TIFF por keywords/patrones."""
|
||||
entries: List[Dict[str, str]] = []
|
||||
if not os.path.isdir(root_path):
|
||||
raise ValueError(f"La ruta raíz no es un directorio: {root_path}")
|
||||
for radicado in sorted(os.listdir(root_path)):
|
||||
subdir = os.path.join(root_path, radicado)
|
||||
if not os.path.isdir(subdir):
|
||||
continue
|
||||
selected = find_first_tiff(subdir)
|
||||
if not selected:
|
||||
selected = find_pdf_in_dir_by_keywords(subdir, keywords)
|
||||
if not selected:
|
||||
continue
|
||||
entries.append({
|
||||
"radicado": radicado,
|
||||
"path": selected,
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def collect_documents_flat(root_path: str, keywords: List[str] = None) -> List[Dict[str, str]]:
|
||||
"""Recolecta documentos PDF/TIFF directamente en la raíz.
|
||||
|
||||
El 'radicado' es el nombre del archivo sin extensión.
|
||||
Si se proveen keywords, solo incluye archivos que coincidan.
|
||||
"""
|
||||
entries: List[Dict[str, str]] = []
|
||||
if not os.path.isdir(root_path):
|
||||
raise ValueError(f"La ruta raíz no es un directorio: {root_path}")
|
||||
|
||||
files = sorted([f for f in os.listdir(root_path) if f.lower().endswith(ALLOWED_DOC_EXTS)])
|
||||
|
||||
for f in files:
|
||||
# Si hay keywords, filtrar
|
||||
if keywords:
|
||||
match = False
|
||||
for kw in keywords:
|
||||
if _matches_pattern(f, kw):
|
||||
match = True
|
||||
break
|
||||
if not match:
|
||||
continue
|
||||
|
||||
radicado = os.path.splitext(f)[0]
|
||||
entries.append({
|
||||
"radicado": radicado,
|
||||
"path": os.path.join(root_path, f)
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def find_first_matching_file(files: list[str], keyword: str) -> str | None:
|
||||
"""Devuelve el primer nombre de archivo que coincide con el keyword/patrón.
|
||||
|
||||
- Usa `_matches_pattern` para soportar comodines `*` y `?`.
|
||||
- Solo considera archivos con extensiones en `ALLOWED_DOC_EXTS`.
|
||||
- Retorna el nombre de archivo tal cual se recibió.
|
||||
"""
|
||||
if not files or not keyword:
|
||||
return None
|
||||
candidates = [f for f in files if _matches_pattern(f, keyword)]
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort()
|
||||
return candidates[0]
|
||||
Reference in New Issue
Block a user