W3.7: Dupin contradiction-scan detective + UI integration
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 39s
CI / Scripts — Python smoke (push) Failing after 4s
CI / Web — npm audit (push) Failing after 37s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s

Adds the third AI detective in the Investigation Bureau runtime: C. Auguste
Dupin, who scans a corpus shortlist for pairs (or small groups) of chunks
that cannot both be true under any ordinary reading.

Runtime:
  - prompts/dupin.md — discipline (no contradiction without ≥2 distinct
    chunk_ids; reject same-vocabulary near-misses; FEW high-confidence
    over MANY weak ones; emit `NO_CONTRADICTIONS` when corpus is silent)
  - src/detectives/dupin.ts — hybridSearch with k=18 (more chunks than
    Holmes because contradictions emerge from comparing dispersed
    claims), strict JSON-array parsing, AT MOST 3 contradictions per call
  - src/tools/write_contradiction.ts — validates topic + ≥2 positions
    drawn from ≥2 distinct chunks, resolves chunk_pk via DB lookup
    (rejects positions citing unknown chunks), INSERTs into
    public.contradictions + writes case/contradictions/R-NNNN.md
  - orchestrator: new `contradiction_scan` kind dispatching to runDupin;
    payload { topic, doc_id?, lang?, context_chunks? }

Chat + UI:
  - request_investigation gains kind=contradiction_scan + topic arg;
    triggered detective auto-resolves to dupin
  - chat-bubble inline card renders dupin in orange (#ff8a4d) to
    distinguish from holmes (cyan) and locard (green)
  - /jobs/[id] page swaps title + subtitle + tone per detective;
    "Question" label becomes "Topic" for contradiction_scan
  - /api/jobs/[id] hydrates public.contradictions when outputs[] surfaces
    contradiction_ids
  - job-status-poller renders ContradictionCard: topic + N positions
    (verbatim statements quoted, stance label optional, link to source
    chunk) + optional notes panel, with resolution_status badge
    (open/resolved/irreconcilable)

R-NNNN shares the contradiction_id_seq slot with relation per
CLAUDE.md naming — same conceptual class (a connection between two
pieces of evidence in tension).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luiz Gustavo 2026-05-23 21:34:04 -03:00
parent b76e81e4b3
commit 5ac53cb3e2
9 changed files with 634 additions and 34 deletions

View file

@ -0,0 +1,73 @@
# You are Auguste Dupin
You are C. Auguste Dupin, originator of analytical ratiocination. Your method
is to read a body of testimony and locate the **incompatibilities** that
ordinary readers gloss over. You do not adjudicate which side is correct —
you isolate the tension itself, name the topic, and quote the conflicting
chunks verbatim so the case-writer can follow up.
## Discipline (non-negotiable)
1. Given a **topic** and a corpus shortlist of chunks, you scan for pairs (or
small groups) of chunks that cannot both be true under any ordinary
reading. Examples of tension:
- Two statements that fix the same event at different dates / places /
times of day.
- One chunk says a person was present, another says they were not.
- One chunk gives a count (witnesses, craft, fragments) that disagrees
with another by more than rounding.
- One chunk asserts the cause of a phenomenon was X, another asserts Y.
- One chunk says a document was destroyed, another references its
existence later.
2. You do NOT count the following as contradictions:
- Two chunks describing different events that merely share a vocabulary.
- A summary chunk paraphrasing an earlier detail-chunk (those agree).
- Redactions vs. uncredacted versions — that's not a contradiction, it's
a redaction gap; emit nothing.
- Speculation chunks contradicting fact chunks — that's normal; only
emit when BOTH sides are presented as fact.
3. Each contradiction you emit must contain at least **2 distinct chunks**
(no chunk in tension with itself). Three or more positions are allowed
when a true rashomon exists.
4. Each position cites its chunk via `chunk_id` + `doc_id` and includes a
**one-sentence `statement`** describing the position in your own words
(the runtime resolves the chunk_pk and verbatim text from the DB).
5. You prefer FEW high-confidence contradictions over MANY weak ones. If
the corpus contains nothing irreconcilable, emit `NO_CONTRADICTIONS`.
## Output protocol
Emit a strict JSON array. No prose. No code fence. Just the array.
```json
[
{
"topic": "Short noun-phrase summarizing the disputed point",
"notes": "Optional one-paragraph commentary (≤ 400 chars). Why this matters; what would resolve it.",
"positions": [
{
"doc_id": "dow-uap-d017-...",
"chunk_id": "c0042",
"statement": "One-sentence summary of what THIS chunk asserts.",
"stance": "asserts"
},
{
"doc_id": "dow-uap-d017-...",
"chunk_id": "c0087",
"statement": "One-sentence summary of what THAT chunk asserts.",
"stance": "denies"
}
]
}
]
```
Constraints:
- ≥ 2 positions per contradiction, drawn from ≥ 2 distinct `chunk_id`s.
- `stance` is optional free-form ("asserts" / "denies" / "dates-as-A" /
"dates-as-B" / etc.); useful for the case-writer but not required.
- `notes` may be empty; if present, keep it tight.
- Emit AT MOST 3 contradictions per call — the strongest you can find.
If the corpus contains no genuine contradiction relative to the topic,
emit the literal single word `NO_CONTRADICTIONS` and stop.

View file

@ -0,0 +1,185 @@
/**
* dupin.ts contradiction-scan detective.
*
* Workflow:
* 1. The runtime grounds Dupin with a small corpus shortlist via
* hybridSearch. Default k=18 Dupin needs MORE chunks than Holmes
* because contradictions emerge from comparing dispersed claims.
* 2. Claude Sonnet 4.6 reads the topic + chunks, emits a JSON array of
* 0-3 contradictions with topic/notes/positions[].
* 3. The runtime parses + calls writeContradiction() per item.
*/
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 {
writeContradiction,
type WriteContradictionArgs,
type ContradictionPosition,
} from "../tools/write_contradiction";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "dupin.md");
export interface DupinTask {
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 {
const blocks = hits.map((h) => {
const text = (lang === "en" ? h.content_en : h.content_pt) || h.content_en || h.content_pt || "";
const pageStr = String(h.page).padStart(3, "0");
return [
`--- ${h.doc_id}/p${pageStr}#${h.chunk_id} ---`,
`type: ${h.type}`,
h.classification ? `classification: ${h.classification}` : null,
"",
text.slice(0, 1100),
].filter(Boolean).join("\n");
});
return blocks.join("\n\n");
}
function buildPrompt(task: DupinTask, hits: SearchHit[], lang: "pt" | "en"): string {
return [
`# Topic to scan for contradictions`,
"",
task.topic,
"",
`## Corpus shortlist (${hits.length} chunks${task.doc_id ? `, scoped to ${task.doc_id}` : ""})`,
"",
renderChunkBlock(hits, lang),
"",
"## Your task",
"",
"Inspect the chunks for pairs (or small groups) that cannot both be true.",
"Emit at most 3 contradictions. Each must cite ≥ 2 distinct chunk_ids.",
"Emit the JSON array exactly as specified by the system prompt — no prose,",
"no code fence, no preamble. If no genuine contradiction exists,",
"emit the literal word `NO_CONTRADICTIONS`.",
].join("\n");
}
function extractJsonArray(text: string): unknown[] | null {
const t = text.trim();
if (t === "NO_CONTRADICTIONS") 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(`dupin returned no JSON array: ${t.slice(0, 200)}`);
}
const parsed = JSON.parse(stripped.slice(first, last + 1));
if (!Array.isArray(parsed)) throw new Error("dupin JSON is not an array");
return parsed;
}
function coercePositions(raw: unknown): ContradictionPosition[] {
if (!Array.isArray(raw)) return [];
const out: ContradictionPosition[] = [];
for (const p of raw) {
if (!p || typeof p !== "object") continue;
const o = p as Record<string, unknown>;
const doc_id = typeof o.doc_id === "string" ? o.doc_id.trim() : "";
const chunk_id = typeof o.chunk_id === "string" ? o.chunk_id.trim() : "";
const statement = typeof o.statement === "string" ? o.statement.trim() : "";
if (!doc_id || !chunk_id || !statement) continue;
out.push({
doc_id, chunk_id, statement,
stance: typeof o.stance === "string" ? o.stance.trim() : undefined,
});
}
return out;
}
export async function runDupin(task: DupinTask): Promise<
| { contradictions: Array<{ contradiction_id: string; case_file: string }> }
| { skipped: true; reason: string }
> {
const lang: "pt" | "en" = task.lang ?? "pt";
const k = task.context_chunks ?? 18;
const hits = await hybridSearch({
query: task.topic,
lang,
doc_id: task.doc_id ?? null,
top_k: k,
recall_k: 80,
});
await audit({
event: "dupin_grounded",
job_id: task.job_id,
detective: "dupin@detective",
topic: task.topic,
n_chunks: hits.length,
doc_id: task.doc_id ?? null,
});
if (hits.length < 2) {
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: "dupin@detective",
cost_usd: llm.costUsd,
tokens_in: llm.tokensIn,
tokens_out: llm.tokensOut,
duration_ms: llm.durationMs,
});
console.error(`[dupin] response (${llm.text.length} chars): ${llm.text.slice(0, 800)}`);
const arr = extractJsonArray(llm.text);
if (arr === null) return { skipped: true, reason: "NO_CONTRADICTIONS" };
const out: Array<{ contradiction_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 topic = typeof o.topic === "string" ? o.topic.trim() : "";
const positions = coercePositions(o.positions);
if (!topic || positions.length < 2) continue;
const args: WriteContradictionArgs = {
topic,
positions,
notes: typeof o.notes === "string" ? o.notes.trim() : undefined,
resolution_status: o.resolution_status === "resolved"
? "resolved"
: o.resolution_status === "irreconcilable"
? "irreconcilable"
: "open",
};
try {
const r = await writeContradiction(args, { job_id: task.job_id, detective: "dupin@detective" });
out.push(r);
} catch (e) {
await audit({
event: "write_contradiction_failed",
job_id: task.job_id,
detective: "dupin@detective",
error: (e as Error).message,
topic: args.topic.slice(0, 200),
});
}
}
return { contradictions: out };
}

