import type { Payload } from 'payload' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AIToolContext } from '@/lib/ai/types' import type { CampaignUser } from '@/payload-types' const { rerankMock } = vi.hoisted(() => ({ rerankMock: vi.fn() })) vi.mock('@/utilities/ai/rerankSpeechExcerpts', () => ({ rerankSpeechExcerpts: rerankMock })) import { findSpeechExcerpts } from '@/utilities/ai/tools/findSpeechExcerpts' import { stub } from '../helpers/stub' const denied = { error: 'Leitura do acervo de falas negada.' } type ExecutableTool = { execute: (args: unknown, options?: unknown) => Promise } const user = (role: CampaignUser['role']): CampaignUser => stub({ collection: 'campaignUser', role }) const leader = user('leader') const advisor = user('advisor') const coordinator = user('coordinator') const ctxFor = (actor: CampaignUser, payload: Payload): AIToolContext => ({ user: actor, payload, }) const payloadFor = (find: ReturnType): Payload => stub({ find: find as unknown as Payload['find'] }) const untouchablePayload = payloadFor( vi.fn(() => { throw new Error('gate must fail closed before any payload query') }), ) const speechResult = (docs: unknown[], totalDocs = docs.length) => ({ docs, totalDocs }) const segmentsResult = (docs: unknown[]) => ({ docs }) const execute = (payload: Payload, actor: CampaignUser = coordinator) => (args: unknown) => (findSpeechExcerpts(ctxFor(actor, payload)) as unknown as ExecutableTool).execute(args) const SPEECH_WHERE = { and: [{ searchText: { like: 'hospital' } }, { searchText: { like: 'suburbio' } }], } const reranked = (index: number, reason = 'Serve à intenção') => ({ index, reason }) beforeEach(() => { rerankMock.mockReset() rerankMock.mockResolvedValue(null) }) describe('findSpeechExcerpts gate (C158)', () => { it('nega leader e advisor antes de qualquer query — e sem chamar o reranker', async () => { for (const actor of [leader, advisor]) { await expect( execute(untouchablePayload, actor)({ tema: 'hospital do subúrbio' }), ).resolves.toEqual(denied) } expect(untouchablePayload.find).not.toHaveBeenCalled() expect(rerankMock).not.toHaveBeenCalled() }) it('deixa coordinator, candidate e communicator passarem (gate herdado pelo chat do C159)', async () => { for (const actor of [coordinator, user('candidate'), user('communicator')]) { const find = vi.fn().mockResolvedValue(speechResult([])) const result = (await execute( payloadFor(find), actor, )({ tema: 'hospital do subúrbio', })) as Record expect(result).toMatchObject({ totalDiscursos: 0, trechos: [], reordenadoPorIA: false, truncado: false, }) expect(find).toHaveBeenCalledWith( expect.objectContaining({ collection: 'speech', where: SPEECH_WHERE, sort: '-speechAt', limit: 30, overrideAccess: false, user: actor, }), ) } }) it('tema sem termos úteis devolve erro sem consultar o acervo nem o reranker', async () => { const find = vi.fn() const result = (await execute(payloadFor(find))({ tema: 'a de o' })) as { error: string } expect(result.error).toContain('Tema sem termos de busca') expect(find).not.toHaveBeenCalled() expect(rerankMock).not.toHaveBeenCalled() }) }) describe('findSpeechExcerpts fallback determinístico (C158)', () => { const scriptSearch = () => { const find = vi.fn() find.mockResolvedValueOnce( speechResult([ { id: 1, speechAt: '2024-05-01T10:00', type: 'DISCURSO' }, { id: 2, speechAt: '2024-04-01T10:00', type: 'BREVES COMUNICAÇÕES' }, ]), ) find.mockResolvedValueOnce( segmentsResult([ { speech: 1, startSeconds: 0, endSeconds: 50, text: 'O hospital do subúrbio e a saúde' }, { speech: 2, startSeconds: 0, endSeconds: 40, text: 'hospital do subúrbio de novo' }, ]), ) return find } it('um trecho por discurso, preservando a ordem do pool scriptado, sem motivo no fallback', async () => { const result = (await execute(payloadFor(scriptSearch()))({ tema: 'hospital do subúrbio', })) as { reordenadoPorIA: boolean consulta: { tema: string; termos: string[]; intencao: string | null } criterio: string trechos: Array> } expect(result.reordenadoPorIA).toBe(false) expect(result.consulta).toEqual({ tema: 'hospital do subúrbio', termos: ['hospital', 'suburbio'], intencao: null, }) expect(result.criterio).toContain('ASR') expect(result.trechos).toHaveLength(2) expect(result.trechos[0]).toMatchObject({ discursoId: 1, data: '01/05/2024 · 10:00', tipo: 'DISCURSO', inicioSegundos: 0, fimSegundos: 50, inicioLabel: '00:00', fimLabel: '00:50', duracaoSegundos: 50, citacao: 'O hospital do subúrbio e a saúde', termosCasados: ['hospital', 'suburbio'], url: '/campanha/comunicacao/acervo/1?t=0&q=hospital+suburbio', }) expect(result.trechos[0]).not.toHaveProperty('motivo') expect(result.trechos[1]).toMatchObject({ discursoId: 2 }) // A intenção ausente vira o tema no prompt do reranker (prefixo determinístico). expect(rerankMock).toHaveBeenCalledWith( expect.objectContaining({ intencao: 'hospital do subúrbio', tema: 'hospital do subúrbio' }), ) const rerankCandidates = (rerankMock.mock.calls[0]![0] as { candidates: unknown[] }).candidates expect(rerankCandidates).toHaveLength(2) }) it('discurso que casa o tema mas não rende janela contínua devolve lista vazia, não erro', async () => { const find = vi.fn() find.mockResolvedValueOnce(speechResult([{ id: 1, speechAt: '2024-05-01T10:00' }])) find.mockResolvedValueOnce( segmentsResult([{ speech: 1, startSeconds: 0, endSeconds: 5, text: 'economia e emprego' }]), ) const result = (await execute(payloadFor(find))({ tema: 'hospital do subúrbio' })) as { totalDiscursos: number trechos: unknown[] } expect(result.totalDiscursos).toBe(1) expect(result.trechos).toEqual([]) expect(rerankMock).not.toHaveBeenCalled() }) it('trunca no limite de candidatos e devolve a dica', async () => { const find = vi.fn() find.mockResolvedValueOnce(speechResult([{ id: 1, speechAt: '2024-05-01T10:00' }], 40)) find.mockResolvedValueOnce( segmentsResult([ { speech: 1, startSeconds: 0, endSeconds: 30, text: 'hospital do subúrbio' }, ]), ) const result = (await execute(payloadFor(find))({ tema: 'hospital do subúrbio' })) as { truncado: boolean dica?: string } expect(result.truncado).toBe(true) expect(result.dica).toContain('refine o tema') }) }) describe('findSpeechExcerpts reranking IA (C158)', () => { const scriptPool = () => { const find = vi.fn() find.mockResolvedValueOnce( speechResult([ { id: 3, speechAt: '2024-06-01T10:00', type: 'DISCURSO' }, { id: 4, speechAt: '2024-05-01T10:00', type: 'DISCURSO' }, ]), ) find.mockResolvedValueOnce( segmentsResult([ { speech: 3, startSeconds: 0, endSeconds: 10, text: 'hospital' }, { speech: 3, startSeconds: 10, endSeconds: 20, text: 'do subúrbio' }, { speech: 3, startSeconds: 20, endSeconds: 30, text: 'e mais' }, { speech: 3, startSeconds: 30, endSeconds: 40, text: 'e mais ainda' }, { speech: 3, startSeconds: 40, endSeconds: 50, text: 'fim' }, { speech: 4, startSeconds: 0, endSeconds: 30, text: 'hospital do subúrbio' }, ]), ) return find } it('usa a ordem e os motivos do reranker, mantendo um trecho por discurso', async () => { rerankMock.mockResolvedValue({ choices: [ { index: 1, reason: 'Argumento completo em 50s' }, { index: 0, reason: 'Versão curta do mesmo discurso' }, reranked(2, 'Outra fala sobre o tema'), ], }) const result = (await execute(payloadFor(scriptPool()))({ tema: 'hospital do subúrbio', intencao: 'reels curto, tom emocionante', })) as { reordenadoPorIA: boolean trechos: Array<{ discursoId: number; motivo?: string; fimSegundos: number; url: string }> } expect(result.reordenadoPorIA).toBe(true) expect(result.trechos).toEqual([ { discursoId: 3, data: '01/06/2024 · 10:00', tipo: 'DISCURSO', inicioSegundos: 0, fimSegundos: 50, inicioLabel: '00:00', fimLabel: '00:50', duracaoSegundos: 50, citacao: 'hospital do subúrbio e mais e mais ainda fim', termosCasados: ['hospital', 'suburbio'], motivo: 'Argumento completo em 50s', url: '/campanha/comunicacao/acervo/3?t=0&q=hospital+suburbio', }, { discursoId: 4, data: '01/05/2024 · 10:00', tipo: 'DISCURSO', inicioSegundos: 0, fimSegundos: 30, inicioLabel: '00:00', fimLabel: '00:30', duracaoSegundos: 30, citacao: 'hospital do subúrbio', termosCasados: ['hospital', 'suburbio'], motivo: 'Outra fala sobre o tema', url: '/campanha/comunicacao/acervo/4?t=0&q=hospital+suburbio', }, ]) const call = rerankMock.mock.calls[0]![0] as { intencao: string; candidates: unknown[] } expect(call.intencao).toBe('reels curto, tom emocionante') expect(call.candidates).toHaveLength(3) }) })