ember-custody 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.
- ember/__init__.py +93 -0
- ember/adapters.py +525 -0
- ember/audit.py +319 -0
- ember/cli.py +351 -0
- ember/crypto.py +94 -0
- ember/keystore.py +234 -0
- ember/ledger.py +314 -0
- ember/merkle.py +256 -0
- ember/receipts.py +189 -0
- ember/records.py +232 -0
- ember/reducer.py +292 -0
- ember/signing.py +144 -0
- ember/vault.py +820 -0
- ember/verify.py +577 -0
- ember_custody-0.1.0.dist-info/METADATA +219 -0
- ember_custody-0.1.0.dist-info/RECORD +21 -0
- ember_custody-0.1.0.dist-info/WHEEL +5 -0
- ember_custody-0.1.0.dist-info/entry_points.txt +3 -0
- ember_custody-0.1.0.dist-info/licenses/LICENSE +152 -0
- ember_custody-0.1.0.dist-info/licenses/NOTICE +5 -0
- ember_custody-0.1.0.dist-info/top_level.txt +1 -0
ember/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Ember: cryptographic custody, redactable Merkle receipts, and verifiable cascade erasure."""
|
|
2
|
+
|
|
3
|
+
from .adapters import (
|
|
4
|
+
AdapterHit,
|
|
5
|
+
InMemoryStoreAdapter,
|
|
6
|
+
Mem0Adapter,
|
|
7
|
+
MemoryStoreAdapter,
|
|
8
|
+
SQLiteStoreAdapter,
|
|
9
|
+
VectorStoreAdapter,
|
|
10
|
+
)
|
|
11
|
+
from .audit import SUITE_V2, SubjectScenario, run_benchmark, verify_suite_hygiene
|
|
12
|
+
from .crypto import CIPHER_NAME, ERASED_TOMBSTONE, DecryptionError
|
|
13
|
+
from .keystore import FileKeyStore, KeyStore, MemoryKeyStore, ShredReport
|
|
14
|
+
from .ledger import IdempotencyConflict, LedgerIntegrityError, SQLiteLedger
|
|
15
|
+
from .merkle import RedactableDoc, inclusion_proof, leaf_hash, merkle_root, split_parts
|
|
16
|
+
from .receipts import Receipt, locate_span, mint_flat_receipt, mint_redactable_receipt
|
|
17
|
+
from .records import ClosureManifest, ErasureCertificate, LedgerEvent, MemoryItem, SourceRecord
|
|
18
|
+
from .reducer import (
|
|
19
|
+
EmberState,
|
|
20
|
+
ErasureIncomplete,
|
|
21
|
+
InvariantViolation,
|
|
22
|
+
ReductionError,
|
|
23
|
+
TombstonedSubjectError,
|
|
24
|
+
apply_event,
|
|
25
|
+
compute_closure,
|
|
26
|
+
decrypt_item_metadata,
|
|
27
|
+
decrypt_item_payload,
|
|
28
|
+
)
|
|
29
|
+
from .signing import AnchorReceipt, CallbackAnchor, Ed25519Signer, NullAnchor, TimestampAnchor
|
|
30
|
+
from .vault import CustodyVault, QueryHit, RememberResult, SearchResult
|
|
31
|
+
from .verify import verify_bundle, verify_ed25519_certificate, verify_ledger_chain, verify_redactable_receipt
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AdapterHit",
|
|
37
|
+
"AnchorReceipt",
|
|
38
|
+
"CIPHER_NAME",
|
|
39
|
+
"CallbackAnchor",
|
|
40
|
+
"ClosureManifest",
|
|
41
|
+
"CustodyVault",
|
|
42
|
+
"DecryptionError",
|
|
43
|
+
"ERASED_TOMBSTONE",
|
|
44
|
+
"Ed25519Signer",
|
|
45
|
+
"EmberState",
|
|
46
|
+
"ErasureCertificate",
|
|
47
|
+
"ErasureIncomplete",
|
|
48
|
+
"FileKeyStore",
|
|
49
|
+
"IdempotencyConflict",
|
|
50
|
+
"InMemoryStoreAdapter",
|
|
51
|
+
"InvariantViolation",
|
|
52
|
+
"KeyStore",
|
|
53
|
+
"LedgerEvent",
|
|
54
|
+
"LedgerIntegrityError",
|
|
55
|
+
"Mem0Adapter",
|
|
56
|
+
"MemoryItem",
|
|
57
|
+
"MemoryKeyStore",
|
|
58
|
+
"MemoryStoreAdapter",
|
|
59
|
+
"NullAnchor",
|
|
60
|
+
"QueryHit",
|
|
61
|
+
"Receipt",
|
|
62
|
+
"RedactableDoc",
|
|
63
|
+
"ReductionError",
|
|
64
|
+
"RememberResult",
|
|
65
|
+
"SQLiteLedger",
|
|
66
|
+
"SQLiteStoreAdapter",
|
|
67
|
+
"SUITE_V2",
|
|
68
|
+
"SearchResult",
|
|
69
|
+
"ShredReport",
|
|
70
|
+
"SourceRecord",
|
|
71
|
+
"SubjectScenario",
|
|
72
|
+
"TimestampAnchor",
|
|
73
|
+
"TombstonedSubjectError",
|
|
74
|
+
"VectorStoreAdapter",
|
|
75
|
+
"__version__",
|
|
76
|
+
"apply_event",
|
|
77
|
+
"compute_closure",
|
|
78
|
+
"decrypt_item_metadata",
|
|
79
|
+
"decrypt_item_payload",
|
|
80
|
+
"inclusion_proof",
|
|
81
|
+
"leaf_hash",
|
|
82
|
+
"locate_span",
|
|
83
|
+
"merkle_root",
|
|
84
|
+
"mint_flat_receipt",
|
|
85
|
+
"mint_redactable_receipt",
|
|
86
|
+
"run_benchmark",
|
|
87
|
+
"split_parts",
|
|
88
|
+
"verify_bundle",
|
|
89
|
+
"verify_ed25519_certificate",
|
|
90
|
+
"verify_ledger_chain",
|
|
91
|
+
"verify_redactable_receipt",
|
|
92
|
+
"verify_suite_hygiene",
|
|
93
|
+
]
|
ember/adapters.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Pluggable store adapters for Ember cryptographic custody.
|
|
2
|
+
|
|
3
|
+
Enables developers to wrap an existing memory engine or vector store (Mem0, LangChain/Chroma/
|
|
4
|
+
Qdrant-style stores, SQLite keyword stores) without rewriting their application interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
import inspect
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
from typing import Any, Protocol, runtime_checkable
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class AdapterHit:
|
|
22
|
+
"""Normalized record returned by any MemoryStoreAdapter search call."""
|
|
23
|
+
|
|
24
|
+
record_id: str
|
|
25
|
+
text: str
|
|
26
|
+
score: float
|
|
27
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class MemoryStoreAdapter(Protocol):
|
|
32
|
+
"""Minimal contract any backing vector store or agent memory engine satisfies."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
|
|
36
|
+
def upsert(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
item_id: str,
|
|
40
|
+
text: str,
|
|
41
|
+
subjects: tuple[str, ...],
|
|
42
|
+
metadata: dict[str, Any],
|
|
43
|
+
) -> tuple[str, ...]: ...
|
|
44
|
+
|
|
45
|
+
def search(
|
|
46
|
+
self,
|
|
47
|
+
query: str,
|
|
48
|
+
*,
|
|
49
|
+
limit: int = 5,
|
|
50
|
+
filters: dict[str, Any] | None = None,
|
|
51
|
+
) -> list[AdapterHit]: ...
|
|
52
|
+
|
|
53
|
+
def delete(self, record_ids: Sequence[str]) -> int: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_TOKEN_RE = re.compile(r"[^\W_]+", re.UNICODE)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _tokenize(text: str) -> set[str]:
|
|
60
|
+
return set(_TOKEN_RE.findall(text.lower()))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_STOPWORDS = frozenset(
|
|
64
|
+
{
|
|
65
|
+
"a",
|
|
66
|
+
"an",
|
|
67
|
+
"and",
|
|
68
|
+
"are",
|
|
69
|
+
"at",
|
|
70
|
+
"based",
|
|
71
|
+
"before",
|
|
72
|
+
"did",
|
|
73
|
+
"do",
|
|
74
|
+
"does",
|
|
75
|
+
"everyone",
|
|
76
|
+
"how",
|
|
77
|
+
"in",
|
|
78
|
+
"is",
|
|
79
|
+
"list",
|
|
80
|
+
"living",
|
|
81
|
+
"many",
|
|
82
|
+
"me",
|
|
83
|
+
"of",
|
|
84
|
+
"people",
|
|
85
|
+
"tell",
|
|
86
|
+
"the",
|
|
87
|
+
"was",
|
|
88
|
+
"what",
|
|
89
|
+
"when",
|
|
90
|
+
"where",
|
|
91
|
+
"which",
|
|
92
|
+
"who",
|
|
93
|
+
"with",
|
|
94
|
+
"work",
|
|
95
|
+
"works",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _content_tokens(text: str) -> set[str]:
|
|
101
|
+
toks = _tokenize(text)
|
|
102
|
+
stripped = toks - _STOPWORDS
|
|
103
|
+
return stripped or toks
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _matches_filters(metadata: dict[str, Any], filters: dict[str, Any] | None) -> bool:
|
|
107
|
+
if not filters:
|
|
108
|
+
return True
|
|
109
|
+
for k, v in filters.items():
|
|
110
|
+
if metadata.get(k) != v:
|
|
111
|
+
return False
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class InMemoryStoreAdapter:
|
|
116
|
+
"""Deterministic keyword-overlap store useful for unit tests, benchmarks, and baselines."""
|
|
117
|
+
|
|
118
|
+
name = "in-memory"
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
self._lock = threading.RLock()
|
|
122
|
+
self._records: dict[str, dict[str, Any]] = {}
|
|
123
|
+
|
|
124
|
+
def upsert(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
item_id: str,
|
|
128
|
+
text: str,
|
|
129
|
+
subjects: tuple[str, ...],
|
|
130
|
+
metadata: dict[str, Any],
|
|
131
|
+
) -> tuple[str, ...]:
|
|
132
|
+
rec_id = f"mem_{uuid.uuid4().hex[:12]}"
|
|
133
|
+
meta = dict(metadata)
|
|
134
|
+
meta.setdefault("ember_item_id", item_id)
|
|
135
|
+
if subjects and "user_id" not in meta:
|
|
136
|
+
meta["user_id"] = subjects[0]
|
|
137
|
+
with self._lock:
|
|
138
|
+
self._records[rec_id] = {
|
|
139
|
+
"record_id": rec_id,
|
|
140
|
+
"text": text,
|
|
141
|
+
"subjects": list(subjects),
|
|
142
|
+
"metadata": meta,
|
|
143
|
+
}
|
|
144
|
+
return (rec_id,)
|
|
145
|
+
|
|
146
|
+
def search(
|
|
147
|
+
self,
|
|
148
|
+
query: str,
|
|
149
|
+
*,
|
|
150
|
+
limit: int = 5,
|
|
151
|
+
filters: dict[str, Any] | None = None,
|
|
152
|
+
) -> list[AdapterHit]:
|
|
153
|
+
if limit <= 0:
|
|
154
|
+
return []
|
|
155
|
+
q_toks = _content_tokens(query)
|
|
156
|
+
scored: list[AdapterHit] = []
|
|
157
|
+
with self._lock:
|
|
158
|
+
for rec in self._records.values():
|
|
159
|
+
if not _matches_filters(rec["metadata"], filters):
|
|
160
|
+
continue
|
|
161
|
+
d_toks = _tokenize(rec["text"])
|
|
162
|
+
overlap = len(q_toks & d_toks)
|
|
163
|
+
if overlap == 0 and query.strip().lower() not in rec["text"].lower():
|
|
164
|
+
continue
|
|
165
|
+
score = overlap / max(1, len(q_toks))
|
|
166
|
+
scored.append(
|
|
167
|
+
AdapterHit(
|
|
168
|
+
record_id=rec["record_id"],
|
|
169
|
+
text=rec["text"],
|
|
170
|
+
score=score,
|
|
171
|
+
metadata=dict(rec["metadata"]),
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
scored.sort(key=lambda h: (-h.score, h.record_id))
|
|
175
|
+
return scored[:limit]
|
|
176
|
+
|
|
177
|
+
def delete(self, record_ids: Sequence[str]) -> int:
|
|
178
|
+
removed = 0
|
|
179
|
+
with self._lock:
|
|
180
|
+
for rid in record_ids:
|
|
181
|
+
if self._records.pop(rid, None) is not None:
|
|
182
|
+
removed += 1
|
|
183
|
+
return removed
|
|
184
|
+
|
|
185
|
+
def delete_by_user_id_naive(self, user_id: str) -> int:
|
|
186
|
+
"""Deliberately naive user_id-only delete used as baseline control in benchmarks."""
|
|
187
|
+
with self._lock:
|
|
188
|
+
doomed = [
|
|
189
|
+
rid
|
|
190
|
+
for rid, rec in self._records.items()
|
|
191
|
+
if rec["metadata"].get("user_id") == user_id
|
|
192
|
+
]
|
|
193
|
+
for rid in doomed:
|
|
194
|
+
del self._records[rid]
|
|
195
|
+
return len(doomed)
|
|
196
|
+
|
|
197
|
+
def all_records(self) -> list[dict[str, Any]]:
|
|
198
|
+
with self._lock:
|
|
199
|
+
return [dict(r) for r in self._records.values()]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SQLiteStoreAdapter:
|
|
203
|
+
"""Persistent single-file SQLite store implementing `MemoryStoreAdapter` with zero extra deps."""
|
|
204
|
+
|
|
205
|
+
name = "sqlite-store"
|
|
206
|
+
|
|
207
|
+
def __init__(self, path: str = ":memory:") -> None:
|
|
208
|
+
if path != ":memory:":
|
|
209
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
self._conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
|
211
|
+
self._lock = threading.RLock()
|
|
212
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
213
|
+
self._conn.execute("PRAGMA synchronous = FULL")
|
|
214
|
+
self._conn.execute(
|
|
215
|
+
"""
|
|
216
|
+
CREATE TABLE IF NOT EXISTS external_memories (
|
|
217
|
+
record_id TEXT PRIMARY KEY,
|
|
218
|
+
item_id TEXT NOT NULL,
|
|
219
|
+
text TEXT NOT NULL,
|
|
220
|
+
meta_json TEXT NOT NULL
|
|
221
|
+
)
|
|
222
|
+
"""
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def close(self) -> None:
|
|
226
|
+
with self._lock:
|
|
227
|
+
self._conn.close()
|
|
228
|
+
|
|
229
|
+
def upsert(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
item_id: str,
|
|
233
|
+
text: str,
|
|
234
|
+
subjects: tuple[str, ...],
|
|
235
|
+
metadata: dict[str, Any],
|
|
236
|
+
) -> tuple[str, ...]:
|
|
237
|
+
import json
|
|
238
|
+
|
|
239
|
+
rec_id = f"sql_{uuid.uuid4().hex[:12]}"
|
|
240
|
+
meta = dict(metadata)
|
|
241
|
+
meta.setdefault("ember_item_id", item_id)
|
|
242
|
+
if subjects and "user_id" not in meta:
|
|
243
|
+
meta["user_id"] = subjects[0]
|
|
244
|
+
with self._lock:
|
|
245
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
246
|
+
try:
|
|
247
|
+
self._conn.execute(
|
|
248
|
+
"INSERT INTO external_memories (record_id, item_id, text, meta_json) VALUES (?, ?, ?, ?)",
|
|
249
|
+
(rec_id, item_id, text, json.dumps(meta, sort_keys=True)),
|
|
250
|
+
)
|
|
251
|
+
self._conn.execute("COMMIT")
|
|
252
|
+
except BaseException:
|
|
253
|
+
self._conn.execute("ROLLBACK")
|
|
254
|
+
raise
|
|
255
|
+
return (rec_id,)
|
|
256
|
+
|
|
257
|
+
def search(
|
|
258
|
+
self,
|
|
259
|
+
query: str,
|
|
260
|
+
*,
|
|
261
|
+
limit: int = 5,
|
|
262
|
+
filters: dict[str, Any] | None = None,
|
|
263
|
+
) -> list[AdapterHit]:
|
|
264
|
+
import json
|
|
265
|
+
|
|
266
|
+
if limit <= 0:
|
|
267
|
+
return []
|
|
268
|
+
q_toks = _content_tokens(query)
|
|
269
|
+
with self._lock:
|
|
270
|
+
rows = self._conn.execute(
|
|
271
|
+
"SELECT record_id, text, meta_json FROM external_memories ORDER BY rowid ASC"
|
|
272
|
+
).fetchall()
|
|
273
|
+
scored: list[AdapterHit] = []
|
|
274
|
+
for rec_id, text, meta_json in rows:
|
|
275
|
+
meta = json.loads(meta_json)
|
|
276
|
+
if not _matches_filters(meta, filters):
|
|
277
|
+
continue
|
|
278
|
+
overlap = len(q_toks & _tokenize(text))
|
|
279
|
+
if overlap == 0 and query.strip().lower() not in text.lower():
|
|
280
|
+
continue
|
|
281
|
+
scored.append(
|
|
282
|
+
AdapterHit(
|
|
283
|
+
record_id=rec_id,
|
|
284
|
+
text=text,
|
|
285
|
+
score=overlap / max(1, len(q_toks)),
|
|
286
|
+
metadata=meta,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
scored.sort(key=lambda h: (-h.score, h.record_id))
|
|
290
|
+
return scored[:limit]
|
|
291
|
+
|
|
292
|
+
def delete(self, record_ids: Sequence[str]) -> int:
|
|
293
|
+
if not record_ids:
|
|
294
|
+
return 0
|
|
295
|
+
with self._lock:
|
|
296
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
297
|
+
try:
|
|
298
|
+
before = self._conn.total_changes
|
|
299
|
+
self._conn.executemany(
|
|
300
|
+
"DELETE FROM external_memories WHERE record_id = ?",
|
|
301
|
+
[(rid,) for rid in record_ids],
|
|
302
|
+
)
|
|
303
|
+
deleted = max(0, self._conn.total_changes - before)
|
|
304
|
+
self._conn.execute("COMMIT")
|
|
305
|
+
return deleted
|
|
306
|
+
except BaseException:
|
|
307
|
+
self._conn.execute("ROLLBACK")
|
|
308
|
+
raise
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class Mem0Adapter:
|
|
312
|
+
"""Drop-in custody adapter wrapping `mem0.Memory` or `mem0.MemoryClient`.
|
|
313
|
+
|
|
314
|
+
Tracks every returned Mem0 `memory_id` in Ember's structural closure so erasing a
|
|
315
|
+
subject deletes direct facts, shared workspace notes, and downstream derived
|
|
316
|
+
summaries across Mem0 regardless of `user_id` bucket fragmentation.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
name = "mem0"
|
|
320
|
+
|
|
321
|
+
def __init__(
|
|
322
|
+
self,
|
|
323
|
+
client: Any,
|
|
324
|
+
*,
|
|
325
|
+
default_user_id: str | None = None,
|
|
326
|
+
default_agent_id: str | None = None,
|
|
327
|
+
infer: bool = False,
|
|
328
|
+
) -> None:
|
|
329
|
+
self.client = client
|
|
330
|
+
self.default_user_id = default_user_id
|
|
331
|
+
self.default_agent_id = default_agent_id
|
|
332
|
+
self.infer = infer
|
|
333
|
+
|
|
334
|
+
def upsert(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
item_id: str,
|
|
338
|
+
text: str,
|
|
339
|
+
subjects: tuple[str, ...],
|
|
340
|
+
metadata: dict[str, Any],
|
|
341
|
+
) -> tuple[str, ...]:
|
|
342
|
+
meta = dict(metadata)
|
|
343
|
+
meta["ember_item_id"] = item_id
|
|
344
|
+
meta["ember_subjects"] = list(subjects)
|
|
345
|
+
|
|
346
|
+
kwargs: dict[str, Any] = {"metadata": meta, "infer": self.infer}
|
|
347
|
+
user_id = meta.get("user_id") or self.default_user_id or (subjects[0] if subjects else None)
|
|
348
|
+
agent_id = meta.get("agent_id") or self.default_agent_id
|
|
349
|
+
if user_id is not None:
|
|
350
|
+
kwargs["user_id"] = user_id
|
|
351
|
+
if agent_id is not None:
|
|
352
|
+
kwargs["agent_id"] = agent_id
|
|
353
|
+
|
|
354
|
+
sig = inspect.signature(self.client.add)
|
|
355
|
+
accepted = set(sig.parameters)
|
|
356
|
+
has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
|
357
|
+
filtered_kwargs = {k: v for k, v in kwargs.items() if k in accepted or has_varkw}
|
|
358
|
+
if "messages" in accepted:
|
|
359
|
+
raw = self.client.add(messages=text, **filtered_kwargs)
|
|
360
|
+
else:
|
|
361
|
+
raw = self.client.add(text, **filtered_kwargs)
|
|
362
|
+
|
|
363
|
+
if raw is None:
|
|
364
|
+
return ()
|
|
365
|
+
rows = raw.get("results", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
|
|
366
|
+
ids: list[str] = []
|
|
367
|
+
for entry in rows:
|
|
368
|
+
if isinstance(entry, dict) and entry.get("id"):
|
|
369
|
+
ids.append(str(entry["id"]))
|
|
370
|
+
if not ids and isinstance(raw, dict) and raw.get("id"):
|
|
371
|
+
ids.append(str(raw["id"]))
|
|
372
|
+
return tuple(ids)
|
|
373
|
+
|
|
374
|
+
def search(
|
|
375
|
+
self,
|
|
376
|
+
query: str,
|
|
377
|
+
*,
|
|
378
|
+
limit: int = 5,
|
|
379
|
+
filters: dict[str, Any] | None = None,
|
|
380
|
+
) -> list[AdapterHit]:
|
|
381
|
+
if limit <= 0:
|
|
382
|
+
return []
|
|
383
|
+
sig = inspect.signature(self.client.search)
|
|
384
|
+
params = set(sig.parameters)
|
|
385
|
+
kwargs: dict[str, Any] = {}
|
|
386
|
+
if "limit" in params:
|
|
387
|
+
kwargs["limit"] = limit
|
|
388
|
+
if filters:
|
|
389
|
+
if "filters" in params:
|
|
390
|
+
kwargs["filters"] = filters
|
|
391
|
+
else:
|
|
392
|
+
for k, v in filters.items():
|
|
393
|
+
if k in params:
|
|
394
|
+
kwargs[k] = v
|
|
395
|
+
raw = self.client.search(query, **kwargs)
|
|
396
|
+
if raw is None:
|
|
397
|
+
return []
|
|
398
|
+
rows = raw.get("results", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
|
|
399
|
+
hits: list[AdapterHit] = []
|
|
400
|
+
for entry in rows:
|
|
401
|
+
if not isinstance(entry, dict):
|
|
402
|
+
continue
|
|
403
|
+
hits.append(
|
|
404
|
+
AdapterHit(
|
|
405
|
+
record_id=str(entry.get("id", "")),
|
|
406
|
+
text=str(entry.get("memory") or entry.get("text") or entry.get("content") or ""),
|
|
407
|
+
score=float(entry.get("score", 0.0) or 0.0),
|
|
408
|
+
metadata=dict(entry.get("metadata") or {}),
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
return hits[:limit]
|
|
412
|
+
|
|
413
|
+
def delete(self, record_ids: Sequence[str]) -> int:
|
|
414
|
+
deleted = 0
|
|
415
|
+
for rid in record_ids:
|
|
416
|
+
if not rid:
|
|
417
|
+
continue
|
|
418
|
+
try:
|
|
419
|
+
sig = inspect.signature(self.client.delete)
|
|
420
|
+
if "memory_id" in sig.parameters:
|
|
421
|
+
self.client.delete(memory_id=rid)
|
|
422
|
+
else:
|
|
423
|
+
self.client.delete(rid)
|
|
424
|
+
deleted += 1
|
|
425
|
+
except TypeError:
|
|
426
|
+
self.client.delete(rid)
|
|
427
|
+
deleted += 1
|
|
428
|
+
return deleted
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class VectorStoreAdapter:
|
|
432
|
+
"""Adapter for LangChain / Chroma / Qdrant-compatible `.add_texts` / `.similarity_search` / `.delete`."""
|
|
433
|
+
|
|
434
|
+
name = "vector-store"
|
|
435
|
+
|
|
436
|
+
def __init__(self, store: Any) -> None:
|
|
437
|
+
self.store = store
|
|
438
|
+
|
|
439
|
+
def upsert(
|
|
440
|
+
self,
|
|
441
|
+
*,
|
|
442
|
+
item_id: str,
|
|
443
|
+
text: str,
|
|
444
|
+
subjects: tuple[str, ...],
|
|
445
|
+
metadata: dict[str, Any],
|
|
446
|
+
) -> tuple[str, ...]:
|
|
447
|
+
meta = {**metadata, "ember_item_id": item_id, "ember_subjects": list(subjects)}
|
|
448
|
+
rec_id = f"vec_{uuid.uuid4().hex[:12]}"
|
|
449
|
+
sig = inspect.signature(self.store.add_texts)
|
|
450
|
+
params = set(sig.parameters)
|
|
451
|
+
has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
|
452
|
+
kwargs: dict[str, Any] = {}
|
|
453
|
+
if "metadatas" in params or has_varkw:
|
|
454
|
+
kwargs["metadatas"] = [meta]
|
|
455
|
+
if "ids" in params or has_varkw:
|
|
456
|
+
kwargs["ids"] = [rec_id]
|
|
457
|
+
returned = self.store.add_texts(texts=[text], **kwargs)
|
|
458
|
+
if isinstance(returned, (list, tuple)) and returned:
|
|
459
|
+
return tuple(str(x) for x in returned)
|
|
460
|
+
return (rec_id,) if ("ids" in kwargs) else ()
|
|
461
|
+
|
|
462
|
+
def search(
|
|
463
|
+
self,
|
|
464
|
+
query: str,
|
|
465
|
+
*,
|
|
466
|
+
limit: int = 5,
|
|
467
|
+
filters: dict[str, Any] | None = None,
|
|
468
|
+
) -> list[AdapterHit]:
|
|
469
|
+
if limit <= 0:
|
|
470
|
+
return []
|
|
471
|
+
if hasattr(self.store, "similarity_search_with_score"):
|
|
472
|
+
sig = inspect.signature(self.store.similarity_search_with_score)
|
|
473
|
+
params = set(sig.parameters)
|
|
474
|
+
has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
|
475
|
+
kwargs: dict[str, Any] = {}
|
|
476
|
+
if "k" in params or has_varkw:
|
|
477
|
+
kwargs["k"] = limit
|
|
478
|
+
if filters is not None and ("filter" in params or has_varkw):
|
|
479
|
+
kwargs["filter"] = filters
|
|
480
|
+
pairs = self.store.similarity_search_with_score(query, **kwargs)
|
|
481
|
+
hits: list[AdapterHit] = []
|
|
482
|
+
for doc, score in pairs:
|
|
483
|
+
meta = dict(getattr(doc, "metadata", {}) or {})
|
|
484
|
+
hits.append(
|
|
485
|
+
AdapterHit(
|
|
486
|
+
record_id=str(getattr(doc, "id", None) or meta.get("id") or meta.get("ember_item_id") or ""),
|
|
487
|
+
text=str(getattr(doc, "page_content", "")),
|
|
488
|
+
score=float(score),
|
|
489
|
+
metadata=meta,
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
return hits[:limit]
|
|
493
|
+
sig = inspect.signature(self.store.similarity_search)
|
|
494
|
+
params = set(sig.parameters)
|
|
495
|
+
has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
|
496
|
+
kwargs = {}
|
|
497
|
+
if "k" in params or has_varkw:
|
|
498
|
+
kwargs["k"] = limit
|
|
499
|
+
if filters is not None and ("filter" in params or has_varkw):
|
|
500
|
+
kwargs["filter"] = filters
|
|
501
|
+
docs = self.store.similarity_search(query, **kwargs)
|
|
502
|
+
return [
|
|
503
|
+
AdapterHit(
|
|
504
|
+
record_id=str(getattr(doc, "id", None) or (getattr(doc, "metadata", {}) or {}).get("ember_item_id", "")),
|
|
505
|
+
text=str(getattr(doc, "page_content", "")),
|
|
506
|
+
score=1.0,
|
|
507
|
+
metadata=dict(getattr(doc, "metadata", {}) or {}),
|
|
508
|
+
)
|
|
509
|
+
for doc in docs
|
|
510
|
+
][:limit]
|
|
511
|
+
|
|
512
|
+
def delete(self, record_ids: Sequence[str]) -> int:
|
|
513
|
+
ids = list(dict.fromkeys(rid for rid in record_ids if rid))
|
|
514
|
+
if not ids:
|
|
515
|
+
return 0
|
|
516
|
+
sig = inspect.signature(self.store.delete)
|
|
517
|
+
params = set(sig.parameters)
|
|
518
|
+
if "ids" in params or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
|
519
|
+
self.store.delete(ids=ids)
|
|
520
|
+
else:
|
|
521
|
+
self.store.delete(ids)
|
|
522
|
+
return len(ids)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
ProbeFilterFn = Callable[[AdapterHit], bool]
|