Pós-processamento de Transcrições do FireRedASR-AED-L: Restauração de Pontuação e Quebra Semântica de Parágrafos

1. A necessidade de refinar transcrições brutas

Modelos automáticos de reconhecimento de fala, como o FireRedASR-AED-L, convertem áudio em sequências textuais contínuas. A saída bruta, embora lexicamente precisa, raramente está pronta para uso direto: carece de sinais de pontuação, não separa tópicos e conserva hesitações próprias da fala espontânea.

Esse material precisa passar por uma etapa de pós-processamento para se tornar legível, arquivável ou editável. O objetivo deste guia é construir um pipeline leve, executável localmente, que adicione pontuação e divida o texto em parágrafos coerentes, sem depender de modelos pesados ou treinamentos adicionais.

2. Módulo de restauração de pontuação

A estratégia adotada combina regras linguísticas com uma camada opcional de refinamento por modelo compacto. A regra cobre os casos mais frequentes; o modelo entra em ação apenas quando a confiança da heurística é baixa.

# formatador_prosodico.py
import re
from typing import List

class MarcadorProsodico:
    def __init__(self, refinar_com_modelo: bool = False):
        self.refinar = refinar_com_modelo
        self.interrogativos = [
            "qual", "quem", "quando", "onde", "por que", "como",
            "pode", "será", "deveria", "está", "foi"
        ]
        self.exclamativos = [
            "excelente", "incrível", "urgente", "parabéns", "atenção"
        ]
        self.conectores = [
            "então", "portanto", "contudo", "porém",
            "além disso", "enfim", "resumindo", "a propósito"
        ]
        self.enchimentos = [
            r"\b(éh|ahm|hum|né|tipo|assim|sabe|ó)\b"
        ]

    def aplicar(self, texto: str) -> str:
        texto = self._remover_hesitacoes(texto)
        fragmentos = self._quebrar_em_fragmentos(texto)
        marcados = [self._colocar_sinal(f) for f in fragmentos]
        resultado = " ".join(marcados)
        return self._refinacao_leve(resultado) if self.refinar else resultado

    def _remover_hesitacoes(self, texto: str) -> str:
        for padrao in self.enchimentos:
            texto = re.sub(padrao, "", texto, flags=re.IGNORECASE)
        return re.sub(r"\s+", " ", texto).strip()

    def _quebrar_em_fragmentos(self, texto: str) -> List[str]:
        padrao = r"(?=\s+(?:então|portanto|contudo|porém|além disso|enfim|resumindo))"
        partes = re.split(padrao, texto)
        partes = [p.strip() for p in partes if p.strip()]
        blocos = []
        acumulado = ""
        for p in partes:
            acumulado = (acumulado + " " + p).strip()
            if len(acumulado) > 25 or any(c in acumulado.lower() for c in self.conectores):
                blocos.append(acumulado)
                acumulado = ""
        if acumulado:
            blocos.append(acumulado)
        return blocos

    def _colocar_sinal(self, frase: str) -> str:
        frase = frase.strip()
        if not frase:
            return frase
        if any(re.search(rf"\b{p}\b", frase, re.IGNORECASE) for p in self.interrogativos):
            return frase + "?"
        if any(re.search(rf"\b{p}\b", frase, re.IGNORECASE) for p in self.exclamativos):
            return frase + "!"
        return frase + "." if len(frase) >= 6 else frase

    def _refinacao_leve(self, texto: str) -> str:
        # Espaço reservado para um modelo compacto, como um BERT-tiny
        # ajustado para previsão de pontuação em português.
        return texto

O funcionamento é dividido em três etapas. Primeiro, retiram-se vogais alongadas e outras hesitações comuns na fala espontânea. Em seguida, o texto é particionado em fragmentos próximos às pausas naturais do locutor. Por fim, cada fragmento recebe o sinal adequado com base em palavras-chave de interrogação ou exclamação; fragmentos curtos demais são mantidos sem sinal final para evitar pontuação excessiva.

3. Módulo de segmentação semântica

Dividir o texto em parágrafos não deve seguir apenas um tamanho fixo. A quebra deve respeitar mudanças de tema, mantendo juntas as sentenças que compartilham vocabulário relevante.

# segmentador_contextual.py
import re
from typing import List

