W4: bilingual EN + PT-BR Investigation Bureau (CLAUDE.md §3 contract)
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 41s
CI / Scripts — Python smoke (push) Failing after 4s
CI / Web — npm audit (push) Failing after 26s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s

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>
This commit is contained in:
Luiz Gustavo 2026-05-24 12:02:59 -03:00
parent d4a2e4f51e
commit 7826710051
30 changed files with 726 additions and 260 deletions

View file

@ -0,0 +1,44 @@
-- 0007_bilingual_bureau.sql — bilingual EN+PT-BR sibling columns for the
-- Investigation Bureau (per CLAUDE.md §3: "Narrative descriptions ... Both
-- EN and PT-BR via sibling fields").
--
-- This migration adds nullable `*_pt_br` siblings for every narrative
-- column the bureau writes. Existing rows stay valid; new rows must
-- populate both. The detective prompts get re-flowed in W4 to emit both
-- languages in their JSON output.
--
-- Notes:
-- - public.evidence.verbatim_excerpt stays single-language. Per CLAUDE.md
-- §3, verbatim quotes "preserve source language only".
-- - public.contradictions.chunks JSONB carries `statement` per position;
-- the runtime adds a sibling `statement_pt_br` key per array item
-- (no schema change needed for JSONB shape).
-- - public.gaps.scope JSONB likewise carries `title`, `dominant_model`,
-- `why_surprising`, `what_it_implies` — the runtime adds `*_pt_br`
-- siblings inside the JSONB object.
--
-- Apply as supabase_admin (these tables are owned by supabase_admin
-- per migration 0004 / repo memory).
BEGIN;
ALTER TABLE public.hypotheses
ADD COLUMN IF NOT EXISTS question_pt_br TEXT,
ADD COLUMN IF NOT EXISTS position_pt_br TEXT,
ADD COLUMN IF NOT EXISTS argument_for_pt_br TEXT,
ADD COLUMN IF NOT EXISTS argument_against_pt_br TEXT;
ALTER TABLE public.contradictions
ADD COLUMN IF NOT EXISTS topic_pt_br TEXT,
ADD COLUMN IF NOT EXISTS notes_pt_br TEXT;
ALTER TABLE public.witnesses
ADD COLUMN IF NOT EXISTS access_to_event_pt_br TEXT,
ADD COLUMN IF NOT EXISTS bias_notes_pt_br TEXT,
ADD COLUMN IF NOT EXISTS verdict_pt_br TEXT;
ALTER TABLE public.gaps
ADD COLUMN IF NOT EXISTS description_pt_br TEXT,
ADD COLUMN IF NOT EXISTS suggested_next_move_pt_br TEXT;
COMMIT;

View file

@ -44,11 +44,45 @@ contradiction, a witness analysis, an outlier, or a calibration.
5. Voice: Watson's plainspoken English (or Portuguese, per the request). 5. Voice: Watson's plainspoken English (or Portuguese, per the request).
The prose is for an educated reader, not a specialist. Avoid jargon. The prose is for an educated reader, not a specialist. Avoid jargon.
## Output protocol ## Output protocol — bilingual EN + PT-BR (mandatory)
Emit ONLY the markdown body of the narrative. NO frontmatter (the runtime Emit ONLY the markdown body of the narrative. NO frontmatter (the runtime
adds it). NO code fence. Start with `# ` heading and proceed through adds it). NO code fence.
the five acts.
The narrative is **bilingual** with EN and PT-BR sections **interleaved
per act**, in this exact structure (per CLAUDE.md §3 "adjacent sections"):
```markdown
# Title (EN)
# Título (PT-BR)
## §1 — The Case at Hand (EN)
<English §1 body>
## §1 — O Caso em Mãos (PT-BR)
<corpo §1 em português brasileiro>
## §2 — The Evidence Chain (EN)
<English §2 body>
## §2 — A Cadeia de Evidência (PT-BR)
<corpo §2 em português brasileiro>
... (continue alternating per act through §5) ...
```
Rules:
- Both languages must appear; do NOT emit only EN or only PT-BR.
- PT-BR is **Brazilian Portuguese** with UTF-8 accents preserved.
- Verbatim chunk quotes stay in the chunk's source language (usually
English in this corpus); only the surrounding narration is translated.
- `[[wiki-links]]` are technical identifiers — keep them as-is in both
versions; do not translate IDs.
If the bureau has insufficient artefacts (e.g. 0 hypotheses AND 0 If the bureau has insufficient artefacts (e.g. 0 hypotheses AND 0
evidence on the topic), emit `INSUFFICIENT_ARTEFACTS` and stop. Do not evidence on the topic), emit `INSUFFICIENT_ARTEFACTS` and stop. Do not

View file

