W3.8: Investigation Bureau complete — Poirot, Taleb, Tetlock, Case-Writer
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 45s
CI / Scripts — Python smoke (push) Failing after 5s
CI / Web — npm audit (push) Failing after 40s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 3s

Brings the bureau from 4 → 8 detectives. All eight run as Bun + claude-CLI
subprocesses against the same Supabase + investigation_jobs LISTEN/NOTIFY
queue, sharing search.ts hybridSearch and writer-side validators that
gate writes against schema + FK.

New detectives:

  Poirot (witness_analysis)
    - prompts/poirot.md — credibility / access / bias / corroboration /
      verdict; uses entity_mentions JOIN chunks to pull 12 chunks per
      person; resolves corroboration_refs chunk_ids defensively (accepts
      bare cNNNN even when the model emits pNNN/cNNNN).
    - INSERT into public.witnesses with W-NNNN naming.
    - Tone: purple (#9b5de5).

  Taleb (outlier_scan)
    - prompts/taleb.md — "surprise is relative to a model"; at most 3
      outliers; each requires explicit dominant_model + why_surprising +
      what_it_implies; fan-out into public.gaps with scope.kind="outlier".
    - Same unscoped-fallback as Dupin (Pass 1 with doc_id, Pass 2 widens
      to corpus if hits < 3).
    - Tone: yellow (#ffd23f).

  Tetlock (calibrate_hypothesis)
    - prompts/tetlock.md — honest Bayesian update; emits new_posterior +
      Δ + recommended_action ∈ {keep, downgrade, upgrade, supersede}.
    - write_calibration UPDATEs public.hypotheses + APPENDS a
      "## Calibration history" section to the H-NNNN.md case file
      (calibration is append-only — each datapoint matters). Posterior
      band auto-corrected to match Tetlock thresholds.
    - NO_NEW_EVIDENCE sentinel handled; pure 'keep' with |Δ|<0.005 only
      touches updated_at + reviewed_by.
    - Tone: teal (#26d4cc).

  Case-Writer (case_report)
    - prompts/case-writer.md — Dr. Watson assembles all artefacts
      (E-NNNN, H-NNNN, R-NNNN, W-NNNN, G-NNNN) into a five-act narrative.
      ILIKE filter on topic; doc_id optional scope.
    - Larger budget cap (≥ $0.50) + longer timeout for prose generation.
    - Writes case/reports/<slug>.md with frontmatter (topic + counts);
      no DB table for v0.
    - New page /c/[slug] renders the report via MarkdownBody + stat chips.
    - Tone: gold (#e0c080).

Hardening across the bureau:
  - Sentinel parsing now accepts backticked AND prose-trailing forms
    (Holmes NO_HYPOTHESES, Dupin NO_CONTRADICTIONS, Schneier
    INSUFFICIENT_HYPOTHESIS, Poirot INSUFFICIENT_TESTIMONY, Taleb
    NO_OUTLIERS, Tetlock NO_NEW_EVIDENCE, Case-Writer
    INSUFFICIENT_ARTEFACTS). Avoids the failure mode where the model
    refuses honestly but the runtime treated it as a parse error
    (observed live with Poirot+Hoover identifying the DIRECTOR
    false-positive disambiguation issue in entity_mentions).

Chat tool extensions (web/lib/chat/tools.ts):
  - request_investigation now accepts 7 kinds. Each routes to its
    detective with appropriate validation (hypothesis_id regex,
    person_id kebab-case, topic non-empty, doc_id for evidence_chain).
  - ETA per kind: Holmes/Dupin 60s, Poirot 45s, Schneier/Tetlock 30s,
    Taleb 50s, Case-Writer 180s (longer prose), Locard 30×n_chunks.

UI integration:
  - chat-bubble inline card paints each detective in its tone color.
  - /jobs/[id] page header swaps name/subtitle/tone per detective;
    question label adapts ("Topic" / "Hypothesis under attack" /
    "Witness under analysis" / "Topic to outlier-scan" / "Hypothesis
    under recalibration" / "Case to assemble").
  - job-status-poller renders: case-report link card (gold), outlier
    cards (yellow), witness cards (purple) — alongside existing
    hypothesis, evidence, contradiction cards.
  - /api/jobs/[id] hydrates witnesses (JOIN entities for canonical_name)
    + gaps (with scope JSONB).
  - /c/[slug] page reads /data/ufo/case/reports/<slug>.md and renders
    with MarkdownBody, frontmatter parsed for stat chips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luiz Gustavo 2026-05-23 22:11:39 -03:00
parent 857dd771d2
commit dd75a67964
22 changed files with 2424 additions and 34 deletions

View file

@ -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: 8002500 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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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<Record<string, unknown>> : [];
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<string, unknown> | 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<EvidenceRow>(
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<HypothesisRow>(
`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<ContradictionRow>(
`SELECT contradiction_id, topic, chunks, resolution_status, notes
FROM public.contradictions
WHERE LOWER(topic) LIKE $1
ORDER BY contradiction_id LIMIT 8`,
[filter],
),
query<WitnessRow>(
`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<GapRow>(
`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<string, unknown> | 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" });
}

View file

@ -73,7 +73,7 @@ function buildPrompt(task: DupinTask, hits: SearchHit[], lang: "pt" | "en"): str
function extractJsonArray(text: string): unknown[] | null { function extractJsonArray(text: string): unknown[] | null {
const t = text.trim(); 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 stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
const first = stripped.indexOf("["); const first = stripped.indexOf("[");
const last = stripped.lastIndexOf("]"); const last = stripped.lastIndexOf("]");

View file

@ -75,7 +75,7 @@ function buildPrompt(task: HolmesTask, hits: SearchHit[], lang: "pt" | "en"): st
function extractJsonArray(text: string): unknown[] | null { function extractJsonArray(text: string): unknown[] | null {
const t = text.trim(); 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 stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
const first = stripped.indexOf("["); const first = stripped.indexOf("[");
const last = stripped.lastIndexOf("]"); const last = stripped.lastIndexOf("]");

View file

@ -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<string, unknown> | 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<string, unknown>;
}
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<string, unknown>;
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<PersonChunkRow>(
`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<string, number>();
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 });
}

View file

@ -92,7 +92,7 @@ function buildPrompt(h: HypothesisRow, evidence: EvidenceRow[]): string {
function extractJsonObject(text: string): Record<string, unknown> | null { function extractJsonObject(text: string): Record<string, unknown> | null {
const t = text.trim(); 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 stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
const first = stripped.indexOf("{"); const first = stripped.indexOf("{");
const last = stripped.lastIndexOf("}"); const last = stripped.lastIndexOf("}");

View file

@ -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<string, unknown>;
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 };
}

View file

@ -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<number>): 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<number>,
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<string, unknown> | 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<string, unknown>;
}
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<HypothesisRow>(
`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<Record<string, unknown>>) {
if (typeof r?.evidence_id === "string") refIds.push(r.evidence_id);
}
}
const evidence = refIds.length > 0
? await query<EvidenceRow>(
`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<number>();
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",
});
}

View file

@ -11,6 +11,10 @@ import { runLocard, type LocardTask } from "./detectives/locard";
import { runHolmes, type HolmesTask } from "./detectives/holmes"; import { runHolmes, type HolmesTask } from "./detectives/holmes";
import { runDupin, type DupinTask } from "./detectives/dupin"; import { runDupin, type DupinTask } from "./detectives/dupin";
import { runSchneier, type SchneierTask } from "./detectives/schneier"; 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 { export interface InvestigationJob {
job_id: string; job_id: string;
@ -69,6 +73,82 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise
} }
break; 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": { case "red_team_review": {
// Payload: { hypothesis_id } // Payload: { hypothesis_id }
const hyp = String(job.payload.hypothesis_id ?? "").trim(); const hyp = String(job.payload.hypothesis_id ?? "").trim();

View file

@ -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,
};
}

