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).
This commit is contained in:
KansaiGaijin
2026-07-10 16:25:52 +12:00
parent a261301638
commit babc0f58ef
9 changed files with 122 additions and 21 deletions

View File

@@ -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"]

View File

@@ -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": "",
}

View File

@@ -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

View File

@@ -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 (?, ?, ?, ?) "

View File

@@ -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