mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
Compare commits
2 commits
c5ccd8e0e7
...
31f78f07ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f78f07ad | ||
|
|
9fae859c0d |
21 changed files with 12851 additions and 36 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: briefing-capacitacao-solla
|
name: briefing-capacitacao-solla
|
||||||
description: 'Gera o Briefing de capacitação Solla 1313 por recorte (cidade · instituição · tema): PDF A4 de até 4 páginas + companion .md, derivado do dossiê já pesquisado (sem segunda pesquisa factual), todo de recorte — princípios/crenças e defesas de Solla com fonte, fatos-âncora e o pedido literal do voto 1313, e perguntas prováveis × melhores respostas dos dois lados; aceita um recorte ou um lote separado por vírgula.'
|
description: 'Gera o Briefing de capacitação Solla 1313 por recorte (cidade · instituição · tema): PDF A4 de até 4 páginas + companion .md, derivado do dossiê já pesquisado (sem segunda pesquisa factual), todo de recorte — princípios/crenças e defesas de Solla com fonte, fatos-âncora e o pedido literal do voto (1313 e, no 2º turno, Lula 13), e perguntas prováveis × melhores respostas dos dois lados; aceita um recorte ou um lote separado por vírgula.'
|
||||||
---
|
---
|
||||||
|
|
||||||
# Briefing de capacitação Solla 1313 (C210)
|
# Briefing de capacitação Solla 1313 (C210)
|
||||||
|
|
@ -13,7 +13,8 @@ capa, manual genérico, roteiro de passos, anti-padrões nem limites:
|
||||||
1. **O que Solla defende** — princípios/crenças do recorte (abertura) + posições
|
1. **O que Solla defende** — princípios/crenças do recorte (abertura) + posições
|
||||||
com lastro (fonte e data);
|
com lastro (fonte e data);
|
||||||
2. **O essencial do recorte** — fatos-âncora com **fase junto ao valor** e o
|
2. **O essencial do recorte** — fatos-âncora com **fase junto ao valor** e o
|
||||||
**pedido literal** do voto + **uma linha** de plano;
|
**pedido literal** do voto (1313 e, no 2º turno, Lula 13) + **uma linha** de
|
||||||
|
plano;
|
||||||
3. **Perguntas prováveis × melhores respostas (1/2 e 2/2)** — as perguntas reais
|
3. **Perguntas prováveis × melhores respostas (1/2 e 2/2)** — as perguntas reais
|
||||||
do recorte, metade do documento, com os dois lados.
|
do recorte, metade do documento, com os dois lados.
|
||||||
|
|
||||||
|
|
@ -29,6 +30,16 @@ invocação) e também roda sozinho. O layout vem do design hi-fi aprovado
|
||||||
`docs/plans/briefing-capacitacao-solla-ui-design.html` — o port é
|
`docs/plans/briefing-capacitacao-solla-ui-design.html` — o port é
|
||||||
classe-a-classe, sem improviso visual.
|
classe-a-classe, sem improviso visual.
|
||||||
|
|
||||||
|
## Missão do 2º turno (Lula × Flávio — 25/10/2026)
|
||||||
|
|
||||||
|
O briefing é o insumo de quem pede voto na rua: desde 08/10/2026 o pedido é
|
||||||
|
**dual** — o voto 1313 (Solla) e, no 2º turno, o voto 13 (Lula). O pedido é
|
||||||
|
**literal do renderer** (não se reescreve no JSON); o `plan` é a única linha em
|
||||||
|
que o autor diz como combinar os dois. Os números do 2º turno (Lula × Flávio,
|
||||||
|
X₁/X₂) **não entram** neste contrato: vivem no relatório de cidade
|
||||||
|
(`relatorio-cidade`, seção "2º turno no município"), e cenário/estimativa seguem
|
||||||
|
proibidos aqui.
|
||||||
|
|
||||||
## Quando usar
|
## Quando usar
|
||||||
|
|
||||||
- A coordenação pede "o briefing de <recorte>" para quem vai panfletar/pedir
|
- A coordenação pede "o briefing de <recorte>" para quem vai panfletar/pedir
|
||||||
|
|
@ -131,7 +142,7 @@ Escrito pelo autor, validado pelo build (`scripts/lib/briefingContent.mjs`):
|
||||||
{ "factId": "era_b_equipamentos", "sourceUrl": "https://…", "title": "≤120", "note": "≤200" },
|
{ "factId": "era_b_equipamentos", "sourceUrl": "https://…", "title": "≤120", "note": "≤200" },
|
||||||
{ "gapReason": "sem fala própria localizada", "title": "…", "note": "…" }
|
{ "gapReason": "sem fala própria localizada", "title": "…", "note": "…" }
|
||||||
],
|
],
|
||||||
"plan": "≤220 chars — uma linha de plano de voto/compromisso nomeado", // obrigatório
|
"plan": "≤220 chars — uma linha de plano de voto/compromisso nomeado (pode dizer como combinar o 1313 com o voto 13 no 2º turno)", // obrigatório
|
||||||
"qa": [ // ≥4, com ≥1 "direita" e ≥1 "esquerda"
|
"qa": [ // ≥4, com ≥1 "direita" e ≥1 "esquerda"
|
||||||
{ "side": "direita|esquerda|entrega", "question": "≤180",
|
{ "side": "direita|esquerda|entrega", "question": "≤180",
|
||||||
"acknowledge": "≤200", "answer": "≤520", "close": "≤200",
|
"acknowledge": "≤200", "answer": "≤520", "close": "≤200",
|
||||||
|
|
@ -151,6 +162,9 @@ Regras duras:
|
||||||
autor copia o `sourceUrl` do item do research no campo `sourceUrl` do âncora
|
autor copia o `sourceUrl` do item do research no campo `sourceUrl` do âncora
|
||||||
(o par `factId` + `sourceUrl` precisa bater — par errado falha fechado);
|
(o par `factId` + `sourceUrl` precisa bater — par errado falha fechado);
|
||||||
- `qa` cobre os dois lados (≥1 `direita` e ≥1 `esquerda`); `entrega` é opcional;
|
- `qa` cobre os dois lados (≥1 `direita` e ≥1 `esquerda`); `entrega` é opcional;
|
||||||
|
desde 08/10/2026, inclua **ao menos uma pergunta de 2º turno** (por que votar
|
||||||
|
em Lula; o que está em jogo para a cidade) — ancorada no mesmo ledger, sem
|
||||||
|
cenário nem número de pesquisa;
|
||||||
- **proibido** qualquer chave de cenário/estimativa/staff-only
|
- **proibido** qualquer chave de cenário/estimativa/staff-only
|
||||||
(`estimatedVotes`, `scenario`, `projection`, `polls`, …) — o briefing não tem
|
(`estimatedVotes`, `scenario`, `projection`, `polls`, …) — o briefing não tem
|
||||||
campo numérico; valor/fase vêm do fato-âncora no render;
|
campo numérico; valor/fase vêm do fato-âncora no render;
|
||||||
|
|
@ -161,8 +175,8 @@ Regras duras:
|
||||||
perguntas** (o build divide a lista ao meio).
|
perguntas** (o build divide a lista ao meio).
|
||||||
|
|
||||||
O pedido é **literal do renderer** (não reescreva no JSON): "Posso contar com
|
O pedido é **literal do renderer** (não reescreva no JSON): "Posso contar com
|
||||||
você? Para deputado federal, **vote 1313, Jorge Solla**." — o `plan` é a única
|
você? Para deputado federal, **vote 1313, Jorge Solla.** E no 2º turno, **vote 13, Lula.**" — o `plan` é a única linha de orientação que o autor escreve
|
||||||
linha de orientação que o autor escreve.
|
(ex.: onde/como combinar o pedido municipal com o voto em Lula).
|
||||||
|
|
||||||
## Recibo do autor
|
## Recibo do autor
|
||||||
|
|
||||||
|
|
@ -221,6 +235,9 @@ já carrega o aviso de insumo.
|
||||||
fonte; lacuna explícita em vez de inferência.
|
fonte; lacuna explícita em vez de inferência.
|
||||||
- **Sem cenário eleitoral/estimativa/staff-only** (nada de `estimatedVotes`,
|
- **Sem cenário eleitoral/estimativa/staff-only** (nada de `estimatedVotes`,
|
||||||
projeção, pesquisa); o briefing não promete efeito.
|
projeção, pesquisa); o briefing não promete efeito.
|
||||||
|
- **Pedido dual literal**: a folha "O pedido" renderiza 1313 + Lula 13 (2º
|
||||||
|
turno) — não é campo do JSON; números de 2º turno continuam fora do briefing
|
||||||
|
(vivem no relatório de cidade).
|
||||||
- **Empenho ≠ pagamento**; esfera explícita nunca somada; leitura relativa
|
- **Empenho ≠ pagamento**; esfera explícita nunca somada; leitura relativa
|
||||||
(nunca % estadual absoluto); PII mínima (telefone/e-mail nunca entram).
|
(nunca % estadual absoluto); PII mínima (telefone/e-mail nunca entram).
|
||||||
- **Voz com dono único**: reusa `.opencode/skills/solla-comunicacao/SKILL.md` +
|
- **Voz com dono único**: reusa `.opencode/skills/solla-comunicacao/SKILL.md` +
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,18 @@ eleitor, sem declaração de fontes) **e** o **Briefing de capacitação** (3º
|
||||||
entregável: até 4 páginas para quem vai pedir o voto 1313, insumo interno). Os
|
entregável: até 4 páginas para quem vai pedir o voto 1313, insumo interno). Os
|
||||||
layouts vêm dos artefatos hi-fi aprovados pelo `designer`.
|
layouts vêm dos artefatos hi-fi aprovados pelo `designer`.
|
||||||
|
|
||||||
|
## Missão do 2º turno (Lula × Flávio — 25/10/2026)
|
||||||
|
|
||||||
|
O dossiê é a **prova material** do 2º turno: o que Solla e o campo PT/federal
|
||||||
|
entregaram à cidade sustenta o pedido de voto para Lula em 25/10. O **boletim
|
||||||
|
modelo** continua sem CTA (insumo de comunicação, não peça de rua); o **briefing
|
||||||
|
de capacitação** (3º entregável) leva o pedido **dual** — 1313 e, no 2º turno,
|
||||||
|
Lula 13 (ver `briefing-capacitacao-solla`). Nada muda no escopo da pesquisa por
|
||||||
|
era (o dossiê é histórico e com fonte; ruído eleitoral de 2026 segue excluído das
|
||||||
|
buscas). O recorte numérico do 2º turno por município/ZE **não vive aqui**: vive
|
||||||
|
no relatório de cidade (`relatorio-cidade`, seção "2º turno no município",
|
||||||
|
artefato de `pnpm build:second-round`) e não deve ser inventado no dossiê.
|
||||||
|
|
||||||
## Quando usar
|
## Quando usar
|
||||||
|
|
||||||
- A comunicação pede "o dossiê de <cidade>", o "boletim modelo de <cidade>"
|
- A comunicação pede "o dossiê de <cidade>", o "boletim modelo de <cidade>"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,49 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
|
||||||
local). O PDF sai para leitura de bolso; o detalhe das etapas está em
|
local). O PDF sai para leitura de bolso; o detalhe das etapas está em
|
||||||
"Pipeline (etapas)".
|
"Pipeline (etapas)".
|
||||||
|
|
||||||
|
## Missão do 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:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
1313, comparecimento, abstenção, brancos+nulos e o cenário **X₁/X₂** do
|
||||||
|
`/potencial` — a MESMA fórmula da página pública, nunca uma segunda conta.
|
||||||
|
Fonte: artefato commitado `src/lib/electionAggregates/bahia-second-round-2026.json`
|
||||||
|
(loader `src/lib/bahiaSecondRound.ts`), gerado por `pnpm build:second-round` a
|
||||||
|
partir do TSE 2026 por seção + da análise 2º turno
|
||||||
|
(`analise-eleicoes-2026/nota-solla-2turno`), com os totais da BA pinados.
|
||||||
|
- **Papel da cidade** (determinístico, `src/lib/secondRoundRole.ts`):
|
||||||
|
`prioridade` (Lula caiu, Solla cresceu com base ≥200 votos — agenda conjunta e
|
||||||
|
contato pessoal), `mobilizacao` (mesmo quadro, base menor), `defesa` (os dois
|
||||||
|
caíram — comparecimento e contenção, sem vitrine), `expansao` (Lula não caiu —
|
||||||
|
presença).
|
||||||
|
- **Movimento local (pesquisa):** o researcher preenche o item de checklist
|
||||||
|
`segundo_turno` (atos, palanques, posições de lideranças locais, o que o 2º
|
||||||
|
turno move na cidade), sempre com `sourceUrl` + `sourceDate`; sem fonte vira
|
||||||
|
lacuna explícita.
|
||||||
|
|
||||||
|
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
|
||||||
|
(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:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Lote (várias cidades)
|
## Lote (várias cidades)
|
||||||
|
|
||||||
A invocação aceita **um** município (caso de sempre) ou **vários**, separados por
|
A invocação aceita **um** município (caso de sempre) ou **vários**, separados por
|
||||||
|
|
@ -223,7 +266,8 @@ de pesquisa que cruza para ele.
|
||||||
```
|
```
|
||||||
|
|
||||||
Checklist (`id`s): `prefeito`, `vice`, `relacao_campo`, `vereadores`,
|
Checklist (`id`s): `prefeito`, `vice`, `relacao_campo`, `vereadores`,
|
||||||
`disputa_local`, `quem_investe`, `noticias`, `imprensa_local`, `emendas_web`.
|
`disputa_local`, `segundo_turno`, `quem_investe`, `noticias`, `imprensa_local`,
|
||||||
|
`emendas_web`.
|
||||||
Item sem `sourceUrl`/`sourceDate` é convertido em lacuna pelo validador; item
|
Item sem `sourceUrl`/`sourceDate` é convertido em lacuna pelo validador; item
|
||||||
ausente também. `approach`, `preCandidates`, `leaders`, `leaderAgenda` e
|
ausente também. `approach`, `preCandidates`, `leaders`, `leaderAgenda` e
|
||||||
`emendasIndicators` são
|
`emendasIndicators` são
|
||||||
|
|
@ -236,6 +280,12 @@ não entra no PDF. Campos de texto livre (`detail`, `hook`, `suggestion`,
|
||||||
`{{fonte:N}}`, resolvidos para `(fonte)` no ponto exato da citação (ver "Links
|
`{{fonte:N}}`, resolvidos para `(fonte)` no ponto exato da citação (ver "Links
|
||||||
clicáveis / fonte inline" adiante).
|
clicáveis / fonte inline" adiante).
|
||||||
|
|
||||||
|
**`segundo_turno` — movimento local da disputa Lula × Flávio:** atos, palanques,
|
||||||
|
posições de lideranças locais e o que o 2º turno move na cidade, com URL+data.
|
||||||
|
Os números oficiais do bloco 2º turno (Lula/Flávio/Solla, X₁/X₂) vêm do
|
||||||
|
**artefato** (`pnpm build:second-round`) — **não** os pesquise nem os estime; a
|
||||||
|
pesquisa é só o movimento local. Sem fonte, vira lacuna explícita na seção.
|
||||||
|
|
||||||
**`leaderAgenda` — pauta provável das lideranças (rede + mesmo campo):** para
|
**`leaderAgenda` — pauta provável das lideranças (rede + mesmo campo):** para
|
||||||
cada liderança que importa na visita, registre o que ela tende a priorizar
|
cada liderança que importa na visita, registre o que ela tende a priorizar
|
||||||
(Saúde, Educação, Infraestrutura…) e o **gancho recente publicado** que ancora a
|
(Saúde, Educação, Infraestrutura…) e o **gancho recente publicado** que ancora a
|
||||||
|
|
@ -325,19 +375,24 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
||||||
`vereadores`, `disputa_local`, `quem_investe`, `emendas_web`): é a única forma
|
`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
|
de a página 1 manter o texto que você redigiu. As listas mostram `e mais N`; o
|
||||||
texto integral
|
texto integral
|
||||||
de cada item fica no aprofundamento (ver "respostas integrais" em `14. Fontes e
|
de cada item fica no aprofundamento (ver "respostas integrais" em "Fontes e
|
||||||
limites`).
|
limites").
|
||||||
- **Seções 2+:** `1. Conta eleitoral` · `2. Concorrentes no município (federal
|
- **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 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
|
e os prováveis candidatos do campo do prefeito (pesquisa), além da **frente de
|
||||||
oposição** quando pesquisada (fatos com fonte) · `3. Rede e
|
oposição** quando pesquisada (fatos com fonte) · `4. Rede e
|
||||||
lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices,
|
lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices,
|
||||||
vereadores mais votados, com partido) e a **Pauta das lideranças (pesquisa)**:
|
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
|
pauta provável + gancho recente por liderança da rede/mesmo campo, marcada
|
||||||
como hipótese · `4. Conjuntura` · `5. Sinais` · `6.
|
como hipótese · `5. Conjuntura` · `6. Sinais` · `7.
|
||||||
Demandas e visitas` · `7. Demografia` — IBGE Censo 2022 do artefato +
|
Demandas e visitas` · `8. Demografia` — IBGE Censo 2022 do artefato +
|
||||||
**complemento pesquisado** (cor/raça e poder aquisitivo) · `8. Atividade
|
**complemento pesquisado** (cor/raça e poder aquisitivo) · `9. Atividade
|
||||||
econômica (pesquisa)` · `9. Transporte e conexões (pesquisa)` · `10. Acervo
|
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"
|
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
|
(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
|
do acervo com o nº de municípios), mais **Vídeo** (YouTube no trecho da fala
|
||||||
|
|
@ -346,8 +401,8 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
||||||
depois do `eventStartAt`, então 645s viram `t=608s` (`YOUTUBE_VIDEO_OFFSET_SECONDS`);
|
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
|
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
|
VOD da Câmara, que é o clipe do próprio trecho) e **Transcrição** (PDF do
|
||||||
Diário) quando existirem · `11. Notícias e imprensa` ·
|
Diário) quando existirem · `12. Notícias e imprensa` ·
|
||||||
`12. Panorama regional` · `13. Abordagem sugerida (personas)` · `14. Fontes e
|
`13. Panorama regional` · `14. Abordagem sugerida (personas)` · `15. Fontes e
|
||||||
limites` — lista as fontes e, antes delas, a tabela **"Pesquisa — respostas
|
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
|
integrais"** com o texto completo de cada item do checklist (o que a página 1
|
||||||
capou).
|
capou).
|
||||||
|
|
@ -410,6 +465,12 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
||||||
ataque pessoal. Prioriza o local (operadores na região) sobre o líder estadual e
|
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
|
sinaliza temas de mão dupla; onde a marca do adversário domina, o insumo é para
|
||||||
contraste, não para confronto frontal.
|
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.
|
||||||
- **Falha isolada no lote**: cidade que falha (token inválido/ambíguo, pesquisa
|
- **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
|
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
|
summary final diz explicitamente o que saiu e o que falhou (sucesso parcial
|
||||||
|
|
@ -419,11 +480,20 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Seção 2º turno saiu como lacuna**: a unidade não está no artefato oficial —
|
||||||
|
rode `pnpm build:second-round -- --analysis-dir=<pasta da análise 2º turno>`
|
||||||
|
(o comando valida totais e versões) e verifique `municipalityCatalog` ×
|
||||||
|
artefato (o teste `tests/unit/bahiaSecondRound.unit.spec.ts` cobre o catálogo).
|
||||||
|
- **Movimento local "Não pesquisado."**: o researcher não preencheu o item
|
||||||
|
`segundo_turno`; é lacuna de pesquisa, não falha do build — repita a pesquisa
|
||||||
|
com fonte datada.
|
||||||
- **Página 1 estourou**: o builder aborta com `scrollHeight > útil` e o erro é
|
- **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
|
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
|
(`PAGE_ONE_*_MAX` em `scripts/lib/cityReportBlocks.mjs`) e preserva o texto
|
||||||
integral no aprofundamento; se ainda estourar, **aperte o teto da pesquisa ou
|
integral no aprofundamento; se ainda estourar, **aperte o teto da pesquisa ou
|
||||||
corte copy do resumo — nunca mexa no layout para "espremer"**.
|
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.
|
||||||
- **`tsx` ausente no homeserver**: `pnpm install --prod=false`.
|
- **`tsx` ausente no homeserver**: `pnpm install --prod=false`.
|
||||||
- **API de emendas 429/erro**: o builder degrada para lacuna com URL+motivo;
|
- **API de emendas 429/erro**: o builder degrada para lacuna com URL+motivo;
|
||||||
reexecute depois (o cache só é escrito com resultado utilizável).
|
reexecute depois (o cache só é escrito com resultado utilizável).
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,13 @@ dossiê daquele recorte estar pesquisado.
|
||||||
- `essential` (≥3) — fatos-âncora que sustentam a conversa (sem trajetória de
|
- `essential` (≥3) — fatos-âncora que sustentam a conversa (sem trajetória de
|
||||||
formação), com a fase junto ao valor;
|
formação), com a fase junto ao valor;
|
||||||
- `plan` — **uma linha** (≤220 chars) de plano de voto/compromisso nomeado
|
- `plan` — **uma linha** (≤220 chars) de plano de voto/compromisso nomeado
|
||||||
(o pedido literal é do renderer, não o reescreva);
|
(o pedido literal — 1313 e Lula 13 no 2º turno — é do renderer, não o
|
||||||
|
reescreva; você pode usar o `plan` para dizer como combinar os dois);
|
||||||
- `qa` (≥4; mire **8–10**) — perguntas prováveis com `side`
|
- `qa` (≥4; mire **8–10**) — perguntas prováveis com `side`
|
||||||
(`direita|esquerda|entrega`), `question`, `acknowledge`, `answer` (ancorada
|
(`direita|esquerda|entrega`), `question`, `acknowledge`, `answer` (ancorada
|
||||||
em `factId` real ou declarada como lacuna) e `close`, cobrindo os dois
|
em `factId` real ou declarada como lacuna) e `close`, cobrindo os dois
|
||||||
lados.
|
lados; inclua **ao menos uma pergunta de 2º turno** (por que votar em Lula;
|
||||||
|
o que está em jogo para a cidade), sem cenário nem número de pesquisa.
|
||||||
3. Devolver **apenas o recibo curto** (seção "Recibo do autor" da skill).
|
3. Devolver **apenas o recibo curto** (seção "Recibo do autor" da skill).
|
||||||
|
|
||||||
## Limites
|
## Limites
|
||||||
|
|
@ -51,7 +53,8 @@ dossiê daquele recorte estar pesquisado.
|
||||||
(`estimatedVotes`, `scenario`, `projection`, `polls`, …): o briefing é
|
(`estimatedVotes`, `scenario`, `projection`, `polls`, …): o briefing é
|
||||||
capacitação, não leitura eleitoral.
|
capacitação, não leitura eleitoral.
|
||||||
- Copy pt-BR, sem PII (telefone/e-mail nunca), sem CTA público, sem promessa de
|
- Copy pt-BR, sem PII (telefone/e-mail nunca), sem CTA público, sem promessa de
|
||||||
entrega; o pedido literal do voto 1313 é do renderer.
|
entrega; o pedido literal do voto (1313 e, no 2º turno, Lula 13) é do
|
||||||
|
renderer.
|
||||||
- Reuse a voz do `solla-comunicacao` (rebates e fórmula de resposta a crítica),
|
- Reuse a voz do `solla-comunicacao` (rebates e fórmula de resposta a crítica),
|
||||||
sem inventar tom novo.
|
sem inventar tom novo.
|
||||||
- **Não** faça pesquisa web nova, **não** edite os `research.json`, **não** rode
|
- **Não** faça pesquisa web nova, **não** edite os `research.json`, **não** rode
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ cidades em paralelo.
|
||||||
2. Fazer a **pesquisa web datada** e escrever
|
2. Fazer a **pesquisa web datada** e escrever
|
||||||
`data/relatorios-cidade/<slug>.research.json` no contrato da skill — item sem
|
`data/relatorios-cidade/<slug>.research.json` no contrato da skill — item sem
|
||||||
`sourceUrl`/`sourceDate` não vai para o arquivo (vira lacuna explícita).
|
`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.
|
||||||
3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):
|
3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):
|
||||||
`slug`, `status`, `researchPath`, `researchedAt`, `itemCount`, `gapCount`,
|
`slug`, `status`, `researchPath`, `researchedAt`, `itemCount`, `gapCount`,
|
||||||
`gaps`, `newsCount90d`, `weakSourceCount`, `failureReason?`. **Nunca** devolva
|
`gaps`, `newsCount90d`, `weakSourceCount`, `failureReason?`. **Nunca** devolva
|
||||||
|
|
@ -32,4 +36,7 @@ cidades em paralelo.
|
||||||
— artefato gitignored).
|
— artefato gitignored).
|
||||||
- Não invente fato sem fonte; não trate empenho como pagamento; não inclua
|
- Não invente fato sem fonte; não trate empenho como pagamento; não inclua
|
||||||
telefone/e-mail de liderança.
|
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.
|
||||||
- Não edite os scripts nem o layout do relatório para "caber".
|
- Não edite os scripts nem o layout do relatório para "caber".
|
||||||
|
|
|
||||||
1
docs/changelog/2026-10-09-relatorio-cidade-2turno.md
Normal file
1
docs/changelog/2026-10-09-relatorio-cidade-2turno.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
**Relatório de cidade no 2º turno (Lula × Flávio, 25/10/2026) — artefato TSE 2026, seção própria no PDF e pedido dual no briefing (2026-10-09):** a família de documentos de cidade passou a servir explicitamente à eleição de Lula no 2º turno. Novo artefato commitado `src/lib/electionAggregates/bahia-second-round-2026.json` (loader `src/lib/bahiaSecondRound.ts`, regra compartilhada `src/lib/secondRoundRole.ts`, builder `pnpm build:second-round` / `scripts/build-second-round-ba.mjs`): cruza o TSE 2026 presidente 1º turno por seção (o mesmo shard oficial do `/potencial`, agregado à geografia do catálogo — Salvador por ZE) com a fatia presidencial de 2022 e o resultado federal de Solla 2026 da análise interna (`analise-eleicoes-2026/nota-solla-2turno`), valida cada número contra `bahia-federal-baseline.json` e contra a análise, e pina os totais da BA (Lula 5.664.771 × Flávio 2.442.585; Solla 152.049). O relatório de cidade ganha a seção **"2º turno no município (Lula × Flávio)"** (nº 2, logo após a conta eleitoral): números oficiais 2022→2026 (Lula, adversário Bolsonaro→Flávio, Solla, comparecimento, abstenção, brancos+nulos), X₁/X₂ pela MESMA fórmula da página pública, papel determinístico da cidade — `prioridade`/`mobilizacao` (Lula caiu e Solla cresceu), `defesa` (os dois caíram) e `expansao` — e o movimento local pesquisado no novo item de checklist `segundo_turno` (atos, palanques, posições, com URL+data; sem fonte vira lacuna). A seção é **cenário, não promessa** (X₁/X₂ são votos em jogo; persuasão em eleição geral tem efeito médio ~0) e fica **fora da página 1** de propósito: a página 1 é medida e falha fechada, e conteúdo determinístico não encolhe no fallback de texto. Na família do dossiê, o **briefing de capacitação** passa a renderizar o **pedido dual** ("vote 1313, Jorge Solla. E no 2º turno, vote 13, Lula.") com ao menos uma pergunta de 2º turno no Q&A, sem cenário/estimativa no contrato; dossiê e boletim seguem sem CTA (prova material do argumento, não peça de rua). Testes: 8 unit novos/atualizados no artefato e na seção (cobertura do catálogo 435, pins de totais, reclassificação dos papéis, ZE 19/15/9/12 × 16/4, X₁/X₂ da ZE 1 do Salvador), pins da missão nas skills `relatorio-cidade` e `briefing-capacitacao-solla`, e o literal do pedido dual no renderer; o build real (Chromium + snapshot de Amargosa) foi rodado com a seção nova e a página 1 segue fechando no orçamento.
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
|
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
|
||||||
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
|
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
|
||||||
"build:potencial-secoes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-potencial-secoes-2026.mjs",
|
"build:potencial-secoes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-potencial-secoes-2026.mjs",
|
||||||
|
"build:second-round": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-second-round-ba.mjs",
|
||||||
"build:state-deputy-card-assets": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-state-deputy-card-assets.mjs",
|
"build:state-deputy-card-assets": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-state-deputy-card-assets.mjs",
|
||||||
"build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-geometries.mjs",
|
"build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-geometries.mjs",
|
||||||
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
|
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
|
||||||
|
|
|
||||||
354
scripts/build-second-round-ba.mjs
Normal file
354
scripts/build-second-round-ba.mjs
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
/**
|
||||||
|
* Builds the committed 2º-turno artifact (`bahia-second-round-2026.json`):
|
||||||
|
* Lula × Flávio in Bahia, 2022 → 1º turno 2026, cut by the same
|
||||||
|
* `municipalityCatalog` geography the campaign uses (Salvador split by ZE).
|
||||||
|
*
|
||||||
|
* Why an artifact: the campaign DB has Solla 2014/2018/2022 and the 2026
|
||||||
|
* estimates, and the official presidential 1º turno 2026 lives in the committed
|
||||||
|
* `public/dados/potencial-secao-2026/BA.json` (per section). Solla's official
|
||||||
|
* 2026 federal result and the 2022 adversary slice come from the internal
|
||||||
|
* analysis repo (analise-eleicoes-2026 / nota-solla-2turno). This builder joins
|
||||||
|
* the two, validates each 2026 number against an independent source, cross-checks
|
||||||
|
* 2022 against `bahia-federal-baseline.json` and pins the BA totals.
|
||||||
|
*
|
||||||
|
* Inputs:
|
||||||
|
* - public/dados/potencial-secao-2026/BA.json TSE 2026 presidente T1 (por seção)
|
||||||
|
* - src/lib/electionAggregates/bahia-federal-baseline.json Solla/Lula 2022 (cross-check)
|
||||||
|
* - --analysis-dir=<dir> outputs of analise-eleicoes-2026 (default ../analise-eleicoes-2026/bahia):
|
||||||
|
* solla_municipios.csv, ba_ausentes_bn.csv, salvador_ze_v3.csv, solla_ze_salvador.csv
|
||||||
|
*
|
||||||
|
* Output: src/lib/electionAggregates/bahia-second-round-2026.json
|
||||||
|
* Uso: pnpm build:second-round -- [--analysis-dir=<dir>] [--check]
|
||||||
|
*
|
||||||
|
* Never runs at build/deploy time — the artifact is committed (same rule as
|
||||||
|
* `pnpm build:election-aggregates`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { parse as parseCsvSync } from 'csv-parse/sync'
|
||||||
|
|
||||||
|
import { dieWithLabel, parseEqualsFlags, writeRepoFile } from './lib/cli.mjs'
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const LABEL = 'build-second-round-ba'
|
||||||
|
const die = dieWithLabel(LABEL)
|
||||||
|
|
||||||
|
const ARTIFACT_PATH = 'src/lib/electionAggregates/bahia-second-round-2026.json'
|
||||||
|
const SHARD_PATH = 'public/dados/potencial-secao-2026/BA.json'
|
||||||
|
const BASELINE_PATH = 'src/lib/electionAggregates/bahia-federal-baseline.json'
|
||||||
|
const DEFAULT_ANALYSIS_DIR = resolve(ROOT, '..', 'analise-eleicoes-2026', 'bahia')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audited Bahia totals (TSE, 1º turno 2026, 100% apurado) — sum over the 435
|
||||||
|
* catalog units (Salvador once, through its 19 ZE). Any divergence aborts the
|
||||||
|
* build: a re-published bulk or a changed analysis must be a deliberate PR.
|
||||||
|
*/
|
||||||
|
const BA_PINS = {
|
||||||
|
aptos26: 11_312_752,
|
||||||
|
comparecimento26: 9_053_601,
|
||||||
|
lula26: 5_664_771,
|
||||||
|
adversary26: 2_442_585,
|
||||||
|
validos26: 8_560_542,
|
||||||
|
lula22: 5_873_081,
|
||||||
|
adversary22: 2_047_599,
|
||||||
|
solla22: 128_968,
|
||||||
|
solla26: 152_049,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
|
||||||
|
const { classifySecondRoundRole } = await import('../src/lib/secondRoundRole.ts')
|
||||||
|
|
||||||
|
const { flags } = parseEqualsFlags(process.argv.slice(2))
|
||||||
|
const analysisDir = resolve(
|
||||||
|
ROOT,
|
||||||
|
typeof flags['analysis-dir'] === 'string' ? flags['analysis-dir'] : DEFAULT_ANALYSIS_DIR,
|
||||||
|
)
|
||||||
|
const checkOnly = flags.check === true
|
||||||
|
|
||||||
|
const int = (value) => {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) die(`Número inválido: ${JSON.stringify(value)}`)
|
||||||
|
return Math.round(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readJson = async (relativePath) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8'))
|
||||||
|
} catch (error) {
|
||||||
|
die(`Falha ao ler ${relativePath}: ${error instanceof Error ? error.message : error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readCsv = async (fileName) => {
|
||||||
|
const path = join(analysisDir, fileName)
|
||||||
|
let text
|
||||||
|
try {
|
||||||
|
text = await readFile(path, 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
die(
|
||||||
|
`Falha ao ler ${path}: ${error instanceof Error ? error.message : error}\n` +
|
||||||
|
' Passe --analysis-dir=<diretório com os CSVs da análise 2º turno>.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return parseCsvSync(text, {
|
||||||
|
columns: true,
|
||||||
|
delimiter: ';',
|
||||||
|
skip_empty_lines: true,
|
||||||
|
trim: true,
|
||||||
|
bom: true,
|
||||||
|
relax_quotes: true,
|
||||||
|
relax_column_count: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexBy = (rows, key) => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const row of rows) map.set(String(row[key]), row)
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2026 president aggregates from the committed per-section shard: one pass over
|
||||||
|
* the 417 municipalities, accumulating `aptos, comparecimento, lula, flávio,
|
||||||
|
* válidos` per município and — for Salvador — per zone (the campaign unit).
|
||||||
|
*/
|
||||||
|
const aggregateShard = (shard) => {
|
||||||
|
const byMunicipality = new Map()
|
||||||
|
const salVadorByZone = new Map()
|
||||||
|
for (const municipality of shard.municipalities) {
|
||||||
|
const code = String(municipality.code)
|
||||||
|
const total = { aptos: 0, comparecimento: 0, lula: 0, adversary: 0, validos: 0 }
|
||||||
|
for (const [zone, , aptos, comparecimento, lula, adversary, validos] of municipality.sections) {
|
||||||
|
total.aptos += aptos
|
||||||
|
total.comparecimento += comparecimento
|
||||||
|
total.lula += lula
|
||||||
|
total.adversary += adversary
|
||||||
|
total.validos += validos
|
||||||
|
if (code === '38490') {
|
||||||
|
const zoneTotal = salVadorByZone.get(zone) ?? {
|
||||||
|
aptos: 0,
|
||||||
|
comparecimento: 0,
|
||||||
|
lula: 0,
|
||||||
|
adversary: 0,
|
||||||
|
validos: 0,
|
||||||
|
}
|
||||||
|
zoneTotal.aptos += aptos
|
||||||
|
zoneTotal.comparecimento += comparecimento
|
||||||
|
zoneTotal.lula += lula
|
||||||
|
zoneTotal.adversary += adversary
|
||||||
|
zoneTotal.validos += validos
|
||||||
|
salVadorByZone.set(zone, zoneTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byMunicipality.set(code, total)
|
||||||
|
}
|
||||||
|
return { byMunicipality, salVadorByZone }
|
||||||
|
}
|
||||||
|
|
||||||
|
const failCheck = (failures) => {
|
||||||
|
if (!failures.length) return
|
||||||
|
die(
|
||||||
|
`Divergência de validação (${failures.length}):\n ` +
|
||||||
|
failures
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((line) => `- ${line}`)
|
||||||
|
.join('\n ') +
|
||||||
|
(failures.length > 20 ? `\n … e mais ${failures.length - 20}.` : ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const [shard, baseline] = await Promise.all([readJson(SHARD_PATH), readJson(BASELINE_PATH)])
|
||||||
|
const [sollaMunicipios, ausentesBn, salvadorZe, sollaSalvadorZe] = await Promise.all([
|
||||||
|
readCsv('solla_municipios.csv'),
|
||||||
|
readCsv('ba_ausentes_bn.csv'),
|
||||||
|
readCsv('salvador_ze_v3.csv'),
|
||||||
|
readCsv('solla_ze_salvador.csv'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const sollaByCity = indexBy(sollaMunicipios, 'cd_tse')
|
||||||
|
const ausByCity = indexBy(ausentesBn, 'cd')
|
||||||
|
const salZeByZone = indexBy(salvadorZe, 'zona')
|
||||||
|
const sollaSalZeByZone = indexBy(sollaSalvadorZe, 'zona')
|
||||||
|
const { byMunicipality, salVadorByZone } = aggregateShard(shard)
|
||||||
|
|
||||||
|
const failures = []
|
||||||
|
const units = {}
|
||||||
|
const totals = {
|
||||||
|
aptos22: 0,
|
||||||
|
aptos26: 0,
|
||||||
|
comparecimento22: 0,
|
||||||
|
comparecimento26: 0,
|
||||||
|
abstencao22: 0,
|
||||||
|
abstencao26: 0,
|
||||||
|
brancosNulos22: 0,
|
||||||
|
brancosNulos26: 0,
|
||||||
|
validos22: 0,
|
||||||
|
validos26: 0,
|
||||||
|
lula22: 0,
|
||||||
|
lula26: 0,
|
||||||
|
adversary22: 0,
|
||||||
|
adversary26: 0,
|
||||||
|
solla22: 0,
|
||||||
|
solla26: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of municipalityCatalog) {
|
||||||
|
const isZone = entry.kind === 'zona'
|
||||||
|
const zoneNumber = entry.zoneNumber ?? null
|
||||||
|
const source = isZone ? sollaSalZeByZone.get(String(zoneNumber).padStart(4, '0')) : null
|
||||||
|
const analysis = isZone ? salZeByZone.get(String(zoneNumber).padStart(4, '0')) : null
|
||||||
|
const cityRow = isZone ? null : sollaByCity.get(entry.tseCityCode)
|
||||||
|
const ausRow = isZone ? null : ausByCity.get(entry.tseCityCode)
|
||||||
|
const shardRow = isZone ? salVadorByZone.get(zoneNumber) : byMunicipality.get(entry.tseCityCode)
|
||||||
|
|
||||||
|
if (!shardRow) {
|
||||||
|
failures.push(`${entry.slug}: sem agregado de 2026 no shard oficial`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!isZone && (!cityRow || !ausRow)) {
|
||||||
|
failures.push(`${entry.slug}: sem linha na análise 2º turno (${entry.tseCityCode})`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isZone && (!source || !analysis)) {
|
||||||
|
failures.push(`${entry.slug}: sem linha da análise para a ZE ${zoneNumber}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2022: análise (primary) — one table per kind, validated below against the
|
||||||
|
// committed TSE baseline (Lula #13 and Solla 1313).
|
||||||
|
const lula22 = int(isZone ? analysis.lula22 : cityRow.lula22)
|
||||||
|
const adversary22 = int(isZone ? analysis.adv22 : cityRow.bolso22)
|
||||||
|
const solla22 = int(isZone ? source.solla22 : cityRow.solla22)
|
||||||
|
const aptos22 = int(isZone ? analysis.te22 : ausRow.aptos22)
|
||||||
|
const abstencao22 = int(isZone ? analysis.abst22 : ausRow.abst22)
|
||||||
|
const comparecimento22 = aptos22 - abstencao22
|
||||||
|
const validos22 = int(isZone ? analysis.val22 : cityRow.validos22)
|
||||||
|
const brancosNulos22 = comparecimento22 - validos22
|
||||||
|
|
||||||
|
// 2026: shard oficial (per section) — the analysis numbers are the check.
|
||||||
|
const lula26 = shardRow.lula
|
||||||
|
const adversary26 = shardRow.adversary
|
||||||
|
const aptos26 = shardRow.aptos
|
||||||
|
const comparecimento26 = shardRow.comparecimento
|
||||||
|
const validos26 = shardRow.validos
|
||||||
|
const abstencao26 = aptos26 - comparecimento26
|
||||||
|
const brancosNulos26 = comparecimento26 - validos26
|
||||||
|
const solla26 = int(isZone ? source.solla26 : cityRow.solla26)
|
||||||
|
|
||||||
|
if (!isZone) {
|
||||||
|
if (lula26 !== int(cityRow.lula26))
|
||||||
|
failures.push(`${entry.slug}: Lula26 shard ${lula26} ≠ análise ${cityRow.lula26}`)
|
||||||
|
if (adversary26 !== int(cityRow.flavio26))
|
||||||
|
failures.push(`${entry.slug}: Flávio26 shard ${adversary26} ≠ análise ${cityRow.flavio26}`)
|
||||||
|
if (aptos26 !== int(ausRow.aptos26))
|
||||||
|
failures.push(`${entry.slug}: aptos26 shard ${aptos26} ≠ análise ${ausRow.aptos26}`)
|
||||||
|
if (comparecimento26 !== int(ausRow.aptos26) - int(ausRow.abst26))
|
||||||
|
failures.push(
|
||||||
|
`${entry.slug}: comp26 shard ${comparecimento26} ≠ análise ${int(ausRow.aptos26) - int(ausRow.abst26)}`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (lula26 !== int(analysis.lula26))
|
||||||
|
failures.push(`${entry.slug}: Lula26 shard ${lula26} ≠ análise ${analysis.lula26}`)
|
||||||
|
if (adversary26 !== int(analysis.adv26))
|
||||||
|
failures.push(`${entry.slug}: Flávio26 shard ${adversary26} ≠ análise ${analysis.adv26}`)
|
||||||
|
if (aptos26 !== int(analysis.te26))
|
||||||
|
failures.push(`${entry.slug}: aptos26 shard ${aptos26} ≠ análise ${analysis.te26}`)
|
||||||
|
if (comparecimento26 !== int(analysis.comp26))
|
||||||
|
failures.push(
|
||||||
|
`${entry.slug}: comp26 shard ${comparecimento26} ≠ análise ${analysis.comp26}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2022 cross-check against the committed Teqo baseline (independent source).
|
||||||
|
const baselineUnit = baseline.municipalities?.[entry.slug]
|
||||||
|
if (!baselineUnit) {
|
||||||
|
failures.push(`${entry.slug}: fora do baseline TSE 2014/2018/2022`)
|
||||||
|
} else {
|
||||||
|
const baselineSolla22 = baselineUnit.votesByYear?.['2022'] ?? null
|
||||||
|
if (baselineSolla22 !== solla22)
|
||||||
|
failures.push(`${entry.slug}: Solla22 baseline ${baselineSolla22} ≠ análise ${solla22}`)
|
||||||
|
const baselineLula22 = baselineUnit.majoritarian2022?.president?.votes ?? null
|
||||||
|
if (baselineLula22 !== lula22)
|
||||||
|
failures.push(`${entry.slug}: Lula22 baseline ${baselineLula22} ≠ análise ${lula22}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dLula = lula26 - lula22
|
||||||
|
const dAdversary = adversary26 - adversary22
|
||||||
|
const dSolla = solla26 - solla22
|
||||||
|
const role = classifySecondRoundRole({ dLula, dSolla, solla26 })
|
||||||
|
|
||||||
|
units[entry.slug] = {
|
||||||
|
name: entry.name,
|
||||||
|
kind: entry.kind,
|
||||||
|
tseCityCode: entry.tseCityCode,
|
||||||
|
ibgeCode: entry.ibgeCode,
|
||||||
|
zone: zoneNumber,
|
||||||
|
role,
|
||||||
|
lula22,
|
||||||
|
lula26,
|
||||||
|
dLula,
|
||||||
|
adversary22,
|
||||||
|
adversary26,
|
||||||
|
dAdversary,
|
||||||
|
solla22,
|
||||||
|
solla26,
|
||||||
|
dSolla,
|
||||||
|
aptos22,
|
||||||
|
aptos26,
|
||||||
|
comparecimento22,
|
||||||
|
comparecimento26,
|
||||||
|
abstencao22,
|
||||||
|
abstencao26,
|
||||||
|
brancosNulos22,
|
||||||
|
brancosNulos26,
|
||||||
|
validos22,
|
||||||
|
validos26,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(totals)) totals[key] += units[entry.slug][key]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, expected] of Object.entries(BA_PINS)) {
|
||||||
|
if (totals[key] !== expected)
|
||||||
|
failures.push(`total BA ${key}: esperado ${expected}, encontrado ${totals[key]}`)
|
||||||
|
}
|
||||||
|
failCheck(failures)
|
||||||
|
|
||||||
|
const artifact = {
|
||||||
|
version: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
scope: 'BA — 2º turno Lula × Flávio',
|
||||||
|
provenance:
|
||||||
|
'TSE 2026 presidente 1º turno por seção (public/dados/potencial-secao-2026/BA.json, oficial) + análise 2º turno da campanha (analise-eleicoes-2026/nota-solla-2turno) para Solla 2026 e a fatia de 2022; 2022 validado contra bahia-federal-baseline.json.',
|
||||||
|
sources: {
|
||||||
|
shard: SHARD_PATH,
|
||||||
|
baseline: BASELINE_PATH,
|
||||||
|
analysisDir:
|
||||||
|
'analise-eleicoes-2026/bahia (solla_municipios, ba_ausentes_bn, salvador_ze_v3, solla_ze_salvador)',
|
||||||
|
},
|
||||||
|
pins: BA_PINS,
|
||||||
|
totals,
|
||||||
|
units,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkOnly) {
|
||||||
|
console.log(`[${LABEL}] OK — ${Object.keys(units).length} unidades, totais batem com os pins.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeRepoFile({
|
||||||
|
label: LABEL,
|
||||||
|
root: ROOT,
|
||||||
|
relativePath: ARTIFACT_PATH,
|
||||||
|
body: `${JSON.stringify(artifact, null, 2)}\n`,
|
||||||
|
})
|
||||||
|
console.log(
|
||||||
|
`[${LABEL}] ${Object.keys(units).length} unidades · Lula ${totals.lula26} · Flávio ${totals.adversary26} · Solla ${totals.solla26}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await main()
|
||||||
|
|
@ -16,7 +16,7 @@ import { resolveDossierUnit } from './dossieUnit.mjs'
|
||||||
import { htmlEscape, moreItemsLabel } from './reportText.mjs'
|
import { htmlEscape, moreItemsLabel } from './reportText.mjs'
|
||||||
|
|
||||||
const BRIEFING_REQUEST_LINE =
|
const BRIEFING_REQUEST_LINE =
|
||||||
'Posso contar com você? Para deputado federal, <em>vote 1313, Jorge Solla.</em>'
|
'Posso contar com você? Para deputado federal, <em>vote 1313, Jorge Solla.</em> E no 2º turno, <em>vote 13, Lula.</em>'
|
||||||
|
|
||||||
const BRIEFING_PRINT_CSS = `
|
const BRIEFING_PRINT_CSS = `
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -263,7 +263,7 @@ export const renderBriefingMd = (content, { unit, report }) => {
|
||||||
'',
|
'',
|
||||||
'## O pedido',
|
'## O pedido',
|
||||||
'',
|
'',
|
||||||
'> “Posso contar com você? Para deputado federal, vote 1313, Jorge Solla.”',
|
'> “Posso contar com você? Para deputado federal, vote 1313, Jorge Solla. E no 2º turno, vote 13, Lula.”',
|
||||||
'',
|
'',
|
||||||
`**Plano:** ${content.plan}`,
|
`**Plano:** ${content.plan}`,
|
||||||
'',
|
'',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getMunicipalityFederalBaseline } from '../../src/lib/bahiaElectionAggregates.ts'
|
import { getMunicipalityFederalBaseline } from '../../src/lib/bahiaElectionAggregates.ts'
|
||||||
|
import {
|
||||||
|
BAHIA_SECOND_ROUND_GENERATED_AT,
|
||||||
|
getBahiaSecondRoundUnit,
|
||||||
|
secondRoundAdversaryCapturePct,
|
||||||
|
secondRoundLulaShares,
|
||||||
|
secondRoundPotentialOf,
|
||||||
|
secondRoundSollaShareOfLula,
|
||||||
|
} from '../../src/lib/bahiaSecondRound.ts'
|
||||||
import { formatEngagementLevelLabel } from '../../src/lib/engagementLevel.ts'
|
import { formatEngagementLevelLabel } from '../../src/lib/engagementLevel.ts'
|
||||||
import { getMunicipalityVoteRank } from '../../src/lib/municipalityVoteRank.ts'
|
import { getMunicipalityVoteRank } from '../../src/lib/municipalityVoteRank.ts'
|
||||||
import { activityStatusLabels } from '../../src/lib/schemas/activity.ts'
|
import { activityStatusLabels } from '../../src/lib/schemas/activity.ts'
|
||||||
|
|
@ -18,6 +26,7 @@ import {
|
||||||
campaignDemandStatusLabels,
|
campaignDemandStatusLabels,
|
||||||
} from '../../src/lib/schemas/campaignDemand.ts'
|
} from '../../src/lib/schemas/campaignDemand.ts'
|
||||||
import { municipalityUpdatePolarityLabels } from '../../src/lib/schemas/municipalityUpdate.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 { SPEECH_TOPICS } from '../../src/lib/speechFacets.ts'
|
||||||
import {
|
import {
|
||||||
formatVoteEstimateEndpointsLabel,
|
formatVoteEstimateEndpointsLabel,
|
||||||
|
|
@ -134,6 +143,14 @@ const withInlineSources = (text, sources) => {
|
||||||
return `${text} ${inlineSourceTokens(sources.length)}`
|
return `${text} ${inlineSourceTokens(sources.length)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "+1.234" / "−208.310" — the 2º-turno deltas never lose the sign. */
|
||||||
|
const signedInteger = (value) =>
|
||||||
|
`${value > 0 ? '+' : value < 0 ? '−' : ''}${formatInteger(Math.abs(value))}`
|
||||||
|
|
||||||
|
/** "+3,2 p.p." / "−4,0 p.p." — pt-BR decimal, explicit sign. */
|
||||||
|
const signedPoints = (value) =>
|
||||||
|
`${value > 0 ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1).replace('.', ',')} p.p.`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Página 1 = resumo de uma olhada: a fonte do item entra compacta (pesquisa web
|
* Página 1 = resumo de uma olhada: a fonte do item entra compacta (pesquisa web
|
||||||
* + data). O texto é o `summary` do researcher quando existe; sem ele, a
|
* + data). O texto é o `summary` do researcher quando existe; sem ele, a
|
||||||
|
|
@ -471,6 +488,206 @@ const buildElectoralSection = ({ snapshot }) => {
|
||||||
].filter(Boolean)
|
].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
|
||||||
|
* campaign unit) + the local web movement. The role copy directs the visit
|
||||||
|
* (mobilize/defend); it never promises vote transfer.
|
||||||
|
*/
|
||||||
|
const buildSecondRoundSection = ({ snapshot, research }) => {
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
buildSecondRoundResearchBlock(research),
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const potential = secondRoundPotentialOf(unit)
|
||||||
|
const shares = secondRoundLulaShares(unit)
|
||||||
|
const capture = secondRoundAdversaryCapturePct(unit)
|
||||||
|
const sollaRatio = secondRoundSollaShareOfLula(unit)
|
||||||
|
const sources = [
|
||||||
|
sourceOfficial(
|
||||||
|
'TSE — 1º turno 2026 (presidente, por seção) e 2022; deputado federal 2026',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
sourceTeqo(
|
||||||
|
'base Teqo — artefato do 2º turno (pnpm build:second-round)',
|
||||||
|
BAHIA_SECOND_ROUND_GENERATED_AT,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
label: 'Lula (PT)',
|
||||||
|
y22: formatInteger(unit.lula22),
|
||||||
|
y26: formatInteger(unit.lula26),
|
||||||
|
delta: signedInteger(unit.dLula),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Adversário (Bolsonaro→Flávio)',
|
||||||
|
y22: formatInteger(unit.adversary22),
|
||||||
|
y26: formatInteger(unit.adversary26),
|
||||||
|
delta: signedInteger(unit.dAdversary),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Solla — dep. federal 1313',
|
||||||
|
y22: formatInteger(unit.solla22),
|
||||||
|
y26: formatInteger(unit.solla26),
|
||||||
|
delta: signedInteger(unit.dSolla),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Comparecimento',
|
||||||
|
y22: formatInteger(unit.comparecimento22),
|
||||||
|
y26: formatInteger(unit.comparecimento26),
|
||||||
|
delta: signedInteger(unit.comparecimento26 - unit.comparecimento22),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Abstenção',
|
||||||
|
y22: formatInteger(unit.abstencao22),
|
||||||
|
y26: formatInteger(unit.abstencao26),
|
||||||
|
delta: signedInteger(unit.abstencao26 - unit.abstencao22),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Brancos + nulos',
|
||||||
|
y22: formatInteger(unit.brancosNulos22),
|
||||||
|
y26: formatInteger(unit.brancosNulos26),
|
||||||
|
delta: signedInteger(unit.brancosNulos26 - unit.brancosNulos22),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const calloutBody = [secondRoundRoleGuidance[unit.role]]
|
||||||
|
if (capture !== null) {
|
||||||
|
calloutBody.push(
|
||||||
|
`Captura do adversário: ${formatPercent(capture / 100)} da perda de Lula em votos (pode passar de 100% quando o comparecimento cresce).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (sollaRatio !== null) {
|
||||||
|
calloutBody.push(
|
||||||
|
`Solla 2026 = ${formatPercent(sollaRatio / 100)} dos votos de Lula no recorte — a base local disponível para organizar a mobilização.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
tone: unit.role === 'defesa' ? 'risk' : 'decision',
|
||||||
|
title: `Papel no 2º turno: ${secondRoundRoleLabels[unit.role]}`,
|
||||||
|
body: calloutBody,
|
||||||
|
sources,
|
||||||
|
},
|
||||||
|
{ kind: 'kpis', items: kpis, sources: [sources[0]] },
|
||||||
|
{
|
||||||
|
kind: 'table',
|
||||||
|
title: 'A conta 2022 → 1º turno 2026 (presidente e Solla 1313)',
|
||||||
|
columns: [
|
||||||
|
{ key: 'label', label: 'Indicador', width: 34 },
|
||||||
|
{ key: 'y22', label: '2022', numeric: true, width: 22 },
|
||||||
|
{ key: 'y26', label: '2026', numeric: true, width: 22 },
|
||||||
|
{ key: 'delta', label: 'Δ', numeric: true, width: 22 },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
note:
|
||||||
|
unit.dLula < 0
|
||||||
|
? `Captura do adversário = ΔFlávio ÷ |ΔLula| = ${
|
||||||
|
capture === null ? '—' : formatPercent(capture / 100)
|
||||||
|
}; pode passar de 100% porque a perda de Lula não é a única fonte do ganho (comparecimento e terceira via também mudam).`
|
||||||
|
: `Lula não perdeu votos no recorte (Δ ${signedInteger(unit.dLula)}).`,
|
||||||
|
sources,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'prose',
|
||||||
|
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),
|
||||||
|
].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.
|
||||||
|
*/
|
||||||
|
|
||||||
const COMPETITOR_SERIES_YEARS = ['2014', '2018', '2022']
|
const COMPETITOR_SERIES_YEARS = ['2014', '2018', '2022']
|
||||||
|
|
||||||
const buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({
|
const buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({
|
||||||
|
|
@ -1520,6 +1737,11 @@ export const buildCityReport = ({
|
||||||
title: 'Conta eleitoral completa (2014/2018/2022)',
|
title: 'Conta eleitoral completa (2014/2018/2022)',
|
||||||
blocks: buildElectoralSection({ snapshot }),
|
blocks: buildElectoralSection({ snapshot }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'segundo-turno',
|
||||||
|
title: '2º turno no município (Lula × Flávio)',
|
||||||
|
blocks: buildSecondRoundSection({ snapshot, research }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'concorrentes',
|
id: 'concorrentes',
|
||||||
title: 'Concorrentes no município (federal e estadual)',
|
title: 'Concorrentes no município (federal e estadual)',
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ const RESEARCH_CHECKLIST = [
|
||||||
{ id: 'relacao_campo', label: 'Relação com o campo (aliado, oposição, neutro)' },
|
{ id: 'relacao_campo', label: 'Relação com o campo (aliado, oposição, neutro)' },
|
||||||
{ id: 'vereadores', label: 'Vereadores e dobradas' },
|
{ id: 'vereadores', label: 'Vereadores e dobradas' },
|
||||||
{ id: 'disputa_local', label: 'Disputa local' },
|
{ id: 'disputa_local', label: 'Disputa local' },
|
||||||
|
{
|
||||||
|
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: '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: 'noticias', label: 'Notícias da cidade/região (janela ≤90 dias)' },
|
||||||
{ id: 'imprensa_local', label: 'Imprensa e rádio local' },
|
{ id: 'imprensa_local', label: 'Imprensa e rádio local' },
|
||||||
|
|
|
||||||
111
src/lib/bahiaSecondRound.ts
Normal file
111
src/lib/bahiaSecondRound.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import artifactJson from '@/lib/electionAggregates/bahia-second-round-2026.json'
|
||||||
|
import type { SecondRoundRole } from '@/lib/secondRoundRole'
|
||||||
|
import { computeSectionPotential, type SectionPotential } from '@/lib/sectionPotential'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Committed Bahia 2º-turno baseline (Lula × Flávio): official TSE 2026 president
|
||||||
|
* T1 per section (aggregated to the campaign's municipality geography — Salvador
|
||||||
|
* split by ZE), joined with the 2022 presidential slice and Solla's 2026 federal
|
||||||
|
* result. Generated by `pnpm build:second-round` from
|
||||||
|
* `public/dados/potencial-secao-2026/BA.json` + the internal 2º-turno analysis,
|
||||||
|
* validated against `bahia-federal-baseline.json` and the audited BA totals —
|
||||||
|
* never computed at build/request time.
|
||||||
|
*
|
||||||
|
* Read-only and pure: the loader only projects the artifact and runs the SAME
|
||||||
|
* `computeSectionPotential` the public `/potencial` page uses, so the report's
|
||||||
|
* X₁/X₂ can never drift from the site's scenario.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BahiaSecondRoundUnit = {
|
||||||
|
/** Display name ("Jaguaquara" / "Salvador — ZE 19"). */
|
||||||
|
name: string
|
||||||
|
kind: 'municipio' | 'zona'
|
||||||
|
tseCityCode: string
|
||||||
|
ibgeCode: string
|
||||||
|
/** Zone number for Salvador ZE units; null for whole municipalities. */
|
||||||
|
zone: number | null
|
||||||
|
/** Deterministic campaign role (`secondRoundRole.ts`). */
|
||||||
|
role: SecondRoundRole
|
||||||
|
/** Lula's votes in the 2022 presidential race. */
|
||||||
|
lula22: number
|
||||||
|
/** Lula's votes in the 1º turno 2026. */
|
||||||
|
lula26: number
|
||||||
|
/** `lula26 − lula22` (negative = lost votes). */
|
||||||
|
dLula: number
|
||||||
|
/** Bolsonaro 2022 / Flávio 2026 (the same adversary seat across cycles). */
|
||||||
|
adversary22: number
|
||||||
|
adversary26: number
|
||||||
|
dAdversary: number
|
||||||
|
/** Solla (federal deputy 1313): 2022 and 2026 nominal votes. */
|
||||||
|
solla22: number
|
||||||
|
solla26: number
|
||||||
|
dSolla: number
|
||||||
|
aptos22: number
|
||||||
|
aptos26: number
|
||||||
|
comparecimento22: number
|
||||||
|
comparecimento26: number
|
||||||
|
abstencao22: number
|
||||||
|
abstencao26: number
|
||||||
|
/** Blank + null votes (comparecimento − válidos). */
|
||||||
|
brancosNulos22: number
|
||||||
|
brancosNulos26: number
|
||||||
|
validos22: number
|
||||||
|
validos26: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type BahiaSecondRoundArtifact = {
|
||||||
|
version: number
|
||||||
|
generatedAt: string
|
||||||
|
scope: string
|
||||||
|
provenance: string
|
||||||
|
sources: Record<string, string>
|
||||||
|
pins: Record<string, number>
|
||||||
|
totals: Record<string, number>
|
||||||
|
units: Record<string, BahiaSecondRoundUnit>
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifact = artifactJson as BahiaSecondRoundArtifact
|
||||||
|
|
||||||
|
export const BAHIA_SECOND_ROUND_GENERATED_AT = artifact.generatedAt
|
||||||
|
|
||||||
|
/** One catalog unit (slug from `municipalityCatalog`) or `null` when unknown. */
|
||||||
|
export const getBahiaSecondRoundUnit = (slug: string): BahiaSecondRoundUnit | null =>
|
||||||
|
artifact.units[slug] ?? null
|
||||||
|
|
||||||
|
export const bahiaSecondRoundTotals = artifact.totals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 2º-turno scenario for one unit — X₁ (votes already available inside the
|
||||||
|
* 1º-turno comparecimento: nulls + blanks + all third-party votes) and X₂
|
||||||
|
* (X₁ + absentees), with the hypothetical 2nd-round shares. Reuses the public
|
||||||
|
* `/potencial` formula; `null` when the raw counts are inconsistent.
|
||||||
|
*/
|
||||||
|
export const secondRoundPotentialOf = (unit: BahiaSecondRoundUnit): SectionPotential | null => {
|
||||||
|
const result = computeSectionPotential({
|
||||||
|
aptos: unit.aptos26,
|
||||||
|
comparecimento: unit.comparecimento26,
|
||||||
|
lula: unit.lula26,
|
||||||
|
flavio: unit.adversary26,
|
||||||
|
validos: unit.validos26,
|
||||||
|
})
|
||||||
|
return result.available ? result.potential : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lula's share of the 1º-turno valid votes (2022 / 2026), in percent. */
|
||||||
|
export const secondRoundLulaShares = (
|
||||||
|
unit: BahiaSecondRoundUnit,
|
||||||
|
): { share22: number; share26: number; deltaPp: number } => {
|
||||||
|
const share22 = unit.validos22 > 0 ? (unit.lula22 / unit.validos22) * 100 : 0
|
||||||
|
const share26 = unit.validos26 > 0 ? (unit.lula26 / unit.validos26) * 100 : 0
|
||||||
|
return { share22, share26, deltaPp: share26 - share22 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solla's 2026 votes as a share of Lula's 2026 votes (the local brand ratio). */
|
||||||
|
export const secondRoundSollaShareOfLula = (unit: BahiaSecondRoundUnit): number | null =>
|
||||||
|
unit.lula26 > 0 ? (unit.solla26 / unit.lula26) * 100 : null
|
||||||
|
|
||||||
|
/** Δ adversary ÷ |Δ Lula| — how much of Lula's loss the adversary captured. */
|
||||||
|
export const secondRoundAdversaryCapturePct = (unit: BahiaSecondRoundUnit): number | null => {
|
||||||
|
if (unit.dLula >= 0) return null
|
||||||
|
return (unit.dAdversary / Math.abs(unit.dLula)) * 100
|
||||||
|
}
|
||||||
11787
src/lib/electionAggregates/bahia-second-round-2026.json
Normal file
11787
src/lib/electionAggregates/bahia-second-round-2026.json
Normal file
File diff suppressed because it is too large
Load diff
60
src/lib/secondRoundRole.ts
Normal file
60
src/lib/secondRoundRole.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* The deterministic role of one 2º-turno unit (município or Salvador ZE) in the
|
||||||
|
* Lula × Flávio campaign — the shared rule between the artifact builder
|
||||||
|
* (`pnpm build:second-round`) and the city report / briefing family. Pure: no
|
||||||
|
* data access, so the builder and the renderers can never disagree.
|
||||||
|
*
|
||||||
|
* The classification comes from the Bahia 1º-turn analysis (2022→2026):
|
||||||
|
* where Lula lost votes AND the local PT brand (Solla 1313) grew, mobilizing is
|
||||||
|
* safe; where both fell, the task is defense/turnout. Persuasão em eleição
|
||||||
|
* geral tem efeito médio ~0 — the role copy points the visit, it does not
|
||||||
|
* promise vote transfer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SecondRoundRole = 'prioridade' | 'mobilizacao' | 'defesa' | 'expansao'
|
||||||
|
|
||||||
|
export type SecondRoundSwingInput = {
|
||||||
|
/** Δ Lula (2026 − 2022), in votes; negative = lost votes. */
|
||||||
|
dLula: number
|
||||||
|
/** Δ Solla (2026 − 2022), in votes. */
|
||||||
|
dSolla: number
|
||||||
|
/** Solla's 2026 votes — sizes the local base (priority threshold ≥200). */
|
||||||
|
solla26: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `prioridade` — Lula fell, Solla grew with a relevant base (≥200 votes): the
|
||||||
|
* operational heart (joint Lula–Solla agenda, person-to-person contact).
|
||||||
|
* `mobilizacao` — same swing, smaller base: leaders + geolocated digital.
|
||||||
|
* `defesa` — both fell: turnout/defense, no showcase.
|
||||||
|
* `expansao` — Lula did not lose here: keep presence and mobilize.
|
||||||
|
*/
|
||||||
|
export const classifySecondRoundRole = ({
|
||||||
|
dLula,
|
||||||
|
dSolla,
|
||||||
|
solla26,
|
||||||
|
}: SecondRoundSwingInput): SecondRoundRole => {
|
||||||
|
if (dLula < 0) {
|
||||||
|
if (dSolla > 0) return solla26 >= 200 ? 'prioridade' : 'mobilizacao'
|
||||||
|
return 'defesa'
|
||||||
|
}
|
||||||
|
return 'expansao'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const secondRoundRoleLabels: Record<SecondRoundRole, string> = {
|
||||||
|
prioridade: 'Mobilização segura — prioridade',
|
||||||
|
mobilizacao: 'Mobilização segura',
|
||||||
|
defesa: 'Defesa e comparecimento',
|
||||||
|
expansao: 'Presença — Lula não perdeu aqui',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const secondRoundRoleGuidance: Record<SecondRoundRole, string> = {
|
||||||
|
prioridade:
|
||||||
|
'Lula perdeu votos aqui e a marca local (Solla) cresceu com base relevante (≥200 votos): prioridade de mobilização — agenda conjunta Lula–Solla, contato pessoal e de vizinhança; o adversário ainda captura parte da perda.',
|
||||||
|
mobilizacao:
|
||||||
|
'Lula perdeu votos e Solla cresceu: mobilização segura com base menor — lideranças locais, digital geolocalizado e contato dirigido.',
|
||||||
|
defesa:
|
||||||
|
'Lula e Solla caíram juntos: base em erosão — a tarefa primária é comparecimento e defesa do voto já conquistado; Solla fala para a própria base, sem vitrine.',
|
||||||
|
expansao:
|
||||||
|
'Lula não perdeu votos neste recorte (ou ganhou): manter presença e mobilização; persuasão em eleição geral tem efeito médio próximo de zero.',
|
||||||
|
}
|
||||||
93
tests/unit/bahiaSecondRound.unit.spec.ts
Normal file
93
tests/unit/bahiaSecondRound.unit.spec.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { statSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
bahiaSecondRoundTotals,
|
||||||
|
getBahiaSecondRoundUnit,
|
||||||
|
secondRoundAdversaryCapturePct,
|
||||||
|
secondRoundLulaShares,
|
||||||
|
secondRoundPotentialOf,
|
||||||
|
secondRoundSollaShareOfLula,
|
||||||
|
} from '@/lib/bahiaSecondRound'
|
||||||
|
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||||
|
import { classifySecondRoundRole } from '@/lib/secondRoundRole'
|
||||||
|
|
||||||
|
const ARTIFACT_PATH = join(process.cwd(), 'src/lib/electionAggregates/bahia-second-round-2026.json')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the committed 2º-turno artifact to the municipality catalog and to the
|
||||||
|
* audited TSE totals. Drift here means the city report would print the wrong
|
||||||
|
* swing silently — regenerate with `pnpm build:second-round`.
|
||||||
|
*/
|
||||||
|
describe('bahia 2º-turno artifact', () => {
|
||||||
|
it('covers exactly the municipality catalog slugs', () => {
|
||||||
|
const missing = municipalityCatalog.filter((entry) => !getBahiaSecondRoundUnit(entry.slug))
|
||||||
|
expect(missing.map((entry) => entry.slug)).toEqual([])
|
||||||
|
expect(municipalityCatalog).toHaveLength(435)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches the audited Bahia totals (TSE, 1º turno 2026 + 2022)', () => {
|
||||||
|
expect(bahiaSecondRoundTotals.lula26).toBe(5_664_771)
|
||||||
|
expect(bahiaSecondRoundTotals.adversary26).toBe(2_442_585)
|
||||||
|
expect(bahiaSecondRoundTotals.solla26).toBe(152_049)
|
||||||
|
expect(bahiaSecondRoundTotals.lula22).toBe(5_873_081)
|
||||||
|
expect(bahiaSecondRoundTotals.adversary22).toBe(2_047_599)
|
||||||
|
expect(bahiaSecondRoundTotals.solla22).toBe(128_968)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the role the shared rule reclassifies', () => {
|
||||||
|
for (const entry of municipalityCatalog) {
|
||||||
|
const unit = getBahiaSecondRoundUnit(entry.slug)!
|
||||||
|
expect(unit.role).toBe(
|
||||||
|
classifySecondRoundRole({
|
||||||
|
dLula: unit.dLula,
|
||||||
|
dSolla: unit.dSolla,
|
||||||
|
solla26: unit.solla26,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('points Salvador to mobilization in the 4 ZE the analysis called out, and defends 16/4', () => {
|
||||||
|
for (const zone of [19, 15, 9, 12]) {
|
||||||
|
expect(getBahiaSecondRoundUnit(`salvador-ze-${zone}`)!.role).toBe('prioridade')
|
||||||
|
}
|
||||||
|
for (const zone of [16, 4]) {
|
||||||
|
expect(getBahiaSecondRoundUnit(`salvador-ze-${zone}`)!.role).toBe('defesa')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes X₁/X₂ with the same formula as the public /potencial scenario', () => {
|
||||||
|
const unit = getBahiaSecondRoundUnit('salvador-ze-1')!
|
||||||
|
const potential = secondRoundPotentialOf(unit)!
|
||||||
|
expect(potential.x1).toBe(unit.comparecimento26 - unit.lula26 - unit.adversary26)
|
||||||
|
expect(potential.x2).toBe(unit.aptos26 - unit.lula26 - unit.adversary26)
|
||||||
|
expect(potential.x1).toBe(6_557)
|
||||||
|
expect(potential.x2).toBe(22_231)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes the swing lenses (shares, Solla ratio, adversary capture)', () => {
|
||||||
|
const feira = getBahiaSecondRoundUnit('feira-de-santana')!
|
||||||
|
const shares = secondRoundLulaShares(feira)
|
||||||
|
expect(shares.deltaPp).toBeCloseTo(-4.57, 1)
|
||||||
|
expect(secondRoundSollaShareOfLula(feira)).toBeCloseTo(1.31, 1)
|
||||||
|
expect(secondRoundAdversaryCapturePct(feira)).toBeCloseTo(82.4, 1)
|
||||||
|
// Capture is undefined where Lula did not lose votes.
|
||||||
|
const expansion = municipalityCatalog
|
||||||
|
.map((entry) => getBahiaSecondRoundUnit(entry.slug)!)
|
||||||
|
.find((unit) => unit.dLula >= 0)!
|
||||||
|
expect(secondRoundAdversaryCapturePct(expansion)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for an unknown slug', () => {
|
||||||
|
expect(getBahiaSecondRoundUnit('nao-existe')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays under the committed artifact byte budget', () => {
|
||||||
|
const { size } = statSync(ARTIFACT_PATH)
|
||||||
|
// Measured at ~287 KB with 435 units; budget with headroom, not a precise pin.
|
||||||
|
expect(size).toBeLessThan(400 * 1024)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -111,6 +111,7 @@ describe('renderBriefingHtml', () => {
|
||||||
|
|
||||||
it('renders the literal request with the vote number and the one-line plan', () => {
|
it('renders the literal request with the vote number and the one-line plan', () => {
|
||||||
expect(html).toContain('vote 1313, Jorge Solla')
|
expect(html).toContain('vote 1313, Jorge Solla')
|
||||||
|
expect(html).toContain('vote 13, Lula')
|
||||||
expect(html).toContain('request-line')
|
expect(html).toContain('request-line')
|
||||||
expect(html).toContain('request-block')
|
expect(html).toContain('request-block')
|
||||||
expect(html).toContain('compromisso nomeado')
|
expect(html).toContain('compromisso nomeado')
|
||||||
|
|
@ -178,6 +179,7 @@ describe('renderBriefingMd (companion superset)', () => {
|
||||||
expect(md).toContain(droppedQuestion.question)
|
expect(md).toContain(droppedQuestion.question)
|
||||||
expect(md).toContain('Insumo interno de capacitação — não publicar')
|
expect(md).toContain('Insumo interno de capacitação — não publicar')
|
||||||
expect(md).toContain('vote 1313, Jorge Solla')
|
expect(md).toContain('vote 1313, Jorge Solla')
|
||||||
|
expect(md).toContain('vote 13, Lula')
|
||||||
expect(md).toContain('**Plano:** Combine onde, quando e como votar')
|
expect(md).toContain('**Plano:** Combine onde, quando e como votar')
|
||||||
expect(md).toContain('[fonte](https://saude.test/ambulancia)')
|
expect(md).toContain('[fonte](https://saude.test/ambulancia)')
|
||||||
expect(md).toContain('## O que Solla defende')
|
expect(md).toContain('## O que Solla defende')
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ describe('skill /briefing-capacitacao-solla documents the flow', () => {
|
||||||
it('pins the hard product literals', () => {
|
it('pins the hard product literals', () => {
|
||||||
expect(skill).toContain('Insumo interno de capacitação — não publicar')
|
expect(skill).toContain('Insumo interno de capacitação — não publicar')
|
||||||
expect(skill).toContain('vote 1313')
|
expect(skill).toContain('vote 1313')
|
||||||
|
expect(skill, 'the 2º-turno mission is part of the ask').toContain('Missão do 2º turno')
|
||||||
|
expect(skill, 'the pedido is dual since 08/10/2026').toContain('vote 13, Lula')
|
||||||
expect(skill).toMatch(/4 páginas/)
|
expect(skill).toMatch(/4 páginas/)
|
||||||
expect(skill).toContain('insumo interno')
|
expect(skill).toContain('insumo interno')
|
||||||
expect(skill).toMatch(/sem CTA público/i)
|
expect(skill).toMatch(/sem CTA público/i)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,28 @@ describe('finite researcher receipt (OPS118)', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('skill /relatorio-cidade carries the 2º turno 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, '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')
|
||||||
|
expect(skill, 'the scenario is never a promise').toMatch(
|
||||||
|
/votos em jogo, não transferência garantida/i,
|
||||||
|
)
|
||||||
|
for (const role of ['prioridade', 'mobilizacao', 'defesa', 'expansao']) {
|
||||||
|
expect(skill, `role vocabulary must include ${role}`).toContain(role)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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(
|
||||||
|
/números oficiais.*artefato/is,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('subagent .opencode/agent/relatorio-cidade.md is researcher-only', () => {
|
describe('subagent .opencode/agent/relatorio-cidade.md is researcher-only', () => {
|
||||||
it('stays a subagent that writes research.json and returns the receipt', () => {
|
it('stays a subagent that writes research.json and returns the receipt', () => {
|
||||||
expect(researcher).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/)
|
expect(researcher).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/)
|
||||||
|
|
|
||||||
|
|
@ -403,14 +403,55 @@ const asReport = (value: unknown) => value as ReportShape
|
||||||
describe('buildCityReport', () => {
|
describe('buildCityReport', () => {
|
||||||
const report = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
|
const report = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
|
||||||
|
|
||||||
it('builds the 14 deepening sections in the product order', () => {
|
it('builds the 15 deepening sections in the product order', () => {
|
||||||
expect(report.sections).toHaveLength(14)
|
expect(report.sections).toHaveLength(15)
|
||||||
expect(report.sections[0].title).toMatch(/Conta eleitoral completa/)
|
expect(report.sections[0].title).toMatch(/Conta eleitoral completa/)
|
||||||
expect(report.sections[1].title).toMatch(/Concorrentes no município/)
|
expect(report.sections[1].title).toMatch(/2º turno no município/)
|
||||||
expect(report.sections[7].title).toMatch(/Atividade econômica/)
|
expect(report.sections[2].title).toMatch(/Concorrentes no município/)
|
||||||
expect(report.sections[8].title).toMatch(/Transporte e conexões/)
|
expect(report.sections[8].title).toMatch(/Atividade econômica/)
|
||||||
expect(report.sections[12].title).toMatch(/Abordagem sugerida/)
|
expect(report.sections[9].title).toMatch(/Transporte e conexões/)
|
||||||
expect(report.sections[13].title).toMatch(/Fontes e limites/)
|
expect(report.sections[13].title).toMatch(/Abordagem sugerida/)
|
||||||
|
expect(report.sections[14].title).toMatch(/Fontes e limites/)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 callout = section.blocks.find((block) => block.kind === 'callout')!
|
||||||
|
expect(callout.title).toContain('Defesa e comparecimento')
|
||||||
|
expect(callout.body!.join(' ')).toContain('Captura do adversário')
|
||||||
|
const table = section.blocks.find((block) => block.kind === 'table')!
|
||||||
|
const rows = table.rows as unknown as Array<Record<string, string>>
|
||||||
|
expect(rows[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
label: 'Lula (PT)',
|
||||||
|
y22: '211.813',
|
||||||
|
y26: '194.272',
|
||||||
|
delta: '−17.541',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// The local movement of the research feeds the section; sem fonte não entra.
|
||||||
|
expect(section.blocks.some((block) => block.title?.includes('movimento local'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gaps the 2º turno research when the researcher did not answer it', () => {
|
||||||
|
const researchWithout = normalizeResearchInput(
|
||||||
|
{
|
||||||
|
municipalitySlug: 'feira-de-santana',
|
||||||
|
researchedAt: '2026-09-14T10:00:00.000Z',
|
||||||
|
items: RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'segundo_turno').map((id) =>
|
||||||
|
validItem(id),
|
||||||
|
),
|
||||||
|
gaps: [],
|
||||||
|
},
|
||||||
|
{ now: generatedAt },
|
||||||
|
)
|
||||||
|
const withoutReport = asReport(
|
||||||
|
buildCityReport({ snapshot, research: researchWithout, emendas, generatedAt }),
|
||||||
|
)
|
||||||
|
const section = withoutReport.sections.find((entry) => entry.id === 'segundo-turno')!
|
||||||
|
const gap = section.blocks.find((block) => block.kind === 'callout' && block.tone === 'gap')!
|
||||||
|
expect(gap.body!.join(' ')).toContain('Não pesquisado.')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds the researched demography, economy and transport data', () => {
|
it('adds the researched demography, economy and transport data', () => {
|
||||||
|
|
@ -504,7 +545,7 @@ describe('buildCityReport', () => {
|
||||||
emptyReport.sections.every((section, index) => section.title.startsWith(`${index + 1}. `)),
|
emptyReport.sections.every((section, index) => section.title.startsWith(`${index + 1}. `)),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(emptyReport.sections.find((section) => section.id === 'demografia')!.title).toMatch(
|
expect(emptyReport.sections.find((section) => section.id === 'demografia')!.title).toMatch(
|
||||||
/^5\. /,
|
/^6\. /,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,13 +144,15 @@ describe('renderReportMd', () => {
|
||||||
|
|
||||||
it('carries the same sections as tables and headings', () => {
|
it('carries the same sections as tables and headings', () => {
|
||||||
expect(md).toContain('## 1. Conta eleitoral completa')
|
expect(md).toContain('## 1. Conta eleitoral completa')
|
||||||
expect(md).toContain('## 2. Concorrentes no município (federal e estadual)')
|
expect(md).toContain('## 2. 2º turno no município (Lula × Flávio)')
|
||||||
expect(md).not.toContain('## 5. Sinais recentes')
|
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).not.toContain('Demandas e visitas')
|
||||||
expect(md).toContain('## 6. Atividade econômica (pesquisa)')
|
expect(md).toContain('## 7. Atividade econômica (pesquisa)')
|
||||||
expect(md).toContain('## 7. Transporte e conexões (pesquisa)')
|
expect(md).toContain('## 8. Transporte e conexões (pesquisa)')
|
||||||
expect(md).toContain('## 11. Abordagem sugerida (pesquisa)')
|
expect(md).toContain('## 12. Abordagem sugerida (pesquisa)')
|
||||||
expect(md).toContain('## 12. Fontes e limites')
|
expect(md).toContain('## 13. Fontes e limites')
|
||||||
expect(md).toContain('| Ano | Votos |')
|
expect(md).toContain('| Ano | Votos |')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ const baseResearch = (overrides: Record<string, unknown> = {}) => ({
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('normalizeResearchInput', () => {
|
describe('normalizeResearchInput', () => {
|
||||||
|
it('includes the 2º turno local movement in the checklist', () => {
|
||||||
|
expect(RESEARCH_CHECKLIST_IDS).toContain('segundo_turno')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps fully sourced items', () => {
|
it('keeps fully sourced items', () => {
|
||||||
const research = normalizeResearchInput(baseResearch(), { now })
|
const research = normalizeResearchInput(baseResearch(), { now })
|
||||||
expect(research.items).toHaveLength(RESEARCH_CHECKLIST_IDS.length)
|
expect(research.items).toHaveLength(RESEARCH_CHECKLIST_IDS.length)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue