teqo-backup/src/lib/speechHighlight.ts
Francisco Solla 3e062749fa feat(C154): vertical de comunicação — papel communicator e acervo pesquisável
Papel communicator com nav própria e gate speechCatalog; acervo em
/campanha/comunicacao/acervo com busca normalizada (destaque no trecho),
filtros de ano/tema/alcance/fase/município/duração em URL canônica e
paginação por discurso via speech.searchText (GIN trgm + backfill).
Detalhe com player nativo no trecho, transcrição clicável, baixar/abrir
fonte e fallback sem VOD. Resolve o débito do C153 (Sollinha fora da
vertical) e corrige o parser de keywords oficiais no dono.

Closes #956
2026-09-13 13:46:58 -03:00

169 lines
5.4 KiB
TypeScript

import { normalizeForSearch } from '@/lib/speechSearch'
/**
* C154 — term highlighting for the speech acervo. The search normalizes text
* (NFD strips accents, lowercase) so "saude" matches "saúde"; highlighting has
* to map those matches back to the ORIGINAL spelling, which means the
* normalized string cannot be searched with `indexOf` on the source text.
*
* `buildNormalizedIndex` normalizes character by character while recording, for
* each normalized character, the source index it came from — so a range in the
* normalized space converts to a source range. Pure and unit-tested.
*/
export type SpeechHighlightPart = {
text: string
highlighted: boolean
}
export type SpeechHighlightedExcerpt = {
parts: SpeechHighlightPart[]
truncatedStart: boolean
truncatedEnd: boolean
}
export type SpeechHighlightRange = {
start: number
end: number
}
type NormalizedIndex = {
normalized: string
/** `sourceIndex[i]` is the original text index that produced `normalized[i]`. */
sourceIndex: number[]
}
const buildNormalizedIndex = (text: string): NormalizedIndex => {
const chars: string[] = []
const sourceIndex: number[] = []
let pendingSpace = false
for (let index = 0; index < text.length; index += 1) {
const char = text[index]!
if (/\s/.test(char)) {
if (chars.length > 0) pendingSpace = true
continue
}
const piece = normalizeForSearch(char)
if (piece === '') continue
if (pendingSpace) {
chars.push(' ')
sourceIndex.push(index)
pendingSpace = false
}
for (const pieceChar of piece) {
chars.push(pieceChar)
sourceIndex.push(index)
}
}
return { normalized: chars.join(''), sourceIndex }
}
/** Normalized search terms (words) of the query, in order, duplicates kept out. */
export const speechSearchTerms = (query: string): string[] => [
...new Set(
normalizeForSearch(query)
.split(' ')
.filter((term) => term.length > 0),
),
]
/** True when every query term occurs somewhere in the text (accent-insensitive). */
export const matchesSearchTerms = (text: string, query: string): boolean => {
const terms = speechSearchTerms(query)
if (terms.length === 0) return false
const { normalized } = buildNormalizedIndex(text)
return terms.every((term) => normalized.includes(term))
}
/** Every occurrence of every term, mapped back to source offsets and merged. */
export const findHighlightRanges = (text: string, query: string): SpeechHighlightRange[] => {
const terms = speechSearchTerms(query)
if (terms.length === 0 || text.length === 0) return []
const { normalized, sourceIndex } = buildNormalizedIndex(text)
if (normalized.length === 0) return []
const ranges: SpeechHighlightRange[] = []
for (const term of terms) {
let from = 0
while (from <= normalized.length - term.length) {
const at = normalized.indexOf(term, from)
if (at === -1) break
ranges.push({
start: sourceIndex[at]!,
end: sourceIndex[at + term.length - 1]! + 1,
})
// Overlapping occurrences ("ana" in "banana") are found one step at a time.
from = at + 1
}
}
ranges.sort((left, right) => left.start - right.start || left.end - right.end)
const merged: SpeechHighlightRange[] = []
for (const range of ranges) {
const last = merged[merged.length - 1]
if (last && range.start <= last.end) {
if (range.end > last.end) last.end = range.end
continue
}
merged.push({ ...range })
}
return merged
}
const partsFromRanges = (
text: string,
ranges: readonly SpeechHighlightRange[],
): SpeechHighlightPart[] => {
const parts: SpeechHighlightPart[] = []
let cursor = 0
for (const range of ranges) {
if (range.start > cursor) {
parts.push({ text: text.slice(cursor, range.start), highlighted: false })
}
parts.push({ text: text.slice(range.start, range.end), highlighted: true })
cursor = Math.max(cursor, range.end)
}
if (cursor < text.length) parts.push({ text: text.slice(cursor), highlighted: false })
return parts
}
/** All matches of the query highlighted, no windowing — the detail transcript. */
export const splitHighlightedParts = (text: string, query: string): SpeechHighlightPart[] =>
partsFromRanges(text, findHighlightRanges(text, query))
/**
* A readable window around the first match (or the text start when there is
* none), with the matched terms highlighted and truncation flags for the
* ellipses the card renders.
*/
export const buildHighlightedExcerpt = (
text: string,
query: string,
{ radius = 160 }: { radius?: number } = {},
): SpeechHighlightedExcerpt => {
const trimmed = text.trim()
if (trimmed === '') return { parts: [], truncatedStart: false, truncatedEnd: false }
const ranges = findHighlightRanges(trimmed, query)
const focus = ranges[0]
const windowStart = focus ? Math.max(0, focus.start - radius) : 0
const windowEnd = focus
? Math.min(trimmed.length, focus.end + radius)
: Math.min(trimmed.length, radius * 2)
const windowed = ranges
.filter((range) => range.end > windowStart && range.start < windowEnd)
.map((range) => ({
start: Math.max(0, range.start - windowStart),
end: Math.min(windowEnd - windowStart, range.end - windowStart),
}))
return {
parts: partsFromRanges(trimmed.slice(windowStart, windowEnd), windowed),
truncatedStart: windowStart > 0,
truncatedEnd: windowEnd < trimmed.length,
}
}