"""Guided Learning API Router.""" from __future__ import annotations import html import json from fastapi import APIRouter, HTTPException from pydantic import BaseModel from pydantic import ValidationError as PydanticValidationError from deeptutor.learning import policy as learning_policy from deeptutor.learning import prompts as learning_prompts from deeptutor.learning.models import ( KnowledgePoint, KnowledgeType, LearningModule, LearningStage, ) from deeptutor.learning.service import LearningService from deeptutor.learning.storage import LearningStore from deeptutor.services.settings.interface_settings import get_ui_language from deeptutor.utils.json_parser import parse_json_response router = APIRouter() def get_learning_service() -> LearningService: # Create a fresh store + service per request to avoid object-level race conditions. store = LearningStore() return LearningService(store) def _validate_book_id(book_id: str) -> None: """Reject empty or path-traversal-bearing book ids (shared by all endpoints).""" if not book_id or ".." in book_id or "/" in book_id or "\\" in book_id or ":" in book_id: raise HTTPException(status_code=400, detail="Invalid book_id") def _parse_modules(body_modules: list[dict]) -> list[LearningModule]: """Parse raw module dicts into LearningModule objects (shared by init/replace).""" modules: list[LearningModule] = [] for i, m in enumerate(body_modules): kps_data = m.get("knowledge_points", []) try: kps = [KnowledgePoint(**kp) for kp in kps_data] except PydanticValidationError as exc: raise HTTPException( status_code=422, detail=f"Invalid knowledge_point data in modules[{i}]: {exc.errors()}", ) from exc # Remove knowledge_points from m to avoid duplicate argument to LearningModule. m_clean = {k: v for k, v in m.items() if k != "knowledge_points"} try: modules.append(LearningModule(knowledge_points=kps, **m_clean)) except PydanticValidationError as exc: raise HTTPException( status_code=422, detail=f"Invalid module data in modules[{i}]: {exc.errors()}", ) from exc return modules def _validate_runnable_modules(modules: list[LearningModule], *, status_code: int = 400) -> None: if not modules: raise HTTPException( status_code=status_code, detail="At least one learning module is required" ) for mod in modules: if not mod.knowledge_points: raise HTTPException( status_code=status_code, detail=f"Module {mod.id!r} must contain at least one knowledge point", ) async def _cancel_active_learning_turn(book_id: str) -> None: from deeptutor.services.session import get_turn_runtime_manager runtime = get_turn_runtime_manager() active_turn = await runtime.store.get_active_turn(book_id) if active_turn: await runtime.cancel_turn(active_turn["id"]) # ── Request models ─────────────────────────────────────────────────────────── class InitModulesRequest(BaseModel): modules: list[dict] # list of LearningModule-compatible dicts class ChapterImport(BaseModel): title: str knowledge_points: list[str] = [] class ImportFromBookRequest(BaseModel): chapters: list[ChapterImport] # ── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/progress") async def list_all_progress(): service = get_learning_service() return service.list_progress() @router.get("/progress/{book_id}") async def get_progress(book_id: str): _validate_book_id(book_id) service = get_learning_service() progress = service.get_or_create(book_id) return progress.model_dump() @router.get("/progress/{book_id}/map") async def get_progress_map(book_id: str): """The dashboard view of a path: the gate-decided next step plus a map of every objective's status (new / learning / mastered). The per-type gate lives in ``learning.policy`` so the dashboard and the tutor agree.""" _validate_book_id(book_id) service = get_learning_service() progress = service.get_or_create(book_id) return { "book_id": book_id, "next": learning_policy.next_objective(progress).to_dict(), "map": learning_policy.map_summary(progress), } @router.post("/progress/{book_id}/init-modules") async def init_modules(book_id: str, body: InitModulesRequest): _validate_book_id(book_id) modules = _parse_modules(body.modules) _validate_runnable_modules(modules) await _cancel_active_learning_turn(book_id) service = get_learning_service() progress = service.get_or_create(book_id) service.init_modules(progress, modules) progress.current_module_id = modules[0].id progress.current_kp_index = 0 service.save(progress) return {"status": "ok", "module_count": len(modules)} @router.post("/progress/{book_id}/import-from-book") async def import_from_book(book_id: str, body: ImportFromBookRequest): _validate_book_id(book_id) modules = [] for i, ch in enumerate(body.chapters): kps = [ KnowledgePoint( id=f"{book_id}_ch{i}_kp{j}", name=kp_name, type=KnowledgeType("concept"), module_id=f"{book_id}_ch{i}", ) for j, kp_name in enumerate(ch.knowledge_points) ] modules.append( LearningModule( id=f"{book_id}_ch{i}", name=ch.title or f"Chapter {i + 1}", order=i, pass_threshold=0.7, knowledge_points=kps, ) ) _validate_runnable_modules(modules) await _cancel_active_learning_turn(book_id) service = get_learning_service() progress = service.get_or_create(book_id) service.init_modules(progress, modules) progress.current_module_id = modules[0].id progress.current_kp_index = 0 service.save(progress) return {"status": "ok", "module_count": len(modules)} @router.delete("/progress/{book_id}") async def delete_progress(book_id: str): _validate_book_id(book_id) store = LearningStore() if not store.exists(book_id): raise HTTPException(status_code=404, detail="Progress not found") store.delete(book_id) return {"status": "ok"} @router.post("/progress/{book_id}/redo") async def redo_progress(book_id: str): _validate_book_id(book_id) store = LearningStore() progress = store.load(book_id) if progress is None: raise HTTPException(status_code=404, detail="Progress not found") progress.current_stage = LearningStage.DIAGNOSTIC progress.mastery_levels = {} progress.qualitative_mastery = {} progress.quiz_attempts = [] progress.error_records = [] progress.repetition_states = {} progress.review_queue = [] progress.pending_question = None progress.feynman_retries = {} progress.feynman_explanations = {} progress.stage_failure_counts = {} progress.stage_failure_notes = {} progress.diagnostic = None progress.current_kp_index = 0 progress.current_module_id = progress.modules[0].id if progress.modules else "" store.save(progress) return {"status": "ok"} class NotebookRecordInput(BaseModel): id: str type: str = "note" title: str = "" output: str = "" class GenerateFromNotebookRequest(BaseModel): notebook_id: str records: list[NotebookRecordInput] @router.post("/progress/{book_id}/generate-from-notebook") async def generate_from_notebook(book_id: str, body: GenerateFromNotebookRequest): _validate_book_id(book_id) if not body.records: raise HTTPException(status_code=400, detail="No records provided") records_data = [ { "type": html.escape(r.type[:50], quote=False), "title": html.escape(r.title[:200], quote=False), "output": html.escape(r.output[:500], quote=False), } for r in body.records[:20] ] records_json = json.dumps(records_data, ensure_ascii=False) from deeptutor.services.llm import complete language = get_ui_language() system_prompt, prompt = learning_prompts.notebook_generation_prompts(language, records_json) response = await complete(prompt=prompt, system_prompt=system_prompt) # LLMs commonly fence/slightly-malform JSON; use the shared fence-stripping # repair parser instead of bare json.loads so the common case isn't a 502. data = parse_json_response(response, fallback=None) if not isinstance(data, dict): raise HTTPException(status_code=502, detail="LLM returned invalid JSON") modules_raw = data.get("modules", []) if not isinstance(modules_raw, list): raise HTTPException( status_code=502, detail="LLM returned invalid structure: modules is not a list" ) _ALLOWED_KP_TYPES = {"memory", "concept", "procedure", "design"} modules = [] for i, m in enumerate(modules_raw): if not isinstance(m, dict) or "name" not in m: continue fallback_name = learning_prompts.default_module_name(language, i + 1) module_name = str(m.get("name") or fallback_name).strip()[:200] or fallback_name kps = [] for j, kp in enumerate(m.get("knowledge_points", [])): if not isinstance(kp, dict) or "name" not in kp: continue kp_name = str(kp["name"]).strip()[:200] if len(kp_name) < 2: continue kp_type = str(kp.get("type", "concept")).strip() if kp_type not in _ALLOWED_KP_TYPES: kp_type = "concept" kps.append( KnowledgePoint( id=f"{book_id}_nb{i}_kp{j}", name=kp_name, type=KnowledgeType(kp_type), module_id=f"{book_id}_nb{i}", ) ) modules.append( LearningModule( id=f"{book_id}_nb{i}", name=module_name, order=i, pass_threshold=0.7, knowledge_points=kps, ) ) _validate_runnable_modules(modules, status_code=502) await _cancel_active_learning_turn(book_id) service = get_learning_service() progress = service.get_or_create(book_id) service.init_modules(progress, modules) progress.current_module_id = modules[0].id progress.current_kp_index = 0 service.save(progress) return { "status": "ok", "module_count": len(modules), "modules": [m.model_dump() for m in modules], }