/** * 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 = { 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 = { 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; 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 = {}; 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 }); } }