Spaces:
Runtime error
Runtime error
Commit ·
58ab44f
1
Parent(s): 47f2fa7
feat(memory): Phase 3 — embedder, zilliz, raptor, worker, __init__
Browse files- packages/memory/__init__.py +37 -0
- packages/memory/embedder.py +102 -0
- packages/memory/raptor.py +278 -0
- packages/memory/tier2_zilliz.py +246 -0
- packages/memory/worker.py +237 -0
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}")
|