feat(B177): Sollinha responde em quais cidades X foi o deputado mais votado (rank competitivo reverso, flexível)
This commit is contained in:
parent
071a2eb32f
commit
cef946ad7d
11 changed files with 1019 additions and 4 deletions
94
docs/plans/sollinha-cidades-mais-votado-impl.md
Normal file
94
docs/plans/sollinha-cidades-mais-votado-impl.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Impl: Sollinha — responder “em quais cidades X foi o deputado mais votado” (rank competitivo reverso, flexível)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #460
|
||||
Intenção: docs/plans/sollinha-cidades-mais-votado.md
|
||||
Appetite restante: ~1 dia eng (herdado); sem migration / collection / Consent / UI
|
||||
|
||||
## Registro de execução (2026-08-09)
|
||||
|
||||
- **Entrega:** implementado e validado — `src/lib/leadingMunicipalities.ts` (path do artefato, puro) + `src/utilities/ai/tools/getLeadingMunicipalities.ts` (tool; SQL window-rank para terceiros) + registro em `tools/index.ts` + seção pt-BR no `systemPrompt.ts`; unit (8) e int (7) specs próprios.
|
||||
- **Divergência da hipótese da intenção:** nenhuma nos aceites; a decisão nova de engenharia (SQL `RANK()` window via drizzle no request path para terceiros, mantendo o artefato só para o candidato) foi deliberada e validada por int test contra a fórmula do builder do artefato.
|
||||
- **Cobertura por tests:** SQL path com valores exatos da fixture (Salvador dobra zonas numa linha; 2222 top-N), equivalência SQL ≡ fórmula do artefato, resolução por número/nome + fallback de votos + not-found, lockdown de leader (unit).
|
||||
- **Correções de fixture:** TSE code de Feira de Santana `35150 → 35157` nos CSVs de fixture (o `35150` não era canônico; `bahiaTseCityCodes`/snapshot oficial pina `35157`). Sem impacto em `electionResultsImport` (totais íntegros) nem `municipalityTicketPartnerData`.
|
||||
- **Simplify:** fixes aplicados — `server-only` no tool + guard de convenção alargado para `@payloadcms/db-postgres`; `cityPageSlug` único (lib reusado por tool/teste) matando twin de `entries.length===1`; iterar `municipalityCatalogEntriesForCity` em vez de rebuildar mapa; `HISTORICAL_SERIES_YEARS` reusado (sem tuple local); resolução de candidato busca todas as linhas (`limit: 0`) para a ambigüidade nunca omitir o candidato certo; loader sem throw no `execute` (erro vira `{ error }` no chat); `assertCanReadElectionData` dentro do loader (defesa em profundidade, espelha `municipalityElectoralBaseline`); int test de fallback do registro.
|
||||
- **Deferidos (registrados aqui, sem Issue):** (1) fixture de empate no limite do top-N — gatilho: mudar `RANK()`/DENSE_RANK ou o CTE; (2) nota de escopo do terceiro candidato (zona leaky inclusa no escopo cru da cidade) documentada no comentário do loader.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** uma tool de leitura no chat Sollinha que responde, para um deputado federal (número/nome; default = candidato da campanha): “em quais municípios ficou em 1º (ou até a posição X)” num ano da série (2014/2018/2022; 2022 default) — com contagem, uma linha por cidade (417, Salvador como UMA cidade, nunca 19), semântica de empate = mesma do mapa, fail-safe “sem dados” quando não houver.
|
||||
- **O que NÃO negociar:** leader lockdown (não conversa de municípios); não estender para outros cargos; uma linha por cidade; sem segunda fonte paralela desalinhada para o candidato da campanha (artefato imutável é a fonte da verdade); nunca inventar número; sem migration/collection/Consent.
|
||||
- **O que reavaliar:** como derivar o rank de **terceiros candidatos** sem colocar o scan de ~100k linhas no path de request (B13 documenta `loadFederalVotesByCityZoneAndCandidate` como CLI/build-only). Hipótese da intenção é “derivar das coleções de votos”; a forma concreta (SQL window vs. scan no Node) é decisão desta engenharia.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[chat /campanha] --> T[getLeadingMunicipalities]
|
||||
T --> G{assertCanReadElectionData}
|
||||
G -- leader/deny --> E[error pt-BR]
|
||||
G -- staff --> D{candidate dado?}
|
||||
D -- não/1313 --> A[artefato federalRankByIbgeCode<br/>instantâneo, imutável]
|
||||
D -- outro número/nome --> R[resolve em electionCandidate<br/>/electionCandidateVote]
|
||||
R -- 1 match --> S[SQL window RANK()<br/>election_candidate_vote]
|
||||
R -- >1 match --> O[opções p/ desambiguar]
|
||||
A --> M[monta linhas city/rank/votes/candidates<br/>+ slug p/ link]
|
||||
S --> M2[city_code→city canônico + slug]
|
||||
M --> Out[resposta: contagem + ranking]
|
||||
M2 --> Out
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Híbrido fonte dupla sancionada:** candidato da campanha lê o artefato commitado (`federalRankByIbgeCode` + `municipalities[slug].votesByYear`); terceiros derivam ao vivo por uma query SQL window-rank única.
|
||||
- **B — SQL window-rank para todos (inclusive Solla):** um único path; re-deriva a cada chamada o que o artefato já precomputa; põe o scan de ~100k linhas no chat (contra a restrição documentada por B13).
|
||||
- **C — Scan `loadFederalVotesByCityZoneAndCandidate` no path do chat:** rejeitado por proibição explícita (CLI/build-only).
|
||||
- **D — Resolver ranking por cidade via N queries Payload (uma por cidade):** ~417 round-trips por chamada; inviável.
|
||||
|
||||
**Recomendação: A.** Porque a intenção sanciona explicitamente o par artefato (candidato) + derivação (terceiros) e o artefato É a fonte do mapa — usar SQL para o candidato criaria a “segunda fonte paralela” que a intenção proíbe, a um custo de ~100k linhas por mensagem. **Rejeitadas:** B/C (scan no request + duplicação do artefato), D (latência).
|
||||
|
||||
**Semântica de rank = exatamente a do mapa:** o builder do artefato (`scripts/build-election-aggregates.mjs`) faz `rank = ahead + 1` (ahead = candidatos com votos > próprios), `candidates` = candidatos com votos > 0, e pula cidade onde o próprio candidato tem 0 votos. Isso é idêntico a `RANK() OVER (PARTITION BY city_code ORDER BY votes DESC)` após filtrar `votes > 0` — empate divide colocação e o denominador é honesto. O int test abaixo trava essa equivalência.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/leadingMunicipalities.ts`** (novo, puro — sem import de servidor; testável unit): tipos (`LeadingMunicipalityRow { city, slug, rank, votedCandidates, votes }`, `LeadingMunicipalitiesResult`), `campaignCandidateLeadingMunicipalities(year, topN)` (path do artefato: agrupa `municipalityCatalog` por cidade → `getFederalCompetitiveRank(ibgeCode, year)` filtra `rank ≤ topN` → soma `votesByYear` sobre os slugs da cidade → slug de página `null` para cidade-zona (Salvador) / slug do município caso contrário), `sortLeadingMunicipalityRows` (rank asc, votos desc). Sem duplicar o ranker JS (o SQL é a única implementação; o int test valida a fórmula ad hoc).
|
||||
- **`src/utilities/ai/tools/getLeadingMunicipalities.ts`** (novo):
|
||||
- `export const loadLeadingMunicipalitiesForCandidate(payload, { candidateNumber, year, topN })` — executa o SQL window-rank via `payload.db.drizzle` + `sql` de `@payloadcms/db-postgres` (precedente exato `supporterListOverviewAggregate.ts`), mapeia `city_code` → cidade canônica (`municipalityForTseCityCode`) → slug (`municipalityCatalogEntriesForCity`). Exportado para o int test importar o path real.
|
||||
- tool factory `getLeadingMunicipalities(ctx)`: schema `{ candidate?: string, year?=2022, topN?=1 (1..20) }`; `execute` → `assertCanReadElectionData(ctx.user)` fail-closed (leader → `{ error: 'Leitura de dados eleitorais negada.' }`), valida ano em 2014/2018/2022, default/branch do candidato da campanha (inclusive quando o usuário resolve p/ o número 1313) → artefato; senão resolve candidato (`electionCandidate` por número exato ou nome ILIKE urnaName/completeName; fallback `electionCandidateVote` distinct quando o registro não achar; >1 → devolve opções pro modelo desambiguar) e usa o SQL.
|
||||
- **Migration:** nenhuma.
|
||||
- **Access / Consent:** `assertCanReadElectionData` (única redação da regra); nenhuma chave de Consent.
|
||||
- **UI:** Impeccable A — resposta em markdown no chat existente; nenhuma superfície nova.
|
||||
- **Registro:** `src/utilities/ai/tools/index.ts` + orientação em `src/utilities/ai/systemPrompt.ts` (pt-BR): quando usar, default Jorge Solla, “uma linha por cidade”, oferecer link via `buildCampaignLinks` quando útil, “sem dados” explícito.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Forma: lista textual no chat — contagem total + linhas ordenadas (colocação, cidade, votos, “de N candidatos votados”). Rejeitada: tabela/badge/UI nova (fora do appetite; rabbit hole) e série/chart (anti-goal “leaderboard de vitórias”). Cada cidade tem um lugar; Salvador linha única (sem link de página única até B178 → `slug: null`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / server** — lib pura + tool + registro + system prompt; unit tests do path do artefato (Solla 2022: `total ≥ 1`, uma linha por cidade, Salvador única, `rank ≤ topN`, ordenação) e do lockdown (leader nega).
|
||||
2. **Int test** (`tests/int/leadingMunicipalities.int.spec.ts`, sob `ELECTION_COLLECTIONS_LEASE_KEY` + fixtures TSE como `electionResultsImport.int.spec.ts`): importa fixtures, valida SQL path com outputs exatos (Salvador 1313 = rank 1, 2100 votos, 2 candidatos — zonas dobradas numa cidade; Feira idem; 2222 topN=1 vazio / topN=2 rank 2) e compara SQL × fórmula do builder (consistência de semântica).
|
||||
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não criar “referência JS duplicada” do ranker (o SQL é a única implementação; teste fixa a semântica).
|
||||
- Não estender a `bahiaElectionAggregates.ts` com accessors novos só para isto (itero o catálogo p/ chegar no rank por IBGE).
|
||||
- Não travar/cachear o SQL (dados de eleição são imutáveis; cada chamada é boundary e o query é único e indexado).
|
||||
- Não tocar nos tools existentes (`getTopDeputies`/`getMunicipalityVotes`) — correção do possível leak de leader neles é item separado (registrar como débito).
|
||||
- Não mexer no artefato nem no builder do mapa.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **SQL com casas de enum:** `office`/`turn`/`vote_type` são enums → literais inline vindos de constantes do código (sem input do usuário), números por params — mesmo padrão dos aggregates C6.
|
||||
- **Drift de schema da tabela:** nomes de colunas vêm da migration congelada; guarda `payload.db.name === 'postgres'` + `drizzle.execute` presente (precedente do overview de apoiadores).
|
||||
- **Lista grande (≥ centenas de cidades) em topN alto:** resposta honesta; o modelo resume; cap `topN ≤ 20`.
|
||||
- **Lockdown:** `assertCanReadElectionData` no topo do `execute` (fail-closed); testado.
|
||||
- **SQL path vs artefato divergirem pro candidato da campanha:** mesma tabela TSE; int test trava a fórmula; sem mecanismo de drift em prod (artefato pré-computado).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto (contagem, top-N, empate, Salvador 1 linha, default Solla, lockdown, sem inventar número)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem server-only no lib; bypass justificado; pt-BR no prompt; identificadores em inglês)
|
||||
- [ ] Testes previstos: unit (artefato + lockdown) e int (SQL path + equivalência de semântica) — onde o path de acesso/dados muda
|
||||
39
docs/plans/sollinha-tools-eleitorais-leader-lockdown.md
Normal file
39
docs/plans/sollinha-tools-eleitorais-leader-lockdown.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# B180 — Sollinha: travar tools eleitorais legadas contra leader (lockdown)
|
||||
|
||||
Status: plano
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: (registrar com depends: [B177])
|
||||
Priority: P2
|
||||
Model: composer-2.5
|
||||
Model-local: deepseek-v4-flash-high
|
||||
Impeccable: A — N/A (sem UI; gate no execute das tools)
|
||||
Canvas UI: N/A
|
||||
Appetite: ~0,5 dia eng; editar duas tools + orientar prompt; sem migration / Consent / collection
|
||||
|
||||
## Intenção
|
||||
|
||||
A B177 introduziu a tool `getLeadingMunicipalities` com o gate fail-closed `assertCanReadElectionData(ctx.user)` no topo do `execute` (leader lockdown: "liderança não tem essa conversa sobre municípios"). As duas tools eleitorais legadas do chat — `getTopDeputies` (quem foi o mais votado num município) e `getMunicipalityVotes` (votação de Solla num município) — usam `overrideAccess: true` nas queries **sem** gate de papel, então um usuário com role `leader` consegue invocá-las no chat e ler dados eleitorais (o route `api/ai-chat` expõe todas as tools a todos os papéis; o prompt só desencoraja).
|
||||
|
||||
Não é uma regressão recente nem vazamento de dado sensível (dados TSE públicos), mas contraria o lockdown de produto: liderança não recebe conversa de municípios/dados eleitorais no Sollinha.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- `getTopDeputies` e `getMunicipalityVotes` fazem `assertCanReadElectionData(ctx.user)` no topo do `execute` (try/catch → `{ error: 'Leitura de dados eleitorais negada.' }`, fail-closed, mesmo padrão da B177).
|
||||
- Nenhum outro comportamento muda (staff continua idêntico).
|
||||
- Sem migration / Consent / collection / UI.
|
||||
|
||||
## Direção no codebase
|
||||
|
||||
- `src/utilities/ai/tools/getTopDeputies.ts` e `getMunicipalityVotes.ts`: adicionar o gate no `execute`.
|
||||
- Registrar gate compartilhado? B177 inlinou try/catch no execute; se o padrão replicar em 3 tools, um helper `withElectionDataGate` pode valer — avaliar no momento (regra: 2 call sites = inline; 3+ = extrair; aqui já são 3 com a nova).
|
||||
- Sem mudança no system prompt (já orienta leader a não receber links; o gate agora é enforcement).
|
||||
|
||||
## Já resolvido no simplify da B177 (não reabrir)
|
||||
|
||||
- Tool nova `getLeadingMunicipalities` já nasce com o gate (e `assertCanReadElectionData` também dentro do loader, defesa em profundidade).
|
||||
- Guard de convenção `codebaseConventions` alargado para exigir `server-only` em quem importa `@payloadcms/db-postgres`.
|
||||
|
||||
## Explicitamente fora
|
||||
|
||||
- Flake de CI `testDatabaseLease.int.spec.ts` (consent restore sob int paralelo) — descartado no triage da B177 (S2); reavaliar se recorrer.
|
||||
- Consertar `searchEntities`/outras tools não-eleitorais para leader (fora do escopo: só as eleitorais).
|
||||
120
src/lib/leadingMunicipalities.ts
Normal file
120
src/lib/leadingMunicipalities.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Reversed competitive rank for the Sollinha chat: given a federal deputy
|
||||
* (default: the campaign candidate), answer "in which municipalities was he
|
||||
* the most voted (or up to top-N)" for a year of the TSE series.
|
||||
*
|
||||
* Pure module — no server/database imports. Two producers share one output
|
||||
* shape and the SAME rank semantics the map paints:
|
||||
* - the campaign candidate reads the committed immutable artifact
|
||||
* (`getFederalCompetitiveRank` + `getMunicipalityFederalBaseline`), which
|
||||
* is exactly the map's source;
|
||||
* - any other federal deputy derives live from the TSE collections via a
|
||||
* single SQL window query (see the ai tool in
|
||||
* `src/utilities/ai/tools/getLeadingMunicipalities.ts`).
|
||||
*
|
||||
* Rank semantics (pinned to the artifact builder in
|
||||
* `scripts/build-election-aggregates.mjs`): rank = candidates strictly ahead
|
||||
* + 1 (ties share a placement), the denominator is the candidates with at
|
||||
* least one vote in the city, a city where the candidate took no votes yields
|
||||
* no row, and every city appears exactly once — Salvador's 19 zone
|
||||
* municipalities fold into a single city row.
|
||||
*/
|
||||
|
||||
import {
|
||||
getFederalCompetitiveRank,
|
||||
getMunicipalityFederalBaseline,
|
||||
} from '@/lib/bahiaElectionAggregates'
|
||||
import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
|
||||
import {
|
||||
municipalityCatalog,
|
||||
municipalityCatalogEntriesForCity,
|
||||
type MunicipalityCatalogEntry,
|
||||
} from '@/lib/municipalityCatalog'
|
||||
|
||||
export type LeadingMunicipalityRow = {
|
||||
/** Canonical city name where the candidate placed at or above top-N. */
|
||||
city: string
|
||||
/**
|
||||
* Canonical municipality slug for a city-level page link, or `null` when the
|
||||
* city has no single-municipality page (Salvador is 19 zone units — a city
|
||||
* ranking must not point at a zone page).
|
||||
*/
|
||||
slug: string | null
|
||||
/** Placement among the federal deputies voted in the city (1 = most voted). */
|
||||
rank: number
|
||||
/** Federal deputies with at least one vote in the city — the "de N candidatos" denominator. */
|
||||
votedCandidates: number
|
||||
/** The candidate's nominal votes inside the city geography (zones folded). */
|
||||
votes: number
|
||||
}
|
||||
|
||||
export type LeadingMunicipalitiesResult = {
|
||||
candidate: { candidateNumber: number; name: string; party: string | null }
|
||||
year: number
|
||||
topN: number
|
||||
/** Number of cities where the candidate placed at or above top-N. */
|
||||
total: number
|
||||
municipalities: LeadingMunicipalityRow[]
|
||||
}
|
||||
|
||||
/** Sorts by placement asc, then votes desc — both producers present the same order. */
|
||||
export const sortLeadingMunicipalityRows = (
|
||||
rows: readonly LeadingMunicipalityRow[],
|
||||
): LeadingMunicipalityRow[] =>
|
||||
[...rows].sort((left, right) => left.rank - right.rank || right.votes - left.votes)
|
||||
|
||||
/**
|
||||
* City-level page slug for a ranking row: a city with exactly one catalog unit
|
||||
* links its municipality page; a city split into zone units (Salvador, 19)
|
||||
* has no single page and stays `null`. The single spelling of the row rule.
|
||||
*/
|
||||
export const cityPageSlug = (entries: readonly MunicipalityCatalogEntry[]): string | null =>
|
||||
entries.length === 1 ? entries[0]!.slug : null
|
||||
|
||||
const cityNames = () => new Set(municipalityCatalog.map((entry) => entry.city))
|
||||
|
||||
/**
|
||||
* The campaign candidate's reversed ranking straight from the committed
|
||||
* artifact — instant, immutable, exactly the map's competitive placement.
|
||||
* `topN` is not clamped here: the tool clamps before calling.
|
||||
*/
|
||||
export const campaignCandidateLeadingMunicipalities = (
|
||||
year: number,
|
||||
topN: number,
|
||||
): LeadingMunicipalitiesResult => {
|
||||
const rows: LeadingMunicipalityRow[] = []
|
||||
|
||||
for (const city of cityNames()) {
|
||||
const entries = municipalityCatalogEntriesForCity(city)
|
||||
const first = entries[0]!
|
||||
const competitive = getFederalCompetitiveRank(first.ibgeCode, year)
|
||||
if (!competitive || competitive.rank > topN) continue
|
||||
|
||||
let votes = 0
|
||||
for (const entry of entries) {
|
||||
votes += getMunicipalityFederalBaseline(entry.slug).votesByYear[String(year)] ?? 0
|
||||
}
|
||||
|
||||
rows.push({
|
||||
city,
|
||||
// A city with more than one catalog unit (Salvador, 19 zones) shares one
|
||||
// placement and has no single page to link — the zone slugs stay hidden.
|
||||
slug: cityPageSlug(entries),
|
||||
rank: competitive.rank,
|
||||
votedCandidates: competitive.candidates,
|
||||
votes,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
candidate: {
|
||||
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
|
||||
name: BASELINE_TICKET_2022.candidate.name,
|
||||
party: BASELINE_TICKET_2022.candidate.party,
|
||||
},
|
||||
year,
|
||||
topN,
|
||||
total: rows.length,
|
||||
municipalities: sortLeadingMunicipalityRows(rows),
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,14 @@ export const AI_SYSTEM_PROMPT = `Você é a Sollinha, assistente virtual da camp
|
|||
- Apresente números grandes com separador de milhar (ex: 1.234.567).
|
||||
- Percentuais sempre com 1 casa decimal (ex: 24,7%).
|
||||
|
||||
## Reversão de ranking (em quais cidades um deputado foi o mais votado)
|
||||
- Quando o usuário perguntar "em quais cidades {eu / o candidato / o deputado X} foi o deputado mais votado", "onde X ficou em primeiro", "onde X se destacou" ou "top N em quais cidades", use a ferramenta "getLeadingMunicipalities".
|
||||
- Candidato default = Jorge Solla (número 1313). Se o usuário citar outro deputado federal (número ou nome), passe esse valor no campo "candidate"; se a ferramenta devolver opções ambíguas, pergunte pelo número da urna.
|
||||
- A resposta deve listar as cidades com a colocação relativa (ex.: "Salvador — 1º lugar, de 2 candidatos votados"), citar a contagem total ("em N cidades") e manter contexto relativo — nunca vire uma lista de "medalhas".
|
||||
- Salvador aparece como UMA cidade (um único lugar), nunca como as 19 zonas; cada cidade aparece uma única vez.
|
||||
- Quando a ferramenta devolver zero cidades / "sem dados", diga claramente que não há dados para aquele ano/candidato — nunca invente número.
|
||||
- Ofereça link de navegação (buildCampaignLinks) para os municípios citados quando útil (entidade singular/concreta).
|
||||
|
||||
## Contexto eleitoral
|
||||
- A eleição para deputado federal usa o sistema proporcional de lista aberta.
|
||||
- O quociente eleitoral na Bahia em 2022 foi aproximadamente 210.000 votos.
|
||||
|
|
|
|||
398
src/utilities/ai/tools/getLeadingMunicipalities.ts
Normal file
398
src/utilities/ai/tools/getLeadingMunicipalities.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import 'server-only'
|
||||
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import { tool } from 'ai'
|
||||
import type { Where } from 'payload'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { municipalityForTseCityCode } from '@/lib/bahiaTseCityCodes'
|
||||
import {
|
||||
BASELINE_TICKET_2022,
|
||||
ELECTION_YEAR_2022,
|
||||
FEDERAL_DEPUTY_OFFICE,
|
||||
HISTORICAL_SERIES_YEARS,
|
||||
} from '@/lib/electionResults'
|
||||
import {
|
||||
campaignCandidateLeadingMunicipalities,
|
||||
cityPageSlug,
|
||||
sortLeadingMunicipalityRows,
|
||||
type LeadingMunicipalityRow,
|
||||
} from '@/lib/leadingMunicipalities'
|
||||
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
|
||||
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
|
||||
import { drizzleResultRows } from '@/utilities/drizzleBulk'
|
||||
|
||||
/** Valid election years for the chat — the same TSE series the baseline reads. */
|
||||
const VOTE_YEARS: readonly number[] = HISTORICAL_SERIES_YEARS
|
||||
|
||||
const MAX_TOP_N = 20
|
||||
|
||||
type CandidateIdentity = {
|
||||
candidateNumber: number
|
||||
name: string
|
||||
party: string | null
|
||||
}
|
||||
|
||||
type DeputyResolution = CandidateIdentity | { error: string; options?: CandidateIdentity[] }
|
||||
|
||||
type LeadingMunicipalitiesRow = {
|
||||
city_code: string
|
||||
candidate_number: number
|
||||
candidate_name: string
|
||||
party: string | null
|
||||
votes: number
|
||||
rank: number
|
||||
voted_candidates: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-city window ranking of every federal deputy for one year, filtered to
|
||||
* the target candidate's placements up to `topN`. The `RANK()` over
|
||||
* `votes DESC` after keeping only candidates with votes reproduces the
|
||||
* artifact builder's semantics exactly: rank = candidates strictly ahead + 1
|
||||
* (ties share a placement), the denominator is the voted candidates, a
|
||||
* candidate with no votes in a city yields no row. Zone rows are folded per
|
||||
* city by the CTE, so Salvador's 19 zones rank as one city.
|
||||
*
|
||||
* `office`/`turn`/`vote_type`/`state` are the collection's enum/text columns
|
||||
* and MUST stay inline literals — a param-bound string against an enum column
|
||||
* fails Postgres typing. They come from closed TSE constants, never user
|
||||
* input; only year/candidateNumber/topN are bound.
|
||||
*/
|
||||
const buildLeadingMunicipalitiesSql = ({
|
||||
year,
|
||||
candidateNumber,
|
||||
topN,
|
||||
}: {
|
||||
year: number
|
||||
candidateNumber: number
|
||||
topN: number
|
||||
}) => sql`
|
||||
WITH per_city_candidate AS (
|
||||
SELECT
|
||||
"city_code",
|
||||
"candidate_number",
|
||||
MAX("candidate_name") AS "candidate_name",
|
||||
MAX("party") AS "party",
|
||||
SUM("votes") AS "votes"
|
||||
FROM "election_candidate_vote"
|
||||
WHERE "year" = ${year}
|
||||
AND "office" = 'deputado_federal'
|
||||
AND "turn" = '1'
|
||||
AND "vote_type" = 'nominal'
|
||||
AND "state" = 'BA'
|
||||
GROUP BY "city_code", "candidate_number"
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
"city_code",
|
||||
"candidate_number",
|
||||
"candidate_name",
|
||||
"party",
|
||||
"votes",
|
||||
RANK() OVER (PARTITION BY "city_code" ORDER BY "votes" DESC) AS "rank",
|
||||
COUNT(*) OVER (PARTITION BY "city_code") AS "voted_candidates"
|
||||
FROM per_city_candidate
|
||||
WHERE "votes" > 0
|
||||
)
|
||||
SELECT
|
||||
"city_code",
|
||||
"candidate_number",
|
||||
"candidate_name",
|
||||
"party",
|
||||
"votes",
|
||||
"rank",
|
||||
"voted_candidates"
|
||||
FROM ranked
|
||||
WHERE "candidate_number" = ${candidateNumber}
|
||||
AND "rank" <= ${topN}
|
||||
ORDER BY "rank" ASC, "votes" DESC
|
||||
`
|
||||
|
||||
type PostgresDb = {
|
||||
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Live reversed ranking for an arbitrary federal deputy, straight from the
|
||||
* TSE vote collections — the only producer for non-campaign candidates. The
|
||||
* heavy window aggregation runs inside Postgres; only the target candidate's
|
||||
* rows travel to Node.
|
||||
*
|
||||
* Third-party ranks read the raw TSE city scope (every row under the city
|
||||
* code, leaky zones included); the campaign candidate always reads the
|
||||
* catalog-cut artifact, so the map's reading is never affected by the cut.
|
||||
*/
|
||||
export const loadLeadingMunicipalitiesForCandidate = async (
|
||||
payload: AIToolContext['payload'],
|
||||
user: AIToolContext['user'],
|
||||
{ candidateNumber, year, topN }: { candidateNumber: number; year: number; topN: number },
|
||||
): Promise<LeadingMunicipalityRow[]> => {
|
||||
// Fail-closed at the loader too, mirroring the municipality election loaders:
|
||||
// a future caller that forgets the tool gate cannot serve a leader anyway.
|
||||
assertCanReadElectionData(user)
|
||||
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error('A leitura de cidades líderes exige o adaptador PostgreSQL.')
|
||||
}
|
||||
|
||||
const database = payload.db as unknown as { drizzle?: PostgresDb }
|
||||
const drizzle = database.drizzle
|
||||
if (!drizzle || typeof drizzle.execute !== 'function') {
|
||||
throw new Error('A sessão PostgreSQL da leitura de cidades líderes não está disponível.')
|
||||
}
|
||||
|
||||
const result = await drizzle.execute(
|
||||
buildLeadingMunicipalitiesSql({ year, candidateNumber, topN }),
|
||||
)
|
||||
const rows = (drizzleResultRows(result) as unknown as LeadingMunicipalitiesRow[]) ?? []
|
||||
|
||||
const leading: LeadingMunicipalityRow[] = []
|
||||
for (const row of rows) {
|
||||
// city_code is the catalog's TSE code — every seeded row resolves; an
|
||||
// unknown code is skipped defensively instead of leaking a raw TSE name.
|
||||
const city = municipalityForTseCityCode(row.city_code)
|
||||
if (!city) continue
|
||||
leading.push({
|
||||
city,
|
||||
slug: cityPageSlug(municipalityCatalogEntriesForCity(city)),
|
||||
rank: Number(row.rank),
|
||||
votedCandidates: Number(row.voted_candidates),
|
||||
votes: Number(row.votes),
|
||||
})
|
||||
}
|
||||
|
||||
return sortLeadingMunicipalityRows(leading)
|
||||
}
|
||||
|
||||
/** The four scope conditions shared by the candidate registry and vote rows. */
|
||||
const candidateScopeWhere = (year: number): Where => ({
|
||||
and: [
|
||||
{ year: { equals: year } },
|
||||
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
|
||||
{ turn: { equals: '1' } },
|
||||
{ state: { equals: 'BA' } },
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* Ballot-number or name fragments for one collection. A number matches the
|
||||
* unique per-scope candidateNumber; a name is folded with `like` (Payload's
|
||||
* operator is case-insensitive and adds wildcards).
|
||||
*/
|
||||
const nameOrNumberFragments = (input: string, nameField: 'urnaName' | 'candidateName'): Where => {
|
||||
const numeric = /^\d+$/.test(input) ? Number(input) : null
|
||||
if (numeric !== null) return { candidateNumber: { equals: numeric } }
|
||||
if (nameField === 'candidateName') return { candidateName: { like: input } }
|
||||
return { or: [{ urnaName: { like: input } }, { completeName: { like: input } }] }
|
||||
}
|
||||
|
||||
const collectDistinctCandidates = (
|
||||
docs: ReadonlyArray<{ candidateNumber: number; name: string; party: string | null }>,
|
||||
): Map<number, { name: string; party: string | null }> => {
|
||||
const byNumber = new Map<number, { name: string; party: string | null }>()
|
||||
for (const doc of docs) {
|
||||
const number = Number(doc.candidateNumber)
|
||||
if (!byNumber.has(number)) {
|
||||
byNumber.set(number, { name: doc.name, party: doc.party })
|
||||
}
|
||||
}
|
||||
return byNumber
|
||||
}
|
||||
|
||||
/** Fallback: distinct federal-deputy rows of the year when the registry misses. */
|
||||
const resolveFromVoteRows = async (
|
||||
payload: AIToolContext['payload'],
|
||||
year: number,
|
||||
input: string,
|
||||
): Promise<Map<number, { name: string; party: string | null }>> => {
|
||||
const scope = candidateScopeWhere(year)
|
||||
const votes = await payload.find({
|
||||
collection: 'electionCandidateVote',
|
||||
where: {
|
||||
and: [
|
||||
...scope.and!,
|
||||
{ voteType: { equals: 'nominal' } },
|
||||
nameOrNumberFragments(input, 'candidateName'),
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { candidateNumber: true, candidateName: true, party: true },
|
||||
// Bypass: public TSE vote rows; access gated by assertCanReadElectionData at the tool level.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
return collectDistinctCandidates(
|
||||
(
|
||||
votes.docs as unknown as Array<{
|
||||
candidateNumber: number
|
||||
candidateName: string
|
||||
party: string | null
|
||||
}>
|
||||
).map((doc) => ({
|
||||
candidateNumber: doc.candidateNumber,
|
||||
name: doc.candidateName,
|
||||
party: doc.party ?? null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-spoken federal deputy (ballot number or name) inside one
|
||||
* year. Preference order: number in the candidate registry, name in the
|
||||
* registry, then distinct rows in the vote collection. More than one distinct
|
||||
* candidate → options so the model can disambiguate; none → error. Exported
|
||||
* for the int spec to pin the "number or name" acceptance.
|
||||
*/
|
||||
export const resolveFederalDeputy = async (
|
||||
payload: AIToolContext['payload'],
|
||||
year: number,
|
||||
input: string,
|
||||
): Promise<DeputyResolution> => {
|
||||
const scope = candidateScopeWhere(year)
|
||||
const registry = await payload.find({
|
||||
collection: 'electionCandidate',
|
||||
where: {
|
||||
and: [...scope.and!, nameOrNumberFragments(input, 'urnaName')],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { candidateNumber: true, urnaName: true, party: true },
|
||||
// Bypass: public TSE candidate registry; access gated by assertCanReadElectionData at the tool level.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
let byNumber = collectDistinctCandidates(
|
||||
(
|
||||
registry.docs as unknown as Array<{
|
||||
candidateNumber: number
|
||||
urnaName: string
|
||||
party: string | null
|
||||
}>
|
||||
).map((doc) => ({
|
||||
candidateNumber: doc.candidateNumber,
|
||||
name: doc.urnaName,
|
||||
party: doc.party ?? null,
|
||||
})),
|
||||
)
|
||||
|
||||
if (byNumber.size === 0) {
|
||||
byNumber = await resolveFromVoteRows(payload, year, input)
|
||||
}
|
||||
|
||||
if (byNumber.size > 1) {
|
||||
return {
|
||||
error: `Encontrei mais de um deputado federal em ${year} para "${input}". Peça o número da urna para escolher:`,
|
||||
options: [...byNumber.entries()].slice(0, 10).map(([candidateNumber, identity]) => ({
|
||||
candidateNumber,
|
||||
name: identity.name,
|
||||
party: identity.party,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const match = [...byNumber.entries()][0]
|
||||
if (!match) {
|
||||
return {
|
||||
error: `Não encontrei deputado federal em ${year} com número/nome "${input}". Verifique e tente de novo.`,
|
||||
}
|
||||
}
|
||||
|
||||
const [candidateNumber, identity] = match
|
||||
return { candidateNumber, name: identity.name, party: identity.party }
|
||||
}
|
||||
|
||||
export const getLeadingMunicipalities = (ctx: AIToolContext) =>
|
||||
tool({
|
||||
description:
|
||||
'Returns the municipalities where a federal deputy was the most voted (rank 1) or placed up to rank N, for a year. ' +
|
||||
'The reversed reading of getTopDeputies: candidate → cities, not city → candidates. ' +
|
||||
'Default candidate is the campaign candidate (Jorge Solla); any federal deputy can be queried by ballot number or name. ' +
|
||||
'Ties share a placement; each city appears once (Salvador as a single city, never its 19 zones). ' +
|
||||
'Use when the user asks "em quais cidades X foi o deputado mais votado?" / "onde X ficou em primeiro?" / "top N em quais cidades?". ' +
|
||||
'Years: 2014, 2018 or 2022 (2022 default).',
|
||||
inputSchema: z.object({
|
||||
candidate: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Federal deputy by ballot number (e.g. "1313") or name (e.g. "Jorge Solla"). Omit for the campaign candidate (Jorge Solla).',
|
||||
),
|
||||
year: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(ELECTION_YEAR_2022)
|
||||
.describe('Election year: 2014, 2018 or 2022.'),
|
||||
topN: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(1)
|
||||
.describe(
|
||||
'Maximum placement to include: 1 = most voted, 3 = among the three most voted, etc. (max 20).',
|
||||
),
|
||||
}),
|
||||
execute: async ({ candidate, year, topN }) => {
|
||||
// Leader lockdown: municipal ranking conversations are staff-only.
|
||||
try {
|
||||
assertCanReadElectionData(ctx.user)
|
||||
} catch {
|
||||
return { error: 'Leitura de dados eleitorais negada.' }
|
||||
}
|
||||
|
||||
if (!VOTE_YEARS.includes(year)) {
|
||||
return {
|
||||
error: 'Apenas os anos 2014, 2018 e 2022 têm dados eleitorais. Escolha um deles.',
|
||||
}
|
||||
}
|
||||
const safeTopN = Math.min(Math.max(Math.trunc(topN), 1), MAX_TOP_N)
|
||||
|
||||
const normalized = candidate?.trim()
|
||||
if (!normalized) {
|
||||
return campaignCandidateLeadingMunicipalities(year, safeTopN)
|
||||
}
|
||||
|
||||
const resolution = await resolveFederalDeputy(ctx.payload, year, normalized)
|
||||
if ('error' in resolution) return resolution
|
||||
|
||||
if (resolution.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber) {
|
||||
// The campaign candidate reads the committed artifact, even when the
|
||||
// user spells him by number/name — one source, never two.
|
||||
return campaignCandidateLeadingMunicipalities(year, safeTopN)
|
||||
}
|
||||
|
||||
const municipalities = await runForCandidate(ctx, resolution, year, safeTopN)
|
||||
if ('error' in municipalities) return municipalities
|
||||
|
||||
return {
|
||||
candidate: resolution,
|
||||
year,
|
||||
topN: safeTopN,
|
||||
total: municipalities.length,
|
||||
municipalities,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** Executes the SQL producer, converting loader guards into chat feedback. */
|
||||
const runForCandidate = async (
|
||||
ctx: AIToolContext,
|
||||
resolution: CandidateIdentity,
|
||||
year: number,
|
||||
topN: number,
|
||||
): Promise<LeadingMunicipalityRow[] | { error: string }> => {
|
||||
try {
|
||||
return await loadLeadingMunicipalitiesForCandidate(ctx.payload, ctx.user, {
|
||||
candidateNumber: resolution.candidateNumber,
|
||||
year,
|
||||
topN,
|
||||
})
|
||||
} catch {
|
||||
return { error: 'Não foi possível consultar as cidades líderes agora. Tente de novo.' }
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { buildCampaignLinks } from '@/utilities/ai/tools/buildCampaignLinks'
|
|||
import { calculate } from '@/utilities/ai/tools/calculate'
|
||||
import { getDobradinhas } from '@/utilities/ai/tools/getDobradinhas'
|
||||
import { getLeaderships } from '@/utilities/ai/tools/getLeaderships'
|
||||
import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
|
||||
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
|
||||
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
|
||||
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
|
||||
|
|
@ -22,6 +23,7 @@ export const buildAITools = (ctx: AIToolContext) => ({
|
|||
buildCampaignLinks: buildCampaignLinks(ctx),
|
||||
getMunicipalityVotes: getMunicipalityVotes(ctx),
|
||||
getTopDeputies: getTopDeputies(ctx),
|
||||
getLeadingMunicipalities: getLeadingMunicipalities(ctx),
|
||||
getDobradinhas: getDobradinhas(ctx),
|
||||
getMunicipalityOverview: getMunicipalityOverview(ctx),
|
||||
getLeaderships: getLeaderships(ctx),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
ANO_ELEICAO;NR_TURNO;SG_UF;CD_MUNICIPIO;NM_MUNICIPIO;NR_ZONA;CD_CARGO;QT_APTOS;QT_COMPARECIMENTO;QT_ABSTENCOES;QT_VOTOS_VALIDOS;QT_VOTOS_NOMINAIS_VALIDOS;QT_VOTOS_LEGENDA;QT_VOTOS_BRANCOS;QT_VOTOS_NULOS;QT_VOTOS_ANULADOS
|
||||
2022;1;BA;38490;SALVADOR;1;6;10000;8000;2000;7500;7300;200;300;200;0
|
||||
2022;1;BA;38490;SALVADOR;2;6;9000;7200;1800;6800;6600;200;250;150;0
|
||||
2022;1;BA;35150;FEIRA DE SANTANA;10;6;5000;4000;1000;3800;3700;100;120;80;0
|
||||
2022;1;BA;35157;FEIRA DE SANTANA;10;6;5000;4000;1000;3800;3700;100;120;80;0
|
||||
2022;1;BA;38490;SALVADOR;1;7;10000;8000;2000;7400;7200;200;300;300;0
|
||||
2022;1;BA;38490;SALVADOR;1;3;10000;8000;2000;7600;7400;200;200;200;0
|
||||
2022;2;BA;38490;SALVADOR;1;3;10000;8200;1800;7800;7600;200;200;200;0
|
||||
|
|
|
|||
|
|
|
@ -3,8 +3,8 @@ ANO_ELEICAO;NR_TURNO;SG_UF;CD_MUNICIPIO;NM_MUNICIPIO;NR_ZONA;CD_CARGO;NR_CANDIDA
|
|||
2022;1;BA;38490;SALVADOR;1;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;800;#NULO#
|
||||
2022;1;BA;38490;SALVADOR;2;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;900;ELEITO
|
||||
2022;1;BA;38490;SALVADOR;2;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;1100;#NULO#
|
||||
2022;1;BA;35150;FEIRA DE SANTANA;10;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;500;ELEITO
|
||||
2022;1;BA;35150;FEIRA DE SANTANA;10;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;300;#NULO#
|
||||
2022;1;BA;35157;FEIRA DE SANTANA;10;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;500;ELEITO
|
||||
2022;1;BA;35157;FEIRA DE SANTANA;10;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;300;#NULO#
|
||||
2022;1;BA;38490;SALVADOR;1;7;13131;ESTADUAL PT;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;400;#NULO#
|
||||
2022;1;BA;38490;SALVADOR;1;3;13;JERÔNIMO;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;5000;2º TURNO
|
||||
2022;1;BA;38490;SALVADOR;1;3;45;RIVAL GOV;PSDB;COLIGAÇÃO OPOSIÇÃO;4200;2º TURNO
|
||||
|
|
|
|||
|
235
tests/int/leadingMunicipalities.int.spec.ts
Normal file
235
tests/int/leadingMunicipalities.int.spec.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { municipalityForTseCityCode } from '@/lib/bahiaTseCityCodes'
|
||||
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
|
||||
import type { BuiltElectionResults } from '@/lib/electionResultsBuild'
|
||||
import { cityPageSlug, type LeadingMunicipalityRow } from '@/lib/leadingMunicipalities'
|
||||
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
loadLeadingMunicipalitiesForCandidate,
|
||||
resolveFederalDeputy,
|
||||
} from '@/utilities/ai/tools/getLeadingMunicipalities'
|
||||
import { buildImportBundles, importElectionBundles } from '@/utilities/electionResultsImport'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
import {
|
||||
acquireTestDatabaseLease,
|
||||
ELECTION_COLLECTIONS_LEASE_KEY,
|
||||
type TestDatabaseLease,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
import { loadTseFixtureResults } from '../helpers/tseFixtures'
|
||||
|
||||
let payload: Payload
|
||||
let electionCollectionsLease: TestDatabaseLease
|
||||
|
||||
const staffUser = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
|
||||
|
||||
const deleteAllElectionData = async () => {
|
||||
for (const collection of [
|
||||
'electionCandidateVote',
|
||||
'electionTally',
|
||||
'electionCandidate',
|
||||
] as const) {
|
||||
const existing = await payload.find({
|
||||
collection,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const doc of existing.docs) {
|
||||
await payload.delete({ collection, id: doc.id, overrideAccess: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const importFixture2022 = async () =>
|
||||
importElectionBundles(payload, buildImportBundles(loadTseFixtureResults()))
|
||||
|
||||
const loadRows = (candidateNumber: number, topN: number): Promise<LeadingMunicipalityRow[]> =>
|
||||
loadLeadingMunicipalitiesForCandidate(payload, staffUser, {
|
||||
candidateNumber,
|
||||
year: ELECTION_YEAR_2022,
|
||||
topN,
|
||||
})
|
||||
|
||||
/**
|
||||
* In-memory re-statement of the artifact builder's rank formula
|
||||
* (`scripts/build-election-aggregates.mjs`): rank = candidates strictly ahead
|
||||
* + 1, denominator = candidates with votes > 0, no row when the target has no
|
||||
* votes in the city. The int test asserts the SQL window query produces
|
||||
* exactly this — pinning "same semantics as the map" without shipping a
|
||||
* second ranker.
|
||||
*/
|
||||
const rankFromBuiltVotes = (
|
||||
built: BuiltElectionResults,
|
||||
candidateNumber: number,
|
||||
topN: number,
|
||||
): Map<string, { rank: number; votedCandidates: number; votes: number }> => {
|
||||
const byCity = new Map<string, Map<number, number>>()
|
||||
for (const row of built.votes) {
|
||||
if (
|
||||
row.office !== 'deputado_federal' ||
|
||||
row.turn !== '1' ||
|
||||
row.voteType !== 'nominal' ||
|
||||
row.votes <= 0
|
||||
) {
|
||||
continue
|
||||
}
|
||||
let byCandidate = byCity.get(row.cityCode)
|
||||
if (!byCandidate) {
|
||||
byCandidate = new Map()
|
||||
byCity.set(row.cityCode, byCandidate)
|
||||
}
|
||||
byCandidate.set(row.candidateNumber, (byCandidate.get(row.candidateNumber) ?? 0) + row.votes)
|
||||
}
|
||||
|
||||
const expected = new Map<string, { rank: number; votedCandidates: number; votes: number }>()
|
||||
for (const [cityCode, byCandidate] of byCity) {
|
||||
const ownVotes = byCandidate.get(candidateNumber)
|
||||
if (!ownVotes || ownVotes <= 0) continue
|
||||
|
||||
let ahead = 0
|
||||
let votedCandidates = 0
|
||||
for (const votes of byCandidate.values()) {
|
||||
if (votes <= 0) continue
|
||||
votedCandidates += 1
|
||||
if (votes > ownVotes) ahead += 1
|
||||
}
|
||||
|
||||
const rank = ahead + 1
|
||||
if (rank <= topN) {
|
||||
expected.set(cityCode, { rank, votedCandidates, votes: ownVotes })
|
||||
}
|
||||
}
|
||||
return expected
|
||||
}
|
||||
|
||||
describe('getLeadingMunicipalities — live SQL path and candidate resolution (B177)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
// The import spec wipes every election row; this lease serializes the
|
||||
// fixture window against the other election-collection specs.
|
||||
electionCollectionsLease = await acquireTestDatabaseLease(
|
||||
payload,
|
||||
ELECTION_COLLECTIONS_LEASE_KEY,
|
||||
)
|
||||
await deleteAllElectionData()
|
||||
await importFixture2022()
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteAllElectionData()
|
||||
await electionCollectionsLease.release()
|
||||
}, 120_000)
|
||||
|
||||
it('folds Salvador zones into one city row with exact ranks and votes', async () => {
|
||||
const rows = await loadRows(1313, 1)
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
city: 'Salvador',
|
||||
slug: null,
|
||||
rank: 1,
|
||||
votedCandidates: 2,
|
||||
votes: 2100,
|
||||
},
|
||||
{
|
||||
city: 'Feira de Santana',
|
||||
slug: cityPageSlug(municipalityCatalogEntriesForCity('Feira de Santana')),
|
||||
rank: 1,
|
||||
votedCandidates: 2,
|
||||
votes: 500,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('top-N widens a candidate who never reached rank 1', async () => {
|
||||
expect(await loadRows(2222, 1)).toEqual([])
|
||||
|
||||
const top2 = await loadRows(2222, 2)
|
||||
expect(top2.map((row) => [row.city, row.rank, row.votes])).toEqual([
|
||||
['Salvador', 2, 1900],
|
||||
['Feira de Santana', 2, 300],
|
||||
])
|
||||
})
|
||||
|
||||
it('matches the in-memory artifact-builder formula on the same fixture rows', async () => {
|
||||
const expected = rankFromBuiltVotes(loadTseFixtureResults(), 1313, 20)
|
||||
|
||||
const cityCodeByCity = new Map<string, string>()
|
||||
for (const cityCode of expected.keys()) {
|
||||
const city = municipalityForTseCityCode(cityCode)
|
||||
if (city) cityCodeByCity.set(city, cityCode)
|
||||
}
|
||||
// Non-vacuous: every expected city must resolve to a canonical name, so a
|
||||
// wrong fixture code can no longer hide the city from both sides.
|
||||
expect(cityCodeByCity.size).toBe(expected.size)
|
||||
|
||||
const rows = await loadRows(1313, 20)
|
||||
|
||||
expect(new Set(rows.map((row) => row.city))).toEqual(new Set(cityCodeByCity.keys()))
|
||||
for (const row of rows) {
|
||||
const cityCode = cityCodeByCity.get(row.city)
|
||||
expect(cityCode, row.city).toBeDefined()
|
||||
const expectedRow = expected.get(cityCode!)!
|
||||
expect(row.rank).toBe(expectedRow.rank)
|
||||
expect(row.votedCandidates).toBe(expectedRow.votedCandidates)
|
||||
expect(row.votes).toBe(expectedRow.votes)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the campaign candidate by ballot number and by name', async () => {
|
||||
await expect(resolveFederalDeputy(payload, ELECTION_YEAR_2022, '1313')).resolves.toEqual({
|
||||
candidateNumber: 1313,
|
||||
name: 'JORGE SOLLA',
|
||||
party: 'PT',
|
||||
})
|
||||
await expect(resolveFederalDeputy(payload, ELECTION_YEAR_2022, 'Jorge Solla')).resolves.toEqual(
|
||||
{ candidateNumber: 1313, name: 'JORGE SOLLA', party: 'PT' },
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a rival by ballot number and by urn name', async () => {
|
||||
await expect(resolveFederalDeputy(payload, ELECTION_YEAR_2022, '2222')).resolves.toEqual({
|
||||
candidateNumber: 2222,
|
||||
name: 'RIVAL FEDERAL',
|
||||
party: 'PL',
|
||||
})
|
||||
await expect(
|
||||
resolveFederalDeputy(payload, ELECTION_YEAR_2022, 'RIVAL FEDERAL'),
|
||||
).resolves.toEqual({ candidateNumber: 2222, name: 'RIVAL FEDERAL', party: 'PL' })
|
||||
})
|
||||
|
||||
it('falls back to the vote rows when the candidate registry misses', async () => {
|
||||
// Drop 2222 from the registry only — the vote rows stay — so resolution
|
||||
// takes the fallback path instead of the registry.
|
||||
await payload.delete({
|
||||
collection: 'electionCandidate',
|
||||
where: {
|
||||
and: [{ year: { equals: ELECTION_YEAR_2022 } }, { candidateNumber: { equals: 2222 } }],
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
await expect(resolveFederalDeputy(payload, ELECTION_YEAR_2022, '2222')).resolves.toEqual({
|
||||
candidateNumber: 2222,
|
||||
name: 'RIVAL FEDERAL',
|
||||
party: 'PL',
|
||||
})
|
||||
|
||||
// Restore the fixture so later file-scoped reads see a consistent registry.
|
||||
await importFixture2022()
|
||||
})
|
||||
|
||||
it('reports a not-found candidate without inventing a number', async () => {
|
||||
const resolution = await resolveFederalDeputy(payload, ELECTION_YEAR_2022, 'NINGUEM')
|
||||
expect('error' in resolution && !('options' in resolution)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -49,9 +49,12 @@ describe('server-only boundary in src/utilities', () => {
|
|||
// P3-K widened the module set: value-imports of next/cache, next/server
|
||||
// and next/headers are equally server-bound (municipalityRevalidation and
|
||||
// campaignJsonMutationRoute were invisible to the Payload-only sweep).
|
||||
// B177 widened it again: @payloadcms/db-postgres is the raw-SQL adapter
|
||||
// used by request-path aggregates (getLeadingMunicipalities) and must be
|
||||
// server-bound too.
|
||||
const importsServerValues = [
|
||||
...source.matchAll(
|
||||
/^import\s+([^'"]+?)\s+from\s+['"](payload|@payload-config|next\/cache|next\/server|next\/headers)['"]/gm,
|
||||
/^import\s+([^'"]+?)\s+from\s+['"](payload|@payload-config|next\/cache|next\/server|next\/headers|@payloadcms\/db-postgres(?:\/drizzle)?)['"]/gm,
|
||||
),
|
||||
].some(([, clause]) => !isTypeOnlyClause(clause!))
|
||||
|
||||
|
|
|
|||
116
tests/unit/leadingMunicipalities.unit.spec.ts
Normal file
116
tests/unit/leadingMunicipalities.unit.spec.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { CampaignUser } from '@/payload-types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults'
|
||||
import {
|
||||
campaignCandidateLeadingMunicipalities,
|
||||
sortLeadingMunicipalityRows,
|
||||
type LeadingMunicipalityRow,
|
||||
} from '@/lib/leadingMunicipalities'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
describe('campaignCandidateLeadingMunicipalities (B177 artifact path)', () => {
|
||||
it('renders the Solla 2022 rank-1 set with one line per city', () => {
|
||||
const result = campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 1)
|
||||
|
||||
expect(result.candidate.candidateNumber).toBe(BASELINE_TICKET_2022.candidate.candidateNumber)
|
||||
expect(result.year).toBe(ELECTION_YEAR_2022)
|
||||
expect(result.topN).toBe(1)
|
||||
expect(result.total).toBe(result.municipalities.length)
|
||||
expect(result.total).toBeGreaterThan(0)
|
||||
|
||||
const cities = result.municipalities.map((row) => row.city)
|
||||
expect(new Set(cities).size).toBe(cities.length)
|
||||
|
||||
for (const row of result.municipalities) {
|
||||
expect(row.rank).toBe(1)
|
||||
expect(row.votes).toBeGreaterThan(0)
|
||||
expect(row.votedCandidates).toBeGreaterThanOrEqual(1)
|
||||
if (row.city === 'Salvador') {
|
||||
expect(row.slug).toBeNull()
|
||||
} else {
|
||||
expect(row.slug).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('orders placements asc, then votes desc', () => {
|
||||
const result = campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 3)
|
||||
const ranks = result.municipalities.map((row) => row.rank)
|
||||
expect([...ranks].sort((a, b) => a - b)).toEqual(ranks)
|
||||
for (let index = 1; index < ranks.length; index += 1) {
|
||||
if (ranks[index - 1] === ranks[index]) {
|
||||
expect(result.municipalities[index - 1]!.votes).toBeGreaterThanOrEqual(
|
||||
result.municipalities[index]!.votes,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('a deeper top-N is a superset of a shallower one (same source)', () => {
|
||||
const top1 = new Set(
|
||||
campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 1).municipalities.map(
|
||||
(row) => row.city,
|
||||
),
|
||||
)
|
||||
const top3Cities = new Set(
|
||||
campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 3).municipalities.map(
|
||||
(row) => row.city,
|
||||
),
|
||||
)
|
||||
for (const city of top1) expect(top3Cities.has(city)).toBe(true)
|
||||
})
|
||||
|
||||
it('Salvador folds into one row and never links a zone page', () => {
|
||||
const result = campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 20)
|
||||
const salvador = result.municipalities.filter((row) => row.city === 'Salvador')
|
||||
expect(salvador.length).toBeLessThanOrEqual(1)
|
||||
if (salvador[0]) {
|
||||
expect(salvador[0]?.slug).toBeNull()
|
||||
}
|
||||
const pageSlugs = result.municipalities
|
||||
.filter((row) => row.slug !== null)
|
||||
.map((row) => row.slug)
|
||||
expect(new Set(pageSlugs).size).toBe(pageSlugs.length)
|
||||
})
|
||||
|
||||
it('links the catalog slug that resolves back to the same canonical city', () => {
|
||||
const result = campaignCandidateLeadingMunicipalities(ELECTION_YEAR_2022, 1)
|
||||
const linked = result.municipalities.find((row) => row.slug !== null)
|
||||
expect(linked).toBeDefined()
|
||||
expect(getMunicipalityCatalogEntry(linked!.slug!)?.city).toBe(linked?.city)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortLeadingMunicipalityRows', () => {
|
||||
it('orders by rank asc and votes desc within a tie', () => {
|
||||
const rows: LeadingMunicipalityRow[] = [
|
||||
{ city: 'A', slug: 'a', rank: 1, votedCandidates: 2, votes: 100 },
|
||||
{ city: 'B', slug: 'b', rank: 2, votedCandidates: 2, votes: 900 },
|
||||
{ city: 'C', slug: 'c', rank: 1, votedCandidates: 2, votes: 300 },
|
||||
{ city: 'D', slug: 'd', rank: 2, votedCandidates: 2, votes: 500 },
|
||||
]
|
||||
expect(sortLeadingMunicipalityRows(rows).map((row) => row.city)).toEqual(['C', 'A', 'B', 'D'])
|
||||
})
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const rows: LeadingMunicipalityRow[] = [
|
||||
{ city: 'X', slug: null, rank: 2, votedCandidates: 2, votes: 10 },
|
||||
{ city: 'Y', slug: 'y', rank: 1, votedCandidates: 2, votes: 10 },
|
||||
]
|
||||
sortLeadingMunicipalityRows(rows)
|
||||
expect(rows[0]?.city).toBe('X')
|
||||
})
|
||||
})
|
||||
|
||||
describe('leader lockdown (B177)', () => {
|
||||
it('assertCanReadElectionData rejects a leader and accepts staff', () => {
|
||||
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
|
||||
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
|
||||
expect(() => assertCanReadElectionData(leader)).toThrow('Leitura de dados eleitorais negada')
|
||||
expect(() => assertCanReadElectionData(coordinator)).not.toThrow()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue