From babc0f58efe8c95d30eb93298b8884d03f854ab0 Mon Sep 17 00:00:00 2001 From: KansaiGaijin <83641841+KansaiGaijin@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:25:52 +1200 Subject: [PATCH] multi-stage Dockerfile + player recap styles Dockerfile: split into whisperx-base and runtime stages so app-only changes skip the 10min pip install on rebuild. Example compose updated. Player recap: replace hardcoded 'story so far' prompt with selectable styles (story/diary/bullets/custom) via dropdown in Setup and Settings. Settings: add NAT20_ONBOARDING_COMPLETED env var; fix merge logic so env vars properly override defaults (not just empty DB values). --- backend/Dockerfile | 26 +++++++++++++++-------- backend/app/config.py | 4 ++++ backend/app/pipeline/summarize.py | 35 +++++++++++++++++++++++++++---- backend/app/routers/notes.py | 6 +++++- backend/app/routers/settings.py | 16 ++++++++------ docker-compose.example.yml | 17 ++++++++++++++- frontend/src/api.ts | 2 ++ frontend/src/pages/Settings.tsx | 13 ++++++++++++ frontend/src/pages/Setup.tsx | 24 +++++++++++++++++++++ 9 files changed, 122 insertions(+), 21 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 87423ea..7f09b00 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,7 @@ # ========================================== -# STAGE 1: Builder Environment +# STAGE 1 — builder: compile Python deps # ========================================== +# Changes only when requirements.txt is modified. FROM python:3.11-slim AS builder WORKDIR /build @@ -28,25 +29,32 @@ RUN find /root/.local -type d -name "__pycache__" -exec rm -rf {} + \ && find /root/.local -name "*.dist-info" -exec sh -c 'rm -f "$1"/RECORD "$1"/INSTALLER' _ {} \; # ========================================== -# STAGE 2: Lightweight Final Runtime +# STAGE 2 — whisperx-base: deps only, no app # ========================================== -FROM python:3.11-slim AS runtime +# Targeted by docker-compose for dev caching. +# If you only need this layer: docker build --target=whisperx-base -t nat20-whisperx-base . +FROM python:3.11-slim AS whisperx-base -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 +# ========================================== +# STAGE 3 — runtime: full release image +# ========================================== +# Build for production: docker build --target=runtime -t nat20-notes-backend . +# Layers app code on top of whisperx-base in a single Dockerfile pass. +FROM whisperx-base AS runtime + +WORKDIR /app + +COPY app /app/app + EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/config.py b/backend/app/config.py index 0d0ca9d..5ccdf02 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -46,4 +46,8 @@ DEFAULT_SETTINGS = { # Optional world context injected into every summarization prompt "world_context": "", "world_context_path": "", + + # Player recap style: "story" | "diary" | "bullets" | "custom" + "player_recap_style": "story", + "player_recap_custom_prompt": "", } diff --git a/backend/app/pipeline/summarize.py b/backend/app/pipeline/summarize.py index da5555f..f27bd5e 100644 --- a/backend/app/pipeline/summarize.py +++ b/backend/app/pipeline/summarize.py @@ -52,12 +52,29 @@ Chunk summaries: {summaries} """ -PLAYER_FINAL_PROMPT = """Combine these chunk summaries into a single "previously on" style recap for the players, in-character perspective only. +PLAYER_FINAL_PROMPTS = { + "story": """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} -""" +""", + "diary": """Combine these chunk summaries into a "dear diary" style entry written from the collective party's first-person ("we") perspective. +Focus on the party's emotions, reactions, and personal reflections alongside the events. +No GM secrets, no OOC content. + +Chunk summaries: +{summaries} +""", + "bullets": """Combine these chunk summaries into clean, scannable bullet points for the players. +Group related items under headers (Exploration, Combat, NPCs, Loot, Decisions). +Each bullet is one concise fact — no narrative fluff or transitions. No GM secrets, no OOC content. + +Chunk summaries: +{summaries} +""", +} + def _call_ollama(host: str, model: str, prompt: str) -> str: @@ -125,7 +142,9 @@ def make_llm_caller(settings: dict): return lambda prompt: _call_ollama(host, model, prompt) -def summarize_session(turns: list[dict], settings: dict, progress_cb=None) -> tuple[str, str]: +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]: call = make_llm_caller(settings) ctx_path = settings.get("world_context_path") or "" if ctx_path and Path(ctx_path).exists(): @@ -148,5 +167,13 @@ def summarize_session(turns: list[dict], settings: dict, progress_cb=None) -> tu 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))) + + # Select player recap prompt based on style setting + style = (player_recap_style or settings.get("player_recap_style") or "story") + if style == "custom": + raw = (player_recap_custom_prompt or settings.get("player_recap_custom_prompt") or "").strip() + 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 diff --git a/backend/app/routers/notes.py b/backend/app/routers/notes.py index 0bddc5b..f8103f7 100644 --- a/backend/app/routers/notes.py +++ b/backend/app/routers/notes.py @@ -26,7 +26,11 @@ def generate_notes(session_id: str): 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) + dm_notes, player_recap = summarize_session( + turns, settings, progress_cb=progress_cb, + player_recap_style=settings.get("player_recap_style"), + player_recap_custom_prompt=settings.get("player_recap_custom_prompt"), + ) with db.tx() as conn: conn.execute( "INSERT INTO notes (session_id, dm_notes, player_recap, generated_at) VALUES (?, ?, ?, ?) " diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 71d3ead..fef84e0 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -13,6 +13,7 @@ router = APIRouter(prefix="/api/settings", tags=["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", @@ -36,13 +37,16 @@ def _coerce(key: str, value: str): def _merge_with_env(raw: dict) -> dict: - """DB values win, env vars fill gaps, DEFAULT_SETTINGS fill remaining gaps.""" - merged = {**config.DEFAULT_SETTINGS, **raw} + """Priority: explicit user DB saves (non-default) > env vars > DEFAULT_SETTINGS.""" + merged = {**config.DEFAULT_SETTINGS} 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 + 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, ""): + merged[key] = db_val return merged diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 4ab7dda..2903e77 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,9 +1,24 @@ # 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: + whisperx-base: + build: + context: ./backend + target: whisperx-base + image: nat20-whisperx-base:latest + restart: "no" + backend: - build: ./backend + build: + context: ./backend + target: runtime + image: nat20-backend:latest restart: unless-stopped + depends_on: + - whisperx-base + environment: + # Skip setup wizard on container restart (or "false" to force re-run) + NAT20_ONBOARDING_COMPLETED: "true" volumes: - app_data:/data - hf_cache:/root/.cache/huggingface diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 64eb59b..6f3c978 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -18,6 +18,8 @@ export interface AppSettings { chunk_word_target: string; world_context: string; world_context_path: string; + player_recap_style: 'story' | 'diary' | 'bullets' | 'custom'; + player_recap_custom_prompt: string; } export type JobStatus = 'queued' | 'running' | 'done' | 'error'; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index fbe77c1..977fce7 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -62,6 +62,19 @@ export default function Settings() { set("world_context_path", e.target.value)} placeholder="...or path to a file inside the container" /> +