feat(C192): busca semântica no acervo de falas (Closes #1147)

Modo "Por tema" no acervo de falas: expansão determinística do tema via
DeepSeek (generateObject, timeout, fallback null) alimenta o motor lexical
existente (searchText/keywords OR), mantendo facetas AND, paginação por fala,
o OR de cortes do C174 e o access control intacto. Resultado de tema mostra
selo e "Por que apareceu" com trecho real (frase contínua), sem score;
degradação honesta para termo exato com aviso e vazio honesto com saídas.

- URL: mode=tema (default omitido; canonicaliza sem q), seletor otimista
- ai/expandSpeechSearchTheme + lib/speechThemeTerms (sanitiza saída do modelo)
- view model com themeMatchTerm + espelho speechMatchesSearchTerm
- UI do card de tema (selos, proveniência, ações) e aviso degradado
- sem migration/Consent/exposição pública; gate de leitura inalterado
- testes unit/int/e2e; design hi-fi estendido e certificado pelo designer
This commit is contained in:
Francisco Solla 2026-09-18 17:26:18 -03:00
parent af6fa698db
commit 45eef25b1a
31 changed files with 1563 additions and 280 deletions

View file

@ -0,0 +1 @@
**C192 — Busca semântica no acervo de falas (2026-09-18):** o acervo (`/campanha/comunicacao/acervo`) ganhou busca **por tema** ao lado da busca literal, sem índice vetorial nem backfill. (1) **Modo explícito e visível**: novo parâmetro de URL `mode=tema` (default `termo`, omitido — deep links da busca exata ficam byte-idênticos; `mode=tema` sem `q` canonicaliza fora), registrado em `speechListParamNames`; o seletor "Buscar por: Termo exato | Por tema" (segmented control, `aria-pressed`) é otimista e preserva facetas/página. (2) **Ponte semântica determinística**: `expandSpeechSearchTheme` (`src/utilities/ai/`, `server-only`) pede ao DeepSeek (`generateObject`, `deepseek-flash`, timeout 4s, temperatura 0.2) de 3 a 8 termos relacionados; nunca lança — sem chave, timeout, erro ou saída malformada resolve `null` (degradação), e lista vazia é resposta legítima (sem aviso). `normalizeSpeechThemeTerms` sanitiza a saída do modelo (`%`/`_`/`\`, termos <3 chars, dedupe, teto de 8/60 chars). (3) **Motor lexical no caminho do Payload**: `buildSpeechListWhereIncludingCutOrigins` OR-a `q` + termos expandidos (`searchText like` / `keywords contains`) com dedupe, mantém facetas AND, paginação por fala e o OR de origem de corte do C174 (ancorado só no `q` literal); `payload.find` segue com `user` + `overrideAccess:false` e `canReadSpeechCatalog` barra a chamada externa de ator negado (fail-closed). (4) **Proveniência honesta**: `toSpeechListItemViewModel` ganha `themeMatchTerm` e `matchKind='theme'`; o card de tema mostra selo "Tema" (+ "Termo exato" quando também casa literal) e o bloco "Por que apareceu" com o excerto real da fala e a frase marcada como faixa contínua (`findPhraseRanges`), sem score nem texto gerado; o CTA só oferece "Assistir no trecho" com casamento literal real. (5) **Degradação e vazio**: aviso discreto (`Alert pending`, literais da cena 04) com "Tentar por tema novamente" e resultados lexicais; o vazio honesto tem as saídas reformular/usar termo exato/limpar filtros (cena 03). (6) **Design**: o `designer` estendeu o artefato do gate (`docs/plans/acervo-busca-semantica-ui-design.html`, cenas 01/02) e certificou a implementação renderizada (desktop 1280 / mobile 390). (7) **Testes**: novos unit de `speechThemeTerms` e `expandSpeechSearchTheme` (mock de provider) e extensões de URL/where/espelho/view model/omnibox; int com expansor injetado (tema, fallback, guard de acesso); e2e do modo degradado sem chave (o `playwright.config.ts` agora zera `DEEPSEEK_API_KEY` no servidor e2e). Sem migration, sem collection/Consent novo, sem exposição pública. `pnpm gate:ci` verde (unit 3742, int 827, e2e do acervo 16).

View file

@ -0,0 +1,156 @@
# Impl: C192 — Busca semântica no acervo de falas
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1147
Intenção: docs/plans/acervo-busca-semantica.md
Appetite restante: herdado (~1–2 dias eng)
## Leitura da intenção
- **Outcome:** em `mode=tema`, buscar um tema sem as palavras exatas devolve falas relacionadas pelo sentido, cada resultado com proveniência determinística (termo exato vs tema) e excerto real; sem resultado → vazio honesto; mecanismo indisponível → resultados lexicais de hoje + aviso discreto, nunca erro nem resultado fantasma.
- **O que NÃO negociar:** o gate de leitura (`canReadSpeechCatalog`/`canReadSpeech`) não alarga nem ganha atalho; `payload.find` sempre com `user` + `overrideAccess: false`; a fala continua a unidade; busca exata continua sendo o default e não regride; sem score numérico na UI; nada de pgvector/backfill dos 997; `openai/*` intocado (OPS123).
- **O que reavaliar:** a hipótese da intenção ("áreas prováveis") está correta quanto aos arquivos. O ponto a reavaliar é a assimetria `speechListFilters` (where) × `lib/speechSearch` (espelho C180): a expansão só entra na perna textual do `where`, então o espelho precisa de um predicado por termo em vez de reescrever a semântica literal — caso contrário `matchedTextSearch` quebraria o C174.
## Abordagem recomendada
```mermaid
flowchart LR
U[assessoria digita tema] --> M{mode=tema?}
M -- não --> LEX[buildSpeechListWhere<br/>literal atual]
M -- sim --> EXP[expandSpeechSearchTheme<br/>DeepSeek generateObject]
EXP -- null/erro --> DEG[where literal +<br/>alerta discreto]
EXP -- terms --> WHERE[where OR: q + termos expandidos<br/>AND facetas + OR cortes C174]
DEG --> Q[payload.find<br/>user · overrideAccess:false]
WHERE --> Q
Q --> VM[toSpeechListItemViewModel<br/>matchKind + themeMatchTerm + excerto real]
VM --> UI[SpeechResultCard · EmptyState · Alert]
```
**Opções consideradas:** A) LLM query expansion + motor lexical existente, sem índice vetorial | B) embeddings query-time (DeepInfra) + artefato em memória | C) pgvector + embeddings + backfill.
**Recomendação:** A — um único `generateObject` determinístico (mesmo contrato de `rerankSpeechExcerpts`) expande o tema em termos lexicais; o `where` do Payload e o access control continuam no caminho, a fala segue unidade, e nada novo precisa ser indexado/backfillado. Cabe no appetite e entrega o outcome no recorte do acervo.
**Rejeitadas:** B porque exige artefato de embeddings (backfill/refresh) e cosine em memória sem ganho claro sobre a expansão lexical para 997 falas, além de introduzir dependência de infra que a intenção mandou evitar; C porque é exatamente o rabbit hole nomeado em "índice vetorial de todo o acervo / infra nova" (custo, storage, sync contínuo, backfill). Detalhe em "Decisões de engenharia".
### Componentes / mudanças
- **`SpeechSearchMode` + `mode` em `SpeechListState`** (`src/utilities/speech/speechListUrl.ts:51`): adiciona `'mode'` a `speechListParamNames:71`, parse de valor único (`tema`; desconhecido → `undefined`), serializa `mode=tema` **somente quando há `q`**. Reusa `firstValue`, `resolveListUrl`, `buildListHref`. Deep links e back/forward passam pelo canonicalizador existente; `mode` desconhecido continua redirecionando porque o nome é suportado e o serializer o omite (raw ≠ canonical).
- **`applySpeechSearchMode`** (`src/utilities/speech/speechOmnibox.ts`): ação pura que devolve `{ kind: 'url', state: { ...state, mode, page: 1 } }` (ou limpa `mode`), preservando facetas. Reusa o padrão de `withPageReset`/`mutateFacet`; não passa por `mutateFacet` (não é facet multi-valor).
- **`normalizeSpeechThemeTerms`** (`src/lib/speechThemeTerms.ts`, novo, puro/client-safe): normaliza (`normalizeForSearch`), remove `%`/`_` e tokens <3 chars, deduplica pela forma normalizada, limita a 8 termos e 60 chars. É a única barreira entre texto de LLM e o `LIKE` — reusa `normalizeForSearch`, `uniqueByNormalizedForm`.
- **`expandSpeechSearchTheme`** (`src/utilities/ai/expandSpeechSearchTheme.ts`, novo, `server-only`): `deepSeek('deepseek-flash')` + `generateObject` + zod `{ terms: z.array(z.string()) }`, `temperature 0.2`, `AbortSignal.timeout(4000)`, `maxOutputTokens` baixo; aplica `normalizeSpeechThemeTerms`. Contrato: sem `DEEPSEEK_API_KEY`, timeout, erro de provider ou saída malformada → `null`; `{ terms: [] }` é resposta legítima (modelo rodou, nada a acrescentar). Espelha `rerankSpeechExcerpts`.
- **`buildSpeechTextBranches` / `buildSpeechListWhereIncludingCutOrigins`** (`src/utilities/speech/speechListFilters.ts:52,77`): passam a aceitar `themeTerms: readonly string[]`; a perna textual vira OR de `{ searchText: like normalize(q) }`, `{ keywords: contains q }` + pares equivalentes por termo expandido + `{ id: { in: originSpeechIds } }` (C174 intocado, ainda só `state.q`); facetas seguem AND. Reusa `buildSpeechFacetWhere`, `collapseListWhereOrBranches`.
- **`speechMatchesSearchTerm` / `speechMatchesSearchQuery`** (`src/lib/speechSearch.ts:31`): extrai o predicado de um termo (espelho exato do `where` per-termo); `speechMatchesSearchQuery` passa a ser `speechMatchesSearchTerm(speech, q)` para o flag literal. Mantém o lockstep C180 e `matchedTextSearch` literal.
- **`toSpeechListItemViewModel`** (`src/utilities/speech/speechViewModels.ts:213`): recebe `themeTerms?: readonly string[]`; `matchKind` ganha `'theme'`; novo `themeMatchTerm: string | null` (primeiro termo expandido que casa em segmento/keyword via espelho); quando não há casamento literal, o excerto é `buildHighlightedExcerpt(segment.text, themeMatchTerm)` — trecho real, sem "porquê" gerado. Reusa `pickMatchingSegment` (generalizado p/ lista de termos), `buildHighlightedExcerpt`, `normalizeForSearch`.
- **`loadSpeechAcervoPageData`** (`src/utilities/speech/speechPageData.ts:160`): ganha 4º parâmetro injetável `expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme` (precedente: `SpeechVideoStartResolver` em `loadSpeechDetailPageData`). Só expande se `state.q && state.mode === 'tema' && canReadSpeechCatalog(user.role)` (fail-closed: ator negado não dispara chamada externa; o `find` continua rejeitando). Retorna `searchMode`, `themeUnavailable` e passa `themeTerms` ao view model. Seleção de `searchText` inalterada (já existe quando `state.q`).
- **UI** (`src/components/campaign/speech/` + rota): `SpeechAcervoFilters.tsx` ganha o seletor segmentado "Buscar por: Termo exato | Por tema" (`role="group"`, `aria-pressed`) que chama `applySpeechSearchMode` via `useCampaignListFilterNavigation`; `SpeechResultCard.tsx:122` ganha o selo "Tema" + bloco "Por que apareceu" com o excerto real e mantém o caminho C174/`originLabel` quando não é tema; novo `SpeechThemeFallbackNotice.tsx` com `Alert variant="pending"` + botão "Tentar por tema novamente" (`router.refresh()`); `page.tsx:29` renderiza o aviso quando `themeUnavailable` e o vazio honesto (cena 03). Reusa `CampaignListOmnibox`, `CampaignListEmptyState`, `Alert`/`AlertTitle`/`AlertDescription`, `useCampaignListFilterNavigation`, `Badge`, `Button`.
- **Migration:** **sem migration**. Nenhum campo/collection/global muda: `searchText`/`keywords` já existem e a expansão é query-time; mudar schema exigiria migration e não é necessário.
- **Access / Consent:** intocado. Nenhuma chave `Consent` nova, nenhum PII de cidadão; o único dado que sai para o DeepSeek é o texto do tema digitado pelo assessor. A leitura continua no `payload.find` com `user` + `overrideAccess: false`; `canReadSpeech`/`canReadSpeechCatalog` não são tocados nem reusados para alargar nada.
- **UI (Impeccable B):** shell existente do acervo, sem rota nova. Cenas do gate a portar: 01 (desktop, selo "Tema" + "Por que apareceu" + "Termo exato" quando ambos), 02 (mobile, toggle tocável, `min-h-11`), 03 (vazio honesto: "Reformular busca" / "Usar termo exato" / "Limpar filtros"), 04 (degradado: seletor visualmente em "Termo exato", aviso `bg-estimate-pending` preservando "Por tema foi pedido", "Tentar por tema novamente"), 05 (loading: aproveitar pending existente; skeleton fica fora do v1). Craft/critique/polish no mesmo gate B antes do PR.
### Dados → forma (N/A justificado)
N/A. O item não apresenta métrica, agregado ou score: o resultado é uma lista de falas com uma explicação qualitativa (selo + trecho). A intenção proíbe explicitamente score numérico de confiança; a forma escolhida é "selo + excerto real", sem barra/percentual.
## Fases verificáveis
1. **Tracer / server** (~1 dia): `mode` no contrato de URL + `normalizeSpeechThemeTerms` + `expandSpeechSearchTheme` + `themeTerms` no `where` + injeção em `loadSpeechAcervoPageData` + `themeMatchTerm` no view model. Testes:
- `tests/unit/speechThemeTerms.unit.spec.ts` (novo): sanitiza wildcards/curtos/duplicados/limites.
- `tests/unit/expandSpeechSearchTheme.unit.spec.ts` (novo, mockando `ai`/`@ai-sdk/deepseek` como `rerankSpeechExcerpts.unit.spec.ts`): sem chave → `null` e sem chamada; erro do provider → `null`; `{terms:[]}` → legítimo; termos válidos; prompt contém o tema e `abortSignal` é `AbortSignal`.
- `tests/unit/speechListUrl.unit.spec.ts` (estender): `mode=tema` parse/serialize; desconhecido canonicaliza; `mode=tema` sem `q` redireciona; deep-link atual (`?q=SUS`) não muda.
- `tests/unit/speechListFilters.unit.spec.ts` (estender): OR `q` + expandidos + OR de cortes, facetas AND; sem `themeTerms` idêntico ao de hoje.
- `tests/unit/speechSearch.unit.spec.ts` (estender): `speechMatchesSearchTerm` (texto normalizado + keyword case-insensitive).
- `tests/unit/speechViewModels.unit.spec.ts` (estender): `themeMatchTerm`, `matchKind='theme'`, excerto highlightando o termo expandido.
2. **UI** (~0,5–1 dia): seletor de modo em `SpeechAcervoFilters`, selo/proveniência no `SpeechResultCard`, aviso de degradação (`Alert pending` + retry), vazio honesto com saídas; `applySpeechSearchMode` em `tests/unit/speechOmnibox.unit.spec.ts`. Testes:
- Estender `tests/int/speechAcervo.int.spec.ts` com expansor injetado: (a) fala que **não** contém o `q` literal mas contém a expansão é devolvida com `themeMatchTerm` e excerto real; (b) expansor `null` → `themeUnavailable: true` e linhas literais; (c) advisor/leader não disparam o expansor (guard fail-closed).
- Estender `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (browserless, sem `DEEPSEEK_API_KEY` no app e2e): `?q=<marker>&mode=tema` → 200, seletor "Buscar por", aviso "A busca por tema está indisponível agora." e resultado exato do marker; `?q=<marker>` (default) inalterado.
3. **Gates:** `pnpm gate:fast`; PR via `pnpm push`. `src/utilities/ai` é prefixo de risco (OPS86) → o PR roda o conjunto curado de e2e; os prefixos `src/utilities/speech`/`src/components/campaign/speech`/`src/app/(campaign)/.../comunicacao`/`src/lib/speech*` já mapeiam `campaignSpeechAcervo`, então `scripts/lib/e2e-affected-manifest.mjs` **não muda**.
## Rabbit holes / Não escopo (engenharia)
- pgvector, embeddings, artefato/backfill dos 997 e sync contínuo — rejeitado nomeadamente na intenção.
- Mandar o catálogo inteiro dos 997 ao LLM para "escolher" a fala — prompt/latência/custo e caixa-preta; a seleção fica no `where` lexical.
- LLM a gerar o "por que apareceu" — alucinação; a proveniência é sempre trecho determinístico e o selo é o modo.
- Segundo passe de rerank por LLM no v1 (gatilho de revisitação: se recall/precisão do lexical+expansão não bastar, avaliar rerank sobre candidatos, como no C158).
- Curadoria de sinônimos/ontologia, busca global, Sollinha, exposição pública do acervo, analytics de busca — fora de escopo por intenção.
- Cache do resultado da expansão: v1 paga uma chamada por request (fatiar página re-expande). Gatilho: se latência/custo doer, cache in-process limitado por `normalizeForSearch(q)`.
- Skeletons da cena 05: aproveitar só o pending/dimming existente; skeleton fica para polish futuro.
## Riscos e mitigação
- **Latência de 1 chamada LLM por busca em página** (e por flip de página): timeout 4s + `generateObject`; a UI usa o pending existente (`aria-busy`); degradação honesta se estourar. Trigger de cache acima.
- **`LIKE` com curinga injetado**: `normalizeSpeechThemeTerms` remove `%`/`_` e limita tamanho/quantidade; o `where` continua parametrizado pelo Payload.
- **Divergência where ↔ espelho C180**: o predicado por termo é único (`speechMatchesSearchTerm`) e testado; qualquer mudança na perna textual tem que passar por ele no mesmo commit.
- **Regressão da busca exata**: default `termo` não emite parâmetro; `buildSpeechListWhere` sem `themeTerms` é byte-idêntico ao atual (teste dedicado).
- **Proveniência enganosa**: quando o casamento é literal, `matchedTextSearch`/`matchKind` continuam mandando; o selo "Tema" só aparece com `themeMatchTerm` real; `[]` do modelo não vira resultado.
- **Chamada externa para ator sem leitura**: guard `canReadSpeechCatalog(user.role)` antes de expandir; o `find` com access continua sendo a barreira final (fail-closed).
- **Custo/quota DeepSeek**: `deepseek-flash`, `maxOutputTokens` baixo, sem rerank; os 997 não são enviados.
- **Confusão do usuário ao degradar**: aviso preserva "Por tema foi pedido" e o seletor volta visualmente a "Termo exato", com retry explícito (cena 04) — nunca erro na cara.
- **e2e de risco**: o diff toca `src/utilities/ai`, então o PR roda o curado; `campaignSpeechAcervo` também é selecionado pelas prefixos de speech e cobre o modo degradado sem rede.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: tema sem palavras exatas devolve falas relacionadas; cada resultado explica termo exato vs tema com trecho; exata inalterada; vazio honesto; degradação com aviso, sem erro nem fantasma; facetas continuam valendo; fala é a unidade.
- [ ] Invariantes AGENTS/engineering-standards: `user` + `overrideAccess: false` em toda leitura; gate `canReadSpeech`/`canReadSpeechCatalog` não alargado; sem pgvector/embeddings; sem `openai/*`; copy pt-BR e identificadores em inglês; sem score numérico.
- [ ] Sem migration (justificado) — nenhum schema/collection/global alterado.
- [ ] Testes de domínio: unit do sanitizador, do módulo de expansão (mock de provider), do contrato de URL, do `where`, do espelho C180 e do view model; int com expansor injetado (tema, fallback, guard de acesso); e2e browserless do modo degradado.
- [ ] `pnpm gate:fast` verde; `pnpm push` com PR ready.
- [ ] Manifesto de e2e mantém cobertura de risco sem edição (`src/utilities/ai` e prefixos de speech já mapeados).
## Decisões de engenharia
### D1 — Mecanismo da busca por tema
**Opções:** A) LLM query expansion + motor lexical existente | B) embeddings query-time (DeepInfra) + cosine em memória sobre artefato | C) pgvector + embeddings + backfill.
**Recomendação:** A — resolve o job ("sem as palavras exatas") sem infra nova nem reindexação, mantém access/`where`/paginação por fala e a proveniência é lexical e determinística.
**Rejeitadas:** C porque é o rabbit hole textual da intenção (pipeline, backfill dos 997, storage, sync); B porque exige manter um artefato de embeddings (gerar/atualizar/servir) e introduz similaridade contínua — mais superfície e sem ganho comprovado no recorte de 997 falas; D) lexical puro sem expandir não atende o outcome ("defesa do SUS" continua sem achar outras palavras); E) mandar o acervo inteiro ao LLM estoura prompt/latência/custo e vira caixa-preta.
### D2 — Contrato de falha da expansão
**Opções:** A) `null` para indisponível **e** vazio | B) `null` só para indisponível; `{terms: []}` quando o modelo roda e não acrescenta nada | C) lançar erro e tratar na página.
**Recomendação:** B — distingue "mecanismo fora" (mostra o aviso da cena 04) de "mecanismo rodou, sem termos" (cai na busca literal sem aviso de indisponibilidade); é mais honesto que colapsar os dois.
**Rejeitadas:** A porque exibir "indisponível" quando o serviço respondeu vazio mente para o usuário; C porque quebra o contrato "nunca lança" do precedente `rerankSpeechExcerpts` e arrisca erro na cara.
### D3 — Parâmetro de URL do modo
**Opções:** A) `mode=tema` (default `termo`, omitido) | B) `mode=semantic`/`mode=exact` | C) `semantic=1` booleano.
**Recomendação:** A — nome English (`mode`, consistente com `q`/`year`/`topic`/…), valor slug pt-BR (`tema`/`termo`) consistente com a convenção de VALORES já fixada nos facets (`curta`/`media`/`sem_duracao`); default omitido mantém deep-links e a URL da busca exata idênticas (zero regressão). Registrado em `speechListParamNames` para o canonicalizador redirecionar valor desconhecido.
**Rejeitadas:** B porque inventa vocabulário em inglês para valores que o usuário vê em pt-BR; C porque um booleano não escala para um terceiro modo e foge do padrão de enum de facet.
### D4 — Proveniência do resultado
**Opções:** A) selo por item + trecho real determinístico | B) "porquê" gerado pelo LLM | C) aviso único no topo.
**Recomendação:** A — `themeMatchTerm` + `buildHighlightedExcerpt` sobre o texto do segmento/keyword; trecho existe no dado, sem alucinação; o selo "Tema"/"Termo exato" marca cada fala (cena 01).
**Rejeitadas:** B por risco de alucinação e min/max imprevisível; C porque não sustenta a confiança em cada fala e some ao rolar.
### D5 — Unidade do resultado e escopo do casamento
**Opções:** A) fala, com o `where` casando texto/keywords e (C174) origem de corte | B) fala + trechos | C) trechos.
**Recomendação:** A — o job é "qual fala serve"; a expansão só entra na perna textual da fala/keyword; o casamento por corte (C174) permanece ancorado só no `q` original (não expandimos títulos de corte) para não inflar superfície/custo.
**Rejeitadas:** B/C porque recorte fino de trecho é o job do C158 no Sollinha e mudaria a unidade/paginação atual.
### D6 — Cache/segundo passe
**Opções:** A) v1 sem cache e sem rerank | B) cache in-process da expansão já no v1 | C) rerank LLM dos candidatos no v1.
**Recomendação:** A — menos superfície, cabe no appetite; o `deepseek-flash` com timeout e `maxOutputTokens` baixo mantém o custo aceitável no interno.
**Rejeitadas:** B adiada (barata de reverter; gatilho: latência/custo por flip de página); C adiada (gatilho: precisão/recall insuficientes) — espelha a decisão do C158 de rerank como segunda etapa, não no caminho crítico.
## Self-score decision-quality
1. **Decisões caras com rejeitadas:** 5/5 — mecanismo, contrato de falha, contrato de URL e proveniência têm opções/recomendação/rejeitadas registradas; custo/rerank deferidos com gatilho.
2. **Cabe no appetite:** 5/5 — query-time, sem migration, sem infra; ~1–2 dias (server+tracer num dia, UI/gates no restante).
3. **Rabbit holes nomeados:** 5/5 — embeddings/pgvector/backfill, catálogo inteiro no LLM, "porquê" gerado, rerank/cache/curadoria explicitamente cortados.
4. **Depth check (reusa shells/helpers):** 5/5 — `buildSpeechListWhere*`, canonicalizador (`resolveListUrl`/`buildListHref`), `CampaignListOmnibox`, `CampaignListEmptyState`, `useCampaignListFilterNavigation`, `Alert`, `buildHighlightedExcerpt`/`speechSearchTerms`, e o contrato de injeção de `loadSpeechDetailPageData`.
5. **Intenção permanece satisfeita:** 5/5 — outcome, anti-goals e guardrails de acesso intactos; a engenharia não reescreveu o produto.
**Total: 5/5** (gate ≥4 satisfeito).
## Triage do /simplify (C192)
Aplicados no PR: gate `speechMatchesSearchTerm` no `pickThemeMatch` (não reivindicar tema que o `where` não usou), evidência por keyword em vez de trecho alheio, copy do vazio no caminho degradado, `aria-disabled` com no-op real, `temperature: 0` na expansão, `SPEECH_OMNIBOX_ID` compartilhado, `SpeechWatchButton` extraído, frase contínua no destaque + testes diretos, prompt interpolando as constantes.
Deferidos com gatilho:
- **Cache da expansão por `q` normalizado** (custo/latência por página e coerência de paginação). Gatilho: latência/custo doer ou paginação incoerente observada. Já previsto em D6.
- **Helper único para o boilerplate DeepSeek** (`generateObject` + guard de chave + timeout + catch→null), hoje repetido em `rerankSpeechExcerpts`, `campaignDemandTitle`, `speechCutMetadata` e `expandSpeechSearchTheme`. Gatilho: tocar qualquer um deles.
- **`collectOccurrences` em `speechHighlight`** (loop duplicado entre `findHighlightRanges`/`findPhraseRanges`). Gatilho: terceira variante de busca de ranges.
- **Sanitizar `themeTerms` na fronteira do `buildSpeechListWhere`** (defense-in-depth para futuros callers; produção já segura pelo provider). Gatilho: novo caller do builder.
- **`CampaignListEmptyState` com 3 props de classe para um caller** — reavaliar se surgir um segundo consumidor (variante nomeada). Gatilho: segundo call site.

View file

@ -475,27 +475,27 @@
<div class="flex flex-col gap-3">
<article class="rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
>
<svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="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"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/>
</svg>
Tema
</span>
<span class="text-xs text-muted-foreground"
>Fala · Data da fala · Duração</span
>
<svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/>
</svg>
Tema
</span>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div class="mt-3 flex items-start gap-4">
<div
@ -514,22 +514,20 @@
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="space-y-2" aria-label="Excerto da fala como placeholder">
<div class="h-2.5 w-full rounded bg-muted"></div>
<div class="h-2.5 w-11/12 rounded bg-muted"></div>
<div class="h-2.5 w-3/5 rounded bg-muted"></div>
</div>
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
>
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “…
atendimento público e acesso universal à saúde …”
<p class="mt-1 text-sm leading-6 text-foreground/90">
“… atendimento público e
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>acesso universal à saúde</mark
>
…”
</p>
</div>
</div>
@ -560,24 +558,24 @@
</article>
<article class="rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
><svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="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"
><svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<span class="text-xs text-muted-foreground"
>Fala · Data da fala · Duração</span
>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
<div
@ -594,21 +592,20 @@
</svg>
</div>
<div>
<div class="space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-4/5 rounded bg-muted"></div>
</div>
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
>
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “… presença
da atenção básica nos bairros e comunidades …”
<p class="mt-1 text-sm leading-6 text-foreground/90">
“… presença da
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>atenção básica</mark
>
nos bairros e comunidades …”
</p>
</div>
</div>
@ -631,24 +628,24 @@
</article>
<article class="rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
><svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="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"
><svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<span class="text-xs text-muted-foreground"
>Fala · Data da fala · Duração</span
>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
<div
@ -665,21 +662,20 @@
</svg>
</div>
<div>
<div class="space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-9/12 rounded bg-muted"></div>
</div>
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
>
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “…
fortalecimento da rede pública de atendimento …”
<p class="mt-1 text-sm leading-6 text-foreground/90">
“… fortalecimento da
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>rede pública de atendimento</mark
>
…”
</p>
</div>
</div>
@ -702,28 +698,28 @@
</article>
<article class="rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
><svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="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"
><svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<span
class="inline-flex h-6 items-center rounded-full border border-border bg-white px-2.5 text-xs font-medium"
>Termo exato</span
>
<span class="text-xs text-muted-foreground"
>Fala · Data da fala · Duração</span
>
<span
class="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
>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
<div
@ -740,21 +736,17 @@
</svg>
</div>
<div>
<div class="space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-5/6 rounded bg-muted"></div>
</div>
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
>
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “…
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
<p class="mt-1 text-sm leading-6 text-foreground/90">
“…
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>defesa do SUS</mark
>
…”
@ -916,21 +908,24 @@
</div>
<div class="space-y-3">
<article class="rounded-xl border border-border p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
><svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="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"
><svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
><span class="text-xs text-muted-foreground">Fala · Data</span>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div
class="mt-3 flex h-28 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground"
@ -945,17 +940,18 @@
<path d="m6 3 14 9-14 9z" />
</svg>
</div>
<div class="mt-3 space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-4/5 rounded bg-muted"></div>
</div>
<div class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2">
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary">
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “… atendimento público
e acesso universal à saúde …”
<p class="mt-1 text-sm leading-6 text-foreground/90">
“… atendimento público e
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>acesso universal à saúde</mark
>
…”
</p>
</div>
<a
@ -973,27 +969,27 @@
></a>
</article>
<article class="rounded-xl border border-border p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
>Tema</span
><span
class="inline-flex h-6 items-center rounded-full border border-border px-2.5 text-xs font-medium"
>Termo exato</span
>
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="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"
>Tema</span
><span
class="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
>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<p class="mt-2 text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
<div class="mt-3 space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-3/4 rounded bg-muted"></div>
</div>
<div class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2">
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary">
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5">
<span class="font-medium">Trecho exemplificativo:</span> “…
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
<p class="mt-1 text-sm leading-6 text-foreground/90">
“…
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>defesa do SUS</mark
>
…”
@ -1309,12 +1305,12 @@
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm leading-6 text-foreground/85">
Trecho exemplificativo com o termo
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
<p class="text-sm leading-6 text-foreground/90">
“… reafirmamos a
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>defesa do SUS</mark
>
destacado no contexto literal.
como compromisso público …”
</p>
<div class="mt-3 flex gap-1.5">
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs">Tema da fala</span
@ -1441,11 +1437,22 @@
</li>
<li>
<strong class="font-medium text-foreground">Selo:</strong> “Tema”; quando também há
casamento literal, o card acrescenta “Termo exato”, sem score numérico.
casamento literal, o card acrescenta “Termo exato”, sem score numérico. Os selos ocupam
uma linha própria, imediatamente antes da metainformação da fala; nunca ficam ao lado de
um título.
</li>
<li>
<strong class="font-medium text-foreground">Proveniência:</strong> bloco “Por que
apareceu” + trecho curto explicitamente exemplificativo neste artefato.
apareceu” + um único excerto real da fala, com o termo que de fato casou destacado. No
modo por tema, esse bloco substitui o excerto comum: as duas áreas nunca convivem nem
repetem o mesmo texto. “Trecho exemplificativo” não é copy do produto.
</li>
<li>
<strong class="font-medium text-foreground">Precedência visual:</strong> evidência na
própria fala pelo tema vence o estado “Fala de origem”, mesmo quando não houve casamento
literal. Sem evidência de tema e sem casamento literal, permanece integralmente o branch
C174 (“Fala de origem” + nota + correspondência no corte). O card comum fica para o
resultado literal fora desses dois casos.
</li>
<li>
<strong class="font-medium text-foreground">Miniaturas e conteúdo:</strong> caixas e

View file

@ -242,6 +242,12 @@ export default defineConfig({
// Keep e2e artifacts outside `.next`: a concurrent development server
// owns that entire directory and may clear nested production bundles.
NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e',
/*
* 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.
*/
DEEPSEEK_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

View file

@ -12,7 +12,10 @@ import {
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SpeechAcervoFilters } from '@/components/campaign/speech/SpeechAcervoFilters'
import { SpeechRefineSearchButton } from '@/components/campaign/speech/SpeechRefineSearchButton'
import { SpeechResultList } from '@/components/campaign/speech/SpeechResultList'
import { SpeechThemeFallbackNotice } from '@/components/campaign/speech/SpeechThemeFallbackNotice'
import { SpeechThemeRetryButton } from '@/components/campaign/speech/SpeechThemeRetryButton'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
@ -36,6 +39,8 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
if (data.redirectHref) redirect(data.redirectHref)
const hasFilters = buildSpeechFiltersKey(data.state) !== ''
const themeMode = data.state.mode === 'tema'
const themeActive = themeMode && data.themeApplied && !data.themeUnavailable
return (
<CampaignPageShell aria-label="Acervo de falas">
@ -53,28 +58,74 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
key={buildSpeechFiltersKey(data.state)}
state={data.state}
filterOptions={data.filterOptions}
themeUnavailable={data.themeUnavailable}
/>
{data.themeUnavailable ? <SpeechThemeFallbackNotice /> : null}
<CampaignListResults>
{themeMode ? (
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">
{data.themeUnavailable
? 'Resultados por termo exato'
: themeActive
? 'Resultados por tema'
: 'Resultados'}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{data.themeUnavailable
? 'Comportamento atual do acervo.'
: themeActive
? 'Confira o indício em cada fala antes de abrir.'
: 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'}
</p>
</div>
{data.themeUnavailable ? <SpeechThemeRetryButton /> : null}
</div>
) : null}
{data.rows.length > 0 ? (
<SpeechResultList rows={data.rows} query={data.state.q} />
) : (
<CampaignListEmptyState
icon={SearchXIcon}
className={themeActive ? 'border-solid' : undefined}
mediaClassName={themeActive ? 'size-12 rounded-full' : undefined}
contentClassName={themeActive ? 'max-w-none' : undefined}
title={
data.state.q
? `Nenhuma fala encontrada para "${data.state.q}"`
: 'Nenhuma fala encontrada'
themeActive
? 'Nenhuma fala encontrada para este tema'
: data.state.q
? `Nenhuma fala encontrada para "${data.state.q}"`
: 'Nenhuma fala encontrada'
}
description={
hasFilters
? 'Tente outro termo, remova filtros ou busque por um tema.'
: 'O acervo ainda não tem falas importadas.'
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.'
: hasFilters
? 'Tente outro termo, remova filtros ou busque por um tema.'
: 'O acervo ainda não tem falas importadas.'
}
>
<Button asChild variant="outline" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar busca e filtros</Link>
</Button>
{themeActive ? (
<div className="flex flex-wrap items-center justify-center gap-2">
<SpeechRefineSearchButton />
<Button asChild variant="outline" className="min-h-11">
<Link href={buildSpeechListHref({ ...data.state, mode: undefined }, 1)}>
Usar termo exato
</Link>
</Button>
<Button asChild variant="ghost" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar filtros</Link>
</Button>
</div>
) : (
<Button asChild variant="outline" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar busca e filtros</Link>
</Button>
)}
</CampaignListEmptyState>
)}

View file

@ -9,32 +9,40 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
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.
* transition like the rows it replaces. `className` / `mediaClassName` /
* `contentClassName` let a caller tune the shell without forking it (C192).
*/
export const CampaignListEmptyState = ({
icon: Icon,
title,
description,
children,
className,
mediaClassName,
contentClassName,
}: {
icon: LucideIcon
title: string
description: ReactNode
/** Call-to-action button(s). */
children?: ReactNode
className?: string
mediaClassName?: string
contentClassName?: string
}) => (
<Empty className="min-h-72 border">
<Empty className={cn('min-h-72 border', className)}>
<EmptyHeader>
<EmptyMedia variant="icon">
<EmptyMedia variant="icon" className={mediaClassName}>
<Icon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
{children ? <EmptyContent>{children}</EmptyContent> : null}
{children ? <EmptyContent className={contentClassName}>{children}</EmptyContent> : null}
</Empty>
)

View file

@ -1,5 +1,6 @@
'use client'
import { SparklesIcon, TriangleAlertIcon } from 'lucide-react'
import { useMemo, useOptimistic, useState } from 'react'
import {
@ -12,6 +13,7 @@ import {
} from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
import { cn } from '@/lib/utils'
import {
buildSpeechListHref,
SPEECH_DURATION_BUCKETS,
@ -20,14 +22,17 @@ import {
speechTopicLabels,
type SpeechFilterOptions,
type SpeechListState,
type SpeechSearchMode,
} from '@/utilities/speech/speechListUrl'
import {
applySpeechOmniboxSuggestion,
applySpeechSearchMode,
buildSpeechOmniboxChips,
buildSpeechOmniboxSuggestionSeeds,
clearSpeechOmnibox,
filterSpeechOmniboxSuggestions,
removeSpeechOmniboxChip,
SPEECH_OMNIBOX_ID,
type SpeechOmniboxAction,
} from '@/utilities/speech/speechOmnibox'
@ -58,12 +63,27 @@ const selectedTriggerLabel = (
return `${base}: ${values.length}`
}
/** C192 — segmented search-mode button (mobile full width, desktop inline). */
const modeButtonClass = (active: boolean, tone: 'termo' | 'tema'): string =>
cn(
'min-h-11 rounded-md px-3 text-sm md:min-h-9',
active
? cn(
'bg-white font-semibold shadow-sm ring-1 ring-border',
tone === 'tema' ? 'text-primary' : 'text-foreground',
)
: 'font-medium text-muted-foreground',
)
export const SpeechAcervoFilters = ({
state,
filterOptions,
themeUnavailable = false,
}: {
state: SpeechListState
filterOptions: SpeechFilterOptions
/** C192 — the theme expansion is down; the selector reflects the fallback. */
themeUnavailable?: boolean
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
@ -72,6 +92,9 @@ export const SpeechAcervoFilters = ({
const [query, setQuery] = useState('')
const [viewState, setOptimisticState] = useOptimistic(state)
const activeMode: SpeechSearchMode =
viewState.mode === 'tema' && !themeUnavailable ? 'tema' : 'termo'
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of filterOptions.municipalities) {
@ -110,6 +133,12 @@ export const SpeechAcervoFilters = ({
navigate(action.state)
}
const runMode = (mode: SpeechSearchMode) => {
const next = applySpeechSearchMode({ state: viewState, mode }).state
setOptimisticState(next)
navigate(next)
}
const facetRows = (
prefix: string,
options: readonly { value: string; label: string }[],
@ -206,7 +235,7 @@ export const SpeechAcervoFilters = ({
}}
>
<CampaignListOmnibox
id="speech-omnibox"
id={SPEECH_OMNIBOX_ID}
label="Buscar no acervo de falas"
placeholder="Busque por assunto, tema, município ou palavra-chave…"
chips={chips}
@ -228,6 +257,55 @@ export const SpeechAcervoFilters = ({
}}
/>
<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">
<span className="hidden text-xs font-medium text-muted-foreground md:inline">
Buscar por
</span>
<div
role="group"
aria-label="Modo de busca"
className="grid w-full grid-cols-2 rounded-lg bg-muted p-1 md:inline-flex md:w-auto"
>
<button
type="button"
aria-pressed={activeMode === 'termo'}
className={modeButtonClass(activeMode === 'termo', 'termo')}
onClick={() => runMode('termo')}
>
Termo exato
</button>
<button
type="button"
aria-pressed={activeMode === 'tema'}
aria-disabled={themeUnavailable || undefined}
className={modeButtonClass(activeMode === 'tema', 'tema')}
onClick={() => {
if (themeUnavailable) return
runMode('tema')
}}
>
<span className="inline-flex items-center gap-1.5">
{themeUnavailable ? (
<TriangleAlertIcon
className="size-3.5 text-estimate-pending-foreground"
aria-hidden="true"
/>
) : (
<SparklesIcon className="size-3.5" aria-hidden="true" />
)}
Por tema
</span>
</button>
</div>
</div>
<p className="hidden text-xs text-muted-foreground md:block">
{themeUnavailable
? 'Indisponível agora; mostramos o termo exato.'
: 'Encontra falas relacionadas pelo sentido.'}
</p>
</div>
<div
role="group"
aria-label="Filtros do acervo"

View file

@ -0,0 +1,21 @@
'use client'
import { Button } from '@/components/ui/button'
import { SPEECH_OMNIBOX_ID } from '@/utilities/speech/speechOmnibox'
/** C192 — the honest empty state's "reformulate" exit: focuses the omnibox. */
export const SpeechRefineSearchButton = () => (
<Button
type="button"
className="min-h-11"
onClick={() => {
const input = document.getElementById(SPEECH_OMNIBOX_ID)
if (input instanceof HTMLInputElement) {
input.scrollIntoView({ block: 'nearest' })
input.focus()
}
}}
>
Reformular busca
</Button>
)

View file

@ -1,4 +1,4 @@
import { ExternalLinkIcon, PlayIcon } from 'lucide-react'
import { ExternalLinkIcon, PlayIcon, SparklesIcon } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
@ -32,6 +32,42 @@ const SpeechExcerpt = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
)
}
/**
* 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>
<p className="mt-1 text-sm leading-6 text-foreground/90">
{`“${excerpt.truncatedStart ? '… ' : ''}`}
<SpeechHighlightParts parts={excerpt.parts} />
{`${excerpt.truncatedEnd ? ' …' : ''}”`}
</p>
</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,
@ -60,34 +96,47 @@ const SpeechMetaLine = ({
}: {
speech: SpeechListItemViewModel
originLabel?: boolean
}) => {
const items: string[] = []
if (originLabel) items.push('Fala de origem')
items.push(speech.speechAtLabel)
if (speech.type) items.push(speech.type)
if (speech.durationLabel) items.push(speech.durationLabel)
if (speech.presidingOfficer) items.push(`Presidiu: ${speech.presidingOfficer}`)
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{items.map((item, index) => (
<span key={item} className="inline-flex items-center gap-x-2 whitespace-nowrap">
{index > 0 ? <MetaSeparator /> : null}
<span>{item}</span>
</span>
))}
</div>
)
}
/**
* 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).
*/
const SpeechWatchButton = ({
speech,
watchLabel,
variant,
className,
}: {
speech: SpeechListItemViewModel
watchLabel: string
variant: 'default' | 'outline'
className: string
}) => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{originLabel ? (
<>
<span>Fala de origem</span>
<MetaSeparator />
</>
) : null}
<span>{speech.speechAtLabel}</span>
{speech.type ? (
<>
<MetaSeparator />
<span>{speech.type}</span>
</>
) : null}
{speech.durationLabel ? (
<>
<MetaSeparator />
<span>{speech.durationLabel}</span>
</>
) : null}
{speech.presidingOfficer ? (
<>
<MetaSeparator />
<span>Presidiu: {speech.presidingOfficer}</span>
</>
) : null}
</div>
<Button asChild variant={variant} className={className}>
<Link href={speech.watchHref}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
{watchLabel}
</Link>
</Button>
)
const SpeechActions = ({
@ -100,12 +149,12 @@ const SpeechActions = ({
fullWidthOnMobile?: boolean
}) => (
<div className={className}>
<Button asChild className={fullWidthOnMobile ? 'min-h-11 w-full md:w-auto' : 'min-h-11'}>
<Link href={speech.watchHref}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
</Link>
</Button>
<SpeechWatchButton
speech={speech}
watchLabel={speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
variant="default"
className={fullWidthOnMobile ? 'min-h-11 w-full md:w-auto' : 'min-h-11'}
/>
{speech.officialTextUrl ? (
<ActionLink
href={speech.officialTextUrl}
@ -129,8 +178,9 @@ 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).
if (!speech.matchedTextSearch) {
// 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) {
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">
@ -168,67 +218,109 @@ export const SpeechResultCard = ({
visibleTopics.length +
(speech.scopes.length - visibleScopes.length) +
(speech.keywords.length - visibleKeywords.length)
const watchLabel = speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'
const excerptNode = <SpeechExcerpt excerpt={speech.excerpt} />
const isTheme = speech.themeMatchTerm !== null
// C192 — a theme 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).
const bodyNode = isTheme ? (
<SpeechThemeProvenance excerpt={speech.excerpt} />
) : (
<SpeechExcerpt excerpt={speech.excerpt} />
)
const chips = (
<>
{visibleTopics.map((topic) => (
<Badge key={topic.value} variant="secondary" className="font-normal">
{topic.label}
</Badge>
))}
{visibleScopes.map((scope) => (
<Badge key={scope.value} variant="secondary" className="font-normal">
{scope.label}
</Badge>
))}
{visibleKeywords.map((keyword) => (
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
{keyword}
</Badge>
))}
{hiddenChips > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{hiddenChips}
</Badge>
) : null}
</>
)
const hasChips = Boolean(speech.topics.length || speech.scopes.length || speech.keywords.length)
return (
<article className="rounded-xl border bg-card p-4">
<SpeechMetaLine speech={speech} />
{isTheme ? (
<div className="flex flex-col items-start gap-2">
<SpeechThemeBadges matchedTextSearch={speech.matchedTextSearch} />
<SpeechMetaLine speech={speech} />
</div>
) : (
<SpeechMetaLine speech={speech} />
)}
<div className="mt-2">
<div className={isTheme ? 'mt-3' : 'mt-2'}>
{speech.thumbnailUrl ? (
<div className="flex flex-col items-start gap-3 sm:flex-row sm:gap-4">
<SpeechResultThumbnail
href={speech.watchHref}
src={speech.thumbnailUrl}
label={watchLabel}
fullWidthOnMobile={isTheme}
/>
<div className="min-w-0 flex-1">{excerptNode}</div>
<div className="min-w-0 flex-1">{bodyNode}</div>
</div>
) : (
excerptNode
bodyNode
)}
</div>
{speech.topics.length || speech.scopes.length || speech.keywords.length ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{visibleTopics.map((topic) => (
<Badge key={topic.value} variant="secondary" className="font-normal">
{topic.label}
</Badge>
))}
{visibleScopes.map((scope) => (
<Badge key={scope.value} variant="secondary" className="font-normal">
{scope.label}
</Badge>
))}
{visibleKeywords.map((keyword) => (
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
{keyword}
</Badge>
))}
{hiddenChips > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{hiddenChips}
</Badge>
) : null}
{isTheme ? (
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{hasChips ? <div className="flex flex-wrap gap-1.5">{chips}</div> : <span />}
<div className="flex flex-col items-stretch gap-2 md:flex-none md:flex-row md:items-center md:justify-end">
<SpeechWatchButton
speech={speech}
watchLabel={watchLabel}
variant="outline"
className="min-h-11 w-full max-md:border-primary max-md:bg-primary max-md:text-primary-foreground md:min-h-10 md:w-auto"
/>
{speech.officialTextUrl ? (
<ActionLink href={speech.officialTextUrl} variant="ghost" fullWidthOnMobile>
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir Diário Oficial
</ActionLink>
) : null}
</div>
</div>
) : null}
) : (
<>
{hasChips ? <div className="mt-3 flex flex-wrap gap-1.5">{chips}</div> : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button asChild className="min-h-11">
<Link href={speech.watchHref}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
{watchLabel}
</Link>
</Button>
{speech.officialTextUrl ? (
<ActionLink href={speech.officialTextUrl} variant="ghost">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir Diário Oficial
</ActionLink>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SpeechWatchButton
speech={speech}
watchLabel={watchLabel}
variant="default"
className="min-h-11"
/>
{speech.officialTextUrl ? (
<ActionLink href={speech.officialTextUrl} variant="ghost">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir Diário Oficial
</ActionLink>
) : null}
</div>
</>
)}
{speech.cuts.length ? <SpeechNestedCuts cuts={speech.cuts} query={query} /> : null}
</article>

View file

@ -4,21 +4,25 @@ import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
/**
* C175/C182 — the trecho thumbnail on the acervo list: a fixed 128×80 slot so
* the lazy image never shifts the card, with the skeleton filling it until the
* image paints (a missing image settles into the neutral slot). The link reuses
* the card's watch target, with the same accessible name as the CTA.
* C192 — theme results take the full width on mobile (`h-28`), like the gate.
*/
export const SpeechResultThumbnail = ({
href,
src,
label,
fullWidthOnMobile = false,
}: {
href: string
src: string
label: string
fullWidthOnMobile?: boolean
}) => {
const [settled, setSettled] = useState(false)
const imageRef = useRef<HTMLImageElement>(null)
@ -32,7 +36,10 @@ export const SpeechResultThumbnail = ({
<Link
href={href}
aria-label={label}
className="relative block h-20 w-32 shrink-0 overflow-hidden rounded-lg border bg-muted"
className={cn(
'relative block shrink-0 overflow-hidden rounded-lg border bg-muted',
fullWidthOnMobile ? 'h-28 w-full md:h-20 md:w-32' : 'h-20 w-32',
)}
>
{settled ? null : <Skeleton className="absolute inset-0 rounded-lg" />}
{/* eslint-disable-next-line @next/next/no-img-element -- external static image (poster route or YouTube cover). */}

View file

@ -0,0 +1,25 @@
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.
*/
export const SpeechThemeFallbackNotice = () => (
<Alert
variant="pending"
data-testid="speech-theme-fallback"
className="gap-3 border-estimate-pending/80 bg-estimate-pending/55 px-4 py-3"
>
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle className="text-sm font-medium">
A busca por tema 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.
</AlertDescription>
</Alert>
)

View file

@ -0,0 +1,24 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { Button } from '@/components/ui/button'
/** C192 — re-runs the same `mode=tema` request after a degraded fallback. */
export const SpeechThemeRetryButton = () => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
return (
<Button
type="button"
variant="outline"
className="min-h-10"
disabled={isPending}
onClick={() => startTransition(() => router.refresh())}
>
Tentar por tema novamente
</Button>
)
}

View file

@ -100,9 +100,41 @@ export const findHighlightRanges = (text: string, query: string): SpeechHighligh
}
}
ranges.sort((left, right) => left.start - right.start || left.end - right.end)
return mergeHighlightRanges(ranges)
}
/**
* C192 — every occurrence of the whole normalized PHRASE as a single range, so
* a theme match like "acesso universal à saúde" is one continuous band (the
* per-term search would drop the "à" and split the band). Falls back to the
* per-term ranges when the phrase does not occur contiguously.
*/
export const findPhraseRanges = (text: string, phrase: string): SpeechHighlightRange[] => {
const needle = normalizeForSearch(phrase)
if (!needle || text.length === 0) return []
const { normalized, sourceIndex } = buildNormalizedIndex(text)
if (normalized.length === 0) return []
const ranges: SpeechHighlightRange[] = []
let from = 0
while (from <= normalized.length - needle.length) {
const at = normalized.indexOf(needle, from)
if (at === -1) break
ranges.push({
start: sourceIndex[at]!,
end: sourceIndex[at + needle.length - 1]! + 1,
})
from = at + 1
}
return ranges.length > 0 ? mergeHighlightRanges(ranges) : findHighlightRanges(text, phrase)
}
const mergeHighlightRanges = (ranges: readonly SpeechHighlightRange[]): SpeechHighlightRange[] => {
const sorted = [...ranges].sort((left, right) => left.start - right.start || left.end - right.end)
const merged: SpeechHighlightRange[] = []
for (const range of ranges) {
for (const range of sorted) {
const last = merged[merged.length - 1]
if (last && range.start <= last.end) {
if (range.end > last.end) last.end = range.end
@ -142,12 +174,12 @@ export const splitHighlightedParts = (text: string, query: string): SpeechHighli
export const buildHighlightedExcerpt = (
text: string,
query: string,
{ radius = 160 }: { radius?: number } = {},
{ radius = 160, phrase = false }: { radius?: number; phrase?: boolean } = {},
): SpeechHighlightedExcerpt => {
const trimmed = text.trim()
if (trimmed === '') return { parts: [], truncatedStart: false, truncatedEnd: false }
const ranges = findHighlightRanges(trimmed, query)
const ranges = phrase ? findPhraseRanges(trimmed, query) : findHighlightRanges(trimmed, query)
const focus = ranges[0]
const windowStart = focus ? Math.max(0, focus.start - radius) : 0
const windowEnd = focus

View file

@ -21,12 +21,29 @@ type SpeechSearchableRecord = {
}
/**
* C180 — pure mirror of the textual `where` branch
* C180 — pure mirror of a SINGLE textual `where` branch
* (`speechListFilters.buildSpeechTextBranches`): normalized `searchText` LIKE
* the normalized query, OR a raw official keyword containing the query.
* the normalized term, OR a raw official keyword containing the term.
* `keywords` compare case-insensitively (accent still significant) to match the
* ILIKE `%q%` Postgres runs. Adding or removing a branch there means changing
* this predicate and its test in the same commit.
* ILIKE `%term%` Postgres runs. Adding or removing a branch there means
* changing this predicate and its test in the same commit. An empty term never
* matches (a branch with no term cannot surface a row).
*/
export const speechMatchesSearchTerm = (
speech: SpeechSearchableRecord,
term: string | undefined,
): boolean => {
const trimmed = term?.trim()
if (!trimmed) return false
if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true
const lowered = trimmed.toLowerCase()
return (speech.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(lowered))
}
/**
* C180 — the search query mirror: empty/missing query is an unconditional match
* (no textual filter), otherwise any of its terms. C192 reuses
* `speechMatchesSearchTerm` for the expanded theme terms.
*/
export const speechMatchesSearchQuery = (
speech: SpeechSearchableRecord,
@ -34,9 +51,7 @@ export const speechMatchesSearchQuery = (
): boolean => {
const trimmed = query?.trim()
if (!trimmed) return true
if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true
const lowered = trimmed.toLowerCase()
return (speech.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(lowered))
return speechMatchesSearchTerm(speech, trimmed)
}
/**

View file

@ -0,0 +1,30 @@
/**
* C192 — sanitization of the LLM-expanded theme terms before they reach the
* Payload `where`. The expansion is untrusted model output: `%`/`_`/`\` would
* widen the `LIKE`/`ILIKE` patterns, very short tokens produce noise, and the
* list has to stay bounded so the textual OR does not explode. Pure and
* client-safe.
*/
import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch'
export const MAX_SPEECH_THEME_TERMS = 8
export const MIN_SPEECH_THEME_TERMS = 3
export const MAX_SPEECH_THEME_TERM_LENGTH = 60
const MIN_THEME_TERM_LENGTH = 3
const LIKE_WILDCARDS = /[%_\\]/g
const sanitizeSpeechThemeTerm = (value: string): string =>
value
.replace(LIKE_WILDCARDS, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_SPEECH_THEME_TERM_LENGTH)
.trim()
/** Removes wildcards, drops too-short/empty terms, dedupes and caps the list. */
export const normalizeSpeechThemeTerms = (values: readonly string[]): string[] =>
uniqueByNormalizedForm(
values
.map(sanitizeSpeechThemeTerm)
.filter((term) => normalizeForSearch(term).length >= MIN_THEME_TERM_LENGTH),
).slice(0, MAX_SPEECH_THEME_TERMS)

View file

@ -0,0 +1,74 @@
import 'server-only'
import { deepSeek } from '@ai-sdk/deepseek'
import { generateObject } from 'ai'
import { z } from 'zod'
import {
MAX_SPEECH_THEME_TERM_LENGTH,
MAX_SPEECH_THEME_TERMS,
MIN_SPEECH_THEME_TERMS,
normalizeSpeechThemeTerms,
} from '@/lib/speechThemeTerms'
const EXPAND_TIMEOUT_MS = 4000
const MAX_OUTPUT_TOKENS = 200
const MAX_THEME_LENGTH = 200
const expandSchema = z.object({
terms: z
.array(z.string())
.describe(
`Termos e expressões curtas (${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS}) que podem aparecer na transcrição de falas sobre o tema. Lista vazia quando não houver termo confiável.`,
),
})
const EXPAND_SYSTEM_PROMPT =
'Você ajuda a assessoria de comunicação a achar, no acervo de discursos do deputado Jorge Solla, ' +
'falas sobre um tema mesmo quando ele não usou as palavras exatas da busca. ' +
'Receberá o tema digitado e deve devolver termos e expressões curtas que provavelmente aparecem ' +
'na transcrição de uma fala sobre esse tema — sinônimos, nomes de programas, termos técnicos e ' +
'palavras relacionadas. Inclua a própria expressão do tema quando ela for útil. ' +
`Devolva de ${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS} termos, cada um com no máximo ${MAX_SPEECH_THEME_TERM_LENGTH} caracteres, sem explicações, ` +
'sem pontuação extra e sem termos genéricos que apareceriam em qualquer discurso (ex.: "Brasil", ' +
'"povo", "governo"). Se não houver termo confiável, devolva a lista vazia. Nunca invente fatos.'
type SpeechThemeExpansion = { terms: string[] }
/**
* The injectable expansion boundary (tests pass a deterministic resolver, the
* same pattern as `SpeechVideoStartResolver`).
*/
export type SpeechThemeExpansionResolver = (theme: string) => Promise<SpeechThemeExpansion | null>
/**
* C192 — resolves the query into a theme expansion. Never throws: missing key,
* empty theme, timeout, provider error or malformed output resolve to `null`,
* and the caller falls back to the literal search with a discreet notice. An
* empty `terms` list is a legitimate answer (the model ran but had nothing to
* add) and is distinct from `null` ("mechanism unavailable").
*/
export const expandSpeechSearchTheme: SpeechThemeExpansionResolver = async (theme) => {
if (!process.env.DEEPSEEK_API_KEY) return null
const trimmed = theme.trim().slice(0, MAX_THEME_LENGTH)
if (!trimmed) return null
try {
const { object } = await generateObject({
model: deepSeek('deepseek-flash'),
schema: expandSchema,
system: EXPAND_SYSTEM_PROMPT,
prompt: trimmed,
// Deterministic as the provider allows: pages 2+ of the same search must
// not be built from a different expansion.
temperature: 0,
maxOutputTokens: MAX_OUTPUT_TOKENS,
abortSignal: AbortSignal.timeout(EXPAND_TIMEOUT_MS),
})
return { terms: normalizeSpeechThemeTerms(object.terms) }
} catch {
return null
}
}

View file

@ -49,22 +49,48 @@ const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
return filters
}
const buildSpeechTextBranches = (q: string): Where[] => [
{ searchText: { like: normalizeForSearch(q) } },
{ keywords: { contains: q } },
]
const buildSpeechTextBranches = (q: string, themeTerms: readonly 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 } })
}
}
return branches
}
/**
* 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.speechMatchesSearchQuery`
* (C180); a semantics change here has to land there too.
* 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.
*/
const buildSpeechTextWhere = (q: string): Where => ({ or: buildSpeechTextBranches(q) })
const buildSpeechTextWhere = (q: string, themeTerms: readonly string[] = []): Where => ({
or: buildSpeechTextBranches(q, themeTerms),
})
export const buildSpeechListWhere = (state: SpeechListState): Where => {
export const buildSpeechListWhere = (
state: SpeechListState,
themeTerms: readonly string[] = [],
): Where => {
const filters = buildSpeechFacetWhere(state)
if (state.q) filters.push(buildSpeechTextWhere(state.q))
if (state.q) filters.push(buildSpeechTextWhere(state.q, themeTerms))
return filters.length ? { and: filters } : {}
}
@ -72,15 +98,18 @@ export const buildSpeechListWhere = (state: SpeechListState): Where => {
/**
* 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).
* 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.
*/
export const buildSpeechListWhereIncludingCutOrigins = (
state: SpeechListState,
originSpeechIds: readonly number[],
themeTerms: readonly string[] = [],
): Where => {
const filters = buildSpeechFacetWhere(state)
if (state.q) {
const branches = buildSpeechTextBranches(state.q)
const branches = buildSpeechTextBranches(state.q, themeTerms)
if (originSpeechIds.length) branches.push({ id: { in: [...originSpeechIds] } })
filters.push({ or: branches })
}

View file

@ -26,6 +26,14 @@ 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`.
*/
export type SpeechSearchMode = 'termo' | 'tema'
/** Duration buckets are ranges over `durationSeconds`; `sem_duracao` is null. */
export const SPEECH_DURATION_BUCKETS = [
{ value: 'curta', label: 'Até 2 min' },
@ -51,6 +59,7 @@ export const speechScopeLabels = Object.fromEntries(
export type SpeechListState = {
page: number
q?: string
mode?: SpeechSearchMode
years?: number[]
topics?: SpeechTopic[]
scopes?: SpeechScope[]
@ -70,6 +79,7 @@ type RawSpeechListParams = RawSearchParams
const speechListParamNames = [
'q',
'mode',
'year',
'topic',
'scope',
@ -95,10 +105,15 @@ const parseMunicipalityValues = (raw: string | string[] | undefined): number[] =
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => id !== undefined && id > 0)
/** Only `tema` is meaningful; anything else (and `termo`) means the default. */
const parseSpeechSearchMode = (raw: string | string[] | undefined): SpeechSearchMode | undefined =>
firstValue(raw) === 'tema' ? 'tema' : undefined
export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const rawQ = normalizedText(firstValue(params.q))
const q = rawQ && isContactSearchQueryReady(rawQ) ? rawQ : undefined
const mode = parseSpeechSearchMode(params.mode)
const years = parseYearValues(params.year)
const topics = parseExhaustiveEnumParam<SpeechTopic>(params.topic, speechTopicSet)
const scopes = parseExhaustiveEnumParam<SpeechScope>(params.scope, speechScopeSet)
@ -112,6 +127,7 @@ export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListSt
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(mode ? { mode } : {}),
...(years.length ? { years } : {}),
...(topics.length ? { topics } : {}),
...(scopes.length ? { scopes } : {}),
@ -127,6 +143,7 @@ const speechListStateToRawParams = (
): RawSpeechListParams => ({
page: String(page),
q: state.q,
mode: state.mode,
year: state.years?.map(String),
topic: state.topics,
scope: state.scopes,
@ -141,6 +158,9 @@ export const serializeCanonicalSpeechListSearchParams = (
): URLSearchParams => {
const params = new URLSearchParams()
if (canonicalState.q) params.set('q', canonicalState.q)
// `termo` is the default and never serialized; `tema` only makes sense with a
// query, so a theme param without `q` canonicalizes away.
if (canonicalState.mode === 'tema' && canonicalState.q) params.set('mode', 'tema')
for (const year of canonicalState.years ?? []) params.append('year', String(year))
for (const topic of canonicalState.topics ?? []) params.append('topic', topic)
for (const scope of canonicalState.scopes ?? []) params.append('scope', scope)

View file

@ -22,6 +22,7 @@ import {
speechTopicLabels,
type SpeechDurationBucket,
type SpeechListState,
type SpeechSearchMode,
} from '@/utilities/speech/speechListUrl'
export type SpeechFilterOption = {
@ -29,6 +30,12 @@ export type SpeechFilterOption = {
label: string
}
/**
* C192 — the omnibox input's DOM id, shared by the filters bar and the empty
* state's "Reformular busca" focus action.
*/
export const SPEECH_OMNIBOX_ID = 'speech-omnibox'
export type SpeechOmniboxAction =
| { kind: 'url'; state: SpeechListState }
| { kind: 'clear'; state: SpeechListState }
@ -238,13 +245,32 @@ export const removeSpeechOmniboxChip = ({
state: SpeechListState
chipId: string
}): SpeechOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
// Dropping the query also drops the theme mode: a theme without a query has
// nothing to expand.
if (chipId === 'q') {
return { kind: 'url', state: withPageReset({ ...state, q: undefined, mode: undefined }) }
}
const { key, value } = chipPrefix(chipId)
const next = mutateFacet(withPageReset(state), key, value, 'remove')
return { kind: 'url', state: next ?? state }
}
/**
* C192 — the search mode toggle. `tema` only sticks with a query; `termo`
* (the default) clears the param so the URL stays canonical and shareable.
*/
export const applySpeechSearchMode = ({
state,
mode,
}: {
state: SpeechListState
mode: SpeechSearchMode
}): SpeechOmniboxAction => ({
kind: 'url',
state: withPageReset({ ...state, mode: mode === 'tema' && state.q ? 'tema' : undefined }),
})
export const clearSpeechOmnibox = (_state: SpeechListState): SpeechOmniboxAction => ({
kind: 'clear',
state: { page: 1 },

View file

@ -2,8 +2,13 @@ import 'server-only'
import type { Payload } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
import type { CampaignUser, Speech } from '@/payload-types'
import {
expandSpeechSearchTheme,
type SpeechThemeExpansionResolver,
} from '@/utilities/ai/expandSpeechSearchTheme'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
import { getYouTubeVideoStart } from '@/utilities/socialFeed/youtubeFeed'
@ -155,17 +160,36 @@ export type SpeechAcervoPageData = {
totalDocs: number
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.
*/
themeUnavailable: boolean
/** C192 — true when the expansion actually contributed terms. */
themeApplied: boolean
}
export const loadSpeechAcervoPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<SpeechListSearchParams> | SpeechListSearchParams,
expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme,
): 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 themeRequested = state.mode === 'tema' && Boolean(state.q)
let themeTerms: readonly string[] = []
let themeUnavailable = false
if (themeRequested && canReadSpeechCatalog(user.role)) {
const expansion = await expandTheme(state.q ?? '')
if (expansion) themeTerms = expansion.terms
else themeUnavailable = 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.
const originSpeechIds = state.q ? await loadSpeechCutOriginSpeechIds(payload, user, state.q) : []
@ -176,7 +200,7 @@ export const loadSpeechAcervoPageData = async (
limit: speechPageSize,
page: state.page,
sort: '-speechAt',
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds),
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds, themeTerms),
// 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,
@ -210,6 +234,7 @@ export const loadSpeechAcervoPageData = async (
query: state.q,
municipalityLabels,
cuts: cutsBySpeech.get(speech.id) ?? [],
themeTerms,
}),
),
state: resolvedUrl.state,
@ -217,6 +242,8 @@ export const loadSpeechAcervoPageData = async (
totalDocs: result.totalDocs,
totalPages: result.totalPages,
filterOptions,
themeUnavailable,
themeApplied: themeTerms.length > 0,
}
}

View file

@ -17,7 +17,11 @@ import {
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
import { speechPosterHref, speechPosterTarget } from '@/lib/speechPoster'
import { normalizeForSearch, speechMatchesSearchQuery } from '@/lib/speechSearch'
import {
normalizeForSearch,
speechMatchesSearchQuery,
speechMatchesSearchTerm,
} from '@/lib/speechSearch'
import {
correctedExcerptOffsetSeconds,
excerptOffsetSeconds,
@ -55,7 +59,7 @@ export type SpeechListRecord = {
searchText?: string | null
}
type SpeechListMatchKind = 'segment' | 'keyword' | 'fallback'
type SpeechListMatchKind = 'segment' | 'keyword' | 'theme' | 'fallback'
export type SpeechListItemViewModel = {
id: number
@ -89,6 +93,12 @@ export type SpeechListItemViewModel = {
* the term; the card then swaps the excerpt for the honest origin note.
*/
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.
*/
themeMatchTerm: string | null
}
export type SpeechDetailSegmentViewModel = {
@ -185,6 +195,41 @@ const pickMatchingSegment = (
})
}
/**
* 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.
*/
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 const buildWatchHref = (
speechId: number,
segment: SpeechSegmentRecord | undefined,
@ -216,6 +261,7 @@ export const toSpeechListItemViewModel = ({
query,
municipalityLabels,
cuts = [],
themeTerms = [],
}: {
speech: SpeechListRecord
segments: readonly SpeechSegmentRecord[]
@ -223,6 +269,8 @@ 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[]
}): SpeechListItemViewModel => {
const matchedSegment = pickMatchingSegment(segments, query)
const q = query?.trim()
@ -232,8 +280,25 @@ 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)
const excerptSource =
matchedSegment?.text ?? segments[0]?.text ?? speech.summary ?? speech.officialTranscript ?? ''
themeMatch?.segment?.text ??
themeMatch?.keyword ??
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 ?? '')
return {
id: speech.id,
@ -241,8 +306,14 @@ export const toSpeechListItemViewModel = ({
type: speech.type ?? null,
durationLabel: formatSpeechDuration(speech.durationSeconds),
presidingOfficer: speech.presidingOfficer ?? null,
excerpt: buildHighlightedExcerpt(excerptSource, q ?? ''),
matchKind: matchedSegment ? 'segment' : keywordMatch ? 'keyword' : 'fallback',
excerpt,
matchKind: matchedSegment
? 'segment'
: keywordMatch
? 'keyword'
: themeMatch
? 'theme'
: 'fallback',
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
keywords: speech.keywords ?? [],
@ -252,6 +323,7 @@ export const toSpeechListItemViewModel = ({
officialTextUrl: speech.officialTextUrl ?? null,
cuts: [...cuts],
matchedTextSearch: speechMatchesSearchQuery(speech, query),
themeMatchTerm: themeMatch?.term ?? null,
}
}

View file

@ -335,6 +335,43 @@ 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 ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('temadeg')
await createSpeech(campaign, { marker })
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.
const themed = await request.get(`/campanha/comunicacao/acervo?q=${marker}&mode=tema`)
expect(themed.status()).toBe(200)
const themedHtml = rendered(await themed.text())
expect(themedHtml).toContain('Modo de busca')
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('Tentar por tema novamente')
expect(themedHtml).toContain('Resultados por termo exato')
expect(themedHtml).toContain('data-testid="speech-theme-fallback"')
// 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')
// 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.')
})
test('advisor and leader are redirected away from the acervo', async ({
campaign,
campaignRequest,

View file

@ -347,4 +347,73 @@ describe('speech acervo (C154)', () => {
loadSpeechDetailPageData(payload, communicator, 999_999_999),
).rejects.toBeInstanceOf(SpeechNotFoundError)
})
it('finds a speech by expanded theme terms and explains the match (C192)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `tema${runId}`
const id = await createSpeech({
segments: [
{
startSeconds: 0,
endSeconds: 3,
text: `Garantir o atendimento universal ${marker} na rede pública`,
},
],
})
const { coordinator } = await createUsers()
const expansionCalls: string[] = []
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: 'defesa do SUS', mode: 'tema' },
async (theme) => {
expansionCalls.push(theme)
return { terms: [`atendimento universal ${marker}`] }
},
)
expect(expansionCalls).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?.matchKind).toBe('theme')
expect(row?.themeMatchTerm).toBe(`atendimento universal ${marker}`)
expect(row?.matchedTextSearch).toBe(false)
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('degrades to the literal search when the expansion is unavailable (C192)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `literal${runId}`
const id = await createSpeech({
segments: [{ startSeconds: 0, endSeconds: 3, text: `A defesa do SUS ${marker} no plenário` }],
})
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: `defesa do SUS ${marker}`, mode: 'tema' },
async () => null,
)
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?.matchedTextSearch).toBe(true)
})
it('never expands for an actor outside the catalog gate (C192)', async () => {
const { advisor } = await createUsers()
let called = false
await expect(
loadSpeechAcervoPageData(payload, advisor, { q: 'SUS', mode: 'tema' }, async () => {
called = true
return { terms: ['saúde'] }
}),
).rejects.toThrow(/permissão/i)
expect(called).toBe(false)
})
})

View file

@ -0,0 +1,69 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { generateObjectMock, deepSeekMock } = vi.hoisted(() => ({
generateObjectMock: vi.fn(),
deepSeekMock: vi.fn(() => 'mock-model'),
}))
vi.mock('ai', () => ({ generateObject: generateObjectMock }))
vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: deepSeekMock }))
import { expandSpeechSearchTheme } from '@/utilities/ai/expandSpeechSearchTheme'
beforeEach(() => {
process.env.DEEPSEEK_API_KEY = 'test-deepseek-key'
generateObjectMock.mockReset()
deepSeekMock.mockClear()
})
afterEach(() => {
delete process.env.DEEPSEEK_API_KEY
})
describe('expandSpeechSearchTheme (C192)', () => {
it('returns the sanitized terms the model produced', async () => {
generateObjectMock.mockResolvedValue({
object: { terms: ['SUS%', 'saúde pública', 'sus', 'ab'] },
})
await expect(expandSpeechSearchTheme('defesa do SUS')).resolves.toEqual({
terms: ['SUS', 'saúde pública'],
})
})
it('honra a lista vazia como resposta legítima (não é indisponibilidade)', async () => {
generateObjectMock.mockResolvedValue({ object: { terms: [] } })
await expect(expandSpeechSearchTheme('tema qualquer')).resolves.toEqual({ terms: [] })
})
it('curto-circuita sem chave sem chamar o provedor', async () => {
delete process.env.DEEPSEEK_API_KEY
await expect(expandSpeechSearchTheme('SUS')).resolves.toBeNull()
expect(generateObjectMock).not.toHaveBeenCalled()
})
it('curto-circuita um tema vazio', async () => {
await expect(expandSpeechSearchTheme(' ')).resolves.toBeNull()
expect(generateObjectMock).not.toHaveBeenCalled()
})
it('degrada para null em erro do provedor — nunca lança', async () => {
generateObjectMock.mockRejectedValue(new Error('provider down'))
await expect(expandSpeechSearchTheme('SUS')).resolves.toBeNull()
})
it('envia o tema e um abortSignal limitado ao modelo', async () => {
generateObjectMock.mockResolvedValue({ object: { terms: ['SUS'] } })
await expandSpeechSearchTheme('defesa do SUS')
const call = generateObjectMock.mock.calls[0]![0] as { prompt: string; abortSignal: unknown }
expect(call.prompt).toBe('defesa do SUS')
expect(call.abortSignal).toBeInstanceOf(AbortSignal)
})
})

View file

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import {
buildHighlightedExcerpt,
findHighlightRanges,
findPhraseRanges,
matchesSearchTerms,
splitHighlightedParts,
} from '@/lib/speechHighlight'
@ -42,6 +43,32 @@ describe('findHighlightRanges', () => {
})
})
describe('findPhraseRanges (C192)', () => {
it('highlights the whole phrase as one continuous range, keeping the accents', () => {
const text = 'Defendemos o atendimento público e acesso universal à saúde para todos'
const ranges = findPhraseRanges(text, 'acesso universal à saúde')
expect(ranges).toHaveLength(1)
expect(text.slice(ranges[0]!.start, ranges[0]!.end)).toBe('acesso universal à saúde')
})
it('finds overlapping occurrences of the same phrase', () => {
expect(findPhraseRanges('ana ana', 'ana')).toEqual([
{ start: 0, end: 3 },
{ start: 4, end: 7 },
])
})
it('falls back to the per-term ranges when the phrase is not contiguous', () => {
const ranges = findPhraseRanges('saúde pública e muito acesso', 'acesso universal')
expect(ranges).toEqual([{ start: 22, end: 28 }])
})
it('returns nothing for an empty phrase', () => {
expect(findPhraseRanges('texto', '')).toEqual([])
})
})
describe('matchesSearchTerms', () => {
it('requires every term, in any order', () => {
expect(matchesSearchTerms('A defesa do SUS e da saúde', 'saude sus')).toBe(true)

View file

@ -122,3 +122,61 @@ describe('buildSpeechListWhereIncludingCutOrigins (C174)', () => {
expect(buildSpeechListWhereIncludingCutOrigins(state, [])).toEqual(buildSpeechListWhere(state))
})
})
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: [
{
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: [{ or: [{ searchText: { like: 'sus' } }, { keywords: { contains: 'SUS' } }] }],
})
})
it('keeps the facets AND-ed and the cut origins OR-ed alongside the expansion', () => {
expect(
buildSpeechListWhereIncludingCutOrigins(
parseSpeechListParams({ q: 'reforma', topic: ['saude'] }),
[7],
['saúde'],
),
).toEqual({
and: [
{ topics: { in: ['saude'] } },
{
or: [
{ searchText: { like: 'reforma' } },
{ keywords: { contains: 'reforma' } },
{ searchText: { like: 'saude' } },
{ keywords: { contains: 'saúde' } },
{ id: { in: [7] } },
],
},
],
})
})
it('stays byte-identical to today without theme terms', () => {
const state = parseSpeechListParams({ q: 'reforma' })
expect(buildSpeechListWhere(state, [])).toEqual(buildSpeechListWhere(state))
})
})

View file

@ -67,6 +67,12 @@ describe('parseSpeechListParams', () => {
expect(state.topics).toBeUndefined()
expect(state.durations).toBeUndefined()
})
it('parses the theme mode and ignores any other value (C192)', () => {
expect(parseSpeechListParams({ q: 'SUS', mode: 'tema' }).mode).toBe('tema')
expect(parseSpeechListParams({ q: 'SUS', mode: 'termo' }).mode).toBeUndefined()
expect(parseSpeechListParams({ q: 'SUS', mode: 'exato' }).mode).toBeUndefined()
})
})
describe('serializeCanonicalSpeechListSearchParams', () => {
@ -84,6 +90,18 @@ describe('serializeCanonicalSpeechListSearchParams', () => {
expect(params.getAll('topic')).toEqual(['saude', 'educacao'])
expect(params.getAll('duration')).toEqual(['curta'])
})
it('serializes mode=tema only alongside a query (C192)', () => {
expect(
serializeCanonicalSpeechListSearchParams(
parseSpeechListParams({ q: 'SUS', mode: 'tema' }),
).toString(),
).toBe('q=SUS&mode=tema')
// A theme without a query canonicalizes away.
expect(
serializeCanonicalSpeechListSearchParams(parseSpeechListParams({ mode: 'tema' })).toString(),
).toBe('')
})
})
describe('buildSpeechListHref', () => {
@ -93,6 +111,15 @@ describe('buildSpeechListHref', () => {
)
expect(buildSpeechListHref(parseSpeechListParams({}), 1)).toBe('/campanha/comunicacao/acervo')
})
it('keeps the exact-search deep link byte-identical (C192)', () => {
expect(buildSpeechListHref(parseSpeechListParams({ q: 'SUS' }), 1)).toBe(
'/campanha/comunicacao/acervo?q=SUS',
)
expect(buildSpeechListHref(parseSpeechListParams({ q: 'SUS', mode: 'tema' }), 1)).toBe(
'/campanha/comunicacao/acervo?q=SUS&mode=tema',
)
})
})
describe('resolveSpeechListUrl', () => {
@ -107,6 +134,15 @@ describe('resolveSpeechListUrl', () => {
expect(resolved.state.page).toBe(2)
expect(resolved.redirectHref).toContain('page=2')
})
it('canonicalizes a theme mode without a query into a redirect (C192)', () => {
const withQuery = resolveSpeechListUrl({ q: 'SUS', mode: 'tema' })
expect(withQuery.state.mode).toBe('tema')
expect(withQuery.redirectHref).toBeUndefined()
const empty = resolveSpeechListUrl({ mode: 'tema' })
expect(empty.redirectHref).toBe('/campanha/comunicacao/acervo')
})
})
describe('buildSpeechFiltersKey', () => {

View file

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import { parseSpeechListParams, type SpeechListState } from '@/utilities/speech/speechListUrl'
import {
applySpeechOmniboxSuggestion,
applySpeechSearchMode,
buildSpeechOmniboxChips,
clearSpeechOmnibox,
removeSpeechOmniboxChip,
@ -96,9 +97,34 @@ describe('removeSpeechOmniboxChip', () => {
expect(emptied.state.topics).toBeUndefined()
})
it('removes the search chip', () => {
const action = removeSpeechOmniboxChip({ state: state({ q: 'SUS' }), chipId: 'q' })
it('removes the search chip and the theme mode with it (C192)', () => {
const action = removeSpeechOmniboxChip({
state: state({ q: 'SUS', mode: 'tema' }),
chipId: 'q',
})
expect(action.state.q).toBeUndefined()
expect(action.state.mode).toBeUndefined()
})
})
describe('applySpeechSearchMode (C192)', () => {
it('sets tema only with a query and resets the page', () => {
expect(applySpeechSearchMode({ state: state({ q: 'SUS', page: '4' }), mode: 'tema' })).toEqual({
kind: 'url',
state: { page: 1, q: 'SUS', mode: 'tema' },
})
expect(applySpeechSearchMode({ state: state({ page: '4' }), mode: 'tema' }).state.mode).toBe(
undefined,
)
})
it('clears the mode back to the exact search', () => {
const action = applySpeechSearchMode({
state: state({ q: 'SUS', mode: 'tema' }),
mode: 'termo',
})
expect(action.state.mode).toBeUndefined()
})
})

View file

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import {
normalizeForSearch,
speechMatchesSearchQuery,
speechMatchesSearchTerm,
uniqueByNormalizedForm,
} from '@/lib/speechSearch'
@ -47,3 +48,18 @@ describe('speechMatchesSearchQuery', () => {
expect(speechMatchesSearchQuery(speech, ' ')).toBe(true)
})
})
describe('speechMatchesSearchTerm (C192)', () => {
it('mirrors a single textual where branch', () => {
const speech = { searchText: 'a saude publica baiana', keywords: ['Farmácia Popular'] }
expect(speechMatchesSearchTerm(speech, 'SAÚDE Pública')).toBe(true)
expect(speechMatchesSearchTerm(speech, 'FARMÁCIA')).toBe(true)
expect(speechMatchesSearchTerm(speech, 'educação')).toBe(false)
})
it('never matches an empty term', () => {
const speech = { searchText: 'a saude publica', keywords: ['SUS'] }
expect(speechMatchesSearchTerm(speech, undefined)).toBe(false)
expect(speechMatchesSearchTerm(speech, ' ')).toBe(false)
})
})

View file

@ -0,0 +1,32 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { MAX_SPEECH_THEME_TERMS, normalizeSpeechThemeTerms } from '@/lib/speechThemeTerms'
describe('normalizeSpeechThemeTerms (C192)', () => {
it('removes LIKE wildcards so the expansion cannot widen the pattern', () => {
expect(normalizeSpeechThemeTerms(['saúde%', 'a_b', 'c\\d'])).toEqual(['saúde', 'a b', 'c d'])
})
it('drops empty and too-short terms', () => {
expect(normalizeSpeechThemeTerms(['', ' ', 'ab', 'SUS', 'de'])).toEqual(['SUS'])
})
it('dedupes by normalized form keeping the first spelling', () => {
expect(normalizeSpeechThemeTerms(['Saúde Pública', 'saude publica', 'SUS'])).toEqual([
'Saúde Pública',
'SUS',
])
})
it('caps the list length', () => {
const values = Array.from({ length: 20 }, (_, index) => `termo ${index}`)
expect(normalizeSpeechThemeTerms(values)).toHaveLength(MAX_SPEECH_THEME_TERMS)
})
it('truncates a single term to the max length', () => {
const term = 'x'.repeat(90)
expect(normalizeSpeechThemeTerms([term])[0]).toHaveLength(60)
})
})

View file

@ -64,3 +64,115 @@ describe('toSpeechListItemViewModel thumbnail (C182 — frame of the speech)', (
expect(viewModel({ durationSeconds: 252, eventId: 67091 }).thumbnailUrl).toBeNull()
})
})
describe('toSpeechListItemViewModel theme match (C192)', () => {
const segment = (text: string) => ({ startSeconds: 0, endSeconds: 3, text })
const themeRow = ({
text,
query,
themeTerms,
keywords = [],
searchText,
}: {
text: string
query?: string
themeTerms: string[]
keywords?: string[]
searchText?: string
}) =>
toSpeechListItemViewModel({
speech: { id: 1, speechAt: '2026-08-11T18:48', keywords, searchText },
segments: [segment(text)],
query,
municipalityLabels: new Map(),
themeTerms,
})
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'],
})
expect(row.matchKind).toBe('theme')
expect(row.themeMatchTerm).toBe('acesso universal')
expect(row.matchedTextSearch).toBe(false)
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('keeps the theme term when the speech also matches the literal query', () => {
const row = themeRow({
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'],
})
expect(row.matchKind).toBe('segment')
expect(row.themeMatchTerm).toBe('saúde pública')
expect(row.matchedTextSearch).toBe(true)
})
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'],
})
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.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'],
})
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.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
})