per-campaign settings, default campaign folders, custom prompt fix
All checks were successful
Build and Push / build (push) Successful in 12m10s

This commit is contained in:
KansaiGaijin
2026-07-21 17:46:37 +12:00
parent 0daca2f208
commit ca13989ca0
14 changed files with 181 additions and 99 deletions

View File

@@ -13,10 +13,12 @@
- 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
## Critical convention: per-campaign 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.
Settings are split into **global** (`settings` table, just `onboarding_completed`) and **per-campaign** (`campaign_settings` table, everything else). Every layer must share the exact same keys:
`backend/app/config.py:CAMPAIGN_SETTINGS`, `frontend/src/api.ts:CampaignSettings`, campaigns router, and Settings/Setup pages. New campaigns inherit settings from the "default" campaign on creation.
The settings router (`/api/settings`) only handles global; campaign settings live at `/api/campaigns/{id}/settings`.
When adding a setting key, add it to `config.py:CAMPAIGN_SETTINGS`, the frontend type, and both the backend router and frontend Settings page.
## Docker / framework quirks

View File

@@ -31,16 +31,15 @@ def campaign_notes_dir(campaign_id: str) -> Path:
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
# Runtime-editable global settings (stored in the `settings` table).
# Only onboarding state lives here; everything else is per-campaign.
GLOBAL_SETTINGS = {
"onboarding_completed": "false",
}
# Per-campaign settings — seeded into campaign_settings on creation.
# Every layer (frontend, router, pipeline) reads these from the campaign.
CAMPAIGN_SETTINGS = {
# Transcription
"whisper_model": "medium", # tiny|base|small|medium|large-v3 - user picks based on their hardware
"whisper_compute_type": "int8",

View File

@@ -6,6 +6,7 @@ import uuid
from contextlib import contextmanager
from . import config
from .config import campaign_audio_dir, campaign_transcript_dir, campaign_notes_dir
_local = threading.local()
@@ -94,6 +95,14 @@ def init_db():
created_at REAL NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS campaign_settings (
campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
UNIQUE(campaign_id, key)
)
""")
try:
conn.execute("ALTER TABLE sessions ADD COLUMN campaign_id TEXT REFERENCES campaigns(id) ON DELETE SET NULL")
except Exception:
@@ -105,13 +114,30 @@ def init_db():
("default", "Default Campaign", now()),
)
conn.execute("UPDATE sessions SET campaign_id = 'default' WHERE campaign_id IS NULL")
campaign_audio_dir("default").mkdir(parents=True, exist_ok=True)
campaign_transcript_dir("default").mkdir(parents=True, exist_ok=True)
campaign_notes_dir("default").mkdir(parents=True, exist_ok=True)
# 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:
# seed global settings (just onboarding_completed)
existing_global = {r["key"] for r in conn.execute("SELECT key FROM settings")}
for k, v in config.GLOBAL_SETTINGS.items():
if k not in existing_global:
conn.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (k, v))
# seed default campaign settings from CAMPAIGN_SETTINGS (migrate old global rows)
existing_cs = {r["key"] for r in conn.execute(
"SELECT key FROM campaign_settings WHERE campaign_id = 'default'"
)}
for k, v in config.CAMPAIGN_SETTINGS.items():
if k not in existing_cs:
# If this key was previously stored in global settings, carry it over
old_row = conn.execute("SELECT value FROM settings WHERE key = ?", (k,)).fetchone()
val = old_row["value"] if old_row else v
conn.execute(
"INSERT INTO campaign_settings (campaign_id, key, value) VALUES (?, ?, ?)",
("default", k, val),
)
def get_settings() -> dict:
conn = get_conn()
@@ -122,6 +148,8 @@ def get_settings() -> dict:
def update_settings(patch: dict):
with tx() as conn:
for k, v in patch.items():
if k not in config.GLOBAL_SETTINGS:
continue
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
@@ -129,6 +157,24 @@ def update_settings(patch: dict):
)
def get_campaign_settings(campaign_id: str) -> dict:
conn = get_conn()
rows = conn.execute(
"SELECT key, value FROM campaign_settings WHERE campaign_id = ?", (campaign_id,)
).fetchall()
return {r["key"]: r["value"] for r in rows}
def update_campaign_settings(campaign_id: str, patch: dict):
with tx() as conn:
for k, v in patch.items():
conn.execute(
"INSERT INTO campaign_settings (campaign_id, key, value) VALUES (?, ?, ?) "
"ON CONFLICT(campaign_id, key) DO UPDATE SET value=excluded.value",
(campaign_id, k, str(v)),
)
def new_id() -> str:
return uuid.uuid4().hex[:12]
@@ -168,7 +214,17 @@ def create_campaign(name: str, description: str = "") -> dict:
"INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, ?, ?)",
(campaign_id, name, description, now()),
)
from .config import campaign_audio_dir, campaign_transcript_dir, campaign_notes_dir
# Inherit CAMPAIGN_SETTINGS from the "default" campaign as a starting point
parent = conn.execute(
"SELECT key, value FROM campaign_settings WHERE campaign_id = 'default'"
).fetchall()
inherited = {r["key"]: r["value"] for r in parent}
for k, v in config.CAMPAIGN_SETTINGS.items():
val = inherited.get(k, v)
conn.execute(
"INSERT INTO campaign_settings (campaign_id, key, value) VALUES (?, ?, ?)",
(campaign_id, k, val),
)
campaign_audio_dir(campaign_id).mkdir(parents=True, exist_ok=True)
campaign_transcript_dir(campaign_id).mkdir(parents=True, exist_ok=True)
campaign_notes_dir(campaign_id).mkdir(parents=True, exist_ok=True)

View File

@@ -26,16 +26,16 @@ def _check_gpu() -> dict:
return {"name": "gpu", "ok": False, "message": f"Could not query GPU: {e}"}
def _check_hf_token() -> dict:
settings = db.get_settings()
def _check_hf_token(campaign_id: str = "default") -> dict:
settings = db.get_campaign_settings(campaign_id)
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()
def _check_llm_backend(campaign_id: str = "default") -> dict:
settings = db.get_campaign_settings(campaign_id)
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."}
@@ -63,11 +63,11 @@ def _check_disk_space() -> dict:
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]:
def run_diagnostics(campaign_id: str = "default") -> list[dict]:
return [
_check_ffmpeg(),
_check_gpu(),
_check_hf_token(),
_check_llm_backend(),
_check_hf_token(campaign_id),
_check_llm_backend(campaign_id),
_check_disk_space(),
]

View File

@@ -144,7 +144,7 @@ def make_llm_caller(settings: dict):
def summarize_session(turns: list[dict], settings: dict, progress_cb=None,
player_recap_style: str | None = None,
player_recap_custom_prompt: str | None = None) -> tuple[str, str]:
player_recap_custom_prompt: str | None = None) -> tuple[str, str, str]:
call = make_llm_caller(settings)
ctx_path = settings.get("world_context_path") or ""
if ctx_path and Path(ctx_path).exists():
@@ -175,5 +175,9 @@ def summarize_session(turns: list[dict], settings: dict, progress_cb=None,
player_template = raw if raw else PLAYER_FINAL_PROMPTS["story"]
else:
player_template = PLAYER_FINAL_PROMPTS.get(style, PLAYER_FINAL_PROMPTS["story"])
player_final = call(player_template.format(summaries="\n\n".join(player_summaries)))
return dm_final, player_final
summaries_text = "\n\n".join(player_summaries)
if "{summaries}" in player_template:
player_final = call(player_template.format(summaries=summaries_text))
else:
player_final = call(f"{player_template}\n\nSession excerpts:\n{summaries_text}")
return dm_final, player_final, player_template

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, HTTPException
from .. import database as db
from .. import database as db, config
router = APIRouter(prefix="/api/campaigns", tags=["campaigns"])
@@ -45,3 +45,24 @@ def delete_campaign(campaign_id: str):
raise HTTPException(404, "Campaign not found")
db.delete_campaign(campaign_id)
return {"ok": True}
@router.get("/{campaign_id}/settings")
def get_campaign_settings(campaign_id: str):
if not db.get_campaign(campaign_id):
raise HTTPException(404, "Campaign not found")
raw = db.get_campaign_settings(campaign_id)
# return defaults for any missing keys
merged = {**config.CAMPAIGN_SETTINGS, **raw}
return merged
@router.post("/{campaign_id}/settings")
def update_campaign_settings(campaign_id: str, body: dict):
if not db.get_campaign(campaign_id):
raise HTTPException(404, "Campaign not found")
clean = {k: str(v) for k, v in body.items() if k in config.CAMPAIGN_SETTINGS}
db.update_campaign_settings(campaign_id, clean)
raw = db.get_campaign_settings(campaign_id)
merged = {**config.CAMPAIGN_SETTINGS, **raw}
return merged

View File

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

View File

@@ -1,5 +1,5 @@
import requests
from fastapi import APIRouter
from fastapi import APIRouter, Query
from .. import database as db
@@ -7,10 +7,10 @@ router = APIRouter(prefix="/api/models", tags=["models"])
@router.get("/ollama")
def list_ollama_models():
def list_ollama_models(campaign_id: str = Query("default")):
"""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()
settings = db.get_campaign_settings(campaign_id)
host = settings.get("ollama_host", "http://host.docker.internal:11434")
try:
resp = requests.get(f"{host.rstrip('/')}/api/tags", timeout=5)

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Request
from .. import database as db, jobs
from ..pipeline.turns import load_turns
from ..pipeline.summarize import summarize_session, PLAYER_FINAL_PROMPTS
from ..pipeline.summarize import summarize_session
router = APIRouter(prefix="/api/sessions/{session_id}/notes", tags=["notes"])
@@ -18,26 +18,21 @@ async def generate_notes(session_id: str, request: Request):
if not session["transcript_path"]:
raise HTTPException(400, "Session hasn't been transcribed yet")
settings = db.get_settings()
cid = session["campaign_id"] or "default"
settings = db.get_campaign_settings(cid)
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"]}
# Per-request style overrides the global setting
# Per-request style overrides the campaign setting
style = body.get("player_recap_style") or settings.get("player_recap_style") or "story"
custom = body.get("player_recap_custom_prompt") or settings.get("player_recap_custom_prompt") or ""
# Resolve the prompt template (same logic as summarize_session)
if style == "custom":
player_template = custom if custom else PLAYER_FINAL_PROMPTS["story"]
else:
player_template = PLAYER_FINAL_PROMPTS.get(style, PLAYER_FINAL_PROMPTS["story"])
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(
dm_notes, player_recap, used_template = summarize_session(
turns, settings, progress_cb=progress_cb,
player_recap_style=style,
player_recap_custom_prompt=custom,
@@ -47,7 +42,7 @@ async def generate_notes(session_id: str, request: Request):
"INSERT INTO notes (session_id, dm_notes, player_recap, player_recap_prompt, generated_at) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(session_id) DO UPDATE SET dm_notes=excluded.dm_notes, "
"player_recap=excluded.player_recap, player_recap_prompt=excluded.player_recap_prompt, generated_at=excluded.generated_at",
(session_id, dm_notes, player_recap, player_template, db.now()),
(session_id, dm_notes, player_recap, used_template, db.now()),
)
conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,))

