/** * DocBureauPanel — surfaces Investigation Bureau artefacts that touch a * specific doc_id on the /d/[docId] page. * * Server component. Resolves: * - Evidence whose source_page_id starts with doc_id/p (FK via the * chunks table would be cleaner but the LIKE prefix is fine and * index-friendly). * - Hypotheses whose evidence_refs contain any E-NNNN from this doc. * - Contradictions whose chunks array contains an item with this doc_id. * - Outliers (gaps) whose scope.doc_id == this doc_id. * - Case reports whose body markdown references the doc_id. * * Renders nothing if all five lists are empty (doc untouched by bureau). */ import Link from "next/link"; import { pgQuery } from "@/lib/retrieval/db"; interface EvRow { evidence_id: string; grade: string; confidence_band: string | null; source_page_id: string } interface HypRow { hypothesis_id: string; position: string; confidence_band: string | null; posterior: number | string | null } interface CtrRow { contradiction_id: string; topic: string; resolution_status: string } interface GapRow { gap_id: string; description: string; scope: unknown; status: string } interface ReportRow { slug: string; topic: string } 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]", }; export async function DocBureauPanel({ docId }: { docId: string }) { // Evidence on this doc (cheap). const ev: EvRow[] = await pgQuery( `SELECT evidence_id, grade, confidence_band, source_page_id FROM public.evidence WHERE source_page_id LIKE $1 || '/%' ORDER BY evidence_id LIMIT 20`, [docId], ).catch(() => []); // Hypotheses citing any of the evidence_ids above. const evIds = ev.map((e) => e.evidence_id); const hyp: HypRow[] = evIds.length > 0 ? await pgQuery( `SELECT hypothesis_id, position, confidence_band, posterior FROM public.hypotheses WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements(evidence_refs) er WHERE er->>'evidence_id' = ANY($1::text[]) ) ORDER BY hypothesis_id LIMIT 12`, [evIds], ).catch(() => []) : []; // Contradictions whose chunks[] has any chunk with this doc_id. const ctr: CtrRow[] = await pgQuery( `SELECT contradiction_id, topic, resolution_status FROM public.contradictions WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements(chunks) c WHERE c->>'doc_id' = $1 ) ORDER BY contradiction_id LIMIT 8`, [docId], ).catch(() => []); // Outliers (gaps with scope.doc_id matching). const gap: GapRow[] = await pgQuery( `SELECT gap_id, description, scope, status FROM public.gaps WHERE scope->>'doc_id' = $1 ORDER BY gap_id LIMIT 8`, [docId], ).catch(() => []); // Case reports referencing this doc_id in the body. We don't store body // in pg; instead we scan the reports/ dir filesystem-side for now. // (Lightweight — bureau reports are O(10) files.) let reports: ReportRow[] = []; try { const { readdir, readFile } = await import("node:fs/promises"); const path = await import("node:path"); const dir = path.join(process.env.CASE_ROOT || "/data/ufo/case", "reports"); const files = await readdir(dir).catch(() => [] as string[]); const found: ReportRow[] = []; for (const f of files.filter((x) => x.endsWith(".md"))) { const md = await readFile(path.join(dir, f), "utf-8"); if (md.includes(docId)) { const topicMatch = md.match(/topic:\s*"([^"]+)"/); found.push({ slug: f.replace(/\.md$/, ""), topic: topicMatch?.[1] ?? f }); } } reports = found; } catch { /* fine */ } const total = ev.length + hyp.length + ctr.length + gap.length + reports.length; if (total === 0) { return (
// Investigation Bureau — untouched
launch an investigation →

No detective has produced an artefact for this document yet.

); } return (
// Investigation Bureau · {total} artefact{total === 1 ? "" : "s"} touch this doc
full bureau →
{hyp.length > 0 && ( {hyp.map((h) => { const tone = (h.confidence_band && BAND_TONE[h.confidence_band]) || "text-[#9aa6b8]"; const post = h.posterior !== null ? Number(h.posterior) : null; return (
{h.hypothesis_id} {h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`}
{h.position}
); })}
)} {ev.length > 0 && ( {ev.map((e) => { const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]"; return (
{e.evidence_id} Grade {e.grade}
{e.source_page_id}
); })}
)} {ctr.length > 0 && ( {ctr.map((c) => (
{c.contradiction_id} {c.resolution_status}
{c.topic}
))}
)} {gap.length > 0 && ( {gap.map((g) => { const s = (g.scope ?? {}) as Record; const title = (s.title as string) || g.description; return (
{g.gap_id} {g.status}
{title}
); })}
)} {reports.length > 0 && ( {reports.map((r) => (
/c/{r.slug}
{r.topic}
))}
)}
); } function Panel({ title, color, border, children }: { title: string; color: string; border: string; children: React.ReactNode; }) { return (
{title}
{children}
); }