feat(C158): Sollinha sugere trechos de falas do acervo para vídeos (Closes #982)

This commit is contained in:
Francisco Solla 2026-09-14 03:21:19 -03:00
parent f58a712ad9
commit 9928fb183e
14 changed files with 1593 additions and 10 deletions

View file

@ -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.

View file

@ -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<br/>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<br/>AND searchText like termo<br/>sort -speechAt, limit 30)]
Q1 --> Q2[(speechSegment.find<br/>speech in ids, sort order)]
Q2 --> W[buildSpeechExcerpt<br/>âncora + variantes 20s/45s<br/>janela 15-60s, cobertura total]
W --> P[pool de até 12 candidatos<br/>1-2 por discurso]
P --> R[rerankSpeechExcerpts IA<br/>escolhe até 3 + motivo]
R -- falha/timeout/sem chave --> D[ordem determinística<br/>recência + cobertura<br/>reordenadoPorIA: false]
R -- ok --> S[seleção com motivo<br/>reordenadoPorIA: true]
D --> A
S --> A[resposta: 1-3 trechos com citação,<br/>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/<id>?t=<s>&q=<termos>
}>,
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/<id>?t=<s>&q=<termos>)`). 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/<id>?t=<floor(início)>&q=<termos>`; 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=<floor>&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.

View file

@ -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.

157
src/lib/speechExcerpt.ts Normal file
View file

@ -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
}

View file

@ -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<number>()
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
}
}

View file

@ -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.

View file

@ -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<Speech, 'id' | 'speechAt' | 'type'>
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<number, SpeechExcerptSegment[]>,
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<string>()
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<number, PoolCandidate>()
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<number>()
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(' '),
),
})

View file

@ -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),
})

View file

@ -103,7 +103,7 @@ const loadSpeechFilterOptions = async (
}
}
const loadSegmentsForSpeeches = async (
export const loadSegmentsForSpeeches = async (
payload: Payload,
user: CampaignUser,
speechIds: readonly number[],

View file

@ -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,

View file

@ -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<string>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
type ExecutableTool = {
execute: (args: unknown, options?: unknown) => Promise<unknown>
}
const executeTool = (user: CampaignUser, args: unknown): Promise<Record<string, unknown>> =>
(findSpeechExcerpts({ user, payload } as AIToolContext) as unknown as ExecutableTool).execute(
args,
) as Promise<Record<string, unknown>>
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
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}`,
}),
)
})
})

View file

@ -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<Parameters<typeof rerankSpeechExcerpts>[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)
})
})

View file

@ -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 })
})
})

View file

@ -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<unknown>
}
const user = (role: CampaignUser['role']): CampaignUser =>
stub<CampaignUser>({ 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<typeof vi.fn>): Payload =>
stub<Payload>({ 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<string, unknown>
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<Record<string, unknown>>
}
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)
})
})