mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C192): busca semântica no acervo de falas (Closes #1147)
Modo "Por tema" no acervo de falas: expansão determinística do tema via DeepSeek (generateObject, timeout, fallback null) alimenta o motor lexical existente (searchText/keywords OR), mantendo facetas AND, paginação por fala, o OR de cortes do C174 e o access control intacto. Resultado de tema mostra selo e "Por que apareceu" com trecho real (frase contínua), sem score; degradação honesta para termo exato com aviso e vazio honesto com saídas. - URL: mode=tema (default omitido; canonicaliza sem q), seletor otimista - ai/expandSpeechSearchTheme + lib/speechThemeTerms (sanitiza saída do modelo) - view model com themeMatchTerm + espelho speechMatchesSearchTerm - UI do card de tema (selos, proveniência, ações) e aviso degradado - sem migration/Consent/exposição pública; gate de leitura inalterado - testes unit/int/e2e; design hi-fi estendido e certificado pelo designer
This commit is contained in:
parent
af6fa698db
commit
45eef25b1a
31 changed files with 1563 additions and 280 deletions
1
docs/changelog/2026-09-18-c192.md
Normal file
1
docs/changelog/2026-09-18-c192.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C192 — Busca semântica no acervo de falas (2026-09-18):** o acervo (`/campanha/comunicacao/acervo`) ganhou busca **por tema** ao lado da busca literal, sem índice vetorial nem backfill. (1) **Modo explícito e visível**: novo parâmetro de URL `mode=tema` (default `termo`, omitido — deep links da busca exata ficam byte-idênticos; `mode=tema` sem `q` canonicaliza fora), registrado em `speechListParamNames`; o seletor "Buscar por: Termo exato | Por tema" (segmented control, `aria-pressed`) é otimista e preserva facetas/página. (2) **Ponte semântica determinística**: `expandSpeechSearchTheme` (`src/utilities/ai/`, `server-only`) pede ao DeepSeek (`generateObject`, `deepseek-flash`, timeout 4s, temperatura 0.2) de 3 a 8 termos relacionados; nunca lança — sem chave, timeout, erro ou saída malformada resolve `null` (degradação), e lista vazia é resposta legítima (sem aviso). `normalizeSpeechThemeTerms` sanitiza a saída do modelo (`%`/`_`/`\`, termos <3 chars, dedupe, teto de 8/60 chars). (3) **Motor lexical no caminho do Payload**: `buildSpeechListWhereIncludingCutOrigins` OR-a `q` + termos expandidos (`searchText like` / `keywords contains`) com dedupe, mantém facetas AND, paginação por fala e o OR de origem de corte do C174 (ancorado só no `q` literal); `payload.find` segue com `user` + `overrideAccess:false` e `canReadSpeechCatalog` barra a chamada externa de ator negado (fail-closed). (4) **Proveniência honesta**: `toSpeechListItemViewModel` ganha `themeMatchTerm` e `matchKind='theme'`; o card de tema mostra selo "Tema" (+ "Termo exato" quando também casa literal) e o bloco "Por que apareceu" com o excerto real da fala e a frase marcada como faixa contínua (`findPhraseRanges`), sem score nem texto gerado; o CTA só oferece "Assistir no trecho" com casamento literal real. (5) **Degradação e vazio**: aviso discreto (`Alert pending`, literais da cena 04) com "Tentar por tema novamente" e resultados lexicais; o vazio honesto tem as saídas reformular/usar termo exato/limpar filtros (cena 03). (6) **Design**: o `designer` estendeu o artefato do gate (`docs/plans/acervo-busca-semantica-ui-design.html`, cenas 01/02) e certificou a implementação renderizada (desktop 1280 / mobile 390). (7) **Testes**: novos unit de `speechThemeTerms` e `expandSpeechSearchTheme` (mock de provider) e extensões de URL/where/espelho/view model/omnibox; int com expansor injetado (tema, fallback, guard de acesso); e2e do modo degradado sem chave (o `playwright.config.ts` agora zera `DEEPSEEK_API_KEY` no servidor e2e). Sem migration, sem collection/Consent novo, sem exposição pública. `pnpm gate:ci` verde (unit 3742, int 827, e2e do acervo 16).
|
||||
156
docs/plans/acervo-busca-semantica-impl.md
Normal file
156
docs/plans/acervo-busca-semantica-impl.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Impl: C192 — Busca semântica no acervo de falas
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-18
|
||||
Issue: #1147
|
||||
Intenção: docs/plans/acervo-busca-semantica.md
|
||||
Appetite restante: herdado (~1–2 dias eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** em `mode=tema`, buscar um tema sem as palavras exatas devolve falas relacionadas pelo sentido, cada resultado com proveniência determinística (termo exato vs tema) e excerto real; sem resultado → vazio honesto; mecanismo indisponível → resultados lexicais de hoje + aviso discreto, nunca erro nem resultado fantasma.
|
||||
- **O que NÃO negociar:** o gate de leitura (`canReadSpeechCatalog`/`canReadSpeech`) não alarga nem ganha atalho; `payload.find` sempre com `user` + `overrideAccess: false`; a fala continua a unidade; busca exata continua sendo o default e não regride; sem score numérico na UI; nada de pgvector/backfill dos 997; `openai/*` intocado (OPS123).
|
||||
- **O que reavaliar:** a hipótese da intenção ("áreas prováveis") está correta quanto aos arquivos. O ponto a reavaliar é a assimetria `speechListFilters` (where) × `lib/speechSearch` (espelho C180): a expansão só entra na perna textual do `where`, então o espelho precisa de um predicado por termo em vez de reescrever a semântica literal — caso contrário `matchedTextSearch` quebraria o C174.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[assessoria digita tema] --> M{mode=tema?}
|
||||
M -- não --> LEX[buildSpeechListWhere<br/>literal atual]
|
||||
M -- sim --> EXP[expandSpeechSearchTheme<br/>DeepSeek generateObject]
|
||||
EXP -- null/erro --> DEG[where literal +<br/>alerta discreto]
|
||||
EXP -- terms --> WHERE[where OR: q + termos expandidos<br/>AND facetas + OR cortes C174]
|
||||
DEG --> Q[payload.find<br/>user · overrideAccess:false]
|
||||
WHERE --> Q
|
||||
Q --> VM[toSpeechListItemViewModel<br/>matchKind + themeMatchTerm + excerto real]
|
||||
VM --> UI[SpeechResultCard · EmptyState · Alert]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) LLM query expansion + motor lexical existente, sem índice vetorial | B) embeddings query-time (DeepInfra) + artefato em memória | C) pgvector + embeddings + backfill.
|
||||
**Recomendação:** A — um único `generateObject` determinístico (mesmo contrato de `rerankSpeechExcerpts`) expande o tema em termos lexicais; o `where` do Payload e o access control continuam no caminho, a fala segue unidade, e nada novo precisa ser indexado/backfillado. Cabe no appetite e entrega o outcome no recorte do acervo.
|
||||
**Rejeitadas:** B porque exige artefato de embeddings (backfill/refresh) e cosine em memória sem ganho claro sobre a expansão lexical para 997 falas, além de introduzir dependência de infra que a intenção mandou evitar; C porque é exatamente o rabbit hole nomeado em "índice vetorial de todo o acervo / infra nova" (custo, storage, sync contínuo, backfill). Detalhe em "Decisões de engenharia".
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`SpeechSearchMode` + `mode` em `SpeechListState`** (`src/utilities/speech/speechListUrl.ts:51`): adiciona `'mode'` a `speechListParamNames:71`, parse de valor único (`tema`; desconhecido → `undefined`), serializa `mode=tema` **somente quando há `q`**. Reusa `firstValue`, `resolveListUrl`, `buildListHref`. Deep links e back/forward passam pelo canonicalizador existente; `mode` desconhecido continua redirecionando porque o nome é suportado e o serializer o omite (raw ≠ canonical).
|
||||
- **`applySpeechSearchMode`** (`src/utilities/speech/speechOmnibox.ts`): ação pura que devolve `{ kind: 'url', state: { ...state, mode, page: 1 } }` (ou limpa `mode`), preservando facetas. Reusa o padrão de `withPageReset`/`mutateFacet`; não passa por `mutateFacet` (não é facet multi-valor).
|
||||
- **`normalizeSpeechThemeTerms`** (`src/lib/speechThemeTerms.ts`, novo, puro/client-safe): normaliza (`normalizeForSearch`), remove `%`/`_` e tokens <3 chars, deduplica pela forma normalizada, limita a 8 termos e 60 chars. É a única barreira entre texto de LLM e o `LIKE` — reusa `normalizeForSearch`, `uniqueByNormalizedForm`.
|
||||
- **`expandSpeechSearchTheme`** (`src/utilities/ai/expandSpeechSearchTheme.ts`, novo, `server-only`): `deepSeek('deepseek-flash')` + `generateObject` + zod `{ terms: z.array(z.string()) }`, `temperature 0.2`, `AbortSignal.timeout(4000)`, `maxOutputTokens` baixo; aplica `normalizeSpeechThemeTerms`. Contrato: sem `DEEPSEEK_API_KEY`, timeout, erro de provider ou saída malformada → `null`; `{ terms: [] }` é resposta legítima (modelo rodou, nada a acrescentar). Espelha `rerankSpeechExcerpts`.
|
||||
- **`buildSpeechTextBranches` / `buildSpeechListWhereIncludingCutOrigins`** (`src/utilities/speech/speechListFilters.ts:52,77`): passam a aceitar `themeTerms: readonly string[]`; a perna textual vira OR de `{ searchText: like normalize(q) }`, `{ keywords: contains q }` + pares equivalentes por termo expandido + `{ id: { in: originSpeechIds } }` (C174 intocado, ainda só `state.q`); facetas seguem AND. Reusa `buildSpeechFacetWhere`, `collapseListWhereOrBranches`.
|
||||
- **`speechMatchesSearchTerm` / `speechMatchesSearchQuery`** (`src/lib/speechSearch.ts:31`): extrai o predicado de um termo (espelho exato do `where` per-termo); `speechMatchesSearchQuery` passa a ser `speechMatchesSearchTerm(speech, q)` para o flag literal. Mantém o lockstep C180 e `matchedTextSearch` literal.
|
||||
- **`toSpeechListItemViewModel`** (`src/utilities/speech/speechViewModels.ts:213`): recebe `themeTerms?: readonly string[]`; `matchKind` ganha `'theme'`; novo `themeMatchTerm: string | null` (primeiro termo expandido que casa em segmento/keyword via espelho); quando não há casamento literal, o excerto é `buildHighlightedExcerpt(segment.text, themeMatchTerm)` — trecho real, sem "porquê" gerado. Reusa `pickMatchingSegment` (generalizado p/ lista de termos), `buildHighlightedExcerpt`, `normalizeForSearch`.
|
||||
- **`loadSpeechAcervoPageData`** (`src/utilities/speech/speechPageData.ts:160`): ganha 4º parâmetro injetável `expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme` (precedente: `SpeechVideoStartResolver` em `loadSpeechDetailPageData`). Só expande se `state.q && state.mode === 'tema' && canReadSpeechCatalog(user.role)` (fail-closed: ator negado não dispara chamada externa; o `find` continua rejeitando). Retorna `searchMode`, `themeUnavailable` e passa `themeTerms` ao view model. Seleção de `searchText` inalterada (já existe quando `state.q`).
|
||||
- **UI** (`src/components/campaign/speech/` + rota): `SpeechAcervoFilters.tsx` ganha o seletor segmentado "Buscar por: Termo exato | Por tema" (`role="group"`, `aria-pressed`) que chama `applySpeechSearchMode` via `useCampaignListFilterNavigation`; `SpeechResultCard.tsx:122` ganha o selo "Tema" + bloco "Por que apareceu" com o excerto real e mantém o caminho C174/`originLabel` quando não é tema; novo `SpeechThemeFallbackNotice.tsx` com `Alert variant="pending"` + botão "Tentar por tema novamente" (`router.refresh()`); `page.tsx:29` renderiza o aviso quando `themeUnavailable` e o vazio honesto (cena 03). Reusa `CampaignListOmnibox`, `CampaignListEmptyState`, `Alert`/`AlertTitle`/`AlertDescription`, `useCampaignListFilterNavigation`, `Badge`, `Button`.
|
||||
- **Migration:** **sem migration**. Nenhum campo/collection/global muda: `searchText`/`keywords` já existem e a expansão é query-time; mudar schema exigiria migration e não é necessário.
|
||||
- **Access / Consent:** intocado. Nenhuma chave `Consent` nova, nenhum PII de cidadão; o único dado que sai para o DeepSeek é o texto do tema digitado pelo assessor. A leitura continua no `payload.find` com `user` + `overrideAccess: false`; `canReadSpeech`/`canReadSpeechCatalog` não são tocados nem reusados para alargar nada.
|
||||
- **UI (Impeccable B):** shell existente do acervo, sem rota nova. Cenas do gate a portar: 01 (desktop, selo "Tema" + "Por que apareceu" + "Termo exato" quando ambos), 02 (mobile, toggle tocável, `min-h-11`), 03 (vazio honesto: "Reformular busca" / "Usar termo exato" / "Limpar filtros"), 04 (degradado: seletor visualmente em "Termo exato", aviso `bg-estimate-pending` preservando "Por tema foi pedido", "Tentar por tema novamente"), 05 (loading: aproveitar pending existente; skeleton fica fora do v1). Craft/critique/polish no mesmo gate B antes do PR.
|
||||
|
||||
### Dados → forma (N/A justificado)
|
||||
|
||||
N/A. O item não apresenta métrica, agregado ou score: o resultado é uma lista de falas com uma explicação qualitativa (selo + trecho). A intenção proíbe explicitamente score numérico de confiança; a forma escolhida é "selo + excerto real", sem barra/percentual.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / server** (~1 dia): `mode` no contrato de URL + `normalizeSpeechThemeTerms` + `expandSpeechSearchTheme` + `themeTerms` no `where` + injeção em `loadSpeechAcervoPageData` + `themeMatchTerm` no view model. Testes:
|
||||
- `tests/unit/speechThemeTerms.unit.spec.ts` (novo): sanitiza wildcards/curtos/duplicados/limites.
|
||||
- `tests/unit/expandSpeechSearchTheme.unit.spec.ts` (novo, mockando `ai`/`@ai-sdk/deepseek` como `rerankSpeechExcerpts.unit.spec.ts`): sem chave → `null` e sem chamada; erro do provider → `null`; `{terms:[]}` → legítimo; termos válidos; prompt contém o tema e `abortSignal` é `AbortSignal`.
|
||||
- `tests/unit/speechListUrl.unit.spec.ts` (estender): `mode=tema` parse/serialize; desconhecido canonicaliza; `mode=tema` sem `q` redireciona; deep-link atual (`?q=SUS`) não muda.
|
||||
- `tests/unit/speechListFilters.unit.spec.ts` (estender): OR `q` + expandidos + OR de cortes, facetas AND; sem `themeTerms` idêntico ao de hoje.
|
||||
- `tests/unit/speechSearch.unit.spec.ts` (estender): `speechMatchesSearchTerm` (texto normalizado + keyword case-insensitive).
|
||||
- `tests/unit/speechViewModels.unit.spec.ts` (estender): `themeMatchTerm`, `matchKind='theme'`, excerto highlightando o termo expandido.
|
||||
2. **UI** (~0,5–1 dia): seletor de modo em `SpeechAcervoFilters`, selo/proveniência no `SpeechResultCard`, aviso de degradação (`Alert pending` + retry), vazio honesto com saídas; `applySpeechSearchMode` em `tests/unit/speechOmnibox.unit.spec.ts`. Testes:
|
||||
- Estender `tests/int/speechAcervo.int.spec.ts` com expansor injetado: (a) fala que **não** contém o `q` literal mas contém a expansão é devolvida com `themeMatchTerm` e excerto real; (b) expansor `null` → `themeUnavailable: true` e linhas literais; (c) advisor/leader não disparam o expansor (guard fail-closed).
|
||||
- Estender `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (browserless, sem `DEEPSEEK_API_KEY` no app e2e): `?q=<marker>&mode=tema` → 200, seletor "Buscar por", aviso "A busca por tema está indisponível agora." e resultado exato do marker; `?q=<marker>` (default) inalterado.
|
||||
3. **Gates:** `pnpm gate:fast`; PR via `pnpm push`. `src/utilities/ai` é prefixo de risco (OPS86) → o PR roda o conjunto curado de e2e; os prefixos `src/utilities/speech`/`src/components/campaign/speech`/`src/app/(campaign)/.../comunicacao`/`src/lib/speech*` já mapeiam `campaignSpeechAcervo`, então `scripts/lib/e2e-affected-manifest.mjs` **não muda**.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- pgvector, embeddings, artefato/backfill dos 997 e sync contínuo — rejeitado nomeadamente na intenção.
|
||||
- Mandar o catálogo inteiro dos 997 ao LLM para "escolher" a fala — prompt/latência/custo e caixa-preta; a seleção fica no `where` lexical.
|
||||
- LLM a gerar o "por que apareceu" — alucinação; a proveniência é sempre trecho determinístico e o selo é o modo.
|
||||
- Segundo passe de rerank por LLM no v1 (gatilho de revisitação: se recall/precisão do lexical+expansão não bastar, avaliar rerank sobre candidatos, como no C158).
|
||||
- Curadoria de sinônimos/ontologia, busca global, Sollinha, exposição pública do acervo, analytics de busca — fora de escopo por intenção.
|
||||
- Cache do resultado da expansão: v1 paga uma chamada por request (fatiar página re-expande). Gatilho: se latência/custo doer, cache in-process limitado por `normalizeForSearch(q)`.
|
||||
- Skeletons da cena 05: aproveitar só o pending/dimming existente; skeleton fica para polish futuro.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Latência de 1 chamada LLM por busca em página** (e por flip de página): timeout 4s + `generateObject`; a UI usa o pending existente (`aria-busy`); degradação honesta se estourar. Trigger de cache acima.
|
||||
- **`LIKE` com curinga injetado**: `normalizeSpeechThemeTerms` remove `%`/`_` e limita tamanho/quantidade; o `where` continua parametrizado pelo Payload.
|
||||
- **Divergência where ↔ espelho C180**: o predicado por termo é único (`speechMatchesSearchTerm`) e testado; qualquer mudança na perna textual tem que passar por ele no mesmo commit.
|
||||
- **Regressão da busca exata**: default `termo` não emite parâmetro; `buildSpeechListWhere` sem `themeTerms` é byte-idêntico ao atual (teste dedicado).
|
||||
- **Proveniência enganosa**: quando o casamento é literal, `matchedTextSearch`/`matchKind` continuam mandando; o selo "Tema" só aparece com `themeMatchTerm` real; `[]` do modelo não vira resultado.
|
||||
- **Chamada externa para ator sem leitura**: guard `canReadSpeechCatalog(user.role)` antes de expandir; o `find` com access continua sendo a barreira final (fail-closed).
|
||||
- **Custo/quota DeepSeek**: `deepseek-flash`, `maxOutputTokens` baixo, sem rerank; os 997 não são enviados.
|
||||
- **Confusão do usuário ao degradar**: aviso preserva "Por tema foi pedido" e o seletor volta visualmente a "Termo exato", com retry explícito (cena 04) — nunca erro na cara.
|
||||
- **e2e de risco**: o diff toca `src/utilities/ai`, então o PR roda o curado; `campaignSpeechAcervo` também é selecionado pelas prefixos de speech e cobre o modo degradado sem rede.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: tema sem palavras exatas devolve falas relacionadas; cada resultado explica termo exato vs tema com trecho; exata inalterada; vazio honesto; degradação com aviso, sem erro nem fantasma; facetas continuam valendo; fala é a unidade.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: `user` + `overrideAccess: false` em toda leitura; gate `canReadSpeech`/`canReadSpeechCatalog` não alargado; sem pgvector/embeddings; sem `openai/*`; copy pt-BR e identificadores em inglês; sem score numérico.
|
||||
- [ ] Sem migration (justificado) — nenhum schema/collection/global alterado.
|
||||
- [ ] Testes de domínio: unit do sanitizador, do módulo de expansão (mock de provider), do contrato de URL, do `where`, do espelho C180 e do view model; int com expansor injetado (tema, fallback, guard de acesso); e2e browserless do modo degradado.
|
||||
- [ ] `pnpm gate:fast` verde; `pnpm push` com PR ready.
|
||||
- [ ] Manifesto de e2e mantém cobertura de risco sem edição (`src/utilities/ai` e prefixos de speech já mapeados).
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
### D1 — Mecanismo da busca por tema
|
||||
|
||||
**Opções:** A) LLM query expansion + motor lexical existente | B) embeddings query-time (DeepInfra) + cosine em memória sobre artefato | C) pgvector + embeddings + backfill.
|
||||
**Recomendação:** A — resolve o job ("sem as palavras exatas") sem infra nova nem reindexação, mantém access/`where`/paginação por fala e a proveniência é lexical e determinística.
|
||||
**Rejeitadas:** C porque é o rabbit hole textual da intenção (pipeline, backfill dos 997, storage, sync); B porque exige manter um artefato de embeddings (gerar/atualizar/servir) e introduz similaridade contínua — mais superfície e sem ganho comprovado no recorte de 997 falas; D) lexical puro sem expandir não atende o outcome ("defesa do SUS" continua sem achar outras palavras); E) mandar o acervo inteiro ao LLM estoura prompt/latência/custo e vira caixa-preta.
|
||||
|
||||
### D2 — Contrato de falha da expansão
|
||||
|
||||
**Opções:** A) `null` para indisponível **e** vazio | B) `null` só para indisponível; `{terms: []}` quando o modelo roda e não acrescenta nada | C) lançar erro e tratar na página.
|
||||
**Recomendação:** B — distingue "mecanismo fora" (mostra o aviso da cena 04) de "mecanismo rodou, sem termos" (cai na busca literal sem aviso de indisponibilidade); é mais honesto que colapsar os dois.
|
||||
**Rejeitadas:** A porque exibir "indisponível" quando o serviço respondeu vazio mente para o usuário; C porque quebra o contrato "nunca lança" do precedente `rerankSpeechExcerpts` e arrisca erro na cara.
|
||||
|
||||
### D3 — Parâmetro de URL do modo
|
||||
|
||||
**Opções:** A) `mode=tema` (default `termo`, omitido) | B) `mode=semantic`/`mode=exact` | C) `semantic=1` booleano.
|
||||
**Recomendação:** A — nome English (`mode`, consistente com `q`/`year`/`topic`/…), valor slug pt-BR (`tema`/`termo`) consistente com a convenção de VALORES já fixada nos facets (`curta`/`media`/`sem_duracao`); default omitido mantém deep-links e a URL da busca exata idênticas (zero regressão). Registrado em `speechListParamNames` para o canonicalizador redirecionar valor desconhecido.
|
||||
**Rejeitadas:** B porque inventa vocabulário em inglês para valores que o usuário vê em pt-BR; C porque um booleano não escala para um terceiro modo e foge do padrão de enum de facet.
|
||||
|
||||
### D4 — Proveniência do resultado
|
||||
|
||||
**Opções:** A) selo por item + trecho real determinístico | B) "porquê" gerado pelo LLM | C) aviso único no topo.
|
||||
**Recomendação:** A — `themeMatchTerm` + `buildHighlightedExcerpt` sobre o texto do segmento/keyword; trecho existe no dado, sem alucinação; o selo "Tema"/"Termo exato" marca cada fala (cena 01).
|
||||
**Rejeitadas:** B por risco de alucinação e min/max imprevisível; C porque não sustenta a confiança em cada fala e some ao rolar.
|
||||
|
||||
### D5 — Unidade do resultado e escopo do casamento
|
||||
|
||||
**Opções:** A) fala, com o `where` casando texto/keywords e (C174) origem de corte | B) fala + trechos | C) trechos.
|
||||
**Recomendação:** A — o job é "qual fala serve"; a expansão só entra na perna textual da fala/keyword; o casamento por corte (C174) permanece ancorado só no `q` original (não expandimos títulos de corte) para não inflar superfície/custo.
|
||||
**Rejeitadas:** B/C porque recorte fino de trecho é o job do C158 no Sollinha e mudaria a unidade/paginação atual.
|
||||
|
||||
### D6 — Cache/segundo passe
|
||||
|
||||
**Opções:** A) v1 sem cache e sem rerank | B) cache in-process da expansão já no v1 | C) rerank LLM dos candidatos no v1.
|
||||
**Recomendação:** A — menos superfície, cabe no appetite; o `deepseek-flash` com timeout e `maxOutputTokens` baixo mantém o custo aceitável no interno.
|
||||
**Rejeitadas:** B adiada (barata de reverter; gatilho: latência/custo por flip de página); C adiada (gatilho: precisão/recall insuficientes) — espelha a decisão do C158 de rerank como segunda etapa, não no caminho crítico.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
1. **Decisões caras com rejeitadas:** 5/5 — mecanismo, contrato de falha, contrato de URL e proveniência têm opções/recomendação/rejeitadas registradas; custo/rerank deferidos com gatilho.
|
||||
2. **Cabe no appetite:** 5/5 — query-time, sem migration, sem infra; ~1–2 dias (server+tracer num dia, UI/gates no restante).
|
||||
3. **Rabbit holes nomeados:** 5/5 — embeddings/pgvector/backfill, catálogo inteiro no LLM, "porquê" gerado, rerank/cache/curadoria explicitamente cortados.
|
||||
4. **Depth check (reusa shells/helpers):** 5/5 — `buildSpeechListWhere*`, canonicalizador (`resolveListUrl`/`buildListHref`), `CampaignListOmnibox`, `CampaignListEmptyState`, `useCampaignListFilterNavigation`, `Alert`, `buildHighlightedExcerpt`/`speechSearchTerms`, e o contrato de injeção de `loadSpeechDetailPageData`.
|
||||
5. **Intenção permanece satisfeita:** 5/5 — outcome, anti-goals e guardrails de acesso intactos; a engenharia não reescreveu o produto.
|
||||
|
||||
**Total: 5/5** (gate ≥4 satisfeito).
|
||||
|
||||
## Triage do /simplify (C192)
|
||||
|
||||
Aplicados no PR: gate `speechMatchesSearchTerm` no `pickThemeMatch` (não reivindicar tema que o `where` não usou), evidência por keyword em vez de trecho alheio, copy do vazio no caminho degradado, `aria-disabled` com no-op real, `temperature: 0` na expansão, `SPEECH_OMNIBOX_ID` compartilhado, `SpeechWatchButton` extraído, frase contínua no destaque + testes diretos, prompt interpolando as constantes.
|
||||
|
||||
Deferidos com gatilho:
|
||||
|
||||
- **Cache da expansão por `q` normalizado** (custo/latência por página e coerência de paginação). Gatilho: latência/custo doer ou paginação incoerente observada. Já previsto em D6.
|
||||
- **Helper único para o boilerplate DeepSeek** (`generateObject` + guard de chave + timeout + catch→null), hoje repetido em `rerankSpeechExcerpts`, `campaignDemandTitle`, `speechCutMetadata` e `expandSpeechSearchTheme`. Gatilho: tocar qualquer um deles.
|
||||
- **`collectOccurrences` em `speechHighlight`** (loop duplicado entre `findHighlightRanges`/`findPhraseRanges`). Gatilho: terceira variante de busca de ranges.
|
||||
- **Sanitizar `themeTerms` na fronteira do `buildSpeechListWhere`** (defense-in-depth para futuros callers; produção já segura pelo provider). Gatilho: novo caller do builder.
|
||||
- **`CampaignListEmptyState` com 3 props de classe para um caller** — reavaliar se surgir um segundo consumidor (variante nomeada). Gatilho: segundo call site.
|
||||
|
|
@ -475,27 +475,27 @@
|
|||
|
||||
<div class="flex flex-col gap-3">
|
||||
<article class="rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
|
||||
>
|
||||
<svg
|
||||
class="size-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/>
|
||||
</svg>
|
||||
Tema
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Fala · Data da fala · Duração</span
|
||||
>
|
||||
<svg
|
||||
class="size-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/>
|
||||
</svg>
|
||||
Tema
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
|
||||
</div>
|
||||
<div class="mt-3 flex items-start gap-4">
|
||||
<div
|
||||
|
|
@ -514,22 +514,20 @@
|
|||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="space-y-2" aria-label="Excerto da fala como placeholder">
|
||||
<div class="h-2.5 w-full rounded bg-muted"></div>
|
||||
<div class="h-2.5 w-11/12 rounded bg-muted"></div>
|
||||
<div class="h-2.5 w-3/5 rounded bg-muted"></div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
|
||||
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
|
||||
>
|
||||
<p
|
||||
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
|
||||
>
|
||||
Por que apareceu
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-foreground/80">
|
||||
<span class="font-medium">Trecho exemplificativo:</span> “…
|
||||
atendimento público e acesso universal à saúde …”
|
||||
<p class="mt-1 text-sm leading-6 text-foreground/90">
|
||||
“… atendimento público e
|
||||
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
|
||||
>acesso universal à saúde</mark
|
||||
>
|
||||
…”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -560,24 +558,24 @@
|
|||
</article>
|
||||
|
||||
<article class="rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
|
||||
><svg
|
||||
class="size-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
|
||||
><svg
|
||||
class="size-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/></svg
|
||||
>Tema</span
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/></svg
|
||||
>Tema</span
|
||||
>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Fala · Data da fala · Duração</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
|
||||
<div
|
||||
|
|
@ -594,21 +592,20 @@
|
|||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-2.5 rounded bg-muted"></div>
|
||||
<div class="h-2.5 w-4/5 rounded bg-muted"></div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
|
||||
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
|
||||
>
|
||||
<p
|
||||
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
|
||||
>
|
||||
Por que apareceu
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-foreground/80">
|
||||
<span class="font-medium">Trecho exemplificativo:</span> “… presença
|
||||
da atenção básica nos bairros e comunidades …”
|
||||
<p class="mt-1 text-sm leading-6 text-foreground/90">
|
||||
“… presença da
|
||||
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
|
||||
>atenção básica</mark
|
||||
>
|
||||
nos bairros e comunidades …”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -631,24 +628,24 @@
|
|||
</article>
|
||||
|
||||
<article class="rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
|
||||
><svg
|
||||
class="size-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
|
||||
><svg
|
||||
class="size-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/></svg
|
||||
>Tema</span
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/></svg
|
||||
>Tema</span
|
||||
>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Fala · Data da fala · Duração</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
|
||||
<div
|
||||
|
|
@ -665,21 +662,20 @@
|
|||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-2.5 rounded bg-muted"></div>
|
||||
<div class="h-2.5 w-9/12 rounded bg-muted"></div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
|
||||
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
|
||||
>
|
||||
<p
|
||||
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
|
||||
>
|
||||
Por que apareceu
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-foreground/80">
|
||||
<span class="font-medium">Trecho exemplificativo:</span> “…
|
||||
fortalecimento da rede pública de atendimento …”
|
||||
<p class="mt-1 text-sm leading-6 text-foreground/90">
|
||||
“… fortalecimento da
|
||||
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
|
||||
>rede pública de atendimento</mark
|
||||
>
|
||||
…”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -702,28 +698,28 @@
|
|||
</article>
|
||||
|
||||
<article class="rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
|
||||
><svg
|
||||
class="size-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
|
||||
><svg
|
||||
class="size-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/></svg
|
||||
>Tema</span
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/></svg
|
||||
>Tema</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-6 items-center rounded-full border border-border bg-white px-2.5 text-xs font-medium"
|
||||
>Termo exato</span
|
||||
>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Fala · Data da fala · Duração</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground"
|
||||
>Termo exato</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
|
||||
<div
|
||||
|
|
@ -740,21 +736,17 @@
|
|||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-2.5 rounded bg-muted"></div>
|
||||
<div class="h-2.5 w-5/6 rounded bg-muted"></div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
|
||||
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
|
||||
>
|
||||
<p
|
||||
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
|
||||
>
|
||||
Por que apareceu
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-foreground/80">
|
||||
<span class="font-medium">Trecho exemplificativo:</span> “…
|
||||
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
|
||||
<p class="mt-1 text-sm leading-6 text-foreground/90">
|
||||
“…
|
||||
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
|
||||
>defesa do SUS</mark
|
||||
>
|
||||
…”
|
||||
|
|
@ -916,21 +908,24 @@
|
|||
</div>
|
||||
<div class="space-y-3">
|
||||
<article class="rounded-xl border border-border p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
|
||||
><svg
|
||||
class="size-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
|
||||
><svg
|
||||
class="size-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/></svg
|
||||
>Tema</span
|
||||
>
|
||||
<path
|
||||
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
|
||||
/></svg
|
||||
>Tema</span
|
||||
><span class="text-xs text-muted-foreground">Fala · Data</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex h-28 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground"
|
||||
|
|
@ -945,17 +940,18 @@
|
|||
<path d="m6 3 14 9-14 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="h-2.5 rounded bg-muted"></div>
|
||||
<div class="h-2.5 w-4/5 rounded bg-muted"></div>
|
||||
</div>
|
||||
<div class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2">
|
||||
<div
|
||||
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary">
|
||||
Por que apareceu
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-foreground/80">
|
||||
<span class="font-medium">Trecho exemplificativo:</span> “… atendimento público
|
||||
e acesso universal à saúde …”
|
||||
<p class="mt-1 text-sm leading-6 text-foreground/90">
|
||||
“… atendimento público e
|
||||
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
|
||||
>acesso universal à saúde</mark
|
||||
>
|
||||
…”
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
|
|
@ -973,27 +969,27 @@
|
|||
></a>
|
||||
</article>
|
||||
<article class="rounded-xl border border-border p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
|
||||
>Tema</span
|
||||
><span
|
||||
class="inline-flex h-6 items-center rounded-full border border-border px-2.5 text-xs font-medium"
|
||||
>Termo exato</span
|
||||
>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
|
||||
>Tema</span
|
||||
><span
|
||||
class="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground"
|
||||
>Termo exato</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="h-2.5 rounded bg-muted"></div>
|
||||
<div class="h-2.5 w-3/4 rounded bg-muted"></div>
|
||||
</div>
|
||||
<div class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2">
|
||||
<div
|
||||
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary">
|
||||
Por que apareceu
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5">
|
||||
<span class="font-medium">Trecho exemplificativo:</span> “…
|
||||
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
|
||||
<p class="mt-1 text-sm leading-6 text-foreground/90">
|
||||
“…
|
||||
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
|
||||
>defesa do SUS</mark
|
||||
>
|
||||
…”
|
||||
|
|
@ -1309,12 +1305,12 @@
|
|||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm leading-6 text-foreground/85">
|
||||
Trecho exemplificativo com o termo
|
||||
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
|
||||
<p class="text-sm leading-6 text-foreground/90">
|
||||
“… reafirmamos a
|
||||
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
|
||||
>defesa do SUS</mark
|
||||
>
|
||||
destacado no contexto literal.
|
||||
como compromisso público …”
|
||||
</p>
|
||||
<div class="mt-3 flex gap-1.5">
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs">Tema da fala</span
|
||||
|
|
@ -1441,11 +1437,22 @@
|
|||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-foreground">Selo:</strong> “Tema”; quando também há
|
||||
casamento literal, o card acrescenta “Termo exato”, sem score numérico.
|
||||
casamento literal, o card acrescenta “Termo exato”, sem score numérico. Os selos ocupam
|
||||
uma linha própria, imediatamente antes da metainformação da fala; nunca ficam ao lado de
|
||||
um título.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-foreground">Proveniência:</strong> bloco “Por que
|
||||
apareceu” + trecho curto explicitamente exemplificativo neste artefato.
|
||||
apareceu” + um único excerto real da fala, com o termo que de fato casou destacado. No
|
||||
modo por tema, esse bloco substitui o excerto comum: as duas áreas nunca convivem nem
|
||||
repetem o mesmo texto. “Trecho exemplificativo” não é copy do produto.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-foreground">Precedência visual:</strong> evidência na
|
||||
própria fala pelo tema vence o estado “Fala de origem”, mesmo quando não houve casamento
|
||||
literal. Sem evidência de tema e sem casamento literal, permanece integralmente o branch
|
||||
C174 (“Fala de origem” + nota + correspondência no corte). O card comum fica para o
|
||||
resultado literal fora desses dois casos.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-foreground">Miniaturas e conteúdo:</strong> caixas e
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import {
|
|||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { SpeechAcervoFilters } from '@/components/campaign/speech/SpeechAcervoFilters'
|
||||
import { SpeechRefineSearchButton } from '@/components/campaign/speech/SpeechRefineSearchButton'
|
||||
import { SpeechResultList } from '@/components/campaign/speech/SpeechResultList'
|
||||
import { SpeechThemeFallbackNotice } from '@/components/campaign/speech/SpeechThemeFallbackNotice'
|
||||
import { SpeechThemeRetryButton } from '@/components/campaign/speech/SpeechThemeRetryButton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
|
||||
|
|
@ -36,6 +39,8 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
|
|||
if (data.redirectHref) redirect(data.redirectHref)
|
||||
|
||||
const hasFilters = buildSpeechFiltersKey(data.state) !== ''
|
||||
const themeMode = data.state.mode === 'tema'
|
||||
const themeActive = themeMode && data.themeApplied && !data.themeUnavailable
|
||||
|
||||
return (
|
||||
<CampaignPageShell aria-label="Acervo de falas">
|
||||
|
|
@ -53,28 +58,74 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
|
|||
key={buildSpeechFiltersKey(data.state)}
|
||||
state={data.state}
|
||||
filterOptions={data.filterOptions}
|
||||
themeUnavailable={data.themeUnavailable}
|
||||
/>
|
||||
|
||||
{data.themeUnavailable ? <SpeechThemeFallbackNotice /> : null}
|
||||
|
||||
<CampaignListResults>
|
||||
{themeMode ? (
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{data.themeUnavailable
|
||||
? 'Resultados por termo exato'
|
||||
: themeActive
|
||||
? 'Resultados por tema'
|
||||
: 'Resultados'}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{data.themeUnavailable
|
||||
? 'Comportamento atual do acervo.'
|
||||
: themeActive
|
||||
? 'Confira o indício em cada fala antes de abrir.'
|
||||
: 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'}
|
||||
</p>
|
||||
</div>
|
||||
{data.themeUnavailable ? <SpeechThemeRetryButton /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.rows.length > 0 ? (
|
||||
<SpeechResultList rows={data.rows} query={data.state.q} />
|
||||
) : (
|
||||
<CampaignListEmptyState
|
||||
icon={SearchXIcon}
|
||||
className={themeActive ? 'border-solid' : undefined}
|
||||
mediaClassName={themeActive ? 'size-12 rounded-full' : undefined}
|
||||
contentClassName={themeActive ? 'max-w-none' : undefined}
|
||||
title={
|
||||
data.state.q
|
||||
? `Nenhuma fala encontrada para "${data.state.q}"`
|
||||
: 'Nenhuma fala encontrada'
|
||||
themeActive
|
||||
? 'Nenhuma fala encontrada para este tema'
|
||||
: data.state.q
|
||||
? `Nenhuma fala encontrada para "${data.state.q}"`
|
||||
: 'Nenhuma fala encontrada'
|
||||
}
|
||||
description={
|
||||
hasFilters
|
||||
? 'Tente outro termo, remova filtros ou busque por um tema.'
|
||||
: 'O acervo ainda não tem falas importadas.'
|
||||
themeActive
|
||||
? 'Não encontramos uma fala que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
|
||||
: hasFilters
|
||||
? 'Tente outro termo, remova filtros ou busque por um tema.'
|
||||
: 'O acervo ainda não tem falas importadas.'
|
||||
}
|
||||
>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar busca e filtros</Link>
|
||||
</Button>
|
||||
{themeActive ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<SpeechRefineSearchButton />
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={buildSpeechListHref({ ...data.state, mode: undefined }, 1)}>
|
||||
Usar termo exato
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" className="min-h-11">
|
||||
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar filtros</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar busca e filtros</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CampaignListEmptyState>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,32 +9,40 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* The standard campaign list empty state (icon, title, description, optional
|
||||
* CTA). Rendered inside `CampaignListResults` so it dims with the shared
|
||||
* transition like the rows it replaces.
|
||||
* transition like the rows it replaces. `className` / `mediaClassName` /
|
||||
* `contentClassName` let a caller tune the shell without forking it (C192).
|
||||
*/
|
||||
export const CampaignListEmptyState = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
mediaClassName,
|
||||
contentClassName,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: ReactNode
|
||||
/** Call-to-action button(s). */
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
mediaClassName?: string
|
||||
contentClassName?: string
|
||||
}) => (
|
||||
<Empty className="min-h-72 border">
|
||||
<Empty className={cn('min-h-72 border', className)}>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<EmptyMedia variant="icon" className={mediaClassName}>
|
||||
<Icon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
<EmptyDescription>{description}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{children ? <EmptyContent>{children}</EmptyContent> : null}
|
||||
{children ? <EmptyContent className={contentClassName}>{children}</EmptyContent> : null}
|
||||
</Empty>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { SparklesIcon, TriangleAlertIcon } from 'lucide-react'
|
||||
import { useMemo, useOptimistic, useState } from 'react'
|
||||
|
||||
import {
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
buildSpeechListHref,
|
||||
SPEECH_DURATION_BUCKETS,
|
||||
|
|
@ -20,14 +22,17 @@ import {
|
|||
speechTopicLabels,
|
||||
type SpeechFilterOptions,
|
||||
type SpeechListState,
|
||||
type SpeechSearchMode,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
import {
|
||||
applySpeechOmniboxSuggestion,
|
||||
applySpeechSearchMode,
|
||||
buildSpeechOmniboxChips,
|
||||
buildSpeechOmniboxSuggestionSeeds,
|
||||
clearSpeechOmnibox,
|
||||
filterSpeechOmniboxSuggestions,
|
||||
removeSpeechOmniboxChip,
|
||||
SPEECH_OMNIBOX_ID,
|
||||
type SpeechOmniboxAction,
|
||||
} from '@/utilities/speech/speechOmnibox'
|
||||
|
||||
|
|
@ -58,12 +63,27 @@ const selectedTriggerLabel = (
|
|||
return `${base}: ${values.length}`
|
||||
}
|
||||
|
||||
/** C192 — segmented search-mode button (mobile full width, desktop inline). */
|
||||
const modeButtonClass = (active: boolean, tone: 'termo' | 'tema'): string =>
|
||||
cn(
|
||||
'min-h-11 rounded-md px-3 text-sm md:min-h-9',
|
||||
active
|
||||
? cn(
|
||||
'bg-white font-semibold shadow-sm ring-1 ring-border',
|
||||
tone === 'tema' ? 'text-primary' : 'text-foreground',
|
||||
)
|
||||
: 'font-medium text-muted-foreground',
|
||||
)
|
||||
|
||||
export const SpeechAcervoFilters = ({
|
||||
state,
|
||||
filterOptions,
|
||||
themeUnavailable = false,
|
||||
}: {
|
||||
state: SpeechListState
|
||||
filterOptions: SpeechFilterOptions
|
||||
/** C192 — the theme expansion is down; the selector reflects the fallback. */
|
||||
themeUnavailable?: boolean
|
||||
}) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
|
|
@ -72,6 +92,9 @@ export const SpeechAcervoFilters = ({
|
|||
const [query, setQuery] = useState('')
|
||||
const [viewState, setOptimisticState] = useOptimistic(state)
|
||||
|
||||
const activeMode: SpeechSearchMode =
|
||||
viewState.mode === 'tema' && !themeUnavailable ? 'tema' : 'termo'
|
||||
|
||||
const municipalityLabelsById = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const option of filterOptions.municipalities) {
|
||||
|
|
@ -110,6 +133,12 @@ export const SpeechAcervoFilters = ({
|
|||
navigate(action.state)
|
||||
}
|
||||
|
||||
const runMode = (mode: SpeechSearchMode) => {
|
||||
const next = applySpeechSearchMode({ state: viewState, mode }).state
|
||||
setOptimisticState(next)
|
||||
navigate(next)
|
||||
}
|
||||
|
||||
const facetRows = (
|
||||
prefix: string,
|
||||
options: readonly { value: string; label: string }[],
|
||||
|
|
@ -206,7 +235,7 @@ export const SpeechAcervoFilters = ({
|
|||
}}
|
||||
>
|
||||
<CampaignListOmnibox
|
||||
id="speech-omnibox"
|
||||
id={SPEECH_OMNIBOX_ID}
|
||||
label="Buscar no acervo de falas"
|
||||
placeholder="Busque por assunto, tema, município ou palavra-chave…"
|
||||
chips={chips}
|
||||
|
|
@ -228,6 +257,55 @@ export const SpeechAcervoFilters = ({
|
|||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3 md:flex-row md:flex-wrap md:items-center md:justify-between">
|
||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||
<span className="hidden text-xs font-medium text-muted-foreground md:inline">
|
||||
Buscar por
|
||||
</span>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Modo de busca"
|
||||
className="grid w-full grid-cols-2 rounded-lg bg-muted p-1 md:inline-flex md:w-auto"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeMode === 'termo'}
|
||||
className={modeButtonClass(activeMode === 'termo', 'termo')}
|
||||
onClick={() => runMode('termo')}
|
||||
>
|
||||
Termo exato
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeMode === 'tema'}
|
||||
aria-disabled={themeUnavailable || undefined}
|
||||
className={modeButtonClass(activeMode === 'tema', 'tema')}
|
||||
onClick={() => {
|
||||
if (themeUnavailable) return
|
||||
runMode('tema')
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{themeUnavailable ? (
|
||||
<TriangleAlertIcon
|
||||
className="size-3.5 text-estimate-pending-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<SparklesIcon className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
Por tema
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hidden text-xs text-muted-foreground md:block">
|
||||
{themeUnavailable
|
||||
? 'Indisponível agora; mostramos o termo exato.'
|
||||
: 'Encontra falas relacionadas pelo sentido.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Filtros do acervo"
|
||||
|
|
|
|||
21
src/components/campaign/speech/SpeechRefineSearchButton.tsx
Normal file
21
src/components/campaign/speech/SpeechRefineSearchButton.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SPEECH_OMNIBOX_ID } from '@/utilities/speech/speechOmnibox'
|
||||
|
||||
/** C192 — the honest empty state's "reformulate" exit: focuses the omnibox. */
|
||||
export const SpeechRefineSearchButton = () => (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
onClick={() => {
|
||||
const input = document.getElementById(SPEECH_OMNIBOX_ID)
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.scrollIntoView({ block: 'nearest' })
|
||||
input.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Reformular busca
|
||||
</Button>
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ExternalLinkIcon, PlayIcon } from 'lucide-react'
|
||||
import { ExternalLinkIcon, PlayIcon, SparklesIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
|
@ -32,6 +32,42 @@ const SpeechExcerpt = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* C192 — the theme result provenance: the real excerpt that matched the theme,
|
||||
* with the expanded term highlighted. It replaces the common excerpt (never
|
||||
* coexists with it) and carries no score.
|
||||
*/
|
||||
const SpeechThemeProvenance = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
|
||||
if (excerpt.parts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-primary">
|
||||
Por que apareceu
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-6 text-foreground/90">
|
||||
{`“${excerpt.truncatedStart ? '… ' : ''}`}
|
||||
<SpeechHighlightParts parts={excerpt.parts} />
|
||||
{`${excerpt.truncatedEnd ? ' …' : ''}”`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SpeechThemeBadges = ({ matchedTextSearch }: { matchedTextSearch: boolean }) => (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">
|
||||
<SparklesIcon className="size-3" aria-hidden="true" />
|
||||
Tema
|
||||
</span>
|
||||
{matchedTextSearch ? (
|
||||
<span className="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
|
||||
Termo exato
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ActionLink = ({
|
||||
href,
|
||||
children,
|
||||
|
|
@ -60,34 +96,47 @@ const SpeechMetaLine = ({
|
|||
}: {
|
||||
speech: SpeechListItemViewModel
|
||||
originLabel?: boolean
|
||||
}) => {
|
||||
const items: string[] = []
|
||||
if (originLabel) items.push('Fala de origem')
|
||||
items.push(speech.speechAtLabel)
|
||||
if (speech.type) items.push(speech.type)
|
||||
if (speech.durationLabel) items.push(speech.durationLabel)
|
||||
if (speech.presidingOfficer) items.push(`Presidiu: ${speech.presidingOfficer}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{items.map((item, index) => (
|
||||
<span key={item} className="inline-flex items-center gap-x-2 whitespace-nowrap">
|
||||
{index > 0 ? <MetaSeparator /> : null}
|
||||
<span>{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary CTA of every result card: a link to the speech (or its trecho)
|
||||
* whose variant/width the caller owns (C192 adds the theme appearance).
|
||||
*/
|
||||
const SpeechWatchButton = ({
|
||||
speech,
|
||||
watchLabel,
|
||||
variant,
|
||||
className,
|
||||
}: {
|
||||
speech: SpeechListItemViewModel
|
||||
watchLabel: string
|
||||
variant: 'default' | 'outline'
|
||||
className: string
|
||||
}) => (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{originLabel ? (
|
||||
<>
|
||||
<span>Fala de origem</span>
|
||||
<MetaSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<span>{speech.speechAtLabel}</span>
|
||||
{speech.type ? (
|
||||
<>
|
||||
<MetaSeparator />
|
||||
<span>{speech.type}</span>
|
||||
</>
|
||||
) : null}
|
||||
{speech.durationLabel ? (
|
||||
<>
|
||||
<MetaSeparator />
|
||||
<span>{speech.durationLabel}</span>
|
||||
</>
|
||||
) : null}
|
||||
{speech.presidingOfficer ? (
|
||||
<>
|
||||
<MetaSeparator />
|
||||
<span>Presidiu: {speech.presidingOfficer}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Button asChild variant={variant} className={className}>
|
||||
<Link href={speech.watchHref}>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{watchLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const SpeechActions = ({
|
||||
|
|
@ -100,12 +149,12 @@ const SpeechActions = ({
|
|||
fullWidthOnMobile?: boolean
|
||||
}) => (
|
||||
<div className={className}>
|
||||
<Button asChild className={fullWidthOnMobile ? 'min-h-11 w-full md:w-auto' : 'min-h-11'}>
|
||||
<Link href={speech.watchHref}>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
|
||||
</Link>
|
||||
</Button>
|
||||
<SpeechWatchButton
|
||||
speech={speech}
|
||||
watchLabel={speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
|
||||
variant="default"
|
||||
className={fullWidthOnMobile ? 'min-h-11 w-full md:w-auto' : 'min-h-11'}
|
||||
/>
|
||||
{speech.officialTextUrl ? (
|
||||
<ActionLink
|
||||
href={speech.officialTextUrl}
|
||||
|
|
@ -129,8 +178,9 @@ export const SpeechResultCard = ({
|
|||
}) => {
|
||||
// C174 (option B): a speech that surfaced only because a cut matched the term
|
||||
// shows the origin note instead of an excerpt the speech does not contain, and
|
||||
// the actions sit at the top right (approved scene 05).
|
||||
if (!speech.matchedTextSearch) {
|
||||
// the actions sit at the top right (approved scene 05). C192 — a theme result
|
||||
// outranks this branch: the theme evidence lives in the speech itself.
|
||||
if (!speech.matchedTextSearch && speech.themeMatchTerm === null) {
|
||||
return (
|
||||
<article className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4">
|
||||
|
|
@ -168,67 +218,109 @@ export const SpeechResultCard = ({
|
|||
visibleTopics.length +
|
||||
(speech.scopes.length - visibleScopes.length) +
|
||||
(speech.keywords.length - visibleKeywords.length)
|
||||
const watchLabel = speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'
|
||||
const excerptNode = <SpeechExcerpt excerpt={speech.excerpt} />
|
||||
const isTheme = speech.themeMatchTerm !== null
|
||||
// C192 — a theme card only offers the trecho seek when the speech really
|
||||
// contains the literal query (a stopword-only segment hit must not claim it).
|
||||
const watchLabel =
|
||||
speech.matchKind === 'segment' && speech.matchedTextSearch ? 'Assistir no trecho' : 'Ver fala'
|
||||
// C192 — the theme card replaces the common excerpt with the provenance block;
|
||||
// the two never coexist (design gate).
|
||||
const bodyNode = isTheme ? (
|
||||
<SpeechThemeProvenance excerpt={speech.excerpt} />
|
||||
) : (
|
||||
<SpeechExcerpt excerpt={speech.excerpt} />
|
||||
)
|
||||
const chips = (
|
||||
<>
|
||||
{visibleTopics.map((topic) => (
|
||||
<Badge key={topic.value} variant="secondary" className="font-normal">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleScopes.map((scope) => (
|
||||
<Badge key={scope.value} variant="secondary" className="font-normal">
|
||||
{scope.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleKeywords.map((keyword) => (
|
||||
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
{hiddenChips > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{hiddenChips}
|
||||
</Badge>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
const hasChips = Boolean(speech.topics.length || speech.scopes.length || speech.keywords.length)
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border bg-card p-4">
|
||||
<SpeechMetaLine speech={speech} />
|
||||
{isTheme ? (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<SpeechThemeBadges matchedTextSearch={speech.matchedTextSearch} />
|
||||
<SpeechMetaLine speech={speech} />
|
||||
</div>
|
||||
) : (
|
||||
<SpeechMetaLine speech={speech} />
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<div className={isTheme ? 'mt-3' : 'mt-2'}>
|
||||
{speech.thumbnailUrl ? (
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:gap-4">
|
||||
<SpeechResultThumbnail
|
||||
href={speech.watchHref}
|
||||
src={speech.thumbnailUrl}
|
||||
label={watchLabel}
|
||||
fullWidthOnMobile={isTheme}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">{excerptNode}</div>
|
||||
<div className="min-w-0 flex-1">{bodyNode}</div>
|
||||
</div>
|
||||
) : (
|
||||
excerptNode
|
||||
bodyNode
|
||||
)}
|
||||
</div>
|
||||
|
||||
{speech.topics.length || speech.scopes.length || speech.keywords.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{visibleTopics.map((topic) => (
|
||||
<Badge key={topic.value} variant="secondary" className="font-normal">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleScopes.map((scope) => (
|
||||
<Badge key={scope.value} variant="secondary" className="font-normal">
|
||||
{scope.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleKeywords.map((keyword) => (
|
||||
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
{hiddenChips > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{hiddenChips}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isTheme ? (
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
{hasChips ? <div className="flex flex-wrap gap-1.5">{chips}</div> : <span />}
|
||||
<div className="flex flex-col items-stretch gap-2 md:flex-none md:flex-row md:items-center md:justify-end">
|
||||
<SpeechWatchButton
|
||||
speech={speech}
|
||||
watchLabel={watchLabel}
|
||||
variant="outline"
|
||||
className="min-h-11 w-full max-md:border-primary max-md:bg-primary max-md:text-primary-foreground md:min-h-10 md:w-auto"
|
||||
/>
|
||||
{speech.officialTextUrl ? (
|
||||
<ActionLink href={speech.officialTextUrl} variant="ghost" fullWidthOnMobile>
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir Diário Oficial
|
||||
</ActionLink>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
{hasChips ? <div className="mt-3 flex flex-wrap gap-1.5">{chips}</div> : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href={speech.watchHref}>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{watchLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
{speech.officialTextUrl ? (
|
||||
<ActionLink href={speech.officialTextUrl} variant="ghost">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir Diário Oficial
|
||||
</ActionLink>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<SpeechWatchButton
|
||||
speech={speech}
|
||||
watchLabel={watchLabel}
|
||||
variant="default"
|
||||
className="min-h-11"
|
||||
/>
|
||||
{speech.officialTextUrl ? (
|
||||
<ActionLink href={speech.officialTextUrl} variant="ghost">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir Diário Oficial
|
||||
</ActionLink>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{speech.cuts.length ? <SpeechNestedCuts cuts={speech.cuts} query={query} /> : null}
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -4,21 +4,25 @@ import Link from 'next/link'
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* C175/C182 — the trecho thumbnail on the acervo list: a fixed 128×80 slot so
|
||||
* the lazy image never shifts the card, with the skeleton filling it until the
|
||||
* image paints (a missing image settles into the neutral slot). The link reuses
|
||||
* the card's watch target, with the same accessible name as the CTA.
|
||||
* C192 — theme results take the full width on mobile (`h-28`), like the gate.
|
||||
*/
|
||||
export const SpeechResultThumbnail = ({
|
||||
href,
|
||||
src,
|
||||
label,
|
||||
fullWidthOnMobile = false,
|
||||
}: {
|
||||
href: string
|
||||
src: string
|
||||
label: string
|
||||
fullWidthOnMobile?: boolean
|
||||
}) => {
|
||||
const [settled, setSettled] = useState(false)
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
|
|
@ -32,7 +36,10 @@ export const SpeechResultThumbnail = ({
|
|||
<Link
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="relative block h-20 w-32 shrink-0 overflow-hidden rounded-lg border bg-muted"
|
||||
className={cn(
|
||||
'relative block shrink-0 overflow-hidden rounded-lg border bg-muted',
|
||||
fullWidthOnMobile ? 'h-28 w-full md:h-20 md:w-32' : 'h-20 w-32',
|
||||
)}
|
||||
>
|
||||
{settled ? null : <Skeleton className="absolute inset-0 rounded-lg" />}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- external static image (poster route or YouTube cover). */}
|
||||
|
|
|
|||
25
src/components/campaign/speech/SpeechThemeFallbackNotice.tsx
Normal file
25
src/components/campaign/speech/SpeechThemeFallbackNotice.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { TriangleAlertIcon } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
|
||||
/**
|
||||
* C192 — the discreet fallback notice when `mode=tema` was asked but the theme
|
||||
* expansion was unavailable. The list below shows the literal results; the
|
||||
* notice keeps the user's request honest instead of hiding the downgrade.
|
||||
*/
|
||||
export const SpeechThemeFallbackNotice = () => (
|
||||
<Alert
|
||||
variant="pending"
|
||||
data-testid="speech-theme-fallback"
|
||||
className="gap-3 border-estimate-pending/80 bg-estimate-pending/55 px-4 py-3"
|
||||
>
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle className="text-sm font-medium">
|
||||
A busca por tema está indisponível agora.
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs leading-5">
|
||||
Você pediu “Por tema”. Para não interromper o trabalho, mostramos abaixo os resultados por
|
||||
termo exato.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
24
src/components/campaign/speech/SpeechThemeRetryButton.tsx
Normal file
24
src/components/campaign/speech/SpeechThemeRetryButton.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTransition } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/** C192 — re-runs the same `mode=tema` request after a degraded fallback. */
|
||||
export const SpeechThemeRetryButton = () => {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-10"
|
||||
disabled={isPending}
|
||||
onClick={() => startTransition(() => router.refresh())}
|
||||
>
|
||||
Tentar por tema novamente
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
30
src/lib/speechThemeTerms.ts
Normal file
30
src/lib/speechThemeTerms.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* C192 — sanitization of the LLM-expanded theme terms before they reach the
|
||||
* Payload `where`. The expansion is untrusted model output: `%`/`_`/`\` would
|
||||
* widen the `LIKE`/`ILIKE` patterns, very short tokens produce noise, and the
|
||||
* list has to stay bounded so the textual OR does not explode. Pure and
|
||||
* client-safe.
|
||||
*/
|
||||
import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch'
|
||||
|
||||
export const MAX_SPEECH_THEME_TERMS = 8
|
||||
export const MIN_SPEECH_THEME_TERMS = 3
|
||||
export const MAX_SPEECH_THEME_TERM_LENGTH = 60
|
||||
const MIN_THEME_TERM_LENGTH = 3
|
||||
const LIKE_WILDCARDS = /[%_\\]/g
|
||||
|
||||
const sanitizeSpeechThemeTerm = (value: string): string =>
|
||||
value
|
||||
.replace(LIKE_WILDCARDS, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, MAX_SPEECH_THEME_TERM_LENGTH)
|
||||
.trim()
|
||||
|
||||
/** Removes wildcards, drops too-short/empty terms, dedupes and caps the list. */
|
||||
export const normalizeSpeechThemeTerms = (values: readonly string[]): string[] =>
|
||||
uniqueByNormalizedForm(
|
||||
values
|
||||
.map(sanitizeSpeechThemeTerm)
|
||||
.filter((term) => normalizeForSearch(term).length >= MIN_THEME_TERM_LENGTH),
|
||||
).slice(0, MAX_SPEECH_THEME_TERMS)
|
||||
74
src/utilities/ai/expandSpeechSearchTheme.ts
Normal file
74
src/utilities/ai/expandSpeechSearchTheme.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import 'server-only'
|
||||
|
||||
import { deepSeek } from '@ai-sdk/deepseek'
|
||||
import { generateObject } from 'ai'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
MAX_SPEECH_THEME_TERM_LENGTH,
|
||||
MAX_SPEECH_THEME_TERMS,
|
||||
MIN_SPEECH_THEME_TERMS,
|
||||
normalizeSpeechThemeTerms,
|
||||
} from '@/lib/speechThemeTerms'
|
||||
|
||||
const EXPAND_TIMEOUT_MS = 4000
|
||||
const MAX_OUTPUT_TOKENS = 200
|
||||
const MAX_THEME_LENGTH = 200
|
||||
|
||||
const expandSchema = z.object({
|
||||
terms: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
`Termos e expressões curtas (${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS}) que podem aparecer na transcrição de falas sobre o tema. Lista vazia quando não houver termo confiável.`,
|
||||
),
|
||||
})
|
||||
|
||||
const EXPAND_SYSTEM_PROMPT =
|
||||
'Você ajuda a assessoria de comunicação a achar, no acervo de discursos do deputado Jorge Solla, ' +
|
||||
'falas sobre um tema mesmo quando ele não usou as palavras exatas da busca. ' +
|
||||
'Receberá o tema digitado e deve devolver termos e expressões curtas que provavelmente aparecem ' +
|
||||
'na transcrição de uma fala sobre esse tema — sinônimos, nomes de programas, termos técnicos e ' +
|
||||
'palavras relacionadas. Inclua a própria expressão do tema quando ela for útil. ' +
|
||||
`Devolva de ${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS} termos, cada um com no máximo ${MAX_SPEECH_THEME_TERM_LENGTH} caracteres, sem explicações, ` +
|
||||
'sem pontuação extra e sem termos genéricos que apareceriam em qualquer discurso (ex.: "Brasil", ' +
|
||||
'"povo", "governo"). Se não houver termo confiável, devolva a lista vazia. Nunca invente fatos.'
|
||||
|
||||
type SpeechThemeExpansion = { terms: string[] }
|
||||
|
||||
/**
|
||||
* The injectable expansion boundary (tests pass a deterministic resolver, the
|
||||
* same pattern as `SpeechVideoStartResolver`).
|
||||
*/
|
||||
export type SpeechThemeExpansionResolver = (theme: string) => Promise<SpeechThemeExpansion | null>
|
||||
|
||||
/**
|
||||
* C192 — resolves the query into a theme expansion. Never throws: missing key,
|
||||
* empty theme, timeout, provider error or malformed output resolve to `null`,
|
||||
* and the caller falls back to the literal search with a discreet notice. An
|
||||
* empty `terms` list is a legitimate answer (the model ran but had nothing to
|
||||
* add) and is distinct from `null` ("mechanism unavailable").
|
||||
*/
|
||||
export const expandSpeechSearchTheme: SpeechThemeExpansionResolver = async (theme) => {
|
||||
if (!process.env.DEEPSEEK_API_KEY) return null
|
||||
|
||||
const trimmed = theme.trim().slice(0, MAX_THEME_LENGTH)
|
||||
if (!trimmed) return null
|
||||
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: deepSeek('deepseek-flash'),
|
||||
schema: expandSchema,
|
||||
system: EXPAND_SYSTEM_PROMPT,
|
||||
prompt: trimmed,
|
||||
// Deterministic as the provider allows: pages 2+ of the same search must
|
||||
// not be built from a different expansion.
|
||||
temperature: 0,
|
||||
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
||||
abortSignal: AbortSignal.timeout(EXPAND_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
return { terms: normalizeSpeechThemeTerms(object.terms) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -49,22 +49,48 @@ const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
|
|||
return filters
|
||||
}
|
||||
|
||||
const buildSpeechTextBranches = (q: string): Where[] => [
|
||||
{ searchText: { like: normalizeForSearch(q) } },
|
||||
{ keywords: { contains: q } },
|
||||
]
|
||||
const buildSpeechTextBranches = (q: string, themeTerms: readonly string[] = []): Where[] => {
|
||||
const branches: Where[] = []
|
||||
const seenText = new Set<string>()
|
||||
const seenKeyword = new Set<string>()
|
||||
|
||||
// C192 — the literal query plus the expanded theme terms, each contributing
|
||||
// the same two branches (normalized text / raw keyword). Duplicates are
|
||||
// dropped so an expansion that repeats the query does not widen the OR.
|
||||
for (const term of [q, ...themeTerms]) {
|
||||
const normalized = normalizeForSearch(term)
|
||||
if (normalized && !seenText.has(normalized)) {
|
||||
seenText.add(normalized)
|
||||
branches.push({ searchText: { like: normalized } })
|
||||
}
|
||||
const raw = term.trim()
|
||||
const lowered = raw.toLowerCase()
|
||||
if (raw && !seenKeyword.has(lowered)) {
|
||||
seenKeyword.add(lowered)
|
||||
branches.push({ keywords: { contains: raw } })
|
||||
}
|
||||
}
|
||||
|
||||
return branches
|
||||
}
|
||||
|
||||
/**
|
||||
* The textual branch: normalized speech text OR a raw official keyword. The
|
||||
* keyword `contains` compiles to ILIKE `%q%` — case-insensitive, accent still
|
||||
* significant. The view-model mirror is `lib/speechSearch.speechMatchesSearchQuery`
|
||||
* (C180); a semantics change here has to land there too.
|
||||
* The textual branch: normalized speech text OR a raw official keyword, for the
|
||||
* query and (C192) every expanded theme term. The keyword `contains` compiles
|
||||
* to ILIKE `%q%` — case-insensitive, accent still significant. The view-model
|
||||
* mirror is `lib/speechSearch.speechMatchesSearchTerm`; a semantics change here
|
||||
* has to land there too.
|
||||
*/
|
||||
const buildSpeechTextWhere = (q: string): Where => ({ or: buildSpeechTextBranches(q) })
|
||||
const buildSpeechTextWhere = (q: string, themeTerms: readonly string[] = []): Where => ({
|
||||
or: buildSpeechTextBranches(q, themeTerms),
|
||||
})
|
||||
|
||||
export const buildSpeechListWhere = (state: SpeechListState): Where => {
|
||||
export const buildSpeechListWhere = (
|
||||
state: SpeechListState,
|
||||
themeTerms: readonly string[] = [],
|
||||
): Where => {
|
||||
const filters = buildSpeechFacetWhere(state)
|
||||
if (state.q) filters.push(buildSpeechTextWhere(state.q))
|
||||
if (state.q) filters.push(buildSpeechTextWhere(state.q, themeTerms))
|
||||
|
||||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
|
|
@ -72,15 +98,18 @@ export const buildSpeechListWhere = (state: SpeechListState): Where => {
|
|||
/**
|
||||
* C174 (option B) — the acervo `where` widened to the origin speech of a
|
||||
* matching cut: the page still paginates by SPEECH, so the origin ids are OR-ed
|
||||
* into the textual branch (facets stay AND-ed over every row).
|
||||
* into the textual branch (facets stay AND-ed over every row). C192 adds the
|
||||
* expanded theme terms to that same textual branch; only the literal query
|
||||
* drives the cut-origin lookup.
|
||||
*/
|
||||
export const buildSpeechListWhereIncludingCutOrigins = (
|
||||
state: SpeechListState,
|
||||
originSpeechIds: readonly number[],
|
||||
themeTerms: readonly string[] = [],
|
||||
): Where => {
|
||||
const filters = buildSpeechFacetWhere(state)
|
||||
if (state.q) {
|
||||
const branches = buildSpeechTextBranches(state.q)
|
||||
const branches = buildSpeechTextBranches(state.q, themeTerms)
|
||||
if (originSpeechIds.length) branches.push({ id: { in: [...originSpeechIds] } })
|
||||
filters.push({ or: branches })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ import {
|
|||
|
||||
export const speechPageSize = 25
|
||||
|
||||
/**
|
||||
* C192 — search mode of the acervo. `termo` is the literal search (default,
|
||||
* never serialized so existing deep links stay byte-identical); `tema` asks the
|
||||
* semantic bridge for related terms. Kept as a single-value param, only
|
||||
* meaningful alongside `q`.
|
||||
*/
|
||||
export type SpeechSearchMode = 'termo' | 'tema'
|
||||
|
||||
/** Duration buckets are ranges over `durationSeconds`; `sem_duracao` is null. */
|
||||
export const SPEECH_DURATION_BUCKETS = [
|
||||
{ value: 'curta', label: 'Até 2 min' },
|
||||
|
|
@ -51,6 +59,7 @@ export const speechScopeLabels = Object.fromEntries(
|
|||
export type SpeechListState = {
|
||||
page: number
|
||||
q?: string
|
||||
mode?: SpeechSearchMode
|
||||
years?: number[]
|
||||
topics?: SpeechTopic[]
|
||||
scopes?: SpeechScope[]
|
||||
|
|
@ -70,6 +79,7 @@ type RawSpeechListParams = RawSearchParams
|
|||
|
||||
const speechListParamNames = [
|
||||
'q',
|
||||
'mode',
|
||||
'year',
|
||||
'topic',
|
||||
'scope',
|
||||
|
|
@ -95,10 +105,15 @@ const parseMunicipalityValues = (raw: string | string[] | undefined): number[] =
|
|||
.map((token) => strictDecimalInteger(token))
|
||||
.filter((id): id is number => id !== undefined && id > 0)
|
||||
|
||||
/** Only `tema` is meaningful; anything else (and `termo`) means the default. */
|
||||
const parseSpeechSearchMode = (raw: string | string[] | undefined): SpeechSearchMode | undefined =>
|
||||
firstValue(raw) === 'tema' ? 'tema' : undefined
|
||||
|
||||
export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListState => {
|
||||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const rawQ = normalizedText(firstValue(params.q))
|
||||
const q = rawQ && isContactSearchQueryReady(rawQ) ? rawQ : undefined
|
||||
const mode = parseSpeechSearchMode(params.mode)
|
||||
const years = parseYearValues(params.year)
|
||||
const topics = parseExhaustiveEnumParam<SpeechTopic>(params.topic, speechTopicSet)
|
||||
const scopes = parseExhaustiveEnumParam<SpeechScope>(params.scope, speechScopeSet)
|
||||
|
|
@ -112,6 +127,7 @@ export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListSt
|
|||
return {
|
||||
page: rawPage ?? 1,
|
||||
...(q ? { q } : {}),
|
||||
...(mode ? { mode } : {}),
|
||||
...(years.length ? { years } : {}),
|
||||
...(topics.length ? { topics } : {}),
|
||||
...(scopes.length ? { scopes } : {}),
|
||||
|
|
@ -127,6 +143,7 @@ const speechListStateToRawParams = (
|
|||
): RawSpeechListParams => ({
|
||||
page: String(page),
|
||||
q: state.q,
|
||||
mode: state.mode,
|
||||
year: state.years?.map(String),
|
||||
topic: state.topics,
|
||||
scope: state.scopes,
|
||||
|
|
@ -141,6 +158,9 @@ export const serializeCanonicalSpeechListSearchParams = (
|
|||
): URLSearchParams => {
|
||||
const params = new URLSearchParams()
|
||||
if (canonicalState.q) params.set('q', canonicalState.q)
|
||||
// `termo` is the default and never serialized; `tema` only makes sense with a
|
||||
// query, so a theme param without `q` canonicalizes away.
|
||||
if (canonicalState.mode === 'tema' && canonicalState.q) params.set('mode', 'tema')
|
||||
for (const year of canonicalState.years ?? []) params.append('year', String(year))
|
||||
for (const topic of canonicalState.topics ?? []) params.append('topic', topic)
|
||||
for (const scope of canonicalState.scopes ?? []) params.append('scope', scope)
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -2,8 +2,13 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
|
||||
import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
|
||||
import type { CampaignUser, Speech } from '@/payload-types'
|
||||
import {
|
||||
expandSpeechSearchTheme,
|
||||
type SpeechThemeExpansionResolver,
|
||||
} from '@/utilities/ai/expandSpeechSearchTheme'
|
||||
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
|
||||
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
|
||||
import { getYouTubeVideoStart } from '@/utilities/socialFeed/youtubeFeed'
|
||||
|
|
@ -155,17 +160,36 @@ export type SpeechAcervoPageData = {
|
|||
totalDocs: number
|
||||
totalPages: number
|
||||
filterOptions: SpeechFilterOptions
|
||||
/**
|
||||
* C192 — true when the actor asked for `mode=tema` but the expansion
|
||||
* mechanism was unavailable; the page shows the discreet fallback notice.
|
||||
*/
|
||||
themeUnavailable: boolean
|
||||
/** C192 — true when the expansion actually contributed terms. */
|
||||
themeApplied: boolean
|
||||
}
|
||||
|
||||
export const loadSpeechAcervoPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: Promise<SpeechListSearchParams> | SpeechListSearchParams,
|
||||
expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme,
|
||||
): Promise<SpeechAcervoPageData> => {
|
||||
const rawSearchParams = await searchParams
|
||||
const canonicalUrl = resolveSpeechListUrl(rawSearchParams)
|
||||
const state = canonicalUrl.state
|
||||
|
||||
// C192 — only expand for an actor who may read the catalog (the `find` below
|
||||
// is still the final, fail-closed barrier) and only with a query.
|
||||
const themeRequested = state.mode === 'tema' && Boolean(state.q)
|
||||
let themeTerms: readonly string[] = []
|
||||
let themeUnavailable = false
|
||||
if (themeRequested && canReadSpeechCatalog(user.role)) {
|
||||
const expansion = await expandTheme(state.q ?? '')
|
||||
if (expansion) themeTerms = expansion.terms
|
||||
else themeUnavailable = true
|
||||
}
|
||||
|
||||
// C174 (option B) — a speech also shows up when one of its cuts matches the
|
||||
// term by title/description, even if the speech text itself does not.
|
||||
const originSpeechIds = state.q ? await loadSpeechCutOriginSpeechIds(payload, user, state.q) : []
|
||||
|
|
@ -176,7 +200,7 @@ export const loadSpeechAcervoPageData = async (
|
|||
limit: speechPageSize,
|
||||
page: state.page,
|
||||
sort: '-speechAt',
|
||||
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds),
|
||||
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds, themeTerms),
|
||||
// C174 — the row only needs its normalized text to tell whether it matched
|
||||
// the term itself (option B); the unsearched list does not pay for it.
|
||||
select: state.q ? { ...speechListSelect, searchText: true } : speechListSelect,
|
||||
|
|
@ -210,6 +234,7 @@ export const loadSpeechAcervoPageData = async (
|
|||
query: state.q,
|
||||
municipalityLabels,
|
||||
cuts: cutsBySpeech.get(speech.id) ?? [],
|
||||
themeTerms,
|
||||
}),
|
||||
),
|
||||
state: resolvedUrl.state,
|
||||
|
|
@ -217,6 +242,8 @@ export const loadSpeechAcervoPageData = async (
|
|||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
filterOptions,
|
||||
themeUnavailable,
|
||||
themeApplied: themeTerms.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ import {
|
|||
type SpeechHighlightPart,
|
||||
} from '@/lib/speechHighlight'
|
||||
import { speechPosterHref, speechPosterTarget } from '@/lib/speechPoster'
|
||||
import { normalizeForSearch, speechMatchesSearchQuery } from '@/lib/speechSearch'
|
||||
import {
|
||||
normalizeForSearch,
|
||||
speechMatchesSearchQuery,
|
||||
speechMatchesSearchTerm,
|
||||
} from '@/lib/speechSearch'
|
||||
import {
|
||||
correctedExcerptOffsetSeconds,
|
||||
excerptOffsetSeconds,
|
||||
|
|
@ -55,7 +59,7 @@ export type SpeechListRecord = {
|
|||
searchText?: string | null
|
||||
}
|
||||
|
||||
type SpeechListMatchKind = 'segment' | 'keyword' | 'fallback'
|
||||
type SpeechListMatchKind = 'segment' | 'keyword' | 'theme' | 'fallback'
|
||||
|
||||
export type SpeechListItemViewModel = {
|
||||
id: number
|
||||
|
|
@ -89,6 +93,12 @@ export type SpeechListItemViewModel = {
|
|||
* the term; the card then swaps the excerpt for the honest origin note.
|
||||
*/
|
||||
matchedTextSearch: boolean
|
||||
/**
|
||||
* C192 — the expanded theme term that matched the speech text/keywords, or
|
||||
* null when the search was literal (or the expansion did not surface this
|
||||
* row). The card uses it to label the result and to explain why it appeared.
|
||||
*/
|
||||
themeMatchTerm: string | null
|
||||
}
|
||||
|
||||
export type SpeechDetailSegmentViewModel = {
|
||||
|
|
@ -185,6 +195,41 @@ const pickMatchingSegment = (
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* C192 — the first expanded theme term that actually surfaced this speech. The
|
||||
* gate is the `speechMatchesSearchTerm` mirror (the same predicate the `where`
|
||||
* runs), so the card never claims a theme the search did not use. The evidence
|
||||
* is the passage that carries the term, or the official keyword when that is
|
||||
* what matched. Returns undefined when no theme term applies to this row.
|
||||
*/
|
||||
const pickThemeMatch = (
|
||||
speech: SpeechListRecord,
|
||||
segments: readonly SpeechSegmentRecord[],
|
||||
themeTerms: readonly string[],
|
||||
): { term: string; segment?: SpeechSegmentRecord; keyword?: string } | undefined => {
|
||||
for (const term of themeTerms) {
|
||||
const trimmed = term.trim()
|
||||
const normalized = normalizeForSearch(trimmed)
|
||||
if (!normalized || !speechMatchesSearchTerm(speech, trimmed)) continue
|
||||
|
||||
if (normalizeForSearch(speech.searchText ?? '').includes(normalized)) {
|
||||
const segment =
|
||||
segments.find((item) => normalizeForSearch(item.text).includes(normalized)) ??
|
||||
segments.find((item) => {
|
||||
const text = normalizeForSearch(item.text)
|
||||
return speechSearchTerms(trimmed).some((word) => text.includes(word))
|
||||
}) ??
|
||||
segments[0]
|
||||
return { term: trimmed, segment }
|
||||
}
|
||||
|
||||
const lowered = trimmed.toLowerCase()
|
||||
const keyword = (speech.keywords ?? []).find((item) => item.toLowerCase().includes(lowered))
|
||||
if (keyword) return { term: trimmed, keyword }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const buildWatchHref = (
|
||||
speechId: number,
|
||||
segment: SpeechSegmentRecord | undefined,
|
||||
|
|
@ -216,6 +261,7 @@ export const toSpeechListItemViewModel = ({
|
|||
query,
|
||||
municipalityLabels,
|
||||
cuts = [],
|
||||
themeTerms = [],
|
||||
}: {
|
||||
speech: SpeechListRecord
|
||||
segments: readonly SpeechSegmentRecord[]
|
||||
|
|
@ -223,6 +269,8 @@ export const toSpeechListItemViewModel = ({
|
|||
municipalityLabels: ReadonlyMap<number, string>
|
||||
/** C174 — the cuts of this speech, nested in the result card. */
|
||||
cuts?: SpeechCuts
|
||||
/** C192 — expanded theme terms; empty in the literal search. */
|
||||
themeTerms?: readonly string[]
|
||||
}): SpeechListItemViewModel => {
|
||||
const matchedSegment = pickMatchingSegment(segments, query)
|
||||
const q = query?.trim()
|
||||
|
|
@ -232,8 +280,25 @@ export const toSpeechListItemViewModel = ({
|
|||
Boolean(normalizedQuery) &&
|
||||
(speech.keywords ?? []).some((keyword) => normalizeForSearch(keyword).includes(normalizedQuery))
|
||||
|
||||
// C192 — the theme evidence wins the excerpt so the card's "Por que apareceu"
|
||||
// block shows the passage that actually matched, even when the speech also
|
||||
// contains the literal query.
|
||||
const themeMatch = pickThemeMatch(speech, segments, themeTerms)
|
||||
|
||||
const excerptSource =
|
||||
matchedSegment?.text ?? segments[0]?.text ?? speech.summary ?? speech.officialTranscript ?? ''
|
||||
themeMatch?.segment?.text ??
|
||||
themeMatch?.keyword ??
|
||||
matchedSegment?.text ??
|
||||
segments[0]?.text ??
|
||||
speech.summary ??
|
||||
speech.officialTranscript ??
|
||||
''
|
||||
|
||||
// C192 — the theme passage highlights the whole phrase as one band (the
|
||||
// per-term split would drop the "à" and break it into pieces).
|
||||
const excerpt = themeMatch
|
||||
? buildHighlightedExcerpt(excerptSource, themeMatch.term, { phrase: true })
|
||||
: buildHighlightedExcerpt(excerptSource, q ?? '')
|
||||
|
||||
return {
|
||||
id: speech.id,
|
||||
|
|
@ -241,8 +306,14 @@ export const toSpeechListItemViewModel = ({
|
|||
type: speech.type ?? null,
|
||||
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
||||
presidingOfficer: speech.presidingOfficer ?? null,
|
||||
excerpt: buildHighlightedExcerpt(excerptSource, q ?? ''),
|
||||
matchKind: matchedSegment ? 'segment' : keywordMatch ? 'keyword' : 'fallback',
|
||||
excerpt,
|
||||
matchKind: matchedSegment
|
||||
? 'segment'
|
||||
: keywordMatch
|
||||
? 'keyword'
|
||||
: themeMatch
|
||||
? 'theme'
|
||||
: 'fallback',
|
||||
topics: topicViewModels(speech),
|
||||
scopes: scopeViewModels(speech),
|
||||
keywords: speech.keywords ?? [],
|
||||
|
|
@ -252,6 +323,7 @@ export const toSpeechListItemViewModel = ({
|
|||
officialTextUrl: speech.officialTextUrl ?? null,
|
||||
cuts: [...cuts],
|
||||
matchedTextSearch: speechMatchesSearchQuery(speech, query),
|
||||
themeMatchTerm: themeMatch?.term ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -335,6 +335,43 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
expect(filteredHtml).toContain('Limpar busca e filtros')
|
||||
})
|
||||
|
||||
test('theme mode degrades honestly to the exact search without the key (C192)', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('temadeg')
|
||||
await createSpeech(campaign, { marker })
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
// The e2e environment blanks DEEPSEEK_API_KEY (playwright.config.ts), so the
|
||||
// expansion is unavailable and the page must fall back with the notice.
|
||||
const themed = await request.get(`/campanha/comunicacao/acervo?q=${marker}&mode=tema`)
|
||||
expect(themed.status()).toBe(200)
|
||||
const themedHtml = rendered(await themed.text())
|
||||
expect(themedHtml).toContain('Modo de busca')
|
||||
expect(themedHtml).toContain('Termo exato')
|
||||
expect(themedHtml).toContain('Por tema')
|
||||
expect(themedHtml).toContain('data-testid="speech-theme-fallback"')
|
||||
expect(themedHtml).toContain('A busca por tema está indisponível agora.')
|
||||
expect(themedHtml).toContain('Tentar por tema novamente')
|
||||
expect(themedHtml).toContain('Resultados por termo exato')
|
||||
expect(themedHtml).toContain('data-testid="speech-theme-fallback"')
|
||||
// The exact results still render and keep the highlighted term.
|
||||
expect(themedHtml).toContain(marker)
|
||||
expect(themedHtml).toContain('<mark')
|
||||
// No theme badge is fabricated.
|
||||
expect(themedHtml).not.toContain('Por que apareceu')
|
||||
|
||||
// The default mode stays untouched: no mode param means no notice.
|
||||
const exact = await request.get(`/campanha/comunicacao/acervo?q=${marker}`)
|
||||
expect(exact.status()).toBe(200)
|
||||
const exactHtml = rendered(await exact.text())
|
||||
expect(exactHtml).toContain(marker)
|
||||
expect(exactHtml).not.toContain('A busca por tema está indisponível agora.')
|
||||
})
|
||||
|
||||
test('advisor and leader are redirected away from the acervo', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
|
|
|
|||
|
|
@ -347,4 +347,73 @@ describe('speech acervo (C154)', () => {
|
|||
loadSpeechDetailPageData(payload, communicator, 999_999_999),
|
||||
).rejects.toBeInstanceOf(SpeechNotFoundError)
|
||||
})
|
||||
|
||||
it('finds a speech by expanded theme terms and explains the match (C192)', async () => {
|
||||
const runId = randomUUID().slice(0, 8)
|
||||
const marker = `tema${runId}`
|
||||
const id = await createSpeech({
|
||||
segments: [
|
||||
{
|
||||
startSeconds: 0,
|
||||
endSeconds: 3,
|
||||
text: `Garantir o atendimento universal ${marker} na rede pública`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { coordinator } = await createUsers()
|
||||
const expansionCalls: string[] = []
|
||||
const data = await loadSpeechAcervoPageData(
|
||||
payload,
|
||||
coordinator,
|
||||
{ q: 'defesa do SUS', mode: 'tema' },
|
||||
async (theme) => {
|
||||
expansionCalls.push(theme)
|
||||
return { terms: [`atendimento universal ${marker}`] }
|
||||
},
|
||||
)
|
||||
|
||||
expect(expansionCalls).toEqual(['defesa do SUS'])
|
||||
expect(data.themeUnavailable).toBe(false)
|
||||
expect(data.themeApplied).toBe(true)
|
||||
const row = data.rows.find((item) => item.id === id)
|
||||
expect(row?.matchKind).toBe('theme')
|
||||
expect(row?.themeMatchTerm).toBe(`atendimento universal ${marker}`)
|
||||
expect(row?.matchedTextSearch).toBe(false)
|
||||
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(true)
|
||||
})
|
||||
|
||||
it('degrades to the literal search when the expansion is unavailable (C192)', async () => {
|
||||
const runId = randomUUID().slice(0, 8)
|
||||
const marker = `literal${runId}`
|
||||
const id = await createSpeech({
|
||||
segments: [{ startSeconds: 0, endSeconds: 3, text: `A defesa do SUS ${marker} no plenário` }],
|
||||
})
|
||||
|
||||
const { coordinator } = await createUsers()
|
||||
const data = await loadSpeechAcervoPageData(
|
||||
payload,
|
||||
coordinator,
|
||||
{ q: `defesa do SUS ${marker}`, mode: 'tema' },
|
||||
async () => null,
|
||||
)
|
||||
|
||||
expect(data.themeUnavailable).toBe(true)
|
||||
expect(data.themeApplied).toBe(false)
|
||||
const row = data.rows.find((item) => item.id === id)
|
||||
expect(row?.themeMatchTerm).toBeNull()
|
||||
expect(row?.matchedTextSearch).toBe(true)
|
||||
})
|
||||
|
||||
it('never expands for an actor outside the catalog gate (C192)', async () => {
|
||||
const { advisor } = await createUsers()
|
||||
let called = false
|
||||
await expect(
|
||||
loadSpeechAcervoPageData(payload, advisor, { q: 'SUS', mode: 'tema' }, async () => {
|
||||
called = true
|
||||
return { terms: ['saúde'] }
|
||||
}),
|
||||
).rejects.toThrow(/permissão/i)
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
69
tests/unit/expandSpeechSearchTheme.unit.spec.ts
Normal file
69
tests/unit/expandSpeechSearchTheme.unit.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { generateObjectMock, deepSeekMock } = vi.hoisted(() => ({
|
||||
generateObjectMock: vi.fn(),
|
||||
deepSeekMock: vi.fn(() => 'mock-model'),
|
||||
}))
|
||||
|
||||
vi.mock('ai', () => ({ generateObject: generateObjectMock }))
|
||||
vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: deepSeekMock }))
|
||||
|
||||
import { expandSpeechSearchTheme } from '@/utilities/ai/expandSpeechSearchTheme'
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DEEPSEEK_API_KEY = 'test-deepseek-key'
|
||||
generateObjectMock.mockReset()
|
||||
deepSeekMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DEEPSEEK_API_KEY
|
||||
})
|
||||
|
||||
describe('expandSpeechSearchTheme (C192)', () => {
|
||||
it('returns the sanitized terms the model produced', async () => {
|
||||
generateObjectMock.mockResolvedValue({
|
||||
object: { terms: ['SUS%', 'saúde pública', 'sus', 'ab'] },
|
||||
})
|
||||
|
||||
await expect(expandSpeechSearchTheme('defesa do SUS')).resolves.toEqual({
|
||||
terms: ['SUS', 'saúde pública'],
|
||||
})
|
||||
})
|
||||
|
||||
it('honra a lista vazia como resposta legítima (não é indisponibilidade)', async () => {
|
||||
generateObjectMock.mockResolvedValue({ object: { terms: [] } })
|
||||
|
||||
await expect(expandSpeechSearchTheme('tema qualquer')).resolves.toEqual({ terms: [] })
|
||||
})
|
||||
|
||||
it('curto-circuita sem chave sem chamar o provedor', async () => {
|
||||
delete process.env.DEEPSEEK_API_KEY
|
||||
|
||||
await expect(expandSpeechSearchTheme('SUS')).resolves.toBeNull()
|
||||
expect(generateObjectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('curto-circuita um tema vazio', async () => {
|
||||
await expect(expandSpeechSearchTheme(' ')).resolves.toBeNull()
|
||||
expect(generateObjectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('degrada para null em erro do provedor — nunca lança', async () => {
|
||||
generateObjectMock.mockRejectedValue(new Error('provider down'))
|
||||
|
||||
await expect(expandSpeechSearchTheme('SUS')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('envia o tema e um abortSignal limitado ao modelo', async () => {
|
||||
generateObjectMock.mockResolvedValue({ object: { terms: ['SUS'] } })
|
||||
|
||||
await expandSpeechSearchTheme('defesa do SUS')
|
||||
|
||||
const call = generateObjectMock.mock.calls[0]![0] as { prompt: string; abortSignal: unknown }
|
||||
expect(call.prompt).toBe('defesa do SUS')
|
||||
expect(call.abortSignal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
32
tests/unit/speechThemeTerms.unit.spec.ts
Normal file
32
tests/unit/speechThemeTerms.unit.spec.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAX_SPEECH_THEME_TERMS, normalizeSpeechThemeTerms } from '@/lib/speechThemeTerms'
|
||||
|
||||
describe('normalizeSpeechThemeTerms (C192)', () => {
|
||||
it('removes LIKE wildcards so the expansion cannot widen the pattern', () => {
|
||||
expect(normalizeSpeechThemeTerms(['saúde%', 'a_b', 'c\\d'])).toEqual(['saúde', 'a b', 'c d'])
|
||||
})
|
||||
|
||||
it('drops empty and too-short terms', () => {
|
||||
expect(normalizeSpeechThemeTerms(['', ' ', 'ab', 'SUS', 'de'])).toEqual(['SUS'])
|
||||
})
|
||||
|
||||
it('dedupes by normalized form keeping the first spelling', () => {
|
||||
expect(normalizeSpeechThemeTerms(['Saúde Pública', 'saude publica', 'SUS'])).toEqual([
|
||||
'Saúde Pública',
|
||||
'SUS',
|
||||
])
|
||||
})
|
||||
|
||||
it('caps the list length', () => {
|
||||
const values = Array.from({ length: 20 }, (_, index) => `termo ${index}`)
|
||||
expect(normalizeSpeechThemeTerms(values)).toHaveLength(MAX_SPEECH_THEME_TERMS)
|
||||
})
|
||||
|
||||
it('truncates a single term to the max length', () => {
|
||||
const term = 'x'.repeat(90)
|
||||
expect(normalizeSpeechThemeTerms([term])[0]).toHaveLength(60)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue