e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""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],
|
|
}
|