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
This commit is contained in:
João Henrique
2026-09-10 08:58:22 -04:00
parent 1156619937
commit c1b544f4b5
25 changed files with 2792 additions and 29 deletions
+33 -1
View File
@@ -7,16 +7,48 @@ grupos de retake, com repositórios que espelham a interface dos
repositórios JSON já existentes.
"""
from .busca_de_conteudo import BuscaDeConteudo, ErroDeBusca, IndexadorSemantico, ResultadoDeBusca
from .conexao import abrir_banco
from .consultas import ConsultasDeAnalises, ErroDeConsultaInvalida
from .esquema import criar_esquema
from .enunciados import AgrupadorDeEnunciados, Enunciado, RepositorioDeEnunciados
from .esquema import criar_esquema, reconstruir_indice_de_busca
from .ingestao_de_voz import (
ErroDeIngestaoDeVoz,
IngestorDeVozNoBanco,
LeitorDeDadosParaIA,
)
from .limpeza import LimpadorDeDuplicatas
from .repositorio_de_planos import (
AcaoDoPlano,
ErroDePlano,
PlanoDeEdicao,
RepositorioDePlanos,
plano_de_dict,
)
from .repositorio_de_analises_sqlite import RepositorioDeAnalisesSQLite
from .repositorio_de_retakes_sqlite import RepositorioDeRetakesSQLite
from .repositorio_de_timeline_sqlite import RepositorioDeTimelineSQLite
__all__ = [
"AcaoDoPlano",
"AgrupadorDeEnunciados",
"BuscaDeConteudo",
"Enunciado",
"ErroDeBusca",
"ErroDeIngestaoDeVoz",
"ErroDePlano",
"IndexadorSemantico",
"IngestorDeVozNoBanco",
"LeitorDeDadosParaIA",
"LimpadorDeDuplicatas",
"PlanoDeEdicao",
"RepositorioDeEnunciados",
"RepositorioDePlanos",
"ResultadoDeBusca",
"abrir_banco",
"criar_esquema",
"plano_de_dict",
"reconstruir_indice_de_busca",
"RepositorioDeAnalisesSQLite",
"RepositorioDeRetakesSQLite",
"RepositorioDeTimelineSQLite",
@@ -0,0 +1,363 @@
"""
Busca de conteúdo falado no banco de análises.
Combina duas listas independentes sobre o mesmo material: a lexical, que acha
a palavra exata via FTS5, e a semântica, que acha a ideia via embeddings dos
enunciados. Nenhuma das duas basta sozinha — a lexical não encontra "preço"
quando a pessoa disse "quanto custa", e a semântica erra nomes próprios e
termos técnicos que precisam bater literalmente.
Toda resposta carrega ``clipe_id``, ``inicio`` e ``fim``. Um acerto sem
timecode não serve para cortar, e por isso não é considerado resultado válido
aqui.
"""
from __future__ import annotations
import math
import sqlite3
from dataclasses import dataclass
from ..integracoes.embeddings import ErroDeEmbedding, ProviderDeEmbeddings, desempacotar
# Constante da fusão recíproca de ranking (RRF). Amortece a diferença entre as
# escalas incomparáveis do FTS5 (bm25, menor é melhor) e do cosseno (maior é
# melhor): o que entra na conta é a posição em cada lista, não a nota.
CONSTANTE_DE_FUSAO = 60.0
class ErroDeBusca(ValueError):
"""Consulta vazia ou parâmetros de busca inválidos."""
@dataclass(frozen=True)
class ResultadoDeBusca:
"""
Um trecho encontrado, sempre localizável na timeline.
Atributos:
video_id: Vídeo onde o trecho está.
clipe_id: Clipe onde o trecho está.
inicio: Início do trecho em segundos.
fim: Fim do trecho em segundos.
texto: Texto do trecho encontrado.
falante: Falante do trecho, quando conhecido.
pontuacao: Nota da fusão; maior é mais relevante.
origem: Como o trecho foi encontrado — "lexical", "semantica" ou
"ambas", para que o resultado seja explicável.
falas: Ids das falas que compõem o trecho, para descer ao corte.
"""
video_id: str
clipe_id: str
inicio: float
fim: float
texto: str
falante: str | None
pontuacao: float
origem: str
falas: tuple[int, ...]
class BuscaDeConteudo:
"""
Busca falas e enunciados por texto exato, por sentido, ou pelos dois.
A busca semântica é opcional: sem um provider de embeddings a classe
continua servindo a busca lexical, em vez de falhar. Isso mantém o painel
utilizável quando o Ollama não está rodando.
Atributos:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
provider: Provider de embeddings, ou None para busca só lexical.
"""
def __init__(
self,
conexao: sqlite3.Connection,
provider: ProviderDeEmbeddings | None = None,
) -> None:
"""
Inicializa a busca sobre uma conexão e um provider opcional.
Parâmetros:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
provider: Provider de embeddings para a busca semântica. Quando
``None``, apenas a busca lexical fica disponível.
"""
self.conexao = conexao
self.provider = provider
def buscar(
self, consulta: str, video_id: str | None = None, limite: int = 10,
) -> list[ResultadoDeBusca]:
"""
Busca um texto combinando as vias lexical e semântica.
Parâmetros:
consulta: Texto livre a procurar.
video_id: Restringe a busca a um vídeo. Quando ``None``, procura
em todo o acervo.
limite: Máximo de resultados devolvidos.
Retorna:
Os trechos mais relevantes, do melhor para o pior.
Pode gerar:
ErroDeBusca: quando a consulta é vazia ou o limite não é positivo.
"""
self._validar(consulta, limite)
lexicais = self.buscar_lexical(consulta, video_id, limite * 2)
semanticos = self.buscar_semantica(consulta, video_id, limite * 2)
return self._fundir(lexicais, semanticos, limite)
def buscar_lexical(
self, consulta: str, video_id: str | None = None, limite: int = 10,
) -> list[ResultadoDeBusca]:
"""
Busca a palavra exata nas falas, via índice FTS5.
Parâmetros:
consulta: Texto a procurar. Acentos são ignorados na comparação.
video_id: Restringe a busca a um vídeo.
limite: Máximo de resultados devolvidos.
Retorna:
As falas que contêm os termos, da mais relevante para a menos.
Pode gerar:
ErroDeBusca: quando a consulta é vazia ou o limite não é positivo.
"""
self._validar(consulta, limite)
filtro = "AND s.video_id = ?" if video_id else ""
parametros: list[object] = [self._consulta_fts(consulta)]
if video_id:
parametros.append(video_id)
parametros.append(limite)
try:
linhas = self.conexao.execute(
f"""SELECT s.id, s.video_id, s.clipe_id, s.inicio, s.fim, s.texto, s.falante,
bm25(busca_de_falas) AS nota
FROM busca_de_falas
JOIN segmentos_de_transcricao s ON s.id = busca_de_falas.rowid
WHERE busca_de_falas MATCH ? {filtro}
ORDER BY nota
LIMIT ?""",
parametros,
).fetchall()
except sqlite3.OperationalError as erro:
raise ErroDeBusca(f"Consulta lexical inválida: {erro}") from erro
return [
ResultadoDeBusca(
video_id=linha["video_id"], clipe_id=linha["clipe_id"],
inicio=linha["inicio"], fim=linha["fim"], texto=linha["texto"],
falante=linha["falante"], pontuacao=-float(linha["nota"]),
origem="lexical", falas=(int(linha["id"]),),
)
for linha in linhas
]
def buscar_semantica(
self, consulta: str, video_id: str | None = None, limite: int = 10,
) -> list[ResultadoDeBusca]:
"""
Busca por sentido nos enunciados, comparando embeddings.
Percorre os vetores em memória: no volume deste acervo (milhares de
enunciados) isso custa milissegundos, e evita depender de uma extensão
de banco vetorial. Se o acervo crescer uma ordem de grandeza, o ponto
de troca por um índice aproximado é aqui, sem mexer no esquema.
Parâmetros:
consulta: Texto a procurar por sentido.
video_id: Restringe a busca a um vídeo.
limite: Máximo de resultados devolvidos.
Retorna:
Os enunciados mais próximos da consulta. Lista vazia quando não há
provider configurado ou nenhum enunciado foi embedado.
Pode gerar:
ErroDeBusca: quando a consulta é vazia ou o limite não é positivo.
ErroDeEmbedding: quando o provider existe mas falha ao responder.
"""
self._validar(consulta, limite)
if self.provider is None:
return []
vetor_da_consulta = self.provider.gerar_para_consulta(consulta)
filtro = "WHERE e.video_id = ?" if video_id else ""
parametros = (video_id,) if video_id else ()
candidatos = []
for linha in self.conexao.execute(
f"""SELECT e.id, e.video_id, e.clipe_id, e.inicio, e.fim, e.texto, e.falante,
b.vetor
FROM enunciados e
JOIN embeddings_de_enunciado b ON b.enunciado_id = e.id
{filtro}""",
parametros,
):
similaridade = self._cosseno(vetor_da_consulta, desempacotar(linha["vetor"]))
candidatos.append((similaridade, linha))
candidatos.sort(key=lambda item: item[0], reverse=True)
return [
ResultadoDeBusca(
video_id=linha["video_id"], clipe_id=linha["clipe_id"],
inicio=linha["inicio"], fim=linha["fim"], texto=linha["texto"],
falante=linha["falante"], pontuacao=similaridade,
origem="semantica", falas=self._falas_do_enunciado(int(linha["id"])),
)
for similaridade, linha in candidatos[:limite]
]
def _falas_do_enunciado(self, enunciado_id: int) -> tuple[int, ...]:
"""Lê os ids das falas que compõem um enunciado, em ordem."""
return tuple(
int(linha["segmento_id"])
for linha in self.conexao.execute(
"SELECT segmento_id FROM falas_do_enunciado WHERE enunciado_id = ? ORDER BY ordem",
(enunciado_id,),
)
)
def _fundir(
self,
lexicais: list[ResultadoDeBusca],
semanticos: list[ResultadoDeBusca],
limite: int,
) -> list[ResultadoDeBusca]:
"""Funde as duas listas por posição (RRF), somando o peso de cada via."""
acumulado: dict[tuple[str, str, int], tuple[float, set[str], ResultadoDeBusca]] = {}
for lista in (lexicais, semanticos):
for posicao, resultado in enumerate(lista):
chave = (resultado.video_id, resultado.clipe_id, round(resultado.inicio * 100))
peso = 1.0 / (CONSTANTE_DE_FUSAO + posicao + 1)
if chave in acumulado:
nota, origens, guardado = acumulado[chave]
origens.add(resultado.origem)
acumulado[chave] = (nota + peso, origens, guardado)
else:
acumulado[chave] = (peso, {resultado.origem}, resultado)
ordenados = sorted(acumulado.values(), key=lambda item: item[0], reverse=True)
return [
ResultadoDeBusca(
video_id=guardado.video_id, clipe_id=guardado.clipe_id,
inicio=guardado.inicio, fim=guardado.fim, texto=guardado.texto,
falante=guardado.falante, pontuacao=round(nota, 6),
origem="ambas" if len(origens) > 1 else next(iter(origens)),
falas=guardado.falas,
)
for nota, origens, guardado in ordenados[:limite]
]
@staticmethod
def _consulta_fts(consulta: str) -> str:
"""
Monta a expressão do FTS5 a partir do texto digitado.
Cada palavra vira um termo com prefixo, e os termos são exigidos
juntos. Escapar em aspas evita que pontuação do usuário seja
interpretada como operador do FTS5 e derrube a consulta.
"""
termos = [palavra for palavra in consulta.replace('"', " ").split() if palavra]
return " AND ".join(f'"{termo}"*' for termo in termos)
@staticmethod
def _cosseno(primeiro: tuple[float, ...], segundo: tuple[float, ...]) -> float:
"""Similaridade de cosseno entre dois vetores, ou 0 se algum for nulo."""
if len(primeiro) != len(segundo):
return 0.0
produto = sum(a * b for a, b in zip(primeiro, segundo))
norma_primeiro = math.sqrt(sum(a * a for a in primeiro))
norma_segundo = math.sqrt(sum(b * b for b in segundo))
if norma_primeiro == 0.0 or norma_segundo == 0.0:
return 0.0
return produto / (norma_primeiro * norma_segundo)
@staticmethod
def _validar(consulta: str, limite: int) -> None:
"""Recusa consulta vazia ou limite não positivo antes de tocar o banco."""
if not isinstance(consulta, str) or not consulta.strip():
raise ErroDeBusca("A consulta não pode ser vazia.")
if limite <= 0:
raise ErroDeBusca("O limite precisa ser maior que zero.")
class IndexadorSemantico:
"""
Gera e grava os embeddings dos enunciados de um vídeo.
Atributos:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
provider: Provider que transforma o texto de cada enunciado em vetor.
"""
def __init__(self, conexao: sqlite3.Connection, provider: ProviderDeEmbeddings) -> None:
"""
Inicializa o indexador com a conexão e o provider de embeddings.
Parâmetros:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
provider: Provider que gera os vetores.
"""
self.conexao = conexao
self.provider = provider
def indexar(self, video_id: str, refazer: bool = False) -> int:
"""
Gera os embeddings faltantes dos enunciados de um vídeo.
Parâmetros:
video_id: Vídeo cujos enunciados serão indexados.
refazer: Quando verdadeiro, regenera também os que já têm vetor —
necessário ao trocar de modelo de embeddings.
Retorna:
A quantidade de enunciados indexados nesta execução.
Pode gerar:
ErroDeEmbedding: quando o provider falha ao gerar algum vetor.
"""
from ..integracoes.embeddings import empacotar
filtro = "" if refazer else "AND b.enunciado_id IS NULL"
pendentes = self.conexao.execute(
f"""SELECT e.id, e.texto FROM enunciados e
LEFT JOIN embeddings_de_enunciado b ON b.enunciado_id = e.id
WHERE e.video_id = ? {filtro}
ORDER BY e.inicio""",
(video_id,),
).fetchall()
indexados = 0
for linha in pendentes:
if not linha["texto"].strip():
continue
vetor = self.provider.gerar_para_documento(linha["texto"])
with self.conexao:
self.conexao.execute(
"""INSERT INTO embeddings_de_enunciado
(enunciado_id, modelo, dimensoes, vetor)
VALUES (?, ?, ?, ?)
ON CONFLICT (enunciado_id) DO UPDATE SET
modelo = excluded.modelo,
dimensoes = excluded.dimensoes,
vetor = excluded.vetor,
gerado_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
(linha["id"], self.provider.modelo, self.provider.dimensoes,
empacotar(vetor)),
)
indexados += 1
return indexados
__all__ = [
"BuscaDeConteudo",
"ErroDeBusca",
"ErroDeEmbedding",
"IndexadorSemantico",
"ResultadoDeBusca",
]
+246
View File
@@ -0,0 +1,246 @@
"""
Agrupamento de falas em enunciados embedáveis.
Uma fala do Whisper costuma ter de 3 a 8 segundos. Embedar um trecho tão curto
produz um vetor instável: pouco texto, muito ruído, e vizinhança semântica
pouco confiável. O enunciado resolve isso juntando falas consecutivas do mesmo
falante até atingir uma duração alvo, formando um bloco com contexto
suficiente para ter significado.
O vínculo com as falas de origem é sempre preservado. Isso é o que diferencia
esta busca de um RAG genérico: todo acerto semântico precisa voltar com o
timecode exato, senão não serve para cortar.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
DURACAO_ALVO_PADRAO = 30.0
DURACAO_MAXIMA_PADRAO = 45.0
INTERVALO_QUE_QUEBRA_BLOCO = 2.0
class ErroDeAgrupamento(ValueError):
"""Parâmetros de agrupamento inválidos."""
@dataclass(frozen=True)
class FalaParaAgrupar:
"""Uma fala já persistida, no mínimo necessário para agrupá-la."""
segmento_id: int
clipe_id: str
inicio: float
fim: float
texto: str
falante: str | None
@dataclass(frozen=True)
class Enunciado:
"""
Um bloco de falas consecutivas tratado como unidade de busca semântica.
Atributos:
clipe_id: Clipe a que o bloco pertence.
inicio: Início do bloco, herdado da primeira fala.
fim: Fim do bloco, herdado da última fala.
texto: Texto das falas concatenado.
falante: Falante do bloco, quando todas as falas são do mesmo.
segmentos: Ids das falas que compõem o bloco, em ordem.
"""
clipe_id: str
inicio: float
fim: float
texto: str
falante: str | None
segmentos: tuple[int, ...]
@property
def duracao(self) -> float:
"""Duração do bloco em segundos."""
return self.fim - self.inicio
class AgrupadorDeEnunciados:
"""
Junta falas consecutivas em blocos de duração próxima a um alvo.
Um bloco é fechado quando atingir a duração alvo, quando o falante mudar,
quando houver um silêncio longo entre duas falas ou quando incluir a
próxima fala ultrapassaria a duração máxima. A troca de falante e o
silêncio longo são fronteiras naturais de assunto: agrupar através delas
misturaria ideias distintas no mesmo vetor.
Atributos:
duracao_alvo: Duração a partir da qual o bloco pode ser fechado.
duracao_maxima: Duração que o bloco não deve ultrapassar.
intervalo_que_quebra: Silêncio entre falas que força um bloco novo.
"""
def __init__(
self,
duracao_alvo: float = DURACAO_ALVO_PADRAO,
duracao_maxima: float = DURACAO_MAXIMA_PADRAO,
intervalo_que_quebra: float = INTERVALO_QUE_QUEBRA_BLOCO,
) -> None:
"""
Inicializa o agrupador com os limites de duração dos blocos.
Parâmetros:
duracao_alvo: Duração a partir da qual o bloco pode ser fechado.
duracao_maxima: Duração que o bloco não deve ultrapassar.
intervalo_que_quebra: Silêncio entre falas que força bloco novo.
Pode gerar:
ErroDeAgrupamento: quando as durações não são positivas ou a
máxima é menor que a alvo.
"""
if duracao_alvo <= 0 or duracao_maxima <= 0:
raise ErroDeAgrupamento("As durações de agrupamento devem ser positivas.")
if duracao_maxima < duracao_alvo:
raise ErroDeAgrupamento(
"A duração máxima não pode ser menor que a duração alvo."
)
self.duracao_alvo = duracao_alvo
self.duracao_maxima = duracao_maxima
self.intervalo_que_quebra = intervalo_que_quebra
def agrupar(self, falas: list[FalaParaAgrupar]) -> list[Enunciado]:
"""
Agrupa falas ordenadas por tempo em enunciados.
Parâmetros:
falas: Falas a agrupar. São ordenadas por clipe e início antes do
agrupamento, então a ordem de entrada não importa.
Retorna:
Os enunciados formados, em ordem de tempo.
"""
ordenadas = sorted(falas, key=lambda fala: (fala.clipe_id, fala.inicio))
enunciados: list[Enunciado] = []
bloco: list[FalaParaAgrupar] = []
for fala in ordenadas:
if bloco and self._deve_fechar(bloco, fala):
enunciados.append(self._montar(bloco))
bloco = []
bloco.append(fala)
if self._duracao(bloco) >= self.duracao_alvo:
enunciados.append(self._montar(bloco))
bloco = []
if bloco:
enunciados.append(self._montar(bloco))
return enunciados
def _deve_fechar(self, bloco: list[FalaParaAgrupar], proxima: FalaParaAgrupar) -> bool:
"""Decide se a próxima fala pertence a um bloco novo."""
ultima = bloco[-1]
if proxima.clipe_id != ultima.clipe_id:
return True
if proxima.falante != ultima.falante:
return True
if proxima.fim - bloco[0].inicio > self.duracao_maxima:
return True
# O silêncio só encerra o bloco depois que ele já tem corpo. Numa fala
# pausada, quebrar no primeiro intervalo longo produziria blocos de
# poucos segundos — curtos demais para gerar um embedding estável, que
# é justamente o problema que o agrupamento existe para resolver.
if proxima.inicio - ultima.fim < self.intervalo_que_quebra:
return False
return self._duracao(bloco) >= self.duracao_alvo / 2
@staticmethod
def _duracao(bloco: list[FalaParaAgrupar]) -> float:
"""Duração coberta por um bloco em formação."""
return bloco[-1].fim - bloco[0].inicio
@staticmethod
def _montar(bloco: list[FalaParaAgrupar]) -> Enunciado:
"""Monta o enunciado imutável a partir das falas acumuladas."""
falantes = {fala.falante for fala in bloco}
return Enunciado(
clipe_id=bloco[0].clipe_id,
inicio=bloco[0].inicio,
fim=bloco[-1].fim,
texto=" ".join(fala.texto.strip() for fala in bloco if fala.texto.strip()),
falante=bloco[0].falante if len(falantes) == 1 else None,
segmentos=tuple(fala.segmento_id for fala in bloco),
)
class RepositorioDeEnunciados:
"""
Lê falas e grava enunciados no banco de análises.
Atributos:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
"""
def __init__(self, conexao: sqlite3.Connection) -> None:
"""
Inicializa o repositório sobre uma conexão existente.
Parâmetros:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
"""
self.conexao = conexao
def carregar_falas(self, video_id: str) -> list[FalaParaAgrupar]:
"""
Carrega as falas de um vídeo no formato aceito pelo agrupador.
Parâmetros:
video_id: Identificador do vídeo cujas falas serão lidas.
Retorna:
As falas do vídeo, ordenadas por clipe e início.
"""
return [
FalaParaAgrupar(
segmento_id=linha["id"], clipe_id=linha["clipe_id"],
inicio=linha["inicio"], fim=linha["fim"],
texto=linha["texto"], falante=linha["falante"],
)
for linha in self.conexao.execute(
"""SELECT id, clipe_id, inicio, fim, texto, falante
FROM segmentos_de_transcricao
WHERE video_id = ? ORDER BY clipe_id, inicio""",
(video_id,),
)
]
def substituir_enunciados(self, video_id: str, enunciados: list[Enunciado]) -> int:
"""
Regrava os enunciados de um vídeo, apagando os anteriores.
Os embeddings são removidos junto pelo ``ON DELETE CASCADE``: um
enunciado com fronteiras novas não pode herdar o vetor do antigo.
Parâmetros:
video_id: Identificador do vídeo dono dos enunciados.
enunciados: Enunciados a gravar.
Retorna:
A quantidade de enunciados gravados.
"""
with self.conexao:
self.conexao.execute("DELETE FROM enunciados WHERE video_id = ?", (video_id,))
for enunciado in enunciados:
cursor = self.conexao.execute(
"""INSERT INTO enunciados
(video_id, clipe_id, inicio, fim, texto, falante, total_de_falas)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(video_id, enunciado.clipe_id, enunciado.inicio, enunciado.fim,
enunciado.texto, enunciado.falante, len(enunciado.segmentos)),
)
self.conexao.executemany(
"""INSERT INTO falas_do_enunciado (enunciado_id, segmento_id, ordem)
VALUES (?, ?, ?)""",
[(cursor.lastrowid, segmento_id, ordem)
for ordem, segmento_id in enumerate(enunciado.segmentos)],
)
return len(enunciados)
+335 -4
View File
@@ -82,10 +82,22 @@ CREATE TABLE IF NOT EXISTS segmentos_de_transcricao (
confianca_voz REAL,
emocao TEXT,
confianca_emocao REAL,
caracteristicas_acusticas TEXT
caracteristicas_acusticas TEXT,
energia_rms REAL,
pitch_mediano_hz REAL,
pitch_desvio_hz REAL,
velocidade_de_fala_pps REAL,
maior_pausa_interna_s REAL,
intervalo_anterior_s REAL
);
CREATE INDEX IF NOT EXISTS idx_segmentos_video_clipe
ON segmentos_de_transcricao(video_id, clipe_id);
-- Consulta temporal: "quais falas entre X e Y segundos". É o acesso mais
-- usado pelo agente de edição, por isso tem índice próprio.
CREATE INDEX IF NOT EXISTS idx_segmentos_intervalo
ON segmentos_de_transcricao(video_id, inicio, fim);
CREATE INDEX IF NOT EXISTS idx_segmentos_falante
ON segmentos_de_transcricao(video_id, falante);
CREATE TABLE IF NOT EXISTS palavras_de_transcricao (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -131,7 +143,8 @@ CREATE TABLE IF NOT EXISTS grupos_de_retake (
faixa_id TEXT NOT NULL,
tipo TEXT NOT NULL,
confianca REAL NOT NULL,
criado_em TEXT NOT NULL
criado_em TEXT NOT NULL,
status_de_revisao TEXT NOT NULL DEFAULT 'pendente'
);
CREATE INDEX IF NOT EXISTS idx_grupos_video ON grupos_de_retake(video_id);
@@ -157,10 +170,328 @@ CREATE TABLE IF NOT EXISTS evidencias_de_retake (
valor REAL
);
CREATE INDEX IF NOT EXISTS idx_evidencias_retake_grupo ON evidencias_de_retake(grupo_id);
-- ---------------------------------------------------------------------------
-- Busca lexical (FTS5)
-- ---------------------------------------------------------------------------
-- Índice de texto completo sobre as falas. Usa ``content=`` (external content)
-- para não duplicar o texto: o FTS5 guarda só o índice invertido e lê o texto
-- da tabela original pelo rowid. ``remove_diacritics 2`` faz "elegancia"
-- encontrar "elegância", que é o comportamento esperado em pt-BR.
CREATE VIRTUAL TABLE IF NOT EXISTS busca_de_falas USING fts5(
texto,
content='segmentos_de_transcricao',
content_rowid='id',
tokenize="unicode61 remove_diacritics 2"
);
CREATE TRIGGER IF NOT EXISTS trg_busca_de_falas_inserir
AFTER INSERT ON segmentos_de_transcricao BEGIN
INSERT INTO busca_de_falas(rowid, texto) VALUES (new.id, new.texto);
END;
CREATE TRIGGER IF NOT EXISTS trg_busca_de_falas_remover
AFTER DELETE ON segmentos_de_transcricao BEGIN
INSERT INTO busca_de_falas(busca_de_falas, rowid, texto)
VALUES ('delete', old.id, old.texto);
END;
CREATE TRIGGER IF NOT EXISTS trg_busca_de_falas_atualizar
AFTER UPDATE OF texto ON segmentos_de_transcricao BEGIN
INSERT INTO busca_de_falas(busca_de_falas, rowid, texto)
VALUES ('delete', old.id, old.texto);
INSERT INTO busca_de_falas(rowid, texto) VALUES (new.id, new.texto);
END;
-- ---------------------------------------------------------------------------
-- Busca semântica (enunciados + embeddings)
-- ---------------------------------------------------------------------------
-- Uma fala do Whisper tem 3 a 8 segundos: curta demais para gerar um embedding
-- com significado estável. O enunciado agrupa falas vizinhas do mesmo falante
-- num bloco de dezenas de segundos, que é a unidade embedável. O vínculo com
-- as falas de origem é preservado em ``falas_do_enunciado`` para que todo
-- acerto semântico volte com timecode utilizável para um corte.
CREATE TABLE IF NOT EXISTS enunciados (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
clipe_id TEXT NOT NULL,
inicio REAL NOT NULL,
fim REAL NOT NULL,
texto TEXT NOT NULL,
falante TEXT,
total_de_falas INTEGER NOT NULL,
criado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_enunciados_video ON enunciados(video_id, inicio);
CREATE TABLE IF NOT EXISTS falas_do_enunciado (
enunciado_id INTEGER NOT NULL REFERENCES enunciados(id) ON DELETE CASCADE,
segmento_id INTEGER NOT NULL REFERENCES segmentos_de_transcricao(id) ON DELETE CASCADE,
ordem INTEGER NOT NULL,
PRIMARY KEY (enunciado_id, segmento_id)
);
CREATE INDEX IF NOT EXISTS idx_falas_do_enunciado_segmento
ON falas_do_enunciado(segmento_id);
CREATE TABLE IF NOT EXISTS embeddings_de_enunciado (
enunciado_id INTEGER PRIMARY KEY REFERENCES enunciados(id) ON DELETE CASCADE,
modelo TEXT NOT NULL,
dimensoes INTEGER NOT NULL,
vetor BLOB NOT NULL,
gerado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- ---------------------------------------------------------------------------
-- Planos de edição
-- ---------------------------------------------------------------------------
-- O plano que a IA devolve é a única evidência de como uma edição foi
-- decidida, e antes disto ele vivia num arquivo temporário que sumia depois de
-- aplicado. Guardá-lo é o que permite comparar o que foi proposto com o que o
-- editor manteve — o único sinal disponível para aprender o estilo de corte.
CREATE TABLE IF NOT EXISTS planos_de_edicao (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT REFERENCES videos(id) ON DELETE CASCADE,
origem TEXT NOT NULL,
tipo_de_video TEXT,
modelo_da_ia TEXT,
intencao TEXT,
criado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_planos_video ON planos_de_edicao(video_id, criado_em);
CREATE TABLE IF NOT EXISTS acoes_do_plano (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plano_id INTEGER NOT NULL REFERENCES planos_de_edicao(id) ON DELETE CASCADE,
ordem INTEGER NOT NULL,
tipo TEXT NOT NULL,
inicio REAL,
fim REAL,
motivo TEXT,
-- Cada tipo de ação tem parâmetros próprios e incompatíveis entre si (um
-- zoom tem escala, um texto tem conteúdo e posição). É carga polimórfica
-- de verdade, consumida inteira por quem aplica: por isso fica em JSON.
parametros TEXT
);
CREATE INDEX IF NOT EXISTS idx_acoes_do_plano ON acoes_do_plano(plano_id, ordem);
CREATE TABLE IF NOT EXISTS aplicacoes_do_plano (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plano_id INTEGER NOT NULL REFERENCES planos_de_edicao(id) ON DELETE CASCADE,
sequencia TEXT,
aplicado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
sucesso INTEGER NOT NULL,
acoes_aplicadas INTEGER NOT NULL DEFAULT 0,
mensagem TEXT
);
CREATE INDEX IF NOT EXISTS idx_aplicacoes_plano ON aplicacoes_do_plano(plano_id);
-- ---------------------------------------------------------------------------
-- Leituras achatadas
-- ---------------------------------------------------------------------------
-- As tabelas acima guardam cada fato uma única vez, no nível em que ele é
-- verdadeiro: a emoção e o falante valem para a frase, os tempos exatos valem
-- para a palavra. As views abaixo reapresentam esses mesmos dados já juntos,
-- para que o agente de edição leia tudo numa consulta só sem que o banco
-- precise repetir valor em disco. Normalizado para gravar, achatado para ler.
-- Uma linha por fala, com as três análises de áudio e o resumo das palavras.
CREATE VIEW IF NOT EXISTS vw_falas_completas AS
SELECT
s.id AS fala_id,
s.video_id,
s.clipe_id,
s.inicio,
s.fim,
s.fim - s.inicio AS duracao,
s.texto,
s.falante,
s.emocao,
s.confianca_emocao,
s.energia_rms,
s.pitch_mediano_hz,
s.pitch_desvio_hz,
s.velocidade_de_fala_pps,
s.maior_pausa_interna_s,
s.intervalo_anterior_s,
count(p.id) AS total_de_palavras
FROM segmentos_de_transcricao s
LEFT JOIN palavras_de_transcricao p ON p.segmento_id = s.id
GROUP BY s.id;
-- Uma linha por palavra, repetindo o que vale para a frase inteira. É a forma
-- mais plana possível de ler a transcrição — sem custo de duplicação em disco,
-- porque a repetição acontece na leitura e não na gravação.
CREATE VIEW IF NOT EXISTS vw_palavras_completas AS
SELECT
p.id AS palavra_id,
p.segmento_id AS fala_id,
s.video_id,
s.clipe_id,
p.ordem,
p.texto AS palavra,
p.inicio AS palavra_inicio,
p.fim AS palavra_fim,
p.confianca AS palavra_confianca,
s.texto AS frase,
s.inicio AS frase_inicio,
s.fim AS frase_fim,
s.falante,
s.emocao,
s.confianca_emocao,
s.energia_rms,
s.pitch_mediano_hz,
s.velocidade_de_fala_pps
FROM palavras_de_transcricao p
JOIN segmentos_de_transcricao s ON s.id = p.segmento_id;
-- Linha do tempo unificada: fala e imagem no mesmo eixo, para percorrer o
-- vídeo em ordem cronológica lendo áudio e imagem juntos. A geometria bruta
-- (landmarks, bounding boxes) fica de fora de propósito: o que decide corte é
-- o fato editorial, e a geometria continua disponível em evidencias_visuais
-- para quem precisar dela.
CREATE VIEW IF NOT EXISTS vw_linha_do_tempo AS
SELECT video_id, clipe_id, inicio, fim, 'fala' AS tipo,
texto AS descricao, falante AS detalhe, confianca_emocao AS confianca
FROM segmentos_de_transcricao
UNION ALL
SELECT video_id, clipe_id, inicio, fim, 'cena' AS tipo,
'mudança de cena' AS descricao, NULL AS detalhe, confianca
FROM cenas
UNION ALL
SELECT video_id, clipe_id, inicio, fim, tipo,
json_extract(valor, '$.identificador') AS descricao,
NULL AS detalhe, confianca
FROM evidencias_visuais
WHERE tipo = 'categoria'
UNION ALL
SELECT video_id, clipe_id, inicio, fim, tipo,
json_extract(valor, '$.texto') AS descricao,
NULL AS detalhe, confianca
FROM evidencias_visuais
WHERE tipo = 'interpretacao_editorial';
-- Cada fala com o que estava em quadro enquanto ela era dita. O vínculo é por
-- sobreposição de tempo, não por chave estrangeira: fala e imagem são medidas
-- em grades diferentes (a fala é um intervalo, a imagem é amostrada por
-- quadro) e nunca coincidem exatamente. As categorias visuais entram
-- agregadas, e não uma linha por amostra, para a resposta caber num prompt.
CREATE VIEW IF NOT EXISTS vw_falas_com_visual AS
SELECT
f.fala_id,
f.video_id,
f.clipe_id,
f.inicio,
f.fim,
f.texto,
f.falante,
f.emocao,
f.energia_rms,
f.velocidade_de_fala_pps,
(SELECT group_concat(DISTINCT json_extract(e.valor, '$.identificador'))
FROM evidencias_visuais e
WHERE e.video_id = f.video_id
AND e.tipo = 'categoria' AND e.confianca >= 0.5
AND e.inicio BETWEEN f.inicio AND f.fim) AS categorias_em_quadro,
(SELECT count(*)
FROM evidencias_visuais e
WHERE e.video_id = f.video_id
AND e.tipo = 'rosto'
AND e.inicio BETWEEN f.inicio AND f.fim) AS amostras_com_rosto,
(SELECT round(avg(json_extract(e.valor, '$.score_global')), 3)
FROM evidencias_visuais e
WHERE e.video_id = f.video_id
AND e.tipo = 'estetica'
AND e.inicio BETWEEN f.inicio AND f.fim) AS estetica_media,
(SELECT count(*)
FROM cenas c
WHERE c.video_id = f.video_id
AND c.inicio > f.inicio AND c.inicio < f.fim) AS cortes_de_cena_dentro
FROM vw_falas_completas f;
"""
# Colunas acrescentadas depois da primeira versão do esquema. ``CREATE TABLE IF
# NOT EXISTS`` não altera tabela existente, então cada uma é aplicada por
# ``_migrar_colunas`` em bancos que já foram criados.
# Views recriadas a cada abertura do banco. "CREATE VIEW IF NOT EXISTS" não
# atualiza a definição de uma view já existente, então uma correção na
# consulta de uma view só chegaria a bancos novos sem isso — o banco de
# produção ficaria preso para sempre na primeira versão que foi criada nele.
_VIEWS = (
"vw_falas_completas",
"vw_palavras_completas",
"vw_linha_do_tempo",
"vw_falas_com_visual",
)
_COLUNAS_ACRESCENTADAS: tuple[tuple[str, str, str], ...] = (
("segmentos_de_transcricao", "energia_rms", "REAL"),
("segmentos_de_transcricao", "pitch_mediano_hz", "REAL"),
("segmentos_de_transcricao", "pitch_desvio_hz", "REAL"),
("segmentos_de_transcricao", "velocidade_de_fala_pps", "REAL"),
("segmentos_de_transcricao", "maior_pausa_interna_s", "REAL"),
("segmentos_de_transcricao", "intervalo_anterior_s", "REAL"),
("grupos_de_retake", "status_de_revisao", "TEXT NOT NULL DEFAULT 'pendente'"),
)
class ErroDeEsquema(RuntimeError):
"""Falha ao criar ou migrar o esquema do banco de análises."""
def _colunas_existentes(conexao: sqlite3.Connection, tabela: str) -> set[str]:
"""Lê os nomes de coluna já presentes numa tabela."""
return {linha[1] for linha in conexao.execute(f"PRAGMA table_info({tabela})")}
def _migrar_colunas(conexao: sqlite3.Connection) -> None:
"""Acrescenta colunas novas em bancos criados por versões anteriores."""
for tabela, coluna, tipo in _COLUNAS_ACRESCENTADAS:
if not _colunas_existentes(conexao, tabela):
continue
if coluna in _colunas_existentes(conexao, tabela):
continue
conexao.execute(f"ALTER TABLE {tabela} ADD COLUMN {coluna} {tipo}")
def reconstruir_indice_de_busca(conexao: sqlite3.Connection) -> int:
"""
Reindexa do zero a busca lexical a partir das falas já persistidas.
Necessário em bancos criados antes de ``busca_de_falas`` existir: os
gatilhos só cobrem escritas futuras, então as falas antigas precisam ser
carregadas uma vez.
Parâmetros:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
Retorna:
A quantidade de falas presentes no índice depois da reconstrução.
"""
with conexao:
conexao.execute("INSERT INTO busca_de_falas(busca_de_falas) VALUES ('rebuild')")
return int(conexao.execute("SELECT count(*) FROM busca_de_falas").fetchone()[0])
def criar_esquema(conexao: sqlite3.Connection) -> None:
"""Cria (de forma idempotente) todas as tabelas do banco de análises."""
conexao.executescript(_DDL)
"""
Cria (de forma idempotente) todas as tabelas do banco de análises.
Aplica também as migrações de coluna necessárias em bancos criados por
versões anteriores do esquema, para que abrir um banco antigo baste para
deixá-lo no formato atual.
Parâmetros:
conexao: Conexão SQLite aberta onde o esquema será aplicado.
Pode gerar:
ErroDeEsquema: quando o SQLite recusa a DDL — tipicamente por falta da
extensão FTS5 na build em uso.
"""
try:
_migrar_colunas(conexao)
for view in _VIEWS:
conexao.execute(f"DROP VIEW IF EXISTS {view}")
conexao.executescript(_DDL)
except sqlite3.OperationalError as erro:
raise ErroDeEsquema(f"Não foi possível aplicar o esquema de análises: {erro}") from erro
conexao.commit()
+282
View File
@@ -0,0 +1,282 @@
"""
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.")
+93
View File
@@ -0,0 +1,93 @@
"""
Remoção de linhas duplicadas deixadas por execuções não idempotentes.
Antes de ``substituir_evidencias_visuais`` e ``substituir_cenas`` existirem, o
Scanner acrescentava evidências e cenas a cada execução em vez de substituir
as do clipe. Bancos criados nesse período acumularam cópias exatas da mesma
observação, o que infla contagens e médias calculadas sobre essas tabelas.
Este módulo apaga essas cópias mantendo sempre a linha de menor ``id`` de cada
grupo idêntico. Só remove duplicata exata — linhas que diferem em qualquer
campo são observações distintas e são preservadas.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
@dataclass(frozen=True)
class ResultadoDaLimpeza:
"""Quantidade de linhas removidas por tabela."""
evidencias_visuais: int
cenas: int
@property
def total(self) -> int:
"""Total de linhas removidas em todas as tabelas."""
return self.evidencias_visuais + self.cenas
class LimpadorDeDuplicatas:
"""
Remove duplicatas exatas de evidências visuais e cenas.
Atributos:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
"""
def __init__(self, conexao: sqlite3.Connection) -> None:
"""
Inicializa o limpador sobre uma conexão existente.
Parâmetros:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
"""
self.conexao = conexao
def contar_duplicatas(self) -> ResultadoDaLimpeza:
"""
Conta quantas linhas seriam removidas, sem remover nada.
Retorna:
As contagens de duplicatas por tabela.
"""
return ResultadoDaLimpeza(
evidencias_visuais=self._contar(
"evidencias_visuais", ("video_id", "clipe_id", "tipo", "inicio", "fim", "valor"),
),
cenas=self._contar("cenas", ("video_id", "clipe_id", "inicio", "fim")),
)
def limpar(self) -> ResultadoDaLimpeza:
"""
Remove as duplicatas, mantendo a linha de menor ``id`` de cada grupo.
Retorna:
As contagens de linhas efetivamente removidas por tabela.
"""
with self.conexao:
evidencias = self._remover(
"evidencias_visuais", ("video_id", "clipe_id", "tipo", "inicio", "fim", "valor"),
)
cenas = self._remover("cenas", ("video_id", "clipe_id", "inicio", "fim"))
return ResultadoDaLimpeza(evidencias_visuais=evidencias, cenas=cenas)
def _contar(self, tabela: str, chave: tuple[str, ...]) -> int:
"""Conta linhas que não são a primeira ocorrência do seu grupo."""
colunas = ", ".join(chave)
return int(self.conexao.execute(
f"""SELECT count(*) FROM {tabela}
WHERE id NOT IN (SELECT min(id) FROM {tabela} GROUP BY {colunas})"""
).fetchone()[0])
def _remover(self, tabela: str, chave: tuple[str, ...]) -> int:
"""Apaga as linhas que não são a primeira ocorrência do seu grupo."""
colunas = ", ".join(chave)
cursor = self.conexao.execute(
f"""DELETE FROM {tabela}
WHERE id NOT IN (SELECT min(id) FROM {tabela} GROUP BY {colunas})"""
)
return cursor.rowcount
@@ -18,6 +18,13 @@ from ..scanner.transcricao_da_timeline import TranscricaoDoClipe
from .conexao import abrir_banco
def _numero_ou_nulo(valor: object) -> float | None:
"""Converte um valor de métrica em float, ou None quando ausente/ilegível."""
if isinstance(valor, bool) or not isinstance(valor, (int, float)):
return None
return float(valor)
class RepositorioDeAnalisesSQLite:
"""Grava metadados de arquivo, transcrição e evidências/cenas visuais."""
@@ -30,6 +37,17 @@ class RepositorioDeAnalisesSQLite:
def registrar_metadados_de_arquivo(
self, video_id: str, metadados: MetadadosDoArquivo, hash_do_conteudo: str | None = None,
) -> None:
"""
Grava os metadados técnicos de um arquivo de mídia do vídeo.
Regravar o mesmo caminho atualiza a linha existente em vez de criar
outra, então o método é seguro para reprocessamento.
Parâmetros:
video_id: Identificador do vídeo dono do arquivo.
metadados: Metadados técnicos extraídos da mídia.
hash_do_conteudo: Hash do conteúdo do arquivo, quando calculado.
"""
with self.conexao:
self._garantir_video(video_id)
self.conexao.execute(
@@ -59,6 +77,16 @@ class RepositorioDeAnalisesSQLite:
def registrar_transcricoes(
self, video_id: str, transcricoes: Iterable[TranscricaoDoClipe],
) -> None:
"""
Acrescenta transcrições sem remover as já gravadas.
Para reprocessamento use ``substituir_transcricoes``: este método
acumula, e chamá-lo duas vezes para o mesmo clipe duplica as falas.
Parâmetros:
video_id: Identificador do vídeo dono das transcrições.
transcricoes: Transcrições por clipe a gravar.
"""
with self.conexao:
self._garantir_video(video_id)
for transcricao in transcricoes:
@@ -118,15 +146,31 @@ class RepositorioDeAnalisesSQLite:
def _inserir_segmento(
self, video_id: str, clipe_id: str, segmento: SegmentoDeTranscricao,
) -> int:
"""Grava uma fala e devolve o id gerado, para as palavras se ligarem a ela.
As métricas acústicas são gravadas duas vezes de propósito: em
``caracteristicas_acusticas`` (JSON completo, para reconstrução fiel)
e em colunas dedicadas (para filtrar e ordenar por elas em SQL sem
precisar abrir o JSON).
"""
metricas = segmento.caracteristicas_acusticas or {}
cursor = self.conexao.execute(
"""INSERT INTO segmentos_de_transcricao
(video_id, clipe_id, inicio, fim, texto, confianca, falante, voz_aparente,
confianca_voz, emocao, confianca_emocao, caracteristicas_acusticas)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
confianca_voz, emocao, confianca_emocao, caracteristicas_acusticas,
energia_rms, pitch_mediano_hz, pitch_desvio_hz, velocidade_de_fala_pps,
maior_pausa_interna_s, intervalo_anterior_s)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(video_id, clipe_id, segmento.inicio, segmento.fim, segmento.texto,
segmento.confianca, segmento.falante, segmento.voz_aparente,
segmento.confianca_voz, segmento.emocao, segmento.confianca_emocao,
json.dumps(segmento.caracteristicas_acusticas, ensure_ascii=False)),
json.dumps(segmento.caracteristicas_acusticas, ensure_ascii=False),
_numero_ou_nulo(metricas.get("energy_rms")),
_numero_ou_nulo(metricas.get("pitch_hz_median")),
_numero_ou_nulo(metricas.get("pitch_hz_std")),
_numero_ou_nulo(metricas.get("speaking_rate_wps")),
_numero_ou_nulo(metricas.get("longest_internal_pause_s")),
_numero_ou_nulo(metricas.get("gap_before_s"))),
)
segmento_id = cursor.lastrowid
for ordem, palavra in enumerate(segmento.palavras):
@@ -140,6 +184,16 @@ class RepositorioDeAnalisesSQLite:
return segmento_id
def carregar_transcricoes(self, video_id: str, clipe_id: str) -> list[SegmentoDeTranscricao]:
"""
Carrega as falas de um clipe, já com as palavras de cada uma.
Parâmetros:
video_id: Identificador do vídeo dono das falas.
clipe_id: Clipe cujas falas serão lidas.
Retorna:
As falas do clipe ordenadas por início, com as palavras em ordem.
"""
segmentos: list[SegmentoDeTranscricao] = []
for linha in self.conexao.execute(
"""SELECT * FROM segmentos_de_transcricao
@@ -166,9 +220,69 @@ class RepositorioDeAnalisesSQLite:
))
return segmentos
def substituir_evidencias_visuais(
self, video_id: str, clipe_id: str, evidencias: Iterable[EvidenciaVisual],
) -> int:
"""
Regrava as evidências visuais de um clipe, apagando as anteriores.
Torna o reprocessamento idempotente. ``registrar_evidencias_visuais``
só acrescenta, então reanalisar o mesmo clipe com ele acumula cópias
da mesma evidência e infla qualquer contagem feita sobre a tabela.
Parâmetros:
video_id: Identificador do vídeo dono das evidências.
clipe_id: Clipe cujas evidências serão substituídas.
evidencias: Evidências a gravar.
Retorna:
A quantidade de evidências gravadas.
"""
evidencias_materializadas = tuple(evidencias)
with self.conexao:
self._garantir_video(video_id)
self.conexao.execute(
"DELETE FROM evidencias_visuais WHERE video_id = ? AND clipe_id = ?",
(video_id, clipe_id),
)
self.registrar_evidencias_visuais(video_id, clipe_id, evidencias_materializadas)
return len(evidencias_materializadas)
def substituir_cenas(
self, video_id: str, cenas: Iterable[Cena], clipe_id: str | None = None,
) -> int:
"""
Regrava as cenas de um clipe, apagando as anteriores.
Parâmetros:
video_id: Identificador do vídeo dono das cenas.
cenas: Cenas a gravar.
clipe_id: Clipe cujas cenas serão substituídas. Quando ``None``,
substitui as cenas do vídeo que não pertencem a clipe algum.
Retorna:
A quantidade de cenas gravadas.
"""
cenas_materializadas = tuple(cenas)
with self.conexao:
self._garantir_video(video_id)
if clipe_id is None:
self.conexao.execute(
"DELETE FROM cenas WHERE video_id = ? AND clipe_id IS NULL", (video_id,))
else:
self.conexao.execute(
"DELETE FROM cenas WHERE video_id = ? AND clipe_id = ?", (video_id, clipe_id))
self.registrar_cenas(video_id, cenas_materializadas, clipe_id)
return len(cenas_materializadas)
def registrar_evidencias_visuais(
self, video_id: str, clipe_id: str, evidencias: Iterable[EvidenciaVisual],
) -> None:
"""Acrescenta evidências visuais sem remover as já gravadas.
Para reprocessamento use ``substituir_evidencias_visuais``: este método
acumula, e chamá-lo duas vezes para o mesmo clipe duplica as linhas.
"""
with self.conexao:
self._garantir_video(video_id)
for evidencia in evidencias:
@@ -184,6 +298,16 @@ class RepositorioDeAnalisesSQLite:
def registrar_cenas(
self, video_id: str, cenas: Iterable[Cena], clipe_id: str | None = None,
) -> None:
"""Acrescenta cenas sem remover as já gravadas.
Para reprocessamento use ``substituir_cenas``: este método acumula, e
chamá-lo duas vezes para o mesmo clipe duplica as cenas.
Parâmetros:
video_id: Identificador do vídeo dono das cenas.
cenas: Cenas detectadas a gravar.
clipe_id: Clipe a que as cenas pertencem, quando houver.
"""
with self.conexao:
self._garantir_video(video_id)
for cena in cenas:
@@ -0,0 +1,293 @@
"""
Persistência dos planos de edição devolvidos pela IA e de suas aplicações.
Antes deste módulo o plano era escrito num arquivo temporário, aplicado na
timeline e descartado. Com isso o sistema não guardava nenhum registro de como
uma edição foi decidida — nem o que a IA propôs, nem se a aplicação funcionou.
Guardar plano e aplicação separadamente é deliberado: o mesmo plano pode ser
aplicado mais de uma vez (em outra sequência, ou depois de um backup), e a
proposta continua sendo a mesma. Comparar o que foi proposto com o que
sobreviveu na timeline é o que dá material para melhorar as decisões futuras.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass, field
from typing import Sequence
class ErroDePlano(ValueError):
"""Plano malformado ou parâmetros inválidos para gravá-lo."""
@dataclass(frozen=True)
class AcaoDoPlano:
"""
Uma operação proposta pela IA sobre a timeline.
Atributos:
tipo: Natureza da operação — corte, zoom, texto, marcador.
inicio: Início do trecho afetado, em segundos.
fim: Fim do trecho afetado, em segundos.
motivo: Justificativa dada pela IA, quando houver.
parametros: Campos específicos do tipo de ação.
"""
tipo: str
inicio: float | None = None
fim: float | None = None
motivo: str | None = None
parametros: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class PlanoDeEdicao:
"""
Um plano completo, como a IA o devolveu.
Atributos:
origem: Mídia ou sequência a que o plano se refere.
acoes: Operações propostas, na ordem em que devem ser aplicadas.
video_id: Vídeo do banco correspondente, quando conhecido.
tipo_de_video: Perfil editorial escolhido no painel.
modelo_da_ia: Modelo que produziu o plano.
intencao: Pedido do usuário que originou o plano.
"""
origem: str
acoes: tuple[AcaoDoPlano, ...]
video_id: str | None = None
tipo_de_video: str | None = None
modelo_da_ia: str | None = None
intencao: str | None = None
class RepositorioDePlanos:
"""
Grava e lê planos de edição e o resultado de suas aplicações.
Atributos:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
"""
def __init__(self, conexao: sqlite3.Connection) -> None:
"""
Inicializa o repositório sobre uma conexão existente.
Parâmetros:
conexao: Conexão SQLite já aberta e com o esquema aplicado.
"""
self.conexao = conexao
def registrar_plano(self, plano: PlanoDeEdicao) -> int:
"""
Grava um plano e suas ações, devolvendo o identificador criado.
Planos nunca são substituídos: cada devolução da IA é um registro
novo, porque o histórico de tentativas é justamente o que se quer
preservar.
Parâmetros:
plano: Plano a gravar.
Retorna:
O identificador do plano gravado.
Pode gerar:
ErroDePlano: quando o plano não tem origem ou não tem ação alguma.
"""
if not plano.origem or not plano.origem.strip():
raise ErroDePlano("O plano precisa indicar a origem (mídia ou sequência).")
if not plano.acoes:
raise ErroDePlano("Um plano sem ações não é gravável.")
with self.conexao:
if plano.video_id:
self.conexao.execute(
"INSERT OR IGNORE INTO videos (id) VALUES (?)", (plano.video_id,))
cursor = self.conexao.execute(
"""INSERT INTO planos_de_edicao
(video_id, origem, tipo_de_video, modelo_da_ia, intencao)
VALUES (?, ?, ?, ?, ?)""",
(plano.video_id, plano.origem, plano.tipo_de_video,
plano.modelo_da_ia, plano.intencao),
)
plano_id = int(cursor.lastrowid)
self.conexao.executemany(
"""INSERT INTO acoes_do_plano
(plano_id, ordem, tipo, inicio, fim, motivo, parametros)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
[(plano_id, ordem, acao.tipo, acao.inicio, acao.fim, acao.motivo,
json.dumps(acao.parametros, ensure_ascii=False))
for ordem, acao in enumerate(plano.acoes)],
)
return plano_id
def registrar_aplicacao(
self,
plano_id: int,
sucesso: bool,
acoes_aplicadas: int = 0,
sequencia: str | None = None,
mensagem: str | None = None,
) -> int:
"""
Grava o resultado de aplicar um plano na timeline.
A falha é gravada tanto quanto o sucesso: saber que um plano não pôde
ser aplicado, e por quê, é informação de diagnóstico que hoje se perde.
Parâmetros:
plano_id: Plano que foi aplicado.
sucesso: Se a aplicação terminou sem erro.
acoes_aplicadas: Quantas ações efetivamente entraram na timeline.
sequencia: Nome da sequência onde foi aplicado.
mensagem: Erro ou observação da aplicação.
Retorna:
O identificador da aplicação registrada.
Pode gerar:
ErroDePlano: quando o plano informado não existe.
"""
existe = self.conexao.execute(
"SELECT 1 FROM planos_de_edicao WHERE id = ?", (plano_id,)).fetchone()
if existe is None:
raise ErroDePlano(f"Plano {plano_id} não existe.")
with self.conexao:
cursor = self.conexao.execute(
"""INSERT INTO aplicacoes_do_plano
(plano_id, sequencia, sucesso, acoes_aplicadas, mensagem)
VALUES (?, ?, ?, ?, ?)""",
(plano_id, sequencia, 1 if sucesso else 0, acoes_aplicadas, mensagem),
)
return int(cursor.lastrowid)
def carregar_plano(self, plano_id: int) -> PlanoDeEdicao | None:
"""
Lê um plano gravado, com suas ações em ordem.
Parâmetros:
plano_id: Plano a carregar.
Retorna:
O plano, ou None quando o identificador não existe.
"""
cabecalho = self.conexao.execute(
"SELECT * FROM planos_de_edicao WHERE id = ?", (plano_id,)).fetchone()
if cabecalho is None:
return None
acoes = tuple(
AcaoDoPlano(
tipo=linha["tipo"], inicio=linha["inicio"], fim=linha["fim"],
motivo=linha["motivo"],
parametros=json.loads(linha["parametros"] or "{}"),
)
for linha in self.conexao.execute(
"SELECT * FROM acoes_do_plano WHERE plano_id = ? ORDER BY ordem",
(plano_id,),
)
)
return PlanoDeEdicao(
origem=cabecalho["origem"], acoes=acoes, video_id=cabecalho["video_id"],
tipo_de_video=cabecalho["tipo_de_video"],
modelo_da_ia=cabecalho["modelo_da_ia"], intencao=cabecalho["intencao"],
)
def listar_planos_do_video(self, video_id: str) -> list[dict[str, object]]:
"""
Lista o histórico de planos de um vídeo, do mais recente ao mais antigo.
Parâmetros:
video_id: Vídeo cujos planos serão listados.
Retorna:
Um resumo por plano, com contagem de ações e de aplicações.
"""
return [
dict(linha) for linha in self.conexao.execute(
"""SELECT p.id, p.criado_em, p.tipo_de_video, p.modelo_da_ia,
(SELECT count(*) FROM acoes_do_plano a WHERE a.plano_id = p.id)
AS total_de_acoes,
(SELECT count(*) FROM aplicacoes_do_plano ap
WHERE ap.plano_id = p.id AND ap.sucesso = 1)
AS aplicacoes_com_sucesso
FROM planos_de_edicao p
WHERE p.video_id = ?
ORDER BY p.criado_em DESC""",
(video_id,),
)
]
def plano_de_dict(
dados: dict,
video_id: str | None = None,
tipo_de_video: str | None = None,
modelo_da_ia: str | None = None,
intencao: str | None = None,
) -> PlanoDeEdicao:
"""
Converte o JSON devolvido pela IA no plano gravável.
Aceita o mesmo formato que o painel já valida — ``source`` mais uma lista
``actions`` com ``kind``, ``start`` e ``end`` — para que gravar o plano não
exija mudar o contrato com a IA.
Parâmetros:
dados: JSON do plano, já desserializado.
video_id: Vídeo do banco correspondente, quando conhecido.
tipo_de_video: Perfil editorial escolhido no painel.
modelo_da_ia: Modelo que produziu o plano.
intencao: Pedido do usuário que originou o plano.
Retorna:
O plano pronto para ``RepositorioDePlanos.registrar_plano``.
Pode gerar:
ErroDePlano: quando falta ``source`` ou ``actions`` não é uma lista
não vazia.
"""
origem = dados.get("source")
acoes_brutas = dados.get("actions")
if not isinstance(origem, str) or not origem.strip():
raise ErroDePlano("O plano precisa ter 'source'.")
if not isinstance(acoes_brutas, list) or not acoes_brutas:
raise ErroDePlano("O plano precisa ter uma lista 'actions' não vazia.")
conhecidos = {"kind", "start", "end", "reason", "motivo"}
acoes: list[AcaoDoPlano] = []
for indice, bruta in enumerate(acoes_brutas):
if not isinstance(bruta, dict) or not bruta.get("kind"):
raise ErroDePlano(f"A ação {indice} não tem 'kind'.")
acoes.append(AcaoDoPlano(
tipo=str(bruta["kind"]),
inicio=_numero(bruta.get("start")),
fim=_numero(bruta.get("end")),
motivo=bruta.get("reason") or bruta.get("motivo"),
parametros={chave: valor for chave, valor in bruta.items()
if chave not in conhecidos},
))
return PlanoDeEdicao(
origem=origem, acoes=tuple(acoes), video_id=video_id,
tipo_de_video=tipo_de_video, modelo_da_ia=modelo_da_ia, intencao=intencao,
)
def _numero(valor: object) -> float | None:
"""Converte um valor em float quando ele for numérico, senão devolve None."""
if isinstance(valor, bool) or not isinstance(valor, (int, float)):
return None
return float(valor)
__all__ = [
"AcaoDoPlano",
"ErroDePlano",
"PlanoDeEdicao",
"RepositorioDePlanos",
"plano_de_dict",
]
@@ -13,7 +13,13 @@ import sqlite3
from pathlib import Path
from typing import Iterable
from ..scanner.retakes.modelos_de_retakes import EvidenciaDeRetake, GrupoDeRetake, TomadaDeRetake
from ..scanner.retakes.modelos_de_retakes import (
STATUS_DE_REVISAO_VALIDOS,
EvidenciaDeRetake,
GrupoDeRetake,
TomadaDeRetake,
)
from ..scanner.retakes.repositorio_de_retakes import GrupoDeRetakeInexistente
from .conexao import abrir_banco
VERSAO_REGRA_FALAS = 1
@@ -70,10 +76,10 @@ class RepositorioDeRetakesSQLite:
for grupo in grupos:
self.conexao.execute(
"""INSERT INTO grupos_de_retake
(id, video_id, faixa_id, tipo, confianca, criado_em)
VALUES (?, ?, ?, ?, ?, ?)""",
(id, video_id, faixa_id, tipo, confianca, criado_em, status_de_revisao)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(grupo.id, grupo.video_id, grupo.faixa_id, grupo.tipo,
grupo.confianca, grupo.criado_em),
grupo.confianca, grupo.criado_em, grupo.status_de_revisao),
)
for tomada in grupo.tomadas:
self.conexao.execute(
@@ -143,5 +149,46 @@ class RepositorioDeRetakesSQLite:
tomadas=tomadas,
evidencias=evidencias,
criado_em=linha["criado_em"],
status_de_revisao=linha["status_de_revisao"],
))
return grupos
def atualizar_status_de_revisao(
self, video_id: str, grupo_id: str, novo_status: str,
) -> GrupoDeRetake:
"""
Registra a decisão manual do usuário sobre um grupo de retakes.
A revisão não altera a detecção automática: grava apenas o estado
escolhido, preservando os demais grupos do vídeo.
Parâmetros:
video_id: Vídeo dono do grupo revisado.
grupo_id: Grupo que recebeu a decisão.
novo_status: Estado escolhido pelo usuário.
Retorna:
O grupo já com o novo status.
Pode gerar:
ValueError: quando o status não é um dos válidos.
GrupoDeRetakeInexistente: quando o grupo não existe no vídeo.
"""
if novo_status not in STATUS_DE_REVISAO_VALIDOS:
validos = ", ".join(sorted(STATUS_DE_REVISAO_VALIDOS))
raise ValueError(
f"Status de revisão inválido: {novo_status!r}. Válidos: {validos}."
)
with self.conexao:
cursor = self.conexao.execute(
"""UPDATE grupos_de_retake SET status_de_revisao = ?
WHERE video_id = ? AND id = ?""",
(novo_status, video_id, grupo_id),
)
if cursor.rowcount == 0:
raise GrupoDeRetakeInexistente(video_id, grupo_id)
grupo = next(
(item for item in self.carregar(video_id) if item.id == grupo_id), None)
if grupo is None:
raise GrupoDeRetakeInexistente(video_id, grupo_id)
return grupo