etchmem 0.1.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.
- etchmem/__init__.py +25 -0
- etchmem/config.py +109 -0
- etchmem/connector/__init__.py +0 -0
- etchmem/connector/base.py +35 -0
- etchmem/connector/remote.py +121 -0
- etchmem/consolidate/__init__.py +0 -0
- etchmem/consolidate/worker.py +559 -0
- etchmem/engine.py +191 -0
- etchmem/models.py +147 -0
- etchmem/recall/__init__.py +0 -0
- etchmem/recall/recaller.py +154 -0
- etchmem/stores/__init__.py +0 -0
- etchmem/stores/base.py +171 -0
- etchmem/stores/chroma_store.py +647 -0
- etchmem/util/__init__.py +0 -0
- etchmem/util/affectability.py +104 -0
- etchmem/util/hashing.py +49 -0
- etchmem-0.1.0.dist-info/METADATA +29 -0
- etchmem-0.1.0.dist-info/RECORD +22 -0
- etchmem-0.1.0.dist-info/WHEEL +5 -0
- etchmem-0.1.0.dist-info/licenses/LICENSE +201 -0
- etchmem-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Consolidation Worker — the engine of etchmem.
|
|
3
|
+
|
|
4
|
+
Called by Engine.consolidate(). Runs in-process, single-pass, crash-safe.
|
|
5
|
+
|
|
6
|
+
Pipeline:
|
|
7
|
+
Stage A — Intake: pull relational deposits into buffer (hash-dedup).
|
|
8
|
+
Stage B — Cluster: group buffer docs (deposits + recall-events) by topic.
|
|
9
|
+
Stage C — Evaluate: route each cluster: drop | keep | form | reconsolidate.
|
|
10
|
+
Reconciliation pass: over all candidate new articles + their injected
|
|
11
|
+
neighbours, decide the final set, write, confirm,
|
|
12
|
+
hard-delete superseded articles.
|
|
13
|
+
Flush: remove promoted/dropped/recall-event buffer docs.
|
|
14
|
+
Mid-band deposits (kept) stay.
|
|
15
|
+
|
|
16
|
+
Crash-safety anchor: flush is the LAST operation. A crash before flush
|
|
17
|
+
means the next run sees the full buffer and re-runs cleanly. Source-set
|
|
18
|
+
hashing prevents double-writes; deleting already-deleted ids is a no-op.
|
|
19
|
+
|
|
20
|
+
All LLM inference happens only at formation-promotion and
|
|
21
|
+
reconsolidation-with-new-signal. Everything else is free arithmetic.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from etchmem.config import Config
|
|
31
|
+
from etchmem.connector.base import LLMConnector
|
|
32
|
+
from etchmem.models import (
|
|
33
|
+
Cluster,
|
|
34
|
+
Deposit,
|
|
35
|
+
InjectedArticle,
|
|
36
|
+
RecallEvent,
|
|
37
|
+
SearchResult,
|
|
38
|
+
)
|
|
39
|
+
from etchmem.stores.chroma_store import ChromaStore
|
|
40
|
+
from etchmem.util import affectability as aff_util
|
|
41
|
+
from etchmem.util.hashing import content_hash, source_set_hash
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Synthesis prompts ──────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
_FORMATION_PROMPT = """\
|
|
47
|
+
You are a knowledge distillation engine. Your task is to synthesize a coherent,
|
|
48
|
+
accurate knowledge article from a set of raw signal fragments.
|
|
49
|
+
|
|
50
|
+
## Raw signal fragments
|
|
51
|
+
{fragments}
|
|
52
|
+
|
|
53
|
+
## Instructions
|
|
54
|
+
- Write a single, cohesive knowledge article that captures all key facts.
|
|
55
|
+
- Do NOT invent facts not present in the fragments.
|
|
56
|
+
- At the end of the article, output a JSON block on its own line:
|
|
57
|
+
{{"tags": {{"access_level": "...", "topic": "...", "product": "..."}}}}
|
|
58
|
+
|
|
59
|
+
Output the article first, then the JSON tags block.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
_RECONSOLIDATION_PROMPT = """\
|
|
63
|
+
You are a knowledge reconsolidation engine. An existing knowledge article
|
|
64
|
+
is being updated with fresh context. Rewrite the article so it remains
|
|
65
|
+
accurate and incorporates the new information.
|
|
66
|
+
|
|
67
|
+
## Existing article
|
|
68
|
+
{existing}
|
|
69
|
+
|
|
70
|
+
## Fresh context (new deposits and recall context)
|
|
71
|
+
{fresh}
|
|
72
|
+
|
|
73
|
+
## Instructions
|
|
74
|
+
- Preserve ALL load-bearing facts from the existing article.
|
|
75
|
+
- Incorporate relevant new information from the fresh context.
|
|
76
|
+
- Remove outdated claims only when the fresh context explicitly supersedes them.
|
|
77
|
+
- At the end, output a JSON block on its own line:
|
|
78
|
+
{{"tags": {{"access_level": "...", "topic": "...", "product": "..."}}}}
|
|
79
|
+
|
|
80
|
+
Output the rewritten article first, then the JSON tags block.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
_FACT_RETENTION_CHECK_PROMPT = """\
|
|
84
|
+
Does the NEW article preserve the key facts of the OLD article?
|
|
85
|
+
|
|
86
|
+
## OLD article
|
|
87
|
+
{old}
|
|
88
|
+
|
|
89
|
+
## NEW article
|
|
90
|
+
{new}
|
|
91
|
+
|
|
92
|
+
Reply with exactly one word: YES or NO.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_synthesis_output(raw: str) -> tuple[str, dict]:
|
|
97
|
+
"""Split LLM output into (article_text, tags_dict)."""
|
|
98
|
+
lines = raw.strip().split("\n")
|
|
99
|
+
tags: dict = {}
|
|
100
|
+
article_lines = []
|
|
101
|
+
for line in lines:
|
|
102
|
+
stripped = line.strip()
|
|
103
|
+
if stripped.startswith("{") and "tags" in stripped:
|
|
104
|
+
try:
|
|
105
|
+
parsed = json.loads(stripped)
|
|
106
|
+
tags = parsed.get("tags", {})
|
|
107
|
+
continue
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
pass
|
|
110
|
+
article_lines.append(line)
|
|
111
|
+
article = "\n".join(article_lines).strip()
|
|
112
|
+
return article, tags
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Run summary ───────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class RunSummary:
|
|
119
|
+
intake_new: int = 0 # relational records newly copied to buffer
|
|
120
|
+
intake_skipped: int = 0 # relational records skipped (already in buffer)
|
|
121
|
+
clusters_total: int = 0
|
|
122
|
+
formed: int = 0 # new injected articles created
|
|
123
|
+
reconsolidated: int = 0 # existing articles rewritten
|
|
124
|
+
dropped: int = 0 # buffer records dropped (noise)
|
|
125
|
+
kept: int = 0 # buffer records kept for next run
|
|
126
|
+
superseded: int = 0 # old injected articles hard-deleted
|
|
127
|
+
flushed: int = 0 # buffer records flushed at end
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> dict[str, int]:
|
|
130
|
+
return {
|
|
131
|
+
"intake_new": self.intake_new,
|
|
132
|
+
"intake_skipped": self.intake_skipped,
|
|
133
|
+
"clusters_total": self.clusters_total,
|
|
134
|
+
"formed": self.formed,
|
|
135
|
+
"reconsolidated": self.reconsolidated,
|
|
136
|
+
"dropped": self.dropped,
|
|
137
|
+
"kept": self.kept,
|
|
138
|
+
"superseded": self.superseded,
|
|
139
|
+
"flushed": self.flushed,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Worker ────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
class Worker:
|
|
146
|
+
"""
|
|
147
|
+
Consolidation worker. Instantiated fresh per Engine.consolidate() call.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
relational_store: ChromaStore,
|
|
153
|
+
buffer_store: ChromaStore,
|
|
154
|
+
injected_store: ChromaStore,
|
|
155
|
+
connector: LLMConnector,
|
|
156
|
+
config: Config,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._relational = relational_store
|
|
159
|
+
self._buffer = buffer_store
|
|
160
|
+
self._injected = injected_store
|
|
161
|
+
self._connector = connector
|
|
162
|
+
self._cfg = config
|
|
163
|
+
|
|
164
|
+
def run(self, num_records: int | str, method: str) -> dict[str, Any]:
|
|
165
|
+
summary = RunSummary()
|
|
166
|
+
|
|
167
|
+
# ── Stage A: Intake ───────────────────────────────────────────────
|
|
168
|
+
self._stage_a(num_records, method, summary)
|
|
169
|
+
|
|
170
|
+
# ── Stage B: Cluster ──────────────────────────────────────────────
|
|
171
|
+
all_buffer_docs = self._get_all_buffer_docs()
|
|
172
|
+
if not all_buffer_docs:
|
|
173
|
+
return summary.to_dict()
|
|
174
|
+
|
|
175
|
+
clusters = self._stage_b(all_buffer_docs)
|
|
176
|
+
summary.clusters_total = len(clusters)
|
|
177
|
+
|
|
178
|
+
# ── Stage C: Evaluate + route ─────────────────────────────────────
|
|
179
|
+
# Collect candidate new articles keyed by article_id → InjectedArticle
|
|
180
|
+
# and which old article ids they supersede.
|
|
181
|
+
candidate_articles: dict[str, InjectedArticle] = {}
|
|
182
|
+
# same-run siblings: article_ids that were deliberately branched
|
|
183
|
+
# from the same cluster (excluded from mutual dedup-deletion)
|
|
184
|
+
same_run_sibling_sets: list[frozenset[str]] = []
|
|
185
|
+
|
|
186
|
+
# ids to flush after the reconciliation pass
|
|
187
|
+
to_flush_ids: list[str] = [] # promoted + dropped + recall-events
|
|
188
|
+
to_keep_ids: list[str] = [] # mid-band deposits — NOT flushed
|
|
189
|
+
|
|
190
|
+
for cluster in clusters:
|
|
191
|
+
action, result = self._stage_c(cluster)
|
|
192
|
+
|
|
193
|
+
doc_ids = [d.id for d in cluster.documents]
|
|
194
|
+
|
|
195
|
+
if action == "drop":
|
|
196
|
+
summary.dropped += len(cluster.documents)
|
|
197
|
+
to_flush_ids.extend(doc_ids)
|
|
198
|
+
|
|
199
|
+
elif action == "keep":
|
|
200
|
+
summary.kept += len(cluster.deposits)
|
|
201
|
+
# Recall-events are always flushed even from kept clusters
|
|
202
|
+
to_flush_ids.extend([d.id for d in cluster.recall_events])
|
|
203
|
+
to_keep_ids.extend([d.id for d in cluster.deposits])
|
|
204
|
+
|
|
205
|
+
elif action == "form":
|
|
206
|
+
articles: list[InjectedArticle] = result # type: ignore[assignment]
|
|
207
|
+
if len(articles) > 1:
|
|
208
|
+
same_run_sibling_sets.append(frozenset(a.id for a in articles))
|
|
209
|
+
for article in articles:
|
|
210
|
+
candidate_articles[article.id] = article
|
|
211
|
+
summary.formed += len(articles)
|
|
212
|
+
to_flush_ids.extend(doc_ids)
|
|
213
|
+
|
|
214
|
+
elif action == "reconsolidate":
|
|
215
|
+
articles = result # type: ignore[assignment]
|
|
216
|
+
if len(articles) > 1:
|
|
217
|
+
same_run_sibling_sets.append(frozenset(a.id for a in articles))
|
|
218
|
+
for article in articles:
|
|
219
|
+
candidate_articles[article.id] = article
|
|
220
|
+
summary.reconsolidated += len(articles)
|
|
221
|
+
to_flush_ids.extend(doc_ids)
|
|
222
|
+
|
|
223
|
+
# ── Reconciliation pass ───────────────────────────────────────────
|
|
224
|
+
superseded_ids = self._reconcile(
|
|
225
|
+
candidate_articles=candidate_articles,
|
|
226
|
+
same_run_sibling_sets=same_run_sibling_sets,
|
|
227
|
+
)
|
|
228
|
+
summary.superseded = len(superseded_ids)
|
|
229
|
+
|
|
230
|
+
# Write new articles first, then delete superseded
|
|
231
|
+
articles_to_write = [
|
|
232
|
+
a for aid, a in candidate_articles.items()
|
|
233
|
+
if not self._injected.exists(aid)
|
|
234
|
+
]
|
|
235
|
+
if articles_to_write:
|
|
236
|
+
self._injected.upsert(articles_to_write)
|
|
237
|
+
|
|
238
|
+
if superseded_ids:
|
|
239
|
+
self._injected.delete(superseded_ids)
|
|
240
|
+
|
|
241
|
+
# ── Flush ─────────────────────────────────────────────────────────
|
|
242
|
+
# Flush only after all writes and deletes are confirmed.
|
|
243
|
+
# Mid-band kept deposits are NOT flushed.
|
|
244
|
+
flush_ids = [fid for fid in to_flush_ids if fid not in to_keep_ids]
|
|
245
|
+
if flush_ids:
|
|
246
|
+
self._buffer.delete(flush_ids)
|
|
247
|
+
summary.flushed = len(flush_ids)
|
|
248
|
+
|
|
249
|
+
# Sweep expired relational records
|
|
250
|
+
self._relational.expire()
|
|
251
|
+
|
|
252
|
+
return summary.to_dict()
|
|
253
|
+
|
|
254
|
+
# ── Stage A — Intake ──────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
def _stage_a(
|
|
257
|
+
self,
|
|
258
|
+
num_records: int | str,
|
|
259
|
+
method: str,
|
|
260
|
+
summary: RunSummary,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Pull relational deposits into buffer.
|
|
264
|
+
Hash-dedup: skip if the content hash already exists in the buffer.
|
|
265
|
+
Relational originals are NOT deleted; they expire via TTL.
|
|
266
|
+
"""
|
|
267
|
+
for record in self._relational.iter_for_consolidation(num_records, method):
|
|
268
|
+
if self._buffer.exists(record.id):
|
|
269
|
+
summary.intake_skipped += 1
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
deposit = Deposit(
|
|
273
|
+
id=record.id,
|
|
274
|
+
content=record.content,
|
|
275
|
+
skill=record.skill,
|
|
276
|
+
hint=record.hint,
|
|
277
|
+
metadata=record.metadata,
|
|
278
|
+
created_at=record.created_at,
|
|
279
|
+
)
|
|
280
|
+
self._buffer.upsert([deposit])
|
|
281
|
+
summary.intake_new += 1
|
|
282
|
+
|
|
283
|
+
# ── Stage B — Cluster ─────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
def _stage_b(
|
|
286
|
+
self,
|
|
287
|
+
documents: list[Deposit | RecallEvent],
|
|
288
|
+
) -> list[Cluster]:
|
|
289
|
+
"""Cluster all buffer documents together."""
|
|
290
|
+
return self._buffer.cluster(
|
|
291
|
+
documents=documents,
|
|
292
|
+
branch_threshold=self._cfg.branch_threshold,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# ── Stage C — Evaluate + route ────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
def _stage_c(
|
|
298
|
+
self,
|
|
299
|
+
cluster: Cluster,
|
|
300
|
+
) -> tuple[str, list[InjectedArticle]]:
|
|
301
|
+
"""
|
|
302
|
+
Evaluate a cluster and return (action, articles).
|
|
303
|
+
action: "drop" | "keep" | "form" | "reconsolidate"
|
|
304
|
+
articles: the synthesized InjectedArticle objects (may be empty for drop/keep)
|
|
305
|
+
"""
|
|
306
|
+
deposits = cluster.deposits
|
|
307
|
+
recall_events = cluster.recall_events
|
|
308
|
+
|
|
309
|
+
# Determine effective affectability score
|
|
310
|
+
# Non-singleton clusters (>1 deposit) bypass the gate → score = 1.0
|
|
311
|
+
if len(deposits) >= self._cfg.min_cluster_size:
|
|
312
|
+
effective_score = 1.0
|
|
313
|
+
else:
|
|
314
|
+
# Singleton or recall-event-only cluster — compute from hints
|
|
315
|
+
affs = [aff_util.seed(d.id, d.hint) for d in cluster.documents]
|
|
316
|
+
effective_score = aff_util.cluster_rise(affs)
|
|
317
|
+
|
|
318
|
+
action = aff_util.route(
|
|
319
|
+
score=effective_score,
|
|
320
|
+
has_recall_events=cluster.has_recall_events,
|
|
321
|
+
drop_below=self._cfg.affectability_drop_below,
|
|
322
|
+
keep_below=self._cfg.affectability_keep_below,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if action in ("drop", "keep"):
|
|
326
|
+
return action, []
|
|
327
|
+
|
|
328
|
+
if action == "form":
|
|
329
|
+
articles = self._form(cluster)
|
|
330
|
+
return "form", articles
|
|
331
|
+
|
|
332
|
+
# action == "reconsolidate"
|
|
333
|
+
articles = self._reconsolidate(cluster)
|
|
334
|
+
return "reconsolidate", articles
|
|
335
|
+
|
|
336
|
+
# ── Formation ─────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
def _form(self, cluster: Cluster) -> list[InjectedArticle]:
|
|
339
|
+
"""
|
|
340
|
+
Synthesize one (or more, if branching) injected articles from a cluster.
|
|
341
|
+
"""
|
|
342
|
+
# Check idempotency: has this source-set already been synthesized?
|
|
343
|
+
source_hashes = frozenset(d.id for d in cluster.deposits)
|
|
344
|
+
article_id = source_set_hash(source_hashes)
|
|
345
|
+
|
|
346
|
+
if self._injected.exists(article_id):
|
|
347
|
+
# Already formed in a previous run — skip synthesis, return as-is
|
|
348
|
+
results = self._injected.get_all(filters={"doc_type": "injected"})
|
|
349
|
+
for item in results:
|
|
350
|
+
if isinstance(item, InjectedArticle) and item.id == article_id:
|
|
351
|
+
return [item]
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
# Synthesize
|
|
355
|
+
fragments = "\n\n---\n\n".join(d.content for d in cluster.deposits)
|
|
356
|
+
prompt = _FORMATION_PROMPT.format(fragments=fragments)
|
|
357
|
+
raw = self._connector.complete(prompt)
|
|
358
|
+
article_text, tags = _parse_synthesis_output(raw)
|
|
359
|
+
|
|
360
|
+
article = InjectedArticle(
|
|
361
|
+
id=article_id,
|
|
362
|
+
content=article_text,
|
|
363
|
+
source_hashes=source_hashes,
|
|
364
|
+
skill=cluster.skill,
|
|
365
|
+
tags=tags,
|
|
366
|
+
created_at=time.time(),
|
|
367
|
+
)
|
|
368
|
+
return [article]
|
|
369
|
+
|
|
370
|
+
# ── Reconsolidation ───────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
def _reconsolidate(self, cluster: Cluster) -> list[InjectedArticle]:
|
|
373
|
+
"""
|
|
374
|
+
Rewrite existing injected knowledge blended with fresh context.
|
|
375
|
+
|
|
376
|
+
Steps:
|
|
377
|
+
1. Re-query injected with each recall-event's query → current articles.
|
|
378
|
+
2. Cheap gate (no LLM): does the cluster add anything new?
|
|
379
|
+
3. If yes, synthesize. Fact-retention check before deleting old.
|
|
380
|
+
4. Branch if distinct experiences warrant multiple articles.
|
|
381
|
+
"""
|
|
382
|
+
# Re-query injected with the recall queries (pointer-based, always current)
|
|
383
|
+
current_articles: dict[str, InjectedArticle] = {}
|
|
384
|
+
for query in cluster.queries:
|
|
385
|
+
results = self._injected.query(
|
|
386
|
+
query_text=query,
|
|
387
|
+
top_k=self._cfg.recall_top_k_injected,
|
|
388
|
+
filters={"doc_type": "injected"},
|
|
389
|
+
source="injected",
|
|
390
|
+
)
|
|
391
|
+
for r in results:
|
|
392
|
+
if r.id not in current_articles:
|
|
393
|
+
items = self._injected.get_all(filters={"doc_type": "injected"})
|
|
394
|
+
for item in items:
|
|
395
|
+
if isinstance(item, InjectedArticle) and item.id == r.id:
|
|
396
|
+
current_articles[r.id] = item
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
# Fresh deposit content for the cluster
|
|
400
|
+
fresh_texts = [d.content for d in cluster.deposits]
|
|
401
|
+
snapshots = [re.relational_snapshot for re in cluster.recall_events]
|
|
402
|
+
all_fresh = fresh_texts + snapshots
|
|
403
|
+
fresh_combined = "\n\n---\n\n".join(t for t in all_fresh if t.strip())
|
|
404
|
+
|
|
405
|
+
new_articles: list[InjectedArticle] = []
|
|
406
|
+
|
|
407
|
+
if not current_articles:
|
|
408
|
+
# No existing injected knowledge — treat as formation
|
|
409
|
+
return self._form(cluster)
|
|
410
|
+
|
|
411
|
+
for old_article in current_articles.values():
|
|
412
|
+
# ── Cheap gate (no LLM) ───────────────────────────────────────
|
|
413
|
+
# Does the cluster add anything the current article doesn't cover?
|
|
414
|
+
# Heuristic: check if any fresh fragment token is not in old article.
|
|
415
|
+
if not self._has_new_signal(old_article.content, fresh_combined):
|
|
416
|
+
# Nothing new — leave injected untouched; recall-events will be flushed
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
# ── Source-set identity ───────────────────────────────────────
|
|
420
|
+
source_hashes = old_article.source_hashes | frozenset(
|
|
421
|
+
d.id for d in cluster.deposits
|
|
422
|
+
)
|
|
423
|
+
article_id = source_set_hash(source_hashes)
|
|
424
|
+
|
|
425
|
+
if self._injected.exists(article_id):
|
|
426
|
+
# Already synthesized this exact combination — skip
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
# ── Synthesize ────────────────────────────────────────────────
|
|
430
|
+
prompt = _RECONSOLIDATION_PROMPT.format(
|
|
431
|
+
existing=old_article.content,
|
|
432
|
+
fresh=fresh_combined,
|
|
433
|
+
)
|
|
434
|
+
raw = self._connector.complete(prompt)
|
|
435
|
+
article_text, tags = _parse_synthesis_output(raw)
|
|
436
|
+
|
|
437
|
+
# ── Fact-retention check ──────────────────────────────────────
|
|
438
|
+
if not self._fact_retention_ok(old_article.content, article_text):
|
|
439
|
+
# New article doesn't preserve load-bearing facts — fallback
|
|
440
|
+
# to a safe merge (prepend old, append new)
|
|
441
|
+
article_text = (
|
|
442
|
+
old_article.content + "\n\n[Updated context:]\n" + article_text
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
new_article = InjectedArticle(
|
|
446
|
+
id=article_id,
|
|
447
|
+
content=article_text,
|
|
448
|
+
source_hashes=source_hashes,
|
|
449
|
+
skill=cluster.skill or old_article.skill,
|
|
450
|
+
tags=tags or old_article.tags,
|
|
451
|
+
created_at=time.time(),
|
|
452
|
+
metadata={"supersedes": old_article.id},
|
|
453
|
+
)
|
|
454
|
+
new_articles.append(new_article)
|
|
455
|
+
|
|
456
|
+
return new_articles
|
|
457
|
+
|
|
458
|
+
# ── Reconciliation pass ────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
def _reconcile(
|
|
461
|
+
self,
|
|
462
|
+
candidate_articles: dict[str, InjectedArticle],
|
|
463
|
+
same_run_sibling_sets: list[frozenset[str]],
|
|
464
|
+
) -> list[str]:
|
|
465
|
+
"""
|
|
466
|
+
Over (new articles ∪ their current injected neighbours), decide the
|
|
467
|
+
final set using dedup_delete_threshold.
|
|
468
|
+
|
|
469
|
+
Returns list of old injected article ids to hard-delete.
|
|
470
|
+
|
|
471
|
+
Crash-safety: write new → confirm → then delete old.
|
|
472
|
+
Same-run siblings are excluded from mutual dedup-deletion.
|
|
473
|
+
"""
|
|
474
|
+
superseded_ids: list[str] = []
|
|
475
|
+
seen_superseded: set[str] = set()
|
|
476
|
+
|
|
477
|
+
# Build sibling lookup for fast exclusion
|
|
478
|
+
sibling_of: dict[str, frozenset[str]] = {}
|
|
479
|
+
for sib_set in same_run_sibling_sets:
|
|
480
|
+
for aid in sib_set:
|
|
481
|
+
sibling_of[aid] = sib_set
|
|
482
|
+
|
|
483
|
+
for new_article in candidate_articles.values():
|
|
484
|
+
# Find existing injected neighbours within dedup threshold
|
|
485
|
+
neighbors = self._injected.get_neighbors(
|
|
486
|
+
query_text=new_article.content,
|
|
487
|
+
top_k=10,
|
|
488
|
+
distance_threshold=self._cfg.dedup_delete_threshold,
|
|
489
|
+
)
|
|
490
|
+
for neighbor in neighbors:
|
|
491
|
+
# Skip if already marked superseded this run
|
|
492
|
+
if neighbor.id in seen_superseded:
|
|
493
|
+
continue
|
|
494
|
+
# Skip if it's a sibling of the new article (deliberate branch)
|
|
495
|
+
if neighbor.id in candidate_articles:
|
|
496
|
+
continue
|
|
497
|
+
# Skip if it IS the new article
|
|
498
|
+
if neighbor.id == new_article.id:
|
|
499
|
+
continue
|
|
500
|
+
superseded_ids.append(neighbor.id)
|
|
501
|
+
seen_superseded.add(neighbor.id)
|
|
502
|
+
|
|
503
|
+
# Also check metadata-recorded supersession (from reconsolidation)
|
|
504
|
+
old_id = new_article.metadata.get("supersedes")
|
|
505
|
+
if old_id and old_id not in seen_superseded and old_id not in candidate_articles:
|
|
506
|
+
superseded_ids.append(old_id)
|
|
507
|
+
seen_superseded.add(old_id)
|
|
508
|
+
|
|
509
|
+
return superseded_ids
|
|
510
|
+
|
|
511
|
+
# ── Helpers ───────────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
def _get_all_buffer_docs(self) -> list[Deposit | RecallEvent]:
|
|
514
|
+
"""Return all current buffer documents (deposits + recall-events)."""
|
|
515
|
+
all_items = self._buffer.get_all()
|
|
516
|
+
return [
|
|
517
|
+
item for item in all_items
|
|
518
|
+
if isinstance(item, (Deposit, RecallEvent))
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
def _has_new_signal(self, existing: str, fresh: str) -> bool:
|
|
522
|
+
"""
|
|
523
|
+
Cheap (no-LLM) gate: does `fresh` contain information not in `existing`?
|
|
524
|
+
|
|
525
|
+
Heuristic: tokenise both by whitespace, check whether fresh introduces
|
|
526
|
+
any words not already in the existing article (after lowercasing).
|
|
527
|
+
This is intentionally conservative — false positives (sending to LLM
|
|
528
|
+
when unnecessary) are cheaper than false negatives (missing an update).
|
|
529
|
+
"""
|
|
530
|
+
if not fresh.strip():
|
|
531
|
+
return False
|
|
532
|
+
existing_tokens = set(existing.lower().split())
|
|
533
|
+
fresh_tokens = set(fresh.lower().split())
|
|
534
|
+
# Stop-words we don't want to count as "new signal"
|
|
535
|
+
_STOP = {
|
|
536
|
+
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
|
|
537
|
+
"for", "of", "with", "by", "is", "are", "was", "were", "be",
|
|
538
|
+
"been", "has", "have", "had", "it", "its", "this", "that",
|
|
539
|
+
"these", "those", "as", "from", "not", "no", "so", "if",
|
|
540
|
+
}
|
|
541
|
+
new_tokens = (fresh_tokens - existing_tokens) - _STOP
|
|
542
|
+
return len(new_tokens) > 0
|
|
543
|
+
|
|
544
|
+
def _fact_retention_ok(self, old_article: str, new_article: str) -> bool:
|
|
545
|
+
"""
|
|
546
|
+
Check that new_article preserves the load-bearing facts of old_article.
|
|
547
|
+
|
|
548
|
+
Uses an LLM call (cheap, bounded prompt).
|
|
549
|
+
Falls back to True on any error (conservative — prefer keeping data).
|
|
550
|
+
"""
|
|
551
|
+
try:
|
|
552
|
+
prompt = _FACT_RETENTION_CHECK_PROMPT.format(
|
|
553
|
+
old=old_article[:2000], # cap for token budget
|
|
554
|
+
new=new_article[:2000],
|
|
555
|
+
)
|
|
556
|
+
response = self._connector.complete(prompt, max_tokens=10)
|
|
557
|
+
return response.strip().upper().startswith("Y")
|
|
558
|
+
except Exception:
|
|
559
|
+
return True # conservative fallback
|