diff --git a/infra/supabase/migrations/0009_pro_anomaly_briefs.sql b/infra/supabase/migrations/0009_pro_anomaly_briefs.sql new file mode 100644 index 0000000..e7add44 --- /dev/null +++ b/infra/supabase/migrations/0009_pro_anomaly_briefs.sql @@ -0,0 +1,49 @@ +-- 0009_pro_anomaly_briefs.sql — Sun-Tzu's silent intermediate artefact. +-- +-- The strategist produces one brief per topic. The case-writer pulls +-- the brief at narrative-assembly time and weaves the thesis + pillars +-- into a confident closing scene. The brief is NEVER surfaced reader- +-- facing — the table is internal to the runtime. +-- +-- Apply as supabase_admin. + +BEGIN; + +CREATE SEQUENCE IF NOT EXISTS public.brief_id_seq START 1; + +CREATE TABLE IF NOT EXISTS public.pro_anomaly_briefs ( + brief_pk BIGSERIAL PRIMARY KEY, + brief_id TEXT UNIQUE NOT NULL, -- B-NNNN + topic TEXT NOT NULL, + topic_pt_br TEXT, + doc_id TEXT, -- optional scope + thesis TEXT NOT NULL, + thesis_pt_br TEXT NOT NULL, + pillars JSONB NOT NULL, -- [{claim,claim_pt_br,support,support_pt_br}] + unexplained TEXT NOT NULL, + unexplained_pt_br TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT 'strategist@detective', + job_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS pro_anomaly_briefs_topic_trgm + ON public.pro_anomaly_briefs USING gin (topic gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS pro_anomaly_briefs_topic_pt_trgm + ON public.pro_anomaly_briefs USING gin (topic_pt_br gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS pro_anomaly_briefs_doc_idx + ON public.pro_anomaly_briefs (doc_id) WHERE doc_id IS NOT NULL; + +-- Grants — investigator role (least privilege) +ALTER TABLE public.pro_anomaly_briefs ENABLE ROW LEVEL SECURITY; +GRANT SELECT, INSERT ON public.pro_anomaly_briefs TO investigator; +GRANT USAGE, SELECT, UPDATE ON SEQUENCE public.brief_id_seq TO investigator; +GRANT USAGE, SELECT, UPDATE ON SEQUENCE public.pro_anomaly_briefs_brief_pk_seq TO investigator; + +CREATE POLICY pro_anomaly_briefs_read ON public.pro_anomaly_briefs FOR SELECT USING (true); +CREATE POLICY pro_anomaly_briefs_insert ON public.pro_anomaly_briefs FOR INSERT + TO investigator, postgres, service_role WITH CHECK (true); + +COMMIT; diff --git a/investigator-runtime/prompts/sun-tzu.md b/investigator-runtime/prompts/sun-tzu.md new file mode 100644 index 0000000..bde5f25 --- /dev/null +++ b/investigator-runtime/prompts/sun-tzu.md @@ -0,0 +1,74 @@ +# You are the strategist (Sun-Tzu) + +You are the disclosure-side analyst. The bureau's other tools (the +extractors, the contradiction-finders) ask "what does the record say?" — +you ask the harder question: **assuming the most anomalous reading is +true, what is the strongest case the corpus already supports?** + +You are not a believer-by-default. You are an adversarial strategist who +plays the pro-disclosure side with the same rigour a red-team plays the +skeptic side. You build the case that would hold up if you had to brief +a serious officer who had only fifteen minutes and a copy of the file. + +Output of this is INVISIBLE TO THE READER. The case-writer narrator will +pull from it silently. Do not address the reader. Do not name yourself. + +## Discipline (non-negotiable) + +1. **Every claim cites a chunk.** `[[doc-id/pNNN#cNNNN]]` next to each + specific assertion. If you cannot ground a claim in a chunk, drop it. + +2. **No fabrication.** If the corpus does not contain a fact, you do not + import it from training-time knowledge. The brief is bounded by the + archive. + +3. **One thesis, three pillars, one residual.** Structure: + - `thesis`: the single sentence the most anomalous reading reduces to. + - `pillars[]`: 2-4 load-bearing claims that hold up the thesis. Each + pillar is a paragraph (≤ 400 chars) with chunk citations. + - `unexplained`: 1-2 sentences naming what the corpus DOES NOT + resolve. This is honest residual, not a hedge — it's the part a + case-writer can use to close on the unknown. + +4. **No skeptic ceremony in your prose.** You are not red-teaming. If a + skeptic counter exists in the corpus, you address it inside a pillar + ("the analysts proposed X; the chunk records Y that X does not + account for") rather than as a separate counter-section. + +5. **House style** (the prompt preamble above already enforces this): + no em-dash-as-comma, no rule-of-three lists, no "Moreover", no AI + vocab, no inflated symbolism. + +## Output protocol — bilingual EN + PT-BR (mandatory) + +Emit a strict JSON object. No prose around it. No code fence. Every +narrative field has its `_pt_br` sibling. + +```json +{ + "thesis": "EN one-sentence — the strongest pro-anomaly reading the corpus supports.", + "thesis_pt_br": "PT-BR uma frase — a leitura pró-anomalia mais forte que o corpus sustenta.", + "pillars": [ + { + "claim": "EN one-sentence claim.", + "claim_pt_br": "PT-BR uma frase de afirmação.", + "support": "EN paragraph (≤ 400 chars) with [[doc-id/pNNN#cNNNN]] citations.", + "support_pt_br": "PT-BR parágrafo (≤ 400 chars) com [[doc-id/pNNN#cNNNN]] citações." + }, + { ... another pillar, also bilingual ... } + ], + "unexplained": "EN 1-2 sentences — what the corpus does NOT resolve.", + "unexplained_pt_br": "PT-BR 1-2 frases — o que o corpus NÃO resolve." +} +``` + +Constraints: +- 2-4 pillars. Three is usually right. Two is fine when the case is + narrow. Avoid four unless each is genuinely independent. +- Every pillar's `support` field must contain at least one + `[[wiki-link]]` citation. +- A missing `_pt_br` sibling is a hard validation failure. + +If the corpus simply does not support a non-trivial pro-anomaly +reading on this topic — emit `NO_STRONG_CASE` and stop. The narrator +will then write the case from the chunks alone, without your brief. diff --git a/investigator-runtime/src/detectives/case_writer.ts b/investigator-runtime/src/detectives/case_writer.ts index 295ff78..6e23365 100644 --- a/investigator-runtime/src/detectives/case_writer.ts +++ b/investigator-runtime/src/detectives/case_writer.ts @@ -60,6 +60,15 @@ interface GapRow { status: string; } +interface BriefRow { + brief_id: string; + thesis: string; + thesis_pt_br: string; + pillars: Array<{ claim: string; claim_pt_br: string; support: string; support_pt_br: string }>; + unexplained: string; + unexplained_pt_br: string; +} + function topicSlug(topic: string): string { return topic .toLowerCase() @@ -129,12 +138,33 @@ function renderNamedWitnesses(rows: WitnessRow[]): string { ].filter(Boolean).join("\n")).join("\n\n"); } +function renderBrief(b: BriefRow | null, lang: "pt" | "en"): string { + if (!b) return "_(no strategic brief on file)_"; + const thesis = lang === "pt" ? b.thesis_pt_br : b.thesis; + const unexplained = lang === "pt" ? b.unexplained_pt_br : b.unexplained; + const pillars = b.pillars.map((p, i) => { + const claim = lang === "pt" ? p.claim_pt_br : p.claim; + const support = lang === "pt" ? p.support_pt_br : p.support; + return `### Pillar ${i + 1}\n${claim}\n\n${support}`; + }).join("\n\n"); + return [ + `**Thesis (the strongest pro-anomaly reading the corpus supports):**`, + thesis, + "", + pillars, + "", + `**Unexplained (honest residual — strong closer material):**`, + unexplained, + ].join("\n"); +} + function buildPrompt( task: CaseWriterTask, scenes: SearchHit[], evidence: EvidenceRow[], witnesses: WitnessRow[], gaps: GapRow[], + brief: BriefRow | null, lang: "pt" | "en", ): string { return [ @@ -180,6 +210,16 @@ function buildPrompt( "", renderNamedWitnesses(witnesses), "", + "## Strategic brief (internal — do NOT cite or attribute)", + "", + "A separate analyst built the strongest pro-anomaly reading the corpus", + "supports. Use the thesis as a quiet through-line; weave the pillar", + "facts into your scenes; let the unexplained clause inform your closing", + "paragraph. DO NOT name the analyst, do not say 'a brief argues', do", + "not include the word 'thesis' or 'pillar' — translate it into prose.", + "", + renderBrief(brief, lang), + "", "## Your task", "", "Write the case file per the system prompt: bilingual EN+PT-BR with", @@ -268,6 +308,19 @@ export async function runCaseWriter(task: CaseWriterTask): Promise< [filter], ); + // Pro-anomaly brief — most recent matching brief on this topic (or doc). + // The narrator never names this artefact; it weaves the thesis silently. + const briefRows = await query( + `SELECT brief_id, thesis, thesis_pt_br, pillars, unexplained, unexplained_pt_br + FROM public.pro_anomaly_briefs + WHERE LOWER(topic) LIKE $1 + OR LOWER(COALESCE(topic_pt_br,'')) LIKE $1 + ${docIdFilter ? "OR doc_id = $2" : ""} + ORDER BY created_at DESC LIMIT 1`, + docIdFilter ? [filter, docIdFilter] : [filter], + ).catch(() => [] as BriefRow[]); + const brief = briefRows[0] ?? null; + await audit({ event: "case_writer_grounded", job_id: task.job_id, @@ -277,6 +330,7 @@ export async function runCaseWriter(task: CaseWriterTask): Promise< n_evidence: evidence.length, n_witnesses: witnesses.length, n_gaps: gaps.length, + brief_id: brief?.brief_id ?? null, }); // Refusal floor: the narrator needs real corpus material. Without enough @@ -286,7 +340,7 @@ export async function runCaseWriter(task: CaseWriterTask): Promise< } const systemPrompt = await readFile(PROMPT_PATH, "utf-8"); - const prompt = buildPrompt(task, scenes, evidence, witnesses, gaps, lang); + const prompt = buildPrompt(task, scenes, evidence, witnesses, gaps, brief, lang); // Case-writer wants more output budget than the other detectives. const llm = await callClaude({ diff --git a/investigator-runtime/src/detectives/sun_tzu.ts b/investigator-runtime/src/detectives/sun_tzu.ts new file mode 100644 index 0000000..bca1916 --- /dev/null +++ b/investigator-runtime/src/detectives/sun_tzu.ts @@ -0,0 +1,177 @@ +/** + * sun_tzu.ts — pro-anomaly strategist (silent backend feeder). + * + * Given a topic, builds the strongest pro-anomaly brief the corpus + * supports: a thesis sentence, 2-4 citation-backed pillars, and an + * honest residual unexplained clause. The brief is stored in + * public.pro_anomaly_briefs and never surfaced reader-facing. The + * case-writer pulls it at narrative-assembly time. + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { audit } from "../lib/audit"; +import { callClaude } from "../lib/claude"; +import { env } from "../lib/env"; +import { hybridSearch, type SearchHit } from "../lib/search"; +import { + writeProAnomalyBrief, + type WriteBriefArgs, + type BriefPillar, +} from "../tools/write_pro_anomaly_brief"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "sun-tzu.md"); + +export interface SunTzuTask { + job_id: string; + topic: string; + topic_pt_br?: string; + doc_id?: string; + lang?: "pt" | "en"; + context_chunks?: number; + budget_cap_usd?: number; +} + +function renderChunkBlock(hits: SearchHit[], lang: "pt" | "en"): string { + return hits.map((h, i) => { + const text = (lang === "en" ? h.content_en : h.content_pt) || h.content_en || h.content_pt || ""; + return [ + `--- chunk ${i + 1} ---`, + `doc_id: ${h.doc_id}`, + `chunk_id: ${h.chunk_id}`, + `page: ${h.page}`, + `type: ${h.type}`, + h.classification ? `classification: ${h.classification}` : null, + "", + text.slice(0, 1100), + ].filter(Boolean).join("\n"); + }).join("\n\n"); +} + +function buildPrompt(task: SunTzuTask, hits: SearchHit[], lang: "pt" | "en"): string { + return [ + `# Topic`, + "", + `**EN.** ${task.topic}`, + `**PT-BR.** ${task.topic_pt_br ?? task.topic}`, + task.doc_id ? `\nScope: ${task.doc_id}` : "", + "", + `## Corpus shortlist (${hits.length} chunks)`, + "", + renderChunkBlock(hits, lang), + "", + "## Your task", + "", + "Build the strongest pro-anomaly brief the chunks above support.", + "Output the strict JSON object per the system prompt. Bilingual is", + "mandatory. If the corpus does not support a non-trivial pro-anomaly", + "reading, emit `NO_STRONG_CASE` and stop.", + ].filter(Boolean).join("\n"); +} + +function extractJsonObject(text: string): Record | null { + const t = text.trim(); + if (/^`?NO_STRONG_CASE`?\b/i.test(t)) return null; + const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, ""); + const first = stripped.indexOf("{"); + const last = stripped.lastIndexOf("}"); + if (first === -1 || last === -1) throw new Error(`no JSON object: ${t.slice(0, 200)}`); + const parsed = JSON.parse(stripped.slice(first, last + 1)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("JSON is not an object"); + } + return parsed as Record; +} + +function coercePillars(raw: unknown): BriefPillar[] { + if (!Array.isArray(raw)) return []; + const out: BriefPillar[] = []; + for (const p of raw) { + if (!p || typeof p !== "object") continue; + const o = p as Record; + const claim = typeof o.claim === "string" ? o.claim.trim() : ""; + const claim_pt_br = typeof o.claim_pt_br === "string" ? o.claim_pt_br.trim() : ""; + const support = typeof o.support === "string" ? o.support.trim() : ""; + const support_pt_br = typeof o.support_pt_br === "string" ? o.support_pt_br.trim() : ""; + if (!claim || !claim_pt_br || !support || !support_pt_br) continue; + out.push({ claim, claim_pt_br, support, support_pt_br }); + } + return out; +} + +export async function runSunTzu(task: SunTzuTask): Promise< + | { brief_id: string } + | { skipped: true; reason: string } +> { + const lang: "pt" | "en" = task.lang ?? "pt"; + const k = task.context_chunks ?? 18; + + const hits = await hybridSearch({ + query: task.topic, + lang, + doc_id: task.doc_id ?? null, + top_k: k, + recall_k: 80, + max_dense_dist: 0.55, + }); + await audit({ + event: "sun_tzu_grounded", + job_id: task.job_id, + detective: "strategist@detective", + topic: task.topic, + n_chunks: hits.length, + doc_id: task.doc_id ?? null, + }); + if (hits.length < 4) { + return { skipped: true, reason: `insufficient_corpus_${hits.length}_of_4` }; + } + + const systemPrompt = await readFile(PROMPT_PATH, "utf-8"); + const llm = await callClaude({ + prompt: buildPrompt(task, hits, lang), + systemPrompt, + model: env.CLAUDE_MODEL, + allowedTools: [], + timeoutMs: env.JOB_TIMEOUT_SECONDS * 1000, + budgetCapUsd: task.budget_cap_usd ?? env.BUDGET_CAP_USD_PER_JOB, + }); + await audit({ + event: "detective_completed", + job_id: task.job_id, + detective: "strategist@detective", + cost_usd: llm.costUsd, + tokens_in: llm.tokensIn, + tokens_out: llm.tokensOut, + duration_ms: llm.durationMs, + }); + + const obj = extractJsonObject(llm.text); + if (obj === null) return { skipped: true, reason: "NO_STRONG_CASE" }; + + const args: WriteBriefArgs = { + topic: task.topic, + topic_pt_br: task.topic_pt_br ?? task.topic, + doc_id: task.doc_id, + thesis: typeof obj.thesis === "string" ? obj.thesis.trim() : "", + thesis_pt_br: typeof obj.thesis_pt_br === "string" ? obj.thesis_pt_br.trim() : "", + pillars: coercePillars(obj.pillars), + unexplained: typeof obj.unexplained === "string" ? obj.unexplained.trim() : "", + unexplained_pt_br: typeof obj.unexplained_pt_br === "string" ? obj.unexplained_pt_br.trim() : "", + }; + + try { + return await writeProAnomalyBrief(args, { + job_id: task.job_id, + detective: "strategist@detective", + }); + } catch (e) { + await audit({ + event: "write_pro_anomaly_brief_failed", + job_id: task.job_id, + detective: "strategist@detective", + error: (e as Error).message, + }); + return { skipped: true, reason: `write_failed: ${(e as Error).message.slice(0, 80)}` }; + } +} diff --git a/investigator-runtime/src/lib/ids.ts b/investigator-runtime/src/lib/ids.ts index a5d0854..14a3382 100644 --- a/investigator-runtime/src/lib/ids.ts +++ b/investigator-runtime/src/lib/ids.ts @@ -21,4 +21,5 @@ export const allocate = { witnessId: () => nextval("public.witness_id_seq", "W"), gapId: () => nextval("public.gap_id_seq", "G"), residualUncertaintyId: () => nextval("public.residual_uncertainty_id_seq","RU"), + briefId: () => nextval("public.brief_id_seq", "B"), }; diff --git a/investigator-runtime/src/orchestrator.ts b/investigator-runtime/src/orchestrator.ts index d08cb05..2e21123 100644 --- a/investigator-runtime/src/orchestrator.ts +++ b/investigator-runtime/src/orchestrator.ts @@ -15,6 +15,7 @@ import { runPoirot, type PoirotTask } from "./detectives/poirot"; import { runTaleb, type TalebTask } from "./detectives/taleb"; import { runTetlock, type TetlockTask } from "./detectives/tetlock"; import { runCaseWriter, type CaseWriterTask } from "./detectives/case_writer"; +import { runSunTzu, type SunTzuTask } from "./detectives/sun_tzu"; export interface InvestigationJob { job_id: string; @@ -75,6 +76,25 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise } break; } + case "anomaly_brief": { + // Payload: { topic, topic_pt_br?, doc_id?, lang? } + const topic = String(job.payload.topic ?? "").trim(); + if (!topic) throw new Error("anomaly_brief requires payload.topic"); + const task: SunTzuTask = { + 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, + lang: job.payload.lang === "en" ? "en" : "pt", + }; + const r = await runSunTzu(task); + if ("skipped" in r) { + outputs.push({ kind: "anomaly_brief", skipped: true, reason: r.reason }); + } else { + outputs.push({ kind: "anomaly_brief", brief_id: r.brief_id }); + } + break; + } case "case_report": { // Payload: { topic, topic_pt_br?, doc_id?, slug?, lang? } const topic = String(job.payload.topic ?? "").trim(); diff --git a/investigator-runtime/src/tools/write_pro_anomaly_brief.ts b/investigator-runtime/src/tools/write_pro_anomaly_brief.ts new file mode 100644 index 0000000..5aaf4b6 --- /dev/null +++ b/investigator-runtime/src/tools/write_pro_anomaly_brief.ts @@ -0,0 +1,83 @@ +/** + * write_pro_anomaly_brief.ts — Sun-Tzu's primary writer. + * + * Inserts a row into public.pro_anomaly_briefs. The brief is INTERNAL — + * the case-writer reads it at assembly time but the reader never sees + * it directly. + */ +import { audit } from "../lib/audit"; +import { allocate } from "../lib/ids"; +import { queryOne } from "../lib/pg"; + +export interface BriefPillar { + claim: string; + claim_pt_br: string; + support: string; + support_pt_br: string; +} + +export interface WriteBriefArgs { + topic: string; + topic_pt_br?: string; + doc_id?: string; + thesis: string; + thesis_pt_br: string; + pillars: BriefPillar[]; + unexplained: string; + unexplained_pt_br: string; +} + +export interface WriteBriefContext { + job_id: string; + detective: string; +} + +export async function writeProAnomalyBrief( + body: WriteBriefArgs, + ctx: WriteBriefContext, +): Promise<{ brief_id: string; pk: number }> { + if (!body.topic?.trim()) throw new Error("topic required"); + if (!body.thesis?.trim()) throw new Error("thesis required"); + if (!body.thesis_pt_br?.trim()) throw new Error("thesis_pt_br required"); + if (!body.unexplained?.trim()) throw new Error("unexplained required"); + if (!body.unexplained_pt_br?.trim()) throw new Error("unexplained_pt_br required"); + if (!Array.isArray(body.pillars) || body.pillars.length < 2 || body.pillars.length > 4) { + throw new Error(`pillars must be 2-4 entries (got ${body.pillars?.length ?? 0})`); + } + for (const p of body.pillars) { + if (!p?.claim?.trim() || !p?.claim_pt_br?.trim() || !p?.support?.trim() || !p?.support_pt_br?.trim()) { + throw new Error("each pillar requires bilingual claim + support"); + } + if (!/\[\[/.test(p.support) && !/\[\[/.test(p.support_pt_br)) { + throw new Error("each pillar's support must contain at least one [[wiki-link]] citation"); + } + } + + const brief_id = await allocate.briefId(); + const row = await queryOne<{ brief_pk: number }>( + `INSERT INTO public.pro_anomaly_briefs + (brief_id, topic, topic_pt_br, doc_id, thesis, thesis_pt_br, + pillars, unexplained, unexplained_pt_br, created_by, job_id) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11) + RETURNING brief_pk`, + [ + brief_id, body.topic.trim(), body.topic_pt_br?.trim() ?? null, + body.doc_id ?? null, body.thesis.trim(), body.thesis_pt_br.trim(), + JSON.stringify(body.pillars), + body.unexplained.trim(), body.unexplained_pt_br.trim(), + ctx.detective, ctx.job_id, + ], + ); + + await audit({ + event: "write_pro_anomaly_brief", + job_id: ctx.job_id, + detective: ctx.detective, + brief_id, + pk: row?.brief_pk, + n_pillars: body.pillars.length, + topic: body.topic.slice(0, 120), + }); + + return { brief_id, pk: row?.brief_pk ?? 0 }; +} diff --git a/web/app/e/[cls]/[id]/page.tsx b/web/app/e/[cls]/[id]/page.tsx index da16713..6f3b9df 100644 --- a/web/app/e/[cls]/[id]/page.tsx +++ b/web/app/e/[cls]/[id]/page.tsx @@ -112,6 +112,18 @@ export default async function EntityPage({ const classColor = CLASS_COLOR[folder as EntityClass]; const classBg = CLASS_BG[folder as EntityClass]; + // Hero illustration — W5.4 generates one painterly editorial image per + // iconic entity, stored at /data/ufo/processing/case-art/.png + // and served via the existing /api/static/processing/ route. + let heroIllustration: string | null = null; + try { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const ufoRoot = process.env.UFO_ROOT || "/data/ufo"; + await fs.stat(path.join(ufoRoot, "processing", "case-art", `${id}.png`)); + heroIllustration = `/api/static/processing/case-art/${id}.png`; + } catch { /* no illustration for this entity yet */ } + // The generated entity bodies hold only "# Title" + empty "## Description" // headings — strip headings and see if any real prose remains. const bodyProse = (wiki?.body ?? "").replace(/^#.*$/gm, "").trim(); @@ -144,8 +156,36 @@ export default async function EntityPage({ - {/* Hero header */} -
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {canonical} +
+
+
+
+ {CLASS_TITLE[folder as EntityClass]} · /e/{folder}/{id} +
+

+ {canonical} +

+
+
+ Ilustração editorial +
+
+
+ )} + + {/* Hero header — shown only when there's no illustration above */} + {!heroIllustration &&
@@ -241,7 +281,7 @@ export default async function EntityPage({ ela. Pode ser extração ruidosa do pipeline original.

)} -
+
}
{/* MAIN — narrative + chunks live */}