teqo-backup/docs/plans/sollinha-para-comunicacao-impl.md

223 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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