From 5bf5fa3cb1d08e4cf7c4fa07340a038f2d1f4752 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 10 Oct 2026 16:19:24 -0300 Subject: [PATCH] =?UTF-8?q?feat(relatorio-cidade):=20foca=20o=20relat?= =?UTF-8?q?=C3=B3rio=20na=20mobiliza=C3=A7=C3=A3o=20do=202=C2=BA=20turno?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .agents/skills/relatorio-cidade/SKILL.md | 315 ++--- .opencode/agent/relatorio-cidade.md | 18 +- .opencode/commands/relatorio-cidade.md | 2 +- ...-10-relatorio-cidade-mobilizacao-2turno.md | 1 + docs/ops/teqo-1313-deploy.md | 6 +- scripts/build-city-report.mjs | 58 +- scripts/cityReportSnapshot.mjs | 303 +---- scripts/extract-city-report-snapshot.mjs | 3 +- scripts/lib/cityReportBlocks.mjs | 1062 ++++------------- scripts/lib/cityReportRender.mjs | 49 +- scripts/lib/cityReportResearch.mjs | 91 +- scripts/lib/cityReportTerritory.mjs | 68 -- scripts/lib/portalTransparenciaEmendas.mjs | 2 +- scripts/lib/test-affected-core.mjs | 1 - tests/int/cityReportSnapshot.int.spec.ts | 103 +- tests/unit/ciSkipInvariants.unit.spec.ts | 2 +- tests/unit/cityReportBatchSkill.unit.spec.ts | 14 +- tests/unit/cityReportBlocks.unit.spec.ts | 734 +++--------- tests/unit/cityReportRender.unit.spec.ts | 81 +- tests/unit/cityReportResearch.unit.spec.ts | 94 +- 20 files changed, 672 insertions(+), 2335 deletions(-) create mode 100644 docs/changelog/2026-10-10-relatorio-cidade-mobilizacao-2turno.md delete mode 100644 scripts/lib/cityReportTerritory.mjs diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index 92e02761..31fd47b6 100644 --- a/.agents/skills/relatorio-cidade/SKILL.md +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -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 " antes de uma viagem — ou de - várias numa invocação (`/relatorio-cidade Ilheus, Itacare, Una`). +- O candidato/CG pede "o relatório de " 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 (`-`); **sem** índice/PDF agregado ``` Saídas: `docs/research/relatorios-cidade/-.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/.build.log`. - Flags úteis: `--emendas=` (replay sem rede), `--author=` - (default `JORGE SOLLA`), `--generated-at=` (reprodutibilidade), + `data/relatorios-cidade/` (HTML, JSONs, `logs/`). Também redirecione a saída + do builder para `logs/.build.log`. + Flags úteis: `--generated-at=` (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/.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 "; 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 (``): - 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/.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/.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/.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. diff --git a/.opencode/agent/relatorio-cidade.md b/.opencode/agent/relatorio-cidade.md index 63ea4365..722da944 100644 --- a/.opencode/agent/relatorio-cidade.md +++ b/.opencode/agent/relatorio-cidade.md @@ -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/.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. diff --git a/.opencode/commands/relatorio-cidade.md b/.opencode/commands/relatorio-cidade.md index 1af7f523..9df7c761 100644 --- a/.opencode/commands/relatorio-cidade.md +++ b/.opencode/commands/relatorio-cidade.md @@ -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. diff --git a/docs/changelog/2026-10-10-relatorio-cidade-mobilizacao-2turno.md b/docs/changelog/2026-10-10-relatorio-cidade-mobilizacao-2turno.md new file mode 100644 index 00000000..6cea3e34 --- /dev/null +++ b/docs/changelog/2026-10-10-relatorio-cidade-mobilizacao-2turno.md @@ -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. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 7dffb8b7..1ebdb33f 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1243,7 +1243,7 @@ anterior de cada fala está em `updates[].previousUrl` no JSON do run. O import (`pnpm camara:import --date `) 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 diff --git a/scripts/build-city-report.mjs b/scripts/build-city-report.mjs index 87520cf3..cdab5192 100644 --- a/scripts/build-city-report.mjs +++ b/scripts/build-city-report.mjs @@ -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=` 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= --research= [--emendas=] [--out-dir=] [--author=]', - ) + die('Uso: --snapshot= --research= [--out-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) { diff --git a/scripts/cityReportSnapshot.mjs b/scripts/cityReportSnapshot.mjs index d2e45503..7cb4170b 100644 --- a/scripts/cityReportSnapshot.mjs +++ b/scripts/cityReportSnapshot.mjs @@ -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, } } diff --git a/scripts/extract-city-report-snapshot.mjs b/scripts/extract-city-report-snapshot.mjs index f445c90a..edceabb1 100644 --- a/scripts/extract-city-report-snapshot.mjs +++ b/scripts/extract-city-report-snapshot.mjs @@ -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)) diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index cdb1bf62..34abe625 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -1,15 +1,20 @@ /** - * Content model of the city report (C163). Pure: takes the snapshot (base Teqo, - * read-only), the validated research JSON and the emendas result, and returns - * the block tree both the PDF and the companion `.md` render. + * Content model of the city report (C163). * - * The order of the blocks is the product contract (intention plan, "Estrutura - * do relatório"): page 1 is the one-look summary; the numbered sections after it - * are the deep dive. "Sem fonte, não publica": every claim carries its source; - * what is missing becomes an explicit gap. + * Since the 2026-10-10 refocus the report serves the 2º turno (Lula × Flávio): + * page 1 is the mobilization summary (role of the unit, official numbers, who + * mobilizes, local movement, the ask) and the numbered sections are the deep + * dive (2º turno, network, conjuncture, demography, economy, transport, news, + * approach, sources). The mandate-era blocks of the pre-travel report (Solla's + * own electoral account and chair math, competitors, delivered emendas, Câmara + * speeches, region panorama, signals/demands) were retired; demography, economy + * and transport stay as visit context. Numbering is positional over the blocks + * actually built. + * + * "Sem fonte, não publica": every claim carries its source; what is missing + * becomes an explicit gap. */ -import { getMunicipalityFederalBaseline } from '../../src/lib/bahiaElectionAggregates.ts' import { BAHIA_SECOND_ROUND_GENERATED_AT, getBahiaSecondRoundUnit, @@ -19,24 +24,11 @@ import { secondRoundSollaShareOfLula, } from '../../src/lib/bahiaSecondRound.ts' import { formatEngagementLevelLabel } from '../../src/lib/engagementLevel.ts' -import { getMunicipalityVoteRank } from '../../src/lib/municipalityVoteRank.ts' -import { activityStatusLabels } from '../../src/lib/schemas/activity.ts' -import { - campaignDemandKindLabels, - campaignDemandStatusLabels, -} from '../../src/lib/schemas/campaignDemand.ts' -import { municipalityUpdatePolarityLabels } from '../../src/lib/schemas/municipalityUpdate.ts' import { secondRoundRoleGuidance, secondRoundRoleLabels } from '../../src/lib/secondRoundRole.ts' -import { SPEECH_TOPICS } from '../../src/lib/speechFacets.ts' -import { - formatVoteEstimateEndpointsLabel, - voteEstimateScenarioLabels, -} from '../../src/lib/voteEstimate.ts' import { supportStatusLabels } from '../../src/utilities/leadership/leadershipLabels.ts' import { municipalityPriorityLabels, politicalTrendLabels, - territorialClassLabels, } from '../../src/utilities/municipality/municipalityLabels.ts' import { @@ -44,31 +36,19 @@ import { formatDateTimeBr, formatFreshness, formatInteger, - formatMoneyCompact, formatPercent, - formatRank, } from './cityReportFormat.mjs' import { researchItemById } from './cityReportResearch.mjs' -import { buildTerritoryPanorama } from './cityReportTerritory.mjs' -import { - capList, - excerptDeepDive, - moreItemsLabel, - summaryListText, - summarySurfaceText, -} from './reportText.mjs' +import { capList, moreItemsLabel, summaryListText, summarySurfaceText } from './reportText.mjs' -const REPORT_TITLE = 'Relatório de cidade — pré-viagem' +const REPORT_TITLE = 'Relatório de cidade — 2º turno (Lula × Flávio)' const GAP_COPY = 'Lacuna explícita — não preencher por inferência.' -const ADVERTENCIA_DEFESO = - 'Ano eleitoral: empenho não é pagamento. O bloco de emendas mostra a fase (empenhada, liquidada, paga, restos).' +const SECOND_ROUND_GUARDRAIL = + 'X₁/X₂ são votos em jogo, não transferência garantida — o cenário de 2º turno é hipotético; persuasão em eleição geral tem efeito médio ~0.' const GAP_CALLOUT_LIMIT = 4 -const SIGNAL_EXCERPT_MAX = 180 -const SPEECH_SUMMARY_MAX = 220 -const SPEECH_MENTION_MAX = 220 /** * Page-1 text budget (C188). Page 1 is a summary surface: it never cuts @@ -79,29 +59,14 @@ const SPEECH_MENTION_MAX = 220 * still apply — each one with a visible counter. */ const PAGE_ONE_LIST_LIMIT = 2 +/** Joined name lists (Responsável, Dobradinhas) — one line in the strip/stat row. */ +const PAGE_ONE_NAMES_MAX = 20 /** * Character ceiling for a joined list of free text (conjuncture risks) — the * joined `answer`s have no deep-dive copy, so a long line degrades to the * pointer when the page does not fit, instead of blowing the guard. */ const PAGE_ONE_JOINED_MAX = 90 -/** Joined name lists (Responsável, Dobradinhas) — one line in the strip/stat row. */ -const PAGE_ONE_NAMES_MAX = 20 -/** - * Page-1 emenda indications: the author/what/esfera rows shown before "e mais N". - * Three rows (not four): a city-polo with many attributed indications overflowed - * the one-page guard, and the full list lives in the deep dive anyway. - */ -const PAGE_ONE_INDICATOR_LIMIT = 3 - -const EMENDAS_EVIDENCE_TITLE = 'Emendas — indícios web (sem atribuição oficial ao município)' - -/** Where each indication points to; região/polo are never summed as the city's own emenda. */ -const EMENDA_SPHERE_LABELS = { - municipio: 'município', - regiao: 'região', - polo: 'polo', -} const sourceTeqo = (label, date) => ({ kind: 'teqo', label, date: date ?? null }) const sourceWeb = (label, url, date) => ({ @@ -184,16 +149,12 @@ const gapCallout = (gaps, title = 'Lacunas desta seção') => { } } -const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors }) => [ +const buildIdentificationItems = ({ municipality, conjuncture, advisors }) => [ { label: 'Território', value: municipality.region }, { label: 'Prioridade', value: conjuncture ? (municipalityPriorityLabels[conjuncture.priority] ?? '—') : '—', }, - { - label: 'Classe', - value: goal ? (territorialClassLabels[goal.territorialClass.class] ?? '—') : '—', - }, { label: 'Nível', value: conjuncture?.engagementLevel @@ -211,23 +172,63 @@ const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors }) }, ] -const buildElectoralKpis = ({ electoral, conjuncture }) => { - const expectedVotes = conjuncture?.expectedVotes ?? null +/** The three official numbers of the unit — shared by page 1 and the section. */ +const secondRoundKpiItems = (unit, potential, shares, sollaRatio) => [ + { + label: 'Lula em 2026', + value: formatPercent(shares.share26 / 100), + hint: `dos válidos · Δ ${signedPoints(shares.deltaPp)} vs 2022`, + }, + { + label: 'Votos fora do 2º turno', + value: potential ? `X₂ = ${formatInteger(potential.x2)}` : '—', + hint: potential + ? `X₁ = ${formatInteger(potential.x1)} já no comparecimento; X₂ soma os ausentes` + : 'sem cenário para os números do recorte', + }, + { + label: 'Solla 2026', + value: formatInteger(unit.solla26), + hint: `Δ ${signedInteger(unit.dSolla)} vs 2022${ + sollaRatio !== null ? ` · ${formatPercent(sollaRatio / 100)} dos votos de Lula` : '' + }`, + }, +] + +/** + * Page-1 cell of the 2º turno: the official numbers plus the deterministic role + * of the unit. A unit outside the artifact degrades to an explicit gap instead + * of an empty cell. + */ +const buildSecondRoundPageBlocks = ({ snapshot }) => { + const unit = getBahiaSecondRoundUnit(snapshot.municipality.slug) + if (!unit) { + return [ + gapCallout( + [ + { + id: 'segundo_turno', + label: 'Artefato do 2º turno', + reason: 'Unidade fora do artefato TSE 2026 — regenere com `pnpm build:second-round`.', + }, + ], + 'Lacunas desta seção', + ), + ] + } + const potential = secondRoundPotentialOf(unit) + const shares = secondRoundLulaShares(unit) + const sollaRatio = secondRoundSollaShareOfLula(unit) return [ { - label: 'Votos em 2022', - value: formatInteger(electoral.rank2022?.votes ?? null), - hint: electoral.rank2022 - ? `% do próprio voto: ${formatPercent(electoral.rank2022.share)}` - : null, + kind: 'kpis', + items: secondRoundKpiItems(unit, potential, shares, sollaRatio), }, - { label: 'Rank no estado', value: formatRank(electoral.rank2022) }, { - label: 'Expectativa de votos', - value: formatInteger(expectedVotes?.central ?? null), - hint: expectedVotes - ? (formatVoteEstimateEndpointsLabel(expectedVotes) ?? 'Cenários da base') - : 'sem registro na base', + kind: 'callout', + tone: unit.role === 'defesa' ? 'risk' : 'decision', + title: `Papel no 2º turno: ${secondRoundRoleLabels[unit.role]}`, + body: [secondRoundRoleGuidance[unit.role]], }, ] } @@ -265,56 +266,55 @@ const buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito return rows } -const buildDeliveredKpis = ({ speeches }, research, emendas) => { - const emendasOk = emendas?.status === 'ok' - return [ - { - label: 'Emendas aportadas', - value: emendasOk ? formatMoneyCompact(emendas.totals.empenhado) : 'Sem fonte oficial', - hint: emendasOk - ? `Pago: ${formatMoneyCompact(emendas.totals.pago)} · ${emendas.rows.length} emenda(s)` - : emendas - ? 'sem emenda atribuível ao município' - : null, - }, - { - label: 'Falas no acervo', - value: `${formatInteger(speeches.totalCount)} trecho(s)`, - hint: 'Busca por município no acervo (C153/C155)', - }, - { - label: 'Notícias (≤90 dias)', - value: `${formatInteger(research.news.length)} matéria(s)`, - hint: 'Pesquisa web datada', - }, - ] +/** + * Local movement of the 2º turno (web): acts, palanques and positioning of + * local leaders in the Lula × Flávio race — what the source says, dated. A + * missing item is the explicit gap (page 1 and the section). + */ +const buildSecondRoundMovementBlock = (research, fallback) => { + const movement = researchAnswerItem(research, 'segundo_turno', fallback) + if (!movement) { + return gapCallout( + [ + { + id: 'segundo_turno', + label: 'Movimento local do 2º turno', + reason: 'Não pesquisado.', + }, + ], + 'Movimento local do 2º turno (pesquisa)', + ) + } + return { + kind: 'callout', + tone: 'info', + title: 'Movimento local do 2º turno (pesquisa)', + body: [`${movement.text} (fonte: pesquisa web · ${formatDateBr(movement.source.date)})`], + } } -const buildAnnouncePanel = (research, fallback) => { +/** The ask on the palanque: Lula 13, anchored in the confirmed local relation. */ +const buildAskPanel = (research, fallback) => { const relacao = researchAnswerItem(research, 'relacao_campo', fallback) - const items = [] + const items = [ + { + text: 'Pedido no palanque: Lula 13 — mobilizar, proteger e dar voz; sem prometer transferência de voto.', + }, + ] if (relacao) { - // A fonte da relação local já aparece em "Quem é quem" na mesma página. items.push({ text: `Relação local confirmada: ${relacao.text}` }) } - // Uma linha invariante em vez de três: a página 1 é "uma olhada" e o - // conteúdo fixo repetido não pode competir com os indícios da cidade. - items.push({ - text: 'Pauta/entrega, agradecimento e compromisso novo — sempre com papel confirmado, fonte e data.', - }) return { kind: 'panel', tone: 'decision', - title: 'O que anunciar agora', + title: 'O que pedir e falar', items, - note: ADVERTENCIA_DEFESO, + note: SECOND_ROUND_GUARDRAIL, } } const buildRiskCards = ({ leaderships, advisors, conjuncture }, research, fallback) => { - const opposition = - researchAnswerItem(research, 'disputa_local', fallback) ?? - researchAnswerItem(research, 'quem_investe', fallback) + const opposition = researchAnswerItem(research, 'disputa_local', fallback) return [ { title: 'Oposição local', @@ -341,53 +341,16 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research, fallba ] } -/** - * Page-1 gaps: the emendas gap is not repeated here — it is carried by the - * "O que Solla entregou" KPI and, when the agent researched it, by the web - * evidence block ("nunca zero silencioso" stays explicit on those surfaces). - */ -const collectPageOneGaps = ({ electoral, prefeito }) => { +/** Page-1 gaps: a missing prefeito is the only research hole summarized here. */ +const collectPageOneGaps = ({ prefeito }) => { const gaps = [] if (!prefeito) gaps.push({ id: 'prefeito', label: 'Prefeito(a)', reason: 'Não pesquisado.' }) - if (!electoral.rank2022) gaps.push({ id: 'rank', label: 'Rank', reason: 'Fora do artefato TSE.' }) return gaps } -const buildEmendasEvidenceBlock = (research, emendas, fallback) => { - if (emendas?.status === 'ok') return null - const indicators = research.emendasIndicators ?? [] - if (!indicators.length) { - const evidence = researchAnswerItem(research, 'emendas_web', fallback) - if (!evidence) return null - return { - kind: 'callout', - tone: 'decision', - title: EMENDAS_EVIDENCE_TITLE, - body: [evidence.text, 'Não somar região/polo como emenda da cidade; URLs nas fontes.'], - } - } - const { items: shown, remaining } = capList(indicators, PAGE_ONE_INDICATOR_LIMIT) - return { - kind: 'indicatorList', - title: EMENDAS_EVIDENCE_TITLE, - items: shown.map((indicator) => ({ - author: indicator.author, - sphere: indicator.sphere, - sphereLabel: EMENDA_SPHERE_LABELS[indicator.sphere] ?? indicator.sphere, - detail: [indicator.purpose, indicator.value, indicator.year].filter(Boolean).join(' · '), - source: sourceWeb(indicator.author, indicator.sourceUrl, indicator.sourceDate), - })), - remaining, - note: 'Não somar região/polo como emenda da cidade; a atribuição oficial fica com o Portal da Transparência.', - } -} - -const buildPageOne = ({ snapshot, research, emendas, generatedAt, textFallback }) => { +const buildPageOne = ({ snapshot, research, generatedAt, textFallback }) => { const prefeito = researchAnswerItem(research, 'prefeito', textFallback) - const gapCalloutBlock = gapCallout( - collectPageOneGaps({ electoral: snapshot.electoral, prefeito }), - 'Pontos sem leitura', - ) + const gapCalloutBlock = gapCallout(collectPageOneGaps({ prefeito }), 'Pontos sem leitura') return { blocks: [ @@ -397,44 +360,32 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt, textFallback } columns: 2, cells: [ { - title: 'Conta eleitoral 2022', - blocks: [{ kind: 'kpis', items: buildElectoralKpis(snapshot) }], + title: '2º turno — Lula × Flávio', + blocks: buildSecondRoundPageBlocks({ snapshot }), }, { - title: 'Quem é quem', + title: 'Quem mobiliza', blocks: [ { kind: 'stats', rows: buildWhoRows(snapshot, research, prefeito, textFallback) }, ], }, ], sources: [ + sourceTeqo('lideranças, responsáveis e prioridade', snapshot.meta?.readAt), + sourceOfficial('TSE — 1º turno 2026 (presidente, por seção) e 2022', null, null), sourceTeqo( - 'base Teqo — TSE 2022, expectativa de votos e lideranças', - snapshot.meta?.readAt, + 'artefato do 2º turno (pnpm build:second-round)', + BAHIA_SECOND_ROUND_GENERATED_AT, ), ], }, - { - kind: 'kpis', - title: 'O que Solla entregou', - items: buildDeliveredKpis(snapshot, research, emendas), - sources: [ - sourceOfficial( - 'Portal da Transparência — emendas', - emendas?.sourceUrl, - emendas?.consultedAt, - ), - sourceTeqo('base Teqo — acervo de falas', snapshot.meta?.readAt), - sourceWeb('pesquisa web — imprensa', null, research.researchedAt), - ], - }, - buildEmendasEvidenceBlock(research, emendas, textFallback), - buildAnnouncePanel(research, textFallback), + buildSecondRoundMovementBlock(research, textFallback), { kind: 'cards', title: 'Riscos — oposição/disputa local', items: buildRiskCards(snapshot, research, textFallback), }, + buildAskPanel(research, textFallback), gapCalloutBlock, { kind: 'footerNote', @@ -444,93 +395,6 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt, textFallback } } } -const buildElectoralSection = ({ snapshot }) => { - const { municipality, electoral } = snapshot - const years = [2014, 2018, 2022] - const rows = years.map((year) => { - const votes = electoral.series.find((point) => point.year === year)?.votes ?? null - const rank = getMunicipalityVoteRank(municipality.slug, year) - const validVotes = getMunicipalityFederalBaseline(municipality.slug).validVotesByYear[ - String(year) - ] - return { - year: String(year), - votes: votes === null ? '—' : formatInteger(votes), - share: rank ? formatPercent(rank.share) : '—', - rankLabel: formatRank(rank), - validVotes: validVotes ? formatInteger(validVotes) : '—', - } - }) - - const gap = electoral.series.length - ? null - : { - id: 'serie', - label: 'Série 2014/2018/2022', - reason: 'Sem série na base para este município.', - } - - return [ - { - kind: 'table', - title: 'Conta eleitoral completa', - columns: [ - { key: 'year', label: 'Ano' }, - { key: 'votes', label: 'Votos', numeric: true }, - { key: 'share', label: '% do próprio voto', numeric: true }, - { key: 'rankLabel', label: 'Rank', numeric: true }, - { key: 'validVotes', label: 'Válidos do cargo', numeric: true }, - ], - rows, - sources: [sourceTeqo('base Teqo — artefato TSE 2014/2018/2022', snapshot.meta?.readAt)], - }, - gap ? gapCallout([gap], 'Lacunas desta seção') : null, - ].filter(Boolean) -} - -/** - * Local movement of the 2º turno (web): acts, palanques and positioning of - * local leaders in the Lula × Flávio race. A missing item is the explicit gap. - */ -const buildSecondRoundResearchBlock = (research) => { - const item = researchItemById(research, 'segundo_turno') - if (!item) { - return gapCallout( - [ - { - id: 'segundo_turno', - label: 'Movimento local do 2º turno', - reason: 'Não pesquisado.', - }, - ], - 'Lacuna desta seção', - ) - } - const sources = sourcesOf(item) - return { - kind: 'table', - title: '2º turno — movimento local e palanque (pesquisa)', - columns: [ - { key: 'detail', label: 'O que a fonte diz', width: 72 }, - { key: 'source', label: 'Fonte', width: 28 }, - ], - rows: [ - { - detail: withInlineSources(item.details ?? item.answer, sources), - source: formatDateBr(item.sourceDate), - sources, - }, - ], - note: 'Atos, palanques e posicionamentos locais na disputa Lula × Flávio — fato publicado com fonte datada.', - sources: sources.map((url) => ({ - kind: 'web', - label: 'Movimento local do 2º turno', - url, - date: item.sourceDate, - })), - } -} - /** * 2º turno section (Lula × Flávio): deterministic numbers from the committed * artifact (official TSE 2026 president T1 per section, aggregated to the @@ -551,7 +415,7 @@ const buildSecondRoundSection = ({ snapshot, research }) => { ], 'Lacunas desta seção', ), - buildSecondRoundResearchBlock(research), + buildSecondRoundMovementTable(research), ].filter(Boolean) } @@ -622,28 +486,6 @@ const buildSecondRoundSection = ({ snapshot, research }) => { ) } - const kpis = [ - { - label: 'Lula em 2026', - value: formatPercent(shares.share26 / 100), - hint: `dos válidos · Δ ${signedPoints(shares.deltaPp)} vs 2022`, - }, - { - label: 'Votos fora do 2º turno', - value: potential ? `X₂ = ${formatInteger(potential.x2)}` : '—', - hint: potential - ? `X₁ = ${formatInteger(potential.x1)} já no comparecimento; X₂ soma os ausentes` - : 'sem cenário para os números do recorte', - }, - { - label: 'Solla 2026', - value: formatInteger(unit.solla26), - hint: `Δ ${signedInteger(unit.dSolla)} vs 2022${ - sollaRatio !== null ? ` · ${formatPercent(sollaRatio / 100)} dos votos de Lula` : '' - }`, - }, - ] - return [ { kind: 'callout', @@ -652,7 +494,11 @@ const buildSecondRoundSection = ({ snapshot, research }) => { body: calloutBody, sources, }, - { kind: 'kpis', items: kpis, sources: [sources[0]] }, + { + kind: 'kpis', + items: secondRoundKpiItems(unit, potential, shares, sollaRatio), + sources: [sources[0]], + }, { kind: 'table', title: 'A conta 2022 → 1º turno 2026 (presidente e Solla 1313)', @@ -676,48 +522,59 @@ const buildSecondRoundSection = ({ snapshot, research }) => { text: 'X₁ = votos já dentro do comparecimento de 2026 que não foram de Lula nem de Flávio (nulos, brancos e terceira via); X₂ = X₁ + ausentes. São votos em jogo, não transferência garantida: o cenário de 2º turno é hipotético. Persuasão em eleição geral tem efeito médio próximo de zero — o papel de Solla é mobilizar, proteger e dar voz, com contato na última semana.', sources, }, - buildSecondRoundResearchBlock(research), + buildSecondRoundMovementTable(research), ].filter(Boolean) } /** - * Page-1 one-liner of the 2º turno was intentionally left out: page 1 is a - * measured, fail-closed surface that already sits at the budget for the - * heaviest cities, and deterministic content cannot be shrunk by the text - * fallback. The 2º turno lives in its own section (right after the electoral - * account), with the full artifact numbers. + * The movement table of the deep dive: what the source says (with inline + * sources) + the date. The page-1 block prints the researched summary instead. */ - -const COMPETITOR_SERIES_YEARS = ['2014', '2018', '2022'] - -const buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({ - kind: 'table', - title, - columns: [ - { key: 'name', label: 'Candidato' }, - { key: 'party', label: 'Partido' }, - ...COMPETITOR_SERIES_YEARS.map((year) => ({ key: `y${year}`, label: year, numeric: true })), - ], - rows: rows.map((row) => ({ - name: row.name, - party: row.party ?? '—', - ...Object.fromEntries( - COMPETITOR_SERIES_YEARS.map((year) => [ - `y${year}`, - year in (row.votesByYear ?? {}) ? formatInteger(row.votesByYear[year]) : '—', - ]), - ), - })), - note: `Top 5 por votos de ${referenceYear} no município (TSE, 1º turno, votos nominais); “—” = sem votos na série.`, - sources: [sourceTeqo('base Teqo — TSE 2014/2018/2022', readAt)], -}) +const buildSecondRoundMovementTable = (research) => { + const item = researchItemById(research, 'segundo_turno') + if (!item) { + return gapCallout( + [ + { + id: 'segundo_turno', + label: 'Movimento local do 2º turno', + reason: 'Não pesquisado.', + }, + ], + 'Lacuna desta seção', + ) + } + const sources = sourcesOf(item) + return { + kind: 'table', + title: '2º turno — movimento local e palanque (pesquisa)', + columns: [ + { key: 'detail', label: 'O que a fonte diz', width: 72 }, + { key: 'source', label: 'Fonte', width: 28 }, + ], + rows: [ + { + detail: withInlineSources(item.details ?? item.answer, sources), + source: formatDateBr(item.sourceDate), + sources, + }, + ], + note: 'Atos, palanques e posicionamentos locais na disputa Lula × Flávio — fato publicado com fonte datada.', + sources: sources.map((url) => ({ + kind: 'web', + label: 'Movimento local do 2º turno', + url, + date: item.sourceDate, + })), + } +} const buildOppositionBlock = (research) => { const entries = research.opposition ?? [] if (!entries.length) return null return { kind: 'table', - title: 'Oposição — o que usar com fonte (pesquisa)', + title: 'Frente de oposição — o que usar com fonte (pesquisa)', columns: [ { key: 'topic', label: 'Tema', width: 22 }, { key: 'detail', label: 'O que a fonte diz', width: 62 }, @@ -742,75 +599,6 @@ const buildOppositionBlock = (research) => { } } -const buildCompetitorsSection = ({ snapshot, research }) => { - const competitors = snapshot.competitors ?? { referenceYear: 2022, federal: [], state: [] } - const offices = [ - { key: 'federal', label: 'Deputado federal' }, - { key: 'state', label: 'Deputado estadual' }, - ] - const blocks = offices.map((office) => { - const rows = competitors[office.key] ?? [] - return rows.length - ? buildCompetitorsTable({ - title: `Concorrentes — ${office.label}`, - rows, - referenceYear: competitors.referenceYear ?? 2022, - readAt: snapshot.meta?.readAt, - }) - : { - kind: 'callout', - tone: 'gap', - title: `Concorrentes — ${office.label}`, - body: ['Sem série de concorrentes na base para este município.', GAP_COPY], - } - }) - - const preCandidates = research.preCandidates ?? [] - blocks.push( - preCandidates.length - ? { - kind: 'table', - title: 'Prováveis candidatos do campo do prefeito em 2026 (pesquisa)', - columns: [ - { key: 'name', label: 'Nome' }, - { key: 'office', label: 'Cargo' }, - { key: 'party', label: 'Partido' }, - { key: 'support', label: 'Apoio' }, - { key: 'source', label: 'Fonte' }, - ], - rows: preCandidates.map((item) => { - const sources = sourcesOf(item) - return { - name: item.name, - office: item.office, - party: item.party ?? '—', - support: withInlineSources(item.support ?? '—', sources), - source: formatDateBr(item.sourceDate), - sources, - } - }), - note: 'Nomes declarados em fontes datadas; o histórico de votos vem das tabelas acima quando o nome aparece na base.', - sources: preCandidates.map((item) => ({ - kind: 'web', - label: `${item.name} — ${item.office}`, - url: item.sourceUrl, - date: item.sourceDate, - })), - } - : { - kind: 'callout', - tone: 'gap', - title: 'Prováveis candidatos do campo do prefeito em 2026', - body: ['Nenhum pré-candidato pesquisado com fonte.', GAP_COPY], - }, - ) - - const opposition = buildOppositionBlock(research) - if (opposition) blocks.push(opposition) - - return blocks -} - const buildNetworkSection = ({ snapshot, research }) => { const { leaderships, advisors } = snapshot const rows = leaderships.rows.map((row) => ({ @@ -838,7 +626,7 @@ const buildNetworkSection = ({ snapshot, research }) => { leaderships.totalCount > rows.length ? `Mostrando ${rows.length} de ${leaderships.totalCount} lideranças (mesmo corte do dossiê).` : null, - sources: [sourceTeqo('base Teqo — lideranças e pledges', snapshot.meta?.readAt)], + sources: [sourceTeqo('lideranças e pledges', snapshot.meta?.readAt)], }) } else { blocks.push({ @@ -867,7 +655,7 @@ const buildNetworkSection = ({ snapshot, research }) => { : 'sem registro', }, ], - sources: [sourceTeqo('base Teqo — pledges', snapshot.meta?.readAt)], + sources: [sourceTeqo('pledges', snapshot.meta?.readAt)], }) const cityLeaders = research?.leaders ?? [] @@ -912,7 +700,7 @@ const buildNetworkSection = ({ snapshot, research }) => { blocks.push( ...buildSourcedListBlocks({ title: 'Dobradinhas (pesquisa)', - note: 'Com quem Solla soma na cidade em 2026 (estadual/federal, chapa e lideranças que transferem voto) — insumo direto da abordagem sugerida; cada item com URL.', + note: 'Com quem Solla soma na cidade (estadual/federal, chapa e lideranças que transferem voto) — insumo direto da abordagem sugerida; cada item com URL.', items: research?.alliances ?? [], }), ) @@ -980,18 +768,6 @@ const buildConjunctureSection = ({ snapshot, research }) => { : 'Sem nível', hint: conjuncture?.levelNote ?? null, }, - { - label: 'Expectativa de votos', - value: conjuncture?.expectedVotes - ? ['pessimistic', 'central', 'optimistic'] - .map( - (scenario) => - `${voteEstimateScenarioLabels[scenario]} ${formatInteger(conjuncture.expectedVotes[scenario])}`, - ) - .join(' · ') - : '—', - hint: 'Cenários da base', - }, ] const blocks = [ @@ -999,7 +775,7 @@ const buildConjunctureSection = ({ snapshot, research }) => { kind: 'stats', title: 'Conjuntura', rows, - sources: [sourceTeqo('base Teqo — estratégia do município', snapshot.meta?.readAt)], + sources: [sourceTeqo('estratégia do município', snapshot.meta?.readAt)], }, ] @@ -1023,134 +799,9 @@ const buildConjunctureSection = ({ snapshot, research }) => { if (conjuncture?.nextSteps) { blocks.push({ kind: 'prose', title: 'Próximos passos', text: conjuncture.nextSteps }) } - if (conjuncture?.budgetNotes) { - blocks.push({ - kind: 'prose', - title: 'Emendas aportadas (nota da base)', - text: conjuncture.budgetNotes, - sources: [sourceTeqo('base Teqo — nota manual', snapshot.meta?.readAt)], - }) - } - blocks.push( - ...buildSourcedListBlocks({ - title: 'Investimentos e obras (pesquisa)', - note: 'O que Solla, o governo do estado e o federal entregaram, anunciaram ou têm em obra no município e na região — com fase/valor quando a fonte informar; cada item com URL. Empenho ≠ pagamento.', - items: research?.investments ?? [], - }), - ) - - return blocks -} - -const buildSignalsSection = ({ snapshot }) => { - const { signals } = snapshot - if (!signals.rows.length) { - return [ - { - kind: 'callout', - tone: 'gap', - title: 'Sinais recentes', - body: ['Nenhum sinal registrado para o município na base.', GAP_COPY], - }, - ] - } - return [ - { - kind: 'table', - title: 'Sinais recentes', - columns: [ - { key: 'date', label: 'Data' }, - { key: 'polarity', label: 'Polaridade' }, - { key: 'flags', label: 'Sinal' }, - { key: 'responsible', label: 'Responsável' }, - { key: 'excerpt', label: 'Resumo' }, - ], - rows: signals.rows.map((row) => ({ - date: formatDateBr(row.createdAt), - polarity: municipalityUpdatePolarityLabels[row.polarity] ?? '—', - flags: [row.urgent ? 'Urgente' : null, row.adversarySignal ? 'Adversário' : null] - .filter(Boolean) - .join(' · '), - responsible: row.responsibleName ?? '—', - excerpt: row.body ? excerptDeepDive(row.body, SIGNAL_EXCERPT_MAX) : '—', - })), - note: - signals.totalCount > signals.rows.length - ? `Mostrando ${signals.rows.length} de ${signals.totalCount} sinais (mesmo corte do dossiê).` - : null, - sources: [sourceTeqo('base Teqo — sinais do município', snapshot.meta?.readAt)], - }, - ] -} - -const buildDemandsVisitsSection = ({ snapshot }) => { - const { demands, activities } = snapshot - const blocks = [] - - if (demands.rows.length) { - blocks.push({ - kind: 'table', - title: 'Demandas', - columns: [ - { key: 'title', label: 'Demanda' }, - { key: 'kind', label: 'Tipo' }, - { key: 'status', label: 'Status' }, - { key: 'updatedAt', label: 'Última atualização' }, - ], - rows: demands.rows.map((row) => ({ - title: row.title, - kind: campaignDemandKindLabels[row.kind] ?? row.kind, - status: campaignDemandStatusLabels[row.status] ?? row.status, - updatedAt: formatDateBr(row.updatedAt), - })), - note: - demands.totalCount > demands.rows.length - ? `Mostrando ${demands.rows.length} de ${demands.totalCount} demandas.` - : null, - sources: [sourceTeqo('base Teqo — demandas', snapshot.meta?.readAt)], - }) - } else { - blocks.push({ - kind: 'callout', - tone: 'gap', - title: 'Demandas', - body: ['Nenhuma demanda vinculada ao município na base.', GAP_COPY], - }) - } - - const activityRows = [ - ...activities.upcoming.map((activity) => ({ ...activity, bucket: 'Agendada' })), - ...activities.recent.map((activity) => ({ ...activity, bucket: 'Realizada' })), - ] - if (activityRows.length) { - blocks.push({ - kind: 'table', - title: 'Visitas e agenda', - columns: [ - { key: 'bucket', label: 'Situação' }, - { key: 'date', label: 'Data' }, - { key: 'title', label: 'Atividade' }, - { key: 'locality', label: 'Local' }, - { key: 'status', label: 'Status' }, - ], - rows: activityRows.map((activity) => ({ - bucket: activity.bucket, - date: activity.startAt ? formatDateTimeBr(activity.startAt) : '—', - title: activity.title, - locality: activity.locality ?? '—', - status: activityStatusLabels[activity.status] ?? activity.status, - })), - sources: [sourceTeqo('base Teqo — atividades', snapshot.meta?.readAt)], - }) - } else { - blocks.push({ - kind: 'callout', - tone: 'gap', - title: 'Visitas e agenda', - body: ['Nenhuma atividade vinculada ao município na base.', GAP_COPY], - }) - } + const opposition = buildOppositionBlock(research) + if (opposition) blocks.push(opposition) return blocks } @@ -1268,174 +919,42 @@ const buildTransportSection = ({ research }) => items: research.transport ?? [], }) -/** - * YouTube link starts at the Câmara excerpt of the speech (`excerptTMs` − - * session start), calibrated by the live video: the 2018-03-13 session video - * starts ~37s after `eventStartAt`, so the excerpt at 645s plays at 608s - * (`youtube.com/live/2cX_gKkJH7Q?t=608`, checked in the browser). `speechAt` is - * the API slot clock, not when the deputy speaks. - */ -const YOUTUBE_VIDEO_OFFSET_SECONDS = 37 - -const withYoutubeTimestamp = (url, offsetSeconds) => { - if (!url) return null - if (!Number.isFinite(offsetSeconds) || offsetSeconds <= 0) return url - const separator = url.includes('?') ? '&' : '?' - return `${url}${separator}t=${Math.floor(offsetSeconds)}s` -} - -const youtubeStartSeconds = (row) => - Number.isFinite(row.youtubeExcerptStartSeconds) - ? Math.max(0, Math.floor(row.youtubeExcerptStartSeconds) - YOUTUBE_VIDEO_OFFSET_SECONDS) - : null - -const speechTopicLabel = (value) => - SPEECH_TOPICS.find((topic) => topic.value === value)?.label ?? value - -/** - * Why the row is in the table decides the "mention" text: a city row is tagged - * with the município in the acervo; a region row is tagged with another - * município of the same Território; a theme row is selected by topic and was - * NOT necessarily tagged with any of them — so it shows the theme, never a - * false "menção ao município". - */ -const speechMention = (row, variant) => { - if (row.mentionExcerpt) return excerptDeepDive(row.mentionExcerpt, SPEECH_MENTION_MAX) - const count = row.mentionedMunicipalityCount - if (variant === 'region') { - return count - ? `Marcada no acervo com município da região — o nome não aparece nos trechos (a fala cita ${formatInteger(count)} municípios).` - : 'Marcada no acervo com município da região — trecho não localizado.' - } - return count - ? `Marcada no acervo — nome não localizado nos trechos (a fala cita ${formatInteger(count)} municípios).` - : 'Marcada no acervo — trecho não localizado.' -} - -const projectSpeechTableRow = (row, variant) => { - const base = { - date: formatDateBr(row.speechAt), - phase: row.phase ?? '—', - description: row.summary ? excerptDeepDive(row.summary, SPEECH_SUMMARY_MAX) : '—', - video: row.youtubeUrl - ? withYoutubeTimestamp(row.youtubeUrl, youtubeStartSeconds(row)) - : (row.vodPlaybackUrl ?? '—'), - transcript: row.officialTextUrl ?? '—', - } - if (variant === 'theme') { - const labels = (row.topics ?? []).map(speechTopicLabel).join(' · ') - return { ...base, topics: labels || '—' } - } - return { ...base, mention: speechMention(row, variant) } -} - -const buildSpeechTable = ({ title, rows, variant = 'city', cityLabel, note, readAt }) => { - const middle = - variant === 'theme' - ? { key: 'topics', label: 'Tema', width: 27 } - : { - key: 'mention', - label: variant === 'region' ? 'Município da região citado' : `Menção a ${cityLabel}`, - width: 27, - } - return { - kind: 'table', - title, - columns: [ - { key: 'date', label: 'Data', width: 9 }, - { key: 'phase', label: 'Fase', width: 10 }, - { key: 'description', label: 'O que é', width: 32 }, - middle, - { key: 'video', label: 'Vídeo', width: 11 }, - { key: 'transcript', label: 'Transcrição', width: 11 }, - ], - rows: rows.map((row) => projectSpeechTableRow(row, variant)), - note, - sources: [sourceTeqo('base Teqo — acervo C153/C155', readAt)], - } -} - -const buildSpeechesSection = ({ snapshot }) => { - const { speeches, municipality } = snapshot - const cityLabel = municipality?.name ?? 'o município' - const cityRows = speeches.rows ?? [] - const region = speeches.region ?? { label: null, rows: [] } - const topics = speeches.topics ?? { themes: [], rows: [] } - const regionRows = region.rows ?? [] - const themeRows = topics.rows ?? [] - - if (!cityRows.length && !regionRows.length && !themeRows.length) { - return [ - { - kind: 'callout', - tone: 'gap', - title: 'Acervo de falas', - body: [ - `Busca por município no acervo: nenhum trecho encontrado (${formatInteger(speeches.totalCount)} no total) e nenhum recorte regional/temático.`, - GAP_COPY, - ], - }, - ] - } - +const buildNewsSection = ({ research }) => { const blocks = [] - if (cityRows.length) { - blocks.push( - buildSpeechTable({ - title: 'Acervo de falas', - rows: cityRows, - variant: 'city', - cityLabel, - note: [ - '“O que é” = sumário oficial da Câmara; “Menção” = passagem que cita o município ou, quando o nome não aparece nos trechos, a marcação do acervo (C153/C155) com o nº de municípios da fala; “Vídeo” e “Transcrição” = links oficiais da Câmara quando existem.', - speeches.totalCount > cityRows.length - ? `Mostrando ${cityRows.length} de ${speeches.totalCount} falas.` - : null, - ] - .filter(Boolean) - .join(' '), - readAt: snapshot.meta?.readAt, - }), - ) + if (research.news.length) { + blocks.push({ + kind: 'table', + title: 'Notícias e imprensa local', + // Título é o texto mais longo: larguras explícitas tornam a tabela + // `table-fixed` (URL quebra em vez de empurrar a coluna) e dão a ela a + // maior fatia — sem largura, a URL sem espaços infla a coluna Link. + columns: [ + { key: 'date', label: 'Data', width: 9 }, + { key: 'outlet', label: 'Veículo', width: 14 }, + { key: 'title', label: 'Título', width: 49 }, + { key: 'link', label: 'Link', width: 28 }, + ], + rows: research.news.map((item) => ({ + date: formatDateBr(item.publishedAt), + outlet: item.outlet ?? '—', + title: item.title, + link: item.url, + })), + note: 'Janela de 90 dias; cada item com URL.', + sources: [sourceWeb('pesquisa web datada', null, research.researchedAt)], + }) } else { blocks.push({ kind: 'callout', - tone: 'info', - title: 'Acervo de falas', - body: [ - `Nenhuma fala cita ${cityLabel} diretamente; abaixo vão o recorte da região e os temas do mandato (não somar região/polo como fala da cidade).`, - ], + tone: 'gap', + title: 'Notícias e imprensa local', + body: ['Nenhuma matéria com fonte e data dentro da janela de 90 dias.', GAP_COPY], }) } - if (regionRows.length) { - blocks.push( - buildSpeechTable({ - title: `Acervo — menções à região (${region.label ?? 'Território de Identidade'})`, - rows: regionRows, - variant: 'region', - note: `Falas marcadas no acervo com municípios do mesmo Território de Identidade (${region.label ?? 'região'}) — a região NÃO é a cidade; use como contexto, nunca some à conta do município.`, - readAt: snapshot.meta?.readAt, - }), - ) - } - - if (themeRows.length) { - blocks.push( - buildSpeechTable({ - title: 'Acervo — falas por tema regional', - rows: themeRows, - variant: 'theme', - note: `Falas do mandato **selecionadas por tema** (${(topics.themes ?? []) - .map(speechTopicLabel) - .join( - ', ', - )}) — o critério é o tema, não uma menção ao município ou à região; use como repertório do tema, sem somar à conta da cidade.`, - readAt: snapshot.meta?.readAt, - }), - ) - } - + const newsGaps = research.gaps.filter((gap) => gap.id.startsWith('noticia_')) + const callout = gapCallout(newsGaps, 'Notícias descartadas') + if (callout) blocks.push(callout) return blocks } @@ -1480,87 +999,6 @@ const buildApproachSection = ({ research }) => { ] } -const buildNewsSection = ({ research }) => { - const blocks = [] - if (research.news.length) { - blocks.push({ - kind: 'table', - title: 'Notícias internas e imprensa local', - // Título é o texto mais longo: larguras explícitas tornam a tabela - // `table-fixed` (URL quebra em vez de empurrar a coluna) e dão a ela a - // maior fatia — sem largura, a URL sem espaços infla a coluna Link. - columns: [ - { key: 'date', label: 'Data', width: 9 }, - { key: 'outlet', label: 'Veículo', width: 14 }, - { key: 'title', label: 'Título', width: 49 }, - { key: 'link', label: 'Link', width: 28 }, - ], - rows: research.news.map((item) => ({ - date: formatDateBr(item.publishedAt), - outlet: item.outlet ?? '—', - title: item.title, - link: item.url, - })), - note: 'Janela de 90 dias; cada item com URL.', - sources: [sourceWeb('pesquisa web datada', null, research.researchedAt)], - }) - } else { - blocks.push({ - kind: 'callout', - tone: 'gap', - title: 'Notícias internas e imprensa local', - body: [ - 'Nenhuma matéria com fonte e data dentro da janela de 90 dias.', - 'Nenhum post interno relacionado ao município (a base não liga Post a município).', - GAP_COPY, - ], - }) - } - - const newsGaps = research.gaps.filter((gap) => gap.id.startsWith('noticia_')) - const callout = gapCallout(newsGaps, 'Notícias descartadas') - if (callout) blocks.push(callout) - return blocks -} - -const buildRegionSection = ({ snapshot }) => { - const panorama = buildTerritoryPanorama(snapshot.municipality.slug) - if (!panorama) { - return [ - { - kind: 'callout', - tone: 'gap', - title: 'Panorama regional', - body: ['Município fora do catálogo/artefato TSE.', GAP_COPY], - }, - ] - } - return [ - { - kind: 'stats', - title: 'Panorama regional (Território de Identidade)', - rows: [ - { label: 'Território', value: panorama.region }, - { label: 'Municípios no TI', value: formatInteger(panorama.municipalityCount) }, - { label: `Votos do TI em ${panorama.year}`, value: formatInteger(panorama.regionVotes) }, - { - label: '% do voto do candidato no estado', - value: formatPercent(panorama.regionShare), - }, - { - label: 'Posição do município no TI', - value: `${formatInteger(panorama.municipalityRank)}º de ${formatInteger(panorama.municipalityTotalUnits)}`, - }, - { - label: 'Votos do município no TI', - value: formatInteger(panorama.municipalityVotes), - }, - ], - sources: [sourceTeqo('artefato TSE commitado — leitura relativa', null)], - }, - ] -} - /** * Page 1 never cuts text (C188): it prints the item's `summary` or the full * `answer`, degrading to a pointer when the page does not fit. This table keeps @@ -1589,11 +1027,11 @@ const buildResearchAnswersTable = (research) => } : null -const buildSourcesSection = ({ snapshot, research, emendas }) => { +const buildSourcesSection = ({ snapshot, research }) => { const items = [] items.push({ kind: 'teqo', - label: `Base Teqo — leitura read-only em ${formatDateTimeBr(snapshot.meta?.readAt)} (${snapshot.meta?.database ?? 'produção'})`, + label: `Leitura read-only em ${formatDateTimeBr(snapshot.meta?.readAt)} (${snapshot.meta?.database ?? 'produção'})`, date: snapshot.meta?.readAt, }) for (const item of research.items) { @@ -1612,21 +1050,6 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => { }) } } - if (emendas?.status === 'ok') { - items.push({ - kind: 'official', - label: `Portal da Transparência — emendas de ${emendas.authorName ?? 'autor'} (${emendas.rows.length} registro(s))`, - url: emendas.sourceUrl, - date: emendas.consultedAt, - }) - } else if (emendas) { - items.push({ - kind: 'official', - label: `Portal da Transparência — sem resultado utilizável (${emendas.reason})`, - url: emendas.sourceUrl, - date: emendas.consultedAt, - }) - } for (const item of research.news) { items.push({ kind: 'web', label: item.title, url: item.url, date: item.publishedAt }) } @@ -1644,7 +1067,6 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => { transport: 'Transporte', opposition: 'Oposição', alliances: 'Dobradinhas', - investments: 'Investimentos e obras', } for (const listKey of Object.keys(listLabels)) { for (const item of research[listKey] ?? []) { @@ -1666,10 +1088,8 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => { limits: [ `Base Teqo: leitura read-only em ${formatDateTimeBr(snapshot.meta?.readAt)} — o PDF é datado e o dado envelhece (snapshot do momento).`, 'Web: cada item carrega data + URL; o que não veio com fonte virou lacuna.', + '2º turno: números oficiais do artefato TSE 2026 (presidente por seção, agregado à unidade); X₁/X₂ são votos em jogo, não transferência garantida — cenário hipotético.', 'Não existe voto por bairro/seção — o relatório não inventa granularidade.', - 'Efeito eleitoral de emenda é condicional ao crédito local — o PDF não promete voto.', - 'Emendas são lidas da fonte oficial em tempo de geração e não são persistidas na base.', - 'Quando a fonte oficial não atribui emenda ao município, indícios web (município, região ou polo) entram como evidência datada — nunca somados como emenda da cidade.', 'Abordagem sugerida é análise das personas ancorada nas fontes listadas item a item, não fato verificado além delas.', 'Oposição: cada linha é fato publicado com URL + data; o PDF não afirma culpa nem orienta ataque pessoal — use como contraste ancorado.', ], @@ -1677,16 +1097,7 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => { ].filter(Boolean) } -const SNAPSHOT_REQUIRED_GROUPS = [ - 'electoral', - 'pledges', - 'leaderships', - 'advisors', - 'signals', - 'activities', - 'speeches', - 'demands', -] +const SNAPSHOT_REQUIRED_GROUPS = ['pledges', 'leaderships', 'advisors'] const assertSnapshotShape = (snapshot) => { if (!snapshot?.municipality?.slug || !snapshot?.municipality?.name) { @@ -1699,32 +1110,11 @@ const assertSnapshotShape = (snapshot) => { ) } } - if (!Array.isArray(snapshot.electoral?.series)) { - throw new Error('Snapshot inválido: electoral.series ausente — extraia de novo.') - } -} - -/** - * Sections whose only content would be an empty-state callout are omitted - * (sinais/demandas) so the report does not spend a page saying "nothing here". - * Research sections keep their explicit gap callout — a missing source is a - * product lacuna, not noise. - */ -const hasSectionData = (id, snapshot) => { - if (id === 'sinais') return (snapshot.signals?.rows?.length ?? 0) > 0 - if (id === 'demandas') { - return ( - (snapshot.demands?.rows?.length ?? 0) > 0 || - (snapshot.activities?.upcoming?.length ?? 0) + (snapshot.activities?.recent?.length ?? 0) > 0 - ) - } - return true } export const buildCityReport = ({ snapshot, research, - emendas, generatedAt = new Date(), textFallback = 'full', }) => { @@ -1732,21 +1122,11 @@ export const buildCityReport = ({ const generatedAtIso = generatedAt instanceof Date ? generatedAt.toISOString() : generatedAt const sections = [ - { - id: 'conta-eleitoral', - title: 'Conta eleitoral completa (2014/2018/2022)', - blocks: buildElectoralSection({ snapshot }), - }, { id: 'segundo-turno', title: '2º turno no município (Lula × Flávio)', blocks: buildSecondRoundSection({ snapshot, research }), }, - { - id: 'concorrentes', - title: 'Concorrentes no município (federal e estadual)', - blocks: buildCompetitorsSection({ snapshot, research }), - }, { id: 'rede', title: 'Rede e lideranças', @@ -1757,12 +1137,6 @@ export const buildCityReport = ({ title: 'Conjuntura', blocks: buildConjunctureSection({ snapshot, research }), }, - { id: 'sinais', title: 'Sinais recentes', blocks: buildSignalsSection({ snapshot }) }, - { - id: 'demandas', - title: 'Demandas e visitas', - blocks: buildDemandsVisitsSection({ snapshot }), - }, { id: 'demografia', title: 'Demografia', @@ -1778,17 +1152,7 @@ export const buildCityReport = ({ title: 'Transporte e conexões (pesquisa)', blocks: buildTransportSection({ research }), }, - { id: 'falas', title: 'Acervo de falas', blocks: buildSpeechesSection({ snapshot }) }, - { - id: 'noticias', - title: 'Notícias internas e imprensa local', - blocks: buildNewsSection({ research }), - }, - { - id: 'regiao', - title: 'Panorama regional (Território de Identidade)', - blocks: buildRegionSection({ snapshot }), - }, + { id: 'noticias', title: 'Notícias e imprensa local', blocks: buildNewsSection({ research }) }, { id: 'abordagem', title: 'Abordagem sugerida (pesquisa)', @@ -1797,11 +1161,9 @@ export const buildCityReport = ({ { id: 'fontes', title: 'Fontes e limites', - blocks: buildSourcesSection({ snapshot, research, emendas }), + blocks: buildSourcesSection({ snapshot, research }), }, - ] - .filter((section) => hasSectionData(section.id, snapshot)) - .map((section, index) => ({ ...section, title: `${index + 1}. ${section.title}` })) + ].map((section, index) => ({ ...section, title: `${index + 1}. ${section.title}` })) return { meta: { @@ -1818,7 +1180,7 @@ export const buildCityReport = ({ actorRole: snapshot.meta?.actorRole ?? null, researchedAt: research.researchedAt, }, - page1: buildPageOne({ snapshot, research, emendas, generatedAt: generatedAtIso, textFallback }), + page1: buildPageOne({ snapshot, research, generatedAt: generatedAtIso, textFallback }), sections, } } diff --git a/scripts/lib/cityReportRender.mjs b/scripts/lib/cityReportRender.mjs index 2a0c1511..7df4ca86 100644 --- a/scripts/lib/cityReportRender.mjs +++ b/scripts/lib/cityReportRender.mjs @@ -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 - ? `(fonte)` - : '' - const date = item.source?.date ? ` · ${htmlEscape(formatDateBr(item.source.date))}` : '' - return `
  • ${htmlEscape(item.author)}${detail} ${htmlEscape(item.sphereLabel)} ${link}${date}
  • ` - }) - .join('') - const more = - block.remaining > 0 - ? `
  • e mais ${formatInteger(block.remaining)} indício(s) — texto integral nas fontes.
  • ` - : '' - return `
      ${items}${more}
    ${ - block.note ? `

    ${htmlEscape(block.note)}

    ` : '' - }` -} - 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) => {

    ${htmlEscape(report.meta.title)}

    ${htmlEscape(report.meta.municipalityName)}

    -

    ${htmlEscape(report.meta.region)} · prioridade e classe na leitura da base

    +

    ${htmlEscape(report.meta.region)} · leitura da base Teqo e do artefato TSE 2026

    Gerado em ${htmlEscape(report.meta.generatedAtLabel)}

    @@ -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(' | ')} |`) diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs index 347ed7ce..5ce2cc76 100644 --- a/scripts/lib/cityReportResearch.mjs +++ b/scripts/lib/cityReportResearch.mjs @@ -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, } } diff --git a/scripts/lib/cityReportTerritory.mjs b/scripts/lib/cityReportTerritory.mjs deleted file mode 100644 index 1a7052e7..00000000 --- a/scripts/lib/cityReportTerritory.mjs +++ /dev/null @@ -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, - } -} diff --git a/scripts/lib/portalTransparenciaEmendas.mjs b/scripts/lib/portalTransparenciaEmendas.mjs index 4f4f2895..fde6a0b2 100644 --- a/scripts/lib/portalTransparenciaEmendas.mjs +++ b/scripts/lib/portalTransparenciaEmendas.mjs @@ -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 ?? '') diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index ab14fcfa..16e3e166 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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', diff --git a/tests/int/cityReportSnapshot.int.spec.ts b/tests/int/cityReportSnapshot.int.spec.ts index a988d71f..cc14d259 100644 --- a/tests/int/cityReportSnapshot.int.spec.ts +++ b/tests/int/cityReportSnapshot.int.spec.ts @@ -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) + } + }, + ) }) diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 0e62e063..a346e493 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -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() diff --git a/tests/unit/cityReportBatchSkill.unit.spec.ts b/tests/unit/cityReportBatchSkill.unit.spec.ts index 93d037a6..8e428bc3 100644 --- a/tests/unit/cityReportBatchSkill.unit.spec.ts +++ b/tests/unit/cityReportBatchSkill.unit.spec.ts @@ -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( diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts index 4711a7da..bccca5de 100644 --- a/tests/unit/cityReportBlocks.unit.spec.ts +++ b/tests/unit/cityReportBlocks.unit.spec.ts @@ -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, + ) }) }) diff --git a/tests/unit/cityReportRender.unit.spec.ts b/tests/unit/cityReportRender.unit.spec.ts index 41e1fbce..85390c6d 100644 --- a/tests/unit/cityReportRender.unit.spec.ts +++ b/tests/unit/cityReportRender.unit.spec.ts @@ -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( 'https://exemplo.test/prefeito', ) - 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('
      ') - 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('
      ') - 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') - }) }) diff --git a/tests/unit/cityReportResearch.unit.spec.ts b/tests/unit/cityReportResearch.unit.spec.ts index 790f1c8b..29314497 100644 --- a/tests/unit/cityReportResearch.unit.spec.ts +++ b/tests/unit/cityReportResearch.unit.spec.ts @@ -25,8 +25,12 @@ const baseResearch = (overrides: Record = {}) => ({ }) 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' })) }) })