deeptutor-reading 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 (50) hide show
  1. deeptutor/api/routers/reading.py +1572 -0
  2. deeptutor/api/routers/reading_extensions.py +498 -0
  3. deeptutor/api/routers/video_learning.py +571 -0
  4. deeptutor/api/routers/voice.py +94 -0
  5. deeptutor/capabilities/reading/__init__.py +33 -0
  6. deeptutor/capabilities/reading/_tool_base.py +86 -0
  7. deeptutor/capabilities/reading/capability.py +497 -0
  8. deeptutor/capabilities/reading/figure_view.py +180 -0
  9. deeptutor/capabilities/reading/media_notes.py +92 -0
  10. deeptutor/capabilities/reading/mode.py +54 -0
  11. deeptutor/capabilities/reading/prompts/en/reading.yaml +86 -0
  12. deeptutor/capabilities/reading/prompts/zh/reading.yaml +69 -0
  13. deeptutor/capabilities/reading/tools.py +680 -0
  14. deeptutor/reading/__init__.py +125 -0
  15. deeptutor/reading/_grounding.py +115 -0
  16. deeptutor/reading/captions.py +144 -0
  17. deeptutor/reading/catalog_models.py +143 -0
  18. deeptutor/reading/catalog_store.py +1092 -0
  19. deeptutor/reading/epub_bilingual.py +248 -0
  20. deeptutor/reading/export.py +259 -0
  21. deeptutor/reading/extensions.py +206 -0
  22. deeptutor/reading/extract.py +600 -0
  23. deeptutor/reading/ingestion.py +1189 -0
  24. deeptutor/reading/knowledge_capture.py +177 -0
  25. deeptutor/reading/models.py +538 -0
  26. deeptutor/reading/page_render.py +98 -0
  27. deeptutor/reading/quiz.py +139 -0
  28. deeptutor/reading/read_aloud.py +33 -0
  29. deeptutor/reading/references.py +183 -0
  30. deeptutor/reading/refresh.py +185 -0
  31. deeptutor/reading/search.py +239 -0
  32. deeptutor/reading/service.py +252 -0
  33. deeptutor/reading/store.py +1571 -0
  34. deeptutor/reading/study_guidance.py +103 -0
  35. deeptutor/reading/translation.py +133 -0
  36. deeptutor/reading/vocabulary.py +131 -0
  37. deeptutor/services/reading_hints.py +503 -0
  38. deeptutor/video_learning/__init__.py +71 -0
  39. deeptutor/video_learning/invidious_account.py +360 -0
  40. deeptutor/video_learning/invidious_account_client.py +85 -0
  41. deeptutor/video_learning/invidious_account_storage.py +177 -0
  42. deeptutor/video_learning/marks.py +409 -0
  43. deeptutor/video_learning/notes.py +233 -0
  44. deeptutor/video_learning/reading_migration.py +213 -0
  45. deeptutor/video_learning/service.py +863 -0
  46. deeptutor_reading-1.7.0.dist-info/METADATA +11 -0
  47. deeptutor_reading-1.7.0.dist-info/RECORD +50 -0
  48. deeptutor_reading-1.7.0.dist-info/WHEEL +5 -0
  49. deeptutor_reading-1.7.0.dist-info/entry_points.txt +6 -0
  50. deeptutor_reading-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1572 @@
