teqo-backup/docs/plans/ai-chat-sollinha.md
Francisco Ignácio Fontoura Solla f9cd1df7b2 feat: AI chat 'Sollinha' — campaign data assistant (v1 read-only)
- Streaming AI endpoint at /campanha/api/ai-chat with DeepSeek V4 Flash
- 8 tools: calculate, getMunicipalityVotes, getTopDeputies, getDobradinhas,
  getMunicipalityOverview, getLeaderships, getOrganizations, searchEntities
- In-memory rate limiter (50 msgs / 15 min per user)
- Desktop: resizable right sidebar via react-resizable-panels
- Mobile: full-screen Drawer opened by FAB (2/3 size of quick-actions FAB)
- Markdown rendering with react-markdown + remark-gfm (tables, bold, lists)
- Session-bound context (clean slate each open)
- Auth via existing campaign-token cookie, tools respect user RBAC
- Architecture doc: docs/plans/ai-chat-sollinha.md
- E2E tests: campaignAiChat.e2e.spec.ts (mocked endpoint)
2026-08-04 06:17:31 -03:00

14 KiB

AI Chat — "Sollinha" (assistente virtual da campanha)

Status: entregue (2026-08-04 — v1 read-only) Atualizado em: 2026-08-04 Item do roadmap: feature nova (não numerada em trilha) Appetite: ~2 dias eng; API route + tools + chat UI com shadcn; sem migration, sem collection, sem Consent

Design

Âncoras: PRODUCT.md (Field Desk; PWA de campo) / DESIGN.md · tema data-theme='campaign'.

Na implementação: chat sidebar no desktop, drawer full-screen no mobile — o assessor mantém a tela principal visível enquanto conversa com a IA.

Brief compacto:

  • Persona / contexto: Assessor/coordenador navegando em /campanha quer fazer perguntas sobre os dados sem sair da tela atual ("Quantos votos tivemos em Ilhéus em 2022?", "Quem é o deputado mais votado em Feira?", "Quais dobradinhas temos em Salvador?").
  • Job principal: abrir chat, perguntar em linguagem natural, receber resposta com dados reais do banco.
  • Estratégia de cor: Restrained — botão 🤖 no header (desktop + mobile), ao lado do sino de notificações. Sheet cinza com bubbles padrão shadcn.
  • Edit where you see: não — chat.
  • Anti-goals: substituir navegação pela IA; persistir histórico entre sessões (complexidade desnecessária no v1); write tools no v1.

Dados → decisão → apresentação

Dados: TSE 2014/2018/2022 (electionCandidateVote, electionTally), municípios, lideranças, dobradinhas, organizações.

Arquitetura

Fluxo de dados

┌──────────┐   POST /campanha/api/ai-chat    ┌──────────────┐
│  Client  │ ───────────────────────────────> │  Route (RSC) │
│ useChat  │ <─────────────────────────────── │  streamText  │
│ (shadcn) │   Streaming (Server-Sent Events) │  + tools     │
└──────────┘                                  └──────┬───────┘
                                                     │
                                          ┌──────────▼───────┐
                                          │  Payload Local   │
                                          │  API (user-scoped│
                                          │  access control) │
                                          └──────────────────┘

Camadas

