From 11886a653d38da40ab51011c359779b37cd1c309 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 07:57:24 -0300 Subject: [PATCH] =?UTF-8?q?feat(B186):=20Sollinha=20=E2=80=94=20prioridade?= =?UTF-8?q?s=20do=20momento=20(getMunicipalityPriorities)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/CHANGELOG-AGENTS.md | 1 + .../sollinha-prioridades-do-momento-impl.md | 94 ++++ src/utilities/ai/systemPrompt.ts | 7 + src/utilities/ai/tools/aiToolScope.ts | 4 + .../ai/tools/getMunicipalityPriorities.ts | 250 +++++++++++ src/utilities/ai/tools/index.ts | 2 + .../municipality/municipalityPriorities.ts | 365 ++++++++++++++++ tests/unit/codebaseConventions.unit.spec.ts | 3 + .../unit/municipalityPriorities.unit.spec.ts | 410 ++++++++++++++++++ tests/unit/prioritiesTool.unit.spec.ts | 289 ++++++++++++ 10 files changed, 1425 insertions(+) create mode 100644 docs/plans/sollinha-prioridades-do-momento-impl.md create mode 100644 src/utilities/ai/tools/getMunicipalityPriorities.ts create mode 100644 src/utilities/municipality/municipalityPriorities.ts create mode 100644 tests/unit/municipalityPriorities.unit.spec.ts create mode 100644 tests/unit/prioritiesTool.unit.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index d9c6bc3d..63227e80 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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 `
`:** 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). diff --git a/docs/plans/sollinha-prioridades-do-momento-impl.md b/docs/plans/sollinha-prioridades-do-momento-impl.md new file mode 100644 index 00000000..708c911a --- /dev/null +++ b/docs/plans/sollinha-prioridades-do-momento-impl.md @@ -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
leader → { error }] + G --> S[aiToolScope
escopo região/cidade/município/todos] + S --> Q1[find municipality
select estratégico + lastUpdateAt] + S --> Q2[find municipalityUpdate
janela: municipality in + createdAt >] + S --> Q3[aggregatePledgesByMunicipality
→ lastPledgeAt por id] + Q1 --> R[rankMunicipalityPriorities
módulo puro] + Q2 --> R + Q3 --> R + R --> E[buckets: sinal_desfavoravel >
estagnacao > potencial] + E --> OUT[{ escopo, criterio, janelaDias,
total, prioridades, truncado }] + OUT --> L[buildCampaignLinks
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) diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts index 101e5449..4af33b98 100644 --- a/src/utilities/ai/systemPrompt.ts +++ b/src/utilities/ai/systemPrompt.ts @@ -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 diff --git a/src/utilities/ai/tools/aiToolScope.ts b/src/utilities/ai/tools/aiToolScope.ts index 843fb3b6..2126d9ba 100644 --- a/src/utilities/ai/tools/aiToolScope.ts +++ b/src/utilities/ai/tools/aiToolScope.ts @@ -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 diff --git a/src/utilities/ai/tools/getMunicipalityPriorities.ts b/src/utilities/ai/tools/getMunicipalityPriorities.ts new file mode 100644 index 00000000..7bda5e75 --- /dev/null +++ b/src/utilities/ai/tools/getMunicipalityPriorities.ts @@ -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( + 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 } : {}), + } + }, + }) diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts index f5832f09..2d46405a 100644 --- a/src/utilities/ai/tools/index.ts +++ b/src/utilities/ai/tools/index.ts @@ -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), diff --git a/src/utilities/municipality/municipalityPriorities.ts b/src/utilities/municipality/municipalityPriorities.ts new file mode 100644 index 00000000..bbaaefbd --- /dev/null +++ b/src/utilities/municipality/municipalityPriorities.ts @@ -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 = { + 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, + updates: PriorityUpdateSignalInput[], + options: MunicipalityPriorityRankOptions, +): MunicipalityPriorityItem[] => { + const now = options.agora ?? new Date() + const cutoff = now.getTime() - options.janelaDias * DAY_MS + + const updatesByMunicipality = new Map() + for (const update of updates) { + const list = updatesByMunicipality.get(update.municipalityID) ?? [] + list.push(update) + updatesByMunicipality.set(update.municipalityID, list) + } + + const decisiveByMunicipality = new Map() + 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) +} diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 185ee867..6584da75 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/municipalityPriorities.unit.spec.ts b/tests/unit/municipalityPriorities.unit.spec.ts new file mode 100644 index 00000000..2037a87c --- /dev/null +++ b/tests/unit/municipalityPriorities.unit.spec.ts @@ -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 => ({ + 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 => ({ + 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 + 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') + }) +}) diff --git a/tests/unit/prioritiesTool.unit.spec.ts b/tests/unit/prioritiesTool.unit.spec.ts new file mode 100644 index 00000000..eeb82091 --- /dev/null +++ b/tests/unit/prioritiesTool.unit.spec.ts @@ -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 + +const findResult = (docs: unknown[]) => ({ docs }) + +const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload }) + +const leader = stub({ collection: 'campaignUser', role: 'leader' }) +const coordinator = stub({ collection: 'campaignUser', role: 'coordinator' }) +const advisor = stub({ collection: 'campaignUser', role: 'advisor' }) + +const scriptedPayload = (find: FindMock): Payload => + stub({ 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) => ({ + 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) => ({ + 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 +} + +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 + + 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 + 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> + } + + 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') + }) +})