Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-memory/src/lexigram/ai/memory/backends/cache.py: 24%
107 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""Cache-backed memory backend — persists entries via CacheBackendProtocol."""
3from __future__ import annotations
5from datetime import UTC, datetime
6from typing import TYPE_CHECKING, Any, cast
8from lexigram.ai.memory.exceptions import MemoryStoreError
9from lexigram.contracts.ai.memory import (
10 MemoryEntry,
11 MemoryQuery,
12 MemorySearchResult,
13)
14from lexigram.contracts.core import HealthCheckResult, HealthStatus
15from lexigram.serialization.backends.json import dumps_str, loads
17if TYPE_CHECKING:
18 from lexigram.contracts.infra.cache import CacheBackendProtocol
20_KEY_PREFIX = "mem:"
21_INDEX_KEY = "mem:index"
24class CacheMemoryBackend:
25 """MemoryStoreProtocol backed by a CacheBackendProtocol (e.g. Redis)."""
27 def __init__(self, cache: CacheBackendProtocol, ttl: int = 86400 * 30) -> None:
28 self._cache = cache
29 self._ttl = ttl
31 def _key(self, owner_id: str, entry_id: str) -> str:
32 return f"{_KEY_PREFIX}{owner_id}:{entry_id}"
34 def _index_key(self, owner_id: str) -> str:
35 return f"{_INDEX_KEY}:{owner_id}"
37 def _to_json(self, entry: MemoryEntry) -> str:
38 payload = {
39 "id": entry.id,
40 "owner_id": entry.owner_id,
41 "content": entry.content,
42 "role": entry.role,
43 "timestamp": entry.timestamp.isoformat(),
44 "importance": entry.importance,
45 "metadata": entry.metadata,
46 "embedding": entry.embedding,
47 }
48 return dumps_str(payload)
50 def _from_json(self, raw: str) -> MemoryEntry:
51 data = cast("dict[str, Any]", loads(raw))
52 return MemoryEntry(
53 id=str(data["id"]),
54 owner_id=str(data["owner_id"]),
55 content=str(data["content"]),
56 role=str(data["role"]),
57 timestamp=datetime.fromisoformat(str(data["timestamp"])),
58 importance=float(data.get("importance", 0.5)),
59 metadata=cast("dict[str, Any]", data.get("metadata", {})),
60 embedding=cast("list[float] | None", data.get("embedding")),
61 )
63 def _as_json_text(self, value: Any | None) -> str | None:
64 if value is None:
65 return None
66 if isinstance(value, str):
67 return value
68 if isinstance(value, bytes):
69 return value.decode("utf-8")
70 raise MemoryStoreError(
71 "Cache value was not JSON-serializable text", store="cache"
72 )
74 async def _cache_get_text(self, key: str) -> str | None:
75 get_result = await self._cache.get(key)
76 if get_result.is_err():
77 raise MemoryStoreError(
78 f"Cache get failed for key {key}",
79 store="cache",
80 ) from get_result.unwrap_err()
81 return self._as_json_text(get_result.unwrap_or(None))
83 async def _cache_set_text(
84 self, key: str, value: str, ttl: int | None = None
85 ) -> None:
86 set_result = await self._cache.set(key, value, ttl=ttl)
87 if set_result.is_err():
88 raise MemoryStoreError(
89 f"Cache set failed for key {key}",
90 store="cache",
91 ) from set_result.unwrap_err()
93 async def _cache_delete(self, key: str) -> None:
94 delete_result = await self._cache.delete(key)
95 if delete_result.is_err():
96 raise MemoryStoreError(
97 f"Cache delete failed for key {key}",
98 store="cache",
99 ) from delete_result.unwrap_err()
101 async def store(self, entry: MemoryEntry) -> None:
102 await self._cache_set_text(
103 self._key(entry.owner_id, entry.id), self._to_json(entry), ttl=self._ttl
104 )
105 index_raw = await self._cache_get_text(self._index_key(entry.owner_id))
106 ids: list[str] = cast("list[str]", loads(index_raw)) if index_raw else []
107 if entry.id not in ids:
108 ids.append(entry.id)
109 await self._cache_set_text(self._index_key(entry.owner_id), dumps_str(ids))
111 async def retrieve(self, query: MemoryQuery) -> list[MemorySearchResult]:
112 index_raw = await self._cache_get_text(self._index_key(query.owner_id))
113 if index_raw is None:
114 return []
116 ids = cast("list[str]", loads(index_raw))
117 entries: list[MemoryEntry] = []
118 for entry_id in ids:
119 raw = await self._cache_get_text(self._key(query.owner_id, entry_id))
120 if raw is not None:
121 entries.append(self._from_json(raw))
123 import math
125 scored: list[tuple[float, MemoryEntry]] = []
126 for entry in entries:
127 age_seconds = (datetime.now(UTC) - entry.timestamp).total_seconds()
128 recency = math.exp(-age_seconds / 86400.0)
129 score = (
130 query.recency_weight * recency
131 + query.importance_weight * entry.importance
132 + query.relevance_weight * 0.5
133 )
134 if score < query.min_relevance:
135 continue
136 if query.time_range:
137 start, end = query.time_range
138 if not (start <= entry.timestamp <= end):
139 continue
140 scored.append((score, entry))
142 scored.sort(key=lambda item: item[0], reverse=True)
143 return [
144 MemorySearchResult(entry=entry, score=score, source="cache")
145 for score, entry in scored[: query.top_k]
146 ]
148 async def get_recent(self, n: int, owner_id: str) -> list[MemoryEntry]:
149 index_raw = await self._cache_get_text(self._index_key(owner_id))
150 if index_raw is None:
151 return []
153 ids = cast("list[str]", loads(index_raw))
154 entries: list[MemoryEntry] = []
155 for entry_id in ids:
156 raw = await self._cache_get_text(self._key(owner_id, entry_id))
157 if raw is not None:
158 entries.append(self._from_json(raw))
159 return sorted(entries, key=lambda entry: entry.timestamp, reverse=True)[:n]
161 async def delete(self, entry_id: str, owner_id: str) -> None:
162 await self._cache_delete(self._key(owner_id, entry_id))
163 index_raw = await self._cache_get_text(self._index_key(owner_id))
164 if index_raw is None:
165 return
166 remaining_ids = [
167 item for item in cast("list[str]", loads(index_raw)) if item != entry_id
168 ]
169 await self._cache_set_text(self._index_key(owner_id), dumps_str(remaining_ids))
171 async def clear(self, owner_id: str) -> None:
172 index_raw = await self._cache_get_text(self._index_key(owner_id))
173 if index_raw is not None:
174 for entry_id in cast("list[str]", loads(index_raw)):
175 await self._cache_delete(self._key(owner_id, entry_id))
176 await self._cache_delete(self._index_key(owner_id))
178 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
179 health_result = await self._cache.health_check(timeout=timeout)
180 status = HealthStatus.HEALTHY
181 if not health_result.is_healthy():
182 status = HealthStatus.DEGRADED
183 return HealthCheckResult(
184 component="memory.cache",
185 status=status,
186 message=health_result.message,
187 error=health_result.error,
188 details={"timeout": timeout},
189 )
192__all__ = ["CacheMemoryBackend"]