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 { 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} />
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
||||||
|
|
|
||||||
|
|
@ -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]" />
|
||||||
|
|
|
||||||
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