From 897ebe0817307ace808927168af319e37a7dcff6 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 14 Sep 2026 05:23:50 -0300 Subject: [PATCH] =?UTF-8?q?feat(C159):=20Sollinha=20na=20assessoria=20de?= =?UTF-8?q?=20comunica=C3=A7=C3=A3o=20=E2=80=94=20chat=20escopado=20ao=20a?= =?UTF-8?q?cervo=20(Closes=20#983)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-14-c159.md | 1 + docs/plans/sollinha-para-comunicacao-impl.md | 223 ++++++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 6 + src/app/(campaign)/campanha/(app)/layout.tsx | 1 - .../(campaign)/campanha/api/ai-chat/route.ts | 4 +- .../campaign/shell/CampaignDesktopHeader.tsx | 11 +- .../campaign/shell/ai/CampaignAIChat.tsx | 12 +- .../shell/ai/CampaignAISidebarContext.tsx | 12 +- .../shell/ai/CampaignAISidebarShell.tsx | 9 +- src/lib/campaignRoles.ts | 8 - src/lib/sollinhaChatSession.ts | 42 +++- src/lib/sollinhaOpeningQuestions.ts | 23 +- src/utilities/ai/campaignNavigationUrls.ts | 9 + src/utilities/ai/systemPrompt.ts | 103 ++++++-- src/utilities/ai/tools/index.ts | 20 +- tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 13 +- tests/unit/aiSystemPrompt.unit.spec.ts | 65 +++++ tests/unit/aiToolsScope.unit.spec.ts | 57 +++++ .../unit/campaignNavigationUrls.unit.spec.ts | 19 ++ ...paignSollinhaOpeningQuestions.unit.spec.ts | 28 ++- tests/unit/sollinhaChatSession.unit.spec.ts | 82 +++++-- tests/unit/speechExcerptsTool.unit.spec.ts | 2 +- 22 files changed, 649 insertions(+), 101 deletions(-) create mode 100644 docs/changelog/2026-09-14-c159.md create mode 100644 docs/plans/sollinha-para-comunicacao-impl.md create mode 100644 tests/unit/aiSystemPrompt.unit.spec.ts create mode 100644 tests/unit/aiToolsScope.unit.spec.ts 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(