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