Refactor digitador bot and add runtime monitoring
This commit is contained in:
@@ -8,6 +8,8 @@ from domain.normalizer import normalize_to_schema
|
||||
from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
from domain.service import InvoiceProcessingService
|
||||
from domain.config import settings
|
||||
from domain.usage_tracking import record_api_usage
|
||||
from infrastructure.database import ExtractionRow, Task, utcnow
|
||||
from infrastructure.serialization import flatten_extraction_result
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -276,7 +278,7 @@ def process_desktop_ingest(
|
||||
else:
|
||||
prompt = instruction or PromptBuilder.build(selected_paths, {}, profile_name=prof.get("name", "invoice"))
|
||||
|
||||
data, _usage = service.extract_full_from_pdf(
|
||||
data, extraction_usage = service.extract_full_from_pdf(
|
||||
temp_path,
|
||||
instruction=prompt,
|
||||
tiff_page_range=tiff_page_range,
|
||||
@@ -292,8 +294,28 @@ def process_desktop_ingest(
|
||||
profile_name=prof.get("name", "invoice"),
|
||||
)
|
||||
normalized_obj = data
|
||||
normalizer_usage = None
|
||||
if isinstance(norm, dict):
|
||||
normalized_obj = norm.get("normalized") or data
|
||||
normalizer_usage = norm.get("_usage")
|
||||
|
||||
usage_result = record_api_usage(
|
||||
dict(task.result or {}),
|
||||
phase="extract",
|
||||
model=str(getattr(service.extractor, "model", model or "unknown")),
|
||||
usage=extraction_usage,
|
||||
radicado=radicado,
|
||||
filename=filename,
|
||||
)
|
||||
usage_result = record_api_usage(
|
||||
usage_result,
|
||||
phase="normalize",
|
||||
model=str(settings.api.normalizer_model),
|
||||
usage=normalizer_usage,
|
||||
radicado=radicado,
|
||||
filename=filename,
|
||||
)
|
||||
task.result = usage_result
|
||||
|
||||
return persist_desktop_ingest_result(
|
||||
session,
|
||||
|
||||
@@ -228,6 +228,28 @@ def serialize_task(task: Task) -> dict[str, Any]:
|
||||
|
||||
|
||||
def persist_extraction_request(request: ExtractionRequest) -> dict[str, Any]:
|
||||
from domain.usage_tracking import record_api_usage
|
||||
|
||||
usage_result: dict[str, Any] = {}
|
||||
|
||||
def capture_usage(
|
||||
phase: str,
|
||||
radicado: str,
|
||||
usage: dict[str, Any] | None,
|
||||
_cost: dict[str, Any] | None,
|
||||
) -> None:
|
||||
nonlocal usage_result
|
||||
model = request.model or settings.api.extractor_model
|
||||
if phase == "normalize":
|
||||
model = settings.api.normalizer_model
|
||||
usage_result = record_api_usage(
|
||||
usage_result,
|
||||
phase=phase,
|
||||
model=str(model),
|
||||
usage=usage,
|
||||
radicado=radicado,
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
batch = ExtractionBatch(
|
||||
status="running",
|
||||
@@ -252,6 +274,7 @@ def persist_extraction_request(request: ExtractionRequest) -> dict[str, Any]:
|
||||
structure_mode=request.structure_mode,
|
||||
retry_on_empty=request.retry_on_empty,
|
||||
radicado_filter=request.radicado_filter,
|
||||
on_usage=capture_usage,
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
@@ -279,6 +302,7 @@ def persist_extraction_request(request: ExtractionRequest) -> dict[str, Any]:
|
||||
session.flush()
|
||||
serialized = serialize_batch(batch)
|
||||
serialized["row_count"] = row_index
|
||||
serialized["api_usage"] = usage_result.get("api_usage", {})
|
||||
return serialized
|
||||
except Exception as exc:
|
||||
with session_scope() as session:
|
||||
@@ -312,6 +336,7 @@ def run_extraction_task(task_id: str) -> None:
|
||||
"extraction_batch_id": batch["id"],
|
||||
"row_count": batch.get("row_count", 0),
|
||||
"batch": batch,
|
||||
"api_usage": batch.get("api_usage", {}),
|
||||
}
|
||||
task.error = None
|
||||
task.finished_at = utcnow()
|
||||
@@ -620,6 +645,8 @@ async def _process_sync(
|
||||
from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
from domain.normalizer import normalize_to_schema
|
||||
from domain.config import settings as _settings
|
||||
from domain.usage_tracking import record_api_usage
|
||||
|
||||
_log = _logging.getLogger("extraction.ingest")
|
||||
|
||||
@@ -656,13 +683,13 @@ async def _process_sync(
|
||||
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(
|
||||
data, extraction_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", "?")
|
||||
tok_in = (extraction_usage or {}).get("prompt_tokens", "?")
|
||||
tok_out = (extraction_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
|
||||
@@ -670,11 +697,13 @@ async def _process_sync(
|
||||
data = {"radicado": radicado, "data": data}
|
||||
norm = normalize_to_schema(data, paths=selected_paths, profile_name=prof.get("name", "invoice"))
|
||||
normalized_obj = data
|
||||
normalizer_usage = None
|
||||
if isinstance(norm, dict):
|
||||
normalized_obj = norm.get("normalized") or data
|
||||
return normalized_obj, selected_paths
|
||||
normalizer_usage = norm.get("_usage")
|
||||
return normalized_obj, selected_paths, extraction_usage, normalizer_usage, model_name
|
||||
|
||||
normalized_obj, selected_paths = await _asyncio.to_thread(_call_llm)
|
||||
normalized_obj, selected_paths, extraction_usage, normalizer_usage, model_name = await _asyncio.to_thread(_call_llm)
|
||||
|
||||
# ── Fase 2: persistir resultado (con FOR UPDATE para serializar) ───
|
||||
def _persist():
|
||||
@@ -682,6 +711,14 @@ async def _process_sync(
|
||||
task = session.get(Task, task_id, with_for_update=True)
|
||||
if task is None:
|
||||
return {"error": "task_not_found"}
|
||||
task.result = record_api_usage(
|
||||
dict(task.result or {}), phase="extract", model=str(model_name),
|
||||
usage=extraction_usage, radicado=radicado, filename=effective_name,
|
||||
)
|
||||
task.result = record_api_usage(
|
||||
dict(task.result or {}), phase="normalize", model=str(_settings.api.normalizer_model),
|
||||
usage=normalizer_usage, radicado=radicado, filename=effective_name,
|
||||
)
|
||||
payload = persist_desktop_ingest_result(
|
||||
session,
|
||||
task=task,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Persistencia compacta del uso real de APIs LLM dentro del resultado de una tarea."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from domain.costs import MODEL_PRICING, compute_cost_from_usage
|
||||
|
||||
|
||||
def _number(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _tokens(usage: dict[str, Any], key: str) -> int:
|
||||
try:
|
||||
return int(usage.get(key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def record_api_usage(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
phase: str,
|
||||
model: str,
|
||||
usage: dict[str, Any] | None,
|
||||
radicado: str | None = None,
|
||||
filename: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Agrega una llamada facturable a ``result.api_usage`` y devuelve una copia.
|
||||
|
||||
El proveedor es la fuente de los tokens. El costo se calcula con la tabla de
|
||||
precios configurada para el modelo y se conserva separado por fase y llamada.
|
||||
"""
|
||||
if not isinstance(usage, dict):
|
||||
return dict(result)
|
||||
|
||||
next_result = dict(result)
|
||||
ledger = dict(next_result.get("api_usage") or {})
|
||||
summary = dict(ledger.get("summary") or {})
|
||||
phases = dict(ledger.get("phases") or {})
|
||||
phase_summary = dict(phases.get(phase) or {})
|
||||
details = list(ledger.get("details") or [])
|
||||
|
||||
prompt_tokens = _tokens(usage, "prompt_tokens")
|
||||
completion_tokens = _tokens(usage, "completion_tokens")
|
||||
token_details = usage.get("prompt_tokens_details") or {}
|
||||
cached_tokens = _tokens(token_details, "cached_tokens") if isinstance(token_details, dict) else 0
|
||||
total_tokens = _tokens(usage, "total_tokens") or prompt_tokens + completion_tokens
|
||||
cost = compute_cost_from_usage(usage, phase=phase, model=model) or {}
|
||||
|
||||
values: dict[str, int | float] = {
|
||||
"requests": 1,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"cached_tokens": cached_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"input_usd": _number(cost.get("input_usd")),
|
||||
"cached_input_usd": _number(cost.get("cached_input_usd")),
|
||||
"output_usd": _number(cost.get("output_usd")),
|
||||
"total_cost_usd": _number(cost.get("total_usd")),
|
||||
"unpriced_requests": 0 if model in MODEL_PRICING else 1,
|
||||
}
|
||||
for key, value in values.items():
|
||||
precision = 6 if key.endswith("usd") else None
|
||||
summary[key] = round(_number(summary.get(key)) + value, precision) if precision else int(_number(summary.get(key)) + value)
|
||||
phase_summary[key] = round(_number(phase_summary.get(key)) + value, precision) if precision else int(_number(phase_summary.get(key)) + value)
|
||||
|
||||
phase_summary["model"] = model
|
||||
phases[phase] = phase_summary
|
||||
details.append({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"phase": phase,
|
||||
"model": model,
|
||||
"radicado": radicado,
|
||||
"filename": filename,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"cached_tokens": cached_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"cost": cost,
|
||||
"pricing_known": model in MODEL_PRICING,
|
||||
})
|
||||
|
||||
ledger.update({"summary": summary, "phases": phases, "details": details})
|
||||
next_result["api_usage"] = ledger
|
||||
return next_result
|
||||
@@ -0,0 +1,51 @@
|
||||
from domain.usage_tracking import record_api_usage
|
||||
|
||||
|
||||
def test_record_api_usage_aggregates_summary_phases_and_details():
|
||||
result = record_api_usage(
|
||||
{},
|
||||
phase="extract",
|
||||
model="gpt-5.6-luna",
|
||||
usage={
|
||||
"prompt_tokens": 1_000,
|
||||
"completion_tokens": 200,
|
||||
"total_tokens": 1_200,
|
||||
"prompt_tokens_details": {"cached_tokens": 100},
|
||||
},
|
||||
radicado="RAD-1",
|
||||
filename="factura.pdf",
|
||||
)
|
||||
result = record_api_usage(
|
||||
result,
|
||||
phase="extract",
|
||||
model="gpt-5.6-luna",
|
||||
usage={"prompt_tokens": 500, "completion_tokens": 50},
|
||||
radicado="RAD-2",
|
||||
)
|
||||
|
||||
ledger = result["api_usage"]
|
||||
assert ledger["summary"]["requests"] == 2
|
||||
assert ledger["summary"]["prompt_tokens"] == 1_500
|
||||
assert ledger["summary"]["completion_tokens"] == 250
|
||||
assert ledger["summary"]["total_tokens"] == 1_750
|
||||
assert ledger["summary"]["unpriced_requests"] == 0
|
||||
assert ledger["phases"]["extract"]["requests"] == 2
|
||||
assert ledger["details"][0]["filename"] == "factura.pdf"
|
||||
assert ledger["details"][0]["pricing_known"] is True
|
||||
|
||||
|
||||
def test_record_api_usage_marks_unknown_model_as_unpriced():
|
||||
result = record_api_usage(
|
||||
{},
|
||||
phase="extract",
|
||||
model="provider/model-without-price",
|
||||
usage={"prompt_tokens": 100, "completion_tokens": 10},
|
||||
)
|
||||
|
||||
assert result["api_usage"]["summary"]["unpriced_requests"] == 1
|
||||
assert result["api_usage"]["details"][0]["pricing_known"] is False
|
||||
|
||||
|
||||
def test_record_api_usage_ignores_missing_usage():
|
||||
original = {"processed": 1}
|
||||
assert record_api_usage(original, phase="extract", model="gpt-5.6-luna", usage=None) == original
|
||||
@@ -24,6 +24,8 @@ from domain.profiles import get_profile
|
||||
from domain.prompts import PromptBuilder
|
||||
from domain.normalizer import normalize_to_schema
|
||||
from domain.rate_limiter import get_rate_limiter
|
||||
from domain.config import settings
|
||||
from domain.usage_tracking import record_api_usage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -131,8 +133,8 @@ async def process_document(
|
||||
await rate_limiter.acquire(estimated_tokens=estimated_tokens)
|
||||
|
||||
# Extracción + normalización (CPU + IO bound, en thread pool)
|
||||
def _extract_and_normalize() -> tuple[dict, dict]:
|
||||
data, _usage = service.extract_full_from_pdf(
|
||||
def _extract_and_normalize() -> tuple[dict, dict, dict | None, dict | None]:
|
||||
data, extraction_usage = service.extract_full_from_pdf(
|
||||
queue_path,
|
||||
instruction=prompt,
|
||||
tiff_page_range=tiff_range,
|
||||
@@ -149,11 +151,13 @@ async def process_document(
|
||||
profile_name=prof.get("name", "invoice"),
|
||||
)
|
||||
normalized_obj = data
|
||||
normalizer_usage = None
|
||||
if isinstance(norm, dict):
|
||||
normalized_obj = norm.get("normalized") or data
|
||||
return raw_data, normalized_obj
|
||||
normalizer_usage = norm.get("_usage")
|
||||
return raw_data, normalized_obj, extraction_usage, normalizer_usage
|
||||
|
||||
_raw, normalized_obj = await asyncio.to_thread(_extract_and_normalize)
|
||||
_raw, normalized_obj, extraction_usage, normalizer_usage = await asyncio.to_thread(_extract_and_normalize)
|
||||
|
||||
# Persistir resultado en BD
|
||||
def _persist() -> dict[str, Any]:
|
||||
@@ -164,6 +168,15 @@ async def process_document(
|
||||
return {"skipped": True, "reason": "task_not_found"}
|
||||
|
||||
result = dict(task.result or {})
|
||||
result = record_api_usage(
|
||||
result, phase="extract", model=str(effective_model), usage=extraction_usage,
|
||||
radicado=radicado, filename=filename,
|
||||
)
|
||||
result = record_api_usage(
|
||||
result, phase="normalize", model=str(settings.api.normalizer_model), usage=normalizer_usage,
|
||||
radicado=radicado, filename=filename,
|
||||
)
|
||||
task.result = result
|
||||
|
||||
if structure_mode == "radicados" and doc_types and doc_type:
|
||||
partials = dict(result.get("partials") or {})
|
||||
|
||||
Reference in New Issue
Block a user