diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md
index 1ab3091b..9678a417 100644
--- a/.agents/skills/dossie-solla-cidade/SKILL.md
+++ b/.agents/skills/dossie-solla-cidade/SKILL.md
@@ -150,8 +150,12 @@ cruza para ele.
"items": [
{
"id": "era_c_emendas", // id do checklist da era
- "answer": "R$ 1,2 milhão para equipamentos de saúde",
+ "answer": "registro integral; aceita {{fonte}} / {{fonte:N}} — vai para o .md e para o lastro",
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
+ "brief": { // obrigatório no item publicado: copy reformulada, curta
+ "title": "≤80 chars — manchete (o quê + onde)",
+ "note": "≤120 chars — 1 frase de contexto; opcional"
+ },
"sphere": "municipio", // municipio | regiao | polo (default municipio)
"numbers": [ // opcional
{ "label": "Saúde", "value": "R$ 1,2 mi", "year": "2024", "phase": "empenhado" }
@@ -183,6 +187,17 @@ Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira
(default `nao_informado`). O `bulletinFacts` (ledger do boletim) só é populado
por item **com fonte** — o boletim não introduz fato novo.
+**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
+em vez de cortar o texto com `…`, cada item publicado traz um `brief` **reescrito
+para caber** (`title` ≤80 / `note` ≤120) preservando o essencial (o quê, onde,
+valor + fase) — nunca inventa fato, nunca sugere exclusividade municipal para
+item `regiao`/`polo` e nunca troca a fase (empenho ≠ pagamento). O cartão/linha
+do PDF imprime `brief.title`/`brief.note`; o `answer`/`details` integrais ficam
+no registro e no companion `.md`. Sem `brief`, o renderer cai no texto integral e
+a guarda de fit A4 do builder **falha fechado** (não corta em silêncio). Tabelas
+longas (lacunas, notícias) não usam `brief`: são **paginadas** em folhas de
+continuação com o texto inteiro.
+
## Conteúdo do dossiê
- **Capa** — série municipal, "INSUMO INTERNO", território, região/polo
diff --git a/.opencode/agent/dossie-solla-cidade.md b/.opencode/agent/dossie-solla-cidade.md
index 090f5db1..f366ba46 100644
--- a/.opencode/agent/dossie-solla-cidade.md
+++ b/.opencode/agent/dossie-solla-cidade.md
@@ -20,7 +20,9 @@ slug e coordena as demais eras/cidades em paralelo.
(ex.: `ilheus.c.research.json`) — item sem `sourceUrl`/`sourceDate` não vai
para o arquivo (vira lacuna explícita). Use o checklist da era e marque
`sphere` (`municipio`/`regiao`/`polo`); região/polo nunca é somado ao
- município.
+ município. Cada item publicado traz **`brief`** (`title` ≤80 / `note` ≤120):
+ copy **reformulada** para caber no A4, sem `…`, sem fato novo e sem trocar a
+ fase — o `answer`/`details` integrais continuam no arquivo (registro/.md).
3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):
`slug`, `era`, `status`, `researchPath`, `researchedAt`, `itemCount`,
`gapCount`, `newsCount`, `gaps`, `failureReason?`. **Nunca** devolva o corpo
diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs
index 86cdc5d7..4bc51303 100644
--- a/scripts/lib/dossieBlocks.mjs
+++ b/scripts/lib/dossieBlocks.mjs
@@ -20,10 +20,11 @@ import { stripInlineSources } from './reportText.mjs'
const MAX_DELIVERIES_PAGE_ONE = 3
const MAX_HOOKS_PAGE_ONE = 2
const MAX_PENDING_PAGE_ONE = 4
-const MAX_ERA_NUMBERS = 12
-const MAX_ERA_ACTIONS = 4
-const MAX_REGION_ITEMS = 4
-const MAX_SCOPE_LIST = 5
+const MAX_ERA_NUMBERS = 6
+/** Curated research actions are all kept (the renderer paginates them); only the generic Câmara list is capped. */
+const MAX_ERA_CAMARA_ACTIONS = 4
+const MAX_REGION_ITEMS = 3
+const MAX_SCOPE_LIST = 3
const sphereLabels = { municipio: 'município', regiao: 'região', polo: 'polo' }
export const dossierSphereLabel = (sphere) => sphereLabels[sphere] ?? sphere
@@ -171,6 +172,7 @@ export const buildDossierReport = ({
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
+ brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
@@ -184,10 +186,17 @@ export const buildDossierReport = ({
: itemNumberRows(items.filter((i) => i.era === era.id)),
MAX_ERA_NUMBERS,
)
- const honors = items
- .filter((item) => item.era === 'C' && item.id === 'era_c_titulos')
- .map((item) => ({ text: item.answer, sourceUrl: item.sourceUrl }))
- const actions = limit([...ownActions, ...extraActions], MAX_ERA_ACTIONS)
+ const honors =
+ era.id === 'C'
+ ? items
+ .filter((item) => item.id === 'era_c_titulos')
+ .map((item) => ({
+ text: item.answer,
+ brief: item.brief ?? null,
+ sourceUrl: item.sourceUrl,
+ }))
+ : []
+ const actions = [...ownActions, ...limit(extraActions, MAX_ERA_CAMARA_ACTIONS)]
if (numbers.length === 0 && actions.length === 0) return null
return {
...era,
@@ -215,6 +224,7 @@ export const buildDossierReport = ({
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
+ brief: item.brief ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
@@ -227,6 +237,7 @@ export const buildDossierReport = ({
[...municipalItems, ...regionalItems].map((item) => ({
topic: item.area,
angle: item.answer,
+ brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
MAX_HOOKS_PAGE_ONE,
@@ -249,6 +260,7 @@ export const buildDossierReport = ({
item: item.answer,
sphere: item.sphere,
evidence: item.details ?? item.label,
+ brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
MAX_REGION_ITEMS,
@@ -264,6 +276,12 @@ export const buildDossierReport = ({
area: item.area,
headline: stripInlineSources(item.answer),
detail: item.details ? stripInlineSources(item.details) : null,
+ brief: item.brief
+ ? {
+ title: stripInlineSources(item.brief.title),
+ note: item.brief.note ? stripInlineSources(item.brief.note) : null,
+ }
+ : null,
value: item.numbers?.[0]?.value ?? null,
numberLabel: item.numbers?.[0]?.label ?? null,
year: item.numbers?.[0]?.year ?? null,
diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs
index bc2bc1e1..c1ee9588 100644
--- a/scripts/lib/dossieBulletin.mjs
+++ b/scripts/lib/dossieBulletin.mjs
@@ -44,14 +44,14 @@ export const buildBulletin = ({
const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({
eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere)}`,
number: fact.value,
- title: fact.headline,
- note: fact.detail ?? null,
+ title: fact.brief?.title ?? fact.headline,
+ note: fact.brief?.note ?? fact.detail,
}))
const moreItems = ordered
.slice(BULLETIN_HIGHLIGHT_LIMIT, BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT)
.map((fact) => ({
- label: fact.headline,
- detail: fact.detail ?? fact.value ?? fact.area,
+ label: fact.brief?.title ?? fact.headline,
+ detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area,
}))
return {
diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs
index d24d93a7..2c3aa1ef 100644
--- a/scripts/lib/dossieBulletinRender.mjs
+++ b/scripts/lib/dossieBulletinRender.mjs
@@ -21,7 +21,7 @@ const renderHighlight = (highlight) => `
`
const renderMoreItem = (item) =>
- `
${htmlEscape(item.label)}: ${htmlEscape(item.detail)}
`
+ `${htmlEscape(item.label)}
`
const PRINT_CSS = `
* { box-sizing: border-box; }
diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs
index dbcbee39..1540a7b4 100644
--- a/scripts/lib/dossieRender.mjs
+++ b/scripts/lib/dossieRender.mjs
@@ -30,8 +30,15 @@ const formatShortDateBr = (value) => {
return parts.length === 3 ? `${parts[0]}/${parts[1]}/${parts[2].slice(-2)}` : full
}
-/** Research text without the inline `{{fonte}}` markers — the row's own link carries the source. */
-const cleanText = (value) => htmlEscape(stripInlineSources(value))
+/**
+ * Print surfaces prefer the reformulated `brief` (short headline + note that fit
+ * the fixed A4 with no ellipsis) and fall back to the full `answer`/`details`
+ * record when an item has none — the builder's A4 fit guard then fails closed
+ * instead of silently dropping information.
+ */
+const copyHtml = (value) => htmlEscape(stripInlineSources(value))
+const briefOr = (brief, fallback) => brief?.title ?? fallback
+const noteOr = (brief, fallback) => brief?.note ?? fallback
const phaseBadge = (phase) => `${htmlEscape(dossierPhaseLabel(phase))}`
@@ -52,8 +59,15 @@ const assetBox = (title, lines) =>
const SHEET_COUNT_BASE = 5
const pageCount = (report) => report.meta.pageTotal
-/** Gaps are paginated, never truncated: 12 rows per A4 sheet. */
-const GAPS_PER_PAGE = 12
+/** Gaps are paginated, never truncated: 7 full-text rows per A4 sheet. */
+const GAPS_PER_PAGE = 7
+
+/** News are paginated, never truncated: 10 full-title rows per A4 sheet. */
+const NEWS_PER_PAGE = 10
+
+/** Era "Atuação registrada" cards: 2 on the first sheet, 6 per continuation sheet. */
+const ERA_ACTIONS_FIRST_SHEET = 2
+const ERA_ACTIONS_PER_SHEET = 6
const chunkGaps = (gaps) => {
const list = Array.isArray(gaps) ? gaps : []
@@ -65,6 +79,31 @@ const chunkGaps = (gaps) => {
return chunks
}
+const chunkNews = (news) => {
+ const list = Array.isArray(news) ? news : []
+ const chunks = []
+ for (let index = 0; index < list.length; index += NEWS_PER_PAGE) {
+ chunks.push(list.slice(index, index + NEWS_PER_PAGE))
+ }
+ return chunks
+}
+
+/**
+ * Era action cards, paginated so no sourced fact is dropped: the first sheet
+ * carries the design's 2 cards (with method + numbers + honors), and the rest
+ * flow to continuation sheets.
+ */
+const chunkEraActions = (era) => {
+ const actions = Array.isArray(era.actions) ? era.actions : []
+ const first = actions.slice(0, ERA_ACTIONS_FIRST_SHEET)
+ const rest = actions.slice(ERA_ACTIONS_FIRST_SHEET)
+ const chunks = [first]
+ for (let index = 0; index < rest.length; index += ERA_ACTIONS_PER_SHEET) {
+ chunks.push(rest.slice(index, index + ERA_ACTIONS_PER_SHEET))
+ }
+ return chunks
+}
+
const timelineGrid = (rows) => `
${rows
@@ -165,8 +204,8 @@ const renderDeliveries = (report) => {
(row) => `
${scopeBadge(row.sphere)}
Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}
-
${cleanText(row.title)}
- ${row.detail ? `
${cleanText(row.detail)}
` : ''}
+
${copyHtml(briefOr(row.brief, row.title))}
+ ${noteOr(row.brief, row.detail) ? `
${copyHtml(noteOr(row.brief, row.detail))}
` : ''}
${valueCell(row.value, 'Valor não informado')}
@@ -194,7 +233,7 @@ const renderHooksAndPending = (report) => {
${hooks
.map(
(hook) =>
- `- Tema: ${htmlEscape(hook.topic)}. Ângulo: ${cleanText(hook.angle)} ${sourceLink(hook.sourceUrl)}
`,
+ `- Tema: ${htmlEscape(hook.topic)}. Ângulo: ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}
`,
)
.join('')}
`
@@ -221,11 +260,34 @@ const renderSummary = (report, pageNo) => `
${sheetFooter(report, 'Resumo', pageNo)}
`
-const renderEra = (report, era, pageNo) => `
-
- ${sheetHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)}
+const renderActionGrid = (actions) => `
+
+ ${actions
+ .map(
+ (action) => `
+ ${scopeBadge(action.sphere)}${htmlEscape(action.year ?? '—')}
+ ${copyHtml(briefOr(action.brief, action.title))}
+ ${noteOr(action.brief, action.detail) ? `${copyHtml(noteOr(action.brief, action.detail))}
` : ''}
+ ${sourceLink(action.sourceUrl)}
+ `,
+ )
+ .join('')}
+
`
-
+const renderEraPage = (report, era, actions, index, pageNo) => {
+ const first = index === 0
+ const slug = era.id.toLowerCase()
+ const anchor = first ? `era-${slug}` : `era-${slug}-${index + 1}`
+ const subtitle = first
+ ? `${era.period} · ${era.subtitle}`
+ : `Atuação registrada · continuação ${index + 1}`
+ return `
+
+ ${sheetHeader(report, era.label, subtitle, pageNo)}
+
+ ${
+ first
+ ? `
Recorte e método
${htmlEscape(era.method)}
@@ -256,39 +318,32 @@ const renderEra = (report, era, pageNo) => `
)
.join('')}
-
Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.
`
: ''
+ }`
+ : ''
}
${
- era.actions.length
+ actions.length
? `
Atuação registrada
O que fez — item, alcance e lastro
-
- ${era.actions
- .map(
- (action) => `
- ${scopeBadge(action.sphere)}${htmlEscape(action.year ?? '—')}
- ${cleanText(action.title)}
- ${action.detail ? `${cleanText(action.detail)}
` : ''}
- ${sourceLink(action.sourceUrl)}
- `,
- )
- .join('')}
-
+ ${renderActionGrid(actions)}
`
: ''
}
${
- era.honors.length
+ first && era.honors.length
? `
Títulos, honrarias e vínculos locais
${era.honors
- .map((honor) => `- ${cleanText(honor.text)} ${sourceLink(honor.sourceUrl)}
`)
+ .map(
+ (honor) =>
+ `- ${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}
`,
+ )
.join('')}
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
@@ -298,6 +353,7 @@ const renderEra = (report, era, pageNo) => `
${sheetFooter(report, era.label, pageNo)}
`
+}
const renderScopeLists = (report) => `
@@ -307,7 +363,10 @@ const renderScopeLists = (report) => `
${scopeBadge('municipio')}${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte
${report.region.municipal.items
- .map((item) => `- ${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}
`)
+ .map(
+ (item) =>
+ `- ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
`,
+ )
.join(
'',
)}${report.region.municipal.items.length === 0 ? '- Nenhum item municipal com fonte.
' : ''}
@@ -316,7 +375,10 @@ const renderScopeLists = (report) => `
${scopeBadge('regiao', 'região / polo')}SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}
${report.region.regional.items
- .map((item) => `- ${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}
`)
+ .map(
+ (item) =>
+ `- ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
`,
+ )
.join(
'',
)}${report.region.regional.items.length === 0 ? '- Nenhum item regional com fonte.
' : ''}
@@ -340,7 +402,7 @@ const renderRegion = (report, pageNo) => `
report.region.context.length
? `
Contexto municipal (IBGE): ${report.region.context
.map(
- (item) => `${htmlEscape(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
+ (item) => `${copyHtml(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
)
.join(' · ')} — leitura relativa, nunca % estadual absoluto.
`
: ''
@@ -359,9 +421,9 @@ const renderRegion = (report, pageNo) => `
${report.region.items
.map(
(item) => `
- | ${cleanText(item.item)} |
+ ${copyHtml(briefOr(item.brief, item.item))} |
${scopeBadge(item.sphere)} |
- ${cleanText(item.evidence)} |
+ ${copyHtml(noteOr(item.brief, item.evidence))} |
${sourceLink(item.sourceUrl)} |
`,
)
@@ -376,7 +438,7 @@ const renderRegion = (report, pageNo) => `
? `
${
report.region.hook
- ? `Gancho possível
Tema: ${htmlEscape(report.region.hook.topic)}. Ângulo: ${cleanText(report.region.hook.angle)} ${sourceLink(report.region.hook.sourceUrl)}
`
+ ? `Gancho possível
Tema: ${htmlEscape(report.region.hook.topic)}. Ângulo: ${copyHtml(briefOr(report.region.hook.brief, report.region.hook.angle))} ${sourceLink(report.region.hook.sourceUrl)}
`
: ''
}
${
@@ -420,34 +482,35 @@ const renderGapsPage = (report, chunk, index, pageNo) => {
`
}
+const renderNewsPage = (report, chunk, index, pageNo) => `
+
+ ${sheetHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
+
+ Lista clicável
+ Notícias e documentos consultados
+
+ Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%
+
+ | Data | Veículo | Título | Link |
+ ${chunk
+ .map(
+ (row) => `
+ | ${htmlEscape(formatShortDateBr(row.date))} |
+ ${htmlEscape(row.outlet)} |
+ ${htmlEscape(row.title)} |
+ ${sourceLink(row.url, '(fonte)')} |
+
`,
+ )
+ .join('')}
+
+
+ ${sheetFooter(report, 'Notícias e documentos consultados · INSUMO INTERNO', pageNo)}
+`
+
const renderSourcesPage = (report, pageNo) => `
${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
- ${
- report.news.length
- ? `
- Lista clicável
- Notícias e documentos consultados
-
- Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%
-
- | Data | Veículo | Título | Link |
- ${report.news
- .map(
- (row) => `
- | ${htmlEscape(formatShortDateBr(row.date))} |
- ${htmlEscape(row.outlet)} |
- ${htmlEscape(row.title)} |
- ${sourceLink(row.url, '(fonte)')} |
-
`,
- )
- .join('')}
-
- `
- : ''
- }
-
Limites de cobertura
${report.limits.coverage
.map((item) => `- ${htmlEscape(item)}
`)
@@ -578,16 +641,24 @@ const PRINT_CSS = `
export const renderDossierHtml = (report) => {
const gapChunks = chunkGaps(report.gaps)
- report.meta.pageTotal = SHEET_COUNT_BASE + report.eras.length + gapChunks.length
+ const newsChunks = chunkNews(report.news)
+ const eraSheets = report.eras.map((era) => ({ era, chunks: chunkEraActions(era) }))
+ const eraSheetCount = eraSheets.reduce((sum, entry) => sum + entry.chunks.length, 0)
+ report.meta.pageTotal = SHEET_COUNT_BASE + eraSheetCount + gapChunks.length + newsChunks.length
let page = 0
const parts = []
parts.push(renderCover(report))
page += 1
parts.push(renderTrajectory(report, ++page))
parts.push(renderSummary(report, ++page))
- for (const era of report.eras) parts.push(renderEra(report, era, ++page))
+ for (const { era, chunks } of eraSheets) {
+ chunks.forEach((actions, index) =>
+ parts.push(renderEraPage(report, era, actions, index, ++page)),
+ )
+ }
parts.push(renderRegion(report, ++page))
gapChunks.forEach((chunk, index) => parts.push(renderGapsPage(report, chunk, index, ++page)))
+ newsChunks.forEach((chunk, index) => parts.push(renderNewsPage(report, chunk, index, ++page)))
parts.push(renderSourcesPage(report, ++page))
return `
diff --git a/scripts/lib/dossieResearch.mjs b/scripts/lib/dossieResearch.mjs
index f7f56622..7bb52429 100644
--- a/scripts/lib/dossieResearch.mjs
+++ b/scripts/lib/dossieResearch.mjs
@@ -14,6 +14,16 @@ const DOSSIER_SPHERES = ['municipio', 'regiao', 'polo']
/** Execution phases (empenho ≠ pagamento). Unknown/absent degrades to "não informada". */
const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos', 'nao_informado']
+/**
+ * Copy budget for a printed `brief` (reformulated headline + note that must fit
+ * the fixed A4 surfaces with no ellipsis). The full `answer`/`details` stay in
+ * the record and the companion `.md`; the `brief` is the reformulated, shorter
+ * version the PDF prints. Items without a `brief` fall back to the full text and
+ * the builder's A4 fit guard fails closed if it overflows.
+ */
+export const DOSSIER_BRIEF_TITLE_MAX = 80
+export const DOSSIER_BRIEF_NOTE_MAX = 120
+
/** Checklist per era — the dossiê's research checklist. `area` labels the boletim card. */
const DOSSIER_RESEARCH_CHECKLIST = [
{
@@ -116,6 +126,27 @@ const normalizePhase = (value) => {
return DOSSIER_PHASES.includes(phase) ? phase : 'nao_informado'
}
+const normalizeBrief = (entry) => {
+ const title = isNonEmptyString(entry?.title) ? entry.title.trim() : null
+ if (!title) return null
+ return { title, note: isNonEmptyString(entry?.note) ? entry.note.trim() : null }
+}
+
+/** Non-fatal: an over-budget brief is kept, but the A4 fit guard will likely fail — surface it early. */
+const warnIfBriefOverBudget = (id, brief) => {
+ if (!brief) return
+ if (brief.title.length > DOSSIER_BRIEF_TITLE_MAX) {
+ console.warn(
+ `[dossie-research] brief.title de ${id} tem ${brief.title.length} chars (teto ${DOSSIER_BRIEF_TITLE_MAX}).`,
+ )
+ }
+ if ((brief.note?.length ?? 0) > DOSSIER_BRIEF_NOTE_MAX) {
+ console.warn(
+ `[dossie-research] brief.note de ${id} tem ${brief.note.length} chars (teto ${DOSSIER_BRIEF_NOTE_MAX}).`,
+ )
+ }
+}
+
const normalizeNumbers = (entry) => {
const numbers = []
for (const number of Array.isArray(entry?.numbers) ? entry.numbers : []) {
@@ -208,6 +239,8 @@ export const normalizeDossierResearchInput = (raw) => {
})
continue
}
+ const brief = normalizeBrief(entry.brief)
+ warnIfBriefOverBudget(id, brief)
items.push({
id,
era,
@@ -215,6 +248,7 @@ export const normalizeDossierResearchInput = (raw) => {
area: checklistItem.area,
answer,
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
+ brief,
sphere,
numbers: normalizeNumbers(entry),
sourceUrl,
diff --git a/tests/unit/dossieRender.unit.spec.ts b/tests/unit/dossieRender.unit.spec.ts
index f123b518..4dc67b12 100644
--- a/tests/unit/dossieRender.unit.spec.ts
+++ b/tests/unit/dossieRender.unit.spec.ts
@@ -60,6 +60,12 @@ const research = mergeDossierResearch([
sourceUrl: 'https://portaldatransparencia.test/emendas',
sourceDate: '2026-09-15',
},
+ {
+ id: 'era_c_titulos',
+ answer: 'Título de cidadão honorário',
+ sourceUrl: 'https://camara.test/titulo',
+ sourceDate: '2026-09-10',
+ },
],
news: [
{
@@ -120,6 +126,89 @@ describe('renderDossierHtml', () => {
it('prints the defeso note', () => {
expect(html).toContain('Nota de defeso eleitoral 2026')
})
+
+ it('renders the local honors only on their own era sheet (never on another era)', () => {
+ expect((html.match(/Títulos, honrarias e vínculos locais/g) ?? []).length).toBe(1)
+ })
+})
+
+describe('renderDossierHtml — reformulated brief (no ellipsis)', () => {
+ const briefedResearch = mergeDossierResearch([
+ normalizeDossierResearchInput({
+ municipalitySlug: 'ilheus',
+ era: 'C',
+ researchedAt: '2026-09-16T10:00:00.000Z',
+ items: [
+ {
+ id: 'era_c_emendas',
+ answer: 'Resposta integral bem longa que não deve aparecer no cartão impresso do dossiê',
+ details: 'Detalhe integral que também não deve aparecer no cartão impresso.',
+ brief: { title: 'Manchete reformulada para caber', note: 'Nota reformulada curta.' },
+ numbers: [{ label: 'Saúde', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
+ sourceUrl: 'https://portaldatransparencia.test/brief',
+ sourceDate: '2026-09-15',
+ },
+ ],
+ news: Array.from({ length: 12 }, (_value, index) => ({
+ title: `Notícia ${index}`,
+ url: `https://jornal.test/${index}`,
+ publishedAt: '2024-06-01',
+ outlet: 'Jornal Local',
+ })),
+ gaps: [],
+ }),
+ ])
+ const briefReport = buildDossierReport({ snapshot, research: briefedResearch, generatedAt })
+ const html = renderDossierHtml(briefReport)
+ const md = renderDossierMd(briefReport)
+
+ it('prints the short brief instead of the full record', () => {
+ expect(html).toContain('Manchete reformulada para caber')
+ expect(html).toContain('Nota reformulada curta.')
+ expect(html).not.toContain('Resposta integral bem longa')
+ })
+
+ it('never truncates with an ellipsis', () => {
+ expect(html).not.toContain('…')
+ })
+
+ it('keeps the full record in the .md companion', () => {
+ expect(md).toContain('Resposta integral bem longa')
+ })
+
+ it('paginates long news lists into continuation sheets', () => {
+ expect(html).toContain('data-page="noticias"')
+ expect(html).toContain('data-page="noticias-2"')
+ })
+})
+
+describe('renderDossierHtml — era continuation sheets', () => {
+ const manyActions = mergeDossierResearch([
+ normalizeDossierResearchInput({
+ municipalitySlug: 'ilheus',
+ era: 'C',
+ researchedAt: '2026-09-16T10:00:00.000Z',
+ items: ['discursos', 'proposicoes', 'emendas', 'titulos', 'atuacao'].map((suffix, index) => ({
+ id: `era_c_${suffix}`,
+ answer: `Ação ${index}`,
+ brief: { title: `Ação curta ${index}`, note: `Nota ${index}` },
+ sourceUrl: `https://exemplo.test/acao-${index}`,
+ sourceDate: '2026-09-10',
+ })),
+ news: [],
+ gaps: [],
+ }),
+ ])
+ const report = buildDossierReport({ snapshot, research: manyActions, generatedAt })
+ const html = renderDossierHtml(report)
+
+ it('paginates era action cards into continuation sheets instead of dropping them', () => {
+ expect(html).toContain('data-page="era-c"')
+ expect(html).toContain('data-page="era-c-2"')
+ for (let index = 0; index < 5; index += 1) {
+ expect(html).toContain(`Ação curta ${index}`)
+ }
+ })
})
describe('renderDossierMd', () => {
diff --git a/tests/unit/dossieResearch.unit.spec.ts b/tests/unit/dossieResearch.unit.spec.ts
index 23a398fa..6a647da4 100644
--- a/tests/unit/dossieResearch.unit.spec.ts
+++ b/tests/unit/dossieResearch.unit.spec.ts
@@ -1,6 +1,8 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import {
+ DOSSIER_BRIEF_NOTE_MAX,
+ DOSSIER_BRIEF_TITLE_MAX,
DOSSIER_RESEARCH_CHECKLIST_IDS,
dossierChecklistForEra,
dossierResearchReceipt,
@@ -41,6 +43,41 @@ describe('normalizeDossierResearchInput', () => {
expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true)
})
+ it('normalizes the reformulated brief and drops it when it has no title', () => {
+ const research = normalizeDossierResearchInput(
+ eraResearch('C', {
+ items: [
+ validItem('era_c_emendas', { brief: { title: 'Manchete curta', note: 'Nota curta' } }),
+ validItem('era_c_discursos', { brief: { note: 'sem título' } }),
+ ],
+ }),
+ )
+ const emendas = research.items.find((item) => item.id === 'era_c_emendas')
+ const discursos = research.items.find((item) => item.id === 'era_c_discursos')
+ expect(emendas?.brief).toEqual({ title: 'Manchete curta', note: 'Nota curta' })
+ expect(discursos?.brief).toBeNull()
+ })
+
+ it('warns when a brief exceeds the print budget (kept, so the A4 guard catches it)', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ try {
+ const research = normalizeDossierResearchInput(
+ eraResearch('C', {
+ items: [
+ validItem('era_c_emendas', {
+ brief: { title: 'x'.repeat(DOSSIER_BRIEF_TITLE_MAX + 1) },
+ }),
+ ],
+ }),
+ )
+ expect(warn).toHaveBeenCalled()
+ expect(research.items[0].brief?.title.length).toBe(DOSSIER_BRIEF_TITLE_MAX + 1)
+ expect(DOSSIER_BRIEF_NOTE_MAX).toBeGreaterThan(0)
+ } finally {
+ warn.mockRestore()
+ }
+ })
+
it('throws when the slug, the date or the era is missing', () => {
expect(() => normalizeDossierResearchInput(eraResearch('A', { municipalitySlug: '' }))).toThrow(
/municipalitySlug/,