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:
@@ -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
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
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' _ {} \;
|
&& 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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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 --from=builder /root/.local /root/.local
|
||||||
COPY app /app/app
|
|
||||||
|
|
||||||
ENV PATH=/root/.local/bin:$PATH
|
ENV PATH=/root/.local/bin:$PATH
|
||||||
ENV PYTHONUNBUFFERED=1
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -46,4 +46,8 @@ DEFAULT_SETTINGS = {
|
|||||||
# Optional world context injected into every summarization prompt
|
# Optional world context injected into every summarization prompt
|
||||||
"world_context": "",
|
"world_context": "",
|
||||||
"world_context_path": "",
|
"world_context_path": "",
|
||||||
|
|
||||||
|
# Player recap style: "story" | "diary" | "bullets" | "custom"
|
||||||
|
"player_recap_style": "story",
|
||||||
|
"player_recap_custom_prompt": "",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,12 +52,29 @@ Chunk summaries:
|
|||||||
{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.
|
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:
|
Chunk summaries:
|
||||||
{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:
|
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)
|
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)
|
call = make_llm_caller(settings)
|
||||||
ctx_path = settings.get("world_context_path") or ""
|
ctx_path = settings.get("world_context_path") or ""
|
||||||
if ctx_path and Path(ctx_path).exists():
|
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:
|
if progress_cb:
|
||||||
progress_cb("Writing final notes...")
|
progress_cb("Writing final notes...")
|
||||||
dm_final = call(DM_FINAL_PROMPT.format(summaries="\n\n".join(dm_summaries)))
|
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
|
return dm_final, player_final
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ def generate_notes(session_id: str):
|
|||||||
|
|
||||||
def run(progress_cb):
|
def run(progress_cb):
|
||||||
turns = load_turns(Path(session["transcript_path"]), speaker_map)
|
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:
|
with db.tx() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO notes (session_id, dm_notes, player_recap, generated_at) VALUES (?, ?, ?, ?) "
|
"INSERT INTO notes (session_id, dm_notes, player_recap, generated_at) VALUES (?, ?, ?, ?) "
|
||||||
|
|||||||
@@ -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 var overrides — set these in docker-compose.yml to prefill the setup wizard
|
||||||
_ENV_OVERRIDES = {
|
_ENV_OVERRIDES = {
|
||||||
|
"onboarding_completed": "NAT20_ONBOARDING_COMPLETED",
|
||||||
"hf_token": "NAT20_HF_TOKEN",
|
"hf_token": "NAT20_HF_TOKEN",
|
||||||
"whisper_model": "NAT20_WHISPER_MODEL",
|
"whisper_model": "NAT20_WHISPER_MODEL",
|
||||||
"whisper_compute_type": "NAT20_WHISPER_COMPUTE_TYPE",
|
"whisper_compute_type": "NAT20_WHISPER_COMPUTE_TYPE",
|
||||||
@@ -36,13 +37,16 @@ def _coerce(key: str, value: str):
|
|||||||
|
|
||||||
|
|
||||||
def _merge_with_env(raw: dict) -> dict:
|
def _merge_with_env(raw: dict) -> dict:
|
||||||
"""DB values win, env vars fill gaps, DEFAULT_SETTINGS fill remaining gaps."""
|
"""Priority: explicit user DB saves (non-default) > env vars > DEFAULT_SETTINGS."""
|
||||||
merged = {**config.DEFAULT_SETTINGS, **raw}
|
merged = {**config.DEFAULT_SETTINGS}
|
||||||
for key, env_name in _ENV_OVERRIDES.items():
|
for key, env_name in _ENV_OVERRIDES.items():
|
||||||
if not raw.get(key, "").strip():
|
val = os.environ.get(env_name)
|
||||||
val = os.environ.get(env_name)
|
if val is not None:
|
||||||
if val is not None:
|
merged[key] = val
|
||||||
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
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
# Example Docker Compose — uses a named volume so data persists across rebuilds
|
# 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.
|
# without needing a specific host path. Copy to docker-compose.yml and customise.
|
||||||
services:
|
services:
|
||||||
|
whisperx-base:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: whisperx-base
|
||||||
|
image: nat20-whisperx-base:latest
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: runtime
|
||||||
|
image: nat20-backend:latest
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
- app_data:/data
|
- app_data:/data
|
||||||
- hf_cache:/root/.cache/huggingface
|
- hf_cache:/root/.cache/huggingface
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface AppSettings {
|
|||||||
chunk_word_target: string;
|
chunk_word_target: string;
|
||||||
world_context: string;
|
world_context: string;
|
||||||
world_context_path: string;
|
world_context_path: string;
|
||||||
|
player_recap_style: 'story' | 'diary' | 'bullets' | 'custom';
|
||||||
|
player_recap_custom_prompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JobStatus = 'queued' | 'running' | 'done' | 'error';
|
export type JobStatus = 'queued' | 'running' | 'done' | 'error';
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ export default function Settings() {
|
|||||||
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-6 space-y-3 mb-6">
|
||||||
|
<h2 className="eyebrow">Player recap style</h2>
|
||||||
|
<select className="input w-full" value={settings.player_recap_style} onChange={(e) => set("player_recap_style", e.target.value)}>
|
||||||
|
<option value="story">Story Recap (previously on…)</option>
|
||||||
|
<option value="diary">Dear Diary</option>
|
||||||
|
<option value="bullets">Bullet Points</option>
|
||||||
|
<option value="custom">Custom Prompt</option>
|
||||||
|
</select>
|
||||||
|
{settings.player_recap_style === "custom" && (
|
||||||
|
<textarea className="input w-full h-32" value={settings.player_recap_custom_prompt} onChange={(e) => set("player_recap_custom_prompt", e.target.value)} placeholder="Write your own final-combine prompt. Use {'{summaries}'} where the chunk summaries should be inserted." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button>
|
<button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
chunk_word_target: '2500',
|
chunk_word_target: '2500',
|
||||||
world_context: '',
|
world_context: '',
|
||||||
world_context_path: '',
|
world_context_path: '',
|
||||||
|
player_recap_style: 'story',
|
||||||
|
player_recap_custom_prompt: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Setup({ onComplete }: { onComplete: () => void }) {
|
export default function Setup({ onComplete }: { onComplete: () => void }) {
|
||||||
@@ -189,6 +191,28 @@ export default function Setup({ onComplete }: { onComplete: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700 pt-4 space-y-3">
|
||||||
|
<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'] })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="story">Story Recap (previously on…)</option>
|
||||||
|
<option value="diary">Dear Diary</option>
|
||||||
|
<option value="bullets">Bullet Points</option>
|
||||||
|
<option value="custom">Custom Prompt</option>
|
||||||
|
</select>
|
||||||
|
{settings.player_recap_style === 'custom' && (
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 h-24 font-mono text-sm"
|
||||||
|
placeholder="Write your own final-combine prompt. Use {summaries} where the chunk summaries should be inserted."
|
||||||
|
value={settings.player_recap_custom_prompt}
|
||||||
|
onChange={e => setSettings({ ...settings, player_recap_custom_prompt: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between pt-4 border-t border-slate-700">
|
<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">
|
<button onClick={handleBack} className="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded font-medium">
|
||||||
Back
|
Back
|
||||||
|
|||||||
Reference in New Issue
Block a user