W3.10: clickable detective tiles + quick-launch form + doc bureau panel
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 37s
CI / Scripts — Python smoke (push) Failing after 5s
CI / Web — npm audit (push) Failing after 40s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s

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>
This commit is contained in:
Luiz Gustavo 2026-05-23 23:33:00 -03:00
parent f013bea462
commit d4a2e4f51e
6 changed files with 519 additions and 13 deletions

View file

@ -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<string, string> = {
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<string, number> = {
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<string, unknown>;
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<string, unknown> = {};
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 });
}
}

View file

@ -14,6 +14,7 @@ import { AuthBar } from "@/components/auth-bar";
import { ChatBubble } from "@/components/chat-bubble"; import { ChatBubble } from "@/components/chat-bubble";
import { DocReadingView } from "@/components/doc-reading-view"; import { DocReadingView } from "@/components/doc-reading-view";
import { AnomalyHighlights, type AnomalyFlag } from "@/components/anomaly-highlights"; import { AnomalyHighlights, type AnomalyFlag } from "@/components/anomaly-highlights";
import { DocBureauPanel } from "@/components/doc-bureau-panel";
import { MarkdownBody } from "@/components/markdown-body"; import { MarkdownBody } from "@/components/markdown-body";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -151,6 +152,8 @@ export default async function DocPage({
)} )}
</header> </header>
<DocBureauPanel docId={docId} />
<AnomalyHighlights docId={docId} ufo={ufoFlags} cryptid={cryptidFlags} /> <AnomalyHighlights docId={docId} ufo={ufoFlags} cryptid={cryptidFlags} />
<DocReadingView docId={docId} reading={reading} chunksByPage={ordered} /> <DocReadingView docId={docId} reading={reading} chunksByPage={ordered} />

View file

@ -9,7 +9,10 @@ const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "The Disclosure Bureau", 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 }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {

View file

@ -8,6 +8,7 @@
*/ */
import Link from "next/link"; import Link from "next/link";
import { pgQuery } from "@/lib/retrieval/db"; import { pgQuery } from "@/lib/retrieval/db";
import { QuickLaunch } from "./quick-launch";
interface CountRow { c: string } interface CountRow { c: string }
interface RecentEvidence { evidence_id: string; grade: string; verbatim_excerpt: string; source_page_id: string; confidence_band: string | null } 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<string, unknown> | null } interface RecentJob { job_id: string; kind: string; status: string; created_at: string; payload: Record<string, unknown> | null }
const DETECTIVES = [ const DETECTIVES = [
{ slug: "holmes", name: "Holmes", role: "Hypothesis tournament", tone: "text-[#7fdbff] border-[#7fdbff]" }, { 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", tone: "text-[#06d6a0] border-[#06d6a0]" }, { 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", tone: "text-[#ff8a4d] border-[#ff8a4d]" }, { 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", tone: "text-[#ff3344] border-[#ff3344]" }, { 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", tone: "text-[#9b5de5] border-[#9b5de5]" }, { 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", tone: "text-[#ffd23f] border-[#ffd23f]" }, { 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", tone: "text-[#26d4cc] border-[#26d4cc]" }, { 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", tone: "text-[#e0c080] border-[#e0c080]" }, { 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; ] as const;
const BAND_TONE: Record<string, string> = { const BAND_TONE: Record<string, string> = {
@ -139,22 +140,28 @@ export async function BureauSnapshot() {
</Link> </Link>
</div> </div>
{/* Detective tiles */} {/* Detective tiles — clickable to their artefact section in /bureau */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-5"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-5">
{DETECTIVES.map((d) => ( {DETECTIVES.map((d) => (
<div <Link
key={d.slug} key={d.slug}
className={`border ${d.tone} bg-[#0d1220] rounded p-2 text-[11px] font-mono leading-snug`} href={d.anchor}
className={`block border ${d.tone} bg-[#0d1220] rounded p-2 text-[11px] font-mono leading-snug transition-colors`}
title={`See ${d.role} artefacts →`}
> >
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className="font-semibold">{d.name}</span> <span className="font-semibold">{d.name}</span>
<span className="text-[9px] text-[#5a6678] uppercase">{d.slug}</span> <span className="text-[9px] text-[#5a6678] uppercase">{d.slug}</span>
</div> </div>
<div className="text-[10px] text-[#9aa6b8] mt-0.5">{d.role}</div> <div className="text-[10px] text-[#9aa6b8] mt-0.5">{d.role}</div>
</div> </Link>
))} ))}
</div> </div>
{/* Quick-launch form */}
<QuickLaunch />
{/* Counter row */} {/* Counter row */}
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 mb-5 text-center"> <div className="grid grid-cols-3 md:grid-cols-6 gap-2 mb-5 text-center">
<Counter href="/bureau#evidence" label="evidence" value={counts.evidence ?? 0} color="text-[#06d6a0]" /> <Counter href="/bureau#evidence" label="evidence" value={counts.evidence ?? 0} color="text-[#06d6a0]" />

View file

@ -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<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>
);
}

View file

@ -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<DetectiveOption>(OPTIONS[0]);
const [value, setValue] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(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<string, string> = { 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 (
<form
onSubmit={submit}
className="rounded-lg border border-[rgba(127,219,255,0.18)] bg-[#0d1220] p-3 mb-5"
>
<div className="text-[10px] font-mono text-[#5a6678] uppercase tracking-wider mb-2">
Launch an investigation
</div>
<div className="grid md:grid-cols-[260px_1fr_auto] gap-2">
<select
value={opt.slug}
onChange={(e) => pickDetective(e.target.value)}
className={`bg-[#060a13] border ${opt.tone} rounded px-2 py-2 text-[12px] font-mono`}
>
{OPTIONS.map((o) => (
<option key={o.slug} value={o.slug}>{o.label}</option>
))}
</select>
<input
type="text"
value={value}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={pending || !value.trim()}
className={`px-4 py-2 rounded border ${opt.tone} text-[12px] font-mono inline-flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed`}
>
{pending ? "queueing…" : "launch"}
{!pending && <ArrowUpRight size={12} />}
</button>
</div>
<div className="text-[10px] font-mono text-[#5a6678] mt-2">
{opt.inputLabel}
{error && <span className="text-[#ff6ec7] ml-2">· {error}</span>}
</div>
</form>
);
}