W3.8: Schneier red-team detective + /h/[hypothesisId] dossier page
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 33s
CI / Scripts — Python smoke (push) Failing after 7s
CI / Web — npm audit (push) Failing after 38s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s

Adds the fourth AI detective in the Investigation Bureau runtime: Bruce
Schneier, who attacks an existing hypothesis as a red-team operator.

Runtime:
  - prompts/schneier.md — discipline (don't disprove, just attack;
    structured output with hidden_assumptions, failure_modes,
    alternative_explanations, recommended_tests, verdict_one_sentence;
    severity ∈ {low, medium, high}; emit INSUFFICIENT_HYPOTHESIS when
    the input is too thin)
  - src/detectives/schneier.ts — reads the hypothesis row + evidence
    chain (joined via evidence_refs FK), feeds Claude with the
    arguments + verbatim quotes, parses strict JSON object
  - src/tools/write_red_team_review.ts — UPDATEs hypotheses.reviewed_by
    + updated_at; APPENDS (or replaces if re-reviewed) a structured
    "## Red-team review (Schneier · X severity)" section to
    case/hypotheses/H-NNNN.md. Caps each list at 5 entries × 240 chars,
    validates verdict ≤ 280 chars.
  - orchestrator: new `red_team_review` kind dispatching to runSchneier

