This repository has been archived on 2026-09-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
praxis/.ciagent/PLAN.md
T
Praxis CI fbd6602814 docs(milestone): complete v0.1 foundation
---ci---
phase: 0
milestone: v0.1
status: complete
---/ci---
2026-08-01 13:32:48 +00:00

291 lines
30 KiB
Markdown

# Praxis — Phase 1 Plan (Minimal Viable Voice Loop)
> **Milestone:** v0.1 (foundation)
> **Phase:** 1 — Minimal Viable Voice Loop
> **Branch:** `phase/01-minimal-voice-loop` (created at EXECUTE)
> **Status:** plan
> **Source artifacts:** PROJECT.md (D-001..D-020), REQUIREMENTS.md, ARCHITECTURE.md, RESEARCH.md (R1-R10), PERSONAS.md, ROADMAP.md
---
## 1. Phase 1 Summary
### Goal
A single learner can open the React web client, speak to an AI tutor playing a Customer Service role-play scenario ("angry customer requesting refund on damaged product", one branch point: escalate vs accept), hear the tutor respond with <600ms end-to-end latency target, receive a single end-of-session text+voice coaching debrief, and have the session logged to SQLite learner state.
### Scope (in)
- Streaming voice loop: Deepgram Nova-3 ASR → Ollama Cloud LLM (`gemma4:cloud`) → Cartesia/Piper TTS, orchestrated by Pipecat with Silero VAD
- One branching Customer Service scenario (refund, one branch point, `failure_mode` field present)
- Interruptibility (abort-and-yield per D-008)
- Pluggable guardrail layer with Customer Service ruleset
- Single-learner SQLite session log + per-session cost logging
- End-of-session text+voice coaching debrief (`deepseek-v4-flash:cloud`, no-think mode)
- React + WebRTC client via Pipecat client SDK
- R1-R4 latency spike (the single biggest v0.1 technical risk — RESEARCH.md directive)
### Scope (out — deferred per PROJECT.md)
- Mastery scoring, competency rubrics, credentials
- Multi-language (Canadian English only)
- Employer dashboard, Live Assist, WhatsApp/USSD
- Multi-learner / auth / multi-tenant
- Active failure-injection provocation (hook present, not provoked — D-009)
- Multiple personas / voice switching (one voice — D-006)
### Risks addressed in this plan
| # | Risk (from RESEARCH.md) | How this plan addresses it |
|---|---|---|
| R1 | Deepgram first-partial latency from Canada unmeasured | SLICE-01 day-1 probe; SLICE-02 integrated measurement |
| R2 | Cartesia first-audio latency unmeasured | SLICE-01 probe; SLICE-02 integrated measurement |
| R3 | Ollama Cloud `gemma4:cloud` first-token latency unmeasured | SLICE-01 probe; SLICE-02 integrated measurement |
| R4 | All-cloud three-hop path likely ~670ms (over 600ms) | SLICE-01 measures the integrated path; TTS behind interface from SLICE-02; Piper pre-staged as mitigation if R4 confirms. **SLICE-01 is the wave-1 go/no-go gate.** |
| R6 | Pipecat + Ollama direct-API integration depth unverified | SLICE-02 task verifies Pipecat Ollama service accepts custom host + bearer; thin adapter if not |
| R7 | Scenario branch detection (learner signal classification) | SLICE-03: LLM-as-judge (`deepseek-v4-flash:cloud` no-think) at session end, offline from voice loop |
### Success criteria (Phase 1 exit)
1. A learner can complete a full session: open client → hear disclaimer → speak to AI customer → AI responds <600ms (target; logged even if exceeded) → reach a branch outcome → receive text+voice debrief → session logged to SQLite.
2. R1-R4 latency report exists with measured (not vendor-claimed) per-segment and end-to-end numbers; a documented TTS decision (Cartesia vs Piper) justified by data.
3. All 15 P1 REQ-IDs verified as covered (see §5 coverage matrix).
4. Per-session cost is logged (token counts + segment latencies + derived cost).
5. Guardrail layer is pluggable (interface + one Customer Service ruleset implementation) and enforces the v0.1 ruleset (disclaimer, no legal/financial/medical advice, stay-in-role).
6. Scenario is YAML → Pydantic → Pipecat Flows with `failure_mode` field present.
---
## 2. Vertical Slices
Slices are ordered into 3 waves. Each slice delivers end-to-end value (a demoable behavior), not a horizontal layer. Wave N+1 depends on Wave N output.
### SLICE-01 — Component & Integrated Latency Spike (R1-R4)
**Wave:** 1
**REQ-IDs covered:** REQ-VOICE-03, REQ-NFR-LAT-01, REQ-LLM-01 (probe), REQ-LLM-02 (probe)
**Personas:** lead-developer, backend-engineer
**Dependencies:** none (first slice)
**Demoable outcome:** A latency report (`docs/latency-report.md` or `reports/latency-spike.md`) with measured per-segment and end-to-end numbers, plus a recorded go/no-go decision on TTS (Cartesia cloud vs Piper self-hosted pre-stage). Running `make latency-spike` (or `python scripts/latency_spike.py`) reproduces the measurements.
**Rationale:** RESEARCH.md is explicit: "This is the single biggest v0.1 technical risk and must be spiked in Phase 1 week 1." The all-cloud three-hop path likely lands ~670ms. We measure before building the full loop so SLICE-02 can wire the correct TTS from the start.
**Tasks:**
| Task ID | Description | Verification |
|---------|-------------|--------------|
| TASK-01-01 | Create repo skeleton: `server/`, `client/`, `scenarios/`, `db/`, `guardrails/`, `llm/`, `asr/`, `tts/`, `scripts/`, `tests/` dirs; `pyproject.toml` (server) with pipecat, deepgram, cartesia, piper-tts, ollama, pydantic, aiosqlite deps; `.env.example` documenting `DEEPGRAM_API_KEY`, `CARTESIA_API_KEY`, `OLLAMA_API_KEY`, `PIPECAT_*` scopes. | `python -c "import pipecat"` succeeds; dir structure matches PERSONAS.md territory. |
| TASK-01-02 | R1 probe: `scripts/probe_deepgram.py` — streaming WebSocket to Deepgram Nova-3, send a sample audio file (or synthesized PCM), measure first-partial-transcript latency from a Canada-region endpoint over 20 iterations; log min/median/p95. | Running the script prints a latency table; results recorded in latency report. |
| TASK-01-03 | R2 probe: `scripts/probe_cartesia.py` — WebSocket to Cartesia Sonic, send a sample text chunk, measure first-audio-byte latency over 20 iterations; log min/median/p95. | Running the script prints a latency table; results recorded. |
| TASK-01-04 | R3 probe: `scripts/probe_ollama.py` — direct API call to `https://ollama.com/api/chat` with `OLLAMA_API_KEY` bearer, model `gemma4:cloud`, `stream=True`, measure time-to-first-token over 20 iterations; also probe `deepseek-v4-flash:cloud` no-think mode TTFT. Log min/median/p95 + any throttle events (R5). | Running the script prints TTFT tables for both models; results recorded. |
| TASK-01-05 | R4 probe: `scripts/probe_e2e.py` — integrated three-hop: feed a sample ASR transcript → Ollama `gemma4:cloud` streaming → Cartesia TTS streaming; measure end-to-end (transcript-in → first-audio-out). Run 10 iterations. Also measure the same path with Piper self-hosted (if Piper can be stood up locally in this task; otherwise note as pending and pre-stage in SLICE-02). | Running the script prints the integrated e2e latency; recorded in report. |
| TASK-01-06 | Write `docs/latency-report.md`: per-segment measured latencies (R1-R4), integrated e2e, comparison vs the 600ms budget, and a TTS decision (Cartesia cloud vs Piper pre-stage) with rationale. If e2e >600ms with Cartesia, document Piper as the production v0.1 TTS and note pre-staging work for SLICE-02. | Report file exists with measured numbers (not vendor claims) and a decision block. |
**Must-have verification criteria:**
- [ ] `scripts/probe_deepgram.py`, `probe_cartesia.py`, `probe_ollama.py`, `probe_e2e.py` all run and produce measured latency output.
- [ ] `docs/latency-report.md` contains real measured numbers for R1, R2, R3, R4 (not vendor claims).
- [ ] Report contains an explicit TTS decision (Cartesia vs Piper) justified by the R4 integrated measurement.
- [ ] If R4 integrated path >600ms, Piper pre-staging is documented as a SLICE-02 task.
---
### SLICE-02 — Thin Vertical Voice Loop (Walking Skeleton)
**Wave:** 1
**REQ-IDs covered:** REQ-VOICE-01, REQ-VOICE-02, REQ-VOICE-03, REQ-VOICE-04, REQ-ORCH-01, REQ-LLM-01, REQ-NFR-LAT-01
**Personas:** lead-developer, backend-engineer, frontend-engineer
**Dependencies:** SLICE-01 (uses the TTS decision; latency budget confirmed feasible)
**Demoable outcome:** A learner opens a minimal React page, clicks "Start", speaks one utterance, and hears the AI reply over WebRTC — end-to-end voice loop works, latency is displayed. Quality may be poor (hardcoded single-turn scenario, no branching, stub guardrail). This is the walking skeleton that makes latency measurable on the real integrated path.
**Rationale:** The first integrated slice must be minimal but complete (client → server → ASR → LLM → TTS → client) so we measure real latency, not probe latency. All swappable services (TTS D-014, LLM D-020, guardrail D-019) sit behind interfaces from this first slice so later swaps don't touch the pipeline.
**Tasks:**
| Task ID | Description | Verification |
|---------|-------------|--------------|
| TASK-02-01 | Define service interfaces in `server/services/`: `TTSProvider` (async `synthesize(text) -> audio_stream`, `voice_id`), `LLMProvider` (async `chat(messages, stream=True) -> token_stream`, `model`), `Guardrail` (async `check(text, context) -> verdict`). ABCs/Protocols with type annotations. | `python -c "from server.services import TTSProvider, LLMProvider, Guardrail"` succeeds; interfaces are abstract. |
| TASK-02-02 | Implement `CartesiaTTS` and `PiperTTS` adapters behind `TTSProvider`. Pre-stage Piper self-hosted on the pilot server per SLICE-01 decision (install `piper-tts`, download one voice model). TTS selection via env var `PRAXIS_TTS=cartesia|piper`. | Both adapters pass unit tests with a mock stream; `PRAXIS_TTS=piper` selects Piper; `PRAXIS_TTS=cartesia` selects Cartesia. |
| TASK-02-03 | Implement `OllamaCloudLLM` adapter behind `LLMProvider` — direct API to `https://ollama.com/api/chat` with bearer auth, `stream=True`, model param. Verify Pipecat's Ollama LLM service accepts custom host + bearer (R6); if not, wrap with this thin adapter so Pipecat consumes it as a generic LLM service. | Adapter unit-tested with a mocked HTTP streaming response; a real call to `gemma4:cloud` returns a first token (confirms R6). |
| TASK-02-04 | Build Pipecat server pipeline in `server/pipeline.py`: Silero VAD → Deepgram Nova-3 STT (streaming) → `OllamaCloudLLM` (`gemma4:cloud`) → selected `TTSProvider` → WebRTC output. Wire interruptibility: learner VAD during TTS aborts TTS + yields floor (D-008, Pipecat built-in). Hardcoded single-turn system prompt (no YAML scenario yet). | `python -m server` starts the Pipecat pipeline; a WebSocket/WebRTC connection is accepted; logs show VAD → STT → LLM → TTS frame flow. |
| TASK-02-05 | Build minimal React client in `client/` (Vite + React + Pipecat client SDK): one page with "Start session" button, mic permission, WebRTC connect, audio playback, live transcript display (optional), and a latency readout. No branching UI, no debrief. | `npm run dev` serves the client; clicking Start connects WebRTC; speaking produces an AI audio reply in the browser. |
| TASK-02-06 | Add an end-to-end latency probe to the pipeline: timestamp at final-transcript-ready, LLM-first-token, TTS-first-audio, client-playback-start; log to console and surface the ASR→TTS-first-audio number to the client for display. | The client displays a latency number after the first turn; logged numbers match `probe_e2e.py` within tolerance. |
| TASK-02-07 | Stub guardrail: `NoOpGuardrail` implementing `Guardrail` (always returns allow) so the pipeline has the pluggable hook in place. Real ruleset comes in SLICE-03. | Pipeline calls `guardrail.check()` on each turn; swapping to a real impl requires no pipeline change. |
**Must-have verification criteria:**
- [ ] A learner can click Start, speak one utterance, and hear the AI reply in the browser.
- [ ] End-to-end latency (transcript-ready → first-audio) is measured and displayed.
- [ ] TTS is selected via env var; both Cartesia and Piper adapters exist behind the `TTSProvider` interface.
- [ ] LLM is behind `LLMProvider`; `gemma4:cloud` returns tokens via direct API (R6 resolved).
- [ ] Interruptibility works: speaking during AI TTS cuts the AI off (manual test).
- [ ] Guardrail slot exists and is swappable without touching the pipeline.
---
### SLICE-03 — Branching Scenario + Guardrails + Interruptibility
**Wave:** 2
**REQ-IDs covered:** REQ-SCEN-01, REQ-SCEN-FMT-01, REQ-ORCH-02, REQ-VOICE-04, REQ-NFR-SAFE-01
**Personas:** lead-developer, backend-engineer, data-engineer
**Dependencies:** SLICE-02 (voice loop + interfaces exist)
**Demoable outcome:** The AI plays the "angry customer refund" scenario with a real branch point — the learner's approach either resolves (accept) or escalates — and the session-start disclaimer plays. Guardrails enforce the Customer Service ruleset. The scenario is defined in YAML, loaded via Pydantic, and drives Pipecat Flows.
**Tasks:**
| Task ID | Description | Verification |
|---------|-------------|--------------|
| TASK-03-01 | Define Pydantic scenario schema in `server/scenarios/schema.py`: `Scenario` (id, path, market, language, title, difficulty, failure_mode, persona, setup, success_criteria, common_mistakes, branches[], debrief) matching the RESEARCH.md example. `Branch` has id, trigger.learner_signals, outcome, failure_mode (optional), debrief_focus. Validate at load time. | Unit tests: a valid YAML parses; an invalid YAML raises a typed Pydantic error. |
| TASK-03-02 | Author `scenarios/customer_service_refund_ca_v01.yaml` per D-010 and the RESEARCH.md example: "Angry customer requesting refund on damaged product", one branch point (accept_resolution vs escalate), `failure_mode: escalates_unresolved` present, success criteria, common mistakes, debrief config (model `deepseek-v4-flash:cloud`, mode `no_think`). | `python -c "from server.scenarios.loader import load; load('customer_service_refund_ca_v01')"` returns a valid `Scenario` object with both branches. |
| TASK-03-03 | Integrate Pipecat Flows: map the scenario branches to a Flows state machine. The system prompt is built from `setup.system_prompt`; opening line from `setup.opening_line` is the first TTS utterance. Branch transition logic is driven by learner-signal classification (TASK-03-06). | Pipeline runs the scenario: AI speaks the opening line, then converses; reaching a branch transitions to the branch outcome. |
| TASK-03-04 | Implement `CustomerServiceGuardrail` behind the `Guardrail` interface (D-019): system-prompt constraints (no legal/financial/medical advice, no real-company impersonation, stay-in-role, concise-for-voice), debrief output filter (block recommendations that learner advise legal action), session-start disclaimer audio ("This is an AI practice session for training purposes. It is not a real conversation and no real company is involved."). Wire into pipeline replacing `NoOpGuardrail`. | Unit tests: guardrail flags a "sue them" recommendation; allows a normal coaching line; disclaimer text is defined. Pipeline plays disclaimer as first audio. |
| TASK-03-05 | Verify interruptibility on branching turns: learner can cut the AI mid-utterance during any turn (including the opening line and post-branch turns); AI aborts TTS and yields (D-008). Manual + automated test. | Manual test: speaking during AI speech cuts it off; a test script confirms TTS abort event fires on VAD during TTS. |
| TASK-03-06 | Implement branch classifier (R7): at session end (or turn boundary), call `deepseek-v4-flash:cloud` in no-think mode as LLM-as-judge to classify learner signals into `accept_resolution` or `escalate` based on the turn transcripts + the scenario's `learner_signals` definitions. Offline from the voice loop (not on the latency-critical path). | A scripted transcript classified as "empathy + concrete_resolution" → accept; "defensive + policy_first" → escalate. |
| TASK-03-07 | Replace the hardcoded system prompt from SLICE-02 with the scenario-driven prompt from the loaded YAML. The pipeline now starts a session by loading a named scenario. | Starting a session with scenario `cs_refund_ca_v01` plays the correct opening line and uses the scenario's system prompt. |
**Must-have verification criteria:**
- [ ] Scenario is YAML → Pydantic → Pipecat Flows; `failure_mode` field is present.
- [ ] One branch point (accept vs escalate) is reachable and changes the session outcome.
- [ ] Session-start disclaimer audio plays as the first AI utterance.
- [ ] `CustomerServiceGuardrail` is plugged into the `Guardrail` interface (no pipeline change) and enforces the ruleset (unit-tested).
- [ ] Interruptibility works on all turns (manual + automated).
- [ ] Branch classifier runs offline (not on the voice latency path) and correctly classifies two scripted transcripts.
---
### SLICE-04 — Learner State + Cost Logging
**Wave:** 2
**REQ-IDs covered:** REQ-STATE-01, REQ-NFR-COST-01
**Personas:** lead-developer, backend-engineer, data-engineer
**Dependencies:** SLICE-02 (loop produces turns to log), SLICE-03 (scenario produces branch outcome to log)
**Demoable outcome:** After a session, `praxis.db` contains the session row with branch path and outcome, all turns with ASR/TTS text and per-turn latency, and a derived cost row. `sqlite3 praxis.db "SELECT * FROM sessions"` shows the last session.
**Tasks:**
| Task ID | Description | Verification |
|---------|-------------|--------------|
| TASK-04-01 | Create SQLite schema in `db/schema.sql` + migrations (`db/migrations/0001_init.sql`): `learner(id, display_name, created_at)` with one hardcoded row (`learner-1`, "Alex"); `sessions(id, learner_id, scenario_id, started_at, ended_at, branch_path_json, outcome, cost_estimated_cents)`; `turns(id, session_id, seq, role, asr_text, tts_text, latency_ms, created_at)`; `progress(learner_id, scenario_id, attempts, last_outcome, updated_at)`. Use aiosqlite for async access. | Migration runs; `sqlite3 praxis.db ".schema"` shows all 4 tables; the hardcoded learner row exists. |
| TASK-04-02 | Implement `db/store.py` async access layer: `start_session(learner_id, scenario_id)`, `log_turn(session_id, seq, role, asr_text, tts_text, latency_ms)`, `end_session(session_id, branch_path, outcome, cost_cents)`, `update_progress(learner_id, scenario_id, outcome)`. Type-annotated, returns typed objects. | Unit tests with a temp DB: start session → log 3 turns → end session → query returns the full session with turns. |
| TASK-04-03 | Wire the store into the Pipecat pipeline: on session start (create row), per turn (log turn with latency), on branch decision (update branch_path), on session end (set outcome + update progress). No auth — `learner_id` is the hardcoded `learner-1`. | After a manual session, `SELECT * FROM sessions` and `SELECT * FROM turns` show the session and its turns. |
| TASK-04-04 | Implement cost logging (REQ-NFR-COST-01, D-012): per session, count LLM input/output tokens (gemma4 + deepseek-v4-flash), Deepgram audio minutes, Cartesia/Piper characters; derive an estimated cost in cents using a `cost_rates.yaml` config (no enforced ceiling). Store in `sessions.cost_estimated_cents`. | After a session, `SELECT cost_estimated_cents FROM sessions` returns a non-null number; a `cost_breakdown` is logged (token counts, minutes, chars). |
**Must-have verification criteria:**
- [ ] SQLite `praxis.db` exists with `learner`, `sessions`, `turns`, `progress` tables.
- [ ] One hardcoded learner row exists (no auth).
- [ ] A completed session produces a `sessions` row + `turns` rows + a `progress` update.
- [ ] `cost_estimated_cents` is non-null for a completed session and backed by a logged breakdown.
---
### SLICE-05 — Coaching Debrief + Full Client UX
**Wave:** 3
**REQ-IDs covered:** REQ-DEBRIEF-01, REQ-LLM-02, REQ-NFR-SAFE-01 (debrief filter)
**Personas:** lead-developer, backend-engineer, frontend-engineer
**Dependencies:** SLICE-03 (branch outcome + scenario debrief config), SLICE-04 (session logged with turns)
**Demoable outcome:** At session end, the learner sees a text coaching debrief and hears a voice version, both generated from their actual turns + branch outcome + the scenario's `debrief_focus`. The React client shows a polished session flow: start → live turn indicators → interrupt feedback → end debrief view (text + audio playback + latency summary).
**Tasks:**
| Task ID | Description | Verification |
|---------|-------------|--------------|
| TASK-05-01 | Implement debrief generation in `server/debrief.py`: on session end, load the session turns + branch outcome + scenario `debrief.debrief_focus`, call `deepseek-v4-flash:cloud` in no-think mode (per D-020 / scenario config) with the debrief prompt template. Produce a concise text summary (what you did well / what to improve / one next step). | A scripted session (turns + outcome=escalate) produces a debrief text that references the learner's actual turns and the `escalates_unresolved` focus. |
| TASK-05-02 | Route the debrief text through `CustomerServiceGuardrail` output filter (block legal-action recommendations, keep focus on learner performance). | Unit test: a debrief containing "tell the customer to sue" is filtered/blocked; a normal coaching debrief passes. |
| TASK-05-03 | Synthesize the debrief as voice via the `TTSProvider` (same voice as the role-play per D-006) and stream to the client over the existing WebRTC connection. | At session end, the client receives and plays the debrief audio; the same `TTSProvider` interface is reused (no new TTS path). |
| TASK-05-04 | Build the full React client session UX: (a) start screen with scenario title + disclaimer acknowledgement, (b) live session view with turn indicators (learner/AI), interrupt feedback (visual on AI-yield), live latency readout, (c) end-of-session debrief view with debrief text + audio replay + latency/cost summary. Replace the SLICE-02 minimal page. | A full session flows through all three views; the debrief view shows text + an audio playback control + a latency summary. |
| TASK-05-05 | Wire debrief persistence: store the debrief text + the branch outcome in the session row (extend `sessions` with `debrief_text` column via migration `0002_debrief.sql`). | After a session, `SELECT debrief_text FROM sessions WHERE id=?` returns the generated debrief. |
| TASK-05-06 | End-to-end verification script (`scripts/e2e_smoke.py` or `tests/test_e2e.py`): start session → simulate 2-3 turns → trigger a branch → end session → assert debrief generated, session + turns + cost logged in SQLite, latency < budget (or logged if exceeded). | Running the script passes; it asserts DB rows, debrief non-empty, cost non-null. |
**Must-have verification criteria:**
- [ ] At session end, a text coaching debrief is generated referencing the learner's actual turns and branch outcome.
- [ ] The debrief is spoken in the same voice as the role-play (D-006) via the `TTSProvider` interface.
- [ ] Debrief text passes the guardrail output filter.
- [ ] React client shows a complete session flow: start → live → debrief views.
- [ ] `deepseek-v4-flash:cloud` no-think mode is used for the debrief (REQ-LLM-02).
- [ ] End-to-end smoke test passes (session → turns → branch → debrief → DB logged).
---
## 3. Wave Ordering
```
Wave 1 (foundation + risk spike — must pass before Wave 2)
├── SLICE-01 Latency spike (R1-R4) [lead-developer, backend-engineer]
└── SLICE-02 Thin vertical voice loop [lead-developer, backend-engineer, frontend-engineer]
↑ depends on SLICE-01 TTS decision
Wave 2 (scenario + state — builds on verified loop)
├── SLICE-03 Branching scenario + guardrails [lead-developer, backend-engineer, data-engineer]
└── SLICE-04 Learner state + cost logging [lead-developer, backend-engineer, data-engineer]
↑ SLICE-03 and SLICE-04 can run in parallel after Wave 1;
SLICE-04 wiring benefits from SLICE-03 branch outcome but schema is independent
Wave 3 (debrief + UX — completes the daily loop)
└── SLICE-05 Coaching debrief + full client [lead-developer, backend-engineer, frontend-engineer]
↑ depends on SLICE-03 (branch outcome + debrief config) and SLICE-04 (session turns logged)
```
**Wave 1 gate:** SLICE-01 produces the latency report + TTS decision. If R4 confirms e2e >600ms with Cartesia, Piper pre-staging becomes a SLICE-02 task before the loop is wired. Wave 2 does not start until the walking skeleton (SLICE-02) demonstrates a working end-to-end voice turn with measured latency.
**Wave 2 parallelism:** SLICE-03 (scenario + guardrails) and SLICE-04 (SQLite state) are largely independent — the schema is authored from REQUIREMENTS, not from scenario runtime. They can proceed in parallel; SLICE-04's pipeline wiring consumes SLICE-03's branch outcome, so the final wiring task in SLICE-04 depends on SLICE-03's branch classifier. In practice, start both, merge the wiring last.
**Wave 3 gate:** SLICE-05 requires both SLICE-03 (branch outcome + debrief config) and SLICE-04 (logged turns) to be verified.
---
## 4. Phase 1 Exit Criteria
All must be true for Phase 1 to ship:
1. **Full session works end-to-end:** A learner opens the React client, hears the disclaimer, speaks to the AI customer (refund scenario), the AI responds, the conversation reaches a branch outcome (accept or escalate), the learner receives a text+voice coaching debrief, and the session is logged to `praxis.db`.
2. **Latency is measured, not assumed:** `docs/latency-report.md` exists with real R1-R4 numbers. End-to-end latency is logged per session (even if >600ms — the target, with Piper mitigation if needed).
3. **TTS is behind an interface and swappable:** `PRAXIS_TTS=cartesia|piper` selects the provider with no pipeline change (D-014).
4. **LLM is behind an interface and swappable:** `LLMProvider` wraps Ollama Cloud direct API; `gemma4:cloud` (role-play) and `deepseek-v4-flash:cloud` no-think (debrief) both callable (D-020, REQ-LLM-01, REQ-LLM-02).
5. **Guardrail layer is pluggable:** `Guardrail` interface + `CustomerServiceGuardrail` implementation; disclaimer plays; ruleset unit-tested (D-019, REQ-NFR-SAFE-01).
6. **Scenario is YAML → Pydantic → Pipecat Flows:** `customer_service_refund_ca_v01.yaml` loads, validates, drives the branching runtime, and carries the `failure_mode` field (D-018, REQ-SCEN-FMT-01, REQ-SCEN-01).
7. **Interruptibility works:** Learner speech cuts AI TTS mid-utterance; AI yields (D-008, REQ-VOICE-04).
8. **Learner state persists:** SQLite has session + turns + progress + cost; single hardcoded learner, no auth (D-007, REQ-STATE-01).
9. **Cost is logged per session:** `cost_estimated_cents` non-null with a logged breakdown (REQ-NFR-COST-01, D-012 — no enforced ceiling).
10. **End-to-end smoke test passes:** `tests/test_e2e.py` (or `scripts/e2e_smoke.py`) verifies the full loop including DB assertions.
---
## 5. REQ Coverage Matrix
Every P1 must/principle REQ-ID mapped to at least one slice.
| REQ-ID | Priority | Slice(s) | Covered by task(s) |
|--------|----------|----------|--------------------|
| REQ-VOICE-01 | must | SLICE-02 | TASK-02-04 (Deepgram Nova-3 streaming ASR in pipeline) |
| REQ-VOICE-02 | must | SLICE-02 | TASK-02-02, TASK-02-04 (TTS behind interface, one voice, Cartesia/Piper) |
| REQ-VOICE-03 | must | SLICE-01, SLICE-02 | TASK-01-05, TASK-02-06 (measured e2e latency) |
| REQ-VOICE-04 | must | SLICE-02, SLICE-03 | TASK-02-04, TASK-03-05 (interruptibility, abort-and-yield) |
| REQ-SCEN-01 | must | SLICE-03 | TASK-03-02, TASK-03-03 (refund scenario, one branch, failure_mode) |
| REQ-STATE-01 | must | SLICE-04 | TASK-04-01..04-03 (SQLite, single learner, session log) |
| REQ-LLM-01 | must | SLICE-01, SLICE-02 | TASK-01-04, TASK-02-03 (gemma4:cloud direct API callable) |
| REQ-LLM-02 | must | SLICE-01, SLICE-05 | TASK-01-04, TASK-05-01 (deepseek-v4-flash:cloud no-think for debrief) |
| REQ-DEBRIEF-01 | must | SLICE-05 | TASK-05-01..05-03 (end-of-session text+voice summary) |
| REQ-ORCH-01 | must | SLICE-02 | TASK-02-04 (Pipecat + Silero VAD + interruptibility) |
| REQ-ORCH-02 | must | SLICE-03 | TASK-03-04 (pluggable guardrail + Customer Service ruleset) |
| REQ-SCEN-FMT-01 | must | SLICE-03 | TASK-03-01, TASK-03-02 (YAML DSL → Pydantic → Pipecat Flows) |
| REQ-NFR-LAT-01 | must | SLICE-01, SLICE-02 | TASK-01-05, TASK-02-06 (<600ms measured + logged) |
| REQ-NFR-SAFE-01 | must (baseline) | SLICE-03, SLICE-05 | TASK-03-04, TASK-05-02 (guardrails + disclaimer + debrief filter) |
| REQ-NFR-COST-01 | must (logging) | SLICE-04 | TASK-04-04 (per-session cost logged, no enforced ceiling) |
**Coverage: 15/15 P1 REQ-IDs mapped.** No P1 REQ is uncovered.
---
## Planning Decisions
| ID | Decision | Rationale | Confidence | Alternatives |
|----|----------|-----------|------------|--------------|
| D-P1-01 | 5 slices across 3 waves | Wave 1 = risk spike + walking skeleton (2 slices); Wave 2 = scenario + state (2 slices, parallelizable); Wave 3 = debrief + UX (1 slice). Balances risk-front-loading with vertical-slice discipline. | 0.85 | 4 slices (merge state into scenario), 6 slices (split client UX from debrief) |
| D-P1-02 | SLICE-01 is a standalone probe slice before SLICE-02 | RESEARCH.md mandates R1-R4 be spiked in week 1. Standalone probes are cheaper/faster than building the full loop first, and the TTS decision (R4) informs SLICE-02 wiring. | 0.90 | Fold probes into SLICE-02 (delays the go/no-go; risks building on the wrong TTS) |
| D-P1-03 | SLICE-02 is a thin walking skeleton (hardcoded single-turn, no branching) | Measures integrated latency on the real path before investing in scenario runtime. Quality is deliberately poor; completeness over polish. | 0.85 | Build the full branching loop directly (couples latency validation to scenario complexity) |
| D-P1-04 | SLICE-03 and SLICE-04 run in parallel in Wave 2 | The SQLite schema is authored from REQUIREMENTS, not from scenario runtime; only the final wiring task depends on the branch classifier. Parallelism shortens Wave 2. | 0.75 | Strict sequence (slower, no benefit) |
| D-P1-05 | Branch classifier (R7) uses LLM-as-judge offline at session end | Keeps the latency-critical voice loop free of a second LLM call. `deepseek-v4-flash:cloud` no-think is cheap and fast enough for a one-shot end-of-session classification. | 0.80 | Rule-based classifier (brittle), inline per-turn classifier (adds latency) |
| D-P1-06 | Debrief reuses the same `TTSProvider` (one voice, D-006) | D-006 mandates one voice persona for both role-play and mentor. No second TTS config; the debrief is just another TTS utterance via the same interface. | 0.90 | Separate mentor voice (violates D-006, adds config risk) |
---
*End of Phase 1 plan. Next step: orchestrator reviews, optionally grills (GRILL stage), then proceeds to EXECUTE on branch `phase/01-minimal-voice-loop`.*