From 159179e09d3d414fb86e7747f47626d7b28a1eb5 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 23 Sep 2026 13:43:48 -0300 Subject: [PATCH] =?UTF-8?q?feat(S28):=20busca=20por=20tema=20na=20Central?= =?UTF-8?q?=20de=20Conte=C3=BAdos=20p=C3=BAblica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-23-s28.md | 1 + .../central-conteudos-busca-semantica-impl.md | 218 ++++++++++++ ...l-conteudos-busca-semantica-ui-design.html | 335 ++++++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 6 +- .../(frontend)/conteudos/(catalog)/page.tsx | 76 ++-- src/components/conteudos/ContentPieceCard.tsx | 82 ++++- .../conteudos/ContentPieceCatalog.tsx | 4 + .../conteudos/ContentPieceFilters.tsx | 110 +++--- .../conteudos/ContentPieceSearchMode.tsx | 82 +++++ .../conteudos/ContentPieceStates.tsx | 57 ++- .../conteudos/contentPieceClasses.ts | 4 + src/lib/contentPieceCatalog.ts | 178 +++++++++- src/utilities/ai/expandSpeechSearchTheme.ts | 80 +++-- src/utilities/ai/themeSearchGuard.ts | 168 +++++++++ src/utilities/content/contentPieceReads.ts | 21 +- .../content/contentPieceThemeSearch.ts | 99 ++++++ tests/e2e/frontendConteudos.e2e.spec.ts | 42 +++ tests/int/contentPiece.int.spec.ts | 124 +++++++ tests/unit/contentPieceCatalog.unit.spec.ts | 101 ++++++ .../unit/expandSpeechSearchTheme.unit.spec.ts | 34 +- tests/unit/themeSearchGuard.unit.spec.ts | 164 +++++++++ 21 files changed, 1858 insertions(+), 128 deletions(-) create mode 100644 docs/changelog/2026-09-23-s28.md create mode 100644 docs/plans/central-conteudos-busca-semantica-impl.md create mode 100644 src/components/conteudos/ContentPieceSearchMode.tsx create mode 100644 src/utilities/ai/themeSearchGuard.ts create mode 100644 src/utilities/content/contentPieceThemeSearch.ts create mode 100644 tests/unit/themeSearchGuard.unit.spec.ts diff --git a/docs/changelog/2026-09-23-s28.md b/docs/changelog/2026-09-23-s28.md new file mode 100644 index 00000000..14d7e1ad --- /dev/null +++ b/docs/changelog/2026-09-23-s28.md @@ -0,0 +1 @@ +**Busca por tema na Central de Conteúdos pública (S28, 2026-09-23):** o campo de busca de `/conteudos` ganhou o modo visível "Buscar por: Termo exato | ✦ Por tema" (exato é o padrão; a escolha vive na URL — `mode=tema`, só com `q` — e o form GET continua funcionando sem JS: Enter busca, mudar o modo submete; o botão de submit do S27 saiu, o CTA de pedido de voto segue no hero). O mecanismo é o do C192 generalizado por corpus (`expandSearchTheme(theme, 'speech' | 'contentPiece')`, mesmo contrato de falha/sanitização; o acervo de falas segue byte-idêntico), e o catálogo público passou a OR-ar o termo literal com os termos expandidos, sempre sobre as peças publicadas (kill switch intacto). Cada resultado diz por que apareceu: selo "✦ Tema" com o trecho real que casa (janela do transcript/descrição com a frase destacada) ou "Termo exato" quando só o `q` casou; quando o casamento veio de tema/cidade/instituição, o bloco mostra um trecho real, curto e rotulado, sem destaque — nunca score, nunca "parecido" enganoso, e o transcript completo não sai do servidor. Vazio honesto do tema ("Nenhuma peça combina bem com esse tema") com três saídas (reformular, usar termo exato, limpar filtros); indisponibilidade (sem chave, timeout, limite ou pedido não-browser) degrada para a busca exata com aviso âmbar discreto + "Tentar por tema novamente". A rota é pública e sem login, então nasceu a guarda anônima (`themeSearchGuard`): limiter in-memory por IP (8/15min, `cf-connecting-ip`→`x-forwarded-for`→`x-real-ip`→`unknown`), gate por Fetch Metadata (`navigate`/`cors`; `no-cors` recusado, scraper sem os headers fica no literal) e cache in-memory TTL 30min por termo normalizado (hit não consome cota; `{terms:[]}` legítimo é cacheado, `null` nunca) — estouro degrada, nunca 429. Achado do app renderizado: o Next 15 consome `RSC`/`Next-Router-Prefetch` antes do userland (o gate original por `rsc` degradava toda navegação client-side) — a correção usa os headers que chegam ao server e a UI mantém `prefetch={false}` em todo link que preserva o modo. Testes: 1 unit spec novo (guarda: elegibilidade/chave/limiter/cache) e 1 estendido (catálogo: modo/OR/proveniência), unit do expansor generalizado, 6 casos int no `contentPiece` (expansão injetada, degradação, lista sem termos, inelegível, sem modo, rascunho) e o e2e `frontendConteudos` estendido (segmented, degradação com aviso, Enter sem JS); pins do `E2E_AFFECTED_MANIFEST` (`contentPieceThemeSearch` na superfície S27; `frontendConteudos` no risk prefix `src/utilities/ai`). Sem migration/schema/Consent/access novo. Design: port classe-a-classe das cenas 01–09, crítica final certificada pelo tier primário (`Design tier: openai/gpt-5.6-sol`) contra o app renderizado (12 screenshots desktop/mobile + hover/foco), após duas rodadas que pediram evidência dos estados de hover/foco, do `{terms:[]}` e do vazio. diff --git a/docs/plans/central-conteudos-busca-semantica-impl.md b/docs/plans/central-conteudos-busca-semantica-impl.md new file mode 100644 index 00000000..3b9c9c50 --- /dev/null +++ b/docs/plans/central-conteudos-busca-semantica-impl.md @@ -0,0 +1,218 @@ +# Impl: S28 — Busca por tema na Central de Conteúdos pública + +Status: **aprovado** +Atualizado em: 2026-09-23 +Issue: #1257 +Intenção: docs/plans/central-conteudos-busca-semantica.md +Design UI (gate): docs/plans/central-conteudos-busca-semantica-ui-design.html +Appetite restante: herdado (~1–2 dias eng) — modo por tema sobre o catálogo do S27, **sem schema novo**: mecanismo lexical do C192 generalizado (expansão + sanitização + OR), guarda de uso anônimo nova (limite + gate de bot/prefetch + cache por termo), proveniência por trecho real e os estados do design. Cortados: embeddings/pgvector, rerank, captcha, limiter/cache distribuídos, analytics e índice novo. +Modo: autônomo (`--auto`) — impl plan nasce aprovado pelo agente. + +## Leitura da intenção + +- **Outcome:** o eleitor sem login digita um tema na Central (`/conteudos`) e escolhe "Por tema" no campo de busca; peças publicadas que tratam do assunto aparecem mesmo sem as palavras exatas, cada resultado por tema marcado ("Tema") com um indício curto e real de por que apareceu (trecho da descrição/transcrição), sem score. "Termo exato" continua o padrão; filtros seguem valendo nos dois modos; vazio honesto com saída; mecanismo indisponível degrada para a busca exata com aviso discreto — nunca erro na cara nem resultado fantasma. +- **O que NÃO negociar:** + - **Corpus fechado:** só peças publicadas da Central (`where status=publicado` + predicado `contentPieceIsPublic`, `src/lib/contentPiece.ts:211`); nada do acervo interno de falas, nada de PII, nada de fala de terceiro fora de peça publicada. + - **Proveniência honesta:** selo "Tema" vs "Termo exato" + trecho real (descrição/transcrição); nunca score numérico, nunca "parecido" enganoso, nunca resultado inventado. + - **Guardrail de custo/abuso (obrigatório):** a rota é pública e a expansão é LLM — precisa de limite de uso e as chamadas não podem ser disparadas por bot/crawler; sob abuso, o modo degrada (nunca 429/erro na cara). + - **Sem regressão do S27:** "Termo exato" é o default e o comportamento de hoje (URL, filtros, canonical `/conteudos`, kill switch) não muda; sem importar o contrato interno de staff. + - **Nada de infra nova:** sem embeddings/pgvector/backfill, sem curadoria de sinônimos, sem busca global/voz. +- **O que reavaliar (hipóteses da intenção):** + - "Reuso do C192 (`expandSpeechSearchTheme`, `speechListFilters`, `speechListUrl`)": a **sanitização** (`src/lib/speechThemeTerms.ts`) e o **contrato de URL** (mode só com `q`) são genéricos e viram reuso direto; o **prompt** fala "acervo de discursos"/"assessoria" — persona errada para a rota pública, então o dono é generalizado por corpus (D6), não importado cru. O `where` do C192 (`speechListFilters.ts:52-96`) **não** serve: o catálogo público filtra em memória (`contentPieceCatalog.ts:201-218`) e é lá que o OR entra. + - "`rateLimit.ts` serve de limitador": **não** — é chaveado por `userId` e só roda atrás de auth (`src/utilities/ai/rateLimit.ts:33`); não existe limitador anônimo/IP no repo (zero `x-forwarded-for`/`clientIp` em `src/`). A guarda anônima é módulo novo (D1/D2). + - "expansão no RSC como no C192": mantida (D3), mas com o gate de prefetch/bot **e** `prefetch={false}` nos links de filtro no modo tema — um payload RSC prefetchado degradado ficaria no router cache do cliente e a navegação seguinte mostraria o aviso em vez do tema. + - "trecho do `excerpt` do S27 basta": **não** — o `excerpt` é o 1º parágrafo da transcrição (`contentPieceCatalog.ts:321-337`) e o termo normalmente casa no meio dela; a evidência é uma janela calculada no servidor sobre `transcript`/`description` (D5), sem expor o transcript inteiro no VM. + +## Abordagem recomendada + +```mermaid +flowchart LR + Q["/conteudos?q=…&mode=tema\n(form GET · URL é o estado)"] --> GATE{"request elegível?\n(navegação de browser, não prefetch/bot)"} + GATE -- não --> DEG["degrada: busca exata\n+ aviso discreto"] + GATE -- sim --> CACHE{"cache in-memory\nnormalizeForSearch(q)"} + CACHE -- hit --> TERMS["termos (sem consumir limite)"] + CACHE -- miss --> LIM["limite anônimo por IP\n(8/15min)"] + LIM -- estourou --> DEG + LIM -- ok --> EXP["expandSearchTheme(corpus contentPiece)\nDeepSeek · timeout 4s · catch→null"] + EXP -- null --> DEG + EXP -- terms --> TERMS + TERMS --> FILTER["filterContentPieceCatalogItems\nOR lexical (q+termos) × facetas AND"] + DEG --> FILTER + READS["contentPieceReads\n(unstable_cache · tag contentPieces)"] --> FILTER + FILTER --> PROV["proveniência por peça (servidor)\ntranscript > description > excerpt\ndestaque da frase (speechHighlight)"] + PROV --> UI["cards: selo Tema/Termo exato\n+ 'Por que apareceu'\n+ vazio honesto"] +``` + +**Opções consideradas (abordagem geral):** A) expansão lexical do C192 generalizada + OR em memória no contrato público do S27 + guarda anônima nova (limite por IP + gate de navegação + cache por termo) + proveniência por janela no servidor + port classe-a-classe do design; B) embeddings query-time (DeepInfra) + cosine sobre artefato; C) pgvector + backfill. +**Recomendação:** **A** — o mecanismo já está em produção no C192 e resolve "sem as palavras exatas" sem infra nova; o corpus público reusa a leitura cacheada do S27 (tag `contentPieces`) e o filtro puro, então o custo novo é só a guarda e a proveniência; nada é indexado/backfillado e o kill switch continua valendo pela tag. +**Rejeitadas:** **B/C** porque são o rabbit hole nomeado na intenção (pipeline de embedding, storage vetorial, sync/backfill) e não mudam o outcome; **D)** modo automático no mesmo campo (sem escolha) porque a intenção fixa o modo visível com exato como padrão. + +### D1 — Guardrail de uso anônimo (limite + bot/prefetch) + +**Opções:** A) limitador in-memory por IP + gate de elegibilidade de navegação, módulo novo `src/utilities/ai/themeSearchGuard.ts`; B) cookie de sessão assinado + Map in-memory; C) só cache da expansão por `q`. +**Recomendação:** **A** — o deploy é Next standalone auto-hospedado em container único (o `Map` por instância funciona e reseta no deploy, exatamente o contrato documentado do `rateLimit.ts:1-14`); chave do cliente por ordem `cf-connecting-ip` → `x-forwarded-for` (1º hop) → `x-real-ip` → `'unknown'` (bucket compartilhado, fail-closed — atrás do Cloudflare tunnel o `cf-connecting-ip` é setado na borda e não é forjável pelo cliente; precedente de `headers()` em RSC é `perfil/page.tsx:33` e em action é `invite.ts:24`, que já lê `x-forwarded-host`/`x-forwarded-proto`); janela de **8 expansões/15min** por chave (constantes unit-testadas). Elegibilidade: a expansão só roda em pedido iniciado por browser — a Fetch Metadata que **chega ao server** decide (`sec-fetch-mode: navigate` do documento, inclusive o form GET sem JS, ou `cors` do fetch RSC do router, que é o mesmo caminho da navegação client-side e **precisa** expandir); `no-cors` é recusado (um ``/`` de terceiro não dispara expansão) e scraper sem os headers Sec-Fetch fica no literal. Estouro/inelegível → **degrada com o aviso**, nunca 429. **Registrado na execução (achado do app renderizado):** o Next 15 consome `RSC` e `Next-Router-Prefetch` antes do userland — `headers()` nunca os vê (verificado com rota de eco e com o app rodando), então o gate por `rsc`/prefetch não existe; a primeira linha contra o prefetch do router é a UI (`prefetch={false}` em todo link que preserva `mode=tema`), porque o prefetch do Next é indistinguível de uma navegação no server. Também registrado: um carregamento degradado consome **duas** unidades da cota (documento + fetch RSC, já que `null` não é cacheado) — aceito: o usuário nesse estado já vê o aviso e o retry continua oferecido. +**Rejeitadas:** **B** porque cookie não impede troca trivial de sessão e adiciona superfície de assinatura/cripto para o mesmo efeito do IP; **C** porque cache sozinho não limita termos únicos — o vetor de custo real é `q` novo a cada chamada. Nota: `rateLimit.ts` (userId) fica **intocado** — 2 call sites, sem terceiro; o helper único do boilerplate DeepSeek segue deferido (D6). + +### D2 — Cache da expansão por `q` + +**Opções:** A) cache in-memory com TTL, chave `normalizeForSearch(q)`, TTL 30min, cap 200 entradas, limpeza no acesso; B) `unstable_cache`; C) sem cache (v1 do C192, D6 de `acervo-busca-semantica-impl.md`). +**Recomendação:** **A** — um termo popular paga uma chamada e serve todos (inclusive hits de crawler que passarem pelo gate); não persiste em disco, reseta no deploy como o limite, e o **hit não consome a cota** (o custo já foi pago — o limite existe para conter chamadas ao provedor). `{terms: []}` é cacheado (resposta legítima, evita re-chamar um termo que nada acrescenta); `null` **nunca** é cacheado (erro transitório/chave ausente). Chave só com texto público normalizado — sem PII, sem id de usuário. +**Rejeitadas:** **B** porque a função cacheada não pode ler `headers()` (o gate e o limite ficam fora, e o valor persistido em disco sobreviveria a deploy sem invalidação por prompt/modelo — e `revalidateTag` dentro de cached fn lança no Next 15.4, `youtubeFeed.ts:253-260`); **C** porque na rota pública o custo por termo único fica aberto dentro da cota (o interno podia esperar). **Gatilho:** 2ª instância/container → cache e limite compartilhados (Redis) viram item; prompt/modelo mudarem → invalidar por deploy (o cache é in-process, some sozinho). + +### D3 — Onde a expansão roda + +**Opções:** A) no RSC da página (padrão C192 `speechPageData.ts:176,184-191`) com o timeout do dono; B) route handler GET próprio + fetch do client; C) Suspense/streaming com fallback. +**Recomendação:** **A** — a página já é dinâmica (`searchParams`), o `loading.tsx` do S27 cobre a espera com o skeleton, a degradação é renderizada no mesmo HTML (funciona sem JS) e não nasce rota/URL nova. O timeout continua o do dono (**4s**, `expandSpeechSearchTheme.ts:14,67`): um segundo timeout para o mesmo mecanismo seria divergência gratuita. A expansão só roda com `mode=tema` + `q` e depois da leitura cacheada; a lista literal nunca espera por ela quando o modo não foi pedido. +**Rejeitadas:** **B** porque exige JS, segundo round-trip e um endpoint público novo (superfície e custo) e duplicaria o caminho sem JS; **C** porque o swap progressivo precisa de boundary client e de um estado que o design não cobre. **Gatilho:** se a latência percebida doer (mobile/conexão ruim), reavaliar com cena nova no designer — não inventar streaming sem design. + +### D4 — Contrato de URL do modo + +**Opções:** A) `mode=tema` (default `exato` omitido), só com `q`, espelhando `speechListUrl.ts:35,109-110,163`; B) `busca=tema`; C) rota separada `/conteudos/tema`. +**Recomendação:** **A** — mesmo vocabulário já em produção no C192 (param `mode` em inglês, valor `tema` pt-BR), canonical `/conteudos` intocado, facetas preservadas nos hrefs (os chips já os preservam, `contentPieceCatalog.ts:97-111,170-198`). Sem `q`, `mode` não parseia nem serializa (não há o que expandir; espelho de `speechOmnibox.ts:263-272`), e remover o chip "Busca" limpa `q` **e** `mode`. O controle do modo são **radios** `name="mode"` dentro do form GET existente (`ContentPieceFilters.tsx:49-79`): com JS um ilhote client auto-submete no `change` (um toque, como o design); sem JS, o Enter no campo submete com o radio marcado (o form tem um único campo text-like — hidden e radio não bloqueiam implicit submission). Sem hidden input e sem parâmetro duplicado. **Registrado na execução:** o submit do form carrega o valor do radio ativo — a busca exata produz `?q=…&mode=exato` no URL, que o parse descarta (só `tema` parseia) e o href canônico nunca gera; é o custo do form sem JS, não um contrato novo. +**Rejeitadas:** **B** porque inventa um segundo vocabulário para o mesmo conceito que já roda; **C** porque parte o contrato público e mata o funcionamento sem JS (e o canonical). Nota: `mode=tema` (valor) não colide com a faceta `tema` (param de assunto) — são params distintos. + +### D5 — Proveniência "por que apareceu" + +**Opções:** A) evidência real por peça: primeiro termo expandido que casa no `searchText` (mesmo predicado do filtro) + janela do campo que contém o termo, ordem `transcript` > `description` > `excerpt`/título, reusando `buildHighlightedExcerpt` (`speechHighlight.ts:197`) com `{ phrase: true }`; B) "porquê" gerado pelo LLM; C) só o selo, sem trecho. +**Recomendação:** **A** — o trecho é a janela onde o termo realmente aparece (destaque da frase, como no C192 `speechViewModels.ts:264-279`), nunca o 1º parágrafo por acaso; quando o casamento veio só de tema/cidade/instituição no haystack (`contentPiece.ts:323-336`), a peça mantém o selo "Tema" e a evidência cai para `description`/`excerpt` **sem destaque**, texto real da peça, sem alegar casamento. Termos iguais ao `q` são descartados antes do selo (`contentPieceThemeTerms(query, terms)`, puro, no dono do contrato) — não reivindicar "Tema" quando o casamento é o próprio termo. Sem score (a intenção proíbe). A janela é calculada **no servidor**: o transcript completo nunca entra no VM nem no payload do client — o `ContentPiecePublicItem` ganha só `themeMatch: { term, evidence: parts + flags }` (serializável). **Registrado na execução:** o cabeçalho da lista no modo tema vira `Resultados para “q”` com a nota "Por tema ativo" quando a expansão contribuiu (artefato cena 05), no lugar do `h2` do S27. +**Rejeitadas:** **B** por alucinação/instabilidade e min/max imprevisível; **C** porque o aceite pede o indício por resultado. **Gatilho:** se a evidência sem trecho aparecer demais (muitos casamentos por tema/cidade), avaliar copy própria ("Tema da peça") — cena nova no designer. + +### D6 — Reuso do mecanismo C192 (o que generalizar e o que não copiar) + +**Opções:** A) importar `expandSpeechSearchTheme` como está; B) generalizar o dono para `expandSearchTheme(theme, corpus)` com `'speech' | 'contentPiece'`, mantendo `expandSpeechSearchTheme` como alias do C192; C) módulo irmão novo copiando o padrão. +**Recomendação:** **B** — o mecanismo é um só (gate de `DEEPSEEK_API_KEY`: `expandSpeechSearchTheme.ts:52`, `deepSeek('deepseek-flash')`: `:59`, `AbortSignal.timeout(4000)`: `:67`, `catch→null`: `:71-73`, `{terms:[]}≠null`) e o que muda é o **corpus do prompt**: o texto atual fala "acervo de discursos"/"assessoria" — persona errada para o eleitor. O alias mantém C192 e seus testes byte-idênticos; `src/lib/speechThemeTerms.ts:10-30` é genérico (sanitização de saída de modelo) e fica intocado. Fronteira explícita: `src/utilities/ai` é **risk prefix** do e2e (`e2e-affected-manifest.mjs:64,334-335`) → tocar acorda `campaignAiTranscribe` + `campaignSpeechAcervo` (e o curado); registrado nos pins. +**Rejeitadas:** **A** porque o prompt de discursos/assessoria não descreve o corpus público (peças: vídeo/áudio/foto/card/texto) — o dono deve conhecer os dois corpus; **C** porque é twin do mesmo mecanismo (o dono é o módulo existente). **Deferido (re-registrado do C192):** helper único do boilerplate DeepSeek — o gatilho "tocar qualquer um deles" dispara, mas a extração tocaria 4 módulos de IA no mesmo PR; mesmo gatilho mantido. + +### D7 — Modo tema com filtros, vazio e degradação + +**Opções:** A) OR textual (`q` + termos) × facetas AND com três estados explícitos (aplicado / vazio legítimo / indisponível); B) modo tema ignora filtros; C) degradação silenciosa (cai para exato sem aviso). +**Recomendação:** **A** — filtros seguem valendo (intenção) e a busca em memória do S27 já combina AND (`contentPieceCatalog.ts:201-218`): o `q` passa a casar qualquer um de `q` + termos normalizados. No modo tema, resultado com termo expandido ganha **"Tema" + "Por que apareceu"**; resultado que casou só o `q` ganha **"Termo exato"** (o aceite pede o porquê por resultado; o C192 só mostra o selo no caminho de tema). `{terms: []}` (modelo rodou, nada a acrescentar) **não** é indisponibilidade: lista literal, sem aviso (distinção do C192 D2). `null` (sem chave/timeout/erro/limite/pedido inelegível) → **aviso discreto** do design (`central-conteudos-busca-semantica-ui-design.html:260-278`, "Busca por tema indisponível agora. / A busca exata continua funcionando.") + resultados exatos + "Tentar por tema novamente" (link para a mesma URL com `prefetch={false}`, sem client extra) — nunca erro na cara. Vazio do modo tema: copy do design ("Nenhuma peça combina bem com esse tema" + "Não mostramos um resultado fraco só para preencher a tela.", `:246-258`) com três saídas distintas: **Reformular busca** (mesma URL sem `q`, mantém filtros), **Usar termo exato** (mesma URL sem `mode`, mantém `q`+filtros) e **Limpar filtros** (`/conteudos`). +**Rejeitadas:** **B** porque contraria o aceite ("filtros seguem valendo"); **C** porque esconde a degradação — "nunca resultado fantasma" inclui não fingir que o tema rodou. + +### D8 — Verificação do modo tema por camada + +**Opções:** A) unit do contrato/guarda/cache + int do loader com resolver injetado + e2e só do caminho degradado (a chave é zerada no app e2e, `playwright.config.ts:282-287`); B) stub de rede do DeepSeek no e2e (nova env/baseURL no provider); C) mock de `fetch` global no e2e. +**Recomendação:** **A** — o sucesso do tema é provado no int com `expandTheme` injetado (mesmo padrão do C192, `speechAcervo.int.spec.ts:351-415`); o e2e cobre o contrato HTTP real que importa na rota pública: degradação com aviso + exato inalterado + segmented submetendo (o app e2e nunca fala com o provedor, como `campaignSpeechAcervo.e2e.spec.ts:340-374`). Nenhuma env nova. +**Rejeitadas:** **B** porque cria superfície de produção (env/baseURL no caminho do provider) só por conveniência de teste; **C** porque mockar `fetch` global esconde o caminho real e não é padrão do repo. + +### Componentes / mudanças + +- **`src/lib/contentPieceCatalog.ts`** (editar, dono do contrato público): `ContentPieceCatalogMode = 'tema'`; `mode` em `ContentPieceCatalogParams` (`:56-63`) e no parse (`:76-94`, só `tema` e só com `q`); `buildContentPieceCatalogHref` (`:97-111`) serializa `mode` depois de `q` e só com `q`; `contentPieceCatalogActiveFilters` (`:170-198`) faz o chip de busca remover `q`+`mode`; `filterContentPieceCatalogItems(items, params, themeTerms = [])` (`:201-218`) casa `q` **ou** qualquer termo normalizado no `searchText` (sem termos, byte-idêntico ao S27); `contentPieceThemeMatch(record, terms)` puro (ordem `transcript` > `description` > `excerpt`, `buildHighlightedExcerpt(..., { phrase: true })`) e `toContentPiecePublicItem(record, { themeTerms })` (`:362-415`) anexa `themeMatch: { term, evidence } | null` ao VM. Reusa `normalizeForSearch` (`speechSearch.ts:9-15`), `slugify` e `speechHighlight`. +- **`src/utilities/content/contentPieceReads.ts`** (editar): extrai `getPublishedContentPieceRecords()` (mesmo `unstable_cache` e tag `contentPieces`, docs crus com `transcript` — hoje o select já o traz, `:22-39`); `getPublishedContentPieceItems()`, `getPublishedContentPieceBySlug` e `hasPublishedContentPieces` mantêm a API e o gate (`where` publicado + `contentPieceIsPublic`; Local API sem `user` = bypass default documentado, `:9-20`). +- **`src/utilities/ai/expandSpeechSearchTheme.ts`** (editar): `ThemeSearchCorpus = 'speech' | 'contentPiece'`; `expandSearchTheme(theme, corpus)` escolhe o prompt pelo corpus (regras de termos idênticas); `expandSpeechSearchTheme` vira alias (`corpus: 'speech'`) para C192 e `expandContentPieceSearchTheme` (`corpus: 'contentPiece'`) é o default da rota pública; contrato `null`/`{terms:[]}` e `normalizeSpeechThemeTerms` intocados. +- **`src/utilities/ai/themeSearchGuard.ts`** (novo, `server-only`): `isThemeSearchRequestEligible(headers)` (bloqueia `next-router-prefetch`; exige `rsc` ou `sec-fetch-mode: navigate`), `themeSearchClientKey(headers)` (ordem do D1), `createThemeSearchGuard({ max, windowMs, ttlMs, maxEntries, now })` (limiter + cache do D2, testável com relógio fake) e o singleton `resolveGuardedThemeExpansion({ q, headers, expandTheme })` → `{ terms } | null` (null = degradar). +- **`src/utilities/content/contentPieceThemeSearch.ts`** (novo, `server-only`): `loadContentPieceCatalogSearch({ rawSearchParams, requestHeaders, expandTheme = expandContentPieceSearchTheme })` → `{ publishedCount, params, filteredItems, facets, activeFilters, themeUnavailable, themeApplied }`. Orquestra: records cacheados → se zero publicado, retorna sem expandir; parse; se `mode=tema` com `q`, chama a guarda (com o `q`); mapeia records → VMs com os termos (anexa `themeMatch`); filtra; deriva facetas do conjunto publicado e os filtros ativos. +- **`src/components/conteudos/ContentPieceSearchMode.tsx`** (novo, client): radios `name="mode"` (`exato`/`tema`) no visual `.seg` do design (`central-conteudos-busca-semantica-ui-design.html:157-169,207-219`), auto-submit no `change` (`requestSubmit`), "Por tema" com ⚠ e `disabled` quando indisponível (design `:270-273`). +- **`src/components/conteudos/ContentPieceFilters.tsx`** (editar, `:49-79,81-122`): bloco do campo no grid do design (`sm:grid-cols-[1fr_300px]`, chips `col-span-2`), o segmented no lugar do botão de submit do S27 (o Enter e o segmented submetem; o CTA de pedido de voto segue no hero do S27, intocado), `prefetch={false}` nos links de filtro quando `mode=tema`, hidden facets inalterados. +- **`src/components/conteudos/ContentPieceCard.tsx`** (editar, `:36-110`): selo "Tema" (com "Termo exato" quando o `q` também casa) e bloco "Por que apareceu" com aspas + partes destacadas — render local no domínio público (a fronteira site→`components/campaign` é vedada; sem importar `SpeechHighlightParts`). +- **`src/components/conteudos/ContentPieceStates.tsx`** (editar, `:33-70`): variante de vazio do modo tema (copy e três saídas do D7) e `ContentPieceThemeFallbackNotice` (aviso do design + "Tentar por tema novamente"). +- **`src/app/(frontend)/conteudos/(catalog)/page.tsx`** (editar, `:102-169`): usa o loader; `publishedCount === 0` mantém o estado global; renderiza aviso/estado de tema; `generateMetadata` (`:47-100`) e canonical/noindex intocados. +- **Migration:** **sem migration** — nenhum campo/collection/global novo; a expansão é query-time e o `searchText` do C211 (`ContentPiece.ts:187-194`) já existe. +- **Access / Consent:** nenhum access novo; a leitura anônima segue Local API sem `user` + `where` publicado + `contentPieceIsPublic`; nenhum `Consent`/`Contact`; o único dado que sai para o provedor é o `q` digitado (texto público, sem PII) e o transcript completo nunca sai do servidor. +- **UI:** Impeccable B (a intenção classifica B) — port classe-a-classe do artefato sobre os shells do S27; gaps em trigger (a), abaixo. +- **Changelog:** `docs/changelog/2026-09-23-s28.md` (uma entrada curta; nunca editar o agregado/HISTORY). + +### Dados → forma (se aplicável) + +N/A — a superfície não apresenta KPI, agregado, contador ou série (a intenção declara "Dados: N/A"). O sinal do resultado é explicação qualitativa (selo + trecho real), nunca score de confiança; nenhuma decisão numérica é desbloqueada. + +## Design tier + +Artefato aprovado no gate: `docs/plans/central-conteudos-busca-semantica-ui-design.html` (sem pasta de assets). Superfícies cobertas: + +- **Cena 01 — desktop 1280 (`:142-190`):** bloco do campo com o segmented "Buscar por: Termo exato | ✦ Por tema" ao lado do input e os chips abaixo; card de resultado por tema com selo "✦ Tema", título, metadados e o bloco "Por que apareceu" com o trecho entre aspas; "Abrir peça →". +- **Cena 02 — mobile 390 (`:192-238`):** mesma peça de campo (uma decisão por vez) e o mesmo card com o bloco de proveniência. +- **Cenas 03–04 (`:240-280`):** vazio honesto ("Nenhuma peça combina bem com esse tema" + "Não mostramos um resultado fraco só para preencher a tela." + Reformular busca / Usar termo exato / Limpar filtros) e degradação (aviso âmbar "Busca por tema indisponível agora." + segmented com ⚠ em "Por tema"). + +**Não cobertas → trigger (a) (dispatch do designer antes do markup final):** + +1. **Resultado que casou só o `q` no modo tema** — o artefato desenha só o card "✦ Tema"; o port mostra "Termo exato" sem bloco de proveniência (decisão D7). +2. **Evidência sem trecho correspondente** — quando o casamento veio de tema/cidade/instituição, o bloco mostra `description`/`excerpt` sem destaque (decisão D5); o designer valida a leitura. +3. **"Expansão rodou sem termos" (`{terms:[]}`)** — estado decidido como lista literal sem aviso (D7); sem cena no artefato. +4. **Bloco de campo no desktop com o segmented no lugar do botão do S27** e a remoção do botão de submit (Enter/segmented submetem; CTA preservado no hero) — o artefato mostra `[1fr_300px]` sem botão; o designer valida o encaixe no form do S27 (`sm:grid-cols-[1fr_auto]`). +5. **Loading do modo tema** — o skeleton do S27 (`(catalog)/loading.tsx`) cobre a espera maior; sem cena própria. +6. **Chips "Mais filtros" e o h3 "Encontre uma peça para pedir voto" do artefato** são lidos como mock do bloco (o artefato não reproduz o hero/fileira do S27): o port mantém a fileira de 5 facetas (`ContentPieceFilters.tsx:81-122`) e o hero do S27 (`ContentPieceHero.tsx:8-36`) — o S28 não redesenha a fileira nem o hero; o designer valida a leitura. + +## Fases verificáveis + +1. **Tracer / server + guarda — quota ~50%:** contrato `mode` + filtro com termos + proveniência pura em `src/lib/contentPieceCatalog.ts` + units; `expandSearchTheme` generalizado + unit; `themeSearchGuard.ts` + unit; `getPublishedContentPieceRecords` + `contentPieceThemeSearch.ts` + int com resolver injetado verdes antes de seguir. +2. **UI (port classe-a-classe) — quota ~35%:** `ContentPieceSearchMode`, bloco do campo em `ContentPieceFilters`, selo/proveniência em `ContentPieceCard`, estados do tema em `ContentPieceStates`, página; e2e `frontendConteudos` estendido + pins do manifest. +3. **Gates — quota ~15%:** `pnpm gate:fast` (lint/format/typecheck/knip/cycles/unit); `pnpm test:int`; e2e curado (`frontendConteudos` já está no curado); `pnpm push` → PR `--base main`. + +## Testes previstos + +- **`tests/unit/contentPieceCatalog.unit.spec.ts`** (editar; bloco de filtro em `:146-179`): parse de `mode` (`tema` só com `q`; `exato`/desconhecido/vazio → null); href canônico (`mode` depois de `q`, só com `q`; facetas preservam; remover `q` limpa `mode`); filtro OR `q`+termos (sem termos, resultado idêntico ao S27); `contentPieceThemeMatch` (janela do `transcript` > `description` > fallback; destaque da frase; termo igual ao `q` descartado); VM com/sem `themeMatch`. +- **`tests/unit/expandSpeechSearchTheme.unit.spec.ts`** (editar, `:25-68`): `expandSearchTheme(..., 'contentPiece')` usa o prompt do corpus público; alias `'speech'` inalterado (sem chave → `null` sem chamar o provedor; erro → `null`; `{terms:[]}` legítimo; sanitização dos termos). +- **`tests/unit/themeSearchGuard.unit.spec.ts`** (novo): elegibilidade (prefetch → inelegível; sem `rsc`/`sec-fetch-mode` → inelegível; navegação/RSC → elegível); chave do cliente (ordem `cf-connecting-ip` > `x-forwarded-for` > `x-real-ip` > `unknown`); limiter com relógio fake (janela, estouro, reset); cache (hit não chama o resolver nem consome a cota; TTL; cap; `[]` cacheado; `null` não cacheado). +- **`tests/int/contentPiece.int.spec.ts`** (editar; já mocka `next/cache`, `:9-13`): loader com `expandTheme` injetado — peça que **não** contém o `q` mas contém o termo expandido aparece com `themeMatch` e evidência real; resolver `null` → `themeUnavailable` + lista literal; `{terms:[]}` → literal sem aviso; pedido inelegível (headers sem `rsc`/`sec-fetch`) → não chama o resolver; sem `mode` → idêntico ao S27; rascunho/despublicado seguem fora. +- **`tests/e2e/frontendConteudos.e2e.spec.ts`** (editar, `:226-266`): `?q=&mode=tema` com a chave zerada → 200, segmented "Termo exato | Por tema", aviso "Busca por tema indisponível agora.", resultados exatos do marker e nenhum "Por que apareceu"; `?q=` inalterado; `?mode=tema` sem `q` → sem aviso e sem `mode` aplicado; clicar "Por tema" submete e a URL carrega `mode=tema`; Enter no campo busca exato. +- **`tests/unit/e2eAffectedManifest.unit.spec.ts`** — **intocado**: `frontendConteudos` já está no `E2E_CURATED_SPECS` (`:74-77`, S27) e o pin do risk prefix (`:85-93`) segue coberto. + +## Pins a atualizar (valores exatos) + +- **`scripts/lib/e2e-affected-manifest.mjs:121-134`** (entrada S27): acrescentar `src/utilities/content/contentPieceThemeSearch.ts` aos prefixes → specs `['frontendConteudos']` (o `src/utilities/content` genérico mapeia só os specs de campanha, `:402`). +- **`scripts/lib/e2e-affected-manifest.mjs:331-336`** (entrada do risk prefix `src/utilities/ai`, `:334`): acrescentar `'frontendConteudos'` aos specs — a expansão/guarda novas moram ali e um diff de IA precisa acordar a rota pública. +- **`tests/unit/e2eAffectedManifest.unit.spec.ts:54-79`** — **intocado**: `frontendConteudos` já no curado; o pin do risk prefix (`:85-93`) segue coberto sem edição. +- **`tests/unit/codebaseConventions.unit.spec.ts:239-257`** — **intocado**: nenhuma rota POST nova (D3 = RSC). +- **`tests/unit/codebaseConventions.unit.spec.ts:417-559`** — **intocado**: os módulos novos ficam em `src/utilities/ai/` e `src/utilities/content/` (subpastas), não no top-level pinado. +- **`.env.example` / envs** — **intocado**: sem chave nova (limite/cache são constantes de código). +- **`tests/unit/contentPieceCatalog.unit.spec.ts:146-179`** e **`tests/int/contentPiece.int.spec.ts:9-13`** — estendidos conforme os testes previstos. + +## Rabbit holes / Não escopo (engenharia) + +- **Embeddings/pgvector/backfill, rerank LLM dos candidatos, curadoria de sinônimos/ontologia** — rejeitados pela intenção; a seleção fica no OR lexical. Gatilho do rerank: precisão/recall insuficientes. +- **"Sempre achar algo"/similaridade fraca** — vazio honesto; nunca "parecido" para preencher a tela. +- **Captcha/Turnstile/honeypot** — fora do v1 (gate de navegação + limite + cache cobrem); gatilho: abuso observado. +- **Redis/limiter e cache distribuídos** — instância única; gatilho: 2ª instância/container (D2). +- **Persistir a expansão no DB ou indexar termos** — query-time; nada de coluna/índice novo. Gatilho do índice em memória: catálogo na casa dos milhares. +- **Streaming/Suspense do resultado por tema** — fora (D3); gatilho: latência percebida doer + cena nova. +- **Helper único do boilerplate DeepSeek** — re-deferido do C192 (D6); mesmo gatilho ("tocar qualquer um deles"). +- **SEO das URLs de tema (sitemap/JSON-LD/noindex próprio)** — fora: o canonical `/conteudos` do S27 já cobre; sem superfície SEO nova. +- **Analytics de busca (termos que falham, cliques)** — C213/fora. +- **Busca por voz, busca global do site, acervo interno como corpus** — vedados pela intenção. +- **Novo `Consent`/`Contact`/collection, score numérico, "porquê" gerado** — proibidos. + +**Débitos adiados (triagem do simplify, sem Issue):** + +- **Guarda injetável no loader (A):** hoje `loadContentPieceCatalogSearch` fixa o singleton `resolveGuardedThemeExpansion` e só o `expandTheme` é injetável; a cota (8/15min no bucket `unknown`) e o cache são do processo e podem apertar testes int futuros. Gatilho: 2º consumidor do loader ou novo int de tema que aproxime da cota → injetar a guarda inteira (ou reset explícito). +- **Namespace por corpus no cache da guarda (B):** a chave do cache é só `normalizeForSearch(q)`; se um 2º corpus adotar `createThemeSearchGuard`/`resolveGuardedThemeExpansion`, termos de um corpus seriam servidos ao outro. Gatilho: 2º corpus (C192/falas) adotar a guarda → chave `corpus:q`. +- **Primitivo de janela compartilhado com o `rateLimit.ts` (D):** `cleanWindows`/`withinLimit`/`cleanCache` repetem o limiter de `src/utilities/ai/rateLimit.ts` (~20 linhas) no mesmo diretório; o D1 manteve o dono do userId intocado de propósito. Gatilho: 3º call site de limiter ou tocar `rateLimit.ts` → extrair as primitivas. + +**Descartados na triagem:** remapear `toPublicItems` uma única vez no modo tema (C — o double-map é o que mantém o teste de Central vazia legível e o catálogo é pequeno) e renderizar `themeMatch.term` no card (E — o design mostra só o selo; renderizar o termo é cena nova do designer, não débito). + +## Riscos e mitigação + +- **Custo aberto na rota pública:** limite por IP (8/15min) + cache por termo + gate de prefetch/bot; int prova que pedido inelegível não chama o provedor; unit prova o estouro e o hit de cache; gatilho de Redis registrado. +- **Crawler que renderiza e constrói a URL de tema:** o gate bloqueia clientes sem sinais de navegação e o limite por IP limita o dano; residual aceito no v1 (captcha fora, gatilho de abuso). +- **Prefetch do Next envenenando o router cache com payload degradado:** `prefetch={false}` em todo link que preserva `mode=tema` (chips, dropdown, retry) — o server não distingue prefetch de navegação (o Next consome `Next-Router-Prefetch` antes do userland), então a UI é a primeira linha; o gate recusa `no-cors` (prefetch de terceiro) e exige `navigate`/`cors`. +- **Latência RSC (até 4s):** timeout do dono; skeleton do S27; degradação honesta; a expansão só roda no modo pedido com `q`. +- **Proveniência enganosa:** predicado único (mesmo `includes` normalizado do filtro) e evidência só de campo que contém o termo; sem destaque quando não contém; termo igual ao `q` descartado; sem score. +- **Transcript vazando:** a janela é calculada no servidor; o VM carrega só `parts` + flags, nunca o transcript. +- **Cache/limite resetando no deploy:** aceito e documentado (mesmo contrato do `rateLimit.ts`); não há estado durável a corromper. +- **IP ausente/rotativo:** bucket `'unknown'` compartilhado (fail-closed) e `cf-connecting-ip` primeiro (borda, não forjável pelo cliente atrás do tunnel). +- **`{terms:[]}` lido como falha:** decidido como lista literal sem aviso (D7); se o dono quiser sinal, vira cena nova. +- **PR toca `src/utilities/ai` (risk prefix) + `src/utilities/content` + `src/lib/contentPieceCatalog`:** o e2e selecionado roda o curado (`frontendConteudos`) mais `campaignAiTranscribe`/`campaignSpeechAcervo`; `gate:fast` + int local antes do push. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: tema sem palavras exatas devolve peças publicadas; cada resultado deixa claro "Tema" vs "Termo exato" com indício curto e real (sem score); "Termo exato" segue padrão e sem regressão; vazio honesto com as três saídas; degradação com aviso discreto e busca exata funcionando; filtros valem nos dois modos. +- [ ] Guardrails: só peças publicadas (`where` + `contentPieceIsPublic`); nada do acervo interno/PII; limite de uso obrigatório + gate de bot/crawler/prefetch; sob abuso, o modo degrada (nunca 429/erro na cara). +- [ ] Invariantes AGENTS/engineering-standards: Local API sem `user` com bypass documentado e gate por `where`/predicado; nenhuma escrita multi-collection; identificadores em inglês/copy pt-BR; sem migration/Consent/Contact; `openai/*` intocado. +- [ ] Testes de domínio previstos (unit/int) onde a rota pública muda; pins de manifest atualizados. +- [ ] `pnpm gate:fast` verde; `pnpm test:int` do spec verde; e2e `frontendConteudos` verde; `pnpm push` via GitHub. + +## Decisões de engenharia + +- **D1 — Guarda anônima:** limiter in-memory por IP (8/15min, `cf-connecting-ip`→`x-forwarded-for`→`x-real-ip`→`unknown`) + gate de elegibilidade (sem prefetch; exige `RSC`/`sec-fetch-mode: navigate`); estouro degrada. Rejeitadas: cookie de sessão (superfície de cripto sem ganho) e cache sozinho (não limita termos únicos). +- **D2 — Cache da expansão:** Map TTL 30min/cap 200 por `normalizeForSearch(q)`, hit não consome cota, `[]` cacheado, `null` não. Rejeitadas: `unstable_cache` (não lê headers; persistência/`revalidateTag`) e sem cache (custo aberto por termo único). +- **D3 — Onde roda:** RSC da página com o timeout de 4s do dono + skeleton do S27. Rejeitadas: route handler + client fetch (JS/round-trip/endpoint novo) e streaming (boundary/estado sem design). +- **D4 — URL:** `mode=tema` só com `q`, default omitido, canonical e facetas preservados; radios no form GET (JS auto-submit; sem JS, Enter). Rejeitadas: `busca=tema` (vocabulário paralelo) e rota separada (parte o contrato/sem JS). +- **D5 — Proveniência:** termo casado + janela `transcript` > `description` > `excerpt` com destaque de frase, calculada no servidor; fallback sem destaque; sem score. Rejeitadas: "porquê" gerado (alucinação) e só selo (aceite pede indício). +- **D6 — Reuso do C192:** generalizar o dono para `expandSearchTheme(theme, corpus)` com alias `speech` e corpus `contentPiece`; sanitização e contrato de falha intocados. Rejeitadas: importar cru (prompt de discursos/assessoria) e módulo irmão (twin). +- **D7 — Modo tema + filtros/estados:** OR `q`+termos × facetas AND; "Tema"/"Termo exato" por resultado; `{terms:[]}` literal sem aviso; `null` degrada com aviso + retry; vazio com três saídas. Rejeitadas: ignorar filtros (contraria o aceite) e degradação silenciosa (esconde o modo). +- **D8 — Verificação:** unit (contrato/guarda/cache) + int com resolver injetado + e2e da degradação e do exato. Rejeitadas: stub de rede no e2e (env de teste no caminho do provider) e mock de `fetch` (esconde o caminho real). +- **Sem migration, sem Consent, sem access novo** — registrado, não presumido. + +## Self-score + +**Self-score decision-quality: 5/5.** (1) Todas as decisões caras (guarda de uso, cache, onde a expansão roda, contrato de URL, proveniência, reuso/generalização do C192, estados do modo e verificação por camada) têm opções + recomendação + rejeitadas explícitas. (2) Cabe no appetite herdado: sem schema/migration, sem rota nova, sem infra; reusa o mecanismo do C192 (generalizado no dono), a leitura cacheada e o filtro puro do S27, `speechHighlight`, o padrão de injeção do `speechPageData` e os componentes do S27; o custo novo é a guarda e o port do bloco/estados do design. (3) Rabbit holes nomeados (embeddings/rerank, captcha, Redis, persistência, streaming, SEO, analytics, helper DeepSeek re-deferido). (4) Depth check: o dono do mecanismo é editado (não duplicado), o contrato público é estendido no próprio módulo, e só nasce módulo onde não há dono (guarda anônima e orquestração da rota pública). (5) Outcome preservado: corpus fechado, proveniência sem score, exato como padrão, vazio honesto, degradação com aviso e filtros nos dois modos estão mapeados; as interpretações (selo "Termo exato" no modo tema, evidência sem trecho, remoção do botão de submit) estão ancoradas no artefato/aceite e registradas como gaps com trigger (a). diff --git a/docs/plans/central-conteudos-busca-semantica-ui-design.html b/docs/plans/central-conteudos-busca-semantica-ui-design.html index 8c30017b..bd65e340 100644 --- a/docs/plans/central-conteudos-busca-semantica-ui-design.html +++ b/docs/plans/central-conteudos-busca-semantica-ui-design.html @@ -71,6 +71,37 @@ color: #184e92; box-shadow: 0 1px 4px rgb(0 0 0/12%); } + .seg-option { + display: flex; + min-height: 42px; + align-items: center; + justify-content: center; + border-radius: 7px; + padding: 0 10px; + color: #3f3937; + font-size: 13px; + font-weight: 800; + white-space: nowrap; + } + .seg-option.is-hover { + background: rgb(255 255 255/58%); + color: #184e92; + } + .seg-option.is-focus { + outline: 3px solid #e4102f; + outline-offset: 2px; + } + .seg-option.is-disabled { + cursor: not-allowed; + color: #8b6c1a; + opacity: 0.64; + } + .evidence-mark { + border-radius: 3px; + background: #fff09a; + padding: 0 2px; + font-weight: 800; + } .chip { display: inline-flex; min-height: 38px; @@ -137,6 +168,14 @@ Uma escolha clara de modo e uma explicação curta por peça. Nada de score, ruído ou resultado forçado.

