ghostdrive1 commited on
Commit
58ab44f
·
1 Parent(s): 47f2fa7

feat(memory): Phase 3 — embedder, zilliz, raptor, worker, __init__

Browse files
packages/memory/__init__.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/memory/__init__.py
3
+
4
+ Memory pipeline public API.
5
+
6
+ Exports:
7
+ Embedder — sentence-transformers batch embed, 384-dim
8
+ ZillizStore — async upsert/search via AsyncMilvusClient
9
+ RaptorTree — RAPTOR tree build + query (Groq summariser, no OpenAI)
10
+ MemoryWorker — Redis mem_buffer flush-to-Zilliz background loop
11
+
12
+ Pre-registered gut-feel bugs (other files that could break this one):
13
+ W1 [HIGH] worker.py flush fires while raptor.py build_tree() running on same
14
+ user_id → partial tree written to Zilliz mid-build → corrupt nodes.
15
+ Fix: asyncio.Lock per user_id before tree build.
16
+ W2 [HIGH] embedder.py loads model at import time → HF Space cold start adds
17
+ ~8s. If main.py lifespan timeout is tight, Space may return 503
18
+ before Brain is ready. Fix: lazy-load model inside Embedder.__init__
19
+ only when first encode() call arrives.
20
+ W3 [MED] Zilliz free-tier has 1M vector cap per collection. If mem_buffer
21
+ flush writes leaf + summary nodes without dedup, cap hit fast.
22
+ Fix: upsert (not insert) keyed on chunk_id hash.
23
+ W4 [MED] RAPTOR summariser calls llm_router → pool key exhausted mid-build
24
+ → AllKeysExhaustedError raised inside tree build → partial tree
25
+ never cleaned up. Fix: catch in raptor.py, return partial tree.
26
+ W5 [LOW] sentence-transformers model download on first run (~90MB). HF Space
27
+ build cache doesn't persist between deploys unless /data mount used.
28
+ Fix: add all-MiniLM-L6-v2 to requirements.txt (triggers cache warm
29
+ during build, not at runtime).
30
+ """
31
+
32
+ from .embedder import Embedder
33
+ from .tier2_zilliz import ZillizStore
34
+ from .raptor import RaptorTree
35
+ from .worker import MemoryWorker
36
+
37
+ __all__ = ["Embedder", "ZillizStore", "RaptorTree", "MemoryWorker"]
packages/memory/embedder.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/memory/embedder.py
3
+
4
+ Batch embedding layer using sentence-transformers all-MiniLM-L6-v2.
5
+ 384-dim cosine-space vectors. CPU-only (HF Spaces free tier).
6
+
7
+ Design:
8
+ - Lazy model load: model downloaded/loaded on first encode() call, not at import.
9
+ - Async-safe: encode() runs in executor to avoid blocking the event loop.
10
+ - Batch: encode list[str] → list[list[float]] in one model.encode() call.
11
+ - Normalise: L2-norm so cosine sim == dot product (Zilliz COSINE metric).
12
+
13
+ Pre-registered gut-feel bugs (other files that could break this one):
14
+ E1 [HIGH] raptor.py calls encode() inside asyncio.gather() across multiple
15
+ user_ids simultaneously. sentence-transformers model.encode() is
16
+ NOT thread-safe when batch_size > 1. Fix: asyncio.Lock on encode().
17
+ E2 [MED] HF Space free tier has ~2GB RAM. all-MiniLM-L6-v2 uses ~90MB.
18
+ If browser_agent.py Playwright is also loaded, OOM risk.
19
+ Fix: track peak RAM at startup, warn via /health endpoint.
20
+ E3 [LOW] model.encode() returns numpy arrays. ZillizStore expects list[float].
21
+ Implicit .tolist() missing → pymilvus TypeError on upsert.
22
+ Fix: explicit .tolist() in encode() return.
23
+ """
24
+
25
+ import asyncio
26
+ import logging
27
+ from functools import lru_cache
28
+ from typing import List
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ MODEL_NAME = "all-MiniLM-L6-v2"
33
+ EMBED_DIM = 384
34
+
35
+
36
+ @lru_cache(maxsize=1)
37
+ def _load_model():
38
+ """Load model once, cache in process. Called inside executor thread."""
39
+ from sentence_transformers import SentenceTransformer # type: ignore
40
+ logger.info(f"Loading embedding model: {MODEL_NAME}")
41
+ model = SentenceTransformer(MODEL_NAME)
42
+ return model
43
+
44
+
45
+ class Embedder:
46
+ """
47
+ Async batch embedder.
48
+
49
+ Usage:
50
+ embedder = Embedder()
51
+ vecs = await embedder.encode(["hello world", "foo bar"])
52
+ # vecs: List[List[float]], shape (2, 384)
53
+ """
54
+
55
+ def __init__(self):
56
+ self._lock = asyncio.Lock() # E1 guard: single encode at a time
57
+ self._loop: asyncio.AbstractEventLoop | None = None
58
+
59
+ async def encode(self, texts: List[str]) -> List[List[float]]:
60
+ """
61
+ Encode a batch of texts to 384-dim normalised float vectors.
62
+
63
+ Args:
64
+ texts: Non-empty list of strings.
65
+
66
+ Returns:
67
+ List of float lists, one per input text.
68
+
69
+ Raises:
70
+ ValueError: If texts is empty.
71
+ """
72
+ if not texts:
73
+ raise ValueError("encode() called with empty texts list")
74
+
75
+ async with self._lock: # E1: serialise concurrent encode calls
76
+ loop = asyncio.get_running_loop()
77
+ # Run CPU-bound model.encode in thread pool — avoids blocking event loop
78
+ embeddings = await loop.run_in_executor(
79
+ None, self._sync_encode, texts
80
+ )
81
+ return embeddings
82
+
83
+ def _sync_encode(self, texts: List[str]) -> List[List[float]]:
84
+ """Blocking encode — called inside executor thread."""
85
+ model = _load_model()
86
+ # normalize_embeddings=True → L2 norm → cosine sim == dot product
87
+ vecs = model.encode(
88
+ texts,
89
+ batch_size=64,
90
+ normalize_embeddings=True,
91
+ show_progress_bar=False,
92
+ )
93
+ return [v.tolist() for v in vecs] # E3: explicit .tolist()
94
+
95
+ async def encode_one(self, text: str) -> List[float]:
96
+ """Convenience: encode a single string."""
97
+ results = await self.encode([text])
98
+ return results[0]
99
+
100
+ @property
101
+ def dim(self) -> int:
102
+ return EMBED_DIM
packages/memory/raptor.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/memory/raptor.py
3
+
4
+ RAPTOR tree — adapted from parthsarthi03/raptor for Ultron V4.
5
+
6
+ Key divergences from original:
7
+ - No OpenAI dependency. Summariser = Groq/any provider via llm_router.
8
+ - No FAISS. Retrieval delegates to ZillizStore (async).
9
+ - No tiktoken. Token counting via simple split (good enough for Groq 8k ctx).
10
+ - Clustering: UMAP + GMM from sklearn (same as original).
11
+ - Tree stored in Zilliz: leaf nodes (layer=0) + summary nodes (layer>0).
12
+ - build_tree(): embed chunks → cluster → summarise clusters → recurse.
13
+ - query(): search summary nodes first (collapse_tree mode), fall back raw.
14
+ - Async throughout. Single asyncio.Lock per user_id during build (W1 guard).
15
+
16
+ Pre-registered gut-feel bugs (other files that could break this one):
17
+ R1 [HIGH] worker.py calls build_tree() after every flush. If flush interval
18
+ is short (e.g., 10 msgs) and sessions are long, build_tree() runs
19
+ repeatedly on overlapping chunk sets → duplicate summary nodes.
20
+ Fix: track last_build_ts per user_id in Redis; skip if < 5min ago.
21
+ R2 [HIGH] UMAP requires n_samples > n_neighbors. If chunk batch < 4,
22
+ global_cluster_embeddings() crashes. Fix: skip RAPTOR if len < 4,
23
+ write raw leaf nodes only.
24
+ R3 [MED] Groq summariser called inside build_tree() → key exhausted →
25
+ AllKeysExhaustedError → tree build aborts, partial nodes in Zilliz.
26
+ Fix: catch AllKeysExhaustedError, mark nodes as orphaned, continue.
27
+ R4 [MED] RAPTOR max_layers=3 default. For very long sessions (100+ chunks)
28
+ tree depth may be insufficient → top-level summaries too coarse.
29
+ Fix: auto-scale layers = max(2, log2(len(chunks)//4)).
30
+ R5 [LOW] sklearn GaussianMixture BIC search up to max_clusters=50 is slow
31
+ on CPU for n_chunks > 200. Fix: cap max_clusters = min(50, n//4).
32
+ """
33
+
34
+ import asyncio
35
+ import logging
36
+ import math
37
+ from typing import List, Optional
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ MAX_LAYERS = 3
42
+ MIN_CLUSTER_SIZE = 4 # R2: skip RAPTOR if fewer chunks
43
+ MAX_TOKENS_PER_CLUSTER = 3500
44
+ SUMMARY_MAX_TOKENS = 150
45
+
46
+ # Per-user locks to prevent concurrent tree builds (W1)
47
+ _build_locks: dict[str, asyncio.Lock] = {}
48
+ _build_locks_mutex = asyncio.Lock()
49
+
50
+
51
+ async def _get_user_lock(user_id: str) -> asyncio.Lock:
52
+ async with _build_locks_mutex:
53
+ if user_id not in _build_locks:
54
+ _build_locks[user_id] = asyncio.Lock()
55
+ return _build_locks[user_id]
56
+
57
+
58
+ def _count_tokens(text: str) -> int:
59
+ """Approximate token count via whitespace split. ~4 chars/token is common."""
60
+ return max(1, len(text) // 4)
61
+
62
+
63
+ def _cluster_embeddings(embeddings, threshold: float = 0.1):
64
+ """
65
+ RAPTOR clustering: UMAP dim-reduce → GMM soft clustering.
66
+ Returns list of cluster label arrays per node (node can belong to multiple clusters).
67
+ Mirrors cluster_utils.perform_clustering from parthsarthi03/raptor.
68
+ """
69
+ import numpy as np
70
+ from sklearn.mixture import GaussianMixture # type: ignore
71
+ import umap # type: ignore
72
+
73
+ n = len(embeddings)
74
+ dim = min(10, n - 2) # UMAP can't reduce to >= n_samples
75
+ if dim < 2:
76
+ # Can't cluster — return all in one cluster
77
+ return [[0] for _ in embeddings]
78
+
79
+ arr = np.array(embeddings)
80
+ n_neighbors = max(2, int(n ** 0.5))
81
+
82
+ # Global reduction
83
+ reduced = umap.UMAP(
84
+ n_neighbors=n_neighbors, n_components=dim, metric="cosine"
85
+ ).fit_transform(arr)
86
+
87
+ # GMM — BIC to find optimal clusters
88
+ max_k = min(50, n // 4) # R5
89
+ if max_k < 2:
90
+ return [[0] for _ in embeddings]
91
+
92
+ bics = []
93
+ for k in range(1, max_k + 1):
94
+ gm = GaussianMixture(n_components=k, random_state=42)
95
+ gm.fit(reduced)
96
+ bics.append(gm.bic(reduced))
97
+
98
+ best_k = bics.index(min(bics)) + 1
99
+ gm = GaussianMixture(n_components=best_k, random_state=42)
100
+ gm.fit(reduced)
101
+ probs = gm.predict_proba(reduced)
102
+ labels = [list(np.where(p > threshold)[0]) or [int(p.argmax())] for p in probs]
103
+ return labels
104
+
105
+
106
+ class RaptorTree:
107
+ """
108
+ RAPTOR tree builder and retriever.
109
+
110
+ Usage:
111
+ raptor = RaptorTree(embedder, zilliz_store, llm_fn)
112
+ await raptor.build_tree(user_id, chunks)
113
+ context = await raptor.query(user_id, query_text, query_vec)
114
+ """
115
+
116
+ def __init__(self, embedder, zilliz_store, llm_fn):
117
+ """
118
+ Args:
119
+ embedder: Embedder instance.
120
+ zilliz_store: ZillizStore instance.
121
+ llm_fn: Async callable(messages: list) → str.
122
+ Should be make_provider_llm_fn(pool) from llm_router.
123
+ """
124
+ self._embedder = embedder
125
+ self._store = zilliz_store
126
+ self._llm = llm_fn
127
+
128
+ async def build_tree(
129
+ self,
130
+ user_id: str,
131
+ chunks: List[str],
132
+ max_layers: int = MAX_LAYERS,
133
+ ) -> None:
134
+ """
135
+ Build RAPTOR tree from text chunks and upsert all nodes into Zilliz.
136
+
137
+ Steps:
138
+ 1. Embed all chunks.
139
+ 2. Upsert as leaf nodes (layer=0).
140
+ 3. Cluster embeddings → summarise each cluster → upsert summary (layer=1).
141
+ 4. Recurse on summary texts up to max_layers.
142
+
143
+ Skips if len(chunks) < MIN_CLUSTER_SIZE (R2).
144
+ """
145
+ if not chunks:
146
+ logger.warning(f"build_tree called with empty chunks for {user_id}")
147
+ return
148
+
149
+ lock = await _get_user_lock(user_id)
150
+ async with lock: # W1: serialise per user_id
151
+ await self._build_layer(user_id, chunks, layer=0, max_layers=max_layers)
152
+
153
+ async def _build_layer(
154
+ self,
155
+ user_id: str,
156
+ texts: List[str],
157
+ layer: int,
158
+ max_layers: int,
159
+ ) -> None:
160
+ """Recursive layer builder."""
161
+ logger.info(f"RAPTOR build_layer user={user_id} layer={layer} n_chunks={len(texts)}")
162
+
163
+ # Embed current layer
164
+ embeddings = await self._embedder.encode(texts)
165
+
166
+ # Upsert current layer to Zilliz
167
+ node_type = "leaf" if layer == 0 else "summary"
168
+ await self._store.upsert(user_id, texts, embeddings, node_type=node_type, layer=layer)
169
+
170
+ # Base cases: max depth reached or too few chunks to cluster
171
+ if layer >= max_layers or len(texts) < MIN_CLUSTER_SIZE: # R2
172
+ return
173
+
174
+ # Cluster
175
+ try:
176
+ labels = _cluster_embeddings(embeddings)
177
+ except Exception as exc:
178
+ logger.warning(f"RAPTOR clustering failed at layer {layer}: {exc}")
179
+ return
180
+
181
+ # Group texts by cluster
182
+ clusters: dict[int, List[str]] = {}
183
+ for i, node_labels in enumerate(labels):
184
+ for lbl in node_labels:
185
+ clusters.setdefault(int(lbl), []).append(texts[i])
186
+
187
+ # Enforce max token budget per cluster
188
+ summary_texts: List[str] = []
189
+ for cluster_texts in clusters.values():
190
+ # Truncate cluster to MAX_TOKENS_PER_CLUSTER
191
+ budget = 0
192
+ selected = []
193
+ for t in cluster_texts:
194
+ toks = _count_tokens(t)
195
+ if budget + toks > MAX_TOKENS_PER_CLUSTER:
196
+ break
197
+ selected.append(t)
198
+ budget += toks
199
+ if not selected:
200
+ selected = cluster_texts[:1]
201
+
202
+ summary = await self._summarise_cluster(selected)
203
+ if summary:
204
+ summary_texts.append(summary)
205
+
206
+ if not summary_texts:
207
+ return
208
+
209
+ # Recurse on summary layer
210
+ await self._build_layer(user_id, summary_texts, layer=layer + 1, max_layers=max_layers)
211
+
212
+ async def _summarise_cluster(self, texts: List[str]) -> Optional[str]:
213
+ """Call LLM to summarise a cluster of texts. Returns None on failure (R3)."""
214
+ combined = "\n\n".join(texts)
215
+ messages = [
216
+ {
217
+ "role": "system",
218
+ "content": "Summarise the following text concisely in 1-3 sentences. Return only the summary.",
219
+ },
220
+ {"role": "user", "content": combined[:6000]}, # stay under Groq ctx
221
+ ]
222
+ try:
223
+ from packages.shared.exceptions import AllKeysExhaustedError # type: ignore
224
+ summary = await self._llm(messages)
225
+ return summary.strip() if summary else None
226
+ except Exception as exc: # R3: AllKeysExhaustedError + any other
227
+ logger.warning(f"RAPTOR summarise failed: {exc}")
228
+ return None
229
+
230
+ async def query(
231
+ self,
232
+ user_id: str,
233
+ query_text: str,
234
+ query_vec: Optional[List[float]] = None,
235
+ top_k: int = 5,
236
+ ) -> str:
237
+ """
238
+ Retrieve relevant context for a query.
239
+
240
+ Strategy (collapse_tree mode from original RAPTOR):
241
+ 1. Search summary nodes first (higher-level context).
242
+ 2. Augment with raw leaf nodes.
243
+ 3. Concatenate unique results, return as context string.
244
+
245
+ Args:
246
+ user_id: Discord user_id.
247
+ query_text: Raw query string.
248
+ query_vec: Pre-computed embedding (optional; computed if None).
249
+ top_k: Number of results per tier.
250
+
251
+ Returns:
252
+ Context string for LLM prompt injection.
253
+ """
254
+ if query_vec is None:
255
+ query_vec = await self._embedder.encode_one(query_text)
256
+
257
+ # Search summaries first (tree top-down)
258
+ summary_hits = await self._store.search(
259
+ user_id, query_vec, top_k=top_k, node_type_filter="summary"
260
+ )
261
+ # Then raw leaves
262
+ leaf_hits = await self._store.search(
263
+ user_id, query_vec, top_k=top_k, node_type_filter="leaf"
264
+ )
265
+
266
+ # Deduplicate and combine
267
+ seen: set[str] = set()
268
+ parts: List[str] = []
269
+ for hit in summary_hits + leaf_hits:
270
+ text = hit["text"]
271
+ if text not in seen:
272
+ seen.add(text)
273
+ parts.append(text)
274
+
275
+ if not parts:
276
+ return "" # caller falls back to pure LLM
277
+
278
+ return "\n---\n".join(parts[:top_k * 2])
packages/memory/tier2_zilliz.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/memory/tier2_zilliz.py
3
+
4
+ Zilliz (Milvus-as-a-Service) async vector store.
5
+
6
+ Architecture:
7
+ - AsyncMilvusClient (pymilvus) — async context manager pattern.
8
+ - Collection per shard: ultron_mem_{shard_id}, shard = hash(user_id) % 15.
9
+ - Schema: id (VARCHAR PK, 64 chars), user_id (VARCHAR), chunk_id (VARCHAR),
10
+ text (VARCHAR, 2048), node_type (VARCHAR, 32), layer (INT64),
11
+ vector (FLOAT_VECTOR, 384), ts (INT64 unix ms).
12
+ - Metric: COSINE. Index: AUTOINDEX (Zilliz manages HNSW internally).
13
+ - Upsert (not insert) keyed on chunk_id — dedup guard (W3).
14
+ - search() returns top_k results as list[dict].
15
+ - ensure_collection() is idempotent — safe to call on every startup.
16
+
17
+ Pre-registered gut-feel bugs (other files that could break this one):
18
+ Z1 [HIGH] Zilliz free cluster goes cold after inactivity (~10min). First
19
+ upsert/search after cold start → gRPC timeout (default 10s).
20
+ Fix: set timeout=30 on all ops; /health pings each shard daily.
21
+ Z2 [HIGH] pymilvus AsyncMilvusClient is EXPERIMENTAL (their own warning).
22
+ If Zilliz server ≥ 2.5 returns new protobuf field, client may
23
+ raise decode error. Fix: pin pymilvus==2.4.x in requirements.txt.
24
+ Z3 [MED] ensure_collection() called concurrently for same shard on startup
25
+ → duplicate create_collection race. Fix: asyncio.Lock per shard.
26
+ Z4 [MED] FLOAT_VECTOR dim mismatch (embedder returns 384, collection created
27
+ with wrong dim) → MilvusException on upsert. Fix: assert dim==384
28
+ in ensure_collection() describe result.
29
+ Z5 [LOW] VARCHAR PK max_length=64 but chunk_id is SHA256 hex (64 chars)
30
+ → exact boundary. If pymilvus adds null terminator, will overflow.
31
+ Fix: truncate chunk_id to 60 chars.
32
+ """
33
+
34
+ import asyncio
35
+ import hashlib
36
+ import logging
37
+ import time
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ NUM_SHARDS = 15
43
+ EMBED_DIM = 384
44
+ COLLECTION_PREFIX = "ultron_mem"
45
+ MAX_TEXT_LEN = 2048
46
+ CHUNK_ID_MAX = 60 # Z5: stay under 64 VARCHAR PK limit
47
+
48
+
49
+ def _shard(user_id: str) -> int:
50
+ """Deterministic shard index for a user_id. Matches locked Zilliz sharding rule."""
51
+ return int(hashlib.md5(user_id.encode()).hexdigest(), 16) % NUM_SHARDS
52
+
53
+
54
+ def _collection_name(user_id: str) -> str:
55
+ return f"{COLLECTION_PREFIX}_{_shard(user_id)}"
56
+
57
+
58
+ def _chunk_id(text: str, user_id: str) -> str:
59
+ """Stable dedup key: SHA256(user_id + text), truncated to 60 chars."""
60
+ raw = hashlib.sha256(f"{user_id}:{text}".encode()).hexdigest()
61
+ return raw[:CHUNK_ID_MAX]
62
+
63
+
64
+ class ZillizStore:
65
+ """
66
+ Async Zilliz vector store.
67
+
68
+ Usage:
69
+ store = ZillizStore(uri=ZILLIZ_URI, token=ZILLIZ_TOKEN)
70
+ await store.ensure_collection(user_id)
71
+ await store.upsert(user_id, chunks, embeddings, node_type="leaf", layer=0)
72
+ results = await store.search(user_id, query_vec, top_k=5)
73
+ await store.close()
74
+ """
75
+
76
+ def __init__(self, uri: str, token: str):
77
+ self._uri = uri
78
+ self._token = token
79
+ self._clients: Dict[int, Any] = {} # shard_id → AsyncMilvusClient
80
+ self._ensure_locks: Dict[int, asyncio.Lock] = {} # Z3: per-shard lock
81
+ self._global_lock = asyncio.Lock()
82
+
83
+ async def _get_client(self, shard: int) -> Any:
84
+ """Lazy init AsyncMilvusClient per shard."""
85
+ if shard not in self._clients:
86
+ async with self._global_lock:
87
+ if shard not in self._clients: # double-check after lock
88
+ from pymilvus import AsyncMilvusClient # type: ignore
89
+ client = AsyncMilvusClient(uri=self._uri, token=self._token)
90
+ await client._connect()
91
+ self._clients[shard] = client
92
+ self._ensure_locks[shard] = asyncio.Lock()
93
+ return self._clients[shard]
94
+
95
+ async def ensure_collection(self, user_id: str) -> None:
96
+ """
97
+ Idempotent: create collection + index if not exists.
98
+ Safe to call every startup. Z3: serialised per shard.
99
+ """
100
+ shard = _shard(user_id)
101
+ client = await self._get_client(shard)
102
+ col = _collection_name(user_id)
103
+
104
+ # Ensure lock exists (may be first call)
105
+ if shard not in self._ensure_locks:
106
+ self._ensure_locks[shard] = asyncio.Lock()
107
+
108
+ async with self._ensure_locks[shard]: # Z3
109
+ exists = await client.has_collection(col, timeout=30)
110
+ if exists:
111
+ logger.debug(f"Collection {col} already exists")
112
+ return
113
+
114
+ logger.info(f"Creating collection {col} (shard {shard})")
115
+ from pymilvus import DataType # type: ignore
116
+ from pymilvus.milvus_client import IndexParams # type: ignore
117
+
118
+ schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
119
+ schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=64)
120
+ schema.add_field("user_id", DataType.VARCHAR, max_length=128)
121
+ schema.add_field("chunk_id", DataType.VARCHAR, max_length=64)
122
+ schema.add_field("text", DataType.VARCHAR, max_length=MAX_TEXT_LEN)
123
+ schema.add_field("node_type", DataType.VARCHAR, max_length=32) # leaf|summary
124
+ schema.add_field("layer", DataType.INT64)
125
+ schema.add_field("vector", DataType.FLOAT_VECTOR, dim=EMBED_DIM) # Z4
126
+ schema.add_field("ts", DataType.INT64)
127
+
128
+ idx = IndexParams()
129
+ idx.add_index("vector", index_type="AUTOINDEX", metric_type="COSINE")
130
+
131
+ await client.create_collection(
132
+ col, schema=schema, index_params=idx, timeout=30
133
+ )
134
+ logger.info(f"Collection {col} created")
135
+
136
+ async def upsert(
137
+ self,
138
+ user_id: str,
139
+ texts: List[str],
140
+ embeddings: List[List[float]],
141
+ node_type: str = "leaf",
142
+ layer: int = 0,
143
+ ) -> int:
144
+ """
145
+ Upsert text chunks + embeddings into user's shard.
146
+
147
+ Args:
148
+ user_id: Discord user_id string.
149
+ texts: Raw text chunks (parallel with embeddings).
150
+ embeddings: 384-dim float vectors.
151
+ node_type: 'leaf' or 'summary'.
152
+ layer: RAPTOR tree layer (0 = leaf).
153
+
154
+ Returns:
155
+ Number of rows upserted.
156
+ """
157
+ if len(texts) != len(embeddings):
158
+ raise ValueError(f"texts/embeddings length mismatch: {len(texts)} vs {len(embeddings)}")
159
+ if not texts:
160
+ return 0
161
+
162
+ await self.ensure_collection(user_id)
163
+ shard = _shard(user_id)
164
+ client = await self._get_client(shard)
165
+ col = _collection_name(user_id)
166
+ ts = int(time.time() * 1000)
167
+
168
+ rows = []
169
+ for text, vec in zip(texts, embeddings):
170
+ cid = _chunk_id(text, user_id)
171
+ rows.append({
172
+ "id": cid, # PK = chunk_id for upsert dedup
173
+ "user_id": user_id,
174
+ "chunk_id": cid,
175
+ "text": text[:MAX_TEXT_LEN],
176
+ "node_type": node_type,
177
+ "layer": layer,
178
+ "vector": vec,
179
+ "ts": ts,
180
+ })
181
+
182
+ res = await client.upsert(col, rows, timeout=30)
183
+ count = res.get("upsert_count", len(rows))
184
+ logger.info(f"Zilliz upsert {count} rows → {col} (layer={layer}, type={node_type})")
185
+ return count
186
+
187
+ async def search(
188
+ self,
189
+ user_id: str,
190
+ query_vec: List[float],
191
+ top_k: int = 5,
192
+ node_type_filter: Optional[str] = None,
193
+ ) -> List[Dict]:
194
+ """
195
+ Cosine ANN search in user's shard.
196
+
197
+ Args:
198
+ user_id: Discord user_id.
199
+ query_vec: 384-dim query embedding.
200
+ top_k: Number of results.
201
+ node_type_filter: Optional 'leaf' or 'summary' filter.
202
+
203
+ Returns:
204
+ List of dicts with keys: text, node_type, layer, score, ts.
205
+ """
206
+ shard = _shard(user_id)
207
+ col = _collection_name(user_id)
208
+ client = await self._get_client(shard)
209
+
210
+ filter_expr = ""
211
+ if node_type_filter:
212
+ filter_expr = f'node_type == "{node_type_filter}"'
213
+
214
+ try:
215
+ hits = await client.search(
216
+ collection_name=col,
217
+ data=[query_vec],
218
+ anns_field="vector",
219
+ limit=top_k,
220
+ output_fields=["text", "node_type", "layer", "ts"],
221
+ filter=filter_expr or "",
222
+ timeout=30,
223
+ )
224
+ except Exception as exc:
225
+ logger.warning(f"Zilliz search failed for {user_id}: {exc}")
226
+ return []
227
+
228
+ results = []
229
+ for hit in hits[0]: # hits[0] = results for first query vector
230
+ results.append({
231
+ "text": hit["entity"].get("text", ""),
232
+ "node_type": hit["entity"].get("node_type", "leaf"),
233
+ "layer": hit["entity"].get("layer", 0),
234
+ "score": hit.get("distance", 0.0),
235
+ "ts": hit["entity"].get("ts", 0),
236
+ })
237
+ return results
238
+
239
+ async def close(self) -> None:
240
+ """Close all open client connections."""
241
+ for client in self._clients.values():
242
+ try:
243
+ await client.close()
244
+ except Exception:
245
+ pass
246
+ self._clients.clear()
packages/memory/worker.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/memory/worker.py
3
+
4
+ Memory flush worker — drains Redis mem_buffer → embeds → RAPTOR tree build → Zilliz.
5
+
6
+ Redis key: ultron:mem_buffer:{user_id} (LIST, right-push on write, left-pop on flush)
7
+ Flush trigger: FLUSH_THRESHOLD items OR FLUSH_INTERVAL_SECS elapsed since last flush.
8
+ Run as background asyncio task started in main.py lifespan.
9
+
10
+ Flow per user_id:
11
+ 1. LRANGE ultron:mem_buffer:{uid} 0 FLUSH_THRESHOLD-1
12
+ 2. Decode JSON: [{"role": ..., "content": ..., "ts": ...}, ...]
13
+ 3. Chunk content into ~512-token windows (50-token overlap)
14
+ 4. Embedder.encode(chunks)
15
+ 5. RaptorTree.build_tree(uid, chunks) -- upserts leaf + summary nodes
16
+ 6. LTRIM ultron:mem_buffer:{uid} FLUSH_THRESHOLD -1 (remove flushed items)
17
+ 7. Set ultron:mem_last_flush:{uid} = now (Redis SET EX 86400)
18
+
19
+ Error handling: Redis unavailable → log + skip (never crash worker loop).
20
+ Zilliz unavailable → log + skip, retry next interval.
21
+
22
+ Pre-registered gut-feel bugs (other files that could break this one):
23
+ MW1 [HIGH] main.py lifespan cancels the worker task on shutdown. If flush is
24
+ mid-write (after embed, before upsert), Zilliz gets orphan vectors
25
+ without tree structure. Fix: asyncio.shield() the upsert call.
26
+ MW2 [HIGH] Redis LRANGE returns bytes, not str. JSON decode fails on raw bytes.
27
+ Fix: decode bytes → str before json.loads().
28
+ MW3 [MED] Two worker iterations overlap if flush_interval_secs is shorter than
29
+ build_tree() runtime. Fix: per-user_id asyncio.Lock in worker loop.
30
+ MW4 [MED] chunk_text() splits on whitespace — if a message is one long URL
31
+ (2000 chars), it becomes a single chunk > 512 tokens. Groq context
32
+ may handle it but RAPTOR BIC clustering will treat it as noise.
33
+ Fix: hard-cap chunk size at 512 tokens with forced split.
34
+ MW5 [LOW] mem_buffer list unbounded if flush never fires (e.g. Zilliz always
35
+ fails). Fix: LTRIM to 200 items max as safety in ensure_buffer_size().
36
+ """
37
+
38
+ import asyncio
39
+ import json
40
+ import logging
41
+ import time
42
+ from typing import List, Optional
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ FLUSH_THRESHOLD = 10 # flush after this many messages buffered
47
+ FLUSH_INTERVAL_SECS = 300 # flush every 5 min regardless of count
48
+ CHUNK_SIZE_CHARS = 2000 # ~500 tokens at 4 chars/token
49
+ CHUNK_OVERLAP_CHARS = 200 # ~50 tokens overlap
50
+ MEM_BUFFER_PREFIX = "ultron:mem_buffer"
51
+ MEM_LAST_FLUSH_PREFIX = "ultron:mem_last_flush"
52
+ MAX_BUFFER_SIZE = 200 # MW5: safety cap
53
+ FLUSH_SLEEP_SECS = 60 # worker poll interval
54
+
55
+
56
+ def _chunk_text(text: str) -> List[str]:
57
+ """
58
+ Split text into overlapping chunks of ~CHUNK_SIZE_CHARS characters.
59
+ MW4: hard-cap each chunk to CHUNK_SIZE_CHARS.
60
+ """
61
+ if len(text) <= CHUNK_SIZE_CHARS:
62
+ return [text] if text.strip() else []
63
+
64
+ chunks = []
65
+ start = 0
66
+ while start < len(text):
67
+ end = start + CHUNK_SIZE_CHARS
68
+ chunk = text[start:end]
69
+ if chunk.strip():
70
+ chunks.append(chunk)
71
+ start = end - CHUNK_OVERLAP_CHARS # overlap
72
+ if start >= len(text) - CHUNK_OVERLAP_CHARS:
73
+ break
74
+ return chunks
75
+
76
+
77
+ def _messages_to_chunks(messages: List[dict]) -> List[str]:
78
+ """Convert role-content message dicts to text chunks."""
79
+ chunks: List[str] = []
80
+ for msg in messages:
81
+ role = msg.get("role", "user")
82
+ content = msg.get("content", "")
83
+ if not content or not content.strip():
84
+ continue
85
+ text = f"{role}: {content}"
86
+ chunks.extend(_chunk_text(text))
87
+ return chunks
88
+
89
+
90
+ class MemoryWorker:
91
+ """
92
+ Background memory flush worker.
93
+
94
+ Usage (in main.py lifespan):
95
+ worker = MemoryWorker(redis_client, embedder, raptor_tree)
96
+ task = asyncio.create_task(worker.run())
97
+ # on shutdown:
98
+ task.cancel()
99
+ """
100
+
101
+ def __init__(self, redis, embedder, raptor_tree):
102
+ """
103
+ Args:
104
+ redis: aioredis / redis.asyncio client (already connected).
105
+ embedder: Embedder instance.
106
+ raptor_tree: RaptorTree instance.
107
+ """
108
+ self._redis = redis
109
+ self._embedder = embedder
110
+ self._raptor = raptor_tree
111
+ self._user_locks: dict[str, asyncio.Lock] = {} # MW3
112
+ self._lock_mutex = asyncio.Lock()
113
+
114
+ async def _get_lock(self, user_id: str) -> asyncio.Lock:
115
+ async with self._lock_mutex:
116
+ if user_id not in self._user_locks:
117
+ self._user_locks[user_id] = asyncio.Lock()
118
+ return self._user_locks[user_id]
119
+
120
+ async def run(self) -> None:
121
+ """Main worker loop. Runs indefinitely until cancelled."""
122
+ logger.info("MemoryWorker started")
123
+ while True:
124
+ try:
125
+ await self._tick()
126
+ except asyncio.CancelledError:
127
+ logger.info("MemoryWorker cancelled")
128
+ raise
129
+ except Exception as exc:
130
+ logger.error(f"MemoryWorker tick error: {exc}")
131
+ await asyncio.sleep(FLUSH_SLEEP_SECS)
132
+
133
+ async def _tick(self) -> None:
134
+ """Single flush cycle: scan all active buffers."""
135
+ try:
136
+ # Find all mem_buffer keys
137
+ keys = await self._redis.keys(f"{MEM_BUFFER_PREFIX}:*")
138
+ except Exception as exc:
139
+ logger.warning(f"Redis keys scan failed: {exc}")
140
+ return
141
+
142
+ if not keys:
143
+ return
144
+
145
+ # MW2: keys may be bytes
146
+ user_ids = []
147
+ for k in keys:
148
+ key_str = k.decode() if isinstance(k, bytes) else k
149
+ uid = key_str.replace(f"{MEM_BUFFER_PREFIX}:", "")
150
+ user_ids.append(uid)
151
+
152
+ # Flush each user concurrently (bounded to 5 at a time)
153
+ sem = asyncio.Semaphore(5)
154
+ tasks = [self._flush_user(uid, sem) for uid in user_ids]
155
+ await asyncio.gather(*tasks, return_exceptions=True)
156
+
157
+ async def _flush_user(self, user_id: str, sem: asyncio.Semaphore) -> None:
158
+ """Flush one user's mem_buffer if threshold or interval reached."""
159
+ async with sem:
160
+ lock = await self._get_lock(user_id) # MW3
161
+ async with lock:
162
+ await self._do_flush(user_id)
163
+
164
+ async def _do_flush(self, user_id: str) -> None:
165
+ """Core flush logic for a single user_id."""
166
+ buf_key = f"{MEM_BUFFER_PREFIX}:{user_id}"
167
+ last_key = f"{MEM_LAST_FLUSH_PREFIX}:{user_id}"
168
+
169
+ try:
170
+ buf_len = await self._redis.llen(buf_key)
171
+ except Exception as exc:
172
+ logger.warning(f"Redis llen failed for {user_id}: {exc}")
173
+ return
174
+
175
+ if buf_len == 0:
176
+ return
177
+
178
+ # Check flush triggers
179
+ should_flush = buf_len >= FLUSH_THRESHOLD
180
+ if not should_flush:
181
+ try:
182
+ last_flush = await self._redis.get(last_key)
183
+ if last_flush:
184
+ elapsed = time.time() - float(last_flush)
185
+ should_flush = elapsed >= FLUSH_INTERVAL_SECS
186
+ except Exception:
187
+ should_flush = False
188
+
189
+ if not should_flush:
190
+ return
191
+
192
+ # LRANGE: get up to FLUSH_THRESHOLD items
193
+ try:
194
+ raw_items = await self._redis.lrange(buf_key, 0, FLUSH_THRESHOLD - 1)
195
+ except Exception as exc:
196
+ logger.warning(f"Redis lrange failed for {user_id}: {exc}")
197
+ return
198
+
199
+ messages = []
200
+ for item in raw_items:
201
+ # MW2: decode bytes
202
+ item_str = item.decode() if isinstance(item, bytes) else item
203
+ try:
204
+ messages.append(json.loads(item_str))
205
+ except json.JSONDecodeError:
206
+ messages.append({"role": "user", "content": item_str})
207
+
208
+ chunks = _messages_to_chunks(messages)
209
+ if not chunks:
210
+ logger.debug(f"No chunks extracted for {user_id}")
211
+ return
212
+
213
+ logger.info(f"MemoryWorker flushing {len(chunks)} chunks for {user_id}")
214
+
215
+ try:
216
+ # asyncio.shield: protect upsert from cancellation (MW1)
217
+ await asyncio.shield(
218
+ self._raptor.build_tree(user_id, chunks)
219
+ )
220
+ except Exception as exc:
221
+ logger.error(f"RAPTOR build_tree failed for {user_id}: {exc}")
222
+ return # don't trim buffer if build failed
223
+
224
+ # Trim flushed items from buffer
225
+ try:
226
+ await self._redis.ltrim(buf_key, len(raw_items), -1)
227
+ await self._redis.set(last_key, str(time.time()), ex=86400)
228
+ except Exception as exc:
229
+ logger.warning(f"Redis post-flush trim failed for {user_id}: {exc}")
230
+
231
+ # MW5: safety cap
232
+ try:
233
+ await self._redis.ltrim(buf_key, -MAX_BUFFER_SIZE, -1)
234
+ except Exception:
235
+ pass
236
+
237
+ logger.info(f"MemoryWorker flush complete for {user_id}")