feat(relatorio-cidade): foca o relatório na mobilização do 2º turno

A federal foi decidida em 04/10; o documento de cidade passa a servir
inteiro à eleição de Lula. Saem conta eleitoral/cadeira de Solla,
concorrentes e pré-candidatos, emendas e entregas (sem fetch do Portal
da Transparência no builder), acervo de falas, panorama regional e
sinais/demandas; demografia, economia e transporte ficam como contexto
da visita.

A página 1 vira só mobilização: KPIs oficiais do artefato TSE 2026
(Lula/Δ, X₁/X₂, Solla), papel determinístico da cidade, quem mobiliza,
movimento local do 2º turno, riscos e o painel "O que pedir e falar"
(pedido Lula 13; guardrail de votos em jogo no lugar do defeso).

O checklist cai de 10 para 8 itens (emendas_web e quem_investe saem;
relacao_campo/disputa_local reancorados no 2º turno); listas
preCandidates/investments/emendasIndicators saem do contrato (arquivos
antigos seguem aceitos em replay). O snapshot enxuga para rede +
conjuntura + demografia (cityReportTerritory.mjs removido).

Prova: unit 5.232 verdes, int do snapshot sem PII e sem os grupos
aposentados, lint/format/typecheck/knip limpos e rebuild da fixture de
Cruz das Almas (9 seções, 16 páginas contra 21, página 1 fechando sem
fallback).
This commit is contained in:
Francisco Solla 2026-10-10 16:19:24 -03:00
parent 31f78f07ad
commit 5bf5fa3cb1
20 changed files with 672 additions and 2335 deletions

View file

