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

1526 lines
50 KiB
Python

"""API REST del microservicio de extraccion."""
from __future__ import annotations
import os
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
try:
from arq import create_pool as _arq_create_pool
from arq.connections import RedisSettings as _RedisSettings
_ARQ_AVAILABLE = True
except ImportError:
_arq_create_pool = None # type: ignore[assignment]
_RedisSettings = None # type: ignore[assignment]
_ARQ_AVAILABLE = False
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Response, UploadFile
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import delete, select
from api.desktop_ingest import (
mark_task_completed_if_done,
persist_desktop_ingest_result,
record_desktop_ingest_error,
)
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.openai_balance import fetch_openai_balance
from domain.service import InvoiceProcessingService
from domain.strategies import OpenAICompatibleExtractor
from infrastructure.database import (
CleanedRow,
CleaningJob,
CountJobFile,
ExtractionBatch,
ExtractionRow,
Job,
Task,
init_db,
session_scope,
utcnow,
)
from infrastructure.serialization import flatten_extraction_result
from infrastructure.uploads import temp_upload_path
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()
def _redis_settings_from_url(url: str):
url = url.removeprefix("redis://")
host_port, *db_parts = url.split("/")
host, *port_parts = host_port.split(":")
port = int(port_parts[0]) if port_parts else 6379
db = int(db_parts[0]) if db_parts else 0
return _RedisSettings(host=host, port=port, database=db)
# Pool ARQ global — compartido por todos los requests
_arq_pool = None
async def get_arq_pool():
global _arq_pool
if not _ARQ_AVAILABLE:
raise RuntimeError("arq no instalado; ASYNC_QUEUE requiere 'pip install arq'.")
if _arq_pool is None:
_arq_pool = await _arq_create_pool(_redis_settings_from_url(settings.redis_url))
return _arq_pool
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
if settings.processing.async_queue:
os.makedirs(settings.queue_dir, exist_ok=True)
try:
await get_arq_pool()
except Exception as exc:
import logging as _logging
_logging.getLogger(__name__).warning(
"No se pudo conectar a Redis (%s). Usando modo sincrono como fallback.", exc
)
yield
if _arq_pool is not None:
await _arq_pool.aclose()
app = FastAPI(
title="Flujo IPS 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 DesktopDocument(BaseModel):
radicado: str
filename: str
doc_type: str | None = None
class ExtractionRequest(BaseModel):
source: str = "legacy"
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
documents: list[DesktopDocument] | None = None
document_count: int | None = None
job_id: str | None = None # vincular al job existente
model: str | None = None
@model_validator(mode="after")
def validate_source(self) -> "ExtractionRequest":
if self.source == "desktop":
if not self.documents:
raise ValueError("Envia documents cuando source=desktop.")
return self
if not self.root_path:
raise ValueError("Envia root_path para extraccion legacy.")
return self
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
class TaskRead(BaseModel):
id: str
job_id: str | None = None
type: str
status: str
title: str | None
request: dict[str, Any]
result: dict[str, Any]
error: str | None
owner_id: str | None
created_at: str
updated_at: str
started_at: str | None
finished_at: str | None
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(),
}
def serialize_task(task: Task) -> dict[str, Any]:
return {
"id": task.id,
"job_id": task.job_id,
"type": task.type,
"status": task.status,
"title": task.title,
"request": task.request,
"result": task.result,
"error": task.error,
"owner_id": task.owner_id,
"created_at": task.created_at.isoformat(),
"updated_at": task.updated_at.isoformat(),
"started_at": task.started_at.isoformat() if task.started_at else None,
"finished_at": task.finished_at.isoformat() if task.finished_at else None,
}
def persist_extraction_request(request: ExtractionRequest) -> dict[str, Any]:
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()
serialized = serialize_batch(batch)
serialized["row_count"] = row_index
return serialized
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
def run_extraction_task(task_id: str) -> None:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
return
task.status = "running"
task.started_at = utcnow()
task.updated_at = utcnow()
request_payload = dict(task.request or {})
try:
request = ExtractionRequest.model_validate(request_payload)
batch = persist_extraction_request(request)
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
return
task.status = "completed"
task.result = {
"extraction_batch_id": batch["id"],
"row_count": batch.get("row_count", 0),
"batch": batch,
}
task.error = None
task.finished_at = utcnow()
task.updated_at = utcnow()
except Exception as exc:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
return
task.status = "failed"
task.error = str(exc)
task.finished_at = utcnow()
task.updated_at = utcnow()
@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.get("/openai/balance")
def openai_balance() -> dict[str, Any]:
"""Crédito disponible en la cuenta OpenAI (o proveedor compatible)."""
return fetch_openai_balance()
@app.post("/schemas")
def build_schema(request: SchemaRequest) -> dict[str, Any]:
"""Descubre campos disponibles para que el cliente API elija `fields`."""
try:
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,
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(
status_code=400,
detail="Envia document_path o root_path con doc_types para descubrir campos.",
)
@app.post("/schemas/upload")
def build_schema_upload(
file: UploadFile = File(...),
structure_mode: str = Form("radicados"),
doc_type_name: str | None = Form(None),
instruction: str | None = Form(None),
tiff_start: int | None = Form(None),
tiff_end: int | None = Form(None),
) -> dict[str, Any]:
"""Descubre campos a partir de un documento subido desde la app desktop."""
tiff_page_range = None
if tiff_start is not None and tiff_end is not None and not (tiff_start == 0 and tiff_end == 0):
tiff_page_range = (tiff_start, tiff_end)
try:
with temp_upload_path(file) as temp_path:
schema, sample = processing_service.build_schema_from_first(
temp_path,
instruction=instruction,
tiff_page_range=tiff_page_range,
)
if doc_type_name:
return {
"schema_by_type": {doc_type_name: schema},
"sample_by_type": {doc_type_name: sample},
"structure_mode": structure_mode,
}
return {"schema": schema, "sample": sample, "structure_mode": structure_mode}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/estimate")
def estimate(request: EstimateRequest) -> dict[str, Any]:
try:
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,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/estimate/upload")
def estimate_upload(
file: UploadFile = File(...),
doc_count: int = Form(1),
selected_paths: str | None = Form(None),
structure_mode: str = Form("radicados"),
tiff_start: int | None = Form(None),
tiff_end: int | None = Form(None),
model: str | None = Form(None),
) -> dict[str, Any]:
"""Estima tokens y costos a partir de un documento subido desde la app desktop."""
import json as _json
tiff_page_range = None
if tiff_start is not None and tiff_end is not None and not (tiff_start == 0 and tiff_end == 0):
tiff_page_range = (tiff_start, tiff_end)
paths_list: list[str] = []
if selected_paths:
try:
paths_list = _json.loads(selected_paths)
except Exception:
paths_list = []
try:
with temp_upload_path(file) as temp_path:
return processing_service.estimator.estimate_from_file(
temp_path,
doc_count=max(1, doc_count),
selected_paths=paths_list or None,
tiff_page_range=tiff_page_range,
model=model or None,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@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.",
)
try:
return persist_extraction_request(request)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/tasks/extraction", response_model=TaskRead)
def create_extraction_task(request: ExtractionRequest, background_tasks: BackgroundTasks) -> dict[str, Any]:
if not request.fields:
raise HTTPException(
status_code=400,
detail="Envia fields. Usa POST /schemas para descubrir campos antes de crear la tarea.",
)
if request.source == "desktop":
documents = [doc.model_dump() for doc in request.documents or []]
unique_radicados = {str(doc["radicado"]) for doc in documents}
total = len(unique_radicados) if unique_radicados else (request.document_count or len(documents))
title = f"Extraccion desktop ({total} documentos)"
with session_scope() as session:
batch = ExtractionBatch(
status="running",
root_path="desktop://upload",
request=request.model_dump(),
updated_at=utcnow(),
)
session.add(batch)
session.flush()
batch_id = batch.id
task = Task(
job_id=request.job_id,
type="extraction",
status="running",
title=title,
request=request.model_dump(),
result={
"source": "desktop",
"extraction_batch_id": batch_id,
"processed": 0,
"total": total,
"row_count": 0,
"partials": {},
"errors": [],
},
started_at=utcnow(),
updated_at=utcnow(),
)
session.add(task)
session.flush()
return serialize_task(task)
with session_scope() as session:
task = Task(
job_id=request.job_id,
type="extraction",
status="pending",
title=f"Extraccion {request.root_path}",
request=request.model_dump(),
result={},
updated_at=utcnow(),
)
session.add(task)
session.flush()
task_id = task.id
serialized = serialize_task(task)
background_tasks.add_task(run_extraction_task, task_id)
return serialized
async def _process_sync(
task_id: str,
batch_id: str,
content: bytes,
effective_name: str,
radicado: str,
doc_type: str | None,
request: dict[str, Any],
) -> dict[str, Any]:
"""Procesa un documento sincronicamente en un thread pool (modo sin Redis).
Estrategia de concurrencia:
- La llamada a la LLM (lenta) se ejecuta sin ningún lock, permitiendo
que todos los documentos llamen a OpenAI en paralelo.
- Solo la escritura del resultado en la BD usa SELECT FOR UPDATE para
evitar la race condition en el contador `processed`.
"""
import asyncio as _asyncio
import logging as _logging
import tempfile as _tempfile
from api.desktop_ingest import _service_for_model, _paths_for_type
from domain.profiles import get_profile
from domain.prompts import PromptBuilder
from domain.normalizer import normalize_to_schema
_log = _logging.getLogger("extraction.ingest")
ext = os.path.splitext(effective_name)[1] or ".pdf"
with _tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
tmp.write(content)
temp_path = tmp.name
try:
# ── Fase 1: llamada a la LLM (sin lock, en paralelo) ──────────────
def _call_llm():
model = request.get("model") or None
svc = _service_for_model(processing_service, model)
req = dict(request)
fields = list(req.get("fields") or [])
doc_type_arg = doc_type
structure_mode = str(req.get("structure_mode") or "radicados")
doc_types = list(req.get("doc_types") or [])
prof = get_profile("invoice")
selected_paths = _paths_for_type(
fields,
doc_type_arg if structure_mode == "radicados" and doc_types else None,
)
if doc_type_arg and structure_mode == "radicados" and doc_types:
try:
prompt = PromptBuilder.build_for_type(
doc_type_arg, selected_paths, {}, profile_name=prof.get("name", "invoice")
)
except Exception:
prompt = PromptBuilder.build(selected_paths, {}, profile_name=prof.get("name", "invoice"))
else:
instr = req.get("instruction")
prompt = instr or PromptBuilder.build(selected_paths, {}, profile_name=prof.get("name", "invoice"))
tiff_range = req.get("tiff_page_range")
model_name = getattr(svc.extractor, "model", "?")
_log.info("→ OpenAI [%s] radicado=%s filename=%s", model_name, radicado, effective_name)
data, _usage = svc.extract_full_from_pdf(
temp_path,
instruction=prompt,
tiff_page_range=tiff_range,
)
tok_in = (_usage or {}).get("prompt_tokens", "?")
tok_out = (_usage or {}).get("completion_tokens", "?")
_log.info("← OpenAI [%s] radicado=%s tokens=%s→%s", model_name, radicado, tok_in, tok_out)
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 normalized_obj, selected_paths
normalized_obj, selected_paths = await _asyncio.to_thread(_call_llm)
# ── Fase 2: persistir resultado (con FOR UPDATE para serializar) ───
def _persist():
with session_scope() as session:
task = session.get(Task, task_id, with_for_update=True)
if task is None:
return {"error": "task_not_found"}
payload = persist_desktop_ingest_result(
session,
task=task,
batch_id=batch_id,
radicado=radicado,
filename=effective_name,
doc_type=doc_type,
request=request,
normalized_obj=normalized_obj,
)
mark_task_completed_if_done(session, task, batch_id)
session.flush() # persiste status=completed / finished_at si aplica
processed_now = int((task.result or {}).get("processed") or 0)
_log.info(
"✓ guardado radicado=%s processed=%d/%d status=%s",
radicado, processed_now, int((task.result or {}).get("total") or 0), task.status,
)
session.refresh(task)
return {**payload, "task": serialize_task(task)}
return await _asyncio.to_thread(_persist)
except Exception as exc:
# La llamada HTTP conserva el error, pero el progreso debe quedar persistido
# para que un documento fallido no deje la tarea eternamente en running.
def _record_error(error: Exception) -> None:
with session_scope() as session:
task = session.get(Task, task_id, with_for_update=True)
if task is None:
return
record_desktop_ingest_error(
session,
task=task,
radicado=radicado,
filename=effective_name,
doc_type=doc_type,
error=error,
)
mark_task_completed_if_done(session, task, batch_id)
session.flush()
try:
await _asyncio.to_thread(_record_error, exc)
except Exception:
_log.exception(
"No se pudo registrar el error de radicado=%s filename=%s",
radicado,
effective_name,
)
raise
finally:
try:
os.unlink(temp_path)
except OSError:
pass
async def _enqueue_async(
task_id: str,
batch_id: str,
content: bytes,
effective_name: str,
radicado: str,
doc_type: str | None,
instruction: str | None,
tiff_page_range: Any,
) -> None:
"""Guarda el archivo en el volumen compartido y encola el job en ARQ."""
ext = os.path.splitext(effective_name)[1] or ".pdf"
job_file = os.path.join(settings.queue_dir, f"{uuid.uuid4().hex}{ext}")
with open(job_file, "wb") as fh:
fh.write(content)
pool = await get_arq_pool()
await pool.enqueue_job(
"process_document",
task_id=task_id,
batch_id=batch_id,
queue_path=job_file,
radicado=radicado,
filename=effective_name,
doc_type=doc_type,
instruction=instruction,
tiff_page_range=list(tiff_page_range) if tiff_page_range else None,
)
def _load_task_info(task_id: str) -> tuple[str, dict[str, Any], dict[str, Any]]:
"""Carga batch_id, request y task serializado desde BD."""
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
if task.status not in {"pending", "running"}:
raise HTTPException(status_code=400, detail="La tarea ya no acepta documentos.")
if str((task.request or {}).get("source") or "") != "desktop":
raise HTTPException(status_code=400, detail="La tarea no fue creada en modo desktop.")
batch_id = str((task.result or {}).get("extraction_batch_id") or "")
if not batch_id:
raise HTTPException(status_code=400, detail="La tarea no tiene lote asociado.")
return batch_id, dict(task.request or {}), serialize_task(task)
@app.post("/tasks/{task_id}/ingest")
async def ingest_task_document(
task_id: str,
file: UploadFile = File(...),
radicado: str = Form(...),
filename: str | None = Form(None),
doc_type: str | None = Form(None),
) -> dict[str, Any]:
"""Procesa o encola un documento.
- ASYNC_QUEUE=false (default): procesa sincronicamente, responde con la tarea actualizada.
- ASYNC_QUEUE=true: encola en Redis/ARQ, responde de inmediato (sondea GET /tasks/{id}).
"""
batch_id, request, serialized = _load_task_info(task_id)
effective_name = filename or file.filename or "documento.pdf"
content = await file.read()
if settings.processing.async_queue:
await _enqueue_async(
task_id, batch_id, content, effective_name,
radicado, doc_type, request.get("instruction"), request.get("tiff_page_range"),
)
return {"queued": True, "task": serialized}
# Modo sincrono: procesa ahora y devuelve resultado completo
return await _process_sync(task_id, batch_id, content, effective_name, radicado, doc_type, request)
@app.post("/tasks/{task_id}/ingest-many")
async def ingest_task_documents_bulk(
task_id: str,
files: list[UploadFile] = File(...),
radicados: str = Form(...),
filenames: str | None = Form(None),
doc_types: str | None = Form(None),
) -> dict[str, Any]:
"""Procesa o encola múltiples documentos en una sola petición HTTP.
Los parámetros `radicados`, `filenames` y `doc_types` son listas
separadas por `|` que se corresponden posicionalmente con `files`.
En modo sincrono (ASYNC_QUEUE=false) procesa en paralelo con asyncio.
En modo async (ASYNC_QUEUE=true) encola todos en ARQ.
"""
import asyncio as _asyncio
rad_list = radicados.split("|")
fname_list = (filenames or "").split("|") if filenames else [""] * len(files)
dtype_list = (doc_types or "").split("|") if doc_types else [None] * len(files)
if len(files) != len(rad_list):
raise HTTPException(
status_code=400,
detail=f"Numero de archivos ({len(files)}) no coincide con radicados ({len(rad_list)})",
)
batch_id, request, serialized = _load_task_info(task_id)
contents = await _asyncio.gather(*[f.read() for f in files])
if settings.processing.async_queue:
await _asyncio.gather(*[
_enqueue_async(
task_id, batch_id, contents[i],
fname_list[i] or files[i].filename or f"doc_{i}.pdf",
rad_list[i], dtype_list[i] or None,
request.get("instruction"), request.get("tiff_page_range"),
)
for i in range(len(files))
])
return {"queued": len(files), "task": serialized}
# Modo sincrono: procesar todos en paralelo con semaforo para no saturar la API
sem = _asyncio.Semaphore(settings.processing.concurrency_limit)
async def _bounded(i: int) -> dict[str, Any]:
async with sem:
return await _process_sync(
task_id, batch_id, contents[i],
fname_list[i] or files[i].filename or f"doc_{i}.pdf",
rad_list[i], dtype_list[i] or None,
request,
)
results = await _asyncio.gather(*[_bounded(i) for i in range(len(files))], return_exceptions=True)
errors = [str(r) for r in results if isinstance(r, Exception)]
return {"processed": len(files) - len(errors), "errors": errors, "task": serialized}
@app.get("/tasks", response_model=list[TaskRead])
def list_tasks(type: str | None = None, status: str | None = None) -> list[dict[str, Any]]:
with session_scope() as session:
query = select(Task).order_by(Task.created_at.desc())
if type:
query = query.where(Task.type == type)
if status:
query = query.where(Task.status == status)
tasks = session.scalars(query).all()
return [serialize_task(task) for task in tasks]
@app.get("/tasks/{task_id}", response_model=TaskRead)
def get_task(task_id: str) -> dict[str, Any]:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
return serialize_task(task)
@app.get("/tasks/{task_id}/rows")
def get_task_rows(task_id: str, limit: int = 500, offset: int = 0) -> dict[str, Any]:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
batch_id = (task.result or {}).get("extraction_batch_id")
if not batch_id:
raise HTTPException(status_code=400, detail="La tarea aun no tiene lote de extraccion asociado")
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 {
"task_id": task_id,
"batch_id": batch_id,
"rows": [row.row_json for row in rows],
"limit": limit,
"offset": offset,
}
@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,
}
# ═══════════════════════════════════════════════════════════════════════════════
# Jobs (contenedores multimodulo)
# ═══════════════════════════════════════════════════════════════════════════════
class JobRead(BaseModel):
id: str
status: str
title: str | None
metadata: dict[str, Any]
created_at: str
updated_at: str
def serialize_job(job: Job) -> dict[str, Any]:
return {
"id": job.id,
"status": job.status,
"title": job.title,
"metadata": job.job_metadata or {},
"created_at": job.created_at.isoformat(),
"updated_at": job.updated_at.isoformat(),
}
class CreateJobRequest(BaseModel):
title: str | None = None
folder_path: str | None = None
class UpdateJobRequest(BaseModel):
title: str | None = None
folder_path: str | None = None
status: str | None = None
@app.post("/jobs", response_model=JobRead)
def create_job(request: CreateJobRequest) -> dict[str, Any]:
"""Crea un trabajo (job) independiente como contenedor para módulos."""
with session_scope() as session:
job = Job(
status="pending",
title=request.title,
job_metadata={"folder_path": request.folder_path} if request.folder_path else {},
updated_at=utcnow(),
)
session.add(job)
session.flush()
return serialize_job(job)
@app.patch("/jobs/{job_id}", response_model=JobRead)
def update_job(job_id: str, request: UpdateJobRequest) -> dict[str, Any]:
"""Actualiza título, metadata o estado de un job."""
with session_scope() as session:
job = session.get(Job, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
if request.title is not None:
job.title = request.title
if request.status is not None:
job.status = request.status
if request.folder_path is not None:
meta = dict(job.job_metadata or {})
meta["folder_path"] = request.folder_path
job.job_metadata = meta
job.updated_at = utcnow()
session.flush()
return serialize_job(job)
@app.get("/jobs", response_model=list[JobRead])
def list_jobs() -> list[dict[str, Any]]:
with session_scope() as session:
jobs = session.scalars(select(Job).order_by(Job.created_at.desc())).all()
return [serialize_job(j) for j in jobs]
@app.get("/jobs/{job_id}", response_model=JobRead)
def get_job(job_id: str) -> dict[str, Any]:
with session_scope() as session:
job = session.get(Job, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
return serialize_job(job)
@app.get("/jobs/{job_id}/tasks", response_model=list[TaskRead])
def get_job_tasks(job_id: str) -> list[dict[str, Any]]:
with session_scope() as session:
job = session.get(Job, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
tasks = session.scalars(
select(Task).where(Task.job_id == job_id).order_by(Task.created_at)
).all()
return [serialize_task(t) for t in tasks]
@app.delete("/jobs/{job_id}", status_code=204, response_class=Response)
def delete_job(job_id: str) -> Response:
"""Elimina un job y todos sus resultados (tasks, conteos, extracciones, limpiezas)."""
with session_scope() as session:
job = session.get(Job, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
tasks = session.scalars(select(Task).where(Task.job_id == job_id)).all()
task_ids = [t.id for t in tasks]
batch_ids: list[str] = []
if task_ids:
batch_ids = list(
session.scalars(
select(ExtractionBatch.id).where(ExtractionBatch.job_module_id.in_(task_ids))
).all()
)
cleaning_ids = list(
session.scalars(select(CleaningJob.id).where(CleaningJob.job_id == job_id)).all()
)
if batch_ids:
cleaning_ids.extend(
session.scalars(
select(CleaningJob.id).where(CleaningJob.extraction_batch_id.in_(batch_ids))
).all()
)
cleaning_ids = list(dict.fromkeys(cleaning_ids))
if cleaning_ids:
session.execute(delete(CleanedRow).where(CleanedRow.cleaning_job_id.in_(cleaning_ids)))
session.execute(delete(CleaningJob).where(CleaningJob.id.in_(cleaning_ids)))
if batch_ids:
session.execute(delete(CleanedRow).where(CleanedRow.extraction_batch_id.in_(batch_ids)))
session.execute(delete(ExtractionRow).where(ExtractionRow.batch_id.in_(batch_ids)))
session.execute(delete(ExtractionBatch).where(ExtractionBatch.id.in_(batch_ids)))
if task_ids:
session.execute(delete(CountJobFile).where(CountJobFile.job_module_id.in_(task_ids)))
session.execute(delete(Task).where(Task.id.in_(task_ids)))
session.delete(job)
session.flush()
return Response(status_code=204)
# ═══════════════════════════════════════════════════════════════════════════════
# Count Jobs — persistir y gestionar resultados del servicio de conteo
# ═══════════════════════════════════════════════════════════════════════════════
class CountJobFileCreate(BaseModel):
radicado: str | None = None
filename: str
file_path: str | None = None
doc_type: str | None = None
service_count: int | None = None
services: list[dict[str, Any]] = Field(default_factory=list)
status: str = "ok"
error: str | None = None
nit: str | None = None
object_key: str | None = None
class CreateCountJobRequest(BaseModel):
title: str | None = None
job_id: str | None = None # si se pasa, se usa el job existente
results: list[CountJobFileCreate]
download_job_id: str | None = None
source: str | None = None
started_at: str | None = None # ISO del cliente (inicio real del conteo)
class CountJobFileRead(BaseModel):
id: str
job_module_id: str
radicado: str | None
filename: str
file_path: str | None
doc_type: str | None
service_count: int | None
services: list[dict[str, Any]]
status: str
is_valid: bool | None
error: str | None
created_at: str
nit: str | None = None
object_key: str | None = None
class UpdateCountJobFileRequest(BaseModel):
is_valid: bool | None
class ExtractionFromCountRequest(BaseModel):
counting_task_id: str
min_service_count: int = 0
valid_counts: list[int] | None = None # conteos exactos válidos (vacío = todos)
job_id: str | None = None # vincular al job existente del conteo
fields: list[str] = Field(default_factory=list)
alias_map: dict[str, str] = Field(default_factory=dict)
doc_types: list[DocumentType] | None = None
structure_mode: str = "general"
instruction: str | None = None
tiff_page_range: tuple[int, int] | None = None
retry_on_empty: bool = False
model: str | None = None
def serialize_count_file(f: CountJobFile) -> dict[str, Any]:
return {
"id": f.id,
"job_module_id": f.job_module_id,
"radicado": f.radicado,
"filename": f.filename,
"file_path": f.file_path,
"doc_type": f.doc_type,
"service_count": f.service_count,
"services": f.services or [],
"status": f.status,
"is_valid": f.is_valid,
"error": f.error,
"created_at": f.created_at.isoformat(),
"nit": getattr(f, "nit", None),
"object_key": getattr(f, "object_key", None),
}
@app.post("/count-jobs", response_model=TaskRead)
def create_count_job(request: CreateCountJobRequest) -> dict[str, Any]:
"""Persiste los resultados del servicio de conteo de facturas en la BD compartida."""
total = len(request.results)
default_title = request.title or f"Conteo ({total} archivos)"
finished = utcnow()
started = finished
if request.started_at:
try:
parsed = datetime.fromisoformat(request.started_at.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
started = parsed
except ValueError:
started = finished
with session_scope() as session:
if request.job_id:
job = session.get(Job, request.job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
# No sobrescribir el nombre que el usuario dio al trabajo.
job.status = "running"
job.updated_at = utcnow()
else:
job = Job(
status="completed",
title=default_title,
updated_at=utcnow(),
)
session.add(job)
session.flush()
task = Task(
job_id=job.id,
type="counting",
status="completed",
title=default_title,
request={
"source": request.source or ("from-download" if request.download_job_id else "desktop"),
"download_job_id": request.download_job_id,
},
result={
"total": total,
"processed": total,
"ok": sum(1 for r in request.results if r.status == "ok"),
"failed": sum(1 for r in request.results if r.status != "ok"),
"nits": sorted({r.nit for r in request.results if r.nit}),
},
started_at=started,
finished_at=finished,
updated_at=finished,
)
session.add(task)
session.flush()
for r in request.results:
session.add(
CountJobFile(
job_module_id=task.id,
radicado=r.radicado,
filename=r.filename,
file_path=r.file_path,
doc_type=r.doc_type,
nit=r.nit,
object_key=r.object_key,
service_count=r.service_count,
services=r.services,
status=r.status,
error=r.error,
)
)
session.flush()
return serialize_task(task)
@app.get("/count-jobs", response_model=list[TaskRead])
def list_count_jobs() -> list[dict[str, Any]]:
with session_scope() as session:
tasks = session.scalars(
select(Task)
.where(Task.type == "counting")
.order_by(Task.created_at.desc())
).all()
return [serialize_task(t) for t in tasks]
@app.get("/count-jobs/{task_id}", response_model=TaskRead)
def get_count_job(task_id: str) -> dict[str, Any]:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None or task.type != "counting":
raise HTTPException(status_code=404, detail="Tarea de conteo no encontrada")
return serialize_task(task)
@app.get("/count-jobs/{task_id}/files", response_model=list[CountJobFileRead])
def list_count_job_files(task_id: str) -> list[dict[str, Any]]:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None or task.type != "counting":
raise HTTPException(status_code=404, detail="Tarea de conteo no encontrada")
files = session.scalars(
select(CountJobFile)
.where(CountJobFile.job_module_id == task_id)
.order_by(CountJobFile.created_at)
).all()
return [serialize_count_file(f) for f in files]
@app.patch("/count-jobs/{task_id}/files/{file_id}", response_model=CountJobFileRead)
def update_count_job_file(
task_id: str, file_id: str, request: UpdateCountJobFileRequest
) -> dict[str, Any]:
with session_scope() as session:
f = session.get(CountJobFile, file_id)
if f is None or f.job_module_id != task_id:
raise HTTPException(status_code=404, detail="Archivo no encontrado")
f.is_valid = request.is_valid
session.flush()
return serialize_count_file(f)
# ═══════════════════════════════════════════════════════════════════════════════
# Extraccion desde resultados de conteo (sin re-subir archivos)
# ═══════════════════════════════════════════════════════════════════════════════
def run_from_count_task(task_id: str) -> None:
"""Procesa archivos validos de un job de conteo en background (lee desde el volumen /data)."""
import logging as _logging
_log = _logging.getLogger("extraction.from_count")
from api.desktop_ingest import _service_for_model, mark_task_completed_if_done
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
return
files_data: list[dict[str, Any]] = list((task.result or {}).get("files", []))
req = dict(task.request or {})
batch_id = str((task.result or {}).get("extraction_batch_id") or "")
model_name: str | None = req.get("model")
instruction: str | None = req.get("instruction")
tiff_page_range = req.get("tiff_page_range")
for file_data in files_data:
file_path: str | None = file_data.get("file_path")
filename = str(file_data.get("filename") or "documento.pdf")
radicado = str(file_data.get("radicado") or file_data.get("filename") or "")
doc_type: str | None = file_data.get("doc_type")
if not file_path or not __import__("os").path.exists(file_path):
_log.warning("Archivo no disponible: %s", file_path or filename)
with session_scope() as session:
task_obj = session.get(Task, task_id)
if task_obj is None:
return
result = dict(task_obj.result or {})
errors = list(result.get("errors") or [])
errors.append(f"Archivo no encontrado: {filename}")
result["errors"] = errors
result["processed"] = int(result.get("processed") or 0) + 1
task_obj.result = result
task_obj.updated_at = utcnow()
mark_task_completed_if_done(session, task_obj, batch_id)
continue
try:
svc = _service_for_model(processing_service, model_name)
with session_scope() as session:
task_obj = session.get(Task, task_id, with_for_update=True)
if task_obj is None:
return
from api.desktop_ingest import process_desktop_ingest
process_desktop_ingest(
svc, session,
task=task_obj,
batch_id=batch_id,
temp_path=file_path,
radicado=radicado,
filename=filename,
doc_type=doc_type,
instruction=instruction,
tiff_page_range=tiff_page_range,
model=model_name,
)
mark_task_completed_if_done(session, task_obj, batch_id)
except Exception as exc:
_log.exception("Error procesando %s: %s", filename, exc)
with session_scope() as session:
task_obj = session.get(Task, task_id)
if task_obj is None:
return
result = dict(task_obj.result or {})
errors = list(result.get("errors") or [])
errors.append(f"{filename}: {exc}")
result["errors"] = errors
result["processed"] = int(result.get("processed") or 0) + 1
task_obj.result = result
task_obj.updated_at = utcnow()
mark_task_completed_if_done(session, task_obj, batch_id)
@app.post("/tasks/extraction/from-count", response_model=TaskRead)
def create_extraction_from_count(
request: ExtractionFromCountRequest, background_tasks: BackgroundTasks
) -> dict[str, Any]:
"""Crea una tarea de extraccion usando archivos validos de un job de conteo previo.
No requiere subir archivos: usa las rutas almacenadas en count_job_files.
"""
if not request.fields:
raise HTTPException(
status_code=400,
detail="Envia fields. Usa POST /extraction/schemas/upload para descubrir campos.",
)
with session_scope() as session:
counting_task = session.get(Task, request.counting_task_id)
if counting_task is None or counting_task.type != "counting":
raise HTTPException(status_code=404, detail="Tarea de conteo no encontrada")
all_files = session.scalars(
select(CountJobFile).where(
CountJobFile.job_module_id == request.counting_task_id
)
).all()
use_exact_counts = request.valid_counts is not None and len(request.valid_counts) > 0
valid_files = [
f for f in all_files
if f.status == "ok"
and f.is_valid is not False
and (
# Si el usuario marcó manualmente como válido, siempre se incluye
f.is_valid is True
# Si hay conteos exactos definidos, el service_count debe estar en la lista
or (use_exact_counts and f.service_count in request.valid_counts)
# Sin filtro exacto, usar umbral mínimo (compatibilidad hacia atrás)
or (not use_exact_counts and (f.service_count is None or f.service_count >= request.min_service_count))
)
]
if not valid_files:
raise HTTPException(
status_code=400,
detail="No hay archivos validos para extraer con los criterios indicados.",
)
files_data = [
{
"id": f.id,
"radicado": f.radicado or f.filename,
"filename": f.filename,
"file_path": f.file_path,
"doc_type": f.doc_type,
"service_count": f.service_count,
}
for f in valid_files
]
total = len(files_data)
if request.job_id:
job = session.get(Job, request.job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
job.status = "running"
job.updated_at = utcnow()
else:
job = Job(
status="running",
title=f"Extraccion desde conteo ({total} archivos)",
updated_at=utcnow(),
)
session.add(job)
session.flush()
batch = ExtractionBatch(
status="running",
root_path="count://",
request={
"source": "from-count",
"counting_task_id": request.counting_task_id,
"fields": request.fields,
"alias_map": request.alias_map,
"min_service_count": request.min_service_count,
"structure_mode": request.structure_mode,
},
updated_at=utcnow(),
)
session.add(batch)
session.flush()
task = Task(
job_id=job.id,
type="extraction",
status="running",
title=job.title,
request={
"source": "from-count",
"counting_task_id": request.counting_task_id,
"fields": request.fields,
"alias_map": request.alias_map,
"doc_types": [dt.model_dump() for dt in request.doc_types or []],
"structure_mode": request.structure_mode,
"instruction": request.instruction,
"tiff_page_range": list(request.tiff_page_range) if request.tiff_page_range else None,
"min_service_count": request.min_service_count,
"model": request.model,
},
result={
"source": "from-count",
"extraction_batch_id": batch.id,
"processed": 0,
"total": total,
"row_count": 0,
"errors": [],
"files": files_data,
"partials": {},
},
started_at=utcnow(),
updated_at=utcnow(),
)
session.add(task)
session.flush()
batch.job_module_id = task.id
session.flush()
serialized = serialize_task(task)
task_id = task.id
background_tasks.add_task(run_from_count_task, task_id)
return serialized