feat(C229): busca por sentido no acervo de falas

Troca o motor do modo tema (Câmara + internet) de expansão lexical para
embeddings (Deep Infra, BAAI/bge-m3), com índice próprio (speechEmbedding),
ranking por proximidade em memória, evidência real do trecho mais próximo,
vazio honesto por limiar e degradação para a busca literal. Inclui a
collection oculta + migration, o CLI pnpm acervo:index (--dry-run/--limit/
--source/--force/--probe), o port do design aprovado e a suíte de testes.

Pós-rebase: o snapshot da migration seguinte (20260926_062039_add_archive_photo,
do C231 já na main) foi regenerado para incluir speech_embedding — sem ele o
próximo migrate:create recriaria a tabela.

Closes #1360
This commit is contained in:
Francisco Solla 2026-09-26 03:01:14 -03:00
parent 111ab265fe
commit 3e8429daae
49 changed files with 23253 additions and 463 deletions

2
.gitignore vendored
View file

@ -84,6 +84,8 @@ next-env.d.ts
/data/falas-web/
# C231 — Flickr photo-archive cache + receipts (pnpm flickr:import)
/data/flickr/
# C229 — speech semantic index reports (pnpm acervo:index)
/data/acervo-index/
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
/data/solla-ceuci-salvador/

View file

@ -0,0 +1 @@
**C229 — Busca por sentido no acervo de falas (2026-09-26):** o modo "Por tema" do acervo interno (Câmara + internet) deixou de ser expansão lexical e passou a ser **similaridade por sentido** (embeddings), com o resultado ordenado por proximidade e a evidência real de cada fala. Motor: `src/lib/speechSemantic.ts` (puro — cosine, pooling, janelas de texto, hash de conteúdo, ranking com limiar `SPEECH_SEMANTIC_MIN_COSINE=0.4`, ordenação com relevância default e data/duração do usuário sobrepondo) e a fronteira de provider `deepInfraEmbed.ts` (endpoint OpenAI-compatible da Deep Infra, modelo `BAAI/bge-m3`, mesma `DEEPINFRA_API_KEY` do ASR, batches de 32, timeout, L2-normalização e contrato "nunca lança → `null`"; sem dependência nova). Índice: collection oculta nova `speechEmbedding` (`kind: speech|segment|window`, `speech`, `order`, `startSeconds`, `model`, `dimensions`, `contentHash`, `vector` json; `read: canReadSpeech`, escrita admin-only, `admin.hidden`; cascade no `Speech.beforeDelete`, que também corrige o rollback SQL do C155) com migration gerada `20260926_034118_add_speech_embedding` — o diff gerado trazia drift das migrations de 2026-09-24 cujos snapshots perderam objetos do C215/C219, então o SQL foi hand-reduzido ao escopo e o snapshot novo repara a cadeia para as próximas migrations. Loader: `mode=tema` embute a query uma vez, rankeia em memória os candidatos das **facetas** (sem `LIKE` de `q` e sem C174), fatia a página de 25 e carrega a evidência do melhor trecho (`speechSemanticSearch.ts`); degradação honesta para a busca literal de hoje + aviso discreto quando o provider cai ou não há candidato indexado (anti-falso-positivo), vazio honesto abaixo do limiar, e o ator fora do gate nunca dispara o provider (fail-closed). As duas loaders compartilham um único `loadSemanticSpeechPage`; o provider só é consultado quando o modelo bate (vetor de outro modelo/1024 dims nunca é comparado). CLI: `pnpm acervo:index` (`scripts/index-speeches.mjs` + `scripts/lib/speechIndex.mjs`) idempotente por hash+modelo, com `--dry-run`, `--limit`, `--source`, `--force`, relatório (indexadas/puladas/falhas/trechos/bytes/tokens/custo) e o `--probe "<tema>"` read-only (top-N com score, só CLI) que calibra o limiar e verifica o aceite pós-deploy; runbook §C155/§C225 ganhou o passo de indexação. UI (Impeccable B, port do design `docs/plans/acervo-busca-por-sentido-ui-design.html`): selo "Tema" + "Termo exato" coexistindo, bloco "Trecho mais próximo do tema" (trecho real, sem `<mark>` e sem score), heading/subtítulo responsivos, seletor de ordenação da internet com "Mais relevantes" default no tema (e vocabulário literal no degradado), vazio honesto da cena 03 e selo literal no card degradado; a Câmara mantém a decisão de produto registrada de não ganhar controle de ordenação novo. Testes: unit do motor/planejamento/CLI/provider/URL/VM/cards, int do ranking/limiar/degradação/facetas/modelo/evidência/cascade/indexação ponta-a-ponta e e2e do degradado + ordenação do tema; `pnpm gate:fast`, `knip`, `format:check` e os 41 e2e do `campaignSpeechAcervo` verdes. Crítica do `designer` (trigger c) contra o app renderizado em três ciclos (390/1280, tema/vazio/degradado/exato): todas as cenas/estados certificados; a única divergência material — o menu **aberto** do "Ordenar por" é o `<select>` nativo aprovado no C216 e não o listbox desenhado no artefato — foi levada ao humano no gate e **aceita explicitamente** (conteúdo/ordem/default conferidos por DOM). `Design tier: openai/gpt-5.6-sol`. O aceite de produto ("combate à oposição", "impeachment") depende do corpus real: Fase 5 do impl plan no homeserver (`acervo:index` + `--probe`), com top-N/limiar/custo a registrar aqui na primeira execução.

View file

@ -757,15 +757,46 @@ changelog `docs/changelog/2026-09-13-c155.md`.
O import é aditivo e idempotente; não há migração para desfazer. Para remover
uma legislatura (ex.: reimportar do zero), apagar os discursos e seus
segmentos — as rels (`speech_rels`, `speech_topics`, `speech_scopes`) caem por
cascade:
cascade (o índice de sentido sai antes; a FK dos vetores é `SET NULL` sobre
coluna NOT NULL, então o SQL cru precisa apagá-los por conta própria):
```sql
DELETE FROM "speech_embedding" WHERE "speech_id" IN (SELECT id FROM "speech" WHERE "legislature" = '55');
DELETE FROM "speech_segment" WHERE "speech_id" IN (SELECT id FROM "speech" WHERE "legislature" = '55');
DELETE FROM "speech" WHERE "legislature" = '55';
```
Para desfazer o acervo inteiro: `DELETE FROM "speech_segment"; DELETE FROM "speech";`
(a cobertura `--coverage` volta a zero). Reexecutar `--all` reconstrói.
Para desfazer o acervo inteiro: `DELETE FROM "speech_embedding"; DELETE FROM
"speech_segment"; DELETE FROM "speech";` (a cobertura `--coverage` volta a
zero). Reexecutar `--all` reconstrói, e `pnpm acervo:index` refaz o índice.
### C229 — índice de sentido do acervo
O modo **"Por tema"** do acervo (Câmara + internet) passou a ser similaridade
por embeddings (C229): as falas importadas precisam entrar no índice vetorial
antes de aparecerem nessa busca. O passo é manual, idempotente por hash de
conteúdo + modelo e deve ser rodado **depois** de qualquer import (C155/C225):
```bash
# no homeserver, com o env de produção carregado (DEEPINFRA_API_KEY)
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
set -a; source ~/stack/.env; set +a
set -a; source ~/stack/teqo-1313.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
# ensaio (read-only): quanto falta indexar e quantos trechos
pnpm acervo:index --dry-run
# indexação (write guard do C229)
ACERVO_INDEX_CONFIRM=1 pnpm acervo:index --source all
# calibração/verificação do aceite (read-only; imprime top-N com score)
pnpm acervo:index --probe "combate à oposição" --top 10
pnpm acervo:index --probe "impeachment" --top 10
```
O relatório JSON fica em `data/acervo-index/reports/` (gitignored) com
indexadas/puladas/falhas/trechos/tokens/custo. Os temas do aceite e 3–4 temas
de controle validam `SPEECH_SEMANTIC_MIN_COSINE` (`src/lib/speechSemantic.ts`);
registre os top-N e o valor final no changelog. Reexecutar só paga o que mudou.
## C225 — rodada de falas da internet no acervo de produção
@ -1050,6 +1081,12 @@ flock -n 9 || { printf '%s\n' 'já existe uma rodada web' >&2; exit 1; }
`pending`/`review` que ainda o referencie. Se essa prova não for feita, a
rodada fica bloqueada para aprovação operacional.
7. **Índice de sentido (C229):** depois do import, rode o passo do C229
(`ACERVO_INDEX_CONFIRM=1 pnpm acervo:index --source web`, ou `--source all`
para pegar as duas origens). Sem ele, as falas novas não aparecem na busca
"Por tema"; o `--probe` confere o aceite e o relatório traz tokens/custo.
O passo está detalhado na seção "C229 — índice de sentido do acervo".
### Inventário read-only
As consultas abaixo não escrevem no banco:

View file

@ -0,0 +1,177 @@
# Impl: C229 — Busca por sentido no acervo de falas
Status: aprovado
Atualizado em: 2026-09-25
Issue: #1360
Intenção: docs/plans/acervo-busca-por-sentido.md
Appetite restante: herdado (~2–3 dias eng)
## Leitura da intenção
- **Outcome:** em `mode=tema`, a busca do acervo de falas (Câmara + internet) passa a ser similaridade por sentido (embeddings), não expansão lexical: lista ordenada por proximidade (data só desempate), cada resultado com o trecho real mais próximo do tema (sem score, sem destaque de termo, sem justificativa gerada); acima do limiar vazio honesto; motor fora → busca literal de hoje com o aviso discreto.
- **O que NÃO negociar:** o gate `canReadSpeech`/`canReadCommunicationCatalog` não alarga nem ganha atalho; toda leitura local com `user` + `overrideAccess: false`; a fala continua a unidade (abrir/assistir/baixar intocado); "Termo exato" continua o default e não regride; nenhum score numérico na UI; gravações enviadas (C199/C219), Central pública (S28) e Sollinha (C158) ficam fora; `expandSpeechSearchTheme` não pode ser deletado (gravações/Central/conteúdo o consomem); `openai/*` intocado (OPS123); Consent/LGPD intocados (o único dado externo é o texto da busca e o texto das falas — sem PII de cidadão).
- **O que reavaliar:** (a) a hipótese de que a expansão sai do caminho principal está certa, mas o mecanismo de degradação **não** mantém a expansão — degrada para a busca literal pura (sem LLM); (b) o índice não cabe no `speech`/`speechSegment` sem inchar o dono e sem um rebuild limpo — vira collection oculta própria; (c) a evidência semântica exige vetores por trecho (não só por fala): o card lê o melhor trecho real da página; (d) o contrato de `sort` hoje é "web-only e `recentes` nunca serializado" e precisa expressar "Mais relevantes" (default do tema) mantendo os bytes do modo exato.
## Abordagem recomendada
```mermaid
flowchart TB
A[modo tema com q] --> B{ator le o catalogo?}
B -- nao --> F[payload.find fail-closed]
B -- sim --> C[embed da query<br/>DeepInfra bge-m3]
C -- null --> DEG[busca literal de hoje<br/>themeUnavailable true]
C -- vetor --> D[find de candidatos<br/>facetas e origem, sem perna textual]
D --> E[find dos vetores de fala<br/>speechEmbedding kind=speech]
E -- zero indexados --> DEG
E -- vetores --> G[rank em memoria<br/>cosine desc, data como desempate]
G --> H[limiar global + ordenacao escolhida]
H --> I[fatia da pagina de 25]
I --> J[docs da pagina + melhor trecho por fala]
J --> K[VM semanticMatch + trecho real sem destaque]
DEG --> L[VM lexical de hoje + aviso discreto]
```
**Opções consideradas:** A) embeddings em memória (DeepInfra, índice derivado em collection oculta, ranking no app) | B) expansão lexical/LLM como motor com reforço de recall | C) pgvector/serviço vetorial dedicado com ANN.
**Recomendação:** A — é o pedido literal (similaridade por sentido), reusa a infra de IA já contratada (`DEEPINFRA_API_KEY`), mantém access/`where`/paginação por fala e não exige imagem de Postgres com `vector` nem serviço novo; o índice é derivado, reconstruível e alimentado por CLI manual, como os imports.
**Rejeitadas:** B porque é exatamente o comportamento que o dono rejeitou ("parece busca por termo somada a matches em temas pré-definidos") e a intenção manda a expansão no máximo degradar; C porque pgvector não existe na imagem (`postgres:17-alpine`; troca de imagem é mudança de infra/produção fora do appetite) e um serviço vetorial dedicado reabre o rabbit hole nomeado.
### Componentes / mudanças
- **`src/utilities/ai/deepInfraEmbed.ts` (novo, `server-only`)** — fronteira do provider: `DEEPINFRA_EMBED_URL = 'https://api.deepinfra.com/v1/openai/embeddings'`, `DEEPINFRA_EMBED_MODEL = 'BAAI/bge-m3'`, `DEEPINFRA_EMBED_DIMENSIONS = 1024`, `DEEPINFRA_EMBED_COST_PER_MILLION_TOKENS_USD` (constante única do relatório), `EMBED_TEXT_MAX_CHARS` (2000), `EMBED_BATCH_SIZE` (32), `embedSpeechTexts(texts)` (input em lote, `encoding_format: 'float'`, parsing por `index`, L2-normaliza, `AbortSignal.timeout(30_000)`, nunca lança → `null`) e `embedSpeechQuery(text)` (1 input, timeout curto de 8s para não segurar a página). Tipo `SpeechQueryEmbeddingResolver`. Segue o padrão `fetch` + timeout + nunca lança de `deepInfraTranscribe.ts`; sem `@ai-sdk/*` nem dependência nova.
- **`src/lib/speechSemantic.ts` (novo, puro/client-safe)** — motor: `dotProduct`/`cosineSimilarity` (vetores normalizados na escrita), `normalizeVector`, `poolSpeechVector` (média L2-normalizada dos trechos = vetor da fala), `speechTextWindows` (janelas de 1200 chars com 200 de sobreposição, para fala sem segmento), `speechEmbeddingHash` (FNV-1a incluindo modelo = idempotência do índice), `rankSemanticHits`, `selectSemanticHits` (limiar), `sortSemanticHits` (relevância/recentes/duração), `pickBestSemanticChunk` e `SPEECH_SEMANTIC_MIN_COSINE = 0.4` (valor inicial — D6).
- **`src/utilities/speech/speechSemanticSearch.ts` (novo, `server-only`)** — orquestração do caminho semântico: candidatos = `payload.find('speech', { where: buildSpeechFacetWhere(state), select: { id, speechAt, durationSeconds }, pagination: false, user, overrideAccess: false })`; vetores = `payload.find('speechEmbedding', { where: kind=speech e speech in candidatos, user, overrideAccess: false })`; cosine em memória, limiar, ordenação escolhida, slice de `speechPageSize`; busca dos vetores de trecho **da página** e resolução do melhor trecho (segmento por `order`/`startSeconds`, janela recomputada do texto original quando `kind=window`). Devolve `pageHits`, `totalHits`, `indexedCandidates` e `evidenceBySpeech`.
- **`src/utilities/speech/speechPageData.ts`** — o 4º parâmetro `expandTheme` vira `embedQuery: SpeechQueryEmbeddingResolver = embedSpeechQuery` nas duas loaders (mesmo padrão de injeção de `loadSpeechDetailPageData`); `resolveSpeechThemeTerms` é removido (expansão sai do acervo); com tema+`q`: gate de papel → `embedQuery` → caminho semântico → docs/segmentos/cortes/facetas da página + evidência; `themeUnavailable` quando o embed da query for `null` **ou** `indexedCandidates === 0`; `themeApplied` passa a significar "o motor de sentido rodou nesta request" (não mais "termos expandidos"); C174 (origem de corte) só é consultado no caminho lexical.
- **`src/utilities/speech/speechListFilters.ts`** — remove `themeTerms` de `buildSpeechTextBranches`/`buildSpeechListWhere*` (a perna textual volta a ser só `q`, como no C180) e exporta `buildSpeechFacetWhere` como fronteira única dos candidatos semânticos (sem twinar o where).
- **`src/lib/speechSearch.ts`** — só comentário do espelho C180 atualizado (a perna textual volta a ter `q`); predicados intocados.
- **`src/utilities/speech/speechListUrl.ts` + `src/lib/acervoListSort.ts`** — contrato de `sort` ciente do modo (D5): `relevancia` reconhecido/omitido só no tema; `recentes` serializado só no tema; demais bytes do modo exato intactos. `ACERVO_THEME_SORT_OPTIONS` (Mais relevantes + os três) e o mapeamento puro "valor do select → estado" vivem no vocabulário compartilhado, mas só o caminho de falas os usa — gravações intocadas.
- **`src/utilities/speech/speechViewModels.ts`** — `SpeechListItemViewModel` e `WebSpeechListItemViewModel` ganham `semanticMatch: boolean` e `matchedTextSearch` (o web também), no lugar de `themeMatchTerm` (removido) e de `pickThemeMatch`; no caminho semântico o `excerpt` é o melhor trecho real **sem** destaque (`buildHighlightedExcerpt(source, '')`), `matchKind='theme'` e `watchHref` mira o `t=` do trecho de evidência quando existir.
- **UI (`src/components/campaign/speech/` + rota)** — `SpeechResultCard.tsx`: `isTheme = speech.semanticMatch`, bloco renomeado para **"Trecho mais próximo do tema"** (sem `<mark>`), selo "Tema" + "Termo exato" coexistindo; `WebSpeechResultCard.tsx` ganha os mesmos selos e o bloco de evidência preservando título/plataforma/capa (a cena 01 é a fonte internet); `SpeechThemeFallbackNotice.tsx` com a copy do design; na degradação os cards literais mostram o selo "Termo exato" (cena 04) sem alterar a lista do modo exato; `page.tsx` com heading/subtítulo do design ("Resultados por tema" / "Do mais próximo ao menos próximo do sentido buscado."), vazio honesto da cena 03 ("Nenhuma fala combina bem com esse tema" + Reformular/Usar termo exato/Limpar filtros), aviso/retry e `AcervoSortSelect` com as opções de tema na internet.
- **Migration:** `add_speech_embedding` (Fase 2, **STOP-POINT**): cria a collection oculta `speechEmbedding` e o índice da FK; commit dos três arquivos (`ts`/`json`/`index.ts`).
- **Access / Consent:** nenhuma chave `Consent` nova, nenhum PII; `speechEmbedding.access.read = canReadSpeech`, `create/update/delete = payloadAdminOnly`; o CLI usa `overrideAccess: true` justificado (ator confiável, como os imports); o gate de papel antecede o embed (ator negado nunca dispara chamada externa) e o `find` continua a barreira final.
- **CLI:** `scripts/index-speeches.mjs` + puro `scripts/lib/speechIndex.mjs` + `pnpm acervo:index` (`--dry-run`, `--limit`, `--source camara|web|all`, `--probe "<tema>" [--top N]`), relatório JSON em `data/acervo-index/reports/` (gitignore) com indexadas/puladas/falhas/cobertura/tokens/custo; guardas `assertLocalDatabase` + `assertWriteConfirm({ flag: 'ACERVO_INDEX_CONFIRM' })`; o `--probe` é read-only e usa o mesmo ranking do app.
- **Manifesto/ops:** entry de `src/collections/SpeechEmbedding.ts` na vertical do acervo em `scripts/lib/e2e-affected-manifest.mjs`; `playwright.config.ts` passa a anular `DEEPINFRA_API_KEY`; `docs/ops/teqo-1313-deploy.md` §C155/§C225 ganham o passo de indexação; uma entrada em `docs/changelog/`.
### Dados → forma (N/A justificado)
N/A. O item não apresenta métrica nem agregado: a saída é uma lista de falas com um trecho real; a intenção proíbe score numérico e a forma aprovada no design é "selo + trecho", sem barra/percentual/justificativa gerada.
## Fases verificáveis
> **STOP-POINT obrigatório (Fase 2):** schema só entra com aprovação humana da migration. A execução **para** depois de revisar o SQL gerado; só então `pnpm migrate` local, `pnpm generate:types` e a sequência. Nada de Fase 3+ antes disso.
1. **Fase 1 — tracer offline (sem migration, ~0,5–1 dia):** `lib/speechSemantic.ts`, `utilities/ai/deepInfraEmbed.ts`, contrato de `sort` do tema, VM/cards (`semanticMatch`, "Trecho mais próximo do tema", selos coexistentes) e copy do degradado — tudo com o loader ainda lexical. Testes: `speechSemantic.unit.spec.ts` (cosine, pooling, hash, janelas, limiar, ordenação, melhor trecho), `deepInfraEmbed.unit.spec.ts` (sem chave → `null` e sem fetch; lote por `index`; dimensão errada/HTTP/timeout → `null`; normalização; modelo e `encoding_format` no corpo), `speechListUrl.unit.spec.ts` (relevância canônica no tema; `recentes` serializado só no tema; bytes do modo exato idênticos; `relevancia` fora do tema descartado), `acervoListSort.unit.spec.ts` (opções/mapeamento do tema; gravações intactas), `speechViewModels.unit.spec.ts` e `speechResultCards.unit.spec.tsx` (trecho sem destaque, selos, `watchHref` com `t=`), `speechListFilters.unit.spec.ts` (builder volta ao formato pré-C192).
2. **Fase 2 — schema (STOP-POINT, ~0,5 dia):** `src/collections/SpeechEmbedding.ts` (slug `speechEmbedding`, `kind: speech|segment|window`, `speech`, `order`, `startSeconds`, `model`, `dimensions`, `contentHash`, `vector` json, `admin.hidden`), registro no `payload.config.ts`, cascade em `Speech.beforeDelete` (embeddings antes da mídia, `req` + bypass justificado), `pnpm migrate:create add_speech_embedding`. **Revisar o SQL; parar para aprovação humana.** Depois: `pnpm migrate`, `pnpm generate:types`, `pnpm generate:importmap` (se necessário) e int de cascade (apagar fala apaga embeddings e segmentos).
3. **Fase 3 — loader + indexação (~0,5–1 dia):** `speechSemanticSearch.ts`, loaders das duas fontes com `embedQuery` injetável, CLI + `--probe`, `.gitignore`. Testes int (sem corpus, com embeddings semeados): (a) fala acima do limiar aparece ordenada por proximidade com `semanticMatch` e evidência real; (b) nada acima do limiar → `rows=[]`, `themeApplied=true`, `themeUnavailable=false` (vazio honesto); (c) `embedQuery → null` → literais + `themeUnavailable=true` (aviso); (d) vetor ok e zero indexados → degrada igual; (e) facetas restringem candidatos; (f) `sort=recentes` no tema ordena por data após o limiar e duração filtra/ordena; (g) `order` fora dos limites → fallback para o primeiro segmento; (h) advisor/leader rejeitados sem chamar o embedder; unit da CLI (`speechIndex.unit.spec.ts`: args, plano, hash/skip, relatório).
4. **Fase 4 — UI/gates (~0,5 dia):** port classe-a-classe das cenas 01–04 (heading, sort com "Mais relevantes", vazio, degradado, selo no card degradado), extensão de `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (degradado com copy nova e sem selo "Tema" fabricado; `source=internet&mode=tema` com "Mais relevantes" e `sort=recentes` canônico sem loop; modo exato inalterado) e entry no manifesto. `pnpm gate:fast`; PR via `pnpm push` (high-risk por `src/migrations` + `src/collections` → curado, que já inclui `campaignSpeechAcervo`).
5. **Fase 5 — pós-deploy (aceite de produto, corpus real):** após o deploy de produção, no homeserver, rodar `pnpm acervo:index` (com `ACERVO_INDEX_CONFIRM=1`) e depois `--probe "combate à oposição"` e `--probe "impeachment"`, além de 3–4 temas de controle fora do domínio; registrar top-N/scores/cobertura/custo na Issue e no changelog; ajustar `SPEECH_SEMANTIC_MIN_COSINE` com a evidência se necessário e repetir.
## Rabbit holes / Não escopo (engenharia)
- pgvector, imagem de Postgres com `vector`, serviço/banco vetorial dedicado e ANN — rejeitado (infra fora do appetite; sem corpus local).
- Reindexação em tempo real/fila/job — imports seguem manuais; a atualização do índice é passo do runbook.
- Expansão lexical/LLM como motor ou como fallback — a degradação é a busca literal pura de hoje.
- Gravações (C199/C219), Central pública (S28), Sollinha (C158), busca global e dossiês — fora do recorte; `expandSpeechSearchTheme` preservado para eles.
- Score/justificativa gerada/por que apareceu numérico — a evidência é trecho real.
- Curadoria de sinônimos, cache de embeddings de query, rerank de segundo passe e quantização — deferidos com gatilho (D2/D6).
- UI nova de ordenação na Câmara — o modo tema da Câmara é sempre relevância, sem controle de sort.
## Riscos e mitigação
- **Limiar mal calibrado (falso-vazio × falso-cheio):** limiar global de 0.40 (D6) calibrado com `--probe` sobre os dois exemplos do aceite e controles; constante única; o vazio é honesto; nenhum resultado abaixo do limiar entra.
- **Latência/memória por request** (carrega vetores de todos os candidatos; ~1000 falas no corpus atual): medir no probe; gatilho — p95 > ~1,5s ou memória do container doer → base64 Float32 (4× menor) e/ou quantização int8 (D2), reavaliando pgvector/artefato materializado.
- **Índice desatualizado/parcial** (fala importada sem indexar some do tema em silêncio): o CLI é idempotente por hash+modelo, `--dry-run` imprime cobertura/pendências e o runbook encadeia `pnpm acervo:index` após os imports; gatilho — se a defasagem doer, o import chama o indexador (mesma função) em vez de duplicar lógica.
- **Evidência desalinhada após reimport** (segmentos trocados sem reindexar): `order` fora dos limites cai no primeiro segmento; o reindex é o conserto; a cobertura do relatório denuncia.
- **Provider/chave ausente em produção** (lição do C192): a ausência degrada com o aviso honesto; o deploy já audita `DEEPINFRA_API_KEY` e o runbook tem o copia-e-cola; o loader nunca lança.
- **Custo/quota:** lotes de 32, teto de chars por texto, `usage` reportado; corpus inteiro estimado em ~20–30k textos, dezenas de minutos e custo baixo no bge-m3 (relatório imprime tokens/custo).
- **Deep-link/contrato de URL:** unit pins dos bytes do modo exato e do round-trip de `relevancia`/`recentes`; e2e do tema+recentes sem loop de redirect.
- **Vazamento de gate:** collection nova lida com `user` + `overrideAccess: false` e `read: canReadSpeech`; papel negado não dispara o embedder; cascade do delete com bypass justificado e coberto por int.
- **e2e determinístico:** `DEEPINFRA_API_KEY` vazio no webServer do Playwright (o caminho semântico real é coberto por int com resolver injetado, não por e2e).
- **Regressão fora do recorte:** gravações/Central/Sollinha não são tocados; `expandSpeechSearchTheme` e seus consumidores ficam; knip garante que nenhum export ficou morto.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: tema sem as palavras exatas devolve por sentido; ordenação por proximidade com data como desempate; trecho real sem score; "Termo exato" intacto e alcançável; vazio honesto com saída; degradação com aviso, sem erro nem fantasma; facetas valendo; fala como unidade; sem UI nova de ordenação na Câmara.
- [ ] **Aceite de produto verificado pós-deploy com corpus real (não há corpus nos bancos locais):** `pnpm acervo:index --probe "combate à oposição"` e `--probe "impeachment"` no homeserver contra `teqo_1313` após o deploy + indexação, com top-N/scores/cobertura/custo registrados na Issue e no changelog; probes de controle fora do domínio validam o limiar; valor final de `SPEECH_SEMANTIC_MIN_COSINE` registrado.
- [ ] Invariantes AGENTS/engineering-standards: `user` + `overrideAccess: false` em toda leitura; `canReadSpeech` reusado sem alargamento; ator negado sem chamada externa (fail-closed); nenhum Consent/PII novo; copy pt-BR e identificadores em inglês; sem score na UI; `openai/*` e `expandSpeechSearchTheme` intocados.
- [ ] Migration isolada na Fase 2, aprovada no stop-point, aplicada localmente e tipos gerados (`pnpm generate:types`); cascade da fala coberto por int.
- [ ] Testes de domínio previstos (unit de motor/provedor/URL/VM/cards/CLI; int de ranking, limiar, degradação, facetas, sort, evidência, guard) e e2e atualizado (degradado + sort do tema) com `campaignSpeechAcervo` selecionado no PR high-risk.
- [ ] Manifesto de e2e com a collection nova; `pnpm gate:fast` verde; PR ready via `pnpm push` (curado, migration = high-risk).
- [ ] Runbook `docs/ops/teqo-1313-deploy.md` (§C155/§C225) com o passo de indexação e a entrada de changelog.
## Decisões de engenharia
### D1 — Motor e provider
**Opções:** A) embeddings multilingual via DeepInfra em memória | B) expansão lexical/LLM como motor | C) pgvector/serviço vetorial dedicado | D) modelo local sem provider.
**Recomendação:** A — reusa `DEEPINFRA_API_KEY` e o padrão `fetch`+timeout+`null` do ASR, sem contrato/secret novo; o motor é cosine em memória no app (a imagem não tem `vector`); a query é 1 chamada e o índice é derivado.
**Rejeitadas:** B porque é o comportamento rejeitado pelo dono; C porque pgvector exige trocar a imagem de produção e ANN não se paga em ~1k falas; D porque exigiria runtime de modelo no container (infra/custo) sem ganho comprovado.
### D2 — Forma do índice: unidade e encoding
**Opções:** A) vetor da fala + vetor por trecho (segmento de ASR; janelas do texto quando não há segmento), JSON de números | B) só chunk, ranking pelo melhor chunk | C) só fala, sem vetor de trecho | D) trechos com texto duplicado no índice.
**Recomendação:** A — o vetor da fala (média L2-normalizada dos trechos, computada na indexação) dá um ranking estável e barato; os vetores de trecho só são carregados para as 25 falas da página e entregam o "Trecho mais próximo do tema" real; JSON v1 é simples e auditável, com gatilho explícito para base64 Float32 (4× menor) e/ou int8 (10×) se o tamanho/latência doerem (o relatório do CLI imprime linhas/bytes estimados).
**Rejeitadas:** B porque supervaloriza uma frase solta e multiplica o payload de rank; C porque sem trecho não há evidência real (e o design exige); D porque duplica transcrição (o texto da janela é recomputado de `officialTranscript`/`summary` pela MESMA função pura do indexador).
### D3 — Onde o índice mora
**Opções:** A) collection oculta nova `speechEmbedding` | B) campos JSON ocultos em `speech` (fala) e `speechSegment` (trechos) | C) storage externo/artefato em arquivo.
**Recomendação:** A — o `speech` fica leve, o índice é reconstruível/apagável sem tocar no acervo, o acesso é um predicado só (`canReadSpeech`) e o cascade do delete é explícito (FK de relationship é `ON DELETE SET NULL` sobre coluna NOT NULL, então filhos antes do pai).
**Rejeitadas:** B porque incha a tabela principal, faz cada leitura do acervo carregar TOAST de vetores e mistura rebuild do índice com escrita das falas; C porque cria artefato volátil fora do banco, sem access control nem cascade (o banco local de dev/test também precisa do índice semeado nos int).
### D4 — Candidatos e ordenação no modo tema
**Opções:** A) candidatos = `where` de facetas/origem apenas; ranking em memória; sem pernas textuais e sem C174 | B) união de resultados semânticos + matches literais do `q` | C) híbrido com boost lexical.
**Recomendação:** A — é a promessa do modo ("ordem por proximidade"); o C174 (corte por título) e o `LIKE` só valem onde já valiam (modo exato/degradado), e os matches literais que importarem aparecem naturalmente se passarem no limiar, com o selo "Termo exato" coexistindo.
**Rejeitadas:** B/C porque reintroduzem a dependência de termo que motivou o item, quebram "data apenas como desempate" (resultado lexical sem score) e transformam o limiar em ficção.
### D5 — Contrato de URL do `sort`
**Opções:** A) `sort` ciente de `mode` no domínio de falas (`relevancia` omitido no tema; `recentes` serializado só no tema; bytes do exato intactos) | B) `relevancia` no vocabulário compartilhado | C) novo parâmetro `order`.
**Recomendação:** A — `relevancia` não vaza para gravações/Central; `sort=recentes` no tema é estado real e canônico; o modo exato continua byte-idêntico (nenhum deep-link muda); o `AcervoSortSelect` recebe o conjunto de opções por prop e o mapeamento "valor do select → estado" fica puro e testado.
**Rejeitadas:** B porque acrescentaria "Mais relevantes" ao seletor das gravações (fora do recorte); C porque duplica o vocabulário e cria duas verdades para ordenar.
### D6 — Limiar e vazio honesto
**Opções:** A) limiar global fixo `SPEECH_SEMANTIC_MIN_COSINE = 0.4`, calibrado por `--probe` | B) sem limiar (mostra sempre top-25) | C) limiar adaptativo por distribuição da consulta | D) expor score/percentual.
**Recomendação:** A — valor inicial 0.40 sobre vetores L2-normalizados (bge-m3); calibrar no pós-deploy com os dois temas do aceite (falso-vazio: se o topo ficar vazio, o limiar está alto) e com 3–4 temas de controle fora do domínio (falso-cheio: se controle entrar no topo, o limiar está baixo); o valor final é uma constante de uma linha, registrada com a evidência.
**Rejeitadas:** B porque viola "nunca resultado 'parecido' só para preencher a tela"; C porque é imprevisível/inexplicável e não testável; D porque é proibido pela intenção (número cru engana o assessor).
### D7 — Mecanismo de degradação
**Opções:** A) busca lexical literal de hoje + aviso discreto | B) degradar mantendo a expansão lexical do C192 | C) esconder o modo tema | D) erro pedindo retry.
**Recomendação:** A — `themeUnavailable` quando o embed da query `null` ou `indexedCandidates === 0`; o caminho é o `buildSpeechListWhere` literal (sem `themeTerms`) + C174, ordenado por data; a expansão lexical sai do acervo de falas (`themeTerms` removido do builder e do VM) mas sobrevive intacta para gravações/Central/conteúdo.
**Rejeitadas:** B porque mantém o motor rejeitado como rede de segurança e mistura proveniências; C porque some com a feature em silêncio; D porque é o "erro na cara" que a intenção proíbe.
### D8 — Evidência e contrato do card
**Opções:** A) melhor trecho real (segmento/janela) por fala da página, sem destaque, `semanticMatch` no VM | B) manter `themeMatchTerm`/destaque lexical no modo tema | C) justificativa gerada por LLM.
**Recomendação:** A — o trecho vem do vetor de trecho (cosine com a query), é texto real do acervo, sem `<mark>` e sem score; o rótulo é "Trecho mais próximo do tema"; "Tema" e "Termo exato" coexistem; `watchHref` leva o `?t=` ao trecho de evidência quando conhecido.
**Rejeitadas:** B porque destaca um termo que não é o motor (mentira de proveniência) e o design o removeu; C por alucinação e imprevisibilidade.
### D9 — Indexação: CLI separado vs acoplado ao import
**Opções:** A) CLI manual `pnpm acervo:index` (idempotente por hash+modelo, `--dry-run`/`--limit`/`--source`/`--probe`), encadeado no runbook após os imports | B) indexar dentro de `import-web-speeches.mjs`/`import-camara` | C) job assíncrono no app.
**Recomendação:** A — um dono só para batching, hash, skip, relatório e probe; os imports seguem manuais e idempotentes; o `--probe` é o instrumento de calibração e de verificação pós-deploy; escrita com `ACERVO_INDEX_CONFIRM=1` e relatório honesto.
**Rejeitadas:** B porque duplicaria a lógica em dois scripts (e o backfill da Câmara está congelado); C porque é o rabbit hole de reindexação em tempo real. Gatilho de revisitação: se a defasagem do índice doer, o import chama a mesma função do CLI (sem segundo dono).
### D10 — Injeção do embedding da query (testes offline)
**Opções:** A) 4º parâmetro `embedQuery` injetável nas duas loaders (precedente `expandTheme`/`SpeechVideoStartResolver`) | B) mock global do módulo | C) testes só com chave real.
**Recomendação:** A — int tests determinísticos semeiam `speechEmbedding` e injetam o vetor da query, exercitando ranking/limiar/degradação sem rede; o default é `embedSpeechQuery`; o e2e apenas cobre o degradado (chave anulada no Playwright).
**Rejeitadas:** B porque acopla o teste ao bundler e esconde o contrato; C porque depende de rede/provedor e não roda no CI.
### D11 — Expansão lexical fora do caminho de falas (sem deletar o dono)
**Opções:** A) remover `themeTerms` do builder/VM de falas e deixar `expandSpeechSearchTheme` intacto para os outros corpus | B) deletar `expandSpeechSearchTheme` | C) manter o parâmetro morto nos filtros.
**Recomendação:** A — o acervo de falas deixa de chamar a expansão; gravações (`recordingPageData`/`expandRecordingSearchTheme`), Central (`expandContentPieceSearchTheme`) e conteúdo continuam usando o mesmo dono; knip garante que nada exportado ficou sem consumidor.
**Rejeitadas:** B porque gravações/Central/conteúdo dependem do módulo (a intenção manda manter a expansão deles); C porque superfície morta mente sobre o mecanismo.
## Self-score decision-quality
1. **Decisões caras com rejeitadas:** 5/5 — motor/provider, forma e moradia do índice, candidatos/ordenação, contrato de URL, limiar, degradação, evidência, CLI e injeção têm opções/recomendação/rejeitadas registradas.
2. **Cabe no appetite:** 4/5 — ~2–3 dias no limite superior: os módulos são pequenos e o UI é port classe-a-classe, mas schema+CLI+rework dos dois loaders concentram o esforço; o stop-point da migration não conta como implementação.
3. **Rabbit holes nomeados:** 5/5 — pgvector/infra vetorial, reindexação em tempo real, expansão como motor, gravações/Central/Sollinha, score/justificativa e quantização deferida com gatilho estão cortados.
4. **Depth check (reusa shells/helpers):** 5/5 — `CampaignListEmptyState`, `AcervoSortSelect`, `CampaignThemeRetryButton`, `SpeechThemeFallbackNotice`, `SpeechResultCard`/`WebSpeechResultCard`, `loadSegmentsForSpeeches`, `speechHighlight`, `campaignListUrl`, `assertLocalDatabase`/`assertWriteConfirm`, o padrão `fetch`+timeout+null do DeepInfra e a injeção de resolver.
5. **Intenção permanece satisfeita:** 5/5 — outcome, anti-goals e guardrails de acesso intactos; a engenharia troca o mecanismo sem reescrever o produto.
**Total: 5/5** (gate ≥4 satisfeito).

View file

@ -91,6 +91,7 @@
"camara:pilot": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/pilot-camara-speeches.mjs",
"camara:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-camara-speeches.mjs",
"falas-web:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-web-speeches.mjs",
"acervo:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-speeches.mjs",
"recordings:classify": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/backfill-recording-facets.mjs",
"typecheck": "tsc --noEmit",
"typecheck:tsc6": "tsc6 --noEmit",

View file

@ -283,8 +283,10 @@ export default defineConfig({
* C192 — blank the DeepSeek key so the acervo's theme expansion always
* degrades to the literal search (never the network) and the degraded
* state stays deterministic, same principle as the Google test key.
* C229 — the same for the embeddings provider of the speech sources.
*/
DEEPSEEK_API_KEY: '',
DEEPINFRA_API_KEY: '',
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
// Prod mode serves a production build where `getCampaignInviteBaseURL`
// fails closed unless NEXT_PUBLIC_SITE_URL is an HTTPS public DNS name

