W3.8: Investigation Bureau complete — Poirot, Taleb, Tetlock, Case-Writer
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:
parent
857dd771d2
commit
dd75a67964
22 changed files with 2424 additions and 34 deletions
55
investigator-runtime/prompts/case-writer.md
Normal file
55
investigator-runtime/prompts/case-writer.md
Normal 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: 800–2500 words. Tight is better than padded.
|
||||
5. Voice: Watson's plainspoken English (or Portuguese, per the request).
|
||||
The prose is for an educated reader, not a specialist. Avoid jargon.
|
||||
|
||||
## Output protocol
|
||||
|
||||
Emit ONLY the markdown body of the narrative. NO frontmatter (the runtime
|
||||
adds it). NO code fence. Start with `# ` heading and proceed through
|
||||
the five acts.
|
||||
|
||||
If the bureau has insufficient artefacts (e.g. 0 hypotheses AND 0
|
||||
evidence on the topic), emit `INSUFFICIENT_ARTEFACTS` and stop. Do not
|
||||
fabricate the case.
|
||||
67
investigator-runtime/prompts/poirot.md
Normal file
67
investigator-runtime/prompts/poirot.md
Normal 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.
|
||||
66
investigator-runtime/prompts/taleb.md
Normal file
66
investigator-runtime/prompts/taleb.md
Normal 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.
|
||||
54
investigator-runtime/prompts/tetlock.md
Normal file
54
investigator-runtime/prompts/tetlock.md
Normal 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.
|
||||
337
investigator-runtime/src/detectives/case_writer.ts
Normal file
337
investigator-runtime/src/detectives/case_writer.ts
Normal 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" });
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ function buildPrompt(task: DupinTask, hits: SearchHit[], lang: "pt" | "en"): str
|
|||
|
||||
function extractJsonArray(text: string): unknown[] | null {
|
||||
const t = text.trim();
|
||||
if (t === "NO_CONTRADICTIONS") return null;
|
||||
if (/^`?NO_CONTRADICTIONS`?\b/i.test(t)) return null;
|
||||
const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
|
||||
const first = stripped.indexOf("[");
|
||||
const last = stripped.lastIndexOf("]");
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function buildPrompt(task: HolmesTask, hits: SearchHit[], lang: "pt" | "en"): st
|
|||
|
||||
function extractJsonArray(text: string): unknown[] | null {
|
||||
const t = text.trim();
|
||||
if (t === "NO_HYPOTHESES") return null;
|
||||
if (/^`?NO_HYPOTHESES`?\b/i.test(t)) return null;
|
||||
const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
|
||||
const first = stripped.indexOf("[");
|
||||
const last = stripped.lastIndexOf("]");
|
||||
|
|
|
|||
249
investigator-runtime/src/detectives/poirot.ts
Normal file
249
investigator-runtime/src/detectives/poirot.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ function buildPrompt(h: HypothesisRow, evidence: EvidenceRow[]): string {
|
|||
|
||||
function extractJsonObject(text: string): Record<string, unknown> | null {
|
||||
const t = text.trim();
|
||||
if (t === "INSUFFICIENT_HYPOTHESIS") return null;
|
||||
if (/^`?INSUFFICIENT_HYPOTHESIS`?\b/i.test(t)) return null;
|
||||
const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
|
||||
const first = stripped.indexOf("{");
|
||||
const last = stripped.lastIndexOf("}");
|
||||
|
|
|
|||
177
investigator-runtime/src/detectives/taleb.ts
Normal file
177
investigator-runtime/src/detectives/taleb.ts
Normal 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 };
|
||||
}
|
||||
260
investigator-runtime/src/detectives/tetlock.ts
Normal file
260
investigator-runtime/src/detectives/tetlock.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ import { runLocard, type LocardTask } from "./detectives/locard";
|
|||
import { runHolmes, type HolmesTask } from "./detectives/holmes";
|
||||
import { runDupin, type DupinTask } from "./detectives/dupin";
|
||||
import { runSchneier, type SchneierTask } from "./detectives/schneier";
|
||||
import { runPoirot, type PoirotTask } from "./detectives/poirot";
|
||||
import { runTaleb, type TalebTask } from "./detectives/taleb";
|
||||
import { runTetlock, type TetlockTask } from "./detectives/tetlock";
|
||||
import { runCaseWriter, type CaseWriterTask } from "./detectives/case_writer";
|
||||
|
||||
export interface InvestigationJob {
|
||||
job_id: string;
|
||||
|
|
@ -69,6 +73,82 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise
|
|||
}
|
||||
break;
|
||||
}
|
||||
case "case_report": {
|
||||
// Payload: { topic, doc_id?, slug?, lang? }
|
||||
const topic = String(job.payload.topic ?? "").trim();
|
||||
if (!topic) throw new Error("case_report requires payload.topic");
|
||||
const task: CaseWriterTask = {
|
||||
job_id: job.job_id, topic,
|
||||
doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined,
|
||||
slug: typeof job.payload.slug === "string" ? job.payload.slug : undefined,
|
||||
lang: job.payload.lang === "en" ? "en" : "pt",
|
||||
};
|
||||
const r = await runCaseWriter(task);
|
||||
if ("skipped" in r) {
|
||||
outputs.push({ kind: "case_report", skipped: true, reason: r.reason });
|
||||
} else {
|
||||
outputs.push({ kind: "case_report", ...r });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "calibrate_hypothesis": {
|
||||
// Payload: { hypothesis_id }
|
||||
const hyp = String(job.payload.hypothesis_id ?? "").trim();
|
||||
if (!hyp) throw new Error("calibrate_hypothesis requires payload.hypothesis_id");
|
||||
const task: TetlockTask = {
|
||||
job_id: job.job_id,
|
||||
hypothesis_id: hyp,
|
||||
lang: job.payload.lang === "en" ? "en" : "pt",
|
||||
};
|
||||
const r = await runTetlock(task);
|
||||
if ("skipped" in r) {
|
||||
outputs.push({ kind: "calibrate_hypothesis", skipped: true, reason: r.reason });
|
||||
} else {
|
||||
outputs.push({ kind: "calibration", ...r });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "outlier_scan": {
|
||||
// Payload: { topic, doc_id?, lang?, context_chunks? }
|
||||
const topic = String(job.payload.topic ?? "").trim();
|
||||
if (!topic) throw new Error("outlier_scan requires payload.topic");
|
||||
const task: TalebTask = {
|
||||
job_id: job.job_id, topic,
|
||||
doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined,
|
||||
lang: job.payload.lang === "en" ? "en" : "pt",
|
||||
context_chunks: typeof job.payload.context_chunks === "number" ? job.payload.context_chunks : undefined,
|
||||
};
|
||||
const r = await runTaleb(task);
|
||||
if ("skipped" in r) {
|
||||
outputs.push({ kind: "outlier_scan", skipped: true, reason: r.reason });
|
||||
} else {
|
||||
for (const o of r.outliers) outputs.push({ kind: "outlier", ...o });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "witness_analysis": {
|
||||
// Payload: { person_id } OR { person_entity_pk }
|
||||
const person_id = typeof job.payload.person_id === "string" ? job.payload.person_id.trim() : undefined;
|
||||
const person_entity_pk = typeof job.payload.person_entity_pk === "number"
|
||||
? job.payload.person_entity_pk : undefined;
|
||||
if (!person_id && !person_entity_pk) {
|
||||
throw new Error("witness_analysis requires payload.person_id or person_entity_pk");
|
||||
}
|
||||
const task: PoirotTask = {
|
||||
job_id: job.job_id,
|
||||
person_id,
|
||||
person_entity_pk,
|
||||
lang: job.payload.lang === "en" ? "en" : "pt",
|
||||
context_chunks: typeof job.payload.context_chunks === "number" ? job.payload.context_chunks : undefined,
|
||||
};
|
||||
const r = await runPoirot(task);
|
||||
if ("skipped" in r) {
|
||||
outputs.push({ kind: "witness_analysis", skipped: true, reason: r.reason });
|
||||
} else {
|
||||
outputs.push({ kind: "witness_analysis", ...r });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "red_team_review": {
|
||||
// Payload: { hypothesis_id }
|
||||
const hyp = String(job.payload.hypothesis_id ?? "").trim();
|
||||
|
|
|
|||
162
investigator-runtime/src/tools/write_calibration.ts
Normal file
162
investigator-runtime/src/tools/write_calibration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
80
investigator-runtime/src/tools/write_case_report.ts
Normal file
80
investigator-runtime/src/tools/write_case_report.ts
Normal 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 };
|
||||
}
|
||||
147
investigator-runtime/src/tools/write_outlier_gap.ts
Normal file
147
investigator-runtime/src/tools/write_outlier_gap.ts
Normal 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 };
|
||||
}
|
||||
198
investigator-runtime/src/tools/write_witness_analysis.ts
Normal file
198
investigator-runtime/src/tools/write_witness_analysis.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -65,6 +65,26 @@ interface ContradictionRow {
|
|||
detected_by: string | null;
|
||||
}
|
||||
|
||||
interface WitnessRow {
|
||||
witness_id: string;
|
||||
canonical_name: string | null;
|
||||
entity_id: string | null;
|
||||
credibility: string | null;
|
||||
access_to_event: string | null;
|
||||
bias_notes: string | null;
|
||||
corroboration_refs: unknown;
|
||||
verdict: string | null;
|
||||
}
|
||||
|
||||
interface GapRow {
|
||||
gap_id: string;
|
||||
description: string;
|
||||
scope: unknown;
|
||||
suggested_next_move: string | null;
|
||||
status: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
function durationMs(started: string | null, finished: string | null, created: string): number | null {
|
||||
const a = started ? new Date(started).getTime() : null;
|
||||
const b = finished ? new Date(finished).getTime() : null;
|
||||
|
|
@ -95,15 +115,19 @@ export async function GET(
|
|||
const evidenceIds: string[] = [];
|
||||
const hypothesisIds: string[] = [];
|
||||
const contradictionIds: string[] = [];
|
||||
const witnessIds: string[] = [];
|
||||
const gapIds: string[] = [];
|
||||
if (Array.isArray(job.outputs)) {
|
||||
for (const o of job.outputs as Array<Record<string, unknown>>) {
|
||||
if (typeof o.evidence_id === "string") evidenceIds.push(o.evidence_id);
|
||||
if (typeof o.hypothesis_id === "string") hypothesisIds.push(o.hypothesis_id);
|
||||
if (typeof o.contradiction_id === "string") contradictionIds.push(o.contradiction_id);
|
||||
if (typeof o.witness_id === "string") witnessIds.push(o.witness_id);
|
||||
if (typeof o.gap_id === "string") gapIds.push(o.gap_id);
|
||||
}
|
||||
}
|
||||
|
||||
const [evidence, hypotheses, contradictions] = await Promise.all([
|
||||
const [evidence, hypotheses, contradictions, witnesses, gaps] = await Promise.all([
|
||||
evidenceIds.length > 0
|
||||
? pgQuery<EvidenceRow>(
|
||||
`SELECT e.evidence_id, e.grade, e.source_page_id,
|
||||
|
|
@ -137,6 +161,25 @@ export async function GET(
|
|||
[contradictionIds],
|
||||
)
|
||||
: Promise.resolve([] as ContradictionRow[]),
|
||||
witnessIds.length > 0
|
||||
? pgQuery<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({
|
||||
|
|
@ -154,6 +197,8 @@ export async function GET(
|
|||
evidence,
|
||||
hypotheses,
|
||||
contradictions,
|
||||
witnesses,
|
||||
gaps,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 });
|
||||
|
|
|
|||
111
web/app/c/[slug]/page.tsx
Normal file
111
web/app/c/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,32 +54,56 @@ export default async function JobPage({
|
|||
const detective = job.kind === "hypothesis_tournament" ? "holmes"
|
||||
: job.kind === "contradiction_scan" ? "dupin"
|
||||
: job.kind === "red_team_review" ? "schneier"
|
||||
: job.kind === "witness_analysis" ? "poirot"
|
||||
: job.kind === "outlier_scan" ? "taleb"
|
||||
: job.kind === "calibrate_hypothesis" ? "tetlock"
|
||||
: job.kind === "case_report" ? "case-writer"
|
||||
: "locard";
|
||||
const detectiveName =
|
||||
detective === "holmes" ? "Sherlock Holmes" :
|
||||
detective === "dupin" ? "C. Auguste Dupin" :
|
||||
detective === "schneier" ? "Bruce Schneier" :
|
||||
detective === "poirot" ? "Hercule Poirot" :
|
||||
detective === "taleb" ? "Nassim Taleb" :
|
||||
detective === "tetlock" ? "Philip Tetlock" :
|
||||
detective === "case-writer" ? "Dr. Watson (Case-Writer)" :
|
||||
"Edmond Locard";
|
||||
const detectiveSubtitle =
|
||||
detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" :
|
||||
detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" :
|
||||
detective === "schneier" ? "Red-team review · hidden assumptions, failure modes, alt explanations" :
|
||||
detective === "poirot" ? "Witness analysis · credibility / access / bias / corroboration" :
|
||||
detective === "taleb" ? "Outlier scan · chunks that violate the dominant model" :
|
||||
detective === "tetlock" ? "Calibration · honest Bayesian update with action recommendation" :
|
||||
detective === "case-writer" ? "Case narrative · five-act Watson assembly of all bureau artefacts" :
|
||||
"Evidence chain · verbatim quotes with chain of custody (Locard)";
|
||||
const detectiveTone =
|
||||
detective === "holmes" ? "text-[#7fdbff]" :
|
||||
detective === "dupin" ? "text-[#ff8a4d]" :
|
||||
detective === "schneier" ? "text-[#ff3344]" :
|
||||
detective === "poirot" ? "text-[#9b5de5]" :
|
||||
detective === "taleb" ? "text-[#ffd23f]" :
|
||||
detective === "tetlock" ? "text-[#26d4cc]" :
|
||||
detective === "case-writer" ? "text-[#e0c080]" :
|
||||
"text-[#06d6a0]";
|
||||
const detectiveBg =
|
||||
detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" :
|
||||
detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" :
|
||||
detective === "schneier" ? "from-[rgba(255,51,68,0.08)]" :
|
||||
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 question = (payload.question ?? payload.topic ?? payload.hypothesis_id) as string | undefined;
|
||||
const question = (payload.question ?? payload.topic ?? payload.hypothesis_id ?? payload.person_id) as string | undefined;
|
||||
const questionLabel =
|
||||
job.kind === "contradiction_scan" ? "Topic" :
|
||||
job.kind === "red_team_review" ? "Hypothesis under attack" :
|
||||
job.kind === "witness_analysis" ? "Witness under analysis" :
|
||||
job.kind === "outlier_scan" ? "Topic to outlier-scan" :
|
||||
job.kind === "calibrate_hypothesis" ? "Hypothesis under recalibration" :
|
||||
job.kind === "case_report" ? "Case to assemble" :
|
||||
"Question";
|
||||
const docId = payload.doc_id as string | undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -687,12 +687,20 @@ function ToolTrace({ t }: { t: ToolBlock }) {
|
|||
const detective = r.detective ?? (
|
||||
r.kind === "hypothesis_tournament" ? "holmes" :
|
||||
r.kind === "contradiction_scan" ? "dupin" :
|
||||
r.kind === "red_team_review" ? "schneier" : "locard"
|
||||
r.kind === "red_team_review" ? "schneier" :
|
||||
r.kind === "witness_analysis" ? "poirot" :
|
||||
r.kind === "outlier_scan" ? "taleb" :
|
||||
r.kind === "calibrate_hypothesis" ? "tetlock" :
|
||||
r.kind === "case_report" ? "case-writer" : "locard"
|
||||
);
|
||||
const tone =
|
||||
detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } :
|
||||
detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } :
|
||||
detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } :
|
||||
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 (
|
||||
<div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}>
|
||||
|
|
|
|||
|
|
@ -78,10 +78,57 @@ interface ContradictionItem {
|
|||
detected_by: string | null;
|
||||
}
|
||||
|
||||
interface WitnessCorrItem {
|
||||
chunk_pk?: number;
|
||||
doc_id: string;
|
||||
chunk_id: string;
|
||||
page: number;
|
||||
supports: boolean;
|
||||
}
|
||||
|
||||
interface WitnessItem {
|
||||
witness_id: string;
|
||||
canonical_name: string | null;
|
||||
entity_id: string | null;
|
||||
credibility: string | null;
|
||||
access_to_event: string | null;
|
||||
bias_notes: string | null;
|
||||
corroboration_refs: WitnessCorrItem[];
|
||||
verdict: string | null;
|
||||
}
|
||||
|
||||
interface CaseReportOutput {
|
||||
kind: "case_report";
|
||||
case_file?: string;
|
||||
slug?: string;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface GapItem {
|
||||
gap_id: string;
|
||||
description: string;
|
||||
scope: {
|
||||
kind?: string;
|
||||
title?: string;
|
||||
doc_id?: string;
|
||||
chunk_id?: string;
|
||||
page?: number;
|
||||
dominant_model?: string;
|
||||
why_surprising?: string;
|
||||
what_it_implies?: string;
|
||||
} | null;
|
||||
suggested_next_move: string | null;
|
||||
status: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
interface FetchedJob extends InitialJob {
|
||||
evidence: EvidenceItem[];
|
||||
hypotheses: HypothesisItem[];
|
||||
contradictions: ContradictionItem[];
|
||||
witnesses: WitnessItem[];
|
||||
gaps: GapItem[];
|
||||
duration_ms: number | null;
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +177,8 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
|
|||
evidence: [],
|
||||
hypotheses: [],
|
||||
contradictions: [],
|
||||
witnesses: [],
|
||||
gaps: [],
|
||||
duration_ms: null,
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -258,6 +307,47 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
|
|||
</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 */}
|
||||
{job.contradictions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -279,7 +369,7 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
|
|||
)}
|
||||
|
||||
{/* Empty / in-flight state */}
|
||||
{!isTerminal(job.status) && job.hypotheses.length === 0 && job.evidence.length === 0 && job.contradictions.length === 0 && (
|
||||
{!isTerminal(job.status) && job.hypotheses.length === 0 && job.evidence.length === 0 && job.contradictions.length === 0 && job.witnesses.length === 0 && job.gaps.length === 0 && (
|
||||
<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">
|
||||
🔎 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 }) {
|
||||
const statusTone =
|
||||
c.resolution_status === "resolved" ? "text-[#06d6a0] border-[#06d6a0]" :
|
||||
|
|
|
|||
|
|
@ -364,7 +364,11 @@ const request_investigation_tool: ToolDefinition = {
|
|||
"kinds: hypothesis_tournament (Holmes — 2-3 rival hypotheses with priors/posteriors) | " +
|
||||
"evidence_chain (Locard — verbatim evidence with chain_of_custody on N chunks of one doc) | " +
|
||||
"contradiction_scan (Dupin — pairs of chunks in irreconcilable tension on a topic) | " +
|
||||
"red_team_review (Schneier — attacks an existing hypothesis: hidden assumptions, failure modes, alt explanations). " +
|
||||
"red_team_review (Schneier — attacks an existing hypothesis: hidden assumptions, failure modes, alt explanations) | " +
|
||||
"witness_analysis (Poirot — credibility / access / bias / corroboration for one named person) | " +
|
||||
"outlier_scan (Taleb — locates AT MOST 3 chunks that violate the dominant model for a topic) | " +
|
||||
"calibrate_hypothesis (Tetlock — recomputes posterior + band of an existing hypothesis vs fresh corpus) | " +
|
||||
"case_report (case-writer — five-act Watson narrative assembling all artefacts on a topic into one document). " +
|
||||
"Returns { job_id, kind, status_url, eta_seconds }. The UI renders a status card " +
|
||||
"with a link to /jobs/<job_id>; the worker takes ~30-120 seconds.",
|
||||
parameters: {
|
||||
|
|
@ -372,7 +376,7 @@ const request_investigation_tool: ToolDefinition = {
|
|||
properties: {
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan", "red_team_review"],
|
||||
enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan", "red_team_review", "witness_analysis", "outlier_scan", "calibrate_hypothesis", "case_report"],
|
||||
description: "Detective task kind.",
|
||||
},
|
||||
hypothesis_id: {
|
||||
|
|
@ -381,6 +385,12 @@ const request_investigation_tool: ToolDefinition = {
|
|||
"For red_team_review: REQUIRED. The H-NNNN id of an existing hypothesis to attack. " +
|
||||
"Ignored for the other kinds.",
|
||||
},
|
||||
person_id: {
|
||||
type: "string",
|
||||
description:
|
||||
"For witness_analysis: REQUIRED. kebab-case entity_id of a person " +
|
||||
"(e.g. 'j-edgar-hoover'). Ignored for the other kinds.",
|
||||
},
|
||||
question: {
|
||||
type: "string",
|
||||
description:
|
||||
|
|
@ -793,8 +803,10 @@ async function handleRequestInvestigation(
|
|||
ctx: ToolHandlerContext,
|
||||
): Promise<unknown> {
|
||||
const kind = String(args.kind ?? "").trim();
|
||||
if (kind !== "hypothesis_tournament" && kind !== "evidence_chain" && kind !== "contradiction_scan" && kind !== "red_team_review") {
|
||||
return { error: "bad_kind", message: "kind must be hypothesis_tournament, evidence_chain, contradiction_scan or red_team_review" };
|
||||
if (kind !== "hypothesis_tournament" && kind !== "evidence_chain" && kind !== "contradiction_scan"
|
||||
&& kind !== "red_team_review" && kind !== "witness_analysis" && kind !== "outlier_scan"
|
||||
&& kind !== "calibrate_hypothesis" && kind !== "case_report") {
|
||||
return { error: "bad_kind", message: "kind must be one of: hypothesis_tournament, evidence_chain, contradiction_scan, red_team_review, witness_analysis, outlier_scan, calibrate_hypothesis, case_report" };
|
||||
}
|
||||
const docArg = typeof args.doc_id === "string" && args.doc_id.trim()
|
||||
? args.doc_id.trim() : ctx.doc_id || null;
|
||||
|
|
@ -819,6 +831,32 @@ async function handleRequestInvestigation(
|
|||
return { error: "hypothesis_id_required", message: "red_team_review needs hypothesis_id like H-0003" };
|
||||
}
|
||||
payload.hypothesis_id = hyp;
|
||||
} else if (kind === "witness_analysis") {
|
||||
const pid = String(args.person_id ?? "").trim();
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(pid)) {
|
||||
return { error: "person_id_required", message: "witness_analysis needs a kebab-case person_id like 'j-edgar-hoover'" };
|
||||
}
|
||||
payload.person_id = pid;
|
||||
payload.lang = lang;
|
||||
} else if (kind === "outlier_scan") {
|
||||
const topic = String(args.topic ?? "").trim();
|
||||
if (!topic) return { error: "topic_required", message: "outlier_scan needs a topic" };
|
||||
payload.topic = topic;
|
||||
payload.lang = lang;
|
||||
if (docArg) payload.doc_id = docArg;
|
||||
} else if (kind === "calibrate_hypothesis") {
|
||||
const hyp = String(args.hypothesis_id ?? "").trim();
|
||||
if (!/^H-\d{4}$/.test(hyp)) {
|
||||
return { error: "hypothesis_id_required", message: "calibrate_hypothesis needs hypothesis_id like H-0003" };
|
||||
}
|
||||
payload.hypothesis_id = hyp;
|
||||
payload.lang = lang;
|
||||
} else if (kind === "case_report") {
|
||||
const topic = String(args.topic ?? "").trim();
|
||||
if (!topic) return { error: "topic_required", message: "case_report needs a topic" };
|
||||
payload.topic = topic;
|
||||
payload.lang = lang;
|
||||
if (docArg) payload.doc_id = docArg;
|
||||
} else {
|
||||
if (!docArg) return { error: "doc_id_required", message: "evidence_chain needs a doc_id" };
|
||||
payload.doc_id = docArg;
|
||||
|
|
@ -829,9 +867,15 @@ async function handleRequestInvestigation(
|
|||
}
|
||||
|
||||
const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous";
|
||||
// Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Schneier ~30s,
|
||||
// Locard ~30s × n_chunks (default 5).
|
||||
const eta = kind === "evidence_chain" ? 30 * 5 : kind === "red_team_review" ? 30 : 60;
|
||||
// Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Poirot ~45s,
|
||||
// Schneier ~30s, Taleb ~50s, Locard ~30s × n_chunks (default 5).
|
||||
const eta = kind === "evidence_chain" ? 30 * 5
|
||||
: kind === "red_team_review" ? 30
|
||||
: kind === "calibrate_hypothesis" ? 30
|
||||
: kind === "witness_analysis" ? 45
|
||||
: kind === "outlier_scan" ? 50
|
||||
: kind === "case_report" ? 180
|
||||
: 60;
|
||||
|
||||
try {
|
||||
const rows = await pgQuery<{ job_id: string; created_at: string }>(
|
||||
|
|
@ -852,6 +896,10 @@ async function handleRequestInvestigation(
|
|||
detective: kind === "hypothesis_tournament" ? "holmes"
|
||||
: kind === "contradiction_scan" ? "dupin"
|
||||
: kind === "red_team_review" ? "schneier"
|
||||
: kind === "witness_analysis" ? "poirot"
|
||||
: kind === "outlier_scan" ? "taleb"
|
||||
: kind === "calibrate_hypothesis" ? "tetlock"
|
||||
: kind === "case_report" ? "case-writer"
|
||||
: "locard",
|
||||
};
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue