mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 10:43:07 +00:00
- 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)
170 lines
14 KiB
Markdown
170 lines
14 KiB
Markdown
# 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()`:
|
|
|
|
```ts
|
|
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`
|