Compare commits

...

2 commits

Author SHA1 Message Date
Francisco Solla
31f78f07ad feat(briefing): pedido dual 1313 + Lula 13 e missão do 2º turno
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
O briefing de capacitação passa a renderizar o pedido dual (deputado
federal 1313 e, no 2º turno, Lula 13) e a exigir ao menos uma pergunta
de 2º turno no Q&A; dossiê e boletim seguem sem CTA (prova material do
argumento, não peça de rua). Registra o changelog da entrega.
2026-10-09 04:01:20 -03:00
Francisco Solla
9fae859c0d feat(relatorio-cidade): leva o 2º turno (Lula × Flávio) ao relatório
Artefato oficial por unidade (município e ZE de Salvador): TSE 2026
presidente por seção + Solla 2026 e a fatia de 2022 da análise
2º turno; cada número validado contra o baseline TSE e os totais da BA
pinados (pnpm build:second-round).

O relatório ganha a seção "2º turno no município (Lula × Flávio)" com
X₁/X₂ pela mesma fórmula do /potencial, o papel determinístico da cidade
(prioridade/mobilizacao/defesa/expansao) e o novo item de pesquisa
segundo_turno (movimento local com fonte); a seção fica fora da página 1
porque é determinística e a página 1 falha fechada por medição.
2026-10-09 04:01:20 -03:00
21 changed files with 12851 additions and 36 deletions

View file

