feat: criado repositório jhonny-editor no Gitea

criado repositório jhonny-editor no Gitea
adicionado script admin/deploy.command com commit automático
atualizado admin/DEV-NOTES.md com template limpo

Resumo:
- 32 arquivos alterados
- 16 novos
- 15 modificados
- 0 removidos

 16 files changed, 638 insertions(+), 61 deletions(-)

Arquivos:
  - code/engine/skills/boas-praticas-oo.md -> .agents/skills/boas-praticas-oo/SKILL.md
  - AGENTS.md
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/integracoes/visual/apple_vision.py
  - code/engine/integracoes/visual/apple_vision_runner.swift
  - code/engine/integracoes/visual/extrator_ffmpeg.py
  - code/engine/scanner/__init__.py
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/coordenacao/__init__.py
  - code/engine/scanner/retakes/adaptador_de_falas.py
  - code/engine/scanner/retakes/agrupador_de_takes.py
  - code/engine/scanner/retakes/detector_de_reinicios.py
  - code/engine/scanner/retakes/modelos_de_retakes.py
  - code/engine/scanner/visual.py
  - .jhonny/
  - .retakes/
  - CODING_STANDARDS.md
  - code/engine/executar_retakes.py
  - code/engine/persistencia/
  - code/engine/scanner/retakes/__init__.py
  - code/engine/scanner/retakes/carregador_de_artefatos.py
  - code/engine/scanner/retakes/classificador_de_retakes.py
  - code/engine/scanner/retakes/coordenador_de_retakes.py
  - code/engine/scanner/retakes/deteccao_de_retakes.py
  - code/engine/scanner/retakes/gerador_de_evidencias.py
  - code/engine/scanner/retakes/repositorio_de_retakes.py
  - code/engine/testes/test_consultas_de_persistencia.py
  - code/engine/testes/test_deteccao_de_retakes.py
  - code/engine/testes/test_persistencia_sqlite.py
  - code/relatorios/audio/arquivos/92f97877259a86a8095b1eecafdefe357212a12247d04b78df60e0c0ae38b2ea/
