disclosure-bureau/investigator-runtime/src/tools/write_evidence.ts
Luiz Gustavo 54a26f8db8
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 46s
CI / Scripts — Python smoke (push) Failing after 4s
CI / Web — npm audit (push) Failing after 34s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s
W3 followup: drop _FOR_WEB token, fix claude CLI args + writer guards, BIGSERIAL grants
Token consolidation:
- docker-compose web service now reads ${CLAUDE_CODE_OAUTH_TOKEN} directly,
  drop the W1-F8 CLAUDE_CODE_OAUTH_TOKEN_FOR_WEB indirection (user feedback:
  one var name, no _FOR_WEB suffix).

investigator-runtime claude.ts:
- --system-prompt silently dropped by CLI v2.1.150 for multi-KB prompts;
  inline the system content into the user prompt with a separator
  (mirrors scripts/reextract/run.py pattern).
- Multi-line prompts via positional -- broke ("Input must be provided …");
  pipe via stdin instead.
- --allowedTools "" is rejected; when no tools wanted, omit it and explicitly
  --disallowedTools the writer/reader set so the model can't reach for any.

investigator-runtime locard.ts:
- Log the raw response (first 600 chars) to container stderr — saved hours
  of debugging when the writer rejected.
- Grade fallback: when Locard omits `grade` but provides custody_steps,
  infer the highest grade that fits (≥3 → A, ≥2 → B, ≥1 → C).

investigator-runtime write_evidence.ts:
- Filter related_hypotheses entries with empty/null hypothesis_id silently
  (Locard sometimes emits [{}] when it knows no link yet) instead of
  failing the whole write.

Migration 0006_investigator_serial_sequences.sql:
- BIGSERIAL on the 7 investigation tables created auto-sequences
  (evidence_evidence_pk_seq etc) that 0004 forgot to GRANT to the
  investigator role. Without those grants every INSERT failed with
  "permission denied for sequence …". Grant USAGE/SELECT/UPDATE on each
  auto-seq.

Verified live: Locard wrote E-0002 + E-0003 from real Sandia chunks
(green fireball Feb 1949; cobalt particle analysis). Grade B, confidence
high, custody chain of 3 steps with honest gaps. Cost $0.09 for both,
~70s wall.

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

204 lines
7.2 KiB
TypeScript