Camada Localização Responsabilidade
API route src/app/(campaign)/campanha/api/ai-chat/route.ts Streaming endpoint: auth via campaign-token, rate limit, streamText com DeepSeek V4 Flash
Tools src/utilities/ai/tools/*.ts 8 funções que o modelo chama: queries Payload com user scoped
System prompt src/utilities/ai/systemPrompt.ts Personalidade "Sollinha" + domínio eleitoral da Bahia
Rate limiter src/utilities/ai/rateLimit.ts In-memory token bucket: 50 mensagens por 15 min por usuário
Chat UI src/components/campaign/shell/ai/*.tsx useChat hook + shadcn message/bubble/input-group
Sheet src/components/campaign/shell/ai/CampaignAIChatSheet.tsx Sheet (desktop) / Drawer (mobile) — wrapper responsivo
Header button src/components/campaign/shell/ai/CampaignAIHeaderButton.tsx Botão 🤖 no CampaignDesktopHeader e CampaignMobileTopBar
Types src/lib/ai/types.ts AIToolContext = { user, payload }

Modelo

  • Modelo: deepseek-v4-flash via @ai-sdk/deepseek
  • Tool calling: nativo (suportado pelo modelo)
  • Multi-step: stopWhen: stepCountIs(10) — permite tool → resposta → tool encadeado
  • Streaming: result.toUIMessageStreamResponse() — Server-Sent Events

Contexto

  • Sem persistência: cada abertura do chat = sessão nova. useChat sem id/initialMessages.
  • Sem histórico entre abas/dispositivos: a complexidade de threads fica para v2.
  • Limpeza implícita: fechar o Sheet/Drawer descarta o estado React.

Rate limiting

  • Algoritmo: in-memory token bucket, keyed by userId.
  • Limite: 50 mensagens por janela de 15 minutos.
  • Resposta no estouro: HTTP 429 com mensagem em pt-BR.
  • Resiliência: sobrevive a um servidor; reseta no deploy. Suficiente para v1.

Ferramentas (v1 read-only)

# Tool Descrição Fonte de dados
1 calculate Aritmética segura (sem eval) — percentuais, somas, taxas Puro, sem DB
2 getMunicipalityVotes Votos do Solla por ano + voto válido no município electionCandidateVote + electionTally
3 getTopDeputies Ranking de deputados federais mais votados no município/ano electionCandidateVote
4 getDobradinhas Parcerias com deputados estaduais, filtráveis por município stateDeputy + municipality.stateDeputies
5 getMunicipalityOverview Nível de engajamento, prioridade, lideranças, compromissos municipality + contagens
6 getLeaderships Lideranças por município ou nome leadership
7 getOrganizations Organizações por município, tipo ou nome organization
8 searchEntities Busca fuzzy cross-collection (fallback quando pergunta ambígua) 4 coleções em paralelo

Cada tool recebe { user, payload } e chama a Local API com overrideAccess: false + user (respeita RBAC) ou overrideAccess: true com justificativa (dados eleitorais públicos).

Decisões travadas

  • Vercel AI SDK (ai + @ai-sdk/deepseek), não LangChain/Mastra. O AI SDK é first-party Next.js, tem streamText com tools nativo, useChat hook no cliente, e o projeto já está em Next.js. Rejeitado: LangChain (overhead de abstração sem ganho para tools simples); Mastra (framework de agentes completo, desnecessário para v1 de chat com tools).
  • Modelo deepseek-v4-flash, não deepseek-chat legado. O nome legado deepseek-chat será descontinuado em 2026-07-24. O V4 Flash suporta tool calling e tem 128K tokens de input. Rejeitado: deepseek-v4-pro (mais caro, desnecessário para queries de dados); deepseek-reasoner (latência maior, sem ganho para tools).
  • 8 tools específicas + 1 calculadora, não 1 tool genérica "query". Tools com propósitos não-sobrepostos melhoram o routing do modelo. Rejeitado: uma tool "query anything" faz o modelo alucinar a sintaxe de query; uma tool "sql" é insegura e desnecessária (Payload já é a camada de query).
  • shadcn chat components (message, bubble, marker, input-group), não hand-rolled. shadcn lançou chat components em junho/2026; o projeto já usa shadcn; a qualidade visual e de acessibilidade é superior a hand-rolling. Rejeitado: @chatscope/chat-ui-kit-react (lib externa, bundle maior, menos integração com Tailwind/shadcn).
  • Sheet (desktop) / Drawer (mobile), não modal Dialog. O assessor precisa manter a tela principal visível enquanto interage com a IA. Rejeitado: Dialog modal (bloqueia a tela principal); página separada (perde o contexto de onde o usuário estava).
  • Header button, não FAB. Produto pediu FAB inicialmente, mas o header é mais discoverable e consistente com o sino de notificações. Rejeitado: FAB flutuante (competiria com o FAB de ações rápidas); ícone no sidebar (menos visível, navegação já tem densidade alta).
  • Contexto limpo a cada abertura. Complexidade de threads/histórico é desnecessária para o target user (assessor de campanha em campo). Rejeitado: persistir conversas no DB (migration, UI de histórico, cleanup — v2); auto-clean por inatividade (surpreende o usuário que volta).

Dependências

Nenhuma — feature independente. Não depende de migrações, collections novas, Consent, ou outras features.

Não escopo (v1)

  • Write tools (criar/editar lideranças, pledges, atividades via chat)
  • Histórico persistente entre sessões
  • Web search (grounding externo)
  • Speech-to-text (Assembly AI)
  • Upload de arquivos/áudio no chat
  • Suporte a múltiplos modelos

Roadmap futuro (arquitetura preparada)

Feature Como a arquitetura atual suporta
Write tools Adicionar tool em src/utilities/ai/tools/, registrar no index.ts. Usar payload.update/payload.create com user scoped — mesmo padrão das tools de leitura.
Web research Adicionar tool webSearch que chama uma API externa. O streamText com tools suporta nativamente.
Speech-to-text InputGroup aceita slot de addon. Adicionar botão de microfone que grava → envia para Assembly AI via server action → injeta transcrição como sendMessage({ text }).
Meeting recording Pipeline: gravar → Assembly AI transcript → sendMessage({ text: "Resuma esta reunião e sugira atualizações nos dados: ..." }).
Mudar de modelo Trocar deepSeek('deepseek-v4-flash') por outro provider no route.ts — tools e UI não mudam.
Histórico persistente Passar id para useChat + salvar mensagens em collection Payload. Migração simples (uma collection aiChatMessage).

Testes

Camada Cobertura
Unit campaignMobileTopBar.unit.spec.tsx cobre o botão AI no header mobile (existente, estendido); codebaseConventions.unit.spec.ts cobre a allowlist do route
Int Queries Payload das tools são cobertas indiretamente pelos int tests dos data loaders existentes (municipalityElectoralBaseline, votePledgeViews, etc.)
E2e tests/e2e/ai-chat.e2e.spec.ts — abre o Sheet, digita mensagem, verifica resposta mockada. Mocka /campanha/api/ai-chat com page.route() para não depender de API key real no CI.

E2e mocking strategy

O endpoint de chat é mockado no Playwright com page.route():

await page.route('**/campanha/api/ai-chat', async (route) => {
  // Retorna um stream SSE mínimo que o useChat entende
  await route.fulfill({
    status: 200,
    headers: { 'Content-Type': 'text/event-stream' },
    body: 'data: {"type":"text","text":"Olá! Como posso ajudar?"}\n\n',
  })
})

Isso permite testar o fluxo completo de UI (botão → Sheet → digitar → resposta) sem chave de API no CI. Não é necessário DEEPSEEK_API_KEY como repo secret.

Configuração de ambiente

Variável Onde Obrigatória
DEEPSEEK_API_KEY Vercel production env Sim (produção)
DEEPSEEK_API_KEY .env.local Sim (desenvolvimento local)
DEEPSEEK_API_KEY GitHub repo secrets Não (e2e mocka o endpoint)

Referências

  • src/app/(campaign)/campanha/api/ai-chat/route.ts — endpoint
  • src/utilities/ai/tools/index.ts — registro de tools
  • src/utilities/ai/systemPrompt.ts — prompt do sistema
  • src/components/campaign/shell/ai/CampaignAIChat.tsx — UI do chat
  • src/components/campaign/shell/ai/CampaignAIChatSheet.tsx — Sheet/Drawer
  • src/components/campaign/shell/ai/CampaignAIHeaderButton.tsx — botão no header
  • src/components/campaign/shell/CampaignDesktopHeader.tsx — header desktop
  • src/components/campaign/shell/CampaignMobileTopBar.tsx — header mobile
  • AGENTS.md — Campaign auth, Municípios model
  • PRODUCT.md / DESIGN.md