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>
145 lines
5.5 KiB
TypeScript
145 lines
5.5 KiB
TypeScript
/**
|
|
* 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 });
|
|
}
|
|
}
|