mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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
419 lines
14 KiB
TypeScript
419 lines
14 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 { 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)
|
||
})
|
||
})
|