View file

@ -9,6 +9,7 @@ import { audit } from "./lib/audit";
import { query } from "./lib/pg"; import { query } from "./lib/pg";
import { runLocard, type LocardTask } from "./detectives/locard"; import { runLocard, type LocardTask } from "./detectives/locard";
import { runHolmes, type HolmesTask } from "./detectives/holmes"; import { runHolmes, type HolmesTask } from "./detectives/holmes";
import { runDupin, type DupinTask } from "./detectives/dupin";
export interface InvestigationJob { export interface InvestigationJob {
job_id: string; job_id: string;
@ -67,6 +68,25 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise
} }
break; break;
} }
case "contradiction_scan": {
// Payload: { topic, doc_id?, lang?, context_chunks? }
const topic = String(job.payload.topic ?? "").trim();
if (!topic) throw new Error("contradiction_scan requires payload.topic");
const task: DupinTask = {
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 runDupin(task);
if ("skipped" in r) {
outputs.push({ kind: "contradiction_scan", skipped: true, reason: r.reason });
} else {
for (const c of r.contradictions) outputs.push({ kind: "contradiction", ...c });
}
break;
}
default: default:
throw new Error(`unknown_kind: ${job.kind}`); throw new Error(`unknown_kind: ${job.kind}`);

View file

@ -0,0 +1,186 @@
/**
* write_contradiction.ts Dupin's primary writer.
*
* Inserts a row into public.contradictions and renders
* case/contradictions/R-NNNN.md.
*
* Validates:
* - topic + at least 2 positions
* - each position has a chunk_pk (resolved from doc_id + chunk_id via the DB)
* - position.statement is non-empty
* - notes 4000 chars
*
* Naming uses the R-NNNN slot from contradiction_id_seq. Note: this CLAUDE.md
* historically reserved R-NNNN for `relation` artefacts (chief-detective);
* contradictions are the same conceptual class (a connection between two
* pieces of evidence in tension), so they share the slot.
*/
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 ContradictionPosition {
doc_id: string;
chunk_id: string;
/** The verbatim or paraphrased claim that puts this chunk on this side. */
statement: string;
/** Optional weight or stance label (e.g. "asserts", "denies"). */
stance?: string;
}
export interface WriteContradictionArgs {
topic: string;
positions: ContradictionPosition[];
notes?: string;
resolution_status?: "open" | "resolved" | "irreconcilable";
}
export interface WriteContradictionContext {
job_id: string;
detective: string;
}
interface ResolvedPosition extends ContradictionPosition {
chunk_pk: number;
page: number;
}
async function resolveChunk(doc_id: string, chunk_id: string): Promise<{ chunk_pk: number; page: number } | null> {
const row = await queryOne<{ chunk_pk: number; page: number }>(
`SELECT chunk_pk, page FROM public.chunks WHERE doc_id = $1 AND chunk_id = $2`,
[doc_id, chunk_id],
);
return row ?? null;
}
function renderMd(
id: string,
body: WriteContradictionArgs,
positions: ResolvedPosition[],
ctx: WriteContradictionContext,
): string {
const fm = [
"---",
`schema_version: "0.1.0"`,
`type: contradiction`,
`contradiction_id: ${id}`,
`topic: ${JSON.stringify(body.topic)}`,
`resolution_status: ${body.resolution_status ?? "open"}`,
`detected_by: ${ctx.detective}`,
`job_id: ${ctx.job_id}`,
`created_at: ${new Date().toISOString()}`,
"---",
].join("\n");
const positionBlocks = positions.map((p, i) => {
const pageStr = String(p.page).padStart(3, "0");
return [
`### Position ${i + 1}${p.stance ? `${p.stance}` : ""}`,
"",
`> ${p.statement}`,
"",
`Source: [[${p.doc_id}/p${pageStr}#${p.chunk_id}]]`,
].join("\n");
});
return [
fm,
"",
`# Contradiction ${id}`,
"",
`**Topic.** ${body.topic}`,
"",
"## Positions in tension",
"",
positionBlocks.join("\n\n"),
"",
"## Notes",
"",
body.notes || "_(no commentary recorded)_",
"",
].join("\n");
}
export async function writeContradiction(
body: WriteContradictionArgs,
ctx: WriteContradictionContext,
): Promise<{ contradiction_id: string; case_file: string }> {
if (!body.topic?.trim()) throw new Error("topic required");
if (!Array.isArray(body.positions) || body.positions.length < 2) {
throw new Error("at least 2 positions required");
}
if (body.notes && body.notes.length > 4000) {
throw new Error(`notes too long (${body.notes.length} > 4000)`);
}
const resolved: ResolvedPosition[] = [];
for (const p of body.positions) {
if (!p?.doc_id?.trim() || !p?.chunk_id?.trim()) {
throw new Error("position requires doc_id + chunk_id");
}
if (!p?.statement?.trim()) {
throw new Error(`position ${p.doc_id}/${p.chunk_id} missing statement`);
}
const chunk = await resolveChunk(p.doc_id, p.chunk_id);
if (!chunk) {
throw new Error(`chunk ${p.doc_id}/${p.chunk_id} not found`);
}
resolved.push({
...p,
statement: p.statement.trim(),
chunk_pk: chunk.chunk_pk,
page: chunk.page,
});
}
// Reject pairs that point at the same chunk on every side — that's not a
// contradiction, that's a single statement.
const uniqueChunks = new Set(resolved.map((p) => p.chunk_pk));
if (uniqueChunks.size < 2) {
throw new Error("contradiction requires positions from at least 2 distinct chunks");
}
const contradiction_id = await allocate.contradictionId();
const chunkPayload = resolved.map((p) => ({
chunk_pk: p.chunk_pk,
doc_id: p.doc_id,
chunk_id: p.chunk_id,
page: p.page,
statement: p.statement,
stance: p.stance ?? null,
}));
await query(
`INSERT INTO public.contradictions
(contradiction_id, topic, chunks, detected_by, resolution_status, notes)
VALUES ($1, $2, $3::jsonb, $4, $5, $6)`,
[
contradiction_id,
body.topic.trim(),
JSON.stringify(chunkPayload),
ctx.detective,
body.resolution_status ?? "open",
body.notes ?? null,
],
);
const dir = path.join(env.CASE_ROOT, "contradictions");
await mkdir(dir, { recursive: true });
const file = path.join(dir, `${contradiction_id}.md`);
await writeFile(file, renderMd(contradiction_id, body, resolved, ctx), "utf-8");
await audit({
event: "write_contradiction",
job_id: ctx.job_id,
detective: ctx.detective,
contradiction_id,
n_positions: resolved.length,
distinct_chunks: uniqueChunks.size,
file,
});
return { contradiction_id, case_file: file };
}

View file

@ -56,6 +56,15 @@ interface HypothesisRow {
evidence_refs: unknown; evidence_refs: unknown;
} }
interface ContradictionRow {
contradiction_id: string;
topic: string;
chunks: unknown;
resolution_status: string | null;
notes: string | null;
detected_by: string | null;
}
function durationMs(started: string | null, finished: string | null, created: string): number | null { function durationMs(started: string | null, finished: string | null, created: string): number | null {
const a = started ? new Date(started).getTime() : null; const a = started ? new Date(started).getTime() : null;
const b = finished ? new Date(finished).getTime() : null; const b = finished ? new Date(finished).getTime() : null;
@ -85,14 +94,16 @@ export async function GET(
// Collect IDs surfaced in outputs[] for hydration. // Collect IDs surfaced in outputs[] for hydration.
const evidenceIds: string[] = []; const evidenceIds: string[] = [];
const hypothesisIds: string[] = []; const hypothesisIds: string[] = [];
const contradictionIds: string[] = [];
if (Array.isArray(job.outputs)) { if (Array.isArray(job.outputs)) {
for (const o of job.outputs as Array<Record<string, unknown>>) { for (const o of job.outputs as Array<Record<string, unknown>>) {
if (typeof o.evidence_id === "string") evidenceIds.push(o.evidence_id); if (typeof o.evidence_id === "string") evidenceIds.push(o.evidence_id);
if (typeof o.hypothesis_id === "string") hypothesisIds.push(o.hypothesis_id); if (typeof o.hypothesis_id === "string") hypothesisIds.push(o.hypothesis_id);
if (typeof o.contradiction_id === "string") contradictionIds.push(o.contradiction_id);
} }
} }
const [evidence, hypotheses] = await Promise.all([ const [evidence, hypotheses, contradictions] = await Promise.all([
evidenceIds.length > 0 evidenceIds.length > 0
? pgQuery<EvidenceRow>( ? pgQuery<EvidenceRow>(
`SELECT e.evidence_id, e.grade, e.source_page_id, `SELECT e.evidence_id, e.grade, e.source_page_id,
@ -117,6 +128,15 @@ export async function GET(
[hypothesisIds], [hypothesisIds],
) )
: Promise.resolve([] as HypothesisRow[]), : Promise.resolve([] as HypothesisRow[]),
contradictionIds.length > 0
? pgQuery<ContradictionRow>(
`SELECT contradiction_id, topic, chunks, resolution_status, notes, detected_by
FROM public.contradictions
WHERE contradiction_id = ANY($1::text[])
ORDER BY contradiction_id`,
[contradictionIds],
)
: Promise.resolve([] as ContradictionRow[]),
]); ]);
return NextResponse.json({ return NextResponse.json({
@ -133,6 +153,7 @@ export async function GET(
outputs: Array.isArray(job.outputs) ? job.outputs : [], outputs: Array.isArray(job.outputs) ? job.outputs : [],
evidence, evidence,
hypotheses, hypotheses,
contradictions,
}); });
} catch (e) { } catch (e) {
return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 }); return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 });

View file

@ -51,13 +51,29 @@ export default async function JobPage({
const job = rows[0]; const job = rows[0];
if (!job) notFound(); if (!job) notFound();
const isHolmes = job.kind === "hypothesis_tournament"; const detective = job.kind === "hypothesis_tournament" ? "holmes"
const detectiveName = isHolmes ? "Sherlock Holmes" : "Edmond Locard"; : job.kind === "contradiction_scan" ? "dupin"
const detectiveSlug = isHolmes ? "holmes" : "locard"; : "locard";
const detectiveTone = isHolmes ? "text-[#7fdbff]" : "text-[#06d6a0]"; const detectiveName =
const detectiveBg = isHolmes ? "from-[rgba(127,219,255,0.08)]" : "from-[rgba(6,214,160,0.08)]"; detective === "holmes" ? "Sherlock Holmes" :
const question = (job.payload as Record<string, unknown>)?.question as string | undefined; detective === "dupin" ? "C. Auguste Dupin" :
const docId = (job.payload as Record<string, unknown>)?.doc_id as string | undefined; "Edmond Locard";
const detectiveSubtitle =
detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" :
detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" :
"Evidence chain · verbatim quotes with chain of custody (Locard)";
const detectiveTone =
detective === "holmes" ? "text-[#7fdbff]" :
detective === "dupin" ? "text-[#ff8a4d]" :
"text-[#06d6a0]";
const detectiveBg =
detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" :
detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" :
"from-[rgba(6,214,160,0.08)]";
const payload = (job.payload ?? {}) as Record<string, unknown>;
const question = (payload.question ?? payload.topic) as string | undefined;
const questionLabel = job.kind === "contradiction_scan" ? "Topic" : "Question";
const docId = payload.doc_id as string | undefined;
return ( return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]"> <div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
@ -77,20 +93,16 @@ export default async function JobPage({
<h1 className={`text-2xl font-mono font-bold ${detectiveTone}`}> <h1 className={`text-2xl font-mono font-bold ${detectiveTone}`}>
{detectiveName} {detectiveName}
</h1> </h1>
<p className="text-[12px] text-[#9aa6b8] mt-1 font-mono"> <p className="text-[12px] text-[#9aa6b8] mt-1 font-mono">{detectiveSubtitle}</p>
{isHolmes
? "Hypothesis tournament · rival hypotheses with Bayesian update"
: "Evidence chain · verbatim quotes with chain of custody (Locard)"}
</p>
</div> </div>
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${detectiveTone} border-current`}> <span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${detectiveTone} border-current`}>
{detectiveSlug} {detective}
</span> </span>
</div> </div>
{question && ( {question && (
<div className="mt-4 p-3 bg-[#060a13] rounded border border-[rgba(127,219,255,0.1)]"> <div className="mt-4 p-3 bg-[#060a13] rounded border border-[rgba(127,219,255,0.1)]">
<div className="text-[10px] text-[#5a6678] font-mono uppercase mb-1">Question</div> <div className="text-[10px] text-[#5a6678] font-mono uppercase mb-1">{questionLabel}</div>
<div className="text-[14px] text-[#e7ecf3] leading-snug">{question}</div> <div className="text-[14px] text-[#e7ecf3] leading-snug">{question}</div>
</div> </div>
)} )}

View file

@ -684,13 +684,19 @@ function ToolTrace({ t }: { t: ToolBlock }) {
</div> </div>
); );
} }
const isHolmes = r.kind === "hypothesis_tournament"; const detective = r.detective ?? (
const tone = isHolmes ? "text-[#7fdbff] border-[#7fdbff]" : "text-[#06d6a0] border-[#06d6a0]"; r.kind === "hypothesis_tournament" ? "holmes" :
r.kind === "contradiction_scan" ? "dupin" : "locard"
);
const tone =
detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } :
detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } :
{ text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" };
return ( return (
<div className={`mt-1 ml-3 p-3 rounded border ${tone} bg-[#060a13]`}> <div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}>
<div className="flex items-baseline justify-between mb-1"> <div className="flex items-baseline justify-between mb-1">
<div className={`font-mono text-[11px] font-bold ${isHolmes ? "text-[#7fdbff]" : "text-[#06d6a0]"}`}> <div className={`font-mono text-[11px] font-bold ${tone.text}`}>
🔎 {isHolmes ? "Holmes" : "Locard"} · {r.kind} 🔎 {tone.label} · {r.kind}
</div> </div>
<span className="text-[10px] text-[#5a6678] font-mono uppercase">{r.status}</span> <span className="text-[10px] text-[#5a6678] font-mono uppercase">{r.status}</span>
</div> </div>
@ -702,7 +708,7 @@ function ToolTrace({ t }: { t: ToolBlock }) {
<Link <Link
href={r.status_url} href={r.status_url}
target="_blank" target="_blank"
className={`mt-2 inline-flex items-center gap-1 text-[11px] font-mono ${isHolmes ? "text-[#7fdbff]" : "text-[#06d6a0]"} hover:underline`} className={`mt-2 inline-flex items-center gap-1 text-[11px] font-mono ${tone.text} hover:underline`}
> >
acompanhar a investigação <ArrowUpRight size={11} /> acompanhar a investigação <ArrowUpRight size={11} />
</Link> </Link>

View file

@ -61,9 +61,27 @@ interface HypothesisItem {
status: string | null; status: string | null;
} }
interface ContradictionPositionItem {
doc_id: string;
chunk_id: string;
page: number;
statement: string;
stance?: string | null;
}
interface ContradictionItem {
contradiction_id: string;
topic: string;
chunks: ContradictionPositionItem[];
resolution_status: string | null;
notes: string | null;
detected_by: string | null;
}
interface FetchedJob extends InitialJob { interface FetchedJob extends InitialJob {
evidence: EvidenceItem[]; evidence: EvidenceItem[];
hypotheses: HypothesisItem[]; hypotheses: HypothesisItem[];
contradictions: ContradictionItem[];
duration_ms: number | null; duration_ms: number | null;
} }
@ -111,6 +129,7 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
...props.initialJob, ...props.initialJob,
evidence: [], evidence: [],
hypotheses: [], hypotheses: [],
contradictions: [],
duration_ms: null, duration_ms: null,
}); });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -239,6 +258,16 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
</div> </div>
)} )}
{/* Contradiction cards */}
{job.contradictions.length > 0 && (
<div className="space-y-3">
<div className="text-[12px] font-mono text-[#ff8a4d] uppercase tracking-wider">
Contradições detectadas ({job.contradictions.length})
</div>
{job.contradictions.map((c) => <ContradictionCard key={c.contradiction_id} c={c} />)}
</div>
)}
{/* Evidence cards */} {/* Evidence cards */}
{job.evidence.length > 0 && ( {job.evidence.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
@ -250,13 +279,14 @@ export function JobStatusPoller(props: { jobId: string; initialJob: InitialJob }
)} )}
{/* Empty / in-flight state */} {/* Empty / in-flight state */}
{!isTerminal(job.status) && job.hypotheses.length === 0 && job.evidence.length === 0 && ( {!isTerminal(job.status) && job.hypotheses.length === 0 && job.evidence.length === 0 && job.contradictions.length === 0 && (
<div className="rounded-lg border border-dashed border-[rgba(127,219,255,0.15)] bg-[#0d1220] p-6 text-center"> <div className="rounded-lg border border-dashed border-[rgba(127,219,255,0.15)] bg-[#0d1220] p-6 text-center">
<div className="text-[12px] font-mono text-[#9aa6b8] animate-pulse"> <div className="text-[12px] font-mono text-[#9aa6b8] animate-pulse">
🔎 Os detetives estão lendo o corpus 🔎 Os detetives estão lendo o corpus
</div> </div>
<div className="text-[10px] font-mono text-[#5a6678] mt-2"> <div className="text-[10px] font-mono text-[#5a6678] mt-2">
Holmes constrói hipóteses rivais com priors + posteriors em ~60 s.<br /> Holmes constrói hipóteses rivais com priors + posteriors em ~60 s.<br />
Dupin localiza pares de chunks em tensão irreconciliável em ~60 s.<br />
Locard documenta evidências verbatim com cadeia de custódia em ~30 s por chunk. Locard documenta evidências verbatim com cadeia de custódia em ~30 s por chunk.
</div> </div>
</div> </div>
@ -405,6 +435,57 @@ function EvidenceCard({ e }: { e: EvidenceItem }) {
); );
} }
function ContradictionCard({ c }: { c: ContradictionItem }) {
const statusTone =
c.resolution_status === "resolved" ? "text-[#06d6a0] border-[#06d6a0]" :
c.resolution_status === "irreconcilable" ? "text-[#ff3344] border-[#ff3344]" :
"text-[#ff8a4d] border-[#ff8a4d]";
return (
<div className="rounded-lg border border-[rgba(255,138,77,0.18)] bg-[#0d1220] p-4">
<div className="flex items-baseline justify-between gap-3 mb-2">
<div className="text-[10px] font-mono text-[#5a6678] uppercase">{c.contradiction_id}</div>
{c.resolution_status && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${statusTone}`}>
{c.resolution_status}
</span>
)}
</div>
<div className="text-[14px] text-[#e7ecf3] leading-snug font-medium mb-3">
{c.topic}
</div>
<div className="space-y-2">
{c.chunks.map((p, i) => {
const pageStr = String(p.page).padStart(3, "0");
return (
<div key={i} className="p-2 bg-[#060a13] rounded border border-[rgba(255,138,77,0.1)]">
<div className="text-[10px] font-mono text-[#5a6678] uppercase mb-1">
Position {i + 1}{p.stance ? `${p.stance}` : ""}
</div>
<blockquote className="text-[12px] text-[#e7ecf3] italic border-l-2 border-[#ff8a4d] pl-2 mb-2">
{p.statement}
</blockquote>
<Link
href={`/d/${p.doc_id}/p${pageStr}#${p.chunk_id}`}
className="text-[10px] font-mono text-[#7fdbff] hover:underline"
>
{p.doc_id}/p{pageStr}#{p.chunk_id}
</Link>
</div>
);
})}
</div>
{c.notes && (
<div className="mt-3 p-2 bg-[#060a13] rounded border border-[rgba(255,138,77,0.08)]">
<div className="text-[10px] font-mono text-[#5a6678] uppercase mb-1">Notes</div>
<div className="text-[12px] text-[#cbd2dd] leading-relaxed">{c.notes}</div>
</div>
)}
</div>
);
}
function ProbabilityBar({ label, value, color }: { label: string; value: number | null; color: string }) { function ProbabilityBar({ label, value, color }: { label: string; value: number | null; color: string }) {
const pct = value !== null ? Math.round(value * 100) : 0; const pct = value !== null ? Math.round(value * 100) : 0;
return ( return (

View file

@ -356,13 +356,14 @@ const request_investigation_tool: ToolDefinition = {
function: { function: {
name: "request_investigation", name: "request_investigation",
description: description:
"Queue a deeper investigation by the 8-detective Investigation Bureau. " + "Queue a deeper investigation by the Investigation Bureau detectives. " +
"Use ONLY when the user asks for analysis that requires structured reasoning " + "Use ONLY when the user asks for analysis that requires structured reasoning " +
"across multiple chunks — e.g. 'build rival hypotheses about X', " + "across multiple chunks — e.g. 'build rival hypotheses about X', " +
"'audit this doc for contradictions', 'trace the chain of custody for claim Y'. " + "'find contradictions about Y', 'trace the chain of custody for claim Z'. " +
"Do NOT use for plain lookups; hybrid_search is faster. " + "Do NOT use for plain lookups; hybrid_search is faster. " +
"kinds: hypothesis_tournament (Holmes — 2-3 rival hypotheses with priors/posteriors) | " + "kinds: hypothesis_tournament (Holmes — 2-3 rival hypotheses with priors/posteriors) | " +
"evidence_chain (Locard — verbatim evidence with chain_of_custody on N chunks of one doc). " + "evidence_chain (Locard — verbatim evidence with chain_of_custody on N chunks of one doc) | " +
"contradiction_scan (Dupin — pairs of chunks in irreconcilable tension on a topic). " +
"Returns { job_id, kind, status_url, eta_seconds }. The UI renders a status card " + "Returns { job_id, kind, status_url, eta_seconds }. The UI renders a status card " +
"with a link to /jobs/<job_id>; the worker takes ~30-120 seconds.", "with a link to /jobs/<job_id>; the worker takes ~30-120 seconds.",
parameters: { parameters: {
@ -370,20 +371,27 @@ const request_investigation_tool: ToolDefinition = {
properties: { properties: {
kind: { kind: {
type: "string", type: "string",
enum: ["hypothesis_tournament", "evidence_chain"], enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan"],
description: "Detective task kind.", description: "Detective task kind.",
}, },
question: { question: {
type: "string", type: "string",
description: description:
"For hypothesis_tournament: the investigative question (one sentence, declarative). " + "For hypothesis_tournament: the investigative question (one sentence, declarative). " +
"Required for hypothesis_tournament; ignored for evidence_chain.", "Required for hypothesis_tournament; ignored for the other kinds.",
},
topic: {
type: "string",
description:
"For contradiction_scan: short noun-phrase naming the disputed point " +
"(e.g. 'date of the Roswell wreckage recovery'). Required for contradiction_scan; " +
"ignored for other kinds.",
}, },
doc_id: { doc_id: {
type: "string", type: "string",
description: description:
"Optional scope. For hypothesis_tournament: narrows the corpus shortlist. " + "Optional scope. hypothesis_tournament / contradiction_scan: narrows the corpus " +
"For evidence_chain: REQUIRED — the doc Locard scans.", "shortlist. evidence_chain: REQUIRED — the doc Locard scans.",
}, },
chunks: { chunks: {
type: "array", type: "array",
@ -778,8 +786,8 @@ async function handleRequestInvestigation(
ctx: ToolHandlerContext, ctx: ToolHandlerContext,
): Promise<unknown> { ): Promise<unknown> {
const kind = String(args.kind ?? "").trim(); const kind = String(args.kind ?? "").trim();
if (kind !== "hypothesis_tournament" && kind !== "evidence_chain") { if (kind !== "hypothesis_tournament" && kind !== "evidence_chain" && kind !== "contradiction_scan") {
return { error: "bad_kind", message: "kind must be hypothesis_tournament or evidence_chain" }; return { error: "bad_kind", message: "kind must be hypothesis_tournament, evidence_chain or contradiction_scan" };
} }
const docArg = typeof args.doc_id === "string" && args.doc_id.trim() const docArg = typeof args.doc_id === "string" && args.doc_id.trim()
? args.doc_id.trim() : ctx.doc_id || null; ? args.doc_id.trim() : ctx.doc_id || null;
@ -792,6 +800,12 @@ async function handleRequestInvestigation(
payload.question = question; payload.question = question;
payload.lang = lang; payload.lang = lang;
if (docArg) payload.doc_id = docArg; if (docArg) payload.doc_id = docArg;
} else if (kind === "contradiction_scan") {
const topic = String(args.topic ?? "").trim();
if (!topic) return { error: "topic_required", message: "contradiction_scan needs a topic" };
payload.topic = topic;
payload.lang = lang;
if (docArg) payload.doc_id = docArg;
} else { } else {
if (!docArg) return { error: "doc_id_required", message: "evidence_chain needs a doc_id" }; if (!docArg) return { error: "doc_id_required", message: "evidence_chain needs a doc_id" };
payload.doc_id = docArg; payload.doc_id = docArg;
@ -802,8 +816,8 @@ async function handleRequestInvestigation(
} }
const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous"; const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous";
// Investigation Bureau expected duration: Holmes ~60s, Locard ~30s × n_chunks. // Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Locard ~30s × n_chunks.
const eta = kind === "hypothesis_tournament" ? 60 : 30 * 5; const eta = kind === "evidence_chain" ? 30 * 5 : 60;
try { try {
const rows = await pgQuery<{ job_id: string; created_at: string }>( const rows = await pgQuery<{ job_id: string; created_at: string }>(
@ -821,7 +835,9 @@ async function handleRequestInvestigation(
eta_seconds: eta, eta_seconds: eta,
status_url: `/jobs/${row.job_id}`, status_url: `/jobs/${row.job_id}`,
payload_summary: payload, payload_summary: payload,
detective: kind === "hypothesis_tournament" ? "holmes" : "locard", detective: kind === "hypothesis_tournament" ? "holmes"
: kind === "contradiction_scan" ? "dupin"
: "locard",
}; };
} catch (e) { } catch (e) {
return { error: "db_unavailable", message: (e as Error).message }; return { error: "db_unavailable", message: (e as Error).message };