disclosure-bureau/web/app/api/bureau/launch/route.ts
Luiz Gustavo d4a2e4f51e
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
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>
2026-05-23 23:33:00 -03:00

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