Pipeline de transcrição — do áudio ao resumo, com estágios¶
Uma reunião de cinquenta minutos entra como upload e sai como três coisas diferentes: a transcrição, um resumo e uma lista de tarefas. Cada peça já tem receita própria — STT, jobs, contabilidade de uso. O que nenhuma delas mostra é a costura, que é onde moram as decisões difíceis:
- a tela já busca o documento; onde o estado dos três estágios deveria ficar?
- o usuário clicou em "cancelar" no minuto 40 de uma transcrição que roda numa worker thread. E agora?
- três estágios, dois deles pagos por token e um pago por relógio. Quem fecha a conta?
Esta página monta o fluxo inteiro, do upload à fatura, e diz o que cada escolha custa.
1. Uma tabela de jobs, ou colunas no próprio registro?¶
As duas formas existem no SDK e resolvem problemas diferentes:
| Forma | Quando | Receita |
|---|---|---|
JobStore — uma linha por unidade de trabalho |
o trabalho é a coisa: uma exportação, uma importação, um lote noturno | Jobs |
StageMap — colunas de status no registro |
o trabalho decora um registro que a tela já está mostrando | Jobs §9 |
Um áudio transcrito é o segundo caso. A tela abre o documento de qualquer jeito; uma tabela de jobs à parte vira uma segunda consulta e um join para desenhar uma página que já tinha tudo de que precisava.
Dá para usar as duas
Nada impede um JobStore para o processamento em lote da madrugada e
um StageMap no documento para o que a tela mostra. Elas não competem
— a pergunta é sempre "quem consulta isto, e a partir de quê".
2. O registro, e as nove colunas¶
O mapa não declara coluna nenhuma. Os mapped_column são seus, para que
migration, tipo e índice fiquem onde o leitor procura:
# src/core/pipeline.py
from uuid import UUID
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
from tempest_fastapi_sdk.tasks import StageMap
STAGES: StageMap = StageMap(
["transcription", "summary", "suggestions"],
prefix="doc_",
)
class DocumentModel(BaseModel):
"""Um áudio enviado, e o que a IA produziu a partir dele."""
__tablename__ = "documents"
owner_id: Mapped[UUID] = mapped_column()
filename: Mapped[str] = mapped_column()
doc_status_transcription: Mapped[str | None] = mapped_column(default=None)
doc_error_transcription: Mapped[str | None] = mapped_column(default=None)
doc_result_transcription: Mapped[str | None] = mapped_column(default=None)
doc_status_summary: Mapped[str | None] = mapped_column(default=None)
doc_error_summary: Mapped[str | None] = mapped_column(default=None)
doc_result_summary: Mapped[str | None] = mapped_column(default=None)
doc_status_suggestions: Mapped[str | None] = mapped_column(default=None)
doc_error_suggestions: Mapped[str | None] = mapped_column(default=None)
doc_result_suggestions: Mapped[str | None] = mapped_column(default=None)
São nove colunas para três estágios, e é exatamente por isso que o mapa existe: sem ele, cada estágio ganha a própria cópia de "marca rodando" e "marca falhou", e um estágio copiado que ficou com o nome de coluna do vizinho compila, importa e reporta o estado do vizinho.
Estágio sem coluna result_ perde o resultado em silêncio
mark(..., result=...) faz setattr no nome que o template resolve.
Se aquela coluna não existir no model, o SQLAlchemy não reclama: o
atributo fica na instância, some no flush e a linha grava só o status.
Medido — doc_status_summary gravou 'done', e
doc_result_summary nunca chegou à tabela.
Declare result_ em todo estágio que devolve alguma coisa, ou use
result_template para apontar para uma coluna que você já tem.
E os motores, num módulo só, porque são caros de construir e existem uma vez por processo:
# src/core/ai.py
from tempest_fastapi_sdk.db import AsyncDatabaseManager
from tempest_fastapi_sdk.genai import (
AIUsageStore,
BaseAIUsageModel,
OpenAICompatGenerator,
)
from tempest_fastapi_sdk.genai.audio import SpeechToText
class AIUsageModel(BaseAIUsageModel):
"""Uma chamada de IA cobrada desta aplicação."""
__tablename__ = "ai_usage"
db = AsyncDatabaseManager("postgresql+asyncpg://localhost/app")
usage: AIUsageStore[AIUsageModel] = AIUsageStore(
db,
model=AIUsageModel,
price_input_per_1k=0.00014,
price_output_per_1k=0.00028,
)
stt = SpeechToText(
"large-v3-turbo",
device="cpu",
compute_type="int8",
batch_size=8,
condition_on_previous_text=False,
)
llm = OpenAICompatGenerator(
"deepseek-chat",
api_key="sk-...",
base_url="https://api.deepseek.com/v1",
)
batch_size + condition_on_previous_text=False é o par que faz sentido
junto, e os knobs de decode
explicam por quê. O base_url não é opcional: sem ele o SDK manda um nome
de modelo da DeepSeek para api.openai.com.
3. Estágio 1 — transcrever¶
O padrão que se repete nos três estágios: sessão curta para marcar, sessão nenhuma durante o trabalho, sessão curta para gravar. Uma transcrição leva minutos; segurar conexão de banco por esse tempo é como se perde um pool.
# src/tasks/transcribe.py
from uuid import UUID
from tempest_fastapi_sdk.tasks import StageStatus
from src.core.ai import db, stt, usage
from src.core.pipeline import STAGES, DocumentModel
async def transcrever(documento_id: UUID) -> None:
"""Transcreve o áudio do documento e grava o texto.
Args:
documento_id (UUID): O documento a processar.
"""
async with db.get_session_context() as session:
documento = await session.get(DocumentModel, documento_id)
if documento is None:
return
STAGES.mark(documento, "transcription", StageStatus.RUNNING)
caminho: str = documento.filename
dono: UUID = documento.owner_id
await session.commit()
resultado = await stt.transcribe(caminho)
await usage.record_duration(
subject_id=dono,
seconds=resultado.duration,
model="large-v3-turbo",
)
async with db.get_session_context() as session:
documento = await session.get(DocumentModel, documento_id)
if documento is not None and STAGES.owns(
documento,
"transcription",
StageStatus.RUNNING,
):
STAGES.mark(
documento,
"transcription",
StageStatus.DONE,
result=resultado.text,
)
await session.commit()
Duas coisas que parecem detalhe e não são:
ownsé relido do banco. O objeto que você carregou antes do trabalho começar ainda tem o status antigo e responderiaTrueaconteça o que acontecer. A releitura é o que faz a checagem valer.record_duration, nãorecord. Modelo que roda no seu hardware não tem conta de token; o que se consome é relógio. Essas linhas ficam comservice=NULLe são excluídas das somas de token, para não virarem uma fatia de 0% em todo gráfico de distribuição.
4. Cancelar uma transcrição que já começou¶
Aqui o caminho fácil não funciona. run_cancellable corre a corotina
contra um predicado e cancela de verdade — mas transcribe() entrega o
decode a asyncio.to_thread, e cancelar a corotina abandona o wrapper
enquanto a thread segue até o fim, ainda ocupando CPU e ainda competindo
com o próximo job.
O que funciona é levantar de dentro do on_progress. O callback roda
na worker thread, dentro do laço que consome os segmentos, e nada no
caminho engole a exceção — ela sobe pelo gerador, sai do to_thread e
chega em quem estava aguardando:
# src/tasks/transcribe.py
import asyncio
import threading
from collections.abc import Awaitable, Callable
from tempest_fastapi_sdk.genai.audio import Transcription
from tempest_fastapi_sdk.tasks import StageInterruptedError
from src.core.ai import stt
async def transcrever_cancelavel(
caminho: str,
*,
cancelado: Callable[[], Awaitable[bool]],
) -> Transcription:
"""Transcreve, desistindo no meio quando o usuário cancela.
Args:
caminho (str): O arquivo de áudio.
cancelado (Callable[[], Awaitable[bool]]): Consulta assíncrona que
responde se o cancelamento já foi pedido.
Returns:
Transcription: O texto, quando o decode chega ao fim.
Raises:
StageInterruptedError: O cancelamento chegou antes do fim.
"""
parar = threading.Event()
def progresso(pronto: float, total: float) -> None:
"""Aborta o decode assim que o vigia levanta a bandeira."""
if parar.is_set():
raise StageInterruptedError
async def vigiar() -> None:
"""Consulta o cancelamento no event loop e avisa a thread."""
while not parar.is_set():
if await cancelado():
parar.set()
return
await asyncio.sleep(2.0)
vigia = asyncio.create_task(vigiar())
try:
return await stt.transcribe(caminho, on_progress=progresso)
finally:
vigia.cancel()
O threading.Event é a ponte, e é obrigatório: o callback roda fora
do event loop, então ele não pode await a consulta de cancelamento. Um
lado pergunta ao banco de 2 em 2 segundos; o outro só lê um booleano.
Medido, não deduzido
Contra um decode de 600 trechos, com o cancelamento chegando em 0,25 s: a exceção propagou e 25 dos 600 trechos tinham sido decodificados. Sem o callback, os 600 rodam até o fim.
O intervalo do vigia é o teto do seu desperdício e o piso da sua
granularidade: a primeira consulta é em t=0, a seguinte só depois do
intervalo, então trabalho que termina dentro dele nunca chega a ver o
cancelamento — também medido, num decode que acabou em 0,06 s com o
vigia em 2 s. Dois segundos num trabalho de minutos é ruído; ajuste se
o seu caso for outro.
O callback não pode fazer I/O
Ele roda na worker thread, a cada segmento. Nada de corotina lá dentro
sem loop.call_soon_threadsafe, nada de query, nada de log síncrono em
arquivo remoto — o custo entra direto no tempo de decode.
Do lado de quem pede, cancelar é escrever o status e responder na hora:
# src/services/documents.py
from uuid import UUID
from src.core.ai import db
from src.core.pipeline import STAGES, DocumentModel
async def cancelar(documento_id: UUID) -> list[str]:
"""Pede para o que ainda está rodando parar.
Args:
documento_id (UUID): O documento a cancelar.
Returns:
list[str]: Os estágios que foram efetivamente cancelados; os que
já haviam terminado saem na outra metade do par e são ignorados
de propósito.
"""
async with db.get_session_context() as session:
documento = await session.get(DocumentModel, documento_id)
if documento is None:
return []
cancelados, _ignorados = STAGES.cancel(documento)
await session.commit()
return cancelados
Não há cascata, e não precisa
Se cada estágio só enfileira o seguinte ao terminar bem, cancelar o primeiro faz o segundo nunca existir. Cancelar é parcial de propósito: uma tela que faz polling vai rotineiramente pedir o cancelamento de algo que concluiu um instante atrás, e isso não é erro.
5. Estágio 2 — resumir, e anotar quem pagou¶
Agora o trabalho é uma chamada de rede, o custo é por token, e o provedor
diz quanto gastou. generate_with_usage devolve os dois:
# src/tasks/summarize.py
from uuid import UUID
from tempest_fastapi_sdk.tasks import StageStatus
from src.core.ai import db, llm, usage
from src.core.pipeline import STAGES, DocumentModel
async def resumir(documento_id: UUID) -> None:
"""Resume a transcrição e registra o consumo da chamada.
Args:
documento_id (UUID): O documento a resumir.
"""
async with db.get_session_context() as session:
documento = await session.get(DocumentModel, documento_id)
if documento is None or documento.doc_result_transcription is None:
return
STAGES.mark(documento, "summary", StageStatus.RUNNING)
transcricao: str = documento.doc_result_transcription
dono: UUID = documento.owner_id
await session.commit()
resumo, tokens = await llm.generate_with_usage(
f"Resuma esta reunião em cinco linhas:\n\n{transcricao}",
)
await usage.record(subject_id=dono, service="summary", usage=tokens)
async with db.get_session_context() as session:
documento = await session.get(DocumentModel, documento_id)
if documento is not None and STAGES.owns(
documento,
"summary",
StageStatus.RUNNING,
):
STAGES.mark(documento, "summary", StageStatus.DONE, result=resumo)
await session.commit()
generate devolve só o texto — e é de propósito
generate() satisfaz o TextBackend, que é o protocolo que todo o
resto do SDK consome; mudar o retorno dele para uma tupla quebraria
cada chamador. Por isso o par: generate/chat para quem quer texto,
generate_with_usage/chat_with_usage para quem vai cobrar.
Provedor que não reportou uso não vira linha
record(usage=None) grava nada, e TokenUsage(0, 0, 0) também
não. "O provedor não disse" é diferente de "a chamada foi de graça":
uma linha zerada contaria para o total de chamadas e para "usuários
ativos" sem contribuir token nenhum.
6. Estágio 3 — sugestões como lista validada¶
O terceiro estágio pede ao modelo um array de objetos, e é o estágio
que quebra. generate_structured_list empacota o que se escreve à mão
toda vez: acha o array mesmo cercado de prosa e de cerca de bloco,
valida item a item, e só repete a geração quando a saída não tem array
nenhum.
# src/tasks/suggest.py
import json
from uuid import UUID
from pydantic import BaseModel
from tempest_fastapi_sdk.genai import generate_structured_list
from tempest_fastapi_sdk.tasks import StageStatus
from src.core.ai import db, llm
from src.core.pipeline import STAGES, DocumentModel
class Tarefa(BaseModel):
"""Uma tarefa que a reunião gerou."""
titulo: str
responsavel: str
prazo: str | None = None
async def sugerir(documento_id: UUID) -> None:
"""Extrai tarefas da transcrição e grava a lista validada.
Args:
documento_id (UUID): O documento a minerar.
"""
async with db.get_session_context() as session:
documento = await session.get(DocumentModel, documento_id)
if documento is None or documento.doc_result_transcription is None:
return
STAGES.mark(documento, "suggestions", StageStatus.RUNNING)
transcricao: str = documento.doc_result_transcription
await session.commit()
tarefas: list[Tarefa] = await generate_structured_list(
llm,
"Liste as tarefas combinadas, como um array JSON de objetos com "
f"titulo, responsavel e prazo:\n\n{transcricao}",
Tarefa,
)
async with db.get_session_context() as session:
documento = await session.get(DocumentModel, documento_id)
if documento is not None and STAGES.owns(
documento,
"suggestions",
StageStatus.RUNNING,
):
STAGES.mark(
documento,
"suggestions",
StageStatus.DONE,
result=json.dumps([t.model_dump() for t in tarefas]),
)
await session.commit()
Só falha estrutural — nenhum array na saída — gasta uma tentativa, e
cada tentativa sobe a temperatura em temperature_step. Repetir uma
geração greedy na mesma temperatura reproduziria a saída anterior; um item
malformado no meio de dez bons não é falha de formato, e sai por
skip_invalid.
Lista vazia é sucesso
[] significa "o modelo respondeu, e a resposta é nenhum item" — uma
reunião sem tarefa combinada. Não confunda com StructuredFormatError,
que é "nenhuma tentativa produziu um array".
Este estágio não devolve TokenUsage
generate_structured_list recebe qualquer coisa com
generate(prompt) -> str, e o protocolo devolve texto. A chamada
acontece, o token é cobrado, e o retorno não tem o que gravar.
Quando o estágio precisa entrar na conta, faça as duas metades você mesmo — e aceite que perde o retry:
# src/tasks/suggest.py
from uuid import UUID
from pydantic import BaseModel
from tempest_fastapi_sdk.genai import parse_structured_list
from src.core.ai import llm, usage
class Tarefa(BaseModel):
"""Uma tarefa que a reunião gerou."""
titulo: str
responsavel: str
prazo: str | None = None
async def sugerir_cobrando(transcricao: str, dono: UUID) -> list[Tarefa]:
"""Extrai tarefas registrando o consumo da chamada.
Args:
transcricao (str): O texto de origem.
dono (UUID): Quem paga a chamada.
Returns:
list[Tarefa]: As tarefas que passaram na validação.
"""
texto, tokens = await llm.generate_with_usage(
f"Liste as tarefas como array JSON:\n\n{transcricao}",
)
await usage.record(subject_id=dono, service="suggestions", usage=tokens)
return parse_structured_list(texto, Tarefa, skip_invalid=True)
Medido: o TokenUsage chega inteiro, e um array com um item inválido
entre dois devolve os válidos em vez de levantar.
7. A tela¶
O endpoint de status não precisa saber o nome de coluna nenhum:
# src/api/routers/documents.py
from uuid import UUID
from fastapi import APIRouter
from src.core.ai import db
from src.core.pipeline import STAGES, DocumentModel
router = APIRouter(prefix="/api/documents")
@router.get("/{documento_id}/status")
async def ler_status(documento_id: UUID) -> dict[str, str | None]:
"""Devolve o estado de cada estágio do documento.
Args:
documento_id (UUID): O documento consultado.
Returns:
dict[str, str | None]: Estágio para status; `None` no estágio que
ainda não começou.
"""
async with db.get_session_context() as session:
documento = await session.get(DocumentModel, documento_id)
if documento is None:
return {}
return {
estagio: None if situacao is None else situacao.value
for estagio, situacao in STAGES.snapshot(documento).items()
}
Uma execução real dos três estágios devolve
{"transcription": "done", "summary": "done", "suggestions": "done"} —
e o front desenha a barra de progresso a partir disso, sem conhecer o
prefix nem os templates.
cancelled é terminal, mas não é falha
Uma tela que destaca failed em vermelho deve deixar cancelled em
paz, e um alerta que dispara em falha não deve tocar. Nada deu errado:
o sistema fez o que mandaram.
8. A conta no fim do mês¶
As duas naturezas de custo — token e relógio — já estão na mesma tabela, e saem separadas na leitura:
# src/services/reports.py
from datetime import timedelta
from tempest_fastapi_sdk.genai import ServiceUsage, UsageTotals
from src.core.ai import usage
async def painel() -> tuple[UsageTotals, list[ServiceUsage]]:
"""Lê o que a tela de custo mostra.
Returns:
tuple[UsageTotals, list[ServiceUsage]]: Totais do período e a
distribuição por serviço.
"""
janela = timedelta(days=30)
return await usage.totals(janela), await usage.by_service(janela)
Rodando o pipeline inteiro uma vez (uma transcrição local de 30 s e um resumo de 3000 tokens de entrada + 800 de saída), o painel lê:
UsageTotals(input_tokens=3000, output_tokens=800, total_tokens=3800,
duration_seconds=30.0, calls=2, cost=0.000644,
cache_hit_tokens=0)
[ServiceUsage(service='summary', total_tokens=3800, share=100.0)]
calls=2 conta as duas linhas — a paga por token e a paga por relógio.
by_service traz só a primeira: a transcrição local ficou com
service=NULL e não vira uma fatia de 0% no gráfico.
O custo não vem arredondado
0.000644 é o valor cheio. Qualquer precisão fixa erra em alguma
escala — arredondar para centavos zera quase toda chamada isolada,
enquanto um total mensal quer centavos. A formatação fica na borda,
que sabe qual dos dois está mostrando. cost is None significa "não
mostre custo", nunca zero.
O preço nunca é gravado
O custo sai dos tokens na hora da leitura, então corrigir
price_input_per_1k conserta o histórico inteiro — sem reprocessar
nada, sem linhas discordando sobre quanto valia um token.
Erros¶
| Exceção | Quando |
|---|---|
StageInterruptedError |
o cancelamento chegou no meio; não é falha, o handler só retorna |
StructuredFormatError |
nenhuma tentativa de generate_structured_list produziu um array decodificável; subclasse de ValueError |
pydantic.ValidationError |
um item não satisfaz o schema e skip_invalid está desligado |
ValueError (na construção do StageMap) |
lista de estágios vazia, estágio duplicado, ou dois estágios resolvendo para a mesma coluna |
ValueError (na construção do SpeechToText) |
batch_size sem vad_filter=True — é o VAD que corta o áudio nos trechos que viram um batch |
Recap¶
O StageMap dá nome às colunas de estado sem declarar nenhuma,
e é a forma certa quando a tela já carrega o registro; cada estágio marca
RUNNING, solta a sessão, trabalha, relê e só grava se owns disser que
o estágio ainda é dele; transcrição cancela levantando de dentro do
on_progress, porque to_thread não é cancelável e a bandeira atravessa
por um threading.Event; generate_with_usage é a metade que devolve o
TokenUsage que record grava, record_duration cobre o modelo local
que não tem token; generate_structured_list acha e valida o array, e a
lista vazia é resposta, não erro.