feat(B186): Sollinha — prioridades do momento (getMunicipalityPriorities)

Tool nova do chat que responde "quais devem ser minhas prioridades neste
momento?" com top N municípios do escopo, cada um com uma linha de
evidência e o critério declarado. Ranking derivado na hora em módulo puro
(municipalityPriorities.ts): 3 baldes por gravidade (sinal desfavorável
recente > estagnação > potencial alto com nível baixo), exclusão de
atualização recente favorável, frescor E9 (max lastUpdateAt/lastPledgeAt),
potencial = expectedVotes.central com fallback nos válidos 2022 (artefato).

Resolver de escopo da B185 extraído para aiToolScope.ts (2º consumidor;
getPendingLeaderships refatorado sem mudança de comportamento). Gate staff
fail-closed, leituras overrideAccess: false + user. Seção nova no system
prompt. 38 unit tests novos; 1772 unit + 680 int verdes; build OK.
This commit is contained in:
Francisco Solla 2026-08-10 07:57:24 -03:00
parent 9b1da7a09f
commit 11886a653d
10 changed files with 1425 additions and 0 deletions

View file

@ -9,6 +9,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-10):** **OPS21** (Issue #538) — `REVALIDATE_SECRET` adicionada na **Vercel production** (projeto `solla/jorgesolla`): diagnóstico D6 confirmado (env ausente em `vercel env ls production`; `POST /api/revalidate` respondia `500` "not configured" em prod, quebrando o runbook pós-seed/mudança direta de DB). Segredo gerado com `openssl rand -hex 32` (valor via stdin, nunca argv), `vercel env add REVALIDATE_SECRET production`; **fallback necessário:** o deploy existente congelava as envs em runtime → `vercel redeploy` do último deploy de produção (2×, pois o primeiro `env add` via pipe herdou o `\n` do `openssl` e gravou valor de 66 chars — removido e re-adicionado limpo). Validação real em prod: sem secret → `401`; secret errado → `401`; secret certo → `200 { revalidated: true, tag: 'posts' }`; tag desconhecida → `400`. Preview não adicionada (projeto sem Git conectado — `vercel env add ... preview` exige branch, precedente D6). Sem código/migration/access/UI. Impl [`revalidate-secret-vercel-production-impl.md`](plans/revalidate-secret-vercel-production-impl.md).
**Recently resolved (2026-08-10):** **C108** (Issue #522) — sino de notificações: **abrir já lê** — `openCampaignNotifications` (action única) lista + marca tudo como lido em 1 round trip, substituindo `listCampaignNotifications` + `markAllCampaignNotificationsRead`; o badge zera na abertura sem clique e o botão "Marcar todas como lidas" deixa de existir. Painel limpo em qualquer tamanho: sem título "Notificações" visível (sr-only para a11y), sem contador, sem botão "Fechar"; desktop vira **modal central** (Dialog shadcn, X no canto superior direito, clique fora fecha — par responsivo Dialog/Drawer do C94 via `useIsMobile`); mobile segue bottom sheet com handle, safe-area e **skeleton de loading** (o painel não abre mais em branco durante o round trip). `markAllNotificationsRead` extraída para `src/utilities/notification/notificationList.ts` (owner-scoped com bypass documentado, idempotente, cap 200 mantido; int testado: marca só as próprias, deixa as do outro intactas); `readAt` saiu do contrato `NotificationListItem` (sem leitores após a remoção do destaque de não lida); copy de erro unificada em `CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE`; `router.refresh()` re-sincroniza a instância irmã do outro header só quando algo foi marcado (race cross-tab documentado); foco visível nos itens e `role="alert"` no erro. P4-D atualizado no ledger — o batch sem transação agora dispara a cada abertura (self-healing). Testes: int (owner-scope + idempotência) e unit novos do bell (dialog limpo, refresh condicional, empty/error, sheet mobile). Sem migration, access, Consent ou schema. Intenção [`sino-notificacoes-leitura-auto-modal-desktop.md`](plans/sino-notificacoes-leitura-auto-modal-desktop.md); impl [`sino-notificacoes-leitura-auto-modal-desktop-impl.md`](plans/sino-notificacoes-leitura-auto-modal-desktop-impl.md).
**Recently resolved (2026-08-10):** **B186** (Issue #525) — o Sollinha ganhou a tool **`getMunicipalityPriorities`** (`src/utilities/ai/tools/getMunicipalityPriorities.ts`): responde "quais devem ser minhas prioridades neste momento?" com **top N municípios do escopo** (coordenador = estado; assessor = portfólio; recorte por território/cidade/município), cada item com **uma linha de evidência** e o **critério de ordenação declarado na resposta** (criterio + janelaDias). **Ranking derivado na hora, 3 baldes mutuamente exclusivos** por gravidade — (1) `sinal_desfavoravel`: última atualização na janela com `ruim`/urgente/alerta de adversário (trecho do body na evidência; "última palavra decide"); (2) `estagnacao`: sem sinal há 30+ dias (default, ajustável 7–90) ou nunca; (3) `potencial`: nível N0/N1/sem nível + **top 5 por potencial do escopo** (`expectedVotes.central` 2026 com fallback nos válidos 2022 do artefato commitado, fonte rotulada na evidência). **Exclusão literal do aceite:** atualização recente `boa` e sem sinais negativos tira o município de todos os baldes; `municipality.priority: 'alta'` é fator (menção "prioritário" + tiebreak), nunca o ranking inteiro; tendência política desfavorável entra como cláusula secundária. **Frescor = E9** (`max(lastUpdateAt, lastPledgeAt)`, `municipalitySignal.ts` — divergência da hipótese B185 de só-`municipalityUpdate`, aprovada no gate: a intenção lista recência de `votePledge` como fonte). Refinamentos: `reason` filtra por balde ("só as sem atualização"), `sortBy: 'potencial'` reordena, `days`/`limit` ajustam. **Arquitetura:** ranking em módulo **puro e client-safe** (`src/utilities/municipality/municipalityPriorities.ts`, 28 unit tests sem DB) + resolver de escopo da B185 **extraído** para `src/utilities/ai/tools/aiToolScope.ts` (2º consumidor; `getPendingLeaderships` refatorado — 18 testes verdes, sem mudança de comportamento); gate staff inline (leader → `{ error: 'Leitura de prioridades de municípios negada.' }` antes de qualquer query), leituras `overrideAccess: false, user: ctx.user`, escopo vazio (assessor fora do portfólio) short-circuita antes da leitura. Seção nova no system prompt; allowlist P3-D estendida (mesma forma distinta da B185). Tool tests novos (`prioritiesTool.unit.spec.ts`, 10) — gate fail-closed, where da janela, escopo região, fallback 2022 (slugs reais do artefato), truncamento. Sem migration/access change/Consent/UI. Intenção [`sollinha-prioridades-do-momento.md`](plans/sollinha-prioridades-do-momento.md); impl [`sollinha-prioridades-do-momento-impl.md`](plans/sollinha-prioridades-do-momento-impl.md).
**Recently resolved (2026-08-10):** **B184** (Issue #514) — no mobile (<768px), o filtro de listas `/campanha` virou o **padrão sem moldura do chassis `CampaignListOmnibox` (B127) — todas as listas**: label oculta (`input` carrega o nome via `aria-label`), campo sem borda/sombra (`md:` restaura o desktop), **form do filtro sticky sob a top bar** com linha separadora edge-to-edge, "Limpar" vira **X circular dentro do input** (aparece com chips OU busca digitada; slot sempre renderizado com `invisible` para a largura não pular; hit target 44px + círculo visual 28px; live region própria "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT), botão texto "Limpar" desktop-only. **Por que sticky no `<form>`:** sticky não escapa do containing block (o pai) — a coluna do input dentro do wrapper curto do omnibox "grudava" ~12px e rolava junto (medido); o form é o único wrapper filho do `CampaignPageShell` (altura total). **Exceção agenda (C101, merge anterior):** a `/campanha/agenda` já tinha o strip sticky próprio (`activity-agenda-filter-strip`) — mantido; o chassis ainda a cobre no campo/X/Limpar. Em municípios, adicionalmente: **"Salvar filtro" vira icon button no header mobile** (slot C94/C95, `SaveMunicipalityFilterControl presentation='icon'`; popover de nomear intacto; nome de rename é o contrato 2.5.3) e **cards edge-to-edge sem borda** com uma linha separando (`border-b last:border-b-0`, `-mx-4`, gate `md:`). "Nova atualização" (atualizações) — único trailing visível no mobile — foi para o header (mesmo padrão); `campaignMobileHeaderIconClassName` extraída (3º consumidor). E2E novo em `campaignMunicipalities.e2e.spec.ts` (390×844): label oculta, borda 0, sticky, X (limpa chips+busca, foco volta sem reabrir popover), salvar→renomear no header, cards com separador, desktop invertido (1280). Débito registrado no caminho: **D7** (sticky dos headers da tabela desktop morto — wrapper `overflow-x-auto` é o scroll container; medido). Sem migration, access, Consent. Intenção [`municipios-mobile-sem-moldura.md`](plans/municipios-mobile-sem-moldura.md); impl [`municipios-mobile-sem-moldura-impl.md`](plans/municipios-mobile-sem-moldura-impl.md).

View file

@ -0,0 +1,94 @@
# Impl: Sollinha: prioridades do momento (gestão)
Status: aprovado
Atualizado em: 2026-08-10
Issue: #525
Intenção: docs/plans/sollinha-prioridades-do-momento.md
Appetite restante: ~1 dia eng (herdado); uma tool nova read-only + módulo puro + testes; sem migration/collection/Consent/UI
## Leitura da intenção
- **Outcome:** uma tool nova do Sollinha responde "quais devem ser minhas prioridades neste momento?" com **top N municípios do escopo do usuário**, cada item com **uma linha de evidência** (o fator que o colocou ali), o **critério de ordenação declarado na resposta**, filtro/reordenação por fator, exclusão de município com atualização recente favorável e sem sinais negativos, assessor = portfólio, leader = deny fail-closed, links via `buildCampaignLinks` (precedente B185/B162).
- **O que NÃO negociar:** leader lockdown (deny antes de qualquer query); assessor vê só o portfólio (RBAC atual via access control); sem estimativas vazando para liderança; ranking **derivado na hora** (nada persistido); sem score numérico na resposta (motivo explícito por item); leitura relativa/local (nunca % estadual absoluto); `municipality.priority` é **fator**, não o ranking inteiro.
- **O que reavaliar:** a hipótese da intenção lista `politicalTrend` como fonte — virou menção secundária na evidência, não balde (nenhum exemplo de evidência da intenção usa tendência; baldes são event-driven). A semântica B185 de "atualização recente" (última `municipalityUpdate` apenas) conflita com o frescor E9 do repo (`max(lastUpdateAt, lastPledgeAt)`); B186 usa **E9** (ver Decisões). Potencial = `expectedVotes.central` com fallback nos válidos 2022 do artefato (recomendação (A) da intenção), com corte relativo "top 5 do escopo" — sem limiar absoluto inventado.
## Abordagem recomendada
```mermaid
flowchart LR
P[ai-chat route] --> B[buildAITools]
B --> T[getMunicipalityPriorities ctx]
T --> G[gate staff inline<br/>leader → { error }]
G --> S[aiToolScope<br/>escopo região/cidade/município/todos]
S --> Q1[find municipality<br/>select estratégico + lastUpdateAt]
S --> Q2[find municipalityUpdate<br/>janela: municipality in + createdAt >]
S --> Q3[aggregatePledgesByMunicipality<br/>→ lastPledgeAt por id]
Q1 --> R[rankMunicipalityPriorities<br/>módulo puro]
Q2 --> R
Q3 --> R
R --> E[buckets: sinal_desfavoravel ><br/>estagnacao > potencial]
E --> OUT[{ escopo, criterio, janelaDias,<br/>total, prioridades, truncado }]
OUT --> L[buildCampaignLinks<br/>município por slug]
```
**Opções consideradas:** A | B | C
- **A — Tool única `getMunicipalityPriorities` + módulo puro de ranking** (`src/utilities/municipality/municipalityPriorities.ts`), com escopo reusando o resolver da B185 extraído para módulo compartilhado.
- **B — Ranking inline na tool** (tudo dentro de `getMunicipalityPriorities.ts`), sem módulo puro.
- **C — Duas tools** (uma de urgência/sinal, uma de potencial).
**Recomendação: A** — o ranking (baldes, ordenação, evidências, exclusão) é o coração do valor e precisa de testes de unidade sem stub de payload; o repo tem padrão forte de lógica pura em módulo (precedentes `engagementLevel.ts`, `mapScaleClasses.ts`, `municipalitySignal.ts`). O resolver de escopo da B185 é comportamento sutil de ~70 linhas (Salvador-primeiro, aliases, acento) que agora terá **2 consumidores** (B189 será o 3º) — extrair para `aiToolScope.ts` é "edit the owner, don't twin"; os testes da B185 permanecem verdes (mesmo shape). **Rejeitadas:** B (testar o ranking via stub de payload embaralha o que é regra de domínio com o que é query; o módulo puro é a fronteira de teste limpa — padrão da casa), C (um só aceite "o que atacar primeiro"; o modelo não deve decidir qual tool chamar para a mesma pergunta; o `motivo` filtra dentro da tool).
### Componentes / mudanças
- **`rankMunicipalityPriorities`** (`src/utilities/municipality/municipalityPriorities.ts`, novo): módulo **puro e client-safe** (importa `municipalitySignal.ts`, `municipalityLabels.ts`, `lib/engagementLevel`, `lib/schemas/municipalityUpdate`) com os baldes, a ordenação por gravidade, a exclusão do "favorável recente" e a geração de evidência de uma linha. Entrada: linhas de município (id/nome/slug/regiao/cidade/priority/engagementLevel/expectedVotes.central/lastUpdateAt/politicalTrend.status) + `lastPledgeAt` por id + atualizações da janela + `janelaDias` + filtros; saída: `prioridades[]` ordenadas com `motivo`/`evidencia`/`nivelEngajamento`/`ultimoSinalAtrasDias`/`potencialEstimado`/`fontePotencial`/`trechoAtualizacao`.
- **`aiToolScope`** (`src/utilities/ai/tools/aiToolScope.ts`, novo): extração do `resolveScope`/`resolveMunicipalities` da B185 (Salvador-primeiro, `resolveMunicipalityName`, território por `normalizeSearchPhrase`, escopo do ator quando omitido) — **refatorar `getPendingLeaderships`** para consumir (mesmo shape, testes verdes).
- **`getMunicipalityPriorities`** (`src/utilities/ai/tools/getMunicipalityPriorities.ts`, novo): factory `tool()`; gate staff inline no topo (1 call site, padrão B185: `isStaffCampaignRole` → `{ error: 'Leitura de prioridades de municípios negada.' }` antes de qualquer query); leituras: municípios do escopo (`overrideAccess: false, user: ctx.user`, select estratégico + `lastUpdateAt`), atualizações da janela (`municipalityUpdate` com `municipality in ids` + `createdAt > cutoff`, select `municipality/polarity/urgent/adversarySignal/createdAt/body`, sort `-createdAt`), pledges via `aggregatePledgesByMunicipality` (helper existente — `lastPledgeAt` por id); fallback de potencial via `getMunicipalityFederalBaseline(slug).validVotesByYear['2022']` (artefato commitado, puro).
- **`index.ts`** (`src/utilities/ai/tools/index.ts`): registrar `getMunicipalityPriorities: getMunicipalityPriorities(ctx)`.
- **`systemPrompt.ts`** (`src/utilities/ai/systemPrompt.ts`): seção "Prioridades do momento" — quando usar, obrigatório declarar o critério devolvido, uma linha de evidência por item, links via `buildCampaignLinks` (município por slug).
- **Migration:** sem migration.
- **Access / Consent:** nenhuma chave nova; gate fail-closed via `isStaffCampaignRole`; leituras delegadas ao access control das collections (assessor = portfólio; leader nem chega a query; `expectedVotes` só chega a staff por `canReadCampaignStaffField`).
- **UI:** Impeccable A — N/A (resposta em texto no chat existente; sem superfície nova).
### Dados → forma
- Forma: **lista ranqueada em texto no chat** com total e truncamento top N (default 10, max 20) — a intenção fixa "lista ranqueada com motivo, não score nu"; o critério é declarado (`criterio` + `janelaDias` no shape). Sem número de score; evidência em pt-BR legível pelo modelo para parafrasear. Rejeitadas: score numérico (anti-goal explícito), grupos por motivo na resposta da tool (o modelo agrupa quando útil; `motivo` filtra por chamada), dashboard/mapa (fora de escopo).
## Decisões de engenharia
1. **Frescor/estagnação = E9, não a semântica B185.** "Estagnação" mede dias desde `max(lastUpdateAt, lastPledgeAt)` (`resolveMunicipalityLastSignalAt` — o "frescor" canônico do repo, usado na fila de alocação E9). **Divergência da hipótese B185** (que define "atualização recente" como última `municipalityUpdate` apenas): a intenção lista `votePledge` (recência de compromissos) como fonte, e um pledge recente É atividade de campanha — a evidência "sem atualização há 60 dias" seria factualmente enganosa. A evidência usa a linguagem E9 ("sem sinal há N dias" / "nunca recebeu sinal" — `formatSilenceAgeLabel`). Janela default **30 dias** (semântica da família B185, `dias` ajustável 7–90). Rejeitada: só `lastUpdateAt` (mente sobre município com pledge recente).
2. **Três baldes mutuamente exclusivos, "última palavra decide".** (1) `sinal_desfavoravel`: a **última** atualização dentro da janela tem `polarity: 'ruim'` **ou** `urgent` **ou** `adversarySignal` (flags dominam a polaridade) → evidência cita a polaridade/label + recência + trecho do body (~200 chars); (2) `estagnacao`: nenhum sinal na janela (idade ≥ janela ou nunca) → evidência `formatSilenceAgeLabel`; (3) `potencial`: `engagementLevel ∈ {null, n0, n1}` e **top 5** por potencial dentro do escopo (não repetidos nos baldes 1/2) → evidência "Potencial alto (~X) e nível N1" com a fonte rotulada (`estimativa central 2026` ou `válidos 2022`). "Última palavra": uma atualização boa/neutra recente depois de uma ruim é leitura de melhora — a mais recente manda. **Exclusão do aceite literal:** última atualização na janela `boa` e sem flags → município **fora de todos os baldes** (não aparece). Rejeitadas: somar sinais (score mágico — anti-goal), janela deslizante de N atualizações (complexidade sem evidência de necessidade), `negativo`-style de 4º balde (tendência desfavorável entra como cláusula secundária na evidência, não como balde).
3. **Ordenação por gravidade declarada.** `sinal_desfavoravel` (mais recente primeiro) → `estagnacao` (mais frio primeiro) → `potencial` (maior potencial primeiro); tiebreak `priority: 'alta'` (fator, não ranking inteiro — corte do rabbit hole) e depois nome. `ordenarPor: 'potencial'` reordena todos os baldes por potencial desc (aceite "pode reordenar"); `motivo` filtra para um balde ("só as sem atualização"). `municipality.priority` entra como menção "prioritário" na evidência + tiebreak.
4. **Potencial = `expectedVotes.central` com fallback nos válidos 2022.** Recomendação (A) da intenção: `expectedVotes.central` (2026, staff-only) é o proxy de potencial; quando ausente, fallback `getMunicipalityFederalBaseline(slug).validVotesByYear['2022']` (artefato commitado — sem query nova); a **fonte é rotulada na evidência**. Corte relativo "top 5 do escopo" entre os de baixo engajamento — sem limiar absoluto inventado, leitura local ao escopo (kernel do research). Rejeitada: só 2022 (ignora a estimativa do staff), limiar absoluto fixo (número mágico), usar `federalRankByIbgeCode` (ranking de competitividade ≠ tamanho de potencial).
5. **Módulo puro separado da tool.** `rankMunicipalityPriorities` em `src/utilities/municipality/municipalityPriorities.ts` (client-safe, sem Payload): recebe linhas e devolve o ranking — testável sem stub. Rejeitada: inline na tool (testes via stub de payload testam query, não regra).
6. **Gate e RBAC = padrão B185.** `if (!isStaffCampaignRole(ctx.user.role)) return { error: 'Leitura de prioridades de municípios negada.' }` inline (1 call site — regra 2=inline/3+=extrair); leituras todas `overrideAccess: false, user: ctx.user` (assessor auto-scoped; `expectedVotes` filtrado por `canReadCampaignStaffField`). `aggregatePledgesByMunicipality` já é o bypass documentado sobre set de ids pré-filtrado pelo ator.
7. **Escopo = extração compartilhada com a B185.** `aiToolScope.ts` exporta `resolveAIToolScope(ctx, scope)` (Salvador → cidade 19 ZE antes do município; `resolveMunicipalityName`; território por `normalizeSearchPhrase`; erro "Escopo não reconhecido"; escopo omitido = do ator). Rejeitada: copiar o resolver na B186 (twin de comportamento sutil; B189 seria o 3º — drift garantido).
## Fases verificáveis
1. **Tracer / server** — `municipalityPriorities.ts` (puro) → `aiToolScope.ts` + refactor da B185 → `getMunicipalityPriorities.ts` + registro no `index.ts` + seção no `systemPrompt.ts` + testes:
- unit do ranking (`tests/unit/municipalityPriorities.unit.spec.ts`): baldes (ruim/urgente/adversary → sinal; idade ≥ janela ou nunca → estagnação; n0/n1/null + top potencial → potencial), exclusão do boa-recente, "última palavra decide", ordenação inter/intra baldes, tiebreak priority/nome, `motivo` filtro, `ordenarPor: 'potencial'`, janela custom, fallback 2022 rotulado, evidências (trecho, silêncio, potencial), `nivelEngajamento` label;
- unit da tool (`tests/unit/prioritiesTool.unit.spec.ts`): leader → deny com payload stub **intocado**; coordinator/advisor passam; where das atualizações (janela + `municipality in` + `overrideAccess: false`); shape (escopo/criterio/janelaDias/total/truncado); refatoração da B185 mantém `pendingLeadershipsTool.unit.spec.ts` verde.
2. **Gates** — `pnpm gate:fast` na iteração; `pnpm push` na entrega.
3. **Docs** — entrada curta em `docs/CHANGELOG-AGENTS.md` + este impl plan commitado.
## Rabbit holes / Não escopo (engenharia)
- Não persistir ranking/score (derivado na hora — fora de escopo da intenção); não criar gate helper compartilhado (1 call site); não mexer em `buildCampaignLinks`/`campaignNavigationUrls` (destino `municipality` por slug já existe).
- Não usar `politicalTrend` como balde (só cláusula secundária na evidência quando `desfavoravel`); não incluir `federalRankByIbgeCode`; não paginar por offset (top N + escopo é o contrato).
- Não mudar o shape `{ error }` do chat nem o route `api/ai-chat`; não "consertar" nada em `getPendingLeaderships` além da troca do resolver (sem mudança de comportamento).
- Não criar limite de `potencial` configurável (fixo 5; revisitável se o produto pedir).
## Riscos e mitigação
- **Refactor da B185 (extração do escopo):** comportamento idêntico, shape idêntico — os 18 testes unitários da B185 são a rede; rodar `pnpm test` completo antes do push.
- **Stub multi-call nos testes da tool:** mesmo padrão da B185 (`vi.fn().mockResolvedValueOnce` + asserts por chamada).
- **Escopo de assessor fora do portfólio:** devolve lista vazia escopada (RBAC atual, fail-closed) com `escopoRestrito: true` — mesmo shape da B185; teste cobre.
- **Janela 30 vs 21 dias (E9):** janela de prioridade é semântica da família B185 (30, ajustável); o frio da fila E9 (21) é outro corte — não conflitam porque a janela é parâmetro declarado na resposta.
- **Divergências de produto vs texto da intenção** (frescor E9, exclusão literal do boa-recente, "última palavra decide", top 5 potencial): apresentadas neste gate antes de executar.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (top N com evidência por item; critério declarado; filtro/reordenação; boa-recente excluído; assessor = portfólio; leader deny fail-closed; links)
- [ ] Invariantes AGENTS/engineering-standards (overrideAccess: false + user; copy pt-BR; identificadores em inglês; sem migration/Consent; top N com total; módulo puro testável)
- [ ] Testes previstos: unit do ranking (baldes/ordem/exclusão/evidências) + unit da tool (gate, where, shape)

View file

@ -52,6 +52,13 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
- Quando o escopo incluir Salvador, resuma a capital no início da resposta (ex.: "Salvador: 7 das 19 zonas sem atualização há mais de 30 dias") usando a contagem e o campo cidade; detalhe zona a zona quando o usuário pedir.
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário.
- Se o usuário pedir "agrupa por assessor", agrupe a lista pelos nomes de assessores presentes nos itens.
## Prioridades do momento
- Use a ferramenta "getMunicipalityPriorities" quando o usuário perguntar "quais devem ser minhas prioridades neste momento?", "o que devo atacar primeiro?" ou "quais municípios estão pegando fogo?" — a ferramenta devolve o ranking do escopo (estado para a coordenação/candidatura; portfólio para o assessor; ou o recorte pedido), cada item com UMA linha de evidência.
- A resposta da ferramenta traz o critério de ordenação ("criterio") e a janela usada ("janelaDias") — declare-os sempre na sua resposta (ex.: "prioridades por gravidade: sinal desfavorável recente > estagnação > potencial alto com nível baixo; janela de 30 dias").
- Cada município citado vem com "motivo" e "evidencia": cite a evidência em uma linha (ex.: "sem sinal há 40 dias", "atualização ruim há 2 dias: «…»", "potencial alto e nível N1") — nunca liste o município sem o porquê.
- Refinamentos: o usuário pode pedir um recorte (escopo), "só as sem atualização" (motivo=estagnacao), "só as com sinal negativo" (motivo=sinal_desfavoravel) ou "ordena por potencial" (sortBy=potencial) — passe os parâmetros e use a mesma leitura.
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário; quando trouxer "truncado: true", sugira estreitar o escopo para ver o restante.
- Ofereça links de navegação (buildCampaignLinks) para os municípios citados (por slug).
## Contexto eleitoral

View file

@ -33,6 +33,10 @@ export type AIToolScope = {
municipalities: AIToolScopeMunicipality[]
}
/** Truncation hint shared by every scope-based tool ("estreite o escopo"). */
export const AI_TOOL_NARROW_SCOPE_HINT =
'Estreite o escopo (território, cidade ou município) para ver o restante.'
/**
* Resolve the requested scope into its municipality set through the actor's
* own access (an advisor asking outside the portfolio gets the empty scoped

View file

@ -0,0 +1,250 @@
import { tool } from 'ai'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { DAY_MS } from '@/lib/text'
import { resolveAIToolScope } from '@/utilities/ai/tools/aiToolScope'
import { type PoliticalTrendStatus } from '@/utilities/municipality/municipalityLabels'
import {
PRIORITY_STALE_SIGNAL_DAYS,
rankMunicipalityPriorities,
type MunicipalityPriorityReason,
type PriorityMunicipalityInput,
type PriorityUpdateSignalInput,
} from '@/utilities/municipality/municipalityPriorities'
import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData'
const DENIED_MESSAGE = 'Leitura de prioridades de municípios negada.'
const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.'
const buildCriterion = (janelaDias: number): string =>
`Prioridades por gravidade: (1) sinal desfavorável recente (última atualização ruim, urgente ou com alerta de adversário na janela de ${janelaDias} dias); ` +
`(2) estagnação (sem sinal há ${janelaDias} dias ou mais, ou nunca); ` +
'(3) potencial alto (top 5 por votos estimados 2026, com fallback nos válidos de 2022) entre municípios de nível N0/N1/sem nível. ' +
'Municípios com atualização recente favorável ficam de fora.'
type MunicipalityRow = {
id: number
name: string
slug: string
city: string | null
region: string | null
priority?: 'alta' | 'normal' | null
engagementLevel?: 'n0' | 'n1' | 'n2' | 'n3' | 'n4' | null
expectedVotes?: { central?: number | null } | null
lastUpdateAt?: string | null
politicalTrend?: { status?: PoliticalTrendStatus | null } | null
}
type UpdateRow = {
municipality: number | string | null
createdAt: string | null
polarity?: 'boa' | 'neutra' | 'ruim' | null
urgent?: boolean | null
adversarySignal?: boolean | null
body?: string | null
}
/**
* B186 — "quais devem ser minhas prioridades neste momento?" — ranked
* municipalities of the actor's scope (or a region/city/municipality cut),
* each with a one-line evidence. The ranking is pure
* (`municipalityPriorities.ts`, E9 freshness semantics); here live only the
* access-scoped reads. Staff-only (leader lockdown fail-closed, same shape as
* B180/B185); no migration/Consent; `expectedVotes` reaches only staff via
* the collection access control.
*/
export const getMunicipalityPriorities = (ctx: AIToolContext) =>
tool({
description:
'Returns the campaign municipalities that deserve attention right now within a scope ' +
"(territory, city, municipality or the user's access scope), ranked by combined gravity " +
'(recent unfavorable signal > stagnation > high potential with low engagement), ' +
'each with a one-line reason. Use when the user asks "Quais devem ser minhas prioridades ' +
'neste momento?", "o que devo atacar primeiro?", "quais municípios estão pegando fogo?" ' +
'or refines it ("só as sem atualização", "ordena por potencial"). ' +
'The criterion is always declared in the response; combine the slugs with buildCampaignLinks.',
inputSchema: z.object({
scope: z
.string()
.trim()
.min(1)
.optional()
.describe(
'Território de identidade, cidade ou município (ex.: "Vale do Jiquiriça", "Salvador", "Feira de Santana"). Omita para usar o escopo de acesso do usuário.',
),
reason: z
.enum(['sinal_desfavoravel', 'estagnacao', 'potencial'])
.optional()
.describe(
'Filtra o ranking para um fator: "sinal_desfavoravel", "estagnacao" ou "potencial" (ex.: "só as sem atualização").',
),
sortBy: z
.enum(['gravidade', 'potencial'])
.optional()
.default('gravidade')
.describe(
'"gravidade" (default) ordena sinal desfavorável > estagnação > potencial; "potencial" reordena todos por votos estimados.',
),
days: z
.number()
.int()
.min(7)
.max(90)
.optional()
.default(PRIORITY_STALE_SIGNAL_DAYS)
.describe('Janela de recência em dias para sinal desfavorável e estagnação (default 30).'),
limit: z
.number()
.int()
.min(1)
.max(20)
.optional()
.default(10)
.describe('Máximo de resultados (default 10, max 20).'),
}),
execute: async ({ scope, reason, sortBy, days, limit }) => {
if (!isStaffCampaignRole(ctx.user.role)) return { error: DENIED_MESSAGE }
const resolved = await resolveAIToolScope(ctx, scope)
if ('error' in resolved) return resolved
const scopeIDs = resolved.municipalityIDs
// The AI SDK applies the zod defaults before execute in the real route;
// the ?? guards keep direct calls (unit tests, future callers) sane.
const janelaDias = days ?? PRIORITY_STALE_SIGNAL_DAYS
const safeLimit = limit ?? 10
const now = Date.now()
const emptyResponse = () => ({
escopo: { tipo: resolved.kind, nome: resolved.name },
escopoRestrito: ctx.user.role === 'advisor',
criterio: buildCriterion(janelaDias),
janelaDias,
motivoAplicado: reason ?? null,
total: 0,
prioridades: [],
truncado: false,
})
// Scoped to nothing (advisor asking outside the portfolio): fail closed
// before the municipality read — never an existence lie, no wasted trips.
if (scopeIDs !== null && scopeIDs.length === 0) return emptyResponse()
const municipalities = await ctx.payload.find({
collection: 'municipality',
where: scopeIDs ? { id: { in: scopeIDs } } : {},
depth: 0,
limit: 0,
pagination: false,
select: {
id: true,
name: true,
slug: true,
city: true,
region: true,
priority: true,
engagementLevel: true,
expectedVotes: true,
lastUpdateAt: true,
politicalTrend: { status: true },
},
overrideAccess: false,
user: ctx.user,
})
const rows = municipalities.docs as unknown as MunicipalityRow[]
const ids = rows.map((row) => row.id)
const [updates, pledges] = await Promise.all([
ids.length === 0
? { docs: [] as UpdateRow[] }
: ctx.payload.find({
collection: 'municipalityUpdate',
where: {
and: [
{ municipality: { in: ids } },
{
createdAt: { greater_than: new Date(now - janelaDias * DAY_MS).toISOString() },
},
],
},
depth: 0,
limit: 0,
pagination: false,
select: {
municipality: true,
createdAt: true,
polarity: true,
urgent: true,
adversarySignal: true,
body: true,
},
sort: '-createdAt',
overrideAccess: false,
user: ctx.user,
}),
aggregatePledgesByMunicipality(ctx.payload, ids),
])
const lastPledgeAtById = new Map<number, string | null>(
ids.map((id) => [id, pledges.get(id)?.lastPledgeAt ?? null]),
)
const inputs: PriorityMunicipalityInput[] = rows.map((row) => ({
id: row.id,
name: row.name,
slug: row.slug,
region: row.region ?? null,
city: row.city ?? null,
priority: row.priority === 'alta' ? 'alta' : 'normal',
engagementLevel: row.engagementLevel ?? null,
expectedVotesCentral: row.expectedVotes?.central ?? null,
validVotes2022: getMunicipalityFederalBaseline(row.slug).validVotesByYear['2022'] ?? null,
lastUpdateAt: row.lastUpdateAt ?? null,
politicalTrendStatus: row.politicalTrend?.status ?? null,
}))
const signals: PriorityUpdateSignalInput[] = (updates.docs as unknown as UpdateRow[]).flatMap(
(update) => {
const municipalityID =
typeof update.municipality === 'number' ? update.municipality : null
if (municipalityID === null || !update.createdAt) return []
return [
{
municipalityID,
createdAt: update.createdAt,
polarity: update.polarity ?? 'neutra',
urgent: update.urgent ?? false,
adversarySignal: update.adversarySignal ?? false,
body: update.body ?? null,
},
]
},
)
const ranked = rankMunicipalityPriorities(inputs, lastPledgeAtById, signals, {
janelaDias,
motivo: reason as MunicipalityPriorityReason | undefined,
ordenarPor: sortBy,
})
const total = ranked.length
const top = ranked.slice(0, safeLimit)
const truncado = total > top.length
return {
escopo: { tipo: resolved.kind, nome: resolved.name },
escopoRestrito: ctx.user.role === 'advisor',
criterio: buildCriterion(janelaDias),
janelaDias,
motivoAplicado: reason ?? null,
total,
prioridades: top,
truncado,
...(truncado ? { dica: NARROW_SCOPE_HINT } : {}),
}
},
})

View file

@ -14,6 +14,7 @@ import { getLeaderships } from '@/utilities/ai/tools/getLeaderships'
import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
import { getMunicipalitiesWithoutUpdate } from '@/utilities/ai/tools/getMunicipalitiesWithoutUpdate'
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
import { getMunicipalityPriorities } from '@/utilities/ai/tools/getMunicipalityPriorities'
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
import { getPartnershipCoverage } from '@/utilities/ai/tools/getPartnershipCoverage'
@ -30,6 +31,7 @@ export const buildAITools = (ctx: AIToolContext) => ({
getDobradinhas: getDobradinhas(ctx),
getMunicipalitiesWithoutUpdate: getMunicipalitiesWithoutUpdate(ctx),
getMunicipalityOverview: getMunicipalityOverview(ctx),
getMunicipalityPriorities: getMunicipalityPriorities(ctx),
getLeaderships: getLeaderships(ctx),
getPendingLeaderships: getPendingLeaderships(ctx),
getOrganizations: getOrganizations(ctx),

View file

@ -0,0 +1,365 @@
/**
* B186 — "prioridades do momento" ranking. Pure and client-safe: the AI tool
* feeds municipality rows, per-municipality pledge recency and the in-window
* updates, and receives the ranked priorities with one evidence line each.
*
* Mirrors the E9 freshness semantic (`municipalitySignal.ts`: the last signal
* is `max(lastUpdateAt, lastPledgeAt)`) and the repo's "a class label never
* renders alone" rule: every item carries the reason that placed it there.
* No score number is ever computed — the buckets order by declared gravity
* (sinal desfavorável > estagnação > potencial) and the reason is prose.
*/
import { formatElectionNumber } from '@/lib/electionFormat'
import {
EMPTY_ENGAGEMENT_LEVEL_LABEL,
formatEngagementLevelLabel,
type EngagementLevel,
} from '@/lib/engagementLevel'
import {
municipalityUpdatePolarityLabels,
type MunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import { DAY_MS } from '@/lib/text'
import { type PoliticalTrendStatus } from '@/utilities/municipality/municipalityLabels'
import {
formatMunicipalitySignalAgeLabel,
formatSilenceAgeLabel,
municipalitySignalAgeInDays,
resolveMunicipalityLastSignalAt,
} from '@/utilities/municipality/municipalitySignal'
/** Default recency window — the B185 family semantic (30 days, adjustable). */
export const PRIORITY_STALE_SIGNAL_DAYS = 30
/** How many low-engagement municipalities the potential bucket keeps per scope. */
export const PRIORITY_POTENTIAL_TOP_N = 5
/** Longest update excerpt carried into an evidence line. */
const PRIORITY_UPDATE_EXCERPT_MAX_LENGTH = 200
export type MunicipalityPriorityReason = 'sinal_desfavoravel' | 'estagnacao' | 'potencial'
export type PriorityMunicipalityInput = {
id: number
name: string
slug: string
region: string | null
city: string | null
priority: 'alta' | 'normal'
engagementLevel: EngagementLevel | null
/** `expectedVotes.central` (2026 staff estimate); `null` when unset. */
expectedVotesCentral: number | null
/** 2022 federal valid votes (committed artifact) — the potential fallback. */
validVotes2022: number | null
lastUpdateAt: string | null
politicalTrendStatus: PoliticalTrendStatus | null
}
export type PriorityUpdateSignalInput = {
municipalityID: number
createdAt: string
polarity: MunicipalityUpdatePolarity
urgent: boolean
adversarySignal: boolean
body: string | null
}
export type MunicipalityPriorityItem = {
id: number
nome: string
slug: string
regiao: string | null
cidade: string | null
motivo: MunicipalityPriorityReason
evidencia: string
prioridade: 'alta' | 'normal'
nivelEngajamento: string
ultimoSinalAtrasDias: number | null
ultimaAtualizacao: string | null
potencialEstimado: number | null
fontePotencial: 'estimativa_2026' | 'validos_2022' | null
}
export type MunicipalityPriorityRankOptions = {
janelaDias: number
/** Narrow the ranking to one reason ("só as sem atualização"). */
motivo?: MunicipalityPriorityReason
/** Default gravity (sinal > estagnação > potencial); potential re-sorts all. */
ordenarPor?: 'gravidade' | 'potencial'
/** One clock read for the whole ranking — tests pin `agora`. */
agora?: Date
}
type EvaluatedMunicipality = {
input: PriorityMunicipalityInput
lastSignalAgeDays: number | null
decisiveUpdate: PriorityUpdateSignalInput | null
decisiveUpdateAgeDays: number | null
bucket: MunicipalityPriorityReason | 'excluido' | 'nada'
potential: number | null
potentialSource: 'estimativa_2026' | 'validos_2022' | null
}
const isLowEngagement = (level: EngagementLevel | null): boolean =>
level === null || level === 'n0' || level === 'n1'
const potentialOf = (
input: PriorityMunicipalityInput,
): {
potential: number | null
source: 'estimativa_2026' | 'validos_2022' | null
} => {
if (input.expectedVotesCentral !== null && input.expectedVotesCentral > 0) {
return { potential: input.expectedVotesCentral, source: 'estimativa_2026' }
}
if (input.validVotes2022 !== null && input.validVotes2022 > 0) {
return { potential: input.validVotes2022, source: 'validos_2022' }
}
return { potential: null, source: null }
}
/** Latest update whose `createdAt` falls inside the window — "última palavra decide". */
const latestInWindowUpdate = (
updates: PriorityUpdateSignalInput[],
cutoff: number,
): PriorityUpdateSignalInput | null => {
let latest: PriorityUpdateSignalInput | null = null
for (const update of updates) {
const createdAt = new Date(update.createdAt).getTime()
if (Number.isNaN(createdAt) || createdAt < cutoff) continue
if (latest === null || createdAt > new Date(latest.createdAt).getTime()) latest = update
}
return latest
}
const isFavorable = (update: PriorityUpdateSignalInput): boolean =>
update.polarity === 'boa' && !update.urgent && !update.adversarySignal
const evaluateMunicipality = (
input: PriorityMunicipalityInput,
lastPledgeAt: string | null,
decisiveUpdate: PriorityUpdateSignalInput | null,
now: Date,
janelaDias: number,
): EvaluatedMunicipality => {
const lastSignalAgeDays = municipalitySignalAgeInDays(
resolveMunicipalityLastSignalAt(input.lastUpdateAt, lastPledgeAt),
now,
)
const decisiveUpdateAgeDays = decisiveUpdate
? municipalitySignalAgeInDays(decisiveUpdate.createdAt, now)
: null
let bucket: EvaluatedMunicipality['bucket'] = 'nada'
if (decisiveUpdate) {
if (isFavorable(decisiveUpdate)) {
// Acceptance rule: a municipio with a recent FAVORABLE update and no
// negative signals is not a priority — it is excluded from every bucket,
// the potential one included.
bucket = 'excluido'
} else if (
decisiveUpdate.polarity === 'ruim' ||
decisiveUpdate.urgent ||
decisiveUpdate.adversarySignal
) {
bucket = 'sinal_desfavoravel'
}
} else if (lastSignalAgeDays === null || lastSignalAgeDays >= janelaDias) {
bucket = 'estagnacao'
}
const { potential, source } = potentialOf(input)
if (bucket === 'nada' && isLowEngagement(input.engagementLevel) && potential !== null) {
bucket = 'potencial'
}
return {
input,
lastSignalAgeDays,
decisiveUpdate,
decisiveUpdateAgeDays,
bucket,
potential,
potentialSource: source,
}
}
const excerptOf = (body: string | null): string | null => {
if (!body) return null
const trimmed = body.replace(/\s+/g, ' ').trim()
if (trimmed.length === 0) return null
if (trimmed.length <= PRIORITY_UPDATE_EXCERPT_MAX_LENGTH) return trimmed
return `${trimmed.slice(0, PRIORITY_UPDATE_EXCERPT_MAX_LENGTH).trimEnd()}…`
}
const engagementLevelLabel = (level: EngagementLevel | null): string =>
level === null ? EMPTY_ENGAGEMENT_LEVEL_LABEL : formatEngagementLevelLabel(level)
const buildEvidence = (evaluated: EvaluatedMunicipality): string => {
const { input, bucket } = evaluated
const parts: string[] = []
if (bucket === 'sinal_desfavoravel' && evaluated.decisiveUpdate) {
const { decisiveUpdate, decisiveUpdateAgeDays } = evaluated
const signalLabel = decisiveUpdate.adversarySignal
? `Alerta de adversário ${formatMunicipalitySignalAgeLabel(decisiveUpdateAgeDays ?? 0)}`
: decisiveUpdate.urgent
? `Atualização urgente ${formatMunicipalitySignalAgeLabel(decisiveUpdateAgeDays ?? 0)}`
: `Atualização ${municipalityUpdatePolarityLabels.ruim.toLowerCase()} ${formatMunicipalitySignalAgeLabel(decisiveUpdateAgeDays ?? 0)}`
const excerpt = excerptOf(decisiveUpdate.body)
parts.push(excerpt ? `${signalLabel}: "${excerpt}"` : signalLabel)
} else if (bucket === 'estagnacao') {
parts.push(formatSilenceAgeLabel(evaluated.lastSignalAgeDays))
} else if (bucket === 'potencial') {
const potentialLabel =
evaluated.potentialSource === 'validos_2022'
? `${formatElectionNumber(evaluated.potential ?? 0)} válidos em 2022`
: `estimativa central de ${formatElectionNumber(evaluated.potential ?? 0)} votos`
const levelLabel =
input.engagementLevel === null
? 'sem nível definido'
: `nível ${formatEngagementLevelLabel(input.engagementLevel)}`
parts.push(`Potencial alto (${potentialLabel}) e ${levelLabel}`)
}
if (input.priority === 'alta') parts.push('prioritário')
if (input.politicalTrendStatus === 'desfavoravel') parts.push('tendência política desfavorável')
return parts.join(' · ')
}
const priorityRank: Record<'alta' | 'normal', number> = { alta: 0, normal: 1 }
const byName = (left: PriorityMunicipalityInput, right: PriorityMunicipalityInput): number =>
left.name.localeCompare(right.name, 'pt-BR')
const tiebreak = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): number => {
const byPriority = priorityRank[left.input.priority] - priorityRank[right.input.priority]
if (byPriority !== 0) return byPriority
return byName(left.input, right.input)
}
const byGravity = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): number => {
const bucketRank: Record<MunicipalityPriorityReason, number> = {
sinal_desfavoravel: 0,
estagnacao: 1,
potencial: 2,
}
const byBucket =
bucketRank[left.bucket as MunicipalityPriorityReason] -
bucketRank[right.bucket as MunicipalityPriorityReason]
if (byBucket !== 0) return byBucket
if (left.bucket === 'sinal_desfavoravel') {
// Most recent first: age 0 = today.
const byAge =
(left.decisiveUpdateAgeDays ?? Number.POSITIVE_INFINITY) -
(right.decisiveUpdateAgeDays ?? Number.POSITIVE_INFINITY)
if (byAge !== 0) return byAge
return tiebreak(left, right)
}
if (left.bucket === 'estagnacao') {
// Coldest first; never-signaled (+Infinity) outranks every date.
const byAge =
(right.lastSignalAgeDays ?? Number.POSITIVE_INFINITY) -
(left.lastSignalAgeDays ?? Number.POSITIVE_INFINITY)
if (byAge !== 0) return byAge
return tiebreak(left, right)
}
if (left.bucket === 'potencial') {
const byPotential = (right.potential ?? 0) - (left.potential ?? 0)
if (byPotential !== 0) return byPotential
return tiebreak(left, right)
}
return tiebreak(left, right)
}
const byPotential = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): number => {
const byValue = (right.potential ?? -1) - (left.potential ?? -1)
if (byValue !== 0) return byValue
return tiebreak(left, right)
}
const toItem = (evaluated: EvaluatedMunicipality): MunicipalityPriorityItem => ({
id: evaluated.input.id,
nome: evaluated.input.name,
slug: evaluated.input.slug,
regiao: evaluated.input.region,
cidade: evaluated.input.city,
motivo: evaluated.bucket as MunicipalityPriorityReason,
evidencia: buildEvidence(evaluated),
prioridade: evaluated.input.priority,
nivelEngajamento: engagementLevelLabel(evaluated.input.engagementLevel),
ultimoSinalAtrasDias: evaluated.lastSignalAgeDays,
ultimaAtualizacao: evaluated.input.lastUpdateAt,
potencialEstimado: evaluated.potential,
fontePotencial: evaluated.potentialSource,
})
/**
* Rank the scope's municipalities into "priorities of the moment". Buckets are
* mutually exclusive and ordered by declared gravity; every returned item
* carries the reason (evidence line) that placed it there. Municipalities with
* a recent favorable update and no negative signals are excluded entirely.
*/
export const rankMunicipalityPriorities = (
municipalities: PriorityMunicipalityInput[],
lastPledgeAtById: ReadonlyMap<number, string | null>,
updates: PriorityUpdateSignalInput[],
options: MunicipalityPriorityRankOptions,
): MunicipalityPriorityItem[] => {
const now = options.agora ?? new Date()
const cutoff = now.getTime() - options.janelaDias * DAY_MS
const updatesByMunicipality = new Map<number, PriorityUpdateSignalInput[]>()
for (const update of updates) {
const list = updatesByMunicipality.get(update.municipalityID) ?? []
list.push(update)
updatesByMunicipality.set(update.municipalityID, list)
}
const decisiveByMunicipality = new Map<number, PriorityUpdateSignalInput>()
for (const [municipalityID, list] of updatesByMunicipality) {
const latest = latestInWindowUpdate(list, cutoff)
if (latest) decisiveByMunicipality.set(municipalityID, latest)
}
const evaluated = municipalities.map((input) =>
evaluateMunicipality(
input,
lastPledgeAtById.get(input.id) ?? null,
decisiveByMunicipality.get(input.id) ?? null,
now,
options.janelaDias,
),
)
// The potential bucket keeps only the top-N low-engagement municipalities of
// the scope — a relative cut (no invented absolute threshold).
const potentialCandidates = evaluated
.filter((entry) => entry.bucket === 'potencial')
.sort(
(left, right) =>
(right.potential ?? 0) - (left.potential ?? 0) || byName(left.input, right.input),
)
const topPotentialIDs = new Set(
potentialCandidates.slice(0, PRIORITY_POTENTIAL_TOP_N).map((entry) => entry.input.id),
)
for (const entry of evaluated) {
if (entry.bucket === 'potencial' && !topPotentialIDs.has(entry.input.id)) {
entry.bucket = 'nada'
}
}
const sortBy = options.ordenarPor === 'potencial' ? byPotential : byGravity
const ranked = evaluated
.filter((entry) => entry.bucket !== 'nada' && entry.bucket !== 'excluido')
.sort(sortBy)
if (options.motivo) {
const reason = options.motivo
return ranked.filter((entry) => entry.bucket === reason).map(toItem)
}
return ranked.map(toItem)
}

View file

@ -563,6 +563,9 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
// over a known set — the actor scope still comes from access control
// (overrideAccess: false + user).
'src/utilities/ai/tools/getPendingLeaderships.ts',
// B186 — same distinct form as B185: the priority tool reads the in-window
// updates of a scope already resolved (or access-narrowed) by the actor.
'src/utilities/ai/tools/getMunicipalityPriorities.ts',
])
it('keeps the scope fragment out of re-spellings', () => {

View file

@ -0,0 +1,410 @@
import { describe, expect, it } from 'vitest'
import { DAY_MS } from '@/lib/text'
import {
PRIORITY_POTENTIAL_TOP_N,
rankMunicipalityPriorities,
type MunicipalityPriorityItem,
type PriorityMunicipalityInput,
type PriorityUpdateSignalInput,
} from '@/utilities/municipality/municipalityPriorities'
const NOW = new Date('2026-08-10T12:00:00.000Z')
const daysAgo = (days: number): string => new Date(NOW.getTime() - days * DAY_MS).toISOString()
const mun = (overrides: Partial<PriorityMunicipalityInput>): PriorityMunicipalityInput => ({
id: 1,
name: 'Amargosa',
slug: 'amargosa',
region: 'Vale do Jiquiriçá',
city: 'Amargosa',
priority: 'normal',
engagementLevel: null,
expectedVotesCentral: null,
validVotes2022: null,
lastUpdateAt: null,
politicalTrendStatus: null,
...overrides,
})
const update = (
municipalityID: number,
createdAt: string,
overrides: Partial<PriorityUpdateSignalInput> = {},
): PriorityUpdateSignalInput => ({
municipalityID,
createdAt,
polarity: 'ruim',
urgent: false,
adversarySignal: false,
body: null,
...overrides,
})
const rank = (
municipalities: PriorityMunicipalityInput[],
options: {
janelaDias?: number
motivo?: 'sinal_desfavoravel' | 'estagnacao' | 'potencial'
ordenarPor?: 'gravidade' | 'potencial'
lastPledgeAtById?: ReadonlyMap<number, string | null>
updates?: PriorityUpdateSignalInput[]
} = {},
): MunicipalityPriorityItem[] =>
rankMunicipalityPriorities(
municipalities,
options.lastPledgeAtById ?? new Map(),
options.updates ?? [],
{
janelaDias: options.janelaDias ?? 30,
motivo: options.motivo,
ordenarPor: options.ordenarPor,
agora: NOW,
},
)
describe('rankMunicipalityPriorities buckets (B186)', () => {
it('atualização ruim na janela vira sinal_desfavoravel com evidência e trecho', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2) })], {
updates: [update(1, daysAgo(2), { body: ' prefeito fechou com o adversário ' })],
})
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
id: 1,
motivo: 'sinal_desfavoravel',
ultimoSinalAtrasDias: 2,
})
expect(result[0]!.evidencia).toBe(
'Atualização ruim há 2 dias: "prefeito fechou com o adversário"',
)
})
it('urgente manda sobre a polaridade boa (flags dominam)', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(1), { polarity: 'boa', urgent: true })],
})
expect(result[0]!.motivo).toBe('sinal_desfavoravel')
expect(result[0]!.evidencia).toContain('Atualização urgente há 1 dia')
})
it('alerta de adversário tem label próprio', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(3) })], {
updates: [update(1, daysAgo(3), { polarity: 'neutra', adversarySignal: true })],
})
expect(result[0]!.evidencia).toBe('Alerta de adversário há 3 dias')
})
it('exclui município com atualização recente favorável e sem sinais negativos', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(1), { polarity: 'boa' })],
})
expect(result).toHaveLength(0)
})
it('exclusão vale inclusive para o balde potencial (aceite literal)', () => {
const result = rank(
[
mun({
id: 1,
lastUpdateAt: daysAgo(1),
engagementLevel: 'n0',
expectedVotesCentral: 90000,
}),
],
{ updates: [update(1, daysAgo(1), { polarity: 'boa' })] },
)
expect(result).toHaveLength(0)
})
it('"última palavra decide": boa recente depois de ruim exclui', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(5)), update(1, daysAgo(1), { polarity: 'boa' })],
})
expect(result).toHaveLength(0)
})
it('"última palavra decide": ruim recente depois de boa é sinal', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(5), { polarity: 'boa' }), update(1, daysAgo(1))],
})
expect(result[0]!.motivo).toBe('sinal_desfavoravel')
})
it('atualização fora da janela não é sinal recente; vale a estagnação', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(45) })], {
updates: [update(1, daysAgo(45))],
})
expect(result[0]!.motivo).toBe('estagnacao')
expect(result[0]!.evidencia).toContain('sem sinal há 45 dias')
})
it('sem sinal nenhum é estagnação máxima ("nunca recebeu sinal")', () => {
const result = rank([mun({ id: 1 })])
expect(result[0]!.motivo).toBe('estagnacao')
expect(result[0]!.evidencia).toBe('nunca recebeu sinal')
expect(result[0]!.ultimoSinalAtrasDias).toBeNull()
})
it('pledge recente é sinal (E9): sem atualização mas com compromisso não é estagnação', () => {
const result = rank([mun({ id: 1, lastUpdateAt: null })], {
lastPledgeAtById: new Map([[1, daysAgo(2)]]),
})
expect(result).toHaveLength(0)
})
it('pledge antigo não salva da estagnação', () => {
const result = rank([mun({ id: 1, lastUpdateAt: null })], {
lastPledgeAtById: new Map([[1, daysAgo(60)]]),
})
expect(result[0]!.motivo).toBe('estagnacao')
expect(result[0]!.evidencia).toContain('sem sinal há 60 dias')
})
it('neutra recente não é estagnação nem sinal; entra no potencial se for top', () => {
const result = rank(
[
mun({
id: 1,
lastUpdateAt: daysAgo(1),
engagementLevel: 'n0',
expectedVotesCentral: 80000,
}),
],
{ updates: [update(1, daysAgo(1), { polarity: 'neutra' })] },
)
expect(result[0]!.motivo).toBe('potencial')
})
it('potencial: nível N0 com estimativa central alta entra com evidência rotulada', () => {
const result = rank([
mun({ id: 1, lastUpdateAt: daysAgo(2), engagementLevel: 'n0', expectedVotesCentral: 52400 }),
])
expect(result[0]!.motivo).toBe('potencial')
expect(result[0]!.evidencia).toBe(
'Potencial alto (estimativa central de 52.400 votos) e nível N0 · Monitorar',
)
expect(result[0]).toMatchObject({
potencialEstimado: 52400,
fontePotencial: 'estimativa_2026',
nivelEngajamento: 'N0 · Monitorar',
})
})
it('potencial: sem nível definido entra e a evidência diz "sem nível definido"', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2), expectedVotesCentral: 40000 })])
expect(result[0]!.motivo).toBe('potencial')
expect(result[0]!.evidencia).toBe(
'Potencial alto (estimativa central de 40.000 votos) e sem nível definido',
)
expect(result[0]!.nivelEngajamento).toBe('Sem nível')
})
it('potencial: fallback nos válidos 2022 quando não há estimativa central', () => {
const result = rank([
mun({ id: 1, lastUpdateAt: daysAgo(2), engagementLevel: 'n1', validVotes2022: 31000 }),
])
expect(result[0]!.motivo).toBe('potencial')
expect(result[0]!.evidencia).toBe(
'Potencial alto (31.000 válidos em 2022) e nível N1 · Presença de mandato',
)
expect(result[0]).toMatchObject({ fontePotencial: 'validos_2022' })
})
it('potencial: engajamento N2+ não é candidato', () => {
const result = rank([
mun({ id: 1, lastUpdateAt: daysAgo(2), engagementLevel: 'n2', expectedVotesCentral: 90000 }),
])
expect(result).toHaveLength(0)
})
it('potencial: sem número algum de potencial não entra', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2), engagementLevel: 'n0' })])
expect(result).toHaveLength(0)
})
it(`potencial: corte relativo top ${PRIORITY_POTENTIAL_TOP_N} do escopo`, () => {
const candidates = Array.from({ length: 8 }, (_, index) =>
mun({
id: index + 1,
name: `Cidade ${index}`,
slug: `cidade-${index}`,
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 10000 + index,
}),
)
const result = rank(candidates)
expect(result).toHaveLength(PRIORITY_POTENTIAL_TOP_N)
expect(result.map((item) => item.id)).toEqual([8, 7, 6, 5, 4])
})
})
describe('rankMunicipalityPriorities ordering (B186)', () => {
it('gravidade: sinal_desfavoravel > estagnacao > potencial', () => {
const result = rank(
[
mun({
id: 1,
lastUpdateAt: daysAgo(5),
engagementLevel: 'n0',
expectedVotesCentral: 90000,
}),
mun({ id: 2, lastUpdateAt: daysAgo(2) }),
mun({ id: 3, lastUpdateAt: daysAgo(40) }),
],
{
updates: [update(2, daysAgo(2))],
},
)
expect(result.map((item) => item.motivo)).toEqual([
'sinal_desfavoravel',
'estagnacao',
'potencial',
])
})
it('dentro do sinal: mais recente primeiro', () => {
const result = rank(
[
mun({ id: 1, name: 'A', lastUpdateAt: daysAgo(2) }),
mun({ id: 2, name: 'B', lastUpdateAt: daysAgo(1) }),
],
{ updates: [update(1, daysAgo(2)), update(2, daysAgo(1))] },
)
expect(result.map((item) => item.id)).toEqual([2, 1])
})
it('dentro da estagnação: mais frio primeiro, nunca-sinal no topo', () => {
const result = rank([
mun({ id: 1, name: 'A', lastUpdateAt: daysAgo(35) }),
mun({ id: 2, name: 'B', lastUpdateAt: null }),
mun({ id: 3, name: 'C', lastUpdateAt: daysAgo(90) }),
])
expect(result.map((item) => item.id)).toEqual([2, 3, 1])
})
it('dentro do potencial: maior potencial primeiro; prioridade alta desempata', () => {
const result = rank([
mun({
id: 1,
name: 'A',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
}),
mun({
id: 2,
name: 'B',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
priority: 'alta',
}),
mun({
id: 3,
name: 'C',
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 80000,
}),
])
expect(result.map((item) => item.id)).toEqual([3, 2, 1])
})
it('prioridade alta aparece na evidência', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(40), priority: 'alta' })])
expect(result[0]!.evidencia).toBe('sem sinal há 40 dias · prioritário')
})
it('tendência política desfavorável entra como cláusula secundária', () => {
const result = rank([
mun({ id: 1, lastUpdateAt: daysAgo(40), politicalTrendStatus: 'desfavoravel' }),
])
expect(result[0]!.evidencia).toBe('sem sinal há 40 dias · tendência política desfavorável')
})
it('trecho longo é truncado com reticências', () => {
const longBody = 'x'.repeat(300)
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(1) })], {
updates: [update(1, daysAgo(1), { body: longBody })],
})
expect(result[0]!.evidencia).toMatch(/^Atualização ruim há 1 dia: "x{200}…"$/)
})
})
describe('rankMunicipalityPriorities options (B186)', () => {
it('motivo filtra para um único balde', () => {
const result = rank(
[
mun({ id: 1, lastUpdateAt: daysAgo(1) }),
mun({ id: 2, lastUpdateAt: daysAgo(40) }),
mun({
id: 3,
lastUpdateAt: daysAgo(2),
engagementLevel: 'n0',
expectedVotesCentral: 50000,
}),
],
{
updates: [update(1, daysAgo(1))],
motivo: 'estagnacao',
},
)
expect(result).toHaveLength(1)
expect(result[0]!.id).toBe(2)
})
it('ordenarPor potencial reordena todos os baldes por potencial desc', () => {
const result = rank(
[
mun({ id: 1, name: 'A', lastUpdateAt: daysAgo(40), expectedVotesCentral: 30000 }),
mun({ id: 2, name: 'B', lastUpdateAt: daysAgo(1), expectedVotesCentral: 90000 }),
mun({ id: 3, name: 'C', lastUpdateAt: daysAgo(45), expectedVotesCentral: null }),
],
{
updates: [update(2, daysAgo(1))],
ordenarPor: 'potencial',
},
)
expect(result.map((item) => item.id)).toEqual([2, 1, 3])
})
it('janela custom muda a fronteira da estagnação e do sinal', () => {
const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(40) })], {
janelaDias: 60,
updates: [update(1, daysAgo(40))],
})
expect(result[0]!.motivo).toBe('sinal_desfavoravel')
const result2 = rank([mun({ id: 2, lastUpdateAt: daysAgo(40) })], { janelaDias: 30 })
expect(result2[0]!.motivo).toBe('estagnacao')
})
})

