mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
refactor(C186): endurece o builder e enxuga o modelo do dossiê (review) (Related #1132)
This commit is contained in:
parent
264fbb8864
commit
23456bb81d
13 changed files with 192 additions and 211 deletions
|
|
@ -197,7 +197,7 @@ por item **com fonte** — o boletim não introduz fato novo.
|
|||
duas listas lado a lado (município ≠ região/polo); itens regionais com
|
||||
evidência de alcance; gancho e lacuna prioritária.
|
||||
- **Fontes e limites** — tabela de lacunas; notícias/documentos consultados
|
||||
(larguras fixas 9/14/49/28%); limites de cobertura; regras para uso editorial;
|
||||
(larguras fixas 12/13/47/28%); limites de cobertura; regras para uso editorial;
|
||||
nota de defeso eleitoral 2026.
|
||||
|
||||
## Conteúdo do Boletim modelo (1 página A4)
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
**C186 — Dossiê Solla por cidade + Boletim Informativo modelo (2026-09-17):** nova skill `/dossie-solla-cidade` (command + subagente researcher por era A/B/C) que reúne, com fonte e data, tudo o que Jorge Solla fez pela cidade/região ao longo da carreira e entrega dois artefatos internos por município: o **dossiê** (PDF A4 de 7 páginas + companion `.md` — capa/resumo/timeline/seções por era/região-polo/lacunas/fontes/defeso) e o **Boletim modelo** (1 página A4, linguagem de eleitor, sem fontes). Arquitetura: um único builder (`scripts/build-dossie-solla-cidade.mjs`) lê o snapshot read-only (mesmo extrator C163, serializado por cidade), a pesquisa por era (`dossieResearch.mjs`, checklist por era, esfera explícita `município|região|polo`, `phase` por valor) e as fontes oficiais em tempo de build (emendas 2015–2026 via Portal da Transparência, Câmara com janela ≤4 anos por limitação da API, contexto IBGE/SIDRA), monta o dossiê e deriva dele o ledger `bulletinFacts` — o boletim herda **apenas** fato com fonte, por construção. O contrato sólido de escape/`{{fonte}}` foi extraído de `cityReportRender.mjs` para o dono único `scripts/lib/reportText.mjs` (export aditivo equivalente em `cityReportResearch.mjs`; specs C163 seguem verdes). O builder emite os dois PDFs num Chromium só, cada um com sua guarda de fit (`data-page="resumo"` e `data-page="boletim"`), e cada era/cidade falha isolada (sucesso parcial). Sem schema, migration, escrita no DB, Consent ou URL pública — skills/scripts/testes apenas; PDF/MD/JSON gitignored (repo público). Novos specs: `dossieResearch`, `dossieBlocks`, `dossieRender`, `dossieBulletin`, `dossieCamara`, `dossieHealthData`, `dossieCareer`, `dossieBatchSkill` (+ `opencodeCommands` e `test-affected-core` atualizados). `pnpm gate:fast` verde (346 arquivos / 3607 testes). Issue: #1132.
|
||||
**C186 — Dossiê Solla por cidade + Boletim Informativo modelo (2026-09-17):** nova skill `/dossie-solla-cidade` (command + subagente researcher por era A/B/C) que reúne, com fonte e data, tudo o que Jorge Solla fez pela cidade/região ao longo da carreira e entrega dois artefatos internos por município: o **dossiê** (PDF A4 multi-página + companion `.md` — capa/trajetória/resumo/seções por era/região-polo/lacunas/fontes/defeso) e o **Boletim modelo** (1 página A4, linguagem de eleitor, sem fontes). Arquitetura: um único builder (`scripts/build-dossie-solla-cidade.mjs`) lê o snapshot read-only (mesmo extrator C163, serializado por cidade), a pesquisa por era (`dossieResearch.mjs`, checklist por era, esfera explícita `município|região|polo`, `phase` por valor) e as fontes oficiais em tempo de build (emendas 2015–2026 via Portal da Transparência, Câmara com janela ≤4 anos por limitação da API, contexto IBGE/SIDRA), monta o dossiê e deriva dele o ledger `bulletinFacts` — o boletim herda **apenas** fato com fonte, por construção. O contrato sólido de escape/`{{fonte}}` foi extraído de `cityReportRender.mjs` para o dono único `scripts/lib/reportText.mjs` (export aditivo equivalente em `cityReportResearch.mjs`; specs C163 seguem verdes). O builder emite os dois PDFs num Chromium só, cada um com sua guarda de fit (`data-page="resumo"` e `data-page="boletim"`), e cada era/cidade falha isolada (sucesso parcial). Sem schema, migration, escrita no DB, Consent ou URL pública — skills/scripts/testes apenas; PDF/MD/JSON gitignored (repo público). Novos specs: `dossieResearch`, `dossieBlocks`, `dossieRender`, `dossieBulletin`, `dossieCamara`, `dossieHealthData`, `dossieCareer`, `dossieBatchSkill` (+ `opencodeCommands` e `test-affected-core` atualizados). `pnpm gate:fast` verde (346 arquivos / 3607 testes). Issue: #1132.
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@ const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
|
|||
const A4_WIDTH_PX = Math.round(210 * MM_TO_PX)
|
||||
/** Rounding slack of the mm→px conversion; content must still fit the page. */
|
||||
const PAGE_FIT_TOLERANCE_PX = 10
|
||||
const DOSSIER_PAGE_ONE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
|
||||
const BOLETIM_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
|
||||
const PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
|
|
@ -80,21 +79,29 @@ if (!slug) die('Snapshot sem "municipality.slug" — extração inválida.')
|
|||
|
||||
const readEraResearch = async (era) => {
|
||||
const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`)
|
||||
const assertMatches = (research) => {
|
||||
if (research.municipalitySlug !== slug) {
|
||||
die(
|
||||
`Pesquisa da Era ${era} é de "${research.municipalitySlug}", não de "${slug}" (${path}) — pare e regenere.`,
|
||||
)
|
||||
}
|
||||
if (research.era !== era) {
|
||||
die(`Pesquisa em ${path} declara Era ${research.era}, não Era ${era} — pare e regenere.`)
|
||||
}
|
||||
return research
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
|
||||
return normalizeDossierResearchInput(raw, { now: generatedAt })
|
||||
return assertMatches(normalizeDossierResearchInput(raw))
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
|
||||
return normalizeDossierResearchInput(
|
||||
{
|
||||
municipalitySlug: slug,
|
||||
era,
|
||||
researchedAt: generatedAt.toISOString(),
|
||||
items: [],
|
||||
},
|
||||
{ now: generatedAt },
|
||||
)
|
||||
return normalizeDossierResearchInput({
|
||||
municipalitySlug: slug,
|
||||
era,
|
||||
researchedAt: generatedAt.toISOString(),
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
die(
|
||||
`Pesquisa da Era ${era} inválida (${path}): ${error instanceof Error ? error.message : error}`,
|
||||
|
|
@ -209,12 +216,12 @@ try {
|
|||
Boolean(document.querySelector('[data-page="resumo"]')),
|
||||
)
|
||||
if (!hasSummary) die('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
|
||||
const dossierOverflows = await measureOverflows(page, DOSSIER_PAGE_ONE_BUDGET_PX)
|
||||
const dossierOverflows = await measureOverflows(page, PAGE_BUDGET_PX)
|
||||
if (dossierOverflows.length > 0) {
|
||||
die(
|
||||
`Página(s) do dossiê estouraram o A4 (${dossierOverflows
|
||||
.map((row) => `${row.page}: ${row.height}px`)
|
||||
.join(', ')} > ${DOSSIER_PAGE_ONE_BUDGET_PX}px úteis). ` +
|
||||
.join(', ')} > ${PAGE_BUDGET_PX}px úteis). ` +
|
||||
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
|
||||
)
|
||||
}
|
||||
|
|
@ -231,12 +238,12 @@ try {
|
|||
Boolean(document.querySelector('[data-page="boletim"]')),
|
||||
)
|
||||
if (!hasBulletin) die('Bloco do boletim ausente no HTML — renderer quebrado.')
|
||||
const bulletinOverflows = await measureOverflows(page, BOLETIM_PAGE_BUDGET_PX)
|
||||
const bulletinOverflows = await measureOverflows(page, PAGE_BUDGET_PX)
|
||||
if (bulletinOverflows.length > 0) {
|
||||
die(
|
||||
`O boletim estourou (${bulletinOverflows
|
||||
.map((row) => `${row.height}px`)
|
||||
.join(', ')} > ${BOLETIM_PAGE_BUDGET_PX}px úteis). ` +
|
||||
.join(', ')} > ${PAGE_BUDGET_PX}px úteis). ` +
|
||||
'Corte itens/caps — o boletim TEM de caber em uma página.',
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
|
||||
import { formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
|
||||
import {
|
||||
CAREER_NOTES,
|
||||
CAREER_TIMELINE,
|
||||
DOSSIER_ERAS,
|
||||
DOSSIER_SCOPE,
|
||||
|
|
@ -25,7 +24,6 @@ const MAX_ERA_NUMBERS = 12
|
|||
const MAX_ERA_ACTIONS = 4
|
||||
const MAX_REGION_ITEMS = 4
|
||||
const MAX_SCOPE_LIST = 5
|
||||
const MAX_GAPS = 30
|
||||
|
||||
const sphereLabels = { municipio: 'município', regiao: 'região', polo: 'polo' }
|
||||
export const dossierSphereLabel = (sphere) => sphereLabels[sphere] ?? sphere
|
||||
|
|
@ -59,7 +57,7 @@ const emendaNumberRows = (emendas) => {
|
|||
if (!isPositive(row[key])) continue
|
||||
rows.push({
|
||||
object: row.functionName ?? row.type ?? 'Emenda',
|
||||
value: Number(row[key]),
|
||||
value: formatMoneyCompact(row[key]),
|
||||
year: row.year ? String(row.year) : null,
|
||||
phase,
|
||||
sphere: 'municipio',
|
||||
|
|
@ -109,16 +107,15 @@ const eraMethod = {
|
|||
C: 'Mandato, relatorias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
|
||||
}
|
||||
|
||||
const healthNumberRows = (health) =>
|
||||
(health?.status === 'ok' ? (health.items ?? []) : []).map((item) => ({
|
||||
object: item.topic,
|
||||
value: item.detail,
|
||||
year: item.year,
|
||||
phase: 'nao_informado',
|
||||
sphere: 'municipio',
|
||||
sourceUrl: item.sourceUrl,
|
||||
sourceDate: item.sourceDate,
|
||||
}))
|
||||
const healthContext = (health) =>
|
||||
health?.status === 'ok'
|
||||
? (health.items ?? []).map((item) => ({
|
||||
topic: item.topic,
|
||||
detail: item.detail,
|
||||
sourceUrl: item.sourceUrl,
|
||||
sourceDate: item.sourceDate,
|
||||
}))
|
||||
: []
|
||||
|
||||
const camaraItemsAsActions = (camara, era) =>
|
||||
(camara?.status === 'ok' ? (camara.items ?? []) : []).map((item) => ({
|
||||
|
|
@ -164,7 +161,6 @@ export const buildDossierReport = ({
|
|||
const regionalItems = items.filter((item) => item.sphere !== 'municipio')
|
||||
|
||||
const eraRows = emendaNumberRows(emendas)
|
||||
const healthRows = healthNumberRows(health)
|
||||
const camaraActions = camaraItemsAsActions(camara, 'C')
|
||||
|
||||
const researchActionsByEra = (era) =>
|
||||
|
|
@ -182,33 +178,23 @@ export const buildDossierReport = ({
|
|||
const eraSections = DOSSIER_ERAS.map((era) => {
|
||||
const ownActions = researchActionsByEra(era.id)
|
||||
const extraActions = era.id === 'C' ? camaraActions : []
|
||||
const numbers =
|
||||
const numbers = limit(
|
||||
era.id === 'C'
|
||||
? limit(
|
||||
[...emendaNumberRows(emendas), ...itemNumberRows(items.filter((i) => i.era === 'C'))],
|
||||
MAX_ERA_NUMBERS,
|
||||
)
|
||||
: limit(
|
||||
[
|
||||
...itemNumberRows(items.filter((i) => i.era === era.id)),
|
||||
...(era.id === 'B' ? healthRows : []),
|
||||
],
|
||||
MAX_ERA_NUMBERS,
|
||||
)
|
||||
? [...eraRows, ...itemNumberRows(items.filter((i) => i.era === 'C'))]
|
||||
: 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 remainingActions = ownActions.length + extraActions.length - actions.length
|
||||
if (numbers.length === 0 && actions.length === 0) return null
|
||||
return {
|
||||
...era,
|
||||
method: eraMethod[era.id],
|
||||
recovery: eraRecovery[era.id],
|
||||
numbers,
|
||||
remainingNumbers: 0,
|
||||
actions,
|
||||
remainingActions: Math.max(0, remainingActions),
|
||||
honors,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
|
@ -246,22 +232,18 @@ export const buildDossierReport = ({
|
|||
MAX_HOOKS_PAGE_ONE,
|
||||
)
|
||||
|
||||
const gaps = limit(
|
||||
(research.gaps ?? []).map((gap) => ({
|
||||
label: gap.label ?? gap.id,
|
||||
reason: gap.reason,
|
||||
nextStep: 'Apurar em fonte primária.',
|
||||
})),
|
||||
MAX_GAPS,
|
||||
)
|
||||
const gaps = (research.gaps ?? []).map((gap) => ({
|
||||
label: gap.label ?? gap.id,
|
||||
reason: gap.reason,
|
||||
nextStep: 'Apurar em fonte primária.',
|
||||
}))
|
||||
|
||||
const pending = limit(
|
||||
(research.gaps ?? []).map((gap) => gap.label ?? gap.id),
|
||||
MAX_PENDING_PAGE_ONE,
|
||||
)
|
||||
|
||||
const municipalNumbers = itemNumberRows(municipalItems)
|
||||
const regionalNumbers = itemNumberRows(regionalItems)
|
||||
const context = healthContext(health)
|
||||
const regionItems = limit(
|
||||
regionalItems.map((item) => ({
|
||||
item: item.answer,
|
||||
|
|
@ -298,7 +280,7 @@ export const buildDossierReport = ({
|
|||
area: 'Emendas',
|
||||
headline: row.object,
|
||||
detail: null,
|
||||
value: formatMoneyCompact(row.value),
|
||||
value: row.value,
|
||||
numberLabel: row.object,
|
||||
year: row.year,
|
||||
phase: row.phase,
|
||||
|
|
@ -307,27 +289,6 @@ export const buildDossierReport = ({
|
|||
})),
|
||||
]
|
||||
|
||||
const sources = []
|
||||
for (const item of items) {
|
||||
sources.push({ kind: 'web', label: item.label, date: item.sourceDate, url: item.sourceUrl })
|
||||
}
|
||||
for (const row of research.news ?? []) {
|
||||
sources.push({
|
||||
kind: 'web',
|
||||
label: row.outlet ? `${row.outlet}: ${row.title}` : row.title,
|
||||
date: row.publishedAt,
|
||||
url: row.url,
|
||||
})
|
||||
}
|
||||
if (emendas?.status === 'ok' && emendas.sourceUrl) {
|
||||
sources.push({
|
||||
kind: 'official',
|
||||
label: 'Portal da Transparência — emendas do autor',
|
||||
date: emendas.consultedAt,
|
||||
url: emendas.sourceUrl,
|
||||
})
|
||||
}
|
||||
|
||||
const generatedAtLabel = formatDateTimeBr(generatedAt)
|
||||
const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'
|
||||
|
||||
|
|
@ -339,8 +300,6 @@ export const buildDossierReport = ({
|
|||
generatedAt,
|
||||
generatedAtLabel,
|
||||
readAtLabel,
|
||||
codeSha: snapshot.meta?.codeSha ?? null,
|
||||
database: snapshot.meta?.database ?? null,
|
||||
},
|
||||
cover: {
|
||||
kicker: 'Comunicação · pesquisa documental',
|
||||
|
|
@ -371,17 +330,16 @@ export const buildDossierReport = ({
|
|||
'Um equipamento de referência ou uma política regional pode atender moradores do município sem constituir entrega exclusiva para ele. Atribuição só entra com alcance documentado.',
|
||||
municipal: {
|
||||
label: 'município',
|
||||
numbers: municipalNumbers,
|
||||
items: limit(municipalItems, MAX_SCOPE_LIST),
|
||||
total: municipalItems.length,
|
||||
},
|
||||
regional: {
|
||||
label: 'região / polo',
|
||||
numbers: regionalNumbers,
|
||||
items: limit(regionalItems, MAX_SCOPE_LIST),
|
||||
total: regionalItems.length,
|
||||
},
|
||||
items: regionItems,
|
||||
context,
|
||||
hook: hooks[0] ?? null,
|
||||
priorityGap: pending[0] ?? null,
|
||||
},
|
||||
|
|
@ -405,8 +363,6 @@ export const buildDossierReport = ({
|
|||
'Valor sempre informa a fase de execução.',
|
||||
],
|
||||
},
|
||||
notes: CAREER_NOTES,
|
||||
sources,
|
||||
bulletinFacts,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,28 +6,28 @@
|
|||
*/
|
||||
|
||||
import { getJsonWithBackoff, speechesUrl } from './camaraFetch.mjs'
|
||||
import { isNonEmptyString } from './cityReportResearch.mjs'
|
||||
import { SOLLA_DEPUTY_ID } from './dossieCareer.mjs'
|
||||
|
||||
const CAMARA_API = 'https://dadosabertos.camara.leg.br/api/v2'
|
||||
export const CAMARA_DEFAULT_FROM = '2015-01-01'
|
||||
const DEFAULT_MAX_PAGES = 2
|
||||
const PAGE_SIZE = 100
|
||||
const ITEMS_PER_LIST = 20
|
||||
|
||||
export const propositionsUrl = (deputyId, page) =>
|
||||
`${CAMARA_API}/proposicoes?idDeputadoAutor=${deputyId}&itens=100&ordem=DESC&ordenarPor=id&pagina=${page}`
|
||||
`${CAMARA_API}/proposicoes?idDeputadoAutor=${deputyId}&itens=${PAGE_SIZE}&ordem=DESC&ordenarPor=id&pagina=${page}`
|
||||
|
||||
const propositionSourceUrl = (id) =>
|
||||
const propositionSourceUrl = (id, deputyId) =>
|
||||
id
|
||||
? `https://www.camara.leg.br/proposicoesWeb/fichadetramitacao?idProposicao=${id}`
|
||||
: `https://www.camara.leg.br/deputados/${SOLLA_DEPUTY_ID}`
|
||||
: `https://www.camara.leg.br/deputados/${deputyId}`
|
||||
|
||||
const isNonEmpty = (value) => typeof value === 'string' && value.trim() !== ''
|
||||
|
||||
export const normalizeProposition = (row) => {
|
||||
const sigla = isNonEmpty(row?.siglaTipo) ? row.siglaTipo.trim() : null
|
||||
export const normalizeProposition = (row, deputyId = SOLLA_DEPUTY_ID) => {
|
||||
const sigla = isNonEmptyString(row?.siglaTipo) ? row.siglaTipo.trim() : null
|
||||
const number = row?.numero ?? null
|
||||
const year = row?.ano ?? null
|
||||
const ementa = isNonEmpty(row?.ementa) ? row.ementa.trim() : null
|
||||
const ementa = isNonEmptyString(row?.ementa) ? row.ementa.trim() : null
|
||||
if (!sigla && !ementa) return null
|
||||
const identifier = [sigla, number && year ? `${number}/${year}` : number ? String(number) : null]
|
||||
.filter(Boolean)
|
||||
|
|
@ -36,17 +36,17 @@ export const normalizeProposition = (row) => {
|
|||
title: identifier || 'Proposição de autoria do deputado',
|
||||
detail: ementa,
|
||||
year: year ? String(year) : null,
|
||||
sourceUrl: propositionSourceUrl(row?.id),
|
||||
sourceUrl: propositionSourceUrl(row?.id, deputyId),
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeSpeech = (row, deputyId) => {
|
||||
const sumario = isNonEmpty(row?.sumario) ? row.sumario.trim() : null
|
||||
const transcricao = isNonEmpty(row?.transcricao) ? row.transcricao.trim() : null
|
||||
const sumario = isNonEmptyString(row?.sumario) ? row.sumario.trim() : null
|
||||
const transcricao = isNonEmptyString(row?.transcricao) ? row.transcricao.trim() : null
|
||||
const detail = (sumario ?? transcricao ?? '').slice(0, 280) || null
|
||||
if (!detail && !isNonEmpty(row?.dataHoraInicio)) return null
|
||||
const date = isNonEmpty(row?.dataHoraInicio) ? row.dataHoraInicio.slice(0, 10) : null
|
||||
const tipo = isNonEmpty(row?.tipoDiscurso) ? row.tipoDiscurso.trim() : 'Pronunciamento'
|
||||
if (!detail && !isNonEmptyString(row?.dataHoraInicio)) return null
|
||||
const date = isNonEmptyString(row?.dataHoraInicio) ? row.dataHoraInicio.slice(0, 10) : null
|
||||
const tipo = isNonEmptyString(row?.tipoDiscurso) ? row.tipoDiscurso.trim() : 'Pronunciamento'
|
||||
return {
|
||||
title: `${tipo} em ${date ?? 'data não informada'}`,
|
||||
detail,
|
||||
|
|
@ -109,8 +109,8 @@ export const fetchCamaraActivity = async ({
|
|||
fetchImpl,
|
||||
label,
|
||||
})
|
||||
propositions.push(...rows.map(normalizeProposition).filter(Boolean))
|
||||
if (rows.length < 100) break
|
||||
propositions.push(...rows.map((row) => normalizeProposition(row, deputyId)).filter(Boolean))
|
||||
if (rows.length < PAGE_SIZE) break
|
||||
}
|
||||
for (const window of speechWindows(from, to)) {
|
||||
if (speeches.length >= ITEMS_PER_LIST) break
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* cannot drift.
|
||||
*/
|
||||
|
||||
export const SOLLA_DEPUTY_ID = 178857
|
||||
export { SOLLA_DEPUTY_ID } from './camaraSpeeches.mjs'
|
||||
export const SOLLA_BIRTH_DATE = '1961-04-11'
|
||||
export const SOLLA_BIRTH_PLACE = 'Salvador-BA'
|
||||
export const SOLLA_OFFICIAL_SITE = 'https://www.camara.leg.br/deputados/178857'
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ const assetBox = (title, lines) =>
|
|||
.map((line) => htmlEscape(line))
|
||||
.join('<br />')}</p></div>`
|
||||
|
||||
const pageCount = (report) => report.meta.pageTotal ?? 5 + report.eras.length
|
||||
/** cover + trajetória + resumo + região + fontes (eras and gap sheets added separately). */
|
||||
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
|
||||
|
|
@ -334,6 +336,16 @@ const renderRegion = (report, pageNo) => `
|
|||
</div>
|
||||
</section>
|
||||
|
||||
${
|
||||
report.region.context.length
|
||||
? `<p class="meta">Contexto municipal (IBGE): ${report.region.context
|
||||
.map(
|
||||
(item) => `${htmlEscape(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
|
||||
)
|
||||
.join(' · ')} — leitura relativa, nunca % estadual absoluto.</p>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${renderScopeLists(report)}
|
||||
|
||||
${
|
||||
|
|
@ -566,7 +578,7 @@ const PRINT_CSS = `
|
|||
|
||||
export const renderDossierHtml = (report) => {
|
||||
const gapChunks = chunkGaps(report.gaps)
|
||||
report.meta.pageTotal = 5 + report.eras.length + gapChunks.length
|
||||
report.meta.pageTotal = SHEET_COUNT_BASE + report.eras.length + gapChunks.length
|
||||
let page = 0
|
||||
const parts = []
|
||||
parts.push(renderCover(report))
|
||||
|
|
|
|||
|
|
@ -120,7 +120,10 @@ const normalizeNumbers = (entry) => {
|
|||
const numbers = []
|
||||
for (const number of Array.isArray(entry?.numbers) ? entry.numbers : []) {
|
||||
const label = isNonEmptyString(number?.label) ? number.label.trim() : null
|
||||
const value = isNonEmptyString(number?.value) ? number.value.trim() : null
|
||||
const value =
|
||||
number?.value === null || number?.value === undefined
|
||||
? null
|
||||
: String(number.value).trim() || null
|
||||
if (!label || !value) continue
|
||||
numbers.push({
|
||||
label,
|
||||
|
|
@ -139,7 +142,7 @@ const normalizeNumbers = (entry) => {
|
|||
* operator fixes the file. Content failures (missing source, unknown item, item
|
||||
* from another era) degrade to explicit gaps.
|
||||
*/
|
||||
export const normalizeDossierResearchInput = (raw, { now = new Date() } = {}) => {
|
||||
export const normalizeDossierResearchInput = (raw) => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Pesquisa do dossiê inválida: o arquivo precisa ser um objeto JSON.')
|
||||
}
|
||||
|
|
@ -157,7 +160,6 @@ export const normalizeDossierResearchInput = (raw, { now = new Date() } = {}) =>
|
|||
}
|
||||
|
||||
const researchedAt = new Date(raw.researchedAt)
|
||||
const reference = now instanceof Date ? now : new Date(now)
|
||||
const gaps = []
|
||||
const items = []
|
||||
const seen = new Set()
|
||||
|
|
@ -266,7 +268,6 @@ export const normalizeDossierResearchInput = (raw, { now = new Date() } = {}) =>
|
|||
municipalitySlug: raw.municipalitySlug.trim(),
|
||||
era,
|
||||
researchedAt: researchedAt.toISOString(),
|
||||
reference,
|
||||
items,
|
||||
news,
|
||||
gaps,
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({
|
|||
})
|
||||
|
||||
const research = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista', { number: undefined })]), {
|
||||
now: generatedAt,
|
||||
}),
|
||||
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('B', [
|
||||
item('era_b_sesab', {
|
||||
|
|
@ -42,7 +40,6 @@ const research = mergeDossierResearch([
|
|||
}),
|
||||
item('era_b_obras', { sphere: 'regiao', answer: 'Hospital de referência regional' }),
|
||||
]),
|
||||
{ now: generatedAt },
|
||||
),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('C', [
|
||||
|
|
@ -51,7 +48,6 @@ const research = mergeDossierResearch([
|
|||
}),
|
||||
item('era_c_titulos', { answer: 'Cidadão Ilheense' }),
|
||||
]),
|
||||
{ now: generatedAt },
|
||||
),
|
||||
])
|
||||
|
||||
|
|
@ -116,14 +112,13 @@ describe('buildDossierReport', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('includes the IBGE baseline as an Era B number', () => {
|
||||
const eraB = build().eras.find((era) => era?.id === 'B')
|
||||
expect(eraB?.numbers).toContainEqual(
|
||||
expect.objectContaining({
|
||||
object: 'População residente (Censo 2022)',
|
||||
phase: 'nao_informado',
|
||||
}),
|
||||
it('surfaces the IBGE baseline as municipal context, not as an era number', () => {
|
||||
const report = build()
|
||||
expect(report.region.context).toContainEqual(
|
||||
expect.objectContaining({ topic: 'População residente (Censo 2022)' }),
|
||||
)
|
||||
const eraB = report.eras.find((era) => era?.id === 'B')
|
||||
expect(eraB?.numbers.some((row) => row.object.includes('População'))).toBe(false)
|
||||
})
|
||||
|
||||
it('never sums região/polo into the município list', () => {
|
||||
|
|
@ -142,9 +137,9 @@ describe('buildDossierReport', () => {
|
|||
|
||||
it('omits an era that has no number and no action', () => {
|
||||
const empty = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(eraFile('A', []), { now: generatedAt }),
|
||||
normalizeDossierResearchInput(eraFile('B', []), { now: generatedAt }),
|
||||
normalizeDossierResearchInput(eraFile('C', []), { now: generatedAt }),
|
||||
normalizeDossierResearchInput(eraFile('A', [])),
|
||||
normalizeDossierResearchInput(eraFile('B', [])),
|
||||
normalizeDossierResearchInput(eraFile('C', [])),
|
||||
])
|
||||
const report = buildDossierReport({
|
||||
snapshot,
|
||||
|
|
|
|||
|
|
@ -57,11 +57,14 @@ describe('buildBulletin', () => {
|
|||
expect(bulletin.timeline.length).toBe(4)
|
||||
})
|
||||
|
||||
it('never drops the fact source from the ledger (boletim can only inherit sourced facts)', () => {
|
||||
it('maps every highlight and "e mais" item to a ledger fact (never invents)', () => {
|
||||
const facts = Array.from({ length: 25 }, (_value, index) => fact(index))
|
||||
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
|
||||
const chosen = bulletin.highlights.length + bulletin.moreItems.length
|
||||
expect(chosen).toBe(BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT)
|
||||
expect(facts.every((row) => Boolean(row.sourceUrl))).toBe(true)
|
||||
const headlines = new Set(facts.map((row) => row.headline))
|
||||
expect(bulletin.highlights.length + bulletin.moreItems.length).toBe(
|
||||
BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT,
|
||||
)
|
||||
expect(bulletin.highlights.every((row) => headlines.has(row.title))).toBe(true)
|
||||
expect(bulletin.moreItems.every((row) => headlines.has(row.label))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,70 +16,61 @@ const snapshot = {
|
|||
}
|
||||
|
||||
const research = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(
|
||||
{
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'A',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'era_a_conquista',
|
||||
answer: 'Gestão da saúde em Vitória da Conquista',
|
||||
details: 'Detalhe <script>alert(1)</script> com {{fonte}}.',
|
||||
sourceUrl: 'https://exemplo.test/conquista',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
news: [],
|
||||
gaps: [],
|
||||
},
|
||||
{ now: generatedAt },
|
||||
),
|
||||
normalizeDossierResearchInput(
|
||||
{
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'B',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'era_b_equipamentos',
|
||||
answer: '1 hospital regional',
|
||||
sphere: 'polo',
|
||||
sourceUrl: 'https://saude.test/hospital',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
news: [],
|
||||
gaps: [],
|
||||
},
|
||||
{ now: generatedAt },
|
||||
),
|
||||
normalizeDossierResearchInput(
|
||||
{
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'C',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'era_c_emendas',
|
||||
answer: 'R$ 1,2 milhão para equipamentos',
|
||||
numbers: [{ label: 'Saúde', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
|
||||
sourceUrl: 'https://portaldatransparencia.test/emendas',
|
||||
sourceDate: '2026-09-15',
|
||||
},
|
||||
],
|
||||
news: [
|
||||
{
|
||||
title: 'Matéria local',
|
||||
url: 'https://jornal.test/materia',
|
||||
publishedAt: '2024-06-01',
|
||||
outlet: 'Jornal Local',
|
||||
},
|
||||
],
|
||||
gaps: [],
|
||||
},
|
||||
{ now: generatedAt },
|
||||
),
|
||||
normalizeDossierResearchInput({
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'A',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'era_a_conquista',
|
||||
answer: 'Gestão da saúde em Vitória da Conquista',
|
||||
details: 'Detalhe <script>alert(1)</script> com {{fonte}}.',
|
||||
sourceUrl: 'https://exemplo.test/conquista',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
news: [],
|
||||
gaps: [],
|
||||
}),
|
||||
normalizeDossierResearchInput({
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'B',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'era_b_equipamentos',
|
||||
answer: '1 hospital regional',
|
||||
sphere: 'polo',
|
||||
sourceUrl: 'https://saude.test/hospital',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
news: [],
|
||||
gaps: [],
|
||||
}),
|
||||
normalizeDossierResearchInput({
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'C',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'era_c_emendas',
|
||||
answer: 'R$ 1,2 milhão para equipamentos',
|
||||
numbers: [{ label: 'Saúde', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
|
||||
sourceUrl: 'https://portaldatransparencia.test/emendas',
|
||||
sourceDate: '2026-09-15',
|
||||
},
|
||||
],
|
||||
news: [
|
||||
{
|
||||
title: 'Matéria local',
|
||||
url: 'https://jornal.test/materia',
|
||||
publishedAt: '2024-06-01',
|
||||
outlet: 'Jornal Local',
|
||||
},
|
||||
],
|
||||
gaps: [],
|
||||
}),
|
||||
])
|
||||
|
||||
const report = buildDossierReport({
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import {
|
|||
normalizeDossierResearchInput,
|
||||
} from '../../scripts/lib/dossieResearch.mjs'
|
||||
|
||||
const now = new Date('2026-09-17T12:00:00.000Z')
|
||||
|
||||
const validItem = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
answer: `Resposta de ${id}`,
|
||||
|
|
@ -37,28 +35,26 @@ describe('normalizeDossierResearchInput', () => {
|
|||
})
|
||||
|
||||
it('keeps fully sourced items and defaults the sphere to município', () => {
|
||||
const research = normalizeDossierResearchInput(eraResearch('C'), { now })
|
||||
const research = normalizeDossierResearchInput(eraResearch('C'))
|
||||
expect(research.items).toHaveLength(dossierChecklistForEra('C').length)
|
||||
expect(research.gaps).toEqual([])
|
||||
expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true)
|
||||
})
|
||||
|
||||
it('throws when the slug, the date or the era is missing', () => {
|
||||
expect(() =>
|
||||
normalizeDossierResearchInput(eraResearch('A', { municipalitySlug: '' }), { now }),
|
||||
).toThrow(/municipalitySlug/)
|
||||
expect(() =>
|
||||
normalizeDossierResearchInput(eraResearch('A', { researchedAt: null }), { now }),
|
||||
).toThrow(/researchedAt/)
|
||||
expect(() => normalizeDossierResearchInput(eraResearch('A', { era: 'Z' }), { now })).toThrow(
|
||||
/era/,
|
||||
expect(() => normalizeDossierResearchInput(eraResearch('A', { municipalitySlug: '' }))).toThrow(
|
||||
/municipalitySlug/,
|
||||
)
|
||||
expect(() => normalizeDossierResearchInput(eraResearch('A', { researchedAt: null }))).toThrow(
|
||||
/researchedAt/,
|
||||
)
|
||||
expect(() => normalizeDossierResearchInput(eraResearch('A', { era: 'Z' }))).toThrow(/era/)
|
||||
})
|
||||
|
||||
it('degrades an unsourced item to an explicit gap', () => {
|
||||
const items = dossierChecklistForEra('A').map((item) => validItem(item.id))
|
||||
items[0] = validItem(items[0].id, { sourceUrl: null })
|
||||
const research = normalizeDossierResearchInput(eraResearch('A', { items }), { now })
|
||||
const research = normalizeDossierResearchInput(eraResearch('A', { items }))
|
||||
expect(research.gaps).toContainEqual(
|
||||
expect.objectContaining({ id: items[0].id, reason: expect.stringMatching(/Sem fonte/) }),
|
||||
)
|
||||
|
|
@ -68,7 +64,6 @@ describe('normalizeDossierResearchInput', () => {
|
|||
it('rejects an item that belongs to another era', () => {
|
||||
const research = normalizeDossierResearchInput(
|
||||
eraResearch('A', { items: [...eraResearch('A').items, validItem('era_c_emendas')] }),
|
||||
{ now },
|
||||
)
|
||||
expect(research.gaps).toContainEqual(
|
||||
expect.objectContaining({ id: 'era_c_emendas', reason: expect.stringMatching(/Era C/) }),
|
||||
|
|
@ -76,7 +71,7 @@ describe('normalizeDossierResearchInput', () => {
|
|||
})
|
||||
|
||||
it('marks unreached checklist items as gaps', () => {
|
||||
const research = normalizeDossierResearchInput(eraResearch('B', { items: [] }), { now })
|
||||
const research = normalizeDossierResearchInput(eraResearch('B', { items: [] }))
|
||||
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(
|
||||
dossierChecklistForEra('B').length,
|
||||
)
|
||||
|
|
@ -90,7 +85,7 @@ describe('normalizeDossierResearchInput', () => {
|
|||
{ label: 'Sem fase', value: 'R$ 10 mil' },
|
||||
],
|
||||
})
|
||||
const research = normalizeDossierResearchInput(eraResearch('C', { items }), { now })
|
||||
const research = normalizeDossierResearchInput(eraResearch('C', { items }))
|
||||
const item = research.items.find((row) => row.id === items[0].id)
|
||||
expect(item?.numbers).toHaveLength(2)
|
||||
expect(item?.numbers[0]).toMatchObject({ label: 'Recurso', phase: 'empenhado' })
|
||||
|
|
@ -100,7 +95,7 @@ describe('normalizeDossierResearchInput', () => {
|
|||
it('flags an invalid sphere as a gap instead of silently summing it', () => {
|
||||
const items = dossierChecklistForEra('A').map((item) => validItem(item.id))
|
||||
items[0] = validItem(items[0].id, { sphere: 'estado' })
|
||||
const research = normalizeDossierResearchInput(eraResearch('A', { items }), { now })
|
||||
const research = normalizeDossierResearchInput(eraResearch('A', { items }))
|
||||
expect(research.gaps).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: items[0].id,
|
||||
|
|
@ -110,7 +105,7 @@ describe('normalizeDossierResearchInput', () => {
|
|||
})
|
||||
|
||||
it('emits the short receipt with the era file path', () => {
|
||||
const receipt = dossierResearchReceipt(normalizeDossierResearchInput(eraResearch('B'), { now }))
|
||||
const receipt = dossierResearchReceipt(normalizeDossierResearchInput(eraResearch('B')))
|
||||
expect(receipt).toMatchObject({ slug: 'ilheus', era: 'B', status: 'ok' })
|
||||
expect(receipt.researchPath).toContain('ilheus.b.research.json')
|
||||
})
|
||||
|
|
@ -120,7 +115,7 @@ describe('mergeDossierResearch', () => {
|
|||
it('merges the three eras ordered A→B→C', () => {
|
||||
const merged = mergeDossierResearch(
|
||||
['A', 'B', 'C'].map((era) =>
|
||||
normalizeDossierResearchInput(eraResearch(era as 'A' | 'B' | 'C'), { now }),
|
||||
normalizeDossierResearchInput(eraResearch(era as 'A' | 'B' | 'C')),
|
||||
),
|
||||
)
|
||||
expect(merged.eras).toEqual(['A', 'B', 'C'])
|
||||
|
|
@ -129,8 +124,8 @@ describe('mergeDossierResearch', () => {
|
|||
})
|
||||
|
||||
it('fails closed when the slugs disagree', () => {
|
||||
const a = normalizeDossierResearchInput(eraResearch('A'), { now })
|
||||
const b = normalizeDossierResearchInput(eraResearch('B', { municipalitySlug: 'una' }), { now })
|
||||
const a = normalizeDossierResearchInput(eraResearch('A'))
|
||||
const b = normalizeDossierResearchInput(eraResearch('B', { municipalitySlug: 'una' }))
|
||||
expect(() => mergeDossierResearch([a, b])).toThrow(/municípios diferentes/)
|
||||
})
|
||||
|
||||
|
|
|
|||
21
tests/unit/reportText.unit.spec.ts
Normal file
21
tests/unit/reportText.unit.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { stripInlineSources } from '../../scripts/lib/reportText.mjs'
|
||||
|
||||
describe('stripInlineSources', () => {
|
||||
it('drops a trailing token and the stray space before punctuation', () => {
|
||||
expect(stripInlineSources('Pagamento pendente de confirmação {{fonte}}.')).toBe(
|
||||
'Pagamento pendente de confirmação.',
|
||||
)
|
||||
})
|
||||
|
||||
it('drops indexed tokens and collapses the leftover spaces', () => {
|
||||
expect(stripInlineSources('Fato {{fonte:2}} e outro {{fonte:3}} aqui.')).toBe(
|
||||
'Fato e outro aqui.',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves text without tokens untouched', () => {
|
||||
expect(stripInlineSources('Sem marcador de fonte.')).toBe('Sem marcador de fonte.')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue