20 KiB
E11 — Motor de sugestões v1 (dado → decisão com humano no loop)
Status: entregue (2026-07-28)
Atualizado em: 2026-07-28 (as-built; revisões da auditoria pré-implementação registradas abaixo)
Item do roadmap: docs/roadmap.md (seção "Inteligência de campanha", E11; plano-mestre inteligencia-campanha.md)
Impeccable: C — superfície nova de sugestões (painel/fila em /campanha), sem design-ref
Appetite: ~3 dias eng; uma migration mínima (20260728_041547_add_allocation_decision_adiada_outcome — o plano dizia "sem migration", ver revisão)
Responsável: —
As-built (2026-07-28)
- Catálogo:
src/lib/suggestionCatalog.ts(client-safe — o formulário de descarte oferece os "descartes" do padrão como quick-picks, então o browser precisa dos textos): 8 padrões com predicados puros sobreMunicipalityTriggerInput(números já derivados, sem artefato), textos do §6.1, triagem 1–5 com rótulos do §6.2 (suggestionTriageLabels), âncoras ilustrativas emSUGGESTION_ANCHORS(interno), janelas de supressão (aceita14d /descarta30d /adiadaatésuppressUntildo snapshot) eisSuggestionSuppressedByDecision— pura, com a regra "nível 1 fura a fila": um gatilho que volta como nível 1 atravessa supressão registrada em urgência menor. Diferencial estrutural codificado (§6.3): P2 cede a P3 e K-B; P7 cede a K-A quando há esforço registrado no ciclo. - Avaliador:
src/utilities/municipalityTriggers.ts(server-only), no molde devisitPlannerData.ts(leitura própria demunicipality— o select do scope compartilhado não temengagementLevel/lastUpdateAt): 6 leituras emPromise.all(pledges, contagem de lideranças exportada devisitPlannerData, metascache(), sinaiskind: 'sinal'≤28d, atividades ≥now−42d, últimasallocationDecision≤90d por padrão), LQ por ano com a fórmula exata do E10, cortes de catálogo memoizados por processo (mediana + quartil superior + padrão intracampo estadual). Pauta do silêncio = alta/N2+ sem gatilho DISPARADO (supressão não conta como silêncio) e sem sinal ≥30d. - Decisão:
resolveSuggestionRecord(actions/suggestion.ts) reavalia o padrão server-side — o snapshot registra os fatores do momento da decisão, nunca os do render; padrão fora da fila recusa comSUGGESTION_STALE_MESSAGE(safe message). Transação + lockmunicipality-suggestion:{id}:{patternId}antes de reler a última decisão.adiadaderiva a duração do nível recomputado (7d; 14d nos níveis 4–5) e gravasuppressUntilno snapshot. Aceite exige a ação do menu (zod valida contra o catálogo); descarte exigealternativeReading(zod + hook da collection). CascarunCampaignFormActionem(app)/suggestionFormActions.ts— compartilhada pelas duas superfícies. - UI:
src/components/campaign/suggestion/—SuggestionsPanel(RSC; estatuto no rodapé;CampaignInfoHint→ conceitos) +SuggestionCard(ilha client: Aceitar com radios do menu — 1º item pré-selecionado, é a checagem barata — + nota opcional; Adiar em um toque com a duração no rótulo; Descartar com quick-picks + textarea obrigatória; submit explícito — a exceção prevista para flows com nota/confirmação, mesmo precedente do E14). Dashboard: slot SuspensesuggestionsSlot(top-5 + pauta do silêncio com chips). Detalhe do município: card naOverviewTabapós elegibilidade de visita, lista completa; município silencioso diz isso no empty state. Foco gerenciado na troca trigger↔form (abrir foca o primeiro campo; cancelar devolve ao trigger). Cor contida: só nível 1 usadestructive. - Conceitos (E18):
triagem-de-sugestoesepauta-do-silencioemdiagnostico. - Testes:
suggestionCatalog.unit.spec.ts(45 casos — red/green por predicado, exclusividade dos diferenciais, supressão incl. pierce de nível 1 e snapshot malformado) ecampaignSuggestions.int.spec.ts(9 casos — P7 end-to-end com decisões governando a fila, K-A vs P7, P6 com priorização mudando a triagem, escopo do advisor/leader, silêncio, action: aceite/adiamento/descarte/stale/fora-de-escopo). Padrões ancorados no artefato (P1/P2/P3/P5/K-B) não são encenáveis por linhas do banco — os int tests que precisam de propriedade de artefato alocam municípios pelo alocador das fixtures até um qualificar (taxas medidas: 109/435 quartil superior, 205/435 quietos; slug explícito colidiria com o alocador, que é uma sequence com wrap). - Medidas: First Load JS de
/campanha268 → 277 kB (catálogo + ilha do card; medido pós-simplify); suíte completa 453 testes em ~84s.
Pós-/simplify (mesma sessão, 2026-07-28)
Três revisores paralelos (qualidade, performance, reuso); tudo aplicado sem mudança de comportamento observável, exceto onde dito:
- Perf P1 — o dashboard varria
votePledgeduas vezes por request: o avaliador re-agregava comaggregatePledgesByMunicipalityo queloadMunicipalityScope(Reactcache(), já pago pelogetCampaignDashboardDatano mesmo request) segura empledgeAggregates. O caminho sem filtro agora reusa o scope; o caminho communicipalityID(detalhe/action) mantém a agregação de 1 município — puxar o scope inteiro ali seria o erro inverso. - Janela de decisões derivada, não chutada: buscava 90 dias de
allocationDecisionquando nada acima demax(supressões, adiamento máximo)= 30d pode suprimir —SUGGESTION_INPUT_WINDOWS.decisionDaysagora é computada das constantes (linhas jsonb crescem com uso real). - Bundle do cliente — a lição do B14 de novo: o catálogo importava
ELECTION_YEAR_*delib/electionResults, cujo primeiro import ébahiaTerritories— três números arrastavam a tabela de territórios para o chunk do card. Anos locais com comentário apontando o espelho. triggerSummaryera copy morta (8 parágrafos que nada renderizava — o card mostra leitura + fatores + menu): campo deletado do tipo e dos 8 padrões.silence.totalCountera estado derivado (sempreentries.length): o bundle expõesilence: MunicipalitySilenceEntry[]e a faixa deriva as contagens.- Filtro de tipo de sinal desceu para o
where(signalType: { in: [invasao, visita_adversario, proposta_broker] }) — menos linhas trafegadas e morreu o único cast do delivery. - LQ 2022 reusa
classification.lq(mesma fórmula, memoizada) em vez de recomputar ao lado —lqForYearfica só para 2014/2018. - Copy dos fatores alinhada ao canônico de
formatDominanceAgainstOwnStandard("… o padrão estadual do candidato", sem prefixo "LQ") — "próprio" era uma terceira variação do possessivo que a docstring do E10 já proibia. - Card adotou os primitivos
ui/field(FieldSet/FieldLegend/FieldLabel, idioma doActivityForm) no lugar de fieldset/legend/label à mão;DAY_MS(3 declarações no delivery) eSUGGESTION_TEXT_MAX_LENGTHunificados no catálogo;formatSilenceAgeLabelmudou paramunicipalitySignal.ts, ao lado do formatter irmão de idade de sinal. - Correção de registro: as suítes são 685 unit / 453 int — o número anterior desta página ("suíte 453") era só a fase int.
Recomendações que ficaram (maiores que o cleanup): consolidar as ~4 leituras de pledges/lideranças que a OverviewTab do detalhe faz por três loaders não coordenados (conta da cadeira + elegibilidade + sugestões — composição pré-existente, o E11 só somou o 4º caminho barato); extrair (feito na rodada 2 — o rebase entregou o 3º site); mover firstErrorMessage/toMessageOnlyState no 3º call siteformatDominanceAgainstOwnStandard para lib/ (cadeia com formatRatioAsPercentLabel) quando um segundo módulo lib precisar dele; os memos de estatística do catálogo (quartil superior, padrão intracampo estadual) migram para municipalityPotential.ts quando ganharem um 2º consumidor (precedente E13).
Rodada 2 — pós-rebase sobre main (2026-07-28)
O rebase trouxe B17/B29/B32+/B37/B40; os três revisores confirmaram que nenhuma premissa do E11 morreu (o card corretamente NÃO adota a máquina de célula do B32+ — formulários inline com submit explícito não têm o problema "fechar desmonta o Alert"; o guard de convenções aceita suggestionFormActions.ts; o cache hit do scope sobreviveu — verificado o mecanismo da chave JSON.stringify(where)), mas o rebase entregou o 3º call site da extração adiada na rodada 1: RelationChipCell (B37, agora em main) extrai a mensagem do mesmo CampaignFormActionState. Aplicado:
- P2 real —
rationalecomposta estourava o cap de 2000 da collection: nota no cap + prefixo do rótulo do menu > 2000 → Payload rejeitava o create e o usuário via o erro genérico, sem retry possível. Clampslice(0, SUGGESTION_TEXT_MAX_LENGTH)(o prefixo carrega a decisão; só uma nota máxima perde a cauda) + caso int pinando (nota de 2000 chars grava com rationale = 2000). firstFormActionMessageemutilities/campaignFormFields.ts(client-safe, dono defieldError) — adotado no card, noRelationChipCell(que antes ignoravafieldErrors) e notoMessageOnlyStatede apoiadores.- Um canal por falha: o card anunciava em dois (linha
role="alert"+ toast) — o card nunca desmonta na falha, então a linha inline basta; o toast ficou só para sucesso (o card sai da fila). metricsnão viaja mais ao cliente (~209 B/card): o painel montaSuggestionCardDatacampo a campo; o view model completo (com metrics para o snapshot da action) fica no servidor.- Miudezas:
SUGGESTION_OUTCOMESexportado do schema (3ª grafia da lista morreu); fallback de snapshot malformado edecisionDaysderivados deMath.min/maxsobre os níveis (recalibração não inverte silenciosamente);metrics100% lidos deinput(contrato "os números sobre os quais a decisão foi tomada" provável por construção); IIFE defieldShareOfValid2022virou irmã nomeada delqForYear;DAY_MSdo int spec importado do catálogo.
Ficou (registrado): juntar as 6 leituras do avaliador num estágio único para ator irrestrito; a tripla leitura de pledges da OverviewTab (pré-existente, acima).
Revisões da auditoria (2026-07-28)
- "Sem migration" caiu: o enum real de
allocationDecision.outcomeeraaceita | descarta | movimento— o plano citavaaceita|descartada|adiada(valores que nunca existiram). "Adiar" exigiu a migrationadd_allocation_decision_adiada_outcome(precedente exato: E14 adicionoumovimento). Registrar adiamento como descarte especial foi rejeitado — envenenaria o dado de leitura alternativa do backtest (E15). - Refs mortas corrigidas:
electionInsights.ts(deletado no Pass 2) e o stackNucleusInsights(não existe; o precedente vivo éMunicipalityVisitEligibilityCard) saíram da abordagem; "bundle A9+ no dashboard" — o dashboard usaloadMunicipalityScope, e o avaliador faz leitura própria (mesma justificativa do planner E13). - P3 sem
runningAgain2026: o campo existe mas está 100%desconhecidoe sem read path — o gatilho usa só o artefato (campo forte + fatia intracampo < 0,5× o padrão estadual próprio, com gate de volume na mediana); "concorre de novo?" é o 1º item do menu (inteligência), não gatilho. - Proxies assumidos (calibráveis, E15): P7 "delta ≈ 0" = cobertura < meta +
lastPledgeAt≥ 21d (trajetória por versions fica para a fase 2); P2 sem NEC (mesmo corte do E10 — gatilho segue sendo E11 fase 2 precisar do eixo de competição); persistência/histerese sem tabela nova — janelas temporais nos próprios gatilhos + supressão pela última decisão; o rebaixamento caro continua protegido no write path do E14. - Pauta do silêncio inclui
priority: altaalém de N2+ — E14 nasceu sem backfill; só N2+ daria pauta vazia até a triagem de níveis acontecer.
Design (Impeccable)
Âncoras: PRODUCT.md (princípios 2, 5 e 7) / DESIGN.md (register product) · CampaignPageShell/CampaignMetricStrip · precedente de stack de insights (Alert + badge) do detalhe do município.
Na implementação: shape → craft → critique → polish (classe C).
Brief compacto:
- Persona / contexto: coordenador abrindo o Teqo segunda de manhã ("o que mudou? onde ajo?"); assessor com 20 municípios recebendo as checagens baratas da semana.
- Job principal: o grosso da análise chega pronto — municípios com gatilho, diagnóstico diferencial e menu de ações; o humano escolhe, e o descarte também é registro.
- Estratégia de cor: Restrained; urgência via posição na triagem (níveis 1–5), não via alarme colorido — só o nível 1 usa o vermelho de campanha.
- Edit where you see: sim — aceitar/adiar/descartar direto no card da sugestão; descarte exige leitura alternativa (1 campo).
- Anti-goals: sugestão executada automaticamente; feed infinito estilo rede social; score numérico com falsa precisão ("87% de chance"); expor limiares exatos na UI (gaming — G4).
Contexto
Relatório §6: catálogo de 25 padrões dado→decisão (P/T/I/J/K) com triagem (§6.2), divisão análise×julgamento (§6.3 — máquina detecta e diagnostica o estrutural; humano escolhe/executa) e modos de falha com mitigação (§6.4 — falso positivo de registro, viés da base, gaming, oráculo, churn). Pós-E8/E10/E14/C12, todos os insumos dos 8 padrões de maior confiança existem: P1 (reduto dormente/ameaçado), P2 (ataque campo forte), P3 (correligionário na frente — runningAgain2026 dá a herança aberta), P5 (expansão acima do padrão), P6 (município grande sem rede), P7 (pledges estagnados), K-A (não responde a investimento), K-B (perdida vestida de oportunidade). A "pauta do silêncio" (municípios sem gatilho nenhum, revisados como pergunta mensal) integra a v1 por ser barata e antídoto do viés da base.
Objetivos
- Avaliador de gatilhos puro e determinístico: município + derivados (E8/E10/E14) + sinais/frescor (C12) → lista de
TriggeredPattern(patternId, fatores, nível de triagem 1–5, leitura provável, descartes, menu de ações com estatuto/fonte, contraindicação) — conteúdo textual vem de um catálogo versionado em código espelhando o relatório §6. - Superfície: painel "Sugestões" no dashboard (top-N por triagem) + seção no município (gatilhos dela); visão do assessor filtrada às seus municípios com as checagens baratas ("checar rede em 48h") destacadas.
- Ações: aceitar (vira nota/encaminhamento e grava
allocationDecisioncom snapshot), adiar (some por X dias), descartar (exigealternativeReading); tudo transacional. - Regras anti-falha (§6.4): gatilho de ausência gera "auditar registro", nunca ação de campo; histerese — sugestão de realocação cara só aparece com persistência de 2 avaliações semanais (persistência via snapshot em
allocationDecisionde avaliações, sem tabela nova: recomputa e compara com a última decisão/adiamento registrado); limiares nunca renderizados com número exato. - Pauta do silêncio: lista mensal dos municípios N2+ sem nenhum gatilho e sem sinal — "silêncio é pergunta, não conforto".
- Access: staff; visão do assessor escopada (
getAccessibleMunicipalityIds);leadernão vê nada disso.
Decisões travadas
- Motor é avaliador em leitura, não job persistido. Roda no load (dados de 435 municípios já vêm no bundle A9+); persistência só de DECISÕES (C12). Rejeitado: cron + tabela de sugestões (estado duplicado que dessincroniza; nada aqui exige assincronia); LLM gerando sugestões em runtime (indeterminístico, não auditável — o catálogo é curado em código a partir do relatório).
- 8 padrões na v1, catálogo fechado. Rejeitado: 25 padrões de uma vez (metade depende de semanas de sinais C12; falso positivo mata a confiança na primeira semana — E1 do relatório).
- Descartar exige leitura alternativa. É o antídoto metodológico do oráculo (§6.4) e o dado que o backtest precisa. Rejeitado: descarte de 1 clique sem motivo.
- i18n e naming:
evaluateMunicipalityTriggers,TriggeredPattern,triageLevel,suggestionCatalog; patternIds estáveis (P1…K-B); textos pt-BR citando estatuto ("hipótese de literatura — relatório §6.1").
Questões em aberto
- Onde mora a superfície principal? Opções: painel no dashboard | rota
/campanha/sugestoes| aba na lista de municípios. Recomendação: painel no dashboard (top-5) + seção completa no detalhe do município; rota dedicada só se o volume real pedir (adiado com gatilho). (shape na implementação decide o layout final) - Adiamento por quanto tempo? Recomendação: 7 dias default, 14 para nível 4–5; registrado como
decision: adiada.
Abordagem proposta
flowchart LR
Bundle["loadMunicipalityListPageBundle + derivados E8/E10/E14"]
Signals["sinais + frescor (C12)"]
Cat["suggestionCatalog<br/>(8 padrões, texto curado)"]
Eval["evaluateMunicipalityTriggers<br/>(puro, determinístico)"]
Triage["triagem 1–5 + histerese<br/>(vs. última allocationDecision)"]
UI["Dashboard painel + seção do município<br/>(aceitar/adiar/descartar)"]
Dec["allocationDecision (C12)"]
Bundle --> Eval
Signals --> Eval
Cat --> Eval
Eval --> Triage --> UI --> Dec
Dec -.histerese/adiamentos.-> Triage
Componentes:
src/lib/suggestionCatalog.ts: os 8 padrões com gatilho (função pura sobre derivados), textos (leitura/descartes/menu/contraindicação com estatuto) e nível de triagem — espelho versionado do relatório §6.1.src/utilities/municipalityTriggers.ts: avaliação por município + agregação/ordenação por triagem; consulta últimasallocationDecisionpara histerese/adiamento (1 query por load,whereem municipality in […]).- UI:
SuggestionCard/SuggestionPanelemsrc/components/campaign/(compõe Alert/Badge existentes do stack de insights); painel no dashboard (campaignDashboardData.tsestende o load); seção no detalhe do município. - Action:
recordAllocationDecision(C12) reusada; wrapperresolveSuggestionvalidaalternativeReadingno descarte. - Sem migration.
Dependências
- Duras: E8, E10, C12; E9 (a fila de colunas é onde o coordenador confere o contexto da sugestão). Suaves: E14 (nível no snapshot e em K-B→N0/N1), dados reais da Onda 0 (motor é inútil com municípios vazios — smoke com dados fictícios apenas).
- Reusa: bundle A9+,
campaignDashboardData.ts,campaignAccess.ts,withPayloadTransaction.
Não escopo
- Padrões T (TI — E12), I/J (alienação/agenda — fase 2), G2-hierarquia completa entre municípios de níveis diferentes além da triagem 1–5; notificação push (D2); qualquer automação de ação.
Rabbit holes
- Catálogo virar motor de regras genérico (DSL, editor de regras). 8 funções puras nomeadas bastam; DSL é plataforma white-label, não este ciclo.
- Histerese exigir tabela de avaliações. Comparar com a última
allocationDecision/adiamento cobre a v1; se falhar na prática, aí sim revisitar persistência. - Explicabilidade virar relatório longo. Card mostra 2 fatores + estatuto + link "por quê" (relatório §6.1); não gerar texto por município.
Adiado com gatilho
- Fase 2 do catálogo (T1–T5, I-A/I-B/I-C, J-A/J-B/J-C, P4/P8–P12, K-C). Gatilho: 2 semanas de uso real + primeiras 20
allocationDecision. - Rota dedicada
/campanha/sugestoes. Gatilho: >15 sugestões ativas recorrentes tornando o painel do dashboard insuficiente. - Digest semanal (e-mail/push). Gatilho: D2 entregue.
Referências
docs/roadmap.md(Inteligência de campanha, E11) · plano-mestre (fila canônica, G4-mitigações)docs/research/relatorio-entrevista-persona-campanha.md§6.1 (padrões), §6.2 (triagem), §6.3 (divisão análise×julgamento), §6.4 (modos de falha)src/utilities/municipalityPageData.ts,src/utilities/campaignDashboardData.ts,src/utilities/campaignAccess.ts,src/collections/ElectionCandidate.ts(runningAgain2026)PRODUCT.mdprincípio 5 /DESIGN.md— âncoras da superfície- AGENTS.md — access, transações, naming