"""Validação semântica de cortes e ganchos antes de alterar a timeline.""" from __future__ import annotations from dataclasses import dataclass from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao from .erros import ErroDePlanoInvalido from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao @dataclass(frozen=True) class ProblemaSemanticoDoPlano: """Descreve uma borda insegura e a ação em que ela foi encontrada.""" codigo: str mensagem: str indice_da_acao: int instante: float class ValidadorSemanticoDePlano: """Protege palavras, frases completas e respiros nas emendas editoriais.""" _TOLERANCIA_EM_SEGUNDOS = 0.01 _PONTUACAO_DE_FECHAMENTO = (".", "!", "?", "…") def __init__(self, margem_minima_em_segundos: float = 0.3) -> None: """Configura a folga acústica mínima exigida junto à fala mantida.""" if margem_minima_em_segundos < 0: raise ValueError("A margem mínima não pode ser negativa.") self._margem_minima = margem_minima_em_segundos def validar( self, plano: PlanoDeEdicao, falas: tuple[SegmentoDeTranscricao, ...], ) -> tuple[ProblemaSemanticoDoPlano, ...]: """Devolve os problemas semânticos encontrados em cortes e ganchos.""" cortes = tuple( (indice, acao) for indice, acao in enumerate(plano.acoes) if acao.tipo is TipoDeAcao.CORTE ) ganchos = tuple( (indice, acao) for indice, acao in enumerate(plano.acoes) if acao.tipo is TipoDeAcao.GANCHO ) palavras = tuple( sorted( (palavra for fala in falas for palavra in fala.palavras), key=lambda palavra: (palavra.inicio, palavra.fim), ) ) problemas: list[ProblemaSemanticoDoPlano] = [] for indice, corte in cortes: problemas.extend(self._validar_palavras_interceptadas(indice, corte, palavras)) problemas.extend(self._validar_borda_inicial(indice, corte, cortes, palavras)) problemas.extend(self._validar_borda_final(indice, corte, cortes, falas, palavras)) for indice, gancho in ganchos: problemas.extend(self._validar_palavras_interceptadas(indice, gancho, palavras)) problemas.extend(self._validar_frase_do_gancho(indice, gancho, falas, palavras)) return tuple(problemas) def garantir_valido( self, plano: PlanoDeEdicao, falas: tuple[SegmentoDeTranscricao, ...], ) -> None: """Levanta erro explicativo quando uma emenda ameaça a integridade da fala.""" problemas = self.validar(plano, falas) if not problemas: return detalhes = " | ".join(problema.mensagem for problema in problemas) raise ErroDePlanoInvalido( "Plano bloqueado pela validação de integridade da fala: " + detalhes ) def _validar_frase_do_gancho( self, indice: int, gancho: AcaoDeEdicao, falas: tuple[SegmentoDeTranscricao, ...], palavras: tuple[PalavraDeTranscricao, ...], ) -> list[ProblemaSemanticoDoPlano]: """Confirma que o gancho duplicado começa e termina como uma fala completa.""" contidas = tuple( palavra for palavra in palavras if gancho.inicio - self._TOLERANCIA_EM_SEGUNDOS <= palavra.inicio and palavra.fim <= gancho.fim + self._TOLERANCIA_EM_SEGUNDOS ) if not contidas: return [ProblemaSemanticoDoPlano( codigo="gancho_sem_fala", mensagem=f"ação {indice + 1}: o trecho do gancho não contém palavras temporizadas", indice_da_acao=indice, instante=gancho.inicio, )] primeira, ultima = contidas[0], contidas[-1] problemas: list[ProblemaSemanticoDoPlano] = [] if primeira.inicio - gancho.inicio < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS: problemas.append(ProblemaSemanticoDoPlano( codigo="margem_inicial_do_gancho_insuficiente", mensagem=( f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s antes " f'da primeira palavra do gancho "{primeira.texto}"' ), indice_da_acao=indice, instante=gancho.inicio, )) if gancho.fim - ultima.fim < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS: problemas.append(ProblemaSemanticoDoPlano( codigo="margem_final_do_gancho_insuficiente", mensagem=( f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s após " f'a última palavra do gancho "{ultima.texto}"' ), indice_da_acao=indice, instante=gancho.fim, )) fala_inicial = self._fala_da_palavra(primeira, falas) primeira_letra = next((letra for letra in primeira.texto if letra.isalpha()), "") comeca_no_meio = ( fala_inicial is not None and primeira.inicio > fala_inicial.inicio + self._TOLERANCIA_EM_SEGUNDOS ) or bool(primeira_letra and primeira_letra.islower()) if comeca_no_meio: problemas.append(ProblemaSemanticoDoPlano( codigo="gancho_comeca_no_meio_da_frase", mensagem=f'ação {indice + 1}: o gancho começa em "{primeira.texto}", no meio da frase', indice_da_acao=indice, instante=gancho.inicio, )) if not ultima.texto.rstrip().endswith(self._PONTUACAO_DE_FECHAMENTO): problemas.append(ProblemaSemanticoDoPlano( codigo="gancho_termina_no_meio_da_frase", mensagem=f'ação {indice + 1}: o gancho termina em "{ultima.texto}" sem concluir a frase', indice_da_acao=indice, instante=gancho.fim, )) return problemas def _validar_palavras_interceptadas( self, indice: int, corte: AcaoDeEdicao, palavras: tuple[PalavraDeTranscricao, ...], ) -> list[ProblemaSemanticoDoPlano]: problemas = [] for borda, instante in (("início", corte.inicio), ("fim", corte.fim)): palavra = next( ( item for item in palavras if item.inicio + self._TOLERANCIA_EM_SEGUNDOS < instante < item.fim - self._TOLERANCIA_EM_SEGUNDOS ), None, ) if palavra is not None: problemas.append(ProblemaSemanticoDoPlano( codigo="palavra_interceptada", mensagem=( f"ação {indice + 1}: o {borda} em {instante:.2f}s corta " f'a palavra "{palavra.texto}"' ), indice_da_acao=indice, instante=instante, )) return problemas def _validar_borda_inicial( self, indice: int, corte: AcaoDeEdicao, cortes: tuple[tuple[int, AcaoDeEdicao], ...], palavras: tuple[PalavraDeTranscricao, ...], ) -> list[ProblemaSemanticoDoPlano]: palavra_anterior = self._ultima_palavra_mantida_antes(corte.inicio, cortes, palavras) if palavra_anterior is None: return [] problemas = [] margem = corte.inicio - palavra_anterior.fim if margem < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS: problemas.append(ProblemaSemanticoDoPlano( codigo="margem_antes_do_corte_insuficiente", mensagem=( f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s após " f'a palavra mantida "{palavra_anterior.texto}" antes de iniciar o corte' ), indice_da_acao=indice, instante=corte.inicio, )) if not palavra_anterior.texto.rstrip().endswith(self._PONTUACAO_DE_FECHAMENTO): problemas.append(ProblemaSemanticoDoPlano( codigo="frase_interrompida_antes_do_corte", mensagem=( f"ação {indice + 1}: a fala mantida termina em " f'"{palavra_anterior.texto}" sem concluir a frase' ), indice_da_acao=indice, instante=corte.inicio, )) return problemas def _validar_borda_final( self, indice: int, corte: AcaoDeEdicao, cortes: tuple[tuple[int, AcaoDeEdicao], ...], falas: tuple[SegmentoDeTranscricao, ...], palavras: tuple[PalavraDeTranscricao, ...], ) -> list[ProblemaSemanticoDoPlano]: proxima_palavra = self._primeira_palavra_mantida_depois(corte.fim, cortes, palavras) if proxima_palavra is None: return [] problemas = [] margem = proxima_palavra.inicio - corte.fim if margem < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS: problemas.append(ProblemaSemanticoDoPlano( codigo="margem_depois_do_corte_insuficiente", mensagem=( f"ação {indice + 1}: termine o corte ao menos {self._margem_minima:.2f}s " f'antes da palavra mantida "{proxima_palavra.texto}"' ), indice_da_acao=indice, instante=corte.fim, )) fala = self._fala_da_palavra(proxima_palavra, falas) comeca_no_meio_do_segmento = ( fala is not None and proxima_palavra.inicio > fala.inicio + self._TOLERANCIA_EM_SEGUNDOS ) primeira_letra = next((letra for letra in proxima_palavra.texto if letra.isalpha()), "") comeca_em_minuscula = bool(primeira_letra and primeira_letra.islower()) if comeca_no_meio_do_segmento or comeca_em_minuscula: problemas.append(ProblemaSemanticoDoPlano( codigo="retomada_no_meio_da_frase", mensagem=( f"ação {indice + 1}: o trecho mantido retoma em " f'"{proxima_palavra.texto}" ({proxima_palavra.inicio:.2f}s), no meio da frase' ), indice_da_acao=indice, instante=corte.fim, )) return problemas @staticmethod def _fala_da_palavra( palavra: PalavraDeTranscricao, falas: tuple[SegmentoDeTranscricao, ...], ) -> SegmentoDeTranscricao | None: return next( ( fala for fala in falas if fala.inicio - 0.01 <= palavra.inicio <= fala.fim + 0.01 ), None, ) @staticmethod def _palavra_esta_mantida( palavra: PalavraDeTranscricao, cortes: tuple[tuple[int, AcaoDeEdicao], ...], ) -> bool: centro = (palavra.inicio + palavra.fim) / 2 return not any(corte.inicio <= centro <= corte.fim for _, corte in cortes) def _ultima_palavra_mantida_antes( self, instante: float, cortes: tuple[tuple[int, AcaoDeEdicao], ...], palavras: tuple[PalavraDeTranscricao, ...], ) -> PalavraDeTranscricao | None: candidatas = ( palavra for palavra in palavras if palavra.fim <= instante + self._TOLERANCIA_EM_SEGUNDOS and self._palavra_esta_mantida(palavra, cortes) ) return max(candidatas, key=lambda palavra: palavra.fim, default=None) def _primeira_palavra_mantida_depois( self, instante: float, cortes: tuple[tuple[int, AcaoDeEdicao], ...], palavras: tuple[PalavraDeTranscricao, ...], ) -> PalavraDeTranscricao | None: candidatas = ( palavra for palavra in palavras if palavra.inicio >= instante - self._TOLERANCIA_EM_SEGUNDOS and self._palavra_esta_mantida(palavra, cortes) ) return min(candidatas, key=lambda palavra: palavra.inicio, default=None)