teqo-backup/src/lib/speechThemeTerms.ts
Francisco Solla 45eef25b1a feat(C192): busca semântica no acervo de falas (Closes #1147)
Modo "Por tema" no acervo de falas: expansão determinística do tema via
DeepSeek (generateObject, timeout, fallback null) alimenta o motor lexical
existente (searchText/keywords OR), mantendo facetas AND, paginação por fala,
o OR de cortes do C174 e o access control intacto. Resultado de tema mostra
selo e "Por que apareceu" com trecho real (frase contínua), sem score;
degradação honesta para termo exato com aviso e vazio honesto com saídas.

- URL: mode=tema (default omitido; canonicaliza sem q), seletor otimista
- ai/expandSpeechSearchTheme + lib/speechThemeTerms (sanitiza saída do modelo)
- view model com themeMatchTerm + espelho speechMatchesSearchTerm
- UI do card de tema (selos, proveniência, ações) e aviso degradado
- sem migration/Consent/exposição pública; gate de leitura inalterado
- testes unit/int/e2e; design hi-fi estendido e certificado pelo designer
2026-09-18 17:48:28 -03:00

30 lines
1.1 KiB
TypeScript

/**
* C192 — sanitization of the LLM-expanded theme terms before they reach the
* Payload `where`. The expansion is untrusted model output: `%`/`_`/`\` would
* widen the `LIKE`/`ILIKE` patterns, very short tokens produce noise, and the
* list has to stay bounded so the textual OR does not explode. Pure and
* client-safe.
*/
import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch'
export const MAX_SPEECH_THEME_TERMS = 8
export const MIN_SPEECH_THEME_TERMS = 3
export const MAX_SPEECH_THEME_TERM_LENGTH = 60
const MIN_THEME_TERM_LENGTH = 3
const LIKE_WILDCARDS = /[%_\\]/g
const sanitizeSpeechThemeTerm = (value: string): string =>
value
.replace(LIKE_WILDCARDS, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_SPEECH_THEME_TERM_LENGTH)
.trim()
/** Removes wildcards, drops too-short/empty terms, dedupes and caps the list. */
export const normalizeSpeechThemeTerms = (values: readonly string[]): string[] =>
uniqueByNormalizedForm(
values
.map(sanitizeSpeechThemeTerm)
.filter((term) => normalizeForSearch(term).length >= MIN_THEME_TERM_LENGTH),
).slice(0, MAX_SPEECH_THEME_TERMS)