diff --git a/web/app/api/bureau/launch/route.ts b/web/app/api/bureau/launch/route.ts new file mode 100644 index 0000000..e7fa5ae --- /dev/null +++ b/web/app/api/bureau/launch/route.ts @@ -0,0 +1,145 @@ +/** + * POST /api/bureau/launch — quick-launch endpoint for the homepage form. + * + * Accepts the same validation as the chat tool's request_investigation but + * via REST so the QuickLaunch component (homepage) can fire jobs without + * routing through the chat agent. Validates by kind: + * - hypothesis_tournament: requires `question` + * - contradiction_scan / outlier_scan / case_report: requires `topic` + * - red_team_review / calibrate_hypothesis: requires `hypothesis_id` (H-NNNN) + * - witness_analysis: requires `person_id` (kebab-case) + * - evidence_chain: requires `doc_id` (optional `chunks`, `claim`) + * + * Returns { job_id, status_url, eta_seconds, kind, detective }. + * + * Auth: requires an authenticated user when Supabase is configured. The + * triggered_by field carries the email so audit ties back to a person. + */ +import { NextResponse } from "next/server"; +import { pgQuery } from "@/lib/retrieval/db"; +import { createClient, isSupabaseConfigured } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const KINDS = new Set([ + "hypothesis_tournament", "evidence_chain", "contradiction_scan", + "red_team_review", "witness_analysis", "outlier_scan", + "calibrate_hypothesis", "case_report", +]); + +const DETECTIVE: Record = { + hypothesis_tournament: "holmes", + evidence_chain: "locard", + contradiction_scan: "dupin", + red_team_review: "schneier", + witness_analysis: "poirot", + outlier_scan: "taleb", + calibrate_hypothesis: "tetlock", + case_report: "case-writer", +}; + +const ETA: Record = { + hypothesis_tournament: 60, + evidence_chain: 150, + contradiction_scan: 60, + red_team_review: 30, + witness_analysis: 45, + outlier_scan: 50, + calibrate_hypothesis: 30, + case_report: 180, +}; + +export async function POST(request: Request) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + const kind = String(body.kind ?? "").trim(); + if (!KINDS.has(kind)) { + return NextResponse.json({ error: "bad_kind", message: `kind must be one of: ${[...KINDS].join(", ")}` }, { status: 400 }); + } + + let triggered_by = "user:anonymous"; + if (isSupabaseConfigured()) { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + } + triggered_by = `user:${user.email ?? user.id}`; + } + + // Validate per-kind payload + build it. + const payload: Record = {}; + switch (kind) { + case "hypothesis_tournament": { + const q = String(body.question ?? "").trim(); + if (!q) return NextResponse.json({ error: "question_required" }, { status: 400 }); + payload.question = q; + if (typeof body.doc_id === "string" && body.doc_id.trim()) payload.doc_id = body.doc_id.trim(); + payload.lang = body.lang === "en" ? "en" : "pt"; + break; + } + case "evidence_chain": { + const doc = String(body.doc_id ?? "").trim(); + if (!doc) return NextResponse.json({ error: "doc_id_required" }, { status: 400 }); + payload.doc_id = doc; + if (Array.isArray(body.chunks)) { + payload.chunks = (body.chunks as unknown[]).filter((c): c is string => typeof c === "string"); + } + if (typeof body.claim === "string" && body.claim.trim()) payload.claim = body.claim.trim(); + break; + } + case "contradiction_scan": + case "outlier_scan": + case "case_report": { + const topic = String(body.topic ?? "").trim(); + if (!topic) return NextResponse.json({ error: "topic_required" }, { status: 400 }); + payload.topic = topic; + if (typeof body.doc_id === "string" && body.doc_id.trim()) payload.doc_id = body.doc_id.trim(); + payload.lang = body.lang === "en" ? "en" : "pt"; + if (kind === "case_report" && typeof body.slug === "string" && /^[a-z0-9][a-z0-9-]*$/.test(body.slug)) { + payload.slug = body.slug; + } + break; + } + case "red_team_review": + case "calibrate_hypothesis": { + const hyp = String(body.hypothesis_id ?? "").trim(); + if (!/^H-\d{4}$/.test(hyp)) return NextResponse.json({ error: "hypothesis_id_required", message: "format: H-NNNN" }, { status: 400 }); + payload.hypothesis_id = hyp; + payload.lang = body.lang === "en" ? "en" : "pt"; + break; + } + case "witness_analysis": { + const pid = String(body.person_id ?? "").trim(); + if (!/^[a-z0-9][a-z0-9-]*$/.test(pid)) return NextResponse.json({ error: "person_id_required", message: "kebab-case e.g. 'donald-keyhoe'" }, { status: 400 }); + payload.person_id = pid; + payload.lang = body.lang === "en" ? "en" : "pt"; + break; + } + } + + try { + const rows = await pgQuery<{ job_id: string }>( + `INSERT INTO public.investigation_jobs (kind, payload, triggered_by, status) + VALUES ($1, $2::jsonb, $3, 'queued') + RETURNING job_id`, + [kind, JSON.stringify(payload), triggered_by], + ); + const job_id = rows[0]?.job_id; + if (!job_id) return NextResponse.json({ error: "insert_failed" }, { status: 500 }); + return NextResponse.json({ + job_id, kind, + detective: DETECTIVE[kind], + status: "queued", + status_url: `/jobs/${job_id}`, + eta_seconds: ETA[kind] ?? 60, + }); + } catch (e) { + return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 }); + } +} diff --git a/web/app/d/[docId]/page.tsx b/web/app/d/[docId]/page.tsx index 9471f76..54ff6da 100644 --- a/web/app/d/[docId]/page.tsx +++ b/web/app/d/[docId]/page.tsx @@ -14,6 +14,7 @@ import { AuthBar } from "@/components/auth-bar"; import { ChatBubble } from "@/components/chat-bubble"; import { DocReadingView } from "@/components/doc-reading-view"; import { AnomalyHighlights, type AnomalyFlag } from "@/components/anomaly-highlights"; +import { DocBureauPanel } from "@/components/doc-bureau-panel"; import { MarkdownBody } from "@/components/markdown-body"; export const dynamic = "force-dynamic"; @@ -151,6 +152,8 @@ export default async function DocPage({ )} + + diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 8b92a08..0626963 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -9,7 +9,10 @@ const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" }); export const metadata: Metadata = { title: "The Disclosure Bureau", - description: "Investigative wiki of the US Department of War UAP/UFO archive (war.gov/ufo)", + description: + "Investigative case folder of declassified UAP/UFO documents, " + + "worked by 8 AI detectives (Holmes · Locard · Dupin · Schneier · " + + "Poirot · Taleb · Tetlock · Case-Writer).", }; export default async function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/web/components/bureau-snapshot.tsx b/web/components/bureau-snapshot.tsx index 6e44d2f..6495cbf 100644 --- a/web/components/bureau-snapshot.tsx +++ b/web/components/bureau-snapshot.tsx @@ -8,6 +8,7 @@ */ import Link from "next/link"; import { pgQuery } from "@/lib/retrieval/db"; +import { QuickLaunch } from "./quick-launch"; interface CountRow { c: string } interface RecentEvidence { evidence_id: string; grade: string; verbatim_excerpt: string; source_page_id: string; confidence_band: string | null } @@ -18,14 +19,14 @@ interface RecentWitness { witness_id: string; canonical_name: string | null; cre 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]" }, + { slug: "holmes", name: "Holmes", role: "Hypothesis tournament", anchor: "/bureau#hypotheses", tone: "text-[#7fdbff] border-[#7fdbff] hover:bg-[rgba(127,219,255,0.06)]" }, + { slug: "locard", name: "Locard", role: "Evidence chain", anchor: "/bureau#evidence", tone: "text-[#06d6a0] border-[#06d6a0] hover:bg-[rgba(6,214,160,0.06)]" }, + { slug: "dupin", name: "Dupin", role: "Contradiction scan", anchor: "/bureau#contradictions", tone: "text-[#ff8a4d] border-[#ff8a4d] hover:bg-[rgba(255,138,77,0.06)]" }, + { slug: "schneier", name: "Schneier", role: "Red-team review", anchor: "/bureau#hypotheses", tone: "text-[#ff3344] border-[#ff3344] hover:bg-[rgba(255,51,68,0.06)]" }, + { slug: "poirot", name: "Poirot", role: "Witness analysis", anchor: "/bureau#witnesses", tone: "text-[#9b5de5] border-[#9b5de5] hover:bg-[rgba(155,93,229,0.06)]" }, + { slug: "taleb", name: "Taleb", role: "Outlier hunter", anchor: "/bureau#outliers", tone: "text-[#ffd23f] border-[#ffd23f] hover:bg-[rgba(255,210,63,0.06)]" }, + { slug: "tetlock", name: "Tetlock", role: "Posterior calibration", anchor: "/bureau#hypotheses", tone: "text-[#26d4cc] border-[#26d4cc] hover:bg-[rgba(38,212,204,0.06)]" }, + { slug: "case-writer", name: "Case-Writer", role: "Five-act narrative", anchor: "/bureau#reports", tone: "text-[#e0c080] border-[#e0c080] hover:bg-[rgba(224,192,128,0.06)]" }, ] as const; const BAND_TONE: Record = { @@ -139,22 +140,28 @@ export async function BureauSnapshot() { - {/* Detective tiles */} + {/* Detective tiles — clickable to their artefact section in /bureau */}
{DETECTIVES.map((d) => ( -
{d.name} {d.slug}
{d.role}
-
+ ))}
+ {/* Quick-launch form */} + + + {/* Counter row */}
diff --git a/web/components/doc-bureau-panel.tsx b/web/components/doc-bureau-panel.tsx new file mode 100644 index 0000000..675f9cf --- /dev/null +++ b/web/components/doc-bureau-panel.tsx @@ -0,0 +1,233 @@ +/** + * 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}
+
+ ); +} diff --git a/web/components/quick-launch.tsx b/web/components/quick-launch.tsx new file mode 100644 index 0000000..a61455a --- /dev/null +++ b/web/components/quick-launch.tsx @@ -0,0 +1,115 @@ +"use client"; + +/** + * QuickLaunch — homepage form to fire an investigation in 1 click without + * opening the chat. + * + * Detective dropdown picks the kind; the form swaps the input label based + * on what that kind needs (topic, question, hypothesis_id, person_id). + * Submit POSTs to /api/bureau/launch which inserts the job + returns the + * job_id; the UI then routes to /jobs/[id]. + */ +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowUpRight } from "lucide-react"; + +interface DetectiveOption { + slug: string; + label: string; + kind: string; + inputLabel: string; + placeholder: string; + field: "question" | "topic" | "hypothesis_id" | "person_id"; + tone: string; +} + +const OPTIONS: DetectiveOption[] = [ + { slug: "holmes", label: "Holmes — hypothesis tournament", kind: "hypothesis_tournament", inputLabel: "Question (one sentence, declarative)", placeholder: "Were the green fireballs natural or unidentified?", field: "question", tone: "text-[#7fdbff] border-[#7fdbff]" }, + { slug: "dupin", label: "Dupin — contradiction scan", kind: "contradiction_scan", inputLabel: "Topic (short noun-phrase)", placeholder: "color of the green fireballs", field: "topic", tone: "text-[#ff8a4d] border-[#ff8a4d]" }, + { slug: "taleb", label: "Taleb — outlier hunt", kind: "outlier_scan", inputLabel: "Topic", placeholder: "anomalous frequency of UAP at nuclear sites", field: "topic", tone: "text-[#ffd23f] border-[#ffd23f]" }, + { slug: "schneier", label: "Schneier — red-team a hypothesis", kind: "red_team_review", inputLabel: "Hypothesis ID (H-NNNN)", placeholder: "H-0003", field: "hypothesis_id", tone: "text-[#ff3344] border-[#ff3344]" }, + { slug: "tetlock", label: "Tetlock — recalibrate a hypothesis", kind: "calibrate_hypothesis", inputLabel: "Hypothesis ID (H-NNNN)", placeholder: "H-0003", field: "hypothesis_id", tone: "text-[#26d4cc] border-[#26d4cc]" }, + { slug: "poirot", label: "Poirot — witness analysis", kind: "witness_analysis", inputLabel: "Person ID (kebab-case)", placeholder: "donald-keyhoe", field: "person_id", tone: "text-[#9b5de5] border-[#9b5de5]" }, + { slug: "case-writer", label: "Case-Writer — assemble narrative", kind: "case_report", inputLabel: "Topic to assemble", placeholder: "green fireballs", field: "topic", tone: "text-[#e0c080] border-[#e0c080]" }, +]; + +export function QuickLaunch() { + const router = useRouter(); + const [opt, setOpt] = useState(OPTIONS[0]); + const [value, setValue] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + function pickDetective(slug: string) { + const next = OPTIONS.find((o) => o.slug === slug); + if (next) { setOpt(next); setValue(""); setError(null); } + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!value.trim()) return; + setPending(true); + setError(null); + try { + const payload: Record = { kind: opt.kind }; + payload[opt.field] = value.trim(); + const r = await fetch("/api/bureau/launch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = (await r.json()) as { job_id?: string; error?: string; message?: string }; + if (!r.ok || !data.job_id) { + setError(data.error || data.message || `HTTP ${r.status}`); + setPending(false); + return; + } + router.push(`/jobs/${data.job_id}`); + } catch (e) { + setError((e as Error).message); + setPending(false); + } + } + + return ( +
+
+ ⚡ Launch an investigation +
+
+ + setValue(e.target.value)} + placeholder={opt.placeholder} + aria-label={opt.inputLabel} + className="bg-[#060a13] border border-[rgba(127,219,255,0.18)] rounded px-3 py-2 text-[12px] text-[#e7ecf3] placeholder:text-[#5a6678] font-mono" + /> + +
+
+ {opt.inputLabel} + {error && · {error}} +
+
+ ); +}