disclosure-bureau/web/app/bureau/page.tsx
Luiz Gustavo 67185ff518
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 40s
CI / Scripts — Python smoke (push) Failing after 3s
CI / Web — npm audit (push) Failing after 31s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s
W3.9: surface the Investigation Bureau on the homepage + /bureau hub
Closes a UX gap the user surfaced: W3.5-3.8 built 8 detectives, 4 new
URL endpoints (/jobs/[id], /h/[id], /c/[slug], /api/h/[id]/red-team)
and a chat tool, but the homepage was unchanged — the bureau was
invisible unless you knew the URL or asked the chat to invoke
request_investigation.

Homepage (web/app/page.tsx):
  - Title `▍ war.gov/ufo — Investigative Wiki` → `▍ The Disclosure Bureau`
  - Subtitle expanded from "Holmes · Poirot · Dupin · Locard" to all 8
    detectives (Holmes · Locard · Dupin · Schneier · Poirot · Taleb ·
    Tetlock · Case-Writer)
  - New `🔎 bureau` topbar link (gold, between graph/stats and batch)
  - BureauSnapshot inserted right after the header

BureauSnapshot (web/components/bureau-snapshot.tsx) — server component:
  - 8 detective tiles with role labels (each in its tone color)
  - 6 clickable counters (evidence / hypotheses / contradictions /
    witnesses / outliers / case reports) — anchor to /bureau#section
  - 6 "recent artefacts" columns surfacing the last 3-4 of each kind:
    hypotheses with prior→posterior + band + ↳reviewed_by marker,
    contradictions with topic + resolution_status, evidence with
    Grade badge + verbatim quote, outliers with title + scope.kind,
    witness analyses with canonical_name + credibility + verdict,
    case reports with slug + link to /c/<slug>
  - "Recent jobs" strip linking to /jobs/[id] color-coded by status
  - Reports read from /data/ufo/case/reports/ via fs.readdir + stat,
    sorted by mtime — no DB round-trip needed for that section

/bureau (web/app/bureau/page.tsx) — full hub:
  - Header with full counts
  - 7 sections (anchored to homepage counter links): Case reports,
    Hypotheses, Evidence, Contradictions, Outliers, Witnesses,
    Recent jobs table — each rendering up to 100 rows
  - Reports section parses frontmatter from each .md to surface topic
    + n_hypotheses + n_evidence on the card

Runtime fixes batched in:
  - Poirot: coerce entity_pk via Number() — node-postgres returns
    BIGINT as string by default; writer's Number.isFinite() rejected
    it as "person_entity_pk required" (j-edgar-hoover retry path)
  - Tetlock: write_calibration rationale cap 600 → 1200 chars. Prompt
    still asks ≤ 600 but a 2× slack beats failing the job on honest
    analysis. Observed live: Tetlock emitted ~620 chars on H-0003 and
    the writer rejected the entire calibration.
  - Case-Writer: Promise.all of 5 queries × max_parallel=2 jobs
    demanded up to 10 connections against the investigator role's
    rolconnlimit=4 → "too many connections for role investigator".
    Sequentialized — the LLM call is the hot path, not these queries.

Smoke results visible now on the homepage:
  - 3 hypotheses (H-0001/2/3) about green fireballs origin
  - 3 contradictions (R-0001/2/3) about color, geographic confinement,
    exclusive-green vs multicolored
  - 2 evidence cards (E-0002/3) Grade B
  - 3 outliers (G-0001/2/3) — including Taleb's deliberate
    meteor-shower-camouflage flag
  - 1 case report at /c/green-fireballs-sandia (Watson 13.4 KB,
    five-act narrative, fully cited)

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

320 lines
14 KiB
TypeScript

