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.
- cortexlayer/__init__.py +61 -0
- cortexlayer/_engine/__init__.py +0 -0
- cortexlayer/_engine/compression.py +87 -0
- cortexlayer/_engine/facts/__init__.py +1 -0
- cortexlayer/_engine/facts/additive_extraction_prompt.txt +476 -0
- cortexlayer/_engine/facts/backends.py +196 -0
- cortexlayer/_engine/facts/engine.py +556 -0
- cortexlayer/_engine/facts/entities.py +796 -0
- cortexlayer/_engine/facts/prompts.py +100 -0
- cortexlayer/_engine/facts/scoring.py +148 -0
- cortexlayer/_engine/ingestion.py +84 -0
- cortexlayer/_engine/linking.py +68 -0
- cortexlayer/_engine/nlp.py +162 -0
- cortexlayer/_engine/retrieval.py +55 -0
- cortexlayer/_engine/shaping.py +96 -0
- cortexlayer/_engine/storage.py +295 -0
- cortexlayer/_validate.py +23 -0
- cortexlayer/_version.py +1 -0
- cortexlayer/client.py +526 -0
- cortexlayer/errors.py +108 -0
- cortexlayer/memory.py +372 -0
- cortexlayer/py.typed +0 -0
- cortexlayer/types.py +199 -0
- cortexlayer-0.1.0.dist-info/METADATA +266 -0
- cortexlayer-0.1.0.dist-info/RECORD +29 -0
- cortexlayer-0.1.0.dist-info/WHEEL +5 -0
- cortexlayer-0.1.0.dist-info/licenses/LICENSE +202 -0
- cortexlayer-0.1.0.dist-info/licenses/NOTICE +40 -0
- cortexlayer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|