Files
João Henrique c1b544f4b5 feat: reorganizado o fluxo de edição para validar a análise do Sca
- reorganizado o fluxo de edição para validar a análise do Scanner, selecionar o tipo de vídeo e enviar suas instruções no JSON.
- banco de análises: métricas de fala viraram colunas, busca lexical FTS5, enunciados com embeddings, views achatadas de leitura (fala/palavra/linha do tempo/fala com visual), ingestor do pipeline de voz e gravação idempotente de evidências visuais e cenas.
- retakes passam a ser gravados no banco de análises (SQLite) em vez de JSON por caso, com status de revisão persistido.
- planos de edição e suas aplicações passam a ser registrados no banco, em vez de se perderem no arquivo temporário.
- comando de limpeza das evidências visuais e cenas duplicadas por execuções antigas do Scanner.
- análise visual: OpenCV (rostos) e PySceneDetect passam a entrar no detector local por padrão; novo adapter InsightFace gera assinatura facial (embedding) para reconhecer a mesma pessoa entre tomadas, gravada como evidência visual no banco.
- corrigido: métricas de fala (energia, pitch, velocidade) agora gravam nas colunas dedicadas, não só no JSON; a view fala+visual passa a casar por vídeo e tempo, já que o mesmo arquivo entra na timeline como clipes distintos de vídeo e áudio; views são recriadas a cada abertura do banco para uma correção de consulta chegar a bancos já existentes.
- reordenadas as abas do painel CEP para Scanner, Refinar e Editar vídeo

Resumo:
- 24 arquivos alterados
- 9 novos
- 15 modificados
- 0 removidos

 15 files changed, 872 insertions(+), 29 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/aplicar_plano_de_edicao.py
  - code/engine/integracoes/visual/README.md
  - code/engine/integracoes/visual/__init__.py
  - code/engine/integracoes/visual/analisadores.py
  - code/engine/persistencia/__init__.py
  - code/engine/persistencia/esquema.py
  - code/engine/persistencia/repositorio_de_analises_sqlite.py
  - code/engine/persistencia/repositorio_de_retakes_sqlite.py
  - code/engine/requirements-visual.txt
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/visual.py
  - code/engine/testes/test_analise_visual_local.py
  - .jhonny/analises.db.pos-scanner-084841
  - code/engine/integracoes/embeddings/
  - code/engine/limpar_duplicatas.py
  - code/engine/persistencia/busca_de_conteudo.py
  - code/engine/persistencia/enunciados.py
  - code/engine/persistencia/ingestao_de_voz.py
  - code/engine/persistencia/limpeza.py
  - code/engine/persistencia/repositorio_de_planos.py
  - code/engine/testes/test_busca_de_conteudo.py
2026-09-10 08:58:22 -04:00

283 lines
11 KiB
Python

