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,388 @@
1
+ """Reference validation + raw-trace lookup used by update / audit.
2
+
3
+ Two distinct concerns share this module because they both center on
4
+ "the set of refs the LLM is allowed to cite":
5
+
6
+ * **Update mode** — refs must point at entities that appear in the
7
+ current chunk's source range. :func:`refs_in_chunk` returns the
8
+ allowed pool; :func:`validate_fact_refs` filters extracted facts.
9
+ * **Audit mode** — every entry on a md chunk gets its raw-trace
10
+ content spliced in as evidence. :func:`annotate_line_with_evidence`
11
+ formats one entry + sources into a block fed to the LLM.
12
+
13
+ No I/O happens beyond reading from the same in-memory entity / L2 doc
14
+ maps the caller has already loaded — the modes are responsible for
15
+ hydrating those once per run.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ import logging
22
+ import re
23
+ from typing import Iterable
24
+
25
+ from deeptutor.services.memory.document import Document, Entry
26
+ from deeptutor.services.memory.ids import is_entry_id, is_valid_ref
27
+ from deeptutor.services.memory.snapshot.entity import Entity
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # ── Update-mode helpers ─────────────────────────────────────────────────
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ExtractedFact:
37
+ """One fact pulled by the LLM during update mode."""
38
+
39
+ text: str
40
+ refs: list[str]
41
+ section: str = ""
42
+
43
+
44
+ def refs_in_chunk_l2(
45
+ entities: Iterable[Entity],
46
+ *,
47
+ surface: str,
48
+ chunk_text: str,
49
+ ) -> set[str]:
50
+ """Set of allowed refs (``surface:entity_id``) for this chunk.
51
+
52
+ An entity is considered "in this chunk" if its rendered marker
53
+ appears in ``chunk_text``. The marker is the same one written by
54
+ :func:`render_traces_for_concat`.
55
+ """
56
+ allowed: set[str] = set()
57
+ for ent in entities:
58
+ marker = _entity_marker(surface, ent.id)
59
+ if marker in chunk_text:
60
+ allowed.add(f"{surface}:{ent.id}")
61
+ return allowed
62
+
63
+
64
+ def refs_in_span_l2(
65
+ entities: Iterable[Entity],
66
+ *,
67
+ surface: str,
68
+ full_text: str,
69
+ start: int,
70
+ end: int,
71
+ ) -> set[str]:
72
+ """Allowed L2 refs for a chunk span, including long split entities."""
73
+ markers: list[tuple[int, str]] = []
74
+ for ent in entities:
75
+ marker = _entity_marker(surface, ent.id)
76
+ pos = full_text.find(marker)
77
+ if pos != -1:
78
+ markers.append((pos, f"{surface}:{ent.id}"))
79
+ return _refs_overlapping_span(markers, text_len=len(full_text), start=start, end=end)
80
+
81
+
82
+ _L3_SURFACE_HEADER_RE = re.compile(r"^### surface: ([a-z][a-z0-9_-]*)", re.MULTILINE)
83
+
84
+
85
+ def refs_in_chunk_l3(
86
+ chunk_text: str,
87
+ *,
88
+ entries_by_surface: dict[str, list[Entry]],
89
+ ) -> set[str]:
90
+ """L3 refs are *surface names* — pointers to the L2 md the synthesis
91
+ drew from. The render emits one ``### surface: <name>`` header per
92
+ surface block; we collect every header visible in the chunk text.
93
+ """
94
+ del entries_by_surface # surface list is derived from the rendered text
95
+ return {m.group(1) for m in _L3_SURFACE_HEADER_RE.finditer(chunk_text)}
96
+
97
+
98
+ def refs_in_span_l3(
99
+ *,
100
+ entries_by_surface: dict[str, list[Entry]],
101
+ full_text: str,
102
+ start: int,
103
+ end: int,
104
+ ) -> set[str]:
105
+ """Surface refs whose render block intersects ``[start, end)``.
106
+
107
+ A surface block runs from its ``### surface:`` header to the next
108
+ one (or the end of the doc). A chunk may legitimately start
109
+ mid-block thanks to the overlap window, so we keep any surface
110
+ whose block extends into the chunk window.
111
+ """
112
+ del entries_by_surface
113
+ headers = list(_L3_SURFACE_HEADER_RE.finditer(full_text))
114
+ if not headers:
115
+ return set()
116
+ allowed: set[str] = set()
117
+ for idx, match in enumerate(headers):
118
+ block_start = match.start()
119
+ block_end = headers[idx + 1].start() if idx + 1 < len(headers) else len(full_text)
120
+ if block_start < end and block_end > start:
121
+ allowed.add(match.group(1))
122
+ return allowed
123
+
124
+
125
+ def validate_fact_refs(
126
+ fact: ExtractedFact,
127
+ *,
128
+ allowed: set[str],
129
+ enforce_required: bool,
130
+ drop_invalid: bool,
131
+ ) -> tuple[list[str], str | None]:
132
+ """Filter / reject a fact's refs.
133
+
134
+ Returns ``(kept_refs, reject_reason)``. ``reject_reason`` is ``None``
135
+ when the fact survives. Behavior:
136
+
137
+ * ``enforce_required=True`` + no refs → reject.
138
+ * ``drop_invalid=True``: refs outside ``allowed`` are removed;
139
+ if the result is empty under ``enforce_required`` → reject.
140
+ * ``drop_invalid=False``: any out-of-pool ref → reject the fact.
141
+ """
142
+ if not fact.refs:
143
+ if enforce_required:
144
+ return [], "missing refs"
145
+ return [], None
146
+
147
+ if drop_invalid:
148
+ kept = [
149
+ normalized
150
+ for ref in fact.refs
151
+ if (normalized := _normalize_allowed_ref(ref, allowed)) is not None
152
+ ]
153
+ if not kept and enforce_required:
154
+ return [], "no surviving refs in chunk pool"
155
+ return _dedupe(kept), None
156
+
157
+ for ref in fact.refs:
158
+ normalized = _normalize_allowed_ref(ref, allowed)
159
+ if normalized is None and not is_valid_ref(ref):
160
+ return [], f"malformed ref {ref!r}"
161
+ if normalized is None:
162
+ return [], f"out-of-pool ref {ref!r}"
163
+ return _dedupe([_normalize_allowed_ref(ref, allowed) or ref for ref in fact.refs]), None
164
+
165
+
166
+ # ── Rendering: traces → concatenated text ───────────────────────────────
167
+
168
+
169
+ _ENTITY_HEADER_FMT = "=== {marker} ==="
170
+ # ``_L2_ENTRY_HEADER_FMT`` and ``_l2_entry_marker`` are no longer used:
171
+ # the L3 input is text-only, so no L2-entry markers are emitted. They
172
+ # would have been ``"=== @l2 m_xxx ==="``.
173
+
174
+
175
+ def render_traces_for_concat(entities: list[Entity], *, surface: str) -> str:
176
+ """Concatenate a list of L2 raw-trace entities into one timeline string.
177
+
178
+ The chunk-pool detector relies on the marker line being unique per
179
+ entity, so it doubles as both a human delimiter and a machine anchor.
180
+ """
181
+ blocks: list[str] = []
182
+ for ent in entities:
183
+ header = _ENTITY_HEADER_FMT.format(marker=_entity_marker(surface, ent.id))
184
+ meta_str = _format_meta(ent)
185
+ body = (ent.content or "").strip()
186
+ block = "\n".join(
187
+ x
188
+ for x in (
189
+ header,
190
+ f"ref: {surface}:{ent.id}",
191
+ f"label: {ent.label}",
192
+ f"ts: {ent.ts or '?'}",
193
+ f"meta: {meta_str}" if meta_str else None,
194
+ "",
195
+ body,
196
+ )
197
+ if x is not None
198
+ )
199
+ blocks.append(block)
200
+ return "\n\n".join(blocks)
201
+
202
+
203
+ def render_l2_entries_for_concat(
204
+ entries_by_surface: dict[str, list[Entry]],
205
+ ) -> str:
206
+ """Concatenate L2 entries (per surface) into one text for L3 chunking.
207
+
208
+ L3 is a *text-only* synthesis layer: the user has explicitly said the
209
+ LLM should not see — or copy — L2 footnote provenance. So this render
210
+ emits **only** the surface header + each entry's prose. No entry-id
211
+ markers, no ``ref:`` / ``refs:`` lines. As a result the chunk-pool
212
+ detector for L3 always returns an empty set (see
213
+ :func:`refs_in_span_l3`); L3 facts have no refs.
214
+ """
215
+ blocks: list[str] = []
216
+ for surface, entries in entries_by_surface.items():
217
+ if not entries:
218
+ continue
219
+ blocks.append(f"### surface: {surface}")
220
+ for entry in entries:
221
+ # Section is kept (it shapes synthesis) but emitted as a
222
+ # parenthetical tag rather than a structured field, so the
223
+ # model treats it as context, not a citation hook.
224
+ tag = f"[{entry.section}] " if entry.section else ""
225
+ blocks.append(f"- {tag}{entry.text}")
226
+ return "\n\n".join(blocks)
227
+
228
+
229
+ # ── Audit-mode helpers ──────────────────────────────────────────────────
230
+
231
+
232
+ def annotate_l2_line_with_evidence(
233
+ line_number: int,
234
+ entry: Entry,
235
+ *,
236
+ surface: str,
237
+ entity_lookup: dict[str, Entity],
238
+ ) -> str:
239
+ """Render one L2 bullet + every raw trace it cites, full content.
240
+
241
+ Output is intentionally human-readable so the model can reason
242
+ about correspondence (md statement ↔ original wording). No
243
+ truncation, ever — that is the point of audit mode.
244
+ """
245
+ lines: list[str] = [
246
+ f"line {line_number}: {entry.text} [^{entry.id}]",
247
+ f" section: {entry.section}",
248
+ ]
249
+ if not entry.refs:
250
+ lines.append(" sources: (none)")
251
+ return "\n".join(lines)
252
+ lines.append(f" sources ({len(entry.refs)}):")
253
+ for ref in entry.refs:
254
+ if ":" not in ref:
255
+ lines.append(f" └ {ref}: (malformed)")
256
+ continue
257
+ _, ent_id = ref.split(":", 1)
258
+ ent = entity_lookup.get(ent_id)
259
+ if ent is None:
260
+ lines.append(f" └ {ref}: (entity not found in current workspace)")
261
+ continue
262
+ body = (ent.content or "").rstrip()
263
+ lines.append(f" └ {ref} (ts={ent.ts or '?'}, label={ent.label!r}):")
264
+ for src_line in body.splitlines():
265
+ lines.append(f" {src_line}")
266
+ return "\n".join(lines)
267
+
268
+
269
+ def annotate_l3_line_with_evidence(
270
+ line_number: int,
271
+ entry: Entry,
272
+ *,
273
+ l2_entry_lookup: dict[str, Entry],
274
+ ) -> str:
275
+ """Render one L3 bullet + every L2 entry it cites, full text + refs."""
276
+ lines: list[str] = [
277
+ f"line {line_number}: {entry.text} [^{entry.id}]",
278
+ f" section: {entry.section}",
279
+ ]
280
+ if not entry.refs:
281
+ lines.append(" sources: (none)")
282
+ return "\n".join(lines)
283
+ lines.append(f" sources ({len(entry.refs)}):")
284
+ for ref in entry.refs:
285
+ if not is_entry_id(ref):
286
+ lines.append(f" └ {ref}: (malformed L2 id)")
287
+ continue
288
+ src = l2_entry_lookup.get(ref)
289
+ if src is None:
290
+ lines.append(f" └ {ref}: (L2 entry not found)")
291
+ continue
292
+ lines.append(f" └ {ref} (section={src.section!r}):")
293
+ lines.append(f" {src.text}")
294
+ if src.refs:
295
+ lines.append(f" upstream refs: {', '.join(src.refs)}")
296
+ return "\n".join(lines)
297
+
298
+
299
+ # ── Internals ───────────────────────────────────────────────────────────
300
+
301
+
302
+ def _entity_marker(surface: str, entity_id: str) -> str:
303
+ return f"@entity {surface}:{entity_id}"
304
+
305
+
306
+ def _format_meta(ent: Entity) -> str:
307
+ if not ent.metadata:
308
+ return ""
309
+ bits = [f"{k}={v}" for k, v in ent.metadata.items() if v not in (None, "", [], {})]
310
+ return " ".join(bits)
311
+
312
+
313
+ def _normalize_allowed_ref(ref: str, allowed: set[str]) -> str | None:
314
+ """Return the canonical allowed ref when the model added label text.
315
+
316
+ LLMs often copy a rendered source as ``<label>:chat:<id>`` even though
317
+ the prompt asks for ``chat:<id>``. Treat that as a recoverable citation
318
+ as long as it unambiguously ends with an allowed chunk-local ref.
319
+ """
320
+ candidate = _strip_ref_wrappers(str(ref).strip())
321
+ if candidate in allowed and is_valid_ref(candidate):
322
+ return candidate
323
+ for allowed_ref in sorted(allowed, key=len, reverse=True):
324
+ if not is_valid_ref(allowed_ref):
325
+ continue
326
+ if _has_ref_suffix(candidate, allowed_ref):
327
+ return allowed_ref
328
+ return None
329
+
330
+
331
+ def _strip_ref_wrappers(ref: str) -> str:
332
+ return ref.strip().strip("`[](){}<>").lstrip("^").strip()
333
+
334
+
335
+ def _has_ref_suffix(candidate: str, allowed_ref: str) -> bool:
336
+ if candidate == allowed_ref:
337
+ return True
338
+ if not candidate.endswith(allowed_ref):
339
+ return False
340
+ prefix = candidate[: -len(allowed_ref)]
341
+ if not prefix:
342
+ return True
343
+ # Common hallucinated forms: "Title:chat:id", "Title?chat:id",
344
+ # "[^m_id]". Do not accept alnum/underscore adjacency.
345
+ return prefix[-1] in {":", ":", "?", "?", "#", "/", "|", " ", "\t", "\n", "^"}
346
+
347
+
348
+ def _dedupe(refs: list[str]) -> list[str]:
349
+ seen: set[str] = set()
350
+ out: list[str] = []
351
+ for ref in refs:
352
+ if ref in seen:
353
+ continue
354
+ seen.add(ref)
355
+ out.append(ref)
356
+ return out
357
+
358
+
359
+ def _refs_overlapping_span(
360
+ markers: list[tuple[int, str]], *, text_len: int, start: int, end: int
361
+ ) -> set[str]:
362
+ allowed: set[str] = set()
363
+ ordered = sorted(markers, key=lambda item: item[0])
364
+ for idx, (block_start, ref) in enumerate(ordered):
365
+ block_end = ordered[idx + 1][0] if idx + 1 < len(ordered) else text_len
366
+ if block_start < end and block_end > start:
367
+ allowed.add(ref)
368
+ return allowed
369
+
370
+
371
+ def collect_l2_entries(docs: dict[str, Document]) -> dict[str, list[Entry]]:
372
+ """Helper for L3 — pull all entries from a {surface: Document} map."""
373
+ return {surface: doc.all_entries() for surface, doc in docs.items()}
374
+
375
+
376
+ __all__ = [
377
+ "ExtractedFact",
378
+ "annotate_l2_line_with_evidence",
379
+ "annotate_l3_line_with_evidence",
380
+ "collect_l2_entries",
381
+ "refs_in_chunk_l2",
382
+ "refs_in_chunk_l3",
383
+ "refs_in_span_l2",
384
+ "refs_in_span_l3",
385
+ "render_l2_entries_for_concat",
386
+ "render_traces_for_concat",
387
+ "validate_fact_refs",
388
+ ]