feat(P01): SLICE-01+02 — Dockerfile, .dockerignore, docker-compose.yml, FastAPI StaticFiles, PRAXIS_DB_PATH env (G-102 fix)

SLICE-01 (lead-developer): multi-stage Dockerfile (node:22-slim→python:3.12-slim),
  .dockerignore (excludes secrets/node_modules/.git), docker-compose.yml
  (port 8789, SQLite volume, env injection for all voice-service vars)
SLICE-02 (backend-engineer+data-engineer): FastAPI mounts client/dist as
  StaticFiles at / after API routes (D-023, REQ-DEPLOY-13).
  G-102 MUST fix: db/store.py + db/migrate.py now read PRAXIS_DB_PATH
  from env so the Docker volume mount persists SQLite data.
G-105 FIX: Dockerfile copies pyproject.toml before source (pip install
  layer cached, source changes don't invalidate).

REQ-DEPLOY-01, 02, 13, 16 covered.

---ci---
project: praxis
phase: 1
milestone: v0.2
status: execute
slice: 01-02
wave: 1
---/ci---
This commit is contained in:
Praxis CI
2026-08-01 14:16:39 +00:00
parent 98779b5a72
commit f04b9b3588
6 changed files with 181 additions and 2 deletions
+56
View File
@@ -0,0 +1,56 @@
# Praxis — Docker build context exclusions
# Keep context small (no node_modules, no .git, no pre-built dist).
# Node / client
client/node_modules/
client/dist/
client/.vite/
# Python
__pycache__/
*.py[cod]
.eggs/
*.egg-info/
build/
dist/
.venv/
venv/
# Git
.git/
.gitignore
# CI / planning (not needed inside the container image)
.ciagent/
# Secrets — NEVER in the image
.env
.env.secrets
.env.*
!.env.example
# SQLite DBs (mounted as a volume, not baked in)
*.db
*.db-journal
*.db-wal
*.db-shm
# Test / coverage artifacts
.pytest_cache/
.coverage
htmlcov/
coverage.out
# Deploy scripts (the CT clones the repo separately for scripts;
# the image only needs server + client + db + scenarios)
scripts/
# Piper voice models (pre-staged locally, not in image)
*.onnx
*.pt
*.bin
piper_models/
# OS
.DS_Store
Thumbs.db
+56
View File
@@ -0,0 +1,56 @@
# Praxis v0.2 — Multi-stage Docker image
# Stage 1: build the React client (client/dist)
# Stage 2: Python server + serve client/dist via FastAPI StaticFiles
#
# Per RESEARCH.md Q4 / ARCHITECTURE.md §Image Build Pipeline.
# Debian-slim (not Alpine) — glibc for numpy/pipecat native extensions.
# ── Stage 1: client builder ──────────────────────────────────────────
FROM node:22-slim AS client-builder
WORKDIR /app/client
# Copy manifest first for layer caching (deps change less often than source).
COPY client/package.json client/package-lock.json ./
RUN npm ci
# Copy client source and build.
COPY client/ ./
RUN npm run build
# → produces /app/client/dist/
# ── Stage 2: server ──────────────────────────────────────────────────
FROM python:3.12-slim AS server
WORKDIR /app
# Build tools for any source-compilation fallback (numpy/aiohttp wheels
# should exist for cp312/linux-amd64, but gcc/g++ + libasound2-dev cover
# the R-DEPLOY-01 risk per RESEARCH.md Q4).
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends -qq gcc g++ libasound2-dev && \
rm -rf /var/lib/apt/lists/*
# Install Python deps before copying source (layer caching).
# G-105 FIX: copy pyproject.toml + README.md first, then pip install,
# THEN copy source — so deps are cached and source changes don't
# invalidate the pip layer.
COPY pyproject.toml README.md ./
RUN pip install --no-cache-dir .
# Copy server source + scenarios + db modules.
COPY server/ ./server/
COPY scenarios/ ./scenarios/
COPY db/ ./db/
# Copy the built client dist from Stage 1.
COPY --from=client-builder /app/client/dist ./client/dist
# Data directory for SQLite (mounted as a volume in docker-compose.yml).
RUN mkdir -p /app/data
VOLUME ["/app/data"]
EXPOSE 8789
# Run the FastAPI server via the existing entrypoint.
CMD ["python", "-m", "server"]
+3 -1
View File
@@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
import os
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
_DEFAULT_DB_PATH = Path("praxis.db") # G-102 FIX: read PRAXIS_DB_PATH from env (must match db/store.py).
_DEFAULT_DB_PATH = Path(os.environ.get("PRAXIS_DB_PATH", "praxis.db"))
_DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" _DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
+4 -1
View File
@@ -13,6 +13,7 @@ No auth — learner_id is the hardcoded 'learner-1' (D-007).
from __future__ import annotations from __future__ import annotations
import json import json
import os
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -22,7 +23,9 @@ import aiosqlite
from db.migrate import apply_migrations from db.migrate import apply_migrations
_DEFAULT_DB_PATH = "praxis.db" # G-102 FIX: read PRAXIS_DB_PATH from env so the Docker volume mount
# actually persists data (docker-compose.yml sets PRAXIS_DB_PATH=/app/data/praxis.db).
_DEFAULT_DB_PATH = os.environ.get("PRAXIS_DB_PATH", "praxis.db")
HARDCODED_LEARNER_ID = "learner-1" HARDCODED_LEARNER_ID = "learner-1"
+48
View File
@@ -0,0 +1,48 @@
# Praxis v0.2 — Docker Compose service definition
# Runs the praxis server inside a Docker container (inside an LXC CT).
# Per RESEARCH.md Q4/Q8 / ARCHITECTURE.md §v0.2 Deployment Architecture.
services:
praxis:
build: .
image: praxis:latest
restart: on-failure
ports:
- "8789:8789"
volumes:
# SQLite DB persistence — survives container recreation (G-102).
- praxis-data:/app/data
environment:
PRAXIS_HOST: "0.0.0.0"
PRAXIS_PORT: "8789"
PRAXIS_DB_PATH: "/app/data/praxis.db"
PRAXIS_SCENARIOS_DIR: "/app/scenarios"
PRAXIS_TTS: "${PRAXIS_TTS:-cartesia}"
PRAXIS_SCENARIO: "${PRAXIS_SCENARIO:-customer_service_refund_ca_v01}"
# Voice-service keys (empty if unprovisioned — server degrades gracefully)
DEEPGRAM_API_KEY: "${DEEPGRAM_API_KEY:-}"
CARTESIA_API_KEY: "${CARTESIA_API_KEY:-}"
OLLAMA_API_KEY: "${OLLAMA_API_KEY:-}"
# Ollama Cloud endpoints (D-020)
OLLAMA_BASE_URL: "${OLLAMA_BASE_URL:-https://ollama.com/v1}"
OLLAMA_CHAT_URL: "${OLLAMA_CHAT_URL:-https://ollama.com/api/chat}"
OLLAMA_ROLEPLAY_MODEL: "${OLLAMA_ROLEPLAY_MODEL:-gemma4:cloud}"
OLLAMA_DEBRIEF_MODEL: "${OLLAMA_DEBRIEF_MODEL:-deepseek-v4-flash:cloud}"
# Deepgram (D-013)
DEEPGRAM_MODEL: "${DEEPGRAM_MODEL:-nova-3}"
DEEPGRAM_LANGUAGE: "${DEEPGRAM_LANGUAGE:-en}"
DEEPGRAM_REGION: "${DEEPGRAM_REGION:-na}"
# Cartesia (D-014)
CARTESIA_VOICE_ID: "${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7c44b7b5e384}"
env_file:
# /etc/praxis/server.env is written by install-service.sh with
# secrets injected via lxc.environment (G-101 fix: GITEA_TOKEN baked
# into the snippet; voice keys from lxc.environment).
- /etc/praxis/server.env
restart_policy:
condition: on-failure
max_attempts: 3
volumes:
praxis-data:
driver: local
+14
View File
@@ -29,6 +29,7 @@ except ImportError: # pragma: no cover
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from server.pipeline import build_pipeline from server.pipeline import build_pipeline
@@ -116,6 +117,19 @@ async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]:
raise HTTPException(status_code=500, detail=str(exc)) raise HTTPException(status_code=500, detail=str(exc))
# ── Static client serving (D-023, REQ-DEPLOY-13) ────────────────────
# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
# take precedence. html=True serves index.html for "/" (SPA root).
# The client has no React Router (single-view state machine: start→live
# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3.
_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist")
if os.path.isdir(_CLIENT_DIST):
app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client")
logger.info(f"Serving client from {_CLIENT_DIST}")
else:
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")
def main() -> int: def main() -> int:
"""Run the server with uvicorn.""" """Run the server with uvicorn."""
import uvicorn import uvicorn