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,319 @@
1
+ """Shared runtime helpers used by every mode.
2
+
3
+ These keep the per-mode files focused on algorithm, not plumbing:
4
+
5
+ * Prompt loading (en/zh, cached).
6
+ * SSE event emission (``_emit``).
7
+ * Document load + atomic write.
8
+ * LLM call wrapper with retries + a one-line warning on failure.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from datetime import datetime, timezone
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Any, Awaitable, Callable
18
+
19
+ import yaml
20
+
21
+ from deeptutor.services.file_io import atomic_write_text as _atomic_write
22
+ from deeptutor.services.llm import clean_thinking_tags
23
+ from deeptutor.services.llm import complete as llm_complete
24
+ from deeptutor.services.llm import stream as llm_stream
25
+ from deeptutor.services.memory.document import Document, parse, serialize
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ OnEvent = Callable[[dict[str, Any]], Awaitable[None]]
30
+
31
+ _PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
32
+ _PROMPT_CACHE: dict[tuple[str, str], dict[str, str]] = {}
33
+
34
+
35
+ _META_CACHE: dict[str, dict[str, Any]] = {}
36
+
37
+
38
+ def load_prompt(name: str, language: str) -> dict[str, str]:
39
+ """Load and cache one prompt YAML by name + language (en/zh)."""
40
+ lang = _lang_code(language)
41
+ key = (lang, name)
42
+ cached = _PROMPT_CACHE.get(key)
43
+ if cached is not None:
44
+ return cached
45
+ path = _PROMPTS_DIR / lang / f"{name}.yaml"
46
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
47
+ if not isinstance(data, dict) or "system" not in data or "user" not in data:
48
+ raise RuntimeError(f"prompt {path} missing 'system'/'user' keys")
49
+ _PROMPT_CACHE[key] = {"system": data["system"], "user": data["user"]}
50
+ return _PROMPT_CACHE[key]
51
+
52
+
53
+ def load_focus_meta(language: str) -> dict[str, Any]:
54
+ """Load the per-surface / per-slot focus + sections map for a language."""
55
+ lang = _lang_code(language)
56
+ cached = _META_CACHE.get(lang)
57
+ if cached is not None:
58
+ return cached
59
+ path = _PROMPTS_DIR / lang / "_meta.yaml"
60
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
61
+ _META_CACHE[lang] = data
62
+ return data
63
+
64
+
65
+ def surface_focus(language: str, surface: str) -> tuple[str, list[str]]:
66
+ meta = load_focus_meta(language).get("surfaces", {}).get(surface) or {}
67
+ return meta.get("focus", ""), list(meta.get("sections", []) or [])
68
+
69
+
70
+ def slot_focus(language: str, slot: str) -> tuple[str, list[str]]:
71
+ meta = load_focus_meta(language).get("slots", {}).get(slot) or {}
72
+ return meta.get("focus", ""), list(meta.get("sections", []) or [])
73
+
74
+
75
+ def _lang_code(language: str) -> str:
76
+ return "zh" if (language or "").lower().startswith("zh") else "en"
77
+
78
+
79
+ async def emit(on_event: OnEvent | None, event: dict[str, Any]) -> None:
80
+ if on_event is None:
81
+ return
82
+ try:
83
+ await on_event(event)
84
+ except Exception:
85
+ logger.debug("consolidator: on_event consumer raised", exc_info=True)
86
+
87
+
88
+ async def call_llm(
89
+ *,
90
+ system_prompt: str,
91
+ user_prompt: str,
92
+ temperature: float = 0.2,
93
+ max_tokens: int = 1500,
94
+ on_event: OnEvent | None = None,
95
+ turn: int | None = None,
96
+ chunk_index: int | None = None,
97
+ label: str | None = None,
98
+ ) -> str:
99
+ """Single LLM call. Returns the raw text body; "" on failure.
100
+
101
+ The model/provider is resolved from the *active* LLM config — the
102
+ mode is expected to have installed a scoped config via
103
+ :func:`activate_llm_selection` if the user picked a non-default
104
+ model. Emits ``llm_io_start`` / ``llm_io_end`` events for the
105
+ workbench trace.
106
+ """
107
+ from deeptutor.services.llm import get_llm_config
108
+
109
+ model_label = get_llm_config().model or None
110
+ if on_event is not None:
111
+ await on_event(
112
+ {
113
+ "stage": "llm_io_start",
114
+ "turn": turn,
115
+ "chunk_index": chunk_index,
116
+ "label": label,
117
+ "system_prompt": system_prompt,
118
+ "user_prompt": user_prompt,
119
+ "model": model_label,
120
+ }
121
+ )
122
+ response_parts: list[str] = []
123
+ in_think_block = False
124
+ try:
125
+ async for delta in llm_stream(
126
+ prompt=user_prompt,
127
+ system_prompt=system_prompt,
128
+ temperature=temperature,
129
+ max_tokens=max_tokens,
130
+ stream_coalesce_chars=64,
131
+ stream_coalesce_seconds=0.05,
132
+ ):
133
+ if not delta:
134
+ continue
135
+ response_parts.append(delta)
136
+ visible_delta, in_think_block = _strip_thinking_delta(delta, in_think_block)
137
+ if on_event is not None:
138
+ if visible_delta:
139
+ await on_event(
140
+ {
141
+ "stage": "llm_io_delta",
142
+ "turn": turn,
143
+ "chunk_index": chunk_index,
144
+ "label": label,
145
+ "delta": visible_delta,
146
+ "model": model_label,
147
+ }
148
+ )
149
+ response = clean_thinking_tags("".join(response_parts))
150
+ if on_event is not None:
151
+ await on_event(
152
+ {
153
+ "stage": "llm_io_end",
154
+ "turn": turn,
155
+ "chunk_index": chunk_index,
156
+ "label": label,
157
+ "response": response,
158
+ "error": None,
159
+ "model": model_label,
160
+ }
161
+ )
162
+ return response
163
+ except Exception as exc: # noqa: BLE001
164
+ # Some providers still do not implement streaming. Fall back to the
165
+ # non-streaming path so memory jobs remain usable.
166
+ logger.warning("consolidator streaming LLM call failed; falling back: %s", exc)
167
+ try:
168
+ response = await llm_complete(
169
+ prompt=user_prompt,
170
+ system_prompt=system_prompt,
171
+ temperature=temperature,
172
+ max_tokens=max_tokens,
173
+ )
174
+ response = clean_thinking_tags(response)
175
+ if on_event is not None:
176
+ await on_event(
177
+ {
178
+ "stage": "llm_io_delta",
179
+ "turn": turn,
180
+ "chunk_index": chunk_index,
181
+ "label": label,
182
+ "delta": response,
183
+ "model": model_label,
184
+ }
185
+ )
186
+ await on_event(
187
+ {
188
+ "stage": "llm_io_end",
189
+ "turn": turn,
190
+ "chunk_index": chunk_index,
191
+ "label": label,
192
+ "response": response,
193
+ "error": None,
194
+ "model": model_label,
195
+ }
196
+ )
197
+ return response
198
+ except Exception as fallback_exc: # noqa: BLE001
199
+ logger.warning("consolidator LLM call failed: %s", fallback_exc)
200
+ if on_event is not None:
201
+ await on_event(
202
+ {
203
+ "stage": "llm_io_end",
204
+ "turn": turn,
205
+ "chunk_index": chunk_index,
206
+ "label": label,
207
+ "response": "",
208
+ "error": str(fallback_exc),
209
+ "model": model_label,
210
+ }
211
+ )
212
+ return ""
213
+
214
+
215
+ def load_doc(path: Path, *, default_title: str) -> Document:
216
+ if not path.exists():
217
+ return Document(title=default_title)
218
+ return parse(path.read_text(encoding="utf-8"))
219
+
220
+
221
+ async def write_doc_atomic(path: Path, doc: Document) -> None:
222
+ path.parent.mkdir(parents=True, exist_ok=True)
223
+ text = serialize(doc)
224
+ await asyncio.to_thread(_atomic_write, path, text)
225
+
226
+
227
+ async def write_doc_checkpoint(
228
+ path: Path,
229
+ doc: Document,
230
+ *,
231
+ layer: str,
232
+ key: str,
233
+ on_event: OnEvent | None = None,
234
+ turn: int | None = None,
235
+ label: str | None = None,
236
+ action: str = "write",
237
+ ) -> int:
238
+ """Write a doc now and register one run-scoped undo checkpoint."""
239
+ existed = path.exists()
240
+ previous = path.read_text(encoding="utf-8") if existed else ""
241
+ await write_doc_atomic(path, doc)
242
+ from deeptutor.services.memory.consolidator.runs import push_undo_checkpoint
243
+
244
+ undo_depth = push_undo_checkpoint(
245
+ layer=layer,
246
+ key=key,
247
+ path=path,
248
+ existed=existed,
249
+ previous_content=previous,
250
+ action=action,
251
+ turn=turn,
252
+ label=label,
253
+ )
254
+ await emit(
255
+ on_event,
256
+ {
257
+ "stage": "doc_updated",
258
+ "layer": layer,
259
+ "key": key,
260
+ "turn": turn,
261
+ "label": label,
262
+ "action": action,
263
+ "undo_depth": undo_depth,
264
+ },
265
+ )
266
+ return undo_depth
267
+
268
+
269
+ def _strip_thinking_delta(delta: str, in_block: bool) -> tuple[str, bool]:
270
+ """Remove streamed <think> blocks before they reach the workbench UI."""
271
+ out: list[str] = []
272
+ text = delta
273
+ while text:
274
+ lower = text.lower()
275
+ if in_block:
276
+ close_at = lower.find("</think>")
277
+ close_len = len("</think>")
278
+ alt_close = lower.find("</thinking>")
279
+ if alt_close != -1 and (close_at == -1 or alt_close < close_at):
280
+ close_at = alt_close
281
+ close_len = len("</thinking>")
282
+ if close_at == -1:
283
+ return "".join(out), True
284
+ text = text[close_at + close_len :]
285
+ in_block = False
286
+ continue
287
+
288
+ open_at = lower.find("<think>")
289
+ open_len = len("<think>")
290
+ alt_open = lower.find("<thinking>")
291
+ if alt_open != -1 and (open_at == -1 or alt_open < open_at):
292
+ open_at = alt_open
293
+ open_len = len("<thinking>")
294
+ if open_at == -1:
295
+ out.append(text)
296
+ break
297
+ out.append(text[:open_at])
298
+ text = text[open_at + open_len :]
299
+ in_block = True
300
+ return "".join(out), in_block
301
+
302
+
303
+ def today_iso() -> str:
304
+ return datetime.now(tz=timezone.utc).date().isoformat()
305
+
306
+
307
+ __all__ = [
308
+ "OnEvent",
309
+ "call_llm",
310
+ "emit",
311
+ "load_doc",
312
+ "load_focus_meta",
313
+ "load_prompt",
314
+ "slot_focus",
315
+ "surface_focus",
316
+ "today_iso",
317
+ "write_doc_atomic",
318
+ "write_doc_checkpoint",
319
+ ]
@@ -0,0 +1,129 @@
1
+ """Legacy public-API shims for :mod:`deeptutor.services.memory.store`.
2
+
3
+ The pre-redesign module exposed ``consolidate_l2`` and ``consolidate_l3``
4
+ returning a :class:`ConsolidateResult`. The store / API router still
5
+ call these names. We keep them as thin wrappers around :func:`run_update`
6
+ so the surface that the router and the test fixtures import doesn't
7
+ change while the implementation switches to chunk-based update + dedup.
8
+
9
+ ``apply_ops`` semantics
10
+ -----------------------
11
+ The pre-redesign ``apply_ops=False`` was a "preview the ops, do not
12
+ write" toggle for the workbench. The new chunk-based update writes
13
+ incrementally and there's no clean way to roll it back atomically. So:
14
+
15
+ * ``apply_ops=True`` (default) → :func:`run_update` runs end-to-end.
16
+ * ``apply_ops=False`` → we still run the update so the workbench can
17
+ observe what would be added via the SSE event stream, but we capture
18
+ the new entry ids and immediately delete them post-write. This keeps
19
+ the preview semantic functionally close to the old behaviour without
20
+ introducing a parallel preview pipeline. Auto-dedup is suppressed
21
+ in preview mode to avoid touching pre-existing entries.
22
+
23
+ Callers that want a true preview (no disk writes at all) should move
24
+ to the new ``run_update`` API directly with a custom on_event consumer.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from typing import Any, Awaitable, Callable
31
+
32
+ from deeptutor.services.memory import paths
33
+ from deeptutor.services.memory.consolidator.modes.update import (
34
+ UpdateResult,
35
+ run_update,
36
+ )
37
+ from deeptutor.services.memory.document import parse, serialize
38
+ from deeptutor.services.memory.ops import ApplyReport, Op
39
+ from deeptutor.services.memory.paths import L3Slot, Surface
40
+
41
+ OnEvent = Callable[[dict[str, Any]], Awaitable[None]]
42
+
43
+
44
+ @dataclass
45
+ class ConsolidateResult:
46
+ report: ApplyReport
47
+ backlog_count: int
48
+ proposed_ops: list[Op] = field(default_factory=list)
49
+
50
+
51
+ async def consolidate_l2(
52
+ surface: Surface,
53
+ *,
54
+ language: str = "en",
55
+ user_label: str = "anonymous",
56
+ on_event: OnEvent | None = None,
57
+ apply_ops: bool = True,
58
+ ) -> ConsolidateResult:
59
+ result = await run_update(
60
+ "L2",
61
+ surface,
62
+ language=language,
63
+ user_label=user_label,
64
+ on_event=on_event,
65
+ )
66
+ if not apply_ops:
67
+ _rollback_new_entries("L2", surface, result.new_entry_ids)
68
+ return _to_consolidate_result(result)
69
+
70
+
71
+ async def consolidate_l3(
72
+ slot: L3Slot,
73
+ *,
74
+ language: str = "en",
75
+ user_label: str = "anonymous",
76
+ on_event: OnEvent | None = None,
77
+ apply_ops: bool = True,
78
+ ) -> ConsolidateResult:
79
+ if slot == "preferences":
80
+ raise ValueError("preferences.md is not auto-consolidated")
81
+ result = await run_update(
82
+ "L3",
83
+ slot,
84
+ language=language,
85
+ user_label=user_label,
86
+ on_event=on_event,
87
+ )
88
+ if not apply_ops:
89
+ _rollback_new_entries("L3", slot, result.new_entry_ids)
90
+ return _to_consolidate_result(result)
91
+
92
+
93
+ def _to_consolidate_result(result: UpdateResult) -> ConsolidateResult:
94
+ reason = (
95
+ "no new input"
96
+ if result.no_new_input
97
+ else f"applied via chunk-update ({result.facts_added} added)"
98
+ )
99
+ return ConsolidateResult(
100
+ report=ApplyReport(
101
+ accepted=True,
102
+ reason=reason,
103
+ results=[], # the new pipeline does not emit per-op OpResult objects
104
+ ),
105
+ backlog_count=result.chunks_processed,
106
+ proposed_ops=[], # the chunk-based mode writes directly; preview is via SSE
107
+ )
108
+
109
+
110
+ def _rollback_new_entries(layer: str, key: str, ids: list[str]) -> None:
111
+ """Remove entries by id (used by ``apply_ops=False`` preview mode).
112
+
113
+ This is best-effort: if the doc was edited externally between the
114
+ update and rollback, ids not found are silently skipped.
115
+ """
116
+ if not ids:
117
+ return
118
+ path = paths.l2_file(key) if layer == "L2" else paths.l3_file(key) # type: ignore[arg-type]
119
+ if not path.exists():
120
+ return
121
+ doc = parse(path.read_text(encoding="utf-8"))
122
+ drop = set(ids)
123
+ for _name, entries in doc.sections:
124
+ entries[:] = [e for e in entries if e.id not in drop]
125
+ doc.sections[:] = [(n, e) for n, e in doc.sections if e]
126
+ path.write_text(serialize(doc), encoding="utf-8")
127
+
128
+
129
+ __all__ = ["ConsolidateResult", "consolidate_l2", "consolidate_l3"]