+
+ Revisão aditiva · trigger (a) do work-issue +

+ As cenas 01–04 permanecem como registro aprovado. As cenas 05–09 abaixo fecham os + estados do port; quando houver diferença de detalhe, a cena nova é a referência canônica + para o estado nomeado. +

+
@@ -278,6 +317,302 @@
+ +
+
+ CENA 05 +

Desktop 1280 · resultado por tema × resultado só pelo termo exato

+
+
+
+
+

+ Mesmo modo · proveniências diferentes +

+

+ Resultados para “saúde do trabalhador” +

+
+ Por tema ativo +
+
+
+
NEEDS ASSET · POSTER REAL
+
+
+ ✦ Tema +

Reduzir a jornada também é cuidar

+

Vídeo · Trabalho · Salvador

+
+ Por que apareceu +

+ “…reduzir a jornada protege a + saúde de quem trabalha…” +

+
+
+ Abrir peça → +
+
+
+
NEEDS ASSET · POSTER REAL
+
+
+ Termo exato +

Saúde do trabalhador em debate

+

Card · Saúde · Salvador

+
+ Abrir peça → +
+
+
+
+

+ Com casamento temático: selo azul + bloco de evidência real. +

+

+ Só casou o q: selo neutro “Termo exato”; o espaço não é + preenchido por um bloco vazio ou inventado. Os cards mantêm a mesma altura da grade. +

