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:
@@ -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"]
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Servicio REST de extraccion."""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# core package
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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(",")])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user