disclosure-bureau/web/components/doc-bureau-panel.tsx

234 lines
10 KiB
TypeScript
Raw Normal View History

W3.10: clickable detective tiles + quick-launch form + doc bureau panel Builds on top of W3.9 to turn the homepage Bureau from a read-only dashboard into a working command center. UI improvements (web/components/bureau-snapshot.tsx): - Detective tiles are now <Link>s — each navigates to its primary artefact section in /bureau (Holmes→#hypotheses, Locard→#evidence, Dupin→#contradictions, Schneier→#hypotheses, Poirot→#witnesses, Taleb→#outliers, Tetlock→#hypotheses, Case-Writer→#reports). Hover bg matches the detective's tone color. - <QuickLaunch /> form inserted right under the tiles. New <QuickLaunch /> client component: - Detective dropdown (7 active kinds; evidence_chain not yet exposed here since it needs a doc_id better picked from the doc page). - Single input swaps placeholder + aria-label by kind: question for Holmes, topic for Dupin/Taleb/Case-Writer, hypothesis_id for Schneier/Tetlock, person_id for Poirot. - Submits to POST /api/bureau/launch and redirects to /jobs/[id] via the next.js router. - Loading state ("queueing…") + error display inline. POST /api/bureau/launch (web/app/api/bureau/launch/route.ts): - Same 8-kind validator as the chat tool's request_investigation. - Auth required when Supabase is configured (triggered_by = user:email). - Returns { job_id, kind, detective, status_url, eta_seconds }. DocBureauPanel on /d/[docId] (web/components/doc-bureau-panel.tsx): - Server component inserted between the doc header and AnomalyHighlights. - Surfaces every bureau artefact that touches the doc: · Evidence whose source_page_id starts with docId/p · Hypotheses citing any of those evidence_ids · Contradictions whose chunks[] has any item with this doc_id · Gaps/outliers with scope.doc_id == docId · Case reports whose markdown body references docId (filesystem scan) - Empty state shows "Investigation Bureau — untouched" with a CTA linking back to the homepage to launch the first investigation. - When non-empty, header counts total artefacts + links to /bureau for the full view. Metadata (web/app/layout.tsx): - description rewritten from "Investigative wiki of the US Department of War UAP/UFO archive (war.gov/ufo)" to one that names the bureau + the 8 detectives. Affects SERP previews + social-card defaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:33:00 +00:00
/**
* 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<string, string> = {
high: "text-[#06d6a0]", medium: "text-[#3fde6a]",
low: "text-[#ffa500]", speculation: "text-[#ff6ec7]",
};
const GRADE_TONE: Record<string, string> = {
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<EvRow>(
`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<HypRow>(
`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<CtrRow>(
`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<GapRow>(
`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 (
<section className="mb-6 rounded-lg border border-dashed border-[rgba(127,219,255,0.18)] bg-[#0d1220] p-4">
<div className="flex items-baseline justify-between flex-wrap gap-2">
<div className="text-[10px] font-mono text-[#5a6678] uppercase tracking-wider">
// Investigation Bureau — untouched
</div>
<Link
href={`/?launch=${encodeURIComponent(docId)}`}
className="text-[10px] font-mono text-[#e0c080] hover:underline"
>
launch an investigation
</Link>
</div>
<p className="text-[11px] text-[#5a6678] font-mono mt-2">
No detective has produced an artefact for this document yet.
</p>
</section>
);
}
return (
<section className="mb-6 rounded-lg border border-[rgba(224,192,128,0.18)] bg-gradient-to-br from-[rgba(224,192,128,0.04)] to-transparent p-4">
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
<div className="text-[10px] font-mono text-[#e0c080] uppercase tracking-wider">
// Investigation Bureau · {total} artefact{total === 1 ? "" : "s"} touch this doc
</div>
<Link href="/bureau" className="text-[10px] font-mono text-[#e0c080] hover:underline">
full bureau
</Link>
</div>
<div className="grid md:grid-cols-2 gap-3">
{hyp.length > 0 && (
<Panel title="Hypotheses" color="text-[#7fdbff]" border="border-[rgba(127,219,255,0.18)]">
{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 (
<Link key={h.hypothesis_id} href={`/h/${h.hypothesis_id}`}
className="block py-1.5 border-t border-[rgba(127,219,255,0.08)] first:border-t-0 hover:bg-[rgba(127,219,255,0.04)]">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[10px] font-mono text-[#5a6678]">{h.hypothesis_id}</span>
<span className={`text-[10px] font-mono ${tone}`}>
{h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`}
</span>
</div>
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{h.position}</div>
</Link>
);
})}
</Panel>
)}
{ev.length > 0 && (
<Panel title="Evidence" color="text-[#06d6a0]" border="border-[rgba(6,214,160,0.18)]">
{ev.map((e) => {
const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<div key={e.evidence_id} className="py-1.5 border-t border-[rgba(6,214,160,0.08)] first:border-t-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[10px] font-mono text-[#5a6678]">{e.evidence_id}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${tone}`}>
Grade {e.grade}
</span>
</div>
<div className="text-[11px] font-mono text-[#5a6678] mt-0.5">{e.source_page_id}</div>
</div>
);
})}
</Panel>
)}
{ctr.length > 0 && (
<Panel title="Contradictions" color="text-[#ff8a4d]" border="border-[rgba(255,138,77,0.18)]">
{ctr.map((c) => (
<div key={c.contradiction_id} className="py-1.5 border-t border-[rgba(255,138,77,0.08)] first:border-t-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span>
<span className="text-[10px] font-mono text-[#9aa6b8]">{c.resolution_status}</span>
</div>
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{c.topic}</div>
</div>
))}
</Panel>
)}
{gap.length > 0 && (
<Panel title="Outliers" color="text-[#ffd23f]" border="border-[rgba(255,210,63,0.25)]">
{gap.map((g) => {
const s = (g.scope ?? {}) as Record<string, unknown>;
const title = (s.title as string) || g.description;
return (
<div key={g.gap_id} className="py-1.5 border-t border-[rgba(255,210,63,0.08)] first:border-t-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[10px] font-mono text-[#5a6678]">{g.gap_id}</span>
<span className="text-[10px] font-mono text-[#9aa6b8]">{g.status}</span>
</div>
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{title}</div>
</div>
);
})}
</Panel>
)}
{reports.length > 0 && (
<Panel title="Case reports referencing this doc" color="text-[#e0c080]" border="border-[rgba(224,192,128,0.25)]">
{reports.map((r) => (
<Link key={r.slug} href={`/c/${r.slug}`}
className="block py-1.5 border-t border-[rgba(224,192,128,0.08)] first:border-t-0 hover:bg-[rgba(224,192,128,0.04)]">
<div className="text-[10px] font-mono text-[#5a6678]">/c/{r.slug}</div>
<div className="text-[12px] text-[#e0c080] leading-snug mt-0.5 font-medium">{r.topic}</div>
</Link>
))}
</Panel>
)}
</div>
</section>
);
}
function Panel({ title, color, border, children }: {
title: string; color: string; border: string; children: React.ReactNode;
}) {
return (
<div className={`rounded border ${border} bg-[#0d1220] p-3`}>
<div className={`text-[10px] font-mono uppercase tracking-wider ${color} mb-1`}>{title}</div>
<div>{children}</div>
</div>
);
}