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
cortexlayer/memory.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""``Memory`` — the embedded, in-process Cortex engine (no server).
|
|
2
|
+
|
|
3
|
+
>>> from cortexlayer import Memory # doctest: +SKIP
|
|
4
|
+
>>> m = Memory() # doctest: +SKIP
|
|
5
|
+
>>> m.add("I moved to Lisbon in March", user_id="alice") # doctest: +SKIP
|
|
6
|
+
>>> m.search("where does alice live?", user_id="alice") # doctest: +SKIP
|
|
7
|
+
|
|
8
|
+
Same method names and result types as :class:`~cortexlayer.CortexClient`, so
|
|
9
|
+
switching between local and hosted is a change of constructor. Memories live in
|
|
10
|
+
an embedded Chroma store under ``data_dir``; each ``user_id`` gets its own
|
|
11
|
+
collection, so users can never see each other's pages.
|
|
12
|
+
|
|
13
|
+
Needs ``pip install "cortexlayer[local]"``. Importing this module does not —
|
|
14
|
+
the engine is only loaded when a ``Memory`` is constructed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from ._validate import need_int, need_str
|
|
24
|
+
from .errors import CortexConfigError, InvalidRequestError, LocalDependencyError, NotFoundError
|
|
25
|
+
from .types import Answer, AddResult, Page, PageList, SearchResult
|
|
26
|
+
|
|
27
|
+
DEFAULT_USER_ID = "default"
|
|
28
|
+
DATA_DIR_ENV = "CORTEXLAYER_DATA_DIR"
|
|
29
|
+
MAX_SEARCH_LIMIT = 20
|
|
30
|
+
MAX_LIST_LIMIT = 500
|
|
31
|
+
BACKENDS = ("raw", "facts")
|
|
32
|
+
_CONFIG_KEYS = {
|
|
33
|
+
"backend", "data_dir", "entity_extractor", "spacy_model",
|
|
34
|
+
"default_user_id", "auto_relink", "llm", "embedder", "custom_instructions",
|
|
35
|
+
"observation_date_from_timestamp", "keyword_scoring",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _default_data_dir() -> str:
|
|
40
|
+
return os.environ.get(DATA_DIR_ENV) or os.path.join(
|
|
41
|
+
os.path.expanduser("~"), ".cortexlayer"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_engine(backend: str = "raw") -> Any:
|
|
46
|
+
"""Import the engine, or explain how to install it."""
|
|
47
|
+
try:
|
|
48
|
+
from ._engine import compression, ingestion, linking, nlp, retrieval, shaping, storage
|
|
49
|
+
if backend == "facts":
|
|
50
|
+
from ._engine.facts import backends as fact_backends
|
|
51
|
+
from ._engine.facts import engine as fact_engine
|
|
52
|
+
except ImportError as e:
|
|
53
|
+
raise LocalDependencyError(
|
|
54
|
+
"The embedded engine needs extra packages. Run: "
|
|
55
|
+
'pip install "cortexlayer[local]" '
|
|
56
|
+
f"(missing: {getattr(e, 'name', None) or e})."
|
|
57
|
+
) from e
|
|
58
|
+
|
|
59
|
+
class _Engine:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
eng = _Engine()
|
|
63
|
+
for mod in (compression, ingestion, linking, nlp, retrieval, shaping, storage):
|
|
64
|
+
setattr(eng, mod.__name__.rsplit(".", 1)[-1], mod)
|
|
65
|
+
if backend == "facts":
|
|
66
|
+
eng.fact_backends, eng.fact_engine = fact_backends, fact_engine
|
|
67
|
+
return eng
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Memory:
|
|
71
|
+
"""Embedded memory store.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
data_dir: where the embedded store lives (default ``~/.cortexlayer``,
|
|
75
|
+
or ``$CORTEXLAYER_DATA_DIR``). It is created on first use.
|
|
76
|
+
entity_extractor: ``"auto"`` (default: spaCy if available, else a
|
|
77
|
+
simpler regex extractor with a one-time warning), ``"spacy"``,
|
|
78
|
+
``"regex"``, or your own object with ``entities(text)`` and
|
|
79
|
+
``sentences(text)`` methods. Entities drive linking, so a weaker
|
|
80
|
+
extractor means fewer links.
|
|
81
|
+
spacy_model: spaCy model name (default ``en_core_web_sm``).
|
|
82
|
+
default_user_id: used when a call omits ``user_id``.
|
|
83
|
+
auto_relink: re-run the linking pass after every ``add``. Off by
|
|
84
|
+
default — linking is a batch pass over all pages, so prefer
|
|
85
|
+
calling :meth:`relink` after adding several memories.
|
|
86
|
+
backend: ``"raw"`` (default) stores your text as small pages — adding
|
|
87
|
+
never needs an LLM. ``"facts"`` asks an LLM to distil each ``add``
|
|
88
|
+
into self-contained facts (Mem0-style), stores those, and boosts
|
|
89
|
+
search by shared entities; it needs an LLM (``llm=``, Ollama by
|
|
90
|
+
default) and an embedder (``embedder=``, Chroma's built-in by
|
|
91
|
+
default).
|
|
92
|
+
llm: (``"facts"``) ``None`` = Ollama at ``$OLLAMA_HOST``; a dict such
|
|
93
|
+
as ``{"model": "qwen3.5:9b", "host": "..."}``; a callable
|
|
94
|
+
``fn(system, user) -> str``; or any object with ``generate()``.
|
|
95
|
+
embedder: (``"facts"``) ``None``/``"chroma"`` (default), ``"ollama"``,
|
|
96
|
+
a dict like ``{"provider": "ollama", "model": "qwen3-embedding:8b"}``,
|
|
97
|
+
or an object with ``embed_batch(texts, action)`` and ``name``. A
|
|
98
|
+
store remembers its embedder and refuses a different one.
|
|
99
|
+
custom_instructions: (``"facts"``) extra extraction rules appended to
|
|
100
|
+
the prompt.
|
|
101
|
+
observation_date_from_timestamp: (``"facts"``) pass ``add(timestamp=)``
|
|
102
|
+
to the extractor as the conversation's Observation Date, so
|
|
103
|
+
"last week" resolves against that date. Off by default, which
|
|
104
|
+
matches Mem0 (it resolves relative dates against today).
|
|
105
|
+
keyword_scoring: (``"facts"``) fuse a BM25 keyword score into search so
|
|
106
|
+
a fact that literally contains a query word is not outranked by
|
|
107
|
+
generic facts with slightly closer vectors. ``True`` (weight 1, as
|
|
108
|
+
Mem0's design; the default) or a float weight; ``False`` is plain
|
|
109
|
+
semantic + entity scoring, identical to Mem0 on a Chroma store.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
data_dir: Optional[str] = None,
|
|
115
|
+
*,
|
|
116
|
+
entity_extractor: Any = "auto",
|
|
117
|
+
spacy_model: str = "en_core_web_sm",
|
|
118
|
+
default_user_id: str = DEFAULT_USER_ID,
|
|
119
|
+
auto_relink: bool = False,
|
|
120
|
+
backend: str = "raw",
|
|
121
|
+
llm: Any = None,
|
|
122
|
+
embedder: Any = None,
|
|
123
|
+
custom_instructions: Optional[str] = None,
|
|
124
|
+
observation_date_from_timestamp: bool = False,
|
|
125
|
+
keyword_scoring: Any = True,
|
|
126
|
+
) -> None:
|
|
127
|
+
if backend not in BACKENDS:
|
|
128
|
+
raise CortexConfigError(f"backend must be one of {BACKENDS}, got {backend!r}")
|
|
129
|
+
self._backend = backend
|
|
130
|
+
self._e = _load_engine(backend)
|
|
131
|
+
self._data_dir = os.path.abspath(data_dir or _default_data_dir())
|
|
132
|
+
self._default_user = self._check_uid(default_user_id)
|
|
133
|
+
self._auto_relink = bool(auto_relink)
|
|
134
|
+
self._nlp = self._e.nlp.resolve_nlp(entity_extractor, spacy_model=spacy_model)
|
|
135
|
+
self._client = self._e.storage.get_client(self._data_dir)
|
|
136
|
+
self._collections: Dict[str, Any] = {}
|
|
137
|
+
self._lock = threading.Lock()
|
|
138
|
+
self._facts = None
|
|
139
|
+
if backend == "facts":
|
|
140
|
+
self._facts = self._e.fact_engine.FactEngine(
|
|
141
|
+
self._client, self._data_dir,
|
|
142
|
+
self._e.fact_backends.resolve_llm(llm),
|
|
143
|
+
self._e.fact_backends.resolve_embedder(embedder),
|
|
144
|
+
self._nlp, custom_instructions=custom_instructions,
|
|
145
|
+
observation_date_from_timestamp=observation_date_from_timestamp,
|
|
146
|
+
keyword_weight=float(keyword_scoring),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_config(cls, config: Dict[str, Any]) -> "Memory":
|
|
151
|
+
"""Build from a plain dict (keys: ``data_dir``, ``entity_extractor``,
|
|
152
|
+
``spacy_model``, ``default_user_id``, ``auto_relink``, ``backend``,
|
|
153
|
+
``llm``, ``embedder``, ``custom_instructions``).
|
|
154
|
+
"""
|
|
155
|
+
if not isinstance(config, dict):
|
|
156
|
+
raise CortexConfigError("config must be a dict")
|
|
157
|
+
unknown = set(config) - _CONFIG_KEYS
|
|
158
|
+
if unknown:
|
|
159
|
+
raise CortexConfigError(f"unknown config keys: {', '.join(sorted(unknown))}")
|
|
160
|
+
backend = config.get("backend", "raw")
|
|
161
|
+
if backend not in BACKENDS:
|
|
162
|
+
raise CortexConfigError(
|
|
163
|
+
f"backend {backend!r} is not available; choose one of {BACKENDS}"
|
|
164
|
+
)
|
|
165
|
+
return cls(**config)
|
|
166
|
+
|
|
167
|
+
def __repr__(self) -> str:
|
|
168
|
+
return f"Memory(data_dir={self._data_dir!r}, backend={self._backend!r})"
|
|
169
|
+
|
|
170
|
+
# --- internals ---
|
|
171
|
+
|
|
172
|
+
def _check_uid(self, uid: str) -> str:
|
|
173
|
+
try:
|
|
174
|
+
return self._e.storage.validate_user_id(uid)
|
|
175
|
+
except ValueError as e:
|
|
176
|
+
raise InvalidRequestError(str(e)) from e
|
|
177
|
+
|
|
178
|
+
def _uid(self, user_id: Optional[str]) -> str:
|
|
179
|
+
"""``None`` means the instance's default user."""
|
|
180
|
+
return self._check_uid(self._default_user if user_id is None else user_id)
|
|
181
|
+
|
|
182
|
+
def _col(self, user_id: Optional[str]):
|
|
183
|
+
uid = self._uid(user_id)
|
|
184
|
+
with self._lock:
|
|
185
|
+
col = self._collections.get(uid)
|
|
186
|
+
if col is None:
|
|
187
|
+
col = self._collections[uid] = (
|
|
188
|
+
self._facts.collection(uid) if self._facts is not None
|
|
189
|
+
else self._e.storage.get_collection(self._client, uid)
|
|
190
|
+
)
|
|
191
|
+
return col
|
|
192
|
+
|
|
193
|
+
def collection(self, user_id: Optional[str] = None) -> Any:
|
|
194
|
+
"""The user's underlying Chroma collection (raw pages, or extracted
|
|
195
|
+
facts for ``backend="facts"``; both share one page schema).
|
|
196
|
+
|
|
197
|
+
For embedders that serve or browse the store directly, e.g. the Cortex
|
|
198
|
+
server. Treat it as read-only: writing through it bypasses embedding,
|
|
199
|
+
entity extraction and the facts pipeline."""
|
|
200
|
+
return self._col(user_id)
|
|
201
|
+
|
|
202
|
+
def _passages(self, user_id, col, query: str, limit: int, expand_links: bool) -> List[dict]:
|
|
203
|
+
"""Seeds (+ optional link expansion) as ``{page_id, text, score, via, ...}``."""
|
|
204
|
+
if self._facts is not None:
|
|
205
|
+
seeds = self._facts.seeds(self._uid(user_id), query, limit)
|
|
206
|
+
else:
|
|
207
|
+
seeds = self._e.storage.query(col, query, n_results=limit)
|
|
208
|
+
if expand_links:
|
|
209
|
+
return self._e.retrieval.expand_links(col, seeds)
|
|
210
|
+
return [
|
|
211
|
+
{"page_id": p["id"], "text": p["text"], "score": p.get("score", 0.0), "via": "direct"}
|
|
212
|
+
for p in seeds
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def _all_pages(self, col) -> List[dict]:
|
|
216
|
+
return self._e.shaping.scan_all(col, self._e.storage.list_pages)
|
|
217
|
+
|
|
218
|
+
# --- memory ---
|
|
219
|
+
|
|
220
|
+
def add(
|
|
221
|
+
self,
|
|
222
|
+
text: str,
|
|
223
|
+
*,
|
|
224
|
+
user_id: Optional[str] = None,
|
|
225
|
+
timestamp: Optional[str] = None,
|
|
226
|
+
) -> AddResult:
|
|
227
|
+
"""Save ``text`` as memory. Long text is chunked into several small
|
|
228
|
+
pages (one fact each works best). ``timestamp`` (e.g. ``"8 May, 2023"``)
|
|
229
|
+
is stored as the date. Does not relink unless ``auto_relink`` is on."""
|
|
230
|
+
need_str(text, "text")
|
|
231
|
+
if timestamp is not None:
|
|
232
|
+
need_str(timestamp, "timestamp")
|
|
233
|
+
col = self._col(user_id)
|
|
234
|
+
if self._facts is not None:
|
|
235
|
+
ids = [f["id"] for f in self._facts.add(self._uid(user_id), text, timestamp)]
|
|
236
|
+
else:
|
|
237
|
+
ids = self._e.ingestion.add_text(col, text, self._nlp, timestamp=timestamp)
|
|
238
|
+
if self._auto_relink and ids:
|
|
239
|
+
self._e.linking.run_linking_pass(col)
|
|
240
|
+
return AddResult(page_ids=ids)
|
|
241
|
+
|
|
242
|
+
def search(
|
|
243
|
+
self,
|
|
244
|
+
query: str,
|
|
245
|
+
*,
|
|
246
|
+
user_id: Optional[str] = None,
|
|
247
|
+
limit: int = 4,
|
|
248
|
+
expand_links: bool = True,
|
|
249
|
+
) -> List[SearchResult]:
|
|
250
|
+
"""Semantic search. With ``expand_links`` (default) related pages are
|
|
251
|
+
pulled in via links — those results have ``via == "link"``. An empty
|
|
252
|
+
store returns ``[]``."""
|
|
253
|
+
need_str(query, "query")
|
|
254
|
+
need_int(limit, "limit", 1, MAX_SEARCH_LIMIT)
|
|
255
|
+
if not isinstance(expand_links, bool):
|
|
256
|
+
raise InvalidRequestError("expand_links must be True or False")
|
|
257
|
+
col = self._col(user_id)
|
|
258
|
+
if col.count() == 0:
|
|
259
|
+
return []
|
|
260
|
+
passages = self._passages(user_id, col, query, limit, expand_links)
|
|
261
|
+
return [self._e.shaping.search_result(p) for p in passages]
|
|
262
|
+
|
|
263
|
+
def answer(
|
|
264
|
+
self,
|
|
265
|
+
query: str,
|
|
266
|
+
*,
|
|
267
|
+
user_id: Optional[str] = None,
|
|
268
|
+
limit: int = 4,
|
|
269
|
+
model: Optional[str] = None,
|
|
270
|
+
chat: Optional[Callable[[str, str], str]] = None,
|
|
271
|
+
) -> Answer:
|
|
272
|
+
"""Retrieve, then have an LLM distil a short direct answer plus the ids
|
|
273
|
+
of the pages that support it.
|
|
274
|
+
|
|
275
|
+
Needs an LLM: by default a local Ollama at ``$OLLAMA_HOST``
|
|
276
|
+
(``http://localhost:11434``). Pass ``chat=fn(prompt, model) -> str`` to
|
|
277
|
+
use any other model. An unreachable LLM raises (this call has no
|
|
278
|
+
degraded mode — use :meth:`search` for raw passages).
|
|
279
|
+
"""
|
|
280
|
+
need_str(query, "query")
|
|
281
|
+
need_int(limit, "limit", 1, MAX_SEARCH_LIMIT)
|
|
282
|
+
col = self._col(user_id)
|
|
283
|
+
if col.count() == 0:
|
|
284
|
+
return Answer(answer="", source_page_ids=[])
|
|
285
|
+
passages = self._passages(user_id, col, query, limit, True)
|
|
286
|
+
kwargs: Dict[str, Any] = {"_chat": chat}
|
|
287
|
+
if model:
|
|
288
|
+
kwargs["model"] = model
|
|
289
|
+
out = self._e.compression.compress(query, passages, **kwargs)
|
|
290
|
+
return Answer(answer=out["answer"], source_page_ids=list(out["source_page_ids"]))
|
|
291
|
+
|
|
292
|
+
def get(self, id: str, *, user_id: Optional[str] = None) -> Page:
|
|
293
|
+
"""One page by id. Unknown ids — including another user's — raise
|
|
294
|
+
:class:`NotFoundError`."""
|
|
295
|
+
need_str(id, "id")
|
|
296
|
+
col = self._col(user_id)
|
|
297
|
+
stored = self._e.storage.get_page(col, id)
|
|
298
|
+
if stored is None:
|
|
299
|
+
raise NotFoundError(f"page not found: {id}", status=404)
|
|
300
|
+
out, incoming = self._e.shaping.link_context(self._all_pages(col))
|
|
301
|
+
return self._e.shaping.page(stored, out, incoming)
|
|
302
|
+
|
|
303
|
+
def get_all(
|
|
304
|
+
self,
|
|
305
|
+
*,
|
|
306
|
+
user_id: Optional[str] = None,
|
|
307
|
+
query: Optional[str] = None,
|
|
308
|
+
limit: int = 50,
|
|
309
|
+
offset: int = 0,
|
|
310
|
+
) -> PageList:
|
|
311
|
+
"""Browse pages; ``query`` is a case-insensitive substring filter (use
|
|
312
|
+
:meth:`search` for semantic search)."""
|
|
313
|
+
need_int(limit, "limit", 1, MAX_LIST_LIMIT)
|
|
314
|
+
need_int(offset, "offset", 0)
|
|
315
|
+
if query is not None:
|
|
316
|
+
need_str(query, "query")
|
|
317
|
+
col = self._col(user_id)
|
|
318
|
+
everything = self._all_pages(col)
|
|
319
|
+
needle = query.strip().lower() if query else None
|
|
320
|
+
matches = [
|
|
321
|
+
p for p in everything if needle is None or needle in p.get("text", "").lower()
|
|
322
|
+
]
|
|
323
|
+
out, incoming = self._e.shaping.link_context(everything)
|
|
324
|
+
window = matches[offset:offset + limit]
|
|
325
|
+
return PageList(
|
|
326
|
+
pages=[self._e.shaping.page(p, out, incoming) for p in window],
|
|
327
|
+
total=len(matches), limit=limit, offset=offset,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def update(self, id: str, text: str, *, user_id: Optional[str] = None) -> None:
|
|
331
|
+
"""Replace a page's text (re-embeds, re-extracts entities, keeps links).
|
|
332
|
+
Links can go stale — call :meth:`relink` after batch edits."""
|
|
333
|
+
need_str(id, "id")
|
|
334
|
+
need_str(text, "text")
|
|
335
|
+
col = self._col(user_id)
|
|
336
|
+
if self._e.storage.get_page(col, id) is None:
|
|
337
|
+
raise NotFoundError(f"page not found: {id}", status=404)
|
|
338
|
+
if self._facts is not None:
|
|
339
|
+
self._facts.update(self._uid(user_id), id, text)
|
|
340
|
+
else:
|
|
341
|
+
self._e.storage.update_page_text(col, id, text, self._nlp.entities(text))
|
|
342
|
+
|
|
343
|
+
def delete(self, id: str, *, user_id: Optional[str] = None) -> None:
|
|
344
|
+
"""Delete a page. Links from other pages that pointed at it are
|
|
345
|
+
skipped by retrieval and cleaned up by the next :meth:`relink`."""
|
|
346
|
+
need_str(id, "id")
|
|
347
|
+
col = self._col(user_id)
|
|
348
|
+
if self._e.storage.get_page(col, id) is None:
|
|
349
|
+
raise NotFoundError(f"page not found: {id}", status=404)
|
|
350
|
+
self._e.storage.delete_page(col, id)
|
|
351
|
+
|
|
352
|
+
def delete_all(self, *, user_id: Optional[str] = None) -> int:
|
|
353
|
+
"""Delete every page of one user. Returns how many were removed."""
|
|
354
|
+
uid = self._uid(user_id)
|
|
355
|
+
col = self._col(uid)
|
|
356
|
+
removed = col.count()
|
|
357
|
+
with self._lock:
|
|
358
|
+
self._collections.pop(uid, None)
|
|
359
|
+
if self._facts is not None:
|
|
360
|
+
self._facts.delete_all(uid)
|
|
361
|
+
else:
|
|
362
|
+
self._client.delete_collection(col.name)
|
|
363
|
+
return removed
|
|
364
|
+
|
|
365
|
+
def relink(self, *, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
366
|
+
"""Re-run the batch linking pass (idempotent). Returns
|
|
367
|
+
``{"pages": n, "links_written": m}``."""
|
|
368
|
+
return self._e.linking.run_linking_pass(self._col(user_id))
|
|
369
|
+
|
|
370
|
+
def count(self, *, user_id: Optional[str] = None) -> int:
|
|
371
|
+
"""Number of pages stored for one user."""
|
|
372
|
+
return self._col(user_id).count()
|
cortexlayer/py.typed
ADDED
|
File without changes
|
cortexlayer/types.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Result types. Plain frozen dataclasses (no pydantic) built tolerantly from
|
|
2
|
+
server JSON: unknown fields are ignored so a newer server never breaks an
|
|
3
|
+
older client."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _s(d: Dict[str, Any], key: str, default: str = "") -> str:
|
|
12
|
+
v = d.get(key, default)
|
|
13
|
+
return v if isinstance(v, str) else default
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _list(d: Dict[str, Any], key: str) -> List[str]:
|
|
17
|
+
v = d.get(key) or []
|
|
18
|
+
return [x for x in v if isinstance(x, str)] if isinstance(v, list) else []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class SearchResult:
|
|
23
|
+
"""One hit from :meth:`CortexClient.search`.
|
|
24
|
+
|
|
25
|
+
``text`` is the full memory text when the source provides it (local
|
|
26
|
+
``Memory`` always does); ``snippet`` is its first ~200 characters.
|
|
27
|
+
|
|
28
|
+
``via`` is ``"direct"`` (vector hit) or ``"link"`` (pulled in by
|
|
29
|
+
link-expansion; ``linked_from`` is the seed page that led to it). ``score``
|
|
30
|
+
semantics depend on the backend (a distance, lower = closer, on raw; the
|
|
31
|
+
fused semantic + keyword + entity score, higher = closer, on facts) —
|
|
32
|
+
compare within one store or server, not across.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
title: str
|
|
37
|
+
snippet: str
|
|
38
|
+
score: float
|
|
39
|
+
via: str = "direct"
|
|
40
|
+
linked_from: Optional[str] = None
|
|
41
|
+
source: str = "private"
|
|
42
|
+
text: str = "" # the full memory text (local ``Memory`` fills it; a server may omit it)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, d: Dict[str, Any]) -> "SearchResult":
|
|
46
|
+
lf = d.get("linked_from")
|
|
47
|
+
return cls(
|
|
48
|
+
id=_s(d, "id"),
|
|
49
|
+
title=_s(d, "title"),
|
|
50
|
+
snippet=_s(d, "snippet"),
|
|
51
|
+
score=float(d.get("score") or 0.0),
|
|
52
|
+
via=_s(d, "via", "direct"),
|
|
53
|
+
linked_from=lf if isinstance(lf, str) else None,
|
|
54
|
+
source=_s(d, "source", "private"),
|
|
55
|
+
text=_s(d, "text"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Page:
|
|
61
|
+
"""A stored memory page. ``links`` point out, ``linked_from`` point in."""
|
|
62
|
+
|
|
63
|
+
id: str
|
|
64
|
+
title: str
|
|
65
|
+
content: str
|
|
66
|
+
snippet: str = ""
|
|
67
|
+
links: List[str] = field(default_factory=list)
|
|
68
|
+
linked_from: List[str] = field(default_factory=list)
|
|
69
|
+
degree: int = 0
|
|
70
|
+
created_at: str = ""
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Page":
|
|
74
|
+
return cls(
|
|
75
|
+
id=_s(d, "id"),
|
|
76
|
+
title=_s(d, "title"),
|
|
77
|
+
content=_s(d, "content"),
|
|
78
|
+
snippet=_s(d, "snippet"),
|
|
79
|
+
links=_list(d, "links"),
|
|
80
|
+
linked_from=_list(d, "linked_from"),
|
|
81
|
+
degree=int(d.get("degree") or 0),
|
|
82
|
+
created_at=_s(d, "created_at"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class PageList:
|
|
88
|
+
"""One window of :meth:`CortexClient.get_all` (``total`` counts all matches)."""
|
|
89
|
+
|
|
90
|
+
pages: List[Page]
|
|
91
|
+
total: int
|
|
92
|
+
limit: int
|
|
93
|
+
offset: int
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_dict(cls, d: Dict[str, Any]) -> "PageList":
|
|
97
|
+
return cls(
|
|
98
|
+
pages=[Page.from_dict(p) for p in d.get("pages") or []],
|
|
99
|
+
total=int(d.get("total") or 0),
|
|
100
|
+
limit=int(d.get("limit") or 0),
|
|
101
|
+
offset=int(d.get("offset") or 0),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class AddResult:
|
|
107
|
+
"""Ids of the pages created by :meth:`CortexClient.add` (long text is
|
|
108
|
+
chunked into several small pages)."""
|
|
109
|
+
|
|
110
|
+
page_ids: List[str]
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, d: Dict[str, Any]) -> "AddResult":
|
|
114
|
+
return cls(page_ids=_list(d, "page_ids"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class Account:
|
|
119
|
+
user_id: str
|
|
120
|
+
account_name: str
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Account":
|
|
124
|
+
return cls(user_id=_s(d, "user_id"), account_name=_s(d, "account_name"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class UsageBucket:
|
|
129
|
+
key: str
|
|
130
|
+
calls: int
|
|
131
|
+
errors: int
|
|
132
|
+
avg_latency_ms: Optional[float] = None
|
|
133
|
+
label: Optional[str] = None
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_dict(cls, d: Dict[str, Any]) -> "UsageBucket":
|
|
137
|
+
avg = d.get("avg_latency_ms")
|
|
138
|
+
lab = d.get("label")
|
|
139
|
+
return cls(
|
|
140
|
+
key=_s(d, "key"),
|
|
141
|
+
calls=int(d.get("calls") or 0),
|
|
142
|
+
errors=int(d.get("errors") or 0),
|
|
143
|
+
avg_latency_ms=float(avg) if isinstance(avg, (int, float)) else None,
|
|
144
|
+
label=lab if isinstance(lab, str) else None,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class Usage:
|
|
150
|
+
"""Your own API usage, aggregated (``group_by``: day | key | operation)."""
|
|
151
|
+
|
|
152
|
+
group_by: str
|
|
153
|
+
since: str
|
|
154
|
+
until: str
|
|
155
|
+
total_calls: int
|
|
156
|
+
total_errors: int
|
|
157
|
+
buckets: List[UsageBucket]
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Usage":
|
|
161
|
+
return cls(
|
|
162
|
+
group_by=_s(d, "group_by"),
|
|
163
|
+
since=_s(d, "since"),
|
|
164
|
+
until=_s(d, "until"),
|
|
165
|
+
total_calls=int(d.get("total_calls") or 0),
|
|
166
|
+
total_errors=int(d.get("total_errors") or 0),
|
|
167
|
+
buckets=[UsageBucket.from_dict(b) for b in d.get("buckets") or []],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class Graph:
|
|
173
|
+
"""Raw graph payload (``nodes`` / ``edges`` as the server sends them)."""
|
|
174
|
+
|
|
175
|
+
nodes: List[Dict[str, Any]]
|
|
176
|
+
edges: List[Dict[str, Any]]
|
|
177
|
+
truncated: bool = False
|
|
178
|
+
stale: bool = False
|
|
179
|
+
start: Optional[str] = None
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Graph":
|
|
183
|
+
start = d.get("start")
|
|
184
|
+
return cls(
|
|
185
|
+
nodes=list(d.get("nodes") or []),
|
|
186
|
+
edges=list(d.get("edges") or []),
|
|
187
|
+
truncated=bool(d.get("truncated", False)),
|
|
188
|
+
stale=bool(d.get("stale", False)),
|
|
189
|
+
start=start if isinstance(start, str) else None,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class Answer:
|
|
195
|
+
"""A short direct answer distilled from retrieved pages
|
|
196
|
+
(:meth:`Memory.answer`; needs an LLM — Ollama by default)."""
|
|
197
|
+
|
|
198
|
+
answer: str
|
|
199
|
+
source_page_ids: List[str]
|