diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md
index 535d1a8c..1ab3091b 100644
--- a/.agents/skills/dossie-solla-cidade/SKILL.md
+++ b/.agents/skills/dossie-solla-cidade/SKILL.md
@@ -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)
diff --git a/docs/changelog/2026-09-17-c186.md b/docs/changelog/2026-09-17-c186.md
index f3738d9a..7fa79eb8 100644
--- a/docs/changelog/2026-09-17-c186.md
+++ b/docs/changelog/2026-09-17-c186.md
@@ -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.
diff --git a/scripts/build-dossie-solla-cidade.mjs b/scripts/build-dossie-solla-cidade.mjs
index 132f51be..3bfe3917 100644
--- a/scripts/build-dossie-solla-cidade.mjs
+++ b/scripts/build-dossie-solla-cidade.mjs
@@ -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.',
)
}
diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs
index 10ce7ad8..86cdc5d7 100644
--- a/scripts/lib/dossieBlocks.mjs
+++ b/scripts/lib/dossieBlocks.mjs
@@ -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,
}
}
diff --git a/scripts/lib/dossieCamara.mjs b/scripts/lib/dossieCamara.mjs
index 1516afa6..0418d591 100644
--- a/scripts/lib/dossieCamara.mjs
+++ b/scripts/lib/dossieCamara.mjs
@@ -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
diff --git a/scripts/lib/dossieCareer.mjs b/scripts/lib/dossieCareer.mjs
index 0abb6851..85420f07 100644
--- a/scripts/lib/dossieCareer.mjs
+++ b/scripts/lib/dossieCareer.mjs
@@ -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'
diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs
index 1a93b2b2..dbcbee39 100644
--- a/scripts/lib/dossieRender.mjs
+++ b/scripts/lib/dossieRender.mjs
@@ -48,7 +48,9 @@ const assetBox = (title, lines) =>
.map((line) => htmlEscape(line))
.join('
')}