From 857dd771d2c0867653ee7e2a5ccd77fc97310cef Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Date: Sat, 23 May 2026 21:48:12 -0300 Subject: [PATCH] W3.8: Schneier red-team detective + /h/[hypothesisId] dossier page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- investigator-runtime/prompts/schneier.md | 63 +++ .../src/detectives/schneier.ts | 195 +++++++++ investigator-runtime/src/orchestrator.ts | 14 + .../src/tools/write_red_team_review.ts | 145 ++++++ .../api/h/[hypothesisId]/red-team/route.ts | 61 +++ web/app/h/[hypothesisId]/page.tsx | 411 ++++++++++++++++++ web/app/jobs/[id]/page.tsx | 36 +- web/components/chat-bubble.tsx | 10 +- web/components/red-team-request-button.tsx | 63 +++ web/lib/chat/tools.ts | 27 +- 10 files changed, 1001 insertions(+), 24 deletions(-) create mode 100644 investigator-runtime/prompts/schneier.md create mode 100644 investigator-runtime/src/detectives/schneier.ts create mode 100644 investigator-runtime/src/tools/write_red_team_review.ts create mode 100644 web/app/api/h/[hypothesisId]/red-team/route.ts create mode 100644 web/app/h/[hypothesisId]/page.tsx create mode 100644 web/components/red-team-request-button.tsx diff --git a/investigator-runtime/prompts/schneier.md b/investigator-runtime/prompts/schneier.md new file mode 100644 index 0000000..3147321 --- /dev/null +++ b/investigator-runtime/prompts/schneier.md @@ -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. diff --git a/investigator-runtime/src/detectives/schneier.ts b/investigator-runtime/src/detectives/schneier.ts new file mode 100644 index 0000000..b523257 --- /dev/null +++ b/investigator-runtime/src/detectives/schneier.ts @@ -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 | 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; +} + +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( + `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>) { + if (typeof r?.evidence_id === "string") refIds.push(r.evidence_id); + } + } + const evidence = refIds.length > 0 + ? await query( + `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", + }); +} diff --git a/investigator-runtime/src/orchestrator.ts b/investigator-runtime/src/orchestrator.ts index 6dd41da..6a29074 100644 --- a/investigator-runtime/src/orchestrator.ts +++ b/investigator-runtime/src/orchestrator.ts @@ -10,6 +10,7 @@ import { query } from "./lib/pg"; 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"; export interface InvestigationJob { job_id: string; @@ -68,6 +69,19 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise } 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": { // Payload: { topic, doc_id?, lang?, context_chunks? } const topic = String(job.payload.topic ?? "").trim(); diff --git a/investigator-runtime/src/tools/write_red_team_review.ts b/investigator-runtime/src/tools/write_red_team_review.ts new file mode 100644 index 0000000..83d5e83 --- /dev/null +++ b/investigator-runtime/src/tools/write_red_team_review.ts @@ -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 }; +} diff --git a/web/app/api/h/[hypothesisId]/red-team/route.ts b/web/app/api/h/[hypothesisId]/red-team/route.ts new file mode 100644 index 0000000..7b08a6a --- /dev/null +++ b/web/app/api/h/[hypothesisId]/red-team/route.ts @@ -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 }); + } +} diff --git a/web/app/h/[hypothesisId]/page.tsx b/web/app/h/[hypothesisId]/page.tsx new file mode 100644 index 0000000..5b74f8c --- /dev/null +++ b/web/app/h/[hypothesisId]/page.tsx @@ -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 = { + high: "text-[#06d6a0] border-[#06d6a0]", + medium: "text-[#3fde6a] border-[#3fde6a]", + low: "text-[#ffa500] border-[#ffa500]", + speculation: "text-[#ff6ec7] border-[#ff6ec7]", +}; + +const GRADE_COLOR: Record = { + A: "text-[#06d6a0] border-[#06d6a0]", + B: "text-[#3fde6a] border-[#3fde6a]", + C: "text-[#ffa500] border-[#ffa500]", +}; + +const SEVERITY_COLOR: Record = { + 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( + `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>) { + if (typeof r?.evidence_id === "string") refIds.push(r.evidence_id); + } + } + const evidence: EvidenceRow[] = refIds.length > 0 + ? await pgQuery( + `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 ( +
+ +
+
+ disclosure.top + / + hypothesis + / + {hypothesisId} +
+ + {/* Header */} +
+
+
+
+ {hypothesisId} · created by {h.created_by} + {h.reviewed_by && <> · reviewed by {h.reviewed_by}} +
+

{h.position}

+

Question: {h.question}

+
+ {h.confidence_band && ( + + {h.confidence_band} + + )} +
+ + {/* Probability bars */} + {(prior !== null || posterior !== null) && ( +
+ + +
+ )} + {delta !== null && ( +
+ Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)} ·{" "} + {delta > 0.05 ? evidência reforçou : + delta < -0.05 ? evidência reduziu : + evidência ambígua} +
+ )} +
+ + {/* Argument grid */} +
+ + +
+ + {/* Evidence chain */} + {evidence.length > 0 && ( +
+
+ Cadeia de evidência ({evidence.length}) +
+
+ {evidence.map((e) => )} +
+
+ )} + + {/* Red-team review */} +
+
+
+ Revisão Red Team (Schneier) +
+ +
+ {redTeam ? : ( +
+ Nenhuma revisão red-team ainda. Click acima para acionar Schneier. +
+ )} +
+
+
+ ); +} + +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 ( +
+
{label}
+ +
+ ); +} + +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 ( +
+
+
{e.evidence_id}
+
+ {e.grade && ( + + Grade {e.grade} + + )} + {e.confidence_band && ( + + {e.confidence_band} + + )} +
+
+ {e.verbatim_excerpt && ( +
+ “{e.verbatim_excerpt.slice(0, 360)}” +
+ )} + {e.doc_id && e.page && ( + + {e.doc_id}/p{String(e.page).padStart(3, "0")}{e.chunk_id ? `#${e.chunk_id}` : ""} + + )} +
+ ); +} + +function RedTeamPanel({ rt }: { rt: ParsedRedTeam }) { + const tone = (rt.severity && SEVERITY_COLOR[rt.severity]) || "text-[#9aa6b8] border-[#9aa6b8]"; + return ( +
+
+
+ {rt.reviewer ?? "—"}{rt.reviewed_at ? ` · ${rt.reviewed_at}` : ""}{rt.job_id ? ` · job ${rt.job_id.slice(0, 8)}…` : ""} +
+ {rt.severity && ( + + {rt.severity} severity + + )} +
+ {rt.verdict && ( +
+ {rt.verdict} +
+ )} +
+ + + + +
+
+ ); +} + +function BulletPanel({ title, items, color }: { title: string; items: string[]; color: string }) { + return ( +
+
{title}
+ {items.length === 0 ? ( +
_(none flagged)_
+ ) : ( +
    + {items.map((x, i) =>
  • {x}
  • )} +
+ )} +
+ ); +} + +function Bar({ label, value, color }: { label: string; value: number | null; color: string }) { + const pct = value !== null ? Math.round(value * 100) : 0; + return ( +
+
+ {label} + {value !== null ? value.toFixed(3) : "—"} +
+
+
+
+
+ ); +} + +function ArgumentBody({ text }: { text: string }) { + if (!text.trim()) return

_(none recorded)_

; + 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 ( +
+ {parts.map((p, i) => + p.kind === "text" ? {p.raw} : + p.href ? {p.label} : + {p.label} + )} +
+ ); +} diff --git a/web/app/jobs/[id]/page.tsx b/web/app/jobs/[id]/page.tsx index 23ce599..65fb1d9 100644 --- a/web/app/jobs/[id]/page.tsx +++ b/web/app/jobs/[id]/page.tsx @@ -53,26 +53,34 @@ export default async function JobPage({ const detective = job.kind === "hypothesis_tournament" ? "holmes" : job.kind === "contradiction_scan" ? "dupin" + : job.kind === "red_team_review" ? "schneier" : "locard"; const detectiveName = - detective === "holmes" ? "Sherlock Holmes" : - detective === "dupin" ? "C. Auguste Dupin" : - "Edmond Locard"; + detective === "holmes" ? "Sherlock Holmes" : + detective === "dupin" ? "C. Auguste Dupin" : + detective === "schneier" ? "Bruce Schneier" : + "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)"; + detective === "holmes" ? "Hypothesis tournament · rival hypotheses with Bayesian update" : + detective === "dupin" ? "Contradiction scan · pairs of chunks in irreconcilable tension" : + detective === "schneier" ? "Red-team review · hidden assumptions, failure modes, alt explanations" : + "Evidence chain · verbatim quotes with chain of custody (Locard)"; const detectiveTone = - detective === "holmes" ? "text-[#7fdbff]" : - detective === "dupin" ? "text-[#ff8a4d]" : - "text-[#06d6a0]"; + detective === "holmes" ? "text-[#7fdbff]" : + detective === "dupin" ? "text-[#ff8a4d]" : + detective === "schneier" ? "text-[#ff3344]" : + "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)]"; + detective === "holmes" ? "from-[rgba(127,219,255,0.08)]" : + detective === "dupin" ? "from-[rgba(255,138,77,0.08)]" : + detective === "schneier" ? "from-[rgba(255,51,68,0.08)]" : + "from-[rgba(6,214,160,0.08)]"; const payload = (job.payload ?? {}) as Record; - const question = (payload.question ?? payload.topic) as string | undefined; - const questionLabel = job.kind === "contradiction_scan" ? "Topic" : "Question"; + const question = (payload.question ?? payload.topic ?? payload.hypothesis_id) as string | undefined; + const questionLabel = + job.kind === "contradiction_scan" ? "Topic" : + job.kind === "red_team_review" ? "Hypothesis under attack" : + "Question"; const docId = payload.doc_id as string | undefined; return ( diff --git a/web/components/chat-bubble.tsx b/web/components/chat-bubble.tsx index cc875de..a3f6ea3 100644 --- a/web/components/chat-bubble.tsx +++ b/web/components/chat-bubble.tsx @@ -686,12 +686,14 @@ function ToolTrace({ t }: { t: ToolBlock }) { } const detective = r.detective ?? ( 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 = - 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" }; + detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } : + detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } : + detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } : + { text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" }; return (
diff --git a/web/components/red-team-request-button.tsx b/web/components/red-team-request-button.tsx new file mode 100644 index 0000000..d4434ca --- /dev/null +++ b/web/components/red-team-request-button.tsx @@ -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(null); + const [error, setError] = useState(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 ( + + Schneier em ação — acompanhar + + ); + } + + return ( +
+ + {error && ( + {error} + )} +
+ ); +} diff --git a/web/lib/chat/tools.ts b/web/lib/chat/tools.ts index 42b1542..b0c9b4c 100644 --- a/web/lib/chat/tools.ts +++ b/web/lib/chat/tools.ts @@ -363,7 +363,8 @@ const request_investigation_tool: ToolDefinition = { "Do NOT use for plain lookups; hybrid_search is faster. " + "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). " + + "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 " + "with a link to /jobs/; the worker takes ~30-120 seconds.", parameters: { @@ -371,9 +372,15 @@ const request_investigation_tool: ToolDefinition = { properties: { kind: { type: "string", - enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan"], + enum: ["hypothesis_tournament", "evidence_chain", "contradiction_scan", "red_team_review"], 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: { type: "string", description: @@ -786,8 +793,8 @@ async function handleRequestInvestigation( ctx: ToolHandlerContext, ): Promise { const kind = String(args.kind ?? "").trim(); - if (kind !== "hypothesis_tournament" && kind !== "evidence_chain" && kind !== "contradiction_scan") { - return { error: "bad_kind", message: "kind must be hypothesis_tournament, evidence_chain or 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, contradiction_scan or red_team_review" }; } const docArg = typeof args.doc_id === "string" && args.doc_id.trim() ? args.doc_id.trim() : ctx.doc_id || null; @@ -806,6 +813,12 @@ async function handleRequestInvestigation( payload.topic = topic; payload.lang = lang; 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 { if (!docArg) return { error: "doc_id_required", message: "evidence_chain needs a doc_id" }; payload.doc_id = docArg; @@ -816,8 +829,9 @@ async function handleRequestInvestigation( } const triggered_by = ctx.user_email ? `user:${ctx.user_email}` : "user:anonymous"; - // Investigation Bureau expected duration: Holmes ~60s, Dupin ~60s, Locard ~30s × n_chunks. - const eta = kind === "evidence_chain" ? 30 * 5 : 60; + // 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; try { const rows = await pgQuery<{ job_id: string; created_at: string }>( @@ -837,6 +851,7 @@ async function handleRequestInvestigation( payload_summary: payload, detective: kind === "hypothesis_tournament" ? "holmes" : kind === "contradiction_scan" ? "dupin" + : kind === "red_team_review" ? "schneier" : "locard", }; } catch (e) {