disclosure-bureau/web/app/api/admin/investigate/test/route.ts
Luiz Gustavo 189a771cbe
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 38s
CI / Scripts — Python smoke (push) Failing after 3s
CI / Web — npm audit (push) Failing after 33s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s
W3.1-W3.4: Investigation Bureau foundation — migrations, runtime, Locard
Migrations:
- 0004_investigation_bureau.sql: 7 new tables (investigation_jobs + evidence,
  hypotheses, contradictions, witnesses, gaps, residual_uncertainties), id
  sequences, pg_notify trigger on investigation_jobs, RLS read-only public,
  investigator role with least-privilege grants (no service_role).
- 0005_investigator_write_policies.sql: fixup adding RLS INSERT/UPDATE
  policies bound to investigator + service_role + postgres (RLS with only a
  SELECT policy was silently blocking the worker's claim UPDATE).

investigator-runtime/ (new Bun + TS container):
- src/main.ts: LISTEN/NOTIFY poller, claim-with-SKIP-LOCKED, drain pool,
  healthcheck file, graceful SIGTERM shutdown.
- src/orchestrator.ts: chief-detective dispatch (evidence_chain → Locard).
  Marks job failed when all per-item outputs error; surfaces first errors.
- src/lib/{env,pg,audit,ids,claude}.ts: typed config (gate #8), pool +
  dedicated LISTEN client, NDJSON audit, sequence allocator (E-NNNN etc),
  claude -p subprocess with quota detection (api_error_status=429).
- src/tools/write_evidence.ts: schema-validate (grade A/B/C custody steps),
  resolve chunk_pk via FK, verify verbatim_excerpt actually appears in
  chunk content, INSERT + render case/evidence/E-NNNN.md + audit.
- src/detectives/locard.ts: load chunk → call Claude with locard.md system
  prompt → parse strict JSON → call writeEvidence locally.
- Dockerfile installs `claude` CLI (OAuth) at build time.

Compose:
- new `investigator` service builds from investigator-runtime/, connects
  with low-privilege role, mounts case/ RW and wiki/+raw/ RO, 512m mem cap.

Web:
- /api/admin/investigate/test (POST+GET) gated by middleware (W0-F1).
  POST creates a job, GET polls status. For W3.6 it becomes the chat tool.

End-to-end smoke: INSERT job → pg_notify → claim → Locard dispatch →
claude subprocess invoked. Auth works (CLI v2.1.150). Currently quota
exhausted (weekly limit · resets 3pm UTC) — pipeline catches the typed
isQuota error, marks job failed with surfaced reason. Architecture proven;
quota reset enables real evidence creation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:49:33 -03:00

87 lines
3.1 KiB
TypeScript

/**
* /api/admin/investigate/test — admin-only trigger for a synthetic
* investigation job. Gated by middleware (W0-F1: /api/admin/* → 404 unless
* profile.role==='admin').
*
* POST { kind?: 'evidence_chain', doc_id, chunks?: string[] } — INSERT a row
* in public.investigation_jobs; the disclosure-investigator container's
* LISTEN handler picks it up, dispatches Locard, and updates status.
* GET ?job_id=... — read the latest state for polling.
*/
import { NextResponse } from "next/server";
import { pgQuery } from "@/lib/retrieval/db";
import { withRequest } from "@/lib/logger";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface JobRow {
job_id: string;
kind: string;
payload: unknown;
status: string;
worker_id: string | null;
started_at: string | null;
finished_at: string | null;
outputs: unknown;
error: string | null;
created_at: string;
}
export async function POST(request: Request) {
const log = withRequest(request);
let body: { kind?: string; doc_id?: string; chunks?: string[]; claim?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
const kind = body.kind ?? "evidence_chain";
const doc_id = body.doc_id;
if (!doc_id) return NextResponse.json({ error: "doc_id_required" }, { status: 400 });
const payload: Record<string, unknown> = { doc_id };
if (body.chunks?.length) payload.chunks = body.chunks;
if (body.claim) payload.claim = body.claim;
// Triggered_by carries the admin email so the audit ties back to a person.
// Middleware already validated the admin role; we just label here.
const triggered_by = `user:${request.headers.get("x-user-email") ?? "admin"}`;
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;
log.info({ event: "investigation_job_created", kind, doc_id, job_id }, "investigation job queued");
return NextResponse.json({
job_id,
kind,
status: "queued",
poll_url: `/api/admin/investigate/test?job_id=${job_id}`,
});
} catch (e) {
return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 });
}
}
export async function GET(request: Request) {
const url = new URL(request.url);
const job_id = url.searchParams.get("job_id");
if (!job_id) return NextResponse.json({ error: "job_id_required" }, { status: 400 });
try {
const rows = await pgQuery<JobRow>(
`SELECT job_id, kind, payload, status, worker_id, started_at, finished_at,
outputs, error, created_at
FROM public.investigation_jobs WHERE job_id = $1`,
[job_id],
);
if (rows.length === 0) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(rows[0]);
} catch (e) {
return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 });
}
}