+
+
+
+ +
+
+ CENA 06 +

Evidência temática sem trecho correspondente · texto real, sem falsa citação

+
+
+
+
+ ✦ Tema +

Mutirão leva atendimento ao Subúrbio

+

Vídeo · Saúde · Salvador

+
+
+ Por que apareceu + Descrição da peça +
+

+ Atendimento perto de casa, com equipes reunidas para ampliar o acesso da população + aos serviços públicos. +

+
+
+
+ +
+
+ +
+
+ CENA 07 +

Expansão concluída sem termos · lista literal, sem alarme

+
+
+
+ +
+ Buscar por: +
+ Termo exato✦ Por tema +
+
+
+ Tipo⌄Cidade⌄Região⌄Tema⌄Instituição⌄ +
+
+
+
+ Termo exato +

Saúde pública perto de quem precisa

+
+
+ Termo exato +

Defender a saúde é cuidar da Bahia

+
+
+ Termo exato +

Mais recursos para a saúde

+
+
+
+

+ NOTA DO DESIGN · não é microcopy da tela: “Por tema” continua ativo porque foi o + modo pedido, mas não aparece aviso, explicação operacional nem selo “Tema”. A lista é + exatamente a busca literal e cada peça encontrada recebe apenas “Termo exato”. O usuário + não precisa saber que a expansão devolveu zero termos. +

