diff --git a/docs/changelog/2026-09-14-c158.md b/docs/changelog/2026-09-14-c158.md
new file mode 100644
index 00000000..f666befa
--- /dev/null
+++ b/docs/changelog/2026-09-14-c158.md
@@ -0,0 +1 @@
+**C158 — Sollinha sugere trechos de falas do acervo para vídeos (2026-09-14):** a ponte entre a pergunta de tema no chat e o acervo da Comunicação (C153/C154). A tool read-only `findSpeechExcerpts` recebe `tema` (palavras de conteúdo) e `intencao` (uso/tom/duração, nas palavras do usuário), sanitiza os termos em `src/lib/speechExcerpt.ts` (normaliza acento, quebra em `[^a-z0-9]+` — `%`/`_` nunca chegam ao `LIKE` —, descarta <3 chars e capa 6), busca candidatos com AND de `searchText like %termo%` sobre `speech.searchText` (índice trgm, `sort -speechAt`, 30) e monta a janela contígua com `buildSpeechExcerpt`: âncora de maior cobertura + expansão **priorizando cobertura** sobre o alvo, variantes curta (~20s) e longa (~45s), gap máximo 5s, teto 60s (segmento único maior é mantido inteiro), mínimo 15s — um trecho por discurso, no máximo 3 (até 5). A escolha entre os candidatos é **refletida** por `src/utilities/ai/rerankSpeechExcerpts.ts` (`server-only`, DeepSeek `deepseek-flash` via `generateObject`+zod, timeout 6s, **nunca lança**, valida índices/motivos; lista vazia é decisão "nenhum serve"); sem chave/erro/saída inválida a tool cai na ordem determinística (`reordenadoPorIA: false`) e **nunca inventa** fala/minutagem — citação e corte sempre vêm do ASR. Cada sugestão devolve `citacao`, `inicioLabel`/`fimLabel`, `motivo` (quando houve IA) e `url` pronta no ponto do acervo (`?t=&q=` via `buildWatchHref`, exportado do dono junto de `formatSpeechAt`/`formatSpeechClock`; o loader `loadSegmentsForSpeeches` também foi exportado e reusado). Gate `canReadSpeechCatalog` fail-closed **antes de qualquer query e do reranker** (coordinator/candidate/communicator leem; **advisor e leader negados**), nascendo herdável pelo C159; seção nova no `systemPrompt` ensina quando usar, o formato (1–3 com citação, corte, justificativa e link) e os guardrails de vazio/negativa. Sem migration, sem Consent, sem UI nova. Provas: 3 unit novos (`speechExcerpt`, `rerankSpeechExcerpts` com `generateObject` mockado, `speechExcerptsTool` com gate intocado) + int `speechExcerpts` (Payload real: 5 papéis, acento, janela/URL, dedup, vazio, spy do reranker) + e2e afetado `campaignAiTranscribe` verde; `gate:fast`, `knip` e `check:cycles` verdes. Nota de execução: o smoke real do `generateObject` com `deepseek-flash` não rodou no ambiente local (sem `DEEPSEEK_API_KEY`); o desenho é fail-safe pelo fallback — validar no primeiro uso em staging/produção (Fase 2 do `sollinha-acervo-falas-trechos-impl.md`). O gate de produto de 2026-09-14 incorporou o reranking por LLM (antes listado como rabbit hole cortado); embeddings/índice vetorial seguem fora.
diff --git a/docs/plans/sollinha-acervo-falas-trechos-impl.md b/docs/plans/sollinha-acervo-falas-trechos-impl.md
new file mode 100644
index 00000000..d0a6d281
--- /dev/null
+++ b/docs/plans/sollinha-acervo-falas-trechos-impl.md
@@ -0,0 +1,251 @@
+# Impl: Sollinha: sugerir trechos de falas do acervo para vídeos
+
+Status: rascunho — revisão pós-gate (2026-09-14): reranking por LLM incorporado a pedido do produto; aguarda confirmação do plano revisado
+Atualizado em: 2026-09-14
+Issue: #982
+Intenção: docs/plans/sollinha-acervo-falas-trechos.md (decisão do gate de 2026-09-14: reranking por LLM incorporado)
+Appetite restante: **ajustado com corte explícito** — herdado ~1 dia + ~0,25–0,5 dia do reranker (aceito pelo humano no gate de 2026-09-14); nada mais entra.
+
+## Leitura da intenção (outcome, não negociar, reavaliar)
+
+- **Outcome:** uma tool read-only nova do chat responde pergunta de tema ("qual uma boa fala para um reels sobre o hospital do subúrbio?") com **1–3 trechos contínuos** do acervo (`speech`/`speechSegment`), cada um com **citação aproximada do ASR + minutagem início/fim + link para o acervo no ponto do trecho** (`?t=`), onde já há assistir/baixar/abrir fonte. **Decisão do gate (2026-09-14):** o Sollinha **reflete** sobre qual trecho serve melhor à intenção declarada (tema, uso, tom, duração) e de que ponto a que ponto — a escolha entre candidatos é **rerankeada por LLM dentro da tool**, com justificativa curta apresentada na resposta; a busca segue textual (sem embeddings).
+- **O que NÃO negociar:**
+ - Sem trecho → dizer que não achou; **nunca inventar fala nem minutagem**; a citação vem do ASR e pode ter ruído — vale como localizador, a referência é o vídeo.
+ - Cada sugestão é **um trecho contínuo, curto o bastante para virar peça** — nada de transcrição inteira nem de trecho montado pulando segmentos.
+ - Quem não lê o acervo recebe **negativa clara, fail-closed, antes de qualquer query** — nunca dado parcial nem erro técnico.
+ - **Sem migration/collection/Consent; sem UI nova.** O acervo segue interno a `/campanha`; o chat não entrega arquivo nem edita.
+- **O que reavaliar (hipóteses da exploração):**
+ - "Busca por termos com `speechSegment.searchText like %termo%` e agrupar por speech": a direção (AND de termos) está certa, mas a **query primária no `speech`** (`speech.searchText`, índice `speech_search_text_trgm_idx`) permite ordenar por recência (`-speechAt`) **antes** de cortar candidatos; a precisão de segmento é validada em memória pela janela. Ver D1.
+ - "A frase precisa aparecer contígua para casar" (nota da exploração sobre `speechListFilters`): verificado no adapter (`@payloadcms/drizzle/queries/parseParams.js:184`: `like` faz split por espaço e AND de `%palavra%`), então `like` já é "termos em qualquer ordem"; o que falta não é a query, e sim **a janela + citação + link + reflexão**.
+ - "Gate pelo `canUseCampaignAssistant`": reavaliado para `canReadSpeechCatalog` (D3) — C159 reabre o chat para o communicator e a tool precisa nascer herdável.
+ - Destino `acervo` no `buildCampaignLinks` (opção B do link): rejeitado; a tool devolve a URL completa de cada trecho (D5).
+ - "Reranking é rabbit hole cortado" (intent original): **revisado pelo gate de 2026-09-14** — reranking por LLM sobre os candidatos recuperados foi incorporado a pedido do produto; embeddings/índice vetorial continuam fora (D9).
+- **Sem reescrita do outcome:** a entrega é a ponte pergunta→trecho com reflexão sobre adequação e corte; edição de vídeo e transcrição no chat continuam fora (rabbit holes).
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ U[pergunta no chat] --> M[modelo extrai tema + intenção
ex.: reels curto sobre hospital do subúrbio]
+ M --> T[findSpeechExcerpts ctx]
+ T --> G{canReadSpeechCatalog?}
+ G -- não --> N[error: leitura negada]
+ G -- sim --> Q1[(speech.find
AND searchText like termo
sort -speechAt, limit 30)]
+ Q1 --> Q2[(speechSegment.find
speech in ids, sort order)]
+ Q2 --> W[buildSpeechExcerpt
âncora + variantes 20s/45s
janela 15-60s, cobertura total]
+ W --> P[pool de até 12 candidatos
1-2 por discurso]
+ P --> R[rerankSpeechExcerpts IA
escolhe até 3 + motivo]
+ R -- falha/timeout/sem chave --> D[ordem determinística
recência + cobertura
reordenadoPorIA: false]
+ R -- ok --> S[seleção com motivo
reordenadoPorIA: true]
+ D --> A
+ S --> A[resposta: 1-3 trechos com citação,
corte mm:ss, justificativa e link]
+```
+
+**Opções consideradas:**
+
+- **A — Tool nova `findSpeechExcerpts`** com busca textual por termos (AND), janela contígua pura em duas variantes de duração e **reranking por LLM** dos candidatos conforme a intenção declarada; registrada em `buildAITools`, gate `canReadSpeechCatalog`, retorno pt-BR no shape dos tools B185/B186.
+- **B — Reusar `buildSpeechListWhere` (C154) dentro da tool** e adaptar o resultado.
+- **C — Estender `searchEntities`/`buildCampaignLinks`** ou criar busca semântica/embeddings.
+- **D — Sem reranker:** só ordenação determinística (o modelo externo escolhe na apresentação).
+
+**Recomendação: A** — a tool é a dona do novo concern (pergunta→trecho com janela, corte e reflexão sobre adequação); o gate tem que ser o do acervo (não o de staff/chat) para C159 herdar; o retorno precisa carregar candidatos com corte, escolha justificada e URL — nada disso existe nos tools atuais. Nome `findSpeechExcerpts`. **Rejeitadas:** B (mistura facetas e `keywords contains` sem âncora/minutagem), C (embeddings/sinônimos são anti-goal e infra nova; `buildCampaignLinks` não conhece `?t=&q=`), D (decisão do gate de 2026-09-14: o produto quer que o Sollinha reflita e justifique com IA — ver D9).
+
+### Componentes / mudanças
+
+- **`src/lib/speechExcerpt.ts`** (novo, puro/client-safe):
+ - `speechExcerptTerms(query)` — `normalizeForSearch` → split `[^a-z0-9]+` → dedupe → descarta `< 3` (`MIN_TERM_LENGTH`) → cap 6 (`MAX_TERMS`).
+ - `buildSpeechExcerpt(segments, terms, { targetSeconds })` → `SpeechExcerpt | null` — âncora de maior cobertura + expansão contígua (D2) numa variante de alvo.
+ - Constantes: `EXCERPT_MIN_SECONDS = 15`, `EXCERPT_DEFAULT_TARGET_SECONDS = 30`, `EXCERPT_MAX_SECONDS = 60`, `EXCERPT_MAX_GAP_SECONDS = 5`, `EXCERPT_MAX_ANCHORS = 5`, `EXCERPT_VARIANT_TARGETS = [20, 45]`.
+- **`src/utilities/ai/rerankSpeechExcerpts.ts`** (novo, `server-only`): `rerankSpeechExcerpts({ intencao, tema, candidates, limit })` → `{ choices: Array<{ index: number; reason: string }> } | null` (D9). Espelha `deriveDemandTitle` (B195): chama `deepSeek('deepseek-flash')` server-side, **nunca lança**, timeout curto, valida a saída com zod (índices no range, sem repetição, ≤ limit, reason não vazia ≤140 chars); lista vazia do modelo é decisão válida (`{ choices: [] }` — "nenhum serve"); saída só com índices inválidos/motivos vazios, erro ou timeout → `null` (o caller dono do fallback).
+- **`src/utilities/ai/tools/findSpeechExcerpts.ts`** (novo): factory `(ctx: AIToolContext) => tool({ description, inputSchema, execute })`; gate fail-closed; queries `overrideAccess: false, user: ctx.user`; pool determinístico; reranker IA com fallback; dedup final; URL via `buildWatchHref`. Contrato de retorno:
+
+ ```ts
+ {
+ consulta: { tema: string; termos: string[]; intencao: string | null },
+ criterio: string, // cobertura dos termos no trecho + variante curta/longa
+ totalDiscursos: number, // candidatos analisados
+ reordenadoPorIA: boolean, // true = reranker IA escolheu/ordenou; false = fallback determinístico
+ trechos: Array<{
+ discursoId: number
+ data: string // formatSpeechAt(speech.speechAt)
+ tipo: string | null
+ inicioSegundos: number // floor(startSeconds)
+ fimSegundos: number // ceil(endSeconds)
+ inicioLabel: string // formatSpeechClock(início)
+ fimLabel: string // formatSpeechClock(fim)
+ duracaoSegundos: number
+ citacao: string // texto ASR cru da janela
+ termosCasados: string[]
+ motivo?: string // 1 linha: por que serve à intenção (só com reranker IA)
+ url: string // /campanha/comunicacao/acervo/?t=&q=
+ }>,
+ truncado: boolean,
+ dica?: string,
+ }
+ ```
+
+ - Schema de entrada: `tema: string` (palavras de conteúdo), `intencao?: string` (o que o usuário quer com a peça, nas palavras dele — uso/tom/duração/plataforma), `limit?: number` (1–5, default 3).
+
+- **`src/utilities/ai/tools/index.ts`** (editar o dono): registrar `findSpeechExcerpts: findSpeechExcerpts(ctx)`.
+- **`src/utilities/speech/speechViewModels.ts`** (editar o dono): **exportar** `buildWatchHref`, `formatSpeechAt` e `formatSpeechClock` (hoje privados) para reuso pela tool — sem mudança de comportamento. Rejeitada: duplicar formatador de link/relógio na tool (twin).
+- **`src/utilities/ai/systemPrompt.ts`** (editar): seção "Trechos de fala do acervo para vídeos" (D7).
+- **Migration:** **sem migration** — `speech.searchText`, `speechSegment.searchText` e os índices trgm (`speech_search_text_trgm_idx`, `speech_segment_search_text_trgm_idx`) já estão em produção (C153/C154).
+- **Access / Consent:** sem mudança de access e **sem Consent novo**. O gate é `canReadSpeechCatalog(ctx.user.role)` (`src/lib/campaignRoles.ts:25`: communicator | coordinator | candidate); as leituras caem no access existente `canReadSpeech` das collections, sempre com `overrideAccess: false` + `user`. O reranker envia citações do acervo ao DeepSeek — o mesmo provedor do próprio chat (nenhum processor novo; dado segue interno).
+- **UI:** **Impeccable A — N/A** (resposta em markdown no chat existente; superfície nova = nenhuma).
+- **Testes:** `tests/unit/speechExcerpt.unit.spec.ts` (puro), `tests/unit/rerankSpeechExcerpts.unit.spec.ts` (validação/fallback do reranker), `tests/unit/speechExcerptsTool.unit.spec.ts` (gate + shape + fallback + seleção IA mockada), `tests/int/speechExcerpts.int.spec.ts` (fluxo com Payload real, reranker mockado). Sem e2e (D8).
+- **Changelog:** `docs/changelog/2026-09-14-c158.md`.
+
+### Dados → forma (a resposta é a superfície)
+
+- **Forma escolhida:** lista curta (1–3) de sugestões no chat; cada uma = **citação aproximada** (texto ASR cru da janela, sem markup), **intervalo** `inicioLabel–fimLabel` (mm:ss), **uma linha de justificativa** (por que aquele trecho serve à intenção, vinda do `motivo`) e **link markdown** para o acervo no trecho (`[abrir no acervo no trecho](/campanha/comunicacao/acervo/?t=&q=)`). A tool devolve dados estruturados + seleção refletida; o modelo redige a resposta.
+- **Rejeitadas:** transcrição inteira/trecho longo no chat (anti-goal; contexto estoura e a citação perde foco); cartões/JSON cru na resposta (o modelo é a camada de apresentação, como nos demais tools); apresentar só o link, sem citação (o usuário pediu "fala", não navegação); highlight/`parts` no texto retornado (a UI do acervo já destaca com `?q=`; aqui o valor é a citação exata); lista sem justificativa (a decisão do gate pede reflexão explícita).
+
+## Decisões de engenharia
+
+> D1–D8 são as decisões de desenho herdadas da revisão pré-gate; **D9 é a decisão nova do gate de 2026-09-14** (reranking por LLM). Faseamento e riscos vivem nas seções próprias.
+
+### D1 — Estratégia de busca: tema → termos → candidatos por discurso + validação por segmento
+
+- **Opções:** A) o modelo extrai um **tema** (palavras de conteúdo) no schema; a tool sanitiza em termos e busca **candidatos no `speech`** (`and` de `searchText: { like: termo }`), ordena `-speechAt`, limita 30, busca os segmentos desses discursos e valida a janela em memória | B) buscar direto em `speechSegment` com AND de termos e agrupar por discurso | C) 1 query por termo (OR) e ranquear por cobertura | D) `buildSpeechListWhere` + pós-processamento.
+- **Recomendação: A.**
+ - Schema: `tema: string` (o modelo extrai da pergunta; ex.: "qual uma boa fala para um reels sobre o hospital do subúrbio?" → `tema: "hospital do subúrbio"`; nunca a pergunta inteira, nunca palavra que o usuário não disse).
+ - `speechExcerptTerms` = `normalizeForSearch` → `split(/[^a-z0-9]+/)` → dedupe → descarta `< 3` (`MIN_TERM_LENGTH`) → cap `6` (`MAX_TERMS`). O split mata `%`/`_`/pontuação (sem curinga acidental) e evita termos que o índice trgm não cobre.
+ - Query de candidatos no `speech` (concatenação normalizada dos segmentos, índice GIN trgm já em prod): `where: { and: terms.map((term) => ({ searchText: { like: term } })) }`, `sort: '-speechAt'`, `limit: SPEECH_CANDIDATE_LIMIT` (30), `select: { speechAt, type }`, `overrideAccess: false, user`. O `and` explícito é a forma testável (o adapter já splita por espaço e ANDa substrings).
+ - Segunda query: segmentos dos candidatos — `where: { speech: { in: ids } }`, `sort: 'order'`, `pagination: false`, `select: { speech, startSeconds, endSeconds, text }`, `user`.
+ - **Fallback com 0 resultados (AND):** **nenhum**. Sem candidato, devolve `trechos: []` + `criterio` + `totalDiscursos: 0`, e o modelo diz que não achou. **Rejeitadas:** (a) refazer com OR ou com o termo mais raro — amplia recall sem controle e pode devolver fala fora do tema ("inventar por aproximação"); (b) relaxar o AND para "qualquer termo" e ranquear por cobertura — ranking de similaridade sem sinal de qualidade; (c) fallback por `keywords` — keyword oficial não tem segmento/âncora; o refinamento do tema é explícito e auditável.
+ - **Rejeitada B (query primária em segmento):** segmento não tem `speechAt`, então não há como ordenar por recência antes do corte — o limite escolheria discursos por ordem de inserção, não por relevância; a query no `speech` usa o mesmo idioma de busca e o mesmo índice, e a precisão de segmento continua garantida pela janela (D2). **Rejeitadas C/D:** OR/cobertura perde o fail-closed do "não achei" e puxa muitos candidatos; D mistura facetas de lista e OR de keyword (ver Abordagem).
+ - **Semântica do vazio:** `totalDiscursos` pode ser > 0 com `trechos: []` — significa que os termos aparecem nos discursos, mas não juntos num trecho contínuo curto (D2); o critério e o prompt tornam isso explícito. Nunca é erro técnico.
+
+### D2 — Janela de trecho contínuo: âncora por cobertura + expansão com teto (duas variantes)
+
+- **Opções:** A) função pura nova `buildSpeechExcerpt(segments, terms, { targetSeconds })` em `src/lib/speechExcerpt.ts` (âncora + expansão contígua, invocada em **duas variantes de alvo** para o reranker escolher o corte que serve à intenção) | B) juntar strings por pontuação/corte de frase | C) janela de N segmentos fixos | D) devolver o segmento âncora isolado (sem expansão) | E) uma única variante de alvo (30s).
+- **Recomendação: A**, com os números e a justificativa:
+ - `EXCERPT_VARIANT_TARGETS = [20, 45]` — **duas variantes por discurso**: curta (~20s, punchy para reels/story) e longa (~45s, argumento completo). É o que dá ao reranker (D9) o que refletir sobre "de que ponto a que ponto": mesmo trecho-âncora, cortes diferentes.
+ - `EXCERPT_MIN_SECONDS = 15` — piso de contexto: abaixo disso a fala perde sentido; é piso de expansão, não corte (discurso curto devolve o que existe).
+ - `EXCERPT_DEFAULT_TARGET_SECONDS = 30` — alvo default da função pura (fallback e testes); as variantes passam 20/45.
+ - `EXCERPT_MAX_SECONDS = 60` — teto duro: acima disso vira transcrição longa e deixa de ser peça. Se um único segmento ASR já exceder 60s, ele é devolvido inteiro (não se corta segmento no meio).
+ - `EXCERPT_MAX_GAP_SECONDS = 5` — pausa máxima entre segmentos consecutivos: ASR é frasal e contíguo; gap maior = silêncio/troca de trecho, não se concatena por cima.
+ - `EXCERPT_MAX_ANCHORS = 5` — tenta até 5 âncoras (ordenadas por cobertura) para achar uma janela que cubra todos os termos; se nenhuma cobrir, o discurso não rende trecho (`null`).
+ - Algoritmo: normaliza cada `text` uma vez; score = quantos termos aparecem; candidatas ordenadas por `(score desc, startSeconds asc)`; para cada âncora: expande **para frente** enquanto `duração < target`, `gap ≤ 5s` e `fim − início ≤ 60s`; depois **para trás** se ainda `< 15s`, com os mesmos limites; confere a cobertura no texto concatenado; retorna `{ startSeconds, endSeconds, text, matchedTerms }` (citação = `text` cru, `matchedTerms` = todos).
+ - **Rejeitadas:** B (heurística de pontuação sem lastro no ASR), C (segmentos têm duração variável; N fixo não controla segundos), D (âncora isolada de 2–3s não é peça utilizável), E (uma variante tira do reranker a decisão sobre o corte — decisão do gate de 2026-09-14). **Nota:** a cobertura é verificada na **janela** (não só na âncora): termos podem estar em segmentos consecutivos e o usuário lê o recorte inteiro — que é exatamente o que a peça vai usar.
+
+### D3 — Gate: `canReadSpeechCatalog` fail-closed, antes de qualquer query
+
+- **Opções:** A) `if (!canReadSpeechCatalog(ctx.user.role)) return { error: 'Leitura do acervo de falas negada.' }` no topo do `execute` | B) `isStaffCampaignRole` | C) `canUseCampaignAssistant` | D) sem gate na tool, só o access da collection.
+- **Recomendação: A** — é **o mesmo predicado do access** `canReadSpeech` (C153) e o recorte exato do acervo: communicator + coordinator + candidate; advisor (staff, mas negado no acervo) e leader negados. Nasce herdável por C159 e não depende de onde a tool é chamada. Early-return **antes** de resolver termos, de qualquer `payload.find` **e do reranker**, no shape do chat (B180): `{ error }`, jamais throw.
+- **Rejeitadas:** B (deixaria o advisor passar e negaria o communicator — quebra o gate de produto de 2026-09-14 e o C159), C (é gate de superfície do chat, não do dado; C159 muda a superfície e a tool ficaria presa), D (a resposta poderia virar erro técnico em vez da negativa clara; o access continua como segunda linha, com todas as queries em `overrideAccess: false`).
+
+### D4 — Citação: texto exato da janela (ASR cru) + termos
+
+- **Opções:** A) `citacao` = segmentos concatenados por espaço, texto cru do ASR, sem highlight; `consulta.termos` no topo (todos casam por construção) | B) devolver `parts` com highlight (como a UI) | C) devolver só o texto normalizado (sem acento) | D) resumir/normalizar a fala.
+- **Recomendação: A** — a intenção manda citação aproximada e admite ruído; cru é fiel ao acervo e o modelo deixa claro que é ASR. O highlight é apresentação da UI (o `?q=` do link destaca no detalhe); `parts` no chat viraria markup sem função. Normalizar/summarizar seria reescrever a fala (risco de "inventar"). `termosCasados` por trecho dá a confiança explícita (cobertura é total por D2); o topo leva `consulta: { tema, termos, intencao }`.
+- **Rejeitadas:** B, C e D — acima.
+
+### D5 — Link: URL completa do trecho devolvida pela tool (opção A do gate)
+
+- **Opções:** A) cada trecho traz `url = buildWatchHref(speechId, janela, termos.join(' '))` → `/campanha/comunicacao/acervo/?t=&q=`; o prompt ensina o modelo a formatar `[abrir no acervo no trecho](url)` | B) adicionar destino `acervo` ao `buildCampaignLinks`/`campaignNavigationUrls` com gate `canReadSpeechCatalog`.
+- **Recomendação: A** — a tool conhece o id e o offset no momento em que monta o trecho; devolver a URL pronta garante que `t` e `q` nasçam juntos e corretos (sem o modelo recompor query string), reusa o builder canônico do acervo (`buildWatchHref`, exportado do dono) e não mexe no contrato de navegação. O detalhe do acervo já lê `?t` (seek do player) e `?q` (destaque da transcrição).
+- **Rejeitadas:** B — expandiria o contrato de navegação e a discriminated union do `buildCampaignLinks` por um único link, exigiria suportar `?t=&q=` no destino (não previsto), e o gate de destinations é por staff (`STAFF_DESTINATIONS` exclui communicator — precisaria de uma terceira classe de destino). `buildCampaignLinks` continua existindo para os demais links.
+
+### D6 — Limites, pool e ranking
+
+- **Opções:** A) pool = até **6 discursos × 2 variantes = 12 candidatos** (recência + cobertura), reranker escolhe até 3, 1 trecho final por discurso | B) paginação de candidatos com offset/retry | C) ranquear por cobertura/ocorrência de termos | D) devolver todos os trechos do discurso.
+- **Recomendação: A**, com os números:
+ - `SPEECH_CANDIDATE_LIMIT = 30` — discursos analisados (query no `speech`), margem para a validação da janela derrubar candidatos sem zerar.
+ - `EXCERPT_POOL_SPEECHES = 6` — discursos que viram candidatos de trecho (os 6 mais recentes com janela válida); cada um rende até 2 variantes (D2) → pool ≤ 12. Tamanho que cabe no prompt do reranker (~≤10k chars) e dá escolha real.
+ - **Dedup final por discurso** (1 trecho por discurso): a peça vai usar UM trecho; duas sugestões do mesmo discurso seriam quase-duplicatas e roubariam a vaga de uma fala diferente — aplicado **depois** do reranker (se ele escolher as duas variantes do mesmo discurso, fica a primeira e a vaga vai para o próximo da ordem).
+ - `limit` default **3**, min 1, max **5**: a intenção fixa 1–3 conforme o pedido; o max 5 cobre "me dá mais opções" sem virar lista de transcrições. O prompt manda apresentar 1–3 (4–5 só se o usuário pedir explicitamente).
+ - **Ordem determinística (fallback do reranker):** recência (`-speechAt`) e, dentro do discurso, a variante longa (~45s, mais contexto de argumento); com reranker IA, a ordem é a dele.
+ - `truncado = totalDocs > SPEECH_CANDIDATE_LIMIT`; `dica` quando truncado (shape B185/B186): `'Há mais discursos candidatos além dos analisados; refine o tema para chegar em outros.'`
+- **Rejeitadas:** B (chat não tem paginação; refinar o tema é o caminho), C (ocorrência de termo não mede adequação à intenção — é justamente o que o reranker faz), D (transcrição inteira é anti-goal).
+
+### D7 — System prompt: seção nova ensinando quando usar, intenção, formato e limites
+
+- **Opções:** A) seção própria no `AI_SYSTEM_PROMPT` (como "Lideranças pendentes"/"Prioridades do momento") | B) só a `description` da tool | C) prompt dedicado separado.
+- **Recomendação: A** — a `description` ensina o modelo a _chamar_; o formato da resposta (1–3, citação, minutagem, justificativa, link, negativa) é comportamento de resposta e precisa de seção; o padrão do repo é esse. Conteúdo:
+ - **Quando usar:** "boa fala/trecho/citação do deputado para uma peça sobre X", "o que ele falou sobre X", "qual trecho serve para um reels sobre X".
+ - **Como preencher:** `tema` = só palavras de conteúdo (ex.: `hospital do subúrbio`); `intencao` = o que o usuário quer com a peça, nas palavras dele (ex.: "reels curto, tom emocionante"; "trecho de até 30 segundos para story"; "argumento completo para o debate") — nunca inventar intenção que o usuário não expressou (sem intenção, repita o tema).
+ - **Como responder:** 1–3 sugestões (default 3), cada uma com a citação entre aspas, "de `inicioLabel` a `fimLabel`", **a justificativa (`motivo`) em uma linha** e o link `[abrir no acervo no trecho](url)`; deixar explícito que a citação é da transcrição automática (ASR), aproximada e sujeita a ruído, e que a referência é o vídeo; não colar a fala/transcrição inteira (a íntegra fica no acervo).
+ - **Guardrails:** nunca inventar fala, minutagem, motivo ou link; `trechos: []` → dizer que não achou e sugerir refinar as palavras; quando `reordenadoPorIA: true` e `totalDiscursos > 0`, dizer que nenhum candidato atendeu bem à intenção e sugerir reformular; `error` → repassar o motivo (acesso negado / tema sem termos); se `totalDiscursos > 0` e `trechos: []` sem IA, explicar em uma linha que os termos aparecem em discursos, mas não juntos num trecho contínuo; se `reordenadoPorIA: false`, não mencionar IA (degradação silenciosa).
+ - **Acesso:** a ferramenta é restrita a quem lê o acervo; se negar, dizer que o acervo é restrito à comunicação e à coordenação/candidatura.
+- **Rejeitadas:** B (description é para tool-calling; formato de resposta não cabe) e C (o prompt é único no repo; a seção é o dono).
+
+### D8 — Testes previstos
+
+- **Opções:** A) unit do puro + unit do reranker + unit da tool (gate/shape/fallback com `stub`/`untouchablePayload`, padrão `pendingLeadershipsTool.unit.spec.ts`) + int do fluxo com Payload real (reranker mockado; `speechAcervo.int.spec.ts` como molde, `upsertSpeechBundle` + `speechBundleFixture` + `campaignFixtures`) | B) só int | C) unit + int + e2e novo.
+- **Recomendação: A** — o puro, o fail-closed e o fallback do reranker são baratos e determinísticos em unit; o int prova o fluxo real (busca normalizada, acesso dos 5 papéis, janela com offsets, dedup). **Sem e2e:** não há UI nova e o e2e de chat exercita o modelo (não determinístico); o comportamento está coberto no int. Discrição registrada.
+ - `tests/unit/speechExcerpt.unit.spec.ts`: `speechExcerptTerms` (acento/pontuação/`%`/`_`, <3 descartado, cap 6) e `buildSpeechExcerpt` (âncora + expansão, gap, teto 60, piso 15, variantes 20/45, discurso curto, sem cobertura → null, contiguidade).
+ - `tests/unit/rerankSpeechExcerpts.unit.spec.ts`: mocka `ai` (`generateObject`) e/ou `@ai-sdk/deepseek`; casos: saída válida → escolhidos com motivo; índice fora do range/duplicado/motivo vazio → `null`; throw → `null`; sem `DEEPSEEK_API_KEY` → `null` (curto-circuito, model nunca chamado).
+ - `tests/unit/speechExcerptsTool.unit.spec.ts`: gate com `untouchablePayload` para **leader E advisor** (`find` **e o reranker** nunca chamados) e passes para **coordinator/candidate/communicator** (o `AIToolContext` aceita o role; communicator prova a herança do C159 independente de `canUseCampaignAssistant`); sem chave/reranker `null` → ordem determinística + `reordenadoPorIA: false`; reranker mockado com sucesso → ordem/motivos + `reordenadoPorIA: true`, dedup pós-reranker (2 variantes do mesmo discurso → 1 + próxima vaga), `where` do `speech` (AND por termo, `sort -speechAt`, `limit 30`, `overrideAccess: false`); shape vazio (`trechos: []`, `criterio`, `totalDiscursos`); `{ error }` de tema sem termos.
+ - `tests/int/speechExcerpts.int.spec.ts`: com `vi.mock` do reranker (determinístico, sem rede); semeia 2–3 discursos com marcador único (`randomUUID`) via `upsertSpeechBundle`; coordinator e communicator leem; advisor e leader recebem `{ error: 'Leitura do acervo de falas negada.' }`; tema sem acento acha segmento com acento; janela cobre segmentos consecutivos (início/fim e citação com os dois textos) e URL `?t=&q=`; um trecho por discurso; ordenação por recência; tema inexistente → `trechos: []` (não erro); spy prova que o reranker recebe `intencao` e os candidatos do pool.
+ - Sem mexer em `codebaseConventions.unit.spec.ts`: a tool não filtra municípios (allowlist P3-D intocada).
+
+### D9 — Reflexão: reranking por LLM dentro da tool (decisão do gate 2026-09-14)
+
+- **Opções:** A) `rerankSpeechExcerpts` (`server-only`) chamado dentro do `execute` da tool: recebe a intenção + o pool de candidatos com citação/corte e devolve até 3 escolhas ordenadas com motivo (`generateObject` + zod, saída validada); qualquer falha → **fallback determinístico** (ordem por recência/cobertura, `reordenadoPorIA: false`) | B) `generateText` + parse manual de JSON (precedente `campaignDemandTitle`) | C) devolver o pool inteiro e deixar o modelo externo escolher na resposta (sem chamada aninhada) | D) reranker heurístico no servidor (sem LLM).
+- **Recomendação: A.**
+ - **Entrada:** `intencao` (palavras do usuário; fallback = tema), `tema`, `candidates` (índice, data, duração, `citacao` truncada em ~700 chars para caber no prompt), `limit`.
+ - **Saída:** `{ choices: [{ index, reason }] }`; validação zod: índices inteiros, sem repetição, dentro do pool, `1..limit`; reason trim não vazia ≤140 chars. Lista vazia do modelo = decisão ("nenhum serve"); só índices inválidos/motivos vazios → `null`.
+ - **Prompt do reranker (pt-BR):** "Você ajuda a escolher trechos de falas do deputado para peças de vídeo. Dada a intenção do usuário e os trechos candidatos (citação do ASR + corte proposto), escolha até N que melhor sirvam à intenção — adequação ao tema, completude do argumento, tom e duração — e justifique cada escolha em uma linha. Responda apenas com o JSON do schema." Instruído a **não inventar** e a **não escolher** trecho que não serve (pode devolver menos que N).
+ - **Determinismo/robustez:** nunca lança (padrão `deriveDemandTitle`): sem `DEEPSEEK_API_KEY`, timeout (`EXCERPT_RERANK_TIMEOUT_MS = 6000`), erro do provider ou saída inválida → `null` e a tool segue no fallback. A tool **nunca** depende do LLM aninhado para responder; a minutagem/citação continuam vindo só do ASR.
+ - **Modelo:** `deepSeek('deepseek-flash')` (mesmo do chat). O provider tem `supportsStructuredOutputs=false`; o tracer valida `generateObject` com `deepseek-flash` (modo auto/tool) e, se flaky, **contingência barata** é B (`generateText` + extração/validação do mesmo zod) — decisão de implementação, mesmo contrato.
+ - **Dedup pós-reranker:** 1 trecho final por discurso (D6), aplicado depois da escolha.
+- **Rejeitadas:** B (parse manual é mais frágil que o `generateObject` validado; só como contingência), C (deixa a reflexão para o modelo externo sem visão estruturada dos candidatos e sem contrato testável — foi a opção D da pergunta do gate), D (heurística não lê a intenção; era o plano pré-gate).
+
+## Fases verificáveis
+
+> Faseamento: **Opções:** A) tracer determinístico (tool sem IA + fallback) → reranker IA → int → prompt/gates | B) tudo num passo | C) começar pelo prompt. **Recomendação: A** — o risco está na janela/termos/gate (determinísticos) e depois no provider do reranker; o tracer entrega a tool funcionando com fallback antes de ligar a IA. **Rejeitadas:** B (mistura risco e acabamento num único gate) e C (prompt sem tool testada não tem o que ensinar).
+
+1. **Tracer determinístico (~0,5 dia):** exports em `speechViewModels.ts` + `src/lib/speechExcerpt.ts` + `findSpeechExcerpts.ts` (com fallback determinístico quando o reranker devolve `null`) + registro em `tools/index.ts` + `tests/unit/speechExcerpt.unit.spec.ts` e `tests/unit/speechExcerptsTool.unit.spec.ts`. Prova: `pnpm test:unit` verde (janela/variantes, termos, gate fail-closed com leader e advisor, passes de coordinator/candidate/communicator, where/shape, fallback).
+2. **Reranker IA (~0,25 dia):** `src/utilities/ai/rerankSpeechExcerpts.ts` + `tests/unit/rerankSpeechExcerpts.unit.spec.ts` + ligação no `execute` (ordem/motivos/dedup) + smoke manual com `deepseek-flash` (fora do CI) para confirmar o structured output. Prova: unit do reranker verde; smoke com 1 chamada real devolvendo escolhas válidas (ou contingência B registrada). **Execução (2026-09-14):** o smoke real não rodou — não há `DEEPSEEK_API_KEY` no ambiente local de desenvolvimento; o contrato ficou pinado por mock (unit) e a falha é fail-safe (fallback determinístico). Validar no primeiro uso em staging/produção e registrar no changelog se a contingência B for necessária.
+3. **Int (~0,25 dia):** `tests/int/speechExcerpts.int.spec.ts` com reranker mockado, no molde do C154 (fixtures + bundle). Prova: spec novo verde (Payload real, 5 papéis, janela/URL/dedup/recência/vazio) e nenhum outro int quebrado.
+4. **Prompt + gates (~0,25 dia):** seção no `systemPrompt.ts`; `pnpm gate:fast`; `pnpm test:int` (ou o spec novo por filtro durante a iteração); `pnpm knip`; `pnpm push`; changelog `docs/changelog/2026-09-14-c158.md`. Sem migration (`migrate:status` intocado).
+
+**Quota total:** ~1,25–1,5 dia (herdado ~1 dia + 0,25–0,5 do reranker, aceito no gate de 2026-09-14); mudanças de "contrato" são exportar 3 helpers puros do dono e uma chamada LLM aninhada na tool.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Busca semântica/embeddings, índice vetorial, sinônimos, stemming, `tsvector`/`unaccent`.** Corte: `like` sobre `searchText` normalizado + janela + reranker sobre os recuperados; sem resultado, "não achei".
+- **Reranker além do pool (segunda busca, query reescrita pela IA, iterative retrieval).** Corte: uma chamada, sobre até 12 candidatos, com fallback.
+- **Reranker como dependência dura** (bloquear/errar a resposta se a IA falhar). Corte: fallback determinístico sempre.
+- **Página de candidatos/offset, busca paginada no chat.** Corte: 30 discursos analisados + pool 12 + refinar o tema.
+- **Transcrição inteira / trechos longos / múltiplos trechos por discurso no chat.** Corte: 15–60s, 1 por discurso, variantes 20/45.
+- **Editar/cortar/gerar/baixar vídeo pelo chat; playlists/favoritos de trechos.** Corte: link para o acervo, onde já há assistir/baixar/abrir fonte.
+- **Destino `acervo` no `buildCampaignLinks` / mudança de `campaignNavigationUrls` / nav.** Corte: URL pronta na tool (D5).
+- **Expor o acervo fora de `/campanha`, Consent novo, migration/collection/field.** Corte: gate + access existentes.
+- **Stopword list/stemming de português, lista de sinônimos, re-segmentação ASR.** Corte: termos ≥3 chars; refinamento é do usuário/modelo.
+- **E2E de chat com o modelo real.** Corte: unit + int (reranker mockado); sem UI nova.
+
+## Riscos e mitigação
+
+- **Latência do reranker:** +1 chamada LLM (~1–4s) por pergunta que usa a tool — aceito no gate (peça em produção); timeout 6s → fallback determinístico; a tool nunca pendura a resposta no reranker.
+- **Custo:** pool ≤12 citações truncadas (~≤10k chars in) + saída curta por chamada; mesma ordem do chat, com rate limit já existente na rota; sem loop (uma chamada por execução da tool).
+- **Provider/structured output:** `@ai-sdk/deepseek` tem `supportsStructuredOutputs=false`; `generateObject` usa o modo auto (tool/JSON). Contingência B (`generateText` + JSON validado) se o smoke falhar; qualquer saída inválida → `null`/fallback.
+- **Reranker "inventar" índice/motivo:** índices validados contra o pool (fora do range/duplicado → descartado), motivo só textual sobre candidato real; a citação/minutagem nunca vêm do reranker — vêm da janela ASR (D2). Motivo não pode contradizer o trecho? Validação é estrutural; o prompt proíbe inventar e o modelo externo lê a citação — risco residual aceito e pinado em teste de shape.
+- **Termo curto (<3) e índice trgm:** `%term%` de 1–2 chars não usa índice e casa quase tudo — mitigado descartando <3 chars; tema só com palavras curtas devolve `{ error: 'Tema sem termos de busca: informe palavras com 3 letras ou mais.' }` e o prompt pede para refinar (nunca "não achei" falso).
+- **Wildcards `%`/`_` no `like`:** o sanitizador quebra em `[a-z0-9]+`, então caracteres de curinga não chegam ao SQL; unit test pina (`tema: "100% saúde_pública"` → `['100','saude','publica']`).
+- **Falso negativo por termos espalhados no discurso:** o candidato passa na query do `speech`, mas nenhum trecho contínuo cobre todos os termos → `trechos: []` com `totalDiscursos > 0`; critério e prompt explicitam. **Gatilho de revisão:** se a taxa de "candidato sem trecho" em uso real for alta, avaliar relaxar a cobertura para os termos casados dentro da janela (`termosCasados` parcial) — mudança de produto, não silenciosa.
+- **Ruído do ASR:** a citação pode sair truncada/trocada; o prompt declara que é aproximada e que o vídeo é a referência; `url`/`?t=` localiza no ponto exato.
+- **Modelo passar a pergunta inteira:** cap de 6 termos + descarte <3 + prompt; o pior caso é um AND mais estreito → vazio (fail-closed), nunca dado inventado.
+- **`DEEPSEEK_API_KEY` ausente em algum ambiente:** reranker curto-circuita (`null`), tool responde com a ordem determinística; comportamento documentado e testado (`reordenadoPorIA: false`).
+- **Performance:** `speech_search_text_trgm_idx` já existe; 30 discursos analisados + pool 12 + segmentos por `speech in`; `sort -speechAt` indexado. **Gatilho:** catálogo > ~5.000 discursos, p95 > 300ms ou termos de 3 chars dominando o plano → reavaliar (limite de candidatos/`tsvector`).
+- **Drift do `speech.searchText`:** só `upsertSpeechBundle` escreve speech e segmentos na mesma transação (C153/C154); a tool usa o campo só para recall de candidato e valida a janela nos segmentos — escrito fora do bundle é caminho não suportado (mesmo contrato do C154).
+- **Discurso sem segmentos ASR (`searchText: ''`):** nunca casa `like`; fica fora naturalmente, sem caso especial nem erro.
+- **Divergência gate vs access:** ambos usam `canReadSpeechCatalog`; a tool nega antes da query (e antes do reranker) e as leituras ainda passam pelo access da collection (`overrideAccess: false`), então não há caminho de dado parcial.
+- **Vazamento por erro/mensagem:** a negativa é genérica ("Leitura do acervo de falas negada.") e não revela existência; `trechos: []` não distingue "sem dado" de "sem acesso" (o gate nega antes). Citações enviadas ao reranker são do mesmo acervo interno e do mesmo provedor do chat.
+- **Dados nos testes:** int cria `speech`/`speechSegment` com `sourceKey`/marcadores únicos e limpa no `afterAll` (molde `speechAcervo.int.spec.ts`); usuários via `campaignFixtures` com ownership; reranker sempre mockado (sem rede).
+- **Pins que mudariam:** nenhum esperado (sem UI/nav/tool list pinada); se algum unit pinar o conjunto de tools, atualizar no tracer.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto: pergunta de tema devolve 1–3 trechos contínuos com citação aproximada do ASR + minutagem início/fim + link para o acervo no ponto (`?t=`); **cada sugestão com justificativa curta de adequação à intenção** (reranking por IA); sem resultado → "não achou"; quem não lê o acervo recebe negativa clara (fail-closed); sem inventar fala/minutagem/motivo; sem migration/collection/Consent; sem UI nova; busca textual (sem embeddings).
+- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user` em toda leitura; gate `canReadSpeechCatalog` (nunca `isStaffCampaignRole`/`canUseCampaignAssistant`); `leader` lockdown e advisor negado; reranker nunca lança e nunca é dependência dura; identificadores em inglês, chaves/copy em pt-BR; tool em `src/utilities/ai/tools/` (sem top-level novo); puro em `src/lib/`; sem migration/Consent; sem e2e.
+- [ ] Testes previstos: unit `speechExcerpt` + `rerankSpeechExcerpts` (validação/fallback/sem chave) + `speechExcerptsTool` (gate fail-closed com payload intocado e reranker não chamado, termos, janela/variantes, where/shape, fallback, seleção IA mockada, dedup, link) e int `speechExcerpts` (Payload real: 5 papéis, acento, janela, URL, dedup, vazio, spy do reranker); `pnpm gate:fast` e `pnpm knip` verdes; `pnpm push` na entrega.
+
+Self-score decision-quality: 5/5 — todas as decisões caras (busca, janela/variantes, gate, citação, link, limites, prompt, testes e **reranker**) têm opções e rejeitadas explícitas, o desenho reusa C153/C154 sem migration/UI, a decisão de produto do gate de 2026-09-14 (reflexão via reranking) está incorporada com fallback fail-safe e o outcome da intenção permanece intocado.
+
+- Decisões caras com rejeitadas: 5/5 — D1 (busca/fallback), D2 (janela/variantes), D3 (gate), D4 (citação), D5 (link), D6 (limites/pool/ranking), D7 (prompt), D8 (testes) e D9 (reranking) no formato Opções|Recomendação|Rejeitadas; as baratas (nomes/keys) ficam registradas em uma linha.
+- Cabe no appetite? 4/5 — ~1,25–1,5 dia (0,25–0,5 acima do herdado, aceito explicitamente no gate de 2026-09-14); sem migration, sem UI, sem dependência nova, com corte explícito de qualquer reranker além de uma chamada sobre o pool.
+- Rabbit holes nomeados? 5/5 — embeddings, reranker iterativo/segunda busca, dependência dura do reranker, paginação, transcrição no chat, edição, destino de nav, Consent/migration, stopwords/re-segmentação e e2e de chat.
+- Depth check reusa? 5/5 — `searchText` + índices trgm (C153/C154), `normalizeForSearch`, `buildWatchHref`/`formatSpeechAt`/`formatSpeechClock` (exportados do dono), factory `tool()`/`AIToolContext`, shape `criterio`/`truncado`/`dica`, **precedente `deriveDemandTitle` (B195) para a chamada LLM server-side com fallback**, `upsertSpeechBundle`/`speechBundleFixture`/`campaignFixtures`; nenhuma query layer nem view model paralelo.
+- Intenção permanece satisfeita? 5/5 — o outcome não foi reescrito: a ponte pergunta→trecho nasce com o gate do acervo (herdável pelo C159), o link aponta para o acervo interno no trecho (opção A), a faixa 1–3 é comportamento de resposta (opção B) e a reflexão pedida no gate foi incorporada como reranking na tool (D9), sem virar busca semântica.
diff --git a/docs/plans/sollinha-acervo-falas-trechos.md b/docs/plans/sollinha-acervo-falas-trechos.md
index 5a3a7dbe..59aa7f3c 100644
--- a/docs/plans/sollinha-acervo-falas-trechos.md
+++ b/docs/plans/sollinha-acervo-falas-trechos.md
@@ -25,6 +25,7 @@ O acervo de falas do deputado já está em produção (C153/C154/C155), mas acha
- A pergunta de exemplo ("Qual seria uma boa fala do deputado para criarmos um reels sobre o hospital do subúrbio?") devolve 1–3 sugestões, cada uma com citação aproximada do trecho e minutagem de começo e fim.
- Cada sugestão leva ao acervo no ponto do trecho (onde já há assistir/baixar/abrir a fonte); o chat não entrega arquivo nem edita.
- **Guardrails:** sem trecho encontrado, o Sollinha diz que não achou — nunca inventa fala ou minutagem; cada sugestão é um trecho contínuo, curto o bastante para virar peça; quem não lê o acervo recebe negativa clara (fail-closed), nunca dado parcial nem erro técnico; a citação vem do ASR e pode ter ruído — vale como localizador, a referência é o vídeo; sem migration/collection/Consent.
+- **Decisão do gate (2026-09-14):** o Sollinha **reflete** sobre qual trecho serve melhor à intenção declarada (tema, uso, tom, duração) e de que ponto a que ponto: a tool devolve candidatos com corte proposto e uma etapa de **reranking por LLM dentro da tool** escolhe/ordena os melhores com uma justificativa curta, apresentada na resposta. Sem embeddings/índice vetorial (a busca continua textual).
## Dados (intenção)
@@ -58,7 +59,7 @@ O acervo de falas do deputado já está em produção (C153/C154/C155), mas acha
## Rabbit holes de produto
-- **Busca semântica/embeddings.** Se alguém "só completar": índice vetorial, sinônimos, reranking, infra nova. **Corte neste item:** busca textual existente; sem resultado, o Sollinha diz que não achou.
+- **Busca semântica/embeddings.** Se alguém "só completar": índice vetorial, sinônimos, infra nova. **Corte neste item:** busca textual existente; sem resultado, o Sollinha diz que não achou. **Revisado no gate (2026-09-14):** o reranking por LLM **sobre os candidatos recuperados pela busca textual** foi incorporado a pedido do produto (ver decisão do gate no aceite); índice vetorial/embeddings seguem fora.
- **Ler a fala inteira no chat.** Se alguém "só completar": transcrição longa na resposta, contexto estourado, citação sem foco. **Corte neste item:** só trechos contínuos curtos com começo/fim; a íntegra fica no acervo.
- **Cortar/editar/gerar vídeo.** Se alguém "só completar": editor, fila de render, storage. **Corte neste item:** o chat sugere e aponta; edição é ferramenta externa.
- **Publicar o acervo fora de `/campanha`.** Se alguém "só completar": página pública, compartilhamento externo, direitos. **Corte neste item:** acervo segue interno.
diff --git a/src/lib/speechExcerpt.ts b/src/lib/speechExcerpt.ts
new file mode 100644
index 00000000..f978541d
--- /dev/null
+++ b/src/lib/speechExcerpt.ts
@@ -0,0 +1,157 @@
+import { normalizeForSearch } from '@/lib/speechSearch'
+
+/**
+ * C158 — continuous speech excerpt builder for the Sollinha suggestion tool.
+ * Pure: given the ASR segments of ONE speech and the sanitized search terms,
+ * pick the segment with the best term coverage and expand it into a
+ * contiguous window (consecutive segments only) that contains every term.
+ * The timestamps come straight from the ASR — a model never invents them.
+ */
+
+export type SpeechExcerptSegment = {
+ startSeconds: number
+ endSeconds: number
+ text: string
+}
+
+export type SpeechExcerpt = {
+ startSeconds: number
+ endSeconds: number
+ text: string
+ /** Every requested term is present in `text` (that is the acceptance rule). */
+ matchedTerms: string[]
+}
+
+const MIN_TERM_LENGTH = 3
+const MAX_TERMS = 6
+const EXCERPT_MIN_SECONDS = 15
+const EXCERPT_DEFAULT_TARGET_SECONDS = 30
+const EXCERPT_MAX_SECONDS = 60
+const EXCERPT_MAX_GAP_SECONDS = 5
+const EXCERPT_MAX_ANCHORS = 5
+/** Short/long variants offered to the C158 reranker ("de que ponto a que ponto"). */
+export const EXCERPT_VARIANT_TARGETS = [20, 45] as const
+
+/**
+ * Search terms of a spoken theme: accent-folded, split on anything that is not
+ * a letter or digit (so `%`/`_` never reach the SQL `LIKE`), deduped, short
+ * terms dropped (the trigram index needs 3+ chars) and capped.
+ */
+export const speechExcerptTerms = (query: string): string[] => {
+ const terms: string[] = []
+ for (const term of normalizeForSearch(query).split(/[^a-z0-9]+/)) {
+ if (term.length < MIN_TERM_LENGTH || terms.includes(term)) continue
+ terms.push(term)
+ if (terms.length >= MAX_TERMS) break
+ }
+ return terms
+}
+
+const windowDuration = (
+ segments: readonly SpeechExcerptSegment[],
+ from: number,
+ to: number,
+): number => segments[to]!.endSeconds - segments[from]!.startSeconds
+
+const canExtendForward = (
+ segments: readonly SpeechExcerptSegment[],
+ from: number,
+ to: number,
+): boolean => {
+ const next = segments[to + 1]
+ if (!next) return false
+ if (next.startSeconds - segments[to]!.endSeconds > EXCERPT_MAX_GAP_SECONDS) return false
+ // A single segment longer than the cap is returned whole (never cut mid-segment).
+ return windowDuration(segments, from, to + 1) <= EXCERPT_MAX_SECONDS
+}
+
+const canExtendBackward = (
+ segments: readonly SpeechExcerptSegment[],
+ from: number,
+ to: number,
+): boolean => {
+ const previous = segments[from - 1]
+ if (!previous) return false
+ if (segments[from]!.startSeconds - previous.endSeconds > EXCERPT_MAX_GAP_SECONDS) return false
+ return windowDuration(segments, from - 1, to) <= EXCERPT_MAX_SECONDS
+}
+
+const windowText = (segments: readonly SpeechExcerptSegment[], from: number, to: number): string =>
+ segments
+ .slice(from, to + 1)
+ .map((segment) => segment.text.trim())
+ .filter((part) => part.length > 0)
+ .join(' ')
+
+const windowCovers = (text: string, terms: readonly string[]): boolean => {
+ const normalized = normalizeForSearch(text)
+ return terms.every((term) => normalized.includes(term))
+}
+
+const expandWindow = (
+ segments: readonly SpeechExcerptSegment[],
+ anchorIndex: number,
+ terms: readonly string[],
+ targetSeconds: number,
+): { from: number; to: number } => {
+ let from = anchorIndex
+ let to = anchorIndex
+
+ // Coverage first, target as the comfort size: keep growing while a term is
+ // missing or the window is still shorter than the target.
+ while (
+ canExtendForward(segments, from, to) &&
+ (!windowCovers(windowText(segments, from, to), terms) ||
+ windowDuration(segments, from, to) < targetSeconds)
+ ) {
+ to += 1
+ }
+ while (
+ canExtendBackward(segments, from, to) &&
+ (!windowCovers(windowText(segments, from, to), terms) ||
+ windowDuration(segments, from, to) < EXCERPT_MIN_SECONDS)
+ ) {
+ from -= 1
+ }
+
+ return { from, to }
+}
+
+/**
+ * Best contiguous excerpt for the terms, or `null` when no window of up to
+ * `EXCERPT_MAX_SECONDS` (single long segment included) carries every term.
+ * `targetSeconds` sizes the window: the short/long variants are two calls.
+ */
+export const buildSpeechExcerpt = (
+ segments: readonly SpeechExcerptSegment[],
+ terms: readonly string[],
+ { targetSeconds = EXCERPT_DEFAULT_TARGET_SECONDS }: { targetSeconds?: number } = {},
+): SpeechExcerpt | null => {
+ if (segments.length === 0 || terms.length === 0) return null
+
+ const normalizedSegments = segments.map((segment) => normalizeForSearch(segment.text))
+
+ const anchors = segments
+ .map((_, index) => ({
+ index,
+ coverage: terms.filter((term) => normalizedSegments[index]!.includes(term)).length,
+ }))
+ .filter(({ coverage }) => coverage > 0)
+ .sort((left, right) => right.coverage - left.coverage || left.index - right.index)
+ .slice(0, EXCERPT_MAX_ANCHORS)
+
+ for (const { index } of anchors) {
+ const { from, to } = expandWindow(segments, index, terms, targetSeconds)
+ const text = windowText(segments, from, to)
+ if (!windowCovers(text, terms)) continue
+
+ return {
+ startSeconds: segments[from]!.startSeconds,
+ endSeconds: segments[to]!.endSeconds,
+ text,
+ matchedTerms: [...terms],
+ }
+ }
+
+ return null
+}
diff --git a/src/utilities/ai/rerankSpeechExcerpts.ts b/src/utilities/ai/rerankSpeechExcerpts.ts
new file mode 100644
index 00000000..e0d31d67
--- /dev/null
+++ b/src/utilities/ai/rerankSpeechExcerpts.ts
@@ -0,0 +1,111 @@
+import 'server-only'
+
+import { deepSeek } from '@ai-sdk/deepseek'
+import { generateObject } from 'ai'
+import { z } from 'zod'
+
+const RERANK_TIMEOUT_MS = 6000
+const MAX_REASON_LENGTH = 140
+const MAX_CITATION_CHARS = 700
+const MAX_OUTPUT_TOKENS = 400
+
+export type SpeechExcerptRerankCandidate = {
+ /** Pool index the tool maps back to the excerpt. */
+ index: number
+ /** Human date label (`formatSpeechAt`). */
+ dateLabel: string
+ durationSeconds: number
+ /** Raw ASR citation, truncated for the prompt. */
+ citation: string
+}
+
+export type SpeechExcerptRerankChoice = {
+ index: number
+ /** One-line reason the excerpt fits the stated intention. */
+ reason: string
+}
+
+const rerankSchema = z.object({
+ choices: z
+ .array(
+ z.object({
+ index: z.number().int(),
+ reason: z.string(),
+ }),
+ )
+ .describe(
+ 'Trechos escolhidos, do melhor para o menos adequado à intenção (lista vazia quando nenhum candidato serve).',
+ ),
+})
+
+const RERANK_SYSTEM_PROMPT =
+ 'Você ajuda a escolher trechos de falas do deputado Jorge Solla para peças de vídeo. ' +
+ 'Receberá a intenção de quem pede e uma lista de trechos candidatos extraídos do acervo, ' +
+ 'cada um com índice, data, duração e a citação da transcrição automática (ASR). ' +
+ 'Escolha os trechos que melhor sirvam à intenção — adequação ao tema, completude do argumento, ' +
+ 'tom e duração — e justifique cada escolha com UMA linha curta e concreta, citando o que no ' +
+ 'trecho atende ao pedido. Se nenhum candidato servir, devolva a lista vazia. ' +
+ 'Nunca invente falas, índices, minutagens ou motivos; a citação pode ter ruído de transcrição.'
+
+/**
+ * C158 — LLM reranking of the deterministic excerpt pool. Sends the user's
+ * intention plus the candidates to DeepSeek and returns an ordered selection
+ * with one-line reasons. Never throws and never blocks the tool: missing key,
+ * timeout, provider error or malformed output all resolve to `null`, and the
+ * caller owns the deterministic fallback. An empty selection is a legitimate
+ * answer ("none serves the intention") and resolves to `{ choices: [] }`.
+ * Timestamps/citations never come from here — they stay with the ASR window.
+ */
+export const rerankSpeechExcerpts = async ({
+ intencao,
+ tema,
+ candidates,
+ limit,
+}: {
+ intencao: string
+ tema: string
+ candidates: readonly SpeechExcerptRerankCandidate[]
+ limit: number
+}): Promise<{ choices: SpeechExcerptRerankChoice[] } | null> => {
+ if (!process.env.DEEPSEEK_API_KEY || candidates.length === 0) return null
+
+ const validIndices = new Set(candidates.map((candidate) => candidate.index))
+
+ try {
+ const { object } = await generateObject({
+ model: deepSeek('deepseek-flash'),
+ schema: rerankSchema,
+ system: RERANK_SYSTEM_PROMPT,
+ prompt: [
+ `Intenção: ${intencao}`,
+ `Tema: ${tema}`,
+ `Escolha no máximo ${limit} trecho(s).`,
+ '',
+ 'Trechos candidatos:',
+ ...candidates.map(
+ (candidate) =>
+ `[${candidate.index}] ${candidate.dateLabel} · ${candidate.durationSeconds}s · "${candidate.citation.slice(0, MAX_CITATION_CHARS)}"`,
+ ),
+ ].join('\n'),
+ temperature: 0.2,
+ maxOutputTokens: MAX_OUTPUT_TOKENS,
+ abortSignal: AbortSignal.timeout(RERANK_TIMEOUT_MS),
+ })
+
+ if (object.choices.length === 0) return { choices: [] }
+
+ const seen = new Set()
+ const choices: SpeechExcerptRerankChoice[] = []
+ for (const choice of object.choices) {
+ const reason = choice.reason.trim().slice(0, MAX_REASON_LENGTH)
+ if (!validIndices.has(choice.index) || seen.has(choice.index) || reason === '') continue
+ seen.add(choice.index)
+ choices.push({ index: choice.index, reason })
+ if (choices.length >= limit) break
+ }
+
+ return choices.length > 0 ? { choices } : null
+ } catch {
+ return null
+ }
+}
diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts
index 037330e0..69452914 100644
--- a/src/utilities/ai/systemPrompt.ts
+++ b/src/utilities/ai/systemPrompt.ts
@@ -14,6 +14,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
- 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.
## Regras
- SEMPRE use as ferramentas disponíveis para buscar dados. Nunca responda de memória.
@@ -64,6 +65,15 @@ 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
+- 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.
+- A citação vem da transcrição automática (ASR), é aproximada e pode ter ruído — deixe isso claro de forma curta; a referência é o vídeo, e o link abre o acervo no ponto do trecho.
+- NUNCA invente fala, minutagem, motivo ou link. Se "trechos" vier vazio, diga que não achou e sugira refinar as palavras; quando "reordenadoPorIA" for true e "totalDiscursos" for maior que zero, diga que nenhum trecho candidato atendeu bem à intenção e sugira reformular o pedido. Se vier "error", explique o motivo. Se "totalDiscursos" for maior que zero com "trechos" vazio sem IA, explique em uma linha que os termos aparecem em discursos, mas não juntos num trecho contínuo curto.
+- 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.
diff --git a/src/utilities/ai/tools/findSpeechExcerpts.ts b/src/utilities/ai/tools/findSpeechExcerpts.ts
new file mode 100644
index 00000000..25655eca
--- /dev/null
+++ b/src/utilities/ai/tools/findSpeechExcerpts.ts
@@ -0,0 +1,271 @@
+import { tool } from 'ai'
+import { z } from 'zod'
+
+import type { AIToolContext } from '@/lib/ai/types'
+import { canReadSpeechCatalog } from '@/lib/campaignRoles'
+import {
+ buildSpeechExcerpt,
+ EXCERPT_VARIANT_TARGETS,
+ speechExcerptTerms,
+ type SpeechExcerpt,
+ type SpeechExcerptSegment,
+} from '@/lib/speechExcerpt'
+import type { Speech } from '@/payload-types'
+import {
+ rerankSpeechExcerpts,
+ type SpeechExcerptRerankChoice,
+} from '@/utilities/ai/rerankSpeechExcerpts'
+import { loadSegmentsForSpeeches } from '@/utilities/speech/speechPageData'
+import {
+ buildWatchHref,
+ formatSpeechAt,
+ formatSpeechClock,
+} from '@/utilities/speech/speechViewModels'
+
+const DENIED_MESSAGE = 'Leitura do acervo de falas negada.'
+const NO_TERMS_MESSAGE = 'Tema sem termos de busca: informe palavras com 3 caracteres ou mais.'
+const EXCERPT_CRITERION =
+ 'Trecho contínuo montado sobre os segmentos transcritos (ASR) que contém todos os termos do tema — janela usual de 15 a 60 segundos (segmento único maior é mantido inteiro); a citação é aproximada e pode ter ruído de transcrição.'
+const TRUNCATED_HINT =
+ 'Há mais discursos candidatos além dos analisados; refine o tema para chegar em outros.'
+
+const SPEECH_CANDIDATE_LIMIT = 30
+const EXCERPT_POOL_SPEECHES = 6
+
+type SpeechCandidate = Pick
+
+type PoolCandidate = {
+ indice: number
+ speechId: number
+ speechAt: string
+ type: string | null
+ excerpt: SpeechExcerpt
+}
+
+type SelectedCandidate = {
+ candidate: PoolCandidate
+ motivo?: string
+}
+
+/**
+ * C158 — suggests continuous speech excerpts from the internal acervo for a
+ * video piece. The deterministic part owns recall: term AND over
+ * `speech.searchText` (trigram), recency ordering and the contiguous ASR
+ * window (short/long variants). The LLM reranker (D9) only reorders/chooses
+ * among those candidates and writes one-line reasons — it never invents a
+ * quote or a timestamp, and never blocks the answer: no key, timeout or
+ * invalid output fall back to the deterministic order.
+ */
+export const findSpeechExcerpts = (ctx: AIToolContext) =>
+ tool({
+ description:
+ 'Finds short continuous excerpts of Jorge Solla speeches from the internal communication acervo that match a theme, for video pieces. ' +
+ 'Use when the user asks for a good speech excerpt/quote for a video about X ' +
+ '(e.g. "Qual seria uma boa fala do deputado para criarmos um reels sobre o hospital do subúrbio?"). ' +
+ 'Pass the theme keywords in "tema" and the user intention in "intencao" (usage, tone, desired duration). ' +
+ 'Each suggestion carries the ASR quote, start/end timecodes, a reason when AI-ranked, and a link to the acervo at the excerpt point.',
+ inputSchema: z.object({
+ tema: z
+ .string()
+ .trim()
+ .min(1)
+ .describe(
+ 'Palavras de conteúdo do tema (ex.: "hospital do subúrbio", "farmácia popular"). Nunca a pergunta inteira nem palavras que o usuário não disse.',
+ ),
+ intencao: z
+ .string()
+ .trim()
+ .min(1)
+ .optional()
+ .describe(
+ 'O que o usuário quer com a peça, nas palavras dele: uso (reels, story, debate), tom e duração desejada. Omita só quando o pedido não expressar nada além do tema.',
+ ),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(5)
+ .optional()
+ .default(3)
+ .describe('Máximo de trechos sugeridos (default 3, max 5).'),
+ }),
+ execute: async ({ tema, intencao, limit }) => {
+ if (!canReadSpeechCatalog(ctx.user.role)) return { error: DENIED_MESSAGE }
+
+ const terms = speechExcerptTerms(tema)
+ const consulta = { tema: tema.trim(), termos: terms, intencao: intencao?.trim() ?? null }
+ if (terms.length === 0) return { error: NO_TERMS_MESSAGE }
+
+ const speeches = await ctx.payload.find({
+ collection: 'speech',
+ where: { and: terms.map((term) => ({ searchText: { like: term } })) },
+ depth: 0,
+ limit: SPEECH_CANDIDATE_LIMIT,
+ page: 1,
+ sort: '-speechAt',
+ select: { speechAt: true, type: true },
+ overrideAccess: false,
+ user: ctx.user,
+ })
+
+ const docs = speeches.docs
+ const truncado = speeches.totalDocs > docs.length
+
+ if (docs.length === 0) {
+ return {
+ consulta,
+ criterio: EXCERPT_CRITERION,
+ totalDiscursos: 0,
+ reordenadoPorIA: false,
+ trechos: [],
+ truncado: false,
+ }
+ }
+
+ const segmentsBySpeech = await loadSegmentsForSpeeches(
+ ctx.payload,
+ ctx.user,
+ docs.map((doc) => doc.id),
+ )
+ const pool = buildExcerptPool(docs, segmentsBySpeech, terms)
+
+ if (pool.length === 0) {
+ return {
+ consulta,
+ criterio: EXCERPT_CRITERION,
+ totalDiscursos: docs.length,
+ reordenadoPorIA: false,
+ trechos: [],
+ truncado,
+ ...(truncado ? { dica: TRUNCATED_HINT } : {}),
+ }
+ }
+
+ const reranked = await rerankSpeechExcerpts({
+ intencao: consulta.intencao ?? consulta.tema,
+ tema: consulta.tema,
+ candidates: pool.map((candidate) => ({
+ index: candidate.indice,
+ dateLabel: formatSpeechAt(candidate.speechAt),
+ durationSeconds: Math.round(
+ candidate.excerpt.endSeconds - candidate.excerpt.startSeconds,
+ ),
+ citation: candidate.excerpt.text,
+ })),
+ limit,
+ })
+
+ const selected = reranked
+ ? selectRerankedExcerpts(pool, reranked.choices, limit)
+ : selectDeterministicExcerpts(pool, limit)
+
+ return {
+ consulta,
+ criterio: EXCERPT_CRITERION,
+ totalDiscursos: docs.length,
+ reordenadoPorIA: reranked !== null,
+ trechos: selected.map(toExcerptViewModel),
+ truncado,
+ ...(truncado ? { dica: TRUNCATED_HINT } : {}),
+ }
+ },
+ })
+
+/** Short/long windows of the most recent matching speeches, capped for the reranker prompt. */
+const buildExcerptPool = (
+ docs: readonly SpeechCandidate[],
+ segmentsBySpeech: ReadonlyMap,
+ terms: readonly string[],
+): PoolCandidate[] => {
+ const pool: PoolCandidate[] = []
+ let contributedSpeeches = 0
+
+ for (const doc of docs) {
+ if (contributedSpeeches >= EXCERPT_POOL_SPEECHES) break
+ if (!doc.speechAt) continue
+
+ const segments = segmentsBySpeech.get(doc.id) ?? []
+ const seenWindows = new Set()
+ let contributed = false
+
+ for (const targetSeconds of EXCERPT_VARIANT_TARGETS) {
+ const excerpt = buildSpeechExcerpt(segments, terms, { targetSeconds })
+ if (!excerpt) continue
+ const windowKey = `${excerpt.startSeconds}-${excerpt.endSeconds}`
+ if (seenWindows.has(windowKey)) continue
+ seenWindows.add(windowKey)
+ pool.push({
+ indice: pool.length,
+ speechId: doc.id,
+ speechAt: doc.speechAt,
+ type: doc.type ?? null,
+ excerpt,
+ })
+ contributed = true
+ }
+
+ if (contributed) contributedSpeeches += 1
+ }
+
+ return pool
+}
+
+/** Fallback order: one excerpt per speech, recency first, the longer variant when both fit. */
+const selectDeterministicExcerpts = (
+ pool: readonly PoolCandidate[],
+ limit: number,
+): SelectedCandidate[] => {
+ const bySpeech = new Map()
+ for (const candidate of pool) {
+ const current = bySpeech.get(candidate.speechId)
+ if (!current) {
+ bySpeech.set(candidate.speechId, candidate)
+ continue
+ }
+ const currentDuration = current.excerpt.endSeconds - current.excerpt.startSeconds
+ const candidateDuration = candidate.excerpt.endSeconds - candidate.excerpt.startSeconds
+ if (candidateDuration > currentDuration) bySpeech.set(candidate.speechId, candidate)
+ }
+
+ return [...bySpeech.values()].slice(0, limit).map((candidate) => ({ candidate }))
+}
+
+/** Reranker result: its order, its reasons, still one excerpt per speech. */
+const selectRerankedExcerpts = (
+ pool: readonly PoolCandidate[],
+ choices: readonly SpeechExcerptRerankChoice[],
+ limit: number,
+): SelectedCandidate[] => {
+ const byIndex = new Map(pool.map((candidate) => [candidate.indice, candidate]))
+ const usedSpeeches = new Set()
+ const selected: SelectedCandidate[] = []
+
+ for (const choice of choices) {
+ const candidate = byIndex.get(choice.index)
+ if (!candidate || usedSpeeches.has(candidate.speechId)) continue
+ usedSpeeches.add(candidate.speechId)
+ selected.push({ candidate, motivo: choice.reason })
+ if (selected.length >= limit) break
+ }
+
+ return selected
+}
+
+const toExcerptViewModel = ({ candidate, motivo }: SelectedCandidate) => ({
+ discursoId: candidate.speechId,
+ data: formatSpeechAt(candidate.speechAt),
+ tipo: candidate.type,
+ inicioSegundos: Math.floor(candidate.excerpt.startSeconds),
+ fimSegundos: Math.ceil(candidate.excerpt.endSeconds),
+ inicioLabel: formatSpeechClock(candidate.excerpt.startSeconds),
+ fimLabel: formatSpeechClock(candidate.excerpt.endSeconds),
+ duracaoSegundos: Math.round(candidate.excerpt.endSeconds - candidate.excerpt.startSeconds),
+ citacao: candidate.excerpt.text,
+ termosCasados: candidate.excerpt.matchedTerms,
+ ...(motivo ? { motivo } : {}),
+ url: buildWatchHref(
+ candidate.speechId,
+ candidate.excerpt,
+ candidate.excerpt.matchedTerms.join(' '),
+ ),
+})
diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts
index 2d46405a..c0393840 100644
--- a/src/utilities/ai/tools/index.ts
+++ b/src/utilities/ai/tools/index.ts
@@ -9,6 +9,7 @@ import type { AIToolContext } from '@/lib/ai/types'
import { buildCampaignLinks } from '@/utilities/ai/tools/buildCampaignLinks'
import { calculate } from '@/utilities/ai/tools/calculate'
+import { findSpeechExcerpts } from '@/utilities/ai/tools/findSpeechExcerpts'
import { getDobradinhas } from '@/utilities/ai/tools/getDobradinhas'
import { getLeaderships } from '@/utilities/ai/tools/getLeaderships'
import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
@@ -37,4 +38,5 @@ export const buildAITools = (ctx: AIToolContext) => ({
getOrganizations: getOrganizations(ctx),
getPartnershipCoverage: getPartnershipCoverage(ctx),
searchEntities: searchEntities(ctx),
+ findSpeechExcerpts: findSpeechExcerpts(ctx),
})
diff --git a/src/utilities/speech/speechPageData.ts b/src/utilities/speech/speechPageData.ts
index 4c3cc63f..6b0e8599 100644
--- a/src/utilities/speech/speechPageData.ts
+++ b/src/utilities/speech/speechPageData.ts
@@ -103,7 +103,7 @@ const loadSpeechFilterOptions = async (
}
}
-const loadSegmentsForSpeeches = async (
+export const loadSegmentsForSpeeches = async (
payload: Payload,
user: CampaignUser,
speechIds: readonly number[],
diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts
index ddc2988f..baf05269 100644
--- a/src/utilities/speech/speechViewModels.ts
+++ b/src/utilities/speech/speechViewModels.ts
@@ -4,6 +4,7 @@
* list/detail render.
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
+import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import {
buildHighlightedExcerpt,
@@ -17,11 +18,8 @@ import { normalizeForSearch } from '@/lib/speechSearch'
import type { Municipality } from '@/payload-types'
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
-export type SpeechSegmentRecord = {
- startSeconds: number
- endSeconds: number
- text: string
-}
+/** ASR segment shape, owned by the C158 excerpt builder (`lib/speechExcerpt`). */
+export type SpeechSegmentRecord = SpeechExcerptSegment
export type SpeechListRecord = {
id: number
@@ -92,7 +90,7 @@ const pad = (value: number): string => String(value).padStart(2, '0')
* `speechAt` is the Câmara wall-clock string ("2026-08-11T18:48", no timezone);
* slicing it keeps the local reading and avoids a `Date` shifting it.
*/
-const formatSpeechAt = (speechAt: string): string => {
+export const formatSpeechAt = (speechAt: string): string => {
const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/.exec(speechAt)
if (!match) return speechAt
const [, year, month, day, hour, minute] = match
@@ -113,7 +111,7 @@ const formatSpeechDuration = (seconds: number | null | undefined): string | null
}
/** Transcript timestamp ("01:12" / "1:02:03"). */
-const formatSpeechClock = (seconds: number): string => {
+export const formatSpeechClock = (seconds: number): string => {
const total = Math.max(0, Math.round(seconds))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
@@ -162,7 +160,7 @@ const pickMatchingSegment = (
})
}
-const buildWatchHref = (
+export const buildWatchHref = (
speechId: number,
segment: SpeechSegmentRecord | undefined,
query: string | undefined,
diff --git a/tests/int/speechExcerpts.int.spec.ts b/tests/int/speechExcerpts.int.spec.ts
new file mode 100644
index 00000000..a7ca9896
--- /dev/null
+++ b/tests/int/speechExcerpts.int.spec.ts
@@ -0,0 +1,221 @@
+// @vitest-environment node
+
+import { randomUUID } from 'node:crypto'
+
+import type { Payload } from 'payload'
+import { getPayload } from 'payload'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { rerankMock } = vi.hoisted(() => ({ rerankMock: vi.fn() }))
+
+vi.mock('@/utilities/ai/rerankSpeechExcerpts', () => ({ rerankSpeechExcerpts: rerankMock }))
+
+import type { AIToolContext } from '@/lib/ai/types'
+import type { CampaignUser } from '@/payload-types'
+import config from '@/payload.config'
+import { findSpeechExcerpts } from '@/utilities/ai/tools/findSpeechExcerpts'
+import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
+
+import { installCampaignFixtures } from '../helpers/campaignFixtures'
+import { speechBundleFixture } from '../helpers/speechBundleFixture'
+
+let payload: Payload
+const createdSourceKeys = new Set()
+const campaignFixtures = installCampaignFixtures({
+ getPayload: () => payload,
+ setPayload: (nextPayload) => {
+ payload = nextPayload
+ },
+})
+
+type ExecutableTool = {
+ execute: (args: unknown, options?: unknown) => Promise
+}
+
+const executeTool = (user: CampaignUser, args: unknown): Promise> =>
+ (findSpeechExcerpts({ user, payload } as AIToolContext) as unknown as ExecutableTool).execute(
+ args,
+ ) as Promise>
+
+const createSpeech = async (overrides: Partial = {}): Promise => {
+ const bundle = speechBundleFixture(overrides)
+ createdSourceKeys.add(bundle.sourceKey)
+ await upsertSpeechBundle(payload, bundle)
+ const found = await payload.find({
+ collection: 'speech',
+ where: { sourceKey: { equals: bundle.sourceKey } },
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ })
+ const speech = found.docs[0]
+ if (!speech) throw new Error('fixture speech was not created')
+ return speech.id
+}
+
+const createUsers = async () => {
+ const fixtures = campaignFixtures()
+ const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
+ fixtures.createCampaignUser('communicator'),
+ fixtures.createCampaignUser('coordinator'),
+ fixtures.createCampaignUser('candidate'),
+ fixtures.createCampaignUser('advisor'),
+ fixtures.createCampaignUser('leader'),
+ ])
+ return { communicator, coordinator, candidate, advisor, leader }
+}
+
+beforeEach(() => {
+ rerankMock.mockReset()
+ rerankMock.mockResolvedValue(null)
+})
+
+describe('findSpeechExcerpts (C158)', () => {
+ beforeAll(async () => {
+ payload = await getPayload({ config: await config })
+ })
+
+ afterAll(async () => {
+ for (const sourceKey of createdSourceKeys) {
+ const found = await payload.find({
+ collection: 'speech',
+ where: { sourceKey: { equals: sourceKey } },
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ })
+ const speech = found.docs[0]
+ if (speech) {
+ await payload.delete({
+ collection: 'speech',
+ id: speech.id,
+ depth: 0,
+ overrideAccess: true,
+ })
+ }
+ }
+ })
+
+ it('devolve trecho contínuo com citação, corte e link no ponto (tema sem acento, ASR com acento)', async () => {
+ const marker = `zebra${randomUUID().slice(0, 8)}`
+ const speechId = await createSpeech({
+ speechAt: '2026-03-10T10:00',
+ type: 'DISCURSO',
+ segments: [
+ { startSeconds: 0, endSeconds: 4, text: `O hospital do subúrbio ${marker}` },
+ { startSeconds: 4, endSeconds: 8, text: `precisa de investimento ${marker}` },
+ ],
+ })
+ const { coordinator } = await createUsers()
+
+ const result = await executeTool(coordinator, { tema: `hospital do suburbio ${marker}` })
+
+ expect(result).toMatchObject({
+ reordenadoPorIA: false,
+ totalDiscursos: 1,
+ trechos: [
+ {
+ discursoId: speechId,
+ data: '10/03/2026 · 10:00',
+ tipo: 'DISCURSO',
+ inicioSegundos: 0,
+ fimSegundos: 8,
+ inicioLabel: '00:00',
+ fimLabel: '00:08',
+ duracaoSegundos: 8,
+ citacao: `O hospital do subúrbio ${marker} precisa de investimento ${marker}`,
+ termosCasados: ['hospital', 'suburbio', marker],
+ url: `/campanha/comunicacao/acervo/${speechId}?t=0&q=hospital+suburbio+${marker}`,
+ },
+ ],
+ })
+ expect((result as { criterio: string }).criterio).toContain('ASR')
+ })
+
+ it('nega advisor e leader (fail-closed) e libera communicator, coordinator e candidate', async () => {
+ const marker = `zebra${randomUUID().slice(0, 8)}`
+ await createSpeech({
+ speechAt: '2026-03-11T10:00',
+ segments: [{ startSeconds: 0, endSeconds: 5, text: `hospital do subúrbio ${marker}` }],
+ })
+ const users = await createUsers()
+
+ for (const actor of [users.advisor, users.leader]) {
+ await expect(executeTool(actor, { tema: `hospital ${marker}` })).resolves.toEqual({
+ error: 'Leitura do acervo de falas negada.',
+ })
+ }
+
+ for (const actor of [users.communicator, users.coordinator, users.candidate]) {
+ const result = await executeTool(actor, { tema: `hospital ${marker}` })
+ expect((result.trechos as unknown[]).length).toBeGreaterThan(0)
+ }
+ })
+
+ it('lista no máximo um trecho por discurso, do mais recente para o mais antigo', async () => {
+ const marker = `zebra${randomUUID().slice(0, 8)}`
+ const older = await createSpeech({
+ speechAt: '2026-01-05T10:00',
+ segments: [{ startSeconds: 0, endSeconds: 6, text: `hospital do subúrbio ${marker} antigo` }],
+ })
+ const newer = await createSpeech({
+ speechAt: '2026-02-05T10:00',
+ segments: [{ startSeconds: 0, endSeconds: 6, text: `hospital do subúrbio ${marker} novo` }],
+ })
+ const { coordinator } = await createUsers()
+
+ const result = await executeTool(coordinator, { tema: `hospital do subúrbio ${marker}` })
+ const trechos = result.trechos as Array<{ discursoId: number }>
+
+ expect(trechos.map((trecho) => trecho.discursoId)).toEqual([newer, older])
+ })
+
+ it('tema sem resultado devolve lista vazia (nunca erro técnico)', async () => {
+ const marker = `zebra${randomUUID().slice(0, 8)}`
+ await createSpeech({
+ speechAt: '2026-03-12T10:00',
+ segments: [{ startSeconds: 0, endSeconds: 5, text: `hospital do subúrbio ${marker}` }],
+ })
+ const { coordinator } = await createUsers()
+
+ const result = await executeTool(coordinator, {
+ tema: `inexistente ${marker}`,
+ })
+
+ expect(result).toMatchObject({ totalDiscursos: 0, trechos: [], reordenadoPorIA: false })
+ })
+
+ it('usa a escolha e o motivo do reranker quando há IA, com a intenção declarada', async () => {
+ const marker = `zebra${randomUUID().slice(0, 8)}`
+ await createSpeech({
+ speechAt: '2026-03-13T10:00',
+ segments: [
+ { startSeconds: 0, endSeconds: 10, text: `hospital do subúrbio ${marker}` },
+ { startSeconds: 10, endSeconds: 20, text: `e mais ${marker}` },
+ { startSeconds: 20, endSeconds: 30, text: `e mais ainda ${marker}` },
+ ],
+ })
+ rerankMock.mockResolvedValue({
+ choices: [{ index: 0, reason: 'Cita o hospital do subúrbio e fecha a ideia' }],
+ })
+ const { coordinator } = await createUsers()
+
+ const result = await executeTool(coordinator, {
+ tema: `hospital do subúrbio ${marker}`,
+ intencao: 'reels curto sobre o hospital do subúrbio',
+ })
+
+ expect(result.reordenadoPorIA).toBe(true)
+ expect(result.trechos).toEqual([
+ expect.objectContaining({
+ motivo: 'Cita o hospital do subúrbio e fecha a ideia',
+ }),
+ ])
+ expect(rerankMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ intencao: 'reels curto sobre o hospital do subúrbio',
+ tema: `hospital do subúrbio ${marker}`,
+ }),
+ )
+ })
+})
diff --git a/tests/unit/rerankSpeechExcerpts.unit.spec.ts b/tests/unit/rerankSpeechExcerpts.unit.spec.ts
new file mode 100644
index 00000000..566b83aa
--- /dev/null
+++ b/tests/unit/rerankSpeechExcerpts.unit.spec.ts
@@ -0,0 +1,129 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { generateObjectMock, deepSeekMock } = vi.hoisted(() => ({
+ generateObjectMock: vi.fn(),
+ deepSeekMock: vi.fn(() => 'mock-model'),
+}))
+
+vi.mock('ai', () => ({ generateObject: generateObjectMock }))
+vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: deepSeekMock }))
+
+import {
+ rerankSpeechExcerpts,
+ type SpeechExcerptRerankCandidate,
+} from '@/utilities/ai/rerankSpeechExcerpts'
+
+const candidate = (index: number): SpeechExcerptRerankCandidate => ({
+ index,
+ dateLabel: '07/02/2023 · 17:28',
+ durationSeconds: 20,
+ citation: 'A saúde pública baiana',
+})
+
+const candidates = [candidate(0), candidate(1), candidate(2)]
+
+const rerank = (overrides: Partial[0]> = {}) =>
+ rerankSpeechExcerpts({
+ intencao: 'reels curto sobre o hospital do subúrbio',
+ tema: 'hospital do subúrbio',
+ candidates,
+ limit: 3,
+ ...overrides,
+ })
+
+beforeEach(() => {
+ process.env.DEEPSEEK_API_KEY = 'test-deepseek-key'
+ generateObjectMock.mockReset()
+ deepSeekMock.mockClear()
+})
+
+afterEach(() => {
+ delete process.env.DEEPSEEK_API_KEY
+})
+
+describe('rerankSpeechExcerpts (C158)', () => {
+ it('filtra índices fora do pool/duplicados e normaliza o motivo', async () => {
+ generateObjectMock.mockResolvedValue({
+ object: {
+ choices: [
+ { index: 0, reason: ' Cita o hospital e fecha a ideia ' },
+ { index: 9, reason: 'fora do pool' },
+ { index: 0, reason: 'duplicado' },
+ { index: 2, reason: 'ok' },
+ ],
+ },
+ })
+
+ await expect(rerank()).resolves.toEqual({
+ choices: [
+ { index: 0, reason: 'Cita o hospital e fecha a ideia' },
+ { index: 2, reason: 'ok' },
+ ],
+ })
+ })
+
+ it('respeita o limite de escolhas', async () => {
+ generateObjectMock.mockResolvedValue({
+ object: {
+ choices: [
+ { index: 0, reason: 'a' },
+ { index: 1, reason: 'b' },
+ { index: 2, reason: 'c' },
+ ],
+ },
+ })
+
+ await expect(rerank({ limit: 1 })).resolves.toEqual({
+ choices: [{ index: 0, reason: 'a' }],
+ })
+ })
+
+ it('honra a lista vazia como decisão ("nenhum serve")', async () => {
+ generateObjectMock.mockResolvedValue({ object: { choices: [] } })
+
+ await expect(rerank()).resolves.toEqual({ choices: [] })
+ })
+
+ it('devolve null quando todos os motivos vêm vazios ou os índices são inválidos', async () => {
+ generateObjectMock.mockResolvedValue({
+ object: { choices: [{ index: 0, reason: ' ' }] },
+ })
+
+ await expect(rerank()).resolves.toBeNull()
+ })
+
+ it('corta o motivo em 140 caracteres', async () => {
+ generateObjectMock.mockResolvedValue({
+ object: { choices: [{ index: 1, reason: 'x'.repeat(500) }] },
+ })
+
+ const result = await rerank()
+ expect(result?.choices[0]!.reason).toHaveLength(140)
+ })
+
+ it('curto-circuita sem chave, sem pool ou em erro do provedor — nunca lança', async () => {
+ await expect(rerank({ candidates: [] })).resolves.toBeNull()
+ expect(generateObjectMock).not.toHaveBeenCalled()
+
+ delete process.env.DEEPSEEK_API_KEY
+ await expect(rerank()).resolves.toBeNull()
+ expect(generateObjectMock).not.toHaveBeenCalled()
+
+ process.env.DEEPSEEK_API_KEY = 'test-deepseek-key'
+ generateObjectMock.mockRejectedValue(new Error('provider down'))
+ await expect(rerank()).resolves.toBeNull()
+ })
+
+ it('envia intenção, tema e citações truncadas ao modelo', async () => {
+ generateObjectMock.mockResolvedValue({ object: { choices: [{ index: 0, reason: 'ok' }] } })
+
+ await rerank({ candidates: [candidate(0), { ...candidate(1), citation: 'y'.repeat(900) }] })
+
+ const call = generateObjectMock.mock.calls[0]![0] as { prompt: string; abortSignal: unknown }
+ expect(call.prompt).toContain('reels curto sobre o hospital do subúrbio')
+ expect(call.prompt).toContain('Tema: hospital do subúrbio')
+ expect(call.prompt).toContain('[0]')
+ expect(call.prompt).not.toContain('y'.repeat(701))
+ expect(call.abortSignal).toBeInstanceOf(AbortSignal)
+ })
+})
diff --git a/tests/unit/speechExcerpt.unit.spec.ts b/tests/unit/speechExcerpt.unit.spec.ts
new file mode 100644
index 00000000..0b121f3b
--- /dev/null
+++ b/tests/unit/speechExcerpt.unit.spec.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ buildSpeechExcerpt,
+ speechExcerptTerms,
+ type SpeechExcerptSegment,
+} from '@/lib/speechExcerpt'
+
+const seg = (startSeconds: number, endSeconds: number, text: string): SpeechExcerptSegment => ({
+ startSeconds,
+ endSeconds,
+ text,
+})
+
+describe('speechExcerptTerms', () => {
+ it('normaliza acento/caixa e quebra em caracteres não alfanuméricos (sem curinga no LIKE)', () => {
+ expect(speechExcerptTerms('100% saúde_pública')).toEqual(['100', 'saude', 'publica'])
+ })
+
+ it('descarta termos com menos de 3 caracteres', () => {
+ expect(speechExcerptTerms('a de oxi')).toEqual(['oxi'])
+ expect(speechExcerptTerms('de a o')).toEqual([])
+ })
+
+ it('deduplica preservando a ordem', () => {
+ expect(speechExcerptTerms('SUS sus SUS saúde')).toEqual(['sus', 'saude'])
+ })
+
+ it('limita a 6 termos', () => {
+ expect(speechExcerptTerms('alfa beta gama delta epsilon zeta eta theta')).toEqual([
+ 'alfa',
+ 'beta',
+ 'gama',
+ 'delta',
+ 'epsilon',
+ 'zeta',
+ ])
+ })
+})
+
+describe('buildSpeechExcerpt', () => {
+ it('escolhe a âncora de maior cobertura e expande até o fim do trecho disponível', () => {
+ const excerpt = buildSpeechExcerpt(
+ [
+ seg(0, 3, 'O hospital do subúrbio'),
+ seg(3, 8, 'precisa de atenção'),
+ seg(8, 14, 'e de investimento'),
+ ],
+ ['hospital', 'suburbio'],
+ )
+
+ expect(excerpt).toEqual({
+ startSeconds: 0,
+ endSeconds: 14,
+ text: 'O hospital do subúrbio precisa de atenção e de investimento',
+ matchedTerms: ['hospital', 'suburbio'],
+ })
+ })
+
+ it('cobre termos espalhados em segmentos consecutivos', () => {
+ const excerpt = buildSpeechExcerpt(
+ [seg(0, 6, 'Vamos falar do hospital'), seg(6, 12, 'do subúrbio e da saúde')],
+ ['hospital', 'suburbio'],
+ )
+
+ expect(excerpt).toMatchObject({ startSeconds: 0, endSeconds: 12 })
+ })
+
+ it('expande além do alvo até cobrir termos distantes dentro do teto de 60s', () => {
+ const segments = [
+ seg(0, 5, 'hospital'),
+ seg(5, 25, 'contexto do mandato'),
+ seg(25, 45, 'mais contexto'),
+ seg(45, 55, 'ainda contexto'),
+ seg(55, 60, 'suburbio'),
+ ]
+
+ for (const targetSeconds of [20, 45]) {
+ expect(
+ buildSpeechExcerpt(segments, ['hospital', 'suburbio'], { targetSeconds }),
+ ).toMatchObject({ startSeconds: 0, endSeconds: 60 })
+ }
+ })
+
+ it('não concatena por cima de um gap maior que 5s', () => {
+ expect(
+ buildSpeechExcerpt(
+ [seg(0, 5, 'hospital'), seg(25, 30, 'suburbio')],
+ ['hospital', 'suburbio'],
+ ),
+ ).toBeNull()
+ })
+
+ it('devolve null quando nenhum segmento cobre o tema', () => {
+ expect(buildSpeechExcerpt([seg(0, 5, 'economia e emprego')], ['hospital'])).toBeNull()
+ })
+
+ it('devolve null sem segmentos ou sem termos', () => {
+ expect(buildSpeechExcerpt([], ['hospital'])).toBeNull()
+ expect(buildSpeechExcerpt([seg(0, 5, 'hospital')], [])).toBeNull()
+ })
+
+ it('as variantes curta e longa produzem janelas diferentes', () => {
+ const segments = [
+ seg(0, 10, 'hospital'),
+ seg(10, 20, 'primeira parte'),
+ seg(20, 30, 'segunda parte'),
+ seg(30, 40, 'terceira parte'),
+ seg(40, 50, 'quarta parte'),
+ ]
+
+ expect(buildSpeechExcerpt(segments, ['hospital'], { targetSeconds: 20 })).toMatchObject({
+ startSeconds: 0,
+ endSeconds: 20,
+ })
+ expect(buildSpeechExcerpt(segments, ['hospital'], { targetSeconds: 45 })).toMatchObject({
+ startSeconds: 0,
+ endSeconds: 50,
+ })
+ })
+
+ it('respeita o teto de 60s na expansão', () => {
+ const excerpt = buildSpeechExcerpt(
+ [seg(0, 30, 'hospital'), seg(30, 60, 'meio'), seg(60, 90, 'fim')],
+ ['hospital'],
+ { targetSeconds: 45 },
+ )
+
+ expect(excerpt).toMatchObject({ startSeconds: 0, endSeconds: 60 })
+ })
+
+ it('expande para trás quando a âncora sozinha não atinge o piso de contexto', () => {
+ const excerpt = buildSpeechExcerpt(
+ [seg(0, 20, 'história da Bahia'), seg(20, 22, 'hospital')],
+ ['hospital'],
+ )
+
+ expect(excerpt).toMatchObject({ startSeconds: 0, endSeconds: 22 })
+ })
+
+ it('devolve um segmento único maior que o teto inteiro (nunca corta no meio)', () => {
+ const excerpt = buildSpeechExcerpt(
+ [seg(0, 90, 'hospital e subúrbio')],
+ ['hospital', 'suburbio'],
+ )
+
+ expect(excerpt).toMatchObject({ startSeconds: 0, endSeconds: 90 })
+ })
+})
diff --git a/tests/unit/speechExcerptsTool.unit.spec.ts b/tests/unit/speechExcerptsTool.unit.spec.ts
new file mode 100644
index 00000000..ba924827
--- /dev/null
+++ b/tests/unit/speechExcerptsTool.unit.spec.ts
@@ -0,0 +1,282 @@
+import type { Payload } from 'payload'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { AIToolContext } from '@/lib/ai/types'
+import type { CampaignUser } from '@/payload-types'
+
+const { rerankMock } = vi.hoisted(() => ({ rerankMock: vi.fn() }))
+
+vi.mock('@/utilities/ai/rerankSpeechExcerpts', () => ({ rerankSpeechExcerpts: rerankMock }))
+
+import { findSpeechExcerpts } from '@/utilities/ai/tools/findSpeechExcerpts'
+
+import { stub } from '../helpers/stub'
+
+const denied = { error: 'Leitura do acervo de falas negada.' }
+
+type ExecutableTool = {
+ execute: (args: unknown, options?: unknown) => Promise
+}
+
+const user = (role: CampaignUser['role']): CampaignUser =>
+ stub({ collection: 'campaignUser', role })
+
+const leader = user('leader')
+const advisor = user('advisor')
+const coordinator = user('coordinator')
+
+const ctxFor = (actor: CampaignUser, payload: Payload): AIToolContext => ({
+ user: actor,
+ payload,
+})
+
+const payloadFor = (find: ReturnType): Payload =>
+ stub({ find: find as unknown as Payload['find'] })
+
+const untouchablePayload = payloadFor(
+ vi.fn(() => {
+ throw new Error('gate must fail closed before any payload query')
+ }),
+)
+
+const speechResult = (docs: unknown[], totalDocs = docs.length) => ({ docs, totalDocs })
+const segmentsResult = (docs: unknown[]) => ({ docs })
+
+const execute =
+ (payload: Payload, actor: CampaignUser = coordinator) =>
+ (args: unknown) =>
+ (findSpeechExcerpts(ctxFor(actor, payload)) as unknown as ExecutableTool).execute(args)
+
+const SPEECH_WHERE = {
+ and: [{ searchText: { like: 'hospital' } }, { searchText: { like: 'suburbio' } }],
+}
+
+const reranked = (index: number, reason = 'Serve à intenção') => ({ index, reason })
+
+beforeEach(() => {
+ rerankMock.mockReset()
+ rerankMock.mockResolvedValue(null)
+})
+
+describe('findSpeechExcerpts gate (C158)', () => {
+ it('nega leader e advisor antes de qualquer query — e sem chamar o reranker', async () => {
+ for (const actor of [leader, advisor]) {
+ await expect(
+ execute(untouchablePayload, actor)({ tema: 'hospital do subúrbio' }),
+ ).resolves.toEqual(denied)
+ }
+ expect(untouchablePayload.find).not.toHaveBeenCalled()
+ expect(rerankMock).not.toHaveBeenCalled()
+ })
+
+ it('deixa coordinator, candidate e communicator passarem (herança do C159)', async () => {
+ for (const actor of [coordinator, user('candidate'), user('communicator')]) {
+ const find = vi.fn().mockResolvedValue(speechResult([]))
+ const result = (await execute(
+ payloadFor(find),
+ actor,
+ )({
+ tema: 'hospital do subúrbio',
+ })) as Record
+
+ expect(result).toMatchObject({
+ totalDiscursos: 0,
+ trechos: [],
+ reordenadoPorIA: false,
+ truncado: false,
+ })
+ expect(find).toHaveBeenCalledWith(
+ expect.objectContaining({
+ collection: 'speech',
+ where: SPEECH_WHERE,
+ sort: '-speechAt',
+ limit: 30,
+ overrideAccess: false,
+ user: actor,
+ }),
+ )
+ }
+ })
+
+ it('tema sem termos úteis devolve erro sem consultar o acervo nem o reranker', async () => {
+ const find = vi.fn()
+ const result = (await execute(payloadFor(find))({ tema: 'a de o' })) as { error: string }
+
+ expect(result.error).toContain('Tema sem termos de busca')
+ expect(find).not.toHaveBeenCalled()
+ expect(rerankMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('findSpeechExcerpts fallback determinístico (C158)', () => {
+ const scriptSearch = () => {
+ const find = vi.fn()
+ find.mockResolvedValueOnce(
+ speechResult([
+ { id: 1, speechAt: '2024-05-01T10:00', type: 'DISCURSO' },
+ { id: 2, speechAt: '2024-04-01T10:00', type: 'BREVES COMUNICAÇÕES' },
+ ]),
+ )
+ find.mockResolvedValueOnce(
+ segmentsResult([
+ { speech: 1, startSeconds: 0, endSeconds: 50, text: 'O hospital do subúrbio e a saúde' },
+ { speech: 2, startSeconds: 0, endSeconds: 40, text: 'hospital do subúrbio de novo' },
+ ]),
+ )
+ return find
+ }
+
+ it('um trecho por discurso, preservando a ordem do pool scriptado, sem motivo no fallback', async () => {
+ const result = (await execute(payloadFor(scriptSearch()))({
+ tema: 'hospital do subúrbio',
+ })) as {
+ reordenadoPorIA: boolean
+ consulta: { tema: string; termos: string[]; intencao: string | null }
+ criterio: string
+ trechos: Array>
+ }
+
+ expect(result.reordenadoPorIA).toBe(false)
+ expect(result.consulta).toEqual({
+ tema: 'hospital do subúrbio',
+ termos: ['hospital', 'suburbio'],
+ intencao: null,
+ })
+ expect(result.criterio).toContain('ASR')
+ expect(result.trechos).toHaveLength(2)
+ expect(result.trechos[0]).toMatchObject({
+ discursoId: 1,
+ data: '01/05/2024 · 10:00',
+ tipo: 'DISCURSO',
+ inicioSegundos: 0,
+ fimSegundos: 50,
+ inicioLabel: '00:00',
+ fimLabel: '00:50',
+ duracaoSegundos: 50,
+ citacao: 'O hospital do subúrbio e a saúde',
+ termosCasados: ['hospital', 'suburbio'],
+ url: '/campanha/comunicacao/acervo/1?t=0&q=hospital+suburbio',
+ })
+ expect(result.trechos[0]).not.toHaveProperty('motivo')
+ expect(result.trechos[1]).toMatchObject({ discursoId: 2 })
+
+ // A intenção ausente vira o tema no prompt do reranker (prefixo determinístico).
+ expect(rerankMock).toHaveBeenCalledWith(
+ expect.objectContaining({ intencao: 'hospital do subúrbio', tema: 'hospital do subúrbio' }),
+ )
+ const rerankCandidates = (rerankMock.mock.calls[0]![0] as { candidates: unknown[] }).candidates
+ expect(rerankCandidates).toHaveLength(2)
+ })
+
+ it('discurso que casa o tema mas não rende janela contínua devolve lista vazia, não erro', async () => {
+ const find = vi.fn()
+ find.mockResolvedValueOnce(speechResult([{ id: 1, speechAt: '2024-05-01T10:00' }]))
+ find.mockResolvedValueOnce(
+ segmentsResult([{ speech: 1, startSeconds: 0, endSeconds: 5, text: 'economia e emprego' }]),
+ )
+
+ const result = (await execute(payloadFor(find))({ tema: 'hospital do subúrbio' })) as {
+ totalDiscursos: number
+ trechos: unknown[]
+ }
+
+ expect(result.totalDiscursos).toBe(1)
+ expect(result.trechos).toEqual([])
+ expect(rerankMock).not.toHaveBeenCalled()
+ })
+
+ it('trunca no limite de candidatos e devolve a dica', async () => {
+ const find = vi.fn()
+ find.mockResolvedValueOnce(speechResult([{ id: 1, speechAt: '2024-05-01T10:00' }], 40))
+ find.mockResolvedValueOnce(
+ segmentsResult([
+ { speech: 1, startSeconds: 0, endSeconds: 30, text: 'hospital do subúrbio' },
+ ]),
+ )
+
+ const result = (await execute(payloadFor(find))({ tema: 'hospital do subúrbio' })) as {
+ truncado: boolean
+ dica?: string
+ }
+
+ expect(result.truncado).toBe(true)
+ expect(result.dica).toContain('refine o tema')
+ })
+})
+
+describe('findSpeechExcerpts reranking IA (C158)', () => {
+ const scriptPool = () => {
+ const find = vi.fn()
+ find.mockResolvedValueOnce(
+ speechResult([
+ { id: 3, speechAt: '2024-06-01T10:00', type: 'DISCURSO' },
+ { id: 4, speechAt: '2024-05-01T10:00', type: 'DISCURSO' },
+ ]),
+ )
+ find.mockResolvedValueOnce(
+ segmentsResult([
+ { speech: 3, startSeconds: 0, endSeconds: 10, text: 'hospital' },
+ { speech: 3, startSeconds: 10, endSeconds: 20, text: 'do subúrbio' },
+ { speech: 3, startSeconds: 20, endSeconds: 30, text: 'e mais' },
+ { speech: 3, startSeconds: 30, endSeconds: 40, text: 'e mais ainda' },
+ { speech: 3, startSeconds: 40, endSeconds: 50, text: 'fim' },
+ { speech: 4, startSeconds: 0, endSeconds: 30, text: 'hospital do subúrbio' },
+ ]),
+ )
+ return find
+ }
+
+ it('usa a ordem e os motivos do reranker, mantendo um trecho por discurso', async () => {
+ rerankMock.mockResolvedValue({
+ choices: [
+ { index: 1, reason: 'Argumento completo em 50s' },
+ { index: 0, reason: 'Versão curta do mesmo discurso' },
+ reranked(2, 'Outra fala sobre o tema'),
+ ],
+ })
+
+ const result = (await execute(payloadFor(scriptPool()))({
+ tema: 'hospital do subúrbio',
+ intencao: 'reels curto, tom emocionante',
+ })) as {
+ reordenadoPorIA: boolean
+ trechos: Array<{ discursoId: number; motivo?: string; fimSegundos: number; url: string }>
+ }
+
+ expect(result.reordenadoPorIA).toBe(true)
+ expect(result.trechos).toEqual([
+ {
+ discursoId: 3,
+ data: '01/06/2024 · 10:00',
+ tipo: 'DISCURSO',
+ inicioSegundos: 0,
+ fimSegundos: 50,
+ inicioLabel: '00:00',
+ fimLabel: '00:50',
+ duracaoSegundos: 50,
+ citacao: 'hospital do subúrbio e mais e mais ainda fim',
+ termosCasados: ['hospital', 'suburbio'],
+ motivo: 'Argumento completo em 50s',
+ url: '/campanha/comunicacao/acervo/3?t=0&q=hospital+suburbio',
+ },
+ {
+ discursoId: 4,
+ data: '01/05/2024 · 10:00',
+ tipo: 'DISCURSO',
+ inicioSegundos: 0,
+ fimSegundos: 30,
+ inicioLabel: '00:00',
+ fimLabel: '00:30',
+ duracaoSegundos: 30,
+ citacao: 'hospital do subúrbio',
+ termosCasados: ['hospital', 'suburbio'],
+ motivo: 'Outra fala sobre o tema',
+ url: '/campanha/comunicacao/acervo/4?t=0&q=hospital+suburbio',
+ },
+ ])
+
+ const call = rerankMock.mock.calls[0]![0] as { intencao: string; candidates: unknown[] }
+ expect(call.intencao).toBe('reels curto, tom emocionante')
+ expect(call.candidates).toHaveLength(3)
+ })
+})