deeptutor-mastery 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. deeptutor/api/routers/mastery_path.py +1292 -0
  2. deeptutor/capabilities/mastery/__init__.py +6 -0
  3. deeptutor/capabilities/mastery/binding.py +178 -0
  4. deeptutor/capabilities/mastery/capability.py +102 -0
  5. deeptutor/capabilities/mastery/choices.py +273 -0
  6. deeptutor/capabilities/mastery/history.py +215 -0
  7. deeptutor/capabilities/mastery/loop.py +668 -0
  8. deeptutor/capabilities/mastery/memory.py +253 -0
  9. deeptutor/capabilities/mastery/mode.py +180 -0
  10. deeptutor/capabilities/mastery/pipeline.py +344 -0
  11. deeptutor/capabilities/mastery/prompts/en/mastery_loop.yaml +154 -0
  12. deeptutor/capabilities/mastery/prompts/zh/mastery_loop.yaml +149 -0
  13. deeptutor/capabilities/mastery/tools.py +3853 -0
  14. deeptutor/learning/__init__.py +59 -0
  15. deeptutor/learning/assessment.py +558 -0
  16. deeptutor/learning/event_hub.py +140 -0
  17. deeptutor/learning/grading.py +64 -0
  18. deeptutor/learning/identity.py +67 -0
  19. deeptutor/learning/lesson_evidence.py +1106 -0
  20. deeptutor/learning/mastery.py +40 -0
  21. deeptutor/learning/migration.py +324 -0
  22. deeptutor/learning/models.py +707 -0
  23. deeptutor/learning/navigation.py +348 -0
  24. deeptutor/learning/objective_relations.py +181 -0
  25. deeptutor/learning/outline_revision.py +75 -0
  26. deeptutor/learning/pending.py +288 -0
  27. deeptutor/learning/policy.py +581 -0
  28. deeptutor/learning/prompts/en.yaml +96 -0
  29. deeptutor/learning/prompts/zh.yaml +96 -0
  30. deeptutor/learning/prompts.py +156 -0
  31. deeptutor/learning/question_card.py +145 -0
  32. deeptutor/learning/scheduler.py +550 -0
  33. deeptutor/learning/semantic_assessment.py +345 -0
  34. deeptutor/learning/service.py +3355 -0
  35. deeptutor/learning/storage.py +2109 -0
  36. deeptutor/learning/teaching.py +170 -0
  37. deeptutor/learning/topic_generation.py +601 -0
  38. deeptutor/learning/topic_materials.py +592 -0
  39. deeptutor/learning/topic_naming.py +166 -0
  40. deeptutor/learning/visual_practice.py +338 -0
  41. deeptutor/services/mastery_hints.py +380 -0
  42. deeptutor/tools/mastery_nav.py +500 -0
  43. deeptutor/tools/mastery_tool.py +47 -0
  44. deeptutor_mastery-1.7.0.dist-info/METADATA +7 -0
  45. deeptutor_mastery-1.7.0.dist-info/RECORD +47 -0
  46. deeptutor_mastery-1.7.0.dist-info/WHEEL +5 -0
  47. deeptutor_mastery-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1292 @@
