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>
204 lines
7.2 KiB
TypeScript
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 };
|
|
}
|