deeptutor-notebook 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.
@@ -0,0 +1,493 @@
1
+ """
2
+ Notebook API Router
3
+ Provides notebook creation, querying, updating, deletion, and record management functions
4
+ """
5
+
6
+ import json
7
+ from typing import AsyncGenerator, Literal
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+ from fastapi.responses import PlainTextResponse, StreamingResponse
11
+ from pydantic import BaseModel
12
+
13
+ from deeptutor.agents.notebook import NotebookSummarizeAgent
14
+ from deeptutor.services.llm import clean_thinking_tags
15
+ from deeptutor.services.notebook import notebook_manager
16
+ from deeptutor.services.notebook.service import NotebookCorruptedError
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ def _unreadable(exc: NotebookCorruptedError) -> HTTPException:
22
+ """Translate a damaged notebook file into a response the UI can explain.
23
+
24
+ 409 rather than 500: the request was fine, the stored file is not, and
25
+ the client can act on it (restore a backup, delete the notebook).
26
+
27
+ Every endpoint re-raises through this before its generic `except
28
+ Exception`, because that generic clause would otherwise flatten a
29
+ diagnosable "your file is damaged" into an anonymous 500. The contract is
30
+ covered by a test that walks the routes, so a new endpoint that forgets
31
+ it fails CI rather than degrading quietly.
32
+ """
33
+ return HTTPException(
34
+ status_code=409,
35
+ detail={
36
+ "code": "notebook_unreadable",
37
+ "notebook_id": exc.notebook_id,
38
+ "message": str(exc),
39
+ },
40
+ )
41
+
42
+
43
+ # === Request/Response Models ===
44
+
45
+
46
+ class CreateNotebookRequest(BaseModel):
47
+ """Create notebook request"""
48
+
49
+ name: str
50
+ description: str = ""
51
+ color: str = "#3B82F6"
52
+ icon: str = "book"
53
+
54
+
55
+ class UpdateNotebookRequest(BaseModel):
56
+ """Update notebook request"""
57
+
58
+ name: str | None = None
59
+ description: str | None = None
60
+ color: str | None = None
61
+ icon: str | None = None
62
+
63
+
64
+ class AddRecordRequest(BaseModel):
65
+ """Add record request"""
66
+
67
+ notebook_ids: list[str]
68
+ record_type: Literal[
69
+ "solve",
70
+ "question",
71
+ "research",
72
+ "chat",
73
+ "co_writer",
74
+ "tutorbot",
75
+ "reading",
76
+ "video_learning",
77
+ ]
78
+ title: str
79
+ summary: str = ""
80
+ user_query: str
81
+ output: str
82
+ metadata: dict = {}
83
+ kb_name: str | None = None
84
+
85
+
86
+ class RemoveRecordRequest(BaseModel):
87
+ """Remove record request"""
88
+
89
+ record_id: str
90
+
91
+
92
+ class UpdateRecordRequest(BaseModel):
93
+ """Update an existing notebook record.
94
+
95
+ Only the fields the client actually sends are forwarded (see
96
+ ``exclude_unset`` at the call site), which is what keeps ``kb_name``
97
+ from being wiped by a request that merely renames the record.
98
+ """
99
+
100
+ title: str | None = None
101
+ summary: str | None = None
102
+ user_query: str | None = None
103
+ output: str | None = None
104
+ metadata: dict | None = None
105
+ kb_name: str | None = None
106
+
107
+
108
+ class MoveRecordRequest(BaseModel):
109
+ """Move or copy a record into another notebook."""
110
+
111
+ target_notebook_id: str
112
+
113
+
114
+ # === API Endpoints ===
115
+
116
+
117
+ async def _build_record_summary(request: AddRecordRequest) -> str:
118
+ if request.summary.strip():
119
+ return clean_thinking_tags(request.summary).strip()
120
+ agent = NotebookSummarizeAgent(language=str(request.metadata.get("ui_language", "en")))
121
+ return clean_thinking_tags(
122
+ await agent.summarize(
123
+ title=request.title,
124
+ record_type=request.record_type,
125
+ user_query=request.user_query,
126
+ output=request.output,
127
+ metadata=request.metadata,
128
+ )
129
+ ).strip()
130
+
131
+
132
+ async def _stream_add_record_with_summary(
133
+ request: AddRecordRequest,
134
+ ) -> AsyncGenerator[str, None]:
135
+ try:
136
+ agent = NotebookSummarizeAgent(language=str(request.metadata.get("ui_language", "en")))
137
+ summary_parts: list[str] = []
138
+ if request.summary.strip():
139
+ summary = clean_thinking_tags(request.summary).strip()
140
+ summary_parts.append(summary)
141
+ if summary:
142
+ yield f"data: {json.dumps({'type': 'summary_chunk', 'content': summary}, ensure_ascii=False)}\n\n"
143
+ else:
144
+ async for chunk in agent.stream_summary(
145
+ title=request.title,
146
+ record_type=request.record_type,
147
+ user_query=request.user_query,
148
+ output=request.output,
149
+ metadata=request.metadata,
150
+ ):
151
+ if not chunk:
152
+ continue
153
+ summary_parts.append(chunk)
154
+
155
+ summary = clean_thinking_tags("".join(summary_parts)).strip()
156
+ if summary:
157
+ yield f"data: {json.dumps({'type': 'summary_chunk', 'content': summary}, ensure_ascii=False)}\n\n"
158
+
159
+ summary = clean_thinking_tags("".join(summary_parts)).strip()
160
+ result = notebook_manager.add_record(
161
+ notebook_ids=request.notebook_ids,
162
+ record_type=request.record_type,
163
+ title=request.title,
164
+ summary=summary,
165
+ user_query=request.user_query,
166
+ output=request.output,
167
+ metadata=request.metadata,
168
+ kb_name=request.kb_name,
169
+ )
170
+ payload = {
171
+ "type": "result",
172
+ "success": True,
173
+ "summary": summary,
174
+ "record": result["record"],
175
+ "added_to_notebooks": result["added_to_notebooks"],
176
+ }
177
+ yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
178
+ except Exception as exc:
179
+ payload = {"type": "error", "detail": str(exc)}
180
+ yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
181
+
182
+
183
+ # NOTE: every literal path below must stay above the `/{notebook_id}` routes.
184
+ # FastAPI matches in declaration order, so a literal declared later is shadowed
185
+ # by the parameterised route and becomes unreachable.
186
+ @router.get("/notebooks/health")
187
+ async def health_check():
188
+ """Health check"""
189
+ return {"status": "healthy", "service": "notebook"}
190
+
191
+
192
+ @router.get("/notebooks")
193
+ async def list_notebooks():
194
+ """
195
+ Get all notebook list
196
+
197
+ Returns:
198
+ Notebook list (includes summary information)
199
+ """
200
+ try:
201
+ notebooks = notebook_manager.list_notebooks()
202
+ return {"notebooks": notebooks, "total": len(notebooks)}
203
+ except NotebookCorruptedError as exc:
204
+ raise _unreadable(exc)
205
+ except Exception as e:
206
+ raise HTTPException(status_code=500, detail=str(e))
207
+
208
+
209
+ @router.get("/notebooks/statistics")
210
+ async def get_statistics():
211
+ """
212
+ Get notebook statistics
213
+
214
+ Returns:
215
+ Statistics information
216
+ """
217
+ try:
218
+ stats = notebook_manager.get_statistics()
219
+ return stats
220
+ except NotebookCorruptedError as exc:
221
+ raise _unreadable(exc)
222
+ except Exception as e:
223
+ raise HTTPException(status_code=500, detail=str(e))
224
+
225
+
226
+ @router.post("/notebooks")
227
+ async def create_notebook(request: CreateNotebookRequest):
228
+ """
229
+ Create new notebook
230
+
231
+ Args:
232
+ request: Create request
233
+
234
+ Returns:
235
+ Created notebook information
236
+ """
237
+ try:
238
+ notebook = notebook_manager.create_notebook(
239
+ name=request.name,
240
+ description=request.description,
241
+ color=request.color,
242
+ icon=request.icon,
243
+ )
244
+ return {"success": True, "notebook": notebook}
245
+ except NotebookCorruptedError as exc:
246
+ raise _unreadable(exc)
247
+ except Exception as e:
248
+ raise HTTPException(status_code=500, detail=str(e))
249
+
250
+
251
+ @router.get("/notebooks/{notebook_id}")
252
+ async def get_notebook(notebook_id: str):
253
+ """
254
+ Get notebook details
255
+
256
+ Args:
257
+ notebook_id: Notebook ID
258
+
259
+ Returns:
260
+ Notebook details (includes all records)
261
+ """
262
+ try:
263
+ notebook = notebook_manager.get_notebook(notebook_id)
264
+ if not notebook:
265
+ raise HTTPException(status_code=404, detail="Notebook not found")
266
+ return notebook
267
+ except HTTPException:
268
+ raise
269
+ except NotebookCorruptedError as exc:
270
+ raise _unreadable(exc)
271
+ except Exception as e:
272
+ raise HTTPException(status_code=500, detail=str(e))
273
+
274
+
275
+ @router.put("/notebooks/{notebook_id}")
276
+ async def update_notebook(notebook_id: str, request: UpdateNotebookRequest):
277
+ """
278
+ Update notebook information
279
+
280
+ Args:
281
+ notebook_id: Notebook ID
282
+ request: Update request
283
+
284
+ Returns:
285
+ Updated notebook information
286
+ """
287
+ try:
288
+ notebook = notebook_manager.update_notebook(
289
+ notebook_id=notebook_id,
290
+ name=request.name,
291
+ description=request.description,
292
+ color=request.color,
293
+ icon=request.icon,
294
+ )
295
+ if not notebook:
296
+ raise HTTPException(status_code=404, detail="Notebook not found")
297
+ return {"success": True, "notebook": notebook}
298
+ except HTTPException:
299
+ raise
300
+ except NotebookCorruptedError as exc:
301
+ raise _unreadable(exc)
302
+ except Exception as e:
303
+ raise HTTPException(status_code=500, detail=str(e))
304
+
305
+
306
+ @router.delete("/notebooks/{notebook_id}")
307
+ async def delete_notebook(notebook_id: str):
308
+ """
309
+ Delete notebook
310
+
311
+ Args:
312
+ notebook_id: Notebook ID
313
+
314
+ Returns:
315
+ Deletion result
316
+ """
317
+ try:
318
+ success = notebook_manager.delete_notebook(notebook_id)
319
+ if not success:
320
+ raise HTTPException(status_code=404, detail="Notebook not found")
321
+ return {"success": True, "message": "Notebook deleted successfully"}
322
+ except HTTPException:
323
+ raise
324
+ except NotebookCorruptedError as exc:
325
+ raise _unreadable(exc)
326
+ except Exception as e:
327
+ raise HTTPException(status_code=500, detail=str(e))
328
+
329
+
330
+ @router.post("/notebooks/actions/add-record")
331
+ async def add_record(request: AddRecordRequest):
332
+ """
333
+ Add record to notebook
334
+
335
+ Args:
336
+ request: Add record request
337
+
338
+ Returns:
339
+ Addition result
340
+ """
341
+ try:
342
+ summary = await _build_record_summary(request)
343
+ result = notebook_manager.add_record(
344
+ notebook_ids=request.notebook_ids,
345
+ record_type=request.record_type,
346
+ title=request.title,
347
+ summary=summary,
348
+ user_query=request.user_query,
349
+ output=request.output,
350
+ metadata=request.metadata,
351
+ kb_name=request.kb_name,
352
+ )
353
+ return {
354
+ "success": True,
355
+ "summary": summary,
356
+ "record": result["record"],
357
+ "added_to_notebooks": result["added_to_notebooks"],
358
+ }
359
+ except NotebookCorruptedError as exc:
360
+ raise _unreadable(exc)
361
+ except Exception as e:
362
+ raise HTTPException(status_code=500, detail=str(e))
363
+
364
+
365
+ @router.post("/notebooks/actions/add-record-with-summary")
366
+ async def add_record_with_summary(request: AddRecordRequest):
367
+ """Add record to notebook and stream generated summary."""
368
+ return StreamingResponse(
369
+ _stream_add_record_with_summary(request),
370
+ media_type="text/event-stream",
371
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
372
+ )
373
+
374
+
375
+ @router.delete("/notebooks/{notebook_id}/records/{record_id}")
376
+ async def remove_record(notebook_id: str, record_id: str):
377
+ """
378
+ Remove record from notebook
379
+
380
+ Args:
381
+ notebook_id: Notebook ID
382
+ record_id: Record ID
383
+
384
+ Returns:
385
+ Deletion result
386
+ """
387
+ try:
388
+ success = notebook_manager.remove_record(notebook_id, record_id)
389
+ if not success:
390
+ raise HTTPException(status_code=404, detail="Record not found")
391
+ return {"success": True, "message": "Record removed successfully"}
392
+ except HTTPException:
393
+ raise
394
+ except NotebookCorruptedError as exc:
395
+ raise _unreadable(exc)
396
+ except Exception as e:
397
+ raise HTTPException(status_code=500, detail=str(e))
398
+
399
+
400
+ @router.get("/notebooks/records/{record_id}/location")
401
+ async def locate_record(record_id: str, notebook_id: str = ""):
402
+ """Resolve a source citation after a collection move, within the active workspace."""
403
+ location = notebook_manager.locate_record(record_id, notebook_id)
404
+ if location is None:
405
+ raise HTTPException(status_code=404, detail="Favorite not found")
406
+ return {"notebook_id": location, "record_id": record_id}
407
+
408
+
409
+ @router.put("/notebooks/{notebook_id}/records/{record_id}")
410
+ async def update_record(notebook_id: str, record_id: str, request: UpdateRecordRequest):
411
+ """Update an existing notebook record in place."""
412
+ try:
413
+ # Forward only what the client actually sent. Passing every field
414
+ # unconditionally would hand `kb_name=None` to the service on every
415
+ # request and clear the record's knowledge-base link as a side effect
416
+ # of renaming it; the service's sentinel default only works if an
417
+ # omitted field never reaches it.
418
+ changes = request.model_dump(exclude_unset=True)
419
+ if "output" in changes or "user_query" in changes:
420
+ raise HTTPException(
421
+ status_code=400,
422
+ detail="Favorites preserve their original content. Add this record to a note to edit it.",
423
+ )
424
+ if not changes:
425
+ raise HTTPException(status_code=400, detail="No fields to update")
426
+ updated = notebook_manager.update_record(
427
+ notebook_id=notebook_id,
428
+ record_id=record_id,
429
+ **changes,
430
+ )
431
+ if not updated:
432
+ raise HTTPException(status_code=404, detail="Record not found")
433
+ return {"success": True, "record": updated}
434
+ except HTTPException:
435
+ raise
436
+ except NotebookCorruptedError as exc:
437
+ raise _unreadable(exc)
438
+ except Exception as e:
439
+ raise HTTPException(status_code=500, detail=str(e))
440
+
441
+
442
+ @router.post("/notebooks/{notebook_id}/records/{record_id}/actions/copy")
443
+ async def copy_record(notebook_id: str, record_id: str, request: MoveRecordRequest):
444
+ """Duplicate a record into another notebook under a fresh id."""
445
+ try:
446
+ copied = notebook_manager.copy_record(notebook_id, record_id, request.target_notebook_id)
447
+ if not copied:
448
+ raise HTTPException(status_code=404, detail="Record or target notebook not found")
449
+ return {"success": True, "record": copied}
450
+ except HTTPException:
451
+ raise
452
+ except NotebookCorruptedError as exc:
453
+ raise _unreadable(exc)
454
+ except Exception as e:
455
+ raise HTTPException(status_code=500, detail=str(e))
456
+
457
+
458
+ @router.post("/notebooks/{notebook_id}/records/{record_id}/actions/move")
459
+ async def move_record(notebook_id: str, record_id: str, request: MoveRecordRequest):
460
+ """Move a record from this notebook into another one."""
461
+ try:
462
+ moved = notebook_manager.move_record(notebook_id, record_id, request.target_notebook_id)
463
+ if not moved:
464
+ raise HTTPException(status_code=404, detail="Record or target notebook not found")
465
+ return {"success": True, "record": moved}
466
+ except HTTPException:
467
+ raise
468
+ except NotebookCorruptedError as exc:
469
+ raise _unreadable(exc)
470
+ except ValueError as exc:
471
+ raise HTTPException(status_code=409, detail=str(exc))
472
+ except Exception as e:
473
+ raise HTTPException(status_code=500, detail=str(e))
474
+
475
+
476
+ @router.get("/notebooks/{notebook_id}/export", response_class=PlainTextResponse)
477
+ async def export_notebook(notebook_id: str):
478
+ """Render the whole notebook as a single Markdown document."""
479
+ try:
480
+ markdown = notebook_manager.export_markdown(notebook_id)
481
+ if markdown is None:
482
+ raise HTTPException(status_code=404, detail="Notebook not found")
483
+ return PlainTextResponse(
484
+ markdown,
485
+ media_type="text/markdown; charset=utf-8",
486
+ headers={"Content-Disposition": f'attachment; filename="{notebook_id}.md"'},
487
+ )
488
+ except HTTPException:
489
+ raise
490
+ except NotebookCorruptedError as exc:
491
+ raise _unreadable(exc)
492
+ except Exception as e:
493
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,19 @@
1
+ """Shared notebook service used by CLI, Web, and runtime."""
2
+
3
+ from .service import (
4
+ Notebook,
5
+ NotebookManager,
6
+ NotebookRecord,
7
+ RecordType,
8
+ get_notebook_manager,
9
+ notebook_manager,
10
+ )
11
+
12
+ __all__ = [
13
+ "Notebook",
14
+ "NotebookManager",
15
+ "NotebookRecord",
16
+ "RecordType",
17
+ "get_notebook_manager",
18
+ "notebook_manager",
19
+ ]