This commit is contained in:
João Henrique
2026-09-08 17:13:19 -04:00
parent b9bf3b2863
commit ee181360c8
40 changed files with 3674 additions and 61 deletions
@@ -0,0 +1,207 @@
"""
Testes de ``engine.persistencia.consultas.ConsultasDeAnalises``.
Verifica que cada método devolve apenas os campos esperados para aquele
nível de detalhe, a partir de um banco populado pelos repositórios já
testados em ``test_persistencia_sqlite.py``, e que entradas inválidas são
rejeitadas com ``ErroDeConsultaInvalida``.
"""
import tempfile
import unittest
from pathlib import Path
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.persistencia import (
ConsultasDeAnalises,
ErroDeConsultaInvalida,
RepositorioDeAnalisesSQLite,
RepositorioDeRetakesSQLite,
RepositorioDeTimelineSQLite,
)
from engine.scanner.modelos import Cena, EvidenciaVisual, SegmentoDeTranscricao
from engine.scanner.retakes.modelos_de_retakes import EvidenciaDeRetake, GrupoDeRetake, TomadaDeRetake
from engine.scanner.transcricao_da_timeline import TranscricaoDoClipe
VIDEO_ID = "v1"
class TesteConsultasDeAnalises(unittest.TestCase):
"""Popula um banco temporário com dados de todas as análises e valida as consultas."""
def setUp(self) -> None:
self._pasta_temporaria = tempfile.TemporaryDirectory()
self.banco = Path(self._pasta_temporaria.name) / "analises.db"
self.addCleanup(self._pasta_temporaria.cleanup)
self._popular_banco()
self.consultas = ConsultasDeAnalises(self.banco)
def _popular_banco(self) -> None:
clipe = Clipe(
identificador="clip-1", nome="Take 1",
intervalo_na_timeline=IntervaloDeTempo(0.0, 20.0),
arquivo="/videos/entrada.mp4", identificador_da_faixa="f1",
)
faixa = Faixa(identificador="f1", nome="V1", tipo="video", indice=0, clipes=[clipe])
timeline = Timeline(identificador=VIDEO_ID, nome="Sequência", duracao=20.0, faixas=[faixa])
RepositorioDeTimelineSQLite(self.banco).registrar_timeline(timeline)
segmento = SegmentoDeTranscricao(
inicio=0.0, fim=6.0, texto="Hoje nós vamos mostrar como funciona o sistema.",
confianca=0.9, falante="locutor", emocao="neutro",
)
transcricao = TranscricaoDoClipe("clip-1", "/videos/entrada.mp4", 0.0, 20.0, [segmento])
RepositorioDeAnalisesSQLite(self.banco).registrar_transcricoes(VIDEO_ID, [transcricao])
repositorio_analises = RepositorioDeAnalisesSQLite(self.banco)
repositorio_analises.registrar_evidencias_visuais(VIDEO_ID, "clip-1", [
EvidenciaVisual(tipo="qualidade", inicio=1.0, fim=2.0, valor={"nitidez": 0.9},
confianca=0.95, provider="opencv"),
EvidenciaVisual(tipo="rosto", inicio=1.0, fim=2.0, valor={"quantidade": 1},
confianca=0.4, provider="opencv"),
])
repositorio_analises.registrar_cenas(VIDEO_ID, [Cena(inicio=0.0, fim=20.0, confianca=0.8)],
clipe_id="clip-1")
grupo = GrupoDeRetake(
id="retake_teste", video_id=VIDEO_ID, faixa_id="f1",
tipo="retake_confirmado", confianca=0.92,
tomadas=[
TomadaDeRetake(0, "fala-1", "clip-1", 0.0, 6.0, "Hoje nós vamos mostrar.",
confianca=0.92),
TomadaDeRetake(1, "fala-2", "clip-1", 9.0, 15.0, "Hoje nós vamos mostrar.",
similaridade_com_anterior=1.0, confianca=0.98),
],
evidencias=[EvidenciaDeRetake("similaridade_textual", "Textos quase idênticos.", 0.98)],
)
RepositorioDeRetakesSQLite(self.banco).registrar(VIDEO_ID, [grupo])
# --- consultar_resumo_do_video -----------------------------------------------------
def test_consultar_resumo_do_video_traz_contagens_agregadas(self) -> None:
resumo = self.consultas.consultar_resumo_do_video(VIDEO_ID)
self.assertIsNotNone(resumo)
self.assertEqual(resumo["nome"], "Sequência")
self.assertEqual(resumo["total_clipes"], 1)
self.assertEqual(resumo["total_falas"], 1)
self.assertEqual(resumo["total_retakes"], 1)
self.assertEqual(resumo["total_cenas"], 1)
def test_consultar_resumo_do_video_inexistente_retorna_none(self) -> None:
self.assertIsNone(self.consultas.consultar_resumo_do_video("video_desconhecido"))
def test_consultar_resumo_do_video_rejeita_video_id_vazio(self) -> None:
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.consultar_resumo_do_video("")
# --- listar_falas_no_intervalo ------------------------------------------------------
def test_listar_falas_no_intervalo_filtra_por_tempo_e_omite_palavras_por_padrao(self) -> None:
falas = self.consultas.listar_falas_no_intervalo(VIDEO_ID, inicio=0.0, fim=10.0)
self.assertEqual(len(falas), 1)
self.assertEqual(falas[0]["clipe_id"], "clip-1")
self.assertEqual(falas[0]["emocao"], "neutro")
self.assertNotIn("palavras", falas[0])
self.assertNotIn("caracteristicas_acusticas", falas[0])
def test_listar_falas_no_intervalo_fora_do_periodo_retorna_lista_vazia(self) -> None:
falas = self.consultas.listar_falas_no_intervalo(VIDEO_ID, inicio=100.0, fim=110.0)
self.assertEqual(falas, [])
def test_listar_falas_no_intervalo_rejeita_inicio_negativo(self) -> None:
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.listar_falas_no_intervalo(VIDEO_ID, inicio=-1.0)
def test_listar_falas_no_intervalo_rejeita_fim_anterior_ao_inicio(self) -> None:
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.listar_falas_no_intervalo(VIDEO_ID, inicio=10.0, fim=5.0)
# --- consultar_clipe_no_instante -----------------------------------------------------
def test_consultar_clipe_no_instante_localiza_o_clipe_correto(self) -> None:
clipe = self.consultas.consultar_clipe_no_instante(VIDEO_ID, 5.0)
self.assertIsNotNone(clipe)
self.assertEqual(clipe["clipe_id"], "clip-1")
def test_consultar_clipe_no_instante_fora_da_timeline_retorna_none(self) -> None:
self.assertIsNone(self.consultas.consultar_clipe_no_instante(VIDEO_ID, 999.0))
def test_consultar_clipe_no_instante_rejeita_instante_negativo(self) -> None:
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.consultar_clipe_no_instante(VIDEO_ID, -1.0)
# --- listar_retakes_do_video / consultar_detalhes_do_retake -------------------------
def test_listar_retakes_do_video_traz_resumo_sem_tomadas_completas(self) -> None:
retakes = self.consultas.listar_retakes_do_video(VIDEO_ID)
self.assertEqual(len(retakes), 1)
resumo = retakes[0]
self.assertEqual(resumo["grupo_id"], "retake_teste")
self.assertEqual(resumo["total_tomadas"], 2)
self.assertEqual(resumo["tomada_principal"]["fala_id"], "fala-2")
self.assertNotIn("tomadas", resumo)
self.assertNotIn("evidencias", resumo)
def test_consultar_detalhes_do_retake_traz_tomadas_e_evidencias_completas(self) -> None:
detalhes = self.consultas.consultar_detalhes_do_retake("retake_teste")
self.assertIsNotNone(detalhes)
self.assertEqual(len(detalhes["tomadas"]), 2)
self.assertEqual(len(detalhes["evidencias"]), 1)
self.assertEqual(detalhes["evidencias"][0]["tipo"], "similaridade_textual")
def test_consultar_detalhes_do_retake_inexistente_retorna_none(self) -> None:
self.assertIsNone(self.consultas.consultar_detalhes_do_retake("retake_inexistente"))
def test_consultar_detalhes_do_retake_rejeita_grupo_id_vazio(self) -> None:
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.consultar_detalhes_do_retake(" ")
# --- listar_evidencias_visuais_do_clipe ----------------------------------------------
def test_listar_evidencias_visuais_filtra_por_tipo(self) -> None:
evidencias = self.consultas.listar_evidencias_visuais_do_clipe(
VIDEO_ID, "clip-1", tipo="qualidade")
self.assertEqual(len(evidencias), 1)
self.assertEqual(evidencias[0]["valor"], {"nitidez": 0.9})
def test_listar_evidencias_visuais_filtra_por_confianca_minima(self) -> None:
evidencias = self.consultas.listar_evidencias_visuais_do_clipe(
VIDEO_ID, "clip-1", confianca_minima=0.9)
self.assertEqual(len(evidencias), 1)
self.assertEqual(evidencias[0]["tipo"], "qualidade")
def test_listar_evidencias_visuais_rejeita_confianca_fora_da_faixa(self) -> None:
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.listar_evidencias_visuais_do_clipe(
VIDEO_ID, "clip-1", confianca_minima=1.5)
def test_listar_evidencias_visuais_rejeita_clipe_id_vazio(self) -> None:
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.listar_evidencias_visuais_do_clipe(VIDEO_ID, "")
# --- listar_cenas_do_video -------------------------------------------------------------
def test_listar_cenas_do_video_filtra_por_clipe(self) -> None:
cenas = self.consultas.listar_cenas_do_video(VIDEO_ID, clipe_id="clip-1")
self.assertEqual(len(cenas), 1)
self.assertEqual(cenas[0]["fim"], 20.0)
def test_listar_cenas_do_video_sem_clipe_traz_todas(self) -> None:
self.assertEqual(len(self.consultas.listar_cenas_do_video(VIDEO_ID)), 1)
def test_listar_cenas_do_video_rejeita_video_id_vazio(self) -> None:
with self.assertRaises(ErroDeConsultaInvalida):
self.consultas.listar_cenas_do_video(None)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,440 @@
"""Testes do módulo de detecção de retakes.
Cobre o fluxo do ``CoordenadorDeRetakes`` (caso prático do requisito, em
que a fala final com informação nova não deve entrar no grupo), os
algoritmos de similaridade, o detector de reinícios, o adaptador de
pipeline e a idempotência do repositório.
"""
import json
import tempfile
import unittest
from pathlib import Path
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
from engine.scanner.retakes import (
ClassificadorDeRetakes,
ComparadorDeFalas,
CoordenadorDeRetakes,
DeteccaoDeRetakes,
GrupoDeRetake,
RepositorioDeRetakes,
AgrupamentoDeFalas,
Fala,
ParAgrupado,
ResultadoDoIntervalo,
)
from engine.scanner.retakes.adaptador_de_falas import AdaptadorDeFalas
from engine.scanner.retakes.modelos_de_retakes import (
STATUS_CONFIRMADO,
STATUS_REJEITADO,
TomadaDeRetake,
)
from engine.scanner.retakes.repositorio_de_retakes import GrupoDeRetakeInexistente
from engine.scanner.transcricao_da_timeline import TranscricaoDoClipe
def _fala(fala_id: str, texto: str, inicio: float, fim: float,
video_id: str = "v1", faixa_id: str = "f1", segmento_id: str = "clip-1") -> Fala:
return Fala(
id=fala_id,
video_id=video_id,
faixa_id=faixa_id,
segmento_id=segmento_id,
ordem=0,
inicio=inicio,
fim=fim,
texto=texto,
texto_normalizado=AdaptadorDeFalas.normalizar(texto),
falante="locutor",
)
class TesteComparadorDeFalas(unittest.TestCase):
def setUp(self):
self.comparador = ComparadorDeFalas()
def test_textos_identicos_possuem_similaridade_maxima(self):
a = _fala("a", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 4.8)
b = _fala("b", "Hoje nós vamos mostrar como funciona o sistema.", 6.2, 10.9)
resultado = self.comparador.comparar(a, b)
self.assertEqual(resultado.similaridade_jaccard, 1.0)
self.assertEqual(resultado.similaridade_do_inicio, 1.0)
self.assertEqual(resultado.similaridade_final, 1.0)
def test_ordem_diferente_penaliza_sequencia_mas_nao_jaccard(self):
a = _fala("a", "Vamos mostrar o sistema funcionando.", 0.0, 3.0)
b = _fala("b", "O sistema será mostrado funcionando.", 4.0, 8.0)
resultado = self.comparador.comparar(a, b)
self.assertGreater(resultado.similaridade_jaccard, 0.3)
self.assertLess(resultado.similaridade_de_sequencia, 1.0)
def test_prefixo_igual_eleva_similaridade_do_inicio(self):
a = _fala("a", "Hoje nós vamos mostrar o sistema.", 0.0, 3.0)
b = _fala("b", "Hoje nós vamos explicar o programa.", 4.0, 7.0)
resultado = self.comparador.comparar(a, b)
self.assertGreaterEqual(resultado.similaridade_do_inicio, 0.5)
self.assertGreater(resultado.similaridade_do_inicio, resultado.similaridade_do_final)
class TesteDetectorDeReinicios(unittest.TestCase):
def test_detecta_reinicio_explicito_e_repeticao_do_inicio(self):
from engine.scanner.retakes import DetectorDeReinicios
falas = [
_fala("101", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0),
_fala("102", "Não, peraí.", 6.0, 8.0),
_fala("103", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0),
]
detector = DetectorDeReinicios()
sinais = detector.detectar(falas)
tipos = {sinal.fala_id: sinal.tipo for sinal in sinais}
self.assertIn("102", tipos)
self.assertEqual(tipos["102"], "reinicio_explicito")
self.assertIn("marcador", sinais[0].evidencias[0])
class TesteClassificador(unittest.TestCase):
def test_replicacao_completa_gera_retake_confirmado(self):
a = _fala("a", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0)
b = _fala("b", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0)
comparacao = ComparadorDeFalas().comparar(a, b)
par = ParAgrupado(a, b, comparacao, ResultadoDoIntervalo(3.0, True, 0.9, True), 1.0)
agrupamento = AgrupamentoDeFalas((a.id, b.id), (par,))
classificacao = ClassificadorDeRetakes().classificar(agrupamento)
self.assertEqual(classificacao.tipo, "retake_confirmado")
self.assertGreaterEqual(classificacao.confianca, 0.8)
class TesteCoordenadorEExemploDoRequisito(unittest.TestCase):
def _montar_transcricoes(self):
segmentos = [
SegmentoDeTranscricao(0.0, 6.0, "Hoje nós vamos mostrar como funciona o sistema.",
palavras=(
PalavraDeTranscricao("Hoje", 0.0, 0.5),
PalavraDeTranscricao("sistema", 5.0, 6.0),
)),
SegmentoDeTranscricao(6.0, 8.0, "Não, peraí."),
SegmentoDeTranscricao(9.0, 15.0, "Hoje nós vamos mostrar como funciona o sistema."),
SegmentoDeTranscricao(16.0, 21.0, "Primeiro, vamos abrir o programa."),
]
clipe = Clipe("clip-1", "clip-1", IntervaloDeTempo(0, 21), arquivo="/v.mp4")
timeline = Timeline("t1", "Sequência",
faixas=[Faixa("f1", "a1", "audio", 0, [clipe])])
frase = TranscricaoDoClipe("clip-1", "/v.mp4", 0.0, 21.0, segmentos)
return frase, timeline
def test_exemplo_pratico_agrupa_repetidas_e_exclui_informacao_nova(self):
frase, _ = self._montar_transcricoes()
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeRetakes(pasta)
coordenador = CoordenadorDeRetakes(repositorio_de_retakes=repositorio)
resultado = coordenador.analisar("v1", [frase])
self.assertEqual(len(resultado.grupos), 1)
grupo = resultado.grupos[0]
self.assertEqual(len(grupo.tomadas), 2)
self.assertEqual(grupo.tomadas[0].inicio, 0.0)
self.assertEqual(grupo.tomadas[1].inicio, 9.0)
textos = " ".join(t.texto for t in grupo.tomadas)
self.assertNotIn("Primeiro", textos)
tipos = {evidencia.tipo for evidencia in grupo.evidencias}
self.assertIn("similaridade_textual", tipos)
def test_repositorio_e_idempotente(self):
frase, _ = self._montar_transcricoes()
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeRetakes(pasta)
coordenador = CoordenadorDeRetakes(repositorio_de_retakes=repositorio)
primeiro = coordenador.analisar("v1", [frase])
segundo = coordenador.analisar("v1", [frase])
ja_analisado = repositorio.ja_analisado("v1")
# A segunda execução não gera um grupo novo (mesmo id de cache).
self.assertEqual(primeiro.grupos[0].id, segundo.grupos[0].id)
self.assertTrue(ja_analisado)
class TesteAdaptadorDePipeline(unittest.TestCase):
def test_deteccao_de_retakes_popula_contexto(self):
from engine.scanner.coordenacao import ContextoDeAnalise
segmentos = [
SegmentoDeTranscricao(0.0, 6.0, "Hoje nós vamos mostrar como funciona o sistema."),
SegmentoDeTranscricao(9.0, 15.0, "Hoje nós vamos mostrar como funciona o sistema."),
]
contexto = ContextoDeAnalise(timeline=Timeline("t1", "Seq"))
contexto.transcricoes = {"clip-1": segmentos}
with tempfile.TemporaryDirectory() as pasta:
detector = DeteccaoDeRetakes(
coordenador=CoordenadorDeRetakes(
repositorio_de_retakes=RepositorioDeRetakes(pasta)))
novo = detector.executar(contexto)
self.assertIsNotNone(novo.retakes)
self.assertEqual(novo.retakes["status"], "concluida")
self.assertEqual(len(novo.retakes["grupos"]), 1)
class TesteIntegracaoVisualAuxiliar(unittest.TestCase):
"""Cobre a Etapa 2: PySceneDetect plugado via config e cenas visuais
como evidência fraca nos retakes (reforço, nunca regra obrigatória)."""
def test_config_visual_aceita_novos_campos_e_preserva_defaults(self):
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
perfil = ConfiguracaoVisualDoScanner.de_dict({
"usar_analise_visual": True,
"detectar_cortes": True,
"sensibilidade": "alta",
})
self.assertTrue(perfil.usar_analise_visual)
self.assertTrue(perfil.detectar_cortes)
self.assertEqual(perfil.sensibilidade, "alta")
self.assertLess(perfil.limiar_de_corte, 27) # alta = mais sensível
# Round-trip pelo JSON do painel.
self.assertTrue(perfil.para_dict()["usar_analise_visual"])
self.assertEqual(perfil.para_dict()["sensibilidade"], "alta")
# Ausência dos campos não quebra o comportamento anterior.
padrao = ConfiguracaoVisualDoScanner.de_dict({})
self.assertFalse(padrao.usar_analise_visual)
self.assertTrue(padrao.detectar_cortes)
self.assertEqual(padrao.limiar_de_corte, 27.0)
def test_config_rejeita_sensibilidade_invalida(self):
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
with self.assertRaises(ValueError):
ConfiguracaoVisualDoScanner(sensibilidade="impossivel")
def test_detector_local_pluga_pyscenedetect_quando_ativado(self):
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
from engine.scanner.visual import criar_detector_visual_local
ativo = criar_detector_visual_local("cache",
configuracao=ConfiguracaoVisualDoScanner(
usar_analise_visual=True,
sensibilidade="media"))
self.assertEqual(len(ativo.detectores_de_intervalo), 1)
desligado = criar_detector_visual_local("cache")
self.assertEqual(desligado.detectores_de_intervalo, [])
def test_cena_visual_gera_evidencia_fraca_no_retake(self):
from engine.scanner.retakes import CoordenadorDeRetakes, RepositorioDeRetakes
from engine.scanner.modelos import CenaVisual
falas = [
_fala("101", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0),
_fala("102", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0),
]
# Um corte visual em 8.0s (na pausa entre as tentativas).
cenas_visuais = [CenaVisual("clip-1", 8.0, 9.0)]
with tempfile.TemporaryDirectory() as pasta:
coordenador = CoordenadorDeRetakes(
repositorio_de_retakes=RepositorioDeRetakes(pasta))
resultado = coordenador.analisar("v1", falas=falas, cenas_visuais=cenas_visuais)
self.assertEqual(len(resultado.grupos), 1)
grupo = resultado.grupos[0]
tipos = {evidencia.tipo for evidencia in grupo.evidencias}
self.assertIn("mudanca_visual_proxima", tipos)
def test_sem_corte_visual_nao_gera_evidencia_visual(self):
from engine.scanner.retakes import CoordenadorDeRetakes, RepositorioDeRetakes
falas = [
_fala("101", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0),
_fala("102", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0),
]
# Sem cenas visuais informadas (comportamento anterior).
with tempfile.TemporaryDirectory() as pasta:
coordenador = CoordenadorDeRetakes(
repositorio_de_retakes=RepositorioDeRetakes(pasta))
resultado = coordenador.analisar("v1", falas=falas)
grupo = resultado.grupos[0]
tipos = {evidencia.tipo for evidencia in grupo.evidencias}
self.assertNotIn("mudanca_visual_proxima", tipos)
def test_mudanca_de_uso_visual_forca_reanalise_por_causa_do_hash(self):
from engine.scanner.retakes import CoordenadorDeRetakes, RepositorioDeRetakes
from engine.scanner.modelos import CenaVisual
falas = [
_fala("101", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0),
_fala("102", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0),
]
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeRetakes(pasta)
coordenador = CoordenadorDeRetakes(repositorio_de_retakes=repositorio)
# 1ª análise sem visual.
sem_visual = coordenador.analisar("v1", falas=falas)
tipos_sem = {e.tipo for e in sem_visual.grupos[0].evidencias}
self.assertNotIn("mudanca_visual_proxima", tipos_sem)
# 2ª análise com visual: o hash muda → não reaproveita a antiga.
com_visual = coordenador.analisar(
"v1", falas=falas, cenas_visuais=[CenaVisual("clip-1", 8.0, 9.0)])
tipos_com = {e.tipo for e in com_visual.grupos[0].evidencias}
self.assertIn("mudanca_visual_proxima", tipos_com)
class TesteRevisaoManualEArtefatos(unittest.TestCase):
"""Cobre a tela de retakes: revisão manual via repositório e leitura dos
artefatos de transcrição do scanner (sem retranscrever)."""
def _grupo(self, grupo_id: str = "retake_001"):
from engine.scanner.retakes import GrupoDeRetake
return GrupoDeRetake(
id=grupo_id, video_id="v1", faixa_id="f1",
tipo="retake_de_fala", confianca=0.9,
tomadas=[TomadaDeRetake(ordem=1, fala_id="a", segmento_id="s",
inicio=0.0, fim=4.0, texto="olá mundo")])
def test_revisao_manual_grava_status_e_preserva_demais_grupos(self):
from engine.scanner.retakes import RepositorioDeRetakes
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeRetakes(diretoria_de_trabalho=pasta)
repositorio.registrar("v1", [self._grupo(), self._grupo("retake_002")])
atualizado = repositorio.atualizar_status_de_revisao(
"v1", "retake_001", STATUS_CONFIRMADO)
self.assertEqual(atualizado.status_de_revisao, STATUS_CONFIRMADO)
grupos = repositorio.carregar("v1")
self.assertEqual(grupos[0].status_de_revisao, STATUS_CONFIRMADO)
self.assertEqual(grupos[1].status_de_revisao, "pendente_de_revisao")
def test_revisao_manual_rejeita_status_invalido_e_grupo_inexistente(self):
from engine.scanner.retakes import RepositorioDeRetakes
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeRetakes(diretoria_de_trabalho=pasta)
repositorio.registrar("v1", [self._grupo()])
with self.assertRaises(ValueError):
repositorio.atualizar_status_de_revisao("v1", "retake_001", "talvez")
with self.assertRaises(GrupoDeRetakeInexistente):
repositorio.atualizar_status_de_revisao(
"v1", "outro", STATUS_REJEITADO)
def test_carregador_le_artefato_do_scanner_e_ignora_sem_transcricao(self):
import json
from pathlib import Path
from engine.scanner.retakes import (
ArtefatoDeTranscricaoInvalido,
CarregadorDeArtefatosDeAudio,
)
artefato = {
"status": "concluida",
"segmentos_por_clipe": [
{"clipe": "clip-1", "segmentos": [
{"inicio": 0.0, "fim": 4.0,
"texto": "Hoje nós vamos mostrar como funciona o sistema."},
{"inicio": 6.0, "fim": 10.0,
"texto": "Hoje nós vamos mostrar como funciona o sistema."},
]},
],
}
with tempfile.TemporaryDirectory() as pasta:
caminho = Path(pasta) / "v1-audio-faixa-1.json"
caminho.write_text(json.dumps(artefato, ensure_ascii=False),
encoding="utf-8")
falas = CarregadorDeArtefatosDeAudio().carregar([caminho], "v1")
self.assertEqual(len(falas), 2)
self.assertTrue(falas[0].texto.startswith("Hoje"))
caminho_vazio = Path(pasta) / "v1-audio-faixa-2.json"
caminho_vazio.write_text(json.dumps({"status": "pendente"}),
encoding="utf-8")
with self.assertRaises(ArtefatoDeTranscricaoInvalido):
CarregadorDeArtefatosDeAudio().carregar([caminho_vazio], "v1")
def test_carregador_ignora_artefato_inexistente(self):
from engine.scanner.retakes import (
ArtefatoDeTranscricaoInvalido,
CarregadorDeArtefatosDeAudio,
)
with self.assertRaises(ArtefatoDeTranscricaoInvalido):
CarregadorDeArtefatosDeAudio().carregar(
["/caminho/inexistente.json"], "v1")
def _grupo_exemplo(diretoria: str):
"""Registra um grupo mínimo no repositório e o devolve."""
repositorio = RepositorioDeRetakes(diretoria_de_trabalho=diretoria)
grupo = GrupoDeRetake(id="g1", video_id="v1", faixa_id="f1",
tipo="retake_confirmado", confianca=0.9)
repositorio.registrar("v1", [grupo])
return grupo
class TesteRevisaoManualECarregador(unittest.TestCase):
"""Cobre a revisão manual do repositório e o carregador de artefatos."""
def setUp(self) -> None:
self.pasta = tempfile.TemporaryDirectory()
self.addCleanup(self.pasta.cleanup)
self.repositorio = RepositorioDeRetakes(
diretoria_de_trabalho=self.pasta.name)
def test_status_padrao_e_pendente(self) -> None:
grupo = _grupo_exemplo(self.pasta.name)
self.assertEqual(grupo.status_de_revisao, "pendente_de_revisao")
def test_revisao_atualiza_e_persiste(self) -> None:
_grupo_exemplo(self.pasta.name)
atualizado = self.repositorio.atualizar_status_de_revisao(
"v1", "g1", "confirmado")
self.assertEqual(atualizado.status_de_revisao, "confirmado")
recarregado = self.repositorio.carregar("v1")[0]
self.assertEqual(recarregado.status_de_revisao, "confirmado")
def test_revisao_rejeita_status_invalido(self) -> None:
_grupo_exemplo(self.pasta.name)
with self.assertRaises(ValueError):
self.repositorio.atualizar_status_de_revisao("v1", "g1", "talvez")
def test_revisao_de_grupo_inexistente_levanta_erro(self) -> None:
with self.assertRaises(LookupError):
self.repositorio.atualizar_status_de_revisao("v1", "g9", "confirmado")
def test_carregador_converte_artefato_do_scanner(self) -> None:
from engine.scanner.retakes import CarregadorDeArtefatosDeAudio
artefato = Path(self.pasta.name) / "v1-audio-faixa-1.json"
artefato.write_text(json.dumps({
"status": "concluida",
"segmentos_por_clipe": [{"clipe": "clipe-1", "segmentos": [
{"inicio": 0.0, "fim": 4.0, "texto": "Hoje vamos testar."},
{"inicio": 5.0, "fim": 9.0, "texto": "Hoje vamos testar."},
]}],
}), encoding="utf-8")
falas = CarregadorDeArtefatosDeAudio().carregar([artefato], "v1")
self.assertEqual(len(falas), 2)
self.assertEqual(falas[0].video_id, "v1")
def test_carregador_levanta_erro_sem_transcricao(self) -> None:
from engine.scanner.retakes import (
ArtefatoDeTranscricaoInvalido, CarregadorDeArtefatosDeAudio)
vazio = Path(self.pasta.name) / "vazio-audio-faixa-1.json"
vazio.write_text(json.dumps({"status": "pendente"}), encoding="utf-8")
with self.assertRaises(ArtefatoDeTranscricaoInvalido):
CarregadorDeArtefatosDeAudio().carregar([vazio], "vazio")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,239 @@
"""Testes da persistência SQLite das análises (timeline, transcrição,
evidências visuais, metadados de arquivo e retakes)."""
import tempfile
import unittest
from pathlib import Path
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.integracoes.midia.extracao_de_metadados import MetadadosDoArquivo
from engine.persistencia import (
RepositorioDeAnalisesSQLite,
RepositorioDeRetakesSQLite,
RepositorioDeTimelineSQLite,
abrir_banco,
criar_esquema,
)
from engine.scanner.modelos import Cena, EvidenciaVisual, PalavraDeTranscricao, SegmentoDeTranscricao
from engine.scanner.retakes.modelos_de_retakes import EvidenciaDeRetake, GrupoDeRetake, TomadaDeRetake
from engine.scanner.transcricao_da_timeline import TranscricaoDoClipe
TABELAS_ESPERADAS = {
"videos", "faixas", "clipes", "arquivos", "analises_versao",
"segmentos_de_transcricao", "palavras_de_transcricao",
"evidencias_visuais", "cenas", "grupos_de_retake",
"tomadas_de_retake", "evidencias_de_retake",
}
class TesteEsquema(unittest.TestCase):
def test_criar_esquema_cria_todas_as_tabelas(self):
with tempfile.TemporaryDirectory() as pasta:
conexao = abrir_banco(Path(pasta) / "analises.db")
tabelas = {
linha[0] for linha in conexao.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
self.assertTrue(TABELAS_ESPERADAS.issubset(tabelas))
def test_criar_esquema_e_idempotente(self):
with tempfile.TemporaryDirectory() as pasta:
conexao = abrir_banco(Path(pasta) / "analises.db")
criar_esquema(conexao) # não deve levantar exceção ao rodar de novo
criar_esquema(conexao)
class TesteRepositorioDeRetakesSQLite(unittest.TestCase):
def _grupo_de_exemplo(self) -> GrupoDeRetake:
return GrupoDeRetake(
id="retake_abc123",
video_id="v1",
faixa_id="f1",
tipo="retake_confirmado",
confianca=0.92,
tomadas=[
TomadaDeRetake(0, "fala-1", "clip-1", 0.0, 6.0, "Hoje nós vamos mostrar.",
similaridade_com_anterior=0.0, confianca=0.92),
TomadaDeRetake(1, "fala-2", "clip-1", 9.0, 15.0, "Hoje nós vamos mostrar.",
similaridade_com_anterior=1.0, confianca=0.92),
],
evidencias=[
EvidenciaDeRetake("similaridade_textual", "Textos quase idênticos.", 0.98),
EvidenciaDeRetake("mudanca_visual_proxima", "Corte visual próximo.", None),
],
)
def test_registrar_e_carregar_preserva_campos(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeRetakesSQLite(Path(pasta) / "analises.db")
grupo = self._grupo_de_exemplo()
repositorio.registrar("v1", [grupo])
carregados = repositorio.carregar("v1")
self.assertEqual(len(carregados), 1)
recarregado = carregados[0]
self.assertEqual(recarregado.id, grupo.id)
self.assertEqual(recarregado.video_id, grupo.video_id)
self.assertEqual(recarregado.faixa_id, grupo.faixa_id)
self.assertEqual(recarregado.tipo, grupo.tipo)
self.assertEqual(recarregado.confianca, grupo.confianca)
self.assertEqual(len(recarregado.tomadas), 2)
self.assertEqual(recarregado.tomadas[0].fala_id, "fala-1")
self.assertEqual(recarregado.tomadas[1].texto, "Hoje nós vamos mostrar.")
tipos_de_evidencia = {e.tipo for e in recarregado.evidencias}
self.assertEqual(tipos_de_evidencia, {"similaridade_textual", "mudanca_visual_proxima"})
def test_ja_analisado_reflete_hash_de_versao(self):
with tempfile.TemporaryDirectory() as pasta:
banco = Path(pasta) / "analises.db"
repositorio = RepositorioDeRetakesSQLite(banco)
grupo = self._grupo_de_exemplo()
self.assertFalse(repositorio.ja_analisado("v1"))
repositorio.registrar("v1", [grupo])
self.assertTrue(repositorio.ja_analisado("v1"))
# Uma versão visual diferente não deve reaproveitar o cache.
self.assertFalse(repositorio.ja_analisado("v1", versao_visual="visual"))
def test_registrar_de_novo_substitui_grupos_anteriores(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeRetakesSQLite(Path(pasta) / "analises.db")
grupo = self._grupo_de_exemplo()
repositorio.registrar("v1", [grupo])
repositorio.registrar("v1", [])
self.assertEqual(repositorio.carregar("v1"), [])
class TesteRepositorioDeAnalisesSQLite(unittest.TestCase):
def test_registrar_metadados_de_arquivo_round_trip(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
metadados = MetadadosDoArquivo(
caminho=Path("/videos/entrada.mp4"), formato="mov,mp4",
duracao=21.4, codec_de_video="h264", codec_de_audio="aac",
largura=1920, altura=1080, taxa_de_quadros=29.97,
canais_de_audio=2, taxa_de_amostragem=48000,
tamanho_em_bytes=123456, orientacao=None, timecode="00:00:00:00",
)
repositorio.registrar_metadados_de_arquivo("v1", metadados, hash_do_conteudo="hash123")
linha = repositorio.conexao.execute(
"SELECT * FROM arquivos WHERE video_id = ?", ("v1",),
).fetchone()
self.assertEqual(linha["caminho"], "/videos/entrada.mp4")
self.assertEqual(linha["largura"], 1920)
self.assertEqual(linha["hash_do_conteudo"], "hash123")
def test_registrar_e_carregar_transcricoes_com_palavras(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
segmento = SegmentoDeTranscricao(
inicio=0.0, fim=4.8, texto="Hoje nós vamos mostrar o sistema.",
confianca=0.95,
palavras=(
PalavraDeTranscricao("Hoje", 0.0, 0.4, confianca=0.9),
PalavraDeTranscricao("nós", 0.4, 0.7, confianca=0.9),
),
emocao="neutro", confianca_emocao=0.6,
caracteristicas_acusticas={"pitch_medio": 120.5},
falante="locutor", voz_aparente="masculina", confianca_voz=0.8,
)
transcricao = TranscricaoDoClipe("clip-1", "/v.mp4", 0.0, 4.8, [segmento])
repositorio.registrar_transcricoes("v1", [transcricao])
carregados = repositorio.carregar_transcricoes("v1", "clip-1")
self.assertEqual(len(carregados), 1)
recarregado = carregados[0]
self.assertEqual(recarregado.texto, segmento.texto)
self.assertEqual(len(recarregado.palavras), 2)
self.assertEqual(recarregado.palavras[0].texto, "Hoje")
self.assertEqual(recarregado.caracteristicas_acusticas, {"pitch_medio": 120.5})
self.assertEqual(recarregado.voz_aparente, "masculina")
def test_registrar_evidencias_visuais_e_cenas(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
evidencia = EvidenciaVisual(
tipo="qualidade", inicio=1.0, fim=2.0,
valor={"nitidez": 0.87}, confianca=0.9, provider="opencv", modelo=None,
)
cena = Cena(inicio=0.0, fim=5.0, confianca=0.8, referencias=(1.2, 3.4))
repositorio.registrar_evidencias_visuais("v1", "clip-1", [evidencia])
repositorio.registrar_cenas("v1", [cena], clipe_id="clip-1")
linha_evidencia = repositorio.conexao.execute(
"SELECT * FROM evidencias_visuais WHERE video_id = ?", ("v1",),
).fetchone()
linha_cena = repositorio.conexao.execute(
"SELECT * FROM cenas WHERE video_id = ?", ("v1",),
).fetchone()
self.assertEqual(linha_evidencia["tipo"], "qualidade")
self.assertIn("nitidez", linha_evidencia["valor"])
self.assertEqual(linha_cena["clipe_id"], "clip-1")
class TesteRepositorioDeTimelineSQLite(unittest.TestCase):
def test_registrar_e_carregar_timeline_com_faixas_e_clipes(self):
clipe = Clipe(
identificador="clip-1", nome="Take 1",
intervalo_na_timeline=IntervaloDeTempo(0.0, 6.0),
intervalo_na_origem=IntervaloDeTempo(10.0, 16.0),
arquivo="/videos/entrada.mp4", identificador_da_faixa="f1",
metadados={"origem": "camera-a"},
)
faixa = Faixa(identificador="f1", nome="V1", tipo="video", indice=0, clipes=[clipe])
timeline = Timeline(
identificador="v1", nome="Sequência", duracao=21.4,
taxa_de_quadros=29.97, largura=1920, altura=1080, faixas=[faixa],
)
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeTimelineSQLite(Path(pasta) / "analises.db")
repositorio.registrar_timeline(timeline)
recarregada = repositorio.carregar_timeline("v1")
self.assertIsNotNone(recarregada)
self.assertEqual(recarregada.identificador, "v1")
self.assertEqual(recarregada.largura, 1920)
self.assertEqual(len(recarregada.faixas), 1)
faixa_recarregada = recarregada.faixas[0]
self.assertEqual(faixa_recarregada.identificador, "f1")
self.assertEqual(len(faixa_recarregada.clipes), 1)
clipe_recarregado = faixa_recarregada.clipes[0]
self.assertEqual(clipe_recarregado.arquivo, "/videos/entrada.mp4")
self.assertEqual(clipe_recarregado.intervalo_na_origem.inicio, 10.0)
self.assertEqual(clipe_recarregado.metadados, {"origem": "camera-a"})
def test_carregar_timeline_inexistente_retorna_none(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeTimelineSQLite(Path(pasta) / "analises.db")
self.assertIsNone(repositorio.carregar_timeline("desconhecido"))
def test_registrar_de_novo_substitui_faixas_e_clipes(self):
faixa_inicial = Faixa(identificador="f1", nome="V1", tipo="video", indice=0, clipes=[
Clipe("clip-1", "Take 1", IntervaloDeTempo(0.0, 6.0)),
])
timeline = Timeline(identificador="v1", nome="Sequência", faixas=[faixa_inicial])
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeTimelineSQLite(Path(pasta) / "analises.db")
repositorio.registrar_timeline(timeline)
timeline_atualizada = Timeline(identificador="v1", nome="Sequência", faixas=[])
repositorio.registrar_timeline(timeline_atualizada)
recarregada = repositorio.carregar_timeline("v1")
self.assertEqual(recarregada.faixas, [])
if __name__ == "__main__":
unittest.main()