feat: Update default models for extraction and normalization to GPT-5.6 Luna across configuration files and documentation. Remove deprecated code and improve pricing details in the UI components. Add tests to ensure correct model settings and pricing calculations.
This commit is contained in:
@@ -85,10 +85,10 @@ count-api results → POST /count-jobs → count_job_files (P
|
||||
| `REDIS_URL` | Redis para cola ARQ (`redis://...`) |
|
||||
| `EXTRACTOR_BASE_URL` | URL base del LLM (compatible OpenAI) |
|
||||
| `EXTRACTOR_API_KEY` | API key del LLM extractor |
|
||||
| `EXTRACTOR_MODEL` | Modelo de extracción (ej. `gpt-5.4-mini`) |
|
||||
| `EXTRACTOR_MODEL` | Modelo de extracción (default: `gpt-5.6-luna`) |
|
||||
| `NORMALIZER_BASE_URL` | URL base del LLM normalizador |
|
||||
| `NORMALIZER_API_KEY` | API key del LLM normalizador |
|
||||
| `NORMALIZER_MODEL` | Modelo normalizador (ej. `gpt-5.4-mini`) |
|
||||
| `NORMALIZER_MODEL` | Modelo normalizador (default: `gpt-5.6-luna`) |
|
||||
| `EXTRACTOR_TIMEOUT_S` | Timeout por documento (default `120`) |
|
||||
| `PDF_DPI` | DPI para renderizado PDF (default `150`) |
|
||||
| `PDF_THREAD_COUNT` | Hilos de renderizado PDF (default `4`) |
|
||||
|
||||
@@ -100,59 +100,43 @@ def _service_for_model(base_service: InvoiceProcessingService, model: str | None
|
||||
)
|
||||
|
||||
|
||||
def process_desktop_ingest(
|
||||
service: InvoiceProcessingService,
|
||||
def persist_desktop_ingest_result(
|
||||
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,
|
||||
request: dict[str, Any],
|
||||
normalized_obj: Any,
|
||||
) -> dict[str, Any]:
|
||||
service = _service_for_model(service, model)
|
||||
request = dict(task.request or {})
|
||||
"""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 [])
|
||||
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,
|
||||
)
|
||||
raw_data = data if isinstance(data, dict) else {"data": data}
|
||||
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
|
||||
|
||||
result = dict(task.result or {})
|
||||
errors = list(result.get("errors") 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 {})
|
||||
@@ -216,6 +200,113 @@ def process_desktop_ingest(
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -20,7 +20,11 @@ from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Respons
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from api.desktop_ingest import mark_task_completed_if_done, process_desktop_ingest
|
||||
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
|
||||
@@ -612,10 +616,6 @@ async def _process_sync(
|
||||
import asyncio as _asyncio
|
||||
import logging as _logging
|
||||
import tempfile as _tempfile
|
||||
from domain.service import InvoiceProcessingService
|
||||
from domain.strategies import OpenAICompatibleExtractor
|
||||
from domain.batch_processor import BatchProcessor
|
||||
from domain.estimator import Estimator
|
||||
from api.desktop_ingest import _service_for_model, _paths_for_type
|
||||
from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
@@ -678,58 +678,61 @@ async def _process_sync(
|
||||
|
||||
# ── Fase 2: persistir resultado (con FOR UPDATE para serializar) ───
|
||||
def _persist():
|
||||
from infrastructure.serialization import flatten_extraction_result
|
||||
from infrastructure.database import ExtractionRow, ExtractionBatch, utcnow
|
||||
req = dict(request)
|
||||
fields = list(req.get("fields") or [])
|
||||
alias_map = dict(req.get("alias_map") or {})
|
||||
with session_scope() as session:
|
||||
task = session.get(Task, task_id, with_for_update=True)
|
||||
if task is None:
|
||||
return {"error": "task_not_found"}
|
||||
result = dict(task.result or {})
|
||||
fused = {"radicado": radicado, "_source_pdf": effective_name}
|
||||
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=effective_name,
|
||||
row_index=row_index,
|
||||
row_json=row,
|
||||
raw_json=fused,
|
||||
))
|
||||
result["row_count"] = row_index
|
||||
processed_now = int(result.get("processed") or 0) + 1
|
||||
result["processed"] = processed_now
|
||||
task.result = result
|
||||
task.updated_at = utcnow()
|
||||
session.flush()
|
||||
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(result.get("total") or 0), task.status,
|
||||
radicado, processed_now, int((task.result or {}).get("total") or 0), task.status,
|
||||
)
|
||||
session.refresh(task)
|
||||
return {
|
||||
"radicado": radicado,
|
||||
"filename": effective_name,
|
||||
"processed": processed_now,
|
||||
"total": int(result.get("total") or 0),
|
||||
"task": serialize_task(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)
|
||||
|
||||
@@ -7,7 +7,7 @@ from io import BytesIO
|
||||
from typing import List, Tuple, Optional
|
||||
import time
|
||||
|
||||
from pdf2image import convert_from_path, pdfinfo_from_path
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
@@ -80,86 +80,6 @@ def pdf_to_images_bs64(
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Utilidades de texto para parseo de JSON embebido y construcción de schema."""
|
||||
import re
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
|
||||
@@ -12,11 +12,6 @@ from typing import List, Dict
|
||||
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.
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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)
|
||||
@@ -31,12 +30,12 @@ class ApiSettings(BaseSettings):
|
||||
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"))
|
||||
extractor_model: str = Field("gpt-5.6-luna", 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"))
|
||||
normalizer_model: str = Field("gpt-5.6-luna", 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"))
|
||||
|
||||
@@ -5,12 +5,12 @@ Define precios por modelo y funciones para estimar costos a partir del uso de to
|
||||
"""
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Precios actualizados por 1M tokens (USD) - Enero 2026
|
||||
# Precios estándar por 1M tokens (USD) - Agosto 2026
|
||||
MODEL_PRICING = {
|
||||
"gpt-4.1-mini": {
|
||||
"input": 0.40,
|
||||
"cached_input": 0.10,
|
||||
"output": 1.60,
|
||||
"gpt-5.6-luna": {
|
||||
"input": 0.20,
|
||||
"cached_input": 0.02,
|
||||
"output": 1.20,
|
||||
},
|
||||
"gpt-5.4": {
|
||||
"input": 2.50,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
Encapsula la lógica de estimación previa al procesamiento.
|
||||
"""
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -150,7 +149,7 @@ class Estimator:
|
||||
# Enriquecer con modelo efectivo y conteo de páginas para el frontend
|
||||
effective_model = model or getattr(service.extractor, 'model', None) or getattr(self.service.extractor, 'model', None)
|
||||
result['extractor_model'] = effective_model
|
||||
result['normalizer_model'] = settings.api.normalizer_model or 'gpt-5.4-mini'
|
||||
result['normalizer_model'] = settings.api.normalizer_model or 'gpt-5.6-luna'
|
||||
|
||||
# Estimar numero de paginas a partir de prompt_tokens de imagen:
|
||||
# GPT-4o cobra ~85 tok base + ~170 tok por tile 512x512.
|
||||
|
||||
@@ -207,7 +207,6 @@ class LLMClient:
|
||||
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]],
|
||||
@@ -236,7 +235,6 @@ class LLMClient:
|
||||
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]],
|
||||
@@ -276,41 +274,5 @@ class LLMClient:
|
||||
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
|
||||
|
||||
__all__ = ["LLMClient"]
|
||||
|
||||
@@ -21,7 +21,6 @@ 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__)
|
||||
|
||||
@@ -49,11 +48,6 @@ class InvoiceProcessingService:
|
||||
|
||||
# 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]:
|
||||
"""
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
create_engine,
|
||||
select,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
||||
@@ -187,13 +186,3 @@ def session_scope() -> Iterator[Session]:
|
||||
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,11 @@
|
||||
from services.extraction.domain.config import ApiSettings
|
||||
|
||||
|
||||
def test_luna_is_the_default_model_for_extraction_and_normalization(monkeypatch) -> None:
|
||||
monkeypatch.delenv("EXTRACTOR_MODEL", raising=False)
|
||||
monkeypatch.delenv("NORMALIZER_MODEL", raising=False)
|
||||
|
||||
api = ApiSettings(_env_file=None)
|
||||
|
||||
assert api.extractor_model == "gpt-5.6-luna"
|
||||
assert api.normalizer_model == "gpt-5.6-luna"
|
||||
@@ -0,0 +1,18 @@
|
||||
from services.extraction.domain.costs import compute_cost_from_usage
|
||||
|
||||
|
||||
def test_gpt_5_6_luna_pricing_includes_cached_tokens() -> None:
|
||||
usage = {
|
||||
"prompt_tokens": 2_000_000,
|
||||
"completion_tokens": 1_000_000,
|
||||
"prompt_tokens_details": {"cached_tokens": 1_000_000},
|
||||
}
|
||||
|
||||
cost = compute_cost_from_usage(usage, model="gpt-5.6-luna")
|
||||
|
||||
assert cost == {
|
||||
"input_usd": 0.20,
|
||||
"cached_input_usd": 0.02,
|
||||
"output_usd": 1.20,
|
||||
"total_usd": 1.42,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Regresiones de persistencia para ingestas desktop síncronas."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api import desktop_ingest
|
||||
from api import main as extraction_api
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
self.batch = SimpleNamespace(status="running", updated_at=None)
|
||||
|
||||
def add(self, value: object) -> None:
|
||||
self.added.append(value)
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
def get(self, _model: object, _identifier: str) -> object:
|
||||
return self.batch
|
||||
|
||||
def refresh(self, _value: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_multiple_document_types_complete_once_per_radicado(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
desktop_ingest,
|
||||
"flatten_extraction_result",
|
||||
lambda fused, _fields, _aliases: [fused],
|
||||
)
|
||||
session = FakeSession()
|
||||
request = {
|
||||
"structure_mode": "radicados",
|
||||
"fields": ["factura/numero", "soporte/codigo"],
|
||||
"doc_types": [{"name": "factura"}, {"name": "soporte"}],
|
||||
"documents": [
|
||||
{"radicado": "RAD-1", "filename": "factura.pdf", "doc_type": "factura"},
|
||||
{"radicado": "RAD-1", "filename": "soporte.pdf", "doc_type": "soporte"},
|
||||
],
|
||||
}
|
||||
task = SimpleNamespace(
|
||||
result={"processed": 0, "total": 1, "row_count": 0, "partials": {}},
|
||||
status="running",
|
||||
finished_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
first = desktop_ingest.persist_desktop_ingest_result(
|
||||
session,
|
||||
task=task,
|
||||
batch_id="batch-1",
|
||||
radicado="RAD-1",
|
||||
filename="factura.pdf",
|
||||
doc_type="factura",
|
||||
request=request,
|
||||
normalized_obj={"numero": "F-1"},
|
||||
)
|
||||
desktop_ingest.mark_task_completed_if_done(session, task, "batch-1")
|
||||
|
||||
assert first["finalized"] is False
|
||||
assert task.result["processed"] == 0
|
||||
assert task.status == "running"
|
||||
assert task.result["partials"] == {"RAD-1": {"factura": {"numero": "F-1"}}}
|
||||
assert session.added == []
|
||||
|
||||
second = desktop_ingest.persist_desktop_ingest_result(
|
||||
session,
|
||||
task=task,
|
||||
batch_id="batch-1",
|
||||
radicado="RAD-1",
|
||||
filename="soporte.pdf",
|
||||
doc_type="soporte",
|
||||
request=request,
|
||||
normalized_obj={"codigo": "S-1"},
|
||||
)
|
||||
desktop_ingest.mark_task_completed_if_done(session, task, "batch-1")
|
||||
|
||||
assert second["finalized"] is True
|
||||
assert task.result["processed"] == 1
|
||||
assert task.result["partials"] == {}
|
||||
assert task.status == "completed"
|
||||
assert session.batch.status == "completed"
|
||||
assert len(session.added) == 1
|
||||
row = session.added[0]
|
||||
assert row.radicado == "RAD-1"
|
||||
assert row.raw_json["factura"] == {"numero": "F-1"}
|
||||
assert row.raw_json["soporte"] == {"codigo": "S-1"}
|
||||
|
||||
|
||||
def test_document_failure_completes_radicado_and_discards_partials(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
desktop_ingest,
|
||||
"flatten_extraction_result",
|
||||
lambda fused, _fields, _aliases: [fused],
|
||||
)
|
||||
session = FakeSession()
|
||||
request = {
|
||||
"structure_mode": "radicados",
|
||||
"fields": ["factura/numero", "soporte/codigo"],
|
||||
"doc_types": [{"name": "factura"}, {"name": "soporte"}],
|
||||
"documents": [
|
||||
{"radicado": "RAD-1", "filename": "factura.pdf", "doc_type": "factura"},
|
||||
{"radicado": "RAD-1", "filename": "soporte.pdf", "doc_type": "soporte"},
|
||||
],
|
||||
}
|
||||
task = SimpleNamespace(
|
||||
result={"processed": 0, "total": 1, "row_count": 0, "partials": {}},
|
||||
status="running",
|
||||
finished_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
desktop_ingest.persist_desktop_ingest_result(
|
||||
session,
|
||||
task=task,
|
||||
batch_id="batch-1",
|
||||
radicado="RAD-1",
|
||||
filename="factura.pdf",
|
||||
doc_type="factura",
|
||||
request=request,
|
||||
normalized_obj={"numero": "F-1"},
|
||||
)
|
||||
failure = desktop_ingest.record_desktop_ingest_error(
|
||||
session,
|
||||
task=task,
|
||||
radicado="RAD-1",
|
||||
filename="soporte.pdf",
|
||||
doc_type="soporte",
|
||||
error=RuntimeError("backend no disponible"),
|
||||
)
|
||||
desktop_ingest.mark_task_completed_if_done(session, task, "batch-1")
|
||||
|
||||
assert failure["first_failure"] is True
|
||||
assert task.result["processed"] == 1
|
||||
assert task.result["partials"] == {}
|
||||
assert task.result["failed_radicados"] == ["RAD-1"]
|
||||
assert task.result["errors"] == [{
|
||||
"radicado": "RAD-1",
|
||||
"filename": "soporte.pdf",
|
||||
"doc_type": "soporte",
|
||||
"error": "backend no disponible",
|
||||
}]
|
||||
assert task.status == "completed"
|
||||
assert session.added == []
|
||||
|
||||
late_success = desktop_ingest.persist_desktop_ingest_result(
|
||||
session,
|
||||
task=task,
|
||||
batch_id="batch-1",
|
||||
radicado="RAD-1",
|
||||
filename="soporte.pdf",
|
||||
doc_type="soporte",
|
||||
request=request,
|
||||
normalized_obj={"codigo": "S-1"},
|
||||
)
|
||||
duplicate_failure = desktop_ingest.record_desktop_ingest_error(
|
||||
session,
|
||||
task=task,
|
||||
radicado="RAD-1",
|
||||
filename="factura.pdf",
|
||||
doc_type="factura",
|
||||
error=RuntimeError("otro fallo tardío"),
|
||||
)
|
||||
|
||||
assert late_success["skipped"] is True
|
||||
assert duplicate_failure["first_failure"] is False
|
||||
assert task.result["processed"] == 1
|
||||
assert task.result["partials"] == {}
|
||||
assert session.added == []
|
||||
|
||||
|
||||
def test_process_sync_persists_failure_before_reraising(monkeypatch) -> None:
|
||||
task = SimpleNamespace(
|
||||
result={"processed": 0, "total": 1, "row_count": 0, "partials": {}},
|
||||
status="running",
|
||||
finished_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
session = FakeSession()
|
||||
|
||||
def get(model: object, _identifier: str, **_kwargs: object) -> object:
|
||||
if model is extraction_api.Task:
|
||||
return task
|
||||
return session.batch
|
||||
|
||||
session.get = get # type: ignore[method-assign]
|
||||
|
||||
@contextmanager
|
||||
def fake_session_scope():
|
||||
yield session
|
||||
|
||||
class FailingService:
|
||||
extractor = SimpleNamespace(model="test-model")
|
||||
|
||||
def extract_full_from_pdf(self, *_args: object, **_kwargs: object) -> object:
|
||||
raise RuntimeError("fallo simulado")
|
||||
|
||||
monkeypatch.setattr(extraction_api, "session_scope", fake_session_scope)
|
||||
monkeypatch.setattr(extraction_api, "processing_service", FailingService())
|
||||
|
||||
with pytest.raises(RuntimeError, match="fallo simulado"):
|
||||
asyncio.run(extraction_api._process_sync(
|
||||
"task-1",
|
||||
"batch-1",
|
||||
b"pdf",
|
||||
"factura.pdf",
|
||||
"RAD-1",
|
||||
"factura",
|
||||
{
|
||||
"fields": ["factura/numero"],
|
||||
"doc_types": [{"name": "factura"}],
|
||||
"documents": [
|
||||
{"radicado": "RAD-1", "filename": "factura.pdf", "doc_type": "factura"},
|
||||
],
|
||||
},
|
||||
))
|
||||
|
||||
assert task.result["processed"] == 1
|
||||
assert task.result["failed_radicados"] == ["RAD-1"]
|
||||
assert task.result["errors"][0]["error"] == "fallo simulado"
|
||||
assert task.status == "completed"
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Regresiones de selección de modelo en trabajos ARQ."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
from worker import tasks
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, task: object) -> None:
|
||||
self.task = task
|
||||
|
||||
def get(self, _model: object, _identifier: str, **_kwargs: object) -> object:
|
||||
return self.task
|
||||
|
||||
def add(self, _value: object) -> None:
|
||||
pass
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeRateLimiter:
|
||||
async def acquire(self, *, estimated_tokens: int) -> None:
|
||||
assert estimated_tokens >= 2000
|
||||
|
||||
|
||||
class FakeService:
|
||||
def __init__(self, model: str) -> None:
|
||||
self.extractor = SimpleNamespace(model=model)
|
||||
self.calls = 0
|
||||
|
||||
def extract_full_from_pdf(self, *_args: object, **_kwargs: object):
|
||||
self.calls += 1
|
||||
return {"numero": "F-1"}, {}
|
||||
|
||||
|
||||
def test_process_document_uses_requested_model(monkeypatch, tmp_path) -> None:
|
||||
task = SimpleNamespace(
|
||||
request={
|
||||
"model": "modelo-elegido",
|
||||
"fields": ["numero"],
|
||||
"structure_mode": "general",
|
||||
"doc_types": [],
|
||||
},
|
||||
result={"processed": 0, "total": 1, "row_count": 0},
|
||||
updated_at=None,
|
||||
)
|
||||
session = FakeSession(task)
|
||||
|
||||
@contextmanager
|
||||
def fake_session_scope():
|
||||
yield session
|
||||
|
||||
base_service = FakeService("modelo-global")
|
||||
selected_service = FakeService("modelo-elegido")
|
||||
selected_models: list[str] = []
|
||||
|
||||
def fake_service_for_model(_base: object, model: str):
|
||||
selected_models.append(model)
|
||||
return selected_service
|
||||
|
||||
monkeypatch.setattr(tasks, "session_scope", fake_session_scope)
|
||||
monkeypatch.setattr(tasks, "get_rate_limiter", lambda: FakeRateLimiter())
|
||||
monkeypatch.setattr(tasks, "_service_for_model", fake_service_for_model)
|
||||
monkeypatch.setattr(tasks, "normalize_to_schema", lambda data, **_kwargs: {"normalized": data})
|
||||
monkeypatch.setattr(tasks, "flatten_extraction_result", lambda *_args, **_kwargs: [])
|
||||
monkeypatch.setattr(tasks, "flag_modified", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(tasks, "mark_task_completed_if_done", lambda *_args, **_kwargs: None)
|
||||
|
||||
queue_file = tmp_path / "factura.pdf"
|
||||
queue_file.write_bytes(b"pdf")
|
||||
ctx = {"service": base_service}
|
||||
|
||||
asyncio.run(tasks.process_document(
|
||||
ctx,
|
||||
task_id="task-1",
|
||||
batch_id="batch-1",
|
||||
queue_path=str(queue_file),
|
||||
radicado="RAD-1",
|
||||
filename="factura.pdf",
|
||||
doc_type=None,
|
||||
instruction=None,
|
||||
tiff_page_range=None,
|
||||
))
|
||||
|
||||
assert selected_models == ["modelo-elegido"]
|
||||
assert selected_service.calls == 1
|
||||
assert base_service.calls == 0
|
||||
assert ctx["services_by_model"]["modelo-elegido"] is selected_service
|
||||
|
||||
|
||||
def test_service_for_request_reuses_cached_model(monkeypatch) -> None:
|
||||
base_service = FakeService("modelo-global")
|
||||
selected_service = FakeService("modelo-elegido")
|
||||
builds = 0
|
||||
|
||||
def fake_service_for_model(_base: object, _model: str):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
return selected_service
|
||||
|
||||
monkeypatch.setattr(tasks, "_service_for_model", fake_service_for_model)
|
||||
ctx = {"service": base_service}
|
||||
request = {"model": "modelo-elegido"}
|
||||
|
||||
first = tasks._service_for_request(ctx, request)
|
||||
second = tasks._service_for_request(ctx, request)
|
||||
|
||||
assert first is selected_service
|
||||
assert second is selected_service
|
||||
assert builds == 1
|
||||
@@ -17,6 +17,7 @@ from infrastructure.serialization import flatten_extraction_result
|
||||
from api.desktop_ingest import (
|
||||
_finalize_radicado_if_ready,
|
||||
_paths_for_type,
|
||||
_service_for_model,
|
||||
mark_task_completed_if_done,
|
||||
)
|
||||
from domain.profiles import get_profile
|
||||
@@ -27,6 +28,27 @@ from domain.rate_limiter import get_rate_limiter
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _service_for_request(
|
||||
ctx: dict[str, Any], request: dict[str, Any]
|
||||
) -> Any:
|
||||
"""Resuelve y reutiliza el servicio correspondiente al modelo solicitado."""
|
||||
base_service = ctx["service"]
|
||||
requested_model = str(request.get("model") or "").strip() or None
|
||||
if not requested_model:
|
||||
return base_service
|
||||
|
||||
current_model = getattr(getattr(base_service, "extractor", None), "model", None)
|
||||
if current_model == requested_model:
|
||||
return base_service
|
||||
|
||||
services_by_model = ctx.setdefault("services_by_model", {})
|
||||
service = services_by_model.get(requested_model)
|
||||
if service is None:
|
||||
service = _service_for_model(base_service, requested_model)
|
||||
services_by_model[requested_model] = service
|
||||
return service
|
||||
|
||||
|
||||
async def process_document(
|
||||
ctx: dict[str, Any],
|
||||
*,
|
||||
@@ -52,7 +74,6 @@ async def process_document(
|
||||
instruction: Instrucción de extracción personalizada.
|
||||
tiff_page_range: Rango de páginas TIFF [inicio, fin].
|
||||
"""
|
||||
service = ctx["service"]
|
||||
rate_limiter = get_rate_limiter()
|
||||
tiff_range = tuple(tiff_page_range) if tiff_page_range else None
|
||||
|
||||
@@ -72,6 +93,14 @@ async def process_document(
|
||||
logger.warning("Tarea %s no encontrada — saltando %s", task_id, filename)
|
||||
return {"skipped": True, "reason": "task_not_found"}
|
||||
|
||||
service = _service_for_request(ctx, request)
|
||||
effective_model = getattr(getattr(service, "extractor", None), "model", "?")
|
||||
logger.info(
|
||||
"Modelo resuelto para task=%s: %s",
|
||||
task_id,
|
||||
effective_model,
|
||||
)
|
||||
|
||||
fields: list[str] = list(request.get("fields") or [])
|
||||
alias_map: dict[str, str] = dict(request.get("alias_map") or {})
|
||||
structure_mode: str = str(request.get("structure_mode") or "radicados")
|
||||
|
||||
Reference in New Issue
Block a user