View file

@ -0,0 +1,289 @@
import type { Payload } from 'payload'
import { describe, expect, it, vi } from 'vitest'
import type { AIToolContext } from '@/lib/ai/types'
import type { CampaignUser } from '@/payload-types'
import { getMunicipalityPriorities } from '@/utilities/ai/tools/getMunicipalityPriorities'
import { stub } from '../helpers/stub'
const denied = { error: 'Leitura de prioridades de municípios negada.' }
type FindMock = ReturnType<typeof vi.fn>
const findResult = (docs: unknown[]) => ({ docs })
const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload })
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
const advisor = stub<CampaignUser>({ collection: 'campaignUser', role: 'advisor' })
const scriptedPayload = (find: FindMock): Payload =>
stub<Payload>({ find: find as Payload['find'] })
const untouchablePayload = scriptedPayload(
vi.fn(() => {
throw new Error('gate must fail closed before any payload query')
}),
)
const municipalityDoc = (overrides: Record<string, unknown>) => ({
id: 10,
name: 'Amargosa',
slug: 'amargosa',
city: 'Amargosa',
region: 'Vale do Jiquiriçá',
priority: 'normal',
engagementLevel: null,
expectedVotes: null,
lastUpdateAt: null,
politicalTrend: null,
...overrides,
})
const updateDoc = (overrides: Record<string, unknown>) => ({
municipality: 10,
createdAt: '2026-08-08T12:00:00.000Z',
polarity: 'ruim',
urgent: false,
adversarySignal: false,
body: 'prefeito fechou com o adversário',
...overrides,
})
type ExecutableTool = {
execute: (args: unknown, options?: unknown) => Promise<unknown>
}
const execute =
(payload: Payload, user: CampaignUser = coordinator) =>
(args: unknown) =>
(getMunicipalityPriorities(ctxFor(user, payload)) as unknown as ExecutableTool).execute!(args)
describe('getMunicipalityPriorities gate (B186)', () => {
it('denies a leader with the chat-shaped error before any payload query', async () => {
const payload = untouchablePayload
await expect(execute(payload, leader)({})).resolves.toEqual(denied)
expect(payload.find).not.toHaveBeenCalled()
})
it('lets a coordinator pass and returns the empty staff shape', async () => {
const find = vi.fn().mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({})) as Record<string, unknown>
expect(result.total).toBe(0)
expect(result.prioridades).toEqual([])
expect(result.truncado).toBe(false)
expect(result.escopoRestrito).toBe(false)
expect(result.escopo).toEqual({ tipo: 'todos', nome: null })
expect(result.janelaDias).toBe(30)
expect(result.motivoAplicado).toBeNull()
expect(result.criterio).toContain('30 dias')
const munCall = find.mock.calls[0]![0] as { collection: string; overrideAccess: boolean }
expect(munCall.collection).toBe('municipality')
expect(munCall.overrideAccess).toBe(false)
expect(find).toHaveBeenCalledTimes(1)
})
it('lets an advisor pass (RBAC atual via access control, não pelo gate)', async () => {
const find = vi.fn().mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find), advisor)({})) as Record<string, unknown>
expect(result.total).toBe(0)
expect(result.escopoRestrito).toBe(true)
})
})
describe('getMunicipalityPriorities reads (B186)', () => {
it('carrega municípios do escopo, atualizações da janela e agregado de pledges com acesso do ator', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
municipalityDoc({ id: 10, expectedVotes: { central: 52400 }, engagementLevel: 'n0' }),
municipalityDoc({
id: 11,
name: 'Brejões',
slug: 'brejoes',
lastUpdateAt: '2026-06-01T12:00:00.000Z',
}),
]),
)
find.mockResolvedValueOnce(findResult([updateDoc({ municipality: 10 })]))
find.mockResolvedValueOnce(
findResult([
{ municipality: 11, declaredVotes: 300, declaredAt: '2026-07-01T12:00:00.000Z' },
]),
)
const result = (await execute(scriptedPayload(find))({})) as {
total: number
prioridades: Array<Record<string, unknown>>
}
const munCall = find.mock.calls[0]![0] as { where: unknown }
expect(munCall.where).toEqual({})
const updateCall = find.mock.calls[1]![0] as {
collection: string
where: { and: unknown[] }
overrideAccess: boolean
sort: string
}
expect(updateCall.collection).toBe('municipalityUpdate')
expect(updateCall.where.and).toHaveLength(2)
expect(updateCall.where.and[0]).toEqual({ municipality: { in: [10, 11] } })
expect(updateCall.where.and[1]).toEqual({
createdAt: { greater_than: expect.any(String) } as unknown,
})
expect(updateCall.overrideAccess).toBe(false)
expect(updateCall.sort).toBe('-createdAt')
const pledgeCall = find.mock.calls[2]![0] as { collection: string; where: unknown }
expect(pledgeCall.collection).toBe('votePledge')
expect(pledgeCall.where).toEqual({ municipality: { in: [10, 11] } })
// The ranking ran with the loaded data: 10 via potential, 11 via stagnation.
expect(result.total).toBe(2)
expect(result.prioridades.map((p) => p.motivo)).toEqual(['sinal_desfavoravel', 'estagnacao'])
const first = result.prioridades[0]!
expect(first).toMatchObject({
id: 10,
nome: 'Amargosa',
slug: 'amargosa',
motivo: 'sinal_desfavoravel',
prioridade: 'normal',
})
expect(first.evidencia).toContain('prefeito fechou com o adversário')
expect(first.ultimaAtualizacao).toBeNull()
})
it('escopo região resolve antes das leituras e estreita o where', async () => {
const find = vi.fn()
const doc = municipalityDoc({
id: 10,
lastUpdateAt: '2026-08-08T12:00:00.000Z',
engagementLevel: 'n2',
})
find.mockResolvedValueOnce(findResult([doc]))
find.mockResolvedValueOnce(findResult([doc]))
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([]))
const result = (await execute(scriptedPayload(find))({
scope: 'Vale do Jiquiriça',
})) as { escopo: { tipo: string; nome: string }; total: number }
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
const scopeCall = find.mock.calls[0]![0] as { where: unknown }
expect(scopeCall.where).toEqual({ region: { equals: 'Vale do Jiquiriçá' } })
const munCall = find.mock.calls[1]![0] as { where: unknown }
expect(munCall.where).toEqual({ id: { in: [10] } })
const updateCall = find.mock.calls[2]![0] as { where: { and: unknown[] } }
expect(updateCall.where.and[0]).toEqual({ municipality: { in: [10] } })
// Fresh signal, low potential and no engagement → no bucket.
expect(result.total).toBe(0)
})
it('escopo desconhecido devolve erro sem consultar nada', async () => {
const find = vi.fn()
const result = (await execute(scriptedPayload(find))({
scope: 'Lugar Inexistente',
})) as { error: string }
expect(result.error).toContain('Escopo não reconhecido')
expect(find).not.toHaveBeenCalled()
})
it('sem municípios no escopo (assessor fora do portfólio) não consulta atualizações nem pledges', async () => {
const find = vi.fn().mockResolvedValue(findResult([]))
const result = (await execute(
scriptedPayload(find),
advisor,
)({
scope: 'Salvador',
})) as { escopoRestrito: boolean; total: number }
expect(result.escopoRestrito).toBe(true)
expect(result.total).toBe(0)
expect(find).toHaveBeenCalledTimes(1)
})
it('aplica limite com total real e devolve a dica de estreitar', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
municipalityDoc({ id: 1, name: 'A', slug: 'a', lastUpdateAt: '2026-06-01T12:00:00.000Z' }),
municipalityDoc({ id: 2, name: 'B', slug: 'b', lastUpdateAt: '2026-06-02T12:00:00.000Z' }),
]),
)
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([]))
const result = (await execute(scriptedPayload(find))({ limit: 1 })) as {
total: number
truncado: boolean
dica?: string
prioridades: unknown[]
}
expect(result.total).toBe(2)
expect(result.truncado).toBe(true)
expect(result.dica).toContain('Estreite o escopo')
expect(result.prioridades).toHaveLength(1)
})
it('passa motivo, ordenação e janela custom ao ranking', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([municipalityDoc({ id: 1, lastUpdateAt: '2026-06-01T12:00:00.000Z' })]),
)
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([]))
const result = (await execute(scriptedPayload(find))({
reason: 'estagnacao',
sortBy: 'potencial',
days: 60,
limit: 3,
})) as {
motivoAplicado: string | null
janelaDias: number
criterio: string
prioridades: unknown[]
}
expect(result.motivoAplicado).toBe('estagnacao')
expect(result.janelaDias).toBe(60)
expect(result.criterio).toContain('60 dias')
expect(result.prioridades).toHaveLength(1)
const updateCall = find.mock.calls[1]![0] as { where: { and: unknown[] } }
const createdAtClause = updateCall.where.and[1] as { createdAt: { greater_than: string } }
const cutoffMs = new Date(createdAtClause.createdAt.greater_than).getTime()
const expectedCutoffMs = Date.now() - 60 * 86_400_000
expect(Math.abs(cutoffMs - expectedCutoffMs)).toBeLessThan(5_000)
})
it('fallback dos válidos 2022 alimenta o potencial quando não há estimativa', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
municipalityDoc({
id: 10,
engagementLevel: 'n1',
expectedVotes: null,
lastUpdateAt: '2026-08-08T12:00:00.000Z',
}),
]),
)
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([]))
const result = (await execute(scriptedPayload(find))({})) as {
prioridades: Array<{ evidencia: string; fontePotencial: string | null }>
}
// "Amargosa" is a real catalog municipality — its 2022 valid votes come
// from the committed artifact; a non-catalog slug would yield null.
expect(result.prioridades[0]!.fontePotencial).toBe('validos_2022')
expect(result.prioridades[0]!.evidencia).toContain('válidos em 2022')
})
})