Chat + UI:
  - request_investigation gains kind=red_team_review + hypothesis_id arg
    (validated against H-NNNN regex); detective auto-resolves to schneier
  - chat-bubble inline card paints Schneier in red (#ff3344)
  - /jobs/[id] page swaps title/subtitle/tone per detective; the
    "Question" label becomes "Hypothesis under attack" for red_team_review

New /h/[hypothesisId] page (hypothesis dossier):
  - Server-rendered from public.hypotheses + public.evidence (joined
    via evidence_refs FK + chunk lookup)
  - Header: ID + creator + reviewer (highlighted when Schneier has
    visited), position as headline, question subtitle, Tetlock band
  - Prior + posterior bars with Δ-delta indicator
  - Argument grid: argument_for (green) vs argument_against (pink)
    side-by-side with [[wiki-link]] auto-linking to source chunks
  - Evidence chain: each E-NNNN with Grade A/B/C badge, verbatim
    blockquote, link to source page
  - Red-team review panel: parses the markdown section in the case
    file (severity badge, verdict, 4 bullet panels for
    hidden_assumptions / failure_modes / alternative_explanations /
    recommended_tests). Empty state when not yet reviewed.

RedTeamRequestButton client component + POST /api/h/[id]/red-team —
authenticated user can trigger Schneier in one click; UI swaps to
"acompanhar" link to /jobs/[id] once queued.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luiz Gustavo 2026-05-23 21:48:12 -03:00
parent 25f19aee63
commit 857dd771d2
10 changed files with 1001 additions and 24 deletions

View file

@ -0,0 +1,63 @@
# You are Bruce Schneier
You are Bruce Schneier — security technologist and adversarial thinker. Given
a hypothesis presented as fact, your job is to **attack it** the way a
red-team operator attacks a system claim. You don't disprove the hypothesis;
you reveal the assumptions, failure modes, and unexplored alternatives that
keep it from being safely shipped as the final answer.
## Discipline (non-negotiable)
1. You read the hypothesis (question, position, argument_for, argument_against)
and the evidence chain backing it. You then produce a **structured attack**:
- `hidden_assumptions[]` — premises the hypothesis treats as given but
that an adversary could falsify. Each is one declarative sentence.
- `failure_modes[]` — concrete conditions under which the hypothesis
would collapse. "If chunk X turns out to be a forgery, the whole
argument fails."
- `alternative_explanations[]` — rival theories NOT addressed by the
existing argument_against. Each is one sentence.
- `recommended_tests[]` — what observation would discriminate between
the hypothesis and its rivals. "Compare the copper-particle Cu/Zn
ratio to known foundry-flare residues."
2. You do NOT argue for any particular alternative; you list them
adversarially.
3. You assign a `severity` flag:
- `high` — at least one hidden_assumption is genuinely unsupported by
the cited evidence, OR a failure mode is plausibly active. The
hypothesis is fragile.
- `medium` — assumptions are reasonable but not airtight; rivals exist
that the argument_against doesn't refute.
- `low` — the hypothesis is well-armored; your attacks are
hypothetical rather than active.
4. You produce a final `verdict_one_sentence`: a single declarative line
the case-writer can quote. ("This hypothesis is fragile under the
current evidence — three hidden assumptions remain unsupported and one
rival has not been engaged.")
5. You do NOT change priors or posteriors. You report; the chief-detective
decides whether to dispatch follow-up evidence work or downgrade the
confidence_band.
## Output protocol
Emit a strict JSON object. No prose. No code fence. Just the object.
```json
{
"severity": "low | medium | high",
"hidden_assumptions": ["sentence", "sentence"],
"failure_modes": ["sentence", "sentence"],
"alternative_explanations": ["sentence", "sentence"],
"recommended_tests": ["sentence", "sentence"],
"verdict_one_sentence": "..."
}
```
Constraints:
- 2-5 entries per array. Empty arrays only when the attack surface is
genuinely empty (rare).
- Each array entry ≤ 200 chars.
- `verdict_one_sentence` ≤ 280 chars.
If the input hypothesis is too thin to attack (e.g. position is one word,
no argument_for, no evidence), emit `INSUFFICIENT_HYPOTHESIS` and stop.

View file

@ -0,0 +1,195 @@
/**
* schneier.ts red-team review detective.
*
* Reads an existing hypothesis (DB row + evidence chain) and produces a
* structured attack: hidden assumptions, failure modes, alternative
* explanations, recommended discriminating tests, and a severity verdict.
*
* Workflow:
* 1. Fetch the hypothesis row by hypothesis_id (404 skipped).
* 2. Fetch evidence rows referenced via evidence_refs FK chain these
* give Schneier the actual quotes to attack. If empty, the prompt
* gets only the argument prose.
* 3. Call Claude with schneier.md system prompt; parse strict JSON.
* 4. Call writeRedTeamReview() which UPDATEs hypotheses.reviewed_by +
* appends a section to the H-NNNN.md case file.
*/
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 { writeRedTeamReview, type RedTeamReviewArgs } from "../tools/write_red_team_review";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "schneier.md");
export interface SchneierTask {
job_id: string;
hypothesis_id: string;
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;
}
interface EvidenceRow {
evidence_id: string;
grade: string;
verbatim_excerpt: string;
source_page_id: string;
confidence_band: string | null;
}
function buildPrompt(h: HypothesisRow, evidence: EvidenceRow[]): string {
const evBlock = evidence.length === 0
? "_(no evidence cataloged yet — work from the argument prose alone)_"
: evidence.map((e) =>
[
`--- ${e.evidence_id} (Grade ${e.grade})${e.confidence_band ? `, ${e.confidence_band}` : ""} ---`,
`source: ${e.source_page_id}`,
"",
`> ${e.verbatim_excerpt.slice(0, 600)}`,
].join("\n"),
).join("\n\n");
return [
`# Hypothesis under red-team review`,
"",
`**ID.** ${h.hypothesis_id}`,
`**Question.** ${h.question}`,
`**Position.** ${h.position}`,
`**Prior.** ${h.prior ?? "—"} · **Posterior.** ${h.posterior ?? "—"} · **Band.** ${h.confidence_band ?? "—"}`,
"",
"## Argument for (per author)",
h.argument_for || "_(none recorded)_",
"",
"## Argument against (per author)",
h.argument_against || "_(none recorded)_",
"",
"## Evidence chain",
evBlock,
"",
"## Your task",
"",
"Red-team the hypothesis. Find what the author didn't address. Emit the",
"JSON object exactly as specified by the system prompt — no prose, no",
"code fence, no preamble. If the hypothesis is too thin to attack,",
"emit the literal word `INSUFFICIENT_HYPOTHESIS`.",
].join("\n");
}
function extractJsonObject(text: string): Record<string, unknown> | null {
const t = text.trim();
if (t === "INSUFFICIENT_HYPOTHESIS") 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(`schneier 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("schneier JSON is not an object");
}
return parsed as Record<string, unknown>;
}
function coerceStringArray(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw.map((x) => typeof x === "string" ? x.trim() : "").filter((s) => s.length > 0);
}
export async function runSchneier(task: SchneierTask): Promise<
| { hypothesis_id: string; case_file: string; severity: 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
FROM public.hypotheses WHERE hypothesis_id = $1`,
[task.hypothesis_id],
);
if (!h) return { skipped: true, reason: "hypothesis_not_found" };
// Pull evidence chain. evidence_refs is a JSONB array of { evidence_id, ... }
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, verbatim_excerpt, source_page_id, confidence_band
FROM public.evidence WHERE evidence_id = ANY($1::text[]) ORDER BY evidence_id`,
[refIds],
)
: [];
await audit({
event: "schneier_grounded",
job_id: task.job_id,
detective: "schneier@detective",
hypothesis_id: h.hypothesis_id,
n_evidence: evidence.length,
});
const systemPrompt = await readFile(PROMPT_PATH, "utf-8");
const prompt = buildPrompt(h, evidence);
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: "schneier@detective",
cost_usd: llm.costUsd,
tokens_in: llm.tokensIn,
tokens_out: llm.tokensOut,
duration_ms: llm.durationMs,
});
console.error(`[schneier] response (${llm.text.length} chars): ${llm.text.slice(0, 800)}`);
const obj = extractJsonObject(llm.text);
if (obj === null) return { skipped: true, reason: "INSUFFICIENT_HYPOTHESIS" };
const severityRaw = typeof obj.severity === "string" ? obj.severity.toLowerCase() : "";
const severity: "low" | "medium" | "high" =
severityRaw === "high" ? "high" : severityRaw === "medium" ? "medium" : "low";
const args: RedTeamReviewArgs = {
hypothesis_id: h.hypothesis_id,
severity,
hidden_assumptions: coerceStringArray(obj.hidden_assumptions),
failure_modes: coerceStringArray(obj.failure_modes),
alternative_explanations: coerceStringArray(obj.alternative_explanations),
recommended_tests: coerceStringArray(obj.recommended_tests),
verdict_one_sentence: typeof obj.verdict_one_sentence === "string"
? obj.verdict_one_sentence.trim()
: "",
};
if (!args.verdict_one_sentence) {
return { skipped: true, reason: "no_verdict" };
}
return await writeRedTeamReview(args, {
job_id: task.job_id,
detective: "schneier@detective",
});
}

View file

@ -10,6 +10,7 @@ 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"; import { runDupin, type DupinTask } from "./detectives/dupin";
import { runSchneier, type SchneierTask } from "./detectives/schneier";
export interface InvestigationJob { export interface InvestigationJob {
job_id: string; job_id: string;
@ -68,6 +69,19 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise
} }
break; break;
} }
case "red_team_review": {
// Payload: { hypothesis_id }
const hyp = String(job.payload.hypothesis_id ?? "").trim();
if (!hyp) throw new Error("red_team_review requires payload.hypothesis_id");
const task: SchneierTask = { job_id: job.job_id, hypothesis_id: hyp };
const r = await runSchneier(task);
if ("skipped" in r) {
outputs.push({ kind: "red_team_review", skipped: true, reason: r.reason });
} else {
outputs.push({ kind: "red_team_review", ...r });
}
break;
}
case "contradiction_scan": { case "contradiction_scan": {
// Payload: { topic, doc_id?, lang?, context_chunks? } // Payload: { topic, doc_id?, lang?, context_chunks? }
const topic = String(job.payload.topic ?? "").trim(); const topic = String(job.payload.topic ?? "").trim();

View file

@ -0,0 +1,145 @@
/**
* write_red_team_review.ts Schneier's primary writer.
*
* UPDATEs public.hypotheses (sets reviewed_by + updated_at) and APPENDS a
* "## Red-team review (Schneier)" section to the H-NNNN.md case file.
*
* No new row is created a hypothesis can be red-teamed multiple times;
* each review appends a dated section to the case file and overwrites
* reviewed_by. The /h/[id] page reads the case file to display the latest
* review.
*/
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 RedTeamReviewArgs {
hypothesis_id: string;
severity: "low" | "medium" | "high";
hidden_assumptions: string[];
failure_modes: string[];
alternative_explanations: string[];
recommended_tests: string[];
verdict_one_sentence: string;
}
export interface RedTeamReviewContext {
job_id: string;
detective: string;
}
const SECTION_MARKER = "## Red-team review";
function buildSection(args: RedTeamReviewArgs, ctx: RedTeamReviewContext): string {
const ts = new Date().toISOString();
const bullets = (items: string[]): string =>
items.length === 0 ? "_(none flagged)_" : items.map((x) => `- ${x}`).join("\n");
return [
"",
`${SECTION_MARKER} (Schneier · ${args.severity} severity)`,
"",
`_Reviewed by ${ctx.detective} on ${ts} — job \`${ctx.job_id}\`._`,
"",
`**Verdict.** ${args.verdict_one_sentence}`,
"",
"### Hidden assumptions",
bullets(args.hidden_assumptions),
"",
"### Failure modes",
bullets(args.failure_modes),
"",
"### Alternative explanations not addressed",
bullets(args.alternative_explanations),
"",
"### Recommended discriminating tests",
bullets(args.recommended_tests),
"",
].join("\n");
}
/**
* If the case file already has a "## Red-team review" section, replace from
* that line through the end of the markdown body. Schneier reviews are
* idempotent most recent overwrites the previous.
*/
function appendOrReplace(existing: string, section: string): string {
const idx = existing.indexOf(`\n${SECTION_MARKER}`);
if (idx === -1) return existing.trimEnd() + "\n" + section;
return existing.slice(0, idx).trimEnd() + "\n" + section;
}
export async function writeRedTeamReview(
body: RedTeamReviewArgs,
ctx: RedTeamReviewContext,
): Promise<{ hypothesis_id: string; case_file: string; severity: string }> {
if (!body.hypothesis_id?.match(/^H-\d{4}$/)) {
throw new Error(`bad hypothesis_id: ${body.hypothesis_id}`);
}
if (body.severity !== "low" && body.severity !== "medium" && body.severity !== "high") {
throw new Error(`bad severity: ${body.severity}`);
}
if (!body.verdict_one_sentence?.trim()) throw new Error("verdict_one_sentence required");
if (body.verdict_one_sentence.length > 280) {
throw new Error(`verdict too long (${body.verdict_one_sentence.length} > 280)`);
}
// Defensive: cap each array to 5 entries × 240 chars (prompt says ≤ 200 but
// we leave some slack rather than truncate silently).
const cap = (items: unknown): string[] =>
Array.isArray(items)
? items
.map((x) => typeof x === "string" ? x.trim() : "")
.filter((s) => s.length > 0 && s.length <= 240)
.slice(0, 5)
: [];
const hidden = cap(body.hidden_assumptions);
const fail = cap(body.failure_modes);
const alt = cap(body.alternative_explanations);
const tests = cap(body.recommended_tests);
// Verify hypothesis exists.
const h = await queryOne<{ hypothesis_id: string }>(
`SELECT hypothesis_id FROM public.hypotheses WHERE hypothesis_id = $1`,
[body.hypothesis_id],
);
if (!h) throw new Error(`hypothesis not found: ${body.hypothesis_id}`);
// UPDATE DB.
await query(
`UPDATE public.hypotheses
SET reviewed_by = $1, updated_at = NOW()
WHERE hypothesis_id = $2`,
[ctx.detective, body.hypothesis_id],
);
// Append / replace red-team section in 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 merged = { ...body, hidden_assumptions: hidden, failure_modes: fail, alternative_explanations: alt, recommended_tests: tests };
const section = buildSection(merged, ctx);
const next = appendOrReplace(existing, section);
await writeFile(file, next, "utf-8");
await audit({
event: "write_red_team_review",
job_id: ctx.job_id,
detective: ctx.detective,
hypothesis_id: body.hypothesis_id,
severity: body.severity,
n_assumptions: hidden.length,
n_failure_modes: fail.length,
n_alternatives: alt.length,
n_tests: tests.length,
file,
});
return { hypothesis_id: body.hypothesis_id, case_file: file, severity: body.severity };
}