/**
* write_evidence.ts — Locard's primary writer.
*
* The first of the gated write tools (sa-security gate #2). Builds a row in
* `public.evidence`, allocates E-NNNN via the sequence, writes the matching
* `case/evidence/E-NNNN.md` markdown file, and audit-logs the act.
*
* Discipline (mirrors the spec):
* - verbatim_excerpt MUST appear verbatim inside the source chunk content
* (we reject if it doesn't — Sonnet is occasionally creative about quoting).
* - source_chunk must exist in public.chunks (FK enforced by DB; we also
* check up front to give a friendlier error).
* - grade A requires ≥ 3 custody steps, B ≥ 2, C ≥ 1.
* - related_hypotheses[].hypothesis_id must exist (if provided).
*/
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { audit } from "../lib/audit";
import { env } from "../lib/env";
import { allocate } from "../lib/ids";
import { query, queryOne } from "../lib/pg";
export interface CustodyStep {
step: string;
uri?: string;
sha256?: string;
notes?: string;
}
export interface WriteEvidenceArgs {
verbatim_excerpt: string;
source_doc_id: string;
source_chunk_id: string;
/** Page is 1-indexed; we derive source_page_id from doc+page. */
page?: number;
bbox?: { x: number; y: number; w: number; h: number };
grade: "A" | "B" | "C";
custody_steps: CustodyStep[];
custody_gaps?: string[];
confidence_band?: "high" | "medium" | "low" | "speculation";
related_hypotheses?: Array<{ hypothesis_id: string; supports: boolean }>;
}
export interface WriteEvidenceContext {
job_id: string;
detective: string;
}
const GRADE_MIN_STEPS: Record<string, number> = { A: 3, B: 2, C: 1 };
function pageId(doc: string, p: number | undefined, chunkId: string): string {
// Prefer explicit page; otherwise we leave it doc-only and the lint
// catches the malformed reference later.
if (typeof p === "number" && p > 0) {
return `${doc}/p${String(p).padStart(3, "0")}#${chunkId}`;
}
return `${doc}#${chunkId}`;
}
function renderMd(args: {
evidence_id: string;
body: WriteEvidenceArgs;
ctx: WriteEvidenceContext;
}): string {
const { evidence_id, body, ctx } = args;
const fm = [
"---",
`schema_version: "0.1.0"`,
`type: evidence`,
`evidence_id: ${evidence_id}`,
`source_doc: ${body.source_doc_id}`,
`source_chunk: ${body.source_chunk_id}`,
body.page ? `source_page: ${body.page}` : null,
`grade: ${body.grade}`,
body.confidence_band ? `confidence_band: ${body.confidence_band}` : null,
`created_by: ${ctx.detective}`,
`job_id: ${ctx.job_id}`,
`created_at: ${new Date().toISOString()}`,
body.bbox ? `bbox: { x: ${body.bbox.x}, y: ${body.bbox.y}, w: ${body.bbox.w}, h: ${body.bbox.h} }` : null,
"---",
].filter(Boolean).join("\n");
const custody = body.custody_steps
.map((s, i) => ` ${i + 1}. **${s.step}**${s.uri ? `${s.uri}` : ""}${s.sha256 ? ` (sha256: \`${s.sha256.slice(0, 16)}\`)` : ""}${s.notes ? `\n ${s.notes}` : ""}`)
.join("\n");
const gaps = (body.custody_gaps ?? []).map((g) => ` - ${g}`).join("\n");
const links = (body.related_hypotheses ?? [])
.map((h) => ` - [[hypothesis/${h.hypothesis_id}]] (${h.supports ? "supports" : "refutes"})`)
.join("\n");
return [
fm,
"",
`# Evidence ${evidence_id}`,
"",
"## Verbatim excerpt (source language)",
"",
"> " + body.verbatim_excerpt.replace(/\n+/g, "\n> "),
"",
"## Chain of custody",
"",
custody,
gaps ? "\n### Gaps\n\n" + gaps : "",
"",
"## Source",
"",
`- Doc: [[${body.source_doc_id}]]`,
`- Chunk: [[${body.source_doc_id}#${body.source_chunk_id}]]`,
body.page ? `- Page: [[${body.source_doc_id}/p${String(body.page).padStart(3, "0")}]]` : "",
"",
"## Linked hypotheses",
"",
links || "_(none yet)_",
"",
].join("\n");
}
export async function writeEvidence(
body: WriteEvidenceArgs,
ctx: WriteEvidenceContext,
): Promise<{ evidence_id: string; case_file: string }> {
// 1. Validate grade minimums (sa-security gate #2: schema check before INSERT).
const minSteps = GRADE_MIN_STEPS[body.grade] ?? 99;
if (!Array.isArray(body.custody_steps) || body.custody_steps.length < minSteps) {
throw new Error(`grade ${body.grade} requires ≥ ${minSteps} custody steps; got ${body.custody_steps?.length ?? 0}`);
}
// 2. Resolve chunk_pk + verify the verbatim excerpt appears in the chunk.
const chunk = await queryOne<{ chunk_pk: number; content_en: string | null; content_pt: string | null }>(
`SELECT chunk_pk, content_en, content_pt
FROM public.chunks
WHERE doc_id = $1 AND chunk_id = $2
LIMIT 1`,
[body.source_doc_id, body.source_chunk_id],
);
if (!chunk) {
throw new Error(`chunk_not_found: ${body.source_doc_id}#${body.source_chunk_id}`);
}
const haystack = `${chunk.content_en ?? ""}\n${chunk.content_pt ?? ""}`;
const needle = body.verbatim_excerpt.trim();
if (needle.length < 8) {
throw new Error(`verbatim_excerpt too short (${needle.length} chars; min 8)`);
}
if (!haystack.includes(needle.slice(0, 80))) {
throw new Error(`verbatim_excerpt not found in source chunk — Sonnet must not paraphrase`);
}
// 3. (Optional) validate hypothesis refs exist. Drop empty/null IDs silently
// — Locard may emit `related_hypotheses: [{}]` when it knows of no link yet.
const validRefs = (body.related_hypotheses ?? []).filter(
(r) => typeof r?.hypothesis_id === "string" && r.hypothesis_id.trim().length > 0,
);
for (const ref of validRefs) {
const h = await queryOne<{ hypothesis_pk: number }>(
`SELECT hypothesis_pk FROM public.hypotheses WHERE hypothesis_id = $1`,
[ref.hypothesis_id],
);
if (!h) throw new Error(`linked hypothesis not found: ${ref.hypothesis_id}`);
}
body.related_hypotheses = validRefs;
// 4. Allocate id + INSERT.
const evidence_id = await allocate.evidenceId();
await query(
`INSERT INTO public.evidence
(evidence_id, verbatim_excerpt, source_chunk_pk, source_page_id, bbox,
grade, custody_steps, custody_gaps, confidence_band, related_hypotheses, created_by)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8::jsonb, $9, $10::jsonb, $11)`,
[
evidence_id,
body.verbatim_excerpt,
chunk.chunk_pk,
pageId(body.source_doc_id, body.page, body.source_chunk_id),
body.bbox ? JSON.stringify(body.bbox) : null,
body.grade,
JSON.stringify(body.custody_steps),
body.custody_gaps ? JSON.stringify(body.custody_gaps) : null,
body.confidence_band ?? null,
JSON.stringify(body.related_hypotheses ?? []),
ctx.detective,
],
);
// 5. Write the case markdown file.
const dir = path.join(env.CASE_ROOT, "evidence");
await mkdir(dir, { recursive: true });
const filepath = path.join(dir, `${evidence_id}.md`);
await writeFile(filepath, renderMd({ evidence_id, body, ctx }), "utf-8");
// 6. Audit.
await audit({
event: "write_evidence",
job_id: ctx.job_id,
detective: ctx.detective,
evidence_id,
source_doc: body.source_doc_id,
source_chunk: body.source_chunk_id,
grade: body.grade,
file: filepath,
});
return { evidence_id, case_file: filepath };
}