feat: criado repositório jhonny-editor no Gitea
criado repositório jhonny-editor no Gitea adicionado script admin/deploy.command com commit automático atualizado admin/DEV-NOTES.md com template limpo Resumo: - 48 arquivos alterados - 26 novos - 19 modificados - 3 removidos 22 files changed, 658 insertions(+), 568 deletions(-) Arquivos: - AGENTS.md - admin/DEV-NOTES.md - admin/OpenCut.command - admin/commit.command - admin/deploy.command - admin/update.command - code/cep-plugin/index.html - code/cep-plugin/main.js - code/cep-plugin/styles.css - code/engine/ARQUITETURA.md - code/engine/arquitetura/README.md - code/engine/gerar_relatorio_timeline.py - code/engine/integracoes/apple_speech/apple_speech_transcriber.swift - code/engine/integracoes/apple_speech/provider_de_transcricao_apple.py - code/engine/integracoes/midia/__init__.py - code/engine/integracoes/whisper/provider_de_transcricao_local.py - code/engine/scanner/__init__.py - code/engine/scanner/coordenacao/__init__.py - code/engine/scanner/descoberta/__init__.py - code/engine/scanner/modelos.py - code/engine/scanner/transcricao_da_timeline.py - code/src/tools/discovery.ts - :memory:.ses - admin/Jhonny.command - admin/inativos/OpenCut.command - admin/inativos/update.command - code/docs/glossario-analise-emocional.md - code/docs/levantamento-apple-vision-e-apple-intelligence.md - code/docs/levantamento-ferramentas-analise-visual-local.md - code/engine/arquitetura/biblioteca-inteligente-de-videos.md - code/engine/executar_scanner.py - code/engine/integracoes/huggingface/ - code/engine/integracoes/midia/extracao_de_metadados.py - code/engine/integracoes/visual/ - code/engine/requirements-visual.txt - code/engine/scanner/configuracao_visual.py - code/engine/scanner/descoberta/descoberta_de_arquivos.py - code/engine/scanner/metadados.py - code/engine/scanner/relatorio_visual.py - code/engine/scanner/retakes/ - code/engine/scanner/visual.py - code/engine/testes/test_analise_visual_local.py - code/engine/testes/test_arquivos_e_metadados.py - code/engine/testes/test_provider_de_transcricao_apple.py - code/relatorios/analise-brools/ - code/relatorios/analise-visual/ - code/relatorios/audio/arquivos/ - code/relatorios/transcricao-timeline.md
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Converte a transcrição existente em falas ordenadas para a análise.
|
||||
|
||||
O módulo de retakes não realiza transcrição. Ele recebe a saída do
|
||||
``TranscricaoDaTimeline`` (lista de ``TranscricaoDoClipe``) e a converte
|
||||
em ``Fala``s ordenadas ao longo da timeline, preservando o vínculo com o
|
||||
segmento/clipe de origem e as palavras alinhadas quando disponíveis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from ..transcricao_da_timeline import TranscricaoDoClipe
|
||||
from .modelos_de_retakes import Fala
|
||||
|
||||
# Sinais de erro mais comuns vindos dos providers de transcrição.
|
||||
_PALAVRAS_DE_ERRO = {"", "...", "…"}
|
||||
|
||||
_VOYELS = "aeiouáéíóúâêôãõàèìòù"
|
||||
_CONSOANTES = "bcdfghjklmnpqrstvwxyz"
|
||||
_PADRAO_SILABA = re.compile(
|
||||
rf"([{_VOYELS}][^aeiouáéíóúâêôãõàèìòù]*)|([{_CONSOANTES}]+[aeiouáéíóúâêôãõàèìòù]?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalizar(texto: str) -> str:
|
||||
"""Minúsculas, sem pontuação e sem espaços duplicados."""
|
||||
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
|
||||
return " ".join(sem_pontuacao.lower().split())
|
||||
|
||||
|
||||
def _prefixo_comum(a: list[str], b: list[str]) -> int:
|
||||
n = 0
|
||||
for x, y in zip(a, b):
|
||||
if x != y:
|
||||
break
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _sucesso_de_silabas(a: list[str], b: list[str]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
silabas_a = [_PADRAO_SILABA.findall(p)[0][0] for p in a]
|
||||
silabas_b = [_PADRAO_SILABA.findall(p)[0][0] for p in b]
|
||||
n = _prefixo_comum(silabas_a, silabas_b)
|
||||
return n / max(len(silabas_a), len(silabas_b))
|
||||
|
||||
|
||||
def _e_ruido(texto: str) -> bool:
|
||||
texto_limpo = _normalizar(texto)
|
||||
if texto_limpo in _PALAVRAS_DE_ERRO:
|
||||
return True
|
||||
# Fala em branco ou "vazia" por provedor.
|
||||
return not texto_limpo
|
||||
|
||||
|
||||
class AdaptadorDeFalas:
|
||||
"""Transforma a transcrição da timeline em falas prontas para análise.
|
||||
|
||||
Recebe uma coleção de ``TranscricaoDoClipe`` (uma por faixa de áudio do
|
||||
vídeo) e devolve as falas ordenadas por tempo. O ``video_id`` é um rótulo
|
||||
informado pelo chamador; quando ausente, usa o identificador da timeline.
|
||||
"""
|
||||
|
||||
def __init__(self, video_id: str | None = None, faixa_id: str | None = None) -> None:
|
||||
self.video_id = video_id
|
||||
self.faixa_id = faixa_id
|
||||
|
||||
def converter(
|
||||
self,
|
||||
transcricoes: Iterable[TranscricaoDoClipe],
|
||||
video_id: str | None = None,
|
||||
faixa_id: str | None = None,
|
||||
) -> list[Fala]:
|
||||
video_id = video_id or self.video_id or "video"
|
||||
faixa_id = faixa_id or self.faixa_id or "faixa"
|
||||
|
||||
falas: list[Fala] = []
|
||||
for transcricao in transcricoes:
|
||||
segmento_id = transcricao.identificador_do_clipe
|
||||
for segmento in transcricao.segmentos:
|
||||
if _e_ruido(segmento.texto):
|
||||
continue
|
||||
falas.append(Fala(
|
||||
id=f"{segmento_id}:{segmento.inicio:.3f}",
|
||||
video_id=video_id,
|
||||
faixa_id=faixa_id,
|
||||
segmento_id=segmento_id,
|
||||
ordem=-1, # preenchida após a ordenação
|
||||
inicio=segmento.inicio,
|
||||
fim=segmento.fim,
|
||||
texto=segmento.texto,
|
||||
texto_normalizado=_normalizar(segmento.texto),
|
||||
palavras=segmento.palavras,
|
||||
falante=segmento.falante,
|
||||
))
|
||||
|
||||
falas.sort(key=lambda item: (item.inicio, item.fim))
|
||||
for indice, fala in enumerate(falas):
|
||||
falas[indice] = Fala(
|
||||
id=fala.id,
|
||||
video_id=fala.video_id,
|
||||
faixa_id=fala.faixa_id,
|
||||
segmento_id=fala.segmento_id,
|
||||
ordem=indice,
|
||||
inicio=fala.inicio,
|
||||
fim=fala.fim,
|
||||
texto=fala.texto,
|
||||
texto_normalizado=fala.texto_normalizado,
|
||||
palavras=fala.palavras,
|
||||
falante=fala.falante,
|
||||
)
|
||||
return falas
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Agrupamento de falas que representam retakes da mesma fala ou ideia.
|
||||
|
||||
Trabalha com uma janela temporal: compara cada fala com as ``n`` seguintes
|
||||
(e só as da mesma faixa de áudio), evitando agrupar frases iguais que
|
||||
aparecem em partes muito distantes do vídeo. Não decide a classificação —
|
||||
apenas forma grupos candidatos e reúne as métricas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from .analisador_de_intervalos import AnalisadorDeIntervalos
|
||||
from .comparador_de_falas import ComparadorDeFalas
|
||||
from .comparador_semantico import ComparadorSemantico
|
||||
from .detector_de_reinicios import DetectorDeReinicios
|
||||
from .modelos_de_retakes import (
|
||||
AgrupamentoDeFalas,
|
||||
Fala,
|
||||
ParAgrupado,
|
||||
ResultadoDoIntervalo,
|
||||
SinalDeReinicio,
|
||||
)
|
||||
|
||||
# Limiares de candidatura de um par a pertencer a um grupo de retake.
|
||||
_SIMILARIDADE_MINIMA_CANDIDATO = 0.55
|
||||
_SIMILARIDADE_FORTE = 0.75
|
||||
_PROXIMIDADE_AUXILIAR = 0.60
|
||||
|
||||
# Janela padrão de falas a serem comparadas com cada uma.
|
||||
_JANELA_PADRAO = 5
|
||||
|
||||
# Região de dúvida que dispara a comparação semântica.
|
||||
_ZONA_DE_DUVIDA_INFERIOR = 0.60
|
||||
_ZONA_DE_DUVIDA_SUPERIOR = 0.85
|
||||
|
||||
|
||||
class AgrupadorDeTakes:
|
||||
"""Gera grupos candidatos conectando falas semelhantes e próximas."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comparador: ComparadorDeFalas,
|
||||
comparador_semantico: ComparadorSemantico,
|
||||
analisador_de_intervalos: AnalisadorDeIntervalos,
|
||||
detector_de_reinicios: DetectorDeReinicios,
|
||||
janela: int = _JANELA_PADRAO,
|
||||
) -> None:
|
||||
self.comparador = comparador
|
||||
self.comparador_semantico = comparador_semantico
|
||||
self.analisador_de_intervalos = analisador_de_intervalos
|
||||
self.detector_de_reinicios = detector_de_reinicios
|
||||
self.janela = janela
|
||||
|
||||
def agrupar(self, falas: Iterable[Fala]) -> list[AgrupamentoDeFalas]:
|
||||
falas = sorted(falas, key=lambda item: (item.ordem, item.inicio))
|
||||
sinais = {sinal.fala_id: sinal for sinal in self.detector_de_reinicios.detectar(falas)}
|
||||
arestas: list[tuple[int, int, ParAgrupado]] = []
|
||||
|
||||
for indice, fala_a in enumerate(falas):
|
||||
limite = min(len(falas), indice + 1 + self.janela)
|
||||
for jindice in range(indice + 1, limite):
|
||||
fala_b = falas[jindice]
|
||||
if fala_a.faixa_id != fala_b.faixa_id:
|
||||
continue
|
||||
par = self._avaliar_par(fala_a, fala_b, sinais)
|
||||
if par is not None:
|
||||
arestas.append((indice, jindice, par))
|
||||
|
||||
return self._agrupar_por_arestas(falas, arestas, sinais)
|
||||
|
||||
def _avaliar_par(
|
||||
self,
|
||||
fala_a: Fala,
|
||||
fala_b: Fala,
|
||||
sinais: dict[str, SinalDeReinicio],
|
||||
) -> ParAgrupado | None:
|
||||
comparacao = self.comparador.comparar(fala_a, fala_b)
|
||||
intervalo = self.analisador_de_intervalos.analisar(fala_a, fala_b)
|
||||
|
||||
tem_reinicio = sinais.get(fala_b.id) is not None
|
||||
semantica = 0.0
|
||||
|
||||
# Comparação semântica só na zona de dúvida, para evitar custo.
|
||||
texto = comparacao.similaridade_final
|
||||
if _ZONA_DE_DUVIDA_INFERIOR <= texto <= _ZONA_DE_DUVIDA_SUPERIOR:
|
||||
semantica = self.comparador_semantico.comparar(fala_a, fala_b)
|
||||
|
||||
melhor = max(comparacao.similaridade_final, semantica)
|
||||
candidato = (
|
||||
melhor >= _SIMILARIDADE_FORTE
|
||||
or (melhor >= _SIMILARIDADE_MINIMA_CANDIDATO
|
||||
and intervalo.proximidade_temporal >= _PROXIMIDADE_AUXILIAR
|
||||
and (tem_reinicio or intervalo.indicio_de_nova_tentativa))
|
||||
)
|
||||
if candidato:
|
||||
return ParAgrupado(fala_a, fala_b, comparacao, intervalo,
|
||||
round(semantica, 4))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _agrupar_por_arestas(
|
||||
falas: list[Fala],
|
||||
arestas: list[tuple[int, int, ParAgrupado]],
|
||||
sinais: dict[str, SinalDeReinicio],
|
||||
) -> list[AgrupamentoDeFalas]:
|
||||
pai = list(range(len(falas)))
|
||||
|
||||
def encontrar(x: int) -> int:
|
||||
while pai[x] != x:
|
||||
pai[x] = pai[pai[x]]
|
||||
x = pai[x]
|
||||
return x
|
||||
|
||||
arestas.sort(key=lambda item: (item[0], item[1]))
|
||||
for a, b, _ in arestas:
|
||||
raiz_a, raiz_b = encontrar(a), encontrar(b)
|
||||
if raiz_a != raiz_b:
|
||||
pai[raiz_b] = raiz_a
|
||||
|
||||
componentes: dict[int, list[int]] = {}
|
||||
for indice in range(len(falas)):
|
||||
componentes.setdefault(encontrar(indice), []).append(indice)
|
||||
|
||||
grupos: list[AgrupamentoDeFalas] = []
|
||||
for inds in componentes.values():
|
||||
if len(inds) < 2:
|
||||
continue
|
||||
inds.sort()
|
||||
fala_ids = tuple(falas[indice].id for indice in inds)
|
||||
pares_por_chave: dict[tuple[str, str], ParAgrupado] = {
|
||||
(par.fala_a.id, par.fala_b.id): par
|
||||
for _, _, par in arestas
|
||||
if par.fala_a.id in fala_ids and par.fala_b.id in fala_ids
|
||||
}
|
||||
pares: list[ParAgrupado] = []
|
||||
for a, b in zip(inds, inds[1:]):
|
||||
chave = (falas[a].id, falas[b].id)
|
||||
par = pares_por_chave.get(chave)
|
||||
if par is None:
|
||||
par = pares_por_chave.get((falas[b].id, falas[a].id))
|
||||
if par is not None:
|
||||
pares.append(par)
|
||||
sinais_do_grupo = tuple(
|
||||
sinais[fala_id] for fala_id in fala_ids if fala_id in sinais)
|
||||
if pares:
|
||||
grupos.append(AgrupamentoDeFalas(fala_ids, tuple(pares), sinais_do_grupo))
|
||||
return grupos
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Análise da relação temporal entre falas.
|
||||
|
||||
Verifica quanto tempo há entre duas falas, se estão na mesma faixa/segmento
|
||||
e se a proximidade sugere uma nova tentativa. Um intervalo muito grande
|
||||
reduz a confiança de que seja um retake imediato.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .modelos_de_retakes import Fala, ResultadoDoIntervalo
|
||||
|
||||
# A partir de quantos segundos o intervalo passa a não sugerir retake.
|
||||
_INTERVALO_MAXIMO = 12.0
|
||||
|
||||
|
||||
class AnalisadorDeIntervalos:
|
||||
"""Calcula a proximidade temporal e o indício de nova tentativa."""
|
||||
|
||||
def __init__(self, intervalo_maximo: float = _INTERVALO_MAXIMO) -> None:
|
||||
self.intervalo_maximo = intervalo_maximo
|
||||
|
||||
def analisar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDoIntervalo:
|
||||
intervalo = max(0.0, fala_b.inicio - fala_a.fim)
|
||||
mesma_faixa = fala_a.faixa_id == fala_b.faixa_id
|
||||
mesmo_segmento = mesma_faixa and fala_a.segmento_id == fala_b.segmento_id
|
||||
|
||||
if intervalo >= self.intervalo_maximo:
|
||||
proximidade = 0.0
|
||||
elif intervalo <= 0:
|
||||
proximidade = 1.0
|
||||
else:
|
||||
# Decai suavemente com o intervalo: 1.0 → ~0 em 12s.
|
||||
proximidade = max(0.0, 1.0 - intervalo / self.intervalo_maximo)
|
||||
|
||||
indicio = mesma_faixa and intervalo <= self.intervalo_maximo and proximidade >= 0.5
|
||||
return ResultadoDoIntervalo(
|
||||
intervalo_em_segundos=round(intervalo, 3),
|
||||
mesmo_segmento=mesmo_segmento,
|
||||
proximidade_temporal=round(proximidade, 4),
|
||||
indicio_de_nova_tentativa=bool(indicio),
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Comparação textual entre falas.
|
||||
|
||||
Responsável pelos algoritmos de similaridade: Jaccard (conjunto de
|
||||
palavras), sequência (ordem das palavras via maior subsequência comum) e
|
||||
similaridade do início/fim da frase. Não decide nada sozinho — apenas
|
||||
produz métricas para o classificador.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .modelos_de_retakes import Fala, ResultadoDaComparacao
|
||||
|
||||
|
||||
def _normalizar(texto: str) -> str:
|
||||
"""Minúsculas, sem pontuação e sem espaços duplicados."""
|
||||
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
|
||||
return " ".join(sem_pontuacao.lower().split())
|
||||
|
||||
|
||||
def _lcs(a: list[str], b: list[str]) -> int:
|
||||
"""Tamanho da maior subsequência comum preservando a ordem."""
|
||||
anterior = [0] * (len(b) + 1)
|
||||
for palavra_a in a:
|
||||
atual = [0] * (len(b) + 1)
|
||||
for j, palavra_b in enumerate(b):
|
||||
if palavra_a == palavra_b:
|
||||
atual[j + 1] = anterior[j] + 1
|
||||
else:
|
||||
atual[j + 1] = max(atual[j], anterior[j + 1])
|
||||
anterior = atual
|
||||
return anterior[-1]
|
||||
|
||||
|
||||
class ComparadorDeFalas:
|
||||
"""Compara duas falas e calcula as métricas de similaridade textual."""
|
||||
|
||||
@staticmethod
|
||||
def normalizar(texto: str) -> str:
|
||||
return _normalizar(texto)
|
||||
|
||||
@staticmethod
|
||||
def _palavras_de(fala: Fala) -> list[str]:
|
||||
return fala.texto_normalizado.split() or _normalizar(fala.texto).split()
|
||||
|
||||
def comparar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDaComparacao:
|
||||
palavras_a = self._palavras_de(fala_a)
|
||||
palavras_b = self._palavras_de(fala_b)
|
||||
|
||||
if not palavras_a or not palavras_b:
|
||||
return ResultadoDaComparacao(fala_a.id, fala_b.id)
|
||||
|
||||
# 1. Jaccard — conjunto de palavras (rápido, ótimo candidato).
|
||||
conjunto_a = set(palavras_a)
|
||||
conjunto_b = set(palavras_b)
|
||||
intersecao = len(conjunto_a & conjunto_b)
|
||||
uniao = len(conjunto_a | conjunto_b)
|
||||
jaccard = intersecao / uniao if uniao else 0.0
|
||||
|
||||
# 2. Sequência — ordem das palavras via LCS normalizada.
|
||||
lcs = _lcs(palavras_a, palavras_b)
|
||||
sequencia = lcs / max(len(palavras_a), len(palavras_b))
|
||||
|
||||
# 3. Início e final da frase.
|
||||
inicio = self._similaridade_de_prefijo(palavras_a, palavras_b)
|
||||
final = self._similaridade_de_sufixo(palavras_a, palavras_b)
|
||||
|
||||
# 4. Similaridade final ponderada.
|
||||
fim = 0.45 * sequencia + 0.30 * jaccard + 0.15 * inicio + 0.10 * final
|
||||
return ResultadoDaComparacao(
|
||||
fala_a_id=fala_a.id,
|
||||
fala_b_id=fala_b.id,
|
||||
similaridade_jaccard=round(jaccard, 4),
|
||||
similaridade_de_sequencia=round(sequencia, 4),
|
||||
similaridade_do_inicio=round(inicio, 4),
|
||||
similaridade_do_final=round(final, 4),
|
||||
similaridade_final=round(min(1.0, fim), 4),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _similaridade_de_prefijo(a: list[str], b: list[str]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
n = 0
|
||||
for x, y in zip(a, b):
|
||||
if x != y:
|
||||
break
|
||||
n += 1
|
||||
return n / max(len(a), len(b))
|
||||
|
||||
@staticmethod
|
||||
def _similaridade_de_sufixo(a: list[str], b: list[str]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
n = 0
|
||||
for x, y in zip(reversed(a), reversed(b)):
|
||||
if x != y:
|
||||
break
|
||||
n += 1
|
||||
return n / max(len(a), len(b))
|
||||
|
||||
@staticmethod
|
||||
def prefixo_comum_em_palavras(texto_a: str, texto_b: str) -> int:
|
||||
a = _normalizar(texto_a).split()
|
||||
b = _normalizar(texto_b).split()
|
||||
n = 0
|
||||
for x, y in zip(a, b):
|
||||
if x != y:
|
||||
break
|
||||
n += 1
|
||||
return n
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Comparação semântica entre falas (opcional).
|
||||
|
||||
A similaridade textual não cobre casos em que as palavras são diferentes
|
||||
mas a ideia é a mesma. Este módulo define um protocolo para providers de
|
||||
embeddings e uma implementação local (via Hugging Face Transformers), além
|
||||
de um fallback puramente lexical usado quando nenhum provider está
|
||||
disponível.
|
||||
|
||||
O `ComparadorSemantico` nunca decide sozinho que há retake — apenas
|
||||
fornece uma métrica adicional para o classificador.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Protocol
|
||||
|
||||
from .modelos_de_retakes import Fala
|
||||
|
||||
|
||||
class ProviderDeSimilaridadeSemantica(Protocol):
|
||||
"""Contrato para quem calcula similaridade semântica entre dois textos."""
|
||||
|
||||
def similaridade(self, texto_a: str, texto_b: str) -> float: ...
|
||||
|
||||
|
||||
class ComparadorLexicalSemantico:
|
||||
"""Fallback puramente lexical para quando não há embeddings.
|
||||
|
||||
Reconstrói uma "similaridade semântica" a partir de palavras que
|
||||
compartilham a mesma raiz (stemming simples por prefixo comum) e de
|
||||
sinônimos frequentes em pt-PT. Destina-se a permitir executar a análise
|
||||
sem carregar modelos pesados.
|
||||
"""
|
||||
|
||||
_PARES_SINONIMOS = (
|
||||
({"mostrar", "explicar", "demonstrar", "apresentar"},),
|
||||
({"sistema", "programa", "aplicativo", "software"},),
|
||||
({"configurar", "configuracao", "instalar", "ajustar"},),
|
||||
)
|
||||
|
||||
def similaridade(self, texto_a: str, texto_b: str) -> float:
|
||||
palavras_a = {p for p in self._palavras(texto_a)}
|
||||
palavras_b = {p for p in self._palavras(texto_b)}
|
||||
if not palavras_a or not palavras_b:
|
||||
return 0.0
|
||||
|
||||
intersecao = 0.0
|
||||
for palavra_a in palavras_a:
|
||||
for palavra_b in palavras_b:
|
||||
if palavra_a == palavra_b:
|
||||
intersecao += 1.0
|
||||
elif self._mesma_raiz(palavra_a, palavra_b):
|
||||
intersecao += 0.7
|
||||
elif self._mesmo_sinonimo(palavra_a, palavra_b):
|
||||
intersecao += 0.6
|
||||
tam = max(len(palavras_a), len(palavras_b))
|
||||
return round(min(1.0, intersecao / tam), 4)
|
||||
|
||||
@staticmethod
|
||||
def _palavras(texto: str) -> list[str]:
|
||||
return [p for p in texto.lower().split()]
|
||||
|
||||
@staticmethod
|
||||
def _mesma_raiz(a: str, b: str) -> bool:
|
||||
raiz = min(len(a), len(b), 4)
|
||||
return raiz >= 4 and a[:raiz] == b[:raiz]
|
||||
|
||||
@staticmethod
|
||||
def _mesmo_sinonimo(a: str, b: str) -> bool:
|
||||
return any(a in grupo and b in grupo for grupo in ComparadorLexicalSemantico._PARES_SINONIMOS)
|
||||
|
||||
|
||||
class ComparadorSemantico:
|
||||
"""Calcula similaridade semântica usando um provider injetável.
|
||||
|
||||
Quando o provider é ``None``, cai no ``ComparadorLexicalSemantico``
|
||||
para não bloquear a análise na ausência de modelos locais.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: ProviderDeSimilaridadeSemantica | None = None) -> None:
|
||||
self.provider = provider or ComparadorLexicalSemantico()
|
||||
|
||||
def comparar(self, fala_a: Fala, fala_b: Fala) -> float:
|
||||
return float(self.provider.similaridade(fala_a.texto, fala_b.texto))
|
||||
|
||||
|
||||
class ProviderDeSimilaridadeHuggingFace:
|
||||
"""Embeds os textos localmente com um modelo de embeddings.
|
||||
|
||||
O modelo é carregado de forma preguiçosa para não custar nada até ser
|
||||
de fato necessário (zona de dúvida do classificador).
|
||||
"""
|
||||
|
||||
def __init__(self, modelo: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") -> None:
|
||||
self.modelo = modelo
|
||||
self._encoder = None
|
||||
|
||||
def similaridade(self, texto_a: str, texto_b: str) -> float:
|
||||
if self._encoder is None:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
self._encoder = SentenceTransformer(self.modelo)
|
||||
embeddings = self._encoder.encode([texto_a, texto_b], normalize_embeddings=True)
|
||||
return float(_cosseno(embeddings[0], embeddings[1]))
|
||||
|
||||
|
||||
def _cosseno(a: list[float], b: list[float]) -> float:
|
||||
produto = sum(x * y for x, y in zip(a, b))
|
||||
norma_a = math.sqrt(sum(x * x for x in a))
|
||||
norma_b = math.sqrt(sum(y * y for y in b))
|
||||
if norma_a == 0 or norma_b == 0:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, produto / (norma_a * norma_b)))
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Detecção de sinais de reinício/recomeço de fala.
|
||||
|
||||
Analisa padrões de que a pessoa começou novamente uma ideia: repetição do
|
||||
início de uma frase, pausas longas, marcadores explícitos ("não", "pera",
|
||||
"vamos de novo", "desculpa") e repetição de palavras consecutivas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from .comparador_de_falas import ComparadorDeFalas
|
||||
from .modelos_de_retakes import Fala, SinalDeReinicio
|
||||
|
||||
_MARCADORES = (
|
||||
"não", "pera", "peraí", "pera ai", "espera", "vamos de novo", "de novo",
|
||||
"vamos recomeçar", "recomeçar", "desculpa", "desculpe", "deixa eu ver",
|
||||
"vou repetir", "esquece", "hmm", "hm", "uhm", "tá", "ta",
|
||||
)
|
||||
|
||||
_RE_MARCADOR = re.compile(
|
||||
r"\b(?:" + "|".join(re.escape(m) for m in _MARCADORES) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PAUSA_LONGA = 1.2 # segundos a partir dos quais a pausa sugere recomeço.
|
||||
|
||||
|
||||
class DetectorDeReinicios:
|
||||
"""Encontra ``SinalDeReinicio`` em cada fala em relação à anterior."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comparador: ComparadorDeFalas | None = None,
|
||||
pausa_longa: float = _PAUSA_LONGA,
|
||||
) -> None:
|
||||
self.comparador = comparador or ComparadorDeFalas()
|
||||
self.pausa_longa = pausa_longa
|
||||
|
||||
def detectar(self, falas: Iterable[Fala]) -> list[SinalDeReinicio]:
|
||||
falas = list(falas)
|
||||
sinais: list[SinalDeReinicio] = []
|
||||
for indice, fala in enumerate(falas):
|
||||
sinal = self._analisar(fala, falas[indice - 1] if indice > 0 else None)
|
||||
if sinal:
|
||||
sinais.append(sinal)
|
||||
return sinais
|
||||
|
||||
def _analisar(self, fala: Fala, anterior: Fala | None) -> SinalDeReinicio | None:
|
||||
evidencias: list[str] = []
|
||||
intensidades: list[float] = []
|
||||
palavras = fala.texto_normalizado.split()
|
||||
|
||||
# 1. Marcadores explícitos de recomeço no início.
|
||||
if palavras and _RE_MARCADOR.match(palavras[0]):
|
||||
evidencias.append("marcador explícito de recomeço")
|
||||
intensidades.append(0.9)
|
||||
|
||||
if anterior is None:
|
||||
if intensidades:
|
||||
return SinalDeReinicio(fala.id, "reinicio_explicito",
|
||||
round(max(intensidades), 3), evidencias)
|
||||
return None
|
||||
|
||||
# 2. Pausa longa antes da fala.
|
||||
pausa = fala.inicio - anterior.fim
|
||||
if pausa >= self.pausa_longa:
|
||||
evidencias.append(f"pausa de {pausa:.1f} segundos")
|
||||
intensidades.append(min(1.0, 0.5 + pausa / 8.0))
|
||||
|
||||
# 3. Repetição do início da frase anterior.
|
||||
prefixo = self.comparador.prefixo_comum_em_palavras(anterior.texto, fala.texto)
|
||||
if prefixo >= 3:
|
||||
evidencias.append(f"repetição das primeiras {prefixo} palavras")
|
||||
intensidades.append(min(1.0, 0.4 + prefixo * 0.08))
|
||||
|
||||
if not evidencias:
|
||||
return None
|
||||
|
||||
return SinalDeReinicio(
|
||||
fala_id=fala.id,
|
||||
tipo="repeticao_do_inicio" if any("repetição" in e for e in evidencias) else "reinicio_pos_pausa",
|
||||
intensidade=round(min(1.0, max(intensidades)), 3),
|
||||
evidencias=evidencias,
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Modelos de dados do submódulo de detecção de retakes.
|
||||
|
||||
Mantemos os mesmos princípios de ``scanner/modelos.py``: dataclasses
|
||||
imutáveis (``frozen=True``) com validação em ``__post_init__`` e sem
|
||||
dependência de implementações concretas de providers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..modelos import PalavraDeTranscricao
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fala:
|
||||
"""Uma fala normalizada, já posicionada na timeline do vídeo.
|
||||
|
||||
Corresponde a um ``SegmentoDeTranscricao`` convertido pelo adaptador,
|
||||
mas carrega o contexto necessário para a análise temporal e de
|
||||
agrupamento: vídeo, faixa de áudio, número de sequência e o vínculo
|
||||
com o segmento/clipe de origem.
|
||||
"""
|
||||
|
||||
id: str
|
||||
video_id: str
|
||||
faixa_id: str
|
||||
segmento_id: str
|
||||
ordem: int
|
||||
inicio: float
|
||||
fim: float
|
||||
texto: str
|
||||
texto_normalizado: str = ""
|
||||
palavras: tuple[PalavraDeTranscricao, ...] = ()
|
||||
falante: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.inicio < 0 or self.fim < self.inicio:
|
||||
raise ValueError(f"Intervalo da fala {self.id} inválido.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SinalDeReinicio:
|
||||
"""Indício de que uma fala recomeçou uma ideia já iniciada."""
|
||||
|
||||
fala_id: str
|
||||
tipo: str
|
||||
intensidade: float
|
||||
evidencias: list[str] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0.0 <= self.intensidade <= 1.0:
|
||||
raise ValueError(f"Intensidade do reinício {self.fala_id} fora de [0, 1].")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultadoDaComparacao:
|
||||
"""Resultado da comparação de duas falas, textual e por sequência."""
|
||||
|
||||
fala_a_id: str
|
||||
fala_b_id: str
|
||||
similaridade_jaccard: float = 0.0
|
||||
similaridade_de_sequencia: float = 0.0
|
||||
similaridade_do_inicio: float = 0.0
|
||||
similaridade_do_final: float = 0.0
|
||||
similaridade_final: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultadoDoIntervalo:
|
||||
"""Relação temporal entre duas falas."""
|
||||
|
||||
intervalo_em_segundos: float
|
||||
mesmo_segmento: bool = False
|
||||
proximidade_temporal: float = 0.0
|
||||
indicio_de_nova_tentativa: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenciaDeRetake:
|
||||
"""Uma evidência legível que sustenta a classificação de um grupo."""
|
||||
|
||||
tipo: str
|
||||
descricao: str
|
||||
valor: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TomadaDeRetake:
|
||||
"""Uma tomada (fala) pertencente a um grupo de retakes."""
|
||||
|
||||
ordem: int
|
||||
fala_id: str
|
||||
segmento_id: str
|
||||
inicio: float
|
||||
fim: float
|
||||
texto: str
|
||||
similaridade_com_anterior: float = 0.0
|
||||
confianca: float = 0.0
|
||||
|
||||
|
||||
def gerar_id_do_grupo() -> str:
|
||||
"""Gera um id curto e monótono para um grupo de retakes."""
|
||||
return f"retake_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParAgrupado:
|
||||
"""Um par de falas que o agrupador juntou como candidato a retake."""
|
||||
|
||||
fala_a: Fala
|
||||
fala_b: Fala
|
||||
comparacao: ResultadoDaComparacao
|
||||
intervalo: ResultadoDoIntervalo
|
||||
similaridade_semantica: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgrupamentoDeFalas:
|
||||
"""Um grupo candidato formado pelo agrupador, antes da classificação."""
|
||||
|
||||
fala_ids: tuple[str, ...]
|
||||
pares: tuple[ParAgrupado, ...]
|
||||
sinais_de_reinicio: tuple[SinalDeReinicio, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrupoDeRetake:
|
||||
"""Agrupa as tomadas que representam tentativas da mesma fala."""
|
||||
|
||||
id: str
|
||||
video_id: str
|
||||
faixa_id: str
|
||||
tipo: str
|
||||
confianca: float
|
||||
tomadas: list[TomadaDeRetake] = field(default_factory=list)
|
||||
evidencias: list[EvidenciaDeRetake] = field(default_factory=list)
|
||||
criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0.0 <= self.confianca <= 1.0:
|
||||
raise ValueError(f"Confiança do grupo {self.id} fora de [0, 1].")
|
||||
|
||||
@property
|
||||
def tomada_principal_id(self) -> str | None:
|
||||
if not self.tomadas:
|
||||
return None
|
||||
return max(self.tomadas, key=lambda item: item.confianca).fala_id
|
||||
|
||||
@property
|
||||
def fala_ids(self) -> list[str]:
|
||||
return [tomada.fala_id for tomada in self.tomadas]
|
||||
Reference in New Issue
Block a user