View file

@ -0,0 +1,61 @@
/**
* POST /api/h/[hypothesisId]/red-team enqueue a Schneier red_team_review
* investigation job for the given hypothesis. Returns { job_id, status_url }.
*
* Requires an authenticated user (any role). Audit captures the requester's
* email in triggered_by so the runtime can attribute the cost.
*/
import { NextResponse } from "next/server";
import { pgQuery } from "@/lib/retrieval/db";
import { createClient, isSupabaseConfigured } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
_request: Request,
ctx: { params: Promise<{ hypothesisId: string }> },
) {
const { hypothesisId } = await ctx.params;
if (!/^H-\d{4}$/.test(hypothesisId)) {
return NextResponse.json({ error: "bad_hypothesis_id" }, { status: 400 });
}
let triggered_by = "user:anonymous";
if (isSupabaseConfigured()) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
}
triggered_by = `user:${user.email ?? user.id}`;
}
// Verify hypothesis exists.
const found = await pgQuery<{ hypothesis_id: string }>(
`SELECT hypothesis_id FROM public.hypotheses WHERE hypothesis_id = $1`,
[hypothesisId],
).catch(() => []);
if (found.length === 0) {
return NextResponse.json({ error: "not_found", hypothesis_id: hypothesisId }, { status: 404 });
}
try {
const rows = await pgQuery<{ job_id: string }>(
`INSERT INTO public.investigation_jobs (kind, payload, triggered_by, status)
VALUES ('red_team_review', $1::jsonb, $2, 'queued')
RETURNING job_id`,
[JSON.stringify({ hypothesis_id: hypothesisId }), triggered_by],
);
const job_id = rows[0]?.job_id;
if (!job_id) return NextResponse.json({ error: "insert_failed" }, { status: 500 });
return NextResponse.json({
job_id,
status: "queued",
status_url: `/jobs/${job_id}`,
eta_seconds: 30,
});
} catch (e) {
return NextResponse.json({ error: "db_unavailable", message: (e as Error).message }, { status: 503 });
}
}

