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/server/assist/routes.py
T
Praxis CI ec397f2c65 docs(milestone): complete v0.5-live-assist — v0.1.13 tagged, milestone release, merged to main
v0.5 (Live Assist — on-the-job voice companion) milestone complete.
4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail,
v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final
review + ship, v0.1.13 = milestone release).

16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog.
469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety).
8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed.
G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for
human legal review before assist surface go-live.

---ci---
project: praxis
phase: 3
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
2026-08-04 22:35:56 +00:00

134 lines
4.6 KiB
Python

"""Assist session API routes (TASK-02-01, D-062, D-069, D-070).
POST /api/assist/shift/start — declare context, bind, create the shift
POST /api/assist/shift/end — end the shift (clean close + aggregation hook)
GET /api/assist/shift/active — return the active assist shift or {active: false}
All routes use the hardcoded learner-1 (D-007 — no learner auth in v0.5). No
operator auth on assist routes (these are learner-facing, not operator-facing).
Registered BEFORE the StaticFiles mount (routes-before-static-mount constraint).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from db.store import HARDCODED_LEARNER_ID, PraxisStore
from server.assist.consent import get_consent_disclosure
from server.assist.context import AssistContextBinder
from server.assist.mode_conflict import ModeConflictError, enforce_mutual_exclusivity
from server.assist.session import AssistSession
router = APIRouter(prefix="/api/assist", tags=["assist"])
class ShiftStartRequest(BaseModel):
path_slug: str = "customer_service"
scenario_tag: str
class ShiftEndRequest(BaseModel):
shift_id: str
outcome: str = "completed"
def _get_store(request: Request) -> PraxisStore:
"""Resolve the PraxisStore from app.state (set in lifespan) or module global."""
store = getattr(request.app.state, "praxis_store", None)
if store is None:
# Fall back to the module-level store (set in server/__main__.py).
from server.__main__ import _store
store = _store
return store
def _get_pg_store(request: Request) -> Any:
return getattr(request.app.state, "pg_store", None)
@router.post("/shift/start")
async def shift_start(body: ShiftStartRequest, request: Request) -> dict[str, Any]:
"""Start an assist shift: enforce mode-exclusivity, bind context, create session."""
store = _get_store(request)
await store.init()
learner_id = HARDCODED_LEARNER_ID
# Mode-conflict guard (REQ-IDEATE-03).
try:
await enforce_mutual_exclusivity(store, learner_id, "assist")
except ModeConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc))
# Bind context (D-059, D-066).
binder = AssistContextBinder(store)
context = await binder.bind(learner_id, body.path_slug, body.scenario_tag)
# Create the assist shift session (D-062).
pg_store = _get_pg_store(request)
session = AssistSession(store, learner_id, context, pg_store=pg_store)
shift_id = await session.start()
# Stash the AssistSession on app.state so /shift/end + the WebRTC endpoint
# can find it. Keyed by shift_id (single-learner pilot — D-007).
active_shifts: dict[str, AssistSession] = getattr(
request.app.state, "assist_shifts", {}
)
active_shifts[shift_id] = session
request.app.state.assist_shifts = active_shifts
return {
"shift_id": shift_id,
"context": {
"current_week": context.current_week,
"scenario_tag": context.scenario_tag,
"coaching_focus": context.coaching_focus,
"theta": context.theta,
},
"consent_disclosure": get_consent_disclosure(),
}
@router.post("/shift/end")
async def shift_end(body: ShiftEndRequest, request: Request) -> dict[str, Any]:
"""End an assist shift: clean close + fire the aggregation hook (D-062)."""
store = _get_store(request)
await store.init()
active_shifts: dict[str, AssistSession] = getattr(
request.app.state, "assist_shifts", {}
)
session = active_shifts.pop(body.shift_id, None)
if session is None:
# Shift not in the in-memory map (server restart) — end the DB row directly.
await store.end_session_assist(body.shift_id, body.outcome, 0, 0)
return {"ok": True, "turn_count": 0, "guardrail_block_count": 0}
outcome = await session.end(body.outcome)
return {
"ok": True,
"turn_count": outcome.get("assist_turn_count", 0),
"guardrail_block_count": outcome.get("guardrail_blocks", 0),
}
@router.get("/shift/active")
async def shift_active(request: Request) -> dict[str, Any]:
"""Return the active assist shift for the learner, or {active: false}."""
store = _get_store(request)
await store.init()
learner_id = HARDCODED_LEARNER_ID
active = await store.get_active_session(learner_id, "assist")
if active is None:
return {"active": False}
return {
"active": True,
"shift_id": active["id"],
"scenario_id": active.get("scenario_id"),
"started_at": active.get("started_at"),
}
__all__ = ["router"]