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:
João Henrique
2026-09-08 16:13:27 -04:00
parent b541f502ba
commit b9bf3b2863
76 changed files with 16147 additions and 322 deletions
@@ -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]