@ -1,31 +1,29 @@
---
name: relatorio-cidade
description: 'Gera o relatório de cidade pré-viagem (PDF A4 + .md) a partir da base Teqo read-only + pesquisa web datada + emendas oficiais; aceita um município ou um lote separado por vírgula.'
---
# Skill: relatorio-cidade
# Relatório de cidade pré-viagem (C163)
# Relatório de cidade — mobilização do 2º turno (C163)
Entrega um **PDF A4 datado + companion `.md`** por município, cruzando a base de
produção do Teqo (**read-only**, snapshot do momento) com pesquisa web (data +
URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Página 1
= resumo de uma olhada; páginas 2+ = aprofundamento; tudo sem fonte vira
**lacuna explícita** — nunca inferência.
URL por item) e os **números oficiais do 2º turno** do artefato TSE 2026
commitado. Página 1 = resumo de uma olhada (**só mobilização**); páginas 2+ =
aprofundamento; tudo sem fonte vira **lacuna explícita** — nunca inferência.
## Quando usar
- O candidato/CG pede "o relatório de <cidade>" antes de uma viagem — ou de
várias numa invocação (`/relatorio-cidade Ilheus, Itacare, Una`).
- O candidato/CG pede "o relatório de <cidade>" para uma visita — ou de várias
numa invocação (`/relatorio-cidade Ilheus, Itacare, Una`).
- Quem executa são o **orquestrador** (agente principal) + um sub-agente
**researcher** por cidade + os dois scripts (`extract` no homeserver, `build`
local). O PDF sai para leitura de bolso; o detalhe das etapas está em
"Pipeline (etapas)".
## Missão do 2º turno (Lula × Flávio — 25/10/2026)
## Missão — 2º turno (Lula × Flávio — 25/10/2026)
A partir de 08/10/2026 o relatório não é só pré-viagem de mandato: **cada visita
também serve à eleição de Lula no 2º turno**. O produto carrega o recorte
oficial do 1º turno de 2026 na seção **"2º turno no município (Lula × Flávio)"**
(nº 2, logo depois da conta eleitoral), com:
A partir de 08/10/2026 o relatório **é uma ferramenta de mobilização do 2º
turno**: cada visita serve à eleição de Lula. O produto carrega o recorte
oficial do 1º turno de 2026 (TSE por seção, agregado à unidade) e organiza a
leitura em torno de quatro perguntas: **onde Lula perdeu/ganhou, qual o papel
da cidade, quem mobiliza e o que está quente agora**.
- **Números oficiais por unidade** (município ou ZE de Salvador, na mesma
geografia do catálogo): Lula 2022→2026, adversário (Bolsonaro→Flávio), Solla
@ -49,19 +47,22 @@ Doutrina (de `PERSONA.md` e da nota): **interior = conversão (presença e
proteção); Salvador = comparecimento (mobilização)**; persuasão em eleição geral
tem efeito médio ~0 — o papel de Solla é **mobilizar, proteger e dar voz**, não
converter. `X₁`/`X₂` são **votos em jogo, não transferência garantida**; o
cenário de 2º turno é hipotético e a seção diz isso. Operação: redutos/vitrine
cenário de 2º turno é hipotético e o PDF diz isso. Operação: redutos/vitrine
(onde Solla é a marca), cinturão do PT local, ZE de Salvador 19/15/9/12
(mobilização) e contenção em 16/4.
Regras duras da seção:
Regras duras da missão:
- os números oficiais (Lula, Flávio, Solla, X₁/X₂) vêm do **artefato** — o
researcher **não** os pesquisa; ele pesquisa só o movimento local.
researcher **não** os pesquisa; ele pesquisa só o movimento local e o palanque.
- não infira comportamento individual (falácia ecológica) nem prometa conversão;
a seção informa **onde mobilizar/proteger**, com a incerteza declarada.
- a seção **não entra na página 1**: a página 1 é medida e falha fechada, e
conteúdo determinístico não encolhe no fallback de texto; o dado de 2º turno
vive na seção própria.
o relatório informa **onde mobilizar/proteger**, com a incerteza declarada.
- **fora de escopo desde o refactor de 2026-10-10** (a federal foi decidida em
04/10): conta eleitoral/cadeira de Solla, concorrentes e pré-candidatos,
emendas e "o que Solla entregou", acervo de falas da Câmara, panorama
regional (TI) e sinais/demandas/visitas **não entram no relatório** — não
reintroduza sem decisão de produto. Demografia, economia e transporte seguem
como contexto da visita.
## Lote (várias cidades)
@ -133,10 +134,9 @@ Uma cidade = um par PDF+MD (`<slug>-<YYYY-MM-DD>`); **sem** índice/PDF agregado
```
Saídas: `docs/research/relatorios-cidade/<slug>-<YYYY-MM-DD>.pdf` + `.md`
(companion revisável em diff). Intermediários gitignored em
`data/relatorios-cidade/` (HTML, JSONs de emendas, `logs/`). Também
redirecione a saída do builder para `logs/<slug>.build.log`.
Flags úteis: `--emendas=<json>` (replay sem rede), `--author=<nome>`
(default `JORGE SOLLA`), `--generated-at=<ISO>` (reprodutibilidade),
`data/relatorios-cidade/` (HTML, JSONs, `logs/`). Também redirecione a saída
do builder para `logs/<slug>.build.log`.
Flags úteis: `--generated-at=<ISO>` (reprodutibilidade);
`CITY_REPORT_STRICT=1` (falha o run se houver lacuna de pesquisa — para
conferência, não para entrega).
5. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status
@ -208,13 +208,6 @@ de pesquisa que cruza para ele.
"sourceUrl": "https://…", "sourceDate": "2026-04-07"
}
],
"preCandidates": [ // prováveis candidatos do campo do prefeito em 2026
{
"name": "…", "office": "Deputado federal", "party": "PSDB",
"support": "Campo do prefeito …",
"sourceUrl": "https://…", "sourceDate": "2026-04-18"
}
],
"leaders": [ // últimos prefeitos/vices, vereadores mais votados
{
"name": "…", "role": "Ex-prefeito", "party": "PSDB", "period": "2017–2024",
@ -242,39 +235,22 @@ de pesquisa que cruza para ele.
"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)
"opposition": [ // o que o campo adversário 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": "…" }
],
"emendasIndicators": [ // indícios de emenda, um por autor (página 1; esfera explícita)
{
"author": "Zé Neto", // obrigatório
"sphere": "municipio", // obrigatório: municipio | regiao | polo
"value": "R$ 1 mi", // opcional (como a fonte informa)
"purpose": "Ambulância do TFD", // opcional (o que beneficia)
"year": "2026", // opcional
"sourceUrl": "https://…", "sourceDate": "2026-08-24"
}
],
"gaps": [{ "id": "…", "label": "…", "reason": "…" }] // lacunas que o agente já sabe
}
```
Checklist (`id`s): `prefeito`, `vice`, `relacao_campo`, `vereadores`,
`disputa_local`, `segundo_turno`, `quem_investe`, `noticias`, `imprensa_local`,
`emendas_web`.
`disputa_local`, `segundo_turno`, `noticias`, `imprensa_local`.
Item sem `sourceUrl`/`sourceDate` é convertido em lacuna pelo validador; item
ausente também. `approach`, `preCandidates`, `leaders`, `leaderAgenda` e
`emendasIndicators` são
ausente também. `approach`, `leaders`, `leaderAgenda` são
listas de pesquisa: cada entrada sem fonte vira lacuna (`abordagem_sem_fonte`,
`precandidato_sem_fonte`, `lideranca_sem_fonte`, `agenda_lideranca_sem_fonte`,
`emenda_indicio_sem_fonte`; `emendasIndicators` ainda exige `author` e `sphere`
— sem eles, `emenda_indicio_incompleto`) e
`lideranca_sem_fonte`, `agenda_lideranca_sem_fonte`) e
não entra no PDF. Campos de texto livre (`detail`, `hook`, `suggestion`,
`support`, `answer`) aceitam os **tokens de fonte inline** `{{fonte}}` /
`{{fonte:N}}`, resolvidos para `(fonte)` no ponto exato da citação (ver "Links
@ -293,135 +269,80 @@ leitura, sempre com URL+data. `name` é obrigatório; exige `topics` **ou** `hoo
sem fonte, não entra. Priorize (a) a **rede da campanha** (as lideranças na base
Teqo, com `supportStatus`) e (b) o **mesmo campo** de Solla — é onde há margem
de conversa. É **hipótese a validar na conversa, não declaração do líder**: o
bloco "Pauta das lideranças (pesquisa)" (seção 3) sai com essa ressalva. Liderança
bloco "Pauta das lideranças (pesquisa)" sai com essa ressalva. Liderança
de rede **sem rastro público** não vira item: fica como tarefa de ativação
(cadastrar/atualizar `supportStatus`), não como pauta inventada.
**`emendas_web` — indícios de emenda (município, região ou polo):** quando a
fonte oficial não atribui emenda ao município, pesquise artigos, falas e
indicações de emenda para (a) o município, (b) a região/Território de
Identidade e (c) a maior cidade próxima (polo regional — cidades pequenas usam
serviços do polo). Registre a síntese em `emendas_web` (answer/details +
`extraSources`) e, principalmente, **cada indício como uma linha de
`emendasIndicators`** — `author`, `sphere` (`municipio`/`regiao`/`polo`), valor,
finalidade, ano e fonte+data. É essa lista estruturada que a página 1 imprime
(autor — o que beneficia · valor · esfera, com link); o texto corrido só entra
quando não há lista (fallback). Sem uma das linhas com fonte, o indício vira
lacuna (`emenda_indicio_sem_fonte`) — nunca some em silêncio. O PDF mostra o
bloco como "Emendas — indícios web" na página 1 e as URLs na seção de fontes.
**Nunca somar** indício de região/polo como emenda da cidade.
**Demografia, economia e transporte (pesquisa):** `demography`, `economy` e
`transport` são listas de `{topic, detail, sourceUrl, sourceDate}` (sem fonte →
lacuna). Pesquise: **cor/raça e rendimento** (Censo 2022 — SIDRA 9605 e 10295;
IBGE Cidades para salário médio); **economia** (PIB e PIB per capita — IBGE
Cidades; composição setorial e emprego formal — RAIS/CAGED e perfis regionais;
café, pecuária e comércio no caso do Extremo Sul); **transporte** (rodovias
federais/estaduais que cortam o município e obras novas/reformadas — DNIT,
Seinfra; aeroporto mais próximo em operação e o do polo, com situação; portos e
ferrovia da região — FIOL/Porto Sul, deixando claro quando não serve
Cidades; composição setorial e emprego formal — RAIS/CAGED e perfis regionais);
**transporte** (rodovias federais/estaduais que cortam o município e obras
novas/reformadas — DNIT, Seinfra; aeroporto mais próximo em operação e o do
polo, com situação; portos e ferrovia da região, 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.
publicados e datados sobre o que o campo adversário fez de errado na região** —
PF/MP, obras paradas, anúncio sem entrega, escândalos. 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 do campo adversário no município/região) 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 de conjuntura e a seção
de fontes lista as URLs.
**Dobradinhas e investimentos (pesquisa):** `alliances` é a lista de
**Dobradinhas (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
`localidadeDoGasto`); o builder casa pela localidade com o nome do município e,
sem linha atribuível, degrada para lacuna (Issue #1025). O resultado fica
cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
`approach`, exibido como bloco "Dobradinhas (pesquisa)" na seção de rede. Sem
fonte, cada item vira lacuna; a lista não é obrigatória.
## Conteúdo do relatório
- **Página 1 (uma olhada):** identificação/prioridade/classe/nível, conta
eleitoral 2022 (votos, % do próprio voto, rank/435) **+ expectativa de votos
(cenário central com pessimista/otimista)** — a meta de cadeira e a cobertura
de pledges **saíram** da página 1; quem é quem (prefeito, vice, relação com o
campo, lideranças, dobradinhas, vereadores), o que Solla entregou (emendas
oficiais com fase, acervo de falas, notícias ≤90 dias), **emendas — indícios
web** quando a fonte oficial não atribui ao município (uma linha por autor:
o que beneficia · valor · **esfera explícita** `município`/`região`/`polo`, com
link), **O que anunciar agora** (a relação local com fonte + os compromissos
que a campanha cumpre) com a advertência de defeso, riscos e pontos sem
leitura. O painel fixo "O que NÃO anunciar" **saiu** da página 1 (copy
invariante; o guardrail de defeso permanece na nota do painel de anúncio). Os
textos livres da pesquisa **nunca são cortados no meio** (C188): a página 1
- **Página 1 (uma olhada, só mobilização):** identificação (território,
prioridade, nível, responsável), o par de células **"2º turno — Lula ×
Flávio"** (KPIs oficiais: Lula em 2026 com Δ p.p., X₁/X₂ e Solla 2026 + o
**papel** da cidade com a orientação de um a dois parágrafos) e **"Quem
mobiliza"** (prefeito, vice, relação com o campo, lideranças na base,
dobradinhas, vereadores/dobradas — com a fonte da pesquisa por linha),
**Movimento local do 2º turno (pesquisa)** em callout, **Riscos —
oposição/disputa local** em cards, **O que pedir e falar** (o pedido Lula 13,
a relação local confirmada e o guardrail de que X₁/X₂ são votos em jogo), os
pontos sem leitura e a nota de rodapé. Sempre com a advertência de que o
cenário de 2º turno é hipotético.
Os textos livres da pesquisa **nunca são cortados no meio** (C188): a página 1
imprime o `summary` do item quando existe; sem ele, imprime o `answer`
integral e, se a página não couber, o builder re-renderiza apontando o
aprofundamento — **sem "…"**. Por isso, escreva `summary` curto e auto-contido
para os itens que aparecem na página 1 (`prefeito`, `vice`, `relacao_campo`,
`vereadores`, `disputa_local`, `quem_investe`, `emendas_web`): é a única forma
de a página 1 manter o texto que você redigiu. As listas mostram `e mais N`; o
texto integral
de cada item fica no aprofundamento (ver "respostas integrais" em "Fontes e
limites").
- **Seções 2+:** `1. Conta eleitoral` · `2. 2º turno no município (Lula ×
Flávio)` — números oficiais do artefato (Lula/adversário/Solla,
comparecimento, abstenção, brancos+nulos e X₁/X₂), o papel da cidade
(`prioridade`/`mobilizacao`/`defesa`/`expansao`) e o **movimento local
pesquisado** (`segundo_turno`), com o cenário rotulado como hipotético ·
`3. 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), além da **frente de
oposição** quando pesquisada (fatos com fonte) · `4. Rede e
lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices,
vereadores mais votados, com partido) e a **Pauta das lideranças (pesquisa)**:
pauta provável + gancho recente por liderança da rede/mesmo campo, marcada
como hipótese · `5. Conjuntura` · `6. Sinais` · `7.
Demandas e visitas` · `8. Demografia` — IBGE Censo 2022 do artefato +
**complemento pesquisado** (cor/raça e poder aquisitivo) · `9. Atividade
econômica (pesquisa)` · `10. Transporte e conexões (pesquisa)` · `11. Acervo
de falas` — cada fala com "O que é" (sumário oficial) e "Menção ao município"
(passagem que cita a cidade ou, se o nome não aparece nos trechos, a marcação
do acervo com o nº de municípios), mais **Vídeo** (YouTube no trecho da fala
— `youtubeExcerptStartSeconds` = época do trecho (`excerptTMs`) − início da
sessão, calibrado contra o vídeo real: o da sessão de 13/03/2018 começa ~37s
depois do `eventStartAt`, então 645s viram `t=608s` (`YOUTUBE_VIDEO_OFFSET_SECONDS`);
o `speechAt` da API é o horário do slot, não o início real; fallback para o
VOD da Câmara, que é o clipe do próprio trecho) e **Transcrição** (PDF do
Diário) quando existirem · `12. Notícias e imprensa` ·
`13. Panorama regional` · `14. Abordagem sugerida (personas)` · `15. Fontes e
limites` — lista as fontes e, antes delas, a tabela **"Pesquisa — respostas
integrais"** com o texto completo de cada item do checklist (o que a página 1
capou).
- **Acervo por região e tema:** como cidade pequena quase nunca tem fala
própria, a seção 10 mostra três recortes **não sobrepostos**: falas do
município (quando existem), **menções à região** (demais municípios do mesmo
Território de Identidade) e **falas por tema regional** (temas-chave do
interior). Região/polo **nunca** é somado como fala da cidade — o bloco diz
isso explicitamente. Fonte: `speeches.region` e `speeches.topics` do snapshot
(extraídos read-only no homeserver; ausentes nos snapshots antigos → seção
degrada para a busca por município sem quebrar). **Cada recorte diz por que a
fala entrou:** a tabela do município mostra "Menção a <cidade>"; a da região
mostra "Município da região citado"; a de tema mostra a coluna **"Tema"** —
porque essas falas foram selecionadas por tema, **não** por menção ao
município/região (não rotular como "marcada com o município" o que só foi
escolhido por tema).
aprofundamento — **sem "…"**. Por isso, escreva `summary` curto e
auto-contido para os itens que aparecem na página 1 (`prefeito`, `vice`,
`relacao_campo`, `vereadores`, `disputa_local`, `segundo_turno`): é a única
forma de a página 1 manter o texto que você redigiu.
- **Seções 2+:** `1. 2º turno no município (Lula × Flávio)` — números oficiais
do artefato, papel da cidade, a conta 2022 → 2026 em tabela e o movimento
local pesquisado, com o cenário rotulado como hipotético · `2. Rede e
lideranças` — a rede da base, os responsáveis, os pledges, as lideranças
locais pesquisadas (ex-prefeitos/vices, vereadores mais votados, com partido)
e a **Pauta das lideranças (pesquisa)** (hipótese a validar) + dobradinhas ·
`3. Conjuntura` — tendência política, forças, riscos, dobradinhas, próximos
passos e a **frente de oposição** quando pesquisada (fatos com fonte) ·
`4. Demografia` — IBGE Censo 2022 do artefato + complemento pesquisado
(cor/raça e poder aquisitivo) · `5. Atividade econômica (pesquisa)` ·
`6. Transporte e conexões (pesquisa)` · `7. Notícias e imprensa local` ·
`8. Abordagem sugerida (pesquisa)` · `9. Fontes e limites` — lista as fontes
e, antes delas, a tabela **"Pesquisa — respostas integrais"** com o texto
completo de cada item do checklist (o que a página 1 capou).
- **Numeração dinâmica:** o builder numera as seções depois de montá-las (o
`id` é estável; o número é posicional). Nunca referencie seção por número
fixo — use o nome/`id`.
- **Links clicáveis / fonte inline:** todo URL no PDF é um link (`<a href>`):
células de tabela, fontes por linha e a seção de fontes. Nos textos de pesquisa
(oposição, dobradinhas, investimentos, pauta das lideranças, abordagem,
células de tabela, fontes por linha e a seção de fontes. Nos textos de
pesquisa (oposição, dobradinhas, pauta das lideranças, abordagem,
demografia…), o `(fonte)` com hyperlink entra **inline no próprio texto, logo
após o trecho que cita o fato verificado** — não numa coluna. Um mesmo texto
pode citar mais de uma fonte: use `{{fonte}}` para a fonte principal
@ -430,47 +351,33 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
companion `.md` vira `[(fonte)](url)`. Se o texto não tiver token, o builder
acrescenta a(s) fonte(s) no fim do trecho. A coluna **Fonte** fica só com a
data e o link completo continua listado abaixo da tabela (`> Fontes:`).
- **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
layout automático e espreme/estoura a tabela.
texto mais longo — a maior fatia.
## Guardrails de produto (não negociáveis)
- **Sem fonte, não publica**: afirmação não trivial sem data+URL não entra.
- **Empenho ≠ pagamento** (defeso/ano eleitoral): o bloco de emendas mostra a
fase (empenhada, liquidada, paga, restos) e a advertência de defeso acompanha o
painel "O que anunciar agora".
- **Leitura relativa**: % do próprio voto, rank, LQ — nunca % estadual absoluto.
- **2º turno é cenário, não promessa**: X₁/X₂ são votos em jogo (fórmula do
`/potencial`), nunca transferência garantida; a parte determinística vem do
TSE oficial no artefato e o PDF declara a incerteza. Não se infere
comportamento individual (falácia ecológica) nem se propõe persuasão onde a
evidência diz efeito ~0: o papel de Solla é **mobilizar, proteger e dar voz**.
- **Leitura relativa**: delta e participação no recorte da unidade (nunca
número absoluto do estado apresentado como se fosse da cidade).
- **PII mínima**: contatos completos (telefone/e-mail) nunca entram; nomes de
lideranças entram porque o produto pede "quem é quem".
- **Completo para o candidato**: estimativas e nível N0–N4 entram no PDF, sem
marca de restrição; o que não sai é para o palanque.
- **Indício regional não é emenda da cidade**: emendas do município, da região
ou do polo entram rotuladas na evidência web e **nunca somadas**; a fonte
oficial ausente continua lacuna explícita.
- **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.
- **2º turno é cenário, não promessa**: X₁/X₂ são votos em jogo (fórmula do
`/potencial`), nunca transferência garantida; a seção é determinística (TSE
oficial no artefato) — o researcher pesquisa só o movimento local e o PDF
declara a incerteza. Não se infere comportamento individual (falácia
ecológica) nem se propõe persuasão onde a evidência diz efeito ~0: o papel de
Solla é mobilizar, proteger e dar voz.
- **Completo para o candidato**: nível N0–N4 e a leitura da base entram no PDF,
sem marca de restrição; o que não sai é para o palanque.
- **Abordagem é análise ancorada**: as sugestões das personas 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 e sinaliza temas de mão
dupla; onde a marca do adversário domina, o insumo é para contraste, não para
confronto frontal.
- **Falha isolada no lote**: cidade que falha (token inválido/ambíguo, pesquisa
sem fonte suficiente, snapshot×research mismatch) não cancela as demais; o
summary final diz explicitamente o que saiu e o que falhou (sucesso parcial
@ -489,17 +396,12 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
com fonte datada.
- **Página 1 estourou**: o builder aborta com `scrollHeight > útil` e o erro é
açãoável. A página 1 já aplica tetos de conteúdo aos textos da pesquisa
(`PAGE_ONE_*_MAX` em `scripts/lib/cityReportBlocks.mjs`) e preserva o texto
integral no aprofundamento; se ainda estourar, **aperte o teto da pesquisa ou
corte copy do resumo — nunca mexa no layout para "espremer"**. O bloco 2º turno
vive na seção própria (nunca na página 1) justamente porque é determinístico e
não encolhe no fallback.
(`PAGE_ONE_*` em `scripts/lib/cityReportBlocks.mjs`) e preserva o texto
integral no aprofundamento; se ainda estourar, **encurte os `summary` da
pesquisa ou os itens de lista — nunca mexa no layout para "espremer"**. O
conteúdo determinístico (KPIs e papel do 2º turno) não encolhe no fallback de
texto.
- **`tsx` ausente no homeserver**: `pnpm install --prod=false`.
- **API de emendas 429/erro**: o builder degrada para lacuna com URL+motivo;
reexecute depois (o cache só é escrito com resultado utilizável).
- **Emenda sem atribuição oficial**: é o comportamento esperado (a API não
expõe o município). Pesquise os indícios web (município/região/polo) no item
`emendas_web`; sem indício, a lacuna fica explícita — nunca zero silencioso.
- **Chromium**: vem do `@playwright/test`; se faltar binário,
`pnpm exec playwright install chromium`.
- **Snapshot de outro município**: o builder recusa (`municipalitySlug` ≠
@ -512,9 +414,10 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
## Referências
- Intenção: `docs/plans/relatorio-cidade-viagem.md`; impl:
`docs/plans/relatorio-cidade-viagem-impl.md`.
- Intenção (histórica, pré-refactor): `docs/plans/relatorio-cidade-viagem.md`;
impl: `docs/plans/relatorio-cidade-viagem-impl.md`.
- Lote + sub-agentes: `docs/plans/ops118-relatorio-cidade-lote-municipios-sub-agentes.md`
e `-impl.md`.
- Refactor de mobilização do 2º turno: `docs/changelog/2026-10-10-relatorio-cidade-mobilizacao-2turno.md`.
- Precedente de PDF: `scripts/build-solla-ceuci-salvador-report.mjs` (C157).
- Runbook: `docs/ops/teqo-1313-deploy.md` §C163.

View file

@ -1,9 +1,9 @@
---
description: Researcher por município — pesquisa web datada e escreve o research.json do relatório de cidade pré-viagem; devolve só o recibo curto (etapa do lote /relatorio-cidade)
description: Researcher por município — pesquisa web datada e escreve o research.json do relatório de cidade (mobilização do 2º turno Lula × Flávio); devolve só o recibo curto (etapa do lote /relatorio-cidade)
mode: subagent
---
# Relatório de cidade pré-viagem — researcher (por município)
# Relatório de cidade — mobilização do 2º turno — researcher (por município)
Você é a etapa **researcher** da skill `.agents/skills/relatorio-cidade/SKILL.md`
(fonte canônica: contrato dos JSONs, guardrails, checklist, recibo). Leia-a antes
@ -17,10 +17,13 @@ cidades em paralelo.
2. Fazer a **pesquisa web datada** e escrever
`data/relatorios-cidade/<slug>.research.json` no contrato da skill — item sem
`sourceUrl`/`sourceDate` não vai para o arquivo (vira lacuna explícita).
O checklist inclui **`segundo_turno`** — o movimento local da disputa Lula ×
Flávio na cidade (atos, palanques, posições de lideranças locais), com fonte
e data por fato. Os **números oficiais** de 2º turno (Lula/Flávio/Solla,
X₁/X₂) vêm do artefato do builder — não os pesquise nem os estime.
O relatório serve à **mobilização do 2º turno (Lula × Flávio)**: pesquise
quem mobiliza na cidade (prefeito/vice/palanque, lideranças), o movimento
local do 2º turno (`segundo_turno` — atos, palanques, posições de lideranças
locais), onde o campo adversário é forte (`disputa_local`), vereadores e
dobradas, notícias (≤90 dias) e imprensa/rádio local.
Os **números oficiais** de 2º turno (Lula/Flávio/Solla, X₁/X₂) vêm do
artefato do builder — não os pesquise nem os estime.
3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):
`slug`, `status`, `researchPath`, `researchedAt`, `itemCount`, `gapCount`,
`gaps`, `newsCount90d`, `weakSourceCount`, `failureReason?`. **Nunca** devolva
@ -34,8 +37,7 @@ cidades em paralelo.
- **Nunca** escreva na base de produção: não use `db:pull`/snapshot de banco.
- **Nunca** commite o `research.json`/PDF/MD/snapshot (dado interno; repo público
— artefato gitignored).
- Não invente fato sem fonte; não trate empenho como pagamento; não inclua
telefone/e-mail de liderança.
- Não invente fato sem fonte; não inclua telefone/e-mail de liderança.
- No 2º turno, não prometa transferência de voto nem infira comportamento
individual: o item `segundo_turno` é **fato datado** (ato, palanque, posição
pública), não análise de cenário nem estimativa de efeito.

View file

@ -1,5 +1,5 @@
---
description: Gera o relatório de cidade pré-viagem (PDF A4 + .md) para um município ou um lote separado por vírgula
description: Gera o relatório de cidade (mobilização do 2º turno Lula × Flávio; PDF A4 + .md) para um município ou um lote separado por vírgula
---
Carregue a skill `relatorio-cidade` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos, dispara um researcher por cidade, roda a extração read-only e o build por cidade e fecha o summary. A fonte canônica é `.agents/skills/relatorio-cidade/SKILL.md` — não a transcreva nem recrie o fluxo.

View file

@ -0,0 +1 @@
**Relatório de cidade vira ferramenta de mobilização do 2º turno (2026-10-10):** com a federal decidida em 04/10, a skill `relatorio-cidade`, o agente researcher, o builder e os testes foram refocados para a eleição de Lula — os blocos de mandato/pré-viagem saíram por decisão de produto: conta eleitoral e expectativa de cadeira de Solla, concorrentes e pré-candidatos, emendas oficiais e indícios web (cai o fetch do Portal da Transparência no builder), acervo de falas da Câmara, panorama regional (TI) e sinais/demandas/visitas; demografia, economia e transporte seguem como contexto da visita. A **página 1 agora é só mobilização**: identificação (território/prioridade/nível/responsável), célula **"2º turno — Lula × Flávio"** (KPIs oficiais Lula 2026 com Δ p.p., X₁/X₂, Solla 2026 + papel determinístico da cidade), célula **"Quem mobiliza"** (prefeito, vice, relação com o campo, lideranças, dobradinhas, vereadores), callout do **movimento local** (item de pesquisa `segundo_turno`), cards de riscos e o painel **"O que pedir e falar"** (pedido Lula 13 + guardrail "votos em jogo, não transferência garantida" no lugar da advertência de defeso). O checklist de pesquisa caiu de 10 para 8 itens (`emendas_web` e `quem_investe` saíram; itens `relacao_campo`/`disputa_local` reancorados no 2º turno); listas `preCandidates`/`investments`/`emendasIndicators` saíram do contrato (arquivos antigos continuam aceitos — campos aposentados são ignorados, compat de replay). O snapshot de produção ficou enxuto (rede + responsáveis + pledges + conjuntura + demografia; sem electoral/goal/signals/activities/speeches/demands/competitors) e o módulo `cityReportTerritory.mjs` foi removido. Testes: unit do research/blocks/render/batch-skill reescritos (64 verdes), int do snapshot atualizado (no PII, grupos aposentados ausentes), pin de retirada na skill (não reintroduzir `emendas_web`/`quem_investe`) e prova real com a fixture de Cruz das Almas — PDF/MD novos com 9 seções e 16 páginas (antes 21), página 1 fechando no orçamento sem fallback.

View file

@ -1243,7 +1243,7 @@ anterior de cada fala está em `updates[].previousUrl` no JSON do run. O import
(`pnpm camara:import --date <dia>`) reconstrói o link a partir da API (a fonte
é a URL legada devolvida pela Câmara); reexecutar o reparo é idempotente.
## C163 — relatório de cidade pré-viagem (leitura read-only)
## C163 — relatório de cidade — mobilização do 2º turno (leitura read-only)
O gerador de PDF por cidade (`/work-issue` C163) extrai um snapshot da **base
de produção** sem escrever nada: a sessão Postgres é aberta com
@ -1279,8 +1279,8 @@ carrega `readAt` + SHA do código — o PDF é datado por construção.
Guardas: sem `CITY_REPORT_CONFIRM=1` o extrator recusa; a URL com `options`
read-only é aplicada antes do Payload subir (qualquer write estoura no servidor);
`PORTAL_TRANSPARENCIA_API_KEY` é opcional (sem chave, emendas viram lacuna
explícita, nunca zero). Nunca aponte o extrator para o banco local esperando
o builder local não chama API externa (os números oficiais do 2º turno vêm do
artefato commitado). Nunca aponte o extrator para o banco local esperando
dados de produção — o caminho é o proxy do homeserver.
## C195 — ingestão do pacote do reel na biblioteca privada

View file

@ -1,9 +1,10 @@
/**
* City report builder (C163): snapshot + research (+ emendas) → PDF A4 + `.md`.
* City report builder (C163): snapshot + research → PDF A4 + `.md`.
*
* Runs on the workstation: no database access, no persistence. Emendas come
* from the official API at generation time (or from `--emendas=<json>` for a
* replay) and are never stored in the base.
* The report serves the 2º turno (Lula × Flávio): mobilization summary on page 1
* and the deep dive after it. Runs on the workstation: no database access, no
* persistence, no external API — the official 2º-turno numbers come from the
* committed artifact (`pnpm build:second-round`).
*
* Usage:
* NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-city-report.mjs \
@ -22,11 +23,6 @@ import { buildCityReport } from './lib/cityReportBlocks.mjs'
import { renderReportHtml, renderReportMd } from './lib/cityReportRender.mjs'
import { normalizeResearchInput } from './lib/cityReportResearch.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import {
DEFAULT_AUTHOR_NAME,
EMENDAS_YEARS,
fetchAuthorEmendas,
} from './lib/portalTransparenciaEmendas.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-city-report'
@ -49,16 +45,12 @@ loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null
const researchPath = typeof flags.research === 'string' ? flags.research : null
const emendasPath = typeof flags.emendas === 'string' ? flags.emendas : null
const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR
const authorName = typeof flags.author === 'string' ? flags.author : DEFAULT_AUTHOR_NAME
const generatedAt =
typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
if (!snapshotPath || !researchPath) {
die(
'Uso: --snapshot=<json> --research=<json> [--emendas=<json>] [--out-dir=<dir>] [--author=<nome>]',
)
die('Uso: --snapshot=<json> --research=<json> [--out-dir=<dir>]')
}
if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`)
@ -98,38 +90,6 @@ if (snapshot.municipality?.slug !== research.municipalitySlug) {
}
const baseName = `${snapshot.municipality.slug}-${generatedAt.toISOString().slice(0, 10)}`
const cachePath = join(CACHE_DIR, `${baseName}.emendas.json`)
const resolveEmendas = async () => {
if (emendasPath) return readJson(emendasPath, 'emendas')
try {
const cached = JSON.parse(await readFile(resolve(ROOT, cachePath), 'utf8'))
console.log(`[${LABEL}] emendas: cache ${cachePath}`)
return cached
} catch (error) {
if (error?.code !== 'ENOENT') {
die(
`Cache de emendas ilegível (${cachePath}): ${error instanceof Error ? error.message : error}`,
)
}
}
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(),
})
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, cachePath), `${JSON.stringify(result, null, 2)}\n`)
console.log(
`[${LABEL}] emendas: ${result.status}${result.status === 'gap' ? ` (${result.reason})` : ` (${result.rows.length} registros)`} → ${cachePath}`,
)
return result
}
const emendas = await resolveEmendas()
const pdfFile = join(outDir, `${baseName}.pdf`)
@ -141,7 +101,7 @@ const pdfFile = join(outDir, `${baseName}.pdf`)
* integral). The guard stays fail-closed if even the pointer does not fit.
*/
const buildArtifacts = (textFallback) => {
const report = buildCityReport({ snapshot, research, emendas, generatedAt, textFallback })
const report = buildCityReport({ snapshot, research, generatedAt, textFallback })
return { report, html: renderReportHtml(report), markdown: renderReportMd(report) }
}
let artifacts = buildArtifacts('full')
@ -208,9 +168,7 @@ console.log(`[${LABEL}] companion → ${mdFile}`)
console.log(`[${LABEL}] PDF → ${pdfFile}`)
console.log(
`[${LABEL}] ${snapshot.municipality.name}: 2022=${snapshot.electoral.rank2022?.votes ?? 'sem dado'} ` +
`rank=${snapshot.electoral.rank2022?.rank ?? '—'} emendas=${emendas.status} ` +
`lacunas_pesquisa=${research.gaps.length} html_bytes=${Buffer.byteLength(html)}`,
`[${LABEL}] ${snapshot.municipality.name}: lacunas_pesquisa=${research.gaps.length} html_bytes=${Buffer.byteLength(html)}`,
)
if (isTruthyEnv(process.env.CITY_REPORT_STRICT) && research.gaps.length > 0) {

View file

@ -6,242 +6,22 @@
* projects out PII and fields the report does not need, and returns plain
* JSON. The render step never touches the database again.
*
* Since the 2026-10-10 refocus the report serves the 2º turno (Lula × Flávio):
* the snapshot carries the network (leaderships, advisors, pledges), the base
* strategy (conjuncture) and the IBGE demographics — the official 2º-turno
* numbers come from the committed artifact, not from the database.
*
* Integration layer, not a pure lib: it imports Payload-coupled utilities and
* is exercised by `tests/int/cityReportSnapshot.int.spec.ts`.
*/
import { FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } from '../src/lib/electionResults.ts'
import { getMunicipalityCatalogEntry, municipalityCatalog } from '../src/lib/municipalityCatalog.ts'
import { getMunicipalityVoteRank } from '../src/lib/municipalityVoteRank.ts'
import { normalizeForSearch } from '../src/lib/speechSearch.ts'
import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts'
import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts'
import {
municipalityElectionGeography,
municipalityGeographyWhere,
} from '../src/utilities/municipality/municipalityElectionGeography.ts'
import {
getMunicipalityDetailViewModel,
resolveAccessibleMunicipalityContext,
} from '../src/utilities/municipality/municipalityPageData.ts'
import { loadAdvisorSummaries } from '../src/utilities/municipality/municipalityViewModels.ts'
import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData.ts'
const SPEECH_LIMIT = 10
const DEMAND_LIMIT = 20
const COMPETITOR_LIMIT = 5
/**
* Region-relevant speech themes: the acervo search for a small município is
* nearly always empty, so the report also shows what the deputy said about the
* REGION and about the themes that matter there. These are the enum values from
* `SPEECH_TOPICS` (src/lib/speechFacets.ts).
*/
const SPEECH_REGION_TOPICS = [
'saude',
'educacao',
'agricultura',
'infraestrutura',
'meio-ambiente',
'cultura',
'economia-trabalho',
'habitacao-cidades',
]
/** Data value of the office enum (only the federal constant is exported today). */
const STATE_DEPUTY_OFFICE = 'deputado_estadual'
const REFERENCE_YEAR = 2022
const projectActivity = (activity) => ({
id: activity.id,
title: activity.title,
status: activity.status,
startAt: activity.startAt,
locality: activity.locality,
})
const SPEECH_SELECT = {
speechAt: true,
type: true,
phase: true,
summary: true,
officialTextUrl: true,
youtubeUrl: true,
vodPlaybackUrl: true,
excerptTMs: true,
eventStartAt: true,
mentionedMunicipalities: true,
topics: true,
}
/**
* Projects one speech for the report: the official summary says what the
* speech is; `mentionExcerpt` is the passage that literally names the matched
* município (null when the acervo tag has no literal mention in the ASR
* segments); `matchedMunicipality` is which name matched; the count gives the
* "tagged among dozens" context; `topics` carries the facet themes.
*/
const projectSpeechRow = (doc, segments, matchNames) => {
const candidates = matchNames
.map((name) => ({ name, key: normalizeForSearch(name) }))
.filter((candidate) => candidate.key)
const matched = candidates.find(({ key }) =>
segments.some((segment) => normalizeForSearch(segment.text).includes(key)),
)
const mentionSegment = matched
? segments.find((segment) => normalizeForSearch(segment.text).includes(matched.key))
: undefined
return {
id: doc.id,
speechAt: doc.speechAt ?? null,
type: doc.type ?? null,
phase: doc.phase ?? null,
summary: doc.summary ?? null,
officialTextUrl: doc.officialTextUrl ?? null,
youtubeUrl: doc.youtubeUrl ?? null,
vodPlaybackUrl: doc.vodPlaybackUrl ?? null,
/** Offset of the excerpt inside the session video (YouTube start). */
youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
mentionExcerpt: mentionSegment?.text ?? null,
matchedMunicipality: matched?.name ?? null,
mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities)
? doc.mentionedMunicipalities.length
: 0,
topics: Array.isArray(doc.topics) ? doc.topics : [],
}
}
/**
* Speeches for the report, in three non-overlapping groups: the município, the
* rest of its Território de Identidade (mantido separado — região não é a
* cidade) and the region-relevant themes. A speech already shown in a more
* specific group is never repeated in the next one.
*/
const loadSpeeches = async (payload, actor, { municipalityID, municipalityName, region }) => {
const find = (state) =>
payload.find({
collection: 'speech',
where: buildSpeechListWhere({ page: 1, ...state }),
depth: 0,
limit: SPEECH_LIMIT,
sort: '-speechAt',
select: SPEECH_SELECT,
user: actor,
overrideAccess: false,
})
const cityResult = await find({ municipalities: [municipalityID] })
const cityIDs = new Set(cityResult.docs.map((doc) => doc.id))
const regionIDs = region?.ids ?? []
const regionResult = regionIDs.length
? await find({ municipalities: regionIDs })
: { docs: [], totalDocs: 0 }
const regionDocs = regionResult.docs.filter((doc) => !cityIDs.has(doc.id))
const seen = new Set([...cityIDs, ...regionDocs.map((doc) => doc.id)])
const topicResult = await find({ topics: SPEECH_REGION_TOPICS })
const topicDocs = topicResult.docs.filter((doc) => !seen.has(doc.id))
const allDocs = [...cityResult.docs, ...regionDocs, ...topicDocs]
const segmentsBySpeech = await loadSegmentsForSpeeches(
payload,
actor,
allDocs.map((doc) => doc.id),
)
const segmentsOf = (doc) => segmentsBySpeech.get(doc.id) ?? []
const cityNames = municipalityName ? [municipalityName] : []
const regionNames = region?.names ?? []
return {
totalCount: cityResult.totalDocs,
rows: cityResult.docs.map((doc) => projectSpeechRow(doc, segmentsOf(doc), cityNames)),
region: {
label: region?.label ?? null,
totalCount: regionDocs.length,
rows: regionDocs.map((doc) => projectSpeechRow(doc, segmentsOf(doc), regionNames)),
},
topics: {
themes: SPEECH_REGION_TOPICS,
totalCount: topicDocs.length,
rows: topicDocs.map((doc) => projectSpeechRow(doc, segmentsOf(doc), cityNames)),
},
}
}
const loadDemands = async (payload, actor, municipalityID) => {
const result = await payload.find({
collection: 'campaignDemand',
where: { municipality: { equals: municipalityID } },
depth: 0,
limit: DEMAND_LIMIT,
sort: '-updatedAt',
select: { title: true, kind: true, status: true, updatedAt: true },
user: actor,
overrideAccess: false,
})
return {
totalCount: result.totalDocs,
rows: result.docs.map((doc) => ({
id: doc.id,
title: doc.title,
kind: doc.kind,
status: doc.status,
updatedAt: doc.updatedAt,
})),
}
}
/**
* Top federal/state deputy candidates by votes inside the município (reference
* year), with the 2014/2018/2022 series — the report's "main competitors"
* table. Same TSE collection the app's comparison card reads.
*/
const loadCompetitors = async (payload, actor, slug, office) => {
const entry = getMunicipalityCatalogEntry(slug)
if (!entry) return { office, rows: [] }
const result = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
{ year: { in: [...HISTORICAL_SERIES_YEARS] } },
{ office: { equals: office } },
{ turn: { equals: '1' } },
{ voteType: { equals: 'nominal' } },
municipalityGeographyWhere(municipalityElectionGeography(entry)),
],
},
depth: 0,
limit: 0,
pagination: false,
select: { year: true, candidateNumber: true, candidateName: true, party: true, votes: true },
user: actor,
overrideAccess: false,
})
const byCandidate = new Map()
for (const row of result.docs) {
const current = byCandidate.get(row.candidateNumber) ?? {
candidateNumber: row.candidateNumber,
name: row.candidateName,
party: row.party ?? null,
votesByYear: {},
}
current.votesByYear[String(row.year)] =
(current.votesByYear[String(row.year)] ?? 0) + (row.votes ?? 0)
byCandidate.set(row.candidateNumber, current)
}
const rows = [...byCandidate.values()]
.sort(
(left, right) =>
(right.votesByYear[String(REFERENCE_YEAR)] ?? 0) -
(left.votesByYear[String(REFERENCE_YEAR)] ?? 0) ||
left.candidateNumber - right.candidateNumber,
)
.slice(0, COMPETITOR_LIMIT)
return { office, year: REFERENCE_YEAR, rows }
}
/**
* Composes the snapshot. `actor` must be a real `campaignUser` doc (the CLI
@ -267,44 +47,9 @@ export const composeCityReportSnapshot = async ({
const context = await resolveAccessibleMunicipalityContext(payload, actor, slug)
const detail = await getMunicipalityDetailViewModel(payload, context, actor)
const dossier = await loadMunicipalityDossierData(payload, actor, detail)
const catalogEntry = getMunicipalityCatalogEntry(slug)
const regionSlugs = catalogEntry
? municipalityCatalog.filter((row) => row.region === catalogEntry.region).map((row) => row.slug)
: []
const regionResult = regionSlugs.length
? await payload.find({
collection: 'municipality',
where: { slug: { in: regionSlugs } },
depth: 0,
limit: 0,
pagination: false,
select: { slug: true, name: true },
user: actor,
overrideAccess: false,
})
: { docs: [] }
const regionDocs = regionResult.docs.filter((doc) => doc.slug !== slug)
const region = {
label: detail.region ?? catalogEntry?.region ?? null,
ids: regionDocs.map((doc) => doc.id).filter((id) => id !== context.id),
names: regionDocs.map((doc) => doc.name).filter(Boolean),
}
const [advisors, speeches, demands, federalCompetitors, stateCompetitors] = await Promise.all([
loadAdvisorSummaries(payload, actor, detail.advisorIDs),
loadSpeeches(payload, actor, {
municipalityID: context.id,
municipalityName: detail.city,
region,
}),
loadDemands(payload, actor, context.id),
loadCompetitors(payload, actor, slug, FEDERAL_DEPUTY_OFFICE),
loadCompetitors(payload, actor, slug, STATE_DEPUTY_OFFICE),
])
const advisors = await loadAdvisorSummaries(payload, actor, detail.advisorIDs)
const strategy = detail.strategy
const rank2022 = getMunicipalityVoteRank(slug)
return {
meta: {
@ -326,17 +71,6 @@ export const composeCityReportSnapshot = async ({
region: detail.region,
ibgeCode: detail.ibgeCode,
},
electoral: {
series: dossier.baseline?.series ?? [],
rank2022,
},
goal: dossier.goalAccount
? {
suggestedGoal: dossier.goalAccount.suggestedGoal,
goalCoverage: dossier.goalAccount.goalCoverage,
territorialClass: { class: dossier.goalAccount.territorialClass.class },
}
: null,
pledges: {
declaredTotal: dossier.pledgeAggregate.declaredTotal,
lastPledgeAt: dossier.pledgeAggregate.lastPledgeAt,
@ -353,26 +87,9 @@ export const composeCityReportSnapshot = async ({
})),
},
advisors: advisors.map((advisor) => ({ id: advisor.id, name: advisor.name })),
signals: {
totalCount: dossier.signals.totalCount,
rows: dossier.signals.rows.map((row) => ({
id: row.id,
createdAt: row.createdAt,
body: row.body,
polarity: row.polarity,
urgent: row.urgent,
adversarySignal: row.adversarySignal,
responsibleName: row.responsibleName,
})),
},
activities: {
upcoming: dossier.upcomingActivities.map(projectActivity),
recent: dossier.recentActivities.map(projectActivity),
},
conjuncture: strategy
? {
priority: strategy.priority,
expectedVotes: strategy.expectedVotes,
politicalTrend: strategy.politicalTrend,
engagementLevel: strategy.engagementLevel,
levelNote: strategy.levelNote,
@ -380,7 +97,6 @@ export const composeCityReportSnapshot = async ({
risks: strategy.risks,
dobradinhaNotes: strategy.dobradinhaNotes,
nextSteps: strategy.nextSteps,
budgetNotes: strategy.budgetNotes,
stateDeputies: strategy.stateDeputies.map((deputy) => ({
id: deputy.id,
name: deputy.name,
@ -388,13 +104,6 @@ export const composeCityReportSnapshot = async ({
})),
}
: null,
speeches,
demands,
competitors: {
referenceYear: REFERENCE_YEAR,
federal: federalCompetitors.rows,
state: stateCompetitors.rows,
},
demographics: dossier.demographics,
}
}

View file

@ -74,8 +74,7 @@ try {
await writeFile(outPath, `${JSON.stringify(snapshot, null, 2)}\n`)
console.log(
`[${LABEL}] snapshot de ${snapshot.municipality.name} → ${out} ` +
`(lideranças=${snapshot.leaderships.totalCount} sinais=${snapshot.signals.totalCount} ` +
`falas=${snapshot.speeches.totalCount} demandas=${snapshot.demands.totalCount})`,
`(lideranças=${snapshot.leaderships.totalCount} assessores=${snapshot.advisors.length})`,
)
} catch (error) {
die(error instanceof Error ? error.message : String(error))

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
* companion cannot drift from the PDF.
*/
import { formatDateBr, formatDateTimeBr, formatInteger } from './cityReportFormat.mjs'
import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs'
import {
htmlEscape,
htmlWithLinks,
@ -111,6 +111,7 @@ const PRINT_CSS = `
.callout.gap { border-left-color: #71717a; background: #f4f4f5; }
.callout.decision { border-left-color: #15803d; background: #f0fdf4; }
.callout.risk { border-left-color: #b45309; background: #fffbeb; }
.callout.info { border-left-color: #1d4ed8; background: #eff6ff; }
.callout h4 { margin: 0 0 1mm; font-size: 8.6pt; }
.callout p { margin: 0 0 .8mm; }
.panel { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; }
@ -120,11 +121,6 @@ const PRINT_CSS = `
.panel ul, .bullets { margin: 0; padding-left: 4mm; }
.panel li, .bullets li { margin-bottom: .8mm; }
.panel-note { color: #92400e; font-size: 7.6pt; margin: 1.2mm 0 0; }
.indicators { margin: 0; padding-left: 4mm; }
.indicators li { margin-bottom: .8mm; }
.indicators .sphere { font-size: 6.6pt; text-transform: uppercase; letter-spacing: .06em; border: .6px solid #a1a1aa; border-radius: 1.2mm; padding: 0 1mm; color: #52525b; }
.indicators .sphere-regiao, .indicators .sphere-polo { border-color: #b45309; color: #92400e; background: #fffbeb; }
.indicators .indicator-source { color: #71717a; font-size: 7.2pt; }
table { width: 100%; border-collapse: collapse; font-size: 8pt; }
table.table-fixed { table-layout: fixed; }
table.table-fixed td, table.table-fixed th { overflow-wrap: anywhere; }
@ -223,31 +219,6 @@ const renderPanel = (block) => {
}`
}
/**
* Emenda indications (C163 page 1): one row per author/what/esfera, each with
* its own source link and a date. The esfera badge is explicit — região/polo are
* never summed as the município's own emenda.
*/
const renderIndicatorList = (block) => {
const items = block.items
.map((item) => {
const detail = item.detail ? ` — ${htmlEscape(item.detail)}` : ''
const link = item.source?.url
? `<a class="inline-source" href="${htmlEscape(item.source.url)}">(fonte)</a>`
: ''
const date = item.source?.date ? ` · ${htmlEscape(formatDateBr(item.source.date))}` : ''
return `<li><strong>${htmlEscape(item.author)}</strong>${detail} <span class="sphere sphere-${htmlEscape(item.sphere)}">${htmlEscape(item.sphereLabel)}</span> <span class="indicator-source">${link}${date}</span></li>`
})
.join('')
const more =
block.remaining > 0
? `<li>e mais ${formatInteger(block.remaining)} indício(s) — texto integral nas fontes.</li>`
: ''
return `<ul class="indicators">${items}${more}</ul>${
block.note ? `<p class="table-note">${htmlEscape(block.note)}</p>` : ''
}`
}
const renderTable = (block) => {
const fixed = block.columns.some((column) => column.width)
const head = block.columns
@ -331,9 +302,6 @@ const renderBlockHtml = (block, sectionTitle = null) => {
case 'panel':
body = renderPanel(block)
break
case 'indicatorList':
body = renderIndicatorList(block)
break
case 'table':
body = renderTable(block)
break
@ -371,7 +339,7 @@ export const renderReportHtml = (report) => {
<div>
<p class="kicker">${htmlEscape(report.meta.title)}</p>
<h1>${htmlEscape(report.meta.municipalityName)}</h1>
<p class="sub">${htmlEscape(report.meta.region)} · prioridade e classe na leitura da base</p>
<p class="sub">${htmlEscape(report.meta.region)} · leitura da base Teqo e do artefato TSE 2026</p>
</div>
<div class="meta">
<p>Gerado em ${htmlEscape(report.meta.generatedAtLabel)}</p>
@ -459,17 +427,6 @@ const mdBlock = (block) => {
}
if (block.note) lines.push(`\n> ${block.note}`)
break
case 'indicatorList':
lines.push(title)
for (const item of block.items) {
const detail = item.detail ? ` — ${item.detail}` : ''
const src = item.source?.url ? ` [(fonte)](${item.source.url})` : ''
const date = item.source?.date ? ` · ${formatDateBr(item.source.date)}` : ''
lines.push(`- **${item.author}**${detail} · _${item.sphereLabel}_${src}${date}`)
}
if (block.remaining > 0) lines.push(`- e mais ${block.remaining} indício(s).`)
if (block.note) lines.push(`\n> ${block.note}`)
break
case 'table': {
lines.push(title)
lines.push(`| ${block.columns.map((column) => column.label).join(' | ')} |`)

View file

@ -14,29 +14,24 @@ const RESEARCH_NEWS_WINDOW_DAYS = 90
const RESEARCH_CHECKLIST = [
{ id: 'prefeito', label: 'Prefeito(a) — nome, partido, federação, situação' },
{ id: 'vice', label: 'Vice-prefeito(a) — nome, partido' },
{ id: 'relacao_campo', label: 'Relação com o campo (aliado, oposição, neutro)' },
{
id: 'relacao_campo',
label: 'Relação com o campo no 2º turno (Lula × Flávio) — palanque, apoio ou oposição',
},
{ id: 'vereadores', label: 'Vereadores e dobradas' },
{ id: 'disputa_local', label: 'Disputa local' },
{ id: 'disputa_local', label: 'Disputa local — força do campo adversário' },
{
id: 'segundo_turno',
label: '2º turno (Lula × Flávio) — atos, palanques e posições locais',
},
{ id: 'quem_investe', label: 'Quem mais investe na cidade (bancada e adversários)' },
{ id: 'noticias', label: 'Notícias da cidade/região (janela ≤90 dias)' },
{ id: 'imprensa_local', label: 'Imprensa e rádio local' },
{
id: 'emendas_web',
label: 'Emendas — indícios web (município, região ou polo)',
},
]
export const RESEARCH_CHECKLIST_IDS = RESEARCH_CHECKLIST.map((item) => item.id)
const checklistById = new Map(RESEARCH_CHECKLIST.map((item) => [item.id, item]))
/** Where an emenda indication points to: the município itself, its region or the regional pole. */
const EMENDA_SPHERES = ['municipio', 'regiao', 'polo']
export const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== ''
export const isValidDate = (value) => isNonEmptyString(value) && !Number.isNaN(Date.parse(value))
@ -228,39 +223,6 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
})
}
const preCandidates = []
for (const entry of Array.isArray(raw.preCandidates) ? raw.preCandidates : []) {
const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null
const office = isNonEmptyString(entry?.office) ? entry.office.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!name || !office) {
gaps.push({
id: 'precandidato_incompleto',
label: name,
reason: 'Pré-candidato sem nome ou cargo — descartado.',
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'precandidato_sem_fonte',
label: name,
reason: 'Pré-candidato sem fonte: URL e data são obrigatórias.',
})
continue
}
preCandidates.push({
name,
office,
party: isNonEmptyString(entry.party) ? entry.party.trim() : null,
support: isNonEmptyString(entry.support) ? entry.support.trim() : null,
note: isNonEmptyString(entry.note) ? entry.note.trim() : null,
sourceUrl,
sourceDate,
})
}
const leaders = []
for (const entry of Array.isArray(raw.leaders) ? raw.leaders : []) {
const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null
@ -341,7 +303,6 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
transport: [],
opposition: [],
alliances: [],
investments: [],
}
for (const key of Object.keys(sourcedLists)) {
for (const entry of Array.isArray(raw[key]) ? raw[key] : []) {
@ -376,61 +337,19 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
}
}
/**
* Structured emenda indications (município/região/polo). The page-1 block is
* "quem indica o quê, para onde e com que fonte" — the free-text `emendas_web`
* synthesis alone cannot show the value/esfera per author. Every row needs a
* source; without it the row is dropped into an explicit gap.
*/
const emendasIndicators = []
for (const entry of Array.isArray(raw.emendasIndicators) ? raw.emendasIndicators : []) {
const author = isNonEmptyString(entry?.author) ? entry.author.trim() : null
const sphere = isNonEmptyString(entry?.sphere) ? entry.sphere.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!author || !sphere || !EMENDA_SPHERES.includes(sphere)) {
gaps.push({
id: 'emenda_indicio_incompleto',
label: author,
reason: `Indício de emenda sem autor ou esfera (${EMENDA_SPHERES.join('/')}) — descartado.`,
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'emenda_indicio_sem_fonte',
label: author,
reason: 'Indício de emenda sem fonte: URL e data são obrigatórias.',
})
continue
}
emendasIndicators.push({
author,
sphere,
value: isNonEmptyString(entry.value) ? entry.value.trim() : null,
purpose: isNonEmptyString(entry.purpose) ? entry.purpose.trim() : null,
year: isNonEmptyString(entry.year) ? entry.year.trim() : null,
sourceUrl,
sourceDate,
})
}
return {
municipalitySlug: raw.municipalitySlug.trim(),
researchedAt: researchedAt.toISOString(),
items,
news,
approach,
preCandidates,
leaders,
leaderAgenda,
emendasIndicators,
demography: sourcedLists.demography,
economy: sourcedLists.economy,
transport: sourcedLists.transport,
opposition: sourcedLists.opposition,
alliances: sourcedLists.alliances,
investments: sourcedLists.investments,
gaps,
}
}

View file

@ -1,68 +0,0 @@
/**
* Territory panorama for the city report (C163) — where the município sits
* inside its Território de Identidade, read from the committed TSE artifact.
*
* Pure by construction: no Payload, no network. The artifact is the same source
* the app's rank/class cards read, so the PDF can never drift from the base.
*/
import {
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
} from '../../src/lib/bahiaElectionAggregates.ts'
import {
getMunicipalityCatalogEntry,
municipalityCatalog,
} from '../../src/lib/municipalityCatalog.ts'
const DEFAULT_PANORAMA_YEAR = 2022
const votesFor = (slug, year) =>
Number(getMunicipalityFederalBaseline(slug).votesByYear[String(year)] ?? 0)
/**
* Region rollup + the município's dense rank inside it, for one year.
* Returns null for a slug outside the catalog (never invent a region).
*/
export const buildTerritoryPanorama = (slug, year = DEFAULT_PANORAMA_YEAR) => {
const entry = getMunicipalityCatalogEntry(slug)
if (!entry) return null
const regionSlugs = municipalityCatalog
.filter((row) => row.region === entry.region)
.map((row) => row.slug)
const regionVotes = regionSlugs.reduce(
(total, regionSlug) => total + votesFor(regionSlug, year),
0,
)
const statewide = getStatewideFederalTotals(year)
const rows = regionSlugs
.map((regionSlug) => ({ slug: regionSlug, votes: votesFor(regionSlug, year) }))
.sort((left, right) => {
if (right.votes !== left.votes) return right.votes - left.votes
return left.slug.localeCompare(right.slug)
})
let rank = 0
let previousVotes
let municipalityRank = null
for (const row of rows) {
if (row.votes !== previousVotes) {
rank += 1
previousVotes = row.votes
}
if (row.slug === slug) municipalityRank = rank
}
return {
region: entry.region,
year,
municipalityCount: regionSlugs.length,
regionVotes,
regionShare: statewide.ownVotes > 0 ? regionVotes / statewide.ownVotes : null,
municipalityVotes: votesFor(slug, year),
municipalityRank,
municipalityTotalUnits: rows.length,
}
}

View file

@ -9,7 +9,7 @@
const EMENDAS_API_URL = 'https://api.portaldatransparencia.gov.br/api-de-dados/emendas'
export const DEFAULT_AUTHOR_NAME = 'JORGE SOLLA'
export const EMENDAS_YEARS = [2023, 2024, 2025, 2026]
const EMENDAS_YEARS = [2023, 2024, 2025, 2026]
const normalizeAuthorName = (value) =>
String(value ?? '')

View file

@ -67,7 +67,6 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/cityReportFormat.mjs',
'scripts/lib/cityReportRender.mjs',
'scripts/lib/cityReportResearch.mjs',
'scripts/lib/cityReportTerritory.mjs',
'scripts/lib/cli.mjs',
'scripts/lib/colinhaReel.mjs',
'scripts/lib/colinhaReelTemplate.mjs',

View file

@ -29,54 +29,63 @@ describe('city report snapshot (C163)', () => {
payload = await getPayload({ config: await config })
})
it('composes a read-only snapshot without contact PII', { timeout: 30_000 }, async () => {
const fixtures = campaignFixtures()
const candidate = await fixtures.createCampaignUser('candidate')
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
it(
'composes the mobilization snapshot (network + conjuncture + demographics), no PII',
{ timeout: 30_000 },
async () => {
const fixtures = campaignFixtures()
const candidate = await fixtures.createCampaignUser('candidate')
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
})
await fixtures.createMunicipalityUpdate({
municipality: municipality.id,
author: candidate.id,
polarity: 'boa',
body: 'Sinal de teste do snapshot',
})
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
})
const actor = { ...candidate, collection: 'campaignUser' as const }
const snapshot = await composeCityReportSnapshot({
payload,
actor,
slug: municipality.slug,
codeSha: 'test-sha',
database: 'localhost:5432/teqo_wt_test',
})
expect(snapshot.meta).toEqual(
expect.objectContaining({
readOnly: true,
const actor = { ...candidate, collection: 'campaignUser' as const }
const snapshot = await composeCityReportSnapshot({
payload,
actor,
slug: municipality.slug,
codeSha: 'test-sha',
actorRole: 'candidate',
}),
)
expect(snapshot.municipality.slug).toBe(municipality.slug)
expect(snapshot.advisors.map((advisorRow: { name: string }) => advisorRow.name)).toContain(
advisor.name,
)
expect(snapshot.leaderships.totalCount).toBeGreaterThan(0)
const row = snapshot.leaderships.rows.find(
(leadershipRow) => leadershipRow.id === leadership.id,
)!
expect(row.name).toBe(contact.name)
expect('phone' in row).toBe(false)
expect('email' in row).toBe(false)
expect('contactID' in row).toBe(false)
expect(snapshot.signals.totalCount).toBeGreaterThan(0)
expect(JSON.stringify(snapshot)).not.toContain(contact.phone)
})
database: 'localhost:5432/teqo_wt_test',
})
expect(snapshot.meta).toEqual(
expect.objectContaining({
readOnly: true,
codeSha: 'test-sha',
actorRole: 'candidate',
}),
)
expect(snapshot.municipality.slug).toBe(municipality.slug)
expect(snapshot.advisors.map((advisorRow: { name: string }) => advisorRow.name)).toContain(
advisor.name,
)
expect(snapshot.leaderships.totalCount).toBeGreaterThan(0)
const row = snapshot.leaderships.rows.find(
(leadershipRow) => leadershipRow.id === leadership.id,
)!
expect(row.name).toBe(contact.name)
expect('phone' in row).toBe(false)
expect('email' in row).toBe(false)
expect('contactID' in row).toBe(false)
expect(JSON.stringify(snapshot)).not.toContain(contact.phone)
// The retired mandate-era groups never enter the snapshot again.
for (const retired of [
'electoral',
'goal',
'signals',
'activities',
'speeches',
'demands',
'competitors',
]) {
expect(retired in snapshot).toBe(false)
}
},
)
})

View file

@ -198,7 +198,7 @@ describe('ciSkipInvariants', () => {
// Self-check the walker sees real edges — direct and transitive — or the
// equality below would fail for the wrong reason.
expect(closure.has('scripts/lib/worktree-env.mjs')).toBe(true)
expect(closure.has('scripts/lib/cityReportTerritory.mjs')).toBe(true)
expect(closure.has('scripts/lib/cityReportRender.mjs')).toBe(true)
const missing = [...closure].filter((path) => !SCRIPTS_SPEC_PINNED.includes(path)).sort()
const stale = SCRIPTS_SPEC_PINNED.filter((path) => !closure.has(path)).sort()

View file

@ -59,9 +59,9 @@ describe('finite researcher receipt (OPS118)', () => {
})
})
describe('skill /relatorio-cidade carries the 2º turno mission', () => {
describe('skill /relatorio-cidade carries the 2º turno mobilization mission', () => {
it('documents the mission, the artifact and the role vocabulary', () => {
expect(skill).toContain('## Missão do 2º turno (Lula × Flávio — 25/10/2026)')
expect(skill).toContain('## Missão — 2º turno (Lula × Flávio — 25/10/2026)')
expect(skill, 'the new checklist item must be declared').toContain('segundo_turno')
expect(skill, 'the artifact build command is pinned').toContain('pnpm build:second-round')
expect(skill, 'the committed artifact path is pinned').toContain('bahia-second-round-2026.json')
@ -73,6 +73,16 @@ describe('skill /relatorio-cidade carries the 2º turno mission', () => {
}
})
it('retires the mandate-era report blocks from the skill contract', () => {
for (const retired of ['emendas_web', 'quem_investe', 'preCandidates', 'emendasIndicators']) {
expect(skill, `skill must not reintroduce ${retired}`).not.toContain(retired)
expect(researcher, `researcher must not reintroduce ${retired}`).not.toContain(retired)
}
expect(skill, 'the refocus is explicit in the skill').toContain(
'fora de escopo desde o refactor',
)
})
it('makes the researcher research the local 2º turno movement, not the official numbers', () => {
expect(researcher).toContain('segundo_turno')
expect(researcher, 'official numbers come from the artifact').toMatch(

View file

@ -22,7 +22,7 @@ const research = normalizeResearchInput(
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) =>
id === 'emendas_web'
id === 'prefeito'
? validItem(id, {
extraSources: [
{
@ -51,16 +51,6 @@ const research = normalizeResearchInput(
sourceDate: '2026-09-10',
},
],
preCandidates: [
{
name: 'Deputado do Prefeito',
office: 'Deputado federal',
party: 'PSDB',
support: 'Prefeito Jorge Almeida',
sourceUrl: 'https://exemplo.test/pre-candidato',
sourceDate: '2026-04-18',
},
],
leaders: [
{
name: 'Ex-Prefeito',
@ -71,6 +61,15 @@ const research = normalizeResearchInput(
sourceDate: '2026-03-05',
},
],
leaderAgenda: [
{
name: 'Ex-Prefeito',
field: 'Mesmo campo',
topics: 'Saúde (hospital municipal)',
sourceUrl: 'https://exemplo.test/pauta',
sourceDate: '2026-09-01',
},
],
demography: [
{
topic: 'Cor/raça',
@ -97,8 +96,8 @@ const research = normalizeResearchInput(
],
opposition: [
{
topic: 'Emenda sob investigação (PF)',
detail: 'Operação apura desvio em emendas do campo adversário.',
topic: 'Obra parada no campo adversário',
detail: 'Anúncio sem entrega na região.',
sourceUrl: 'https://exemplo.test/oposicao',
sourceDate: '2026-09-10',
},
@ -111,62 +110,11 @@ const research = normalizeResearchInput(
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',
},
],
emendasIndicators: [
{
author: 'Zé Neto',
sphere: 'municipio',
value: 'R$ 1 mi',
purpose: 'Ambulância do TFD',
year: '2026',
sourceUrl: 'https://exemplo.test/ze-neto',
sourceDate: '2026-09-01',
},
{
author: 'Deputado do Polo',
sphere: 'polo',
value: 'R$ 500 mil',
sourceUrl: 'https://exemplo.test/polo-emenda',
sourceDate: '2026-09-01',
},
],
gaps: [],
},
{ now: generatedAt },
)
const emendas = {
status: 'ok',
reason: null,
detail: null,
sourceUrl: 'https://api.portaldatransparencia.gov.br/api-de-dados/emendas?nomeAutor=JORGE+SOLLA',
consultedAt: '2026-09-15T11:00:00.000Z',
requestCount: 4,
years: [2023, 2024, 2025, 2026],
authorName: 'JORGE SOLLA',
authorCode: '1234',
rows: [
{
code: '202312340001',
year: 2023,
type: 'Individual',
authorCode: '1234',
empenhado: 1_000_000,
liquidado: 500_000,
pago: 250_000,
restoPago: 10_000,
},
],
totals: { empenhado: 1_000_000, liquidado: 500_000, pago: 250_000, restoPago: 10_000 },
}
const snapshot = {
meta: {
source: 'Base Teqo (produção, leitura read-only)',
@ -187,41 +135,9 @@ const snapshot = {
city: 'Feira de Santana',
region: 'Portal do Sertão',
ibgeCode: '2910800',
tseCityCode: '31270',
zoneNumber: null,
tseZones: [12, 13],
lastUpdateAt: '2026-09-01T10:00:00.000Z',
},
electoral: {
candidateName: 'Jorge Solla',
candidateParty: 'PT',
series: [
{ year: 2014, votes: 30_000 },
{ year: 2018, votes: 40_000 },
{ year: 2022, votes: 50_000 },
],
tally2022: { aptos: 400_000, comparecimento: 300_000, votosValidos: 280_000 },
ticket2022: { president: 100_000, governor: 90_000 },
rank2022: { rank: 3, votes: 50_000, share: 0.05, totalUnits: 435 },
},
goal: {
suggestedGoal: 60_000,
goalCoverage: { goal: 60_000, committed: 15_000, coverageRatio: 0.25, deficit: 45_000 },
territorialClass: {
class: 'expansao',
factors: [],
lq: 1.2,
ownShare: 0.05,
inCoreBlock: true,
},
potential: {},
territoryCaptureBenchmark: {},
},
pledges: {
declaredTotal: 15_000,
effectiveByScenario: { pessimistic: 10_000, central: 15_000, optimistic: 20_000 },
pledgeCount: 7,
missingEstimateCount: 1,
lastPledgeAt: '2026-08-30T10:00:00.000Z',
},
leaderships: {
@ -231,50 +147,15 @@ const snapshot = {
id: 1,
name: 'Liderança Um',
supportStatus: 'engajado',
exclusive: true,
organizationNames: ['Associação X'],
stateDeputies: [{ name: 'Deputada Estadual' }],
hasAppAccess: true,
updatedAt: '2026-09-10T10:00:00.000Z',
},
],
},
advisors: [{ id: 2, name: 'Seed Assessor' }],
signals: {
totalCount: 1,
rows: [
{
id: 10,
createdAt: '2026-09-12T10:00:00.000Z',
body: 'Sinal recente sobre a cidade',
polarity: 'boa',
urgent: false,
adversarySignal: false,
responsibleName: 'Seed Assessor',
activeVolunteers: 10,
newSupports: 2,
resolvedAt: null,
},
],
},
activities: {
upcoming: [
{
id: 20,
title: 'Visita ao centro',
status: 'confirmado',
startAt: '2026-09-20T13:00:00.000Z',
endAt: null,
locality: 'Centro',
deputyPresent: true,
taskProgress: { done: 1, total: 2 },
},
],
recent: [],
},
conjuncture: {
priority: 'alta',
expectedVotes: { pessimistic: 40_000, central: 50_000, optimistic: 70_000 },
politicalTrend: {
status: 'favoravel',
note: 'Prefeito sinaliza apoio',
@ -283,75 +164,12 @@ const snapshot = {
},
engagementLevel: 'n3',
levelNote: 'Rede ativa',
levelChangedAt: '2026-08-01T10:00:00.000Z',
strengths: ['Rede de saúde forte'],
risks: ['Oposição com rádio local'],
dobradinhaNotes: 'Dobradinha com a deputada estadual',
nextSteps: 'Agendar reunião com o prefeito',
budgetNotes: 'Emenda de custeio aportada em 2025',
stateDeputies: [{ id: 5, name: 'Deputada Estadual', party: 'PT' }],
},
competitors: {
referenceYear: 2022,
federal: [
{
candidateNumber: 1313,
name: 'Solla',
party: 'PT',
votesByYear: { '2018': 40_000, '2022': 50_000 },
},
{
candidateNumber: 4545,
name: 'Concorrente A',
party: 'PSDB',
votesByYear: { '2018': 20_000, '2022': 30_000 },
},
],
state: [],
},
speeches: {
totalCount: 2,
rows: [
{
id: 30,
speechAt: '2026-07-01T10:00:00.000Z',
year: 2026,
phase: 'Discurso em plenário',
summary: 'Resumo da fala',
officialTextUrl: 'https://camara.test/discurso',
youtubeUrl: 'https://youtube.test/video',
vodPlaybackUrl: 'https://vod.test/video.mp4',
youtubeExcerptStartSeconds: 645,
mentionExcerpt: 'Trecho da fala que cita Feira de Santana',
mentionedMunicipalityCount: 3,
},
{
id: 31,
speechAt: '2026-06-01T10:00:00.000Z',
year: 2026,
phase: 'Pequeno Expediente',
summary: 'Saúde indígena na Bahia',
officialTextUrl: 'https://camara.test/discurso-2',
youtubeUrl: null,
vodPlaybackUrl: null,
mentionExcerpt: null,
mentionedMunicipalityCount: 25,
},
],
},
demands: {
totalCount: 1,
rows: [
{
id: 40,
title: 'Demanda de saúde',
kind: 'saude',
status: 'recebida',
createdAt: '2026-09-01T10:00:00.000Z',
updatedAt: '2026-09-05T10:00:00.000Z',
},
],
},
demographics: {
population: 600_000,
ageBands: { '0-17': 150_000, '18-29': 120_000, '30-59': 250_000, '60+': 80_000 },
@ -366,10 +184,6 @@ type ReportItem = {
value?: string
hint?: string | null
url?: string | null
author?: string
detail?: string
sphere?: string
sphereLabel?: string
title?: string
body?: string
source?: { url?: string | null; date?: string | null }
@ -400,23 +214,56 @@ type ReportShape = {
const asReport = (value: unknown) => value as ReportShape
describe('buildCityReport', () => {
const report = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
const byId = (report: ReportShape, id: string) =>
report.sections.find((section) => section.id === id)!
it('builds the 15 deepening sections in the product order', () => {
expect(report.sections).toHaveLength(15)
expect(report.sections[0].title).toMatch(/Conta eleitoral completa/)
expect(report.sections[1].title).toMatch(/2º turno no município/)
expect(report.sections[2].title).toMatch(/Concorrentes no município/)
expect(report.sections[8].title).toMatch(/Atividade econômica/)
expect(report.sections[9].title).toMatch(/Transporte e conexões/)
expect(report.sections[13].title).toMatch(/Abordagem sugerida/)
expect(report.sections[14].title).toMatch(/Fontes e limites/)
describe('buildCityReport', () => {
const report = asReport(buildCityReport({ snapshot, research, generatedAt }))
it('builds the nine 2º-turno sections in the product order', () => {
expect(report.sections).toHaveLength(9)
expect(report.sections.map((section) => section.id)).toEqual([
'segundo-turno',
'rede',
'conjuntura',
'demografia',
'economia',
'transporte',
'noticias',
'abordagem',
'fontes',
])
expect(report.sections[0].title).toBe('1. 2º turno no município (Lula × Flávio)')
expect(report.sections[1].title).toBe('2. Rede e lideranças')
expect(report.sections[8].title).toBe('9. Fontes e limites')
})
it('retires the mandate-era sections from the report', () => {
const ids = report.sections.map((section) => section.id)
for (const retired of [
'conta-eleitoral',
'concorrentes',
'falas',
'regiao',
'sinais',
'demandas',
]) {
expect(ids).not.toContain(retired)
}
const serialized = JSON.stringify(report)
for (const copy of [
'Concorrentes',
'O que Solla entregou',
'Acervo de falas',
'Panorama regional',
'Emendas',
]) {
expect(serialized).not.toContain(copy)
}
})
it('brings the 2º turno artifact to the dedicated section', () => {
const section = report.sections.find((entry) => entry.id === 'segundo-turno')!
expect(section.title).toBe('2. 2º turno no município (Lula × Flávio)')
const section = byId(report, 'segundo-turno')
const callout = section.blocks.find((block) => block.kind === 'callout')!
expect(callout.title).toContain('Defesa e comparecimento')
expect(callout.body!.join(' ')).toContain('Captura do adversário')
@ -447,15 +294,88 @@ describe('buildCityReport', () => {
{ now: generatedAt },
)
const withoutReport = asReport(
buildCityReport({ snapshot, research: researchWithout, emendas, generatedAt }),
buildCityReport({ snapshot, research: researchWithout, generatedAt }),
)
const section = withoutReport.sections.find((entry) => entry.id === 'segundo-turno')!
const section = byId(withoutReport, 'segundo-turno')
const gap = section.blocks.find((block) => block.kind === 'callout' && block.tone === 'gap')!
expect(gap.body!.join(' ')).toContain('Não pesquisado.')
const pageMovement = withoutReport.page1.blocks.find(
(block) => block.title === 'Movimento local do 2º turno (pesquisa)',
)!
expect(pageMovement.tone).toBe('gap')
})
it('keeps page 1 in the contract order — mobilization only', () => {
const kinds = report.page1.blocks.map((block) => block.kind)
expect(kinds).toEqual(['strip', 'grid', 'callout', 'cards', 'panel', 'footerNote'])
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
expect(grid.cells!.map((cell) => cell.title)).toEqual([
'2º turno — Lula × Flávio',
'Quem mobiliza',
])
const ask = report.page1.blocks.find((block) => block.kind === 'panel')!
expect(ask.title).toBe('O que pedir e falar')
expect(ask.tone).toBe('decision')
expect(ask.note).toContain('votos em jogo')
expect(ask.items!.some((item) => item.text!.includes('Lula 13'))).toBe(true)
})
it('shows the official 2º-turno KPIs and the deterministic role on page 1', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const cell = grid.cells![0]
const kpis = cell.blocks.find((block) => block.kind === 'kpis')!
expect(kpis.items!.map((item) => item.label)).toEqual([
'Lula em 2026',
'Votos fora do 2º turno',
'Solla 2026',
])
expect(kpis.items![0].hint).toContain('Δ')
expect(kpis.items![1].value).toMatch(/^X₂ = /)
expect(kpis.items![1].hint).toContain('X₁ = ')
const role = cell.blocks.find((block) => block.kind === 'callout')!
expect(role.title).toContain('Papel no 2º turno')
})
it('reads the relative lens (share and delta), never the statewide absolute', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const kpis = grid.cells![0].blocks.find((block) => block.kind === 'kpis')!
expect(kpis.items![0].value).toMatch(/%$/)
expect(kpis.items![0].hint).toContain('dos válidos')
})
it('puts research answers in Quem mobiliza with compact sources and URLs in the sources section', () => {
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).toBeNull()
expect(prefeito.source!.date).toBe('2026-09-10')
const labels = who.rows!.map((row) => row.label)
for (const label of [
'Vice',
'Relação com o campo',
'Lideranças',
'Dobradinhas',
'Vereadores/dobradas',
]) {
expect(labels).toContain(label)
}
const fontes = byId(report, 'fontes').blocks.find((block) => block.kind === 'sources')!
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/prefeito')).toBe(true)
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/polo')).toBe(true)
})
it('describes the local movement from the research with its date', () => {
const movement = report.page1.blocks.find(
(block) => block.title === 'Movimento local do 2º turno (pesquisa)',
)!
expect(movement.kind).toBe('callout')
expect(movement.body!.join(' ')).toContain('Resposta de segundo_turno')
expect(movement.body!.join(' ')).toContain('10/09/2026')
})
it('adds the researched demography, economy and transport data', () => {
const demografia = report.sections.find((section) => section.id === 'demografia')!.blocks[0]
const demografia = byId(report, 'demografia').blocks[0]
const rows = demografia.rows as unknown as TableRow[]
expect(rows[0].indicator).toBe('População')
expect(rows[0].source).toBe('base Teqo')
@ -465,147 +385,47 @@ describe('buildCityReport', () => {
expect(
demografia.sources!.some((source) => source.url === 'https://exemplo.test/cor-raca'),
).toBe(true)
const economia = report.sections.find((section) => section.id === 'economia')!.blocks[0]
const economia = byId(report, 'economia').blocks[0]
expect((economia.rows as unknown as TableRow[])[0].detail).toContain('Serviços')
const transporte = report.sections.find((section) => section.id === 'transporte')!.blocks[0]
const transporte = byId(report, 'transporte').blocks[0]
expect((transporte.rows as unknown as TableRow[])[0].detail).toContain('BR-101')
expect(transporte.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/br101' }),
)
})
it('lists the main competitors with their vote series and the pre-candidates', () => {
const concorrentes = report.sections.find((section) => section.id === 'concorrentes')!
const federal = concorrentes.blocks[0]
const rows = federal.rows as unknown as TableRow[]
expect(federal.title).toContain('Deputado federal')
expect(rows.map((row) => row.name)).toEqual(['Solla', 'Concorrente A'])
expect(rows[1].y2022).toBe('30.000')
expect(rows[1].y2014).toBe('—')
const preCandidates = concorrentes.blocks[2]
const preRows = preCandidates.rows as unknown as TableRow[]
expect(preRows[0].name).toBe('Deputado do Prefeito')
expect(preCandidates.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/pre-candidato' }),
)
})
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)',
it('renders the opposition front inside the conjuncture section, when researched', () => {
const conjuntura = byId(report, 'conjuntura')
const opposition = conjuntura.blocks.find(
(block) => block.title === 'Frente de 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(rows[0].topic).toBe('Obra parada no campo adversário')
expect(rows[0].detail).toContain('Anúncio sem entrega')
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(
/^6\. /,
)
})
it('lists the researched city leaders in the network section', () => {
const rede = report.sections.find((section) => section.id === 'rede')!
it('lists the researched city leaders, their agenda and the dobradinhas in the network section', () => {
const rede = byId(report, 'rede')
const leadersBlock = rede.blocks.find(
(block) => block.title === 'Lideranças da cidade (pesquisa)',
)!
const rows = leadersBlock.rows as unknown as TableRow[]
expect(rows[0].name).toBe('Ex-Prefeito')
expect(rows[0].party).toBe('PSDB')
expect(rows[0].period).toBe('2017–2024')
expect(leadersBlock.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/lideranca' }),
)
})
it('shows expected votes on page 1 instead of the goal/pledge coverage', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const kpis = grid.cells![0].blocks.find((block) => block.kind === 'kpis')!
expect(kpis.items!.map((item) => item.label)).toEqual([
'Votos em 2022',
'Rank no estado',
'Expectativa de votos',
])
expect(kpis.items![2].value).toBe('50.000')
expect(kpis.items![2].hint).toContain('Pessimista 40.000')
expect(kpis.items![2].hint).toContain('Otimista 70.000')
const leftLabels = grid.cells![0].blocks.flatMap((block) =>
(block.rows ?? []).map((row) => row.label),
)
expect(leftLabels).not.toContain('Cobertura de pledges')
expect(JSON.stringify(report.page1)).not.toContain('Meta 2026')
})
it('describes what each speech is and how the município is mentioned', () => {
const falas = report.sections.find((section) => section.id === 'falas')!.blocks[0]
const rows = falas.rows as unknown as TableRow[]
expect(falas.columns!.map((column) => column.label)).toEqual([
'Data',
'Fase',
'O que é',
`Menção a ${snapshot.municipality.name}`,
'Vídeo',
'Transcrição',
])
expect(falas.columns!.find((column) => column.key === 'description')!.width).toBe(32)
expect(falas.columns!.find((column) => column.key === 'mention')!.width).toBe(27)
expect(rows[0].description).toBe('Resumo da fala')
expect(rows[0].mention).toContain('cita Feira de Santana')
expect(rows[0].video).toBe('https://youtube.test/video?t=608s')
expect(rows[0].transcript).toBe('https://camara.test/discurso')
expect(rows[1].description).toBe('Saúde indígena na Bahia')
expect(rows[1].mention).toContain('nome não localizado nos trechos')
expect(rows[1].mention).toContain('25 municípios')
expect(rows[1].video).toBe('—')
const agenda = rede.blocks.find((block) => block.title === 'Pauta das lideranças (pesquisa)')!
expect((agenda.rows as unknown as TableRow[])[0].topics).toContain('hospital')
const dobradinhas = rede.blocks.find((block) => block.title === 'Dobradinhas (pesquisa)')!
expect((dobradinhas.rows as unknown as TableRow[])[0].topic).toContain('Deputada Estadual')
})
it('renders the persona approach section and its sources', () => {
const abordagem = report.sections.find((section) => section.id === 'abordagem')!.blocks[0]
const abordagem = byId(report, 'abordagem').blocks[0]
const rows = abordagem.rows as unknown as TableRow[]
expect(rows[0].topic).toContain('Ciência política')
expect(rows[0].suggestion).toContain('SAMU')
@ -614,183 +434,6 @@ describe('buildCityReport', () => {
)
})
it('keeps page 1 in the contract order (blocks + footer)', () => {
const kinds = report.page1.blocks.map((block) => block.kind)
expect(kinds).toEqual(['strip', 'grid', 'kpis', 'panel', 'cards', 'footerNote'])
const titles = report.page1.blocks.map((block) => block.title).filter(Boolean)
expect(titles).toEqual(
expect.arrayContaining(['O que Solla entregou', 'Riscos — oposição/disputa local']),
)
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
expect(grid.cells!.map((cell) => cell.title)).toEqual(['Conta eleitoral 2022', 'Quem é quem'])
const announce = report.page1.blocks.find((block) => block.kind === 'panel')!
expect(announce.title).toBe('O que anunciar agora')
expect(announce.tone).toBe('decision')
expect(announce.note).toContain('empenho não é pagamento')
})
it('reads the relative lens (share/rank), never the statewide absolute', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const kpis = grid.cells![0].blocks.find((block) => block.kind === 'kpis')!
expect(kpis.items![0].value).toBe('50.000')
expect(kpis.items![0].hint).toContain('5,0%')
expect(kpis.items![1].value).toBe('3º de 435')
})
it('shows emendas with phase and the official source', () => {
const delivered = report.page1.blocks.find(
(block) => block.kind === 'kpis' && block.title === 'O que Solla entregou',
)!
expect(delivered.items![0].value).toBe('R$ 1,0 mi')
expect(delivered.items![0].hint).toContain('Pago: R$ 250 mil')
expect(delivered.sources![0]).toEqual(
expect.objectContaining({
kind: 'official',
url: expect.stringContaining('portaldatransparencia'),
}),
)
})
it('degrades missing emendas into an explicit gap, never a zero', () => {
const gapReport = asReport(
buildCityReport({
snapshot,
research,
emendas: {
status: 'gap',
reason: 'Chave ausente',
detail: null,
sourceUrl: null,
consultedAt: null,
},
generatedAt,
}),
)
const delivered = gapReport.page1.blocks.find(
(block) => block.kind === 'kpis' && block.title === 'O que Solla entregou',
)!
expect(delivered.items![0].value).toBe('Sem fonte oficial')
expect(delivered.items![0].hint).toContain('sem emenda atribuível')
})
it('shows the structured emenda indications when the official source is a gap', () => {
const gapReport = asReport(
buildCityReport({
snapshot,
research,
emendas: {
status: 'gap',
reason: 'Sem emenda do autor com localidade Feira de Santana na janela.',
detail: 'API não expõe o município da emenda.',
sourceUrl: 'https://api.portaldatransparencia.gov.br/api-de-dados/emendas',
consultedAt: '2026-09-15T11:00:00.000Z',
},
generatedAt,
}),
)
const evidence = gapReport.page1.blocks.find(
(block) =>
block.kind === 'indicatorList' &&
block.title === 'Emendas — indícios web (sem atribuição oficial ao município)',
)
expect(evidence).toBeTruthy()
expect(evidence!.items!.map((item) => item.author)).toEqual(['Zé Neto', 'Deputado do Polo'])
expect(evidence!.items![0].sphereLabel).toBe('município')
expect(evidence!.items![0].detail).toBe('Ambulância do TFD · R$ 1 mi · 2026')
expect(evidence!.items![0].source!.url).toBe('https://exemplo.test/ze-neto')
expect(evidence!.items!.map((item) => item.sphereLabel)).toContain('polo')
expect(evidence!.note).toContain('Não somar região/polo')
const evidenceOk = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
expect(
evidenceOk.page1.blocks.some(
(block) => block.title === 'Emendas — indícios web (sem atribuição oficial ao município)',
),
).toBe(false)
})
it('falls back to the emendas_web answer when the research has no structured indications', () => {
const legacyResearch = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id)),
gaps: [],
},
{ now: generatedAt },
)
const legacy = asReport(
buildCityReport({
snapshot,
research: legacyResearch,
emendas: {
status: 'gap',
reason: 'Sem emenda do autor com localidade Feira de Santana na janela.',
detail: null,
sourceUrl: null,
consultedAt: null,
},
generatedAt,
}),
)
const evidence = legacy.page1.blocks.find(
(block) =>
block.kind === 'callout' &&
block.title === 'Emendas — indícios web (sem atribuição oficial ao município)',
)
expect(evidence).toBeTruthy()
expect(evidence!.body!.join(' ')).toContain('Resposta de emendas_web')
})
it('caps the emenda indications and shows the remainder', () => {
const manyIndicators = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id)),
emendasIndicators: Array.from({ length: 6 }, (_, index) => ({
author: `Autor ${index + 1}`,
sphere: 'municipio',
value: `R$ ${index + 1} mi`,
sourceUrl: `https://exemplo.test/indicio-${index + 1}`,
sourceDate: '2026-09-01',
})),
gaps: [],
},
{ now: generatedAt },
)
const capped = asReport(
buildCityReport({
snapshot,
research: manyIndicators,
emendas: {
status: 'gap',
reason: 'Sem emenda do autor com localidade Feira de Santana na janela.',
detail: null,
sourceUrl: null,
consultedAt: null,
},
generatedAt,
}),
)
const evidence = capped.page1.blocks.find((block) => block.kind === 'indicatorList')!
expect(evidence.items).toHaveLength(3)
expect(evidence.remaining).toBe(3)
})
it('puts research answers in Quem é quem with compact sources and URLs in the sources section', () => {
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).toBeNull()
expect(prefeito.source!.date).toBe('2026-09-10')
const fontes = report.sections
.find((section) => section.id === 'fontes')!
.blocks.find((block) => block.kind === 'sources')!
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/prefeito')).toBe(true)
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/polo')).toBe(true)
})
it('keeps the full page-1 answer (no "…") and preserves the integral in the deep dive (C188)', () => {
const longAnswer = 'palavra '.repeat(20).trim()
const longResearch = normalizeResearchInput(
@ -802,18 +445,16 @@ describe('buildCityReport', () => {
},
{ now: generatedAt },
)
const capped = asReport(
buildCityReport({ snapshot, research: longResearch, emendas, generatedAt }),
)
const capped = asReport(buildCityReport({ snapshot, research: longResearch, generatedAt }))
const who = capped.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe(longAnswer)
expect(prefeito.value!.includes('…')).toBe(false)
const appendix = capped.sections
.find((section) => section.id === 'fontes')!
.blocks.find((block) => block.title === 'Pesquisa — respostas integrais (texto completo)')!
const appendix = byId(capped, 'fontes').blocks.find(
(block) => block.title === 'Pesquisa — respostas integrais (texto completo)',
)!
const rows = appendix.rows as unknown as TableRow[]
expect(rows).toHaveLength(RESEARCH_CHECKLIST_IDS.length)
expect(rows[0].answer).toContain(longAnswer)
@ -831,9 +472,7 @@ describe('buildCityReport', () => {
},
{ now: generatedAt },
)
const capped = asReport(
buildCityReport({ snapshot, research: summarized, emendas, generatedAt }),
)
const capped = asReport(buildCityReport({ snapshot, research: summarized, generatedAt }))
const who = capped.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
@ -855,7 +494,6 @@ describe('buildCityReport', () => {
buildCityReport({
snapshot,
research: longResearch,
emendas,
generatedAt,
textFallback: 'pointer',
}),
@ -865,19 +503,9 @@ describe('buildCityReport', () => {
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe(SUMMARY_POINTER_COPY)
expect(prefeito.value!.includes('…')).toBe(false)
const appendix = pointer.sections
.find((section) => section.id === 'fontes')!
.blocks.find((block) => block.title === 'Pesquisa — respostas integrais (texto completo)')!
const rows = appendix.rows as unknown as TableRow[]
expect(rows[0].answer).toContain(longAnswer)
})
it('keeps short research answers untouched on page 1 and shows the list remainder', () => {
const who = report.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe('Resposta de prefeito')
it('shows the dobradinhas remainder when the list is capped', () => {
const manyDeputies = asReport(
buildCityReport({
snapshot: {
@ -892,7 +520,6 @@ describe('buildCityReport', () => {
},
},
research,
emendas,
generatedAt,
}),
)
@ -903,12 +530,49 @@ describe('buildCityReport', () => {
expect(dobradinhas.value).not.toContain('Deputado Três')
})
it('builds the region panorama from the committed artifact', () => {
const region = report.sections.find((section) => section.id === 'regiao')!.blocks[0]
const rowByLabel = (label: string) => region.rows!.find((row) => row.label === label)!
expect(rowByLabel('Território').value).toBe('Portal do Sertão')
expect(rowByLabel('Municípios no TI').value).toBe('17')
const position = Number(rowByLabel('Posição do município no TI').value.split('º')[0])
expect(position).toBeGreaterThan(0)
it('tolerates snapshots from the pre-refactor shape (extra groups ignored)', () => {
const legacySnapshot = {
...snapshot,
electoral: {
series: [{ year: 2022, votes: 1 }],
rank2022: { rank: 1, votes: 1, share: 1, totalUnits: 435 },
},
goal: { suggestedGoal: 1, goalCoverage: {}, territorialClass: { class: 'expansao' } },
signals: { totalCount: 1, rows: [] },
activities: { upcoming: [], recent: [] },
speeches: { totalCount: 0, rows: [] },
demands: { totalCount: 0, rows: [] },
competitors: { referenceYear: 2022, federal: [], state: [] },
}
const legacy = asReport(buildCityReport({ snapshot: legacySnapshot, research, generatedAt }))
expect(legacy.sections).toHaveLength(9)
expect(legacy.page1.blocks.map((block) => block.kind)).toEqual([
'strip',
'grid',
'callout',
'cards',
'panel',
'footerNote',
])
})
it('shows a gap instead of an empty 2º-turno cell when the unit is outside the artifact', () => {
const outside = asReport(
buildCityReport({
snapshot: {
...snapshot,
municipality: { ...snapshot.municipality, slug: 'nao-existe-no-artefato' },
},
research,
generatedAt,
}),
)
const cell = outside.page1.blocks.find((block) => block.kind === 'grid')!.cells![0]
const gap = cell.blocks.find((block) => block.kind === 'callout' && block.tone === 'gap')!
expect(gap.body!.join(' ')).toContain('build:second-round')
const section = byId(outside, 'segundo-turno')
expect(section.blocks.some((block) => block.kind === 'callout' && block.tone === 'gap')).toBe(
true,
)
})
})

View file

@ -20,23 +20,6 @@ const research = normalizeResearchInput(
sourceDate: '2026-09-10',
})),
news: [],
emendasIndicators: [
{
author: 'Zé Neto',
sphere: 'municipio',
value: 'R$ 1 mi',
purpose: 'Ambulância do TFD',
sourceUrl: 'https://exemplo.test/indicio-municipio',
sourceDate: '2026-09-01',
},
{
author: 'Deputado do Polo',
sphere: 'polo',
value: 'R$ 500 mil',
sourceUrl: 'https://exemplo.test/indicio-polo',
sourceDate: '2026-09-01',
},
],
gaps: [],
},
{ now: generatedAt },
@ -57,44 +40,17 @@ const report = buildCityReport({
city: 'Feira de Santana',
region: 'Portal do Sertão',
ibgeCode: '2910800',
tseCityCode: '31270',
zoneNumber: null,
tseZones: [12, 13],
lastUpdateAt: null,
},
electoral: {
candidateName: 'Jorge Solla',
candidateParty: 'PT',
series: [],
tally2022: null,
ticket2022: { president: null, governor: null },
rank2022: null,
},
goal: null,
pledges: {
declaredTotal: 0,
effectiveByScenario: {},
pledgeCount: 0,
missingEstimateCount: 0,
lastPledgeAt: null,
},
leaderships: { totalCount: 0, rows: [] },
advisors: [],
signals: { totalCount: 0, rows: [] },
activities: { upcoming: [], recent: [] },
conjuncture: null,
speeches: { totalCount: 0, rows: [] },
demands: { totalCount: 0, rows: [] },
demographics: null,
},
research,
emendas: {
status: 'gap',
reason: 'Chave ausente',
detail: null,
sourceUrl: null,
consultedAt: null,
},
generatedAt,
})
@ -116,7 +72,7 @@ describe('renderReportHtml', () => {
expect(html).toContain(
'<a href="https://exemplo.test/prefeito">https://exemplo.test/prefeito</a>',
)
expect(html).toContain('href="https://exemplo.test/emendas_web"')
expect(html).toContain('href="https://exemplo.test/segundo_turno"')
})
it('prints the gaps as callouts and the limits list', () => {
@ -124,18 +80,12 @@ describe('renderReportHtml', () => {
expect(html).toContain('Limites')
})
it('renders the emenda indications with the esfera badge and per-row source link', () => {
expect(html).toContain('<ul class="indicators">')
expect(html).toContain('sphere sphere-municipio')
expect(html).toContain('sphere sphere-polo')
expect(html).toContain('href="https://exemplo.test/indicio-municipio"')
expect(html).toContain('Não somar região/polo')
})
it('renders the announce panel without the fixed "O que NÃO anunciar" copy', () => {
it('renders the ask panel of the 2º turno (no mandate-era copy)', () => {
expect(html).toContain('<div class="panel decision">')
expect(html).toContain('O que anunciar agora')
expect(html).toContain('O que pedir e falar')
expect(html).not.toContain('O que anunciar agora')
expect(html).not.toContain('O que NÃO anunciar')
expect(html).not.toContain('indicators')
})
})
@ -143,17 +93,12 @@ describe('renderReportMd', () => {
const md = renderReportMd(report)
it('carries the same sections as tables and headings', () => {
expect(md).toContain('## 1. Conta eleitoral completa')
expect(md).toContain('## 2. 2º turno no município (Lula × Flávio)')
expect(md).toContain('## 1. 2º turno no município (Lula × Flávio)')
expect(md).toContain('> **Papel no 2º turno: Defesa e comparecimento**')
expect(md).toContain('## 3. Concorrentes no município (federal e estadual)')
expect(md).not.toContain('## 6. Sinais recentes')
expect(md).not.toContain('Demandas e visitas')
expect(md).toContain('## 7. Atividade econômica (pesquisa)')
expect(md).toContain('## 8. Transporte e conexões (pesquisa)')
expect(md).toContain('## 12. Abordagem sugerida (pesquisa)')
expect(md).toContain('## 13. Fontes e limites')
expect(md).toContain('| Ano | Votos |')
expect(md).toContain('## 2. Rede e lideranças')
expect(md).toContain('## 9. Fontes e limites')
expect(md).not.toContain('Conta eleitoral')
expect(md).not.toContain('Concorrentes')
})
it('lists sources with URLs', () => {
@ -164,10 +109,4 @@ describe('renderReportMd', () => {
it('states the base read date (dated snapshot)', () => {
expect(md).toContain('Base Teqo (read-only) lida em')
})
it('renders the emenda indications in the companion with the (fonte) link', () => {
expect(md).toContain('**Zé Neto** — Ambulância do TFD · R$ 1 mi · _município_')
expect(md).toContain('[(fonte)](https://exemplo.test/indicio-municipio)')
expect(md).not.toContain('O que NÃO anunciar')
})
})

View file

@ -25,8 +25,12 @@ const baseResearch = (overrides: Record<string, unknown> = {}) => ({
})
describe('normalizeResearchInput', () => {
it('includes the 2º turno local movement in the checklist', () => {
it('pins the 2º-turno mobilization checklist (no mandate-era items)', () => {
expect(RESEARCH_CHECKLIST_IDS).toContain('segundo_turno')
expect(RESEARCH_CHECKLIST_IDS).toContain('relacao_campo')
expect(RESEARCH_CHECKLIST_IDS).not.toContain('emendas_web')
expect(RESEARCH_CHECKLIST_IDS).not.toContain('quem_investe')
expect(RESEARCH_CHECKLIST_IDS).toHaveLength(8)
})
it('keeps fully sourced items', () => {
@ -35,6 +39,21 @@ describe('normalizeResearchInput', () => {
expect(research.gaps).toEqual([])
})
it('ignores retired top-level lists from old research files (replay compat)', () => {
const research = normalizeResearchInput(
baseResearch({
preCandidates: [{ name: 'X', office: 'Deputado federal' }],
investments: [{ topic: 'Obra', detail: 'sem fonte' }],
emendasIndicators: [{ author: 'Y', sphere: 'municipio' }],
}),
{ now },
)
expect('preCandidates' in research).toBe(false)
expect('investments' in research).toBe(false)
expect('emendasIndicators' in research).toBe(false)
expect(research.gaps).toEqual([])
})
it('normalizes the optional per-item summary without changing the source contract (C188)', () => {
const research = normalizeResearchInput(
baseResearch({
@ -96,16 +115,16 @@ 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) =>
items: RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'imprensa_local').map((id) =>
validItem(id),
),
gaps: [{ id: 'emendas_web', label: 'Emendas na web', reason: 'Fonte oficial respondeu.' }],
gaps: [{ id: 'imprensa_local', label: 'Imprensa local', reason: 'Sem veículo ativo.' }],
}),
{ now },
)
const emendasGaps = research.gaps.filter((gap) => gap.id === 'emendas_web')
expect(emendasGaps).toHaveLength(1)
expect(emendasGaps[0].reason).toBe('Fonte oficial respondeu.')
const pressGaps = research.gaps.filter((gap) => gap.id === 'imprensa_local')
expect(pressGaps).toHaveLength(1)
expect(pressGaps[0].reason).toBe('Sem veículo ativo.')
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(0)
})
@ -113,18 +132,18 @@ describe('normalizeResearchInput', () => {
const research = normalizeResearchInput(
baseResearch({
items: [
validItem('emendas_web', {
validItem('prefeito', {
extraSources: [
{ label: 'Polo regional', url: 'https://exemplo.test/polo', date: '2026-09-01' },
{ label: 'Sem data', url: 'https://exemplo.test/sem-data' },
],
}),
...RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'emendas_web').map((id) => validItem(id)),
...RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'prefeito').map((id) => validItem(id)),
],
}),
{ now },
)
const item = research.items.find((entry) => entry.id === 'emendas_web')!
const item = research.items.find((entry) => entry.id === 'prefeito')!
expect(item.extraSources).toHaveLength(1)
expect(item.extraSources[0].url).toBe('https://exemplo.test/polo')
expect(research.gaps).toContainEqual(
@ -132,50 +151,6 @@ describe('normalizeResearchInput', () => {
)
})
it('keeps sourced emenda indications and gaps those without author, esfera or source', () => {
const research = normalizeResearchInput(
baseResearch({
emendasIndicators: [
{
author: 'Zé Neto',
sphere: 'municipio',
value: 'R$ 1 mi',
purpose: 'Ambulância do TFD',
year: '2026',
sourceUrl: 'https://exemplo.test/ze-neto',
sourceDate: '2026-09-01',
},
{
author: 'Sem esfera',
sphere: 'capital',
sourceUrl: 'https://exemplo.test/capital',
sourceDate: '2026-09-01',
},
{ author: 'Sem fonte', sphere: 'regiao' },
],
}),
{ now },
)
expect(research.emendasIndicators).toHaveLength(1)
expect(research.emendasIndicators[0]).toEqual(
expect.objectContaining({
author: 'Zé Neto',
sphere: 'municipio',
value: 'R$ 1 mi',
purpose: 'Ambulância do TFD',
}),
)
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'emenda_indicio_incompleto' }),
)
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: 'emenda_indicio_sem_fonte',
reason: expect.stringMatching(/URL e data/),
}),
)
})
it('keeps sourced approach suggestions and gaps ones without source', () => {
const research = normalizeResearchInput(
baseResearch({
@ -252,8 +227,8 @@ describe('normalizeResearchInput', () => {
baseResearch({
opposition: [
{
topic: 'Emenda sob investigação (PF)',
detail: 'Operação apura desvio em emendas.',
topic: 'Obra parada no campo adversário',
detail: 'Anúncio sem entrega.',
sourceUrl: 'https://exemplo.test/oposicao',
sourceDate: '2026-09-10',
},
@ -264,7 +239,7 @@ describe('normalizeResearchInput', () => {
)
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(firstOpposition.topic).toBe('Obra parada no campo adversário')
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: 'opposition_sem_fonte',
@ -273,7 +248,7 @@ describe('normalizeResearchInput', () => {
)
})
it('keeps sourced dobradinhas and investments, gapping those without source', () => {
it('keeps sourced dobradinhas and gaps those without source', () => {
const research = normalizeResearchInput(
baseResearch({
alliances: [
@ -283,13 +258,12 @@ describe('normalizeResearchInput', () => {
sourceUrl: 'https://exemplo.test/dobradinha',
sourceDate: '2026-09-10',
},
{ topic: 'Sem fonte', detail: 'Não entra.' },
],
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' }))
expect(research.gaps).toContainEqual(expect.objectContaining({ id: 'alliances_sem_fonte' }))
})
})