/**
* /bureau — Investigation Bureau hub.
*
* Full listing of every artefact: evidence, hypotheses, contradictions,
* witnesses, outliers, case reports, recent jobs. Each anchor-section
* matches the hash-links from the homepage's counter row.
*/
import Link from "next/link";
import { pgQuery } from "@/lib/retrieval/db";
import { AuthBar } from "@/components/auth-bar";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface EvidenceRow {
evidence_id: string;
grade: string;
verbatim_excerpt: string;
source_page_id: string;
confidence_band: string | null;
}
interface HypothesisRow {
hypothesis_id: string;
question: string;
position: string;
prior: number | string | null;
posterior: number | string | null;
confidence_band: string | null;
status: string;
reviewed_by: string | null;
created_at: string;
}
interface ContradictionRow {
contradiction_id: string;
topic: string;
resolution_status: string;
chunks: unknown;
}
interface GapRow {
gap_id: string;
description: string;
scope: unknown;
status: string;
suggested_next_move: string | null;
}
interface WitnessRow {
witness_id: string;
canonical_name: string | null;
entity_id: string | null;
credibility: string | null;
verdict: string | null;
}
interface JobRow {
job_id: string;
kind: string;
status: string;
payload: Record<string, unknown> | null;
created_at: string;
finished_at: string | null;
triggered_by: string | null;
}
const BAND_TONE: 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_TONE: Record<string, string> = {
A: "text-[#06d6a0] border-[#06d6a0]",
B: "text-[#3fde6a] border-[#3fde6a]",
C: "text-[#ffa500] border-[#ffa500]",
};
export default async function BureauPage() {
// All artefacts. Server component — single round per query, no n+1.
const [hyp, ev, ctr, gap, wit, jobs] = await Promise.all([
pgQuery<HypothesisRow>(
`SELECT hypothesis_id, question, position, prior, posterior, confidence_band,
status, reviewed_by, created_at
FROM public.hypotheses ORDER BY created_at DESC LIMIT 100`,
).catch(() => []),
pgQuery<EvidenceRow>(
`SELECT evidence_id, grade, verbatim_excerpt, source_page_id, confidence_band
FROM public.evidence ORDER BY created_at DESC LIMIT 100`,
).catch(() => []),
pgQuery<ContradictionRow>(
`SELECT contradiction_id, topic, resolution_status, chunks
FROM public.contradictions ORDER BY created_at DESC LIMIT 100`,
).catch(() => []),
pgQuery<GapRow>(
`SELECT gap_id, description, scope, status, suggested_next_move
FROM public.gaps ORDER BY created_at DESC LIMIT 100`,
).catch(() => []),
pgQuery<WitnessRow>(
`SELECT w.witness_id, e.canonical_name, e.entity_id, w.credibility, w.verdict
FROM public.witnesses w
LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk
ORDER BY w.created_at DESC LIMIT 100`,
).catch(() => []),
pgQuery<JobRow>(
`SELECT job_id, kind, status, payload, created_at, finished_at, triggered_by
FROM public.investigation_jobs ORDER BY created_at DESC LIMIT 25`,
).catch(() => []),
]);
const { readdir, stat, readFile } = await import("node:fs/promises");
const path = await import("node:path");
const reportsDir = path.join(process.env.CASE_ROOT || "/data/ufo/case", "reports");
let reports: Array<{ slug: string; topic: string; mtimeMs: number; n_evidence: number; n_hypotheses: number }> = [];
try {
const files = await readdir(reportsDir);
const items = await Promise.all(
files.filter((f) => f.endsWith(".md")).map(async (f) => {
const full = path.join(reportsDir, f);
const st = await stat(full);
const head = (await readFile(full, "utf-8")).slice(0, 2000);
const topicMatch = head.match(/topic:\s*"([^"]+)"/);
const eMatch = head.match(/n_evidence:\s*(\d+)/);
const hMatch = head.match(/n_hypotheses:\s*(\d+)/);
return {
slug: f.replace(/\.md$/, ""),
topic: topicMatch?.[1] ?? f,
mtimeMs: st.mtimeMs,
n_evidence: Number(eMatch?.[1] ?? 0),
n_hypotheses: Number(hMatch?.[1] ?? 0),
};
}),
);
reports = items.sort((a, b) => b.mtimeMs - a.mtimeMs);
} catch { /* fine */ }
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<AuthBar />
<div className="mx-auto max-w-6xl 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 className="text-[#e0c080]">bureau</span>
</div>
<header className="mb-8 border-b border-[rgba(224,192,128,0.32)] pb-4">
<h1 className="font-mono text-3xl text-[#e0c080]"> The Investigation Bureau</h1>
<p className="text-[#8896aa] text-sm mt-1">
Live case folder · {ev.length} evidence · {hyp.length} hypotheses · {ctr.length} contradictions ·{" "}
{wit.length} witnesses · {gap.length} outliers · {reports.length} reports
</p>
</header>
{/* Case reports */}
<Section id="reports" title="Case reports" color="text-[#e0c080]">
{reports.length === 0 ? <Empty /> : reports.map((r) => (
<Link key={r.slug} href={`/c/${r.slug}`} className="block rounded border border-[rgba(224,192,128,0.18)] bg-[#0d1220] p-3 hover:bg-[rgba(224,192,128,0.04)] mb-2">
<div className="text-[10px] font-mono text-[#5a6678]">/c/{r.slug}</div>
<div className="text-[14px] text-[#e7ecf3] font-medium mt-1">{r.topic}</div>
<div className="text-[10px] font-mono text-[#5a6678] mt-1">
{r.n_hypotheses} hypotheses · {r.n_evidence} evidence
</div>
</Link>
))}
</Section>
{/* Hypotheses */}
<Section id="hypotheses" title="Hypotheses" color="text-[#7fdbff]">
{hyp.length === 0 ? <Empty /> : hyp.map((h) => {
const post = h.posterior !== null ? Number(h.posterior) : null;
const prior = h.prior !== null ? Number(h.prior) : null;
const delta = post !== null && prior !== null ? post - prior : null;
const bandTone = (h.confidence_band && BAND_TONE[h.confidence_band]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<Link key={h.hypothesis_id} href={`/h/${h.hypothesis_id}`}
className="block rounded border border-[rgba(127,219,255,0.18)] bg-[#0d1220] p-3 hover:bg-[rgba(127,219,255,0.04)] mb-2">
<div className="flex items-baseline justify-between gap-2 mb-1 flex-wrap">
<span className="text-[10px] font-mono text-[#5a6678]">{h.hypothesis_id} · {h.status}</span>
<div className="flex items-center gap-1">
{h.confidence_band && (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}>{h.confidence_band}</span>
)}
{h.reviewed_by && (
<span className="text-[9px] font-mono text-[#ff3344]"> {h.reviewed_by}</span>
)}
</div>
</div>
<div className="text-[13px] text-[#e7ecf3] leading-snug">{h.position}</div>
<div className="text-[10px] font-mono text-[#5a6678] mt-1">
prior {prior?.toFixed(2) ?? "—"} posterior {post?.toFixed(2) ?? "—"}
{delta !== null && <span className={delta > 0 ? " text-[#06d6a0]" : delta < 0 ? " text-[#ff6ec7]" : ""}> · Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)}</span>}
</div>
</Link>
);
})}
</Section>
{/* Evidence */}
<Section id="evidence" title="Evidence" color="text-[#06d6a0]">
{ev.length === 0 ? <Empty /> : ev.map((e) => {
const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<div key={e.evidence_id} className="rounded border border-[rgba(6,214,160,0.18)] bg-[#0d1220] p-3 mb-2">
<div className="flex items-baseline justify-between gap-2 mb-1">
<span className="text-[10px] font-mono text-[#5a6678]">{e.evidence_id} · {e.source_page_id}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${tone}`}>Grade {e.grade}</span>
</div>
<blockquote className="text-[12px] text-[#cbd2dd] italic border-l-2 border-[#06d6a0] pl-2 mt-1">
{e.verbatim_excerpt}
</blockquote>
</div>
);
})}
</Section>
{/* Contradictions */}
<Section id="contradictions" title="Contradictions" color="text-[#ff8a4d]">
{ctr.length === 0 ? <Empty /> : ctr.map((c) => {
const n = Array.isArray(c.chunks) ? c.chunks.length : 0;
return (
<div key={c.contradiction_id} className="rounded border border-[rgba(255,138,77,0.18)] bg-[#0d1220] p-3 mb-2">
<div className="flex items-baseline justify-between gap-2 mb-1">
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span>
<span className="text-[10px] font-mono text-[#9aa6b8]">{n} positions · {c.resolution_status}</span>
</div>
<div className="text-[13px] text-[#e7ecf3] leading-snug">{c.topic}</div>
</div>
);
})}
</Section>
{/* Outliers */}
<Section id="outliers" title="Outliers" color="text-[#ffd23f]">
{gap.length === 0 ? <Empty /> : gap.map((g) => {
const s = (g.scope ?? {}) as Record<string, unknown>;
const title = (s.title as string) || g.description;
const isOutlier = s.kind === "outlier";
return (
<div key={g.gap_id} className="rounded border border-[rgba(255,210,63,0.25)] bg-[#0d1220] p-3 mb-2">
<div className="flex items-baseline justify-between gap-2 mb-1">
<span className="text-[10px] font-mono text-[#5a6678]">{g.gap_id}{isOutlier && " · outlier"}</span>
<span className="text-[10px] font-mono text-[#9aa6b8]">{g.status}</span>
</div>
<div className="text-[13px] text-[#e7ecf3] leading-snug">{title}</div>
{s.why_surprising !== undefined && (
<div className="text-[11px] text-[#cbd2dd] mt-1 leading-relaxed">{String(s.why_surprising)}</div>
)}
{g.suggested_next_move && (
<div className="text-[10px] font-mono text-[#06d6a0] mt-1"> {g.suggested_next_move}</div>
)}
</div>
);
})}
</Section>
{/* Witnesses */}
<Section id="witnesses" title="Witness analyses" color="text-[#9b5de5]">
{wit.length === 0 ? <Empty /> : wit.map((w) => (
<div key={w.witness_id} className="rounded border border-[rgba(155,93,229,0.18)] bg-[#0d1220] p-3 mb-2">
<div className="flex items-baseline justify-between gap-2 mb-1">
<span className="text-[10px] font-mono text-[#5a6678]">{w.witness_id}</span>
<span className="text-[10px] font-mono text-[#9b5de5]">{w.credibility ?? "—"}</span>
</div>
<div className="text-[13px] text-[#e7ecf3] font-medium">
{w.entity_id ? <Link href={`/e/people/${w.entity_id}`} className="hover:underline">{w.canonical_name ?? w.entity_id}</Link> : (w.canonical_name ?? "—")}
</div>
{w.verdict && <blockquote className="text-[12px] text-[#cbd2dd] italic mt-1 border-l-2 border-[#9b5de5] pl-2">{w.verdict}</blockquote>}
</div>
))}
</Section>
{/* Recent jobs */}
<Section id="jobs" title="Recent investigation jobs" color="text-[#9aa6b8]">
{jobs.length === 0 ? <Empty /> : (
<div className="rounded border border-[rgba(127,219,255,0.08)] bg-[#0d1220] overflow-hidden">
<table className="w-full text-[11px] font-mono">
<thead className="text-[#5a6678] border-b border-[rgba(127,219,255,0.1)]">
<tr>
<th className="text-left px-3 py-2">job</th>
<th className="text-left px-3 py-2">kind</th>
<th className="text-left px-3 py-2">status</th>
<th className="text-left px-3 py-2">created</th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr key={j.job_id} className="border-t border-[rgba(127,219,255,0.04)] hover:bg-[rgba(127,219,255,0.03)]">
<td className="px-3 py-2">
<Link href={`/jobs/${j.job_id}`} className="text-[#7fdbff] hover:underline">{j.job_id.slice(0, 8)}</Link>
</td>
<td className="px-3 py-2 text-[#cbd2dd]">{j.kind}</td>
<td className={`px-3 py-2 ${
j.status === "complete" ? "text-[#06d6a0]" :
j.status === "failed" ? "text-[#ff6ec7]" :
j.status === "running" ? "text-[#ffd23f]" :
"text-[#9aa6b8]"
}`}>{j.status}</td>
<td className="px-3 py-2 text-[#5a6678]">{new Date(j.created_at).toLocaleString("pt-BR")}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
</div>
</div>
);
}
function Section({ id, title, color, children }: { id: string; title: string; color: string; children: React.ReactNode }) {
return (
<section id={id} className="mb-10 scroll-mt-20">
<h2 className={`font-mono text-xs uppercase tracking-[0.18em] ${color} mb-3`}>// {title}</h2>
<div>{children}</div>
</section>
);
}
function Empty() {
return <div className="text-[11px] font-mono text-[#5a6678]">_(none yet)_</div>;
}