fix(C163): emendas por localidade, fontes compactas na página 1 e datas pt-BR (Related #1025)

This commit is contained in:
Francisco Solla 2026-09-15 08:34:23 -03:00
parent ab9c164615
commit 85de335cc3
10 changed files with 181 additions and 44 deletions

View file

@ -0,0 +1 @@
**FIX — C163 relatório de cidade: emendas por município, página 1 e datas (2026-09-15, Related #1025):** quatro defeitos encontrados na primeira geração real (Itamaraju), todos no caminho de leitura/render do relatório, nenhum toca a base. **(1) Emendas oficiais mentiam:** o endpoint `/api-de-dados/emendas` não tem filtro por município (swagger: `codigoEmenda, numeroEmenda, nomeAutor, tipoEmenda, ano, codigoFuncao, codigoSubfuncao, pagina`; `codigoMunicipio` é ignorado) e devolve valores como string pt-BR (`"81.000,00"`), que o `Number()` do módulo zerava. A extração real trouxe as **41 emendas 2023–2026 do autor** (localidade UF/Nacional/Múltiplo) com totais 0 — zero silencioso proibido. Fix: parse pt-BR, filtro por `localidadeDoGasto` casando com o `municipalityName` do snapshot (novo parâmetro do builder) e fail-closed → `status: 'gap'` com URL + motivo quando nenhuma linha é atribuível ao município. **(2) Página 1 estourava com pesquisa real:** a fonte por linha imprimia a URL completa em coluna de ~44mm e a guarda de overflow abortava até com respostas mínimas. Fix: fonte compacta na página 1 (`pesquisa web · data`; URL completa preservada em "Fontes e limites", seção 10) e a dica do KPI de emendas em gap deixou de repetir o motivo longo. **(3) Gap duplicado:** item do checklist declarado como gap pelo agente também virava "Não pesquisado."; agora o gap explícito marca o item como coberto. **(4) Data com um dia a menos:** `sourceDate` "AAAA-MM-DD" era formatado em America/Bahia a partir da meia-noite UTC (06/10 → 05/10); `formatDateBr` mantém a data de calendário para strings date-only. Regressões pino em `tests/unit/{portalTransparenciaEmendas,cityReportBlocks,cityReportResearch,cityReportFormat}.unit.spec.ts`. Verificação: unit 3108/3108, `tsc --noEmit` e eslint verdes. Issue #1025 registra a limitação da fonte e a decisão em aberto (fonte oficial por município × lacuna documentada); runbook/skill ainda prometem o filtro `codigoMunicipio` — atualizar quando a Issue fechar.

View file

@ -116,6 +116,7 @@ const resolveEmendas = async () => {
const result = await fetchAuthorEmendas({
years: EMENDAS_YEARS,
municipalityCode: snapshot.municipality.ibgeCode,
municipalityName: snapshot.municipality.name,
authorName,
apiKey: process.env.PORTAL_TRANSPARENCIA_API_KEY ?? null,
consultedAt: generatedAt.toISOString(),

View file

@ -63,12 +63,18 @@ const sourceOfficial = (label, url, date) => ({
date: date ?? null,
})
/**
* Página 1 = resumo de uma olhada: a fonte do item entra compacta (pesquisa web
* + data). A URL completa de cada item é preservada em "Fontes e limites"
* (seção 10) — imprimir a URL por linha estoura a página 1 com a pesquisa real
* (ver guarda de overflow do builder).
*/
const researchAnswerItem = (research, id) => {
const item = researchItemById(research, id)
if (!item) return null
return {
text: item.answer,
source: sourceWeb('Pesquisa web', item.sourceUrl, item.sourceDate),
source: sourceWeb(null, null, item.sourceDate),
}
}
@ -173,7 +179,9 @@ const buildDeliveredKpis = ({ speeches }, research, emendas) => {
value: emendasOk ? formatMoneyCompact(emendas.totals.empenhado) : 'Sem fonte oficial',
hint: emendasOk
? `Pago: ${formatMoneyCompact(emendas.totals.pago)} · ${emendas.rows.length} emenda(s)`
: (emendas?.reason ?? null),
: emendas
? 'sem emenda atribuível ao município'
: null,
},
{
label: 'Falas no acervo',
@ -223,7 +231,7 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
{
title: 'Oposição local',
body: opposition
? `${opposition.text} (fonte: ${opposition.source.url} · ${formatDateBr(opposition.source.date)})`
? `${opposition.text} (fonte: pesquisa web · ${formatDateBr(opposition.source.date)}; URLs na seção 10)`
: 'Sem leitura suficiente — lacuna',
},
{

View file

@ -52,11 +52,17 @@ export const formatMoneyCompact = (value) => {
return `R$ ${formatDecimal(millions)} mi`
}
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
export const formatDateBr = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('pt-BR', { timeZone: 'America/Bahia' }).format(date)
// Date-only strings ("2026-08-17") parse as UTC midnight; formatting them in
// America/Bahia would print the previous day. Keep the calendar date.
const timeZone =
typeof value === 'string' && DATE_ONLY_PATTERN.test(value) ? 'UTC' : 'America/Bahia'
return new Intl.DateTimeFormat('pt-BR', { timeZone }).format(date)
}
export const formatDateTimeBr = (value) => {

View file

@ -98,6 +98,20 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
})
}
for (const entry of Array.isArray(raw.gaps) ? raw.gaps : []) {
if (isNonEmptyString(entry?.reason)) {
const id = isNonEmptyString(entry.id) ? entry.id : 'lacuna'
// A gap declared by the agent already accounts for the checklist item —
// otherwise the item would show up twice ("Não pesquisado." + the real gap).
if (checklistById.has(id)) seen.add(id)
gaps.push({
id,
label: isNonEmptyString(entry.label) ? entry.label : null,
reason: entry.reason.trim(),
})
}
}
for (const checklistItem of RESEARCH_CHECKLIST) {
if (!seen.has(checklistItem.id)) {
gaps.push({ id: checklistItem.id, label: checklistItem.label, reason: 'Não pesquisado.' })
@ -133,16 +147,6 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
})
}
for (const entry of Array.isArray(raw.gaps) ? raw.gaps : []) {
if (isNonEmptyString(entry?.reason)) {
gaps.push({
id: isNonEmptyString(entry.id) ? entry.id : 'lacuna',
label: isNonEmptyString(entry.label) ? entry.label : null,
reason: entry.reason.trim(),
})
}
}
return {
municipalitySlug: raw.municipalitySlug.trim(),
researchedAt: researchedAt.toISOString(),

View file

@ -22,8 +22,19 @@ const normalizeAuthorName = (value) =>
export const authorNameMatches = (candidate, expected) =>
normalizeAuthorName(candidate) === normalizeAuthorName(expected)
const number = (value) => {
const parsed = Number(value)
/**
* The Portal returns amounts as pt-BR strings ("81.000,00"); `Number()` alone
* would zero every row. Numbers stay accepted for replays/fixtures.
*/
const parseAmount = (value) => {
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
if (typeof value !== 'string') return 0
const raw = value.trim()
if (raw === '') return 0
const normalized = raw.includes(',')
? raw.replace(/\./g, '').replace(',', '.')
: raw.replace(/\./g, '')
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : 0
}
@ -35,10 +46,10 @@ export const normalizeEmendaRow = (row) => ({
authorCode: row?.codigoAutor !== undefined ? String(row.codigoAutor) : null,
authorName: typeof row?.nomeAutor === 'string' ? row.nomeAutor : null,
functionName: typeof row?.nomeFuncao === 'string' ? row.nomeFuncao : null,
empenhado: number(row?.valorEmpenhado),
liquidado: number(row?.valorLiquidado),
pago: number(row?.valorPago),
restoPago: number(row?.valorRestoPago),
empenhado: parseAmount(row?.valorEmpenhado),
liquidado: parseAmount(row?.valorLiquidado),
pago: parseAmount(row?.valorPago),
restoPago: parseAmount(row?.valorRestoPago),
})
const rowMunicipalityCode = (row) => {
@ -46,13 +57,38 @@ const rowMunicipalityCode = (row) => {
return candidate === undefined || candidate === null ? null : String(candidate)
}
const normalizeText = (value) =>
String(value ?? '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/\s+/g, ' ')
.trim()
/**
* The `/emendas` endpoint has no municipality parameter (official swagger:
* codigoEmenda, numeroEmenda, nomeAutor, tipoEmenda, ano, codigoFuncao,
* codigoSubfuncao, pagina) and does not expose `codigoMunicipio` per row. The
* only per-city signal is `localidadeDoGasto` ("ITAMARAJU - BA"); rows at UF /
* "Nacional" / "MÚLTIPLO" level are not attributable to the município and are
* dropped (fail-closed) instead of inflating the city total.
*/
const localityMatchesMunicipality = (locality, municipalityName) => {
const normalizedLocality = normalizeText(locality)
const normalizedName = normalizeText(municipalityName)
if (!normalizedLocality || !normalizedName) return false
return (
normalizedLocality === normalizedName || normalizedLocality.startsWith(`${normalizedName} -`)
)
}
export const sumEmendas = (rows) =>
rows.reduce(
(totals, row) => ({
empenhado: totals.empenhado + number(row.empenhado),
liquidado: totals.liquidado + number(row.liquidado),
pago: totals.pago + number(row.pago),
restoPago: totals.restoPago + number(row.restoPago),
empenhado: totals.empenhado + parseAmount(row.empenhado),
liquidado: totals.liquidado + parseAmount(row.liquidado),
pago: totals.pago + parseAmount(row.pago),
restoPago: totals.restoPago + parseAmount(row.restoPago),
}),
{ empenhado: 0, liquidado: 0, pago: 0, restoPago: 0 },
)
@ -69,14 +105,7 @@ const gapResult = ({ reason, detail = null, sourceUrl, consultedAt, requestCount
totals: sumEmendas([]),
})
const fetchAuthorYear = async ({
fetchImpl,
apiKey,
authorName,
municipalityCode,
year,
maxPages,
}) => {
const fetchAuthorYear = async ({ fetchImpl, apiKey, authorName, year, maxPages }) => {
const rows = []
let requests = 0
for (let page = 1; page <= maxPages; page += 1) {
@ -85,7 +114,6 @@ const fetchAuthorYear = async ({
nomeAutor: authorName,
pagina: String(page),
})
if (municipalityCode) params.set('codigoMunicipio', String(municipalityCode))
const url = `${EMENDAS_API_URL}?${params.toString()}`
requests += 1
const response = await fetchImpl(url, {
@ -138,6 +166,7 @@ const fetchAuthorYear = async ({
* @param {{
* years?: number[],
* municipalityCode?: string | null,
* municipalityName?: string | null,
* authorName?: string,
* apiKey?: string | null,
* fetchImpl?: (url: string, init?: { headers?: Record<string, string> }) => Promise<{ ok: boolean, status: number, json: () => Promise<unknown> }>,
@ -149,6 +178,7 @@ const fetchAuthorYear = async ({
export const fetchAuthorEmendas = async ({
years = EMENDAS_YEARS,
municipalityCode = null,
municipalityName = null,
authorName = DEFAULT_AUTHOR_NAME,
apiKey = null,
fetchImpl = fetch,
@ -174,7 +204,6 @@ export const fetchAuthorEmendas = async ({
fetchImpl,
apiKey,
authorName,
municipalityCode,
year,
maxPages,
})
@ -225,10 +254,13 @@ export const fetchAuthorEmendas = async ({
}
}
const requestedMunicipality = municipalityCode !== null || municipalityName !== null
const rows = matched
.filter((row) => {
const code = rowMunicipalityCode(row)
return code === null || municipalityCode === null || code === String(municipalityCode)
if (code !== null) return municipalityCode === null || code === String(municipalityCode)
if (!requestedMunicipality) return true
return localityMatchesMunicipality(row?.localidadeDoGasto, municipalityName)
})
.map(normalizeEmendaRow)
.sort(
@ -236,6 +268,21 @@ export const fetchAuthorEmendas = async ({
(right.year ?? 0) - (left.year ?? 0) || (left.code ?? '').localeCompare(right.code ?? ''),
)
if (rows.length === 0) {
return {
...gapResult({
reason: requestedMunicipality
? `Sem emenda do autor com localidade ${municipalityName ?? municipalityCode} na janela.`
: 'Sem emenda do autor na janela consultada.',
detail: 'API não expõe o município da emenda.',
sourceUrl,
consultedAt,
requestCount,
years,
}),
}
}
return {
status: 'ok',
reason: null,

View file

@ -218,8 +218,18 @@ const snapshot = {
},
}
type ReportItem = { label?: string; text?: string; value?: string; hint?: string | null }
type ReportRow = { label: string; value: string; source?: { url?: string } }
type ReportItem = {
label?: string
text?: string
value?: string
hint?: string | null
url?: string | null
}
type ReportRow = {
label: string
value: string
source?: { url?: string | null; date?: string | null }
}
type ReportBlock = {
kind: string
title?: string
@ -310,12 +320,15 @@ describe('buildCityReport', () => {
expect(gaps.body!.join(' ')).toMatch(/Emendas/)
})
it('puts research answers in Quem é quem with their source', () => {
it('puts research answers in Quem é quem with compact sources and URLs in section 10', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const who = grid.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe('Resposta de prefeito')
expect(prefeito.source!.url).toBe('https://exemplo.test/prefeito')
expect(prefeito.source!.url).toBeNull()
expect(prefeito.source!.date).toBe('2026-09-10')
const fontes = report.sections.find((section) => section.id === 'fontes')!.blocks[0]
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/prefeito')).toBe(true)
})
it('builds the region panorama from the committed artifact', () => {

View file

@ -31,6 +31,8 @@ describe('cityReportFormat', () => {
it('formats dates in pt-BR', () => {
expect(formatDateBr('2026-09-15T12:00:00.000Z')).toBe('15/09/2026')
expect(formatDateBr('2026-08-17')).toBe('17/08/2026')
expect(formatDateBr('2024-10-06')).toBe('06/10/2024')
expect(formatDateBr(null)).toBe('—')
})

View file

@ -68,6 +68,22 @@ describe('normalizeResearchInput', () => {
)
})
it('treats an explicit gap as researched, without a duplicate "Não pesquisado"', () => {
const research = normalizeResearchInput(
baseResearch({
items: RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'emendas_web').map((id) =>
validItem(id),
),
gaps: [{ id: 'emendas_web', label: 'Emendas na web', reason: 'Fonte oficial respondeu.' }],
}),
{ now },
)
const emendasGaps = research.gaps.filter((gap) => gap.id === 'emendas_web')
expect(emendasGaps).toHaveLength(1)
expect(emendasGaps[0].reason).toBe('Fonte oficial respondeu.')
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(0)
})
it('drops news outside the 90-day window and without source', () => {
const research = normalizeResearchInput(
baseResearch({

View file

@ -24,7 +24,7 @@ const row = (overrides: Record<string, unknown> = {}) => ({
valorLiquidado: 50000,
valorPago: 25000,
valorRestoPago: 1000,
codigoMunicipio: '2930709',
localidadeDoGasto: 'Itamaraju - BA',
...overrides,
})
@ -47,6 +47,21 @@ describe('portalTransparenciaEmendas', () => {
)
})
it('parses the pt-BR amount strings returned by the Portal API', () => {
const normalized = normalizeEmendaRow(
row({
valorEmpenhado: '81.000,00',
valorLiquidado: '0,00',
valorPago: '1.234,56',
valorRestoPago: '1.000.000,01',
}),
)
expect(normalized.empenhado).toBe(81000)
expect(normalized.liquidado).toBe(0)
expect(normalized.pago).toBe(1234.56)
expect(normalized.restoPago).toBe(1000000.01)
})
it('sums phases', () => {
const totals = sumEmendas([
normalizeEmendaRow(row()),
@ -63,18 +78,23 @@ describe('portalTransparenciaEmendas', () => {
expect(result.rows).toEqual([])
})
it('fetches only the author exact-match and the municipality', async () => {
it('fetches the author exact-match and keeps only the município rows', async () => {
const fetchImpl = vi.fn(async (url: string) => {
expect(url).toContain('nomeAutor=JORGE+SOLLA')
expect(url).toContain('codigoMunicipio=2930709')
expect(url).not.toContain('codigoMunicipio')
expect(url).toContain('ano=2023')
if (url.includes('pagina=2')) return jsonResponse([])
return jsonResponse([row(), row({ nomeAutor: 'JORGE SOLLA NETO', codigoAutor: '9999' })])
return jsonResponse([
row(),
row({ codigoEmenda: 'uf', localidadeDoGasto: 'BAHIA (UF)' }),
row({ nomeAutor: 'JORGE SOLLA NETO', codigoAutor: '9999' }),
])
})
const later = vi.fn(async () => jsonResponse([]))
const result = await fetchAuthorEmendas({
years: [2023, 2024],
municipalityCode: '2930709',
municipalityCode: '2915601',
municipalityName: 'Itamaraju',
apiKey: 'chave',
fetchImpl: (url: string) => (url.includes('ano=2023') ? fetchImpl(url) : later()),
})
@ -84,6 +104,25 @@ describe('portalTransparenciaEmendas', () => {
expect(result.requestCount).toBe(3)
})
it('degrades to a gap when no author row carries the município locality', async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse([
row({ localidadeDoGasto: 'BAHIA (UF)' }),
row({ localidadeDoGasto: 'Múltiplo' }),
]),
)
const result = await fetchAuthorEmendas({
years: [2023],
municipalityCode: '2915601',
municipalityName: 'Itamaraju',
apiKey: 'chave',
fetchImpl,
})
expect(result.status).toBe('gap')
expect(result.reason).toMatch(/localidade Itamaraju/)
expect(result.detail).toMatch(/não expõe o município/)
})
it('degrades to a gap when two authors share the name', async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse([row(), row({ codigoEmenda: 'x', codigoAutor: '5678' })]),