Plaiglab / webapp /static /app.js
SanidhyaDhangar's picture
PlaigLab — Hugging Face Space (Docker) clean deploy
ebebfe8
Raw
History Blame Contribute Delete
20.2 kB
/* 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
? "<h3 class='sec-head'>Past cases</h3>" + cases.map(c => `
<div class="hist-row" data-case="${esc(c.case_id)}">
<span class="hist-verdict v-${esc((c.verdict||'').toLowerCase())}">${esc(c.verdict||'?')}</span>
<span class="hist-title">${esc(c.title || c.filename || c.case_id)}</span>
<span class="hist-meta">${c.similarity_index ?? "?"}% sim · AI ${c.ai_score ?? "?"} · ${esc(c.created||"")}</span>
</div>`).join("")
: "<p class='cov-note'>no past cases yet</p>";
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
? "<b>VERDICT BASIS</b> — " + 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 => `<code>${esc(e.codepoint)} ${esc(e.name)}${esc(e.folded_to)}</code>`).join(" ");
evp.innerHTML =
`<b>${ob.spoof_suspected ? "⚠ OBFUSCATION EVIDENCE — evasion attempt suspected"
: "obfuscation artifacts (minor)"}</b>
<span>${ob.homoglyph_count} homoglyph(s) · ${ob.zero_width_count} hidden character(s)
· ${ob.invisible_per_10k_chars}/10k chars invisible</span> ${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]) => `<div class="stat-row"><span>${k}</span><b>${v}</b></div>`).join("");
/* exhibits */
const list = $("sourceList");
list.innerHTML = r.sources.length ? "" :
"<p style='font-family:var(--mono);font-size:14px'>No matching sources surfaced from the open indexes.</p>";
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 =>
`<div class="pair-row"><div class="who">SUBMITTED — cosine ${p.similarity}</div>${esc(p.submitted)}
<div class="who" style="margin-top:6px">SOURCE</div>${esc(p.source)}</div>`).join("");
card.innerHTML = `
<div class="exhibit-tag">${String.fromCharCode(65 + (i % 26))}</div>
<div>
<div class="src-title"><a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.title)}</a></div>
<div class="src-meta">
<span class="badge">${esc(s.provider)}</span>
${s.deep_scraped ? '<span class="badge deep">FULL TEXT SCRAPED</span>' : ""}
${s.ai_score != null ? `<span class="badge ai-src ${s.ai_score >= 70 ? "hot" : s.ai_score >= 45 ? "warm" : "ok"}">SOURCE AI ${s.ai_score}%</span>` : ""}
${esc(authors)}${authors ? " · " : ""}${esc(s.venue || "")} ${s.year || ""}
· exact fingerprint ${s.exact_fingerprint_overlap}%
</div>
</div>
<div class="src-pct">${s.match_percent}<small>%</small></div>
<div class="src-bar"><i></i></div>
${pairs ? `<details class="src-pairs"><summary>matched passages (${s.top_pairs.length})</summary>${pairs}</details>` : ""}`;
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 => `
<div class="near-row">
<span class="badge">${esc(n.provider)}</span>
${n.deep_scraped ? '<span class="badge deep">FULL TEXT</span>' : ""}
<a href="${esc(n.url)}" target="_blank" rel="noopener">${esc(n.title)}</a>
<i>${n.year || ""}</i>
<b>${n.match_percent}% · best sentence ${Math.round((n.best_sentence_sim || 0) * 100)}%</b>
</div>`).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 = "<span>document start</span><span>document end</span>";
/* AI detector ensemble breakdown */
const dets = r.ai_detectors || [];
$("aiDetectors").innerHTML = dets.length ? dets.map(d => `
<div class="det-row">
<span class="det-name">${esc(d.name)} <i>${esc(d.kind || "")}</i></span>
<div class="det-bar"><i style="width:${Math.min(100, d.score)}%"
class="${d.score >= 70 ? "hot" : d.score >= 45 ? "warm" : ""}"></i></div>
<b>${d.score}</b>
</div>`).join("")
: "<p class='cov-note'>language-model detectors unavailable — stylometry only</p>";
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: <b>${esc(fusionLabel[r.ai_fusion] || r.ai_fusion || "—")}</b>` +
(cf ? ` · P(AI)=${cf.p_ai} · conformal band [${cf.abstain_lo}, ${cf.abstain_hi}]` +
(cf.inconclusive ? ` · <b class="warn-text">INCONCLUSIVE — abstaining</b>` : "")
: "");
/* optional LLM executive summary */
const ls = $("llmSummary");
if (r.llm_summary) {
ls.hidden = false;
ls.innerHTML = `<b>LLM REVIEWER SUMMARY</b> ${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 <b>${esc(b.conformity)}</b> ` +
`(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 = `<b>${df.suspicious ? "⚠ DATA FABRICATION SIGNALS" : "data integrity check"}</b>` +
`<span>${bLine}${gLine}</span>` +
(df.flags && df.flags.length ? df.flags.map(f => `<div>• ${esc(f)}</div>`).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 = `<b>${fig.reused ? "⚠ FIGURE REUSE DETECTED" : "figure check"}</b>` +
`<span>${fig.submitted_figures} figure(s) in submission; ${fig.matches.length} match a source image</span>` +
fig.matches.map(m => `<div>• figure matches "${esc(m.source_title)}" (Hamming ${m.hamming}/64)</div>`).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 `<i style="width:${w}%;background:${segColor(s.style_ai_score)}"
title="sentences ${s.start}-${s.end}: style-AI ${s.style_ai_score ?? "?"}"></i>`;
}).join("");
$("seamLegend").innerHTML =
(sm.mixed_style_suspected
? "<b class='warn-text'>⚠ MIXED authorship suspected</b> — "
: "") +
`${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]) => `<span class="chip">${esc(p)} <b>${n}</b></span>`).join("") +
`<span class="chip total">total considered <b>${cov.total_works_considered ?? 0}</b></span>` +
`<span class="chip">deep-read PDFs <b>${(cov.deep_read_pdfs || []).length}</b></span>` +
(cov.year_range ? `<span class="chip">literature ${cov.year_range[0]}${cov.year_range[1]}</span>` : "");
$("covPdfs").innerHTML = (cov.deep_read_pdfs || []).length
? "<b>PDFs actually downloaded &amp; read in full:</b>" +
cov.deep_read_pdfs.map(p => `
<div class="near-row"><span class="badge deep">${esc(p.provider)}</span>
<a href="${esc(p.url)}" target="_blank" rel="noopener">${esc(p.title)}</a>
<b>${p.words_read.toLocaleString()} words read</b></div>`).join("")
: "<p class='cov-note'>no open-access full texts were retrievable for this case</p>";
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 =
"<tr><th>candidate work</th><th>year</th><th>provider</th>" +
"<th>screen sim</th><th>access</th><th>match %</th></tr>" +
cands.slice(0, 500).map(c => {
const [lbl, cls] = ACCESS[c.access] || ["metadata only", "acc-meta"];
const doi = c.doi
? `<div class="cand-doi">DOI: <a href="https://doi.org/${esc(c.doi)}" target="_blank" rel="noopener">${esc(c.doi)}</a></div>`
: "";
const ven = c.venue ? `<div class="cand-venue">${esc(c.venue)}</div>` : "";
return `
<tr class="${c.deep_read ? "deep-row" : ""}">
<td><a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(c.title || "(untitled)")}</a>${ven}${doi}</td>
<td>${c.year || ""}</td><td>${esc(c.provider)}</td>
<td>${(c.rank_sim ?? 0).toFixed(3)}</td>
<td><span class="acc ${cls}">${lbl}</span></td>
<td>${c.match_percent == null ? "—" : c.match_percent + "%"}</td>
</tr>`;
}).join("");
$("manifestBtn").href = `/api/jobs/${currentJobId}/manifest`;
$("pdfBtn").href = `/api/jobs/${currentJobId}/pdf`;
$("queryList").innerHTML = r.queries_used.map(q => `<span>${esc(q)}</span>`).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));
})();