W3.8: Schneier red-team detective + /h/[hypothesisId] dossier page
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:
parent
25f19aee63
commit
857dd771d2
10 changed files with 1001 additions and 24 deletions
63
investigator-runtime/prompts/schneier.md
Normal file
63
investigator-runtime/prompts/schneier.md
Normal 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.
|
||||
195
investigator-runtime/src/detectives/schneier.ts
Normal file
195
investigator-runtime/src/detectives/schneier.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
145
investigator-runtime/src/tools/write_red_team_review.ts
Normal file
145
investigator-runtime/src/tools/write_red_team_review.ts
Normal 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 };
|
||||
}
|
||||
61
web/app/api/h/[hypothesisId]/red-team/route.ts
Normal file
61
web/app/api/h/[hypothesisId]/red-team/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
411
web/app/h/[hypothesisId]/page.tsx
Normal file
411
web/app/h/[hypothesisId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" :
|
||||
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" :
|
||||
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]" :
|
||||
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)]" :
|
||||
detective === "schneier" ? "from-[rgba(255,51,68,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 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 (
|
||||
|
|
|
|||
|
|
@ -686,11 +686,13 @@ 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" } :
|
||||
detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } :
|
||||
{ text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" };
|
||||
return (
|
||||
<div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}>
|
||||
|
|
|
|||
63
web/components/red-team-request-button.tsx
Normal file
63
web/components/red-team-request-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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/<job_id>; 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<unknown> {
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue