diff --git a/investigator-runtime/src/detectives/case_writer.ts b/investigator-runtime/src/detectives/case_writer.ts index 6e23365..e605c15 100644 --- a/investigator-runtime/src/detectives/case_writer.ts +++ b/investigator-runtime/src/detectives/case_writer.ts @@ -69,6 +69,13 @@ interface BriefRow { unexplained_pt_br: string; } +interface ContradictionRow { + contradiction_id: string; + topic: string; + topic_pt_br: string | null; + chunks: unknown; +} + function topicSlug(topic: string): string { return topic .toLowerCase() @@ -158,12 +165,29 @@ function renderBrief(b: BriefRow | null, lang: "pt" | "en"): string { ].join("\n"); } +function renderTensions(rows: ContradictionRow[], lang: "pt" | "en"): string { + if (rows.length === 0) return "_(no tensions in the record)_"; + return rows.map((c) => { + const topic = lang === "pt" ? (c.topic_pt_br ?? c.topic) : c.topic; + const positions = Array.isArray(c.chunks) ? c.chunks as Array> : []; + const lines = positions.slice(0, 3).map((p) => { + const stmt = lang === "pt" + ? (p.statement_pt_br as string) ?? (p.statement as string) + : (p.statement as string); + const pageStr = String(p.page ?? "").padStart(3, "0"); + return ` - ${stmt} [[${p.doc_id}/p${pageStr}#${p.chunk_id}]]`; + }).join("\n"); + return `### ${topic}\n${lines}`; + }).join("\n\n"); +} + function buildPrompt( task: CaseWriterTask, scenes: SearchHit[], evidence: EvidenceRow[], witnesses: WitnessRow[], gaps: GapRow[], + tensions: ContradictionRow[], brief: BriefRow | null, lang: "pt" | "en", ): string { @@ -210,6 +234,15 @@ function buildPrompt( "", renderNamedWitnesses(witnesses), "", + "## Tensions in the record", + "", + "Points where the documents disagree with each other — a witness says", + "green, another says red; one report fixes a date, another contradicts", + "it. These are intrigue, not failure. Use them to build suspense: the", + "record itself could not settle the question.", + "", + renderTensions(tensions, lang), + "", "## Strategic brief (internal — do NOT cite or attribute)", "", "A separate analyst built the strongest pro-anomaly reading the corpus", @@ -321,6 +354,16 @@ export async function runCaseWriter(task: CaseWriterTask): Promise< ).catch(() => [] as BriefRow[]); const brief = briefRows[0] ?? null; + // Tensions — Dupin's contradictions on this topic. Enthusiast-grade + // intrigue ("the record disagreed with itself"), not skeptic framing. + const tensions = await query( + `SELECT contradiction_id, topic, topic_pt_br, chunks + FROM public.contradictions + WHERE LOWER(topic) LIKE $1 OR LOWER(COALESCE(topic_pt_br,'')) LIKE $1 + ORDER BY created_at DESC LIMIT 4`, + [filter], + ).catch(() => [] as ContradictionRow[]); + await audit({ event: "case_writer_grounded", job_id: task.job_id, @@ -340,7 +383,7 @@ export async function runCaseWriter(task: CaseWriterTask): Promise< } const systemPrompt = await readFile(PROMPT_PATH, "utf-8"); - const prompt = buildPrompt(task, scenes, evidence, witnesses, gaps, brief, lang); + const prompt = buildPrompt(task, scenes, evidence, witnesses, gaps, tensions, brief, lang); // Case-writer wants more output budget than the other detectives. const llm = await callClaude({