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>
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
/**
|
|
* DocBureauPanel — surfaces Investigation Bureau artefacts that touch a
|
|
* specific doc_id on the /d/[docId] page.
|
|
*
|
|
* Server component. Resolves:
|
|
* - Evidence whose source_page_id starts with doc_id/p (FK via the
|
|
* chunks table would be cleaner but the LIKE prefix is fine and
|
|
* index-friendly).
|
|
* - Hypotheses whose evidence_refs contain any E-NNNN from this doc.
|
|
* - Contradictions whose chunks array contains an item with this doc_id.
|
|
* - Outliers (gaps) whose scope.doc_id == this doc_id.
|
|
* - Case reports whose body markdown references the doc_id.
|
|
*
|
|
* Renders nothing if all five lists are empty (doc untouched by bureau).
|
|
*/
|
|
import Link from "next/link";
|
|
import { pgQuery } from "@/lib/retrieval/db";
|
|
|
|
interface EvRow { evidence_id: string; grade: string; confidence_band: string | null; source_page_id: string }
|
|
interface HypRow { hypothesis_id: string; position: string; position_pt_br: string | null; confidence_band: string | null; posterior: number | string | null }
|
|
interface CtrRow { contradiction_id: string; topic: string; topic_pt_br: string | null; resolution_status: string }
|
|
interface GapRow { gap_id: string; description: string; description_pt_br: string | null; scope: unknown; status: string }
|
|
interface ReportRow { slug: string; topic: string }
|
|
|
|
const BAND_TONE: Record<string, string> = {
|
|
high: "text-[#06d6a0]", medium: "text-[#3fde6a]",
|
|
low: "text-[#ffa500]", speculation: "text-[#ff6ec7]",
|
|
};
|
|
|
|
const GRADE_TONE: Record<string, string> = {
|
|
A: "text-[#06d6a0] border-[#06d6a0]",
|
|
B: "text-[#3fde6a] border-[#3fde6a]",
|
|
C: "text-[#ffa500] border-[#ffa500]",
|
|
};
|
|
|
|
export async function DocBureauPanel({ docId }: { docId: string }) {
|
|
// Evidence on this doc (cheap).
|
|
const ev: EvRow[] = await pgQuery<EvRow>(
|
|
`SELECT evidence_id, grade, confidence_band, source_page_id
|
|
FROM public.evidence
|
|
WHERE source_page_id LIKE $1 || '/%'
|
|
ORDER BY evidence_id LIMIT 20`,
|
|
[docId],
|
|
).catch(() => []);
|
|
|
|
// Hypotheses citing any of the evidence_ids above.
|
|
const evIds = ev.map((e) => e.evidence_id);
|
|
const hyp: HypRow[] = evIds.length > 0
|
|
? await pgQuery<HypRow>(
|
|
`SELECT hypothesis_id, position, position_pt_br, confidence_band, posterior
|
|
FROM public.hypotheses
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(evidence_refs) er
|
|
WHERE er->>'evidence_id' = ANY($1::text[])
|
|
)
|
|
ORDER BY hypothesis_id LIMIT 12`,
|
|
[evIds],
|
|
).catch(() => [])
|
|
: [];
|
|
|
|
// Contradictions whose chunks[] has any chunk with this doc_id.
|
|
const ctr: CtrRow[] = await pgQuery<CtrRow>(
|
|
`SELECT contradiction_id, topic, topic_pt_br, resolution_status
|
|
FROM public.contradictions
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(chunks) c
|
|
WHERE c->>'doc_id' = $1
|
|
)
|
|
ORDER BY contradiction_id LIMIT 8`,
|
|
[docId],
|
|
).catch(() => []);
|
|
|
|
// Outliers (gaps with scope.doc_id matching).
|
|
const gap: GapRow[] = await pgQuery<GapRow>(
|
|
`SELECT gap_id, description, description_pt_br, scope, status
|
|
FROM public.gaps
|
|
WHERE scope->>'doc_id' = $1
|
|
ORDER BY gap_id LIMIT 8`,
|
|
[docId],
|
|
).catch(() => []);
|
|
|
|
// Case reports referencing this doc_id in the body. We don't store body
|
|
// in pg; instead we scan the reports/ dir filesystem-side for now.
|
|
// (Lightweight — bureau reports are O(10) files.)
|
|
let reports: ReportRow[] = [];
|
|
try {
|
|
const { readdir, readFile } = await import("node:fs/promises");
|
|
const path = await import("node:path");
|
|
const dir = path.join(process.env.CASE_ROOT || "/data/ufo/case", "reports");
|
|
const files = await readdir(dir).catch(() => [] as string[]);
|
|
const found: ReportRow[] = [];
|
|
for (const f of files.filter((x) => x.endsWith(".md"))) {
|
|
const md = await readFile(path.join(dir, f), "utf-8");
|
|
if (md.includes(docId)) {
|
|
const topicMatch = md.match(/topic:\s*"([^"]+)"/);
|
|
found.push({ slug: f.replace(/\.md$/, ""), topic: topicMatch?.[1] ?? f });
|
|
}
|
|
}
|
|
reports = found;
|
|
} catch { /* fine */ }
|
|
|
|
const total = ev.length + hyp.length + ctr.length + gap.length + reports.length;
|
|
if (total === 0) {
|
|
return (
|
|
<section className="mb-6 rounded-lg border border-dashed border-[rgba(127,219,255,0.18)] bg-[#0d1220] p-4">
|
|
<div className="flex items-baseline justify-between flex-wrap gap-2">
|
|
<div className="text-[10px] font-mono text-[#5a6678] uppercase tracking-wider">
|
|
// Investigation Bureau — untouched
|
|
</div>
|
|
<Link
|
|
href={`/?launch=${encodeURIComponent(docId)}`}
|
|
className="text-[10px] font-mono text-[#e0c080] hover:underline"
|
|
>
|
|
launch an investigation →
|
|
</Link>
|
|
</div>
|
|
<p className="text-[11px] text-[#5a6678] font-mono mt-2">
|
|
No detective has produced an artefact for this document yet.
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="mb-6 rounded-lg border border-[rgba(224,192,128,0.18)] bg-gradient-to-br from-[rgba(224,192,128,0.04)] to-transparent p-4">
|
|
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
|
<div className="text-[10px] font-mono text-[#e0c080] uppercase tracking-wider">
|
|
// Investigation Bureau · {total} artefact{total === 1 ? "" : "s"} touch this doc
|
|
</div>
|
|
<Link href="/bureau" className="text-[10px] font-mono text-[#e0c080] hover:underline">
|
|
full bureau →
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="grid md:grid-cols-2 gap-3">
|
|
{hyp.length > 0 && (
|
|
<Panel title="Hypotheses" color="text-[#7fdbff]" border="border-[rgba(127,219,255,0.18)]">
|
|
{hyp.map((h) => {
|
|
const tone = (h.confidence_band && BAND_TONE[h.confidence_band]) || "text-[#9aa6b8]";
|
|
const post = h.posterior !== null ? Number(h.posterior) : null;
|
|
return (
|
|
<Link key={h.hypothesis_id} href={`/h/${h.hypothesis_id}`}
|
|
className="block py-1.5 border-t border-[rgba(127,219,255,0.08)] first:border-t-0 hover:bg-[rgba(127,219,255,0.04)]">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{h.hypothesis_id}</span>
|
|
<span className={`text-[10px] font-mono ${tone}`}>
|
|
{h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`}
|
|
</span>
|
|
</div>
|
|
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{h.position_pt_br || h.position}</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</Panel>
|
|
)}
|
|
|
|
{ev.length > 0 && (
|
|
<Panel title="Evidence" color="text-[#06d6a0]" border="border-[rgba(6,214,160,0.18)]">
|
|
{ev.map((e) => {
|
|
const tone = (e.grade && GRADE_TONE[e.grade]) || "text-[#9aa6b8] border-[#9aa6b8]";
|
|
return (
|
|
<div key={e.evidence_id} className="py-1.5 border-t border-[rgba(6,214,160,0.08)] first:border-t-0">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{e.evidence_id}</span>
|
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-mono uppercase border ${tone}`}>
|
|
Grade {e.grade}
|
|
</span>
|
|
</div>
|
|
<div className="text-[11px] font-mono text-[#5a6678] mt-0.5">{e.source_page_id}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Panel>
|
|
)}
|
|
|
|
{ctr.length > 0 && (
|
|
<Panel title="Contradictions" color="text-[#ff8a4d]" border="border-[rgba(255,138,77,0.18)]">
|
|
{ctr.map((c) => (
|
|
<div key={c.contradiction_id} className="py-1.5 border-t border-[rgba(255,138,77,0.08)] first:border-t-0">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span>
|
|
<span className="text-[10px] font-mono text-[#9aa6b8]">{c.resolution_status}</span>
|
|
</div>
|
|
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{c.topic_pt_br || c.topic}</div>
|
|
</div>
|
|
))}
|
|
</Panel>
|
|
)}
|
|
|
|
{gap.length > 0 && (
|
|
<Panel title="Outliers" color="text-[#ffd23f]" border="border-[rgba(255,210,63,0.25)]">
|
|
{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;
|
|
return (
|
|
<div key={g.gap_id} className="py-1.5 border-t border-[rgba(255,210,63,0.08)] first:border-t-0">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-[10px] font-mono text-[#5a6678]">{g.gap_id}</span>
|
|
<span className="text-[10px] font-mono text-[#9aa6b8]">{g.status}</span>
|
|
</div>
|
|
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{title}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Panel>
|
|
)}
|
|
|
|
{reports.length > 0 && (
|
|
<Panel title="Case reports referencing this doc" color="text-[#e0c080]" border="border-[rgba(224,192,128,0.25)]">
|
|
{reports.map((r) => (
|
|
<Link key={r.slug} href={`/c/${r.slug}`}
|
|
className="block py-1.5 border-t border-[rgba(224,192,128,0.08)] first:border-t-0 hover:bg-[rgba(224,192,128,0.04)]">
|
|
<div className="text-[10px] font-mono text-[#5a6678]">/c/{r.slug}</div>
|
|
<div className="text-[12px] text-[#e0c080] leading-snug mt-0.5 font-medium">{r.topic}</div>
|
|
</Link>
|
|
))}
|
|
</Panel>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Panel({ title, color, border, children }: {
|
|
title: string; color: string; border: string; children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={`rounded border ${border} bg-[#0d1220] p-3`}>
|
|
<div className={`text-[10px] font-mono uppercase tracking-wider ${color} mb-1`}>{title}</div>
|
|
<div>{children}</div>
|
|
</div>
|
|
);
|
|
}
|