Initial commit: Nat20 Notes — TTRPG session transcription & summarization

This commit is contained in:
KansaiGaijin
2026-07-06 23:45:54 +12:00
commit 524c8ab96d
50 changed files with 2969 additions and 0 deletions

10
backend/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
.venv/
venv/
*.egg-info/
.git/
.env

52
backend/Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# ==========================================
# STAGE 1: Builder Environment
# ==========================================
FROM python:3.11-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
patchelf \
&& rm -rf /var/lib/apt/lists/*
ENV PIP_NO_CACHE_DIR=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
COPY requirements.txt .
RUN pip install --user --extra-index-url https://download.pytorch.org/whl/cu121 -r requirements.txt
# Silence the "Lightning auto-upgraded checkpoint" warning on every diarization run
RUN python -c "import sys, subprocess, whisperx, pathlib; ckpt = pathlib.Path(whisperx.__file__).parent/'assets'/'pytorch_model.bin'; ckpt.exists() and subprocess.run([sys.executable, '-m', 'pytorch_lightning.utilities.upgrade_checkpoint', str(ckpt)])" 2>/dev/null || true
RUN patchelf --clear-execstack /root/.local/lib/python3.11/site-packages/ctranslate2.libs/libctranslate2-*.so*
RUN find /root/.local -type d -name "__pycache__" -exec rm -rf {} + \
&& find /root/.local -type d \( -name "test" -o -name "tests" \) -exec rm -rf {} + \
&& find /root/.local -name "*.dist-info" -exec sh -c 'rm -f "$1"/RECORD "$1"/INSTALLER' _ {} \;
# ==========================================
# STAGE 2: Lightweight Final Runtime
# ==========================================
FROM python:3.11-slim AS runtime
WORKDIR /app
# ffmpeg is mandatory for the audio-extraction step.
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Copy the (now stripped) site-packages from the builder stage. None of the
# build-essential/gcc toolchain from Stage 1 ends up here.
COPY --from=builder /root/.local /root/.local
COPY app /app/app
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

49
backend/app/config.py Normal file
View File

@@ -0,0 +1,49 @@
"""
App-wide configuration. Everything is either an environment variable (set once,
at deploy time) or a row in the `settings` table (editable at runtime via the
setup wizard / settings page) - never a hardcoded path or host.
"""
import os
from pathlib import Path
# Deploy-time config (env vars) - where things live on disk inside the container.
DATA_DIR = Path(os.environ.get("APP_DATA_DIR", "/data"))
UPLOAD_DIR = DATA_DIR / "audio"
AUDIO_DIR = DATA_DIR / "audio"
TRANSCRIPT_DIR = DATA_DIR / "transcriptions"
NOTES_DIR = DATA_DIR / "notes"
DB_PATH = DATA_DIR / "app.db"
for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR):
d.mkdir(parents=True, exist_ok=True)
# Runtime-editable settings (stored in DB, these are just first-run defaults).
# NOTE: this flat key/value schema is the single source of truth for settings -
# it's what every consumer (pipeline/summarize.py, pipeline/transcribe.py,
# routers/models.py, routers/diagnostics.py) actually reads. The settings
# router and frontend must mirror these exact keys - a nested schema would
# silently disconnect the Settings/Setup UI from the pipeline.
DEFAULT_SETTINGS = {
# Onboarding
"onboarding_completed": "false",
# Transcription
"whisper_model": "medium", # tiny|base|small|medium|large-v3 - user picks based on their hardware
"whisper_compute_type": "int8",
"hf_token": "", # required for diarization (pyannote gated models)
# Summarization backend: "ollama" (local) or "api" (hosted, OpenAI-compatible)
"llm_mode": "ollama",
"ollama_host": "http://localhost:11434",
"ollama_model": "qwen2.5:7b",
"api_base_url": "https://api.openai.com/v1",
"api_key": "",
"api_model": "gpt-4o-mini",
# Chunking for long transcripts (map-reduce summarization)
"chunk_word_target": "2500",
# Optional world context injected into every summarization prompt
"world_context": "",
"world_context_path": "",
}

136
backend/app/database.py Normal file
View File

@@ -0,0 +1,136 @@
import json
import sqlite3
import threading
import time
import uuid
from contextlib import contextmanager
from . import config
_local = threading.local()
def get_conn():
if not hasattr(_local, "conn"):
_local.conn = sqlite3.connect(config.DB_PATH, check_same_thread=False)
_local.conn.row_factory = sqlite3.Row
_local.conn.execute("PRAGMA foreign_keys = ON")
return _local.conn
@contextmanager
def tx():
conn = get_conn()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def init_db():
with tx() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
original_filename TEXT NOT NULL,
video_path TEXT,
audio_path TEXT,
transcript_path TEXT,
status TEXT NOT NULL DEFAULT 'uploaded',
created_at REAL NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS speakers (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
raw_label TEXT NOT NULL,
display_name TEXT,
UNIQUE(session_id, raw_label)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS notes (
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
dm_notes TEXT,
player_recap TEXT,
generated_at REAL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
job_type TEXT NOT NULL, -- 'transcribe' | 'summarize'
status TEXT NOT NULL DEFAULT 'queued', -- queued|running|done|error
progress TEXT, -- free-text progress message
error TEXT,
error_stage TEXT, -- which pipeline stage failed, e.g. 'transcription'
error_detail TEXT, -- full traceback, for logs/advanced view only
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
""")
# seed defaults if empty
existing = {r["key"] for r in conn.execute("SELECT key FROM settings")}
for k, v in config.DEFAULT_SETTINGS.items():
if k not in existing:
conn.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (k, v))
def get_settings() -> dict:
conn = get_conn()
rows = conn.execute("SELECT key, value FROM settings").fetchall()
return {r["key"]: r["value"] for r in rows}
def update_settings(patch: dict):
with tx() as conn:
for k, v in patch.items():
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(k, str(v)),
)
def new_id() -> str:
return uuid.uuid4().hex[:12]
def now() -> float:
return time.time()
def create_job(session_id: str, job_type: str) -> str:
job_id = new_id()
with tx() as conn:
conn.execute(
"INSERT INTO jobs (id, session_id, job_type, status, created_at, updated_at) "
"VALUES (?, ?, ?, 'queued', ?, ?)",
(job_id, session_id, job_type, now(), now()),
)
return job_id
def update_job(job_id: str, **fields):
if not fields:
return
fields["updated_at"] = now()
cols = ", ".join(f"{k} = ?" for k in fields)
with tx() as conn:
conn.execute(f"UPDATE jobs SET {cols} WHERE id = ?", (*fields.values(), job_id))
def get_job(job_id: str) -> dict | None:
row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return dict(row) if row else None

View File

@@ -0,0 +1,73 @@
import shutil
import subprocess
import requests
from . import database as db, config
def _check_ffmpeg() -> dict:
path = shutil.which("ffmpeg")
if not path:
return {"name": "ffmpeg", "ok": False, "message": "ffmpeg not found on PATH. This should be baked into the Docker image - if you're seeing this, the image build is broken."}
return {"name": "ffmpeg", "ok": True, "message": path}
def _check_gpu() -> dict:
try:
import torch
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
free, total = torch.cuda.mem_get_info(0)
free_gb, total_gb = free / 1e9, total / 1e9
return {"name": "gpu", "ok": True, "message": f"{name}{free_gb:.1f}GB free of {total_gb:.1f}GB"}
return {"name": "gpu", "ok": False, "message": "No CUDA GPU visible. Check that the container was started with --gpus all / GPU passthrough in compose, and that host NVIDIA drivers + Container Toolkit are installed. Transcription will fall back to CPU, which is much slower."}
except Exception as e:
return {"name": "gpu", "ok": False, "message": f"Could not query GPU: {e}"}
def _check_hf_token() -> dict:
settings = db.get_settings()
token = settings.get("hf_token", "")
if not token:
return {"name": "huggingface_token", "ok": False, "message": "No HF token set. Diarization (speaker separation) will fail without one. Add it in Settings."}
return {"name": "huggingface_token", "ok": True, "message": "Token is set (not validated against HF until first diarization run)."}
def _check_llm_backend() -> dict:
settings = db.get_settings()
if settings.get("llm_mode") == "api":
if not settings.get("api_key"):
return {"name": "llm_backend", "ok": False, "message": "Hosted API selected but no API key set."}
return {"name": "llm_backend", "ok": True, "message": f"Hosted API configured ({settings.get('api_base_url')})"}
host = settings.get("ollama_host", "http://localhost:11434")
model = settings.get("ollama_model", "")
try:
resp = requests.get(f"{host.rstrip('/')}/api/tags", timeout=5)
resp.raise_for_status()
models = [m["name"] for m in resp.json().get("models", [])]
if model not in models:
return {"name": "llm_backend", "ok": False, "message": f"Reached Ollama at {host}, but model '{model}' isn't pulled there. Available: {', '.join(models) or '(none)'}. Run: ollama pull {model}"}
return {"name": "llm_backend", "ok": True, "message": f"Ollama reachable at {host}, model '{model}' available"}
except requests.exceptions.ConnectionError:
return {"name": "llm_backend", "ok": False, "message": f"Could not connect to Ollama at {host}. Check the host/port in Settings, and that Ollama is running and reachable from this container."}
except Exception as e:
return {"name": "llm_backend", "ok": False, "message": f"Error checking Ollama: {e}"}
def _check_disk_space() -> dict:
usage = shutil.disk_usage(config.DATA_DIR)
free_gb = usage.free / 1e9
ok = free_gb > 5
return {"name": "disk_space", "ok": ok, "message": f"{free_gb:.1f}GB free in {config.DATA_DIR}" + ("" if ok else " — this is low, recordings and model caches need room")}
def run_diagnostics() -> list[dict]:
return [
_check_ffmpeg(),
_check_gpu(),
_check_hf_token(),
_check_llm_backend(),
_check_disk_space(),
]

29
backend/app/errors.py Normal file
View File

@@ -0,0 +1,29 @@
class PipelineError(Exception):
"""Base class for pipeline failures. `stage` and `message` are shown to the
user directly; the original exception (if any) is kept for logs only."""
def __init__(self, stage: str, message: str, cause: Exception | None = None):
self.stage = stage
self.message = message
self.cause = cause
super().__init__(f"[{stage}] {message}")
class AudioExtractionError(PipelineError):
def __init__(self, message: str, cause: Exception | None = None):
super().__init__("audio_extraction", message, cause)
class TranscriptionError(PipelineError):
def __init__(self, message: str, cause: Exception | None = None):
super().__init__("transcription", message, cause)
class DiarizationError(PipelineError):
def __init__(self, message: str, cause: Exception | None = None):
super().__init__("diarization", message, cause)
class SummarizationError(PipelineError):
def __init__(self, message: str, cause: Exception | None = None):
super().__init__("summarization", message, cause)

41
backend/app/jobs.py Normal file
View File

@@ -0,0 +1,41 @@
"""
Minimal background job runner.
"""
import traceback
from concurrent.futures import ThreadPoolExecutor
from . import database as db
from .errors import PipelineError
from .logging_config import get_logger
log = get_logger(__name__)
_executor = ThreadPoolExecutor(max_workers=2) # transcription is GPU-bound anyway; no benefit to more workers
def submit(job_id: str, fn, *args, **kwargs):
def _run():
log.info("Job %s starting", job_id)
db.update_job(job_id, status="running", progress="Starting...")
try:
def progress_cb(msg):
log.info("Job %s: %s", job_id, msg)
db.update_job(job_id, progress=msg)
fn(*args, progress_cb=progress_cb, **kwargs)
db.update_job(job_id, status="done", progress="Complete")
log.info("Job %s complete", job_id)
except PipelineError as e:
log.error("Job %s failed at stage '%s': %s", job_id, e.stage, e.message)
db.update_job(
job_id, status="error",
error=e.message, error_stage=e.stage,
error_detail=traceback.format_exc(),
)
except Exception as e:
log.exception("Job %s failed with unexpected error", job_id)
db.update_job(
job_id, status="error",
error=f"Unexpected error: {e}", error_stage="unknown",
error_detail=traceback.format_exc(),
)
_executor.submit(_run)

View File

@@ -0,0 +1,18 @@
import logging
import sys
def setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
# quiet down noisy libraries so pipeline logs are easy to spot
for noisy in ("httpx", "urllib3", "pyannote"):
logging.getLogger(noisy).setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

67
backend/app/main.py Normal file
View File

@@ -0,0 +1,67 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi import HTTPException
from . import database as db, config
from .logging_config import setup_logging, get_logger
from .errors import PipelineError
from .routers import settings as settings_router
from .routers import sessions as sessions_router
from .routers import speakers as speakers_router
from .routers import notes as notes_router
from .routers import jobs as jobs_router
from .routers import models as models_router
from .routers import diagnostics as diagnostics_router
setup_logging()
log = get_logger(__name__)
app = FastAPI(title="Nat20 Notes")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(PipelineError)
async def pipeline_error_handler(request: Request, exc: PipelineError):
log.error("Pipeline error in %s: %s", exc.stage, exc.message, exc_info=exc.cause or exc)
return JSONResponse(status_code=502, content={"stage": exc.stage, "message": exc.message})
@app.exception_handler(Exception)
async def unhandled_error_handler(request: Request, exc: Exception):
log.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"stage": "unknown", "message": f"Unexpected error: {exc}"})
@app.on_event("startup")
def on_startup():
db.init_db()
log.info("Database initialized at %s", config.DB_PATH)
app.include_router(settings_router.router)
app.include_router(sessions_router.router)
app.include_router(speakers_router.router)
app.include_router(notes_router.router)
app.include_router(jobs_router.router)
app.include_router(models_router.router)
app.include_router(diagnostics_router.router)
@app.get("/api/sessions/{session_id}/audio")
def get_audio(session_id: str):
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
if not audio_path.exists():
raise HTTPException(404, "Audio not available for this session")
return FileResponse(audio_path, media_type="audio/wav")
@app.get("/api/health")
def health():
return {"ok": True}

View File

View File

@@ -0,0 +1,35 @@
import subprocess
from pathlib import Path
from ..errors import AudioExtractionError
from ..logging_config import get_logger
log = get_logger(__name__)
def extract_audio(video_path: Path, audio_path: Path) -> Path:
"""Extract 16kHz mono wav from any video/audio container."""
if not video_path.exists():
raise AudioExtractionError(f"Uploaded file not found on disk at {video_path}. It may not have finished uploading, or the upload volume isn't mounted correctly.")
if audio_path.exists():
log.info("Audio already extracted at %s, skipping", audio_path)
return audio_path
log.info("Extracting audio: %s -> %s", video_path, audio_path)
result = subprocess.run(
[
"ffmpeg", "-y", "-i", str(video_path),
"-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
str(audio_path),
],
capture_output=True, text=True,
)
if result.returncode != 0:
# surface ffmpeg's own error line rather than the whole verbose log
stderr_tail = "\n".join(result.stderr.strip().splitlines()[-5:])
raise AudioExtractionError(
f"ffmpeg failed to extract audio (exit code {result.returncode}). "
f"This usually means the file is corrupt or not a supported format. Last output:\n{stderr_tail}"
)
return audio_path

View File

@@ -0,0 +1,152 @@
"""
Map-reduce summarization: chunk transcript -> per-chunk DM + player summaries
-> combine into final notes. Backend-agnostic: works against a local Ollama
instance or any OpenAI-compatible hosted API, selected via settings.
"""
from pathlib import Path
import requests
from .turns import chunk_turns, turns_to_text, fmt_time
from ..errors import SummarizationError
from ..logging_config import get_logger
log = get_logger(__name__)
DM_CHUNK_PROMPT = """You are summarizing a chunk of a tabletop RPG session transcript for the Game Master's private notes.
{world_context_block}
Extract concise bullet points:
- Major plot events and decisions made
- NPC interactions (names, what was said/promised/revealed)
- Combat outcomes (who fought what, notable rolls, deaths/near-deaths)
- Loot, rewards, or resources gained/lost
- Any GM secrets, foreshadowing, or plot threads revealed at the table
- Open questions or hooks left dangling
Be concise and factual. Use the speaker names given. Do not invent anything not in the transcript.
Transcript chunk (time range {time_range}):
{chunk_text}
"""
PLAYER_CHUNK_PROMPT = """You are summarizing a chunk of a tabletop RPG session transcript for a player-facing recap.
{world_context_block}
Extract concise bullet points of what happened IN THE STORY from the players' in-character perspective:
- Where the party went, who they met, what was said in-character
- Combat encounters and outcomes
- Loot/items the party found
- Decisions the party made and their in-fiction consequences
Exclude out-of-character rules discussion, GM asides, and anything that reads as a secret not yet revealed to characters.
If unsure whether something is a spoiler, leave it out.
Transcript chunk (time range {time_range}):
{chunk_text}
"""
DM_FINAL_PROMPT = """Combine these chunk summaries from a single session into one cohesive GM session log.
Organize into sections: Recap, NPC Interactions, Combat & Encounters, Loot & Rewards, Decisions & Consequences, Open Threads / Hooks for Next Session.
Write in clear prose/bullets, no fluff, no repetition across chunks.
Chunk summaries:
{summaries}
"""
PLAYER_FINAL_PROMPT = """Combine these chunk summaries into a single "previously on" style recap for the players, in-character perspective only.
Write it like a story recap they could reread before the next session. No GM secrets, no OOC content. Keep it engaging but concise.
Chunk summaries:
{summaries}
"""
def _call_ollama(host: str, model: str, prompt: str) -> str:
url = f"{host.rstrip('/')}/api/generate"
try:
resp = requests.post(
url,
json={"model": model, "prompt": prompt, "stream": False, "options": {"num_ctx": 8192}},
timeout=600,
)
except requests.exceptions.ConnectionError as e:
raise SummarizationError(
f"Could not connect to Ollama at {host}. Check the host/port in Settings and that Ollama is running.", cause=e
)
except requests.exceptions.Timeout as e:
raise SummarizationError(f"Ollama at {host} didn't respond within 10 minutes - it may be overloaded or stuck.", cause=e)
if resp.status_code == 404:
raise SummarizationError(
f"Ollama returned 404 for model '{model}'. This almost always means the model isn't pulled on that host. "
f"Run: ollama pull {model}", cause=None
)
try:
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
raise SummarizationError(f"Ollama returned an error ({resp.status_code}): {resp.text[:300]}", cause=e)
return resp.json()["response"].strip()
def _call_api(base_url: str, api_key: str, model: str, prompt: str) -> str:
url = f"{base_url.rstrip('/')}/chat/completions"
try:
resp = requests.post(
url,
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "messages": [{"role": "user", "content": prompt}]},
timeout=600,
)
except requests.exceptions.ConnectionError as e:
raise SummarizationError(f"Could not connect to API at {base_url}. Check the base URL in Settings.", cause=e)
except requests.exceptions.Timeout as e:
raise SummarizationError(f"API at {base_url} didn't respond within 10 minutes.", cause=e)
if resp.status_code == 401:
raise SummarizationError("API rejected the request as unauthorized (401) - check your API key in Settings.", cause=None)
if resp.status_code == 404:
raise SummarizationError(f"API returned 404 - check the base URL and that model '{model}' exists for this provider.", cause=None)
try:
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
raise SummarizationError(f"API returned an error ({resp.status_code}): {resp.text[:300]}", cause=e)
try:
return resp.json()["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError) as e:
raise SummarizationError(f"API response didn't have the expected shape: {resp.text[:300]}", cause=e)
def make_llm_caller(settings: dict):
if settings.get("llm_mode") == "api":
base_url, api_key, model = settings["api_base_url"], settings["api_key"], settings["api_model"]
return lambda prompt: _call_api(base_url, api_key, model, prompt)
host, model = settings["ollama_host"], settings["ollama_model"]
return lambda prompt: _call_ollama(host, model, prompt)
def summarize_session(turns: list[dict], settings: dict, progress_cb=None) -> tuple[str, str]:
call = make_llm_caller(settings)
ctx_path = settings.get("world_context_path") or ""
if ctx_path and Path(ctx_path).exists():
world_context = Path(ctx_path).read_text(encoding="utf-8").strip()
else:
world_context = (settings.get("world_context") or "").strip()
world_block = f"\nCampaign context (use this to recognize names/places correctly):\n{world_context}\n" if world_context else ""
chunks = chunk_turns(turns, int(settings.get("chunk_word_target", 2500)))
dm_summaries, player_summaries = [], []
for i, chunk in enumerate(chunks):
if progress_cb:
progress_cb(f"Summarizing chunk {i+1}/{len(chunks)}")
time_range = f"{fmt_time(chunk[0]['start'])}-{fmt_time(chunk[-1]['end'])}"
chunk_text = turns_to_text(chunk)
dm_summaries.append(call(DM_CHUNK_PROMPT.format(world_context_block=world_block, time_range=time_range, chunk_text=chunk_text)))
player_summaries.append(call(PLAYER_CHUNK_PROMPT.format(world_context_block=world_block, time_range=time_range, chunk_text=chunk_text)))
if progress_cb:
progress_cb("Writing final notes...")
dm_final = call(DM_FINAL_PROMPT.format(summaries="\n\n".join(dm_summaries)))
player_final = call(PLAYER_FINAL_PROMPT.format(summaries="\n\n".join(player_summaries)))
return dm_final, player_final

View File

@@ -0,0 +1,104 @@
"""
Transcription + diarization using WhisperX as a Python library.
"""
import gc
import json
from pathlib import Path
import torch
import whisperx
from ..errors import TranscriptionError, DiarizationError
from ..logging_config import get_logger
log = get_logger(__name__)
def transcribe_and_diarize(
audio_path: Path,
output_json: Path,
model_size: str,
compute_type: str,
hf_token: str,
progress_cb=None,
) -> dict:
device = "cuda" if torch.cuda.is_available() else "cpu"
if device == "cpu":
log.warning("No CUDA GPU available - running on CPU, this will be much slower")
def report(msg):
log.info(msg)
if progress_cb:
progress_cb(msg)
report("Loading transcription model...")
try:
model = whisperx.load_model(model_size, device, compute_type=compute_type)
except RuntimeError as e:
if "out of memory" in str(e).lower():
raise TranscriptionError(
f"Ran out of GPU memory loading the '{model_size}' model. "
f"Try a smaller model size in Settings (e.g. 'small' or 'medium'), "
f"or check nothing else is using the GPU right now.", cause=e
)
raise TranscriptionError(f"Failed to load Whisper model '{model_size}': {e}", cause=e)
except Exception as e:
raise TranscriptionError(f"Failed to load Whisper model '{model_size}': {e}", cause=e)
try:
report("Loading audio...")
audio = whisperx.load_audio(str(audio_path))
report("Transcribing...")
result = model.transcribe(audio, batch_size=16)
except RuntimeError as e:
if "out of memory" in str(e).lower():
raise TranscriptionError(
f"Ran out of GPU memory during transcription. Try a smaller Whisper model size in Settings.", cause=e
)
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
except Exception as e:
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
finally:
del model
gc.collect()
if device == "cuda":
torch.cuda.empty_cache()
try:
report("Aligning...")
align_model, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
result = whisperx.align(result["segments"], align_model, metadata, audio, device, return_char_alignments=False)
del align_model
gc.collect()
if device == "cuda":
torch.cuda.empty_cache()
except Exception as e:
raise TranscriptionError(f"Alignment step failed: {e}", cause=e)
if hf_token:
try:
report("Diarizing...")
diarize_model = whisperx.diarize.DiarizationPipeline(use_auth_token=hf_token, device=device)
diarize_segments = diarize_model(audio)
result = whisperx.assign_word_speakers(diarize_segments, result)
except Exception as e:
msg = str(e)
if "gated" in msg.lower() or "403" in msg or "access" in msg.lower():
raise DiarizationError(
"HuggingFace rejected access to the diarization model. Make sure you've accepted the "
"terms on the gated model page (linked in the error below) using the SAME account "
f"your HF token belongs to.\n{msg}", cause=e
)
raise DiarizationError(f"Diarization failed: {msg}", cause=e)
else:
report("No HF token set - skipping speaker diarization (all speech will be unattributed)")
try:
output_json.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(json.dumps(result, ensure_ascii=False), encoding='utf-8')
except OSError as e:
raise TranscriptionError(f"Transcribed successfully, but failed to write output to {output_json}: {e}", cause=e)
report("Done")
return result

View File

@@ -0,0 +1,60 @@
import json
from pathlib import Path
def fmt_time(seconds: float) -> str:
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
return f"{h:02d}:{m:02d}:{s:02d}"
def load_turns(transcript_json: Path, speaker_map: dict[str, str] | None = None) -> list[dict]:
"""Parse whisperx json into merged (start, end, speaker, text) turns.
speaker_map maps raw labels (SPEAKER_00...) to display names; unmapped labels pass through raw."""
data = json.loads(transcript_json.read_text())
speaker_map = speaker_map or {}
turns = []
for seg in data.get("segments", []):
raw = seg.get("speaker", "UNKNOWN")
speaker = speaker_map.get(raw, raw)
text = (seg.get("text") or "").strip()
if not text:
continue
if turns and turns[-1]["speaker"] == speaker:
turns[-1]["text"] += " " + text
turns[-1]["end"] = seg["end"]
else:
turns.append({"start": seg["start"], "end": seg["end"], "speaker": speaker, "text": text, "raw_speaker": raw})
return turns
def distinct_speakers(transcript_json: Path) -> list[dict]:
"""Return each raw speaker label with a couple of sample lines, for the naming UI."""
turns = load_turns(transcript_json) # no map -> raw labels
seen: dict[str, list[dict]] = {}
for t in turns:
seen.setdefault(t["raw_speaker"], []).append(t)
return [
{
"raw_label": label,
"samples": [{"start": t["start"], "text": t["text"][:140]} for t in items[:3]],
}
for label, items in sorted(seen.items())
]
def chunk_turns(turns: list[dict], word_target: int) -> list[list[dict]]:
chunks, current, word_count = [], [], 0
for turn in turns:
current.append(turn)
word_count += len(turn["text"].split())
if word_count >= word_target:
chunks.append(current)
current, word_count = [], 0
if current:
chunks.append(current)
return chunks
def turns_to_text(turns: list[dict]) -> str:
return "\n".join(f"[{fmt_time(t['start'])}] {t['speaker']}: {t['text']}" for t in turns)

View File

View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter
from ..diagnostics import run_diagnostics
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
@router.get("")
def get_diagnostics():
checks = run_diagnostics()
return {"ok": all(c["ok"] for c in checks), "checks": checks}

View File

@@ -0,0 +1,82 @@
import os
from fastapi import APIRouter, HTTPException, Query
from .. import database as db, config
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
# Canonical job status vocabulary (matches the `jobs` table): queued|running|done|error.
# The frontend must use these exact strings, not invented ones like "pending"/"completed"/"failed".
@router.get("/{job_id}")
def get_job(job_id: str):
job = db.get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
return job
@router.post("/{job_id}/cancel")
async def cancel_job(job_id: str):
job = db.get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
if job["status"] in ("queued", "running"):
# The thread pool executor has no hard-kill hook, so this marks the job
# as cancelled; the pipeline itself does not currently check for
# cancellation mid-run, so a running job may still finish in the background.
db.update_job(job_id, status="error", error="Cancelled by user", error_stage="cancelled")
return {"status": "success", "message": f"Job {job_id} cancellation signal sent."}
@router.delete("/{job_id}")
async def delete_job(
job_id: str,
strategy: str = Query(..., description="One of: 'all', 'artifacts_only', 'none'"),
):
job = db.get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
session_id = job["session_id"]
session_row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
session = dict(session_row) if session_row else None
purged = []
if strategy in ("all", "artifacts_only") and session:
for path_key in ("transcript_path",):
path = session.get(path_key)
if path and os.path.exists(path):
os.remove(path)
purged.append(path_key)
with db.tx() as conn:
conn.execute("DELETE FROM notes WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM speakers WHERE session_id = ?", (session_id,))
purged.append("notes_and_speakers")
if strategy == "all" and session:
for path_key in ("video_path", "audio_path"):
path = session.get(path_key)
if path and os.path.exists(path):
os.remove(path)
purged.append(path_key)
with db.tx() as conn:
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
purged.append("session_record")
elif strategy in ("all", "artifacts_only", "none"):
with db.tx() as conn:
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
purged.append("job_record")
# If a failed job left the session stuck in 'transcribing', roll it back
if job["status"] == "error" and session and session["status"] == "transcribing":
with db.tx() as conn:
conn.execute("UPDATE sessions SET status = 'uploaded' WHERE id = ?", (session_id,))
if strategy not in ("all", "artifacts_only", "none"):
raise HTTPException(400, f"Unknown strategy '{strategy}'")
return {"status": "success", "message": f"Job {job_id} cleared using strategy: {strategy}", "purged": purged}

View File

@@ -0,0 +1,20 @@
import requests
from fastapi import APIRouter
from .. import database as db
router = APIRouter(prefix="/api/models", tags=["models"])
@router.get("/ollama")
def list_ollama_models():
"""List models already pulled on the configured Ollama host, so the setup
wizard can offer a dropdown instead of asking the user to type a tag blind."""
settings = db.get_settings()
host = settings.get("ollama_host", "http://host.docker.internal:11434")
try:
resp = requests.get(f"{host.rstrip('/')}/api/tags", timeout=5)
resp.raise_for_status()
return {"ok": True, "models": [m["name"] for m in resp.json().get("models", [])]}
except Exception as e:
return {"ok": False, "error": str(e), "models": []}

View File

@@ -0,0 +1,48 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from .. import database as db, jobs
from ..pipeline.turns import load_turns
from ..pipeline.summarize import summarize_session
router = APIRouter(prefix="/api/sessions/{session_id}/notes", tags=["notes"])
@router.post("/generate")
def generate_notes(session_id: str):
session = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not session:
raise HTTPException(404, "Session not found")
if not session["transcript_path"]:
raise HTTPException(400, "Session hasn't been transcribed yet")
settings = db.get_settings()
speaker_map = {r["raw_label"]: r["display_name"] for r in
db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))
if r["display_name"]}
job_id = db.create_job(session_id, "summarize")
def run(progress_cb):
turns = load_turns(Path(session["transcript_path"]), speaker_map)
dm_notes, player_recap = summarize_session(turns, settings, progress_cb=progress_cb)
with db.tx() as conn:
conn.execute(
"INSERT INTO notes (session_id, dm_notes, player_recap, generated_at) VALUES (?, ?, ?, ?) "
"ON CONFLICT(session_id) DO UPDATE SET dm_notes=excluded.dm_notes, "
"player_recap=excluded.player_recap, generated_at=excluded.generated_at",
(session_id, dm_notes, player_recap, db.now()),
)
conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,))
jobs.submit(job_id, run)
return {"job_id": job_id}
@router.get("")
def get_notes(session_id: str):
row = db.get_conn().execute("SELECT * FROM notes WHERE session_id = ?", (session_id,)).fetchone()
if not row:
raise HTTPException(404, "Notes not generated yet")
return dict(row)

View File

@@ -0,0 +1,190 @@
import json
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from .. import database as db, config, jobs
from ..errors import PipelineError
from ..pipeline.audio import extract_audio
from ..pipeline.transcribe import transcribe_and_diarize
def _dedup_path(path: Path) -> Path:
"""Append a counter suffix like ` (1)`, ` (2)` if the file exists."""
if not path.exists():
return path
stem = path.stem
suffix = path.suffix
parent = path.parent
counter = 1
while True:
new = parent / f"{stem} ({counter}){suffix}"
if not new.exists():
return new
counter += 1
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
# All uploaded/derived files live under config.UPLOAD_DIR / config.AUDIO_DIR /
# config.TRANSCRIPT_DIR (all under config.DATA_DIR, the /data volume) - never
# a hardcoded /app/storage path, which isn't backed by any volume the rest of
# the app agrees on.
def _row_to_dict(row) -> dict:
return dict(row)
@router.get("")
def list_sessions():
rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall()
sessions = []
for r in rows:
s = _row_to_dict(r)
job = db.get_conn().execute(
"SELECT * FROM jobs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", (s["id"],)
).fetchone()
s["latest_job"] = _row_to_dict(job) if job else None
sessions.append(s)
return sessions
@router.get("/{session_id}")
def get_session(session_id: str):
row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not row:
raise HTTPException(404, "Session not found")
session = _row_to_dict(row)
job = db.get_conn().execute(
"SELECT * FROM jobs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", (session_id,)
).fetchone()
session["latest_job"] = _row_to_dict(job) if job else None
# Stats computed from transcript
if session.get("transcript_path"):
tp = Path(session["transcript_path"])
if tp.exists():
try:
data = json.loads(tp.read_text())
segments = data.get("segments", [])
if segments:
session["audio_duration"] = segments[-1]["end"]
session["word_count"] = sum(len(s.get("text", "").split()) for s in segments)
session["language"] = data.get("language")
except (json.JSONDecodeError, OSError):
pass
return session
def _run_transcription(session_id: str, video_path: Path, audio_path: Path, transcript_path: Path):
settings = db.get_settings()
def run(progress_cb):
db.get_conn() # ensure thread-local connection exists in this worker thread
with db.tx() as conn:
conn.execute("UPDATE sessions SET status = 'transcribing' WHERE id = ?", (session_id,))
progress_cb("Extracting audio...")
try:
extract_audio(video_path, audio_path)
transcribe_and_diarize(
audio_path,
transcript_path,
model_size=settings.get("whisper_model", "medium"),
compute_type=settings.get("whisper_compute_type", "int8"),
hf_token=settings.get("hf_token", ""),
progress_cb=progress_cb,
)
except PipelineError:
with db.tx() as conn:
conn.execute("UPDATE sessions SET status = 'uploaded' WHERE id = ?", (session_id,))
raise
with db.tx() as conn:
conn.execute(
"UPDATE sessions SET audio_path = ?, transcript_path = ?, status = 'transcribed' WHERE id = ?",
(str(audio_path), str(transcript_path), session_id),
)
job_id = db.create_job(session_id, "transcribe")
jobs.submit(job_id, run)
return job_id
@router.post("")
async def create_session(name: str = Form(...), file: UploadFile = File(None), upload_path: str = Form(None)):
session_id = db.new_id()
if upload_path:
src = Path(upload_path)
if not src.exists():
raise HTTPException(400, f"Uploaded file not found at {upload_path}")
safe_filename = os.path.basename(src)
video_path = config.UPLOAD_DIR / f"{session_id}_{safe_filename}"
shutil.move(str(src), str(video_path))
elif file:
safe_filename = os.path.basename(file.filename or f"{session_id}")
video_path = config.UPLOAD_DIR / f"{session_id}_{safe_filename}"
with open(video_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
else:
raise HTTPException(400, "Either a file upload or upload_path is required")
with db.tx() as conn:
conn.execute(
"INSERT INTO sessions (id, name, original_filename, video_path, status, created_at) "
"VALUES (?, ?, ?, ?, 'uploaded', ?)",
(session_id, name, safe_filename, str(video_path), db.now()),
)
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
return {"session_id": session_id, "job_id": job_id}
@router.post("/{session_id}/transcribe")
def retry_transcription(session_id: str):
"""Re-run transcription for a session, e.g. after a failed job."""
row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not row:
raise HTTPException(404, "Session not found")
session = _row_to_dict(row)
video_path = Path(session["video_path"])
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
return {"job_id": job_id}
@router.post("/upload-chunk")
async def upload_chunk(
file: UploadFile = File(...),
chunk_index: int = Form(...),
total_chunks: int = Form(...),
filename: str = Form(...),
upload_id: str = Form(...),
):
"""Assemble a large upload sent in chunks (bypasses nginx's client_max_body_size
for files bigger than that). Returns a filepath under UPLOAD_DIR that can later
be referenced when creating a session, for very large recordings."""
safe_filename = os.path.basename(filename)
temp_dir = config.UPLOAD_DIR / f"tmp_{upload_id}"
temp_dir.mkdir(parents=True, exist_ok=True)
chunk_path = temp_dir / f"chunk_{chunk_index}"
with open(chunk_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
if len(os.listdir(temp_dir)) == total_chunks:
final_file_path = _dedup_path(config.UPLOAD_DIR / safe_filename)
with open(final_file_path, "wb") as final_file:
for i in range(total_chunks):
with open(temp_dir / f"chunk_{i}", "rb") as chunk_f:
final_file.write(chunk_f.read())
shutil.rmtree(temp_dir)
return {"status": "completed", "filepath": str(final_file_path), "filename": safe_filename}
return {"status": "chunk_received", "chunk_index": chunk_index}

View File

@@ -0,0 +1,62 @@
import os
from fastapi import APIRouter
from .. import database as db, config
router = APIRouter(prefix="/api/settings", tags=["settings"])
# Settings are stored as flat key/value rows (see database.py / config.DEFAULT_SETTINGS).
# This router is a thin pass-through over that table - it must not invent its own
# schema, or the Settings/Setup pages will silently stop affecting the actual
# transcription/summarization pipeline, which reads these same flat keys.
# Env var overrides — set these in docker-compose.yml to prefill the setup wizard
_ENV_OVERRIDES = {
"hf_token": "NAT20_HF_TOKEN",
"whisper_model": "NAT20_WHISPER_MODEL",
"whisper_compute_type": "NAT20_WHISPER_COMPUTE_TYPE",
"ollama_host": "NAT20_OLLAMA_HOST",
"ollama_model": "NAT20_OLLAMA_MODEL",
"api_base_url": "NAT20_API_BASE_URL",
"api_key": "NAT20_API_KEY",
"api_model": "NAT20_API_MODEL",
"chunk_word_target": "NAT20_CHUNK_WORD_TARGET",
"world_context": "NAT20_WORLD_CONTEXT",
"world_context_path": "NAT20_WORLD_CONTEXT_PATH",
}
_BOOL_KEYS = {"onboarding_completed"}
def _coerce(key: str, value: str):
if key in _BOOL_KEYS:
return str(value).lower() in ("1", "true", "yes")
return value
def _merge_with_env(raw: dict) -> dict:
"""DB values win, env vars fill gaps, DEFAULT_SETTINGS fill remaining gaps."""
merged = {**config.DEFAULT_SETTINGS, **raw}
for key, env_name in _ENV_OVERRIDES.items():
if not raw.get(key, "").strip():
val = os.environ.get(env_name)
if val is not None:
merged[key] = val
return merged
@router.get("")
async def get_settings():
raw = db.get_settings()
merged = _merge_with_env(raw)
return {k: _coerce(k, v) for k, v in merged.items()}
@router.post("")
async def update_settings(patch: dict):
clean = {k: str(v) for k, v in patch.items() if k in config.DEFAULT_SETTINGS}
db.update_settings(clean)
raw = db.get_settings()
merged = _merge_with_env(raw)
return {k: _coerce(k, v) for k, v in merged.items()}

View File

@@ -0,0 +1,91 @@
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import database as db, config
from ..pipeline.turns import distinct_speakers, load_turns
router = APIRouter(prefix="/api/sessions/{session_id}/speakers", tags=["speakers"])
def _get_session_or_404(session_id: str):
row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not row:
raise HTTPException(404, "Session not found")
return dict(row)
@router.get("")
def get_speakers(session_id: str):
session = _get_session_or_404(session_id)
if not session["transcript_path"]:
raise HTTPException(400, "Session hasn't been transcribed yet")
speakers = distinct_speakers(Path(session["transcript_path"]))
existing = {r["raw_label"]: r["display_name"] for r in
db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))}
for sp in speakers:
sp["display_name"] = existing.get(sp["raw_label"], "")
return speakers
class SpeakerName(BaseModel):
raw_label: str
display_name: str
@router.put("")
def set_speaker_name(session_id: str, body: SpeakerName):
_get_session_or_404(session_id)
with db.tx() as conn:
conn.execute(
"INSERT INTO speakers (id, session_id, raw_label, display_name) VALUES (?, ?, ?, ?) "
"ON CONFLICT(session_id, raw_label) DO UPDATE SET display_name = excluded.display_name",
(db.new_id(), session_id, body.raw_label, body.display_name),
)
return {"ok": True}
class MergeSpeakersBody(BaseModel):
target_label: str
source_labels: list[str]
@router.post("/merge")
def merge_speakers(session_id: str, body: MergeSpeakersBody):
session = _get_session_or_404(session_id)
if not session["transcript_path"]:
raise HTTPException(400, "Session hasn't been transcribed yet")
if body.target_label in body.source_labels:
raise HTTPException(400, "Target label cannot also be a source label")
tp = Path(session["transcript_path"])
data = json.loads(tp.read_text())
changed = 0
for seg in data.get("segments", []):
if seg.get("speaker") in body.source_labels:
seg["speaker"] = body.target_label
changed += 1
tp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
with db.tx() as conn:
for label in body.source_labels:
conn.execute("DELETE FROM speakers WHERE session_id = ? AND raw_label = ?", (session_id, label))
return {"ok": True, "segments_reassigned": changed}
@router.get("/turns")
def get_turns(session_id: str):
"""Full transcript with display names applied - powers the review/playback screen."""
session = _get_session_or_404(session_id)
if not session["transcript_path"]:
raise HTTPException(400, "Session hasn't been transcribed yet")
speaker_map = {r["raw_label"]: r["display_name"] for r in
db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))
if r["display_name"]}
return load_turns(Path(session["transcript_path"]), speaker_map)

24
backend/requirements.txt Normal file
View File

@@ -0,0 +1,24 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
requests==2.32.3
# Pin torch/torchaudio explicitly to the cu121 wheels (see --extra-index-url in
# the Dockerfile). If left unpinned, `pip install whisperx` resolves its
# loose "torch>=2" constraint against whatever the newest release is
# (currently a cu128 build) - that's both a much bigger download (duplicate/
# newer CUDA libs) and known to be ABI-incompatible with the cuDNN version
# ctranslate2 (a whisperx dependency) expects on older pins. Pinning fixes
# both the image size and a real "libcudnn_ops_infer.so.8: cannot open
# shared object file" crash some torch/ctranslate2 combos hit.
torch==2.3.1
torchaudio==2.3.1
whisperx==3.3.1
ctranslate2==4.4.0
faster-whisper==1.1.0
pyannote.audio==3.3.2
nltk==3.9.1
pandas==2.2.3
transformers==4.44.2
matplotlib