mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #565 from fsolla/B189-sollinha-municipios-sem-atualizacao-recente-cobertura-d
B189 — Sollinha: municípios sem atualização recente (cobertura do acompanhamento)
This commit is contained in:
commit
9b1da7a09f
9 changed files with 754 additions and 108 deletions
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-10):** **B189** (Issue #526) — o Sollinha ganhou a tool **`getMunicipalitiesWithoutUpdate`** (`src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts`): "quais municípios estão sem atualização há mais de X dias" (cobertura do acompanhamento) no escopo do usuário, com **contagem total exaustiva** (sem top N — cobertura é "tudo além do limiar"), **limiar declarado na resposta** (`limiarDias` + `criterio` interpolado; default 30, ajustável por pergunta via `days` 1–365, estritamente maior), lista ordenada do mais velho ao mais recente com "nunca atualizado" no topo (estagnação máxima, sempre incluídos), itens com `diasSemAtualizacao`/`cidade`/`regiao`/`tipo` (ZE vs município inteiro)/**assessores** (nomes em query própria `campaignUser` — `name` não é identity-gated; nunca `depth`). **Fonte de recência = `municipality.lastUpdateAt`** (campo derivado pelos hooks de `municipalityUpdate`, admin read-only — a semântica compartilhada da família B185 "última atualização de campo", nunca `updatedAt` do município; sanity local provou create→coluna→delete→null via hooks); filtro de recência em JS com `municipalitySignalAgeInDays` (helper E9 reusado — coerência rótulo↔limiar sem boundary de instante no `where`). **Gate staff inline** (leader → `{ error: 'Leitura de municípios negada.' }` antes de qualquer query, shape da B180); leituras `overrideAccess: false, user: ctx.user` (assessor = portfólio automático); **guard fail-closed de escopo vazio** (assessor fora do portfólio → shape vazio escopado sem leitura). **Resolver de escopo extraído** da B185 para `src/utilities/ai/tools/aiToolScope.ts` (`resolveAIToolScope` — Salvador primeiro, município por alias, território acento-tolerante; 2º call site da família; nomes de tipos alinhados à extração em voo da B186 para o merge dela vir trivial; contrato da B185 inalterado, spec existente cobre a regressão) e **loader de nomes compartilhado** `aiToolQueries.ts` (fundiu `loadNamesByIds` da B185 com o lookup local — don't twin). Seção nova no system prompt: declarar sempre limiar+critério, resumir Salvador ("7 das 19 zonas sem atualização há mais de 30 dias") e agrupar por assessor quando pedido (mesmo precedente "o modelo agrupa pela lista" da B185). Unit tests novos (`municipalitiesWithoutUpdateTool.unit.spec.ts`, 15): gate fail-closed com payload intocado, critério + borda estrita (31→incluído/30→excluído/`days:15`), ordem (nunca por nome, depois mais velho→recente), escopos acento/Salvador/município/erro, ids do escopo no where, escopo vazio fail-closed, nomes de assessores, tipo ZE. Sem migration/access/Consent/UI. Intenção [`sollinha-municipios-sem-atualizacao-recente.md`](plans/sollinha-municipios-sem-atualizacao-recente.md); impl [`sollinha-municipios-sem-atualizacao-recente-impl.md`](plans/sollinha-municipios-sem-atualizacao-recente-impl.md).
|
||||
|
||||
**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).
|
||||
|
|
|
|||
112
docs/plans/sollinha-municipios-sem-atualizacao-recente-impl.md
Normal file
112
docs/plans/sollinha-municipios-sem-atualizacao-recente-impl.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Impl: Sollinha — municípios sem atualização recente (cobertura do acompanhamento)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #526
|
||||
Intenção: docs/plans/sollinha-municipios-sem-atualizacao-recente.md
|
||||
Appetite restante: ~0,5–1 dia eng (herdado); sem migration / Consent / collection / UI nova
|
||||
|
||||
## Registro de execução (2026-08-10)
|
||||
|
||||
- **Entrega:** `getMunicipalitiesWithoutUpdate` nova (`src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts`) + resolver compartilhado `aiToolScope.ts` + loader de nomes compartilhado `aiToolQueries.ts` + registro no `index.ts` + seção no system prompt; 15 unit tests (`tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts`). Gate humano 2026-08-10 aprovou o plano sem alterações; sanity local provou o contrato `lastUpdateAt` ↔ hooks (create → coluna = createdAt; delete → null).
|
||||
- **Divergência da hipótese da intenção (aprovada no gate):** fonte de recência = `municipality.lastUpdateAt` derivado (não agregar `municipalityUpdate` na tool); Salvador por ZE com resumo via prompt (não modo de agrupamento na tool).
|
||||
- **/simplify (3 reviewers paralelos) — fixes aplicados:**
|
||||
- **Colisão de merge com B186 (em voo):** as duas branches extraíram o mesmo resolver com nomes de tipo diferentes. B189 alinhou os nomes aos da B186 (`AIToolScope`/`AIToolScopeKind`/`AIToolScopeMunicipality`, helper interno `resolveMunicipalities`) — o merge da B186 (segundo) fica trivial. Função exportada `resolveAIToolScope` já coincidia.
|
||||
- `loadNamesByIds` (B185) e `loadAdvisorNames` (B189) fundidos em `aiToolQueries.ts` (`loadAIToolNamesByIds`, 2 call sites — don't twin).
|
||||
- Guard fail-closed de escopo vazio: assessor pedindo território fora do portfólio devolve shape vazio escopado sem leitura de municípios (precedente B186) + teste novo (find chamado 1×).
|
||||
- `sortStale` sem `?? 0` (partição garante ISO; `!` documentado) — type honesty.
|
||||
- Prompt: exemplo de Salvador com limiar estrito ("há mais de 30 dias"); `index.ts` na ordem alfabética da família.
|
||||
- **Deferidos (≤3 / polish, sem Issue):** cast `Array<number | string>` espelhando o shape de doc da B185; `ScopeMunicipality` privada (exportar quebraria knip); zod `.default(30)` + default no destructure (um para o pipeline, outro para execute direto).
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o Sollinha responde "quais municípios estão sem atualização há mais de X dias" no **escopo do usuário** (coordenador = estado; assessor = portfólio; leader = deny fail-closed), com contagem total, um item por município com "há N dias" (ou "nunca atualizado" no topo, estagnação máxima), limiar declarado na resposta ("sem atualização há 30+ dias") e ajustável por pergunta; refinável por região/cidade; agrupável por assessor quando pedido; links de navegação para o detalhe de cada município (B162).
|
||||
- **O que NÃO negociar:** leader lockdown (fail-closed antes de qualquer query — shape do gate da B180); assessor vê só o portfólio (RBAC atual das collections, `overrideAccess: false, user: ctx.user`); "sem atualização" mede **só** `municipalityUpdate` (semântica compartilhada da família B185 — nunca `updatedAt` do município); lista exaustiva com contagem (cobertura é "tudo além do limiar", não top N); limiar sempre visível na resposta; sem estimativas de votos (assimetria intacta); sem dados eleitorais; sem write.
|
||||
- **O que reavaliar:**
|
||||
1. A hipótese "fonte: `municipalityUpdate` (createdAt) × `municipality`" — o campo derivado **`municipality.lastUpdateAt`** (admin read-only, `index: true`, mantido pelos hooks de `MunicipalityUpdate` — `recomputeMunicipalityLastUpdateAt` — em create/update/delete) **já é** exatamente essa semântica, sempre consistente. Re-derivar agregando `municipalityUpdate` por município na tool seria gemiar lógica que o banco já mantém e divergir da coluna que a UI exibe. A tool lê `lastUpdateAt` direto.
|
||||
2. A recomendação da intenção de "agrupar Salvador por cidade por padrão com opção de detalhar por ZE" — tratado como decisão de forma (ver Abordagem): itens por ZE com `cidade: 'Salvador'` + instrução de prompt para o modelo resumir a capital ("Salvador: 7 das 19 zonas…"), mesmo precedente do corte "agregado na tool" da B185 (o modelo agrupa pela lista; sem modo de saída extra).
|
||||
3. Questões abertas da intenção: limiar default **30** dias (validado no gate; ajustável por `days` na pergunta); "sem atualização" = `lastUpdateAt` (derivado de `municipalityUpdate`) — opção (a) da intenção, coerente com a família.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[ai-chat route] --> B[buildAITools]
|
||||
B --> T[getMunicipalitiesWithoutUpdate ctx]
|
||||
T --> G[gate staff inline<br/>leader → { error }]
|
||||
G --> S[resolveAIToolScope<br/>município → Salvador → região]
|
||||
S --> Q1[find municipality<br/>escopo + access do usuário<br/>select lastUpdateAt/advisors/…]
|
||||
Q1 --> F[idade = ageInDays(lastUpdateAt)<br/>inclui null sempre; > days?]
|
||||
F --> Q2[find campaignUser<br/>nomes dos assessores]
|
||||
Q2 --> R[{ escopo, limiarDias, criterio,<br/>total, nuncaAtualizados, municipios }]
|
||||
R --> L[buildCampaignLinks<br/>municipality por slug]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Tool nova `getMunicipalitiesWithoutUpdate`** lendo `municipality.lastUpdateAt` (fonte única derivada), filtro de recência em JS sobre dias já arredondados (`municipalitySignalAgeInDays`), escopo via resolver compartilhado extraído da B185.
|
||||
- **B — Tool lendo `municipalityUpdate`** e agregando max(createdAt) por município na execução.
|
||||
- **C — Filtro de recência no `where`** (`lastUpdateAt: { less_than: cutoff }` + `exists: false`), com sort nativo.
|
||||
- **D — Reaproveitar `getPendingLeaderships`** (modo novo) em vez de tool nova.
|
||||
|
||||
**Recomendação: A** — a semântica de recência já vive em `municipality.lastUpdateAt` (o banco é o dono; a UI inteira — lista, dossier — lê esse campo; a tool leitura não re-deriva nada). O filtro em JS com `ageInDays` arredondado garante **coerência rótulo↔critério** ("há 30 dias" nunca aparece sob limiar "30+") sem depender de boundary de instante no `where`. **Rejeitadas:** B (gemia a derivação; divergiria de `lastUpdateAt` se os hooks mudassem; query mais cara), C (o `less_than` no instante cru e o rótulo em dias arredondados discordam na borda — item de 30,4 dias entraria no where mas rotularia "há 30 dias" sob um limiar declarado "mais de 30"), D (a B185 responde outro aceite — pendências de abordagem; o eixo aqui é cobertura por recência, e o modo esconderia a tool da descoberta do modelo).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`resolveAIToolScope`** (`src/utilities/ai/tools/aiToolScope.ts`, novo): extração da resolução de escopo da B185 (2º call site da família; B186 será o 3º — regra "edit the owner, don't twin"): `resolveScope`/`resolveMunicipalities`/`ScopeMunicipality`/`ResolvedScope` de `getPendingLeaderships.ts` movidos com nomes exportados (`resolveAIToolScope`, `AIToolScopeMunicipality`, `AIToolResolvedScope`), mesmo comportamento (Salvador primeiro → município via `resolveMunicipalityName` → território por `normalizeSearchPhrase` → erro "Escopo não reconhecido…"), `overrideAccess: false, user: ctx.user`.
|
||||
- **`getPendingLeaderships.ts`** (refactor): passar a importar o resolver compartilhado; remover as definições locais — comportamento idêntico, sem mudança de contrato (spec existente `pendingLeadershipsTool.unit.spec.ts` continua verde sem edição).
|
||||
- **`getMunicipalitiesWithoutUpdate`** (`src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts`, novo): factory `tool()` com input Zod `{ scope?: string, days?: number (1–365, default 30) }`; gate inline no topo do execute (2º call site do padrão staff — regra 2=inline: `if (!isStaffCampaignRole(ctx.user.role)) return { error: 'Leitura de municípios negada.' }`, shape do chat da B180); uma `find` em `municipality` (where do escopo + access automático do assessor; `select: { id, name, slug, city, region, kind, lastUpdateAt, advisors }`, `depth: 0`, `limit: 0`, `pagination: false`); partição e ordenação em JS; uma `find` em `campaignUser` para nomes dos assessores (`select: { name: true }` — `name` não é identity-gated, ver `canReadCampaignUserIdentity` aplica-se só a contact/email/phone).
|
||||
- **`index.ts`** (`src/utilities/ai/tools/index.ts`): registrar `getMunicipalitiesWithoutUpdate: getMunicipalitiesWithoutUpdate(ctx)`.
|
||||
- **`systemPrompt.ts`** (`src/utilities/ai/`): seção "Municípios sem atualização recente" — quando usar; **declarar sempre o limiar e o critério** devolvidos pela tool; "nunca atualizado" no topo; quando o escopo incluir Salvador, resumir a capital no início (ex.: "Salvador: 7 das 19 zonas…") e detalhar por ZE sob pedido; `escopoRestrito: true` → dizer que a lista é do portfólio; links via `buildCampaignLinks` (destination `municipality` por slug) para os citados.
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** nenhuma chave nova; gate fail-closed via `isStaffCampaignRole` (2 call sites — inline, sem extrair HOF); leituras delegadas ao access control (`canReadMunicipality` — assessor portfólio; leader nem chega a query).
|
||||
- **UI:** Impeccable A — N/A (resposta em texto no chat existente; sem superfície nova).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Forma: **lista exaustiva em texto no chat** com contagem (`total`), itens ordenados do mais velho ao mais recente (nunca atualizados no topo), cada item com `diasSemAtualizacao`/`nuncaAtualizado` e `cidade`/`regiao`/`assessores` para o modelo agrupar sob pedido ("agrupa por assessor" = o modelo agrupa pelos nomes). **Rejeitadas:** agrupamento por cidade/assessor como modo de saída da tool (o modelo agrupa pela lista — mesma leitura, precedente B185); top N com truncamento (contrato é exaustivo: "tudo além do limiar"; o modelo comprime listas longas e sugere estreitar o escopo); percentuais/estatísticas (anti-goal da intenção).
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
1. **Fonte de recência = `municipality.lastUpdateAt`.** Derivado pelos hooks de `MunicipalityUpdate` (create/update/delete → recompute do max createdAt por município), admin read-only, `index: true`. A tool nunca toca `municipalityUpdate`; nunca usa `municipality.updatedAt` (edição de config não é acompanhamento de campo — decisão (a) da intenção, família B185). Verificação na implementação: conferir na DB local que `lastUpdateAt` reflete o último `municipalityUpdate.createdAt` após create/delete (spec de hooks já cobre; sanity manual).
|
||||
2. **Critério e coerência rótulo↔limiar.** `dias = municipalitySignalAgeInDays(lastUpdateAt)` (floor, `DAY_MS` — helper puro da família E9, reusado; não duplicar a conta). Inclui: `dias === null` (nunca atualizado — sempre, estagnação máxima) ou `dias > days` (estritamente maior). **Rejeitada:** filtro no `where` por instante (`less_than: now - days*DAY_MS`) — discorda do rótulo arredondado na borda e mistura dois relógios. O where do `find` carrega só escopo (região/cidade/nome); recência é partição em JS sobre ≤435 docs — custo irrelevante.
|
||||
3. **Ordenação.** Partição "nunca atualizado" primeiro (nome asc como desempate determinístico), depois por `lastUpdateAt` asc (mais velho primeiro; desempate nome asc). Determinística e testável — a resposta do modelo segue a ordem devolvida.
|
||||
4. **Gate.** Inline (2 call sites da família hoje — 1 por tool): `isStaffCampaignRole` → senão `{ error: 'Leitura de municípios negada.' }`. **Rejeitada:** extrair HOF estilo `electionDataGate` (B185 registrou a mesma escolha; 2 sites ainda não justificam — revisitar na B186, 3º da família).
|
||||
5. **Escopo compartilhado.** Extração da B185 para `aiToolScope.ts` — comportamento idêntico (ordem Salvador→município→território, acento-tolerante), tipos nomeados. B185 passa a importar; contrato da tool da B185 inalterado (spec existente cobre a regressão).
|
||||
6. **Assessores.** `municipality.advisors` (hasMany → campaignUser, ids com depth 0) + lookup de nomes em `campaignUser` com `overrideAccess: false, user: ctx.user` (`name` é legível por qualquer campaign user; `canReadCampaignUserIdentity` só protege contact/email/phone). Item com `assessores: [{ id, nome }]` (nome `null` só se o usuário sumiu — mantém id, sem vazar vínculo fora do acesso porque a leitura já é do ator).
|
||||
7. **Topologia da resposta.** `{ escopo: { tipo, nome }, escopoRestrito, limiarDias, criterio, total, nuncaAtualizados, municipios: [...] }` — `total` = `municipios.length` (exaustivo; sem `truncado`); `nuncaAtualizados` = contagem da partição "nunca" (o modelo usa para o resumo de Salvador). `criterio` declara a definição com o limiar interpolado (ex.: "Municípios sem atualização de acompanhamento há mais de 30 dias (última atualização registrada); nunca atualizados contam como estagnação máxima.").
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / server** — `aiToolScope.ts` (extração) + refactor da B185 + `getMunicipalitiesWithoutUpdate.ts` + registro no `index.ts` + seção no `systemPrompt.ts` + unit tests novos (`tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts`):
|
||||
- gate: leader → `{ error: 'Leitura de municípios negada.' }` com payload stub **intocado**; coordinator/advisor passam;
|
||||
- critério: `lastUpdateAt` com 31+ dias sob `days: 30` → incluído; ≤30 dias → excluído; `null` → incluído sempre, no topo; `days: 15` respeitado; ordem: nunca primeiro, depois mais velho→recente;
|
||||
- escopo: "Vale do Jiquiriça" (sem acento) → where `region: 'Vale do Jiquiriçá'`; "Salvador" → where `city: 'Salvador'`; município por nome; escopo inválido → mensagem de erro; sem escopo → where `{}` com `overrideAccess: false, user: ctx.user`;
|
||||
- assessores: nomes populados via segunda query `campaignUser` (`vi.fn().mockResolvedValueOnce` sequencial);
|
||||
- shape: `total` = lista exaustiva; `nuncaAtualizados` correto; `limiarDias`/`criterio` presentes;
|
||||
- regressão: spec `pendingLeadershipsTool.unit.spec.ts` verde sem edição (extração não mudou contrato).
|
||||
2. **Gates** — `pnpm gate:fast` na iteração; sanity manual: `lastUpdateAt` reflete o último update (criar/excluir update na DB local e conferir a coluna); entrega com `pnpm push`.
|
||||
3. **Docs** — entrada curta em `docs/CHANGELOG-AGENTS.md` + este impl plan commitado.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não criar gate genérico HOF nem helper de recência novo (`municipalitySignalAgeInDays` existe — família E9; não duplicar).
|
||||
- Não tocar em `buildCampaignLinks`/`campaignNavigationUrls` (destinos `municipality`/`municipalityList` já existem; sem sort novo na lista).
|
||||
- Não usar `updatedAt` do município nem `votePledge.lastPledgeAt` (o sinal E9 é outra semântica — a intenção B189 fixa só `municipalityUpdate`).
|
||||
- Sem paginação/limite (`truncado`) — contrato exaustivo; sem estatísticas/percentuais.
|
||||
- Não mudar o shape `{ error }` do chat nem o route `api/ai-chat`.
|
||||
- Não editar outras Issues `in-progress`; não tocar migrations (nenhuma).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Extrair o resolver da B185 quebra a tool em produção?** Comportamento idêntico copiado com nomes exportados + spec existente da B185 cobre a regressão sem edição; refactor mecânico.
|
||||
- **Lista exaustiva longa (coordenador, DB esparso):** até ~435 docs de um `find` barato (indexado, select enxuto); o modelo comprime a resposta (prompt instrui: contagem + mais antigos + sugerir estreitar escopo) — contrato de produto mantido (total sempre presente).
|
||||
- **Borda rótulo↔limiar:** eliminada pela partição em JS com dias arredondados (Decisão 2) — teste unitário com 30,4 dias e `days: 30`.
|
||||
- **`lastUpdateAt` defasado na produção:** o hook recomputa em create/update/delete de updates; se algum dia um update for inserido fora do runtime (SQL direto), a coluna pode desatualizar — mesma janela de risco da UI (aceita; a família usa o mesmo campo).
|
||||
- **Salvador resumido pelo modelo:** cobertura de produto via prompt (item ZE com `cidade`); se o modelo falhar em resumir, a lista ZE-a-ZE ainda é exaustiva e correta — aceite não depende do resumo.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto (perguntas "quais municípios sem atualização há mais de X dias" no escopo do usuário, contagem total, item por município com "há N dias"/"nunca atualizado" no topo, limiar declarado e ajustável, refinável por região/cidade, agrupável por assessor, links B162, RBAC atual, leader deny fail-closed)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (overrideAccess: false + user; copy pt-BR; identificadores em inglês; sem migration/Consent; lista exaustiva com contagem; "sem atualização" = `municipalityUpdate` via `lastUpdateAt`)
|
||||
- [ ] Testes previstos: unit (gate fail-closed, critério + borda, ordem, escopo acento-tolerante, assessores, shape exaustivo, regressão B185)
|
||||
|
|
@ -45,6 +45,15 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
|
|||
- 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 as lideranças citadas (por id) e municípios (por slug); assessores (por id) quando o perfil permitir.
|
||||
|
||||
## Municípios sem atualização recente
|
||||
- Use a ferramenta "getMunicipalitiesWithoutUpdate" para "quais municípios estão sem atualização há mais de X dias", "quais municípios nunca foram atualizados" ou perguntas de cobertura do acompanhamento ("o que está ficando para trás no registro").
|
||||
- A resposta da ferramenta traz o limiar e o critério — declare-os sempre na sua resposta (ex.: "critério: municípios sem atualização de acompanhamento há mais de 30 dias; nunca atualizados contam como estagnação máxima"). Se o usuário pedir outro limiar na pergunta ("há mais de 15 dias?"), passe o valor em "days".
|
||||
- A lista vem ordenada: municípios nunca atualizados primeiro (rotule-os como "nunca atualizado"), depois do mais antigo ao mais recente. Quando a lista for longa, apresente a contagem total e os mais antigos, e sugira estreitar o escopo para ver o restante.
|
||||
- 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.
|
||||
- Ofereça links de navegação (buildCampaignLinks) para os municípios citados (por slug).
|
||||
|
||||
## Contexto eleitoral
|
||||
- A eleição para deputado federal usa o sistema proporcional de lista aberta.
|
||||
- O quociente eleitoral na Bahia em 2022 foi aproximadamente 210.000 votos.
|
||||
|
|
|
|||
27
src/utilities/ai/tools/aiToolQueries.ts
Normal file
27
src/utilities/ai/tools/aiToolQueries.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
|
||||
/**
|
||||
* Shared name lookups of the campaign intelligence tools (family B185/B189):
|
||||
* names are resolved in dedicated queries with the actor's own access — never
|
||||
* `depth` populating `campaignUser`/`contact`.
|
||||
*/
|
||||
export const loadAIToolNamesByIds = async (
|
||||
ctx: AIToolContext,
|
||||
collection: 'contact' | 'campaignUser',
|
||||
ids: number[],
|
||||
): Promise<Map<number, string>> => {
|
||||
const names = new Map<number, string>()
|
||||
if (ids.length === 0) return names
|
||||
const result = await ctx.payload.find({
|
||||
collection,
|
||||
where: { id: { in: ids } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
for (const doc of result.docs) names.set(doc.id, doc.name)
|
||||
return names
|
||||
}
|
||||
99
src/utilities/ai/tools/aiToolScope.ts
Normal file
99
src/utilities/ai/tools/aiToolScope.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Shared AI-tool scope resolution for the campaign-management tool family
|
||||
* (B185 `getPendingLeaderships`, B186 `getMunicipalityPriorities`, B189
|
||||
* `getMunicipalitiesWithoutUpdate`, …): a user-spoken scope ("Vale do
|
||||
* Jiquiriça", "Salvador", "Feira de Santana") is resolved into its
|
||||
* municipality set through the actor's own access. Extracted so the subtle
|
||||
* behavior (Salvador-primeiro, aliases canônicos, tolerância a acento) has
|
||||
* one owner instead of a copy per tool.
|
||||
*/
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { salvadorCity } from '@/lib/salvadorCity'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
type AIToolScopeKind = 'regiao' | 'cidade' | 'municipio' | 'todos'
|
||||
|
||||
type AIToolScopeMunicipality = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
city: string | null
|
||||
region: string | null
|
||||
}
|
||||
|
||||
export type AIToolScope = {
|
||||
kind: AIToolScopeKind
|
||||
name: string | null
|
||||
/** `null` = the actor's whole scope (access control narrows the queries). */
|
||||
municipalityIDs: number[] | null
|
||||
municipalities: AIToolScopeMunicipality[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the requested scope into its municipality set through the actor's
|
||||
* own access (an advisor asking outside the portfolio gets the empty scoped
|
||||
* set — never an existence lie). The generic error stays reserved for names
|
||||
* that match nothing at all.
|
||||
*/
|
||||
export const resolveAIToolScope = async (
|
||||
ctx: AIToolContext,
|
||||
scope: string | undefined,
|
||||
): Promise<AIToolScope | { error: string }> => {
|
||||
if (!scope) {
|
||||
return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
|
||||
}
|
||||
|
||||
// Salvador first: the virtual city is a first-class scope (19 ZE juntas) and
|
||||
// the catalog has no row literally named "Salvador" — the municipality query
|
||||
// would be a guaranteed-miss round trip.
|
||||
if (normalizeSearchPhrase(scope) === normalizeSearchPhrase(salvadorCity.city)) {
|
||||
const resolved = await resolveMunicipalities(ctx, { city: { equals: salvadorCity.city } })
|
||||
return { kind: 'cidade', name: salvadorCity.name, ...resolved }
|
||||
}
|
||||
|
||||
const canonicalMunicipality = resolveMunicipalityName(scope)
|
||||
if (canonicalMunicipality) {
|
||||
const resolved = await resolveMunicipalities(ctx, { name: { equals: canonicalMunicipality } })
|
||||
return { kind: 'municipio', name: canonicalMunicipality, ...resolved }
|
||||
}
|
||||
|
||||
const territory = bahiaIdentityTerritories.find(
|
||||
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(scope),
|
||||
)
|
||||
if (territory) {
|
||||
const resolved = await resolveMunicipalities(ctx, { region: { equals: territory } })
|
||||
return { kind: 'regiao', name: territory, ...resolved }
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Escopo não reconhecido: "${scope}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
|
||||
}
|
||||
}
|
||||
|
||||
const resolveMunicipalities = async (
|
||||
ctx: AIToolContext,
|
||||
where: Where,
|
||||
): Promise<{ municipalityIDs: number[]; municipalities: AIToolScopeMunicipality[] }> => {
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { id: true, name: true, slug: true, city: true, region: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
const municipalities = result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
}))
|
||||
return { municipalityIDs: municipalities.map((m) => m.id), municipalities }
|
||||
}
|
||||
206
src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts
Normal file
206
src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { tool } from 'ai'
|
||||
import type { Where } from 'payload'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { loadAIToolNamesByIds } from '@/utilities/ai/tools/aiToolQueries'
|
||||
import { resolveAIToolScope, type AIToolScope } from '@/utilities/ai/tools/aiToolScope'
|
||||
import { municipalitySignalAgeInDays } from '@/utilities/municipality/municipalitySignal'
|
||||
|
||||
const DENIED_MESSAGE = 'Leitura de municípios negada.'
|
||||
|
||||
type ScopedMunicipality = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
city: string | null
|
||||
region: string | null
|
||||
kind: string | null
|
||||
lastUpdateAt: string | null
|
||||
advisors: Array<number | string> | null
|
||||
}
|
||||
|
||||
type CoverageItem = {
|
||||
id: number
|
||||
nome: string
|
||||
slug: string
|
||||
tipo: string
|
||||
regiao: string | null
|
||||
cidade: string | null
|
||||
ultimaAtualizacao: string | null
|
||||
diasSemAtualizacao: number | null
|
||||
nuncaAtualizado: boolean
|
||||
assessores: Array<{ id: number; nome: string | null }>
|
||||
}
|
||||
|
||||
/**
|
||||
* B189 — "quais municípios estão sem atualização há mais de X dias" (cobertura
|
||||
* do acompanhamento): varre o escopo do usuário, aplica o limiar (default 30,
|
||||
* ajustável na pergunta) e devolve a lista EXAUSTIVA ordenada do mais velho ao
|
||||
* mais recente, com "nunca atualizado" no topo. Recência vem de
|
||||
* `municipality.lastUpdateAt` (derivado dos hooks de `municipalityUpdate` — a
|
||||
* semântica compartilhada da família B185; nunca `updatedAt` do município).
|
||||
* Staff-only (leader lockdown fail-closed, mesmo shape do gate eleitoral);
|
||||
* leituras com `overrideAccess: false, user: ctx.user` (assessor = portfólio).
|
||||
* Sem migration/Consent; nomes de assessores resolvidos em query manual.
|
||||
*/
|
||||
export const getMunicipalitiesWithoutUpdate = (ctx: AIToolContext) =>
|
||||
tool({
|
||||
description:
|
||||
'Returns municipalities in scope without a recent follow-up update: last municipalityUpdate ' +
|
||||
'older than N days, or never updated (listed first, stagnation). ' +
|
||||
'Use when the user asks "Quais municípios estão sem atualização há mais de X dias?", ' +
|
||||
'"Quais municípios nunca foram atualizados?" or "Cobertura do acompanhamento em X?". ' +
|
||||
'The threshold 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.',
|
||||
),
|
||||
days: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.optional()
|
||||
.default(30)
|
||||
.describe(
|
||||
'Limiar em dias: municípios sem atualização há mais de N dias (estritamente maior). Default 30.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ scope, days = 30 }) => {
|
||||
if (!isStaffCampaignRole(ctx.user.role)) return { error: DENIED_MESSAGE }
|
||||
|
||||
const resolved = await resolveAIToolScope(ctx, scope)
|
||||
if ('error' in resolved) return resolved
|
||||
|
||||
// An advisor asking for a territory outside the portfolio gets the empty
|
||||
// scoped set — never an existence lie, and no wasted municipality read.
|
||||
if (resolved.municipalityIDs !== null && resolved.municipalityIDs.length === 0) {
|
||||
return {
|
||||
escopo: { tipo: resolved.kind, nome: resolved.name },
|
||||
escopoRestrito: ctx.user.role === 'advisor',
|
||||
limiarDias: days,
|
||||
criterio: buildCriterion(days),
|
||||
total: 0,
|
||||
nuncaAtualizados: 0,
|
||||
municipios: [],
|
||||
}
|
||||
}
|
||||
|
||||
const docs = await loadScopeMunicipalities(ctx, resolved)
|
||||
const items = docs
|
||||
.map(buildCoverageItem)
|
||||
.filter((item) => item.diasSemAtualizacao === null || item.diasSemAtualizacao > days)
|
||||
|
||||
const [neverUpdated, stale] = partitionByStagnation(items)
|
||||
const municipios = [...sortNeverUpdated(neverUpdated), ...sortStale(stale)]
|
||||
|
||||
const advisorIDs = uniqueRelationshipIds(
|
||||
municipios.flatMap((item) => item.assessores.map((a) => a.id)),
|
||||
)
|
||||
const advisorNames = await loadAIToolNamesByIds(ctx, 'campaignUser', advisorIDs)
|
||||
for (const item of municipios) {
|
||||
item.assessores = item.assessores.map((advisor) => ({
|
||||
id: advisor.id,
|
||||
nome: advisorNames.get(advisor.id) ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
escopo: { tipo: resolved.kind, nome: resolved.name },
|
||||
escopoRestrito: ctx.user.role === 'advisor',
|
||||
limiarDias: days,
|
||||
criterio: buildCriterion(days),
|
||||
total: municipios.length,
|
||||
nuncaAtualizados: neverUpdated.length,
|
||||
municipios,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const buildCriterion = (days: number): string =>
|
||||
`Municípios sem atualização de acompanhamento há mais de ${days} dias (última atualização registrada); nunca atualizados contam como estagnação máxima.`
|
||||
|
||||
const loadScopeMunicipalities = async (
|
||||
ctx: AIToolContext,
|
||||
scope: AIToolScope,
|
||||
): Promise<ScopedMunicipality[]> => {
|
||||
const where: Where = scope.municipalityIDs ? { id: { in: scope.municipalityIDs } } : {}
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
city: true,
|
||||
region: true,
|
||||
kind: true,
|
||||
lastUpdateAt: true,
|
||||
advisors: true,
|
||||
},
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
return result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
kind: doc.kind ?? null,
|
||||
lastUpdateAt: doc.lastUpdateAt ?? null,
|
||||
advisors: (Array.isArray(doc.advisors) ? doc.advisors : null) as Array<number | string> | null,
|
||||
}))
|
||||
}
|
||||
|
||||
const buildCoverageItem = (doc: ScopedMunicipality): CoverageItem => ({
|
||||
id: doc.id,
|
||||
nome: doc.name,
|
||||
slug: doc.slug,
|
||||
tipo: doc.kind === 'zona' ? 'Zona eleitoral (Salvador)' : 'Município inteiro',
|
||||
regiao: doc.region,
|
||||
cidade: doc.city,
|
||||
ultimaAtualizacao: doc.lastUpdateAt,
|
||||
diasSemAtualizacao: municipalitySignalAgeInDays(doc.lastUpdateAt),
|
||||
nuncaAtualizado: doc.lastUpdateAt === null,
|
||||
assessores: (Array.isArray(doc.advisors) ? doc.advisors : [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
.map((id) => ({ id, nome: null })),
|
||||
})
|
||||
|
||||
const partitionByStagnation = (items: CoverageItem[]): [CoverageItem[], CoverageItem[]] => {
|
||||
const neverUpdated: CoverageItem[] = []
|
||||
const stale: CoverageItem[] = []
|
||||
for (const item of items) {
|
||||
if (item.nuncaAtualizado) neverUpdated.push(item)
|
||||
else stale.push(item)
|
||||
}
|
||||
return [neverUpdated, stale]
|
||||
}
|
||||
|
||||
const byName = (left: CoverageItem, right: CoverageItem): number =>
|
||||
left.nome.localeCompare(right.nome, 'pt-BR')
|
||||
|
||||
const sortNeverUpdated = (items: CoverageItem[]): CoverageItem[] => [...items].sort(byName)
|
||||
|
||||
const sortStale = (items: CoverageItem[]): CoverageItem[] =>
|
||||
[...items].sort((left, right) => {
|
||||
// The stale partition never contains never-updated items, so the ISO
|
||||
// timestamp is always present here.
|
||||
const leftAt = new Date(left.ultimaAtualizacao!).getTime()
|
||||
const rightAt = new Date(right.ultimaAtualizacao!).getTime()
|
||||
if (leftAt !== rightAt) return leftAt - rightAt
|
||||
return byName(left, right)
|
||||
})
|
||||
|
|
@ -3,12 +3,13 @@ import type { Where } from 'payload'
|
|||
import { z } from 'zod'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { salvadorCity } from '@/lib/salvadorCity'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import { loadAIToolNamesByIds } from '@/utilities/ai/tools/aiToolQueries'
|
||||
import {
|
||||
resolveAIToolScope,
|
||||
type AIToolScope as ResolvedScope,
|
||||
} from '@/utilities/ai/tools/aiToolScope'
|
||||
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
|
||||
|
||||
const DENIED_MESSAGE = 'Leitura de dados de lideranças negada.'
|
||||
|
|
@ -18,21 +19,6 @@ const EMPTY_MUNICIPALITIES_CRITERION = 'Municípios do escopo sem nenhuma lidera
|
|||
const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.'
|
||||
const PENDING_SUPPORT_STATUSES = ['a_abordar', 'em_disputa', 'engajado'] as const
|
||||
|
||||
type ScopeMunicipality = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
city: string | null
|
||||
region: string | null
|
||||
}
|
||||
|
||||
type ResolvedScope = {
|
||||
kind: 'regiao' | 'cidade' | 'municipio' | 'todos'
|
||||
name: string | null
|
||||
municipalityIDs: number[] | null
|
||||
municipalities: ScopeMunicipality[]
|
||||
}
|
||||
|
||||
type PendingLeadershipDoc = {
|
||||
id: number
|
||||
contact: number | string | null
|
||||
|
|
@ -90,7 +76,7 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
|
|||
execute: async ({ scope, filter, mode, limit }) => {
|
||||
if (!isStaffCampaignRole(ctx.user.role)) return { error: DENIED_MESSAGE }
|
||||
|
||||
const resolved = await resolveScope(ctx, scope)
|
||||
const resolved = await resolveAIToolScope(ctx, scope)
|
||||
if ('error' in resolved) return resolved
|
||||
|
||||
if (mode === 'municipios_sem_lideranca') {
|
||||
|
|
@ -104,71 +90,6 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
|
|||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the requested scope into its municipality set through the actor's
|
||||
* own access (an advisor asking outside the portfolio gets the empty scoped
|
||||
* set — never an existence lie). The generic error stays reserved for names
|
||||
* that match nothing at all.
|
||||
*/
|
||||
const resolveScope = async (
|
||||
ctx: AIToolContext,
|
||||
scope: string | undefined,
|
||||
): Promise<ResolvedScope | { error: string }> => {
|
||||
if (!scope) {
|
||||
return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
|
||||
}
|
||||
|
||||
// Salvador first: the virtual city is a first-class scope (19 ZE juntas) and
|
||||
// the catalog has no row literally named "Salvador" — the municipality query
|
||||
// would be a guaranteed-miss round trip.
|
||||
if (normalizeSearchPhrase(scope) === normalizeSearchPhrase(salvadorCity.city)) {
|
||||
const resolved = await resolveMunicipalities(ctx, { city: { equals: salvadorCity.city } })
|
||||
return { kind: 'cidade', name: salvadorCity.name, ...resolved }
|
||||
}
|
||||
|
||||
const canonicalMunicipality = resolveMunicipalityName(scope)
|
||||
if (canonicalMunicipality) {
|
||||
const resolved = await resolveMunicipalities(ctx, { name: { equals: canonicalMunicipality } })
|
||||
return { kind: 'municipio', name: canonicalMunicipality, ...resolved }
|
||||
}
|
||||
|
||||
const territory = bahiaIdentityTerritories.find(
|
||||
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(scope),
|
||||
)
|
||||
if (territory) {
|
||||
const resolved = await resolveMunicipalities(ctx, { region: { equals: territory } })
|
||||
return { kind: 'regiao', name: territory, ...resolved }
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Escopo não reconhecido: "${scope}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
|
||||
}
|
||||
}
|
||||
|
||||
const resolveMunicipalities = async (
|
||||
ctx: AIToolContext,
|
||||
where: Where,
|
||||
): Promise<{ municipalityIDs: number[]; municipalities: ScopeMunicipality[] }> => {
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { id: true, name: true, slug: true, city: true, region: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
const municipalities = result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
}))
|
||||
return { municipalityIDs: municipalities.map((m) => m.id), municipalities }
|
||||
}
|
||||
|
||||
const listPendingLeaderships = async (
|
||||
ctx: AIToolContext,
|
||||
scope: ResolvedScope,
|
||||
|
|
@ -243,9 +164,9 @@ const listPendingLeaderships = async (
|
|||
const advisorIDs = uniqueRelationshipIds(top.flatMap((doc) => doc.advisors ?? []))
|
||||
|
||||
const [contactNames, municipalityNames, advisorNames] = await Promise.all([
|
||||
loadNamesByIds(ctx, 'contact', contactIDs),
|
||||
loadAIToolNamesByIds(ctx, 'contact', contactIDs),
|
||||
loadMunicipalityNamesByIds(ctx, municipalityIDs),
|
||||
loadNamesByIds(ctx, 'campaignUser', advisorIDs),
|
||||
loadAIToolNamesByIds(ctx, 'campaignUser', advisorIDs),
|
||||
])
|
||||
|
||||
const liderancas = top.map((doc) => {
|
||||
|
|
@ -359,27 +280,6 @@ const listMunicipalitiesWithoutLeadership = async (
|
|||
}
|
||||
}
|
||||
|
||||
const loadNamesByIds = async (
|
||||
ctx: AIToolContext,
|
||||
collection: 'contact' | 'campaignUser',
|
||||
ids: number[],
|
||||
): Promise<Map<number, string>> => {
|
||||
const names = new Map<number, string>()
|
||||
if (ids.length === 0) return names
|
||||
const result = await ctx.payload.find({
|
||||
collection,
|
||||
where: { id: { in: ids } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
for (const doc of result.docs) names.set(doc.id, doc.name)
|
||||
return names
|
||||
}
|
||||
|
||||
const loadMunicipalityNamesByIds = async (
|
||||
ctx: AIToolContext,
|
||||
ids: number[],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { calculate } from '@/utilities/ai/tools/calculate'
|
|||
import { getDobradinhas } from '@/utilities/ai/tools/getDobradinhas'
|
||||
import { getLeaderships } from '@/utilities/ai/tools/getLeaderships'
|
||||
import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
|
||||
import { getMunicipalitiesWithoutUpdate } from '@/utilities/ai/tools/getMunicipalitiesWithoutUpdate'
|
||||
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
|
||||
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
|
||||
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
|
||||
|
|
@ -27,6 +28,7 @@ export const buildAITools = (ctx: AIToolContext) => ({
|
|||
getTopDeputies: getTopDeputies(ctx),
|
||||
getLeadingMunicipalities: getLeadingMunicipalities(ctx),
|
||||
getDobradinhas: getDobradinhas(ctx),
|
||||
getMunicipalitiesWithoutUpdate: getMunicipalitiesWithoutUpdate(ctx),
|
||||
getMunicipalityOverview: getMunicipalityOverview(ctx),
|
||||
getLeaderships: getLeaderships(ctx),
|
||||
getPendingLeaderships: getPendingLeaderships(ctx),
|
||||
|
|
|
|||
289
tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts
Normal file
289
tests/unit/municipalitiesWithoutUpdateTool.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 { DAY_MS } from '@/lib/text'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getMunicipalitiesWithoutUpdate } from '@/utilities/ai/tools/getMunicipalitiesWithoutUpdate'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
const denied = { error: 'Leitura 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: 1,
|
||||
name: 'Amargosa',
|
||||
slug: 'amargosa',
|
||||
city: 'Amargosa',
|
||||
region: 'Vale do Jiquiriçá',
|
||||
kind: 'municipio',
|
||||
lastUpdateAt: null,
|
||||
advisors: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const daysAgo = (days: number): string => new Date(Date.now() - days * DAY_MS).toISOString()
|
||||
|
||||
type ExecutableTool = {
|
||||
execute: (args: unknown, options?: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
const execute =
|
||||
(payload: Payload, user: CampaignUser = coordinator) =>
|
||||
(args: unknown) =>
|
||||
(getMunicipalitiesWithoutUpdate(ctxFor(user, payload)) as unknown as ExecutableTool).execute!(
|
||||
args,
|
||||
)
|
||||
|
||||
const run = async (
|
||||
find: FindMock,
|
||||
args: unknown,
|
||||
user: CampaignUser = coordinator,
|
||||
): Promise<Record<string, unknown>> =>
|
||||
(await execute(scriptedPayload(find), user)(args)) as Record<string, unknown>
|
||||
|
||||
describe('getMunicipalitiesWithoutUpdate gate (B189)', () => {
|
||||
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 run(find, {})
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.municipios).toEqual([])
|
||||
expect(result.escopoRestrito).toBe(false)
|
||||
expect(result.limiarDias).toBe(30)
|
||||
expect(result.criterio).toContain('há mais de 30 dias')
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: 'municipality',
|
||||
where: {},
|
||||
overrideAccess: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('lets an advisor pass (RBAC atual via access control, não pelo gate)', async () => {
|
||||
const find = vi.fn().mockResolvedValue(findResult([]))
|
||||
const result = await run(find, {}, advisor)
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.escopoRestrito).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMunicipalitiesWithoutUpdate coverage criterion (B189)', () => {
|
||||
it('includes never-updated (top) and municipalities older than the threshold; excludes fresher ones', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
findResult([
|
||||
municipalityDoc({ id: 1, lastUpdateAt: null, name: 'Nunca' }),
|
||||
municipalityDoc({ id: 2, lastUpdateAt: daysAgo(40), name: 'Velho' }),
|
||||
municipalityDoc({ id: 3, lastUpdateAt: daysAgo(31), name: 'Borda incluída' }),
|
||||
municipalityDoc({ id: 4, lastUpdateAt: daysAgo(30), name: 'Borda excluída' }),
|
||||
municipalityDoc({ id: 5, lastUpdateAt: daysAgo(5), name: 'Fresco' }),
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await run(find, {})
|
||||
const ids = (result.municipios as Array<{ id: number }>).map((m) => m.id)
|
||||
|
||||
expect(result.total).toBe(3)
|
||||
expect(result.nuncaAtualizados).toBe(1)
|
||||
expect(ids).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('respects a custom threshold from the question (strictly greater)', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
findResult([
|
||||
municipalityDoc({ id: 1, lastUpdateAt: daysAgo(20) }),
|
||||
municipalityDoc({ id: 2, lastUpdateAt: daysAgo(16) }),
|
||||
municipalityDoc({ id: 3, lastUpdateAt: daysAgo(15) }),
|
||||
municipalityDoc({ id: 4, lastUpdateAt: daysAgo(14) }),
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await run(find, { days: 15 })
|
||||
const ids = (result.municipios as Array<{ id: number }>).map((m) => m.id)
|
||||
expect(ids).toEqual([1, 2])
|
||||
expect(result.limiarDias).toBe(15)
|
||||
expect(result.criterio).toContain('há mais de 15 dias')
|
||||
})
|
||||
|
||||
it('orders never-updated first (by name), then oldest to newest (by name as tie-break)', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
findResult([
|
||||
municipalityDoc({ id: 1, lastUpdateAt: daysAgo(40), name: 'Beta' }),
|
||||
municipalityDoc({ id: 2, lastUpdateAt: null, name: 'Zulu' }),
|
||||
municipalityDoc({ id: 3, lastUpdateAt: daysAgo(40), name: 'Alpha' }),
|
||||
municipalityDoc({ id: 4, lastUpdateAt: null, name: 'Mike' }),
|
||||
municipalityDoc({ id: 5, lastUpdateAt: daysAgo(32), name: 'Gama' }),
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await run(find, {})
|
||||
const names = (result.municipios as Array<{ nome: string }>).map((m) => m.nome)
|
||||
expect(names).toEqual(['Mike', 'Zulu', 'Alpha', 'Beta', 'Gama'])
|
||||
})
|
||||
|
||||
it('labels never-updated items with null days and the stagnation flag', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
findResult([municipalityDoc({ id: 1, lastUpdateAt: null, name: 'Nunca' })]),
|
||||
)
|
||||
|
||||
const result = await run(find, {})
|
||||
const item = (result.municipios as Array<Record<string, unknown>>)[0]!
|
||||
expect(item.diasSemAtualizacao).toBeNull()
|
||||
expect(item.nuncaAtualizado).toBe(true)
|
||||
expect(item.ultimaAtualizacao).toBeNull()
|
||||
expect(item.tipo).toBe('Município inteiro')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMunicipalitiesWithoutUpdate scope resolution (B189)', () => {
|
||||
it('resolves a region scope without accents into the region where', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(findResult([municipalityDoc({ id: 1, region: 'Vale do Jiquiriçá' })]))
|
||||
const result = await run(find, { scope: 'Vale do Jiquiriça' })
|
||||
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { region: { equals: 'Vale do Jiquiriçá' } },
|
||||
}),
|
||||
)
|
||||
expect(find).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [1] } } }))
|
||||
})
|
||||
|
||||
it('resolves "Salvador" into the 19 zones as one city scope', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([municipalityDoc({ id: 1 }), municipalityDoc({ id: 2, name: 'Salvador ZE 2' })]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
const result = await run(find, { scope: 'Salvador' })
|
||||
expect(result.escopo).toEqual({ tipo: 'cidade', nome: 'Salvador (cidade)' })
|
||||
expect(find).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ where: { city: { equals: 'Salvador' } } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a municipality by name', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(findResult([municipalityDoc({ id: 1 })]))
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
const result = await run(find, { scope: 'amargosa' })
|
||||
expect(result.escopo).toEqual({ tipo: 'municipio', nome: 'Amargosa' })
|
||||
})
|
||||
|
||||
it('rejects an unknown scope with the family error message', async () => {
|
||||
const find = vi.fn()
|
||||
const result = await run(find, { scope: 'Atlântida' })
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Escopo não reconhecido') })
|
||||
expect(find).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed with the empty scoped set for an advisor asking outside the portfolio (no municipality read)', async () => {
|
||||
const find = vi.fn().mockResolvedValue(findResult([]))
|
||||
const result = await run(find, { scope: 'Vale do Jiquiriça' }, advisor)
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.nuncaAtualizados).toBe(0)
|
||||
expect(result.municipios).toEqual([])
|
||||
expect(result.escopoRestrito).toBe(true)
|
||||
expect(find).toHaveBeenCalledTimes(1)
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { region: { equals: 'Vale do Jiquiriçá' } } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('filters the scope query by municipality ids when a scope resolved them', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(findResult([municipalityDoc({ id: 1 }), municipalityDoc({ id: 2 })]))
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
await run(find, { scope: 'Salvador' }, advisor)
|
||||
expect(find).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: { id: { in: [1, 2] } },
|
||||
overrideAccess: false,
|
||||
user: advisor,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMunicipalitiesWithoutUpdate advisors (B189)', () => {
|
||||
it('resolves advisor names via a second campaignUser query and attaches them to items', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([municipalityDoc({ id: 1, advisors: [41, 42] }), municipalityDoc({ id: 2 })]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([{ id: 41, name: 'Ana Assessora' }]))
|
||||
|
||||
const result = await run(find, {})
|
||||
const items = result.municipios as Array<{
|
||||
assessores: Array<{ id: number; nome: string | null }>
|
||||
}>
|
||||
expect(items[0]!.assessores).toEqual([
|
||||
{ id: 41, nome: 'Ana Assessora' },
|
||||
{ id: 42, nome: null },
|
||||
])
|
||||
expect(items[1]!.assessores).toEqual([])
|
||||
expect(find).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
collection: 'campaignUser',
|
||||
where: { id: { in: [41, 42] } },
|
||||
select: { name: true },
|
||||
overrideAccess: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps zone items typed as electoral zones', async () => {
|
||||
const find = vi.fn().mockResolvedValue(
|
||||
findResult([
|
||||
municipalityDoc({
|
||||
id: 1,
|
||||
kind: 'zona',
|
||||
name: 'Salvador ZE 3',
|
||||
city: 'Salvador',
|
||||
region: 'Metropolitano de Salvador',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
const result = await run(find, {})
|
||||
const item = (result.municipios as Array<Record<string, unknown>>)[0]!
|
||||
expect(item.tipo).toBe('Zona eleitoral (Salvador)')
|
||||
expect(item.cidade).toBe('Salvador')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue