Initial commit: Nat20 Notes — TTRPG session transcription & summarization

This commit is contained in:
KansaiGaijin
2026-07-06 23:45:54 +12:00
commit 0f9e9d4c5c
49 changed files with 2926 additions and 0 deletions

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)