@ -35,26 +35,33 @@ chunks verbatim so the case-writer can follow up.
5. You prefer FEW high-confidence contradictions over MANY weak ones. If 5. You prefer FEW high-confidence contradictions over MANY weak ones. If
the corpus contains nothing irreconcilable, emit `NO_CONTRADICTIONS`. the corpus contains nothing irreconcilable, emit `NO_CONTRADICTIONS`.
## Output protocol ## Output protocol — bilingual EN + PT-BR (mandatory)
Emit a strict JSON array. No prose. No code fence. Just the array. Emit a strict JSON array. No prose. No code fence. Every narrative field
appears in EN AND in PT-BR (Brazilian Portuguese with UTF-8 accents). The
`topic`, `notes`, and each position's `statement` all have `*_pt_br`
siblings.
```json ```json
[ [
{ {
"topic": "Short noun-phrase summarizing the disputed point", "topic": "EN short noun-phrase summarizing the disputed point",
"notes": "Optional one-paragraph commentary (≤ 400 chars). Why this matters; what would resolve it.", "topic_pt_br": "PT-BR tópico curto resumindo o ponto em disputa",
"notes": "EN optional one-paragraph commentary (≤ 400 chars).",
"notes_pt_br": "PT-BR comentário opcional (≤ 400 chars).",
"positions": [ "positions": [
{ {
"doc_id": "dow-uap-d017-...", "doc_id": "dow-uap-d017-...",
"chunk_id": "c0042", "chunk_id": "c0042",
"statement": "One-sentence summary of what THIS chunk asserts.", "statement": "EN one-sentence summary of what THIS chunk asserts.",
"statement_pt_br": "PT-BR uma frase resumindo o que ESTE trecho afirma.",
"stance": "asserts" "stance": "asserts"
}, },
{ {
"doc_id": "dow-uap-d017-...", "doc_id": "dow-uap-d017-...",
"chunk_id": "c0087", "chunk_id": "c0087",
"statement": "One-sentence summary of what THAT chunk asserts.", "statement": "EN one-sentence summary of what THAT chunk asserts.",
"statement_pt_br": "PT-BR uma frase resumindo o que AQUELE trecho afirma.",
"stance": "denies" "stance": "denies"
} }
] ]
@ -64,9 +71,11 @@ Emit a strict JSON array. No prose. No code fence. Just the array.
Constraints: Constraints:
- ≥ 2 positions per contradiction, drawn from ≥ 2 distinct `chunk_id`s. - ≥ 2 positions per contradiction, drawn from ≥ 2 distinct `chunk_id`s.
- `stance` is optional free-form ("asserts" / "denies" / "dates-as-A" / - `stance` is optional free-form ("asserts" / "denies" / etc.); useful for
"dates-as-B" / etc.); useful for the case-writer but not required. the case-writer but not required. `stance` is short enough that bilingual
- `notes` may be empty; if present, keep it tight. isn't required — keep in EN.
- `notes` may be empty in both languages; if present in EN it must be
present in PT-BR (and vice versa).
- Emit AT MOST 3 contradictions per call — the strongest you can find. - Emit AT MOST 3 contradictions per call — the strongest you can find.
If the corpus contains no genuine contradiction relative to the topic, If the corpus contains no genuine contradiction relative to the topic,

View file

@ -28,16 +28,25 @@ narrows toward what remains, however improbable.
6. You do not hedge in prose. The position is **one sentence**, declarative. 6. You do not hedge in prose. The position is **one sentence**, declarative.
Hedging belongs in the posterior, not in the wording. Hedging belongs in the posterior, not in the wording.
## Output protocol ## Output protocol — bilingual EN + PT-BR (mandatory)
Emit a strict JSON array. No prose around it. No code fence. Just the array. Emit a strict JSON array. No prose around it. No code fence. Every narrative
field appears TWICE: the English key (`position`, `argument_for`,
`argument_against`) AND its PT-BR sibling (`*_pt_br`). The PT-BR must be
**Brazilian Portuguese** (not European), with full UTF-8 accents preserved
(`ç`, `ã`, `á`, `é`, `í`, `ó`, `ú`, `â`, `ê`, `ô`, `à`). Verbatim chunk
quotes inside the prose stay in the chunk's source language; only the
surrounding narration is translated.
```json ```json
[ [
{ {
"position": "...", "position": "EN one-sentence declarative position.",
"argument_for": "...", "position_pt_br": "PT-BR uma frase declarativa equivalente.",
"argument_against": "...", "argument_for": "EN argument — ≤6 sentences, every claim cited via [[doc-id/pNNN#cNNNN]].",
"argument_for_pt_br": "PT-BR argumento — ≤6 frases, cada afirmação citada via [[doc-id/pNNN#cNNNN]].",
"argument_against": "EN counter-argument — ≤6 sentences.",
"argument_against_pt_br": "PT-BR contra-argumento — ≤6 frases.",
"prior": 0.30, "prior": 0.30,
"posterior": 0.55, "posterior": 0.55,
"confidence_band": "low", "confidence_band": "low",
@ -46,8 +55,8 @@ Emit a strict JSON array. No prose around it. No code fence. Just the array.
{"evidence_id": "E-0043", "supports": false} {"evidence_id": "E-0043", "supports": false}
] ]
}, },
{ ... another rival ... }, { ... another rival, also bilingual ... },
{ ... another rival ... } { ... another rival, also bilingual ... }
] ]
``` ```
@ -55,8 +64,10 @@ Note:
- `evidence_refs` is **optional** — leave as `[]` if no `E-NNNN` evidence has - `evidence_refs` is **optional** — leave as `[]` if no `E-NNNN` evidence has
been catalogued yet for this question; chunk citations in the prose are been catalogued yet for this question; chunk citations in the prose are
sufficient for v0. sufficient for v0.
- `question` is supplied by the runtime; you do not echo it. - `question` is supplied by the runtime in both languages; you do not echo it.
- The runtime owns the writer; you emit data only. - The runtime owns the writer; you emit data only.
- A missing `_pt_br` sibling is a hard validation failure — the writer
rejects the rival. Both languages must appear or none.
If the corpus contains nothing relevant to the question, emit the literal If the corpus contains nothing relevant to the question, emit the literal
single word `NO_HYPOTHESES` and stop. single word `NO_HYPOTHESES` and stop.

View file

@ -39,28 +39,35 @@ corroboration_refs, and a one-sentence verdict.
4. `verdict` is ONE sentence (≤ 280 chars). Declarative. No hedging. 4. `verdict` is ONE sentence (≤ 280 chars). Declarative. No hedging.
Hedging belongs in `credibility`, not in the wording. Hedging belongs in `credibility`, not in the wording.
## Output protocol ## Output protocol — bilingual EN + PT-BR (mandatory)
Emit a strict JSON object. No prose. No code fence. Emit a strict JSON object. No prose. No code fence. Every narrative field
appears in EN AND in PT-BR (Brazilian Portuguese with UTF-8 accents).
```json ```json
{ {
"credibility": "high | medium | low | speculation", "credibility": "high | medium | low | speculation",
"access_to_event": "One paragraph describing what the person had direct, indirect, or no access to. Ground specific facts in chunk_ids.", "access_to_event": "EN one paragraph describing access. Ground specific facts in chunk_ids.",
"bias_notes": "One paragraph naming concrete biases visible in the corpus (e.g. official role conflict, prior public stance, institutional pressure). Avoid generic skepticism.", "access_to_event_pt_br": "PT-BR um parágrafo descrevendo acesso. Fundamente fatos específicos em chunk_ids.",
"bias_notes": "EN one paragraph naming concrete biases visible in the corpus.",
"bias_notes_pt_br": "PT-BR um parágrafo nomeando vieses concretos visíveis no corpus.",
"corroboration_refs": [ "corroboration_refs": [
{"chunk_id": "c0042", "supports": true}, {"chunk_id": "c0042", "supports": true},
{"chunk_id": "c0087", "supports": false} {"chunk_id": "c0087", "supports": false}
], ],
"verdict": "One-sentence declarative judgment of this witness's reliability for the matters at hand." "verdict": "EN one-sentence declarative judgment.",
"verdict_pt_br": "PT-BR uma frase declarativa equivalente."
} }
``` ```
Constraints: Constraints:
- `access_to_event` and `bias_notes` ≤ 800 chars each. - `access_to_event` and `bias_notes` ≤ 800 chars each (per language).
- `corroboration_refs` ≤ 8 entries, MUST cite chunk_id values that - `corroboration_refs` ≤ 8 entries, MUST cite chunk_id values that appear
appear in the corpus shortlist you were given. in the corpus shortlist you were given.
- `verdict` ≤ 280 chars, no hedging language inside the sentence. - `verdict` ≤ 280 chars (per language), no hedging language inside the
sentence.
- A missing `*_pt_br` sibling is a hard validation failure — the writer
rejects the analysis.
If the corpus contains no chunks where the named person actually appears If the corpus contains no chunks where the named person actually appears
(only the entity card from the wiki without supporting passages), emit (only the entity card from the wiki without supporting passages), emit

View file

@ -38,26 +38,38 @@ keep it from being safely shipped as the final answer.
decides whether to dispatch follow-up evidence work or downgrade the decides whether to dispatch follow-up evidence work or downgrade the
confidence_band. confidence_band.
## Output protocol ## Output protocol — bilingual EN + PT-BR (mandatory)
Emit a strict JSON object. No prose. No code fence. Just the object. Emit a strict JSON object. No prose. No code fence. Every narrative field
appears in EN AND in PT-BR (Brazilian Portuguese with UTF-8 accents). The
arrays are **parallel**: `hidden_assumptions[i]` and
`hidden_assumptions_pt_br[i]` describe the SAME assumption, in the two
languages, in matching order. Same arity (length must match).
```json ```json
{ {
"severity": "low | medium | high", "severity": "low | medium | high",
"hidden_assumptions": ["sentence", "sentence"], "hidden_assumptions": ["EN sentence", "EN sentence"],
"failure_modes": ["sentence", "sentence"], "hidden_assumptions_pt_br": ["PT-BR frase", "PT-BR frase"],
"alternative_explanations": ["sentence", "sentence"], "failure_modes": ["EN sentence", "EN sentence"],
"recommended_tests": ["sentence", "sentence"], "failure_modes_pt_br": ["PT-BR frase", "PT-BR frase"],
"verdict_one_sentence": "..." "alternative_explanations": ["EN sentence", "EN sentence"],
"alternative_explanations_pt_br": ["PT-BR frase", "PT-BR frase"],
"recommended_tests": ["EN sentence", "EN sentence"],
"recommended_tests_pt_br": ["PT-BR frase", "PT-BR frase"],
"verdict_one_sentence": "EN one declarative sentence.",
"verdict_one_sentence_pt_br": "PT-BR uma frase declarativa equivalente."
} }
``` ```
Constraints: Constraints:
- 2-5 entries per array. Empty arrays only when the attack surface is - 2-5 entries per array. Empty arrays only when the attack surface is
genuinely empty (rare). genuinely empty (rare). EN array and its PT-BR sibling MUST have the
- Each array entry ≤ 200 chars. same length.
- `verdict_one_sentence` ≤ 280 chars. - Each array entry ≤ 240 chars (per language).
- `verdict_one_sentence` ≤ 280 chars (per language).
- A missing `*_pt_br` sibling, or a length mismatch, is a hard validation
failure — the writer rejects the review.
If the input hypothesis is too thin to attack (e.g. position is one word, 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. no argument_for, no evidence), emit `INSUFFICIENT_HYPOTHESIS` and stop.

View file

@ -37,30 +37,37 @@ implies for the case.
5. Severity: implicit. You do not assign a severity field — your job 5. Severity: implicit. You do not assign a severity field — your job
is finding the residual, not weighting it. is finding the residual, not weighting it.
## Output protocol ## Output protocol — bilingual EN + PT-BR (mandatory)
Emit a strict JSON array. No prose. No code fence. Emit a strict JSON array. No prose. No code fence. Every narrative field
appears in EN AND in PT-BR (Brazilian Portuguese with UTF-8 accents).
```json ```json
[ [
{ {
"title": "Short label for this outlier (≤ 80 chars)", "title": "EN short label (≤ 80 chars)",
"title_pt_br": "PT-BR título curto (≤ 80 chars)",
"chunk_id": "c0042", "chunk_id": "c0042",
"doc_id": "dow-uap-d017-...", "doc_id": "dow-uap-d017-...",
"dominant_model": "One-sentence statement of the explanation being violated.", "dominant_model": "EN one-sentence statement of the explanation being violated.",
"why_surprising": "One paragraph. Concrete. Quantitative when possible.", "dominant_model_pt_br": "PT-BR uma frase do modelo dominante sendo violado.",
"what_it_implies": "One sentence. Pick (a), (b), or (c) per the rules.", "why_surprising": "EN one paragraph. Concrete. Quantitative when possible.",
"suggested_next_move": "One sentence." "why_surprising_pt_br": "PT-BR um parágrafo. Concreto. Quantitativo quando possível.",
"what_it_implies": "EN one sentence. Pick (a), (b), or (c) per the rules.",
"what_it_implies_pt_br": "PT-BR uma frase. Escolha (a), (b) ou (c) conforme as regras.",
"suggested_next_move": "EN one sentence.",
"suggested_next_move_pt_br": "PT-BR uma frase."
} }
] ]
``` ```
Constraints: Constraints:
- 0-3 entries. Empty array `[]` when nothing stands out (rare and - 0-3 entries. Empty array `[]` when nothing stands out (rare and honest).
honest). - `why_surprising` ≤ 600 chars (per language).
- `why_surprising` ≤ 600 chars. - All other strings ≤ 280 chars (per language).
- All other strings ≤ 280 chars.
- `chunk_id` MUST be present in the corpus shortlist. - `chunk_id` MUST be present in the corpus shortlist.
- A missing `*_pt_br` sibling is a hard validation failure — the writer
rejects the outlier.
If the corpus shortlist has no genuine outlier — everything fits a If the corpus shortlist has no genuine outlier — everything fits a
single mundane explanation — emit `NO_OUTLIERS` and stop. single mundane explanation — emit `NO_OUTLIERS` and stop.

View file

@ -28,26 +28,30 @@ the posterior never rose).
- `supersede` — a new hypothesis better explains the data; close - `supersede` — a new hypothesis better explains the data; close
this one and queue a new tournament. Include `supersede_reason`. this one and queue a new tournament. Include `supersede_reason`.
## Output protocol ## Output protocol — bilingual EN + PT-BR (mandatory)
Emit a strict JSON object. No prose. No code fence. Emit a strict JSON object. No prose. No code fence. Every narrative field
appears in EN AND in PT-BR (Brazilian Portuguese with UTF-8 accents).
```json ```json
{ {
"new_posterior": 0.45, "new_posterior": 0.45,
"new_confidence_band": "low", "new_confidence_band": "low",
"delta": 0.05, "delta": 0.05,
"rationale": "Concrete prose with [[doc-id/pNNN#cNNNN]] citations.", "rationale": "EN concrete prose with [[doc-id/pNNN#cNNNN]] citations.",
"rationale_pt_br": "PT-BR prosa concreta com [[doc-id/pNNN#cNNNN]] citações.",
"recommended_action": "keep | downgrade | upgrade | supersede", "recommended_action": "keep | downgrade | upgrade | supersede",
"supersede_reason": "Only when action == 'supersede'. Otherwise omit." "supersede_reason": "EN — only when action == 'supersede'. Otherwise omit.",
"supersede_reason_pt_br": "PT-BR — só quando action == 'supersede'. Caso contrário, omita."
} }
``` ```
Constraints: Constraints:
- `new_posterior` ∈ [0, 1]. - `new_posterior` ∈ [0, 1].
- `new_confidence_band` MUST match the band thresholds for `new_posterior`. - `new_confidence_band` MUST match the band thresholds for `new_posterior`.
- `rationale` ≤ 600 chars. - `rationale` ≤ 1200 chars (per language).
- `supersede_reason` ≤ 280 chars. - `supersede_reason` ≤ 280 chars (per language).
- A missing `_pt_br` sibling is a hard validation failure.
If the corpus has NO new evidence since the hypothesis was last reviewed If the corpus has NO new evidence since the hypothesis was last reviewed
(no chunks beyond what was already cited), emit `NO_NEW_EVIDENCE` and (no chunks beyond what was already cited), emit `NO_NEW_EVIDENCE` and

View file

@ -24,6 +24,7 @@ const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "case-writer.md");
export interface CaseWriterTask { export interface CaseWriterTask {
job_id: string; job_id: string;
topic: string; topic: string;
topic_pt_br?: string;
/** When set, restrict to artefacts touching this doc_id (via chunk FK). */ /** When set, restrict to artefacts touching this doc_id (via chunk FK). */
doc_id?: string; doc_id?: string;
lang?: "pt" | "en"; lang?: "pt" | "en";
@ -164,15 +165,18 @@ function buildPrompt(
witnesses: WitnessRow[], witnesses: WitnessRow[],
gaps: GapRow[], gaps: GapRow[],
): string { ): string {
const langNote = task.lang === "en"
? "Write the narrative in English."
: "Escreva a narrativa em português brasileiro (PT-BR), preservando acentos UTF-8. Cite [[wiki-links]] em inglês como aparecem nos artefatos.";
return [ return [
`# Case folder: ${task.topic}`, `# Case folder`,
"",
`**Topic (EN).** ${task.topic}`,
`**Tópico (PT-BR).** ${task.topic_pt_br ?? task.topic}`,
"", "",
task.doc_id ? `Scoped to document: ${task.doc_id}` : "Scope: all documents", task.doc_id ? `Scoped to document: ${task.doc_id}` : "Scope: all documents",
"", "",
langNote, "**Bilingual output mandatory.** Write each act in BOTH English and",
"Brazilian Portuguese (PT-BR), interleaved per the system-prompt",
"structure. UTF-8 accents preserved. Verbatim chunk quotes stay in",
"their source language; only the surrounding narration is translated.",
"", "",
"## Artefacts available", "## Artefacts available",
"", "",
@ -321,7 +325,7 @@ export async function runCaseWriter(task: CaseWriterTask): Promise<
if (body_md === null) return { skipped: true, reason: "INSUFFICIENT_ARTEFACTS" }; if (body_md === null) return { skipped: true, reason: "INSUFFICIENT_ARTEFACTS" };
return await writeCaseReport({ return await writeCaseReport({
topic, slug, body_md, topic, topic_pt_br: task.topic_pt_br, slug, body_md,
meta: { meta: {
n_evidence: evidence.length, n_evidence: evidence.length,
n_hypotheses: hypotheses.length, n_hypotheses: hypotheses.length,

View file

@ -66,8 +66,9 @@ function buildPrompt(task: DupinTask, hits: SearchHit[], lang: "pt" | "en"): str
"Inspect the chunks for pairs (or small groups) that cannot both be true.", "Inspect the chunks for pairs (or small groups) that cannot both be true.",
"Emit at most 3 contradictions. Each must cite ≥ 2 distinct chunk_ids.", "Emit at most 3 contradictions. Each must cite ≥ 2 distinct chunk_ids.",
"Emit the JSON array exactly as specified by the system prompt — no prose,", "Emit the JSON array exactly as specified by the system prompt — no prose,",
"no code fence, no preamble. If no genuine contradiction exists,", "no code fence, no preamble. **Bilingual is mandatory:** every narrative",
"emit the literal word `NO_CONTRADICTIONS`.", "field (topic, notes, statement) appears in both EN and PT-BR. If no",
"genuine contradiction exists, emit the literal word `NO_CONTRADICTIONS`.",
].join("\n"); ].join("\n");
} }
@ -94,9 +95,10 @@ function coercePositions(raw: unknown): ContradictionPosition[] {
const doc_id = typeof o.doc_id === "string" ? o.doc_id.trim() : ""; const doc_id = typeof o.doc_id === "string" ? o.doc_id.trim() : "";
const chunk_id = typeof o.chunk_id === "string" ? o.chunk_id.trim() : ""; const chunk_id = typeof o.chunk_id === "string" ? o.chunk_id.trim() : "";
const statement = typeof o.statement === "string" ? o.statement.trim() : ""; const statement = typeof o.statement === "string" ? o.statement.trim() : "";
if (!doc_id || !chunk_id || !statement) continue; const statement_pt_br = typeof o.statement_pt_br === "string" ? o.statement_pt_br.trim() : "";
if (!doc_id || !chunk_id || !statement || !statement_pt_br) continue;
out.push({ out.push({
doc_id, chunk_id, statement, doc_id, chunk_id, statement, statement_pt_br,
stance: typeof o.stance === "string" ? o.stance.trim() : undefined, stance: typeof o.stance === "string" ? o.stance.trim() : undefined,
}); });
} }
@ -179,12 +181,14 @@ export async function runDupin(task: DupinTask): Promise<
if (!raw || typeof raw !== "object") continue; if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>; const o = raw as Record<string, unknown>;
const topic = typeof o.topic === "string" ? o.topic.trim() : ""; const topic = typeof o.topic === "string" ? o.topic.trim() : "";
const topic_pt_br = typeof o.topic_pt_br === "string" ? o.topic_pt_br.trim() : "";
const positions = coercePositions(o.positions); const positions = coercePositions(o.positions);
if (!topic || positions.length < 2) continue; if (!topic || !topic_pt_br || positions.length < 2) continue;
const args: WriteContradictionArgs = { const args: WriteContradictionArgs = {
topic, topic, topic_pt_br,
positions, positions,
notes: typeof o.notes === "string" ? o.notes.trim() : undefined, notes: typeof o.notes === "string" ? o.notes.trim() : undefined,
notes_pt_br: typeof o.notes_pt_br === "string" ? o.notes_pt_br.trim() : undefined,
resolution_status: o.resolution_status === "resolved" resolution_status: o.resolution_status === "resolved"
? "resolved" ? "resolved"
: o.resolution_status === "irreconcilable" : o.resolution_status === "irreconcilable"

View file

@ -28,6 +28,9 @@ const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "holmes.md");
export interface HolmesTask { export interface HolmesTask {
job_id: string; job_id: string;
question: string; question: string;
/** Optional PT-BR mirror of the question. If omitted, the EN one is used
* for both sides until the model emits PT-BR output. */
question_pt_br?: string;
/** Optional scope narrowing — restrict the search to one doc / entity. */ /** Optional scope narrowing — restrict the search to one doc / entity. */
doc_id?: string; doc_id?: string;
lang?: "pt" | "en"; lang?: "pt" | "en";
@ -54,10 +57,12 @@ function renderChunkBlock(hits: SearchHit[], lang: "pt" | "en"): string {
function buildPrompt(task: HolmesTask, hits: SearchHit[], lang: "pt" | "en"): string { function buildPrompt(task: HolmesTask, hits: SearchHit[], lang: "pt" | "en"): string {
const block = renderChunkBlock(hits, lang); const block = renderChunkBlock(hits, lang);
const ptQ = task.question_pt_br?.trim();
return [ return [
`# Question to investigate`, `# Question to investigate`,
"", "",
task.question, `**EN.** ${task.question}`,
ptQ ? `**PT-BR.** ${ptQ}` : null,
"", "",
`## Corpus shortlist (${hits.length} chunks${task.doc_id ? `, scoped to ${task.doc_id}` : ""})`, `## Corpus shortlist (${hits.length} chunks${task.doc_id ? `, scoped to ${task.doc_id}` : ""})`,
"", "",
@ -66,11 +71,13 @@ function buildPrompt(task: HolmesTask, hits: SearchHit[], lang: "pt" | "en"): st
"## Your task", "## Your task",
"", "",
"Build 2-3 rival hypotheses about the question above. Each must cite at", "Build 2-3 rival hypotheses about the question above. Each must cite at",
"least one chunk via [[doc-id/pNNN#cNNNN]] in argument_for and", "least one chunk via [[doc-id/pNNN#cNNNN]] in both argument_for and",
"argument_against. Assign priors + posteriors summing roughly to 1.0.", "argument_against (EN) and in argument_for_pt_br and",
"Emit the JSON array exactly as specified by the system prompt — no prose,", "argument_against_pt_br (PT-BR). Assign priors + posteriors summing",
"no code fence, no preamble.", "roughly to 1.0. Emit the JSON array exactly as specified by the system",
].join("\n"); "prompt — no prose, no code fence, no preamble. **Bilingual is mandatory:",
"every narrative field appears in both EN and PT-BR.**",
].filter(Boolean).join("\n");
} }
function extractJsonArray(text: string): unknown[] | null { function extractJsonArray(text: string): unknown[] | null {
@ -143,20 +150,26 @@ export async function runHolmes(task: HolmesTask): Promise<
const out: Array<{ hypothesis_id: string; case_file: string }> = []; const out: Array<{ hypothesis_id: string; case_file: string }> = [];
for (const raw of arr.slice(0, 3)) { for (const raw of arr.slice(0, 3)) {
const r = raw as Record<string, unknown>;
const strOrUndef = (k: string): string | undefined =>
typeof r[k] === "string" && (r[k] as string).trim().length > 0
? (r[k] as string).trim() : undefined;
const args: WriteHypothesisArgs = { const args: WriteHypothesisArgs = {
question: task.question, question: task.question,
position: String((raw as { position?: unknown }).position ?? "").trim(), question_pt_br: task.question_pt_br ?? task.question,
argument_for: typeof (raw as { argument_for?: unknown }).argument_for === "string" position: String(r.position ?? "").trim(),
? (raw as { argument_for: string }).argument_for : undefined, position_pt_br: strOrUndef("position_pt_br"),
argument_against: typeof (raw as { argument_against?: unknown }).argument_against === "string" argument_for: strOrUndef("argument_for"),
? (raw as { argument_against: string }).argument_against : undefined, argument_for_pt_br: strOrUndef("argument_for_pt_br"),
prior: Number((raw as { prior?: unknown }).prior), argument_against: strOrUndef("argument_against"),
posterior: Number((raw as { posterior?: unknown }).posterior), argument_against_pt_br: strOrUndef("argument_against_pt_br"),
confidence_band: (raw as { confidence_band?: WriteHypothesisArgs["confidence_band"] }).confidence_band, prior: Number(r.prior),
evidence_refs: Array.isArray((raw as { evidence_refs?: unknown }).evidence_refs) posterior: Number(r.posterior),
? (raw as { evidence_refs: Array<{ evidence_id?: string; supports?: boolean; weight?: number }> }).evidence_refs confidence_band: r.confidence_band as WriteHypothesisArgs["confidence_band"],
.filter((r): r is { evidence_id: string; supports?: boolean; weight?: number } => evidence_refs: Array.isArray(r.evidence_refs)
typeof r?.evidence_id === "string" && r.evidence_id.length > 0) ? (r.evidence_refs as Array<{ evidence_id?: string; supports?: boolean; weight?: number }>)
.filter((x): x is { evidence_id: string; supports?: boolean; weight?: number } =>
typeof x?.evidence_id === "string" && x.evidence_id.length > 0)
: [], : [],
}; };
if (!args.position) continue; if (!args.position) continue;

View file

@ -90,8 +90,10 @@ function buildPrompt(
"", "",
"Produce the structured witness analysis as specified by the system", "Produce the structured witness analysis as specified by the system",
"prompt. Cite chunk_ids from the shortlist above in", "prompt. Cite chunk_ids from the shortlist above in",
"`corroboration_refs`. If the shortlist is too thin to ground an", "`corroboration_refs`. **Bilingual is mandatory:** access_to_event,",
"honest assessment, emit `INSUFFICIENT_TESTIMONY`.", "bias_notes, and verdict each appear in both EN and PT-BR. If the",
"shortlist is too thin to ground an honest assessment, emit",
"`INSUFFICIENT_TESTIMONY`.",
].filter(Boolean).join("\n"); ].filter(Boolean).join("\n");
} }
@ -224,16 +226,22 @@ export async function runPoirot(task: PoirotTask): Promise<
? obj.credibility as "high" | "medium" | "low" | "speculation" ? obj.credibility as "high" | "medium" | "low" | "speculation"
: "speculation"; : "speculation";
const str = (k: string): string =>
typeof obj[k] === "string" ? (obj[k] as string).trim() : "";
const args: WriteWitnessAnalysisArgs = { const args: WriteWitnessAnalysisArgs = {
person_entity_pk: entity_pk, person_entity_pk: entity_pk,
credibility, credibility,
access_to_event: typeof obj.access_to_event === "string" ? obj.access_to_event.trim() : "", access_to_event: str("access_to_event"),
bias_notes: typeof obj.bias_notes === "string" ? obj.bias_notes.trim() : "", access_to_event_pt_br: str("access_to_event_pt_br"),
bias_notes: str("bias_notes"),
bias_notes_pt_br: str("bias_notes_pt_br"),
corroboration_refs: coerceCorroboration(obj.corroboration_refs), corroboration_refs: coerceCorroboration(obj.corroboration_refs),
verdict: typeof obj.verdict === "string" ? obj.verdict.trim() : "", verdict: str("verdict"),
verdict_pt_br: str("verdict_pt_br"),
}; };
if (!args.access_to_event || !args.bias_notes || !args.verdict) { if (!args.access_to_event || !args.bias_notes || !args.verdict
return { skipped: true, reason: "incomplete_analysis" }; || !args.access_to_event_pt_br || !args.bias_notes_pt_br || !args.verdict_pt_br) {
return { skipped: true, reason: "incomplete_bilingual_analysis" };
} }
// Pass the shortlist's most-represented doc_id as a fallback for chunk_id // Pass the shortlist's most-represented doc_id as a fallback for chunk_id

View file

@ -85,8 +85,10 @@ function buildPrompt(h: HypothesisRow, evidence: EvidenceRow[]): string {
"", "",
"Red-team the hypothesis. Find what the author didn't address. Emit the", "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", "JSON object exactly as specified by the system prompt — no prose, no",
"code fence, no preamble. If the hypothesis is too thin to attack,", "code fence, no preamble. **Bilingual is mandatory:** every narrative",
"emit the literal word `INSUFFICIENT_HYPOTHESIS`.", "field appears in both EN and PT-BR with matching array lengths. If",
"the hypothesis is too thin to attack, emit the literal word",
"`INSUFFICIENT_HYPOTHESIS`.",
].join("\n"); ].join("\n");
} }
@ -177,15 +179,20 @@ export async function runSchneier(task: SchneierTask): Promise<
hypothesis_id: h.hypothesis_id, hypothesis_id: h.hypothesis_id,
severity, severity,
hidden_assumptions: coerceStringArray(obj.hidden_assumptions), hidden_assumptions: coerceStringArray(obj.hidden_assumptions),
hidden_assumptions_pt_br: coerceStringArray(obj.hidden_assumptions_pt_br),
failure_modes: coerceStringArray(obj.failure_modes), failure_modes: coerceStringArray(obj.failure_modes),
failure_modes_pt_br: coerceStringArray(obj.failure_modes_pt_br),
alternative_explanations: coerceStringArray(obj.alternative_explanations), alternative_explanations: coerceStringArray(obj.alternative_explanations),
alternative_explanations_pt_br: coerceStringArray(obj.alternative_explanations_pt_br),
recommended_tests: coerceStringArray(obj.recommended_tests), recommended_tests: coerceStringArray(obj.recommended_tests),
recommended_tests_pt_br: coerceStringArray(obj.recommended_tests_pt_br),
verdict_one_sentence: typeof obj.verdict_one_sentence === "string" verdict_one_sentence: typeof obj.verdict_one_sentence === "string"
? obj.verdict_one_sentence.trim() ? obj.verdict_one_sentence.trim() : "",
: "", verdict_one_sentence_pt_br: typeof obj.verdict_one_sentence_pt_br === "string"
? obj.verdict_one_sentence_pt_br.trim() : "",
}; };
if (!args.verdict_one_sentence) { if (!args.verdict_one_sentence || !args.verdict_one_sentence_pt_br) {
return { skipped: true, reason: "no_verdict" }; return { skipped: true, reason: "no_verdict_bilingual" };
} }
return await writeRedTeamReview(args, { return await writeRedTeamReview(args, {

View file

@ -61,8 +61,9 @@ function buildPrompt(task: TalebTask, hits: SearchHit[], lang: "pt" | "en"): str
"", "",
"Identify AT MOST 3 outliers per the system prompt rules. State the", "Identify AT MOST 3 outliers per the system prompt rules. State the",
"dominant_model first, then the chunk that violates it. Emit the", "dominant_model first, then the chunk that violates it. Emit the",
"JSON array exactly as specified — no prose, no code fence. If", "JSON array exactly as specified — no prose, no code fence.",
"nothing genuinely stands out, emit `NO_OUTLIERS`.", "**Bilingual is mandatory:** every narrative field appears in both",
"EN and PT-BR. If nothing genuinely stands out, emit `NO_OUTLIERS`.",
].join("\n"); ].join("\n");
} }
@ -144,21 +145,36 @@ export async function runTaleb(task: TalebTask): Promise<
for (const raw of arr.slice(0, 3)) { for (const raw of arr.slice(0, 3)) {
if (!raw || typeof raw !== "object") continue; if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>; const o = raw as Record<string, unknown>;
const str = (k: string): string =>
typeof o[k] === "string" ? (o[k] as string).trim() : "";
const args: WriteOutlierGapArgs = { const args: WriteOutlierGapArgs = {
title: typeof o.title === "string" ? o.title.trim() : "", title: str("title"),
doc_id: typeof o.doc_id === "string" ? o.doc_id.trim() : "", title_pt_br: str("title_pt_br"),
chunk_id: typeof o.chunk_id === "string" ? o.chunk_id.trim() : "", doc_id: str("doc_id"),
dominant_model: typeof o.dominant_model === "string" ? o.dominant_model.trim() : "", chunk_id: str("chunk_id"),
why_surprising: typeof o.why_surprising === "string" ? o.why_surprising.trim() : "", dominant_model: str("dominant_model"),
what_it_implies: typeof o.what_it_implies === "string" ? o.what_it_implies.trim() : "", dominant_model_pt_br: str("dominant_model_pt_br"),
suggested_next_move: typeof o.suggested_next_move === "string" ? o.suggested_next_move.trim() : "", why_surprising: str("why_surprising"),
why_surprising_pt_br: str("why_surprising_pt_br"),
what_it_implies: str("what_it_implies"),
what_it_implies_pt_br: str("what_it_implies_pt_br"),
suggested_next_move: str("suggested_next_move"),
suggested_next_move_pt_br: str("suggested_next_move_pt_br"),
}; };
if (!args.title || !args.doc_id || !args.chunk_id || !args.dominant_model const argsAny = args as unknown as Record<string, unknown>;
|| !args.why_surprising || !args.what_it_implies || !args.suggested_next_move) { const missing = [
"title", "title_pt_br", "doc_id", "chunk_id",
"dominant_model", "dominant_model_pt_br",
"why_surprising", "why_surprising_pt_br",
"what_it_implies", "what_it_implies_pt_br",
"suggested_next_move", "suggested_next_move_pt_br",
].filter((k) => !argsAny[k]);
if (missing.length > 0) {
await audit({ await audit({
event: "write_outlier_gap_failed", event: "write_outlier_gap_failed",
job_id: task.job_id, detective: "taleb@detective", job_id: task.job_id, detective: "taleb@detective",
reason: "incomplete_outlier", title: args.title.slice(0, 120), reason: "incomplete_bilingual_outlier",
missing, title: args.title.slice(0, 120),
}); });
continue; continue;
} }

View file

@ -127,8 +127,10 @@ function buildPrompt(
"## Your task", "## Your task",
"", "",
"Recompute the posterior honestly. Emit the JSON object exactly as", "Recompute the posterior honestly. Emit the JSON object exactly as",
"specified by the system prompt. If there is NO new chunk to move the", "specified by the system prompt. **Bilingual is mandatory:** rationale",
"posterior on, emit `NO_NEW_EVIDENCE`.", "appears in both EN and PT-BR; supersede_reason (when present) also",
"bilingual. If there is NO new chunk to move the posterior on, emit",
"`NO_NEW_EVIDENCE`.",
].join("\n"); ].join("\n");
} }
@ -243,14 +245,18 @@ export async function runTetlock(task: TetlockTask): Promise<
new_confidence_band: bandFromPosterior(new_posterior), new_confidence_band: bandFromPosterior(new_posterior),
delta, delta,
rationale: typeof obj.rationale === "string" ? obj.rationale.trim() : "", rationale: typeof obj.rationale === "string" ? obj.rationale.trim() : "",
rationale_pt_br: typeof obj.rationale_pt_br === "string" ? obj.rationale_pt_br.trim() : "",
recommended_action: action, recommended_action: action,
supersede_reason: typeof obj.supersede_reason === "string" ? obj.supersede_reason.trim() : undefined, supersede_reason: typeof obj.supersede_reason === "string" ? obj.supersede_reason.trim() : undefined,
supersede_reason_pt_br: typeof obj.supersede_reason_pt_br === "string" ? obj.supersede_reason_pt_br.trim() : undefined,
old_posterior, old_posterior,
old_confidence_band: h.confidence_band, old_confidence_band: h.confidence_band,
}; };
if (!args.rationale) return { skipped: true, reason: "no_rationale" }; if (!args.rationale || !args.rationale_pt_br) {
if (action === "supersede" && !args.supersede_reason) { return { skipped: true, reason: "no_rationale_bilingual" };
return { skipped: true, reason: "supersede_reason_missing" }; }
if (action === "supersede" && (!args.supersede_reason || !args.supersede_reason_pt_br)) {
return { skipped: true, reason: "supersede_reason_missing_bilingual" };
} }
return await writeCalibration(args, { return await writeCalibration(args, {

View file

@ -55,12 +55,14 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise
break; break;
} }
case "hypothesis_tournament": { case "hypothesis_tournament": {
// Payload: { question, doc_id?, lang?, context_chunks? } // Payload: { question, question_pt_br?, doc_id?, lang?, context_chunks? }
const question = String(job.payload.question ?? "").trim(); const question = String(job.payload.question ?? "").trim();
if (!question) throw new Error("hypothesis_tournament requires payload.question"); if (!question) throw new Error("hypothesis_tournament requires payload.question");
const task: HolmesTask = { const task: HolmesTask = {
job_id: job.job_id, job_id: job.job_id,
question, question,
question_pt_br: typeof job.payload.question_pt_br === "string"
? job.payload.question_pt_br.trim() : undefined,
doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined, doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined,
lang: job.payload.lang === "en" ? "en" : "pt", lang: job.payload.lang === "en" ? "en" : "pt",
context_chunks: typeof job.payload.context_chunks === "number" ? job.payload.context_chunks : undefined, context_chunks: typeof job.payload.context_chunks === "number" ? job.payload.context_chunks : undefined,
@ -74,11 +76,13 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise
break; break;
} }
case "case_report": { case "case_report": {
// Payload: { topic, doc_id?, slug?, lang? } // Payload: { topic, topic_pt_br?, doc_id?, slug?, lang? }
const topic = String(job.payload.topic ?? "").trim(); const topic = String(job.payload.topic ?? "").trim();
if (!topic) throw new Error("case_report requires payload.topic"); if (!topic) throw new Error("case_report requires payload.topic");
const task: CaseWriterTask = { const task: CaseWriterTask = {
job_id: job.job_id, topic, job_id: job.job_id, topic,
topic_pt_br: typeof job.payload.topic_pt_br === "string"
? job.payload.topic_pt_br.trim() : undefined,
doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined, doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined,
slug: typeof job.payload.slug === "string" ? job.payload.slug : undefined, slug: typeof job.payload.slug === "string" ? job.payload.slug : undefined,
lang: job.payload.lang === "en" ? "en" : "pt", lang: job.payload.lang === "en" ? "en" : "pt",

View file

@ -18,8 +18,10 @@ export interface WriteCalibrationArgs {
new_confidence_band: "high" | "medium" | "low" | "speculation"; new_confidence_band: "high" | "medium" | "low" | "speculation";
delta: number; delta: number;
rationale: string; rationale: string;
rationale_pt_br?: string;
recommended_action: "keep" | "downgrade" | "upgrade" | "supersede"; recommended_action: "keep" | "downgrade" | "upgrade" | "supersede";
supersede_reason?: string; supersede_reason?: string;
supersede_reason_pt_br?: string;
/** previous posterior captured at call time — used in the case-file row. */ /** previous posterior captured at call time — used in the case-file row. */
old_posterior: number | null; old_posterior: number | null;
old_confidence_band: string | null; old_confidence_band: string | null;
@ -41,6 +43,7 @@ function bandFromPosterior(p: number): "high" | "medium" | "low" | "speculation"
function buildSection(args: WriteCalibrationArgs, ctx: WriteCalibrationContext): string { function buildSection(args: WriteCalibrationArgs, ctx: WriteCalibrationContext): string {
const ts = new Date().toISOString(); const ts = new Date().toISOString();
const ptRationale = args.rationale_pt_br ?? args.rationale;
const rows = [ const rows = [
`### ${ts}${args.recommended_action}`, `### ${ts}${args.recommended_action}`,
"", "",
@ -52,10 +55,13 @@ function buildSection(args: WriteCalibrationArgs, ctx: WriteCalibrationContext):
`| band | ${args.old_confidence_band ?? "—"} | **${args.new_confidence_band}** |`, `| band | ${args.old_confidence_band ?? "—"} | **${args.new_confidence_band}** |`,
`| delta | — | ${args.delta >= 0 ? "+" : ""}${args.delta.toFixed(3)} |`, `| delta | — | ${args.delta >= 0 ? "+" : ""}${args.delta.toFixed(3)} |`,
"", "",
`**Rationale.** ${args.rationale}`, `**Rationale (EN).** ${args.rationale}`,
"",
`**Justificativa (PT-BR).** ${ptRationale}`,
]; ];
if (args.recommended_action === "supersede" && args.supersede_reason) { if (args.recommended_action === "supersede" && args.supersede_reason) {
rows.push("", `**Supersede reason.** ${args.supersede_reason}`); rows.push("", `**Supersede reason (EN).** ${args.supersede_reason}`);
rows.push("", `**Motivo da substituição (PT-BR).** ${args.supersede_reason_pt_br ?? args.supersede_reason}`);
} }
rows.push(""); rows.push("");
return rows.join("\n"); return rows.join("\n");
@ -86,10 +92,10 @@ export async function writeCalibration(
// Force the band to match the posterior — Tetlock can mis-label. // Force the band to match the posterior — Tetlock can mis-label.
body.new_confidence_band = expectedBand; body.new_confidence_band = expectedBand;
if (!body.rationale?.trim()) throw new Error("rationale required"); if (!body.rationale?.trim()) throw new Error("rationale required");
// Soft cap: 1200 chars. Tetlock often writes 600-800 of substantive if (!body.rationale_pt_br?.trim()) throw new Error("rationale_pt_br required (bilingual contract)");
// reasoning + chunk citations; the prompt asks for ≤ 600 but a 2× slack // Soft cap: 1200 chars per language.
// beats failing the job on an honest analysis.
if (body.rationale.length > 1200) throw new Error(`rationale too long (${body.rationale.length} > 1200)`); if (body.rationale.length > 1200) throw new Error(`rationale too long (${body.rationale.length} > 1200)`);
if (body.rationale_pt_br.length > 1200) throw new Error(`rationale_pt_br too long`);
const action = body.recommended_action; const action = body.recommended_action;
if (!["keep", "downgrade", "upgrade", "supersede"].includes(action)) { if (!["keep", "downgrade", "upgrade", "supersede"].includes(action)) {

View file

@ -13,6 +13,7 @@ import { env } from "../lib/env";
export interface WriteCaseReportArgs { export interface WriteCaseReportArgs {
topic: string; topic: string;
topic_pt_br?: string;
slug: string; slug: string;
body_md: string; body_md: string;
meta: { meta: {
@ -36,6 +37,7 @@ function renderFrontmatter(args: WriteCaseReportArgs, ctx: WriteCaseReportContex
`schema_version: "0.1.0"`, `schema_version: "0.1.0"`,
`type: case_report`, `type: case_report`,
`topic: ${JSON.stringify(args.topic)}`, `topic: ${JSON.stringify(args.topic)}`,
`topic_pt_br: ${JSON.stringify(args.topic_pt_br ?? args.topic)}`,
`slug: ${args.slug}`, `slug: ${args.slug}`,
`created_by: ${ctx.detective}`, `created_by: ${ctx.detective}`,
`job_id: ${ctx.job_id}`, `job_id: ${ctx.job_id}`,

View file

@ -27,14 +27,17 @@ export interface ContradictionPosition {
chunk_id: string; chunk_id: string;
/** The verbatim or paraphrased claim that puts this chunk on this side. */ /** The verbatim or paraphrased claim that puts this chunk on this side. */
statement: string; statement: string;
statement_pt_br?: string;
/** Optional weight or stance label (e.g. "asserts", "denies"). */ /** Optional weight or stance label (e.g. "asserts", "denies"). */
stance?: string; stance?: string;
} }
export interface WriteContradictionArgs { export interface WriteContradictionArgs {
topic: string; topic: string;
topic_pt_br?: string;
positions: ContradictionPosition[]; positions: ContradictionPosition[];
notes?: string; notes?: string;
notes_pt_br?: string;
resolution_status?: "open" | "resolved" | "irreconcilable"; resolution_status?: "open" | "resolved" | "irreconcilable";
} }
@ -46,6 +49,7 @@ export interface WriteContradictionContext {
interface ResolvedPosition extends ContradictionPosition { interface ResolvedPosition extends ContradictionPosition {
chunk_pk: number; chunk_pk: number;
page: number; page: number;
statement_pt_br: string;
} }
/** /**
@ -90,7 +94,9 @@ function renderMd(
return [ return [
`### Position ${i + 1}${p.stance ? `${p.stance}` : ""}`, `### Position ${i + 1}${p.stance ? `${p.stance}` : ""}`,
"", "",
`> ${p.statement}`, `**(EN)** > ${p.statement}`,
"",
`**(PT-BR)** > ${p.statement_pt_br}`,
"", "",
`Source: [[${p.doc_id}/p${pageStr}#${p.chunk_id}]]`, `Source: [[${p.doc_id}/p${pageStr}#${p.chunk_id}]]`,
].join("\n"); ].join("\n");
@ -101,16 +107,21 @@ function renderMd(
"", "",
`# Contradiction ${id}`, `# Contradiction ${id}`,
"", "",
`**Topic.** ${body.topic}`, `**Topic (EN).** ${body.topic}`,
`**Tópico (PT-BR).** ${body.topic_pt_br ?? body.topic}`,
"", "",
"## Positions in tension", "## Positions in tension",
"", "",
positionBlocks.join("\n\n"), positionBlocks.join("\n\n"),
"", "",
"## Notes", "## Notes (EN)",
"", "",
body.notes || "_(no commentary recorded)_", body.notes || "_(no commentary recorded)_",
"", "",
"## Notas (PT-BR)",
"",
body.notes_pt_br || "_(sem comentário registrado)_",
"",
].join("\n"); ].join("\n");
} }
@ -119,12 +130,16 @@ export async function writeContradiction(
ctx: WriteContradictionContext, ctx: WriteContradictionContext,
): Promise<{ contradiction_id: string; case_file: string }> { ): Promise<{ contradiction_id: string; case_file: string }> {
if (!body.topic?.trim()) throw new Error("topic required"); if (!body.topic?.trim()) throw new Error("topic required");
if (!body.topic_pt_br?.trim()) throw new Error("topic_pt_br required (bilingual contract)");
if (!Array.isArray(body.positions) || body.positions.length < 2) { if (!Array.isArray(body.positions) || body.positions.length < 2) {
throw new Error("at least 2 positions required"); throw new Error("at least 2 positions required");
} }
if (body.notes && body.notes.length > 4000) { if (body.notes && body.notes.length > 4000) {
throw new Error(`notes too long (${body.notes.length} > 4000)`); throw new Error(`notes too long (${body.notes.length} > 4000)`);
} }
if (body.notes && !body.notes_pt_br?.trim()) {
throw new Error("notes_pt_br required when notes set (bilingual contract)");
}
const resolved: ResolvedPosition[] = []; const resolved: ResolvedPosition[] = [];
for (const p of body.positions) { for (const p of body.positions) {
@ -134,6 +149,9 @@ export async function writeContradiction(
if (!p?.statement?.trim()) { if (!p?.statement?.trim()) {
throw new Error(`position ${p.doc_id}/${p.chunk_id} missing statement`); throw new Error(`position ${p.doc_id}/${p.chunk_id} missing statement`);
} }
if (!p?.statement_pt_br?.trim()) {
throw new Error(`position ${p.doc_id}/${p.chunk_id} missing statement_pt_br (bilingual contract)`);
}
const chunk = await resolveChunk(p.doc_id, p.chunk_id); const chunk = await resolveChunk(p.doc_id, p.chunk_id);
if (!chunk) { if (!chunk) {
throw new Error(`chunk ${p.doc_id}/${p.chunk_id} not found`); throw new Error(`chunk ${p.doc_id}/${p.chunk_id} not found`);
@ -141,6 +159,7 @@ export async function writeContradiction(
resolved.push({ resolved.push({
...p, ...p,
statement: p.statement.trim(), statement: p.statement.trim(),
statement_pt_br: p.statement_pt_br.trim(),
chunk_pk: chunk.chunk_pk, chunk_pk: chunk.chunk_pk,
page: chunk.page, page: chunk.page,
}); });
@ -160,20 +179,24 @@ export async function writeContradiction(
chunk_id: p.chunk_id, chunk_id: p.chunk_id,
page: p.page, page: p.page,
statement: p.statement, statement: p.statement,
statement_pt_br: p.statement_pt_br,
stance: p.stance ?? null, stance: p.stance ?? null,
})); }));
await query( await query(
`INSERT INTO public.contradictions `INSERT INTO public.contradictions
(contradiction_id, topic, chunks, detected_by, resolution_status, notes) (contradiction_id, topic, topic_pt_br, chunks, detected_by,
VALUES ($1, $2, $3::jsonb, $4, $5, $6)`, resolution_status, notes, notes_pt_br)
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)`,
[ [
contradiction_id, contradiction_id,
body.topic.trim(), body.topic.trim(),
body.topic_pt_br!.trim(),
JSON.stringify(chunkPayload), JSON.stringify(chunkPayload),
ctx.detective, ctx.detective,
body.resolution_status ?? "open", body.resolution_status ?? "open",
body.notes ?? null, body.notes ?? null,
body.notes_pt_br ?? null,
], ],
); );

View file

@ -25,9 +25,13 @@ export interface EvidenceRef {
export interface WriteHypothesisArgs { export interface WriteHypothesisArgs {
question: string; question: string;
question_pt_br?: string;
position: string; position: string;
position_pt_br?: string;
argument_for?: string; argument_for?: string;
argument_for_pt_br?: string;
argument_against?: string; argument_against?: string;
argument_against_pt_br?: string;
prior?: number; prior?: number;
posterior?: number; posterior?: number;
confidence_band?: "high" | "medium" | "low" | "speculation"; confidence_band?: "high" | "medium" | "low" | "speculation";
@ -70,23 +74,39 @@ function renderMd(id: string, body: WriteHypothesisArgs, ctx: WriteHypothesisCon
`created_at: ${new Date().toISOString()}`, `created_at: ${new Date().toISOString()}`,
"---", "---",
].filter(Boolean).join("\n"); ].filter(Boolean).join("\n");
const ptQuestion = body.question_pt_br ?? body.question;
const ptPosition = body.position_pt_br ?? body.position;
const ptFor = body.argument_for_pt_br ?? body.argument_for;
const ptAgainst = body.argument_against_pt_br ?? body.argument_against;
return [ return [
fm, fm,
"", "",
`# Hypothesis ${id}`, `# Hypothesis ${id}`,
"", "",
`**Question.** ${body.question}`, `**Question (EN).** ${body.question}`,
`**Pergunta (PT-BR).** ${ptQuestion}`,
"", "",
`**Position.** ${body.position}`, `**Position (EN).** ${body.position}`,
`**Posição (PT-BR).** ${ptPosition}`,
"", "",
"## Argument for", "## Argument for (EN)",
"", "",
body.argument_for || "_(none recorded — speculation)_", body.argument_for || "_(none recorded — speculation)_",
"", "",
"## Argument against", "## Argumento a favor (PT-BR)",
"",
ptFor || "_(nenhum registrado — especulação)_",
"",
"## Argument against (EN)",
"", "",
body.argument_against || "_(none recorded — no counter-argument framed yet)_", body.argument_against || "_(none recorded — no counter-argument framed yet)_",
"", "",
"## Argumento contra (PT-BR)",
"",
ptAgainst || "_(nenhum registrado — sem contra-argumento ainda)_",
"",
"## Evidence", "## Evidence",
"", "",
evRefs || "_(none linked yet — Locard chain pending)_", evRefs || "_(none linked yet — Locard chain pending)_",
@ -100,6 +120,13 @@ export async function writeHypothesis(
): Promise<{ hypothesis_id: string; case_file: string }> { ): Promise<{ hypothesis_id: string; case_file: string }> {
if (!body.question?.trim()) throw new Error("question required"); if (!body.question?.trim()) throw new Error("question required");
if (!body.position?.trim()) throw new Error("position required"); if (!body.position?.trim()) throw new Error("position required");
if (!body.position_pt_br?.trim()) throw new Error("position_pt_br required (bilingual contract)");
if (body.argument_for && !body.argument_for_pt_br?.trim()) {
throw new Error("argument_for_pt_br required when argument_for is set (bilingual contract)");
}
if (body.argument_against && !body.argument_against_pt_br?.trim()) {
throw new Error("argument_against_pt_br required when argument_against is set (bilingual contract)");
}
const prior = clamp01(body.prior); const prior = clamp01(body.prior);
const posterior = clamp01(body.posterior); const posterior = clamp01(body.posterior);
@ -128,12 +155,16 @@ export async function writeHypothesis(
const hypothesis_id = await allocate.hypothesisId(); const hypothesis_id = await allocate.hypothesisId();
await query( await query(
`INSERT INTO public.hypotheses `INSERT INTO public.hypotheses
(hypothesis_id, question, position, argument_for, argument_against, (hypothesis_id, question, question_pt_br, position, position_pt_br,
argument_for, argument_for_pt_br, argument_against, argument_against_pt_br,
evidence_refs, prior, posterior, confidence_band, status, created_by) evidence_refs, prior, posterior, confidence_band, status, created_by)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11)`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11,$12,$13,$14,$15)`,
[ [
hypothesis_id, body.question, body.position, hypothesis_id,
body.argument_for ?? null, body.argument_against ?? null, body.question, body.question_pt_br ?? body.question,
body.position, body.position_pt_br,
body.argument_for ?? null, body.argument_for_pt_br ?? null,
body.argument_against ?? null, body.argument_against_pt_br ?? null,
JSON.stringify(refs), JSON.stringify(refs),
prior, posterior, band, body.status ?? "open", prior, posterior, band, body.status ?? "open",
ctx.detective, ctx.detective,

View file

@ -16,12 +16,17 @@ import { query, queryOne } from "../lib/pg";
export interface WriteOutlierGapArgs { export interface WriteOutlierGapArgs {
title: string; title: string;
title_pt_br?: string;
doc_id: string; doc_id: string;
chunk_id: string; chunk_id: string;
dominant_model: string; dominant_model: string;
dominant_model_pt_br?: string;
why_surprising: string; why_surprising: string;
why_surprising_pt_br?: string;
what_it_implies: string; what_it_implies: string;
what_it_implies_pt_br?: string;
suggested_next_move: string; suggested_next_move: string;
suggested_next_move_pt_br?: string;
} }
export interface WriteOutlierGapContext { export interface WriteOutlierGapContext {
@ -61,25 +66,42 @@ function renderMd(
fm, fm,
"", "",
`# Outlier ${id}${body.title}`, `# Outlier ${id}${body.title}`,
`**Título (PT-BR).** ${body.title_pt_br ?? body.title}`,
"", "",
`**Source.** [[${body.doc_id}/p${pageStr}#${body.chunk_id}]]`, `**Source.** [[${body.doc_id}/p${pageStr}#${body.chunk_id}]]`,
"", "",
"## Dominant model", "## Dominant model (EN)",
"", "",
body.dominant_model, body.dominant_model,
"", "",
"## Why surprising", "## Modelo dominante (PT-BR)",
"",
body.dominant_model_pt_br ?? body.dominant_model,
"",
"## Why surprising (EN)",
"", "",
body.why_surprising, body.why_surprising,
"", "",
"## What it implies", "## Por que é surpreendente (PT-BR)",
"",
body.why_surprising_pt_br ?? body.why_surprising,
"",
"## What it implies (EN)",
"", "",
body.what_it_implies, body.what_it_implies,
"", "",
"## Suggested next move", "## O que implica (PT-BR)",
"",
body.what_it_implies_pt_br ?? body.what_it_implies,
"",
"## Suggested next move (EN)",
"", "",
body.suggested_next_move, body.suggested_next_move,
"", "",
"## Próximo passo sugerido (PT-BR)",
"",
body.suggested_next_move_pt_br ?? body.suggested_next_move,
"",
].join("\n"); ].join("\n");
} }
@ -88,12 +110,18 @@ export async function writeOutlierGap(
ctx: WriteOutlierGapContext, ctx: WriteOutlierGapContext,
): Promise<{ gap_id: string; case_file: string }> { ): Promise<{ gap_id: string; case_file: string }> {
if (!body.title?.trim()) throw new Error("title required"); if (!body.title?.trim()) throw new Error("title required");
if (!body.title_pt_br?.trim()) throw new Error("title_pt_br required (bilingual contract)");
if (!body.doc_id?.trim() || !body.chunk_id?.trim()) throw new Error("doc_id + chunk_id required"); if (!body.doc_id?.trim() || !body.chunk_id?.trim()) throw new Error("doc_id + chunk_id required");
if (!body.dominant_model?.trim()) throw new Error("dominant_model required"); if (!body.dominant_model?.trim()) throw new Error("dominant_model required");
if (!body.dominant_model_pt_br?.trim()) throw new Error("dominant_model_pt_br required (bilingual contract)");
if (!body.why_surprising?.trim()) throw new Error("why_surprising required"); if (!body.why_surprising?.trim()) throw new Error("why_surprising required");
if (!body.why_surprising_pt_br?.trim()) throw new Error("why_surprising_pt_br required (bilingual contract)");
if (!body.what_it_implies?.trim()) throw new Error("what_it_implies required"); if (!body.what_it_implies?.trim()) throw new Error("what_it_implies required");
if (!body.what_it_implies_pt_br?.trim()) throw new Error("what_it_implies_pt_br required (bilingual contract)");
if (!body.suggested_next_move?.trim()) throw new Error("suggested_next_move required"); if (!body.suggested_next_move?.trim()) throw new Error("suggested_next_move required");
if (!body.suggested_next_move_pt_br?.trim()) throw new Error("suggested_next_move_pt_br required (bilingual contract)");
if (body.why_surprising.length > 600) throw new Error(`why_surprising too long`); if (body.why_surprising.length > 600) throw new Error(`why_surprising too long`);
if (body.why_surprising_pt_br.length > 600) throw new Error(`why_surprising_pt_br too long`);
const cid = normalizeChunkId(body.chunk_id); const cid = normalizeChunkId(body.chunk_id);
const chunk = await queryOne<{ chunk_pk: number; page: number }>( const chunk = await queryOne<{ chunk_pk: number; page: number }>(
@ -109,21 +137,28 @@ export async function writeOutlierGap(
doc_id: body.doc_id, doc_id: body.doc_id,
chunk_id: cid, chunk_id: cid,
page: chunk.page, page: chunk.page,
dominant_model: body.dominant_model,
why_surprising: body.why_surprising,
what_it_implies: body.what_it_implies,
title: body.title, title: body.title,
title_pt_br: body.title_pt_br,
dominant_model: body.dominant_model,
dominant_model_pt_br: body.dominant_model_pt_br,
why_surprising: body.why_surprising,
why_surprising_pt_br: body.why_surprising_pt_br,
what_it_implies: body.what_it_implies,
what_it_implies_pt_br: body.what_it_implies_pt_br,
}; };
await query( await query(
`INSERT INTO public.gaps `INSERT INTO public.gaps
(gap_id, description, scope, suggested_next_move, status, created_by) (gap_id, description, description_pt_br, scope,
VALUES ($1, $2, $3::jsonb, $4, 'open', $5)`, suggested_next_move, suggested_next_move_pt_br, status, created_by)
VALUES ($1, $2, $3, $4::jsonb, $5, $6, 'open', $7)`,
[ [
gap_id, gap_id,
body.title, body.title,
body.title_pt_br,
JSON.stringify(scope), JSON.stringify(scope),
body.suggested_next_move, body.suggested_next_move,
body.suggested_next_move_pt_br,
ctx.detective, ctx.detective,
], ],
); );

View file

@ -19,10 +19,15 @@ export interface RedTeamReviewArgs {
hypothesis_id: string; hypothesis_id: string;
severity: "low" | "medium" | "high"; severity: "low" | "medium" | "high";
hidden_assumptions: string[]; hidden_assumptions: string[];
hidden_assumptions_pt_br?: string[];
failure_modes: string[]; failure_modes: string[];
failure_modes_pt_br?: string[];
alternative_explanations: string[]; alternative_explanations: string[];
alternative_explanations_pt_br?: string[];
recommended_tests: string[]; recommended_tests: string[];
recommended_tests_pt_br?: string[];
verdict_one_sentence: string; verdict_one_sentence: string;
verdict_one_sentence_pt_br?: string;
} }
export interface RedTeamReviewContext { export interface RedTeamReviewContext {
@ -34,8 +39,14 @@ const SECTION_MARKER = "## Red-team review";
function buildSection(args: RedTeamReviewArgs, ctx: RedTeamReviewContext): string { function buildSection(args: RedTeamReviewArgs, ctx: RedTeamReviewContext): string {
const ts = new Date().toISOString(); const ts = new Date().toISOString();
const bullets = (items: string[]): string => const bullets = (items: string[], emptyMsg: string): string =>
items.length === 0 ? "_(none flagged)_" : items.map((x) => `- ${x}`).join("\n"); items.length === 0 ? emptyMsg : items.map((x) => `- ${x}`).join("\n");
const pt = (a: string[] | undefined, fb: string[]) => a && a.length === fb.length ? a : fb;
const ptHidden = pt(args.hidden_assumptions_pt_br, args.hidden_assumptions);
const ptFail = pt(args.failure_modes_pt_br, args.failure_modes);
const ptAlt = pt(args.alternative_explanations_pt_br, args.alternative_explanations);
const ptTests = pt(args.recommended_tests_pt_br, args.recommended_tests);
const ptVerdict = args.verdict_one_sentence_pt_br ?? args.verdict_one_sentence;
return [ return [
"", "",
@ -43,19 +54,32 @@ function buildSection(args: RedTeamReviewArgs, ctx: RedTeamReviewContext): strin
"", "",
`_Reviewed by ${ctx.detective} on ${ts} — job \`${ctx.job_id}\`._`, `_Reviewed by ${ctx.detective} on ${ts} — job \`${ctx.job_id}\`._`,
"", "",
`**Verdict.** ${args.verdict_one_sentence}`, `**Verdict (EN).** ${args.verdict_one_sentence}`,
`**Veredito (PT-BR).** ${ptVerdict}`,
"", "",
"### Hidden assumptions", "### Hidden assumptions (EN)",
bullets(args.hidden_assumptions), bullets(args.hidden_assumptions, "_(none flagged)_"),
"", "",
"### Failure modes", "### Premissas ocultas (PT-BR)",
bullets(args.failure_modes), bullets(ptHidden, "_(nenhuma sinalizada)_"),
"", "",
"### Alternative explanations not addressed", "### Failure modes (EN)",
bullets(args.alternative_explanations), bullets(args.failure_modes, "_(none flagged)_"),
"", "",
"### Recommended discriminating tests", "### Modos de falha (PT-BR)",
bullets(args.recommended_tests), bullets(ptFail, "_(nenhum sinalizado)_"),
"",
"### Alternative explanations not addressed (EN)",
bullets(args.alternative_explanations, "_(none flagged)_"),
"",
"### Explicações alternativas não abordadas (PT-BR)",
bullets(ptAlt, "_(nenhuma sinalizada)_"),
"",
"### Recommended discriminating tests (EN)",
bullets(args.recommended_tests, "_(none flagged)_"),
"",
"### Testes discriminantes recomendados (PT-BR)",
bullets(ptTests, "_(nenhum sinalizado)_"),
"", "",
].join("\n"); ].join("\n");
} }
@ -82,9 +106,15 @@ export async function writeRedTeamReview(
throw new Error(`bad severity: ${body.severity}`); 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?.trim()) throw new Error("verdict_one_sentence required");
if (!body.verdict_one_sentence_pt_br?.trim()) {
throw new Error("verdict_one_sentence_pt_br required (bilingual contract)");
}
if (body.verdict_one_sentence.length > 280) { if (body.verdict_one_sentence.length > 280) {
throw new Error(`verdict too long (${body.verdict_one_sentence.length} > 280)`); throw new Error(`verdict too long (${body.verdict_one_sentence.length} > 280)`);
} }
if (body.verdict_one_sentence_pt_br.length > 280) {
throw new Error(`verdict_pt_br too long (${body.verdict_one_sentence_pt_br.length} > 280)`);
}
// Defensive: cap each array to 5 entries × 240 chars (prompt says ≤ 200 but // Defensive: cap each array to 5 entries × 240 chars (prompt says ≤ 200 but
// we leave some slack rather than truncate silently). // we leave some slack rather than truncate silently).

View file

@ -31,9 +31,12 @@ export interface WriteWitnessAnalysisArgs {
person_entity_pk: number; person_entity_pk: number;
credibility: "high" | "medium" | "low" | "speculation"; credibility: "high" | "medium" | "low" | "speculation";
access_to_event: string; access_to_event: string;
access_to_event_pt_br?: string;
bias_notes: string; bias_notes: string;
bias_notes_pt_br?: string;
corroboration_refs: CorroborationRef[]; corroboration_refs: CorroborationRef[];
verdict: string; verdict: string;
verdict_pt_br?: string;
} }
export interface WriteWitnessAnalysisContext { export interface WriteWitnessAnalysisContext {
@ -106,16 +109,25 @@ function renderMd(
"", "",
`**Credibility.** ${body.credibility}`, `**Credibility.** ${body.credibility}`,
"", "",
`**Verdict.** ${body.verdict}`, `**Verdict (EN).** ${body.verdict}`,
`**Veredito (PT-BR).** ${body.verdict_pt_br ?? body.verdict}`,
"", "",
"## Access to event", "## Access to event (EN)",
"", "",
body.access_to_event, body.access_to_event,
"", "",
"## Bias notes", "## Acesso ao evento (PT-BR)",
"",
body.access_to_event_pt_br ?? body.access_to_event,
"",
"## Bias notes (EN)",
"", "",
body.bias_notes, body.bias_notes,
"", "",
"## Notas de viés (PT-BR)",
"",
body.bias_notes_pt_br ?? body.bias_notes,
"",
"## Corroboration chain", "## Corroboration chain",
"", "",
refBlocks, refBlocks,
@ -132,11 +144,17 @@ export async function writeWitnessAnalysis(
const validBand = ["high", "medium", "low", "speculation"].includes(body.credibility); const validBand = ["high", "medium", "low", "speculation"].includes(body.credibility);
if (!validBand) throw new Error(`bad credibility: ${body.credibility}`); if (!validBand) throw new Error(`bad credibility: ${body.credibility}`);
if (!body.access_to_event?.trim()) throw new Error("access_to_event required"); if (!body.access_to_event?.trim()) throw new Error("access_to_event required");
if (!body.access_to_event_pt_br?.trim()) throw new Error("access_to_event_pt_br required (bilingual contract)");
if (!body.bias_notes?.trim()) throw new Error("bias_notes required"); if (!body.bias_notes?.trim()) throw new Error("bias_notes required");
if (!body.bias_notes_pt_br?.trim()) throw new Error("bias_notes_pt_br required (bilingual contract)");
if (!body.verdict?.trim()) throw new Error("verdict required"); if (!body.verdict?.trim()) throw new Error("verdict required");
if (!body.verdict_pt_br?.trim()) throw new Error("verdict_pt_br required (bilingual contract)");
if (body.verdict.length > 280) throw new Error(`verdict too long (${body.verdict.length} > 280)`); if (body.verdict.length > 280) throw new Error(`verdict too long (${body.verdict.length} > 280)`);
if (body.verdict_pt_br.length > 280) throw new Error(`verdict_pt_br too long (${body.verdict_pt_br.length} > 280)`);
if (body.access_to_event.length > 800) throw new Error(`access_to_event too long (${body.access_to_event.length} > 800)`); if (body.access_to_event.length > 800) throw new Error(`access_to_event too long (${body.access_to_event.length} > 800)`);
if (body.access_to_event_pt_br.length > 800) throw new Error(`access_to_event_pt_br too long`);
if (body.bias_notes.length > 800) throw new Error(`bias_notes too long (${body.bias_notes.length} > 800)`); if (body.bias_notes.length > 800) throw new Error(`bias_notes too long (${body.bias_notes.length} > 800)`);
if (body.bias_notes_pt_br.length > 800) throw new Error(`bias_notes_pt_br too long`);
// Verify entity exists and is a person. // Verify entity exists and is a person.
const ent = await queryOne<{ canonical_name: string; entity_class: string }>( const ent = await queryOne<{ canonical_name: string; entity_class: string }>(
@ -159,17 +177,20 @@ export async function writeWitnessAnalysis(
const witness_id = await allocate.witnessId(); const witness_id = await allocate.witnessId();
await query( await query(
`INSERT INTO public.witnesses `INSERT INTO public.witnesses
(witness_id, person_entity_pk, credibility, access_to_event, (witness_id, person_entity_pk, credibility,
bias_notes, corroboration_refs, verdict, created_by) access_to_event, access_to_event_pt_br,
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)`, bias_notes, bias_notes_pt_br,
corroboration_refs, verdict, verdict_pt_br, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`,
[ [
witness_id, body.person_entity_pk, body.credibility, witness_id, body.person_entity_pk, body.credibility,
body.access_to_event, body.bias_notes, body.access_to_event, body.access_to_event_pt_br,
body.bias_notes, body.bias_notes_pt_br,
JSON.stringify(refs.map((r) => ({ JSON.stringify(refs.map((r) => ({
chunk_pk: r.chunk_pk, doc_id: r.doc_id, chunk_id: r.chunk_id, chunk_pk: r.chunk_pk, doc_id: r.doc_id, chunk_id: r.chunk_id,
page: r.page, supports: r.supports, page: r.page, supports: r.supports,
}))), }))),
body.verdict, ctx.detective, body.verdict, body.verdict_pt_br, ctx.detective,
], ],
); );

View file

@ -46,9 +46,13 @@ interface EvidenceRow {
interface HypothesisRow { interface HypothesisRow {
hypothesis_id: string; hypothesis_id: string;
question: string | null; question: string | null;
question_pt_br: string | null;
position: string | null; position: string | null;
position_pt_br: string | null;
argument_for: string | null; argument_for: string | null;
argument_for_pt_br: string | null;
argument_against: string | null; argument_against: string | null;
argument_against_pt_br: string | null;
prior: number | null; prior: number | null;
posterior: number | null; posterior: number | null;
confidence_band: string | null; confidence_band: string | null;
@ -59,9 +63,11 @@ interface HypothesisRow {
interface ContradictionRow { interface ContradictionRow {
contradiction_id: string; contradiction_id: string;
topic: string; topic: string;
topic_pt_br: string | null;
chunks: unknown; chunks: unknown;
resolution_status: string | null; resolution_status: string | null;
notes: string | null; notes: string | null;
notes_pt_br: string | null;
detected_by: string | null; detected_by: string | null;
} }
@ -71,16 +77,21 @@ interface WitnessRow {
entity_id: string | null; entity_id: string | null;
credibility: string | null; credibility: string | null;
access_to_event: string | null; access_to_event: string | null;
access_to_event_pt_br: string | null;
bias_notes: string | null; bias_notes: string | null;
bias_notes_pt_br: string | null;
corroboration_refs: unknown; corroboration_refs: unknown;
verdict: string | null; verdict: string | null;
verdict_pt_br: string | null;
} }
interface GapRow { interface GapRow {
gap_id: string; gap_id: string;
description: string; description: string;
description_pt_br: string | null;
scope: unknown; scope: unknown;
suggested_next_move: string | null; suggested_next_move: string | null;
suggested_next_move_pt_br: string | null;
status: string; status: string;
created_by: string; created_by: string;
} }
@ -144,7 +155,8 @@ export async function GET(
: Promise.resolve([] as EvidenceRow[]), : Promise.resolve([] as EvidenceRow[]),
hypothesisIds.length > 0 hypothesisIds.length > 0
? pgQuery<HypothesisRow>( ? pgQuery<HypothesisRow>(
`SELECT hypothesis_id, question, position, argument_for, argument_against, `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 prior, posterior, confidence_band, status, evidence_refs
FROM public.hypotheses FROM public.hypotheses
WHERE hypothesis_id = ANY($1::text[]) WHERE hypothesis_id = ANY($1::text[])
@ -154,7 +166,8 @@ export async function GET(
: Promise.resolve([] as HypothesisRow[]), : Promise.resolve([] as HypothesisRow[]),
contradictionIds.length > 0 contradictionIds.length > 0
? pgQuery<ContradictionRow>( ? pgQuery<ContradictionRow>(
`SELECT contradiction_id, topic, chunks, resolution_status, notes, detected_by `SELECT contradiction_id, topic, topic_pt_br, chunks, resolution_status,
notes, notes_pt_br, detected_by
FROM public.contradictions FROM public.contradictions
WHERE contradiction_id = ANY($1::text[]) WHERE contradiction_id = ANY($1::text[])
ORDER BY contradiction_id`, ORDER BY contradiction_id`,
@ -163,9 +176,10 @@ export async function GET(
: Promise.resolve([] as ContradictionRow[]), : Promise.resolve([] as ContradictionRow[]),
witnessIds.length > 0 witnessIds.length > 0
? pgQuery<WitnessRow>( ? pgQuery<WitnessRow>(
`SELECT w.witness_id, e.canonical_name, e.entity_id, `SELECT w.witness_id, e.canonical_name, e.entity_id, w.credibility,
w.credibility, w.access_to_event, w.bias_notes, w.access_to_event, w.access_to_event_pt_br,
w.corroboration_refs, w.verdict w.bias_notes, w.bias_notes_pt_br,
w.corroboration_refs, w.verdict, w.verdict_pt_br
FROM public.witnesses w FROM public.witnesses w
LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk
WHERE w.witness_id = ANY($1::text[]) WHERE w.witness_id = ANY($1::text[])
@ -175,7 +189,8 @@ export async function GET(
: Promise.resolve([] as WitnessRow[]), : Promise.resolve([] as WitnessRow[]),
gapIds.length > 0 gapIds.length > 0
? pgQuery<GapRow>( ? pgQuery<GapRow>(
`SELECT gap_id, description, scope, suggested_next_move, status, created_by `SELECT gap_id, description, description_pt_br, scope,
suggested_next_move, suggested_next_move_pt_br, status, created_by
FROM public.gaps WHERE gap_id = ANY($1::text[]) ORDER BY gap_id`, FROM public.gaps WHERE gap_id = ANY($1::text[]) ORDER BY gap_id`,
[gapIds], [gapIds],
) )

View file

@ -22,7 +22,9 @@ interface EvidenceRow {
interface HypothesisRow { interface HypothesisRow {
hypothesis_id: string; hypothesis_id: string;
question: string; question: string;
question_pt_br: string | null;
position: string; position: string;
position_pt_br: string | null;
prior: number | string | null; prior: number | string | null;
posterior: number | string | null; posterior: number | string | null;
confidence_band: string | null; confidence_band: string | null;
@ -33,15 +35,18 @@ interface HypothesisRow {
interface ContradictionRow { interface ContradictionRow {
contradiction_id: string; contradiction_id: string;
topic: string; topic: string;
topic_pt_br: string | null;
resolution_status: string; resolution_status: string;
chunks: unknown; chunks: unknown;
} }
interface GapRow { interface GapRow {
gap_id: string; gap_id: string;
description: string; description: string;
description_pt_br: string | null;
scope: unknown; scope: unknown;
status: string; status: string;
suggested_next_move: string | null; suggested_next_move: string | null;
suggested_next_move_pt_br: string | null;
} }
interface WitnessRow { interface WitnessRow {
witness_id: string; witness_id: string;
@ -49,6 +54,7 @@ interface WitnessRow {
entity_id: string | null; entity_id: string | null;
credibility: string | null; credibility: string | null;
verdict: string | null; verdict: string | null;
verdict_pt_br: string | null;
} }
interface JobRow { interface JobRow {
job_id: string; job_id: string;
@ -77,8 +83,8 @@ export default async function BureauPage() {
// All artefacts. Server component — single round per query, no n+1. // All artefacts. Server component — single round per query, no n+1.
const [hyp, ev, ctr, gap, wit, jobs] = await Promise.all([ const [hyp, ev, ctr, gap, wit, jobs] = await Promise.all([
pgQuery<HypothesisRow>( pgQuery<HypothesisRow>(
`SELECT hypothesis_id, question, position, prior, posterior, confidence_band, `SELECT hypothesis_id, question, question_pt_br, position, position_pt_br,
status, reviewed_by, created_at prior, posterior, confidence_band, status, reviewed_by, created_at
FROM public.hypotheses ORDER BY created_at DESC LIMIT 100`, FROM public.hypotheses ORDER BY created_at DESC LIMIT 100`,
).catch(() => []), ).catch(() => []),
pgQuery<EvidenceRow>( pgQuery<EvidenceRow>(
@ -86,15 +92,17 @@ export default async function BureauPage() {
FROM public.evidence ORDER BY created_at DESC LIMIT 100`, FROM public.evidence ORDER BY created_at DESC LIMIT 100`,
).catch(() => []), ).catch(() => []),
pgQuery<ContradictionRow>( pgQuery<ContradictionRow>(
`SELECT contradiction_id, topic, resolution_status, chunks `SELECT contradiction_id, topic, topic_pt_br, resolution_status, chunks
FROM public.contradictions ORDER BY created_at DESC LIMIT 100`, FROM public.contradictions ORDER BY created_at DESC LIMIT 100`,
).catch(() => []), ).catch(() => []),
pgQuery<GapRow>( pgQuery<GapRow>(
`SELECT gap_id, description, scope, status, suggested_next_move `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`, FROM public.gaps ORDER BY created_at DESC LIMIT 100`,
).catch(() => []), ).catch(() => []),
pgQuery<WitnessRow>( pgQuery<WitnessRow>(
`SELECT w.witness_id, e.canonical_name, e.entity_id, w.credibility, w.verdict `SELECT w.witness_id, e.canonical_name, e.entity_id, w.credibility,
w.verdict, w.verdict_pt_br
FROM public.witnesses w FROM public.witnesses w
LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk
ORDER BY w.created_at DESC LIMIT 100`, ORDER BY w.created_at DESC LIMIT 100`,
@ -183,7 +191,7 @@ export default async function BureauPage() {
)} )}
</div> </div>
</div> </div>
<div className="text-[13px] text-[#e7ecf3] leading-snug">{h.position}</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"> <div className="text-[10px] font-mono text-[#5a6678] mt-1">
prior {prior?.toFixed(2) ?? "—"} posterior {post?.toFixed(2) ?? "—"} 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>} {delta !== null && <span className={delta > 0 ? " text-[#06d6a0]" : delta < 0 ? " text-[#ff6ec7]" : ""}> · Δ {delta >= 0 ? "+" : ""}{delta.toFixed(3)}</span>}
@ -221,7 +229,7 @@ export default async function BureauPage() {
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span> <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> <span className="text-[10px] font-mono text-[#9aa6b8]">{n} positions · {c.resolution_status}</span>
</div> </div>
<div className="text-[13px] text-[#e7ecf3] leading-snug">{c.topic}</div> <div className="text-[13px] text-[#e7ecf3] leading-snug">{c.topic_pt_br || c.topic}</div>
</div> </div>
); );
})} })}
@ -231,8 +239,10 @@ export default async function BureauPage() {
<Section id="outliers" title="Outliers" color="text-[#ffd23f]"> <Section id="outliers" title="Outliers" color="text-[#ffd23f]">
{gap.length === 0 ? <Empty /> : gap.map((g) => { {gap.length === 0 ? <Empty /> : gap.map((g) => {
const s = (g.scope ?? {}) as Record<string, unknown>; const s = (g.scope ?? {}) as Record<string, unknown>;
const title = (s.title as string) || g.description; 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 isOutlier = s.kind === "outlier";
const nextMove = g.suggested_next_move_pt_br || g.suggested_next_move;
return ( return (
<div key={g.gap_id} className="rounded border border-[rgba(255,210,63,0.25)] bg-[#0d1220] p-3 mb-2"> <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"> <div className="flex items-baseline justify-between gap-2 mb-1">
@ -240,11 +250,11 @@ export default async function BureauPage() {
<span className="text-[10px] font-mono text-[#9aa6b8]">{g.status}</span> <span className="text-[10px] font-mono text-[#9aa6b8]">{g.status}</span>
</div> </div>
<div className="text-[13px] text-[#e7ecf3] leading-snug">{title}</div> <div className="text-[13px] text-[#e7ecf3] leading-snug">{title}</div>
{s.why_surprising !== undefined && ( {why && (
<div className="text-[11px] text-[#cbd2dd] mt-1 leading-relaxed">{String(s.why_surprising)}</div> <div className="text-[11px] text-[#cbd2dd] mt-1 leading-relaxed">{why}</div>
)} )}
{g.suggested_next_move && ( {nextMove && (
<div className="text-[10px] font-mono text-[#06d6a0] mt-1"> {g.suggested_next_move}</div> <div className="text-[10px] font-mono text-[#06d6a0] mt-1"> {nextMove}</div>
)} )}
</div> </div>
); );
@ -262,7 +272,7 @@ export default async function BureauPage() {
<div className="text-[13px] text-[#e7ecf3] font-medium"> <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 ?? "—")} {w.entity_id ? <Link href={`/e/people/${w.entity_id}`} className="hover:underline">{w.canonical_name ?? w.entity_id}</Link> : (w.canonical_name ?? "—")}
</div> </div>
{w.verdict && <blockquote className="text-[12px] text-[#cbd2dd] italic mt-1 border-l-2 border-[#9b5de5] pl-2">{w.verdict}</blockquote>} {(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> </div>
))} ))}
</Section> </Section>

View file

@ -25,9 +25,13 @@ export const dynamic = "force-dynamic";
interface HypothesisRow { interface HypothesisRow {
hypothesis_id: string; hypothesis_id: string;
question: string; question: string;
question_pt_br: string | null;
position: string; position: string;
position_pt_br: string | null;
argument_for: string | null; argument_for: string | null;
argument_for_pt_br: string | null;
argument_against: string | null; argument_against: string | null;
argument_against_pt_br: string | null;
prior: number | string | null; prior: number | string | null;
posterior: number | string | null; posterior: number | string | null;
confidence_band: string | null; confidence_band: string | null;
@ -146,7 +150,8 @@ export default async function HypothesisPage({
if (!/^H-\d{4}$/.test(hypothesisId)) notFound(); if (!/^H-\d{4}$/.test(hypothesisId)) notFound();
const rows = await pgQuery<HypothesisRow>( const rows = await pgQuery<HypothesisRow>(
`SELECT hypothesis_id, question, position, argument_for, argument_against, `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, prior, posterior, confidence_band, status, evidence_refs,
created_by, reviewed_by, created_at, updated_at created_by, reviewed_by, created_at, updated_at
FROM public.hypotheses WHERE hypothesis_id = $1`, FROM public.hypotheses WHERE hypothesis_id = $1`,
@ -209,8 +214,11 @@ export default async function HypothesisPage({
{hypothesisId} · created by {h.created_by} {hypothesisId} · created by {h.created_by}
{h.reviewed_by && <> · reviewed by <span className="text-[#ff3344]">{h.reviewed_by}</span></>} {h.reviewed_by && <> · reviewed by <span className="text-[#ff3344]">{h.reviewed_by}</span></>}
</div> </div>
<h1 className="text-xl font-mono text-[#e7ecf3] leading-snug">{h.position}</h1> <h1 className="text-xl font-mono text-[#e7ecf3] leading-snug">{h.position_pt_br || h.position}</h1>
<p className="text-[12px] text-[#9aa6b8] mt-1">Question: {h.question}</p> {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> </div>
{h.confidence_band && ( {h.confidence_band && (
<span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}> <span className={`px-2 py-0.5 rounded text-[10px] font-mono uppercase border ${bandTone}`}>
@ -238,8 +246,8 @@ export default async function HypothesisPage({
{/* Argument grid */} {/* Argument grid */}
<div className="mt-4 grid md:grid-cols-2 gap-3"> <div className="mt-4 grid md:grid-cols-2 gap-3">
<ArgumentPanel kind="for" body={h.argument_for} /> <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} /> <ArgumentPanel kind="against" body={h.argument_against_pt_br || h.argument_against} bodyEn={h.argument_against_pt_br ? h.argument_against : null} />
</div> </div>
{/* Evidence chain */} {/* Evidence chain */}
@ -273,13 +281,20 @@ export default async function HypothesisPage({
); );
} }
function ArgumentPanel({ kind, body }: { kind: "for" | "against"; body: string | null }) { 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 tone = kind === "for" ? "border-[#06d6a0] text-[#06d6a0]" : "border-[#ff6ec7] text-[#ff6ec7]";
const label = kind === "for" ? "Argumento a favor" : "Argumento contra"; const label = kind === "for" ? "Argumento a favor (PT-BR)" : "Argumento contra (PT-BR)";
const enLabel = kind === "for" ? "Argument for (EN)" : "Argument against (EN)";
return ( return (
<div className={`rounded-lg border ${tone} bg-[#0d1220] p-4`}> <div className={`rounded-lg border ${tone} bg-[#0d1220] p-4`}>
<div className={`text-[10px] font-mono uppercase mb-2 ${tone}`}>{label}</div> <div className={`text-[10px] font-mono uppercase mb-2 ${tone}`}>{label}</div>
<ArgumentBody text={body ?? ""} /> <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> </div>
); );
} }

View file

@ -12,10 +12,10 @@ import { QuickLaunch } from "./quick-launch";
interface CountRow { c: string } interface CountRow { c: string }
interface RecentEvidence { evidence_id: string; grade: string; verbatim_excerpt: string; source_page_id: string; confidence_band: string | null } interface RecentEvidence { evidence_id: string; grade: string; verbatim_excerpt: string; source_page_id: string; confidence_band: string | null }
interface RecentHypothesis { hypothesis_id: string; position: string; posterior: number | string | null; confidence_band: string | null; reviewed_by: string | null } interface RecentHypothesis { hypothesis_id: string; position: string; position_pt_br: string | null; posterior: number | string | null; confidence_band: string | null; reviewed_by: string | null }
interface RecentContradiction { contradiction_id: string; topic: string; resolution_status: string } interface RecentContradiction { contradiction_id: string; topic: string; topic_pt_br: string | null; resolution_status: string }
interface RecentGap { gap_id: string; description: string; scope: unknown } interface RecentGap { gap_id: string; description: string; description_pt_br: string | null; scope: unknown }
interface RecentWitness { witness_id: string; canonical_name: string | null; credibility: string | null; verdict: string | null } interface RecentWitness { witness_id: string; canonical_name: string | null; credibility: string | null; verdict: string | null; verdict_pt_br: string | null }
interface RecentJob { job_id: string; kind: string; status: string; created_at: string; payload: Record<string, unknown> | null } interface RecentJob { job_id: string; kind: string; status: string; created_at: string; payload: Record<string, unknown> | null }
const DETECTIVES = [ const DETECTIVES = [
@ -60,7 +60,7 @@ async function loadSnapshot() {
const [hyp, ev, ctr, gap, wit, jobs] = await Promise.all([ const [hyp, ev, ctr, gap, wit, jobs] = await Promise.all([
pgQuery<RecentHypothesis>( pgQuery<RecentHypothesis>(
`SELECT hypothesis_id, position, posterior, confidence_band, reviewed_by `SELECT hypothesis_id, position, position_pt_br, posterior, confidence_band, reviewed_by
FROM public.hypotheses ORDER BY created_at DESC LIMIT 4`, FROM public.hypotheses ORDER BY created_at DESC LIMIT 4`,
).catch(() => []), ).catch(() => []),
pgQuery<RecentEvidence>( pgQuery<RecentEvidence>(
@ -68,14 +68,15 @@ async function loadSnapshot() {
FROM public.evidence ORDER BY created_at DESC LIMIT 3`, FROM public.evidence ORDER BY created_at DESC LIMIT 3`,
).catch(() => []), ).catch(() => []),
pgQuery<RecentContradiction>( pgQuery<RecentContradiction>(
`SELECT contradiction_id, topic, resolution_status `SELECT contradiction_id, topic, topic_pt_br, resolution_status
FROM public.contradictions ORDER BY created_at DESC LIMIT 3`, FROM public.contradictions ORDER BY created_at DESC LIMIT 3`,
).catch(() => []), ).catch(() => []),
pgQuery<RecentGap>( pgQuery<RecentGap>(
`SELECT gap_id, description, scope FROM public.gaps ORDER BY created_at DESC LIMIT 3`, `SELECT gap_id, description, description_pt_br, scope
FROM public.gaps ORDER BY created_at DESC LIMIT 3`,
).catch(() => []), ).catch(() => []),
pgQuery<RecentWitness>( pgQuery<RecentWitness>(
`SELECT w.witness_id, e.canonical_name, w.credibility, w.verdict `SELECT w.witness_id, e.canonical_name, w.credibility, w.verdict, w.verdict_pt_br
FROM public.witnesses w FROM public.witnesses w
LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk LEFT JOIN public.entities e ON e.entity_pk = w.person_entity_pk
ORDER BY w.created_at DESC LIMIT 3`, ORDER BY w.created_at DESC LIMIT 3`,
@ -191,7 +192,7 @@ export async function BureauSnapshot() {
{h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`} {h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`}
</span> </span>
</div> </div>
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{h.position}</div> <div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{h.position_pt_br || h.position}</div>
{h.reviewed_by && ( {h.reviewed_by && (
<div className="text-[9px] font-mono text-[#ff3344] mt-0.5"> reviewed by {h.reviewed_by}</div> <div className="text-[9px] font-mono text-[#ff3344] mt-0.5"> reviewed by {h.reviewed_by}</div>
)} )}
@ -213,7 +214,7 @@ export async function BureauSnapshot() {
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span> <span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span>
<span className="text-[10px] font-mono text-[#9aa6b8]">{c.resolution_status}</span> <span className="text-[10px] font-mono text-[#9aa6b8]">{c.resolution_status}</span>
</div> </div>
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{c.topic}</div> <div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{c.topic_pt_br || c.topic}</div>
</div> </div>
))} ))}
</ArtefactColumn> </ArtefactColumn>
@ -250,7 +251,8 @@ export async function BureauSnapshot() {
> >
{gap.map((g) => { {gap.map((g) => {
const s = (g.scope ?? {}) as Record<string, unknown>; const s = (g.scope ?? {}) as Record<string, unknown>;
const title = (s.title as string) || g.description; const title = (s.title_pt_br as string) || (s.title as string)
|| g.description_pt_br || g.description;
return ( return (
<div key={g.gap_id} className="py-1.5 border-t border-[rgba(255,210,63,0.08)] first:border-t-0"> <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"> <div className="flex items-baseline justify-between gap-2">
@ -279,7 +281,7 @@ export async function BureauSnapshot() {
<span className="text-[10px] font-mono text-[#9b5de5]">{w.credibility ?? "—"}</span> <span className="text-[10px] font-mono text-[#9b5de5]">{w.credibility ?? "—"}</span>
</div> </div>
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5 font-semibold">{w.canonical_name ?? "—"}</div> <div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5 font-semibold">{w.canonical_name ?? "—"}</div>
{w.verdict && <div className="text-[11px] text-[#9aa6b8] mt-0.5 italic">{w.verdict.slice(0, 200)}</div>} {(w.verdict_pt_br || w.verdict) && <div className="text-[11px] text-[#9aa6b8] mt-0.5 italic">{(w.verdict_pt_br || w.verdict || "").slice(0, 200)}</div>}
</div> </div>
))} ))}
</ArtefactColumn> </ArtefactColumn>

View file

@ -17,9 +17,9 @@ import Link from "next/link";
import { pgQuery } from "@/lib/retrieval/db"; import { pgQuery } from "@/lib/retrieval/db";
interface EvRow { evidence_id: string; grade: string; confidence_band: string | null; source_page_id: string } interface EvRow { evidence_id: string; grade: string; confidence_band: string | null; source_page_id: string }
interface HypRow { hypothesis_id: string; position: string; confidence_band: string | null; posterior: number | string | null } 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; resolution_status: string } interface CtrRow { contradiction_id: string; topic: string; topic_pt_br: string | null; resolution_status: string }
interface GapRow { gap_id: string; description: string; scope: unknown; status: string } interface GapRow { gap_id: string; description: string; description_pt_br: string | null; scope: unknown; status: string }
interface ReportRow { slug: string; topic: string } interface ReportRow { slug: string; topic: string }
const BAND_TONE: Record<string, string> = { const BAND_TONE: Record<string, string> = {
@ -47,7 +47,7 @@ export async function DocBureauPanel({ docId }: { docId: string }) {
const evIds = ev.map((e) => e.evidence_id); const evIds = ev.map((e) => e.evidence_id);
const hyp: HypRow[] = evIds.length > 0 const hyp: HypRow[] = evIds.length > 0
? await pgQuery<HypRow>( ? await pgQuery<HypRow>(
`SELECT hypothesis_id, position, confidence_band, posterior `SELECT hypothesis_id, position, position_pt_br, confidence_band, posterior
FROM public.hypotheses FROM public.hypotheses
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements(evidence_refs) er SELECT 1 FROM jsonb_array_elements(evidence_refs) er
@ -60,7 +60,7 @@ export async function DocBureauPanel({ docId }: { docId: string }) {
// Contradictions whose chunks[] has any chunk with this doc_id. // Contradictions whose chunks[] has any chunk with this doc_id.
const ctr: CtrRow[] = await pgQuery<CtrRow>( const ctr: CtrRow[] = await pgQuery<CtrRow>(
`SELECT contradiction_id, topic, resolution_status `SELECT contradiction_id, topic, topic_pt_br, resolution_status
FROM public.contradictions FROM public.contradictions
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements(chunks) c SELECT 1 FROM jsonb_array_elements(chunks) c
@ -72,7 +72,7 @@ export async function DocBureauPanel({ docId }: { docId: string }) {
// Outliers (gaps with scope.doc_id matching). // Outliers (gaps with scope.doc_id matching).
const gap: GapRow[] = await pgQuery<GapRow>( const gap: GapRow[] = await pgQuery<GapRow>(
`SELECT gap_id, description, scope, status `SELECT gap_id, description, description_pt_br, scope, status
FROM public.gaps FROM public.gaps
WHERE scope->>'doc_id' = $1 WHERE scope->>'doc_id' = $1
ORDER BY gap_id LIMIT 8`, ORDER BY gap_id LIMIT 8`,
@ -147,7 +147,7 @@ export async function DocBureauPanel({ docId }: { docId: string }) {
{h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`} {h.confidence_band ?? "—"}{post !== null && ` · ${post.toFixed(2)}`}
</span> </span>
</div> </div>
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{h.position}</div> <div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{h.position_pt_br || h.position}</div>
</Link> </Link>
); );
})} })}
@ -181,7 +181,7 @@ export async function DocBureauPanel({ docId }: { docId: string }) {
<span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span> <span className="text-[10px] font-mono text-[#5a6678]">{c.contradiction_id}</span>
<span className="text-[10px] font-mono text-[#9aa6b8]">{c.resolution_status}</span> <span className="text-[10px] font-mono text-[#9aa6b8]">{c.resolution_status}</span>
</div> </div>
<div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{c.topic}</div> <div className="text-[12px] text-[#cbd2dd] leading-snug mt-0.5">{c.topic_pt_br || c.topic}</div>
</div> </div>
))} ))}
</Panel> </Panel>
@ -191,7 +191,8 @@ export async function DocBureauPanel({ docId }: { docId: string }) {
<Panel title="Outliers" color="text-[#ffd23f]" border="border-[rgba(255,210,63,0.25)]"> <Panel title="Outliers" color="text-[#ffd23f]" border="border-[rgba(255,210,63,0.25)]">
{gap.map((g) => { {gap.map((g) => {
const s = (g.scope ?? {}) as Record<string, unknown>; const s = (g.scope ?? {}) as Record<string, unknown>;
const title = (s.title as string) || g.description; const title = (s.title_pt_br as string) || (s.title as string)
|| g.description_pt_br || g.description;
return ( return (
<div key={g.gap_id} className="py-1.5 border-t border-[rgba(255,210,63,0.08)] first:border-t-0"> <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"> <div className="flex items-baseline justify-between gap-2">

View file

@ -52,9 +52,13 @@ interface EvidenceItem {
interface HypothesisItem { interface HypothesisItem {
hypothesis_id: string; hypothesis_id: string;
question: string | null; question: string | null;
question_pt_br?: string | null;
position: string | null; position: string | null;
position_pt_br?: string | null;
argument_for: string | null; argument_for: string | null;
argument_for_pt_br?: string | null;
argument_against: string | null; argument_against: string | null;
argument_against_pt_br?: string | null;
prior: number | string | null; prior: number | string | null;
posterior: number | string | null; posterior: number | string | null;
confidence_band: string | null; confidence_band: string | null;
@ -66,15 +70,18 @@ interface ContradictionPositionItem {
chunk_id: string; chunk_id: string;
page: number; page: number;
statement: string; statement: string;
statement_pt_br?: string | null;
stance?: string | null; stance?: string | null;
} }
interface ContradictionItem { interface ContradictionItem {
contradiction_id: string; contradiction_id: string;
topic: string; topic: string;
topic_pt_br?: string | null;
chunks: ContradictionPositionItem[]; chunks: ContradictionPositionItem[];
resolution_status: string | null; resolution_status: string | null;
notes: string | null; notes: string | null;
notes_pt_br?: string | null;
detected_by: string | null; detected_by: string | null;
} }
@ -92,9 +99,12 @@ interface WitnessItem {
entity_id: string | null; entity_id: string | null;
credibility: string | null; credibility: string | null;
access_to_event: string | null; access_to_event: string | null;
access_to_event_pt_br?: string | null;
bias_notes: string | null; bias_notes: string | null;
bias_notes_pt_br?: string | null;
corroboration_refs: WitnessCorrItem[]; corroboration_refs: WitnessCorrItem[];
verdict: string | null; verdict: string | null;
verdict_pt_br?: string | null;
} }
interface CaseReportOutput { interface CaseReportOutput {
@ -108,17 +118,23 @@ interface CaseReportOutput {
interface GapItem { interface GapItem {
gap_id: string; gap_id: string;
description: string; description: string;
description_pt_br?: string | null;
scope: { scope: {
kind?: string; kind?: string;
title?: string; title?: string;
title_pt_br?: string;
doc_id?: string; doc_id?: string;
chunk_id?: string; chunk_id?: string;
page?: number; page?: number;
dominant_model?: string; dominant_model?: string;
dominant_model_pt_br?: string;
why_surprising?: string; why_surprising?: string;
why_surprising_pt_br?: string;
what_it_implies?: string; what_it_implies?: string;
what_it_implies_pt_br?: string;
} | null; } | null;
suggested_next_move: string | null; suggested_next_move: string | null;
suggested_next_move_pt_br?: string | null;
status: string; status: string;
created_by: string; created_by: string;
} }
@ -416,9 +432,14 @@ function HypothesisCard({ h }: { h: HypothesisItem }) {
)} )}
</div> </div>
<div className="text-[14px] text-[#e7ecf3] leading-snug font-medium mb-3"> <div className="text-[14px] text-[#e7ecf3] leading-snug font-medium mb-1">
{h.position_pt_br || h.position}
</div>
{h.position_pt_br && h.position && h.position_pt_br !== h.position && (
<div className="text-[11px] text-[#5a6678] leading-snug italic mb-3">
{h.position} {h.position}
</div> </div>
)}
{(prior !== null || posterior !== null) && ( {(prior !== null || posterior !== null) && (
<div className="grid grid-cols-2 gap-3 mb-3 text-[11px] font-mono"> <div className="grid grid-cols-2 gap-3 mb-3 text-[11px] font-mono">
@ -435,16 +456,28 @@ function HypothesisCard({ h }: { h: HypothesisItem }) {
</div> </div>
)} )}
{h.argument_for && ( {(h.argument_for_pt_br || h.argument_for) && (
<div className="mt-3"> <div className="mt-3">
<div className="text-[10px] font-mono text-[#06d6a0] uppercase mb-1">Argumento a favor</div> <div className="text-[10px] font-mono text-[#06d6a0] uppercase mb-1">Argumento a favor (PT-BR)</div>
<ArgumentBody text={h.argument_for} /> <ArgumentBody text={h.argument_for_pt_br || h.argument_for!} />
{h.argument_for && h.argument_for_pt_br && (
<details className="mt-2">
<summary className="text-[10px] font-mono text-[#5a6678] cursor-pointer">Argument for (EN)</summary>
<div className="mt-1"><ArgumentBody text={h.argument_for} /></div>
</details>
)}
</div> </div>
)} )}
{h.argument_against && ( {(h.argument_against_pt_br || h.argument_against) && (
<div className="mt-3"> <div className="mt-3">
<div className="text-[10px] font-mono text-[#ff6ec7] uppercase mb-1">Argumento contra</div> <div className="text-[10px] font-mono text-[#ff6ec7] uppercase mb-1">Argumento contra (PT-BR)</div>
<ArgumentBody text={h.argument_against} /> <ArgumentBody text={h.argument_against_pt_br || h.argument_against!} />
{h.argument_against && h.argument_against_pt_br && (
<details className="mt-2">
<summary className="text-[10px] font-mono text-[#5a6678] cursor-pointer">Argument against (EN)</summary>
<div className="mt-1"><ArgumentBody text={h.argument_against} /></div>
</details>
)}
</div> </div>
)} )}
</div> </div>
@ -540,7 +573,7 @@ function GapCard({ g }: { g: GapItem }) {
</span> </span>
</div> </div>
<div className="text-[14px] text-[#e7ecf3] font-medium mb-2"> <div className="text-[14px] text-[#e7ecf3] font-medium mb-2">
{s.title || g.description} {s.title_pt_br || s.title || g.description_pt_br || g.description}
</div> </div>
{s.doc_id && s.chunk_id && pageStr && ( {s.doc_id && s.chunk_id && pageStr && (
<div className="text-[10px] font-mono mb-2"> <div className="text-[10px] font-mono mb-2">
@ -553,27 +586,27 @@ function GapCard({ g }: { g: GapItem }) {
</Link> </Link>
</div> </div>
)} )}
{s.dominant_model && ( {(s.dominant_model_pt_br || s.dominant_model) && (
<div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]"> <div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]">
<div className="text-[10px] font-mono text-[#9aa6b8] uppercase mb-1">Dominant model</div> <div className="text-[10px] font-mono text-[#9aa6b8] uppercase mb-1">Modelo dominante</div>
<div className="text-[12px] text-[#cbd2dd] leading-relaxed">{s.dominant_model}</div> <div className="text-[12px] text-[#cbd2dd] leading-relaxed">{s.dominant_model_pt_br || s.dominant_model}</div>
</div> </div>
)} )}
{s.why_surprising && ( {(s.why_surprising_pt_br || s.why_surprising) && (
<div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]"> <div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]">
<div className="text-[10px] font-mono text-[#ffd23f] uppercase mb-1">Why surprising</div> <div className="text-[10px] font-mono text-[#ffd23f] uppercase mb-1">Por que é surpreendente</div>
<div className="text-[12px] text-[#e7ecf3] leading-relaxed">{s.why_surprising}</div> <div className="text-[12px] text-[#e7ecf3] leading-relaxed">{s.why_surprising_pt_br || s.why_surprising}</div>
</div> </div>
)} )}
{s.what_it_implies && ( {(s.what_it_implies_pt_br || s.what_it_implies) && (
<div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]"> <div className="mt-2 p-2 bg-[#060a13] rounded border border-[rgba(255,210,63,0.08)]">
<div className="text-[10px] font-mono text-[#ff8a4d] uppercase mb-1">What it implies</div> <div className="text-[10px] font-mono text-[#ff8a4d] uppercase mb-1">O que implica</div>
<div className="text-[12px] text-[#cbd2dd] leading-relaxed">{s.what_it_implies}</div> <div className="text-[12px] text-[#cbd2dd] leading-relaxed">{s.what_it_implies_pt_br || s.what_it_implies}</div>
</div> </div>
)} )}
{g.suggested_next_move && ( {(g.suggested_next_move_pt_br || g.suggested_next_move) && (
<div className="mt-2 text-[11px] font-mono text-[#06d6a0]"> <div className="mt-2 text-[11px] font-mono text-[#06d6a0]">
{g.suggested_next_move} {g.suggested_next_move_pt_br || g.suggested_next_move}
</div> </div>
)} )}
</div> </div>
@ -607,23 +640,30 @@ function WitnessCard({ w }: { w: WitnessItem }) {
)} )}
</div> </div>
{w.verdict && ( {(w.verdict_pt_br || w.verdict) && (
<>
<blockquote className="text-[13px] text-[#e7ecf3] italic border-l-2 border-[#9b5de5] pl-3 my-2"> <blockquote className="text-[13px] text-[#e7ecf3] italic border-l-2 border-[#9b5de5] pl-3 my-2">
{w.verdict_pt_br || w.verdict}
</blockquote>
{w.verdict_pt_br && w.verdict && w.verdict_pt_br !== w.verdict && (
<blockquote className="text-[11px] text-[#5a6678] italic border-l border-[rgba(155,93,229,0.3)] pl-3 mb-2">
{w.verdict} {w.verdict}
</blockquote> </blockquote>
)} )}
</>
)}
<div className="grid md:grid-cols-2 gap-3 mt-3"> <div className="grid md:grid-cols-2 gap-3 mt-3">
{w.access_to_event && ( {(w.access_to_event_pt_br || w.access_to_event) && (
<div className="p-2 rounded bg-[#060a13] border border-[rgba(155,93,229,0.08)]"> <div className="p-2 rounded bg-[#060a13] border border-[rgba(155,93,229,0.08)]">
<div className="text-[10px] font-mono text-[#9b5de5] uppercase mb-1">Access to event</div> <div className="text-[10px] font-mono text-[#9b5de5] uppercase mb-1">Acesso ao evento (PT-BR)</div>
<div className="text-[11px] text-[#cbd2dd] leading-relaxed">{w.access_to_event}</div> <div className="text-[11px] text-[#cbd2dd] leading-relaxed">{w.access_to_event_pt_br || w.access_to_event}</div>
</div> </div>
)} )}
{w.bias_notes && ( {(w.bias_notes_pt_br || w.bias_notes) && (
<div className="p-2 rounded bg-[#060a13] border border-[rgba(155,93,229,0.08)]"> <div className="p-2 rounded bg-[#060a13] border border-[rgba(155,93,229,0.08)]">
<div className="text-[10px] font-mono text-[#ff8a4d] uppercase mb-1">Bias notes</div> <div className="text-[10px] font-mono text-[#ff8a4d] uppercase mb-1">Notas de viés (PT-BR)</div>
<div className="text-[11px] text-[#cbd2dd] leading-relaxed">{w.bias_notes}</div> <div className="text-[11px] text-[#cbd2dd] leading-relaxed">{w.bias_notes_pt_br || w.bias_notes}</div>
</div> </div>
)} )}
</div> </div>
@ -672,21 +712,30 @@ function ContradictionCard({ c }: { c: ContradictionItem }) {
</span> </span>
)} )}
</div> </div>
<div className="text-[14px] text-[#e7ecf3] leading-snug font-medium mb-3"> <div className="text-[14px] text-[#e7ecf3] leading-snug font-medium mb-1">
{c.topic} {c.topic_pt_br || c.topic}
</div> </div>
{c.topic_pt_br && c.topic_pt_br !== c.topic && (
<div className="text-[11px] text-[#5a6678] italic mb-3">{c.topic}</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{c.chunks.map((p, i) => { {c.chunks.map((p, i) => {
const pageStr = String(p.page).padStart(3, "0"); const pageStr = String(p.page).padStart(3, "0");
const stmt = p.statement_pt_br || p.statement;
return ( return (
<div key={i} className="p-2 bg-[#060a13] rounded border border-[rgba(255,138,77,0.1)]"> <div key={i} className="p-2 bg-[#060a13] rounded border border-[rgba(255,138,77,0.1)]">
<div className="text-[10px] font-mono text-[#5a6678] uppercase mb-1"> <div className="text-[10px] font-mono text-[#5a6678] uppercase mb-1">
Position {i + 1}{p.stance ? `${p.stance}` : ""} Position {i + 1}{p.stance ? `${p.stance}` : ""}
</div> </div>
<blockquote className="text-[12px] text-[#e7ecf3] italic border-l-2 border-[#ff8a4d] pl-2 mb-2"> <blockquote className="text-[12px] text-[#e7ecf3] italic border-l-2 border-[#ff8a4d] pl-2 mb-1">
{stmt}
</blockquote>
{p.statement_pt_br && p.statement_pt_br !== p.statement && p.statement && (
<blockquote className="text-[11px] text-[#5a6678] italic border-l border-[rgba(255,138,77,0.3)] pl-2 mb-2">
{p.statement} {p.statement}
</blockquote> </blockquote>
)}
<Link <Link
href={`/d/${p.doc_id}/p${pageStr}#${p.chunk_id}`} href={`/d/${p.doc_id}/p${pageStr}#${p.chunk_id}`}
className="text-[10px] font-mono text-[#7fdbff] hover:underline" className="text-[10px] font-mono text-[#7fdbff] hover:underline"
@ -698,10 +747,10 @@ function ContradictionCard({ c }: { c: ContradictionItem }) {
})} })}
</div> </div>
{c.notes && ( {(c.notes_pt_br || c.notes) && (
<div className="mt-3 p-2 bg-[#060a13] rounded border border-[rgba(255,138,77,0.08)]"> <div className="mt-3 p-2 bg-[#060a13] rounded border border-[rgba(255,138,77,0.08)]">
<div className="text-[10px] font-mono text-[#5a6678] uppercase mb-1">Notes</div> <div className="text-[10px] font-mono text-[#5a6678] uppercase mb-1">Notas</div>
<div className="text-[12px] text-[#cbd2dd] leading-relaxed">{c.notes}</div> <div className="text-[12px] text-[#cbd2dd] leading-relaxed">{c.notes_pt_br || c.notes}</div>
</div> </div>
)} )}
</div> </div>

36
web/lib/i18n/pick.ts Normal file
View file

@ -0,0 +1,36 @@
/**
* pickLang read the locale-preferred field with EN fallback.
*
* The bureau stores every narrative as a pair: `field` (EN) and
* `field_pt_br` (Brazilian Portuguese). UI components call this helper to
* surface the correct one based on the request locale.
*
* - locale "pt-br" or "pt" prefer PT-BR, fall back to EN
* - locale "en" prefer EN, fall back to PT-BR
*
* Empty / whitespace-only strings are treated as missing so a partial row
* still surfaces the language that has content.
*/
export type Locale = "pt-br" | "pt" | "en" | string | null | undefined;
function isPt(locale: Locale): boolean {
return locale === "pt-br" || locale === "pt";
}
function nonEmpty(s: string | null | undefined): string | null {
if (typeof s !== "string") return null;
const t = s.trim();
return t.length > 0 ? s : null;
}
export function pickLang(
en: string | null | undefined,
pt_br: string | null | undefined,
locale: Locale,
): string | null {
const enValid = nonEmpty(en);
const ptValid = nonEmpty(pt_br);
if (isPt(locale)) return ptValid ?? enValid;
return enValid ?? ptValid;
}