mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
31f78f07ad
commit
5bf5fa3cb1
20 changed files with 672 additions and 2335 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(' | ')} |`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? '')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue