From 67185ff518635a48526f53f8c17225ba1561baff Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Date: Sat, 23 May 2026 22:41:28 -0300 Subject: [PATCH] W3.9: surface the Investigation Bureau on the homepage + /bureau hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ - "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) --- .../src/detectives/case_writer.ts | 105 +++--- investigator-runtime/src/detectives/poirot.ts | 4 +- .../src/tools/write_calibration.ts | 5 +- web/app/bureau/page.tsx | 320 ++++++++++++++++ web/app/page.tsx | 14 +- web/components/bureau-snapshot.tsx | 342 ++++++++++++++++++ 6 files changed, 734 insertions(+), 56 deletions(-) create mode 100644 web/app/bureau/page.tsx create mode 100644 web/components/bureau-snapshot.tsx diff --git a/investigator-runtime/src/detectives/case_writer.ts b/investigator-runtime/src/detectives/case_writer.ts index 258efba..7fdb651 100644 --- a/investigator-runtime/src/detectives/case_writer.ts +++ b/investigator-runtime/src/detectives/case_writer.ts @@ -223,58 +223,59 @@ export async function runCaseWriter(task: CaseWriterTask): Promise< const filter = `%${topic.toLowerCase()}%`; const docIdFilter = task.doc_id ?? null; - // Pull artefacts. For evidence/contradictions we use the doc_id when set; - // otherwise widen by topic substring on the artefact's text fields. - const [evidence, hypotheses, contradictions, witnesses, gaps] = await Promise.all([ - query( - docIdFilter - ? `SELECT e.evidence_id, e.grade, e.source_page_id, e.verbatim_excerpt, - e.confidence_band, e.related_hypotheses - FROM public.evidence e - WHERE e.source_page_id LIKE $1 || '/%' - ORDER BY e.evidence_id LIMIT 20` - : `SELECT e.evidence_id, e.grade, e.source_page_id, e.verbatim_excerpt, - e.confidence_band, e.related_hypotheses - FROM public.evidence e - WHERE LOWER(e.verbatim_excerpt) LIKE $1 - ORDER BY e.evidence_id LIMIT 20`, - [docIdFilter ?? filter], - ), - query( - `SELECT hypothesis_id, question, position, argument_for, argument_against, - prior, posterior, confidence_band, status, reviewed_by - FROM public.hypotheses - WHERE LOWER(question) LIKE $1 OR LOWER(position) LIKE $1 - ORDER BY hypothesis_id LIMIT 12`, - [filter], - ), - query( - `SELECT contradiction_id, topic, chunks, resolution_status, notes - FROM public.contradictions - WHERE LOWER(topic) LIKE $1 - ORDER BY contradiction_id LIMIT 8`, - [filter], - ), - query( - `SELECT w.witness_id, e.canonical_name, w.credibility, w.verdict, - w.access_to_event, w.bias_notes - FROM public.witnesses w - LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk - WHERE LOWER(COALESCE(w.verdict,'')) LIKE $1 - OR LOWER(COALESCE(e.canonical_name,'')) LIKE $1 - ORDER BY w.witness_id LIMIT 8`, - [filter], - ), - query( - `SELECT gap_id, description, scope, suggested_next_move, status - FROM public.gaps - WHERE LOWER(description) LIKE $1 - OR LOWER(COALESCE(scope->>'title','')) LIKE $1 - OR LOWER(COALESCE(scope->>'why_surprising','')) LIKE $1 - ORDER BY gap_id LIMIT 8`, - [filter], - ), - ]); + // Pull artefacts SEQUENTIALLY. The investigator role has rolconnlimit=4 and + // pool.max=4; Promise.all of 5 queries × max_parallel=2 jobs would demand + // 10 simultaneous connections and the role rejects "too many connections". + // Sequential gather still finishes well under a second — case-writer's hot + // path is the LLM call, not these queries. + const evidence = await query( + docIdFilter + ? `SELECT e.evidence_id, e.grade, e.source_page_id, e.verbatim_excerpt, + e.confidence_band, e.related_hypotheses + FROM public.evidence e + WHERE e.source_page_id LIKE $1 || '/%' + ORDER BY e.evidence_id LIMIT 20` + : `SELECT e.evidence_id, e.grade, e.source_page_id, e.verbatim_excerpt, + e.confidence_band, e.related_hypotheses + FROM public.evidence e + WHERE LOWER(e.verbatim_excerpt) LIKE $1 + ORDER BY e.evidence_id LIMIT 20`, + [docIdFilter ?? filter], + ); + const hypotheses = await query( + `SELECT hypothesis_id, question, position, argument_for, argument_against, + prior, posterior, confidence_band, status, reviewed_by + FROM public.hypotheses + WHERE LOWER(question) LIKE $1 OR LOWER(position) LIKE $1 + ORDER BY hypothesis_id LIMIT 12`, + [filter], + ); + const contradictions = await query( + `SELECT contradiction_id, topic, chunks, resolution_status, notes + FROM public.contradictions + WHERE LOWER(topic) LIKE $1 + ORDER BY contradiction_id LIMIT 8`, + [filter], + ); + const witnesses = await query( + `SELECT w.witness_id, e.canonical_name, w.credibility, w.verdict, + w.access_to_event, w.bias_notes + FROM public.witnesses w + LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk + WHERE LOWER(COALESCE(w.verdict,'')) LIKE $1 + OR LOWER(COALESCE(e.canonical_name,'')) LIKE $1 + ORDER BY w.witness_id LIMIT 8`, + [filter], + ); + const gaps = await query( + `SELECT gap_id, description, scope, suggested_next_move, status + FROM public.gaps + WHERE LOWER(description) LIKE $1 + OR LOWER(COALESCE(scope->>'title','')) LIKE $1 + OR LOWER(COALESCE(scope->>'why_surprising','')) LIKE $1 + ORDER BY gap_id LIMIT 8`, + [filter], + ); await audit({ event: "case_writer_grounded", diff --git a/investigator-runtime/src/detectives/poirot.ts b/investigator-runtime/src/detectives/poirot.ts index d4ddd84..0de58ba 100644 --- a/investigator-runtime/src/detectives/poirot.ts +++ b/investigator-runtime/src/detectives/poirot.ts @@ -149,7 +149,9 @@ export async function runPoirot(task: PoirotTask): Promise< ); if (!row) return { skipped: true, reason: "person_not_found" }; if (row.entity_class !== "person") return { skipped: true, reason: "entity_is_not_person" }; - entity_pk = row.entity_pk; + // node-postgres returns BIGINT as a string by default; coerce so writer's + // Number.isFinite() check passes. + entity_pk = Number(row.entity_pk); canonical_name = row.canonical_name; aliases = row.aliases ?? []; } else if (entity_pk !== null) { diff --git a/investigator-runtime/src/tools/write_calibration.ts b/investigator-runtime/src/tools/write_calibration.ts index a73c570..5c124a0 100644 --- a/investigator-runtime/src/tools/write_calibration.ts +++ b/investigator-runtime/src/tools/write_calibration.ts @@ -86,7 +86,10 @@ export async function writeCalibration( // Force the band to match the posterior — Tetlock can mis-label. body.new_confidence_band = expectedBand; if (!body.rationale?.trim()) throw new Error("rationale required"); - if (body.rationale.length > 600) throw new Error(`rationale too long`); + // Soft cap: 1200 chars. Tetlock often writes 600-800 of substantive + // reasoning + chunk citations; the prompt asks for ≤ 600 but a 2× slack + // beats failing the job on an honest analysis. + if (body.rationale.length > 1200) throw new Error(`rationale too long (${body.rationale.length} > 1200)`); const action = body.recommended_action; if (!["keep", "downgrade", "upgrade", "supersede"].includes(action)) { diff --git a/web/app/bureau/page.tsx b/web/app/bureau/page.tsx new file mode 100644 index 0000000..9bbc1eb --- /dev/null +++ b/web/app/bureau/page.tsx @@ -0,0 +1,320 @@ +/** + * /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 | null; + created_at: string; + finished_at: string | null; + triggered_by: string | null; +} + +const BAND_TONE: Record = { + high: "text-[#06d6a0] border-[#06d6a0]", + medium: "text-[#3fde6a] border-[#3fde6a]", + low: "text-[#ffa500] border-[#ffa500]", + speculation: "text-[#ff6ec7] border-[#ff6ec7]", +}; + +const GRADE_TONE: Record = { + 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( + `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( + `SELECT evidence_id, grade, verbatim_excerpt, source_page_id, confidence_band + FROM public.evidence ORDER BY created_at DESC LIMIT 100`, + ).catch(() => []), + pgQuery( + `SELECT contradiction_id, topic, resolution_status, chunks + FROM public.contradictions ORDER BY created_at DESC LIMIT 100`, + ).catch(() => []), + pgQuery( + `SELECT gap_id, description, scope, status, suggested_next_move + FROM public.gaps ORDER BY created_at DESC LIMIT 100`, + ).catch(() => []), + pgQuery( + `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( + `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 ( +
+ +
+
+ disclosure.top + / + bureau +
+ +
+

▍ The Investigation Bureau

+

+ Live case folder · {ev.length} evidence · {hyp.length} hypotheses · {ctr.length} contradictions ·{" "} + {wit.length} witnesses · {gap.length} outliers · {reports.length} reports +

+
+ + {/* Case reports */} +
+ {reports.length === 0 ? : reports.map((r) => ( + +
/c/{r.slug}
+
{r.topic}
+
+ {r.n_hypotheses} hypotheses · {r.n_evidence} evidence +
+ + ))} +
+ + {/* Hypotheses */} +
+ {hyp.length === 0 ? : 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 ( + +
+ {h.hypothesis_id} · {h.status} +
+ {h.confidence_band && ( + {h.confidence_band} + )} + {h.reviewed_by && ( + ↳ {h.reviewed_by} + )} +
+
+
{h.position}
+
+ prior {prior?.toFixed(2) ?? "—"} → posterior {post?.toFixed(2) ?? "—"} + {delta !== null && 0 ? " text-[#06d6a0]" : delta < 0 ? " text-[#ff6ec7]" : ""}> · Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)}} +
+ + ); + })} +
+ + {/* Evidence */} +
+ {ev.length === 0 ? : ev.map((e) => { + const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]"; + return ( +
+
+ {e.evidence_id} · {e.source_page_id} + Grade {e.grade} +
+
+ “{e.verbatim_excerpt}” +
+
+ ); + })} +
+ + {/* Contradictions */} +
+ {ctr.length === 0 ? : ctr.map((c) => { + const n = Array.isArray(c.chunks) ? c.chunks.length : 0; + return ( +
+
+ {c.contradiction_id} + {n} positions · {c.resolution_status} +
+
{c.topic}
+
+ ); + })} +
+ + {/* Outliers */} +
+ {gap.length === 0 ? : gap.map((g) => { + const s = (g.scope ?? {}) as Record; + const title = (s.title as string) || g.description; + const isOutlier = s.kind === "outlier"; + return ( +
+
+ {g.gap_id}{isOutlier && " · outlier"} + {g.status} +
+
{title}
+ {s.why_surprising !== undefined && ( +
{String(s.why_surprising)}
+ )} + {g.suggested_next_move && ( +
→ {g.suggested_next_move}
+ )} +
+ ); + })} +
+ + {/* Witnesses */} +
+ {wit.length === 0 ? : wit.map((w) => ( +
+
+ {w.witness_id} + {w.credibility ?? "—"} +
+
+ {w.entity_id ? {w.canonical_name ?? w.entity_id} : (w.canonical_name ?? "—")} +
+ {w.verdict &&
{w.verdict}
} +
+ ))} +
+ + {/* Recent jobs */} +
+ {jobs.length === 0 ? : ( +
+ + + + + + + + + + + {jobs.map((j) => ( + + + + + + + ))} + +
jobkindstatuscreated
+ {j.job_id.slice(0, 8)}… + {j.kind}{j.status}{new Date(j.created_at).toLocaleString("pt-BR")}
+
+ )} +
+
+
+ ); +} + +function Section({ id, title, color, children }: { id: string; title: string; color: string; children: React.ReactNode }) { + return ( +
+

// {title}

+
{children}
+
+ ); +} + +function Empty() { + return
_(none yet)_
; +} diff --git a/web/app/page.tsx b/web/app/page.tsx index 6655c7c..2365b5d 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -6,6 +6,7 @@ import { BatchProgressBanner } from "@/components/batch-progress-banner"; import { getLocale } from "@/components/locale-toggle"; import { summarize, pickPitch } from "@/lib/doc-summary"; import { DocListFilters } from "@/components/doc-list-filters"; +import { BureauSnapshot } from "@/components/bureau-snapshot"; // Read wiki/ filesystem at request time, not build time. export const dynamic = "force-dynamic"; @@ -63,6 +64,13 @@ export default async function Home() { > 📊 stats + + 🔎 bureau +

