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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user