fix: troca except Exception genérico por exceções específicas nos scripts do Scanner

executar_scanner.py: nos dois pontos que processavam clipe-a-clipe (análise
visual e transcrição), o except genérico escondia bugs de programação junto
com falhas esperadas de mídia/dependência — foi assim que o bug do soundfile
ausente passou despercebido até um teste manual. Agora captura só
(OSError, RuntimeError, ValueError, json.JSONDecodeError) na visual e
(ImportError, OSError, RuntimeError, ValueError) na transcrição; erros de
programação propagam e aparecem de verdade.

executar_retakes.py e gerar_relatorio_de_analises.py: o except Exception no
main() é uma fronteira de processo legítima (traduz qualquer falha em JSON
pra tela), mantido, mas agora imprime o traceback completo no stderr antes
de reportar — hoje esse traceback se perdia, sobrando só str(erro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-09-09 23:38:52 -04:00
co-authored by Claude Sonnet 5
parent 88f9af28cf
commit 1156619937
3 changed files with 64 additions and 9 deletions
+27 -4
View File
@@ -33,6 +33,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys import sys
import traceback
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
@@ -53,8 +54,12 @@ from engine.scanner.retakes import (
ClassificadorDeRetakes, ClassificadorDeRetakes,
CoordenadorDeRetakes, CoordenadorDeRetakes,
RegrasDeClassificacao, RegrasDeClassificacao,
RepositorioDeRetakes,
) )
from engine.persistencia import RepositorioDeRetakesSQLite
# Os retakes são gravados no mesmo banco das demais análises do projeto. O
# caminho é absoluto porque o painel roda este script com cwd ``code``.
CAMINHO_DO_BANCO = Path(__file__).resolve().parent.parent.parent / ".jhonny" / "analises.db"
# Confiança mínima exibida por critério de confirmação da tela (§9 do guia). # Confiança mínima exibida por critério de confirmação da tela (§9 do guia).
LIMIARES_POR_CRITERIO = { LIMIARES_POR_CRITERIO = {
@@ -92,6 +97,23 @@ def _grupos_do_dict(grupos) -> list[dict]:
return [asdict(grupo) for grupo in grupos] return [asdict(grupo) for grupo in grupos]
def _abrir_repositorio(pasta_do_caso: Path) -> RepositorioDeRetakesSQLite:
"""Abre o repositório de retakes no banco de análises do projeto.
Os retakes ficam no mesmo banco das falas e das evidências visuais, e não
num JSON por caso: é o que permite ao agente de edição ler, numa consulta
só, a fala e o fato de ela ser a terceira tentativa da mesma frase.
Parâmetros:
pasta_do_caso: Pasta do caso, mantida na assinatura porque o painel a
informa; o banco em si é o do projeto, não o da pasta.
Retorna:
O repositório SQLite pronto para uso.
"""
return RepositorioDeRetakesSQLite(CAMINHO_DO_BANCO)
def _analisar(pedido: dict, pasta_do_caso: Path) -> dict: def _analisar(pedido: dict, pasta_do_caso: Path) -> dict:
"""Executa a análise completa de retakes para o caso informado.""" """Executa a análise completa de retakes para o caso informado."""
configuracao = pedido.get("configuracao", {}) configuracao = pedido.get("configuracao", {})
@@ -99,7 +121,7 @@ def _analisar(pedido: dict, pasta_do_caso: Path) -> dict:
video_id = str(pedido.get("video_id") or "video") video_id = str(pedido.get("video_id") or "video")
_emitir("Preparando análise", 5) _emitir("Preparando análise", 5)
repositorio = RepositorioDeRetakes(diretoria_de_trabalho=pasta_do_caso / "retakes") repositorio = _abrir_repositorio(pasta_do_caso)
if repositorio.ja_analisado(video_id): if repositorio.ja_analisado(video_id):
_emitir("Reutilizando análise existente", 100) _emitir("Reutilizando análise existente", 100)
return {"reutilizado": True, return {"reutilizado": True,
@@ -134,7 +156,7 @@ def _revisar(pedido: dict, pasta_do_caso: Path) -> dict:
if acao not in STATUS_PADRAO_DO_PEDIDO: if acao not in STATUS_PADRAO_DO_PEDIDO:
validos = ", ".join(sorted(STATUS_PADRAO_DO_PEDIDO)) validos = ", ".join(sorted(STATUS_PADRAO_DO_PEDIDO))
raise ValueError(f"Ação de revisão desconhecida: {acao!r}. Válidos: {validos}.") raise ValueError(f"Ação de revisão desconhecida: {acao!r}. Válidos: {validos}.")
repositorio = RepositorioDeRetakes(diretoria_de_trabalho=pasta_do_caso / "retakes") repositorio = _abrir_repositorio(pasta_do_caso)
grupo = repositorio.atualizar_status_de_revisao( grupo = repositorio.atualizar_status_de_revisao(
video_id, grupo_id, STATUS_PADRAO_DO_PEDIDO[acao]) video_id, grupo_id, STATUS_PADRAO_DO_PEDIDO[acao])
return {"grupos": _grupos_do_dict([grupo])} return {"grupos": _grupos_do_dict([grupo])}
@@ -158,7 +180,8 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
try: try:
resultado = executar(args.entrada, args.pasta_do_caso) resultado = executar(args.entrada, args.pasta_do_caso)
except Exception as erro: # repassa a causa para a tela decidir como exibir except Exception as erro: # fronteira do processo: repassa a causa para a tela decidir como exibir
traceback.print_exc() # traceback completo no stderr, para não perder um bug real por trás do "erro"
print(json.dumps({"evento": "erro", "mensagem": str(erro)}, print(json.dumps({"evento": "erro", "mensagem": str(erro)},
ensure_ascii=False), flush=True) ensure_ascii=False), flush=True)
sys.exit(1) sys.exit(1)
+34 -4
View File
@@ -54,10 +54,31 @@ CAMINHO_DO_BANCO = CAMINHO_DO_CODIGO.parent / ".jhonny" / "analises.db"
def nome_seguro(nome: str) -> str: def nome_seguro(nome: str) -> str:
"""Converte o nome de uma sequência em nome de pasta seguro no filesystem.
Parâmetros:
nome: Nome original da sequência, como vem do Premiere.
Retorna:
O nome sem caracteres problemáticos, ou "timeline" quando nada sobra.
"""
return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline" return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline"
def executar(entrada: Path, saida: Path) -> Path: def executar(entrada: Path, saida: Path) -> Path:
"""Executa uma análise do Scanner conforme o pedido do painel.
Persiste a timeline, as evidências visuais, as cenas e a transcrição no
banco de análises, e grava os relatórios JSON que o painel exibe.
Parâmetros:
entrada: Arquivo JSON com o perfil e a timeline a analisar.
saida: Caminho de referência para a pasta onde o relatório será
escrito; o nome final é derivado do nome da sequência.
Retorna:
O caminho do relatório JSON gerado.
"""
pedido = json.loads(entrada.read_text(encoding="utf-8")) pedido = json.loads(entrada.read_text(encoding="utf-8"))
configuracao = ConfiguracaoVisualDoScanner.de_dict(pedido["perfil"]) configuracao = ConfiguracaoVisualDoScanner.de_dict(pedido["perfil"])
timeline = ConversorDeTimeline().converter(pedido["timeline"]) timeline = ConversorDeTimeline().converter(pedido["timeline"])
@@ -100,11 +121,17 @@ def executar(entrada: Path, saida: Path) -> Path:
gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos) gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos)
resultados.append(json.loads(arquivo.read_text(encoding="utf-8"))) resultados.append(json.loads(arquivo.read_text(encoding="utf-8")))
# Grava no banco os fatos visuais e as cenas detectadas deste clipe. # Grava no banco os fatos visuais e as cenas detectadas deste clipe.
repositorio_de_analises.registrar_evidencias_visuais( # Substitui em vez de acrescentar: reanalisar o mesmo clipe tem de
# deixar uma cópia só de cada evidência, não empilhar execuções.
repositorio_de_analises.substituir_evidencias_visuais(
timeline.identificador, clipe.identificador, resultado.evidencias) timeline.identificador, clipe.identificador, resultado.evidencias)
repositorio_de_analises.registrar_cenas( repositorio_de_analises.substituir_cenas(
timeline.identificador, resultado.cenas, clipe.identificador) timeline.identificador, resultado.cenas, clipe.identificador)
except Exception as erro: except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as erro:
# Falha esperada de mídia/detector (arquivo ilegível, subprocesso do
# Apple Vision indisponível, resposta malformada): não deve descartar
# os demais clipes. Erros de programação (TypeError, AttributeError
# etc.) propagam normalmente para não serem mascarados como isso.
resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome, resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome,
"arquivo_original": clipe.arquivo}, "erro": str(erro)}) "arquivo_original": clipe.arquivo}, "erro": str(erro)})
concluidos += peso concluidos += peso
@@ -176,7 +203,10 @@ def executar(entrada: Path, saida: Path) -> Path:
"confianca": item.confianca, "falante": item.falante, "confianca": item.confianca, "falante": item.falante,
"caracteristicas_acusticas": item.caracteristicas_acusticas} "caracteristicas_acusticas": item.caracteristicas_acusticas}
for item in transcricao.segmentos] for item in transcricao.segmentos]
except Exception as erro: except (ImportError, OSError, RuntimeError, ValueError) as erro:
# Dependência ausente (ex.: soundfile), modelo/áudio ilegível, ou
# falha de runtime do Whisper/pyannote: registra e segue sem
# transcrição. Erros de programação propagam normalmente.
transcricao_configurada = {**transcricao_configurada, "erro": str(erro)} transcricao_configurada = {**transcricao_configurada, "erro": str(erro)}
for faixa in audio: for faixa in audio:
arquivo = f"{caso}-audio-faixa-{faixa['indice'] + 1}.json" arquivo = f"{caso}-audio-faixa-{faixa['indice'] + 1}.json"
+3 -1
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys import sys
import traceback
from pathlib import Path from pathlib import Path
# O painel chama este arquivo com cwd ``code/engine``; injetar ``code`` (pai do # O painel chama este arquivo com cwd ``code/engine``; injetar ``code`` (pai do
@@ -169,7 +170,8 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
try: try:
relatorio = executar(args.banco, args.saida, args.video_id) relatorio = executar(args.banco, args.saida, args.video_id)
except Exception as erro: # repassa a causa para a tela decidir como exibir except Exception as erro: # fronteira do processo: repassa a causa para a tela decidir como exibir
traceback.print_exc() # traceback completo no stderr, para não perder um bug real por trás do "erro"
print(json.dumps({"evento": "erro", "mensagem": str(erro)}, ensure_ascii=False), flush=True) print(json.dumps({"evento": "erro", "mensagem": str(erro)}, ensure_ascii=False), flush=True)
sys.exit(1) sys.exit(1)
print(json.dumps({"evento": "concluido", "arquivo": str(args.saida), print(json.dumps({"evento": "concluido", "arquivo": str(args.saida),