feat(S28): busca por tema na Central de Conteúdos pública

This commit is contained in:
Francisco Solla 2026-09-23 13:43:48 -03:00
parent 75e5825b05
commit 159179e09d
21 changed files with 1858 additions and 128 deletions

View file

@ -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.

View file

@ -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 `<link rel=prefetch>`/`<img>` 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=<marker>&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=<marker>` 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).

View file

@ -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.
</p>
<div class="mt-5 rounded-xl border border-[#184e92]/20 bg-[#eef4fb] p-4 text-sm leading-6">
<b class="text-[#184e92]">Revisão aditiva · trigger (a) do work-issue</b>
<p class="mt-1 text-[#514945]">
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.
</p>
</div>
</header>
<section>
@ -278,6 +317,302 @@
</article>
</div>
</section>
<section>
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 05</span>
<h2>Desktop 1280 · resultado por tema × resultado só pelo termo exato</h2>
</div>
<div class="desktop frame mx-auto overflow-hidden rounded-xl bg-white p-10">
<div class="flex items-end justify-between gap-6">
<div>
<p class="text-xs font-bold tracking-[0.08em] text-[#184e92] uppercase">
Mesmo modo · proveniências diferentes
</p>
<h3 class="display mt-1 text-2xl font-black">
Resultados para “saúde do trabalhador”
</h3>
</div>
<span class="text-xs text-[#6c615e]">Por tema ativo</span>
</div>
<div class="mt-6 grid grid-cols-2 gap-5">
<article class="card grid min-h-[540px] grid-rows-[190px_1fr]">
<div class="asset">NEEDS ASSET · POSTER REAL</div>
<div class="flex flex-col p-5">
<div>
<span
class="inline-flex rounded-full bg-[#eef4fb] px-2 py-1 text-xs font-bold text-[#184e92]"
>✦ Tema</span
>
<h4 class="mt-3 text-lg font-extrabold">Reduzir a jornada também é cuidar</h4>
<p class="mt-1 text-xs text-[#6c615e]">Vídeo · Trabalho · Salvador</p>
<div class="mt-4 rounded-lg bg-[#f5f5f4] p-4">
<b class="text-xs">Por que apareceu</b>
<p class="mt-1 text-sm leading-6">
“…reduzir a jornada protege a
<span class="evidence-mark">saúde de quem trabalha</span>…”
</p>
</div>
</div>
<a class="mt-auto pt-5 text-sm font-extrabold text-[#a21c1c]">Abrir peça →</a>
</div>
</article>
<article class="card grid min-h-[540px] grid-rows-[190px_1fr]">
<div class="asset">NEEDS ASSET · POSTER REAL</div>
<div class="flex flex-col p-5">
<div>
<span
class="inline-flex rounded-full border border-black/12 bg-white px-2 py-1 text-xs font-bold text-[#514945]"
>Termo exato</span
>
<h4 class="mt-3 text-lg font-extrabold">Saúde do trabalhador em debate</h4>
<p class="mt-1 text-xs text-[#6c615e]">Card · Saúde · Salvador</p>
</div>
<a class="mt-auto pt-5 text-sm font-extrabold text-[#a21c1c]">Abrir peça →</a>
</div>
</article>
</div>
<div class="mt-5 grid grid-cols-2 gap-5 text-xs leading-5 text-[#6c615e]">
<p>
<b class="text-black">Com casamento temático:</b> selo azul + bloco de evidência real.
</p>
<p>
<b class="text-black">Só casou o q:</b> 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.
</p>
</div>
</div>
</section>
<section>
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 06</span>
<h2>Evidência temática sem trecho correspondente · texto real, sem falsa citação</h2>
</div>
<div class="grid gap-8 lg:grid-cols-[1fr_390px]">
<article class="frame rounded-xl bg-white p-8">
<div class="max-w-2xl">
<span class="rounded-full bg-[#eef4fb] px-2 py-1 text-xs font-bold text-[#184e92]"
>✦ Tema</span
>
<h3 class="mt-3 text-xl font-extrabold">Mutirão leva atendimento ao Subúrbio</h3>
<p class="mt-1 text-xs text-[#6c615e]">Vídeo · Saúde · Salvador</p>
<div class="mt-5 rounded-lg bg-[#f5f5f4] p-4">
<div class="flex items-baseline justify-between gap-4">
<b class="text-xs">Por que apareceu</b>
<span class="text-[11px] font-bold text-[#6c615e]">Descrição da peça</span>
</div>
<p class="mt-2 text-sm leading-6">
Atendimento perto de casa, com equipes reunidas para ampliar o acesso da população
aos serviços públicos.
</p>
</div>
</div>
</article>
<aside class="frame rounded-xl bg-[#fff8f2] p-6 text-sm leading-6">
<b class="text-[#a21c1c]">Regra visual deste fallback</b>
<ul class="mt-3 list-disc space-y-2 pl-5 text-[#514945]">
<li>mantém “✦ Tema” e “Por que apareceu”;</li>
<li>usa trecho real da descrição ou do resumo;</li>
<li><b>sem aspas</b>, porque não é uma janela de frase casada;</li>
<li>sem marca-texto, negrito ou termo sugerido pelo modelo;</li>
<li>“Descrição da peça” explicita a origem sem prometer casamento textual.</li>
</ul>
</aside>
</div>
</section>
<section>
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 07</span>
<h2>Expansão concluída sem termos · lista literal, sem alarme</h2>
</div>
<div class="desktop frame mx-auto overflow-hidden rounded-xl bg-white p-10">
<div class="grid grid-cols-[1fr_300px] gap-3 rounded-xl bg-[#fff8f2] p-4">
<input class="field" value="saúde" aria-label="Buscar peças" />
<div>
<span class="mb-1 block text-xs font-bold text-[#6c615e]">Buscar por:</span>
<div class="seg">
<span class="seg-option">Termo exato</span
><span class="seg-option active">✦ Por tema</span>
</div>
</div>
<div class="col-span-2 flex flex-wrap gap-2">
<span class="chip">Tipo⌄</span><span class="chip">Cidade⌄</span
><span class="chip">Região⌄</span><span class="chip">Tema⌄</span
><span class="chip">Instituição⌄</span>
</div>
</div>
<div class="mt-7 grid grid-cols-3 gap-4">
<article class="card p-4">
<span
class="rounded-full border border-black/12 px-2 py-1 text-xs font-bold text-[#514945]"
>Termo exato</span
>
<h3 class="mt-3 font-extrabold">Saúde pública perto de quem precisa</h3>
</article>
<article class="card p-4">
<span
class="rounded-full border border-black/12 px-2 py-1 text-xs font-bold text-[#514945]"
>Termo exato</span
>
<h3 class="mt-3 font-extrabold">Defender a saúde é cuidar da Bahia</h3>
</article>
<article class="card p-4">
<span
class="rounded-full border border-black/12 px-2 py-1 text-xs font-bold text-[#514945]"
>Termo exato</span
>
<h3 class="mt-3 font-extrabold">Mais recursos para a saúde</h3>
</article>
</div>
</div>
<p
class="desktop mx-auto mt-3 rounded-lg border border-dashed border-[#184e92]/35 bg-[#eef4fb] p-4 text-sm leading-6"
>
<b>NOTA DO DESIGN · não é microcopy da tela:</b> “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.
</p>
</section>
<section>
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 08</span>
<h2>Bloco canônico de busca · desktop 1280 e mobile 390</h2>
</div>
<div class="grid items-start gap-8 xl:grid-cols-[1fr_390px]">
<article class="frame rounded-xl bg-white p-8">
<p class="text-xs font-bold tracking-[0.08em] text-[#184e92] uppercase">Desktop</p>
<div class="mt-4 grid grid-cols-[1fr_300px] gap-3 rounded-xl bg-[#fff8f2] p-4">
<input class="field self-end" value="fim da escala 6x1" aria-label="Buscar peças" />
<div>
<span class="mb-1 block text-xs font-bold text-[#6c615e]">Buscar por:</span>
<div class="seg">
<span class="seg-option">Termo exato</span
><span class="seg-option active">✦ Por tema</span>
</div>
</div>
<div class="col-span-2 flex flex-wrap gap-2">
<span class="chip">Tipo⌄</span><span class="chip">Cidade⌄</span
><span class="chip">Região⌄</span><span class="chip">Tema⌄</span
><span class="chip">Instituição⌄</span>
</div>
</div>
<p class="mt-4 text-xs leading-5 text-[#6c615e]">
Grade canônica: <b>1fr + 300px</b>. 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.
</p>
</article>
<article class="phone frame overflow-hidden bg-white p-4">
<p class="text-xs font-bold tracking-[0.08em] text-[#184e92] uppercase">Mobile</p>
<div class="mt-3 rounded-xl bg-[#fff8f2] p-3">
<input class="field" value="fim da escala 6x1" aria-label="Buscar peças" />
<span class="mt-3 block text-xs font-bold text-[#6c615e]">Buscar por:</span>
<div class="seg mt-1">
<span class="seg-option">Termo exato</span
><span class="seg-option active">✦ Por tema</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<span class="chip">Tipo⌄</span><span class="chip">Cidade⌄</span
><span class="chip">Região⌄</span><span class="chip">Tema⌄</span
><span class="chip">Instituição⌄</span>
</div>
</div>
<p class="mt-4 text-xs leading-5 text-[#6c615e]">
Uma coluna; nenhuma faceta é substituída por “Mais filtros”. Sem botão de submit.
</p>
</article>
</div>
</section>
<section>
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 09</span>
<h2>Segmented · hover, foco, ativo e indisponível</h2>
</div>
<div class="frame rounded-xl bg-white p-8">
<div class="grid gap-6 lg:grid-cols-4">
<div>
<p class="mb-2 text-xs font-bold text-[#6c615e]">HOVER · opção disponível</p>
<div class="seg">
<span class="seg-option active">Termo exato</span
><span class="seg-option is-hover">✦ Por tema</span>
</div>
</div>
<div>
<p class="mb-2 text-xs font-bold text-[#6c615e]">FOCO VISÍVEL · teclado</p>
<div class="seg">
<span class="seg-option">Termo exato</span
><span class="seg-option is-focus">✦ Por tema</span>
</div>
</div>
<div>
<p class="mb-2 text-xs font-bold text-[#6c615e]">ATIVO</p>
<div class="seg">
<span class="seg-option">Termo exato</span
><span class="seg-option active">✦ Por tema</span>
</div>
</div>
<div>
<p class="mb-2 text-xs font-bold text-[#6c615e]">INDISPONÍVEL · degradação</p>
<div class="seg">
<span class="seg-option active !text-black">Termo exato</span
><span class="seg-option is-disabled">⚠ Por tema</span>
</div>
</div>
</div>
<div
class="mt-7 grid gap-5 border-t border-black/10 pt-6 text-sm leading-6 md:grid-cols-2"
>
<div>
<b>Interação e acessibilidade</b>
<p class="mt-1 text-[#514945]">
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.
</p>
</div>
<div>
<b>Degradação consolidada</b>
<p class="mt-1 text-[#514945]">
O aviso âmbar da cena 04 permanece. “Termo exato” fica ativo; “⚠ Por tema” fica
desabilitado, sem ação.
<b>Não há botão “Ver resultados exatos” no bloco canônico</b>: a lista exata já está
presente. O retry, quando oferecido, vive no aviso, não ocupa o lugar do submit
removido.
</p>
</div>
</div>
</div>
</section>
<section>
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>REGISTRO DA REVISÃO</span>
<h2>Leituras do mock e loading</h2>
</div>
<div class="frame grid gap-6 rounded-xl bg-[#181514] p-7 text-white md:grid-cols-2">
<div>
<b class="text-[#ffeb00]">Leitura confirmada</b>
<p class="mt-2 text-sm leading-6 text-white/80">
“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.
</p>
</div>
<div>
<b class="text-[#ffeb00]">Loading confirmado</b>
<p class="mt-2 text-sm leading-6 text-white/80">
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.
</p>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -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

View file

@ -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<Metadata> {
const [items, globalMetadata] = await Promise.all([
@ -81,13 +82,23 @@ export default async function ConteudosPage({
}: {
searchParams: Promise<ContentPieceCatalogSearchParams>
}) {
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 (
<>
<ContentPiecePageHeader />
@ -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({
<ContentPieceHero />
<section className="mx-auto w-full max-w-6xl px-5 py-8 sm:px-8 sm:py-10">
<ContentPieceFilters params={params} facets={facets} activeFilters={activeFilters} />
{themeUnavailable ? (
<div className="mb-4">
<ContentPieceThemeFallbackNotice retryHref={buildContentPieceCatalogHref(params)} />
</div>
) : null}
<ContentPieceFilters
params={params}
facets={facets}
activeFilters={activeFilters}
themeUnavailable={themeUnavailable}
/>
<div className="mt-9">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black tracking-[-0.02em] sm:text-2xl">
Escolha, compartilhe e peça voto
{themeMode ? `Resultados para “${params.q}”` : 'Escolha, compartilhe e peça voto'}
</h2>
<p className="mt-1 text-sm text-(--campaign-muted)">
O play só carrega a mídia quando você pedir.
</p>
</div>
<span className="hidden text-sm text-(--campaign-muted) sm:inline">
Abra o título para ver detalhes
</span>
{themeMode ? (
themeApplied ? (
<span className="text-sm text-(--campaign-muted)">Por tema ativo</span>
) : null
) : (
<span className="hidden text-sm text-(--campaign-muted) sm:inline">
Abra o título para ver detalhes
</span>
)}
</div>
<div className="mt-5">
{filteredItems.length > 0 ? (
<ContentPieceCatalog items={filteredItems} showCardInvite={!hasActiveFilters} />
{items.length > 0 ? (
<ContentPieceCatalog
items={items}
showCardInvite={!hasActiveFilters}
themeMode={themeMode}
/>
) : themeMode && !themeUnavailable ? (
<ContentPieceThemeNoResults params={params} />
) : (
<ContentPieceNoResults activeFilters={activeFilters} />
)}

View file

@ -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 }) => (
<span className={CONTENT_PIECE_TAG}>{item.isLink ? item.originLabel : item.typeLabel}</span>
)
/**
* 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 ? (
<span className={CONTENT_PIECE_TAG}>✦ Tema</span>
) : (
<span className={CONTENT_PIECE_EXACT_TAG}>Termo exato</span>
)
}
return (
<span className={CONTENT_PIECE_TAG}>{item.isLink ? item.originLabel : item.typeLabel}</span>
)
}
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 (
<div className="mt-3 rounded-lg bg-[#f5f5f4] p-3">
<div className="flex items-baseline justify-between gap-3">
<b className="text-xs">Por que apareceu</b>
{evidence.quoted ? null : (
<span className="text-[11px] font-bold text-(--campaign-muted)">{originLabel}</span>
)}
</div>
<p className="mt-1 text-sm leading-6">
{evidence.quoted ? `“${evidence.truncatedStart ? '… ' : ''}` : null}
{evidence.parts.map((part, index) =>
part.highlighted ? (
<mark key={index} className={EVIDENCE_MARK}>
{part.text}
</mark>
) : (
<span key={index}>{part.text}</span>
),
)}
{evidence.quoted ? `${evidence.truncatedEnd ? ' …' : ''}”` : null}
</p>
</div>
)
}
const CardTitle = ({ item, className }: { item: ContentPiecePublicItem; className?: string }) => (
<h3 className={cn('mt-2 font-[family-name:var(--font-exo2)] font-extrabold', className)}>
@ -34,8 +97,8 @@ const CardMeta = ({ item }: { item: ContentPiecePublicItem }) =>
item.metaLabel ? <p className="mt-1 text-xs text-(--campaign-muted)">{item.metaLabel}</p> : 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"
/>
<div className="min-w-0 flex-1">
<CardTag item={item} />
<CardTag item={item} themeMode={themeMode} />
<CardTitle item={item} className="text-sm" />
<CardMeta item={item} />
<CardThemeEvidence item={item} />
<div className="mt-3 flex flex-wrap gap-2">
<ContentPieceDownloadLink item={item} className="min-h-9 px-3" />
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-9 px-3" />
@ -91,9 +156,10 @@ export const ContentPieceCard = ({
className="aspect-video"
/>
<div className="p-4">
<CardTag item={item} />
<CardTag item={item} themeMode={themeMode} />
<CardTitle item={item} className="text-base" />
<CardMeta item={item} />
<CardThemeEvidence item={item} />
<div className={cn('mt-4 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={onShare} />

View file

@ -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<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPiecePublicItem | null>(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 (

View file

@ -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 `<details>` 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 `<details>` 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,
)}
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<div className="relative">
<div className="grid gap-3 rounded-xl bg-(--campaign-cream) p-4 sm:grid-cols-[1fr_300px]">
<div className="relative self-end">
<input
type="search"
name="q"
@ -73,52 +79,52 @@ export const ContentPieceFilters = ({
</Link>
) : null}
</div>
<button type="submit" className={`${CONTENT_PIECE_PRIMARY_BUTTON} px-6`}>
{hasActiveFilters ? 'Buscar peças' : 'Escolher uma peça e pedir voto'}
</button>
</div>
<ContentPieceSearchMode mode={params.mode} themeUnavailable={themeUnavailable} />
<div className="mt-3 flex flex-wrap items-center gap-2">
{facetFilters.map((filter) => (
<Link
key={filter.facet}
href={filter.removeHref}
aria-label={`Remover filtro ${filter.label}: ${filter.value}`}
className={CONTENT_PIECE_ACTIVE_CHIP}
>
{filter.label} · {filter.value}
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</Link>
))}
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
{facetFilters.map((filter) => (
<Link
key={filter.facet}
href={filter.removeHref}
prefetch={themeMode ? false : undefined}
aria-label={`Remover filtro ${filter.label}: ${filter.value}`}
className={CONTENT_PIECE_ACTIVE_CHIP}
>
{filter.label} · {filter.value}
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</Link>
))}
{CONTENT_PIECE_CATALOG_FACETS.map((facet) =>
params[facet] || facets[facet].length === 0 ? null : (
<details key={facet} className="relative">
<summary
className={`${CONTENT_PIECE_CHIP} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
>
{contentPieceCatalogFacetLabels[facet]}
<span aria-hidden="true" className="text-base leading-none">
⌄
</span>
</summary>
<ul className="absolute z-20 mt-1 max-h-72 min-w-44 list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1 shadow-[0_12px_30px_rgb(0_0_0/15%)]">
{facets[facet].map((option) => (
<li key={option.value}>
<Link
href={buildContentPieceCatalogHref({ ...params, [facet]: option.value })}
className={DROPDOWN_LINK}
>
{option.label}
</Link>
</li>
))}
</ul>
</details>
),
)}
{CONTENT_PIECE_CATALOG_FACETS.map((facet) =>
params[facet] || facets[facet].length === 0 ? null : (
<details key={facet} className="relative">
<summary
className={`${CONTENT_PIECE_CHIP} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
>
{contentPieceCatalogFacetLabels[facet]}
<span aria-hidden="true" className="text-base leading-none">
⌄
</span>
</summary>
<ul className="absolute z-20 mt-1 max-h-72 min-w-44 list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1 shadow-[0_12px_30px_rgb(0_0_0/15%)]">
{facets[facet].map((option) => (
<li key={option.value}>
<Link
href={buildContentPieceCatalogHref({ ...params, [facet]: option.value })}
prefetch={themeMode ? false : undefined}
className={DROPDOWN_LINK}
>
{option.label}
</Link>
</li>
))}
</ul>
</details>
),
)}
</div>
</div>
</form>
)

View file

@ -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<HTMLInputElement>) => {
event.currentTarget.form?.requestSubmit()
}
return (
<fieldset className="min-w-0">
<legend className="mb-1 text-xs font-bold text-(--campaign-muted)">Buscar por:</legend>
<div className="grid grid-cols-2 rounded-[10px] bg-[#ebe9e9] p-1">
<label className={cn(MODE_OPTION, MODE_OPTION_ENABLED)}>
<input
type="radio"
name="mode"
value="exato"
defaultChecked={!themeChecked}
onChange={submitOnChange}
className={cn(MODE_INPUT, MODE_INPUT_ENABLED)}
/>
<span>Termo exato</span>
</label>
<label
className={cn(MODE_OPTION, themeUnavailable ? MODE_OPTION_DISABLED : MODE_OPTION_ENABLED)}
>
<input
type="radio"
name="mode"
value="tema"
defaultChecked={themeChecked}
disabled={themeUnavailable}
onChange={submitOnChange}
className={cn(MODE_INPUT, themeUnavailable ? MODE_INPUT_DISABLED : MODE_INPUT_ENABLED)}
/>
<span>{themeUnavailable ? '⚠ Por tema' : '✦ Por tema'}</span>
</label>
</div>
</fieldset>
)
}

View file

@ -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 = ({
</Link>
</section>
)
/**
* 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 }) => (
<section className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14">
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
Nenhuma peça combina bem com esse tema
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
Não mostramos um resultado fraco só para preencher a tela.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<Link
href={buildContentPieceCatalogHref({ ...params, q: '', mode: null })}
className={`${CONTENT_PIECE_PRIMARY_BUTTON} sm:min-w-40`}
>
Reformular busca
</Link>
<Link
href={buildContentPieceCatalogHref({ ...params, mode: null })}
className={CONTENT_PIECE_OUTLINE_BUTTON}
>
Usar termo exato
</Link>
<Link href={CONTENT_PIECE_CATALOG_PATH} className={CONTENT_PIECE_OUTLINE_BUTTON}>
Limpar filtros
</Link>
</div>
</section>
)
/**
* 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 }) => (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm leading-5 text-amber-900">
<b>Busca por tema indisponível agora.</b>
<br />A busca exata continua funcionando.{' '}
<Link
href={retryHref}
prefetch={false}
className="font-bold text-amber-950 underline underline-offset-2 focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)"
>
Tentar por tema novamente
</Link>
</div>
)

View file

@ -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',

View file

@ -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<ContentPieceCatalogFacet, st
instituicao: 'Instituição',
}
/**
* S28 — search mode of the public catalogue. `tema` asks the theme expansion
* for related terms; the literal search is the default and is never serialized
* (existing deep links stay byte-identical). Only meaningful alongside `q`.
*/
export type ContentPieceCatalogMode = 'tema'
export type ContentPieceCatalogParams = {
tipo: ContentPieceType | null
cidade: string | null
@ -60,6 +68,7 @@ export type ContentPieceCatalogParams = {
tema: SpeechTopic | null
instituicao: string | null
q: string
mode: ContentPieceCatalogMode | null
}
export type ContentPieceCatalogSearchParams = Record<string, string | string[] | undefined>
@ -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<string>()
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
? {

View file

@ -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<SpeechThemeExpansion | null>
export type ThemeSearchExpansionResolver = (theme: string) => Promise<ThemeSearchExpansion | null>
/** 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<ThemeSearchExpansion | null> => {
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')

View file

@ -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<Headers, 'get'>
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 `<link rel=prefetch>`
* or an `<img>` 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<string, ThemeSearchWindow>()
const cache = new Map<string, ThemeSearchCacheEntry>()
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

View file

@ -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<ContentPiecePublicItem[]> => {
/**
* 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<ContentPiecePublicSource[]> => {
const { docs } = await getCachedPublishedContentPieces()()
return docs
}
/** Published pieces as serializable public view models, newest first. */
export const getPublishedContentPieceItems = async (): Promise<ContentPiecePublicItem[]> =>
(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

View file

@ -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<ReturnType<typeof getPublishedContentPieceRecords>>,
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<ContentPieceCatalogSearchData> => {
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,
}
}

View file

@ -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)

View file

@ -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)
})
})

View file

@ -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<string, string>, 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', () => {

View file

@ -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)
})
})

View file

@ -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<string, string> = {}) =>
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 `<link rel=prefetch>`/`<img>` 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)
})
})