feat(B192): follow-ups sugeridos após cada resposta do Sollinha (bloco no prompt + chips extraídos)

This commit is contained in:
Francisco Solla 2026-08-10 08:43:16 -03:00
parent a970f05c8a
commit 382c4c46ec
10 changed files with 571 additions and 12 deletions

View file

@ -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 `<form>`:** 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 — <data>" + "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).

View file

@ -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 `<form>`). 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

View file

@ -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),
})

View file

@ -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 }) => {
</span>
)
}
// 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 (
<div
key={index}
@ -144,7 +173,7 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{part.text}
{body}
</ReactMarkdown>
</div>
)
@ -186,9 +215,16 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
{/* Input area */}
<div className="shrink-0 border-t border-border px-3 py-3">
{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) && (
<ChatChipGroup
questions={openingQuestions}
questions={
showOpeningChips
? openingQuestions
: followUpSuggestions.map((text) => ({ text }))
}
onPick={({ text }) => {
if (busy) return
sendMessage({ text })

View file

@ -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

View file

@ -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) }
}

View file

@ -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
}

View file

@ -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}
- <pergunta 1>?
- <pergunta 2>?
- 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.
`

View file

@ -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)
})
})

View file

@ -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.')
})
})