teqo-backup/tests/unit/speechSemantic.unit.spec.ts
Francisco Solla 3201b7a93f feat(C237): vetor da fala inclui o sumário/keywords oficiais
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
2026-09-29 22:53:41 -03:00

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]))
})
})