"""
Ingestão no banco de análises do JSON produzido pelo pipeline de voz.
O pipeline de voz (transcrição, diarização e métricas de fala) roda hoje fora
deste repositório e entrega um ``dados-para-ia.json`` cujo ``segments`` já é
uma tabela plana: cada item traz o texto e os tempos da frase, o falante e os
seis escalares de métrica lado a lado. Este módulo trata esse arquivo como
formato de entrada — nunca como armazenamento — e o converte em linhas do
banco de análises.
A leitura e a gravação ficam em classes separadas: ``LeitorDeDadosParaIA`` não
conhece banco algum e ``IngestorDeVozNoBanco`` não conhece o formato do
arquivo. Assim uma mudança no JSON de origem não alcança a persistência, e
outra origem de transcrição pode reaproveitar a gravação.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import Sequence
# Tradução entre as chaves de métrica do pipeline de voz e as colunas do banco.
# São seis escalares de conjunto fechado: por isso viram coluna, e não um JSON
# opaco que não se consegue filtrar em SQL.
COLUNAS_DE_METRICA: dict[str, str] = {
"energy_rms": "energia_rms",
"pitch_hz_median": "pitch_mediano_hz",
"pitch_hz_std": "pitch_desvio_hz",
"speaking_rate_wps": "velocidade_de_fala_pps",
"longest_internal_pause_s": "maior_pausa_interna_s",
"gap_before_s": "intervalo_anterior_s",
}
class ErroDeIngestaoDeVoz(ValueError):
"""Arquivo de voz ausente, ilegível ou fora do formato esperado."""
@dataclass(frozen=True)
class PalavraDoPipelineDeVoz:
"""Uma palavra transcrita, com os tempos que permitem cortar sem picotá-la."""
texto: str
inicio: float
fim: float
confianca: float | None = None
@dataclass(frozen=True)
class FalaDoPipelineDeVoz:
"""
Uma frase transcrita com as três análises de áudio reunidas.
Reúne numa só unidade o que o pipeline produz em etapas diferentes sobre o
mesmo intervalo de tempo: o texto (transcrição), quem falou (diarização) e
como falou (métricas). São descrições do mesmo trecho, e por isso ocupam a
mesma linha do banco.
Atributos:
inicio: Início da frase em segundos, relativo à mídia de origem.
fim: Fim da frase em segundos, relativo à mídia de origem.
texto: Texto transcrito da frase.
falante: Identificador do falante atribuído pela diarização.
metricas: Escalares de métrica de fala, já nas chaves do pipeline.
palavras: Palavras da frase, em ordem.
"""
inicio: float
fim: float
texto: str
falante: str | None = None
metricas: dict[str, float | None] = field(default_factory=dict)
palavras: tuple[PalavraDoPipelineDeVoz, ...] = ()
@property
def duracao(self) -> float:
"""Duração da frase em segundos."""
return self.fim - self.inicio
@dataclass(frozen=True)
class ResultadoDaIngestao:
"""Contagem do que foi gravado, para o chamador relatar sem reconsultar."""
falas: int
palavras: int
falantes: int
class LeitorDeDadosParaIA:
"""
Converte um ``dados-para-ia.json`` em falas do pipeline de voz.
Não acessa banco de dados nem filesystem além da leitura do arquivo
indicado, e não decide nada de editorial: apenas normaliza o formato.
"""
def ler(self, caminho: str | Path) -> tuple[FalaDoPipelineDeVoz, ...]:
"""
Lê o arquivo e devolve as falas nele contidas, em ordem de tempo.
Parâmetros:
caminho: Caminho do ``dados-para-ia.json`` a carregar.
Retorna:
As falas do arquivo, ordenadas pelo início.
Pode gerar:
ErroDeIngestaoDeVoz: quando o arquivo não existe, não é JSON
válido, não traz ``segments`` ou traz um segmento sem os
tempos obrigatórios.
"""
caminho = Path(caminho)
if not caminho.is_file():
raise ErroDeIngestaoDeVoz(f"Arquivo de voz não encontrado: {caminho}")
try:
dados = json.loads(caminho.read_text(encoding="utf-8"))
except json.JSONDecodeError as erro:
raise ErroDeIngestaoDeVoz(f"JSON inválido em {caminho}: {erro}") from erro
except OSError as erro:
raise ErroDeIngestaoDeVoz(f"Não foi possível ler {caminho}: {erro}") from erro
if not isinstance(dados, dict) or not isinstance(dados.get("segments"), list):
raise ErroDeIngestaoDeVoz(
f"{caminho} não tem a lista 'segments' esperada do pipeline de voz."
)
falas = [self._converter_fala(item, indice, caminho)
for indice, item in enumerate(dados["segments"])]
return tuple(sorted(falas, key=lambda fala: fala.inicio))
def _converter_fala(
self, item: object, indice: int, caminho: Path,
) -> FalaDoPipelineDeVoz:
"""Converte um item de ``segments`` numa fala validada."""
if not isinstance(item, dict):
raise ErroDeIngestaoDeVoz(f"Segmento {indice} de {caminho} não é um objeto.")
inicio = self._numero_obrigatorio(item, "start", indice, caminho)
fim = self._numero_obrigatorio(item, "end", indice, caminho)
if fim < inicio:
raise ErroDeIngestaoDeVoz(
f"Segmento {indice} de {caminho} termina ({fim}) antes de começar ({inicio})."
)
metricas = {chave: self._numero_opcional(item.get(chave))
for chave in COLUNAS_DE_METRICA}
return FalaDoPipelineDeVoz(
inicio=inicio,
fim=fim,
texto=str(item.get("text", "")).strip(),
falante=item.get("speaker") or None,
metricas=metricas,
palavras=self._converter_palavras(item.get("words")),
)
def _converter_palavras(self, bruto: object) -> tuple[PalavraDoPipelineDeVoz, ...]:
"""Converte a lista ``words`` de um segmento, ignorando itens malformados."""
if not isinstance(bruto, list):
return ()
palavras = []
for item in bruto:
if not isinstance(item, dict):
continue
inicio = self._numero_opcional(item.get("start"))
fim = self._numero_opcional(item.get("end"))
if inicio is None or fim is None:
continue
palavras.append(PalavraDoPipelineDeVoz(
texto=str(item.get("text", "")),
inicio=inicio,
fim=fim,
confianca=self._numero_opcional(item.get("confidence")),
))
return tuple(palavras)
@staticmethod
def _numero_obrigatorio(item: dict, chave: str, indice: int, caminho: Path) -> float:
"""Lê um número que precisa existir, com erro que diz qual campo faltou."""
valor = item.get(chave)
if not isinstance(valor, (int, float)) or isinstance(valor, bool):
raise ErroDeIngestaoDeVoz(
f"Segmento {indice} de {caminho} não tem o campo numérico '{chave}'."
)
return float(valor)
@staticmethod
def _numero_opcional(valor: object) -> float | None:
"""Lê um número que pode faltar, devolvendo None quando ausente."""
if isinstance(valor, bool) or not isinstance(valor, (int, float)):
return None
return float(valor)
class IngestorDeVozNoBanco:
"""
Grava falas do pipeline de voz no banco de análises.
Substitui integralmente as falas do clipe recebido, para que reprocessar a
mesma mídia seja idempotente sem apagar análises de outros clipes.
Atributos:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
"""
def __init__(self, conexao: sqlite3.Connection) -> None:
"""
Inicializa o ingestor com a conexão onde as falas serão gravadas.
Parâmetros:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
"""
self.conexao = conexao
def ingerir(
self, video_id: str, clipe_id: str, falas: Sequence[FalaDoPipelineDeVoz],
) -> ResultadoDaIngestao:
"""
Grava as falas do clipe, substituindo o que houver dele no banco.
Parâmetros:
video_id: Identificador do vídeo/timeline dono das falas.
clipe_id: Identificador do clipe a que as falas pertencem.
falas: Falas a gravar, já normalizadas pelo leitor.
Retorna:
As contagens de falas, palavras e falantes distintos gravados.
Pode gerar:
ErroDeIngestaoDeVoz: quando ``video_id`` ou ``clipe_id`` for vazio.
"""
self._validar_identificador("video_id", video_id)
self._validar_identificador("clipe_id", clipe_id)
colunas_de_metrica = tuple(COLUNAS_DE_METRICA.values())
insercao = (
"INSERT INTO segmentos_de_transcricao "
"(video_id, clipe_id, inicio, fim, texto, falante, "
+ ", ".join(colunas_de_metrica)
+ ") VALUES (?, ?, ?, ?, ?, ?, "
+ ", ".join("?" * len(colunas_de_metrica))
+ ")"
)
total_de_palavras = 0
with self.conexao:
self.conexao.execute("INSERT OR IGNORE INTO videos (id) VALUES (?)", (video_id,))
self.conexao.execute(
"DELETE FROM segmentos_de_transcricao WHERE video_id = ? AND clipe_id = ?",
(video_id, clipe_id),
)
for fala in falas:
cursor = self.conexao.execute(insercao, (
video_id, clipe_id, fala.inicio, fala.fim, fala.texto, fala.falante,
*(fala.metricas.get(chave) for chave in COLUNAS_DE_METRICA),
))
total_de_palavras += self._inserir_palavras(
int(cursor.lastrowid), fala,
)
return ResultadoDaIngestao(
falas=len(falas),
palavras=total_de_palavras,
falantes=len({fala.falante for fala in falas if fala.falante}),
)
def _inserir_palavras(self, segmento_id: int, fala: FalaDoPipelineDeVoz) -> int:
"""Grava as palavras de uma fala e devolve quantas foram gravadas."""
self.conexao.executemany(
"""INSERT INTO palavras_de_transcricao
(segmento_id, ordem, texto, inicio, fim, confianca, falante)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
[(segmento_id, ordem, palavra.texto, palavra.inicio, palavra.fim,
palavra.confianca, fala.falante)
for ordem, palavra in enumerate(fala.palavras)],
)
return len(fala.palavras)
@staticmethod
def _validar_identificador(nome: str, valor: str) -> None:
"""Recusa identificador vazio antes de escrever qualquer linha."""
if not isinstance(valor, str) or not valor.strip():
raise ErroDeIngestaoDeVoz(f"O parâmetro '{nome}' é obrigatório.")