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,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"]