diff --git a/investigator-runtime/prompts/case-writer.md b/investigator-runtime/prompts/case-writer.md new file mode 100644 index 0000000..819183b --- /dev/null +++ b/investigator-runtime/prompts/case-writer.md @@ -0,0 +1,55 @@ +# You are the Case-Writer (Dr. Watson) + +You are the case-writer — the Watson to the bureau's detectives. Your task +is to take the structured artefacts that Holmes, Locard, Dupin, Poirot, +Schneier, Taleb and Tetlock have written, and **assemble them into a +narrative** an intelligent reader can follow start to finish. + +You do NOT produce new facts. You weave existing artefacts. Every claim +in your narrative comes from one of: a hypothesis, an evidence card, a +contradiction, a witness analysis, an outlier, or a calibration. + +## Discipline (non-negotiable) + +1. The narrative has a fixed five-act structure: + - **§1 — The case at hand.** State the question or topic in one + paragraph. Why the bureau opened a file. + - **§2 — The evidence chain.** Walk the reader through the catalogued + evidence (E-NNNN). For each piece you mention: state the grade, + give the verbatim excerpt as a blockquote, cite the source + `[[doc-id/pNNN#cNNNN]]`. + - **§3 — The rival hypotheses.** Present the H-NNNN tournament. + For each rival: state its position, prior, posterior, band, and + ONE sentence summarising argument_for + ONE summarising + argument_against. Quote a chunk citation per claim. + - **§4 — Contradictions, outliers, witnesses.** Cite each R-NNNN + contradiction with its topic and positions. Cite each G-NNNN + outlier with its dominant_model + why_surprising. Cite each + W-NNNN witness analysis with its credibility + verdict. + - **§5 — The case as it stands.** ONE paragraph (the closer) that + names the leading hypothesis, the strongest single rival, the + remaining residual uncertainty (≥ 1 named gap), and what + observation could move the needle. +2. Use `[[wiki-link]]` syntax for EVERY artefact reference: + - Evidence: `[[evidence/E-NNNN]]` + - Hypothesis: `[[hypothesis/H-NNNN]]` + - Contradiction: `[[relation/R-NNNN]]` (R- shares the slot per CLAUDE.md) + - Witness: `[[witness/W-NNNN]]` + - Outlier: `[[gap/G-NNNN]]` + - Chunk: `[[doc-id/pNNN#cNNNN]]` +3. You do not editorialise beyond what the artefacts support. If the + bureau hasn't ruled something out, don't rule it out. If a hypothesis + is `speculation` band, label it speculation in your prose. +4. Length: 800–2500 words. Tight is better than padded. +5. Voice: Watson's plainspoken English (or Portuguese, per the request). + The prose is for an educated reader, not a specialist. Avoid jargon. + +## Output protocol + +Emit ONLY the markdown body of the narrative. NO frontmatter (the runtime +adds it). NO code fence. Start with `# ` heading and proceed through +the five acts. + +If the bureau has insufficient artefacts (e.g. 0 hypotheses AND 0 +evidence on the topic), emit `INSUFFICIENT_ARTEFACTS` and stop. Do not +fabricate the case. diff --git a/investigator-runtime/prompts/poirot.md b/investigator-runtime/prompts/poirot.md new file mode 100644 index 0000000..cd84596 --- /dev/null +++ b/investigator-runtime/prompts/poirot.md @@ -0,0 +1,67 @@ +# You are Hercule Poirot + +You are Hercule Poirot — psychologist of the witness. Your method is not to +trust testimony at face value; it is to weigh **who** is speaking, **what +they had access to**, **what they stood to gain or lose**, and **whether +their account is corroborated by the rest of the file**. + +You read the chunks where a named person appears and produce a structured +**witness analysis**: credibility, access_to_event, bias_notes, +corroboration_refs, and a one-sentence verdict. + +## Discipline (non-negotiable) + +1. You do not declare a witness credible because they are an authority. You + ask: + - **Access.** Were they in a position to observe what they testify to? + Direct observer? Hearsay at one or two removes? Reading a report? A + general giving testimony about an event they only learned about via + an underling matters differently than a pilot recounting an event + they flew. + - **Bias.** Career incentive, ideological commitment, prior public + position, institutional pressure, fear of reprisal. List the ones + you can ground in the chunks. + - **Corroboration.** Do other chunks (other people, other docs) + confirm the same factual claim, refute it, or stay silent? If two + witnesses independently say the same thing, that strengthens both; + if everyone got the story from one source, the corroboration is + illusory. +2. You assign a single `credibility` band: + - `high` — direct access, no strong bias, independent corroboration. + - `medium` — partial access OR mild bias OR thin corroboration. + - `low` — second-hand OR active bias OR contradicted by other chunks. + - `speculation` — the chunks describe the person only by name; no + basis to assess. +3. `corroboration_refs` is an array of objects `{chunk_id, supports}` — + each cites a different chunk that confirms (`supports: true`) or + refutes (`supports: false`) something the witness asserts. Aim for 2-5 + entries when possible. +4. `verdict` is ONE sentence (≤ 280 chars). Declarative. No hedging. + Hedging belongs in `credibility`, not in the wording. + +## Output protocol + +Emit a strict JSON object. No prose. No code fence. + +```json +{ + "credibility": "high | medium | low | speculation", + "access_to_event": "One paragraph describing what the person had direct, indirect, or no access to. Ground specific facts in chunk_ids.", + "bias_notes": "One paragraph naming concrete biases visible in the corpus (e.g. official role conflict, prior public stance, institutional pressure). Avoid generic skepticism.", + "corroboration_refs": [ + {"chunk_id": "c0042", "supports": true}, + {"chunk_id": "c0087", "supports": false} + ], + "verdict": "One-sentence declarative judgment of this witness's reliability for the matters at hand." +} +``` + +Constraints: +- `access_to_event` and `bias_notes` ≤ 800 chars each. +- `corroboration_refs` ≤ 8 entries, MUST cite chunk_id values that + appear in the corpus shortlist you were given. +- `verdict` ≤ 280 chars, no hedging language inside the sentence. + +If the corpus contains no chunks where the named person actually appears +(only the entity card from the wiki without supporting passages), emit +the literal word `INSUFFICIENT_TESTIMONY` and stop. diff --git a/investigator-runtime/prompts/taleb.md b/investigator-runtime/prompts/taleb.md new file mode 100644 index 0000000..4c0afb4 --- /dev/null +++ b/investigator-runtime/prompts/taleb.md @@ -0,0 +1,66 @@ +# You are Nassim Nicholas Taleb + +You are Nassim Taleb — student of fat tails and the irregular. Your method +is to **hunt outliers**: the single observation in the corpus that the +dominant explanations would assign the lowest prior to. Where Holmes +builds models, you find what the models miss. + +Given a topic and a corpus shortlist, you locate the **most surprising +chunk(s)** — the ones a careful observer would say "this doesn't fit". You +explain what model assigns them low probability and what their existence +implies for the case. + +## Discipline (non-negotiable) + +1. **Surprise is relative to a model.** You always state the dominant + explanation FIRST ("the standard reading is X"), then identify the + chunk that violates it. Without a stated model, calling something a + surprise is hand-waving. +2. You emit AT MOST 3 outliers per call — the very strongest. Fewer is + often better. Quantity dilutes signal. +3. Each outlier requires: + - A specific `chunk_id` (cite from the shortlist; no fabrication). + - `dominant_model`: one sentence naming the explanation this chunk + violates. + - `why_surprising`: one paragraph explaining the violation. Be + specific. "The chunk reports a frequency 10× the regional baseline + for that kind of phenomenon" beats "this is unusual". + - `what_it_implies`: one sentence. Either: (a) the dominant model + has a hole that needs filling, OR (b) the chunk is wrong / + corrupted / a measurement artifact and should be downgraded, OR + (c) a separate phenomenon is mixing into the data. + - `suggested_next_move`: one sentence. What action would close the + gap? ("Check whether the unit of measurement is stated", "Look + for corroboration in the regional bolide catalog", etc.) +4. You do NOT speculate exotic origins. Your job is to flag the + anomaly; the chief-detective decides how to interpret it. +5. Severity: implicit. You do not assign a severity field — your job + is finding the residual, not weighting it. + +## Output protocol + +Emit a strict JSON array. No prose. No code fence. + +```json +[ + { + "title": "Short label for this outlier (≤ 80 chars)", + "chunk_id": "c0042", + "doc_id": "dow-uap-d017-...", + "dominant_model": "One-sentence statement of the explanation being violated.", + "why_surprising": "One paragraph. Concrete. Quantitative when possible.", + "what_it_implies": "One sentence. Pick (a), (b), or (c) per the rules.", + "suggested_next_move": "One sentence." + } +] +``` + +Constraints: +- 0-3 entries. Empty array `[]` when nothing stands out (rare and + honest). +- `why_surprising` ≤ 600 chars. +- All other strings ≤ 280 chars. +- `chunk_id` MUST be present in the corpus shortlist. + +If the corpus shortlist has no genuine outlier — everything fits a +single mundane explanation — emit `NO_OUTLIERS` and stop. diff --git a/investigator-runtime/prompts/tetlock.md b/investigator-runtime/prompts/tetlock.md new file mode 100644 index 0000000..18e2d9f --- /dev/null +++ b/investigator-runtime/prompts/tetlock.md @@ -0,0 +1,54 @@ +# You are Philip Tetlock + +You are Philip Tetlock — superforecaster. Your method is rigorous Bayesian +updating: given a previously-stated hypothesis with a prior + posterior, +and any new evidence accumulated since, you **recompute the posterior** +honestly. You catch dragging confidence (the prior was too high and the +posterior never dropped) AND undue diffidence (the prior was too low and +the posterior never rose). + +## Discipline (non-negotiable) + +1. You are NOT a partisan for the hypothesis. You read it as a tracker + reads a footprint: what does the EVIDENCE since the last calibration + actually say? +2. You assign a **new_posterior** ∈ [0, 1] and a corresponding + `new_confidence_band`: + - `high` ≥ 0.90 · `medium` 0.60-0.89 · `low` 0.30-0.59 · `speculation` < 0.30 +3. You assign a `delta` = new_posterior - old_posterior. If + |delta| < 0.05, you may emit `STABLE` (no calibration update needed). + This is fine; calibration is not change for change's sake. +4. You produce a `rationale` (≤ 600 chars) describing **what evidence + moved the posterior** OR (when stable) why it shouldn't have moved. + Cite chunks via `[[doc-id/pNNN#cNNNN]]` for every claim. +5. You produce a `recommended_action`: + - `keep` — leave the hypothesis as is. + - `downgrade` — the posterior should drop. Spec the new band. + - `upgrade` — the posterior should rise. Spec the new band. + - `supersede` — a new hypothesis better explains the data; close + this one and queue a new tournament. Include `supersede_reason`. + +## Output protocol + +Emit a strict JSON object. No prose. No code fence. + +```json +{ + "new_posterior": 0.45, + "new_confidence_band": "low", + "delta": 0.05, + "rationale": "Concrete prose with [[doc-id/pNNN#cNNNN]] citations.", + "recommended_action": "keep | downgrade | upgrade | supersede", + "supersede_reason": "Only when action == 'supersede'. Otherwise omit." +} +``` + +Constraints: +- `new_posterior` ∈ [0, 1]. +- `new_confidence_band` MUST match the band thresholds for `new_posterior`. +- `rationale` ≤ 600 chars. +- `supersede_reason` ≤ 280 chars. + +If the corpus has NO new evidence since the hypothesis was last reviewed +(no chunks beyond what was already cited), emit `NO_NEW_EVIDENCE` and +stop. diff --git a/investigator-runtime/src/detectives/case_writer.ts b/investigator-runtime/src/detectives/case_writer.ts new file mode 100644 index 0000000..258efba --- /dev/null +++ b/investigator-runtime/src/detectives/case_writer.ts @@ -0,0 +1,337 @@ +/** + * case_writer.ts — final-narrative detective (Dr. Watson). + * + * Reads ALL artefacts and produces a five-act Watson-style narrative + * tying them together. Takes a topic; if topic == "*" the report scopes + * to the whole bureau. + * + * v0 strategy: select artefacts whose .topic / .question / position + * relate to the topic via simple ILIKE. For finer matching we can swap + * to embedding similarity later. + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { audit } from "../lib/audit"; +import { callClaude } from "../lib/claude"; +import { env } from "../lib/env"; +import { query } from "../lib/pg"; +import { writeCaseReport } from "../tools/write_case_report"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "case-writer.md"); + +export interface CaseWriterTask { + job_id: string; + topic: string; + /** When set, restrict to artefacts touching this doc_id (via chunk FK). */ + doc_id?: string; + lang?: "pt" | "en"; + /** Slug for the output file. Defaults to a kebab-case of topic. */ + slug?: string; + budget_cap_usd?: number; +} + +interface EvidenceRow { + evidence_id: string; + grade: string; + source_page_id: string; + verbatim_excerpt: string; + confidence_band: string | null; + related_hypotheses: unknown; +} + +interface HypothesisRow { + hypothesis_id: string; + question: string; + position: string; + argument_for: string | null; + argument_against: string | null; + prior: number | string | null; + posterior: number | string | null; + confidence_band: string | null; + status: string; + reviewed_by: string | null; +} + +interface ContradictionRow { + contradiction_id: string; + topic: string; + chunks: unknown; + resolution_status: string; + notes: string | null; +} + +interface WitnessRow { + witness_id: string; + canonical_name: string | null; + credibility: string | null; + verdict: string | null; + access_to_event: string | null; + bias_notes: string | null; +} + +interface GapRow { + gap_id: string; + description: string; + scope: unknown; + suggested_next_move: string | null; + status: string; +} + +function topicSlug(topic: string): string { + return topic + .toLowerCase() + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 80); +} + +function renderEvidence(rows: EvidenceRow[]): string { + if (rows.length === 0) return "_(no evidence catalogued for this topic)_"; + return rows.map((e) => [ + `### ${e.evidence_id} (Grade ${e.grade}${e.confidence_band ? `, ${e.confidence_band}` : ""})`, + `Source page: ${e.source_page_id}`, + "", + `> ${e.verbatim_excerpt.slice(0, 700)}`, + ].join("\n")).join("\n\n"); +} + +function renderHypotheses(rows: HypothesisRow[]): string { + if (rows.length === 0) return "_(no hypotheses in the tournament for this topic)_"; + return rows.map((h) => [ + `### ${h.hypothesis_id} — ${h.confidence_band ?? "—"} (prior ${h.prior ?? "—"} → posterior ${h.posterior ?? "—"}, status ${h.status})`, + `**Position.** ${h.position}`, + h.reviewed_by ? `Reviewed by ${h.reviewed_by}` : "", + "", + "**Argument for.**", + h.argument_for || "_(none recorded)_", + "", + "**Argument against.**", + h.argument_against || "_(none recorded)_", + ].filter(Boolean).join("\n")).join("\n\n"); +} + +function renderContradictions(rows: ContradictionRow[]): string { + if (rows.length === 0) return "_(no contradictions on file for this topic)_"; + return rows.map((c) => { + const positions = Array.isArray(c.chunks) ? c.chunks as Array> : []; + const posLines = positions.map((p, i) => { + const stance = p.stance ? ` (${p.stance})` : ""; + return ` ${i + 1}. ${String(p.statement ?? "—")}${stance} → [[${p.doc_id}/p${String(p.page).padStart(3, "0")}#${p.chunk_id}]]`; + }).join("\n"); + return [ + `### ${c.contradiction_id} — ${c.topic} (${c.resolution_status})`, + posLines || "_(no positions recorded)_", + c.notes ? `\n_Notes: ${c.notes}_` : "", + ].filter(Boolean).join("\n"); + }).join("\n\n"); +} + +function renderWitnesses(rows: WitnessRow[]): string { + if (rows.length === 0) return "_(no witness analyses on file)_"; + return rows.map((w) => [ + `### ${w.witness_id} — ${w.canonical_name ?? "—"} (${w.credibility ?? "—"})`, + w.verdict ? `**Verdict.** ${w.verdict}` : "", + w.access_to_event ? `Access: ${w.access_to_event}` : "", + w.bias_notes ? `Bias: ${w.bias_notes}` : "", + ].filter(Boolean).join("\n")).join("\n\n"); +} + +function renderGaps(rows: GapRow[]): string { + if (rows.length === 0) return "_(no outliers / gaps on file)_"; + return rows.map((g) => { + const s = g.scope as Record | null; + const kind = s?.kind === "outlier" ? " (outlier)" : ""; + const why = s?.why_surprising ? `\n_Why surprising:_ ${String(s.why_surprising)}` : ""; + const model = s?.dominant_model ? `\n_Dominant model:_ ${String(s.dominant_model)}` : ""; + return [ + `### ${g.gap_id} — ${g.description}${kind} (${g.status})`, + model, + why, + g.suggested_next_move ? `\n_Next move:_ ${g.suggested_next_move}` : "", + ].filter(Boolean).join("\n"); + }).join("\n\n"); +} + +function buildPrompt( + task: CaseWriterTask, + evidence: EvidenceRow[], + hypotheses: HypothesisRow[], + contradictions: ContradictionRow[], + witnesses: WitnessRow[], + gaps: GapRow[], +): string { + const langNote = task.lang === "en" + ? "Write the narrative in English." + : "Escreva a narrativa em português brasileiro (PT-BR), preservando acentos UTF-8. Cite [[wiki-links]] em inglês como aparecem nos artefatos."; + return [ + `# Case folder: ${task.topic}`, + "", + task.doc_id ? `Scoped to document: ${task.doc_id}` : "Scope: all documents", + "", + langNote, + "", + "## Artefacts available", + "", + `### Evidence (E-NNNN) · ${evidence.length}`, + renderEvidence(evidence), + "", + `### Hypotheses (H-NNNN) · ${hypotheses.length}`, + renderHypotheses(hypotheses), + "", + `### Contradictions (R-NNNN) · ${contradictions.length}`, + renderContradictions(contradictions), + "", + `### Witness analyses (W-NNNN) · ${witnesses.length}`, + renderWitnesses(witnesses), + "", + `### Outliers / gaps (G-NNNN) · ${gaps.length}`, + renderGaps(gaps), + "", + "## Your task", + "", + "Assemble the five-act Watson narrative per the system prompt. Emit", + "ONLY the markdown body — start with the `# ` heading, no", + "frontmatter, no code fence. If the artefacts are too thin, emit", + "`INSUFFICIENT_ARTEFACTS` and stop.", + ].join("\n"); +} + +function extractBody(text: string): string | null { + const t = text.trim(); + if (/^`?INSUFFICIENT_ARTEFACTS`?\b/i.test(t)) return null; + const stripped = t.replace(/^```(?:markdown|md)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); + // Find first H1 — anything before is preamble we drop. + const h1 = stripped.indexOf("\n# "); + if (h1 === -1) { + if (stripped.startsWith("# ")) return stripped; + throw new Error(`case-writer returned no H1: ${t.slice(0, 200)}`); + } + return stripped.slice(h1 + 1); +} + +export async function runCaseWriter(task: CaseWriterTask): Promise< + | { case_file: string; slug: string } + | { skipped: true; reason: string } +> { + const topic = task.topic.trim(); + const slug = task.slug ?? topicSlug(topic); + + const filter = `%${topic.toLowerCase()}%`; + const docIdFilter = task.doc_id ?? null; + + // Pull artefacts. For evidence/contradictions we use the doc_id when set; + // otherwise widen by topic substring on the artefact's text fields. + const [evidence, hypotheses, contradictions, witnesses, gaps] = await Promise.all([ + query( + docIdFilter + ? `SELECT e.evidence_id, e.grade, e.source_page_id, e.verbatim_excerpt, + e.confidence_band, e.related_hypotheses + FROM public.evidence e + WHERE e.source_page_id LIKE $1 || '/%' + ORDER BY e.evidence_id LIMIT 20` + : `SELECT e.evidence_id, e.grade, e.source_page_id, e.verbatim_excerpt, + e.confidence_band, e.related_hypotheses + FROM public.evidence e + WHERE LOWER(e.verbatim_excerpt) LIKE $1 + ORDER BY e.evidence_id LIMIT 20`, + [docIdFilter ?? filter], + ), + query( + `SELECT hypothesis_id, question, position, argument_for, argument_against, + prior, posterior, confidence_band, status, reviewed_by + FROM public.hypotheses + WHERE LOWER(question) LIKE $1 OR LOWER(position) LIKE $1 + ORDER BY hypothesis_id LIMIT 12`, + [filter], + ), + query( + `SELECT contradiction_id, topic, chunks, resolution_status, notes + FROM public.contradictions + WHERE LOWER(topic) LIKE $1 + ORDER BY contradiction_id LIMIT 8`, + [filter], + ), + query( + `SELECT w.witness_id, e.canonical_name, w.credibility, w.verdict, + w.access_to_event, w.bias_notes + FROM public.witnesses w + LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk + WHERE LOWER(COALESCE(w.verdict,'')) LIKE $1 + OR LOWER(COALESCE(e.canonical_name,'')) LIKE $1 + ORDER BY w.witness_id LIMIT 8`, + [filter], + ), + query( + `SELECT gap_id, description, scope, suggested_next_move, status + FROM public.gaps + WHERE LOWER(description) LIKE $1 + OR LOWER(COALESCE(scope->>'title','')) LIKE $1 + OR LOWER(COALESCE(scope->>'why_surprising','')) LIKE $1 + ORDER BY gap_id LIMIT 8`, + [filter], + ), + ]); + + await audit({ + event: "case_writer_grounded", + job_id: task.job_id, + detective: "case-writer@detective", + topic, slug, doc_id: docIdFilter, + n_evidence: evidence.length, + n_hypotheses: hypotheses.length, + n_contradictions: contradictions.length, + n_witnesses: witnesses.length, + n_gaps: gaps.length, + }); + + const total = evidence.length + hypotheses.length + contradictions.length + + witnesses.length + gaps.length; + if (total < 2 || (evidence.length === 0 && hypotheses.length === 0)) { + return { skipped: true, reason: "insufficient_artefacts" }; + } + + const systemPrompt = await readFile(PROMPT_PATH, "utf-8"); + const prompt = buildPrompt(task, evidence, hypotheses, contradictions, witnesses, gaps); + + // Case-writer wants more output budget than the other detectives. + const llm = await callClaude({ + prompt, systemPrompt, + model: env.CLAUDE_MODEL, + allowedTools: [], + timeoutMs: Math.max(env.JOB_TIMEOUT_SECONDS, 240) * 1000, + budgetCapUsd: task.budget_cap_usd ?? Math.max(env.BUDGET_CAP_USD_PER_JOB, 0.50), + }); + await audit({ + event: "detective_completed", + job_id: task.job_id, + detective: "case-writer@detective", + cost_usd: llm.costUsd, + tokens_in: llm.tokensIn, + tokens_out: llm.tokensOut, + duration_ms: llm.durationMs, + }); + console.error(`[case-writer] response (${llm.text.length} chars): ${llm.text.slice(0, 600)}`); + + const body_md = extractBody(llm.text); + if (body_md === null) return { skipped: true, reason: "INSUFFICIENT_ARTEFACTS" }; + + return await writeCaseReport({ + topic, slug, body_md, + meta: { + n_evidence: evidence.length, + n_hypotheses: hypotheses.length, + n_contradictions: contradictions.length, + n_witnesses: witnesses.length, + n_outliers: gaps.filter((g) => { + const s = g.scope as Record | null; + return s?.kind === "outlier"; + }).length, + n_calibrations: 0, // Calibrations live inside hypothesis case files, not a table yet. + }, + }, { job_id: task.job_id, detective: "case-writer@detective" }); +} + diff --git a/investigator-runtime/src/detectives/dupin.ts b/investigator-runtime/src/detectives/dupin.ts index 7f76f03..9f7092e 100644 --- a/investigator-runtime/src/detectives/dupin.ts +++ b/investigator-runtime/src/detectives/dupin.ts @@ -73,7 +73,7 @@ function buildPrompt(task: DupinTask, hits: SearchHit[], lang: "pt" | "en"): str function extractJsonArray(text: string): unknown[] | null { const t = text.trim(); - if (t === "NO_CONTRADICTIONS") return null; + if (/^`?NO_CONTRADICTIONS`?\b/i.test(t)) return null; const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); const first = stripped.indexOf("["); const last = stripped.lastIndexOf("]"); diff --git a/investigator-runtime/src/detectives/holmes.ts b/investigator-runtime/src/detectives/holmes.ts index ace5f4b..505abb3 100644 --- a/investigator-runtime/src/detectives/holmes.ts +++ b/investigator-runtime/src/detectives/holmes.ts @@ -75,7 +75,7 @@ function buildPrompt(task: HolmesTask, hits: SearchHit[], lang: "pt" | "en"): st function extractJsonArray(text: string): unknown[] | null { const t = text.trim(); - if (t === "NO_HYPOTHESES") return null; + if (/^`?NO_HYPOTHESES`?\b/i.test(t)) return null; const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); const first = stripped.indexOf("["); const last = stripped.lastIndexOf("]"); diff --git a/investigator-runtime/src/detectives/poirot.ts b/investigator-runtime/src/detectives/poirot.ts new file mode 100644 index 0000000..d4ddd84 --- /dev/null +++ b/investigator-runtime/src/detectives/poirot.ts @@ -0,0 +1,249 @@ +/** + * poirot.ts — witness-analysis detective. + * + * Reads chunks where a named person appears, produces a structured + * credibility analysis (access / bias / corroboration / verdict). + * + * Workflow: + * 1. Resolve person by entity_id (e.g. "j-edgar-hoover") to entity_pk. + * 2. Pull up to 12 chunks via entity_mentions JOIN chunks, ordered by + * anomaly flag DESC, page ASC. These are Poirot's evidence. + * 3. Feed Claude with canonical_name + aliases + the chunks. Parse + * strict JSON object. + * 4. writeWitnessAnalysis() validates + INSERTs + writes W-NNNN.md. + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { audit } from "../lib/audit"; +import { callClaude } from "../lib/claude"; +import { env } from "../lib/env"; +import { query, queryOne } from "../lib/pg"; +import { + writeWitnessAnalysis, + type WriteWitnessAnalysisArgs, + type CorroborationRef, +} from "../tools/write_witness_analysis"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "poirot.md"); + +export interface PoirotTask { + job_id: string; + /** entity_id slug like "j-edgar-hoover" (preferred), OR person_entity_pk. */ + person_id?: string; + person_entity_pk?: number; + lang?: "pt" | "en"; + context_chunks?: number; + budget_cap_usd?: number; +} + +interface PersonChunkRow { + chunk_pk: number; + doc_id: string; + chunk_id: string; + page: number; + type: string; + classification: string | null; + ufo_anomaly: boolean | null; + content_en: string | null; + content_pt: string | null; + surface_form: string | null; +} + +function renderChunkBlock(rows: PersonChunkRow[], lang: "pt" | "en"): string { + const blocks = rows.map((r, i) => { + const text = (lang === "en" ? r.content_en : r.content_pt) || r.content_en || r.content_pt || ""; + return [ + `--- chunk ${i + 1} ---`, + `doc_id: ${r.doc_id}`, + `chunk_id: ${r.chunk_id}`, + `page: ${r.page}`, + r.surface_form ? `surface_form_in_chunk: ${r.surface_form}` : null, + `type: ${r.type}`, + r.classification ? `classification: ${r.classification}` : null, + "", + text.slice(0, 1100), + ].filter(Boolean).join("\n"); + }); + return blocks.join("\n\n"); +} + +function buildPrompt( + canonical_name: string, + aliases: string[], + rows: PersonChunkRow[], + lang: "pt" | "en", +): string { + const aliasLine = aliases.length > 0 ? `Aliases: ${aliases.slice(0, 8).join(", ")}` : null; + return [ + `# Witness under analysis`, + "", + `**Canonical name.** ${canonical_name}`, + aliasLine, + "", + `## Corpus shortlist (${rows.length} chunks where this person appears)`, + "", + renderChunkBlock(rows, lang), + "", + "## Your task", + "", + "Produce the structured witness analysis as specified by the system", + "prompt. Cite chunk_ids from the shortlist above in", + "`corroboration_refs`. If the shortlist is too thin to ground an", + "honest assessment, emit `INSUFFICIENT_TESTIMONY`.", + ].filter(Boolean).join("\n"); +} + +function extractJsonObject(text: string): Record | null { + const t = text.trim(); + // The skip sentinel can appear bare, in backticks, or as the leading token + // followed by Poirot's explanation prose. All count as "skipped". + if (/^`?INSUFFICIENT_TESTIMONY`?\b/i.test(t)) return null; + const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); + const first = stripped.indexOf("{"); + const last = stripped.lastIndexOf("}"); + if (first === -1 || last === -1) { + throw new Error(`poirot returned no JSON object: ${t.slice(0, 200)}`); + } + const parsed = JSON.parse(stripped.slice(first, last + 1)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("poirot JSON is not an object"); + } + return parsed as Record; +} + +function coerceCorroboration(raw: unknown): CorroborationRef[] { + if (!Array.isArray(raw)) return []; + const out: CorroborationRef[] = []; + for (const r of raw) { + if (!r || typeof r !== "object") continue; + const o = r as Record; + const chunk_id = typeof o.chunk_id === "string" ? o.chunk_id.trim() : ""; + if (!chunk_id) continue; + out.push({ + chunk_id, + doc_id: typeof o.doc_id === "string" ? o.doc_id.trim() : undefined, + supports: o.supports !== false, + }); + } + return out; +} + +export async function runPoirot(task: PoirotTask): Promise< + | { witness_id: string; case_file: string; credibility: string; person_entity_pk: number } + | { skipped: true; reason: string } +> { + const lang: "pt" | "en" = task.lang ?? "pt"; + const k = task.context_chunks ?? 12; + + let entity_pk: number | null = task.person_entity_pk ?? null; + let canonical_name = ""; + let aliases: string[] = []; + + if (entity_pk === null && task.person_id) { + const row = await queryOne<{ entity_pk: number; canonical_name: string; aliases: string[] | null; entity_class: string }>( + `SELECT entity_pk, canonical_name, aliases, entity_class + FROM public.entities WHERE entity_id = $1`, + [task.person_id], + ); + if (!row) return { skipped: true, reason: "person_not_found" }; + if (row.entity_class !== "person") return { skipped: true, reason: "entity_is_not_person" }; + entity_pk = row.entity_pk; + canonical_name = row.canonical_name; + aliases = row.aliases ?? []; + } else if (entity_pk !== null) { + const row = await queryOne<{ canonical_name: string; aliases: string[] | null; entity_class: string }>( + `SELECT canonical_name, aliases, entity_class FROM public.entities WHERE entity_pk = $1`, + [entity_pk], + ); + if (!row) return { skipped: true, reason: "person_not_found" }; + if (row.entity_class !== "person") return { skipped: true, reason: "entity_is_not_person" }; + canonical_name = row.canonical_name; + aliases = row.aliases ?? []; + } else { + return { skipped: true, reason: "no_person_specified" }; + } + + // Pull the most relevant chunks where this person appears. + const rows = await query( + `SELECT c.chunk_pk, c.doc_id, c.chunk_id, c.page, c.type, c.classification, + c.ufo_anomaly, + c.content_en, c.content_pt, em.surface_form + FROM public.entity_mentions em + JOIN public.chunks c ON c.chunk_pk = em.chunk_pk + WHERE em.entity_pk = $1 + AND LENGTH(COALESCE(c.content_en, c.content_pt, '')) > 80 + ORDER BY c.ufo_anomaly DESC NULLS LAST, c.page ASC, c.order_in_page ASC + LIMIT $2`, + [entity_pk, k], + ); + + await audit({ + event: "poirot_grounded", + job_id: task.job_id, + detective: "poirot@detective", + person_entity_pk: entity_pk, + canonical_name, + n_chunks: rows.length, + }); + + if (rows.length === 0) { + return { skipped: true, reason: "no_chunks_for_person" }; + } + + const systemPrompt = await readFile(PROMPT_PATH, "utf-8"); + const prompt = buildPrompt(canonical_name, aliases, rows, lang); + const llm = await callClaude({ + prompt, + systemPrompt, + model: env.CLAUDE_MODEL, + allowedTools: [], + timeoutMs: env.JOB_TIMEOUT_SECONDS * 1000, + budgetCapUsd: task.budget_cap_usd ?? env.BUDGET_CAP_USD_PER_JOB, + }); + await audit({ + event: "detective_completed", + job_id: task.job_id, + detective: "poirot@detective", + cost_usd: llm.costUsd, + tokens_in: llm.tokensIn, + tokens_out: llm.tokensOut, + duration_ms: llm.durationMs, + }); + console.error(`[poirot] response (${llm.text.length} chars): ${llm.text.slice(0, 600)}`); + + const obj = extractJsonObject(llm.text); + if (obj === null) return { skipped: true, reason: "INSUFFICIENT_TESTIMONY" }; + + const credibility = + obj.credibility === "high" || obj.credibility === "medium" || + obj.credibility === "low" || obj.credibility === "speculation" + ? obj.credibility as "high" | "medium" | "low" | "speculation" + : "speculation"; + + const args: WriteWitnessAnalysisArgs = { + person_entity_pk: entity_pk, + credibility, + access_to_event: typeof obj.access_to_event === "string" ? obj.access_to_event.trim() : "", + bias_notes: typeof obj.bias_notes === "string" ? obj.bias_notes.trim() : "", + corroboration_refs: coerceCorroboration(obj.corroboration_refs), + verdict: typeof obj.verdict === "string" ? obj.verdict.trim() : "", + }; + if (!args.access_to_event || !args.bias_notes || !args.verdict) { + return { skipped: true, reason: "incomplete_analysis" }; + } + + // Pass the shortlist's most-represented doc_id as a fallback for chunk_id + // resolution in case the model emits a bare "c0042" without doc_id. + const docCount = new Map(); + for (const r of rows) docCount.set(r.doc_id, (docCount.get(r.doc_id) ?? 0) + 1); + let fallbackDoc = ""; + let bestN = 0; + for (const [d, n] of docCount.entries()) if (n > bestN) { fallbackDoc = d; bestN = n; } + + return await writeWitnessAnalysis(args, { + job_id: task.job_id, + detective: "poirot@detective", + }, { fallback_doc_id: fallbackDoc }); +} diff --git a/investigator-runtime/src/detectives/schneier.ts b/investigator-runtime/src/detectives/schneier.ts index b523257..4dbd3f6 100644 --- a/investigator-runtime/src/detectives/schneier.ts +++ b/investigator-runtime/src/detectives/schneier.ts @@ -92,7 +92,7 @@ function buildPrompt(h: HypothesisRow, evidence: EvidenceRow[]): string { function extractJsonObject(text: string): Record | null { const t = text.trim(); - if (t === "INSUFFICIENT_HYPOTHESIS") return null; + if (/^`?INSUFFICIENT_HYPOTHESIS`?\b/i.test(t)) return null; const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); const first = stripped.indexOf("{"); const last = stripped.lastIndexOf("}"); diff --git a/investigator-runtime/src/detectives/taleb.ts b/investigator-runtime/src/detectives/taleb.ts new file mode 100644 index 0000000..d4b5141 --- /dev/null +++ b/investigator-runtime/src/detectives/taleb.ts @@ -0,0 +1,177 @@ +/** + * taleb.ts — outlier-hunter detective. + * + * Given a topic, locates the most surprising chunks given the dominant + * explanations. Writes each as a public.gaps row + case/gaps/G-NNNN.md. + * + * Workflow: + * 1. hybridSearch shortlist (k=18, same as Dupin — outliers need + * dispersion). + * 2. Claude reads + emits a JSON array of 0-3 outliers. + * 3. writeOutlierGap() validates chunk_pk FK + persists. + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { audit } from "../lib/audit"; +import { callClaude } from "../lib/claude"; +import { env } from "../lib/env"; +import { hybridSearch, type SearchHit } from "../lib/search"; +import { writeOutlierGap, type WriteOutlierGapArgs } from "../tools/write_outlier_gap"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "taleb.md"); + +export interface TalebTask { + job_id: string; + topic: string; + doc_id?: string; + lang?: "pt" | "en"; + context_chunks?: number; + budget_cap_usd?: number; +} + +function renderChunkBlock(hits: SearchHit[], lang: "pt" | "en"): string { + return hits.map((h, i) => { + const text = (lang === "en" ? h.content_en : h.content_pt) || h.content_en || h.content_pt || ""; + return [ + `--- chunk ${i + 1} ---`, + `doc_id: ${h.doc_id}`, + `chunk_id: ${h.chunk_id}`, + `page: ${h.page}`, + `type: ${h.type}`, + h.classification ? `classification: ${h.classification}` : null, + "", + text.slice(0, 1100), + ].filter(Boolean).join("\n"); + }).join("\n\n"); +} + +function buildPrompt(task: TalebTask, hits: SearchHit[], lang: "pt" | "en"): string { + return [ + `# Topic to scan for outliers`, + "", + task.topic, + "", + `## Corpus shortlist (${hits.length} chunks${task.doc_id ? `, scoped to ${task.doc_id}` : ""})`, + "", + renderChunkBlock(hits, lang), + "", + "## Your task", + "", + "Identify AT MOST 3 outliers per the system prompt rules. State the", + "dominant_model first, then the chunk that violates it. Emit the", + "JSON array exactly as specified — no prose, no code fence. If", + "nothing genuinely stands out, emit `NO_OUTLIERS`.", + ].join("\n"); +} + +function extractJsonArray(text: string): unknown[] | null { + const t = text.trim(); + if (/^`?NO_OUTLIERS`?\b/i.test(t)) return null; + const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); + const first = stripped.indexOf("["); + const last = stripped.lastIndexOf("]"); + if (first === -1 || last === -1) { + throw new Error(`taleb returned no JSON array: ${t.slice(0, 200)}`); + } + const parsed = JSON.parse(stripped.slice(first, last + 1)); + if (!Array.isArray(parsed)) throw new Error("taleb JSON is not an array"); + return parsed; +} + +export async function runTaleb(task: TalebTask): Promise< + | { outliers: Array<{ gap_id: string; case_file: string }> } + | { skipped: true; reason: string } +> { + const lang: "pt" | "en" = task.lang ?? "pt"; + const k = task.context_chunks ?? 18; + + let hits = await hybridSearch({ + query: task.topic, lang, + doc_id: task.doc_id ?? null, + top_k: k, recall_k: 80, + }); + let scope_widened = false; + if (task.doc_id && hits.length < 3) { + const widened = await hybridSearch({ + query: task.topic, lang, doc_id: null, top_k: k, recall_k: 80, + }); + if (widened.length > hits.length) { + hits = widened; + scope_widened = true; + } + } + + await audit({ + event: "taleb_grounded", + job_id: task.job_id, + detective: "taleb@detective", + topic: task.topic, + n_chunks: hits.length, + doc_id: task.doc_id ?? null, + scope_widened, + }); + + if (hits.length < 3) { + return { skipped: true, reason: "insufficient_corpus" }; + } + + const systemPrompt = await readFile(PROMPT_PATH, "utf-8"); + const prompt = buildPrompt(task, hits, lang); + const llm = await callClaude({ + prompt, systemPrompt, + model: env.CLAUDE_MODEL, + allowedTools: [], + timeoutMs: env.JOB_TIMEOUT_SECONDS * 1000, + budgetCapUsd: task.budget_cap_usd ?? env.BUDGET_CAP_USD_PER_JOB, + }); + await audit({ + event: "detective_completed", + job_id: task.job_id, + detective: "taleb@detective", + cost_usd: llm.costUsd, + tokens_in: llm.tokensIn, + tokens_out: llm.tokensOut, + duration_ms: llm.durationMs, + }); + console.error(`[taleb] response (${llm.text.length} chars): ${llm.text.slice(0, 600)}`); + + const arr = extractJsonArray(llm.text); + if (arr === null) return { skipped: true, reason: "NO_OUTLIERS" }; + + const out: Array<{ gap_id: string; case_file: string }> = []; + for (const raw of arr.slice(0, 3)) { + if (!raw || typeof raw !== "object") continue; + const o = raw as Record; + const args: WriteOutlierGapArgs = { + title: typeof o.title === "string" ? o.title.trim() : "", + doc_id: typeof o.doc_id === "string" ? o.doc_id.trim() : "", + chunk_id: typeof o.chunk_id === "string" ? o.chunk_id.trim() : "", + dominant_model: typeof o.dominant_model === "string" ? o.dominant_model.trim() : "", + why_surprising: typeof o.why_surprising === "string" ? o.why_surprising.trim() : "", + what_it_implies: typeof o.what_it_implies === "string" ? o.what_it_implies.trim() : "", + suggested_next_move: typeof o.suggested_next_move === "string" ? o.suggested_next_move.trim() : "", + }; + if (!args.title || !args.doc_id || !args.chunk_id || !args.dominant_model + || !args.why_surprising || !args.what_it_implies || !args.suggested_next_move) { + await audit({ + event: "write_outlier_gap_failed", + job_id: task.job_id, detective: "taleb@detective", + reason: "incomplete_outlier", title: args.title.slice(0, 120), + }); + continue; + } + try { + const r = await writeOutlierGap(args, { job_id: task.job_id, detective: "taleb@detective" }); + out.push(r); + } catch (e) { + await audit({ + event: "write_outlier_gap_failed", + job_id: task.job_id, detective: "taleb@detective", + error: (e as Error).message, title: args.title.slice(0, 120), + }); + } + } + return { outliers: out }; +} diff --git a/investigator-runtime/src/detectives/tetlock.ts b/investigator-runtime/src/detectives/tetlock.ts new file mode 100644 index 0000000..500ded5 --- /dev/null +++ b/investigator-runtime/src/detectives/tetlock.ts @@ -0,0 +1,260 @@ +/** + * tetlock.ts — posterior calibration detective. + * + * Reads an existing hypothesis + the corpus chunks added since its last + * calibration (proxied by updated_at) + the evidence chain. Asks Claude + * to recompute posterior + recommend action. + * + * The "new evidence" pool is a hybridSearch on the hypothesis's question + * AFTER the hypothesis's `updated_at` cutoff — chunks ingested or marked + * searchable since are the candidate signal. + * + * v0 simplification: chunks don't carry an updated_at column right now. + * So we shortlist via hybridSearch using the question text and let + * Tetlock decide whether the chunks are NEW relative to the cited + * evidence_refs (it sees both lists and reasons accordingly). When the + * chunks layer grows an ingest_at column, swap this to a strict cutoff. + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { audit } from "../lib/audit"; +import { callClaude } from "../lib/claude"; +import { env } from "../lib/env"; +import { query, queryOne } from "../lib/pg"; +import { hybridSearch, type SearchHit } from "../lib/search"; +import { writeCalibration, type WriteCalibrationArgs } from "../tools/write_calibration"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "tetlock.md"); + +export interface TetlockTask { + job_id: string; + hypothesis_id: string; + lang?: "pt" | "en"; + budget_cap_usd?: number; +} + +interface HypothesisRow { + hypothesis_id: string; + question: string; + position: string; + argument_for: string | null; + argument_against: string | null; + prior: number | string | null; + posterior: number | string | null; + confidence_band: string | null; + evidence_refs: unknown; + updated_at: string; +} + +interface EvidenceRow { + evidence_id: string; + grade: string; + source_page_id: string; + verbatim_excerpt: string; +} + +function asNumber(n: number | string | null): number | null { + if (n === null || n === undefined) return null; + const v = typeof n === "string" ? parseFloat(n) : n; + return Number.isFinite(v) ? v : null; +} + +function bandFromPosterior(p: number): "high" | "medium" | "low" | "speculation" { + if (p >= 0.90) return "high"; + if (p >= 0.60) return "medium"; + if (p >= 0.30) return "low"; + return "speculation"; +} + +function renderShortlist(hits: SearchHit[], lang: "pt" | "en", citedChunkPks: Set): string { + if (hits.length === 0) return "_(no new shortlist)_"; + return hits.map((h, i) => { + const text = (lang === "en" ? h.content_en : h.content_pt) || h.content_en || h.content_pt || ""; + const seen = citedChunkPks.has(h.chunk_pk) ? " (already cited)" : " (NEW)"; + return [ + `--- chunk ${i + 1}${seen} ---`, + `doc_id: ${h.doc_id}`, + `chunk_id: ${h.chunk_id}`, + `page: ${h.page}`, + "", + text.slice(0, 900), + ].join("\n"); + }).join("\n\n"); +} + +function buildPrompt( + h: HypothesisRow, + evidence: EvidenceRow[], + hits: SearchHit[], + citedChunkPks: Set, + lang: "pt" | "en", +): string { + const cur_post = asNumber(h.posterior); + const evBlock = evidence.length === 0 + ? "_(no cataloged evidence at the previous calibration)_" + : evidence.map((e) => + `--- ${e.evidence_id} (Grade ${e.grade}) — ${e.source_page_id}\n> ${e.verbatim_excerpt.slice(0, 500)}` + ).join("\n\n"); + + return [ + `# Hypothesis under recalibration`, + "", + `**ID.** ${h.hypothesis_id}`, + `**Question.** ${h.question}`, + `**Position.** ${h.position}`, + `**Last posterior.** ${cur_post ?? "—"} · **Band.** ${h.confidence_band ?? "—"}`, + `**Last updated.** ${h.updated_at}`, + "", + "## Argument for (at last calibration)", + h.argument_for || "_(none recorded)_", + "", + "## Argument against (at last calibration)", + h.argument_against || "_(none recorded)_", + "", + "## Evidence cited at last calibration", + evBlock, + "", + `## Fresh corpus shortlist (${hits.length} chunks)`, + "", + "Chunks marked **NEW** were not in the evidence chain at the last", + "calibration. Use them to update; chunks marked **already cited** are", + "shown for continuity.", + "", + renderShortlist(hits, lang, citedChunkPks), + "", + "## Your task", + "", + "Recompute the posterior honestly. Emit the JSON object exactly as", + "specified by the system prompt. If there is NO new chunk to move the", + "posterior on, emit `NO_NEW_EVIDENCE`.", + ].join("\n"); +} + +function extractJsonObject(text: string): Record | null { + const t = text.trim(); + if (/^`?NO_NEW_EVIDENCE`?\b/i.test(t)) return null; + const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); + const first = stripped.indexOf("{"); + const last = stripped.lastIndexOf("}"); + if (first === -1 || last === -1) { + throw new Error(`tetlock returned no JSON object: ${t.slice(0, 200)}`); + } + const parsed = JSON.parse(stripped.slice(first, last + 1)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("tetlock JSON is not an object"); + } + return parsed as Record; +} + +export async function runTetlock(task: TetlockTask): Promise< + | { hypothesis_id: string; case_file: string; new_posterior: number; recommended_action: string } + | { skipped: true; reason: string } +> { + const h = await queryOne( + `SELECT hypothesis_id, question, position, argument_for, argument_against, + prior, posterior, confidence_band, evidence_refs, updated_at + FROM public.hypotheses WHERE hypothesis_id = $1`, + [task.hypothesis_id], + ); + if (!h) return { skipped: true, reason: "hypothesis_not_found" }; + + const refIds: string[] = []; + if (Array.isArray(h.evidence_refs)) { + for (const r of h.evidence_refs as Array>) { + if (typeof r?.evidence_id === "string") refIds.push(r.evidence_id); + } + } + const evidence = refIds.length > 0 + ? await query( + `SELECT evidence_id, grade, source_page_id, verbatim_excerpt + FROM public.evidence WHERE evidence_id = ANY($1::text[]) ORDER BY evidence_id`, + [refIds], + ) + : []; + + // Pull citedChunkPks (FK on evidence) so we can tag "NEW" vs "already cited" + // in the shortlist. + const citedChunkPks = new Set(); + if (refIds.length > 0) { + const rows = await query<{ source_chunk_pk: number | null }>( + `SELECT source_chunk_pk FROM public.evidence WHERE evidence_id = ANY($1::text[])`, + [refIds], + ); + for (const r of rows) if (r.source_chunk_pk !== null) citedChunkPks.add(r.source_chunk_pk); + } + + // Fresh corpus shortlist on the question. + const lang: "pt" | "en" = task.lang ?? "pt"; + const hits = await hybridSearch({ + query: h.question, lang, + doc_id: null, top_k: 12, recall_k: 60, + }); + + await audit({ + event: "tetlock_grounded", + job_id: task.job_id, + detective: "tetlock@detective", + hypothesis_id: h.hypothesis_id, + n_evidence_prev: evidence.length, + n_shortlist: hits.length, + n_already_cited: citedChunkPks.size, + }); + + const systemPrompt = await readFile(PROMPT_PATH, "utf-8"); + const prompt = buildPrompt(h, evidence, hits, citedChunkPks, lang); + const llm = await callClaude({ + prompt, systemPrompt, + model: env.CLAUDE_MODEL, + allowedTools: [], + timeoutMs: env.JOB_TIMEOUT_SECONDS * 1000, + budgetCapUsd: task.budget_cap_usd ?? env.BUDGET_CAP_USD_PER_JOB, + }); + await audit({ + event: "detective_completed", + job_id: task.job_id, + detective: "tetlock@detective", + cost_usd: llm.costUsd, + tokens_in: llm.tokensIn, + tokens_out: llm.tokensOut, + duration_ms: llm.durationMs, + }); + console.error(`[tetlock] response (${llm.text.length} chars): ${llm.text.slice(0, 600)}`); + + const obj = extractJsonObject(llm.text); + if (obj === null) return { skipped: true, reason: "NO_NEW_EVIDENCE" }; + + const new_posterior = Number(obj.new_posterior); + if (!Number.isFinite(new_posterior) || new_posterior < 0 || new_posterior > 1) { + return { skipped: true, reason: "bad_new_posterior" }; + } + const old_posterior = asNumber(h.posterior); + const delta = old_posterior !== null ? new_posterior - old_posterior : 0; + + const actionRaw = typeof obj.recommended_action === "string" ? obj.recommended_action.toLowerCase() : ""; + const action: WriteCalibrationArgs["recommended_action"] = + actionRaw === "downgrade" || actionRaw === "upgrade" || + actionRaw === "supersede" || actionRaw === "keep" ? actionRaw : "keep"; + + const args: WriteCalibrationArgs = { + hypothesis_id: h.hypothesis_id, + new_posterior, + new_confidence_band: bandFromPosterior(new_posterior), + delta, + rationale: typeof obj.rationale === "string" ? obj.rationale.trim() : "", + recommended_action: action, + supersede_reason: typeof obj.supersede_reason === "string" ? obj.supersede_reason.trim() : undefined, + old_posterior, + old_confidence_band: h.confidence_band, + }; + if (!args.rationale) return { skipped: true, reason: "no_rationale" }; + if (action === "supersede" && !args.supersede_reason) { + return { skipped: true, reason: "supersede_reason_missing" }; + } + + return await writeCalibration(args, { + job_id: task.job_id, + detective: "tetlock@detective", + }); +} diff --git a/investigator-runtime/src/orchestrator.ts b/investigator-runtime/src/orchestrator.ts index 6a29074..9ae7994 100644 --- a/investigator-runtime/src/orchestrator.ts +++ b/investigator-runtime/src/orchestrator.ts @@ -11,6 +11,10 @@ import { runLocard, type LocardTask } from "./detectives/locard"; import { runHolmes, type HolmesTask } from "./detectives/holmes"; import { runDupin, type DupinTask } from "./detectives/dupin"; import { runSchneier, type SchneierTask } from "./detectives/schneier"; +import { runPoirot, type PoirotTask } from "./detectives/poirot"; +import { runTaleb, type TalebTask } from "./detectives/taleb"; +import { runTetlock, type TetlockTask } from "./detectives/tetlock"; +import { runCaseWriter, type CaseWriterTask } from "./detectives/case_writer"; export interface InvestigationJob { job_id: string; @@ -69,6 +73,82 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise } break; } + case "case_report": { + // Payload: { topic, doc_id?, slug?, lang? } + const topic = String(job.payload.topic ?? "").trim(); + if (!topic) throw new Error("case_report requires payload.topic"); + const task: CaseWriterTask = { + job_id: job.job_id, topic, + doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined, + slug: typeof job.payload.slug === "string" ? job.payload.slug : undefined, + lang: job.payload.lang === "en" ? "en" : "pt", + }; + const r = await runCaseWriter(task); + if ("skipped" in r) { + outputs.push({ kind: "case_report", skipped: true, reason: r.reason }); + } else { + outputs.push({ kind: "case_report", ...r }); + } + break; + } + case "calibrate_hypothesis": { + // Payload: { hypothesis_id } + const hyp = String(job.payload.hypothesis_id ?? "").trim(); + if (!hyp) throw new Error("calibrate_hypothesis requires payload.hypothesis_id"); + const task: TetlockTask = { + job_id: job.job_id, + hypothesis_id: hyp, + lang: job.payload.lang === "en" ? "en" : "pt", + }; + const r = await runTetlock(task); + if ("skipped" in r) { + outputs.push({ kind: "calibrate_hypothesis", skipped: true, reason: r.reason }); + } else { + outputs.push({ kind: "calibration", ...r }); + } + break; + } + case "outlier_scan": { + // Payload: { topic, doc_id?, lang?, context_chunks? } + const topic = String(job.payload.topic ?? "").trim(); + if (!topic) throw new Error("outlier_scan requires payload.topic"); + const task: TalebTask = { + job_id: job.job_id, topic, + doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined, + lang: job.payload.lang === "en" ? "en" : "pt", + context_chunks: typeof job.payload.context_chunks === "number" ? job.payload.context_chunks : undefined, + }; + const r = await runTaleb(task); + if ("skipped" in r) { + outputs.push({ kind: "outlier_scan", skipped: true, reason: r.reason }); + } else { + for (const o of r.outliers) outputs.push({ kind: "outlier", ...o }); + } + break; + } + case "witness_analysis": { + // Payload: { person_id } OR { person_entity_pk } + const person_id = typeof job.payload.person_id === "string" ? job.payload.person_id.trim() : undefined; + const person_entity_pk = typeof job.payload.person_entity_pk === "number" + ? job.payload.person_entity_pk : undefined; + if (!person_id && !person_entity_pk) { + throw new Error("witness_analysis requires payload.person_id or person_entity_pk"); + } + const task: PoirotTask = { + job_id: job.job_id, + person_id, + person_entity_pk, + lang: job.payload.lang === "en" ? "en" : "pt", + context_chunks: typeof job.payload.context_chunks === "number" ? job.payload.context_chunks : undefined, + }; + const r = await runPoirot(task); + if ("skipped" in r) { + outputs.push({ kind: "witness_analysis", skipped: true, reason: r.reason }); + } else { + outputs.push({ kind: "witness_analysis", ...r }); + } + break; + } case "red_team_review": { // Payload: { hypothesis_id } const hyp = String(job.payload.hypothesis_id ?? "").trim(); diff --git a/investigator-runtime/src/tools/write_calibration.ts b/investigator-runtime/src/tools/write_calibration.ts new file mode 100644 index 0000000..a73c570 --- /dev/null +++ b/investigator-runtime/src/tools/write_calibration.ts @@ -0,0 +1,162 @@ +/** + * write_calibration.ts — Tetlock's primary writer. + * + * UPDATEs public.hypotheses (posterior + confidence_band + reviewed_by + + * updated_at) and APPENDS (or replaces) a "## Calibration history" section + * to the H-NNNN.md case file. Each calibration includes a timestamp + + * old/new posterior + recommended_action + rationale. + */ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { audit } from "../lib/audit"; +import { env } from "../lib/env"; +import { query, queryOne } from "../lib/pg"; + +export interface WriteCalibrationArgs { + hypothesis_id: string; + new_posterior: number; + new_confidence_band: "high" | "medium" | "low" | "speculation"; + delta: number; + rationale: string; + recommended_action: "keep" | "downgrade" | "upgrade" | "supersede"; + supersede_reason?: string; + /** previous posterior captured at call time — used in the case-file row. */ + old_posterior: number | null; + old_confidence_band: string | null; +} + +export interface WriteCalibrationContext { + job_id: string; + detective: string; +} + +const SECTION_MARKER = "## Calibration history"; + +function bandFromPosterior(p: number): "high" | "medium" | "low" | "speculation" { + if (p >= 0.90) return "high"; + if (p >= 0.60) return "medium"; + if (p >= 0.30) return "low"; + return "speculation"; +} + +function buildSection(args: WriteCalibrationArgs, ctx: WriteCalibrationContext): string { + const ts = new Date().toISOString(); + const rows = [ + `### ${ts} — ${args.recommended_action}`, + "", + `_Calibrated by ${ctx.detective} — job \`${ctx.job_id}\`._`, + "", + `| field | old | new |`, + `|---|---|---|`, + `| posterior | ${args.old_posterior ?? "—"} | **${args.new_posterior}** |`, + `| band | ${args.old_confidence_band ?? "—"} | **${args.new_confidence_band}** |`, + `| delta | — | ${args.delta >= 0 ? "+" : ""}${args.delta.toFixed(3)} |`, + "", + `**Rationale.** ${args.rationale}`, + ]; + if (args.recommended_action === "supersede" && args.supersede_reason) { + rows.push("", `**Supersede reason.** ${args.supersede_reason}`); + } + rows.push(""); + return rows.join("\n"); +} + +function appendCalibration(existing: string, section: string): string { + // Calibration history is APPEND-only (Tetlock can be invoked many times + // and each datapoint matters). Find the section, append; create it if + // missing. + const idx = existing.indexOf(`\n${SECTION_MARKER}`); + if (idx === -1) { + return existing.trimEnd() + "\n\n" + SECTION_MARKER + "\n\n" + section; + } + return existing.trimEnd() + "\n" + section; +} + +export async function writeCalibration( + body: WriteCalibrationArgs, + ctx: WriteCalibrationContext, +): Promise<{ hypothesis_id: string; case_file: string; new_posterior: number; recommended_action: string }> { + if (!body.hypothesis_id?.match(/^H-\d{4}$/)) { + throw new Error(`bad hypothesis_id: ${body.hypothesis_id}`); + } + if (!Number.isFinite(body.new_posterior) || body.new_posterior < 0 || body.new_posterior > 1) { + throw new Error(`new_posterior out of range: ${body.new_posterior}`); + } + const expectedBand = bandFromPosterior(body.new_posterior); + // Force the band to match the posterior — Tetlock can mis-label. + body.new_confidence_band = expectedBand; + if (!body.rationale?.trim()) throw new Error("rationale required"); + if (body.rationale.length > 600) throw new Error(`rationale too long`); + + const action = body.recommended_action; + if (!["keep", "downgrade", "upgrade", "supersede"].includes(action)) { + throw new Error(`bad recommended_action: ${action}`); + } + if (action === "supersede" && !body.supersede_reason?.trim()) { + throw new Error("supersede_reason required when action == supersede"); + } + + // Verify hypothesis exists. + const h = await queryOne<{ hypothesis_id: string; status: string }>( + `SELECT hypothesis_id, status FROM public.hypotheses WHERE hypothesis_id = $1`, + [body.hypothesis_id], + ); + if (!h) throw new Error(`hypothesis not found: ${body.hypothesis_id}`); + + // UPDATE DB: posterior + band (always), status='superseded' when action=='supersede'. + if (action === "supersede") { + await query( + `UPDATE public.hypotheses + SET posterior = $1, confidence_band = $2, status = 'superseded', + reviewed_by = $3, updated_at = NOW() + WHERE hypothesis_id = $4`, + [body.new_posterior, body.new_confidence_band, ctx.detective, body.hypothesis_id], + ); + } else if (action === "keep" && Math.abs(body.delta) < 0.005) { + // Pure keep with no movement — only touch updated_at + reviewed_by. + await query( + `UPDATE public.hypotheses + SET reviewed_by = $1, updated_at = NOW() + WHERE hypothesis_id = $2`, + [ctx.detective, body.hypothesis_id], + ); + } else { + await query( + `UPDATE public.hypotheses + SET posterior = $1, confidence_band = $2, + reviewed_by = $3, updated_at = NOW() + WHERE hypothesis_id = $4`, + [body.new_posterior, body.new_confidence_band, ctx.detective, body.hypothesis_id], + ); + } + + // Append calibration row to the case file. + const file = path.join(env.CASE_ROOT, "hypotheses", `${body.hypothesis_id}.md`); + let existing: string; + try { + existing = await readFile(file, "utf-8"); + } catch (e) { + throw new Error(`hypothesis case file missing: ${file} (${(e as Error).message})`); + } + const section = buildSection(body, ctx); + const next = appendCalibration(existing, section); + await writeFile(file, next, "utf-8"); + + await audit({ + event: "write_calibration", + job_id: ctx.job_id, + detective: ctx.detective, + hypothesis_id: body.hypothesis_id, + new_posterior: body.new_posterior, + new_confidence_band: body.new_confidence_band, + delta: body.delta, + recommended_action: action, + file, + }); + + return { + hypothesis_id: body.hypothesis_id, case_file: file, + new_posterior: body.new_posterior, + recommended_action: action, + }; +} diff --git a/investigator-runtime/src/tools/write_case_report.ts b/investigator-runtime/src/tools/write_case_report.ts new file mode 100644 index 0000000..9694df4 --- /dev/null +++ b/investigator-runtime/src/tools/write_case_report.ts @@ -0,0 +1,80 @@ +/** + * write_case_report.ts — Case-Writer's primary writer. + * + * Saves the Watson-style narrative to case/reports/.md with bureau + * frontmatter. Lightweight — no DB row by default; the harvest is the + * markdown itself. (Future migration could add a public.case_reports table + * if discoverability via SQL becomes needed.) + */ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { audit } from "../lib/audit"; +import { env } from "../lib/env"; + +export interface WriteCaseReportArgs { + topic: string; + slug: string; + body_md: string; + meta: { + n_evidence: number; + n_hypotheses: number; + n_contradictions: number; + n_witnesses: number; + n_outliers: number; + n_calibrations: number; + }; +} + +export interface WriteCaseReportContext { + job_id: string; + detective: string; +} + +function renderFrontmatter(args: WriteCaseReportArgs, ctx: WriteCaseReportContext): string { + return [ + "---", + `schema_version: "0.1.0"`, + `type: case_report`, + `topic: ${JSON.stringify(args.topic)}`, + `slug: ${args.slug}`, + `created_by: ${ctx.detective}`, + `job_id: ${ctx.job_id}`, + `created_at: ${new Date().toISOString()}`, + `n_evidence: ${args.meta.n_evidence}`, + `n_hypotheses: ${args.meta.n_hypotheses}`, + `n_contradictions: ${args.meta.n_contradictions}`, + `n_witnesses: ${args.meta.n_witnesses}`, + `n_outliers: ${args.meta.n_outliers}`, + `n_calibrations: ${args.meta.n_calibrations}`, + "---", + "", + ].join("\n"); +} + +export async function writeCaseReport( + args: WriteCaseReportArgs, + ctx: WriteCaseReportContext, +): Promise<{ case_file: string; slug: string }> { + if (!args.topic?.trim()) throw new Error("topic required"); + if (!args.slug?.match(/^[a-z0-9][a-z0-9-]*$/)) throw new Error(`bad slug: ${args.slug}`); + if (!args.body_md?.trim()) throw new Error("body_md required"); + if (args.body_md.length < 200) throw new Error("body_md too short (< 200 chars)"); + + const dir = path.join(env.CASE_ROOT, "reports"); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, `${args.slug}.md`); + await writeFile(file, renderFrontmatter(args, ctx) + args.body_md.trimEnd() + "\n", "utf-8"); + + await audit({ + event: "write_case_report", + job_id: ctx.job_id, + detective: ctx.detective, + slug: args.slug, + topic: args.topic.slice(0, 200), + body_chars: args.body_md.length, + file, + ...args.meta, + }); + + return { case_file: file, slug: args.slug }; +} diff --git a/investigator-runtime/src/tools/write_outlier_gap.ts b/investigator-runtime/src/tools/write_outlier_gap.ts new file mode 100644 index 0000000..b697d95 --- /dev/null +++ b/investigator-runtime/src/tools/write_outlier_gap.ts @@ -0,0 +1,147 @@ +/** + * write_outlier_gap.ts — Taleb's primary writer. + * + * INSERTs a row into public.gaps and writes case/gaps/G-NNNN.md. + * Reuses the gap_id_seq slot (G = gap, per CLAUDE.md). The `scope` JSONB + * column carries {kind: "outlier", chunk_ref, dominant_model, why_surprising, + * what_it_implies} so the case-writer can render the outlier as a residual + * uncertainty in the final case-report.md. + */ +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 WriteOutlierGapArgs { + title: string; + doc_id: string; + chunk_id: string; + dominant_model: string; + why_surprising: string; + what_it_implies: string; + suggested_next_move: string; +} + +export interface WriteOutlierGapContext { + job_id: string; + detective: string; +} + +function normalizeChunkId(raw: string): string { + const m = raw.match(/c\d{4,}$/); + return m ? m[0] : raw; +} + +function renderMd( + id: string, + body: WriteOutlierGapArgs, + chunk_pk: number, + page: number, + ctx: WriteOutlierGapContext, +): string { + const pageStr = String(page).padStart(3, "0"); + const fm = [ + "---", + `schema_version: "0.1.0"`, + `type: gap`, + `gap_id: ${id}`, + `kind: outlier`, + `title: ${JSON.stringify(body.title)}`, + `chunk_pk: ${chunk_pk}`, + `chunk_ref: ${JSON.stringify(`${body.doc_id}/p${pageStr}#${body.chunk_id}`)}`, + `created_by: ${ctx.detective}`, + `job_id: ${ctx.job_id}`, + `created_at: ${new Date().toISOString()}`, + "---", + ].join("\n"); + + return [ + fm, + "", + `# Outlier ${id} — ${body.title}`, + "", + `**Source.** [[${body.doc_id}/p${pageStr}#${body.chunk_id}]]`, + "", + "## Dominant model", + "", + body.dominant_model, + "", + "## Why surprising", + "", + body.why_surprising, + "", + "## What it implies", + "", + body.what_it_implies, + "", + "## Suggested next move", + "", + body.suggested_next_move, + "", + ].join("\n"); +} + +export async function writeOutlierGap( + body: WriteOutlierGapArgs, + ctx: WriteOutlierGapContext, +): Promise<{ gap_id: string; case_file: string }> { + if (!body.title?.trim()) throw new Error("title required"); + if (!body.doc_id?.trim() || !body.chunk_id?.trim()) throw new Error("doc_id + chunk_id required"); + if (!body.dominant_model?.trim()) throw new Error("dominant_model required"); + if (!body.why_surprising?.trim()) throw new Error("why_surprising required"); + if (!body.what_it_implies?.trim()) throw new Error("what_it_implies required"); + if (!body.suggested_next_move?.trim()) throw new Error("suggested_next_move required"); + if (body.why_surprising.length > 600) throw new Error(`why_surprising too long`); + + const cid = normalizeChunkId(body.chunk_id); + const chunk = await queryOne<{ chunk_pk: number; page: number }>( + `SELECT chunk_pk, page FROM public.chunks WHERE doc_id = $1 AND chunk_id = $2`, + [body.doc_id, cid], + ); + if (!chunk) throw new Error(`chunk ${body.doc_id}/${cid} not found`); + + const gap_id = await allocate.gapId(); + const scope = { + kind: "outlier", + chunk_pk: chunk.chunk_pk, + doc_id: body.doc_id, + chunk_id: cid, + page: chunk.page, + dominant_model: body.dominant_model, + why_surprising: body.why_surprising, + what_it_implies: body.what_it_implies, + title: body.title, + }; + + await query( + `INSERT INTO public.gaps + (gap_id, description, scope, suggested_next_move, status, created_by) + VALUES ($1, $2, $3::jsonb, $4, 'open', $5)`, + [ + gap_id, + body.title, + JSON.stringify(scope), + body.suggested_next_move, + ctx.detective, + ], + ); + + const dir = path.join(env.CASE_ROOT, "gaps"); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, `${gap_id}.md`); + await writeFile(file, renderMd(gap_id, { ...body, chunk_id: cid }, chunk.chunk_pk, chunk.page, ctx), "utf-8"); + + await audit({ + event: "write_outlier_gap", + job_id: ctx.job_id, + detective: ctx.detective, + gap_id, + chunk_pk: chunk.chunk_pk, + title: body.title.slice(0, 120), + file, + }); + + return { gap_id, case_file: file }; +} diff --git a/investigator-runtime/src/tools/write_witness_analysis.ts b/investigator-runtime/src/tools/write_witness_analysis.ts new file mode 100644 index 0000000..c894f8a --- /dev/null +++ b/investigator-runtime/src/tools/write_witness_analysis.ts @@ -0,0 +1,198 @@ +/** + * write_witness_analysis.ts — Poirot's primary writer. + * + * INSERTs a row into public.witnesses (FK to entities.entity_pk for the + * person) and writes case/witnesses/W-NNNN.md. + * + * Validates: + * - person_entity_pk exists in public.entities and is of class 'person' + * - credibility ∈ {high, medium, low, speculation} + * - access_to_event + bias_notes are present (non-empty) + * - corroboration_refs[].chunk_id resolves to a chunk_pk in public.chunks + * - verdict ≤ 280 chars + */ +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 CorroborationRef { + /** chunk_id slug (e.g. "c0042"). The writer resolves chunk_pk for storage. */ + chunk_id: string; + /** Optional doc_id when the chunk_id alone might be ambiguous across docs. */ + doc_id?: string; + /** true=supports, false=refutes. */ + supports: boolean; +} + +export interface WriteWitnessAnalysisArgs { + person_entity_pk: number; + credibility: "high" | "medium" | "low" | "speculation"; + access_to_event: string; + bias_notes: string; + corroboration_refs: CorroborationRef[]; + verdict: string; +} + +export interface WriteWitnessAnalysisContext { + job_id: string; + detective: string; +} + +function normalizeChunkId(raw: string): string { + const m = raw.match(/c\d{4,}$/); + return m ? m[0] : raw; +} + +interface ResolvedRef { + chunk_pk: number; + doc_id: string; + chunk_id: string; + page: number; + supports: boolean; +} + +async function resolveRef(ref: CorroborationRef, fallbackDocId?: string): Promise { + const cid = normalizeChunkId(ref.chunk_id); + if (!cid) return null; + // If doc_id provided, scope; else allow any doc. + const docHint = ref.doc_id?.trim() || fallbackDocId?.trim() || null; + const row = docHint + ? await queryOne<{ chunk_pk: number; page: number; doc_id: string }>( + `SELECT chunk_pk, page, doc_id FROM public.chunks WHERE doc_id = $1 AND chunk_id = $2`, + [docHint, cid], + ) + : await queryOne<{ chunk_pk: number; page: number; doc_id: string }>( + `SELECT chunk_pk, page, doc_id FROM public.chunks WHERE chunk_id = $1 LIMIT 1`, + [cid], + ); + if (!row) return null; + return { chunk_pk: row.chunk_pk, doc_id: row.doc_id, chunk_id: cid, page: row.page, supports: ref.supports }; +} + +function renderMd( + id: string, + canonical_name: string, + body: WriteWitnessAnalysisArgs, + refs: ResolvedRef[], + ctx: WriteWitnessAnalysisContext, +): string { + const refBlocks = refs.length === 0 + ? "_(no corroboration cited)_" + : refs.map((r) => { + const pageStr = String(r.page).padStart(3, "0"); + return `- [[${r.doc_id}/p${pageStr}#${r.chunk_id}]] (${r.supports ? "supports" : "refutes"})`; + }).join("\n"); + + const fm = [ + "---", + `schema_version: "0.1.0"`, + `type: witness_analysis`, + `witness_id: ${id}`, + `subject: ${JSON.stringify(canonical_name)}`, + `credibility: ${body.credibility}`, + `created_by: ${ctx.detective}`, + `job_id: ${ctx.job_id}`, + `created_at: ${new Date().toISOString()}`, + "---", + ].join("\n"); + + return [ + fm, + "", + `# Witness analysis ${id} — ${canonical_name}`, + "", + `**Credibility.** ${body.credibility}`, + "", + `**Verdict.** ${body.verdict}`, + "", + "## Access to event", + "", + body.access_to_event, + "", + "## Bias notes", + "", + body.bias_notes, + "", + "## Corroboration chain", + "", + refBlocks, + "", + ].join("\n"); +} + +export async function writeWitnessAnalysis( + body: WriteWitnessAnalysisArgs, + ctx: WriteWitnessAnalysisContext, + opts?: { fallback_doc_id?: string }, +): Promise<{ witness_id: string; case_file: string; credibility: string; person_entity_pk: number }> { + if (!Number.isFinite(body.person_entity_pk)) throw new Error("person_entity_pk required"); + const validBand = ["high", "medium", "low", "speculation"].includes(body.credibility); + if (!validBand) throw new Error(`bad credibility: ${body.credibility}`); + if (!body.access_to_event?.trim()) throw new Error("access_to_event required"); + if (!body.bias_notes?.trim()) throw new Error("bias_notes required"); + if (!body.verdict?.trim()) throw new Error("verdict required"); + if (body.verdict.length > 280) throw new Error(`verdict too long (${body.verdict.length} > 280)`); + if (body.access_to_event.length > 800) throw new Error(`access_to_event too long (${body.access_to_event.length} > 800)`); + if (body.bias_notes.length > 800) throw new Error(`bias_notes too long (${body.bias_notes.length} > 800)`); + + // Verify entity exists and is a person. + const ent = await queryOne<{ canonical_name: string; entity_class: string }>( + `SELECT canonical_name, entity_class FROM public.entities WHERE entity_pk = $1`, + [body.person_entity_pk], + ); + if (!ent) throw new Error(`entity not found: pk=${body.person_entity_pk}`); + if (ent.entity_class !== "person") { + throw new Error(`entity is not a person: ${ent.entity_class}`); + } + + // Resolve corroboration refs. Drop unresolvable ones (don't fail the whole call). + const refs: ResolvedRef[] = []; + for (const r of (body.corroboration_refs ?? []).slice(0, 8)) { + if (!r?.chunk_id) continue; + const resolved = await resolveRef(r, opts?.fallback_doc_id); + if (resolved) refs.push(resolved); + } + + const witness_id = await allocate.witnessId(); + await query( + `INSERT INTO public.witnesses + (witness_id, person_entity_pk, credibility, access_to_event, + bias_notes, corroboration_refs, verdict, created_by) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)`, + [ + witness_id, body.person_entity_pk, body.credibility, + body.access_to_event, body.bias_notes, + JSON.stringify(refs.map((r) => ({ + chunk_pk: r.chunk_pk, doc_id: r.doc_id, chunk_id: r.chunk_id, + page: r.page, supports: r.supports, + }))), + body.verdict, ctx.detective, + ], + ); + + const dir = path.join(env.CASE_ROOT, "witnesses"); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, `${witness_id}.md`); + await writeFile(file, renderMd(witness_id, ent.canonical_name, body, refs, ctx), "utf-8"); + + await audit({ + event: "write_witness_analysis", + job_id: ctx.job_id, + detective: ctx.detective, + witness_id, + person_entity_pk: body.person_entity_pk, + canonical_name: ent.canonical_name, + credibility: body.credibility, + n_corroboration: refs.length, + file, + }); + + return { + witness_id, case_file: file, + credibility: body.credibility, + person_entity_pk: body.person_entity_pk, + }; +} diff --git a/web/app/api/jobs/[id]/route.ts b/web/app/api/jobs/[id]/route.ts index c6092c5..df9308b 100644 --- a/web/app/api/jobs/[id]/route.ts +++ b/web/app/api/jobs/[id]/route.ts @@ -65,6 +65,26 @@ interface ContradictionRow { detected_by: string | null; } +interface WitnessRow { + witness_id: string; + canonical_name: string | null; + entity_id: string | null; + credibility: string | null; + access_to_event: string | null; + bias_notes: string | null; + corroboration_refs: unknown; + verdict: string | null; +} + +interface GapRow { + gap_id: string; + description: string; + scope: unknown; + suggested_next_move: string | null; + status: string; + created_by: string; +} + function durationMs(started: string | null, finished: string | null, created: string): number | null { const a = started ? new Date(started).getTime() : null; const b = finished ? new Date(finished).getTime() : null; @@ -95,15 +115,19 @@ export async function GET( const evidenceIds: string[] = []; const hypothesisIds: string[] = []; const contradictionIds: string[] = []; + const witnessIds: string[] = []; + const gapIds: string[] = []; if (Array.isArray(job.outputs)) { for (const o of job.outputs as Array>) { if (typeof o.evidence_id === "string") evidenceIds.push(o.evidence_id); if (typeof o.hypothesis_id === "string") hypothesisIds.push(o.hypothesis_id); if (typeof o.contradiction_id === "string") contradictionIds.push(o.contradiction_id); + if (typeof o.witness_id === "string") witnessIds.push(o.witness_id); + if (typeof o.gap_id === "string") gapIds.push(o.gap_id); } } - const [evidence, hypotheses, contradictions] = await Promise.all([ + const [evidence, hypotheses, contradictions, witnesses, gaps] = await Promise.all([ evidenceIds.length > 0 ? pgQuery( `SELECT e.evidence_id, e.grade, e.source_page_id, @@ -137,6 +161,25 @@ export async function GET( [contradictionIds], ) : Promise.resolve([] as ContradictionRow[]), + witnessIds.length > 0 + ? pgQuery( + `SELECT w.witness_id, e.canonical_name, e.entity_id, + w.credibility, w.access_to_event, w.bias_notes, + w.corroboration_refs, w.verdict + FROM public.witnesses w + LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk + WHERE w.witness_id = ANY($1::text[]) + ORDER BY w.witness_id`, + [witnessIds], + ) + : Promise.resolve([] as WitnessRow[]), + gapIds.length > 0 + ? pgQuery( + `SELECT gap_id, description, scope, suggested_next_move, status, created_by + FROM public.gaps WHERE gap_id = ANY($1::text[]) ORDER BY gap_id`, + [gapIds], + ) + : Promise.resolve([] as GapRow[]), ]); return NextResponse.json({ @@ -154,6 +197,8 @@ export async function GET( evidence, hypotheses, contradictions, + witnesses, + gaps, }); } catch (e) { return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 }); diff --git a/web/app/c/[slug]/page.tsx b/web/app/c/[slug]/page.tsx new file mode 100644 index 0000000..5bdd0e5 --- /dev/null +++ b/web/app/c/[slug]/page.tsx @@ -0,0 +1,111 @@ +/** + * /c/[slug] — Case report viewer. + * + * Reads /data/ufo/case/reports/.md, parses frontmatter for metadata, + * renders the markdown body via MarkdownBody. The case-writer detective + * writes these files; this page is the reader. + */ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { MarkdownBody } from "@/components/markdown-body"; +import { AuthBar } from "@/components/auth-bar"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const CASE_ROOT = process.env.CASE_ROOT || "/data/ufo/case"; + +interface Frontmatter { + topic?: string; + created_by?: string; + created_at?: string; + job_id?: string; + n_evidence?: number; + n_hypotheses?: number; + n_contradictions?: number; + n_witnesses?: number; + n_outliers?: number; +} + +function parseFrontmatter(md: string): { fm: Frontmatter; body: string } { + const m = md.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/); + if (!m) return { fm: {}, body: md }; + const fm: Frontmatter = {}; + for (const line of m[1].split("\n")) { + const kv = line.match(/^([a-z_]+):\s*(.+)$/); + if (!kv) continue; + const k = kv[1] as keyof Frontmatter; + let v: string | number = kv[2].trim(); + if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); + if (k === "n_evidence" || k === "n_hypotheses" || k === "n_contradictions" + || k === "n_witnesses" || k === "n_outliers") { + const n = Number(v); + if (Number.isFinite(n)) (fm[k] as number) = n; + } else { + (fm[k] as string) = v as string; + } + } + return { fm, body: m[2] }; +} + +export default async function CaseReportPage({ + params, +}: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) notFound(); + + let md: string; + try { + md = await readFile(path.join(CASE_ROOT, "reports", `${slug}.md`), "utf-8"); + } catch { + notFound(); + } + const { fm, body } = parseFrontmatter(md); + + const stats: Array<{ label: string; value: number | undefined; color: string }> = [ + { label: "evidence", value: fm.n_evidence, color: "text-[#06d6a0]" }, + { label: "hypotheses", value: fm.n_hypotheses, color: "text-[#7fdbff]" }, + { label: "contradictions", value: fm.n_contradictions, color: "text-[#ff8a4d]" }, + { label: "witnesses", value: fm.n_witnesses, color: "text-[#9b5de5]" }, + { label: "outliers", value: fm.n_outliers, color: "text-[#ffd23f]" }, + ]; + + return ( +
+ +
+
+ disclosure.top + / + case-report + / + {slug} +
+ +
+
+ Case report{fm.created_by && <> · written by {fm.created_by}} + {fm.created_at && <> · {fm.created_at}} +
+ {fm.topic && ( +

{fm.topic}

+ )} +
+ {stats.filter((s) => typeof s.value === "number").map((s) => ( + + {s.value} + {s.label} + + ))} +
+
+ +
+ {body} +
+
+
+ ); +} diff --git a/web/app/jobs/[id]/page.tsx b/web/app/jobs/[id]/page.tsx index 65fb1d9..0c98294 100644 --- a/web/app/jobs/[id]/page.tsx +++ b/web/app/jobs/[id]/page.tsx @@ -54,32 +54,56 @@ export default async function JobPage({ const detective = job.kind === "hypothesis_tournament" ? "holmes" : job.kind === "contradiction_scan" ? "dupin" : job.kind === "red_team_review" ? "schneier" + : job.kind === "witness_analysis" ? "poirot" + : job.kind === "outlier_scan" ? "taleb" + : job.kind === "calibrate_hypothesis" ? "tetlock" + : job.kind === "case_report" ? "case-writer" : "locard"; const detectiveName = - detective === "holmes" ? "Sherlock Holmes" : - detective === "dupin" ? "C. Auguste Dupin" : - detective === "schneier" ? "Bruce Schneier" : - "Edmond Locard"; + detective === "holmes" ? "Sherlock Holmes" : + detective === "dupin" ? "C. Auguste Dupin" : + detective === "schneier" ? "Bruce Schneier" : + detective === "poirot" ? "Hercule Poirot" : + detective === "taleb" ? "Nassim Taleb" : + detective === "tetlock" ? "Philip Tetlock" : + detective === "case-writer" ? "Dr. Watson (Case-Writer)" : + "Edmond Locard"; const detectiveSubtitle = - detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" : - detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" : - detective === "schneier" ? "Red-team review · hidden assumptions, failure modes, alt explanations" : - "Evidence chain · verbatim quotes with chain of custody (Locard)"; + detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" : + detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" : + detective === "schneier" ? "Red-team review · hidden assumptions, failure modes, alt explanations" : + detective === "poirot" ? "Witness analysis · credibility / access / bias / corroboration" : + detective === "taleb" ? "Outlier scan · chunks that violate the dominant model" : + detective === "tetlock" ? "Calibration · honest Bayesian update with action recommendation" : + detective === "case-writer" ? "Case narrative · five-act Watson assembly of all bureau artefacts" : + "Evidence chain · verbatim quotes with chain of custody (Locard)"; const detectiveTone = - detective === "holmes" ? "text-[#7fdbff]" : - detective === "dupin" ? "text-[#ff8a4d]" : - detective === "schneier" ? "text-[#ff3344]" : - "text-[#06d6a0]"; + detective === "holmes" ? "text-[#7fdbff]" : + detective === "dupin" ? "text-[#ff8a4d]" : + detective === "schneier" ? "text-[#ff3344]" : + detective === "poirot" ? "text-[#9b5de5]" : + detective === "taleb" ? "text-[#ffd23f]" : + detective === "tetlock" ? "text-[#26d4cc]" : + detective === "case-writer" ? "text-[#e0c080]" : + "text-[#06d6a0]"; const detectiveBg = - detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" : - detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" : - detective === "schneier" ? "from-[rgba(255,51,68,0.08)]" : - "from-[rgba(6,214,160,0.08)]"; + detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" : + detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" : + detective === "schneier" ? "from-[rgba(255,51,68,0.08)]" : + detective === "poirot" ? "from-[rgba(155,93,229,0.08)]" : + detective === "taleb" ? "from-[rgba(255,210,63,0.08)]" : + detective === "tetlock" ? "from-[rgba(38,212,204,0.08)]" : + detective === "case-writer" ? "from-[rgba(224,192,128,0.08)]" : + "from-[rgba(6,214,160,0.08)]"; const payload = (job.payload ?? {}) as Record; - const question = (payload.question ?? payload.topic ?? payload.hypothesis_id) as string | undefined; + const question = (payload.question ?? payload.topic ?? payload.hypothesis_id ?? payload.person_id) as string | undefined; const questionLabel = job.kind === "contradiction_scan" ? "Topic" : job.kind === "red_team_review" ? "Hypothesis under attack" : + job.kind === "witness_analysis" ? "Witness under analysis" : + job.kind === "outlier_scan" ? "Topic to outlier-scan" : + job.kind === "calibrate_hypothesis" ? "Hypothesis under recalibration" : + job.kind === "case_report" ? "Case to assemble" : "Question"; const docId = payload.doc_id as string | undefined; diff --git a/web/components/chat-bubble.tsx b/web/components/chat-bubble.tsx index a3f6ea3..ffd6468 100644 --- a/web/components/chat-bubble.tsx +++ b/web/components/chat-bubble.tsx @@ -687,13 +687,21 @@ function ToolTrace({ t }: { t: ToolBlock }) { const detective = r.detective ?? ( r.kind === "hypothesis_tournament" ? "holmes" : r.kind === "contradiction_scan" ? "dupin" : - r.kind === "red_team_review" ? "schneier" : "locard" + r.kind === "red_team_review" ? "schneier" : + r.kind === "witness_analysis" ? "poirot" : + r.kind === "outlier_scan" ? "taleb" : + r.kind === "calibrate_hypothesis" ? "tetlock" : + r.kind === "case_report" ? "case-writer" : "locard" ); const tone = - detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } : - detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } : - detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } : - { text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" }; + detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } : + detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } : + detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } : + detective === "poirot" ? { text: "text-[#9b5de5]", border: "border-[#9b5de5]", label: "Poirot" } : + detective === "taleb" ? { text: "text-[#ffd23f]", border: "border-[#ffd23f]", label: "Taleb" } : + detective === "tetlock" ? { text: "text-[#26d4cc]", border: "border-[#26d4cc]", label: "Tetlock" } : + detective === "case-writer" ? { text: "text-[#e0c080]", border: "border-[#e0c080]", label: "Case-Writer" } : + { text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" }; return (
diff --git a/web/components/job-status-poller.tsx b/web/components/job-status-poller.tsx index 57270d0..4ddc138 100644 --- a/web/components/job-status-poller.tsx +++ b/web/components/job-status-poller.tsx @@ -78,10 +78,57 @@ interface ContradictionItem { detected_by: string | null; } +interface WitnessCorrItem { + chunk_pk?: number; + doc_id: string; + chunk_id: string; + page: number; + supports: boolean; +} + +interface WitnessItem { + witness_id: string; + canonical_name: string | null; + entity_id: string | null; + credibility: string | null; + access_to_event: string | null; + bias_notes: string | null; + corroboration_refs: WitnessCorrItem[]; + verdict: string | null; +} + +interface CaseReportOutput { + kind: "case_report"; + case_file?: string; + slug?: string; + skipped?: boolean; + reason?: string; +} + +interface GapItem { + gap_id: string; + description: string; + scope: { + kind?: string; + title?: string; + doc_id?: string; + chunk_id?: string; + page?: number; + dominant_model?: string; + why_surprising?: string; + what_it_implies?: string; + } | null; + suggested_next_move: string | null; + status: string; + created_by: string; +} + interface FetchedJob extends InitialJob { evidence: EvidenceItem[]; hypotheses: HypothesisItem[]; contradictions: ContradictionItem[]; + witnesses: WitnessItem[]; + gaps: GapItem[]; duration_ms: number | null; } @@ -130,6 +177,8 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob } evidence: [], hypotheses: [], contradictions: [], + witnesses: [], + gaps: [], duration_ms: null, }); const [error, setError] = useState(null); @@ -258,6 +307,47 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
)} + {/* Case-report link card */} + {job.outputs.filter((o): o is CaseReportOutput => + (o as JobPayloadOutput).kind === "case_report" && typeof (o as CaseReportOutput).slug === "string" + ).map((o) => ( + +
+ Case report ready +
+
+ /c/{o.slug} +
+
+ Open the Watson narrative → +
+ + ))} + + {/* Outlier / gap cards */} + {job.gaps.length > 0 && ( +
+
+ Outliers ({job.gaps.length}) +
+ {job.gaps.map((g) => )} +
+ )} + + {/* Witness cards */} + {job.witnesses.length > 0 && ( +
+
+ Análise de testemunha ({job.witnesses.length}) +
+ {job.witnesses.map((w) => )} +
+ )} + {/* Contradiction cards */} {job.contradictions.length > 0 && (
@@ -279,7 +369,7 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob } )} {/* Empty / in-flight state */} - {!isTerminal(job.status) && job.hypotheses.length === 0 && job.evidence.length === 0 && job.contradictions.length === 0 && ( + {!isTerminal(job.status) && job.hypotheses.length === 0 && job.evidence.length === 0 && job.contradictions.length === 0 && job.witnesses.length === 0 && job.gaps.length === 0 && (
🔎 Os detetives estão lendo o corpus… @@ -435,6 +525,138 @@ function EvidenceCard({ e }: { e: EvidenceItem }) { ); } +function GapCard({ g }: { g: GapItem }) { + const s = g.scope ?? {}; + const isOutlier = s.kind === "outlier"; + const pageStr = s.page ? String(s.page).padStart(3, "0") : null; + return ( +
+
+
+ {g.gap_id}{isOutlier && " · outlier"} +
+ + {g.status} + +
+
+ {s.title || g.description} +
+ {s.doc_id && s.chunk_id && pageStr && ( +
+ Source:{" "} + + {s.doc_id}/p{pageStr}#{s.chunk_id} + +
+ )} + {s.dominant_model && ( +
+
Dominant model
+
{s.dominant_model}
+
+ )} + {s.why_surprising && ( +
+
Why surprising
+
{s.why_surprising}
+
+ )} + {s.what_it_implies && ( +
+
What it implies
+
{s.what_it_implies}
+
+ )} + {g.suggested_next_move && ( +
+ → {g.suggested_next_move} +
+ )} +
+ ); +} + +function WitnessCard({ w }: { w: WitnessItem }) { + const credTone = + w.credibility === "high" ? "text-[#06d6a0] border-[#06d6a0]" : + w.credibility === "medium" ? "text-[#3fde6a] border-[#3fde6a]" : + w.credibility === "low" ? "text-[#ffa500] border-[#ffa500]" : + w.credibility === "speculation" ? "text-[#ff6ec7] border-[#ff6ec7]" : + "text-[#9aa6b8] border-[#9aa6b8]"; + return ( +
+
+
+
{w.witness_id}
+
+ {w.entity_id ? ( + + {w.canonical_name ?? w.entity_id} + + ) : (w.canonical_name ?? "—")} +
+
+ {w.credibility && ( + + {w.credibility} + + )} +
+ + {w.verdict && ( +
+ {w.verdict} +
+ )} + +
+ {w.access_to_event && ( +
+
Access to event
+
{w.access_to_event}
+
+ )} + {w.bias_notes && ( +
+
Bias notes
+
{w.bias_notes}
+
+ )} +
+ + {w.corroboration_refs && w.corroboration_refs.length > 0 && ( +
+ + Corroboration chain ({w.corroboration_refs.length}) + +
    + {w.corroboration_refs.map((r, i) => { + const pageStr = String(r.page).padStart(3, "0"); + return ( +
  • + + {r.doc_id}/p{pageStr}#{r.chunk_id} + {" "} + + ({r.supports ? "supports" : "refutes"}) + +
  • + ); + })} +
+
+ )} +
+ ); +} + function ContradictionCard({ c }: { c: ContradictionItem }) { const statusTone = c.resolution_status === "resolved" ? "text-[#06d6a0] border-[#06d6a0]" : diff --git a/web/lib/chat/tools.ts b/web/lib/chat/tools.ts index b0c9b4c..7a3bceb 100644 --- a/web/lib/chat/tools.ts +++ b/web/lib/chat/tools.ts @@ -364,7 +364,11 @@ const request_investigation_tool: ToolDefinition = { "kinds: hypothesis_tournament (Holmes — 2-3 rival hypotheses with priors/posteriors) | " + "evidence_chain (Locard — verbatim evidence with chain_of_custody on N chunks of one doc) | " + "contradiction_scan (Dupin — pairs of chunks in irreconcilable tension on a topic) | " + - "red_team_review (Schneier — attacks an existing hypothesis: hidden assumptions, failure modes, alt explanations). " + + "red_team_review (Schneier — attacks an existing hypothesis: hidden assumptions, failure modes, alt explanations) | " + + "witness_analysis (Poirot — credibility / access / bias / corroboration for one named person) | " + + "outlier_scan (Taleb — locates AT MOST 3 chunks that violate the dominant model for a topic) | " + + "calibrate_hypothesis (Tetlock — recomputes posterior + band of an existing hypothesis vs fresh corpus) | " + + "case_report (case-writer — five-act Watson narrative assembling all artefacts on a topic into one document). " + "Returns { job_id, kind, status_url, eta_seconds }. The UI renders a status card " + "with a link to /jobs/; the worker takes ~30-120 seconds.", parameters: { @@ -372,7 +376,7 @@ const request_investigation_tool: ToolDefinition = { properties: { kind: { type: "string", - enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan", "red_team_review"], + enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan", "red_team_review", "witness_analysis", "outlier_scan", "calibrate_hypothesis", "case_report"], description: "Detective task kind.", }, hypothesis_id: { @@ -381,6 +385,12 @@ const request_investigation_tool: ToolDefinition = { "For red_team_review: REQUIRED. The H-NNNN id of an existing hypothesis to attack. " + "Ignored for the other kinds.", }, + person_id: { + type: "string", + description: + "For witness_analysis: REQUIRED. kebab-case entity_id of a person " + + "(e.g. 'j-edgar-hoover'). Ignored for the other kinds.", + }, question: { type: "string", description: @@ -793,8 +803,10 @@ async function handleRequestInvestigation( ctx: ToolHandlerContext, ): Promise { const kind = String(args.kind ?? "").trim(); - if (kind !== "hypothesis_tournament" && kind !== "evidence_chain" && kind !== "contradiction_scan" && kind !== "red_team_review") { - return { error: "bad_kind", message: "kind must be hypothesis_tournament, evidence_chain, contradiction_scan or red_team_review" }; + if (kind !== "hypothesis_tournament" && kind !== "evidence_chain" && kind !== "contradiction_scan" + && kind !== "red_team_review" && kind !== "witness_analysis" && kind !== "outlier_scan" + && kind !== "calibrate_hypothesis" && kind !== "case_report") { + return { error: "bad_kind", message: "kind must be one of: hypothesis_tournament, evidence_chain, contradiction_scan, red_team_review, witness_analysis, outlier_scan, calibrate_hypothesis, case_report" }; } const docArg = typeof args.doc_id === "string" && args.doc_id.trim() ? args.doc_id.trim() : ctx.doc_id || null; @@ -819,6 +831,32 @@ async function handleRequestInvestigation( return { error: "hypothesis_id_required", message: "red_team_review needs hypothesis_id like H-0003" }; } payload.hypothesis_id = hyp; + } else if (kind === "witness_analysis") { + const pid = String(args.person_id ?? "").trim(); + if (!/^[a-z0-9][a-z0-9-]*$/.test(pid)) { + return { error: "person_id_required", message: "witness_analysis needs a kebab-case person_id like 'j-edgar-hoover'" }; + } + payload.person_id = pid; + payload.lang = lang; + } else if (kind === "outlier_scan") { + const topic = String(args.topic ?? "").trim(); + if (!topic) return { error: "topic_required", message: "outlier_scan needs a topic" }; + payload.topic = topic; + payload.lang = lang; + if (docArg) payload.doc_id = docArg; + } else if (kind === "calibrate_hypothesis") { + const hyp = String(args.hypothesis_id ?? "").trim(); + if (!/^H-\d{4}$/.test(hyp)) { + return { error: "hypothesis_id_required", message: "calibrate_hypothesis needs hypothesis_id like H-0003" }; + } + payload.hypothesis_id = hyp; + payload.lang = lang; + } else if (kind === "case_report") { + const topic = String(args.topic ?? "").trim(); + if (!topic) return { error: "topic_required", message: "case_report needs a topic" }; + payload.topic = topic; + payload.lang = lang; + if (docArg) payload.doc_id = docArg; } else { if (!docArg) return { error: "doc_id_required", message: "evidence_chain needs a doc_id" }; payload.doc_id = docArg; @@ -829,9 +867,15 @@ async function handleRequestInvestigation( } const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous"; - // Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Schneier ~30s, - // Locard ~30s × n_chunks (default 5). - const eta = kind === "evidence_chain" ? 30 * 5 : kind === "red_team_review" ? 30 : 60; + // Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Poirot ~45s, + // Schneier ~30s, Taleb ~50s, Locard ~30s × n_chunks (default 5). + const eta = kind === "evidence_chain" ? 30 * 5 + : kind === "red_team_review" ? 30 + : kind === "calibrate_hypothesis" ? 30 + : kind === "witness_analysis" ? 45 + : kind === "outlier_scan" ? 50 + : kind === "case_report" ? 180 + : 60; try { const rows = await pgQuery<{ job_id: string; created_at: string }>( @@ -852,6 +896,10 @@ async function handleRequestInvestigation( detective: kind === "hypothesis_tournament" ? "holmes" : kind === "contradiction_scan" ? "dupin" : kind === "red_team_review" ? "schneier" + : kind === "witness_analysis" ? "poirot" + : kind === "outlier_scan" ? "taleb" + : kind === "calibrate_hypothesis" ? "tetlock" + : kind === "case_report" ? "case-writer" : "locard", }; } catch (e) {