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