From 189ad7a349011ca1a545884728efee51ac5cbbef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 13:01:43 -0300 Subject: [PATCH] =?UTF-8?q?E11:=20suggestion=20engine=20v1=20=E2=80=94=20t?= =?UTF-8?q?he=20human-in-the-loop=20decision=20queue.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + AGENTS.md | 2 + docs/plans/motor-de-sugestoes.md | 41 +- docs/roadmap.md | 30 +- .../[slug]/MunicipalityDetailTabs.tsx | 54 + src/app/(campaign)/campanha/(app)/page.tsx | 51 + .../campanha/(app)/suggestionFormActions.ts | 56 + .../(campaign)/campanha/actions/suggestion.ts | 145 + src/collections/AllocationDecision.ts | 5 +- .../campaign/dashboard/CampaignDashboard.tsx | 5 + .../campaign/suggestion/SuggestionCard.tsx | 348 + .../campaign/suggestion/SuggestionsPanel.tsx | 151 + src/lib/campaignIntelligenceConcepts.ts | 31 + src/lib/schemas/suggestion.ts | 51 + src/lib/suggestionCatalog.ts | 654 + ...dd_allocation_decision_adiada_outcome.json | 12002 ++++++++++++++++ ..._add_allocation_decision_adiada_outcome.ts | 14 + src/migrations/index.ts | 6 + src/payload-types.ts | 2 +- src/utilities/municipalitySignal.ts | 8 + src/utilities/municipalityTriggers.ts | 448 + src/utilities/visitEligibility.ts | 9 +- src/utilities/visitPlannerData.ts | 4 +- tests/int/campaignSuggestions.int.spec.ts | 446 + tests/unit/suggestionCatalog.unit.spec.ts | 468 + 25 files changed, 15007 insertions(+), 25 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/suggestionFormActions.ts create mode 100644 src/app/(campaign)/campanha/actions/suggestion.ts create mode 100644 src/components/campaign/suggestion/SuggestionCard.tsx create mode 100644 src/components/campaign/suggestion/SuggestionsPanel.tsx create mode 100644 src/lib/schemas/suggestion.ts create mode 100644 src/lib/suggestionCatalog.ts create mode 100644 src/migrations/20260728_041547_add_allocation_decision_adiada_outcome.json create mode 100644 src/migrations/20260728_041547_add_allocation_decision_adiada_outcome.ts create mode 100644 src/utilities/municipalityTriggers.ts create mode 100644 tests/int/campaignSuggestions.int.spec.ts create mode 100644 tests/unit/suggestionCatalog.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index a669815d..7f2147a5 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **E11 entregue em código (2026-07-28):** motor de sugestões v1 — os 8 padrões de maior confiança do relatório §6.1 (P1/P2/P3/P5/P6/P7/K-A/K-B) viram uma fila de decisão com humano no loop: painel "Sugestões" no Início staff (top-5 por triagem + pauta do silêncio) e card na visão geral do município, aceitar/adiar/descartar gravando `allocationDecision`. **Arquitetura em três camadas:** `src/lib/suggestionCatalog.ts` (client-safe — predicados puros sobre `MunicipalityTriggerInput`, um tipo só de números/flags já derivados, + a copy exata dos cards: leitura provável, "descartes" que viram quick-picks do formulário de descarte, menu ordenado barato-antes-de-caro, contraindicação, estatuto; âncoras ilustrativas num objeto interno `SUGGESTION_ANCHORS`, calibração = E15); `src/utilities/municipalityTriggers.ts` (`server-only`, molde do `visitPlannerData.ts` do E13: leitura própria de `municipality` porque o select do scope compartilhado não tem `engagementLevel`/`lastUpdateAt`, + 6 leituras em `Promise.all` — pledges, `countLeadershipsByMunicipality` exportado do planner, metas `cache()`, sinais `kind: 'sinal'` ≤28d, atividades ≥now−42d, últimas decisões ≤90d — e LQ por ano com a fórmula EXATA do classificador E10, sem uma terceira escrita); `resolveSuggestionRecord` (action) que **reavalia o padrão no servidor** antes de gravar — o snapshot registra os fatores do momento da decisão, nunca os do render, e padrão fora da fila recusa com `SUGGESTION_STALE_MESSAGE` (safe message) em vez de registrar decisão sobre fila que andou; lock `municipality-suggestion:{id}:{patternId}` **antes** de reler a última decisão (dois staff no mesmo card serializam). **A auditoria matou o "sem migration" do plano:** o enum real de `outcome` era `aceita|descarta|movimento` — os valores `descartada|adiada` que o plano citava nunca existiram — então "Adiar" custou a migration `20260728_041547_add_allocation_decision_adiada_outcome` (precedente exato do `movimento` do E14); adiamento registra `postponeDays` derivado do nível recomputado (7d; 14d nos níveis 4–5) e `suppressUntil` no snapshot, sem coluna nova. **Supressão é pura e unit-testada** (`isSuggestionSuppressedByDecision`, no catálogo): aceita 14d, descarta 30d, adiada até o snooze (snapshot malformado cai no adiamento mínimo, nunca "para sempre") — com a regra "nível 1 fura a fila": gatilho que volta como risco confirmado atravessa supressão registrada em urgência menor. **Diferencial estrutural codificado (§6.3):** P2 cede a P3 (correligionário segura o campo) e a K-B (série diz perdida); P7 cede a K-A quando há atividade realizada no ciclo — nunca dois veredictos sobre o mesmo dado. **P3 dispara só pelo artefato** (campo ≥ 35% dos válidos + fatia intracampo < 0,5× o padrão estadual próprio + gate de volume na mediana): `runningAgain2026` existe mas está 100% `desconhecido` e sem read path, então "concorre de novo?" é o 1º item do menu (inteligência), não gatilho. **Pauta do silêncio** = alta/N2+ sem gatilho **disparado** (supressão não é silêncio — decisão registrada é resposta) e sem sinal ≥30d; inclui `priority: alta` porque E14 nasceu sem backfill. UI: submit explícito nos três desfechos (exceção prevista em `campanha-edit-where-you-see` para flows com nota/confirmação — aceite carrega a ação do menu escolhida + nota, descarte exige leitura alternativa com quick-picks; adiamento é um toque com a duração no rótulo), radios nativos (`accent-primary` — primeiro radio precedente no repo), foco gerenciado na troca trigger↔form (abrir foca o primeiro campo, cancelar devolve ao trigger — verificado no browser), só nível 1 usa `destructive`, estatuto no rodapé do painel. **Um leak de base CSS achado no craft:** o `ul { my-6 ml-6 list-disc }` do artigo público vaza para superfícies campaign — a lista de fatores precisou do reset `m-0 list-none p-0 [&>li]:mt-0` (mesmo remédio do B20). **Testes com taxa medida:** os padrões ancorados no artefato não são encenáveis por linhas do banco, então os int tests que precisam de propriedade de artefato (quartil superior do P6, município quieto do silêncio) alocam municípios pelo alocador das fixtures até um qualificar — slug explícito colidiria com o alocador (sequence com wrap, ~23% de colisão por run); taxas medidas com script sobre o artefato: 109/435 quartil superior, 205/435 quietos, e **reduto+coreBlock são só 13/435**, o que tirou P1 do alcance de int (predicado fica pinado nos 45 casos unit; a costura sinais→P1 foi verificada no browser com dados reais do dev, que têm sinais de adversário). Verbetes E18 `triagem-de-sugestoes` e `pauta-do-silencio`. First Load JS `/campanha` 268 → 277 kB (catálogo + ilha do card, medido pós-simplify). Suíte 453 (45 unit novos + 9 int novos) em ~84s; **as falhas rotativas de timeout na suíte cheia eram contenção de ambiente** (outra worktree Cursor subiu dev server no meio das runs — árvore limpa 70s e a minha 84s, ambas verdes com a máquina calma). Aikido 0 findings em todos os arquivos com lógica nova. **Adjacentes registrados no plano:** ligar o override do "não vá" do compositor de giros (E13) ao `resolveSuggestion`; fase 2 do catálogo (T/I/J, P4/P8–P12, K-C) com gatilho de 2 semanas de uso + 20 decisões; rota dedicada se >15 sugestões ativas recorrentes. **Pós-`/simplify` (mesma sessão):** o achado que manda era de perf — o dashboard varria `votePledge` **duas vezes** por request (o avaliador re-agregava o que `loadMunicipalityScope`, React `cache()` já pago pelo `getCampaignDashboardData`, segurava em `pledgeAggregates`); o caminho sem filtro agora reusa o scope e o caminho de 1 município mantém a agregação própria. Também: a janela de decisões caiu de 90d chutados para 30d **derivados** das constantes de supressão; o catálogo importava `ELECTION_YEAR_*` de `electionResults`, cujo 1º import é `bahiaTerritories` — três números arrastavam a tabela de territórios para o chunk do card (a lição do B14, de novo; anos locais com comentário); `triggerSummary` era copy morta (8 parágrafos que nada renderizava — deletado); `silence.totalCount` era derivado (bundle virou array); o filtro de tipo de sinal desceu para o `where` e matou o único cast do delivery; LQ 2022 reusa `classification.lq` memoizado; copy dos fatores alinhada ao canônico "do candidato" (a docstring do E10 já proibia variação do possessivo); card adotou `FieldSet`/`FieldLegend`/`FieldLabel` de `ui/field`; `DAY_MS` e `SUGGESTION_TEXT_MAX_LENGTH` unificados no catálogo; `formatSilenceAgeLabel` mudou para `municipalitySignal.ts`. **Correção de registro:** suítes são 685 unit / 453 int (o "453" acima era só a fase int). **Ficou com gatilho:** consolidar as leituras repetidas de pledges/lideranças da `OverviewTab` (3 loaders não coordenados, pré-existente); extrair o helper de mensagem de erro de `CampaignFormActionState` no 3º call site (hoje arrastaria zod ao bundle); `formatDominanceAgainstOwnStandard` → `lib/` quando outro módulo lib precisar. Plano as-built: [`motor-de-sugestoes.md`](../../../docs/plans/motor-de-sugestoes.md). - **B40 entregue em código (2026-07-28):** login com biometria (WebAuthn) em `/campanha` — enrollment opt-in depois do login com senha (toast dismissível no shell, no molde do `InstallPwaToast`, + seção permanente "Acesso por biometria" em `/campanha/perfil`), botão de platform authenticator na tela de login e revogação por dispositivo. Vale para **todos os papéis, liderança inclusive** (é quem tem mais atrito: celular + senha), e **não abre chave de `Consent`** — o SO nunca libera a digital, guardamos só uma chave pública, então o art. 11 não entra e o lote da Onda 0 não reabre. Collection `campaignWebAuthnCredential` (migration `20260728_041958_add_campaign_webauthn_credentials`), 4 rotas JSON em `campanha/webauthn/` **fora de `(app)`** (duas são anônimas; o path `/campanha` do cookie já as cobre), `@simplewebauthn/server@13` no servidor e `browser@13` no cliente. **A lacuna que a auditoria abriu e que era o coração da entrega:** o Payload 3.82.0 **não tem caminho password-less** (`loginOperation` exige `password`), então `issueCampaignWebAuthnSession` forja a sessão só com exports públicos — `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (`payload/shared`) → `getFieldsToSign` + `jwtSign` → o `setCampaignAuthCookie` do **B39 ✓**, com TTL longo porque cadastrar o aparelho _é_ o opt-in de confiança. **A mina, confirmada em código:** `addSessionToUser` faz read-modify-write do documento **inteiro** do usuário, e carregar com `payload.findByID` passaria pelo `afterRead` `removePrivateAuthFields` — gravar isso de volta apagaria `sessions`/`email` (derrubando **todas as outras sessões**) e expandiria `avatar` em objeto; daí `db.findOne` cru com o comentário nomeando o perigo, `user.updatedAt = null` como o próprio login do Payload faz, lock `campaign-webauthn-session:{id}` numa transação (reescrever o array inteiro faz dois logins simultâneos perderem sessão) e int spec provando que a sessão pré-existente sobrevive ao mint. **Duas armadilhas que a auditoria não previu, e as duas se escondem atrás de um caminho que "funciona":** (1) `getCampaignWebAuthnRelyingParty` derivava o RP da origem canônica e **tinha sucesso** num preview da Vercel — o botão aparecia e o browser estourava `SecurityError`, porque quem serve a página é outro host; agora a função compara o host do request (`origin`, ou `x-forwarded-host`/`host`) com o host derivado e devolve `null` quando divergem, que é o "a UI se esconde" que o plano prometia; (2) o challenge do cookie HMAC precisa ser **os mesmos bytes** que o `@simplewebauthn/server` gerou — a primeira versão gerava o seu ao lado do dele e **todo `register` respondia 400**, achado só pelo e2e. Também: deletar um `campaignUser` quebrava, porque a FK que o Payload emite para a relação obrigatória é `ON DELETE SET NULL` sobre coluna `NOT NULL` → `beforeDelete` em `CampaignUser` apagando as credenciais na mesma transação (cascade explícito, sem editar a migration à mão); `checkLoginPermission` lança o erro genérico do Payload, capturado e relançado em pt-BR para que a conta trancada saiba que é bloqueio e não falha de biometria; e o erro do botão vivia no `FieldError` **e** na região `aria-live`, anunciado duas vezes pelo leitor de tela (o `sr-only` ficou só com o estado pendente). **Medido:** `@simplewebauthn/browser` custa **3,0 kB** gzip contra o orçamento de 10 kB do plano, e o `/simplify` o tirou do First Load JS de vez — a metade sempre carregada (probe de capacidade + tipo de erro) virou `src/lib/campaignWebAuthnSupport.ts`, que não importa nada, e as três ilhas chegam à cerimônia por `import()` dentro do handler: `/campanha/login` caiu de 130 para **126 kB** e `/campanha`, onde o toast de enrollment monta em toda navegação autenticada, ficou **byte-idêntico** aos 268 kB de antes do item. e2e com **autenticador virtual por CDP** (`WebAuthn.addVirtualAuthenticator`, `transport: 'internal'`, que também faz `isUserVerifyingPlatformAuthenticatorAvailable()` responder `true`) cobrindo cadastrar → sair → entrar com biometria → revogar; para o caso da credencial revogada o fixture ganhou `expectedRequestFailurePaths`, porque o teste provoca um 4xx de propósito e o guard global tratava o `console.error` do fetch como erro não tratado. Aikido 0 findings. Plano as-built: [`login-biometrico-campanha.md`](../../../docs/plans/login-biometrico-campanha.md). **O `/simplify` cobrou uma simplificação minha de volta, e essa é a lição transferível:** tirar a anotação `Promise` da action pareceu redundância removida e o `tsc` provou o contrário — `runCampaignFormAction` tipa o ramo de falha **sem a chave `status`**, então só o tipo anotado (onde ela é opcional) permite ao chamador estreitar por `status === "success"`; voltou com o motivo escrito ao lado. O resto do passe: `canWriteWebAuthnCredentials` virou `payloadAdminOnly` em vez de reescrever o predicado; a sonda de disponibilidade (duas cópias com o mesmo guard de cancelamento) virou `useCampaignBiometricsAvailable`; o card passou a usar `FieldError` como o botão — e por isso perdeu a mensagem de erro da região `sr-only`, pelo mesmo motivo de anúncio duplo já corrigido no botão; as três frases declaradas dos dois lados foram para `campaignAuthCopy.ts`, e a que importava era "Este aparelho já está cadastrado.", que o servidor trata como entrada do allowlist `safeMessages` por **string exata** enquanto o cliente a reproduzia à mão; `expectedRequestFailurePaths` virou opção do Playwright (`test.use` no topo em vez de `push` no meio do teste); e o int que dizia "a senha ainda funciona" passou a **logar com a senha**, em vez de reafirmar o e-mail que a asserção acima já cobria. Dois débitos ficaram de fora com motivo: o núcleo HMAC é ~35 linhas iguais às de `supporterImportToken.ts` (2 call sites, e extrair mexeria no caminho do token de import) e `postJson` era a 6ª cópia do mesmo fetch. **Essa segunda o `main` resolveu enquanto o item estava aberto** (B32+ ✓), então o rebase não foi só um merge: as 4 rotas nasceram de novo sobre `campaignJsonMutationRoute` e o transporte passou a ser `postCampaignJson` — não por gosto, porque o B32+ trouxe um **guard de convenção** que reprova um `route.ts` exportando `POST` fora da casca, e as quatro estavam nomeadas nele. Duas coisas a saber de quem adotar a casca num caminho não-célula: ela **sempre** parseia um corpo, e as duas rotas `*-options` não têm nenhum — daí `campaignWebAuthnNoBodySchema`, com o cliente postando `{}`, que é o preço honesto de ter a verificação de origem por construção em vez de por disciplina; e `postJson` sobrevive como casca de **uma linha** sobre `postCampaignJson` que só descarta o `ok`, porque o discriminante do envelope é que decide. **A tradução de `SyntaxError` que a primeira versão dessa casca tinha caiu no `/simplify` do fechamento, e o motivo generaliza para qualquer ilha destas três telas:** ela escrevia "Resposta inválida do servidor." num `Error` cru, e as três ilhas leem `message` **só** de um `CampaignWebAuthnError` — a frase era redigida para a pessoa e jogada no canal que descarta mensagens. Deletar em vez de re-tipar, porque o fallback de cada ilha ("…Use sua senha.") é a copy melhor para quem está em campo; nada muda na tela, e o comentário agora registra por que o ramo está ausente. Verificado no servidor de produção local, sem browser: cross-origin 403, sem sessão 401, corpo não-JSON no 400 compartilhado, e o corpo `{}` chegando ao handler. - **B17 entregue em código (2026-07-28):** seletor de colunas, e **nas sete superfícies** do sistema de listas (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios), não só em municípios — o picker nasceu como capacidade do `CampaignTable` (uma prop `columnVisibility`, devolvida inteira por `readCampaignColumnVisibility(listId)`, liga o controle e filtra), então o custo marginal por lista foi `label` nas colunas e uma prop. Fora: `/campanha/assessores`, cujo `AdvisorsTable` ainda é `ui/Table` cru com `table-fixed`. **Duas premissas do plano de 24/07 morreram.** (1) `localStorage` — e o motivo é de tipo, não de gosto: `CampaignTableColumn.cell` é **função**, o array de colunas não atravessa a fronteira server/client, e esconder no cliente exigiria `data-column` + `