"""Testes da ingestão de voz, do agrupamento em enunciados e da busca.""" from __future__ import annotations import json import tempfile import unittest from pathlib import Path from engine.persistencia.busca_de_conteudo import ( BuscaDeConteudo, ErroDeBusca, IndexadorSemantico, ) from engine.persistencia.conexao import abrir_banco from engine.persistencia.enunciados import ( AgrupadorDeEnunciados, ErroDeAgrupamento, FalaParaAgrupar, RepositorioDeEnunciados, ) from engine.persistencia.esquema import reconstruir_indice_de_busca from engine.persistencia.ingestao_de_voz import ( ErroDeIngestaoDeVoz, FalaDoPipelineDeVoz, IngestorDeVozNoBanco, LeitorDeDadosParaIA, PalavraDoPipelineDeVoz, ) from engine.persistencia.limpeza import LimpadorDeDuplicatas def _dados_para_ia() -> dict: """Monta um ``dados-para-ia.json`` mínimo com as três análises de áudio.""" return { "schema_version": "1.0", "source": {"file_name": "teste.mp4", "duration_seconds": 20.0}, "segments": [ { "id": 1, "start": 0.0, "end": 4.0, "text": "Bom dia a todos.", "speaker": "SPEAKER_00", "energy_rms": 0.12, "pitch_hz_median": 180.0, "pitch_hz_std": 12.0, "speaking_rate_wps": 1.0, "longest_internal_pause_s": 0.2, "gap_before_s": None, "words": [ {"text": "Bom", "start": 0.0, "end": 0.5, "confidence": 0.99}, {"text": "dia", "start": 0.5, "end": 1.0, "confidence": 0.98}, ], }, { "id": 2, "start": 5.0, "end": 9.0, "text": "Vamos falar de elegância.", "speaker": "SPEAKER_00", "energy_rms": 0.20, "pitch_hz_median": 200.0, "pitch_hz_std": 15.0, "speaking_rate_wps": 1.2, "longest_internal_pause_s": 0.1, "gap_before_s": 1.0, "words": [{"text": "Vamos", "start": 5.0, "end": 5.4, "confidence": 0.97}], }, ], } class TesteLeitorDeDadosParaIA(unittest.TestCase): """Verifica a normalização do JSON do pipeline de voz.""" def _escrever(self, conteudo: object, pasta: str) -> Path: caminho = Path(pasta) / "dados-para-ia.json" caminho.write_text(json.dumps(conteudo), encoding="utf-8") return caminho def test_le_falas_com_falante_metricas_e_palavras(self) -> None: with tempfile.TemporaryDirectory() as pasta: falas = LeitorDeDadosParaIA().ler(self._escrever(_dados_para_ia(), pasta)) self.assertEqual(len(falas), 2) self.assertEqual(falas[0].texto, "Bom dia a todos.") self.assertEqual(falas[0].falante, "SPEAKER_00") self.assertEqual(falas[0].metricas["energy_rms"], 0.12) self.assertEqual(len(falas[0].palavras), 2) self.assertAlmostEqual(falas[0].duracao, 4.0) def test_ordena_as_falas_por_inicio(self) -> None: dados = _dados_para_ia() dados["segments"].reverse() with tempfile.TemporaryDirectory() as pasta: falas = LeitorDeDadosParaIA().ler(self._escrever(dados, pasta)) self.assertEqual([fala.inicio for fala in falas], [0.0, 5.0]) def test_arquivo_inexistente_gera_erro_especifico(self) -> None: with self.assertRaises(ErroDeIngestaoDeVoz): LeitorDeDadosParaIA().ler("/caminho/que/nao/existe.json") def test_json_sem_segments_gera_erro_especifico(self) -> None: with tempfile.TemporaryDirectory() as pasta: caminho = self._escrever({"source": {}}, pasta) with self.assertRaises(ErroDeIngestaoDeVoz): LeitorDeDadosParaIA().ler(caminho) def test_segmento_sem_tempo_gera_erro_que_nomeia_o_campo(self) -> None: dados = _dados_para_ia() del dados["segments"][0]["start"] with tempfile.TemporaryDirectory() as pasta: caminho = self._escrever(dados, pasta) with self.assertRaises(ErroDeIngestaoDeVoz) as contexto: LeitorDeDadosParaIA().ler(caminho) self.assertIn("start", str(contexto.exception)) class TesteIngestorDeVozNoBanco(unittest.TestCase): """Verifica a gravação das falas do pipeline de voz.""" def _conexao(self, pasta: str): return abrir_banco(Path(pasta) / "analises.db") def test_metricas_viram_colunas_consultaveis(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._conexao(pasta) with tempfile.TemporaryDirectory() as origem: caminho = Path(origem) / "dados.json" caminho.write_text(json.dumps(_dados_para_ia()), encoding="utf-8") falas = LeitorDeDadosParaIA().ler(caminho) resultado = IngestorDeVozNoBanco(conexao).ingerir("VID", "CLIPE", falas) self.assertEqual(resultado.falas, 2) self.assertEqual(resultado.palavras, 3) self.assertEqual(resultado.falantes, 1) linha = conexao.execute( """SELECT energia_rms, pitch_mediano_hz, velocidade_de_fala_pps, falante FROM segmentos_de_transcricao WHERE inicio = 0.0""" ).fetchone() self.assertAlmostEqual(linha["energia_rms"], 0.12) self.assertAlmostEqual(linha["pitch_mediano_hz"], 180.0) self.assertEqual(linha["falante"], "SPEAKER_00") def test_reingerir_o_mesmo_clipe_nao_duplica(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._conexao(pasta) falas = (FalaDoPipelineDeVoz( inicio=0.0, fim=1.0, texto="oi", falante="A", palavras=(PalavraDoPipelineDeVoz("oi", 0.0, 1.0),), ),) ingestor = IngestorDeVozNoBanco(conexao) for _ in range(3): ingestor.ingerir("VID", "CLIPE", falas) total = conexao.execute( "SELECT count(*) FROM segmentos_de_transcricao").fetchone()[0] self.assertEqual(total, 1) def test_identificador_vazio_gera_erro_especifico(self) -> None: with tempfile.TemporaryDirectory() as pasta: with self.assertRaises(ErroDeIngestaoDeVoz): IngestorDeVozNoBanco(self._conexao(pasta)).ingerir("", "CLIPE", ()) class TesteAgrupadorDeEnunciados(unittest.TestCase): """Verifica as fronteiras de agrupamento das falas em enunciados.""" def _fala(self, identificador: int, inicio: float, fim: float, falante: str | None = "A", clipe: str = "C1") -> FalaParaAgrupar: return FalaParaAgrupar(identificador, clipe, inicio, fim, f"fala {identificador}", falante) def test_agrupa_falas_vizinhas_do_mesmo_falante(self) -> None: falas = [self._fala(i, i * 5.0, i * 5.0 + 4.5) for i in range(4)] enunciados = AgrupadorDeEnunciados().agrupar(falas) self.assertEqual(len(enunciados), 1) self.assertEqual(enunciados[0].segmentos, (0, 1, 2, 3)) def test_troca_de_falante_fecha_o_bloco(self) -> None: falas = [self._fala(0, 0.0, 4.0, "A"), self._fala(1, 4.1, 8.0, "B")] enunciados = AgrupadorDeEnunciados().agrupar(falas) self.assertEqual(len(enunciados), 2) self.assertEqual([e.falante for e in enunciados], ["A", "B"]) def test_clipes_diferentes_nunca_se_juntam(self) -> None: falas = [self._fala(0, 0.0, 4.0, clipe="C1"), self._fala(1, 4.1, 8.0, clipe="C2")] self.assertEqual(len(AgrupadorDeEnunciados().agrupar(falas)), 2) def test_silencio_nao_quebra_bloco_ainda_curto(self) -> None: """Quebrar cedo produziria blocos curtos demais para embedar.""" falas = [self._fala(0, 0.0, 2.0), self._fala(1, 10.0, 12.0)] self.assertEqual(len(AgrupadorDeEnunciados().agrupar(falas)), 1) def test_duracao_maxima_e_respeitada(self) -> None: falas = [self._fala(i, i * 10.0, i * 10.0 + 9.5) for i in range(8)] enunciados = AgrupadorDeEnunciados().agrupar(falas) self.assertTrue(all(e.duracao <= 45.0 for e in enunciados)) def test_duracao_maxima_menor_que_alvo_gera_erro(self) -> None: with self.assertRaises(ErroDeAgrupamento): AgrupadorDeEnunciados(duracao_alvo=30.0, duracao_maxima=10.0) class _ProviderFalso: """Provider determinístico: vetor derivado das letras, sem rede.""" modelo = "falso" dimensoes = 4 def _vetor(self, texto: str) -> tuple[float, ...]: base = [0.0] * self.dimensoes for posicao, letra in enumerate(texto.lower()): if letra.isalpha(): base[posicao % self.dimensoes] += ord(letra) % 7 return tuple(base) def gerar_para_documento(self, texto: str) -> tuple[float, ...]: return self._vetor(texto) def gerar_para_consulta(self, texto: str) -> tuple[float, ...]: return self._vetor(texto) class TesteBuscaDeConteudo(unittest.TestCase): """Verifica a busca lexical, a semântica e a fusão das duas.""" def _banco_com_falas(self, pasta: str): conexao = abrir_banco(Path(pasta) / "analises.db") falas = ( FalaDoPipelineDeVoz(inicio=0.0, fim=4.0, texto="Vamos falar de elegância e postura.", falante="A"), FalaDoPipelineDeVoz(inicio=5.0, fim=9.0, texto="A cirurgia leva cerca de duas horas.", falante="A"), ) IngestorDeVozNoBanco(conexao).ingerir("VID", "C1", falas) return conexao def test_busca_lexical_ignora_acento_e_devolve_timecode(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._banco_com_falas(pasta) resultados = BuscaDeConteudo(conexao).buscar_lexical("elegancia") self.assertEqual(len(resultados), 1) self.assertEqual(resultados[0].inicio, 0.0) self.assertEqual(resultados[0].fim, 4.0) self.assertEqual(resultados[0].origem, "lexical") def test_busca_lexical_nao_inventa_resultado(self) -> None: """Diferente da semântica, a lexical sabe dizer que não achou.""" with tempfile.TemporaryDirectory() as pasta: conexao = self._banco_com_falas(pasta) self.assertEqual(BuscaDeConteudo(conexao).buscar_lexical("paralelepipedo"), []) def test_indice_lexical_acompanha_remocao_de_fala(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._banco_com_falas(pasta) conexao.execute("DELETE FROM segmentos_de_transcricao WHERE inicio = 0.0") conexao.commit() self.assertEqual(BuscaDeConteudo(conexao).buscar_lexical("elegancia"), []) def test_reconstruir_indice_recupera_falas_pre_existentes(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._banco_com_falas(pasta) self.assertEqual(reconstruir_indice_de_busca(conexao), 2) def test_busca_semantica_devolve_falas_de_origem(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._banco_com_falas(pasta) repositorio = RepositorioDeEnunciados(conexao) enunciados = AgrupadorDeEnunciados().agrupar(repositorio.carregar_falas("VID")) repositorio.substituir_enunciados("VID", enunciados) provider = _ProviderFalso() self.assertEqual(IndexadorSemantico(conexao, provider).indexar("VID"), 1) resultados = BuscaDeConteudo(conexao, provider).buscar_semantica("elegância") self.assertEqual(len(resultados), 1) self.assertTrue(resultados[0].falas) def test_sem_provider_a_busca_semantica_devolve_vazio_sem_falhar(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._banco_com_falas(pasta) self.assertEqual(BuscaDeConteudo(conexao).buscar_semantica("qualquer"), []) def test_consulta_vazia_gera_erro_especifico(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._banco_com_falas(pasta) with self.assertRaises(ErroDeBusca): BuscaDeConteudo(conexao).buscar(" ") def test_pontuacao_do_usuario_nao_quebra_a_consulta(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = self._banco_com_falas(pasta) resultados = BuscaDeConteudo(conexao).buscar_lexical('cirurgia "quanto?"') self.assertIsInstance(resultados, list) class TesteViewsAchatadas(unittest.TestCase): """Verifica que as views entregam a leitura única esperada pelo agente.""" def test_view_de_palavras_repete_o_que_vale_para_a_frase(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = abrir_banco(Path(pasta) / "analises.db") falas = (FalaDoPipelineDeVoz( inicio=0.0, fim=2.0, texto="Bom dia", falante="A", metricas={"energy_rms": 0.5}, palavras=(PalavraDoPipelineDeVoz("Bom", 0.0, 1.0), PalavraDoPipelineDeVoz("dia", 1.0, 2.0)), ),) IngestorDeVozNoBanco(conexao).ingerir("VID", "C1", falas) linhas = conexao.execute( "SELECT palavra, falante, frase FROM vw_palavras_completas ORDER BY ordem" ).fetchall() self.assertEqual([linha["palavra"] for linha in linhas], ["Bom", "dia"]) self.assertTrue(all(linha["falante"] == "A" for linha in linhas)) self.assertTrue(all(linha["frase"] == "Bom dia" for linha in linhas)) def test_view_de_falas_conta_as_palavras(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = abrir_banco(Path(pasta) / "analises.db") falas = (FalaDoPipelineDeVoz( inicio=0.0, fim=2.0, texto="Bom dia", falante="A", palavras=(PalavraDoPipelineDeVoz("Bom", 0.0, 1.0), PalavraDoPipelineDeVoz("dia", 1.0, 2.0)), ),) IngestorDeVozNoBanco(conexao).ingerir("VID", "C1", falas) linha = conexao.execute( "SELECT total_de_palavras FROM vw_falas_completas").fetchone() self.assertEqual(linha["total_de_palavras"], 2) class TesteLimpadorDeDuplicatas(unittest.TestCase): """Verifica a remoção das duplicatas deixadas por execuções antigas.""" def test_remove_copias_exatas_e_preserva_a_primeira(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = abrir_banco(Path(pasta) / "analises.db") conexao.execute("INSERT OR IGNORE INTO videos (id) VALUES ('VID')") for _ in range(3): conexao.execute( """INSERT INTO cenas (video_id, clipe_id, inicio, fim, confianca, referencias) VALUES ('VID', 'C1', 1.0, 2.0, 0.9, '[]')""") conexao.commit() limpador = LimpadorDeDuplicatas(conexao) self.assertEqual(limpador.contar_duplicatas().cenas, 2) self.assertEqual(limpador.limpar().cenas, 2) self.assertEqual( conexao.execute("SELECT count(*) FROM cenas").fetchone()[0], 1) def test_linhas_diferentes_sao_preservadas(self) -> None: with tempfile.TemporaryDirectory() as pasta: conexao = abrir_banco(Path(pasta) / "analises.db") conexao.execute("INSERT OR IGNORE INTO videos (id) VALUES ('VID')") for inicio in (1.0, 2.0, 3.0): conexao.execute( """INSERT INTO cenas (video_id, clipe_id, inicio, fim, confianca, referencias) VALUES ('VID', 'C1', ?, ?, 0.9, '[]')""", (inicio, inicio + 1)) conexao.commit() self.assertEqual(LimpadorDeDuplicatas(conexao).limpar().cenas, 0) if __name__ == "__main__": unittest.main()