View File

@@ -84,8 +84,9 @@ def get_session(session_id: str):
return session
def _run_transcription(session_id: str, video_path: Path, audio_path: Path, transcript_path: Path):
settings = db.get_settings()
def _run_transcription(session_id: str, video_path: Path, audio_path: Path, transcript_path: Path, campaign_id: str | None = None):
cid = campaign_id or "default"
settings = db.get_campaign_settings(cid)
def run(progress_cb):
db.get_conn() # ensure thread-local connection exists in this worker thread
@@ -159,7 +160,7 @@ async def create_session(
audio_path = audio_dir / f"{session_id}.wav"
transcript_path = transcript_dir / f"{session_id}.json"
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path, campaign_id)
return {"session_id": session_id, "job_id": job_id}
@@ -179,7 +180,7 @@ def retry_transcription(session_id: str):
else:
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)
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path, campaign_id)
return {"job_id": job_id}

View File

@@ -6,27 +6,10 @@ 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.
# Only global (GLOBAL_SETTINGS) keys live here; campaign settings are under /api/campaigns/{id}/settings.
# Env var overrides — set these in docker-compose.yml to prefill the setup wizard
_ENV_OVERRIDES = {
"onboarding_completed": "NAT20_ONBOARDING_COMPLETED",
"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",
"player_recap_style": "NAT20_PLAYER_RECAP_STYLE",
"player_recap_custom_prompt": "NAT20_PLAYER_RECAP_CUSTOM_PROMPT",
}
_BOOL_KEYS = {"onboarding_completed"}
@@ -39,15 +22,15 @@ def _coerce(key: str, value: str):
def _merge_with_env(raw: dict) -> dict:
"""Priority: explicit user DB saves (non-default) > env vars > DEFAULT_SETTINGS."""
merged = {**config.DEFAULT_SETTINGS}
"""Priority: explicit user DB saves (non-default) > env vars > GLOBAL_SETTINGS."""
merged = {**config.GLOBAL_SETTINGS}
for key, env_name in _ENV_OVERRIDES.items():
val = os.environ.get(env_name)
if val is not None:
merged[key] = val
for key, val in raw.items():
db_val = val.strip()
if db_val and db_val != config.DEFAULT_SETTINGS.get(key, ""):
if db_val and db_val != config.GLOBAL_SETTINGS.get(key, ""):
merged[key] = db_val
return merged
@@ -61,7 +44,7 @@ async def get_settings():
@router.post("")
async def update_settings(patch: dict):
clean = {k: str(v) for k, v in patch.items() if k in config.DEFAULT_SETTINGS}
clean = {k: str(v) for k, v in patch.items() if k in config.GLOBAL_SETTINGS}
db.update_settings(clean)
raw = db.get_settings()
merged = _merge_with_env(raw)

