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.
- deeptutor/agents/loop/native_memory.py +24 -0
- deeptutor/api/routers/memory.py +785 -0
- deeptutor/services/memory/__init__.py +42 -0
- deeptutor/services/memory/consolidator/__init__.py +62 -0
- deeptutor/services/memory/consolidator/chunker.py +131 -0
- deeptutor/services/memory/consolidator/guards.py +104 -0
- deeptutor/services/memory/consolidator/line_doc.py +486 -0
- deeptutor/services/memory/consolidator/meta.py +194 -0
- deeptutor/services/memory/consolidator/modes/__init__.py +31 -0
- deeptutor/services/memory/consolidator/modes/_runtime.py +319 -0
- deeptutor/services/memory/consolidator/modes/_shims.py +129 -0
- deeptutor/services/memory/consolidator/modes/audit.py +481 -0
- deeptutor/services/memory/consolidator/modes/dedup.py +228 -0
- deeptutor/services/memory/consolidator/modes/merge.py +227 -0
- deeptutor/services/memory/consolidator/modes/update.py +706 -0
- deeptutor/services/memory/consolidator/parse.py +135 -0
- deeptutor/services/memory/consolidator/prompts/en/_meta.yaml +36 -0
- deeptutor/services/memory/consolidator/prompts/en/audit_l2.yaml +42 -0
- deeptutor/services/memory/consolidator/prompts/en/audit_l3.yaml +38 -0
- deeptutor/services/memory/consolidator/prompts/en/dedup.yaml +37 -0
- deeptutor/services/memory/consolidator/prompts/en/update_l2.yaml +45 -0
- deeptutor/services/memory/consolidator/prompts/en/update_l3.yaml +51 -0
- deeptutor/services/memory/consolidator/prompts/zh/_meta.yaml +35 -0
- deeptutor/services/memory/consolidator/prompts/zh/audit_l2.yaml +40 -0
- deeptutor/services/memory/consolidator/prompts/zh/audit_l3.yaml +34 -0
- deeptutor/services/memory/consolidator/prompts/zh/dedup.yaml +34 -0
- deeptutor/services/memory/consolidator/prompts/zh/update_l2.yaml +40 -0
- deeptutor/services/memory/consolidator/prompts/zh/update_l3.yaml +43 -0
- deeptutor/services/memory/consolidator/references.py +388 -0
- deeptutor/services/memory/consolidator/runs.py +405 -0
- deeptutor/services/memory/document.py +235 -0
- deeptutor/services/memory/ids.py +80 -0
- deeptutor/services/memory/ops.py +149 -0
- deeptutor/services/memory/paths.py +104 -0
- deeptutor/services/memory/prompts/en.yaml +156 -0
- deeptutor/services/memory/prompts/zh.yaml +144 -0
- deeptutor/services/memory/recall.py +237 -0
- deeptutor/services/memory/settings.py +198 -0
- deeptutor/services/memory/snapshot/__init__.py +127 -0
- deeptutor/services/memory/snapshot/adapters.py +690 -0
- deeptutor/services/memory/snapshot/diff.py +67 -0
- deeptutor/services/memory/snapshot/entity.py +92 -0
- deeptutor/services/memory/snapshot/store.py +113 -0
- deeptutor/services/memory/store.py +453 -0
- deeptutor/services/memory/trace.py +153 -0
- deeptutor_cli/memory.py +99 -0
- deeptutor_memory-1.7.0.dist-info/METADATA +6 -0
- deeptutor_memory-1.7.0.dist-info/RECORD +50 -0
- deeptutor_memory-1.7.0.dist-info/WHEEL +5 -0
- 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]
|