Modo "Por tema" no acervo de falas: expansão determinística do tema via DeepSeek (generateObject, timeout, fallback null) alimenta o motor lexical existente (searchText/keywords OR), mantendo facetas AND, paginação por fala, o OR de cortes do C174 e o access control intacto. Resultado de tema mostra selo e "Por que apareceu" com trecho real (frase contínua), sem score; degradação honesta para termo exato com aviso e vazio honesto com saídas. - URL: mode=tema (default omitido; canonicaliza sem q), seletor otimista - ai/expandSpeechSearchTheme + lib/speechThemeTerms (sanitiza saída do modelo) - view model com themeMatchTerm + espelho speechMatchesSearchTerm - UI do card de tema (selos, proveniência, ações) e aviso degradado - sem migration/Consent/exposição pública; gate de leitura inalterado - testes unit/int/e2e; design hi-fi estendido e certificado pelo designer
21 KiB
Impl: C192 — Busca semântica no acervo de falas
Status: aprovado Atualizado em: 2026-09-18 Issue: #1147 Intenção: docs/plans/acervo-busca-semantica.md Appetite restante: herdado (~1–2 dias eng)
Leitura da intenção
- Outcome: em
mode=tema, buscar um tema sem as palavras exatas devolve falas relacionadas pelo sentido, cada resultado com proveniência determinística (termo exato vs tema) e excerto real; sem resultado → vazio honesto; mecanismo indisponível → resultados lexicais de hoje + aviso discreto, nunca erro nem resultado fantasma. - O que NÃO negociar: o gate de leitura (
canReadSpeechCatalog/canReadSpeech) não alarga nem ganha atalho;payload.findsempre comuser+overrideAccess: false; a fala continua a unidade; busca exata continua sendo o default e não regride; sem score numérico na UI; nada de pgvector/backfill dos 997;openai/*intocado (OPS123). - O que reavaliar: a hipótese da intenção ("áreas prováveis") está correta quanto aos arquivos. O ponto a reavaliar é a assimetria
speechListFilters(where) ×lib/speechSearch(espelho C180): a expansão só entra na perna textual dowhere, então o espelho precisa de um predicado por termo em vez de reescrever a semântica literal — caso contráriomatchedTextSearchquebraria o C174.
Abordagem recomendada
flowchart LR
U[assessoria digita tema] --> M{mode=tema?}
M -- não --> LEX[buildSpeechListWhere<br/>literal atual]
M -- sim --> EXP[expandSpeechSearchTheme<br/>DeepSeek generateObject]
EXP -- null/erro --> DEG[where literal +<br/>alerta discreto]
EXP -- terms --> WHERE[where OR: q + termos expandidos<br/>AND facetas + OR cortes C174]
DEG --> Q[payload.find<br/>user · overrideAccess:false]
WHERE --> Q
Q --> VM[toSpeechListItemViewModel<br/>matchKind + themeMatchTerm + excerto real]
VM --> UI[SpeechResultCard · EmptyState · Alert]
Opções consideradas: A) LLM query expansion + motor lexical existente, sem índice vetorial | B) embeddings query-time (DeepInfra) + artefato em memória | C) pgvector + embeddings + backfill.
Recomendação: A — um único generateObject determinístico (mesmo contrato de rerankSpeechExcerpts) expande o tema em termos lexicais; o where do Payload e o access control continuam no caminho, a fala segue unidade, e nada novo precisa ser indexado/backfillado. Cabe no appetite e entrega o outcome no recorte do acervo.
Rejeitadas: B porque exige artefato de embeddings (backfill/refresh) e cosine em memória sem ganho claro sobre a expansão lexical para 997 falas, além de introduzir dependência de infra que a intenção mandou evitar; C porque é exatamente o rabbit hole nomeado em "índice vetorial de todo o acervo / infra nova" (custo, storage, sync contínuo, backfill). Detalhe em "Decisões de engenharia".
Componentes / mudanças
SpeechSearchMode+modeemSpeechListState(src/utilities/speech/speechListUrl.ts:51): adiciona'mode'aspeechListParamNames:71, parse de valor único (tema; desconhecido →undefined), serializamode=temasomente quando háq. ReusafirstValue,resolveListUrl,buildListHref. Deep links e back/forward passam pelo canonicalizador existente;modedesconhecido continua redirecionando porque o nome é suportado e o serializer o omite (raw ≠ canonical).applySpeechSearchMode(src/utilities/speech/speechOmnibox.ts): ação pura que devolve{ kind: 'url', state: { ...state, mode, page: 1 } }(ou limpamode), preservando facetas. Reusa o padrão dewithPageReset/mutateFacet; não passa pormutateFacet(não é facet multi-valor).normalizeSpeechThemeTerms(src/lib/speechThemeTerms.ts, novo, puro/client-safe): normaliza (normalizeForSearch), remove%/_e tokens <3 chars, deduplica pela forma normalizada, limita a 8 termos e 60 chars. É a única barreira entre texto de LLM e oLIKE— reusanormalizeForSearch,uniqueByNormalizedForm.expandSpeechSearchTheme(src/utilities/ai/expandSpeechSearchTheme.ts, novo,server-only):deepSeek('deepseek-flash')+generateObject+ zod{ terms: z.array(z.string()) },temperature 0.2,AbortSignal.timeout(4000),maxOutputTokensbaixo; aplicanormalizeSpeechThemeTerms. Contrato: semDEEPSEEK_API_KEY, timeout, erro de provider ou saída malformada →null;{ terms: [] }é resposta legítima (modelo rodou, nada a acrescentar). EspelharerankSpeechExcerpts.buildSpeechTextBranches/buildSpeechListWhereIncludingCutOrigins(src/utilities/speech/speechListFilters.ts:52,77): passam a aceitarthemeTerms: readonly string[]; a perna textual vira OR de{ searchText: like normalize(q) },{ keywords: contains q }+ pares equivalentes por termo expandido +{ id: { in: originSpeechIds } }(C174 intocado, ainda sóstate.q); facetas seguem AND. ReusabuildSpeechFacetWhere,collapseListWhereOrBranches.speechMatchesSearchTerm/speechMatchesSearchQuery(src/lib/speechSearch.ts:31): extrai o predicado de um termo (espelho exato dowhereper-termo);speechMatchesSearchQuerypassa a serspeechMatchesSearchTerm(speech, q)para o flag literal. Mantém o lockstep C180 ematchedTextSearchliteral.toSpeechListItemViewModel(src/utilities/speech/speechViewModels.ts:213): recebethemeTerms?: readonly string[];matchKindganha'theme'; novothemeMatchTerm: string | null(primeiro termo expandido que casa em segmento/keyword via espelho); quando não há casamento literal, o excerto ébuildHighlightedExcerpt(segment.text, themeMatchTerm)— trecho real, sem "porquê" gerado. ReusapickMatchingSegment(generalizado p/ lista de termos),buildHighlightedExcerpt,normalizeForSearch.loadSpeechAcervoPageData(src/utilities/speech/speechPageData.ts:160): ganha 4º parâmetro injetávelexpandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme(precedente:SpeechVideoStartResolveremloadSpeechDetailPageData). Só expande sestate.q && state.mode === 'tema' && canReadSpeechCatalog(user.role)(fail-closed: ator negado não dispara chamada externa; ofindcontinua rejeitando). RetornasearchMode,themeUnavailablee passathemeTermsao view model. Seleção desearchTextinalterada (já existe quandostate.q).- UI (
src/components/campaign/speech/+ rota):SpeechAcervoFilters.tsxganha o seletor segmentado "Buscar por: Termo exato | Por tema" (role="group",aria-pressed) que chamaapplySpeechSearchModeviauseCampaignListFilterNavigation;SpeechResultCard.tsx:122ganha o selo "Tema" + bloco "Por que apareceu" com o excerto real e mantém o caminho C174/originLabelquando não é tema; novoSpeechThemeFallbackNotice.tsxcomAlert variant="pending"+ botão "Tentar por tema novamente" (router.refresh());page.tsx:29renderiza o aviso quandothemeUnavailablee o vazio honesto (cena 03). ReusaCampaignListOmnibox,CampaignListEmptyState,Alert/AlertTitle/AlertDescription,useCampaignListFilterNavigation,Badge,Button. - Migration: sem migration. Nenhum campo/collection/global muda:
searchText/keywordsjá existem e a expansão é query-time; mudar schema exigiria migration e não é necessário. - Access / Consent: intocado. Nenhuma chave
Consentnova, nenhum PII de cidadão; o único dado que sai para o DeepSeek é o texto do tema digitado pelo assessor. A leitura continua nopayload.findcomuser+overrideAccess: false;canReadSpeech/canReadSpeechCatalognão são tocados nem reusados para alargar nada. - UI (Impeccable B): shell existente do acervo, sem rota nova. Cenas do gate a portar: 01 (desktop, selo "Tema" + "Por que apareceu" + "Termo exato" quando ambos), 02 (mobile, toggle tocável,
min-h-11), 03 (vazio honesto: "Reformular busca" / "Usar termo exato" / "Limpar filtros"), 04 (degradado: seletor visualmente em "Termo exato", avisobg-estimate-pendingpreservando "Por tema foi pedido", "Tentar por tema novamente"), 05 (loading: aproveitar pending existente; skeleton fica fora do v1). Craft/critique/polish no mesmo gate B antes do PR.
Dados → forma (N/A justificado)
N/A. O item não apresenta métrica, agregado ou score: o resultado é uma lista de falas com uma explicação qualitativa (selo + trecho). A intenção proíbe explicitamente score numérico de confiança; a forma escolhida é "selo + excerto real", sem barra/percentual.
Fases verificáveis
- Tracer / server (~1 dia):
modeno contrato de URL +normalizeSpeechThemeTerms+expandSpeechSearchTheme+themeTermsnowhere+ injeção emloadSpeechAcervoPageData+themeMatchTermno view model. Testes:tests/unit/speechThemeTerms.unit.spec.ts(novo): sanitiza wildcards/curtos/duplicados/limites.tests/unit/expandSpeechSearchTheme.unit.spec.ts(novo, mockandoai/@ai-sdk/deepseekcomorerankSpeechExcerpts.unit.spec.ts): sem chave →nulle sem chamada; erro do provider →null;{terms:[]}→ legítimo; termos válidos; prompt contém o tema eabortSignaléAbortSignal.tests/unit/speechListUrl.unit.spec.ts(estender):mode=temaparse/serialize; desconhecido canonicaliza;mode=temasemqredireciona; deep-link atual (?q=SUS) não muda.tests/unit/speechListFilters.unit.spec.ts(estender): ORq+ expandidos + OR de cortes, facetas AND; semthemeTermsidêntico ao de hoje.tests/unit/speechSearch.unit.spec.ts(estender):speechMatchesSearchTerm(texto normalizado + keyword case-insensitive).tests/unit/speechViewModels.unit.spec.ts(estender):themeMatchTerm,matchKind='theme', excerto highlightando o termo expandido.
- UI (~0,5–1 dia): seletor de modo em
SpeechAcervoFilters, selo/proveniência noSpeechResultCard, aviso de degradação (Alert pending+ retry), vazio honesto com saídas;applySpeechSearchModeemtests/unit/speechOmnibox.unit.spec.ts. Testes:- Estender
tests/int/speechAcervo.int.spec.tscom expansor injetado: (a) fala que não contém oqliteral mas contém a expansão é devolvida comthemeMatchTerme excerto real; (b) expansornull→themeUnavailable: truee linhas literais; (c) advisor/leader não disparam o expansor (guard fail-closed). - Estender
tests/e2e/campaignSpeechAcervo.e2e.spec.ts(browserless, semDEEPSEEK_API_KEYno app e2e):?q=<marker>&mode=tema→ 200, seletor "Buscar por", aviso "A busca por tema está indisponível agora." e resultado exato do marker;?q=<marker>(default) inalterado.
- Estender
- Gates:
pnpm gate:fast; PR viapnpm push.src/utilities/aié prefixo de risco (OPS86) → o PR roda o conjunto curado de e2e; os prefixossrc/utilities/speech/src/components/campaign/speech/src/app/(campaign)/.../comunicacao/src/lib/speech*já mapeiamcampaignSpeechAcervo, entãoscripts/lib/e2e-affected-manifest.mjsnão muda.
Rabbit holes / Não escopo (engenharia)
- pgvector, embeddings, artefato/backfill dos 997 e sync contínuo — rejeitado nomeadamente na intenção.
- Mandar o catálogo inteiro dos 997 ao LLM para "escolher" a fala — prompt/latência/custo e caixa-preta; a seleção fica no
wherelexical. - LLM a gerar o "por que apareceu" — alucinação; a proveniência é sempre trecho determinístico e o selo é o modo.
- Segundo passe de rerank por LLM no v1 (gatilho de revisitação: se recall/precisão do lexical+expansão não bastar, avaliar rerank sobre candidatos, como no C158).
- Curadoria de sinônimos/ontologia, busca global, Sollinha, exposição pública do acervo, analytics de busca — fora de escopo por intenção.
- Cache do resultado da expansão: v1 paga uma chamada por request (fatiar página re-expande). Gatilho: se latência/custo doer, cache in-process limitado por
normalizeForSearch(q). - Skeletons da cena 05: aproveitar só o pending/dimming existente; skeleton fica para polish futuro.
Riscos e mitigação
- Latência de 1 chamada LLM por busca em página (e por flip de página): timeout 4s +
generateObject; a UI usa o pending existente (aria-busy); degradação honesta se estourar. Trigger de cache acima. LIKEcom curinga injetado:normalizeSpeechThemeTermsremove%/_e limita tamanho/quantidade; owherecontinua parametrizado pelo Payload.- Divergência where ↔ espelho C180: o predicado por termo é único (
speechMatchesSearchTerm) e testado; qualquer mudança na perna textual tem que passar por ele no mesmo commit. - Regressão da busca exata: default
termonão emite parâmetro;buildSpeechListWheresemthemeTermsé byte-idêntico ao atual (teste dedicado). - Proveniência enganosa: quando o casamento é literal,
matchedTextSearch/matchKindcontinuam mandando; o selo "Tema" só aparece comthemeMatchTermreal;[]do modelo não vira resultado. - Chamada externa para ator sem leitura: guard
canReadSpeechCatalog(user.role)antes de expandir; ofindcom access continua sendo a barreira final (fail-closed). - Custo/quota DeepSeek:
deepseek-flash,maxOutputTokensbaixo, sem rerank; os 997 não são enviados. - Confusão do usuário ao degradar: aviso preserva "Por tema foi pedido" e o seletor volta visualmente a "Termo exato", com retry explícito (cena 04) — nunca erro na cara.
- e2e de risco: o diff toca
src/utilities/ai, então o PR roda o curado;campaignSpeechAcervotambém é selecionado pelas prefixos de speech e cobre o modo degradado sem rede.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto: tema sem palavras exatas devolve falas relacionadas; cada resultado explica termo exato vs tema com trecho; exata inalterada; vazio honesto; degradação com aviso, sem erro nem fantasma; facetas continuam valendo; fala é a unidade.
- Invariantes AGENTS/engineering-standards:
user+overrideAccess: falseem toda leitura; gatecanReadSpeech/canReadSpeechCatalognão alargado; sem pgvector/embeddings; semopenai/*; copy pt-BR e identificadores em inglês; sem score numérico. - Sem migration (justificado) — nenhum schema/collection/global alterado.
- Testes de domínio: unit do sanitizador, do módulo de expansão (mock de provider), do contrato de URL, do
where, do espelho C180 e do view model; int com expansor injetado (tema, fallback, guard de acesso); e2e browserless do modo degradado. pnpm gate:fastverde;pnpm pushcom PR ready.- Manifesto de e2e mantém cobertura de risco sem edição (
src/utilities/aie prefixos de speech já mapeados).
Decisões de engenharia
D1 — Mecanismo da busca por tema
Opções: A) LLM query expansion + motor lexical existente | B) embeddings query-time (DeepInfra) + cosine em memória sobre artefato | C) pgvector + embeddings + backfill.
Recomendação: A — resolve o job ("sem as palavras exatas") sem infra nova nem reindexação, mantém access/where/paginação por fala e a proveniência é lexical e determinística.
Rejeitadas: C porque é o rabbit hole textual da intenção (pipeline, backfill dos 997, storage, sync); B porque exige manter um artefato de embeddings (gerar/atualizar/servir) e introduz similaridade contínua — mais superfície e sem ganho comprovado no recorte de 997 falas; D) lexical puro sem expandir não atende o outcome ("defesa do SUS" continua sem achar outras palavras); E) mandar o acervo inteiro ao LLM estoura prompt/latência/custo e vira caixa-preta.
D2 — Contrato de falha da expansão
Opções: A) null para indisponível e vazio | B) null só para indisponível; {terms: []} quando o modelo roda e não acrescenta nada | C) lançar erro e tratar na página.
Recomendação: B — distingue "mecanismo fora" (mostra o aviso da cena 04) de "mecanismo rodou, sem termos" (cai na busca literal sem aviso de indisponibilidade); é mais honesto que colapsar os dois.
Rejeitadas: A porque exibir "indisponível" quando o serviço respondeu vazio mente para o usuário; C porque quebra o contrato "nunca lança" do precedente rerankSpeechExcerpts e arrisca erro na cara.
D3 — Parâmetro de URL do modo
Opções: A) mode=tema (default termo, omitido) | B) mode=semantic/mode=exact | C) semantic=1 booleano.
Recomendação: A — nome English (mode, consistente com q/year/topic/…), valor slug pt-BR (tema/termo) consistente com a convenção de VALORES já fixada nos facets (curta/media/sem_duracao); default omitido mantém deep-links e a URL da busca exata idênticas (zero regressão). Registrado em speechListParamNames para o canonicalizador redirecionar valor desconhecido.
Rejeitadas: B porque inventa vocabulário em inglês para valores que o usuário vê em pt-BR; C porque um booleano não escala para um terceiro modo e foge do padrão de enum de facet.
D4 — Proveniência do resultado
Opções: A) selo por item + trecho real determinístico | B) "porquê" gerado pelo LLM | C) aviso único no topo.
Recomendação: A — themeMatchTerm + buildHighlightedExcerpt sobre o texto do segmento/keyword; trecho existe no dado, sem alucinação; o selo "Tema"/"Termo exato" marca cada fala (cena 01).
Rejeitadas: B por risco de alucinação e min/max imprevisível; C porque não sustenta a confiança em cada fala e some ao rolar.
D5 — Unidade do resultado e escopo do casamento
Opções: A) fala, com o where casando texto/keywords e (C174) origem de corte | B) fala + trechos | C) trechos.
Recomendação: A — o job é "qual fala serve"; a expansão só entra na perna textual da fala/keyword; o casamento por corte (C174) permanece ancorado só no q original (não expandimos títulos de corte) para não inflar superfície/custo.
Rejeitadas: B/C porque recorte fino de trecho é o job do C158 no Sollinha e mudaria a unidade/paginação atual.
D6 — Cache/segundo passe
Opções: A) v1 sem cache e sem rerank | B) cache in-process da expansão já no v1 | C) rerank LLM dos candidatos no v1.
Recomendação: A — menos superfície, cabe no appetite; o deepseek-flash com timeout e maxOutputTokens baixo mantém o custo aceitável no interno.
Rejeitadas: B adiada (barata de reverter; gatilho: latência/custo por flip de página); C adiada (gatilho: precisão/recall insuficientes) — espelha a decisão do C158 de rerank como segunda etapa, não no caminho crítico.
Self-score decision-quality
- Decisões caras com rejeitadas: 5/5 — mecanismo, contrato de falha, contrato de URL e proveniência têm opções/recomendação/rejeitadas registradas; custo/rerank deferidos com gatilho.
- Cabe no appetite: 5/5 — query-time, sem migration, sem infra; ~1–2 dias (server+tracer num dia, UI/gates no restante).
- Rabbit holes nomeados: 5/5 — embeddings/pgvector/backfill, catálogo inteiro no LLM, "porquê" gerado, rerank/cache/curadoria explicitamente cortados.
- Depth check (reusa shells/helpers): 5/5 —
buildSpeechListWhere*, canonicalizador (resolveListUrl/buildListHref),CampaignListOmnibox,CampaignListEmptyState,useCampaignListFilterNavigation,Alert,buildHighlightedExcerpt/speechSearchTerms, e o contrato de injeção deloadSpeechDetailPageData. - Intenção permanece satisfeita: 5/5 — outcome, anti-goals e guardrails de acesso intactos; a engenharia não reescreveu o produto.
Total: 5/5 (gate ≥4 satisfeito).
Triage do /simplify (C192)
Aplicados no PR: gate speechMatchesSearchTerm no pickThemeMatch (não reivindicar tema que o where não usou), evidência por keyword em vez de trecho alheio, copy do vazio no caminho degradado, aria-disabled com no-op real, temperature: 0 na expansão, SPEECH_OMNIBOX_ID compartilhado, SpeechWatchButton extraído, frase contínua no destaque + testes diretos, prompt interpolando as constantes.
Deferidos com gatilho:
- Cache da expansão por
qnormalizado (custo/latência por página e coerência de paginação). Gatilho: latência/custo doer ou paginação incoerente observada. Já previsto em D6. - Helper único para o boilerplate DeepSeek (
generateObject+ guard de chave + timeout + catch→null), hoje repetido emrerankSpeechExcerpts,campaignDemandTitle,speechCutMetadataeexpandSpeechSearchTheme. Gatilho: tocar qualquer um deles. collectOccurrencesemspeechHighlight(loop duplicado entrefindHighlightRanges/findPhraseRanges). Gatilho: terceira variante de busca de ranges.- Sanitizar
themeTermsna fronteira dobuildSpeechListWhere(defense-in-depth para futuros callers; produção já segura pelo provider). Gatilho: novo caller do builder. CampaignListEmptyStatecom 3 props de classe para um caller — reavaliar se surgir um segundo consumidor (variante nomeada). Gatilho: segundo call site.