/* PlaigLab front-of-house: upload -> live investigation log -> dossier report */ "use strict"; const $ = (id) => document.getElementById(id); const views = { upload: $("view-upload"), progress: $("view-progress"), report: $("view-report") }; const caseNo = Math.random().toString(36).slice(2, 8).toUpperCase(); $("caseNo").textContent = `CASE Nº ${caseNo}`; function show(name) { Object.entries(views).forEach(([k, el]) => { el.hidden = k !== name; }); window.scrollTo({ top: 0, behavior: "smooth" }); } /* ---------------- upload wiring ---------------- */ const dz = $("dropzone"); const fileInput = $("fileInput"); dz.addEventListener("click", () => fileInput.click()); dz.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") fileInput.click(); }); ["dragover", "dragenter"].forEach(ev => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("drag"); })); ["dragleave", "drop"].forEach(ev => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("drag"); })); dz.addEventListener("drop", (e) => { const f = e.dataTransfer.files[0]; if (f) submitFile(f); }); fileInput.addEventListener("change", () => { if (fileInput.files[0]) submitFile(fileInput.files[0]); }); const depthParam = () => ($("maxToggle") && $("maxToggle").checked ? "max" : $("deepToggle").checked ? "deep" : "standard"); $("sampleBtn").addEventListener("click", async () => { const r = await fetch(`/api/sample?depth=${depthParam()}`, { method: "POST" }); const j = await r.json(); beginInvestigation(j.job_id, "specimen_case.txt"); }); $("newCaseBtn").addEventListener("click", () => { fileInput.value = ""; show("upload"); }); /* ---------------- past-case history ---------------- */ $("historyBtn").addEventListener("click", async () => { const box = $("caseHistory"); if (!box.hidden) { box.hidden = true; return; } const r = await fetch("/api/cases"); const j = await r.json(); const cases = j.cases || []; box.hidden = false; box.innerHTML = cases.length ? "

Past cases

