Initial commit: Nat20 Notes — TTRPG session transcription & summarization
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
36
AGENTS.md
Normal file
36
AGENTS.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Nat20 Notes — agent guide
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `docker compose up -d --build` — build & launch both services (backend :8000, frontend :8020)
|
||||||
|
- `cd frontend && npm run dev` — Vite dev server (proxies `/api` → `localhost:8000`)
|
||||||
|
- **No tests, no lint/typecheck scripts exist.** Don't look for them.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Two Docker Compose services: `backend` (Python 3.11, FastAPI, SQLite, WhisperX as lib) and `frontend` (React 18 + Vite + Tailwind, served by nginx which proxies `/api` → backend).
|
||||||
|
- Data lives in Docker volume `app_data` at `/data` (SQLite DB + `uploads/audio/transcripts/notes` subdirs). No external DB.
|
||||||
|
- Background jobs (transcribe, summarize) run via in-process `ThreadPoolExecutor(max_workers=2)` — no Redis/Celery.
|
||||||
|
- Frontend serves on port **8020** (d20 nod).
|
||||||
|
|
||||||
|
## Critical convention: flat settings schema
|
||||||
|
|
||||||
|
Settings are a flat key/value table in SQLite. **Every layer must share exact same keys:**
|
||||||
|
`backend/app/config.py:DEFAULT_SETTINGS`, `frontend/src/api.ts:AppSettings`, the settings router, and Setup/Settings pages. Changing a key without updating all layers silently desyncs the UI.
|
||||||
|
|
||||||
|
## Docker / framework quirks
|
||||||
|
|
||||||
|
- Torch pinned to `2.3.1` with `cu121` wheels (`--extra-index-url https://download.pytorch.org/whl/cu121`) — cuDNN ABI compat with `ctranslate2`. Don't bump unpinned.
|
||||||
|
- Backend Dockerfile is two-stage (builder + runtime). ffmpeg required in runtime for audio extraction.
|
||||||
|
- nginx `client_max_body_size` 500M, proxy timeouts 3600s. Files >15MB auto-chunked by frontend `createSession()`.
|
||||||
|
- Frontend changes in Compose mode require a rebuild (`docker compose up -d --build`). Vite dev server is for local-only dev.
|
||||||
|
|
||||||
|
## Tailwind theme
|
||||||
|
|
||||||
|
Custom colors (`deep`, `panel`, `brass`, `ember`, etc.) and fonts (`Fraunces`, `Inter`, `IBM Plex Mono`) in `tailwind.config.js`. Reusable component classes in `frontend/src/styles.css` (`.card`, `.btn-primary`, `.btn-secondary`, `.input`, `.eyebrow`) — use these over raw Tailwind utilities.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Requires NVIDIA Container Toolkit + HuggingFace token for speaker diarization.
|
||||||
|
- First run: setup wizard, then `onboarding_completed` flag gates access to main UI.
|
||||||
|
- Default Ollama endpoint: `http://localhost:11434`.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Nat20 Notes
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
91
README.md
Normal file
91
README.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Nat20 Notes
|
||||||
|
|
||||||
|
Turn a recorded tabletop RPG session (audio or video) into two documents:
|
||||||
|
a full GM/DM session log, and a spoiler-free player recap — using local
|
||||||
|
transcription (WhisperX) and either a local LLM (Ollama) or any
|
||||||
|
OpenAI-compatible hosted API for summarization.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- An NVIDIA GPU with drivers + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed on the host (for transcription; CPU-only works but is much slower)
|
||||||
|
- A free [HuggingFace token](https://huggingface.co/settings/tokens) — needed for speaker diarization. You'll also need to accept the terms on the gated pyannote model page it links you to on first run.
|
||||||
|
- Either: [Ollama](https://ollama.com) running somewhere reachable from this app (local or LAN), **or** an API key for a hosted LLM (OpenAI, or any OpenAI-compatible provider)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <this-repo>
|
||||||
|
cd nat20-notes
|
||||||
|
# Review docker-compose.yml — see Configuration below for env vars
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://<your-server>:8020` (yes, that port's a nod to the d20) and follow the setup wizard:
|
||||||
|
|
||||||
|
1. Choose a Whisper model size based on your GPU's available VRAM (guidance shown in-app)
|
||||||
|
2. Choose local Ollama or a hosted API for summarization, and paste your HF token
|
||||||
|
3. Optionally paste campaign/world context (NPC names, places) so summaries recognize them correctly
|
||||||
|
|
||||||
|
A reference compose file using named volumes only (no host paths) is at
|
||||||
|
[`docker-compose.example.yml`](./docker-compose.example.yml).
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
1. **Upload** a recording (audio or video — video is auto-converted, audio-only files skip that step and are much faster). Files over **90 MB** are auto-chunked with 3-way concurrent uploads.
|
||||||
|
2. **Transcribe** — runs in the background. On completion you're **automatically taken** to the speaker-naming screen.
|
||||||
|
3. **Name your speakers** — a waveform-style "session reel" shows each detected speaker's segments; click any point to jump the audio there and hear who's talking, then type in their name. Use the checkboxes to **merge** speakers (e.g. when diarization over-splits one person into `SPEAKER_00`, `SPEAKER_05`, etc.).
|
||||||
|
4. **Generate notes** — click "Done naming" and confirm; notes generation starts automatically and you're taken to the job progress screen. When finished, navigate to the notes viewer.
|
||||||
|
5. **Review & tweak** — regenerate notes, rename speakers, or delete jobs/sessions from the session detail page.
|
||||||
|
|
||||||
|
## Data layout
|
||||||
|
|
||||||
|
The app stores everything under `/data` (inside the container), which by default
|
||||||
|
is a [bind mount](./docker-compose.yml) to a host path of your choice:
|
||||||
|
|
||||||
|
| Directory / File | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `audio/` | Uploaded recordings and extracted audio |
|
||||||
|
| `transcriptions/` | Per-session transcript JSON files |
|
||||||
|
| `notes/` | Generated notes (GM log + player recap) |
|
||||||
|
| `app.db` | SQLite database (sessions, speakers, settings, jobs) |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Prefilling the setup wizard
|
||||||
|
|
||||||
|
Set `NAT20_*` environment variables under the `backend` service in
|
||||||
|
`docker-compose.yml` — the wizard will pick them up as defaults:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
NAT20_HF_TOKEN: "hf_..."
|
||||||
|
NAT20_WHISPER_MODEL: medium
|
||||||
|
NAT20_OLLAMA_HOST: http://192.168.0.16:11434
|
||||||
|
NAT20_WORLD_CONTEXT_PATH: /data/campaign-context.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
See the `environment:` block in `docker-compose.yml` for the full list.
|
||||||
|
|
||||||
|
### Campaign context
|
||||||
|
|
||||||
|
You can paste context directly in the Settings page, or point to a file
|
||||||
|
inside the container using the `world_context_path` setting (or the
|
||||||
|
`NAT20_WORLD_CONTEXT_PATH` env var). The file path version is useful for
|
||||||
|
large campaign bibles that you update independently.
|
||||||
|
|
||||||
|
## Notes on hardware
|
||||||
|
|
||||||
|
Transcription is GPU-bound and by far the slowest step for long sessions.
|
||||||
|
Summarization is comparatively light — a 7-8B parameter local model is
|
||||||
|
sufficient for most groups; only step up in size if you have the VRAM
|
||||||
|
headroom after Whisper's footprint (they don't run at the same time, so
|
||||||
|
you only need enough VRAM for whichever is currently running, plus normal
|
||||||
|
system overhead from other GPU-using services).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `backend/` — FastAPI, SQLite (no external DB needed), WhisperX as a library (no nested Docker), in-process `ThreadPoolExecutor` background jobs (no Redis/Celery)
|
||||||
|
- `frontend/` — React 18 + Vite + Tailwind, served via nginx (listens on port **8020**) which proxies `/api` to the backend
|
||||||
|
|
||||||
|
Both run as standard Docker Compose services — no special orchestration needed beyond GPU passthrough for the backend.
|
||||||
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
|
||||||
33
docker-compose.example.yml
Normal file
33
docker-compose.example.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Example Docker Compose — uses a named volume so data persists across rebuilds
|
||||||
|
# without needing a specific host path. Copy to docker-compose.yml and customise.
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- app_data:/data
|
||||||
|
- hf_cache:/root/.cache/huggingface
|
||||||
|
- torch_cache:/root/.cache/torch
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8020:8020"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app_data:
|
||||||
|
hf_cache:
|
||||||
|
torch_cache:
|
||||||
44
docker-compose.yml
Normal file
44
docker-compose.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Prefill the setup wizard — uncomment and set your values:
|
||||||
|
NAT20_HF_TOKEN: "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"
|
||||||
|
NAT20_WHISPER_MODEL: medium
|
||||||
|
NAT20_WHISPER_COMPUTE_TYPE: int8
|
||||||
|
NAT20_OLLAMA_HOST: http://192.168.0.16:11434
|
||||||
|
NAT20_OLLAMA_MODEL: qwen2.5:7b
|
||||||
|
# NAT20_API_BASE_URL: https://api.openai.com/v1
|
||||||
|
# NAT20_API_KEY: ""
|
||||||
|
# NAT20_API_MODEL: gpt-4o-mini
|
||||||
|
# NAT20_CHUNK_WORD_TARGET: "2500"
|
||||||
|
# NAT20_WORLD_CONTEXT: "" # Add a string of context OR
|
||||||
|
# NAT20_WORLD_CONTEXT_PATH: /data/campaign-context.txt # Link to a document of context
|
||||||
|
volumes:
|
||||||
|
- /mnt/media/media/dnd-sessions:/data
|
||||||
|
- hf_cache:/root/.cache/huggingface
|
||||||
|
- torch_cache:/root/.cache/torch
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8020:8020"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app_data:
|
||||||
|
hf_cache:
|
||||||
|
torch_cache:
|
||||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 8020
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nat20 Notes</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-deep text-ink font-body">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
frontend/nginx.conf
Normal file
17
frontend/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
server {
|
||||||
|
listen 8020;
|
||||||
|
|
||||||
|
client_max_body_size 500M;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "session-notes-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.11",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
59
frontend/src/App.tsx
Normal file
59
frontend/src/App.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Setup from './pages/Setup';
|
||||||
|
import Sessions from './pages/Sessions';
|
||||||
|
import SessionDetail from './pages/SessionDetail';
|
||||||
|
import Speakers from './pages/Speakers';
|
||||||
|
import Notes from './pages/Notes';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
import Diagnostics from './pages/Diagnostics';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isConfigured, setIsConfigured] = useState(false);
|
||||||
|
|
||||||
|
const checkSettings = () => {
|
||||||
|
api.getSettings()
|
||||||
|
.then(data => {
|
||||||
|
setIsConfigured(!!(data && data.onboarding_completed));
|
||||||
|
})
|
||||||
|
.catch(() => setIsConfigured(false))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="min-h-screen bg-slate-900 text-white flex items-center justify-center">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* If not configured, force them to the Setup wizard */}
|
||||||
|
<Route
|
||||||
|
path="/setup"
|
||||||
|
element={!isConfigured ? <Setup onComplete={checkSettings} /> : <Navigate to="/" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* If configured, serve the main sessions workspace */}
|
||||||
|
{isConfigured ? (
|
||||||
|
<>
|
||||||
|
<Route path="/" element={<Sessions />} />
|
||||||
|
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||||
|
<Route path="/sessions/:id/speakers" element={<Speakers />} />
|
||||||
|
<Route path="/sessions/:id/notes" element={<Notes />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/diagnostics" element={<Diagnostics />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Route path="*" element={<Navigate to="/setup" />} />
|
||||||
|
)}
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
190
frontend/src/api.ts
Normal file
190
frontend/src/api.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
const BASE_URL = '/api';
|
||||||
|
|
||||||
|
// Flat settings schema - must mirror backend/app/config.py's DEFAULT_SETTINGS
|
||||||
|
// exactly. This is the single source of truth; don't reintroduce a nested
|
||||||
|
// shape here without updating config.py, routers/settings.py, Setup.tsx and
|
||||||
|
// Settings.tsx together.
|
||||||
|
export interface AppSettings {
|
||||||
|
onboarding_completed: boolean;
|
||||||
|
whisper_model: string;
|
||||||
|
whisper_compute_type: string;
|
||||||
|
hf_token: string;
|
||||||
|
llm_mode: 'ollama' | 'api';
|
||||||
|
ollama_host: string;
|
||||||
|
ollama_model: string;
|
||||||
|
api_base_url: string;
|
||||||
|
api_key: string;
|
||||||
|
api_model: string;
|
||||||
|
chunk_word_target: string;
|
||||||
|
world_context: string;
|
||||||
|
world_context_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JobStatus = 'queued' | 'running' | 'done' | 'error';
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
job_type: 'transcribe' | 'summarize';
|
||||||
|
status: JobStatus;
|
||||||
|
progress: string | null;
|
||||||
|
error: string | null;
|
||||||
|
error_stage: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteStrategy = 'all' | 'artifacts_only' | 'none';
|
||||||
|
|
||||||
|
export const jobApi = {
|
||||||
|
getJob: async (jobId: string): Promise<Job> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/jobs/${jobId}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch job');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
cancelJob: async (jobId: string): Promise<any> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/jobs/${jobId}/cancel`, { method: 'POST' });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
deleteJob: async (jobId: string, strategy: DeleteStrategy): Promise<any> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/jobs/${jobId}?strategy=${strategy}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete job');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Settings
|
||||||
|
getSettings: async (): Promise<AppSettings> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/settings`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
updateSettings: async (settings: Partial<AppSettings>): Promise<AppSettings> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
// kept as an alias since some pages call it by this name
|
||||||
|
saveSettings: async (settings: Partial<AppSettings>): Promise<AppSettings> => {
|
||||||
|
return api.updateSettings(settings);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
listSessions: async (): Promise<any[]> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
createSession: async (name: string, file: File, onProgress?: (pct: number) => void): Promise<{ session_id: string; job_id: string }> => {
|
||||||
|
const CHUNK_THRESHOLD = 90 * 1024 * 1024;
|
||||||
|
if (file.size > CHUNK_THRESHOLD) {
|
||||||
|
const result = await api.uploadFileInChunks(file, onProgress || (() => {}));
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('upload_path', result.filepath);
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
|
||||||
|
if (!res.ok) throw new Error('Failed to create session');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('file', file);
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
|
||||||
|
if (!res.ok) throw new Error('Failed to create session');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
getSession: async (sessionId: string): Promise<any> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch session');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
retryTranscription: async (sessionId: string): Promise<{ job_id: string }> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/transcribe`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error('Failed to start transcription');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
audioUrl: (sessionId: string): string => `${BASE_URL}/sessions/${sessionId}/audio`,
|
||||||
|
|
||||||
|
// Speakers
|
||||||
|
getSpeakers: async (sessionId: string): Promise<any[]> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
getTurns: async (sessionId: string): Promise<any[]> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers/turns`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
setSpeakerName: async (sessionId: string, raw_label: string, display_name: string): Promise<void> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ raw_label, display_name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to save speaker name');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
getNotes: async (sessionId: string): Promise<any> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes`);
|
||||||
|
if (!res.ok) throw new Error('Notes not generated yet');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
generateNotes: async (sessionId: string): Promise<{ job_id: string }> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes/generate`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error('Failed to start note generation');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
getDiagnostics: async (): Promise<{ ok: boolean; checks: any[] }> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/diagnostics`);
|
||||||
|
if (!res.ok) throw new Error('Failed to reach backend');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Jobs (re-exported here so pages that only import `api` still work)
|
||||||
|
deleteJob: jobApi.deleteJob,
|
||||||
|
cancelJob: jobApi.cancelJob,
|
||||||
|
getJob: jobApi.getJob,
|
||||||
|
|
||||||
|
// Large-file (chunked) upload, used automatically by createSession()
|
||||||
|
// when the file exceeds 90MB. Sends up to 3 chunks concurrently.
|
||||||
|
uploadFileInChunks: async (
|
||||||
|
file: File,
|
||||||
|
onProgress: (percent: number) => void
|
||||||
|
): Promise<any> => {
|
||||||
|
const CHUNK_SIZE = 1024 * 1024 * 15;
|
||||||
|
const CONCURRENCY = 3;
|
||||||
|
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||||
|
const uploadId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const uploadOne = async (chunkIndex: number): Promise<any> => {
|
||||||
|
const start = chunkIndex * CHUNK_SIZE;
|
||||||
|
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||||
|
const chunk = file.slice(start, end);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', chunk, file.name);
|
||||||
|
formData.append('chunk_index', chunkIndex.toString());
|
||||||
|
formData.append('total_chunks', totalChunks.toString());
|
||||||
|
formData.append('filename', file.name);
|
||||||
|
formData.append('upload_id', uploadId);
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/upload-chunk`, { method: 'POST', body: formData });
|
||||||
|
if (!res.ok) throw new Error(`Failed to upload chunk ${chunkIndex}`);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastResult: any = null;
|
||||||
|
for (let i = 0; i < totalChunks; i += CONCURRENCY) {
|
||||||
|
const batch = [];
|
||||||
|
for (let j = i; j < Math.min(i + CONCURRENCY, totalChunks); j++) {
|
||||||
|
batch.push(uploadOne(j));
|
||||||
|
}
|
||||||
|
const results = await Promise.all(batch);
|
||||||
|
lastResult = results[results.length - 1];
|
||||||
|
onProgress(Math.round((Math.min(i + CONCURRENCY, totalChunks) / totalChunks) * 100));
|
||||||
|
}
|
||||||
|
return lastResult;
|
||||||
|
},
|
||||||
|
};
|
||||||
66
frontend/src/components/SessionReel.tsx
Normal file
66
frontend/src/components/SessionReel.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
const SPEAKER_COLORS = ["#C9A227", "#5F7A61", "#4E6A87", "#7A5670", "#B4523A", "#8A8F5C"];
|
||||||
|
|
||||||
|
function colorFor(label: string, palette: Map<string, string>) {
|
||||||
|
if (!palette.has(label)) {
|
||||||
|
palette.set(label, SPEAKER_COLORS[palette.size % SPEAKER_COLORS.length]);
|
||||||
|
}
|
||||||
|
return palette.get(label)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionReel({
|
||||||
|
turns,
|
||||||
|
duration,
|
||||||
|
onSeek,
|
||||||
|
currentTime,
|
||||||
|
}: {
|
||||||
|
turns: { start: number; end: number; raw_speaker: string }[];
|
||||||
|
duration: number;
|
||||||
|
onSeek: (t: number) => void;
|
||||||
|
currentTime: number;
|
||||||
|
}) {
|
||||||
|
const palette = useMemo(() => new Map<string, string>(), []);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const valid = duration > 0 && isFinite(duration);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if (!trackRef.current || !valid) return;
|
||||||
|
const rect = trackRef.current.getBoundingClientRect();
|
||||||
|
const frac = (e.clientX - rect.left) / rect.width;
|
||||||
|
onSeek(Math.max(0, Math.min(duration, frac * duration)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const playheadPct = valid ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={"relative h-14 rounded-md border overflow-hidden " + (valid ? "bg-deep border-white/10 cursor-pointer" : "bg-panel2 border-white/5")}
|
||||||
|
role="slider"
|
||||||
|
aria-label="Session timeline"
|
||||||
|
>
|
||||||
|
{valid &&
|
||||||
|
turns.map((t, i) => {
|
||||||
|
const left = (t.start / duration) * 100;
|
||||||
|
const width = Math.max(((t.end - t.start) / duration) * 100, 0.15);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute top-1.5 bottom-1.5 rounded-sm opacity-70 hover:opacity-100 transition"
|
||||||
|
style={{ left: `${left}%`, width: `${width}%`, background: colorFor(t.raw_speaker, palette) }}
|
||||||
|
title={`${t.raw_speaker} @ ${Math.floor(t.start)}s`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-px bg-ink shadow-[0_0_6px_1px_rgba(237,230,214,0.6)]"
|
||||||
|
style={{ left: `${playheadPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { colorFor, SPEAKER_COLORS };
|
||||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
55
frontend/src/pages/Diagnostics.tsx
Normal file
55
frontend/src/pages/Diagnostics.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
ffmpeg: "ffmpeg",
|
||||||
|
gpu: "GPU / CUDA",
|
||||||
|
huggingface_token: "HuggingFace token",
|
||||||
|
llm_backend: "Summarization backend",
|
||||||
|
disk_space: "Disk space",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Diagnostics() {
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const r = await api.getDiagnostics().catch((e) => ({ ok: false, checks: [], _error: e.message }));
|
||||||
|
setResult(r);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
run();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl mx-auto py-16 px-4">
|
||||||
|
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||||
|
<h1 className="font-display text-3xl mt-4 mb-2">System check</h1>
|
||||||
|
<p className="text-ink/60 mb-6">Run this before your first session, or any time something isn't working.</p>
|
||||||
|
|
||||||
|
<button className="btn-secondary mb-6" onClick={run} disabled={loading}>
|
||||||
|
{loading ? "Checking..." : "Run checks again"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{result?._error && (
|
||||||
|
<div className="card p-4 text-ember">Couldn't reach the backend: {result._error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result?.checks?.map((c: any) => (
|
||||||
|
<div key={c.name} className="card p-4 flex gap-3">
|
||||||
|
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${c.ok ? "bg-moss" : "bg-ember"}`} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{LABELS[c.name] || c.name}</div>
|
||||||
|
<div className="text-sm text-ink/60 whitespace-pre-wrap">{c.message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/pages/Notes.tsx
Normal file
35
frontend/src/pages/Notes.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
export default function Notes() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [notes, setNotes] = useState<any>(null);
|
||||||
|
const [tab, setTab] = useState<"dm" | "player">("dm");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getNotes(id!).then(setNotes);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!notes) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto py-16 px-4">
|
||||||
|
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||||
|
<h1 className="font-display text-3xl mt-4 mb-6">Session notes</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<button className={tab === "dm" ? "btn-primary" : "btn-secondary"} onClick={() => setTab("dm")}>
|
||||||
|
DM notes
|
||||||
|
</button>
|
||||||
|
<button className={tab === "player" ? "btn-primary" : "btn-secondary"} onClick={() => setTab("player")}>
|
||||||
|
Player recap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-6 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{tab === "dm" ? notes.dm_notes : notes.player_recap}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
frontend/src/pages/SessionDetail.tsx
Normal file
257
frontend/src/pages/SessionDetail.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { api, Job } from '../api';
|
||||||
|
|
||||||
|
function Toast({ message, onDone }: { message: string; onDone: () => void }) {
|
||||||
|
useEffect(() => { const t = setTimeout(onDone, 2500); return () => clearTimeout(t); }, [onDone]);
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-emerald-700 text-white px-5 py-3 rounded-xl shadow-2xl text-sm font-medium">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElapsedTimer({ createdAt, endedAt }: { createdAt: number; endedAt?: number }) {
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (endedAt !== undefined) {
|
||||||
|
setElapsed(Math.floor(endedAt - createdAt));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tick = () => setElapsed(Math.floor(Date.now() / 1000 - createdAt));
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [createdAt, endedAt]);
|
||||||
|
return <span className="font-mono text-xs">{formatDuration(elapsed)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
uploaded: 'Ready to transcribe',
|
||||||
|
transcribing: 'Transcribing...',
|
||||||
|
transcribed: 'Ready to name speakers',
|
||||||
|
complete: 'Notes ready',
|
||||||
|
};
|
||||||
|
|
||||||
|
function JobStatusCard({ job, session, onRefresh, onDeleted }: { job: Job; session?: any; onRefresh: () => void; onDeleted: (strategy: string) => void }) {
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!confirm('Are you sure you want to halt this job midway?')) return;
|
||||||
|
setBusy(true);
|
||||||
|
await api.cancelJob(job.id);
|
||||||
|
onRefresh();
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeDeletion = async (strategy: 'all' | 'artifacts_only' | 'none') => {
|
||||||
|
setBusy(true);
|
||||||
|
await api.deleteJob(job.id, strategy);
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setBusy(false);
|
||||||
|
onDeleted(strategy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeClass =
|
||||||
|
job.status === 'done'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
|
||||||
|
: job.status === 'error'
|
||||||
|
? 'bg-rose-500/20 text-rose-400 border border-rose-500/30'
|
||||||
|
: 'bg-amber-500/20 text-amber-400 border border-amber-500/30 animate-pulse';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-lg">{job.job_type === 'transcribe' ? 'Transcription' : 'Note generation'}</h3>
|
||||||
|
<span className={`px-2.5 py-1 text-xs font-bold rounded-full uppercase ${badgeClass}`}>
|
||||||
|
{job.status === 'error' ? 'Failed' : job.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.status === 'error' ? (
|
||||||
|
<div className="bg-rose-950/40 border border-rose-900/50 text-rose-300 text-sm p-3 rounded mb-3 font-mono">
|
||||||
|
<strong>Error{job.error_stage ? ` [${job.error_stage}]` : ''}:</strong> {job.error || 'Unexpected error.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-ink/60 mb-3">{job.progress || 'Waiting...'}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.status !== 'queued' && job.created_at && (
|
||||||
|
<div className="flex items-center gap-1 text-ink/40 mb-3">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<ElapsedTimer
|
||||||
|
createdAt={job.created_at}
|
||||||
|
endedAt={job.status === 'done' || job.status === 'error' ? job.updated_at : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session?.audio_duration && (
|
||||||
|
<div className="text-xs text-ink/40 mb-3 font-mono">
|
||||||
|
Audio {formatDuration(session.audio_duration)}
|
||||||
|
{session.word_count ? ` \u2022 ${session.word_count.toLocaleString()} words` : ''}
|
||||||
|
{session.language ? ` \u2022 ${session.language}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{(job.status === 'queued' || job.status === 'running') && (
|
||||||
|
<button disabled={busy} onClick={handleCancel} className="btn-secondary text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{job.status === 'error' && job.job_type === 'transcribe' && (
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => { setBusy(true); await api.retryTranscription(job.session_id); setBusy(false); onRefresh(); }}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
Restart transcription
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button disabled={busy} onClick={() => setShowDeleteModal(true)} className="btn-secondary text-sm ml-auto">
|
||||||
|
Delete...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDeleteModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-panel border border-white/10 max-w-md w-full rounded-xl p-6 shadow-2xl">
|
||||||
|
<h4 className="text-lg font-bold mb-2">Clean up session data?</h4>
|
||||||
|
<p className="text-sm text-ink/60 mb-6 leading-relaxed">
|
||||||
|
Choose what to remove along with this job's tracking record.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button onClick={() => executeDeletion('all')} className="w-full text-left card p-3 text-sm">
|
||||||
|
🗑️ <strong>Wipe everything</strong>
|
||||||
|
<span className="block text-xs text-ink/40 mt-0.5">Session record, notes, transcript, and the source audio/video.</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => executeDeletion('artifacts_only')} className="w-full text-left card p-3 text-sm">
|
||||||
|
📝 <strong>Keep source file</strong>
|
||||||
|
<span className="block text-xs text-ink/40 mt-0.5">Clears notes/transcript but keeps the original recording.</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => executeDeletion('none')} className="w-full text-left card p-3 text-sm">
|
||||||
|
❌ <strong>Just clear this job</strong>
|
||||||
|
<span className="block text-xs text-ink/40 mt-0.5">Only removes the job tracking row. No files touched.</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end">
|
||||||
|
<button onClick={() => setShowDeleteModal(false)} className="text-sm text-ink/50 hover:text-ink">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [session, setSession] = useState<any>(null);
|
||||||
|
const [job, setJob] = useState<Job | null>(null);
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!id) return;
|
||||||
|
api.getSession(id).then((s) => {
|
||||||
|
setSession(s);
|
||||||
|
setJob(s.latest_job || null);
|
||||||
|
});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
// Poll while a job is actively running so status/progress stay live.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!job || (job.status !== 'queued' && job.status !== 'running')) return;
|
||||||
|
const interval = setInterval(load, 2500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [job, load]);
|
||||||
|
|
||||||
|
// Preload audio as soon as transcription finishes so it's buffered for the speakers page
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.status !== 'transcribed' || !id) return;
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'preload';
|
||||||
|
link.as = 'audio';
|
||||||
|
link.href = api.audioUrl(id);
|
||||||
|
document.head.appendChild(link);
|
||||||
|
return () => link.remove();
|
||||||
|
}, [session?.status, id]);
|
||||||
|
|
||||||
|
// Auto-redirect to speakers page once transcription completes
|
||||||
|
const autoRedirected = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.status === 'transcribed') {
|
||||||
|
if (!autoRedirected.current) {
|
||||||
|
autoRedirected.current = true;
|
||||||
|
nav(`/sessions/${id}/speakers`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
autoRedirected.current = false;
|
||||||
|
}
|
||||||
|
}, [session?.status, id, nav]);
|
||||||
|
|
||||||
|
const retryTranscription = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
await api.retryTranscription(id);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateNotes = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
await api.generateNotes(id);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-16 px-4">
|
||||||
|
{toast && <Toast message={toast} onDone={() => setToast(null)} />}
|
||||||
|
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||||
|
<h1 className="font-display text-3xl mt-4 mb-1">{session.name}</h1>
|
||||||
|
<p className="text-ink/40 font-mono text-xs mb-6">{session.original_filename}</p>
|
||||||
|
<p className="text-brass mb-8">{STATUS_LABEL[session.status] ?? session.status}</p>
|
||||||
|
|
||||||
|
{job && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<JobStatusCard job={job} session={session} onRefresh={load} onDeleted={(strategy) => {
|
||||||
|
if (strategy === 'all') {
|
||||||
|
setToast('Session wiped');
|
||||||
|
setTimeout(() => nav('/'), 600);
|
||||||
|
} else if (strategy === 'artifacts_only') {
|
||||||
|
setToast('Transcript and notes cleared — source file kept');
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
setToast('Job record removed');
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.status === 'uploaded' && !job && (
|
||||||
|
<button className="btn-primary" onClick={retryTranscription}>Start transcription</button>
|
||||||
|
)}
|
||||||
|
{session.status === 'complete' && (
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<button className="btn-secondary" onClick={generateNotes}>Regenerate notes</button>
|
||||||
|
<Link to={`/sessions/${id}/notes`} className="btn-primary">View notes</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
frontend/src/pages/Sessions.tsx
Normal file
95
frontend/src/pages/Sessions.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
uploaded: "Ready to transcribe",
|
||||||
|
transcribing: "Transcribing...",
|
||||||
|
transcribed: "Ready to name speakers",
|
||||||
|
complete: "Notes ready",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Sessions() {
|
||||||
|
const [sessions, setSessions] = useState<any[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
const refresh = useCallback(() => api.listSessions().then(setSessions), []);
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Poll while any session is actively processing
|
||||||
|
useEffect(() => {
|
||||||
|
const hasActive = sessions.some(s => s.status === 'transcribing');
|
||||||
|
if (!hasActive) return;
|
||||||
|
const interval = setInterval(refresh, 2500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [sessions, refresh]);
|
||||||
|
|
||||||
|
const upload = async () => {
|
||||||
|
if (!file || !name) return;
|
||||||
|
setUploadProgress(0);
|
||||||
|
const { session_id } = await api.createSession(name, file, setUploadProgress);
|
||||||
|
setUploadProgress(null);
|
||||||
|
nav(`/sessions/${session_id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto py-16 px-4">
|
||||||
|
<div className="eyebrow mb-2">Nat20 Notes</div>
|
||||||
|
<h1 className="font-display text-4xl mb-8">Your Transcriptions</h1>
|
||||||
|
|
||||||
|
<div className="card p-6 mb-8">
|
||||||
|
<h2 className="font-display text-xl mb-4">New transcription</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Session name, e.g. Session 1 — The Frozen Crypt"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="audio/*,video/*"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="text-sm text-ink/70"
|
||||||
|
/>
|
||||||
|
<button className="btn-primary self-start" disabled={!file || !name || uploadProgress !== null} onClick={upload}>
|
||||||
|
{uploadProgress !== null ? `Uploading ${uploadProgress}%` : "Upload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<p className="text-ink/40">No sessions yet — upload a recording to get started.</p>
|
||||||
|
)}
|
||||||
|
{sessions.map((s) => {
|
||||||
|
const job = s.latest_job;
|
||||||
|
const progress = job?.progress;
|
||||||
|
const displayStatus = s.status === 'transcribing' && progress
|
||||||
|
? progress
|
||||||
|
: (STATUS_LABEL[s.status] ?? s.status);
|
||||||
|
const statusColor = job?.status === 'error' ? 'text-rose-400' : 'text-brass';
|
||||||
|
return (
|
||||||
|
<Link key={s.id} to={`/sessions/${s.id}`} className="card p-4 flex items-center justify-between hover:border-brass/40 border border-transparent block">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{s.name}</div>
|
||||||
|
<div className="text-xs text-ink/40 font-mono">{s.original_filename}</div>
|
||||||
|
</div>
|
||||||
|
<div className={"text-sm " + statusColor}>{displayStatus}</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex gap-4">
|
||||||
|
<Link to="/diagnostics" className="text-sm text-ink/40 hover:text-brass">System check</Link>
|
||||||
|
<Link to="/settings" className="text-sm text-ink/40 hover:text-brass">Settings</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/pages/Settings.tsx
Normal file
68
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const [settings, setSettings] = useState<any>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(setSettings);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
const set = (k: string, v: any) => setSettings({ ...settings, [k]: v });
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
await api.updateSettings(settings);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl mx-auto py-16 px-4">
|
||||||
|
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||||
|
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
|
||||||
|
|
||||||
|
<div className="card p-6 space-y-4 mb-6">
|
||||||
|
<h2 className="eyebrow">Transcription</h2>
|
||||||
|
<select className="input w-full" value={settings.whisper_model} onChange={(e) => set("whisper_model", e.target.value)}>
|
||||||
|
<option value="tiny">Tiny</option>
|
||||||
|
<option value="small">Small</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="large-v3">Large v3</option>
|
||||||
|
</select>
|
||||||
|
<input className="input w-full" type="password" placeholder="HuggingFace token" value={settings.hf_token} onChange={(e) => set("hf_token", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-6 space-y-4 mb-6">
|
||||||
|
<h2 className="eyebrow">Summarization</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className={settings.llm_mode === "ollama" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "ollama")}>Local (Ollama)</button>
|
||||||
|
<button className={settings.llm_mode === "api" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "api")}>Hosted API</button>
|
||||||
|
</div>
|
||||||
|
{settings.llm_mode === "ollama" ? (
|
||||||
|
<>
|
||||||
|
<input className="input w-full" value={settings.ollama_host} onChange={(e) => set("ollama_host", e.target.value)} />
|
||||||
|
<input className="input w-full" value={settings.ollama_model} onChange={(e) => set("ollama_model", e.target.value)} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input className="input w-full" value={settings.api_base_url} onChange={(e) => set("api_base_url", e.target.value)} />
|
||||||
|
<input className="input w-full" type="password" value={settings.api_key} onChange={(e) => set("api_key", e.target.value)} />
|
||||||
|
<input className="input w-full" value={settings.api_model} onChange={(e) => set("api_model", e.target.value)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-6 space-y-3 mb-6">
|
||||||
|
<h2 className="eyebrow">Campaign context</h2>
|
||||||
|
<textarea className="input w-full h-32" value={settings.world_context} onChange={(e) => set("world_context", e.target.value)} placeholder="Paste world context directly..." />
|
||||||
|
<input className="input w-full" value={settings.world_context_path} onChange={(e) => set("world_context_path", e.target.value)} placeholder="...or path to a file inside the container" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
frontend/src/pages/Setup.tsx
Normal file
205
frontend/src/pages/Setup.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api, AppSettings } from '../api';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
onboarding_completed: false,
|
||||||
|
whisper_model: 'medium',
|
||||||
|
whisper_compute_type: 'int8',
|
||||||
|
hf_token: '',
|
||||||
|
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',
|
||||||
|
chunk_word_target: '2500',
|
||||||
|
world_context: '',
|
||||||
|
world_context_path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Setup({ onComplete }: { onComplete: () => void }) {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(data => {
|
||||||
|
if (data) {
|
||||||
|
setSettings({ ...DEFAULT_SETTINGS, ...data });
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to connect to settings endpoint:', err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNext = () => setStep(prev => prev + 1);
|
||||||
|
const handleBack = () => setStep(prev => prev - 1);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await api.saveSettings({ ...settings, onboarding_completed: true });
|
||||||
|
onComplete();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-900 text-slate-100 flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-2xl bg-slate-800 border border-slate-700 rounded-xl p-8 shadow-2xl">
|
||||||
|
<div className="mb-8 flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-teal-400">Nat20Notes Configuration Wizard</h1>
|
||||||
|
<span className="text-sm text-slate-400 font-mono">Step {step} of 2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">1. Audio & Diarization Engine</h2>
|
||||||
|
<p className="text-slate-400 text-sm mb-4">Set up your local Whisper sizing and secure deep speaker parsing models.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Whisper Model Size</label>
|
||||||
|
<select
|
||||||
|
value={settings.whisper_model}
|
||||||
|
onChange={e => setSettings({ ...settings, whisper_model: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="tiny">Tiny (~39M params)</option>
|
||||||
|
<option value="base">Base (~74M params)</option>
|
||||||
|
<option value="small">Small (~244M params)</option>
|
||||||
|
<option value="medium">Medium (~769M params)</option>
|
||||||
|
<option value="large-v3">Large V3 (~1.5B params - Recommended GPU)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 p-4 rounded border border-slate-700 space-y-3">
|
||||||
|
<label className="block text-sm font-medium">Hugging Face Access Token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="hf_..."
|
||||||
|
value={settings.hf_token}
|
||||||
|
onChange={e => setSettings({ ...settings, hf_token: e.target.value })}
|
||||||
|
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white font-mono"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-slate-400 leading-relaxed">
|
||||||
|
💡 <strong>Diarization Requirements:</strong> To differentiate between multiple speakers, you must register a token:
|
||||||
|
<ol className="list-decimal list-inside ml-2 mt-1 space-y-1">
|
||||||
|
<li>Create an account on <a href="https://huggingface.co" target="_blank" rel="noreferrer" className="text-teal-400 underline">huggingface.co</a></li>
|
||||||
|
<li>Accept licensing conditions for <a href="https://huggingface.co/pyannote/speaker-diarization-3.1" target="_blank" rel="noreferrer" className="text-teal-400 underline">pyannote/speaker-diarization-3.1</a></li>
|
||||||
|
<li>Accept licensing conditions for <a href="https://huggingface.co/pyannote/segmentation-3.0" target="_blank" rel="noreferrer" className="text-teal-400 underline">pyannote/segmentation-3.0</a></li>
|
||||||
|
<li>Generate a <strong>Read</strong> token in Settings → Access Tokens.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<button onClick={handleNext} className="bg-teal-600 hover:bg-teal-500 text-white px-5 py-2 rounded font-medium">
|
||||||
|
Continue Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">2. Summarization Engine (LLM)</h2>
|
||||||
|
<p className="text-slate-400 text-sm mb-4">Choose where your post-transcription formatting and smart summaries are generated.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 border-b border-slate-700 pb-4">
|
||||||
|
{(['ollama', 'api'] as const).map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => setSettings({ ...settings, llm_mode: mode })}
|
||||||
|
className={`px-4 py-2 rounded uppercase text-xs font-bold tracking-wider ${settings.llm_mode === mode ? 'bg-teal-600 text-white' : 'bg-slate-900 text-slate-400'}`}
|
||||||
|
>
|
||||||
|
{mode === 'ollama' ? 'Local (Ollama)' : 'Hosted API'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.llm_mode === 'ollama' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Ollama Host Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.ollama_host}
|
||||||
|
onChange={e => setSettings({ ...settings, ollama_host: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Model Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="qwen2.5:7b"
|
||||||
|
value={settings.ollama_model}
|
||||||
|
onChange={e => setSettings({ ...settings, ollama_model: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Must match your local CLI entry for running "ollama pull <model>".</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">API Base URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.api_base_url}
|
||||||
|
onChange={e => setSettings({ ...settings, api_base_url: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={settings.api_key}
|
||||||
|
onChange={e => setSettings({ ...settings, api_key: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono text-white"
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Model Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.api_model}
|
||||||
|
onChange={e => setSettings({ ...settings, api_model: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700 pt-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-300">Campaign context (optional)</h3>
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 h-24"
|
||||||
|
placeholder="Paste world/campaign context directly..."
|
||||||
|
value={settings.world_context}
|
||||||
|
onChange={e => setSettings({ ...settings, world_context: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono text-sm"
|
||||||
|
placeholder="...or point to a local file"
|
||||||
|
value={settings.world_context_path}
|
||||||
|
onChange={e => setSettings({ ...settings, world_context_path: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4 border-t border-slate-700">
|
||||||
|
<button onClick={handleBack} className="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded font-medium">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSave} className="bg-emerald-600 hover:bg-emerald-500 text-white px-5 py-2 rounded font-medium">
|
||||||
|
Complete Setup & Launch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
frontend/src/pages/Speakers.tsx
Normal file
181
frontend/src/pages/Speakers.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { SessionReel, colorFor } from "../components/SessionReel";
|
||||||
|
|
||||||
|
export default function Speakers() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [speakers, setSpeakers] = useState<any[]>([]);
|
||||||
|
const [turns, setTurns] = useState<any[]>([]);
|
||||||
|
const [names, setNames] = useState<Record<string, string>>({});
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [mergeTarget, setMergeTarget] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const paletteRef = useRef(new Map<string, string>());
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
api.getSpeakers(id!).then((sp) => {
|
||||||
|
setSpeakers(sp);
|
||||||
|
const n: Record<string, string> = {};
|
||||||
|
sp.forEach((s: any) => (n[s.raw_label] = s.display_name || ""));
|
||||||
|
setNames(n);
|
||||||
|
});
|
||||||
|
api.getTurns(id!).then(setTurns);
|
||||||
|
};
|
||||||
|
useEffect(load, [id]);
|
||||||
|
|
||||||
|
const save = async (raw_label: string) => {
|
||||||
|
await api.setSpeakerName(id!, raw_label, names[raw_label] || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const playAt = (t: number) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.readyState < 2) return;
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = t;
|
||||||
|
const p = audio.play();
|
||||||
|
if (p) p.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelected = (label: string) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(label)) next.delete(label); else next.add(label);
|
||||||
|
setSelected(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doMerge = async () => {
|
||||||
|
if (!mergeTarget || selected.size < 2) return;
|
||||||
|
setBusy(true);
|
||||||
|
const sourceLabels = [...selected].filter(l => l !== mergeTarget);
|
||||||
|
await fetch(`/api/sessions/${id}/speakers/merge`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ target_label: mergeTarget, source_labels: sourceLabels }),
|
||||||
|
});
|
||||||
|
setSelected(new Set());
|
||||||
|
setMergeTarget("");
|
||||||
|
setBusy(false);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
await api.generateNotes(id!);
|
||||||
|
setBusy(false);
|
||||||
|
nav(`/sessions/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedArr = [...selected];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-16 px-4">
|
||||||
|
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||||
|
<h1 className="font-display text-3xl mt-4 mb-2">Name your speakers</h1>
|
||||||
|
<p className="text-ink/60 mb-6">Click anywhere on the reel to jump to that moment and hear who's talking.</p>
|
||||||
|
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={api.audioUrl(id!)}
|
||||||
|
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||||
|
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||||
|
controls
|
||||||
|
className="w-full mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<SessionReel turns={turns} duration={duration} currentTime={currentTime} onSeek={playAt} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.size >= 2 && (
|
||||||
|
<div className="card p-4 mb-6 flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-sm text-ink/60">{selected.size} selected</span>
|
||||||
|
<select
|
||||||
|
className="input text-sm flex-1 min-w-[200px]"
|
||||||
|
value={mergeTarget}
|
||||||
|
onChange={(e) => setMergeTarget(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Merge into...</option>
|
||||||
|
{selectedArr.map((l) => (
|
||||||
|
<option key={l} value={l}>{l} {names[l] ? `(${names[l]})` : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="btn-primary text-sm" disabled={!mergeTarget || busy} onClick={doMerge}>
|
||||||
|
{busy ? "Merging..." : "Merge"}
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary text-sm" onClick={() => { setSelected(new Set()); setMergeTarget(""); }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{speakers.map((sp) => (
|
||||||
|
<div key={sp.raw_label} className={"card p-4 " + (selected.has(sp.raw_label) ? "ring-2 ring-brass" : "")}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(sp.raw_label)}
|
||||||
|
onChange={() => toggleSelected(sp.raw_label)}
|
||||||
|
className="accent-brass"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full inline-block flex-shrink-0"
|
||||||
|
style={{ background: colorFor(sp.raw_label, paletteRef.current) }}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs text-ink/40">{sp.raw_label}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="input w-full mb-3"
|
||||||
|
placeholder="Player or character name"
|
||||||
|
value={names[sp.raw_label] || ""}
|
||||||
|
onChange={(e) => setNames({ ...names, [sp.raw_label]: e.target.value })}
|
||||||
|
onBlur={() => save(sp.raw_label)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sp.samples.map((s: any, i: number) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => playAt(s.start)}
|
||||||
|
className="block text-left text-sm text-ink/60 hover:text-ink w-full truncate"
|
||||||
|
>
|
||||||
|
▶ {s.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<button className="btn-primary" onClick={() => setShowConfirm(true)}>
|
||||||
|
Done naming
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-panel border border-white/10 max-w-md w-full rounded-xl p-6 shadow-2xl">
|
||||||
|
<h4 className="text-lg font-bold mb-2">Generate notes now?</h4>
|
||||||
|
<p className="text-sm text-ink/60 mb-6 leading-relaxed">
|
||||||
|
This will use your speaker names to create session notes. Are you sure?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button disabled={busy} onClick={handleDone} className="btn-primary">
|
||||||
|
{busy ? "Starting..." : "Yes, generate notes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/styles.css
Normal file
32
frontend/src/styles.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
::selection {
|
||||||
|
background: #C9A227;
|
||||||
|
color: #14171F;
|
||||||
|
}
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid #C9A227;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.card {
|
||||||
|
@apply bg-panel border border-white/5 rounded-lg;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-brass text-deep font-medium px-4 py-2 rounded-md hover:brightness-110 transition disabled:opacity-40 disabled:pointer-events-none;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-panel2 text-ink font-medium px-4 py-2 rounded-md border border-white/10 hover:border-brass/50 transition;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply bg-deep border border-white/10 rounded-md px-3 py-2 text-ink placeholder:text-ink/30 focus:border-brass/60;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
@apply font-mono text-xs uppercase tracking-widest text-brass/80;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/tailwind.config.js
Normal file
26
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
deep: "#14171F",
|
||||||
|
panel: "#1E2230",
|
||||||
|
panel2: "#262B3D",
|
||||||
|
ink: "#EDE6D6",
|
||||||
|
inkdim: "#A9A4956e".slice(0, 7),
|
||||||
|
brass: "#C9A227",
|
||||||
|
ember: "#B4523A",
|
||||||
|
moss: "#5F7A61",
|
||||||
|
dusk: "#4E6A87",
|
||||||
|
plum: "#7A5670",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
display: ["Fraunces", "Georgia", "serif"],
|
||||||
|
body: ["Inter", "system-ui", "sans-serif"],
|
||||||
|
mono: ["IBM Plex Mono", "monospace"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user