teqo-backup/src/lib/speechExcerpt.ts

157 lines
5 KiB
TypeScript

import { normalizeForSearch } from '@/lib/speechSearch'
/**
* C158 — continuous speech excerpt builder for the Sollinha suggestion tool.
* Pure: given the ASR segments of ONE speech and the sanitized search terms,
* pick the segment with the best term coverage and expand it into a
* contiguous window (consecutive segments only) that contains every term.
* The timestamps come straight from the ASR — a model never invents them.
*/
export type SpeechExcerptSegment = {
startSeconds: number
endSeconds: number
text: string
}
export type SpeechExcerpt = {
startSeconds: number
endSeconds: number
text: string
/** Every requested term is present in `text` (that is the acceptance rule). */
matchedTerms: string[]
}
const MIN_TERM_LENGTH = 3
const MAX_TERMS = 6
const EXCERPT_MIN_SECONDS = 15
const EXCERPT_DEFAULT_TARGET_SECONDS = 30
const EXCERPT_MAX_SECONDS = 60
const EXCERPT_MAX_GAP_SECONDS = 5
const EXCERPT_MAX_ANCHORS = 5
/** Short/long variants offered to the C158 reranker ("de que ponto a que ponto"). */
export const EXCERPT_VARIANT_TARGETS = [20, 45] as const
/**
* Search terms of a spoken theme: accent-folded, split on anything that is not
* a letter or digit (so `%`/`_` never reach the SQL `LIKE`), deduped, short
* terms dropped (the trigram index needs 3+ chars) and capped.
*/
export const speechExcerptTerms = (query: string): string[] => {
const terms: string[] = []
for (const term of normalizeForSearch(query).split(/[^a-z0-9]+/)) {
if (term.length < MIN_TERM_LENGTH || terms.includes(term)) continue
terms.push(term)
if (terms.length >= MAX_TERMS) break
}
return terms
}
const windowDuration = (
segments: readonly SpeechExcerptSegment[],
from: number,
to: number,
): number => segments[to]!.endSeconds - segments[from]!.startSeconds
const canExtendForward = (
segments: readonly SpeechExcerptSegment[],
from: number,
to: number,
): boolean => {
const next = segments[to + 1]
if (!next) return false
if (next.startSeconds - segments[to]!.endSeconds > EXCERPT_MAX_GAP_SECONDS) return false
// A single segment longer than the cap is returned whole (never cut mid-segment).
return windowDuration(segments, from, to + 1) <= EXCERPT_MAX_SECONDS
}
const canExtendBackward = (
segments: readonly SpeechExcerptSegment[],
from: number,
to: number,
): boolean => {
const previous = segments[from - 1]
if (!previous) return false
if (segments[from]!.startSeconds - previous.endSeconds > EXCERPT_MAX_GAP_SECONDS) return false
return windowDuration(segments, from - 1, to) <= EXCERPT_MAX_SECONDS
}
const windowText = (segments: readonly SpeechExcerptSegment[], from: number, to: number): string =>
segments
.slice(from, to + 1)
.map((segment) => segment.text.trim())
.filter((part) => part.length > 0)
.join(' ')
const windowCovers = (text: string, terms: readonly string[]): boolean => {
const normalized = normalizeForSearch(text)
return terms.every((term) => normalized.includes(term))
}
const expandWindow = (
segments: readonly SpeechExcerptSegment[],
anchorIndex: number,
terms: readonly string[],
targetSeconds: number,
): { from: number; to: number } => {
let from = anchorIndex
let to = anchorIndex
// Coverage first, target as the comfort size: keep growing while a term is
// missing or the window is still shorter than the target.
while (
canExtendForward(segments, from, to) &&
(!windowCovers(windowText(segments, from, to), terms) ||
windowDuration(segments, from, to) < targetSeconds)
) {
to += 1
}
while (
canExtendBackward(segments, from, to) &&
(!windowCovers(windowText(segments, from, to), terms) ||
windowDuration(segments, from, to) < EXCERPT_MIN_SECONDS)
) {
from -= 1
}
return { from, to }
}
/**
* Best contiguous excerpt for the terms, or `null` when no window of up to
* `EXCERPT_MAX_SECONDS` (single long segment included) carries every term.
* `targetSeconds` sizes the window: the short/long variants are two calls.
*/
export const buildSpeechExcerpt = (
segments: readonly SpeechExcerptSegment[],
terms: readonly string[],
{ targetSeconds = EXCERPT_DEFAULT_TARGET_SECONDS }: { targetSeconds?: number } = {},
): SpeechExcerpt | null => {
if (segments.length === 0 || terms.length === 0) return null
const normalizedSegments = segments.map((segment) => normalizeForSearch(segment.text))
const anchors = segments
.map((_, index) => ({
index,
coverage: terms.filter((term) => normalizedSegments[index]!.includes(term)).length,
}))
.filter(({ coverage }) => coverage > 0)
.sort((left, right) => right.coverage - left.coverage || left.index - right.index)
.slice(0, EXCERPT_MAX_ANCHORS)
for (const { index } of anchors) {
const { from, to } = expandWindow(segments, index, terms, targetSeconds)
const text = windowText(segments, from, to)
if (!windowCovers(text, terms)) continue
return {
startSeconds: segments[from]!.startSeconds,
endSeconds: segments[to]!.endSeconds,
text,
matchedTerms: [...terms],
}
}
return null
}