Files
AI_portal/contract-check/static/app.js
2026-07-07 13:16:26 +02:00

568 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Contract check frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
setup: $("s-setup"),
processing: $("s-processing"),
results: $("s-results"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
let state = {
jobId: null,
checklist: [],
analysis: null,
pendingFile: null, // File chosen by user but not yet uploaded
};
let nextCustomId = 1;
// ── Load default checklist ──
async function loadChecklist() {
try {
const r = await fetch("api/checklist");
const data = await r.json();
state.checklist = (data.items || []).map((it) => ({
...it,
checked: it.default !== false,
}));
renderChecklist();
} catch (err) {
alert("Nepodařilo se načíst kontrolní seznam: " + err.message);
}
}
function renderChecklist() {
const c = $("checklist");
c.innerHTML = "";
for (const item of state.checklist) {
const div = document.createElement("label");
div.className = "checklist-item" + (item.custom ? " custom" : "");
div.innerHTML = `
<input type="checkbox" ${item.checked ? "checked" : ""}>
<div class="checklist-item-body">
<div class="checklist-item-label">${escapeHtml(item.label)}</div>
${item.hint ? `<div class="checklist-item-hint">${escapeHtml(item.hint)}</div>` : ""}
</div>
${item.custom ? `<button class="checklist-remove" type="button" title="Odstranit">×</button>` : ""}
`;
const cb = div.querySelector("input");
cb.addEventListener("change", () => {
item.checked = cb.checked;
updateRunButton();
});
const rm = div.querySelector(".checklist-remove");
if (rm) {
rm.addEventListener("click", (e) => {
e.preventDefault();
state.checklist = state.checklist.filter((x) => x.id !== item.id);
renderChecklist();
updateRunButton();
});
}
c.appendChild(div);
}
updateRunButton();
}
$("check-all-btn").addEventListener("click", () => {
for (const it of state.checklist) it.checked = true;
renderChecklist();
});
$("uncheck-all-btn").addEventListener("click", () => {
for (const it of state.checklist) it.checked = false;
renderChecklist();
});
$("add-item-form").addEventListener("submit", (e) => {
e.preventDefault();
const input = $("add-item-input");
const label = input.value.trim();
if (!label) return;
state.checklist.push({
id: `custom_${nextCustomId++}`,
label,
hint: "",
checked: true,
custom: true,
});
input.value = "";
renderChecklist();
});
// ── File selection (just stores the file, doesn't upload yet) ──
const fileInput = $("file-input");
const dropZone = $("drop-zone");
$("browse-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => e.target.files[0] && setFile(e.target.files[0]));
["dragenter", "dragover"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
["dragleave", "drop"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && setFile(e.dataTransfer.files[0]));
function setFile(file) {
state.pendingFile = file;
$("file-info-name").textContent = file.name;
$("file-info").classList.remove("hidden");
dropZone.classList.add("compact");
updateRunButton();
}
$("file-info-clear").addEventListener("click", () => {
state.pendingFile = null;
fileInput.value = "";
$("file-info").classList.add("hidden");
dropZone.classList.remove("compact");
updateRunButton();
});
function updateRunButton() {
const hasFile = !!state.pendingFile;
const selectedCount = state.checklist.filter((it) => it.checked).length;
const btn = $("run-btn");
const hint = $("run-hint");
// run-hint only exists after a file is selected (lives in the file-info strip)
if (!hasFile) {
btn.disabled = true;
return;
}
if (selectedCount === 0) {
btn.disabled = true;
if (hint) hint.textContent = "Vyberte alespoň jednu položku ke kontrole níže.";
} else {
btn.disabled = false;
if (hint) hint.textContent = `Připraveno spustit kontrolu ${selectedCount} bodů.`;
}
}
$("run-btn").addEventListener("click", async () => {
if (!state.pendingFile) return;
const selected = state.checklist.filter((it) => it.checked);
if (!selected.length) return;
show("processing");
$("processing-title").textContent = "Nahrávám smlouvu…";
try {
const fd = new FormData();
fd.append("file", state.pendingFile);
const ur = await fetch("api/upload", { method: "POST", body: fd });
if (!ur.ok) throw new Error((await ur.json()).detail || ur.statusText);
const upJson = await ur.json();
state.jobId = upJson.job_id;
$("processing-title").textContent = `Analyzuji smlouvu (${selected.length} bodů)…`;
const ar = await fetch(`api/analyze/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: selected.map((it) => ({
id: it.id,
label: it.label,
hint: it.hint || "",
})),
}),
});
if (!ar.ok) throw new Error((await ar.json()).detail || ar.statusText);
state.analysis = await ar.json();
renderResults();
show("results");
} catch (err) {
alert("Chyba: " + err.message);
show("setup");
}
});
function renderResults() {
const a = state.analysis;
const items = a.items || [];
const usedOcr = !!a._used_ocr;
const counts = items.reduce((acc, it) => {
acc[it.status || "warning"] = (acc[it.status || "warning"] || 0) + 1;
return acc;
}, {});
$("results-meta").textContent =
`Vyhodnoceno ${items.length} bodů. ` +
`OK: ${counts.ok || 0}, Pozor: ${counts.warning || 0}, ` +
`Problém: ${counts.problem || 0}, Chybí: ${counts.missing || 0}.`;
const riskMap = {
low: ["Nízké riziko", "risk-low"],
medium: ["Střední riziko", "risk-medium"],
high: ["Vysoké riziko", "risk-high"],
};
const r = riskMap[a.risk_level] || ["—", ""];
const overall = $("overall-card");
const ocrNotice = usedOcr
? `<div class="ocr-notice">⚙ Smlouva neměla textovou vrstvu — použito OCR (rozpoznávání textu z obrazu). Stažené PDF bude obsahovat jen souhrnnou stránku, bez zvýraznění v původním textu (kvalita OCR neumožňuje spolehlivé vyhledání citací).</div>`
: "";
overall.innerHTML = `
<div class="risk-badge ${r[1]}">${r[0]}</div>
<div class="overall-text">
${escapeHtml(a.overall_summary || "")}
${ocrNotice}
</div>
`;
const findings = $("findings");
findings.innerHTML = "";
const labelMap = { ok: "OK", warning: "POZOR", problem: "PROBLÉM", missing: "CHYBÍ" };
// Sort: problem > warning > missing > ok
const order = { problem: 0, warning: 1, missing: 2, ok: 3 };
const sorted = [...items].sort((a, b) =>
(order[a.status] ?? 9) - (order[b.status] ?? 9)
);
for (const it of sorted) {
const div = document.createElement("div");
div.className = `finding status-${it.status || "warning"}`;
const excerptsHtml = (it.excerpts || []).map((ex) => `
<div class="excerpt">
<div class="excerpt-text">
${ex.page ? `<span class="excerpt-page">str. ${ex.page}</span>` : ""}
<span>„${escapeHtml(ex.text || "")}"</span>
</div>
${ex.comment ? `<div class="excerpt-comment">${escapeHtml(ex.comment)}</div>` : ""}
</div>
`).join("");
const canAdvise = it.status === "warning" || it.status === "problem";
const adviceBtn = canAdvise
? `<button class="btn btn-secondary btn-sm advice-btn" data-id="${escapeHtmlAttr(it.id)}" type="button">Navrhnout úpravu</button>`
: "";
div.innerHTML = `
<div class="finding-header">
<span class="finding-status">${labelMap[it.status] || it.status || ""}</span>
<span class="finding-title">${escapeHtml(it.title || it.label || it.id)}</span>
</div>
${it.summary ? `<p class="finding-summary">${escapeHtml(it.summary)}</p>` : ""}
${excerptsHtml ? `<div class="finding-excerpts">${excerptsHtml}</div>` : ""}
${adviceBtn ? `<div class="finding-advice-row">${adviceBtn}</div>
<div class="finding-advice hidden" data-advice="${escapeHtmlAttr(it.id)}"></div>` : ""}
`;
findings.appendChild(div);
}
findings.onclick = onFindingClick;
state.supplierDone = false;
state.supplierStatus = "checking";
updateDownloadGate();
renderSupplier();
resetChat();
}
// ── Per-clause improvement advice ──
async function onFindingClick(e) {
const btn = e.target.closest(".advice-btn");
if (!btn) return;
const id = btn.dataset.id;
const box = document.querySelector(`[data-advice="${CSS.escape(id)}"]`);
if (box && !box.classList.contains("hidden") && box.dataset.loaded) {
box.classList.toggle("hidden"); // collapse if already loaded
return;
}
btn.disabled = true; const old = btn.textContent; btn.textContent = "Generuji…";
try {
const r = await fetch(`api/advice/${state.jobId}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item_id: id }),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const d = await r.json();
box.innerHTML = renderAdvice(d.advice || "");
box.dataset.loaded = "1";
box.classList.remove("hidden");
btn.textContent = "Skrýt návrh";
} catch (err) {
alert("Chyba: " + err.message);
btn.textContent = old;
} finally {
btn.disabled = false;
}
}
function renderAdvice(text) {
return `<div class="advice-label">Návrh úprav</div>${mdToHtml(text)}`;
}
function updateDownloadGate() {
const btn = $("download-pdf-btn");
const note = $("download-gate-note");
btn.disabled = !state.supplierDone;
if (state.supplierDone) {
btn.title = "";
note.classList.add("hidden");
note.textContent = "";
} else {
const msg = state.supplierStatus === "checking"
? "⏳ PDF bude k dispozici po dokončení ověření protistrany v rejstřících (probíhá…). Ověření je součástí PDF."
: "⚠ Stažení PDF je zatím nedostupné: nejprve dokončete ověření protistrany (zadejte IČO v kartě výše). Výsledek ověření je povinnou součástí PDF.";
btn.title = "Nejprve dokončete ověření protistrany v rejstřících";
note.textContent = msg;
note.classList.remove("hidden");
}
}
// ── Supplier reputation (ARES + MFČR) ──
async function renderSupplier(ico) {
const card = $("supplier-card");
card.innerHTML = `<div class="supplier-loading">Ověřuji protistranu v rejstřících…</div>`;
state.supplierDone = false;
state.supplierStatus = "checking";
updateDownloadGate();
try {
const r = await fetch(`api/supplier/${state.jobId}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(ico ? { ico } : {}),
});
const d = await r.json();
if (d.found) {
card.innerHTML = supplierReportHtml(d);
state.supplierDone = true;
} else {
card.innerHTML = supplierManualHtml(d);
bindSupplierManual();
state.supplierStatus = "manual";
}
} catch (err) {
card.innerHTML = supplierManualHtml({ reason: "Ověření selhalo: " + err.message });
bindSupplierManual();
state.supplierStatus = "manual";
}
updateDownloadGate();
}
function supplierReportHtml(d) {
const a = d.report.ares, v = d.report.vat;
const flags = (d.report.flags || []).map(([lvl, txt]) =>
`<span class="rep-flag rep-${lvl}">${escapeHtml(txt)}</span>`).join("");
const row = (k, val) => val ? `<div class="rep-row"><span>${k}</span><span>${escapeHtml(String(val))}</span></div>` : "";
const srcMap = { contract: "z textu smlouvy", ares_name: "podle názvu (ARES)", manual: "zadáno ručně" };
return `
<div class="supplier-head">
<span class="supplier-title">Protistrana — ověření v rejstřících</span>
<button class="btn-link" id="supplier-edit" type="button">Změnit IČO</button>
</div>
<div class="rep-flags">${flags}</div>
${row("Název", a.name)}
${row("IČO / DIČ", [a.ico, a.dic].filter(Boolean).join(" / "))}
${row("Adresa", a.address)}
${row("Vznik", a.founded)}
${a.dissolved ? row("Zánik", a.dissolved) : ""}
<div class="rep-row"><span>Aktivní v rejstříku</span><span>${a.active ? "ano" : "ne"}</span></div>
<div class="rep-row"><span>Plátce DPH</span><span>${a.vat_registered ? "ano" : "ne"}</span></div>
<div class="rep-row"><span>Insolvence</span><span>${a.in_insolvency ? "ANO — pozor" : "ne"}</span></div>
<div class="rep-row"><span>Nespolehlivý plátce DPH</span><span>${v.unreliable === true ? "ANO — pozor" : v.unreliable === false ? "ne" : "neověřeno"}</span></div>
${v.accounts && v.accounts.length ? `<div class="rep-row"><span>Zveřejněné účty</span><span>${v.accounts.slice(0,4).map(escapeHtml).join(", ")}${v.accounts.length>4?" …":""}</span></div>` : ""}
<div class="rep-source">Zdroj: ${srcMap[d.source] || d.source} · ARES + MFČR</div>`;
}
function supplierManualHtml(d) {
const cands = (d.candidates || []).map((c) =>
`<button class="btn btn-secondary btn-sm sup-cand" data-ico="${escapeHtmlAttr(c.ico)}" type="button">${escapeHtml(c.name || c.ico)} (${escapeHtml(c.ico || "?")})</button>`).join(" ");
return `
<div class="supplier-head"><span class="supplier-title">Protistrana — ověření v rejstřících</span></div>
<p class="supplier-reason">${escapeHtml(d.reason || "IČO se nepodařilo zjistit.")}</p>
${d.name_guess ? `<p class="supplier-reason">Odhad názvu: <strong>${escapeHtml(d.name_guess)}</strong></p>` : ""}
${cands ? `<div class="sup-cands">${cands}</div>` : ""}
<div class="sup-manual-row">
<input type="text" id="sup-ico" class="add-item-input" placeholder="Zadejte IČO (8 číslic)">
<button class="btn btn-primary btn-sm" id="sup-check" type="button">Ověřit</button>
</div>`;
}
function bindSupplierManual() {
const chk = $("sup-check");
if (chk) chk.addEventListener("click", () => {
const ico = ($("sup-ico").value || "").replace(/\D/g, "");
if (ico.length === 8) renderSupplier(ico);
else alert("IČO musí mít 8 číslic.");
});
document.querySelectorAll(".sup-cand").forEach((b) =>
b.addEventListener("click", () => renderSupplier(b.dataset.ico)));
const card = $("supplier-card");
card.querySelector("#supplier-edit") &&
card.querySelector("#supplier-edit").addEventListener("click", () =>
{ card.innerHTML = supplierManualHtml({ reason: "Zadejte IČO protistrany ručně." }); bindSupplierManual(); });
}
// delegate "Změnit IČO" (present in the report view)
$("supplier-card").addEventListener("click", (e) => {
if (e.target.id === "supplier-edit") {
$("supplier-card").innerHTML = supplierManualHtml({ reason: "Zadejte IČO protistrany ručně." });
bindSupplierManual();
}
});
// ── Chat with the contract ──
let chatMessages = [];
function resetChat() {
chatMessages = [];
$("chat-log").innerHTML = "";
}
// Minimal markdown → HTML for assistant replies (bold, headings, bullets).
function mdToHtml(md) {
let s = escapeHtml(md);
s = s.replace(/^#{3,}\s+(.*)$/gm, "<h4>$1</h4>")
.replace(/^##\s+(.*)$/gm, "<h3>$1</h3>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
s = s.replace(/^\s*[-•]\s+(.*)$/gm, "<li>$1</li>");
s = s.replace(/(<li>[\s\S]*?<\/li>)/g, (m) => "<ul>" + m + "</ul>");
return s.split(/\n{2,}/).map((b) =>
/^<(ul|h3|h4)/.test(b.trim()) ? b : "<p>" + b.replace(/\n/g, "<br>") + "</p>"
).join("");
}
function appendChat(role, text) {
const wrap = document.createElement("div");
wrap.className = `msg msg-${role}`;
const author = role === "user" ? "Vy" : "Asistent";
const ava = role === "user"
? '<span class="msg-avatar you">Vy</span>'
: '<span class="msg-avatar">AI</span>';
const body = role === "user" ? escapeHtml(text) : mdToHtml(text);
wrap.innerHTML = `<div class="msg-author">${ava}${author}</div><div class="msg-body">${body}</div>`;
$("chat-log").appendChild(wrap);
$("chat-log").scrollTop = $("chat-log").scrollHeight;
return wrap.querySelector(".msg-body");
}
// Assistant placeholder with animated typing dots (same as Firemní asistent).
function appendTyping() {
const wrap = document.createElement("div");
wrap.className = "msg msg-assistant msg-typing";
wrap.innerHTML =
'<div class="msg-author"><span class="msg-avatar">AI</span>Asistent</div>' +
'<div class="msg-body"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>';
$("chat-log").appendChild(wrap);
$("chat-log").scrollTop = $("chat-log").scrollHeight;
return wrap;
}
$("chat-form").addEventListener("submit", async (e) => {
e.preventDefault();
const inp = $("chat-input");
const q = inp.value.trim();
if (!q || !state.jobId) return;
inp.value = "";
inp.style.height = "auto";
appendChat("user", q);
chatMessages.push({ role: "user", content: q });
const msgEl = appendTyping();
const body = msgEl.querySelector(".msg-body");
$("chat-send").disabled = true;
let acc = "", first = false;
try {
const r = await fetch(`api/chat/${state.jobId}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: chatMessages }),
});
if (!r.ok || !r.body) throw new Error("HTTP " + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf("\n\n")) !== -1) {
const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
if (!raw.startsWith("data:")) continue;
let evt; try { evt = JSON.parse(raw.slice(5).trim()); } catch { continue; }
if (evt.type === "textResponseChunk") {
if (!first) { msgEl.classList.remove("msg-typing"); body.innerHTML = ""; first = true; }
acc += evt.textResponse || "";
body.innerHTML = mdToHtml(acc);
$("chat-log").scrollTop = $("chat-log").scrollHeight;
} else if (evt.type === "finalizeResponseStream") {
msgEl.classList.remove("msg-typing");
acc = evt.textResponse || acc;
body.innerHTML = mdToHtml(acc);
} else if (evt.type === "error") {
msgEl.classList.remove("msg-typing");
body.innerHTML = `<span class="chat-err">Chyba: ${escapeHtml(evt.error || "")}</span>`;
}
}
}
if (acc) chatMessages.push({ role: "assistant", content: acc });
else { msgEl.classList.remove("msg-typing"); body.textContent = "Bez odpovědi."; }
} catch (err) {
msgEl.classList.remove("msg-typing");
body.innerHTML = `<span class="chat-err">Spojení selhalo: ${escapeHtml(String(err))}</span>`;
} finally {
$("chat-send").disabled = false;
}
});
// Enter sends, Shift+Enter = newline; textarea auto-grows.
const chatInput = $("chat-input");
chatInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
$("chat-form").requestSubmit();
}
});
chatInput.addEventListener("input", () => {
chatInput.style.height = "auto";
chatInput.style.height = Math.min(chatInput.scrollHeight, 160) + "px";
});
// Download is gated on the reliability check; opens the include-modal.
$("download-pdf-btn").addEventListener("click", () => {
if (!state.jobId) return;
if (!state.supplierDone) {
alert("Nejprve dokončete ověření protistrany — bude součástí PDF.");
return;
}
$("inc-chat").checked = chatMessages.length > 0;
$("export-modal").classList.remove("hidden");
});
$("export-cancel").addEventListener("click", () =>
$("export-modal").classList.add("hidden"));
$("export-go").addEventListener("click", async () => {
const btn = $("export-go"); const old = btn.textContent;
btn.disabled = true; btn.textContent = "Generuji…";
try {
const r = await fetch(`api/annotated/${state.jobId}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
include_supplier: true,
include_advice: $("inc-advice").checked,
include_chat: $("inc-chat").checked,
chat: chatMessages,
}),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const cd = r.headers.get("content-disposition") || "";
const m = cd.match(/filename="?([^"]+)"?/);
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = m ? m[1] : "kontrola.pdf";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 60000);
$("export-modal").classList.add("hidden");
} catch (err) {
alert("Chyba: " + err.message);
} finally {
btn.disabled = false; btn.textContent = old;
}
});
$("restart-btn").addEventListener("click", () => {
state = {
jobId: null,
checklist: state.checklist,
analysis: null,
pendingFile: null,
};
fileInput.value = "";
$("file-info").classList.add("hidden");
dropZone.classList.remove("compact");
show("setup");
updateRunButton();
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function escapeHtmlAttr(s) { return escapeHtml(s); }
loadChecklist();
})();