memwal 0.1.6.dev0__tar.gz → 0.1.6.dev2__tar.gz
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.
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/PKG-INFO +19 -2
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/README.md +17 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/__init__.py +5 -1
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/client.py +56 -10
- memwal-0.1.6.dev2/memwal/mock.py +585 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/pyproject.toml +1 -1
- memwal-0.1.6.dev2/tests/test_mock.py +162 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/.gitignore +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/CHANGELOG.md +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/.env.example +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/.gitignore +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/interactive_demo.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/examples/verify_credentials.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/compatibility.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/middleware.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/types.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/memwal/utils.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/run_tests.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/__init__.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_client.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_env_presets.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_integration.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_middleware.py +0 -0
- {memwal-0.1.6.dev0 → memwal-0.1.6.dev2}/tests/test_signing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.6.
|
|
3
|
+
Version: 0.1.6.dev2
|
|
4
4
|
Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
|
|
5
5
|
Project-URL: Homepage, https://memory.walrus.xyz
|
|
6
6
|
Project-URL: Documentation, https://memory.walrus.xyz
|
|
@@ -124,6 +124,23 @@ matches = client.recall(RecallParams(query="food allergies"))
|
|
|
124
124
|
client.close()
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
+
### Offline tests and CI
|
|
128
|
+
|
|
129
|
+
`MemWalMock` and `MemWalMockSync` implement the common memory API in process. They require no credentials, relayer, chain, or paid storage and use deterministic token-overlap ranking.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from memwal import MemWalMock, RecallParams
|
|
133
|
+
|
|
134
|
+
async def test_memory_flow():
|
|
135
|
+
memwal = MemWalMock.create(namespace="test-user")
|
|
136
|
+
await memwal.remember_and_wait("The user prefers dark mode")
|
|
137
|
+
|
|
138
|
+
result = await memwal.recall(RecallParams(query="display preference"))
|
|
139
|
+
assert "dark mode" in result.results[0].text
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, `forget(blob_id)`, and `clear(namespace)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.
|
|
143
|
+
|
|
127
144
|
### Context Manager
|
|
128
145
|
|
|
129
146
|
```python
|
|
@@ -85,6 +85,23 @@ matches = client.recall(RecallParams(query="food allergies"))
|
|
|
85
85
|
client.close()
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
### Offline tests and CI
|
|
89
|
+
|
|
90
|
+
`MemWalMock` and `MemWalMockSync` implement the common memory API in process. They require no credentials, relayer, chain, or paid storage and use deterministic token-overlap ranking.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from memwal import MemWalMock, RecallParams
|
|
94
|
+
|
|
95
|
+
async def test_memory_flow():
|
|
96
|
+
memwal = MemWalMock.create(namespace="test-user")
|
|
97
|
+
await memwal.remember_and_wait("The user prefers dark mode")
|
|
98
|
+
|
|
99
|
+
result = await memwal.recall(RecallParams(query="display preference"))
|
|
100
|
+
assert "dark mode" in result.results[0].text
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The mock supports remember/job polling, bulk remember, recall, analyze, embed, ask, health, restore, `forget(blob_id)`, and `clear(namespace)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.
|
|
104
|
+
|
|
88
105
|
### Context Manager
|
|
89
106
|
|
|
90
107
|
```python
|
|
@@ -32,6 +32,7 @@ from .client import (
|
|
|
32
32
|
MemWalSync,
|
|
33
33
|
)
|
|
34
34
|
from .middleware import with_memwal_langchain, with_memwal_openai
|
|
35
|
+
from .mock import MemWalMock, MemWalMockSeed, MemWalMockSync
|
|
35
36
|
from .types import (
|
|
36
37
|
ENV_PRESETS,
|
|
37
38
|
AnalyzedFact,
|
|
@@ -72,6 +73,9 @@ __all__ = [
|
|
|
72
73
|
# Core client
|
|
73
74
|
"MemWal",
|
|
74
75
|
"MemWalSync",
|
|
76
|
+
"MemWalMock",
|
|
77
|
+
"MemWalMockSync",
|
|
78
|
+
"MemWalMockSeed",
|
|
75
79
|
"MemWalError",
|
|
76
80
|
"MemWalCompatibilityError",
|
|
77
81
|
"MemWalRememberJobFailed",
|
|
@@ -116,4 +120,4 @@ __all__ = [
|
|
|
116
120
|
"RecallManualResult",
|
|
117
121
|
]
|
|
118
122
|
|
|
119
|
-
__version__ = "0.1.6.
|
|
123
|
+
__version__ = "0.1.6.dev2"
|
|
@@ -30,6 +30,7 @@ import base64
|
|
|
30
30
|
import json
|
|
31
31
|
import random
|
|
32
32
|
import time
|
|
33
|
+
import uuid
|
|
33
34
|
from datetime import datetime, timezone
|
|
34
35
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, Union
|
|
35
36
|
|
|
@@ -223,6 +224,9 @@ class MemWal:
|
|
|
223
224
|
self._session_build_task: Optional[asyncio.Task[str]] = None
|
|
224
225
|
self._relayer_version_metadata: Optional[Dict[str, Any]] = None
|
|
225
226
|
self._compatibility_lock: Optional[asyncio.Lock] = None
|
|
227
|
+
# Preserve a generated key across an ambiguous transport failure. A
|
|
228
|
+
# subsequent identical call then collapses onto the accepted paid job.
|
|
229
|
+
self._pending_remember_keys: Dict[str, str] = {}
|
|
226
230
|
|
|
227
231
|
@classmethod
|
|
228
232
|
def create(
|
|
@@ -281,7 +285,10 @@ class MemWal:
|
|
|
281
285
|
# ============================================================
|
|
282
286
|
|
|
283
287
|
async def remember(
|
|
284
|
-
self,
|
|
288
|
+
self,
|
|
289
|
+
text: str,
|
|
290
|
+
namespace: Optional[str] = None,
|
|
291
|
+
idempotency_key: Optional[str] = None,
|
|
285
292
|
) -> RememberAcceptedResult:
|
|
286
293
|
"""Submit a remember request and return as soon as the server accepts it.
|
|
287
294
|
|
|
@@ -299,12 +306,27 @@ class MemWal:
|
|
|
299
306
|
:class:`RememberAcceptedResult` with ``job_id`` and initial
|
|
300
307
|
status (``"pending"``).
|
|
301
308
|
"""
|
|
309
|
+
resolved_namespace = namespace or self._namespace
|
|
310
|
+
request_identity = f"{resolved_namespace}\0{text}"
|
|
311
|
+
generated_key = idempotency_key is None
|
|
312
|
+
resolved_key = idempotency_key or self._pending_remember_keys.get(request_identity)
|
|
313
|
+
if resolved_key is None:
|
|
314
|
+
resolved_key = str(uuid.uuid4())
|
|
315
|
+
if generated_key:
|
|
316
|
+
self._pending_remember_keys[request_identity] = resolved_key
|
|
317
|
+
|
|
302
318
|
data = await self._signed_request(
|
|
303
319
|
"POST",
|
|
304
320
|
"/api/remember",
|
|
305
|
-
{
|
|
321
|
+
{
|
|
322
|
+
"text": text,
|
|
323
|
+
"namespace": resolved_namespace,
|
|
324
|
+
"idempotency_key": resolved_key,
|
|
325
|
+
},
|
|
306
326
|
accepted_statuses=(200, 202),
|
|
307
327
|
)
|
|
328
|
+
if generated_key:
|
|
329
|
+
self._pending_remember_keys.pop(request_identity, None)
|
|
308
330
|
return RememberAcceptedResult(
|
|
309
331
|
job_id=data["job_id"],
|
|
310
332
|
status=data.get("status", "pending"),
|
|
@@ -312,9 +334,12 @@ class MemWal:
|
|
|
312
334
|
|
|
313
335
|
# Alias for parity with TS SDK ``rememberAsync``.
|
|
314
336
|
async def remember_async(
|
|
315
|
-
self,
|
|
337
|
+
self,
|
|
338
|
+
text: str,
|
|
339
|
+
namespace: Optional[str] = None,
|
|
340
|
+
idempotency_key: Optional[str] = None,
|
|
316
341
|
) -> RememberAcceptedResult:
|
|
317
|
-
return await self.remember(text, namespace)
|
|
342
|
+
return await self.remember(text, namespace, idempotency_key)
|
|
318
343
|
|
|
319
344
|
async def wait_for_remember_job(
|
|
320
345
|
self,
|
|
@@ -380,6 +405,7 @@ class MemWal:
|
|
|
380
405
|
namespace: Optional[str] = None,
|
|
381
406
|
poll_interval_ms: int = 1500,
|
|
382
407
|
timeout_ms: int = 60_000,
|
|
408
|
+
idempotency_key: Optional[str] = None,
|
|
383
409
|
) -> RememberResult:
|
|
384
410
|
"""Submit a remember and wait for the background worker to finish.
|
|
385
411
|
|
|
@@ -388,12 +414,24 @@ class MemWal:
|
|
|
388
414
|
``failed``). Mirrors TS ``rememberAndWait``.
|
|
389
415
|
"""
|
|
390
416
|
|
|
391
|
-
|
|
392
|
-
|
|
417
|
+
resolved_namespace = namespace or self._namespace
|
|
418
|
+
request_identity = f"{resolved_namespace}\0{text}"
|
|
419
|
+
generated_key = idempotency_key is None
|
|
420
|
+
resolved_key = idempotency_key or self._pending_remember_keys.get(request_identity)
|
|
421
|
+
if resolved_key is None:
|
|
422
|
+
resolved_key = str(uuid.uuid4())
|
|
423
|
+
if generated_key:
|
|
424
|
+
self._pending_remember_keys[request_identity] = resolved_key
|
|
425
|
+
|
|
426
|
+
accepted = await self.remember(text, resolved_namespace, resolved_key)
|
|
427
|
+
result = await self.wait_for_remember_job(
|
|
393
428
|
accepted.job_id,
|
|
394
429
|
poll_interval_ms=poll_interval_ms,
|
|
395
430
|
timeout_ms=timeout_ms,
|
|
396
431
|
)
|
|
432
|
+
if generated_key:
|
|
433
|
+
self._pending_remember_keys.pop(request_identity, None)
|
|
434
|
+
return result
|
|
397
435
|
|
|
398
436
|
# ============================================================
|
|
399
437
|
# Bulk remember (ENG-1408)
|
|
@@ -1285,16 +1323,22 @@ class MemWalSync:
|
|
|
1285
1323
|
return asyncio.run(coro)
|
|
1286
1324
|
|
|
1287
1325
|
def remember(
|
|
1288
|
-
self,
|
|
1326
|
+
self,
|
|
1327
|
+
text: str,
|
|
1328
|
+
namespace: Optional[str] = None,
|
|
1329
|
+
idempotency_key: Optional[str] = None,
|
|
1289
1330
|
) -> RememberAcceptedResult:
|
|
1290
1331
|
"""Synchronous version of :meth:`MemWal.remember` (async accept)."""
|
|
1291
|
-
return self._run(self._inner.remember(text, namespace))
|
|
1332
|
+
return self._run(self._inner.remember(text, namespace, idempotency_key))
|
|
1292
1333
|
|
|
1293
1334
|
# Alias for parity with TS SDK ``rememberAsync``.
|
|
1294
1335
|
def remember_async(
|
|
1295
|
-
self,
|
|
1336
|
+
self,
|
|
1337
|
+
text: str,
|
|
1338
|
+
namespace: Optional[str] = None,
|
|
1339
|
+
idempotency_key: Optional[str] = None,
|
|
1296
1340
|
) -> RememberAcceptedResult:
|
|
1297
|
-
return self._run(self._inner.remember_async(text, namespace))
|
|
1341
|
+
return self._run(self._inner.remember_async(text, namespace, idempotency_key))
|
|
1298
1342
|
|
|
1299
1343
|
def wait_for_remember_job(
|
|
1300
1344
|
self,
|
|
@@ -1317,6 +1361,7 @@ class MemWalSync:
|
|
|
1317
1361
|
namespace: Optional[str] = None,
|
|
1318
1362
|
poll_interval_ms: int = 1500,
|
|
1319
1363
|
timeout_ms: int = 60_000,
|
|
1364
|
+
idempotency_key: Optional[str] = None,
|
|
1320
1365
|
) -> RememberResult:
|
|
1321
1366
|
"""Synchronous version of :meth:`MemWal.remember_and_wait`."""
|
|
1322
1367
|
return self._run(
|
|
@@ -1325,6 +1370,7 @@ class MemWalSync:
|
|
|
1325
1370
|
namespace,
|
|
1326
1371
|
poll_interval_ms=poll_interval_ms,
|
|
1327
1372
|
timeout_ms=timeout_ms,
|
|
1373
|
+
idempotency_key=idempotency_key,
|
|
1328
1374
|
)
|
|
1329
1375
|
)
|
|
1330
1376
|
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""Deterministic, dependency-free in-memory Walrus Memory client for tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
from .types import (
|
|
14
|
+
AnalyzedFact,
|
|
15
|
+
AnalyzeResult,
|
|
16
|
+
AnalyzeWaitResult,
|
|
17
|
+
AskMemory,
|
|
18
|
+
AskResult,
|
|
19
|
+
EmbedResult,
|
|
20
|
+
HealthResult,
|
|
21
|
+
RecallMemory,
|
|
22
|
+
RecallParams,
|
|
23
|
+
RecallResult,
|
|
24
|
+
RememberAcceptedResult,
|
|
25
|
+
RememberBulkAcceptedResult,
|
|
26
|
+
RememberBulkItem,
|
|
27
|
+
RememberBulkItemResult,
|
|
28
|
+
RememberBulkOptions,
|
|
29
|
+
RememberBulkResult,
|
|
30
|
+
RememberBulkStatusItem,
|
|
31
|
+
RememberBulkStatusResult,
|
|
32
|
+
RememberJobStatus,
|
|
33
|
+
RememberResult,
|
|
34
|
+
RestoreResult,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class MemWalMockSeed:
|
|
40
|
+
"""One optional memory loaded when a mock is created."""
|
|
41
|
+
|
|
42
|
+
text: str
|
|
43
|
+
namespace: Optional[str] = None
|
|
44
|
+
blob_id: Optional[str] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class _Memory:
|
|
49
|
+
id: str
|
|
50
|
+
job_id: str
|
|
51
|
+
blob_id: str
|
|
52
|
+
text: str
|
|
53
|
+
namespace: str
|
|
54
|
+
sequence: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _tokens(text: str) -> set[str]:
|
|
58
|
+
normalized = unicodedata.normalize("NFKC", text).casefold()
|
|
59
|
+
return set(re.findall(r"[^\W_]+", normalized, flags=re.UNICODE))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _distance(query_tokens: set[str], text: str) -> float:
|
|
63
|
+
if not query_tokens:
|
|
64
|
+
return 1.0
|
|
65
|
+
matches = len(query_tokens.intersection(_tokens(text)))
|
|
66
|
+
return 1.0 - matches / len(query_tokens)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_text(text: str, field: str = "text") -> None:
|
|
70
|
+
if not isinstance(text, str) or not text.strip():
|
|
71
|
+
raise ValueError(f"{field} cannot be empty")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MemWalMock:
|
|
75
|
+
"""In-memory implementation of the common async :class:`MemWal` API.
|
|
76
|
+
|
|
77
|
+
The mock never creates an HTTP client or contacts Sui/Walrus. Recall uses
|
|
78
|
+
deterministic token-overlap distance; it is intentionally simple and is
|
|
79
|
+
designed for application tests, not relevance benchmarking.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
namespace: str = "default",
|
|
85
|
+
owner: str = "mock-owner",
|
|
86
|
+
initial_memories: Optional[Sequence[MemWalMockSeed]] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
if not namespace:
|
|
89
|
+
raise ValueError("namespace cannot be empty")
|
|
90
|
+
self._namespace = namespace
|
|
91
|
+
self._owner = owner
|
|
92
|
+
self._memories: List[_Memory] = []
|
|
93
|
+
self._jobs: Dict[str, _Memory] = {}
|
|
94
|
+
self._sequence = 0
|
|
95
|
+
for seed in initial_memories or []:
|
|
96
|
+
self._store(seed.text, seed.namespace, seed.blob_id)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def create(
|
|
100
|
+
cls,
|
|
101
|
+
namespace: str = "default",
|
|
102
|
+
owner: str = "mock-owner",
|
|
103
|
+
initial_memories: Optional[Sequence[MemWalMockSeed]] = None,
|
|
104
|
+
) -> "MemWalMock":
|
|
105
|
+
return cls(namespace, owner, initial_memories)
|
|
106
|
+
|
|
107
|
+
async def close(self) -> None:
|
|
108
|
+
"""No-op for API parity with :class:`MemWal`."""
|
|
109
|
+
|
|
110
|
+
async def __aenter__(self) -> "MemWalMock":
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
114
|
+
await self.close()
|
|
115
|
+
|
|
116
|
+
async def remember(
|
|
117
|
+
self, text: str, namespace: Optional[str] = None
|
|
118
|
+
) -> RememberAcceptedResult:
|
|
119
|
+
memory = self._store(text, namespace)
|
|
120
|
+
return RememberAcceptedResult(job_id=memory.job_id, status="done")
|
|
121
|
+
|
|
122
|
+
async def remember_async(
|
|
123
|
+
self, text: str, namespace: Optional[str] = None
|
|
124
|
+
) -> RememberAcceptedResult:
|
|
125
|
+
return await self.remember(text, namespace)
|
|
126
|
+
|
|
127
|
+
async def wait_for_remember_job(
|
|
128
|
+
self,
|
|
129
|
+
job_id: str,
|
|
130
|
+
poll_interval_ms: int = 1500,
|
|
131
|
+
timeout_ms: int = 60_000,
|
|
132
|
+
) -> RememberResult:
|
|
133
|
+
del poll_interval_ms, timeout_ms
|
|
134
|
+
memory = self._jobs.get(job_id)
|
|
135
|
+
if memory is None:
|
|
136
|
+
raise KeyError(f"Remember job not found: {job_id}")
|
|
137
|
+
return self._remember_result(memory)
|
|
138
|
+
|
|
139
|
+
async def remember_and_wait(
|
|
140
|
+
self,
|
|
141
|
+
text: str,
|
|
142
|
+
namespace: Optional[str] = None,
|
|
143
|
+
poll_interval_ms: int = 1500,
|
|
144
|
+
timeout_ms: int = 60_000,
|
|
145
|
+
) -> RememberResult:
|
|
146
|
+
accepted = await self.remember(text, namespace)
|
|
147
|
+
return await self.wait_for_remember_job(
|
|
148
|
+
accepted.job_id, poll_interval_ms, timeout_ms
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def get_remember_status(self, job_id: str) -> RememberJobStatus:
|
|
152
|
+
memory = self._jobs.get(job_id)
|
|
153
|
+
if memory is None:
|
|
154
|
+
return RememberJobStatus(job_id=job_id, status="not_found")
|
|
155
|
+
return RememberJobStatus(
|
|
156
|
+
job_id=job_id,
|
|
157
|
+
status="done",
|
|
158
|
+
owner=self._owner,
|
|
159
|
+
namespace=memory.namespace,
|
|
160
|
+
blob_id=memory.blob_id,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def remember_bulk_async(
|
|
164
|
+
self, items: Sequence[RememberBulkItem]
|
|
165
|
+
) -> RememberBulkAcceptedResult:
|
|
166
|
+
job_ids = [self._store(item.text, item.namespace).job_id for item in items]
|
|
167
|
+
return RememberBulkAcceptedResult(
|
|
168
|
+
job_ids=job_ids, total=len(job_ids), status="done"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def remember_bulk(
|
|
172
|
+
self, items: Sequence[RememberBulkItem]
|
|
173
|
+
) -> RememberBulkAcceptedResult:
|
|
174
|
+
return await self.remember_bulk_async(items)
|
|
175
|
+
|
|
176
|
+
async def get_remember_bulk_status(
|
|
177
|
+
self, job_ids: Sequence[str]
|
|
178
|
+
) -> RememberBulkStatusResult:
|
|
179
|
+
results = []
|
|
180
|
+
for job_id in job_ids:
|
|
181
|
+
memory = self._jobs.get(job_id)
|
|
182
|
+
results.append(
|
|
183
|
+
RememberBulkStatusItem(
|
|
184
|
+
job_id=job_id,
|
|
185
|
+
status="done" if memory else "not_found",
|
|
186
|
+
blob_id=memory.blob_id if memory else None,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return RememberBulkStatusResult(results=results)
|
|
190
|
+
|
|
191
|
+
async def wait_for_remember_jobs(
|
|
192
|
+
self,
|
|
193
|
+
job_ids: Sequence[str],
|
|
194
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
195
|
+
) -> RememberBulkResult:
|
|
196
|
+
del opts
|
|
197
|
+
results: List[RememberBulkItemResult] = []
|
|
198
|
+
for job_id in job_ids:
|
|
199
|
+
memory = self._jobs.get(job_id)
|
|
200
|
+
results.append(
|
|
201
|
+
RememberBulkItemResult(
|
|
202
|
+
id=job_id,
|
|
203
|
+
blob_id=memory.blob_id if memory else "",
|
|
204
|
+
status="done" if memory else "failed",
|
|
205
|
+
error=None if memory else "Remember job not found",
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
succeeded = sum(result.status == "done" for result in results)
|
|
209
|
+
return RememberBulkResult(
|
|
210
|
+
results=results,
|
|
211
|
+
total=len(results),
|
|
212
|
+
succeeded=succeeded,
|
|
213
|
+
failed=len(results) - succeeded,
|
|
214
|
+
timed_out=0,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def remember_bulk_and_wait(
|
|
218
|
+
self,
|
|
219
|
+
items: Sequence[RememberBulkItem],
|
|
220
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
221
|
+
) -> RememberBulkResult:
|
|
222
|
+
accepted = await self.remember_bulk_async(items)
|
|
223
|
+
return await self.wait_for_remember_jobs(accepted.job_ids, opts)
|
|
224
|
+
|
|
225
|
+
async def recall(
|
|
226
|
+
self,
|
|
227
|
+
query: Union[str, RecallParams],
|
|
228
|
+
limit: int = 10,
|
|
229
|
+
namespace: Optional[str] = None,
|
|
230
|
+
max_distance: Optional[float] = None,
|
|
231
|
+
) -> RecallResult:
|
|
232
|
+
if isinstance(query, RecallParams):
|
|
233
|
+
limit = query.limit
|
|
234
|
+
namespace = query.namespace
|
|
235
|
+
max_distance = query.max_distance
|
|
236
|
+
query_text = query.query
|
|
237
|
+
else:
|
|
238
|
+
query_text = query
|
|
239
|
+
_validate_text(query_text, "query")
|
|
240
|
+
if not isinstance(limit, int) or limit < 0:
|
|
241
|
+
raise ValueError("limit must be a non-negative integer")
|
|
242
|
+
resolved_namespace = namespace or self._namespace
|
|
243
|
+
query_tokens = _tokens(query_text)
|
|
244
|
+
ranked = [
|
|
245
|
+
(memory, _distance(query_tokens, memory.text))
|
|
246
|
+
for memory in self._memories
|
|
247
|
+
if memory.namespace == resolved_namespace
|
|
248
|
+
]
|
|
249
|
+
if max_distance is not None:
|
|
250
|
+
ranked = [item for item in ranked if item[1] < max_distance]
|
|
251
|
+
ranked.sort(key=lambda item: (item[1], item[0].sequence))
|
|
252
|
+
results = [
|
|
253
|
+
RecallMemory(blob_id=memory.blob_id, text=memory.text, distance=score)
|
|
254
|
+
for memory, score in ranked[:limit]
|
|
255
|
+
]
|
|
256
|
+
return RecallResult(results=results, total=len(results))
|
|
257
|
+
|
|
258
|
+
async def analyze(
|
|
259
|
+
self,
|
|
260
|
+
text: str,
|
|
261
|
+
namespace: Optional[str] = None,
|
|
262
|
+
occurred_at: Optional[Union[str, datetime]] = None,
|
|
263
|
+
) -> AnalyzeResult:
|
|
264
|
+
del occurred_at
|
|
265
|
+
memory = self._store(text, namespace)
|
|
266
|
+
return AnalyzeResult(
|
|
267
|
+
facts=[
|
|
268
|
+
AnalyzedFact(
|
|
269
|
+
text=text, id=memory.id, blob_id=memory.blob_id
|
|
270
|
+
)
|
|
271
|
+
],
|
|
272
|
+
fact_count=1,
|
|
273
|
+
job_ids=[memory.job_id],
|
|
274
|
+
status="done",
|
|
275
|
+
owner=self._owner,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def analyze_and_wait(
|
|
279
|
+
self,
|
|
280
|
+
text: str,
|
|
281
|
+
namespace: Optional[str] = None,
|
|
282
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
283
|
+
occurred_at: Optional[Union[str, datetime]] = None,
|
|
284
|
+
) -> AnalyzeWaitResult:
|
|
285
|
+
analyzed = await self.analyze(text, namespace, occurred_at=occurred_at)
|
|
286
|
+
settled = await self.wait_for_remember_jobs(analyzed.job_ids, opts)
|
|
287
|
+
return AnalyzeWaitResult(
|
|
288
|
+
results=settled.results,
|
|
289
|
+
total=settled.total,
|
|
290
|
+
succeeded=settled.succeeded,
|
|
291
|
+
failed=settled.failed,
|
|
292
|
+
timed_out=settled.timed_out,
|
|
293
|
+
facts=analyzed.facts,
|
|
294
|
+
owner=self._owner,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
async def embed(self, text: str) -> EmbedResult:
|
|
298
|
+
_validate_text(text)
|
|
299
|
+
vector = [0.0] * 16
|
|
300
|
+
for token in _tokens(text):
|
|
301
|
+
value = 2166136261
|
|
302
|
+
for char in token:
|
|
303
|
+
value ^= ord(char)
|
|
304
|
+
value = (value * 16777619) & 0xFFFFFFFF
|
|
305
|
+
vector[value % len(vector)] += 1.0
|
|
306
|
+
magnitude = math.sqrt(sum(value * value for value in vector)) or 1.0
|
|
307
|
+
return EmbedResult(vector=[value / magnitude for value in vector])
|
|
308
|
+
|
|
309
|
+
async def ask(
|
|
310
|
+
self, question: str, limit: int = 5, namespace: Optional[str] = None
|
|
311
|
+
) -> AskResult:
|
|
312
|
+
recalled = await self.recall(question, limit, namespace)
|
|
313
|
+
memories = [
|
|
314
|
+
AskMemory(
|
|
315
|
+
blob_id=memory.blob_id,
|
|
316
|
+
text=memory.text,
|
|
317
|
+
distance=memory.distance,
|
|
318
|
+
)
|
|
319
|
+
for memory in recalled.results
|
|
320
|
+
]
|
|
321
|
+
return AskResult(
|
|
322
|
+
answer="\n".join(memory.text for memory in memories),
|
|
323
|
+
memories_used=len(memories),
|
|
324
|
+
memories=memories,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
|
|
328
|
+
del limit
|
|
329
|
+
return RestoreResult(
|
|
330
|
+
restored=0,
|
|
331
|
+
skipped=0,
|
|
332
|
+
total=0,
|
|
333
|
+
namespace=namespace,
|
|
334
|
+
owner=self._owner,
|
|
335
|
+
truncated=False,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async def health(self) -> HealthResult:
|
|
339
|
+
return HealthResult(
|
|
340
|
+
status="ok",
|
|
341
|
+
version="memwal-mock",
|
|
342
|
+
relayer_version="memwal-mock",
|
|
343
|
+
api_version="1.0.0",
|
|
344
|
+
min_supported_sdk={"typescript": "0.0.0", "python": "0.0.0", "mcp": "0.0.0"},
|
|
345
|
+
feature_flags={"offlineMock": True},
|
|
346
|
+
deprecations=[],
|
|
347
|
+
build={},
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
async def compatibility(self) -> Dict[str, Any]:
|
|
351
|
+
return {
|
|
352
|
+
"relayerVersion": "memwal-mock",
|
|
353
|
+
"apiVersion": "1.0.0",
|
|
354
|
+
"minSupportedSdk": {
|
|
355
|
+
"typescript": "0.0.0",
|
|
356
|
+
"python": "0.0.0",
|
|
357
|
+
"mcp": "0.0.0",
|
|
358
|
+
},
|
|
359
|
+
"featureFlags": {"offlineMock": True},
|
|
360
|
+
"deprecations": [],
|
|
361
|
+
"build": {},
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
def forget(self, blob_id: str) -> bool:
|
|
365
|
+
for index, memory in enumerate(self._memories):
|
|
366
|
+
if memory.blob_id == blob_id:
|
|
367
|
+
self._memories.pop(index)
|
|
368
|
+
self._jobs.pop(memory.job_id, None)
|
|
369
|
+
return True
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
def clear(self, namespace: Optional[str] = None) -> int:
|
|
373
|
+
removed = [
|
|
374
|
+
memory
|
|
375
|
+
for memory in self._memories
|
|
376
|
+
if namespace is None or memory.namespace == namespace
|
|
377
|
+
]
|
|
378
|
+
self._memories = [
|
|
379
|
+
memory
|
|
380
|
+
for memory in self._memories
|
|
381
|
+
if namespace is not None and memory.namespace != namespace
|
|
382
|
+
]
|
|
383
|
+
for memory in removed:
|
|
384
|
+
self._jobs.pop(memory.job_id, None)
|
|
385
|
+
return len(removed)
|
|
386
|
+
|
|
387
|
+
def _store(
|
|
388
|
+
self,
|
|
389
|
+
text: str,
|
|
390
|
+
namespace: Optional[str] = None,
|
|
391
|
+
blob_id: Optional[str] = None,
|
|
392
|
+
) -> _Memory:
|
|
393
|
+
_validate_text(text)
|
|
394
|
+
resolved_namespace = namespace or self._namespace
|
|
395
|
+
if not resolved_namespace:
|
|
396
|
+
raise ValueError("namespace cannot be empty")
|
|
397
|
+
self._sequence += 1
|
|
398
|
+
suffix = f"{self._sequence:06d}"
|
|
399
|
+
memory = _Memory(
|
|
400
|
+
id=f"mock-job-{suffix}",
|
|
401
|
+
job_id=f"mock-job-{suffix}",
|
|
402
|
+
blob_id=blob_id or f"mock-blob-{suffix}",
|
|
403
|
+
text=text,
|
|
404
|
+
namespace=resolved_namespace,
|
|
405
|
+
sequence=self._sequence,
|
|
406
|
+
)
|
|
407
|
+
self._memories.append(memory)
|
|
408
|
+
self._jobs[memory.job_id] = memory
|
|
409
|
+
return memory
|
|
410
|
+
|
|
411
|
+
def _remember_result(self, memory: _Memory) -> RememberResult:
|
|
412
|
+
return RememberResult(
|
|
413
|
+
id=memory.id,
|
|
414
|
+
blob_id=memory.blob_id,
|
|
415
|
+
owner=self._owner,
|
|
416
|
+
namespace=memory.namespace,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class MemWalMockSync:
|
|
421
|
+
"""Synchronous wrapper around :class:`MemWalMock` for non-async tests."""
|
|
422
|
+
|
|
423
|
+
def __init__(self, inner: MemWalMock) -> None:
|
|
424
|
+
self._inner = inner
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def create(
|
|
428
|
+
cls,
|
|
429
|
+
namespace: str = "default",
|
|
430
|
+
owner: str = "mock-owner",
|
|
431
|
+
initial_memories: Optional[Sequence[MemWalMockSeed]] = None,
|
|
432
|
+
) -> "MemWalMockSync":
|
|
433
|
+
return cls(MemWalMock.create(namespace, owner, initial_memories))
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _run(coro: Any) -> Any:
|
|
437
|
+
try:
|
|
438
|
+
loop = asyncio.get_running_loop()
|
|
439
|
+
except RuntimeError:
|
|
440
|
+
loop = None
|
|
441
|
+
|
|
442
|
+
if loop is not None and loop.is_running():
|
|
443
|
+
# Match MemWalSync in notebooks and other hosts with a live loop:
|
|
444
|
+
# execute the coroutine on a fresh event loop in a worker thread.
|
|
445
|
+
import concurrent.futures
|
|
446
|
+
|
|
447
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
448
|
+
return pool.submit(asyncio.run, coro).result()
|
|
449
|
+
return asyncio.run(coro)
|
|
450
|
+
|
|
451
|
+
def remember(self, text: str, namespace: Optional[str] = None) -> RememberAcceptedResult:
|
|
452
|
+
return self._run(self._inner.remember(text, namespace))
|
|
453
|
+
|
|
454
|
+
def remember_async(
|
|
455
|
+
self, text: str, namespace: Optional[str] = None
|
|
456
|
+
) -> RememberAcceptedResult:
|
|
457
|
+
return self.remember(text, namespace)
|
|
458
|
+
|
|
459
|
+
def wait_for_remember_job(
|
|
460
|
+
self,
|
|
461
|
+
job_id: str,
|
|
462
|
+
poll_interval_ms: int = 1500,
|
|
463
|
+
timeout_ms: int = 60_000,
|
|
464
|
+
) -> RememberResult:
|
|
465
|
+
return self._run(
|
|
466
|
+
self._inner.wait_for_remember_job(
|
|
467
|
+
job_id,
|
|
468
|
+
poll_interval_ms=poll_interval_ms,
|
|
469
|
+
timeout_ms=timeout_ms,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def get_remember_status(self, job_id: str) -> RememberJobStatus:
|
|
474
|
+
return self._run(self._inner.get_remember_status(job_id))
|
|
475
|
+
|
|
476
|
+
def remember_and_wait(
|
|
477
|
+
self,
|
|
478
|
+
text: str,
|
|
479
|
+
namespace: Optional[str] = None,
|
|
480
|
+
poll_interval_ms: int = 1500,
|
|
481
|
+
timeout_ms: int = 60_000,
|
|
482
|
+
) -> RememberResult:
|
|
483
|
+
return self._run(
|
|
484
|
+
self._inner.remember_and_wait(
|
|
485
|
+
text,
|
|
486
|
+
namespace,
|
|
487
|
+
poll_interval_ms=poll_interval_ms,
|
|
488
|
+
timeout_ms=timeout_ms,
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def remember_bulk_async(
|
|
493
|
+
self, items: Sequence[RememberBulkItem]
|
|
494
|
+
) -> RememberBulkAcceptedResult:
|
|
495
|
+
return self._run(self._inner.remember_bulk_async(items))
|
|
496
|
+
|
|
497
|
+
def remember_bulk(
|
|
498
|
+
self, items: Sequence[RememberBulkItem]
|
|
499
|
+
) -> RememberBulkAcceptedResult:
|
|
500
|
+
return self.remember_bulk_async(items)
|
|
501
|
+
|
|
502
|
+
def get_remember_bulk_status(
|
|
503
|
+
self, job_ids: Sequence[str]
|
|
504
|
+
) -> RememberBulkStatusResult:
|
|
505
|
+
return self._run(self._inner.get_remember_bulk_status(job_ids))
|
|
506
|
+
|
|
507
|
+
def wait_for_remember_jobs(
|
|
508
|
+
self,
|
|
509
|
+
job_ids: Sequence[str],
|
|
510
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
511
|
+
) -> RememberBulkResult:
|
|
512
|
+
return self._run(self._inner.wait_for_remember_jobs(job_ids, opts))
|
|
513
|
+
|
|
514
|
+
def remember_bulk_and_wait(
|
|
515
|
+
self,
|
|
516
|
+
items: Sequence[RememberBulkItem],
|
|
517
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
518
|
+
) -> RememberBulkResult:
|
|
519
|
+
return self._run(self._inner.remember_bulk_and_wait(items, opts))
|
|
520
|
+
|
|
521
|
+
def recall(
|
|
522
|
+
self,
|
|
523
|
+
query: Union[str, RecallParams],
|
|
524
|
+
limit: int = 10,
|
|
525
|
+
namespace: Optional[str] = None,
|
|
526
|
+
max_distance: Optional[float] = None,
|
|
527
|
+
) -> RecallResult:
|
|
528
|
+
return self._run(
|
|
529
|
+
self._inner.recall(query, limit, namespace, max_distance)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
def analyze(
|
|
533
|
+
self,
|
|
534
|
+
text: str,
|
|
535
|
+
namespace: Optional[str] = None,
|
|
536
|
+
occurred_at: Optional[Union[str, datetime]] = None,
|
|
537
|
+
) -> AnalyzeResult:
|
|
538
|
+
return self._run(
|
|
539
|
+
self._inner.analyze(text, namespace, occurred_at=occurred_at)
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def analyze_and_wait(
|
|
543
|
+
self,
|
|
544
|
+
text: str,
|
|
545
|
+
namespace: Optional[str] = None,
|
|
546
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
547
|
+
occurred_at: Optional[Union[str, datetime]] = None,
|
|
548
|
+
) -> AnalyzeWaitResult:
|
|
549
|
+
return self._run(
|
|
550
|
+
self._inner.analyze_and_wait(
|
|
551
|
+
text, namespace, opts, occurred_at=occurred_at
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
def embed(self, text: str) -> EmbedResult:
|
|
556
|
+
return self._run(self._inner.embed(text))
|
|
557
|
+
|
|
558
|
+
def ask(
|
|
559
|
+
self, question: str, limit: int = 5, namespace: Optional[str] = None
|
|
560
|
+
) -> AskResult:
|
|
561
|
+
return self._run(self._inner.ask(question, limit, namespace))
|
|
562
|
+
|
|
563
|
+
def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
|
|
564
|
+
return self._run(self._inner.restore(namespace, limit))
|
|
565
|
+
|
|
566
|
+
def health(self) -> HealthResult:
|
|
567
|
+
return self._run(self._inner.health())
|
|
568
|
+
|
|
569
|
+
def compatibility(self) -> Dict[str, Any]:
|
|
570
|
+
return self._run(self._inner.compatibility())
|
|
571
|
+
|
|
572
|
+
def forget(self, blob_id: str) -> bool:
|
|
573
|
+
return self._inner.forget(blob_id)
|
|
574
|
+
|
|
575
|
+
def clear(self, namespace: Optional[str] = None) -> int:
|
|
576
|
+
return self._inner.clear(namespace)
|
|
577
|
+
|
|
578
|
+
def close(self) -> None:
|
|
579
|
+
self._run(self._inner.close())
|
|
580
|
+
|
|
581
|
+
def __enter__(self) -> "MemWalMockSync":
|
|
582
|
+
return self
|
|
583
|
+
|
|
584
|
+
def __exit__(self, *exc: Any) -> None:
|
|
585
|
+
self.close()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Offline mock client regression tests."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from memwal import (
|
|
8
|
+
MemWalMock,
|
|
9
|
+
MemWalMockSeed,
|
|
10
|
+
MemWalMockSync,
|
|
11
|
+
MemWalSync,
|
|
12
|
+
RecallParams,
|
|
13
|
+
RememberBulkItem,
|
|
14
|
+
RememberBulkOptions,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.asyncio
|
|
19
|
+
async def test_mock_remember_and_recall_are_deterministic_and_offline(monkeypatch):
|
|
20
|
+
def reject_network(*args, **kwargs):
|
|
21
|
+
raise AssertionError("MemWalMock must not create a network client")
|
|
22
|
+
|
|
23
|
+
monkeypatch.setattr("httpx.AsyncClient", reject_network)
|
|
24
|
+
mock = MemWalMock.create(namespace="user-a", owner="test-owner")
|
|
25
|
+
coffee = await mock.remember_and_wait("I prefer coffee in the morning")
|
|
26
|
+
tea = await mock.remember_and_wait("I drink tea at night")
|
|
27
|
+
|
|
28
|
+
assert coffee.id == "mock-job-000001"
|
|
29
|
+
assert coffee.blob_id == "mock-blob-000001"
|
|
30
|
+
assert coffee.namespace == "user-a"
|
|
31
|
+
assert tea.blob_id == "mock-blob-000002"
|
|
32
|
+
|
|
33
|
+
recalled = await mock.recall(RecallParams(query="morning coffee", limit=2))
|
|
34
|
+
assert [memory.text for memory in recalled.results] == [
|
|
35
|
+
"I prefer coffee in the morning",
|
|
36
|
+
"I drink tea at night",
|
|
37
|
+
]
|
|
38
|
+
assert recalled.results[0].distance == 0
|
|
39
|
+
assert recalled.results[1].distance == 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_mock_isolates_namespaces_and_honors_max_distance():
|
|
44
|
+
mock = MemWalMock.create()
|
|
45
|
+
await mock.remember_and_wait("Alice likes ramen", "user-a")
|
|
46
|
+
await mock.remember_and_wait("Bob likes tacos", "user-b")
|
|
47
|
+
|
|
48
|
+
alice = await mock.recall(
|
|
49
|
+
RecallParams(query="likes", namespace="user-a", max_distance=0.5)
|
|
50
|
+
)
|
|
51
|
+
bob = await mock.recall("likes", namespace="user-b")
|
|
52
|
+
empty = await mock.recall("likes")
|
|
53
|
+
|
|
54
|
+
assert [memory.text for memory in alice.results] == ["Alice likes ramen"]
|
|
55
|
+
assert [memory.text for memory in bob.results] == ["Bob likes tacos"]
|
|
56
|
+
assert empty.total == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_mock_supports_jobs_bulk_analyze_forget_and_clear():
|
|
61
|
+
mock = MemWalMock.create()
|
|
62
|
+
accepted = await mock.remember("single fact")
|
|
63
|
+
status = await mock.get_remember_status(accepted.job_id)
|
|
64
|
+
assert status.status == "done"
|
|
65
|
+
assert status.blob_id == "mock-blob-000001"
|
|
66
|
+
|
|
67
|
+
bulk = await mock.remember_bulk_and_wait(
|
|
68
|
+
[
|
|
69
|
+
RememberBulkItem(text="bulk one", namespace="one"),
|
|
70
|
+
RememberBulkItem(text="bulk two", namespace="two"),
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
assert bulk.succeeded == 2
|
|
74
|
+
assert bulk.failed == 0
|
|
75
|
+
|
|
76
|
+
analyzed = await mock.analyze_and_wait("durable analyzed fact", "analysis")
|
|
77
|
+
assert analyzed.facts[0].text == "durable analyzed fact"
|
|
78
|
+
|
|
79
|
+
assert mock.forget("mock-blob-000001") is True
|
|
80
|
+
assert mock.forget("missing") is False
|
|
81
|
+
assert mock.clear("one") == 1
|
|
82
|
+
assert (await mock.recall("bulk", namespace="one")).total == 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_mock_seed_embed_health_and_compatibility():
|
|
87
|
+
first = MemWalMock.create(
|
|
88
|
+
initial_memories=[
|
|
89
|
+
MemWalMockSeed(text="seed memory", namespace="seed", blob_id="seed-blob")
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
second = MemWalMock.create()
|
|
93
|
+
|
|
94
|
+
assert await first.embed("same text") == await second.embed("same text")
|
|
95
|
+
recalled = await first.recall("seed", namespace="seed")
|
|
96
|
+
assert recalled.results[0].blob_id == "seed-blob"
|
|
97
|
+
assert (await first.health()).status == "ok"
|
|
98
|
+
assert (await first.compatibility())["featureFlags"]["offlineMock"] is True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_sync_mock_wraps_core_flows_and_accepts_production_options():
|
|
102
|
+
mock = MemWalMockSync.create(namespace="sync")
|
|
103
|
+
opts = RememberBulkOptions(poll_interval_ms=1, timeout_ms=10)
|
|
104
|
+
|
|
105
|
+
accepted = mock.remember("accepted memory")
|
|
106
|
+
stored = mock.wait_for_remember_job(
|
|
107
|
+
accepted.job_id, poll_interval_ms=1, timeout_ms=10
|
|
108
|
+
)
|
|
109
|
+
waited = mock.remember_and_wait(
|
|
110
|
+
"sync memory", poll_interval_ms=1, timeout_ms=10
|
|
111
|
+
)
|
|
112
|
+
bulk = mock.remember_bulk_and_wait(
|
|
113
|
+
[RememberBulkItem(text="bulk memory")], opts=opts
|
|
114
|
+
)
|
|
115
|
+
assert mock.wait_for_remember_jobs([accepted.job_id], opts=opts).succeeded == 1
|
|
116
|
+
analyzed = mock.analyze("analyzed memory", occurred_at="2024-01-01T00:00:00Z")
|
|
117
|
+
analyzed_wait = mock.analyze_and_wait(
|
|
118
|
+
"analyzed and waited",
|
|
119
|
+
opts=opts,
|
|
120
|
+
occurred_at="2024-01-01T00:00:00Z",
|
|
121
|
+
)
|
|
122
|
+
recalled = mock.recall("sync")
|
|
123
|
+
|
|
124
|
+
assert stored.blob_id == "mock-blob-000001"
|
|
125
|
+
assert waited.namespace == "sync"
|
|
126
|
+
assert bulk.succeeded == 1
|
|
127
|
+
assert analyzed.fact_count == 1
|
|
128
|
+
assert analyzed_wait.succeeded == 1
|
|
129
|
+
assert recalled.results[0].text == "sync memory"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_sync_mock_polling_and_analyze_signatures_match_production():
|
|
133
|
+
methods = (
|
|
134
|
+
"wait_for_remember_job",
|
|
135
|
+
"remember_and_wait",
|
|
136
|
+
"wait_for_remember_jobs",
|
|
137
|
+
"remember_bulk_and_wait",
|
|
138
|
+
"analyze",
|
|
139
|
+
"analyze_and_wait",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def parameter_contract(method):
|
|
143
|
+
return [
|
|
144
|
+
(parameter.name, parameter.kind, parameter.default)
|
|
145
|
+
for parameter in inspect.signature(method).parameters.values()
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
for method_name in methods:
|
|
149
|
+
assert parameter_contract(getattr(MemWalMockSync, method_name)) == parameter_contract(
|
|
150
|
+
getattr(MemWalSync, method_name)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_sync_mock_works_inside_an_existing_event_loop():
|
|
156
|
+
mock = MemWalMockSync.create(namespace="notebook")
|
|
157
|
+
|
|
158
|
+
stored = mock.remember_and_wait("called from a running loop")
|
|
159
|
+
recalled = mock.recall("running loop")
|
|
160
|
+
|
|
161
|
+
assert stored.namespace == "notebook"
|
|
162
|
+
assert recalled.results[0].text == "called from a running loop"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|