deeptutor-memory 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.
Files changed (50) hide show
  1. deeptutor/agents/loop/native_memory.py +24 -0
  2. deeptutor/api/routers/memory.py +785 -0
  3. deeptutor/services/memory/__init__.py +42 -0
  4. deeptutor/services/memory/consolidator/__init__.py +62 -0
  5. deeptutor/services/memory/consolidator/chunker.py +131 -0
  6. deeptutor/services/memory/consolidator/guards.py +104 -0
  7. deeptutor/services/memory/consolidator/line_doc.py +486 -0
  8. deeptutor/services/memory/consolidator/meta.py +194 -0
  9. deeptutor/services/memory/consolidator/modes/__init__.py +31 -0
  10. deeptutor/services/memory/consolidator/modes/_runtime.py +319 -0
  11. deeptutor/services/memory/consolidator/modes/_shims.py +129 -0
  12. deeptutor/services/memory/consolidator/modes/audit.py +481 -0
  13. deeptutor/services/memory/consolidator/modes/dedup.py +228 -0
  14. deeptutor/services/memory/consolidator/modes/merge.py +227 -0
  15. deeptutor/services/memory/consolidator/modes/update.py +706 -0
  16. deeptutor/services/memory/consolidator/parse.py +135 -0
  17. deeptutor/services/memory/consolidator/prompts/en/_meta.yaml +36 -0
  18. deeptutor/services/memory/consolidator/prompts/en/audit_l2.yaml +42 -0
  19. deeptutor/services/memory/consolidator/prompts/en/audit_l3.yaml +38 -0
  20. deeptutor/services/memory/consolidator/prompts/en/dedup.yaml +37 -0
  21. deeptutor/services/memory/consolidator/prompts/en/update_l2.yaml +45 -0
  22. deeptutor/services/memory/consolidator/prompts/en/update_l3.yaml +51 -0
  23. deeptutor/services/memory/consolidator/prompts/zh/_meta.yaml +35 -0
  24. deeptutor/services/memory/consolidator/prompts/zh/audit_l2.yaml +40 -0
  25. deeptutor/services/memory/consolidator/prompts/zh/audit_l3.yaml +34 -0
  26. deeptutor/services/memory/consolidator/prompts/zh/dedup.yaml +34 -0
  27. deeptutor/services/memory/consolidator/prompts/zh/update_l2.yaml +40 -0
  28. deeptutor/services/memory/consolidator/prompts/zh/update_l3.yaml +43 -0
  29. deeptutor/services/memory/consolidator/references.py +388 -0
  30. deeptutor/services/memory/consolidator/runs.py +405 -0
  31. deeptutor/services/memory/document.py +235 -0
  32. deeptutor/services/memory/ids.py +80 -0
  33. deeptutor/services/memory/ops.py +149 -0
  34. deeptutor/services/memory/paths.py +104 -0
  35. deeptutor/services/memory/prompts/en.yaml +156 -0
  36. deeptutor/services/memory/prompts/zh.yaml +144 -0
  37. deeptutor/services/memory/recall.py +237 -0
  38. deeptutor/services/memory/settings.py +198 -0
  39. deeptutor/services/memory/snapshot/__init__.py +127 -0
  40. deeptutor/services/memory/snapshot/adapters.py +690 -0
  41. deeptutor/services/memory/snapshot/diff.py +67 -0
  42. deeptutor/services/memory/snapshot/entity.py +92 -0
  43. deeptutor/services/memory/snapshot/store.py +113 -0
  44. deeptutor/services/memory/store.py +453 -0
  45. deeptutor/services/memory/trace.py +153 -0
  46. deeptutor_cli/memory.py +99 -0
  47. deeptutor_memory-1.7.0.dist-info/METADATA +6 -0
  48. deeptutor_memory-1.7.0.dist-info/RECORD +50 -0
  49. deeptutor_memory-1.7.0.dist-info/WHEEL +5 -0
  50. deeptutor_memory-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,690 @@