+
+ +
+
+ CENA 08 +

Bloco canônico de busca · desktop 1280 e mobile 390

+
+
+
+

Desktop

+
+ +
+ Buscar por: +
+ Termo exato✦ Por tema +
+
+
+ Tipo⌄Cidade⌄Região⌄Tema⌄Instituição⌄ +
+
+

+ Grade canônica: 1fr + 300px. Não há botão de submit: Enter no campo busca; + mudar o modo submete. Abaixo permanecem as cinco facetas do S27, na ordem existente. +

+
+
+

Mobile

+
+ + Buscar por: +
+ Termo exato✦ Por tema +
+
+ Tipo⌄Cidade⌄Região⌄Tema⌄Instituição⌄ +
+
+

+ Uma coluna; nenhuma faceta é substituída por “Mais filtros”. Sem botão de submit. +

+
+
+
+ +
+
+ CENA 09 +

Segmented · hover, foco, ativo e indisponível

+
+
+
+
+

HOVER · opção disponível

+
+ Termo exato✦ Por tema +
+
+
+

FOCO VISÍVEL · teclado

+
+ Termo exato✦ Por tema +
+
+
+

ATIVO

+
+ Termo exato✦ Por tema +
+
+
+

INDISPONÍVEL · degradação

+
+ Termo exato⚠ Por tema +
+
+
+
+
+ Interação e acessibilidade +

+ As duas opções são um único grupo de rádio. Tab entra no grupo; setas + esquerda/direita alternam a opção; foco usa contorno vermelho de 3px com offset de + 2px; trocar o modo submete a busca. Hover não se confunde com seleção. +

+
+
+ Degradação consolidada +

+ O aviso âmbar da cena 04 permanece. “Termo exato” fica ativo; “⚠ Por tema” fica + desabilitado, sem ação. + Não há botão “Ver resultados exatos” no bloco canônico: a lista exata já está + presente. O retry, quando oferecido, vive no aviso, não ocupa o lugar do submit + removido. +

+
+
+
+
+ +
+
+ REGISTRO DA REVISÃO +

Leituras do mock e loading

+
+
+
+ Leitura confirmada +

+ “Mais filtros⌄” e o h3 “Encontre uma peça para pedir voto” das cenas 01–02 eram + abreviações do mock, não um redesenho do S27. O port preserva a fileira real de cinco + facetas — Tipo · Cidade · Região · Tema · Instituição — e preserva integralmente o + hero do S27, inclusive seu CTA narrativo de pedido de voto. +

+
+
+ Loading confirmado +

+ O skeleton existente do S27 cobre a espera do modo tema. Não há nova informação ou + decisão durante a espera; portanto, uma cena de loading específica seria redundante e + não faz parte desta revisão. +

+
+
+
diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 018ec246..51f922af 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -162,6 +162,8 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/utilities/content/contentPieceReads.ts', // S27-FOLLOWUP-DRY — the owner of the page's og:image fallback. 'src/utilities/ogImageReads.ts', + // S28 — the theme-mode loader of the same public surface. + 'src/utilities/content/contentPieceThemeSearch.ts', // The tag/revalidation vocabulary owner for the content pieces listing. 'src/utilities/documents.ts', 'src/collections/ContentPiece.ts', @@ -370,8 +372,10 @@ export const E2E_AFFECTED_MANIFEST = [ { // C199 — the recordings' timestamped ASR uses the same provider owner; the // acervo spec exercises the recordings surface the job feeds. + // S28 — the same owner now generalizes the expansion for the public + // Central, so a diff there must wake the public spec too. prefixes: ['src/utilities/ai'], - specs: ['campaignAiTranscribe', 'campaignSpeechAcervo'], + specs: ['campaignAiTranscribe', 'campaignSpeechAcervo', 'frontendConteudos'], }, { // Zod input schemas surface in the browser through form flows; the diff --git a/src/app/(frontend)/conteudos/(catalog)/page.tsx b/src/app/(frontend)/conteudos/(catalog)/page.tsx index 3173601c..61c698cc 100644 --- a/src/app/(frontend)/conteudos/(catalog)/page.tsx +++ b/src/app/(frontend)/conteudos/(catalog)/page.tsx @@ -6,23 +6,24 @@ import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageH import { ContentPieceEmptyState, ContentPieceNoResults, + ContentPieceThemeFallbackNotice, + ContentPieceThemeNoResults, } from '@/components/conteudos/ContentPieceStates' import { CONTENT_PIECE_CATALOG_PATH, - contentPieceCatalogActiveFilters, - contentPieceCatalogFacets, + buildContentPieceCatalogHref, contentPieceMediaKind, - filterContentPieceCatalogItems, - parseContentPieceCatalogParams, type ContentPieceCatalogSearchParams, type ContentPiecePublicItem, } from '@/lib/contentPieceCatalog' import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads' +import { loadContentPieceCatalogSearch } from '@/utilities/content/contentPieceThemeSearch' import { getCachedGlobal } from '@/utilities/globalReads' import { hasPublishedJingles } from '@/utilities/jingleReads' import { resolveOgImage } from '@/utilities/ogImageReads' import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo' import type { Metadata } from 'next' +import { headers } from 'next/headers' const CATALOG_TITLE = 'Central de Conteúdos — Peça voto pra Solla 1313' const CATALOG_DESCRIPTION = @@ -32,11 +33,11 @@ const isImagePiece = (item: ContentPiecePublicItem): boolean => contentPieceMediaKind(item) === 'image' /** - * S27 — the public Central: the published pieces (C211) with the five facets, - * the term search and the share of the vote from the card itself. Indexable - * with its own title while there is content; zero published renders the honest - * empty state and hides discovery (header badge and footer link), never a 404. - * The filtered URLs canonicalize to `/conteudos`. + * S27/S28 — the public Central: the published pieces (C211) with the five + * facets, the term search (literal or by theme) and the share of the vote from + * the card itself. Indexable with its own title while there is content; zero + * published renders the honest empty state and hides discovery (header badge + * and footer link), never a 404. The filtered URLs canonicalize to `/conteudos`. */ export async function generateMetadata(): Promise { const [items, globalMetadata] = await Promise.all([ @@ -81,13 +82,23 @@ export default async function ConteudosPage({ }: { searchParams: Promise }) { - const [rawSearchParams, items, showJingles] = await Promise.all([ + const [rawSearchParams, requestHeaders, showJingles] = await Promise.all([ searchParams, - getPublishedContentPieceItems(), + headers(), hasPublishedJingles(), ]) + const { + publishedCount, + params, + items, + facets, + activeFilters, + themeMode, + themeUnavailable, + themeApplied, + } = await loadContentPieceCatalogSearch({ rawSearchParams, requestHeaders }) - if (items.length === 0) { + if (publishedCount === 0) { return ( <> @@ -99,10 +110,6 @@ export default async function ConteudosPage({ ) } - const params = parseContentPieceCatalogParams(rawSearchParams) - const facets = contentPieceCatalogFacets(items) - const filteredItems = filterContentPieceCatalogItems(items, params) - const activeFilters = contentPieceCatalogActiveFilters(params, facets) const hasActiveFilters = activeFilters.length > 0 return ( @@ -112,26 +119,49 @@ export default async function ConteudosPage({
- + {themeUnavailable ? ( +
+ +
+ ) : null} + +

- Escolha, compartilhe e peça voto + {themeMode ? `Resultados para “${params.q}”` : 'Escolha, compartilhe e peça voto'}

O play só carrega a mídia quando você pedir.