View File

@@ -1,11 +1,12 @@
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 {
// Global settings (just onboarding tracking).
export interface GlobalSettings {
onboarding_completed: boolean;
}
// Per-campaign settings — mirrors backend CAMPAIGN_SETTINGS.
export interface CampaignSettings {
whisper_model: string;
whisper_compute_type: string;
hf_token: string;
@@ -100,11 +101,11 @@ export const campaignApi = {
export const api = {
// Settings
getSettings: async (): Promise<AppSettings> => {
getSettings: async (): Promise<GlobalSettings> => {
const res = await fetch(`${BASE_URL}/settings`);
return res.json();
},
updateSettings: async (settings: Partial<AppSettings>): Promise<AppSettings> => {
updateSettings: async (settings: Partial<GlobalSettings>): Promise<GlobalSettings> => {
const res = await fetch(`${BASE_URL}/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -112,9 +113,21 @@ export const api = {
});
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);
// Campaign settings
getCampaignSettings: async (campaignId: string): Promise<CampaignSettings> => {
const res = await fetch(`${BASE_URL}/campaigns/${encodeURIComponent(campaignId)}/settings`);
if (!res.ok) throw new Error('Failed to get campaign settings');
return res.json();
},
updateCampaignSettings: async (campaignId: string, settings: Partial<CampaignSettings>): Promise<CampaignSettings> => {
const res = await fetch(`${BASE_URL}/campaigns/${encodeURIComponent(campaignId)}/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error('Failed to update campaign settings');
return res.json();
},
// Sessions

View File

@@ -1,27 +1,35 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useCampaign } from "../contexts/CampaignContext";
export default function Settings() {
const { currentCampaign } = useCampaign();
const [settings, setSettings] = useState<any>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
api.getSettings().then(setSettings);
}, []);
const campaignId = currentCampaign?.id;
if (!settings) return null;
useEffect(() => {
if (!campaignId) return;
api.getCampaignSettings(campaignId).then(setSettings);
}, [campaignId]);
if (!settings || !campaignId) return null;
const set = (k: string, v: any) => setSettings({ ...settings, [k]: v });
const save = async () => {
await api.updateSettings(settings);
await api.updateCampaignSettings(campaignId, settings);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div className="max-w-4xl mx-auto py-16 px-4">
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
<div className="flex items-center gap-3 mb-8">
<h1 className="font-display text-3xl mt-4">Settings</h1>
<span className="text-sm text-ink/40 font-mono mt-4">for {currentCampaign?.name}</span>
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="card p-6 space-y-4">

View File

@@ -1,8 +1,7 @@
import { useState, useEffect } from 'react';
import { api, AppSettings } from '../api';
import { api } from '../api';
const DEFAULT_SETTINGS: AppSettings = {
onboarding_completed: false,
const DEFAULT_SETTINGS: Record<string, string> = {
whisper_model: 'medium',
whisper_compute_type: 'int8',
hf_token: '',
@@ -21,23 +20,24 @@ const DEFAULT_SETTINGS: AppSettings = {
export default function Setup({ onComplete }: { onComplete: () => void }) {
const [step, setStep] = useState(1);
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [settings, setSettings] = useState<Record<string, string>>(DEFAULT_SETTINGS);
// On first load, try to fetch existing settings from the "default" campaign
// (which is always created by init_db).
useEffect(() => {
api.getSettings().then(data => {
if (data) {
setSettings({ ...DEFAULT_SETTINGS, ...data });
}
}).catch(err => {
console.error('Failed to connect to settings endpoint:', err);
});
api.getCampaignSettings('default')
.then(data => {
if (data) setSettings(prev => ({ ...prev, ...data }));
})
.catch(() => {});
}, []);
const handleNext = () => setStep(prev => prev + 1);
const handleBack = () => setStep(prev => prev - 1);
const handleSave = async () => {
await api.saveSettings({ ...settings, onboarding_completed: true });
await api.updateCampaignSettings('default', settings);
await api.updateSettings({ onboarding_completed: true });
onComplete();
};
@@ -195,7 +195,7 @@ export default function Setup({ onComplete }: { onComplete: () => void }) {
<h3 className="text-sm font-semibold text-slate-300">Player recap style</h3>
<select
value={settings.player_recap_style}
onChange={e => setSettings({ ...settings, player_recap_style: e.target.value as AppSettings['player_recap_style'] })}
onChange={e => setSettings({ ...settings, player_recap_style: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2"
>
<option value="story">Story Recap (previously on)</option>