feat(C163): relatório — frente de oposição, dobradinhas, investimentos e seções dinâmicas

- Frente de oposição: lista de pesquisa 'opposition' (fato publicado com URL+data, sem inferir culpa) renderizada na seção 2, com nota de guardrail e URLs na seção de fontes.

- Dobradinhas: lista 'alliances' (com quem Solla soma na cidade) como bloco na seção de rede, insumo direto da abordagem.

- Investimentos: lista 'investments' (Solla, estado e federal, município e região; empenho ≠ pagamento) como bloco na conjuntura.

- Seções dinâmicas: numeração por posição; Sinais recentes e Demandas e visitas omitidas quando vazias (lacuna explícita só onde é produto).

Skill, changelog e testes unit cobrindo validador e builder.
This commit is contained in:
Francisco Solla 2026-09-15 18:28:06 -03:00
parent afd5f137ac
commit d46360d1dc
8 changed files with 289 additions and 24 deletions

View file

@ -119,6 +119,15 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
"transport": [ // rodovias, aeroporto, portos, ferrovia
{ "topic": "Rodovia federal (BR-101)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
],
"opposition": [ // o que a oposição fez de errado na região (fato com fonte)
{ "topic": "Emenda sob investigação (PF)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
],
"alliances": [ // dobradinhas: com quem somar na cidade
{ "topic": "Dobradinha estadual (PT)", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
],
"investments": [ // investimentos/obras de Solla, do estado e do federal (município e região)
{ "topic": "Obra federal — saúde", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" }
],
"gaps": [{ "id": "…", "label": "…", "reason": "…" }] // lacunas que o agente já sabe
}
```
@ -151,6 +160,29 @@ ferrovia da região — FIOL/Porto Sul, deixando claro quando não serve
diretamente a cidade). O enquadramento é "onde falta × onde o estado e a União
têm acertado" — o candidato é da base.
**Frente de oposição (pesquisa):** `opposition` é uma lista de
`{topic, detail, sourceUrl, sourceDate}` (sem fonte → lacuna) com **fatos
publicados e datados sobre o que a oposição fez de errado na região** — PF/MP em
emendas, obras paradas, anúncio sem entrega, escândalos do campo adversário.
Regras: (1) cada linha é fato publicado, **nunca** inferência de culpa — atribua
à fonte ("segundo a PF / o jornal") e diferencie acusação de condenação; (2)
priorize o **local** (operadores da oposição no município/região, ex.: emendas
investigadas) sobre o ataque frontal ao líder estadual; (3) marque temas de
**mão dupla** (casos que também citam o campo da base) para a coordenação não
entrar desprevenida; (4) **não** recomende ataque pessoal nem onde a marca do
adversário domina — o relatório é insumo ancorado, não roteiro de ataque. O item
entra na seção 2 (junto aos concorrentes) e a seção 14 lista as URLs.
**Dobradinhas e investimentos (pesquisa):** `alliances` é a lista de
`{topic, detail, sourceUrl, sourceDate}` com **com quem Solla soma na cidade**
(estadual/federal, chapa e lideranças que transferem voto) — insumo direto da
`approach`, exibido como bloco "Dobradinhas (pesquisa)" na seção 3 (rede).
`investments` lista **investimentos e obras de Solla, do governo do estado e do
federal no município e na região**, com fase/valor quando a fonte informar
(**empenho ≠ pagamento**), exibido como bloco "Investimentos e obras (pesquisa)"
na seção 4 (conjuntura). Sem fonte, cada item vira lacuna; nenhuma das duas
listas é obrigatória.
**Emendas oficiais:** sem `--emendas`, o builder consulta o Portal da
Transparência (`PORTAL_TRANSPARENCIA_API_KEY` no ambiente; sem chave → lacuna).
A API oficial **não filtra por município** (só UF/Nacional/Múltiplo na
@ -170,7 +202,8 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
anunciar, riscos e pontos sem leitura.
- **Seções 2+:** `1. Conta eleitoral` · `2. Concorrentes no município (federal
e estadual)` — top 5 por votos de 2022 na base TSE, com série 2014/2018/2022
e os prováveis candidatos do campo do prefeito (pesquisa) · `3. Rede e
e os prováveis candidatos do campo do prefeito (pesquisa), além da **frente de
oposição** quando pesquisada (fatos com fonte) · `3. Rede e
lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices,
vereadores mais votados, com partido) · `4. Conjuntura` · `5. Sinais` · `6.
Demandas e visitas` · `7. Demografia` — IBGE Censo 2022 do artefato +
@ -189,7 +222,14 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
limites`.
- **Links clicáveis:** todo URL no PDF é um link (`<a href>`): células de
tabela, fontes por linha e a seção de fontes.
- **Tabela da seção 11 (notícias) com larguras fixas:** `Data 9 · Veículo 14 ·
- **Numeração dinâmica e omissão de seções vazias:** o builder numera as seções
depois de montá-las (o `id` é estável; o número é posicional). As seções
**Sinais recentes** e **Demandas e visitas** são **omitidas quando não há
dado na base** (mostrar um callout de "nada aqui" só gastaria página); as
seções de pesquisa (`opposition`, `alliances`, `investments`, demografia,
economia, transporte) mantêm a lacuna explícita quando o item falta. Nunca
referencie seção por número fixo — use o nome/`id`.
- **Tabela de notícias com larguras fixas:** `Data 9 · Veículo 14 ·
Título 49 · Link 28` (%). As larguras explícitas tornam a tabela `table-fixed`
(a URL longa/crua quebra em vez de inflar a coluna) e dão ao **Título** — o
texto mais longo — a maior fatia. Sem largura, a URL sem espaços domina o
@ -211,6 +251,11 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
- **Abordagem é análise ancorada**: as sugestões das personas (governo do PT na
região, projetos futuros, prioridades locais) saem do `approach` com fonte por
item — sem fonte, não entra; o PDF deixa claro que é análise, não fato novo.
- **Frente de oposição é fato, não ataque**: `opposition` só recebe fato publicado
com URL + data; o PDF **não** afirma culpa, **não** infere dolo e **não** orienta
ataque pessoal. Prioriza o local (operadores na região) sobre o líder estadual e
sinaliza temas de mão dupla; onde a marca do adversário domina, o insumo é para
contraste, não para confronto frontal.
- **Artefato gitignored**: o repo é público; o PDF/MD com dado interno nunca é
commitado (só a skill/scripts/changelog).

View file

@ -0,0 +1 @@
**C163 — Relatório de cidade: frente de oposição como pesquisa ancorada (2026-09-15):** nova lista de pesquisa `opposition` no contrato do relatório de cidade (`{topic, detail, sourceUrl, sourceDate}` — sem fonte vira lacuna, igual a `demography`/`economy`/`transport`). Ela responde a "o que a oposição fez de errado na região que dá para usar a favor da campanha": PF/MP em emendas, obras paradas, anúncio sem entrega, escândalos do campo adversário. O bloco entra na **seção 2 (Concorrentes)**, ao lado dos prováveis candidatos do campo do prefeito, como tabela Tema/O que a fonte diz/Fonte, e a seção 14 lista as URLs. Guardrails fixos no `note` do bloco, na seção 14 e na skill: cada linha é fato publicado com URL+data, **nunca** inferência de culpa (diferencie acusação de condenação e atribua à fonte); priorize o **local** (operadores da oposição na região) sobre o ataque frontal ao líder estadual; marque temas de **mão dupla**; e não oriente ataque pessoal onde a marca do adversário domina. Cobertura unit nova no validador (fonte mantida, sem fonte vira lacuna) e no builder (bloco renderizado com fonte), sem nova seção numerada (mantém as 14 seções).

View file

@ -0,0 +1 @@
**C163 — Relatório de cidade: seções dinâmicas, dobradinhas e investimentos (2026-09-15):** o builder agora **numera as seções por posição** (o `id` é estável; o número é derivado) e **omite "Sinais recentes" e "Demandas e visitas" quando não há dado na base** — mostrar um callout de "nada aqui" só gastava página; as seções de pesquisa seguem com lacuna explícita quando o item falta. Duas listas de pesquisa novas, no mesmo contrato `{topic, detail, sourceUrl, sourceDate}` (sem fonte → lacuna): **`alliances`** (dobradinhas — com quem Solla soma na cidade: estadual/federal, chapa e lideranças que transferem voto), exibida na seção de rede como "Dobradinhas (pesquisa)" e insumo direto da `approach`; e **`investments`** (investimentos e obras de Solla, do governo do estado e do federal no município e na região, com fase/valor quando a fonte informar — empenho ≠ pagamento), exibida na seção de conjuntura como "Investimentos e obras (pesquisa)". A seção de fontes rotula as duas listas e a skill registra o contrato, os guardrails e a regra de numeração dinâmica/omissão. Cobertura unit nova: validador (fonte mantida, sem fonte vira lacuna) e builder (dobradinhas na rede, investimentos na conjuntura, omissão de 5/6 com renumeração contígua).

View file

@ -399,6 +399,32 @@ const buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({
sources: [sourceTeqo('base Teqo — TSE 2014/2018/2022', readAt)],
})
const buildOppositionBlock = (research) => {
const entries = research.opposition ?? []
if (!entries.length) return null
return {
kind: 'table',
title: 'Oposição — o que usar com fonte (pesquisa)',
columns: [
{ key: 'topic', label: 'Tema', width: 22 },
{ key: 'detail', label: 'O que a fonte diz', width: 62 },
{ key: 'source', label: 'Fonte', width: 16 },
],
rows: entries.map((item) => ({
topic: item.topic,
detail: item.detail,
source: formatDateBr(item.sourceDate),
})),
note: 'Fato publicado com fonte datada — não é condenação. Atribua à fonte e não infira culpa; tema nacional pode ser de mão dupla.',
sources: entries.map((item) => ({
kind: 'web',
label: `Oposição — ${item.topic}`,
url: item.sourceUrl,
date: item.sourceDate,
})),
}
}
const buildCompetitorsSection = ({ snapshot, research }) => {
const competitors = snapshot.competitors ?? { referenceYear: 2022, federal: [], state: [] }
const offices = [
@ -458,6 +484,9 @@ const buildCompetitorsSection = ({ snapshot, research }) => {
},
)
const opposition = buildOppositionBlock(research)
if (opposition) blocks.push(opposition)
return blocks
}
@ -556,10 +585,18 @@ const buildNetworkSection = ({ snapshot, research }) => {
})
}
blocks.push(
...buildSourcedListBlocks({
title: 'Dobradinhas (pesquisa)',
note: 'Com quem Solla soma na cidade em 2026 (estadual/federal, chapa e lideranças que transferem voto) — insumo direto da abordagem sugerida; cada item com URL.',
items: research?.alliances ?? [],
}),
)
return blocks
}
const buildConjunctureSection = ({ snapshot }) => {
const buildConjunctureSection = ({ snapshot, research }) => {
const conjuncture = snapshot.conjuncture
const trend = conjuncture?.politicalTrend
const rows = [
@ -632,6 +669,14 @@ const buildConjunctureSection = ({ snapshot }) => {
})
}
blocks.push(
...buildSourcedListBlocks({
title: 'Investimentos e obras (pesquisa)',
note: 'O que Solla, o governo do estado e o federal entregaram, anunciaram ou têm em obra no município e na região — com fase/valor quando a fonte informar; cada item com URL. Empenho ≠ pagamento.',
items: research?.investments ?? [],
}),
)
return blocks
}
@ -1095,11 +1140,19 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
date: item.sourceDate,
})
}
for (const listKey of ['demography', 'economy', 'transport']) {
const listLabels = {
demography: 'Demografia',
economy: 'Economia',
transport: 'Transporte',
opposition: 'Oposição',
alliances: 'Dobradinhas',
investments: 'Investimentos e obras',
}
for (const listKey of Object.keys(listLabels)) {
for (const item of research[listKey] ?? []) {
items.push({
kind: 'web',
label: `${listKey === 'demography' ? 'Demografia' : listKey === 'economy' ? 'Economia' : 'Transporte'} — ${item.topic}`,
label: `${listLabels[listKey]} — ${item.topic}`,
url: item.sourceUrl,
date: item.sourceDate,
})
@ -1119,6 +1172,7 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
'Emendas são lidas da fonte oficial em tempo de geração e não são persistidas na base.',
'Quando a fonte oficial não atribui emenda ao município, indícios web (município, região ou polo) entram como evidência datada — nunca somados como emenda da cidade.',
'Abordagem sugerida é análise das personas ancorada nas fontes listadas item a item, não fato verificado além delas.',
'Oposição: cada linha é fato publicado com URL + data; o PDF não afirma culpa nem orienta ataque pessoal — use como contraste ancorado.',
],
},
]
@ -1151,6 +1205,23 @@ const assertSnapshotShape = (snapshot) => {
}
}
/**
* Sections whose only content would be an empty-state callout are omitted
* (sinais/demandas) so the report does not spend a page saying "nothing here".
* Research sections keep their explicit gap callout — a missing source is a
* product lacuna, not noise.
*/
const hasSectionData = (id, snapshot) => {
if (id === 'sinais') return (snapshot.signals?.rows?.length ?? 0) > 0
if (id === 'demandas') {
return (
(snapshot.demands?.rows?.length ?? 0) > 0 ||
(snapshot.activities?.upcoming?.length ?? 0) + (snapshot.activities?.recent?.length ?? 0) > 0
)
}
return true
}
export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new Date() }) => {
assertSnapshotShape(snapshot)
const generatedAtIso = generatedAt instanceof Date ? generatedAt.toISOString() : generatedAt
@ -1158,63 +1229,69 @@ export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new
const sections = [
{
id: 'conta-eleitoral',
title: '1. Conta eleitoral completa (2014/2018/2022)',
title: 'Conta eleitoral completa (2014/2018/2022)',
blocks: buildElectoralSection({ snapshot }),
},
{
id: 'concorrentes',
title: '2. Concorrentes no município (federal e estadual)',
title: 'Concorrentes no município (federal e estadual)',
blocks: buildCompetitorsSection({ snapshot, research }),
},
{
id: 'rede',
title: '3. Rede e lideranças',
title: 'Rede e lideranças',
blocks: buildNetworkSection({ snapshot, research }),
},
{ id: 'conjuntura', title: '4. Conjuntura', blocks: buildConjunctureSection({ snapshot }) },
{ id: 'sinais', title: '5. Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
{
id: 'conjuntura',
title: 'Conjuntura',
blocks: buildConjunctureSection({ snapshot, research }),
},
{ id: 'sinais', title: 'Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
{
id: 'demandas',
title: '6. Demandas e visitas',
title: 'Demandas e visitas',
blocks: buildDemandsVisitsSection({ snapshot }),
},
{
id: 'demografia',
title: '7. Demografia',
title: 'Demografia',
blocks: buildDemographicsSection({ snapshot, research }),
},
{
id: 'economia',
title: '8. Atividade econômica (pesquisa)',
title: 'Atividade econômica (pesquisa)',
blocks: buildEconomySection({ research }),
},
{
id: 'transporte',
title: '9. Transporte e conexões (pesquisa)',
title: 'Transporte e conexões (pesquisa)',
blocks: buildTransportSection({ research }),
},
{ id: 'falas', title: '10. Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
{ id: 'falas', title: 'Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
{
id: 'noticias',
title: '11. Notícias internas e imprensa local',
title: 'Notícias internas e imprensa local',
blocks: buildNewsSection({ research }),
},
{
id: 'regiao',
title: '12. Panorama regional (Território de Identidade)',
title: 'Panorama regional (Território de Identidade)',
blocks: buildRegionSection({ snapshot }),
},
{
id: 'abordagem',
title: '13. Abordagem sugerida (pesquisa)',
title: 'Abordagem sugerida (pesquisa)',
blocks: buildApproachSection({ research }),
},
{
id: 'fontes',
title: '14. Fontes e limites',
title: 'Fontes e limites',
blocks: buildSourcesSection({ snapshot, research, emendas }),
},
]
.filter((section) => hasSectionData(section.id, snapshot))
.map((section, index) => ({ ...section, title: `${index + 1}. ${section.title}` }))
return {
meta: {

View file

@ -267,7 +267,14 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
})
}
const sourcedLists = { demography: [], economy: [], transport: [] }
const sourcedLists = {
demography: [],
economy: [],
transport: [],
opposition: [],
alliances: [],
investments: [],
}
for (const key of Object.keys(sourcedLists)) {
for (const entry of Array.isArray(raw[key]) ? raw[key] : []) {
const topic = isNonEmptyString(entry?.topic) ? entry.topic.trim() : null
@ -311,6 +318,9 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
demography: sourcedLists.demography,
economy: sourcedLists.economy,
transport: sourcedLists.transport,
opposition: sourcedLists.opposition,
alliances: sourcedLists.alliances,
investments: sourcedLists.investments,
gaps,
}
}

View file

@ -94,6 +94,30 @@ const research = normalizeResearchInput(
sourceDate: '2026-09-10',
},
],
opposition: [
{
topic: 'Emenda sob investigação (PF)',
detail: 'Operação apura desvio em emendas do campo adversário.',
sourceUrl: 'https://exemplo.test/oposicao',
sourceDate: '2026-09-10',
},
],
alliances: [
{
topic: 'Deputada Estadual (PT)',
detail: 'Dobradinha estadual indicada com Solla na cidade.',
sourceUrl: 'https://exemplo.test/dobradinha',
sourceDate: '2026-09-10',
},
],
investments: [
{
topic: 'Obra federal no município',
detail: 'Investimento do governo federal em saúde; em execução.',
sourceUrl: 'https://exemplo.test/investimento',
sourceDate: '2026-09-10',
},
],
gaps: [],
},
{ now: generatedAt },
@ -398,6 +422,65 @@ describe('buildCityReport', () => {
)
})
it('renders the opposition front as sourced contrast, when researched', () => {
const concorrentes = report.sections.find((section) => section.id === 'concorrentes')!
const opposition = concorrentes.blocks.find(
(block) => block.title === 'Oposição — o que usar com fonte (pesquisa)',
)!
const rows = opposition.rows as unknown as TableRow[]
expect(rows[0].topic).toBe('Emenda sob investigação (PF)')
expect(rows[0].detail).toContain('desvio em emendas')
expect(opposition.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/oposicao' }),
)
})
it('renders the dobradinhas research in the network section', () => {
const rede = report.sections.find((section) => section.id === 'rede')!
const dobradinhas = rede.blocks.find((block) => block.title === 'Dobradinhas (pesquisa)')!
const rows = dobradinhas.rows as unknown as TableRow[]
expect(rows[0].topic).toContain('Deputada Estadual')
expect(dobradinhas.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/dobradinha' }),
)
})
it('renders the investments research in the conjuncture section', () => {
const conjuntura = report.sections.find((section) => section.id === 'conjuntura')!
const investments = conjuntura.blocks.find(
(block) => block.title === 'Investimentos e obras (pesquisa)',
)!
const rows = investments.rows as unknown as TableRow[]
expect(rows[0].detail).toContain('governo federal')
expect(investments.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/investimento' }),
)
})
it('omits the empty signals/demands sections and renumbers the rest', () => {
const emptyReport = asReport(
buildCityReport({
snapshot: {
...snapshot,
signals: { totalCount: 0, rows: [] },
demands: { totalCount: 0, rows: [] },
activities: { upcoming: [], recent: [] },
},
research,
emendas,
generatedAt,
}),
)
expect(emptyReport.sections.some((section) => section.id === 'sinais')).toBe(false)
expect(emptyReport.sections.some((section) => section.id === 'demandas')).toBe(false)
expect(
emptyReport.sections.every((section, index) => section.title.startsWith(`${index + 1}. `)),
).toBe(true)
expect(emptyReport.sections.find((section) => section.id === 'demografia')!.title).toMatch(
/^5\. /,
)
})
it('lists the researched city leaders in the network section', () => {
const rede = report.sections.find((section) => section.id === 'rede')!
const leadersBlock = rede.blocks.find(

View file

@ -114,10 +114,12 @@ describe('renderReportMd', () => {
it('carries the same sections as tables and headings', () => {
expect(md).toContain('## 1. Conta eleitoral completa')
expect(md).toContain('## 2. Concorrentes no município (federal e estadual)')
expect(md).toContain('## 8. Atividade econômica (pesquisa)')
expect(md).toContain('## 9. Transporte e conexões (pesquisa)')
expect(md).toContain('## 13. Abordagem sugerida (pesquisa)')
expect(md).toContain('## 14. Fontes e limites')
expect(md).not.toContain('## 5. Sinais recentes')
expect(md).not.toContain('Demandas e visitas')
expect(md).toContain('## 6. Atividade econômica (pesquisa)')
expect(md).toContain('## 7. Transporte e conexões (pesquisa)')
expect(md).toContain('## 11. Abordagem sugerida (pesquisa)')
expect(md).toContain('## 12. Fontes e limites')
expect(md).toContain('| Ano | Votos |')
})

View file

@ -177,4 +177,50 @@ describe('normalizeResearchInput', () => {
expect.objectContaining({ id: 'inventado', reason: expect.stringMatching(/checklist/) }),
)
})
it('keeps sourced opposition facts and gaps entries without source', () => {
const research = normalizeResearchInput(
baseResearch({
opposition: [
{
topic: 'Emenda sob investigação (PF)',
detail: 'Operação apura desvio em emendas.',
sourceUrl: 'https://exemplo.test/oposicao',
sourceDate: '2026-09-10',
},
{ topic: 'Sem fonte', detail: 'Não entra.' },
],
}),
{ now },
)
expect(research.opposition).toHaveLength(1)
const [firstOpposition] = research.opposition as unknown as Array<{ topic: string }>
expect(firstOpposition.topic).toBe('Emenda sob investigação (PF)')
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: 'opposition_sem_fonte',
reason: expect.stringMatching(/URL e data/),
}),
)
})
it('keeps sourced dobradinhas and investments, gapping those without source', () => {
const research = normalizeResearchInput(
baseResearch({
alliances: [
{
topic: 'Dobradinha estadual',
detail: 'Deputada X (PT) indicada com Solla.',
sourceUrl: 'https://exemplo.test/dobradinha',
sourceDate: '2026-09-10',
},
],
investments: [{ topic: 'Sem fonte', detail: 'Não entra.' }],
}),
{ now },
)
expect(research.alliances).toHaveLength(1)
expect(research.investments).toHaveLength(0)
expect(research.gaps).toContainEqual(expect.objectContaining({ id: 'investments_sem_fonte' }))
})
})