mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S28): busca por tema na Central de Conteúdos pública
This commit is contained in:
parent
f47c2645ac
commit
8d81883509
21 changed files with 1858 additions and 128 deletions
1
docs/changelog/2026-09-23-s28.md
Normal file
1
docs/changelog/2026-09-23-s28.md
Normal 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.
|
||||
218
docs/plans/central-conteudos-busca-semantica-impl.md
Normal file
218
docs/plans/central-conteudos-busca-semantica-impl.md
Normal 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).
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -166,6 +166,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',
|
||||
|
|
@ -375,8 +377,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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
82
src/components/conteudos/ContentPieceSearchMode.tsx
Normal file
82
src/components/conteudos/ContentPieceSearchMode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
168
src/utilities/ai/themeSearchGuard.ts
Normal file
168
src/utilities/ai/themeSearchGuard.ts
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
99
src/utilities/content/contentPieceThemeSearch.ts
Normal file
99
src/utilities/content/contentPieceThemeSearch.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -269,6 +269,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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
164
tests/unit/themeSearchGuard.unit.spec.ts
Normal file
164
tests/unit/themeSearchGuard.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue