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,237 @@
|
|
|
1
|
+
"""Reading memory back — what the learner has been doing lately.
|
|
2
|
+
|
|
3
|
+
L1 keeps two kinds of record, and answering "what happened lately" needs both:
|
|
4
|
+
|
|
5
|
+
* the **snapshot** knows what *exists* — every chat session, notebook record,
|
|
6
|
+
quiz attempt, document, book and knowledge base, each with a label and a
|
|
7
|
+
timestamp. :func:`recent` reads that.
|
|
8
|
+
* the **trace** knows what was *done* — the events surfaces emit as they run.
|
|
9
|
+
:func:`recent_queries` reads the one kind that records the learner asking
|
|
10
|
+
something in their own words.
|
|
11
|
+
|
|
12
|
+
Both run on stamps: no message body, no document text, no answer is read. That
|
|
13
|
+
is what makes them safe to call from an interactive path rather than only from
|
|
14
|
+
consolidation, which is the whole reason this module exists separately from
|
|
15
|
+
:mod:`deeptutor.services.memory.store`.
|
|
16
|
+
|
|
17
|
+
Every hit carries ``days_ago`` rather than leaving an ISO timestamp for the
|
|
18
|
+
caller to subtract. A model asked to do date arithmetic on
|
|
19
|
+
``2026-08-16T11:03:00+00:00`` will sometimes get it wrong, and "how long ago"
|
|
20
|
+
is the entire basis on which anything decides whether an item is still worth
|
|
21
|
+
raising.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any, Iterable
|
|
30
|
+
|
|
31
|
+
from deeptutor.services.memory.paths import SURFACES, Surface
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_DEFAULT_LIMIT = 20
|
|
36
|
+
_MAX_LIMIT = 100
|
|
37
|
+
# A week: long enough to survive a couple of quiet days, short enough that what
|
|
38
|
+
# comes back is still recognisably "lately".
|
|
39
|
+
_DEFAULT_DAYS = 7
|
|
40
|
+
# A label is a title, a question or a filename. Anything longer is a body that
|
|
41
|
+
# leaked into one, and carrying it would defeat the point of stamps-only reads.
|
|
42
|
+
_MAX_LABEL_CHARS = 200
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class RecallHit:
|
|
47
|
+
"""One thing the learner touched, shaped for a caller to act on."""
|
|
48
|
+
|
|
49
|
+
surface: str
|
|
50
|
+
label: str
|
|
51
|
+
ts: str
|
|
52
|
+
days_ago: int | None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"surface": self.surface,
|
|
57
|
+
"label": self.label,
|
|
58
|
+
"ts": self.ts,
|
|
59
|
+
"days_ago": self.days_ago,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Time ─────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _now() -> datetime:
|
|
67
|
+
return datetime.now(tz=timezone.utc)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_ts(ts: str) -> datetime | None:
|
|
71
|
+
if not ts:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
parsed = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def days_ago(ts: str, *, now: datetime | None = None) -> int | None:
|
|
81
|
+
"""Whole days between ``ts`` and now, or ``None`` for an unusable stamp."""
|
|
82
|
+
parsed = _parse_ts(ts)
|
|
83
|
+
if parsed is None:
|
|
84
|
+
return None
|
|
85
|
+
delta = (now or _now()) - parsed
|
|
86
|
+
# A future stamp (clock skew on an imported record) reads as today rather
|
|
87
|
+
# than as a negative age.
|
|
88
|
+
return max(0, delta.days)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cutoff(days: int | None, now: datetime) -> datetime | None:
|
|
92
|
+
if days is None or days < 0:
|
|
93
|
+
return None
|
|
94
|
+
return now - timedelta(days=days)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Shaping ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _clean_label(raw: str) -> str:
|
|
101
|
+
"""One line, bounded. Empty when there is nothing worth showing."""
|
|
102
|
+
text = " ".join(str(raw or "").split())
|
|
103
|
+
return text[:_MAX_LABEL_CHARS]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _resolve_surfaces(surfaces: Iterable[str] | None) -> tuple[Surface, ...]:
|
|
107
|
+
if not surfaces:
|
|
108
|
+
return SURFACES
|
|
109
|
+
wanted = {str(surface).strip().lower() for surface in surfaces}
|
|
110
|
+
resolved = tuple(surface for surface in SURFACES if surface in wanted)
|
|
111
|
+
return resolved or SURFACES
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _clamp_limit(limit: int | None) -> int:
|
|
115
|
+
if not limit or limit < 1:
|
|
116
|
+
return _DEFAULT_LIMIT
|
|
117
|
+
return min(int(limit), _MAX_LIMIT)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _newest_first(hits: list[RecallHit]) -> list[RecallHit]:
|
|
121
|
+
"""Sort newest first, dropping repeats of the same label on the same surface.
|
|
122
|
+
|
|
123
|
+
A knowledge base queried five times, or a session reopened all afternoon,
|
|
124
|
+
is one thing the learner is working on — not five. Keeping the duplicates
|
|
125
|
+
would let a single topic crowd out everything else downstream.
|
|
126
|
+
"""
|
|
127
|
+
hits.sort(key=lambda hit: hit.ts, reverse=True)
|
|
128
|
+
seen: set[tuple[str, str]] = set()
|
|
129
|
+
deduped: list[RecallHit] = []
|
|
130
|
+
for hit in hits:
|
|
131
|
+
key = (hit.surface, hit.label.casefold())
|
|
132
|
+
if key in seen:
|
|
133
|
+
continue
|
|
134
|
+
seen.add(key)
|
|
135
|
+
deduped.append(hit)
|
|
136
|
+
return deduped
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── What exists (snapshot) ───────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def recent(
|
|
143
|
+
*,
|
|
144
|
+
days: int | None = _DEFAULT_DAYS,
|
|
145
|
+
limit: int | None = _DEFAULT_LIMIT,
|
|
146
|
+
surfaces: Iterable[str] | None = None,
|
|
147
|
+
) -> list[RecallHit]:
|
|
148
|
+
"""The latest workspace activity across surfaces, newest first.
|
|
149
|
+
|
|
150
|
+
Stamps only — no content is read, so this stays cheap enough to call on a
|
|
151
|
+
page load.
|
|
152
|
+
|
|
153
|
+
Items without a usable timestamp are dropped rather than kept: placing an
|
|
154
|
+
item of unknown age inside "the last seven days" asserts something the data
|
|
155
|
+
does not support. This notably excludes knowledge bases, whose snapshot
|
|
156
|
+
timestamp is their *earliest* index time — a KB in daily use would look
|
|
157
|
+
stale here. What the learner has actually been asking a KB lives in the
|
|
158
|
+
trace instead; see :func:`recent_queries`.
|
|
159
|
+
"""
|
|
160
|
+
from deeptutor.services.memory.snapshot import adapters
|
|
161
|
+
|
|
162
|
+
now = _now()
|
|
163
|
+
cutoff = _cutoff(days, now)
|
|
164
|
+
hits: list[RecallHit] = []
|
|
165
|
+
for surface in _resolve_surfaces(surfaces):
|
|
166
|
+
try:
|
|
167
|
+
stamps = adapters.read_stamps(surface)
|
|
168
|
+
except Exception:
|
|
169
|
+
logger.warning("recall.recent: stamps failed surface=%s", surface, exc_info=True)
|
|
170
|
+
continue
|
|
171
|
+
for stamp in stamps:
|
|
172
|
+
parsed = _parse_ts(stamp.ts)
|
|
173
|
+
if parsed is None or (cutoff is not None and parsed < cutoff):
|
|
174
|
+
continue
|
|
175
|
+
label = _clean_label(stamp.label)
|
|
176
|
+
if not label:
|
|
177
|
+
continue
|
|
178
|
+
hits.append(
|
|
179
|
+
RecallHit(
|
|
180
|
+
surface=surface,
|
|
181
|
+
label=label,
|
|
182
|
+
ts=stamp.ts,
|
|
183
|
+
days_ago=days_ago(stamp.ts, now=now),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return _newest_first(hits)[: _clamp_limit(limit)]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ── What was asked (trace) ───────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
# The trace kinds that carry a learner-authored question. Knowledge-base
|
|
192
|
+
# queries are the only ones today: the other emitters record system-side facts
|
|
193
|
+
# (a stated preference, a partner turn), not something the learner typed as a
|
|
194
|
+
# question.
|
|
195
|
+
_QUERY_KINDS: dict[Surface, str] = {"kb": "query"}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def recent_queries(
|
|
199
|
+
*,
|
|
200
|
+
days: int | None = _DEFAULT_DAYS,
|
|
201
|
+
limit: int | None = _DEFAULT_LIMIT,
|
|
202
|
+
) -> list[RecallHit]:
|
|
203
|
+
"""Searches the learner ran lately, in their own words, newest first.
|
|
204
|
+
|
|
205
|
+
Complements :func:`recent`: the snapshot can say a knowledge base exists,
|
|
206
|
+
but only the trace records that someone asked it about eigenvalues on
|
|
207
|
+
Tuesday.
|
|
208
|
+
"""
|
|
209
|
+
from deeptutor.services.memory import trace
|
|
210
|
+
|
|
211
|
+
now = _now()
|
|
212
|
+
cutoff = _cutoff(days, now)
|
|
213
|
+
hits: list[RecallHit] = []
|
|
214
|
+
for surface, kind in _QUERY_KINDS.items():
|
|
215
|
+
try:
|
|
216
|
+
events = trace.iter_since(surface, cutoff)
|
|
217
|
+
except Exception:
|
|
218
|
+
logger.warning("recall.recent_queries: failed surface=%s", surface, exc_info=True)
|
|
219
|
+
continue
|
|
220
|
+
for event in events:
|
|
221
|
+
if event.kind != kind:
|
|
222
|
+
continue
|
|
223
|
+
label = _clean_label(str((event.payload or {}).get("query") or ""))
|
|
224
|
+
if not label:
|
|
225
|
+
continue
|
|
226
|
+
hits.append(
|
|
227
|
+
RecallHit(
|
|
228
|
+
surface=surface,
|
|
229
|
+
label=label,
|
|
230
|
+
ts=event.ts,
|
|
231
|
+
days_ago=days_ago(event.ts, now=now),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return _newest_first(hits)[: _clamp_limit(limit)]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = ["RecallHit", "days_ago", "recent", "recent_queries"]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""User-tunable parameters for the memory consolidator.
|
|
2
|
+
|
|
3
|
+
Single source of truth is ``data/user/settings/main.yaml`` under the
|
|
4
|
+
``memory:`` subtree. Defaults live here. The frontend ``/settings/memory``
|
|
5
|
+
page reads/writes the same subtree via the API.
|
|
6
|
+
|
|
7
|
+
Decoupled from the algorithm code: every mode picks values up via
|
|
8
|
+
:func:`load_memory_settings`, never via module-level constants.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from deeptutor.utils.config_manager import ConfigManager
|
|
17
|
+
|
|
18
|
+
_SETTINGS_KEY = "memory"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class UpdateSettings:
|
|
23
|
+
l2_budget: int = 20
|
|
24
|
+
l3_budget: int = 10
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AuditSettings:
|
|
29
|
+
l2_budget: int = 20
|
|
30
|
+
l3_budget: int = 10
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class DedupSettings:
|
|
35
|
+
iterations: int = 3
|
|
36
|
+
auto_after_update: bool = True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class MergeSettings:
|
|
41
|
+
"""No-LLM footnote consolidation (collapse duplicate refs into one footnote each)."""
|
|
42
|
+
|
|
43
|
+
auto_after_update: bool = True
|
|
44
|
+
auto_after_audit: bool = True
|
|
45
|
+
auto_after_dedup: bool = True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ChunkingSettings:
|
|
50
|
+
overlap_ratio: float = 0.10
|
|
51
|
+
boundary: Literal["paragraph", "sentence"] = "paragraph"
|
|
52
|
+
min_chunk_chars: int = 1000
|
|
53
|
+
max_chunk_chars: int = 64000
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ReferenceSettings:
|
|
58
|
+
enforce_required: bool = True
|
|
59
|
+
drop_invalid_refs: bool = True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class MemorySettings:
|
|
64
|
+
update: UpdateSettings = field(default_factory=UpdateSettings)
|
|
65
|
+
audit: AuditSettings = field(default_factory=AuditSettings)
|
|
66
|
+
dedup: DedupSettings = field(default_factory=DedupSettings)
|
|
67
|
+
merge: MergeSettings = field(default_factory=MergeSettings)
|
|
68
|
+
chunking: ChunkingSettings = field(default_factory=ChunkingSettings)
|
|
69
|
+
reference: ReferenceSettings = field(default_factory=ReferenceSettings)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_memory_settings() -> MemorySettings:
|
|
73
|
+
"""Return the current ``memory:`` subtree merged on top of defaults.
|
|
74
|
+
|
|
75
|
+
Missing keys fall back to defaults. Out-of-range numeric values are
|
|
76
|
+
clamped to safe ranges so a malformed YAML never crashes a run.
|
|
77
|
+
"""
|
|
78
|
+
raw = ConfigManager().load_config().get(_SETTINGS_KEY) or {}
|
|
79
|
+
return _from_dict(MemorySettings, raw)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def save_memory_settings(payload: dict[str, Any]) -> MemorySettings:
|
|
83
|
+
"""Merge ``payload`` into the on-disk ``memory:`` subtree.
|
|
84
|
+
|
|
85
|
+
Unknown keys are dropped; values are coerced to the schema's types
|
|
86
|
+
so the YAML never picks up junk. Returns the post-merge settings.
|
|
87
|
+
"""
|
|
88
|
+
merged = _from_dict(MemorySettings, payload)
|
|
89
|
+
coerced = asdict(merged)
|
|
90
|
+
ConfigManager().save_config({_SETTINGS_KEY: coerced})
|
|
91
|
+
return merged
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def memory_settings_dict() -> dict[str, Any]:
|
|
95
|
+
"""Settings as a plain dict — JSON-safe for the API response."""
|
|
96
|
+
return asdict(load_memory_settings())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Coercion + clamping ─────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_MIN_BUDGET = 1
|
|
103
|
+
_MAX_BUDGET = 200
|
|
104
|
+
_MIN_DEDUP_ITER = 1
|
|
105
|
+
_MAX_DEDUP_ITER = 20
|
|
106
|
+
_MIN_OVERLAP = 0.0
|
|
107
|
+
_MAX_OVERLAP = 0.5
|
|
108
|
+
_MIN_CHUNK_CHARS = 200
|
|
109
|
+
_MAX_CHUNK_CHARS = 64000
|
|
110
|
+
_BOUNDARIES = ("paragraph", "sentence")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _from_dict(cls: type, raw: Any) -> Any:
|
|
114
|
+
"""Build a frozen dataclass from a partial dict.
|
|
115
|
+
|
|
116
|
+
Strategy: walk fields, if a field is itself a dataclass and the input
|
|
117
|
+
has a matching dict, recurse. Otherwise coerce + clamp. Defaults fill
|
|
118
|
+
any missing field.
|
|
119
|
+
"""
|
|
120
|
+
if not is_dataclass(cls):
|
|
121
|
+
raise TypeError(f"{cls!r} is not a dataclass")
|
|
122
|
+
|
|
123
|
+
instance_defaults = cls() # type: ignore[call-arg]
|
|
124
|
+
if not isinstance(raw, dict):
|
|
125
|
+
return instance_defaults
|
|
126
|
+
|
|
127
|
+
kwargs: dict[str, Any] = {}
|
|
128
|
+
for f in fields(cls):
|
|
129
|
+
provided = raw.get(f.name)
|
|
130
|
+
default = getattr(instance_defaults, f.name)
|
|
131
|
+
if isinstance(f.type, type) and is_dataclass(f.type):
|
|
132
|
+
kwargs[f.name] = _from_dict(f.type, provided) if provided is not None else default
|
|
133
|
+
continue
|
|
134
|
+
# nested dataclass detection through the actual default type
|
|
135
|
+
if is_dataclass(default):
|
|
136
|
+
kwargs[f.name] = (
|
|
137
|
+
_from_dict(type(default), provided) if provided is not None else default
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
kwargs[f.name] = _coerce_scalar(f.name, provided, default)
|
|
141
|
+
return cls(**kwargs)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _coerce_scalar(name: str, raw: Any, default: Any) -> Any:
|
|
145
|
+
if raw is None:
|
|
146
|
+
return default
|
|
147
|
+
if isinstance(default, bool):
|
|
148
|
+
return bool(raw)
|
|
149
|
+
if isinstance(default, int):
|
|
150
|
+
try:
|
|
151
|
+
int_value = int(raw)
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
return default
|
|
154
|
+
return _clamp_int(name, int_value, default)
|
|
155
|
+
if isinstance(default, float):
|
|
156
|
+
try:
|
|
157
|
+
float_value = float(raw)
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
return default
|
|
160
|
+
return _clamp_float(name, float_value, default)
|
|
161
|
+
if isinstance(default, str):
|
|
162
|
+
str_value = str(raw)
|
|
163
|
+
if name == "boundary" and str_value not in _BOUNDARIES:
|
|
164
|
+
return default
|
|
165
|
+
return str_value
|
|
166
|
+
return raw
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _clamp_int(name: str, value: int, default: int) -> int:
|
|
170
|
+
if name.endswith("budget"):
|
|
171
|
+
return max(_MIN_BUDGET, min(_MAX_BUDGET, value))
|
|
172
|
+
if name == "iterations":
|
|
173
|
+
return max(_MIN_DEDUP_ITER, min(_MAX_DEDUP_ITER, value))
|
|
174
|
+
if name == "min_chunk_chars":
|
|
175
|
+
return max(_MIN_CHUNK_CHARS, min(_MAX_CHUNK_CHARS, value))
|
|
176
|
+
if name == "max_chunk_chars":
|
|
177
|
+
return max(_MIN_CHUNK_CHARS, min(_MAX_CHUNK_CHARS, value))
|
|
178
|
+
return max(0, value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _clamp_float(name: str, value: float, default: float) -> float:
|
|
182
|
+
if name == "overlap_ratio":
|
|
183
|
+
return max(_MIN_OVERLAP, min(_MAX_OVERLAP, value))
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = [
|
|
188
|
+
"AuditSettings",
|
|
189
|
+
"ChunkingSettings",
|
|
190
|
+
"DedupSettings",
|
|
191
|
+
"MemorySettings",
|
|
192
|
+
"MergeSettings",
|
|
193
|
+
"ReferenceSettings",
|
|
194
|
+
"UpdateSettings",
|
|
195
|
+
"load_memory_settings",
|
|
196
|
+
"memory_settings_dict",
|
|
197
|
+
"save_memory_settings",
|
|
198
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Workspace snapshot subsystem for L1 memory.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
- :func:`read_snapshot` — current entities for a surface (no I/O on
|
|
6
|
+
``state.json`` / ``changes.jsonl``).
|
|
7
|
+
- :func:`read_stamps` — the same set without content: identity, label,
|
|
8
|
+
fingerprint, timestamp. What the diff and "what happened lately" both
|
|
9
|
+
actually need.
|
|
10
|
+
- :func:`refresh_snapshot` — re-read workspace, diff against last
|
|
11
|
+
persisted state, append changes, persist new state. Returns the
|
|
12
|
+
computed change list. Idempotent: a no-change refresh writes nothing
|
|
13
|
+
to the changes log.
|
|
14
|
+
- :func:`read_changes` — paginated history of past refreshes for one
|
|
15
|
+
surface (git-log-style display source).
|
|
16
|
+
- :func:`current_state` — the persisted ``state.json`` for a surface
|
|
17
|
+
(consolidator uses ``last_refresh`` to gate L2 updates).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import Iterable, Sequence
|
|
24
|
+
|
|
25
|
+
from deeptutor.services.memory.paths import Surface
|
|
26
|
+
from deeptutor.services.memory.snapshot import adapters, store
|
|
27
|
+
from deeptutor.services.memory.snapshot.diff import diff_snapshots
|
|
28
|
+
from deeptutor.services.memory.snapshot.entity import (
|
|
29
|
+
ChangeEntry,
|
|
30
|
+
Entity,
|
|
31
|
+
EntityStamp,
|
|
32
|
+
Stamped,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_snapshot(surface: Surface) -> list[Entity]:
|
|
37
|
+
return adapters.read_entities(surface)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_stamps(surface: Surface) -> list[EntityStamp]:
|
|
41
|
+
"""Diff-shaped view of a surface, without its content. See :mod:`.adapters`."""
|
|
42
|
+
return adapters.read_stamps(surface)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pending_changes(
|
|
46
|
+
surface: Surface, entities: Sequence[Stamped] | None = None
|
|
47
|
+
) -> list[ChangeEntry]:
|
|
48
|
+
"""Compute the diff between current workspace and the last persisted state.
|
|
49
|
+
|
|
50
|
+
Pure / read-only: never writes to ``state.json`` or ``changes.jsonl``.
|
|
51
|
+
Used by the L1 view to show "what would a refresh capture right now".
|
|
52
|
+
|
|
53
|
+
Accepts anything carrying ``id`` / ``label`` / ``fingerprint`` — a caller
|
|
54
|
+
that already has full :class:`Entity` objects (the L1 view, which needs
|
|
55
|
+
their content anyway) passes those; a caller that only needs the diff gets
|
|
56
|
+
the cheap :class:`EntityStamp` path by passing nothing.
|
|
57
|
+
"""
|
|
58
|
+
stamps: Sequence[Stamped] = entities if entities is not None else adapters.read_stamps(surface)
|
|
59
|
+
curr_fp = {e.id: e.fingerprint for e in stamps}
|
|
60
|
+
curr_labels = {e.id: e.label for e in stamps}
|
|
61
|
+
|
|
62
|
+
prev = store.load_state(surface)
|
|
63
|
+
prev_fp = prev.get("fingerprints") or {}
|
|
64
|
+
prev_labels = prev.get("labels") or {}
|
|
65
|
+
|
|
66
|
+
return diff_snapshots(
|
|
67
|
+
prev_fp,
|
|
68
|
+
curr_fp,
|
|
69
|
+
label_map=curr_labels,
|
|
70
|
+
prev_label_map=prev_labels,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def refresh_snapshot(surface: Surface) -> list[ChangeEntry]:
|
|
75
|
+
"""Reconcile persisted state with the workspace and log what moved.
|
|
76
|
+
|
|
77
|
+
Runs on stamps, not full entities: a refresh only ever writes fingerprints
|
|
78
|
+
and labels, so reading message bodies here would be pure waste.
|
|
79
|
+
"""
|
|
80
|
+
stamps = adapters.read_stamps(surface)
|
|
81
|
+
changes = pending_changes(surface, stamps)
|
|
82
|
+
curr_fp = {e.id: e.fingerprint for e in stamps}
|
|
83
|
+
curr_labels = {e.id: e.label for e in stamps}
|
|
84
|
+
store.append_changes(surface, changes)
|
|
85
|
+
store.save_state(
|
|
86
|
+
surface,
|
|
87
|
+
fingerprints=curr_fp,
|
|
88
|
+
labels=curr_labels,
|
|
89
|
+
last_refresh=datetime.now(tz=timezone.utc).isoformat(),
|
|
90
|
+
)
|
|
91
|
+
return changes
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_changes(surface: Surface, *, limit: int = 200, offset: int = 0) -> list[ChangeEntry]:
|
|
95
|
+
bound = max(1, min(limit, 1000))
|
|
96
|
+
all_changes: list[ChangeEntry] = list(store.iter_changes(surface))
|
|
97
|
+
# Most recent first — the file is append-order, reverse it.
|
|
98
|
+
all_changes.reverse()
|
|
99
|
+
return all_changes[offset : offset + bound]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def current_state(surface: Surface) -> dict:
|
|
103
|
+
return store.load_state(surface)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def clear_changes(surface: Surface) -> None:
|
|
107
|
+
store.clear_changes(surface)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"Entity",
|
|
112
|
+
"EntityStamp",
|
|
113
|
+
"Stamped",
|
|
114
|
+
"ChangeEntry",
|
|
115
|
+
"read_snapshot",
|
|
116
|
+
"read_stamps",
|
|
117
|
+
"pending_changes",
|
|
118
|
+
"refresh_snapshot",
|
|
119
|
+
"read_changes",
|
|
120
|
+
"current_state",
|
|
121
|
+
"clear_changes",
|
|
122
|
+
"adapters",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Iterable kept for static-analyzer happiness when consumers import *.
|
|
127
|
+
_: Iterable = []
|