-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Expand file tree
/
Copy path_note_service.py
More file actions
381 lines (326 loc) · 15.1 KB
/
_note_service.py
File metadata and controls
381 lines (326 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
"""Private note-row primitives — classifier + CRUD.
This module owns the backend note-row operations shared by ``NotesAPI``
(plain notes + saved-from-chat notes) and ``ArtifactsAPI`` (mind maps,
which the server stores in the same note collection). It deliberately
sits *below* both feature facades so neither has to import the other,
and so the mind-map adapter (``_mind_map.NoteBackedMindMapService``)
has a single seam to delegate through.
``NoteRowKind`` is a private classification of the raw row shapes
returned by the ``GET_NOTES_AND_MIND_MAPS`` RPC. It is intentionally
NOT part of the public ``notebooklm`` surface — the public ``Note``
dataclass and ``client.notes`` / ``client.artifacts`` facades remain
the only stable contract.
Risk-mitigation note (refactor-history.md §Risks): saved-chat note metadata is
not always reliably present on the wire. When the classifier cannot
positively identify a row as a saved-from-chat note it defaults to
``NOTE`` (not ``UNKNOWN``) so the NotesAPI list path keeps surfacing
the row — losing a chat-mode tag is preferable to dropping the note.
"""
from __future__ import annotations
import asyncio
import logging
from enum import Enum
from typing import TYPE_CHECKING, Any
from ._row_adapters_notes import NoteRow
from .exceptions import RPCError
from .rpc.types import RPCMethod
from .types import Note
if TYPE_CHECKING:
from ._runtime_contracts import RpcCaller
__all__ = ["NoteService"] # NoteRowKind is intentionally NOT exported
logger = logging.getLogger(__name__)
# Module-level strong-ref anchor for fire-and-forget cleanup tasks (RUF006).
# ``asyncio.create_task`` returns a Task that the event loop only holds via a
# weak reference, so an unrooted Task can be garbage-collected mid-execution —
# losing the orphan-row cleanup the cancel-safety shield is supposed to
# guarantee. Each created task adds itself here and removes itself in a
# done-callback so the set stays bounded.
#
# Intentionally module-level (not per-instance): the cleanup tasks are
# detached fire-and-forget work whose only purpose is to keep the loop's
# Task storage from GC-ing them mid-flight. Sharing one set across all
# ``NoteService`` instances is correct and simpler than per-instance
# bookkeeping — there is no per-instance state on the tasks themselves.
# Audit CC6: single-loop-per-client invariant per ADR-004; not safe for multi-loop fan-out.
_cleanup_tasks: set[asyncio.Task[Any]] = set()
class NoteRowKind(Enum):
"""Private classification of rows from ``GET_NOTES_AND_MIND_MAPS``.
Not part of the public API — kept private so the wire-shape
classification can evolve without a SemVer hit. Phase 6 may add
further variants (e.g. distinct treatment for saved-from-chat
notes) without breaking external callers.
"""
NOTE = "note"
SAVED_CHAT = "saved_chat"
MIND_MAP = "mind_map"
DELETED = "deleted"
UNKNOWN = "unknown"
class NoteService:
"""Backend note-row primitives — fetch + classify + CRUD.
Owns the ``GET_NOTES_AND_MIND_MAPS`` / ``CREATE_NOTE`` /
``UPDATE_NOTE`` / ``DELETE_NOTE`` RPC family. Shared by
``NotesAPI`` and by ``NoteBackedMindMapService`` (the adapter
that powers ``ArtifactsAPI`` mind-map paths).
Takes the narrow :class:`RpcCaller` capability — note CRUD only
needs ``rpc_call(...)``; everything else (drain hooks, transport,
loop-affinity guards) is irrelevant to this service.
"""
def __init__(self, rpc: RpcCaller) -> None:
self._rpc = rpc
# ------------------------------------------------------------------
# Row fetch + classification
# ------------------------------------------------------------------
async def fetch_note_rows(self, notebook_id: str) -> list[Any]:
"""Fetch all note + mind-map rows for a notebook.
Returns the raw row list (each row is itself a list whose first
element is the row ID). Soft-deleted rows are included — callers
decide whether to filter via :meth:`classify_row`.
"""
params = [notebook_id]
result = await self._rpc.rpc_call(
RPCMethod.GET_NOTES_AND_MIND_MAPS,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
rows = self._extract_note_row_container(result)
if not rows:
return []
normalized: list[Any] = []
for item in rows:
row = self._normalize_note_row(item)
if row is not None:
normalized.append(row)
return normalized
def _extract_note_row_container(self, result: Any) -> list[Any]:
"""Return the list that contains raw note rows.
Historical responses wrap rows as ``[[row, ...]]``. Newer web
responses use the same first response field for rows and a second
timestamp field, so this helper also accepts a flat row list.
"""
if not result or not isinstance(result, list):
return []
first = result[0]
if self._is_note_row_like(first):
return result
if isinstance(first, list):
return first
return []
def _normalize_note_row(self, item: Any) -> list[Any] | None:
"""Normalize supported note wrapper shapes into parser rows.
Current NotebookLM front-end code wraps live notes as
``[None, [note_id, content, metadata, ..., title]]``. The public
parsers expect ``[note_id, nested_note]``, so normalize that wrapper
before classification/parsing while preserving legacy rows and
soft-deleted rows such as ``[note_id, None, 2]``.
"""
if not self._is_note_row_like(item):
return None
if isinstance(item[0], str):
return item
nested = item[1]
return [nested[0], nested, *item[2:]]
def _is_note_row_like(self, item: Any) -> bool:
if not isinstance(item, list) or len(item) == 0:
return False
if isinstance(item[0], str):
return True
return (
item[0] is None
and len(item) > 1
and isinstance(item[1], list)
and len(item[1]) > 0
and isinstance(item[1][0], str)
)
def classify_row(self, row: list[Any]) -> NoteRowKind:
"""Identify what kind of row this is.
Wire shapes encountered:
* deleted: ``["id", None, 2]`` — content is ``None`` and the
slot at position 2 is the soft-delete sentinel.
* mind-map: content payload parses as JSON with ``"children":``
or ``"nodes":`` keys (regardless of legacy vs current shape).
* saved-chat: a plain note row whose metadata flags chat mode.
That metadata is not reliably present on the wire, so when we
cannot positively confirm chat mode we fall through to
``NOTE`` rather than ``UNKNOWN`` (refactor-history.md §Risks).
* plain note: default for any other content-bearing row.
Position knowledge (the deletion sentinel and the
legacy-vs-current content dispatch) lives in
:class:`notebooklm._row_adapters_notes.NoteRow`. This classifier reads
named properties on the adapter and does not touch raw indices.
"""
if not isinstance(row, list) or len(row) == 0:
return NoteRowKind.UNKNOWN
note_row = NoteRow(row)
if note_row.is_deleted:
return NoteRowKind.DELETED
content = note_row.content
if NoteRow.is_mind_map_content(content):
return NoteRowKind.MIND_MAP
if content is None:
return NoteRowKind.UNKNOWN
# Phase 6 may grow saved-chat detection; for now default to NOTE
# so a chat-mode note never silently drops out of NotesAPI.list().
return NoteRowKind.NOTE
def extract_content(self, row: list[Any]) -> str | None:
"""Get the JSON content payload of a row, or ``None``.
Thin facade over :attr:`NoteRow.content`. Kept on
:class:`NoteService` so existing callers
(``NotesAPI._extract_content``, ``NoteBackedMindMapService.extract_content``,
and the tests pinning ``service.extract_content`` behaviour)
continue to work unchanged while position knowledge moves to
the adapter.
"""
if not isinstance(row, list):
return None
return NoteRow(row).content
# ------------------------------------------------------------------
# CRUD
# ------------------------------------------------------------------
async def create_note(
self,
notebook_id: str,
title: str = "New Note",
content: str = "",
*,
operation_variant: str = "plain",
) -> Note:
"""Create a note row and finalize its content + title.
``CREATE_NOTE`` ignores the title param server-side, so we follow
up with ``UPDATE_NOTE`` to set both content and title. Returns a
:class:`Note` dataclass for consistency with ``NotesAPI``.
Cancellation behaviour (audit item §28): the UPDATE_NOTE
finalize is wrapped in ``asyncio.shield`` so an outer cancel
cannot abort an in-flight finalize. If ``CancelledError``
propagates while the shielded UPDATE_NOTE is still running, a
best-effort DELETE_NOTE cleanup is scheduled (NOT awaited —
re-raise must not block on cleanup) to honour the caller's
cancel intent without leaving an orphan row behind. The legacy
``_mind_map.MindMapService.create_note`` path that previously
owned this contract was retired; the contract itself now lives here.
"""
params = [notebook_id, "", [1], None, title]
result = await self._rpc.rpc_call(
RPCMethod.CREATE_NOTE,
params,
source_path=f"/notebook/{notebook_id}",
operation_variant=operation_variant,
)
note_id: str | None = None
if result and isinstance(result, list) and len(result) > 0:
if isinstance(result[0], list) and len(result[0]) > 0:
note_id = result[0][0]
elif isinstance(result[0], str):
note_id = result[0]
if not note_id:
# CREATE_NOTE returned a payload we cannot extract a note id
# from. Returning ``Note(id="")`` would be a success-shaped
# lie: the title/content were never finalized via UPDATE_NOTE,
# and any later operation keyed on the empty id misbehaves.
# Raise instead, matching the sibling create paths
# (``_source_add`` / ``notebooks.create``) which surface an
# error rather than fabricate a degenerate resource.
raise RPCError(
"CREATE_NOTE returned no usable note id; the note was not created",
method_id=RPCMethod.CREATE_NOTE.value,
)
# Shield the UPDATE_NOTE finalize from outer cancellation:
# CREATE_NOTE has already persisted a row server-side; without
# the shield, a cancel arriving between CREATE_NOTE and
# UPDATE_NOTE completion leaves an orphan row with no
# title/content.
#
# ``update_task`` is a freestanding ``asyncio.Task`` (not a
# bare coroutine) so the cancel-time cleanup branch can await
# it before issuing the best-effort DELETE_NOTE. If we instead
# fired DELETE_NOTE in parallel with the still-running
# shielded UPDATE_NOTE, delete could complete first and update could then
# write to an already-soft-deleted row — observable as an
# inconsistent row state on the server side and a swallowed
# exception in the cleanup task.
update_task = asyncio.create_task(self.update_note(notebook_id, note_id, content, title))
try:
await asyncio.shield(update_task)
except asyncio.CancelledError:
# Ordered fire-and-forget cleanup: first wait for the
# shielded UPDATE_NOTE to finish (success OR error),
# THEN issue the best-effort DELETE_NOTE. The re-raise
# MUST NOT await the wrapper task. Strong-ref via
# ``_cleanup_tasks`` so the loop's weak-ref Task storage
# cannot GC the wrapper mid-flight (RUF006); the
# done-callback discards on completion so the set stays
# bounded.
async def _finalize_then_cleanup() -> None:
try:
try:
await update_task
except Exception: # noqa: BLE001 — log and proceed to delete
logger.debug(
"Shielded UPDATE_NOTE failed before cleanup for note %s in notebook %s",
note_id,
notebook_id,
exc_info=True,
)
finally:
await self._delete_note_best_effort(notebook_id, note_id)
cleanup_task = asyncio.create_task(_finalize_then_cleanup())
_cleanup_tasks.add(cleanup_task)
cleanup_task.add_done_callback(_cleanup_tasks.discard)
raise
return Note(
id=note_id,
notebook_id=notebook_id,
title=title,
content=content,
)
async def _delete_note_best_effort(self, notebook_id: str, note_id: str) -> None:
"""Best-effort DELETE_NOTE cleanup for a partially-finalized create.
Used as a fire-and-forget ``asyncio.create_task`` target when an
outer cancel arrives mid-UPDATE_NOTE: we never block the
re-raise on this call, and any failure (network, auth refresh,
etc.) is logged and swallowed. The only desired side effect is
orphan-row removal.
"""
try:
await self.delete_note(notebook_id, note_id)
except Exception: # noqa: BLE001 — best-effort cleanup, must not surface
logger.warning(
"Best-effort DELETE_NOTE cleanup failed for note %s in notebook %s",
note_id,
notebook_id,
exc_info=True,
)
async def update_note(
self,
notebook_id: str,
note_id: str,
content: str,
title: str,
) -> None:
"""Update a note row's content and title in place."""
params = [
notebook_id,
note_id,
[[[content, title, [], 0]]],
]
await self._rpc.rpc_call(
RPCMethod.UPDATE_NOTE,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
async def delete_note(self, notebook_id: str, note_id: str) -> None:
"""Soft-delete a note row.
Returns ``None``. Idempotent: a missing note still succeeds
(``DELETE_NOTE`` is ``allow_null=True`` with no missing-signal). The
public facade (``client.notes.delete`` /
``NoteBackedMindMapService.delete_mind_map``) returns ``None`` as of
v0.7.0 (issue #1211).
"""
params = [notebook_id, None, [note_id]]
await self._rpc.rpc_call(
RPCMethod.DELETE_NOTE,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)