" + cases.map(c => `
${esc(c.verdict||'?')} ${esc(c.title || c.filename || c.case_id)} ${c.similarity_index ?? "?"}% sim · AI ${c.ai_score ?? "?"} · ${esc(c.created||"")}
`).join("") : "

no past cases yet

"; box.querySelectorAll(".hist-row").forEach(row => { row.addEventListener("click", async () => { const id = row.getAttribute("data-case"); const rep = await fetch(`/api/cases/${id}`); if (rep.ok) { currentJobId = id; renderReport(await rep.json()); } }); }); }); async function submitFile(file) { const ext = file.name.toLowerCase().split(".").pop(); if (!["pdf", "txt", "docx"].includes(ext)) { alert("Only PDF, DOCX or TXT evidence is admissible."); return; } const fd = new FormData(); fd.append("file", file); const r = await fetch(`/api/analyze?depth=${depthParam()}`, { method: "POST", body: fd }); if (!r.ok) { alert("Upload failed: " + (await r.text())); return; } const j = await r.json(); beginInvestigation(j.job_id, file.name); } /* ---------------- progress polling ---------------- */ const STAGE_KEYS = ["extract", "understood", "searching", "graph", "scraping", "comparison", "ai-writing", "report"]; const STAGE_MATCH = { extract: /extracting/, understood: /document understood/, searching: /searching|search wave|unique candidates/, graph: /citation graph|historical canon|literature space/, scraping: /deep-scraping|full text acquired/, comparison: /deep comparison/, "ai-writing": /ai-writing/, report: /report ready/, }; let pollTimer = null; let currentJobId = null; function beginInvestigation(jobId, filename) { currentJobId = jobId; $("investFile").textContent = `evidence: ${filename} · case ${caseNo}`; $("console").innerHTML = ""; document.querySelectorAll("#custody li").forEach(li => li.className = ""); show("progress"); let seen = 0; pollTimer = setInterval(async () => { const r = await fetch(`/api/jobs/${jobId}`); if (!r.ok) return; const j = await r.json(); const log = j.log || []; for (; seen < log.length; seen++) appendLog(log[seen]); markStages(log); if (j.status === "done") { clearInterval(pollTimer); const rep = await fetch(`/api/jobs/${jobId}/report`); renderReport(await rep.json()); } else if (j.status === "error") { clearInterval(pollTimer); appendLog("INVESTIGATION FAILED: " + j.error); } }, 900); } function appendLog(msg) { const div = document.createElement("div"); div.textContent = msg; const con = $("console"); con.appendChild(div); con.scrollTop = con.scrollHeight; } function markStages(log) { const joined = log.join("\n"); let lastHit = -1; STAGE_KEYS.forEach((k, i) => { if (STAGE_MATCH[k].test(joined)) lastHit = i; }); document.querySelectorAll("#custody li").forEach((li, i) => { li.className = i < lastHit ? "done" : i === lastHit ? "active" : ""; }); } /* ---------------- report rendering ---------------- */ const ARC_LEN = 251.3; function setDial(arcId, needleId, valId, value, dur = 1200) { const v = Math.max(0, Math.min(100, value)); $(arcId).style.strokeDashoffset = ARC_LEN * (1 - v / 100); $(needleId).style.transform = `rotate(${-90 + 1.8 * v}deg)`; const el = $(valId); const t0 = performance.now(); (function tick(t) { const p = Math.min(1, (t - t0) / dur); el.textContent = (v * (1 - Math.pow(1 - p, 3))).toFixed(1); if (p < 1) requestAnimationFrame(tick); })(t0); } function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } function renderReport(r) { show("report"); $("rptCase").textContent = `CASE ${caseNo}`; $("rptTitle").textContent = r.title; const yrs = r.literature_years ? ` · literature ${r.literature_years[0]}–${r.literature_years[1]}` : ""; $("rptMeta").textContent = `${r.filename} · ${r.words.toLocaleString()} words · ${r.sentences} sentences · ` + `${(r.depth || "standard").toUpperCase()} sweep · ${r.candidates_screened} works screened${yrs} · ${r.elapsed_seconds}s`; const stamp = $("stamp"); // verdict now comes from the recall-first engine (R1): borderline = REVIEW const verdictCls = { PLAGIARIZED: "", SUSPICIOUS: "warn", REVIEW: "warn", CLEAN: "ok" }; const stampText = r.verdict || "REVIEW"; $("stampText").textContent = stampText; stamp.className = "stamp " + (verdictCls[stampText] ?? "warn"); stamp.style.animation = "none"; void stamp.offsetWidth; // restart slam animation stamp.style.animation = ""; $("verdictBasis").innerHTML = (r.verdict_reasons || []).length ? "VERDICT BASIS — " + r.verdict_reasons.map(esc).join(" · ") : ""; /* obfuscation / evasion evidence */ const ob = r.obfuscation || {}; const evp = $("evasionPanel"); if ((ob.homoglyph_count || 0) > 0 || (ob.zero_width_count || 0) > 0) { evp.hidden = false; const ex = (ob.homoglyph_examples || []).slice(0, 6) .map(e => `${esc(e.codepoint)} ${esc(e.name)} → ${esc(e.folded_to)}`).join(" "); evp.innerHTML = `${ob.spoof_suspected ? "⚠ OBFUSCATION EVIDENCE — evasion attempt suspected" : "obfuscation artifacts (minor)"} ${ob.homoglyph_count} homoglyph(s) · ${ob.zero_width_count} hidden character(s) · ${ob.invisible_per_10k_chars}/10k chars invisible ${ex}`; } else { evp.hidden = true; } setDial("simArc", "simNeedle", "simVal", r.similarity_index); setDial("aiArc", "aiNeedle", "aiVal", r.ai_score); $("simSub").textContent = `${r.plagiarism_risk} RISK`; $("aiSub").textContent = r.ai_band.replace(/_/g, " ").toUpperCase(); $("statStack").innerHTML = [ ["sources matched", r.sources.length], ["full PDFs read", r.full_texts_read ?? r.sources.filter(s => s.deep_scraped).length], ["works screened", r.candidates_screened], ["via citation graph", r.graph_expanded ?? 0], ["via topic canon", r.topic_canon ?? 0], ["matched sentences", r.heatmap.filter(h => h.sim >= 0.55).length + " / " + r.sentences], ["semantic engine", r.semantic_enabled ? "ON (MiniLM)" : "OFF"], ["vault passages", (r.coverage && r.coverage.vault_passages) || 0], ].map(([k, v]) => `
${k}${v}
`).join(""); /* exhibits */ const list = $("sourceList"); list.innerHTML = r.sources.length ? "" : "

No matching sources surfaced from the open indexes.

"; r.sources.forEach((s, i) => { const card = document.createElement("div"); card.className = "source-card"; card.style.animationDelay = `${0.08 * i}s`; const authors = (s.authors || []).filter(Boolean).join(", "); const pairs = (s.top_pairs || []).map(p => `
SUBMITTED — cosine ${p.similarity}
${esc(p.submitted)}
SOURCE
${esc(p.source)}
`).join(""); card.innerHTML = `
${String.fromCharCode(65 + (i % 26))}
${esc(s.title)}
${esc(s.provider)} ${s.deep_scraped ? 'FULL TEXT SCRAPED' : ""} ${s.ai_score != null ? `= 45 ? "warm" : "ok"}">SOURCE AI ${s.ai_score}%` : ""} ${esc(authors)}${authors ? " · " : ""}${esc(s.venue || "")} ${s.year || ""} · exact fingerprint ${s.exact_fingerprint_overlap}%
${s.match_percent}%
${pairs ? `
matched passages (${s.top_pairs.length})${pairs}
` : ""}`; list.appendChild(card); requestAnimationFrame(() => requestAnimationFrame(() => { card.querySelector(".src-bar i").style.width = Math.min(100, s.match_percent) + "%"; })); }); /* near misses (R1: nothing hidden below the threshold) */ const nm = r.near_misses || []; $("nearBlock").hidden = nm.length === 0; $("nearMisses").innerHTML = nm.map(n => `
${esc(n.provider)} ${n.deep_scraped ? 'FULL TEXT' : ""} ${esc(n.title)} ${n.year || ""} ${n.match_percent}% · best sentence ${Math.round((n.best_sentence_sim || 0) * 100)}%
`).join(""); /* manuscript heatmap */ const ms = $("manuscript"); ms.innerHTML = ""; r.heatmap.forEach(h => { const sp = document.createElement("span"); sp.textContent = h.text + " "; if ((h.exact || 0) >= 0.5) sp.className = "m-exact"; else if (h.sim >= 0.72) sp.className = "m-high"; else if (h.sim >= 0.55) sp.className = "m-mid"; if (h.sem && sp.className) sp.className += " m-sem"; if (h.source >= 0 && r.sources[h.source]) { sp.title = `${Math.round(h.sim * 100)}% similar${h.sem ? " (semantic)" : ""} — ${r.sources[h.source].title}`; } ms.appendChild(sp); }); /* AI strip */ const strip = $("aiStrip"); strip.innerHTML = ""; const bandColor = { likely_ai: "rgba(164,36,59,.75)", mixed_or_uncertain: "rgba(217,164,59,.8)", likely_human: "rgba(79,111,82,.65)" }; (r.ai_chunks.length ? r.ai_chunks : [{ score: r.ai_score, band: r.ai_band, preview: "" }]).forEach(c => { const seg = document.createElement("i"); seg.style.background = bandColor[c.band] || "#ccc"; seg.title = `chunk ${c.index ?? 0}: ${c.score}% AI signal (${c.band.replace(/_/g, " ")})`; strip.appendChild(seg); }); $("aiStripLabels").innerHTML = "document startdocument end"; /* AI detector ensemble breakdown */ const dets = r.ai_detectors || []; $("aiDetectors").innerHTML = dets.length ? dets.map(d => `
${esc(d.name)} ${esc(d.kind || "")}
= 45 ? "warm" : ""}">
${d.score}
`).join("") : "

language-model detectors unavailable — stylometry only

"; const lm = r.ai_lm_metrics; $("lmMetrics").textContent = lm ? `binoculars ratio ${lm.binoculars} · perplexity ${lm.ppl} · ` + `top-10 token rate ${lm.gltr_top10} · ${lm.tokens_scored} tokens scored` : ""; const cf = r.ai_conformal; const fusionLabel = { meta_hc3: "HC3-calibrated meta-classifier", hand_lm: "hand-weighted (uncalibrated)", stylometry_only: "stylometry only" }; $("fusionNote").innerHTML = `fusion: ${esc(fusionLabel[r.ai_fusion] || r.ai_fusion || "—")}` + (cf ? ` · P(AI)=${cf.p_ai} · conformal band [${cf.abstain_lo}, ${cf.abstain_hi}]` + (cf.inconclusive ? ` · INCONCLUSIVE — abstaining` : "") : ""); /* optional LLM executive summary */ const ls = $("llmSummary"); if (r.llm_summary) { ls.hidden = false; ls.innerHTML = `LLM REVIEWER SUMMARY ${esc(r.llm_summary)}`; } else { ls.hidden = true; } /* data fraud panel */ const df = r.data_fraud; const fp = $("fraudPanel"); if (df && (df.suspicious || df.benford)) { fp.hidden = false; const b = df.benford; const bLine = b ? `Benford: ${b.n_numbers} numbers, conformity ${esc(b.conformity)} ` + `(MAD ${b.mad}, χ² ${b.chi_square}/${b.chi2_critical_p05})` : ""; const gLine = df.grim ? ` · GRIM: ${df.grim.count} impossible mean(s)` : ""; fp.className = "fraud-panel" + (df.suspicious ? " hot" : ""); fp.innerHTML = `${df.suspicious ? "⚠ DATA FABRICATION SIGNALS" : "data integrity check"}` + `${bLine}${gLine}` + (df.flags && df.flags.length ? df.flags.map(f => `
• ${esc(f)}
`).join("") : ""); } else { fp.hidden = true; } /* figure plagiarism panel */ const fig = r.figure_analysis; const gp = $("figurePanel"); if (fig && fig.submitted_figures) { gp.hidden = false; gp.className = "figure-panel" + (fig.reused ? " hot" : ""); gp.innerHTML = `${fig.reused ? "⚠ FIGURE REUSE DETECTED" : "figure check"}` + `${fig.submitted_figures} figure(s) in submission; ${fig.matches.length} match a source image` + fig.matches.map(m => `
• figure matches "${esc(m.source_title)}" (Hamming ${m.hamming}/64)
`).join(""); } else { gp.hidden = true; } /* style seam map */ const sm = r.style_seams; $("seamBlock").hidden = !(sm && sm.segments && sm.segments.length); if (sm && sm.segments && sm.segments.length) { const total = sm.segments[sm.segments.length - 1].end || 1; const segColor = (s) => s == null ? "rgba(120,120,120,.4)" : s >= 70 ? "rgba(164,36,59,.75)" : s >= 45 ? "rgba(217,164,59,.8)" : "rgba(79,111,82,.6)"; $("seamStrip").innerHTML = sm.segments.map(s => { const w = 100 * (s.end - s.start) / total; return ``; }).join(""); $("seamLegend").innerHTML = (sm.mixed_style_suspected ? "⚠ MIXED authorship suspected — " : "") + `${sm.seams.length} stylistic seam(s); each block is a segment, colour = its AI-style score ` + `(green human · amber mixed · red AI). Segments: ` + sm.segments.map(s => `[${s.start}–${s.end}: ${s.style_ai_score ?? "?"}]`).join(" "); } /* COVERAGE — the full scrape manifest (R2) */ const cov = r.coverage || {}; $("covChips").innerHTML = Object.entries(cov.providers || {}).map(([p, n]) => `${esc(p)} ${n}`).join("") + `total considered ${cov.total_works_considered ?? 0}` + `deep-read PDFs ${(cov.deep_read_pdfs || []).length}` + (cov.year_range ? `literature ${cov.year_range[0]}–${cov.year_range[1]}` : ""); $("covPdfs").innerHTML = (cov.deep_read_pdfs || []).length ? "PDFs actually downloaded & read in full:" + cov.deep_read_pdfs.map(p => `
${esc(p.provider)} ${esc(p.title)} ${p.words_read.toLocaleString()} words read
`).join("") : "

no open-access full texts were retrievable for this case

"; const cands = cov.candidates || []; const ACCESS = { read_full_text: ["✓ full text read", "acc-read"], oa_available: ["open access (not read)", "acc-oa"], shadow_text: ["shadow text", "acc-oa"], metadata_only: ["metadata only · paywalled?", "acc-meta"], }; $("covTable").innerHTML = "candidate workyearprovider" + "screen simaccessmatch %" + cands.slice(0, 500).map(c => { const [lbl, cls] = ACCESS[c.access] || ["metadata only", "acc-meta"]; const doi = c.doi ? `
DOI: ${esc(c.doi)}
` : ""; const ven = c.venue ? `
${esc(c.venue)}
` : ""; return ` ${esc(c.title || "(untitled)")}${ven}${doi} ${c.year || ""}${esc(c.provider)} ${(c.rank_sim ?? 0).toFixed(3)} ${lbl} ${c.match_percent == null ? "—" : c.match_percent + "%"} `; }).join(""); $("manifestBtn").href = `/api/jobs/${currentJobId}/manifest`; $("pdfBtn").href = `/api/jobs/${currentJobId}/pdf`; $("queryList").innerHTML = r.queries_used.map(q => `${esc(q)}`).join(""); } /* ---------------- sticky report navigator (additive) ---------------- */ (function reportNav() { const nav = $("reportNav"); if (!nav) return; // mirror the verdict stamp into the sticky bar const stampText = $("stampText"); const rnVerdict = $("rnVerdict"); const syncVerdict = () => { const v = (stampText.textContent || "").trim(); rnVerdict.textContent = v || "—"; rnVerdict.className = "rn-verdict v-" + v.toLowerCase(); }; new MutationObserver(syncVerdict).observe(stampText, { childList: true, characterData: true, subtree: true }); // sticky-bar PDF button triggers the real download link $("rnPdf").addEventListener("click", (e) => { e.preventDefault(); $("pdfBtn").click(); }); // scroll-spy: highlight the section currently in view const links = [...nav.querySelectorAll(".rn-links a")]; const byId = (id) => links.find(a => a.getAttribute("href") === "#" + id); const targets = links .map(a => document.getElementById(a.getAttribute("href").slice(1))) .filter(Boolean); const spy = new IntersectionObserver((entries) => { entries.forEach(en => { if (!en.isIntersecting) return; links.forEach(a => a.classList.remove("active")); const link = byId(en.target.id); if (link) link.classList.add("active"); }); }, { rootMargin: "-72px 0px -65% 0px", threshold: 0 }); targets.forEach(t => spy.observe(t)); })();