Skip to content

Repository files navigation

Async Transcription & Summarization API

A FastAPI service that turns long audio into a structured summary, running entirely on local models. Built around the problem that makes AI features awkward to ship: the work takes minutes, but an HTTP request can't.

Submit a source, get a job id back immediately, poll it for the result.

Swagger UI showing the /health, /summarize, /summarize/upload and /jobs endpoints

The engineering problem

Transcribing an hour of audio and summarizing it takes several minutes of CPU work. A synchronous endpoint that does this holds a connection open the whole time, times out behind most proxies and load balancers, blocks a worker, and gives the caller nothing to show until it either finishes or dies.

So the API is built as a job queue over HTTP:

sequenceDiagram
    participant C as Client
    participant A as API
    participant W as Worker thread
    C->>A: POST /summarize
    A->>A: validate, create job
    A->>W: schedule (background)
    A-->>C: 202 {id, status: "pending"}
    W->>W: download → transcribe → summarize
    C->>A: GET /jobs/{id}
    A-->>C: {status: "running"}
    C->>A: GET /jobs/{id}
    A-->>C: {status: "done", transcription, summary}
Loading

The submit endpoints return in milliseconds. The pipeline is synchronous, CPU-bound Python, so it is declared as a plain def and FastAPI runs it in a worker thread — the event loop stays free to serve polls while the job runs.

Design decisions

Decision Why
202 + polling instead of a blocking call A minutes-long request is a timeout waiting to happen. The client gets an id it can poll, retry, or abandon.
Dependencies built once in lifespan The Whisper model costs seconds and GBs of RAM to load. Loading it per request would dominate the runtime; here it's warm before the first request lands.
Map-reduce summarization A 65k-character transcript doesn't fit a context window. Above a threshold the text is chunked, each chunk summarized, then the partials merged — so long inputs degrade gracefully instead of being silently truncated.
Per-job temporary directory Audio is an intermediate artifact. A 1-hour video produces a ~100 MB MP3; a shared directory would grow without bound, leak .part files from failed downloads, and let two jobs on the same video overwrite each other.
Generic error to the client, traceback to the logs Exceptions carry local filesystem paths. The caller gets "Processing failed."; the operator gets the stack trace.
Streaming upload with a size cap The cap is enforced while writing, not read from Content-Length — a header the client controls and can lie about.
Injected config and collaborators Settings is passed in, not read from globals, so every component can be tested against fakes. The test suite runs with no network, no Whisper and no LLM.

Stack

FastAPI · uv (lockfile-based dependency management) · Whisper (local transcription) · Ollama (local LLM via its OpenAI-compatible API) · yt-dlp · pytest · Docker Compose · Streamlit (optional web client)

Nothing is sent to a third-party API — both models run on the machine.

Quickstart

Local

ffmpeg is required by yt-dlp and Whisper, and is not a Python package:

brew install ffmpeg
uv sync

You also need Ollama running with the model pulled:

ollama pull qwen3:8b

Then:

uv run uvicorn main:app --reload

Interactive docs at http://localhost:8000/docs

Web client (Streamlit)

An optional Streamlit UI for trying the service by hand — submit a URL or a file, watch the job status, read the summary. It is a pure HTTP client: it talks only to the API, so it stays honest to the same contract as any other caller and is never needed to run or deploy the service.

uv sync --group ui                       # one-time: install the UI extras
uv run streamlit run streamlit_app.py    # with the API already running

It defaults to http://localhost:8000; point it elsewhere from the sidebar or with SUMMARIZER_API_URL.

Docker

docker compose up --build
docker compose exec ollama ollama pull qwen3:8b

This brings up the API and an Ollama container together. On macOS, pointing the API at a native Ollama instead is significantly faster — Linux containers get no access to the Apple Silicon GPU, so a containerized LLM is CPU-only:

OLLAMA_BASE_URL=http://host.docker.internal:11434/v1 docker compose up api

Configuration is overridden through environment variables or a .env file — see .env.example for the full list and defaults.

API

Method Route Description
GET /health Liveness probe and the active model configuration
POST /summarize Queue a video URL → 202 with the job id
POST /summarize/upload Queue an uploaded audio file → 202 with the job id
GET /jobs/{id} Job status and, once ready, transcription and summary
curl -X POST localhost:8000/summarize \
  -H 'content-type: application/json' \
  -d '{"url": "https://www.youtube.com/watch?v=BEtBxcem6-M"}'
# -> 202 {"id": "…", "status": "pending", "source": "url", …}

curl -X POST localhost:8000/summarize/upload -F 'file=@talk.mp3'
# -> 202 {"id": "…", "status": "pending", "source": "upload", …}

curl localhost:8000/jobs/<id>
# -> status: pending -> running -> done | error

Both sources feed the same pipeline; the upload path simply skips the download stage.

Layout

app/
  config.py      Settings (pydantic-settings), read from env or .env
  prompts.py     System prompts and summary templates
  download.py    Downloader — media URL to MP3 via yt-dlp
  transcribe.py  Transcriber — local Whisper, model loaded once
  summarize.py   Summarizer — LLM via OpenAI-compatible API, map-reduce
  pipeline.py    VideoSummarizer — orchestrates the stages
main.py          FastAPI app: lifespan, endpoints, background jobs
streamlit_app.py Optional Streamlit client — talks to the API over HTTP only
tests/           Unit and HTTP-level tests

Tests

uv run pytest

30 tests, no network and no models required — the HTTP layer is exercised against a stubbed pipeline, and the pure logic (URL parsing, chunk splitting, temporary-directory lifecycle, upload size cap, path-traversal handling) is tested directly.

Known limitations

Deliberate scope choices, not oversights:

  • Jobs live in memory. A restart loses them, and with multiple uvicorn workers a poll can land on a process that doesn't know the job. Production needs a shared store — Redis or Postgres — and, past a certain load, a real task queue instead of BackgroundTasks.
  • No authentication. The service assumes it sits behind something that handles it.
  • Unbounded concurrency. Nothing limits how many jobs run at once; enough simultaneous submissions will exhaust RAM. A worker pool with a bounded queue is the next step.

License

MIT — see LICENSE.

About

Asynchronous FastAPI service that transcribes and summarizes audio/video using local LLMs (Ollama + Whisper).

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages