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 = '

Sin resultados.

'; 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 = `

${escapeHtml(item.filename)}

${escapeHtml(item.status)} `; 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 = ` Texto Probabilidad Es servicio `; 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 = ` ${escapeHtml(row.text)} ${formatProbability(row.probability)} ${isService} `; 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);