cortexlayer 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.
@@ -0,0 +1,556 @@
1
+ """Fact-memory engine: LLM-extracted facts in Chroma, entity-boosted search.
2
+
3
+ Implements the algorithm of Mem0 2.1's "V3 phased batch pipeline" (Apache
4
+ License 2.0; https://github.com/mem0ai/mem0, ``mem0/memory/main.py``) on Cortex's
5
+ own storage, so Cortex does not depend on ``mem0ai``. See the repository NOTICE.
6
+
7
+ ``add`` (one call per piece of text):
8
+
9
+ 0. context: the last 10 messages previously added for this user (sqlite);
10
+ 1. retrieve the 10 most similar existing facts (their ids are replaced by
11
+ "0".."9" so the model cannot hallucinate real ids);
12
+ 2. ONE LLM call with Mem0's additive-extraction prompt (+ optional custom
13
+ instructions) -> JSON list of self-contained facts;
14
+ 3. batch-embed the facts;
15
+ 4. md5 dedup against those 10 existing facts and within the batch;
16
+ 5. insert each fact as a page (same schema as the raw backend, so the linking
17
+ pass, link-expansion and browse work unchanged) with its entities;
18
+ 6. index the facts' spaCy entities in a per-user entity collection.
19
+
20
+ ``search``: embed the query, over-fetch semantic candidates, boost the ones
21
+ linked to entities found in the query, threshold + rank (see ``scoring``).
22
+
23
+ Differences from Mem0, all deliberate:
24
+ - optional ``observation_date_from_timestamp`` (off = identical to Mem0);
25
+ - no history/audit table (only the last-10-messages context table);
26
+ - BM25 keyword term is opt-in (``keyword_weight``; Mem0 has it but it is inactive with
27
+ Chroma, so off = identical to Mem0 — see ``scoring`` and task 0079);
28
+ - an embedding *outage* raises instead of silently dropping facts;
29
+ - ``linked_memory_ids`` in the LLM output is ignored, exactly as Mem0 ignores it.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import json
36
+ import logging
37
+ import os
38
+ import re
39
+ import sqlite3
40
+ import threading
41
+ from typing import Any, Dict, List, Optional, Sequence
42
+
43
+ from ...errors import CortexConfigError, LLMError
44
+ from .. import ingestion, storage
45
+ from ..nlp import NLP
46
+ from . import entities as entity_extraction
47
+ from . import prompts, scoring
48
+ from .backends import LLM, Embedder
49
+
50
+ log = logging.getLogger("cortexlayer.facts")
51
+
52
+ FACTS_PREFIX = "cortex_facts__"
53
+ ENTITIES_PREFIX = "cortex_facts_entities__"
54
+ CONTEXT_MESSAGES = 10 # last-k messages shown to the extractor
55
+ EXISTING_FACTS_CONTEXT = 10 # similar existing facts shown to the extractor
56
+ ENTITY_REUSE_SIMILARITY = 0.95 # an entity this close to an existing one is the same entity
57
+ SEARCH_THRESHOLD = 0.1
58
+
59
+
60
+ # --- LLM reply parsing (Mem0's remove_code_blocks / extract_json) -----------------
61
+
62
+
63
+ def _strip_code_blocks(content: Any) -> str:
64
+ if isinstance(content, list):
65
+ content = "".join(
66
+ b if isinstance(b, str) else (b.get("text", "") if isinstance(b, dict) else "")
67
+ for b in content
68
+ )
69
+ if not isinstance(content, str):
70
+ return ""
71
+ stripped = content.strip()
72
+ match = re.match(r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$", stripped)
73
+ inner = match.group(1).strip() if match else stripped
74
+ return re.sub(r"<think>.*?</think>", "", inner, flags=re.DOTALL).strip()
75
+
76
+
77
+ def _extract_json(text: str) -> str:
78
+ text = text.strip()
79
+ match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL)
80
+ if match:
81
+ return match.group(1)
82
+ start, end = text.find("{"), text.rfind("}")
83
+ return text[start:end + 1] if start != -1 and end > start else text
84
+
85
+
86
+ def parse_extraction(reply: Any) -> List[Dict[str, Any]]:
87
+ """The model's reply -> ``[{"text": ..., ...}]``; ``[]`` if unparseable or empty
88
+ (logged, never raised — a garbled reply is not a service outage)."""
89
+ body = _strip_code_blocks(reply)
90
+ if not body:
91
+ return []
92
+ try:
93
+ try:
94
+ parsed = json.loads(body, strict=False)
95
+ except json.JSONDecodeError:
96
+ parsed = json.loads(_extract_json(body), strict=False)
97
+ except (json.JSONDecodeError, ValueError) as e:
98
+ log.warning("could not parse extraction reply as JSON: %s", e)
99
+ return []
100
+ memories = parsed.get("memory", []) if isinstance(parsed, dict) else []
101
+ return [m for m in memories if isinstance(m, dict)] if isinstance(memories, list) else []
102
+
103
+
104
+ # --- last-k messages (Mem0's sqlite ``messages`` table) ---------------------------
105
+
106
+
107
+ class _Messages:
108
+ """Recent add() texts per user, shown to the extractor as context."""
109
+
110
+ def __init__(self, path: str) -> None:
111
+ self._path = path
112
+ self._lock = threading.Lock()
113
+ with self._connect() as c:
114
+ c.execute(
115
+ "CREATE TABLE IF NOT EXISTS messages ("
116
+ "seq INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, "
117
+ "role TEXT, content TEXT)"
118
+ )
119
+ c.execute("CREATE INDEX IF NOT EXISTS messages_scope ON messages (scope, seq)")
120
+
121
+ def _connect(self) -> sqlite3.Connection:
122
+ os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True)
123
+ return sqlite3.connect(self._path)
124
+
125
+ def last(self, scope: str, limit: int = CONTEXT_MESSAGES) -> List[Dict[str, str]]:
126
+ with self._lock, self._connect() as c:
127
+ rows = c.execute(
128
+ "SELECT role, content FROM (SELECT seq, role, content FROM messages "
129
+ "WHERE scope = ? ORDER BY seq DESC LIMIT ?) ORDER BY seq ASC",
130
+ (scope, limit),
131
+ ).fetchall()
132
+ return [{"role": r, "content": t} for r, t in rows]
133
+
134
+ def save(self, scope: str, messages: Sequence[Dict[str, str]]) -> None:
135
+ with self._lock, self._connect() as c:
136
+ c.executemany(
137
+ "INSERT INTO messages (scope, role, content) VALUES (?, ?, ?)",
138
+ [(scope, m.get("role"), m.get("content")) for m in messages],
139
+ )
140
+ c.execute( # keep only the newest CONTEXT_MESSAGES per scope
141
+ "DELETE FROM messages WHERE scope = ? AND seq NOT IN "
142
+ "(SELECT seq FROM messages WHERE scope = ? ORDER BY seq DESC LIMIT ?)",
143
+ (scope, scope, CONTEXT_MESSAGES),
144
+ )
145
+
146
+ def clear(self, scope: str) -> None:
147
+ with self._lock, self._connect() as c:
148
+ c.execute("DELETE FROM messages WHERE scope = ?", (scope,))
149
+
150
+
151
+ def _norm(text: str) -> str:
152
+ return " ".join(text.strip().lower().split())
153
+
154
+
155
+ class FactEngine:
156
+ """LLM fact extraction + entity-boosted search over per-user Chroma collections."""
157
+
158
+ def __init__(
159
+ self,
160
+ client: Any,
161
+ data_dir: str,
162
+ llm: LLM,
163
+ embedder: Embedder,
164
+ nlp: NLP,
165
+ *,
166
+ custom_instructions: Optional[str] = None,
167
+ threshold: float = SEARCH_THRESHOLD,
168
+ observation_date_from_timestamp: bool = False,
169
+ keyword_weight: float = 0.0,
170
+ ) -> None:
171
+ self._client = client
172
+ self._llm = llm
173
+ self._embedder = embedder
174
+ self._nlp = nlp
175
+ # Mem0-style entity extraction needs a real spaCy pipeline; without one
176
+ # the entity index stays empty and search is plain semantic (as Mem0).
177
+ self._spacy = getattr(nlp, "_nlp", None)
178
+ self._custom = custom_instructions or None
179
+ self._threshold = threshold
180
+ # Mem0's pipeline (as the Cortex server uses it) never passes an observation
181
+ # date, so the extractor resolves "yesterday"/"last week" against TODAY. With
182
+ # this on, the add() timestamp is passed as the Observation Date instead.
183
+ self._obs_from_ts = observation_date_from_timestamp
184
+ # 0 = no keyword term (identical to Mem0 on Chroma); >0 fuses BM25 into search.
185
+ self._kw_weight = max(float(keyword_weight), 0.0)
186
+ self._messages = _Messages(os.path.join(data_dir, "cortexlayer_facts.db"))
187
+
188
+ # --- collections ---
189
+
190
+ def _open(self, name: str):
191
+ col = self._client.get_or_create_collection(
192
+ name=name, metadata={"embedder": self._embedder.name}
193
+ )
194
+ recorded = (col.metadata or {}).get("embedder")
195
+ if recorded and recorded != self._embedder.name:
196
+ raise CortexConfigError(
197
+ f"This store was created with embedder {recorded!r} but {self._embedder.name!r} is "
198
+ "configured; vectors from different embedders cannot be mixed. Use the original "
199
+ "embedder or a new data_dir."
200
+ )
201
+ return col
202
+
203
+ def collection(self, user_id: str):
204
+ """The user's facts collection (same page schema as the raw backend)."""
205
+ return self._open(FACTS_PREFIX + user_id)
206
+
207
+ def _entities(self, user_id: str):
208
+ return self._open(ENTITIES_PREFIX + user_id)
209
+
210
+ def _embed(self, texts: Sequence[str], action: str) -> List[List[float]]:
211
+ try:
212
+ return self._embedder.embed_batch(list(texts), action)
213
+ except LLMError:
214
+ raise
215
+ except Exception as e: # noqa: BLE001 — provider errors of any kind
216
+ raise LLMError(f"embedding failed: {e}") from e
217
+
218
+ # --- add ---
219
+
220
+ def add(self, user_id: str, text: str, timestamp: Optional[str] = None) -> List[Dict[str, str]]:
221
+ """Extract facts from ``text`` and store them. Returns ``[{"id", "text"}]``
222
+ (empty if nothing was worth remembering). Raises :class:`LLMError` if the
223
+ model or embedder is unreachable — that is not "no facts"."""
224
+ if timestamp:
225
+ text = ingestion.apply_timestamp(text, timestamp)
226
+ messages = [{"role": "user", "content": text}]
227
+ parsed = f"user: {text}\n"
228
+ facts = self.collection(user_id)
229
+
230
+ # Phase 0-1: context + similar existing facts (ids hidden behind "0".."9")
231
+ last = self._messages.last(user_id)
232
+ existing_texts: List[Dict[str, str]] = []
233
+ existing_hashes: set = set()
234
+ total = facts.count()
235
+ if total:
236
+ qvec = self._embed([parsed], "search")[0]
237
+ res = facts.query(
238
+ query_embeddings=[qvec], n_results=min(EXISTING_FACTS_CONTEXT, total),
239
+ include=["documents", "metadatas"],
240
+ )
241
+ for i, (doc, meta) in enumerate(zip(res["documents"][0], res["metadatas"][0])):
242
+ existing_texts.append({"id": str(i), "text": doc})
243
+ if meta and meta.get("hash"):
244
+ existing_hashes.add(meta["hash"])
245
+
246
+ # Phase 2: one LLM call
247
+ user_prompt = prompts.build_extraction_prompt(
248
+ existing_memories=existing_texts, new_messages=parsed,
249
+ last_k_messages=last, custom_instructions=self._custom,
250
+ timestamp=timestamp if self._obs_from_ts else None,
251
+ )
252
+ try:
253
+ reply = self._llm.generate(prompts.extraction_system_prompt(), user_prompt)
254
+ except LLMError:
255
+ raise
256
+ except Exception as e: # noqa: BLE001
257
+ raise LLMError(f"LLM extraction failed: {e}") from e
258
+ extracted = parse_extraction(reply)
259
+ if not extracted:
260
+ self._messages.save(user_id, messages)
261
+ return []
262
+
263
+ # Phase 3: batch embed (fall back one by one; an outage must not look like "nothing")
264
+ texts = [m["text"] for m in extracted if isinstance(m.get("text"), str) and m["text"]]
265
+ vectors: Dict[str, List[float]] = {}
266
+ try:
267
+ vectors = dict(zip(texts, self._embed(texts, "add")))
268
+ except LLMError:
269
+ failures = 0
270
+ for t in texts:
271
+ try:
272
+ vectors[t] = self._embed([t], "add")[0]
273
+ except LLMError as e:
274
+ failures += 1
275
+ log.warning("could not embed extracted fact, skipping it: %s", e)
276
+ if texts and failures == len(texts):
277
+ raise
278
+
279
+ # Phase 4-5: hash dedup (vs the similar existing facts and within the batch)
280
+ stamp = timestamp or storage.utc_now_iso()
281
+ now = storage.utc_now_iso()
282
+ records: List[Dict[str, Any]] = []
283
+ seen: set = set()
284
+ for m in extracted:
285
+ t = m.get("text")
286
+ if not t or t not in vectors:
287
+ continue
288
+ h = hashlib.md5(t.encode()).hexdigest()
289
+ if h in existing_hashes or h in seen:
290
+ continue
291
+ seen.add(h)
292
+ extra: Dict[str, Any] = {"hash": h, "updated_at": now}
293
+ if m.get("attributed_to"):
294
+ extra["attributed_to"] = str(m["attributed_to"])
295
+ records.append({"text": t, "vector": vectors[t], "extra": extra})
296
+ if not records:
297
+ self._messages.save(user_id, messages)
298
+ return []
299
+
300
+ # Phase 6: persist
301
+ out: List[Dict[str, str]] = []
302
+ for rec in records:
303
+ pid = storage.insert_page(
304
+ facts, rec["text"], self._nlp.entities(rec["text"]),
305
+ created_at=stamp, embedding=rec["vector"], extra=rec["extra"],
306
+ )
307
+ rec["id"] = pid
308
+ out.append({"id": pid, "text": rec["text"]})
309
+
310
+ # Phase 7: entity index (best effort, as Mem0)
311
+ try:
312
+ self._link_entities(user_id, records)
313
+ except Exception as e: # noqa: BLE001
314
+ log.warning("entity linking failed: %s", e)
315
+
316
+ self._messages.save(user_id, messages)
317
+ return out
318
+
319
+ def _link_entities(self, user_id: str, records: List[Dict[str, Any]]) -> None:
320
+ if self._spacy is None:
321
+ return
322
+ per_record = entity_extraction.extract_entities_batch(
323
+ [r["text"] for r in records], nlp=self._spacy
324
+ )
325
+ merged: Dict[str, List[Any]] = {} # norm -> [type, text, {memory ids}]
326
+ for rec, ents in zip(records, per_record):
327
+ for etype, etext in ents:
328
+ key = _norm(etext)
329
+ if key in merged:
330
+ merged[key][2].add(rec["id"])
331
+ else:
332
+ merged[key] = [etype, etext, {rec["id"]}]
333
+ if not merged:
334
+ return
335
+ keys = list(merged)
336
+ vecs = self._embed([merged[k][1] for k in keys], "add")
337
+
338
+ col = self._entities(user_id)
339
+ exact: Dict[str, Any] = {}
340
+ if col.count():
341
+ listed = col.get(include=["metadatas", "documents"])
342
+ for eid, meta, doc in zip(listed["ids"], listed["metadatas"], listed["documents"]):
343
+ exact.setdefault(_norm(doc or ""), (eid, meta or {}))
344
+ nearest = None
345
+ if col.count():
346
+ nearest = col.query(query_embeddings=vecs, n_results=1, include=["distances"])
347
+
348
+ ins_ids, ins_vecs, ins_meta, ins_docs = [], [], [], []
349
+ for j, key in enumerate(keys):
350
+ etype, etext, mem_ids = merged[key]
351
+ hit = exact.get(key)
352
+ if hit is None and nearest is not None and nearest["ids"][j]:
353
+ if scoring.distance_to_score(nearest["distances"][j][0]) >= ENTITY_REUSE_SIMILARITY:
354
+ eid = nearest["ids"][j][0]
355
+ got = col.get(ids=[eid], include=["metadatas"])
356
+ hit = (eid, (got["metadatas"] or [{}])[0] or {})
357
+ if hit is not None:
358
+ eid, meta = hit
359
+ linked = set(storage._decode_list(meta.get("linked_memory_ids"))) | mem_ids
360
+ col.update(ids=[eid], metadatas=[{"linked_memory_ids": storage._encode_list(sorted(linked))}])
361
+ else:
362
+ ins_ids.append(storage.new_page_id())
363
+ ins_vecs.append(vecs[j])
364
+ ins_docs.append(etext)
365
+ ins_meta.append({
366
+ "entity_type": etype,
367
+ "linked_memory_ids": storage._encode_list(sorted(mem_ids)),
368
+ })
369
+ if ins_ids:
370
+ col.add(ids=ins_ids, embeddings=ins_vecs, documents=ins_docs, metadatas=ins_meta)
371
+
372
+ # --- bulk import (migration from a Mem0 store) ---
373
+
374
+ def import_facts(
375
+ self,
376
+ user_id: str,
377
+ facts: Sequence[Dict[str, Any]],
378
+ entities: Sequence[Dict[str, Any]] = (),
379
+ ) -> Dict[str, int]:
380
+ """Load already-embedded facts (and their entity index) without an LLM
381
+ or embedder call — used to move a Mem0 store into this engine.
382
+
383
+ ``facts``: ``{"id", "text", "vector", "created_at"?, "updated_at"?,
384
+ "hash"?, "attributed_to"?}``. ``entities``: ``{"id", "text", "type",
385
+ "vector", "linked_memory_ids"}``. Ids are preserved, so links between
386
+ the two survive. Idempotent: ids already present are skipped. The
387
+ vectors must come from the embedder this engine is configured with —
388
+ the collection is stamped with its name and cannot be told otherwise.
389
+ Returns ``{"facts", "facts_skipped", "entities", "entities_skipped"}``.
390
+ """
391
+ col = self.collection(user_id)
392
+ have = set(col.get(ids=[f["id"] for f in facts])["ids"]) if facts else set()
393
+ added = 0
394
+ for f in facts:
395
+ if f["id"] in have:
396
+ continue
397
+ text = f["text"]
398
+ extra: Dict[str, Any] = {
399
+ "hash": f.get("hash") or hashlib.md5(text.encode()).hexdigest(),
400
+ "updated_at": f.get("updated_at") or f.get("created_at") or storage.utc_now_iso(),
401
+ }
402
+ if f.get("attributed_to"):
403
+ extra["attributed_to"] = str(f["attributed_to"])
404
+ storage.insert_page(
405
+ col, text, self._nlp.entities(text), page_id=f["id"],
406
+ created_at=f.get("created_at"), embedding=list(f["vector"]), extra=extra,
407
+ )
408
+ added += 1
409
+ stored = have | {f["id"] for f in facts}
410
+
411
+ ecol = self._entities(user_id)
412
+ ehave = set(ecol.get(ids=[e["id"] for e in entities])["ids"]) if entities else set()
413
+ ins = [
414
+ e for e in entities
415
+ if e["id"] not in ehave and any(m in stored for m in e["linked_memory_ids"])
416
+ ]
417
+ if ins:
418
+ ecol.add(
419
+ ids=[e["id"] for e in ins],
420
+ embeddings=[list(e["vector"]) for e in ins],
421
+ documents=[e["text"] for e in ins],
422
+ metadatas=[{
423
+ "entity_type": e.get("type") or "",
424
+ # Only ids that exist: mem0 keeps ids of memories since deleted.
425
+ "linked_memory_ids": storage._encode_list(
426
+ sorted(m for m in e["linked_memory_ids"] if m in stored)
427
+ ),
428
+ } for e in ins],
429
+ )
430
+ return {
431
+ "facts": added, "facts_skipped": len(have),
432
+ "entities": len(ins), "entities_skipped": len(entities) - len(ins),
433
+ }
434
+
435
+ # --- search ---
436
+
437
+ def seeds(self, user_id: str, query: str, limit: int) -> List[dict]:
438
+ """Top ``limit`` facts for ``query`` as storage-shaped page dicts with a
439
+ fused ``score`` (higher = better)."""
440
+ facts = self.collection(user_id)
441
+ total = facts.count()
442
+ if total == 0:
443
+ return []
444
+ query_entities = (
445
+ entity_extraction.extract_entities(query, nlp=self._spacy) if self._spacy else []
446
+ )
447
+ qvec = self._embed([query], "search")[0]
448
+ res = facts.query(
449
+ query_embeddings=[qvec], n_results=min(max(limit * 4, 60), total),
450
+ include=["documents", "metadatas", "distances"],
451
+ )
452
+ candidates = [
453
+ {"id": pid, "score": scoring.distance_to_score(dist), "document": doc, "metadata": meta}
454
+ for pid, doc, meta, dist in zip(
455
+ res["ids"][0], res["documents"][0], res["metadatas"][0], res["distances"][0]
456
+ )
457
+ ]
458
+ boosts = self._entity_boosts(user_id, query_entities) if query_entities else {}
459
+ keyword: Dict[str, float] = {}
460
+ if self._kw_weight > 0:
461
+ keyword = self._keyword_scores(facts, query, qvec, candidates)
462
+ ranked = scoring.score_and_rank(
463
+ candidates, boosts, self._threshold, limit, keyword, self._kw_weight
464
+ )
465
+ pages = []
466
+ for r in ranked:
467
+ page = storage._page_from_result(r["id"], r["document"], r["metadata"])
468
+ page["score"] = r["score"]
469
+ pages.append(page)
470
+ return pages
471
+
472
+ def _keyword_scores(
473
+ self, facts: Any, query: str, qvec: Sequence[float], candidates: List[Dict[str, Any]]
474
+ ) -> Dict[str, float]:
475
+ """BM25 over all of the user's facts. Keyword hits the semantic over-fetch
476
+ missed are appended to ``candidates`` (with their true semantic score) so
477
+ an exact-term fact can win even when it is not among the nearest vectors.
478
+
479
+ Reads every fact's text per query — exact and always in sync with the
480
+ store (no side index to maintain), O(facts); fine to ~10^4 per user."""
481
+ try:
482
+ got = facts.get(include=["documents"])
483
+ scores = scoring.keyword_scores(query, dict(zip(got["ids"], got["documents"])))
484
+ have = {c["id"] for c in candidates}
485
+ missing = sorted((i for i in scores if i not in have), key=scores.get, reverse=True)
486
+ missing = missing[: scoring.KEYWORD_MAX_EXTRA_CANDIDATES]
487
+ if missing:
488
+ extra = facts.get(ids=missing, include=["documents", "metadatas", "embeddings"])
489
+ for pid, doc, meta, vec in zip(
490
+ extra["ids"], extra["documents"], extra["metadatas"], extra["embeddings"]
491
+ ):
492
+ # Chroma's default space is squared L2, as in query(); mirror it exactly.
493
+ dist = sum((a - b) ** 2 for a, b in zip(vec, qvec))
494
+ candidates.append({
495
+ "id": pid, "score": scoring.distance_to_score(dist),
496
+ "document": doc, "metadata": meta,
497
+ })
498
+ return scores
499
+ except Exception as e: # noqa: BLE001 — a keyword failure degrades to semantic search
500
+ log.warning("keyword scoring failed: %s", e)
501
+ return {}
502
+
503
+ def _entity_boosts(self, user_id: str, query_entities: Sequence[Any]) -> Dict[str, float]:
504
+ seen: set = set()
505
+ deduped: List[str] = []
506
+ for _type, text in list(query_entities)[: scoring.MAX_QUERY_ENTITIES]:
507
+ key = _norm(text)
508
+ if key and key not in seen:
509
+ seen.add(key)
510
+ deduped.append(text)
511
+ col = self._entities(user_id)
512
+ total = col.count()
513
+ if not deduped or total == 0:
514
+ return {}
515
+ boosts: Dict[str, float] = {}
516
+ try:
517
+ vecs = self._embed(deduped, "search")
518
+ for vec in vecs:
519
+ res = col.query(
520
+ query_embeddings=[vec], n_results=min(500, total),
521
+ include=["metadatas", "distances"],
522
+ )
523
+ for meta, dist in zip(res["metadatas"][0], res["distances"][0]):
524
+ sim = scoring.distance_to_score(dist)
525
+ if sim < scoring.ENTITY_MATCH_MIN_SIMILARITY:
526
+ continue
527
+ linked = storage._decode_list((meta or {}).get("linked_memory_ids"))
528
+ boost = scoring.entity_boost(sim, len(linked))
529
+ for mid in linked:
530
+ boosts[mid] = max(boosts.get(mid, 0.0), boost)
531
+ except Exception as e: # noqa: BLE001 — a boost failure degrades to plain search
532
+ log.warning("entity boost failed: %s", e)
533
+ return boosts
534
+
535
+ # --- edit ---
536
+
537
+ def update(self, user_id: str, page_id: str, text: str) -> None:
538
+ """Replace a fact's text (re-embed, refresh hash/entities; links kept).
539
+ The entity index is not rewritten (as Mem0)."""
540
+ vec = self._embed([text], "update")[0]
541
+ self.collection(user_id).update(
542
+ ids=[page_id], documents=[text], embeddings=[vec],
543
+ metadatas=[{
544
+ "hash": hashlib.md5(text.encode()).hexdigest(),
545
+ "updated_at": storage.utc_now_iso(),
546
+ "entities": storage._encode_list(self._nlp.entities(text)),
547
+ }],
548
+ )
549
+
550
+ def delete_all(self, user_id: str) -> None:
551
+ for name in (FACTS_PREFIX + user_id, ENTITIES_PREFIX + user_id):
552
+ try:
553
+ self._client.delete_collection(name)
554
+ except Exception: # noqa: BLE001 — already absent
555
+ pass
556
+ self._messages.clear(user_id)