Files
IPSExtractor/services/extraction/api/desktop_ingest.py
T

327 lines
9.8 KiB
Python

"""Procesamiento de ingestas individuales desde la app desktop."""
from __future__ import annotations
import logging
from typing import Any
from domain.normalizer import normalize_to_schema
from domain.profiles import get_profile
from domain.prompts import PromptBuilder
from domain.service import InvoiceProcessingService
from infrastructure.database import ExtractionRow, Task, utcnow
from infrastructure.serialization import flatten_extraction_result
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _paths_for_type(fields: list[str], doc_type: str | None) -> list[str]:
if not doc_type:
return list(fields or [])
prefix = f"{doc_type}/"
return [path.split("/", 1)[1] for path in fields if path.startswith(prefix)]
def _expected_doc_types_for_radicado(request: dict[str, Any], radicado: str) -> set[str]:
expected: set[str] = set()
for item in request.get("documents") or []:
if str(item.get("radicado") or "") != radicado:
continue
doc_type = str(item.get("doc_type") or item.get("docType") or "").strip()
if doc_type:
expected.add(doc_type)
return expected
def _finalize_radicado_if_ready(
session: Session,
*,
task: Task,
batch_id: str,
radicado: str,
request: dict[str, Any],
fields: list[str],
alias_map: dict[str, str],
) -> bool:
partials = dict((task.result or {}).get("partials") or {})
radicado_partials = dict(partials.get(radicado) or {})
expected = _expected_doc_types_for_radicado(request, radicado)
if expected and not expected.issubset(set(radicado_partials.keys())):
return False
fused: dict[str, Any] = {"radicado": radicado, **radicado_partials}
source_pdf = None
for item in request.get("documents") or []:
if str(item.get("radicado") or "") == radicado:
source_pdf = item.get("filename")
break
if source_pdf:
fused["_source_pdf"] = source_pdf
row_index = int((task.result or {}).get("row_count") or 0)
for row in flatten_extraction_result(fused, fields, alias_map):
row_index += 1
session.add(
ExtractionRow(
batch_id=batch_id,
radicado=radicado,
source_pdf=source_pdf,
row_index=row_index,
row_json=row,
raw_json=fused,
)
)
partials.pop(radicado, None)
result = dict(task.result or {})
result["partials"] = partials
result["row_count"] = row_index
result["processed"] = int(result.get("processed") or 0) + 1
task.result = result
task.updated_at = utcnow()
return True
def _service_for_model(base_service: InvoiceProcessingService, model: str | None) -> InvoiceProcessingService:
"""Devuelve un servicio con el modelo solicitado; reutiliza base_service si coincide."""
if not model:
return base_service
current_model = getattr(base_service.extractor, "model", None)
if current_model == model:
return base_service
from domain.strategies import OpenAICompatibleExtractor
from domain.batch_processor import BatchProcessor
from domain.estimator import Estimator
extractor = OpenAICompatibleExtractor(model=model)
return InvoiceProcessingService(
extractor,
batch_processor_factory=lambda s: BatchProcessor(s),
estimator_factory=lambda s: Estimator(s),
)
def persist_desktop_ingest_result(
session: Session,
*,
task: Task,
batch_id: str,
radicado: str,
filename: str,
doc_type: str | None,
request: dict[str, Any],
normalized_obj: Any,
) -> dict[str, Any]:
"""Persiste una extracción desktop y contabiliza una vez por radicado.
Cuando una tarea separa los campos por tipo documental, cada documento se
guarda primero en ``partials``. El radicado se materializa y ``processed`` se
incrementa únicamente cuando llegaron todos sus tipos esperados.
"""
fields = list(request.get("fields") or [])
alias_map = dict(request.get("alias_map") or {})
structure_mode = str(request.get("structure_mode") or "radicados")
doc_types = list(request.get("doc_types") or [])
result = dict(task.result or {})
failed_radicados = {str(value) for value in result.get("failed_radicados") or []}
# Otro documento del mismo radicado puede terminar después de que uno de sus
# hermanos haya fallado. No debe recrear parciales ni contabilizarlo otra vez.
if radicado in failed_radicados:
return {
"radicado": radicado,
"filename": filename,
"doc_type": doc_type,
"finalized": False,
"skipped": True,
"reason": "radicado_failed",
"processed": int(result.get("processed") or 0),
"total": int(result.get("total") or 0),
}
if structure_mode == "radicados" and doc_types and doc_type:
partials = dict(result.get("partials") or {})
radicado_partials = dict(partials.get(radicado) or {})
radicado_partials[doc_type] = normalized_obj or {}
partials[radicado] = radicado_partials
result["partials"] = partials
task.result = result
session.flush()
finalized = _finalize_radicado_if_ready(
session,
task=task,
batch_id=batch_id,
radicado=radicado,
request=request,
fields=fields,
alias_map=alias_map,
)
return {
"radicado": radicado,
"filename": filename,
"doc_type": doc_type,
"finalized": finalized,
"processed": int((task.result or {}).get("processed") or 0),
"total": int((task.result or {}).get("total") or 0),
}
fused = {"radicado": radicado, "_source_pdf": filename}
if doc_type:
fused[doc_type] = normalized_obj or {}
else:
fused.update(normalized_obj if isinstance(normalized_obj, dict) else {"data": normalized_obj})
row_index = int(result.get("row_count") or 0)
for row in flatten_extraction_result(fused, fields, alias_map):
row_index += 1
session.add(
ExtractionRow(
batch_id=batch_id,
radicado=radicado,
source_pdf=filename,
row_index=row_index,
row_json=row,
raw_json=fused,
)
)
result["row_count"] = row_index
result["processed"] = int(result.get("processed") or 0) + 1
task.result = result
task.updated_at = utcnow()
session.flush()
return {
"radicado": radicado,
"filename": filename,
"doc_type": doc_type,
"finalized": True,
"processed": int(result.get("processed") or 0),
"total": int(result.get("total") or 0),
}
def record_desktop_ingest_error(
session: Session,
*,
task: Task,
radicado: str,
filename: str,
doc_type: str | None,
error: Exception,
) -> dict[str, Any]:
"""Registra un fallo y da por procesado el radicado exactamente una vez."""
result = dict(task.result or {})
errors = list(result.get("errors") or [])
errors.append({
"radicado": radicado,
"filename": filename,
"doc_type": doc_type,
"error": str(error),
})
result["errors"] = errors
failed_radicados = {str(value) for value in result.get("failed_radicados") or []}
first_failure = radicado not in failed_radicados
if first_failure:
failed_radicados.add(radicado)
result["processed"] = int(result.get("processed") or 0) + 1
# Un radicado fallido ya no puede completarse con los parciales restantes.
partials = dict(result.get("partials") or {})
partials.pop(radicado, None)
result["partials"] = partials
result["failed_radicados"] = sorted(failed_radicados)
task.result = result
task.updated_at = utcnow()
session.flush()
return {
"radicado": radicado,
"filename": filename,
"doc_type": doc_type,
"error": str(error),
"first_failure": first_failure,
"processed": int(result.get("processed") or 0),
"total": int(result.get("total") or 0),
}
def process_desktop_ingest(
service: InvoiceProcessingService,
session: Session,
*,
task: Task,
batch_id: str,
temp_path: str,
radicado: str,
filename: str,
doc_type: str | None,
instruction: str | None,
tiff_page_range: tuple[int, int] | None,
model: str | None = None,
) -> dict[str, Any]:
service = _service_for_model(service, model)
request = dict(task.request or {})
fields = list(request.get("fields") or [])
structure_mode = str(request.get("structure_mode") or "radicados")
doc_types = list(request.get("doc_types") or [])
prof = get_profile("invoice")
selected_paths = _paths_for_type(fields, doc_type if structure_mode == "radicados" and doc_types else None)
if doc_type and structure_mode == "radicados" and doc_types:
try:
prompt = PromptBuilder.build_for_type(doc_type, selected_paths, {}, profile_name=prof.get("name", "invoice"))
except Exception:
prompt = PromptBuilder.build(selected_paths, {}, profile_name=prof.get("name", "invoice"))
else:
prompt = instruction or PromptBuilder.build(selected_paths, {}, profile_name=prof.get("name", "invoice"))
data, _usage = service.extract_full_from_pdf(
temp_path,
instruction=prompt,
tiff_page_range=tiff_page_range,
)
if isinstance(data, dict):
data["radicado"] = radicado
else:
data = {"radicado": radicado, "data": data}
norm = normalize_to_schema(
data,
paths=selected_paths,
profile_name=prof.get("name", "invoice"),
)
normalized_obj = data
if isinstance(norm, dict):
normalized_obj = norm.get("normalized") or data
return persist_desktop_ingest_result(
session,
task=task,
batch_id=batch_id,
radicado=radicado,
filename=filename,
doc_type=doc_type,
request=request,
normalized_obj=normalized_obj,
)
def mark_task_completed_if_done(session: Session, task: Task, batch_id: str) -> None:
result = dict(task.result or {})
processed = int(result.get("processed") or 0)
total = int(result.get("total") or 0)
partials = dict(result.get("partials") or {})
if processed < total or partials:
return
from infrastructure.database import ExtractionBatch
task.status = "completed"
task.finished_at = utcnow()
task.updated_at = utcnow()
batch = session.get(ExtractionBatch, batch_id)
if batch is not None:
batch.status = "completed"
batch.updated_at = utcnow()