- ▍ war.gov/ufo — Investigative Wiki + ▍ The Disclosure Bureau

{docs.length} declassified documents · {docs.reduce((s, d) => s + d.pages, 0)} pages · - AI-cataloged by the Investigation Bureau (Holmes · Poirot · Dupin · Locard) + investigated by 8 AI detectives (Holmes · Locard · Dupin · Schneier · Poirot · Taleb · Tetlock · Case-Writer)

+ + diff --git a/web/components/bureau-snapshot.tsx b/web/components/bureau-snapshot.tsx new file mode 100644 index 0000000..6e44d2f --- /dev/null +++ b/web/components/bureau-snapshot.tsx @@ -0,0 +1,342 @@ +/** + * BureauSnapshot — homepage panel surfacing the Investigation Bureau's + * live artefact stack. + * + * Reads counts + the 3 most-recent items from each table directly via pg. + * Server component — renders straight into the homepage shell, no client + * hydration cost. + */ +import Link from "next/link"; +import { pgQuery } from "@/lib/retrieval/db"; + +interface CountRow { c: string } +interface RecentEvidence { evidence_id: string; grade: string; verbatim_excerpt: string; source_page_id: string; confidence_band: string | null } +interface RecentHypothesis { hypothesis_id: string; position: string; posterior: number | string | null; confidence_band: string | null; reviewed_by: string | null } +interface RecentContradiction { contradiction_id: string; topic: string; resolution_status: string } +interface RecentGap { gap_id: string; description: string; scope: unknown } +interface RecentWitness { witness_id: string; canonical_name: string | null; credibility: string | null; verdict: string | null } +interface RecentJob { job_id: string; kind: string; status: string; created_at: string; payload: Record | null } + +const DETECTIVES = [ + { slug: "holmes", name: "Holmes", role: "Hypothesis tournament", tone: "text-[#7fdbff] border-[#7fdbff]" }, + { slug: "locard", name: "Locard", role: "Evidence chain", tone: "text-[#06d6a0] border-[#06d6a0]" }, + { slug: "dupin", name: "Dupin", role: "Contradiction scan", tone: "text-[#ff8a4d] border-[#ff8a4d]" }, + { slug: "schneier", name: "Schneier", role: "Red-team review", tone: "text-[#ff3344] border-[#ff3344]" }, + { slug: "poirot", name: "Poirot", role: "Witness analysis", tone: "text-[#9b5de5] border-[#9b5de5]" }, + { slug: "taleb", name: "Taleb", role: "Outlier hunter", tone: "text-[#ffd23f] border-[#ffd23f]" }, + { slug: "tetlock", name: "Tetlock", role: "Posterior calibration", tone: "text-[#26d4cc] border-[#26d4cc]" }, + { slug: "case-writer", name: "Case-Writer", role: "Five-act narrative", tone: "text-[#e0c080] border-[#e0c080]" }, +] as const; + +const BAND_TONE: Record = { + high: "text-[#06d6a0]", + medium: "text-[#3fde6a]", + low: "text-[#ffa500]", + speculation: "text-[#ff6ec7]", +}; + +const GRADE_TONE: Record = { + A: "text-[#06d6a0] border-[#06d6a0]", + B: "text-[#3fde6a] border-[#3fde6a]", + C: "text-[#ffa500] border-[#ffa500]", +}; + +async function loadSnapshot() { + // Counts come from a single round-trip via UNION ALL. + const counts = await pgQuery( + `SELECT 'evidence' AS k, COUNT(*)::text AS c FROM public.evidence + UNION ALL SELECT 'hypotheses', COUNT(*)::text FROM public.hypotheses + UNION ALL SELECT 'contradictions', COUNT(*)::text FROM public.contradictions + UNION ALL SELECT 'witnesses', COUNT(*)::text FROM public.witnesses + UNION ALL SELECT 'gaps', COUNT(*)::text FROM public.gaps + UNION ALL SELECT 'open_hyp', COUNT(*)::text FROM public.hypotheses WHERE status='open' + UNION ALL SELECT 'jobs_running', COUNT(*)::text FROM public.investigation_jobs WHERE status IN ('queued','running') + UNION ALL SELECT 'jobs_complete_24h', COUNT(*)::text FROM public.investigation_jobs WHERE status='complete' AND finished_at > NOW() - INTERVAL '24 hours'`, + ).catch(() => [] as Array); + + const byKey: Record = {}; + for (const r of counts) byKey[r.k] = Number(r.c); + + const [hyp, ev, ctr, gap, wit, jobs] = await Promise.all([ + pgQuery( + `SELECT hypothesis_id, position, posterior, confidence_band, reviewed_by + FROM public.hypotheses ORDER BY created_at DESC LIMIT 4`, + ).catch(() => []), + pgQuery( + `SELECT evidence_id, grade, verbatim_excerpt, source_page_id, confidence_band + FROM public.evidence ORDER BY created_at DESC LIMIT 3`, + ).catch(() => []), + pgQuery( + `SELECT contradiction_id, topic, resolution_status + FROM public.contradictions ORDER BY created_at DESC LIMIT 3`, + ).catch(() => []), + pgQuery( + `SELECT gap_id, description, scope FROM public.gaps ORDER BY created_at DESC LIMIT 3`, + ).catch(() => []), + pgQuery( + `SELECT w.witness_id, e.canonical_name, 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 3`, + ).catch(() => []), + pgQuery( + `SELECT job_id, kind, status, created_at, payload + FROM public.investigation_jobs ORDER BY created_at DESC LIMIT 6`, + ).catch(() => []), + ]); + + // Case reports live on disk. Read the reports dir. + const { readdir, stat } = 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; mtimeMs: number }> = []; + try { + const files = await readdir(reportsDir); + const items = await Promise.all( + files.filter((f) => f.endsWith(".md")).map(async (f) => { + const st = await stat(path.join(reportsDir, f)); + return { slug: f.replace(/\.md$/, ""), mtimeMs: st.mtimeMs }; + }), + ); + reports = items.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 4); + } catch { /* no reports yet — fine */ } + + return { counts: byKey, hyp, ev, ctr, gap, wit, jobs, reports }; +} + +export async function BureauSnapshot() { + const snap = await loadSnapshot().catch(() => null); + if (!snap) { + return ( +
+ Investigation Bureau snapshot unavailable (db unreachable). +
+ ); + } + const { counts, hyp, ev, ctr, gap, wit, jobs, reports } = snap; + const totalArtefacts = (counts.evidence ?? 0) + (counts.hypotheses ?? 0) + (counts.contradictions ?? 0) + + (counts.witnesses ?? 0) + (counts.gaps ?? 0) + reports.length; + const running = counts.jobs_running ?? 0; + + return ( +
+ {/* Section heading */} +
+
+

+ The Investigation Bureau +

+

+ 8 AI detectives · {totalArtefacts} artefacts in the case folder + {running > 0 && · {running} job{running === 1 ? "" : "s"} in progress} +

+
+ + → open the bureau + +
+ + {/* Detective tiles */} +
+ {DETECTIVES.map((d) => ( +
+
+ {d.name} + {d.slug} +
+
{d.role}
+
+ ))} +
+ + {/* Counter row */} +
+ + + + + + +
+ + {/* Recent artefacts grid: hypotheses + contradictions + outliers + reports */} +
+ {hyp.length > 0 && ( + + {hyp.map((h) => { + const post = h.posterior !== null ? Number(h.posterior) : null; + const bandTone = (h.confidence_band && BAND_TONE[h.confidence_band]) || "text-[#9aa6b8]"; + return ( + +
+ {h.hypothesis_id} + + {h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`} + +
+
{h.position}
+ {h.reviewed_by && ( +
↳ reviewed by {h.reviewed_by}
+ )} + + ); + })} +
+ )} + + {ctr.length > 0 && ( + + {ctr.map((c) => ( +
+
+ {c.contradiction_id} + {c.resolution_status} +
+
{c.topic}
+
+ ))} +
+ )} + + {ev.length > 0 && ( + + {ev.map((e) => { + const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8]"; + return ( +
+
+ {e.evidence_id} + Grade {e.grade} +
+
+ “{(e.verbatim_excerpt || "").slice(0, 140)}…” +
+
+ ); + })} +
+ )} + + {gap.length > 0 && ( + + {gap.map((g) => { + const s = (g.scope ?? {}) as Record; + const title = (s.title as string) || g.description; + return ( +
+
+ {g.gap_id} + {s.kind === "outlier" && ( + outlier + )} +
+
{title}
+
+ ); + })} +
+ )} + + {wit.length > 0 && ( + + {wit.map((w) => ( +
+
+ {w.witness_id} + {w.credibility ?? "—"} +
+
{w.canonical_name ?? "—"}
+ {w.verdict &&
{w.verdict.slice(0, 200)}
} +
+ ))} +
+ )} + + {reports.length > 0 && ( + + {reports.map((r) => ( + +
case-report
+
/c/{r.slug}
+
Watson five-act narrative →
+ + ))} +
+ )} +
+ + {/* Job activity strip */} + {jobs.length > 0 && ( +
+ Recent jobs:{" "} + {jobs.slice(0, 4).map((j, i) => ( + + {i > 0 && · } + + {j.kind}/{j.status} + + + ))} +
+ )} +
+ ); +} + +function Counter({ label, value, color, href }: { label: string; value: number; color: string; href: string }) { + return ( + +
{value}
+
{label}
+ + ); +} + +function ArtefactColumn({ + title, color, border, children, +}: { title: string; color: string; border: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +}