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.
- deeptutor/api/routers/notebook.py +493 -0
- deeptutor/services/notebook/__init__.py +19 -0
- deeptutor/services/notebook/service.py +744 -0
- deeptutor_cli/notebook.py +131 -0
- deeptutor_notebook-1.7.0.dist-info/METADATA +6 -0
- deeptutor_notebook-1.7.0.dist-info/RECORD +8 -0
- deeptutor_notebook-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_notebook-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -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
|
+
]
|