ec397f2c65
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---
134 lines
4.6 KiB
Python
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"] |