chore: Remove obsolete build and GUI files, and introduce a new Docker Compose setup for microservices architecture. This change streamlines the project structure by eliminating legacy scripts and integrating a PostgreSQL database service alongside extraction and cleaning services.

This commit is contained in:
erik
2026-07-03 18:33:50 -05:00
parent 78df190d27
commit b55ad74539
75 changed files with 1699 additions and 8514 deletions
+22
View File
@@ -0,0 +1,22 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends poppler-utils libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY domain /app/domain
COPY core /app/core
COPY infrastructure /app/infrastructure
COPY api /app/api
EXPOSE 8001
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8001"]
+35
View File
@@ -0,0 +1,35 @@
# IPSExtractor Extraction Service
Microservicio independiente de extraccion. Esta carpeta puede vivir como repo
propio: contiene su API REST, dominio, infraestructura, dependencias y
Dockerfile sin importar codigo de otros servicios.
## Estructura
```text
api/ Endpoints FastAPI
domain/ Logica de extraccion, prompts, estimacion y normalizacion
core/ Utilidades propias de PDF, texto y CSV
infrastructure/ Persistencia SQLAlchemy del servicio
requirements.txt
Dockerfile
```
## Desarrollo local
```bash
pip install -r requirements.txt
uvicorn api.main:app --host 0.0.0.0 --port 8001
```
## Endpoints principales
- `GET /health`
- `GET /config`
- `GET /models`
- `POST /schemas`
- `POST /estimate`
- `POST /batches`
- `GET /batches/{batch_id}`
- `GET /batches/{batch_id}/rows`
+1
View File
@@ -0,0 +1 @@
"""Servicio REST de extraccion."""
+327
View File
@@ -0,0 +1,327 @@
"""API REST del microservicio de extraccion."""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from domain.batch_processor import BatchProcessor
from domain.config import extractor_llm_config, normalizer_llm_config, settings
from domain.config import get_allowed_document_types
from domain.estimator import Estimator
from domain.service import InvoiceProcessingService
from domain.strategies import OpenAICompatibleExtractor
from infrastructure.database import (
ExtractionBatch,
ExtractionRow,
init_db,
session_scope,
utcnow,
)
from infrastructure.serialization import flatten_extraction_result
def build_processing_service() -> InvoiceProcessingService:
extractor_config = extractor_llm_config()
extractor = OpenAICompatibleExtractor(
api_key=str(extractor_config["api_key"]),
base_url=str(extractor_config["base_url"]),
model=str(extractor_config["model"]),
)
return InvoiceProcessingService(
extractor,
batch_processor_factory=lambda service: BatchProcessor(service),
estimator_factory=lambda service: Estimator(service),
)
processing_service = build_processing_service()
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(
title="IPSExtractor Extraction Service",
version="1.0.0",
lifespan=lifespan,
)
class DocumentType(BaseModel):
name: str
keyword: str = ""
class SchemaRequest(BaseModel):
document_path: str | None = None
root_path: str | None = None
doc_types: list[DocumentType] | None = None
instruction: str | None = None
tiff_page_range: tuple[int, int] | None = None
structure_mode: str = "radicados"
class ExtractionRequest(BaseModel):
root_path: str
keywords: list[str] | None = None
doc_types: list[DocumentType] | None = None
fields: list[str] = Field(default_factory=list)
alias_map: dict[str, str] = Field(default_factory=dict)
instruction: str | None = None
tiff_page_range: tuple[int, int] | None = None
structure_mode: str = "radicados"
retry_on_empty: bool = False
radicado_filter: list[str] | None = None
class EstimateRequest(BaseModel):
root_path: str
keywords: list[str] | None = None
doc_types: list[DocumentType] | None = None
schema_map: dict[str, str] | None = Field(default=None, alias="schema")
selected_fields: list[str] | None = None
schema_by_type: dict[str, dict[str, str]] | None = None
selected_paths: list[str] | None = None
structure_mode: str = "radicados"
class ExtractionBatchRead(BaseModel):
id: str
status: str
root_path: str | None
request: dict[str, Any]
error: str | None
created_at: str
updated_at: str
def serialize_batch(batch: ExtractionBatch) -> dict[str, Any]:
return {
"id": batch.id,
"status": batch.status,
"root_path": batch.root_path,
"request": batch.request,
"error": batch.error,
"created_at": batch.created_at.isoformat(),
"updated_at": batch.updated_at.isoformat(),
}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/config")
def get_config() -> dict[str, Any]:
extractor_config = extractor_llm_config()
normalizer_config = normalizer_llm_config()
return {
"extractor": {
"provider": extractor_config["provider"],
"base_url": extractor_config["base_url"],
"model": extractor_config["model"],
"timeout_s": extractor_config["timeout_s"],
"api_key_configured": bool(str(extractor_config["api_key"]).strip()),
},
"normalizer": {
"provider": normalizer_config["provider"],
"base_url": normalizer_config["base_url"],
"model": normalizer_config["model"],
"timeout_s": normalizer_config["timeout_s"],
"api_key_configured": bool(str(normalizer_config["api_key"]).strip()),
},
"processing": {
"concurrency_limit": settings.processing.concurrency_limit,
"allowed_document_types": list(get_allowed_document_types()),
},
"imaging": {
"pdf_dpi": settings.imaging.pdf_dpi,
"pdf_thread_count": settings.imaging.pdf_thread_count,
"tiff_pages": settings.imaging.tiff_pages,
"poppler_path": settings.imaging.poppler_path,
},
}
@app.get("/models")
def list_models() -> dict[str, Any]:
extractor_config = extractor_llm_config()
normalizer_config = normalizer_llm_config()
try:
models = processing_service.extractor.llm.list_models()
return {
"available": models,
"configured_extractor_model": extractor_config["model"],
"configured_normalizer_model": normalizer_config["model"],
"source": extractor_config["base_url"],
}
except Exception as exc:
return {
"available": [],
"configured_extractor_model": extractor_config["model"],
"configured_normalizer_model": normalizer_config["model"],
"source": extractor_config["base_url"],
"warning": f"No se pudieron consultar modelos del backend: {exc}",
}
@app.post("/schemas")
def build_schema(request: SchemaRequest) -> dict[str, Any]:
"""Descubre campos disponibles para que el cliente API elija `fields`."""
if request.document_path:
schema, sample = processing_service.build_schema_from_first(
request.document_path,
instruction=request.instruction,
tiff_page_range=request.tiff_page_range,
)
return {"schema": schema, "sample": sample}
if request.root_path and request.doc_types:
schema_by_type, sample_by_type, first_radicado = (
processing_service.build_schema_from_first_for_types(
request.root_path,
[dt.model_dump() for dt in request.doc_types],
instruction=request.instruction,
tiff_page_range=request.tiff_page_range,
structure_mode=request.structure_mode,
)
)
return {
"schema_by_type": schema_by_type,
"sample_by_type": sample_by_type,
"first_radicado": first_radicado,
}
raise HTTPException(
status_code=400,
detail="Envia document_path o root_path con doc_types para descubrir campos.",
)
@app.post("/estimate")
def estimate(request: EstimateRequest) -> dict[str, Any]:
return processing_service.estimate_tokens_and_cost(
request.root_path,
request.keywords,
request.schema_map,
request.selected_fields,
doc_types=[dt.model_dump() for dt in request.doc_types or []] or None,
schema_by_type=request.schema_by_type,
selected_paths=request.selected_paths,
structure_mode=request.structure_mode,
)
@app.post("/batches", response_model=ExtractionBatchRead)
def run_extraction(request: ExtractionRequest) -> dict[str, Any]:
if not request.fields:
raise HTTPException(
status_code=400,
detail="El API no tiene selector visual: envia fields. Usa POST /schemas para descubrirlos.",
)
with session_scope() as session:
batch = ExtractionBatch(
status="running",
root_path=request.root_path,
request=request.model_dump(),
updated_at=utcnow(),
)
session.add(batch)
session.flush()
batch_id = batch.id
try:
results = processing_service.process_root_with_field_selection(
request.root_path,
request.keywords,
doc_types=[dt.model_dump() for dt in request.doc_types or []] or None,
fields=request.fields,
csv_output_path=None,
instruction_override=request.instruction,
alias_map=request.alias_map,
tiff_page_range=request.tiff_page_range,
structure_mode=request.structure_mode,
retry_on_empty=request.retry_on_empty,
radicado_filter=request.radicado_filter,
)
with session_scope() as session:
row_index = 0
for item in results:
raw = item if isinstance(item, dict) else {"data": item}
rows = flatten_extraction_result(raw, request.fields, request.alias_map)
for row in rows:
row_index += 1
session.add(
ExtractionRow(
batch_id=batch_id,
radicado=str(raw.get("radicado") or row.get("radicado") or ""),
source_pdf=raw.get("_source_pdf"),
row_index=row_index,
row_json=row,
raw_json=raw,
)
)
batch = session.get(ExtractionBatch, batch_id)
if batch is None:
raise RuntimeError("No se encontro el lote recien creado.")
batch.status = "completed"
batch.updated_at = utcnow()
session.flush()
return serialize_batch(batch)
except Exception as exc:
with session_scope() as session:
batch = session.get(ExtractionBatch, batch_id)
if batch is not None:
batch.status = "failed"
batch.error = str(exc)
batch.updated_at = utcnow()
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/batches")
def list_batches() -> list[dict[str, Any]]:
with session_scope() as session:
batches = session.scalars(
select(ExtractionBatch).order_by(ExtractionBatch.created_at.desc())
).all()
return [serialize_batch(batch) for batch in batches]
@app.get("/batches/{batch_id}")
def get_batch(batch_id: str) -> dict[str, Any]:
with session_scope() as session:
batch = session.get(ExtractionBatch, batch_id)
if batch is None:
raise HTTPException(status_code=404, detail="Lote no encontrado")
return serialize_batch(batch)
@app.get("/batches/{batch_id}/rows")
def get_rows(batch_id: str, limit: int = 500, offset: int = 0) -> dict[str, Any]:
with session_scope() as session:
batch = session.get(ExtractionBatch, batch_id)
if batch is None:
raise HTTPException(status_code=404, detail="Lote no encontrado")
rows = session.scalars(
select(ExtractionRow)
.where(ExtractionRow.batch_id == batch_id)
.order_by(ExtractionRow.row_index, ExtractionRow.id)
.offset(offset)
.limit(limit)
).all()
return {
"batch_id": batch_id,
"rows": [row.row_json for row in rows],
"limit": limit,
"offset": offset,
}
+3
View File
@@ -0,0 +1,3 @@
# core package
+254
View File
@@ -0,0 +1,254 @@
"""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 set_at_path(root: Dict[str, Any], path: str, value: Any) -> None:
"""Asigna ``value`` en ``root`` siguiendo la ruta con segmentos ``/`` (crea dicts intermedios)."""
parts = [p for p in path.split('/') if p]
if not parts:
return
cur: Any = root
for p in parts[:-1]:
if not isinstance(cur, dict):
return
nxt = cur.get(p)
if not isinstance(nxt, dict):
cur[p] = {}
nxt = cur[p]
cur = nxt
if isinstance(cur, dict):
cur[parts[-1]] = value
def _resolve_raw_string_for_scalar_path(raw: Dict[str, Any], path: str) -> tuple[Optional[str], bool]:
"""Resuelve un string en ``raw`` para la ruta canónica ``path``.
Devuelve ``(valor, ambiguous)``. Si ``ambiguous`` es True, no debe usarse ningún valor
(varias claves aplanadas comparten el último segmento del path).
"""
direct = _get_at_path(raw, path)
if isinstance(direct, str) and direct.strip():
return direct, False
parts = [p for p in path.split('/') if p]
if not parts:
return None, False
last = parts[-1]
raw_flat = flatten_non_list_fields(raw)
same_last: List[tuple[str, str]] = []
for k, v in raw_flat.items():
if not isinstance(v, str) or not v.strip():
continue
segs = [s for s in k.split('/') if s]
if not segs:
continue
if segs[-1] == last:
same_last.append((k, v))
if len(same_last) == 1:
return same_last[0][1], False
if len(same_last) > 1:
return None, True
return None, False
def merge_verbatim_long_strings(
raw: Dict[str, Any],
normalized: Dict[str, Any],
paths: Optional[List[str]],
) -> Dict[str, Any]:
"""Sustituye en ``normalized`` cadenas truncadas por la versión más larga del ``raw`` (misma ruta o sufijo único).
Solo actúa sobre rutas escalares (sin ``*``). Evita sustituciones cuando hay varias claves
candidatas con el mismo último segmento o cuando el texto normalizado no parece un prefijo
del crudo (salvo cadena vacía o solo espacios).
"""
if not isinstance(normalized, dict) or not isinstance(raw, dict):
return normalized
for path in paths or []:
if not path or '*' in path:
continue
norm_val = _get_at_path(normalized, path)
if not isinstance(norm_val, str):
continue
raw_val, ambiguous = _resolve_raw_string_for_scalar_path(raw, path)
if ambiguous or raw_val is None:
continue
if len(raw_val) <= len(norm_val):
continue
nt = norm_val.strip()
if nt:
if nt not in raw_val:
lim = min(80, len(nt))
if lim == 0 or not raw_val.startswith(nt[:lim]):
continue
set_at_path(normalized, path, raw_val)
return normalized
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
+233
View File
@@ -0,0 +1,233 @@
"""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
logger = logging.getLogger(__name__)
_DEFAULT_POPPLER = 'dependencies/poppler-24.08.0/Library/bin'
def _resolve_poppler(poppler_path: str | None) -> str:
"""Devuelve la ruta a Poppler: usa la provista o el default del proyecto."""
return poppler_path or _DEFAULT_POPPLER
def pdf_to_images_bs64(
pdf_path: str,
validate: bool = True,
dpi: int = 800,
thread_count: int = 10,
poppler_path: str | None = None,
) -> List[str]:
"""
Convierte un archivo PDF completo a una lista de imágenes PNG en base64.
Args:
pdf_path: Ruta al archivo PDF.
validate: Si es True, intenta abrir cada imagen generada para verificar su integridad.
dpi: Resolución de la imagen (puntos por pulgada).
thread_count: Número de hilos para la conversión paralela de páginas.
poppler_path: Ruta personalizada a los binarios de Poppler (opcional).
Returns:
Lista de strings con las imágenes codificadas en base64.
Raises:
ValueError: Si la validación de alguna imagen falla.
"""
poppler_bin = _resolve_poppler(poppler_path)
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, poppler_path: str | None = None) -> int:
"""
Obtiene el número total de páginas de un PDF.
Args:
pdf_path: Ruta al archivo PDF.
poppler_path: Ruta personalizada a los binarios de Poppler (opcional).
Returns:
Número de páginas (0 si ocurre un error).
"""
info = pdfinfo_from_path(pdf_path, poppler_path=_resolve_poppler(poppler_path))
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,
poppler_path: str | None = None,
) -> List[str]:
"""
Convierte un rango específico de páginas de un PDF a imágenes base64.
Args:
pdf_path: Ruta al archivo PDF.
first_page: Índice de la primera página (1-indexed).
last_page: Índice de la última página (inclusivo).
dpi: Resolución de la imagen.
grayscale: Si es True, convierte las imágenes a escala de grises para ahorrar ancho de banda/tokens.
validate: Si es True, verifica la integridad de las imágenes generadas.
thread_count: Número de hilos para procesamiento paralelo.
poppler_path: Ruta personalizada a Poppler.
Returns:
Lista de imágenes en base64.
"""
poppler_bin = _resolve_poppler(poppler_path)
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 archivo TIFF multipágina a una lista de imágenes PNG en base64.
Args:
tiff_path: Ruta al archivo TIFF.
validate: Si es True, verifica la integridad de las imágenes generadas.
pages_range: Tupla (inicio, fin) de páginas a procesar (1-indexed, inclusivo).
Use None para procesar todas las páginas.
Returns:
Lista de imágenes en base64.
"""
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,
poppler_path: str | None = None,
) -> List[str]:
"""
Función unificada para convertir documentos (PDF o TIFF) a imágenes base64.
Detecta automáticamente el formato por la extensión del archivo.
Args:
path: Ruta al archivo.
validate: Si es True, valida las imágenes generadas.
dpi: Resolución para archivos PDF.
thread_count: Hilos paralelos para archivos PDF.
tiff_pages: Páginas TIFF por defecto si tiff_page_range es None.
tiff_page_range: Rango explícito de páginas para TIFF (inicio, fin).
poppler_path: Ruta personalizada a Poppler para PDFs.
Returns:
Lista de imágenes en base64.
"""
ext = os.path.splitext(path)[1].lower()
if ext == ".pdf":
return pdf_to_images_bs64(path, validate=validate, dpi=dpi, thread_count=thread_count, poppler_path=poppler_path)
if ext in (".tif", ".tiff"):
effective_range = tiff_pages
if tiff_page_range is not None:
effective_range = None if tiff_page_range == (None, None) else tiff_page_range
return tiff_to_images_bs64(path, validate=validate, pages_range=effective_range)
raise ValueError(f"Formato no soportado: {ext}")
+56
View File
@@ -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
View File
@@ -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]
+14
View File
@@ -0,0 +1,14 @@
"""Paquete `domain`: lógica de alto nivel de extracción y orquestación.
Incluye:
- `service`: fachada de procesamiento de facturas.
- `batch_processor`: procesamiento por lotes y orquestación de radicados.
- `normalizer`: normalización LLM a un esquema canónico.
- `strategies`: estrategias de extracción con backends compatibles con Chat Completions.
- `profiles`: perfiles por tipo de documento e instrucciones por defecto.
- `prompts`: construcción de prompts de extracción/normalización.
- `config`: configuración centralizada (Pydantic BaseSettings).
- `logging_config`: configuración de logging JSON y loggers dedicados.
"""
from __future__ import annotations
@@ -0,0 +1,916 @@
"""Procesador por lotes para la extracción de facturas.
Encapsula la lógica de iteración sobre radicados, extracción, normalización y exportación a CSV.
"""
import os
import logging
import asyncio
import openai
from typing import List, Dict, Any, Optional, Tuple, Callable, Awaitable, TYPE_CHECKING
if TYPE_CHECKING:
from domain.service import InvoiceProcessingService
from domain.profiles import get_profile
from domain.prompts import PromptBuilder
from domain.normalizer import normalize_to_schema
from domain.costs import compute_cost_from_usage, accumulate_usage
from domain.file_discovery import get_sorted_radicados, get_files_as_radicados
from domain.csv_manager import CSVManager
from domain.config import settings, get_allowed_document_types
from core.utils import collect_radicado_docs, find_first_matching_file, collect_documents_flat
from core.csv_utils import merge_verbatim_long_strings, rows_from_data
logger = logging.getLogger(__name__)
DOCUMENT_FORCE_TARGETS = {
"tipo de documento",
"documento_forzate",
}
DOCUMENT_TYPE_HINT_ALIASES = {
"PT",
"PAS",
"PASS",
}
class BatchEventSink:
"""Recibe eventos del lote sin depender de un servidor externo."""
def start_batch(self, *args, **kwargs):
return None
def update_progress(self, *args, **kwargs):
return None
def log(self, *args, **kwargs):
return None
def report_phase(self, *args, **kwargs):
return None
def report_error(self, *args, **kwargs):
return None
def complete_batch(self, *args, **kwargs):
return None
class BatchProcessor:
def __init__(self, service: 'InvoiceProcessingService'):
"""
Args:
service: Instancia de InvoiceProcessingService para delegar la extracción individual.
"""
self.service = service
self.csv_manager = CSVManager()
self.event_sink = BatchEventSink()
self._pause_event = asyncio.Event()
self._pause_event.set() # No pausado inicialmente
self._stop_requested = False
# Contadores acumulados para progreso de lote
self._track_tokens: int = 0
self._track_cost_usd: float = 0.0
self._track_success: int = 0
self._track_errors: int = 0
def process_root(
self,
root_path: str,
keywords: List[str] | None = None,
*,
doc_types: Optional[List[Dict[str, str]]] = None,
fields: Optional[List[str]] = None,
on_progress: Optional[callable] = None,
csv_output_path: Optional[str] = None,
csv_header_override: Optional[List[str]] = None,
instruction_override: Optional[str] = None,
on_usage: Optional[Callable[[str, str, Dict[str, Any] | None, Dict[str, Any] | None], None]] = None,
on_error_retry: Optional[Callable[[Exception], Awaitable[bool]]] = None,
alias_map: Optional[Dict[str, str]] = None,
tiff_page_range: Optional[Tuple[int, int]] = None,
structure_mode: str = "radicados",
retry_on_empty: bool = False,
radicado_filter: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Procesa radicados con selección de campos y CSV incremental.
Delega en métodos específicos para multi-documento o single-document.
"""
# Resetear estado de pausa/parada y contadores de progreso
self._pause_event.set()
self._stop_requested = False
self._track_tokens = 0
self._track_cost_usd = 0.0
self._track_success = 0
self._track_errors = 0
# Notificar inicio si es procesamiento masivo
if fields:
# Intentar estimar total de radicados
try:
if structure_mode == "general":
temp_rads = get_files_as_radicados(root_path)
else:
temp_rads = get_sorted_radicados(root_path)
if radicado_filter:
filter_set = {r.strip() for r in radicado_filter if r.strip()}
temp_rads = [r for r in temp_rads if r in filter_set]
self.event_sink.start_batch(
root_path,
len(temp_rads),
config={
"structure_mode": structure_mode,
"fields_count": len(fields)
},
csv_output_path=csv_output_path,
)
except Exception as e:
logger.error(f"Error al iniciar progreso del lote: {e}")
# 1. Preparación de Schemas (Fase 1 Multi-doc)
if doc_types and not fields:
return self._prepare_multidoc_schemas(root_path, doc_types, instruction_override, tiff_page_range, structure_mode)
# 2. Procesamiento Multi-doc (Fase 2) - ASYNC
if doc_types and fields:
return asyncio.run(self._process_multidoc_async(
root_path=root_path,
doc_types=doc_types,
fields=fields,
csv_output_path=csv_output_path,
csv_header_override=csv_header_override,
alias_map=alias_map,
tiff_page_range=tiff_page_range,
on_progress=on_progress,
on_usage=on_usage,
on_error_retry=on_error_retry,
structure_mode=structure_mode,
retry_on_empty=retry_on_empty,
radicado_filter=radicado_filter,
))
# 3. Procesamiento Single-doc (Retrocompatibilidad)
# Si no hay fields, es la fase de detección de esquema (secuencial, solo 1 doc)
if not fields:
return self._process_singledoc(
root_path=root_path,
keywords=keywords,
fields=fields,
csv_output_path=csv_output_path,
csv_header_override=csv_header_override,
instruction_override=instruction_override,
alias_map=alias_map,
tiff_page_range=tiff_page_range,
on_progress=on_progress,
on_usage=on_usage,
structure_mode=structure_mode,
retry_on_empty=retry_on_empty,
radicado_filter=radicado_filter,
)
else:
# Si hay fields, es procesamiento masivo -> ASYNC
return asyncio.run(self._process_singledoc_async(
root_path=root_path,
keywords=keywords,
fields=fields,
csv_output_path=csv_output_path,
csv_header_override=csv_header_override,
instruction_override=instruction_override,
alias_map=alias_map,
tiff_page_range=tiff_page_range,
on_progress=on_progress,
on_usage=on_usage,
on_error_retry=on_error_retry,
structure_mode=structure_mode,
retry_on_empty=retry_on_empty,
radicado_filter=radicado_filter,
))
def _prepare_multidoc_schemas(self, root_path, doc_types, instruction, tiff_page_range, structure_mode):
schema_by_type, first_full_by_type, first_radicado = self.service.build_schema_from_first_for_types(
root_path,
doc_types,
instruction=instruction,
tiff_page_range=tiff_page_range,
structure_mode=structure_mode
)
return [{
"_needs_fields": True,
"schema_by_type": schema_by_type,
"first_full_by_type": first_full_by_type,
"first_radicado": first_radicado,
}]
async def _process_multidoc_async(
self, root_path, doc_types, fields, csv_output_path,
csv_header_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None,
structure_mode="radicados", retry_on_empty=False, radicado_filter=None
):
effective_fields = self._expand_document_force_fields(fields or [], alias_map)
effective_alias_map = self._expand_document_force_alias_map(alias_map, effective_fields)
csv_header = self._prepare_csv_header(effective_fields, effective_alias_map, csv_header_override)
if structure_mode == "general":
radicados = get_files_as_radicados(root_path)
else:
radicados = get_sorted_radicados(root_path)
if not radicados and not os.path.isdir(root_path):
raise ValueError(f"La ruta raíz no es un directorio: {root_path}")
# Aplicar filtro de radicados si está activo
if radicado_filter:
filter_set = {r.strip() for r in radicado_filter if r.strip()}
if structure_mode == "general":
radicados = [r for r in radicados if os.path.splitext(r)[0] in filter_set]
else:
radicados = [r for r in radicados if r in filter_set]
logger.info(f"Filtro activo: {len(radicados)} radicados seleccionados de {len(filter_set)} indicados")
total = len(radicados)
prof = get_profile('invoice')
# Limitar concurrencia (Configurable)
limit = settings.processing.concurrency_limit
if limit < 1: limit = 1
sem = asyncio.Semaphore(limit)
# Para mantener el orden de escritura en CSV, usamos un Lock
csv_lock = asyncio.Lock()
# Contador thread-safe para progreso
completed_count = 0
progress_lock = asyncio.Lock()
async def process_radicado(idx, rad):
nonlocal completed_count
async with sem:
attempts = 0
while True: # Bucle para permitir reintentos tras pausa
attempts += 1
should_retry_empty = False
await self._pause_event.wait()
if self._stop_requested:
return {"radicado": rad, "status": "stopped"}
try:
# Determinar ID visual y ruta
if structure_mode == "general":
rad_id = os.path.splitext(rad)[0]
rad_path = os.path.join(root_path, rad) # Es el archivo
else:
rad_id = rad
rad_path = os.path.join(root_path, rad) # Es la carpeta
logger.info(f"Procesando radicado {rad_id} (multi) [{idx}/{total}]", extra={"radicado": rad_id})
fused_data = {"radicado": rad_id}
agg_extract_usage = {"prompt_tokens": 0, "completion_tokens": 0, "prompt_tokens_details": {"cached_tokens": 0}}
agg_normalize_usage = {"prompt_tokens": 0, "completion_tokens": 0, "prompt_tokens_details": {"cached_tokens": 0}}
for dt in doc_types:
tname = str(dt.get("name") or "").strip() or "Tipo"
kw = str(dt.get("keyword") or "").strip()
match = None
if structure_mode == "general":
# En modo general, el archivo ES el documento.
# Opcional: filtrar si el nombre coincide con keyword, pero usualmente
# si el usuario elige "General" y "Tipos", quiere aplicar los tipos a ese archivo.
# Pero si tiene varios tipos (Factura, Anexo), no tiene sentido aplicarlos todos al mismo archivo
# a menos que quiera intentar extraer cosas distintas.
# Asumiremos que el archivo aplica para el tipo.
match = rad_path
else:
try:
files = sorted(await asyncio.to_thread(os.listdir, rad_path))
except Exception:
files = []
match_name = find_first_matching_file(files, kw) if kw else None
match = os.path.join(rad_path, match_name) if match_name else None
if not match:
logger.info(f"[{rad_id}] Tipo '{tname}' sin archivo coincidente (kw='{kw}')")
fused_data[tname] = {}
continue
# Extraer
selected_paths_for_type = [p.split('/', 1)[1] for p in effective_fields if p.startswith(f"{tname}/")]
try:
prompt_t = PromptBuilder.build_for_type(tname, selected_paths_for_type, {}, profile_name=prof.get('name', 'invoice'))
except Exception:
prompt_t = PromptBuilder.build(selected_paths_for_type, {}, profile_name=prof.get('name', 'invoice'))
# Llamada ASYNC - Fase: Extracción
self.event_sink.report_phase("extract", rad_id)
self.event_sink.log(f"Iniciando extracción IA", level="info", radicado=rad_id)
data, usage_ext = await self.service.extract_full_from_pdf_async(match, instruction=prompt_t, tiff_page_range=tiff_page_range)
self._accumulate_usage(agg_extract_usage, usage_ext)
# Normalizar (CPU bound, mover a thread)
raw_data = data if isinstance(data, dict) else {"data": data}
if isinstance(data, dict):
data['radicado'] = rad_id
else:
data = {"radicado": rad_id, "data": data}
# Normalización en thread aparte - Fase: Normalización
self.event_sink.report_phase("normalize", rad_id)
self.event_sink.log(f"Extracción OK — normalizando campos", level="info", radicado=rad_id)
norm = await asyncio.to_thread(
normalize_to_schema,
data,
paths=selected_paths_for_type,
profile_name=prof.get('name', 'invoice')
)
normalized_obj = data
n_usage = None
if isinstance(norm, dict):
n_usage = norm.get("_usage")
normalized_obj = norm.get("normalized") or data
if isinstance(normalized_obj, dict):
normalized_obj = self._inject_document_force_fields_from_raw(
normalized_obj,
raw_data,
selected_paths_for_type,
)
merge_verbatim_long_strings(raw_data, normalized_obj, selected_paths_for_type)
self._accumulate_usage(agg_normalize_usage, n_usage)
fused_data[tname] = normalized_obj or {}
fused_data["_source_pdf"] = match
if retry_on_empty and self._is_effectively_empty_output(fused_data, effective_fields, effective_alias_map):
if attempts < 2:
logger.info(f"[{rad_id}] Resultado vacío; reintentando extracción ({attempts + 1}/2)", extra={"radicado": rad_id})
should_retry_empty = True
else:
logger.info(f"[{rad_id}] Resultado vacío tras 2 intentos; se conserva el resultado.", extra={"radicado": rad_id})
# Report usage
if should_retry_empty:
continue
self._report_usage(on_usage, rad_id, "extract", agg_extract_usage)
self._report_usage(on_usage, rad_id, "normalize", agg_normalize_usage)
# CSV Export protegido por lock
if csv_output_path:
async with csv_lock:
await asyncio.to_thread(
self._write_csv_multidoc,
csv_output_path, fused_data, effective_fields, doc_types,
prof, csv_header, effective_alias_map, rad_id
)
return fused_data
except openai.RateLimitError as e:
if on_error_retry:
# Pausar todos los demás
self._pause_event.clear()
try:
should_retry = await on_error_retry(e)
if should_retry:
self._pause_event.set()
continue # Reintentar este radicado
else:
self._stop_requested = True
self._pause_event.set()
return self._handle_error_async(rad, e, csv_output_path, csv_header)
except Exception as e_retry:
self._pause_event.set()
return self._handle_error_async(rad, e_retry, csv_output_path, csv_header)
else:
return self._handle_error_async(rad, e, csv_output_path, csv_header)
except Exception as e:
return self._handle_error_async(rad, e, csv_output_path, csv_header)
finally:
async with progress_lock:
if not should_retry_empty:
completed_count += 1
self._notify_progress(on_progress, rad, completed_count, total)
if not should_retry_empty:
break # Salir del bucle True si no hubo RateLimitError o se manejó
tasks = [process_radicado(i, r) for i, r in enumerate(radicados, start=1)]
resultados = await asyncio.gather(*tasks)
return list(resultados)
def _process_singledoc(
self, root_path, keywords, fields, csv_output_path,
csv_header_override, instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None,
structure_mode="radicados", retry_on_empty=False, radicado_filter=None
):
if structure_mode == "general":
entries = collect_documents_flat(root_path, keywords)
else:
entries = collect_radicado_docs(root_path, keywords or [])
# Aplicar filtro de radicados si está activo
if radicado_filter:
filter_set = {r.strip() for r in radicado_filter if r.strip()}
entries = [e for e in entries if e.get("radicado") in filter_set]
logger.info(f"Filtro activo: {len(entries)} entradas seleccionadas de {len(filter_set)} indicadas")
if not entries:
logger.info("No se encontraron radicados con PDFs que coincidan.")
return []
# Primer documento para schema
first = entries[0]
prof = get_profile('invoice')
inst0 = instruction_override if instruction_override is not None else prof["default_instruction"]
schema, first_full = self.service.build_schema_from_first(first["path"], instruction=inst0, tiff_page_range=tiff_page_range)
selected_fields = fields or []
if not selected_fields:
return [{
"_needs_fields": True,
"schema": schema,
"first_full": first_full,
"radicado": first["radicado"],
"_source_pdf": first["path"],
}]
# Fallback a procesamiento secuencial/async si se llama directamente
return asyncio.run(self._process_singledoc_async(
root_path, keywords, fields, csv_output_path, csv_header_override,
instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry, structure_mode, retry_on_empty,
radicado_filter=radicado_filter,
))
async def _process_singledoc_async(
self, root_path, keywords, fields, csv_output_path,
csv_header_override, instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None,
structure_mode="radicados", retry_on_empty=False, radicado_filter=None
):
# Recolección de archivos (IO bloqueante)
if structure_mode == "general":
entries = await asyncio.to_thread(collect_documents_flat, root_path, keywords)
else:
entries = await asyncio.to_thread(collect_radicado_docs, root_path, keywords or [])
# Aplicar filtro de radicados si está activo
if radicado_filter:
filter_set = {r.strip() for r in radicado_filter if r.strip()}
entries = [e for e in entries if e.get("radicado") in filter_set]
logger.info(f"Filtro activo: {len(entries)} entradas seleccionadas de {len(filter_set)} indicadas")
if not entries:
logger.info("No se encontraron radicados con PDFs que coincidan.")
return []
selected_fields = self._expand_document_force_fields(fields or [], alias_map)
effective_alias_map = self._expand_document_force_alias_map(alias_map, selected_fields)
prompt = PromptBuilder.build(selected_fields, {})
csv_header = self._prepare_csv_header(selected_fields, effective_alias_map, csv_header_override)
prof = get_profile('invoice')
total = len(entries)
# Limitar concurrencia (Configurable)
limit = settings.processing.concurrency_limit
if limit < 1: limit = 1
sem = asyncio.Semaphore(limit)
csv_lock = asyncio.Lock()
progress_lock = asyncio.Lock()
completed_count = 0
async def process_entry(idx, entry):
nonlocal completed_count
async with sem:
attempts = 0
while True: # Bucle para permitir reintentos tras pausa
attempts += 1
should_retry_empty = False
await self._pause_event.wait()
if self._stop_requested:
return {"radicado": entry['radicado'], "status": "stopped"}
try:
logger.info(f"Procesando radicado {entry['radicado']} [{idx}/{total}]", extra={"radicado": entry['radicado'], "pdf": entry['path']})
# Async Extract - Fase: Extracción
self.event_sink.report_phase("extract", entry['radicado'])
self.event_sink.log(f"Iniciando extracción IA", level="info", radicado=entry['radicado'])
data, usage_ext = await self.service.extract_full_from_pdf_async(entry["path"], instruction=prompt, tiff_page_range=tiff_page_range)
self._report_usage(on_usage, entry['radicado'], "extract", usage_ext)
raw_data = data if isinstance(data, dict) else {"data": data}
if isinstance(data, dict):
data['radicado'] = entry['radicado']
else:
data = {"radicado": entry['radicado'], "data": data}
data["_source_pdf"] = entry["path"]
# Normalizar (Thread) - Fase: Normalización
self.event_sink.report_phase("normalize", entry['radicado'])
self.event_sink.log(f"Extracción OK — normalizando campos", level="info", radicado=entry['radicado'])
norm = await asyncio.to_thread(
normalize_to_schema,
data, paths=selected_fields, profile_name=prof.get('name', 'invoice')
)
if isinstance(norm, dict):
n_usage = norm.get("_usage")
self._report_usage(on_usage, entry['radicado'], "normalize", n_usage)
normalized_obj = norm.get("normalized")
if isinstance(normalized_obj, dict):
data = self._inject_document_force_fields_from_raw(
normalized_obj,
raw_data,
selected_fields,
)
merge_verbatim_long_strings(raw_data, data, selected_fields)
data['radicado'] = entry['radicado']
data["_source_pdf"] = entry["path"]
if retry_on_empty and self._is_effectively_empty_output(data, selected_fields, effective_alias_map):
if attempts < 2:
logger.info(f"[{entry['radicado']}] Resultado vacío; reintentando extracción ({attempts + 1}/2)", extra={"radicado": entry['radicado'], "pdf": entry['path']})
should_retry_empty = True
else:
logger.info(f"[{entry['radicado']}] Resultado vacío tras 2 intentos; se conserva el resultado.", extra={"radicado": entry['radicado'], "pdf": entry['path']})
if should_retry_empty:
continue
if csv_output_path:
async with csv_lock:
await asyncio.to_thread(
self._write_csv_singledoc,
csv_output_path, data, selected_fields, prof, csv_header, effective_alias_map, entry['radicado']
)
return data
except openai.RateLimitError as e:
if on_error_retry:
# Pausar todos los demás
self._pause_event.clear()
try:
should_retry = await on_error_retry(e)
if should_retry:
self._pause_event.set()
continue # Reintentar este radicado
else:
self._stop_requested = True
self._pause_event.set()
return self._handle_error_async(entry['radicado'], e, csv_output_path, csv_header, entry['path'])
except Exception as e_retry:
self._pause_event.set()
return self._handle_error_async(entry['radicado'], e_retry, csv_output_path, csv_header, entry['path'])
else:
return self._handle_error_async(entry['radicado'], e, csv_output_path, csv_header, entry['path'])
except Exception as e:
return self._handle_error_async(entry['radicado'], e, csv_output_path, csv_header, entry['path'])
finally:
async with progress_lock:
if not should_retry_empty:
completed_count += 1
self._notify_progress(on_progress, entry['radicado'], completed_count, total)
if not should_retry_empty:
break # Salir del bucle si no hubo RateLimitError o se manejó
tasks = [process_entry(i, e) for i, e in enumerate(entries, start=1)]
resultados = await asyncio.gather(*tasks)
return list(resultados)
# --- Helpers ---
def _expand_alias_map(self, alias_map: dict) -> dict:
"""Expande alias con wildcard `/*/' al sub-campo equivalente."""
expanded = dict(alias_map)
for orig_key, alias_value in list(alias_map.items()):
if "/*/" in orig_key:
sub = orig_key.split("/*/")[-1]
if sub and sub not in expanded:
expanded[sub] = alias_value
return expanded
def _detect_document_force_target(self, alias_map: Optional[Dict[str, str]]) -> Optional[str]:
if not alias_map:
return None
for alias_value in alias_map.values():
target = str(alias_value or "").strip()
if target.lower() in DOCUMENT_FORCE_TARGETS:
return target
return None
def _expand_document_force_fields(self, fields: List[str], alias_map: Optional[Dict[str, str]]) -> List[str]:
"""Si existe `documento_forzate`, incluye todos los tipos documentales válidos del mismo nivel."""
target = self._detect_document_force_target(alias_map)
if not target:
return list(fields or [])
allowed_types = list(get_allowed_document_types())
expanded = list(fields or [])
seen = set(expanded)
for orig_key, alias_value in (alias_map or {}).items():
if str(alias_value or "").strip().lower() not in DOCUMENT_FORCE_TARGETS:
continue
base = str(orig_key or "").strip()
if not base:
continue
if "/" in base:
prefix = base.rsplit("/", 1)[0]
candidates = [f"{prefix}/{doc_type}" for doc_type in allowed_types]
else:
candidates = allowed_types
for candidate in candidates:
if candidate not in seen:
expanded.append(candidate)
seen.add(candidate)
return expanded
def _expand_document_force_alias_map(self, alias_map: Optional[Dict[str, str]], fields: List[str]) -> Dict[str, str]:
"""Mapea todos los tipos documentales válidos al mismo alias especial."""
expanded = dict(alias_map or {})
target = self._detect_document_force_target(expanded)
if not target:
return expanded
allowed_types = list(get_allowed_document_types())
for field in fields or []:
field_str = str(field or "").strip()
if not field_str:
continue
key_tail = field_str.split("/")[-1].upper()
if key_tail not in allowed_types:
continue
expanded[field_str] = target
return expanded
def _prepare_csv_header(self, fields, alias_map, override):
if override:
return override
header_base = ['radicado'] + list(fields or [])
if not alias_map:
return header_base
alias_map2 = self._expand_alias_map(alias_map)
raw_header = [alias_map2.get(h, h) for h in header_base]
seen: set = set()
final_header = []
for h in raw_header:
if h not in seen:
final_header.append(h)
seen.add(h)
return final_header
def _is_effectively_empty_output(self, data: Dict[str, Any], fields: List[str], alias_map: Optional[Dict[str, str]]) -> bool:
"""Verdadero cuando la salida final solo conserva el radicado y no trae datos útiles."""
try:
rows, _header = rows_from_data(data, allowed_paths=fields)
final_rows = self._apply_alias_to_rows(rows, alias_map)
except Exception:
return False
if not final_rows:
return True
for row in final_rows:
if not isinstance(row, dict):
continue
for key, value in row.items():
if str(key).strip().lower() == "radicado":
continue
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
return False
return True
def _inject_document_force_fields_from_raw(self, normalized_obj: Dict[str, Any], raw_obj: Dict[str, Any], selected_fields: List[str]) -> Dict[str, Any]:
"""Inyecta campos documentales desde la extracción cruda si el normalizador los omitió."""
result = dict(normalized_obj or {})
selected_keys = {str(p or "").split("/")[-1].strip().upper() for p in (selected_fields or [])}
candidates = self._collect_document_force_candidates(raw_obj)
for dtype, value in candidates.items():
if selected_keys and dtype not in selected_keys:
continue
if dtype not in result or result.get(dtype) in (None, ""):
result[dtype] = value
return result
def _collect_document_force_candidates(self, obj: Any) -> Dict[str, Any]:
"""Recorre el JSON crudo buscando claves tipo CC/TI/EPT/PT con sus valores."""
found: Dict[str, Any] = {}
valid_keys = set(get_allowed_document_types()) | DOCUMENT_TYPE_HINT_ALIASES
def walk(node: Any) -> None:
if isinstance(node, dict):
for key, value in node.items():
key_tail = str(key or "").split("/")[-1].strip().upper()
if key_tail in valid_keys and value not in (None, ""):
found[key_tail] = value
walk(value)
elif isinstance(node, list):
for item in node:
walk(item)
walk(obj)
return found
def _notify_progress(self, callback, rad, idx, total):
if callback:
try:
callback(rad, idx, total)
except Exception:
pass
# Notificar progreso con contadores reales acumulados
try:
self._track_success = idx - self._track_errors
# Log de radicado completado exitosamente
if rad and self._track_errors < idx:
self.event_sink.log(
f"Radicado completado ({self._track_success}/{total} exitosos)",
level="success",
radicado=str(rad)
)
if idx >= total:
self.event_sink.complete_batch(
success_count=self._track_success,
error_count=self._track_errors,
total_tokens=self._track_tokens,
total_cost_usd=self._track_cost_usd,
)
else:
self.event_sink.update_progress(
current_index=idx,
success_count=self._track_success,
error_count=self._track_errors,
total_tokens=self._track_tokens,
total_cost_usd=self._track_cost_usd,
status="processing"
)
except Exception as e:
logger.debug(f"Error al notificar progreso del lote: {e}")
def _accumulate_usage(self, agg, new_usage):
accumulate_usage(agg, new_usage)
def _report_usage(self, callback, rad, phase, usage):
if not usage:
return
try:
model = None
if phase == "extract":
model = getattr(self.service.extractor, "model", None)
elif phase == "normalize":
model = settings.api.normalizer_model
cost = compute_cost_from_usage(usage, phase, model=model)
# Acumular tokens y costos para el progreso del lote
prompt_tokens = int(usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0)
self._track_tokens += prompt_tokens + completion_tokens
if isinstance(cost, dict):
self._track_cost_usd += float(cost.get("total_usd") or 0.0)
if callback:
callback(phase, rad, usage, cost)
except Exception:
pass
def _write_csv_multidoc(self, path, data, fields, doc_types, prof, header, alias_map, rad):
try:
agg_base = prof.get('aggregate_list_paths') or None
agg_paths_expanded = []
if agg_base:
for base in agg_base:
for dt in doc_types:
tname = str(dt.get("name") or "").strip() or "Tipo"
agg_paths_expanded.append(f"{tname}/{base}")
rows, computed_header = rows_from_data(
data,
aggregate_list_paths=agg_paths_expanded or None,
allowed_paths=fields,
)
final_header = header if header else computed_header
final_rows = self._apply_alias_to_rows(rows, alias_map)
self.csv_manager.append_rows(path, final_rows, final_header)
except Exception:
logger.exception(f"Error escribiendo CSV para radicado {rad}", extra={"radicado": rad})
def _write_csv_singledoc(self, path, data, fields, prof, header, alias_map, rad):
try:
rows, computed_header = rows_from_data(
data,
aggregate_list_paths=(prof.get('aggregate_list_paths') or None),
allowed_paths=fields,
)
final_header = header if header else computed_header
final_rows = self._apply_alias_to_rows(rows, alias_map)
self.csv_manager.append_rows(path, final_rows, final_header)
except Exception:
logger.exception(f"Error escribiendo CSV para radicado {rad}", extra={"radicado": rad})
def _apply_alias_to_rows(self, rows, alias_map):
if not alias_map:
return rows
alias_map2 = self._expand_alias_map(alias_map)
aliased_rows = []
for r in rows:
if isinstance(r, dict):
new_r = {}
for k, v in r.items():
new_k = alias_map2.get(k, k)
v = self._format_document_type_value(k, new_k, v)
if new_k in new_r:
# Lógica de fusión solicitada:
# 1. Si son iguales, dejar uno.
# 2. Si son diferentes, separar por coma.
prev_val = new_r[new_k]
# Normalizar a string para comparación y fusión
s_prev = str(prev_val).strip() if prev_val is not None else ""
s_curr = str(v).strip() if v is not None else ""
if s_prev == s_curr:
continue
if not s_prev:
new_r[new_k] = v
elif not s_curr:
pass
else:
new_r[new_k] = f"{s_prev}, {s_curr}"
else:
new_r[new_k] = v
aliased_rows.append(new_r)
else:
aliased_rows.append(r)
return aliased_rows
def _format_document_type_value(self, original_key, aliased_key, value):
"""Compone 'CC 123456' cuando una clave de tipo documental se exporta como 'tipo de documento'."""
if value is None:
return value
document_type_keys = set(get_allowed_document_types())
key_tail = str(original_key or "").split("/")[-1].strip().upper()
target = str(aliased_key or "").strip().lower()
if key_tail not in document_type_keys or target not in DOCUMENT_FORCE_TARGETS:
return value
text = str(value).strip()
if not text:
return value
# Si ya viene prefijado con el mismo tipo, no duplicarlo.
if text.upper().startswith(f"{key_tail} "):
return text
return f"{key_tail} {text}"
def _handle_error_async(self, rad, e, csv_path, header, pdf_path=None):
error_obj = {
"radicado": rad,
"error": str(e)
}
if pdf_path: error_obj["_source_pdf"] = pdf_path
logger.exception(f"Error procesando radicado {rad}", extra={"radicado": rad})
# Acumular error en el contador de lote
self._track_errors += 1
error_message = str(e)
try:
self.event_sink.report_error(f"[{rad}] {error_message}")
except Exception:
pass
try:
self.event_sink.log(
message=f"Error procesando radicado: {error_message}",
level="error",
radicado=str(rad)
)
except Exception:
pass
if csv_path:
header_err = header if header else ["radicado", "error"]
try:
self.csv_manager.append_rows(csv_path, [error_obj], header_err)
except Exception:
pass
return error_obj
+170
View File
@@ -0,0 +1,170 @@
"""Configuración centralizada de la aplicación.
Usa Pydantic BaseSettings para cargar valores por defecto y variables de entorno.
Se lee automáticamente un archivo `.env` en la raíz del proyecto si existe.
"""
from __future__ import annotations
from typing import Optional, Tuple
import logging
import os
# Cargar variables desde .env en el entorno (para que otros módulos que usan os.environ las vean)
try:
from dotenv import load_dotenv # type: ignore
# En desarrollo, dar precedencia al archivo .env sobre variables previas del entorno
load_dotenv(override=True)
except Exception:
pass
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import AliasChoices
class ApiSettings(BaseSettings):
"""Backends LLM compatibles con la API de OpenAI.
Extraccion y normalizacion tienen credenciales independientes para poder
mezclar proveedores, por ejemplo Grok para vision y OpenAI para normalizar.
"""
extractor_provider: str = Field("custom", validation_alias=AliasChoices("EXTRACTOR_PROVIDER"))
extractor_base_url: str = Field("", validation_alias=AliasChoices("EXTRACTOR_BASE_URL"))
extractor_api_key: str = Field("", validation_alias=AliasChoices("EXTRACTOR_API_KEY"))
extractor_model: str = Field("", validation_alias=AliasChoices("EXTRACTOR_MODEL"))
normalizer_provider: str = Field("custom", validation_alias=AliasChoices("NORMALIZER_PROVIDER"))
normalizer_base_url: str = Field("", validation_alias=AliasChoices("NORMALIZER_BASE_URL"))
normalizer_api_key: str = Field("", validation_alias=AliasChoices("NORMALIZER_API_KEY"))
normalizer_model: str = Field("", validation_alias=AliasChoices("NORMALIZER_MODEL"))
request_timeout_s: int = Field(120, validation_alias=AliasChoices("EXTRACTOR_TIMEOUT_S", "LLM_TIMEOUT_S"))
normalizer_timeout_s: int = Field(0, validation_alias=AliasChoices("NORMALIZER_TIMEOUT_S"))
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@property
def base_url(self) -> str:
"""Alias de compatibilidad para codigo anterior: backend de extraccion."""
return self.extractor_base_url
@property
def api_key(self) -> str:
"""Alias de compatibilidad para codigo anterior: API key de extraccion."""
return self.extractor_api_key
@property
def model(self) -> str:
"""Alias de compatibilidad para codigo anterior: modelo de extraccion."""
return self.extractor_model
@property
def effective_normalizer_base_url(self) -> str:
return self.normalizer_base_url or self.extractor_base_url
@property
def effective_normalizer_api_key(self) -> str:
return self.normalizer_api_key or self.extractor_api_key
@property
def effective_normalizer_timeout_s(self) -> int:
return self.normalizer_timeout_s or self.request_timeout_s
class ImagingSettings(BaseSettings):
"""Parámetros de conversión de documentos a imágenes."""
poppler_path: Optional[str] = Field(None, validation_alias=AliasChoices("POPPLER_PATH"))
pdf_dpi: int = Field(600, validation_alias=AliasChoices("PDF_DPI"))
pdf_thread_count: int = Field(10, validation_alias=AliasChoices("PDF_THREAD_COUNT"))
tiff_pages: Optional[Tuple[int, int]] = Field((2, 3), validation_alias=AliasChoices("TIFF_PAGES"))
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
class RetrySettings(BaseSettings):
"""Estrategia de reintentos para llamadas al backend."""
max_attempts: int = Field(3, validation_alias=AliasChoices("RETRY_MAX_ATTEMPTS"))
wait_min_s: float = Field(1.0, validation_alias=AliasChoices("RETRY_WAIT_MIN_S"))
wait_max_s: float = Field(8.0, validation_alias=AliasChoices("RETRY_WAIT_MAX_S"))
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
class ProcessingSettings(BaseSettings):
"""Configuración de procesamiento y concurrencia."""
concurrency_limit: int = Field(100, validation_alias=AliasChoices("CONCURRENCY_LIMIT"))
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
class AppSettings(BaseSettings):
"""Configuración raíz agrupada."""
api: ApiSettings = ApiSettings()
imaging: ImagingSettings = ImagingSettings()
retry: RetrySettings = RetrySettings()
processing: ProcessingSettings = ProcessingSettings()
csv_delimiter: str = Field("|", validation_alias=AliasChoices("CSV_DELIMITER"))
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
settings = AppSettings()
def extractor_llm_config() -> dict[str, str | int]:
"""Configuracion efectiva para llamadas de extraccion."""
api = settings.api
return {
"provider": api.extractor_provider or "custom",
"api_key": api.extractor_api_key,
"base_url": api.extractor_base_url,
"model": api.extractor_model,
"timeout_s": api.request_timeout_s,
}
def normalizer_llm_config() -> dict[str, str | int]:
"""Configuracion efectiva para llamadas de normalizacion."""
api = settings.api
return {
"provider": api.normalizer_provider or "custom",
"api_key": api.effective_normalizer_api_key,
"base_url": api.effective_normalizer_base_url,
"model": api.normalizer_model,
"timeout_s": api.effective_normalizer_timeout_s,
}
# Tipos documentales válidos por defecto para el campo especial `documento_forzate`.
# NIT se excluye explícitamente porque no debe tratarse como tipo de documento personal.
DEFAULT_ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = (
"CC",
"TI",
"CE",
"RC",
"PA",
"PPT",
"PEP",
"EPT",
"NUIP",
)
# Alias de compatibilidad.
ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = DEFAULT_ALLOWED_DOCUMENT_TYPES
def normalize_document_types(values: object) -> tuple[str, ...]:
"""Normaliza tipos documentales configurables para API/servicios."""
raw_values = values if isinstance(values, (list, tuple, set)) else []
normalized: list[str] = []
seen: set[str] = set()
for item in raw_values:
text = str(item or "").strip().upper()
if not text or text == "NIT" or text in seen:
continue
seen.add(text)
normalized.append(text)
return tuple(normalized or DEFAULT_ALLOWED_DOCUMENT_TYPES)
def get_allowed_document_types() -> tuple[str, ...]:
"""Devuelve tipos documentales válidos desde `ALLOWED_DOCUMENT_TYPES` o defaults."""
raw = os.getenv("ALLOWED_DOCUMENT_TYPES", "")
if not raw.strip():
return DEFAULT_ALLOWED_DOCUMENT_TYPES
return normalize_document_types([part.strip() for part in raw.split(",")])
+119
View File
@@ -0,0 +1,119 @@
"""Utilidades para el cálculo de costos de tokens.
Define precios por modelo y funciones para estimar costos a partir del uso de tokens
(prompt, completion, cached).
"""
from typing import Dict, Any, Optional
# Precios actualizados por 1M tokens (USD) - Enero 2026
MODEL_PRICING = {
"gpt-4.1-mini": {
"input": 0.40,
"cached_input": 0.10,
"output": 1.60,
},
"gpt-5.4": {
"input": 2.50,
"cached_input": 0.25,
"output": 15.00,
},
"gpt-5.4-mini": {
"input": 0.75,
"cached_input": 0.075,
"output": 4.50,
},
"gpt-5.4-nano": {
"input": 0.20,
"cached_input": 0.02,
"output": 0.80,
},
"grok-4.3": {
"input": 1.25,
"cached_input": 0.0,
"output": 2.50,
},
}
ZERO_PRICING = {
"input": 0.0,
"cached_input": 0.0,
"output": 0.0,
}
# Fallback por fase cuando no hay tarifa conocida para el modelo configurado.
PRICING = {
"extract": ZERO_PRICING,
"normalize": ZERO_PRICING,
}
def compute_cost_from_usage(
usage: Dict[str, Any] | None,
phase: str = "extract",
model: Optional[str] = None
) -> Dict[str, Any] | None:
"""Calcula el costo estimado basado en el uso de tokens, la fase o el modelo específico."""
if not isinstance(usage, dict):
return None
prompt_tokens = int(usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0)
cached_tokens = 0
try:
details = usage.get("prompt_tokens_details") or {}
cached_tokens = int(details.get("cached_tokens") or 0)
except Exception:
cached_tokens = 0
non_cached_prompt = max(prompt_tokens - cached_tokens, 0)
# Determinar tarifas
rates = None
if model and model in MODEL_PRICING:
rates = MODEL_PRICING[model]
else:
# Fallback a fase (backward compatibility)
rates = PRICING.get(phase, PRICING["extract"])
rate_in = rates["input"]
rate_cached = rates["cached_input"]
rate_out = rates["output"]
cost_input = (non_cached_prompt / 1_000_000.0) * rate_in
cost_cached = (cached_tokens / 1_000_000.0) * rate_cached
cost_output = (completion_tokens / 1_000_000.0) * rate_out
return {
"input_usd": round(cost_input, 6),
"cached_input_usd": round(cost_cached, 6),
"output_usd": round(cost_output, 6),
"total_usd": round(cost_input + cost_cached + cost_output, 6),
}
def accumulate_usage(agg: Dict[str, Any], new_usage: Dict[str, Any] | None) -> None:
"""Acumula tokens de `new_usage` en el dict agregado `agg` (in-place)."""
if not isinstance(new_usage, dict):
return
agg["prompt_tokens"] += int(new_usage.get("prompt_tokens") or 0)
agg["completion_tokens"] += int(new_usage.get("completion_tokens") or 0)
details = new_usage.get("prompt_tokens_details") or {}
agg["prompt_tokens_details"]["cached_tokens"] += int(details.get("cached_tokens") or 0)
def sum_costs(a: Dict[str, Any] | None, b: Dict[str, Any] | None) -> Dict[str, Any]:
"""Suma dos objetos de costos."""
def safe(x): return float(x) if x is not None else 0.0
input_usd = safe((a or {}).get("input_usd")) + safe((b or {}).get("input_usd"))
cached_usd = safe((a or {}).get("cached_input_usd")) + safe((b or {}).get("cached_input_usd"))
output_usd = safe((a or {}).get("output_usd")) + safe((b or {}).get("output_usd"))
total = {
"input_usd": round(input_usd, 6),
"cached_input_usd": round(cached_usd, 6),
"output_usd": round(output_usd, 6),
}
total["total_usd"] = round(total["input_usd"] + total["cached_input_usd"] + total["output_usd"], 6)
return total
+52
View File
@@ -0,0 +1,52 @@
"""Gestor de escritura CSV.
Maneja la escritura incremental segura (con lock) en archivos CSV.
"""
import csv
import os
import re
import threading
from typing import Any, Dict, List
_LINE_BREAK_RE = re.compile(r"[\r\n\v\f\x85\u2028\u2029]+")
def sanitize_csv_cell_value(value: Any) -> Any:
"""Reemplaza saltos de línea en valores de celda por un espacio (una fila física por registro).
Conserva ``None`` y escalares numéricos/booleanos sin tocar. Cadenas y demás
tipos se normalizan solo si la representación en texto contiene saltos.
"""
if value is None:
return value
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
if not _LINE_BREAK_RE.search(value):
return value
return _LINE_BREAK_RE.sub(" ", value).strip()
s = str(value)
if not _LINE_BREAK_RE.search(s):
return value
return _LINE_BREAK_RE.sub(" ", s).strip()
class CSVManager:
def __init__(self):
self._lock = threading.Lock()
def append_rows(self, csv_path: str, rows: List[Dict[str, Any]], header: List[str]) -> None:
"""Anexa filas a un CSV, creando encabezado si el archivo no existe."""
os.makedirs(os.path.dirname(csv_path) or ".", exist_ok=True)
file_exists = os.path.exists(csv_path) and os.path.getsize(csv_path) > 0
with self._lock:
with open(csv_path, "a", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=header, extrasaction="ignore", delimiter="|")
if not file_exists:
writer.writeheader()
for r in rows:
sanitized = {k: sanitize_csv_cell_value(v) for k, v in r.items()}
writer.writerow(sanitized)
+128
View File
@@ -0,0 +1,128 @@
"""Estimador de costos y tokens.
Encapsula la lógica de estimación previa al procesamiento.
"""
import os
from typing import List, Dict, Any, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from domain.service import InvoiceProcessingService
from domain.profiles import get_profile
from domain.prompts import PromptBuilder
from domain.normalizer import normalize_to_schema
from domain.costs import compute_cost_from_usage, sum_costs, accumulate_usage
from domain.config import settings
from domain.file_discovery import get_sorted_radicados, find_file_for_type_in_radicados, get_files_as_radicados
from core.utils import collect_radicado_docs, collect_documents_flat
class Estimator:
def __init__(self, service: 'InvoiceProcessingService'):
self.service = service
def estimate(
self,
root_path: str,
keywords: List[str] | None = None,
schema: Dict[str, str] | None = None,
selected_fields: List[str] | None = None,
*,
doc_types: Optional[List[Dict[str, str]]] = None,
schema_by_type: Optional[Dict[str, Dict[str, str]]] = None,
selected_paths: Optional[List[str]] = None,
structure_mode: str = "radicados",
) -> Dict[str, Any]:
"""Calcula estimado de tokens y costos para toda la carpeta basado en el primer documento."""
if doc_types:
return self._estimate_multidoc(root_path, doc_types, schema_by_type, selected_paths, structure_mode)
return self._estimate_singledoc(root_path, keywords, schema, selected_fields, structure_mode)
def _estimate_multidoc(self, root_path, doc_types, schema_by_type, selected_paths, structure_mode="radicados"):
if structure_mode == "general":
radicados = get_files_as_radicados(root_path)
else:
radicados = get_sorted_radicados(root_path)
if not radicados:
return {"total_docs": 0, "per_doc": {}, "estimated_total": {}}
prof = get_profile('invoice')
first_radicado_found = None
agg_extract_usage = {"prompt_tokens": 0, "completion_tokens": 0, "prompt_tokens_details": {"cached_tokens": 0}}
agg_normalize_usage = {"prompt_tokens": 0, "completion_tokens": 0, "prompt_tokens_details": {"cached_tokens": 0}}
for dt in doc_types:
tname = str(dt.get("name") or "").strip() or "Tipo"
kw = str(dt.get("keyword") or "").strip()
match, rad_used = find_file_for_type_in_radicados(root_path, radicados, kw)
if not match: continue
if first_radicado_found is None:
first_radicado_found = rad_used
sel_all = selected_paths or []
selected_paths_for_type = [p.split('/', 1)[1] for p in sel_all if p.startswith(f"{tname}/")]
sch_t = (schema_by_type or {}).get(tname) or {}
try:
prompt_t = PromptBuilder.build_for_type(tname, selected_paths_for_type, sch_t, profile_name=prof.get('name', 'invoice'))
except Exception:
prompt_t = PromptBuilder.build(selected_paths_for_type, sch_t, profile_name=prof.get('name', 'invoice'))
data, usage_ext = self.service.extract_full_from_pdf(match, instruction=prompt_t)
self._accumulate_usage(agg_extract_usage, usage_ext)
if isinstance(data, dict): data['radicado'] = rad_used
else: data = {"radicado": rad_used, "data": data}
norm = normalize_to_schema(data, paths=selected_paths_for_type, profile_name=prof.get('name', 'invoice'))
if isinstance(norm, dict):
self._accumulate_usage(agg_normalize_usage, norm.get("_usage"))
return self._build_result(agg_extract_usage, agg_normalize_usage, len(radicados), first_radicado_found or radicados[0])
def _estimate_singledoc(self, root_path, keywords, schema, selected_fields, structure_mode="radicados"):
if structure_mode == "general":
entries = collect_documents_flat(root_path, keywords)
else:
entries = collect_radicado_docs(root_path, keywords or [])
if not entries:
return {"total_docs": 0, "per_doc": {}, "estimated_total": {}}
first = entries[0]
prof = get_profile('invoice')
prompt = PromptBuilder.build(selected_fields or [], schema or {})
data, usage_ext = self.service.extract_full_from_pdf(first["path"], instruction=prompt)
if isinstance(data, dict): data['radicado'] = first['radicado']
else: data = {"radicado": first['radicado'], "data": data}
norm = normalize_to_schema(data, paths=selected_fields, profile_name=prof.get('name', 'invoice'))
usage_norm = None
if isinstance(norm, dict):
usage_norm = norm.get("_usage")
return self._build_result(usage_ext, usage_norm, len(entries), first["radicado"])
def _accumulate_usage(self, agg, new_usage):
accumulate_usage(agg, new_usage)
def _build_result(self, usage_ext, usage_norm, total_docs, first_radicado):
model_ext = getattr(self.service.extractor, "model", None)
cost_ext = compute_cost_from_usage(usage_ext, "extract", model=model_ext)
cost_norm = compute_cost_from_usage(usage_norm, "normalize", model=settings.api.normalizer_model)
per_doc_cost = sum_costs(cost_ext, cost_norm)
estimated_total = {k: round(v * total_docs, 6) for k, v in per_doc_cost.items()}
return {
"total_docs": total_docs,
"first_radicado": first_radicado,
"extractor_usage": usage_ext,
"normalizer_usage": usage_norm,
"per_doc_cost": per_doc_cost,
"estimated_total_cost": estimated_total,
}
@@ -0,0 +1,64 @@
"""Lógica de descubrimiento de archivos y radicados.
Separa la lógica de recorrer el sistema de archivos del servicio principal.
"""
import os
from typing import List, Optional, Tuple
from core.utils import find_first_matching_file, ALLOWED_DOC_EXTS
def get_sorted_radicados(root_path: str) -> List[str]:
"""Devuelve una lista ordenada de nombres de subdirectorios (radicados) en root_path."""
if not os.path.isdir(root_path):
return []
return sorted([d for d in os.listdir(root_path) if os.path.isdir(os.path.join(root_path, d))])
def get_files_as_radicados(root_path: str) -> List[str]:
"""Devuelve una lista de nombres de archivos (sin extensión) que actúan como radicados."""
if not os.path.isdir(root_path):
return []
files = sorted([f for f in os.listdir(root_path) if f.lower().endswith(ALLOWED_DOC_EXTS)])
# Retornamos el nombre completo del archivo para que el procesador pueda encontrarlo,
# O retornamos el stem y luego reconstruimos?
# El procesador espera "radicado" que luego usa para os.path.join(root, rad).
# Si retornamos el nombre de archivo, os.path.join funcionará para encontrar el archivo.
# Pero el "radicado" visual debe ser sin extensión.
# Vamos a retornar el nombre de archivo completo aquí, y el procesador deberá manejarlo.
return files
def find_file_for_type_in_radicados(
root_path: str,
radicados: List[str],
keyword: str
) -> Tuple[Optional[str], Optional[str]]:
"""Busca el primer archivo que coincida con keyword en la lista de radicados.
Retorna: (ruta_completa_del_archivo, nombre_del_radicado)
"""
for rad in radicados:
rad_path = os.path.join(root_path, rad)
# Si rad es un archivo (modo General), verificamos si coincide
if os.path.isfile(rad_path):
# En modo general, 'rad' es el nombre del archivo.
# Si hay keyword, verificamos si el archivo coincide con el keyword.
# Pero esta función se usa para "Multi-Doc Type", donde buscamos UN archivo para UN tipo.
# Si estamos en modo general, el archivo ES el documento.
# Simplemente retornamos el archivo si coincide con el keyword (o si no hay keyword).
from core.utils import _matches_pattern
if not keyword or _matches_pattern(rad, keyword):
return rad_path, os.path.splitext(rad)[0]
continue
try:
files = sorted(os.listdir(rad_path))
except Exception:
continue
match_name = find_first_matching_file(files, keyword) if keyword else None
if match_name:
return os.path.join(rad_path, match_name), rad
return None, None
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
import time
import logging
from openai import OpenAI, AsyncOpenAI
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from .config import extractor_llm_config, settings
class LLMClient:
"""Cliente unificado para llamadas Chat Completions (SDK OpenAI).
- Centraliza api_key, base_url, modelo y timeout desde configuracion.
- Expone helpers para respuestas de texto y JSON.
- Registra métricas mínimas en `domain.api_calls` (start/end/error + usage).
- Aplica reintentos ante timeouts.
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
default_model: Optional[str] = None,
request_timeout_s: Optional[float] = None,
) -> None:
default_config = extractor_llm_config()
self.api_key = api_key if api_key is not None else str(default_config["api_key"])
self.base_url = base_url if base_url is not None else str(default_config["base_url"])
self.default_model = default_model if default_model is not None else str(default_config["model"])
self.request_timeout_s = request_timeout_s or float(default_config["timeout_s"])
self._client = self._build_sync_client()
self._async_client = self._build_async_client()
self._api_logger = logging.getLogger("domain.api_calls")
def _build_sync_client(self) -> OpenAI:
kwargs: Dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
kwargs["base_url"] = self.base_url
return OpenAI(**kwargs)
def _build_async_client(self) -> AsyncOpenAI:
kwargs: Dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
kwargs["base_url"] = self.base_url
return AsyncOpenAI(**kwargs)
def update_config(
self,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
default_model: Optional[str] = None,
request_timeout_s: Optional[float] = None,
) -> None:
if api_key is not None:
self.api_key = api_key
if base_url is not None:
self.base_url = base_url
if default_model is not None:
self.default_model = default_model
if request_timeout_s is not None:
self.request_timeout_s = request_timeout_s
self._client = self._build_sync_client()
self._async_client = self._build_async_client()
def list_models(self) -> List[str]:
"""Lista modelos publicados por el backend compatible con OpenAI."""
models = self._client.models.list()
return sorted(
str(getattr(model, "id", ""))
for model in getattr(models, "data", [])
if getattr(model, "id", None)
)
@retry(
stop=stop_after_attempt(settings.retry.max_attempts),
wait=wait_exponential(multiplier=settings.retry.wait_min_s, max=settings.retry.wait_max_s),
retry=retry_if_exception_type((TimeoutError,)),
reraise=True,
)
def _create_completion(self, *, messages: List[Dict[str, Any]], model: str, timeout_s: float, **extra) -> Any:
return self._client.chat.completions.create(
messages=messages,
model=model,
timeout=timeout_s,
**extra,
)
@retry(
stop=stop_after_attempt(settings.retry.max_attempts),
wait=wait_exponential(multiplier=settings.retry.wait_min_s, max=settings.retry.wait_max_s),
retry=retry_if_exception_type((TimeoutError,)),
reraise=True,
)
async def _create_completion_async(self, *, messages: List[Dict[str, Any]], model: str, timeout_s: float, **extra) -> Any:
return await self._async_client.chat.completions.create(
messages=messages,
model=model,
timeout=timeout_s,
**extra,
)
def _log_start(self, *, component: str, model: str, timeout_s: float, messages_count: int) -> None:
try:
self._api_logger.info(
"api_call_start",
extra={
"component": component,
"base_url": self.base_url,
"model": model,
"timeout_s": timeout_s,
"messages": messages_count,
},
)
except Exception:
pass
def _log_end(self, *, component: str, model: str, timeout_s: float, elapsed_ms: int, usage: Optional[Dict[str, Any]]) -> None:
try:
self._api_logger.info(
"api_call_end",
extra={
"component": component,
"base_url": self.base_url,
"model": model,
"timeout_s": timeout_s,
"elapsed_ms": elapsed_ms,
"prompt_tokens": (usage or {}).get("prompt_tokens"),
"completion_tokens": (usage or {}).get("completion_tokens"),
"total_tokens": (usage or {}).get("total_tokens"),
},
)
except Exception:
pass
def _log_error(self, *, component: str, model: str, timeout_s: float, elapsed_ms: Optional[int], error: Exception) -> None:
try:
self._api_logger.info(
"api_call_error",
extra={
"component": component,
"base_url": self.base_url,
"model": model,
"timeout_s": timeout_s,
"elapsed_ms": (elapsed_ms if elapsed_ms is not None else None),
"error": str(error),
},
)
except Exception:
pass
@staticmethod
def _extract_usage(obj: Any) -> Optional[Dict[str, Any]]:
try:
usage = getattr(obj, "usage", None)
if usage is None:
return None
usage_dict: Dict[str, Any] = {
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
}
details = getattr(usage, "prompt_tokens_details", None)
if details is not None:
try:
usage_dict["prompt_tokens_details"] = dict(details) # type: ignore[arg-type]
except Exception:
try:
usage_dict["prompt_tokens_details"] = {
"cached_tokens": getattr(details, "cached_tokens", None),
"audio_tokens": getattr(details, "audio_tokens", None),
}
except Exception:
pass
return usage_dict
except Exception:
return None
def chat_text(
self,
messages: List[Dict[str, Any]],
*,
model: Optional[str] = None,
timeout_s: Optional[float] = None,
component: str = "llm_client",
) -> Tuple[str, Optional[Dict[str, Any]]]:
"""Devuelve (texto, usage)."""
eff_model = model or self.default_model
eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s)
self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or []))
start = time.time()
try:
chat_completion = self._create_completion(
messages=messages,
model=eff_model,
timeout_s=eff_timeout,
)
elapsed_ms = int((time.time() - start) * 1000)
usage = self._extract_usage(chat_completion)
self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage)
text: str = chat_completion.choices[0].message.content # type: ignore[assignment]
return text, usage
except Exception as ex:
elapsed_ms = int((time.time() - start) * 1000)
self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex)
raise
async def chat_text_async(
self,
messages: List[Dict[str, Any]],
*,
model: Optional[str] = None,
timeout_s: Optional[float] = None,
component: str = "llm_client",
) -> Tuple[str, Optional[Dict[str, Any]]]:
"""Devuelve (texto, usage) de forma asíncrona."""
eff_model = model or self.default_model
eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s)
self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or []))
start = time.time()
try:
chat_completion = await self._create_completion_async(
messages=messages,
model=eff_model,
timeout_s=eff_timeout,
)
elapsed_ms = int((time.time() - start) * 1000)
usage = self._extract_usage(chat_completion)
self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage)
text: str = chat_completion.choices[0].message.content # type: ignore[assignment]
return text, usage
except Exception as ex:
elapsed_ms = int((time.time() - start) * 1000)
self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex)
raise
def chat_json(
self,
messages: List[Dict[str, Any]],
*,
model: Optional[str] = None,
response_format: str = "json_object",
timeout_s: Optional[float] = None,
component: str = "llm_client",
max_completion_tokens: Optional[int] = None,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
"""Devuelve (objeto JSON parseado, usage)."""
import json
eff_model = model or self.default_model
eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s)
self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or []))
start = time.time()
try:
completion_kwargs: Dict[str, Any] = {
"response_format": {"type": response_format},
}
if max_completion_tokens is not None:
completion_kwargs["max_completion_tokens"] = max_completion_tokens
chat_completion = self._create_completion(
messages=messages,
model=eff_model,
timeout_s=eff_timeout,
**completion_kwargs,
)
elapsed_ms = int((time.time() - start) * 1000)
usage = self._extract_usage(chat_completion)
self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage)
content = chat_completion.choices[0].message.content # type: ignore[assignment]
obj = json.loads(content)
return obj, usage
except Exception as ex:
elapsed_ms = int((time.time() - start) * 1000)
self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex)
raise
async def chat_json_async(
self,
messages: List[Dict[str, Any]],
*,
model: Optional[str] = None,
response_format: str = "json_object",
timeout_s: Optional[float] = None,
component: str = "llm_client",
max_completion_tokens: Optional[int] = None,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
"""Devuelve (objeto JSON parseado, usage) de forma asíncrona."""
import json
eff_model = model or self.default_model
eff_timeout = float(timeout_s if timeout_s is not None else self.request_timeout_s)
self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or []))
start = time.time()
try:
completion_kwargs: Dict[str, Any] = {
"response_format": {"type": response_format},
}
if max_completion_tokens is not None:
completion_kwargs["max_completion_tokens"] = max_completion_tokens
chat_completion = await self._create_completion_async(
messages=messages,
model=eff_model,
timeout_s=eff_timeout,
**completion_kwargs,
)
elapsed_ms = int((time.time() - start) * 1000)
usage = self._extract_usage(chat_completion)
self._log_end(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, usage=usage)
content = chat_completion.choices[0].message.content # type: ignore[assignment]
obj = json.loads(content)
return obj, usage
except Exception as ex:
elapsed_ms = int((time.time() - start) * 1000)
self._log_error(component=component, model=eff_model, timeout_s=eff_timeout, elapsed_ms=elapsed_ms, error=ex)
raise
@@ -0,0 +1,182 @@
"""Configuración de logging en formato JSON y loggers dedicados.
Crea handlers rotativos para:
- logs de aplicación (`logs/ips_extractor.log`),
- logs de normalización LLM (`logs/llm.log`),
- logs de llamadas a APIs (`logs/api_calls.log`),
además de un stream a consola para WARNING+.
"""
from __future__ import annotations
import json
import logging
import logging.handlers
import os
import time
from datetime import datetime
from functools import wraps
from typing import Callable, Any
import coloredlogs
class JsonFormatter(logging.Formatter):
"""Formatter que serializa registros a JSON con extras."""
def format(self, record: logging.LogRecord) -> str:
base = {
"ts": datetime.utcfromtimestamp(record.created).isoformat() + "Z",
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
# incluir extras no estándar
extras = {}
for k, v in record.__dict__.items():
if k in (
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
"created", "msecs", "relativeCreated", "thread", "threadName", "processName",
"process", "message", "asctime",
):
continue
# evitar objetos no serializables
try:
json.dumps(v)
extras[k] = v
except Exception:
extras[k] = str(v)
if extras:
base.update(extras)
return json.dumps(base, ensure_ascii=False)
_configured = False
def log_execution_time(logger: logging.Logger | None = None, level: int = logging.DEBUG) -> Callable:
"""Decorador para registrar el tiempo de ejecución de una función."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
# Si no se pasa logger, intentar obtenerlo del primer argumento (self) si es una clase
# o usar el logger del módulo de la función.
_logger = logger
if _logger is None:
if args and hasattr(args[0], '__class__'):
# Intenta buscar un logger en la instancia
_logger = getattr(args[0], 'logger', None)
if _logger is None:
_logger = logging.getLogger(func.__module__)
start_time = time.time()
try:
result = func(*args, **kwargs)
return result
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
if _logger:
_logger.exception(
f"Error en ejecución de {func.__name__}",
extra={"duration_ms": duration_ms, "function": func.__name__, "error": str(e)}
)
raise
finally:
duration_ms = (time.time() - start_time) * 1000
if _logger:
_logger.log(
level,
f"Ejecución de {func.__name__} completada",
extra={"duration_ms": duration_ms, "function": func.__name__}
)
return wrapper
return decorator
def configure_logging(level: int = logging.INFO) -> None:
"""Configura logging global de forma idempotente.
- Crea archivos rotativos en `logs/` con formato JSON.
- Configura salida a consola con colores y formato legible.
- Define loggers dedicados para normalización LLM y llamadas a APIs.
"""
global _configured
if _configured:
return
os.makedirs("logs", exist_ok=True)
root = logging.getLogger()
# idempotencia: limpiar handlers existentes solo si fueron nuestros json
# si ya hay handlers, no duplicar
if not root.handlers:
root.setLevel(level)
# Handler de archivo principal (JSON)
app_handler = logging.handlers.RotatingFileHandler(
filename=os.path.join("logs", "ips_extractor.log"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
app_handler.setFormatter(JsonFormatter())
app_handler.setLevel(logging.INFO)
root.addHandler(app_handler)
# Handler de consola (Colores y legible para humanos)
console = logging.StreamHandler()
console.setLevel(level) # Usar el nivel configurado (INFO por defecto)
# Formato de consola: Tiempo | Nivel | Logger | Mensaje
console_fmt = "%(asctime)s %(levelname)-8s %(name)s %(message)s"
console_formatter = coloredlogs.ColoredFormatter(
fmt=console_fmt,
datefmt='%H:%M:%S',
level_styles={
'debug': {'color': 'black', 'bright': True},
'info': {'color': 'green'},
'warning': {'color': 'yellow'},
'error': {'color': 'red'},
'critical': {'color': 'red', 'bold': True},
},
field_styles={
'asctime': {'color': 'cyan'},
'levelname': {'bold': True},
'name': {'color': 'blue'},
}
)
console.setFormatter(console_formatter)
root.addHandler(console)
# Logger dedicado para normalizador (LLM)
llm_logger = logging.getLogger("domain.normalizer")
# evitar duplicados si ya está configurado
has_file = any(isinstance(h, logging.handlers.RotatingFileHandler) for h in llm_logger.handlers)
if not has_file:
llm_handler = logging.handlers.RotatingFileHandler(
filename=os.path.join("logs", "llm.log"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
llm_handler.setFormatter(JsonFormatter())
llm_handler.setLevel(logging.INFO)
llm_logger.addHandler(llm_handler)
llm_logger.setLevel(logging.INFO)
llm_logger.propagate = False # No propagar al root para no duplicar en consola/main log si no se desea
# Logger dedicado para llamadas a APIs (peticiones y tokens)
api_calls_logger = logging.getLogger("domain.api_calls")
has_api_file = any(isinstance(h, logging.handlers.RotatingFileHandler) for h in api_calls_logger.handlers)
if not has_api_file:
api_handler = logging.handlers.RotatingFileHandler(
filename=os.path.join("logs", "api_calls.log"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
api_handler.setFormatter(JsonFormatter())
api_handler.setLevel(logging.INFO)
api_calls_logger.addHandler(api_handler)
api_calls_logger.setLevel(logging.INFO)
api_calls_logger.propagate = False
_configured = True
+198
View File
@@ -0,0 +1,198 @@
"""Normalizador LLM: adapta un JSON fuente al esquema canónico de rutas.
Usa un backend compatible con Chat Completions para transformar el JSON devuelto
por el extractor a un objeto que respete exactamente las rutas/campos canónicos
seleccionados por el usuario (incluyendo listas items/*).
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional
from .config import normalizer_llm_config, settings
from .prompts import PromptBuilder
from .llm_client import LLMClient
logger = logging.getLogger('domain.normalizer')
def _build_normalizer_client() -> tuple[LLMClient, dict[str, Any]]:
config = normalizer_llm_config()
client = LLMClient(
api_key=str(config["api_key"]),
base_url=str(config["base_url"]),
default_model=str(config["model"]),
request_timeout_s=float(config["timeout_s"]),
)
return client, config
def _ensure_dict_at_path(root: Dict[str, Any], key: str) -> Dict[str, Any]:
val = root.get(key)
if not isinstance(val, dict):
val = {}
root[key] = val
return val
def _ensure_list_with_object(root: Dict[str, Any], key: str) -> Dict[str, Any]:
val = root.get(key)
if not isinstance(val, list) or not val or not isinstance(val[0], dict):
root[key] = [{}]
return root[key][0]
return val[0]
def build_canonical_template(paths: List[str]) -> Dict[str, Any]:
"""Convierte rutas 'a/b/*/c' en un objeto anidado con listas y nulls.
- Siempre incluye 'radicado' en la raíz.
- Para 'x/*/y' crea 'x': [{ 'y': null }]. Si hay más subniveles, anida en ese objeto.
"""
template: Dict[str, Any] = {"radicado": None}
for raw_path in paths or []:
if not raw_path:
continue
segments = [s for s in raw_path.split('/') if s]
if not segments:
continue
i = 0
cur: Dict[str, Any] = template
while i < len(segments):
name = segments[i]
is_last = (i == len(segments) - 1)
# próximo segmento indica lista
if i + 1 < len(segments) and segments[i + 1] == '*':
# asegurar lista con un objeto
cur = _ensure_list_with_object(cur, name)
i += 2 # saltar el '*'
# si terminamos después del '*', no hay campo que asignar aquí
if i >= len(segments):
break
# continuar con el objeto dentro de la lista
continue
# segmento normal
if is_last:
cur[name] = None
else:
cur = _ensure_dict_at_path(cur, name)
i += 1
return template
def normalize_to_schema(
data: Dict[str, Any],
paths: List[str],
profile_name: str,
) -> Optional[Dict[str, Any]]:
"""
Usa un LLM para mapear un JSON extraído a un esquema canónico definido por rutas.
Este proceso asegura que, independientemente de cómo el extractor haya devuelto los datos,
el resultado final tenga la estructura jerárquica esperada (ej. 'cabecera/fecha', 'items/*/descripcion').
Args:
data: El diccionario con los datos extraídos del documento.
paths: Lista de rutas canónicas (ej. ["cliente/nombre", "totales/iva"]).
profile_name: Nombre del perfil de extracción (ej. 'invoice') para cargar instrucciones.
Returns:
Un diccionario con la clave "normalized" conteniendo el objeto transformado
y "_usage" con las estadísticas de tokens. Retorna None si falla.
"""
try:
t0 = time.perf_counter()
template = build_canonical_template(paths)
# Construcción centralizada de mensajes
messages = PromptBuilder.build_normalization_messages(profile_name, template, data)
llm_client, normalizer_config = _build_normalizer_client()
eff_model = str(normalizer_config["model"])
eff_base_url = str(normalizer_config["base_url"])
eff_timeout = float(normalizer_config["timeout_s"])
# log inicio
try:
logger.info(
"llm_normalize_start",
extra={
"model": eff_model,
"base_url": eff_base_url,
"timeout_s": eff_timeout,
"paths_count": len(paths or []),
"template_fields": list(template.keys()),
"radicado": (data.get("radicado") if isinstance(data, dict) else None),
},
)
except Exception:
pass
# Llamada centralizada vía LLMClient (JSON estricto)
try:
normalized_obj, usage = llm_client.chat_json(
messages=messages, model=eff_model,
response_format="json_object",
timeout_s=float(eff_timeout),
component="normalizer",
max_completion_tokens=16384,
)
except Exception:
normalized_obj = None
usage = None
if not isinstance(normalized_obj, dict):
try:
dt_ms = int((time.perf_counter() - t0) * 1000)
logger.info(
"llm_normalize_end",
extra={
"normalized": False,
"duration_ms": dt_ms,
"model": eff_model,
"base_url": eff_base_url,
"radicado": (data.get("radicado") if isinstance(data, dict) else None),
},
)
except Exception:
pass
return None
# asegurar radicado en raíz si existía en data
rad = None
try:
rad = data.get("radicado")
except Exception:
rad = None
if rad is not None and isinstance(normalized_obj, dict):
normalized_obj["radicado"] = rad
try:
dt_ms = int((time.perf_counter() - t0) * 1000)
logger.info(
"llm_normalize_end",
extra={
"normalized": True,
"duration_ms": dt_ms,
"model": eff_model,
"base_url": eff_base_url,
"radicado": rad,
},
)
except Exception:
pass
# Devolver solo el objeto normalizado (compatibilidad) cuando lo llame alguien antiguo
return {"normalized": normalized_obj, "_usage": usage}
except Exception as ex:
try:
dt_ms = int((time.perf_counter() - t0) * 1000) if 't0' in locals() else None
logger.info(
"llm_normalize_end",
extra={
"normalized": False,
"error": str(ex),
"duration_ms": dt_ms,
"model": (eff_model if 'eff_model' in locals() else settings.api.normalizer_model),
"base_url": (eff_base_url if 'eff_base_url' in locals() else settings.api.effective_normalizer_base_url),
"radicado": (data.get("radicado") if isinstance(data, dict) else None),
},
)
except Exception:
pass
return None
+38
View File
@@ -0,0 +1,38 @@
"""Perfiles de extracción.
Define configuraciones por tipo de documento para guiar prompts, envoltura
de resultados y agregaciones especiales en CSV.
"""
from __future__ import annotations
from typing import Dict, List, Optional, TypedDict
class Profile(TypedDict, total=False):
name: str
wrap_root: Optional[str]
default_instruction: str
aggregate_list_paths: List[str]
DEFAULT_INVOICE_INSTRUCTION = (
"Extrae los datos de la factura a un JSON, identificando la tabla principal por sus columnas (código, cantidad, valor unitario) "
"para crear un array con la clave fija 'items', y usando las etiquetas literales del documento como claves para el resto de la información. "
"Si el documento muestra una línea o bloque de texto bajo una etiqueta como «Observación:», «Observaciones» o equivalente, "
"inclúyelo en el JSON con la misma clave que refleje el documento (respeta mayúsculas, tildes y singular/plural tal como aparezcan). "
"Ten especial cuidado en no confundir el número de autorización con el código."
)
PROFILES: Dict[str, Profile] = {
"invoice": {
"name": "invoice",
"wrap_root": None,
"default_instruction": DEFAULT_INVOICE_INSTRUCTION,
"aggregate_list_paths": [],
},
}
def get_profile(name: str) -> Profile:
key = (name or "invoice").lower()
return PROFILES.get(key, PROFILES["invoice"]) # por defecto factura
+116
View File
@@ -0,0 +1,116 @@
"""Builder para prompts de extracción.
Centraliza la construcción de instrucciones reducidas en base a:
- selección de campos (paths de schema)
- schema de referencia (ruta->tipo)
- perfil (invoice/prenuevaeps)
"""
from __future__ import annotations
import json
from typing import Dict, List, Any
from .profiles import get_profile
from .config import get_allowed_document_types
def _build_instructions() -> List[str]:
tipos_documento = ", ".join(get_allowed_document_types())
return [
"Extrae únicamente los campos solicitados respetando jerarquía y listas",
"Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional",
"No inventes claves nuevas ni cambies nombres existentes",
"Si un campo no existe, usa null o [] según corresponda",
"Ten especial cuidado en no confundir el número de autorización con el código",
f"Los únicos tipos de documento válidos para 'documento_forzate' son: {tipos_documento}. NIT no es válido para ese campo.",
"Si entre los valores visibles aparece uno o varios tipos de documento válidos seguidos por su número (por ejemplo: 'CC 123456789' o 'EPT 123456789'), aunque no estén escritos con formato 'clave: valor', y existe el campo solicitado 'documento_forzate', guarda cada valor completo con su sigla exacta.",
"Respeta la sigla exacta del documento encontrado. Ejemplo: si el documento dice 'EPT', devuelve 'EPT 123456789' y no lo conviertas a 'PT'.",
"Solo si una ruta de observación, nota o texto aclaratorio figura explícitamente en 'campos_requeridos' (con el nombre exacto indicado), "
"extrae el texto completo visible bajo su etiqueta en el documento (p. ej. línea «Observación:» …); no añadas ese campo si no está listado en 'campos_requeridos'.",
]
_REGLAS_NOMENCLATURA = [
"Usa EXACTAMENTE los nombres listados en 'campos_requeridos'",
"No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)",
"Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}",
"No cambies nombres de claves por 'equivalentes'",
"Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado",
]
_NOTA_LISTAS = "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}"
class PromptBuilder:
@staticmethod
def _base_payload(
profile_name: str,
fields: List[str],
schema: Dict[str, str],
extra_meta: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
profile = get_profile(profile_name or "invoice")
meta: Dict[str, Any] = {
"perfil": profile.get("name", "invoice"),
"descripcion": "Extracción estructurada desde OCR de facturas (sin datos personales reales)",
}
if extra_meta:
meta.update(extra_meta)
return {
"meta": meta,
"instrucciones": _build_instructions(),
"campos_requeridos": fields,
"schema_referencia": {k: schema[k] for k in fields if k in schema},
"reglas_estrictas_nomenclatura": _REGLAS_NOMENCLATURA,
"salida": {"tipo": "json_object", "raiz": None},
"nota_listas": _NOTA_LISTAS,
}
@staticmethod
def build(selected_fields: List[str], schema: Dict[str, str], profile_name: str | None = None) -> str:
payload = PromptBuilder._base_payload(profile_name or "invoice", selected_fields, schema)
return json.dumps(payload, indent=2, ensure_ascii=False)
@staticmethod
def build_normalization_messages(profile_name: str, template: Dict[str, Any], source_json: Dict[str, Any]) -> List[Dict[str, str]]:
system_txt = (
"Eres un asistente que normaliza un JSON a un ESQUEMA CANÓNICO dado. "
"Debes devolver EXCLUSIVAMENTE un JSON que respete exactamente la estructura y nombres del 'template'. "
"Reglas: (1) No inventes claves; (2) No uses sinónimos ni variantes; (3) Respeta listas (si 'items' es lista, devuelve un array de objetos); "
"(4) Si falta un valor, usa null o [] según corresponda; (5) No incluyas texto extra ni comentarios. "
"(6) Los campos de texto largo (notas, observaciones, leyendas, pie de página, etc.): copia el texto COMPLETO "
"del source_json sin resumir, sin omitir tramos ni acortar; respeta tildes y caracteres tal cual en el valor de origen."
)
user_obj = {
"perfil": profile_name,
"template": template,
"source_json": source_json,
"instrucciones": (
"Mapea los valores del 'source_json' al 'template'. Si hay claves equivalentes en el source, "
"elige siempre los nombres del template. No devuelvas rutas con '/', sino objetos anidados. "
"Para cualquier cadena larga del documento (p. ej. notas legales o bloques de observación), "
"transcribe el contenido literal completo; no uses elipsis ni versiones abreviadas."
),
}
return [
{"role": "system", "content": system_txt},
{"role": "user", "content": json.dumps(user_obj, ensure_ascii=False)},
]
@staticmethod
def build_for_type(
doc_type_name: str,
selected_paths_for_type: List[str],
schema_for_type: Dict[str, str],
profile_name: str | None = None,
) -> str:
"""Construye un prompt reducido para un Tipo de Documento específico.
doc_type_name se usa en meta para trazabilidad; no afecta el esquema.
"""
payload = PromptBuilder._base_payload(
profile_name or "invoice",
selected_paths_for_type,
schema_for_type,
extra_meta={"tipo_documento": doc_type_name},
)
return json.dumps(payload, indent=2, ensure_ascii=False)
+383
View File
@@ -0,0 +1,383 @@
"""Servicios de alto nivel para el procesamiento de facturas.
Orquesta la extracción usando una `InvoiceExtractorStrategy`, la conversión de
documentos (PDF/TIFF) a imágenes base64 y el parseo del bloque JSON.
Incluye un flujo por lotes con selección de campos y exportación incremental a CSV.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple, Callable, Awaitable
import json
import logging
import os
import time
import asyncio
from .strategies import InvoiceExtractorStrategy
from .profiles import get_profile
from .config import settings
from .logging_config import log_execution_time
from .file_discovery import get_sorted_radicados, find_file_for_type_in_radicados, get_files_as_radicados
from .batch_processor import BatchProcessor
from .estimator import Estimator
from core.pdf_utils import document_to_images_bs64
from core.text_utils import extract_json_from_text, build_schema
from core.utils import find_first_matching_file
logger = logging.getLogger(__name__)
class InvoiceProcessingService:
"""Fachada de negocio para extraer y transformar datos de facturas."""
def __init__(
self,
extractor: InvoiceExtractorStrategy,
batch_processor_factory: Callable[[InvoiceProcessingService], BatchProcessor],
estimator_factory: Callable[[InvoiceProcessingService], Estimator]
) -> None:
"""
Inicializa el servicio de procesamiento de facturas.
Args:
extractor: Estrategia para extraer datos (ej. GPT-4o, Claude).
batch_processor_factory: Fábrica para crear el procesador por lotes,
evitando dependencias circulares.
estimator_factory: Fábrica para crear el estimador de costos y tokens.
"""
self.extractor = extractor
# Componentes delegados inyectados mediante factories para evitar dependencia circular directa en construcción
self.batch_processor = batch_processor_factory(self)
self.estimator = estimator_factory(self)
# Lock para escrituras concurrentes al mismo CSV desde múltiples hilos
# (Mantenido por retrocompatibilidad si se accede directo, aunque BatchProcessor usa su propio CSVManager)
try:
import threading
self._csv_lock = threading.Lock()
except Exception:
self._csv_lock = None
def _convert_to_images(self, pdf_path: str, dpi: int, thread_count: int, tiff_pages: Optional[Tuple[int, int]], tiff_page_range: Optional[Tuple[int, int]]) -> List[str]:
"""
Convierte un documento (PDF o TIFF) a una lista de imágenes codificadas en base64.
Args:
pdf_path: Ruta al archivo.
dpi: Resolución de la conversión.
thread_count: Número de hilos para procesamiento paralelo (solo PDF).
tiff_pages: Páginas por defecto para TIFF si no se especifica rango.
tiff_page_range: Rango específico de páginas (inicio, fin) para TIFF.
Returns:
Lista de strings con las imágenes en base64 (formato PNG).
"""
filename = os.path.basename(pdf_path)
tiff_pages_cfg = getattr(settings.imaging, 'tiff_pages', (2, 3)) if tiff_pages is None else tiff_pages
poppler = getattr(settings.imaging, 'poppler_path', None)
try:
return document_to_images_bs64(
pdf_path, dpi=dpi, thread_count=thread_count,
tiff_pages=tiff_pages_cfg, tiff_page_range=tiff_page_range,
poppler_path=poppler,
)
except Exception as e:
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
raise
def _apply_extractor_fallback(self, pdf_path: str) -> List[str]:
"""
Intento de recuperación ante errores de extracción usando parámetros reducidos.
Carga imágenes con DPI fijo (400) y limita a las primeras 5 páginas para
reducir el consumo de tokens y evitar límites de contexto.
Args:
pdf_path: Ruta al archivo.
Returns:
Lista de hasta 5 imágenes en base64.
"""
fb_dpi = 400
tiff_pages_fb = getattr(settings.imaging, 'tiff_pages', (2, 3))
poppler = getattr(settings.imaging, 'poppler_path', None)
images = document_to_images_bs64(pdf_path, dpi=fb_dpi, tiff_pages=tiff_pages_fb, poppler_path=poppler)
return images[:5] if len(images) > 5 else images
def _parse_model_output(self, model_output: dict, pdf_path: str, dpi_cfg: int) -> Tuple[Any, Any]:
"""
Extrae y valida el bloque JSON de la respuesta del modelo de lenguaje.
Args:
model_output: Diccionario con la respuesta cruda ("raw") y el uso de tokens ("usage").
pdf_path: Ruta del archivo original (para logging).
dpi_cfg: DPI usado (para logging).
Returns:
Tupla (datos_dict, usage_dict) con el JSON parseado y estadísticas de uso.
Raises:
ValueError: Si no se encuentra un JSON válido en la respuesta.
"""
filename = os.path.basename(pdf_path)
raw_text = model_output.get("raw", "")
usage = model_output.get("usage")
json_text = extract_json_from_text(raw_text)
if not json_text:
logger.error(f"No se pudo extraer JSON válido de la respuesta para {filename}", extra={"pdf": pdf_path})
raise ValueError(f"No se pudo extraer un bloque JSON del texto de respuesta para {filename}.")
data = json.loads(json_text)
try:
if isinstance(data, dict):
top_keys = list(data.keys())
preview = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
logger.debug(f"Datos extraídos de {filename} (keys={len(top_keys)}: {preview})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
else:
logger.debug(f"Datos extraídos de {filename} (tipo={type(data).__name__})", extra={"pdf": pdf_path, "dpi": dpi_cfg})
except Exception:
pass
return data, usage
@log_execution_time(logger=logger)
def extract_full_from_pdf(
self,
pdf_path: str,
instruction: Optional[str] = None,
*,
tiff_page_range: Optional[Tuple[int, int]] = None,
history: Optional[List[Dict[str, Any]]] = None,
) -> Tuple[Any, Any]:
"""
Extrae un JSON completo desde un documento (PDF/TIFF) usando el extractor.
El proceso incluye:
1. Conversión del documento a imágenes base64.
2. Llamada al modelo de extracción.
3. En caso de error, reintento automático con parámetros de recuperación (DPI reducido).
4. Parseo del resultado JSON.
Args:
pdf_path: Ruta al archivo PDF o TIFF.
instruction: Instrucción personalizada para el modelo (opcional).
tiff_page_range: Rango de páginas específico si es un TIFF.
history: Historial de mensajes para el modelo (opcional).
Returns:
Tupla (datos_extraidos, estadisticas_uso).
"""
filename = os.path.basename(pdf_path)
logger.info(f"Iniciando extracción de documento: {filename}", extra={"pdf": pdf_path})
t0 = time.time()
dpi_cfg = settings.imaging.pdf_dpi
thr_cfg = settings.imaging.pdf_thread_count
tiff_pages = getattr(settings.imaging, 'tiff_pages', (2, 3))
profile = get_profile('invoice')
text_instruction = instruction if instruction is not None else profile["default_instruction"]
images_b64 = self._convert_to_images(pdf_path, dpi_cfg, thr_cfg, tiff_pages, tiff_page_range)
logger.debug(
f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg})",
extra={"pdf": pdf_path, "dpi": dpi_cfg},
)
try:
model_output = self.extractor.extract(images_b64, text_instruction, history=history)
except Exception as e:
logger.warning(f"Fallo en extracción. Reintentando con DPI reducido: {filename}", extra={"pdf": pdf_path, "error": str(e)})
images_b64_fb = self._apply_extractor_fallback(pdf_path)
model_output = self.extractor.extract(images_b64_fb, text_instruction, history=history)
data, usage = self._parse_model_output(model_output, pdf_path, dpi_cfg)
logger.info(f"Extracción finalizada: {filename} (total_ms={int((time.time()-t0)*1000)})", extra={"pdf": pdf_path})
return data, usage
@log_execution_time(logger=logger)
async def extract_full_from_pdf_async(
self,
pdf_path: str,
instruction: Optional[str] = None,
*,
tiff_page_range: Optional[Tuple[int, int]] = None,
history: Optional[List[Dict[str, Any]]] = None,
) -> Tuple[Any, Any]:
"""
Versión asíncrona de extract_full_from_pdf.
Realiza la conversión de imágenes en un hilo separado para no bloquear el loop de eventos.
"""
filename = os.path.basename(pdf_path)
logger.info(f"Iniciando extracción asíncrona de documento: {filename}", extra={"pdf": pdf_path})
t0 = time.time()
dpi_cfg = settings.imaging.pdf_dpi
thr_cfg = settings.imaging.pdf_thread_count
tiff_pages = getattr(settings.imaging, 'tiff_pages', (2, 3))
profile = get_profile('invoice')
text_instruction = instruction if instruction is not None else profile["default_instruction"]
try:
images_b64 = await asyncio.to_thread(
self._convert_to_images, pdf_path, dpi_cfg, thr_cfg, tiff_pages, tiff_page_range,
)
except Exception as e:
logger.error(f"Error al convertir documento a imágenes: {filename}", extra={"pdf": pdf_path, "error": str(e)})
raise
logger.debug(
f"Imágenes procesadas para {filename} (count={len(images_b64)}, dpi={dpi_cfg}, threads={thr_cfg})",
extra={"pdf": pdf_path, "dpi": dpi_cfg},
)
try:
model_output = await self.extractor.extract_async(images_b64, text_instruction, history=history)
except Exception as e:
logger.warning(f"Fallo en extracción. Reintentando con DPI reducido: {filename}", extra={"pdf": pdf_path, "error": str(e)})
images_b64_fb = await asyncio.to_thread(self._apply_extractor_fallback, pdf_path)
model_output = await self.extractor.extract_async(images_b64_fb, text_instruction, history=history)
data, usage = self._parse_model_output(model_output, pdf_path, dpi_cfg)
logger.info(f"Extracción finalizada: {filename} (total_ms={int((time.time()-t0)*1000)})", extra={"pdf": pdf_path})
return data, usage
def build_schema_from_first(
self,
pdf_path: str,
instruction: Optional[str] = None,
*,
tiff_page_range: Optional[Tuple[int, int]] = None,
) -> Tuple[Dict[str, str], Dict[str, Any]]:
"""
Genera un esquema de datos (rutas de campos) basándose en el primer documento procesado.
Útil para permitir al usuario seleccionar qué campos desea exportar a CSV.
Args:
pdf_path: Ruta al documento de muestra.
instruction: Instrucción para el modelo.
tiff_page_range: Rango de páginas si es TIFF.
Returns:
Tupla (esquema_dict, datos_ejemplo_dict).
"""
data, _usage = self.extract_full_from_pdf(pdf_path, instruction=instruction, tiff_page_range=tiff_page_range)
schema = build_schema(data)
try:
logger.debug(f"Schema generado para {os.path.basename(pdf_path)} (total_paths={len(schema)})", extra={"pdf": pdf_path})
except Exception:
pass
return schema, data
def build_schema_from_first_for_types(
self,
root_path: str,
doc_types: List[Dict[str, str]],
instruction: Optional[str] = None,
*,
tiff_page_range: Optional[Tuple[int, int]] = None,
structure_mode: str = "radicados",
) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, Any]], str | None]:
"""
Genera esquemas para múltiples tipos de documentos buscando un ejemplo de cada uno.
Args:
root_path: Directorio raíz de los documentos.
doc_types: Lista de tipos con sus palabras clave de búsqueda.
instruction: Instrucción para el modelo.
tiff_page_range: Rango de páginas si es TIFF.
structure_mode: 'radicados' o 'general'.
Returns:
Tupla (esquemas_por_tipo, muestras_por_tipo, primer_radicado_encontrado).
"""
if structure_mode == "general":
radicados = get_files_as_radicados(root_path)
else:
radicados = get_sorted_radicados(root_path)
if not radicados:
raise ValueError(f"No se encontraron documentos/radicados en: {root_path}")
schema_by_type: Dict[str, Dict[str, str]] = {}
first_full_by_type: Dict[str, Dict[str, Any]] = {}
prof = get_profile('invoice')
inst0 = instruction if instruction is not None else prof["default_instruction"]
first_radicado_found = None
for dt in (doc_types or []):
tname = str(dt.get("name") or "").strip() or "Tipo"
kw = str(dt.get("keyword") or "").strip()
pdf_path, rad_used = find_file_for_type_in_radicados(root_path, radicados, kw)
if not pdf_path:
logger.info(f"No se encontró archivo para tipo '{tname}' con keyword '{kw}' en ningún radicado")
schema_by_type[tname] = {}
first_full_by_type[tname] = {}
continue
if first_radicado_found is None:
first_radicado_found = rad_used
try:
logger.info(
f"Usando archivo para schema por tipo '{tname}': {os.path.basename(pdf_path)} (Radicado: {rad_used})",
extra={"tipo": tname, "radicado": rad_used, "pdf": pdf_path},
)
sch, dat = self.build_schema_from_first(pdf_path, instruction=inst0, tiff_page_range=tiff_page_range)
schema_by_type[tname] = sch or {}
first_full_by_type[tname] = dat or {}
except Exception as e:
logger.exception(f"Error generando schema para tipo '{tname}'", extra={"tipo": tname, "radicado": rad_used})
schema_by_type[tname] = {}
first_full_by_type[tname] = {"_error": str(e)}
return schema_by_type, first_full_by_type, (first_radicado_found or radicados[0])
# --- Delegación a BatchProcessor y Estimator ---
@log_execution_time(logger=logger)
def process_root_with_field_selection(
self,
root_path: str,
keywords: List[str] | None = None,
*,
doc_types: Optional[List[Dict[str, str]]] = None,
fields: Optional[List[str]] = None,
on_progress: Optional[callable] = None,
csv_output_path: Optional[str] = None,
csv_header_override: Optional[List[str]] = None,
instruction_override: Optional[str] = None,
on_usage: Optional[Callable[[str, str, Dict[str, Any] | None, Dict[str, Any] | None], None]] = None,
on_error_retry: Optional[Callable[[Exception], Awaitable[bool]]] = None,
alias_map: Optional[Dict[str, str]] = None,
tiff_page_range: Optional[Tuple[int, int]] = None,
structure_mode: str = "radicados",
retry_on_empty: bool = False,
radicado_filter: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Procesa radicados con selección de campos y CSV incremental. (Delegado a BatchProcessor)"""
return self.batch_processor.process_root(
root_path, keywords,
doc_types=doc_types, fields=fields, on_progress=on_progress,
csv_output_path=csv_output_path, csv_header_override=csv_header_override,
instruction_override=instruction_override, on_usage=on_usage,
on_error_retry=on_error_retry,
alias_map=alias_map, tiff_page_range=tiff_page_range,
structure_mode=structure_mode,
retry_on_empty=retry_on_empty,
radicado_filter=radicado_filter,
)
def estimate_tokens_and_cost(
self,
root_path: str,
keywords: List[str] | None = None,
schema: Dict[str, str] | None = None,
selected_fields: List[str] | None = None,
*,
doc_types: Optional[List[Dict[str, str]]] = None,
schema_by_type: Optional[Dict[str, Dict[str, str]]] = None,
selected_paths: Optional[List[str]] = None,
structure_mode: str = "radicados",
) -> Dict[str, Any]:
"""Calcula estimado de tokens y costos. (Delegado a Estimator)"""
return self.estimator.estimate(
root_path, keywords, schema, selected_fields,
doc_types=doc_types, schema_by_type=schema_by_type, selected_paths=selected_paths,
structure_mode=structure_mode
)
+131
View File
@@ -0,0 +1,131 @@
"""Estrategias de extracción de información desde imágenes de documentos.
Define la interfaz `InvoiceExtractorStrategy` y una implementación basada en
un endpoint compatible con la API de OpenAI para modelos multimodales.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional
from .config import extractor_llm_config
from .llm_client import LLMClient
import logging
import time
class InvoiceExtractorStrategy(ABC):
"""Interfaz para extraer datos a partir de páginas en base64 e instrucción."""
@abstractmethod
def extract(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""Devuelve un dict con al menos la clave "raw" (texto de salida del modelo)."""
raise NotImplementedError
async def extract_async(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""Versión asíncrona de extract. Por defecto lanza NotImplementedError."""
raise NotImplementedError
_LOG_EXTRA_BASE = {"dpi": '-', "pdf": '-', "radicado": '-'}
class OpenAICompatibleExtractor(InvoiceExtractorStrategy):
"""Extractor contra un backend compatible con Chat Completions.
Parámetros configurables: `api_key`, `base_url` y `model`.
"""
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, model: Optional[str] = None, llm_client: Optional[LLMClient] = None) -> None:
config = extractor_llm_config()
self.api_key = api_key if api_key is not None else str(config["api_key"])
self.base_url = base_url if base_url is not None else str(config["base_url"])
self.model = model if model is not None else str(config["model"])
self.request_timeout_s = float(config["timeout_s"])
self.llm = llm_client or LLMClient(
api_key=self.api_key,
base_url=self.base_url,
default_model=self.model,
request_timeout_s=self.request_timeout_s,
)
self._logger = logging.getLogger(__name__)
def _build_messages(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
content: List[Dict[str, Any]] = [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img}"}}
for img in images_b64
]
content.append({"type": "text", "text": instruction})
messages = list(history) if history else []
messages.append({"role": "user", "content": content})
return messages
def _log_call_start(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]]) -> None:
prompt_len = len(instruction) if isinstance(instruction, str) else 0
self._logger.info(
"backend_call_start",
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
"timeout_s": self.request_timeout_s, "images": len(images_b64),
"prompt_len": prompt_len, "history_len": len(history) if history else 0},
)
self._logger.debug(
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
extra=_LOG_EXTRA_BASE,
)
def _log_call_ok(self, elapsed: float) -> None:
self._logger.info(
"Extracción completada",
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
"timeout_s": self.request_timeout_s, "elapsed_ms": int(elapsed * 1000)},
)
self._logger.debug(f"Backend OK (elapsed_ms={int(elapsed*1000)})", extra=_LOG_EXTRA_BASE)
def _log_call_error(self, elapsed: float) -> None:
self._logger.exception(
"Error en llamada al backend",
extra={**_LOG_EXTRA_BASE, "base_url": self.base_url, "model": self.model,
"timeout_s": self.request_timeout_s, "elapsed_ms": int(elapsed * 1000)},
)
self._logger.debug(f"Backend error tras {int(elapsed*1000)} ms", extra=_LOG_EXTRA_BASE)
def extract(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""Construye el prompt multimodal y devuelve el texto crudo del modelo."""
messages = self._build_messages(images_b64, instruction, history)
self._log_call_start(images_b64, instruction, history)
start = time.time()
try:
text, usage_dict = self.llm.chat_text(
messages=messages, model=self.model,
timeout_s=self.request_timeout_s, component="extractor",
)
self._log_call_ok(time.time() - start)
return {"raw": text, "usage": usage_dict}
except Exception:
self._log_call_error(time.time() - start)
raise
async def extract_async(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""Versión asíncrona de extract."""
messages = self._build_messages(images_b64, instruction, history)
self._log_call_start(images_b64, instruction, history)
start = time.time()
try:
text, usage_dict = await self.llm.chat_text_async(
messages=messages, model=self.model,
timeout_s=self.request_timeout_s, component="extractor",
)
self._log_call_ok(time.time() - start)
return {"raw": text, "usage": usage_dict}
except Exception:
self._log_call_error(time.time() - start)
raise
def update_config(self, *, api_key: str | None = None, base_url: str | None = None, model: str | None = None) -> None:
"""Actualiza la configuración del cliente y recrea la instancia interna."""
if api_key is not None:
self.api_key = api_key
if base_url is not None:
self.base_url = base_url
if model is not None:
self.model = model
# actualizar cliente LLM con config actual
self.llm.update_config(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=self.request_timeout_s)
@@ -0,0 +1,127 @@
"""Persistencia del servicio sobre la base de datos de integracion."""
from __future__ import annotations
import os
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any, Iterator
from sqlalchemy import (
JSON,
DateTime,
ForeignKey,
Integer,
String,
Text,
create_engine,
select,
text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor",
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def new_id() -> str:
return str(uuid.uuid4())
class ExtractionBatch(Base):
__tablename__ = "extraction_batches"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
root_path: Mapped[str | None] = mapped_column(Text, nullable=True)
request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class ExtractionRow(Base):
__tablename__ = "extraction_rows"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
radicado: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
source_pdf: Mapped[str | None] = mapped_column(Text, nullable=True)
row_index: Mapped[int] = mapped_column(Integer, default=0)
row_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class CleaningJob(Base):
__tablename__ = "cleaning_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
extraction_batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
log: Mapped[list[str]] = mapped_column(JSON, default=list)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class CleanedRow(Base):
__tablename__ = "cleaned_rows"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
cleaning_job_id: Mapped[str] = mapped_column(ForeignKey("cleaning_jobs.id"), index=True)
extraction_batch_id: Mapped[str] = mapped_column(ForeignKey("extraction_batches.id"), index=True)
row_index: Mapped[int] = mapped_column(Integer, default=0)
row_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
def init_db() -> None:
if DATABASE_URL.startswith("postgresql"):
with engine.begin() as connection:
connection.execute(text("SELECT pg_advisory_lock(74512001)"))
try:
Base.metadata.create_all(bind=connection)
finally:
connection.execute(text("SELECT pg_advisory_unlock(74512001)"))
return
Base.metadata.create_all(bind=engine)
@contextmanager
def session_scope() -> Iterator[Session]:
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def get_extraction_rows(session: Session, batch_id: str) -> list[ExtractionRow]:
return list(
session.scalars(
select(ExtractionRow)
.where(ExtractionRow.batch_id == batch_id)
.order_by(ExtractionRow.row_index, ExtractionRow.id)
)
)
@@ -0,0 +1,40 @@
"""Helpers para serializar resultados extraidos como filas de base de datos."""
from __future__ import annotations
from typing import Any
from core.csv_utils import rows_from_data
def expand_alias_map(alias_map: dict[str, str]) -> dict[str, str]:
expanded = dict(alias_map or {})
for original, alias in list(expanded.items()):
if "/*/" in original:
tail = original.split("/*/", 1)[1]
if tail:
expanded.setdefault(tail, alias)
return expanded
def flatten_extraction_result(
data: dict[str, Any],
fields: list[str] | None,
alias_map: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
rows, _header = rows_from_data(data, allowed_paths=fields)
if not alias_map:
return rows
aliases = expand_alias_map(alias_map)
aliased: list[dict[str, Any]] = []
for row in rows:
new_row: dict[str, Any] = {}
for key, value in row.items():
new_key = aliases.get(key, key)
if new_key in new_row and new_row[new_key] not in (None, ""):
if value not in (None, "") and str(value) != str(new_row[new_key]):
new_row[new_key] = f"{new_row[new_key]}, {value}"
else:
new_row[new_key] = value
aliased.append(new_row)
return aliased
+12
View File
@@ -0,0 +1,12 @@
coloredlogs==15.0.1
fastapi==0.115.6
openai==2.1.0
pdf2image==1.17.0
pillow==11.3.0
psycopg[binary]==3.2.3
pydantic==2.11.9
pydantic-settings==2.11.0
python-dotenv==1.1.1
SQLAlchemy==2.0.36
tenacity==9.1.2
uvicorn[standard]==0.32.1