View file

@ -0,0 +1,80 @@
/**
* write_case_report.ts Case-Writer's primary writer.
*
* Saves the Watson-style narrative to case/reports/<slug>.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 };
}

View file

@ -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 };
}

View file

@ -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<ResolvedRef | null> {
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,
};
}

View file

@ -65,6 +65,26 @@ interface ContradictionRow {
detected_by: string | null; 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 { function durationMs(started: string | null, finished: string | null, created: string): number | null {
const a = started ? new Date(started).getTime() : null; const a = started ? new Date(started).getTime() : null;
const b = finished ? new Date(finished).getTime() : null; const b = finished ? new Date(finished).getTime() : null;
@ -95,15 +115,19 @@ export async function GET(
const evidenceIds: string[] = []; const evidenceIds: string[] = [];
const hypothesisIds: string[] = []; const hypothesisIds: string[] = [];
const contradictionIds: string[] = []; const contradictionIds: string[] = [];
const witnessIds: string[] = [];
const gapIds: string[] = [];
if (Array.isArray(job.outputs)) { if (Array.isArray(job.outputs)) {
for (const o of job.outputs as Array<Record<string, unknown>>) { for (const o of job.outputs as Array<Record<string, unknown>>) {
if (typeof o.evidence_id === "string") evidenceIds.push(o.evidence_id); 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.hypothesis_id === "string") hypothesisIds.push(o.hypothesis_id);
if (typeof o.contradiction_id === "string") contradictionIds.push(o.contradiction_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 evidenceIds.length > 0
? pgQuery<EvidenceRow>( ? pgQuery<EvidenceRow>(
`SELECT e.evidence_id, e.grade, e.source_page_id, `SELECT e.evidence_id, e.grade, e.source_page_id,
@ -137,6 +161,25 @@ export async function GET(
[contradictionIds], [contradictionIds],
) )
: Promise.resolve([] as ContradictionRow[]), : Promise.resolve([] as ContradictionRow[]),
witnessIds.length > 0
? pgQuery<WitnessRow>(
`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<GapRow>(
`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({ return NextResponse.json({
@ -154,6 +197,8 @@ export async function GET(
evidence, evidence,
hypotheses, hypotheses,
contradictions, contradictions,
witnesses,
gaps,
}); });
} catch (e) { } catch (e) {
return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 }); return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 });

111
web/app/c/[slug]/page.tsx Normal file
View file

@ -0,0 +1,111 @@
/**
* /c/[slug] Case report viewer.
*
* Reads /data/ufo/case/reports/<slug>.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 (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<AuthBar />
<div className="mx-auto max-w-3xl px-4 py-8 pt-16">
<div className="text-[11px] text-[#5a6678] font-mono mb-2">
<Link href="/" className="hover:text-[#7fdbff]">disclosure.top</Link>
<span className="mx-1">/</span>
<span>case-report</span>
<span className="mx-1">/</span>
<span className="text-[#e0c080]">{slug}</span>
</div>
<div className="rounded-lg border border-[rgba(224,192,128,0.18)] bg-gradient-to-br from-[rgba(224,192,128,0.06)] to-transparent p-4 mb-6">
<div className="text-[10px] font-mono text-[#5a6678] uppercase mb-2">
Case report{fm.created_by && <> · written by <span className="text-[#e0c080]">{fm.created_by}</span></>}
{fm.created_at && <> · {fm.created_at}</>}
</div>
{fm.topic && (
<h1 className="text-xl font-mono text-[#e7ecf3] leading-snug">{fm.topic}</h1>
)}
<div className="mt-3 flex flex-wrap gap-3 text-[10px] font-mono">
{stats.filter((s) => typeof s.value === "number").map((s) => (
<span key={s.label}>
<span className={s.color}>{s.value}</span>
<span className="text-[#5a6678] ml-1">{s.label}</span>
</span>
))}
</div>
</div>
<article className="prose prose-invert prose-sm max-w-none">
<MarkdownBody>{body}</MarkdownBody>
</article>
</div>
</div>
);
}

View file

@ -54,32 +54,56 @@ export default async function JobPage({
const detective = job.kind === "hypothesis_tournament" ? "holmes" const detective = job.kind === "hypothesis_tournament" ? "holmes"
: job.kind === "contradiction_scan" ? "dupin" : job.kind === "contradiction_scan" ? "dupin"
: job.kind === "red_team_review" ? "schneier" : 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"; : "locard";
const detectiveName = const detectiveName =
detective === "holmes" ? "Sherlock Holmes" : detective === "holmes" ? "Sherlock Holmes" :
detective === "dupin" ? "C. Auguste Dupin" : detective === "dupin" ? "C. Auguste Dupin" :
detective === "schneier" ? "Bruce Schneier" : detective === "schneier" ? "Bruce Schneier" :
"Edmond Locard"; detective === "poirot" ? "Hercule Poirot" :
detective === "taleb" ? "Nassim Taleb" :
detective === "tetlock" ? "Philip Tetlock" :
detective === "case-writer" ? "Dr. Watson (Case-Writer)" :
"Edmond Locard";
const detectiveSubtitle = const detectiveSubtitle =
detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" : detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" :
detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" : detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" :
detective === "schneier" ? "Red-team review · hidden assumptions, failure modes, alt explanations" : detective === "schneier" ? "Red-team review · hidden assumptions, failure modes, alt explanations" :
"Evidence chain · verbatim quotes with chain of custody (Locard)"; 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 = const detectiveTone =
detective === "holmes" ? "text-[#7fdbff]" : detective === "holmes" ? "text-[#7fdbff]" :
detective === "dupin" ? "text-[#ff8a4d]" : detective === "dupin" ? "text-[#ff8a4d]" :
detective === "schneier" ? "text-[#ff3344]" : detective === "schneier" ? "text-[#ff3344]" :
"text-[#06d6a0]"; detective === "poirot" ? "text-[#9b5de5]" :
detective === "taleb" ? "text-[#ffd23f]" :
detective === "tetlock" ? "text-[#26d4cc]" :
detective === "case-writer" ? "text-[#e0c080]" :
"text-[#06d6a0]";
const detectiveBg = const detectiveBg =
detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" : detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" :
detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" : detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" :
detective === "schneier" ? "from-[rgba(255,51,68,0.08)]" : detective === "schneier" ? "from-[rgba(255,51,68,0.08)]" :
"from-[rgba(6,214,160,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<string, unknown>; const payload = (job.payload ?? {}) as Record<string, unknown>;
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 = const questionLabel =
job.kind === "contradiction_scan" ? "Topic" : job.kind === "contradiction_scan" ? "Topic" :
job.kind === "red_team_review" ? "Hypothesis under attack" : 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"; "Question";
const docId = payload.doc_id as string | undefined; const docId = payload.doc_id as string | undefined;

View file

@ -687,13 +687,21 @@ function ToolTrace({ t }: { t: ToolBlock }) {
const detective = r.detective ?? ( const detective = r.detective ?? (
r.kind === "hypothesis_tournament" ? "holmes" : r.kind === "hypothesis_tournament" ? "holmes" :
r.kind === "contradiction_scan" ? "dupin" : 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 = const tone =
detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } : detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } :
detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } : detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } :
detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } : detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } :
{ text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" }; 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 ( return (
<div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}> <div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}>
<div className="flex items-baseline justify-between mb-1"> <div className="flex items-baseline justify-between mb-1">

View file

@ -78,10 +78,57 @@ interface ContradictionItem {
detected_by: string | null; 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 { interface FetchedJob extends InitialJob {
evidence: EvidenceItem[]; evidence: EvidenceItem[];
hypotheses: HypothesisItem[]; hypotheses: HypothesisItem[];
contradictions: ContradictionItem[]; contradictions: ContradictionItem[];
witnesses: WitnessItem[];
gaps: GapItem[];
duration_ms: number | null; duration_ms: number | null;
} }
@ -130,6 +177,8 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
evidence: [], evidence: [],
hypotheses: [], hypotheses: [],
contradictions: [], contradictions: [],
witnesses: [],
gaps: [],
duration_ms: null, duration_ms: null,
}); });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -258,6 +307,47 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
</div> </div>
)} )}
{/* 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) => (
<Link
key={o.slug}
href={`/c/${o.slug}`}
className="block rounded-lg border border-[rgba(224,192,128,0.3)] bg-gradient-to-br from-[rgba(224,192,128,0.08)] to-transparent p-4 hover:border-[#e0c080] transition-colors"
>
<div className="text-[10px] font-mono text-[#e0c080] uppercase tracking-wider mb-1">
Case report ready
</div>
<div className="text-[14px] font-medium text-[#e7ecf3]">
/c/{o.slug}
</div>
<div className="text-[10px] font-mono text-[#5a6678] mt-1">
Open the Watson narrative
</div>
</Link>
))}
{/* Outlier / gap cards */}
{job.gaps.length > 0 && (
<div className="space-y-3">
<div className="text-[12px] font-mono text-[#ffd23f] uppercase tracking-wider">
Outliers ({job.gaps.length})
</div>
{job.gaps.map((g) => <GapCard key={g.gap_id} g={g} />)}
</div>
)}
{/* Witness cards */}
{job.witnesses.length > 0 && (
<div className="space-y-3">
<div className="text-[12px] font-mono text-[#9b5de5] uppercase tracking-wider">
Análise de testemunha ({job.witnesses.length})
</div>
{job.witnesses.map((w) => <WitnessCard key={w.witness_id} w={w} />)}
</div>
)}
{/* Contradiction cards */} {/* Contradiction cards */}
{job.contradictions.length > 0 && ( {job.contradictions.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
@ -279,7 +369,7 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
)} )}
{/* Empty / in-flight state */} {/* 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 && (
<div className="rounded-lg border border-dashed border-[rgba(127,219,255,0.15)] bg-[#0d1220] p-6 text-center"> <div className="rounded-lg border border-dashed border-[rgba(127,219,255,0.15)] bg-[#0d1220] p-6 text-center">
<div className="text-[12px] font-mono text-[#9aa6b8] animate-pulse"> <div className="text-[12px] font-mono text-[#9aa6b8] animate-pulse">
🔎 Os detetives estão lendo o corpus 🔎 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 (
<div className="rounded-lg border border-[rgba(255,210,63,0.25)] bg-[#0d1220] p-4">
<div className="flex items-baseline justify-between gap-3 mb-2 flex-wrap">
<div className="text-[10px] font-mono text-[#5a6678] uppercase">
{g.gap_id}{isOutlier && " · outlier"}
</div>
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase border border-[#ffd23f] text-[#ffd23f]">
{g.status}
</span>
</div>
<div className="text-[14px] text-[#e7ecf3] font-medium mb-2">
{s.title || g.description}
</div>
{s.doc_id && s.chunk_id && pageStr && (
<div className="text-[10px] font-mono mb-2">
Source:{" "}
<Link
href={`/d/${s.doc_id}/p${pageStr}#${s.chunk_id}`}
className="text-[#7fdbff] hover:underline"
>
{s.doc_id}/p{pageStr}#{s.chunk_id}
</Link>
</div>
)}
{s.dominant_model && (
<div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]">
<div className="text-[10px] font-mono text-[#9aa6b8] uppercase mb-1">Dominant model</div>
<div className="text-[12px] text-[#cbd2dd] leading-relaxed">{s.dominant_model}</div>
</div>
)}
{s.why_surprising && (
<div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]">
<div className="text-[10px] font-mono text-[#ffd23f] uppercase mb-1">Why surprising</div>
<div className="text-[12px] text-[#e7ecf3] leading-relaxed">{s.why_surprising}</div>
</div>
)}
{s.what_it_implies && (
<div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]">
<div className="text-[10px] font-mono text-[#ff8a4d] uppercase mb-1">What it implies</div>
<div className="text-[12px] text-[#cbd2dd] leading-relaxed">{s.what_it_implies}</div>
</div>
)}
{g.suggested_next_move && (
<div className="mt-2 text-[11px] font-mono text-[#06d6a0]">
{g.suggested_next_move}
</div>
)}
</div>
);
}
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 (
<div className="rounded-lg border border-[rgba(155,93,229,0.18)] bg-[#0d1220] p-4">
<div className="flex items-baseline justify-between gap-3 mb-2 flex-wrap">
<div>
<div className="text-[10px] font-mono text-[#5a6678] uppercase">{w.witness_id}</div>
<div className="text-[14px] text-[#e7ecf3] leading-snug font-medium mt-1">
{w.entity_id ? (
<Link href={`/e/people/${w.entity_id}`} className="hover:underline">
{w.canonical_name ?? w.entity_id}
</Link>
) : (w.canonical_name ?? "—")}
</div>
</div>
{w.credibility && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${credTone}`}>
{w.credibility}
</span>
)}
</div>
{w.verdict && (
<blockquote className="text-[13px] text-[#e7ecf3] italic border-l-2 border-[#9b5de5] pl-3 my-2">
{w.verdict}
</blockquote>
)}
<div className="grid md:grid-cols-2 gap-3 mt-3">
{w.access_to_event && (
<div className="p-2 rounded bg-[#060a13] border border-[rgba(155,93,229,0.08)]">
<div className="text-[10px] font-mono text-[#9b5de5] uppercase mb-1">Access to event</div>
<div className="text-[11px] text-[#cbd2dd] leading-relaxed">{w.access_to_event}</div>
</div>
)}
{w.bias_notes && (
<div className="p-2 rounded bg-[#060a13] border border-[rgba(155,93,229,0.08)]">
<div className="text-[10px] font-mono text-[#ff8a4d] uppercase mb-1">Bias notes</div>
<div className="text-[11px] text-[#cbd2dd] leading-relaxed">{w.bias_notes}</div>
</div>
)}
</div>
{w.corroboration_refs && w.corroboration_refs.length > 0 && (
<details className="mt-3">
<summary className="text-[10px] font-mono text-[#5a6678] uppercase cursor-pointer hover:text-[#9aa6b8]">
Corroboration chain ({w.corroboration_refs.length})
</summary>
<ul className="mt-2 ml-3 text-[11px] font-mono space-y-1">
{w.corroboration_refs.map((r, i) => {
const pageStr = String(r.page).padStart(3, "0");
return (
<li key={i}>
<Link
href={`/d/${r.doc_id}/p${pageStr}#${r.chunk_id}`}
className="text-[#7fdbff] hover:underline"
>
{r.doc_id}/p{pageStr}#{r.chunk_id}
</Link>{" "}
<span className={r.supports ? "text-[#06d6a0]" : "text-[#ff6ec7]"}>
({r.supports ? "supports" : "refutes"})
</span>
</li>
);
})}
</ul>
</details>
)}
</div>
);
}
function ContradictionCard({ c }: { c: ContradictionItem }) { function ContradictionCard({ c }: { c: ContradictionItem }) {
const statusTone = const statusTone =
c.resolution_status === "resolved" ? "text-[#06d6a0] border-[#06d6a0]" : c.resolution_status === "resolved" ? "text-[#06d6a0] border-[#06d6a0]" :

View file

@ -364,7 +364,11 @@ const request_investigation_tool: ToolDefinition = {
"kinds: hypothesis_tournament (Holmes — 2-3 rival hypotheses with priors/posteriors) | " + "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) | " + "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) | " + "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 " + "Returns { job_id, kind, status_url, eta_seconds }. The UI renders a status card " +
"with a link to /jobs/<job_id>; the worker takes ~30-120 seconds.", "with a link to /jobs/<job_id>; the worker takes ~30-120 seconds.",
parameters: { parameters: {
@ -372,7 +376,7 @@ const request_investigation_tool: ToolDefinition = {
properties: { properties: {
kind: { kind: {
type: "string", 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.", description: "Detective task kind.",
}, },
hypothesis_id: { 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. " + "For red_team_review: REQUIRED. The H-NNNN id of an existing hypothesis to attack. " +
"Ignored for the other kinds.", "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: { question: {
type: "string", type: "string",
description: description:
@ -793,8 +803,10 @@ async function handleRequestInvestigation(
ctx: ToolHandlerContext, ctx: ToolHandlerContext,
): Promise<unknown> { ): Promise<unknown> {
const kind = String(args.kind ?? "").trim(); const kind = String(args.kind ?? "").trim();
if (kind !== "hypothesis_tournament" && kind !== "evidence_chain" && kind !== "contradiction_scan" && kind !== "red_team_review") { if (kind !== "hypothesis_tournament" && kind !== "evidence_chain" && kind !== "contradiction_scan"
return { error: "bad_kind", message: "kind must be hypothesis_tournament, evidence_chain, contradiction_scan or red_team_review" }; && 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() const docArg = typeof args.doc_id === "string" && args.doc_id.trim()
? args.doc_id.trim() : ctx.doc_id || null; ? 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" }; return { error: "hypothesis_id_required", message: "red_team_review needs hypothesis_id like H-0003" };
} }
payload.hypothesis_id = hyp; 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 { } else {
if (!docArg) return { error: "doc_id_required", message: "evidence_chain needs a doc_id" }; if (!docArg) return { error: "doc_id_required", message: "evidence_chain needs a doc_id" };
payload.doc_id = docArg; payload.doc_id = docArg;
@ -829,9 +867,15 @@ async function handleRequestInvestigation(
} }
const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous"; const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous";
// Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Schneier ~30s, // Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Poirot ~45s,
// Locard ~30s × n_chunks (default 5). // Schneier ~30s, Taleb ~50s, Locard ~30s × n_chunks (default 5).
const eta = kind === "evidence_chain" ? 30 * 5 : kind === "red_team_review" ? 30 : 60; 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 { try {
const rows = await pgQuery<{ job_id: string; created_at: string }>( const rows = await pgQuery<{ job_id: string; created_at: string }>(
@ -852,6 +896,10 @@ async function handleRequestInvestigation(
detective: kind === "hypothesis_tournament" ? "holmes" detective: kind === "hypothesis_tournament" ? "holmes"
: kind === "contradiction_scan" ? "dupin" : kind === "contradiction_scan" ? "dupin"
: kind === "red_team_review" ? "schneier" : kind === "red_team_review" ? "schneier"
: kind === "witness_analysis" ? "poirot"
: kind === "outlier_scan" ? "taleb"
: kind === "calibrate_hypothesis" ? "tetlock"
: kind === "case_report" ? "case-writer"
: "locard", : "locard",
}; };
} catch (e) { } catch (e) {