From 8915cc9137a274951baaccb6b6d6bd5cb622a7e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 09:18:11 +0000 Subject: [PATCH] feat(portal): surface cross-article claim links + clarify case vocabulary (T1.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The case dashboard hid the cross-article relationship links that already existed, and two sections read as opaque to a reviewer. - Claims list (case-view.js) surfaces each claim's non-contradiction relationship links — supports/updates/duplicates, both directions, naming the linked claim — over the same library items the ⚠ contradiction badge already used. Contradiction keeps its badge; the diachronic revision/* edges stay a forensic surface, excluded. Display-only; no collector or dossier-graph change. - One-line explainers under the Evidence and Case-graph headers answering 'what is this section for'; the outbound-links line now reads 'cites N outbound URLs (M also in this case) · cited by K case sources' instead of the ambiguous 'links to N external sources'. Pure render/copy. CHANGELOG + SMOKE P26.e-f. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017SZCGu5qxuF2QCuGyJrNcH --- CHANGELOG.md | 14 +++++++++ docs/SMOKE_TEST.md | 2 ++ src/portal/case-graph-view.js | 4 +++ src/portal/case-view.js | 53 +++++++++++++++++++++++++++++++++++ src/portal/evidence-block.js | 10 +++++-- src/portal/index.css | 2 ++ 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c556ca..e1de79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ Sections per release: **Added** (new features), **Changed** ### Added +- **Case dashboard — cross-article claim links + clearer vocabulary + (Phase 26 prep, T1.3).** The claims list now surfaces the + **cross-article relationship links** that already existed but were + never shown — each claim's `supports` / `updates` / `duplicates` + edges (both directions, naming the linked claim). Contradiction + keeps its own ⚠ badge. And the two sections a reviewer found opaque + now carry one-line explainers: the **Evidence** list ("each captured + source: what it claims and which outbound URLs it cites; grouped by + independent origin") and the **Case graph** ("the same sources + plotted, plus the entities in them and how they interconnect"); the + outbound-links line reads "cites N outbound URLs (M also in this + case) · cited by K case sources" instead of the ambiguous "links + to…". Display-only — the links already lived in the store. + - **Corpus brief — download + provenance surfacing (Phase 26 prep, T1.1/T1.2).** The stored case brief gains **Download .md / .json** controls beside "Publish brief…" (the `.md` is the same readable, diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 2c45e9f..757bb4b 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -1307,6 +1307,8 @@ stored brief (run "Analyze corpus…" once). | P26.b | **Download .json** | ✅ a `case-brief-.json` downloads; contains `brief`, `grounding`, `model`, `members`, `inputHash` | | P26.c | Inspect the rendered brief on the dashboard | ✅ each **crux** shows its grounded evidence quotes with a source link; each **load-bearing** claim shows a "Source:" link (and a "Claim:" text when `claim_ref` resolves); each **position** shows "Held by:" source links | | P26.d | Click a source link | ✅ opens the captured source's original URL in a new tab; nothing publishes | +| P26.e | A case with a `supports`/`updates`/`duplicates` link between two claims → case dashboard ▸ Claims | ✅ the claim rows show a "→ supports: " / "← updated by: " badge naming the linked claim; contradiction still shows its ⚠ badge separately | +| P26.f | Read the Evidence and Case-graph section headers | ✅ each has a one-line explainer; the source's link line reads "cites N outbound URLs (M also in this case) · cited by K case sources" | --- diff --git a/src/portal/case-graph-view.js b/src/portal/case-graph-view.js index b746bb0..4d70319 100644 --- a/src/portal/case-graph-view.js +++ b/src/portal/case-graph-view.js @@ -32,6 +32,10 @@ export function renderCaseGraph(host, { data, callbacks = {} }) { const positions = layoutCaseGraph(graph, { size: SIZE }); block.appendChild(el('h3', 'xr-case__heading', 'Case graph — articles, entities, and how they connect')); + block.appendChild(el('div', 'xr-case__explainer', + 'The same sources as the Evidence list above, plotted as a graph — plus the people and ' + + 'organizations tagged or claimed in them, which sources share entities (co-tag links), and ' + + 'where claims contradict. Click an entity for its dossier, an article to open it in the reader.')); const c = graph.counts; block.appendChild(el('div', 'xr-view__dossier-line', `${c.articles} article${c.articles === 1 ? '' : 's'} · ${c.entities} entit${c.entities === 1 ? 'y' : 'ies'} · ` diff --git a/src/portal/case-view.js b/src/portal/case-view.js index eb57946..ddc1d26 100644 --- a/src/portal/case-view.js +++ b/src/portal/case-view.js @@ -63,6 +63,44 @@ function contradictedCoords(items) { return out; } +// Claim titles by coordinate — so a cross-article link can name the +// claim it points at (built from the case's own claim items). +function claimTitleByCoord(items) { + const map = new Map(); + for (const item of items) { + if (item.typeKey === 'claim' && item.claimCoord) map.set(item.claimCoord, item.title || ''); + } + return map; +} + +// Non-contradiction relationship links (supports / updates / duplicates) +// touching each claim coordinate, both directions — the cross-article +// links the dashboard never surfaced. Contradiction keeps its own ⚠ +// badge; the diachronic revision/* edges are a forensic surface, +// excluded here. Returns coord → [{relationship, dir, otherCoord}]. +const RELATED_RELATIONSHIPS = new Set(['supports', 'updates', 'duplicates']); +function relatedLinksByCoord(items) { + const map = new Map(); + const add = (coord, entry) => { + if (!coord) return; + if (!map.has(coord)) map.set(coord, []); + map.get(coord).push(entry); + }; + for (const item of items) { + if (item.typeKey !== 'link' || !RELATED_RELATIONSHIPS.has(item.relationship)) continue; + add(item.sourceCoord, { relationship: item.relationship, dir: 'out', otherCoord: item.targetCoord }); + add(item.targetCoord, { relationship: item.relationship, dir: 'in', otherCoord: item.sourceCoord }); + } + return map; +} + +// "supports →" / "← supported by" phrasing per relationship + direction. +const RELATED_PHRASE = { + supports: { out: 'supports', in: 'supported by' }, + updates: { out: 'updates', in: 'updated by' }, + duplicates: { out: 'duplicates', in: 'duplicated by' } +}; + /** * @param {HTMLElement} host * @param {object} params @@ -301,6 +339,8 @@ export function renderCaseView(host, params) { // --- claims with stance/⚠ badges --- const assessments = latestAssessmentByCoord(items); const contradicted = contradictedCoords(items); + const relatedLinks = relatedLinksByCoord(items); + const claimTitles = claimTitleByCoord(items); const claims = caseItems.filter((i) => i.typeKey === 'claim'); const section = el('div', 'xr-case__claims'); section.appendChild(el('h3', 'xr-case__heading', `Claims (${claims.length})`)); @@ -333,6 +373,19 @@ export function renderCaseView(host, params) { } row.appendChild(headRow); if (item.sub) row.appendChild(el('div', 'xr-row__sub', truncate(item.sub, 240))); + // Cross-article relationship links (supports/updates/duplicates) + // touching this claim — the links the dashboard never surfaced. + const related = item.claimCoord ? relatedLinks.get(item.claimCoord) : null; + if (related && related.length) { + const relRow = el('div', 'xr-case__related'); + for (const r of related.slice(0, 6)) { + const phrase = (RELATED_PHRASE[r.relationship] || {})[r.dir] || r.relationship; + const other = (r.otherCoord && claimTitles.get(r.otherCoord)) || 'another claim'; + relRow.appendChild(el('span', 'xr-badge xr-badge--muted', + `${r.dir === 'out' ? '→' : '←'} ${phrase}: ${truncate(other, 60)}`)); + } + row.appendChild(relRow); + } list.appendChild(row); } if (claims.length === 0) { diff --git a/src/portal/evidence-block.js b/src/portal/evidence-block.js index 961f27c..f453649 100644 --- a/src/portal/evidence-block.js +++ b/src/portal/evidence-block.js @@ -46,6 +46,10 @@ export function renderEvidenceBlock(host, dossierOrId, opts = {}) { if (ev.articles.length === 0 && ev.unprocessed_sources.length === 0) { block.remove(); return; } block.appendChild(el('h3', 'xr-case__heading', 'Evidence — sources collapsed by origin')); + block.appendChild(el('div', 'xr-case__explainer', + 'Each captured source in this case: what it claims, and which outbound URLs it cites. ' + + 'Sources reporting the same fact are grouped by their independent origin, so N reports ' + + 'tracing to one origin count as one.')); block.appendChild(el('div', 'xr-view__dossier-line', `${ev.coverage.articles} sources · ${ev.coverage.attested_articles} attested · ` + `${ev.coverage.articles_with_audit} audited · ${ev.coverage.unprocessed} unprocessed`)); @@ -105,11 +109,11 @@ export function renderEvidenceBlock(host, dossierOrId, opts = {}) { if (lnk && (lnk.captured || lnk.linked_by.length > 0)) { const bits = []; if (lnk.captured) { - bits.push(`links to ${lnk.external} external source${lnk.external === 1 ? '' : 's'}` - + (lnk.corpus_links.length ? ` (${lnk.corpus_links.length} in this case)` : '')); + bits.push(`cites ${lnk.external} outbound URL${lnk.external === 1 ? '' : 's'}` + + (lnk.corpus_links.length ? ` (${lnk.corpus_links.length} also in this case)` : '')); } if (lnk.linked_by.length > 0) { - bits.push(`linked from ${lnk.linked_by.length} case article${lnk.linked_by.length === 1 ? '' : 's'}`); + bits.push(`cited by ${lnk.linked_by.length} case source${lnk.linked_by.length === 1 ? '' : 's'}`); } li.appendChild(el('div', 'xr-inspector__mono', bits.join(' · '))); } diff --git a/src/portal/index.css b/src/portal/index.css index 4e5dcd3..8baffe6 100644 --- a/src/portal/index.css +++ b/src/portal/index.css @@ -560,6 +560,8 @@ body.xr-portal { .xr-synth__prov-label { color: var(--xr-text-dim); margin-right: 4px; } .xr-synth__src { color: var(--xr-accent, #22d3ee); text-decoration: none; } .xr-synth__src:hover { text-decoration: underline; } +.xr-case__explainer { color: var(--xr-text-dim); font-size: 12px; margin: 2px 0 8px; max-width: 70ch; line-height: 1.4; } +.xr-case__related { display: flex; gap: 6px; flex-wrap: wrap; margin: 4px 0 2px; } .xr-synth__prop { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 6px 0; border-bottom: 1px solid var(--xr-border); } .xr-synth__prop--rejected { color: var(--xr-text-dim); } .xr-synth__prop-desc { flex: 1 1 auto; min-width: 200px; }