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,744 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared notebook manager.
|
|
3
|
+
|
|
4
|
+
This module keeps the notebook storage format unchanged so Web and CLI
|
|
5
|
+
can operate on the same files under ``data/user``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import ExitStack, contextmanager
|
|
12
|
+
from enum import Enum
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from deeptutor.services.file_io import atomic_write_json
|
|
23
|
+
from deeptutor.services.llm import clean_thinking_tags
|
|
24
|
+
from deeptutor.services.path_service import get_path_service
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RecordType(str, Enum):
|
|
30
|
+
"""Notebook record type."""
|
|
31
|
+
|
|
32
|
+
SOLVE = "solve"
|
|
33
|
+
QUESTION = "question"
|
|
34
|
+
RESEARCH = "research"
|
|
35
|
+
CHAT = "chat"
|
|
36
|
+
CO_WRITER = "co_writer"
|
|
37
|
+
TUTORBOT = "tutorbot"
|
|
38
|
+
READING = "reading"
|
|
39
|
+
VIDEO_LEARNING = "video_learning"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotebookRecord(BaseModel):
|
|
43
|
+
"""Single record stored in a notebook."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
type: RecordType
|
|
47
|
+
title: str
|
|
48
|
+
summary: str = ""
|
|
49
|
+
user_query: str
|
|
50
|
+
output: str
|
|
51
|
+
metadata: dict = {}
|
|
52
|
+
created_at: float
|
|
53
|
+
kb_name: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Notebook(BaseModel):
|
|
57
|
+
"""Notebook model."""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
name: str
|
|
61
|
+
description: str = ""
|
|
62
|
+
created_at: float
|
|
63
|
+
updated_at: float
|
|
64
|
+
records: list[NotebookRecord] = []
|
|
65
|
+
color: str = "#3B82F6"
|
|
66
|
+
icon: str = "book"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_UNSET = object()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class NotebookCorruptedError(RuntimeError):
|
|
73
|
+
"""A notebook file exists on disk but its JSON could not be parsed.
|
|
74
|
+
|
|
75
|
+
Raised instead of silently reporting "no such notebook" so a damaged
|
|
76
|
+
file surfaces as a real error the caller can show, rather than as a
|
|
77
|
+
notebook that appears to have vanished while its data is still there.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, notebook_id: str, path: Path, cause: Exception) -> None:
|
|
81
|
+
super().__init__(f"Notebook {notebook_id!r} is unreadable ({path}): {cause}")
|
|
82
|
+
self.notebook_id = notebook_id
|
|
83
|
+
self.path = path
|
|
84
|
+
self.cause = cause
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _clean_record_summary(summary: str) -> str:
|
|
88
|
+
"""Remove private model scratchpads before notebook summaries are persisted."""
|
|
89
|
+
return clean_thinking_tags(str(summary or "")).strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NotebookManager:
|
|
93
|
+
"""Manage notebook files stored under ``data/user/workspace/notebook``."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, base_dir: str | None = None):
|
|
96
|
+
if base_dir is None:
|
|
97
|
+
path_service = get_path_service()
|
|
98
|
+
base_dir_path = path_service.get_notebook_dir()
|
|
99
|
+
else:
|
|
100
|
+
base_dir_path = Path(base_dir)
|
|
101
|
+
|
|
102
|
+
self.base_dir = base_dir_path
|
|
103
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
self.index_file = self.base_dir / "notebooks_index.json"
|
|
105
|
+
# One re-entrant lock per notebook, plus one for the shared index.
|
|
106
|
+
# Every read-modify-write cycle below runs under the matching lock so
|
|
107
|
+
# two concurrent saves cannot both load the same revision and clobber
|
|
108
|
+
# one another. Writes themselves go through ``atomic_write_json``, so
|
|
109
|
+
# a reader never observes a half-written file even across processes;
|
|
110
|
+
# the locks close the remaining same-process lost-update window.
|
|
111
|
+
# Lock order is always notebook-then-index, never the reverse.
|
|
112
|
+
self._locks_guard = threading.Lock()
|
|
113
|
+
self._notebook_locks: dict[str, threading.RLock] = {}
|
|
114
|
+
self._index_lock = threading.RLock()
|
|
115
|
+
self._ensure_index()
|
|
116
|
+
|
|
117
|
+
@contextmanager
|
|
118
|
+
def _locked(self, notebook_id: str) -> Iterator[None]:
|
|
119
|
+
"""Hold the per-notebook lock for one read-modify-write cycle."""
|
|
120
|
+
with self._locks_guard:
|
|
121
|
+
lock = self._notebook_locks.get(notebook_id)
|
|
122
|
+
if lock is None:
|
|
123
|
+
lock = threading.RLock()
|
|
124
|
+
self._notebook_locks[notebook_id] = lock
|
|
125
|
+
with lock:
|
|
126
|
+
yield
|
|
127
|
+
|
|
128
|
+
def _ensure_index(self) -> None:
|
|
129
|
+
if not self.index_file.exists():
|
|
130
|
+
atomic_write_json(self.index_file, {"notebooks": []})
|
|
131
|
+
|
|
132
|
+
def _load_index(self) -> dict:
|
|
133
|
+
try:
|
|
134
|
+
with open(self.index_file, encoding="utf-8") as f:
|
|
135
|
+
return json.load(f)
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
return {"notebooks": []}
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
# The index is a derived cache: every entry can be rebuilt from the
|
|
140
|
+
# notebook files themselves, so recovering here loses nothing.
|
|
141
|
+
logger.warning("notebook index unreadable (%s); rebuilding", exc)
|
|
142
|
+
return {"notebooks": self._rebuild_index_entries()}
|
|
143
|
+
|
|
144
|
+
def _rebuild_index_entries(self) -> list[dict]:
|
|
145
|
+
"""Reconstruct index rows by scanning the notebook files on disk."""
|
|
146
|
+
entries: list[dict] = []
|
|
147
|
+
for path in sorted(self.base_dir.glob("*.json")):
|
|
148
|
+
if path == self.index_file:
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
with open(path, encoding="utf-8") as f:
|
|
152
|
+
notebook = json.load(f)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
# The file is left untouched on disk, so the damage stays
|
|
155
|
+
# visible and repairable: list_notebooks() re-adopts it as an
|
|
156
|
+
# ``unreadable`` entry instead of the notebook quietly vanishing.
|
|
157
|
+
logger.warning(
|
|
158
|
+
"notebook file %s is unreadable (%s); excluded from the rebuilt index",
|
|
159
|
+
path,
|
|
160
|
+
exc,
|
|
161
|
+
)
|
|
162
|
+
continue
|
|
163
|
+
entries.append(self._index_row(notebook))
|
|
164
|
+
return entries
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _index_row(notebook: dict) -> dict:
|
|
168
|
+
"""Project a full notebook down to the fields the index carries."""
|
|
169
|
+
return {
|
|
170
|
+
"id": notebook["id"],
|
|
171
|
+
"name": notebook.get("name", ""),
|
|
172
|
+
"description": notebook.get("description", ""),
|
|
173
|
+
"created_at": notebook.get("created_at", 0.0),
|
|
174
|
+
"updated_at": notebook.get("updated_at", 0.0),
|
|
175
|
+
"record_count": len(notebook.get("records", [])),
|
|
176
|
+
"color": notebook.get("color", "#3B82F6"),
|
|
177
|
+
"icon": notebook.get("icon", "book"),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def _save_index(self, index: dict) -> None:
|
|
181
|
+
atomic_write_json(self.index_file, index)
|
|
182
|
+
|
|
183
|
+
def _get_notebook_file(self, notebook_id: str) -> Path:
|
|
184
|
+
base = self.base_dir.resolve()
|
|
185
|
+
target = (base / f"{notebook_id}.json").resolve()
|
|
186
|
+
if not target.is_relative_to(base):
|
|
187
|
+
raise ValueError(f"notebook id escapes base directory: {notebook_id!r}")
|
|
188
|
+
return target
|
|
189
|
+
|
|
190
|
+
def _load_notebook(self, notebook_id: str) -> dict | None:
|
|
191
|
+
"""Return the notebook, or ``None`` when no such file exists.
|
|
192
|
+
|
|
193
|
+
A file that exists but cannot be parsed raises
|
|
194
|
+
:class:`NotebookCorruptedError` rather than returning ``None`` —
|
|
195
|
+
conflating the two is what made damaged notebooks look deleted.
|
|
196
|
+
"""
|
|
197
|
+
filepath = self._get_notebook_file(notebook_id)
|
|
198
|
+
if not filepath.exists():
|
|
199
|
+
return None
|
|
200
|
+
try:
|
|
201
|
+
with open(filepath, encoding="utf-8") as f:
|
|
202
|
+
notebook = json.load(f)
|
|
203
|
+
except FileNotFoundError:
|
|
204
|
+
return None
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
logger.error("notebook %s failed to load from %s: %s", notebook_id, filepath, exc)
|
|
207
|
+
raise NotebookCorruptedError(notebook_id, filepath, exc) from exc
|
|
208
|
+
if self._sanitize_loaded_notebook(notebook):
|
|
209
|
+
try:
|
|
210
|
+
self._save_notebook(notebook_id, notebook)
|
|
211
|
+
except Exception:
|
|
212
|
+
logger.warning("could not persist sanitized notebook %s", notebook_id)
|
|
213
|
+
return notebook
|
|
214
|
+
|
|
215
|
+
def _sanitize_loaded_notebook(self, notebook: dict) -> bool:
|
|
216
|
+
changed = False
|
|
217
|
+
records = notebook.get("records", [])
|
|
218
|
+
if not isinstance(records, list):
|
|
219
|
+
return False
|
|
220
|
+
for record in records:
|
|
221
|
+
if not isinstance(record, dict):
|
|
222
|
+
continue
|
|
223
|
+
raw_summary = record.get("summary", "")
|
|
224
|
+
cleaned = _clean_record_summary(raw_summary)
|
|
225
|
+
if cleaned != raw_summary:
|
|
226
|
+
record["summary"] = cleaned
|
|
227
|
+
changed = True
|
|
228
|
+
return changed
|
|
229
|
+
|
|
230
|
+
def _save_notebook(self, notebook_id: str, notebook: dict) -> None:
|
|
231
|
+
atomic_write_json(self._get_notebook_file(notebook_id), notebook)
|
|
232
|
+
|
|
233
|
+
def _touch_index_entry(self, notebook_id: str, notebook: dict) -> None:
|
|
234
|
+
"""Refresh this notebook's index row, re-adding it if it went missing."""
|
|
235
|
+
with self._index_lock:
|
|
236
|
+
index = self._load_index()
|
|
237
|
+
rows = index.setdefault("notebooks", [])
|
|
238
|
+
row = self._index_row(notebook)
|
|
239
|
+
for position, nb_info in enumerate(rows):
|
|
240
|
+
if nb_info.get("id") == notebook_id:
|
|
241
|
+
# Keep created_at from the index when the file lacks it.
|
|
242
|
+
row["created_at"] = notebook.get("created_at", nb_info.get("created_at", 0.0))
|
|
243
|
+
rows[position] = row
|
|
244
|
+
break
|
|
245
|
+
else:
|
|
246
|
+
rows.append(row)
|
|
247
|
+
self._save_index(index)
|
|
248
|
+
|
|
249
|
+
# === Notebook Operations ===
|
|
250
|
+
|
|
251
|
+
def create_notebook(
|
|
252
|
+
self, name: str, description: str = "", color: str = "#3B82F6", icon: str = "book"
|
|
253
|
+
) -> dict:
|
|
254
|
+
notebook_id = str(uuid.uuid4())[:8]
|
|
255
|
+
now = time.time()
|
|
256
|
+
|
|
257
|
+
notebook = {
|
|
258
|
+
"id": notebook_id,
|
|
259
|
+
"name": name,
|
|
260
|
+
"description": description,
|
|
261
|
+
"created_at": now,
|
|
262
|
+
"updated_at": now,
|
|
263
|
+
"records": [],
|
|
264
|
+
"color": color,
|
|
265
|
+
"icon": icon,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
with self._locked(notebook_id):
|
|
269
|
+
self._save_notebook(notebook_id, notebook)
|
|
270
|
+
self._touch_index_entry(notebook_id, notebook)
|
|
271
|
+
return notebook
|
|
272
|
+
|
|
273
|
+
def get_or_create_notebook(
|
|
274
|
+
self, name: str, description: str = "", color: str = "#3B82F6", icon: str = "book"
|
|
275
|
+
) -> dict:
|
|
276
|
+
"""Return an existing notebook with this exact name, or create one."""
|
|
277
|
+
with self._index_lock:
|
|
278
|
+
for row in self._load_index().get("notebooks", []):
|
|
279
|
+
if row.get("name") != name:
|
|
280
|
+
continue
|
|
281
|
+
notebook_id = str(row.get("id") or "")
|
|
282
|
+
notebook = self._load_notebook(notebook_id) if notebook_id else None
|
|
283
|
+
if notebook:
|
|
284
|
+
return notebook
|
|
285
|
+
return self.create_notebook(name=name, description=description, color=color, icon=icon)
|
|
286
|
+
|
|
287
|
+
def list_notebooks(self) -> list[dict]:
|
|
288
|
+
"""List every notebook on disk, newest first.
|
|
289
|
+
|
|
290
|
+
Rows come from the index rather than from parsing each notebook in
|
|
291
|
+
full, and the index is reconciled against the directory first, so a
|
|
292
|
+
notebook whose index row was lost still shows up. A notebook whose
|
|
293
|
+
file is damaged is reported with ``unreadable`` set instead of being
|
|
294
|
+
dropped — silently omitting it is what made data look deleted.
|
|
295
|
+
"""
|
|
296
|
+
damaged: list[dict] = []
|
|
297
|
+
with self._index_lock:
|
|
298
|
+
index = self._load_index()
|
|
299
|
+
rows = {str(row.get("id")): row for row in index.get("notebooks", []) if row.get("id")}
|
|
300
|
+
|
|
301
|
+
on_disk = {
|
|
302
|
+
path.stem for path in self.base_dir.glob("*.json") if path != self.index_file
|
|
303
|
+
}
|
|
304
|
+
# Drop rows whose file is gone; adopt files the index never learned
|
|
305
|
+
# about. Only the unknown files are parsed — indexed ones are taken
|
|
306
|
+
# at their word, which keeps listing O(index) rather than O(content).
|
|
307
|
+
changed = False
|
|
308
|
+
for notebook_id in list(rows):
|
|
309
|
+
if notebook_id not in on_disk:
|
|
310
|
+
rows.pop(notebook_id)
|
|
311
|
+
changed = True
|
|
312
|
+
for notebook_id in sorted(on_disk - rows.keys()):
|
|
313
|
+
try:
|
|
314
|
+
notebook = self._load_notebook(notebook_id)
|
|
315
|
+
except NotebookCorruptedError:
|
|
316
|
+
# Surface it as a placeholder instead of dropping it: the
|
|
317
|
+
# file is still on disk and the user needs to know that.
|
|
318
|
+
damaged.append({"id": notebook_id, "unreadable": True})
|
|
319
|
+
continue
|
|
320
|
+
if notebook:
|
|
321
|
+
rows[notebook_id] = self._index_row(notebook)
|
|
322
|
+
changed = True
|
|
323
|
+
if changed:
|
|
324
|
+
self._save_index({"notebooks": list(rows.values())})
|
|
325
|
+
|
|
326
|
+
notebooks: list[dict] = [
|
|
327
|
+
{
|
|
328
|
+
"id": notebook_id,
|
|
329
|
+
"name": row.get("name", ""),
|
|
330
|
+
"description": row.get("description", ""),
|
|
331
|
+
"created_at": row.get("created_at", 0.0),
|
|
332
|
+
"updated_at": row.get("updated_at", 0.0),
|
|
333
|
+
"record_count": row.get("record_count", 0),
|
|
334
|
+
"color": row.get("color", "#3B82F6"),
|
|
335
|
+
"icon": row.get("icon", "book"),
|
|
336
|
+
}
|
|
337
|
+
for notebook_id, row in rows.items()
|
|
338
|
+
]
|
|
339
|
+
notebooks.sort(key=lambda x: x["updated_at"], reverse=True)
|
|
340
|
+
|
|
341
|
+
# Damaged notebooks have no trustworthy metadata to sort by, so they
|
|
342
|
+
# ride at the end with just enough for the UI to flag them.
|
|
343
|
+
for entry in damaged:
|
|
344
|
+
notebooks.append(
|
|
345
|
+
{
|
|
346
|
+
"id": entry["id"],
|
|
347
|
+
"name": entry["id"],
|
|
348
|
+
"description": "",
|
|
349
|
+
"created_at": 0.0,
|
|
350
|
+
"updated_at": 0.0,
|
|
351
|
+
"record_count": 0,
|
|
352
|
+
"color": "#3B82F6",
|
|
353
|
+
"icon": "book",
|
|
354
|
+
"unreadable": True,
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
return notebooks
|
|
358
|
+
|
|
359
|
+
def get_notebook(self, notebook_id: str) -> dict | None:
|
|
360
|
+
return self._load_notebook(notebook_id)
|
|
361
|
+
|
|
362
|
+
def update_notebook(
|
|
363
|
+
self,
|
|
364
|
+
notebook_id: str,
|
|
365
|
+
name: str | None = None,
|
|
366
|
+
description: str | None = None,
|
|
367
|
+
color: str | None = None,
|
|
368
|
+
icon: str | None = None,
|
|
369
|
+
) -> dict | None:
|
|
370
|
+
with self._locked(notebook_id):
|
|
371
|
+
notebook = self._load_notebook(notebook_id)
|
|
372
|
+
if not notebook:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
if name is not None:
|
|
376
|
+
notebook["name"] = name
|
|
377
|
+
if description is not None:
|
|
378
|
+
notebook["description"] = description
|
|
379
|
+
if color is not None:
|
|
380
|
+
notebook["color"] = color
|
|
381
|
+
if icon is not None:
|
|
382
|
+
notebook["icon"] = icon
|
|
383
|
+
|
|
384
|
+
notebook["updated_at"] = time.time()
|
|
385
|
+
self._save_notebook(notebook_id, notebook)
|
|
386
|
+
self._touch_index_entry(notebook_id, notebook)
|
|
387
|
+
return notebook
|
|
388
|
+
|
|
389
|
+
def delete_notebook(self, notebook_id: str) -> bool:
|
|
390
|
+
with self._locked(notebook_id):
|
|
391
|
+
filepath = self._get_notebook_file(notebook_id)
|
|
392
|
+
if not filepath.exists():
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
filepath.unlink()
|
|
396
|
+
with self._index_lock:
|
|
397
|
+
index = self._load_index()
|
|
398
|
+
index["notebooks"] = [
|
|
399
|
+
nb for nb in index.get("notebooks", []) if nb.get("id") != notebook_id
|
|
400
|
+
]
|
|
401
|
+
self._save_index(index)
|
|
402
|
+
return True
|
|
403
|
+
|
|
404
|
+
# === Record Operations ===
|
|
405
|
+
|
|
406
|
+
def add_record(
|
|
407
|
+
self,
|
|
408
|
+
notebook_ids: list[str],
|
|
409
|
+
record_type: RecordType | str,
|
|
410
|
+
title: str,
|
|
411
|
+
user_query: str,
|
|
412
|
+
output: str,
|
|
413
|
+
summary: str = "",
|
|
414
|
+
metadata: dict | None = None,
|
|
415
|
+
kb_name: str | None = None,
|
|
416
|
+
) -> dict:
|
|
417
|
+
record_id = str(uuid.uuid4())[:8]
|
|
418
|
+
now = time.time()
|
|
419
|
+
# Accept both enum instances and plain string values from callers.
|
|
420
|
+
resolved_type = (
|
|
421
|
+
record_type if isinstance(record_type, RecordType) else RecordType(str(record_type))
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
record = {
|
|
425
|
+
"id": record_id,
|
|
426
|
+
"type": resolved_type,
|
|
427
|
+
"title": title,
|
|
428
|
+
"summary": _clean_record_summary(summary),
|
|
429
|
+
"user_query": user_query,
|
|
430
|
+
"output": output,
|
|
431
|
+
"metadata": metadata or {},
|
|
432
|
+
"created_at": now,
|
|
433
|
+
"kb_name": kb_name,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
added_to: list[str] = []
|
|
437
|
+
for notebook_id in notebook_ids:
|
|
438
|
+
# One lock per notebook rather than one around the whole loop:
|
|
439
|
+
# the notebooks are independent files and nothing here reads two
|
|
440
|
+
# of them at once, so a single wide lock would only add contention.
|
|
441
|
+
with self._locked(notebook_id):
|
|
442
|
+
try:
|
|
443
|
+
notebook = self._load_notebook(notebook_id)
|
|
444
|
+
except NotebookCorruptedError:
|
|
445
|
+
logger.warning("skipping unreadable notebook %s while saving", notebook_id)
|
|
446
|
+
continue
|
|
447
|
+
if not notebook:
|
|
448
|
+
continue
|
|
449
|
+
# Each notebook stores its own copy. They share a record id so
|
|
450
|
+
# the save is traceable, but editing one does not touch the
|
|
451
|
+
# others — use ``copy_record`` for an explicit independent copy.
|
|
452
|
+
notebook.setdefault("records", []).append(dict(record))
|
|
453
|
+
notebook["updated_at"] = now
|
|
454
|
+
self._save_notebook(notebook_id, notebook)
|
|
455
|
+
self._touch_index_entry(notebook_id, notebook)
|
|
456
|
+
added_to.append(notebook_id)
|
|
457
|
+
|
|
458
|
+
return {"record": record, "added_to_notebooks": added_to}
|
|
459
|
+
|
|
460
|
+
def get_records(self, notebook_id: str, record_ids: list[str] | None = None) -> list[dict]:
|
|
461
|
+
notebook = self._load_notebook(notebook_id)
|
|
462
|
+
if not notebook:
|
|
463
|
+
return []
|
|
464
|
+
|
|
465
|
+
records = list(notebook.get("records", []))
|
|
466
|
+
if not record_ids:
|
|
467
|
+
return records
|
|
468
|
+
|
|
469
|
+
wanted = set(record_ids)
|
|
470
|
+
return [record for record in records if str(record.get("id", "")) in wanted]
|
|
471
|
+
|
|
472
|
+
def get_record(self, notebook_id: str, record_id: str) -> dict | None:
|
|
473
|
+
records = self.get_records(notebook_id, [record_id])
|
|
474
|
+
return records[0] if records else None
|
|
475
|
+
|
|
476
|
+
def update_record(
|
|
477
|
+
self,
|
|
478
|
+
notebook_id: str,
|
|
479
|
+
record_id: str,
|
|
480
|
+
*,
|
|
481
|
+
title: str | None = None,
|
|
482
|
+
summary: str | None = None,
|
|
483
|
+
user_query: str | None = None,
|
|
484
|
+
output: str | None = None,
|
|
485
|
+
metadata: dict | None = None,
|
|
486
|
+
kb_name: str | None | object = _UNSET,
|
|
487
|
+
) -> dict | None:
|
|
488
|
+
"""Edit one record in place. Only this notebook's copy is affected.
|
|
489
|
+
|
|
490
|
+
Every parameter defaults to "leave alone". ``kb_name`` additionally
|
|
491
|
+
distinguishes *omitted* from an explicit ``None``, so a caller that
|
|
492
|
+
only renames a record cannot accidentally drop its knowledge-base
|
|
493
|
+
link — pass ``kb_name=None`` when clearing it is what you mean.
|
|
494
|
+
"""
|
|
495
|
+
with self._locked(notebook_id):
|
|
496
|
+
notebook = self._load_notebook(notebook_id)
|
|
497
|
+
if not notebook:
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
updated_record: dict | None = None
|
|
501
|
+
for record in notebook.get("records", []):
|
|
502
|
+
if str(record.get("id", "")) != str(record_id):
|
|
503
|
+
continue
|
|
504
|
+
if title is not None:
|
|
505
|
+
record["title"] = title
|
|
506
|
+
if summary is not None:
|
|
507
|
+
record["summary"] = _clean_record_summary(summary)
|
|
508
|
+
if user_query is not None:
|
|
509
|
+
record["user_query"] = user_query
|
|
510
|
+
if output is not None:
|
|
511
|
+
record["output"] = output
|
|
512
|
+
if metadata is not None:
|
|
513
|
+
current_metadata = record.get("metadata", {}) or {}
|
|
514
|
+
record["metadata"] = {**current_metadata, **metadata}
|
|
515
|
+
if kb_name is not _UNSET:
|
|
516
|
+
record["kb_name"] = kb_name
|
|
517
|
+
updated_record = record
|
|
518
|
+
break
|
|
519
|
+
|
|
520
|
+
if updated_record is None:
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
notebook["updated_at"] = time.time()
|
|
524
|
+
self._save_notebook(notebook_id, notebook)
|
|
525
|
+
self._touch_index_entry(notebook_id, notebook)
|
|
526
|
+
return updated_record
|
|
527
|
+
|
|
528
|
+
def get_records_by_references(self, notebook_references: list[dict]) -> list[dict]:
|
|
529
|
+
resolved: list[dict] = []
|
|
530
|
+
|
|
531
|
+
for ref in notebook_references:
|
|
532
|
+
notebook_id = str(ref.get("notebook_id", "") or "").strip()
|
|
533
|
+
if not notebook_id:
|
|
534
|
+
continue
|
|
535
|
+
record_ids = [
|
|
536
|
+
str(record_id).strip()
|
|
537
|
+
for record_id in (ref.get("record_ids") or [])
|
|
538
|
+
if str(record_id).strip()
|
|
539
|
+
]
|
|
540
|
+
try:
|
|
541
|
+
notebook = self._load_notebook(notebook_id)
|
|
542
|
+
except NotebookCorruptedError:
|
|
543
|
+
# Resolving references feeds a chat turn; one damaged notebook
|
|
544
|
+
# must not take the whole turn down with it.
|
|
545
|
+
logger.warning("skipping unreadable notebook %s while resolving refs", notebook_id)
|
|
546
|
+
continue
|
|
547
|
+
if not notebook:
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
notebook_name = str(notebook.get("name", "") or notebook_id)
|
|
551
|
+
for record in self.get_records(notebook_id, record_ids):
|
|
552
|
+
resolved.append(
|
|
553
|
+
{
|
|
554
|
+
**record,
|
|
555
|
+
"notebook_id": notebook_id,
|
|
556
|
+
"notebook_name": notebook_name,
|
|
557
|
+
}
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
return resolved
|
|
561
|
+
|
|
562
|
+
def remove_record(self, notebook_id: str, record_id: str) -> bool:
|
|
563
|
+
with self._locked(notebook_id):
|
|
564
|
+
notebook = self._load_notebook(notebook_id)
|
|
565
|
+
if not notebook:
|
|
566
|
+
return False
|
|
567
|
+
|
|
568
|
+
records = notebook.get("records", [])
|
|
569
|
+
original_count = len(records)
|
|
570
|
+
notebook["records"] = [r for r in records if str(r.get("id")) != str(record_id)]
|
|
571
|
+
|
|
572
|
+
if len(notebook["records"]) == original_count:
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
notebook["updated_at"] = time.time()
|
|
576
|
+
self._save_notebook(notebook_id, notebook)
|
|
577
|
+
self._touch_index_entry(notebook_id, notebook)
|
|
578
|
+
return True
|
|
579
|
+
|
|
580
|
+
def copy_record(
|
|
581
|
+
self, source_notebook_id: str, record_id: str, target_notebook_id: str
|
|
582
|
+
) -> dict | None:
|
|
583
|
+
"""Duplicate a record into another notebook under a fresh id.
|
|
584
|
+
|
|
585
|
+
The copy is independent from the moment it lands: editing either side
|
|
586
|
+
leaves the other untouched. Returns ``None`` when the source record or
|
|
587
|
+
the target notebook does not exist.
|
|
588
|
+
"""
|
|
589
|
+
if source_notebook_id == target_notebook_id:
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
source_record = self.get_record(source_notebook_id, record_id)
|
|
593
|
+
if source_record is None:
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
copied = dict(source_record)
|
|
597
|
+
copied["id"] = str(uuid.uuid4())[:8]
|
|
598
|
+
copied["metadata"] = {
|
|
599
|
+
**(source_record.get("metadata") or {}),
|
|
600
|
+
"copied_from": {"notebook_id": source_notebook_id, "record_id": record_id},
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
with self._locked(target_notebook_id):
|
|
604
|
+
target = self._load_notebook(target_notebook_id)
|
|
605
|
+
if not target:
|
|
606
|
+
return None
|
|
607
|
+
target.setdefault("records", []).append(copied)
|
|
608
|
+
target["updated_at"] = time.time()
|
|
609
|
+
self._save_notebook(target_notebook_id, target)
|
|
610
|
+
self._touch_index_entry(target_notebook_id, target)
|
|
611
|
+
return copied
|
|
612
|
+
|
|
613
|
+
def locate_record(self, record_id: str, notebook_id: str = "") -> str | None:
|
|
614
|
+
"""Find a saved source within this workspace, preferring its original collection."""
|
|
615
|
+
ids = [notebook_id] if notebook_id else []
|
|
616
|
+
ids.extend(item["id"] for item in self.list_notebooks() if item["id"] != notebook_id)
|
|
617
|
+
for candidate in ids:
|
|
618
|
+
try:
|
|
619
|
+
if self.get_record(candidate, record_id) is not None:
|
|
620
|
+
return candidate
|
|
621
|
+
except NotebookCorruptedError:
|
|
622
|
+
continue
|
|
623
|
+
return None
|
|
624
|
+
|
|
625
|
+
def move_record(
|
|
626
|
+
self, source_notebook_id: str, record_id: str, target_notebook_id: str
|
|
627
|
+
) -> dict | None:
|
|
628
|
+
"""Move without changing identity, so existing note citations remain valid.
|
|
629
|
+
|
|
630
|
+
Lock both collections in stable order. Write the destination first so
|
|
631
|
+
an interrupted write can never destroy the only copy of the source.
|
|
632
|
+
"""
|
|
633
|
+
if source_notebook_id == target_notebook_id:
|
|
634
|
+
return None
|
|
635
|
+
with ExitStack() as stack:
|
|
636
|
+
for notebook_id in sorted([source_notebook_id, target_notebook_id]):
|
|
637
|
+
stack.enter_context(self._locked(notebook_id))
|
|
638
|
+
source = self._load_notebook(source_notebook_id)
|
|
639
|
+
target = self._load_notebook(target_notebook_id)
|
|
640
|
+
if source is None or target is None:
|
|
641
|
+
return None
|
|
642
|
+
record = next((r for r in source.get("records", []) if r["id"] == record_id), None)
|
|
643
|
+
if record is None:
|
|
644
|
+
return None
|
|
645
|
+
existing = next((r for r in target.get("records", []) if r["id"] == record_id), None)
|
|
646
|
+
if existing is not None and existing != record:
|
|
647
|
+
raise ValueError(
|
|
648
|
+
"The destination already contains a different version of this favorite."
|
|
649
|
+
)
|
|
650
|
+
if existing is None:
|
|
651
|
+
target.setdefault("records", []).append(record)
|
|
652
|
+
target["updated_at"] = time.time()
|
|
653
|
+
self._save_notebook(target_notebook_id, target)
|
|
654
|
+
self._touch_index_entry(target_notebook_id, target)
|
|
655
|
+
source["records"] = [r for r in source.get("records", []) if r["id"] != record_id]
|
|
656
|
+
source["updated_at"] = time.time()
|
|
657
|
+
self._save_notebook(source_notebook_id, source)
|
|
658
|
+
self._touch_index_entry(source_notebook_id, source)
|
|
659
|
+
return record
|
|
660
|
+
|
|
661
|
+
def export_markdown(self, notebook_id: str) -> str | None:
|
|
662
|
+
"""Render a whole notebook as one Markdown document."""
|
|
663
|
+
notebook = self._load_notebook(notebook_id)
|
|
664
|
+
if not notebook:
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
lines: list[str] = [f"# {notebook.get('name', notebook_id)}"]
|
|
668
|
+
description = str(notebook.get("description") or "").strip()
|
|
669
|
+
if description:
|
|
670
|
+
lines.append("")
|
|
671
|
+
lines.append(f"> {description}")
|
|
672
|
+
|
|
673
|
+
for record in notebook.get("records", []):
|
|
674
|
+
lines.append("")
|
|
675
|
+
lines.append("---")
|
|
676
|
+
lines.append("")
|
|
677
|
+
lines.append(f"## {record.get('title') or '(untitled)'}")
|
|
678
|
+
|
|
679
|
+
created_at = record.get("created_at")
|
|
680
|
+
stamp = (
|
|
681
|
+
time.strftime("%Y-%m-%d %H:%M", time.localtime(created_at)) if created_at else ""
|
|
682
|
+
)
|
|
683
|
+
meta_bits = [bit for bit in (str(record.get("type") or ""), stamp) if bit]
|
|
684
|
+
if meta_bits:
|
|
685
|
+
lines.append("")
|
|
686
|
+
lines.append(f"*{' · '.join(meta_bits)}*")
|
|
687
|
+
|
|
688
|
+
summary = str(record.get("summary") or "").strip()
|
|
689
|
+
if summary:
|
|
690
|
+
lines.append("")
|
|
691
|
+
lines.append(f"> {summary}")
|
|
692
|
+
|
|
693
|
+
output = str(record.get("output") or "").strip()
|
|
694
|
+
if output:
|
|
695
|
+
lines.append("")
|
|
696
|
+
lines.append(output)
|
|
697
|
+
|
|
698
|
+
return "\n".join(lines) + "\n"
|
|
699
|
+
|
|
700
|
+
def get_statistics(self) -> dict:
|
|
701
|
+
notebooks = self.list_notebooks()
|
|
702
|
+
|
|
703
|
+
total_records = 0
|
|
704
|
+
# Derived from the enum so a newly added record type is counted the
|
|
705
|
+
# day it is introduced, instead of silently missing from the totals.
|
|
706
|
+
type_counts = {member.value: 0 for member in RecordType}
|
|
707
|
+
|
|
708
|
+
for nb_info in notebooks:
|
|
709
|
+
try:
|
|
710
|
+
notebook = self._load_notebook(nb_info["id"])
|
|
711
|
+
except NotebookCorruptedError:
|
|
712
|
+
continue
|
|
713
|
+
if notebook:
|
|
714
|
+
for record in notebook.get("records", []):
|
|
715
|
+
total_records += 1
|
|
716
|
+
record_type = record.get("type", "")
|
|
717
|
+
if record_type in type_counts:
|
|
718
|
+
type_counts[record_type] += 1
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
"total_notebooks": len(notebooks),
|
|
722
|
+
"total_records": total_records,
|
|
723
|
+
"records_by_type": type_counts,
|
|
724
|
+
"recent_notebooks": notebooks[:5],
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
_instances: dict[str, NotebookManager] = {}
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def get_notebook_manager() -> NotebookManager:
|
|
732
|
+
base_dir = get_path_service().get_notebook_dir().resolve()
|
|
733
|
+
key = str(base_dir)
|
|
734
|
+
if key not in _instances:
|
|
735
|
+
_instances[key] = NotebookManager(base_dir=str(base_dir))
|
|
736
|
+
return _instances[key]
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
class _NotebookManagerProxy:
|
|
740
|
+
def __getattr__(self, name: str):
|
|
741
|
+
return getattr(get_notebook_manager(), name)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
notebook_manager = _NotebookManagerProxy()
|