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,706 @@
|
|
|
1
|
+
"""Update mode — chunk-based incremental fact extraction.
|
|
2
|
+
|
|
3
|
+
Algorithm
|
|
4
|
+
---------
|
|
5
|
+
1. Compute "new since last update" by id-set diff against ``*.meta.json``.
|
|
6
|
+
2. Concatenate the new inputs by time (oldest first).
|
|
7
|
+
3. ``chunk_with_boundary`` cuts the concat into ≤ budget pieces, never
|
|
8
|
+
truncating mid-paragraph (or mid-sentence, per settings).
|
|
9
|
+
4. For each chunk: LLM call → parse facts → filter by ref pool → append
|
|
10
|
+
to in-memory ``Document``.
|
|
11
|
+
5. Atomic flush to disk + update ``*.meta.json``.
|
|
12
|
+
6. If ``dedup.auto_after_update`` is set, kick off the dedup pass.
|
|
13
|
+
|
|
14
|
+
The append step uses the existing :class:`ops.AddOp` apply path so the
|
|
15
|
+
document's invariants (id allocation, validation, footnote rebuild on
|
|
16
|
+
serialize) stay centralized.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
from deeptutor.services.memory import paths
|
|
26
|
+
from deeptutor.services.memory import snapshot as snap
|
|
27
|
+
from deeptutor.services.memory.consolidator.chunker import (
|
|
28
|
+
chunk_with_boundary,
|
|
29
|
+
)
|
|
30
|
+
from deeptutor.services.memory.consolidator.meta import (
|
|
31
|
+
load_l2_meta,
|
|
32
|
+
load_l3_meta,
|
|
33
|
+
save_l2_meta,
|
|
34
|
+
save_l3_meta,
|
|
35
|
+
)
|
|
36
|
+
from deeptutor.services.memory.consolidator.modes._runtime import (
|
|
37
|
+
OnEvent,
|
|
38
|
+
call_llm,
|
|
39
|
+
emit,
|
|
40
|
+
load_doc,
|
|
41
|
+
load_prompt,
|
|
42
|
+
slot_focus,
|
|
43
|
+
surface_focus,
|
|
44
|
+
today_iso,
|
|
45
|
+
write_doc_checkpoint,
|
|
46
|
+
)
|
|
47
|
+
from deeptutor.services.memory.consolidator.references import (
|
|
48
|
+
ExtractedFact,
|
|
49
|
+
refs_in_span_l2,
|
|
50
|
+
refs_in_span_l3,
|
|
51
|
+
render_l2_entries_for_concat,
|
|
52
|
+
render_traces_for_concat,
|
|
53
|
+
validate_fact_refs,
|
|
54
|
+
)
|
|
55
|
+
from deeptutor.services.memory.document import Document, Entry, serialize
|
|
56
|
+
from deeptutor.services.memory.ops import AddOp
|
|
57
|
+
from deeptutor.services.memory.ops import apply as apply_ops
|
|
58
|
+
from deeptutor.services.memory.paths import L3Slot, Surface
|
|
59
|
+
from deeptutor.services.memory.settings import load_memory_settings
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
Layer = str # "L2" | "L3"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class UpdateResult:
|
|
68
|
+
layer: Layer
|
|
69
|
+
key: str
|
|
70
|
+
chunks_processed: int
|
|
71
|
+
facts_added: int
|
|
72
|
+
refs_dropped: int
|
|
73
|
+
new_entry_ids: list[str] = field(default_factory=list)
|
|
74
|
+
no_new_input: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Public entry ────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def run_update(
|
|
81
|
+
layer: Layer,
|
|
82
|
+
key: str,
|
|
83
|
+
*,
|
|
84
|
+
language: str = "en",
|
|
85
|
+
user_label: str = "anonymous",
|
|
86
|
+
budget: int | None = None,
|
|
87
|
+
llm_selection: dict | None = None,
|
|
88
|
+
on_event: OnEvent | None = None,
|
|
89
|
+
) -> UpdateResult:
|
|
90
|
+
"""Dispatch to the layer-specific update implementation.
|
|
91
|
+
|
|
92
|
+
The chosen ``llm_selection`` (``{profile_id, model_id}``) is
|
|
93
|
+
installed as a scoped LLM config for the duration of the run so
|
|
94
|
+
every internal :func:`call_llm` resolves to the right provider.
|
|
95
|
+
"""
|
|
96
|
+
from deeptutor.services.model_selection.runtime import (
|
|
97
|
+
activate_llm_selection,
|
|
98
|
+
reset_llm_selection,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
settings = load_memory_settings()
|
|
102
|
+
token = None
|
|
103
|
+
if llm_selection:
|
|
104
|
+
try:
|
|
105
|
+
_config, token = activate_llm_selection(llm_selection)
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
logger.warning(
|
|
108
|
+
"memory update: ignoring unresolvable llm_selection %s: %s", llm_selection, exc
|
|
109
|
+
)
|
|
110
|
+
token = None
|
|
111
|
+
try:
|
|
112
|
+
if layer == "L2":
|
|
113
|
+
return await _run_update_l2(
|
|
114
|
+
key, # type: ignore[arg-type]
|
|
115
|
+
language=language,
|
|
116
|
+
user_label=user_label,
|
|
117
|
+
budget=budget if budget is not None else settings.update.l2_budget,
|
|
118
|
+
llm_selection=llm_selection,
|
|
119
|
+
on_event=on_event,
|
|
120
|
+
settings=settings,
|
|
121
|
+
)
|
|
122
|
+
if layer == "L3":
|
|
123
|
+
return await _run_update_l3(
|
|
124
|
+
key, # type: ignore[arg-type]
|
|
125
|
+
language=language,
|
|
126
|
+
user_label=user_label,
|
|
127
|
+
budget=budget if budget is not None else settings.update.l3_budget,
|
|
128
|
+
llm_selection=llm_selection,
|
|
129
|
+
on_event=on_event,
|
|
130
|
+
settings=settings,
|
|
131
|
+
)
|
|
132
|
+
raise ValueError(f"unknown layer {layer!r}")
|
|
133
|
+
finally:
|
|
134
|
+
reset_llm_selection(token)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── L2 ──────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _run_update_l2(
|
|
141
|
+
surface: Surface,
|
|
142
|
+
*,
|
|
143
|
+
language: str,
|
|
144
|
+
user_label: str,
|
|
145
|
+
budget: int,
|
|
146
|
+
llm_selection: dict | None,
|
|
147
|
+
on_event: OnEvent | None,
|
|
148
|
+
settings,
|
|
149
|
+
) -> UpdateResult:
|
|
150
|
+
meta = load_l2_meta(surface)
|
|
151
|
+
all_entities = sorted(
|
|
152
|
+
snap.read_snapshot(surface),
|
|
153
|
+
key=lambda e: (e.ts or "", e.id),
|
|
154
|
+
)
|
|
155
|
+
seen = meta.seen_entity_refs
|
|
156
|
+
new_entities = [e for e in all_entities if f"{surface}:{e.id}" not in seen]
|
|
157
|
+
seen_now = {f"{surface}:{e.id}" for e in all_entities}
|
|
158
|
+
|
|
159
|
+
await emit(
|
|
160
|
+
on_event,
|
|
161
|
+
{
|
|
162
|
+
"stage": "trace_loaded",
|
|
163
|
+
"surface": surface,
|
|
164
|
+
"total": len(all_entities),
|
|
165
|
+
"new": len(new_entities),
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not new_entities:
|
|
170
|
+
# Still persist a fresh meta so the "last_update_at" timestamp
|
|
171
|
+
# moves; this signals "we checked, nothing new".
|
|
172
|
+
save_l2_meta(surface, seen_entity_refs=seen_now)
|
|
173
|
+
# Even when no facts were added we still run merge — the doc may
|
|
174
|
+
# be in the legacy entry-keyed footnote layout and the user
|
|
175
|
+
# expects "click update" to clean it up.
|
|
176
|
+
if settings.merge.auto_after_update:
|
|
177
|
+
from deeptutor.services.memory.consolidator.modes.merge import run_merge
|
|
178
|
+
|
|
179
|
+
await run_merge(
|
|
180
|
+
"L2",
|
|
181
|
+
surface,
|
|
182
|
+
language=language,
|
|
183
|
+
user_label=user_label,
|
|
184
|
+
on_event=on_event,
|
|
185
|
+
)
|
|
186
|
+
await emit(on_event, {"stage": "done", "no_new_input": True, "facts_added": 0})
|
|
187
|
+
return UpdateResult(
|
|
188
|
+
layer="L2",
|
|
189
|
+
key=surface,
|
|
190
|
+
chunks_processed=0,
|
|
191
|
+
facts_added=0,
|
|
192
|
+
refs_dropped=0,
|
|
193
|
+
no_new_input=True,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
text = render_traces_for_concat(new_entities, surface=surface)
|
|
197
|
+
chunks = chunk_with_boundary(
|
|
198
|
+
text,
|
|
199
|
+
budget=budget,
|
|
200
|
+
overlap_ratio=settings.chunking.overlap_ratio,
|
|
201
|
+
min_chunk_chars=settings.chunking.min_chunk_chars,
|
|
202
|
+
max_chunk_chars=settings.chunking.max_chunk_chars,
|
|
203
|
+
boundary=settings.chunking.boundary,
|
|
204
|
+
)
|
|
205
|
+
await emit(
|
|
206
|
+
on_event,
|
|
207
|
+
{"stage": "chunked", "chunks": len(chunks), "budget": budget, "chars": len(text)},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
prompt = load_prompt("update_l2", language)
|
|
211
|
+
focus, sections = surface_focus(language, surface)
|
|
212
|
+
l2_path = paths.l2_file(surface)
|
|
213
|
+
doc = load_doc(l2_path, default_title=f"{surface} memory")
|
|
214
|
+
|
|
215
|
+
facts_added = 0
|
|
216
|
+
refs_dropped = 0
|
|
217
|
+
new_entry_ids: list[str] = []
|
|
218
|
+
|
|
219
|
+
for chunk in chunks:
|
|
220
|
+
await emit(
|
|
221
|
+
on_event,
|
|
222
|
+
{
|
|
223
|
+
"stage": "progress",
|
|
224
|
+
"mode": "update",
|
|
225
|
+
"turn": chunk.index + 1,
|
|
226
|
+
"total": len(chunks),
|
|
227
|
+
"chunk_start": chunk.start,
|
|
228
|
+
"chunk_end": chunk.end,
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
system = prompt["system"].format(
|
|
232
|
+
user_label=user_label,
|
|
233
|
+
surface=surface,
|
|
234
|
+
sections=", ".join(sections) if sections else "(any)",
|
|
235
|
+
focus=focus,
|
|
236
|
+
today=today_iso(),
|
|
237
|
+
)
|
|
238
|
+
allowed = refs_in_span_l2(
|
|
239
|
+
new_entities,
|
|
240
|
+
surface=surface,
|
|
241
|
+
full_text=text,
|
|
242
|
+
start=chunk.start,
|
|
243
|
+
end=chunk.end,
|
|
244
|
+
)
|
|
245
|
+
user = prompt["user"].format(
|
|
246
|
+
surface=surface,
|
|
247
|
+
existing=_render_existing_l2(doc),
|
|
248
|
+
chunk=_chunk_with_ref_header(chunk.text, allowed),
|
|
249
|
+
chunk_index=chunk.index + 1,
|
|
250
|
+
chunk_total=len(chunks),
|
|
251
|
+
chunk_start=chunk.start,
|
|
252
|
+
chunk_end=chunk.end,
|
|
253
|
+
)
|
|
254
|
+
raw = await call_llm(
|
|
255
|
+
system_prompt=system,
|
|
256
|
+
user_prompt=user,
|
|
257
|
+
on_event=on_event,
|
|
258
|
+
turn=chunk.index + 1,
|
|
259
|
+
chunk_index=chunk.index,
|
|
260
|
+
label="update",
|
|
261
|
+
)
|
|
262
|
+
facts = _parse_facts(raw)
|
|
263
|
+
|
|
264
|
+
kept_in_chunk: list[ExtractedFact] = []
|
|
265
|
+
for fact in facts:
|
|
266
|
+
kept_refs, reject_reason = validate_fact_refs(
|
|
267
|
+
fact,
|
|
268
|
+
allowed=allowed,
|
|
269
|
+
enforce_required=settings.reference.enforce_required,
|
|
270
|
+
drop_invalid=settings.reference.drop_invalid_refs,
|
|
271
|
+
)
|
|
272
|
+
if reject_reason is not None:
|
|
273
|
+
refs_dropped += 1
|
|
274
|
+
await emit(
|
|
275
|
+
on_event,
|
|
276
|
+
{
|
|
277
|
+
"stage": "refs_dropped",
|
|
278
|
+
"turn": chunk.index + 1,
|
|
279
|
+
"reason": reject_reason,
|
|
280
|
+
"text": fact.text[:120],
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
continue
|
|
284
|
+
kept_in_chunk.append(
|
|
285
|
+
ExtractedFact(text=fact.text, refs=kept_refs, section=fact.section)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
added_now = _append_facts_to_doc(doc, kept_in_chunk, sections)
|
|
289
|
+
facts_added += len(added_now)
|
|
290
|
+
new_entry_ids.extend(added_now)
|
|
291
|
+
if added_now:
|
|
292
|
+
await write_doc_checkpoint(
|
|
293
|
+
l2_path,
|
|
294
|
+
doc,
|
|
295
|
+
layer="L2",
|
|
296
|
+
key=surface,
|
|
297
|
+
on_event=on_event,
|
|
298
|
+
turn=chunk.index + 1,
|
|
299
|
+
label="update",
|
|
300
|
+
action="append_facts",
|
|
301
|
+
)
|
|
302
|
+
await emit(
|
|
303
|
+
on_event,
|
|
304
|
+
{
|
|
305
|
+
"stage": "facts_extracted",
|
|
306
|
+
"turn": chunk.index + 1,
|
|
307
|
+
"kept": len(kept_in_chunk),
|
|
308
|
+
"added": len(added_now),
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
save_l2_meta(surface, seen_entity_refs=seen_now)
|
|
313
|
+
|
|
314
|
+
await emit(
|
|
315
|
+
on_event,
|
|
316
|
+
{
|
|
317
|
+
"stage": "done",
|
|
318
|
+
"facts_added": facts_added,
|
|
319
|
+
"refs_dropped": refs_dropped,
|
|
320
|
+
"chunks_processed": len(chunks),
|
|
321
|
+
"auto_dedup": settings.dedup.auto_after_update,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if settings.dedup.auto_after_update and facts_added > 0:
|
|
326
|
+
# Avoid a circular import: dedup imports settings, refs, line_doc.
|
|
327
|
+
from deeptutor.services.memory.consolidator.modes.dedup import run_dedup
|
|
328
|
+
|
|
329
|
+
await run_dedup(
|
|
330
|
+
"L2",
|
|
331
|
+
surface,
|
|
332
|
+
language=language,
|
|
333
|
+
user_label=user_label,
|
|
334
|
+
iterations=settings.dedup.iterations,
|
|
335
|
+
llm_selection=llm_selection,
|
|
336
|
+
on_event=on_event,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if settings.merge.auto_after_update:
|
|
340
|
+
from deeptutor.services.memory.consolidator.modes.merge import run_merge
|
|
341
|
+
|
|
342
|
+
await run_merge(
|
|
343
|
+
"L2",
|
|
344
|
+
surface,
|
|
345
|
+
language=language,
|
|
346
|
+
user_label=user_label,
|
|
347
|
+
on_event=on_event,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return UpdateResult(
|
|
351
|
+
layer="L2",
|
|
352
|
+
key=surface,
|
|
353
|
+
chunks_processed=len(chunks),
|
|
354
|
+
facts_added=facts_added,
|
|
355
|
+
refs_dropped=refs_dropped,
|
|
356
|
+
new_entry_ids=new_entry_ids,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ── L3 ──────────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def _run_update_l3(
|
|
364
|
+
slot: L3Slot,
|
|
365
|
+
*,
|
|
366
|
+
language: str,
|
|
367
|
+
user_label: str,
|
|
368
|
+
budget: int,
|
|
369
|
+
llm_selection: dict | None,
|
|
370
|
+
on_event: OnEvent | None,
|
|
371
|
+
settings,
|
|
372
|
+
) -> UpdateResult:
|
|
373
|
+
if slot == "preferences":
|
|
374
|
+
raise ValueError("preferences.md is not auto-consolidated")
|
|
375
|
+
|
|
376
|
+
meta = load_l3_meta(slot)
|
|
377
|
+
l2_docs = _load_all_l2_docs()
|
|
378
|
+
entries_by_surface: dict[str, list[Entry]] = {}
|
|
379
|
+
seen_now: dict[str, set[str]] = {}
|
|
380
|
+
for surface, doc in l2_docs.items():
|
|
381
|
+
all_entries = doc.all_entries()
|
|
382
|
+
seen_now[surface] = {e.id for e in all_entries}
|
|
383
|
+
# Sort by id (ULID) ascending → roughly time-ascending.
|
|
384
|
+
new_entries = sorted(
|
|
385
|
+
(e for e in all_entries if e.id not in meta.seen_l2_entry_ids.get(surface, set())),
|
|
386
|
+
key=lambda e: e.id,
|
|
387
|
+
)
|
|
388
|
+
entries_by_surface[surface] = new_entries
|
|
389
|
+
|
|
390
|
+
new_count = sum(len(v) for v in entries_by_surface.values())
|
|
391
|
+
total_count = sum(len(d.all_entries()) for d in l2_docs.values())
|
|
392
|
+
await emit(
|
|
393
|
+
on_event,
|
|
394
|
+
{
|
|
395
|
+
"stage": "trace_loaded",
|
|
396
|
+
"slot": slot,
|
|
397
|
+
"total_l2_entries": total_count,
|
|
398
|
+
"new_l2_entries": new_count,
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if new_count == 0:
|
|
403
|
+
save_l3_meta(slot, seen_l2_entry_ids=seen_now)
|
|
404
|
+
if settings.merge.auto_after_update:
|
|
405
|
+
from deeptutor.services.memory.consolidator.modes.merge import run_merge
|
|
406
|
+
|
|
407
|
+
await run_merge(
|
|
408
|
+
"L3",
|
|
409
|
+
slot,
|
|
410
|
+
language=language,
|
|
411
|
+
user_label=user_label,
|
|
412
|
+
on_event=on_event,
|
|
413
|
+
)
|
|
414
|
+
await emit(on_event, {"stage": "done", "no_new_input": True, "facts_added": 0})
|
|
415
|
+
return UpdateResult(
|
|
416
|
+
layer="L3",
|
|
417
|
+
key=slot,
|
|
418
|
+
chunks_processed=0,
|
|
419
|
+
facts_added=0,
|
|
420
|
+
refs_dropped=0,
|
|
421
|
+
no_new_input=True,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
text = render_l2_entries_for_concat(entries_by_surface)
|
|
425
|
+
chunks = chunk_with_boundary(
|
|
426
|
+
text,
|
|
427
|
+
budget=budget,
|
|
428
|
+
overlap_ratio=settings.chunking.overlap_ratio,
|
|
429
|
+
min_chunk_chars=settings.chunking.min_chunk_chars,
|
|
430
|
+
max_chunk_chars=settings.chunking.max_chunk_chars,
|
|
431
|
+
boundary=settings.chunking.boundary,
|
|
432
|
+
)
|
|
433
|
+
await emit(
|
|
434
|
+
on_event,
|
|
435
|
+
{"stage": "chunked", "chunks": len(chunks), "budget": budget, "chars": len(text)},
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
prompt = load_prompt("update_l3", language)
|
|
439
|
+
focus, sections = slot_focus(language, slot)
|
|
440
|
+
l3_path = paths.l3_file(slot)
|
|
441
|
+
doc = load_doc(l3_path, default_title=_default_l3_title(slot))
|
|
442
|
+
|
|
443
|
+
facts_added = 0
|
|
444
|
+
refs_dropped = 0
|
|
445
|
+
new_entry_ids: list[str] = []
|
|
446
|
+
|
|
447
|
+
for chunk in chunks:
|
|
448
|
+
await emit(
|
|
449
|
+
on_event,
|
|
450
|
+
{
|
|
451
|
+
"stage": "progress",
|
|
452
|
+
"mode": "update",
|
|
453
|
+
"turn": chunk.index + 1,
|
|
454
|
+
"total": len(chunks),
|
|
455
|
+
"chunk_start": chunk.start,
|
|
456
|
+
"chunk_end": chunk.end,
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
system = prompt["system"].format(
|
|
460
|
+
user_label=user_label,
|
|
461
|
+
slot=slot,
|
|
462
|
+
sections=", ".join(sections) if sections else "(any)",
|
|
463
|
+
focus=focus,
|
|
464
|
+
today=today_iso(),
|
|
465
|
+
)
|
|
466
|
+
# L3 refs are *surface names* (chat / notebook / ...). The pool
|
|
467
|
+
# is whichever surface blocks intersect this chunk; the LLM is
|
|
468
|
+
# told to cite from that list. Per-entry-id provenance was
|
|
469
|
+
# explicitly dropped — L3 points at L2 *files*, not L2 entries,
|
|
470
|
+
# which gives the user a clean 7-footnote chain
|
|
471
|
+
# (L3 → L2 md → L1 raw traces).
|
|
472
|
+
allowed = refs_in_span_l3(
|
|
473
|
+
entries_by_surface=entries_by_surface,
|
|
474
|
+
full_text=text,
|
|
475
|
+
start=chunk.start,
|
|
476
|
+
end=chunk.end,
|
|
477
|
+
)
|
|
478
|
+
user = prompt["user"].format(
|
|
479
|
+
slot=slot,
|
|
480
|
+
existing=_render_existing_l3(doc),
|
|
481
|
+
chunk=_chunk_with_ref_header(chunk.text, allowed),
|
|
482
|
+
chunk_index=chunk.index + 1,
|
|
483
|
+
chunk_total=len(chunks),
|
|
484
|
+
)
|
|
485
|
+
raw = await call_llm(
|
|
486
|
+
system_prompt=system,
|
|
487
|
+
user_prompt=user,
|
|
488
|
+
on_event=on_event,
|
|
489
|
+
turn=chunk.index + 1,
|
|
490
|
+
chunk_index=chunk.index,
|
|
491
|
+
label="update",
|
|
492
|
+
)
|
|
493
|
+
facts = _parse_facts(raw)
|
|
494
|
+
|
|
495
|
+
kept_in_chunk: list[ExtractedFact] = []
|
|
496
|
+
for fact in facts:
|
|
497
|
+
kept_refs, reject_reason = validate_fact_refs(
|
|
498
|
+
fact,
|
|
499
|
+
allowed=allowed,
|
|
500
|
+
enforce_required=settings.reference.enforce_required,
|
|
501
|
+
drop_invalid=settings.reference.drop_invalid_refs,
|
|
502
|
+
)
|
|
503
|
+
if reject_reason is not None:
|
|
504
|
+
refs_dropped += 1
|
|
505
|
+
await emit(
|
|
506
|
+
on_event,
|
|
507
|
+
{
|
|
508
|
+
"stage": "refs_dropped",
|
|
509
|
+
"turn": chunk.index + 1,
|
|
510
|
+
"reason": reject_reason,
|
|
511
|
+
"text": fact.text[:120],
|
|
512
|
+
},
|
|
513
|
+
)
|
|
514
|
+
continue
|
|
515
|
+
kept_in_chunk.append(
|
|
516
|
+
ExtractedFact(text=fact.text, refs=kept_refs, section=fact.section)
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
added_now = _append_facts_to_doc(doc, kept_in_chunk, sections)
|
|
520
|
+
facts_added += len(added_now)
|
|
521
|
+
new_entry_ids.extend(added_now)
|
|
522
|
+
if added_now:
|
|
523
|
+
await write_doc_checkpoint(
|
|
524
|
+
l3_path,
|
|
525
|
+
doc,
|
|
526
|
+
layer="L3",
|
|
527
|
+
key=slot,
|
|
528
|
+
on_event=on_event,
|
|
529
|
+
turn=chunk.index + 1,
|
|
530
|
+
label="update",
|
|
531
|
+
action="append_facts",
|
|
532
|
+
)
|
|
533
|
+
await emit(
|
|
534
|
+
on_event,
|
|
535
|
+
{
|
|
536
|
+
"stage": "facts_extracted",
|
|
537
|
+
"turn": chunk.index + 1,
|
|
538
|
+
"kept": len(kept_in_chunk),
|
|
539
|
+
"added": len(added_now),
|
|
540
|
+
},
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
save_l3_meta(slot, seen_l2_entry_ids=seen_now)
|
|
544
|
+
await emit(
|
|
545
|
+
on_event,
|
|
546
|
+
{
|
|
547
|
+
"stage": "done",
|
|
548
|
+
"facts_added": facts_added,
|
|
549
|
+
"refs_dropped": refs_dropped,
|
|
550
|
+
"chunks_processed": len(chunks),
|
|
551
|
+
"auto_dedup": settings.dedup.auto_after_update,
|
|
552
|
+
},
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
if settings.dedup.auto_after_update and facts_added > 0:
|
|
556
|
+
from deeptutor.services.memory.consolidator.modes.dedup import run_dedup
|
|
557
|
+
|
|
558
|
+
await run_dedup(
|
|
559
|
+
"L3",
|
|
560
|
+
slot,
|
|
561
|
+
language=language,
|
|
562
|
+
user_label=user_label,
|
|
563
|
+
iterations=settings.dedup.iterations,
|
|
564
|
+
llm_selection=llm_selection,
|
|
565
|
+
on_event=on_event,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
if settings.merge.auto_after_update:
|
|
569
|
+
from deeptutor.services.memory.consolidator.modes.merge import run_merge
|
|
570
|
+
|
|
571
|
+
await run_merge(
|
|
572
|
+
"L3",
|
|
573
|
+
slot,
|
|
574
|
+
language=language,
|
|
575
|
+
user_label=user_label,
|
|
576
|
+
on_event=on_event,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
return UpdateResult(
|
|
580
|
+
layer="L3",
|
|
581
|
+
key=slot,
|
|
582
|
+
chunks_processed=len(chunks),
|
|
583
|
+
facts_added=facts_added,
|
|
584
|
+
refs_dropped=refs_dropped,
|
|
585
|
+
new_entry_ids=new_entry_ids,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ── Helpers ─────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _parse_facts(raw: str) -> list[ExtractedFact]:
|
|
593
|
+
"""Tolerant JSON parse → list[ExtractedFact]. Empty on any failure."""
|
|
594
|
+
if not raw:
|
|
595
|
+
return []
|
|
596
|
+
snippet = _extract_json_object(raw)
|
|
597
|
+
if snippet is None:
|
|
598
|
+
return []
|
|
599
|
+
try:
|
|
600
|
+
data = json.loads(snippet)
|
|
601
|
+
except json.JSONDecodeError:
|
|
602
|
+
return []
|
|
603
|
+
items = data.get("facts") if isinstance(data, dict) else None
|
|
604
|
+
if not isinstance(items, list):
|
|
605
|
+
return []
|
|
606
|
+
out: list[ExtractedFact] = []
|
|
607
|
+
for item in items:
|
|
608
|
+
if not isinstance(item, dict):
|
|
609
|
+
continue
|
|
610
|
+
text = str(item.get("text", "")).strip()
|
|
611
|
+
section = str(item.get("section", "")).strip()
|
|
612
|
+
refs_raw = item.get("refs", [])
|
|
613
|
+
refs = [str(r).strip() for r in (refs_raw if isinstance(refs_raw, list) else []) if r]
|
|
614
|
+
if not text:
|
|
615
|
+
continue
|
|
616
|
+
out.append(ExtractedFact(text=text, refs=refs, section=section))
|
|
617
|
+
return out
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _extract_json_object(raw: str) -> str | None:
|
|
621
|
+
text = raw.strip()
|
|
622
|
+
if text.startswith("```"):
|
|
623
|
+
text = text.split("\n", 1)[1] if "\n" in text else text
|
|
624
|
+
if text.rstrip().endswith("```"):
|
|
625
|
+
text = text.rstrip()[:-3]
|
|
626
|
+
start = text.find("{")
|
|
627
|
+
if start == -1:
|
|
628
|
+
return None
|
|
629
|
+
try:
|
|
630
|
+
_parsed, end = json.JSONDecoder().raw_decode(text[start:])
|
|
631
|
+
except json.JSONDecodeError:
|
|
632
|
+
return None
|
|
633
|
+
return text[start : start + end]
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _append_facts_to_doc(
|
|
637
|
+
doc: Document, facts: list[ExtractedFact], allowed_sections: list[str]
|
|
638
|
+
) -> list[str]:
|
|
639
|
+
"""Append each fact as one AddOp; return the new entry ids."""
|
|
640
|
+
new_ids: list[str] = []
|
|
641
|
+
fallback_section = allowed_sections[0] if allowed_sections else "Notes"
|
|
642
|
+
for fact in facts:
|
|
643
|
+
section = fact.section if fact.section else fallback_section
|
|
644
|
+
if allowed_sections and section not in allowed_sections:
|
|
645
|
+
# Map an off-list section into the first allowed one — keeps
|
|
646
|
+
# the section catalog stable across runs.
|
|
647
|
+
section = fallback_section
|
|
648
|
+
op = AddOp(section=section, text=fact.text, refs=fact.refs)
|
|
649
|
+
report = apply_ops(doc, [op])
|
|
650
|
+
if report.accepted and report.results:
|
|
651
|
+
new_id = report.results[0].entry_id
|
|
652
|
+
if new_id:
|
|
653
|
+
new_ids.append(new_id)
|
|
654
|
+
else:
|
|
655
|
+
logger.warning(
|
|
656
|
+
"update: skipped fact (%s): %s",
|
|
657
|
+
report.reason,
|
|
658
|
+
fact.text[:80],
|
|
659
|
+
)
|
|
660
|
+
return new_ids
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _render_existing_l2(doc: Document) -> str:
|
|
664
|
+
if not doc.all_entries():
|
|
665
|
+
return "(empty — first run)"
|
|
666
|
+
return serialize(doc).strip()
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _render_existing_l3(doc: Document) -> str:
|
|
670
|
+
if not doc.all_entries():
|
|
671
|
+
return "(empty — first run)"
|
|
672
|
+
return serialize(doc).strip()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _chunk_with_ref_header(chunk_text: str, allowed: set[str]) -> str:
|
|
676
|
+
if not allowed:
|
|
677
|
+
return chunk_text
|
|
678
|
+
refs = "\n".join(f"- {ref}" for ref in sorted(allowed))
|
|
679
|
+
return f"# Chunk-local citeable refs\n{refs}\n\n{chunk_text}"
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _load_all_l2_docs() -> dict[str, Document]:
|
|
683
|
+
from deeptutor.services.memory.document import parse
|
|
684
|
+
|
|
685
|
+
docs: dict[str, Document] = {}
|
|
686
|
+
for surface in paths.SURFACES:
|
|
687
|
+
path = paths.l2_file(surface)
|
|
688
|
+
if not path.exists():
|
|
689
|
+
continue
|
|
690
|
+
try:
|
|
691
|
+
docs[surface] = parse(path.read_text(encoding="utf-8"))
|
|
692
|
+
except Exception:
|
|
693
|
+
continue
|
|
694
|
+
return docs
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _default_l3_title(slot: L3Slot) -> str:
|
|
698
|
+
return {
|
|
699
|
+
"recent": "Recent summary",
|
|
700
|
+
"profile": "User profile",
|
|
701
|
+
"scope": "Knowledge scope",
|
|
702
|
+
"preferences": "Preferences",
|
|
703
|
+
}[slot]
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
__all__ = ["UpdateResult", "run_update"]
|