Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/background/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { NostrClient } from '../shared/nostr-client.js';
import { EventBuilder } from '../shared/event-builder.js';
import { fetchSubstackPost, fetchSubstackComments } from '../shared/platforms/substack-api.js';
import { handleScreenshotCapture } from '../shared/screenshot.js';
import { runSuggestionPass, runAuditPass, runAuditModulePass, getLlmConfig, runLensPass, getLensConfig, runCorpusMapPass, runCorpusReducePass, getCorpusConfig } from '../shared/llm-client.js';
import { runSuggestionPass, runAuditPass, runAuditModulePass, getLlmConfig, runLensPass, getLensConfig, runCorpusMapPass, runCorpusReducePass, runHypothesisEdgePass, getCorpusConfig } from '../shared/llm-client.js';
import { pdfDocumentUrl } from '../shared/pdf-detect.js';
import { Signer } from '../shared/signer.js';
import { loadFlags, isEnabled } from '../shared/metadata/feature-flags.js';
Expand Down Expand Up @@ -578,6 +578,17 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
);
return true; // async sendResponse
}
// Portal → worker: hypothesis-edge suggestion (Phase 26 H.4). One
// reduce-shaped call; same triple gate inside the pass; returns RAW
// tool output (the portal validates, grounds, runs the both-sides
// post-check, and gates every mutation behind human Accept).
if (message.type === 'xray:llm:hypothesis-edges') {
runHypothesisEdgePass(message.request || {}).then(
(result) => sendResponse(result),
(err) => sendResponse({ ok: false, error: (err && err.message) || 'Hypothesis edge call failed' })
);
return true; // async sendResponse
}
if (message.type === 'xray:llm:corpus-config') {
getCorpusConfig().then(
(cfg) => sendResponse({ ok: true, ...cfg }),
Expand Down
9 changes: 5 additions & 4 deletions src/portal/case-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,12 @@ export function renderCaseView(host, params) {
data, dossier,
callbacks: { onReloadCase: callbacks.onReloadCase }
});
// 26 H.2/H.3 — the hypothesis map, assembled from the same
// shared `data`, with the manual authoring affordances.
// Renders nothing on a claimless case with no map.
// 26 H.2/H.3/H.4 — the hypothesis map, assembled from the
// same shared `data`, with the manual authoring affordances
// and the gated LLM edge suggestion (dossier feeds its
// digest). Renders nothing on a claimless case with no map.
renderHypothesesBlock(hypothesesHost, {
data,
data, dossier,
callbacks: { onReloadCase: callbacks.onReloadCase }
});
renderCaseTimeline(timelineHost, dossier);
Expand Down
211 changes: 206 additions & 5 deletions src/portal/hypothesis-block.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,20 @@
import { el, truncate } from './dom.js';
import { collectHypothesisMapData, buildHypothesisMap } from '../shared/hypothesis-map.js';
import { HypothesisModel, HypothesisEdgeModel, HYPOTHESIS_EDGE_ROLES, HYPOTHESIS_EDGE_ROLE_LABELS } from '../shared/hypothesis-model.js';
import {
validateHypothesisEdges, groundEdgeQuotes, filterEdgeProposals, unopposedHypotheses
} from '../shared/hypothesis-suggest.js';
import { digestDossier, DIGEST_CLAIM_CAP } from '../shared/case-synthesis.js';
import { VERDICT_STATE_LABELS, PROPOSITION_CLASS_LABELS } from '../shared/truth-taxonomy.js';
import { Utils } from '../shared/utils.js';

function sendMessage(msg) {
return new Promise((resolve) => {
try { chrome.runtime.sendMessage(msg, (resp) => resolve(resp)); }
catch (_) { resolve(null); }
});
}

const MAX_EDGES_PER_SECTION = 12;
const MAX_DANGLING = 6;

Expand All @@ -31,6 +42,17 @@ export const CRUX_BADGE_TITLE =
'This claim is attached to more than one hypothesis — the disagreement, made legible.';
export const VERDICT_CHIP_TITLE =
'Verdict context for this claim — it does not weight the edge.';
export const SUGGEST_STATUS_PROPOSING = 'Proposing edges…';
export const SUGGEST_STATUS_MALFORMED = 'The model returned malformed edge proposals.';
export const SUGGEST_STATUS_FAILED = 'Edge suggestion failed';

/** The suggest pass's disclosure line — pure, so the guard walks it. */
export function suggestStatusLine({ checked, dropped, rejected, proposals }) {
return `${checked} quote${checked === 1 ? '' : 's'} checked · `
+ `${dropped} ungrounded (dropped) · ${rejected} rejected · `
+ `${proposals} proposal${proposals === 1 ? '' : 's'}`;
}

export const AUTHORING_STRINGS = Object.freeze([
'Add hypothesis…', 'Attach claim…', 'Add', 'Attach', 'Cancel',
'✕ detach', '✕ delete hypothesis',
Expand All @@ -39,6 +61,15 @@ export const AUTHORING_STRINGS = Object.freeze([
'Statement (the competing answer, editable)',
'Note (optional — why this claim bears on this answer)',
'A claim can support one hypothesis and undermine another — attach it to each; nothing is netted.',
// H.4 suggest-edges surface (static fragments; counts are appended
// as plain numbers of the map's own sections).
'Suggest edges (LLM)…',
'Set an Anthropic API key in Options → Advanced → LLM assist',
'Proposed attachments — nothing applies without your Accept.',
'Accept', 'Accepted ✓', 'Dismiss', 'Refresh map',
'rejected:',
'received no undermining scrutiny in this pass — treat their support as unexamined, not established:',
SUGGEST_STATUS_PROPOSING, SUGGEST_STATUS_MALFORMED, SUGGEST_STATUS_FAILED,
CRUX_BADGE_TITLE, VERDICT_CHIP_TITLE
]);

Expand Down Expand Up @@ -91,6 +122,10 @@ export function buildHypothesisBlockModel(map) {
title: h.label,
statement: h.statement,
persisted: h.persisted,
// The UNCAPPED per-role counts (the map row's coverage) —
// the delete confirm must state the full blast radius even
// when the rendered lists are truncated.
coverage: { supports: h.coverage.supports, undermines: h.coverage.undermines },
provenance: provenanceBadge(h.suggested_by),
holders: h.holders.map((hold) => ({
label: hold.title || hold.url || `${hold.article_hash.slice(0, 12)}… (not in local archive)`,
Expand Down Expand Up @@ -233,13 +268,140 @@ function mountAttachClaim(host, { caseId, card, claims, onChanged }) {
host.appendChild(form);
}

/**
* H.4 — the LLM edge-suggestion surface. One reduce-shaped pass over
* the dossier digest + the map's hypothesis rows (seeds included; a
* seed is promoted at accept time). Everything the model returns goes
* validate → ground → filter → both-sides disclosure, and each
* surviving proposal lands only on a human Accept, stamped
* `suggested_by: 'llm:<model>'`.
*/
function mountSuggestPanel(panelHost, { data, dossier, onChanged, onSettled, model }) {
panelHost.replaceChildren();
const status = el('div', 'xr-inspector__mono', SUGGEST_STATUS_PROPOSING);
panelHost.appendChild(status);
const settle = () => { if (onSettled) onSettled(); };

(async () => {
// Re-collect the map FRESH: edges accepted in a previous panel
// (and seeds promoted by them) must reach `existingEdges` and
// `rows`, or a re-run would re-propose already-applied work
// instead of rejecting it as 'already attached'.
const input = await collectHypothesisMapData(data.case.id, { data });
const map = buildHypothesisMap(input, null);
// Digest set === validation/grounding set (the 20.6 discipline):
// digestDossier caps its claim index, so cap the SAME list here.
const orbitClaims = ((data.orbit && data.orbit.claims) || []).slice(0, DIGEST_CLAIM_CAP);
const claimsById = {};
for (const c of orbitClaims) claimsById[c.id] = c;
const rows = map.hypotheses.map((h) => ({ id: h.id, label: h.label, statement: h.statement }));
const existingEdges = map.hypotheses.flatMap((h) =>
[...h.edges.supports, ...h.edges.undermines].map((e) => ({
hypothesis_id: h.id, ref: e.ref, role: e.role
})));

const res = await sendMessage({ type: 'xray:llm:hypothesis-edges', request: {
dossierDigest: digestDossier(dossier, { claims: orbitClaims }),
hypotheses: rows,
caseName: data.case.name || '',
scopeQuestion: map.question.text || ''
} });
if (!res || !res.ok) {
status.textContent = `${SUGGEST_STATUS_FAILED}: ${(res && res.error) || 'no response'}`;
settle();
return;
}
const v = validateHypothesisEdges(res.edgesInput);
if (!v.ok) {
status.textContent = SUGGEST_STATUS_MALFORMED;
Utils.error('hypothesis edge validation', v.errors);
settle();
return;
}

const grounded = groundEdgeQuotes(res.edgesInput.edges, claimsById);
const { acceptable, rejected } = filterEdgeProposals(grounded.edges, {
hypotheses: rows, claimsById, existingEdges
});
const unopposed = unopposedHypotheses(rows, acceptable, existingEdges);
const passModel = res.model || model;

settle();
status.textContent = suggestStatusLine({
checked: grounded.checked, dropped: grounded.dropped,
rejected: rejected.length, proposals: acceptable.length
});
if (unopposed.length > 0) {
panelHost.appendChild(el('div', 'xr-view__dossier-line',
`${unopposed.length} hypothes${unopposed.length === 1 ? 'is' : 'es'} `
+ 'received no undermining scrutiny in this pass — treat their support as unexamined, not established: '
+ unopposed.map((u) => u.label).join(' · ')));
}
if (acceptable.length === 0 && rejected.length === 0) return;

panelHost.appendChild(el('div', 'xr-case__explainer',
'Proposed attachments — nothing applies without your Accept.'));
const rowById = new Map(rows.map((r) => [r.id, r]));
for (const p of acceptable) {
const row = el('div', 'xr-hyp__edge');
const line = el('div', 'xr-hyp__edgeline');
line.appendChild(el('span', 'xr-badge xr-badge--muted', HYPOTHESIS_EDGE_ROLE_LABELS[p.role]));
line.appendChild(el('span', null,
`${(rowById.get(p.hypothesis_id) || {}).label || p.hypothesis_id} ← ${truncate((claimsById[p.claim_ref] || {}).text || p.claim_ref, 120)}`));
const accept = el('button', 'xr-portal__btn', 'Accept');
accept.type = 'button';
accept.addEventListener('click', async () => {
try {
accept.disabled = true;
const target = rowById.get(p.hypothesis_id);
const hyp = await HypothesisModel.create({
case_id: data.case.id, label: target.label,
statement: target.statement === target.label ? '' : target.statement,
suggested_by: `llm:${passModel}`
});
await HypothesisEdgeModel.create({
hypothesis_id: hyp.id, claim_ref: p.claim_ref, role: p.role,
note: p.why || '', quote: p.quote, suggested_by: `llm:${passModel}`
});
accept.textContent = 'Accepted ✓';
} catch (err) {
accept.disabled = false;
Utils.error('Accept edge failed', err);
}
});
line.appendChild(accept);
const dismiss = el('button', 'xr-portal__btn xr-portal__btn--ghost', 'Dismiss');
dismiss.type = 'button';
dismiss.addEventListener('click', () => row.remove());
line.appendChild(dismiss);
row.appendChild(line);
if (p.quote) row.appendChild(el('blockquote', 'xr-finding-row__quote', truncate(p.quote, 200)));
if (p.why) row.appendChild(el('div', 'xr-inspector__mono', p.why));
panelHost.appendChild(row);
}
for (const r of rejected.slice(0, 8)) {
panelHost.appendChild(el('div', 'xr-inspector__mono',
`rejected: ${r.hypothesis_id} ← ${r.claim_ref} (${r.reason})`));
}
const refresh = el('button', 'xr-portal__btn', 'Refresh map');
refresh.type = 'button';
refresh.addEventListener('click', () => onChanged());
panelHost.appendChild(refresh);
})().catch((err) => {
Utils.error('Suggest edges failed', err);
status.textContent = `${SUGGEST_STATUS_FAILED}.`;
settle();
});
}

/**
* Render the hypothesis map for a case. `data` is the shared
* `collectCaseDossierData` envelope (assembled once by the case view);
* the brief and the hypothesis/edge models are read live.
* `dossier` the built case dossier (for the H.4 digest); the brief and
* the hypothesis/edge models are read live.
* `callbacks.onReloadCase` re-renders the case view after authoring.
*/
export function renderHypothesesBlock(host, { data, callbacks = {} }) {
export function renderHypothesesBlock(host, { data, dossier, callbacks = {} }) {
if (!data || !data.case) return;
const block = el('div', 'xr-view__dossier xr-hyp');
host.appendChild(block);
Expand All @@ -259,14 +421,51 @@ export function renderHypothesesBlock(host, { data, callbacks = {} }) {
if (model.questionLine) block.appendChild(el('div', 'xr-view__dossier-line', model.questionLine));
if (!model.empty) block.appendChild(el('div', 'xr-view__dossier-line', model.countsLine));

// H.3 — add a competing answer by hand.
// H.3 — add a competing answer by hand; H.4 — the gated LLM
// suggestion (caseSynthesis + llmAssist + key, checked in the
// worker too; this button is advisory surface-gating only).
const controls = el('div', 'xr-synth__controls');
const addHost = el('div');
const addBtn = el('button', 'xr-portal__btn', 'Add hypothesis…');
addBtn.type = 'button';
addBtn.addEventListener('click', () =>
mountAddHypothesis(addHost, { caseId: data.case.id, onChanged }));
block.appendChild(addBtn);
controls.appendChild(addBtn);
const suggestHost = el('div');
if (!model.empty && dossier && orbitClaims.length > 0) {
const cfg = await sendMessage({ type: 'xray:llm:corpus-config' });
if (cfg && cfg.enabled) {
const suggestBtn = el('button', 'xr-portal__btn', 'Suggest edges (LLM)…');
suggestBtn.type = 'button';
if (!cfg.hasKey) {
suggestBtn.disabled = true;
suggestBtn.title = 'Set an Anthropic API key in Options → Advanced → LLM assist';
}
suggestBtn.addEventListener('click', () => {
// The digest carries at most DIGEST_CLAIM_CAP claims —
// the confirm states what is actually sent.
const sent = Math.min(orbitClaims.length, DIGEST_CLAIM_CAP);
const capNote = orbitClaims.length > DIGEST_CLAIM_CAP
? ` (the first ${sent} of ${orbitClaims.length} claims — the digest is capped)` : '';
if (!confirm(`Suggest claim→hypothesis edges with the LLM?\n\n`
+ `This sends the case's dossier digest (${sent} claim${sent === 1 ? '' : 's'}${capNote}) `
+ `and ${map.hypotheses.length} hypothes${map.hypotheses.length === 1 ? 'is' : 'es'} to Anthropic — one call. `
+ `Every proposal still needs your Accept.`)) return;
// One pass at a time — a second click mid-flight would
// spend a second API call and interleave panels.
suggestBtn.disabled = true;
mountSuggestPanel(suggestHost, {
data, dossier, onChanged,
onSettled: () => { suggestBtn.disabled = !cfg.hasKey; },
model: cfg.model
});
});
controls.appendChild(suggestBtn);
}
}
block.appendChild(controls);
block.appendChild(addHost);
block.appendChild(suggestHost);
if (model.empty) return;

const grid = el('div', 'xr-hyp__grid');
Expand All @@ -290,7 +489,9 @@ export function renderHypothesesBlock(host, { data, callbacks = {} }) {
const delBtn = el('button', 'xr-portal__btn xr-portal__btn--ghost', '✕ delete hypothesis');
delBtn.type = 'button';
delBtn.addEventListener('click', async () => {
const n = card.sections.reduce((a, s) => a + s.edges.length, 0);
// The UNCAPPED count — the rendered lists truncate at
// MAX_EDGES_PER_SECTION, but delete removes them all.
const n = card.coverage.supports + card.coverage.undermines;
const msg = n > 0
? `Delete hypothesis "${card.title}"? Its ${n} claim attachment${n === 1 ? '' : 's'} will also be removed.`
: `Delete hypothesis "${card.title}"?`;
Expand Down
8 changes: 7 additions & 1 deletion src/shared/case-synthesis.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,16 @@ export async function corpusInputHash(members, orbitClaimIds, promptVersion = CO
* (20.6). Size-capped; counts stay on the face so the model (and the
* reader) see coverage.
*/
// The digest's claim-index cap — exported so callers that must keep
// their validation/grounding set identical to the digest set (the 20.6
// discipline) can cap the SAME list, and so spend-confirms can state
// what is actually sent.
export const DIGEST_CLAIM_CAP = 150;

export function digestDossier(dossier, { claims = [] } = {}) {
const shape = dossier.shape_of_knowledge || {};
const knots = dossier.knots || {};
const claimIndex = claims.slice(0, 150).map((c) => ({
const claimIndex = claims.slice(0, DIGEST_CLAIM_CAP).map((c) => ({
id: c.id,
text: (c.text || '').slice(0, 160),
article_hash: c.article_hash || null
Expand Down
Loading