feat: Add .env.example configuration file and integrate Redis service into Docker Compose for improved task management. Update desktop application to support document indexing and extraction workflows, enhancing user experience with a new extraction wizard and stepper components. Revise README and documentation for clarity on setup and usage.

This commit is contained in:
erik
2026-07-05 22:39:07 -05:00
parent 9aca926988
commit d95f8f6ad8
70 changed files with 9807 additions and 499 deletions
@@ -0,0 +1,347 @@
const MAX_SYNC_FILES = 50;␍
const MAX_ASYNC_FILES = 500;␍
const JOB_CHUNK_SIZE = 500;␍
const LONG_POLL_MS = 30000;␍
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);␍
␍
function $(id) {␍
return document.getElementById(id);␍
}␍
␍
function getBaseUrl() {␍
const input = $("baseUrl").value.trim();␍
return (input || window.location.origin).replace(/\/$/, "");␍
}␍
␍
function getIncludeRejected() {␍
return $("includeRejected").checked;␍
}␍
␍
function getSelectedFiles(inputId) {␍
const input = $(inputId);␍
return Array.from(input.files || []);␍
}␍
␍
function validateFiles(files, maxFiles) {␍
if (files.length === 0) {␍
throw new Error("Selecciona al menos un archivo PDF.");␍
}␍
if (files.length > maxFiles) {␍
throw new Error(`Máximo ${maxFiles} archivos por petición.`);␍
}␍
for (const file of files) {␍
if (!file.name.toLowerCase().endsWith(".pdf")) {␍
throw new Error(`"${file.name}" no es un PDF.`);␍
}␍
}␍
}␍
␍
async function parseError(res) {␍
try {␍
const data = await res.json();␍
if (data && data.error) return data.error;␍
} catch (_) {␍
/* ignore */␍
}␍
return `Error HTTP ${res.status}`;␍
}␍
␍
function buildMultipart(files) {␍
const form = new FormData();␍
for (const file of files) {␍
form.append("files", file, file.name);␍
}␍
return form;␍
}␍
␍
function setLoading(button, loading, label) {␍
button.disabled = loading;␍
button.textContent = loading ? "Procesando…" : label;␍
}␍
␍
function showError(containerId, message) {␍
const el = $(containerId);␍
el.hidden = false;␍
el.textContent = message;␍
}␍
␍
function hideError(containerId) {␍
const el = $(containerId);␍
el.hidden = true;␍
el.textContent = "";␍
}␍
␍
function formatProbability(value) {␍
if (typeof value !== "number") return "—";␍
return (value * 100).toFixed(1) + "%";␍
}␍
␍
function renderResults(container, results) {␍
container.innerHTML = "";␍
␍
if (!results || results.length === 0) {␍
container.innerHTML = '<p class="empty">Sin resultados.</p>';␍
return;␍
}␍
␍
for (const item of results) {␍
const card = document.createElement("article");␍
card.className = "result-card";␍
␍
const header = document.createElement("div");␍
header.className = "result-header";␍
header.innerHTML = `␍
<h3>${escapeHtml(item.filename)}</h3>␍
<span class="badge badge-${item.status}">${escapeHtml(item.status)}</span>␍
`;␍
card.appendChild(header);␍
␍
if (item.status === "error" && item.error) {␍
const err = document.createElement("p");␍
err.className = "result-error";␍
err.textContent = item.error;␍
card.appendChild(err);␍
} else {␍
const meta = document.createElement("p");␍
meta.className = "result-meta";␍
meta.textContent = `Servicios detectados: ${item.service_count ?? 0}`;␍
card.appendChild(meta);␍
␍
if (item.services && item.services.length > 0) {␍
const table = document.createElement("table");␍
table.innerHTML = `␍
<thead>␍
<tr>␍
<th>Texto</th>␍
<th>Probabilidad</th>␍
<th>Es servicio</th>␍
</tr>␍
</thead>␍
<tbody></tbody>␍
`;␍
const tbody = table.querySelector("tbody");␍
for (const row of item.services) {␍
const tr = document.createElement("tr");␍
const isService =␍
row.is_service === undefined || row.is_service === null␍
? "—"␍
: row.is_service␍
? "Sí"␍
: "No";␍
tr.innerHTML = `␍
<td>${escapeHtml(row.text)}</td>␍
<td>${formatProbability(row.probability)}</td>␍
<td>${isService}</td>␍
`;␍
tbody.appendChild(tr);␍
}␍
card.appendChild(table);␍
}␍
}␍
␍
container.appendChild(card);␍
}␍
}␍
␍
function escapeHtml(text) {␍
const div = document.createElement("div");␍
div.textContent = text ?? "";␍
return div.innerHTML;␍
}␍
␍
async function countSync(baseUrl, files, includeRejected) {␍
const url = new URL(`${baseUrl}/v1/count`);␍
if (includeRejected) url.searchParams.set("include_rejected", "true");␍
␍
const res = await fetch(url, {␍
method: "POST",␍
body: buildMultipart(files),␍
});␍
␍
if (!res.ok) throw new Error(await parseError(res));␍
const data = await res.json();␍
return data.results || [];␍
}␍
␍
async function createEmptyJob(baseUrl, includeRejected) {␍
const res = await fetch(`${baseUrl}/v1/jobs`, {␍
method: "POST",␍
headers: { "Content-Type": "application/json" },␍
body: JSON.stringify({ include_rejected: includeRejected }),␍
});␍
if (!res.ok) throw new Error(await parseError(res));␍
const data = await res.json();␍
return data.job_id;␍
}␍
␍
async function uploadChunk(baseUrl, jobId, files, includeRejected) {␍
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/upload`);␍
if (includeRejected) url.searchParams.set("include_rejected", "true");␍
␍
const res = await fetch(url, {␍
method: "POST",␍
body: buildMultipart(files),␍
});␍
if (!res.ok) throw new Error(await parseError(res));␍
return res.json();␍
}␍
␍
async function getJobStatus(baseUrl, jobId, waitMs = 0) {␍
const url = new URL(`${baseUrl}/v1/jobs/${jobId}`);␍
if (waitMs > 0) url.searchParams.set("wait_ms", String(waitMs));␍
␍
const res = await fetch(url);␍
if (!res.ok) throw new Error(await parseError(res));␍
return res.json();␍
}␍
␍
async function fetchAllResults(baseUrl, jobId) {␍
const results = [];␍
let cursor = "";␍
let hasMore = true;␍
␍
while (hasMore) {␍
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/results`);␍
url.searchParams.set("limit", "500");␍
if (cursor) url.searchParams.set("cursor", cursor);␍
␍
const res = await fetch(url);␍
if (!res.ok) throw new Error(await parseError(res));␍
const page = await res.json();␍
results.push(...(page.results || []));␍
cursor = page.next_cursor || "";␍
hasMore = Boolean(page.has_more);␍
}␍
␍
return results;␍
}␍
␍
function chunkArray(items, size) {␍
const chunks = [];␍
for (let i = 0; i < items.length; i += size) {␍
chunks.push(items.slice(i, i + size));␍
}␍
return chunks;␍
}␍
␍
async function runAsyncJob(baseUrl, files, includeRejected, onProgress) {␍
const jobId = await createEmptyJob(baseUrl, includeRejected);␍
const chunks = chunkArray(files, JOB_CHUNK_SIZE);␍
␍
for (let i = 0; i < chunks.length; i++) {␍
onProgress({␍
status: "uploading",␍
total: files.length,␍
processed: 0,␍
failed: 0,␍
uploadChunk: i + 1,␍
uploadChunks: chunks.length,␍
});␍
await uploadChunk(baseUrl, jobId, chunks[i], includeRejected);␍
}␍
␍
while (true) {␍
const status = await getJobStatus(baseUrl, jobId, LONG_POLL_MS);␍
onProgress(status);␍
if (TERMINAL_STATUSES.has(status.status)) {␍
if (status.status === "cancelled") {␍
throw new Error("El job fue cancelado.");␍
}␍
break;␍
}␍
}␍
␍
return fetchAllResults(baseUrl, jobId);␍
}␍
␍
function updateProgress(status) {␍
const bar = $("asyncProgress");␍
const label = $("asyncProgressLabel");␍
const total = status.total || 0;␍
const done = (status.processed || 0) + (status.failed || 0);␍
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;␍
␍
bar.hidden = false;␍
label.hidden = false;␍
␍
if (status.status === "uploading") {␍
const chunkPct =␍
status.uploadChunks > 0␍
? Math.round((status.uploadChunk / status.uploadChunks) * 100)␍
: 0;␍
bar.value = chunkPct;␍
label.textContent = `Subiendo lote ${status.uploadChunk}/${status.uploadChunks} (${total} archivos)…`;␍
return;␍
}␍
␍
bar.value = pct;␍
label.textContent = `Estado: ${status.status} — ${done}/${total} procesados (${status.failed || 0} fallidos)`;␍
}␍
␍
function initTabs() {␍
const tabs = document.querySelectorAll(".tab");␍
const panels = document.querySelectorAll(".panel");␍
␍
tabs.forEach((tab) => {␍
tab.addEventListener("click", () => {␍
tabs.forEach((t) => t.classList.remove("active"));␍
panels.forEach((p) => p.classList.remove("active"));␍
tab.classList.add("active");␍
document.getElementById(tab.dataset.panel).classList.add("active");␍
});␍
});␍
}␍
␍
function init() {␍
$("baseUrl").value = window.location.origin;␍
␍
initTabs();␍
␍
$("syncForm").addEventListener("submit", async (e) => {␍
e.preventDefault();␍
const btn = $("syncSubmit");␍
hideError("syncError");␍
$("syncResults").innerHTML = "";␍
␍
try {␍
const files = getSelectedFiles("syncFiles");␍
validateFiles(files, MAX_SYNC_FILES);␍
setLoading(btn, true, "Contar servicios");␍
const results = await countSync(getBaseUrl(), files, getIncludeRejected());␍
renderResults($("syncResults"), results);␍
} catch (err) {␍
showError("syncError", err.message);␍
} finally {␍
setLoading(btn, false, "Contar servicios");␍
}␍
});␍
␍
$("asyncForm").addEventListener("submit", async (e) => {␍
e.preventDefault();␍
const btn = $("asyncSubmit");␍
hideError("asyncError");␍
$("asyncResults").innerHTML = "";␍
$("asyncProgress").hidden = true;␍
$("asyncProgressLabel").hidden = true;␍
␍
try {␍
const files = getSelectedFiles("asyncFiles");␍
validateFiles(files, MAX_ASYNC_FILES);␍
setLoading(btn, true, "Iniciar job");␍
const results = await runAsyncJob(␍
getBaseUrl(),␍
files,␍
getIncludeRejected(),␍
updateProgress,␍
);␍
renderResults($("asyncResults"), results);␍
} catch (err) {␍
showError("asyncError", err.message);␍
} finally {␍
setLoading(btn, false, "Iniciar job");␍
}␍
});␍
}␍
␍
document.addEventListener("DOMContentLoaded", init);␍
␍
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed index.html app.js
var FS embed.FS
@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Services Count — Demo</title>
<style>
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--text: #1a2332;
--muted: #5c6b7a;
--border: #d8dee6;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--error-bg: #fef2f2;
--error-text: #b91c1c;
--ok: #15803d;
--warn: #b45309;
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
}
header {
margin-bottom: 1.5rem;
}
header h1 {
margin: 0 0 0.25rem;
font-size: 1.75rem;
font-weight: 700;
}
header p {
margin: 0;
color: var(--muted);
}
header a {
color: var(--primary);
text-decoration: none;
}
header a:hover { text-decoration: underline; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.35rem;
font-size: 0.9rem;
}
input[type="text"],
input[type="file"] {
width: 100%;
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font: inherit;
}
.checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
}
.checkbox input { width: auto; }
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab {
flex: 1;
padding: 0.65rem 1rem;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
cursor: pointer;
font: inherit;
font-weight: 600;
color: var(--muted);
}
.tab.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.panel { display: none; }
.panel.active { display: block; }
.btn {
display: inline-block;
padding: 0.6rem 1.25rem;
background: var(--primary);
color: #fff;
border: none;
border-radius: var(--radius);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.btn:hover:not(:disabled) { background: var(--primary-hover); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.error {
background: var(--error-bg);
color: var(--error-text);
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.hint {
font-size: 0.85rem;
color: var(--muted);
margin-top: 0.35rem;
}
progress {
width: 100%;
height: 0.65rem;
margin: 0.75rem 0 0.25rem;
}
.progress-label {
font-size: 0.85rem;
color: var(--muted);
}
.results { margin-top: 1.25rem; }
.result-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 0.75rem;
background: #fafbfc;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.result-header h3 {
margin: 0;
font-size: 1rem;
word-break: break-all;
}
.badge {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.2rem 0.5rem;
border-radius: 4px;
background: #e2e8f0;
flex-shrink: 0;
}
.badge-ok { background: #dcfce7; color: var(--ok); }
.badge-error { background: #fee2e2; color: var(--error-text); }
.badge-pending { background: #fef3c7; color: var(--warn); }
.result-meta {
margin: 0 0 0.75rem;
font-weight: 600;
}
.result-error {
margin: 0;
color: var(--error-text);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.5rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--muted);
}
.empty {
color: var(--muted);
font-style: italic;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Services Count — Demo</h1>
<p>
Cliente de ejemplo para la API de conteo de servicios en facturas médicas.
<a href="/openapi.json" target="_blank" rel="noopener">OpenAPI</a>
</p>
</header>
<div class="card">
<div class="field">
<label for="baseUrl">URL base de la API</label>
<input type="text" id="baseUrl" placeholder="http://localhost:8080">
<p class="hint">Por defecto usa el mismo origen desde el que se sirve esta página.</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="includeRejected">
Incluir filas rechazadas (<code>include_rejected</code>)
</label>
</div>
</div>
<div class="tabs">
<button type="button" class="tab active" data-panel="syncPanel">Síncrono</button>
<button type="button" class="tab" data-panel="asyncPanel">Asíncrono</button>
</div>
<div id="syncPanel" class="panel active">
<div class="card">
<form id="syncForm">
<div class="field">
<label for="syncFiles">Archivos PDF (máx. 50)</label>
<input type="file" id="syncFiles" accept=".pdf,application/pdf" multiple required>
<p class="hint">Envía una petición <code>POST /v1/count</code> y muestra la respuesta al instante.</p>
</div>
<div id="syncError" class="error" hidden></div>
<button type="submit" class="btn" id="syncSubmit">Contar servicios</button>
</form>
<div id="syncResults" class="results"></div>
</div>
</div>
<div id="asyncPanel" class="panel">
<div class="card">
<form id="asyncForm">
<div class="field">
<label for="asyncFiles">Archivos PDF (hasta 500)</label>
<input type="file" id="asyncFiles" accept=".pdf,application/pdf" multiple required>
<p class="hint">
Flujo producción: MinIO → cola NATS → workers (autoscaler).
Sube por lotes de hasta 500; el progreso usa long-polling.
</p>
</div>
<div id="asyncError" class="error" hidden></div>
<progress id="asyncProgress" value="0" max="100" hidden></progress>
<p id="asyncProgressLabel" class="progress-label" hidden></p>
<button type="submit" class="btn" id="asyncSubmit">Iniciar job</button>
</form>
<div id="asyncResults" class="results"></div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>