disclosure-bureau/web/app/h/[hypothesisId]/page.tsx
Luiz Gustavo 857dd771d2
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 33s
CI / Scripts — Python smoke (push) Failing after 7s
CI / Web — npm audit (push) Failing after 38s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s
W3.8: Schneier red-team detective + /h/[hypothesisId] dossier page
Adds the fourth AI detective in the Investigation Bureau runtime: Bruce
Schneier, who attacks an existing hypothesis as a red-team operator.

Runtime:
  - prompts/schneier.md — discipline (don't disprove, just attack;
    structured output with hidden_assumptions, failure_modes,
    alternative_explanations, recommended_tests, verdict_one_sentence;
    severity ∈ {low, medium, high}; emit INSUFFICIENT_HYPOTHESIS when
    the input is too thin)
  - src/detectives/schneier.ts — reads the hypothesis row + evidence
    chain (joined via evidence_refs FK), feeds Claude with the
    arguments + verbatim quotes, parses strict JSON object
  - src/tools/write_red_team_review.ts — UPDATEs hypotheses.reviewed_by
    + updated_at; APPENDS (or replaces if re-reviewed) a structured
    "## Red-team review (Schneier · X severity)" section to
    case/hypotheses/H-NNNN.md. Caps each list at 5 entries × 240 chars,
    validates verdict ≤ 280 chars.
  - orchestrator: new `red_team_review` kind dispatching to runSchneier