class SegmentadorContextual:
    def __init__(self, comprimento_maximo: int = 180, limiar_tema: float = 0.55):
        self.comprimento_maximo = comprimento_maximo
        self.limiar_tema = limiar_tema
        self.palavras_vazias = {
            "de", "a", "o", "que", "e", "do", "da", "em", "um", "para",
            "é", "com", "não", "uma", "os", "no", "se", "na", "por", "mais"
        }

    def agrupar(self, texto: str) -> List[str]:
        sentencas = re.split(r"(?<=[.!?])\s+", texto)
        sentencas = [s.strip() for s in sentencas if s.strip()]

        paragrafos = []
        atual = []
        tamanho_atual = 0

        for i, sentenca in enumerate(sentencas):
            atual.append(sentenca)
            tamanho_atual += len(sentenca)

            proxima = sentencas[i + 1] if i + 1 < len(sentencas) else ""

            deve_quebrar = (
                tamanho_atual >= self.comprimento_maximo or
                (proxima and self._mudanca_de_tema(sentenca, proxima))
            )

            if deve_quebrar:
                paragrafos.append(" ".join(atual))
                atual = []
                tamanho_atual = 0

        if atual:
            paragrafos.append(" ".join(atual))

        return paragrafos

    def _extrair_termos(self, sentenca: str) -> set:
        tokens = re.findall(r"\b[a-zA-Zà-úÀ-Ú]{3,}\b", sentenca.lower())
        return set(t for t in tokens if t not in self.palavras_vazias)

    def _mudanca_de_tema(self, anterior: str, seguinte: str) -> bool:
        termos_antes = self._extrair_termos(anterior)
        termos_depois = self._extrair_termos(seguinte)

        if not termos_antes or not termos_depois:
            return False

        intersecao = len(termos_antes & termos_depois)
        uniao = len(termos_antes | termos_depois)
        similaridade = intersecao / uniao if uniao else 0.0

        return similaridade < self.limiar_tema

A lógica verifica duas condições a cada sentença. Se o parágrafo corrente atingir o limite de caracteres, acontece uma quebra forçada para garentir legibilidade. Caso contrário, calcula-se a sobreposição de termos relevantes entre a sentença atual e a seguinte. Quando a similaridade de Jaccard cai abaixo do limiar, entende-se que o tema mudou e inicia-se um novo parágrafo.

4. Integração ao pipeline do FireRedASR

Supondo que o texto reconhecido esteja disponível em uma variável, basta encadear os dois módulos antes de exibir ou exportar o resultado. O exemplo abaixo usa Streamlit como interface mínima.

# fluxo_principal.py
import streamlit as st
from formatador_prosodico import MarcadorProsodico
from segmentador_contextual import SegmentadorContextual

texto_bruto = st.text_area("Transcrição bruta", height=150)

formatador = MarcadorProsodico(refinar_com_modelo=False)
segmentador = SegmentadorContextual(comprimento_maximo=150, limiar_tema=0.55)

if st.button("Refinar transcrição"):
    texto_pontuado = formatador.aplicar(texto_bruto)
    paragrafos = segmentador.agrupar(texto_pontuado)
    texto_final = "\n\n".join(paragrafos)

    st.text_area("Resultado estruturado", texto_final, height=300)
    st.download_button(
        label="Baixar transcrição",
        data=texto_final,
        file_name="transcricao_otimizada.txt",
        mime="text/plain"
    )

Para uma entrada como hoje vamos discutir o orçamento do projeto primeiro rever os gastos do trimestre passado houve um excesso de dez por cento depois vamos falar sobre cortes no marketing, o pipeline produz parágrafos claros, com pontuação adequada e separação entre o tema orçamentário e a discussão de cortes.

5. Configurações por tipo de conteúdo

Parâmetros diferentes funcionam melhor para diferentes gêneros de áudio. Reuniões formais, entrevistas e palestras possuem ritmos distintos de fala e mudança de assunto.

# ajustes_por_cenario.py
from formatador_prosodico import MarcadorProsodico
from segmentador_contextual import SegmentadorContextual

configuracoes = {
    "reuniao_formal": {
        "formatador": MarcadorProsodico(refinar_com_modelo=True),
        "segmentador": SegmentadorContextual(
            comprimento_maximo=200,
            limiar_tema=0.50
        )
    },
    "entrevista": {
        "formatador": MarcadorProsodico(refinar_com_modelo=False),
        "segmentador": SegmentadorContextual(
            comprimento_maximo=100,
            limiar_tema=0.75
        )
    },
    "palestra": {
        "formatador": MarcadorProsodico(refinar_com_modelo=True),
        "segmentador": SegmentadorContextual(
            comprimento_maximo=280,
            limiar_tema=0.45
        )
    }
}

def processar(texto: str, cenario: str) -> str:
    cfg = configuracoes[cenario]
    pontuado = cfg["formatador"].aplicar(texto)
    paragrafos = cfg["segmentador"].agrupar(pontuado)
    return "\n\n".join(paragrafos)

tipo = st.selectbox("Tipo de conteúdo", list(configuracoes.keys()))
resultado = processar(texto_bruto, tipo)

Em reuniões, o limiar baixo torna o segmentador sensível a mudanças de pauta. Em entrevistas, o limiar alto evita que cada pergunta e resposta sejam fragmentadas em dezenas de parágrafos. Em palestras, parágrafos maiores preservam a continuidade argumentativa do expositor.

Tags: FireRedASR-AED-L ASR Streamlit Python Processamento de Linguagem Natural

Publicado em 7-11 03:00