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,481 @@
|
|
|
1
|
+
"""Audit mode — line-level edits checked against raw evidence.
|
|
2
|
+
|
|
3
|
+
Algorithm
|
|
4
|
+
---------
|
|
5
|
+
1. Render the target md as a line-numbered, footnote-stripped view
|
|
6
|
+
(:func:`line_doc.render_view`).
|
|
7
|
+
2. Chunk the rendered view into ≤ budget pieces; each chunk is a
|
|
8
|
+
contiguous slice of lines whose bullet entries get **annotated with
|
|
9
|
+
their full source content** (no truncation).
|
|
10
|
+
3. Per chunk: LLM call → parse edits → apply against the in-memory doc
|
|
11
|
+
in reverse line order. All chunks use the original line view, whose
|
|
12
|
+
entry IDs anchor edits after earlier chunks change line positions.
|
|
13
|
+
4. Atomic flush.
|
|
14
|
+
|
|
15
|
+
The chunker is deliberately char-based (same as update) so chunk size
|
|
16
|
+
behaves predictably alongside the annotation block (which can be much
|
|
17
|
+
larger than the bare md). The annotated block fed to the LLM is what
|
|
18
|
+
gets counted against the budget.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
import logging
|
|
25
|
+
|
|
26
|
+
from deeptutor.services.memory import paths
|
|
27
|
+
from deeptutor.services.memory import snapshot as snap
|
|
28
|
+
from deeptutor.services.memory.consolidator.chunker import (
|
|
29
|
+
chunk_with_boundary,
|
|
30
|
+
)
|
|
31
|
+
from deeptutor.services.memory.consolidator.line_doc import (
|
|
32
|
+
LineView,
|
|
33
|
+
apply_edits,
|
|
34
|
+
parse_edits_payload,
|
|
35
|
+
render_view,
|
|
36
|
+
)
|
|
37
|
+
from deeptutor.services.memory.consolidator.modes._runtime import (
|
|
38
|
+
OnEvent,
|
|
39
|
+
call_llm,
|
|
40
|
+
emit,
|
|
41
|
+
load_doc,
|
|
42
|
+
load_prompt,
|
|
43
|
+
slot_focus,
|
|
44
|
+
surface_focus,
|
|
45
|
+
today_iso,
|
|
46
|
+
write_doc_checkpoint,
|
|
47
|
+
)
|
|
48
|
+
from deeptutor.services.memory.consolidator.references import (
|
|
49
|
+
annotate_l2_line_with_evidence,
|
|
50
|
+
annotate_l3_line_with_evidence,
|
|
51
|
+
)
|
|
52
|
+
from deeptutor.services.memory.document import Document, Entry
|
|
53
|
+
from deeptutor.services.memory.paths import L3Slot, Surface
|
|
54
|
+
from deeptutor.services.memory.settings import load_memory_settings
|
|
55
|
+
from deeptutor.services.memory.snapshot.entity import Entity
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class AuditResult:
|
|
62
|
+
layer: str
|
|
63
|
+
key: str
|
|
64
|
+
chunks_processed: int
|
|
65
|
+
edits_applied: int = 0
|
|
66
|
+
edits_rejected: int = 0
|
|
67
|
+
no_doc: bool = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Public entry ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def run_audit(
|
|
74
|
+
layer: str,
|
|
75
|
+
key: str,
|
|
76
|
+
*,
|
|
77
|
+
language: str = "en",
|
|
78
|
+
user_label: str = "anonymous",
|
|
79
|
+
budget: int | None = None,
|
|
80
|
+
llm_selection: dict | None = None,
|
|
81
|
+
on_event: OnEvent | None = None,
|
|
82
|
+
) -> AuditResult:
|
|
83
|
+
from deeptutor.services.model_selection.runtime import (
|
|
84
|
+
activate_llm_selection,
|
|
85
|
+
reset_llm_selection,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
settings = load_memory_settings()
|
|
89
|
+
token = None
|
|
90
|
+
if llm_selection:
|
|
91
|
+
try:
|
|
92
|
+
_config, token = activate_llm_selection(llm_selection)
|
|
93
|
+
except Exception as exc: # noqa: BLE001
|
|
94
|
+
logger.warning(
|
|
95
|
+
"memory audit: ignoring unresolvable llm_selection %s: %s", llm_selection, exc
|
|
96
|
+
)
|
|
97
|
+
token = None
|
|
98
|
+
try:
|
|
99
|
+
if layer == "L2":
|
|
100
|
+
return await _run_audit_l2(
|
|
101
|
+
key, # type: ignore[arg-type]
|
|
102
|
+
language=language,
|
|
103
|
+
user_label=user_label,
|
|
104
|
+
budget=budget if budget is not None else settings.audit.l2_budget,
|
|
105
|
+
llm_selection=llm_selection,
|
|
106
|
+
on_event=on_event,
|
|
107
|
+
settings=settings,
|
|
108
|
+
)
|
|
109
|
+
if layer == "L3":
|
|
110
|
+
return await _run_audit_l3(
|
|
111
|
+
key, # type: ignore[arg-type]
|
|
112
|
+
language=language,
|
|
113
|
+
user_label=user_label,
|
|
114
|
+
budget=budget if budget is not None else settings.audit.l3_budget,
|
|
115
|
+
llm_selection=llm_selection,
|
|
116
|
+
on_event=on_event,
|
|
117
|
+
settings=settings,
|
|
118
|
+
)
|
|
119
|
+
raise ValueError(f"unknown layer {layer!r}")
|
|
120
|
+
finally:
|
|
121
|
+
reset_llm_selection(token)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── L2 ──────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _run_audit_l2(
|
|
128
|
+
surface: Surface,
|
|
129
|
+
*,
|
|
130
|
+
language: str,
|
|
131
|
+
user_label: str,
|
|
132
|
+
budget: int,
|
|
133
|
+
llm_selection: dict | None,
|
|
134
|
+
on_event: OnEvent | None,
|
|
135
|
+
settings,
|
|
136
|
+
) -> AuditResult:
|
|
137
|
+
l2_path = paths.l2_file(surface)
|
|
138
|
+
if not l2_path.exists():
|
|
139
|
+
await emit(on_event, {"stage": "done", "no_doc": True})
|
|
140
|
+
return AuditResult(layer="L2", key=surface, chunks_processed=0, no_doc=True)
|
|
141
|
+
|
|
142
|
+
doc = load_doc(l2_path, default_title=f"{surface} memory")
|
|
143
|
+
audit_view = render_view(doc)
|
|
144
|
+
entity_lookup = {ent.id: ent for ent in snap.read_snapshot(surface)}
|
|
145
|
+
prompt = load_prompt("audit_l2", language)
|
|
146
|
+
focus, _sections = surface_focus(language, surface)
|
|
147
|
+
|
|
148
|
+
annotated_text, line_ranges = _build_annotated_l2(doc, surface, entity_lookup)
|
|
149
|
+
chunks = chunk_with_boundary(
|
|
150
|
+
annotated_text,
|
|
151
|
+
budget=budget,
|
|
152
|
+
overlap_ratio=settings.chunking.overlap_ratio,
|
|
153
|
+
min_chunk_chars=settings.chunking.min_chunk_chars,
|
|
154
|
+
max_chunk_chars=settings.chunking.max_chunk_chars,
|
|
155
|
+
boundary=settings.chunking.boundary,
|
|
156
|
+
)
|
|
157
|
+
await emit(
|
|
158
|
+
on_event,
|
|
159
|
+
{"stage": "chunked", "chunks": len(chunks), "budget": budget, "chars": len(annotated_text)},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
edits_applied = 0
|
|
163
|
+
edits_rejected = 0
|
|
164
|
+
for chunk in chunks:
|
|
165
|
+
await emit(
|
|
166
|
+
on_event,
|
|
167
|
+
{
|
|
168
|
+
"stage": "progress",
|
|
169
|
+
"mode": "audit",
|
|
170
|
+
"turn": chunk.index + 1,
|
|
171
|
+
"total": len(chunks),
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
system = prompt["system"].format(
|
|
175
|
+
user_label=user_label,
|
|
176
|
+
surface=surface,
|
|
177
|
+
focus=focus,
|
|
178
|
+
today=today_iso(),
|
|
179
|
+
)
|
|
180
|
+
user = prompt["user"].format(surface=surface, chunk=chunk.text)
|
|
181
|
+
raw = await call_llm(
|
|
182
|
+
system_prompt=system,
|
|
183
|
+
user_prompt=user,
|
|
184
|
+
on_event=on_event,
|
|
185
|
+
turn=chunk.index + 1,
|
|
186
|
+
chunk_index=chunk.index,
|
|
187
|
+
label="audit",
|
|
188
|
+
)
|
|
189
|
+
edits = parse_edits_payload(raw, layer="L2")
|
|
190
|
+
if not edits:
|
|
191
|
+
await emit(
|
|
192
|
+
on_event,
|
|
193
|
+
{"stage": "facts_extracted", "turn": chunk.index + 1, "edits": 0},
|
|
194
|
+
)
|
|
195
|
+
continue
|
|
196
|
+
doc, report = apply_edits(doc, edits, view=audit_view)
|
|
197
|
+
edits_applied += len(report.applied)
|
|
198
|
+
edits_rejected += len(report.rejected)
|
|
199
|
+
if report.applied:
|
|
200
|
+
await write_doc_checkpoint(
|
|
201
|
+
l2_path,
|
|
202
|
+
doc,
|
|
203
|
+
layer="L2",
|
|
204
|
+
key=surface,
|
|
205
|
+
on_event=on_event,
|
|
206
|
+
turn=chunk.index + 1,
|
|
207
|
+
label="audit",
|
|
208
|
+
action="apply_edits",
|
|
209
|
+
)
|
|
210
|
+
for res in report.applied:
|
|
211
|
+
await emit(
|
|
212
|
+
on_event,
|
|
213
|
+
{
|
|
214
|
+
"stage": "op_applied",
|
|
215
|
+
"turn": chunk.index + 1,
|
|
216
|
+
"op": res.op.op,
|
|
217
|
+
"detail": res.detail,
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
for res in report.rejected:
|
|
221
|
+
await emit(
|
|
222
|
+
on_event,
|
|
223
|
+
{
|
|
224
|
+
"stage": "op_rejected",
|
|
225
|
+
"turn": chunk.index + 1,
|
|
226
|
+
"op": res.op.op,
|
|
227
|
+
"detail": res.detail,
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
await emit(
|
|
232
|
+
on_event,
|
|
233
|
+
{
|
|
234
|
+
"stage": "done",
|
|
235
|
+
"edits_applied": edits_applied,
|
|
236
|
+
"edits_rejected": edits_rejected,
|
|
237
|
+
"chunks_processed": len(chunks),
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if load_memory_settings().merge.auto_after_audit:
|
|
242
|
+
from deeptutor.services.memory.consolidator.modes.merge import run_merge
|
|
243
|
+
|
|
244
|
+
await run_merge(
|
|
245
|
+
"L2",
|
|
246
|
+
surface,
|
|
247
|
+
language=language,
|
|
248
|
+
user_label=user_label,
|
|
249
|
+
on_event=on_event,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return AuditResult(
|
|
253
|
+
layer="L2",
|
|
254
|
+
key=surface,
|
|
255
|
+
chunks_processed=len(chunks),
|
|
256
|
+
edits_applied=edits_applied,
|
|
257
|
+
edits_rejected=edits_rejected,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── L3 ──────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def _run_audit_l3(
|
|
265
|
+
slot: L3Slot,
|
|
266
|
+
*,
|
|
267
|
+
language: str,
|
|
268
|
+
user_label: str,
|
|
269
|
+
budget: int,
|
|
270
|
+
llm_selection: dict | None,
|
|
271
|
+
on_event: OnEvent | None,
|
|
272
|
+
settings,
|
|
273
|
+
) -> AuditResult:
|
|
274
|
+
if slot == "preferences":
|
|
275
|
+
raise ValueError("preferences.md is not audited automatically")
|
|
276
|
+
|
|
277
|
+
l3_path = paths.l3_file(slot)
|
|
278
|
+
if not l3_path.exists():
|
|
279
|
+
await emit(on_event, {"stage": "done", "no_doc": True})
|
|
280
|
+
return AuditResult(layer="L3", key=slot, chunks_processed=0, no_doc=True)
|
|
281
|
+
|
|
282
|
+
doc = load_doc(l3_path, default_title=f"{slot} memory")
|
|
283
|
+
audit_view = render_view(doc)
|
|
284
|
+
l2_lookup = _build_l2_entry_lookup()
|
|
285
|
+
prompt = load_prompt("audit_l3", language)
|
|
286
|
+
focus, _sections = slot_focus(language, slot)
|
|
287
|
+
|
|
288
|
+
annotated_text, _ranges = _build_annotated_l3(doc, l2_lookup)
|
|
289
|
+
chunks = chunk_with_boundary(
|
|
290
|
+
annotated_text,
|
|
291
|
+
budget=budget,
|
|
292
|
+
overlap_ratio=settings.chunking.overlap_ratio,
|
|
293
|
+
min_chunk_chars=settings.chunking.min_chunk_chars,
|
|
294
|
+
max_chunk_chars=settings.chunking.max_chunk_chars,
|
|
295
|
+
boundary=settings.chunking.boundary,
|
|
296
|
+
)
|
|
297
|
+
await emit(
|
|
298
|
+
on_event,
|
|
299
|
+
{"stage": "chunked", "chunks": len(chunks), "budget": budget, "chars": len(annotated_text)},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
edits_applied = 0
|
|
303
|
+
edits_rejected = 0
|
|
304
|
+
for chunk in chunks:
|
|
305
|
+
await emit(
|
|
306
|
+
on_event,
|
|
307
|
+
{
|
|
308
|
+
"stage": "progress",
|
|
309
|
+
"mode": "audit",
|
|
310
|
+
"turn": chunk.index + 1,
|
|
311
|
+
"total": len(chunks),
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
system = prompt["system"].format(
|
|
315
|
+
user_label=user_label,
|
|
316
|
+
slot=slot,
|
|
317
|
+
focus=focus,
|
|
318
|
+
today=today_iso(),
|
|
319
|
+
)
|
|
320
|
+
user = prompt["user"].format(slot=slot, chunk=chunk.text)
|
|
321
|
+
raw = await call_llm(
|
|
322
|
+
system_prompt=system,
|
|
323
|
+
user_prompt=user,
|
|
324
|
+
on_event=on_event,
|
|
325
|
+
turn=chunk.index + 1,
|
|
326
|
+
chunk_index=chunk.index,
|
|
327
|
+
label="audit",
|
|
328
|
+
)
|
|
329
|
+
edits = parse_edits_payload(raw, layer="L3")
|
|
330
|
+
if not edits:
|
|
331
|
+
await emit(
|
|
332
|
+
on_event,
|
|
333
|
+
{"stage": "facts_extracted", "turn": chunk.index + 1, "edits": 0},
|
|
334
|
+
)
|
|
335
|
+
continue
|
|
336
|
+
doc, report = apply_edits(doc, edits, view=audit_view)
|
|
337
|
+
edits_applied += len(report.applied)
|
|
338
|
+
edits_rejected += len(report.rejected)
|
|
339
|
+
if report.applied:
|
|
340
|
+
await write_doc_checkpoint(
|
|
341
|
+
l3_path,
|
|
342
|
+
doc,
|
|
343
|
+
layer="L3",
|
|
344
|
+
key=slot,
|
|
345
|
+
on_event=on_event,
|
|
346
|
+
turn=chunk.index + 1,
|
|
347
|
+
label="audit",
|
|
348
|
+
action="apply_edits",
|
|
349
|
+
)
|
|
350
|
+
for res in report.applied:
|
|
351
|
+
await emit(
|
|
352
|
+
on_event,
|
|
353
|
+
{
|
|
354
|
+
"stage": "op_applied",
|
|
355
|
+
"turn": chunk.index + 1,
|
|
356
|
+
"op": res.op.op,
|
|
357
|
+
"detail": res.detail,
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
for res in report.rejected:
|
|
361
|
+
await emit(
|
|
362
|
+
on_event,
|
|
363
|
+
{
|
|
364
|
+
"stage": "op_rejected",
|
|
365
|
+
"turn": chunk.index + 1,
|
|
366
|
+
"op": res.op.op,
|
|
367
|
+
"detail": res.detail,
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
await emit(
|
|
372
|
+
on_event,
|
|
373
|
+
{
|
|
374
|
+
"stage": "done",
|
|
375
|
+
"edits_applied": edits_applied,
|
|
376
|
+
"edits_rejected": edits_rejected,
|
|
377
|
+
"chunks_processed": len(chunks),
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if load_memory_settings().merge.auto_after_audit:
|
|
382
|
+
from deeptutor.services.memory.consolidator.modes.merge import run_merge
|
|
383
|
+
|
|
384
|
+
await run_merge(
|
|
385
|
+
"L3",
|
|
386
|
+
slot,
|
|
387
|
+
language=language,
|
|
388
|
+
user_label=user_label,
|
|
389
|
+
on_event=on_event,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return AuditResult(
|
|
393
|
+
layer="L3",
|
|
394
|
+
key=slot,
|
|
395
|
+
chunks_processed=len(chunks),
|
|
396
|
+
edits_applied=edits_applied,
|
|
397
|
+
edits_rejected=edits_rejected,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ── Annotation builders ────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _build_annotated_l2(
|
|
405
|
+
doc: Document, surface: str, entity_lookup: dict[str, Entity]
|
|
406
|
+
) -> tuple[str, list[tuple[int, int]]]:
|
|
407
|
+
"""Render the doc as `line N: ... ` + per-bullet source dumps.
|
|
408
|
+
|
|
409
|
+
Returns ``(text, ranges)`` where ``ranges`` is per-bullet
|
|
410
|
+
``(start_char, end_char)`` for future fine-grained linking. Not
|
|
411
|
+
consumed by the LLM directly; reserved for the UI's diff view.
|
|
412
|
+
"""
|
|
413
|
+
view = render_view(doc)
|
|
414
|
+
pieces: list[str] = [_render_line_index(view)]
|
|
415
|
+
ranges: list[tuple[int, int]] = []
|
|
416
|
+
cursor = len(pieces[0])
|
|
417
|
+
for line in view.lines:
|
|
418
|
+
if line.kind != "bullet" or not line.entry_id:
|
|
419
|
+
continue
|
|
420
|
+
entry = view.entry_by_id.get(line.entry_id)
|
|
421
|
+
if entry is None:
|
|
422
|
+
continue
|
|
423
|
+
block = annotate_l2_line_with_evidence(
|
|
424
|
+
line.number, entry, surface=surface, entity_lookup=entity_lookup
|
|
425
|
+
)
|
|
426
|
+
# Two newlines separate annotation blocks.
|
|
427
|
+
prefix = "\n\n"
|
|
428
|
+
start = cursor + len(prefix)
|
|
429
|
+
pieces.append(prefix + block)
|
|
430
|
+
cursor = start + len(block)
|
|
431
|
+
ranges.append((start, cursor))
|
|
432
|
+
return "".join(pieces), ranges
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _build_annotated_l3(
|
|
436
|
+
doc: Document, l2_lookup: dict[str, Entry]
|
|
437
|
+
) -> tuple[str, list[tuple[int, int]]]:
|
|
438
|
+
view = render_view(doc)
|
|
439
|
+
pieces: list[str] = [_render_line_index(view)]
|
|
440
|
+
ranges: list[tuple[int, int]] = []
|
|
441
|
+
cursor = len(pieces[0])
|
|
442
|
+
for line in view.lines:
|
|
443
|
+
if line.kind != "bullet" or not line.entry_id:
|
|
444
|
+
continue
|
|
445
|
+
entry = view.entry_by_id.get(line.entry_id)
|
|
446
|
+
if entry is None:
|
|
447
|
+
continue
|
|
448
|
+
block = annotate_l3_line_with_evidence(line.number, entry, l2_entry_lookup=l2_lookup)
|
|
449
|
+
prefix = "\n\n"
|
|
450
|
+
start = cursor + len(prefix)
|
|
451
|
+
pieces.append(prefix + block)
|
|
452
|
+
cursor = start + len(block)
|
|
453
|
+
ranges.append((start, cursor))
|
|
454
|
+
return "".join(pieces), ranges
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _render_line_index(view: LineView) -> str:
|
|
458
|
+
width = max(2, len(str(len(view.lines))))
|
|
459
|
+
head = "# Line-numbered view (LLM-facing):\n"
|
|
460
|
+
body = "\n".join(f"{line.number:>{width}}: {line.text}" for line in view.lines)
|
|
461
|
+
return head + body
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _build_l2_entry_lookup() -> dict[str, Entry]:
|
|
465
|
+
from deeptutor.services.memory.document import parse
|
|
466
|
+
|
|
467
|
+
out: dict[str, Entry] = {}
|
|
468
|
+
for surface in paths.SURFACES:
|
|
469
|
+
path = paths.l2_file(surface)
|
|
470
|
+
if not path.exists():
|
|
471
|
+
continue
|
|
472
|
+
try:
|
|
473
|
+
doc = parse(path.read_text(encoding="utf-8"))
|
|
474
|
+
except Exception:
|
|
475
|
+
continue
|
|
476
|
+
for entry in doc.all_entries():
|
|
477
|
+
out[entry.id] = entry
|
|
478
|
+
return out
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
__all__ = ["AuditResult", "run_audit"]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Dedup mode — iterative line-level merge / delete over the full doc.
|
|
2
|
+
|
|
3
|
+
Each iteration:
|
|
4
|
+
1. Render the full md as a line-numbered view (footnote-stripped).
|
|
5
|
+
2. One LLM call returns ``{"edits": [...]}`` (replace + delete only —
|
|
6
|
+
the dedup prompt forbids inserts).
|
|
7
|
+
3. Apply in reverse line order.
|
|
8
|
+
4. If the LLM returned zero edits, **stop early** (saves tokens).
|
|
9
|
+
|
|
10
|
+
The configured ``iterations`` is the *upper bound*, not a quota.
|
|
11
|
+
|
|
12
|
+
Dedup is invoked either:
|
|
13
|
+
- automatically after a successful :func:`run_update` (controlled by
|
|
14
|
+
``memory.dedup.auto_after_update``), or
|
|
15
|
+
- explicitly via the workbench `[Dedup]` button.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
from deeptutor.services.memory import paths
|
|
24
|
+
from deeptutor.services.memory.consolidator.line_doc import (
|
|
25
|
+
apply_edits,
|
|
26
|
+
parse_edits_payload,
|
|
27
|
+
render_view,
|
|
28
|
+
)
|
|
29
|
+
from deeptutor.services.memory.consolidator.modes._runtime import (
|
|
30
|
+
OnEvent,
|
|
31
|
+
call_llm,
|
|
32
|
+
emit,
|
|
33
|
+
load_doc,
|
|
34
|
+
load_prompt,
|
|
35
|
+
today_iso,
|
|
36
|
+
write_doc_checkpoint,
|
|
37
|
+
)
|
|
38
|
+
from deeptutor.services.memory.settings import load_memory_settings
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DedupResult:
|
|
45
|
+
layer: str
|
|
46
|
+
key: str
|
|
47
|
+
iterations_run: int
|
|
48
|
+
edits_applied: int
|
|
49
|
+
converged_early: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run_dedup(
|
|
53
|
+
layer: str,
|
|
54
|
+
key: str,
|
|
55
|
+
*,
|
|
56
|
+
language: str = "en",
|
|
57
|
+
user_label: str = "anonymous",
|
|
58
|
+
iterations: int | None = None,
|
|
59
|
+
llm_selection: dict | None = None,
|
|
60
|
+
on_event: OnEvent | None = None,
|
|
61
|
+
) -> DedupResult:
|
|
62
|
+
from deeptutor.services.model_selection.runtime import (
|
|
63
|
+
activate_llm_selection,
|
|
64
|
+
reset_llm_selection,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
settings = load_memory_settings()
|
|
68
|
+
iters = iterations if iterations is not None else settings.dedup.iterations
|
|
69
|
+
|
|
70
|
+
token = None
|
|
71
|
+
if llm_selection:
|
|
72
|
+
try:
|
|
73
|
+
_config, token = activate_llm_selection(llm_selection)
|
|
74
|
+
except Exception as exc: # noqa: BLE001
|
|
75
|
+
logger.warning(
|
|
76
|
+
"memory dedup: ignoring unresolvable llm_selection %s: %s", llm_selection, exc
|
|
77
|
+
)
|
|
78
|
+
token = None
|
|
79
|
+
try:
|
|
80
|
+
return await _run_dedup_inner(
|
|
81
|
+
layer, key, iters=iters, language=language, user_label=user_label, on_event=on_event
|
|
82
|
+
)
|
|
83
|
+
finally:
|
|
84
|
+
reset_llm_selection(token)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _run_dedup_inner(
|
|
88
|
+
layer: str,
|
|
89
|
+
key: str,
|
|
90
|
+
*,
|
|
91
|
+
iters: int,
|
|
92
|
+
language: str,
|
|
93
|
+
user_label: str,
|
|
94
|
+
on_event: OnEvent | None,
|
|
95
|
+
) -> DedupResult:
|
|
96
|
+
path = _path_for(layer, key)
|
|
97
|
+
if not path.exists():
|
|
98
|
+
await emit(on_event, {"stage": "done", "no_doc": True, "edits_applied": 0})
|
|
99
|
+
return DedupResult(
|
|
100
|
+
layer=layer, key=key, iterations_run=0, edits_applied=0, converged_early=True
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
doc = load_doc(path, default_title=_default_title(layer, key))
|
|
104
|
+
if not doc.all_entries():
|
|
105
|
+
await emit(on_event, {"stage": "done", "no_doc": True, "edits_applied": 0})
|
|
106
|
+
return DedupResult(
|
|
107
|
+
layer=layer, key=key, iterations_run=0, edits_applied=0, converged_early=True
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
prompt = load_prompt("dedup", language)
|
|
111
|
+
total_applied = 0
|
|
112
|
+
converged = False
|
|
113
|
+
|
|
114
|
+
for i in range(iters):
|
|
115
|
+
view = render_view(doc)
|
|
116
|
+
await emit(
|
|
117
|
+
on_event,
|
|
118
|
+
{
|
|
119
|
+
"stage": "progress",
|
|
120
|
+
"mode": "dedup",
|
|
121
|
+
"turn": i + 1,
|
|
122
|
+
"total": iters,
|
|
123
|
+
"lines": len(view.lines),
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
system = prompt["system"].format(user_label=user_label, today=today_iso())
|
|
127
|
+
user = prompt["user"].format(
|
|
128
|
+
doc=_render_with_numbers(view),
|
|
129
|
+
iteration=i + 1,
|
|
130
|
+
iterations_total=iters,
|
|
131
|
+
)
|
|
132
|
+
raw = await call_llm(
|
|
133
|
+
system_prompt=system,
|
|
134
|
+
user_prompt=user,
|
|
135
|
+
on_event=on_event,
|
|
136
|
+
turn=i + 1,
|
|
137
|
+
label="dedup",
|
|
138
|
+
)
|
|
139
|
+
edits = parse_edits_payload(raw, layer=layer)
|
|
140
|
+
if not edits:
|
|
141
|
+
converged = True
|
|
142
|
+
await emit(on_event, {"stage": "facts_extracted", "turn": i + 1, "edits": 0})
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
doc, report = apply_edits(doc, edits)
|
|
146
|
+
total_applied += len(report.applied)
|
|
147
|
+
if report.applied:
|
|
148
|
+
await write_doc_checkpoint(
|
|
149
|
+
path,
|
|
150
|
+
doc,
|
|
151
|
+
layer=layer,
|
|
152
|
+
key=key,
|
|
153
|
+
on_event=on_event,
|
|
154
|
+
turn=i + 1,
|
|
155
|
+
label="dedup",
|
|
156
|
+
action="apply_edits",
|
|
157
|
+
)
|
|
158
|
+
await emit(
|
|
159
|
+
on_event,
|
|
160
|
+
{
|
|
161
|
+
"stage": "op_applied",
|
|
162
|
+
"turn": i + 1,
|
|
163
|
+
"applied": len(report.applied),
|
|
164
|
+
"rejected": len(report.rejected),
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
if not report.applied and not report.rejected:
|
|
168
|
+
converged = True
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
await emit(
|
|
172
|
+
on_event,
|
|
173
|
+
{
|
|
174
|
+
"stage": "done",
|
|
175
|
+
"edits_applied": total_applied,
|
|
176
|
+
"iterations_run": min(iters, i + 1 if iters else 0),
|
|
177
|
+
"converged_early": converged,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if load_memory_settings().merge.auto_after_dedup:
|
|
182
|
+
from deeptutor.services.memory.consolidator.modes.merge import run_merge
|
|
183
|
+
|
|
184
|
+
await run_merge(
|
|
185
|
+
layer,
|
|
186
|
+
key,
|
|
187
|
+
language=language,
|
|
188
|
+
user_label=user_label,
|
|
189
|
+
on_event=on_event,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return DedupResult(
|
|
193
|
+
layer=layer,
|
|
194
|
+
key=key,
|
|
195
|
+
iterations_run=min(iters, (i + 1) if iters else 0),
|
|
196
|
+
edits_applied=total_applied,
|
|
197
|
+
converged_early=converged,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── Helpers ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _path_for(layer: str, key: str):
|
|
205
|
+
if layer == "L2":
|
|
206
|
+
return paths.l2_file(key) # type: ignore[arg-type]
|
|
207
|
+
if layer == "L3":
|
|
208
|
+
return paths.l3_file(key) # type: ignore[arg-type]
|
|
209
|
+
raise ValueError(f"unknown layer {layer!r}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _default_title(layer: str, key: str) -> str:
|
|
213
|
+
if layer == "L2":
|
|
214
|
+
return f"{key} memory"
|
|
215
|
+
return {
|
|
216
|
+
"recent": "Recent summary",
|
|
217
|
+
"profile": "User profile",
|
|
218
|
+
"scope": "Knowledge scope",
|
|
219
|
+
"preferences": "Preferences",
|
|
220
|
+
}.get(key, f"{key} memory")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _render_with_numbers(view) -> str:
|
|
224
|
+
width = max(2, len(str(len(view.lines))))
|
|
225
|
+
return "\n".join(f"{line.number:>{width}}: {line.text}" for line in view.lines)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
__all__ = ["DedupResult", "run_dedup"]
|