diff --git a/docs/changelog/2026-09-14-c159.md b/docs/changelog/2026-09-14-c159.md
new file mode 100644
index 00000000..f49e252b
--- /dev/null
+++ b/docs/changelog/2026-09-14-c159.md
@@ -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.
diff --git a/docs/plans/sollinha-para-comunicacao-impl.md b/docs/plans/sollinha-para-comunicacao-impl.md
new file mode 100644
index 00000000..caef4472
--- /dev/null
+++ b/docs/plans/sollinha-para-comunicacao-impl.md
@@ -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
sem gate de papel (D1)"]
+ SH --> UI["FAB · Drawer · botão do header · painel"]
+ SH --> P["CampaignAISidebarProvider role"]
+ P --> R["readSollinhaChatSession(role)
role mismatch → sessão nova (D7)"]
+ P --> C["CampaignAIChat
chips + boas-vindas por papel (D5/D6)"]
+ C --> API["POST /campanha/api/ai-chat"]
+ API --> SYS["buildAISystemPrompt(role)
communicator = escopo acervo (D4)"]
+ API --> REG["buildAITools ctx
communicator/desconhecido → mínimo (D2)"]
+ REG --> T1["findSpeechExcerpts (C158)
trechos + deep-link do acervo"]
+ REG --> T2["calculate"]
+ REG --> T3["buildCampaignLinks
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): `` 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.
diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs
index 38d02a89..ae5974ee 100644
--- a/scripts/lib/e2e-affected-manifest.mjs
+++ b/scripts/lib/e2e-affected-manifest.mjs
@@ -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`,
diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx
index 27c59789..810df6da 100644
--- a/src/app/(campaign)/campanha/(app)/layout.tsx
+++ b/src/app/(campaign)/campanha/(app)/layout.tsx
@@ -108,7 +108,6 @@ export default async function CampaignAppLayout({ children }: { children: React.
notificationBell={}
/>
}
/>
{/*
diff --git a/src/app/(campaign)/campanha/api/ai-chat/route.ts b/src/app/(campaign)/campanha/api/ai-chat/route.ts
index 65e14007..3cd7ea97 100644
--- a/src/app/(campaign)/campanha/api/ai-chat/route.ts
+++ b/src/app/(campaign)/campanha/api/ai-chat/route.ts
@@ -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),
diff --git a/src/components/campaign/shell/CampaignDesktopHeader.tsx b/src/components/campaign/shell/CampaignDesktopHeader.tsx
index 41e81336..dbf68943 100644
--- a/src/components/campaign/shell/CampaignDesktopHeader.tsx
+++ b/src/components/campaign/shell/CampaignDesktopHeader.tsx
@@ -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 = ({
{node}
))}
{notificationBell ?? null}
- {canUseCampaignAssistant(role) ? : null}
+
)
diff --git a/src/components/campaign/shell/ai/CampaignAIChat.tsx b/src/components/campaign/shell/ai/CampaignAIChat.tsx
index 8b4688fd..28e7b079 100644
--- a/src/components/campaign/shell/ai/CampaignAIChat.tsx
+++ b/src/components/campaign/shell/ai/CampaignAIChat.tsx
@@ -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 (
{/* Messages area */}
@@ -148,10 +155,7 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
-
- Olá! Eu sou o Sollinha, assistente virtual da campanha. Pergunte sobre votações,
- municípios, dobradinhas, lideranças e muito mais.
-
+
{welcomeText}
) : (
diff --git a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx
index 75a235be..5eb85100 100644
--- a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx
+++ b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx
@@ -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
diff --git a/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx b/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx
index a5453d12..1f5a11a5 100644
--- a/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx
+++ b/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx
@@ -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 (
diff --git a/src/lib/campaignRoles.ts b/src/lib/campaignRoles.ts
index 49966162..37de398d 100644
--- a/src/lib/campaignRoles.ts
+++ b/src/lib/campaignRoles.ts
@@ -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'
diff --git a/src/lib/sollinhaChatSession.ts b/src/lib/sollinhaChatSession.ts
index 667bf1e9..9ec4af2b 100644
--- a/src/lib/sollinhaChatSession.ts
+++ b/src/lib/sollinhaChatSession.ts
@@ -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 = 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 =>
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,
diff --git a/src/lib/sollinhaOpeningQuestions.ts b/src/lib/sollinhaOpeningQuestions.ts
index c45a6e79..7112c56c 100644
--- a/src/lib/sollinhaOpeningQuestions.ts
+++ b/src/lib/sollinhaOpeningQuestions.ts
@@ -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
+}
diff --git a/src/utilities/ai/campaignNavigationUrls.ts b/src/utilities/ai/campaignNavigationUrls.ts
index 6b822ca5..3f17d14e 100644
--- a/src/utilities/ai/campaignNavigationUrls.ts
+++ b/src/utilities/ai/campaignNavigationUrls.ts
@@ -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.',
diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts
index 69452914..9cdd9d75 100644
--- a/src/utilities/ai/systemPrompt.ts
+++ b/src/utilities/ai/systemPrompt.ts
@@ -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.
`
+}
diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts
index c0393840..4837c20d 100644
--- a/src/utilities/ai/tools/index.ts
+++ b/src/utilities/ai/tools/index.ts
@@ -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),
})
diff --git a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
index 2563634d..a439e969 100644
--- a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
+++ b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
@@ -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 ({
diff --git a/tests/unit/aiSystemPrompt.unit.spec.ts b/tests/unit/aiSystemPrompt.unit.spec.ts
new file mode 100644
index 00000000..38e158b0
--- /dev/null
+++ b/tests/unit/aiSystemPrompt.unit.spec.ts
@@ -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')
+ }
+ })
+})
diff --git a/tests/unit/aiToolsScope.unit.spec.ts b/tests/unit/aiToolsScope.unit.spec.ts
new file mode 100644
index 00000000..62e924ba
--- /dev/null
+++ b/tests/unit/aiToolsScope.unit.spec.ts
@@ -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({ role }),
+ payload: stub({}),
+})
+
+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)
+ }
+ }
+ })
+})
diff --git a/tests/unit/campaignNavigationUrls.unit.spec.ts b/tests/unit/campaignNavigationUrls.unit.spec.ts
index 65059825..cc4180d1 100644
--- a/tests/unit/campaignNavigationUrls.unit.spec.ts
+++ b/tests/unit/campaignNavigationUrls.unit.spec.ts
@@ -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',
diff --git a/tests/unit/campaignSollinhaOpeningQuestions.unit.spec.ts b/tests/unit/campaignSollinhaOpeningQuestions.unit.spec.ts
index b5032681..9ccb40ab 100644
--- a/tests/unit/campaignSollinhaOpeningQuestions.unit.spec.ts
+++ b/tests/unit/campaignSollinhaOpeningQuestions.unit.spec.ts
@@ -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)
}
diff --git a/tests/unit/sollinhaChatSession.unit.spec.ts b/tests/unit/sollinhaChatSession.unit.spec.ts
index 2414bcbf..6852c5b4 100644
--- a/tests/unit/sollinhaChatSession.unit.spec.ts
+++ b/tests/unit/sollinhaChatSession.unit.spec.ts
@@ -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)
})
})
diff --git a/tests/unit/speechExcerptsTool.unit.spec.ts b/tests/unit/speechExcerptsTool.unit.spec.ts
index ba924827..5f3e00d7 100644
--- a/tests/unit/speechExcerptsTool.unit.spec.ts
+++ b/tests/unit/speechExcerptsTool.unit.spec.ts
@@ -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(