User flagged that the bureau was emitting English-only output, violating
the project's bilingual rule. Every narrative field now ships in both
languages: stored in sibling DB columns + rendered as adjacent markdown
sections per CLAUDE.md §3.
Migration 0007 (apply as supabase_admin):
- public.hypotheses +question_pt_br, +position_pt_br,
+argument_for_pt_br, +argument_against_pt_br
- public.contradictions +topic_pt_br, +notes_pt_br
- public.witnesses +access_to_event_pt_br, +bias_notes_pt_br,
+verdict_pt_br
- public.gaps +description_pt_br, +suggested_next_move_pt_br
- public.evidence: unchanged (verbatim_excerpt stays source-language)
- JSONB siblings inside contradictions.chunks + gaps.scope handled at
runtime (statement_pt_br, title_pt_br, dominant_model_pt_br,
why_surprising_pt_br, what_it_implies_pt_br).
Detective prompts (all 7) rewritten with explicit bilingual JSON contract:
- Output protocol section names every EN field + its _pt_br sibling
- "Bilingual is mandatory" warning in the task instruction
- Sentinel skip-states unchanged (NO_HYPOTHESES, NO_CONTRADICTIONS,
INSUFFICIENT_TESTIMONY, INSUFFICIENT_HYPOTHESIS, NO_OUTLIERS,
NO_NEW_EVIDENCE, INSUFFICIENT_ARTEFACTS)
- Schneier: parallel arrays — hidden_assumptions[i] matches
hidden_assumptions_pt_br[i], lengths must match
- Case-Writer: interleaved §1 (EN) / §1 (PT-BR) per act in the body
Writer-side validation (all 7 tools):
- Reject INSERT if PT-BR sibling missing when EN field is set
- Persist both languages atomically in one INSERT (no half-updates)
- Markdown renderers write adjacent EN+PT-BR sections in case files
(## Argument for (EN) followed by ## Argumento a favor (PT-BR), etc.)
Detective parse layer (all 7 detectives):
- Coerce both keys from JSON output
- "incomplete_bilingual_*" skip reason when either side missing
- Defensive: PT-BR fields trimmed + length-capped same as EN
Orchestrator propagates question_pt_br + topic_pt_br through job payload
to runHolmes / runCaseWriter, mirroring the chat-tool entry point.
Web (UI):
- /api/jobs/[id] hydrates _pt_br siblings from pg
- job-status-poller HypothesisCard: PT-BR primary, EN in <details>
fallback when both exist
- ContradictionCard: PT-BR statement primary + secondary EN quote
- WitnessCard: PT-BR verdict primary + secondary EN quote, panels in PT
- GapCard: PT-BR title/why/implies primary
- /bureau hub: SELECTs both columns, renders PT-BR primary
- /h/[id]: ArgumentPanel renders PT-BR primary with collapsible EN
fallback when both exist
- BureauSnapshot homepage: position_pt_br / topic_pt_br / verdict_pt_br
primary
- DocBureauPanel /d/[doc]: same primary-PT-BR pattern
- New web/lib/i18n/pick.ts helper (unused yet by chat/agents — kept
for future locale-driven switching when both languages are equally
full; current rule is PT-BR-first since the user is brasileiro)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
330 lines
15 KiB
TypeScript
330 lines
15 KiB
TypeScript
/**
|
|
* /bureau — Investigation Bureau hub.
|
|
*
|
|
* Full listing of every artefact: evidence, hypotheses, contradictions,
|
|
* witnesses, outliers, case reports, recent jobs. Each anchor-section
|
|
* matches the hash-links from the homepage's counter row.
|
|
*/
|
|
import Link from "next/link";
|
|
import { pgQuery } from "@/lib/retrieval/db";
|
|
import { AuthBar } from "@/components/auth-bar";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
interface EvidenceRow {
|
|
evidence_id: string;
|
|
grade: string;
|
|
verbatim_excerpt: string;
|
|
source_page_id: string;
|
|
confidence_band: string | null;
|
|
}
|
|
interface HypothesisRow {
|
|
hypothesis_id: string;
|
|
question: string;
|
|
question_pt_br: string | null;
|
|
position: string;
|
|
position_pt_br: string | null;
|
|
prior: number | string | null;
|
|
posterior: number | string | null;
|
|
confidence_band: string | null;
|
|
status: string;
|
|
reviewed_by: string | null;
|
|
created_at: string;
|
|
}
|
|
interface ContradictionRow {
|
|
contradiction_id: string;
|
|
topic: string;
|
|
topic_pt_br: string | null;
|
|
resolution_status: string;
|
|
chunks: unknown;
|
|
}
|
|
interface GapRow {
|
|
gap_id: string;
|
|
description: string;
|
|
description_pt_br: string | null;
|
|
scope: unknown;
|
|
status: string;
|
|
suggested_next_move: string | null;
|
|
suggested_next_move_pt_br: string | null;
|
|
}
|
|
interface WitnessRow {
|
|
witness_id: string;
|
|
canonical_name: string | null;
|
|
entity_id: string | null;
|
|
credibility: string | null;
|
|
verdict: string | null;
|
|
verdict_pt_br: string | null;
|
|
}
|
|
interface JobRow {
|
|
job_id: string;
|
|
kind: string;
|
|
status: string;
|
|
payload: Record<string, unknown> | null;
|
|
created_at: string;
|
|
finished_at: string | null;
|
|
triggered_by: string | null;
|
|
}
|
|
|
|
const BAND_TONE: 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_TONE: Record<string, string> = {
|
|
A: "text-[#06d6a0] border-[#06d6a0]",
|
|
B: "text-[#3fde6a] border-[#3fde6a]",
|
|
C: "text-[#ffa500] border-[#ffa500]",
|
|
};
|
|
|
|
export default async function BureauPage() {
|
|
// All artefacts. Server component — single round per query, no n+1.
|
|
const [hyp, ev, ctr, gap, wit, jobs] = await Promise.all([
|
|
pgQuery<HypothesisRow>(
|
|
`SELECT hypothesis_id, question, question_pt_br, position, position_pt_br,
|
|
prior, posterior, confidence_band, status, reviewed_by, created_at
|
|
FROM public.hypotheses ORDER BY created_at DESC LIMIT 100`,
|
|
).catch(() => []),
|
|
pgQuery<EvidenceRow>(
|
|
`SELECT evidence_id, grade, verbatim_excerpt, source_page_id, confidence_band
|
|
FROM public.evidence ORDER BY created_at DESC LIMIT 100`,
|
|
).catch(() => []),
|
|
pgQuery<ContradictionRow>(
|
|
`SELECT contradiction_id, topic, topic_pt_br, resolution_status, chunks
|
|
FROM public.contradictions ORDER BY created_at DESC LIMIT 100`,
|
|
).catch(() => []),
|
|
pgQuery<GapRow>(
|
|
`SELECT gap_id, description, description_pt_br, scope, status,
|
|
suggested_next_move, suggested_next_move_pt_br
|
|
FROM public.gaps ORDER BY created_at DESC LIMIT 100`,
|
|
).catch(() => []),
|
|
pgQuery<WitnessRow>(
|
|
`SELECT w.witness_id, e.canonical_name, e.entity_id, w.credibility,
|
|
w.verdict, w.verdict_pt_br
|
|
FROM public.witnesses w
|
|
LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk
|
|
ORDER BY w.created_at DESC LIMIT 100`,
|
|
).catch(() => []),
|
|
pgQuery<JobRow>(
|
|
`SELECT job_id, kind, status, payload, created_at, finished_at, triggered_by
|
|
FROM public.investigation_jobs ORDER BY created_at DESC LIMIT 25`,
|
|
).catch(() => []),
|
|
]);
|
|
|
|
const { readdir, stat, readFile } = await import("node:fs/promises");
|
|
const path = await import("node:path");
|
|
const reportsDir = path.join(process.env.CASE_ROOT || "/data/ufo/case", "reports");
|
|
let reports: Array<{ slug: string; topic: string; mtimeMs: number; n_evidence: number; n_hypotheses: number }> = [];
|
|
try {
|
|
const files = await readdir(reportsDir);
|
|
const items = await Promise.all(
|
|
files.filter((f) => f.endsWith(".md")).map(async (f) => {
|
|
const full = path.join(reportsDir, f);
|
|
const st = await stat(full);
|
|
const head = (await readFile(full, "utf-8")).slice(0, 2000);
|
|
const topicMatch = head.match(/topic:\s*"([^"]+)"/);
|
|
const eMatch = head.match(/n_evidence:\s*(\d+)/);
|
|
const hMatch = head.match(/n_hypotheses:\s*(\d+)/);
|
|
return {
|
|
slug: f.replace(/\.md$/, ""),
|
|
topic: topicMatch?.[1] ?? f,
|
|
mtimeMs: st.mtimeMs,
|
|
n_evidence: Number(eMatch?.[1] ?? 0),
|
|
n_hypotheses: Number(hMatch?.[1] ?? 0),
|
|
};
|
|
}),
|
|
);
|
|
reports = items.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
} catch { /* fine */ }
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
|
<AuthBar />
|
|
<div className="mx-auto max-w-6xl 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 className="text-[#e0c080]">bureau</span>
|
|
</div>
|
|
|
|
<header className="mb-8 border-b border-[rgba(224,192,128,0.32)] pb-4">
|
|
<h1 className="font-mono text-3xl text-[#e0c080]">▍ The Investigation Bureau</h1>
|
|
<p className="text-[#8896aa] text-sm mt-1">
|
|
Live case folder · {ev.length} evidence · {hyp.length} hypotheses · {ctr.length} contradictions ·{" "}
|
|
{wit.length} witnesses · {gap.length} outliers · {reports.length} reports
|
|
</p>
|
|
</header>
|
|
|
|
{/* Case reports */}
|
|
<Section id="reports" title="Case reports" color="text-[#e0c080]">
|
|
{reports.length === 0 ? <Empty /> : reports.map((r) => (
|
|
<Link key={r.slug} href={`/c/${r.slug}`} className="block rounded border border-[rgba(224,192,128,0.18)] bg-[#0d1220] p-3 hover:bg-[rgba(224,192,128,0.04)] mb-2">
|
|
<div className="text-[10px] font-mono text-[#5a6678]">/c/{r.slug}</div>
|
|
<div className="text-[14px] text-[#e7ecf3] font-medium mt-1">{r.topic}</div>
|
|
<div className="text-[10px] font-mono text-[#5a6678] mt-1">
|
|
{r.n_hypotheses} hypotheses · {r.n_evidence} evidence
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</Section>
|
|
|
|
{/* Hypotheses */}
|
|
<Section id="hypotheses" title="Hypotheses" color="text-[#7fdbff]">
|
|
{hyp.length === 0 ? <Empty /> : hyp.map((h) => {
|
|
const post = h.posterior !== null ? Number(h.posterior) : null;
|
|
const prior = h.prior !== null ? Number(h.prior) : null;
|
|
const delta = post !== null && prior !== null ? post - prior : null;
|
|
const bandTone = (h.confidence_band && BAND_TONE[h.confidence_band]) || "text-[#9aa6b8] border-[#9aa6b8]";
|
|
return (
|
|
<Link key={h.hypothesis_id} href={`/h/${h.hypothesis_id}`}
|
|
className="block rounded border border-[rgba(127,219,255,0.18)] bg-[#0d1220] p-3 hover:bg-[rgba(127,219,255,0.04)] mb-2">
|
|
<div className="flex items-baseline justify-between gap-2 mb-1 flex-wrap">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{h.hypothesis_id} · {h.status}</span>
|
|
<div className="flex items-center gap-1">
|
|
{h.confidence_band && (
|
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}>{h.confidence_band}</span>
|
|
)}
|
|
{h.reviewed_by && (
|
|
<span className="text-[9px] font-mono text-[#ff3344]">↳ {h.reviewed_by}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="text-[13px] text-[#e7ecf3] leading-snug">{h.position_pt_br || h.position}</div>
|
|
<div className="text-[10px] font-mono text-[#5a6678] mt-1">
|
|
prior {prior?.toFixed(2) ?? "—"} → posterior {post?.toFixed(2) ?? "—"}
|
|
{delta !== null && <span className={delta > 0 ? " text-[#06d6a0]" : delta < 0 ? " text-[#ff6ec7]" : ""}> · Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)}</span>}
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</Section>
|
|
|
|
{/* Evidence */}
|
|
<Section id="evidence" title="Evidence" color="text-[#06d6a0]">
|
|
{ev.length === 0 ? <Empty /> : ev.map((e) => {
|
|
const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]";
|
|
return (
|
|
<div key={e.evidence_id} className="rounded border border-[rgba(6,214,160,0.18)] bg-[#0d1220] p-3 mb-2">
|
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{e.evidence_id} · {e.source_page_id}</span>
|
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${tone}`}>Grade {e.grade}</span>
|
|
</div>
|
|
<blockquote className="text-[12px] text-[#cbd2dd] italic border-l-2 border-[#06d6a0] pl-2 mt-1">
|
|
“{e.verbatim_excerpt}”
|
|
</blockquote>
|
|
</div>
|
|
);
|
|
})}
|
|
</Section>
|
|
|
|
{/* Contradictions */}
|
|
<Section id="contradictions" title="Contradictions" color="text-[#ff8a4d]">
|
|
{ctr.length === 0 ? <Empty /> : ctr.map((c) => {
|
|
const n = Array.isArray(c.chunks) ? c.chunks.length : 0;
|
|
return (
|
|
<div key={c.contradiction_id} className="rounded border border-[rgba(255,138,77,0.18)] bg-[#0d1220] p-3 mb-2">
|
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span>
|
|
<span className="text-[10px] font-mono text-[#9aa6b8]">{n} positions · {c.resolution_status}</span>
|
|
</div>
|
|
<div className="text-[13px] text-[#e7ecf3] leading-snug">{c.topic_pt_br || c.topic}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Section>
|
|
|
|
{/* Outliers */}
|
|
<Section id="outliers" title="Outliers" color="text-[#ffd23f]">
|
|
{gap.length === 0 ? <Empty /> : gap.map((g) => {
|
|
const s = (g.scope ?? {}) as Record<string, unknown>;
|
|
const title = (s.title_pt_br as string) || (s.title as string) || g.description_pt_br || g.description;
|
|
const why = (s.why_surprising_pt_br as string) || (s.why_surprising as string) || null;
|
|
const isOutlier = s.kind === "outlier";
|
|
const nextMove = g.suggested_next_move_pt_br || g.suggested_next_move;
|
|
return (
|
|
<div key={g.gap_id} className="rounded border border-[rgba(255,210,63,0.25)] bg-[#0d1220] p-3 mb-2">
|
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{g.gap_id}{isOutlier && " · outlier"}</span>
|
|
<span className="text-[10px] font-mono text-[#9aa6b8]">{g.status}</span>
|
|
</div>
|
|
<div className="text-[13px] text-[#e7ecf3] leading-snug">{title}</div>
|
|
{why && (
|
|
<div className="text-[11px] text-[#cbd2dd] mt-1 leading-relaxed">{why}</div>
|
|
)}
|
|
{nextMove && (
|
|
<div className="text-[10px] font-mono text-[#06d6a0] mt-1">→ {nextMove}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</Section>
|
|
|
|
{/* Witnesses */}
|
|
<Section id="witnesses" title="Witness analyses" color="text-[#9b5de5]">
|
|
{wit.length === 0 ? <Empty /> : wit.map((w) => (
|
|
<div key={w.witness_id} className="rounded border border-[rgba(155,93,229,0.18)] bg-[#0d1220] p-3 mb-2">
|
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{w.witness_id}</span>
|
|
<span className="text-[10px] font-mono text-[#9b5de5]">{w.credibility ?? "—"}</span>
|
|
</div>
|
|
<div className="text-[13px] text-[#e7ecf3] font-medium">
|
|
{w.entity_id ? <Link href={`/e/people/${w.entity_id}`} className="hover:underline">{w.canonical_name ?? w.entity_id}</Link> : (w.canonical_name ?? "—")}
|
|
</div>
|
|
{(w.verdict_pt_br || w.verdict) && <blockquote className="text-[12px] text-[#cbd2dd] italic mt-1 border-l-2 border-[#9b5de5] pl-2">{w.verdict_pt_br || w.verdict}</blockquote>}
|
|
</div>
|
|
))}
|
|
</Section>
|
|
|
|
{/* Recent jobs */}
|
|
<Section id="jobs" title="Recent investigation jobs" color="text-[#9aa6b8]">
|
|
{jobs.length === 0 ? <Empty /> : (
|
|
<div className="rounded border border-[rgba(127,219,255,0.08)] bg-[#0d1220] overflow-hidden">
|
|
<table className="w-full text-[11px] font-mono">
|
|
<thead className="text-[#5a6678] border-b border-[rgba(127,219,255,0.1)]">
|
|
<tr>
|
|
<th className="text-left px-3 py-2">job</th>
|
|
<th className="text-left px-3 py-2">kind</th>
|
|
<th className="text-left px-3 py-2">status</th>
|
|
<th className="text-left px-3 py-2">created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{jobs.map((j) => (
|
|
<tr key={j.job_id} className="border-t border-[rgba(127,219,255,0.04)] hover:bg-[rgba(127,219,255,0.03)]">
|
|
<td className="px-3 py-2">
|
|
<Link href={`/jobs/${j.job_id}`} className="text-[#7fdbff] hover:underline">{j.job_id.slice(0, 8)}…</Link>
|
|
</td>
|
|
<td className="px-3 py-2 text-[#cbd2dd]">{j.kind}</td>
|
|
<td className={`px-3 py-2 ${
|
|
j.status === "complete" ? "text-[#06d6a0]" :
|
|
j.status === "failed" ? "text-[#ff6ec7]" :
|
|
j.status === "running" ? "text-[#ffd23f]" :
|
|
"text-[#9aa6b8]"
|
|
}`}>{j.status}</td>
|
|
<td className="px-3 py-2 text-[#5a6678]">{new Date(j.created_at).toLocaleString("pt-BR")}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ id, title, color, children }: { id: string; title: string; color: string; children: React.ReactNode }) {
|
|
return (
|
|
<section id={id} className="mb-10 scroll-mt-20">
|
|
<h2 className={`font-mono text-xs uppercase tracking-[0.18em] ${color} mb-3`}>// {title}</h2>
|
|
<div>{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Empty() {
|
|
return <div className="text-[11px] font-mono text-[#5a6678]">_(none yet)_</div>;
|
|
}
|