1
+ """Immersive reading API — materials, unit text, annotations, export.
2
+
3
+ A thin adapter over :mod:`deeptutor.reading`: it validates HTTP inputs, maps
4
+ engine errors to status codes, and streams bytes. No reading logic lives here,
5
+ so the router and the capability's tools cannot drift apart — both call the same
6
+ service functions.
7
+
8
+ Per-user isolation comes from the path service, exactly as for notebooks: the
9
+ store resolves ``<user workspace>/reading`` at call time, so a request already
10
+ scoped to a user by the auth dependency reaches only that user's materials.
11
+
12
+ The raw-file route returns a ``FileResponse``, which serves HTTP Range requests.
13
+ That matters: it is what lets pdf.js load a large PDF incrementally instead of
14
+ pulling the whole file before rendering page one.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import hashlib
21
+ import logging
22
+ from pathlib import Path
23
+ import shutil
24
+ import tempfile
25
+ from typing import Any, Literal
26
+ from urllib.parse import quote
27
+ import uuid
28
+
29
+ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, UploadFile
30
+ from fastapi.params import File
31
+ from fastapi.responses import FileResponse, Response
32
+ from pydantic import BaseModel, Field, model_validator
33
+
34
+ from deeptutor.learning.storage import LearningStore
35
+ from deeptutor.multi_user.learning_access import (
36
+ assert_learning_material,
37
+ assert_learning_material_mutation,
38
+ current_learning_policy,
39
+ )
40
+ from deeptutor.reading import (
41
+ ANNOTATION_COLORS,
42
+ Annotation,
43
+ IngestionStatus,
44
+ MaterialNotFound,
45
+ ReadingCatalogStore,
46
+ ReadingError,
47
+ ReadingPosition,
48
+ ReadingStore,
49
+ ReadingUpgradeConflict,
50
+ SourceKind,
51
+ export_material,
52
+ render_outline,
53
+ )
54
+ from deeptutor.reading.captions import caption_material_media, captions_enabled
55
+ from deeptutor.reading.ingestion import (
56
+ MAX_TRANSCRIPT_BYTES,
57
+ ReadingIngestionService,
58
+ url_material_id,
59
+ )
60
+ from deeptutor.reading.knowledge_capture import (
61
+ organize_workspace_notes,
62
+ send_workspace_to_notebook,
63
+ )
64
+ from deeptutor.reading.models import MAX_TEXT_SELECTOR_CHARS
65
+ from deeptutor.services.session.workspace_preferences import WORKSPACE_MODE_READING
66
+ from deeptutor.utils.document_validator import DocumentValidator
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+ router = APIRouter()
71
+
72
+ # Streaming upload ceiling. Same number the extractor enforces, so a file that
73
+ # passes here cannot then be rejected deeper in with a less helpful message.
74
+ MAX_MATERIAL_BYTES = DocumentValidator.MAX_FILE_SIZE
75
+ _UPLOAD_CHUNK = 1024 * 1024
76
+ _MEDIA_EXTENSIONS = {
77
+ ".mp4",
78
+ ".mov",
79
+ ".mkv",
80
+ ".webm",
81
+ ".m4v",
82
+ ".mp3",
83
+ ".m4a",
84
+ ".wav",
85
+ ".aac",
86
+ ".ogg",
87
+ ".flac",
88
+ }
89
+
90
+
91
+ def _store() -> ReadingStore:
92
+ return ReadingStore()
93
+
94
+
95
+ def _record_reading_position(material_id: str, *, locator: int, percentage: float) -> None:
96
+ LearningStore().record_reading_position(
97
+ material_id,
98
+ locator=locator,
99
+ percentage=percentage,
100
+ )
101
+
102
+
103
+ def _catalog() -> ReadingCatalogStore:
104
+ return ReadingCatalogStore()
105
+
106
+
107
+ def _ingestion() -> ReadingIngestionService:
108
+ catalog = _catalog()
109
+ return ReadingIngestionService(ReadingStore(catalog.root), catalog)
110
+
111
+
112
+ def _new_material_id() -> str:
113
+ return f"rm_{uuid.uuid4().hex[:12]}"
114
+
115
+
116
+ def _schedule_media_captions(
117
+ background_tasks: BackgroundTasks, store: ReadingStore, material_id: str
118
+ ) -> None:
119
+ """Best-effort: caption a material's embedded figures after ingest.
120
+
121
+ The vision model writes one sentence per image back into the store's media
122
+ index so a text-only model can still describe every figure in the book.
123
+ This must never affect the upload result or the ingest status, so any
124
+ failure is only logged.
125
+ """
126
+ if not captions_enabled():
127
+ return
128
+ background_tasks.add_task(_caption_material_best_effort, store, material_id)
129
+
130
+
131
+ async def _caption_material_best_effort(store: ReadingStore, material_id: str) -> None:
132
+ """Run the caption pass without ever letting an error escape."""
133
+ if not captions_enabled():
134
+ return
135
+ try:
136
+ await caption_material_media(material_id, store=store)
137
+ except Exception: # noqa: BLE001 - captioning is best-effort
138
+ logger.warning("Image captioning failed for %s", material_id, exc_info=True)
139
+
140
+
141
+ def _content_facts(store: ReadingStore, record: Any) -> tuple[int, int]:
142
+ """Stored size and extracted unit count, or zeros while still processing."""
143
+ if getattr(record.status, "value", record.status) != "ready":
144
+ return 0, 0
145
+ try:
146
+ manifest = store.manifest(record.material_id)
147
+ except Exception: # noqa: BLE001 - a missing manifest is a zero, not a 500
148
+ return 0, 0
149
+ return int(manifest.byte_size), int(manifest.unit_count)
150
+
151
+
152
+ def _http_error(exc: Exception) -> HTTPException:
153
+ """Map an engine error to the status code that describes it.
154
+
155
+ 404 for "no such material", 400 for everything the caller can fix (bad
156
+ locator, unsupported format, no extractable text). A 500 is reserved for
157
+ failures that are genuinely ours.
158
+ """
159
+ if isinstance(exc, PermissionError):
160
+ return HTTPException(status_code=403, detail=str(exc))
161
+ if isinstance(exc, MaterialNotFound):
162
+ return HTTPException(status_code=404, detail=str(exc))
163
+ if isinstance(exc, ReadingUpgradeConflict):
164
+ return HTTPException(status_code=409, detail=str(exc))
165
+ if isinstance(exc, ReadingError):
166
+ return HTTPException(status_code=400, detail=str(exc))
167
+ logger.warning("unexpected reading error", exc_info=True)
168
+ return HTTPException(status_code=500, detail="The reader could not complete that request.")
169
+
170
+
171
+ def _assigned_material_ids() -> set[str] | None:
172
+ """Return the account allowlist, or ``None`` for unrestricted accounts."""
173
+ policy = current_learning_policy()
174
+ if policy is None:
175
+ return None
176
+ reading = policy.get("reading")
177
+ if not isinstance(reading, dict):
178
+ return {"*"}
179
+ assigned = set(reading.get("material_ids") or [])
180
+ return None if "*" in assigned else assigned
181
+
182
+
183
+ def _material_allowed(material_id: str) -> bool:
184
+ assigned = _assigned_material_ids()
185
+ return assigned is None or material_id in assigned
186
+
187
+
188
+ def _enforce_learning_materials(*material_ids: str) -> None:
189
+ for material_id in material_ids:
190
+ if material_id:
191
+ assert_learning_material(material_id)
192
+
193
+
194
+ def _workspace_payload(row: Any) -> dict[str, Any]:
195
+ """Hide tabs that are no longer assigned to a learning account."""
196
+ payload = row.to_dict()
197
+ assigned = _assigned_material_ids()
198
+ if assigned is None:
199
+ return payload
200
+ payload["tabs"] = [
201
+ tab
202
+ for tab in payload.get("tabs", [])
203
+ if tab.get("material", {}).get("material_id") in assigned
204
+ ]
205
+ if payload.get("active_material_id") not in assigned:
206
+ payload["active_material_id"] = None
207
+ return payload
208
+
209
+
210
+ # === Models ===================================================================
211
+
212
+
213
+ class MaterialInfo(BaseModel):
214
+ material_id: str
215
+ filename: str
216
+ unit: str
217
+ unit_count: int
218
+ mime: str = ""
219
+ title: str = ""
220
+ byte_size: int = 0
221
+ char_count: int = 0
222
+ created_at: float = 0.0
223
+ has_raw_view: bool = False
224
+ render_mode: Literal["text", "pdf", "epub", "video", "audio"] = "text"
225
+ extractor: str = ""
226
+ content_format: Literal["plain_text", "web_markdown"] = "plain_text"
227
+ source_type: str = "upload"
228
+ source_url: str = ""
229
+ revision: int = 1
230
+ annotation_count: int = 0
231
+
232
+
233
+ class MaterialDetail(MaterialInfo):
234
+ outline: list[dict[str, Any]] = Field(default_factory=list)
235
+ outline_text: str = ""
236
+ unit_refs: list[dict[str, Any]] = Field(default_factory=list)
237
+
238
+
239
+ class UnitText(BaseModel):
240
+ locator: int
241
+ unit: str
242
+ text: str
243
+
244
+
245
+ class TextQuoteSelectorPayload(BaseModel):
246
+ type: Literal["TextQuoteSelector"]
247
+ exact: str = Field(min_length=1, max_length=2000)
248
+ prefix: str = Field(default="", max_length=128)
249
+ suffix: str = Field(default="", max_length=128)
250
+
251
+
252
+ class TextPositionSelectorPayload(BaseModel):
253
+ type: Literal["TextPositionSelector"]
254
+ start: int = Field(ge=0)
255
+ end: int = Field(gt=0)
256
+
257
+ @model_validator(mode="after")
258
+ def ordered(self) -> "TextPositionSelectorPayload":
259
+ if self.end <= self.start:
260
+ raise ValueError("selector end must be greater than start")
261
+ if self.end - self.start > MAX_TEXT_SELECTOR_CHARS:
262
+ raise ValueError(f"selector span must not exceed {MAX_TEXT_SELECTOR_CHARS} characters")
263
+ return self
264
+
265
+
266
+ class AnnotationPayload(BaseModel):
267
+ """An annotation as the reader sends it.
268
+
269
+ ``rects`` are normalised to the unit box (0..1, origin top-left) by the
270
+ client, because only the client knows the rendered geometry. They are still
271
+ re-validated server-side — an inverted or out-of-range rectangle is ordered
272
+ and clipped rather than trusted.
273
+ """
274
+
275
+ annotation_id: str = ""
276
+ locator: int = Field(ge=1)
277
+ kind: Literal["highlight", "underline", "note", "citation"] = "highlight"
278
+ color: str = "yellow"
279
+ quote: str = Field(default="", max_length=2000)
280
+ note: str = ""
281
+ rects: list[list[float]] = Field(default_factory=list)
282
+ source_anchor: str = Field(default="", max_length=4096)
283
+ selectors: list[TextQuoteSelectorPayload | TextPositionSelectorPayload] = Field(
284
+ default_factory=list,
285
+ max_length=2,
286
+ )
287
+
288
+ def to_annotation(self) -> Annotation:
289
+ return Annotation.from_dict(
290
+ {
291
+ "annotation_id": self.annotation_id,
292
+ "locator": self.locator,
293
+ "kind": self.kind,
294
+ "color": self.color if self.color in ANNOTATION_COLORS else "yellow",
295
+ "quote": self.quote,
296
+ "note": self.note,
297
+ "rects": self.rects,
298
+ "source_anchor": self.source_anchor,
299
+ "selectors": [selector.model_dump() for selector in self.selectors],
300
+ "author": "user",
301
+ }
302
+ )
303
+
304
+
305
+ class AnnotationInfo(BaseModel):
306
+ annotation_id: str
307
+ locator: int
308
+ material_revision: int = 1
309
+ kind: str
310
+ color: str
311
+ quote: str
312
+ note: str
313
+ rects: list[list[float]]
314
+ source_anchor: str = ""
315
+ selectors: list[dict[str, Any]] = Field(default_factory=list)
316
+ author: str
317
+ created_at: float
318
+ updated_at: float
319
+
320
+
321
+ class PositionPayload(BaseModel):
322
+ locator: int = Field(ge=1)
323
+ source_anchor: str = Field(default="", max_length=4096)
324
+ percentage: float | None = Field(default=None, ge=0.0, le=1.0)
325
+
326
+
327
+ class PositionInfo(PositionPayload):
328
+ percentage: float = 0.0
329
+ updated_at: float = 0.0
330
+
331
+
332
+ class BookmarkPayload(BaseModel):
333
+ locator: int = Field(ge=1)
334
+ label: str = Field(default="", max_length=200)
335
+ source_anchor: str = Field(default="", max_length=4096)
336
+
337
+
338
+ class BookmarkInfo(BookmarkPayload):
339
+ bookmark_id: str
340
+ created_at: float = 0.0
341
+
342
+
343
+ class BookmarkList(BaseModel):
344
+ bookmarks: list[BookmarkInfo]
345
+
346
+
347
+ class SupportedFormats(BaseModel):
348
+ extensions: list[str]
349
+ max_bytes: int
350
+ raw_view_extensions: list[str]
351
+
352
+
353
+ class EpubPairingRequest(BaseModel):
354
+ english_material_id: str
355
+ chinese_material_id: str
356
+
357
+
358
+ class UrlImportRequest(BaseModel):
359
+ urls: list[str] = Field(min_length=1, max_length=50)
360
+ workspace_id: str = ""
361
+ workspace_title: str = ""
362
+
363
+
364
+ class ZimArticleImportRequest(BaseModel):
365
+ kb_ref: str = Field(min_length=1, max_length=300)
366
+ article_path: str = Field(min_length=1, max_length=2048)
367
+ title: str = Field(default="", max_length=300)
368
+ workspace_id: str = ""
369
+
370
+
371
+ class WorkspaceCreateRequest(BaseModel):
372
+ title: str = Field(default="Untitled collection", max_length=300)
373
+ description: str = Field(default="", max_length=2000)
374
+ color: str = Field(default="", max_length=32)
375
+ material_ids: list[str] = Field(default_factory=list, max_length=100)
376
+
377
+
378
+ class WorkspaceUpdateRequest(BaseModel):
379
+ title: str | None = Field(default=None, max_length=300)
380
+ description: str | None = Field(default=None, max_length=2000)
381
+ color: str | None = Field(default=None, max_length=32)
382
+
383
+
384
+ class WorkspaceMaterialRequest(BaseModel):
385
+ material_id: str
386
+ make_active: bool = False
387
+
388
+
389
+ class WorkspaceReorderRequest(BaseModel):
390
+ material_ids: list[str] = Field(min_length=1, max_length=100)
391
+
392
+
393
+ class ReadingSessionCreateRequest(BaseModel):
394
+ title: str = Field(default="New reading conversation", max_length=100)
395
+ active_material_id: str = ""
396
+
397
+
398
+ class ReadingSessionRenameRequest(BaseModel):
399
+ title: str = Field(min_length=1, max_length=100)
400
+
401
+
402
+ class ReadingSessionLinkRequest(BaseModel):
403
+ target_session_id: str
404
+
405
+
406
+ class DuplicateFileQuery(BaseModel):
407
+ filename: str = Field(default="", max_length=512)
408
+ # sha256(bytes)[:16], computed by the browser with the store's algorithm.
409
+ content_id: str = Field(default="", max_length=64)
410
+ size_bytes: int = 0
411
+ mime: str = Field(default="", max_length=128)
412
+
413
+
414
+ class DuplicateCheckRequest(BaseModel):
415
+ files: list[DuplicateFileQuery] = Field(default_factory=list, max_length=50)
416
+ urls: list[str] = Field(default_factory=list, max_length=50)
417
+
418
+
419
+ class OrganizeNotesRequest(BaseModel):
420
+ material_ids: list[str] = Field(default_factory=list, max_length=100)
421
+
422
+
423
+ class NotebookCaptureRequest(OrganizeNotesRequest):
424
+ notebook_ids: list[str] = Field(min_length=1, max_length=20)
425
+ title: str = Field(default="", max_length=300)
426
+ summary: str = Field(default="", max_length=1000)
427
+
428
+
429
+ # === Routes ===================================================================
430
+
431
+
432
+ @router.get("/library/materials")
433
+ async def list_library_materials(
434
+ search: str = Query(default="", max_length=200),
435
+ status: Literal["queued", "processing", "ready", "failed"] | None = None,
436
+ library_filter: Literal["all", "unassigned", "processing", "failed"] = Query(
437
+ default="all", alias="filter"
438
+ ),
439
+ ) -> dict[str, Any]:
440
+ """Every material the owner has, with the collections holding each one.
441
+
442
+ Membership travels with the row because the library view exists to answer
443
+ "where is this used, and what did I upload that is used nowhere" — a
444
+ question the client cannot ask one material at a time.
445
+ """
446
+ catalog = _catalog()
447
+ store = _store()
448
+ try:
449
+ for manifest in store.list_materials():
450
+ if catalog.get_material(manifest.material_id) is None:
451
+ catalog.register_manifest(manifest)
452
+ rows = [
453
+ row
454
+ for row in catalog.list_materials(
455
+ search=search, status=status, library_filter=library_filter
456
+ )
457
+ if _material_allowed(row.material_id)
458
+ ]
459
+ membership = catalog.collections_for_materials([row.material_id for row in rows])
460
+ from deeptutor.services.session import get_sqlite_session_store
461
+
462
+ reward_totals = await get_sqlite_session_store().reading_quiz_reward_totals(
463
+ [row.material_id for row in rows]
464
+ )
465
+ materials: list[dict[str, Any]] = []
466
+ for row in rows:
467
+ payload = row.to_dict()
468
+ payload["collections"] = membership.get(row.material_id, [])
469
+ size_bytes, unit_count = _content_facts(store, row)
470
+ payload["size_bytes"] = size_bytes
471
+ payload["unit_count"] = unit_count
472
+ payload["quiz_stars"] = reward_totals.get(row.material_id, 0)
473
+ materials.append(payload)
474
+ # Counts describe every material this account may see, not only the
475
+ # filtered page and never revoked or unassigned learner material.
476
+ assigned = _assigned_material_ids()
477
+ return {
478
+ "materials": materials,
479
+ "counts": catalog.library_counts(None if assigned is None else sorted(assigned)),
480
+ }
481
+ except Exception as exc:
482
+ raise _http_error(exc) from exc
483
+
484
+
485
+ @router.post("/library/duplicate-check")
486
+ async def duplicate_check(payload: DuplicateCheckRequest) -> dict[str, Any]:
487
+ """Say what the library already holds, before anything is uploaded.
488
+
489
+ Identical bytes are answered from the content id the browser computed;
490
+ a same-name-different-content upload is the genuinely ambiguous case and
491
+ is reported separately so the client can ask instead of guessing.
492
+ """
493
+ catalog = _catalog()
494
+ store = _store()
495
+
496
+ def described(record: Any) -> dict[str, Any]:
497
+ """The match, with the facts that let a user tell two copies apart."""
498
+ payload_row = record.to_dict()
499
+ size_bytes, unit_count = _content_facts(store, record)
500
+ payload_row["size_bytes"] = size_bytes
501
+ payload_row["unit_count"] = unit_count
502
+ return payload_row
503
+
504
+ try:
505
+ matches: list[dict[str, Any]] = []
506
+ for item in payload.files:
507
+ kind = "same_content"
508
+ record = catalog.find_material_by_content(item.content_id) if item.content_id else None
509
+ if record is None and item.filename:
510
+ record = catalog.find_ready_material_by_filename(item.filename, mime=item.mime)
511
+ kind = "same_name"
512
+ if record is None or not _material_allowed(record.material_id):
513
+ continue
514
+ matches.append(
515
+ {
516
+ "query": {"filename": item.filename, "url": ""},
517
+ "kind": kind,
518
+ "material": described(record),
519
+ "collections": catalog.collections_for_material(record.material_id),
520
+ }
521
+ )
522
+ for url in payload.urls:
523
+ try:
524
+ material_id = url_material_id(url)
525
+ except Exception: # noqa: BLE001 - a malformed URL is simply not a match
526
+ continue
527
+ record = catalog.get_material(material_id)
528
+ if record is None or not _material_allowed(record.material_id):
529
+ continue
530
+ matches.append(
531
+ {
532
+ "query": {"filename": "", "url": url},
533
+ "kind": "same_content",
534
+ "material": described(record),
535
+ "collections": catalog.collections_for_material(record.material_id),
536
+ }
537
+ )
538
+ return {"matches": matches}
539
+ except Exception as exc:
540
+ raise _http_error(exc) from exc
541
+
542
+
543
+ @router.post("/library/import-urls", status_code=202)
544
+ async def import_urls(
545
+ payload: UrlImportRequest, background_tasks: BackgroundTasks
546
+ ) -> dict[str, Any]:
547
+ """Queue safe webpage, YouTube, or Bilibili imports into a workspace."""
548
+ try:
549
+ assert_learning_material("", upload=True)
550
+ except PermissionError as exc:
551
+ raise _http_error(exc) from exc
552
+ service = _ingestion()
553
+ try:
554
+ materials = [service.queue_url(url) for url in payload.urls]
555
+ workspace_id = payload.workspace_id.strip()
556
+ if workspace_id:
557
+ for material in materials:
558
+ service.catalog.add_material(workspace_id, material.material_id)
559
+ workspace = service.catalog.get_workspace(workspace_id)
560
+ else:
561
+ # Naming the collection after its first material is what keeps a
562
+ # library from filling up with rows all called "Imported reading".
563
+ workspace = service.catalog.create_workspace(
564
+ payload.workspace_title
565
+ or (materials[0].title if materials else "Reading collection"),
566
+ [row.material_id for row in materials],
567
+ )
568
+ for material in materials:
569
+ background_tasks.add_task(service.process_url, material.material_id)
570
+ return {
571
+ "materials": [row.to_dict() for row in materials],
572
+ "workspace": workspace.to_dict() if workspace else None,
573
+ }
574
+ except Exception as exc:
575
+ raise _http_error(exc) from exc
576
+
577
+
578
+ @router.post("/library/import-zim-article")
579
+ async def import_zim_article(payload: ZimArticleImportRequest) -> dict[str, Any]:
580
+ """Open one selected Kiwix article in Immersive Reading.
581
+
582
+ Only its bounded text snapshot enters the reading store. The ZIM archive
583
+ stays on the existing kiwix-serve instance, without bulk extraction.
584
+ """
585
+ from deeptutor.multi_user.knowledge_access import manager_for_resource, resolve_kb
586
+ from deeptutor.reading.catalog_models import IngestionStatus, SourceKind
587
+ from deeptutor.reading.extract import split_markdown_by_headings
588
+ from deeptutor.services.rag.pipelines.kiwix.client import (
589
+ KiwixClient,
590
+ KiwixError,
591
+ validate_article_path,
592
+ )
593
+ from deeptutor.services.workspace.knowledge import library_request
594
+
595
+ try:
596
+ assert_learning_material("", upload=True)
597
+ # Knowledge Center browses the account library outside the current
598
+ # conversation's selected KB list. Resolve the source with that same
599
+ # access scope, then restore the Reading workspace for storage.
600
+ token = library_request.set(True)
601
+ try:
602
+ resource = resolve_kb(payload.kb_ref)
603
+ entry = manager_for_resource(resource).get_metadata(resource.name)
604
+ finally:
605
+ library_request.reset(token)
606
+ if not isinstance(entry, dict) or entry.get("type") != "kiwix":
607
+ raise ReadingError("Select a connected Kiwix knowledge base.")
608
+ path = validate_article_path(payload.article_path)
609
+ client = KiwixClient(entry["server_url"], entry["zim_name"])
610
+ text = await client.read_article(path)
611
+ units, outline = split_markdown_by_headings(text)
612
+ if not units:
613
+ raise ReadingError("The selected ZIM article has no readable text.")
614
+ title = payload.title.strip() or path.rsplit("/", 1)[-1].replace("_", " ")
615
+ material_id = hashlib.sha256(f"{resource.id}\0{path}".encode()).hexdigest()[:16]
616
+ catalog = _catalog()
617
+ store = ReadingStore(catalog.root)
618
+ source_url = f"{client.base_url}/content/{client.zim_name}/" + "/".join(
619
+ quote(segment, safe="") for segment in path.split("/")
620
+ )
621
+ store.ingest_units(
622
+ material_id,
623
+ filename=f"{title[:150]}.md",
624
+ units=units,
625
+ unit="section",
626
+ title=title,
627
+ mime="text/markdown",
628
+ extractor="kiwix-article",
629
+ content_format="plain_text",
630
+ source_type="zim_article",
631
+ source_url=source_url,
632
+ outline=outline or None,
633
+ )
634
+ material = catalog.upsert_material(
635
+ content_id=material_id,
636
+ material_id=material_id,
637
+ filename=f"{title[:150]}.md",
638
+ title=title,
639
+ source_kind=SourceKind.WEB,
640
+ source_url=source_url,
641
+ mime="text/markdown",
642
+ render_mode="text",
643
+ status=IngestionStatus.READY,
644
+ )
645
+ if payload.workspace_id.strip():
646
+ catalog.add_material(payload.workspace_id.strip(), material_id)
647
+ workspace = catalog.get_workspace(payload.workspace_id.strip())
648
+ else:
649
+ workspace = catalog.create_workspace(title, [material_id])
650
+ return {
651
+ "material": material.to_dict(),
652
+ "workspace": workspace.to_dict() if workspace else None,
653
+ }
654
+ except HTTPException:
655
+ raise
656
+ except (KiwixError, KeyError) as exc:
657
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
658
+ except Exception as exc:
659
+ raise _http_error(exc) from exc
660
+
661
+
662
+ @router.post("/materials/{material_id}/retry", status_code=202)
663
+ async def retry_import(material_id: str, background_tasks: BackgroundTasks) -> dict[str, Any]:
664
+ service = _ingestion()
665
+ try:
666
+ assert_learning_material(material_id)
667
+ material = service.catalog.get_material(material_id)
668
+ if material is None:
669
+ raise MaterialNotFound(f"material {material_id!r} not found")
670
+ service.catalog.update_material_status(material_id, "queued", progress=0)
671
+ background_tasks.add_task(service.retry, material_id)
672
+ return {"material": service.catalog.get_material(material_id).to_dict()}
673
+ except Exception as exc:
674
+ raise _http_error(exc) from exc
675
+
676
+
677
+ @router.get("/workspaces")
678
+ async def list_workspaces(
679
+ search: str = Query(default="", max_length=200),
680
+ ) -> dict[str, Any]:
681
+ try:
682
+ rows = _catalog().list_workspaces(search=search)
683
+ return {"workspaces": [_workspace_payload(row) for row in rows]}
684
+ except Exception as exc:
685
+ raise _http_error(exc) from exc
686
+
687
+
688
+ @router.get("/workspaces/index")
689
+ async def list_workspace_index() -> dict[str, Any]:
690
+ """Just enough to *name* a collection: id and title.
691
+
692
+ The sidebar groups reading conversations under their collection and
693
+ refreshes on every stream end. ``/workspaces`` answers with each
694
+ collection's whole tab list — every material, its cover, its unit
695
+ count — none of which a group heading renders.
696
+
697
+ Declared above ``/workspaces/{workspace_id}``: that route matches any
698
+ single segment, so a literal path below it would never be reached.
699
+ """
700
+ try:
701
+ return {
702
+ "collections": [
703
+ {"workspace_id": row.workspace_id, "title": row.title}
704
+ for row in _catalog().list_workspaces()
705
+ ]
706
+ }
707
+ except Exception as exc:
708
+ raise _http_error(exc) from exc
709
+
710
+
711
+ @router.post("/workspaces", status_code=201)
712
+ async def create_workspace(payload: WorkspaceCreateRequest) -> dict[str, Any]:
713
+ try:
714
+ _enforce_learning_materials(*payload.material_ids)
715
+ row = _catalog().create_workspace(
716
+ payload.title,
717
+ payload.material_ids,
718
+ description=payload.description,
719
+ color=payload.color,
720
+ )
721
+ return {"workspace": row.to_dict()}
722
+ except Exception as exc:
723
+ raise _http_error(exc) from exc
724
+
725
+
726
+ @router.get("/workspaces/{workspace_id}")
727
+ async def get_workspace(workspace_id: str) -> dict[str, Any]:
728
+ try:
729
+ catalog = _catalog()
730
+ row = catalog.get_workspace(workspace_id)
731
+ if row is None:
732
+ raise MaterialNotFound(f"reading workspace {workspace_id!r} not found")
733
+ return {
734
+ "workspace": _workspace_payload(row),
735
+ "sessions": [session.to_dict() for session in catalog.list_sessions(workspace_id)],
736
+ }
737
+ except Exception as exc:
738
+ raise _http_error(exc) from exc
739
+
740
+
741
+ @router.get("/workspaces/{workspace_id}/ask-hint")
742
+ async def get_workspace_ask_hint(
743
+ workspace_id: str,
744
+ session_id: str = "",
745
+ locator: int | None = None,
746
+ selection: str = "",
747
+ ) -> dict[str, Any]:
748
+ """One question the learner could ask about their current reading context."""
749
+ from deeptutor.services.reading_hints import get_ask_hint
750
+
751
+ return await get_ask_hint(workspace_id, session_id, locator, selection)
752
+
753
+
754
+ @router.patch("/workspaces/{workspace_id}")
755
+ async def update_workspace(workspace_id: str, payload: WorkspaceUpdateRequest) -> dict[str, Any]:
756
+ try:
757
+ row = _catalog().update_workspace(
758
+ workspace_id,
759
+ title=payload.title,
760
+ description=payload.description,
761
+ color=payload.color,
762
+ )
763
+ return {"workspace": row.to_dict()}
764
+ except Exception as exc:
765
+ raise _http_error(exc) from exc
766
+
767
+
768
+ @router.delete("/workspaces/{workspace_id}")
769
+ async def delete_workspace(workspace_id: str) -> dict[str, Any]:
770
+ from deeptutor.services.session import get_session_store
771
+
772
+ try:
773
+ catalog = _catalog()
774
+ sessions = catalog.list_sessions(workspace_id)
775
+ if not catalog.delete_workspace(workspace_id):
776
+ raise MaterialNotFound(f"reading workspace {workspace_id!r} not found")
777
+ session_store = get_session_store()
778
+ for session in sessions:
779
+ await session_store.delete_session(session.session_id)
780
+ return {"status": "ok", "workspace_id": workspace_id}
781
+ except Exception as exc:
782
+ raise _http_error(exc) from exc
783
+
784
+
785
+ @router.post("/workspaces/{workspace_id}/materials")
786
+ async def add_workspace_material(
787
+ workspace_id: str, payload: WorkspaceMaterialRequest
788
+ ) -> dict[str, Any]:
789
+ try:
790
+ assert_learning_material(payload.material_id)
791
+ row = _catalog().add_material(
792
+ workspace_id, payload.material_id, make_active=payload.make_active
793
+ )
794
+ return {"workspace": row.to_dict()}
795
+ except Exception as exc:
796
+ raise _http_error(exc) from exc
797
+
798
+
799
+ @router.put("/workspaces/{workspace_id}/materials/order")
800
+ async def reorder_workspace_materials(
801
+ workspace_id: str, payload: WorkspaceReorderRequest
802
+ ) -> dict[str, Any]:
803
+ try:
804
+ _enforce_learning_materials(*payload.material_ids)
805
+ row = _catalog().reorder_materials(workspace_id, payload.material_ids)
806
+ return {"workspace": row.to_dict()}
807
+ except Exception as exc:
808
+ raise _http_error(exc) from exc
809
+
810
+
811
+ @router.put("/workspaces/{workspace_id}/materials/{material_id}/active")
812
+ async def activate_workspace_material(workspace_id: str, material_id: str) -> dict[str, Any]:
813
+ try:
814
+ assert_learning_material(material_id)
815
+ row = _catalog().set_active_material(workspace_id, material_id)
816
+ return {"workspace": row.to_dict()}
817
+ except Exception as exc:
818
+ raise _http_error(exc) from exc
819
+
820
+
821
+ @router.delete("/workspaces/{workspace_id}/materials/{material_id}")
822
+ async def remove_workspace_material(workspace_id: str, material_id: str) -> dict[str, Any]:
823
+ try:
824
+ assert_learning_material(material_id)
825
+ row = _catalog().remove_material(workspace_id, material_id)
826
+ return {"workspace": row.to_dict()}
827
+ except Exception as exc:
828
+ raise _http_error(exc) from exc
829
+
830
+
831
+ @router.get("/workspaces/{workspace_id}/sessions")
832
+ async def list_reading_sessions(workspace_id: str) -> dict[str, Any]:
833
+ from deeptutor.services.session import get_session_store
834
+
835
+ try:
836
+ catalog = _catalog()
837
+ rows = catalog.list_sessions(workspace_id)
838
+ # The catalog stores the title a conversation was attached with, which
839
+ # is the placeholder every conversation starts life as: the real name
840
+ # is written by the title model *after* that first turn finishes, into
841
+ # the session store. Read it from there so the reader sees the same
842
+ # name the sidebar does instead of a list of "New conversation".
843
+ titles: dict[str, str] = {}
844
+ try:
845
+ summaries = await get_session_store().get_session_summaries(
846
+ [row.session_id for row in rows]
847
+ )
848
+ titles = {
849
+ str(summary.get("session_id") or summary.get("id") or ""): str(
850
+ summary.get("title") or ""
851
+ )
852
+ for summary in summaries
853
+ }
854
+ except Exception:
855
+ logger.debug("reading sessions: live titles unavailable", exc_info=True)
856
+ return {
857
+ "sessions": [
858
+ row.to_dict()
859
+ | {
860
+ "title": titles.get(row.session_id) or row.title,
861
+ "linked_session_ids": catalog.list_session_links(workspace_id, row.session_id),
862
+ }
863
+ for row in rows
864
+ ]
865
+ }
866
+ except Exception as exc:
867
+ raise _http_error(exc) from exc
868
+
869
+
870
+ @router.post("/workspaces/{workspace_id}/sessions", status_code=201)
871
+ async def create_reading_session(
872
+ workspace_id: str, payload: ReadingSessionCreateRequest
873
+ ) -> dict[str, Any]:
874
+ from deeptutor.services.session import get_session_store
875
+
876
+ catalog = _catalog()
877
+ try:
878
+ if payload.active_material_id:
879
+ assert_learning_material(payload.active_material_id)
880
+ workspace = catalog.get_workspace(workspace_id)
881
+ if workspace is None:
882
+ raise MaterialNotFound(f"reading workspace {workspace_id!r} not found")
883
+ active_material_id = payload.active_material_id or workspace.active_material_id
884
+ if active_material_id and active_material_id not in {
885
+ tab.material.material_id for tab in workspace.tabs
886
+ }:
887
+ raise ReadingError("active material does not belong to this reading workspace")
888
+ session_store = get_session_store()
889
+ session = await session_store.create_session(title=payload.title)
890
+ await session_store.update_session_preferences(
891
+ session["id"],
892
+ {
893
+ "capability": "immersive_reading",
894
+ "workspace_mode": WORKSPACE_MODE_READING,
895
+ "session_kind": "immersive_reading",
896
+ "reading_workspace_id": workspace_id,
897
+ "reading_material_id": active_material_id or "",
898
+ },
899
+ )
900
+ reading_session = catalog.attach_session(
901
+ workspace_id,
902
+ session["id"],
903
+ title=payload.title,
904
+ active_material_id=active_material_id,
905
+ )
906
+ return {"session": reading_session.to_dict()}
907
+ except Exception as exc:
908
+ raise _http_error(exc) from exc
909
+
910
+
911
+ @router.patch("/workspaces/{workspace_id}/sessions/{session_id}")
912
+ async def rename_reading_session(
913
+ workspace_id: str, session_id: str, payload: ReadingSessionRenameRequest
914
+ ) -> dict[str, Any]:
915
+ from deeptutor.services.session import get_session_store
916
+
917
+ try:
918
+ catalog = _catalog()
919
+ row = catalog.rename_session(workspace_id, session_id, payload.title)
920
+ await get_session_store().update_session_title(session_id, payload.title)
921
+ return {"session": row.to_dict()}
922
+ except Exception as exc:
923
+ raise _http_error(exc) from exc
924
+
925
+
926
+ @router.delete("/workspaces/{workspace_id}/sessions/{session_id}")
927
+ async def delete_reading_session(workspace_id: str, session_id: str) -> dict[str, Any]:
928
+ from deeptutor.services.session import get_session_store
929
+
930
+ try:
931
+ catalog = _catalog()
932
+ if not catalog.detach_session(workspace_id, session_id):
933
+ raise MaterialNotFound("reading session not found")
934
+ await get_session_store().delete_session(session_id)
935
+ return {"status": "ok", "session_id": session_id}
936
+ except Exception as exc:
937
+ raise _http_error(exc) from exc
938
+
939
+
940
+ @router.post("/workspaces/{workspace_id}/sessions/{session_id}/links")
941
+ async def link_reading_session(
942
+ workspace_id: str, session_id: str, payload: ReadingSessionLinkRequest
943
+ ) -> dict[str, Any]:
944
+ try:
945
+ catalog = _catalog()
946
+ catalog.link_session(workspace_id, session_id, payload.target_session_id)
947
+ return {
948
+ "session_id": session_id,
949
+ "linked_session_ids": catalog.list_session_links(workspace_id, session_id),
950
+ }
951
+ except Exception as exc:
952
+ raise _http_error(exc) from exc
953
+
954
+
955
+ @router.delete("/workspaces/{workspace_id}/sessions/{session_id}/links/{target_session_id}")
956
+ async def unlink_reading_session(
957
+ workspace_id: str, session_id: str, target_session_id: str
958
+ ) -> dict[str, Any]:
959
+ try:
960
+ catalog = _catalog()
961
+ catalog.unlink_session(workspace_id, session_id, target_session_id)
962
+ return {
963
+ "session_id": session_id,
964
+ "linked_session_ids": catalog.list_session_links(workspace_id, session_id),
965
+ }
966
+ except Exception as exc:
967
+ raise _http_error(exc) from exc
968
+
969
+
970
+ @router.post("/workspaces/{workspace_id}/notes/organize")
971
+ async def organize_reading_notes(
972
+ workspace_id: str, payload: OrganizeNotesRequest
973
+ ) -> dict[str, Any]:
974
+ try:
975
+ _enforce_learning_materials(*payload.material_ids)
976
+ catalog = _catalog()
977
+ notes = await asyncio.to_thread(
978
+ organize_workspace_notes,
979
+ workspace_id,
980
+ material_ids=payload.material_ids,
981
+ catalog=catalog,
982
+ reading_store=ReadingStore(catalog.root),
983
+ )
984
+ return {"notes": notes.to_dict()}
985
+ except Exception as exc:
986
+ raise _http_error(exc) from exc
987
+
988
+
989
+ @router.post("/workspaces/{workspace_id}/notebook")
990
+ async def capture_reading_to_notebook(
991
+ workspace_id: str, payload: NotebookCaptureRequest
992
+ ) -> dict[str, Any]:
993
+ try:
994
+ _enforce_learning_materials(*payload.material_ids)
995
+ catalog = _catalog()
996
+ result = await asyncio.to_thread(
997
+ send_workspace_to_notebook,
998
+ workspace_id,
999
+ payload.notebook_ids,
1000
+ material_ids=payload.material_ids,
1001
+ title=payload.title,
1002
+ summary=payload.summary,
1003
+ catalog=catalog,
1004
+ reading_store=ReadingStore(catalog.root),
1005
+ )
1006
+ return {"success": True, **result}
1007
+ except Exception as exc:
1008
+ raise _http_error(exc) from exc
1009
+
1010
+
1011
+ @router.get("/supported-formats", response_model=SupportedFormats)
1012
+ async def supported_formats() -> SupportedFormats:
1013
+ """What the reader accepts — the single source of truth for the file picker."""
1014
+ from deeptutor.reading.extract import RAW_VIEW_EXTENSIONS
1015
+ from deeptutor.utils.document_extractor import SUPPORTED_DOC_EXTENSIONS
1016
+
1017
+ return SupportedFormats(
1018
+ extensions=sorted(set(SUPPORTED_DOC_EXTENSIONS) | _MEDIA_EXTENSIONS),
1019
+ max_bytes=MAX_MATERIAL_BYTES,
1020
+ raw_view_extensions=sorted(set(RAW_VIEW_EXTENSIONS) | _MEDIA_EXTENSIONS),
1021
+ )
1022
+
1023
+
1024
+ @router.get("/materials", response_model=list[MaterialInfo])
1025
+ async def list_materials() -> list[MaterialInfo]:
1026
+ store = _store()
1027
+ try:
1028
+ return [
1029
+ _info(store, manifest)
1030
+ for manifest in store.list_materials()
1031
+ if _material_allowed(manifest.material_id)
1032
+ ]
1033
+ except Exception as exc:
1034
+ raise _http_error(exc) from exc
1035
+
1036
+
1037
+ @router.post("/materials", response_model=MaterialDetail)
1038
+ async def upload_material(
1039
+ background_tasks: BackgroundTasks,
1040
+ file: UploadFile = File(...), # noqa: B008
1041
+ reuse: bool = Query(default=True),
1042
+ ) -> MaterialDetail:
1043
+ """Ingest an uploaded document and return it ready to read.
1044
+
1045
+ The upload is streamed to a temp file with a running size check, so an
1046
+ oversized file is rejected before it is fully buffered rather than after.
1047
+
1048
+ Audio and video answer as soon as the file is stored and transcription is
1049
+ queued, the same shape as a URL import. Holding the request open for the
1050
+ length of a lecture meant no progress could reach the client, the browser
1051
+ or a proxy could time the upload out after the transcription had already
1052
+ been paid for, and nothing was left on disk to retry from.
1053
+ """
1054
+ try:
1055
+ assert_learning_material("", upload=True)
1056
+ except PermissionError as exc:
1057
+ raise _http_error(exc) from exc
1058
+ filename = (file.filename or "").strip()
1059
+ if not filename:
1060
+ raise HTTPException(status_code=400, detail="The upload has no filename.")
1061
+ if filename.lower().endswith(".epub.zip"):
1062
+ filename = filename[: -len(".zip")]
1063
+
1064
+ tmp_dir = Path(tempfile.mkdtemp(prefix="dt-reading-"))
1065
+ tmp_path = tmp_dir / Path(filename).name
1066
+ written = 0
1067
+ try:
1068
+ with tmp_path.open("wb") as sink:
1069
+ while chunk := await file.read(_UPLOAD_CHUNK):
1070
+ written += len(chunk)
1071
+ if written > MAX_MATERIAL_BYTES:
1072
+ raise HTTPException(
1073
+ status_code=413,
1074
+ detail=(
1075
+ f"{filename} exceeds the "
1076
+ f"{MAX_MATERIAL_BYTES // (1024 * 1024)} MB limit."
1077
+ ),
1078
+ )
1079
+ sink.write(chunk)
1080
+ if written == 0:
1081
+ raise HTTPException(status_code=400, detail=f"{filename} is empty.")
1082
+
1083
+ store = _store()
1084
+ if Path(filename).suffix.lower() in _MEDIA_EXTENSIONS:
1085
+ service = ReadingIngestionService(store, _catalog())
1086
+ record = await service.queue_media(tmp_path, filename=filename)
1087
+ background_tasks.add_task(service.process_media, record.material_id)
1088
+ return _detail(store, store.manifest(record.material_id))
1089
+ else:
1090
+ manifest = store.ingest(tmp_path, filename=filename)
1091
+ catalog = _catalog()
1092
+ if reuse or catalog.get_material(manifest.material_id) is None:
1093
+ catalog.register_manifest(manifest)
1094
+ _schedule_media_captions(background_tasks, store, manifest.material_id)
1095
+ return _detail(store, manifest)
1096
+ # A separate material over the same extracted content: the bytes
1097
+ # are stored once, while annotations and reading position are kept
1098
+ # apart because the user asked for a second, independent copy.
1099
+ record = catalog.upsert_material(
1100
+ content_id=manifest.material_id,
1101
+ material_id=_new_material_id(),
1102
+ filename=manifest.filename,
1103
+ title=manifest.title,
1104
+ source_kind=SourceKind.FILE,
1105
+ mime=manifest.mime,
1106
+ render_mode=manifest.render_mode,
1107
+ status=IngestionStatus.READY,
1108
+ )
1109
+ _schedule_media_captions(background_tasks, store, record.material_id)
1110
+ return _detail(store, store.manifest(record.material_id))
1111
+ except HTTPException:
1112
+ raise
1113
+ except Exception as exc:
1114
+ raise _http_error(exc) from exc
1115
+ finally:
1116
+ shutil.rmtree(tmp_dir, ignore_errors=True)
1117
+
1118
+
1119
+ @router.get("/materials/{material_id}", response_model=MaterialDetail)
1120
+ async def get_material(material_id: str) -> MaterialDetail:
1121
+ store = _store()
1122
+ try:
1123
+ assert_learning_material(material_id)
1124
+ return _detail(store, store.manifest(material_id))
1125
+ except Exception as exc:
1126
+ raise _http_error(exc) from exc
1127
+
1128
+
1129
+ @router.get("/materials/{material_id}/epub-pairing-candidates")
1130
+ async def epub_pairing_candidates(material_id: str) -> list[dict[str, Any]]:
1131
+ from deeptutor.reading.epub_bilingual import recommend_epub_candidates
1132
+
1133
+ try:
1134
+ assert_learning_material(material_id)
1135
+ return await asyncio.to_thread(recommend_epub_candidates, _store(), material_id)
1136
+ except Exception as exc:
1137
+ raise _http_error(exc) from exc
1138
+
1139
+
1140
+ @router.get("/epub-pairings")
1141
+ async def epub_pairings() -> list[dict[str, Any]]:
1142
+ from deeptutor.reading.epub_bilingual import list_epub_pairings
1143
+
1144
+ return list_epub_pairings(_store())
1145
+
1146
+
1147
+ @router.post("/epub-pairings")
1148
+ async def create_epub_pairing(payload: EpubPairingRequest) -> dict[str, Any]:
1149
+ from deeptutor.reading.epub_bilingual import create_epub_pairing
1150
+
1151
+ try:
1152
+ _enforce_learning_materials(payload.english_material_id, payload.chinese_material_id)
1153
+ pairing = await asyncio.to_thread(
1154
+ create_epub_pairing,
1155
+ _store(),
1156
+ payload.english_material_id,
1157
+ payload.chinese_material_id,
1158
+ )
1159
+ return {"pairing": pairing}
1160
+ except Exception as exc:
1161
+ raise _http_error(exc) from exc
1162
+
1163
+
1164
+ @router.delete("/epub-pairings/{pairing_id}")
1165
+ async def remove_epub_pairing(pairing_id: str) -> dict[str, Any]:
1166
+ from deeptutor.reading.epub_bilingual import delete_epub_pairing
1167
+
1168
+ try:
1169
+ removed = await asyncio.to_thread(delete_epub_pairing, _store(), pairing_id)
1170
+ except Exception as exc:
1171
+ raise _http_error(exc) from exc
1172
+ if not removed:
1173
+ raise HTTPException(status_code=404, detail="EPUB pairing not found")
1174
+ return {"status": "ok", "pairing_id": pairing_id}
1175
+
1176
+
1177
+ @router.delete("/materials/{material_id}")
1178
+ async def delete_material(material_id: str) -> dict[str, Any]:
1179
+ store = _store()
1180
+ catalog = _catalog()
1181
+ record = catalog.get_material(material_id)
1182
+ removed_from = catalog.collections_for_material(material_id) if record else []
1183
+ try:
1184
+ assert_learning_material_mutation(material_id)
1185
+ shared = record is not None and catalog.count_materials_for_content(record.content_id) > 1
1186
+ if shared and record is not None:
1187
+ # A sibling material still reads this content: drop this row and
1188
+ # only the annotations that belong to it.
1189
+ store.delete_material_state(material_id, content_id=record.content_id)
1190
+ removed = catalog.delete_material(material_id)
1191
+ else:
1192
+ with store.staged_delete(material_id) as staged:
1193
+ if staged:
1194
+ catalog.delete_material(material_id)
1195
+ removed = staged or bool(record and catalog.delete_material(material_id))
1196
+ except Exception as exc:
1197
+ raise _http_error(exc) from exc
1198
+ if not removed:
1199
+ raise HTTPException(status_code=404, detail=f"material {material_id!r} not found")
1200
+ return {
1201
+ "status": "ok",
1202
+ "deleted": True,
1203
+ "material_id": material_id,
1204
+ "removed_from": removed_from,
1205
+ }
1206
+
1207
+
1208
+ @router.get("/materials/{material_id}/transcript")
1209
+ async def get_transcript(material_id: str) -> dict[str, Any]:
1210
+ """Every transcript segment of a timed material, in one response.
1211
+
1212
+ The reader's transcript panel needs all of them at once. Fetching them one
1213
+ locator at a time meant a request per segment — fine when a segment was ten
1214
+ minutes long, wasteful now that they follow the speaker's own sentences.
1215
+ """
1216
+ store = _store()
1217
+ try:
1218
+ assert_learning_material(material_id)
1219
+ manifest = store.manifest(material_id)
1220
+ if manifest.render_mode not in {"video", "audio"}:
1221
+ raise ReadingError("this material has no timed transcript")
1222
+ refs = {row.locator: row for row in store.unit_references(material_id)}
1223
+ segments: list[dict[str, Any]] = []
1224
+ budget = MAX_TRANSCRIPT_BYTES
1225
+ for locator, text in store.iter_units(material_id):
1226
+ budget -= len(text.encode("utf-8"))
1227
+ if budget < 0:
1228
+ break
1229
+ ref = refs.get(locator)
1230
+ segments.append(
1231
+ {
1232
+ "locator": locator,
1233
+ "text": text,
1234
+ "title": ref.title if ref else "",
1235
+ "source_href": ref.source_href if ref else "",
1236
+ }
1237
+ )
1238
+ return {
1239
+ "material_id": material_id,
1240
+ "revision": manifest.revision,
1241
+ "unit_count": manifest.unit_count,
1242
+ "truncated": len(segments) < manifest.unit_count,
1243
+ "segments": segments,
1244
+ }
1245
+ except Exception as exc:
1246
+ raise _http_error(exc) from exc
1247
+
1248
+
1249
+ @router.get("/materials/{material_id}/units/{locator}", response_model=UnitText)
1250
+ async def get_unit(material_id: str, locator: int) -> UnitText:
1251
+ """One unit's text — the reader's text view, and the only view for non-PDFs."""
1252
+ store = _store()
1253
+ try:
1254
+ assert_learning_material(material_id)
1255
+ manifest = store.manifest(material_id)
1256
+ return UnitText(
1257
+ locator=locator,
1258
+ unit=manifest.unit,
1259
+ text=store.unit_text(material_id, locator),
1260
+ )
1261
+ except Exception as exc:
1262
+ raise _http_error(exc) from exc
1263
+
1264
+
1265
+ @router.get("/materials/{material_id}/revisions")
1266
+ async def list_material_revisions(material_id: str) -> list[dict[str, Any]]:
1267
+ """Prior immutable snapshots retained when a URL is cleaned/refetched."""
1268
+ store = _store()
1269
+ try:
1270
+ return [row.to_dict() for row in store.revisions(material_id)]
1271
+ except Exception as exc:
1272
+ raise _http_error(exc) from exc
1273
+
1274
+
1275
+ @router.get(
1276
+ "/materials/{material_id}/revisions/{revision}/units/{locator}",
1277
+ response_model=UnitText,
1278
+ )
1279
+ async def get_revision_unit(material_id: str, revision: int, locator: int) -> UnitText:
1280
+ store = _store()
1281
+ try:
1282
+ manifest = next(
1283
+ (row for row in store.revisions(material_id) if row.revision == revision),
1284
+ None,
1285
+ )
1286
+ if manifest is None:
1287
+ raise MaterialNotFound(f"revision {revision} not found")
1288
+ return UnitText(
1289
+ locator=locator,
1290
+ unit=manifest.unit,
1291
+ text=store.revision_unit_text(material_id, revision, locator),
1292
+ )
1293
+ except Exception as exc:
1294
+ raise _http_error(exc) from exc
1295
+
1296
+
1297
+ @router.get("/materials/{material_id}/raw")
1298
+ async def get_raw(material_id: str) -> FileResponse:
1299
+ """The original bytes, for the faithful viewer. Serves Range requests."""
1300
+ return _material_file_response(material_id, browser_ready=False)
1301
+
1302
+
1303
+ @router.get("/materials/{material_id}/render")
1304
+ async def get_render(material_id: str) -> FileResponse:
1305
+ """Serve a browser-ready EPUB archive, or the original for other formats."""
1306
+ return _material_file_response(material_id, browser_ready=True)
1307
+
1308
+
1309
+ def _material_file_response(material_id: str, *, browser_ready: bool) -> FileResponse:
1310
+ store = _store()
1311
+ try:
1312
+ assert_learning_material(material_id)
1313
+ manifest = store.manifest(material_id)
1314
+ path = store.render_path(material_id) if browser_ready else store.raw_path(material_id)
1315
+ except Exception as exc:
1316
+ raise _http_error(exc) from exc
1317
+ if path is None or not path.is_file():
1318
+ raise HTTPException(
1319
+ status_code=404,
1320
+ detail=f"{manifest.filename} has no stored original to render.",
1321
+ )
1322
+ return FileResponse(
1323
+ path,
1324
+ media_type=manifest.mime or "application/octet-stream",
1325
+ filename=manifest.filename,
1326
+ content_disposition_type="inline",
1327
+ )
1328
+
1329
+
1330
+ @router.get("/materials/{material_id}/assets/{asset_name}")
1331
+ async def get_snapshot_asset(material_id: str, asset_name: str) -> FileResponse:
1332
+ """Serve one authenticated, MIME-sniffed image captured with a web page."""
1333
+ from deeptutor.services.web_source.snapshot_assets import snapshot_asset_mime
1334
+
1335
+ store = _store()
1336
+ try:
1337
+ assert_learning_material(material_id)
1338
+ path = store.asset_path(material_id, asset_name)
1339
+ except Exception as exc:
1340
+ raise _http_error(exc) from exc
1341
+ if path is None:
1342
+ raise HTTPException(status_code=404, detail="Snapshot image not found.")
1343
+ data = path.read_bytes()
1344
+ mime = snapshot_asset_mime(data)
1345
+ if mime is None:
1346
+ raise HTTPException(status_code=404, detail="Snapshot image is invalid.")
1347
+ return FileResponse(
1348
+ path,
1349
+ media_type=mime,
1350
+ filename=path.name,
1351
+ content_disposition_type="inline",
1352
+ headers={
1353
+ "Cache-Control": "private, max-age=31536000, immutable",
1354
+ "X-Content-Type-Options": "nosniff",
1355
+ },
1356
+ )
1357
+
1358
+
1359
+ @router.get("/materials/{material_id}/media")
1360
+ async def list_material_media(material_id: str) -> list[dict[str, Any]]:
1361
+ """The embedded-image index (name / locator / mime / size), empty if none."""
1362
+ store = _store()
1363
+ try:
1364
+ return store.media_items(material_id)
1365
+ except Exception as exc:
1366
+ raise _http_error(exc) from exc
1367
+
1368
+
1369
+ @router.get("/materials/{material_id}/media/{name}")
1370
+ async def get_material_media(material_id: str, name: str) -> FileResponse:
1371
+ """One embedded image's bytes, for the reader pane's media strip."""
1372
+ store = _store()
1373
+ try:
1374
+ path = store.media_path(material_id, name)
1375
+ mime = next(
1376
+ (row.get("mime") for row in store.media_items(material_id) if row.get("name") == name),
1377
+ "",
1378
+ )
1379
+ except Exception as exc:
1380
+ raise _http_error(exc) from exc
1381
+ if path is None:
1382
+ raise HTTPException(status_code=404, detail="No such image in this material.")
1383
+ return FileResponse(
1384
+ path,
1385
+ media_type=mime or "image/png",
1386
+ filename=name,
1387
+ content_disposition_type="inline",
1388
+ )
1389
+
1390
+
1391
+ @router.get("/materials/{material_id}/annotations", response_model=list[AnnotationInfo])
1392
+ async def list_annotations(material_id: str) -> list[AnnotationInfo]:
1393
+ store = _store()
1394
+ try:
1395
+ assert_learning_material(material_id)
1396
+ return [_annotation_info(row) for row in store.annotations(material_id)]
1397
+ except Exception as exc:
1398
+ raise _http_error(exc) from exc
1399
+
1400
+
1401
+ @router.get("/materials/{material_id}/position", response_model=PositionInfo)
1402
+ async def get_position(material_id: str) -> PositionInfo:
1403
+ """Return the user's last durable viewport for this material."""
1404
+ store = _store()
1405
+ try:
1406
+ assert_learning_material(material_id)
1407
+ return PositionInfo(**store.position(material_id).to_dict())
1408
+ except Exception as exc:
1409
+ raise _http_error(exc) from exc
1410
+
1411
+
1412
+ @router.put("/materials/{material_id}/position", response_model=PositionInfo)
1413
+ async def save_position(material_id: str, payload: PositionPayload) -> PositionInfo:
1414
+ """Persist a validated numeric locator plus an optional renderer anchor."""
1415
+ store = _store()
1416
+ try:
1417
+ assert_learning_material(material_id)
1418
+ saved = store.save_position(
1419
+ material_id,
1420
+ ReadingPosition(
1421
+ locator=payload.locator,
1422
+ source_anchor=payload.source_anchor,
1423
+ percentage=(
1424
+ payload.percentage
1425
+ if payload.percentage is not None
1426
+ else store.position(material_id).percentage
1427
+ ),
1428
+ ),
1429
+ )
1430
+ try:
1431
+ await asyncio.to_thread(
1432
+ _record_reading_position,
1433
+ material_id,
1434
+ locator=saved.locator,
1435
+ percentage=saved.percentage,
1436
+ )
1437
+ except Exception:
1438
+ logger.exception("Reading position saved, but learning activity recording failed")
1439
+ return PositionInfo(**saved.to_dict())
1440
+ except Exception as exc:
1441
+ raise _http_error(exc) from exc
1442
+
1443
+
1444
+ @router.get("/materials/{material_id}/bookmarks", response_model=BookmarkList)
1445
+ async def list_bookmarks(material_id: str) -> BookmarkList:
1446
+ """Every place the reader has kept in this material, in reading order."""
1447
+ store = _store()
1448
+ try:
1449
+ assert_learning_material(material_id)
1450
+ rows = store.bookmarks(material_id)
1451
+ except Exception as exc:
1452
+ raise _http_error(exc) from exc
1453
+ return BookmarkList(bookmarks=[BookmarkInfo(**row.to_dict()) for row in rows])
1454
+
1455
+
1456
+ @router.post("/materials/{material_id}/bookmarks", response_model=BookmarkInfo)
1457
+ async def add_bookmark(material_id: str, payload: BookmarkPayload) -> BookmarkInfo:
1458
+ """Keep one place. Bookmarking an already-kept locator returns that one."""
1459
+ store = _store()
1460
+ try:
1461
+ assert_learning_material(material_id)
1462
+ saved = store.add_bookmark(
1463
+ material_id,
1464
+ payload.locator,
1465
+ payload.label,
1466
+ payload.source_anchor,
1467
+ )
1468
+ except Exception as exc:
1469
+ raise _http_error(exc) from exc
1470
+ return BookmarkInfo(**saved.to_dict())
1471
+
1472
+
1473
+ @router.delete("/materials/{material_id}/bookmarks/{bookmark_id}")
1474
+ async def delete_bookmark(material_id: str, bookmark_id: str) -> dict[str, bool]:
1475
+ store = _store()
1476
+ try:
1477
+ assert_learning_material(material_id)
1478
+ removed = store.delete_bookmark(material_id, bookmark_id)
1479
+ except Exception as exc:
1480
+ raise _http_error(exc) from exc
1481
+ if not removed:
1482
+ raise HTTPException(status_code=404, detail="Bookmark not found")
1483
+ return {"ok": True}
1484
+
1485
+
1486
+ @router.put("/materials/{material_id}/annotations", response_model=AnnotationInfo)
1487
+ async def save_annotation(material_id: str, payload: AnnotationPayload) -> AnnotationInfo:
1488
+ """Create or update one annotation (id absent = create)."""
1489
+ store = _store()
1490
+ try:
1491
+ assert_learning_material(material_id)
1492
+ saved = store.save_annotation(material_id, payload.to_annotation())
1493
+ except Exception as exc:
1494
+ raise _http_error(exc) from exc
1495
+ return _annotation_info(saved)
1496
+
1497
+
1498
+ @router.delete("/materials/{material_id}/annotations/{annotation_id}")
1499
+ async def delete_annotation(material_id: str, annotation_id: str) -> dict[str, Any]:
1500
+ store = _store()
1501
+ try:
1502
+ assert_learning_material(material_id)
1503
+ removed = store.delete_annotation(material_id, annotation_id)
1504
+ except Exception as exc:
1505
+ raise _http_error(exc) from exc
1506
+ if not removed:
1507
+ raise HTTPException(status_code=404, detail="annotation not found")
1508
+ return {"status": "ok", "annotation_id": annotation_id}
1509
+
1510
+
1511
+ @router.get("/materials/{material_id}/export")
1512
+ async def export(
1513
+ material_id: str,
1514
+ fmt: Literal["auto", "pdf", "markdown"] = Query("auto"),
1515
+ ) -> Response:
1516
+ """Download the material with its annotations applied.
1517
+
1518
+ ``pdf`` writes real PDF annotations into a copy of the original, so the
1519
+ export keeps working outside DeepTutor; ``markdown`` returns the marks as
1520
+ text, which is what every non-PDF format gets.
1521
+ """
1522
+ store = _store()
1523
+ try:
1524
+ assert_learning_material(material_id)
1525
+ result = export_material(store, material_id, fmt=fmt)
1526
+ except Exception as exc:
1527
+ raise _http_error(exc) from exc
1528
+ return Response(
1529
+ content=result.data,
1530
+ media_type=result.media_type,
1531
+ headers={
1532
+ "Content-Disposition": _attachment_header(result.filename),
1533
+ "Content-Length": str(result.byte_size),
1534
+ },
1535
+ )
1536
+
1537
+
1538
+ # === Helpers ==================================================================
1539
+
1540
+
1541
+ def _info(store: ReadingStore, manifest: Any) -> MaterialInfo:
1542
+ return MaterialInfo(
1543
+ **manifest.to_dict() | {"annotation_count": len(store.annotations(manifest.material_id))}
1544
+ )
1545
+
1546
+
1547
+ def _detail(store: ReadingStore, manifest: Any) -> MaterialDetail:
1548
+ outline = store.outline(manifest.material_id)
1549
+ return MaterialDetail(
1550
+ **manifest.to_dict()
1551
+ | {
1552
+ "annotation_count": len(store.annotations(manifest.material_id)),
1553
+ "outline": [entry.to_dict() for entry in outline],
1554
+ "outline_text": render_outline(store, manifest.material_id),
1555
+ "unit_refs": [entry.to_dict() for entry in store.unit_references(manifest.material_id)],
1556
+ }
1557
+ )
1558
+
1559
+
1560
+ def _annotation_info(row: Annotation) -> AnnotationInfo:
1561
+ return AnnotationInfo(**row.to_dict())
1562
+
1563
+
1564
+ def _attachment_header(filename: str) -> str:
1565
+ """RFC 5987 disposition so non-ASCII names survive the round trip."""
1566
+ from urllib.parse import quote
1567
+
1568
+ ascii_fallback = filename.encode("ascii", "ignore").decode("ascii") or "export"
1569
+ return f"attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename)}"
1570
+
1571
+
1572
+ __all__ = ["MAX_MATERIAL_BYTES", "router"]