teqo-backup/src/lib/speechSearch.ts
Francisco Solla 671af1e21f
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
feat(C232): pré-catalogar o acervo de fotos com IA local e curadoria que vence
Collection archivePhoto ganha o grupo catalog (legenda/descrição/cena/texto
visível/temas/pessoas/município/origem/catalogedAt), curatedFields (o que a
assessoria edita nunca é sobrescrito), takenOn e searchText com índice GIN trgm
(migration aditiva add_archive_photo_catalog); hook beforeValidate deriva o
índice e marca curadoria só em edição humana; pipeline transacional baixa os
bytes privados, prepara JPEG <=1024px com sharp e mescla o VLM local com o
gazetteer/catálogo curado (pessoas só do TEXTO, nunca de rosto); CLI
pnpm archive:catalog (plan/--apply/--verify, recibo honesto, idempotente por
catalogedAt) com engine OpenAI-compatible na rede privada atrás de guarda de
host (ARCHIVE_VISION_ALLOW_REMOTE é o escape); client HTTP com retry/timeout e
parser zod fail-closed; resolver de município extraído para owner compartilhado
com o catálogo de peças delegando; unit/int novos e runbook staging-primeiro.
2026-09-26 12:30:34 -03:00

76 lines
2.8 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.
*/
export const TRANSCRIPT_TEXT_MAX_LENGTH = Number.MAX_SAFE_INTEGER
/** 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()
/** Escapes a literal for a RegExp source; the whole-word matchers share it. */
export const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
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
}