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>
This commit is contained in:
parent
f013bea462
commit
d4a2e4f51e
6 changed files with 519 additions and 13 deletions
145
web/app/api/bureau/launch/route.ts
Normal file
145
web/app/api/bureau/launch/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
|||
)}
|
||||
</header>
|
||||
|
||||
<DocBureauPanel docId={docId} />
|
||||
|
||||
<AnomalyHighlights docId={docId} ufo={ufoFlags} cryptid={cryptidFlags} />
|
||||
|
||||
<DocReadingView docId={docId} reading={reading} chunksByPage={ordered} />
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<string, string> = {
|
||||
|
|
@ -139,22 +140,28 @@ export async function BureauSnapshot() {
|
|||
</Link>
|
||||
</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">
|
||||
{DETECTIVES.map((d) => (
|
||||
<div
|
||||
<Link
|
||||
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">
|
||||
<span className="font-semibold">{d.name}</span>
|
||||
<span className="text-[9px] text-[#5a6678] uppercase">{d.slug}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[#9aa6b8] mt-0.5">{d.role}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick-launch form */}
|
||||
<QuickLaunch />
|
||||
|
||||
|
||||
{/* Counter row */}
|
||||
<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]" />
|
||||
|
|
|
|||
233
web/components/doc-bureau-panel.tsx
Normal file
233
web/components/doc-bureau-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
web/components/quick-launch.tsx
Normal file
115
web/components/quick-launch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue