diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 8e7f4f8c..fd304a35 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-10):** **B192** (Issue #533) — o Sollinha encerra respostas factuais com **follow-ups sugeridos como chips** (2–3, no mesmo slot dos chips de abertura do B191, acima do input; tocar envia como mensagem do usuário; substituídos a cada resposta; somem durante o streaming; mobile limita a 2). **Uma única chamada ao modelo:** o prompt (`systemPrompt.ts`) instrui encerrar com o bloco estável `**Sugestões de continuação:**` + lista markdown (só perguntas respondíveis pelas próprias tools, leader sem sugestão eleitoral — B180; bloco = instrução de interface, nunca citar em prosa) e o cliente extrai. **Contrato em fonte única:** `src/lib/sollinhaFollowUpSuggestions.ts` — `SOLLINHA_FOLLOW_UP_MARKER` (interpolado no prompt) + `splitSollinhaFollowUpBlock` (última ocorrência vence; body sempre mascarado do marcador em diante; itens `-/*/1./1)` aceitos, bold e duplicados limpos, teto 3; **fail-closed: <2 itens ou linha não-lista → sem chips**, resposta normal). UI: strip no render de todo text part assistant **antes** do markdown/links (bloco nunca pisca como prosa; mensagens antigas restauradas via B188 continuam mascaradas e os chips renascem por derivação); slot B191 estendido (catálogo vazio → follow-ups); tipo compartilhado renomeado `SollinhaOpeningQuestion → SollinhaChatChip`. **Strip defensivo no servidor:** `route.ts` remove o bloco das mensagens assistant ao reconverter para o modelo (próximo turno sem eco nem custo de contexto). Unit (16 casos do split) + e2e novo `campaignAiChatFollowUps.e2e.spec.ts` (wire format v7 como o mock do B187; bolha sem o bloco, chips, substituição, fail-closed, cap mobile). Sem migration/access/Consent/schema/endpoint novo. Intenção [`sollinha-follow-ups-respostas.md`](plans/sollinha-follow-ups-respostas.md); impl [`sollinha-follow-ups-respostas-impl.md`](plans/sollinha-follow-ups-respostas-impl.md). + **Recently resolved (2026-08-10):** **OPS23** (Issue #566) — a Action OPS18 (`plan-issue-ready-on-main-merge.yml`) falhava **sempre** no primeiro disparo real (PR #563, run 31381977772): o job roda `actions/setup-node@v5` sem `pnpm/action-setup` e o script é plain Node — com `package-manager-cache: true` (default do v5), o setup-node resolve o `packageManager` do package.json (`pnpm@10.11.0`) e procura o executável pnpm → `Unable to locate executable file: pnpm`, promote nunca rodava. Correção: `package-manager-cache: false` no setup-node (precedente exato dos jobs plain-Node: cooldown do `ci.yml`, `vercel-promote.yml`, `archive-cursor-agent.yml`; `pnpm/action-setup@v6` rejeitado — só existe onde há `pnpm install`). Varredura dos 21 usos de setup-node: `plan-issue-ready` era o único com o bug latente. Validação funcional = próximo merge de PR com `Related #N` (trigger `closed+merged→main` só dispara em merge real). Impl [`ops23-action-promote-related-setup-node-impl.md`](plans/ops23-action-promote-related-setup-node-impl.md). **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). @@ -12,6 +14,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **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). +**Recently resolved (2026-08-10):** **B192** (Issue #533) — o Sollinha encerra respostas factuais com **follow-ups sugeridos como chips** (2–3, no mesmo slot dos chips de abertura do B191, acima do input; tocar envia como mensagem do usuário; substituídos a cada resposta; somem durante o streaming; mobile limita a 2). **Uma única chamada ao modelo:** o prompt (`systemPrompt.ts`) instrui encerrar com o bloco estável `**Sugestões de continuação:**` + lista markdown (só perguntas respondíveis pelas próprias tools, leader sem sugestão eleitoral — B180; bloco = instrução de interface, nunca citar em prosa) e o cliente extrai. **Contrato em fonte única:** `src/lib/sollinhaFollowUpSuggestions.ts` — `SOLLINHA_FOLLOW_UP_MARKER` (interpolado no prompt) + `splitSollinhaFollowUpBlock` (última ocorrência vence; body sempre mascarado do marcador em diante; itens `-/*/1./1)` aceitos, bold e duplicados limpos, teto 3; **fail-closed: <2 itens ou linha não-lista → sem chips**, resposta normal). UI: strip no render de todo text part assistant **antes** do markdown/links (bloco nunca pisca como prosa; mensagens antigas restauradas via B188 continuam mascaradas e os chips renascem por derivação); slot B191 estendido (catálogo vazio → follow-ups); tipo compartilhado renomeado `SollinhaOpeningQuestion → SollinhaChatChip`. **Strip defensivo no servidor:** `route.ts` remove o bloco das mensagens assistant ao reconverter para o modelo (próximo turno sem eco nem custo de contexto). Unit (16 casos do split) + e2e novo `campaignAiChatFollowUps.e2e.spec.ts` (wire format v7 como o mock do B187; bolha sem o bloco, chips, substituição, fail-closed, cap mobile). Sem migration/access/Consent/schema/endpoint novo. Intenção [`sollinha-follow-ups-respostas.md`](plans/sollinha-follow-ups-respostas.md); impl [`sollinha-follow-ups-respostas-impl.md`](plans/sollinha-follow-ups-respostas-impl.md). + **Recently resolved (2026-08-10):** **B184** (Issue #514) — no mobile (<768px), o filtro de listas `/campanha` virou o **padrão sem moldura do chassis `CampaignListOmnibox` (B127) — todas as listas**: label oculta (`input` carrega o nome via `aria-label`), campo sem borda/sombra (`md:` restaura o desktop), **form do filtro sticky sob a top bar** com linha separadora edge-to-edge, "Limpar" vira **X circular dentro do input** (aparece com chips OU busca digitada; slot sempre renderizado com `invisible` para a largura não pular; hit target 44px + círculo visual 28px; live region própria "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT), botão texto "Limpar" desktop-only. **Por que sticky no `
`:** sticky não escapa do containing block (o pai) — a coluna do input dentro do wrapper curto do omnibox "grudava" ~12px e rolava junto (medido); o form é o único wrapper filho do `CampaignPageShell` (altura total). **Exceção agenda (C101, merge anterior):** a `/campanha/agenda` já tinha o strip sticky próprio (`activity-agenda-filter-strip`) — mantido; o chassis ainda a cobre no campo/X/Limpar. Em municípios, adicionalmente: **"Salvar filtro" vira icon button no header mobile** (slot C94/C95, `SaveMunicipalityFilterControl presentation='icon'`; popover de nomear intacto; nome de rename é o contrato 2.5.3) e **cards edge-to-edge sem borda** com uma linha separando (`border-b last:border-b-0`, `-mx-4`, gate `md:`). "Nova atualização" (atualizações) — único trailing visível no mobile — foi para o header (mesmo padrão); `campaignMobileHeaderIconClassName` extraída (3º consumidor). E2E novo em `campaignMunicipalities.e2e.spec.ts` (390×844): label oculta, borda 0, sticky, X (limpa chips+busca, foco volta sem reabrir popover), salvar→renomear no header, cards com separador, desktop invertido (1280). Débito registrado no caminho: **D7** (sticky dos headers da tabela desktop morto — wrapper `overflow-x-auto` é o scroll container; medido). Sem migration, access, Consent. Intenção [`municipios-mobile-sem-moldura.md`](plans/municipios-mobile-sem-moldura.md); impl [`municipios-mobile-sem-moldura-impl.md`](plans/municipios-mobile-sem-moldura-impl.md). **Recently resolved (2026-08-09):** **C103** (Issue #504) — o formulário de criação rápida da agenda ficou **usável de ponta a ponta no celular**: no narrow (mesmo breakpoint 640px do calendário), o sheet de criação abre **do topo** (`swipeDirection="up"` na primitiva `Drawer` — que já suportava top sheet; zero mudança em `Drawer.tsx`) preenchendo `100dvh-1rem`, o conteúdo **rola** num scroll container e o rodapé ("Mais detalhes" + "Salvar") é **fixo** — verificado em smoke com teclado aberto (viewport 280px: sheet encolhe via `dvh`, conteúdo rola 187/187px e o Salvar permanece visível). O form ganhou `variant: 'popover' | 'sheet'`: no sheet, **labels viram `sr-only`** (a11y e `getByLabelText` intactos), placeholders dão contexto ("Adicionar título _", "Município _", "Local (opcional)", "Adicionar responsáveis"), obrigatórios marcados com asterisco (título/município no placeholder, Início no trigger), campos empilhados **sem gaps com `divide-y`** e inputs/triggers sem borda (lista iOS do canvas). O **seletor de data/hora abre como bottom sheet aninhado** no mobile (`ActivityDateTimeField` ganhou `isNarrow`/`label`/`required`: conteúdo compartilhado Calendar+selects renderizado em `Drawer` com header "Início — " + "Pronto"; desktop continua popover). Props backward-compatible: `placeholder`/`className` no `StrictCombobox`, `labelClassName`/`emptyText`/`triggerClassName` no `ResponsibleMultiSelect`. Decisão do smoke: swipe handle **removido** do top sheet (primitiva o posiciona no rodapé, contradizendo o gesto de swipe-up; continua nos bottom sheets). E2E mobile novo (viewport 390×844): sheet do topo (`y ≤ 1`), Salvar visível sem scroll, picker em bottom sheet com Pronto, evento aparece após salvar; unit narrow cobre placeholders/asterisco/Pronto. Desktop visualmente inalterado (B181 segue na fila para o clip do popover ≤720px). Sem migration/access/Consent/schema. Intenção [`agenda-mobile-form-criacao-usavel.md`](plans/agenda-mobile-form-criacao-usavel.md); impl [`agenda-mobile-form-criacao-usavel-impl.md`](plans/agenda-mobile-form-criacao-usavel-impl.md). diff --git a/docs/plans/sollinha-follow-ups-respostas-impl.md b/docs/plans/sollinha-follow-ups-respostas-impl.md new file mode 100644 index 00000000..43f9898e --- /dev/null +++ b/docs/plans/sollinha-follow-ups-respostas-impl.md @@ -0,0 +1,96 @@ +# Impl: Sollinha: follow-ups sugeridos após cada resposta (instruídos no prompt, extraídos pela UI) + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #533 +Intenção: docs/plans/sollinha-follow-ups-respostas.md +Appetite restante: herdado (~1 dia eng) — cabe folgado + +## Leitura da intenção + +- **Outcome:** a cada resposta final do Sollinha que inclua o bloco, o slot de chips acima do input mostra 2–3 follow-ups relacionados ao conteúdo; a resposta exibida não contém o bloco; conversa vazia → chips de abertura (B191), após a primeira resposta → follow-ups no mesmo slot; fail-closed (sem bloco/malformado → sem chips, resposta normal); tocar num chip envia a pergunta como mensagem do usuário; geração não consome cota nem adiciona latência (mesmo stream). +- **O que NÃO negociar:** uma única chamada ao modelo (sem segunda chamada/endpoint novo); mesmo slot do B191 acima do input; fail-closed absoluto — nenhum caminho em que a extração estrague a mensagem; só a resposta **final** do turno alimenta o slot; o bloco nunca aparece como prosa na conversa; follow-ups respeitam papel (leader sem sugestão eleitoral — lockdown B180); 2–3 por resposta. +- **O que reavaliar:** a hipótese "extração no cliente antes do render do markdown" (mantém — é o único render de parts do chat, verificado: só `CampaignAIChat.tsx` renderiza `message.parts`); a questão em aberto do formato (A: rótulo estável + lista markdown) é assumida com **marcador exato** decidido aqui; a questão "limpar o bloco do texto reenviado" ganha resposta concreta: **sim, strip defensivo no servidor** (custo 3 linhas, reusa a mesma função pura); "sempre incluir ou só quando útil" — instrução de sempre incluir em respostas factuais (rec. A da intenção), com exclusão explícita de saudações/esclarecimentos/erros. + +## Abordagem recomendada + +```mermaid +flowchart LR + P[systemPrompt.ts — seção final: formato do bloco] --> M[deepseek-v4-flash responde] + M -->|texto com bloco no fim| C[CampaignAIChat] + LIB[src/lib/sollinhaFollowUpSuggestions.ts — split puro] --> C + C -->|body| BUBBLE[Bolha sem o bloco] + C -->|status ready + última msg assistant| SLOT[Slot acima do input] + SLOT --> CHIP[ChatChipGroup — reuso B191] + CHIP -->|sendMessage text| CHAT[useChat] + ROUTE[route.ts — strip defensivo ao reconverter] -->|próximo turno sem bloco| M +``` + +**Opções consideradas:** + +- A — Instrução no prompt + bloco no fim da resposta + extração pura no cliente (`split` body/suggestions), render do body sem o bloco, chips no slot do B191; strip do bloco também ao reconverter as mensagens para o modelo no próximo turno (`route.ts`). +- B — Segunda chamada de IA (modelo menor) para gerar sugestões após a resposta. +- C — Extração client-side por heurística sobre o conteúdo da resposta (sem instrução no prompt), ex.: extrair perguntas "?" do texto. +- D — Mutação do histórico no settle (stripar o bloco via `setMessages` quando `status` fica `ready`), guardando os chips em estado derivado. + +**Recomendação: A** — é a única que honra a decisão de mecanismo do gate da intenção (o bloco vem na própria resposta, uma chamada, zero latência extra) e mantém o fail-closed como propriedade natural de uma função pura: sem bloco → body = texto integral, chips vazios. O bloco **fica** na mensagem persistida (B188) e a extração é derivada — chips renascem numa restauração de sessão de graça, e respostas antigas com bloco seguem mascaradas na exibição. O strip no servidor (parte da opção A) garante que o modelo nunca veja o bloco no turno seguinte (o prompt também instrui, mas o strip é determinístico e corta o eco). +**Rejeitadas:** B porque viola a decisão de gate (custo, latência, cota) e a intenção a descarta explicitamente; C porque não tem contrato estável — o modelo não sabe que deve fechar com o bloco e a heurística pegaria ruído ("posso te ajudar com algo?"); D porque destrói a re-derivação após reload (B188 restaura a mensagem **sem** o bloco → chips perdidos), adiciona máquina de estado (efeito + estado de chips) e o strip no momento errado (durante streaming a mensagem ainda muda). + +### Componentes / mudanças + +- **`src/lib/sollinhaFollowUpSuggestions.ts`** (novo, puro, client-safe): `SOLLINHA_FOLLOW_UP_MARKER` (fonte única do contrato — o prompt interpola a constante, nunca duplica) + `splitSollinhaFollowUpBlock(text): { body, suggestions }`: + - `body` = texto antes da **última** ocorrência do marcador, com whitespace final aparado; sem marcador → body = texto integral. + - `suggestions` = itens de lista (linhas `- ` / `* ` / `N. `) extraídos do bloco (tudo após o marcador), trimados, vazios descartados, teto 3. + - **Fail-closed do contrato:** menos de 2 itens parseáveis → `suggestions: []` (o contrato de produto é "2–3"; um chip solto parece quebrado; resposta segue normal). +- **`src/utilities/ai/systemPrompt.ts`**: nova seção final `## Sugestões de continuação (formato para a interface)` interpolando `SOLLINHA_FOLLOW_UP_MARKER`: + - Em respostas factuais (dados, explicações, links), encerrar com o marcador + 2–3 perguntas curtas de follow-up, como lista markdown. + - Só perguntas que o Sollinha responde com as próprias ferramentas ("nunca algo que responderia 'ainda não tenho acesso'"). + - Respeitar o papel: leader não recebe sugestão eleitoral/staff. + - O bloco é instrução de formato para a interface, não conteúdo: nunca citar em prosa, nunca repetir no corpo, nunca no meio da resposta, nada depois do bloco. + - Sem bloco em saudações, perguntas de esclarecimento ou respostas de erro. +- **`src/components/campaign/shell/ai/CampaignAIChat.tsx`**: + - Render: cada text part de mensagem assistant passa por `splitSollinhaFollowUpBlock` → renderiza `body` no markdown (strip vale para todas as mensagens assistant, inclusive streaming — o bloco nunca pisca como prosa; a extração roda antes do markdown, antes de qualquer transformação de link/markdown, sem duplicar pós-processamento). + - Slot: mesmo lugar do B191 (acima do ``). Prioridade: `messages.length === 0 && status === 'ready'` → chips de abertura (inalterado); senão, `status === 'ready'` + última mensagem assistant com `suggestions` não-vazias → follow-ups; durante `busy` → nada (rec. A da intenção: chips somem durante a geração). Cap por viewport: 3 desktop / 2 mobile (`FOLLOW_UP_MOBILE_LIMIT = 2`). + - `onPick` → `sendMessage({ text })` (mesmo mecanismo do B191). + - Derivação: última mensagem `role === 'assistant'` → último text part → `suggestions`. Só a resposta final do turno alimenta o slot. +- **`src/app/(campaign)/campanha/api/ai-chat/route.ts`**: após `convertToModelMessages(messages)`, mapear mensagens assistant (content string ou array de parts) com `splitSollinhaFollowUpBlock(...).body` — strip idempotente, o modelo nunca recebe o bloco de volta no contexto. +- **Tipo compartilhado (polish pequeno):** renomear `SollinhaOpeningQuestion` → `SollinhaChatChip` (o slot é compartilhado; o nome atual mentiria com 2 consumidores) — toca `sollinhaOpeningQuestions.ts`, `ChatChipGroup.tsx`, `CampaignAIChat.tsx` (sem mudança de comportamento). A extração retorna `string[]` e o call-site mapeia `{ text }` — decisão tomada junto da rename para o contrato do `ChatChipGroup` ser a única "forma" de chip. +- **Migration:** sem migration (nenhuma mudança de schema/collection). +- **Access / Consent:** nenhum — a regra de papel vive no prompt (herda o lockdown B180 pelo lado da **sugestão**; o enforcement das tools não muda). +- **UI:** Impeccable B — encaixe na superfície existente. Shape vem do B191 (pills no mesmo slot); craft: reuso integral do `ChatChipGroup` (tokens do tema, claro/escuro nativos, foco visível), zero CSS/motion novo; critique/polish só no que o gate B apontar. + +### Dados → forma (se aplicável) + +- N/A: chips são textos derivados da resposta; nenhuma métrica/KPI/série nova. O "dado" é o contrato do marcador, que é fonte única em `src/lib/` e interpolado no prompt (sem drift cliente/prompt). + +## Fases verificáveis + +1. **Módulo puro + testes unit (quota ~35%)** — `sollinhaFollowUpSuggestions.ts` + `tests/unit/campaignSollinhaFollowUpSuggestions.unit.spec.ts`: sem marcador (body integral, chips vazios); marcador + 2–3 bullets (body sem bloco, chips certos); 4 bullets → teto 3; 1 bullet → fail-closed `[]`; lista numerada aceita; marcador repetido → última ocorrência vence; texto depois da lista (sign-off) → fail-closed; idempotência (split de body já limpo); itens vazios descartados; marcador sem lista. +2. **Prompt + route strip** — seção no `systemPrompt.ts` (interpolação da constante) + strip no `route.ts`; sem teste próprio (coberto por tsc/gates + teste do módulo puro). +3. **UI** — `CampaignAIChat`: strip no render + slot de follow-ups (reuso `ChatChipGroup`, cap mobile 2/desktop 3, some durante busy) + rename do tipo `SollinhaChatChip`. +4. **E2E** — `tests/e2e/campaignAiChatFollowUps.e2e.spec.ts` (mock do `/campanha/api/ai-chat`, padrão do `campaignAiChatOpeningChips.e2e.spec.ts`): resposta com bloco → bolha **sem** o marcador/lista, chips aparecem acima do input; tocar num chip envia a pergunta e a conversa continua; resposta **sem** bloco → sem chips, resposta íntegra (fail-closed); cap mobile (2 chips no drawer). +5. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Não criar segundo componente de chip nem abstração nova — o slot é um só, alimentado por duas fontes (catálogo B191 / extração B192). +- Não guardar chips em estado próprio nem persistir (B188 cobre a conversa; chips são derivados). +- Não limpar o bloco da sessão persistida (B188): o strip é derivado na exibição — mexer no armazenamento quebraria a re-derivação pós-reload e duplicaria a lógica. +- Não tocar tools, `electionDataGate`, Consent, schema. Não adicionar chamada/endpoint/modelo novo. +- Não inventar marcador alternativo tolerante (ex.: aceitar "Sugestões:" sem o bold): o contrato é um só, fail-closed cobre desvios. + +## Riscos e mitigação + +- **Aderência probabilística do modelo** (esquece o bloco ou formata errado em respostas longas com tools): fail-closed no cliente (sem chips, resposta normal); instrução curta no fim do prompt; marcador explícito e curto; risco residual aceito pela intenção (menor que mecanismo determinístico rígido). +- **Bloco no meio da resposta / texto após o bloco**: tudo a partir da última ocorrência do marcador é tratado como bloco — legítima body pode ser cortada num desvio do modelo; aceito (contrato instrui "bloco é a última coisa"); o leak nunca é prosa quebrada, e o fail-closed da contagem de itens limita o dano. +- **Bloco contamina o histórico do modelo**: strip no `route.ts` (determinístico) + instrução no prompt; dupla proteção. +- **Conflito de render com B187/B188/B191** (mesmo `CampaignAIChat.tsx`): todos já merged em main (B191 #554, B187 #559, B188) — sem conflito de branch; o strip roda antes do markdown/links (ordem documentada na intenção). +- **Chips somem durante streaming e voltam na chegada**: comportamento deliberado (rec. A da intenção) — input travado durante `busy`, chips mortos confundiriam. +- **`status` não-`ready` com conversa restaurada (B188)**: chips só renderizam com `ready` — sem estado morto. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (mesmo slot, 2–3 chips, fail-closed, tocar envia, resposta sem bloco, papel respeitado) +- [ ] Invariantes AGENTS/engineering-standards (identificadores em inglês, copy pt-BR, sem schema, sem Consent novo, client boundary — módulo puro client-safe, sem imports server) +- [ ] Testes de domínio previstos: unit do split + e2e do fluxo (com bloco / sem bloco, cap mobile, envio) +- [ ] Sem efeitos de streaming na extração (função pura por render, idempotente) e sem estado de chips novo diff --git a/src/app/(campaign)/campanha/api/ai-chat/route.ts b/src/app/(campaign)/campanha/api/ai-chat/route.ts index ccb88f52..75fe9aad 100644 --- a/src/app/(campaign)/campanha/api/ai-chat/route.ts +++ b/src/app/(campaign)/campanha/api/ai-chat/route.ts @@ -13,6 +13,7 @@ import { checkRateLimit } from '@/utilities/ai/rateLimit' import { AI_SYSTEM_PROMPT } from '@/utilities/ai/systemPrompt' import { buildAITools } from '@/utilities/ai/tools' import { getCampaignUserRaw } from '@/utilities/campaignAuth' +import { splitSollinhaFollowUpBlock } from '@/lib/sollinhaFollowUpSuggestions' export const maxDuration = 60 @@ -37,10 +38,30 @@ export async function POST(req: Request) { const { messages } = await req.json() + const modelMessages = await convertToModelMessages(messages) + + // B192 — the follow-up block is a format directive for the chat UI, not + // conversation content: strip it before the model ever sees it again, so a + // previous answer's suggestions neither echo back nor burn context. Uses the + // same pure splitter as the client (idempotent; no block → unchanged). + const strippedMessages = modelMessages.map((message) => { + if (message.role !== 'assistant') return message + const content = Array.isArray(message.content) + ? message.content.map((part) => + part.type === 'text' + ? { ...part, text: splitSollinhaFollowUpBlock(part.text).body } + : part, + ) + : typeof message.content === 'string' + ? splitSollinhaFollowUpBlock(message.content).body + : message.content + return { ...message, content } + }) + const result = streamText({ model: deepSeek('deepseek-v4-flash'), system: AI_SYSTEM_PROMPT, - messages: await convertToModelMessages(messages), + messages: strippedMessages, tools: buildAITools({ user, payload }), stopWhen: stepCountIs(10), }) diff --git a/src/components/campaign/shell/ai/CampaignAIChat.tsx b/src/components/campaign/shell/ai/CampaignAIChat.tsx index 440865e1..12981d76 100644 --- a/src/components/campaign/shell/ai/CampaignAIChat.tsx +++ b/src/components/campaign/shell/ai/CampaignAIChat.tsx @@ -19,6 +19,7 @@ import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarCo import { ChatChipGroup } from '@/components/campaign/shell/ai/ChatChipGroup' import { useMicTranscript } from '@/components/campaign/shell/ai/useMicTranscript' import { getSollinhaOpeningQuestions } from '@/lib/sollinhaOpeningQuestions' +import { splitSollinhaFollowUpBlock } from '@/lib/sollinhaFollowUpSuggestions' /** * B187+B188 — links in the assistant's markdown must look like links: brand @@ -32,6 +33,9 @@ import { getSollinhaOpeningQuestions } from '@/lib/sollinhaOpeningQuestions' */ const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/ +/** B192 — narrower drawer surface: fewer follow-up chips than on desktop. */ +const FOLLOW_UP_MOBILE_LIMIT = 2 + const markdownComponents: Components = { a: ({ node: _node, href, children, ...rest }) => { const external = typeof href === 'string' ? externalLinkTarget(href) : null @@ -60,6 +64,26 @@ export const CampaignAIChat = ({ className }: { className?: string }) => { const messages = useMemo(() => ctx?.messages ?? [], [ctx]) + // B192 — follow-up suggestions of the LATEST assistant message (the final + // response of the turn — older answers never feed the slot). Derived before + // the `ctx` early return so the hooks stay unconditional; only rendered once + // the chat is idle (during streaming the input is locked, so dead chips + // would confuse; the new ones appear the moment the answer lands). + const latestAssistantSuggestionText = useMemo(() => { + if (ctx?.status !== 'ready' || messages.length === 0) return null + const lastAssistant = [...messages].reverse().find((message) => message.role === 'assistant') + return lastAssistant?.parts.filter((part) => part.type === 'text').at(-1)?.text ?? null + }, [messages, ctx?.status]) + + const followUpSuggestions = useMemo(() => { + if (!latestAssistantSuggestionText) return [] + const { suggestions } = splitSollinhaFollowUpBlock(latestAssistantSuggestionText) + // Viewport cap mirrors the opening catalog: fewer chips on narrow surfaces. + return suggestions.slice(0, ctx?.isMobile ? FOLLOW_UP_MOBILE_LIMIT : suggestions.length) + }, [latestAssistantSuggestionText, ctx?.isMobile]) + + const showFollowUpChips = followUpSuggestions.length > 0 + // Auto-scroll to bottom on new messages useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -135,6 +159,11 @@ export const CampaignAIChat = ({ className }: { className?: string }) => { ) } + // B192 — the follow-up block is a format directive + // for the chip slot, never chat content: strip it + // BEFORE markdown/link rendering so it never shows + // as prose (fail-closed: no marker → unchanged). + const { body } = splitSollinhaFollowUpBlock(part.text) return (
{ remarkPlugins={[remarkGfm]} components={markdownComponents} > - {part.text} + {body}
) @@ -186,9 +215,16 @@ export const CampaignAIChat = ({ className }: { className?: string }) => { {/* Input area */}
- {showOpeningChips && ( + {/* B191+B192 — one chip slot above the input, fed by two sources: the + curated opening catalog while the conversation is empty, and the + follow-ups extracted from the latest answer afterwards. */} + {(showOpeningChips || showFollowUpChips) && ( ({ text })) + } onPick={({ text }) => { if (busy) return sendMessage({ text }) diff --git a/src/components/campaign/shell/ai/ChatChipGroup.tsx b/src/components/campaign/shell/ai/ChatChipGroup.tsx index 6a7b2184..66aac1df 100644 --- a/src/components/campaign/shell/ai/ChatChipGroup.tsx +++ b/src/components/campaign/shell/ai/ChatChipGroup.tsx @@ -2,7 +2,7 @@ import { cn } from '@/lib/utils' -import type { SollinhaOpeningQuestion } from '@/lib/sollinhaOpeningQuestions' +import type { SollinhaChatChip } from '@/lib/sollinhaOpeningQuestions' /** * Presentational chip group for the chat's quick-question slot (B191). Renders @@ -14,8 +14,8 @@ export const ChatChipGroup = ({ onPick, className, }: { - questions: readonly SollinhaOpeningQuestion[] - onPick: (question: SollinhaOpeningQuestion) => void + questions: readonly SollinhaChatChip[] + onPick: (question: SollinhaChatChip) => void className?: string }) => { if (questions.length === 0) return null diff --git a/src/lib/sollinhaFollowUpSuggestions.ts b/src/lib/sollinhaFollowUpSuggestions.ts new file mode 100644 index 00000000..feaf3740 --- /dev/null +++ b/src/lib/sollinhaFollowUpSuggestions.ts @@ -0,0 +1,70 @@ +/** + * Sollinha follow-up suggestions (B192): the assistant is instructed (see + * `systemPrompt.ts`, which interpolates `SOLLINHA_FOLLOW_UP_MARKER`) to end + * factual answers with a stable block — the marker line followed by a markdown + * list of 2–3 follow-up questions. This module is the single source of the + * contract: it splits the raw response text into the body to display and the + * suggestions that feed the chat's chip slot. + * + * Fail-closed by design on the SUGGESTIONS: no marker, a malformed block, a + * truncated block or fewer than two parseable items all yield no chips — the + * answer renders as today, and nothing the model does can corrupt the + * displayed message. The BODY is always stripped from the marker onward (the + * block is a format directive for the interface, never chat content), whether + * or not the suggestions parse. + */ +export const SOLLINHA_FOLLOW_UP_MARKER = '**Sugestões de continuação:**' + +/** Upper bound per the product contract ("2–3 per answer"). */ +const SOLLINHA_FOLLOW_UP_MAX = 3 + +/** + * Fewer than this many parseable items is treated as a malformed block: the + * product contract is "2–3 chips", and a single stray chip looks broken. + */ +const SOLLINHA_FOLLOW_UP_MIN = 2 + +/** Markdown bullets, dashes and numbered lists (`1.` and pt-BR `1)`). */ +const LIST_ITEM = /^\s*(?:[-*]|\d+[.)])\s+(.+?)\s*$/ + +export type SollinhaFollowUpBlock = { + /** Response text with the block removed (trailing whitespace trimmed). */ + body: string + /** Follow-up questions parsed from the block; empty when absent/malformed. */ + suggestions: string[] +} + +const parseSuggestions = (block: string): string[] => { + const items: string[] = [] + for (const line of block.split('\n')) { + const match = LIST_ITEM.exec(line) + if (match) { + const text = match[1]?.trim().replaceAll('**', '') + if (text && !items.includes(text) && items.length < SOLLINHA_FOLLOW_UP_MAX) { + items.push(text) + } + } else if (line.trim()) { + // A non-empty line that is not a list item means the block is not the + // clean marker+list the prompt contracts (prose after the list, text on + // the marker line, …) — treat the whole block as malformed (fail-closed). + return [] + } + } + return items.length >= SOLLINHA_FOLLOW_UP_MIN ? items : [] +} + +/** + * Splits an assistant message text at the LAST occurrence of the marker — + * everything from the marker onward is the block, per the prompt contract + * ("the block is the last thing in the response"). Idempotent: a body that was + * already stripped has no marker and passes through unchanged. + */ +export const splitSollinhaFollowUpBlock = (text: string): SollinhaFollowUpBlock => { + const markerIndex = text.lastIndexOf(SOLLINHA_FOLLOW_UP_MARKER) + if (markerIndex === -1) { + return { body: text, suggestions: [] } + } + const body = text.slice(0, markerIndex).trimEnd() + const block = text.slice(markerIndex + SOLLINHA_FOLLOW_UP_MARKER.length) + return { body, suggestions: parseSuggestions(block) } +} diff --git a/src/lib/sollinhaOpeningQuestions.ts b/src/lib/sollinhaOpeningQuestions.ts index 4d5ab1ee..c45a6e79 100644 --- a/src/lib/sollinhaOpeningQuestions.ts +++ b/src/lib/sollinhaOpeningQuestions.ts @@ -12,13 +12,16 @@ import type { CampaignRole } from '@/lib/campaignRoles' import { isStaffCampaignRole } from '@/lib/campaignRoles' -export type SollinhaOpeningQuestion = { - /** The exact text sent as the user message when the chip is picked. */ +/** + * A text chip in the chat's quick-question slot (B191 opening catalog, B192 + * follow-ups) — the exact text sent as the user message when picked. + */ +export type SollinhaChatChip = { text: string } /** Staff (coordinator/advisor/candidate): 4 chips on wide surfaces, 3 on mobile. */ -const STAFF_OPENING_QUESTIONS: readonly SollinhaOpeningQuestion[] = [ +const STAFF_OPENING_QUESTIONS: readonly SollinhaChatChip[] = [ { text: 'Quem foi o deputado mais votado em Feira de Santana?' }, { text: 'Quantos votos tivemos em Ilhéus em 2022?' }, { text: 'Quais dobradinhas temos em Salvador?' }, @@ -26,7 +29,7 @@ const STAFF_OPENING_QUESTIONS: readonly SollinhaOpeningQuestion[] = [ ] /** Leader: 3 chips, all answerable by the leader-facing toolset today. */ -const LEADER_OPENING_QUESTIONS: readonly SollinhaOpeningQuestion[] = [ +const LEADER_OPENING_QUESTIONS: readonly SollinhaChatChip[] = [ { text: 'O que você sabe fazer?' }, { text: 'Me manda o link dos meus contatos' }, { text: 'Me manda o link do meu perfil' }, @@ -42,7 +45,7 @@ const MOBILE_LIMIT = 3 export const getSollinhaOpeningQuestions = ( role: CampaignRole, isMobile: boolean, -): readonly SollinhaOpeningQuestion[] => { +): readonly SollinhaChatChip[] => { const catalog = isStaffCampaignRole(role) ? STAFF_OPENING_QUESTIONS : LEADER_OPENING_QUESTIONS return isMobile ? catalog.slice(0, MOBILE_LIMIT) : catalog } diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts index 101e5449..4ad78819 100644 --- a/src/utilities/ai/systemPrompt.ts +++ b/src/utilities/ai/systemPrompt.ts @@ -1,3 +1,5 @@ +import { SOLLINHA_FOLLOW_UP_MARKER } from '@/lib/sollinhaFollowUpSuggestions' + export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da campanha de Jorge Solla (PT-BA) para deputado federal. ## Quem você é @@ -58,4 +60,16 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp - 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. - Os dados de 2026 ainda não existem — você trabalha com os históricos de 2014, 2018 e 2022. + +## Sugestões de continuação (formato para a interface) +- Ao responder uma pergunta com conteúdo útil (dados, explicações, links), encerre a resposta com 2–3 perguntas curtas de follow-up que o usuário possa tocar para continuar a conversa. +- Formato exato — o bloco é a ÚLTIMA coisa da resposta, sem nenhum texto depois: + ${SOLLINHA_FOLLOW_UP_MARKER} + - ? + - ? +- Regras do bloco: + - O bloco é uma instrução de formato para a interface, não faz parte da conversa: nunca fale dele em prosa ("posso sugerir..."), nunca repita as sugestões no corpo da resposta e nunca o coloque no meio da resposta. + - Só sugira perguntas que VOCÊ consegue responder com as suas próprias ferramentas — nunca algo que responderia "ainda não tenho acesso a essa informação". + - Respeite o papel do usuário: para liderança (role leader), nada de sugestões sobre dados eleitorais ou áreas staff — apenas o que a liderança pode perguntar. + - Não use o bloco em saudações, perguntas de esclarecimento ou respostas de erro. ` diff --git a/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts b/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts new file mode 100644 index 00000000..4b70d1d4 --- /dev/null +++ b/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts @@ -0,0 +1,190 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from './fixtures/campaignE2EFixtures.js' + +/** + * Mock the AI endpoint with SDK v7 UI-message-stream SSE chunks (start / + * text-start / text-delta / text-end / finish — the same wire format the + * `campaignAiLinks.e2e.spec.ts` mock uses; a bare legacy `{"type":"text"}` + * chunk fails the client schema and the chat never settles), deciding the + * answer from the last user text so the follow-up slot can be asserted across + * the conversation. + */ +const FIRST_RESPONSE = `Em 2022, o Solla teve 12.345 votos em Ilhéus. + +**Sugestões de continuação:** +- Quantos votos tivemos em Salvador em 2022? +- Quem foi o deputado mais votado em Barreiras? +- Como está o município de Vitória da Conquista?` + +const SECOND_RESPONSE = `Em 2022, foram 88.765 votos em Salvador. + +**Sugestões de continuação:** +- Qual a votação em Feira de Santana? +- Quais dobradinhas temos em Salvador?` + +const THIRD_RESPONSE = `Os dados de 2026 ainda não existem.` + +const lastUserText = (body: unknown): string | null => { + const messages = + (body as { messages?: Array<{ role?: string; parts?: Array<{ type?: string; text?: string }> }> }) + ?.messages ?? [] + for (const message of [...messages].reverse()) { + if (message.role !== 'user') continue + for (const part of message.parts ?? []) { + if (part.type === 'text' && part.text) return part.text + } + } + return null +} + +const mockAiChat = (page: Page) => + page.route('**/campanha/api/ai-chat', async (route) => { + const raw = route.request().postData() ?? '' + const userText = lastUserText(JSON.parse(raw || '{}')) + const response = + userText?.includes('Ilhéus') ? FIRST_RESPONSE + : userText?.includes('Salvador em 2022?') ? SECOND_RESPONSE + : THIRD_RESPONSE + await route.fulfill({ + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + body: [ + 'data: {"type":"start"}\n\n', + 'data: {"type":"text-start","id":"t1"}\n\n', + `data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(response)}}\n\n`, + 'data: {"type":"text-end","id":"t1"}\n\n', + 'data: {"type":"finish","finishReason":"stop"}\n\n', + ].join(''), + }) + }) + +const chipButton = (page: Page, text: string) => + page.getByRole('button', { name: text, exact: true }) + +/** + * Same hydration flake class as B191: clicks landing before hydration are + * silent no-ops. Retry until the send actually lands — sending unmounts the + * chips (`status` flips to busy), which is the unambiguous observable. + */ +const pickChip = async (page: Page, text: string) => { + await expect(async () => { + await chipButton(page, text).click({ timeout: 1_000 }) + await expect(chipButton(page, text)).toHaveCount(0, { timeout: 1_000 }) + }).toPass({ timeout: 15_000 }) +} + +const askViaInput = async (page: Page, text: string, expected: string) => { + const input = page.getByPlaceholder('Pergunte para o Sollinha...') + // Dev-mode defense (same as campaignAiLinks): a first-hit route compile can + // trigger a full-page reload that wipes the draft — re-send until the answer + // actually lands. + await expect(async () => { + await input.fill(text) + await input.press('Enter') + await expect(page.getByText(expected)).toBeVisible({ timeout: 10_000 }) + }).toPass({ timeout: 60_000 }) +} + +test.describe('B192 — follow-ups sugeridos após cada resposta do Sollinha', () => { + test.beforeEach(async ({ page }) => { + test.slow() + await mockAiChat(page) + }) + + test('resposta com bloco: bolha sem o bloco, chips acima do input, tocar envia e o slot é substituído', async ({ + page, + campaign, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Follow-up Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + + // B191 gesture opens the conversation; the mock answers with a block. + await pickChip(page, 'Quantos votos tivemos em Ilhéus em 2022?') + await expect(page.getByText('Em 2022, o Solla teve 12.345 votos em Ilhéus.')).toBeVisible({ + timeout: 20_000, + }) + + // The block never shows as prose — not even the marker line. + await expect(page.getByText('Sugestões de continuação')).toHaveCount(0) + + // The three follow-ups occupy the slot; the opening catalog is gone. + for (const text of [ + 'Quantos votos tivemos em Salvador em 2022?', + 'Quem foi o deputado mais votado em Barreiras?', + 'Como está o município de Vitória da Conquista?', + ]) { + await expect(chipButton(page, text)).toBeVisible() + } + await expect( + chipButton(page, 'Quem foi o deputado mais votado em Feira de Santana?'), + ).toHaveCount(0) + + // Touching a follow-up sends it as the user's message and the slot is + // replaced by the next answer's suggestions. + await pickChip(page, 'Quantos votos tivemos em Salvador em 2022?') + await expect(page.getByText('Em 2022, foram 88.765 votos em Salvador.')).toBeVisible({ + timeout: 20_000, + }) + await expect(chipButton(page, 'Qual a votação em Feira de Santana?')).toBeVisible() + await expect(chipButton(page, 'Quais dobradinhas temos em Salvador?')).toBeVisible() + await expect(chipButton(page, 'Como está o município de Vitória da Conquista?')).toHaveCount(0) + + // An answer without a block empties the slot (fail-closed, no error). + await pickChip(page, 'Quais dobradinhas temos em Salvador?') + await expect(page.getByText('Os dados de 2026 ainda não existem.')).toBeVisible({ + timeout: 20_000, + }) + await expect(chipButton(page, 'Qual a votação em Feira de Santana?')).toHaveCount(0) + await expect(page.getByText('Sugestões de continuação')).toHaveCount(0) + }) + + test('drawer mobile: no máximo 2 follow-ups por resposta', async ({ page, campaign }) => { + await page.setViewportSize({ width: 500, height: 800 }) + const user = await campaign.fixtures.createCampaignUser('advisor', { + name: campaign.fixtures.value('Follow-up Assessor'), + }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' }) + await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click() + await expect(drawer).toBeVisible({ timeout: 20_000 }) + + await askViaInput(page, 'Quantos votos tivemos em Ilhéus em 2022?', 'Em 2022, o Solla teve 12.345 votos em Ilhéus.') + await expect(page.getByText('Em 2022, o Solla teve 12.345 votos em Ilhéus.')).toBeVisible({ + timeout: 20_000, + }) + + await expect(chipButton(page, 'Quantos votos tivemos em Salvador em 2022?')).toBeVisible() + await expect(chipButton(page, 'Quem foi o deputado mais votado em Barreiras?')).toBeVisible() + await expect( + chipButton(page, 'Como está o município de Vitória da Conquista?'), + ).toHaveCount(0) + await expect(page.getByText('Sugestões de continuação')).toHaveCount(0) + }) + + test('resposta sem bloco: sem chips e sem erro (fail-closed)', async ({ page, campaign }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Follow-up Sem Bloco'), + }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + await askViaInput(page, 'Os dados de 2026 já existem?', 'Os dados de 2026 ainda não existem.') + await expect(page.getByText('Os dados de 2026 ainda não existem.')).toBeVisible({ + timeout: 20_000, + }) + + await expect(chipButton(page, 'Quantos votos tivemos em Salvador em 2022?')).toHaveCount(0) + await expect(page.getByText('Sugestões de continuação')).toHaveCount(0) + }) +}) diff --git a/tests/unit/campaignSollinhaFollowUpSuggestions.unit.spec.ts b/tests/unit/campaignSollinhaFollowUpSuggestions.unit.spec.ts new file mode 100644 index 00000000..3028c809 --- /dev/null +++ b/tests/unit/campaignSollinhaFollowUpSuggestions.unit.spec.ts @@ -0,0 +1,125 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + SOLLINHA_FOLLOW_UP_MARKER, + splitSollinhaFollowUpBlock, +} from '@/lib/sollinhaFollowUpSuggestions' + +const marker = SOLLINHA_FOLLOW_UP_MARKER + +describe('splitSollinhaFollowUpBlock', () => { + it('passes text without the marker through unchanged and yields no chips', () => { + const text = 'Em 2022, tivemos 12.345 votos em Ilhéus.\n\nQualquer coisa é só chamar!' + expect(splitSollinhaFollowUpBlock(text)).toEqual({ body: text, suggestions: [] }) + }) + + it('strips the block and returns 2–3 suggestions as chips', () => { + const text = `Em 2022, tivemos 12.345 votos em Ilhéus.\n\n${marker}\n- Quantos votos tivemos em Salvador?\n- Quem foi o deputado mais votado em Feira de Santana?\n- Como está o município de Vitória da Conquista?` + const { body, suggestions } = splitSollinhaFollowUpBlock(text) + expect(body).toBe('Em 2022, tivemos 12.345 votos em Ilhéus.') + expect(suggestions).toEqual([ + 'Quantos votos tivemos em Salvador?', + 'Quem foi o deputado mais votado em Feira de Santana?', + 'Como está o município de Vitória da Conquista?', + ]) + }) + + it('accepts a 2-item block (the contract minimum)', () => { + const text = `Resposta.\n\n${marker}\n- Primeira?\n- Segunda?` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual(['Primeira?', 'Segunda?']) + }) + + it('caps suggestions at 3 items', () => { + const text = `Resposta.\n\n${marker}\n- Um?\n- Dois?\n- Três?\n- Quatro?` + expect(splitSollinhaFollowUpBlock(text).suggestions).toHaveLength(3) + }) + + it('fails closed on a single-item block (contract is 2–3)', () => { + const text = `Resposta.\n\n${marker}\n- Só uma pergunta?` + const { body, suggestions } = splitSollinhaFollowUpBlock(text) + expect(suggestions).toEqual([]) + expect(body).toBe('Resposta.') + }) + + it('fails closed on a marker without any list', () => { + const text = `Resposta.\n\n${marker}\n\nQualquer coisa, é só chamar!` + const { body, suggestions } = splitSollinhaFollowUpBlock(text) + expect(suggestions).toEqual([]) + expect(body).toBe('Resposta.') + }) + + it('accepts numbered list items', () => { + const text = `Resposta.\n\n${marker}\n1. Primeira?\n2. Segunda?\n3. Terceira?` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual([ + 'Primeira?', + 'Segunda?', + 'Terceira?', + ]) + }) + + it('accepts pt-BR `1)`-style numbered items', () => { + const text = `Resposta.\n\n${marker}\n1) Primeira?\n2) Segunda?` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual(['Primeira?', 'Segunda?']) + }) + + it('strips markdown bold from item text (chips send plain questions)', () => { + const text = `Resposta.\n\n${marker}\n- **Quantos votos tivemos em Salvador?**\n- Quem foi o mais votado em Barreiras?` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual([ + 'Quantos votos tivemos em Salvador?', + 'Quem foi o mais votado em Barreiras?', + ]) + }) + + it('deduplicates repeated items', () => { + const text = `Resposta.\n\n${marker}\n- Mesma pergunta?\n- Mesma pergunta?\n- Outra?` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual([ + 'Mesma pergunta?', + 'Outra?', + ]) + }) + + it('handles CRLF line endings', () => { + const text = `Resposta.\r\n\r\n${marker}\r\n- Primeira?\r\n- Segunda?` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual(['Primeira?', 'Segunda?']) + }) + + it('fails closed on prose after a full 3-item list (cap does not mask it)', () => { + const text = `Resposta.\n\n${marker}\n- Uma?\n- Duas?\n- Três?\n\nEspero ter ajudado!` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual([]) + }) + + it('uses the LAST occurrence of the marker when the model repeats it', () => { + const text = `Resposta.\n\n${marker}\n- Antigas?\n- Também antigas?\n\nMais corpo.\n\n${marker}\n- Novas?\n- Atuais?` + const { body, suggestions } = splitSollinhaFollowUpBlock(text) + expect(body).toBe('Resposta.\n\n**Sugestões de continuação:**\n- Antigas?\n- Também antigas?\n\nMais corpo.') + expect(suggestions).toEqual(['Novas?', 'Atuais?']) + }) + + it('fails closed when prose follows the list (the block is not the last thing)', () => { + const text = `Resposta.\n\n${marker}\n- Uma?\n- Duas?\n\nEspero ter ajudado!` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual([]) + }) + + it('drops empty lines and trims item text', () => { + const text = `Resposta.\n\n${marker}\n- Uma pergunta com espaços? \n\n- Outra?` + expect(splitSollinhaFollowUpBlock(text).suggestions).toEqual([ + 'Uma pergunta com espaços?', + 'Outra?', + ]) + }) + + it('is idempotent — an already-stripped body passes through unchanged', () => { + const text = 'Em 2022, tivemos 12.345 votos em Ilhéus.' + expect(splitSollinhaFollowUpBlock(splitSollinhaFollowUpBlock(text).body)).toEqual({ + body: text, + suggestions: [], + }) + }) + + it('trims trailing whitespace of the body', () => { + const text = `Resposta com fim de linha.\n\n${marker}\n- Uma?\n- Duas?` + expect(splitSollinhaFollowUpBlock(text).body).toBe('Resposta com fim de linha.') + }) +})