224
scripts/index-speeches.mjs Normal file
View file

@ -0,0 +1,224 @@
/**
* C229 — builds/updates the semantic index of the speech acervo (`pnpm
* acervo:index`). It plans the retrieval units of every speech (ASR segments
* or text windows), embeds only what changed — idempotent by content hash +
* model — writes the vectors in one transaction per speech and prints an honest
* report (indexed/skipped/failed/units/tokens/cost). `--probe "<tema>"` is the
* read-only calibration: it embeds one query and prints the top-N indexed
* speeches with their cosine, without writing to the database.
*
* The corpus and the provider live on the homeserver: run this after the Câmara
* backfill or a web round (see `docs/ops/teqo-1313-deploy.md` §C155/§C225), and
* pass `ACERVO_INDEX_CONFIRM=1` when the target is not provably local (the
* homeserver runs with `NODE_ENV=production` behind the socat proxy).
*
* Usage:
* pnpm acervo:index --dry-run
* pnpm acervo:index --source web --limit 20
* ACERVO_INDEX_CONFIRM=1 pnpm acervo:index --source all
* ACERVO_INDEX_CONFIRM=1 pnpm acervo:index --force
* pnpm acervo:index --probe "combate à oposição" --top 10
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
import { assertWriteConfirm, databaseTarget, dieWithLabel, loadCliEnv } from './lib/cli.mjs'
import {
ACERVO_INDEX_CONFIRM_FLAG,
formatSpeechIndexProbe,
formatSpeechIndexReport,
parseSpeechIndexArgs,
speechIndexCostUsd,
speechIndexEstimatedBytes,
speechIndexReportStamp,
} from './lib/speechIndex.mjs'
loadCliEnv()
const die = dieWithLabel('acervo:index')
const config = (await import('../src/payload.config.ts')).default
const {
DEEPINFRA_EMBED_COST_PER_MILLION_TOKENS_USD,
DEEPINFRA_EMBED_DIMENSIONS,
DEEPINFRA_EMBED_MODEL,
embedSpeechQuery,
} = await import('../src/utilities/ai/deepInfraEmbed.ts')
const { indexSpeechSources, planSpeechIndexBatch, probeSpeechIndex, previewSpeechIndex } =
await import('../src/utilities/speech/speechEmbeddingIndex.ts')
const HELP = `
Uso: pnpm acervo:index [opções]
Constrói/atualiza o índice vetorial do acervo de falas (Câmara + internet) e
relata o que entrou. Idempotente por hash de conteúdo + modelo: reexecutar só
paga o que mudou. Após os imports (C155/C225), rode este passo.
Opções:
--source <all|camara|web> recorte do acervo (default all)
--limit <n> processa no máximo n falas
--dry-run planeja sem chamar o provedor nem escrever
--force reindexa mesmo com hash atual
--probe <tema> read-only: top-N por similaridade (calibração)
--top <n> quantos no probe (default 10)
--out <dir> diretório dos relatórios (default data/acervo-index)
--help esta ajuda
Escrita em alvo não-local, com NODE_ENV=production ou com ALLOW_REMOTE_DB exige
${ACERVO_INDEX_CONFIRM_FLAG}=1. O probe é read-only e não exige a flag.
`
/** Every speech of the recorte with its segments, in one pass per collection. */
const loadSources = async (payload, { source, limit }) => {
const where = source === 'all' ? {} : { origin: { equals: source } }
const speeches = await payload.find({
collection: 'speech',
where,
depth: 0,
...(limit ? { limit, page: 1 } : { limit: 0, pagination: false }),
sort: 'speechAt',
select: { sourceKey: true, officialTranscript: true, summary: true, searchText: true },
// Intentional bypass: the index CLI is a trusted actor with no session.
overrideAccess: true,
})
const ids = speeches.docs.map((speech) => speech.id)
const segmentsBySpeech = new Map()
if (ids.length > 0) {
const segments = await payload.find({
collection: 'speechSegment',
where: { speech: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
sort: 'order',
select: { speech: true, order: true, startSeconds: true, text: true },
// Intentional bypass: same trusted read.
overrideAccess: true,
})
for (const segment of segments.docs) {
const speechId = typeof segment.speech === 'number' ? segment.speech : segment.speech?.id
const list = segmentsBySpeech.get(speechId) ?? []
list.push({
order: segment.order,
startSeconds: segment.startSeconds,
text: segment.text,
})
segmentsBySpeech.set(speechId, list)
}
}
const sources = speeches.docs.map((speech) => ({
id: speech.id,
sourceKey: speech.sourceKey,
officialTranscript: speech.officialTranscript,
summary: speech.summary,
searchText: speech.searchText,
segments: segmentsBySpeech.get(speech.id) ?? [],
}))
return sources
}
const writeReport = async (options, report) => {
const reportPath = join(
options.out,
'reports',
`acervo-index-${speechIndexReportStamp(report.runAt)}.json`,
)
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, JSON.stringify(report, null, 2))
return reportPath
}
async function main() {
const options = parseSpeechIndexArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
assertLocalDatabase(
'acervo:index',
'O índice escreve no acervo; produção exige ' + `${ACERVO_INDEX_CONFIRM_FLAG}=1.`,
)
if (!options.probe && !options.dryRun) {
assertWriteConfirm({
label: 'acervo:index',
flag: ACERVO_INDEX_CONFIRM_FLAG,
command: 'pnpm acervo:index',
})
}
const payload = await getPayload({ config })
if (options.probe) {
const queryVector = await embedSpeechQuery(options.probe)
if (!queryVector) {
die('não foi possível vetorizar o tema (DEEPINFRA_API_KEY ausente ou provedor fora).')
}
const hits = await probeSpeechIndex(payload, queryVector, { limit: options.top })
for (const line of formatSpeechIndexProbe(options.probe, hits)) console.log(line)
process.exit(0)
}
const runAt = new Date().toISOString()
const startedAt = Date.now()
const sources = await loadSources(payload, options)
console.log(
`[acervo:index] alvo: ${databaseTarget()} | modo: ${options.dryRun ? 'dry-run (read-only)' : 'index'} | fonte: ${options.source} | falas no recorte: ${sources.length}`,
)
if (options.dryRun) {
const batch = await planSpeechIndexBatch(payload, sources, { force: options.force })
const preview = previewSpeechIndex(batch, sources.length)
const report = {
runAt,
mode: 'dry-run',
target: databaseTarget(),
source: options.source,
model: DEEPINFRA_EMBED_MODEL,
...preview,
estimatedBytes: speechIndexEstimatedBytes(preview.units, DEEPINFRA_EMBED_DIMENSIONS),
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatSpeechIndexReport(report)) console.log(line)
console.log(`\n[acervo:index] relatório JSON: ${reportPath}`)
process.exit(0)
}
const result = await indexSpeechSources(payload, sources, { force: options.force })
const report = {
runAt,
mode: 'index',
target: databaseTarget(),
source: options.source,
model: DEEPINFRA_EMBED_MODEL,
speeches: sources.length,
upToDate: result.upToDate,
skippedNoText: result.skippedNoText,
toIndex: result.indexed + result.failed,
indexed: result.indexed,
failed: result.failed,
units: result.units,
estimatedBytes: speechIndexEstimatedBytes(result.units, DEEPINFRA_EMBED_DIMENSIONS),
promptTokens: result.promptTokens,
costUsd: speechIndexCostUsd(result.promptTokens, DEEPINFRA_EMBED_COST_PER_MILLION_TOKENS_USD),
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatSpeechIndexReport(report)) console.log(line)
console.log(`\n[acervo:index] relatório JSON: ${reportPath}`)
if (result.failed > 0 && result.indexed === 0) {
die('nenhuma fala foi indexada — falhas listadas no relatório.')
}
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})

View file