1
+ """Workspace → ``Entity`` adapters.
2
+
3
+ One pure read-only function per surface. Adapters never mutate
4
+ workspace state. They read whatever lives under
5
+ ``data/user/workspace/`` (or, for chat/quiz, the chat history SQLite
6
+ DB; for kb-list, the kb config JSON; for the ``partner`` surface, the
7
+ per-partner conversation JSONL under ``data/partners/``).
8
+
9
+ Each adapter returns a ``list[Entity]`` with stable ``id`` and a
10
+ deterministic ``fingerprint`` so the diff engine can detect changes
11
+ across refreshes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from contextlib import closing
17
+ from datetime import datetime, timezone
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ from pathlib import Path
22
+ import sqlite3
23
+ from typing import Any
24
+
25
+ from deeptutor.multi_user.paths import get_account_path_service
26
+ from deeptutor.services.memory.paths import Surface
27
+ from deeptutor.services.memory.snapshot.entity import Entity, EntityStamp
28
+ from deeptutor.services.path_service import get_path_service
29
+ from deeptutor.services.workspace.context import WorkspacePathService, WorkspaceScope
30
+ from deeptutor.services.workspace.models import WorkspaceError
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # ── Helpers ──────────────────────────────────────────────────────────
36
+
37
+
38
+ def _sha1(*parts: object) -> str:
39
+ h = hashlib.sha1(usedforsecurity=False)
40
+ for part in parts:
41
+ if part is None:
42
+ continue
43
+ if isinstance(part, (dict, list)):
44
+ blob = json.dumps(part, sort_keys=True, ensure_ascii=False)
45
+ else:
46
+ blob = str(part)
47
+ h.update(blob.encode("utf-8"))
48
+ h.update(b"\0")
49
+ return h.hexdigest()[:16]
50
+
51
+
52
+ def _iso(ts: float | int | str | None) -> str:
53
+ if isinstance(ts, str):
54
+ try:
55
+ datetime.fromisoformat(ts.replace("Z", "+00:00"))
56
+ return ts
57
+ except Exception:
58
+ pass
59
+ if isinstance(ts, (int, float)):
60
+ try:
61
+ return datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
62
+ except Exception:
63
+ pass
64
+ return ""
65
+
66
+
67
+ def _iter_chat_dbs() -> list[tuple[str, Path]]:
68
+ """``(qualifier, chat-history db)`` pairs across the account's workspaces.
69
+
70
+ The default workspace comes first with an empty qualifier so existing
71
+ snapshots keep their bare session ids — renumbering them would churn every
72
+ account-level snapshot (#1799). Each additional registered workspace
73
+ contributes its own chat db under a ``ws:<workspace_id>:`` qualifier, which
74
+ is what makes custom-workspace conversations visible to account-level
75
+ Memory without id collisions against the default workspace.
76
+
77
+ The account's own data root supplies the default entry; binding data roots
78
+ are derived the same way :class:`WorkspacePathService` derives them
79
+ (``<content_root>/.deeptutor/data``), with the same symlink refusal — a
80
+ redirected store is skipped rather than raised, so one broken binding
81
+ cannot blank an account-wide scan.
82
+ """
83
+ account = get_account_path_service()
84
+ pairs: list[tuple[str, Path]] = [("", account.get_chat_history_db())]
85
+ try:
86
+ from deeptutor.services.workspace import get_content_workspace_service
87
+
88
+ bindings = get_content_workspace_service().registered_bindings()
89
+ except Exception:
90
+ logger.warning(
91
+ "workspace binding enumeration failed; default workspace only", exc_info=True
92
+ )
93
+ return pairs
94
+ for binding in bindings:
95
+ try:
96
+ paths = WorkspacePathService(
97
+ account,
98
+ WorkspaceScope(binding.workspace_id, account.workspace_root, binding.root),
99
+ )
100
+ except WorkspaceError:
101
+ logger.warning(
102
+ "workspace %s skipped from memory aggregation: redirected data store",
103
+ binding.workspace_id,
104
+ )
105
+ continue
106
+ pairs.append((f"ws:{binding.workspace_id}:", paths.get_chat_history_db()))
107
+ return pairs
108
+
109
+
110
+ # ── Adapters ─────────────────────────────────────────────────────────
111
+
112
+
113
+ def read_notebook_entities() -> list[Entity]:
114
+ ps = get_path_service()
115
+ index_file = ps.get_notebook_index_file()
116
+ if not index_file.exists():
117
+ return []
118
+ try:
119
+ index = json.loads(index_file.read_text(encoding="utf-8"))
120
+ except (OSError, json.JSONDecodeError):
121
+ return []
122
+ notebooks = index.get("notebooks") or []
123
+ out: list[Entity] = []
124
+ for nb in notebooks:
125
+ if not isinstance(nb, dict):
126
+ continue
127
+ nb_id = nb.get("id")
128
+ nb_name = nb.get("name") or nb_id
129
+ if not nb_id:
130
+ continue
131
+ nb_file = ps.get_notebook_file(nb_id)
132
+ if not nb_file.exists():
133
+ continue
134
+ try:
135
+ nb_data = json.loads(nb_file.read_text(encoding="utf-8"))
136
+ except (OSError, json.JSONDecodeError) as exc:
137
+ logger.warning("notebook snapshot skipped corrupt file: %s (%s)", nb_file, exc)
138
+ continue
139
+ for r in nb_data.get("records") or []:
140
+ if not isinstance(r, dict):
141
+ continue
142
+ rid = r.get("id")
143
+ if not rid:
144
+ continue
145
+ title = r.get("title", "") or ""
146
+ user_query = r.get("user_query", "") or ""
147
+ output = r.get("output", "") or ""
148
+ summary = r.get("summary", "") or ""
149
+ content = "\n\n".join(
150
+ part
151
+ for part in (
152
+ f"# {title}" if title else "",
153
+ f"**User query**: {user_query}" if user_query else "",
154
+ f"**Summary**: {summary}" if summary else "",
155
+ output,
156
+ )
157
+ if part
158
+ )
159
+ out.append(
160
+ Entity(
161
+ id=rid,
162
+ label=f"{title} · {nb_name}" if title else f"({rid}) · {nb_name}",
163
+ ts=_iso(r.get("created_at")),
164
+ content=content,
165
+ metadata={
166
+ "notebook_id": nb_id,
167
+ "notebook_name": nb_name,
168
+ "record_type": r.get("record_type", ""),
169
+ "kb_name": r.get("kb_name"),
170
+ },
171
+ fingerprint=_sha1(title, user_query, output, summary),
172
+ )
173
+ )
174
+ return out
175
+
176
+
177
+ def read_cowriter_entities() -> list[Entity]:
178
+ docs_dir = get_path_service().get_co_writer_docs_dir()
179
+ if not docs_dir.exists():
180
+ return []
181
+ out: list[Entity] = []
182
+ for entry in sorted(docs_dir.iterdir()):
183
+ manifest = entry / "manifest.json"
184
+ if not manifest.exists():
185
+ continue
186
+ try:
187
+ m = json.loads(manifest.read_text(encoding="utf-8"))
188
+ except (OSError, json.JSONDecodeError) as exc:
189
+ logger.warning("cowriter snapshot skipped corrupt manifest: %s (%s)", manifest, exc)
190
+ continue
191
+ doc_id = m.get("id")
192
+ if not doc_id:
193
+ continue
194
+ title = m.get("title", "") or ""
195
+ content = m.get("content", "") or ""
196
+ out.append(
197
+ Entity(
198
+ id=doc_id,
199
+ label=title or doc_id,
200
+ ts=_iso(m.get("updated_at") or m.get("created_at")),
201
+ content=content,
202
+ metadata={"doc_id": doc_id, "title": title},
203
+ fingerprint=_sha1(title, content),
204
+ )
205
+ )
206
+ return out
207
+
208
+
209
+ def read_book_entities() -> list[Entity]:
210
+ books_dir = get_path_service().get_book_dir()
211
+ if not books_dir.exists():
212
+ return []
213
+ out: list[Entity] = []
214
+ for entry in sorted(books_dir.iterdir()):
215
+ if not entry.is_dir():
216
+ continue
217
+ manifest_path = entry / "manifest.json"
218
+ if not manifest_path.exists():
219
+ continue
220
+ try:
221
+ m = json.loads(manifest_path.read_text(encoding="utf-8"))
222
+ except (OSError, json.JSONDecodeError) as exc:
223
+ logger.warning("book snapshot skipped corrupt manifest: %s (%s)", manifest_path, exc)
224
+ continue
225
+ book_id = m.get("id")
226
+ if not book_id:
227
+ continue
228
+ title = m.get("title", "") or ""
229
+ description = m.get("description", "") or ""
230
+ # Pull page titles + chapter outline from spine for L2 to chew on.
231
+ spine_path = entry / "spine.json"
232
+ page_titles: list[str] = []
233
+ if spine_path.exists():
234
+ try:
235
+ spine = json.loads(spine_path.read_text(encoding="utf-8"))
236
+ page_titles = [
237
+ str(p.get("title") or p.get("id") or "")
238
+ for p in (spine.get("pages") or [])
239
+ if isinstance(p, dict)
240
+ ]
241
+ except (OSError, json.JSONDecodeError):
242
+ page_titles = []
243
+ content = "\n\n".join(
244
+ part
245
+ for part in (
246
+ f"# {title}",
247
+ description,
248
+ "## Pages\n" + "\n".join(f"- {p}" for p in page_titles) if page_titles else "",
249
+ )
250
+ if part
251
+ )
252
+ out.append(
253
+ Entity(
254
+ id=book_id,
255
+ label=title or book_id,
256
+ ts=_iso(m.get("updated_at") or m.get("created_at")),
257
+ content=content,
258
+ metadata={
259
+ "book_id": book_id,
260
+ "page_count": m.get("page_count", 0),
261
+ "chapter_count": m.get("chapter_count", 0),
262
+ "knowledge_bases": m.get("knowledge_bases", []),
263
+ "status": m.get("status", ""),
264
+ },
265
+ fingerprint=_sha1(title, description, m.get("updated_at"), len(page_titles)),
266
+ )
267
+ )
268
+ return out
269
+
270
+
271
+ def _partner_display_name(partner_dir, partner_id: str) -> str:
272
+ """Read ``name`` out of a partner's ``config.yaml`` for tagging.
273
+
274
+ Falls back to the directory id when the config is missing or unreadable.
275
+ """
276
+ cfg = partner_dir / "config.yaml"
277
+ if not cfg.exists():
278
+ return partner_id
279
+ try:
280
+ import yaml
281
+
282
+ data = yaml.safe_load(cfg.read_text(encoding="utf-8")) or {}
283
+ except Exception:
284
+ return partner_id
285
+ if isinstance(data, dict):
286
+ name = data.get("name")
287
+ if isinstance(name, str) and name.strip():
288
+ return name.strip()
289
+ return partner_id
290
+
291
+
292
+ def _partner_session_entity(path, partner_id: str, partner_name: str) -> Entity | None:
293
+ """Build one Entity from a partner session JSONL file.
294
+
295
+ The conversation is inlined as ``user / assistant`` blocks (mirroring
296
+ :func:`read_chat_entities`) so L2 sees the actual dialogue, and the
297
+ originating partner is tagged into both the label and the metadata so
298
+ the consolidator carries provenance into L2/L3.
299
+ """
300
+ records: list[dict] = []
301
+ try:
302
+ with path.open("r", encoding="utf-8") as fh:
303
+ for raw in fh:
304
+ raw = raw.strip()
305
+ if not raw:
306
+ continue
307
+ try:
308
+ obj = json.loads(raw)
309
+ except json.JSONDecodeError:
310
+ continue
311
+ if isinstance(obj, dict):
312
+ records.append(obj)
313
+ except OSError:
314
+ return None
315
+
316
+ blocks: list[str] = []
317
+ for r in records:
318
+ role = r.get("role") or ""
319
+ body = (r.get("content") or "").strip()
320
+ if not body:
321
+ continue
322
+ blocks.append(f"### {role}\n{body}")
323
+ if not blocks:
324
+ return None
325
+
326
+ session_key = path.stem
327
+ archived = session_key.startswith("_archived_")
328
+ last = records[-1]
329
+ last_ts = last.get("timestamp", "")
330
+ last_content = last.get("content", "")
331
+ return Entity(
332
+ id=f"{partner_id}:{session_key}",
333
+ label=f"{session_key} · {partner_name}",
334
+ ts=_iso(last_ts),
335
+ content="\n\n".join(blocks),
336
+ metadata={
337
+ "partner_id": partner_id,
338
+ "partner_name": partner_name,
339
+ "session_key": session_key,
340
+ "archived": archived,
341
+ "message_count": len(blocks),
342
+ },
343
+ fingerprint=_sha1(len(blocks), last_ts, last_content),
344
+ )
345
+
346
+
347
+ def read_partner_entities() -> list[Entity]:
348
+ """One Entity per partner conversation *session* (archived + active),
349
+ tagged with the originating partner. Surfaced under the ``partner``
350
+ surface (UI label "伙伴").
351
+
352
+ Partner runtimes persist conversations as JSONL under
353
+ ``data/partners/<id>/sessions/*.jsonl`` — a store entirely separate from
354
+ the chat-history SQLite DB the ``chat`` adapter reads. This adapter
355
+ bridges that store into the memory pipeline so partner conversations
356
+ consolidate into L2/L3 like every other surface.
357
+
358
+ Admins see the legacy process-wide Partner sessions. Regular users see only
359
+ their own per-user sessions for Partners that are still assigned to them;
360
+ neither side scans another user's relationship-state directory.
361
+ """
362
+ from deeptutor.multi_user.context import get_current_user
363
+ from deeptutor.multi_user.partner_access import assigned_partner_ids
364
+ from deeptutor.multi_user.paths import get_admin_path_service
365
+
366
+ user = get_current_user()
367
+ admin_root = get_admin_path_service().workspace_root.resolve()
368
+ # Read-only adapters are also called directly in maintenance/tests where no
369
+ # CurrentUser ContextVar is installed. Preserve the older path-scope guard
370
+ # instead of treating a non-admin workspace as the implicit local admin.
371
+ if user.is_admin and get_path_service().workspace_root.resolve() != admin_root:
372
+ return []
373
+ partners_root = admin_root / "partners"
374
+ if not partners_root.exists():
375
+ return []
376
+
377
+ allowed = None if user.is_admin else assigned_partner_ids(user.id)
378
+ out: list[Entity] = []
379
+ for partner_dir in sorted(partners_root.iterdir()):
380
+ if not partner_dir.is_dir():
381
+ continue
382
+ partner_id = partner_dir.name
383
+ if allowed is not None and partner_id not in allowed:
384
+ continue
385
+ sessions_dir = (
386
+ partner_dir / "sessions"
387
+ if user.is_admin
388
+ else partner_dir / "users" / user.id / "sessions"
389
+ )
390
+ if not sessions_dir.is_dir():
391
+ continue
392
+ partner_name = _partner_display_name(partner_dir, partner_id)
393
+ for sess_file in sorted(sessions_dir.glob("*.jsonl")):
394
+ entity = _partner_session_entity(sess_file, partner_id, partner_name)
395
+ if entity is not None:
396
+ out.append(entity)
397
+ return out
398
+
399
+
400
+ def read_kb_entities() -> list[Entity]:
401
+ """KB *list* snapshot — one Entity per registered knowledge base.
402
+
403
+ KB queries stay event-driven and live in the trace store; the L2
404
+ consolidator combines both signals.
405
+ """
406
+ kb_root = get_path_service().get_knowledge_bases_root()
407
+ cfg_file = kb_root / "kb_config.json"
408
+ if not cfg_file.exists():
409
+ return []
410
+ try:
411
+ cfg = json.loads(cfg_file.read_text(encoding="utf-8"))
412
+ except (OSError, json.JSONDecodeError):
413
+ return []
414
+ kbs = cfg.get("knowledge_bases") or {}
415
+ if not isinstance(kbs, dict):
416
+ return []
417
+ out: list[Entity] = []
418
+ for kb_name, kb_data in sorted(kbs.items()):
419
+ if not isinstance(kb_name, str):
420
+ continue
421
+ if not isinstance(kb_data, dict):
422
+ kb_data = {}
423
+ description = kb_data.get("description", "") or ""
424
+ versions = kb_data.get("index_versions") or []
425
+ sigs = sorted(v.get("signature", "") for v in versions if isinstance(v, dict))
426
+ earliest_created = min(
427
+ (
428
+ v.get("created_at", "")
429
+ for v in versions
430
+ if isinstance(v, dict) and v.get("created_at")
431
+ ),
432
+ default="",
433
+ )
434
+ content = "\n\n".join(
435
+ part
436
+ for part in (
437
+ f"# {kb_name}",
438
+ description,
439
+ f"Index versions: {len(versions)}",
440
+ )
441
+ if part
442
+ )
443
+ out.append(
444
+ Entity(
445
+ id=kb_name,
446
+ label=kb_name,
447
+ ts=earliest_created,
448
+ content=content,
449
+ metadata={
450
+ "kb_name": kb_name,
451
+ "version_count": len(versions),
452
+ "rag_provider": kb_data.get("rag_provider", ""),
453
+ },
454
+ fingerprint=_sha1(description, sigs),
455
+ )
456
+ )
457
+ return out
458
+
459
+
460
+ def read_chat_entities() -> list[Entity]:
461
+ """One Entity per chat session across the account's workspaces.
462
+
463
+ ``content`` inlines all turns as ``user / assistant`` blocks so L2 sees the
464
+ actual conversation. Sessions from the default workspace keep their bare
465
+ session id; sessions from additional workspaces carry a ``ws:<id>:``
466
+ qualifier plus a ``workspace_id`` in metadata, so account-level Memory
467
+ sees every conversation with provenance and no id collisions (#1799).
468
+ """
469
+ out: list[Entity] = []
470
+ for qualifier, db_path in _iter_chat_dbs():
471
+ if not db_path.exists():
472
+ continue
473
+ try:
474
+ with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
475
+ conn.row_factory = sqlite3.Row
476
+ sessions = conn.execute(
477
+ "SELECT id, title, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
478
+ ).fetchall()
479
+ for sess in sessions:
480
+ sid = sess["id"]
481
+ msgs = conn.execute(
482
+ "SELECT id, role, content, capability, created_at "
483
+ "FROM messages WHERE session_id = ? "
484
+ "ORDER BY created_at ASC, id ASC",
485
+ (sid,),
486
+ ).fetchall()
487
+ blocks: list[str] = []
488
+ for m in msgs:
489
+ role = m["role"]
490
+ body = (m["content"] or "").strip()
491
+ if not body:
492
+ continue
493
+ blocks.append(f"### {role}\n{body}")
494
+ content = "\n\n".join(blocks)
495
+ last_msg_id = msgs[-1]["id"] if msgs else 0
496
+ metadata: dict[str, Any] = {
497
+ "session_id": sid,
498
+ "message_count": len(msgs),
499
+ }
500
+ if qualifier:
501
+ metadata["workspace_id"] = qualifier.removeprefix("ws:").removesuffix(":")
502
+ out.append(
503
+ Entity(
504
+ id=f"{qualifier}{sid}",
505
+ label=sess["title"] or sid,
506
+ ts=_iso(sess["updated_at"]),
507
+ content=content,
508
+ metadata=metadata,
509
+ fingerprint=_sha1(last_msg_id, sess["updated_at"]),
510
+ )
511
+ )
512
+ except sqlite3.Error as exc:
513
+ logger.warning("chat snapshot scan failed for %s: %s", db_path, exc)
514
+ continue
515
+ return out
516
+
517
+
518
+ def read_quiz_entities() -> list[Entity]:
519
+ """One Entity per recorded quiz attempt (notebook_entries row), across
520
+ the account's workspaces with the same qualifier scheme as chat (#1799)."""
521
+ out: list[Entity] = []
522
+ for qualifier, db_path in _iter_chat_dbs():
523
+ if not db_path.exists():
524
+ continue
525
+ try:
526
+ with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
527
+ conn.row_factory = sqlite3.Row
528
+ rows = conn.execute(
529
+ "SELECT id, session_id, turn_id, question_id, question, "
530
+ "question_type, options_json, correct_answer, explanation, "
531
+ "difficulty, user_answer, is_correct, bookmarked, "
532
+ "created_at FROM notebook_entries "
533
+ "ORDER BY created_at DESC"
534
+ ).fetchall()
535
+ for r in rows:
536
+ qid = r["question_id"] or f"row_{r['id']}"
537
+ entity_id = f"{qualifier}{r['session_id']}:{qid}"
538
+ question = (r["question"] or "").strip()
539
+ user_answer = (r["user_answer"] or "").strip()
540
+ correct = (r["correct_answer"] or "").strip()
541
+ explanation = (r["explanation"] or "").strip()
542
+ is_correct = bool(int(r["is_correct"] or 0))
543
+ content = "\n\n".join(
544
+ part
545
+ for part in (
546
+ f"**Question**: {question}" if question else "",
547
+ f"**User answer**: {user_answer}" if user_answer else "",
548
+ f"**Correct answer**: {correct}" if correct else "",
549
+ f"**Explanation**: {explanation}" if explanation else "",
550
+ )
551
+ if part
552
+ )
553
+ metadata: dict[str, Any] = {
554
+ "session_id": r["session_id"],
555
+ "turn_id": r["turn_id"],
556
+ "question_id": qid,
557
+ "question_type": r["question_type"],
558
+ "difficulty": r["difficulty"],
559
+ "is_correct": is_correct,
560
+ "bookmarked": bool(int(r["bookmarked"] or 0)),
561
+ }
562
+ if qualifier:
563
+ metadata["workspace_id"] = qualifier.removeprefix("ws:").removesuffix(":")
564
+ out.append(
565
+ Entity(
566
+ id=entity_id,
567
+ label=question[:80] or qid,
568
+ ts=_iso(r["created_at"]),
569
+ content=content,
570
+ metadata=metadata,
571
+ fingerprint=_sha1(question, user_answer, correct, is_correct),
572
+ )
573
+ )
574
+ except sqlite3.Error as exc:
575
+ logger.warning("quiz snapshot scan failed for %s: %s", db_path, exc)
576
+ continue
577
+ return out
578
+
579
+
580
+ # ── Probes (identity + stamp, without content) ───────────────────────
581
+
582
+
583
+ def probe_chat_entities() -> list[EntityStamp]:
584
+ """Chat stamps without reading a single message body.
585
+
586
+ Deliberately mirrors :func:`read_chat_entities`'s ``id`` / ``label`` /
587
+ ``fingerprint`` expression by expression. In particular the fingerprint is
588
+ ``_sha1(last_msg_id, updated_at)`` where ``last_msg_id`` is the id of the
589
+ last message under ``ORDER BY created_at ASC, id ASC`` — NOT ``MAX(id)``,
590
+ which would diverge whenever ids and timestamps disagree (clock skew, a
591
+ backfilled row). Selecting the first row of the reversed ordering picks the
592
+ same message. Sessions with no messages stamp ``0``, as the full read does.
593
+
594
+ Skipping ``content`` is the whole point: the full read concatenates every
595
+ message of every session, which is the wrong price to pay for a caller that
596
+ only wants to know what the sessions are called.
597
+
598
+ Iterates the same workspace-qualified chat dbs as :func:`read_chat_entities`
599
+ (#1799) — the qualifier expression must stay mirrored exactly.
600
+ """
601
+ out: list[EntityStamp] = []
602
+ for qualifier, db_path in _iter_chat_dbs():
603
+ if not db_path.exists():
604
+ continue
605
+ try:
606
+ with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
607
+ conn.row_factory = sqlite3.Row
608
+ last_msg_id: dict[str, int] = {
609
+ row["session_id"]: row["id"]
610
+ for row in conn.execute(
611
+ "SELECT session_id, id FROM ("
612
+ " SELECT session_id, id, ROW_NUMBER() OVER ("
613
+ " PARTITION BY session_id ORDER BY created_at DESC, id DESC"
614
+ " ) AS rn FROM messages"
615
+ ") WHERE rn = 1"
616
+ )
617
+ }
618
+ for sess in conn.execute(
619
+ "SELECT id, title, updated_at FROM sessions ORDER BY updated_at DESC"
620
+ ):
621
+ sid = sess["id"]
622
+ out.append(
623
+ EntityStamp(
624
+ id=f"{qualifier}{sid}",
625
+ label=sess["title"] or sid,
626
+ fingerprint=_sha1(last_msg_id.get(sid, 0), sess["updated_at"]),
627
+ ts=_iso(sess["updated_at"]),
628
+ )
629
+ )
630
+ except sqlite3.Error as exc:
631
+ logger.warning("chat snapshot probe failed for %s: %s", db_path, exc)
632
+ continue
633
+ return out
634
+
635
+
636
+ # ── Dispatch ─────────────────────────────────────────────────────────
637
+
638
+
639
+ _READERS = {
640
+ "notebook": read_notebook_entities,
641
+ "cowriter": read_cowriter_entities,
642
+ "book": read_book_entities,
643
+ "partner": read_partner_entities,
644
+ "kb": read_kb_entities,
645
+ "chat": read_chat_entities,
646
+ "quiz": read_quiz_entities,
647
+ }
648
+
649
+ # Surfaces with a cheap stamps-only path. A surface belongs here only when
650
+ # skipping content actually saves something: every other adapter reads a
651
+ # bounded amount, so a second code path would be duplication without a payoff
652
+ # — and a second chance to drift from its full reader.
653
+ _PROBES = {
654
+ "chat": probe_chat_entities,
655
+ }
656
+
657
+
658
+ def read_entities(surface: Surface) -> list[Entity]:
659
+ reader = _READERS.get(surface)
660
+ if reader is None:
661
+ return []
662
+ try:
663
+ return reader()
664
+ except Exception as exc:
665
+ logger.warning("snapshot adapter failed surface=%s: %s", surface, exc)
666
+ return []
667
+
668
+
669
+ def read_stamps(surface: Surface) -> list[EntityStamp]:
670
+ """Identity + label + fingerprint + timestamp for every entity on *surface*.
671
+
672
+ Uses the surface's probe when it has one and falls back to projecting the
673
+ full read otherwise, so a surface without a probe is simply slower — never
674
+ unsupported. A probe that raises also falls back rather than reporting an
675
+ empty surface, which the diff would otherwise read as "everything was
676
+ deleted".
677
+ """
678
+ probe = _PROBES.get(surface)
679
+ if probe is not None:
680
+ try:
681
+ return probe()
682
+ except Exception as exc:
683
+ logger.warning("snapshot probe failed surface=%s; full read: %s", surface, exc)
684
+ return [
685
+ EntityStamp(id=e.id, label=e.label, fingerprint=e.fingerprint, ts=e.ts)
686
+ for e in read_entities(surface)
687
+ ]
688
+
689
+
690
+ SUPPORTED_SURFACES: tuple[Surface, ...] = tuple(_READERS.keys()) # type: ignore[arg-type,assignment]