Chat + UI:
  - request_investigation gains kind=red_team_review + hypothesis_id arg
    (validated against H-NNNN regex); detective auto-resolves to schneier
  - chat-bubble inline card paints Schneier in red (#ff3344)
  - /jobs/[id] page swaps title/subtitle/tone per detective; the
    "Question" label becomes "Hypothesis under attack" for red_team_review

New /h/[hypothesisId] page (hypothesis dossier):
  - Server-rendered from public.hypotheses + public.evidence (joined
    via evidence_refs FK + chunk lookup)
  - Header: ID + creator + reviewer (highlighted when Schneier has
    visited), position as headline, question subtitle, Tetlock band
  - Prior + posterior bars with Δ-delta indicator
  - Argument grid: argument_for (green) vs argument_against (pink)
    side-by-side with [[wiki-link]] auto-linking to source chunks
  - Evidence chain: each E-NNNN with Grade A/B/C badge, verbatim
    blockquote, link to source page
  - Red-team review panel: parses the markdown section in the case
    file (severity badge, verdict, 4 bullet panels for
    hidden_assumptions / failure_modes / alternative_explanations /
    recommended_tests). Empty state when not yet reviewed.

RedTeamRequestButton client component + POST /api/h/[id]/red-team —
authenticated user can trigger Schneier in one click; UI swaps to
"acompanhar" link to /jobs/[id] once queued.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:48:12 -03:00

411 lines
16 KiB
TypeScript

/**
* /h/[hypothesisId] — Hypothesis dossier.
*
* Renders one H-NNNN as a full case file:
* - question + position
* - prior + posterior with delta + Tetlock band
* - argument_for vs argument_against side-by-side (responsive)
* - evidence chain cards (each E-NNNN supporting/refuting)
* - red-team review (Schneier) from the .md case file, parsed live
*
* Adjacent action panel: "Red-team this hypothesis" → POST to chat or
* direct enqueue to investigation_jobs.
*/
import { notFound } from "next/navigation";
import Link from "next/link";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pgQuery } from "@/lib/retrieval/db";
import { AuthBar } from "@/components/auth-bar";
import { RedTeamRequestButton } from "@/components/red-team-request-button";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface HypothesisRow {
hypothesis_id: string;
question: string;
position: string;
argument_for: string | null;
argument_against: string | null;
prior: number | string | null;
posterior: number | string | null;
confidence_band: string | null;
status: string;
evidence_refs: unknown;
created_by: string;
reviewed_by: string | null;
created_at: string;
updated_at: string;
}
interface EvidenceRow {
evidence_id: string;
grade: string;
source_page_id: string;
doc_id: string | null;
page: number | null;
chunk_id: string | null;
verbatim_excerpt: string | null;
confidence_band: string | null;
}
const BAND_COLOR: Record<string, string> = {
high: "text-[#06d6a0] border-[#06d6a0]",
medium: "text-[#3fde6a] border-[#3fde6a]",
low: "text-[#ffa500] border-[#ffa500]",
speculation: "text-[#ff6ec7] border-[#ff6ec7]",
};
const GRADE_COLOR: Record<string, string> = {
A: "text-[#06d6a0] border-[#06d6a0]",
B: "text-[#3fde6a] border-[#3fde6a]",
C: "text-[#ffa500] border-[#ffa500]",
};
const SEVERITY_COLOR: Record<string, string> = {
high: "text-[#ff3344] border-[#ff3344]",
medium: "text-[#ff8a4d] border-[#ff8a4d]",
low: "text-[#9aa6b8] border-[#9aa6b8]",
};
function asNumber(n: number | string | null): number | null {
if (n === null || n === undefined) return null;
const v = typeof n === "string" ? parseFloat(n) : n;
return Number.isFinite(v) ? v : null;
}
const CASE_ROOT = process.env.CASE_ROOT || "/data/ufo/case";
interface ParsedRedTeam {
severity: "low" | "medium" | "high" | null;
reviewer: string | null;
reviewed_at: string | null;
job_id: string | null;
verdict: string | null;
hidden_assumptions: string[];
failure_modes: string[];
alternative_explanations: string[];
recommended_tests: string[];
}
/**
* Parse the "## Red-team review (Schneier · X severity)" section from a
* hypothesis case file. The section is structured (we wrote it ourselves
* in write_red_team_review.ts), so we can extract with light regex.
*/
function parseRedTeam(md: string): ParsedRedTeam | null {
const header = md.match(/##\s+Red-team review\s+\(([^)\n]+)\)/i);
if (!header) return null;
const meta = header[1];
const severityMatch = meta.match(/\b(low|medium|high)\b\s+severity/i);
const severity = severityMatch ? (severityMatch[1].toLowerCase() as "low" | "medium" | "high") : null;
const section = md.slice(header.index ?? 0);
// _Reviewed by X on Y — job `Z`._
const review = section.match(/Reviewed by\s+([^\n]+?)\s+on\s+([^\n—]+?)\s*—\s*job\s+`([^`]+)`/);
const reviewer = review?.[1]?.trim() ?? null;
const reviewedAt = review?.[2]?.trim() ?? null;
const jobId = review?.[3]?.trim() ?? null;
const verdictMatch = section.match(/\*\*Verdict\.\*\*\s+([^\n]+)/);
const verdict = verdictMatch?.[1]?.trim() ?? null;
function pickSection(name: string): string[] {
const re = new RegExp(`###\\s+${name}([\\s\\S]+?)(?=\\n###|\\n##|$)`, "i");
const m = section.match(re);
if (!m) return [];
const body = m[1];
if (/_\(none flagged\)_/i.test(body)) return [];
return body
.split("\n")
.map((l) => l.trim())
.filter((l) => l.startsWith("- "))
.map((l) => l.slice(2).trim())
.filter((l) => l.length > 0);
}
return {
severity,
reviewer,
reviewed_at: reviewedAt,
job_id: jobId,
verdict,
hidden_assumptions: pickSection("Hidden assumptions"),
failure_modes: pickSection("Failure modes"),
alternative_explanations: pickSection("Alternative explanations not addressed"),
recommended_tests: pickSection("Recommended discriminating tests"),
};
}
export default async function HypothesisPage({
params,
}: { params: Promise<{ hypothesisId: string }> }) {
const { hypothesisId } = await params;
if (!/^H-\d{4}$/.test(hypothesisId)) notFound();
const rows = await pgQuery<HypothesisRow>(
`SELECT hypothesis_id, question, position, argument_for, argument_against,
prior, posterior, confidence_band, status, evidence_refs,
created_by, reviewed_by, created_at, updated_at
FROM public.hypotheses WHERE hypothesis_id = $1`,
[hypothesisId],
).catch(() => [] as HypothesisRow[]);
const h = rows[0];
if (!h) notFound();
const refIds: string[] = [];
if (Array.isArray(h.evidence_refs)) {
for (const r of h.evidence_refs as Array<Record<string, unknown>>) {
if (typeof r?.evidence_id === "string") refIds.push(r.evidence_id);
}
}
const evidence: EvidenceRow[] = refIds.length > 0
? await pgQuery<EvidenceRow>(
`SELECT e.evidence_id, e.grade, e.source_page_id,
split_part(e.source_page_id, '/p', 1) AS doc_id,
NULLIF(split_part(e.source_page_id, '/p', 2), '')::int AS page,
c.chunk_id, e.verbatim_excerpt, e.confidence_band
FROM public.evidence e
LEFT JOIN public.chunks c ON c.chunk_pk = e.source_chunk_pk
WHERE e.evidence_id = ANY($1::text[])
ORDER BY e.evidence_id`,
[refIds],
).catch(() => [] as EvidenceRow[])
: [];
let redTeam: ParsedRedTeam | null = null;
try {
const file = path.join(CASE_ROOT, "hypotheses", `${hypothesisId}.md`);
const md = await readFile(file, "utf-8");
redTeam = parseRedTeam(md);
} catch {
redTeam = null;
}
const prior = asNumber(h.prior);
const posterior = asNumber(h.posterior);
const delta = prior !== null && posterior !== null ? posterior - prior : null;
const bandTone = (h.confidence_band && BAND_COLOR[h.confidence_band]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<AuthBar />
<div className="mx-auto max-w-5xl px-4 py-8 pt-16">
<div className="text-[11px] text-[#5a6678] font-mono mb-2">
<Link href="/" className="hover:text-[#7fdbff]">disclosure.top</Link>
<span className="mx-1">/</span>
<span>hypothesis</span>
<span className="mx-1">/</span>
<span className="text-[#7fdbff]">{hypothesisId}</span>
</div>
{/* Header */}
<div className="rounded-lg border border-[rgba(127,219,255,0.18)] bg-gradient-to-br from-[rgba(127,219,255,0.08)] to-transparent p-5">
<div className="flex items-baseline justify-between gap-4 flex-wrap">
<div>
<div className="text-[10px] font-mono text-[#5a6678] uppercase mb-1">
{hypothesisId} · created by {h.created_by}
{h.reviewed_by && <> · reviewed by <span className="text-[#ff3344]">{h.reviewed_by}</span></>}
</div>
<h1 className="text-xl font-mono text-[#e7ecf3] leading-snug">{h.position}</h1>
<p className="text-[12px] text-[#9aa6b8] mt-1">Question: {h.question}</p>
</div>
{h.confidence_band && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}>
{h.confidence_band}
</span>
)}
</div>
{/* Probability bars */}
{(prior !== null || posterior !== null) && (
<div className="mt-4 grid grid-cols-2 gap-3 text-[11px] font-mono">
<Bar label="prior" value={prior} color="#5a6678" />
<Bar label="posterior" value={posterior} color="#7fdbff" />
</div>
)}
{delta !== null && (
<div className="mt-2 text-[10px] font-mono text-[#5a6678]">
Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)} ·{" "}
{delta > 0.05 ? <span className="text-[#06d6a0]">evidência reforçou</span> :
delta < -0.05 ? <span className="text-[#ff6ec7]">evidência reduziu</span> :
<span className="text-[#9aa6b8]">evidência ambígua</span>}
</div>
)}
</div>
{/* Argument grid */}
<div className="mt-4 grid md:grid-cols-2 gap-3">
<ArgumentPanel kind="for" body={h.argument_for} />
<ArgumentPanel kind="against" body={h.argument_against} />
</div>
{/* Evidence chain */}
{evidence.length > 0 && (
<div className="mt-6">
<div className="text-[12px] font-mono text-[#06d6a0] uppercase tracking-wider mb-2">
Cadeia de evidência ({evidence.length})
</div>
<div className="space-y-3">
{evidence.map((e) => <EvidenceMini key={e.evidence_id} e={e} />)}
</div>
</div>
)}
{/* Red-team review */}
<div className="mt-6">
<div className="flex items-baseline justify-between mb-2 flex-wrap gap-2">
<div className="text-[12px] font-mono text-[#ff3344] uppercase tracking-wider">
Revisão Red Team (Schneier)
</div>
<RedTeamRequestButton hypothesisId={hypothesisId} alreadyReviewed={Boolean(redTeam)} />
</div>
{redTeam ? <RedTeamPanel rt={redTeam} /> : (
<div className="rounded-lg border border-dashed border-[rgba(255,51,68,0.18)] bg-[#0d1220] p-4 text-[12px] font-mono text-[#9aa6b8]">
Nenhuma revisão red-team ainda. Click acima para acionar Schneier.
</div>
)}
</div>
</div>
</div>
);
}
function ArgumentPanel({ kind, body }: { kind: "for" | "against"; body: string | null }) {
const tone = kind === "for" ? "border-[#06d6a0] text-[#06d6a0]" : "border-[#ff6ec7] text-[#ff6ec7]";
const label = kind === "for" ? "Argumento a favor" : "Argumento contra";
return (
<div className={`rounded-lg border ${tone} bg-[#0d1220] p-4`}>
<div className={`text-[10px] font-mono uppercase mb-2 ${tone}`}>{label}</div>
<ArgumentBody text={body ?? ""} />
</div>
);
}
function EvidenceMini({ e }: { e: EvidenceRow }) {
const gradeTone = (e.grade && GRADE_COLOR[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]";
const bandTone = (e.confidence_band && BAND_COLOR[e.confidence_band]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<div className="rounded-lg border border-[rgba(6,214,160,0.18)] bg-[#0d1220] p-3">
<div className="flex items-baseline justify-between gap-2 mb-1">
<div className="text-[10px] font-mono text-[#5a6678] uppercase">{e.evidence_id}</div>
<div className="flex items-center gap-2">
{e.grade && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${gradeTone}`}>
Grade {e.grade}
</span>
)}
{e.confidence_band && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}>
{e.confidence_band}
</span>
)}
</div>
</div>
{e.verbatim_excerpt && (
<blockquote className="text-[12px] text-[#e7ecf3] italic border-l-2 border-[#06d6a0] pl-2 my-1">
{e.verbatim_excerpt.slice(0, 360)}
</blockquote>
)}
{e.doc_id && e.page && (
<Link
href={`/d/${e.doc_id}/p${String(e.page).padStart(3, "0")}${e.chunk_id ? `#${e.chunk_id}` : ""}`}
className="text-[10px] font-mono text-[#7fdbff] hover:underline"
>
{e.doc_id}/p{String(e.page).padStart(3, "0")}{e.chunk_id ? `#${e.chunk_id}` : ""}
</Link>
)}
</div>
);
}
function RedTeamPanel({ rt }: { rt: ParsedRedTeam }) {
const tone = (rt.severity && SEVERITY_COLOR[rt.severity]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<div className="rounded-lg border border-[rgba(255,51,68,0.18)] bg-[#0d1220] p-4 space-y-3">
<div className="flex items-baseline justify-between gap-2 flex-wrap">
<div className="text-[10px] font-mono text-[#5a6678]">
{rt.reviewer ?? "—"}{rt.reviewed_at ? ` · ${rt.reviewed_at}` : ""}{rt.job_id ? ` · job ${rt.job_id.slice(0, 8)}` : ""}
</div>
{rt.severity && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${tone}`}>
{rt.severity} severity
</span>
)}
</div>
{rt.verdict && (
<blockquote className="text-[14px] text-[#e7ecf3] italic border-l-2 border-[#ff3344] pl-3">
{rt.verdict}
</blockquote>
)}
<div className="grid md:grid-cols-2 gap-3">
<BulletPanel title="Hidden assumptions" items={rt.hidden_assumptions} color="text-[#ff8a4d]" />
<BulletPanel title="Failure modes" items={rt.failure_modes} color="text-[#ff3344]" />
<BulletPanel title="Alternative explanations" items={rt.alternative_explanations} color="text-[#ff6ec7]" />
<BulletPanel title="Recommended tests" items={rt.recommended_tests} color="text-[#7fdbff]" />
</div>
</div>
);
}
function BulletPanel({ title, items, color }: { title: string; items: string[]; color: string }) {
return (
<div className="rounded border border-[rgba(255,51,68,0.1)] bg-[#060a13] p-3">
<div className={`text-[10px] font-mono uppercase mb-1 ${color}`}>{title}</div>
{items.length === 0 ? (
<div className="text-[11px] font-mono text-[#5a6678]">_(none flagged)_</div>
) : (
<ul className="text-[11px] text-[#cbd2dd] leading-relaxed list-disc pl-4 space-y-1">
{items.map((x, i) => <li key={i}>{x}</li>)}
</ul>
)}
</div>
);
}
function Bar({ label, value, color }: { label: string; value: number | null; color: string }) {
const pct = value !== null ? Math.round(value * 100) : 0;
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[#5a6678]">{label}</span>
<span className="text-[#e7ecf3]">{value !== null ? value.toFixed(3) : "—"}</span>
</div>
<div className="h-1.5 bg-[rgba(127,219,255,0.08)] rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: color }} />
</div>
</div>
);
}
function ArgumentBody({ text }: { text: string }) {
if (!text.trim()) return <p className="text-[12px] text-[#5a6678]">_(none recorded)_</p>;
const parts: Array<{ kind: "text" | "link"; raw: string; href?: string; label?: string }> = [];
const re = /\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g;
let lastIdx = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > lastIdx) parts.push({ kind: "text", raw: text.slice(lastIdx, m.index) });
const target = m[1];
const label = m[2] ?? m[1];
let href: string | undefined;
const chunkMatch = target.match(/^([a-z0-9][a-z0-9-]*)\/p(\d{3})#(c\d{4})$/);
const pageMatch = target.match(/^([a-z0-9][a-z0-9-]*)\/p(\d{3})$/);
if (chunkMatch) href = `/d/${chunkMatch[1]}/p${chunkMatch[2]}#${chunkMatch[3]}`;
else if (pageMatch) href = `/d/${pageMatch[1]}/p${pageMatch[2]}`;
parts.push({ kind: "link", raw: m[0], href, label });
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) parts.push({ kind: "text", raw: text.slice(lastIdx) });
return (
<div className="text-[13px] text-[#cbd2dd] leading-relaxed whitespace-pre-wrap">
{parts.map((p, i) =>
p.kind === "text" ? <span key={i}>{p.raw}</span> :
p.href ? <Link key={i} href={p.href} className="text-[#7fdbff] hover:underline">{p.label}</Link> :
<span key={i} className="text-[#5a6678]">{p.label}</span>
)}
</div>
);
}