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,486 @@
|
|
|
1
|
+
"""Line-numbered view of a memory document + line-level edit ops.
|
|
2
|
+
|
|
3
|
+
The audit / dedup modes ask the LLM to operate on a memory document
|
|
4
|
+
the same way an IDE assistant operates on source code: it sees
|
|
5
|
+
numbered lines and emits structured edits referencing those numbers.
|
|
6
|
+
|
|
7
|
+
To keep the document's invariants intact, the LLM only ever sees a
|
|
8
|
+
**sanitized** view — section headers (``## name``) and entry bullets
|
|
9
|
+
(``- text [^m_xxx]``). The footnote block is hidden and rebuilt by
|
|
10
|
+
:func:`apply_edits` from the surviving entries' refs.
|
|
11
|
+
|
|
12
|
+
Editing model
|
|
13
|
+
-------------
|
|
14
|
+
Three op types: ``ReplaceLineOp``, ``DeleteLinesOp``, ``InsertAfterOp``.
|
|
15
|
+
Apply in **descending line order** so earlier lines never shift under
|
|
16
|
+
later edits. Each op carries a free-form ``reason`` for observability;
|
|
17
|
+
audit/dedup prompts require it. Refs are mandatory on replace / insert
|
|
18
|
+
of an entry (validated by the runtime).
|
|
19
|
+
|
|
20
|
+
Public API
|
|
21
|
+
----------
|
|
22
|
+
* :func:`render_view` — turn a :class:`Document` into a list of numbered
|
|
23
|
+
:class:`Line` rows + lookup tables.
|
|
24
|
+
* :func:`apply_edits` — apply a batch of edits to a document; pure
|
|
25
|
+
function (returns a new document) so callers can preview without
|
|
26
|
+
mutating shared state.
|
|
27
|
+
* :func:`parse_edits_payload` — tolerant JSON → typed edit list parser.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import re
|
|
36
|
+
from typing import Iterable, Literal, Union
|
|
37
|
+
|
|
38
|
+
from deeptutor.services.memory.document import Document, Entry
|
|
39
|
+
from deeptutor.services.memory.ids import is_entry_id, new_entry_id
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
LineKind = Literal["title", "blank", "section", "bullet"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Line:
|
|
48
|
+
number: int # 1-based, matches what the LLM sees
|
|
49
|
+
kind: LineKind
|
|
50
|
+
text: str # rendered text (no leading "n: ")
|
|
51
|
+
entry_id: str | None = None # for bullet lines, the m_xxx id
|
|
52
|
+
section: str | None = None # for bullet lines, owning section name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class LineView:
|
|
57
|
+
"""Snapshot of the sanitized document seen by audit / dedup LLMs."""
|
|
58
|
+
|
|
59
|
+
lines: list[Line]
|
|
60
|
+
entry_by_id: dict[str, Entry]
|
|
61
|
+
entries_in_order: list[Entry]
|
|
62
|
+
|
|
63
|
+
def render(self, *, with_numbers: bool = True) -> str:
|
|
64
|
+
if with_numbers:
|
|
65
|
+
width = max(2, len(str(len(self.lines))))
|
|
66
|
+
return "\n".join(f"{line.number:>{width}}: {line.text}" for line in self.lines)
|
|
67
|
+
return "\n".join(line.text for line in self.lines)
|
|
68
|
+
|
|
69
|
+
def line(self, number: int) -> Line | None:
|
|
70
|
+
return self.lines[number - 1] if 1 <= number <= len(self.lines) else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Edit ops ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class ReplaceLineOp:
|
|
78
|
+
line: int
|
|
79
|
+
new_text: str
|
|
80
|
+
refs: list[str]
|
|
81
|
+
reason: str = ""
|
|
82
|
+
op: Literal["replace"] = "replace"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class DeleteLinesOp:
|
|
87
|
+
line_start: int
|
|
88
|
+
line_end: int # inclusive
|
|
89
|
+
reason: str = ""
|
|
90
|
+
op: Literal["delete"] = "delete"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class InsertAfterOp:
|
|
95
|
+
after_line: int
|
|
96
|
+
text: str
|
|
97
|
+
refs: list[str]
|
|
98
|
+
# ``section`` is optional: when None, the engine uses the section
|
|
99
|
+
# containing ``after_line``. If after_line is 0 (top-of-doc) or
|
|
100
|
+
# points at a title/blank, section MUST be provided.
|
|
101
|
+
section: str | None = None
|
|
102
|
+
reason: str = ""
|
|
103
|
+
op: Literal["insert"] = "insert"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
Edit = Union[ReplaceLineOp, DeleteLinesOp, InsertAfterOp]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class EditResult:
|
|
111
|
+
op: Edit
|
|
112
|
+
status: Literal["applied", "rejected"]
|
|
113
|
+
detail: str = ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class EditReport:
|
|
118
|
+
applied: list[EditResult] = field(default_factory=list)
|
|
119
|
+
rejected: list[EditResult] = field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def all_results(self) -> list[EditResult]:
|
|
123
|
+
return self.applied + self.rejected
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── Render: Document → LineView ─────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_view(doc: Document) -> LineView:
|
|
130
|
+
"""Produce the numbered view the LLM operates on."""
|
|
131
|
+
lines: list[Line] = []
|
|
132
|
+
entries_in_order: list[Entry] = []
|
|
133
|
+
entry_by_id: dict[str, Entry] = {}
|
|
134
|
+
|
|
135
|
+
if doc.title:
|
|
136
|
+
lines.append(Line(number=len(lines) + 1, kind="title", text=f"# {doc.title}"))
|
|
137
|
+
lines.append(Line(number=len(lines) + 1, kind="blank", text=""))
|
|
138
|
+
|
|
139
|
+
for section_name, entries in doc.sections:
|
|
140
|
+
if not entries:
|
|
141
|
+
continue
|
|
142
|
+
lines.append(Line(number=len(lines) + 1, kind="section", text=f"## {section_name}"))
|
|
143
|
+
for entry in entries:
|
|
144
|
+
lines.append(
|
|
145
|
+
Line(
|
|
146
|
+
number=len(lines) + 1,
|
|
147
|
+
kind="bullet",
|
|
148
|
+
text=f"- {entry.text} [^{entry.id}]",
|
|
149
|
+
entry_id=entry.id,
|
|
150
|
+
section=section_name,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
entries_in_order.append(entry)
|
|
154
|
+
entry_by_id[entry.id] = entry
|
|
155
|
+
lines.append(Line(number=len(lines) + 1, kind="blank", text=""))
|
|
156
|
+
|
|
157
|
+
# Strip the trailing blank so the rendered view doesn't end with an
|
|
158
|
+
# empty line — keeps line counts predictable.
|
|
159
|
+
while lines and lines[-1].kind == "blank":
|
|
160
|
+
lines.pop()
|
|
161
|
+
|
|
162
|
+
return LineView(
|
|
163
|
+
lines=lines,
|
|
164
|
+
entry_by_id=entry_by_id,
|
|
165
|
+
entries_in_order=entries_in_order,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Apply edits ─────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def apply_edits(
|
|
173
|
+
doc: Document, edits: Iterable[Edit], *, view: LineView | None = None
|
|
174
|
+
) -> tuple[Document, EditReport]:
|
|
175
|
+
"""Apply a batch of edits, in reverse line order, to a fresh copy.
|
|
176
|
+
|
|
177
|
+
Returns ``(new_doc, report)``. ``new_doc`` is always returned; if
|
|
178
|
+
any edit was rejected, those are captured in ``report.rejected`` and
|
|
179
|
+
the rest are still applied. The caller decides what to do with a
|
|
180
|
+
partial-success batch (audit/dedup just write the partial result).
|
|
181
|
+
|
|
182
|
+
Reverse order avoids line-number drift: removing line 5 does not
|
|
183
|
+
affect the meaning of "line 3" since 3 < 5 and we process 5 first.
|
|
184
|
+
"""
|
|
185
|
+
# Chunked callers retain the view from which all model prompts were built.
|
|
186
|
+
# Its entry IDs keep later edits anchored after earlier lines are deleted.
|
|
187
|
+
view = view if view is not None else render_view(doc)
|
|
188
|
+
edit_list = _sort_reverse(list(edits))
|
|
189
|
+
report = EditReport()
|
|
190
|
+
|
|
191
|
+
# Work on a deep-ish copy: the entry list per section is fresh, but
|
|
192
|
+
# Entry instances themselves are reused (and possibly mutated in
|
|
193
|
+
# place by replace).
|
|
194
|
+
new_doc = Document(
|
|
195
|
+
title=doc.title,
|
|
196
|
+
sections=[(name, list(entries)) for name, entries in doc.sections],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
for edit in edit_list:
|
|
200
|
+
try:
|
|
201
|
+
detail = _apply_one(edit, new_doc, view)
|
|
202
|
+
report.applied.append(EditResult(op=edit, status="applied", detail=detail))
|
|
203
|
+
except _Reject as exc:
|
|
204
|
+
logger.warning("line-edit rejected: %s — %s", _short(edit), exc)
|
|
205
|
+
report.rejected.append(EditResult(op=edit, status="rejected", detail=str(exc)))
|
|
206
|
+
|
|
207
|
+
_drop_empty_sections(new_doc)
|
|
208
|
+
return new_doc, report
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _Reject(Exception):
|
|
212
|
+
"""Internal sentinel — signals one edit is unsafe; siblings still apply."""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _apply_one(edit: Edit, doc: Document, view: LineView) -> str:
|
|
216
|
+
if isinstance(edit, ReplaceLineOp):
|
|
217
|
+
return _apply_replace(edit, doc, view)
|
|
218
|
+
if isinstance(edit, DeleteLinesOp):
|
|
219
|
+
return _apply_delete(edit, doc, view)
|
|
220
|
+
if isinstance(edit, InsertAfterOp):
|
|
221
|
+
return _apply_insert(edit, doc, view)
|
|
222
|
+
raise _Reject(f"unknown edit type {type(edit).__name__}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _apply_replace(edit: ReplaceLineOp, doc: Document, view: LineView) -> str:
|
|
226
|
+
line = view.line(edit.line)
|
|
227
|
+
if line is None:
|
|
228
|
+
raise _Reject(f"line {edit.line} out of range")
|
|
229
|
+
if line.kind != "bullet" or not line.entry_id:
|
|
230
|
+
raise _Reject(f"line {edit.line} is not an editable entry")
|
|
231
|
+
if not edit.new_text.strip():
|
|
232
|
+
raise _Reject("new_text empty")
|
|
233
|
+
if not edit.refs:
|
|
234
|
+
raise _Reject("replace requires non-empty refs")
|
|
235
|
+
|
|
236
|
+
entry = _entry_in_doc(doc, line.entry_id)
|
|
237
|
+
if entry is None:
|
|
238
|
+
raise _Reject(f"entry {line.entry_id} not found in current doc")
|
|
239
|
+
entry.text = edit.new_text.strip()
|
|
240
|
+
entry.refs = list(edit.refs)
|
|
241
|
+
return f"replace {entry.id}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _apply_delete(edit: DeleteLinesOp, doc: Document, view: LineView) -> str:
|
|
245
|
+
if edit.line_end < edit.line_start:
|
|
246
|
+
raise _Reject(f"line_end {edit.line_end} < line_start {edit.line_start}")
|
|
247
|
+
ids_to_drop: set[str] = set()
|
|
248
|
+
for n in range(edit.line_start, edit.line_end + 1):
|
|
249
|
+
line = view.line(n)
|
|
250
|
+
if line is None or line.kind != "bullet" or not line.entry_id:
|
|
251
|
+
continue # section/blank lines are removed only as a side-effect of empties
|
|
252
|
+
ids_to_drop.add(line.entry_id)
|
|
253
|
+
if not ids_to_drop:
|
|
254
|
+
raise _Reject("range covers no entries")
|
|
255
|
+
for _name, entries in doc.sections:
|
|
256
|
+
entries[:] = [e for e in entries if e.id not in ids_to_drop]
|
|
257
|
+
return f"deleted {len(ids_to_drop)} entries"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _apply_insert(edit: InsertAfterOp, doc: Document, view: LineView) -> str:
|
|
261
|
+
if not edit.text.strip():
|
|
262
|
+
raise _Reject("insert text empty")
|
|
263
|
+
if not edit.refs:
|
|
264
|
+
raise _Reject("insert requires non-empty refs")
|
|
265
|
+
|
|
266
|
+
section = edit.section
|
|
267
|
+
if section is None:
|
|
268
|
+
if edit.after_line < 1 or edit.after_line > len(view.lines):
|
|
269
|
+
raise _Reject(
|
|
270
|
+
"after_line out of range; for top-of-doc insert provide `section` explicitly"
|
|
271
|
+
)
|
|
272
|
+
anchor = view.line(edit.after_line)
|
|
273
|
+
section = anchor.section if anchor and anchor.section else None
|
|
274
|
+
if section is None and anchor and anchor.kind == "section":
|
|
275
|
+
section = anchor.text.lstrip("# ").strip()
|
|
276
|
+
if section is None:
|
|
277
|
+
raise _Reject("no section context for insert; supply `section`")
|
|
278
|
+
|
|
279
|
+
entry = Entry(
|
|
280
|
+
id=new_entry_id(),
|
|
281
|
+
section=section,
|
|
282
|
+
text=edit.text.strip(),
|
|
283
|
+
refs=list(edit.refs),
|
|
284
|
+
)
|
|
285
|
+
target = _section_entries(doc, section)
|
|
286
|
+
# When inserting after a bullet inside an existing section, honor
|
|
287
|
+
# the local position; otherwise append at end.
|
|
288
|
+
anchor = view.line(edit.after_line) if 1 <= edit.after_line <= len(view.lines) else None
|
|
289
|
+
if anchor and anchor.kind == "bullet" and anchor.section == section and anchor.entry_id:
|
|
290
|
+
for idx, existing in enumerate(target):
|
|
291
|
+
if existing.id == anchor.entry_id:
|
|
292
|
+
target.insert(idx + 1, entry)
|
|
293
|
+
break
|
|
294
|
+
else:
|
|
295
|
+
target.append(entry)
|
|
296
|
+
else:
|
|
297
|
+
target.append(entry)
|
|
298
|
+
return f"inserted {entry.id} into {section!r}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── Parse edits payload ─────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
_REF_WRAPPER_CHARS = "`[](){}<>^ \t\n\r"
|
|
305
|
+
_ENTRY_ID_REF_RE = re.compile(r"^m_[0-9A-HJKMNP-TV-Z]{26}$")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _clean_refs(raw_refs: object, *, layer: str | None) -> list[str]:
|
|
309
|
+
"""Strip wrappers + drop garbage refs from one ``refs`` array.
|
|
310
|
+
|
|
311
|
+
Two cleanups, in order:
|
|
312
|
+
|
|
313
|
+
1. **Wrapper strip**. The audit / dedup line-numbered view shows each
|
|
314
|
+
bullet as ``- text [^m_xxx]``. LLMs sometimes copy the marker
|
|
315
|
+
(``^m_xxx``) wholesale into the new refs array — caret and all.
|
|
316
|
+
Strip ``` ` [ ] ( ) { } < > ^ ``` plus whitespace from both sides.
|
|
317
|
+
|
|
318
|
+
2. **Layer-shape filter**. After stripping, an L2 doc whose refs
|
|
319
|
+
still look like ``m_<ULID>`` (an entry id from the line view) is
|
|
320
|
+
almost certainly hallucinated — real L2 refs are ``surface:id``.
|
|
321
|
+
Drop them. L3 ref shape *is* ``m_<ULID>`` so the filter is a
|
|
322
|
+
no-op there.
|
|
323
|
+
"""
|
|
324
|
+
if not isinstance(raw_refs, list):
|
|
325
|
+
return []
|
|
326
|
+
out: list[str] = []
|
|
327
|
+
for r in raw_refs:
|
|
328
|
+
if not r:
|
|
329
|
+
continue
|
|
330
|
+
s = str(r).strip(_REF_WRAPPER_CHARS).strip()
|
|
331
|
+
if not s:
|
|
332
|
+
continue
|
|
333
|
+
if layer == "L2" and _ENTRY_ID_REF_RE.match(s):
|
|
334
|
+
continue
|
|
335
|
+
out.append(s)
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def parse_edits_payload(raw: str, *, layer: str | None = None) -> list[Edit]:
|
|
340
|
+
"""Tolerant JSON parse → list[Edit].
|
|
341
|
+
|
|
342
|
+
Accepts ``{"edits": [...]}`` or a top-level ``[...]``. Each entry's
|
|
343
|
+
``op`` field discriminates the type. Unknown ops are dropped.
|
|
344
|
+
|
|
345
|
+
``layer`` (``"L2"`` / ``"L3"``) controls ref-shape filtering — see
|
|
346
|
+
:func:`_clean_refs`. Omit it (or pass ``None``) when the caller
|
|
347
|
+
can't or shouldn't filter by layer; refs are still stripped of
|
|
348
|
+
wrapper characters.
|
|
349
|
+
"""
|
|
350
|
+
snippet = _extract_json(raw)
|
|
351
|
+
if snippet is None:
|
|
352
|
+
return []
|
|
353
|
+
try:
|
|
354
|
+
data = json.loads(snippet)
|
|
355
|
+
except json.JSONDecodeError:
|
|
356
|
+
logger.warning("line-edit parse: malformed JSON")
|
|
357
|
+
return []
|
|
358
|
+
items = data.get("edits") if isinstance(data, dict) else data
|
|
359
|
+
if not isinstance(items, list):
|
|
360
|
+
return []
|
|
361
|
+
|
|
362
|
+
edits: list[Edit] = []
|
|
363
|
+
for raw_op in items:
|
|
364
|
+
if not isinstance(raw_op, dict):
|
|
365
|
+
continue
|
|
366
|
+
kind = raw_op.get("op")
|
|
367
|
+
try:
|
|
368
|
+
if kind == "replace":
|
|
369
|
+
edits.append(
|
|
370
|
+
ReplaceLineOp(
|
|
371
|
+
line=int(raw_op.get("line", 0)),
|
|
372
|
+
new_text=str(raw_op.get("new_text", "")).strip(),
|
|
373
|
+
refs=_clean_refs(raw_op.get("refs", []), layer=layer),
|
|
374
|
+
reason=str(raw_op.get("reason", "")).strip(),
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
elif kind == "delete":
|
|
378
|
+
edits.append(
|
|
379
|
+
DeleteLinesOp(
|
|
380
|
+
line_start=int(raw_op.get("line_start", raw_op.get("line", 0))),
|
|
381
|
+
line_end=int(raw_op.get("line_end", raw_op.get("line", 0))),
|
|
382
|
+
reason=str(raw_op.get("reason", "")).strip(),
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
elif kind == "insert":
|
|
386
|
+
section = raw_op.get("section")
|
|
387
|
+
edits.append(
|
|
388
|
+
InsertAfterOp(
|
|
389
|
+
after_line=int(raw_op.get("after_line", 0)),
|
|
390
|
+
text=str(raw_op.get("text", "")).strip(),
|
|
391
|
+
refs=_clean_refs(raw_op.get("refs", []), layer=layer),
|
|
392
|
+
section=str(section).strip() if section else None,
|
|
393
|
+
reason=str(raw_op.get("reason", "")).strip(),
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
except (TypeError, ValueError):
|
|
397
|
+
continue
|
|
398
|
+
return edits
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ── Helpers ─────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _sort_reverse(edits: list[Edit]) -> list[Edit]:
|
|
405
|
+
def key(e: Edit) -> tuple[int, int]:
|
|
406
|
+
if isinstance(e, ReplaceLineOp):
|
|
407
|
+
return (e.line, 0)
|
|
408
|
+
if isinstance(e, DeleteLinesOp):
|
|
409
|
+
return (e.line_end, 1) # delete sorts before insert at same line
|
|
410
|
+
if isinstance(e, InsertAfterOp):
|
|
411
|
+
return (e.after_line, 2)
|
|
412
|
+
return (0, 9)
|
|
413
|
+
|
|
414
|
+
return sorted(edits, key=key, reverse=True)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _entry_in_doc(doc: Document, entry_id: str) -> Entry | None:
|
|
418
|
+
if not is_entry_id(entry_id):
|
|
419
|
+
return None
|
|
420
|
+
for _section, entries in doc.sections:
|
|
421
|
+
for entry in entries:
|
|
422
|
+
if entry.id == entry_id:
|
|
423
|
+
return entry
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _section_entries(doc: Document, name: str) -> list[Entry]:
|
|
428
|
+
for section, entries in doc.sections:
|
|
429
|
+
if section == name:
|
|
430
|
+
return entries
|
|
431
|
+
new_entries: list[Entry] = []
|
|
432
|
+
doc.sections.append((name, new_entries))
|
|
433
|
+
return new_entries
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _drop_empty_sections(doc: Document) -> None:
|
|
437
|
+
doc.sections[:] = [(name, entries) for name, entries in doc.sections if entries]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _short(edit: Edit) -> str:
|
|
441
|
+
if isinstance(edit, ReplaceLineOp):
|
|
442
|
+
return f"replace L{edit.line}"
|
|
443
|
+
if isinstance(edit, DeleteLinesOp):
|
|
444
|
+
return f"delete L{edit.line_start}-{edit.line_end}"
|
|
445
|
+
if isinstance(edit, InsertAfterOp):
|
|
446
|
+
return f"insert@L{edit.after_line}"
|
|
447
|
+
return repr(edit)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_FENCE_RE = re.compile(r"^```[a-zA-Z]*\s*|\s*```$")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _extract_json(raw: str) -> str | None:
|
|
454
|
+
text = _FENCE_RE.sub("", raw.strip())
|
|
455
|
+
# First {...} or [...] via raw_decode so trailing prose braces cannot
|
|
456
|
+
# extend the slice past the real JSON value.
|
|
457
|
+
obj_start = text.find("{")
|
|
458
|
+
arr_start = text.find("[")
|
|
459
|
+
if obj_start == -1 and arr_start == -1:
|
|
460
|
+
return None
|
|
461
|
+
if obj_start == -1:
|
|
462
|
+
start = arr_start
|
|
463
|
+
elif arr_start == -1:
|
|
464
|
+
start = obj_start
|
|
465
|
+
else:
|
|
466
|
+
start = min(obj_start, arr_start)
|
|
467
|
+
try:
|
|
468
|
+
_parsed, end = json.JSONDecoder().raw_decode(text[start:])
|
|
469
|
+
except json.JSONDecodeError:
|
|
470
|
+
return None
|
|
471
|
+
return text[start : start + end]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
__all__ = [
|
|
475
|
+
"DeleteLinesOp",
|
|
476
|
+
"Edit",
|
|
477
|
+
"EditReport",
|
|
478
|
+
"EditResult",
|
|
479
|
+
"InsertAfterOp",
|
|
480
|
+
"Line",
|
|
481
|
+
"LineView",
|
|
482
|
+
"ReplaceLineOp",
|
|
483
|
+
"apply_edits",
|
|
484
|
+
"parse_edits_payload",
|
|
485
|
+
"render_view",
|
|
486
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Per-doc consolidator metadata (``*.meta.json`` files).
|
|
2
|
+
|
|
3
|
+
For each L2/L3 markdown doc we keep a sidecar JSON capturing the set of
|
|
4
|
+
upstream ids "seen" at the last update. ``run_update`` uses set diff
|
|
5
|
+
against the live state to compute "what's new since last update" — a
|
|
6
|
+
purely id-based diff is robust against mtime / time-zone / replays.
|
|
7
|
+
|
|
8
|
+
Files
|
|
9
|
+
-----
|
|
10
|
+
* ``memory/L2/<surface>.meta.json``::
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"version": 1,
|
|
14
|
+
"last_update_at": "<iso-utc>",
|
|
15
|
+
"seen_entity_refs": ["chat:01HZK4...", ...]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
* ``memory/L3/<slot>.meta.json``::
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
"version": 1,
|
|
22
|
+
"last_update_at": "<iso-utc>",
|
|
23
|
+
"seen_l2_entry_ids": {
|
|
24
|
+
"chat": ["m_xxx", ...],
|
|
25
|
+
"notebook": ["m_yyy"],
|
|
26
|
+
...
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Atomic writes via temp + rename. Missing files behave as "first run".
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
import tempfile
|
|
42
|
+
|
|
43
|
+
from deeptutor.services.memory import paths
|
|
44
|
+
from deeptutor.services.memory.paths import L3Slot, Surface
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
_META_VERSION = 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── L2 meta ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class L2Meta:
|
|
56
|
+
last_update_at: str | None = None
|
|
57
|
+
seen_entity_refs: set[str] = field(default_factory=set)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def l2_meta_path(surface: Surface) -> Path:
|
|
61
|
+
return paths.l2_dir() / f"{surface}.meta.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_l2_meta(surface: Surface) -> L2Meta:
|
|
65
|
+
return _load_meta_l2(l2_meta_path(surface))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_l2_meta(surface: Surface, *, seen_entity_refs: set[str]) -> L2Meta:
|
|
69
|
+
path = l2_meta_path(surface)
|
|
70
|
+
meta = L2Meta(
|
|
71
|
+
last_update_at=_now_iso(),
|
|
72
|
+
seen_entity_refs=set(seen_entity_refs),
|
|
73
|
+
)
|
|
74
|
+
_atomic_write_json(
|
|
75
|
+
path,
|
|
76
|
+
{
|
|
77
|
+
"version": _META_VERSION,
|
|
78
|
+
"last_update_at": meta.last_update_at,
|
|
79
|
+
"seen_entity_refs": sorted(meta.seen_entity_refs),
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
return meta
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── L3 meta ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class L3Meta:
|
|
90
|
+
last_update_at: str | None = None
|
|
91
|
+
seen_l2_entry_ids: dict[str, set[str]] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def l3_meta_path(slot: L3Slot) -> Path:
|
|
95
|
+
return paths.l3_dir() / f"{slot}.meta.json"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_l3_meta(slot: L3Slot) -> L3Meta:
|
|
99
|
+
return _load_meta_l3(l3_meta_path(slot))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def save_l3_meta(
|
|
103
|
+
slot: L3Slot,
|
|
104
|
+
*,
|
|
105
|
+
seen_l2_entry_ids: dict[str, set[str]],
|
|
106
|
+
) -> L3Meta:
|
|
107
|
+
path = l3_meta_path(slot)
|
|
108
|
+
meta = L3Meta(
|
|
109
|
+
last_update_at=_now_iso(),
|
|
110
|
+
seen_l2_entry_ids={surface: set(ids) for surface, ids in seen_l2_entry_ids.items()},
|
|
111
|
+
)
|
|
112
|
+
_atomic_write_json(
|
|
113
|
+
path,
|
|
114
|
+
{
|
|
115
|
+
"version": _META_VERSION,
|
|
116
|
+
"last_update_at": meta.last_update_at,
|
|
117
|
+
"seen_l2_entry_ids": {
|
|
118
|
+
surface: sorted(ids) for surface, ids in meta.seen_l2_entry_ids.items()
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
return meta
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── Internals ───────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_meta_l2(path: Path) -> L2Meta:
|
|
129
|
+
data = _read_json(path)
|
|
130
|
+
if not data:
|
|
131
|
+
return L2Meta()
|
|
132
|
+
refs = data.get("seen_entity_refs") or []
|
|
133
|
+
return L2Meta(
|
|
134
|
+
last_update_at=data.get("last_update_at"),
|
|
135
|
+
seen_entity_refs=set(refs) if isinstance(refs, list) else set(),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_meta_l3(path: Path) -> L3Meta:
|
|
140
|
+
data = _read_json(path)
|
|
141
|
+
if not data:
|
|
142
|
+
return L3Meta()
|
|
143
|
+
raw = data.get("seen_l2_entry_ids") or {}
|
|
144
|
+
if not isinstance(raw, dict):
|
|
145
|
+
raw = {}
|
|
146
|
+
return L3Meta(
|
|
147
|
+
last_update_at=data.get("last_update_at"),
|
|
148
|
+
seen_l2_entry_ids={
|
|
149
|
+
surface: set(ids) if isinstance(ids, list) else set() for surface, ids in raw.items()
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _read_json(path: Path) -> dict | None:
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
159
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
160
|
+
logger.warning("memory meta: failed to read %s: %s", path, exc)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _atomic_write_json(path: Path, payload: dict) -> None:
|
|
165
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
fd, tmp_str = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
|
167
|
+
try:
|
|
168
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
169
|
+
json.dump(payload, fh, ensure_ascii=False, indent=2, sort_keys=False)
|
|
170
|
+
fh.flush()
|
|
171
|
+
os.fsync(fh.fileno())
|
|
172
|
+
os.replace(tmp_str, path)
|
|
173
|
+
finally:
|
|
174
|
+
if os.path.exists(tmp_str):
|
|
175
|
+
try:
|
|
176
|
+
os.remove(tmp_str)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _now_iso() -> str:
|
|
182
|
+
return datetime.now(tz=timezone.utc).isoformat()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = [
|
|
186
|
+
"L2Meta",
|
|
187
|
+
"L3Meta",
|
|
188
|
+
"l2_meta_path",
|
|
189
|
+
"l3_meta_path",
|
|
190
|
+
"load_l2_meta",
|
|
191
|
+
"load_l3_meta",
|
|
192
|
+
"save_l2_meta",
|
|
193
|
+
"save_l3_meta",
|
|
194
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Four user-visible consolidation modes.
|
|
2
|
+
|
|
3
|
+
* :func:`run_update` — chunk-based incremental fact extraction.
|
|
4
|
+
* :func:`run_audit` — chunk-based line-level edits against raw evidence.
|
|
5
|
+
* :func:`run_dedup` — iterative line-level dedup over the full doc.
|
|
6
|
+
* :func:`run_merge` — no-LLM footnote consolidation (collapse duplicate refs).
|
|
7
|
+
|
|
8
|
+
Plus thin shims (:func:`consolidate_l2`, :func:`consolidate_l3`) kept
|
|
9
|
+
for :mod:`deeptutor.services.memory.store` so the public API surface
|
|
10
|
+
stays stable while the implementation switches under the hood.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from deeptutor.services.memory.consolidator.modes._shims import (
|
|
16
|
+
consolidate_l2,
|
|
17
|
+
consolidate_l3,
|
|
18
|
+
)
|
|
19
|
+
from deeptutor.services.memory.consolidator.modes.audit import run_audit
|
|
20
|
+
from deeptutor.services.memory.consolidator.modes.dedup import run_dedup
|
|
21
|
+
from deeptutor.services.memory.consolidator.modes.merge import run_merge
|
|
22
|
+
from deeptutor.services.memory.consolidator.modes.update import run_update
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"consolidate_l2",
|
|
26
|
+
"consolidate_l3",
|
|
27
|
+
"run_audit",
|
|
28
|
+
"run_dedup",
|
|
29
|
+
"run_merge",
|
|
30
|
+
"run_update",
|
|
31
|
+
]
|