feat(C159): Sollinha na assessoria de comunicação — chat escopado ao acervo (Closes #983)

This commit is contained in:
Francisco Solla 2026-09-14 05:23:50 -03:00
parent 346f33c1ce
commit 897ebe0817
22 changed files with 649 additions and 101 deletions

View file

@ -0,0 +1 @@
**C159 — Sollinha na assessoria de comunicação (chat escopado ao acervo) (2026-09-14):** o papel `communicator` passou a ver e abrir o Sollinha na vertical `/campanha/comunicacao` (FAB, drawer e botão do header), com o chat **escopado ao acervo de falas** do C158 — pergunta de acervo devolve trechos com link para o Acervo, pergunta fora do escopo recebe negativa clara e educada, nunca erro técnico nem dado de campanha/eleitoral/município. O gate de superfície `canUseCampaignAssistant` (que escondia as três superfícies desde o C154) foi removido no dono `src/lib/campaignRoles.ts`; o escopo passa a ser por construção no registry: `buildAITools` entrega ao communicator (e a um papel desconhecido, fail-closed) apenas `calculate`, `buildCampaignLinks` e `findSpeechExcerpts`, e mantém as 15 tools para staff/leader — o modelo não vê o que não pode chamar, o que elimina a classe de `Forbidden`/vazio enganoso que tools sem gate produziriam (opção B de gatear 15 donos foi rejeitada). O prompt virou `buildAISystemPrompt(role)` no dono: a composição da assessoria derruba as afirmações de acesso a dados da campanha, as seções de tools de campanha e o `## Contexto eleitoral` (que materializava o quociente de 210.000), e ganha o bloco `## Escopo do seu acesso` + follow-ups só de acervo; staff/leader mantêm o texto byte a byte. Ajuste pontual no dono dos links (`assertDestinationAccess`): `leaderContacts` passou a ser negado para não-líderes com mensagem própria (antes vazava para o communicator um link que redireciona). Chips de abertura próprios de acervo (3, todos respondíveis por `findSpeechExcerpts`) e boas-vindas por papel no `CampaignAIChat` (prefixo "Olá! Eu sou o Sollinha" preservado). A sessão do chat em `sessionStorage` ganhou o papel que a escreveu e mismatch/legado sem papel viram conversa nova (fail-closed): um tab compartilhado não restaura mais a conversa de staff para a assessoria — risco direto ao guardrail. Sem migration, collection, Consent ou mudança de access; gate do dado segue `canReadSpeechCatalog` (C153/C158); leader lockdown, advisor, nav, rate limit 50/15min, `maxDuration 60` e limites de conversa intocados. Verificação: `pnpm gate:fast` (lint/format/typecheck + 305 arquivos/2956 unit), `knip`/`check:cycles`/`format:check` verdes; e2e `campaignSpeechAcervo` 6/6 (teste do communicator invertido para pinar shell + chips de acervo + ausência dos chips de staff) e superfície do chat (`campaignSollinhaContext`, `campaignAiChatOpeningChips`, `campaignAiLinks`, `campaignSollinhaWidth`, `campaignAiChatResize`) 19/19 local; manifest ganhou a entry `src/lib/sollinhaOpeningQuestions` → specs que pinam os chips.

View file

@ -0,0 +1,223 @@
# Impl: Sollinha na assessoria de comunicação (chat escopado ao acervo)
Status: aprovado
Atualizado em: 2026-09-14
Issue: #983
Intenção: docs/plans/sollinha-para-comunicacao.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** o `communicator` vê e abre o Sollinha na vertical `/campanha/comunicacao` (FAB, drawer e botão do header); uma pergunta de acervo responde com trechos de falas do C158 e caminho para o Acervo; pergunta fora do escopo recebe negativa clara e educada — nunca erro técnico, nunca dado de campanha, eleitoral ou de município; os chips de abertura dele são perguntas de acervo; leader lockdown, `advisor`, nav do communicator, rate limit e limites de conversa ficam intocados. Sem migration, collection, Consent ou UI nova.
- **O que NÃO negociar:** leader lockdown absoluto (B180) e `advisor` sem acervo/sem mudança no chat (C153/C154); nav do communicator intacta (só Comunicação); zero dado de campanha/eleitoral/município ao communicator; negativa sempre em linguagem de produto (nunca `Forbidden`/stack); o gate do dado segue sendo `canReadSpeechCatalog` (`src/lib/campaignRoles.ts:25`); sem Consent novo; `maxDuration = 60` e rate limit 50/15 min inalterados; `pnpm push` como caminho canônico de entrega.
- **O que reavaliar (hipóteses da exploração):**
- "as demais tools negam com mensagem clara, com ajuste pontual nos donos": com escopo por construção (D2) o communicator não vê as tools de campanha — a negativa clara passa a ser do prompt (D4) e a ausência de tool fecha a classe inteira de falhas; tocar 7 donos sem gate vira trabalho morto. O ajuste pontual fica no único dono que o escopo mínimo alcança de fato: os links (D3).
- `canUseCampaignAssistant` (`campaignRoles.ts:34`): o predicado virou `true` para todos; redefini-lo ou manter um branch do communicator é abstração morta (D1 — morre no dono, junto dos comentários C154).
- A sessão do chat (`src/lib/sollinhaChatSession.ts`) não guarda papel e nada a limpa no login — em dispositivo compartilhado (mesma tab), a conversa de staff pode reaparecer para o communicator, vazando exatamente o que o guardrail proíbe (D7 — entra no escopo, fix mínimo fail-closed).
- O fallback de chips para papel desconhecido (hoje leader-safe) precisa de decisão explícita ao nascer o branch do communicator (D5).
- O gap do `leaderContacts` no `buildCampaignLinks` (`campaignNavigationUrls.ts:291-296`): passa para qualquer não-leader, mas `/campanha/meus-contatos` redireciona quem não é leader — link quebrado e resposta mentirosa (D3).
## Abordagem recomendada
```mermaid
flowchart LR
L["layout (app): role"] --> SH["CampaignAISidebarShell<br/>sem gate de papel (D1)"]
SH --> UI["FAB · Drawer · botão do header · painel"]
SH --> P["CampaignAISidebarProvider role"]
P --> R["readSollinhaChatSession(role)<br/>role mismatch → sessão nova (D7)"]
P --> C["CampaignAIChat<br/>chips + boas-vindas por papel (D5/D6)"]
C --> API["POST /campanha/api/ai-chat"]
API --> SYS["buildAISystemPrompt(role)<br/>communicator = escopo acervo (D4)"]
API --> REG["buildAITools ctx<br/>communicator/desconhecido → mínimo (D2)"]
REG --> T1["findSpeechExcerpts (C158)<br/>trechos + deep-link do acervo"]
REG --> T2["calculate"]
REG --> T3["buildCampaignLinks<br/>leaderContacts só leader (D3)"]
```
**Opções consideradas:**
- **A — Reabrir a superfície removendo o gate morto (D1) e escopar as tools por construção no registry (D2):** o communicator recebe só `{ calculate, buildCampaignLinks, findSpeechExcerpts }`; coordinator/advisor/candidate/leader seguem com as 15. Prompt variante (D4), chips e boas-vindas por papel (D5/D6), sessão carimbada com o papel (D7) e correção pontual do `leaderContacts` no dono (D3). A negativa de escopo vem do prompt + ausência de tool; a tool do acervo mantém o `{ error }` do C158 para não-leitores.
- **B — Expor as 15 tools a todos e gatear cada tool sem gate com negativa clara** (`getLeaderships`, `getOrganizations`, `searchEntities`, `getMunicipalityOverview`, `getDobradinhas`...): ajuste nos donos de cada uma, mantendo o access como está.
- **C — Gatear na rota** (`route.ts`): montar `system` e `tools` condicionais por papel no handler.
**Recomendação: A** — o escopo por construção elimina a classe inteira de falhas (o `Forbidden` que o access lança em `payload.find` — precedente `tests/int/speechCatalog.int.spec.ts:144-154` — e o vazio enganoso dos filtros `{ id: { in: [] } }`) e o vazamento por chamada inesperada: o modelo não vê o que não pode chamar, então não há alucinação de tool nem erro técnico a converter. Reusa os donos existentes (registry, prompt, chips, sessão, `assertDestinationAccess`) e mantém o gate do dado onde já vive (`canReadSpeechCatalog`, C153/C158).
**Rejeitadas:** **B** — sete donos para gatear, cada um uma cópia da política, e a lista é aberta por omissão: qualquer tool nova nasce exposta e a classe de falha volta; a decisão do gate ("negativa clara em linguagem de produto") é honrada sem essa fiação porque o communicator não tem as tools e a negativa é do prompt. **C** — esconde a política na camada de transporte, não é testável isoladamente (o contrato real é registry+prompt) e duplicaria a decisão de papel em dois pontos da mesma rota.
### Decisões de engenharia
### D1 — Superfície: remover `canUseCampaignAssistant` (editar o dono)
- **Opções:** A) apagar o predicado em `src/lib/campaignRoles.ts:28-34` e destravar os 2 consumidores (`CampaignAISidebarShell.tsx:42-45` e `CampaignDesktopHeader.tsx:9,29`) | B) redefinir o predicado para `() => true` | C) manter o predicado e adicionar um branch para communicator.
- **Recomendação: A** — o predicado virou "todos", tem só esses 2 consumidores (não há outro no repo) e nenhum teste pina o nome; os detalhes de superfície (FAB, drawer, provider, botão do header, painel) já derivam do shell sem gate próprio, então remover o early-return habilita as três superfícies de uma vez. O `role` continua descendo ao provider (chips/boas-vindas/chat) — só o portão morre. Atualizar os comentários C154 em `campaignRoles.ts` e em `CampaignAISidebarShell.tsx:42-45` (comentário C159 no lugar; C154 deixa de ser verdade).
- **Rejeitadas:** B — abstração morta que convida um próximo agente a "consertar" a decisão de volta; C — gêmeo do gate antigo, exatamente o "edit the owner, don't twin".
### D2 — Escopo de tools por papel: allowlist no registry
- **Opções:** A) `buildAITools(ctx)` (`src/utilities/ai/tools/index.ts`, dono) filtra internamente: papel de escopo mínimo recebe `{ calculate, buildCampaignLinks, findSpeechExcerpts }`; os demais, as 15 | B) expor todas e gatear cada tool sem gate no próprio dono | C) filtrar na rota.
- **Recomendação: A**, com estes contornos:
- **Predicado local** no registry: `isFullAssistantRole(role) = isStaffCampaignRole(role) || role === 'leader'` (reusa `campaignRoles.ts`; não cria predicado novo exportado — 1 call site + testes). Qualquer coisa fora disso, **incluindo role desconhecido em runtime, cai no escopo mínimo (fail-closed)**: um papel inesperado não tem garantia de staff/leitura e o mínimo não vaza nem lança.
- **Composição tipada:** montar `speechTools` e, quando full, `{ ...speechTools, ...campaignTools }` — sem `pick` genérico, sem perder a literalidade das chaves (o teste lê `Object.keys`).
- **Guard compartilhado: não** — o concern "quais tools cada papel vê" é do registry; prompt e chips têm concerns próprios (D4/D5) e não compartilham a decisão.
- **Negativa clara:** o prompt (D4) responde pedidos fora do escopo em linguagem de produto com a orientação para o acervo; o modelo não pode chamar tool que não está no schema. O `{ error: 'Leitura do acervo de falas negada.' }` do C158 permanece para leader/advisor (que continuam com todas as tools e são negados pelo gate da tool).
- **Nota de escopo:** a dívida pré-existente de `Forbidden`/vazio em tools sem gate para outros papéis (ex.: leader chamando `getDobradinhas`) **não** entra aqui — o B180 cobre o lock eleitoral e o C159 é sobre o communicator.
- **Rejeitadas:** B — sete donos para editar, cada cópia é um twin da política, e uma tool futura nasce exposta (lista aberta); C — o registry é o dono do contrato `tools` e é testável sem subir a rota.
### D3 — `buildCampaignLinks`/`leaderContacts`: corrigir no dono
- **Opções:** A) em `assertDestinationAccess` (`src/utilities/ai/campaignNavigationUrls.ts:242-275`), `leaderContacts` só passa para leader; demais recebem negativa clara | B) deixar como está (o prompt do communicator desaconselha) | C) adicionar um destino `acervo` ao catálogo de links.
- **Recomendação: A** — `leaderContacts` é destino exclusivo da liderança (a página `/campanha/meus-contatos` redireciona não-leader para `/campanha`), então hoje o link é quebrado para communicator **e** staff; uma mensagem própria fecha o gap: `{ error: 'A área Meus contatos é exclusiva da liderança.', alternatives: [CAMPAIGN_HOME, CAMPAIGN_PROFILE_HOME] }`, inserida depois do bloco do leader. `home` e `perfil` continuam passando para todos (legítimos: `/campanha` redireciona o communicator para a vertical via `roleHome` em `campaignPageActor.ts:65-69`; `/campanha/perfil` não tem gate). Nenhum destino novo é criado.
- **Rejeitadas:** B — link quebrado e resposta mentirosa no chat, contra o aceite "nunca erro técnico"; C — já rejeitada no C158 (D5): o deep-link `?t=&q=` é contrato da tool do acervo (`buildWatchHref`) e o catálogo de navegação não suporta query string; duplicaria o concern.
### D4 — Prompt por papel: `buildAISystemPrompt(role)` no dono
- **Opções:** A) função no dono `src/utilities/ai/systemPrompt.ts`, com o corpo único e duas peças variáveis (seção de conhecimento e bloco de escopo), chamada pela rota (`route.ts:63`) | B) manter a constante e anexar/substituir um bloco na rota | C) prompt dedicado em arquivo separado.
- **Recomendação: A** — a base hoje afirma acesso a dados eleitorais, municípios, dobradinhas, lideranças, organizações e metas (`systemPrompt.ts:13-16`), tudo falso para o communicator; um bloco anexado criaria contradição (o modelo alucina com a afirmação antiga). A composição mínima é: **constante do corpo** + `knowledgeSection` por papel + `scopeRules` por papel; para todos os papéis menos communicator a saída é o texto atual (as peças variáveis são interpoladas com o texto existente — sem regressão de tom). `AI_SYSTEM_PROMPT` deixa de ser export (só a rota consome; knip limpo) e a rota passa `system: buildAISystemPrompt(user.role)`.
- **Conhecimento do communicator** (substitui as linhas 13-16 atuais):
```text
- Você tem acesso ao acervo interno de falas do deputado na Câmara para sugerir trechos de vídeo.
- Você conhece o funcionamento do acervo: cada fala tem data, duração, transcrição automática e um vídeo de referência que abre no trecho.
```
- **Bloco de escopo do communicator** (logo após "O que você sabe"):
```text
## Escopo do seu acesso
- Você atende a assessoria de comunicação: seu trabalho é ajudar a encontrar trechos de falas do deputado no acervo para vídeos e reels.
- Você NÃO tem acesso a dados eleitorais, de municípios, de campanha, de lideranças, de organizações, de dobradinhas ou de apoiadores. Se pedirem, diga com educação que esses dados não fazem parte do seu acesso e ofereça buscar um trecho no acervo de falas.
- A busca do Acervo (Comunicação → Acervo) continua sendo a superfície principal para garimpar falas; seus links levam direto ao ponto do trecho.
- Nunca repita, resuma ou comente dados de campanha que apareçam em mensagens anteriores da conversa: se o assunto não for o acervo, negue com naturalidade e retome o que você faz.
```
- **Follow-up** (linha 91 do bloco atual ganha a irmã): `- 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. Para a assessoria de comunicação (role communicator), só sugestões sobre o acervo de falas.`
- Leader/advisor/coordinator/candidate: texto idêntico ao atual (pinado por unit).
- **Rejeitadas:** B (contradição na seção de conhecimento + política fora do dono), C (duplicaria ~90 linhas e criaria drift entre prompts no repo, que é único).
### D5 — Chips de abertura: catálogo próprio do communicator
- **Opções:** A) `COMMUNICATOR_OPENING_QUESTIONS` (3, todas respondíveis por `findSpeechExcerpts`) + branch explícito em `getSollinhaOpeningQuestions` | B) reusar o conjunto do leader | C) criar um conjunto genérico "não-staff".
- **Recomendação: A** (decisão do gate 2026-09-14), com o texto exato:
```ts
/** Communicator: 3 chips, all answerable by findSpeechExcerpts. */
const COMMUNICATOR_OPENING_QUESTIONS: readonly SollinhaChatChip[] = [
{ text: 'O que o Solla já falou sobre Farmácia Popular?' },
{ text: 'Me dá uma fala do deputado para um reels sobre saúde.' },
{ text: 'Qual um trecho bom sobre o hospital do subúrbio?' },
]
```
Branch: `role === 'communicator'` → catálogo próprio; `isStaffCampaignRole(role)` → staff; senão leader. `MOBILE_LIMIT = 3` permanece (o catálogo do communicator já tem 3 — desktop e mobile iguais). **Fallback de papel desconhecido: leader-safe (mantido)** — os chips de acervo pressupõem leitura garantida do acervo (um papel desconhecido pode receber `{ error }` da tool); os chips do leader são meta/links pessoais e não expõem dado interno. Gatilho registrado: quando um valor novo entrar no enum de role, adicionar branch explícito + unit.
- **Rejeitadas:** B (não respondem o job de acervo; com D3, "Meus contatos" ainda viraria negativa), C (nenhum conjunto genérico é respondível por todos os papéis).
### D6 — Boas-vindas do chat: variante por papel
- **Opções:** A) condicional inline em `CampaignAIChat.tsx:151-154` (1 call site, sem módulo) | B) módulo `sollinhaWelcome.ts` | C) texto único mais genérico para todos.
- **Recomendação: A**, com a copy exata:
- communicator: `Olá! Eu sou o Sollinha. Posso buscar no acervo de falas do deputado trechos para suas peças — é só dizer o tema.`
- demais papéis: texto atual inalterado (`Olá! Eu sou o Sollinha, assistente virtual da campanha. Pergunte sobre votações, municípios, dobradinhas, lideranças e muito mais.`).
O prefixo "Olá! Eu sou o Sollinha" é comum — os e2e existentes (`campaignSollinhaContext`, `campaignAiLinks`, `campaignAiChatOpeningChips`) seguem válidos sem mudança.
- **Rejeitadas:** B (abstração para 1 call site), C (o texto atual cita votações/municípios/dobradinhas — falso para o communicator; não resolve).
### D7 — Sessão do chat: carimbar o papel e descartar mismatch (guardrail)
- **Opções:** A) `role` no payload persistido + mismatch → sessão descartada/fresh, fail-closed, no dono `src/lib/sollinhaChatSession.ts` | B) limpar a sessão no login/logout do campaignUser | C) registrar débito em Issue separada.
- **Recomendação: A** — o vazamento é direto ao guardrail "nunca dado de campanha para o communicator" e o fix é barato e local, sem bump de versão:
- `SollinhaChatSession` ganha `role: CampaignRole` (type-only de `campaignRoles.ts`; `lib` não importa `utilities`, ok).
- `isSollinhaChatSession` exige `role` num set com os 5 valores — sessões legadas (sem role) → `null`/fresh (fail-closed; a lista local é espelho do enum e um valor novo cai em descarte, comportamento aceitável).
- `readSollinhaChatSession(role)` devolve `null` quando `session.role !== role` (mismatch nunca restaura mensagens nem `open`).
- `writeSollinhaChatSession(messages, open, openBy, role)` grava o papel; `CampaignAISidebarProvider` passa `role` no read e nos dois writes (`CampaignAISidebarContext.tsx:91,131,146`).
- Impacto: `tests/unit/sollinhaChatSession.unit.spec.ts` estendido; `campaignSollinhaContext.e2e.spec.ts` lê só `messages/open` e restaura no mesmo papel — permanece verde. Sessões antigas (sem role) são descartadas uma vez no deploy — perda de conversa efêmera, sem dado persistente; registrar no changelog.
- Risco residual nomeado: dois usuários **da mesma role** no mesmo tab continuam restaurando a conversa — é o mesmo modelo de confiança do login (quem loga vê o que o papel vê); o crossing que o guardrail proíbe (staff → communicator) é exatamente o que o mismatch fecha.
- **Rejeitadas:** B (não há ponto único de limpeza no fluxo de auth do campaignUser; acoplaria auth a uma preocupação de chat, fora do appetite), C (barato demais para virar débito com risco de vazamento).
### D8 — E2E e manifest: estender o acervo, não crescer a suíte
- **Opções:** A) estender `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (teste do communicator) + corrigir o prefixo do manifest | B) criar spec e2e novo do chat do communicator | C) não mexer em e2e (só unit).
- **Recomendação: A** — o teste atual (`:87-89`) assere ativamente `not.toContain('campaign-ai-shell')`/`not.toContain('Sollinha')`; deixá-lo sem atualização faz a suíte **mentir e quebrar**. Inverter para provar: `toContain('campaign-ai-shell')`, `toContain('Olá! Eu sou o Sollinha')`, presença dos 3 chips do communicator, ausência dos chips de staff (ex.: `Quem foi o deputado mais votado em Feira de Santana?`), mantendo os asserts de nav (sem `href="/campanha/apoiadores"`/`/municipios`). O HTML SSR já carrega os chips (o e2e de chips depende disso), e o clique nos chips é o mesmo componente já coberto para staff — sem browser novo. No manifest (`scripts/lib/e2e-affected-manifest.mjs:194-207`), adicionar `src/lib/sollinhaOpeningQuestions` ao prefixo da entrada "Sollinha AI surfaces": hoje um diff só nesse arquivo acorda apenas o smoke de home (fallback) e não o spec dos chips — a entrada passa a refletir o dono da curadoria (`src/lib/campaignRoles` já está mapeado para `campaignSpeechAcervo` pela entrada C154).
- **Rejeitadas:** B (a UI é a mesma; o custo de browser não paga — HTML + unit já provam), C (o pin de ausência do C154 quebra e a suíte não cobriria a nova superfície).
### D9 — Testes: unit-first, sem int novo
- **Opções:** A) units por dono + e2e estendido (D8) | B) int da rota com `streamText` mockado | C) e2e de chat com o communicator (modelo mockado).
- **Recomendação: A** — nenhum access, write path, query ou schema muda; as fronteiras Payload/DB do C159 não existem (a tool do C158, que as tem, já é coberta pelo int dela). Mapa por decisão:
- `tests/unit/campaignSollinhaOpeningQuestions.unit.spec.ts` (estender): communicator → os 3 textos (desktop e mobile); staff/leader intactos; fallback desconhecido → leader.
- `tests/unit/aiToolsScope.unit.spec.ts` (novo): `buildAITools` para communicator e role desconhecido → exatamente `['calculate', 'buildCampaignLinks', 'findSpeechExcerpts']`; coordinator/candidate/advisor/leader → 15 chaves; o mínimo não contém tools de campanha (prova por chaves, sem executar query).
- `tests/unit/campaignNavigationUrls.unit.spec.ts` (estender): `leaderContacts` negado para communicator/coordinator/advisor/candidate com a mensagem clara; leader segue ok (`:117-127`); `home`/`perfil` ok para communicator.
- `tests/unit/sollinhaChatSession.unit.spec.ts` (estender): write grava role; read com role igual restaura; role diferente → `null`; sessão sem role → `null`; role inválido → `null`.
- `tests/unit/aiSystemPrompt.unit.spec.ts` (novo): `buildAISystemPrompt('communicator')` contém o bloco de escopo e a regra de follow-up do acervo e **não** contém "dados eleitorais da Bahia" na seção de conhecimento; `buildAISystemPrompt('coordinator')`/`('leader')` mantêm a seção completa.
- **Sem int novo** e sem mudança nos ints existentes (`speechExcerpts.int.spec.ts:135-154` já prova a herança do communicator na tool). `codebaseConventions`/`campaignNav`/`campaignPageActorGate` seguem sem mudança (nav e gate de página intocados).
- **Rejeitadas:** B (a rota é fina; mockar streaming é infra nova para pouco sinal), C (o comportamento determinístico está no registry/prompt/sessão; e2e de chat já cobre o componente para staff/leader).
### Componentes / mudanças
- **`src/lib/campaignRoles.ts`** (editar): remover `canUseCampaignAssistant` (e o comentário C154); nenhum outro predicado muda.
- **`src/components/campaign/shell/ai/CampaignAISidebarShell.tsx`** (editar): remover import/early-return (`:42-45`); `role` continua indo ao provider; comentário C159 no lugar do C154.
- **`src/components/campaign/shell/CampaignDesktopHeader.tsx`** (editar): `<CampaignAIHeaderButton />` sempre renderizado (`:29`); remover import do predicado (`:9`).
- **`src/utilities/ai/tools/index.ts`** (editar): `buildAITools` escopa por papel (D2) reusando `isStaffCampaignRole`; `speechTools`/`campaignTools` com tipagem literal.
- **`src/utilities/ai/systemPrompt.ts`** (editar): `buildAISystemPrompt(role)` (D4); remove export de `AI_SYSTEM_PROMPT`.
- **`src/app/(campaign)/campanha/api/ai-chat/route.ts`** (editar): `system: buildAISystemPrompt(user.role)` (`:63`); auth, rate limit, `buildAITools` e `maxDuration` intactos.
- **`src/utilities/ai/campaignNavigationUrls.ts`** (editar): negativa clara de `leaderContacts` para não-leaders em `assertDestinationAccess` (D3).
- **`src/lib/sollinhaOpeningQuestions.ts`** (editar): catálogo e branch do communicator; docstring atualizada (D5).
- **`src/components/campaign/shell/ai/CampaignAIChat.tsx`** (editar): boas-vindas por papel (D6).
- **`src/lib/sollinhaChatSession.ts`** (editar) e **`src/components/campaign/shell/ai/CampaignAISidebarContext.tsx`** (editar): papel persistido + mismatch fail-closed (D7).
- **`scripts/lib/e2e-affected-manifest.mjs`** (editar): prefixo `src/lib/sollinhaOpeningQuestions` (D8).
- **`tests/e2e/campaignSpeechAcervo.e2e.spec.ts`** (editar) + units de D9.
- **`docs/changelog/2026-09-14-c159.md`** (novo): entrada curta da entrega.
- **Migration:** sem migration (nenhum schema/collection/field; `pnpm migrate:status` intocado).
- **Access / Consent:** sem mudança de access e **sem Consent novo**. O gate do dado permanece `canReadSpeechCatalog` (access C153 + gate da tool C158); nenhuma query nova; `overrideAccess: false` + `user` intocados. O papel no `sessionStorage` é estado do cliente, não dado de servidor.
- **UI:** Impeccable D — sem design novo (superfícies existentes habilitadas por papel); copy exata: chips (D5) e boas-vindas (D6).
### Dados → forma (se aplicável)
N/A — nenhuma apresentação de dados nova (quem apresenta os trechos é a tool do C158, dentro do chat que já existe). Nenhum formato, tabela, threshold ou env novo.
## Fases verificáveis
1. **Tracer / server (~0,45 dia):** D2 (registry) + D4 (prompt + rota) + D3 (links) + D7 (sessão) com seus units (`aiToolsScope`, `aiSystemPrompt`, `campaignNavigationUrls`, `sollinhaChatSession`). Prova: `pnpm test:unit` verde; o communicator, mesmo antes da superfície, já tem escopo mínimo se chamado por API.
2. **Superfície/UI e e2e (~0,35 dia):** D1 (shell/header) + D5 (chips) + D6 (boas-vindas) + D8 (e2e do acervo + manifest) + unit dos chips estendido. Prova: spec do acervo atualizado verde (shell + chips + ausência dos chips de staff), unit dos chips verde, comentários C154 corrigidos.
3. **Gates e entrega (~0,2 dia):** `pnpm gate:fast` na iteração; rodar os e2e afetados (spec do acervo/`pnpm test:e2e:affected`, discricionário) antes do push; `pnpm push` (gate:ci local); changelog `docs/changelog/2026-09-14-c159.md`; `pnpm knip` limpo (o predicado removido não deixa export órfão). Sem migration.
**Quota total:** ~0,5–1 dia eng (herdado), dentro do appetite.
## Rabbit holes / Não escopo (engenharia)
- **Gatear as 15 tools individualmente** (opção B de D2) — 7 donos, cópias da política e lista aberta a tools futuras. Corte: allowlist no registry.
- **"Melhorar" as negativas de access das collections** para o chat — access é dado, não superfície; o gate é o do C158. Corte: nenhuma mudança de access.
- **Abrir o acervo/chat pleno para advisor/leader** — decisão C153 mantida; corte explícito.
- **Novas tools** (pauta, agenda, demandas, clipping) e destino `acervo` no `buildCampaignLinks` — corte: deep-links da tool do C158; catálogo de navegação intocado.
- **Redesign do drawer/persona ou reescrita global do prompt** — corte: 2 trechos variáveis + bloco de escopo.
- **Consent/migration/collection** — fora; o item é acesso/superfície/prompt/testes.
- **Mexer em rate limit (`rateLimit.ts`) ou limites da sessão (`SOLLINHA_CHAT_MAX_*`)** — valem iguais.
- **Sanitizar o histórico no servidor** — o estado vive no `sessionStorage`; o fix é no dono da sessão (D7).
- **"Consertar" o hit hardcoded de `searchEntities` (`searchEntities.ts:36-43`)** — não exposto ao communicator por D2; pré-existente, fora.
- **Int da rota com streaming mockado / e2e novo de chat** — cobertura determinística está no unit + HTML (D9).
- **Regressão a procurar:** leader lockdown (B180) e o deny de advisor no acervo (C153/C154) não são tocados — só ganham prova nos units/e2e existentes.
## Riscos e mitigação
- **Regressão do leader lockdown via D3:** `assertDestinationAccess` mexe na allowlist do leader — mitigado por unit (`:117-127` segue pinando leader ok; staff destinations negadas) e pelo e2e de links.
- **Sessões antigas descartadas no deploy (D7):** conversa em `sessionStorage` sem role vira fresh — efêmera, sem dado persistente; documentado no changelog. Comportamento é o desejado (fail-closed).
- **Prompt do communicator alucinar acesso:** a seção de conhecimento é reduzida e o bloco de escopo é explícito; não há tool de campanha no schema para materializar vazamento — o pior caso é uma resposta genérica de "não tenho acesso", que é o aceite 3.
- **E2E do acervo era o pin da ausência (C154):** atualizado no mesmo PR (D8); deixá-lo para depois garantiria CI vermelho.
- **`knip`/dead code:** remoção de `canUseCampaignAssistant` e do export `AI_SYSTEM_PROMPT` são os pontos a verificar no `pnpm gate:fast`/`pnpm knip`; nenhum arquivo/comentário C154 fica em pé descrevendo comportamento que morreu.
- **Seleção de e2e em PRs futuros:** o prefixo novo do manifest amplia corretamente o gatilho de `sollinhaOpeningQuestions` (não cresce a suíte, corrige o mapa OPS86).
- **Fallback de chips/links para role desconhecido:** leader-safe + escopo mínimo de tools + sessão descartada — três camadas fail-closed, todas pinadas em unit.
- **`campaignAiChatOpeningChips` (browser):** não muda (staff/leader intactos; o communicator é provado no HTML do acervo) — se o assert de boas-vindas `Olá! Eu sou o Sollinha` quebrar por causa da copy, ajustar apenas o texto do communicator (prefixo comum preservado).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: communicator vê/abre o Sollinha na vertical (FAB/drawer/header); pergunta de acervo responde com trechos + link; fora do escopo → negativa clara; chips de acervo próprios; leader/advisor/nav/rate limit/limites intocados.
- [ ] Invariantes AGENTS/engineering-standards: sem migration/collection/Consent; access e `canReadSpeechCatalog` intocados; nenhuma query nova (e as existentes seguem `overrideAccess: false` + `user`); copy pt-BR e identificadores em inglês; sem abstração nova (<3 call sites); "edit the owner, don't twin" (registry/prompt/chips/sessão/links editados no dono); comentários C154 corrigidos onde ficaram falsos; `pnpm knip` limpo.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: **nenhuma mudança de access/write path**; units de D9 cobrem os donos editados; e2e `campaignSpeechAcervo` atualizado; int do C158 segue verde; `pnpm gate:fast` e `pnpm push` verdes (e2e afetados rodados antes do push).
## Self-score decision-quality
- Decisões caras com rejeitadas: 5/5 — D1–D9 no formato Opções/Recomendação/Rejeitadas (superfície, escopo de tools, links, prompt, chips, boas-vindas, sessão, e2e/manifest, testes); as baratas (mensagem do `leaderContacts`, nome/set da sessão, prefixo do manifest) ficam registradas em uma linha.
- Cabe no appetite? 5/5 — ~0,5–1 dia herdado; nenhum schema/migration/Consent/UI nova/dependência; a única superfície de risco novo (sessão) é um fix local de ~30 linhas com unit.
- Rabbit holes nomeados? 5/5 — gatear 15 tools, mudar access das collections, abrir acervo para advisor/leader, novas tools/destino de acervo, reescrever prompt/persona, Consent/migration, rate limit, sanitização server-side, e2e/route novos.
- Depth check reusa? 5/5 — `canReadSpeechCatalog`/`isStaffCampaignRole` (C153/C154), shape `{ error }` (B180/B185), `assertDestinationAccess` (B162+), `findSpeechExcerpts`/`buildWatchHref` (C158), padrão de chips (B191) e de sessão (B188/OPS22); nenhum predicado, tool, gate ou store paralelo nasce.
- Intenção permanece satisfeita? 5/5 — os 5 aceites e os guardrails estão cobertos pelas decisões, sem ampliar escopo (nada de tools novas, nada de acervo para advisor/leader, leader lockdown intocado) e com a negativa clara vinda do prompt + ausência de tool em vez de 7 ajustes de dono.

View file

@ -206,6 +206,12 @@ export const E2E_AFFECTED_MANIFEST = [
'campaignSollinhaWidth',
],
},
{
// C159 — the opening-question catalog is pinned by the staff/leader chip
// spec and by the communicator assertions in the acervo spec.
prefixes: ['src/lib/sollinhaOpeningQuestions'],
specs: ['campaignAiChatOpeningChips', 'campaignSpeechAcervo'],
},
{
prefixes: [
`${CAMPAIGN_APP}/pessoas`,

View file

@ -108,7 +108,6 @@ export default async function CampaignAppLayout({ children }: { children: React.
notificationBell={<CampaignNotificationBellSlot user={user} />}
/>
<CampaignDesktopHeader
role={user.role}
notificationBell={<CampaignNotificationBellSlot user={user} />}
/>
{/*

View file

@ -11,7 +11,7 @@ import { getPayload } from 'payload'
import { splitSollinhaFollowUpBlock } from '@/lib/sollinhaFollowUpSuggestions'
import { checkRateLimit } from '@/utilities/ai/rateLimit'
import { AI_SYSTEM_PROMPT } from '@/utilities/ai/systemPrompt'
import { buildAISystemPrompt } from '@/utilities/ai/systemPrompt'
import { buildAITools } from '@/utilities/ai/tools'
import { getCampaignUserRaw } from '@/utilities/campaignAuth'
@ -60,7 +60,7 @@ export async function POST(req: Request) {
const result = streamText({
model: deepSeek('deepseek-flash'),
system: AI_SYSTEM_PROMPT,
system: buildAISystemPrompt(user.role),
messages: strippedMessages,
tools: buildAITools({ user, payload }),
stopWhen: stepCountIs(10),

View file

@ -6,15 +6,8 @@ import { CampaignAIHeaderButton } from '@/components/campaign/shell/ai/CampaignA
import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
import { SidebarTrigger } from '@/components/ui/Sidebar'
import { canUseCampaignAssistant, type CampaignRole } from '@/lib/campaignRoles'
export const CampaignDesktopHeader = ({
notificationBell,
role,
}: {
notificationBell?: ReactNode
role: CampaignRole
}) => {
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => {
const headerActions = useCampaignHeaderActions()
return (
@ -26,7 +19,7 @@ export const CampaignDesktopHeader = ({
<Fragment key={id}>{node}</Fragment>
))}
{notificationBell ?? null}
{canUseCampaignAssistant(role) ? <CampaignAIHeaderButton /> : null}
<CampaignAIHeaderButton />
</div>
</header>
)

View file

@ -140,6 +140,13 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
const showOpeningChips = messages.length === 0 && status === 'ready'
const openingQuestions = getSollinhaOpeningQuestions(role, isMobile)
// C159 — the assessoria de comunicação has an acervo-scoped assistant: the
// greeting must promise what her toolset answers, not the campaign catalog.
const welcomeText =
role === 'communicator'
? 'Olá! Eu sou o Sollinha. Posso buscar no acervo de falas do deputado trechos para suas peças — é só dizer o tema.'
: 'Olá! Eu sou o Sollinha, assistente virtual da campanha. Pergunte sobre votações, municípios, dobradinhas, lideranças e muito mais.'
return (
<div className={cn('grid min-h-0 grid-rows-[1fr_auto]', className)}>
{/* Messages area */}
@ -148,10 +155,7 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
<div className="flex h-full items-center justify-center px-4 text-center">
<div className="max-w-xs space-y-3">
<Bot className="mx-auto size-10 text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground">
Olá! Eu sou o Sollinha, assistente virtual da campanha. Pergunte sobre votações,
municípios, dobradinhas, lideranças e muito mais.
</p>
<p className="text-sm text-muted-foreground">{welcomeText}</p>
</div>
</div>
) : (

View file

@ -88,7 +88,7 @@ export const CampaignAISidebarProvider = ({
useEffect(() => {
if (sessionReadRef.current || !measured) return
sessionReadRef.current = true
const session = readSollinhaChatSession()
const session = readSollinhaChatSession(role)
if (session) {
setMessages(session.messages)
// B199: the mid-stream `open` write must never clobber a restored
@ -100,7 +100,7 @@ export const CampaignAISidebarProvider = ({
}
restoredSessionRef.current = session !== null
setSessionRestored(true)
}, [setMessages, measured, isMobile])
}, [setMessages, measured, isMobile, role])
// OPS22: every user surface (FAB, header button, drawer swipe) opens/closes
// the chat through the context `setOpen`/`toggle` — those mark the intent.
@ -128,8 +128,8 @@ export const CampaignAISidebarProvider = ({
if (!sessionRestored || status !== 'ready') return
settledMessagesRef.current = messages
lastWrittenOpenRef.current = open
writeSollinhaChatSession(messages, open, openByForWrite())
}, [sessionRestored, status, messages, open])
writeSollinhaChatSession(messages, open, role, openByForWrite())
}, [sessionRestored, status, messages, open, role])
// B199: persist `open` without waiting for the settle — closing the drawer
// mid-stream must reach the storage immediately, or a reload would restore
@ -143,8 +143,8 @@ export const CampaignAISidebarProvider = ({
useEffect(() => {
if (!sessionRestored || status === 'ready' || lastWrittenOpenRef.current === open) return
lastWrittenOpenRef.current = open
writeSollinhaChatSession(settledMessagesRef.current, open, openByForWrite())
}, [sessionRestored, status, open])
writeSollinhaChatSession(settledMessagesRef.current, open, role, openByForWrite())
}, [sessionRestored, status, open, role])
// The chat has two surfaces keyed by viewport — the desktop panel and the
// mobile drawer — and both derive from the SAME `open` flag. Crossing the

View file

@ -10,7 +10,6 @@ import {
} from 'react-resizable-panels'
import type { CampaignRole } from '@/lib/campaignRoles'
import { canUseCampaignAssistant } from '@/lib/campaignRoles'
import {
CHAT_MIN_PX,
getSavedChatWidthPx,
@ -39,11 +38,9 @@ export const CampaignAISidebarShell = ({
}) => {
const panelRef = usePanelRef()
// C154 — the communication assessor has no Sollinha: the assistant's tools
// are campaign-scoped and the vertical is her whole surface (the FAB, the
// drawer and the desktop header button all live inside these surfaces).
if (!canUseCampaignAssistant(role)) return <>{children}</>
// C159 — every role reaches the assistant: the communication assessor's
// chat is scoped by the toolset (speech acervo only), not by hiding the
// FAB, the drawer or the desktop header button. (C154 hid all three.)
return (
<CampaignAISidebarProvider role={role} panelRef={panelRef}>
<CampaignAIFab />

View file

@ -24,11 +24,3 @@ export const isUnrestrictedCampaignRole = (role: CampaignRole): boolean =>
*/
export const canReadSpeechCatalog = (role: CampaignRole): boolean =>
role === 'communicator' || isUnrestrictedCampaignRole(role)
/**
* Sollinha (AI assistant) surfaces. The communication assessor does not use
* the campaign assistant — its tools are campaign-scoped and the vertical is
* her whole job (C153 debt, resolved by C154). Leaders keep the safe
* link/meta chips.
*/
export const canUseCampaignAssistant = (role: CampaignRole): boolean => role !== 'communicator'

View file

@ -3,13 +3,34 @@
* live in `sessionStorage`, so a reload of the same tab restores the chat, a
* new tab starts clean, and closing the tab erases everything. Nothing is
* written to the server and nothing crosses tabs.
*
* C159 — the persisted session carries the role it belongs to. The chat is now
* reachable by every role, including the communication assessor (whose scope is
* the speech acervo), and a shared tab must never restore a previous user's
* campaign conversation for her. A session from another role is discarded
* (fail-closed), as is a session written before this field existed.
*/
import type { UIMessage } from 'ai'
import type { CampaignRole } from '@/lib/campaignRoles'
export const SOLLINHA_CHAT_SESSION_STORAGE_KEY = 'teqo:campaign:sollinha-chat-session'
const SOLLINHA_CHAT_SESSION_VERSION = 1
/**
* Mirror of the role enum: a role missing here never restores a session
* (fail-closed). When a new value enters `CampaignRole`, add it here and in
* the session unit spec in the same change.
*/
const CAMPAIGN_ROLES: ReadonlySet<string> = new Set([
'coordinator',
'advisor',
'candidate',
'leader',
'communicator',
])
/** Count guardrail — oldest messages are dropped at write time, no UI warning. */
export const SOLLINHA_CHAT_MAX_MESSAGES = 50
@ -28,6 +49,8 @@ export type SollinhaChatSessionOpenOrigin = 'user' | 'settle'
export type SollinhaChatSession = {
version: typeof SOLLINHA_CHAT_SESSION_VERSION
/** The role that wrote the session — another role never restores it. */
role: CampaignRole
messages: UIMessage[]
open: boolean
openBy?: SollinhaChatSessionOpenOrigin
@ -38,6 +61,9 @@ const MESSAGE_ROLES = new Set(['system', 'user', 'assistant'])
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const isCampaignRole = (value: unknown): value is CampaignRole =>
typeof value === 'string' && CAMPAIGN_ROLES.has(value)
const isUIMessage = (value: unknown): value is UIMessage => {
if (!isRecord(value)) return false
return (
@ -54,6 +80,7 @@ const isSollinhaChatSession = (value: unknown): value is SollinhaChatSession =>
if (!isRecord(value)) return false
return (
value.version === SOLLINHA_CHAT_SESSION_VERSION &&
isCampaignRole(value.role) &&
Array.isArray(value.messages) &&
value.messages.every(isUIMessage) &&
typeof value.open === 'boolean' &&
@ -61,13 +88,19 @@ const isSollinhaChatSession = (value: unknown): value is SollinhaChatSession =>
)
}
export const readSollinhaChatSession = (): SollinhaChatSession | null => {
/**
* C159 — a session whose role differs from the current actor is invisible:
* the tab may have been shared, and the previous user's conversation is
* exactly what a narrower role must not see.
*/
export const readSollinhaChatSession = (role: CampaignRole): SollinhaChatSession | null => {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY)
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
return isSollinhaChatSession(parsed) ? parsed : null
if (!isSollinhaChatSession(parsed) || parsed.role !== role) return null
return parsed
} catch {
return null
}
@ -99,16 +132,19 @@ export const pruneSollinhaChatMessages = (messages: UIMessage[]): UIMessage[] =>
/**
* Writes the pruned session. Fail-open: storage failures never break the chat.
* `openBy` defaults to `'settle'` — a write without intent information must
* not restore the mobile drawer (OPS22).
* not restore the mobile drawer (OPS22). The role is always stamped (C159):
* the session belongs to the actor who wrote it.
*/
export const writeSollinhaChatSession = (
messages: UIMessage[],
open: boolean,
role: CampaignRole,
openBy: SollinhaChatSessionOpenOrigin = 'settle',
): void => {
if (typeof window === 'undefined') return
const session: SollinhaChatSession = {
version: SOLLINHA_CHAT_SESSION_VERSION,
role,
messages: pruneSollinhaChatMessages(messages),
open,
openBy,

View file

@ -6,7 +6,8 @@
* election/dobradinha/overview questions backed by their tools; leaders get a
* minimal safe set (meta + links via `buildCampaignLinks`, whose leader
* allowlist is home/perfil/leaderContacts) — never an election tool, which
* fails closed for the leader (B180).
* fails closed for the leader (B180). The communication assessor (C159) gets
* acervo questions answerable by `findSpeechExcerpts`, her whole toolset.
*/
import type { CampaignRole } from '@/lib/campaignRoles'
@ -35,17 +36,31 @@ const LEADER_OPENING_QUESTIONS: readonly SollinhaChatChip[] = [
{ text: 'Me manda o link do meu perfil' },
]
/** Communicator (C159): 3 chips, all answerable by `findSpeechExcerpts`. */
const COMMUNICATOR_OPENING_QUESTIONS: readonly SollinhaChatChip[] = [
{ text: 'O que o Solla já falou sobre Farmácia Popular?' },
{ text: 'Me dá uma fala do deputado para um reels sobre saúde.' },
{ text: 'Qual um trecho bom sobre o hospital do subúrbio?' },
]
const MOBILE_LIMIT = 3
/**
* Opening-question chips for an empty conversation. Staff get the full catalog
* (capped by viewport); any non-staff role (leader — and unknown roles as a
* fail-closed fallback) gets the safe set. Deterministic and client-safe.
* (capped by viewport), the communication assessor the acervo set, and any
* other role (leader — and unknown roles as a fail-closed fallback) the safe
* leader set. Deterministic and client-safe.
*/
export const getSollinhaOpeningQuestions = (
role: CampaignRole,
isMobile: boolean,
): readonly SollinhaChatChip[] => {
const catalog = isStaffCampaignRole(role) ? STAFF_OPENING_QUESTIONS : LEADER_OPENING_QUESTIONS
const catalog = openingQuestionsForRole(role)
return isMobile ? catalog.slice(0, MOBILE_LIMIT) : catalog
}
const openingQuestionsForRole = (role: CampaignRole): readonly SollinhaChatChip[] => {
if (role === 'communicator') return COMMUNICATOR_OPENING_QUESTIONS
if (isStaffCampaignRole(role)) return STAFF_OPENING_QUESTIONS
return LEADER_OPENING_QUESTIONS
}

View file

@ -254,6 +254,15 @@ const assertDestinationAccess = (
}
}
// C159 — Meus contatos is the leader's own area; every other role would get
// a link that redirects them away.
if (destination === 'leaderContacts') {
return {
error: 'A área Meus contatos é exclusiva da liderança.',
alternatives: [CAMPAIGN_HOME, CAMPAIGN_PROFILE_HOME],
}
}
if (UNRESTRICTED_DESTINATIONS.has(destination) && !isUnrestrictedCampaignRole(role)) {
return {
error: 'A área de assessores é restrita a coordenador e candidato.',

View file

@ -1,28 +1,33 @@
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
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.
const DEFAULT_IDENTITY_LINE =
'- Você é prestativo, direto e conhece profundamente os dados da campanha.'
const COMMUNICATOR_IDENTITY_LINE =
'- Você é prestativo, direto e conhece profundamente o acervo de falas do deputado.'
## Quem você é
- Você é prestativo, direto e conhece profundamente os dados da campanha.
- Você se refere a si mesmo sempre no masculino, em primeira pessoa: "sou o Sollinha", "estou à disposição", "obrigado". Nunca flexione no feminino adjetivos ou particípios sobre você (ex.: "sou a Sollinha", "obrigada", "estou disponível" — não "disponível" no feminino).
- Você fala português do Brasil, em tom profissional mas acolhedor.
- Você NUNCA inventa dados. Se uma ferramenta retornar "não encontrado", você diz isso claramente.
- Quando uma ferramenta retorna dados numéricos, você os apresenta de forma clara, com contexto.
## O que você sabe
- Você tem acesso aos dados eleitorais da Bahia (votações de 2014, 2018 e 2022).
const DEFAULT_KNOWLEDGE = `- Você tem acesso aos dados eleitorais da Bahia (votações de 2014, 2018 e 2022).
- Você conhece os municípios da Bahia (417 municípios, além das 19 zonas eleitorais de Salvador).
- Você tem acesso às dobradinhas (parcerias com deputados estaduais), lideranças, organizações e metas da campanha.
- Você sabe sobre os níveis de engajamento (N0 a N4) e o que cada um significa.
- Você tem acesso ao acervo interno de falas do deputado na Câmara para sugerir trechos de vídeo.
- Você tem acesso ao acervo interno de falas do deputado na Câmara para sugerir trechos de vídeo.`
## Regras
- SEMPRE use as ferramentas disponíveis para buscar dados. Nunca responda de memória.
- Se o usuário perguntar algo que você não tem ferramenta para responder, diga educadamente que ainda não tem acesso a essa informação.
- Para cálculos matemáticos (porcentagens, somas, taxas de crescimento), SEMPRE use a ferramenta "calculate". Não faça contas de cabeça.
- Se uma pergunta for ambígua (ex: "me fala sobre Salvador"), use a ferramenta "searchEntities" primeiro para descobrir o que o usuário quer.
const COMMUNICATOR_KNOWLEDGE = `- Você tem acesso ao acervo interno de falas do deputado na Câmara para sugerir trechos de vídeo.
- Você conhece o funcionamento do acervo: cada fala tem data, duração, transcrição automática e um vídeo de referência que abre no trecho.`
## Navegação no app
const COMMUNICATOR_SCOPE_RULES = `## Escopo do seu acesso
- Você atende a assessoria de comunicação: seu trabalho é ajudar a encontrar trechos de falas do deputado no acervo para vídeos e reels.
- Você NÃO tem acesso a dados eleitorais, de municípios, de campanha, de lideranças, de organizações, de dobradinhas ou de apoiadores. Se pedirem, diga com educação que esses dados não fazem parte do seu acesso e ofereça buscar um trecho no acervo de falas.
- A busca do Acervo (Comunicação → Acervo) continua sendo a superfície principal para garimpar falas; seus links levam direto ao ponto do trecho.
- Nunca repita, resuma ou comente dados de campanha que apareçam em mensagens anteriores da conversa: se o assunto não for o acervo, negue com naturalidade e retome o que você faz.`
/**
* Campaign-only instruction blocks. The scoped composition leaves them out
* entirely: instructing the assessoria to call tools she does not have (and
* quoting election figures) is exactly the hallucination surface C159 removes.
*/
const CAMPAIGN_SECTIONS = `## Navegação no app
- Use a ferramenta "buildCampaignLinks" para montar links clicáveis que levam o usuário à tela certa em /campanha.
- Ofereça links quando: (1) o usuário pedir explicitamente para abrir/ir/mandar o link; (2) você responder sobre uma entidade singular concreta (município, liderança, dobradinha, etc.) — inclua um link "ver no app" quando ajudar.
- SEMPRE resolva ids/slugs com searchEntities ou outras ferramentas de dados ANTES de chamar buildCampaignLinks. Nunca invente slug ou id.
@ -65,7 +70,59 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário; quando trouxer "truncado: true", sugira estreitar o escopo para ver o restante.
- Ofereça links de navegação (buildCampaignLinks) para os municípios citados (por slug).
## Trechos de fala do acervo para vídeos
`
const ELECTORAL_CONTEXT_SECTION = `## Contexto eleitoral
- 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.
`
const LEADER_FOLLOW_UP_RULE =
' - 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.'
/**
* C159 — the prompt is role-aware at the owner: the communication assessor
* gets the acervo-scoped composition (the old single constant asserted access
* to election/municipality data for every role, and carried the campaign tool
* instructions the assessoria cannot call). An unknown role at runtime falls
* on the scoped side too, mirroring the tool allowlist (fail-closed). Staff
* and leader keep the exact text they had before.
*/
export const buildAISystemPrompt = (role: CampaignRole): string => {
const scoped = !(isStaffCampaignRole(role) || role === 'leader')
const identityLine = scoped ? COMMUNICATOR_IDENTITY_LINE : DEFAULT_IDENTITY_LINE
const knowledge = scoped ? COMMUNICATOR_KNOWLEDGE : DEFAULT_KNOWLEDGE
const scopeSection = scoped ? `\n${COMMUNICATOR_SCOPE_RULES}\n` : ''
const ambiguousQuestionRule = scoped
? '- Se uma pergunta for ambígua, peça um esclarecimento curto antes de buscar no acervo.'
: '- Se uma pergunta for ambígua (ex: "me fala sobre Salvador"), use a ferramenta "searchEntities" primeiro para descobrir o que o usuário quer.'
const campaignSections = scoped ? '' : CAMPAIGN_SECTIONS
const electoralContextSection = scoped ? '' : ELECTORAL_CONTEXT_SECTION
const followUpRoleRule = scoped
? `${LEADER_FOLLOW_UP_RULE} Para a assessoria de comunicação (role communicator), só sugestões sobre o acervo de falas.`
: LEADER_FOLLOW_UP_RULE
return `Você é o Sollinha, assistente virtual da campanha de Jorge Solla (PT-BA) para deputado federal.
## Quem você é
${identityLine}
- Você se refere a si mesmo sempre no masculino, em primeira pessoa: "sou o Sollinha", "estou à disposição", "obrigado". Nunca flexione no feminino adjetivos ou particípios sobre você (ex.: "sou a Sollinha", "obrigada", "estou disponível" — não "disponível" no feminino).
- Você fala português do Brasil, em tom profissional mas acolhedor.
- Você NUNCA inventa dados. Se uma ferramenta retornar "não encontrado", você diz isso claramente.
- Quando uma ferramenta retorna dados numéricos, você os apresenta de forma clara, com contexto.
## O que você sabe
${knowledge}
${scopeSection}
## Regras
- SEMPRE use as ferramentas disponíveis para buscar dados. Nunca responda de memória.
- Se o usuário perguntar algo que você não tem ferramenta para responder, diga educadamente que ainda não tem acesso a essa informação.
- Para cálculos matemáticos (porcentagens, somas, taxas de crescimento), SEMPRE use a ferramenta "calculate". Não faça contas de cabeça.
${ambiguousQuestionRule}
${campaignSections}## Trechos de fala do acervo para vídeos
- Use a ferramenta "findSpeechExcerpts" quando o usuário pedir uma boa fala/trecho/citação do deputado para uma peça sobre um tema (ex.: "qual uma boa fala para um reels sobre o hospital do subúrbio?", "o que ele falou sobre X?", "qual trecho serve para um story sobre Y?").
- Preencha "tema" só com as palavras de conteúdo (ex.: "hospital do subúrbio") — nunca a pergunta inteira nem palavras que o usuário não disse. Preencha "intencao" com o que o usuário quer com a peça, nas palavras dele: uso (reels, story, debate), tom e duração desejada; se o pedido não expressar nada além do tema, omita.
- Responda com 1–3 sugestões (default 3). Para cada uma, apresente a citação entre aspas, o intervalo "de {inicioLabel} a {fimLabel}", a justificativa de uma linha (campo "motivo", quando vier) e o link em markdown: [abrir no acervo no trecho](url). Declare o "criterio" da busca em uma linha.
@ -74,12 +131,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
- Não cole a transcrição inteira nem trechos longos: a íntegra fica no acervo ligado pelo link. Quando as sugestões não trouxerem "motivo", apresente-as sem justificativa e não mencione IA.
- A ferramenta é restrita a quem lê o acervo; se ela negar o acesso, diga que o acervo é restrito à comunicação e à coordenação/candidatura.
## Contexto eleitoral
- 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)
${electoralContextSection}## 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}
@ -88,6 +140,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
- 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.
${followUpRoleRule}
- Não use o bloco em saudações, perguntas de esclarecimento ou respostas de erro.
`
}

View file

@ -3,9 +3,15 @@
*
* Each domain tool is a factory that receives an `AIToolContext` (user + payload instance)
* so database queries respect the authenticated user's access scope.
*
* C159 — the communication assessor NEVER receives the campaign tools: her
* chat is scoped to the speech acervo, and scope-by-construction is what keeps
* an out-of-scope question from becoming a forbidden query or a leaked number.
*/
import type { AIToolContext } from '@/lib/ai/types'
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { buildCampaignLinks } from '@/utilities/ai/tools/buildCampaignLinks'
import { calculate } from '@/utilities/ai/tools/calculate'
@ -23,9 +29,22 @@ import { getPendingLeaderships } from '@/utilities/ai/tools/getPendingLeadership
import { getTopDeputies } from '@/utilities/ai/tools/getTopDeputies'
import { searchEntities } from '@/utilities/ai/tools/searchEntities'
/**
* Roles that keep the full campaign toolset. Anything else — the communicator
* and an unknown role at runtime — falls back to the speech-only set
* (fail-closed).
*/
const isFullAssistantRole = (role: CampaignRole): boolean =>
isStaffCampaignRole(role) || role === 'leader'
export const buildAITools = (ctx: AIToolContext) => ({
calculate,
buildCampaignLinks: buildCampaignLinks(ctx),
...(isFullAssistantRole(ctx.user.role) ? buildCampaignTools(ctx) : {}),
findSpeechExcerpts: findSpeechExcerpts(ctx),
})
const buildCampaignTools = (ctx: AIToolContext) => ({
getMunicipalityVotes: getMunicipalityVotes(ctx),
getTopDeputies: getTopDeputies(ctx),
getLeadingMunicipalities: getLeadingMunicipalities(ctx),
@ -38,5 +57,4 @@ export const buildAITools = (ctx: AIToolContext) => ({
getOrganizations: getOrganizations(ctx),
getPartnershipCoverage: getPartnershipCoverage(ctx),
searchEntities: searchEntities(ctx),
findSpeechExcerpts: findSpeechExcerpts(ctx),
})

View file

@ -67,7 +67,7 @@ const createSpeech = async (
}
test.describe('communication vertical (C154)', () => {
test('communicator lands on the acervo and sees only the vertical nav', async ({
test('communicator lands on the acervo and sees only the vertical nav, with her scoped Sollinha', async ({
campaign,
campaignRequest,
}) => {
@ -84,9 +84,14 @@ test.describe('communication vertical (C154)', () => {
expect(html).toContain('href="/campanha/comunicacao"')
expect(html).not.toContain('href="/campanha/apoiadores"')
expect(html).not.toContain('href="/campanha/municipios"')
// C154 — no Sollinha for the communication assessor (C153 debt resolved).
expect(html).not.toContain('campaign-ai-shell')
expect(html).not.toContain('Sollinha')
// C159 — the communication assessor gets the Sollinha scoped to the
// acervo: the surface exists, the greeting speaks acervo and the opening
// chips are hers (never the staff set).
expect(html).toContain('campaign-ai-shell')
expect(html).toContain('Olá! Eu sou o Sollinha')
expect(html).toContain('O que o Solla já falou sobre Farmácia Popular?')
expect(html).toContain('Qual um trecho bom sobre o hospital do subúrbio?')
expect(html).not.toContain('Quem foi o deputado mais votado em Feira de Santana?')
})
test('coordinator sees the vertical from the staff nav; advisor does not', async ({

View file

@ -0,0 +1,65 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import type { CampaignRole } from '@/lib/campaignRoles'
import { buildAISystemPrompt } from '@/utilities/ai/systemPrompt'
describe('buildAISystemPrompt (C159)', () => {
it('scopes the communication assessor to the speech acervo', () => {
const prompt = buildAISystemPrompt('communicator')
expect(prompt).toContain('## Escopo do seu acesso')
expect(prompt).toContain('NÃO tem acesso a dados eleitorais')
expect(prompt).toContain('acervo interno de falas do deputado')
// Follow-ups are limited to what her toolset answers.
expect(prompt).toContain(
'Para a assessoria de comunicação (role communicator), só sugestões sobre o acervo de falas.',
)
})
it('leaves no staff knowledge, campaign tool instruction or election figure in the scoped prompt', () => {
const prompt = buildAISystemPrompt('communicator')
for (const forbidden of [
'dados eleitorais da Bahia',
'417 municípios',
'conhece profundamente os dados da campanha',
'searchEntities',
'getLeadingMunicipalities',
'getPendingLeaderships',
'getMunicipalityPriorities',
'## Contexto eleitoral',
'quociente eleitoral',
]) {
expect(prompt).not.toContain(forbidden)
}
})
it('falls back to the scoped prompt for an unknown role (fail-closed)', () => {
const prompt = buildAISystemPrompt('desconhecido' as CampaignRole)
expect(prompt).toContain('## Escopo do seu acesso')
expect(prompt).not.toContain('dados eleitorais da Bahia')
expect(prompt).not.toContain('searchEntities')
})
it('keeps the full composition for staff and the leader', () => {
for (const role of ['coordinator', 'advisor', 'candidate', 'leader'] as const) {
const prompt = buildAISystemPrompt(role)
expect(prompt).toContain('dados eleitorais da Bahia')
expect(prompt).toContain('417 municípios')
expect(prompt).toContain('## Navegação no app')
expect(prompt).toContain('## Contexto eleitoral')
expect(prompt).toContain('searchEntities')
expect(prompt).not.toContain('## Escopo do seu acesso')
expect(prompt).not.toContain('só sugestões sobre o acervo de falas')
expect(prompt).toContain(
'para liderança (role leader), nada de sugestões sobre dados eleitorais',
)
}
})
it('keeps the shared acervo guidance for every role', () => {
for (const role of ['coordinator', 'leader', 'communicator'] as const) {
expect(buildAISystemPrompt(role)).toContain('## Trechos de fala do acervo para vídeos')
}
})
})

View file

@ -0,0 +1,57 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { describe, expect, it } from 'vitest'
import type { CampaignUser } from '@/payload-types'
import { buildAITools } from '@/utilities/ai/tools'
import { stub } from '../helpers/stub'
// C159 — the communication assessor's chat is scoped to the speech acervo by
// construction: the campaign tools never reach the model, so an out-of-scope
// question cannot become a forbidden query or a leaked number. The scoped set
// is the acervo tool plus the minimum (math and the links she can reach).
const SCOPED_TOOLS = ['calculate', 'buildCampaignLinks', 'findSpeechExcerpts']
const CAMPAIGN_TOOLS = [
'getMunicipalityVotes',
'getTopDeputies',
'getLeadingMunicipalities',
'getDobradinhas',
'getMunicipalitiesWithoutUpdate',
'getMunicipalityOverview',
'getMunicipalityPriorities',
'getLeaderships',
'getPendingLeaderships',
'getOrganizations',
'getPartnershipCoverage',
'searchEntities',
]
const ctxFor = (role: CampaignUser['role']) => ({
user: stub<CampaignUser>({ role }),
payload: stub<Payload>({}),
})
describe('buildAITools role scope (C159)', () => {
it('gives the communication assessor only the acervo toolset', () => {
const tools = buildAITools(ctxFor('communicator'))
expect(Object.keys(tools).sort()).toEqual([...SCOPED_TOOLS].sort())
})
it('falls back to the acervo toolset for an unknown role (fail-closed)', () => {
const tools = buildAITools(ctxFor('desconhecido' as CampaignUser['role']))
expect(Object.keys(tools).sort()).toEqual([...SCOPED_TOOLS].sort())
})
it('keeps the full toolset for staff and the leader (lockdown untouched)', () => {
for (const role of ['coordinator', 'advisor', 'candidate', 'leader'] as const) {
const keys = Object.keys(buildAITools(ctxFor(role)))
expect(keys).toHaveLength(SCOPED_TOOLS.length + CAMPAIGN_TOOLS.length)
for (const toolName of [...SCOPED_TOOLS, ...CAMPAIGN_TOOLS]) {
expect(keys).toContain(toolName)
}
}
})
})

View file

@ -126,6 +126,25 @@ describe('campaignNavigationUrls', () => {
expect(profile).toEqual({ ok: true, path: CAMPAIGN_PROFILE_HOME, label: 'Perfil' })
})
it('blocks Meus contatos for every non-leader role (C159)', () => {
for (const role of ['coordinator', 'advisor', 'candidate', 'communicator'] as const) {
const outcome = buildCampaignNavigationLink(role, { destination: 'leaderContacts' })
expect(outcome.ok).toBe(false)
if (!outcome.ok) {
expect(outcome.error).toMatch(/exclusiva da liderança/)
expect(outcome.alternatives).toEqual([CAMPAIGN_HOME, CAMPAIGN_PROFILE_HOME])
}
}
})
it('keeps home and perfil available to the communication assessor (C159)', () => {
const home = buildCampaignNavigationLink('communicator', { destination: 'home' })
expect(home).toEqual({ ok: true, path: CAMPAIGN_HOME, label: 'Início' })
const profile = buildCampaignNavigationLink('communicator', { destination: 'perfil' })
expect(profile).toEqual({ ok: true, path: CAMPAIGN_PROFILE_HOME, label: 'Perfil' })
})
it('blocks assessor area for advisors', () => {
const outcome = buildCampaignNavigationLink(advisor.role, {
destination: 'advisor',

View file

@ -2,6 +2,7 @@
import { describe, expect, it } from 'vitest'
import type { CampaignRole } from '@/lib/campaignRoles'
import { getSollinhaOpeningQuestions } from '@/lib/sollinhaOpeningQuestions'
const STAFF_TEXT = [
@ -17,6 +18,14 @@ const LEADER_TEXT = [
'Me manda o link do meu perfil',
]
// C159 — the assessoria de comunicação gets acervo questions, all answerable
// by her toolset (`findSpeechExcerpts`).
const COMMUNICATOR_TEXT = [
'O que o Solla já falou sobre Farmácia Popular?',
'Me dá uma fala do deputado para um reels sobre saúde.',
'Qual um trecho bom sobre o hospital do subúrbio?',
]
describe('getSollinhaOpeningQuestions', () => {
it('offers the full curated catalog to staff on desktop', () => {
for (const role of ['coordinator', 'advisor', 'candidate'] as const) {
@ -46,8 +55,25 @@ describe('getSollinhaOpeningQuestions', () => {
expect(getSollinhaOpeningQuestions('leader', true).map((q) => q.text)).toEqual(LEADER_TEXT)
})
it('gives the communication assessor the acervo set — never a campaign question', () => {
for (const isMobile of [false, true]) {
const questions = getSollinhaOpeningQuestions('communicator', isMobile)
expect(questions.map((q) => q.text)).toEqual(COMMUNICATOR_TEXT)
for (const question of questions) {
expect(question.text).not.toMatch(
/deputado mais votado|votos tivemos|dobradinhas temos|como está o município|meus contatos/i,
)
}
}
})
it('falls back to the leader safe set for an unknown role (fail-closed)', () => {
const unknown = 'desconhecido' as CampaignRole
expect(getSollinhaOpeningQuestions(unknown, false).map((q) => q.text)).toEqual(LEADER_TEXT)
})
it('every text is non-empty and trimmed', () => {
for (const role of ['coordinator', 'leader'] as const) {
for (const role of ['coordinator', 'leader', 'communicator'] as const) {
for (const question of getSollinhaOpeningQuestions(role, false)) {
expect(question.text.trim().length).toBeGreaterThan(0)
}

View file

@ -23,63 +23,93 @@ describe('sollinhaChatSession storage', () => {
})
it('returns null when storage is missing or invalid (fail-closed)', () => {
expect(readSollinhaChatSession()).toBeNull()
expect(readSollinhaChatSession('coordinator')).toBeNull()
window.sessionStorage.setItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY, 'not-json')
expect(readSollinhaChatSession()).toBeNull()
expect(readSollinhaChatSession('coordinator')).toBeNull()
window.sessionStorage.setItem(
SOLLINHA_CHAT_SESSION_STORAGE_KEY,
JSON.stringify({ version: 2, messages: [], open: false }),
JSON.stringify({ version: 2, role: 'coordinator', messages: [], open: false }),
)
expect(readSollinhaChatSession()).toBeNull()
expect(readSollinhaChatSession('coordinator')).toBeNull()
window.sessionStorage.setItem(
SOLLINHA_CHAT_SESSION_STORAGE_KEY,
JSON.stringify({ version: 1, messages: 'nope', open: false }),
JSON.stringify({ version: 1, role: 'coordinator', messages: 'nope', open: false }),
)
expect(readSollinhaChatSession()).toBeNull()
expect(readSollinhaChatSession('coordinator')).toBeNull()
window.sessionStorage.setItem(
SOLLINHA_CHAT_SESSION_STORAGE_KEY,
JSON.stringify({ version: 1, messages: [{ id: 1, role: 'user' }], open: false }),
JSON.stringify({
version: 1,
role: 'coordinator',
messages: [{ id: 1, role: 'user' }],
open: false,
}),
)
expect(readSollinhaChatSession()).toBeNull()
expect(readSollinhaChatSession('coordinator')).toBeNull()
window.sessionStorage.setItem(
SOLLINHA_CHAT_SESSION_STORAGE_KEY,
JSON.stringify({ version: 1, messages: [], open: 'yes' }),
JSON.stringify({ version: 1, role: 'coordinator', messages: [], open: 'yes' }),
)
expect(readSollinhaChatSession()).toBeNull()
expect(readSollinhaChatSession('coordinator')).toBeNull()
})
it('round-trips messages and open state', () => {
const messages = [makeMessage('1', 'user'), makeMessage('2', 'assistant')]
writeSollinhaChatSession(messages, true)
const session = readSollinhaChatSession()
writeSollinhaChatSession(messages, true, 'coordinator')
const session = readSollinhaChatSession('coordinator')
expect(session?.messages).toEqual(messages)
expect(session?.open).toBe(true)
expect(session?.role).toBe('coordinator')
})
it('round-trips the open origin and defaults to settle without one', () => {
writeSollinhaChatSession([], true, 'user')
expect(readSollinhaChatSession()?.openBy).toBe('user')
writeSollinhaChatSession([], true, 'settle')
expect(readSollinhaChatSession()?.openBy).toBe('settle')
writeSollinhaChatSession([], true, 'coordinator', 'user')
expect(readSollinhaChatSession('coordinator')?.openBy).toBe('user')
writeSollinhaChatSession([], true, 'coordinator', 'settle')
expect(readSollinhaChatSession('coordinator')?.openBy).toBe('settle')
// No origin passed — a write without intent must never restore a drawer.
writeSollinhaChatSession([], true)
expect(readSollinhaChatSession()?.openBy).toBe('settle')
writeSollinhaChatSession([], true, 'coordinator')
expect(readSollinhaChatSession('coordinator')?.openBy).toBe('settle')
})
it('rejects an invalid open origin (fail-closed)', () => {
window.sessionStorage.setItem(
SOLLINHA_CHAT_SESSION_STORAGE_KEY,
JSON.stringify({ version: 1, messages: [], open: true, openBy: 'system' }),
JSON.stringify({
version: 1,
role: 'coordinator',
messages: [],
open: true,
openBy: 'system',
}),
)
expect(readSollinhaChatSession()).toBeNull()
expect(readSollinhaChatSession('coordinator')).toBeNull()
})
it('still reads legacy sessions without an open origin', () => {
it('still reads sessions without an open origin', () => {
window.sessionStorage.setItem(
SOLLINHA_CHAT_SESSION_STORAGE_KEY,
JSON.stringify({ version: 1, role: 'coordinator', messages: [], open: true }),
)
expect(readSollinhaChatSession('coordinator')?.openBy).toBeUndefined()
})
it('discards sessions written before the role field (C159, fail-closed)', () => {
window.sessionStorage.setItem(
SOLLINHA_CHAT_SESSION_STORAGE_KEY,
JSON.stringify({ version: 1, messages: [], open: true }),
)
expect(readSollinhaChatSession()?.openBy).toBeUndefined()
expect(readSollinhaChatSession('coordinator')).toBeNull()
})
it('never restores another role’s conversation (C159)', () => {
writeSollinhaChatSession(
[makeMessage('1', 'user'), makeMessage('2', 'assistant')],
true,
'coordinator',
)
expect(readSollinhaChatSession('communicator')).toBeNull()
expect(readSollinhaChatSession('coordinator')?.messages).toHaveLength(2)
})
it('prunes to the message-count guardrail keeping the newest', () => {
@ -123,8 +153,8 @@ describe('sollinhaChatSession storage', () => {
const messages = Array.from({ length: SOLLINHA_CHAT_MAX_MESSAGES + 5 }, (_, index) =>
makeMessage(String(index), index % 2 === 0 ? 'user' : 'assistant'),
)
writeSollinhaChatSession(messages, false)
const session = readSollinhaChatSession()
writeSollinhaChatSession(messages, false, 'coordinator')
const session = readSollinhaChatSession('coordinator')
expect(session?.messages).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES - 1)
expect(session?.messages[0]?.id).toBe('6')
expect(session?.messages[0]?.role).toBe('user')
@ -153,7 +183,7 @@ describe('sollinhaChatSession storage', () => {
],
},
] as unknown as UIMessage[]
writeSollinhaChatSession(messages, true)
expect(readSollinhaChatSession()?.messages).toEqual(messages)
writeSollinhaChatSession(messages, true, 'coordinator')
expect(readSollinhaChatSession('coordinator')?.messages).toEqual(messages)
})
})

View file

@ -69,7 +69,7 @@ describe('findSpeechExcerpts gate (C158)', () => {
expect(rerankMock).not.toHaveBeenCalled()
})
it('deixa coordinator, candidate e communicator passarem (herança do C159)', async () => {
it('deixa coordinator, candidate e communicator passarem (gate herdado pelo chat do C159)', async () => {
for (const actor of [coordinator, user('candidate'), user('communicator')]) {
const find = vi.fn().mockResolvedValue(speechResult([]))
const result = (await execute(