mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
157 lines
5 KiB
TypeScript
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
|
|
}
|