mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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
71 lines
2.5 KiB
TypeScript
71 lines
2.5 KiB
TypeScript
/**
|
|
* Pure text normalization for the speech catalog (C153). The segment hook
|
|
* stores `normalizeForSearch(text)` in `speechSegment.searchText` and the
|
|
* search UI (C154) normalizes the query with the SAME function before the
|
|
* `contains` query, so accent/case variants match the trigram index.
|
|
*/
|
|
|
|
/** NFD-strips accents, lowercases and collapses whitespace. */
|
|
export const normalizeForSearch = (value: string): string =>
|
|
value
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
|
|
type SpeechSearchableRecord = {
|
|
/** Normalized segment text persisted by the segment hook (C153). */
|
|
searchText?: string | null
|
|
keywords?: readonly string[] | null
|
|
}
|
|
|
|
/**
|
|
* C180 — pure mirror of a SINGLE textual `where` branch
|
|
* (`speechListFilters.buildSpeechTextBranches`): normalized `searchText` LIKE
|
|
* the normalized term, OR a raw official keyword containing the term.
|
|
* `keywords` compare case-insensitively (accent still significant) to match the
|
|
* ILIKE `%term%` Postgres runs. Adding or removing a branch there means
|
|
* changing this predicate and its test in the same commit. An empty term never
|
|
* matches (a branch with no term cannot surface a row).
|
|
*/
|
|
export const speechMatchesSearchTerm = (
|
|
speech: SpeechSearchableRecord,
|
|
term: string | undefined,
|
|
): boolean => {
|
|
const trimmed = term?.trim()
|
|
if (!trimmed) return false
|
|
if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true
|
|
const lowered = trimmed.toLowerCase()
|
|
return (speech.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(lowered))
|
|
}
|
|
|
|
/**
|
|
* C180 — the search query mirror: empty/missing query is an unconditional match
|
|
* (no textual filter), otherwise any of its terms. C192 reuses
|
|
* `speechMatchesSearchTerm` for the expanded theme terms.
|
|
*/
|
|
export const speechMatchesSearchQuery = (
|
|
speech: SpeechSearchableRecord,
|
|
query: string | undefined,
|
|
): boolean => {
|
|
const trimmed = query?.trim()
|
|
if (!trimmed) return true
|
|
return speechMatchesSearchTerm(speech, trimmed)
|
|
}
|
|
|
|
/**
|
|
* Dedupes values by their normalized form, preserving the first spelling.
|
|
* Used by the facet classifier before persisting people/program mentions.
|
|
*/
|
|
export const uniqueByNormalizedForm = (values: readonly string[]): string[] => {
|
|
const seen = new Set<string>()
|
|
const result: string[] = []
|
|
for (const value of values) {
|
|
const key = normalizeForSearch(value)
|
|
if (key === '' || seen.has(key)) continue
|
|
seen.add(key)
|
|
result.push(value.trim())
|
|
}
|
|
return result
|
|
}
|