fix(C186): dossiê/boletim sem cortes — copy reformulada (brief) + paginação (Related #1132)

O builder cortava o texto no meio com "…" quando não cabia no A4, perdendo
informação. Agora:

- contrato de pesquisa ganha `brief` (`title` ≤80 / `note` ≤120) reescrito pelo
  researcher: o PDF imprime o brief reformulado e o registro/.md mantém
  `answer`/`details` integrais (sem fonte nova, sem trocar a fase);
- sem `brief` o renderer cai no texto completo e a guarda de fit A4 falha
  fechado — nunca mais corta em silêncio;
- tabelas longas (lacunas, notícias) e os cards de "Atuação registrada" das eras
  são paginados em folhas de continuação: nada é truncado nem descartado;
- corrige honrarias da Era C vazando para a página da Era B;
- remove o `clipCopy`/`MAX_NEWS_ROWS`; SKILL.md e agent doc documentam o `brief`.
This commit is contained in:
Francisco Solla 2026-09-17 18:01:44 -03:00
parent ef87c68a18
commit f658d5eaaa
9 changed files with 341 additions and 75 deletions

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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 {

View file

@ -21,7 +21,7 @@ const renderHighlight = (highlight) => `
</article>`
const renderMoreItem = (item) =>
`<p class="more-item"><strong>${htmlEscape(item.label)}:</strong> ${htmlEscape(item.detail)}</p>`
`<p class="more-item"><strong>${htmlEscape(item.label)}</strong></p>`
const PRINT_CSS = `
* { box-sizing: border-box; }

View file

@ -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) => `<span class="phase">${htmlEscape(dossierPhaseLabel(phase))}</span>`
@ -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) => `
<div class="timeline-grid">
${rows
@ -165,8 +204,8 @@ const renderDeliveries = (report) => {
(row) => `<div class="delivery-row">
<div class="delivery-scope">${scopeBadge(row.sphere)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
<div>
<p class="delivery-title">${cleanText(row.title)}</p>
${row.detail ? `<p class="muted small">${cleanText(row.detail)}</p>` : ''}
<p class="delivery-title">${copyHtml(briefOr(row.brief, row.title))}</p>
${noteOr(row.brief, row.detail) ? `<p class="muted small">${copyHtml(noteOr(row.brief, row.detail))}</p>` : ''}
</div>
<div class="delivery-value">
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
@ -194,7 +233,7 @@ const renderHooksAndPending = (report) => {
<ul class="tight-list">${hooks
.map(
(hook) =>
`<li><strong>Tema:</strong> ${htmlEscape(hook.topic)}. <strong>Ângulo:</strong> ${cleanText(hook.angle)} ${sourceLink(hook.sourceUrl)}</li>`,
`<li><strong>Tema:</strong> ${htmlEscape(hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
)
.join('')}</ul>
</div>`
@ -221,11 +260,34 @@ const renderSummary = (report, pageNo) => `
${sheetFooter(report, 'Resumo', pageNo)}
</article>`
const renderEra = (report, era, pageNo) => `
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
${sheetHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)}
const renderActionGrid = (actions) => `
<div class="action-grid">
${actions
.map(
(action) => `<article class="panel">
<div class="action-head">${scopeBadge(action.sphere)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
<h4 class="action-title">${copyHtml(briefOr(action.brief, action.title))}</h4>
${noteOr(action.brief, action.detail) ? `<p class="muted small">${copyHtml(noteOr(action.brief, action.detail))}</p>` : ''}
${sourceLink(action.sourceUrl)}
</article>`,
)
.join('')}
</div>`
<div class="era-method">
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 `
<article class="sheet" data-page="${anchor}" aria-label="${htmlEscape(era.label)}">
${sheetHeader(report, era.label, subtitle, pageNo)}
${
first
? `<div class="era-method">
<div>
<p class="eyebrow">Recorte e método</p>
<p class="muted">${htmlEscape(era.method)}</p>
@ -256,39 +318,32 @@ const renderEra = (report, era, pageNo) => `
)
.join('')}</tbody>
</table>
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
</section>`
: ''
}`
: ''
}
${
era.actions.length
actions.length
? `<section class="block">
<p class="eyebrow">Atuação registrada</p>
<h3 class="section-title">O que fez — item, alcance e lastro</h3>
<div class="action-grid">
${era.actions
.map(
(action) => `<article class="panel">
<div class="action-head">${scopeBadge(action.sphere)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
<h4 class="action-title">${cleanText(action.title)}</h4>
${action.detail ? `<p class="muted small">${cleanText(action.detail)}</p>` : ''}
${sourceLink(action.sourceUrl)}
</article>`,
)
.join('')}
</div>
${renderActionGrid(actions)}
</section>`
: ''
}
${
era.honors.length
first && era.honors.length
? `<section class="block honors">
<div class="panel">
<p class="eyebrow">Títulos, honrarias e vínculos locais</p>
<ul class="tight-list">${era.honors
.map((honor) => `<li>${cleanText(honor.text)} ${sourceLink(honor.sourceUrl)}</li>`)
.map(
(honor) =>
`<li>${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}</li>`,
)
.join('')}</ul>
</div>
${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)}
</article>`
}
const renderScopeLists = (report) => `
<section class="block">
@ -307,7 +363,10 @@ const renderScopeLists = (report) => `
<div class="panel">
<div class="action-head">${scopeBadge('municipio')}<span class="meta strong">${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte</span></div>
<ul class="tight-list">${report.region.municipal.items
.map((item) => `<li>${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}</li>`)
.map(
(item) =>
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
)
.join(
'',
)}${report.region.municipal.items.length === 0 ? '<li class="muted">Nenhum item municipal com fonte.</li>' : ''}</ul>
@ -316,7 +375,10 @@ const renderScopeLists = (report) => `
<div class="panel panel-region">
<div class="action-head">${scopeBadge('regiao', 'região / polo')}<span class="warning-text meta strong">SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}</span></div>
<ul class="tight-list">${report.region.regional.items
.map((item) => `<li>${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}</li>`)
.map(
(item) =>
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
)
.join(
'',
)}${report.region.regional.items.length === 0 ? '<li class="muted">Nenhum item regional com fonte.</li>' : ''}</ul>
@ -340,7 +402,7 @@ const renderRegion = (report, pageNo) => `
report.region.context.length
? `<p class="meta">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.</p>`
: ''
@ -359,9 +421,9 @@ const renderRegion = (report, pageNo) => `
<tbody>${report.region.items
.map(
(item) => `<tr>
<td>${cleanText(item.item)}</td>
<td>${copyHtml(briefOr(item.brief, item.item))}</td>
<td>${scopeBadge(item.sphere)}</td>
<td>${cleanText(item.evidence)}</td>
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
<td>${sourceLink(item.sourceUrl)}</td>
</tr>`,
)
@ -376,7 +438,7 @@ const renderRegion = (report, pageNo) => `
? `<section class="page1-grid">
${
report.region.hook
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>Tema:</strong> ${htmlEscape(report.region.hook.topic)}. <strong>Ângulo:</strong> ${cleanText(report.region.hook.angle)} ${sourceLink(report.region.hook.sourceUrl)}</p></div>`
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>Tema:</strong> ${htmlEscape(report.region.hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(report.region.hook.brief, report.region.hook.angle))} ${sourceLink(report.region.hook.sourceUrl)}</p></div>`
: '<div></div>'
}
${
@ -420,34 +482,35 @@ const renderGapsPage = (report, chunk, index, pageNo) => {
</article>`
}
const renderNewsPage = (report, chunk, index, pageNo) => `
<article class="sheet" data-page="noticias${index > 0 ? `-${index + 1}` : ''}" aria-label="Notícias e documentos consultados">
${sheetHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
<section class="block">
<p class="eyebrow">Lista clicável</p>
<h3 class="section-title">Notícias e documentos consultados</h3>
<table class="document-table">
<caption class="sr-only">Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%</caption>
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
<tbody>${chunk
.map(
(row) => `<tr>
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
<td>${htmlEscape(row.outlet)}</td>
<td>${htmlEscape(row.title)}</td>
<td>${sourceLink(row.url, '(fonte)')}</td>
</tr>`,
)
.join('')}</tbody>
</table>
</section>
${sheetFooter(report, 'Notícias e documentos consultados · INSUMO INTERNO', pageNo)}
</article>`
const renderSourcesPage = (report, pageNo) => `
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê">
${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
${
report.news.length
? `<section class="block">
<p class="eyebrow">Lista clicável</p>
<h3 class="section-title">Notícias e documentos consultados</h3>
<table class="document-table">
<caption class="sr-only">Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%</caption>
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
<tbody>${report.news
.map(
(row) => `<tr>
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
<td>${htmlEscape(row.outlet)}</td>
<td>${htmlEscape(row.title)}</td>
<td>${sourceLink(row.url, '(fonte)')}</td>
</tr>`,
)
.join('')}</tbody>
</table>
</section>`
: ''
}
<section class="page1-grid">
<div class="panel"><p class="eyebrow">Limites de cobertura</p><ul class="tight-list">${report.limits.coverage
.map((item) => `<li>${htmlEscape(item)}</li>`)
@ -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 `<!doctype html>

View file

@ -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,

View file

@ -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', () => {

View file

@ -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/,