Refactor digitador bot and add runtime monitoring

This commit is contained in:
erik
2026-08-30 14:02:58 -05:00
parent 3b01456875
commit d9e2012e22
46 changed files with 1720 additions and 248 deletions
+22 -3
View File
@@ -560,10 +560,29 @@ def post_event(
metadata=body.metadata,
timestamp=body.timestamp,
)
if body.index is not None and body.total is not None:
overlay_keys = {
"estado",
"ultimo",
"processed",
"exitosos",
"errores",
"omitidos",
"existentes",
"reinicios",
"radicado_actual",
"elapsed_seconds",
"speed_per_minute",
}
has_overlay = any(key in body.metadata for key in overlay_keys)
if (body.index is not None and body.total is not None) or has_overlay:
result = dict(job.result or {})
result["processed"] = body.index
result["total"] = body.total
if body.index is not None and body.total is not None:
result["total"] = body.total
for key in overlay_keys:
if key in body.metadata:
result[key] = body.metadata[key]
if "processed" not in body.metadata and body.index is not None:
result["processed"] = body.index
if body.nit:
result["nit_actual"] = body.nit
store.patch_job(digitador_job_id, result=result)
@@ -561,6 +561,9 @@ class JobStore:
timeouts = 0
existentes = 0
uploaded = 0
reinicios = 0
elapsed_seconds = 0
speed_per_minute = 0.0
for c in children:
r = dict(c.result or {})
total += int(r.get("total") or 0)
@@ -572,6 +575,9 @@ class JobStore:
timeouts += int(r.get("timeouts") or r.get("timeout") or 0)
existentes += int(r.get("existentes") or 0)
uploaded += int(r.get("uploaded_count") or 0)
reinicios += int(r.get("reinicios") or 0)
elapsed_seconds = max(elapsed_seconds, int(r.get("elapsed_seconds") or 0))
speed_per_minute += float(r.get("speed_per_minute") or 0)
processed = 0
for c in children:
@@ -605,6 +611,9 @@ class JobStore:
"existentes": existentes,
"processed": processed,
"uploaded_count": uploaded,
"reinicios": reinicios,
"elapsed_seconds": elapsed_seconds,
"speed_per_minute": round(speed_per_minute, 2),
"reports_prefix": parent.reports_prefix,
"shards": [
{
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
from types import SimpleNamespace
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
from api import main
def test_post_event_persists_live_overlay_in_job_result(monkeypatch):
patched: list[dict] = []
job = SimpleNamespace(result={"total": 10})
event = SimpleNamespace(to_dict=lambda: {"id": 1})
fake_store = SimpleNamespace(
get_job=lambda _job_id: job,
add_event=lambda *_args, **_kwargs: event,
patch_job=lambda _job_id, **kwargs: patched.append(kwargs) or job,
)
monkeypatch.setattr(main, "store", fake_store)
response = main.post_event(
"job-1",
main.EventBody(
event_type="radicado_processing",
message="Procesando",
index=4,
total=10,
metadata={
"estado": "Procesando",
"processed": 3,
"reinicios": 1,
"radicado_actual": "RAD-4",
"elapsed_seconds": 90,
"speed_per_minute": 2.0,
},
),
_="token",
)
assert response == {"id": 1}
result = patched[0]["result"]
assert result["processed"] == 3
assert result["total"] == 10
assert result["radicado_actual"] == "RAD-4"
assert result["reinicios"] == 1
+7 -1
View File
@@ -46,7 +46,7 @@ def test_distributed_parent_uses_agent_counters_and_live_progress(store):
result={"total": 4, "processed": 2},
status=JobStatus.RUNNING.value,
)
second = job_store.create_job(
_second = job_store.create_job(
"PC-02",
"uploads/second/input.csv",
parent_digitador_job_id=parent.id,
@@ -72,6 +72,9 @@ def test_distributed_parent_uses_agent_counters_and_live_progress(store):
"timeouts": 1,
"existentes": 1,
"uploaded_count": 3,
"reinicios": 2,
"elapsed_seconds": 90,
"speed_per_minute": 1.5,
},
)
live = job_store.refresh_parent_from_shards(parent.id)
@@ -82,3 +85,6 @@ def test_distributed_parent_uses_agent_counters_and_live_progress(store):
assert live.result["existentes"] == 1
assert live.result["timeouts"] == 1
assert live.result["uploaded_count"] == 3
assert live.result["reinicios"] == 2
assert live.result["elapsed_seconds"] == 90
assert live.result["speed_per_minute"] == 1.5
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ipsextractor-desktop",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Aplicación de escritorio Flujo IPS para automatizar procesos de cuentas médicas.",
"main": "electron/main.cjs",
@@ -48,4 +48,35 @@ describe("App", () => {
expect(screen.getByRole("heading", { name: /limpieza.*cop/i })).toBeInTheDocument();
expect(screen.getByLabelText("2024")).toBeInTheDocument();
});
it("offers the digitador package download inside an open job", async () => {
const user = userEvent.setup();
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = String(input);
const body = url.endsWith("/jobs")
? [{
id: "job-1",
status: "pending",
title: "Trabajo prueba",
metadata: {},
created_at: "2026-08-30T12:00:00Z",
updated_at: "2026-08-30T12:00:00Z",
}]
: [];
return { ok: true, text: async () => JSON.stringify(body) } as Response;
});
render(<App />);
await user.click(await screen.findByText("Trabajo prueba"));
const digitadorMenu = screen
.getAllByRole("button", { name: /bot digitador/i })
.find((button) => button.hasAttribute("aria-expanded"));
expect(digitadorMenu).toBeDefined();
await user.click(digitadorMenu!);
expect(screen.getByRole("button", { name: /descargar bot/i })).toHaveAttribute(
"title",
"Descargar el paquete del bot digitador",
);
});
});
+74 -7
View File
@@ -6,6 +6,7 @@ import {
Download,
Hash,
Keyboard,
Coins,
Link2,
Package,
Settings,
@@ -38,8 +39,10 @@ import "./modules/poblacion_apta/PoblacionAptaModule";
import "./modules/downloads/DownloadsModule";
import "./modules/digitador/DigitadorModule";
const BOT_PACKAGE_URL =
const DOWNLOAD_BOT_PACKAGE_URL =
"http://s3.builker.xyz/apps-versions/BotDescargaPYSCuentasMedicas/latest/bot_descarga.zip";
const DIGITADOR_BOT_PACKAGE_URL =
"http://s3.builker.xyz/apps-versions/BotDigitadorPYSCuentasMedicas/latest/bot_digitador.zip";
type Status = "idle" | "ok" | "error" | "busy";
type View = "home" | "job" | "settings";
@@ -100,8 +103,11 @@ function App() {
const [busy, setBusy] = useState(false);
const [releaseDate, setReleaseDate] = useState(APP_RELEASE_DATE);
const [downloadsMenuOpen, setDownloadsMenuOpen] = useState(true);
const [digitadorMenuOpen, setDigitadorMenuOpen] = useState(false);
const [botDownloadBusy, setBotDownloadBusy] = useState(false);
const [botDownloadError, setBotDownloadError] = useState<string | null>(null);
const [digitadorDownloadBusy, setDigitadorDownloadBusy] = useState(false);
const [digitadorDownloadError, setDigitadorDownloadError] = useState<string | null>(null);
const update = useAutoUpdateCheck();
useEffect(() => {
@@ -208,6 +214,7 @@ function App() {
setActiveJob(job);
setJobSection("downloads");
setDownloadsMenuOpen(true);
setDigitadorMenuOpen(false);
setJobSectionContext({});
setView("job");
}
@@ -216,6 +223,7 @@ function App() {
setJobSection(section);
setJobSectionContext(context);
setDownloadsMenuOpen(section === "downloads");
setDigitadorMenuOpen(section === "digitador");
}
async function downloadBotPackage() {
@@ -224,11 +232,11 @@ function App() {
try {
const api = window.desktopApi;
if (typeof api?.downloadUrl === "function") {
await api.downloadUrl(BOT_PACKAGE_URL);
await api.downloadUrl(DOWNLOAD_BOT_PACKAGE_URL);
return;
}
const a = document.createElement("a");
a.href = BOT_PACKAGE_URL;
a.href = DOWNLOAD_BOT_PACKAGE_URL;
a.download = "bot_descarga.zip";
a.rel = "noopener noreferrer";
a.target = "_blank";
@@ -242,6 +250,30 @@ function App() {
}
}
async function downloadDigitadorPackage() {
setDigitadorDownloadBusy(true);
setDigitadorDownloadError(null);
try {
const api = window.desktopApi;
if (typeof api?.downloadUrl === "function") {
await api.downloadUrl(DIGITADOR_BOT_PACKAGE_URL);
return;
}
const a = document.createElement("a");
a.href = DIGITADOR_BOT_PACKAGE_URL;
a.download = "bot_digitador.zip";
a.rel = "noopener noreferrer";
a.target = "_blank";
document.body.appendChild(a);
a.click();
a.remove();
} catch (err) {
setDigitadorDownloadError(err instanceof Error ? err.message : "No se pudo iniciar la descarga del bot digitador.");
} finally {
setDigitadorDownloadBusy(false);
}
}
function openSettings(section: SettingsSection = "connection") {
setSettingsReturnView(view === "job" ? "job" : "home");
setSettingsSection(section);
@@ -405,13 +437,48 @@ function App() {
<Users size={17} />
<span>Población apta</span>
</button>
<div className="side-nav-group">
<button
type="button"
className={jobSection === "digitador" ? "active" : ""}
aria-expanded={digitadorMenuOpen}
onClick={() => {
if (jobSection === "digitador") {
setDigitadorMenuOpen((open) => !open);
} else {
handleActivateSection("digitador");
}
}}
>
<Keyboard size={17} />
<span>Bot digitador</span>
<ChevronDown
size={14}
className={`side-nav-chevron${digitadorMenuOpen ? " side-nav-chevron--open" : ""}`}
aria-hidden
/>
</button>
{digitadorMenuOpen && (
<div className="side-nav-sub" role="group" aria-label="Opciones del bot digitador">
<button
type="button"
disabled={digitadorDownloadBusy}
title={digitadorDownloadError || "Descargar el paquete del bot digitador"}
onClick={() => void downloadDigitadorPackage()}
>
<Package size={15} />
<span>{digitadorDownloadBusy ? "Descargando…" : "Descargar bot"}</span>
</button>
</div>
)}
</div>
<button
type="button"
className={jobSection === "digitador" ? "active" : ""}
onClick={() => handleActivateSection("digitador")}
className={jobSection === "costs" ? "active" : ""}
onClick={() => handleActivateSection("costs")}
>
<Keyboard size={17} />
<span>Bot digitador</span>
<Coins size={17} />
<span>Costos</span>
</button>
<button
type="button"
@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import { DigitadorLiveMetrics } from "./DigitadorLiveMetrics";
describe("DigitadorLiveMetrics", () => {
it("renders the remote equivalent of the local overlay", () => {
render(
<DigitadorLiveMetrics
events={[{
id: 1,
job_id: "job-1",
event_type: "radicado_processing",
message: "Procesando",
timestamp: "2026-08-30T12:00:00Z",
metadata: {
estado: "Procesando",
radicado_actual: "RAD-123",
reinicios: 2,
speed_per_minute: 3.5,
elapsed_seconds: 3661,
ultimo: "Registro 3/10",
},
}]}
/>,
);
expect(screen.getByText("RAD-123")).toBeInTheDocument();
expect(screen.getByText("3.5 rad/min")).toBeInTheDocument();
expect(screen.getByText("01:01:01")).toBeInTheDocument();
expect(screen.getByText("Registro 3/10")).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import type { DigitadorJobEvent } from "../../../shared/types";
type Props = { events: DigitadorJobEvent[] };
function value(metadata: Record<string, unknown>, key: string, fallback: string | number = "—") {
const raw = metadata[key];
return raw === undefined || raw === null || raw === "" ? String(fallback) : String(raw);
}
function duration(secondsValue: unknown): string {
const total = Math.max(0, Number(secondsValue) || 0);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = Math.floor(total % 60);
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
}
export function DigitadorLiveMetrics({ events }: Props) {
const latest = [...events].reverse().find((event) => {
const metadata = event.metadata ?? {};
return "estado" in metadata || "radicado_actual" in metadata;
});
if (!latest) return null;
const metadata = latest.metadata ?? {};
return (
<div className="digitador-live-metrics" aria-label="Estado en vivo del bot digitador">
<div><span>Estado</span><strong>{value(metadata, "estado", latest.event_type)}</strong></div>
<div><span>Radicado</span><strong title={value(metadata, "radicado_actual")}>{value(metadata, "radicado_actual")}</strong></div>
<div><span>Reinicios</span><strong>{value(metadata, "reinicios", 0)}</strong></div>
<div><span>Velocidad</span><strong>{value(metadata, "speed_per_minute", 0)} rad/min</strong></div>
<div><span>Tiempo</span><strong>{duration(metadata.elapsed_seconds)}</strong></div>
<div><span>Último evento</span><strong title={value(metadata, "ultimo", latest.message)}>{value(metadata, "ultimo", latest.message)}</strong></div>
</div>
);
}
@@ -17,6 +17,7 @@ import type {
} from "../../../shared/types";
import type { ApiClient } from "../../lib/apiClient";
import { ParallelBotProgress } from "../../components/ParallelBotProgress";
import { DigitadorLiveMetrics } from "./DigitadorLiveMetrics";
declare const desktopApi: import("../../../shared/types").DesktopBridge | undefined;
@@ -284,6 +285,8 @@ export function DigitadorResultsPanel({ client, job, tasks, onStartDigitador }:
</div>
</div>
<DigitadorLiveMetrics events={events} />
<p className="wiz-hint" style={{ marginBottom: 12, flexShrink: 0 }}>
{selected.shard_count && Number(selected.shard_count) > 1
? `${selected.shard_count} bots (distribuido)`
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApiClient } from "../../lib/apiClient";
import { formatDateTime } from "../../lib/time";
import { ParallelBotProgress } from "../../components/ParallelBotProgress";
import { DigitadorLiveMetrics } from "./DigitadorLiveMetrics";
import type {
DigitadorJob,
DigitadorJobEvent,
@@ -627,6 +628,8 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
</div>
</div>
<DigitadorLiveMetrics events={events} />
<ParallelBotProgress shards={shards} unit="filas" />
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
@@ -5635,3 +5635,65 @@ th {
margin-left: 28px !important;
margin-right: 28px !important;
}
/* ── Costos de API por trabajo ── */
.job-costs-view {
box-sizing: border-box;
height: 100%;
overflow: auto;
padding: 22px 28px 32px;
}
.job-costs-toolbar { align-items: flex-start; display: flex; gap: 20px; justify-content: space-between; }
.job-costs-toolbar h2 { font-size: 20px; margin: 0 0 4px; }
.job-costs-toolbar p { color: var(--text-muted); font-size: 13px; margin: 0; }
.job-costs-summary { display: grid; gap: 12px; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 20px 0; }
.job-costs-summary > div { background: var(--surface, #fff); border: 1px solid var(--border); border-radius: 10px; display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; }
.job-costs-summary span { color: var(--text-muted); font-size: 12px; }
.job-costs-summary strong { font-size: 20px; }
.job-cost-warning { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; color: #92400e; font-size: 12px; margin: -8px 0 16px; padding: 9px 12px; }
.job-costs-list { display: flex; flex-direction: column; gap: 10px; }
.job-cost-card { background: var(--surface, #fff); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.job-cost-card > summary { align-items: center; cursor: pointer; display: grid; gap: 16px; grid-template-columns: minmax(220px, 1.5fr) auto auto minmax(100px, auto); list-style-position: inside; padding: 14px 16px; }
.job-cost-process { display: inline-flex; flex-direction: column; min-width: 0; }
.job-cost-process span { color: var(--text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-cost-api-badge { background: #f1f5f9; border-radius: 999px; color: #64748b; font-size: 11px; padding: 4px 8px; }
.job-cost-api-badge.has-usage { background: #dcfce7; color: #15803d; }
.job-cost-total { color: #0f766e; text-align: right; }
.job-cost-detail { border-top: 1px solid var(--border); padding: 12px 16px 16px; }
.job-cost-table { border-collapse: collapse; font-size: 12px; width: 100%; }
.job-cost-table th, .job-cost-table td { border-bottom: 1px solid var(--border); padding: 9px 10px; text-align: right; }
.job-cost-table th:first-child, .job-cost-table td:first-child { text-align: left; }
.job-cost-table td small { color: var(--text-muted); display: block; font-size: 10px; margin-top: 2px; }
.job-cost-calls { margin-top: 14px; }
.job-cost-calls > summary { color: #2563eb; cursor: pointer; font-size: 12px; font-weight: 600; }
.job-cost-calls-scroll { margin-top: 8px; max-height: 340px; overflow: auto; }
.job-cost-no-api, .job-costs-empty { color: var(--text-muted); font-size: 13px; }
.job-costs-empty { align-items: center; border: 1px dashed var(--border); border-radius: 10px; display: flex; flex-direction: column; padding: 40px; }
@media (max-width: 900px) {
.job-costs-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.job-cost-card > summary { grid-template-columns: 1fr auto; }
.job-cost-card > summary > span:not(.job-cost-api-badge) { display: none; }
}
.digitador-live-metrics {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 4px 0 16px;
width: 100%;
}
.digitador-live-metrics > div {
background: var(--surface, #fff);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 9px 11px;
}
.digitador-live-metrics span { color: var(--text-muted); font-size: 10px; text-transform: uppercase; }
.digitador-live-metrics strong { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -5,4 +5,5 @@ export type JobSection =
| "poblacion_apta"
| "downloads"
| "digitador"
| "costs"
| "results";
@@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import type { Task } from "../../shared/types";
import { JobCostsView } from "./JobCostsView";
function task(result: Task["result"]): Task {
return {
id: "task-1",
type: "extraction",
status: "completed",
title: "Extracción facturas",
request: {},
result,
error: null,
owner_id: null,
job_id: "job-1",
created_at: "2026-08-30T12:00:00Z",
updated_at: "2026-08-30T12:01:00Z",
started_at: "2026-08-30T12:00:00Z",
finished_at: "2026-08-30T12:01:00Z",
};
}
describe("JobCostsView", () => {
it("shows real API usage totals for a process", () => {
render(
<JobCostsView
refreshing={false}
onRefresh={() => undefined}
tasks={[task({
api_usage: {
summary: { requests: 2, total_tokens: 1250, total_cost_usd: 0.00125 },
phases: {
extract: { requests: 2, prompt_tokens: 1000, completion_tokens: 250, total_cost_usd: 0.00125, model: "gpt-5.6-luna" },
},
},
})]}
/>,
);
expect(screen.getByText("Extracción facturas")).toBeInTheDocument();
expect(screen.getByText("2 llamadas")).toBeInTheDocument();
expect(screen.getAllByText("1.250").length).toBeGreaterThan(0);
expect(screen.getAllByText("$0.00125").length).toBeGreaterThan(0);
});
it("shows processes that did not consume a priced API", () => {
render(<JobCostsView refreshing={false} onRefresh={() => undefined} tasks={[task({})]} />);
expect(screen.getByText("Sin consumo API")).toBeInTheDocument();
});
});
@@ -0,0 +1,190 @@
import { Coins, RefreshCw } from "lucide-react";
import type { Task } from "../../shared/types";
type UsageSummary = {
requests?: number;
prompt_tokens?: number;
cached_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
input_usd?: number;
cached_input_usd?: number;
output_usd?: number;
total_cost_usd?: number;
unpriced_requests?: number;
model?: string;
};
type UsageDetail = UsageSummary & {
timestamp?: string;
phase?: string;
model?: string;
radicado?: string | null;
filename?: string | null;
cost?: { total_usd?: number };
pricing_known?: boolean;
};
type ApiUsage = {
summary?: UsageSummary;
phases?: Record<string, UsageSummary>;
details?: UsageDetail[];
};
interface Props {
tasks: Task[];
refreshing: boolean;
onRefresh: () => void;
}
const PROCESS_NAMES: Record<string, string> = {
counting: "Conteo",
extraction: "Extracción",
cleaning: "Limpieza",
poblacion_apta: "Población apta",
downloads: "Bot de descarga",
digitador: "Bot digitador",
};
function usageOf(task: Task): ApiUsage {
const value = task.result?.api_usage;
return value && typeof value === "object" ? value as ApiUsage : {};
}
function n(value: unknown): number {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
function usd(value: unknown): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 4,
maximumFractionDigits: 6,
}).format(n(value));
}
function integer(value: unknown): string {
return new Intl.NumberFormat("es-CO").format(n(value));
}
function phaseName(phase: string): string {
if (phase === "extract") return "Extracción LLM";
if (phase === "normalize") return "Normalización LLM";
return phase;
}
export function JobCostsView({ tasks, refreshing, onRefresh }: Props) {
const ordered = [...tasks].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const summaries = ordered.map((task) => usageOf(task).summary ?? {});
const totalCost = summaries.reduce((sum, item) => sum + n(item.total_cost_usd), 0);
const totalTokens = summaries.reduce((sum, item) => sum + n(item.total_tokens), 0);
const totalRequests = summaries.reduce((sum, item) => sum + n(item.requests), 0);
const unpricedRequests = summaries.reduce((sum, item) => sum + n(item.unpriced_requests), 0);
return (
<div className="job-costs-view">
<div className="job-costs-toolbar">
<div>
<h2>Costos de API por proceso</h2>
<p>Uso real reportado por el proveedor. Los valores históricos conservan la tarifa aplicada al procesar.</p>
</div>
<button type="button" className="btn btn-sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
Actualizar
</button>
</div>
<div className="job-costs-summary">
<div><span>Costo total</span><strong>{usd(totalCost)}</strong></div>
<div><span>Tokens</span><strong>{integer(totalTokens)}</strong></div>
<div><span>Llamadas API</span><strong>{integer(totalRequests)}</strong></div>
<div><span>Procesos</span><strong>{integer(tasks.length)}</strong></div>
</div>
{unpricedRequests > 0 && (
<div className="job-cost-warning">
{integer(unpricedRequests)} llamadas tienen un modelo sin tarifa configurada; sus tokens se muestran, pero no se suman al costo.
</div>
)}
{ordered.length === 0 ? (
<div className="job-costs-empty"><Coins size={28} /><p>Aún no hay procesos en este trabajo.</p></div>
) : (
<div className="job-costs-list">
{ordered.map((task) => {
const apiUsage = usageOf(task);
const summary = apiUsage.summary ?? {};
const phases = Object.entries(apiUsage.phases ?? {});
const details = apiUsage.details ?? [];
const hasUsage = n(summary.requests) > 0;
return (
<details className="job-cost-card" key={task.id}>
<summary>
<div className="job-cost-process">
<strong>{PROCESS_NAMES[task.type] ?? task.type}</strong>
<span>{task.title || `Proceso ${task.id.slice(0, 8)}`}</span>
</div>
<span className={`job-cost-api-badge${hasUsage ? " has-usage" : ""}`}>
{hasUsage ? `${integer(summary.requests)} llamadas` : "Sin consumo API"}
</span>
<span>{integer(summary.total_tokens)} tokens</span>
<strong className="job-cost-total">{usd(summary.total_cost_usd)}</strong>
</summary>
<div className="job-cost-detail">
{hasUsage ? (
<>
<table className="job-cost-table">
<thead><tr><th>Fase / modelo</th><th>Llamadas</th><th>Entrada</th><th>En caché</th><th>Salida</th><th>Costo</th></tr></thead>
<tbody>
{phases.map(([phase, item]) => (
<tr key={phase}>
<td><strong>{phaseName(phase)}</strong><small>{item.model || "Modelo no informado"}</small></td>
<td>{integer(item.requests)}</td>
<td>{integer(item.prompt_tokens)}</td>
<td>{integer(item.cached_tokens)}</td>
<td>{integer(item.completion_tokens)}</td>
<td>{usd(item.total_cost_usd)}</td>
</tr>
))}
</tbody>
</table>
{details.length > 0 && (
<details className="job-cost-calls">
<summary>Ver detalle de {details.length} llamadas</summary>
<div className="job-cost-calls-scroll">
<table className="job-cost-table">
<thead><tr><th>Documento</th><th>Fase</th><th>Modelo</th><th>Entrada</th><th>Caché</th><th>Salida</th><th>Costo</th></tr></thead>
<tbody>
{details.map((item, index) => (
<tr key={`${item.timestamp ?? "call"}-${index}`}>
<td>{item.radicado || item.filename || "—"}<small>{item.radicado && item.filename ? item.filename : ""}</small></td>
<td>{phaseName(item.phase || "API")}</td>
<td>{item.model || "—"}</td>
<td>{integer(item.prompt_tokens)}</td>
<td>{integer(item.cached_tokens)}</td>
<td>{integer(item.completion_tokens)}</td>
<td>{item.pricing_known === false ? "Sin tarifa" : usd(item.cost?.total_usd)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</>
) : (
<p className="job-cost-no-api">Este proceso no registró llamadas a una API LLM tarifada.</p>
)}
</div>
</details>
);
})}
</div>
)}
</div>
);
}
@@ -11,6 +11,7 @@ import { JobExtractionWizard } from "../modules/extraction/JobExtractionWizard";
import { DownloadsWizardModule } from "../modules/downloads/DownloadsWizardModule";
import { DigitadorWizardModule } from "../modules/digitador/DigitadorWizardModule";
import { JobResultsView } from "./JobResultsView";
import { JobCostsView } from "./JobCostsView";
interface Props {
client: ApiClient;
@@ -30,6 +31,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
const [extractionProgress, setExtractionProgress] = useState<BackgroundProgress | null>(null);
const [cleaningProgress, setCleaningProgress] = useState<BackgroundProgress | null>(null);
const [poblacionAptaProgress, setPoblacionAptaProgress] = useState<BackgroundProgress | null>(null);
const [costsRefreshing, setCostsRefreshing] = useState(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const backgroundRunning =
@@ -45,6 +47,11 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
} catch { /* silent */ }
}, [client, job.id]);
const refreshCosts = useCallback(async () => {
setCostsRefreshing(true);
try { await loadTasks(); } finally { setCostsRefreshing(false); }
}, [loadTasks]);
useEffect(() => {
loadTasks();
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
@@ -129,6 +136,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
{section === "poblacion_apta" && "Población apta"}
{section === "downloads" && "Bot de descarga"}
{section === "digitador" && "Bot digitador"}
{section === "costs" && "Costos de API"}
{section === "results" && "Resultados del trabajo"}
</div>
</header>
@@ -236,6 +244,10 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
onContinueToDigitador={(ctx) => onActivateSection("digitador", ctx)}
/>
</div>
<div className={`job-section-pane${section === "costs" ? "" : " job-section-pane--hidden"}`}>
<JobCostsView tasks={tasks} refreshing={costsRefreshing} onRefresh={() => void refreshCosts()} />
</div>
</div>
</div>
);
+23 -1
View File
@@ -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,
+42 -5
View File
@@ -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
+17 -4
View File
@@ -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 {})