@ -499,6 +499,8 @@ export const E2E_AFFECTED_MANIFEST = [
'src/lib/webSpeech',
'src/collections/Speech.ts',
'src/collections/InternetSpeechMedia.ts',
// C229 — the semantic index collection/engine the theme mode reads.
'src/collections/SpeechEmbedding.ts',
// C216 — the source and ordering vocabularies shared by the acervo.
'src/lib/acervoSource',
'src/lib/acervoListSort',

116
scripts/lib/speechIndex.mjs Normal file
View file

@ -0,0 +1,116 @@
/**
* C229 — pure helpers of `pnpm acervo:index`: argument parsing, the report
* shape/cost math and the `--probe` rendering. The DB/embedding work lives in
* `src/utilities/speech/speechEmbeddingIndex.ts`; this module is pinned by
* unit tests so a flag or a report line cannot drift silently.
*/
export const ACERVO_INDEX_CONFIRM_FLAG = 'ACERVO_INDEX_CONFIRM'
const DEFAULT_OUT_DIR = 'data/acervo-index'
const SPEECH_INDEX_SOURCES = ['all', 'camara', 'web']
/**
* @param {string[]} argv
*/
export const parseSpeechIndexArgs = (argv) => {
const options = {
source: 'all',
limit: null,
dryRun: false,
force: false,
probe: null,
top: 10,
out: DEFAULT_OUT_DIR,
help: false,
}
const fail = (message) => {
throw new Error(message)
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = () => {
const next = argv[++index]
if (next === undefined || next.startsWith('--')) fail(`faltou valor para ${arg}.`)
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--source') options.source = value()
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--dry-run') options.dryRun = true
else if (arg === '--force') options.force = true
else if (arg === '--probe') options.probe = value()
else if (arg === '--top') options.top = Number(value())
else if (arg === '--out') options.out = value()
else fail(`argumento desconhecido: ${arg}`)
}
if (options.help) return options
if (!SPEECH_INDEX_SOURCES.includes(options.source)) {
fail(`--source deve ser um de: ${SPEECH_INDEX_SOURCES.join(', ')}.`)
}
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
fail('--limit deve ser >= 1.')
}
if (!Number.isInteger(options.top) || options.top < 1) fail('--top deve ser >= 1.')
if (options.out.includes('..')) fail('--out não pode escapar do diretório do repo (sem "..").')
if (options.probe && (options.dryRun || options.force || options.limit !== null)) {
fail('--probe é read-only e não combina com --dry-run/--force/--limit.')
}
if (options.top !== 10 && !options.probe) fail('--top só vale com --probe.')
return options
}
/** Filesystem-safe stamp of the run instant (same shape as the other reports). */
export const speechIndexReportStamp = (iso) => iso.replace(/[:.]/g, '-')
export const speechIndexCostUsd = (promptTokens, pricePerMillionTokensUsd) =>
(promptTokens / 1_000_000) * pricePerMillionTokensUsd
/** Rough JSON cost of the vectors: units × dims × ~8 bytes per number. */
export const speechIndexEstimatedBytes = (units, dimensions) => units * dimensions * 8
/**
* @param {{
* target: string, mode: 'index' | 'dry-run', source: string,
* speeches: number, upToDate: number, skippedNoText: number, toIndex: number,
* units: number, estimatedBytes?: number, indexed?: number, failed?: number,
* promptTokens?: number, costUsd?: number, durationMs: number,
* }} report
* @returns {string[]}
*/
export const formatSpeechIndexReport = (report) => {
const bytes =
typeof report.estimatedBytes === 'number'
? ` | bytes estimados: ${(report.estimatedBytes / 1_000_000).toFixed(1)} MB`
: ''
const lines = [
`[acervo:index] alvo: ${report.target} | modo: ${report.mode} | fonte: ${report.source}`,
`falas: ${report.speeches} | atualizadas: ${report.upToDate} | sem texto: ${report.skippedNoText} | a indexar: ${report.toIndex}`,
]
if (report.mode === 'index') {
lines.push(
`indexadas: ${report.indexed ?? 0} | falhas: ${report.failed ?? 0} | trechos vetorizados: ${report.units}${bytes} | tokens: ${report.promptTokens ?? 0} | custo estimado: US$ ${(report.costUsd ?? 0).toFixed(4)}`,
)
} else {
lines.push(`trechos a vetorizar: ${report.units}${bytes}`)
}
lines.push(`duração: ${(report.durationMs / 1000).toFixed(1)}s`)
return lines
}
/**
* @param {string} term
* @param {{ score: number, speechAt: string, sourceKey: string, origin: string }[]} hits
* @returns {string[]}
*/
export const formatSpeechIndexProbe = (term, hits) => [
`[acervo:index] probe "${term}" — top ${hits.length}`,
...hits.map(
(hit, index) =>
`${index + 1}. ${hit.score.toFixed(4)} · ${hit.speechAt} · ${hit.origin} · ${hit.sourceKey}`,
),
]

View file

@ -100,6 +100,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/reportText.mjs',
'scripts/lib/seed-minimal-manifest.mjs',
'scripts/lib/sollaCeuciSalvadorMetrics.mjs',
'scripts/lib/speechIndex.mjs',
'scripts/lib/staging-test-account.mjs',
'scripts/lib/test-affected-core.mjs',
'scripts/lib/testing-audit-metrics-core.mjs',

View file

@ -61,7 +61,7 @@ type SpeechAcervoPageProps = {
* (the C216 web speeches).
*/
const AcervoHeader = ({ source }: { source: AcervoSource }) => (
<div className="flex flex-col gap-4 pt-4 md:pt-0">
<div className="flex flex-col gap-2 pt-4 max-md:-mb-6 md:gap-4 md:pt-0">
{/* Mobile keeps only the switcher row: the shell chrome already carries the
title and the action joins the toggle (approved scene 6). */}
<div className="flex flex-wrap items-end justify-between gap-4 max-md:hidden">
@ -81,12 +81,14 @@ const AcervoHeader = ({ source }: { source: AcervoSource }) => (
<RecordingUploadDialog />
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<AcervoSourceToggle source={source} />
<div className="flex items-center justify-between gap-2 md:gap-3">
<div className="min-w-0 flex-1 md:flex-none">
<AcervoSourceToggle source={source} />
</div>
<RecordingUploadDialog
triggerLabel=""
triggerAriaLabel="Enviar gravação"
triggerClassName="min-h-10 md:hidden"
triggerClassName="min-h-9 md:hidden"
/>
</div>
</div>
@ -97,11 +99,14 @@ const AcervoHeader = ({ source }: { source: AcervoSource }) => (
* term / theme / idle) and the hint line of `mode=tema`. The caller owns the
* right side (`controls`: the Câmara leaves the retry button bare, the other
* two wrap it with their sort select) and whether the row renders at all.
* C229 — `semanticTheme` swaps the hint of the speech sources for the design
* copy of the sense engine; the recordings keep their lexical wording.
*/
const AcervoResultsHeading = ({
themeMode,
themeActive,
themeUnavailable,
semanticTheme = false,
idleTitle,
itemNoun,
controls,
@ -109,6 +114,7 @@ const AcervoResultsHeading = ({
themeMode: boolean
themeActive: boolean
themeUnavailable: boolean
semanticTheme?: boolean
idleTitle: string
itemNoun: 'fala' | 'gravação'
controls: ReactNode
@ -123,13 +129,32 @@ const AcervoResultsHeading = ({
: idleTitle}
</h2>
{themeMode ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{themeUnavailable
? 'Comportamento atual do acervo.'
: themeActive
? `Confira o indício em cada ${itemNoun} antes de abrir.`
: 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'}
</p>
themeUnavailable ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{semanticTheme
? 'Ordenados por data, como na busca literal.'
: 'Comportamento atual do acervo.'}
</p>
) : themeActive ? (
semanticTheme ? (
<>
<p className="mt-0.5 hidden text-xs text-muted-foreground md:block">
Do mais próximo ao menos próximo do sentido buscado.
</p>
<p className="mt-0.5 text-xs text-muted-foreground md:hidden">
Ordenados pela proximidade de sentido.
</p>
</>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
Confira o indício em cada {itemNoun} antes de abrir.
</p>
)
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
Nenhum termo relacionado foi acrescentado; mostramos a busca literal.
</p>
)
) : null}
</div>
{controls}
@ -149,9 +174,9 @@ const AcervoResultsControls = ({
themeUnavailable: boolean
hint: string
}) => (
<div className="flex flex-wrap items-end gap-2">
<div className="flex w-full flex-wrap items-end gap-2 md:w-auto">
{themeUnavailable ? <CampaignThemeRetryButton /> : null}
<AcervoSortSelect state={state} hint={hint} />
<AcervoSortSelect state={state} hint={hint} themeUnavailable={themeUnavailable} />
</div>
)
@ -293,6 +318,7 @@ const WebSpeechesSource = ({
themeMode={themeMode}
themeActive={themeActive}
themeUnavailable={data.themeUnavailable}
semanticTheme
idleTitle="Resultados encontrados"
itemNoun="fala"
controls={
@ -310,17 +336,18 @@ const WebSpeechesSource = ({
<CampaignListEmptyState
className="border-solid"
mediaClassName="rounded-none bg-transparent"
headerClassName={themeActive ? 'max-w-lg [text-wrap:wrap]' : undefined}
icon={SearchXIcon}
title={
themeActive
? 'Nenhuma fala encontrada para este tema'
? 'Nenhuma fala combina bem com esse tema'
: hasFilters
? 'Nenhuma fala encontrada'
: 'Nenhuma fala da internet catalogada ainda'
}
description={
themeActive
? 'Não encontramos uma fala que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
? 'Não encontramos uma fala próxima o suficiente do sentido buscado com os filtros atuais. Não vamos completar a lista com resultados pouco relacionados.'
: hasFilters
? 'Não encontramos uma fala para esta busca com os filtros atuais. Tente outro termo ou remova um filtro.'
: 'As falas encontradas na internet aparecem aqui depois que a ingestão as catalogar.'
@ -334,7 +361,7 @@ const WebSpeechesSource = ({
Usar termo exato
</Link>
</Button>
<Button asChild variant="ghost" className="min-h-11">
<Button asChild variant="ghost" className="min-h-11 text-muted-foreground">
<Link href={buildAcervoSourceHref('internet')}>Limpar filtros</Link>
</Button>
</div>
@ -387,6 +414,7 @@ const CamaraSource = ({ data }: { data: Awaited<ReturnType<typeof loadSpeechAcer
themeMode={themeMode}
themeActive={themeActive}
themeUnavailable={data.themeUnavailable}
semanticTheme
idleTitle="Resultados"
itemNoun="fala"
controls={data.themeUnavailable ? <CampaignThemeRetryButton /> : null}
@ -398,19 +426,20 @@ const CamaraSource = ({ data }: { data: Awaited<ReturnType<typeof loadSpeechAcer
) : (
<CampaignListEmptyState
icon={SearchXIcon}
headerClassName={themeActive ? 'max-w-lg [text-wrap:wrap]' : undefined}
className={themeActive ? 'border-solid' : undefined}
mediaClassName={themeActive ? 'size-12 rounded-full' : undefined}
contentClassName={themeActive ? 'max-w-none' : undefined}
title={
themeActive
? 'Nenhuma fala encontrada para este tema'
? 'Nenhuma fala combina bem com esse tema'
: data.state.q
? `Nenhuma fala encontrada para "${data.state.q}"`
: 'Nenhuma fala encontrada'
}
description={
themeActive
? 'Não encontramos uma fala que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
? 'Não encontramos uma fala próxima o suficiente do sentido buscado com os filtros atuais. Não vamos completar a lista com resultados pouco relacionados.'
: hasFilters
? 'Tente outro termo, remova filtros ou busque por um tema.'
: 'O acervo ainda não tem falas importadas.'
@ -424,7 +453,7 @@ const CamaraSource = ({ data }: { data: Awaited<ReturnType<typeof loadSpeechAcer
Usar termo exato
</Link>
</Button>
<Button asChild variant="ghost" className="min-h-11">
<Button asChild variant="ghost" className="min-h-11 text-muted-foreground">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar filtros</Link>
</Button>
</div>

View file

@ -78,6 +78,15 @@ const deleteSpeechAssets: CollectionBeforeDeleteHook = async ({ id, req }) => {
.map((media) => (typeof media === 'object' && media !== null ? media.id : media))
.filter((mediaId): mediaId is number => typeof mediaId === 'number')
// C229 — the semantic index is derived; deleting the speech deletes its rows
// first (the relationship FK cannot SET NULL over a NOT NULL column).
await req.payload.delete({
collection: 'speechEmbedding',
where: { speech: { equals: id } },
req,
// Intentional bypass: same cascade over derived vectors.
overrideAccess: true,
})
await req.payload.delete({
collection: 'speechSegment',
where: { speech: { equals: id } },

View file

@ -0,0 +1,107 @@
import type { CollectionConfig } from 'payload'
import { canReadSpeech, payloadAdminOnly } from '@/utilities/campaignAccess'
/**
* C229 — the semantic index of the speech acervo, one row per vector. `kind:
* 'speech'` is the speech-level vector (element-wise mean of its units, used by
* the ranking); `kind: 'segment'` points at an ASR segment by `order` and
* `kind: 'window'` at a fixed-size text window of a speech without segments —
* both are the real evidence excerpt the card shows ("Trecho mais próximo do
* tema"). The vector is L2-normalized at write time and stored as JSON.
*
* Derived data, never authoritative: the acervo keeps its access gate
* (`canReadSpeech`), the import CLIs restore it and `pnpm acervo:index`
* rebuilds/stales it by `contentHash` + `model`. Admin-hidden: the product
* surface is the catalog UI.
*/
export const SpeechEmbedding: CollectionConfig = {
slug: 'speechEmbedding',
labels: {
singular: 'Vetor de fala',
plural: 'Vetores de fala',
},
admin: {
group: 'Comunicação',
hidden: () => true,
useAsTitle: 'kind',
},
access: {
create: payloadAdminOnly,
read: canReadSpeech,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
name: 'speech',
type: 'relationship',
relationTo: 'speech',
label: 'Fala',
required: true,
index: true,
},
{
name: 'kind',
type: 'select',
label: 'Unidade',
required: true,
index: true,
options: [
{ label: 'Fala', value: 'speech' },
{ label: 'Segmento', value: 'segment' },
{ label: 'Janela de texto', value: 'window' },
],
},
{
name: 'order',
type: 'number',
label: 'Ordem',
admin: {
description: 'Posição do trecho na fala; vazio no vetor da fala.',
},
},
{
name: 'startSeconds',
type: 'number',
label: 'Início (s)',
admin: {
description: 'Início do segmento quando conhecido; vazio na janela de texto.',
},
},
{
name: 'model',
type: 'text',
label: 'Modelo',
required: true,
admin: {
description: 'Modelo de embedding que gerou o vetor (muda ⇒ reindexar).',
},
},
{
name: 'dimensions',
type: 'number',
label: 'Dimensões',
required: true,
},
{
name: 'contentHash',
type: 'text',
label: 'Hash do conteúdo',
required: true,
admin: {
description: 'Hash do texto + modelo; decide o pulo na reindexação.',
},
},
{
name: 'vector',
type: 'json',
label: 'Vetor',
required: true,
admin: {
readOnly: true,
description: 'Embedding L2-normalizado da unidade.',
},
},
],
}

View file

@ -1,7 +1,13 @@
'use client'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { ACERVO_SORT_OPTIONS, acervoSortIsDuration, type AcervoSortKey } from '@/lib/acervoListSort'
import {
ACERVO_SORT_OPTIONS,
ACERVO_THEME_SORT_OPTIONS,
acervoSortIsDuration,
type AcervoThemeSortKey,
} from '@/lib/acervoListSort'
import { cn } from '@/lib/utils'
import {
buildRecordingListHref,
type RecordingListState,
@ -16,31 +22,72 @@ import { buildSpeechListHref, type SpeechListState } from '@/utilities/speech/sp
* the boundary, so the client-safe domain contracts are imported inside this
* component and the page passes only the serializable state and the honest
* line shown while a duration order is active (it lists measured rows only).
*
* C229 — the web theme mode opens with "Mais relevantes" (the semantic default,
* never serialized) and keeps "Mais recentes" as a real, serializable state;
* recordings (and the exact mode) keep the C216 vocabulary untouched.
*/
type AcervoSortState = RecordingListState | SpeechListState
export const AcervoSortSelect = ({ state, hint }: { state: AcervoSortState; hint: string }) => {
export const AcervoSortSelect = ({
state,
hint,
themeUnavailable = false,
}: {
state: AcervoSortState
hint: string
/** C229 — the engine is down: the list is literal, so show the exact-mode order. */
themeUnavailable?: boolean
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) =>
next.source === 'enviadas' ? buildRecordingListHref(next, 1) : buildSpeechListHref(next, 1),
})
const change = (value: AcervoSortKey) => {
navigate({ ...state, page: 1, sort: value === 'recentes' ? undefined : value })
const isWebTheme = state.source === 'internet' && state.mode === 'tema'
const isSpeechTheme = isWebTheme && !themeUnavailable
const options = isSpeechTheme ? ACERVO_THEME_SORT_OPTIONS : ACERVO_SORT_OPTIONS
const selected: AcervoThemeSortKey = state.sort ?? (isSpeechTheme ? 'relevancia' : 'recentes')
const change = (value: AcervoThemeSortKey) => {
// Relevance means "no sort" in the theme mode; "recentes" does so in every
// other path (each mode's never-serialized default).
if (value === 'relevancia' || (value === 'recentes' && !isSpeechTheme)) {
navigate({ ...state, page: 1, sort: undefined })
return
}
navigate({ ...state, page: 1, sort: value })
}
return (
<div className="flex flex-col items-end gap-1">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<div
className={cn(
'flex flex-col items-end gap-1',
// C229 — the web theme control takes the full row on mobile (design
// scene 02): one bordered line with the label left and the order right.
isWebTheme && 'w-full md:w-auto',
)}
>
<label
className={cn(
'flex items-center gap-2 text-xs text-muted-foreground',
isWebTheme &&
'min-h-11 w-full justify-between rounded-lg border border-input bg-white px-3 md:min-h-0 md:w-auto md:justify-start md:gap-2 md:rounded-none md:border-0 md:bg-transparent md:px-0',
)}
>
Ordenar por
<select
value={state.sort ?? 'recentes'}
value={selected}
disabled={isPending}
className="min-h-10 rounded-md border border-input bg-white px-3 py-2 text-sm text-foreground disabled:opacity-70"
onChange={(event) => change(event.target.value as AcervoSortKey)}
className={cn(
'min-h-10 rounded-md border border-input bg-white px-3 py-2 text-sm text-foreground disabled:opacity-70',
isWebTheme &&
'min-h-11 rounded-md border-0 bg-transparent px-0 text-sm font-medium outline-none md:min-h-10 md:rounded-md md:border md:border-input md:bg-white md:px-3',
)}
onChange={(event) => change(event.target.value as AcervoThemeSortKey)}
>
{ACERVO_SORT_OPTIONS.map((option) => (
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>

View file

@ -15,7 +15,8 @@ import { cn } from '@/lib/utils'
* The standard campaign list empty state (icon, title, description, optional
* CTA). Rendered inside `CampaignListResults` so it dims with the shared
* transition like the rows it replaces. `className` / `mediaClassName` /
* `contentClassName` let a caller tune the shell without forking it (C192).
* `contentClassName` / `headerClassName` let a caller tune the shell without
* forking it (C192/C229).
*/
export const CampaignListEmptyState = ({
icon: Icon,
@ -25,6 +26,7 @@ export const CampaignListEmptyState = ({
className,
mediaClassName,
contentClassName,
headerClassName,
}: {
icon: LucideIcon
title: string
@ -34,9 +36,11 @@ export const CampaignListEmptyState = ({
className?: string
mediaClassName?: string
contentClassName?: string
/** C229 — widens the copy of the theme empty state (design scene 03). */
headerClassName?: string
}) => (
<Empty className={cn('min-h-72 border', className)}>
<EmptyHeader>
<EmptyHeader className={headerClassName}>
<EmptyMedia variant="icon" className={mediaClassName}>
<Icon aria-hidden="true" />
</EmptyMedia>

View file

@ -28,6 +28,7 @@ export const CampaignSearchModeControl = ({
themeUnavailable = false,
onSelect,
relatedHint,
unavailableHint = 'Indisponível agora; mostramos o termo exato.',
}: {
activeMode: SpeechSearchMode
/** The theme expansion is down; the selector reflects the fallback. */
@ -35,6 +36,12 @@ export const CampaignSearchModeControl = ({
onSelect: (mode: SpeechSearchMode) => void
/** Desktop hint of the corpus ("Encontra falas relacionadas pelo sentido."). */
relatedHint: string
/**
* C229 — the degraded helper. The speech sources use the approved copy of the
* fallback scene, which stays visible on mobile too (the success hint does
* not); the recordings keep their own line.
*/
unavailableHint?: string
}) => (
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3 md:flex-row md:flex-wrap md:items-center md:justify-between">
<div className="flex w-full items-center gap-2 md:w-auto">
@ -76,8 +83,13 @@ export const CampaignSearchModeControl = ({
</button>
</div>
</div>
<p className="hidden text-xs text-muted-foreground md:block">
{themeUnavailable ? 'Indisponível agora; mostramos o termo exato.' : relatedHint}
<p
className={cn(
'text-xs text-muted-foreground',
themeUnavailable ? 'block' : 'hidden md:block',
)}
>
{themeUnavailable ? unavailableHint : relatedHint}
</p>
</div>
)

View file

@ -269,6 +269,7 @@ export const SpeechAcervoFilters = ({
themeUnavailable={themeUnavailable}
onSelect={runMode}
relatedHint="Encontra falas relacionadas pelo sentido."
unavailableHint="Você pode tentar “Por tema” novamente."
/>
<div

View file

@ -1,4 +1,4 @@
import { ExternalLinkIcon, PlayIcon, SparklesIcon } from 'lucide-react'
import { ExternalLinkIcon, PlayIcon } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
@ -9,8 +9,12 @@ import {
type SpeechChipGroup,
} from '@/components/campaign/speech/SpeechResultChips'
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
import {
SpeechLiteralMatchBadge,
SpeechSemanticBadges,
SpeechSemanticProvenance,
} from '@/components/campaign/speech/SpeechSemanticEvidence'
import { Button } from '@/components/ui/button'
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
import type { SpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
/**
@ -23,42 +27,6 @@ const MAX_KEYWORD_CHIPS = 3
const MetaSeparator = () => <span aria-hidden="true">·</span>
/**
* C192 — the theme result provenance: the real excerpt that matched the theme,
* with the expanded term highlighted. It replaces the common excerpt (never
* coexists with it) and carries no score.
*/
const SpeechThemeProvenance = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
if (excerpt.parts.length === 0) return null
return (
<div className="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-primary">
Por que apareceu
</p>
<SpeechExcerpt
excerpt={excerpt}
className="mt-1 text-sm leading-6 text-foreground/90"
quoted
/>
</div>
)
}
const SpeechThemeBadges = ({ matchedTextSearch }: { matchedTextSearch: boolean }) => (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">
<SparklesIcon className="size-3" aria-hidden="true" />
Tema
</span>
{matchedTextSearch ? (
<span className="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
Termo exato
</span>
) : null}
</div>
)
const ActionLink = ({
href,
children,
@ -109,7 +77,7 @@ const SpeechMetaLine = ({
/**
* The primary CTA of every result card: a link to the speech (or its trecho)
* whose variant/width the caller owns (C192 adds the theme appearance).
* whose variant/width the caller owns (C229 adds the theme appearance).
*/
const SpeechWatchButton = ({
speech,
@ -169,13 +137,18 @@ export const SpeechResultCard = ({
}) => {
// C174 (option B): a speech that surfaced only because a cut matched the term
// shows the origin note instead of an excerpt the speech does not contain, and
// the actions sit at the top right (approved scene 05). C192 — a theme result
// outranks this branch: the theme evidence lives in the speech itself.
if (!speech.matchedTextSearch && speech.themeMatchTerm === null) {
// the actions sit at the top right (approved scene 05). C229 — a semantic
// result outranks this branch: the evidence lives in the speech itself.
if (!speech.matchedTextSearch && !speech.semanticMatch) {
return (
<article className="rounded-xl border bg-card p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4">
<div className="min-w-0">
{speech.literalFallback ? (
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<SpeechLiteralMatchBadge />
</div>
) : null}
<SpeechMetaLine speech={speech} originLabel />
<p className="mt-2 text-sm text-muted-foreground">
A fala não contém o termo exato; ela aparece porque um corte vinculado corresponde à
@ -212,15 +185,15 @@ export const SpeechResultCard = ({
className: 'font-normal text-muted-foreground',
},
]
const isTheme = speech.themeMatchTerm !== null
// C192 — a theme card only offers the trecho seek when the speech really
const isTheme = speech.semanticMatch
// C229 — a semantic card only offers the trecho seek when the speech really
// contains the literal query (a stopword-only segment hit must not claim it).
const watchLabel =
speech.matchKind === 'segment' && speech.matchedTextSearch ? 'Assistir no trecho' : 'Ver fala'
// C192 — the theme card replaces the common excerpt with the provenance block;
// the two never coexist (design gate).
// C229 — the semantic card replaces the common excerpt with the evidence
// block; the two never coexist (design gate).
const bodyNode = isTheme ? (
<SpeechThemeProvenance excerpt={speech.excerpt} />
<SpeechSemanticProvenance excerpt={speech.excerpt} />
) : (
<SpeechExcerpt
excerpt={speech.excerpt}
@ -234,11 +207,18 @@ export const SpeechResultCard = ({
<article className="rounded-xl border bg-card p-4">
{isTheme ? (
<div className="flex flex-col items-start gap-2">
<SpeechThemeBadges matchedTextSearch={speech.matchedTextSearch} />
<SpeechSemanticBadges matchedTextSearch={speech.matchedTextSearch} />
<SpeechMetaLine speech={speech} />
</div>
) : (
<SpeechMetaLine speech={speech} />
<div className="flex flex-col items-start gap-2">
{speech.literalFallback ? (
<div className="flex flex-wrap items-center gap-1.5">
<SpeechLiteralMatchBadge />
</div>
) : null}
<SpeechMetaLine speech={speech} />
</div>
)}
<div className={isTheme ? 'mt-3' : 'mt-2'}>

View file

@ -0,0 +1,58 @@
import { SparklesIcon } from 'lucide-react'
import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt'
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
/**
* C229 — the shared semantic marks of a result card (design scenes 01/02): the
* "Tema" seal (the engine surfaced the row by meaning), the coexisting "Termo
* exato" seal (the row also contains the literal query) and the evidence block
* "Trecho mais próximo do tema" — a real passage, no highlight and no score.
* Both the Câmara and the web cards render exactly this structure.
*/
export const SpeechSemanticBadges = ({ matchedTextSearch }: { matchedTextSearch: boolean }) => (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">
<SparklesIcon className="size-3" aria-hidden="true" />
Tema
</span>
{matchedTextSearch ? (
<span className="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
Termo exato
</span>
) : null}
</div>
)
/**
* C229 — the seal of a literal result inside the degraded theme path (design
* scene 04): the engine was down, the list below is by term. The plain exact
* search never renders it (the page only passes `literalFallback` when the
* actor asked for the theme).
*/
export const SpeechLiteralMatchBadge = () => (
<span
data-testid="speech-literal-badge"
className="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground"
>
Termo exato
</span>
)
export const SpeechSemanticProvenance = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
if (excerpt.parts.length === 0) return null
return (
<div className="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-primary">
Trecho mais próximo do tema
</p>
<SpeechExcerpt
excerpt={excerpt}
className="mt-1 text-sm leading-6 text-foreground/90"
quoted
/>
</div>
)
}

View file

@ -3,9 +3,10 @@ import { TriangleAlertIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
/**
* C192 — the discreet fallback notice when `mode=tema` was asked but the theme
* expansion was unavailable. The list below shows the literal results; the
* notice keeps the user's request honest instead of hiding the downgrade.
* C229 — the discreet fallback notice when `mode=tema` was asked but the sense
* engine was unavailable (no provider or an empty index). The list below shows
* the literal results; the notice keeps the user's request honest instead of
* hiding the downgrade.
*/
export const SpeechThemeFallbackNotice = () => (
<Alert
@ -15,11 +16,10 @@ export const SpeechThemeFallbackNotice = () => (
>
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle className="text-sm font-medium">
A busca por tema está indisponível agora.
A busca por sentido está indisponível agora.
</AlertTitle>
<AlertDescription className="text-xs leading-5">
Você pediu “Por tema”. Para não interromper o trabalho, mostramos abaixo os resultados por
termo exato.
Para não interromper o trabalho, mostramos resultados por correspondência de termos.
</AlertDescription>
</Alert>
)

View file

@ -7,6 +7,11 @@ import {
type SpeechChipGroup,
} from '@/components/campaign/speech/SpeechResultChips'
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
import {
SpeechLiteralMatchBadge,
SpeechSemanticBadges,
SpeechSemanticProvenance,
} from '@/components/campaign/speech/SpeechSemanticEvidence'
import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill'
import { Button } from '@/components/ui/button'
import type { WebSpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
@ -19,7 +24,9 @@ const MAX_SCOPE_CHIPS = 2
* C216 — one web speech in the "Falas na internet" list (design scenes 01/03/05):
* the cover (or the honest neutral placeholder), the platform pill on the cover
* and beside the date, the matching excerpt with the search highlight and the
* single "Ver fala" action.
* single "Ver fala" action. C229 — in the theme mode the card carries the
* "Tema"/"Termo exato" seals and the real evidence block ("Trecho mais próximo
* do tema") instead of the highlighted excerpt.
*/
export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewModel }) => {
const { excerpt, thumbnailUrl, watchHref } = speech
@ -29,23 +36,24 @@ export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewM
{ key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS },
]
const hasChips = Boolean(speech.topics.length || speech.scopes.length)
const isTheme = speech.semanticMatch
return (
<article className="grid gap-4 rounded-xl border border-border p-4 md:grid-cols-[152px_minmax(0,1fr)_auto]">
<article className="grid gap-4 rounded-xl border border-border p-4 md:grid-cols-[128px_minmax(0,1fr)_auto]">
<div className="relative">
{thumbnailUrl ? (
<SpeechResultThumbnail
href={watchHref}
src={thumbnailUrl}
label={`Ver fala: ${speech.title}`}
className="aspect-video w-full md:w-[152px]"
fullWidthOnMobile
/>
) : (
<Link
href={watchHref}
aria-hidden="true"
tabIndex={-1}
className="relative grid aspect-video w-full place-items-center overflow-hidden rounded-lg bg-stone-200 text-stone-500 md:w-[152px]"
className="relative grid h-28 w-full place-items-center overflow-hidden rounded-lg bg-stone-200 text-stone-500 md:h-20 md:w-32"
>
<VideoIcon className="size-7" aria-hidden="true" />
<span className="absolute bottom-2 text-[10px] font-semibold">SEM CAPA</span>
@ -62,13 +70,23 @@ export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewM
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{isTheme ? <SpeechSemanticBadges matchedTextSearch={speech.matchedTextSearch} /> : null}
{!isTheme && speech.literalFallback ? <SpeechLiteralMatchBadge /> : null}
<div
className={`flex flex-wrap items-center gap-2 ${isTheme || speech.literalFallback ? 'mt-2' : ''}`}
>
<WebSpeechPlatformPill platform={speech.platform.value} label={speech.platform.label} />
<span className="text-xs text-muted-foreground">{metaLine}</span>
</div>
<h3 className="mt-2 text-sm font-semibold">{speech.title}</h3>
<SpeechExcerpt excerpt={excerpt} className="mt-2 text-sm leading-6 text-foreground/90" />
{isTheme ? (
<div className="mt-2">
<SpeechSemanticProvenance excerpt={excerpt} />
</div>
) : (
<SpeechExcerpt excerpt={excerpt} className="mt-2 text-sm leading-6 text-foreground/90" />
)}
{hasChips ? (
<div className="mt-2 flex flex-wrap gap-1.5">

View file

@ -20,7 +20,9 @@ const ACERVO_SORT_SET = new Set<string>(ACERVO_SORT_OPTIONS.map(({ value }) => v
const firstParam = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
/** Only the non-default orders are meaningful; anything else (and `recentes`) is the default. */
/**
* Only the non-default orders are meaningful; anything else (and `recentes`) is the default.
*/
export const parseAcervoSort = (raw: string | string[] | undefined): AcervoSortKey | undefined => {
const value = firstParam(raw)
return value && value !== 'recentes' && ACERVO_SORT_SET.has(value)
@ -28,6 +30,34 @@ export const parseAcervoSort = (raw: string | string[] | undefined): AcervoSortK
: undefined
}
/**
* C229 — the ordering vocabulary of the theme mode of the speech acervo:
* relevance is the default (never serialized) and the explicit date/duration
* order must stay askable. Recordings and the public Central keep
* `ACERVO_SORT_OPTIONS` — their theme mode is not the semantic engine.
*/
export const ACERVO_THEME_SORT_OPTIONS = [
{ value: 'relevancia', label: 'Mais relevantes' },
...ACERVO_SORT_OPTIONS,
] as const
export type AcervoThemeSortKey = (typeof ACERVO_THEME_SORT_OPTIONS)[number]['value']
const ACERVO_THEME_SORT_SET = new Set<string>(ACERVO_THEME_SORT_OPTIONS.map(({ value }) => value))
/**
* Theme-aware parse: `relevancia` (and anything unknown) parses away as the
* default, while `recentes` is a REAL state here — the exact-mode default has
* to stay explicitly askable after relevance became the theme default.
*/
export const parseAcervoThemeSort = (
raw: string | string[] | undefined,
): AcervoSortKey | undefined => {
const value = firstParam(raw)
if (!value || value === 'relevancia' || !ACERVO_THEME_SORT_SET.has(value)) return undefined
return value as AcervoSortKey
}
/** Duration orders only make sense over rows with a measured duration. */
export const acervoSortIsDuration = (sort: AcervoSortKey | undefined): boolean =>
sort === 'duracao_maior' || sort === 'duracao_menor'

View file

@ -61,7 +61,7 @@ const buildNormalizedIndex = (text: string): NormalizedIndex => {
}
/** Normalized search terms (words) of the query, in order, duplicates kept out. */
export const speechSearchTerms = (query: string): string[] => [
const speechSearchTerms = (query: string): string[] => [
...new Set(
normalizeForSearch(query)
.split(' ')

297
src/lib/speechSemantic.ts Normal file
View file

@ -0,0 +1,297 @@
/**
* C229 — pure semantic engine of the speech acervo. The index stores
* L2-normalized vectors per speech and per retrieval unit (ASR segment or text
* window); this module owns the vector math, the window splitter used when a
* speech has no segments, the deterministic content hash of the index and the
* ranking rules (threshold, relevance default, explicit order override). No IO,
* Payload or Next: the loader and the index CLI share it and the unit tests
* pin it without a network.
*/
/** The provisional cut-off of the theme mode — calibrated by `acervo:index --probe`. */
export const SPEECH_SEMANTIC_MIN_COSINE = 0.4
/** Text windows of speeches without ASR segments: ~1200 chars, ~200 of overlap. */
const SPEECH_WINDOW_SIZE_CHARS = 1200
const SPEECH_WINDOW_OVERLAP_CHARS = 200
export type SpeechSemanticVector = readonly number[]
export const dotProduct = (a: SpeechSemanticVector, b: SpeechSemanticVector): number => {
if (a.length !== b.length) return 0
let sum = 0
for (let index = 0; index < a.length; index += 1) sum += a[index] * b[index]
return sum
}
const vectorNorm = (vector: SpeechSemanticVector): number => Math.sqrt(dotProduct(vector, vector))
/** L2-normalizes; a zero/degenerate vector normalizes to zeros (never NaN). */
export const normalizeVector = (vector: SpeechSemanticVector): number[] => {
const norm = vectorNorm(vector)
if (norm === 0) return vector.map(() => 0)
return vector.map((value) => value / norm)
}
/**
* Cosine of two vectors. The index normalizes on write, but this stays a real
* cosine so a non-normalized fixture never turns into a silent dot product.
*/
export const cosineSimilarity = (a: SpeechSemanticVector, b: SpeechSemanticVector): number => {
if (a.length !== b.length || a.length === 0) return 0
const normA = vectorNorm(a)
const normB = vectorNorm(b)
if (normA === 0 || normB === 0) return 0
return dotProduct(a, b) / (normA * normB)
}
/** The speech-level vector: element-wise mean of its unit vectors, normalized. */
export const meanPoolVectors = (vectors: readonly SpeechSemanticVector[]): number[] => {
const usable = vectors.filter((vector) => vector.length > 0)
if (usable.length === 0) return []
const dimensions = usable[0].length
const sums = new Array<number>(dimensions).fill(0)
let counted = 0
for (const vector of usable) {
if (vector.length !== dimensions) continue
for (let index = 0; index < dimensions; index += 1) sums[index] += vector[index]
counted += 1
}
if (counted === 0) return []
return normalizeVector(sums.map((sum) => sum / counted))
}
export type SpeechTextWindow = {
order: number
start: number
end: number
text: string
}
/**
* Splits a text into overlapping windows without cutting a word in half: the
* end retreats to the last whitespace inside the window when possible. The
* SAME function builds the index and re-reads the evidence window, so `order`
* is stable.
*/
export const speechTextWindows = (
text: string,
{
size = SPEECH_WINDOW_SIZE_CHARS,
overlap = SPEECH_WINDOW_OVERLAP_CHARS,
}: { size?: number; overlap?: number } = {},
): SpeechTextWindow[] => {
if (!Number.isInteger(size) || size <= 0) return []
const safeOverlap = Math.min(Math.max(overlap, 0), size - 1)
const windows: SpeechTextWindow[] = []
let start = 0
while (start < text.length) {
let end = Math.min(start + size, text.length)
if (end < text.length) {
const pivot = text.lastIndexOf(' ', end)
if (pivot > start) end = pivot
}
const chunk = text.slice(start, end).trim()
if (chunk) windows.push({ order: windows.length, start, end, text: chunk })
if (end >= text.length) break
start = Math.max(end - safeOverlap, start + 1)
}
return windows
}
/**
* FNV-1a over `model\0text` — the staleness key of one index row. Cheap and
* deterministic; it only decides whether a re-import must re-embed.
*/
export const speechEmbeddingHash = (text: string, model: string): string => {
const source = `${model}\u0000${text}`
let hash = 0x811c9dc5
for (let index = 0; index < source.length; index += 1) {
hash ^= source.charCodeAt(index)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
export type SpeechSemanticCandidate = {
id: number
/** Câmara wall-clock string; the only tiebreak of the relevance order. */
speechAt: string
durationSeconds?: number | null
vector: SpeechSemanticVector | null
}
export type SpeechSemanticHit = {
id: number
speechAt: string
durationSeconds: number | null
score: number
}
/** Newest first; id breaks exact ties so the order is total and stable. */
const byDateDesc = (a: SpeechSemanticHit, b: SpeechSemanticHit): number =>
a.speechAt === b.speechAt ? a.id - b.id : a.speechAt < b.speechAt ? 1 : -1
/**
* Similarity ranking with the honest cut-off: candidates without a usable
* vector (wrong dimension included) and hits below `minScore` are dropped, the
* rest sorts by score desc with the date (and then the id) as tiebreak.
*/
export const rankSemanticHits = (
query: SpeechSemanticVector,
candidates: readonly SpeechSemanticCandidate[],
{ minScore = SPEECH_SEMANTIC_MIN_COSINE }: { minScore?: number } = {},
): SpeechSemanticHit[] => {
const hits: SpeechSemanticHit[] = []
for (const candidate of candidates) {
if (!candidate.vector || candidate.vector.length !== query.length) continue
const score = cosineSimilarity(query, candidate.vector)
if (score < minScore) continue
hits.push({
id: candidate.id,
speechAt: candidate.speechAt,
durationSeconds: candidate.durationSeconds ?? null,
score,
})
}
return hits.sort((a, b) => b.score - a.score || byDateDesc(a, b))
}
export type SpeechSemanticSort = 'relevancia' | 'recentes' | 'duracao_maior' | 'duracao_menor'
/**
* The explicit user order overrides relevance without re-filtering: the hits
* already passed the threshold, and `Array.sort` is stable, so equal dates or
* durations keep the relevance order.
*/
export const sortSemanticHits = (
hits: readonly SpeechSemanticHit[],
sort: SpeechSemanticSort,
): SpeechSemanticHit[] => {
const sorted = [...hits]
switch (sort) {
case 'recentes':
return sorted.sort(byDateDesc)
case 'duracao_maior':
return sorted.sort(
(a, b) => (b.durationSeconds ?? -1) - (a.durationSeconds ?? -1) || byDateDesc(a, b),
)
case 'duracao_menor':
return sorted.sort(
(a, b) =>
(a.durationSeconds ?? Number.POSITIVE_INFINITY) -
(b.durationSeconds ?? Number.POSITIVE_INFINITY) || byDateDesc(a, b),
)
default:
return sorted
}
}
export type SpeechSemanticChunk = {
/** Stable identity of the row inside its speech (the embedding row id). */
key: number
vector: SpeechSemanticVector | null
}
/**
* The evidence unit of one speech: the chunk closest to the query. `null` when
* the speech has no usable chunk (the caller falls back to the lexical
* excerpt). Ties resolve to the lowest key so the card never jumps.
*/
export const pickBestSemanticChunk = (
query: SpeechSemanticVector,
chunks: readonly SpeechSemanticChunk[],
): { key: number; score: number } | null => {
let best: { key: number; score: number } | null = null
for (const chunk of chunks) {
if (!chunk.vector || chunk.vector.length !== query.length) continue
const score = cosineSimilarity(query, chunk.vector)
if (!best || score > best.score || (score === best.score && chunk.key < best.key)) {
best = { key: chunk.key, score }
}
}
return best
}
/**
* The raw text the index and the evidence reader share for speeches without
* ASR segments: the official transcript first (the real spelling), then the
* summary, then the normalized search text as the last honest resort.
*/
export const speechSemanticSourceText = (speech: {
officialTranscript?: string | null
summary?: string | null
searchText?: string | null
}): string | null => {
for (const candidate of [speech.officialTranscript, speech.summary, speech.searchText]) {
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
}
return null
}
type SpeechIndexUnit = {
kind: 'segment' | 'window'
/** Segment ordinal (the collection's) or the window index. */
order: number
startSeconds: number | null
text: string
}
export type SpeechIndexPlan = {
units: SpeechIndexUnit[]
/** Hash of every unit text + model: the CLI's skip key. */
speechHash: string
}
export type SpeechIndexSegment = {
order: number
startSeconds: number
text: string
}
/**
* The retrieval units of one speech: the ASR segments when they exist, else
* fixed text windows of the source text. `null` when the speech has no text at
* all (the caller reports it as skipped). Pure: the index CLI and its tests
* share it, and the evidence reader re-derives windows with the same splitter.
*/
export const planSpeechIndex = (
speech: {
officialTranscript?: string | null
summary?: string | null
searchText?: string | null
segments?: readonly SpeechIndexSegment[]
},
{ model }: { model: string },
): SpeechIndexPlan | null => {
const units: SpeechIndexUnit[] = []
const segments = speech.segments ?? []
if (segments.length > 0) {
for (const segment of segments) {
const text = segment.text.trim()
if (!text) continue
units.push({
kind: 'segment',
order: segment.order,
startSeconds: segment.startSeconds,
text,
})
}
} else {
const sourceText = speechSemanticSourceText(speech)
if (sourceText) {
for (const window of speechTextWindows(sourceText)) {
units.push({ kind: 'window', order: window.order, startSeconds: null, text: window.text })
}
}
}
if (units.length === 0) return null
const speechHash = speechEmbeddingHash(
units.map((unit) => `${unit.kind}:${unit.order}:${unit.text}`).join('\n'),
model,
)
return { units, speechHash }
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
/**
* C229 — semantic index of the speech acervo (`speechEmbedding`).
*
* Hand-reduced from the generated diff: the two 2026-09-24 snapshots
* (`add_content_piece_link_failure_reason`, `add_content_piece_people`) lost
* the objects of the C215/C219 migrations, so the generator tried to recreate
* `internet_speech_media`, the recording facet tables and the speech origin
* columns — all of which already exist wherever the chain applied. The snapshot
* written next to this file is the current full config state, so future
* migrations diff from a healed baseline; this SQL stays scoped to the new
* table.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_speech_embedding_kind" AS ENUM('speech', 'segment', 'window');
CREATE TABLE "speech_embedding" (
"id" serial PRIMARY KEY NOT NULL,
"speech_id" integer NOT NULL,
"kind" "enum_speech_embedding_kind" NOT NULL,
"order" numeric,
"start_seconds" numeric,
"model" varchar NOT NULL,
"dimensions" numeric NOT NULL,
"content_hash" varchar NOT NULL,
"vector" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "speech_embedding_id" integer;
ALTER TABLE "speech_embedding" ADD CONSTRAINT "speech_embedding_speech_id_speech_id_fk" FOREIGN KEY ("speech_id") REFERENCES "public"."speech"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "speech_embedding_speech_idx" ON "speech_embedding" USING btree ("speech_id");
CREATE INDEX "speech_embedding_kind_idx" ON "speech_embedding" USING btree ("kind");
CREATE INDEX "speech_embedding_updated_at_idx" ON "speech_embedding" USING btree ("updated_at");
CREATE INDEX "speech_embedding_created_at_idx" ON "speech_embedding" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_speech_embedding_fk" FOREIGN KEY ("speech_embedding_id") REFERENCES "public"."speech_embedding"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_speech_embedding_id_idx" ON "payload_locked_documents_rels" USING btree ("speech_embedding_id");`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "speech_embedding" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_speech_embedding_fk";
DROP INDEX IF EXISTS "payload_locked_documents_rels_speech_embedding_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "speech_embedding_id";
DROP TABLE IF EXISTS "speech_embedding" CASCADE;
DROP TYPE IF EXISTS "public"."enum_speech_embedding_kind";`)
}

View file

@ -8023,6 +8023,163 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.speech_embedding": {
"name": "speech_embedding",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"speech_id": {
"name": "speech_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "enum_speech_embedding_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"order": {
"name": "order",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"start_seconds": {
"name": "start_seconds",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"dimensions": {
"name": "dimensions",
"type": "numeric",
"primaryKey": false,
"notNull": true
},
"content_hash": {
"name": "content_hash",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"vector": {
"name": "vector",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_at": {
"name": "created_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"speech_embedding_speech_idx": {
"name": "speech_embedding_speech_idx",
"columns": [
{
"expression": "speech_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"speech_embedding_kind_idx": {
"name": "speech_embedding_kind_idx",
"columns": [
{
"expression": "kind",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"speech_embedding_updated_at_idx": {
"name": "speech_embedding_updated_at_idx",
"columns": [
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"speech_embedding_created_at_idx": {
"name": "speech_embedding_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"speech_embedding_speech_id_speech_id_fk": {
"name": "speech_embedding_speech_id_speech_id_fk",
"tableFrom": "speech_embedding",
"tableTo": "speech",
"columnsFrom": [
"speech_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.internet_speech_media": {
"name": "internet_speech_media",
"schema": "",
@ -16045,6 +16202,12 @@
"primaryKey": false,
"notNull": false
},
"speech_embedding_id": {
"name": "speech_embedding_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"internet_speech_media_id": {
"name": "internet_speech_media_id",
"type": "integer",
@ -16557,6 +16720,21 @@
"method": "btree",
"with": {}
},
"payload_locked_documents_rels_speech_embedding_id_idx": {
"name": "payload_locked_documents_rels_speech_embedding_id_idx",
"columns": [
{
"expression": "speech_embedding_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_locked_documents_rels_internet_speech_media_id_idx": {
"name": "payload_locked_documents_rels_internet_speech_media_id_idx",
"columns": [
@ -17220,6 +17398,19 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"payload_locked_documents_rels_speech_embedding_fk": {
"name": "payload_locked_documents_rels_speech_embedding_fk",
"tableFrom": "payload_locked_documents_rels",
"tableTo": "speech_embedding",
"columnsFrom": [
"speech_embedding_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"payload_locked_documents_rels_internet_speech_media_fk": {
"name": "payload_locked_documents_rels_internet_speech_media_fk",
"tableFrom": "payload_locked_documents_rels",
@ -19164,6 +19355,15 @@
"publishing"
]
},
"public.enum_speech_embedding_kind": {
"name": "enum_speech_embedding_kind",
"schema": "public",
"values": [
"speech",
"segment",
"window"
]
},
"public.enum_reel_feature": {
"name": "enum_reel_feature",
"schema": "public",

View file

@ -86,6 +86,7 @@ import * as migration_20260924_040210_add_recording_facets from './20260924_0402
import * as migration_20260924_042028_add_web_speech_origin from './20260924_042028_add_web_speech_origin';
import * as migration_20260924_045625_add_content_piece_link_failure_reason from './20260924_045625_add_content_piece_link_failure_reason';
import * as migration_20260924_123940_add_content_piece_people from './20260924_123940_add_content_piece_people';
import * as migration_20260926_034118_add_speech_embedding from './20260926_034118_add_speech_embedding';
import * as migration_20260926_062039_add_archive_photo from './20260926_062039_add_archive_photo';
export const migrations = [
@ -529,6 +530,11 @@ export const migrations = [
down: migration_20260924_123940_add_content_piece_people.down,
name: '20260924_123940_add_content_piece_people',
},
{
up: migration_20260926_034118_add_speech_embedding.up,
down: migration_20260926_034118_add_speech_embedding.down,
name: '20260926_034118_add_speech_embedding'
},
{
up: migration_20260926_062039_add_archive_photo.up,
down: migration_20260926_062039_add_archive_photo.down,

View file

@ -89,6 +89,7 @@ export interface Config {
speech: Speech;
speechSegment: SpeechSegment;
speechCut: SpeechCut;
speechEmbedding: SpeechEmbedding;
internetSpeechMedia: InternetSpeechMedia;
reel: Reel;
reelMedia: ReelMedia;
@ -145,6 +146,7 @@ export interface Config {
speech: SpeechSelect<false> | SpeechSelect<true>;
speechSegment: SpeechSegmentSelect<false> | SpeechSegmentSelect<true>;
speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>;
speechEmbedding: SpeechEmbeddingSelect<false> | SpeechEmbeddingSelect<true>;
internetSpeechMedia: InternetSpeechMediaSelect<false> | InternetSpeechMediaSelect<true>;
reel: ReelSelect<false> | ReelSelect<true>;
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
@ -1141,6 +1143,46 @@ export interface SpeechCut {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "speechEmbedding".
*/
export interface SpeechEmbedding {
id: number;
speech: number | Speech;
kind: 'speech' | 'segment' | 'window';
/**
* Posição do trecho na fala; vazio no vetor da fala.
*/
order?: number | null;
/**
* Início do segmento quando conhecido; vazio na janela de texto.
*/
startSeconds?: number | null;
/**
* Modelo de embedding que gerou o vetor (muda ⇒ reindexar).
*/
model: string;
dimensions: number;
/**
* Hash do texto + modelo; decide o pulo na reindexação.
*/
contentHash: string;
/**
* Embedding L2-normalizado da unidade.
*/
vector:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* Reels de tutorial da assessoria. Os arquivos são privados e só abrem com login da campanha; publicar não dispara nada no Instagram.
*
@ -2230,6 +2272,10 @@ export interface PayloadLockedDocument {
relationTo: 'speechCut';
value: number | SpeechCut;
} | null)
| ({
relationTo: 'speechEmbedding';
value: number | SpeechEmbedding;
} | null)
| ({
relationTo: 'internetSpeechMedia';
value: number | InternetSpeechMedia;
@ -2867,6 +2913,22 @@ export interface SpeechCutSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "speechEmbedding_select".
*/
export interface SpeechEmbeddingSelect<T extends boolean = true> {
speech?: T;
kind?: T;
order?: T;
startSeconds?: T;
model?: T;
dimensions?: T;
contentHash?: T;
vector?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "internetSpeechMedia_select".
@ -3870,6 +3932,7 @@ export interface TaskCreateCollectionExport {
| 'speech'
| 'speechSegment'
| 'speechCut'
| 'speechEmbedding'
| 'internetSpeechMedia'
| 'reel'
| 'reelMedia'

View file

@ -47,6 +47,7 @@ import { ShareLink } from './collections/ShareLink'
import { Signature } from './collections/Signature'
import { Speech } from './collections/Speech'
import { SpeechCut } from './collections/SpeechCut'
import { SpeechEmbedding } from './collections/SpeechEmbedding'
import { SpeechSegment } from './collections/SpeechSegment'
import { StateDeputy } from './collections/StateDeputy'
import { Subscription } from './collections/Subscription'
@ -130,6 +131,7 @@ export default buildConfig({
Speech,
SpeechSegment,
SpeechCut,
SpeechEmbedding,
InternetSpeechMedia,
Reel,
ReelMedia,

View file

@ -0,0 +1,134 @@
import 'server-only'
import { normalizeVector, type SpeechSemanticVector } from '@/lib/speechSemantic'
/**
* C229 — the embeddings network seam of the speech acervo, deliberately the
* only place the app/index CLI talks to Deep Infra for sense. Same provider and
* key as the ASR (`deepInfraTranscribe.ts`), OpenAI-compatible endpoint and the
* same contract: never throws — a missing key, timeout, provider error or
* malformed output resolves to `null` and the caller degrades honestly.
*
* The model is BAAI/bge-m3 (multilingual, 1024 dims, 8192 tokens) at
* US$0.010/1M tokens (DeepInfra public price, 2026-09-25); the vectors are
* L2-normalized here so the index and the query share the same scale and the
* pure ranking can stay a cosine.
*/
export const DEEPINFRA_EMBED_URL = 'https://api.deepinfra.com/v1/openai/embeddings'
export const DEEPINFRA_EMBED_MODEL = 'BAAI/bge-m3'
export const DEEPINFRA_EMBED_DIMENSIONS = 1024
export const DEEPINFRA_EMBED_COST_PER_MILLION_TOKENS_USD = 0.01
/** Cost guard per text: a window is ~1200 chars; anything longer is truncated. */
export const EMBED_TEXT_MAX_CHARS = 2000
export const EMBED_BATCH_SIZE = 32
const EMBED_TIMEOUT_MS = 30_000
const QUERY_TIMEOUT_MS = 8_000
export type SpeechTextsEmbedding = {
vectors: SpeechSemanticVector[]
promptTokens: number
}
/** The injectable query boundary (tests pass a deterministic resolver). */
export type SpeechQueryEmbeddingResolver = (query: string) => Promise<SpeechSemanticVector | null>
type EmbedResponse = {
data?: unknown
usage?: { prompt_tokens?: unknown }
}
/** Parses one OpenAI-shaped batch and returns the vectors in input order. */
const parseBatch = (
payload: unknown,
expected: number,
): { vectors: number[][]; promptTokens: number } | null => {
const { data, usage } = (payload ?? {}) as EmbedResponse
if (!Array.isArray(data) || data.length !== expected) return null
const byIndex = new Array<number[] | null>(expected).fill(null)
for (const item of data) {
if (!item || typeof item !== 'object') return null
const { index, embedding } = item as { index?: unknown; embedding?: unknown }
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= expected) {
return null
}
if (!Array.isArray(embedding) || embedding.length !== DEEPINFRA_EMBED_DIMENSIONS) return null
if (!embedding.every((value) => typeof value === 'number' && Number.isFinite(value))) {
return null
}
byIndex[index] = embedding as number[]
}
if (byIndex.some((vector) => vector === null)) return null
const promptTokens = typeof usage?.prompt_tokens === 'number' ? usage.prompt_tokens : 0
return { vectors: byIndex.map((vector) => normalizeVector(vector ?? [])), promptTokens }
}
/**
* Embeds texts in `EMBED_BATCH_SIZE` batches, in input order. Returns `null`
* when the mechanism is unavailable or any batch comes back malformed — the
* index CLI never persists a partial/mixed index from a failed run.
*/
export const embedSpeechTexts = async (
texts: readonly string[],
{ timeoutMs = EMBED_TIMEOUT_MS }: { timeoutMs?: number } = {},
): Promise<SpeechTextsEmbedding | null> => {
if (texts.length === 0) return { vectors: [], promptTokens: 0 }
const apiKey = process.env.DEEPINFRA_API_KEY
if (!apiKey) return null
const batches: string[][] = []
for (let start = 0; start < texts.length; start += EMBED_BATCH_SIZE) {
batches.push(
texts
.slice(start, start + EMBED_BATCH_SIZE)
.map((text) => text.slice(0, EMBED_TEXT_MAX_CHARS)),
)
}
const vectors: number[][] = []
let promptTokens = 0
for (const input of batches) {
try {
const response = await fetch(DEEPINFRA_EMBED_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: DEEPINFRA_EMBED_MODEL,
input,
encoding_format: 'float',
}),
signal: AbortSignal.timeout(timeoutMs),
})
if (!response.ok) return null
const parsed = parseBatch(await response.json(), input.length)
if (!parsed) return null
vectors.push(...parsed.vectors)
promptTokens += parsed.promptTokens
} catch {
return null
}
}
return { vectors, promptTokens }
}
/**
* The single query embedding of a theme search. A blank query or an
* unavailable provider resolves to `null` (the loader degrades to the literal
* search with the discreet notice).
*/
export const embedSpeechQuery: SpeechQueryEmbeddingResolver = async (query) => {
const trimmed = query.trim()
if (!trimmed) return null
const embedded = await embedSpeechTexts([trimmed], { timeoutMs: QUERY_TIMEOUT_MS })
return embedded?.vectors[0] ?? null
}

View file

@ -269,7 +269,7 @@ const toExcerptViewModel = ({ candidate, motivo }: SelectedCandidate) => ({
...(motivo ? { motivo } : {}),
url: buildWatchHref(
candidate.speechId,
candidate.excerpt,
candidate.excerpt.startSeconds,
candidate.excerpt.matchedTerms.join(' '),
),
})

View file

@ -0,0 +1,313 @@
import 'server-only'
import type { Payload } from 'payload'
import {
cosineSimilarity,
meanPoolVectors,
planSpeechIndex,
type SpeechIndexPlan,
type SpeechIndexSegment,
} from '@/lib/speechSemantic'
import { DEEPINFRA_EMBED_MODEL, embedSpeechTexts } from '@/utilities/ai/deepInfraEmbed'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
/**
* C229 — the index owner: plans the retrieval units of a speech (pure), embeds
* the stale ones in one provider call and replaces the rows in a transaction.
* The CLI is the only production caller (a trusted actor, `overrideAccess:
* true`); the loader only reads. Idempotent by the speech-level `contentHash`
* (unit texts + model), so a re-run after an import only pays for what changed.
*/
export type SpeechIndexSource = {
id: number
officialTranscript?: string | null
summary?: string | null
searchText?: string | null
segments: readonly SpeechIndexSegment[]
}
type PlannedSpeechIndex = {
source: SpeechIndexSource
plan: SpeechIndexPlan
}
export type SpeechIndexPlanBatch = {
/** Speeches whose units must be embedded (hash/model changed). */
stale: PlannedSpeechIndex[]
/** Speech-level hash already matches the plan. */
upToDate: number
/** No usable text at all. */
skippedNoText: number
units: number
}
export type SpeechIndexBatchResult = {
indexed: number
upToDate: number
skippedNoText: number
failed: number
units: number
promptTokens: number
}
export type SpeechIndexPlanDeps = {
force?: boolean
}
export type SpeechIndexDeps = SpeechIndexPlanDeps & {
embedTexts?: typeof embedSpeechTexts
}
/** Stored speech-level hashes of the given speeches (the skip key). */
const loadStoredSpeechHashes = async (
payload: Payload,
speechIds: readonly number[],
): Promise<Map<number, string>> => {
const stored = new Map<number, string>()
if (speechIds.length === 0) return stored
const result = await payload.find({
collection: 'speechEmbedding',
where: { and: [{ kind: { equals: 'speech' } }, { speech: { in: [...speechIds] } }] },
depth: 0,
limit: 0,
pagination: false,
select: { speech: true, contentHash: true },
// Intentional bypass: the index CLI is a trusted actor with no session.
overrideAccess: true,
})
for (const row of result.docs) {
const speech = row.speech
const speechId = typeof speech === 'number' ? speech : (speech?.id ?? null)
if (typeof speechId === 'number' && row.contentHash) stored.set(speechId, row.contentHash)
}
return stored
}
/** Plans a batch and separates what must be embedded from what is current. */
export const planSpeechIndexBatch = async (
payload: Payload,
sources: readonly SpeechIndexSource[],
{ force = false }: SpeechIndexPlanDeps = {},
): Promise<SpeechIndexPlanBatch> => {
const planned: PlannedSpeechIndex[] = []
let skippedNoText = 0
for (const source of sources) {
const plan = planSpeechIndex(source, { model: DEEPINFRA_EMBED_MODEL })
if (!plan) {
skippedNoText += 1
continue
}
planned.push({ source, plan })
}
const storedHashes = force
? new Map<number, string>()
: await loadStoredSpeechHashes(
payload,
planned.map(({ source }) => source.id),
)
const stale = planned.filter(
({ source, plan }) => storedHashes.get(source.id) !== plan.speechHash,
)
return {
stale,
upToDate: planned.length - stale.length,
skippedNoText,
units: stale.reduce((total, { plan }) => total + plan.units.length, 0),
}
}
/**
* Embeds and persists the stale speeches of a batch. The provider call is ONE
* ordered sequence for the whole batch: a provider failure writes nothing.
* Persistence is per speech (one transaction each), so a database failure
* mid-batch can leave the earlier speeches indexed — `failed` reports it
* honestly and the next run only pays for the rest.
*/
export const indexSpeechSources = async (
payload: Payload,
sources: readonly SpeechIndexSource[],
deps: SpeechIndexDeps = {},
): Promise<SpeechIndexBatchResult> => {
const { embedTexts = embedSpeechTexts, force = false } = deps
const batch = await planSpeechIndexBatch(payload, sources, { force })
const base = {
upToDate: batch.upToDate,
skippedNoText: batch.skippedNoText,
units: 0,
promptTokens: 0,
}
if (batch.stale.length === 0) return { ...base, indexed: 0, failed: 0 }
const texts = batch.stale.flatMap(({ plan }) => plan.units.map((unit) => unit.text))
const embedded = await embedTexts(texts)
if (!embedded) {
return { ...base, indexed: 0, failed: batch.stale.length }
}
// The whole batch was embedded already: the tokens were spent even if a
// later write fails.
const spent = { units: texts.length, promptTokens: embedded.promptTokens }
let cursor = 0
let indexed = 0
for (const { source, plan } of batch.stale) {
const vectors = embedded.vectors.slice(cursor, cursor + plan.units.length)
cursor += plan.units.length
if (vectors.length !== plan.units.length) {
return { ...base, ...spent, indexed, failed: batch.stale.length - indexed }
}
const speechVector = meanPoolVectors(vectors)
await withPayloadTransaction(payload, async ({ req }) => {
await payload.delete({
collection: 'speechEmbedding',
where: { speech: { equals: source.id } },
req,
// Intentional bypass: rebuilding the derived index of a trusted run.
overrideAccess: true,
})
await payload.create({
collection: 'speechEmbedding',
data: {
speech: source.id,
kind: 'speech',
model: DEEPINFRA_EMBED_MODEL,
dimensions: speechVector.length,
contentHash: plan.speechHash,
vector: Array.from(speechVector),
},
req,
// Intentional bypass: same trusted rebuild.
overrideAccess: true,
})
for (const [index, unit] of plan.units.entries()) {
const vector = vectors[index]
if (!vector) continue
await payload.create({
collection: 'speechEmbedding',
data: {
speech: source.id,
kind: unit.kind,
order: unit.order,
startSeconds: unit.startSeconds,
model: DEEPINFRA_EMBED_MODEL,
dimensions: vector.length,
contentHash: `${plan.speechHash}#${unit.kind}:${unit.order}`,
vector: Array.from(vector),
},
req,
// Intentional bypass: same trusted rebuild.
overrideAccess: true,
})
}
})
indexed += 1
}
return {
upToDate: base.upToDate,
skippedNoText: base.skippedNoText,
indexed,
failed: batch.stale.length - indexed,
...spent,
}
}
export type SpeechIndexProbeHit = {
id: number
sourceKey: string
speechAt: string
origin: string
score: number
}
/**
* Read-only calibration of the theme cut-off: ranks every indexed speech
* against the query vector (score included) and returns the top N. The score
* never reaches the UI; this is the CLI-only instrument the runbook uses to
* verify the acceptance themes and tune `SPEECH_SEMANTIC_MIN_COSINE`.
*/
export const probeSpeechIndex = async (
payload: Payload,
queryVector: readonly number[],
{ limit = 10 }: { limit?: number } = {},
): Promise<SpeechIndexProbeHit[]> => {
const embeddings = await payload.find({
collection: 'speechEmbedding',
// Same embedding space as the query: another model's 1024 dims would score
// nonsense (the collection keeps `model` exactly to denounce that).
where: { and: [{ kind: { equals: 'speech' } }, { model: { equals: DEEPINFRA_EMBED_MODEL } }] },
depth: 0,
limit: 0,
pagination: false,
select: { speech: true, vector: true },
// Intentional bypass: the probe is a trusted read with no session.
overrideAccess: true,
})
const vectorBySpeech = new Map<number, number[]>()
for (const row of embeddings.docs) {
const speech = row.speech
const speechId = typeof speech === 'number' ? speech : (speech?.id ?? null)
const vector = Array.isArray(row.vector) ? row.vector : null
if (typeof speechId !== 'number' || !vector || vector.length !== queryVector.length) continue
if (!vector.every((value) => typeof value === 'number' && Number.isFinite(value))) continue
vectorBySpeech.set(speechId, vector as number[])
}
if (vectorBySpeech.size === 0) return []
const speeches = await payload.find({
collection: 'speech',
where: { id: { in: [...vectorBySpeech.keys()] } },
depth: 0,
limit: 0,
pagination: false,
select: { sourceKey: true, speechAt: true, origin: true },
// Intentional bypass: same trusted read.
overrideAccess: true,
})
return speeches.docs
.flatMap((speech) => {
const vector = vectorBySpeech.get(speech.id)
if (!vector) return []
const score = cosineSimilarity(queryVector, vector)
return [
{
id: speech.id,
sourceKey: speech.sourceKey,
speechAt: speech.speechAt,
origin: speech.origin,
score,
},
]
})
.sort((left, right) => right.score - left.score)
.slice(0, Math.max(1, limit))
}
/** Shape shared by the CLI report and the `--dry-run` preview. */
export type SpeechIndexPreview = {
speeches: number
upToDate: number
skippedNoText: number
toIndex: number
units: number
}
export const previewSpeechIndex = (
batch: SpeechIndexPlanBatch,
speeches: number,
): SpeechIndexPreview => ({
speeches,
upToDate: batch.upToDate,
skippedNoText: batch.skippedNoText,
toIndex: batch.stale.length,
units: batch.units,
})

View file

@ -36,7 +36,13 @@ export const durationBucketWhere = (bucket: SpeechDurationBucket): Where => {
}
}
const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
/**
* C229 — the facet-only branches of the acervo. This is the ONE candidate
* boundary of the semantic theme path: the sense search ranks every speech the
* facets allow, attaching no textual branch (the `q` LIKE stays exclusive to
* the literal/degraded path). Also the base of the literal `where` below.
*/
export const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
// C215/C216 — ONE discriminator decides the source: the web list carries
// `source=internet` and reads `origin: web`; everything else is the Câmara
// list (the web speeches are never mixed here). The Fase facet is Câmara-only.
@ -62,48 +68,28 @@ const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
return filters
}
const buildSpeechTextBranches = (q: string, themeTerms: readonly string[] = []): Where[] => {
const buildSpeechTextBranches = (q: string): Where[] => {
const branches: Where[] = []
const seenText = new Set<string>()
const seenKeyword = new Set<string>()
// C192 — the literal query plus the expanded theme terms, each contributing
// the same two branches (normalized text / raw keyword). Duplicates are
// dropped so an expansion that repeats the query does not widen the OR.
for (const term of [q, ...themeTerms]) {
const normalized = normalizeForSearch(term)
if (normalized && !seenText.has(normalized)) {
seenText.add(normalized)
branches.push({ searchText: { like: normalized } })
}
const raw = term.trim()
const lowered = raw.toLowerCase()
if (raw && !seenKeyword.has(lowered)) {
seenKeyword.add(lowered)
branches.push({ keywords: { contains: raw } })
}
}
const normalized = normalizeForSearch(q)
if (normalized) branches.push({ searchText: { like: normalized } })
const raw = q.trim()
if (raw) branches.push({ keywords: { contains: raw } })
return branches
}
/**
* The textual branch: normalized speech text OR a raw official keyword, for the
* query and (C192) every expanded theme term. The keyword `contains` compiles
* to ILIKE `%q%` — case-insensitive, accent still significant. The view-model
* mirror is `lib/speechSearch.speechMatchesSearchTerm`; a semantics change here
* has to land there too.
* The textual branch: normalized speech text OR a raw official keyword. The
* keyword `contains` compiles to ILIKE `%q%` — case-insensitive, accent still
* significant. The view-model mirror is `lib/speechSearch.speechMatchesSearchTerm`;
* a semantics change here has to land there too.
*/
const buildSpeechTextWhere = (q: string, themeTerms: readonly string[] = []): Where => ({
or: buildSpeechTextBranches(q, themeTerms),
const buildSpeechTextWhere = (q: string): Where => ({
or: buildSpeechTextBranches(q),
})
export const buildSpeechListWhere = (
state: SpeechListState,
themeTerms: readonly string[] = [],
): Where => {
export const buildSpeechListWhere = (state: SpeechListState): Where => {
const filters = buildSpeechFacetWhere(state)
if (state.q) filters.push(buildSpeechTextWhere(state.q, themeTerms))
if (state.q) filters.push(buildSpeechTextWhere(state.q))
return filters.length ? { and: filters } : {}
}
@ -111,18 +97,17 @@ export const buildSpeechListWhere = (
/**
* C174 (option B) — the acervo `where` widened to the origin speech of a
* matching cut: the page still paginates by SPEECH, so the origin ids are OR-ed
* into the textual branch (facets stay AND-ed over every row). C192 adds the
* expanded theme terms to that same textual branch; only the literal query
* drives the cut-origin lookup.
* into the textual branch (facets stay AND-ed over every row). The cut-origin
* lookup is lexical (`state.q`) and therefore never runs in the C229 theme
* path — there the semantic engine selects the candidates.
*/
export const buildSpeechListWhereIncludingCutOrigins = (
state: SpeechListState,
originSpeechIds: readonly number[],
themeTerms: readonly string[] = [],
): Where => {
const filters = buildSpeechFacetWhere(state)
if (state.q) {
const branches = buildSpeechTextBranches(state.q, themeTerms)
const branches = buildSpeechTextBranches(state.q)
if (originSpeechIds.length) branches.push({ id: { in: [...originSpeechIds] } })
filters.push({ or: branches })
}

View file

@ -5,7 +5,12 @@
* member equals no filter); year/phase/municipality are data-driven and
* validated structurally.
*/
import { parseAcervoSort, type AcervoSortKey } from '@/lib/acervoListSort'
import {
acervoSortIsDuration,
parseAcervoSort,
parseAcervoThemeSort,
type AcervoSortKey,
} from '@/lib/acervoListSort'
import { ACERVO_SOURCE_INTERNET, parseAcervoSource } from '@/lib/acervoSource'
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
@ -31,10 +36,10 @@ import {
export const speechPageSize = 25
/**
* C192 — search mode of the acervo. `termo` is the literal search (default,
* never serialized so existing deep links stay byte-identical); `tema` asks the
* semantic bridge for related terms. Kept as a single-value param, only
* meaningful alongside `q`.
* C192/C229 — search mode of the acervo. `termo` is the literal search
* (default, never serialized so existing deep links stay byte-identical);
* `tema` asks the sense engine (C229) for related speeches. Kept as a
* single-value param, only meaningful alongside `q`.
*/
export type SpeechSearchMode = 'termo' | 'tema'
@ -76,7 +81,11 @@ export type SpeechListState = {
phases?: string[]
municipalities?: number[]
durations?: SpeechDurationBucket[]
/** C216 — the web list ordering; the Câmara contract has no sort. */
/**
* C216 — the web list ordering; the Câmara contract has no sort. C229 — in
* the theme mode the relevance order is the default (`sort` absent) and
* `recentes` becomes a real state; the exact mode keeps its bytes.
*/
sort?: AcervoSortKey
}
@ -135,7 +144,14 @@ export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListSt
params.duration,
speechDurationSet,
)
const sort = isWeb ? parseAcervoSort(params.sort) : undefined
// C229 — the web sort is mode-aware: the theme mode defaults to relevance
// (`sort` absent) and recognizes `recentes`/`relevancia`; the exact mode
// keeps the C216 rule (only the duration orders are meaningful).
const sort = isWeb
? mode === 'tema'
? parseAcervoThemeSort(params.sort)
: parseAcervoSort(params.sort)
: undefined
return {
...(isWeb ? { source: ACERVO_SOURCE_INTERNET } : {}),
@ -189,9 +205,14 @@ export const serializeCanonicalSpeechListSearchParams = (
params.append('municipality', String(municipality))
}
for (const duration of canonicalState.durations ?? []) params.append('duration', duration)
// C216 — `recentes` is the default and never serialized; the sort belongs to
// the web source only.
if (canonicalState.source === 'internet' && canonicalState.sort) {
// C216 — `recentes` is the exact-mode default and never serialized; C229 —
// in the theme mode relevance took that place, so an explicit `recentes` is
// a real state and serializes. Both rules live on the web source only.
if (
canonicalState.source === 'internet' &&
canonicalState.sort &&
(canonicalState.mode === 'tema' || acervoSortIsDuration(canonicalState.sort))
) {
params.set('sort', canonicalState.sort)
}
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))

View file

@ -3,12 +3,10 @@ import 'server-only'
import type { Payload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { speechSemanticSourceText, type SpeechSemanticVector } from '@/lib/speechSemantic'
import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
import type { CampaignUser, Speech } from '@/payload-types'
import {
expandSpeechSearchTheme,
type SpeechThemeExpansionResolver,
} from '@/utilities/ai/expandSpeechSearchTheme'
import { embedSpeechQuery, type SpeechQueryEmbeddingResolver } from '@/utilities/ai/deepInfraEmbed'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
import { getYouTubeVideoStart } from '@/utilities/socialFeed/youtubeFeed'
@ -27,6 +25,10 @@ import {
type SpeechFilterOptions,
type SpeechListState,
} from '@/utilities/speech/speechListUrl'
import {
loadSpeechSemanticEvidence,
searchSpeechesByMeaning,
} from '@/utilities/speech/speechSemanticSearch'
import {
municipalityIdsOfSpeech,
toSpeechDetailViewModel,
@ -36,7 +38,8 @@ import {
type SpeechDetailRecord,
type SpeechDetailViewModel,
type SpeechListItemViewModel,
type SpeechSegmentRecord,
type SpeechSegmentRecordWithOrder,
type SpeechSemanticEvidence,
type WebSpeechDetailRecord,
type WebSpeechDetailViewModel,
type WebSpeechListItemViewModel,
@ -77,6 +80,7 @@ const speechDetailSelect = {
const segmentSelect = {
speech: true,
order: true,
startSeconds: true,
endSeconds: true,
text: true,
@ -134,36 +138,12 @@ const loadSpeechFilterOptions = async (
}
}
/**
* C216 — the shared theme gate of both sources: expands only for an actor who
* may read the catalog (the `find` is still the final barrier) and only with a
* query. The caller injects the resolver so the tests stay offline.
*/
const resolveSpeechThemeTerms = async ({
state,
role,
expandTheme,
}: {
state: SpeechListState
role: CampaignUser['role']
expandTheme: SpeechThemeExpansionResolver
}): Promise<{ themeTerms: readonly string[]; themeUnavailable: boolean }> => {
const themeRequested = state.mode === 'tema' && Boolean(state.q)
if (!themeRequested || !canReadCommunicationCatalog(role)) {
return { themeTerms: [], themeUnavailable: false }
}
const expansion = await expandTheme(state.q ?? '')
return expansion
? { themeTerms: expansion.terms, themeUnavailable: false }
: { themeTerms: [], themeUnavailable: true }
}
export const loadSegmentsForSpeeches = async (
payload: Payload,
user: CampaignUser,
speechIds: readonly number[],
): Promise<Map<number, SpeechSegmentRecord[]>> => {
const bySpeech = new Map<number, SpeechSegmentRecord[]>()
): Promise<Map<number, SpeechSegmentRecordWithOrder[]>> => {
const bySpeech = new Map<number, SpeechSegmentRecordWithOrder[]>()
if (speechIds.length === 0) return bySpeech
const result = await payload.find({
@ -182,6 +162,7 @@ export const loadSegmentsForSpeeches = async (
const speechId = typeof segment.speech === 'number' ? segment.speech : segment.speech.id
const list = bySpeech.get(speechId) ?? []
list.push({
order: segment.order,
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
@ -191,6 +172,108 @@ export const loadSegmentsForSpeeches = async (
return bySpeech
}
/**
* C229 — the full docs of a page of semantic hits, in the ranking order (a
* Payload `in` does not preserve it) and through the access layer.
*/
const loadSpeechDocsByIds = async (
payload: Payload,
user: CampaignUser,
ids: readonly number[],
select: Record<string, boolean>,
): Promise<Speech[]> => {
if (ids.length === 0) return []
const result = await payload.find({
collection: 'speech',
where: { id: { in: [...ids] } },
depth: 0,
limit: 0,
pagination: false,
select,
user,
overrideAccess: false,
})
const byId = new Map(result.docs.map((doc) => [doc.id, doc as Speech]))
return ids.flatMap((id) => {
const doc = byId.get(id)
return doc ? [doc] : []
})
}
type SemanticSpeechPage = {
speeches: Speech[]
segmentsBySpeech: Map<number, SpeechSegmentRecordWithOrder[]>
evidenceBySpeech: Map<number, SpeechSemanticEvidence>
totalDocs: number
totalPages: number
state: SpeechListState
redirectHref?: string
}
/**
* C229 — the sense page shared by both sources: rank the facet-allowed
* candidates, slice the requested page in memory, load the full docs in
* ranking order and resolve the real passage closest to the theme. Returns
* `null` when the engine must degrade (there are candidates but none is
* indexed); a candidate-less combination is an honest empty page, not a
* degradation — the engine ran and there is nothing to match.
*/
const loadSemanticSpeechPage = async ({
payload,
user,
state,
rawSearchParams,
queryVector,
select,
}: {
payload: Payload
user: CampaignUser
state: SpeechListState
rawSearchParams: SpeechListSearchParams
queryVector: SpeechSemanticVector
select: Record<string, boolean>
}): Promise<SemanticSpeechPage | null> => {
const { hits, candidates, indexedCandidates } = await searchSpeechesByMeaning(
payload,
user,
state,
queryVector,
)
if (candidates > 0 && indexedCandidates === 0) return null
const totalPages = Math.max(1, Math.ceil(hits.length / speechPageSize))
const resolvedUrl = resolveSpeechListUrl(rawSearchParams, totalPages)
const page = resolvedUrl.state.page
const pageHits = hits.slice((page - 1) * speechPageSize, page * speechPageSize)
const speeches = await loadSpeechDocsByIds(
payload,
user,
pageHits.map((hit) => hit.id),
select,
)
const speechIds = speeches.map((speech) => speech.id)
const segmentsBySpeech = await loadSegmentsForSpeeches(payload, user, speechIds)
const evidenceBySpeech = await loadSpeechSemanticEvidence(
payload,
user,
queryVector,
speechIds,
segmentsBySpeech,
new Map(speeches.map((speech) => [speech.id, speechSemanticSourceText(speech)])),
)
return {
speeches,
segmentsBySpeech,
evidenceBySpeech,
totalDocs: hits.length,
totalPages,
state: resolvedUrl.state,
redirectHref: resolvedUrl.redirectHref,
}
}
export type SpeechAcervoPageData = {
rows: SpeechListItemViewModel[]
state: SpeechListState
@ -199,11 +282,12 @@ export type SpeechAcervoPageData = {
totalPages: number
filterOptions: SpeechFilterOptions
/**
* C192 — true when the actor asked for `mode=tema` but the expansion
* mechanism was unavailable; the page shows the discreet fallback notice.
* C229 — true when the actor asked for `mode=tema` but the sense engine was
* unavailable (no provider or no indexed candidate); the page shows the
* discreet fallback notice over the literal results.
*/
themeUnavailable: boolean
/** C192 — true when the expansion actually contributed terms. */
/** C229 — true when the sense engine actually ran this request. */
themeApplied: boolean
}
@ -211,19 +295,68 @@ export const loadSpeechAcervoPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<SpeechListSearchParams> | SpeechListSearchParams,
expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme,
embedQuery: SpeechQueryEmbeddingResolver = embedSpeechQuery,
): Promise<SpeechAcervoPageData> => {
const rawSearchParams = await searchParams
const canonicalUrl = resolveSpeechListUrl(rawSearchParams)
const state = canonicalUrl.state
// C192 — only expand for an actor who may read the catalog (the `find` below
// is still the final, fail-closed barrier) and only with a query.
const { themeTerms, themeUnavailable } = await resolveSpeechThemeTerms({
state,
role: user.role,
expandTheme,
})
// C229 — the theme mode is the sense engine: the query is embedded once and
// the facet-allowed candidates are ranked over the stored speech-level
// vectors. A missing mechanism (no key/provider error) or an empty index
// degrades to the literal search below with the discreet notice; the literal
// `q` LIKE and the C174 cut-origin lookup never run in the semantic path.
const themeRequested = state.mode === 'tema' && Boolean(state.q)
const actorCanRead = canReadCommunicationCatalog(user.role)
if (themeRequested && actorCanRead) {
const queryVector = await embedQuery(state.q ?? '')
if (queryVector) {
const page = await loadSemanticSpeechPage({
payload,
user,
state,
rawSearchParams,
queryVector,
select: { ...speechListSelect, searchText: true },
})
if (page) {
const speechIds = page.speeches.map((speech) => speech.id)
const [cutsBySpeech, filterOptions] = await Promise.all([
loadSpeechCutsForSpeeches(payload, user, speechIds),
loadSpeechFilterOptions(payload, user),
])
const municipalityIds = new Set<number>()
for (const speech of page.speeches) {
for (const id of municipalityIdsOfSpeech(speech)) municipalityIds.add(id)
}
const municipalityLabels = municipalityNameMap(
await loadMunicipalityLabelsByIds(payload, [...municipalityIds]),
)
return {
rows: page.speeches.map((speech) =>
toSpeechListItemViewModel({
speech,
segments: page.segmentsBySpeech.get(speech.id) ?? [],
query: state.q,
municipalityLabels,
cuts: cutsBySpeech.get(speech.id) ?? [],
semanticMatch: true,
semanticEvidence: page.evidenceBySpeech.get(speech.id) ?? null,
}),
),
state: page.state,
redirectHref: page.redirectHref ?? canonicalUrl.redirectHref,
totalDocs: page.totalDocs,
totalPages: page.totalPages,
filterOptions,
themeUnavailable: false,
themeApplied: true,
}
}
}
}
// C174 (option B) — a speech also shows up when one of its cuts matches the
// term by title/description, even if the speech text itself does not.
@ -235,7 +368,7 @@ export const loadSpeechAcervoPageData = async (
limit: speechPageSize,
page: state.page,
sort: '-speechAt',
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds, themeTerms),
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds),
// C174 — the row only needs its normalized text to tell whether it matched
// the term itself (option B); the unsearched list does not pay for it.
select: state.q ? { ...speechListSelect, searchText: true } : speechListSelect,
@ -261,6 +394,7 @@ export const loadSpeechAcervoPageData = async (
await loadMunicipalityLabelsByIds(payload, [...municipalityIds]),
)
const themeUnavailable = themeRequested && actorCanRead
return {
rows: speeches.map((speech) =>
toSpeechListItemViewModel({
@ -269,7 +403,7 @@ export const loadSpeechAcervoPageData = async (
query: state.q,
municipalityLabels,
cuts: cutsBySpeech.get(speech.id) ?? [],
themeTerms,
literalFallback: themeUnavailable,
}),
),
state: resolvedUrl.state,
@ -278,7 +412,7 @@ export const loadSpeechAcervoPageData = async (
totalPages: result.totalPages,
filterOptions,
themeUnavailable,
themeApplied: themeTerms.length > 0,
themeApplied: false,
}
}
@ -299,6 +433,14 @@ const webSpeechListSelect = {
thumbnail: true,
} as const
/** C229 — the theme path needs the source text of the evidence windows. */
const webSpeechThemeSelect = {
...webSpeechListSelect,
searchText: true,
officialTranscript: true,
summary: true,
} as const
const webSpeechDetailSelect = {
...webSpeechListSelect,
sourceUrl: true,
@ -313,9 +455,9 @@ export type WebSpeechAcervoPageData = {
totalDocs: number
totalPages: number
filterOptions: SpeechFilterOptions
/** C216 — true when `mode=tema` was asked but the expansion was unavailable. */
/** C229 — true when `mode=tema` was asked but the sense engine was unavailable. */
themeUnavailable: boolean
/** C216 — true when the expansion actually contributed terms. */
/** C229 — true when the sense engine actually ran this request. */
themeApplied: boolean
}
@ -330,7 +472,7 @@ export const loadWebSpeechAcervoPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<SpeechListSearchParams> | SpeechListSearchParams,
expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme,
embedQuery: SpeechQueryEmbeddingResolver = embedSpeechQuery,
): Promise<WebSpeechAcervoPageData> => {
const rawSearchParams = await searchParams
// The source is forced (the page dispatches on `source=internet`): the
@ -339,11 +481,45 @@ export const loadWebSpeechAcervoPageData = async (
const canonicalUrl = resolveSpeechListUrl(webSourceParams)
const state = canonicalUrl.state
const { themeTerms, themeUnavailable } = await resolveSpeechThemeTerms({
state,
role: user.role,
expandTheme,
})
// C229 — same sense engine as the Câmara source; the degradation contract is
// identical (notice over literal results, never an error).
const themeRequested = state.mode === 'tema' && Boolean(state.q)
const actorCanRead = canReadCommunicationCatalog(user.role)
if (themeRequested && actorCanRead) {
const queryVector = await embedQuery(state.q ?? '')
if (queryVector) {
const page = await loadSemanticSpeechPage({
payload,
user,
state,
rawSearchParams: webSourceParams,
queryVector,
select: webSpeechThemeSelect,
})
if (page) {
const filterOptions = await loadSpeechFilterOptions(payload, user, 'web')
return {
rows: page.speeches.map((speech) =>
toWebSpeechListItemViewModel({
speech: speech as WebSpeechListRecord,
segments: page.segmentsBySpeech.get(speech.id) ?? [],
query: state.q,
semanticMatch: true,
semanticEvidence: page.evidenceBySpeech.get(speech.id) ?? null,
}),
),
state: page.state,
redirectHref: page.redirectHref ?? canonicalUrl.redirectHref,
totalDocs: page.totalDocs,
totalPages: page.totalPages,
filterOptions,
themeUnavailable: false,
themeApplied: true,
}
}
}
}
const result = await payload.find({
collection: 'speech',
@ -351,7 +527,7 @@ export const loadWebSpeechAcervoPageData = async (
limit: speechPageSize,
page: state.page,
sort: webSpeechSortOrder(state),
where: buildSpeechListWhere(state, themeTerms),
where: buildSpeechListWhere(state),
// The row only needs its normalized text to tell whether it matched the
// term itself; the unsearched list does not pay for it.
select: state.q ? { ...webSpeechListSelect, searchText: true } : webSpeechListSelect,
@ -368,13 +544,14 @@ export const loadWebSpeechAcervoPageData = async (
loadSpeechFilterOptions(payload, user, 'web'),
])
const themeUnavailable = themeRequested && actorCanRead
return {
rows: speeches.map((speech) =>
toWebSpeechListItemViewModel({
speech,
segments: segmentsBySpeech.get(speech.id) ?? [],
query: state.q,
themeTerms,
literalFallback: themeUnavailable,
}),
),
state: resolvedUrl.state,
@ -383,7 +560,7 @@ export const loadWebSpeechAcervoPageData = async (
totalPages: result.totalPages,
filterOptions,
themeUnavailable,
themeApplied: themeTerms.length > 0,
themeApplied: false,
}
}
@ -491,6 +668,7 @@ export const loadSpeechDetailPageData = async (
return toSpeechDetailViewModel({
speech: speech as SpeechDetailRecord,
segments: segments.docs.map((segment) => ({
order: segment.order,
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,

View file

@ -0,0 +1,225 @@
import 'server-only'
import type { Payload } from 'payload'
import {
pickBestSemanticChunk,
rankSemanticHits,
sortSemanticHits,
speechTextWindows,
type SpeechSemanticHit,
type SpeechSemanticSort,
type SpeechSemanticVector,
} from '@/lib/speechSemantic'
import type { CampaignUser } from '@/payload-types'
import { DEEPINFRA_EMBED_MODEL } from '@/utilities/ai/deepInfraEmbed'
import { buildSpeechFacetWhere } from '@/utilities/speech/speechListFilters'
import type { SpeechListState } from '@/utilities/speech/speechListUrl'
import type {
SpeechSegmentRecordWithOrder,
SpeechSemanticEvidence,
} from '@/utilities/speech/speechViewModels'
/**
* C229 — the semantic path of the acervo list. The candidates are the speeches
* the facets allow (never the `q` LIKE branches) and the ranking runs in memory
* over the stored `kind: 'speech'` vectors; the page's evidence comes from the
* `segment`/`window` rows, loaded only for the current page. Every read goes
* through the Payload access layer (`user` + `overrideAccess: false`); the
* loader owns the threshold/ordering and the honest degradation.
*/
export type SpeechMeaningSearchResult = {
/** Ranked hits (threshold applied), already in the requested order. */
hits: SpeechSemanticHit[]
/** Facet-allowed speeches before the ranking. */
candidates: number
/** Candidates with a comparable speech-level vector — zero degrades. */
indexedCandidates: number
}
/** Theme mode has no explicit order: relevance is the default (C229/D5). */
const semanticSortOf = (state: SpeechListState): SpeechSemanticSort => state.sort ?? 'relevancia'
const numberVector = (value: unknown): number[] | null => {
if (!Array.isArray(value) || value.length === 0) return null
if (!value.every((item) => typeof item === 'number' && Number.isFinite(item))) return null
return value as number[]
}
/** The index row as this module selects it (never the whole document). */
type EmbeddingRow = {
id: number
speech: number | { id: number } | null
kind?: 'speech' | 'segment' | 'window' | null
order?: number | null
vector?: unknown
}
const speechIdOf = (row: EmbeddingRow): number | null => {
const speech = row.speech
if (typeof speech === 'number') return speech
if (speech && typeof speech === 'object' && typeof speech.id === 'number') return speech.id
return null
}
/** Facet-filtered candidates + the speech-level vectors + the in-memory rank. */
export const searchSpeechesByMeaning = async (
payload: Payload,
user: CampaignUser,
state: SpeechListState,
queryVector: SpeechSemanticVector,
): Promise<SpeechMeaningSearchResult> => {
const candidates = await payload.find({
collection: 'speech',
where: { and: buildSpeechFacetWhere(state) },
depth: 0,
limit: 0,
pagination: false,
select: { speechAt: true, durationSeconds: true },
user,
overrideAccess: false,
})
if (candidates.docs.length === 0) return { hits: [], candidates: 0, indexedCandidates: 0 }
const embeddings = await payload.find({
collection: 'speechEmbedding',
where: {
and: [
{ kind: { equals: 'speech' } },
// Only the current model: a stale vector from another embedding space
// must never be scored against the query (the loader degrades instead).
{ model: { equals: DEEPINFRA_EMBED_MODEL } },
{ speech: { in: candidates.docs.map((doc) => doc.id) } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { speech: true, vector: true },
user,
overrideAccess: false,
})
const vectorBySpeech = new Map<number, number[]>()
for (const row of embeddings.docs) {
const speechId = speechIdOf(row)
const vector = numberVector(row.vector)
if (speechId === null || !vector || vector.length !== queryVector.length) continue
vectorBySpeech.set(speechId, vector)
}
const hits = rankSemanticHits(
queryVector,
candidates.docs.map((doc) => ({
id: doc.id,
speechAt: doc.speechAt,
durationSeconds: doc.durationSeconds ?? null,
vector: vectorBySpeech.get(doc.id) ?? null,
})),
)
return {
hits: sortSemanticHits(hits, semanticSortOf(state)),
candidates: candidates.docs.length,
indexedCandidates: vectorBySpeech.size,
}
}
/**
* Resolves the real passage of one stored unit. A segment row points at the ASR
* segment by `order` (the collection's own ordinal); a window row is recomputed
* with the SAME pure splitter the index used, over the same source text. Both
* fall back to the first unit when the stored reference is gone (a re-import
* that changed the units is the reindex trigger).
*/
const resolveUnitText = (
row: EmbeddingRow,
segments: readonly SpeechSegmentRecordWithOrder[],
sourceText: string | null,
): { text: string; startSeconds: number | null } | null => {
if (row.kind === 'segment') {
const segment = segments.find((item) => item.order === row.order) ?? segments[0]
if (!segment) return null
return { text: segment.text, startSeconds: segment.startSeconds }
}
if (row.kind === 'window') {
if (!sourceText) return null
const windows = speechTextWindows(sourceText)
const window =
(typeof row.order === 'number' ? windows[row.order] : undefined) ?? windows[0] ?? null
return window ? { text: window.text, startSeconds: null } : null
}
return null
}
/**
* The evidence of the current page: for each speech, the stored unit closest to
* the query. Speeches without a usable unit are simply absent from the map (the
* view model falls back to the lexical excerpt).
*/
export const loadSpeechSemanticEvidence = async (
payload: Payload,
user: CampaignUser,
queryVector: SpeechSemanticVector,
speechIds: readonly number[],
segmentsBySpeech: ReadonlyMap<number, readonly SpeechSegmentRecordWithOrder[]>,
sourceTextBySpeech: ReadonlyMap<number, string | null>,
): Promise<Map<number, SpeechSemanticEvidence>> => {
const evidence = new Map<number, SpeechSemanticEvidence>()
if (speechIds.length === 0) return evidence
const rows = await payload.find({
collection: 'speechEmbedding',
where: {
and: [
{ kind: { in: ['segment', 'window'] } },
// Same embedding space only (see `searchSpeechesByMeaning`).
{ model: { equals: DEEPINFRA_EMBED_MODEL } },
{ speech: { in: [...speechIds] } },
],
},
depth: 0,
limit: 0,
pagination: false,
sort: 'order',
select: { speech: true, kind: true, order: true, vector: true },
user,
overrideAccess: false,
})
const rowsBySpeech = new Map<number, EmbeddingRow[]>()
for (const row of rows.docs) {
const speechId = speechIdOf(row)
if (speechId === null) continue
const list = rowsBySpeech.get(speechId) ?? []
list.push(row)
rowsBySpeech.set(speechId, list)
}
for (const speechId of speechIds) {
const speechRows = rowsBySpeech.get(speechId) ?? []
const best = pickBestSemanticChunk(
queryVector,
speechRows.flatMap((row) => {
const vector = numberVector(row.vector)
if (!vector || vector.length !== queryVector.length) return []
return [{ key: row.id, vector }]
}),
)
if (!best) continue
const row = speechRows.find((candidate) => candidate.id === best.key)
if (!row) continue
const resolved = resolveUnitText(
row,
segmentsBySpeech.get(speechId) ?? [],
sourceTextBySpeech.get(speechId) ?? null,
)
if (resolved) evidence.set(speechId, resolved)
}
return evidence
}

View file

@ -17,17 +17,12 @@ import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import {
buildHighlightedExcerpt,
pickMatchingSegment,
speechSearchTerms,
splitHighlightedParts,
type SpeechHighlightedExcerpt,
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
import { speechPosterHref, speechPosterTarget } from '@/lib/speechPoster'
import {
normalizeForSearch,
speechMatchesSearchQuery,
speechMatchesSearchTerm,
} from '@/lib/speechSearch'
import { normalizeForSearch, speechMatchesSearchQuery } from '@/lib/speechSearch'
import {
correctedExcerptOffsetSeconds,
excerptOffsetSeconds,
@ -49,6 +44,12 @@ import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechL
/** ASR segment shape, owned by the C158 excerpt builder (`lib/speechExcerpt`). */
export type SpeechSegmentRecord = SpeechExcerptSegment
/**
* C229 — the segment plus its stored `order`, so the semantic evidence reader
* can resolve the index row back to the real passage text.
*/
export type SpeechSegmentRecordWithOrder = SpeechSegmentRecord & { order: number }
export type SpeechListRecord = {
id: number
speechAt: string
@ -106,11 +107,18 @@ export type SpeechListItemViewModel = {
*/
matchedTextSearch: boolean
/**
* C192 — the expanded theme term that matched the speech text/keywords, or
* null when the search was literal (or the expansion did not surface this
* row). The card uses it to label the result and to explain why it appeared.
* C229 — the semantic engine surfaced this row in the theme mode and the
* excerpt is the real passage closest to the theme. The card uses it to show
* the "Tema" seal and the "Trecho mais próximo do tema" block (no highlight,
* no score); false in the literal/degraded path.
*/
themeMatchTerm: string | null
semanticMatch: boolean
/**
* C229 — the actor asked for the theme mode but the engine was down, so this
* is a literal result of the degraded path; the card labels it "Termo exato"
* (design scene 04) without changing the plain exact search.
*/
literalFallback: boolean
}
export type SpeechDetailSegmentViewModel = {
@ -193,38 +201,12 @@ const scopeViewModels = (speech: SpeechListRecord) =>
(speech.scopes ?? []).map((value) => ({ value, label: speechScopeLabels[value] }))
/**
* C192 — the first expanded theme term that actually surfaced this speech. The
* gate is the `speechMatchesSearchTerm` mirror (the same predicate the `where`
* runs), so the card never claims a theme the search did not use. The evidence
* is the passage that carries the term, or the official keyword when that is
* what matched. Returns undefined when no theme term applies to this row.
* C229 — the semantic evidence of one row: the real passage closest to the
* theme (segment or text window) and its start when the player can seek to it.
*/
const pickThemeMatch = (
speech: SpeechListRecord,
segments: readonly SpeechSegmentRecord[],
themeTerms: readonly string[],
): { term: string; segment?: SpeechSegmentRecord; keyword?: string } | undefined => {
for (const term of themeTerms) {
const trimmed = term.trim()
const normalized = normalizeForSearch(trimmed)
if (!normalized || !speechMatchesSearchTerm(speech, trimmed)) continue
if (normalizeForSearch(speech.searchText ?? '').includes(normalized)) {
const segment =
segments.find((item) => normalizeForSearch(item.text).includes(normalized)) ??
segments.find((item) => {
const text = normalizeForSearch(item.text)
return speechSearchTerms(trimmed).some((word) => text.includes(word))
}) ??
segments[0]
return { term: trimmed, segment }
}
const lowered = trimmed.toLowerCase()
const keyword = (speech.keywords ?? []).find((item) => item.toLowerCase().includes(lowered))
if (keyword) return { term: trimmed, keyword }
}
return undefined
export type SpeechSemanticEvidence = {
text: string
startSeconds: number | null
}
/**
@ -234,11 +216,13 @@ const pickThemeMatch = (
*/
const withSeekQuery = (
href: string,
segment: SpeechSegmentRecord | undefined,
startSeconds: number | null | undefined,
query: string | undefined,
): string => {
const params = new URLSearchParams()
if (segment) params.set('t', String(Math.max(0, Math.floor(segment.startSeconds))))
if (typeof startSeconds === 'number' && Number.isFinite(startSeconds)) {
params.set('t', String(Math.max(0, Math.floor(startSeconds))))
}
if (query) params.set('q', query)
const queryString = params.toString()
return queryString ? `${href}?${queryString}` : href
@ -246,9 +230,9 @@ const withSeekQuery = (
export const buildWatchHref = (
speechId: number,
segment: SpeechSegmentRecord | undefined,
startSeconds: number | null | undefined,
query: string | undefined,
): string => withSeekQuery(`${CAMPAIGN_COMMUNICATION_ACERVO}/${speechId}`, segment, query)
): string => withSeekQuery(`${CAMPAIGN_COMMUNICATION_ACERVO}/${speechId}`, startSeconds, query)
type SpeechCuts = readonly SpeechCutSummaryViewModel[]
@ -261,7 +245,9 @@ export const toSpeechListItemViewModel = ({
query,
municipalityLabels,
cuts = [],
themeTerms = [],
semanticMatch = false,
literalFallback = false,
semanticEvidence = null,
}: {
speech: SpeechListRecord
segments: readonly SpeechSegmentRecord[]
@ -269,8 +255,12 @@ export const toSpeechListItemViewModel = ({
municipalityLabels: ReadonlyMap<number, string>
/** C174 — the cuts of this speech, nested in the result card. */
cuts?: SpeechCuts
/** C192 — expanded theme terms; empty in the literal search. */
themeTerms?: readonly string[]
/** C229 — the sense engine surfaced this row (a hit, by construction). */
semanticMatch?: boolean
/** C229 — literal result of the degraded theme path (design scene 04). */
literalFallback?: boolean
/** C229 — the passage closest to the theme; null when the row has no unit. */
semanticEvidence?: SpeechSemanticEvidence | null
}): SpeechListItemViewModel => {
const matchedSegment = pickMatchingSegment(segments, query)
const q = query?.trim()
@ -280,25 +270,18 @@ export const toSpeechListItemViewModel = ({
Boolean(normalizedQuery) &&
(speech.keywords ?? []).some((keyword) => normalizeForSearch(keyword).includes(normalizedQuery))
// C192 — the theme evidence wins the excerpt so the card's "Por que apareceu"
// block shows the passage that actually matched, even when the speech also
// contains the literal query.
const themeMatch = pickThemeMatch(speech, segments, themeTerms)
// C229 — the semantic evidence wins the excerpt so the theme card shows the
// real passage closest to the sense searched, without any highlight (the
// theme engine never matched a term) and without a score.
const excerptSource =
themeMatch?.segment?.text ??
themeMatch?.keyword ??
semanticEvidence?.text ??
matchedSegment?.text ??
segments[0]?.text ??
speech.summary ??
speech.officialTranscript ??
''
// C192 — the theme passage highlights the whole phrase as one band (the
// per-term split would drop the "à" and break it into pieces).
const excerpt = themeMatch
? buildHighlightedExcerpt(excerptSource, themeMatch.term, { phrase: true })
: buildHighlightedExcerpt(excerptSource, q ?? '')
const excerpt = buildHighlightedExcerpt(excerptSource, semanticMatch ? '' : (q ?? ''))
return {
id: speech.id,
@ -307,23 +290,28 @@ export const toSpeechListItemViewModel = ({
durationLabel: formatSpeechDuration(speech.durationSeconds),
presidingOfficer: speech.presidingOfficer ?? null,
excerpt,
matchKind: matchedSegment
? 'segment'
: keywordMatch
? 'keyword'
: themeMatch
? 'theme'
matchKind: semanticMatch
? 'theme'
: matchedSegment
? 'segment'
: keywordMatch
? 'keyword'
: 'fallback',
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
keywords: speech.keywords ?? [],
municipalities: municipalityViewModels(speech, municipalityLabels),
thumbnailUrl: thumbnailUrlOf(speech),
watchHref: buildWatchHref(speech.id, matchedSegment, q),
watchHref: buildWatchHref(
speech.id,
semanticEvidence?.startSeconds ?? matchedSegment?.startSeconds,
q,
),
officialTextUrl: speech.officialTextUrl ?? null,
cuts: [...cuts],
matchedTextSearch: speechMatchesSearchQuery(speech, query),
themeMatchTerm: themeMatch?.term ?? null,
semanticMatch,
literalFallback,
}
}
@ -403,7 +391,7 @@ export type WebSpeechListRecord = {
scopes?: SpeechScope[] | null
/** Upload relation at depth 0 (id) or depth 1 (object); presence drives the cover. */
thumbnail?: number | { id: number } | null
/** Normalized search text; lets the row mirror the Câmara theme evidence. */
/** Normalized search text; lets the row mirror the Câmara literal match. */
searchText?: string | null
}
@ -430,6 +418,12 @@ export type WebSpeechListItemViewModel = {
/** Detail link that seeks the player to the matching segment (`?t=`) and
* keeps the search term for the transcript highlight (`?q=`). */
watchHref: string
/** C229 — the semantic engine surfaced this row; same contract as the Câmara. */
semanticMatch: boolean
/** C229 — literal result of the degraded theme path (design scene 04). */
literalFallback: boolean
/** C229 — the row also contains the literal query; the card shows "Termo exato". */
matchedTextSearch: boolean
}
export type WebSpeechDetailViewModel = {
@ -464,38 +458,36 @@ const webSpeechTitle = (speech: WebSpeechListRecord): string =>
const buildWebSpeechWatchHref = (
speechId: number,
segment: SpeechSegmentRecord | undefined,
startSeconds: number | null | undefined,
query: string | undefined,
): string => withSeekQuery(campaignInternetSpeechHref(speechId), segment, query)
): string => withSeekQuery(campaignInternetSpeechHref(speechId), startSeconds, query)
/**
* List item of one web speech: platform chip, publication date + duration, the
* matching excerpt (theme evidence preferred, like the Câmara) and the private
* cover href. The cuts/VOD of the Câmara row have no counterpart here.
* matching excerpt (C229 semantic evidence preferred, like the Câmara) and the
* private cover href. The cuts/VOD of the Câmara row have no counterpart here.
*/
export const toWebSpeechListItemViewModel = ({
speech,
segments,
query,
themeTerms = [],
semanticMatch = false,
literalFallback = false,
semanticEvidence = null,
}: {
speech: WebSpeechListRecord
segments: readonly SpeechSegmentRecord[]
query?: string
/** C216 — expanded theme terms; empty in the literal search. */
themeTerms?: readonly string[]
/** C229 — the sense engine surfaced this row (a hit, by construction). */
semanticMatch?: boolean
/** C229 — literal result of the degraded theme path (design scene 04). */
literalFallback?: boolean
/** C229 — the passage closest to the theme; null when the row has no unit. */
semanticEvidence?: SpeechSemanticEvidence | null
}): WebSpeechListItemViewModel => {
const matchedSegment = pickMatchingSegment(segments, query)
const q = query?.trim()
// Same evidence rule as the Câmara VM: a theme passage wins the excerpt so
// the card shows why the row appeared.
const themeMatch = pickThemeMatch(speech, segments, themeTerms)
const excerptSource =
themeMatch?.segment?.text ??
themeMatch?.keyword ??
matchedSegment?.text ??
segments[0]?.text ??
''
const excerptSource = semanticEvidence?.text ?? matchedSegment?.text ?? segments[0]?.text ?? ''
return {
id: speech.id,
@ -503,14 +495,19 @@ export const toWebSpeechListItemViewModel = ({
platform: webPlatformViewModel(speech.platform),
dateLabel: formatSpeechDate(speech.speechAt),
durationLabel: formatSpeechDuration(speech.durationSeconds),
excerpt: themeMatch
? buildHighlightedExcerpt(excerptSource, themeMatch.term, { phrase: true })
: buildHighlightedExcerpt(excerptSource, q ?? ''),
excerpt: buildHighlightedExcerpt(excerptSource, semanticMatch ? '' : (q ?? '')),
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
thumbnailUrl:
relationshipId(speech.thumbnail) === null ? null : campaignInternetSpeechCoverHref(speech.id),
watchHref: buildWebSpeechWatchHref(speech.id, matchedSegment ?? themeMatch?.segment, q),
watchHref: buildWebSpeechWatchHref(
speech.id,
semanticEvidence?.startSeconds ?? matchedSegment?.startSeconds,
q,
),
semanticMatch,
literalFallback,
matchedTextSearch: speechMatchesSearchQuery(speech, query),
}
}

View file

@ -339,7 +339,7 @@ test.describe('communication vertical (C154/C162)', () => {
expect(filteredHtml).toContain('Limpar busca e filtros')
})
test('theme mode degrades honestly to the exact search without the key (C192)', async ({
test('theme mode degrades honestly to the exact search without the key (C229)', async ({
campaign,
campaignRequest,
}) => {
@ -349,8 +349,9 @@ test.describe('communication vertical (C154/C162)', () => {
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
// The e2e environment blanks DEEPSEEK_API_KEY (playwright.config.ts), so the
// expansion is unavailable and the page must fall back with the notice.
// The e2e environment blanks DEEPINFRA_API_KEY (playwright.config.ts), so
// the sense engine is unavailable and the page must fall back with the
// notice.
const themed = await request.get(`/campanha/comunicacao/acervo?q=${marker}&mode=tema`)
expect(themed.status()).toBe(200)
const themedHtml = rendered(await themed.text())
@ -358,22 +359,49 @@ test.describe('communication vertical (C154/C162)', () => {
expect(themedHtml).toContain('Termo exato')
expect(themedHtml).toContain('Por tema')
expect(themedHtml).toContain('data-testid="speech-theme-fallback"')
expect(themedHtml).toContain('A busca por tema está indisponível agora.')
expect(themedHtml).toContain('A busca por sentido está indisponível agora.')
expect(themedHtml).toContain('Tentar por tema novamente')
expect(themedHtml).toContain('Resultados por termo exato')
expect(themedHtml).toContain('data-testid="speech-theme-fallback"')
// The degraded literal card carries the term seal (design scene 04).
expect(themedHtml).toContain('data-testid="speech-literal-badge"')
// The exact results still render and keep the highlighted term.
expect(themedHtml).toContain(marker)
expect(themedHtml).toContain('<mark')
// No theme badge is fabricated.
expect(themedHtml).not.toContain('Por que apareceu')
// No semantic evidence is fabricated.
expect(themedHtml).not.toContain('Trecho mais próximo do tema')
// The default mode stays untouched: no mode param means no notice.
const exact = await request.get(`/campanha/comunicacao/acervo?q=${marker}`)
expect(exact.status()).toBe(200)
const exactHtml = rendered(await exact.text())
expect(exactHtml).toContain(marker)
expect(exactHtml).not.toContain('A busca por tema está indisponível agora.')
expect(exactHtml).not.toContain('A busca por sentido está indisponível agora.')
})
test('the web theme sort canonicalizes without a redirect loop (C229)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('temaordem')
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const recentes = await request.get(
`/campanha/comunicacao/acervo?source=internet&q=${marker}&mode=tema&sort=recentes`,
)
expect(recentes.status()).toBe(200)
const recentesHtml = rendered(await recentes.text())
// The engine is down in e2e (no key): the ordering control honestly shows
// the literal vocabulary and the explicit order survives canonicalization.
expect(recentesHtml).toContain('Mais recentes')
expect(recentesHtml).not.toContain('Mais relevantes')
expect(recentesHtml).toContain('A busca por sentido está indisponível agora.')
// `relevancia` is the theme default: canonicalizes away, no loop.
const relevance = await request.get(
`/campanha/comunicacao/acervo?source=internet&q=${marker}&mode=tema&sort=relevancia`,
)
expect(relevance.status()).toBe(200)
})
test('advisor and leader are redirected away from the acervo', async ({

View file

@ -6,9 +6,12 @@ import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { SpeechTopic } from '@/lib/speechFacets'
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
import { speechPosterHref } from '@/lib/speechPoster'
import config from '@/payload.config'
import { DEEPINFRA_EMBED_MODEL } from '@/utilities/ai/deepInfraEmbed'
import { indexSpeechSources } from '@/utilities/speech/speechEmbeddingIndex'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import {
loadSpeechAcervoPageData,
@ -65,6 +68,42 @@ const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promis
return speech.id
}
/**
* C229 — writes one index row directly (the CLI has its own unit coverage);
* the vectors are tiny and hand-picked so the ranking assertions are exact.
*/
const seedSpeechEmbedding = async (
speechId: number,
kind: 'speech' | 'segment' | 'window',
order: number | null,
vector: number[],
): Promise<void> => {
await payload.create({
collection: 'speechEmbedding',
data: {
speech: speechId,
kind,
order,
model: DEEPINFRA_EMBED_MODEL,
dimensions: vector.length,
contentHash: `${kind}-${order ?? 'root'}-${vector.join(',')}`,
vector,
},
overrideAccess: true,
})
}
/** Facets of a test speech with the topic overridden (the facet boundary test). */
const facetsWithTopics = (topics: SpeechTopic[]): NonNullable<SpeechImportBundle['facets']> => ({
topics,
scopes: ['bahia'],
municipalities: [],
people: [],
programs: [],
projects: [],
classifiedBy: 'gazetteer',
})
/**
* C172 — the detail loader asks YouTube for the session video's start by
* default; the fixtures keep the suite offline by injecting a resolver.
@ -348,42 +387,75 @@ describe('speech acervo (C154)', () => {
).rejects.toBeInstanceOf(SpeechNotFoundError)
})
it('finds a speech by expanded theme terms and explains the match (C192)', async () => {
it('ranks by sense with the closest real passage and no highlight (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `tema${runId}`
const id = await createSpeech({
const closer = await createSpeech({
speechAt: '2020-01-01T10:00',
segments: [
{
startSeconds: 0,
endSeconds: 3,
text: `Garantir o atendimento universal ${marker} na rede pública`,
},
{ startSeconds: 10, endSeconds: 13, text: 'Primeiro trecho da fala' },
{ startSeconds: 40, endSeconds: 43, text: `O embate com a oposição ${runId}` },
],
})
const newer = await createSpeech({
speechAt: '2026-01-01T10:00',
segments: [{ startSeconds: 0, endSeconds: 3, text: 'Um assunto distante' }],
})
await seedSpeechEmbedding(closer, 'speech', null, [1, 0])
await seedSpeechEmbedding(newer, 'speech', null, [0.9, 0.435])
await seedSpeechEmbedding(closer, 'segment', 2, [1, 0])
await seedSpeechEmbedding(closer, 'segment', 1, [0, 1])
await seedSpeechEmbedding(newer, 'segment', 1, [0.9, 0.435])
const { coordinator } = await createUsers()
const expansionCalls: string[] = []
const embedCalls: string[] = []
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: 'defesa do SUS', mode: 'tema' },
async (theme) => {
expansionCalls.push(theme)
return { terms: [`atendimento universal ${marker}`] }
{ q: 'combate à oposição', mode: 'tema' },
async (query) => {
embedCalls.push(query)
return [1, 0]
},
)
expect(expansionCalls).toEqual(['defesa do SUS'])
expect(embedCalls).toEqual(['combate à oposição'])
expect(data.themeUnavailable).toBe(false)
expect(data.themeApplied).toBe(true)
const row = data.rows.find((item) => item.id === id)
expect(row?.matchKind).toBe('theme')
expect(row?.themeMatchTerm).toBe(`atendimento universal ${marker}`)
// Proximity beats the date: the older speech outranks the newer one.
const ids = data.rows.map((row) => row.id)
expect(ids.indexOf(closer)).toBeLessThan(ids.indexOf(newer))
const row = data.rows.find((item) => item.id === closer)
expect(row?.semanticMatch).toBe(true)
expect(row?.matchedTextSearch).toBe(false)
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(true)
expect(row?.matchKind).toBe('theme')
// The evidence is the real segment closest to the query, without highlight.
expect(row?.excerpt.parts.map((part) => part.text).join('')).toContain(
`embate com a oposição ${runId}`,
)
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(false)
expect(row?.watchHref).toContain('t=40')
})
it('degrades to the literal search when the expansion is unavailable (C192)', async () => {
it('returns the honest empty when nothing passes the cut-off (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const id = await createSpeech({ speechAt: '1985-01-01T10:00', year: 1985 })
await seedSpeechEmbedding(id, 'speech', null, [0, 1])
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: `tema distante ${runId}`, mode: 'tema', year: '1985' },
async () => [1, 0],
)
expect(data.rows).toEqual([])
expect(data.themeApplied).toBe(true)
expect(data.themeUnavailable).toBe(false)
})
it('degrades to the literal search when the embedder is unavailable (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `literal${runId}`
const id = await createSpeech({
@ -401,17 +473,198 @@ describe('speech acervo (C154)', () => {
expect(data.themeUnavailable).toBe(true)
expect(data.themeApplied).toBe(false)
const row = data.rows.find((item) => item.id === id)
expect(row?.themeMatchTerm).toBeNull()
expect(row?.semanticMatch).toBe(false)
expect(row?.matchedTextSearch).toBe(true)
})
it('never expands for an actor outside the catalog gate (C192)', async () => {
it('degrades when the candidates have no index rows (C229)', async () => {
const id = await createSpeech({ speechAt: '1984-01-01T10:00', year: 1984 })
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: 'qualquer tema', mode: 'tema', year: '1984' },
async () => [1, 0],
)
expect(data.themeUnavailable).toBe(true)
expect(data.themeApplied).toBe(false)
expect(data.rows.every((row) => row.id !== id)).toBe(true)
})
it('keeps the facets as the candidate boundary (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const saude = await createSpeech({
speechAt: '2024-01-01T10:00',
facets: facetsWithTopics(['saude']),
segments: [{ startSeconds: 0, endSeconds: 3, text: `Saúde ${runId}` }],
})
const educacao = await createSpeech({
speechAt: '2024-01-02T10:00',
facets: facetsWithTopics(['educacao']),
segments: [{ startSeconds: 0, endSeconds: 3, text: `Educação ${runId}` }],
})
await seedSpeechEmbedding(saude, 'speech', null, [1, 0])
await seedSpeechEmbedding(educacao, 'speech', null, [1, 0])
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: 'tema qualquer', mode: 'tema', topic: 'saude' },
async () => [1, 0],
)
const ids = data.rows.map((row) => row.id)
expect(ids).toContain(saude)
expect(ids).not.toContain(educacao)
})
it('ranks by relevance over the date in the theme mode (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const older = await createSpeech({
speechAt: '2020-01-01T10:00',
segments: [{ startSeconds: 0, endSeconds: 3, text: `Fala ${runId}` }],
})
const newer = await createSpeech({
speechAt: '2026-01-01T10:00',
segments: [{ startSeconds: 0, endSeconds: 3, text: `Fala ${runId}` }],
})
await seedSpeechEmbedding(older, 'speech', null, [1, 0])
await seedSpeechEmbedding(newer, 'speech', null, [0.9, 0.435])
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: `tema ${runId}`, mode: 'tema' },
async () => [1, 0],
)
// Relevance first: the older speech outranks the newer one.
const ids = data.rows.map((row) => row.id)
expect(ids.indexOf(older)).toBeLessThan(ids.indexOf(newer))
// The Câmara contract has no sort: the dated state never leaks through.
expect(data.state.sort).toBeUndefined()
})
it('indexes a speech end-to-end and skips it on the second run (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const id = await createSpeech({
speechAt: '1986-01-01T10:00',
year: 1986,
segments: [
{ startSeconds: 0, endSeconds: 3, text: `Primeiro trecho ${runId}` },
{ startSeconds: 3, endSeconds: 6, text: `Segundo trecho ${runId}` },
],
})
const source = {
id,
segments: [
{ order: 1, startSeconds: 0, text: `Primeiro trecho ${runId}` },
{ order: 2, startSeconds: 3, text: `Segundo trecho ${runId}` },
],
}
const embedCalls: string[][] = []
const embedTexts = async (texts: readonly string[]) => {
embedCalls.push([...texts])
return {
vectors: texts.map((_, index) => (index === 0 ? [1, 0] : [0.6, 0.8])),
promptTokens: 10,
}
}
const first = await indexSpeechSources(payload, [source], { embedTexts })
expect(first).toMatchObject({ indexed: 1, failed: 0, units: 2, upToDate: 0 })
expect(embedCalls).toEqual([[`Primeiro trecho ${runId}`, `Segundo trecho ${runId}`]])
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: `tema ${runId}`, mode: 'tema', year: '1986' },
async () => [1, 0],
)
const row = data.rows.find((item) => item.id === id)
expect(row?.semanticMatch).toBe(true)
expect(row?.excerpt.parts.map((part) => part.text).join('')).toContain(
`Primeiro trecho ${runId}`,
)
const second = await indexSpeechSources(payload, [source], { embedTexts })
expect(second).toMatchObject({ indexed: 0, failed: 0, upToDate: 1 })
expect(embedCalls).toHaveLength(1)
// A stored order that no longer exists falls back to the FIRST real segment
// (never a shifted positional guess).
await seedSpeechEmbedding(id, 'segment', 999, [0, 1])
const fallback = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: `tema ${runId}`, mode: 'tema', year: '1986' },
async () => [0, 1],
)
expect(
fallback.rows
.find((item) => item.id === id)
?.excerpt.parts.map((part) => part.text)
.join(''),
).toContain(`Primeiro trecho ${runId}`)
})
it('cascades the index rows when the speech is deleted (C229)', async () => {
const id = await createSpeech()
await seedSpeechEmbedding(id, 'speech', null, [1, 0])
await payload.delete({ collection: 'speech', id, depth: 0, overrideAccess: true })
const found = await payload.find({
collection: 'speechEmbedding',
where: { speech: { equals: id } },
depth: 0,
limit: 1,
overrideAccess: true,
})
expect(found.totalDocs).toBe(0)
})
it('ignores vectors from another embedding model (C229)', async () => {
const id = await createSpeech({ speechAt: '1983-01-01T10:00', year: 1983 })
await payload.create({
collection: 'speechEmbedding',
data: {
speech: id,
kind: 'speech',
model: 'outro-modelo',
dimensions: 2,
contentHash: 'outro-modelo',
vector: [1, 0],
},
overrideAccess: true,
})
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: 'qualquer tema', mode: 'tema', year: '1983' },
async () => [1, 0],
)
// The foreign vector is never scored against a bge-m3 query: the loader
// degrades instead of ranking nonsense.
expect(data.themeUnavailable).toBe(true)
expect(data.rows).toEqual([])
})
it('never calls the sense engine for an actor outside the catalog gate (C229)', async () => {
const { advisor } = await createUsers()
let called = false
await expect(
loadSpeechAcervoPageData(payload, advisor, { q: 'SUS', mode: 'tema' }, async () => {
called = true
return { terms: ['saúde'] }
return [1, 0]
}),
).rejects.toThrow(/permissão/i)
expect(called).toBe(false)

View file

@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
import config from '@/payload.config'
import { DEEPINFRA_EMBED_MODEL } from '@/utilities/ai/deepInfraEmbed'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import {
loadSpeechAcervoPageData,
@ -95,6 +96,28 @@ const createWebSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Pro
return speech.id
}
/** C229 — one hand-picked index row for the web source (vectors are tiny on purpose). */
const seedSpeechEmbedding = async (
speechId: number,
kind: 'speech' | 'segment' | 'window',
order: number | null,
vector: number[],
): Promise<void> => {
await payload.create({
collection: 'speechEmbedding',
data: {
speech: speechId,
kind,
order,
model: DEEPINFRA_EMBED_MODEL,
dimensions: vector.length,
contentHash: `${kind}-${order ?? 'root'}-${vector.join(',')}`,
vector,
},
overrideAccess: true,
})
}
const createCamaraSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = speechBundleFixture(overrides)
createdSourceKeys.add(bundle.sourceKey)
@ -267,7 +290,7 @@ describe('web speech acervo (C216)', () => {
expect(byDuration.rows.map((row) => row.id)).toEqual([older])
})
it('expands a theme only over the web rows and keeps the evidence (C192)', async () => {
it('runs the sense engine only over the web rows and keeps the evidence (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `temaweb${runId}`
const id = await createWebSpeech({
@ -279,25 +302,65 @@ describe('web speech acervo (C216)', () => {
},
],
})
await seedSpeechEmbedding(id, 'speech', null, [1, 0])
await seedSpeechEmbedding(id, 'segment', 1, [1, 0])
const { coordinator } = await createUsers()
const expansionCalls: string[] = []
const embedCalls: string[] = []
const data = await loadWebSpeechAcervoPageData(
payload,
coordinator,
{ q: 'defesa do SUS', mode: 'tema' },
async (theme) => {
expansionCalls.push(theme)
return { terms: [`atendimento universal ${marker}`] }
{ source: 'internet', q: 'defesa do SUS', mode: 'tema' },
async (query) => {
embedCalls.push(query)
return [1, 0]
},
)
expect(expansionCalls).toEqual(['defesa do SUS'])
expect(embedCalls).toEqual(['defesa do SUS'])
expect(data.themeUnavailable).toBe(false)
expect(data.themeApplied).toBe(true)
const row = data.rows.find((item) => item.id === id)
expect(row).toBeDefined()
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(true)
expect(row?.semanticMatch).toBe(true)
expect(row?.matchedTextSearch).toBe(false)
expect(row?.excerpt.parts.map((part) => part.text).join('')).toContain(marker)
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(false)
})
it('lets an explicit order override relevance in the theme mode (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const older = await createWebSpeech({
speechAt: '2020-01-01T00:00',
segments: [{ startSeconds: 0, endSeconds: 3, text: `Fala tema ${runId}` }],
})
const newer = await createWebSpeech({
speechAt: '2026-01-01T00:00',
segments: [{ startSeconds: 0, endSeconds: 3, text: `Fala tema ${runId}` }],
})
await seedSpeechEmbedding(older, 'speech', null, [1, 0])
await seedSpeechEmbedding(newer, 'speech', null, [0.9, 0.435])
const { coordinator } = await createUsers()
const byRelevance = await loadWebSpeechAcervoPageData(
payload,
coordinator,
{ source: 'internet', q: `tema ${runId}`, mode: 'tema' },
async () => [1, 0],
)
expect(byRelevance.rows.map((row) => row.id).indexOf(older)).toBeLessThan(
byRelevance.rows.map((row) => row.id).indexOf(newer),
)
const byDate = await loadWebSpeechAcervoPageData(
payload,
coordinator,
{ source: 'internet', q: `tema ${runId}`, mode: 'tema', sort: 'recentes' },
async () => [1, 0],
)
expect(byDate.state.sort).toBe('recentes')
expect(byDate.rows.map((row) => row.id).indexOf(newer)).toBeLessThan(
byDate.rows.map((row) => row.id).indexOf(older),
)
})
it('carries the private routes, the platform and the origin attribution in the detail', async () => {

View file

@ -2,7 +2,13 @@
import { describe, expect, it } from 'vitest'
import { ACERVO_SORT_OPTIONS, acervoSortIsDuration, parseAcervoSort } from '@/lib/acervoListSort'
import {
ACERVO_SORT_OPTIONS,
ACERVO_THEME_SORT_OPTIONS,
acervoSortIsDuration,
parseAcervoSort,
parseAcervoThemeSort,
} from '@/lib/acervoListSort'
// C216 — the shared ordering vocabulary of the acervo (recordings C219 + web
// speeches): `recentes` is the default and never serialized, and only the two
@ -37,3 +43,27 @@ describe('ACERVO_SORT_OPTIONS', () => {
])
})
})
// C229 — the theme vocabulary of the speech acervo: relevance is the default
// and `recentes` must remain askable; recordings/Central keep the list above.
describe('theme sort vocabulary', () => {
it('opens with relevance and keeps the shared options', () => {
expect(ACERVO_THEME_SORT_OPTIONS).toEqual([
{ value: 'relevancia', label: 'Mais relevantes' },
...ACERVO_SORT_OPTIONS,
])
})
it('parses relevance and unknown values as the default', () => {
expect(parseAcervoThemeSort('relevancia')).toBeUndefined()
expect(parseAcervoThemeSort('maratona')).toBeUndefined()
expect(parseAcervoThemeSort(undefined)).toBeUndefined()
})
it('keeps recentes and the duration orders meaningful', () => {
expect(parseAcervoThemeSort('recentes')).toBe('recentes')
expect(parseAcervoThemeSort('duracao_maior')).toBe('duracao_maior')
expect(parseAcervoThemeSort('duracao_menor')).toBe('duracao_menor')
expect(parseAcervoThemeSort(['recentes', 'duracao_maior'])).toBe('recentes')
})
})

View file

@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DEEPINFRA_EMBED_DIMENSIONS,
DEEPINFRA_EMBED_MODEL,
DEEPINFRA_EMBED_URL,
EMBED_BATCH_SIZE,
EMBED_TEXT_MAX_CHARS,
embedSpeechQuery,
embedSpeechTexts,
} from '@/utilities/ai/deepInfraEmbed'
/**
* C229 — the embeddings seam with `fetch` stubbed: the body carries the model
* and the batched input, the key never rides the URL, every failure degrades to
* `null` instead of throwing, and the vectors come back L2-normalized in input
* order.
*/
const fetchMock = vi.fn()
const vector = (head: number[]): number[] => [
...head,
...new Array(DEEPINFRA_EMBED_DIMENSIONS - head.length).fill(0),
]
const jsonResponse = (payload: unknown, status = 200) =>
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as Response
const embeddingResponse = (vectors: number[][], promptTokens = 7) =>
jsonResponse({
data: vectors.map((embedding, index) => ({ index, embedding })),
usage: { prompt_tokens: promptTokens },
})
const stubFetch = (...responses: Response[]) => {
fetchMock.mockReset()
for (const response of responses) fetchMock.mockResolvedValueOnce(response)
vi.stubGlobal('fetch', fetchMock)
}
beforeEach(() => {
process.env.DEEPINFRA_API_KEY = 'test-deepinfra-key'
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
delete process.env.DEEPINFRA_API_KEY
vi.unstubAllGlobals()
})
describe('embedSpeechTexts', () => {
it('posts the model and the batched input with the bearer key', async () => {
stubFetch(embeddingResponse([vector([3, 4])]))
const result = await embedSpeechTexts(['fala sobre o SUS'])
expect(result?.vectors).toEqual([vector([0.6, 0.8])])
expect(result?.promptTokens).toBe(7)
const [url, init] = fetchMock.mock.calls[0] as [string, { method: string; body: string }]
expect(url).toBe(DEEPINFRA_EMBED_URL)
expect(url).not.toContain('test-deepinfra-key')
expect(init.method).toBe('POST')
const body = JSON.parse(init.body) as Record<string, unknown>
expect(body.model).toBe(DEEPINFRA_EMBED_MODEL)
expect(body.input).toEqual(['fala sobre o SUS'])
expect(body.encoding_format).toBe('float')
})
it('returns an empty result without calling the provider', async () => {
const result = await embedSpeechTexts([])
expect(result).toEqual({ vectors: [], promptTokens: 0 })
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails closed when the key is missing', async () => {
delete process.env.DEEPINFRA_API_KEY
await expect(embedSpeechTexts(['fala'])).resolves.toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('keeps the input order when the provider answers out of order', async () => {
stubFetch(
jsonResponse({
data: [
{ index: 1, embedding: vector([0, 1]) },
{ index: 0, embedding: vector([1, 0]) },
],
}),
)
const result = await embedSpeechTexts(['primeira', 'segunda'])
expect(result?.vectors).toEqual([vector([1, 0]), vector([0, 1])])
})
it('splits more than the batch size into sequential calls', async () => {
const texts = new Array(EMBED_BATCH_SIZE + 1).fill('fala')
stubFetch(
embeddingResponse(texts.slice(0, EMBED_BATCH_SIZE).map(() => vector([1]))),
embeddingResponse([vector([1])]),
)
const result = await embedSpeechTexts(texts)
expect(result?.vectors).toHaveLength(EMBED_BATCH_SIZE + 1)
expect(fetchMock).toHaveBeenCalledTimes(2)
const second = JSON.parse((fetchMock.mock.calls[1][1] as { body: string }).body) as {
input: string[]
}
expect(second.input).toHaveLength(1)
})
it('truncates each text to the cost guard', async () => {
stubFetch(embeddingResponse([vector([1])]))
await embedSpeechTexts(['x'.repeat(EMBED_TEXT_MAX_CHARS + 500)])
const body = JSON.parse((fetchMock.mock.calls[0][1] as { body: string }).body) as {
input: string[]
}
expect(body.input[0]).toHaveLength(EMBED_TEXT_MAX_CHARS)
})
it('rejects a wrong dimension, a non-finite value and a broken shape', async () => {
stubFetch(jsonResponse({ data: [{ index: 0, embedding: [1, 0] }] }))
await expect(embedSpeechTexts(['fala'])).resolves.toBeNull()
stubFetch(
jsonResponse({
data: [{ index: 0, embedding: vector([1]).map((v, i) => (i === 0 ? Number.NaN : v)) }],
}),
)
await expect(embedSpeechTexts(['fala'])).resolves.toBeNull()
// Same length as the input, but the second answer repeats index 0: the
// missing index must fail the batch (never a silent mix-up).
stubFetch(
jsonResponse({
data: [
{ index: 0, embedding: vector([1]) },
{ index: 0, embedding: vector([1]) },
],
}),
)
await expect(embedSpeechTexts(['um', 'dois'])).resolves.toBeNull()
})
it('maps an HTTP error and a thrown call to null', async () => {
stubFetch(jsonResponse({ error: 'boom' }, 500))
await expect(embedSpeechTexts(['fala'])).resolves.toBeNull()
fetchMock.mockReset()
fetchMock.mockRejectedValue(new Error('network down'))
vi.stubGlobal('fetch', fetchMock)
await expect(embedSpeechTexts(['fala'])).resolves.toBeNull()
})
})
describe('embedSpeechQuery', () => {
it('embeds a single trimmed query', async () => {
stubFetch(embeddingResponse([vector([1])]))
await expect(embedSpeechQuery(' combate à oposição ')).resolves.toEqual(vector([1]))
const body = JSON.parse((fetchMock.mock.calls[0][1] as { body: string }).body) as {
input: string[]
}
expect(body.input).toEqual(['combate à oposição'])
})
it('fails closed on a blank query and on a missing key', async () => {
await expect(embedSpeechQuery(' ')).resolves.toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
delete process.env.DEEPINFRA_API_KEY
await expect(embedSpeechQuery('impeachment')).resolves.toBeNull()
})
})

View file

@ -0,0 +1,134 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
formatSpeechIndexProbe,
formatSpeechIndexReport,
parseSpeechIndexArgs,
speechIndexCostUsd,
speechIndexEstimatedBytes,
speechIndexReportStamp,
} from '../../scripts/lib/speechIndex.mjs'
// C229 — the pure CLI contract of `pnpm acervo:index`: flags, the honest
// report lines and the cost math. The DB/embedding path is covered by the int
// suite and the probe by the runbook.
describe('parseSpeechIndexArgs', () => {
it('defaults to the whole catalog with a dry report shape', () => {
expect(parseSpeechIndexArgs([])).toEqual({
source: 'all',
limit: null,
dryRun: false,
force: false,
probe: null,
top: 10,
out: 'data/acervo-index',
help: false,
})
})
it('parses the write options', () => {
expect(
parseSpeechIndexArgs(['--source', 'web', '--limit', '20', '--dry-run', '--force']),
).toMatchObject({ source: 'web', limit: 20, dryRun: true, force: true })
})
it('parses the probe with its own top', () => {
expect(parseSpeechIndexArgs(['--probe', 'combate à oposição', '--top', '5'])).toMatchObject({
probe: 'combate à oposição',
top: 5,
})
})
it('refuses unknown sources, invalid numbers and escaping out dirs', () => {
expect(() => parseSpeechIndexArgs(['--source', 'gravacoes'])).toThrow(/--source/)
expect(() => parseSpeechIndexArgs(['--limit', '0'])).toThrow(/--limit/)
expect(() => parseSpeechIndexArgs(['--top', 'x', '--probe', 'tema'])).toThrow(/--top/)
expect(() => parseSpeechIndexArgs(['--out', '../fora'])).toThrow(/--out/)
})
it('refuses mixing the read-only probe with write options', () => {
expect(() => parseSpeechIndexArgs(['--probe', 'tema', '--force'])).toThrow(/--probe/)
expect(() => parseSpeechIndexArgs(['--probe', 'tema', '--limit', '3'])).toThrow(/--probe/)
expect(() => parseSpeechIndexArgs(['--top', '3'])).toThrow(/--top só vale com --probe/)
})
})
describe('speechIndexCostUsd', () => {
it('multiplies the tokens by the model price per million', () => {
expect(speechIndexCostUsd(1_000_000, 0.01)).toBeCloseTo(0.01)
expect(speechIndexCostUsd(250_000, 0.01)).toBeCloseTo(0.0025)
expect(speechIndexCostUsd(0, 0.01)).toBe(0)
})
})
describe('speechIndexEstimatedBytes', () => {
it('estimates the JSON cost of the vectors', () => {
expect(speechIndexEstimatedBytes(0, 1024)).toBe(0)
expect(speechIndexEstimatedBytes(1000, 1024)).toBe(8_192_000)
})
})
describe('formatSpeechIndexReport', () => {
const base = {
target: 'localhost/teqo_wt229',
source: 'all',
speeches: 100,
upToDate: 80,
skippedNoText: 5,
toIndex: 15,
units: 300,
durationMs: 12_340,
}
it('renders the dry-run preview without write claims', () => {
const lines = formatSpeechIndexReport({
...base,
mode: 'dry-run',
estimatedBytes: 8_192_000,
})
expect(lines[0]).toContain('modo: dry-run | fonte: all')
expect(lines[1]).toContain('falas: 100 | atualizadas: 80 | sem texto: 5 | a indexar: 15')
expect(lines[2]).toContain('trechos a vetorizar: 300')
expect(lines[2]).toContain('bytes estimados: 8.2 MB')
expect(lines[3]).toContain('duração: 12.3s')
expect(lines.join('\n')).not.toContain('custo')
})
it('renders the write report with indexed, failures, tokens and cost', () => {
const lines = formatSpeechIndexReport({
...base,
mode: 'index',
indexed: 15,
failed: 0,
promptTokens: 123_456,
costUsd: 0.0012,
})
expect(lines[2]).toContain('indexadas: 15 | falhas: 0 | trechos vetorizados: 300')
expect(lines[2]).toContain('tokens: 123456')
expect(lines[2]).toContain('custo estimado: US$ 0.0012')
})
})
describe('formatSpeechIndexProbe', () => {
it('renders the ranked calibration lines', () => {
const lines = formatSpeechIndexProbe('impeachment', [
{ score: 0.71234, speechAt: '2016-04-17T20:00', origin: 'camara', sourceKey: 'camara:1' },
{ score: 0.5011, speechAt: '2024-01-01T10:00', origin: 'web', sourceKey: 'web:youtube:x' },
])
expect(lines[0]).toBe('[acervo:index] probe "impeachment" — top 2')
expect(lines[1]).toBe('1. 0.7123 · 2016-04-17T20:00 · camara · camara:1')
expect(lines[2]).toBe('2. 0.5011 · 2024-01-01T10:00 · web · web:youtube:x')
})
})
describe('speechIndexReportStamp', () => {
it('turns the ISO instant into a filesystem-safe stamp', () => {
expect(speechIndexReportStamp('2026-09-26T03:41:18.123Z')).toBe('2026-09-26T03-41-18-123Z')
})
})

View file

@ -3,6 +3,7 @@
import { describe, expect, it } from 'vitest'
import {
buildSpeechFacetWhere,
buildSpeechListWhere,
buildSpeechListWhereIncludingCutOrigins,
} from '@/utilities/speech/speechListFilters'
@ -132,63 +133,34 @@ describe('buildSpeechListWhereIncludingCutOrigins (C174)', () => {
})
})
describe('buildSpeechListWhere theme expansion (C192)', () => {
it('ORs the query with every expanded theme term', () => {
const where = buildSpeechListWhere(parseSpeechListParams({ q: 'defesa do SUS' }), [
'saúde pública',
'SUS',
])
expect(where).toEqual({
and: [
camara,
{
or: [
{ searchText: { like: 'defesa do sus' } },
{ keywords: { contains: 'defesa do SUS' } },
{ searchText: { like: 'saude publica' } },
{ keywords: { contains: 'saúde pública' } },
{ searchText: { like: 'sus' } },
{ keywords: { contains: 'SUS' } },
],
},
],
})
})
it('dedupes a term that repeats the query', () => {
expect(buildSpeechListWhere(parseSpeechListParams({ q: 'SUS' }), ['SUS'])).toEqual({
and: [camara, { or: [{ searchText: { like: 'sus' } }, { keywords: { contains: 'SUS' } }] }],
})
})
it('keeps the facets AND-ed and the cut origins OR-ed alongside the expansion', () => {
// C229 — the semantic theme path seeds its candidates from this facet-only
// boundary (no textual branch); the literal where above builds on it.
describe('buildSpeechFacetWhere (C229)', () => {
it('returns the source discriminator and every facet branch', () => {
expect(
buildSpeechListWhereIncludingCutOrigins(
parseSpeechListParams({ q: 'reforma', topic: ['saude'] }),
[7],
['saúde'],
buildSpeechFacetWhere(
parseSpeechListParams({
year: '2026',
topic: ['saude'],
scope: ['bahia'],
phase: ['Breves Comunicações'],
municipality: '12',
duration: ['curta'],
}),
),
).toEqual({
and: [
camara,
{ topics: { in: ['saude'] } },
{
or: [
{ searchText: { like: 'reforma' } },
{ keywords: { contains: 'reforma' } },
{ searchText: { like: 'saude' } },
{ keywords: { contains: 'saúde' } },
{ id: { in: [7] } },
],
},
],
})
).toEqual([
camara,
{ year: { in: [2026] } },
{ topics: { in: ['saude'] } },
{ scopes: { in: ['bahia'] } },
{ phase: { in: ['Breves Comunicações'] } },
{ mentionedMunicipalities: { in: [12] } },
{ durationSeconds: { less_than: 120 } },
])
})
it('stays byte-identical to today without theme terms', () => {
const state = parseSpeechListParams({ q: 'reforma' })
expect(buildSpeechListWhere(state, [])).toEqual(buildSpeechListWhere(state))
it('never carries the textual query branch', () => {
expect(buildSpeechFacetWhere(parseSpeechListParams({ q: 'SUS' }))).toEqual([camara])
})
})

View file

@ -238,3 +238,62 @@ describe('web speeches source (C216)', () => {
expect(speechHasActiveFilters(parseSpeechListParams({}))).toBe(false)
})
})
// C229 — the theme mode of the web source: relevance is the default and
// `recentes` becomes a real state that must serialize; the exact mode keeps
// its bytes and the Câmara keeps no sort at all.
describe('theme sort of the web source (C229)', () => {
const themeParams = { source: 'internet', q: 'combate à oposição', mode: 'tema' } as const
it('parses the theme sort with relevance and unknown values as the default', () => {
expect(parseSpeechListParams({ ...themeParams, sort: 'recentes' }).sort).toBe('recentes')
expect(parseSpeechListParams({ ...themeParams, sort: 'duracao_maior' }).sort).toBe(
'duracao_maior',
)
expect(parseSpeechListParams({ ...themeParams, sort: 'relevancia' }).sort).toBeUndefined()
expect(parseSpeechListParams({ ...themeParams, sort: 'maratona' }).sort).toBeUndefined()
// The Câmara contract never carries a sort, not even in the theme mode.
expect(parseSpeechListParams({ q: 'SUS', mode: 'tema', sort: 'recentes' }).sort).toBeUndefined()
})
it('serializes an explicit recentes in the theme mode only', () => {
expect(
serializeCanonicalSpeechListSearchParams(
parseSpeechListParams({ ...themeParams, sort: 'recentes' }),
).toString(),
).toBe('source=internet&q=combate+%C3%A0+oposi%C3%A7%C3%A3o&mode=tema&sort=recentes')
// Relevance and unknown values canonicalize away.
expect(
serializeCanonicalSpeechListSearchParams(
parseSpeechListParams({ ...themeParams, sort: 'relevancia' }),
).get('sort'),
).toBeNull()
// The exact mode still omits its own default.
expect(
serializeCanonicalSpeechListSearchParams(
parseSpeechListParams({ source: 'internet', q: 'SUS', sort: 'recentes' }),
).get('sort'),
).toBeNull()
})
it('canonicalizes relevance and unknown theme sorts into a redirect', () => {
const relevance = resolveSpeechListUrl({ ...themeParams, sort: 'relevancia' })
expect(relevance.state.sort).toBeUndefined()
expect(relevance.redirectHref).toBe(
'/campanha/comunicacao/acervo?source=internet&q=combate+%C3%A0+oposi%C3%A7%C3%A3o&mode=tema',
)
const unknown = resolveSpeechListUrl({ ...themeParams, sort: 'maratona' })
expect(unknown.redirectHref).toBe(
'/campanha/comunicacao/acervo?source=internet&q=combate+%C3%A0+oposi%C3%A7%C3%A3o&mode=tema',
)
})
it('keeps the canonical round-trip of an explicit theme order', () => {
const state = parseSpeechListParams({ ...themeParams, sort: 'recentes' })
expect(buildSpeechListHref(state, 2)).toBe(
'/campanha/comunicacao/acervo?source=internet&q=combate+%C3%A0+oposi%C3%A7%C3%A3o&mode=tema&sort=recentes&page=2',
)
expect(resolveSpeechListUrl({ ...themeParams, sort: 'recentes' }).redirectHref).toBeUndefined()
})
})

View file

@ -62,7 +62,8 @@ const speech = (overrides: Partial<SpeechListItemViewModel> = {}): SpeechListIte
officialTextUrl: null,
cuts: [],
matchedTextSearch: true,
themeMatchTerm: null,
semanticMatch: false,
literalFallback: false,
...overrides,
})
@ -88,6 +89,9 @@ const webSpeech = (
],
thumbnailUrl: null,
watchHref: '/campanha/comunicacao/acervo/internet/9',
semanticMatch: false,
literalFallback: false,
matchedTextSearch: false,
...overrides,
})
@ -115,18 +119,70 @@ describe('speech result cards excerpt', () => {
expect(html).not.toContain('text-sm leading-relaxed')
})
it('wraps the theme provenance excerpt in quotes', () => {
it('shows the semantic evidence block with the seal and without a highlight', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, { speech: speech({ themeMatchTerm: 'saúde' }) }),
createElement(SpeechResultCard, {
speech: speech({
semanticMatch: true,
excerpt: {
parts: [{ text: 'O embate com a oposição', highlighted: false }],
truncatedStart: false,
truncatedEnd: true,
},
}),
}),
)
expect(html).toContain('Por que apareceu')
expect(html).toContain('Trecho mais próximo do tema')
expect(html).toContain('Tema')
expect(html).toContain('“')
expect(html).toContain('”')
// The provenance replaces the common excerpt — they never coexist (C192).
expect(html).not.toContain('<mark')
// The evidence block replaces the common excerpt — they never coexist.
expect(html).not.toContain('text-sm leading-relaxed')
})
it('shows the term seal alongside the theme seal when the query also matches', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, {
speech: speech({ semanticMatch: true, matchedTextSearch: true }),
}),
)
expect(html).toContain('Tema')
expect(html).toContain('Termo exato')
})
it('shows the same semantic evidence on the web card', () => {
const html = renderWithAppRouter(
createElement(WebSpeechResultCard, {
speech: webSpeech({
semanticMatch: true,
excerpt: {
parts: [{ text: 'O embate com a oposição', highlighted: false }],
truncatedStart: false,
truncatedEnd: true,
},
}),
}),
)
expect(html).toContain('Trecho mais próximo do tema')
expect(html).toContain('Tema')
expect(html).not.toContain('<mark')
expect(html).toContain('Fala na internet')
})
it('labels a degraded literal card with the term seal', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, { speech: speech({ literalFallback: true }) }),
)
expect(html).toContain('data-testid="speech-literal-badge"')
expect(html).toContain('Termo exato')
expect(html).not.toContain('Trecho mais próximo do tema')
})
it('keeps the web card own excerpt classes', () => {
const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))

View file

@ -0,0 +1,249 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
cosineSimilarity,
dotProduct,
meanPoolVectors,
normalizeVector,
pickBestSemanticChunk,
planSpeechIndex,
rankSemanticHits,
sortSemanticHits,
SPEECH_SEMANTIC_MIN_COSINE,
speechEmbeddingHash,
speechSemanticSourceText,
speechTextWindows,
type SpeechSemanticCandidate,
type SpeechSemanticHit,
} from '@/lib/speechSemantic'
// C229 — the pure engine of the semantic acervo: vector math, text windows,
// the content hash and the ranking rules (threshold + relevance default with
// the explicit order overriding it). No network and no Payload here.
describe('vector math', () => {
it('computes the dot product and rejects mismatched dimensions', () => {
expect(dotProduct([1, 2, 3], [4, 5, 6])).toBe(32)
expect(dotProduct([1, 2], [1])).toBe(0)
})
it('normalizes to unit length and keeps a zero vector finite', () => {
expect(normalizeVector([3, 4])).toEqual([0.6, 0.8])
expect(normalizeVector([0, 0])).toEqual([0, 0])
})
it('computes a real cosine regardless of scale', () => {
expect(cosineSimilarity([1, 0], [1, 0])).toBeCloseTo(1)
expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0)
expect(cosineSimilarity([1, 0], [-1, 0])).toBeCloseTo(-1)
expect(cosineSimilarity([2, 0], [7, 0])).toBeCloseTo(1)
expect(cosineSimilarity([1, 2], [1])).toBe(0)
expect(cosineSimilarity([0, 0], [1, 1])).toBe(0)
})
it('pools vectors by the normalized mean and skips unusable shapes', () => {
expect(
meanPoolVectors([
[1, 0],
[1, 0],
]),
).toEqual([1, 0])
// The mean of opposite directions is the zero vector, never NaN.
expect(
meanPoolVectors([
[1, 0],
[-1, 0],
]),
).toEqual([0, 0])
expect(
meanPoolVectors([
[1, 0],
[0, 1, 0],
]),
).toEqual([1, 0])
expect(meanPoolVectors([])).toEqual([])
})
})
describe('speechTextWindows', () => {
const longText = 'palavra '.repeat(400).trim()
it('keeps a short text as a single window', () => {
expect(speechTextWindows('fala curta')).toEqual([
{ order: 0, start: 0, end: 10, text: 'fala curta' },
])
expect(speechTextWindows('')).toEqual([])
})
it('splits long text into ordered overlapping windows without cutting words', () => {
const windows = speechTextWindows(longText, { size: 100, overlap: 20 })
expect(windows.length).toBeGreaterThan(2)
expect(windows.map((window) => window.order)).toEqual(windows.map((_, index) => index))
for (const window of windows) {
expect(window.text.length).toBeLessThanOrEqual(100)
expect(window.text).toBe(window.text.trim())
// No window starts or ends mid-word: the slice boundaries are word edges.
expect(longText.slice(window.start, window.end)).toBe(
`${longText.slice(window.start, window.end).trim()}`,
)
}
// Overlap: the next window starts before the previous one ended.
expect(windows[1].start).toBeLessThan(windows[0].end)
// Full coverage: every character of the text belongs to some window.
let covered = 0
for (const window of windows) covered = Math.max(covered, window.end)
expect(covered).toBe(longText.length)
})
})
describe('speechEmbeddingHash', () => {
it('is deterministic, text-sensitive and model-sensitive', () => {
expect(speechEmbeddingHash('fala', 'bge')).toBe(speechEmbeddingHash('fala', 'bge'))
expect(speechEmbeddingHash('fala', 'bge')).not.toBe(speechEmbeddingHash('outra', 'bge'))
expect(speechEmbeddingHash('fala', 'bge')).not.toBe(speechEmbeddingHash('fala', 'outro'))
})
})
describe('rankSemanticHits', () => {
const candidates: SpeechSemanticCandidate[] = [
{ id: 1, speechAt: '2026-01-01T10:00', vector: [1, 0] }, // 1.0
{ id: 2, speechAt: '2026-02-01T10:00', vector: [0.8, 0.6] }, // 0.8
{ id: 3, speechAt: '2026-03-01T10:00', vector: [0.8, 0.6] }, // 0.8, newer wins the tie
{ id: 4, speechAt: '2026-04-01T10:00', vector: [0, 1] }, // 0.0 (below the cut-off)
{ id: 5, speechAt: '2026-05-01T10:00', vector: null },
{ id: 6, speechAt: '2026-06-01T10:00', vector: [1, 0, 0] },
{ id: 7, speechAt: '2026-07-01T10:00', vector: [0.4, 0.916515] }, // ~0.4 (boundary, kept)
]
const query = [1, 0]
it('drops unusable candidates and the scores below the cut-off', () => {
const hits = rankSemanticHits(query, candidates)
expect(hits.map((hit) => hit.id)).toEqual([1, 3, 2, 7])
})
it('sorts by score desc with the date as tiebreak and keeps the duration', () => {
const hits = rankSemanticHits(query, [
{ id: 9, speechAt: '2026-01-01T10:00', durationSeconds: 120, vector: [1, 0] },
{ id: 8, speechAt: '2026-01-01T10:00', durationSeconds: null, vector: [1, 0] },
])
expect(hits.map((hit) => hit.id)).toEqual([8, 9])
expect(hits[0]).toMatchObject({ durationSeconds: null, score: 1 })
expect(hits[1]).toMatchObject({ durationSeconds: 120 })
})
it('honors a custom cut-off', () => {
expect(rankSemanticHits(query, candidates, { minScore: 0.9 }).map((hit) => hit.id)).toEqual([1])
expect(
rankSemanticHits([1, 0], [{ id: 1, speechAt: 'x', vector: [1, 0] }], { minScore: 0.95 }),
).toHaveLength(1)
})
it('keeps the documented default cut-off', () => {
expect(SPEECH_SEMANTIC_MIN_COSINE).toBe(0.4)
})
})
describe('sortSemanticHits', () => {
const hits: SpeechSemanticHit[] = [
{ id: 1, speechAt: '2026-01-01T10:00', durationSeconds: 600, score: 0.9 },
{ id: 3, speechAt: '2026-02-01T10:00', durationSeconds: null, score: 0.7 },
{ id: 2, speechAt: '2026-03-01T10:00', durationSeconds: 120, score: 0.5 },
]
it('keeps the relevance order by default', () => {
expect(sortSemanticHits(hits, 'relevancia').map((hit) => hit.id)).toEqual([1, 3, 2])
})
it('orders by date when the user asks for recentes', () => {
expect(sortSemanticHits(hits, 'recentes').map((hit) => hit.id)).toEqual([2, 3, 1])
})
it('orders by duration in both directions', () => {
expect(sortSemanticHits(hits, 'duracao_maior').map((hit) => hit.id)).toEqual([1, 2, 3])
expect(sortSemanticHits(hits, 'duracao_menor').map((hit) => hit.id)).toEqual([2, 1, 3])
})
})
describe('pickBestSemanticChunk', () => {
it('picks the closest usable chunk and breaks ties by the lowest key', () => {
expect(
pickBestSemanticChunk(
[1, 0],
[
{ key: 20, vector: [0, 1] },
{ key: 10, vector: [1, 0] },
{ key: 30, vector: [1, 0] },
],
),
).toEqual({ key: 10, score: 1 })
})
it('returns null when no chunk can be compared', () => {
expect(pickBestSemanticChunk([1, 0], [])).toBeNull()
expect(pickBestSemanticChunk([1, 0], [{ key: 1, vector: null }])).toBeNull()
expect(pickBestSemanticChunk([1, 0], [{ key: 1, vector: [1, 0, 0] }])).toBeNull()
})
})
describe('speechSemanticSourceText', () => {
it('prefers the official transcript, then the summary, then the search text', () => {
expect(
speechSemanticSourceText({
officialTranscript: 'Transcrição',
summary: 'Resumo',
searchText: 'busca',
}),
).toBe('Transcrição')
expect(speechSemanticSourceText({ officialTranscript: ' ', summary: 'Resumo' })).toBe('Resumo')
expect(speechSemanticSourceText({ searchText: 'busca' })).toBe('busca')
expect(speechSemanticSourceText({})).toBeNull()
})
})
describe('planSpeechIndex', () => {
const model = 'test-model'
it('indexes the ASR segments by their own order and skips blanks', () => {
const plan = planSpeechIndex(
{
segments: [
{ order: 1, startSeconds: 0, text: 'Primeiro trecho' },
{ order: 2, startSeconds: 3, text: ' ' },
{ order: 3, startSeconds: 6, text: 'Terceiro trecho' },
],
},
{ model },
)
expect(plan?.units).toEqual([
{ kind: 'segment', order: 1, startSeconds: 0, text: 'Primeiro trecho' },
{ kind: 'segment', order: 3, startSeconds: 6, text: 'Terceiro trecho' },
])
expect(plan?.speechHash).toBe(
speechEmbeddingHash('segment:1:Primeiro trecho\nsegment:3:Terceiro trecho', model),
)
})
it('falls back to text windows when the speech has no segments', () => {
const plan = planSpeechIndex({ officialTranscript: 'fala '.repeat(400) }, { model })
expect(plan?.units.length).toBeGreaterThan(1)
expect(plan?.units.every((unit) => unit.kind === 'window')).toBe(true)
expect(plan?.units[0]).toMatchObject({ order: 0, startSeconds: null })
})
it('returns null without any usable text', () => {
expect(planSpeechIndex({ segments: [] }, { model })).toBeNull()
expect(planSpeechIndex({ officialTranscript: ' ', summary: null }, { model })).toBeNull()
})
it('changes the hash with the model so a model swap reindexes', () => {
const speech = { segments: [{ order: 1, startSeconds: 0, text: 'Fala' }] }
expect(planSpeechIndex(speech, { model: 'a' })?.speechHash).not.toBe(
planSpeechIndex(speech, { model: 'b' })?.speechHash,
)
})
})

View file

@ -69,18 +69,22 @@ describe('toSpeechListItemViewModel thumbnail (C182 — frame of the speech)', (
})
})
describe('toSpeechListItemViewModel theme match (C192)', () => {
const segment = (text: string) => ({ startSeconds: 0, endSeconds: 3, text })
const themeRow = ({
describe('toSpeechListItemViewModel semantic evidence (C229)', () => {
const segment = (text: string, startSeconds = 0) => ({
startSeconds,
endSeconds: startSeconds + 3,
text,
})
const semanticRow = ({
text,
query,
themeTerms,
evidence,
keywords = [],
searchText,
}: {
text: string
query?: string
themeTerms: string[]
evidence?: { text: string; startSeconds: number | null } | null
keywords?: string[]
searchText?: string
}) =>
@ -89,94 +93,75 @@ describe('toSpeechListItemViewModel theme match (C192)', () => {
segments: [segment(text)],
query,
municipalityLabels: new Map(),
themeTerms,
semanticMatch: Boolean(evidence),
semanticEvidence: evidence,
})
it('surfaces the theme passage with the expanded term highlighted', () => {
const row = themeRow({
text: 'Defendemos o atendimento público e acesso universal à saúde',
searchText: 'defendemos o atendimento publico e acesso universal a saude',
query: 'defesa do SUS',
themeTerms: ['acesso universal'],
it('shows the semantic passage with no highlight when no term matches', () => {
const row = semanticRow({
text: 'Uma fala sobre outro assunto',
query: 'combate à oposição',
evidence: { text: 'O embate com a oposição e o bolsonarismo', startSeconds: 42 },
})
expect(row.matchKind).toBe('theme')
expect(row.themeMatchTerm).toBe('acesso universal')
expect(row.semanticMatch).toBe(true)
expect(row.matchedTextSearch).toBe(false)
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(false)
expect(row.excerpt.parts.map((part) => part.text).join('')).toContain('bolsonarismo')
expect(row.watchHref).toBe(
'/campanha/comunicacao/acervo/1?t=42&q=combate+%C3%A0+oposi%C3%A7%C3%A3o',
)
})
it('keeps the theme term when the speech also matches the literal query', () => {
const row = themeRow({
it('keeps the literal match flag when the speech also contains the query', () => {
const row = semanticRow({
text: 'A defesa do SUS e da saúde pública baiana',
searchText: 'a defesa do sus e da saude publica baiana',
query: 'SUS',
themeTerms: ['saúde pública'],
evidence: { text: 'A defesa do SUS', startSeconds: 0 },
})
expect(row.matchKind).toBe('segment')
expect(row.themeMatchTerm).toBe('saúde pública')
expect(row.semanticMatch).toBe(true)
expect(row.matchedTextSearch).toBe(true)
// The evidence never highlights the term: the engine did not match it.
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(false)
})
it('matches a keyword as theme evidence and uses it as the excerpt', () => {
const row = themeRow({
text: 'Uma fala sobre outro assunto',
query: 'defesa do SUS',
themeTerms: ['Farmácia Popular'],
keywords: ['Farmácia Popular'],
it('stays literal without semantic evidence', () => {
const row = semanticRow({
text: 'A defesa do SUS',
searchText: 'a defesa do sus',
query: 'SUS',
})
expect(row.matchKind).toBe('theme')
expect(row.themeMatchTerm).toBe('Farmácia Popular')
const excerptText = row.excerpt.parts.map((part) => part.text).join('')
expect(excerptText).toContain('Farmácia Popular')
expect(row.semanticMatch).toBe(false)
expect(row.matchKind).toBe('segment')
expect(row.matchedTextSearch).toBe(true)
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('does not claim a theme the where never used (word-only coincidence)', () => {
const row = themeRow({
text: 'O atendimento público é prioridade',
searchText: 'o atendimento publico e prioridade',
query: 'defesa do SUS',
themeTerms: ['atendimento universal'],
it('seeks the evidence start when known and omits the seek without one', () => {
const window = semanticRow({
text: 'Outra fala',
query: 'tema',
evidence: { text: 'Trecho de janela', startSeconds: null },
})
expect(window.watchHref).toBe('/campanha/comunicacao/acervo/1?q=tema')
})
it('flags a degraded literal row without claiming the theme', () => {
const row = toSpeechListItemViewModel({
speech: { id: 1, speechAt: '2026-08-11T18:48', searchText: 'a defesa do sus' },
segments: [segment('A defesa do SUS')],
query: 'SUS',
municipalityLabels: new Map(),
literalFallback: true,
})
expect(row.themeMatchTerm).toBeNull()
expect(row.matchKind).toBe('fallback')
})
it('stays literal without theme terms or when none matches', () => {
expect(
themeRow({ text: 'A defesa do SUS', query: 'SUS', themeTerms: [] }).themeMatchTerm,
).toBeNull()
expect(
themeRow({ text: 'A defesa do SUS', query: 'SUS', themeTerms: ['educação'] }).themeMatchTerm,
).toBeNull()
})
it('never claims a theme term the where mirror did not match (C192)', () => {
// "saúde pública" never appears contiguously in the search text, so the
// mirror rejects it and only the term that really matched is claimed.
expect(
themeRow({
text: 'Saúde para todos e a rede pública de atendimento',
searchText: 'saude para todos e a rede publica de atendimento',
query: 'defesa do SUS',
themeTerms: ['saúde pública', 'atendimento'],
}).themeMatchTerm,
).toBe('atendimento')
})
it('falls back to keywords as theme evidence when the text does not match', () => {
const row = themeRow({
text: 'Uma fala sobre outro assunto',
query: 'defesa do SUS',
themeTerms: ['Farmácia Popular'],
keywords: ['Farmácia Popular'],
})
expect(row.themeMatchTerm).toBe('Farmácia Popular')
expect(row.literalFallback).toBe(true)
expect(row.semanticMatch).toBe(false)
expect(row.matchKind).toBe('segment')
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
})
@ -198,7 +183,6 @@ describe('web speech view models (C216)', () => {
},
segments: [],
query: undefined,
themeTerms: [],
})
it('labels the platform and the day-only publication date', () => {