Two complaints in one wave:
(W4.1) User: "Não pode ter vícios de IA como uso excessivo de '-' que a IA
coloca geralmente no lugar de vírgulas por exemplo. Isso deve fazer parte
do prompt geral."
- New prompts/_house-style.md banning the 9 most common AI prose tells
in both EN and PT-BR:
1. Em dashes as comma replacements (—)
2. Rule-of-three lists ("concrete, rigorous, and grounded")
3. Conjunctive openers ("Moreover", "Notably", "Ademais")
4. Superficial -ing analyses ("marking a shift", "destacando")
5. Inflated symbolism + AI vocab (tapestry, navigate, delve,
underscore, robust, multifaceted, marco histórico, ...)
6. Negative parallelisms ("Not just X but Y")
7. Vague attribution ("Some scholars say...")
8. Summary closers ("In summary...", "Em suma...")
9. Hedging fluff ("It's important to note...")
Verbatim chunk quotes are explicitly exempt; preserve as-is.
- claude.ts callClaude() lazily loads _house-style.md once per process
and PREPENDS it to every detective's system prompt:
composedSystem = houseStyle + "---" + detective.systemPrompt
This means all 7 detectives + future ones get the rules without any
per-prompt change.
(W4.2) User: "Quando entra em uma página da investigação não tem como
voltar! UX terrível!"
- New <BureauNav> sticky topbar with explicit "← home" + "🔎 bureau"
buttons + clickable breadcrumb trail. Always visible at the top of
every bureau page so the user can escape in one click.
- Wired into /bureau, /h/[hypothesisId], /c/[slug], /jobs/[id]. Each
page passes its sensible parent crumb (/bureau#hypotheses,
/bureau#reports, /bureau#jobs).
- Replaces the previous plain-text "disclosure.top / hypothesis /
H-0004" line which had no visual affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
425 lines
17 KiB
TypeScript
425 lines
17 KiB
TypeScript
/**
|
|
* /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 { BureauNav } from "@/components/bureau-nav";
|
|
import { RedTeamRequestButton } from "@/components/red-team-request-button";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
interface HypothesisRow {
|
|
hypothesis_id: string;
|
|
question: string;
|
|
question_pt_br: string | null;
|
|
position: string;
|
|
position_pt_br: string | null;
|
|
argument_for: string | null;
|
|
argument_for_pt_br: string | null;
|
|
argument_against: string | null;
|
|
argument_against_pt_br: 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, question_pt_br, position, position_pt_br,
|
|
argument_for, argument_for_pt_br, argument_against, argument_against_pt_br,
|
|
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]">
|
|
<BureauNav crumbs={[
|
|
{ label: "bureau", href: "/bureau" },
|
|
{ label: "hypotheses", href: "/bureau#hypotheses" },
|
|
{ label: hypothesisId },
|
|
]} />
|
|
<AuthBar />
|
|
<div className="mx-auto max-w-5xl px-4 py-6 pt-4">
|
|
|
|
{/* 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_pt_br || h.position}</h1>
|
|
{h.position_pt_br && h.position_pt_br !== h.position && (
|
|
<p className="text-[12px] text-[#5a6678] italic mt-0.5">{h.position}</p>
|
|
)}
|
|
<p className="text-[12px] text-[#9aa6b8] mt-1">Pergunta: {h.question_pt_br || 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_pt_br || h.argument_for} bodyEn={h.argument_for_pt_br ? h.argument_for : null} />
|
|
<ArgumentPanel kind="against" body={h.argument_against_pt_br || h.argument_against} bodyEn={h.argument_against_pt_br ? h.argument_against : null} />
|
|
</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, bodyEn }: { kind: "for" | "against"; body: string | null; bodyEn?: string | null }) {
|
|
const tone = kind === "for" ? "border-[#06d6a0] text-[#06d6a0]" : "border-[#ff6ec7] text-[#ff6ec7]";
|
|
const label = kind === "for" ? "Argumento a favor (PT-BR)" : "Argumento contra (PT-BR)";
|
|
const enLabel = kind === "for" ? "Argument for (EN)" : "Argument against (EN)";
|
|
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 ?? ""} />
|
|
{bodyEn && (
|
|
<details className="mt-3 pt-3 border-t border-[rgba(127,219,255,0.08)]">
|
|
<summary className="text-[10px] font-mono text-[#5a6678] cursor-pointer hover:text-[#9aa6b8]">{enLabel}</summary>
|
|
<div className="mt-2"><ArgumentBody text={bodyEn} /></div>
|
|
</details>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|