From 9928fb183eca2678f85935e4f03bb8e08bd87acc Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 14 Sep 2026 03:21:19 -0300 Subject: [PATCH] =?UTF-8?q?feat(C158):=20Sollinha=20sugere=20trechos=20de?= =?UTF-8?q?=20falas=20do=20acervo=20para=20v=C3=ADdeos=20(Closes=20#982)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-14-c158.md | 1 + .../sollinha-acervo-falas-trechos-impl.md | 251 ++++++++++++++++ docs/plans/sollinha-acervo-falas-trechos.md | 3 +- src/lib/speechExcerpt.ts | 157 ++++++++++ src/utilities/ai/rerankSpeechExcerpts.ts | 111 +++++++ src/utilities/ai/systemPrompt.ts | 10 + src/utilities/ai/tools/findSpeechExcerpts.ts | 271 +++++++++++++++++ src/utilities/ai/tools/index.ts | 2 + src/utilities/speech/speechPageData.ts | 2 +- src/utilities/speech/speechViewModels.ts | 14 +- tests/int/speechExcerpts.int.spec.ts | 221 ++++++++++++++ tests/unit/rerankSpeechExcerpts.unit.spec.ts | 129 ++++++++ tests/unit/speechExcerpt.unit.spec.ts | 149 +++++++++ tests/unit/speechExcerptsTool.unit.spec.ts | 282 ++++++++++++++++++ 14 files changed, 1593 insertions(+), 10 deletions(-) create mode 100644 docs/changelog/2026-09-14-c158.md create mode 100644 docs/plans/sollinha-acervo-falas-trechos-impl.md create mode 100644 src/lib/speechExcerpt.ts create mode 100644 src/utilities/ai/rerankSpeechExcerpts.ts create mode 100644 src/utilities/ai/tools/findSpeechExcerpts.ts create mode 100644 tests/int/speechExcerpts.int.spec.ts create mode 100644 tests/unit/rerankSpeechExcerpts.unit.spec.ts create mode 100644 tests/unit/speechExcerpt.unit.spec.ts create mode 100644 tests/unit/speechExcerptsTool.unit.spec.ts 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) + }) +})