Spaces:
Running
Running
| const CLAIM_HISTORY_URL = "greenwashing_claim_history.json"; | |
| const SUPERCLAIMS_URL = "greenwashing_superclaims.json"; | |
| const CODEBOOK_URL = "greenwashing_codebook.json"; | |
| const CLAIM_SUPERCLAIM_MAP_URL = "claim_superclaim_map.json"; | |
| const COLLAPSE_MAP_URL = "subclaim_bertopic_collapse.json"; | |
| let flattenedSnippets = null; | |
| /** @type {string | null} */ | |
| let artifactBundleVersion = null; | |
| /** @type {Map<string, { topicId: number, collapseFlag: boolean, collapseWith: string[], topicLabel?: string, hierarchyConfidence?: number }> | null} */ | |
| let collapseBySubclaim = null; | |
| /** @type {Map<string, { superclaimId: string, superclaimText: string }> | null} subclaim → mapped superclaim (from loaded JSON) */ | |
| let superclaimMappingBySubclaim = null; | |
| let dataLoadError = null; | |
| function normalizeSubclaimId(raw) { | |
| const s = String(raw).trim(); | |
| if (!s) return ""; | |
| if (s.startsWith("NC_")) return s; | |
| return `NC_${s.replace(/^(NC_|SC_)/, "")}`; | |
| } | |
| function normalizeSuperclaimId(raw) { | |
| const s = String(raw).trim(); | |
| if (!s) return ""; | |
| if (s.startsWith("SC_")) return s; | |
| return `SC_${s.replace(/^(SC_|NC_)/, "")}`; | |
| } | |
| /** | |
| * @param {unknown} json | |
| * @param {"subclaim" | "superclaim"} kind | |
| * @returns {Map<string, string>} | |
| */ | |
| function loadIdTextMap(json, kind) { | |
| if (!json || typeof json !== "object" || Array.isArray(json)) { | |
| const label = | |
| kind === "subclaim" ? "greenwashing_codebook.json" : "greenwashing_superclaims.json"; | |
| throw new Error(`${label} must be a JSON object of {id: text}`); | |
| } | |
| const out = new Map(); | |
| for (const [k, v] of Object.entries(json)) { | |
| const text = String(v == null ? "" : v).trim(); | |
| const id = | |
| kind === "subclaim" | |
| ? normalizeSubclaimId(k) | |
| : normalizeSuperclaimId(k); | |
| if (!id) continue; | |
| out.set(id, text); | |
| } | |
| return out; | |
| } | |
| /** | |
| * @param {unknown} obj | |
| * @returns {{ subclaimId: string, superclaimId: string, mapSuperText: string }[]} | |
| */ | |
| function parseClaimSuperclaimMap(obj) { | |
| const rows = []; | |
| if (obj == null) return rows; | |
| if (typeof obj === "object" && !Array.isArray(obj)) { | |
| const keys = Object.keys(obj); | |
| const first = keys[0]; | |
| const sample = first != null ? obj[first] : undefined; | |
| const isCombined = | |
| sample != null && | |
| typeof sample === "object" && | |
| !Array.isArray(sample) && | |
| (Object.prototype.hasOwnProperty.call(sample, "superclaim_id") || | |
| Object.prototype.hasOwnProperty.call(sample, "superclaimId") || | |
| Object.prototype.hasOwnProperty.call(sample, "sc_id")); | |
| if (isCombined) { | |
| for (const [subId, record] of Object.entries(obj)) { | |
| if (!record || typeof record !== "object" || Array.isArray(record)) continue; | |
| const sc = | |
| record.superclaim_id ?? | |
| record.superclaimId ?? | |
| record.sc_id ?? | |
| record.SC; | |
| if (sc == null) continue; | |
| const mapSuperText = String( | |
| record.superclaim_text ?? | |
| record.superclaimText ?? | |
| record.superclaim ?? | |
| "" | |
| ).trim(); | |
| rows.push({ | |
| subclaimId: normalizeSubclaimId(subId), | |
| superclaimId: normalizeSuperclaimId(sc), | |
| mapSuperText, | |
| }); | |
| } | |
| return rows; | |
| } | |
| for (const [nc, sc] of Object.entries(obj)) { | |
| rows.push({ | |
| subclaimId: normalizeSubclaimId(nc), | |
| superclaimId: normalizeSuperclaimId(sc), | |
| mapSuperText: "", | |
| }); | |
| } | |
| return rows; | |
| } | |
| if (Array.isArray(obj)) { | |
| for (const item of obj) { | |
| if (Array.isArray(item) && item.length >= 2) { | |
| rows.push({ | |
| subclaimId: normalizeSubclaimId(item[0]), | |
| superclaimId: normalizeSuperclaimId(item[1]), | |
| mapSuperText: "", | |
| }); | |
| continue; | |
| } | |
| if (item && typeof item === "object") { | |
| const nc = | |
| item.subclaim_id ?? | |
| item.nc_id ?? | |
| item.subclaim ?? | |
| item.NC; | |
| const sc = | |
| item.superclaim_id ?? | |
| item.sc_id ?? | |
| item.superclaim ?? | |
| item.SC; | |
| if (nc == null || sc == null) continue; | |
| const mapSuperText = String( | |
| item.superclaim_text ?? item.superclaimText ?? "" | |
| ).trim(); | |
| rows.push({ | |
| subclaimId: normalizeSubclaimId(nc), | |
| superclaimId: normalizeSuperclaimId(sc), | |
| mapSuperText, | |
| }); | |
| } | |
| } | |
| } | |
| return rows; | |
| } | |
| async function loadClaimsData() { | |
| try { | |
| const [historyRes, superRes, codebookRes, mapRes] = await Promise.all([ | |
| fetch(CLAIM_HISTORY_URL), | |
| fetch(SUPERCLAIMS_URL), | |
| fetch(CODEBOOK_URL), | |
| fetch(CLAIM_SUPERCLAIM_MAP_URL), | |
| ]); | |
| if (!historyRes.ok) throw new Error(`claim history HTTP ${historyRes.status}`); | |
| if (!superRes.ok) throw new Error(`superclaims HTTP ${superRes.status}`); | |
| if (!codebookRes.ok) throw new Error(`codebook HTTP ${codebookRes.status}`); | |
| if (!mapRes.ok) throw new Error(`claim_superclaim_map HTTP ${mapRes.status}`); | |
| const [historyJson, superJson, codebookJson, mapJson] = await Promise.all([ | |
| historyRes.json(), | |
| superRes.json(), | |
| codebookRes.json(), | |
| mapRes.json(), | |
| ]); | |
| // BERTopic collapse (offline artifact; optional). | |
| collapseBySubclaim = new Map(); | |
| try { | |
| const collapseRes = await fetch(COLLAPSE_MAP_URL); | |
| if (collapseRes.ok) { | |
| const collapseJson = await collapseRes.json(); | |
| if (typeof collapseJson.claims_bundle_version === "string") { | |
| artifactBundleVersion = collapseJson.claims_bundle_version; | |
| } | |
| const sub = collapseJson.subclaims || {}; | |
| for (const [sid, row] of Object.entries(sub)) { | |
| if (!row || typeof row !== "object") continue; | |
| const collapseWith = Array.isArray(row.collapse_with) | |
| ? row.collapse_with.map(String) | |
| : []; | |
| const hc = row.hierarchy_confidence; | |
| const entry = { | |
| topicId: Number(row.topic_id), | |
| collapseFlag: Boolean(row.collapse_flag), | |
| collapseWith, | |
| topicLabel: | |
| typeof row.topic_label === "string" ? row.topic_label : undefined, | |
| }; | |
| if (typeof hc === "number" && Number.isFinite(hc)) { | |
| entry.hierarchyConfidence = hc; | |
| } | |
| collapseBySubclaim.set(sid, entry); | |
| } | |
| } | |
| } catch { | |
| // Missing or invalid collapse file: UI continues without collapse hints. | |
| } | |
| const superclaimsById = loadIdTextMap(superJson, "superclaim"); | |
| const codebookById = loadIdTextMap(codebookJson, "subclaim"); | |
| const mapRows = parseClaimSuperclaimMap(mapJson); | |
| const superclaimBySubclaim = new Map(); | |
| for (const { subclaimId, superclaimId, mapSuperText } of mapRows) { | |
| if (!subclaimId || !superclaimId) continue; | |
| superclaimBySubclaim.set(subclaimId, { | |
| superclaimId, | |
| superclaimText: | |
| superclaimsById.get(superclaimId) || mapSuperText || "", | |
| }); | |
| } | |
| superclaimMappingBySubclaim = superclaimBySubclaim; | |
| const claims = historyJson.claims || {}; | |
| const snippets = []; | |
| for (const [claimId, claimObj] of Object.entries(claims)) { | |
| if (!claimObj || typeof claimObj !== "object") continue; | |
| const subclaimId = claimId.startsWith("NC_") | |
| ? claimId | |
| : `NC_${claimId.replace(/^(NC_|SC_)/, "")}`; | |
| const subclaimText = | |
| codebookById.get(subclaimId) || | |
| claimObj.current_text || | |
| ""; | |
| const mapping = superclaimBySubclaim.get(subclaimId); | |
| if (!mapping) continue; | |
| const { superclaimId, superclaimText } = mapping; | |
| const history = Array.isArray(claimObj.history) ? claimObj.history : []; | |
| history.forEach((entry) => { | |
| if (!entry || !entry.source_snippet) return; | |
| snippets.push({ | |
| sentenceSnippet: entry.source_snippet, | |
| snippetLower: entry.source_snippet.toLowerCase(), | |
| superclaimText, | |
| superclaimId, | |
| subclaimId, | |
| subclaimText, | |
| }); | |
| }); | |
| } | |
| flattenedSnippets = snippets; | |
| if (isRedesignedUi()) { | |
| setTextIfPresent("stat-subclaims", codebookById.size); | |
| setTextIfPresent("stat-superclaims", superclaimsById.size); | |
| } | |
| } catch (err) { | |
| console.error("Failed to load claim data:", err); | |
| dataLoadError = err; | |
| } | |
| } | |
| /** Paragraphs = blocks separated by line breaks (after normalizing newlines). */ | |
| function splitIntoParagraphs(text) { | |
| const normalized = text.replace(/\r\n/g, "\n").trim(); | |
| if (!normalized) return []; | |
| return normalized | |
| // Treat each line as its own paragraph; ignore empty lines. | |
| .split(/\n+/) | |
| .map((p) => p.replace(/\s+/g, " ").trim()) | |
| .filter(Boolean); | |
| } | |
| function computeMatchConfidence(paragraphLower, snippetLower) { | |
| // NOTE: Keeps *mapping selection* behavior (local overlap / LCS). | |
| if (!paragraphLower || !snippetLower) return 0; | |
| if (paragraphLower === snippetLower) return 1; | |
| if (snippetLower.includes(paragraphLower) || paragraphLower.includes(snippetLower)) return 1; | |
| const minLen = Math.min(paragraphLower.length, snippetLower.length); | |
| if (minLen < 12) return 0; | |
| const intersection = longestCommonSubstr(paragraphLower, snippetLower); | |
| return intersection.length / minLen; | |
| } | |
| /** | |
| * Unique superclaims mapped from peer subclaims in the same BERTopic cluster. | |
| * @param {string[]} peerSubclaimIds | |
| * @param {string} [currentSuperclaimId] matched superclaim for this row (for labels) | |
| */ | |
| function superclaimTargetsFromPeers(peerSubclaimIds, currentSuperclaimId) { | |
| const map = superclaimMappingBySubclaim; | |
| if (!map || !peerSubclaimIds.length) return []; | |
| const bySc = new Map(); | |
| for (const raw of peerSubclaimIds) { | |
| const sid = normalizeSubclaimId(raw); | |
| if (!sid) continue; | |
| const m = map.get(sid); | |
| if (!m || !m.superclaimId) continue; | |
| if (!bySc.has(m.superclaimId)) { | |
| bySc.set(m.superclaimId, { | |
| superclaimId: m.superclaimId, | |
| superclaimText: m.superclaimText || "", | |
| }); | |
| } | |
| } | |
| const list = Array.from(bySc.values()).map((entry) => ({ | |
| ...entry, | |
| isCurrentMapping: | |
| Boolean(currentSuperclaimId) && entry.superclaimId === currentSuperclaimId, | |
| })); | |
| list.sort((a, b) => { | |
| if (a.isCurrentMapping !== b.isCurrentMapping) return a.isCurrentMapping ? 1 : -1; | |
| return a.superclaimId.localeCompare(b.superclaimId); | |
| }); | |
| return list; | |
| } | |
| function formatSuperclaimTargetsHtml(targets, { mappedPeerCount, artifactPeerCount }) { | |
| if (!targets.length) { | |
| return `<div class="collapse-peer-superclaims collapse-peer-superclaims--empty">No superclaim targets could be resolved from cluster peers that still appear in your map.</div>`; | |
| } | |
| const maxShow = 8; | |
| const slice = targets.slice(0, maxShow); | |
| const more = | |
| targets.length > maxShow | |
| ? `<div class="collapse-more">+${targets.length - maxShow} more superclaim(s)</div>` | |
| : ""; | |
| const staleNote = | |
| artifactPeerCount > mappedPeerCount | |
| ? `<p class="collapse-peer-superclaims-stale">${artifactPeerCount - mappedPeerCount} offline cluster peer(s) are not in your current <code>claim_superclaim_map</code> and were ignored.</p>` | |
| : ""; | |
| const items = slice | |
| .map((t) => { | |
| const idHtml = `<span class="claim-id">${escapeHtml(t.superclaimId)}</span>`; | |
| const badge = t.isCurrentMapping | |
| ? ` <span class="collapse-mapping-badge">same as match</span>` | |
| : ""; | |
| const textShort = t.superclaimText | |
| ? escapeHtml( | |
| t.superclaimText.length > 160 | |
| ? `${t.superclaimText.slice(0, 157)}…` | |
| : t.superclaimText | |
| ) | |
| : ""; | |
| return `<li class="collapse-sc-target-item"> | |
| <div class="collapse-sc-target-line">${idHtml}${badge}</div> | |
| ${textShort ? `<div class="collapse-sc-target-text">${textShort}</div>` : ""} | |
| </li>`; | |
| }) | |
| .join(""); | |
| return ` | |
| <div class="collapse-peer-superclaims"> | |
| <div class="collapse-peer-superclaims-title">Potential superclaims to collapse toward</div> | |
| <p class="collapse-peer-superclaims-hint">Using <strong>${mappedPeerCount}</strong> cluster peer subclaim${mappedPeerCount === 1 ? "" : "s"} that still exist in your map${artifactPeerCount !== mappedPeerCount ? ` (of ${artifactPeerCount} in the offline artifact)` : ""}. Targets below are their mapped superclaims.</p> | |
| ${staleNote} | |
| <ul class="collapse-sc-target-list">${items}</ul> | |
| ${more} | |
| </div> | |
| `; | |
| } | |
| function formatHierarchyLine(row, liveConfidence) { | |
| if (typeof liveConfidence === "number" && Number.isFinite(liveConfidence)) { | |
| const v = clamp01(liveConfidence); | |
| const html = `<div class="hierarchy-confidence">Confidence (LLM): <strong>${v.toFixed(2)}</strong></div>`; | |
| return { | |
| html, | |
| titlePart: `LLM confidence: ${v.toFixed(2)}`, | |
| }; | |
| } | |
| if ( | |
| !row || | |
| typeof row.hierarchyConfidence !== "number" || | |
| !Number.isFinite(row.hierarchyConfidence) | |
| ) { | |
| return { html: "", titlePart: "" }; | |
| } | |
| const v = row.hierarchyConfidence; | |
| const html = `<div class="hierarchy-confidence">Confidence (offline cosine, subclaim↔superclaim): <strong>${v.toFixed(2)}</strong></div>`; | |
| return { | |
| html, | |
| titlePart: `Offline cosine: ${v.toFixed(2)}`, | |
| }; | |
| } | |
| /** | |
| * @param {string} subclaimId | |
| * @param {string} [currentSuperclaimId] superclaim id for the matched row (for target labeling) | |
| * @param {number|null|undefined} [liveConfidence] LLM confidence for this mapping (preferred over offline cosine) | |
| */ | |
| function formatCollapseMeta(subclaimId, currentSuperclaimId, liveConfidence) { | |
| if (!collapseBySubclaim || !collapseBySubclaim.size) { | |
| return { | |
| html: `<span class="collapse-meta">No artifact loaded (run <code>python scripts/build_subclaim_collapse_bertopic.py</code>).</span>`, | |
| title: "Generate subclaim_bertopic_collapse.json", | |
| }; | |
| } | |
| const row = collapseBySubclaim.get(subclaimId); | |
| if (!row) { | |
| return { | |
| html: `<span class="collapse-meta">No BERTopic row for this subclaim.</span>`, | |
| title: "", | |
| }; | |
| } | |
| // UI display rule: only show collapse *targets* when the offline hierarchy cosine | |
| // similarity is above a minimum threshold. (We still show the score itself.) | |
| const MIN_COLLAPSE_SIMILARITY = 0.6; | |
| const hier = formatHierarchyLine(row, liveConfidence); | |
| const peersArtifact = (row.collapseWith || []).map(String); | |
| const peersMapped = superclaimMappingBySubclaim | |
| ? peersArtifact.filter((p) => | |
| superclaimMappingBySubclaim.has(normalizeSubclaimId(p)) | |
| ) | |
| : [...peersArtifact]; | |
| const label = row.topicLabel ? escapeHtml(String(row.topicLabel)) : ""; | |
| if (!row.collapseFlag || peersArtifact.length === 0) { | |
| const tid = Number.isFinite(row.topicId) ? row.topicId : ""; | |
| const labelBit = label | |
| ? `<div class="collapse-topic-label">${label}</div>` | |
| : ""; | |
| const titleBits = [ | |
| hier.titlePart, | |
| label ? `Topic label: ${row.topicLabel}` : "", | |
| "Singleton or outlier — not flagged for merge", | |
| ].filter(Boolean); | |
| return { | |
| html: ` | |
| <div class="collapse-meta"> | |
| ${hier.html} | |
| <span class="collapse-badge collapse-badge-quiet">Unique topic${tid !== "" ? ` (${tid})` : ""}</span> | |
| ${labelBit} | |
| </div> | |
| `, | |
| title: titleBits.join(" · "), | |
| }; | |
| } | |
| if (peersMapped.length === 0) { | |
| const labelBlock = label | |
| ? `<div class="collapse-topic-label">${label}</div>` | |
| : ""; | |
| const titleBits = [ | |
| hier.titlePart, | |
| label ? `Topic label: ${row.topicLabel}` : "", | |
| "Cluster peers missing from current map", | |
| ].filter(Boolean); | |
| return { | |
| html: ` | |
| <div class="collapse-meta"> | |
| ${hier.html} | |
| <span class="collapse-badge">Topic cluster</span> | |
| ${labelBlock} | |
| <div class="collapse-cluster-hint collapse-cluster-hint--error"> | |
| The offline artifact lists ${peersArtifact.length} cluster peer subclaim${peersArtifact.length === 1 ? "" : "s"}, but none appear in your current <code>claim_superclaim_map</code>. Regenerate <code>subclaim_bertopic_collapse.json</code> or refresh the map so collapse targets stay in sync. | |
| </div> | |
| </div> | |
| `, | |
| title: titleBits.join(" · "), | |
| }; | |
| } | |
| if ( | |
| (typeof liveConfidence !== "number" || !Number.isFinite(liveConfidence)) && | |
| typeof row.hierarchyConfidence === "number" && | |
| Number.isFinite(row.hierarchyConfidence) && | |
| row.hierarchyConfidence < MIN_COLLAPSE_SIMILARITY | |
| ) { | |
| const labelBlock = label | |
| ? `<div class="collapse-topic-label">${label}</div>` | |
| : ""; | |
| const titleBits = [ | |
| hier.titlePart, | |
| label ? `Topic label: ${row.topicLabel}` : "", | |
| `Collapse suggestions hidden (similarity < ${MIN_COLLAPSE_SIMILARITY.toFixed(2)})`, | |
| ].filter(Boolean); | |
| return { | |
| html: ` | |
| <div class="collapse-meta"> | |
| ${hier.html} | |
| <span class="collapse-badge">Topic cluster</span> | |
| ${labelBlock} | |
| <div class="collapse-cluster-hint collapse-cluster-hint--muted"> | |
| Cluster peers exist, but collapse targets are hidden because the offline cosine similarity is below <strong>${MIN_COLLAPSE_SIMILARITY.toFixed( | |
| 2 | |
| )}</strong>. | |
| </div> | |
| </div> | |
| `, | |
| title: titleBits.join(" · "), | |
| }; | |
| } | |
| const scTargets = superclaimTargetsFromPeers(peersMapped, currentSuperclaimId); | |
| const targetsHtml = formatSuperclaimTargetsHtml(scTargets, { | |
| mappedPeerCount: peersMapped.length, | |
| artifactPeerCount: peersArtifact.length, | |
| }); | |
| const labelBlock = label | |
| ? `<div class="collapse-topic-label">${label}</div>` | |
| : ""; | |
| const titleBits = [ | |
| hier.titlePart, | |
| label ? `Topic label: ${row.topicLabel}` : "", | |
| scTargets.length | |
| ? `Superclaims: ${scTargets.map((t) => t.superclaimId).join(", ")}` | |
| : "", | |
| ].filter(Boolean); | |
| return { | |
| html: ` | |
| <div class="collapse-meta"> | |
| ${hier.html} | |
| <span class="collapse-badge">Topic cluster</span> | |
| ${labelBlock} | |
| ${targetsHtml} | |
| </div> | |
| `, | |
| title: titleBits.join(" · "), | |
| }; | |
| } | |
| function findBestMatchesForParagraph(paragraph) { | |
| if (!flattenedSnippets || flattenedSnippets.length === 0) return []; | |
| const lowerParagraph = paragraph.toLowerCase(); | |
| const candidates = flattenedSnippets | |
| .filter((s) => { | |
| const confidence = computeMatchConfidence(lowerParagraph, s.snippetLower); | |
| return confidence >= 0.6; | |
| }) | |
| .map((s) => ({ | |
| ...s, | |
| mappingConfidence: computeMatchConfidence( | |
| lowerParagraph, | |
| s.snippetLower | |
| ), | |
| })); | |
| // For each paragraph, ensure at most one match per subclaim. | |
| const bestBySubclaim = new Map(); | |
| for (const c of candidates) { | |
| const key = c.subclaimId; | |
| const existing = bestBySubclaim.get(key); | |
| if (!existing || c.mappingConfidence > existing.mappingConfidence) { | |
| bestBySubclaim.set(key, c); | |
| } | |
| } | |
| const dedup = Array.from(bestBySubclaim.values()).sort( | |
| (a, b) => b.mappingConfidence - a.mappingConfidence | |
| ); | |
| return dedup.slice(0, 4); | |
| } | |
| function longestCommonSubstr(a, b) { | |
| const dp = Array(a.length + 1) | |
| .fill(null) | |
| .map(() => Array(b.length + 1).fill(0)); | |
| let longest = 0; | |
| let endIdx = 0; | |
| for (let i = 1; i <= a.length; i++) { | |
| for (let j = 1; j <= b.length; j++) { | |
| if (a[i - 1] === b[j - 1]) { | |
| dp[i][j] = dp[i - 1][j - 1] + 1; | |
| if (dp[i][j] > longest) { | |
| longest = dp[i][j]; | |
| endIdx = i; | |
| } | |
| } | |
| } | |
| } | |
| return a.slice(endIdx - longest, endIdx); | |
| } | |
| function isRedesignedUi() { | |
| try { | |
| return ( | |
| Boolean(document.querySelector(".shell")) && | |
| Boolean(document.getElementById("results-container")) && | |
| Boolean(document.getElementById("proposals-list")) | |
| ); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function setTextIfPresent(id, value) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| el.textContent = String(value); | |
| } | |
| function clamp01(x) { | |
| const n = typeof x === "number" ? x : Number(x); | |
| if (!Number.isFinite(n)) return 0; | |
| return Math.max(0, Math.min(1, n)); | |
| } | |
| function pickConfidence(m) { | |
| if (!m || typeof m !== "object") return { value: null, label: "" }; | |
| const llm = typeof m.confidence === "number" && Number.isFinite(m.confidence) ? clamp01(m.confidence) : null; | |
| if (llm != null) { | |
| const src = String(m.confidenceSource || "").trim(); | |
| if (src === "llm" || src === "llm_prompt") return { value: llm, label: "Confidence (LLM)" }; | |
| if (src === "tfidf_fallback") return { value: llm, label: "Confidence (TF‑IDF fallback)" }; | |
| if (src === "tfidf_no_openai_key") return { value: llm, label: "Confidence (TF‑IDF; no OpenAI key)" }; | |
| return { value: llm, label: "Confidence" }; | |
| } | |
| const heuristic = | |
| typeof m.mappingConfidence === "number" && Number.isFinite(m.mappingConfidence) | |
| ? clamp01(m.mappingConfidence) | |
| : null; | |
| if (heuristic != null) return { value: heuristic, label: "Confidence (heuristic)" }; | |
| return { value: null, label: "" }; | |
| } | |
| function formatTopicChipHtml(subclaimId) { | |
| if (!collapseBySubclaim || !collapseBySubclaim.size) { | |
| return `<div class="topic-chip topic-chip--none">⚠ No artifact loaded</div>`; | |
| } | |
| const row = collapseBySubclaim.get(String(subclaimId || "")); | |
| if (!row) return `<div class="topic-chip topic-chip--none">⚠ No BERTopic row</div>`; | |
| const tid = Number.isFinite(row.topicId) ? row.topicId : ""; | |
| const peers = Array.isArray(row.collapseWith) ? row.collapseWith : []; | |
| if (!row.collapseFlag || peers.length === 0) { | |
| return `<div class="topic-chip topic-chip--none">⚠ Unique Topic${tid !== "" ? ` (${tid})` : ""}</div>`; | |
| } | |
| return `<div class="topic-chip topic-chip--match">Topic cluster${tid !== "" ? ` (${tid})` : ""}</div>`; | |
| } | |
| function renderResultsRedesigned(paragraphsWithMatches) { | |
| const container = document.getElementById("results-container"); | |
| if (!container) return; | |
| container.innerHTML = ""; | |
| if (!paragraphsWithMatches.length) { | |
| container.innerHTML = `<div class="empty-note">No results yet. Paste an article and click “Analyze Paragraphs”.</div>`; | |
| setTextIfPresent("stat-paragraphs", 0); | |
| return; | |
| } | |
| setTextIfPresent("stat-paragraphs", paragraphsWithMatches.length); | |
| paragraphsWithMatches.forEach(({ paragraph, matches }, idx) => { | |
| const card = document.createElement("div"); | |
| card.className = "para-card"; | |
| const title = `Paragraph ${idx + 1} of ${paragraphsWithMatches.length}`; | |
| const paraHtml = escapeHtml(paragraph || ""); | |
| const subBlocks = (Array.isArray(matches) ? matches : []).length | |
| ? matches | |
| .map((m) => { | |
| return ` | |
| <div class="claim-block claim-block--sub"> | |
| <div> | |
| <span class="claim-type-tag claim-type-tag--sub">Subclaim <span class="claim-id-badge">${escapeHtml( | |
| m.subclaimId || "" | |
| )}</span></span> | |
| </div> | |
| <div class="claim-text">${escapeHtml(m.subclaimText || "")}</div> | |
| </div> | |
| `; | |
| }) | |
| .join("") | |
| : `<div class="no-match-note">No subclaim mapping found.</div>`; | |
| const superBlocks = (Array.isArray(matches) ? matches : []).length | |
| ? matches | |
| .map((m) => { | |
| const picked = pickConfidence(m); | |
| const conf = picked.value != null ? picked.value : 0; | |
| return ` | |
| <div class="claim-block claim-block--super"> | |
| <div> | |
| <span class="claim-type-tag claim-type-tag--super">Superclaim <span class="claim-id-badge">${escapeHtml( | |
| m.superclaimId || "" | |
| )}</span></span> | |
| </div> | |
| <div class="claim-text">${escapeHtml(m.superclaimText || "")}</div> | |
| ${formatTopicChipHtml(m.subclaimId)} | |
| <div class="sim-row" style="margin-top:8px"> | |
| <span class="sim-label">${escapeHtml(picked.label || "Confidence")}</span> | |
| <div class="sim-bar-wrap"><div class="sim-bar-fill sim-bar-fill--amber" style="width:${Math.round( | |
| conf * 100 | |
| )}%"></div></div> | |
| <span class="sim-val">${conf.toFixed(2)}</span> | |
| </div> | |
| </div> | |
| `; | |
| }) | |
| .join("") | |
| : `<div class="no-match-note">No superclaim mapping found.</div>`; | |
| card.innerHTML = ` | |
| <div class="para-card-header"> | |
| <div class="para-num">${idx + 1}</div> | |
| <div> | |
| <div class="para-card-title">${escapeHtml(title)}</div> | |
| <div class="para-card-sub">Maps below apply only to this paragraph.</div> | |
| </div> | |
| </div> | |
| <div class="para-grid"> | |
| <div class="para-col"> | |
| <div class="col-label">Paragraph Text</div> | |
| <div class="para-text-block">${paraHtml}</div> | |
| </div> | |
| <div class="para-col"> | |
| <div class="col-label">Subclaim(s)</div> | |
| ${subBlocks} | |
| </div> | |
| <div class="para-col"> | |
| <div class="col-label">Superclaim(s)</div> | |
| ${superBlocks} | |
| </div> | |
| </div> | |
| `; | |
| container.appendChild(card); | |
| }); | |
| } | |
| function renderResults(paragraphsWithMatches) { | |
| if (isRedesignedUi()) { | |
| renderResultsRedesigned(paragraphsWithMatches); | |
| return; | |
| } | |
| const container = document.getElementById("results-container"); | |
| container.innerHTML = ""; | |
| if (!paragraphsWithMatches.length) { | |
| const p = document.createElement("p"); | |
| p.className = "placeholder"; | |
| p.textContent = "No paragraphs found. Paste article text (paragraphs separated by blank lines) and click “Analyze paragraphs”."; | |
| container.appendChild(p); | |
| return; | |
| } | |
| const ledger = document.createElement("div"); | |
| ledger.className = "results-ledger"; | |
| const total = paragraphsWithMatches.length; | |
| paragraphsWithMatches.forEach(({ paragraph, matches, proposals: paragraphProposals = [] }, idx) => { | |
| const card = document.createElement("article"); | |
| card.className = "paragraph-result-card"; | |
| card.setAttribute("aria-labelledby", `paragraph-result-title-${idx}`); | |
| const header = document.createElement("header"); | |
| header.className = "paragraph-result-header"; | |
| const badge = document.createElement("span"); | |
| badge.className = "paragraph-result-badge"; | |
| badge.textContent = String(idx + 1); | |
| const headerText = document.createElement("div"); | |
| headerText.className = "paragraph-result-header-text"; | |
| const titleEl = document.createElement("div"); | |
| titleEl.className = "paragraph-result-title"; | |
| titleEl.id = `paragraph-result-title-${idx}`; | |
| titleEl.textContent = `Paragraph ${idx + 1} of ${total}`; | |
| const subEl = document.createElement("div"); | |
| subEl.className = "paragraph-result-sub"; | |
| subEl.textContent = "Maps below apply only to this paragraph."; | |
| headerText.appendChild(titleEl); | |
| headerText.appendChild(subEl); | |
| header.appendChild(badge); | |
| header.appendChild(headerText); | |
| card.appendChild(header); | |
| const table = document.createElement("table"); | |
| table.className = "results-table results-table--in-card"; | |
| const thead = document.createElement("thead"); | |
| thead.innerHTML = ` | |
| <tr> | |
| <th scope="col">Paragraph text</th> | |
| <th scope="col">Subclaim(s)</th> | |
| <th scope="col">Superclaim(s)</th> | |
| </tr> | |
| `; | |
| const tbody = document.createElement("tbody"); | |
| if (!matches.length) { | |
| const tr = document.createElement("tr"); | |
| const tdSentence = document.createElement("td"); | |
| tdSentence.className = "sentence-cell paragraph-cell"; | |
| tdSentence.textContent = paragraph; | |
| const tdSub = document.createElement("td"); | |
| const tdSuper = document.createElement("td"); | |
| tdSub.innerHTML = `<div class="no-match"><strong>No subclaim mapping found</strong></div>`; | |
| tdSuper.innerHTML = `<div class="no-match"><strong>No superclaim mapping found</strong></div>`; | |
| tr.appendChild(tdSentence); | |
| tr.appendChild(tdSub); | |
| tr.appendChild(tdSuper); | |
| tbody.appendChild(tr); | |
| } else { | |
| const rowSpan = matches.length; | |
| matches.forEach((m, mIdx) => { | |
| const tr = document.createElement("tr"); | |
| if (mIdx === 0) { | |
| const tdSentence = document.createElement("td"); | |
| tdSentence.className = "sentence-cell paragraph-cell"; | |
| tdSentence.textContent = paragraph; | |
| tdSentence.rowSpan = rowSpan; | |
| tr.appendChild(tdSentence); | |
| } | |
| const tdSub = document.createElement("td"); | |
| const tdSuper = document.createElement("td"); | |
| tdSub.innerHTML = ` | |
| <div class="claim-label">Subclaim <span class="claim-id">(${m.subclaimId})</span></div> | |
| <div class="claim-text">${m.subclaimText}</div> | |
| `; | |
| const picked = pickConfidence(m); | |
| const collapse = formatCollapseMeta(m.subclaimId, m.superclaimId, picked.value); | |
| tdSuper.innerHTML = ` | |
| <div class="claim-label">Superclaim <span class="claim-id">(${m.superclaimId})</span></div> | |
| <div class="claim-text">${m.superclaimText}</div> | |
| <div class="claim-meta collapse-block" title="${escapeHtmlAttr(collapse.title)}"> | |
| ${collapse.html} | |
| </div> | |
| `; | |
| tr.appendChild(tdSub); | |
| tr.appendChild(tdSuper); | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| table.appendChild(thead); | |
| table.appendChild(tbody); | |
| card.appendChild(table); | |
| ledger.appendChild(card); | |
| }); | |
| container.appendChild(ledger); | |
| } | |
| function escapeHtmlAttr(s) { | |
| return String(s) | |
| .replaceAll("&", "&") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">"); | |
| } | |
| function escapeHtml(s) { | |
| return String(s) | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">"); | |
| } | |
| const REVIEWER_STORAGE_KEY = "CLAIMS_REVIEWER_NAME"; | |
| function getReviewerName() { | |
| const el = document.getElementById("reviewer-name"); | |
| const fromInput = el && el.value != null ? String(el.value).trim() : ""; | |
| if (fromInput) return fromInput; | |
| try { | |
| const fromStorage = localStorage.getItem(REVIEWER_STORAGE_KEY); | |
| return fromStorage ? String(fromStorage).trim() : ""; | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| function persistReviewerName(name) { | |
| const el = document.getElementById("reviewer-name"); | |
| if (el) el.value = name; | |
| try { | |
| localStorage.setItem(REVIEWER_STORAGE_KEY, name); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| function requireReviewerName() { | |
| const name = getReviewerName(); | |
| if (!name) { | |
| alert("Please enter your reviewer name before approving, rejecting, or applying proposals."); | |
| const el = document.getElementById("reviewer-name"); | |
| if (el) el.focus(); | |
| return null; | |
| } | |
| persistReviewerName(name); | |
| return name; | |
| } | |
| function formatProposalMetaHtml(p) { | |
| const bits = []; | |
| if (p.reviewedBy) { | |
| bits.push( | |
| `<div class="proposal-line"><strong>Reviewed by:</strong> ${escapeHtml( | |
| p.reviewedBy | |
| )}</div>` | |
| ); | |
| } | |
| if (p.appliedBy) { | |
| bits.push( | |
| `<div class="proposal-line"><strong>Applied by:</strong> ${escapeHtml( | |
| p.appliedBy | |
| )}</div>` | |
| ); | |
| } | |
| return bits.length ? `<div class="proposal-meta">${bits.join("")}</div>` : ""; | |
| } | |
| function getApiCandidates() { | |
| const out = []; | |
| /** @param {string|null|undefined} v */ | |
| const pushUnique = (v) => { | |
| if (v === "") { | |
| if (!out.includes("")) out.push(""); | |
| return; | |
| } | |
| if (v == null) return; | |
| const s = String(v).trim().replace(/\/+$/, ""); | |
| if (!s) return; | |
| if (!out.includes(s)) out.push(s); | |
| }; | |
| const host = | |
| typeof window !== "undefined" && window.location && window.location.hostname | |
| ? window.location.hostname | |
| : ""; | |
| const isDevHost = host === "localhost" || host === "127.0.0.1"; | |
| const isLocalApiUrl = (base) => | |
| /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/i.test(String(base || "").trim()); | |
| const meta = document.querySelector('meta[name="claims-api-base"]'); | |
| const fromMeta = meta && meta.getAttribute("content"); | |
| // 1) Same-origin `/api/*` (Vercel rewrites, or a dev proxy) — always try first in the browser. | |
| if (typeof window !== "undefined") { | |
| pushUnique(""); | |
| } | |
| // 2) Local uvicorn before any hardcoded remote meta/storage so localhost dev is not blocked by a dead URL. | |
| if (isDevHost) { | |
| pushUnique("http://localhost:8001"); | |
| } | |
| try { | |
| const fromStorage = localStorage.getItem("CLAIMS_API_BASE"); | |
| if (fromStorage != null && String(fromStorage).trim() !== "") { | |
| if (!isLocalApiUrl(fromStorage) || isDevHost) { | |
| pushUnique(fromStorage); | |
| } | |
| } | |
| } catch { | |
| // ignore | |
| } | |
| if (fromMeta != null && String(fromMeta).trim() !== "") { | |
| if (!isLocalApiUrl(fromMeta) || isDevHost) { | |
| pushUnique(fromMeta); | |
| } | |
| } | |
| return out; | |
| } | |
| function resolveApiUrl(base, path) { | |
| if (!path) return base || ""; | |
| if (/^https?:\/\//i.test(path)) return path; | |
| const b = base || ""; | |
| if (!b) return path; | |
| return `${b}${path.startsWith("/") ? "" : "/"}${path}`; | |
| } | |
| /** Same-origin calls must send cookies so Vercel Deployment Protection can authorize `/api/*`. Cross-origin keeps `omit` so `Access-Control-Allow-Origin: *` stays valid. */ | |
| function apiFetchCredentials(target) { | |
| try { | |
| if (typeof window === "undefined" || !window.location) return "omit"; | |
| const resolved = new URL(target, window.location.href); | |
| if (resolved.origin === window.location.origin) return "same-origin"; | |
| } catch { | |
| // ignore | |
| } | |
| return "omit"; | |
| } | |
| /** Prefer a short hint when the server returned HTML (e.g. Vercel auth) instead of JSON. */ | |
| function shortenApiFailureMessage(res, text) { | |
| const raw = text == null ? "" : String(text); | |
| const ct = (res && res.headers && res.headers.get("content-type")) || ""; | |
| if ( | |
| raw.includes("Authentication Required") || | |
| (ct.includes("text/html") && raw.includes("vercel") && raw.length > 400) | |
| ) { | |
| return ( | |
| "Vercel Deployment Protection blocked this API request (HTML login page instead of JSON). " + | |
| "Options: turn off protection for this environment, use a production deployment without protection, " + | |
| "or stay signed in—same-origin requests now send cookies so protected previews can work after you open the site once." | |
| ); | |
| } | |
| if (ct.includes("text/html") && raw.length > 400) { | |
| return `Unexpected HTML from API (HTTP ${res.status}). Check the URL and deployment protection settings.`; | |
| } | |
| return raw.length > 900 ? `${raw.slice(0, 900)}…` : raw; | |
| } | |
| async function postJson(url, body) { | |
| const bases = getApiCandidates(); | |
| let lastErr = null; | |
| for (const base of bases) { | |
| try { | |
| const target = resolveApiUrl(base, url); | |
| const creds = apiFetchCredentials(target); | |
| const res = await fetch(target, { | |
| method: "POST", | |
| credentials: creds, | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body), | |
| }); | |
| const text = await res.text(); | |
| let data = null; | |
| try { | |
| data = text ? JSON.parse(text) : null; | |
| } catch { | |
| // ignore | |
| } | |
| if (!res.ok) { | |
| const msg = | |
| (data && (data.detail || data.message)) || | |
| shortenApiFailureMessage(res, text) || | |
| `HTTP ${res.status}`; | |
| // IMPORTANT: never fall back to a different backend on a valid HTTP error response. | |
| // Doing so can mix proposal IDs between deployments (list from one backend, apply on another). | |
| throw new Error(msg); | |
| } | |
| return data; | |
| } catch (e) { | |
| const target = resolveApiUrl(base, url); | |
| const msg = e && e.message ? String(e.message) : String(e); | |
| lastErr = new Error( | |
| msg === "Failed to fetch" | |
| ? `Failed to fetch (${target}). Confirm the API is deployed (open /api/health in a tab), CORS allows this origin, and meta claims-api-base / localStorage CLAIMS_API_BASE are correct or cleared.` | |
| : `${msg} (${target})` | |
| ); | |
| // If we got a real application error (not a network failure), stop here. | |
| // "Proposal not found" is a good example: falling back will only make it worse. | |
| if (msg !== "Failed to fetch") break; | |
| } | |
| } | |
| throw lastErr || new Error("Request failed."); | |
| } | |
| async function getJson(url) { | |
| const bases = getApiCandidates(); | |
| let lastErr = null; | |
| for (const base of bases) { | |
| try { | |
| const target = resolveApiUrl(base, url); | |
| const creds = apiFetchCredentials(target); | |
| const res = await fetch(target, { method: "GET", credentials: creds }); | |
| const text = await res.text(); | |
| let errData = null; | |
| if (!res.ok) { | |
| try { | |
| errData = text ? JSON.parse(text) : null; | |
| } catch { | |
| // ignore | |
| } | |
| const msg = | |
| (errData && (errData.detail || errData.message)) || | |
| shortenApiFailureMessage(res, text) || | |
| `HTTP ${res.status}`; | |
| throw new Error(msg); | |
| } | |
| if (text != null && String(text).trim()) { | |
| try { | |
| return JSON.parse(text); | |
| } catch (parseErr) { | |
| throw new Error( | |
| `Invalid JSON (HTTP ${res.status}): ${parseErr && parseErr.message ? parseErr.message : String(parseErr)}` | |
| ); | |
| } | |
| } | |
| return null; | |
| } catch (e) { | |
| const target = resolveApiUrl(base, url); | |
| const msg = e && e.message ? String(e.message) : String(e); | |
| lastErr = new Error( | |
| msg === "Failed to fetch" | |
| ? `Failed to fetch (${target}). Confirm the API is deployed (open /api/health in a tab), CORS allows this origin, and meta claims-api-base / localStorage CLAIMS_API_BASE are correct or cleared.` | |
| : `${msg} (${target})` | |
| ); | |
| if (msg !== "Failed to fetch") break; | |
| } | |
| } | |
| throw lastErr || new Error("Request failed."); | |
| } | |
| function formatNewSuperclaimSectionsHtml(p) { | |
| const article = escapeHtml(p.paragraph || ""); | |
| const payload = p.payload || {}; | |
| const scText = escapeHtml(String(payload.superclaimText || "").trim()); | |
| return ` | |
| <div class="proposal-section"> | |
| <div class="proposal-section-heading">Article</div> | |
| <div class="proposal-section-body">${article}</div> | |
| </div> | |
| <div class="proposal-section"> | |
| <div class="proposal-section-heading">Suggested original superclaim</div> | |
| <div class="proposal-section-body proposal-section-body--superclaim">${scText}</div> | |
| </div> | |
| `; | |
| } | |
| function formatProposalTitle(p) { | |
| const type = p.type || ""; | |
| if (type === "new_subclaim") return "New subclaim"; | |
| if (type === "new_superclaim") return "Suggested original superclaim"; | |
| if (type === "link_subclaim_to_superclaim") return "Remap subclaim → superclaim"; | |
| if (type === "merge_subclaims") return "Merge subclaims"; | |
| if (type === "merge_superclaims") return "Merge superclaims"; | |
| return type || "Proposal"; | |
| } | |
| function formatProposalBodyHtml(p) { | |
| const payload = p.payload || {}; | |
| if (p.type === "merge_subclaims") { | |
| const ids = Array.isArray(payload.mergeSubclaimIds) | |
| ? payload.mergeSubclaimIds.map((x) => String(x)).join(", ") | |
| : ""; | |
| const canonText = String(payload.canonicalSubclaimText || "").trim(); | |
| const removeText = String(payload.removeSubclaimText || "").trim(); | |
| const mergeTexts = | |
| canonText || removeText | |
| ? `<div class="merge-detail"> | |
| <div class="merge-item"> | |
| <div class="merge-item-label">Keep (canonical)</div> | |
| <div class="merge-item-id">${escapeHtml(payload.canonicalSubclaimId || "")}</div> | |
| <div class="merge-item-text">${escapeHtml( | |
| canonText || "(no text in payload)" | |
| )}</div> | |
| </div> | |
| <div class="merge-item"> | |
| <div class="merge-item-label">Merge away</div> | |
| <div class="merge-item-id">${escapeHtml(payload.removeSubclaimId || "")}</div> | |
| <div class="merge-item-text">${escapeHtml( | |
| removeText || "(no text in payload)" | |
| )}</div> | |
| </div> | |
| </div>` | |
| : ""; | |
| const idLines = | |
| mergeTexts === "" | |
| ? `<div class="proposal-line"><strong>Canonical:</strong> <code>${escapeHtml( | |
| payload.canonicalSubclaimId || "" | |
| )}</code></div> | |
| <div class="proposal-line"><strong>Remove:</strong> <code>${escapeHtml( | |
| payload.removeSubclaimId || "" | |
| )}</code></div>` | |
| : ""; | |
| const cos = | |
| typeof payload.pairCosine === "number" | |
| ? `<div class="proposal-line"><strong>Pair cosine (TF‑IDF):</strong> ${payload.pairCosine.toFixed( | |
| 3 | |
| )}</div>` | |
| : ""; | |
| return ` | |
| ${mergeTexts} | |
| ${idLines} | |
| <div class="proposal-line"><strong>Group:</strong> <code>${escapeHtml(ids)}</code></div> | |
| <div class="proposal-line"><strong>Shared superclaim:</strong> <code>${escapeHtml( | |
| payload.sharedSuperclaimId || "" | |
| )}</code></div> | |
| ${cos} | |
| ${p.rationale ? `<div class="proposal-line proposal-reason">${escapeHtml(p.rationale)}</div>` : ""} | |
| `; | |
| } | |
| if (p.type === "merge_superclaims") { | |
| const ids = Array.isArray(payload.mergeSuperclaimIds) | |
| ? payload.mergeSuperclaimIds.map((x) => String(x)).join(", ") | |
| : ""; | |
| const canonText = String(payload.canonicalSuperclaimText || "").trim(); | |
| const removeText = String(payload.removeSuperclaimText || "").trim(); | |
| const mergeTexts = | |
| canonText || removeText | |
| ? `<div class="merge-detail"> | |
| <div class="merge-item"> | |
| <div class="merge-item-label">Keep (canonical)</div> | |
| <div class="merge-item-id">${escapeHtml(payload.canonicalSuperclaimId || "")}</div> | |
| <div class="merge-item-text">${escapeHtml( | |
| canonText || "(no text in payload)" | |
| )}</div> | |
| </div> | |
| <div class="merge-item"> | |
| <div class="merge-item-label">Merge away</div> | |
| <div class="merge-item-id">${escapeHtml(payload.removeSuperclaimId || "")}</div> | |
| <div class="merge-item-text">${escapeHtml( | |
| removeText || "(no text in payload)" | |
| )}</div> | |
| </div> | |
| </div>` | |
| : ""; | |
| const idLines = | |
| mergeTexts === "" | |
| ? `<div class="proposal-line"><strong>Canonical:</strong> <code>${escapeHtml( | |
| payload.canonicalSuperclaimId || "" | |
| )}</code></div> | |
| <div class="proposal-line"><strong>Remove:</strong> <code>${escapeHtml( | |
| payload.removeSuperclaimId || "" | |
| )}</code></div>` | |
| : ""; | |
| const cos = | |
| typeof payload.pairCosine === "number" | |
| ? `<div class="proposal-line"><strong>Pair cosine (TF‑IDF):</strong> ${payload.pairCosine.toFixed( | |
| 3 | |
| )}</div>` | |
| : ""; | |
| return ` | |
| ${mergeTexts} | |
| ${idLines} | |
| <div class="proposal-line"><strong>Group:</strong> <code>${escapeHtml(ids)}</code></div> | |
| ${cos} | |
| ${p.rationale ? `<div class="proposal-line proposal-reason">${escapeHtml(p.rationale)}</div>` : ""} | |
| `; | |
| } | |
| if (p.type === "new_subclaim") { | |
| const sc = payload.suggestedSuperclaimId | |
| ? `<div class="proposal-line"><strong>Suggested superclaim:</strong> <code>${escapeHtml( | |
| payload.suggestedSuperclaimId | |
| )}</code> ${payload.suggestedSuperclaimText ? `— ${escapeHtml(payload.suggestedSuperclaimText)}` : ""}</div>` | |
| : ""; | |
| const conf = | |
| typeof payload.confidence === "number" | |
| ? `<div class="proposal-line"><strong>LLM confidence:</strong> ${(payload.confidence * 100).toFixed(0)}%</div>` | |
| : ""; | |
| return ` | |
| ${sc} | |
| ${conf} | |
| ${p.rationale ? `<div class="proposal-line proposal-reason">${escapeHtml(p.rationale)}</div>` : ""} | |
| `; | |
| } | |
| if (p.type === "new_superclaim") { | |
| const conf = | |
| typeof payload.confidence === "number" | |
| ? `<div class="proposal-line"><strong>LLM confidence (best candidate):</strong> ${(payload.confidence * 100).toFixed(0)}%</div>` | |
| : ""; | |
| const near = | |
| payload.fromLowConfidenceMapping && payload.nearbySuperclaimId | |
| ? `<div class="proposal-line"><strong>Closest taxonomy superclaim:</strong> <code>${escapeHtml( | |
| String(payload.nearbySuperclaimId) | |
| )}</code>${payload.nearbySuperclaimText ? ` — ${escapeHtml(String(payload.nearbySuperclaimText))}` : ""}</div>` | |
| : ""; | |
| const reason = p.rationale | |
| ? `<div class="proposal-line proposal-reason"><strong>Reasoning:</strong> ${escapeHtml(p.rationale)}</div>` | |
| : ""; | |
| return ` | |
| ${conf} | |
| ${near} | |
| ${reason} | |
| `; | |
| } | |
| return ` | |
| <div class="proposal-line"><strong>Payload:</strong> <code>${escapeHtml( | |
| JSON.stringify(payload) | |
| )}</code></div> | |
| ${p.rationale ? `<div class="proposal-line proposal-reason">${escapeHtml(p.rationale)}</div>` : ""} | |
| `; | |
| } | |
| async function refreshPendingProposals() { | |
| const list = document.getElementById("proposals-list"); | |
| if (list) { | |
| list.innerHTML = `<div class="empty-note">Loading pending proposals…</div>`; | |
| let proposals = []; | |
| try { | |
| const raw = await getJson("/api/proposals?status=pending"); | |
| if (!Array.isArray(raw)) { | |
| throw new Error( | |
| raw == null | |
| ? "Empty response from /api/proposals (expected a JSON array)." | |
| : `Invalid response from /api/proposals: expected an array, got ${typeof raw}.` | |
| ); | |
| } | |
| proposals = raw; | |
| } catch (e) { | |
| list.innerHTML = `<div class="empty-note">Unable to load proposals: ${escapeHtml( | |
| e.message || String(e) | |
| )}</div>`; | |
| setTextIfPresent("stat-proposals", 0); | |
| return; | |
| } | |
| setTextIfPresent("stat-proposals", proposals.length); | |
| if (proposals.length === 0) { | |
| list.innerHTML = `<div class="empty-note">No pending proposals yet.</div>`; | |
| return; | |
| } | |
| const byId = new Map(); | |
| proposals.forEach((p) => { | |
| if (p && p.id) byId.set(String(p.id), p); | |
| }); | |
| list.innerHTML = ""; | |
| proposals.forEach((p) => { | |
| const card = document.createElement("div"); | |
| card.className = "proposal-card"; | |
| const type = formatProposalTitle(p); | |
| const icon = | |
| p.type === "merge_superclaims" || p.type === "merge_subclaims" | |
| ? "⇄" | |
| : p.type === "link_subclaim_to_superclaim" | |
| ? "↪" | |
| : p.type === "new_superclaim" | |
| ? "+" | |
| : "•"; | |
| const payload = p.payload || {}; | |
| const cos = | |
| typeof payload.pairCosine === "number" && Number.isFinite(payload.pairCosine) | |
| ? payload.pairCosine | |
| : null; | |
| const mergeDetail = | |
| p.type === "merge_superclaims" || p.type === "merge_subclaims" | |
| ? (() => { | |
| const keepId = | |
| p.type === "merge_superclaims" | |
| ? payload.canonicalSuperclaimId | |
| : payload.canonicalSubclaimId; | |
| const removeId = | |
| p.type === "merge_superclaims" | |
| ? payload.removeSuperclaimId | |
| : payload.removeSubclaimId; | |
| const keepText = | |
| p.type === "merge_superclaims" | |
| ? payload.canonicalSuperclaimText | |
| : payload.canonicalSubclaimText; | |
| const removeText = | |
| p.type === "merge_superclaims" | |
| ? payload.removeSuperclaimText | |
| : payload.removeSubclaimText; | |
| return ` | |
| <div class="merge-detail"> | |
| <div class="merge-item"> | |
| <div class="merge-item-label">Keep (canonical)</div> | |
| <div class="merge-item-id">${escapeHtml(keepId || "")}</div> | |
| <div class="merge-item-text">${escapeHtml( | |
| String(keepText || "").trim() || "(no text in payload)" | |
| )}</div> | |
| </div> | |
| <div class="merge-item"> | |
| <div class="merge-item-label">Merge away</div> | |
| <div class="merge-item-id">${escapeHtml(removeId || "")}</div> | |
| <div class="merge-item-text">${escapeHtml( | |
| String(removeText || "").trim() || "(no text in payload)" | |
| )}</div> | |
| </div> | |
| </div> | |
| `; | |
| })() | |
| : ""; | |
| const scoreRow = | |
| cos != null | |
| ? ` | |
| <div class="score-row"> | |
| <span class="sim-label">TF‑IDF Cosine Similarity</span> | |
| <div class="sim-bar-wrap" style="flex:1"> | |
| <div class="sim-bar-fill sim-bar-fill--amber" style="width:${Math.round( | |
| clamp01(cos) * 100 | |
| )}%"></div> | |
| </div> | |
| <span class="score-val">${cos.toFixed(3)}</span> | |
| </div> | |
| ` | |
| : ""; | |
| const rationale = String(p.rationale || "").trim(); | |
| const bodyLine = escapeHtml(String(p.paragraph || "").trim()); | |
| card.innerHTML = ` | |
| <div class="proposal-header"> | |
| <div class="proposal-icon">${escapeHtml(icon)}</div> | |
| <div> | |
| <div class="proposal-type">${escapeHtml(type)}</div> | |
| <div class="proposal-id">${escapeHtml(p.id || "")}</div> | |
| </div> | |
| </div> | |
| ${bodyLine ? `<div class="proposal-body">${bodyLine}</div>` : ""} | |
| ${mergeDetail} | |
| ${scoreRow} | |
| ${rationale ? `<div class="score-note">${escapeHtml(rationale)}</div>` : ""} | |
| <div class="proposal-actions"> | |
| <button class="btn-approve" type="button" data-action="approve" data-id="${escapeHtmlAttr( | |
| p.id || "" | |
| )}">✓ Approve</button> | |
| <button class="btn-reject" type="button" data-action="reject" data-id="${escapeHtmlAttr( | |
| p.id || "" | |
| )}">✕ Reject</button> | |
| <button class="btn-apply" type="button" data-action="apply" data-id="${escapeHtmlAttr( | |
| p.id || "" | |
| )}">Apply to CSV →</button> | |
| </div> | |
| `; | |
| list.appendChild(card); | |
| }); | |
| // Match the static mock's footer note. | |
| const footer = document.createElement("div"); | |
| footer.className = "empty-note"; | |
| footer.textContent = | |
| "No further proposals. Run the taxonomy diagnosis pipeline to generate new proposals."; | |
| list.appendChild(footer); | |
| list.querySelectorAll("button[data-action]").forEach((btn) => { | |
| btn.addEventListener("click", async (ev) => { | |
| const el = ev.currentTarget; | |
| const action = el.getAttribute("data-action"); | |
| const id = el.getAttribute("data-id"); | |
| if (!id || !action) return; | |
| const card = el.closest(".proposal-card"); | |
| const cardButtons = card ? Array.from(card.querySelectorAll("button[data-action]")) : [el]; | |
| cardButtons.forEach((b) => (b.disabled = true)); | |
| try { | |
| const reviewer = requireReviewerName(); | |
| if (!reviewer) { | |
| cardButtons.forEach((b) => (b.disabled = false)); | |
| return; | |
| } | |
| if (action === "apply") { | |
| await postJson(`/api/proposals/${encodeURIComponent(id)}/approve`, { | |
| reviewer_name: reviewer, | |
| }); | |
| } | |
| await postJson(`/api/proposals/${encodeURIComponent(id)}/${action}`, { | |
| reviewer_name: reviewer, | |
| }); | |
| await refreshPendingProposals(); | |
| } catch (e) { | |
| cardButtons.forEach((b) => (b.disabled = false)); | |
| alert(`Proposal ${action} failed: ${e.message || String(e)}`); | |
| } | |
| }); | |
| }); | |
| return; | |
| } | |
| const container = document.getElementById("proposals-container"); | |
| if (!container) return; | |
| container.innerHTML = `<p class="placeholder">Loading pending proposals…</p>`; | |
| let proposals = []; | |
| try { | |
| const raw = await getJson("/api/proposals?status=pending"); | |
| if (!Array.isArray(raw)) { | |
| throw new Error( | |
| raw == null | |
| ? "Empty response from /api/proposals (expected a JSON array)." | |
| : `Invalid response from /api/proposals: expected an array, got ${typeof raw}.` | |
| ); | |
| } | |
| proposals = raw; | |
| } catch (e) { | |
| container.innerHTML = `<p class="placeholder error-text">Unable to load proposals: ${escapeHtml( | |
| e.message || String(e) | |
| )}</p>`; | |
| return; | |
| } | |
| if (proposals.length === 0) { | |
| container.innerHTML = `<p class="placeholder">No pending proposals yet.</p> | |
| <p class="placeholder proposal-empty-hint">This list only shows proposals with status <strong>pending</strong> (approved/rejected/applied disappear here).</p> | |
| <p class="placeholder proposal-empty-hint">Proposals are written to the database only when <strong>Analyze paragraphs</strong> succeeds on the <strong>backend</strong> (<code>/api/analyze</code>). If the status line says the backend failed and switched to a local heuristic, nothing is saved—open DevTools → Console / Network for the <code>/api/analyze</code> error.</p> | |
| <p class="placeholder proposal-empty-hint">Open <a href="/api/health" target="_blank" rel="noopener"><code>/api/health</code></a>: <code>proposalsPersistence</code> should be <code>postgres</code> for Railway, <code>taxonomyProposalsTotal</code> is the row count in the DB (if <code>0</code>, no proposals were stored yet—run backend <strong>Analyze</strong> or import rows). This panel only lists <strong>pending</strong>; open <a href="/api/proposals" target="_blank" rel="noopener"><code>/api/proposals</code></a> for every status.</p>`; | |
| return; | |
| } | |
| const wrap = document.createElement("div"); | |
| wrap.className = "results-ledger"; | |
| const byId = new Map(); | |
| proposals.forEach((p) => { | |
| if (p && p.id) byId.set(String(p.id), p); | |
| const card = document.createElement("div"); | |
| card.className = "proposal-card"; | |
| const type = formatProposalTitle(p); | |
| const icon = | |
| p.type === "merge_superclaims" || p.type === "merge_subclaims" | |
| ? "⇄" | |
| : p.type === "link_subclaim_to_superclaim" | |
| ? "↪" | |
| : p.type === "new_superclaim" | |
| ? "+" | |
| : "•"; | |
| const bodyLine = escapeHtml(String(p.paragraph || "").trim()); | |
| card.innerHTML = ` | |
| <div class="proposal-header"> | |
| <div class="proposal-icon">${escapeHtml(icon)}</div> | |
| <div> | |
| <div class="proposal-type">${escapeHtml(type)}</div> | |
| <div class="proposal-id">${escapeHtml(p.id || "")}</div> | |
| </div> | |
| </div> | |
| ${bodyLine ? `<div class="proposal-body">${bodyLine}</div>` : ""} | |
| <div style="padding:0 16px 4px">${formatProposalBodyHtml(p)}${formatProposalMetaHtml(p)}</div> | |
| <div class="proposal-actions"> | |
| <button class="btn-approve" data-action="approve" data-id="${escapeHtmlAttr(p.id || "")}">✓ Approve</button> | |
| <button class="btn-reject" data-action="reject" data-id="${escapeHtmlAttr(p.id || "")}">✕ Reject</button> | |
| <button class="btn-apply" data-action="apply" data-id="${escapeHtmlAttr(p.id || "")}">Apply to CSV →</button> | |
| </div> | |
| `; | |
| wrap.appendChild(card); | |
| }); | |
| container.innerHTML = ""; | |
| container.appendChild(wrap); | |
| container.querySelectorAll("button[data-action]").forEach((btn) => { | |
| btn.addEventListener("click", async (ev) => { | |
| const el = ev.currentTarget; | |
| const action = el.getAttribute("data-action"); | |
| const id = el.getAttribute("data-id"); | |
| if (!id || !action) return; | |
| const card = el.closest(".proposal-card"); | |
| const cardButtons = card ? Array.from(card.querySelectorAll("button[data-action]")) : [el]; | |
| cardButtons.forEach((b) => (b.disabled = true)); | |
| try { | |
| const reviewer = requireReviewerName(); | |
| if (!reviewer) { | |
| cardButtons.forEach((b) => (b.disabled = false)); | |
| return; | |
| } | |
| // Apply implies approval: approve first, then apply. | |
| if (action === "apply") { | |
| await postJson(`/api/proposals/${encodeURIComponent(id)}/approve`, { | |
| reviewer_name: reviewer, | |
| }); | |
| } | |
| await postJson(`/api/proposals/${encodeURIComponent(id)}/${action}`, { | |
| reviewer_name: reviewer, | |
| }); | |
| // Applying should also refresh claims data next run; for now just refresh the list. | |
| await refreshPendingProposals(); | |
| } catch (e) { | |
| cardButtons.forEach((b) => (b.disabled = false)); | |
| alert(`Proposal ${action} failed: ${e.message || String(e)}`); | |
| } | |
| }); | |
| }); | |
| } | |
| async function handleAnalyzeClick() { | |
| const btn = document.getElementById("analyze-btn"); | |
| const statusEl = document.getElementById("status"); | |
| const text = document.getElementById("article-input").value; | |
| statusEl.textContent = ""; | |
| if (!text.trim()) { | |
| statusEl.textContent = "Please paste an article first."; | |
| statusEl.classList.remove("error-text"); | |
| return; | |
| } | |
| btn.disabled = true; | |
| statusEl.textContent = "Analyzing…"; | |
| statusEl.classList.remove("error-text"); | |
| // BERTopic collapse + snippet index live in static JSON; load them even when the backend | |
| // handles /api/analyze, otherwise collapseBySubclaim stays null and the UI shows "No artifact loaded". | |
| if (!flattenedSnippets && !dataLoadError) { | |
| await loadClaimsData(); | |
| } | |
| // Prefer the backend LLM API (supports proposals + human approval). Fallback to local heuristic if unavailable. | |
| try { | |
| const api = await postJson("/api/analyze", { text }); | |
| const rows = Array.isArray(api?.paragraphs) ? api.paragraphs : []; | |
| const withMatches = rows.map((r) => ({ | |
| paragraph: r.paragraph, | |
| matches: Array.isArray(r.matches) ? r.matches : [], | |
| proposals: Array.isArray(r.proposals) ? r.proposals : [], | |
| })); | |
| renderResults(withMatches); | |
| statusEl.textContent = `Analyzed ${withMatches.length} paragraph${withMatches.length === 1 ? "" : "s"} · bundle ${escapeHtml( | |
| api?.bundleVersion || "" | |
| )}.`; | |
| await refreshPendingProposals(); | |
| btn.disabled = false; | |
| return; | |
| } catch (e) { | |
| console.warn("Backend /api/analyze failed; falling back to local matching.", e); | |
| } | |
| // Local fallback (does not POST proposals to the server—only backend /api/analyze does) | |
| statusEl.textContent = "Backend unavailable; running local heuristic…"; | |
| if (!flattenedSnippets && !dataLoadError) { | |
| await loadClaimsData(); | |
| } | |
| if (dataLoadError) { | |
| statusEl.textContent = | |
| "Unable to load claim JSON files. Serve the folder over HTTP and check that the four data files are present."; | |
| statusEl.classList.add("error-text"); | |
| btn.disabled = false; | |
| return; | |
| } | |
| const paragraphs = splitIntoParagraphs(text); | |
| const withMatches = paragraphs.map((p) => ({ | |
| paragraph: p, | |
| matches: findBestMatchesForParagraph(p), | |
| })); | |
| renderResults(withMatches); | |
| const bundleBit = | |
| artifactBundleVersion != null ? ` · artifact ${artifactBundleVersion}` : ""; | |
| statusEl.textContent = `Analyzed ${paragraphs.length} paragraph${paragraphs.length === 1 ? "" : "s"}${bundleBit}. Local matching only—backend /api/analyze failed, so proposals were not saved (see Console / Network for the error).`; | |
| statusEl.classList.add("error-text"); | |
| btn.disabled = false; | |
| void refreshPendingProposals(); | |
| } | |
| window.addEventListener("DOMContentLoaded", () => { | |
| const btn = document.getElementById("analyze-btn"); | |
| if (btn) btn.addEventListener("click", handleAnalyzeClick); | |
| const reviewerEl = document.getElementById("reviewer-name"); | |
| if (reviewerEl) { | |
| try { | |
| const saved = localStorage.getItem(REVIEWER_STORAGE_KEY); | |
| if (saved) reviewerEl.value = saved; | |
| } catch { | |
| // ignore | |
| } | |
| reviewerEl.addEventListener("change", () => { | |
| const v = String(reviewerEl.value || "").trim(); | |
| if (v) persistReviewerName(v); | |
| }); | |
| } | |
| void loadClaimsData(); | |
| refreshPendingProposals(); | |
| }); | |