- - Abra o título para ver detalhes - + {themeMode ? ( + themeApplied ? ( + Por tema ativo + ) : null + ) : ( + + Abra o título para ver detalhes + + )}
- {filteredItems.length > 0 ? ( - + {items.length > 0 ? ( + + ) : themeMode && !themeUnavailable ? ( + ) : ( )} diff --git a/src/components/conteudos/ContentPieceCard.tsx b/src/components/conteudos/ContentPieceCard.tsx index 4d6bc655..6bcf685f 100644 --- a/src/components/conteudos/ContentPieceCard.tsx +++ b/src/components/conteudos/ContentPieceCard.tsx @@ -5,7 +5,11 @@ import { cn } from '@/lib/utils' import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions' import { ContentPieceMedia } from './ContentPieceMedia' -import { CONTENT_PIECE_CARD, CONTENT_PIECE_TAG } from './contentPieceClasses' +import { + CONTENT_PIECE_CARD, + CONTENT_PIECE_EXACT_TAG, + CONTENT_PIECE_TAG, +} from './contentPieceClasses' type ContentPieceCardProps = { item: ContentPiecePublicItem @@ -13,11 +17,70 @@ type ContentPieceCardProps = { onToggle: () => void onEnded: () => void onShare: (item: ContentPiecePublicItem) => void + /** S28 — the board is in the theme mode: the badge states the provenance. */ + themeMode: boolean } -const CardTag = ({ item }: { item: ContentPiecePublicItem }) => ( - {item.isLink ? item.originLabel : item.typeLabel} -) +/** + * S28 — the badge of a result. In the literal search it stays the editorial + * tag; in the theme mode it states where the result came from: "✦ Tema" when an + * expanded term surfaced the piece, "Termo exato" when only the query did + * (never an invented theme). + */ +const CardTag = ({ item, themeMode }: { item: ContentPiecePublicItem; themeMode: boolean }) => { + if (themeMode) { + return item.themeMatch ? ( + ✦ Tema + ) : ( + Termo exato + ) + } + + return ( + {item.isLink ? item.originLabel : item.typeLabel} + ) +} + +const EVIDENCE_MARK = 'rounded-[3px] bg-[#fff09a] px-0.5 font-extrabold text-black' + +/** + * S28 — "Por que apareceu" (artefato: cenas 05/06): the real passage that + * carries the expanded term, quoted and highlighted. When the match came from + * another part of the haystack the block falls back to a real passage of the + * piece — no quotes, no highlight, with the origin label — and never invents a + * match or a score. + */ +const CardThemeEvidence = ({ item }: { item: ContentPiecePublicItem }) => { + const evidence = item.themeMatch?.evidence + if (!evidence || evidence.parts.length === 0) return null + + const originLabel = + evidence.source === 'description' ? 'Descrição da peça' : 'Transcrição da peça' + + return ( +
+
+ Por que apareceu + {evidence.quoted ? null : ( + {originLabel} + )} +
+

+ {evidence.quoted ? `“${evidence.truncatedStart ? '… ' : ''}` : null} + {evidence.parts.map((part, index) => + part.highlighted ? ( + + {part.text} + + ) : ( + {part.text} + ), + )} + {evidence.quoted ? `${evidence.truncatedEnd ? ' …' : ''}”` : null} +

+
+ ) +} const CardTitle = ({ item, className }: { item: ContentPiecePublicItem; className?: string }) => (

@@ -34,8 +97,8 @@ const CardMeta = ({ item }: { item: ContentPiecePublicItem }) => item.metaLabel ?

{item.metaLabel}

: null /** - * S27 — one catalogue card (artefato: cenas 01/02/07): video/audio/card and - * link pieces take the full card with the asset on top; a photo/text is the + * S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video/audio/card + * and link pieces take the full card with the asset on top; a photo/text is the * compact horizontal row. Actions work from the card itself — download never * needs the piece page. */ @@ -45,6 +108,7 @@ export const ContentPieceCard = ({ onToggle, onEnded, onShare, + themeMode, }: ContentPieceCardProps) => { const kind = contentPieceMediaKind(item) const isCompact = kind === 'image' || kind === 'text' || kind === 'other' @@ -65,9 +129,10 @@ export const ContentPieceCard = ({ className="h-24 w-32 shrink-0 rounded-lg" />
- + +
@@ -91,9 +156,10 @@ export const ContentPieceCard = ({ className="aspect-video" />
- + +
diff --git a/src/components/conteudos/ContentPieceCatalog.tsx b/src/components/conteudos/ContentPieceCatalog.tsx index c71c1035..2d00652f 100644 --- a/src/components/conteudos/ContentPieceCatalog.tsx +++ b/src/components/conteudos/ContentPieceCatalog.tsx @@ -18,9 +18,12 @@ import { ContentPieceShareSheet } from './ContentPieceShareSheet' export const ContentPieceCatalog = ({ items, showCardInvite, + themeMode, }: { items: readonly ContentPiecePublicItem[] showCardInvite: boolean + /** S28 — the board is in the theme mode: cards state their provenance. */ + themeMode: boolean }) => { const [playingId, setPlayingId] = useState(null) const [sharingItem, setSharingItem] = useState(null) @@ -39,6 +42,7 @@ export const ContentPieceCatalog = ({ onToggle: () => setPlayingId((current) => (current === item.id ? null : item.id)), onEnded: () => setPlayingId((current) => (current === item.id ? null : current)), onShare: setSharingItem, + themeMode, }) return ( diff --git a/src/components/conteudos/ContentPieceFilters.tsx b/src/components/conteudos/ContentPieceFilters.tsx index e0ce6245..8bbbe36d 100644 --- a/src/components/conteudos/ContentPieceFilters.tsx +++ b/src/components/conteudos/ContentPieceFilters.tsx @@ -11,11 +11,11 @@ import { type ContentPieceCatalogParams, } from '@/lib/contentPieceCatalog' +import { ContentPieceSearchMode } from './ContentPieceSearchMode' import { CONTENT_PIECE_ACTIVE_CHIP, CONTENT_PIECE_CHIP, CONTENT_PIECE_FOCUS, - CONTENT_PIECE_PRIMARY_BUTTON, } from './contentPieceClasses' const FIELD_CLASS = @@ -25,21 +25,27 @@ const DROPDOWN_LINK = 'flex min-h-9 items-center rounded-lg px-2.5 text-sm font-medium text-black hover:bg-(--campaign-band) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)' /** - * S27 — the catalogue filters (artefato: cenas 01/06): a GET form (works - * without JS, the URL is the state) with the search term plus one chip per - * facet — a `
` menu when empty, an active chip with its removal link - * when set. Facets that no published piece carries never render. + * S28 — the catalogue filters (artefato: cenas 07–09): a GET form (works + * without JS, the URL is the state) with the search term, the mode segmented + * control and one chip per facet — a `
` menu when empty, an active + * chip with its removal link when set. There is no submit button: Enter on the + * field searches and changing the mode submits. Facets that no published piece + * carries never render; in the theme mode the links keep `prefetch={false}` so + * a prefetched (never expanded) payload does not poison the client router + * cache. */ export const ContentPieceFilters = ({ params, facets, activeFilters, + themeUnavailable, }: { params: ContentPieceCatalogParams facets: ContentPieceCatalogFacets activeFilters: readonly ContentPieceCatalogActiveFilter[] + themeUnavailable: boolean }) => { - const hasActiveFilters = activeFilters.length > 0 + const themeMode = params.mode === 'tema' const termFilter = activeFilters.find((filter) => filter.facet === 'q') const facetFilters = activeFilters.filter( (filter): filter is typeof filter & { facet: ContentPieceCatalogFacet } => filter.facet !== 'q', @@ -53,8 +59,8 @@ export const ContentPieceFilters = ({ ) : null, )} -
-
+
+
) : null}
- -
+ -
- {facetFilters.map((filter) => ( - - {filter.label} · {filter.value} - - - ))} +
+ {facetFilters.map((filter) => ( + + {filter.label} · {filter.value} + + + ))} - {CONTENT_PIECE_CATALOG_FACETS.map((facet) => - params[facet] || facets[facet].length === 0 ? null : ( -
- - {contentPieceCatalogFacetLabels[facet]} - - -
    - {facets[facet].map((option) => ( -
  • - - {option.label} - -
  • - ))} -
-
- ), - )} + {CONTENT_PIECE_CATALOG_FACETS.map((facet) => + params[facet] || facets[facet].length === 0 ? null : ( +
+ + {contentPieceCatalogFacetLabels[facet]} + + +
    + {facets[facet].map((option) => ( +
  • + + {option.label} + +
  • + ))} +
+
+ ), + )} +
) diff --git a/src/components/conteudos/ContentPieceSearchMode.tsx b/src/components/conteudos/ContentPieceSearchMode.tsx new file mode 100644 index 00000000..856ac364 --- /dev/null +++ b/src/components/conteudos/ContentPieceSearchMode.tsx @@ -0,0 +1,82 @@ +'use client' + +import type { ChangeEvent } from 'react' + +import type { ContentPieceCatalogMode } from '@/lib/contentPieceCatalog' +import { cn } from '@/lib/utils' + +const MODE_OPTION = cn( + 'relative flex min-h-[42px] items-center justify-center rounded-[7px] px-2.5 text-[13px] font-extrabold text-[#3f3937] transition-colors', + 'has-[:checked]:bg-white has-[:checked]:text-[#184e92] has-[:checked]:shadow-[0_1px_4px_rgb(0_0_0/12%)]', + 'has-[:focus-visible]:outline-[3px] has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-(--pt-red)', + 'motion-reduce:transition-none', +) + +const MODE_OPTION_ENABLED = 'cursor-pointer hover:bg-white/58 hover:text-[#184e92]' + +const MODE_OPTION_DISABLED = 'cursor-not-allowed text-[#8b6c1a] opacity-[0.64]' + +/** + * The radio itself covers the whole option (invisible, on top): the entire + * segment is the hit target and the native keyboard group keeps working. + */ +const MODE_INPUT = 'absolute inset-0 size-full opacity-0' + +const MODE_INPUT_ENABLED = 'cursor-pointer' + +const MODE_INPUT_DISABLED = 'cursor-not-allowed' + +/** + * S28 — the search mode of the Central (artefato: cenas 07–09): a single radio + * group inside the GET form, "Termo exato" as the default. With JS, changing + * the mode submits the form (one tap); without JS, Enter on the search field + * submits it with the checked option. When the mechanism is unavailable the + * theme option is disabled with the ⚠ mark — the notice above explains — and + * the exact option stays active. + */ +export const ContentPieceSearchMode = ({ + mode, + themeUnavailable, +}: { + mode: ContentPieceCatalogMode | null + themeUnavailable: boolean +}) => { + const themeChecked = mode === 'tema' && !themeUnavailable + + const submitOnChange = (event: ChangeEvent) => { + event.currentTarget.form?.requestSubmit() + } + + return ( +
+ Buscar por: +
+ + +
+
+ ) +} diff --git a/src/components/conteudos/ContentPieceStates.tsx b/src/components/conteudos/ContentPieceStates.tsx index 5b616e6d..3e18bf60 100644 --- a/src/components/conteudos/ContentPieceStates.tsx +++ b/src/components/conteudos/ContentPieceStates.tsx @@ -3,10 +3,12 @@ import Link from 'next/link' import { CONTENT_PIECE_CATALOG_PATH, + buildContentPieceCatalogHref, type ContentPieceCatalogActiveFilter, + type ContentPieceCatalogParams, } from '@/lib/contentPieceCatalog' -import { CONTENT_PIECE_PRIMARY_BUTTON } from './contentPieceClasses' +import { CONTENT_PIECE_OUTLINE_BUTTON, CONTENT_PIECE_PRIMARY_BUTTON } from './contentPieceClasses' /** * S27 — the honest states of the Central (artefato: cena 05/06). Zero @@ -68,3 +70,56 @@ export const ContentPieceNoResults = ({

) + +/** + * S28 — the honest empty of the theme mode (artefato: cena 03): no weak result + * fills the board, and the three ways out are the reformulation, the exact + * search and the clear filters. + */ +export const ContentPieceThemeNoResults = ({ params }: { params: ContentPieceCatalogParams }) => ( +
+

+ Nenhuma peça combina bem com esse tema +

+

+ Não mostramos um resultado fraco só para preencher a tela. +

+
+ + Reformular busca + + + Usar termo exato + + + Limpar filtros + +
+
+) + +/** + * S28 — the degradation notice (artefato: cenas 04/09): the exact search keeps + * working and the retry lives here (the canonical block has no submit button). + * The link never prefetches: a prefetched payload is never expanded and would + * poison the client router cache. + */ +export const ContentPieceThemeFallbackNotice = ({ retryHref }: { retryHref: string }) => ( +
+ Busca por tema indisponível agora. +
A busca exata continua funcionando.{' '} + + Tentar por tema novamente + +
+) diff --git a/src/components/conteudos/contentPieceClasses.ts b/src/components/conteudos/contentPieceClasses.ts index e00f1f6f..da42518d 100644 --- a/src/components/conteudos/contentPieceClasses.ts +++ b/src/components/conteudos/contentPieceClasses.ts @@ -18,6 +18,10 @@ export const CONTENT_PIECE_ACTIVE_CHIP = cn( export const CONTENT_PIECE_TAG = 'inline-flex rounded-full bg-[#eef4fb] px-2 py-1 text-[11px] font-extrabold text-[#184e92]' +/** S28 — the neutral badge of a theme-mode result that only matched the query. */ +export const CONTENT_PIECE_EXACT_TAG = + 'inline-flex rounded-full border border-black/12 bg-white px-2 py-1 text-[11px] font-extrabold text-[#514945]' + export const CONTENT_PIECE_PRIMARY_BUTTON = cn( 'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] bg-(--pt-yellow) px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-yellow-ink) no-underline', 'shadow-[0_4px_0_#cfb900] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[2px] active:shadow-[0_2px_0_#cfb900] motion-reduce:transition-none', diff --git a/src/lib/contentPieceCatalog.ts b/src/lib/contentPieceCatalog.ts index bdab9d29..07a55d4f 100644 --- a/src/lib/contentPieceCatalog.ts +++ b/src/lib/contentPieceCatalog.ts @@ -25,7 +25,8 @@ import { import { slugify } from '@/lib/slug' import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock' import { SPEECH_TOPICS, type SpeechTopic } from '@/lib/speechFacets' -import { normalizeForSearch } from '@/lib/speechSearch' +import { buildHighlightedExcerpt, type SpeechHighlightPart } from '@/lib/speechHighlight' +import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch' export const CONTENT_PIECE_CATALOG_PATH = '/conteudos' @@ -53,6 +54,13 @@ export const contentPieceCatalogFacetLabels: Record @@ -78,6 +87,7 @@ export const parseContentPieceCatalogParams = ( ): ContentPieceCatalogParams => { const tipo = firstValue(raw.tipo) const tema = firstValue(raw.tema) + const q = firstValue(raw.q) const slugFacet = (facet: ContentPieceCatalogFacet): string | null => { const value = firstValue(raw[facet]) return value && FACET_SLUG_PATTERN.test(value) ? value : null @@ -89,7 +99,10 @@ export const parseContentPieceCatalogParams = ( regiao: slugFacet('regiao'), tema: isContentPieceTopic(tema) ? tema : null, instituicao: slugFacet('instituicao'), - q: firstValue(raw.q), + q, + // Only `tema` is meaningful; anything else (and the default) means exact, + // and a mode without a term has nothing to expand. + mode: firstValue(raw.mode) === 'tema' && q ? 'tema' : null, } } @@ -104,7 +117,12 @@ export const buildContentPieceCatalogHref = ( if (params.tema) search.set('tema', params.tema) if (params.instituicao) search.set('instituicao', params.instituicao) const term = params.q?.trim() - if (term) search.set('q', term) + if (term) { + search.set('q', term) + // `tema` is the only non-default mode and it only makes sense with a query, + // so a mode without `q` canonicalizes away. + if (params.mode === 'tema') search.set('mode', 'tema') + } const query = search.toString() return query ? `${CONTENT_PIECE_CATALOG_PATH}?${query}` : CONTENT_PIECE_CATALOG_PATH @@ -190,31 +208,64 @@ export const contentPieceCatalogActiveFilters = ( facet: 'q', label: 'Busca', value: params.q.trim(), - removeHref: buildContentPieceCatalogHref({ ...params, q: '' }), + // Clearing the search clears the mode with it: `tema` has nothing to + // expand without a query. + removeHref: buildContentPieceCatalogHref({ ...params, q: '', mode: null }), }) } return filters } +/** + * S28 — the expanded terms that may claim "Tema" on a result: empty/duplicate + * terms are dropped and a term equal to the literal query is not a theme match + * (the piece surfaced by the query itself, not by the expansion). + */ +export const contentPieceThemeTerms = (query: string, themeTerms: readonly string[]): string[] => { + const normalizedQuery = normalizeForSearch(query) + return uniqueByNormalizedForm(themeTerms).filter( + (term) => normalizeForSearch(term) !== normalizedQuery, + ) +} + +const normalizedSearchTerms = (query: string, themeTerms: readonly string[]): string[] => { + const seen = new Set() + const terms: string[] = [] + for (const value of [query, ...themeTerms]) { + const normalized = normalizeForSearch(value) + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + terms.push(normalized) + } + return terms +} + /** All active facets combine (AND); the term matches the denormalized haystack. */ export const filterContentPieceCatalogItems = ( items: readonly ContentPiecePublicItem[], params: ContentPieceCatalogParams, + themeTerms: readonly string[] = [], ): ContentPiecePublicItem[] => { - const term = normalizeForSearch(params.q) + const searchTerms = normalizedSearchTerms(params.q, themeTerms) - return items.filter( - (item) => - (!params.tipo || item.type === params.tipo) && - (!params.cidade || (item.cityLabel !== null && slugify(item.cityLabel) === params.cidade)) && - (!params.regiao || - (item.regionLabel !== null && slugify(item.regionLabel) === params.regiao)) && - (!params.tema || item.topics.includes(params.tema)) && - (!params.instituicao || - (item.institution !== null && slugify(item.institution) === params.instituicao)) && - (!term || normalizeForSearch(item.searchText).includes(term)), - ) + return items.filter((item) => { + if ( + (params.tipo && item.type !== params.tipo) || + (params.cidade && (item.cityLabel === null || slugify(item.cityLabel) !== params.cidade)) || + (params.regiao && + (item.regionLabel === null || slugify(item.regionLabel) !== params.regiao)) || + (params.tema && !item.topics.includes(params.tema)) || + (params.instituicao && + (item.institution === null || slugify(item.institution) !== params.instituicao)) + ) { + return false + } + if (searchTerms.length === 0) return true + + const haystack = normalizeForSearch(item.searchText) + return searchTerms.some((term) => haystack.includes(term)) + }) } export type ContentPiecePublicSource = { @@ -261,6 +312,8 @@ export type ContentPiecePublicItem = { /** `Tema · Local` (or institution/city fallbacks) — the card's metadata line. */ metaLabel: string searchText: string + /** S28 — why the piece appeared in the theme mode; null in the literal search. */ + themeMatch: ContentPieceThemeMatch | null publicPath: string /** The archived file, or null on a link piece — never partially populated. */ file: ContentPiecePublicFile | null @@ -275,6 +328,29 @@ type ContentPiecePublicFile = { export type ContentPieceMediaKind = 'video' | 'audio' | 'image' | 'text' | 'other' +/** S28 — where the "Por que apareceu" passage came from (label of the fallback). */ +type ContentPieceThemeEvidenceSource = 'transcript' | 'description' | 'excerpt' + +type ContentPieceThemeEvidence = { + parts: SpeechHighlightPart[] + truncatedStart: boolean + truncatedEnd: boolean + /** + * True when the passage is the window that carries the matched term (the + * quoted, highlighted one); false when it is a real passage of the piece that + * does not contain the term (no quotes, no highlight, no claim). + */ + quoted: boolean + source: ContentPieceThemeEvidenceSource +} + +export type ContentPieceThemeMatch = { + /** The expanded term that surfaced the piece (never the literal query). */ + term: string + /** Real passage for "Por que apareceu"; null when the piece carries no text. */ + evidence: ContentPieceThemeEvidence | null +} + /** * How the card/preview renders the archived file: the stored MIME decides * (video/audio play on demand, image previews, text shows the excerpt); an @@ -341,6 +417,71 @@ const durationLabelOf = (seconds: number | null | undefined): string | null => ? formatSpeechClock(seconds) : null +/** + * S28 — the first expanded term that actually surfaced this piece. The gate is + * the same normalized `includes` the filter runs over `searchText`, so the card + * never claims a theme the search did not use. The evidence is the window that + * carries the term (`transcript` > `description`, highlighted as one phrase); + * when the match came from another part of the haystack (title, topic, city, + * institution) the block falls back to a real passage of the piece without + * highlight — text of the piece, never a fabricated quote. Terms must already + * be filtered by `contentPieceThemeTerms` (the literal query is not a theme). + */ +export const contentPieceThemeMatch = ( + record: ContentPiecePublicSource, + themeTerms: readonly string[], +): ContentPieceThemeMatch | null => { + if (themeTerms.length === 0) return null + + const haystack = normalizeForSearch(record.searchText ?? '') + const transcript = record.transcript?.trim() ?? '' + const description = record.description?.trim() ?? '' + + for (const term of themeTerms) { + const normalized = normalizeForSearch(term) + if (!normalized || !haystack.includes(normalized)) continue + + if (normalizeForSearch(transcript).includes(normalized)) { + return { + term, + evidence: { + ...buildHighlightedExcerpt(transcript, term, { phrase: true }), + quoted: true, + source: 'transcript', + }, + } + } + if (normalizeForSearch(description).includes(normalized)) { + return { + term, + evidence: { + ...buildHighlightedExcerpt(description, term, { phrase: true }), + quoted: true, + source: 'description', + }, + } + } + + // A real short passage of the piece (never the whole 1200-char description + // on a card), without quotes: there is no matched term to point at. + const fallback = contentPieceTextExcerpt(description || transcript) + return { + term, + evidence: fallback + ? { + parts: [{ text: fallback, highlighted: false }], + truncatedStart: false, + truncatedEnd: false, + quoted: false, + source: description ? 'description' : 'excerpt', + } + : null, + } + } + + return null +} + const dateLabelOf = (value: string | null | undefined): string | null => value && /^\d{4}-\d{2}-\d{2}/.test(value) ? formatSpeechDate(value) : null @@ -358,9 +499,13 @@ const mediaOf = ( * Public view of one piece, or null when it may not be shown: no slug (never * published) or the `contentPieceIsPublic` predicate fails (published without * file and without link). The gate lives here too, not only in the query. + * + * S28 — `themeTerms` (already filtered by `contentPieceThemeTerms`) annotates + * the item with the theme match; empty in the literal search. */ export const toContentPiecePublicItem = ( record: ContentPiecePublicSource, + { themeTerms = [] }: { themeTerms?: readonly string[] } = {}, ): ContentPiecePublicItem | null => { const slug = record.slug?.trim() if (!slug) return null @@ -402,6 +547,7 @@ export const toContentPiecePublicItem = ( pieceDateLabel: dateLabelOf(record.pieceDate), metaLabel, searchText: record.searchText ?? '', + themeMatch: contentPieceThemeMatch(record, themeTerms), publicPath: contentPiecePublicPath(slug), file: media ? { diff --git a/src/utilities/ai/expandSpeechSearchTheme.ts b/src/utilities/ai/expandSpeechSearchTheme.ts index 4bde7b5d..fac0bca4 100644 --- a/src/utilities/ai/expandSpeechSearchTheme.ts +++ b/src/utilities/ai/expandSpeechSearchTheme.ts @@ -15,40 +15,70 @@ 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 expandSchemaFor = (corpus: ThemeSearchCorpus) => + 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 ${corpus === 'speech' ? 'na transcrição de falas' : 'na descrição ou na transcrição de uma peça'} sobre o tema. Lista vazia quando não houver termo confiável.`, + ), + }) -const EXPAND_SYSTEM_PROMPT = +/** The term rules are one contract; only the noun of the corpus changes. */ +const themeExpansionRules = (subject: 'discurso' | 'peça'): string => + `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 ${subject} (ex.: "Brasil", ` + + '"povo", "governo"). Se não houver termo confiável, devolva a lista vazia. Nunca invente fatos.' + +const SPEECH_CORPUS_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.' + themeExpansionRules('discurso') -type SpeechThemeExpansion = { terms: string[] } +const CONTENT_PIECE_CORPUS_PROMPT = + 'Você ajuda o eleitor a achar, na Central de Conteúdos do deputado Jorge Solla, peças de campanha ' + + '(vídeos, áudios, fotos, cards e textos) sobre um tema mesmo quando a peça não usa as palavras ' + + 'exatas da busca. ' + + 'Receberá o tema digitado e deve devolver termos e expressões curtas que provavelmente aparecem ' + + 'na descrição ou na transcrição de uma peça 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. ' + + themeExpansionRules('peça') + +/** + * S28 — the corpus a theme expansion serves: the internal speech acervo (C192) + * and the public Central de Conteúdos (S28). The mechanism is one; only the + * system prompt describes a different body of material and persona. + */ +export type ThemeSearchCorpus = 'speech' | 'contentPiece' + +const systemPromptFor = (corpus: ThemeSearchCorpus): string => + corpus === 'contentPiece' ? CONTENT_PIECE_CORPUS_PROMPT : SPEECH_CORPUS_PROMPT + +export type ThemeSearchExpansion = { terms: string[] } /** * The injectable expansion boundary (tests pass a deterministic resolver, the * same pattern as `SpeechVideoStartResolver`). */ -export type SpeechThemeExpansionResolver = (theme: string) => Promise +export type ThemeSearchExpansionResolver = (theme: string) => Promise + +/** C192 — the speech-typed name the acervo keeps using. */ +export type SpeechThemeExpansionResolver = ThemeSearchExpansionResolver /** - * 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"). + * C192/S28 — resolves the query into a theme expansion for a corpus. 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) => { +export const expandSearchTheme = async ( + theme: string, + corpus: ThemeSearchCorpus, +): Promise => { if (!process.env.DEEPSEEK_API_KEY) return null const trimmed = theme.trim().slice(0, MAX_THEME_LENGTH) @@ -57,8 +87,8 @@ export const expandSpeechSearchTheme: SpeechThemeExpansionResolver = async (them try { const { object } = await generateObject({ model: deepSeek('deepseek-flash'), - schema: expandSchema, - system: EXPAND_SYSTEM_PROMPT, + schema: expandSchemaFor(corpus), + system: systemPromptFor(corpus), prompt: trimmed, // Deterministic as the provider allows: pages 2+ of the same search must // not be built from a different expansion. @@ -72,3 +102,11 @@ export const expandSpeechSearchTheme: SpeechThemeExpansionResolver = async (them return null } } + +/** C192 — the acervo de falas expansion (byte-identical contract). */ +export const expandSpeechSearchTheme: SpeechThemeExpansionResolver = (theme) => + expandSearchTheme(theme, 'speech') + +/** S28 — the public Central de Conteúdos expansion. */ +export const expandContentPieceSearchTheme: ThemeSearchExpansionResolver = (theme) => + expandSearchTheme(theme, 'contentPiece') diff --git a/src/utilities/ai/themeSearchGuard.ts b/src/utilities/ai/themeSearchGuard.ts new file mode 100644 index 00000000..6c9d06ee --- /dev/null +++ b/src/utilities/ai/themeSearchGuard.ts @@ -0,0 +1,168 @@ +import 'server-only' + +import { normalizeForSearch } from '@/lib/speechSearch' +import type { ThemeSearchExpansionResolver } from '@/utilities/ai/expandSpeechSearchTheme' + +/** + * S28 — the anonymous guard of the public theme search. The Central de Conteúdos + * has no login and the expansion is an LLM call, so the mode needs a use limit + * and must never be driven by a bot/crawler/prefetch. Both the limiter and the + * expansion cache are in-memory (single self-hosted instance; reset on deploy, + * same contract as `rateLimit.ts`) and live outside `unstable_cache` because + * they read request headers. Overflow always degrades to the literal search — + * never a 429 in the voter's face. + */ + +/** The headers subset the guard reads (a `Headers` instance satisfies it). */ +export type ThemeSearchHeaders = Pick + +const PREFETCH_HEADER = 'next-router-prefetch' +const FETCH_MODE_HEADER = 'sec-fetch-mode' +/** Borda (Cloudflare tunnel) first: not forgeable by the client. */ +const CLIENT_IP_HEADERS = ['cf-connecting-ip', 'x-forwarded-for', 'x-real-ip'] as const + +const THEME_SEARCH_MAX_EXPANSIONS = 8 +const THEME_SEARCH_WINDOW_MS = 15 * 60 * 1000 +const THEME_SEARCH_CACHE_TTL_MS = 30 * 60 * 1000 +const THEME_SEARCH_CACHE_MAX_ENTRIES = 200 + +/** + * A theme expansion only runs for a browser-initiated request. Next 15 consumes + * `RSC`/`Next-Router-Prefetch` before userland (`headers()` never sees them — + * verified against the running app), so the gate is the Fetch Metadata that + * does arrive: `navigate` (a document load, JS-less form GET included) or + * `cors` (the router's RSC fetch — the same one a client-side navigation uses, + * which must expand). `no-cors` is refused so a foreign `` + * or an `` cannot trigger an expansion, and a scraper without the + * Sec-Fetch headers stays literal. The first line against Next's own prefetch + * is the UI: every theme-preserving link renders with `prefetch={false}`, since + * the router's prefetch is indistinguishable from a navigation here. + */ +export const isThemeSearchRequestEligible = (headers: ThemeSearchHeaders): boolean => { + if (headers.get(PREFETCH_HEADER)) return false + const mode = headers.get(FETCH_MODE_HEADER) + return mode === 'navigate' || mode === 'cors' +} + +/** + * Anonymous client key for the limiter: the edge IP headers in order, with the + * first `x-forwarded-for` hop as the first fallback. Missing all of them shares + * the `unknown` bucket (fail-closed: the tightest limit applies). + */ +export const themeSearchClientKey = (headers: ThemeSearchHeaders): string => { + for (const name of CLIENT_IP_HEADERS) { + const value = headers.get(name)?.split(',')[0]?.trim() + if (value) return value + } + return 'unknown' +} + +type ThemeSearchWindow = { + count: number + resetAt: number +} + +type ThemeSearchCacheEntry = { + terms: string[] + expiresAt: number +} + +export type ThemeSearchGuardOptions = { + max?: number + windowMs?: number + ttlMs?: number + maxEntries?: number + /** Injectable clock for the unit tests. */ + now?: () => number +} + +type ResolveThemeExpansionInput = { + q: string + headers: ThemeSearchHeaders + expandTheme: ThemeSearchExpansionResolver +} + +export type ThemeSearchGuard = { + /** + * `{ terms }` when the expansion ran (or was served from the cache) — the + * empty list included — and `null` whenever the caller must degrade to the + * literal search with the notice (ineligible request, quota, unavailable). + */ + resolve: (input: ResolveThemeExpansionInput) => Promise<{ terms: string[] } | null> +} + +export const createThemeSearchGuard = (options: ThemeSearchGuardOptions = {}): ThemeSearchGuard => { + const max = options.max ?? THEME_SEARCH_MAX_EXPANSIONS + const windowMs = options.windowMs ?? THEME_SEARCH_WINDOW_MS + const ttlMs = options.ttlMs ?? THEME_SEARCH_CACHE_TTL_MS + const maxEntries = options.maxEntries ?? THEME_SEARCH_CACHE_MAX_ENTRIES + const now = options.now ?? Date.now + + const windows = new Map() + const cache = new Map() + + const cleanWindows = (at: number) => { + for (const [key, entry] of windows) { + if (at >= entry.resetAt) windows.delete(key) + } + } + + const cleanCache = (at: number) => { + for (const [key, entry] of cache) { + if (at >= entry.expiresAt) cache.delete(key) + } + } + + /** A hit does not consume quota: the provider call was already paid for. */ + const withinLimit = (clientKey: string, at: number): boolean => { + const existing = windows.get(clientKey) + if (!existing || at >= existing.resetAt) { + windows.set(clientKey, { count: 1, resetAt: at + windowMs }) + return true + } + if (existing.count >= max) return false + existing.count += 1 + return true + } + + const resolve = async ({ + q, + headers, + expandTheme, + }: ResolveThemeExpansionInput): Promise<{ terms: string[] } | null> => { + if (!isThemeSearchRequestEligible(headers)) return null + + const theme = q.trim() + const cacheKey = normalizeForSearch(theme) + if (!cacheKey) return null + + const at = now() + cleanCache(at) + const cached = cache.get(cacheKey) + if (cached) return { terms: cached.terms } + + cleanWindows(at) + if (!withinLimit(themeSearchClientKey(headers), at)) return null + + const expansion = await expandTheme(theme) + // `null` is transient (missing key/timeout/provider error): never cached. + if (!expansion) return null + + if (cache.size >= maxEntries) { + const oldest = cache.keys().next().value + if (oldest !== undefined) cache.delete(oldest) + } + cache.set(cacheKey, { terms: expansion.terms, expiresAt: now() + ttlMs }) + + return { terms: expansion.terms } + } + + return { resolve } +} + +/** + * The process-wide guard: one in-memory limiter and cache, reset on deploy. The + * cache key is the normalized public query (no PII, no user id); a `null` + * expansion is never stored, so a later request can retry. + */ +export const resolveGuardedThemeExpansion = createThemeSearchGuard().resolve diff --git a/src/utilities/content/contentPieceReads.ts b/src/utilities/content/contentPieceReads.ts index 25c04910..18b6e11e 100644 --- a/src/utilities/content/contentPieceReads.ts +++ b/src/utilities/content/contentPieceReads.ts @@ -1,6 +1,10 @@ import 'server-only' -import { toContentPiecePublicItem, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog' +import { + toContentPiecePublicItem, + type ContentPiecePublicItem, + type ContentPiecePublicSource, +} from '@/lib/contentPieceCatalog' import { getCollectionListingTag } from '@/utilities/documents' import configPromise from '@payload-config' import { unstable_cache } from 'next/cache' @@ -56,14 +60,21 @@ const getCachedPublishedContentPieces = () => tags: [getCollectionListingTag('contentPiece')], }) -/** Published pieces as serializable public view models, newest first. */ -export const getPublishedContentPieceItems = async (): Promise => { +/** + * The published records with the raw fields the public surface needs + * (`transcript`/`description` included, for the S28 theme provenance). The + * caller maps them into view models — with or without theme terms. + */ +export const getPublishedContentPieceRecords = async (): Promise => { const { docs } = await getCachedPublishedContentPieces()() - return docs +} + +/** Published pieces as serializable public view models, newest first. */ +export const getPublishedContentPieceItems = async (): Promise => + (await getPublishedContentPieceRecords()) .map((piece) => toContentPiecePublicItem(piece)) .filter((item): item is ContentPiecePublicItem => item !== null) -} /** * One published piece by its public slug. A draft (no slug), an unpublished diff --git a/src/utilities/content/contentPieceThemeSearch.ts b/src/utilities/content/contentPieceThemeSearch.ts new file mode 100644 index 00000000..f13c0934 --- /dev/null +++ b/src/utilities/content/contentPieceThemeSearch.ts @@ -0,0 +1,99 @@ +import 'server-only' + +import { + contentPieceCatalogActiveFilters, + contentPieceCatalogFacets, + contentPieceThemeTerms, + filterContentPieceCatalogItems, + parseContentPieceCatalogParams, + toContentPiecePublicItem, + type ContentPieceCatalogActiveFilter, + type ContentPieceCatalogFacets, + type ContentPieceCatalogParams, + type ContentPieceCatalogSearchParams, + type ContentPiecePublicItem, +} from '@/lib/contentPieceCatalog' +import { + expandContentPieceSearchTheme, + type ThemeSearchExpansionResolver, +} from '@/utilities/ai/expandSpeechSearchTheme' +import { + resolveGuardedThemeExpansion, + type ThemeSearchHeaders, +} from '@/utilities/ai/themeSearchGuard' +import { getPublishedContentPieceRecords } from '@/utilities/content/contentPieceReads' + +/** + * S28 — the public catalogue search, both modes. The literal search is the + * default and the theme mode is an OR over the literal query plus the expanded + * terms, over the SAME cached published records the S27 surface reads. The + * expansion only runs for `mode=tema` with a query, only when the Central has + * something published, and always through the anonymous guard (limit + + * eligibility + cache): the caller renders the discreet notice whenever it + * resolves `null`. + */ +export type ContentPieceCatalogSearchData = { + /** Public pieces on the Central before filters: zero renders the honest empty state. */ + publishedCount: number + params: ContentPieceCatalogParams + items: ContentPiecePublicItem[] + facets: ContentPieceCatalogFacets + activeFilters: ContentPieceCatalogActiveFilter[] + /** The actor asked for the theme mode (always with a query): the badges state the provenance. */ + themeMode: boolean + /** The actor asked for `mode=tema` but the mechanism was unavailable: degrade with the notice. */ + themeUnavailable: boolean + /** The expansion actually contributed terms (an empty list is not "applied"). */ + themeApplied: boolean +} + +const toPublicItems = ( + records: Awaited>, + themeTerms: readonly string[] = [], +): ContentPiecePublicItem[] => + records + .map((record) => toContentPiecePublicItem(record, { themeTerms })) + .filter((item): item is ContentPiecePublicItem => item !== null) + +export const loadContentPieceCatalogSearch = async ({ + rawSearchParams, + requestHeaders, + expandTheme = expandContentPieceSearchTheme, +}: { + rawSearchParams: ContentPieceCatalogSearchParams + requestHeaders: ThemeSearchHeaders + expandTheme?: ThemeSearchExpansionResolver +}): Promise => { + const records = await getPublishedContentPieceRecords() + const params = parseContentPieceCatalogParams(rawSearchParams) + const publishedItems = toPublicItems(records) + + const themeRequested = params.mode === 'tema' + let themeTerms: string[] = [] + let themeUnavailable = false + + // An empty Central has nothing to search: never pay for an expansion there. + if (themeRequested && publishedItems.length > 0) { + const expansion = await resolveGuardedThemeExpansion({ + q: params.q, + headers: requestHeaders, + expandTheme, + }) + if (expansion) themeTerms = contentPieceThemeTerms(params.q, expansion.terms) + else themeUnavailable = true + } + + const publicItems = themeTerms.length > 0 ? toPublicItems(records, themeTerms) : publishedItems + const facets = contentPieceCatalogFacets(publicItems) + + return { + publishedCount: publicItems.length, + params, + items: filterContentPieceCatalogItems(publicItems, params, themeTerms), + facets, + activeFilters: contentPieceCatalogActiveFilters(params, facets), + themeMode: themeRequested, + themeUnavailable, + themeApplied: themeTerms.length > 0, + } +} diff --git a/tests/e2e/frontendConteudos.e2e.spec.ts b/tests/e2e/frontendConteudos.e2e.spec.ts index 16dc28dd..c0898214 100644 --- a/tests/e2e/frontendConteudos.e2e.spec.ts +++ b/tests/e2e/frontendConteudos.e2e.spec.ts @@ -265,6 +265,48 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { await expect(page.getByRole('link', { name: 'Voltar à Central' })).toBeVisible() }) + test('offers the theme mode and degrades honestly without the provider key', async ({ + page, + request, + }) => { + const headers = await adminHeaders(request, BASE_URL) + await unpublishEveryPiece(request, headers) + const marker = uniqueMarker() + const title = `Saúde do trabalhador ${marker}` + await createPiece(request, headers, { title, type: 'video' }) + + // The e2e build blanks DEEPSEEK_API_KEY (same as the C192 spec), so the + // theme mode always degrades: the exact results stay and the notice explains. + const response = await page.goto(`/conteudos?q=${marker}&mode=tema`) + expect(response?.status()).toBe(200) + await expect(page.getByRole('heading', { name: `Resultados para “${marker}”` })).toBeVisible() + await expect(page.getByRole('radio', { name: 'Termo exato' })).toBeChecked() + await expect(page.getByRole('radio', { name: 'Por tema' })).toBeDisabled() + await expect(page.getByText('Busca por tema indisponível agora.')).toBeVisible() + await expect(page.getByRole('link', { name: title, exact: true })).toBeVisible() + await expect(page.getByText('Por que apareceu')).toHaveCount(0) + + // A mode without a query has nothing to expand: no notice, exact is active. + await page.goto('/conteudos?mode=tema') + await expect(page.getByText('Busca por tema indisponível agora.')).toHaveCount(0) + await expect(page.getByRole('radio', { name: 'Termo exato' })).toBeChecked() + await expect(page.getByRole('radio', { name: 'Por tema' })).toBeEnabled() + + // Changing the mode submits the GET form with the query in force. + await page.goto(`/conteudos?q=${marker}`) + await page.getByRole('radio', { name: 'Por tema' }).check() + await expect(page).toHaveURL(new RegExp(`q=${marker}&mode=tema$`)) + await expect(page.getByText('Busca por tema indisponível agora.')).toBeVisible() + + // Enter on the field searches without JS (the canonical block has no button). + await page.goto('/conteudos') + const field = page.getByRole('searchbox', { name: 'Buscar peças' }) + await field.fill(marker) + await field.press('Enter') + await expect(page).toHaveURL(new RegExp(`q=${marker}&mode=exato$`)) + await expect(page.getByRole('link', { name: title, exact: true })).toBeVisible() + }) + test('opens the piece page with OG preview and the same actions', async ({ page, request }) => { const headers = await adminHeaders(request, BASE_URL) await unpublishEveryPiece(request, headers) diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index 03cc1e11..19a7031a 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -55,6 +55,7 @@ import { getPublishedContentPieceItems, hasPublishedContentPieces, } from '@/utilities/content/contentPieceReads' +import { loadContentPieceCatalogSearch } from '@/utilities/content/contentPieceThemeSearch' import { attachContentPieceMedia, receiveContentPieceUpload, @@ -933,4 +934,127 @@ describe('content pieces (C211)', () => { }) expect((await callPublicPieceMedia({ slug: publishedSlug })).status).toBe(404) }) + + it('finds a piece by the expanded theme terms with real evidence (S28)', async () => { + const marker = `tema-${Date.now()}` + const term = `atendimento universal ${marker}` + const match = await createPiece({ + title: `Mutirão no Subúrbio ${marker}`, + transcript: `Garantir o ${term} na rede pública`, + status: 'publicado', + }) + + const expansionCalls: string[] = [] + const data = await loadContentPieceCatalogSearch({ + rawSearchParams: { q: 'defesa do SUS', mode: 'tema' }, + requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), + expandTheme: async (theme) => { + expansionCalls.push(theme) + return { terms: [term] } + }, + }) + + expect(expansionCalls).toEqual(['defesa do SUS']) + expect(data.themeUnavailable).toBe(false) + expect(data.themeApplied).toBe(true) + const item = data.items.find((row) => row.id === match.piece.id) + expect(item?.themeMatch?.term).toBe(term) + expect(item?.themeMatch?.evidence?.quoted).toBe(true) + expect(item?.themeMatch?.evidence?.parts.some((part) => part.highlighted)).toBe(true) + }) + + it('degrades to the literal search when the expansion is unavailable (S28)', async () => { + const marker = `literal-tema-${Date.now()}` + const match = await createPiece({ title: `Debate na rádio ${marker}`, status: 'publicado' }) + + const data = await loadContentPieceCatalogSearch({ + rawSearchParams: { q: marker, mode: 'tema' }, + requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), + expandTheme: async () => null, + }) + + expect(data.themeUnavailable).toBe(true) + expect(data.themeApplied).toBe(false) + const item = data.items.find((row) => row.id === match.piece.id) + expect(item).toBeTruthy() + expect(item?.themeMatch).toBeNull() + }) + + it('keeps the literal list without a notice when the expansion adds nothing (S28)', async () => { + const marker = `vazio-tema-${Date.now()}` + const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' }) + + const data = await loadContentPieceCatalogSearch({ + rawSearchParams: { q: marker, mode: 'tema' }, + requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), + expandTheme: async () => ({ terms: [] }), + }) + + expect(data.themeUnavailable).toBe(false) + expect(data.themeApplied).toBe(false) + expect(data.items.map((row) => row.id)).toEqual([match.piece.id]) + expect(data.items.every((row) => row.themeMatch === null)).toBe(true) + }) + + it('never expands without navigation signals (S28)', async () => { + const marker = `inelegivel-${Date.now()}` + await createPiece({ title: `Peça ${marker}`, status: 'publicado' }) + + let called = false + const data = await loadContentPieceCatalogSearch({ + rawSearchParams: { q: marker, mode: 'tema' }, + requestHeaders: new Headers(), + expandTheme: async () => { + called = true + return { terms: [marker] } + }, + }) + + expect(called).toBe(false) + expect(data.themeUnavailable).toBe(true) + }) + + it('keeps the literal search untouched without the mode (S28)', async () => { + const marker = `exato-${Date.now()}` + const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' }) + + let called = false + const data = await loadContentPieceCatalogSearch({ + rawSearchParams: { q: marker }, + requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), + expandTheme: async () => { + called = true + return { terms: [marker] } + }, + }) + + expect(called).toBe(false) + expect(data.params.mode).toBeNull() + expect(data.themeUnavailable).toBe(false) + expect(data.items.map((row) => row.id)).toEqual([match.piece.id]) + }) + + it('never surfaces a draft in the theme search (S28)', async () => { + const marker = `rascunho-tema-${Date.now()}` + const term = `texto sobre ${marker}` + const draft = await createPiece({ + title: `Rascunho ${marker}`, + transcript: term, + }) + const published = await createPiece({ + title: `Publicada ${marker}`, + transcript: term, + status: 'publicado', + }) + + const data = await loadContentPieceCatalogSearch({ + rawSearchParams: { q: `assunto ${marker}`, mode: 'tema' }, + requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), + expandTheme: async () => ({ terms: [term] }), + }) + + const ids = data.items.map((row) => row.id) + expect(ids).toContain(published.piece.id) + expect(ids).not.toContain(draft.piece.id) + }) }) diff --git a/tests/unit/contentPieceCatalog.unit.spec.ts b/tests/unit/contentPieceCatalog.unit.spec.ts index ca22559b..16982e14 100644 --- a/tests/unit/contentPieceCatalog.unit.spec.ts +++ b/tests/unit/contentPieceCatalog.unit.spec.ts @@ -11,6 +11,8 @@ import { contentPieceMediaPath, contentPiecePublicPath, contentPieceTextExcerpt, + contentPieceThemeMatch, + contentPieceThemeTerms, filterContentPieceCatalogItems, parseContentPieceCatalogParams, toContentPiecePublicItem, @@ -52,6 +54,7 @@ describe('content piece catalog params', () => { tema: 'economia-trabalho', instituicao: 'camara-dos-deputados', q: ' escala ', + mode: 'tema', }), ).toEqual({ tipo: 'video', @@ -60,6 +63,7 @@ describe('content piece catalog params', () => { tema: 'economia-trabalho', instituicao: 'camara-dos-deputados', q: 'escala', + mode: 'tema', }) expect(parseContentPieceCatalogParams({ tipo: 'inexistente', tema: 'nada' })).toEqual({ @@ -69,11 +73,21 @@ describe('content piece catalog params', () => { tema: null, instituicao: null, q: '', + mode: null, }) expect(parseContentPieceCatalogParams({ cidade: '../etc/passwd' }).cidade).toBeNull() expect(parseContentPieceCatalogParams({}).q).toBe('') }) + it('parses the theme mode only with a query and drops unknown modes', () => { + expect(parseContentPieceCatalogParams({ q: 'escala', mode: 'tema' }).mode).toBe('tema') + // `exato` is the default and never serialized; unknown values mean exact too. + expect(parseContentPieceCatalogParams({ q: 'escala', mode: 'exato' }).mode).toBeNull() + expect(parseContentPieceCatalogParams({ q: 'escala', mode: 'semantico' }).mode).toBeNull() + expect(parseContentPieceCatalogParams({ q: ' ', mode: 'tema' }).mode).toBeNull() + expect(parseContentPieceCatalogParams({ mode: 'tema' }).mode).toBeNull() + }) + it('builds the canonical href with a fixed order and no empty params', () => { expect(buildContentPieceCatalogHref({})).toBe('/conteudos') expect(buildContentPieceCatalogHref({ q: ' ' })).toBe('/conteudos') @@ -88,6 +102,11 @@ describe('content piece catalog params', () => { expect(buildContentPieceCatalogHref({ tipo: 'video', tema: null, q: '' })).toBe( '/conteudos?tipo=video', ) + expect(buildContentPieceCatalogHref({ q: 'escala', mode: 'tema' })).toBe( + '/conteudos?q=escala&mode=tema', + ) + // A mode without a query has nothing to expand and is not serialized. + expect(buildContentPieceCatalogHref({ q: '', mode: 'tema' })).toBe('/conteudos') }) it('lists the active filters with their removal hrefs', () => { @@ -178,6 +197,88 @@ describe('content piece catalog filtering', () => { expect(idsFor({ tipo: 'video', cidade: 'feira-de-santana' })).toEqual([]) expect(idsFor({ tipo: 'video', q: 'escala' })).toEqual([1]) }) + + it('ORs the literal query with the expanded theme terms, facets still AND', () => { + const idsForTerms = (raw: Record, terms: string[]) => + filterContentPieceCatalogItems(items, parseContentPieceCatalogParams(raw), terms).map( + (row) => row.id, + ) + + // No terms is byte-identical to the literal search. + expect(idsForTerms({ q: 'escala' }, [])).toEqual([1]) + // The expanded term surfaces a piece the query alone would not. + expect(idsForTerms({ q: 'giro' }, ['card'])).toEqual([2]) + expect(idsForTerms({ q: 'zzz' }, ['card', 'giro'])).toEqual([2]) + // Facets keep narrowing the OR result. + expect(idsForTerms({ q: 'zzz', tipo: 'video' }, ['card'])).toEqual([]) + expect(idsForTerms({ q: 'zzz' }, ['ESCALA'])).toEqual([1]) + }) +}) + +describe('content piece theme terms and provenance (S28)', () => { + it('drops empty terms, duplicates and the literal query itself', () => { + expect(contentPieceThemeTerms('escala', ['', ' ', 'Escala', 'fim da escala 6x1'])).toEqual([ + 'fim da escala 6x1', + ]) + expect(contentPieceThemeTerms('escala', ['FIM DA ESCALA', 'fim da escala'])).toEqual([ + 'FIM DA ESCALA', + ]) + }) + + it('quotes the transcript window that carries the term', () => { + const match = contentPieceThemeMatch(source({ searchText: 'a reducao da jornada e saude' }), [ + 'redução da jornada', + ]) + + expect(match?.term).toBe('redução da jornada') + expect(match?.evidence?.quoted).toBe(true) + expect(match?.evidence?.source).toBe('transcript') + expect(match?.evidence?.parts.some((part) => part.highlighted)).toBe(true) + }) + + it('falls back to the description window when the transcript does not carry it', () => { + const match = contentPieceThemeMatch( + source({ + transcript: null, + description: 'Solla explica a jornada de trabalho.', + searchText: 'jornada de trabalho', + }), + ['jornada de trabalho'], + ) + + expect(match?.evidence?.source).toBe('description') + expect(match?.evidence?.quoted).toBe(true) + }) + + it('shows a real passage without highlight when the match came from elsewhere', () => { + const match = contentPieceThemeMatch( + source({ searchText: 'camara dos deputados saude', description: 'Solla explica a jornada.' }), + ['camara dos deputados'], + ) + + expect(match?.term).toBe('camara dos deputados') + expect(match?.evidence?.quoted).toBe(false) + expect(match?.evidence?.source).toBe('description') + expect(match?.evidence?.parts).toEqual([ + { text: 'Solla explica a jornada.', highlighted: false }, + ]) + }) + + it('never claims a term the haystack does not carry', () => { + expect(contentPieceThemeMatch(source(), ['escola sem partido'])).toBeNull() + expect(contentPieceThemeMatch(source(), [])).toBeNull() + }) + + it('annotates the public item only when the theme mode passes terms', () => { + const themed = source({ searchText: 'a reducao da jornada e saude' }) + const item = toContentPiecePublicItem(themed, { themeTerms: ['redução da jornada'] }) + expect(item?.themeMatch?.term).toBe('redução da jornada') + + const literal = toContentPiecePublicItem(themed) + expect(literal?.themeMatch).toBeNull() + // The transcript itself never reaches the view model. + expect(literal && 'transcript' in literal).toBe(false) + }) }) describe('content piece public view model', () => { diff --git a/tests/unit/expandSpeechSearchTheme.unit.spec.ts b/tests/unit/expandSpeechSearchTheme.unit.spec.ts index 34006066..95193d58 100644 --- a/tests/unit/expandSpeechSearchTheme.unit.spec.ts +++ b/tests/unit/expandSpeechSearchTheme.unit.spec.ts @@ -10,7 +10,11 @@ const { generateObjectMock, deepSeekMock } = vi.hoisted(() => ({ vi.mock('ai', () => ({ generateObject: generateObjectMock })) vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: deepSeekMock })) -import { expandSpeechSearchTheme } from '@/utilities/ai/expandSpeechSearchTheme' +import { + expandContentPieceSearchTheme, + expandSearchTheme, + expandSpeechSearchTheme, +} from '@/utilities/ai/expandSpeechSearchTheme' beforeEach(() => { process.env.DEEPSEEK_API_KEY = 'test-deepseek-key' @@ -67,3 +71,31 @@ describe('expandSpeechSearchTheme (C192)', () => { expect(call.abortSignal).toBeInstanceOf(AbortSignal) }) }) + +describe('expandSearchTheme (S28 — corpus)', () => { + it('uses the public Central prompt for contentPiece and keeps the acervo prompt', async () => { + generateObjectMock.mockResolvedValue({ object: { terms: ['escala 6x1'] } }) + + await expandContentPieceSearchTheme('fim da escala') + await expandSpeechSearchTheme('defesa do SUS') + + const publicCall = generateObjectMock.mock.calls[0]![0] as { system: string } + const speechCall = generateObjectMock.mock.calls[1]![0] as { system: string } + expect(publicCall.system).toContain('Central de Conteúdos') + expect(publicCall.system).not.toContain('acervo de discursos') + expect(speechCall.system).toContain('acervo de discursos') + expect(speechCall.system).not.toContain('Central de Conteúdos') + }) + + it('shares the sanitization and the failure contract with the acervo', async () => { + generateObjectMock.mockResolvedValue({ object: { terms: ['SUS%', 'saúde pública', 'sus'] } }) + + await expect(expandSearchTheme('defesa do SUS', 'contentPiece')).resolves.toEqual({ + terms: ['SUS', 'saúde pública'], + }) + + delete process.env.DEEPSEEK_API_KEY + await expect(expandSearchTheme('defesa do SUS', 'contentPiece')).resolves.toBeNull() + expect(generateObjectMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/themeSearchGuard.unit.spec.ts b/tests/unit/themeSearchGuard.unit.spec.ts new file mode 100644 index 00000000..0a83c66b --- /dev/null +++ b/tests/unit/themeSearchGuard.unit.spec.ts @@ -0,0 +1,164 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest' + +import { + createThemeSearchGuard, + isThemeSearchRequestEligible, + themeSearchClientKey, +} from '@/utilities/ai/themeSearchGuard' + +const navigation = (extra: Record = {}) => + new Headers({ 'sec-fetch-mode': 'navigate', ...extra }) + +const termsResolver = (terms: string[]) => vi.fn(async () => ({ terms })) + +describe('theme search eligibility (S28)', () => { + it('accepts the document navigation and the router RSC fetch', () => { + expect(isThemeSearchRequestEligible(navigation())).toBe(true) + // A client-side navigation arrives as `cors` (Next consumes the RSC header + // before userland, so it cannot be the signal). + expect(isThemeSearchRequestEligible(new Headers({ 'sec-fetch-mode': 'cors' }))).toBe(true) + + expect(isThemeSearchRequestEligible(new Headers())).toBe(false) + expect(isThemeSearchRequestEligible(new Headers({ 'user-agent': 'curl/8' }))).toBe(false) + // A foreign ``/`` must never trigger an expansion. + expect(isThemeSearchRequestEligible(new Headers({ 'sec-fetch-mode': 'no-cors' }))).toBe(false) + expect(isThemeSearchRequestEligible(new Headers({ 'sec-fetch-mode': 'same-origin' }))).toBe( + false, + ) + }) + + it('refuses an explicit Next prefetch header when the platform exposes it', () => { + expect(isThemeSearchRequestEligible(navigation({ 'next-router-prefetch': '1' }))).toBe(false) + }) +}) + +describe('theme search client key (S28)', () => { + it('prefers the edge IP header and falls back to the proxy headers', () => { + expect( + themeSearchClientKey( + new Headers({ + 'cf-connecting-ip': '203.0.113.7', + 'x-forwarded-for': '198.51.100.9, 10.0.0.1', + 'x-real-ip': '10.0.0.2', + }), + ), + ).toBe('203.0.113.7') + expect(themeSearchClientKey(new Headers({ 'x-forwarded-for': '198.51.100.9, 10.0.0.1' }))).toBe( + '198.51.100.9', + ) + expect(themeSearchClientKey(new Headers({ 'x-real-ip': '10.0.0.2' }))).toBe('10.0.0.2') + }) + + it('shares the fail-closed bucket when no IP header exists', () => { + expect(themeSearchClientKey(new Headers())).toBe('unknown') + }) +}) + +describe('theme search guard (S28)', () => { + it('degrades without calling the resolver when the request is ineligible', async () => { + const guard = createThemeSearchGuard() + const expandTheme = termsResolver(['saúde']) + + await expect( + guard.resolve({ q: 'SUS', headers: new Headers(), expandTheme }), + ).resolves.toBeNull() + expect(expandTheme).not.toHaveBeenCalled() + }) + + it('does not spend the quota on an ineligible request', async () => { + const guard = createThemeSearchGuard({ max: 1 }) + const expandTheme = termsResolver(['saúde']) + + await guard.resolve({ q: 'bot', headers: new Headers(), expandTheme }) + await expect(guard.resolve({ q: 'SUS', headers: navigation(), expandTheme })).resolves.toEqual({ + terms: ['saúde'], + }) + }) + + it('expands once and serves the same normalized term from the cache', async () => { + const guard = createThemeSearchGuard() + const expandTheme = termsResolver(['saúde pública']) + + await expect(guard.resolve({ q: 'SUS', headers: navigation(), expandTheme })).resolves.toEqual({ + terms: ['saúde pública'], + }) + await expect( + guard.resolve({ q: ' sus ', headers: navigation(), expandTheme }), + ).resolves.toEqual({ terms: ['saúde pública'] }) + expect(expandTheme).toHaveBeenCalledTimes(1) + }) + + it('does not consume quota on a cache hit', async () => { + const guard = createThemeSearchGuard({ max: 1 }) + const expandTheme = termsResolver(['saúde']) + + await guard.resolve({ q: 'SUS', headers: navigation(), expandTheme }) + // The quota is spent; the cached term still resolves. + await expect(guard.resolve({ q: 'SUS', headers: navigation(), expandTheme })).resolves.toEqual({ + terms: ['saúde'], + }) + // A fresh term is refused. + await expect( + guard.resolve({ q: 'escola', headers: navigation(), expandTheme }), + ).resolves.toBeNull() + }) + + it('caches a legitimate empty list and never caches unavailability', async () => { + const guard = createThemeSearchGuard() + const empty = termsResolver([]) + const unavailable = vi.fn(async () => null) + + await expect( + guard.resolve({ q: 'nada', headers: navigation(), expandTheme: empty }), + ).resolves.toEqual({ terms: [] }) + await guard.resolve({ q: 'nada', headers: navigation(), expandTheme: empty }) + expect(empty).toHaveBeenCalledTimes(1) + + await expect( + guard.resolve({ q: 'falha', headers: navigation(), expandTheme: unavailable }), + ).resolves.toBeNull() + await guard.resolve({ q: 'falha', headers: navigation(), expandTheme: unavailable }) + expect(unavailable).toHaveBeenCalledTimes(2) + }) + + it('limits the expansions per client and resets after the window', async () => { + let now = 1_000 + const guard = createThemeSearchGuard({ max: 2, windowMs: 60_000, now: () => now }) + const expandTheme = termsResolver(['saúde']) + + await guard.resolve({ q: 'um', headers: navigation(), expandTheme }) + await guard.resolve({ q: 'dois', headers: navigation(), expandTheme }) + await expect( + guard.resolve({ q: 'tres', headers: navigation(), expandTheme }), + ).resolves.toBeNull() + expect(expandTheme).toHaveBeenCalledTimes(2) + + now += 60_000 + await expect(guard.resolve({ q: 'tres', headers: navigation(), expandTheme })).resolves.toEqual( + { terms: ['saúde'] }, + ) + }) + + it('expires the cached expansion after the TTL', async () => { + let now = 1_000 + const guard = createThemeSearchGuard({ ttlMs: 30_000, now: () => now }) + const expandTheme = termsResolver(['saúde']) + + await guard.resolve({ q: 'SUS', headers: navigation(), expandTheme }) + now += 30_000 + await guard.resolve({ q: 'SUS', headers: navigation(), expandTheme }) + expect(expandTheme).toHaveBeenCalledTimes(2) + }) + + it('caps the cache and evicts the oldest term', async () => { + const guard = createThemeSearchGuard({ maxEntries: 1 }) + const expandTheme = termsResolver(['saúde']) + + await guard.resolve({ q: 'um', headers: navigation(), expandTheme }) + await guard.resolve({ q: 'dois', headers: navigation(), expandTheme }) + await guard.resolve({ q: 'um', headers: navigation(), expandTheme }) + expect(expandTheme).toHaveBeenCalledTimes(3) + }) +})