Initial commit: Nat20 Notes — TTRPG session transcription & summarization
This commit is contained in:
10
backend/.dockerignore
Normal file
10
backend/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.git/
|
||||
.env
|
||||
52
backend/Dockerfile
Normal file
52
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
49
backend/app/config.py
Normal file
49
backend/app/config.py
Normal 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
136
backend/app/database.py
Normal 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
|
||||
73
backend/app/diagnostics.py
Normal file
73
backend/app/diagnostics.py
Normal 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
29
backend/app/errors.py
Normal 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
41
backend/app/jobs.py
Normal 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)
|
||||
18
backend/app/logging_config.py
Normal file
18
backend/app/logging_config.py
Normal 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
67
backend/app/main.py
Normal 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}
|
||||
0
backend/app/pipeline/__init__.py
Normal file
0
backend/app/pipeline/__init__.py
Normal file
35
backend/app/pipeline/audio.py
Normal file
35
backend/app/pipeline/audio.py
Normal 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
|
||||
152
backend/app/pipeline/summarize.py
Normal file
152
backend/app/pipeline/summarize.py
Normal 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
|
||||
104
backend/app/pipeline/transcribe.py
Normal file
104
backend/app/pipeline/transcribe.py
Normal 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
|
||||
60
backend/app/pipeline/turns.py
Normal file
60
backend/app/pipeline/turns.py
Normal 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)
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
11
backend/app/routers/diagnostics.py
Normal file
11
backend/app/routers/diagnostics.py
Normal 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}
|
||||
82
backend/app/routers/jobs.py
Normal file
82
backend/app/routers/jobs.py
Normal 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}
|
||||
20
backend/app/routers/models.py
Normal file
20
backend/app/routers/models.py
Normal 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": []}
|
||||
48
backend/app/routers/notes.py
Normal file
48
backend/app/routers/notes.py
Normal 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)
|
||||
190
backend/app/routers/sessions.py
Normal file
190
backend/app/routers/sessions.py
Normal 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}
|
||||
62
backend/app/routers/settings.py
Normal file
62
backend/app/routers/settings.py
Normal 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()}
|
||||
91
backend/app/routers/speakers.py
Normal file
91
backend/app/routers/speakers.py
Normal 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
24
backend/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user