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.
- deeptutor/api/routers/reading.py +1572 -0
- deeptutor/api/routers/reading_extensions.py +498 -0
- deeptutor/api/routers/video_learning.py +571 -0
- deeptutor/api/routers/voice.py +94 -0
- deeptutor/capabilities/reading/__init__.py +33 -0
- deeptutor/capabilities/reading/_tool_base.py +86 -0
- deeptutor/capabilities/reading/capability.py +497 -0
- deeptutor/capabilities/reading/figure_view.py +180 -0
- deeptutor/capabilities/reading/media_notes.py +92 -0
- deeptutor/capabilities/reading/mode.py +54 -0
- deeptutor/capabilities/reading/prompts/en/reading.yaml +86 -0
- deeptutor/capabilities/reading/prompts/zh/reading.yaml +69 -0
- deeptutor/capabilities/reading/tools.py +680 -0
- deeptutor/reading/__init__.py +125 -0
- deeptutor/reading/_grounding.py +115 -0
- deeptutor/reading/captions.py +144 -0
- deeptutor/reading/catalog_models.py +143 -0
- deeptutor/reading/catalog_store.py +1092 -0
- deeptutor/reading/epub_bilingual.py +248 -0
- deeptutor/reading/export.py +259 -0
- deeptutor/reading/extensions.py +206 -0
- deeptutor/reading/extract.py +600 -0
- deeptutor/reading/ingestion.py +1189 -0
- deeptutor/reading/knowledge_capture.py +177 -0
- deeptutor/reading/models.py +538 -0
- deeptutor/reading/page_render.py +98 -0
- deeptutor/reading/quiz.py +139 -0
- deeptutor/reading/read_aloud.py +33 -0
- deeptutor/reading/references.py +183 -0
- deeptutor/reading/refresh.py +185 -0
- deeptutor/reading/search.py +239 -0
- deeptutor/reading/service.py +252 -0
- deeptutor/reading/store.py +1571 -0
- deeptutor/reading/study_guidance.py +103 -0
- deeptutor/reading/translation.py +133 -0
- deeptutor/reading/vocabulary.py +131 -0
- deeptutor/services/reading_hints.py +503 -0
- deeptutor/video_learning/__init__.py +71 -0
- deeptutor/video_learning/invidious_account.py +360 -0
- deeptutor/video_learning/invidious_account_client.py +85 -0
- deeptutor/video_learning/invidious_account_storage.py +177 -0
- deeptutor/video_learning/marks.py +409 -0
- deeptutor/video_learning/notes.py +233 -0
- deeptutor/video_learning/reading_migration.py +213 -0
- deeptutor/video_learning/service.py +863 -0
- deeptutor_reading-1.7.0.dist-info/METADATA +11 -0
- deeptutor_reading-1.7.0.dist-info/RECORD +50 -0
- deeptutor_reading-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_reading-1.7.0.dist-info/entry_points.txt +6 -0
- 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"]
|