Transcription pipeline — from audio to summary, in stages¶
A fifty-minute meeting arrives as an upload and leaves as three different things: the transcript, a summary, and a list of tasks. Each piece already has its own recipe — STT, jobs, usage accounting. What none of them shows is the seam, which is where the hard decisions live:
- the screen already loads the document; where should the three stages' state go?
- the user clicked "cancel" 40 minutes into a transcription running on a worker thread. Now what?
- three stages, two billed per token and one billed by the clock. Who closes the bill?
This page builds the whole flow, from upload to invoice, and says what each choice costs.
1. A jobs table, or columns on the record itself?¶
Both shapes exist in the SDK and solve different problems:
| Shape | When | Recipe |
|---|---|---|
JobStore — one row per unit of work |
the work is the thing: an export, an import, a nightly batch | Jobs |
StageMap — status columns on the record |
the work decorates a record the screen is already showing | Jobs §9 |
A transcribed audio file is the second case. The screen opens the document either way; a separate jobs table becomes a second query and a join to draw a page that already had everything it needed.
You can use both
Nothing stops a JobStore for the nightly batch and a StageMap on
the document for what the screen shows. They do not compete — the
question is always "who queries this, and starting from what".
2. The record, and the nine columns¶
The map declares no columns. The mapped_columns are yours, so that
migrations, types and indexes stay where a reader looks for them:
# 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):
"""An uploaded audio file, and what the AI produced from it."""
__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)
Nine columns for three stages — and that is exactly why the map exists: without it, every stage gets its own copy of "mark running" and "mark failed", and a copy-pasted stage left holding its neighbour's column name compiles, imports, and reports the neighbour's state.
A stage with no result_ column loses the result silently
mark(..., result=...) does a setattr on whatever name the template
resolves to. If that column does not exist on the model, SQLAlchemy
does not complain: the attribute stays on the instance, disappears at
flush, and the row stores only the status. Measured —
doc_status_summary was written as 'done', and doc_result_summary
never reached the table.
Declare result_ for every stage that returns something, or point
result_template at a column you already have.
And the engines, in a single module, because they are expensive to build and exist once per process:
# 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):
"""One billed AI call in this application."""
__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 is the pair that belongs
together, and the decode knobs
explain why. base_url is not optional: without it the SDK sends a
DeepSeek model name to api.openai.com.
3. Stage 1 — transcribe¶
The pattern that repeats across all three stages: short session to mark, no session during the work, short session to store. A transcription takes minutes; holding a database connection for that long is how a pool is lost.
# 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 transcribe(document_id: UUID) -> None:
"""Transcribe the document's audio and store the text.
Args:
document_id (UUID): The document to process.
"""
async with db.get_session_context() as session:
document = await session.get(DocumentModel, document_id)
if document is None:
return
STAGES.mark(document, "transcription", StageStatus.RUNNING)
path: str = document.filename
owner: UUID = document.owner_id
await session.commit()
result = await stt.transcribe(path)
await usage.record_duration(
subject_id=owner,
seconds=result.duration,
model="large-v3-turbo",
)
async with db.get_session_context() as session:
document = await session.get(DocumentModel, document_id)
if document is not None and STAGES.owns(
document,
"transcription",
StageStatus.RUNNING,
):
STAGES.mark(
document,
"transcription",
StageStatus.DONE,
result=result.text,
)
await session.commit()
Two things that look like details and are not:
ownsreads from the database again. The object you loaded before the work started still holds the old status and would answerTruewhatever happened. The re-read is what makes the check mean anything.record_duration, notrecord. A model running on your own hardware has no token bill; what it consumes is wall clock. Those rows carryservice=NULLand are excluded from token sums, so they never become a 0% slice on every distribution chart.
4. Cancelling a transcription that already started¶
The easy path does not work here. run_cancellable races the coroutine
against a predicate and cancels for real — but transcribe() hands the
decode to asyncio.to_thread, and cancelling the coroutine abandons the
wrapper while the thread runs to completion, still burning CPU and still
competing with the next job.
What does work is raising from inside on_progress. The callback runs
on the worker thread, inside the loop that consumes the segments, and
nothing along the way swallows the exception — it travels up through the
generator, out of the to_thread, and into whoever was awaiting:
# 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 transcribe_cancellable(
path: str,
*,
cancelled: Callable[[], Awaitable[bool]],
) -> Transcription:
"""Transcribe, giving up midway when the user cancels.
Args:
path (str): The audio file.
cancelled (Callable[[], Awaitable[bool]]): Async lookup answering
whether cancellation was requested.
Returns:
Transcription: The text, when the decode reaches the end.
Raises:
StageInterruptedError: Cancellation arrived first.
"""
stop = threading.Event()
def progress(done: float, total: float) -> None:
"""Abort the decode as soon as the watcher raises the flag."""
if stop.is_set():
raise StageInterruptedError
async def watch() -> None:
"""Poll cancellation on the event loop and tell the thread."""
while not stop.is_set():
if await cancelled():
stop.set()
return
await asyncio.sleep(2.0)
watcher = asyncio.create_task(watch())
try:
return await stt.transcribe(path, on_progress=progress)
finally:
watcher.cancel()
The threading.Event is the bridge, and it is mandatory: the callback
runs outside the event loop, so it cannot await the cancellation
lookup. One side asks the database every two seconds; the other only reads
a boolean.
Measured, not deduced
Against a 600-segment decode, with cancellation arriving at 0.25 s: the exception propagated and 25 of the 600 segments had been decoded. Without the callback, all 600 run to the end.
The watcher's interval is your ceiling on waste and your floor on
granularity: the first lookup happens at t=0 and the next only after
the interval, so work that finishes inside it never gets to see the
cancellation — also measured, on a decode that ended in 0.06 s with the
watcher at 2 s. Two seconds inside a job that takes minutes is noise;
tune it if your case differs.
The callback must not do I/O
It runs on the worker thread, once per segment. No coroutines in there
without loop.call_soon_threadsafe, no queries, no synchronous log to
a remote file — the cost lands straight on the decode time.
On the requesting side, cancelling is writing the status and answering immediately:
# src/services/documents.py
from uuid import UUID
from src.core.ai import db
from src.core.pipeline import STAGES, DocumentModel
async def cancel(document_id: UUID) -> list[str]:
"""Ask whatever is still running to stop.
Args:
document_id (UUID): The document to cancel.
Returns:
list[str]: The stages actually cancelled; the ones that had already
finished come back in the other half of the pair and are ignored on
purpose.
"""
async with db.get_session_context() as session:
document = await session.get(DocumentModel, document_id)
if document is None:
return []
cancelled, _ignored = STAGES.cancel(document)
await session.commit()
return cancelled
There is no cascade, and none is needed
If each stage only enqueues the next one on success, cancelling the first makes the second never exist. Cancelling is partial on purpose: a polling screen will routinely ask to cancel something that finished a moment ago, and that is not an error.
5. Stage 2 — summarize, and note who paid¶
Now the work is a network call, the cost is per token, and the provider
reports what it spent. generate_with_usage returns both:
# 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 summarize(document_id: UUID) -> None:
"""Summarize the transcript and record what the call consumed.
Args:
document_id (UUID): The document to summarize.
"""
async with db.get_session_context() as session:
document = await session.get(DocumentModel, document_id)
if document is None or document.doc_result_transcription is None:
return
STAGES.mark(document, "summary", StageStatus.RUNNING)
transcript: str = document.doc_result_transcription
owner: UUID = document.owner_id
await session.commit()
summary, tokens = await llm.generate_with_usage(
f"Summarize this meeting in five lines:\n\n{transcript}",
)
await usage.record(subject_id=owner, service="summary", usage=tokens)
async with db.get_session_context() as session:
document = await session.get(DocumentModel, document_id)
if document is not None and STAGES.owns(
document,
"summary",
StageStatus.RUNNING,
):
STAGES.mark(document, "summary", StageStatus.DONE, result=summary)
await session.commit()
generate returns text only — and that is deliberate
generate() satisfies TextBackend, the protocol the rest of the SDK
consumes; changing its return type to a tuple would break every
caller. Hence the pair: generate/chat for whoever wants text,
generate_with_usage/chat_with_usage for whoever is going to bill.
A provider that reported no usage does not become a row
record(usage=None) writes nothing, and neither does
TokenUsage(0, 0, 0). "The provider did not say" is different from
"the call was free": a zeroed row would count toward total calls and
toward "active users" while contributing no tokens at all.
6. Stage 3 — suggestions as a validated list¶
The third stage asks the model for an array of objects, and it is the
stage that breaks. generate_structured_list packages what everyone
hand-writes every time: it finds the array even when wrapped in prose and
code fences, validates item by item, and only spends another generation
when the output has no array at all.
# 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 Task(BaseModel):
"""A task the meeting produced."""
title: str
assignee: str
due: str | None = None
async def suggest(document_id: UUID) -> None:
"""Extract tasks from the transcript and store the validated list.
Args:
document_id (UUID): The document to mine.
"""
async with db.get_session_context() as session:
document = await session.get(DocumentModel, document_id)
if document is None or document.doc_result_transcription is None:
return
STAGES.mark(document, "suggestions", StageStatus.RUNNING)
transcript: str = document.doc_result_transcription
await session.commit()
tasks: list[Task] = await generate_structured_list(
llm,
"List the agreed tasks, as a JSON array of objects with title, "
f"assignee and due:\n\n{transcript}",
Task,
)
async with db.get_session_context() as session:
document = await session.get(DocumentModel, document_id)
if document is not None and STAGES.owns(
document,
"suggestions",
StageStatus.RUNNING,
):
STAGES.mark(
document,
"suggestions",
StageStatus.DONE,
result=json.dumps([t.model_dump() for t in tasks]),
)
await session.commit()
Only a structural failure — no array in the output — costs an attempt,
and each attempt raises the temperature by temperature_step. Retrying a
greedy generation at the same temperature would reproduce the previous
output; one malformed item among ten good ones is not a formatting
failure, and is handled by skip_invalid.
An empty list is a success
[] means "the model answered, and the answer is no items" — a
meeting with no task agreed. Do not confuse it with
StructuredFormatError, which means "no attempt produced an array".
This stage does not return TokenUsage
generate_structured_list accepts anything with
generate(prompt) -> str, and that protocol returns text. The call
happens, the tokens are billed, and the return value has nothing to
store.
When the stage has to enter the bill, do both halves yourself — and accept that you lose the 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 Task(BaseModel):
"""A task the meeting produced."""
title: str
assignee: str
due: str | None = None
async def suggest_billed(transcript: str, owner: UUID) -> list[Task]:
"""Extract tasks while recording what the call consumed.
Args:
transcript (str): The source text.
owner (UUID): Who pays for the call.
Returns:
list[Task]: The tasks that passed validation.
"""
text, tokens = await llm.generate_with_usage(
f"List the tasks as a JSON array:\n\n{transcript}",
)
await usage.record(subject_id=owner, service="suggestions", usage=tokens)
return parse_structured_list(text, Task, skip_invalid=True)
Measured: the TokenUsage arrives intact, and an array holding one
invalid item between two valid ones returns the valid ones instead of
raising.
7. The screen¶
The status endpoint needs to know no column name at all:
# 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("/{document_id}/status")
async def read_status(document_id: UUID) -> dict[str, str | None]:
"""Return the state of each of the document's stages.
Args:
document_id (UUID): The document being queried.
Returns:
dict[str, str | None]: Stage to status; `None` for a stage that has
not started.
"""
async with db.get_session_context() as session:
document = await session.get(DocumentModel, document_id)
if document is None:
return {}
return {
stage: None if state is None else state.value
for stage, state in STAGES.snapshot(document).items()
}
A real run of all three stages returns
{"transcription": "done", "summary": "done", "suggestions": "done"} —
and the front end draws its progress bar from that, knowing neither the
prefix nor the templates.
cancelled is terminal, but it is not a failure
A screen that highlights failed in red should leave cancelled
alone, and an alert that fires on failure should stay quiet. Nothing
went wrong: the system did exactly what it was told.
8. The bill at the end of the month¶
Both natures of cost — tokens and wall clock — are already in the same table, and come out separated on read:
# src/services/reports.py
from datetime import timedelta
from tempest_fastapi_sdk.genai import ServiceUsage, UsageTotals
from src.core.ai import usage
async def dashboard() -> tuple[UsageTotals, list[ServiceUsage]]:
"""Read what the cost screen shows.
Returns:
tuple[UsageTotals, list[ServiceUsage]]: Period totals and the
breakdown per service.
"""
window = timedelta(days=30)
return await usage.totals(window), await usage.by_service(window)
Running the whole pipeline once (a 30 s local transcription plus a summary of 3000 input + 800 output tokens), the dashboard reads:
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 counts both rows — the one billed per token and the one billed
by the clock. by_service brings only the first: the local transcription
carries service=NULL and never becomes a 0% slice on the chart.
The cost is not rounded
0.000644 is the full value. Any fixed precision is wrong at some
scale — rounding to cents zeroes almost every single call, while a
monthly total wants cents. Formatting belongs at the boundary, which
knows which of the two it is showing. cost is None means "do not
show a cost", never zero.
The price is never stored
Cost is derived from the tokens at read time, so fixing
price_input_per_1k fixes the entire history — with nothing
reprocessed, and no rows disagreeing about what a token was worth.
Errors¶
| Exception | When |
|---|---|
StageInterruptedError |
cancellation arrived midway; not a failure, the handler just returns |
StructuredFormatError |
no generate_structured_list attempt produced a decodable array; subclass of ValueError |
pydantic.ValidationError |
an item does not satisfy the schema and skip_invalid is off |
ValueError (building the StageMap) |
empty stage list, duplicate stage, or two stages resolving to the same column |
ValueError (building the SpeechToText) |
batch_size without vad_filter=True — it is the VAD that cuts the audio into the chunks a batch is made of |
Recap¶
StageMap names the state columns without declaring any, and
is the right shape when the screen already loads the record; each stage
marks RUNNING, releases the session, works, re-reads and only stores if
owns says the stage is still its own; a transcription cancels by raising
from inside on_progress, because to_thread is not cancellable and the
flag crosses over on a threading.Event; generate_with_usage is the
half that returns the TokenUsage that record stores, while
record_duration covers the local model that has no tokens;
generate_structured_list finds and validates the array, and an empty
list is an answer, not an error.