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:
parent
9b1da7a09f
commit
11886a653d
10 changed files with 1425 additions and 0 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
94
docs/plans/sollinha-prioridades-do-momento-impl.md
Normal file
94
docs/plans/sollinha-prioridades-do-momento-impl.md
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
250
src/utilities/ai/tools/getMunicipalityPriorities.ts
Normal file
250
src/utilities/ai/tools/getMunicipalityPriorities.ts
Normal 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 } : {}),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
365
src/utilities/municipality/municipalityPriorities.ts
Normal file
365
src/utilities/municipality/municipalityPriorities.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
410
tests/unit/municipalityPriorities.unit.spec.ts
Normal file
410
tests/unit/municipalityPriorities.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
289
tests/unit/prioritiesTool.unit.spec.ts
Normal file
289
tests/unit/prioritiesTool.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue