teqo-backup/tests/int/webSpeechAcervo.int.spec.ts
Francisco Solla 3e8429daae feat(C229): busca por sentido no acervo de falas
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
2026-09-26 11:59:21 -03:00

436 lines
15 KiB
TypeScript

// @vitest-environment node
import { randomUUID } from 'node:crypto'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
import config from '@/payload.config'
import { DEEPINFRA_EMBED_MODEL } from '@/utilities/ai/deepInfraEmbed'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import {
loadSpeechAcervoPageData,
loadSpeechDetailPageData,
loadWebSpeechAcervoPageData,
loadWebSpeechDetailPageData,
loadWebSpeechTitleForActor,
SpeechNotFoundError,
} from '@/utilities/speech/speechPageData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
import {
createInternetSpeechMedia,
WEB_SPEECH_MP3_BYTES,
WEB_SPEECH_MP4_BYTES,
} from '../helpers/webSpeechMediaFixture'
// C216 — the "Falas na internet" loaders over the real Payload boundary: the
// list reads only web rows, the facets answer over the web catalog, the detail
// carries the private media routes and the Câmara detail never serves a web row
// (and vice versa). The private routes themselves are e2e territory; here the
// view model contract is the target.
let payload: Payload
const createdSourceKeys = new Set<string>()
const createdMediaIds = new Set<number>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const createMedia = async (bytes: Buffer, filename: string): Promise<number> => {
const mediaId = await createInternetSpeechMedia(payload, bytes, filename)
createdMediaIds.add(mediaId)
return mediaId
}
const webBundle = (
overrides: Partial<SpeechImportBundle> = {},
sourceKey = `web:test:${randomUUID()}`,
): SpeechImportBundle => {
createdSourceKeys.add(sourceKey)
return {
...speechBundleFixture(),
sourceKey,
origin: 'web',
platform: 'youtube',
externalId: sourceKey,
sourceUrl: `https://www.youtube.com/watch?v=${encodeURIComponent(sourceKey)}`,
title: 'Fala publicada na internet',
channel: 'Canal do teste',
// Câmara-only coordinates never travel on a web row.
legislature: null,
type: null,
phase: null,
eventId: null,
audioId: null,
excerptTMs: null,
vodPlaybackUrl: null,
vodDownloadUrl: null,
officialTextUrl: null,
officialTranscript: null,
summary: null,
presidingOfficer: null,
...overrides,
}
}
const createWebSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = webBundle(overrides)
await upsertSpeechBundle(payload, bundle)
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: bundle.sourceKey } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const speech = found.docs[0]
if (!speech) throw new Error('web speech fixture was not created')
return speech.id
}
/** C229 — one hand-picked index row for the web source (vectors are tiny on purpose). */
const seedSpeechEmbedding = async (
speechId: number,
kind: 'speech' | 'segment' | 'window',
order: number | null,
vector: number[],
): Promise<void> => {
await payload.create({
collection: 'speechEmbedding',
data: {
speech: speechId,
kind,
order,
model: DEEPINFRA_EMBED_MODEL,
dimensions: vector.length,
contentHash: `${kind}-${order ?? 'root'}-${vector.join(',')}`,
vector,
},
overrideAccess: true,
})
}
const createCamaraSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = speechBundleFixture(overrides)
createdSourceKeys.add(bundle.sourceKey)
await upsertSpeechBundle(payload, bundle)
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: bundle.sourceKey } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const speech = found.docs[0]
if (!speech) throw new Error('Câmara speech fixture was not created')
return speech.id
}
const createUsers = async () => {
const fixtures = campaignFixtures()
const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
fixtures.createCampaignUser('communicator'),
fixtures.createCampaignUser('coordinator'),
fixtures.createCampaignUser('candidate'),
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('leader'),
])
return { communicator, coordinator, candidate, advisor, leader }
}
describe('web speech acervo (C216)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
for (const sourceKey of createdSourceKeys) {
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: sourceKey } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const speech = found.docs[0]
if (speech) {
await payload.delete({
collection: 'speech',
id: speech.id,
depth: 0,
overrideAccess: true,
})
}
}
for (const mediaId of createdMediaIds) {
await payload
.delete({
collection: INTERNET_SPEECH_MEDIA_SLUG,
id: mediaId,
depth: 0,
overrideAccess: true,
})
.catch(() => undefined)
}
})
it('lists only web rows: the Câmara speech with the same marker stays invisible', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `internet${runId}`
const webId = await createWebSpeech({
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} na internet` }],
})
const camaraId = await createCamaraSpeech({
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} na Câmara` }],
})
const { communicator } = await createUsers()
const data = await loadWebSpeechAcervoPageData(payload, communicator, { q: marker })
expect(data.totalDocs).toBe(1)
expect(data.rows.map((row) => row.id)).toEqual([webId])
expect(data.rows[0]?.excerpt.parts.map((part) => part.text).join('')).toContain(marker)
// And the Câmara list does not pick the web row up.
const camara = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
expect(camara.rows.map((row) => row.id)).toEqual([camaraId])
})
it('finds a web speech by a term in a transcript over 40,000 characters', async () => {
const marker = `long-final-${randomUUID().slice(0, 8)}`
const longText = 'palavra '.repeat(3_000)
const id = await createWebSpeech({
segments: [
{ startSeconds: 0, endSeconds: 120, text: `${longText} primeiro` },
{ startSeconds: 120, endSeconds: 240, text: `${longText} segundo` },
{ startSeconds: 240, endSeconds: 360, text: `${longText} ${marker}` },
],
})
const { communicator } = await createUsers()
const data = await loadWebSpeechAcervoPageData(payload, communicator, { q: marker })
expect(data.totalDocs).toBe(1)
expect(data.rows.map((row) => row.id)).toEqual([id])
})
it('filters by topic, year, duration and cited municipality over the web catalog', async () => {
const id = await createWebSpeech({
speechAt: '2021-04-12T00:00',
durationSeconds: 90,
facets: {
topics: ['igualdade-racial'],
scopes: ['brasil'],
municipalities: matchMunicipalityMentions('Feira de Santana'),
people: [],
programs: [],
projects: [],
classifiedBy: 'gazetteer',
},
})
const { communicator } = await createUsers()
const municipality = await payload.find({
collection: 'municipality',
where: { slug: { equals: 'feira-de-santana' } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const municipalityId = municipality.docs[0]?.id
if (!municipalityId) throw new Error('seeded municipality missing')
const data = await loadWebSpeechAcervoPageData(payload, communicator, {
topic: 'igualdade-racial',
year: '2021',
duration: 'curta',
municipality: String(municipalityId),
})
expect(data.rows.map((row) => row.id)).toContain(id)
expect(data.filterOptions.years).toContain(2021)
// The web source has no Fase facet.
expect(data.filterOptions.phases).toEqual([])
})
it('orders by publication date and gates the duration orders', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `ordem${runId}`
const older = await createWebSpeech({
speechAt: '2020-01-10T00:00',
durationSeconds: 90,
title: `Mais antiga ${marker}`,
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} antiga` }],
})
const newer = await createWebSpeech({
speechAt: '2026-01-10T00:00',
durationSeconds: null,
title: `Mais recente ${marker}`,
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} recente` }],
})
const { communicator } = await createUsers()
const byRecency = await loadWebSpeechAcervoPageData(payload, communicator, { q: marker })
expect(byRecency.rows.map((row) => row.id)).toEqual([newer, older])
// A duration order only lists rows with a measured duration.
const byDuration = await loadWebSpeechAcervoPageData(payload, communicator, {
q: marker,
sort: 'duracao_maior',
})
expect(byDuration.state.sort).toBe('duracao_maior')
expect(byDuration.rows.map((row) => row.id)).toEqual([older])
})
it('runs the sense engine only over the web rows and keeps the evidence (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `temaweb${runId}`
const id = await createWebSpeech({
segments: [
{
startSeconds: 0,
endSeconds: 3,
text: `Garantir o atendimento universal ${marker} na rede pública`,
},
],
})
await seedSpeechEmbedding(id, 'speech', null, [1, 0])
await seedSpeechEmbedding(id, 'segment', 1, [1, 0])
const { coordinator } = await createUsers()
const embedCalls: string[] = []
const data = await loadWebSpeechAcervoPageData(
payload,
coordinator,
{ source: 'internet', q: 'defesa do SUS', mode: 'tema' },
async (query) => {
embedCalls.push(query)
return [1, 0]
},
)
expect(embedCalls).toEqual(['defesa do SUS'])
expect(data.themeUnavailable).toBe(false)
expect(data.themeApplied).toBe(true)
const row = data.rows.find((item) => item.id === id)
expect(row?.semanticMatch).toBe(true)
expect(row?.matchedTextSearch).toBe(false)
expect(row?.excerpt.parts.map((part) => part.text).join('')).toContain(marker)
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(false)
})
it('lets an explicit order override relevance in the theme mode (C229)', async () => {
const runId = randomUUID().slice(0, 8)
const older = await createWebSpeech({
speechAt: '2020-01-01T00:00',
segments: [{ startSeconds: 0, endSeconds: 3, text: `Fala tema ${runId}` }],
})
const newer = await createWebSpeech({
speechAt: '2026-01-01T00:00',
segments: [{ startSeconds: 0, endSeconds: 3, text: `Fala tema ${runId}` }],
})
await seedSpeechEmbedding(older, 'speech', null, [1, 0])
await seedSpeechEmbedding(newer, 'speech', null, [0.9, 0.435])
const { coordinator } = await createUsers()
const byRelevance = await loadWebSpeechAcervoPageData(
payload,
coordinator,
{ source: 'internet', q: `tema ${runId}`, mode: 'tema' },
async () => [1, 0],
)
expect(byRelevance.rows.map((row) => row.id).indexOf(older)).toBeLessThan(
byRelevance.rows.map((row) => row.id).indexOf(newer),
)
const byDate = await loadWebSpeechAcervoPageData(
payload,
coordinator,
{ source: 'internet', q: `tema ${runId}`, mode: 'tema', sort: 'recentes' },
async () => [1, 0],
)
expect(byDate.state.sort).toBe('recentes')
expect(byDate.rows.map((row) => row.id).indexOf(newer)).toBeLessThan(
byDate.rows.map((row) => row.id).indexOf(older),
)
})
it('carries the private routes, the platform and the origin attribution in the detail', async () => {
const mediaId = await createMedia(WEB_SPEECH_MP3_BYTES, 'source.mp3')
const id = await createWebSpeech({
platform: 'radio',
title: 'Entrevista na rádio',
channel: 'Rádio Metrópole',
sourceUrl: 'https://radio.example/entrevista',
speechAt: '2026-09-21T00:00',
mirroredMedia: mediaId,
segments: [{ startSeconds: 0, endSeconds: 4, text: 'A saúde pública baiana' }],
})
const { communicator } = await createUsers()
const view = await loadWebSpeechDetailPageData(payload, communicator, id, 'saúde')
expect(view.title).toBe('Entrevista na rádio')
expect(view.platform).toEqual({ value: 'radio', label: 'Rádio' })
expect(view.channel).toBe('Rádio Metrópole')
expect(view.sourceUrl).toBe('https://radio.example/entrevista')
expect(view.dateLabel).toBe('21/09/2026')
expect(view.mediaKind).toBe('audio')
expect(view.fileHref).toBe(`/campanha/comunicacao/acervo/internet/${id}/arquivo`)
expect(view.downloadHref).toBe(`/campanha/comunicacao/acervo/internet/${id}/arquivo?download=1`)
expect(view.segments[0]?.parts.some((part) => part.highlighted)).toBe(true)
})
it('points the cover at the private route only when a thumbnail was captured', async () => {
const thumbnailId = await createMedia(WEB_SPEECH_MP4_BYTES, 'thumb.mp4')
const withCover = await createWebSpeech({ thumbnail: thumbnailId })
const withoutCover = await createWebSpeech({})
const { communicator } = await createUsers()
const data = await loadWebSpeechAcervoPageData(payload, communicator, {})
const row = (id: number) => data.rows.find((item) => item.id === id)
expect(row(withCover)?.thumbnailUrl).toBe(
`/campanha/comunicacao/acervo/internet/${withCover}/capa`,
)
expect(row(withoutCover)?.thumbnailUrl).toBeNull()
})
it('never serves one origin from the other origin detail loader', async () => {
const webId = await createWebSpeech({})
const camaraId = await createCamaraSpeech({})
const { communicator } = await createUsers()
await expect(
loadSpeechDetailPageData(payload, communicator, webId, undefined, async () => null),
).rejects.toBeInstanceOf(SpeechNotFoundError)
await expect(
loadWebSpeechDetailPageData(payload, communicator, camaraId),
).rejects.toBeInstanceOf(SpeechNotFoundError)
await expect(loadWebSpeechTitleForActor(payload, communicator, camaraId)).resolves.toBeNull()
})
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
const id = await createWebSpeech({})
const { communicator, coordinator, candidate, advisor, leader } = await createUsers()
for (const reader of [communicator, coordinator, candidate]) {
const data = await loadWebSpeechAcervoPageData(payload, reader, {})
expect(data.rows.map((row) => row.id)).toContain(id)
await expect(loadWebSpeechDetailPageData(payload, reader, id)).resolves.toBeDefined()
}
for (const denied of [advisor, leader]) {
await expect(loadWebSpeechAcervoPageData(payload, denied, {})).rejects.toThrow(/permissão/i)
await expect(loadWebSpeechDetailPageData(payload, denied, id)).rejects.toThrow(/permissão/i)
}
})
})