mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
A Fase 5 pós-deploy do C229 mediu o corpus real e o aceite 'impeachment' falhava: a 222 (votação do impeachment de Dilma, 17/04/2016) ficava em #841 porque o índice só embutia o ASR — e a fala nunca pronuncia 'impeachment'; a relevância dela mora no sumário/keywords oficiais da Câmara. O plano do índice passa a carregar metadataText (sumário — ou título no web — + keywords, incluído no hash de idempotência) e o vetor da fala vira normalize(média das unidades + 2·metadata), calibrado no corpus real (w=2: 222 em #8; w=1: #25). A metadata é vetorizada no mesmo lote do provedor mas não vira unidade armazenada nem evidência; sem migration. Related #1395
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
composeSpeechVector,
|
|
cosineSimilarity,
|
|
dotProduct,
|
|
meanPoolVectors,
|
|
normalizeVector,
|
|
pickBestSemanticChunk,
|
|
planSpeechIndex,
|
|
rankSemanticHits,
|
|
sortSemanticHits,
|
|
SPEECH_METADATA_VECTOR_WEIGHT,
|
|
SPEECH_SEMANTIC_MIN_COSINE,
|
|
speechEmbeddingHash,
|
|
speechMetadataText,
|
|
speechSemanticSourceText,
|
|
speechTextWindows,
|
|
type SpeechSemanticCandidate,
|
|
type SpeechSemanticHit,
|
|
} from '@/lib/speechSemantic'
|
|
|
|
// C229 — the pure engine of the semantic acervo: vector math, text windows,
|
|
// the content hash and the ranking rules (threshold + relevance default with
|
|
// the explicit order overriding it). No network and no Payload here.
|
|
|
|
describe('vector math', () => {
|
|
it('computes the dot product and rejects mismatched dimensions', () => {
|
|
expect(dotProduct([1, 2, 3], [4, 5, 6])).toBe(32)
|
|
expect(dotProduct([1, 2], [1])).toBe(0)
|
|
})
|
|
|
|
it('normalizes to unit length and keeps a zero vector finite', () => {
|
|
expect(normalizeVector([3, 4])).toEqual([0.6, 0.8])
|
|
expect(normalizeVector([0, 0])).toEqual([0, 0])
|
|
})
|
|
|
|
it('computes a real cosine regardless of scale', () => {
|
|
expect(cosineSimilarity([1, 0], [1, 0])).toBeCloseTo(1)
|
|
expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0)
|
|
expect(cosineSimilarity([1, 0], [-1, 0])).toBeCloseTo(-1)
|
|
expect(cosineSimilarity([2, 0], [7, 0])).toBeCloseTo(1)
|
|
expect(cosineSimilarity([1, 2], [1])).toBe(0)
|
|
expect(cosineSimilarity([0, 0], [1, 1])).toBe(0)
|
|
})
|
|
|
|
it('pools vectors by the normalized mean and skips unusable shapes', () => {
|
|
expect(
|
|
meanPoolVectors([
|
|
[1, 0],
|
|
[1, 0],
|
|
]),
|
|
).toEqual([1, 0])
|
|
// The mean of opposite directions is the zero vector, never NaN.
|
|
expect(
|
|
meanPoolVectors([
|
|
[1, 0],
|
|
[-1, 0],
|
|
]),
|
|
).toEqual([0, 0])
|
|
expect(
|
|
meanPoolVectors([
|
|
[1, 0],
|
|
[0, 1, 0],
|
|
]),
|
|
).toEqual([1, 0])
|
|
expect(meanPoolVectors([])).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('speechTextWindows', () => {
|
|
const longText = 'palavra '.repeat(400).trim()
|
|
|
|
it('keeps a short text as a single window', () => {
|
|
expect(speechTextWindows('fala curta')).toEqual([
|
|
{ order: 0, start: 0, end: 10, text: 'fala curta' },
|
|
])
|
|
expect(speechTextWindows('')).toEqual([])
|
|
})
|
|
|
|
it('splits long text into ordered overlapping windows without cutting words', () => {
|
|
const windows = speechTextWindows(longText, { size: 100, overlap: 20 })
|
|
|
|
expect(windows.length).toBeGreaterThan(2)
|
|
expect(windows.map((window) => window.order)).toEqual(windows.map((_, index) => index))
|
|
for (const window of windows) {
|
|
expect(window.text.length).toBeLessThanOrEqual(100)
|
|
expect(window.text).toBe(window.text.trim())
|
|
// No window starts or ends mid-word: the slice boundaries are word edges.
|
|
expect(longText.slice(window.start, window.end)).toBe(
|
|
`${longText.slice(window.start, window.end).trim()}`,
|
|
)
|
|
}
|
|
// Overlap: the next window starts before the previous one ended.
|
|
expect(windows[1].start).toBeLessThan(windows[0].end)
|
|
// Full coverage: every character of the text belongs to some window.
|
|
let covered = 0
|
|
for (const window of windows) covered = Math.max(covered, window.end)
|
|
expect(covered).toBe(longText.length)
|
|
})
|
|
})
|
|
|
|
describe('speechEmbeddingHash', () => {
|
|
it('is deterministic, text-sensitive and model-sensitive', () => {
|
|
expect(speechEmbeddingHash('fala', 'bge')).toBe(speechEmbeddingHash('fala', 'bge'))
|
|
expect(speechEmbeddingHash('fala', 'bge')).not.toBe(speechEmbeddingHash('outra', 'bge'))
|
|
expect(speechEmbeddingHash('fala', 'bge')).not.toBe(speechEmbeddingHash('fala', 'outro'))
|
|
})
|
|
})
|
|
|
|
describe('rankSemanticHits', () => {
|
|
const candidates: SpeechSemanticCandidate[] = [
|
|
{ id: 1, speechAt: '2026-01-01T10:00', vector: [1, 0] }, // 1.0
|
|
{ id: 2, speechAt: '2026-02-01T10:00', vector: [0.8, 0.6] }, // 0.8
|
|
{ id: 3, speechAt: '2026-03-01T10:00', vector: [0.8, 0.6] }, // 0.8, newer wins the tie
|
|
{ id: 4, speechAt: '2026-04-01T10:00', vector: [0, 1] }, // 0.0 (below the cut-off)
|
|
{ id: 5, speechAt: '2026-05-01T10:00', vector: null },
|
|
{ id: 6, speechAt: '2026-06-01T10:00', vector: [1, 0, 0] },
|
|
{ id: 7, speechAt: '2026-07-01T10:00', vector: [0.4, 0.916515] }, // ~0.4 (boundary, kept)
|
|
]
|
|
const query = [1, 0]
|
|
|
|
it('drops unusable candidates and the scores below the cut-off', () => {
|
|
const hits = rankSemanticHits(query, candidates)
|
|
expect(hits.map((hit) => hit.id)).toEqual([1, 3, 2, 7])
|
|
})
|
|
|
|
it('sorts by score desc with the date as tiebreak and keeps the duration', () => {
|
|
const hits = rankSemanticHits(query, [
|
|
{ id: 9, speechAt: '2026-01-01T10:00', durationSeconds: 120, vector: [1, 0] },
|
|
{ id: 8, speechAt: '2026-01-01T10:00', durationSeconds: null, vector: [1, 0] },
|
|
])
|
|
expect(hits.map((hit) => hit.id)).toEqual([8, 9])
|
|
expect(hits[0]).toMatchObject({ durationSeconds: null, score: 1 })
|
|
expect(hits[1]).toMatchObject({ durationSeconds: 120 })
|
|
})
|
|
|
|
it('honors a custom cut-off', () => {
|
|
expect(rankSemanticHits(query, candidates, { minScore: 0.9 }).map((hit) => hit.id)).toEqual([1])
|
|
expect(
|
|
rankSemanticHits([1, 0], [{ id: 1, speechAt: 'x', vector: [1, 0] }], { minScore: 0.95 }),
|
|
).toHaveLength(1)
|
|
})
|
|
|
|
it('keeps the documented default cut-off', () => {
|
|
expect(SPEECH_SEMANTIC_MIN_COSINE).toBe(0.4)
|
|
})
|
|
})
|
|
|
|
describe('sortSemanticHits', () => {
|
|
const hits: SpeechSemanticHit[] = [
|
|
{ id: 1, speechAt: '2026-01-01T10:00', durationSeconds: 600, score: 0.9 },
|
|
{ id: 3, speechAt: '2026-02-01T10:00', durationSeconds: null, score: 0.7 },
|
|
{ id: 2, speechAt: '2026-03-01T10:00', durationSeconds: 120, score: 0.5 },
|
|
]
|
|
|
|
it('keeps the relevance order by default', () => {
|
|
expect(sortSemanticHits(hits, 'relevancia').map((hit) => hit.id)).toEqual([1, 3, 2])
|
|
})
|
|
|
|
it('orders by date when the user asks for recentes', () => {
|
|
expect(sortSemanticHits(hits, 'recentes').map((hit) => hit.id)).toEqual([2, 3, 1])
|
|
})
|
|
|
|
it('orders by duration in both directions', () => {
|
|
expect(sortSemanticHits(hits, 'duracao_maior').map((hit) => hit.id)).toEqual([1, 2, 3])
|
|
expect(sortSemanticHits(hits, 'duracao_menor').map((hit) => hit.id)).toEqual([2, 1, 3])
|
|
})
|
|
})
|
|
|
|
describe('pickBestSemanticChunk', () => {
|
|
it('picks the closest usable chunk and breaks ties by the lowest key', () => {
|
|
expect(
|
|
pickBestSemanticChunk(
|
|
[1, 0],
|
|
[
|
|
{ key: 20, vector: [0, 1] },
|
|
{ key: 10, vector: [1, 0] },
|
|
{ key: 30, vector: [1, 0] },
|
|
],
|
|
),
|
|
).toEqual({ key: 10, score: 1 })
|
|
})
|
|
|
|
it('returns null when no chunk can be compared', () => {
|
|
expect(pickBestSemanticChunk([1, 0], [])).toBeNull()
|
|
expect(pickBestSemanticChunk([1, 0], [{ key: 1, vector: null }])).toBeNull()
|
|
expect(pickBestSemanticChunk([1, 0], [{ key: 1, vector: [1, 0, 0] }])).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('speechSemanticSourceText', () => {
|
|
it('prefers the official transcript, then the summary, then the search text', () => {
|
|
expect(
|
|
speechSemanticSourceText({
|
|
officialTranscript: 'Transcrição',
|
|
summary: 'Resumo',
|
|
searchText: 'busca',
|
|
}),
|
|
).toBe('Transcrição')
|
|
expect(speechSemanticSourceText({ officialTranscript: ' ', summary: 'Resumo' })).toBe('Resumo')
|
|
expect(speechSemanticSourceText({ searchText: 'busca' })).toBe('busca')
|
|
expect(speechSemanticSourceText({})).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('planSpeechIndex', () => {
|
|
const model = 'test-model'
|
|
|
|
it('indexes the ASR segments by their own order and skips blanks', () => {
|
|
const plan = planSpeechIndex(
|
|
{
|
|
segments: [
|
|
{ order: 1, startSeconds: 0, text: 'Primeiro trecho' },
|
|
{ order: 2, startSeconds: 3, text: ' ' },
|
|
{ order: 3, startSeconds: 6, text: 'Terceiro trecho' },
|
|
],
|
|
},
|
|
{ model },
|
|
)
|
|
|
|
expect(plan?.units).toEqual([
|
|
{ kind: 'segment', order: 1, startSeconds: 0, text: 'Primeiro trecho' },
|
|
{ kind: 'segment', order: 3, startSeconds: 6, text: 'Terceiro trecho' },
|
|
])
|
|
expect(plan?.speechHash).toBe(
|
|
speechEmbeddingHash('segment:1:Primeiro trecho\nsegment:3:Terceiro trecho', model),
|
|
)
|
|
})
|
|
|
|
it('falls back to text windows when the speech has no segments', () => {
|
|
const plan = planSpeechIndex({ officialTranscript: 'fala '.repeat(400) }, { model })
|
|
|
|
expect(plan?.units.length).toBeGreaterThan(1)
|
|
expect(plan?.units.every((unit) => unit.kind === 'window')).toBe(true)
|
|
expect(plan?.units[0]).toMatchObject({ order: 0, startSeconds: null })
|
|
})
|
|
|
|
it('returns null without any usable text', () => {
|
|
expect(planSpeechIndex({ segments: [] }, { model })).toBeNull()
|
|
expect(planSpeechIndex({ officialTranscript: ' ', summary: null }, { model })).toBeNull()
|
|
})
|
|
|
|
it('changes the hash with the model so a model swap reindexes', () => {
|
|
const speech = { segments: [{ order: 1, startSeconds: 0, text: 'Fala' }] }
|
|
expect(planSpeechIndex(speech, { model: 'a' })?.speechHash).not.toBe(
|
|
planSpeechIndex(speech, { model: 'b' })?.speechHash,
|
|
)
|
|
})
|
|
|
|
it('plans the official metadata and pins it in the hash (C237)', () => {
|
|
const speech = {
|
|
summary: 'Processo de impeachment da Presidenta Dilma Rousseff.',
|
|
keywords: ['IMPEACHMENT', 'DILMA ROUSSEFF'],
|
|
segments: [{ order: 1, startSeconds: 0, text: 'Sr. Presidente...' }],
|
|
}
|
|
const plan = planSpeechIndex(speech, { model })
|
|
|
|
expect(plan?.metadataText).toBe(
|
|
'Processo de impeachment da Presidenta Dilma Rousseff.\nIMPEACHMENT, DILMA ROUSSEFF',
|
|
)
|
|
expect(plan?.speechHash).toBe(
|
|
speechEmbeddingHash(
|
|
'segment:1:Sr. Presidente...\nmetadata:Processo de impeachment da Presidenta Dilma Rousseff.\nIMPEACHMENT, DILMA ROUSSEFF',
|
|
model,
|
|
),
|
|
)
|
|
// Editing the summary or the keywords must reindex the speech.
|
|
expect(
|
|
planSpeechIndex({ ...speech, summary: 'Outro sumário' }, { model })?.speechHash,
|
|
).not.toBe(plan?.speechHash)
|
|
expect(planSpeechIndex({ ...speech, keywords: ['OUTRA'] }, { model })?.speechHash).not.toBe(
|
|
plan?.speechHash,
|
|
)
|
|
// A speech without metadata keeps the pre-C237 hash bytes.
|
|
expect(planSpeechIndex({ segments: speech.segments }, { model })?.metadataText).toBeNull()
|
|
expect(planSpeechIndex({ segments: speech.segments }, { model })?.speechHash).toBe(
|
|
speechEmbeddingHash('segment:1:Sr. Presidente...', model),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('speechMetadataText (C237)', () => {
|
|
it('prefers the summary over the title and joins the raw keywords', () => {
|
|
expect(
|
|
speechMetadataText({ summary: 'Resumo', title: 'Título', keywords: ['SUS', ' Saúde '] }),
|
|
).toBe('Resumo\nSUS, Saúde')
|
|
expect(speechMetadataText({ title: 'Título', keywords: [] })).toBe('Título')
|
|
expect(speechMetadataText({ summary: ' ', keywords: [' '] })).toBeNull()
|
|
expect(speechMetadataText({})).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('composeSpeechVector (C237)', () => {
|
|
const unit = [0.6, 0.8]
|
|
|
|
it('keeps the pure mean without metadata', () => {
|
|
expect(composeSpeechVector([unit], null)).toEqual(meanPoolVectors([unit]))
|
|
// A metadata vector of another dimension never poisons the mean.
|
|
expect(composeSpeechVector([unit], [1, 0, 0])).toEqual(meanPoolVectors([unit]))
|
|
})
|
|
|
|
it('pulls the speech vector toward the weighted metadata', () => {
|
|
const composed = composeSpeechVector([unit], [1, 0])
|
|
|
|
expect(composed).toEqual(
|
|
normalizeVector([unit[0] + SPEECH_METADATA_VECTOR_WEIGHT * 1, unit[1]]),
|
|
)
|
|
expect(cosineSimilarity(composed, [1, 0])).toBeGreaterThan(cosineSimilarity(unit, [1, 0]))
|
|
})
|
|
})
|