teqo-backup/tests/int/speechAcervo.int.spec.ts
Francisco Solla 45eef25b1a feat(C192): busca semântica no acervo de falas (Closes #1147)
Modo "Por tema" no acervo de falas: expansão determinística do tema via
DeepSeek (generateObject, timeout, fallback null) alimenta o motor lexical
existente (searchText/keywords OR), mantendo facetas AND, paginação por fala,
o OR de cortes do C174 e o access control intacto. Resultado de tema mostra
selo e "Por que apareceu" com trecho real (frase contínua), sem score;
degradação honesta para termo exato com aviso e vazio honesto com saídas.

- URL: mode=tema (default omitido; canonicaliza sem q), seletor otimista
- ai/expandSpeechSearchTheme + lib/speechThemeTerms (sanitiza saída do modelo)
- view model com themeMatchTerm + espelho speechMatchesSearchTerm
- UI do card de tema (selos, proveniência, ações) e aviso degradado
- sem migration/Consent/exposição pública; gate de leitura inalterado
- testes unit/int/e2e; design hi-fi estendido e certificado pelo designer
2026-09-18 17:48:28 -03:00

419 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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 { speechPosterHref } from '@/lib/speechPoster'
import config from '@/payload.config'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import {
loadSpeechAcervoPageData,
loadSpeechDetailPageData,
SpeechNotFoundError,
type SpeechVideoStartResolver,
} from '@/utilities/speech/speechPageData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
let payload: Payload
const createdSourceKeys = new Set<string>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const feiraDeSantana = () => matchMunicipalityMentions('Feira de Santana')
const feiraDeSantanaId = async (): Promise<number> => {
const found = await payload.find({
collection: 'municipality',
where: { slug: { equals: 'feira-de-santana' } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const municipality = found.docs[0]
if (!municipality) throw new Error('seeded municipality missing')
return municipality.id
}
const baseBundle = (overrides: Partial<SpeechImportBundle> = {}): SpeechImportBundle => {
const bundle = speechBundleFixture(overrides)
createdSourceKeys.add(bundle.sourceKey)
return bundle
}
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = baseBundle(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('fixture speech was not created')
return speech.id
}
/**
* C172 — the detail loader asks YouTube for the session video's start by
* default; the fixtures keep the suite offline by injecting a resolver.
*/
const noVideoStart: SpeechVideoStartResolver = async () => null
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('speech acervo (C154)', () => {
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,
})
}
}
})
it('searches the segments accent-insensitively and paginates by speech', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `zebra${runId}`
const first = await createSpeech({
segments: [
{ startSeconds: 0, endSeconds: 3, text: `A defesa da saúde ${marker}` },
{ startSeconds: 3, endSeconds: 6, text: `Mais saúde ${marker} para a Bahia` },
],
})
const second = await createSpeech({
segments: [{ startSeconds: 0, endSeconds: 3, text: `Saúde ${marker} no interior` }],
})
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, coordinator, {
q: `saude ${marker}`,
})
// Three matching segments, two speeches: the count is per speech.
expect(data.totalDocs).toBe(2)
expect(data.rows.map((row) => row.id).sort()).toEqual([first, second].sort())
expect(data.rows.every((row) => row.matchKind === 'segment')).toBe(true)
const excerptText = data.rows
.flatMap((row) => row.excerpt.parts)
.map((part) => part.text)
.join('')
expect(excerptText).toContain(marker)
})
it('matches an official keyword as an exact term', async () => {
const runId = randomUUID().slice(0, 8)
const id = await createSpeech({
keywords: [`Farmácia Popular ${runId}`],
segments: [{ startSeconds: 0, endSeconds: 2, text: 'Discurso sem o termo buscado' }],
})
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, {
q: `Farmácia Popular ${runId}`,
})
const row = data.rows.find((item) => item.id === id)
expect(row?.matchKind).toBe('keyword')
})
it('filters by topic, year, duration and cited municipality', async () => {
const id = await createSpeech({
speechAt: '2019-05-10T15:00',
year: 2019,
durationSeconds: 90,
facets: {
topics: ['igualdade-racial'],
scopes: ['brasil'],
municipalities: feiraDeSantana(),
people: [],
programs: [],
projects: [],
classifiedBy: 'gazetteer',
},
})
const { communicator } = await createUsers()
const municipalityId = await feiraDeSantanaId()
const data = await loadSpeechAcervoPageData(payload, communicator, {
topic: 'igualdade-racial',
year: '2019',
duration: 'curta',
municipality: String(municipalityId),
})
expect(data.rows.map((row) => row.id)).toContain(id)
expect(data.filterOptions.years).toContain(2019)
})
it('returns the detail with segments, cited municipalities and the seek offset', async () => {
const id = await createSpeech({
segments: [
{ startSeconds: 0, endSeconds: 3, text: 'Primeiro trecho' },
{ startSeconds: 3, endSeconds: 8, text: 'O trecho sobre a saúde' },
],
})
const { communicator } = await createUsers()
const view = await loadSpeechDetailPageData(payload, communicator, id, undefined, noVideoStart)
expect(view.id).toBe(id)
expect(view.segments).toHaveLength(2)
expect(view.segments[1]?.startSeconds).toBe(3)
// C166 — the excerpt picker needs the raw duration and the day-only label.
expect(view.durationSeconds).toBe(252)
expect(view.speechDateLabel).toBe('07/02/2023')
expect(view.municipalities.map((municipality) => municipality.name)).toContain(
'Feira de Santana',
)
expect(view.presidingOfficer).toBe('Pompeo de Mattos')
})
it('derives the YouTube default and the resoluble VOD from the detail row (C162)', async () => {
const withBoth = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
vodPlaybackUrl: 'https://vod.camara.leg.br/old.mp4',
vodDownloadUrl: 'https://vod.camara.leg.br/old.mp4',
excerptTMs: 1786473834650,
eventStartAt: '2026-08-11T15:00',
})
const onlyYoutube = await createSpeech({ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A' })
const { communicator } = await createUsers()
const both = await loadSpeechDetailPageData(
payload,
communicator,
withBoth,
undefined,
noVideoStart,
)
expect(both.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(both.youtubeOffsetSeconds).toBe(2634)
expect(both.vodResolvable).toBe(true)
const youtubeOnly = await loadSpeechDetailPageData(
payload,
communicator,
onlyYoutube,
undefined,
noVideoStart,
)
expect(youtubeOnly.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(youtubeOnly.vodResolvable).toBe(false)
})
it('moves the YouTube offset to the video start, measured evidence winning (C172)', async () => {
// No measurement for this video: the anchor the resolver answers is applied.
const anchored = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
excerptTMs: 1786473834650,
eventStartAt: '2026-08-11T15:00',
})
// Fala 997's session video has a measured lag (13s): it wins with no anchor.
const measured = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=DC_i9Kp1LVk',
excerptTMs: 1786485082000,
eventStartAt: '2026-08-11T15:32',
})
const { communicator } = await createUsers()
const withAnchor = await loadSpeechDetailPageData(
payload,
communicator,
anchored,
undefined,
async () => '2026-08-11T18:00:40Z',
)
// 2634s of session offset − 40s of video start delay.
expect(withAnchor.youtubeOffsetSeconds).toBe(2594)
let anchorCalls = 0
const withEvidence = await loadSpeechDetailPageData(
payload,
communicator,
measured,
undefined,
async () => {
anchorCalls += 1
return null
},
)
// 11962s of session offset − the measured 13s, and the measured recording
// never pays the anchor lookup.
expect(withEvidence.youtubeOffsetSeconds).toBe(11949)
expect(anchorCalls).toBe(0)
})
it('keeps the direct download link off the list view model (C162)', async () => {
const id = await createSpeech({ vodDownloadUrl: 'https://vod.camara.leg.br/old.mp4' })
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, {})
const row = data.rows.find((item) => item.id === id)
expect(row).toBeDefined()
expect(row).not.toHaveProperty('downloadUrl')
})
it('points the list thumbnail at the frame of the speech, or the YouTube cover (C182)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `miniatura${runId}`
const resolvable = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} no plenário` }],
})
const youtubeOnly = await createSpeech({
eventId: null,
audioId: null,
excerptTMs: null,
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} só no YouTube` }],
})
const withoutVideo = await createSpeech({
eventId: null,
audioId: null,
excerptTMs: null,
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} sem vídeo` }],
})
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
const row = (id: number) => data.rows.find((item) => item.id === id)
expect(row(resolvable)?.thumbnailUrl).toBe(speechPosterHref(resolvable))
expect(row(youtubeOnly)?.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
expect(row(withoutVideo)?.thumbnailUrl).toBeNull()
// The raw VOD coordinates never reach the list view model.
expect(JSON.stringify(row(resolvable))).not.toContain('vod.camara.leg.br')
})
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
const id = await createSpeech()
const { communicator, coordinator, candidate, advisor, leader } = await createUsers()
for (const reader of [communicator, coordinator, candidate]) {
const data = await loadSpeechAcervoPageData(payload, reader, {})
expect(data.rows.length).toBeGreaterThan(0)
}
for (const denied of [advisor, leader]) {
await expect(loadSpeechAcervoPageData(payload, denied, {})).rejects.toThrow(/permissão/i)
await expect(loadSpeechDetailPageData(payload, denied, id)).rejects.toThrow(/permissão/i)
}
})
it('throws the named not-found error for a missing speech', async () => {
const { communicator } = await createUsers()
await expect(
loadSpeechDetailPageData(payload, communicator, 999_999_999),
).rejects.toBeInstanceOf(SpeechNotFoundError)
})
it('finds a speech by expanded theme terms and explains the match (C192)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `tema${runId}`
const id = await createSpeech({
segments: [
{
startSeconds: 0,
endSeconds: 3,
text: `Garantir o atendimento universal ${marker} na rede pública`,
},
],
})
const { coordinator } = await createUsers()
const expansionCalls: string[] = []
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: 'defesa do SUS', mode: 'tema' },
async (theme) => {
expansionCalls.push(theme)
return { terms: [`atendimento universal ${marker}`] }
},
)
expect(expansionCalls).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?.matchKind).toBe('theme')
expect(row?.themeMatchTerm).toBe(`atendimento universal ${marker}`)
expect(row?.matchedTextSearch).toBe(false)
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('degrades to the literal search when the expansion is unavailable (C192)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `literal${runId}`
const id = await createSpeech({
segments: [{ startSeconds: 0, endSeconds: 3, text: `A defesa do SUS ${marker} no plenário` }],
})
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: `defesa do SUS ${marker}`, mode: 'tema' },
async () => null,
)
expect(data.themeUnavailable).toBe(true)
expect(data.themeApplied).toBe(false)
const row = data.rows.find((item) => item.id === id)
expect(row?.themeMatchTerm).toBeNull()
expect(row?.matchedTextSearch).toBe(true)
})
it('never expands for an actor outside the catalog gate (C192)', async () => {
const { advisor } = await createUsers()
let called = false
await expect(
loadSpeechAcervoPageData(payload, advisor, { q: 'SUS', mode: 'tema' }, async () => {
called = true
return { terms: ['saúde'] }
}),
).rejects.toThrow(/permissão/i)
expect(called).toBe(false)
})
})