Skip to content

Commit c2bacf1

Browse files
feat(progress): add course progress summary
Add a local course progress summary mode to studyctl progress, including --course and --json output while preserving concept confidence recording. Add a deterministic NotebookLM client boundary with an in-memory fake and contract tests for create, skip, update, stale source, and auth-failure behavior. Document the NotebookLM adapter pattern and update the CLI reference for the new progress commands.
1 parent 1f83d9d commit c2bacf1

9 files changed

Lines changed: 1035 additions & 6 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,9 @@ site/
479479
docs/internal/
480480
docs/local_repo_docs/
481481
docs/plans/
482-
docs/architecture/
482+
docs/architecture/*
483+
!docs/architecture/
484+
!docs/architecture/adapter-pattern-notebooklm.md
483485

484486
# Demo recordings (large, local-only)
485487
demos/
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
# Adapter Pattern For NotebookLM Workflows
2+
3+
This note explains the strict adapter approach used for the local NotebookLM
4+
simulation boundary in `studyctl`.
5+
6+
## What
7+
8+
The adapter pattern puts a stable interface between application code and an
9+
external dependency.
10+
11+
In this repo, the application logic should depend on a small `NotebookClient`
12+
contract, not directly on `notebooklm-py`, browser auth, Google RPC responses,
13+
or network timing.
14+
15+
## Why It Matters
16+
17+
NotebookLM is valuable but operationally awkward:
18+
19+
- It needs authentication.
20+
- It depends on a third-party service.
21+
- It can fail because of quota, browser state, cookies, rate limits, or RPC
22+
changes.
23+
- It is expensive to test repeatedly.
24+
25+
Those are infrastructure concerns. They should sit at the edge of the system.
26+
The study workflow itself should be testable using local files and local data.
27+
28+
The production rule is:
29+
30+
> Core workflow code talks to a port. Real services and fakes are adapters.
31+
32+
## Networking Analogy
33+
34+
Think of the adapter like a routing boundary.
35+
36+
Your internal network should not depend on the exact implementation details of
37+
an upstream provider. You define your local routing policy, then peer with the
38+
provider through a controlled edge.
39+
40+
In code:
41+
42+
- The protocol is the routing policy.
43+
- The real adapter is the provider edge.
44+
- The fake adapter is a lab router.
45+
- Contract tests are your BGP/session validation checks.
46+
47+
## Shape In This Repo
48+
49+
```text
50+
studyctl content workflow
51+
|
52+
v
53+
NotebookClient protocol
54+
|
55+
+--> InMemoryNotebookClient fake
56+
|
57+
+--> NotebookLMPyAdapter real implementation
58+
```
59+
60+
The workflow owns the behavior:
61+
62+
- create notebook if missing
63+
- upload new source
64+
- skip unchanged source
65+
- replace changed source
66+
- report stale remote source
67+
- surface auth failures as expected errors
68+
69+
The adapter owns the mechanics:
70+
71+
- how to authenticate
72+
- how to call NotebookLM
73+
- how to map external source objects into internal dataclasses
74+
- how to translate upstream exceptions
75+
76+
## Dependency Direction
77+
78+
```mermaid
79+
flowchart TD
80+
CLI["studyctl CLI"] --> Workflow["Content workflow"]
81+
Workflow --> Port["NotebookClient protocol"]
82+
Fake["InMemoryNotebookClient"] --> Port
83+
Real["NotebookLMPyAdapter"] --> Port
84+
Real --> Lib["notebooklm-py"]
85+
Lib --> Service["Google NotebookLM"]
86+
87+
classDef core fill:#e8f4ff,stroke:#2463a6,color:#111;
88+
classDef edge fill:#fff4e5,stroke:#b46b00,color:#111;
89+
classDef external fill:#f5e8ff,stroke:#7140a0,color:#111;
90+
91+
class CLI,Workflow,Port core;
92+
class Fake,Real edge;
93+
class Lib,Service external;
94+
```
95+
96+
The important detail: the workflow imports the protocol, not the real adapter.
97+
98+
That keeps this kind of dependency out of core workflow code:
99+
100+
```python
101+
from notebooklm import NotebookLMClient # avoid in workflow code
102+
```
103+
104+
Instead, workflow code receives a client that satisfies the protocol:
105+
106+
```python
107+
async def sync_notebook_sources(
108+
client: NotebookClient,
109+
*,
110+
notebook_title: str,
111+
uploads: list[SourceUpload],
112+
) -> NotebookSyncResult:
113+
...
114+
```
115+
116+
## Minimal Port
117+
118+
The port should be boring and small. It expresses what `studyctl` needs, not
119+
everything NotebookLM can do.
120+
121+
```python
122+
from pathlib import Path
123+
from typing import Protocol
124+
from dataclasses import dataclass
125+
126+
127+
@dataclass(frozen=True)
128+
class NotebookRef:
129+
id: str
130+
title: str
131+
132+
133+
@dataclass(frozen=True)
134+
class SourceUpload:
135+
path: Path
136+
title: str
137+
content_hash: str
138+
139+
140+
@dataclass(frozen=True)
141+
class NotebookSource:
142+
id: str
143+
title: str
144+
path: Path
145+
content_hash: str
146+
status: str = "ready"
147+
148+
149+
class NotebookClient(Protocol):
150+
async def ensure_notebook(self, title: str) -> NotebookRef: ...
151+
152+
async def list_sources(self, notebook_id: str) -> list[NotebookSource]: ...
153+
154+
async def add_source(self, notebook_id: str, upload: SourceUpload) -> NotebookSource: ...
155+
156+
async def replace_source(
157+
self,
158+
notebook_id: str,
159+
source_id: str,
160+
upload: SourceUpload,
161+
) -> NotebookSource: ...
162+
```
163+
164+
**Teaching moment**: a `Protocol` is structural typing - if an object has the
165+
right methods, it satisfies the interface. This is Python's duck typing with
166+
type-checker support.
167+
168+
## Workflow Code
169+
170+
The workflow has no idea whether the client is fake or real.
171+
172+
```python
173+
async def sync_notebook_sources(
174+
client: NotebookClient,
175+
*,
176+
notebook_title: str,
177+
uploads: list[SourceUpload],
178+
) -> NotebookSyncResult:
179+
notebook = await client.ensure_notebook(notebook_title)
180+
existing = await client.list_sources(notebook.id)
181+
existing_by_path = {source.path: source for source in existing}
182+
desired_paths = {upload.path for upload in uploads}
183+
184+
created: list[str] = []
185+
updated: list[str] = []
186+
skipped: list[str] = []
187+
188+
for upload in uploads:
189+
current = existing_by_path.get(upload.path)
190+
if current is None:
191+
source = await client.add_source(notebook.id, upload)
192+
created.append(source.title)
193+
continue
194+
195+
if current.content_hash == upload.content_hash:
196+
skipped.append(current.title)
197+
continue
198+
199+
source = await client.replace_source(notebook.id, current.id, upload)
200+
updated.append(source.title)
201+
202+
stale = [source.title for source in existing if source.path not in desired_paths]
203+
return NotebookSyncResult(
204+
notebook=notebook,
205+
created=created,
206+
updated=updated,
207+
skipped=skipped,
208+
stale=stale,
209+
)
210+
```
211+
212+
This is the core value of the pattern: the interesting behavior is simple,
213+
local, and directly testable.
214+
215+
## Fake Adapter
216+
217+
The fake adapter is not a mock. It is a small implementation of the same port.
218+
219+
```python
220+
class InMemoryNotebookClient:
221+
def __init__(self, *, authenticated: bool = True) -> None:
222+
self._authenticated = authenticated
223+
self._notebooks: dict[str, _NotebookState] = {}
224+
self._next_notebook_id = 1
225+
self._next_source_id = 1
226+
227+
async def ensure_notebook(self, title: str) -> NotebookRef:
228+
self._require_auth()
229+
for state in self._notebooks.values():
230+
if state.notebook.title == title:
231+
return state.notebook
232+
233+
notebook = NotebookRef(id=f"nb-{self._next_notebook_id}", title=title)
234+
self._next_notebook_id += 1
235+
self._notebooks[notebook.id] = _NotebookState(notebook=notebook)
236+
return notebook
237+
```
238+
239+
Use this fake for:
240+
241+
- unit tests
242+
- local simulation
243+
- planner development
244+
- CI
245+
- demos that should not touch real NotebookLM
246+
247+
Do not use it to prove Google integration works. That belongs in a smaller
248+
number of integration tests around the real adapter.
249+
250+
## Real Adapter
251+
252+
The real adapter should map `notebooklm-py` into the internal protocol.
253+
254+
Sketch:
255+
256+
```python
257+
class NotebookLMPyAdapter:
258+
def __init__(self, client: NotebookLMClient) -> None:
259+
self._client = client
260+
261+
async def ensure_notebook(self, title: str) -> NotebookRef:
262+
try:
263+
notebooks = await self._client.notebooks.list()
264+
for notebook in notebooks:
265+
if notebook.title == title:
266+
return NotebookRef(id=notebook.id, title=notebook.title)
267+
268+
created = await self._client.notebooks.create(title=title)
269+
return NotebookRef(id=created.id, title=created.title)
270+
except NotebookLMError as exc:
271+
raise NotebookClientError(str(exc)) from exc
272+
```
273+
274+
The adapter can use current `notebooklm-py` features:
275+
276+
- `client.notebooks.get_metadata()` for source metadata inspection
277+
- `client.sources.list()` for source inventory
278+
- `client.sources.add_file()` for upload
279+
- source status helpers for readiness
280+
- `download_quiz()` and `download_flashcards()` for review import
281+
- the upstream exception hierarchy for clean user-facing errors
282+
283+
The workflow should not depend on those concrete types directly.
284+
285+
## TDD Method Used
286+
287+
The implementation follows this loop:
288+
289+
```mermaid
290+
stateDiagram-v2
291+
[*] --> Red
292+
Red --> VerifyRed: run focused pytest
293+
VerifyRed --> Green: expected failure
294+
VerifyRed --> Red: wrong failure
295+
Green --> VerifyGreen: implement smallest code
296+
VerifyGreen --> Refactor: tests pass
297+
VerifyGreen --> Green: tests fail
298+
Refactor --> VerifyGreen
299+
VerifyGreen --> [*]: focused suite green
300+
```
301+
302+
For the NotebookLM boundary, the first tests define behavior:
303+
304+
```python
305+
async def test_sync_skips_unchanged_sources() -> None:
306+
client = InMemoryNotebookClient()
307+
await sync_notebook_sources(
308+
client,
309+
notebook_title="Python",
310+
uploads=[SourceUpload(path=Path("a.md"), title="a", content_hash="same")],
311+
)
312+
313+
result = await sync_notebook_sources(
314+
client,
315+
notebook_title="Python",
316+
uploads=[SourceUpload(path=Path("a.md"), title="a", content_hash="same")],
317+
)
318+
319+
assert result.created == []
320+
assert result.updated == []
321+
assert result.skipped == ["a"]
322+
```
323+
324+
That test fails before the module exists. Then the minimum protocol and fake are
325+
created to pass it.
326+
327+
## Contract Tests
328+
329+
Contract tests validate behavior shared by all adapters.
330+
331+
```mermaid
332+
flowchart LR
333+
Contract["contract tests"] --> Fake["fake adapter"]
334+
Contract --> Real["real adapter"]
335+
Fake --> SameRules["same behavior rules"]
336+
Real --> SameRules
337+
```
338+
339+
The contract should cover:
340+
341+
- creates notebook
342+
- uploads new sources
343+
- skips unchanged sources
344+
- updates changed sources
345+
- reports stale sources
346+
- raises a stable auth error
347+
348+
Later, the same contract can run against the real adapter with explicit
349+
credentials and an integration-test marker.
350+
351+
## Common Mistakes
352+
353+
- Putting `notebooklm-py` imports in core workflow code.
354+
- Mocking every method call instead of using a small fake with real behavior.
355+
- Making the port mirror the full external library API.
356+
- Testing only the happy path and missing stale/update/auth cases.
357+
- Letting adapter exceptions leak directly into CLI tracebacks.
358+
- Using the fake as proof that the external service still works.
359+
360+
## Practical Rule
361+
362+
If a behavior can be expressed as local data in and local data out, test it with
363+
the fake.
364+
365+
Only use real NotebookLM tests for the thin edge where `studyctl` maps its
366+
internal protocol to the external library.

0 commit comments

Comments
 (0)