mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Troca o motor do modo tema (Câmara + internet) de expansão lexical para embeddings (Deep Infra, BAAI/bge-m3), com índice próprio (speechEmbedding), ranking por proximidade em memória, evidência real do trecho mais próximo, vazio honesto por limiar e degradação para a busca literal. Inclui a collection oculta + migration, o CLI pnpm acervo:index (--dry-run/--limit/ --source/--force/--probe), o port do design aprovado e a suíte de testes. Pós-rebase: o snapshot da migration seguinte (20260926_062039_add_archive_photo, do C231 já na main) foi regenerado para incluir speech_embedding — sem ele o próximo migrate:create recriaria a tabela. Closes #1360
259 lines
9.6 KiB
TypeScript
259 lines
9.6 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import { speechPosterHref } from '@/lib/speechPoster'
|
|
import {
|
|
toSpeechListItemViewModel,
|
|
toWebSpeechDetailViewModel,
|
|
toWebSpeechListItemViewModel,
|
|
type SpeechListRecord,
|
|
type WebSpeechDetailRecord,
|
|
type WebSpeechListRecord,
|
|
} from '@/utilities/speech/speechViewModels'
|
|
|
|
const viewModel = (overrides: Partial<SpeechListRecord> = {}) =>
|
|
toSpeechListItemViewModel({
|
|
speech: { id: 1, speechAt: '2026-08-11T18:48', ...overrides },
|
|
segments: [],
|
|
municipalityLabels: new Map(),
|
|
})
|
|
|
|
describe('toSpeechListItemViewModel thumbnail (C175)', () => {
|
|
it('carries the YouTube cover when the session link parses', () => {
|
|
const row = viewModel({ youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A' })
|
|
|
|
expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
|
|
})
|
|
|
|
it('has no thumbnail without a YouTube link', () => {
|
|
expect(viewModel().thumbnailUrl).toBeNull()
|
|
expect(viewModel({ youtubeUrl: null }).thumbnailUrl).toBeNull()
|
|
expect(viewModel({ youtubeUrl: 'https://camara.leg.br/discurso' }).thumbnailUrl).toBeNull()
|
|
})
|
|
|
|
it('keeps the source link independent from the thumbnail', () => {
|
|
const row = viewModel({
|
|
youtubeUrl: 'https://youtu.be/lLhRDkSPw0A',
|
|
officialTextUrl: 'https://camara.leg.br/discurso',
|
|
})
|
|
|
|
expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
|
|
expect(row.officialTextUrl).toBe('https://camara.leg.br/discurso')
|
|
})
|
|
|
|
it('has no source link without the official text, never the YouTube fallback (C177)', () => {
|
|
expect(viewModel().officialTextUrl).toBeNull()
|
|
expect(viewModel({ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A' }).officialTextUrl).toBeNull()
|
|
expect(viewModel({ officialTextUrl: 'https://camara.leg.br/discurso' }).officialTextUrl).toBe(
|
|
'https://camara.leg.br/discurso',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('toSpeechListItemViewModel thumbnail (C182 — frame of the speech)', () => {
|
|
const coordinates = { eventId: 67091, audioId: 558641, excerptTMs: 1675801808560 }
|
|
const cover = 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg'
|
|
const youtubeUrl = 'https://www.youtube.com/watch?v=lLhRDkSPw0A'
|
|
|
|
it('prefers the frame route over the session cover when the Câmara can resolve the excerpt', () => {
|
|
const row = viewModel({ id: 42, durationSeconds: 252, ...coordinates, youtubeUrl })
|
|
|
|
expect(row.thumbnailUrl).toBe(speechPosterHref(42))
|
|
})
|
|
|
|
it('falls back to the session cover without coordinates or without a duration', () => {
|
|
expect(viewModel({ durationSeconds: 252, youtubeUrl }).thumbnailUrl).toBe(cover)
|
|
expect(viewModel({ durationSeconds: 0, ...coordinates, youtubeUrl }).thumbnailUrl).toBe(cover)
|
|
expect(viewModel({ durationSeconds: 252, eventId: 67091 }).thumbnailUrl).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('toSpeechListItemViewModel semantic evidence (C229)', () => {
|
|
const segment = (text: string, startSeconds = 0) => ({
|
|
startSeconds,
|
|
endSeconds: startSeconds + 3,
|
|
text,
|
|
})
|
|
const semanticRow = ({
|
|
text,
|
|
query,
|
|
evidence,
|
|
keywords = [],
|
|
searchText,
|
|
}: {
|
|
text: string
|
|
query?: string
|
|
evidence?: { text: string; startSeconds: number | null } | null
|
|
keywords?: string[]
|
|
searchText?: string
|
|
}) =>
|
|
toSpeechListItemViewModel({
|
|
speech: { id: 1, speechAt: '2026-08-11T18:48', keywords, searchText },
|
|
segments: [segment(text)],
|
|
query,
|
|
municipalityLabels: new Map(),
|
|
semanticMatch: Boolean(evidence),
|
|
semanticEvidence: evidence,
|
|
})
|
|
|
|
it('shows the semantic passage with no highlight when no term matches', () => {
|
|
const row = semanticRow({
|
|
text: 'Uma fala sobre outro assunto',
|
|
query: 'combate à oposição',
|
|
evidence: { text: 'O embate com a oposição e o bolsonarismo', startSeconds: 42 },
|
|
})
|
|
|
|
expect(row.matchKind).toBe('theme')
|
|
expect(row.semanticMatch).toBe(true)
|
|
expect(row.matchedTextSearch).toBe(false)
|
|
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(false)
|
|
expect(row.excerpt.parts.map((part) => part.text).join('')).toContain('bolsonarismo')
|
|
expect(row.watchHref).toBe(
|
|
'/campanha/comunicacao/acervo/1?t=42&q=combate+%C3%A0+oposi%C3%A7%C3%A3o',
|
|
)
|
|
})
|
|
|
|
it('keeps the literal match flag when the speech also contains the query', () => {
|
|
const row = semanticRow({
|
|
text: 'A defesa do SUS e da saúde pública baiana',
|
|
searchText: 'a defesa do sus e da saude publica baiana',
|
|
query: 'SUS',
|
|
evidence: { text: 'A defesa do SUS', startSeconds: 0 },
|
|
})
|
|
|
|
expect(row.semanticMatch).toBe(true)
|
|
expect(row.matchedTextSearch).toBe(true)
|
|
// The evidence never highlights the term: the engine did not match it.
|
|
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(false)
|
|
})
|
|
|
|
it('stays literal without semantic evidence', () => {
|
|
const row = semanticRow({
|
|
text: 'A defesa do SUS',
|
|
searchText: 'a defesa do sus',
|
|
query: 'SUS',
|
|
})
|
|
|
|
expect(row.semanticMatch).toBe(false)
|
|
expect(row.matchKind).toBe('segment')
|
|
expect(row.matchedTextSearch).toBe(true)
|
|
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
|
|
})
|
|
|
|
it('seeks the evidence start when known and omits the seek without one', () => {
|
|
const window = semanticRow({
|
|
text: 'Outra fala',
|
|
query: 'tema',
|
|
evidence: { text: 'Trecho de janela', startSeconds: null },
|
|
})
|
|
expect(window.watchHref).toBe('/campanha/comunicacao/acervo/1?q=tema')
|
|
})
|
|
|
|
it('flags a degraded literal row without claiming the theme', () => {
|
|
const row = toSpeechListItemViewModel({
|
|
speech: { id: 1, speechAt: '2026-08-11T18:48', searchText: 'a defesa do sus' },
|
|
segments: [segment('A defesa do SUS')],
|
|
query: 'SUS',
|
|
municipalityLabels: new Map(),
|
|
literalFallback: true,
|
|
})
|
|
|
|
expect(row.literalFallback).toBe(true)
|
|
expect(row.semanticMatch).toBe(false)
|
|
expect(row.matchKind).toBe('segment')
|
|
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// C216 — the web speeches view models: platform label, day-only date, the
|
|
// private cover href, the media kind of the detail and the origin attribution.
|
|
describe('web speech view models (C216)', () => {
|
|
const webListRow = (overrides: Partial<WebSpeechListRecord> = {}) =>
|
|
toWebSpeechListItemViewModel({
|
|
speech: {
|
|
id: 7,
|
|
speechAt: '2026-09-20T00:00',
|
|
title: 'Entrevista na rádio',
|
|
platform: 'radio',
|
|
durationSeconds: 90,
|
|
topics: ['saude'],
|
|
scopes: ['bahia'],
|
|
...overrides,
|
|
},
|
|
segments: [],
|
|
query: undefined,
|
|
})
|
|
|
|
it('labels the platform and the day-only publication date', () => {
|
|
const row = webListRow()
|
|
expect(row.platform).toEqual({ value: 'radio', label: 'Rádio' })
|
|
expect(row.dateLabel).toBe('20/09/2026')
|
|
expect(row.durationLabel).toBe('1min30s')
|
|
expect(row.title).toBe('Entrevista na rádio')
|
|
})
|
|
|
|
it('falls back for a row without a platform or title', () => {
|
|
const row = webListRow({ platform: null, title: ' ' })
|
|
expect(row.platform).toEqual({ value: null, label: 'Outra plataforma' })
|
|
expect(row.title).toBe('Fala da internet #7')
|
|
})
|
|
|
|
it('links the private cover only when the ingestion captured a thumbnail', () => {
|
|
expect(webListRow().thumbnailUrl).toBeNull()
|
|
expect(webListRow({ thumbnail: 12 }).thumbnailUrl).toBe(
|
|
'/campanha/comunicacao/acervo/internet/7/capa',
|
|
)
|
|
expect(webListRow({ thumbnail: { id: 12 } }).thumbnailUrl).toBe(
|
|
'/campanha/comunicacao/acervo/internet/7/capa',
|
|
)
|
|
})
|
|
|
|
it('builds the detail href with the segment seek and keeps the query', () => {
|
|
expect(webListRow().watchHref).toBe('/campanha/comunicacao/acervo/internet/7')
|
|
|
|
const row = toWebSpeechListItemViewModel({
|
|
speech: { id: 7, speechAt: '2026-09-20T00:00', title: 'Fala' },
|
|
segments: [{ startSeconds: 12.7, endSeconds: 15, text: 'A saúde pública importa' }],
|
|
query: 'saúde',
|
|
})
|
|
expect(row.watchHref).toBe('/campanha/comunicacao/acervo/internet/7?t=12&q=sa%C3%BAde')
|
|
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
|
|
})
|
|
|
|
it('picks the native control from the stored mime type', () => {
|
|
const detail = (mirroredMedia: WebSpeechDetailRecord['mirroredMedia']) =>
|
|
toWebSpeechDetailViewModel({
|
|
speech: {
|
|
id: 7,
|
|
speechAt: '2026-09-20T00:00',
|
|
title: 'Entrevista na rádio',
|
|
platform: 'radio',
|
|
channel: 'Rádio Metrópole',
|
|
sourceUrl: 'https://radio.example/entrevista',
|
|
durationSeconds: 87,
|
|
mirroredMedia,
|
|
},
|
|
segments: [{ startSeconds: 0, endSeconds: 4, text: 'A saúde pública' }],
|
|
})
|
|
|
|
const audio = detail({ id: 12, mimeType: 'audio/mpeg', filename: 'source.mp3' })
|
|
expect(audio.mediaKind).toBe('audio')
|
|
expect(audio.fileHref).toBe('/campanha/comunicacao/acervo/internet/7/arquivo')
|
|
expect(audio.downloadHref).toBe('/campanha/comunicacao/acervo/internet/7/arquivo?download=1')
|
|
expect(audio.channel).toBe('Rádio Metrópole')
|
|
expect(audio.sourceUrl).toBe('https://radio.example/entrevista')
|
|
expect(audio.dateLabel).toBe('20/09/2026')
|
|
expect(audio.durationSeconds).toBe(87)
|
|
expect(audio.durationLabel).toBe('1min27s')
|
|
expect(audio.segments[0]?.startLabel).toBe('00:00')
|
|
|
|
const video = detail({ id: 12, mimeType: 'video/mp4', filename: 'source.mp4' })
|
|
expect(video.mediaKind).toBe('video')
|
|
|
|
const missing = detail(null)
|
|
expect(missing.mediaKind).toBe('video')
|
|
expect(missing.fileHref).toBeNull()
|
|
expect(missing.downloadHref).toBeNull()
|
|
})
|
|
})
|