From 45eef25b1a13570fca527f14bf949ef5a995c84f Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 18 Sep 2026 17:26:18 -0300 Subject: [PATCH] =?UTF-8?q?feat(C192):=20busca=20sem=C3=A2ntica=20no=20ace?= =?UTF-8?q?rvo=20de=20falas=20(Closes=20#1147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/changelog/2026-09-18-c192.md | 1 + docs/plans/acervo-busca-semantica-impl.md | 156 +++++++++ .../acervo-busca-semantica-ui-design.html | 313 +++++++++--------- playwright.config.ts | 6 + .../(app)/comunicacao/acervo/page.tsx | 69 +++- .../shared/CampaignListEmptyState.tsx | 16 +- .../campaign/speech/SpeechAcervoFilters.tsx | 80 ++++- .../speech/SpeechRefineSearchButton.tsx | 21 ++ .../campaign/speech/SpeechResultCard.tsx | 250 +++++++++----- .../campaign/speech/SpeechResultThumbnail.tsx | 9 +- .../speech/SpeechThemeFallbackNotice.tsx | 25 ++ .../speech/SpeechThemeRetryButton.tsx | 24 ++ src/lib/speechHighlight.ts | 40 ++- src/lib/speechSearch.ts | 29 +- src/lib/speechThemeTerms.ts | 30 ++ src/utilities/ai/expandSpeechSearchTheme.ts | 74 +++++ src/utilities/speech/speechListFilters.ts | 55 ++- src/utilities/speech/speechListUrl.ts | 20 ++ src/utilities/speech/speechOmnibox.ts | 28 +- src/utilities/speech/speechPageData.ts | 29 +- src/utilities/speech/speechViewModels.ts | 82 ++++- tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 37 +++ tests/int/speechAcervo.int.spec.ts | 69 ++++ .../unit/expandSpeechSearchTheme.unit.spec.ts | 69 ++++ tests/unit/speechHighlight.unit.spec.ts | 27 ++ tests/unit/speechListFilters.unit.spec.ts | 58 ++++ tests/unit/speechListUrl.unit.spec.ts | 36 ++ tests/unit/speechOmnibox.unit.spec.ts | 30 +- tests/unit/speechSearch.unit.spec.ts | 16 + tests/unit/speechThemeTerms.unit.spec.ts | 32 ++ tests/unit/speechViewModels.unit.spec.ts | 112 +++++++ 31 files changed, 1563 insertions(+), 280 deletions(-) create mode 100644 docs/changelog/2026-09-18-c192.md create mode 100644 docs/plans/acervo-busca-semantica-impl.md create mode 100644 src/components/campaign/speech/SpeechRefineSearchButton.tsx create mode 100644 src/components/campaign/speech/SpeechThemeFallbackNotice.tsx create mode 100644 src/components/campaign/speech/SpeechThemeRetryButton.tsx create mode 100644 src/lib/speechThemeTerms.ts create mode 100644 src/utilities/ai/expandSpeechSearchTheme.ts create mode 100644 tests/unit/expandSpeechSearchTheme.unit.spec.ts create mode 100644 tests/unit/speechThemeTerms.unit.spec.ts diff --git a/docs/changelog/2026-09-18-c192.md b/docs/changelog/2026-09-18-c192.md new file mode 100644 index 00000000..7d25d36c --- /dev/null +++ b/docs/changelog/2026-09-18-c192.md @@ -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). diff --git a/docs/plans/acervo-busca-semantica-impl.md b/docs/plans/acervo-busca-semantica-impl.md new file mode 100644 index 00000000..44f6bb95 --- /dev/null +++ b/docs/plans/acervo-busca-semantica-impl.md @@ -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
literal atual] + M -- sim --> EXP[expandSpeechSearchTheme
DeepSeek generateObject] + EXP -- null/erro --> DEG[where literal +
alerta discreto] + EXP -- terms --> WHERE[where OR: q + termos expandidos
AND facetas + OR cortes C174] + DEG --> Q[payload.find
user · overrideAccess:false] + WHERE --> Q + Q --> VM[toSpeechListItemViewModel
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=&mode=tema` → 200, seletor "Buscar por", aviso "A busca por tema está indisponível agora." e resultado exato do marker; `?q=` (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. diff --git a/docs/plans/acervo-busca-semantica-ui-design.html b/docs/plans/acervo-busca-semantica-ui-design.html index d78113ea..6740d2d5 100644 --- a/docs/plans/acervo-busca-semantica-ui-design.html +++ b/docs/plans/acervo-busca-semantica-ui-design.html @@ -475,27 +475,27 @@
-
- -
+ - - - Tema - - Fala · Data da fala · Duração + + Tema + +
+

Fala · Data da fala · Duração

-
-
-
-
-

Por que apareceu

-

- Trecho exemplificativo: “… - atendimento público e acesso universal à saúde …” +

+ “… atendimento público e + acesso universal à saúde + …”

@@ -560,24 +558,24 @@
-
- +
+ + Tema - Tema - Fala · Data da fala · Duração +
+

Fala · Data da fala · Duração

-
-
-
-

Por que apareceu

-

- Trecho exemplificativo: “… presença - da atenção básica nos bairros e comunidades …” +

+ “… presença da + atenção básica + nos bairros e comunidades …”

@@ -631,24 +628,24 @@
-
- +
+ + Tema - Tema - Fala · Data da fala · Duração +
+

Fala · Data da fala · Duração

-
-
-
-

Por que apareceu

-

- Trecho exemplificativo: “… - fortalecimento da rede pública de atendimento …” +

+ “… fortalecimento da + rede pública de atendimento + …”

@@ -702,28 +698,28 @@
-
- +
+ + Tema - Tema - Termo exato - Fala · Data da fala · Duração + Termo exato +
+

Fala · Data da fala · Duração

-
-
-
-

Por que apareceu

-

- Trecho exemplificativo: “… - + “… + defesa do SUS …” @@ -916,21 +908,24 @@

-
- +
+ + Tema - TemaFala · Data +
+

Fala · Data da fala · Duração

-
-
-
-
-
+

Por que apareceu

-

- Trecho exemplificativo: “… atendimento público - e acesso universal à saúde …” +

+ “… atendimento público e + acesso universal à saúde + …”

-
- TemaTermo exato +
+
+ TemaTermo exato +
+

Fala · Data da fala · Duração

-

Fala · Data da fala · Duração

-
-
-
-
-
+

Por que apareceu

-

- Trecho exemplificativo: “… - + “… + defesa do SUS …” @@ -1309,12 +1305,12 @@

-

- Trecho exemplificativo com o termo - + “… reafirmamos a + defesa do SUS - destacado no contexto literal. + como compromisso público …”

Tema da fala
  • Selo: “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.
  • Proveniência: 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. +
  • +
  • + Precedência visual: 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.
  • Miniaturas e conteúdo: caixas e diff --git a/playwright.config.ts b/playwright.config.ts index ac663fbf..6983f212 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx index 400251f8..1ff3ff26 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx @@ -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 ( @@ -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 ? : null} + + {themeMode ? ( +
    +
    +

    + {data.themeUnavailable + ? 'Resultados por termo exato' + : themeActive + ? 'Resultados por tema' + : 'Resultados'} +

    +

    + {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.'} +

    +
    + {data.themeUnavailable ? : null} +
    + ) : null} + {data.rows.length > 0 ? ( ) : ( - + {themeActive ? ( +
    + + + +
    + ) : ( + + )}
    )} diff --git a/src/components/campaign/shared/CampaignListEmptyState.tsx b/src/components/campaign/shared/CampaignListEmptyState.tsx index 794ed783..532f7079 100644 --- a/src/components/campaign/shared/CampaignListEmptyState.tsx +++ b/src/components/campaign/shared/CampaignListEmptyState.tsx @@ -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 }) => ( - + - + {title} {description} - {children ? {children} : null} + {children ? {children} : null} ) diff --git a/src/components/campaign/speech/SpeechAcervoFilters.tsx b/src/components/campaign/speech/SpeechAcervoFilters.tsx index a9d56dfd..a46808f7 100644 --- a/src/components/campaign/speech/SpeechAcervoFilters.tsx +++ b/src/components/campaign/speech/SpeechAcervoFilters.tsx @@ -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() 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 = ({ }} > +
    +
    + + Buscar por + +
    + + +
    +
    +

    + {themeUnavailable + ? 'Indisponível agora; mostramos o termo exato.' + : 'Encontra falas relacionadas pelo sentido.'} +

    +
    +
    ( + +) diff --git a/src/components/campaign/speech/SpeechResultCard.tsx b/src/components/campaign/speech/SpeechResultCard.tsx index 98de302e..956df7b8 100644 --- a/src/components/campaign/speech/SpeechResultCard.tsx +++ b/src/components/campaign/speech/SpeechResultCard.tsx @@ -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 ( +
    +

    + Por que apareceu +

    +

    + {`“${excerpt.truncatedStart ? '… ' : ''}`} + + {`${excerpt.truncatedEnd ? ' …' : ''}”`} +

    +
    + ) +} + +const SpeechThemeBadges = ({ matchedTextSearch }: { matchedTextSearch: boolean }) => ( +
    + + + {matchedTextSearch ? ( + + Termo exato + + ) : null} +
    +) + 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 ( +
    + {items.map((item, index) => ( + + {index > 0 ? : null} + {item} + + ))} +
    + ) +} + +/** + * 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 }) => ( -
    - {originLabel ? ( - <> - Fala de origem - - - ) : null} - {speech.speechAtLabel} - {speech.type ? ( - <> - - {speech.type} - - ) : null} - {speech.durationLabel ? ( - <> - - {speech.durationLabel} - - ) : null} - {speech.presidingOfficer ? ( - <> - - Presidiu: {speech.presidingOfficer} - - ) : null} -
    + ) const SpeechActions = ({ @@ -100,12 +149,12 @@ const SpeechActions = ({ fullWidthOnMobile?: boolean }) => (
    - + {speech.officialTextUrl ? ( { // 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 (
    @@ -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 = + 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 ? ( + + ) : ( + + ) + const chips = ( + <> + {visibleTopics.map((topic) => ( + + {topic.label} + + ))} + {visibleScopes.map((scope) => ( + + {scope.label} + + ))} + {visibleKeywords.map((keyword) => ( + + {keyword} + + ))} + {hiddenChips > 0 ? ( + + +{hiddenChips} + + ) : null} + + ) + const hasChips = Boolean(speech.topics.length || speech.scopes.length || speech.keywords.length) return (
    - + {isTheme ? ( +
    + + +
    + ) : ( + + )} -
    +
    {speech.thumbnailUrl ? (
    -
    {excerptNode}
    +
    {bodyNode}
    ) : ( - excerptNode + bodyNode )}
    - {speech.topics.length || speech.scopes.length || speech.keywords.length ? ( -
    - {visibleTopics.map((topic) => ( - - {topic.label} - - ))} - {visibleScopes.map((scope) => ( - - {scope.label} - - ))} - {visibleKeywords.map((keyword) => ( - - {keyword} - - ))} - {hiddenChips > 0 ? ( - - +{hiddenChips} - - ) : null} + {isTheme ? ( +
    + {hasChips ?
    {chips}
    : } +
    + + {speech.officialTextUrl ? ( + + + ) : null} +
    - ) : null} + ) : ( + <> + {hasChips ?
    {chips}
    : null} -
    - - {speech.officialTextUrl ? ( - - - ) : null} -
    +
    + + {speech.officialTextUrl ? ( + + + ) : null} +
    + + )} {speech.cuts.length ? : null}
    diff --git a/src/components/campaign/speech/SpeechResultThumbnail.tsx b/src/components/campaign/speech/SpeechResultThumbnail.tsx index 2c7dfee4..8cd16f81 100644 --- a/src/components/campaign/speech/SpeechResultThumbnail.tsx +++ b/src/components/campaign/speech/SpeechResultThumbnail.tsx @@ -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(null) @@ -32,7 +36,10 @@ export const SpeechResultThumbnail = ({ {settled ? null : } {/* eslint-disable-next-line @next/next/no-img-element -- external static image (poster route or YouTube cover). */} diff --git a/src/components/campaign/speech/SpeechThemeFallbackNotice.tsx b/src/components/campaign/speech/SpeechThemeFallbackNotice.tsx new file mode 100644 index 00000000..ef0144c2 --- /dev/null +++ b/src/components/campaign/speech/SpeechThemeFallbackNotice.tsx @@ -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 = () => ( + + +) diff --git a/src/components/campaign/speech/SpeechThemeRetryButton.tsx b/src/components/campaign/speech/SpeechThemeRetryButton.tsx new file mode 100644 index 00000000..4b26fcd3 --- /dev/null +++ b/src/components/campaign/speech/SpeechThemeRetryButton.tsx @@ -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 ( + + ) +} diff --git a/src/lib/speechHighlight.ts b/src/lib/speechHighlight.ts index ccce8261..a844c2dc 100644 --- a/src/lib/speechHighlight.ts +++ b/src/lib/speechHighlight.ts @@ -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 diff --git a/src/lib/speechSearch.ts b/src/lib/speechSearch.ts index 1f9144f6..ec573aa0 100644 --- a/src/lib/speechSearch.ts +++ b/src/lib/speechSearch.ts @@ -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) } /** diff --git a/src/lib/speechThemeTerms.ts b/src/lib/speechThemeTerms.ts new file mode 100644 index 00000000..266847be --- /dev/null +++ b/src/lib/speechThemeTerms.ts @@ -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) diff --git a/src/utilities/ai/expandSpeechSearchTheme.ts b/src/utilities/ai/expandSpeechSearchTheme.ts new file mode 100644 index 00000000..4bde7b5d --- /dev/null +++ b/src/utilities/ai/expandSpeechSearchTheme.ts @@ -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 + +/** + * 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 + } +} diff --git a/src/utilities/speech/speechListFilters.ts b/src/utilities/speech/speechListFilters.ts index b59c5365..da455242 100644 --- a/src/utilities/speech/speechListFilters.ts +++ b/src/utilities/speech/speechListFilters.ts @@ -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() + const seenKeyword = new Set() + + // 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 }) } diff --git a/src/utilities/speech/speechListUrl.ts b/src/utilities/speech/speechListUrl.ts index 58dbfc2e..46defaf8 100644 --- a/src/utilities/speech/speechListUrl.ts +++ b/src/utilities/speech/speechListUrl.ts @@ -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(params.topic, speechTopicSet) const scopes = parseExhaustiveEnumParam(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) diff --git a/src/utilities/speech/speechOmnibox.ts b/src/utilities/speech/speechOmnibox.ts index 3777d6a8..00546dd7 100644 --- a/src/utilities/speech/speechOmnibox.ts +++ b/src/utilities/speech/speechOmnibox.ts @@ -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 }, diff --git a/src/utilities/speech/speechPageData.ts b/src/utilities/speech/speechPageData.ts index 743df116..bae9d337 100644 --- a/src/utilities/speech/speechPageData.ts +++ b/src/utilities/speech/speechPageData.ts @@ -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, + expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme, ): Promise => { 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, } } diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts index f5d9e22e..7d060830 100644 --- a/src/utilities/speech/speechViewModels.ts +++ b/src/utilities/speech/speechViewModels.ts @@ -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 /** 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, } } diff --git a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts index b2b30346..0b0d0542 100644 --- a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts +++ b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts @@ -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(' { 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) + }) }) diff --git a/tests/unit/expandSpeechSearchTheme.unit.spec.ts b/tests/unit/expandSpeechSearchTheme.unit.spec.ts new file mode 100644 index 00000000..34006066 --- /dev/null +++ b/tests/unit/expandSpeechSearchTheme.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/speechHighlight.unit.spec.ts b/tests/unit/speechHighlight.unit.spec.ts index 90538fc3..30af789d 100644 --- a/tests/unit/speechHighlight.unit.spec.ts +++ b/tests/unit/speechHighlight.unit.spec.ts @@ -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) diff --git a/tests/unit/speechListFilters.unit.spec.ts b/tests/unit/speechListFilters.unit.spec.ts index 44736262..d9673779 100644 --- a/tests/unit/speechListFilters.unit.spec.ts +++ b/tests/unit/speechListFilters.unit.spec.ts @@ -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)) + }) +}) diff --git a/tests/unit/speechListUrl.unit.spec.ts b/tests/unit/speechListUrl.unit.spec.ts index 8acc197f..e34a45cd 100644 --- a/tests/unit/speechListUrl.unit.spec.ts +++ b/tests/unit/speechListUrl.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/speechOmnibox.unit.spec.ts b/tests/unit/speechOmnibox.unit.spec.ts index b8ba6e84..3dd9ac31 100644 --- a/tests/unit/speechOmnibox.unit.spec.ts +++ b/tests/unit/speechOmnibox.unit.spec.ts @@ -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() }) }) diff --git a/tests/unit/speechSearch.unit.spec.ts b/tests/unit/speechSearch.unit.spec.ts index c9ea9615..91bff36e 100644 --- a/tests/unit/speechSearch.unit.spec.ts +++ b/tests/unit/speechSearch.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/speechThemeTerms.unit.spec.ts b/tests/unit/speechThemeTerms.unit.spec.ts new file mode 100644 index 00000000..3434ffbb --- /dev/null +++ b/tests/unit/speechThemeTerms.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/speechViewModels.unit.spec.ts b/tests/unit/speechViewModels.unit.spec.ts index e5cb9d57..35c53354 100644 --- a/tests/unit/speechViewModels.unit.spec.ts +++ b/tests/unit/speechViewModels.unit.spec.ts @@ -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) + }) +})