xyberos-redis 0.1.0__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.
- xyberos_redis-0.1.0/PKG-INFO +85 -0
- xyberos_redis-0.1.0/README.md +70 -0
- xyberos_redis-0.1.0/pyproject.toml +27 -0
- xyberos_redis-0.1.0/setup.cfg +4 -0
- xyberos_redis-0.1.0/tests/test_cache.py +50 -0
- xyberos_redis-0.1.0/tests/test_memory.py +68 -0
- xyberos_redis-0.1.0/tests/test_parity.py +50 -0
- xyberos_redis-0.1.0/tests/test_plugin.py +46 -0
- xyberos_redis-0.1.0/tests/test_vector.py +45 -0
- xyberos_redis-0.1.0/xyberos_redis/__init__.py +19 -0
- xyberos_redis-0.1.0/xyberos_redis/cache.py +80 -0
- xyberos_redis-0.1.0/xyberos_redis/memory.py +104 -0
- xyberos_redis-0.1.0/xyberos_redis/plugin.py +96 -0
- xyberos_redis-0.1.0/xyberos_redis/vector.py +135 -0
- xyberos_redis-0.1.0/xyberos_redis.egg-info/PKG-INFO +85 -0
- xyberos_redis-0.1.0/xyberos_redis.egg-info/SOURCES.txt +18 -0
- xyberos_redis-0.1.0/xyberos_redis.egg-info/dependency_links.txt +1 -0
- xyberos_redis-0.1.0/xyberos_redis.egg-info/entry_points.txt +2 -0
- xyberos_redis-0.1.0/xyberos_redis.egg-info/requires.txt +8 -0
- xyberos_redis-0.1.0/xyberos_redis.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-redis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Redis plugin (RFC-0019, M4): RedisVectorStore + RedisMemory + cache backing for CacheResponder
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,redis,vector,memory,cache,state
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: state
|
|
11
|
+
Requires-Dist: redis; extra == "state"
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
|
14
|
+
Requires-Dist: fakeredis; extra == "test"
|
|
15
|
+
|
|
16
|
+
# xyberos-redis
|
|
17
|
+
|
|
18
|
+
**Redis plugin — RFC-0019, M4.** `RedisVectorStore` + `RedisMemory` + cache
|
|
19
|
+
backing for `CacheResponder`, all lazy-importing `redis`. Covers the
|
|
20
|
+
`[state]` extra scope (cache + state + vector).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e ./redis
|
|
26
|
+
pip install redis # or: pip install xyberos[state]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from xyberos import create_app
|
|
33
|
+
from xyberos_redis import RedisPlugin
|
|
34
|
+
|
|
35
|
+
app = create_app()
|
|
36
|
+
app.load_plugin(RedisPlugin(url="redis://localhost:6379")) # or set REDIS_URL
|
|
37
|
+
|
|
38
|
+
vector = app.resolve("redis_vector") # a VectorStore
|
|
39
|
+
memory = app.resolve("redis_memory") # a Memory
|
|
40
|
+
cache = app.resolve("redis_cache") # exact-match string cache
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`RedisPlugin(replace_defaults=True)` also swaps the app's `vector_store` /
|
|
44
|
+
`memory` providers.
|
|
45
|
+
|
|
46
|
+
### CacheResponder backing
|
|
47
|
+
|
|
48
|
+
`RedisVectorStore` is a full `VectorStore`, so it plugs straight into the core
|
|
49
|
+
`CacheResponder` for near-exact `prompt -> answer` caching:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from xyberos.llm import HashEmbedder
|
|
53
|
+
from xyberos.router import CacheResponder
|
|
54
|
+
from xyberos_redis import RedisVectorStore
|
|
55
|
+
|
|
56
|
+
responder = CacheResponder(store=RedisVectorStore(url="redis://localhost:6379"),
|
|
57
|
+
embedder=HashEmbedder())
|
|
58
|
+
responder.teach("what is xyberos?", "a cognitive platform")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Design
|
|
62
|
+
|
|
63
|
+
- **`RedisVectorStore`** — one Redis hash per namespace; vectors JSON-encoded;
|
|
64
|
+
queries scored with exact cosine (the same scan-and-score approach as the
|
|
65
|
+
stdlib `SqliteVectorStore`, so behavior — and parity — holds without needing
|
|
66
|
+
a RediSearch vector module).
|
|
67
|
+
- **`RedisMemory`** — one entry per `store()` appended to a Redis list;
|
|
68
|
+
`retrieve()` returns `MemoryEntry`s oldest-first, mirroring `SqliteMemory`.
|
|
69
|
+
- **`RedisStringCache`** — tiny exact-match `get/set/delete` with optional TTL.
|
|
70
|
+
- `redis` is imported **lazily**; a clear `ProviderError` is raised when missing.
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install pytest fakeredis
|
|
76
|
+
pytest tests/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Tests run against **fakeredis** (an in-memory redis-py client), so they need no
|
|
80
|
+
Redis server; they skip cleanly when `redis`/`fakeredis` are absent. The vector
|
|
81
|
+
parity tests run the exact scenarios the `SqliteVectorStore` reference passes.
|
|
82
|
+
|
|
83
|
+
## Ship location
|
|
84
|
+
|
|
85
|
+
`[state]` extra (`redis` already added to `pyproject.toml`).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# xyberos-redis
|
|
2
|
+
|
|
3
|
+
**Redis plugin — RFC-0019, M4.** `RedisVectorStore` + `RedisMemory` + cache
|
|
4
|
+
backing for `CacheResponder`, all lazy-importing `redis`. Covers the
|
|
5
|
+
`[state]` extra scope (cache + state + vector).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e ./redis
|
|
11
|
+
pip install redis # or: pip install xyberos[state]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from xyberos import create_app
|
|
18
|
+
from xyberos_redis import RedisPlugin
|
|
19
|
+
|
|
20
|
+
app = create_app()
|
|
21
|
+
app.load_plugin(RedisPlugin(url="redis://localhost:6379")) # or set REDIS_URL
|
|
22
|
+
|
|
23
|
+
vector = app.resolve("redis_vector") # a VectorStore
|
|
24
|
+
memory = app.resolve("redis_memory") # a Memory
|
|
25
|
+
cache = app.resolve("redis_cache") # exact-match string cache
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`RedisPlugin(replace_defaults=True)` also swaps the app's `vector_store` /
|
|
29
|
+
`memory` providers.
|
|
30
|
+
|
|
31
|
+
### CacheResponder backing
|
|
32
|
+
|
|
33
|
+
`RedisVectorStore` is a full `VectorStore`, so it plugs straight into the core
|
|
34
|
+
`CacheResponder` for near-exact `prompt -> answer` caching:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from xyberos.llm import HashEmbedder
|
|
38
|
+
from xyberos.router import CacheResponder
|
|
39
|
+
from xyberos_redis import RedisVectorStore
|
|
40
|
+
|
|
41
|
+
responder = CacheResponder(store=RedisVectorStore(url="redis://localhost:6379"),
|
|
42
|
+
embedder=HashEmbedder())
|
|
43
|
+
responder.teach("what is xyberos?", "a cognitive platform")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Design
|
|
47
|
+
|
|
48
|
+
- **`RedisVectorStore`** — one Redis hash per namespace; vectors JSON-encoded;
|
|
49
|
+
queries scored with exact cosine (the same scan-and-score approach as the
|
|
50
|
+
stdlib `SqliteVectorStore`, so behavior — and parity — holds without needing
|
|
51
|
+
a RediSearch vector module).
|
|
52
|
+
- **`RedisMemory`** — one entry per `store()` appended to a Redis list;
|
|
53
|
+
`retrieve()` returns `MemoryEntry`s oldest-first, mirroring `SqliteMemory`.
|
|
54
|
+
- **`RedisStringCache`** — tiny exact-match `get/set/delete` with optional TTL.
|
|
55
|
+
- `redis` is imported **lazily**; a clear `ProviderError` is raised when missing.
|
|
56
|
+
|
|
57
|
+
## Tests
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install pytest fakeredis
|
|
61
|
+
pytest tests/
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Tests run against **fakeredis** (an in-memory redis-py client), so they need no
|
|
65
|
+
Redis server; they skip cleanly when `redis`/`fakeredis` are absent. The vector
|
|
66
|
+
parity tests run the exact scenarios the `SqliteVectorStore` reference passes.
|
|
67
|
+
|
|
68
|
+
## Ship location
|
|
69
|
+
|
|
70
|
+
`[state]` extra (`redis` already added to `pyproject.toml`).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xyberos-redis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Redis plugin (RFC-0019, M4): RedisVectorStore + RedisMemory + cache backing for CacheResponder"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
dependencies = ["xyberos>=1.0"]
|
|
13
|
+
keywords = ["xyberos", "plugin", "redis", "vector", "memory", "cache", "state"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
state = ["redis"]
|
|
17
|
+
test = ["pytest", "fakeredis"]
|
|
18
|
+
|
|
19
|
+
[project.entry-points."xyberos.plugins"]
|
|
20
|
+
redis = "xyberos_redis.plugin:plugin"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
packages = ["xyberos_redis"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for the Redis cache story: CacheResponder backing + string cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from xyberos.llm import HashEmbedder
|
|
9
|
+
from xyberos.router import CacheResponder
|
|
10
|
+
|
|
11
|
+
from xyberos_redis import RedisStringCache, RedisVectorStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture()
|
|
15
|
+
def client():
|
|
16
|
+
return pytest.importorskip("fakeredis").FakeStrictRedis()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_redis_vector_store_backs_cache_responder(client):
|
|
20
|
+
"""RedisVectorStore is a VectorStore, so it can back CacheResponder."""
|
|
21
|
+
responder = CacheResponder(
|
|
22
|
+
store=RedisVectorStore(client=client),
|
|
23
|
+
embedder=HashEmbedder(),
|
|
24
|
+
threshold=0.9,
|
|
25
|
+
)
|
|
26
|
+
responder.teach("what is xyberos?", "a cognitive platform")
|
|
27
|
+
assert responder.size == 1
|
|
28
|
+
|
|
29
|
+
answer = responder.respond(SimpleNamespace(prompt="what is xyberos?"))
|
|
30
|
+
assert answer == "a cognitive platform"
|
|
31
|
+
# A near-identical prompt also hits (exact-match embedding -> score 1.0).
|
|
32
|
+
assert responder.respond(SimpleNamespace(prompt="what is xyberos?")) is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_redis_string_cache(client):
|
|
36
|
+
cache = RedisStringCache(client=client)
|
|
37
|
+
assert cache.get("k") is None
|
|
38
|
+
cache.set("k", "v")
|
|
39
|
+
assert cache.get("k") == "v"
|
|
40
|
+
cache.delete("k")
|
|
41
|
+
assert cache.get("k") is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_redis_string_cache_clear(client):
|
|
45
|
+
cache = RedisStringCache(client=client)
|
|
46
|
+
cache.set("a", "1")
|
|
47
|
+
cache.set("b", "2")
|
|
48
|
+
cache.clear()
|
|
49
|
+
assert cache.get("a") is None
|
|
50
|
+
assert cache.get("b") is None
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tests for RedisMemory — unit + parity with SqliteMemory semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from xyberos.memory import MemoryEntry, SqliteMemory
|
|
10
|
+
|
|
11
|
+
from xyberos_redis import RedisMemory
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture()
|
|
15
|
+
def client():
|
|
16
|
+
return pytest.importorskip("fakeredis").FakeStrictRedis()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def memory(client):
|
|
21
|
+
return RedisMemory(client=client)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ctx(prompt="hello", response="hi", metadata=None, plan=None, error=None):
|
|
25
|
+
return SimpleNamespace(
|
|
26
|
+
prompt=prompt, response=response, metadata=metadata, plan=plan, error=error
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_store_and_retrieve_in_order(memory):
|
|
31
|
+
memory.store(_ctx(prompt="first", response="one"))
|
|
32
|
+
memory.store(_ctx(prompt="second", response="two"))
|
|
33
|
+
entries = memory.retrieve(_ctx())
|
|
34
|
+
assert [e.prompt for e in entries] == ["first", "second"]
|
|
35
|
+
assert entries[0].response == "one"
|
|
36
|
+
assert entries[1].response == "two"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_metadata_round_trip(memory):
|
|
40
|
+
memory.store(_ctx(prompt="q", response="a", metadata={"user": "baltz"}))
|
|
41
|
+
entry = memory.retrieve(_ctx())[0]
|
|
42
|
+
assert entry.metadata == {"user": "baltz"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_clear(memory):
|
|
46
|
+
memory.store(_ctx())
|
|
47
|
+
memory.clear()
|
|
48
|
+
assert memory.retrieve(_ctx()) == []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_parity_with_sqlite_memory(client):
|
|
52
|
+
"""RedisMemory and SqliteMemory must expose the same observable behavior."""
|
|
53
|
+
redis_mem = RedisMemory(client=client)
|
|
54
|
+
sqlite_mem = SqliteMemory(":memory:")
|
|
55
|
+
ctx = _ctx(prompt="q", response="a", metadata={"k": "v"}, plan={"step": 1})
|
|
56
|
+
|
|
57
|
+
redis_mem.store(ctx)
|
|
58
|
+
sqlite_mem.store(ctx)
|
|
59
|
+
|
|
60
|
+
redis_entries = redis_mem.retrieve(_ctx())
|
|
61
|
+
sqlite_entries = sqlite_mem.retrieve(_ctx())
|
|
62
|
+
|
|
63
|
+
assert len(redis_entries) == len(sqlite_entries) == 1
|
|
64
|
+
assert redis_entries[0].prompt == sqlite_entries[0].prompt == "q"
|
|
65
|
+
assert redis_entries[0].response == sqlite_entries[0].response == "a"
|
|
66
|
+
assert redis_entries[0].metadata == sqlite_entries[0].metadata == {"k": "v"}
|
|
67
|
+
assert redis_entries[0].plan == sqlite_entries[0].plan == {"step": 1}
|
|
68
|
+
assert redis_entries[0].created_at # both record a timestamp
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""M4 parity: RedisVectorStore must behave like SqliteVectorStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from xyberos.vector import SqliteVectorStore
|
|
7
|
+
|
|
8
|
+
from xyberos_redis import RedisVectorStore
|
|
9
|
+
|
|
10
|
+
QUERY = [1.0, 0.0, 0.0, 0.0]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_parity_scenarios(store) -> None:
|
|
14
|
+
"""The full VectorStore contract; every adapter must pass these."""
|
|
15
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
|
|
16
|
+
store.upsert("ns", "b", [0.8, 0.2, 0.0, 0.0], {"text": "beta"})
|
|
17
|
+
store.upsert("ns", "c", [0.5, 0.5, 0.0, 0.0], {"text": "gamma"})
|
|
18
|
+
store.upsert("other", "x", [1.0, 1.0, 1.0, 1.0], {"text": "other ns"})
|
|
19
|
+
|
|
20
|
+
hits = store.query("ns", QUERY, top_k=3)
|
|
21
|
+
assert [h.id for h in hits] == ["a", "b", "c"]
|
|
22
|
+
assert hits[0].payload["text"] == "alpha"
|
|
23
|
+
assert hits[0].score > hits[1].score > hits[2].score
|
|
24
|
+
assert len(store.query("ns", QUERY, top_k=1)) == 1
|
|
25
|
+
|
|
26
|
+
strict = store.query("ns", QUERY, top_k=3, threshold=0.99)
|
|
27
|
+
assert [h.id for h in strict] == ["a"]
|
|
28
|
+
|
|
29
|
+
store.delete("ns", "b")
|
|
30
|
+
assert [h.id for h in store.query("ns", QUERY, top_k=3)] == ["a", "c"]
|
|
31
|
+
assert [h.id for h in store.query("other", [1.0, 1.0, 1.0, 1.0], top_k=2)] == ["x"]
|
|
32
|
+
assert all(abs(h.score) < 1e-6 for h in store.query("ns", [0.0, 0.0, 0.0, 1.0], top_k=2))
|
|
33
|
+
|
|
34
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha-v2"})
|
|
35
|
+
assert store.query("ns", QUERY, top_k=1)[0].payload["text"] == "alpha-v2"
|
|
36
|
+
|
|
37
|
+
store.clear("ns")
|
|
38
|
+
assert store.query("ns", QUERY, top_k=3) == []
|
|
39
|
+
assert [h.id for h in store.query("other", [1.0, 1.0, 1.0, 1.0], top_k=1)] == ["x"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_sqlite_reference() -> None:
|
|
43
|
+
"""The stdlib reference passes the same scenarios the adapters must match."""
|
|
44
|
+
run_parity_scenarios(SqliteVectorStore(":memory:"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_redis_parity():
|
|
48
|
+
pytest.importorskip("fakeredis")
|
|
49
|
+
client = pytest.importorskip("fakeredis").FakeStrictRedis()
|
|
50
|
+
run_parity_scenarios(RedisVectorStore(client=client))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Tests for loading the redis plugin into a Xyberos app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from xyberos import create_app
|
|
7
|
+
|
|
8
|
+
from xyberos_redis import RedisPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture()
|
|
12
|
+
def client():
|
|
13
|
+
return pytest.importorskip("fakeredis").FakeStrictRedis()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_plugin_conforms_to_contract():
|
|
17
|
+
plugin = RedisPlugin()
|
|
18
|
+
assert plugin.name == "redis"
|
|
19
|
+
assert callable(plugin.register) and callable(plugin.unregister)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_plugin_registers_services(client):
|
|
23
|
+
app = create_app()
|
|
24
|
+
app.load_plugin(RedisPlugin(client=client, key_prefix="test"))
|
|
25
|
+
assert "redis_client" in app.registry.names
|
|
26
|
+
assert "redis_vector" in app.registry.names
|
|
27
|
+
assert "redis_memory" in app.registry.names
|
|
28
|
+
assert "redis_cache" in app.registry.names
|
|
29
|
+
|
|
30
|
+
vector = app.resolve("redis_vector")
|
|
31
|
+
vector.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
|
|
32
|
+
assert vector.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=1)[0].payload["text"] == "alpha"
|
|
33
|
+
|
|
34
|
+
memory = app.resolve("redis_memory")
|
|
35
|
+
memory.store(type("Ctx", (), {"prompt": "q", "response": "a", "metadata": {}})())
|
|
36
|
+
assert memory.retrieve(type("Ctx", (), {})())[0].prompt == "q"
|
|
37
|
+
|
|
38
|
+
app.unload_plugin("redis")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_replace_defaults(client):
|
|
42
|
+
app = create_app()
|
|
43
|
+
app.load_plugin(RedisPlugin(client=client, key_prefix="test", replace_defaults=True))
|
|
44
|
+
assert app.resolve("memory").__class__.__name__ == "RedisMemory"
|
|
45
|
+
assert app.resolve("vector_store").__class__.__name__ == "RedisVectorStore"
|
|
46
|
+
app.unload_plugin("redis")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Unit tests for RedisVectorStore (fakeredis, no server needed)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from xyberos_redis import RedisVectorStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture()
|
|
11
|
+
def client():
|
|
12
|
+
return pytest.importorskip("fakeredis").FakeStrictRedis()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture()
|
|
16
|
+
def store(client):
|
|
17
|
+
return RedisVectorStore(client=client)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_upsert_and_query(store):
|
|
21
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0], {"text": "alpha"})
|
|
22
|
+
store.upsert("ns", "b", [0.0, 1.0, 0.0], {"text": "beta"})
|
|
23
|
+
hits = store.query("ns", [1.0, 0.0, 0.0], top_k=2)
|
|
24
|
+
assert hits[0].id == "a"
|
|
25
|
+
assert hits[0].payload["text"] == "alpha"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_namespaces_isolated(store):
|
|
29
|
+
store.upsert("one", "a", [1.0, 0.0, 0.0], {"text": "one"})
|
|
30
|
+
store.upsert("two", "b", [0.0, 1.0, 0.0], {"text": "two"})
|
|
31
|
+
assert [h.id for h in store.query("one", [1.0, 0.0, 0.0])] == ["a"]
|
|
32
|
+
assert [h.id for h in store.query("two", [1.0, 0.0, 0.0])] == ["b"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_delete(store):
|
|
36
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0])
|
|
37
|
+
store.upsert("ns", "b", [0.0, 1.0, 0.0, 0.0])
|
|
38
|
+
store.delete("ns", "a")
|
|
39
|
+
assert [h.id for h in store.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=2)] == ["b"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_clear(store):
|
|
43
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0])
|
|
44
|
+
store.clear("ns")
|
|
45
|
+
assert store.query("ns", [1.0, 0.0, 0.0, 0.0]) == []
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Redis plugin (RFC-0019, M4): vector store + memory + cache backing.
|
|
2
|
+
|
|
3
|
+
* :class:`RedisVectorStore` — a :class:`~xyberos.contracts.VectorStore` backed
|
|
4
|
+
by Redis hashes (can also back :class:`~xyberos.router.CacheResponder`).
|
|
5
|
+
* :class:`RedisMemory` — a :class:`~xyberos.contracts.Memory` backed by a Redis
|
|
6
|
+
list.
|
|
7
|
+
* :class:`RedisStringCache` — a small exact-match string cache for ephemeral
|
|
8
|
+
state.
|
|
9
|
+
|
|
10
|
+
``redis`` is imported lazily on first use and a clear ``ProviderError`` is
|
|
11
|
+
raised when it is missing (``pip install xyberos[state]``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .cache import RedisStringCache
|
|
15
|
+
from .memory import RedisMemory
|
|
16
|
+
from .plugin import RedisPlugin
|
|
17
|
+
from .vector import RedisVectorStore
|
|
18
|
+
|
|
19
|
+
__all__ = ["RedisMemory", "RedisPlugin", "RedisStringCache", "RedisVectorStore"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""A tiny exact-match string cache backed by Redis (lazy ``redis``).
|
|
2
|
+
|
|
3
|
+
This covers the "ephemeral state" side of the RFC-0019 M4 Redis scope; the
|
|
4
|
+
*vector* cache backing for :class:`~xyberos.router.CacheResponder` is provided
|
|
5
|
+
by :class:`~xyberos_redis.RedisVectorStore` (a full ``VectorStore``), which can
|
|
6
|
+
be passed as ``CacheResponder(store=..., embedder=...)`` for near-exact
|
|
7
|
+
``prompt -> answer`` caching.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from xyberos.exceptions.provider import ProviderError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _require_redis() -> Any:
|
|
19
|
+
try:
|
|
20
|
+
redis = importlib.import_module("redis")
|
|
21
|
+
except ImportError as exc:
|
|
22
|
+
raise ProviderError(
|
|
23
|
+
"the 'redis' package is required; install it with "
|
|
24
|
+
"'pip install xyberos[state]'"
|
|
25
|
+
) from exc
|
|
26
|
+
return redis
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RedisStringCache:
|
|
30
|
+
"""An exact-match ``key -> value`` string cache with optional TTL."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
url: str | None = None,
|
|
36
|
+
client: Any | None = None,
|
|
37
|
+
key_prefix: str = "xyberos:cache",
|
|
38
|
+
) -> None:
|
|
39
|
+
self._url = url
|
|
40
|
+
self._client = client
|
|
41
|
+
self._prefix = key_prefix
|
|
42
|
+
|
|
43
|
+
def start(self) -> None:
|
|
44
|
+
self._get_client()
|
|
45
|
+
|
|
46
|
+
def stop(self) -> None:
|
|
47
|
+
self._client = None
|
|
48
|
+
|
|
49
|
+
def get(self, key: str) -> str | None:
|
|
50
|
+
value = self._get_client().get(f"{self._prefix}:{key}")
|
|
51
|
+
if value is None:
|
|
52
|
+
return None
|
|
53
|
+
return value.decode("utf-8") if isinstance(value, bytes) else str(value)
|
|
54
|
+
|
|
55
|
+
def set(self, key: str, value: str, *, ttl: int | None = None) -> None:
|
|
56
|
+
client = self._get_client()
|
|
57
|
+
full_key = f"{self._prefix}:{key}"
|
|
58
|
+
client.set(full_key, value, ex=ttl)
|
|
59
|
+
|
|
60
|
+
def delete(self, key: str) -> None:
|
|
61
|
+
self._get_client().delete(f"{self._prefix}:{key}")
|
|
62
|
+
|
|
63
|
+
def clear(self) -> None:
|
|
64
|
+
keys = self._keys()
|
|
65
|
+
if keys:
|
|
66
|
+
self._get_client().delete(*keys)
|
|
67
|
+
|
|
68
|
+
def _keys(self) -> list[str]:
|
|
69
|
+
client = self._get_client()
|
|
70
|
+
pattern = f"{self._prefix}:*"
|
|
71
|
+
if hasattr(client, "keys"):
|
|
72
|
+
return [k.decode("utf-8") if isinstance(k, bytes) else str(k) for k in client.keys(pattern)]
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
def _get_client(self) -> Any:
|
|
76
|
+
if self._client is not None:
|
|
77
|
+
return self._client
|
|
78
|
+
redis = _require_redis()
|
|
79
|
+
self._client = redis.from_url(self._url) if self._url else redis.Redis()
|
|
80
|
+
return self._client
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Redis-backed :class:`~xyberos.contracts.Memory` (lazy ``redis``).
|
|
2
|
+
|
|
3
|
+
Mirrors the stdlib ``SqliteMemory`` semantics: ``store`` appends one
|
|
4
|
+
reconstructable snapshot (a ``MemoryEntry``) and ``retrieve`` returns all
|
|
5
|
+
entries, oldest first, in insertion order. The list lives under
|
|
6
|
+
``<prefix>:memory``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from xyberos.contracts.memory import Memory
|
|
17
|
+
from xyberos.memory import MemoryEntry
|
|
18
|
+
from xyberos.exceptions.provider import ProviderError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _require_redis() -> Any:
|
|
22
|
+
try:
|
|
23
|
+
redis = importlib.import_module("redis")
|
|
24
|
+
except ImportError as exc:
|
|
25
|
+
raise ProviderError(
|
|
26
|
+
"the 'redis' package is required; install it with "
|
|
27
|
+
"'pip install xyberos[state]'"
|
|
28
|
+
) from exc
|
|
29
|
+
return redis
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _decode(value: Any) -> str:
|
|
33
|
+
if isinstance(value, bytes):
|
|
34
|
+
return value.decode("utf-8")
|
|
35
|
+
return str(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _utc_now() -> str:
|
|
39
|
+
return datetime.now(timezone.utc).isoformat()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RedisMemory(Memory):
|
|
43
|
+
"""A :class:`Memory` backed by a Redis list (one row per ``store`` call)."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
url: str | None = None,
|
|
49
|
+
client: Any | None = None,
|
|
50
|
+
key: str = "xyberos:memory",
|
|
51
|
+
) -> None:
|
|
52
|
+
self._url = url
|
|
53
|
+
self._client = client
|
|
54
|
+
self._key = key
|
|
55
|
+
|
|
56
|
+
def start(self) -> None:
|
|
57
|
+
self._get_client()
|
|
58
|
+
|
|
59
|
+
def stop(self) -> None:
|
|
60
|
+
self._client = None
|
|
61
|
+
|
|
62
|
+
def retrieve(self, context: object) -> list[MemoryEntry]:
|
|
63
|
+
client = self._get_client()
|
|
64
|
+
raw_entries = client.lrange(self._key, 0, -1)
|
|
65
|
+
entries: list[MemoryEntry] = []
|
|
66
|
+
for raw in raw_entries:
|
|
67
|
+
record = json.loads(_decode(raw))
|
|
68
|
+
entries.append(
|
|
69
|
+
MemoryEntry(
|
|
70
|
+
prompt=record.get("prompt"),
|
|
71
|
+
response=record.get("response"),
|
|
72
|
+
metadata=record.get("metadata") or {},
|
|
73
|
+
plan=record.get("plan"),
|
|
74
|
+
error=record.get("error"),
|
|
75
|
+
created_at=record.get("created_at", ""),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return entries
|
|
79
|
+
|
|
80
|
+
def store(self, context: object) -> None:
|
|
81
|
+
client = self._get_client()
|
|
82
|
+
record = json.dumps(
|
|
83
|
+
{
|
|
84
|
+
"prompt": getattr(context, "prompt", None),
|
|
85
|
+
"response": getattr(context, "response", None),
|
|
86
|
+
"metadata": dict(getattr(context, "metadata", None) or {}),
|
|
87
|
+
"plan": getattr(context, "plan", None),
|
|
88
|
+
"error": getattr(context, "error", None),
|
|
89
|
+
"created_at": _utc_now(),
|
|
90
|
+
},
|
|
91
|
+
default=str,
|
|
92
|
+
)
|
|
93
|
+
client.rpush(self._key, record)
|
|
94
|
+
|
|
95
|
+
def clear(self) -> None:
|
|
96
|
+
client = self._get_client()
|
|
97
|
+
client.delete(self._key)
|
|
98
|
+
|
|
99
|
+
def _get_client(self) -> Any:
|
|
100
|
+
if self._client is not None:
|
|
101
|
+
return self._client
|
|
102
|
+
redis = _require_redis()
|
|
103
|
+
self._client = redis.from_url(self._url) if self._url else redis.Redis()
|
|
104
|
+
return self._client
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Redis plugin entry point (RFC-0019, M4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from xyberos.contracts import Plugin
|
|
10
|
+
from xyberos.exceptions.provider import ProviderError
|
|
11
|
+
|
|
12
|
+
from .cache import RedisStringCache
|
|
13
|
+
from .memory import RedisMemory
|
|
14
|
+
from .vector import RedisVectorStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _require_redis() -> Any:
|
|
18
|
+
try:
|
|
19
|
+
redis = importlib.import_module("redis")
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
raise ProviderError(
|
|
22
|
+
"the 'redis' package is required; install it with "
|
|
23
|
+
"'pip install xyberos[state]'"
|
|
24
|
+
) from exc
|
|
25
|
+
return redis
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RedisPlugin(Plugin):
|
|
29
|
+
"""Registers Redis-backed services.
|
|
30
|
+
|
|
31
|
+
Registers named services ``redis_client``, ``redis_vector``,
|
|
32
|
+
``redis_memory`` and ``redis_cache`` (all sharing one Redis client). When
|
|
33
|
+
``replace_defaults=True`` it also replaces the app's ``vector_store`` and
|
|
34
|
+
``memory`` providers. Configuration comes from explicit args, then
|
|
35
|
+
``REDIS_URL`` / ``REDIS_PREFIX`` env vars.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
url: str | None = None,
|
|
42
|
+
client: Any | None = None,
|
|
43
|
+
key_prefix: str | None = None,
|
|
44
|
+
replace_defaults: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._url = url or os.getenv("REDIS_URL")
|
|
47
|
+
self._client = client
|
|
48
|
+
self._prefix = key_prefix or os.getenv("REDIS_PREFIX") or "xyberos"
|
|
49
|
+
self._replace_defaults = replace_defaults
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def name(self) -> str:
|
|
53
|
+
return "redis"
|
|
54
|
+
|
|
55
|
+
def _shared_client(self) -> Any:
|
|
56
|
+
if self._client is None:
|
|
57
|
+
redis = _require_redis()
|
|
58
|
+
self._client = redis.from_url(self._url) if self._url else redis.Redis()
|
|
59
|
+
return self._client
|
|
60
|
+
|
|
61
|
+
def register(self, kernel: object) -> None:
|
|
62
|
+
client = self._shared_client()
|
|
63
|
+
kernel.register("redis_client", client, replace=True)
|
|
64
|
+
kernel.register(
|
|
65
|
+
"redis_vector",
|
|
66
|
+
RedisVectorStore(client=client, key_prefix=f"{self._prefix}:vs"),
|
|
67
|
+
replace=True,
|
|
68
|
+
)
|
|
69
|
+
kernel.register(
|
|
70
|
+
"redis_memory",
|
|
71
|
+
RedisMemory(client=client, key=f"{self._prefix}:memory"),
|
|
72
|
+
replace=True,
|
|
73
|
+
)
|
|
74
|
+
kernel.register(
|
|
75
|
+
"redis_cache",
|
|
76
|
+
RedisStringCache(client=client, key_prefix=f"{self._prefix}:cache"),
|
|
77
|
+
replace=True,
|
|
78
|
+
)
|
|
79
|
+
if self._replace_defaults:
|
|
80
|
+
kernel.register(
|
|
81
|
+
"vector_store",
|
|
82
|
+
RedisVectorStore(client=client, key_prefix=f"{self._prefix}:vs"),
|
|
83
|
+
replace=True,
|
|
84
|
+
)
|
|
85
|
+
kernel.register(
|
|
86
|
+
"memory",
|
|
87
|
+
RedisMemory(client=client, key=f"{self._prefix}:memory"),
|
|
88
|
+
replace=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def unregister(self, kernel: object) -> None:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
#: Auto-discovered by ``app.load_entry_points()``.
|
|
96
|
+
plugin = RedisPlugin()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Redis-backed :class:`~xyberos.contracts.VectorStore` (lazy ``redis``).
|
|
2
|
+
|
|
3
|
+
Vectors are stored as JSON inside one Redis hash per namespace
|
|
4
|
+
(``<prefix>:<namespace>``). On query the whole namespace is scanned and scored
|
|
5
|
+
with exact cosine similarity — the same approach the stdlib
|
|
6
|
+
``SqliteVectorStore`` uses (it also scans rows and computes cosine in Python),
|
|
7
|
+
so behavior is identical and parity holds without requiring a RediSearch
|
|
8
|
+
vector module.
|
|
9
|
+
|
|
10
|
+
Because it is a full :class:`VectorStore`, it can be passed straight to
|
|
11
|
+
:class:`~xyberos.router.CacheResponder(store=..., embedder=...)` for
|
|
12
|
+
near-exact ``prompt -> answer`` caching.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from xyberos.contracts.vector import ScoredHit, VectorStore
|
|
23
|
+
from xyberos.exceptions.provider import ProviderError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _require_redis() -> Any:
|
|
27
|
+
try:
|
|
28
|
+
redis = importlib.import_module("redis")
|
|
29
|
+
except ImportError as exc:
|
|
30
|
+
raise ProviderError(
|
|
31
|
+
"the 'redis' package is required; install it with "
|
|
32
|
+
"'pip install xyberos[state]'"
|
|
33
|
+
) from exc
|
|
34
|
+
return redis
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _decode(value: Any) -> str:
|
|
38
|
+
if isinstance(value, bytes):
|
|
39
|
+
return value.decode("utf-8")
|
|
40
|
+
return str(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
|
44
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
45
|
+
norm_a = sum(x * x for x in a) ** 0.5
|
|
46
|
+
norm_b = sum(x * x for x in b) ** 0.5
|
|
47
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
48
|
+
return 0.0
|
|
49
|
+
return dot / (norm_a * norm_b)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RedisVectorStore(VectorStore):
|
|
53
|
+
"""A :class:`VectorStore` backed by Redis hashes (one per namespace)."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
url: str | None = None,
|
|
59
|
+
client: Any | None = None,
|
|
60
|
+
key_prefix: str = "xyberos:vs",
|
|
61
|
+
) -> None:
|
|
62
|
+
self._url = url
|
|
63
|
+
self._client = client
|
|
64
|
+
self._prefix = key_prefix
|
|
65
|
+
|
|
66
|
+
def start(self) -> None:
|
|
67
|
+
"""Connect to Redis (kernel lifecycle hook)."""
|
|
68
|
+
self._get_client()
|
|
69
|
+
|
|
70
|
+
def stop(self) -> None:
|
|
71
|
+
"""Release the client (kernel lifecycle hook)."""
|
|
72
|
+
self._client = None
|
|
73
|
+
|
|
74
|
+
def upsert(
|
|
75
|
+
self,
|
|
76
|
+
namespace: str,
|
|
77
|
+
id: str,
|
|
78
|
+
vector: Sequence[float],
|
|
79
|
+
payload: Mapping[str, Any] | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
client = self._get_client()
|
|
82
|
+
record = json.dumps(
|
|
83
|
+
{"v": [float(value) for value in vector], "p": dict(payload or {})},
|
|
84
|
+
default=str,
|
|
85
|
+
)
|
|
86
|
+
client.hset(self._key(namespace), str(id), record)
|
|
87
|
+
|
|
88
|
+
def query(
|
|
89
|
+
self,
|
|
90
|
+
namespace: str,
|
|
91
|
+
vector: Sequence[float],
|
|
92
|
+
*,
|
|
93
|
+
top_k: int = 5,
|
|
94
|
+
threshold: float | None = None,
|
|
95
|
+
) -> list[ScoredHit]:
|
|
96
|
+
client = self._get_client()
|
|
97
|
+
raw = client.hgetall(self._key(namespace))
|
|
98
|
+
if not raw:
|
|
99
|
+
return []
|
|
100
|
+
query_vector = [float(value) for value in vector]
|
|
101
|
+
scored: list[ScoredHit] = []
|
|
102
|
+
for raw_id, raw_record in raw.items():
|
|
103
|
+
record = json.loads(_decode(raw_record))
|
|
104
|
+
similarity = _cosine(query_vector, record["v"])
|
|
105
|
+
if threshold is not None and similarity < threshold:
|
|
106
|
+
continue
|
|
107
|
+
scored.append(
|
|
108
|
+
ScoredHit(
|
|
109
|
+
id=_decode(raw_id),
|
|
110
|
+
score=similarity,
|
|
111
|
+
payload=record.get("p"),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
scored.sort(key=lambda hit: hit.score, reverse=True)
|
|
115
|
+
return scored[:top_k]
|
|
116
|
+
|
|
117
|
+
def delete(self, namespace: str, id: str) -> None:
|
|
118
|
+
client = self._get_client()
|
|
119
|
+
client.hdel(self._key(namespace), str(id))
|
|
120
|
+
|
|
121
|
+
def clear(self, namespace: str) -> None:
|
|
122
|
+
client = self._get_client()
|
|
123
|
+
client.delete(self._key(namespace))
|
|
124
|
+
|
|
125
|
+
# -- internals ----------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _get_client(self) -> Any:
|
|
128
|
+
if self._client is not None:
|
|
129
|
+
return self._client
|
|
130
|
+
redis = _require_redis()
|
|
131
|
+
self._client = redis.from_url(self._url) if self._url else redis.Redis()
|
|
132
|
+
return self._client
|
|
133
|
+
|
|
134
|
+
def _key(self, namespace: str) -> str:
|
|
135
|
+
return f"{self._prefix}:{namespace}"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-redis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Redis plugin (RFC-0019, M4): RedisVectorStore + RedisMemory + cache backing for CacheResponder
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,redis,vector,memory,cache,state
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: state
|
|
11
|
+
Requires-Dist: redis; extra == "state"
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
|
14
|
+
Requires-Dist: fakeredis; extra == "test"
|
|
15
|
+
|
|
16
|
+
# xyberos-redis
|
|
17
|
+
|
|
18
|
+
**Redis plugin — RFC-0019, M4.** `RedisVectorStore` + `RedisMemory` + cache
|
|
19
|
+
backing for `CacheResponder`, all lazy-importing `redis`. Covers the
|
|
20
|
+
`[state]` extra scope (cache + state + vector).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e ./redis
|
|
26
|
+
pip install redis # or: pip install xyberos[state]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from xyberos import create_app
|
|
33
|
+
from xyberos_redis import RedisPlugin
|
|
34
|
+
|
|
35
|
+
app = create_app()
|
|
36
|
+
app.load_plugin(RedisPlugin(url="redis://localhost:6379")) # or set REDIS_URL
|
|
37
|
+
|
|
38
|
+
vector = app.resolve("redis_vector") # a VectorStore
|
|
39
|
+
memory = app.resolve("redis_memory") # a Memory
|
|
40
|
+
cache = app.resolve("redis_cache") # exact-match string cache
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`RedisPlugin(replace_defaults=True)` also swaps the app's `vector_store` /
|
|
44
|
+
`memory` providers.
|
|
45
|
+
|
|
46
|
+
### CacheResponder backing
|
|
47
|
+
|
|
48
|
+
`RedisVectorStore` is a full `VectorStore`, so it plugs straight into the core
|
|
49
|
+
`CacheResponder` for near-exact `prompt -> answer` caching:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from xyberos.llm import HashEmbedder
|
|
53
|
+
from xyberos.router import CacheResponder
|
|
54
|
+
from xyberos_redis import RedisVectorStore
|
|
55
|
+
|
|
56
|
+
responder = CacheResponder(store=RedisVectorStore(url="redis://localhost:6379"),
|
|
57
|
+
embedder=HashEmbedder())
|
|
58
|
+
responder.teach("what is xyberos?", "a cognitive platform")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Design
|
|
62
|
+
|
|
63
|
+
- **`RedisVectorStore`** — one Redis hash per namespace; vectors JSON-encoded;
|
|
64
|
+
queries scored with exact cosine (the same scan-and-score approach as the
|
|
65
|
+
stdlib `SqliteVectorStore`, so behavior — and parity — holds without needing
|
|
66
|
+
a RediSearch vector module).
|
|
67
|
+
- **`RedisMemory`** — one entry per `store()` appended to a Redis list;
|
|
68
|
+
`retrieve()` returns `MemoryEntry`s oldest-first, mirroring `SqliteMemory`.
|
|
69
|
+
- **`RedisStringCache`** — tiny exact-match `get/set/delete` with optional TTL.
|
|
70
|
+
- `redis` is imported **lazily**; a clear `ProviderError` is raised when missing.
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install pytest fakeredis
|
|
76
|
+
pytest tests/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Tests run against **fakeredis** (an in-memory redis-py client), so they need no
|
|
80
|
+
Redis server; they skip cleanly when `redis`/`fakeredis` are absent. The vector
|
|
81
|
+
parity tests run the exact scenarios the `SqliteVectorStore` reference passes.
|
|
82
|
+
|
|
83
|
+
## Ship location
|
|
84
|
+
|
|
85
|
+
`[state]` extra (`redis` already added to `pyproject.toml`).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_cache.py
|
|
4
|
+
tests/test_memory.py
|
|
5
|
+
tests/test_parity.py
|
|
6
|
+
tests/test_plugin.py
|
|
7
|
+
tests/test_vector.py
|
|
8
|
+
xyberos_redis/__init__.py
|
|
9
|
+
xyberos_redis/cache.py
|
|
10
|
+
xyberos_redis/memory.py
|
|
11
|
+
xyberos_redis/plugin.py
|
|
12
|
+
xyberos_redis/vector.py
|
|
13
|
+
xyberos_redis.egg-info/PKG-INFO
|
|
14
|
+
xyberos_redis.egg-info/SOURCES.txt
|
|
15
|
+
xyberos_redis.egg-info/dependency_links.txt
|
|
16
|
+
xyberos_redis.egg-info/entry_points.txt
|
|
17
|
+
xyberos_redis.egg-info/requires.txt
|
|
18
|
+
xyberos_redis.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xyberos_redis
|