diff --git a/investigator-runtime/src/detectives/case_writer.ts b/investigator-runtime/src/detectives/case_writer.ts index 3389c0a..c557d01 100644 --- a/investigator-runtime/src/detectives/case_writer.ts +++ b/investigator-runtime/src/detectives/case_writer.ts @@ -266,13 +266,18 @@ function buildPrompt( function extractBody(text: string): string | null { const t = text.trim(); if (/^`?INSUFFICIENT_ARTEFACTS`?\b/i.test(t)) return null; - const stripped = t.replace(/^```(?:markdown|md)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); - // Find first H1 — anything before is preamble we drop. + const stripped = t + .replace(/^```(?:markdown|md)?\s*\n?/i, "") + .replace(/\n?```\s*$/i, "") + .trimStart(); + // If the text already opens on the title there is no preamble to drop. + // Crucial: the narrator emits TWO H1s (EN title then PT-BR title) — both are + // the title. Searching for the "first \n# " would mistake the PT-BR H1 for + // the real start and silently discard the English title. + if (stripped.startsWith("# ")) return stripped; + // Otherwise drop any preamble before the first H1. const h1 = stripped.indexOf("\n# "); - if (h1 === -1) { - if (stripped.startsWith("# ")) return stripped; - throw new Error(`case-writer returned no H1: ${t.slice(0, 200)}`); - } + if (h1 === -1) throw new Error(`case-writer returned no H1: ${t.slice(0, 200)}`); return stripped.slice(h1 + 1); } diff --git a/web/app/c/[slug]/page.tsx b/web/app/c/[slug]/page.tsx index f2edbe6..45e2ecb 100644 --- a/web/app/c/[slug]/page.tsx +++ b/web/app/c/[slug]/page.tsx @@ -13,6 +13,7 @@ import { MarkdownBody } from "@/components/markdown-body"; import { AuthBar } from "@/components/auth-bar"; import { BureauNav } from "@/components/bureau-nav"; import { getLocale } from "@/components/locale-toggle"; +import { pickLocaleBody } from "@/lib/bilingual"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -89,7 +90,8 @@ export async function generateMetadata( const title = locale === "pt-br" ? (h1s[1] ?? h1s[0] ?? c.fm.topic_pt_br ?? c.fm.topic ?? slug) : (h1s[0] ?? c.fm.topic ?? slug); - const desc = pickLead(c.body, locale).slice(0, 200); + const localeBody = pickLocaleBody(c.body.replace(/^#\s+.+$\n?/gm, ""), locale); + const desc = pickLead(localeBody, locale).slice(0, 200); const canonical = `${SITE_URL}/c/${slug}`; // OG image — use the case's editorial illustration when present. WhatsApp, // Twitter, Slack, Telegram, ChatGPT search all pull this as the link card. @@ -138,7 +140,10 @@ export default async function CaseReportPage({ const bodyTitle = locale === "pt-br" ? (h1s[1] ?? h1s[0]) : h1s[0]; // Strip H1 lines ("# Title") — the "## section" headers survive since the // regex requires whitespace after a single #. Avoids duplicating the title. - const body = c.body.replace(/^#\s+.+$\n?/gm, "").replace(/^\s+/, ""); + const fullBody = c.body.replace(/^#\s+.+$\n?/gm, "").replace(/^\s+/, ""); + // Keep only the active language's scenes — the file holds EN and PT-BR + // sections interleaved; showing both was the language-mixing bug. + const body = pickLocaleBody(fullBody, locale); const title = bodyTitle ?? (locale === "pt-br" ? (fm.topic_pt_br ?? fm.topic ?? slug) : (fm.topic ?? slug)); const dateLabel = fm.created_at ? new Date(fm.created_at).toLocaleDateString(locale === "pt-br" ? "pt-BR" : "en-US", { day: "numeric", month: "long", year: "numeric" }) : null; const canonical = `${SITE_URL}/c/${slug}`; diff --git a/web/components/case-library.tsx b/web/components/case-library.tsx index 3f8b8d2..c4a7800 100644 --- a/web/components/case-library.tsx +++ b/web/components/case-library.tsx @@ -13,6 +13,7 @@ import Link from "next/link"; import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; +import { pickLocaleBody } from "@/lib/bilingual"; interface CaseFile { slug: string; @@ -102,7 +103,7 @@ async function loadCases(locale: "pt-br" | "en"): Promise { slug: f.replace(/\.md$/, ""), topic: enTitle, topic_pt_br: ptTitle, - opening: pickOpening(body, locale), + opening: pickOpening(pickLocaleBody(body, locale), locale), mtimeMs: st.mtimeMs, }); } catch { /* skip broken file */ } diff --git a/web/components/featured-case.tsx b/web/components/featured-case.tsx index d8fd5d9..53cdf64 100644 --- a/web/components/featured-case.tsx +++ b/web/components/featured-case.tsx @@ -8,6 +8,7 @@ import Link from "next/link"; import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; +import { pickLocaleBody } from "@/lib/bilingual"; interface FeaturedCaseData { slug: string; @@ -86,7 +87,7 @@ async function loadFeatured(locale: "pt-br" | "en"): Promise` section + * followed by its Brazilian-Portuguese twin `## N. ` (CLAUDE.md §3). + * The reader, however, must see ONE language at a time — otherwise an EN + * visitor reads the EN scene immediately followed by the same scene in PT, + * and vice-versa. + * + * We classify each `## ` section by language (PT-BR carries diacritics and + * Portuguese function words; the English scene text does not) and keep only + * the sections matching the active locale. This is more robust than relying + * on EN-then-PT ordering: it survives a missing twin or an out-of-order + * section. If a body can't be split (no `## ` sections, or none match the + * locale), we return it unchanged — better to show everything than to hide + * content. + */ + +const PT_DIACRITICS = /[áàâãéêíóôõúüç]/gi; +const PT_WORDS = + /\b(não|que|com|para|sobre|uma|um|também|após|às|aos|nos|nas|pelo|pela|seu|sua|foi|era|está|são|sem|entre|quando|onde|índice|registro|relatório|noite|céu)\b/gi; +const EN_WORDS = + /\b(the|and|with|what|which|after|over|about|into|from|that|this|their|was|were|been|when|where|night|sky|report|record)\b/gi; + +/** + * Heuristic: does this markdown block read as Brazilian Portuguese? + * Diacritics are the strongest signal — English narration here has none, + * while PT-BR is accent-rich. Function-word counts break ties. + */ +export function isPortuguese(block: string): boolean { + const diacritics = (block.match(PT_DIACRITICS) || []).length; + const pt = (block.match(PT_WORDS) || []).length; + const en = (block.match(EN_WORDS) || []).length; + // Diacritics weigh heavily: a few accents reliably mark PT-BR even when an + // English place name (e.g. "Áden") sneaks one into an EN block. + return diacritics * 2 + pt > en; +} + +/** Section number token ("I", "IV", "3") from an H2 like "## IV. Cables". */ +function sectionKey(section: string): string | null { + const m = section.match(/^##\s+([ivxlcdm\d]+)[.)]/i); + return m ? m[1].toUpperCase() : null; +} + +/** + * Return only the portion of a bilingual case body that matches `locale`. + * Operates on a body whose H1 title lines have already been stripped. + * + * Primary signal is structural, not linguistic: the narrator emits each + * numbered scene as an English `## N.` section immediately followed by its + * Brazilian-Portuguese twin `## N.`. So for a section number seen twice, the + * first occurrence is English and the second is Portuguese — a far more + * reliable split than language sniffing (an English scene quoting accented + * names would otherwise be misread as Portuguese). Language detection is the + * fallback only for unpaired or unnumbered sections. + */ +export function pickLocaleBody(body: string, locale: "en" | "pt-br"): string { + const parts = body.split(/(?=^##\s)/m); + // Content before the first H2 (rare intro/orphan) is shown in both languages. + const head = parts.length > 0 && !/^##\s/.test(parts[0]) ? parts[0].trimEnd() : ""; + const sections = parts.filter((p) => /^##\s/.test(p)); + if (sections.length === 0) return body; + + const counts = new Map(); + for (const s of sections) { + const k = sectionKey(s); + if (k) counts.set(k, (counts.get(k) ?? 0) + 1); + } + + const wantPt = locale === "pt-br"; + const seen = new Map(); + const picked: string[] = []; + for (const s of sections) { + const k = sectionKey(s); + let isPt: boolean; + if (k && (counts.get(k) ?? 0) >= 2) { + const order = seen.get(k) ?? 0; + seen.set(k, order + 1); + isPt = order >= 1; // first = EN, second = PT-BR + } else { + isPt = isPortuguese(s); // unpaired / unnumbered — sniff the language + } + if (isPt === wantPt) picked.push(s.trimEnd()); + } + // Can't isolate the requested language — fall back to the full body so no + // content silently disappears. + if (picked.length === 0) return body; + + const out = picked.join("\n\n"); + return head ? `${head}\n\n${out}` : out; +}