1
+ """Guided Learning API Router."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import asynccontextmanager, suppress
7
+ import html
8
+ import json
9
+ import re
10
+ import time
11
+ import uuid
12
+
13
+ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
14
+ from pydantic import BaseModel, Field
15
+ from pydantic import ValidationError as PydanticValidationError
16
+
17
+ from deeptutor.learning import policy as learning_policy
18
+ from deeptutor.learning import prompts as learning_prompts
19
+ from deeptutor.learning.models import (
20
+ KnowledgePoint,
21
+ KnowledgeType,
22
+ LearningModule,
23
+ LearningProgress,
24
+ MasteryInteraction,
25
+ MasteryTopic,
26
+ ReadingLearningRecords,
27
+ TopicMetadata,
28
+ TopicSource,
29
+ TopicSourceKind,
30
+ )
31
+ from deeptutor.learning.objective_relations import (
32
+ ObjectiveRelationError,
33
+ RelationRefs,
34
+ normalize_refs,
35
+ )
36
+ from deeptutor.learning.service import LearningService
37
+ from deeptutor.learning.storage import LearningStore
38
+ from deeptutor.learning.topic_generation import MAX_MODULE_LIMIT
39
+ from deeptutor.services.settings.interface_settings import get_response_language
40
+ from deeptutor.utils.json_parser import parse_json_response
41
+
42
+ router = APIRouter()
43
+ ws_router = APIRouter()
44
+
45
+ #: Signals that change what a topic screen shows without advancing the path's
46
+ #: revision, so they are forwarded even when the durable event tail is empty.
47
+ #: A conversation joining or leaving the topic changes its session list; a
48
+ #: deleted topic changes everything.
49
+ _SCREEN_ONLY_SIGNALS = frozenset({"session.bound", "session.released", "topic.deleted"})
50
+
51
+
52
+ def get_learning_service() -> LearningService:
53
+ # Create a fresh store + service per request to avoid object-level race conditions.
54
+ store = LearningStore()
55
+ return LearningService(store)
56
+
57
+
58
+ def _turn_application_service():
59
+ from deeptutor.app.container import get_application_container
60
+
61
+ return get_application_container().turns
62
+
63
+
64
+ def _validate_book_id(book_id: str) -> None:
65
+ """Reject empty or path-traversal-bearing book ids (shared by all endpoints)."""
66
+ if not book_id or ".." in book_id or "/" in book_id or "\\" in book_id or ":" in book_id:
67
+ raise HTTPException(status_code=400, detail="Invalid book_id")
68
+
69
+
70
+ def _parse_modules(body_modules: list[dict]) -> list[LearningModule]:
71
+ """Parse raw module dicts into LearningModule objects (shared by init/replace)."""
72
+ modules: list[LearningModule] = []
73
+ for i, m in enumerate(body_modules):
74
+ kps_data = m.get("knowledge_points", [])
75
+ try:
76
+ kps = [KnowledgePoint(**kp) for kp in kps_data]
77
+ except PydanticValidationError as exc:
78
+ raise HTTPException(
79
+ status_code=422,
80
+ detail=f"Invalid knowledge_point data in modules[{i}]: {exc.errors()}",
81
+ ) from exc
82
+ # Remove knowledge_points from m to avoid duplicate argument to LearningModule.
83
+ m_clean = {k: v for k, v in m.items() if k != "knowledge_points"}
84
+ try:
85
+ modules.append(LearningModule(knowledge_points=kps, **m_clean))
86
+ except PydanticValidationError as exc:
87
+ raise HTTPException(
88
+ status_code=422,
89
+ detail=f"Invalid module data in modules[{i}]: {exc.errors()}",
90
+ ) from exc
91
+ return modules
92
+
93
+
94
+ def _validate_runnable_modules(modules: list[LearningModule], *, status_code: int = 400) -> None:
95
+ if not modules:
96
+ raise HTTPException(
97
+ status_code=status_code, detail="At least one learning module is required"
98
+ )
99
+ for mod in modules:
100
+ if not mod.knowledge_points:
101
+ raise HTTPException(
102
+ status_code=status_code,
103
+ detail=f"Module {mod.id!r} must contain at least one knowledge point",
104
+ )
105
+
106
+
107
+ async def _cancel_active_learning_turn(book_id: str) -> None:
108
+ learning_store = LearningStore()
109
+ turns = _turn_application_service()
110
+ lease = await asyncio.to_thread(learning_store.get_path_lease, book_id)
111
+ if lease is not None:
112
+ if lease.session_id == "__path_api__":
113
+ # Another administrative mutation owns the path. The caller's
114
+ # acquisition attempt will return a deterministic HTTP 409.
115
+ return
116
+ if await turns.cancel_turn_and_wait(lease.turn_id):
117
+ # ``cancel_turn`` can finalize a restart orphan without an
118
+ # in-memory task, so its normal runtime ``finally`` cannot release
119
+ # the mastery path lease.
120
+ await asyncio.to_thread(
121
+ learning_store.release_path_lease,
122
+ book_id,
123
+ turn_id=lease.turn_id,
124
+ )
125
+ else:
126
+ raise HTTPException(
127
+ status_code=409,
128
+ detail="Active learning turn could not be stopped before changing this path",
129
+ )
130
+ return
131
+
132
+ # Compatibility for turns started before explicit path leases existed.
133
+ session_ids = await asyncio.to_thread(learning_store.list_session_ids, book_id)
134
+ if book_id not in session_ids:
135
+ session_ids.append(book_id)
136
+ from deeptutor.services.session import get_session_store
137
+
138
+ session_store = get_session_store()
139
+ for session_id in session_ids:
140
+ for turn in await session_store.list_active_turns(session_id):
141
+ if str(turn.get("capability") or "") == "mastery_path":
142
+ if not await turns.cancel_turn_and_wait(turn["id"]):
143
+ raise HTTPException(
144
+ status_code=409,
145
+ detail="Active learning turn could not be stopped before changing this path",
146
+ )
147
+
148
+
149
+ @asynccontextmanager
150
+ async def _exclusive_path_mutation(book_id: str):
151
+ """Cancel the tutor, then exclude a newly racing tutor/API write."""
152
+ from deeptutor.learning.storage import PathLeaseConflictError
153
+
154
+ await _cancel_active_learning_turn(book_id)
155
+ store = LearningStore()
156
+ operation_id = f"api-{uuid.uuid4().hex}"
157
+ try:
158
+ await asyncio.to_thread(
159
+ store.acquire_path_lease,
160
+ book_id,
161
+ "__path_api__",
162
+ operation_id,
163
+ bind_session=False,
164
+ )
165
+ except PathLeaseConflictError as exc:
166
+ raise HTTPException(
167
+ status_code=409,
168
+ detail=(
169
+ "Mastery path changed activity while the operation was starting; "
170
+ f"active session: {exc.lease.session_id}"
171
+ ),
172
+ ) from exc
173
+ try:
174
+ yield
175
+ finally:
176
+ await asyncio.to_thread(
177
+ store.release_path_lease,
178
+ book_id,
179
+ turn_id=operation_id,
180
+ )
181
+
182
+
183
+ # ── Request models ───────────────────────────────────────────────────────────
184
+
185
+
186
+ class KnowledgePointInput(BaseModel):
187
+ id: str | None = None
188
+ client_ref: str = ""
189
+ name: str = Field(..., min_length=1, max_length=200)
190
+ type: KnowledgeType = KnowledgeType.CONCEPT
191
+ module_id: str = ""
192
+ prerequisite_ids: list[str] = Field(default_factory=list)
193
+ prerequisite_refs: list[str] = Field(default_factory=list)
194
+ topic_source_ids: list[str] = Field(default_factory=list)
195
+ topic_source_refs: list[str] = Field(default_factory=list)
196
+
197
+
198
+ class ModuleInput(BaseModel):
199
+ id: str | None = None
200
+ name: str = Field(..., min_length=1, max_length=200)
201
+ order: int | None = None
202
+ objective: str = Field(default="", max_length=300)
203
+ pass_threshold: float = 0.7
204
+ knowledge_points: list[KnowledgePointInput] = Field(default_factory=list)
205
+
206
+
207
+ class InitModulesRequest(BaseModel):
208
+ modules: list[ModuleInput]
209
+
210
+
211
+ class RenamePathRequest(BaseModel):
212
+ """An empty name is a valid request: it restores the derived display name."""
213
+
214
+ name: str = ""
215
+
216
+
217
+ class ReviewSettingsRequest(BaseModel):
218
+ """A per-path recall target; higher values schedule shorter intervals."""
219
+
220
+ desired_retention: float = Field(..., ge=0.7, le=0.99, allow_inf_nan=False)
221
+
222
+
223
+ class ChapterImport(BaseModel):
224
+ title: str
225
+ knowledge_points: list[str] = []
226
+
227
+
228
+ class ImportFromBookRequest(BaseModel):
229
+ chapters: list[ChapterImport]
230
+
231
+
232
+ class TopicSourceRequest(BaseModel):
233
+ id: str = ""
234
+ client_ref: str = ""
235
+ kind: TopicSourceKind
236
+ source_id: str = ""
237
+ label: str = Field(..., min_length=1, max_length=200)
238
+ excerpt: str = Field(default="", max_length=8_000)
239
+ available: bool = True
240
+ metadata: dict = Field(default_factory=dict)
241
+
242
+
243
+ class GenerateTopicDraftRequest(BaseModel):
244
+ name: str = Field(..., min_length=1, max_length=120)
245
+ goal: str = Field(..., min_length=1, max_length=2_000)
246
+ sources: list[TopicSourceRequest] = Field(default_factory=list, max_length=16)
247
+ #: Documents a previous draft left out. Sent when the learner asks for
248
+ #: them to be covered, so the regeneration is told what was missed rather
249
+ #: than being asked the same question and expected to answer differently.
250
+ must_cover: list[str] = Field(default_factory=list, max_length=40)
251
+
252
+
253
+ class ConfirmTopicRequest(GenerateTopicDraftRequest):
254
+ # The learner states a goal and picks materials; naming is not one more
255
+ # box to fill before they can start. An empty name is derived from the
256
+ # goal below, and stays renameable afterwards.
257
+ name: str = Field(default="", max_length=120)
258
+ description: str = Field(default="", max_length=500)
259
+ emoji: str = Field(default="🧭", max_length=16)
260
+ # Optional: a mastery goal is created *before* it has an outline, and the
261
+ # outline is then designed with the tutor in the goal's first session.
262
+ # The region ceiling is the generator's, not a second opinion: a route
263
+ # over a fourteen-document library legitimately has more than eight, and
264
+ # this used to reject the very draft the server had just produced.
265
+ modules: list[ModuleInput] = Field(default_factory=list, max_length=MAX_MODULE_LIMIT)
266
+
267
+
268
+ class EditTopicMapRequest(BaseModel):
269
+ modules: list[ModuleInput] = Field(..., min_length=1, max_length=MAX_MODULE_LIMIT)
270
+
271
+
272
+ class LearnerOverrideRequest(BaseModel):
273
+ mastered: bool
274
+ note: str = Field(default="", max_length=500)
275
+
276
+
277
+ def _topic_sources(items: list[TopicSourceRequest]) -> list[TopicSource]:
278
+ return [
279
+ TopicSource(
280
+ id=item.id.strip() or f"source_{uuid.uuid4().hex}",
281
+ kind=item.kind,
282
+ source_id=item.source_id.strip()[:300],
283
+ label=item.label.strip(),
284
+ excerpt=item.excerpt.strip(),
285
+ position=index,
286
+ available=item.available,
287
+ metadata=dict(item.metadata),
288
+ )
289
+ for index, item in enumerate(items)
290
+ ]
291
+
292
+
293
+ def _topic_source_aliases(
294
+ items: list[TopicSourceRequest], sources: list[TopicSource]
295
+ ) -> dict[str, str]:
296
+ aliases: dict[str, str] = {}
297
+ for item, source in zip(items, sources, strict=True):
298
+ alias = item.client_ref.strip()
299
+ if alias:
300
+ if alias in aliases and aliases[alias] != source.id:
301
+ raise ObjectiveRelationError(f"Source reference {alias!r} is ambiguous")
302
+ aliases[alias] = source.id
303
+ return aliases
304
+
305
+
306
+ def _module_payloads(modules: list[ModuleInput]) -> list[dict]:
307
+ return [module.model_dump(mode="json") for module in modules]
308
+
309
+
310
+ def _relation_refs(
311
+ inputs: list[ModuleInput], modules: list[LearningModule]
312
+ ) -> dict[str, RelationRefs]:
313
+ refs: dict[str, RelationRefs] = {}
314
+ for input_module, module in zip(inputs, modules, strict=True):
315
+ for input_point, point in zip(
316
+ input_module.knowledge_points, module.knowledge_points, strict=True
317
+ ):
318
+ refs[point.id] = RelationRefs(
319
+ client_ref=input_point.client_ref.strip(),
320
+ prerequisite_refs=normalize_refs(
321
+ [*input_point.prerequisite_refs, *input_point.prerequisite_ids],
322
+ label="prerequisite",
323
+ ),
324
+ topic_source_refs=normalize_refs(
325
+ [*input_point.topic_source_refs, *input_point.topic_source_ids],
326
+ label="topic source",
327
+ ),
328
+ )
329
+ return refs
330
+
331
+
332
+ def _review_queue(progress, *, now: float | None = None) -> list[dict]:
333
+ from deeptutor.learning.scheduler import SpacedRepetitionScheduler, review_sort_key
334
+
335
+ moment = time.time() if now is None else now
336
+ scheduler = SpacedRepetitionScheduler()
337
+ names = {kp.id: kp.name for module in progress.modules for kp in module.knowledge_points}
338
+ return [
339
+ {
340
+ "id": task.id,
341
+ "knowledge_point_id": task.knowledge_point_id,
342
+ "knowledge_point_name": names.get(task.knowledge_point_id, ""),
343
+ "knowledge_type": task.knowledge_type.value,
344
+ "due_at": task.due_at,
345
+ "priority": task.priority,
346
+ "due": task.due_at <= moment,
347
+ "forgetting_risk": round(task.forgetting_risk, 3),
348
+ "reason": task.reason,
349
+ "evidence_source": task.evidence_source,
350
+ "evidence_id": task.evidence_id,
351
+ "stability": round(task.state.stability, 3),
352
+ "retrievability": round(scheduler.retrievability(task.state, now=moment), 3),
353
+ "desired_retention": task.state.desired_retention,
354
+ "lapse_count": task.state.lapse_count,
355
+ "recent_failure": bool(
356
+ task.state.consecutive_wrong
357
+ or any(
358
+ error.knowledge_point_id == task.knowledge_point_id
359
+ and error.status in ("active", "retrying")
360
+ for error in progress.error_records
361
+ )
362
+ ),
363
+ }
364
+ for task in sorted(
365
+ learning_policy.reviewable_tasks(progress),
366
+ key=lambda item: review_sort_key(item, now=moment),
367
+ )
368
+ ]
369
+
370
+
371
+ def _read_projection(progress, *, now: float | None = None):
372
+ """Build a fresh queue for reads without persisting hydration or time drift."""
373
+ from deeptutor.learning.scheduler import SpacedRepetitionScheduler
374
+
375
+ projected = progress.model_copy(deep=True)
376
+ moment = time.time() if now is None else now
377
+ projected.review_queue = SpacedRepetitionScheduler().build_review_queue(projected, now=moment)
378
+ return projected
379
+
380
+
381
+ def _next_step_payload(store: LearningStore, path_id: str, progress) -> dict:
382
+ interaction = (
383
+ store.get_active_interaction(path_id) if progress.pending_question is not None else None
384
+ )
385
+ return _next_step_from_interaction(progress, interaction)
386
+
387
+
388
+ def _next_step_from_interaction(
389
+ progress: LearningProgress,
390
+ interaction: MasteryInteraction | None,
391
+ ) -> dict:
392
+ return learning_policy.next_objective(
393
+ progress,
394
+ pending_session_id=interaction.session_id if interaction is not None else "",
395
+ ).to_dict()
396
+
397
+
398
+ def _topic_payload_from_snapshot(
399
+ progress: LearningProgress,
400
+ topic: MasteryTopic,
401
+ session_count: int,
402
+ active_interaction: MasteryInteraction | None,
403
+ ) -> dict:
404
+ path_id = progress.book_id
405
+ moment = time.time()
406
+ projected = _read_projection(progress, now=moment)
407
+ return {
408
+ "path_id": path_id,
409
+ "name": learning_policy.path_display_name(progress),
410
+ "metadata": topic.metadata.model_dump(mode="json"),
411
+ "sources": [source.model_dump(mode="json") for source in topic.sources],
412
+ "path_revision": progress.version,
413
+ "next": learning_policy.next_objective(
414
+ projected,
415
+ now=moment,
416
+ pending_session_id=active_interaction.session_id if active_interaction else "",
417
+ ).to_dict(),
418
+ "map": learning_policy.map_summary(projected, now=moment),
419
+ "reviews": _review_queue(projected, now=moment),
420
+ "review_settings": {
421
+ "desired_retention": progress.desired_retention,
422
+ "scope": "path",
423
+ },
424
+ # Who this goal is for. Null until intake has happened, which is also
425
+ # what the dashboard renders as "not asked yet".
426
+ "learner_profile": (
427
+ progress.learner_profile.model_dump(mode="json")
428
+ if progress.learner_profile is not None and not progress.learner_profile.is_empty()
429
+ else None
430
+ ),
431
+ "session_count": session_count,
432
+ "updated_at": progress.updated_at,
433
+ }
434
+
435
+
436
+ def _topic_payload(store: LearningStore, path_id: str) -> dict:
437
+ progress = store.load(path_id)
438
+ if progress is None:
439
+ raise HTTPException(status_code=404, detail="Mastery topic not found")
440
+ topic = store.get_topic(path_id, progress=progress)
441
+ if topic is None: # pragma: no cover - a loaded path always synthesizes metadata
442
+ raise HTTPException(status_code=404, detail="Mastery topic not found")
443
+ active_interaction = (
444
+ store.get_active_interaction(path_id) if progress.pending_question is not None else None
445
+ )
446
+ return _topic_payload_from_snapshot(
447
+ progress,
448
+ topic,
449
+ len(store.list_session_ids(path_id)),
450
+ active_interaction,
451
+ )
452
+
453
+
454
+ # ── Endpoints ────────────────────────────────────────────────────────────────
455
+
456
+
457
+ @router.get("/topics")
458
+ async def list_topics():
459
+ store = LearningStore()
460
+ topics = await asyncio.to_thread(
461
+ lambda: [
462
+ _topic_payload_from_snapshot(*snapshot)
463
+ for snapshot in store.list_topic_snapshots(status="active")
464
+ ]
465
+ )
466
+ return {"topics": topics}
467
+
468
+
469
+ @router.get("/topics/index")
470
+ async def list_topic_index():
471
+ """Just enough to *name* a topic: id, title, emoji.
472
+
473
+ The sidebar groups study conversations under their topic, and it refreshes
474
+ on every stream end. ``/topics`` answers with each path's whole knowledge
475
+ map, review queue and source excerpts — kilobytes per topic, none of which
476
+ a group header renders. This is the same walk with the payload cut to what
477
+ a label needs.
478
+
479
+ Declared above ``/topics/{path_id}``: that route matches any single
480
+ segment, so a literal path below it would never be reached.
481
+ """
482
+ store = LearningStore()
483
+ return {
484
+ "topics": await asyncio.to_thread(
485
+ lambda: [
486
+ {
487
+ "path_id": progress.book_id,
488
+ "name": learning_policy.path_display_name(progress),
489
+ "emoji": topic.metadata.emoji,
490
+ }
491
+ for progress, topic, _session_count, _interaction in store.list_topic_snapshots(
492
+ status="active"
493
+ )
494
+ ]
495
+ )
496
+ }
497
+
498
+
499
+ @router.get("/reading/records", response_model=ReadingLearningRecords)
500
+ async def list_reading_learning_records() -> ReadingLearningRecords:
501
+ """Reading progress and extension activity for the current account."""
502
+
503
+ store = LearningStore()
504
+ return await asyncio.to_thread(store.list_reading_records)
505
+
506
+
507
+ @router.post("/topics/draft")
508
+ async def generate_topic_route(body: GenerateTopicDraftRequest):
509
+ from deeptutor.learning.topic_generation import TopicGenerationError, generate_topic_draft
510
+
511
+ try:
512
+ return await generate_topic_draft(
513
+ name=body.name,
514
+ goal=body.goal,
515
+ sources=_topic_sources(body.sources),
516
+ language=get_response_language(),
517
+ must_cover=body.must_cover,
518
+ )
519
+ except TopicGenerationError as exc:
520
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
521
+
522
+
523
+ #: Longest provisional name derived from a goal. A goal is a paragraph; a name
524
+ #: has to fit on a card.
525
+ _PROVISIONAL_NAME_CHARS = 32
526
+
527
+
528
+ def _provisional_name(goal: str) -> str:
529
+ """A card-sized name for a goal the learner did not name themselves.
530
+
531
+ The goal's own opening clause, because that is the sentence they wrote and
532
+ the one they will recognise in a list. Anything is better than the storage
533
+ id, which is what an unnamed path displayed before goals could be created
534
+ without an outline to borrow a module name from.
535
+ """
536
+ head = re.split(r"[\n。.!?!?;;]", str(goal or "").strip(), maxsplit=1)[0].strip()
537
+ if not head:
538
+ return ""
539
+ if len(head) <= _PROVISIONAL_NAME_CHARS:
540
+ return head
541
+ return head[:_PROVISIONAL_NAME_CHARS].rstrip() + "…"
542
+
543
+
544
+ @router.post("/topics")
545
+ async def create_topic(body: ConfirmTopicRequest):
546
+ from deeptutor.learning.topic_generation import (
547
+ TopicGenerationError,
548
+ materialize_modules,
549
+ )
550
+
551
+ path_id = f"topic_{uuid.uuid4().hex}"
552
+ modules = []
553
+ if body.modules:
554
+ try:
555
+ modules = materialize_modules(
556
+ path_id,
557
+ _module_payloads(body.modules),
558
+ strict=True,
559
+ module_limit=MAX_MODULE_LIMIT,
560
+ )
561
+ except TopicGenerationError as exc:
562
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
563
+ sources = _topic_sources(body.sources)
564
+ source_aliases = _topic_source_aliases(body.sources, sources)
565
+ store = LearningStore()
566
+ metadata = TopicMetadata(
567
+ path_id=path_id,
568
+ goal=body.goal.strip(),
569
+ description=body.description.strip(),
570
+ emoji=body.emoji.strip() or "🧭",
571
+ map_seed=store._default_map_seed(path_id),
572
+ )
573
+ # A name is a different object from a goal: the goal is the sentence they
574
+ # wrote, the name is the label they will recognise in a list months later.
575
+ # The task model writes it; the truncated goal stands in when it cannot.
576
+ resolved_name = body.name.strip()
577
+ if not resolved_name:
578
+ from deeptutor.learning.topic_naming import suggest_topic_name
579
+
580
+ resolved_name = await suggest_topic_name(
581
+ body.goal,
582
+ source_labels=[source.label for source in sources],
583
+ ) or _provisional_name(body.goal)
584
+ service = LearningService(store)
585
+ try:
586
+ progress = await asyncio.to_thread(
587
+ service.create_topic,
588
+ path_id,
589
+ name=resolved_name,
590
+ modules=modules,
591
+ metadata=metadata,
592
+ sources=sources,
593
+ relation_refs=_relation_refs(body.modules, modules),
594
+ source_aliases=source_aliases,
595
+ )
596
+ except ObjectiveRelationError as exc:
597
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
598
+ payload = await asyncio.to_thread(_topic_payload, store, path_id)
599
+ payload["path_revision"] = progress.version
600
+ return payload
601
+
602
+
603
+ @router.get("/topics/{path_id}")
604
+ async def get_topic(path_id: str):
605
+ _validate_book_id(path_id)
606
+ return await asyncio.to_thread(_topic_payload, LearningStore(), path_id)
607
+
608
+
609
+ @router.get("/topics/{path_id}/review-settings")
610
+ async def get_review_settings(path_id: str):
611
+ _validate_book_id(path_id)
612
+ progress = await asyncio.to_thread(LearningStore().load, path_id)
613
+ if progress is None:
614
+ raise HTTPException(status_code=404, detail="Mastery topic not found")
615
+ return {"desired_retention": progress.desired_retention, "scope": "path"}
616
+
617
+
618
+ @router.put("/topics/{path_id}/review-settings")
619
+ async def update_review_settings(path_id: str, body: ReviewSettingsRequest):
620
+ _validate_book_id(path_id)
621
+ if not await asyncio.to_thread(LearningStore().exists, path_id):
622
+ raise HTTPException(status_code=404, detail="Mastery topic not found")
623
+ async with _exclusive_path_mutation(path_id):
624
+ store = LearningStore()
625
+
626
+ def update(tx):
627
+ from deeptutor.learning.scheduler import SpacedRepetitionScheduler
628
+
629
+ if tx.progress.desired_retention == body.desired_retention and all(
630
+ state.desired_retention == body.desired_retention
631
+ for state in tx.progress.repetition_states.values()
632
+ ):
633
+ return
634
+ SpacedRepetitionScheduler().set_desired_retention(tx.progress, body.desired_retention)
635
+ tx.touch()
636
+ tx.emit(
637
+ "review.settings_changed",
638
+ {"desired_retention": tx.progress.desired_retention},
639
+ )
640
+
641
+ try:
642
+ await asyncio.to_thread(store.mutate, path_id, update)
643
+ except KeyError as exc:
644
+ raise HTTPException(status_code=404, detail="Mastery topic not found") from exc
645
+ return await asyncio.to_thread(_topic_payload, LearningStore(), path_id)
646
+
647
+
648
+ @router.put("/topics/{path_id}/map")
649
+ async def edit_topic_map(path_id: str, body: EditTopicMapRequest):
650
+ _validate_book_id(path_id)
651
+ from deeptutor.learning.topic_generation import (
652
+ TopicGenerationError,
653
+ materialize_modules,
654
+ )
655
+
656
+ async with _exclusive_path_mutation(path_id):
657
+ store = LearningStore()
658
+ progress = await asyncio.to_thread(store.load, path_id)
659
+ if progress is None:
660
+ raise HTTPException(status_code=404, detail="Mastery topic not found")
661
+ existing_module_ids = {module.id for module in progress.modules}
662
+ existing_objective_ids = {
663
+ point.id for module in progress.modules for point in module.knowledge_points
664
+ }
665
+ try:
666
+ modules = materialize_modules(
667
+ path_id,
668
+ _module_payloads(body.modules),
669
+ strict=True,
670
+ existing_module_ids=existing_module_ids,
671
+ existing_objective_ids=existing_objective_ids,
672
+ existing_objective_counts={
673
+ module.id: len(module.knowledge_points) for module in progress.modules
674
+ },
675
+ module_limit=MAX_MODULE_LIMIT,
676
+ )
677
+ except TopicGenerationError as exc:
678
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
679
+ try:
680
+ await asyncio.to_thread(
681
+ LearningService(store).replace_modules_for_path,
682
+ path_id,
683
+ modules,
684
+ event_type="topic.map_edited",
685
+ relation_refs=_relation_refs(body.modules, modules),
686
+ )
687
+ except ObjectiveRelationError as exc:
688
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
689
+ return await asyncio.to_thread(_topic_payload, LearningStore(), path_id)
690
+
691
+
692
+ @router.post("/topics/{path_id}/objectives/{kp_id}/override")
693
+ async def set_learner_override(
694
+ path_id: str,
695
+ kp_id: str,
696
+ body: LearnerOverrideRequest,
697
+ ):
698
+ _validate_book_id(path_id)
699
+ async with _exclusive_path_mutation(path_id):
700
+ try:
701
+ progress = await asyncio.to_thread(
702
+ LearningService(LearningStore()).set_learner_mastery_override,
703
+ path_id,
704
+ kp_id,
705
+ mastered=body.mastered,
706
+ note=body.note,
707
+ )
708
+ except Exception as exc:
709
+ from deeptutor.learning.service import MasteryInteractionError
710
+
711
+ if isinstance(exc, MasteryInteractionError):
712
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
713
+ raise
714
+ return {
715
+ "status": "ok",
716
+ "path_revision": progress.version,
717
+ "map": learning_policy.map_summary(progress),
718
+ }
719
+
720
+
721
+ @router.get("/topics/{path_id}/sessions")
722
+ async def list_topic_sessions(path_id: str):
723
+ _validate_book_id(path_id)
724
+ learning_store = LearningStore()
725
+ if not await asyncio.to_thread(learning_store.exists, path_id):
726
+ raise HTTPException(status_code=404, detail="Mastery topic not found")
727
+ # The same walk chat's navigation tools use (``learning.navigation``), so
728
+ # the atlas screen and a hand-off card can never disagree about which
729
+ # conversations a topic has.
730
+ from deeptutor.learning.navigation import topic_sessions
731
+
732
+ return {
733
+ "path_id": path_id,
734
+ "sessions": await topic_sessions(path_id, store=learning_store),
735
+ }
736
+
737
+
738
+ class SetSessionModeRequest(BaseModel):
739
+ mode: str = Field(..., max_length=32)
740
+
741
+
742
+ @router.put("/topics/{path_id}/sessions/{session_id}/mode")
743
+ async def set_session_mode(path_id: str, session_id: str, body: SetSessionModeRequest):
744
+ """Change what a conversation is doing, from the learner's own buttons.
745
+
746
+ The same move the tutor makes with ``mastery_mode``, through the same
747
+ admission rule — so pressing "Study" on a goal with no outline is refused
748
+ with the sentence the tutor would have said, rather than silently putting
749
+ the conversation somewhere its tools will then refuse to work.
750
+ """
751
+ from deeptutor.capabilities.mastery.mode import MODES, admission_error, normalize_mode
752
+
753
+ _validate_book_id(path_id)
754
+ requested = str(body.mode or "").strip().lower()
755
+ if requested not in MODES:
756
+ raise HTTPException(
757
+ status_code=422,
758
+ detail=f"mode must be one of: {', '.join(MODES)}",
759
+ )
760
+
761
+ store = LearningStore()
762
+ progress = await asyncio.to_thread(store.load, path_id)
763
+ has_outline = progress is not None and any(
764
+ module.knowledge_points for module in progress.modules
765
+ )
766
+ refusal = admission_error(requested, has_outline=has_outline)
767
+ if refusal:
768
+ raise HTTPException(status_code=409, detail=refusal)
769
+
770
+ from deeptutor.services.session import get_session_store
771
+
772
+ session_store = get_session_store()
773
+ if await session_store.get_session(session_id) is None:
774
+ raise HTTPException(status_code=404, detail="Session not found")
775
+ await session_store.update_session_preferences(session_id, {"mastery_session_mode": requested})
776
+ return {"session_id": session_id, "mode": normalize_mode(requested)}
777
+
778
+
779
+ @router.get("/topics/{path_id}/ask-hint")
780
+ async def get_topic_ask_hint(path_id: str, session_id: str = ""):
781
+ """One question the learner could ask here, for the composer placeholder.
782
+
783
+ Written by the task model, never blocking: an empty ``hint`` means the
784
+ composer keeps the static placeholder it has always had.
785
+ """
786
+ _validate_book_id(path_id)
787
+ from deeptutor.services.mastery_hints import get_ask_hint
788
+
789
+ return await get_ask_hint(path_id, session_id)
790
+
791
+
792
+ @ws_router.websocket("/mastery-paths")
793
+ async def mastery_topic_websocket(ws: WebSocket) -> None:
794
+ """Subscribe to one living topic with durable revision replay."""
795
+
796
+ from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
797
+ from deeptutor.learning.event_hub import mastery_topic_event_hub
798
+ from deeptutor.multi_user.context import reset_current_user
799
+
800
+ user_token = await ws_require_auth(ws)
801
+ if user_token is ws_auth_failed:
802
+ return
803
+ await ws.accept()
804
+ send_lock = asyncio.Lock()
805
+ subscription = None
806
+ forward_task: asyncio.Task | None = None
807
+ cursor = 0
808
+
809
+ async def send(payload: dict) -> None:
810
+ async with send_lock:
811
+ await ws.send_json(payload)
812
+
813
+ async def stop_forwarding() -> None:
814
+ nonlocal subscription, forward_task
815
+ if subscription is not None:
816
+ subscription.close()
817
+ subscription = None
818
+ if forward_task is not None:
819
+ forward_task.cancel()
820
+ with suppress(asyncio.CancelledError):
821
+ await forward_task
822
+ forward_task = None
823
+
824
+ async def forward(path_id: str, store: LearningStore) -> None:
825
+ nonlocal cursor
826
+ assert subscription is not None
827
+ while True:
828
+ signal = await subscription.get()
829
+ events = await asyncio.to_thread(
830
+ store.list_events,
831
+ path_id,
832
+ after_revision=cursor,
833
+ )
834
+ if events:
835
+ cursor = max(cursor, max(event.revision for event in events))
836
+ elif signal.revision <= cursor and signal.reason not in _SCREEN_ONLY_SIGNALS:
837
+ continue
838
+ cursor = max(cursor, signal.revision)
839
+ await send(
840
+ {
841
+ "type": "topic_event",
842
+ "path_id": path_id,
843
+ "revision": cursor,
844
+ "reason": signal.reason,
845
+ "sequence": signal.sequence,
846
+ "events": [event.model_dump(mode="json") for event in events],
847
+ }
848
+ )
849
+
850
+ try:
851
+ while True:
852
+ try:
853
+ message = await ws.receive_json()
854
+ except WebSocketDisconnect:
855
+ break
856
+ message_type = str(message.get("type") or "").strip()
857
+ if message_type != "subscribe":
858
+ await send({"type": "error", "content": "Expected a subscribe message"})
859
+ continue
860
+ path_id = str(message.get("path_id") or "").strip()
861
+ try:
862
+ _validate_book_id(path_id)
863
+ except HTTPException:
864
+ await send({"type": "error", "content": "Invalid path_id"})
865
+ continue
866
+ store = LearningStore()
867
+ if not await asyncio.to_thread(store.exists, path_id):
868
+ await send({"type": "error", "content": "Mastery topic not found"})
869
+ continue
870
+
871
+ await stop_forwarding()
872
+ requested_cursor = max(0, int(message.get("after_revision") or 0))
873
+ progress = await asyncio.to_thread(store.load, path_id)
874
+ current_revision = int(progress.version if progress else 0)
875
+ # A stale browser cache must not be able to pin the subscription
876
+ # beyond the server's durable head and suppress future updates.
877
+ cursor = min(requested_cursor, current_revision)
878
+ # Register before replay. A concurrent commit is therefore either
879
+ # present in this DB tail, queued on the subscription, or both;
880
+ # the cursor in ``forward`` removes the harmless overlap.
881
+ subscription = mastery_topic_event_hub.subscribe(
882
+ path_id,
883
+ scope=store.event_scope,
884
+ )
885
+ events = await asyncio.to_thread(
886
+ store.list_events,
887
+ path_id,
888
+ after_revision=cursor,
889
+ )
890
+ if events:
891
+ cursor = max(cursor, max(event.revision for event in events))
892
+ cursor = max(cursor, current_revision)
893
+ await send(
894
+ {
895
+ "type": "subscribed",
896
+ "path_id": path_id,
897
+ "revision": cursor,
898
+ "events": [event.model_dump(mode="json") for event in events],
899
+ }
900
+ )
901
+ forward_task = asyncio.create_task(forward(path_id, store))
902
+ finally:
903
+ await stop_forwarding()
904
+ reset_current_user(user_token)
905
+
906
+
907
+ @router.get("/progress")
908
+ async def list_all_progress():
909
+ service = get_learning_service()
910
+ return await asyncio.to_thread(service.list_progress)
911
+
912
+
913
+ @router.get("/progress/{book_id}")
914
+ async def get_progress(book_id: str):
915
+ _validate_book_id(book_id)
916
+ service = get_learning_service()
917
+ progress = await asyncio.to_thread(service.store.load, book_id)
918
+ if progress is None:
919
+ raise HTTPException(status_code=404, detail="Mastery progress not found")
920
+ payload = progress.model_dump(mode="json")
921
+ if progress.pending_question is not None:
922
+ from deeptutor.learning.pending import public_pending_question
923
+
924
+ payload["pending_question"] = public_pending_question(progress.pending_question).to_dict()
925
+ return payload
926
+
927
+
928
+ @router.get("/progress/{book_id}/map")
929
+ async def get_progress_map(book_id: str):
930
+ """The dashboard view of a path: the gate-decided next step plus a map of
931
+ every objective's status (new / learning / mastered). The per-type gate
932
+ lives in ``learning.policy`` so the dashboard and the tutor agree."""
933
+ _validate_book_id(book_id)
934
+ service = get_learning_service()
935
+ progress = service.get_or_create(book_id)
936
+ moment = time.time()
937
+ progress = _read_projection(progress, now=moment)
938
+ return {
939
+ "book_id": book_id,
940
+ "name": learning_policy.path_display_name(progress),
941
+ "path_revision": progress.version,
942
+ "next": learning_policy.next_objective(progress, now=moment).to_dict(),
943
+ "map": learning_policy.map_summary(progress, now=moment),
944
+ }
945
+
946
+
947
+ @router.get("/progress/{book_id}/board")
948
+ async def get_progress_board(book_id: str):
949
+ """The visual learning board: every knowledge point as a card, enriched
950
+ with its next review time and a deterministic grid position derived from
951
+ the module order. A read-only projection of the same mastery data the
952
+ tutor and the map view use."""
953
+ _validate_book_id(book_id)
954
+ service = get_learning_service()
955
+ progress = service.get_or_create(book_id)
956
+ moment = time.time()
957
+ progress = _read_projection(progress, now=moment)
958
+ summary = learning_policy.map_summary(progress, now=moment)
959
+
960
+ due_by_kp = {task.knowledge_point_id: task.due_at for task in progress.review_queue}
961
+
962
+ cards: list[dict] = []
963
+ modules: list[dict] = []
964
+ for module in summary["modules"]:
965
+ module_cards: list[dict] = []
966
+ for index, kp in enumerate(module["knowledge_points"]):
967
+ card = {
968
+ "id": kp["id"],
969
+ "name": kp["name"],
970
+ "type": kp["type"],
971
+ "module_id": module["id"],
972
+ "module_name": module["name"],
973
+ "status": kp["status"],
974
+ "mastery_level": kp["mastery"],
975
+ "next_review_at": due_by_kp.get(kp["id"]),
976
+ "position": {"column": module["order"], "row": index},
977
+ }
978
+ cards.append(card)
979
+ module_cards.append(card)
980
+ modules.append(
981
+ {
982
+ "id": module["id"],
983
+ "name": module["name"],
984
+ "order": module["order"],
985
+ "mastered": module["mastered"],
986
+ "total": module["total"],
987
+ "cards": module_cards,
988
+ }
989
+ )
990
+
991
+ return {
992
+ "book_id": book_id,
993
+ "name": summary["name"],
994
+ "path_revision": progress.version,
995
+ "cards": cards,
996
+ "modules": modules,
997
+ }
998
+
999
+
1000
+ @router.get("/progress/{book_id}/objectives/{kp_id}")
1001
+ async def get_objective_report(book_id: str, kp_id: str):
1002
+ """The evidence behind one objective: attempts, schedule, errors, prompts.
1003
+
1004
+ ``policy.objective_report`` is pure over the aggregate, so the questions
1005
+ themselves — which live in the durable interaction log, not the aggregate —
1006
+ are joined on here, redacted of their answer keys.
1007
+ """
1008
+ _validate_book_id(book_id)
1009
+ store = LearningStore()
1010
+ progress, evidence, evidence_count = await asyncio.to_thread(
1011
+ store.load_with_learning_evidence, book_id, kp_id, limit=20
1012
+ )
1013
+ if progress is None:
1014
+ raise HTTPException(status_code=404, detail="Progress not found")
1015
+ moment = time.time()
1016
+ progress = _read_projection(progress, now=moment)
1017
+ report = learning_policy.objective_report(progress, kp_id, now=moment)
1018
+ if report is None:
1019
+ raise HTTPException(status_code=404, detail="Objective not found")
1020
+
1021
+ from deeptutor.learning.pending import public_pending_question
1022
+
1023
+ interactions = await asyncio.to_thread(store.list_interactions, book_id)
1024
+ prompts = {
1025
+ interaction.interaction_id: public_pending_question(interaction.question).prompt
1026
+ for interaction in interactions
1027
+ }
1028
+ for attempt in report["attempts"]:
1029
+ attempt["prompt"] = prompts.get(attempt["question_id"], "")
1030
+ report["evidence"] = [item.model_dump(mode="json") for item in evidence]
1031
+ report["evidence_count"] = evidence_count
1032
+ return {"book_id": book_id, "path_revision": progress.version, "objective": report}
1033
+
1034
+
1035
+ @router.get("/progress/{book_id}/events")
1036
+ async def get_progress_events(book_id: str, after_revision: int = 0):
1037
+ """Ordered, redacted domain events for reconnect and incremental UI sync."""
1038
+ _validate_book_id(book_id)
1039
+ store = LearningStore()
1040
+ progress = await asyncio.to_thread(store.load, book_id)
1041
+ if progress is None:
1042
+ raise HTTPException(status_code=404, detail="Progress not found")
1043
+ events = await asyncio.to_thread(
1044
+ store.list_events,
1045
+ book_id,
1046
+ after_revision=max(0, after_revision),
1047
+ )
1048
+ return {
1049
+ "book_id": book_id,
1050
+ "events": [event.model_dump(mode="json") for event in events],
1051
+ }
1052
+
1053
+
1054
+ @router.get("/progress/{book_id}/sessions")
1055
+ async def get_progress_sessions(book_id: str):
1056
+ """Expose the explicit conversation associations for this path."""
1057
+ _validate_book_id(book_id)
1058
+ store = LearningStore()
1059
+ if not await asyncio.to_thread(store.exists, book_id):
1060
+ raise HTTPException(status_code=404, detail="Progress not found")
1061
+ session_ids = await asyncio.to_thread(store.list_session_ids, book_id)
1062
+ return {"book_id": book_id, "session_ids": session_ids}
1063
+
1064
+
1065
+ @router.post("/progress/{book_id}/init-modules")
1066
+ async def init_modules(book_id: str, body: InitModulesRequest):
1067
+ _validate_book_id(book_id)
1068
+ modules = _parse_modules(_module_payloads(body.modules))
1069
+ _validate_runnable_modules(modules)
1070
+ async with _exclusive_path_mutation(book_id):
1071
+ service = get_learning_service()
1072
+ progress = await asyncio.to_thread(service.replace_modules_for_path, book_id, modules)
1073
+ return {
1074
+ "status": "ok",
1075
+ "module_count": len(modules),
1076
+ "path_revision": progress.version,
1077
+ }
1078
+
1079
+
1080
+ @router.post("/progress/{book_id}/import-from-book")
1081
+ async def import_from_book(book_id: str, body: ImportFromBookRequest):
1082
+ _validate_book_id(book_id)
1083
+ modules = []
1084
+ for i, ch in enumerate(body.chapters):
1085
+ kps = [
1086
+ KnowledgePoint(
1087
+ id=f"{book_id}_ch{i}_kp{j}",
1088
+ name=kp_name,
1089
+ type=KnowledgeType("concept"),
1090
+ module_id=f"{book_id}_ch{i}",
1091
+ )
1092
+ for j, kp_name in enumerate(ch.knowledge_points)
1093
+ ]
1094
+ modules.append(
1095
+ LearningModule(
1096
+ id=f"{book_id}_ch{i}",
1097
+ name=ch.title or f"Chapter {i + 1}",
1098
+ order=i,
1099
+ pass_threshold=0.7,
1100
+ knowledge_points=kps,
1101
+ )
1102
+ )
1103
+ _validate_runnable_modules(modules)
1104
+ async with _exclusive_path_mutation(book_id):
1105
+ service = get_learning_service()
1106
+ progress = await asyncio.to_thread(service.replace_modules_for_path, book_id, modules)
1107
+ return {
1108
+ "status": "ok",
1109
+ "module_count": len(modules),
1110
+ "path_revision": progress.version,
1111
+ }
1112
+
1113
+
1114
+ @router.patch("/progress/{book_id}")
1115
+ async def rename_progress(book_id: str, body: RenamePathRequest):
1116
+ """Rename a path — the only edit that is the learner's rather than the tutor's.
1117
+
1118
+ Guarded like every other path mutation so a rename cannot interleave with a
1119
+ tutoring turn's own commit, and emitted as an event so the activity feed
1120
+ records who called it what.
1121
+ """
1122
+ _validate_book_id(book_id)
1123
+ store = LearningStore()
1124
+ if not await asyncio.to_thread(store.exists, book_id):
1125
+ raise HTTPException(status_code=404, detail="Progress not found")
1126
+ async with _exclusive_path_mutation(book_id):
1127
+ progress = await asyncio.to_thread(LearningService(store).rename_path, book_id, body.name)
1128
+ return {
1129
+ "status": "ok",
1130
+ "name": learning_policy.path_display_name(progress),
1131
+ "path_revision": progress.version,
1132
+ }
1133
+
1134
+
1135
+ @router.delete("/progress/{book_id}")
1136
+ async def delete_progress(book_id: str):
1137
+ _validate_book_id(book_id)
1138
+ store = LearningStore()
1139
+ if not await asyncio.to_thread(store.exists, book_id):
1140
+ raise HTTPException(status_code=404, detail="Progress not found")
1141
+ async with _exclusive_path_mutation(book_id):
1142
+ await asyncio.to_thread(store.delete, book_id)
1143
+ return {"status": "ok"}
1144
+
1145
+
1146
+ @router.post("/progress/{book_id}/skip-question")
1147
+ async def skip_pending_question(book_id: str):
1148
+ """Drop an outstanding question the learner can no longer answer.
1149
+
1150
+ The narrow escape hatch for a path stalled on ``answer_pending``; unlike
1151
+ ``redo`` it keeps every mastery level and review the learner has earned.
1152
+ """
1153
+ _validate_book_id(book_id)
1154
+ store = LearningStore()
1155
+ if not await asyncio.to_thread(store.exists, book_id):
1156
+ raise HTTPException(status_code=404, detail="Progress not found")
1157
+ async with _exclusive_path_mutation(book_id):
1158
+ progress, skipped = await asyncio.to_thread(
1159
+ LearningService(store).abandon_active_question, book_id
1160
+ )
1161
+ return {"status": "ok", "skipped": skipped, "path_revision": progress.version}
1162
+
1163
+
1164
+ @router.post("/progress/{book_id}/redo")
1165
+ async def redo_progress(book_id: str):
1166
+ _validate_book_id(book_id)
1167
+ store = LearningStore()
1168
+ if not await asyncio.to_thread(store.exists, book_id):
1169
+ raise HTTPException(status_code=404, detail="Progress not found")
1170
+ async with _exclusive_path_mutation(book_id):
1171
+ progress = await asyncio.to_thread(LearningService(store).reset_path, book_id)
1172
+ return {"status": "ok", "path_revision": progress.version}
1173
+
1174
+
1175
+ class NotebookRecordInput(BaseModel):
1176
+ id: str
1177
+ type: str = "note"
1178
+ title: str = ""
1179
+ output: str = ""
1180
+
1181
+
1182
+ class GenerateFromNotebookRequest(BaseModel):
1183
+ notebook_id: str
1184
+ records: list[NotebookRecordInput]
1185
+
1186
+
1187
+ class GenerateFromReadingRequest(BaseModel):
1188
+ workspace_id: str
1189
+ material_ids: list[str] = Field(default_factory=list, max_length=20)
1190
+
1191
+
1192
+ @router.post("/progress/{book_id}/generate-from-notebook")
1193
+ async def generate_from_notebook(book_id: str, body: GenerateFromNotebookRequest):
1194
+ _validate_book_id(book_id)
1195
+ if not body.records:
1196
+ raise HTTPException(status_code=400, detail="No records provided")
1197
+
1198
+ records_data = [
1199
+ {
1200
+ "type": html.escape(r.type[:50], quote=False),
1201
+ "title": html.escape(r.title[:200], quote=False),
1202
+ "output": html.escape(r.output[:500], quote=False),
1203
+ }
1204
+ for r in body.records[:20]
1205
+ ]
1206
+ records_json = json.dumps(records_data, ensure_ascii=False)
1207
+ from deeptutor.services.llm import complete
1208
+
1209
+ language = get_response_language()
1210
+ system_prompt, prompt = learning_prompts.notebook_generation_prompts(language, records_json)
1211
+ response = await complete(prompt=prompt, system_prompt=system_prompt)
1212
+ # LLMs commonly fence/slightly-malform JSON; use the shared fence-stripping
1213
+ # repair parser instead of bare json.loads so the common case isn't a 502.
1214
+ data = parse_json_response(response, fallback=None)
1215
+ if not isinstance(data, dict):
1216
+ raise HTTPException(status_code=502, detail="LLM returned invalid JSON")
1217
+
1218
+ modules_raw = data.get("modules", [])
1219
+ if not isinstance(modules_raw, list):
1220
+ raise HTTPException(
1221
+ status_code=502, detail="LLM returned invalid structure: modules is not a list"
1222
+ )
1223
+ _ALLOWED_KP_TYPES = {"memory", "concept", "procedure", "design"}
1224
+ modules = []
1225
+ for i, m in enumerate(modules_raw):
1226
+ if not isinstance(m, dict) or "name" not in m:
1227
+ continue
1228
+ fallback_name = learning_prompts.default_module_name(language, i + 1)
1229
+ module_name = str(m.get("name") or fallback_name).strip()[:200] or fallback_name
1230
+ kps = []
1231
+ for j, kp in enumerate(m.get("knowledge_points", [])):
1232
+ if not isinstance(kp, dict) or "name" not in kp:
1233
+ continue
1234
+ kp_name = str(kp["name"]).strip()[:200]
1235
+ if len(kp_name) < 2:
1236
+ continue
1237
+ kp_type = str(kp.get("type", "concept")).strip()
1238
+ if kp_type not in _ALLOWED_KP_TYPES:
1239
+ kp_type = "concept"
1240
+ kps.append(
1241
+ KnowledgePoint(
1242
+ id=f"{book_id}_nb{i}_kp{j}",
1243
+ name=kp_name,
1244
+ type=KnowledgeType(kp_type),
1245
+ module_id=f"{book_id}_nb{i}",
1246
+ )
1247
+ )
1248
+ modules.append(
1249
+ LearningModule(
1250
+ id=f"{book_id}_nb{i}",
1251
+ name=module_name,
1252
+ order=i,
1253
+ pass_threshold=0.7,
1254
+ knowledge_points=kps,
1255
+ )
1256
+ )
1257
+ _validate_runnable_modules(modules, status_code=502)
1258
+ async with _exclusive_path_mutation(book_id):
1259
+ service = get_learning_service()
1260
+ progress = await asyncio.to_thread(service.replace_modules_for_path, book_id, modules)
1261
+ return {
1262
+ "status": "ok",
1263
+ "module_count": len(modules),
1264
+ "modules": [m.model_dump() for m in modules],
1265
+ "path_revision": progress.version,
1266
+ }
1267
+
1268
+
1269
+ @router.post("/progress/{book_id}/generate-from-reading")
1270
+ async def generate_from_reading(book_id: str, body: GenerateFromReadingRequest):
1271
+ """Create a mastery curriculum from a private reading workspace."""
1272
+ from deeptutor.reading.knowledge_capture import mastery_source_records
1273
+
1274
+ try:
1275
+ records = await asyncio.to_thread(
1276
+ mastery_source_records,
1277
+ body.workspace_id,
1278
+ material_ids=body.material_ids,
1279
+ )
1280
+ except Exception as exc:
1281
+ from deeptutor.reading import ReadingError
1282
+
1283
+ if isinstance(exc, ReadingError):
1284
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1285
+ raise
1286
+ return await generate_from_notebook(
1287
+ book_id,
1288
+ GenerateFromNotebookRequest(
1289
+ notebook_id=f"reading:{body.workspace_id}",
1290
+ records=[NotebookRecordInput(**record) for record in records],
1291
+ ),
1292
+ )