View file

@ -0,0 +1,411 @@
/**
* /h/[hypothesisId] Hypothesis dossier.
*
* Renders one H-NNNN as a full case file:
* - question + position
* - prior + posterior with delta + Tetlock band
* - argument_for vs argument_against side-by-side (responsive)
* - evidence chain cards (each E-NNNN supporting/refuting)
* - red-team review (Schneier) from the .md case file, parsed live
*
* Adjacent action panel: "Red-team this hypothesis" POST to chat or
* direct enqueue to investigation_jobs.
*/
import { notFound } from "next/navigation";
import Link from "next/link";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pgQuery } from "@/lib/retrieval/db";
import { AuthBar } from "@/components/auth-bar";
import { RedTeamRequestButton } from "@/components/red-team-request-button";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
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;
evidence_refs: unknown;
created_by: string;
reviewed_by: string | null;
created_at: string;
updated_at: string;
}
interface EvidenceRow {
evidence_id: string;
grade: string;
source_page_id: string;
doc_id: string | null;
page: number | null;
chunk_id: string | null;
verbatim_excerpt: string | null;
confidence_band: string | null;
}
const BAND_COLOR: Record<string, string> = {
high: "text-[#06d6a0] border-[#06d6a0]",
medium: "text-[#3fde6a] border-[#3fde6a]",
low: "text-[#ffa500] border-[#ffa500]",
speculation: "text-[#ff6ec7] border-[#ff6ec7]",
};
const GRADE_COLOR: Record<string, string> = {
A: "text-[#06d6a0] border-[#06d6a0]",
B: "text-[#3fde6a] border-[#3fde6a]",
C: "text-[#ffa500] border-[#ffa500]",
};
const SEVERITY_COLOR: Record<string, string> = {
high: "text-[#ff3344] border-[#ff3344]",
medium: "text-[#ff8a4d] border-[#ff8a4d]",
low: "text-[#9aa6b8] border-[#9aa6b8]",
};
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;
}
const CASE_ROOT = process.env.CASE_ROOT || "/data/ufo/case";
interface ParsedRedTeam {
severity: "low" | "medium" | "high" | null;
reviewer: string | null;
reviewed_at: string | null;
job_id: string | null;
verdict: string | null;
hidden_assumptions: string[];
failure_modes: string[];
alternative_explanations: string[];
recommended_tests: string[];
}
/**
* Parse the "## Red-team review (Schneier · X severity)" section from a
* hypothesis case file. The section is structured (we wrote it ourselves
* in write_red_team_review.ts), so we can extract with light regex.
*/
function parseRedTeam(md: string): ParsedRedTeam | null {
const header = md.match(/##\s+Red-team review\s+\(([^)\n]+)\)/i);
if (!header) return null;
const meta = header[1];
const severityMatch = meta.match(/\b(low|medium|high)\b\s+severity/i);
const severity = severityMatch ? (severityMatch[1].toLowerCase() as "low" | "medium" | "high") : null;
const section = md.slice(header.index ?? 0);
// _Reviewed by X on Y — job `Z`._
const review = section.match(/Reviewed by\s+([^\n]+?)\s+on\s+([^\n—]+?)\s*—\s*job\s+`([^`]+)`/);
const reviewer = review?.[1]?.trim() ?? null;
const reviewedAt = review?.[2]?.trim() ?? null;
const jobId = review?.[3]?.trim() ?? null;
const verdictMatch = section.match(/\*\*Verdict\.\*\*\s+([^\n]+)/);
const verdict = verdictMatch?.[1]?.trim() ?? null;
function pickSection(name: string): string[] {
const re = new RegExp(`###\\s+${name}([\\s\\S]+?)(?=\\n###|\\n##|$)`, "i");
const m = section.match(re);
if (!m) return [];
const body = m[1];
if (/_\(none flagged\)_/i.test(body)) return [];
return body
.split("\n")
.map((l) => l.trim())
.filter((l) => l.startsWith("- "))
.map((l) => l.slice(2).trim())
.filter((l) => l.length > 0);
}
return {
severity,
reviewer,
reviewed_at: reviewedAt,
job_id: jobId,
verdict,
hidden_assumptions: pickSection("Hidden assumptions"),
failure_modes: pickSection("Failure modes"),
alternative_explanations: pickSection("Alternative explanations not addressed"),
recommended_tests: pickSection("Recommended discriminating tests"),
};
}
export default async function HypothesisPage({
params,
}: { params: Promise<{ hypothesisId: string }> }) {
const { hypothesisId } = await params;
if (!/^H-\d{4}$/.test(hypothesisId)) notFound();
const rows = await pgQuery<HypothesisRow>(
`SELECT hypothesis_id, question, position, argument_for, argument_against,
prior, posterior, confidence_band, status, evidence_refs,
created_by, reviewed_by, created_at, updated_at
FROM public.hypotheses WHERE hypothesis_id = $1`,
[hypothesisId],
).catch(() => [] as HypothesisRow[]);
const h = rows[0];
if (!h) notFound();
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: EvidenceRow[] = refIds.length > 0
? await pgQuery<EvidenceRow>(
`SELECT e.evidence_id, e.grade, e.source_page_id,
split_part(e.source_page_id, '/p', 1) AS doc_id,
NULLIF(split_part(e.source_page_id, '/p', 2), '')::int AS page,
c.chunk_id, e.verbatim_excerpt, e.confidence_band
FROM public.evidence e
LEFT JOIN public.chunks c ON c.chunk_pk = e.source_chunk_pk
WHERE e.evidence_id = ANY($1::text[])
ORDER BY e.evidence_id`,
[refIds],
).catch(() => [] as EvidenceRow[])
: [];
let redTeam: ParsedRedTeam | null = null;
try {
const file = path.join(CASE_ROOT, "hypotheses", `${hypothesisId}.md`);
const md = await readFile(file, "utf-8");
redTeam = parseRedTeam(md);
} catch {
redTeam = null;
}
const prior = asNumber(h.prior);
const posterior = asNumber(h.posterior);
const delta = prior !== null && posterior !== null ? posterior - prior : null;
const bandTone = (h.confidence_band && BAND_COLOR[h.confidence_band]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<AuthBar />
<div className="mx-auto max-w-5xl 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>hypothesis</span>
<span className="mx-1">/</span>
<span className="text-[#7fdbff]">{hypothesisId}</span>
</div>
{/* Header */}
<div className="rounded-lg border border-[rgba(127,219,255,0.18)] bg-gradient-to-br from-[rgba(127,219,255,0.08)] to-transparent p-5">
<div className="flex items-baseline justify-between gap-4 flex-wrap">
<div>
<div className="text-[10px] font-mono text-[#5a6678] uppercase mb-1">
{hypothesisId} · created by {h.created_by}
{h.reviewed_by && <> · reviewed by <span className="text-[#ff3344]">{h.reviewed_by}</span></>}
</div>
<h1 className="text-xl font-mono text-[#e7ecf3] leading-snug">{h.position}</h1>
<p className="text-[12px] text-[#9aa6b8] mt-1">Question: {h.question}</p>
</div>
{h.confidence_band && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}>
{h.confidence_band}
</span>
)}
</div>
{/* Probability bars */}
{(prior !== null || posterior !== null) && (
<div className="mt-4 grid grid-cols-2 gap-3 text-[11px] font-mono">
<Bar label="prior" value={prior} color="#5a6678" />
<Bar label="posterior" value={posterior} color="#7fdbff" />
</div>
)}
{delta !== null && (
<div className="mt-2 text-[10px] font-mono text-[#5a6678]">
Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)} ·{" "}
{delta > 0.05 ? <span className="text-[#06d6a0]">evidência reforçou</span> :
delta < -0.05 ? <span className="text-[#ff6ec7]">evidência reduziu</span> :
<span className="text-[#9aa6b8]">evidência ambígua</span>}
</div>
)}
</div>
{/* Argument grid */}
<div className="mt-4 grid md:grid-cols-2 gap-3">
<ArgumentPanel kind="for" body={h.argument_for} />
<ArgumentPanel kind="against" body={h.argument_against} />
</div>
{/* Evidence chain */}
{evidence.length > 0 && (
<div className="mt-6">
<div className="text-[12px] font-mono text-[#06d6a0] uppercase tracking-wider mb-2">
Cadeia de evidência ({evidence.length})
</div>
<div className="space-y-3">
{evidence.map((e) => <EvidenceMini key={e.evidence_id} e={e} />)}
</div>
</div>
)}
{/* Red-team review */}
<div className="mt-6">
<div className="flex items-baseline justify-between mb-2 flex-wrap gap-2">
<div className="text-[12px] font-mono text-[#ff3344] uppercase tracking-wider">
Revisão Red Team (Schneier)
</div>
<RedTeamRequestButton hypothesisId={hypothesisId} alreadyReviewed={Boolean(redTeam)} />
</div>
{redTeam ? <RedTeamPanel rt={redTeam} /> : (
<div className="rounded-lg border border-dashed border-[rgba(255,51,68,0.18)] bg-[#0d1220] p-4 text-[12px] font-mono text-[#9aa6b8]">
Nenhuma revisão red-team ainda. Click acima para acionar Schneier.
</div>
)}
</div>
</div>
</div>
);
}
function ArgumentPanel({ kind, body }: { kind: "for" | "against"; body: string | null }) {
const tone = kind === "for" ? "border-[#06d6a0] text-[#06d6a0]" : "border-[#ff6ec7] text-[#ff6ec7]";
const label = kind === "for" ? "Argumento a favor" : "Argumento contra";
return (
<div className={`rounded-lg border ${tone} bg-[#0d1220] p-4`}>
<div className={`text-[10px] font-mono uppercase mb-2 ${tone}`}>{label}</div>
<ArgumentBody text={body ?? ""} />
</div>
);
}
function EvidenceMini({ e }: { e: EvidenceRow }) {
const gradeTone = (e.grade && GRADE_COLOR[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]";
const bandTone = (e.confidence_band && BAND_COLOR[e.confidence_band]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<div className="rounded-lg border border-[rgba(6,214,160,0.18)] bg-[#0d1220] p-3">
<div className="flex items-baseline justify-between gap-2 mb-1">
<div className="text-[10px] font-mono text-[#5a6678] uppercase">{e.evidence_id}</div>
<div className="flex items-center gap-2">
{e.grade && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${gradeTone}`}>
Grade {e.grade}
</span>
)}
{e.confidence_band && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}>
{e.confidence_band}
</span>
)}
</div>
</div>
{e.verbatim_excerpt && (
<blockquote className="text-[12px] text-[#e7ecf3] italic border-l-2 border-[#06d6a0] pl-2 my-1">
{e.verbatim_excerpt.slice(0, 360)}
</blockquote>
)}
{e.doc_id && e.page && (
<Link
href={`/d/${e.doc_id}/p${String(e.page).padStart(3, "0")}${e.chunk_id ? `#${e.chunk_id}` : ""}`}
className="text-[10px] font-mono text-[#7fdbff] hover:underline"
>
{e.doc_id}/p{String(e.page).padStart(3, "0")}{e.chunk_id ? `#${e.chunk_id}` : ""}
</Link>
)}
</div>
);
}
function RedTeamPanel({ rt }: { rt: ParsedRedTeam }) {
const tone = (rt.severity && SEVERITY_COLOR[rt.severity]) || "text-[#9aa6b8] border-[#9aa6b8]";
return (
<div className="rounded-lg border border-[rgba(255,51,68,0.18)] bg-[#0d1220] p-4 space-y-3">
<div className="flex items-baseline justify-between gap-2 flex-wrap">
<div className="text-[10px] font-mono text-[#5a6678]">
{rt.reviewer ?? "—"}{rt.reviewed_at ? ` · ${rt.reviewed_at}` : ""}{rt.job_id ? ` · job ${rt.job_id.slice(0, 8)}` : ""}
</div>
{rt.severity && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${tone}`}>
{rt.severity} severity
</span>
)}
</div>
{rt.verdict && (
<blockquote className="text-[14px] text-[#e7ecf3] italic border-l-2 border-[#ff3344] pl-3">
{rt.verdict}
</blockquote>
)}
<div className="grid md:grid-cols-2 gap-3">
<BulletPanel title="Hidden assumptions" items={rt.hidden_assumptions} color="text-[#ff8a4d]" />
<BulletPanel title="Failure modes" items={rt.failure_modes} color="text-[#ff3344]" />
<BulletPanel title="Alternative explanations" items={rt.alternative_explanations} color="text-[#ff6ec7]" />
<BulletPanel title="Recommended tests" items={rt.recommended_tests} color="text-[#7fdbff]" />
</div>
</div>
);
}
function BulletPanel({ title, items, color }: { title: string; items: string[]; color: string }) {
return (
<div className="rounded border border-[rgba(255,51,68,0.1)] bg-[#060a13] p-3">
<div className={`text-[10px] font-mono uppercase mb-1 ${color}`}>{title}</div>
{items.length === 0 ? (
<div className="text-[11px] font-mono text-[#5a6678]">_(none flagged)_</div>
) : (
<ul className="text-[11px] text-[#cbd2dd] leading-relaxed list-disc pl-4 space-y-1">
{items.map((x, i) => <li key={i}>{x}</li>)}
</ul>
)}
</div>
);
}
function Bar({ label, value, color }: { label: string; value: number | null; color: string }) {
const pct = value !== null ? Math.round(value * 100) : 0;
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[#5a6678]">{label}</span>
<span className="text-[#e7ecf3]">{value !== null ? value.toFixed(3) : "—"}</span>
</div>
<div className="h-1.5 bg-[rgba(127,219,255,0.08)] rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: color }} />
</div>
</div>
);
}
function ArgumentBody({ text }: { text: string }) {
if (!text.trim()) return <p className="text-[12px] text-[#5a6678]">_(none recorded)_</p>;
const parts: Array<{ kind: "text" | "link"; raw: string; href?: string; label?: string }> = [];
const re = /\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g;
let lastIdx = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > lastIdx) parts.push({ kind: "text", raw: text.slice(lastIdx, m.index) });
const target = m[1];
const label = m[2] ?? m[1];
let href: string | undefined;
const chunkMatch = target.match(/^([a-z0-9][a-z0-9-]*)\/p(\d{3})#(c\d{4})$/);
const pageMatch = target.match(/^([a-z0-9][a-z0-9-]*)\/p(\d{3})$/);
if (chunkMatch) href = `/d/${chunkMatch[1]}/p${chunkMatch[2]}#${chunkMatch[3]}`;
else if (pageMatch) href = `/d/${pageMatch[1]}/p${pageMatch[2]}`;
parts.push({ kind: "link", raw: m[0], href, label });
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) parts.push({ kind: "text", raw: text.slice(lastIdx) });
return (
<div className="text-[13px] text-[#cbd2dd] leading-relaxed whitespace-pre-wrap">
{parts.map((p, i) =>
p.kind === "text" ? <span key={i}>{p.raw}</span> :
p.href ? <Link key={i} href={p.href} className="text-[#7fdbff] hover:underline">{p.label}</Link> :
<span key={i} className="text-[#5a6678]">{p.label}</span>
)}
</div>
);
}

View file

@ -53,26 +53,34 @@ export default async function JobPage({
const detective = job.kind === "hypothesis_tournament" ? "holmes" const detective = job.kind === "hypothesis_tournament" ? "holmes"
: job.kind === "contradiction_scan" ? "dupin" : job.kind === "contradiction_scan" ? "dupin"
: job.kind === "red_team_review" ? "schneier"
: "locard"; : "locard";
const detectiveName = const detectiveName =
detective === "holmes" ? "Sherlock Holmes" : detective === "holmes" ? "Sherlock Holmes" :
detective === "dupin" ? "C. Auguste Dupin" : detective === "dupin" ? "C. Auguste Dupin" :
detective === "schneier" ? "Bruce Schneier" :
"Edmond Locard"; "Edmond Locard";
const detectiveSubtitle = const detectiveSubtitle =
detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" : detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" :
detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" : detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" :
detective === "schneier" ? "Red-team review · hidden assumptions, failure modes, alt explanations" :
"Evidence chain · verbatim quotes with chain of custody (Locard)"; "Evidence chain · verbatim quotes with chain of custody (Locard)";
const detectiveTone = const detectiveTone =
detective === "holmes" ? "text-[#7fdbff]" : detective === "holmes" ? "text-[#7fdbff]" :
detective === "dupin" ? "text-[#ff8a4d]" : detective === "dupin" ? "text-[#ff8a4d]" :
detective === "schneier" ? "text-[#ff3344]" :
"text-[#06d6a0]"; "text-[#06d6a0]";
const detectiveBg = const detectiveBg =
detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" : detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" :
detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" : detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" :
detective === "schneier" ? "from-[rgba(255,51,68,0.08)]" :
"from-[rgba(6,214,160,0.08)]"; "from-[rgba(6,214,160,0.08)]";
const payload = (job.payload ?? {}) as Record<string, unknown>; const payload = (job.payload ?? {}) as Record<string, unknown>;
const question = (payload.question ?? payload.topic) as string | undefined; const question = (payload.question ?? payload.topic ?? payload.hypothesis_id) as string | undefined;
const questionLabel = job.kind === "contradiction_scan" ? "Topic" : "Question"; const questionLabel =
job.kind === "contradiction_scan" ? "Topic" :
job.kind === "red_team_review" ? "Hypothesis under attack" :
"Question";
const docId = payload.doc_id as string | undefined; const docId = payload.doc_id as string | undefined;
return ( return (

View file

@ -686,11 +686,13 @@ function ToolTrace({ t }: { t: ToolBlock }) {
} }
const detective = r.detective ?? ( const detective = r.detective ?? (
r.kind === "hypothesis_tournament" ? "holmes" : r.kind === "hypothesis_tournament" ? "holmes" :
r.kind === "contradiction_scan" ? "dupin" : "locard" r.kind === "contradiction_scan" ? "dupin" :
r.kind === "red_team_review" ? "schneier" : "locard"
); );
const tone = const tone =
detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } : detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } :
detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } : detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } :
detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } :
{ text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" }; { text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" };
return ( return (
<div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}> <div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}>