@ -1,6 +1,6 @@
---
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)
@ -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
com lastro (fonte e data);
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
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 é
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
- 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" },
{ "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"
{ "side": "direita|esquerda|entrega", "question": "≤180",
"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
(o par `factId` + `sourceUrl` precisa bater — par errado falha fechado);
- `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
(`estimatedVotes`, `scenario`, `projection`, `polls`, …) — o briefing não tem
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).
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
linha de orientação que o autor escreve.
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
(ex.: onde/como combinar o pedido municipal com o voto em Lula).
## Recibo do autor
@ -221,6 +235,9 @@ já carrega o aviso de insumo.
fonte; lacuna explícita em vez de inferência.
- **Sem cenário eleitoral/estimativa/staff-only** (nada de `estimatedVotes`,
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
(nunca % estadual absoluto); PII mínima (telefone/e-mail nunca entram).
- **Voz com dono único**: reusa `.opencode/skills/solla-comunicacao/SKILL.md` +

View file

@ -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
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
- A comunicação pede "o dossiê de <cidade>", o "boletim modelo de <cidade>"

View file

@ -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
"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)
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`,
`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
ausente também. `approach`, `preCandidates`, `leaders`, `leaderAgenda` e
`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
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
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
@ -325,19 +375,24 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
`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 `14. Fontes e
limites`).
- **Seções 2+:** `1. Conta eleitoral` · `2. Concorrentes no município (federal
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) · `3. Rede e
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 · `4. Conjuntura` · `5. Sinais` · `6.
Demandas e visitas` · `7. Demografia` — IBGE Censo 2022 do artefato +
**complemento pesquisado** (cor/raça e poder aquisitivo) · `8. Atividade
econômica (pesquisa)` · `9. Transporte e conexões (pesquisa)` · `10. Acervo
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
@ -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`);
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 · `11. Notícias e imprensa` ·
`12. Panorama regional` · `13. Abordagem sugerida (personas)` · `14. Fontes e
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).
@ -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
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.
- **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
@ -419,11 +480,20 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
## 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 é
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"**.
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`.
- **API de emendas 429/erro**: o builder degrada para lacuna com URL+motivo;
reexecute depois (o cache só é escrito com resultado utilizável).

View file

@ -27,11 +27,13 @@ dossiê daquele recorte estar pesquisado.
- `essential` (≥3) — fatos-âncora que sustentam a conversa (sem trajetória de
formação), com a fase junto ao valor;
- `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`
(`direita|esquerda|entrega`), `question`, `acknowledge`, `answer` (ancorada
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).
## Limites
@ -51,7 +53,8 @@ dossiê daquele recorte estar pesquisado.
(`estimatedVotes`, `scenario`, `projection`, `polls`, …): o briefing é
capacitação, não leitura eleitoral.
- 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),
sem inventar tom novo.
- **Não** faça pesquisa web nova, **não** edite os `research.json`, **não** rode

View file

@ -17,6 +17,10 @@ cidades em paralelo.
2. Fazer a **pesquisa web datada** e escrever
`data/relatorios-cidade/<slug>.research.json` no contrato da skill — item sem
`sourceUrl`/`sourceDate` não vai para o arquivo (vira lacuna explícita).
O checklist inclui **`segundo_turno`** — o movimento local da disputa Lula ×
Flávio na cidade (atos, palanques, posições de lideranças locais), com fonte
e data por fato. Os **números oficiais** de 2º turno (Lula/Flávio/Solla,
X₁/X₂) vêm do artefato do builder — não os pesquise nem os estime.
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
@ -32,4 +36,7 @@ cidades em paralelo.
— artefato gitignored).
- Não invente fato sem fonte; não trate empenho como pagamento; 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.
- Não edite os scripts nem o layout do relatório para "caber".

View 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.

View file

@ -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: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: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: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",

View 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()

View file

@ -16,7 +16,7 @@ import { resolveDossierUnit } from './dossieUnit.mjs'
import { htmlEscape, moreItemsLabel } from './reportText.mjs'
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 = `
:root {
@ -263,7 +263,7 @@ export const renderBriefingMd = (content, { unit, report }) => {
'',
'## 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}`,
'',

View file

@ -10,6 +10,14 @@
*/
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 { getMunicipalityVoteRank } from '../../src/lib/municipalityVoteRank.ts'
import { activityStatusLabels } from '../../src/lib/schemas/activity.ts'
@ -18,6 +26,7 @@ import {
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,
@ -134,6 +143,14 @@ const withInlineSources = (text, sources) => {
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
* + data). O texto é o `summary` do researcher quando existe; sem ele, a
@ -471,6 +488,206 @@ const buildElectoralSection = ({ snapshot }) => {
].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 buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({
@ -1520,6 +1737,11 @@ export const buildCityReport = ({
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)',

View file

@ -17,6 +17,10 @@ const RESEARCH_CHECKLIST = [
{ id: 'relacao_campo', label: 'Relação com o campo (aliado, oposição, neutro)' },
{ id: 'vereadores', label: 'Vereadores e dobradas' },
{ 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: 'noticias', label: 'Notícias da cidade/região (janela ≤90 dias)' },
{ id: 'imprensa_local', label: 'Imprensa e rádio local' },

111
src/lib/bahiaSecondRound.ts Normal file
View 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
}

File diff suppressed because it is too large Load diff

View 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.',
}

View 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)
})
})

View file

@ -111,6 +111,7 @@ describe('renderBriefingHtml', () => {
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 13, Lula')
expect(html).toContain('request-line')
expect(html).toContain('request-block')
expect(html).toContain('compromisso nomeado')
@ -178,6 +179,7 @@ describe('renderBriefingMd (companion superset)', () => {
expect(md).toContain(droppedQuestion.question)
expect(md).toContain('Insumo interno de capacitação — não publicar')
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('[fonte](https://saude.test/ambulancia)')
expect(md).toContain('## O que Solla defende')

View file

@ -37,6 +37,8 @@ describe('skill /briefing-capacitacao-solla documents the flow', () => {
it('pins the hard product literals', () => {
expect(skill).toContain('Insumo interno de capacitação — não publicar')
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).toContain('insumo interno')
expect(skill).toMatch(/sem CTA público/i)

View file

@ -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', () => {
it('stays a subagent that writes research.json and returns the receipt', () => {
expect(researcher).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/)

View file

@ -403,14 +403,55 @@ const asReport = (value: unknown) => value as ReportShape
describe('buildCityReport', () => {
const report = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
it('builds the 14 deepening sections in the product order', () => {
expect(report.sections).toHaveLength(14)
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(/Concorrentes no município/)
expect(report.sections[7].title).toMatch(/Atividade econômica/)
expect(report.sections[8].title).toMatch(/Transporte e conexões/)
expect(report.sections[12].title).toMatch(/Abordagem sugerida/)
expect(report.sections[13].title).toMatch(/Fontes e limites/)
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/)
})
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', () => {
@ -504,7 +545,7 @@ describe('buildCityReport', () => {
emptyReport.sections.every((section, index) => section.title.startsWith(`${index + 1}. `)),
).toBe(true)
expect(emptyReport.sections.find((section) => section.id === 'demografia')!.title).toMatch(
/^5\. /,
/^6\. /,
)
})

View file

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

View file

@ -25,6 +25,10 @@ const baseResearch = (overrides: Record<string, unknown> = {}) => ({
})
describe('normalizeResearchInput', () => {
it('includes the 2º turno local movement in the checklist', () => {
expect(RESEARCH_CHECKLIST_IDS).toContain('segundo_turno')
})
it('keeps fully sourced items', () => {
const research = normalizeResearchInput(baseResearch(), { now })
expect(research.items).toHaveLength(RESEARCH_CHECKLIST_IDS.length)