# ============================================== # Breakpilot Drive - Unit API Routes # ============================================== # Endpoints for listing/getting definitions, sessions, telemetry, # recommendations, and analytics. # CRUD definition routes are in unit_definition_routes.py. # Extracted from unit_api.py for file-size compliance. from fastapi import APIRouter, HTTPException, Query, Depends, Request from typing import List, Optional, Dict, Any from datetime import datetime, timedelta import uuid import logging from unit_models import ( UnitDefinitionResponse, CreateSessionRequest, SessionResponse, TelemetryPayload, TelemetryResponse, CompleteSessionRequest, SessionSummaryResponse, UnitListItem, RecommendedUnit, ) from unit_helpers import ( get_optional_current_user, get_unit_database, create_session_token, get_session_from_token, REQUIRE_AUTH, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) # ============================================== # Definition List/Get Endpoints # ============================================== @router.get("/definitions", response_model=List[UnitListItem]) async def list_unit_definitions( template: Optional[str] = Query(None, description="Filter by template: flight_path, station_loop"), grade: Optional[str] = Query(None, description="Filter by grade level"), locale: str = Query("de-DE", description="Filter by locale"), user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> List[UnitListItem]: """ List available unit definitions. Returns published units matching the filter criteria. """ db = await get_unit_database() if db: try: units = await db.list_units( template=template, grade=grade, locale=locale, published_only=True ) return [ UnitListItem( unit_id=u["unit_id"], template=u["template"], difficulty=u["difficulty"], duration_minutes=u["duration_minutes"], locale=u["locale"], grade_band=u["grade_band"], ) for u in units ] except Exception as e: logger.error(f"Failed to list units: {e}") # Fallback: return demo unit return [ UnitListItem( unit_id="demo_unit_v1", template="flight_path", difficulty="base", duration_minutes=5, locale=["de-DE"], grade_band=["5", "6", "7"], ) ] @router.get("/definitions/{unit_id}", response_model=UnitDefinitionResponse) async def get_unit_definition( unit_id: str, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> UnitDefinitionResponse: """ Get a specific unit definition. Returns the full unit configuration including stops, interactions, etc. """ db = await get_unit_database() if db: try: unit = await db.get_unit_definition(unit_id) if unit: return UnitDefinitionResponse( unit_id=unit["unit_id"], template=unit["template"], version=unit["version"], locale=unit["locale"], grade_band=unit["grade_band"], duration_minutes=unit["duration_minutes"], difficulty=unit["difficulty"], definition=unit["definition"], ) except Exception as e: logger.error(f"Failed to get unit definition: {e}") # Demo unit fallback if unit_id == "demo_unit_v1": return UnitDefinitionResponse( unit_id="demo_unit_v1", template="flight_path", version="1.0.0", locale=["de-DE"], grade_band=["5", "6", "7"], duration_minutes=5, difficulty="base", definition={ "unit_id": "demo_unit_v1", "template": "flight_path", "version": "1.0.0", "learning_objectives": ["Demo: Grundfunktion testen"], "stops": [ {"stop_id": "stop_1", "label": {"de-DE": "Start"}, "interaction": {"type": "aim_and_pass"}}, {"stop_id": "stop_2", "label": {"de-DE": "Mitte"}, "interaction": {"type": "aim_and_pass"}}, {"stop_id": "stop_3", "label": {"de-DE": "Ende"}, "interaction": {"type": "aim_and_pass"}}, ], "teacher_controls": {"allow_skip": True, "allow_replay": True}, }, ) raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") # ============================================== # Session Endpoints # ============================================== @router.post("/sessions", response_model=SessionResponse) async def create_unit_session( request_data: CreateSessionRequest, request: Request, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> SessionResponse: """ Create a new unit session. - Validates unit exists - Creates session record - Returns session token for telemetry """ session_id = str(uuid.uuid4()) expires_at = datetime.utcnow() + timedelta(hours=4) # Validate unit exists db = await get_unit_database() if db: try: unit = await db.get_unit_definition(request_data.unit_id) if not unit: raise HTTPException(status_code=404, detail=f"Unit not found: {request_data.unit_id}") # Create session in database total_stops = len(unit.get("definition", {}).get("stops", [])) await db.create_session( session_id=session_id, unit_id=request_data.unit_id, student_id=request_data.student_id, locale=request_data.locale, difficulty=request_data.difficulty, total_stops=total_stops, ) except HTTPException: raise except Exception as e: logger.error(f"Failed to create session: {e}") # Continue with in-memory fallback # Create session token session_token = create_session_token(session_id, request_data.student_id) # Build definition URL base_url = str(request.base_url).rstrip("/") definition_url = f"{base_url}/api/units/definitions/{request_data.unit_id}" return SessionResponse( session_id=session_id, unit_definition_url=definition_url, session_token=session_token, telemetry_endpoint="/api/units/telemetry", expires_at=expires_at, ) @router.post("/telemetry", response_model=TelemetryResponse) async def receive_telemetry( payload: TelemetryPayload, request: Request, ) -> TelemetryResponse: """ Receive batched telemetry events from Unity client. - Validates session token - Stores events in database - Returns count of accepted events """ # Verify session token session_data = await get_session_from_token(request) if session_data is None: # Allow without auth in dev mode if REQUIRE_AUTH: raise HTTPException(status_code=401, detail="Invalid or expired session token") logger.warning("Telemetry received without valid token (dev mode)") # Verify session_id matches if session_data and session_data.get("session_id") != payload.session_id: raise HTTPException(status_code=403, detail="Session ID mismatch") accepted = 0 db = await get_unit_database() for event in payload.events: try: # Set timestamp if not provided timestamp = event.ts or datetime.utcnow().isoformat() if db: await db.store_telemetry_event( session_id=payload.session_id, event_type=event.type, stop_id=event.stop_id, timestamp=timestamp, metrics=event.metrics, ) accepted += 1 logger.debug(f"Telemetry: {event.type} for session {payload.session_id}") except Exception as e: logger.error(f"Failed to store telemetry event: {e}") return TelemetryResponse(accepted=accepted) @router.post("/sessions/{session_id}/complete", response_model=SessionSummaryResponse) async def complete_session( session_id: str, request_data: CompleteSessionRequest, request: Request, ) -> SessionSummaryResponse: """ Complete a unit session. - Processes postcheck answers if provided - Calculates learning gain - Returns summary and recommendations """ # Verify session token session_data = await get_session_from_token(request) if REQUIRE_AUTH and session_data is None: raise HTTPException(status_code=401, detail="Invalid or expired session token") db = await get_unit_database() summary = {} recommendations = {} if db: try: # Get session data session = await db.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") # Calculate postcheck score if answers provided postcheck_score = None if request_data.postcheck_answers: # Simple scoring: count correct answers # In production, would validate against question bank postcheck_score = len(request_data.postcheck_answers) * 0.2 # Placeholder postcheck_score = min(postcheck_score, 1.0) # Complete session in database await db.complete_session( session_id=session_id, postcheck_score=postcheck_score, ) # Get updated session summary session = await db.get_session(session_id) # Calculate learning gain pre_score = session.get("precheck_score") post_score = session.get("postcheck_score") learning_gain = None if pre_score is not None and post_score is not None: learning_gain = post_score - pre_score summary = { "session_id": session_id, "unit_id": session.get("unit_id"), "duration_seconds": session.get("duration_seconds"), "completion_rate": session.get("completion_rate"), "precheck_score": pre_score, "postcheck_score": post_score, "pre_to_post_gain": learning_gain, "stops_completed": session.get("stops_completed"), "total_stops": session.get("total_stops"), } # Get recommendations recommendations = await db.get_recommendations( student_id=session.get("student_id"), completed_unit_id=session.get("unit_id"), ) except HTTPException: raise except Exception as e: logger.error(f"Failed to complete session: {e}") summary = {"session_id": session_id, "error": str(e)} else: # Fallback summary summary = { "session_id": session_id, "duration_seconds": 0, "completion_rate": 1.0, "message": "Database not available", } return SessionSummaryResponse( summary=summary, next_recommendations=recommendations or { "h5p_activity_ids": [], "worksheet_pdf_url": None, }, ) @router.get("/sessions/{session_id}") async def get_session( session_id: str, request: Request, ) -> Dict[str, Any]: """ Get session details. Returns current state of a session including progress. """ # Verify session token session_data = await get_session_from_token(request) if REQUIRE_AUTH and session_data is None: raise HTTPException(status_code=401, detail="Invalid or expired session token") db = await get_unit_database() if db: try: session = await db.get_session(session_id) if session: return session except Exception as e: logger.error(f"Failed to get session: {e}") raise HTTPException(status_code=404, detail="Session not found") # ============================================== # Recommendations & Analytics # ============================================== @router.get("/recommendations/{student_id}", response_model=List[RecommendedUnit]) async def get_recommendations( student_id: str, grade: Optional[str] = Query(None, description="Grade level filter"), locale: str = Query("de-DE", description="Locale filter"), limit: int = Query(5, ge=1, le=20), user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> List[RecommendedUnit]: """ Get recommended units for a student. Based on completion status and performance. """ db = await get_unit_database() if db: try: recommendations = await db.get_student_recommendations( student_id=student_id, grade=grade, locale=locale, limit=limit, ) return [ RecommendedUnit( unit_id=r["unit_id"], template=r["template"], difficulty=r["difficulty"], reason=r["reason"], ) for r in recommendations ] except Exception as e: logger.error(f"Failed to get recommendations: {e}") # Fallback: recommend demo unit return [ RecommendedUnit( unit_id="demo_unit_v1", template="flight_path", difficulty="base", reason="Neu: Noch nicht gespielt", ) ] @router.get("/analytics/student/{student_id}") async def get_student_analytics( student_id: str, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> Dict[str, Any]: """ Get unit analytics for a student. Includes completion rates, learning gains, time spent. """ db = await get_unit_database() if db: try: analytics = await db.get_student_unit_analytics(student_id) return analytics except Exception as e: logger.error(f"Failed to get analytics: {e}") return { "student_id": student_id, "units_attempted": 0, "units_completed": 0, "avg_completion_rate": 0.0, "avg_learning_gain": None, "total_minutes": 0, } @router.get("/analytics/unit/{unit_id}") async def get_unit_analytics( unit_id: str, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> Dict[str, Any]: """ Get analytics for a specific unit. Shows aggregate performance across all students. """ db = await get_unit_database() if db: try: analytics = await db.get_unit_performance(unit_id) return analytics except Exception as e: logger.error(f"Failed to get unit analytics: {e}") return { "unit_id": unit_id, "total_sessions": 0, "completed_sessions": 0, "completion_percent": 0.0, "avg_duration_minutes": 0, "avg_learning_gain": None, } @router.get("/health") async def health_check() -> Dict[str, Any]: """Health check for unit API.""" db = await get_unit_database() db_status = "connected" if db else "disconnected" return { "status": "healthy", "service": "breakpilot-units", "database": db_status, "auth_required": REQUIRE_AUTH, }