View file

@ -0,0 +1,63 @@
"use client";
/**
* RedTeamRequestButton POSTs to /api/h/[id]/red-team to enqueue a Schneier
* red_team_review job. Shows the job_id + link to /jobs/[id] once submitted.
*/
import { useState } from "react";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
export function RedTeamRequestButton({
hypothesisId,
alreadyReviewed,
}: { hypothesisId: string; alreadyReviewed: boolean }) {
const [pending, setPending] = useState(false);
const [jobId, setJobId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function requestReview() {
setPending(true);
setError(null);
try {
const r = await fetch(`/api/h/${hypothesisId}/red-team`, { method: "POST" });
const d = (await r.json()) as { job_id?: string; error?: string; message?: string };
if (!r.ok || !d.job_id) {
setError(d.error || d.message || `HTTP ${r.status}`);
return;
}
setJobId(d.job_id);
} catch (e) {
setError((e as Error).message);
} finally {
setPending(false);
}
}
if (jobId) {
return (
<Link
href={`/jobs/${jobId}`}
target="_blank"
className="inline-flex items-center gap-1 text-[11px] font-mono text-[#ff3344] hover:underline"
>
Schneier em ação acompanhar <ArrowUpRight size={11} />
</Link>
);
}
return (
<div className="flex items-center gap-2">
<button
onClick={requestReview}
disabled={pending}
className="px-3 py-1 rounded border border-[#ff3344] text-[11px] font-mono text-[#ff3344] hover:bg-[rgba(255,51,68,0.06)] disabled:opacity-40 disabled:cursor-not-allowed"
>
{pending ? "Enfileirando…" : alreadyReviewed ? "Re-revisar (Schneier)" : "Acionar Schneier (red-team)"}
</button>
{error && (
<span className="text-[10px] font-mono text-[#ff6ec7]">{error}</span>
)}
</div>
);
}

View file

@ -363,7 +363,8 @@ const request_investigation_tool: ToolDefinition = {
"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). " + "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). " +
"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: {
@ -371,9 +372,15 @@ const request_investigation_tool: ToolDefinition = {
properties: { properties: {
kind: { kind: {
type: "string", type: "string",
enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan"], enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan", "red_team_review"],
description: "Detective task kind.", description: "Detective task kind.",
}, },
hypothesis_id: {
type: "string",
description:
"For red_team_review: REQUIRED. The H-NNNN id of an existing hypothesis to attack. " +
"Ignored for the other kinds.",
},
question: { question: {
type: "string", type: "string",
description: description:
@ -786,8 +793,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" && kind !== "contradiction_scan") { 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 or contradiction_scan" }; return { error: "bad_kind", message: "kind must be hypothesis_tournament, evidence_chain, contradiction_scan or red_team_review" };
} }
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;
@ -806,6 +813,12 @@ async function handleRequestInvestigation(
payload.topic = topic; payload.topic = topic;
payload.lang = lang; payload.lang = lang;
if (docArg) payload.doc_id = docArg; if (docArg) payload.doc_id = docArg;
} else if (kind === "red_team_review") {
const hyp = String(args.hypothesis_id ?? "").trim();
if (!/^H-\d{4}$/.test(hyp)) {
return { error: "hypothesis_id_required", message: "red_team_review needs hypothesis_id like H-0003" };
}
payload.hypothesis_id = hyp;
} 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;
@ -816,8 +829,9 @@ async function handleRequestInvestigation(
} }
const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous"; const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous";
// Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Locard ~30s × n_chunks. // Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Schneier ~30s,
const eta = kind === "evidence_chain" ? 30 * 5 : 60; // Locard ~30s × n_chunks (default 5).
const eta = kind === "evidence_chain" ? 30 * 5 : kind === "red_team_review" ? 30 : 60;
try { try {
const rows = await pgQuery<{ job_id: string; created_at: string }>( const rows = await pgQuery<{ job_id: string; created_at: string }>(
@ -837,6 +851,7 @@ async function handleRequestInvestigation(
payload_summary: payload, payload_summary: payload,
detective: kind === "hypothesis_tournament" ? "holmes" detective: kind === "hypothesis_tournament" ? "holmes"
: kind === "contradiction_scan" ? "dupin" : kind === "contradiction_scan" ? "dupin"
: kind === "red_team_review" ? "schneier"
: "locard", : "locard",
}; };
} catch (e) { } catch (e) {