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,96 @@
1
+ """Shape stored pages / retrieval passages into the public result types.
2
+
3
+ Mirrors the Cortex server's ``/v1`` shaping so local ``Memory`` and the hosted
4
+ ``CortexClient`` return identical objects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Dict, Iterable, List, Optional, Tuple
10
+
11
+ from ..types import Page, SearchResult
12
+
13
+ SCAN_CAP = 5000
14
+ _SCAN_CHUNK = 500
15
+
16
+
17
+ def first_line(text: str) -> str:
18
+ for line in text.splitlines():
19
+ if line.strip():
20
+ return line.strip()[:120]
21
+ return ""
22
+
23
+
24
+ def snippet(text: str) -> str:
25
+ return " ".join(text.split())[:200]
26
+
27
+
28
+ def search_result(passage: dict) -> SearchResult:
29
+ """Retrieval passage -> :class:`SearchResult` (with ``via`` provenance)."""
30
+ text = passage.get("text", "")
31
+ linked = passage.get("linked_from")
32
+ return SearchResult(
33
+ id=passage.get("page_id", ""),
34
+ title=first_line(text),
35
+ snippet=snippet(text),
36
+ score=float(passage.get("score", 0.0) or 0.0),
37
+ via=passage.get("via", "direct"),
38
+ linked_from=linked if isinstance(linked, str) else None,
39
+ source="private",
40
+ text=text,
41
+ )
42
+
43
+
44
+ def scan_all(collection, list_pages, cap: int = SCAN_CAP) -> List[dict]:
45
+ """Chunked scan of one user's whole collection (capped)."""
46
+ items: List[dict] = []
47
+ seen = 0
48
+ while seen < cap:
49
+ batch = list_pages(collection, limit=_SCAN_CHUNK, offset=seen)
50
+ if not batch:
51
+ break
52
+ seen += len(batch)
53
+ items.extend(batch)
54
+ return items
55
+
56
+
57
+ def link_context(all_pages: Iterable[dict]) -> Tuple[Dict[str, List[str]], Dict[str, List[str]]]:
58
+ """(outgoing, incoming) adjacency over ALL of a user's pages.
59
+
60
+ Both are restricted to ids that exist and sorted; self-links are ignored
61
+ for incoming — so the numbers agree with the server's graph view.
62
+ """
63
+ pages = list(all_pages)
64
+ known = {p["id"] for p in pages}
65
+ out = {
66
+ p["id"]: sorted({t for t in p.get("links", []) if t in known})
67
+ for p in pages
68
+ }
69
+ incoming: Dict[str, List[str]] = {}
70
+ for nid, targets in out.items():
71
+ for target in targets:
72
+ if target != nid:
73
+ incoming.setdefault(target, []).append(nid)
74
+ return out, {k: sorted(v) for k, v in incoming.items()}
75
+
76
+
77
+ def page(
78
+ stored: dict,
79
+ out: Optional[Dict[str, List[str]]] = None,
80
+ incoming: Optional[Dict[str, List[str]]] = None,
81
+ ) -> Page:
82
+ """Stored page dict -> :class:`Page` with link context."""
83
+ pid = stored.get("id", "")
84
+ text = stored.get("text", "")
85
+ links = [t for t in (out or {}).get(pid, stored.get("links", [])) if t != pid]
86
+ back = (incoming or {}).get(pid, [])
87
+ return Page(
88
+ id=pid,
89
+ title=first_line(text),
90
+ content=text,
91
+ snippet=snippet(text),
92
+ links=links,
93
+ linked_from=back,
94
+ degree=len(set(links) | set(back)),
95
+ created_at=stored.get("created_at", "") or "",
96
+ )
@@ -0,0 +1,295 @@
1
+ """Chroma storage wrapper — the single source of truth for Cortex pages.
2
+
3
+ Schema per entry:
4
+ id string unique page ID
5
+ document string raw page text
6
+ embedding vector generated at ingestion (Chroma default embedding function)
7
+ entities metadata list of extracted entities/key terms
8
+ links metadata list of linked page IDs (populated by the linking pass)
9
+ created_at metadata ISO-8601 timestamp
10
+
11
+ Implementation note: Chroma metadata values must be scalars (str/int/float/bool),
12
+ so ``entities`` and ``links`` are stored as JSON-encoded strings and transparently
13
+ decoded by this module. Callers always work with plain Python lists.
14
+
15
+ Multi-user isolation: the raw backend keeps one Chroma collection per user in
16
+ the shared persist dir. ``get_collection`` takes a
17
+ ``user_id`` and returns that user's scoped handle; every other function in this
18
+ module operates on a passed-in handle, so scoping is by construction — there is
19
+ no unscoped full-store scan anywhere. ``default`` keeps the legacy
20
+ ``cortex_pages`` name (zero migration); other users get
21
+ ``cortex_pages__<user>``. Page IDs are uuid4 hex (globally unique), so a leaked
22
+ cross-user ID simply does not exist in the caller's collection: ``get_page``
23
+ returns None (fails closed).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import datetime
29
+ import json
30
+ import re
31
+ import uuid
32
+
33
+ import chromadb
34
+ from chromadb.api.models.Collection import Collection
35
+ from chromadb.config import Settings
36
+
37
+ COLLECTION_NAME = "cortex_pages"
38
+ DEFAULT_PERSIST_DIR = "./data/chroma"
39
+
40
+ DEFAULT_USER_ID = "default"
41
+ USER_COLLECTION_PREFIX = "cortex_pages__"
42
+ MAX_USER_ID_LENGTH = 64
43
+ # §8.4 charset, tightened to require leading/trailing alphanumerics: Chroma
44
+ # collection names must start AND end with [a-zA-Z0-9], so a user_id ending in
45
+ # "-" or "_" would otherwise fail at get_or_create time. Flagged for Miguel
46
+ # in the 0027 notes (0026 proposed bare ^[A-Za-z0-9_-]{1,64}$).
47
+ _USER_ID_RE = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9])?")
48
+
49
+
50
+ def validate_user_id(user_id: str) -> str:
51
+ """Validate-never-mangle per §8.4: return ``user_id`` or raise ValueError.
52
+
53
+ Invalid input is an error, never a silent fallback/mangling (mangling can
54
+ map two distinct inputs onto one store → cross-user collision).
55
+ """
56
+ if (
57
+ not isinstance(user_id, str)
58
+ or not user_id
59
+ or len(user_id) > MAX_USER_ID_LENGTH
60
+ or _USER_ID_RE.fullmatch(user_id) is None
61
+ ):
62
+ raise ValueError(
63
+ "invalid user_id: must match "
64
+ f"[A-Za-z0-9][A-Za-z0-9_-]{{0,62}}[A-Za-z0-9]? (1-{MAX_USER_ID_LENGTH} chars); "
65
+ f"got {user_id!r}"
66
+ )
67
+ return user_id
68
+
69
+
70
+ def collection_name_for_user(user_id: str = DEFAULT_USER_ID) -> str:
71
+ """Map a validated user_id to its Chroma collection name.
72
+
73
+ ``default`` keeps the legacy ``cortex_pages`` name (zero migration);
74
+ every other user gets ``cortex_pages__<user>``.
75
+ """
76
+ validate_user_id(user_id)
77
+ if user_id == DEFAULT_USER_ID:
78
+ return COLLECTION_NAME
79
+ return f"{USER_COLLECTION_PREFIX}{user_id}"
80
+
81
+
82
+ def utc_now_iso() -> str:
83
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
84
+
85
+
86
+ def new_page_id() -> str:
87
+ return uuid.uuid4().hex
88
+
89
+
90
+ def _encode_list(values: list[str]) -> str:
91
+ return json.dumps(list(values))
92
+
93
+
94
+ def _decode_list(raw: str | None) -> list[str]:
95
+ if not raw:
96
+ return []
97
+ try:
98
+ decoded = json.loads(raw)
99
+ except (json.JSONDecodeError, TypeError):
100
+ return []
101
+ return [str(v) for v in decoded] if isinstance(decoded, list) else []
102
+
103
+
104
+ def get_client(persist_dir: str = DEFAULT_PERSIST_DIR) -> chromadb.PersistentClient:
105
+ """Open (creating if needed) the persistent embedded Chroma client.
106
+
107
+ Chroma's anonymous product telemetry is switched off: a library should not
108
+ phone home by default.
109
+ """
110
+ return chromadb.PersistentClient(
111
+ path=persist_dir, settings=Settings(anonymized_telemetry=False)
112
+ )
113
+
114
+
115
+ def get_collection(
116
+ client: chromadb.PersistentClient,
117
+ user_id: str = DEFAULT_USER_ID,
118
+ name: str | None = None,
119
+ embedding_function=None,
120
+ ) -> Collection:
121
+ """Get-or-create the calling user's pages collection (user-scoped handle).
122
+
123
+ ``user_id`` selects the collection per §8: ``default`` → legacy
124
+ ``cortex_pages`` (zero migration), others → ``cortex_pages__<user>``.
125
+ ``name`` is a deprecated escape hatch kept for old call sites/tests; if
126
+ given it wins, otherwise the name derives from ``user_id``.
127
+
128
+ ``embedding_function`` defaults to None, which means Chroma's built-in
129
+ default (ONNX MiniLM-L6-v2). Pass an
130
+ explicit function here only to override it (e.g. in tests).
131
+ """
132
+ resolved = name if name is not None else collection_name_for_user(user_id)
133
+ kwargs: dict = {"name": resolved}
134
+ if embedding_function is not None:
135
+ kwargs["embedding_function"] = embedding_function
136
+ return client.get_or_create_collection(**kwargs)
137
+
138
+
139
+ def _page_from_result(
140
+ page_id: str, document: str | None, metadata: dict | None
141
+ ) -> dict:
142
+ metadata = metadata or {}
143
+ return {
144
+ "id": page_id,
145
+ "text": document or "",
146
+ "entities": _decode_list(metadata.get("entities")),
147
+ "links": _decode_list(metadata.get("links")),
148
+ "created_at": metadata.get("created_at", ""),
149
+ }
150
+
151
+
152
+ def insert_page(
153
+ collection: Collection,
154
+ text: str,
155
+ entities: list[str],
156
+ page_id: str | None = None,
157
+ created_at: str | None = None,
158
+ embedding: list[float] | None = None,
159
+ extra: dict | None = None,
160
+ ) -> str:
161
+ """Insert one page into the caller's user-scoped collection. Returns the page ID.
162
+
163
+ IDs are uuid4 hex (globally unique); the same hex in two users'
164
+ collections denotes different pages, and a leaked cross-user ID fails
165
+ closed on ``get_page`` (returns None — the ID is absent in your store).
166
+ ``links`` starts empty until the per-user linking pass runs."""
167
+ pid = page_id or new_page_id()
168
+ kwargs: dict = {}
169
+ if embedding is not None:
170
+ # Caller-supplied vector (fact memory embeds with its own model);
171
+ # without it Chroma embeds the document with its default function.
172
+ kwargs["embeddings"] = [embedding]
173
+ collection.add(
174
+ ids=[pid],
175
+ documents=[text],
176
+ metadatas=[
177
+ {
178
+ **(extra or {}),
179
+ "entities": _encode_list(entities),
180
+ "links": _encode_list([]),
181
+ "created_at": created_at or utc_now_iso(),
182
+ }
183
+ ],
184
+ **kwargs,
185
+ )
186
+ return pid
187
+
188
+
189
+ def get_page(collection: Collection, page_id: str) -> dict | None:
190
+ """Fetch one page by ID from the caller's user-scoped collection.
191
+
192
+ Returns None if the ID does not exist *in this user's collection* —
193
+ including a leaked ID from another user (fails closed, no ownership
194
+ re-check needed since collections are disjoint)."""
195
+ result = collection.get(ids=[page_id])
196
+ if not result["ids"]:
197
+ return None
198
+ return _page_from_result(
199
+ result["ids"][0],
200
+ result["documents"][0] if result["documents"] else None,
201
+ result["metadatas"][0] if result["metadatas"] else None,
202
+ )
203
+
204
+
205
+ def list_pages(
206
+ collection: Collection, limit: int = 100, offset: int = 0
207
+ ) -> list[dict]:
208
+ """Paginated scan of the caller's *user-scoped* collection.
209
+
210
+ Used by the per-user linking pass (arch §2.3/§8: per-user passes, never
211
+ global). ``collection`` is always a user-scoped handle from
212
+ ``get_collection(client, user_id)``, so there is no unscoped
213
+ full-store scan — isolation is by collection handle, not by filter.
214
+ """
215
+ result = collection.get(limit=limit, offset=offset)
216
+ return [
217
+ _page_from_result(pid, doc, meta)
218
+ for pid, doc, meta in zip(
219
+ result["ids"],
220
+ result["documents"] or [],
221
+ result["metadatas"] or [],
222
+ )
223
+ ]
224
+
225
+
226
+ def update_page_text(
227
+ collection: Collection,
228
+ page_id: str,
229
+ text: str,
230
+ entities: list[str] | None = None,
231
+ ) -> None:
232
+ """Replace a page's text (re-embeds via the collection's embedding function).
233
+
234
+ If ``entities`` is given, replace those too; otherwise keep existing ones.
235
+ ``links`` are left untouched.
236
+ """
237
+ metadata_update: dict = {}
238
+ if entities is not None:
239
+ metadata_update["entities"] = _encode_list(entities)
240
+ collection.update(
241
+ ids=[page_id],
242
+ documents=[text],
243
+ **({"metadatas": [metadata_update]} if metadata_update else {}),
244
+ )
245
+
246
+
247
+ def update_links(collection: Collection, page_id: str, links: list[str]) -> None:
248
+ """Overwrite a page's ``links`` metadata field."""
249
+ collection.update(
250
+ ids=[page_id],
251
+ metadatas=[{"links": _encode_list(links)}],
252
+ )
253
+
254
+
255
+ def append_link(collection: Collection, page_id: str, target_id: str) -> None:
256
+ """Add one link to a page (no-op if already present)."""
257
+ page = get_page(collection, page_id)
258
+ if page is None:
259
+ raise KeyError(f"page not found: {page_id}")
260
+ if target_id not in page["links"]:
261
+ update_links(collection, page_id, [*page["links"], target_id])
262
+
263
+
264
+ def delete_page(collection: Collection, page_id: str) -> None:
265
+ collection.delete(ids=[page_id])
266
+
267
+
268
+ def count(collection: Collection) -> int:
269
+ return collection.count()
270
+
271
+
272
+ def query(
273
+ collection: Collection, query_text: str, n_results: int = 5
274
+ ) -> list[dict]:
275
+ """Vector similarity search over the caller's user-scoped collection.
276
+
277
+ Returns page dicts plus ``score`` (raw Chroma distance, lower = closer).
278
+ Never spans collections."""
279
+ result = collection.query(
280
+ query_texts=[query_text],
281
+ n_results=n_results,
282
+ )
283
+ ids = result["ids"][0] if result["ids"] else []
284
+ docs = result["documents"][0] if result["documents"] else []
285
+ metas = result["metadatas"][0] if result["metadatas"] else []
286
+ distances = result.get("distances", [[]])[0] if result.get("distances") else []
287
+ pages = [
288
+ _page_from_result(pid, doc, meta)
289
+ for pid, doc, meta in zip(ids, docs, metas)
290
+ ]
291
+ for page, dist in zip(pages, distances):
292
+ page["score"] = dist
293
+ for page in pages[len(distances):]:
294
+ page["score"] = 0.0
295
+ return pages
@@ -0,0 +1,23 @@
1
+ """Argument validation shared by ``CortexClient`` and ``Memory`` so both
2
+ surfaces accept and reject exactly the same inputs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Optional
7
+
8
+ from .errors import InvalidRequestError
9
+
10
+
11
+ def need_str(value: Any, name: str) -> str:
12
+ if not isinstance(value, str) or not value.strip():
13
+ raise InvalidRequestError(f"{name} must be a non-empty string")
14
+ return value
15
+
16
+
17
+ def need_int(value: Any, name: str, lo: int, hi: Optional[int] = None) -> int:
18
+ if isinstance(value, bool) or not isinstance(value, int):
19
+ raise InvalidRequestError(f"{name} must be an integer")
20
+ if value < lo or (hi is not None and value > hi):
21
+ rng = f">= {lo}" if hi is None else f"{lo}..{hi}"
22
+ raise InvalidRequestError(f"{name} must be {rng}")
23
+ return value
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"