/** * /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 { BureauNav } from "@/components/bureau-nav"; import { RedTeamRequestButton } from "@/components/red-team-request-button"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; interface HypothesisRow { hypothesis_id: string; question: string; question_pt_br: string | null; position: string; position_pt_br: string | null; argument_for: string | null; argument_for_pt_br: string | null; argument_against: string | null; argument_against_pt_br: 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 = { high: "text-[#06d6a0] border-[#06d6a0]", medium: "text-[#3fde6a] border-[#3fde6a]", low: "text-[#ffa500] border-[#ffa500]", speculation: "text-[#ff6ec7] border-[#ff6ec7]", }; const GRADE_COLOR: Record = { A: "text-[#06d6a0] border-[#06d6a0]", B: "text-[#3fde6a] border-[#3fde6a]", C: "text-[#ffa500] border-[#ffa500]", }; const SEVERITY_COLOR: Record = { 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( `SELECT hypothesis_id, question, question_pt_br, position, position_pt_br, argument_for, argument_for_pt_br, argument_against, argument_against_pt_br, 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>) { if (typeof r?.evidence_id === "string") refIds.push(r.evidence_id); } } const evidence: EvidenceRow[] = refIds.length > 0 ? await pgQuery( `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 (
{/* Header */}
{hypothesisId} · created by {h.created_by} {h.reviewed_by && <> · reviewed by {h.reviewed_by}}

{h.position_pt_br || h.position}

{h.position_pt_br && h.position_pt_br !== h.position && (

{h.position}

)}

Pergunta: {h.question_pt_br || h.question}

{h.confidence_band && ( {h.confidence_band} )}
{/* Probability bars */} {(prior !== null || posterior !== null) && (
)} {delta !== null && (
Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)} ·{" "} {delta > 0.05 ? evidência reforçou : delta < -0.05 ? evidência reduziu : evidência ambígua}
)}
{/* Argument grid */}
{/* Evidence chain */} {evidence.length > 0 && (
Cadeia de evidência ({evidence.length})
{evidence.map((e) => )}
)} {/* Red-team review */}
Revisão Red Team (Schneier)
{redTeam ? : (
Nenhuma revisão red-team ainda. Click acima para acionar Schneier.
)}
); } function ArgumentPanel({ kind, body, bodyEn }: { kind: "for" | "against"; body: string | null; bodyEn?: string | null }) { const tone = kind === "for" ? "border-[#06d6a0] text-[#06d6a0]" : "border-[#ff6ec7] text-[#ff6ec7]"; const label = kind === "for" ? "Argumento a favor (PT-BR)" : "Argumento contra (PT-BR)"; const enLabel = kind === "for" ? "Argument for (EN)" : "Argument against (EN)"; return (
{label}
{bodyEn && (
{enLabel}
)}
); } 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 (
{e.evidence_id}
{e.grade && ( Grade {e.grade} )} {e.confidence_band && ( {e.confidence_band} )}
{e.verbatim_excerpt && (
“{e.verbatim_excerpt.slice(0, 360)}”
)} {e.doc_id && e.page && ( {e.doc_id}/p{String(e.page).padStart(3, "0")}{e.chunk_id ? `#${e.chunk_id}` : ""} )}
); } function RedTeamPanel({ rt }: { rt: ParsedRedTeam }) { const tone = (rt.severity && SEVERITY_COLOR[rt.severity]) || "text-[#9aa6b8] border-[#9aa6b8]"; return (
{rt.reviewer ?? "—"}{rt.reviewed_at ? ` · ${rt.reviewed_at}` : ""}{rt.job_id ? ` · job ${rt.job_id.slice(0, 8)}…` : ""}
{rt.severity && ( {rt.severity} severity )}
{rt.verdict && (
{rt.verdict}
)}
); } function BulletPanel({ title, items, color }: { title: string; items: string[]; color: string }) { return (
{title}
{items.length === 0 ? (
_(none flagged)_
) : (
    {items.map((x, i) =>
  • {x}
  • )}
)}
); } function Bar({ label, value, color }: { label: string; value: number | null; color: string }) { const pct = value !== null ? Math.round(value * 100) : 0; return (
{label} {value !== null ? value.toFixed(3) : "—"}
); } function ArgumentBody({ text }: { text: string }) { if (!text.trim()) return

_(none recorded)_

; 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 (
{parts.map((p, i) => p.kind === "text" ? {p.raw} : p.href ? {p.label} : {p.label} )}
); }