W5.9: case-writer pulls Dupin contradictions as narrative "tensions"
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 46s
CI / Scripts — Python smoke (push) Failing after 5s
CI / Web — npm audit (push) Failing after 44s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s

Enthusiast-grade intrigue, not skeptic framing. When the corpus disagrees
with itself — one witness says the fireball was green, another says red;
one report dates an event, another contradicts it — that is suspense
material, not a debunk. The narrator now receives a "Tensions in the
record" section (Dupin's contradictions for the topic, top 4) and is told
to use them to build the question the record itself could not settle.

  - New ContradictionRow type + renderTensions() (bilingual, cites chunks)
  - Query: contradictions matching topic ILIKE, top 4 by recency
  - buildPrompt gains a tensions param + "## Tensions in the record"
    section framed as intrigue ("these are intrigue, not failure")

Deferred deploy: the bulk entity-enrichment run is still finishing the
organization class inside the live investigator container; rebuilding now
would kill it. Will deploy this + reprocess the flagship green-fireballs
case_report once the bulk completes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luiz Gustavo 2026-05-25 01:31:59 -03:00
parent 3e88a64933
commit eca177b079

View file

@ -69,6 +69,13 @@ interface BriefRow {
unexplained_pt_br: string; unexplained_pt_br: string;
} }
interface ContradictionRow {
contradiction_id: string;
topic: string;
topic_pt_br: string | null;
chunks: unknown;
}
function topicSlug(topic: string): string { function topicSlug(topic: string): string {
return topic return topic
.toLowerCase() .toLowerCase()
@ -158,12 +165,29 @@ function renderBrief(b: BriefRow | null, lang: "pt" | "en"): string {
].join("\n"); ].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<Record<string, unknown>> : [];
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( function buildPrompt(
task: CaseWriterTask, task: CaseWriterTask,
scenes: SearchHit[], scenes: SearchHit[],
evidence: EvidenceRow[], evidence: EvidenceRow[],
witnesses: WitnessRow[], witnesses: WitnessRow[],
gaps: GapRow[], gaps: GapRow[],
tensions: ContradictionRow[],
brief: BriefRow | null, brief: BriefRow | null,
lang: "pt" | "en", lang: "pt" | "en",
): string { ): string {
@ -210,6 +234,15 @@ function buildPrompt(
"", "",
renderNamedWitnesses(witnesses), 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)", "## Strategic brief (internal — do NOT cite or attribute)",
"", "",
"A separate analyst built the strongest pro-anomaly reading the corpus", "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[]); ).catch(() => [] as BriefRow[]);
const brief = briefRows[0] ?? null; 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<ContradictionRow>(
`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({ await audit({
event: "case_writer_grounded", event: "case_writer_grounded",
job_id: task.job_id, 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 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. // Case-writer wants more output budget than the other detectives.
const llm = await callClaude({ const llm = await callClaude({