mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
224 lines
7.5 KiB
TypeScript
224 lines
7.5 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))
|
|
}
|
|
|
|
/** Any record carrying the searchable text of one segment (speech/recording). */
|
|
export type SearchableSegment = { text: string }
|
|
|
|
/**
|
|
* C154/C199 — the first segment that carries the whole query (all terms, any
|
|
* order), else the first one carrying any term. Shared by the speech acervo and
|
|
* the uploaded recordings, which pick the excerpt of a result the same way.
|
|
*/
|
|
export const pickMatchingSegment = <Segment extends SearchableSegment>(
|
|
segments: readonly Segment[],
|
|
query: string | undefined,
|
|
): Segment | undefined => {
|
|
const q = query?.trim()
|
|
if (!q || segments.length === 0) return undefined
|
|
const allTerms = segments.find((segment) => matchesSearchTerms(segment.text, q))
|
|
if (allTerms) return allTerms
|
|
const terms = speechSearchTerms(q)
|
|
return segments.find((segment) => {
|
|
const normalized = normalizeForSearch(segment.text)
|
|
return terms.some((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
|
|
}
|
|
}
|
|
|
|
return mergeHighlightRanges(ranges)
|
|
}
|
|
|
|
/**
|
|
* C192 — every occurrence of the whole normalized PHRASE as a single range, so
|
|
* a theme match like "acesso universal à saúde" is one continuous band (the
|
|
* per-term search would drop the "à" and split the band). Falls back to the
|
|
* per-term ranges when the phrase does not occur contiguously.
|
|
*/
|
|
export const findPhraseRanges = (text: string, phrase: string): SpeechHighlightRange[] => {
|
|
const needle = normalizeForSearch(phrase)
|
|
if (!needle || text.length === 0) return []
|
|
|
|
const { normalized, sourceIndex } = buildNormalizedIndex(text)
|
|
if (normalized.length === 0) return []
|
|
|
|
const ranges: SpeechHighlightRange[] = []
|
|
let from = 0
|
|
while (from <= normalized.length - needle.length) {
|
|
const at = normalized.indexOf(needle, from)
|
|
if (at === -1) break
|
|
ranges.push({
|
|
start: sourceIndex[at]!,
|
|
end: sourceIndex[at + needle.length - 1]! + 1,
|
|
})
|
|
from = at + 1
|
|
}
|
|
|
|
return ranges.length > 0 ? mergeHighlightRanges(ranges) : findHighlightRanges(text, phrase)
|
|
}
|
|
|
|
const mergeHighlightRanges = (ranges: readonly SpeechHighlightRange[]): SpeechHighlightRange[] => {
|
|
const sorted = [...ranges].sort((left, right) => left.start - right.start || left.end - right.end)
|
|
const merged: SpeechHighlightRange[] = []
|
|
for (const range of sorted) {
|
|
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, phrase = false }: { radius?: number; phrase?: boolean } = {},
|
|
): SpeechHighlightedExcerpt => {
|
|
const trimmed = text.trim()
|
|
if (trimmed === '') return { parts: [], truncatedStart: false, truncatedEnd: false }
|
|
|
|
const ranges = phrase ? findPhraseRanges(trimmed, query) : 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,
|
|
}
|
|
}
|