Upload folder using huggingface_hub
Browse files- REVIEW_v4.5.0-beta.md +442 -0
- ROADMAP.md +152 -0
- config.yaml +6 -6
- data/subconscious_audit.jsonl +0 -0
- docs/PATTERN_LEARNER_SPEC.md +553 -0
- mnemocore_verify.py +6 -6
- src/mnemocore/api/main.py +32 -1
- src/mnemocore/core/binary_hdv.py +2 -3
- src/mnemocore/core/hnsw_index.py +2 -7
- src/mnemocore/core/qdrant_store.py +47 -41
- src/mnemocore/core/subconscious_ai.py +19 -15
- src/mnemocore/core/tier_manager.py +6 -2
- sync_qdrant.py +50 -0
- test_qdrant_scores.py +61 -0
REVIEW_v4.5.0-beta.md
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore v4.5.0-beta — Code Review
|
| 2 |
+
**Reviewer:** Omega (GLM-5)
|
| 3 |
+
**Datum:** 2026-02-20 07:45 CET
|
| 4 |
+
**Scope:** Full kodbas, fokus på query/store-flödet
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 🚨 KRITISKA PROBLEMER (Blockers)
|
| 9 |
+
|
| 10 |
+
### 1. **Query Returnerar 0 Resultat** 🔴 BLOCKER
|
| 11 |
+
**Symptom:** `POST /query` returnerar tom lista även efter framgångsrik `POST /store`
|
| 12 |
+
|
| 13 |
+
**Root Cause Analysis:**
|
| 14 |
+
|
| 15 |
+
#### 1.1 HNSW Index Manager — Position Mapping Bug
|
| 16 |
+
**Fil:** `hnsw_index.py:221-236`
|
| 17 |
+
|
| 18 |
+
```python
|
| 19 |
+
def _position_to_node_id(self, position: int) -> Optional[str]:
|
| 20 |
+
"""Map HNSW sequential position back to node_id."""
|
| 21 |
+
if not hasattr(self, "_position_map"):
|
| 22 |
+
object.__setattr__(self, "_position_map", {})
|
| 23 |
+
pm: Dict[int, str] = self._position_map
|
| 24 |
+
|
| 25 |
+
# Rebuild position map if needed (after index rebuild)
|
| 26 |
+
if len(pm) < len(self._id_map):
|
| 27 |
+
pm.clear()
|
| 28 |
+
for pos, (fid, nid) in enumerate(
|
| 29 |
+
sorted(self._id_map.items(), key=lambda x: x[0])
|
| 30 |
+
):
|
| 31 |
+
pm[pos] = nid
|
| 32 |
+
|
| 33 |
+
return pm.get(position)
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
**PROBLEM:** Position map bygger på `sorted(_id_map.items(), key=lambda x: x[0])` vilket sorterar efter FAISS ID (int), **inte** efter insättningsordning. HNSW returnerar positioner baserat på insättningsordning, men mappningen är inkonsekvent.
|
| 37 |
+
|
| 38 |
+
**Fix:**
|
| 39 |
+
```python
|
| 40 |
+
# Behåll insättningsordning separat
|
| 41 |
+
def add(self, node_id: str, hdv_data: np.ndarray) -> None:
|
| 42 |
+
# ... existing code ...
|
| 43 |
+
self._insertion_order.append(node_id) # NY
|
| 44 |
+
|
| 45 |
+
def _position_to_node_id(self, position: int) -> Optional[str]:
|
| 46 |
+
if position < len(self._insertion_order):
|
| 47 |
+
return self._insertion_order[position]
|
| 48 |
+
return None
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
#### 1.2 TextEncoder — Token Normalization Inkonsekvens
|
| 54 |
+
**Fil:** `binary_hdv.py:339-342`
|
| 55 |
+
|
| 56 |
+
```python
|
| 57 |
+
def encode(self, text: str) -> BinaryHDV:
|
| 58 |
+
tokens = text.lower().split() # <-- BARA whitespace split
|
| 59 |
+
if not tokens:
|
| 60 |
+
return BinaryHDV.random(self.dimension)
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
**PROBLEM:** Query-text vs lagrad text kan ha olika tokenisering:
|
| 64 |
+
- `"Hello World"` → tokens: `["hello", "world"]`
|
| 65 |
+
- `"Hello, World!"` → tokens: `["hello,", "world!"]` ← olika token!
|
| 66 |
+
|
| 67 |
+
**Fix:**
|
| 68 |
+
```python
|
| 69 |
+
import re
|
| 70 |
+
|
| 71 |
+
def encode(self, text: str) -> BinaryHDV:
|
| 72 |
+
# Konsekvent tokenisering
|
| 73 |
+
tokens = re.findall(r'\b\w+\b', text.lower())
|
| 74 |
+
if not tokens:
|
| 75 |
+
return BinaryHDV.random(self.dimension)
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
#### 1.3 HNSW Upgrade Threshold Race Condition
|
| 81 |
+
**Fil:** `hnsw_index.py:87-117`
|
| 82 |
+
|
| 83 |
+
```python
|
| 84 |
+
def _maybe_upgrade_to_hnsw(self) -> None:
|
| 85 |
+
if len(self._id_map) < FLAT_THRESHOLD: # 256
|
| 86 |
+
return
|
| 87 |
+
|
| 88 |
+
# ... existing code ...
|
| 89 |
+
existing: List[Tuple[int, np.ndarray]] = []
|
| 90 |
+
for fid, node_id in self._id_map.items():
|
| 91 |
+
if node_id in self._vector_cache:
|
| 92 |
+
existing.append((fid, self._vector_cache[node_id]))
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
**PROBLEM:** `_vector_cache` används bara vid HNSW-upgrade, men vid normal flat-index-användning cachas inte vektorer. Vid upgrade saknas data.
|
| 96 |
+
|
| 97 |
+
**Fix:** Alltid cacha vektorer:
|
| 98 |
+
```python
|
| 99 |
+
def add(self, node_id: str, hdv_data: np.ndarray) -> None:
|
| 100 |
+
# ... existing code ...
|
| 101 |
+
self._vector_cache[node_id] = hdv_data.copy() # ALLTID, inte bara HNSW
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
### 2. **Qdrant Vector Unpacking Mismatch** 🔴 HIGH
|
| 107 |
+
**Fil:** `tier_manager.py:387-392` + `qdrant_store.py`
|
| 108 |
+
|
| 109 |
+
```python
|
| 110 |
+
# Vid save till Qdrant (tier_manager.py):
|
| 111 |
+
bits = np.unpackbits(node.hdv.data)
|
| 112 |
+
vector = bits.astype(float).tolist() # 16,384 floats
|
| 113 |
+
|
| 114 |
+
# Vid search från Qdrant (qdrant_store.py):
|
| 115 |
+
arr = np.array(vec_data) > 0.5
|
| 116 |
+
packed = np.packbits(arr.astype(np.uint8))
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
**PROBLEM:** Qdrant använder COSINE distance för HOT och MANHATTAN för WARM, men BinaryHDV använder HAMMING distance. Similarity scores kan vara inkompatibla.
|
| 120 |
+
|
| 121 |
+
**Konfiguration (`config.yaml`):**
|
| 122 |
+
```yaml
|
| 123 |
+
qdrant:
|
| 124 |
+
collection_hot:
|
| 125 |
+
distance: COSINE # ← Fel för binary vectors!
|
| 126 |
+
collection_warm:
|
| 127 |
+
distance: MANHATTAN # ← Också suboptimalt
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
**Fix:** Använd `Distance.DOT` för binary vectors med normaliserad similarity.
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
### 3. **FAISS Binary HNSW — Inte Fullt Implementerat** 🔴 HIGH
|
| 135 |
+
**Fil:** `hnsw_index.py:59-66`
|
| 136 |
+
|
| 137 |
+
```python
|
| 138 |
+
def _build_hnsw_index(self, existing_nodes: Optional[List[Tuple[int, np.ndarray]]] = None) -> None:
|
| 139 |
+
hnsw = faiss.IndexBinaryHNSW(self.dimension, self.m)
|
| 140 |
+
hnsw.hnsw.efConstruction = self.ef_construction
|
| 141 |
+
hnsw.hnsw.efSearch = self.ef_search
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
**PROBLEM:** `IndexBinaryHNSW` saknar `IndexIDMap`-stöd. Koden försöker hantera detta med `_position_map`, men detta är skört vid:
|
| 145 |
+
- Delete + re-add
|
| 146 |
+
- Concurrent access
|
| 147 |
+
- Index rebuilds
|
| 148 |
+
|
| 149 |
+
**Risk:** Position mapping kan bli desynkroniserad → query returnerar fel IDs eller inga resultat.
|
| 150 |
+
|
| 151 |
+
---
|
| 152 |
+
|
| 153 |
+
## ⚠️ HÖGA RISKER (High Priority)
|
| 154 |
+
|
| 155 |
+
### 4. **Demotion Race Condition** 🟠
|
| 156 |
+
**Fil:** `tier_manager.py:175-220`
|
| 157 |
+
|
| 158 |
+
```python
|
| 159 |
+
async def get_memory(self, node_id: str) -> Optional[MemoryNode]:
|
| 160 |
+
demote_candidate = None
|
| 161 |
+
result_node = None
|
| 162 |
+
|
| 163 |
+
async with self.lock:
|
| 164 |
+
if node_id in self.hot:
|
| 165 |
+
node = self.hot[node_id]
|
| 166 |
+
node.access()
|
| 167 |
+
|
| 168 |
+
if self._should_demote(node):
|
| 169 |
+
node.tier = "warm" # Markerar som warm
|
| 170 |
+
demote_candidate = node
|
| 171 |
+
|
| 172 |
+
result_node = node
|
| 173 |
+
|
| 174 |
+
# I/O OUTSIDE LOCK — gap där annan tråd kan försöka access
|
| 175 |
+
if demote_candidate:
|
| 176 |
+
await self._save_to_warm(demote_candidate) # Kan misslyckas
|
| 177 |
+
|
| 178 |
+
async with self.lock:
|
| 179 |
+
if demote_candidate.id in self.hot:
|
| 180 |
+
del self.hot[demote_candidate.id] # Nu borta
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
**PROBLEM:** Tidsfönster mellan "mark as warm" och "delete from hot" där:
|
| 184 |
+
- `get_memory()` kan returnera samma node twice
|
| 185 |
+
- Query kan missa noden under övergången
|
| 186 |
+
|
| 187 |
+
---
|
| 188 |
+
|
| 189 |
+
### 5. **Subconscious AI — Infinite Loop Risk** 🟠
|
| 190 |
+
**Fil:** `subconscious_ai.py` (inte granskad fullt, men config visar risk)
|
| 191 |
+
|
| 192 |
+
```yaml
|
| 193 |
+
subconscious_ai:
|
| 194 |
+
enabled: false # BETA - bra att den är avstängd
|
| 195 |
+
pulse_interval_seconds: 120
|
| 196 |
+
rate_limit_per_hour: 50
|
| 197 |
+
max_memories_per_cycle: 10
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
**Risk:** Om `micro_self_improvement_enabled: true` kan systemet gå in i självförbättringsspiraler.
|
| 201 |
+
|
| 202 |
+
---
|
| 203 |
+
|
| 204 |
+
### 6. **Memory Leak i _vector_cache** 🟠
|
| 205 |
+
**Fil:** `hnsw_index.py:107`
|
| 206 |
+
|
| 207 |
+
```python
|
| 208 |
+
@property
|
| 209 |
+
def _vector_cache(self) -> Dict[str, np.ndarray]:
|
| 210 |
+
if not hasattr(self, "_vcache"):
|
| 211 |
+
object.__setattr__(self, "_vcache", {})
|
| 212 |
+
return self._vcache
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
**PROBLEM:** `_vector_cache` växer obegränsat. Ingen cleanup vid delete eller consolidation.
|
| 216 |
+
|
| 217 |
+
**Fix:**
|
| 218 |
+
```python
|
| 219 |
+
def remove(self, node_id: str) -> None:
|
| 220 |
+
# ... existing code ...
|
| 221 |
+
self._vector_cache.pop(node_id, None) # Finns redan, men verifiera
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## 📊 PRESTANDA & SKALBARHET
|
| 227 |
+
|
| 228 |
+
### 7. **O(N) Linear Search Fallback** 🟡
|
| 229 |
+
**Fil:** `tier_manager.py:902+`
|
| 230 |
+
|
| 231 |
+
När HNSW inte är tillgängligt (FAISS ej installerat), faller systemet tillbaka till:
|
| 232 |
+
|
| 233 |
+
```python
|
| 234 |
+
def _linear_search_hot(self, query_vec: BinaryHDV, top_k: int) -> List[Tuple[str, float]]:
|
| 235 |
+
# Inte visad i filen, men nämnd som fallback
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
**Prestandaimpakt:**
|
| 239 |
+
- 2,000 memories (HOT max): ~4ms
|
| 240 |
+
- 10,000 memories: ~20ms
|
| 241 |
+
- 100,000 memories: ~200ms ← Ej acceptabelt för real-time query
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
### 8. **Qdrant Batch Operations Saknas** 🟡
|
| 246 |
+
**Fil:** `qdrant_store.py`
|
| 247 |
+
|
| 248 |
+
```python
|
| 249 |
+
async def upsert(self, collection: str, points: List[models.PointStruct]):
|
| 250 |
+
await qdrant_breaker.call(
|
| 251 |
+
self.client.upsert, collection_name=collection, points=points
|
| 252 |
+
)
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
**PROBLEM:** Consolidation (`consolidate_warm_to_cold`) gör en-at-a-time deletes istället för batch:
|
| 256 |
+
|
| 257 |
+
```python
|
| 258 |
+
# tier_manager.py:750
|
| 259 |
+
if ids_to_delete:
|
| 260 |
+
await self.qdrant.delete(collection, ids_to_delete) # Bra!
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
Men `list_warm()` och `search()` saknar pagination-optimering.
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## 🏗️ ARKITEKTUR & DESIGN
|
| 268 |
+
|
| 269 |
+
### 9. **Dependency Injection — Halvvägs** 🟡
|
| 270 |
+
**Status:** Singeltons borttagna, men inte fullt DI
|
| 271 |
+
|
| 272 |
+
**Gott:**
|
| 273 |
+
- `HAIMEngine(config=..., tier_manager=...)` stöder injection
|
| 274 |
+
- `Container` pattern i `container.py`
|
| 275 |
+
|
| 276 |
+
**Dåligt:**
|
| 277 |
+
- `get_config()` är fortfarande global
|
| 278 |
+
- `BinaryHDV.random()` använder global `np.random`
|
| 279 |
+
|
| 280 |
+
**Rekommendation:**
|
| 281 |
+
```python
|
| 282 |
+
class BinaryHDV:
|
| 283 |
+
def __init__(self, data: np.ndarray, dimension: int, rng: Optional[np.random.Generator] = None):
|
| 284 |
+
self._rng = rng or np.random.default_rng()
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
---
|
| 288 |
+
|
| 289 |
+
### 10. **Error Handling — Inkonsekvent** 🟡
|
| 290 |
+
**Filer:** Spridda
|
| 291 |
+
|
| 292 |
+
Vissa funktioner returnerar `None`:
|
| 293 |
+
```python
|
| 294 |
+
async def get_memory(self, node_id: str) -> Optional[MemoryNode]:
|
| 295 |
+
# Returnerar None om ej hittad
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
Andra kastar exceptions:
|
| 299 |
+
```python
|
| 300 |
+
async def delete_memory(self, node_id: str):
|
| 301 |
+
if not node:
|
| 302 |
+
raise MemoryNotFoundError(node_id)
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
**Rekommendation:** Konsekvent mönster:
|
| 306 |
+
- `get_*` → return `Optional[T]` (None = not found)
|
| 307 |
+
- `*_or_raise` → raise exception
|
| 308 |
+
- `delete_*` → return `bool` (deleted or not)
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## 🔒 SÄKERHET & ROBUSTHET
|
| 313 |
+
|
| 314 |
+
### 11. **API Key i Env Var — Bra** ✅
|
| 315 |
+
**Fil:** `api/main.py:81`
|
| 316 |
+
|
| 317 |
+
```python
|
| 318 |
+
security = config.security if config else None
|
| 319 |
+
expected_key = (security.api_key if security else None) or os.getenv("HAIM_API_KEY", "")
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
**Gott:** API key måste sättas explicit, fallback till env var.
|
| 323 |
+
|
| 324 |
+
---
|
| 325 |
+
|
| 326 |
+
### 12. **Rate Limiting — Implementerat** ✅
|
| 327 |
+
**Fil:** `api/middleware.py`
|
| 328 |
+
|
| 329 |
+
```python
|
| 330 |
+
class QueryRateLimiter(RateLimiter):
|
| 331 |
+
def __init__(self):
|
| 332 |
+
super().__init__(requests=500, window_seconds=60) # 500/min
|
| 333 |
+
```
|
| 334 |
+
|
| 335 |
+
**Gott:** Separate limits för store/query/concept/analogy.
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
### 13. **Input Validation — Svag** 🟡
|
| 340 |
+
**Fil:** `api/models.py`
|
| 341 |
+
|
| 342 |
+
```python
|
| 343 |
+
class StoreRequest(BaseModel):
|
| 344 |
+
content: str = Field(..., min_length=1, max_length=100000)
|
| 345 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
**PROBLEM:** Ingen validering av `metadata`-innehåll. Kan innehålla:
|
| 349 |
+
- Ogiltiga UTF-8 characters
|
| 350 |
+
- Recursive structures
|
| 351 |
+
- Sensitive data leaks
|
| 352 |
+
|
| 353 |
+
**Fix:**
|
| 354 |
+
```python
|
| 355 |
+
from pydantic import field_validator
|
| 356 |
+
|
| 357 |
+
class StoreRequest(BaseModel):
|
| 358 |
+
@field_validator('metadata')
|
| 359 |
+
@classmethod
|
| 360 |
+
def validate_metadata(cls, v):
|
| 361 |
+
if v and len(str(v)) > 10000: # Max 10KB metadata
|
| 362 |
+
raise ValueError('Metadata too large')
|
| 363 |
+
return v
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
---
|
| 367 |
+
|
| 368 |
+
## 📝 KODKVALITET
|
| 369 |
+
|
| 370 |
+
### 14. **Test Coverage — 39 Passing** ✅
|
| 371 |
+
**Fil:** `test_regression_output.txt`
|
| 372 |
+
|
| 373 |
+
```
|
| 374 |
+
39 passed, 5 warnings in 3.47s
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
**Gott:** Alla tester passerar. Men:
|
| 378 |
+
- Inga tester för HNSW upgrade path
|
| 379 |
+
- Inga tester för concurrent access
|
| 380 |
+
- Inga tester för Qdrant integration (kräver live Qdrant)
|
| 381 |
+
|
| 382 |
+
---
|
| 383 |
+
|
| 384 |
+
### 15. **Documentation — Komplett** ✅
|
| 385 |
+
**Filer:** `README.md` (43KB), `CHANGELOG.md`, inline docs
|
| 386 |
+
|
| 387 |
+
**Gott:** Dokumentation är omfattande och uppdaterad.
|
| 388 |
+
|
| 389 |
+
---
|
| 390 |
+
|
| 391 |
+
## 🎯 PRIORITERAD FIX-LISTA
|
| 392 |
+
|
| 393 |
+
| Prioritet | Problem | Fil | Estimerad tid |
|
| 394 |
+
|-----------|---------|-----|---------------|
|
| 395 |
+
| 🔴 P0 | Position mapping bug | `hnsw_index.py` | 2h |
|
| 396 |
+
| 🔴 P0 | Token normalization | `binary_hdv.py` | 30min |
|
| 397 |
+
| 🔴 P0 | Vector cache vid upgrade | `hnsw_index.py` | 1h |
|
| 398 |
+
| 🟠 P1 | Qdrant distance mismatch | `config.yaml` + `qdrant_store.py` | 2h |
|
| 399 |
+
| 🟠 P1 | Demotion race condition | `tier_manager.py` | 3h |
|
| 400 |
+
| 🟡 P2 | Linear search fallback | `tier_manager.py` | 4h |
|
| 401 |
+
| 🟡 P2 | Memory leak _vector_cache | `hnsw_index.py` | 30min |
|
| 402 |
+
|
| 403 |
+
---
|
| 404 |
+
|
| 405 |
+
## 🔧 REKOMMENDERAD ACTION PLAN
|
| 406 |
+
|
| 407 |
+
### Fas 1: Query Fix (Dag 1)
|
| 408 |
+
1. **Fixa `_position_to_node_id()`** — Använd insättningsordning istället för sorted IDs
|
| 409 |
+
2. **Fixa `TextEncoder.encode()`** — Konsekvent tokenisering med regex
|
| 410 |
+
3. **Alltid cacha vektorer** — Ta bort conditional `_vector_cache`
|
| 411 |
+
|
| 412 |
+
### Fas 2: Qdrant Alignment (Dag 2)
|
| 413 |
+
1. **Ändra distance metric** — `Distance.DOT` för binary vectors
|
| 414 |
+
2. **Verifiera vector unpacking** — Säkerställ 16,384 → 2,048 byte mapping
|
| 415 |
+
|
| 416 |
+
### Fas 3: Hardening (Dag 3)
|
| 417 |
+
1. **Lägg till HNSW upgrade tester**
|
| 418 |
+
2. **Fixa demotion race condition**
|
| 419 |
+
3. **Input validation för metadata**
|
| 420 |
+
|
| 421 |
+
---
|
| 422 |
+
|
| 423 |
+
## 📋 SUMMARY
|
| 424 |
+
|
| 425 |
+
**Total kod:** ~25,000 LOC (src/)
|
| 426 |
+
**Tester:** 39 passing
|
| 427 |
+
**Kritiska buggar:** 3
|
| 428 |
+
**Höga risker:** 4
|
| 429 |
+
**Medel risker:** 5
|
| 430 |
+
|
| 431 |
+
**Verdict:** v4.5.0-beta är **inte production-ready**. Query-flödet har 3 kritiska buggar som förhindrar korrekt retrieval. Arkitekturen är solid, men implementationen av HNSW/index-mapping behöver omskrivas.
|
| 432 |
+
|
| 433 |
+
**Rekommendation:**
|
| 434 |
+
1. Omedelbart fixa P0-issues (4-5 timmars arbete)
|
| 435 |
+
2. Kör regression tests
|
| 436 |
+
3. Deploy till staging för validering
|
| 437 |
+
4. Sätt Opus 4.6 + Gemini 3.1 på Fas 1-3
|
| 438 |
+
|
| 439 |
+
---
|
| 440 |
+
|
| 441 |
+
*Review genererad av Omega (GLM-5) för Robin Granberg*
|
| 442 |
+
*Senast uppdaterad: 2026-02-20 07:45 CET*
|
ROADMAP.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore Roadmap
|
| 2 |
+
|
| 3 |
+
**Open Source Infrastructure for Persistent Cognitive Memory**
|
| 4 |
+
|
| 5 |
+
Version: 4.5.0-beta | Updated: 2026-02-20
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Vision
|
| 10 |
+
|
| 11 |
+
MnemoCore provides the foundational memory layer for cognitive AI systems —
|
| 12 |
+
a production-ready, self-hosted alternative to cloud-dependent memory solutions.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Current Status (v4.5.0-beta)
|
| 17 |
+
|
| 18 |
+
| Component | Status |
|
| 19 |
+
|-----------|--------|
|
| 20 |
+
| Binary HDV Engine | ✅ Stable |
|
| 21 |
+
| Tiered Storage (HOT/WARM/COLD) | ✅ Functional |
|
| 22 |
+
| HNSW Index | ✅ Working |
|
| 23 |
+
| Query/Store API | ✅ Operational |
|
| 24 |
+
| Qdrant Integration | ✅ Available |
|
| 25 |
+
| MCP Server | 🟡 Beta |
|
| 26 |
+
| PyPI Distribution | 🟡 Pending |
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Phase 5: Production Hardening
|
| 31 |
+
|
| 32 |
+
**Goal:** Battle-tested, enterprise-ready release
|
| 33 |
+
|
| 34 |
+
### 5.1 Stability & Testing
|
| 35 |
+
- [ ] Increase test coverage to 80%+
|
| 36 |
+
- [ ] Add integration tests for Qdrant backend
|
| 37 |
+
- [ ] Stress test with 100k+ memories
|
| 38 |
+
- [ ] Add chaos engineering tests (network failures, disk full)
|
| 39 |
+
|
| 40 |
+
### 5.2 Performance Optimization
|
| 41 |
+
- [ ] Benchmark query latency at scale
|
| 42 |
+
- [ ] Optimize HNSW index rebuild time
|
| 43 |
+
- [ ] Add batch operation endpoints
|
| 44 |
+
- [ ] Profile and reduce memory footprint
|
| 45 |
+
|
| 46 |
+
### 5.3 Developer Experience
|
| 47 |
+
- [ ] Complete API documentation (OpenAPI spec)
|
| 48 |
+
- [ ] Add usage examples for common patterns
|
| 49 |
+
- [ ] Create quickstart guide
|
| 50 |
+
- [ ] Add Jupyter notebook tutorials
|
| 51 |
+
|
| 52 |
+
### 5.4 Operations
|
| 53 |
+
- [ ] Docker Compose production config
|
| 54 |
+
- [ ] Kubernetes Helm chart
|
| 55 |
+
- [ ] Prometheus metrics endpoint
|
| 56 |
+
- [ ] Health check hardening
|
| 57 |
+
|
| 58 |
+
**ETA:** 2-3 weeks
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## Phase 6: Feature Expansion
|
| 63 |
+
|
| 64 |
+
**Goal:** More cognitive capabilities
|
| 65 |
+
|
| 66 |
+
### 6.1 Advanced Retrieval
|
| 67 |
+
- [ ] Temporal queries ("memories from last week")
|
| 68 |
+
- [ ] Multi-hop associative recall
|
| 69 |
+
- [ ] Contextual ranking (personalized relevance)
|
| 70 |
+
- [ ] Negation queries ("NOT about project X")
|
| 71 |
+
|
| 72 |
+
### 6.2 Memory Enrichment
|
| 73 |
+
- [ ] Auto-tagging via LLM
|
| 74 |
+
- [ ] Entity extraction (names, dates, concepts)
|
| 75 |
+
- [ ] Sentiment scoring
|
| 76 |
+
- [ ] Importance classification
|
| 77 |
+
|
| 78 |
+
### 6.3 Multi-Modal Support
|
| 79 |
+
- [ ] Image embedding storage
|
| 80 |
+
- [ ] Audio transcript indexing
|
| 81 |
+
- [ ] Document chunk management
|
| 82 |
+
|
| 83 |
+
**ETA:** 4-6 weeks
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
## Phase 7: Ecosystem
|
| 88 |
+
|
| 89 |
+
**Goal:** Easy integration with existing AI stacks
|
| 90 |
+
|
| 91 |
+
### 7.1 Integrations
|
| 92 |
+
- [ ] LangChain memory adapter
|
| 93 |
+
- [ ] LlamaIndex integration
|
| 94 |
+
- [ ] OpenAI Assistants API compatible
|
| 95 |
+
- [ ] Claude MCP protocol
|
| 96 |
+
|
| 97 |
+
### 7.2 SDKs
|
| 98 |
+
- [ ] Python SDK (official)
|
| 99 |
+
- [ ] TypeScript/JavaScript SDK
|
| 100 |
+
- [ ] Go SDK
|
| 101 |
+
- [ ] Rust SDK
|
| 102 |
+
|
| 103 |
+
### 7.3 Community
|
| 104 |
+
- [ ] Discord/Slack community
|
| 105 |
+
- [ ] Contributing guide
|
| 106 |
+
- [ ] Feature request process
|
| 107 |
+
- [ ] Regular release cadence
|
| 108 |
+
|
| 109 |
+
**ETA:** 8-12 weeks
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## Long-Term Vision (Phase 8+)
|
| 114 |
+
|
| 115 |
+
### Research Directions
|
| 116 |
+
- [ ] Hierarchical memory (episodic → semantic → procedural)
|
| 117 |
+
- [ ] Forgetting curves with spaced repetition
|
| 118 |
+
- [ ] Dream consolidation during idle cycles
|
| 119 |
+
- [ ] Meta-learning from usage patterns
|
| 120 |
+
|
| 121 |
+
### Platform
|
| 122 |
+
- [ ] Managed cloud offering (optional)
|
| 123 |
+
- [ ] Multi-tenant support
|
| 124 |
+
- [ ] Federation across nodes
|
| 125 |
+
- [ ] Privacy-preserving memory sharing
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## Release Schedule
|
| 130 |
+
|
| 131 |
+
| Version | Target | Focus |
|
| 132 |
+
|---------|--------|-------|
|
| 133 |
+
| v4.5.0 | Current | Beta stabilization |
|
| 134 |
+
| v5.0.0 | +2 weeks | Production ready |
|
| 135 |
+
| v5.1.0 | +4 weeks | Performance + DX |
|
| 136 |
+
| v6.0.0 | +6 weeks | Feature expansion |
|
| 137 |
+
| v7.0.0 | +10 weeks | Ecosystem |
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## Contributing
|
| 142 |
+
|
| 143 |
+
MnemoCore is open source under MIT license.
|
| 144 |
+
|
| 145 |
+
- **GitHub:** https://github.com/RobinALG87/MnemoCore-Infrastructure-for-Persistent-Cognitive-Memory
|
| 146 |
+
- **PyPI:** `pip install mnemocore`
|
| 147 |
+
- **Issues:** Use GitHub Issues for bugs and feature requests
|
| 148 |
+
- **PRs:** Welcome! See CONTRIBUTING.md
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
*Roadmap maintained by Robin Granberg & Omega*
|
config.yaml
CHANGED
|
@@ -13,7 +13,7 @@ haim:
|
|
| 13 |
# Memory tier thresholds
|
| 14 |
tiers:
|
| 15 |
hot:
|
| 16 |
-
max_memories:
|
| 17 |
ltp_threshold_min: 0.7
|
| 18 |
eviction_policy: "lru"
|
| 19 |
|
|
@@ -135,12 +135,12 @@ haim:
|
|
| 135 |
# =========================================================================
|
| 136 |
subconscious_ai:
|
| 137 |
# BETA FEATURE - Must be explicitly enabled
|
| 138 |
-
enabled:
|
| 139 |
beta_mode: true
|
| 140 |
|
| 141 |
# Model configuration
|
| 142 |
model_provider: "ollama" # ollama | lm_studio | openai_api | anthropic_api
|
| 143 |
-
model_name: "phi3.5:
|
| 144 |
model_url: "http://localhost:11434"
|
| 145 |
# api_key: null # For API providers
|
| 146 |
# api_base_url: null
|
|
@@ -152,16 +152,16 @@ haim:
|
|
| 152 |
|
| 153 |
# Resource management
|
| 154 |
max_cpu_percent: 30.0
|
| 155 |
-
cycle_timeout_seconds:
|
| 156 |
rate_limit_per_hour: 50
|
| 157 |
|
| 158 |
# Operations
|
| 159 |
memory_sorting_enabled: true
|
| 160 |
enhanced_dreaming_enabled: true
|
| 161 |
-
micro_self_improvement_enabled:
|
| 162 |
|
| 163 |
# Safety
|
| 164 |
-
dry_run:
|
| 165 |
log_all_decisions: true
|
| 166 |
audit_trail_path: "./data/subconscious_audit.jsonl"
|
| 167 |
max_memories_per_cycle: 10
|
|
|
|
| 13 |
# Memory tier thresholds
|
| 14 |
tiers:
|
| 15 |
hot:
|
| 16 |
+
max_memories: 3000
|
| 17 |
ltp_threshold_min: 0.7
|
| 18 |
eviction_policy: "lru"
|
| 19 |
|
|
|
|
| 135 |
# =========================================================================
|
| 136 |
subconscious_ai:
|
| 137 |
# BETA FEATURE - Must be explicitly enabled
|
| 138 |
+
enabled: true
|
| 139 |
beta_mode: true
|
| 140 |
|
| 141 |
# Model configuration
|
| 142 |
model_provider: "ollama" # ollama | lm_studio | openai_api | anthropic_api
|
| 143 |
+
model_name: "phi3.5:latest"
|
| 144 |
model_url: "http://localhost:11434"
|
| 145 |
# api_key: null # For API providers
|
| 146 |
# api_base_url: null
|
|
|
|
| 152 |
|
| 153 |
# Resource management
|
| 154 |
max_cpu_percent: 30.0
|
| 155 |
+
cycle_timeout_seconds: 120
|
| 156 |
rate_limit_per_hour: 50
|
| 157 |
|
| 158 |
# Operations
|
| 159 |
memory_sorting_enabled: true
|
| 160 |
enhanced_dreaming_enabled: true
|
| 161 |
+
micro_self_improvement_enabled: true # Initially disabled
|
| 162 |
|
| 163 |
# Safety
|
| 164 |
+
dry_run: false
|
| 165 |
log_all_decisions: true
|
| 166 |
audit_trail_path: "./data/subconscious_audit.jsonl"
|
| 167 |
max_memories_per_cycle: 10
|
data/subconscious_audit.jsonl
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
docs/PATTERN_LEARNER_SPEC.md
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore Pattern Learner — Specification Draft
|
| 2 |
+
|
| 3 |
+
**Version:** 0.1-draft
|
| 4 |
+
**Date:** 2026-02-20
|
| 5 |
+
**Status:** Draft for Review
|
| 6 |
+
**Author:** Omega (GLM-5) for Robin Granberg
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## Executive Summary
|
| 11 |
+
|
| 12 |
+
Pattern Learner är en MnemoCore-modul som lär sig från användarinteraktioner **utan att lagra persondata**. Den extraherar statistiska mönster, topic clustering och kvalitetsmetrics som kan användas för att förbättra chatbot-performance över tid.
|
| 13 |
+
|
| 14 |
+
**Key principle:** Learn patterns, forget people.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## Problem Statement
|
| 19 |
+
|
| 20 |
+
### Healthcare Chatbot Challenges
|
| 21 |
+
|
| 22 |
+
| Utmaning | Konsekvens |
|
| 23 |
+
|----------|------------|
|
| 24 |
+
| GDPR/HIPAA compliance | Kan inte lagra konversationer |
|
| 25 |
+
| Multitenancy | Data får inte läcka mellan kliniker |
|
| 26 |
+
| Quality improvement | Behöver veta vad som fungerar |
|
| 27 |
+
| Knowledge gaps | Behöver identifiera vad som saknas i docs |
|
| 28 |
+
|
| 29 |
+
### Current Solutions (Limitations)
|
| 30 |
+
|
| 31 |
+
- **Stateless RAG:** Ingen inlärning alls
|
| 32 |
+
- **Full memory:** GDPR-risk, sekretessproblem
|
| 33 |
+
- **Manual analytics:** Tidskrävande, inte real-time
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Solution: Pattern Learner
|
| 38 |
+
|
| 39 |
+
### Core Concept
|
| 40 |
+
|
| 41 |
+
```
|
| 42 |
+
User Query ──► Anonymize ──► Extract Pattern ──► Aggregate
|
| 43 |
+
│
|
| 44 |
+
└── PII removed before storage
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
**What IS stored:**
|
| 48 |
+
- Topic clusters (anonymized)
|
| 49 |
+
- Query frequency distributions
|
| 50 |
+
- Response quality aggregates
|
| 51 |
+
- Knowledge gap indicators
|
| 52 |
+
|
| 53 |
+
**What is NOT stored:**
|
| 54 |
+
- User identities
|
| 55 |
+
- Clinic associations
|
| 56 |
+
- Patient data
|
| 57 |
+
- Raw conversations
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## Architecture
|
| 62 |
+
|
| 63 |
+
### High-Level Design
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 67 |
+
│ Pattern Learner Module │
|
| 68 |
+
├─────────────────────────────────────────────────────────────┤
|
| 69 |
+
│ │
|
| 70 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
| 71 |
+
│ │ Anonymizer │───►│Topic Extractor│───►│ Aggregator │ │
|
| 72 |
+
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
| 73 |
+
│ │ │ │ │
|
| 74 |
+
│ │ ▼ ▼ │
|
| 75 |
+
│ │ ┌──────────────┐ ┌──────────────┐ │
|
| 76 |
+
│ │ │Topic Embedder│ │ Stats Store │ │
|
| 77 |
+
│ │ │ (MnemoCore) │ │ (Encrypted) │ │
|
| 78 |
+
│ │ └──────────────┘ └──────────────┘ │
|
| 79 |
+
│ │ │ │ │
|
| 80 |
+
│ └───────────────────┴────────────────────┘ │
|
| 81 |
+
│ │ │
|
| 82 |
+
│ ▼ │
|
| 83 |
+
│ ┌──────────────┐ │
|
| 84 |
+
│ │ Insights API│ │
|
| 85 |
+
│ └──────────────┘ │
|
| 86 |
+
│ │
|
| 87 |
+
└─────────────────────────────────────────────────────────────┘
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
### Components
|
| 91 |
+
|
| 92 |
+
#### 1. Anonymizer
|
| 93 |
+
|
| 94 |
+
**Purpose:** Remove all PII before processing
|
| 95 |
+
|
| 96 |
+
**Methods:**
|
| 97 |
+
- Named Entity Recognition (NER) for person names
|
| 98 |
+
- Pattern matching for phone numbers, addresses
|
| 99 |
+
- Clinic/organization detection
|
| 100 |
+
- Session ID hashing
|
| 101 |
+
|
| 102 |
+
```python
|
| 103 |
+
class Anonymizer:
|
| 104 |
+
"""Remove PII from queries before pattern extraction"""
|
| 105 |
+
|
| 106 |
+
def __init__(self):
|
| 107 |
+
self.ner_model = load_ner_model("sv") # Swedish
|
| 108 |
+
self.patterns = {
|
| 109 |
+
"phone": r"\+?\d{1,3}[\s-]?\d{2,4}[\s-]?\d{2,4}[\s-]?\d{2,4}",
|
| 110 |
+
"email": r"[\w\.-]+@[\w\.-]+\.\w+",
|
| 111 |
+
"personal_number": r"\d{6,8}[-\s]?\d{4}",
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
def anonymize(self, text: str) -> str:
|
| 115 |
+
"""Remove all PII from text"""
|
| 116 |
+
|
| 117 |
+
# 1. NER for names
|
| 118 |
+
entities = self.ner_model.extract(text)
|
| 119 |
+
for entity in entities:
|
| 120 |
+
if entity.type in ["PER", "ORG"]:
|
| 121 |
+
text = text.replace(entity.text, "[ANON]")
|
| 122 |
+
|
| 123 |
+
# 2. Pattern matching
|
| 124 |
+
for pattern_type, pattern in self.patterns.items():
|
| 125 |
+
text = re.sub(pattern, f"[{pattern_type.upper()}]", text)
|
| 126 |
+
|
| 127 |
+
# 3. Remove clinic names (configurable blacklist)
|
| 128 |
+
for clinic_name in self.clinic_blacklist:
|
| 129 |
+
text = text.replace(clinic_name, "[KLINIK]")
|
| 130 |
+
|
| 131 |
+
return text
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
#### 2. Topic Extractor
|
| 137 |
+
|
| 138 |
+
**Purpose:** Extract semantic topics from anonymized queries
|
| 139 |
+
|
| 140 |
+
**Methods:**
|
| 141 |
+
- Keyword extraction (TF-IDF)
|
| 142 |
+
- Topic modeling (LDA, BERTopic)
|
| 143 |
+
- Embedding-based clustering
|
| 144 |
+
|
| 145 |
+
```python
|
| 146 |
+
class TopicExtractor:
|
| 147 |
+
"""Extract topics from anonymized queries"""
|
| 148 |
+
|
| 149 |
+
def __init__(self, mnemocore_engine):
|
| 150 |
+
self.engine = mnemocore_engine
|
| 151 |
+
self.topic_threshold = 0.5
|
| 152 |
+
|
| 153 |
+
async def extract_topics(self, query: str) -> List[str]:
|
| 154 |
+
"""Extract topics from anonymized query"""
|
| 155 |
+
|
| 156 |
+
# 1. Get keywords
|
| 157 |
+
keywords = self._extract_keywords(query)
|
| 158 |
+
|
| 159 |
+
# 2. Find similar topics in MnemoCore
|
| 160 |
+
similar = await self.engine.query(query, top_k=5)
|
| 161 |
+
|
| 162 |
+
# 3. Cluster into topics
|
| 163 |
+
topics = []
|
| 164 |
+
for memory_id, similarity in similar:
|
| 165 |
+
if similarity > self.topic_threshold:
|
| 166 |
+
memory = await self.engine.get_memory(memory_id)
|
| 167 |
+
topics.extend(memory.metadata.get("topics", []))
|
| 168 |
+
|
| 169 |
+
# 4. Deduplicate
|
| 170 |
+
return list(set(topics + keywords))
|
| 171 |
+
|
| 172 |
+
def _extract_keywords(self, text: str) -> List[str]:
|
| 173 |
+
"""Extract keywords using TF-IDF"""
|
| 174 |
+
# Simple implementation
|
| 175 |
+
words = text.lower().split()
|
| 176 |
+
return [w for w in words if len(w) > 3 and w not in STOPWORDS_SV]
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
#### 3. Aggregator
|
| 182 |
+
|
| 183 |
+
**Purpose:** Store statistical patterns without PII
|
| 184 |
+
|
| 185 |
+
**Data structures:**
|
| 186 |
+
|
| 187 |
+
```python
|
| 188 |
+
@dataclass
|
| 189 |
+
class TopicStats:
|
| 190 |
+
"""Statistics for a topic"""
|
| 191 |
+
topic: str
|
| 192 |
+
count: int = 0
|
| 193 |
+
first_seen: datetime = None
|
| 194 |
+
last_seen: datetime = None
|
| 195 |
+
trend: float = 0.0 # Recent increase/decrease
|
| 196 |
+
|
| 197 |
+
@dataclass
|
| 198 |
+
class ResponseQuality:
|
| 199 |
+
"""Aggregated response quality (no individual ratings)"""
|
| 200 |
+
response_signature: str # Hash of response template
|
| 201 |
+
avg_rating: float = 0.5
|
| 202 |
+
sample_count: int = 0
|
| 203 |
+
last_updated: datetime = None
|
| 204 |
+
|
| 205 |
+
@dataclass
|
| 206 |
+
class KnowledgeGap:
|
| 207 |
+
"""Topics with no good answers"""
|
| 208 |
+
topic: str
|
| 209 |
+
query_count: int = 0
|
| 210 |
+
failure_rate: float = 1.0 # % of queries that got "I don't know"
|
| 211 |
+
suggested_action: str = "" # "add documentation", "improve answer"
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
**Storage:**
|
| 215 |
+
|
| 216 |
+
```python
|
| 217 |
+
class PatternStore:
|
| 218 |
+
"""Store patterns (encrypted, no PII)"""
|
| 219 |
+
|
| 220 |
+
def __init__(self, encryption_key: bytes):
|
| 221 |
+
self.key = encryption_key
|
| 222 |
+
self.topics: Dict[str, TopicStats] = {}
|
| 223 |
+
self.qualities: Dict[str, ResponseQuality] = {}
|
| 224 |
+
self.gaps: Dict[str, KnowledgeGap] = {}
|
| 225 |
+
|
| 226 |
+
def record_topic(self, topic: str):
|
| 227 |
+
"""Record that a topic was queried"""
|
| 228 |
+
if topic not in self.topics:
|
| 229 |
+
self.topics[topic] = TopicStats(
|
| 230 |
+
topic=topic,
|
| 231 |
+
first_seen=datetime.utcnow()
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
stats = self.topics[topic]
|
| 235 |
+
stats.count += 1
|
| 236 |
+
stats.last_seen = datetime.utcnow()
|
| 237 |
+
|
| 238 |
+
def record_quality(self, response_sig: str, rating: int):
|
| 239 |
+
"""Record response quality (aggregated)"""
|
| 240 |
+
if response_sig not in self.qualities:
|
| 241 |
+
self.qualities[response_sig] = ResponseQuality(
|
| 242 |
+
response_signature=response_sig
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
q = self.qualities[response_sig]
|
| 246 |
+
# Exponential moving average
|
| 247 |
+
q.avg_rating = 0.9 * q.avg_rating + 0.1 * (rating / 5.0)
|
| 248 |
+
q.sample_count += 1
|
| 249 |
+
q.last_updated = datetime.utcnow()
|
| 250 |
+
|
| 251 |
+
def record_gap(self, topic: str, had_answer: bool):
|
| 252 |
+
"""Record knowledge gap"""
|
| 253 |
+
if topic not in self.gaps:
|
| 254 |
+
self.gaps[topic] = KnowledgeGap(topic=topic)
|
| 255 |
+
|
| 256 |
+
gap = self.gaps[topic]
|
| 257 |
+
gap.query_count += 1
|
| 258 |
+
if not had_answer:
|
| 259 |
+
gap.failure_rate = (gap.failure_rate * (gap.query_count - 1) + 1) / gap.query_count
|
| 260 |
+
else:
|
| 261 |
+
gap.failure_rate = (gap.failure_rate * (gap.query_count - 1)) / gap.query_count
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
#### 4. Insights API
|
| 267 |
+
|
| 268 |
+
**Purpose:** Provide actionable insights to admins/developers
|
| 269 |
+
|
| 270 |
+
**Endpoints:**
|
| 271 |
+
|
| 272 |
+
```python
|
| 273 |
+
# GET /insights/topics?top_k=10
|
| 274 |
+
{
|
| 275 |
+
"topics": [
|
| 276 |
+
{"topic": "implantat", "count": 1250, "trend": 0.15},
|
| 277 |
+
{"topic": "rotfyllning", "count": 980, "trend": -0.02},
|
| 278 |
+
{"topic": "priser", "count": 850, "trend": 0.30}
|
| 279 |
+
],
|
| 280 |
+
"period": "30d"
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
# GET /insights/gaps
|
| 284 |
+
{
|
| 285 |
+
"knowledge_gaps": [
|
| 286 |
+
{
|
| 287 |
+
"topic": "tandreglering vuxna",
|
| 288 |
+
"query_count": 145,
|
| 289 |
+
"failure_rate": 0.85,
|
| 290 |
+
"suggested_action": "add documentation"
|
| 291 |
+
},
|
| 292 |
+
{
|
| 293 |
+
"topic": "akut tandvård",
|
| 294 |
+
"query_count": 89,
|
| 295 |
+
"failure_rate": 0.72,
|
| 296 |
+
"suggested_action": "improve answer"
|
| 297 |
+
}
|
| 298 |
+
]
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
# GET /insights/quality
|
| 302 |
+
{
|
| 303 |
+
"top_responses": [
|
| 304 |
+
{"signature": "abc123", "avg_rating": 4.8, "sample_count": 520},
|
| 305 |
+
{"signature": "def456", "avg_rating": 4.5, "sample_count": 340}
|
| 306 |
+
],
|
| 307 |
+
"worst_responses": [
|
| 308 |
+
{"signature": "xyz789", "avg_rating": 2.1, "sample_count": 45}
|
| 309 |
+
]
|
| 310 |
+
}
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
---
|
| 314 |
+
|
| 315 |
+
## MnemoCore Integration
|
| 316 |
+
|
| 317 |
+
### Usage Pattern
|
| 318 |
+
|
| 319 |
+
```python
|
| 320 |
+
from mnemocore import HAIMEngine
|
| 321 |
+
from mnemocore.pattern_learner import PatternLearner
|
| 322 |
+
|
| 323 |
+
# Initialize MnemoCore (stores topic embeddings)
|
| 324 |
+
engine = HAIMEngine(dimension=16384)
|
| 325 |
+
await engine.initialize()
|
| 326 |
+
|
| 327 |
+
# Initialize Pattern Learner
|
| 328 |
+
learner = PatternLearner(
|
| 329 |
+
engine=engine,
|
| 330 |
+
encryption_key=get_encryption_key(),
|
| 331 |
+
anonymizer=Anonymizer()
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
# Process a query (automatic learning)
|
| 335 |
+
async def handle_query(user_query: str, tenant_id: str):
|
| 336 |
+
# 1. Anonymize
|
| 337 |
+
anon_query = learner.anonymize(user_query)
|
| 338 |
+
|
| 339 |
+
# 2. Extract patterns (no PII)
|
| 340 |
+
topics = await learner.extract_topics(anon_query)
|
| 341 |
+
|
| 342 |
+
# 3. Record topic usage
|
| 343 |
+
for topic in topics:
|
| 344 |
+
learner.record_topic(topic)
|
| 345 |
+
|
| 346 |
+
# 4. Get answer from RAG
|
| 347 |
+
answer = await rag_lookup(anon_query)
|
| 348 |
+
|
| 349 |
+
# 5. Record if we had an answer
|
| 350 |
+
learner.record_gap(
|
| 351 |
+
topic=topics[0] if topics else "unknown",
|
| 352 |
+
had_answer=(answer is not None)
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
return answer
|
| 356 |
+
|
| 357 |
+
# Get insights (admin only)
|
| 358 |
+
async def get_dashboard():
|
| 359 |
+
top_topics = learner.get_top_topics(10)
|
| 360 |
+
gaps = learner.get_knowledge_gaps()
|
| 361 |
+
quality = learner.get_response_quality()
|
| 362 |
+
|
| 363 |
+
return {
|
| 364 |
+
"popular_topics": top_topics,
|
| 365 |
+
"needs_documentation": gaps,
|
| 366 |
+
"response_performance": quality
|
| 367 |
+
}
|
| 368 |
+
```
|
| 369 |
+
|
| 370 |
+
---
|
| 371 |
+
|
| 372 |
+
## GDPR Compliance
|
| 373 |
+
|
| 374 |
+
### Data Minimization
|
| 375 |
+
|
| 376 |
+
| Data Type | Stored? | Justification |
|
| 377 |
+
|-----------|---------|---------------|
|
| 378 |
+
| Raw queries | ❌ | PII risk |
|
| 379 |
+
| User IDs | ❌ | Not needed |
|
| 380 |
+
| Session IDs | ❌ | Not needed |
|
| 381 |
+
| Clinic IDs | ❌ | Not needed |
|
| 382 |
+
| **Topic labels** | ✅ | Anonymized |
|
| 383 |
+
| **Topic counts** | ✅ | Statistical |
|
| 384 |
+
| **Quality scores** | ✅ | Aggregated |
|
| 385 |
+
| **Gap indicators** | ✅ | Anonymized |
|
| 386 |
+
|
| 387 |
+
### Right to Erasure (GDPR Art 17)
|
| 388 |
+
|
| 389 |
+
Since no PII is stored, right to erasure is **automatically satisfied**.
|
| 390 |
+
|
| 391 |
+
### Data Retention
|
| 392 |
+
|
| 393 |
+
```python
|
| 394 |
+
# Configurable retention
|
| 395 |
+
retention_policy = {
|
| 396 |
+
"topic_stats": "365d", # Keep for 1 year
|
| 397 |
+
"quality_scores": "90d", # Keep for 3 months
|
| 398 |
+
"gap_indicators": "30d", # Refresh monthly
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
# Automatic cleanup
|
| 402 |
+
async def cleanup_old_patterns():
|
| 403 |
+
cutoff = datetime.utcnow() - timedelta(days=retention_policy["topic_stats"])
|
| 404 |
+
for topic, stats in learner.topics.items():
|
| 405 |
+
if stats.last_seen < cutoff:
|
| 406 |
+
del learner.topics[topic]
|
| 407 |
+
```
|
| 408 |
+
|
| 409 |
+
---
|
| 410 |
+
|
| 411 |
+
## Security Considerations
|
| 412 |
+
|
| 413 |
+
### Encryption
|
| 414 |
+
|
| 415 |
+
- All pattern data encrypted at rest (AES-256)
|
| 416 |
+
- Encryption keys managed via HSM or Azure Key Vault
|
| 417 |
+
- Per-tenant encryption optional (for multi-tenant isolation)
|
| 418 |
+
|
| 419 |
+
### Access Control
|
| 420 |
+
|
| 421 |
+
```python
|
| 422 |
+
# Insights API requires admin role
|
| 423 |
+
@app.get("/insights/topics")
|
| 424 |
+
@require_role("admin")
|
| 425 |
+
async def get_topics():
|
| 426 |
+
return learner.get_top_topics(10)
|
| 427 |
+
```
|
| 428 |
+
|
| 429 |
+
### Audit Logging
|
| 430 |
+
|
| 431 |
+
```python
|
| 432 |
+
# Log all pattern access (not the patterns themselves)
|
| 433 |
+
async def log_access(user_id: str, endpoint: str, timestamp: datetime):
|
| 434 |
+
await audit_log.store({
|
| 435 |
+
"user_id": user_id,
|
| 436 |
+
"endpoint": endpoint,
|
| 437 |
+
"timestamp": timestamp.isoformat(),
|
| 438 |
+
# No pattern data logged
|
| 439 |
+
})
|
| 440 |
+
```
|
| 441 |
+
|
| 442 |
+
---
|
| 443 |
+
|
| 444 |
+
## Implementation Roadmap
|
| 445 |
+
|
| 446 |
+
### Phase 1: MVP (2 weeks)
|
| 447 |
+
|
| 448 |
+
- [ ] Anonymizer with Swedish NER
|
| 449 |
+
- [ ] Basic topic extraction (keywords)
|
| 450 |
+
- [ ] Topic counter (no MnemoCore yet)
|
| 451 |
+
- [ ] Simple insights API
|
| 452 |
+
|
| 453 |
+
### Phase 2: MnemoCore Integration (2 weeks)
|
| 454 |
+
|
| 455 |
+
- [ ] Topic embedding storage in MnemoCore
|
| 456 |
+
- [ ] Semantic topic clustering
|
| 457 |
+
- [ ] Gap detection using similarity search
|
| 458 |
+
|
| 459 |
+
### Phase 3: Quality Metrics (2 weeks)
|
| 460 |
+
|
| 461 |
+
- [ ] Response quality tracking
|
| 462 |
+
- [ ] Feedback integration
|
| 463 |
+
- [ ] Quality dashboard
|
| 464 |
+
|
| 465 |
+
### Phase 4: Production Hardening (2 weeks)
|
| 466 |
+
|
| 467 |
+
- [ ] Encryption at rest
|
| 468 |
+
- [ ] Access control
|
| 469 |
+
- [ ] Audit logging
|
| 470 |
+
- [ ] Performance optimization
|
| 471 |
+
|
| 472 |
+
---
|
| 473 |
+
|
| 474 |
+
## Business Value
|
| 475 |
+
|
| 476 |
+
### For Healthcare Organizations
|
| 477 |
+
|
| 478 |
+
| Value | Metric |
|
| 479 |
+
|-------|--------|
|
| 480 |
+
| **Documentation gaps** | Know what to add to knowledge base |
|
| 481 |
+
| **Popular topics** | Prioritize documentation efforts |
|
| 482 |
+
| **Response quality** | Improve user satisfaction |
|
| 483 |
+
| **Trend analysis** | Identify emerging needs |
|
| 484 |
+
|
| 485 |
+
### For Opus Dental (Competitive Advantage)
|
| 486 |
+
|
| 487 |
+
| Advantage | Value |
|
| 488 |
+
|-----------|-------|
|
| 489 |
+
| **Continuous improvement** | Chatbot gets smarter without storing PII |
|
| 490 |
+
| **Customer insights** | Know what clinics need |
|
| 491 |
+
| **Compliance by design** | GDPR-safe from day 1 |
|
| 492 |
+
| **Unique selling point** | "Learning chatbot" vs competitors |
|
| 493 |
+
|
| 494 |
+
---
|
| 495 |
+
|
| 496 |
+
## Technical Requirements
|
| 497 |
+
|
| 498 |
+
### Dependencies
|
| 499 |
+
|
| 500 |
+
```
|
| 501 |
+
mnemocore>=4.5.0
|
| 502 |
+
spacy[sv]>=3.7.0 # Swedish NER
|
| 503 |
+
numpy>=1.24.0
|
| 504 |
+
cryptography>=41.0.0 # Encryption
|
| 505 |
+
```
|
| 506 |
+
|
| 507 |
+
### Infrastructure
|
| 508 |
+
|
| 509 |
+
- MnemoCore instance (can be shared or per-tenant)
|
| 510 |
+
- Encrypted storage (Azure SQL, PostgreSQL with TDE)
|
| 511 |
+
- Optional: Azure Key Vault for key management
|
| 512 |
+
|
| 513 |
+
### Performance
|
| 514 |
+
|
| 515 |
+
- Topic extraction: <50ms per query
|
| 516 |
+
- Insights API: <200ms
|
| 517 |
+
- Storage: ~1KB per unique topic (highly efficient)
|
| 518 |
+
|
| 519 |
+
---
|
| 520 |
+
|
| 521 |
+
## Open Questions
|
| 522 |
+
|
| 523 |
+
1. **Topic granularity:** How specific should topics be? "Implantat" vs "Implantat pris" vs "Implantat komplikationer"
|
| 524 |
+
|
| 525 |
+
2. **Trend detection:** What time window for trend analysis? 7d? 30d?
|
| 526 |
+
|
| 527 |
+
3. **Multi-language:** Support for Finnish/Norwegian in addition to Swedish?
|
| 528 |
+
|
| 529 |
+
4. **Tenant isolation:** Should patterns be shared across tenants (anonymized) or kept separate?
|
| 530 |
+
|
| 531 |
+
5. **Feedback mechanism:** How to collect ratings? Thumbs up/down? 1-5 stars?
|
| 532 |
+
|
| 533 |
+
---
|
| 534 |
+
|
| 535 |
+
## Conclusion
|
| 536 |
+
|
| 537 |
+
Pattern Learner enables **continuous improvement** of healthcare chatbots **without GDPR risk**. It learns what users ask about, which answers work, and where documentation is missing — all without storing any personal data.
|
| 538 |
+
|
| 539 |
+
**Key innovation:** Transform "memory" into "patterns" — compliance-safe learning.
|
| 540 |
+
|
| 541 |
+
---
|
| 542 |
+
|
| 543 |
+
## Next Steps
|
| 544 |
+
|
| 545 |
+
1. Review this spec
|
| 546 |
+
2. Decide on open questions
|
| 547 |
+
3. Prioritize MVP features
|
| 548 |
+
4. Start implementation
|
| 549 |
+
|
| 550 |
+
---
|
| 551 |
+
|
| 552 |
+
*Draft by Omega (GLM-5) for Robin Granberg*
|
| 553 |
+
*2026-02-20*
|
mnemocore_verify.py
CHANGED
|
@@ -42,7 +42,7 @@ def setup_test_env():
|
|
| 42 |
@pytest.mark.asyncio
|
| 43 |
async def test_text_encoder_normalization():
|
| 44 |
"""Verify BUG-02: Text normalization fixes identical string variances"""
|
| 45 |
-
encoder = TextEncoder(dimension=
|
| 46 |
hdv1 = encoder.encode("Hello World")
|
| 47 |
hdv2 = encoder.encode("hello, world!")
|
| 48 |
|
|
@@ -51,14 +51,14 @@ async def test_text_encoder_normalization():
|
|
| 51 |
def test_hnsw_singleton():
|
| 52 |
"""Verify BUG-08: HNSWIndexManager is a thread-safe singleton"""
|
| 53 |
HNSWIndexManager._instance = None
|
| 54 |
-
idx1 = HNSWIndexManager(dimension=
|
| 55 |
-
idx2 = HNSWIndexManager(dimension=
|
| 56 |
assert idx1 is idx2, "HNSWIndexManager is not a singleton"
|
| 57 |
|
| 58 |
def test_hnsw_index_add_search():
|
| 59 |
"""Verify BUG-01 & BUG-03: Vector cache lost / Position mapping"""
|
| 60 |
HNSWIndexManager._instance = None
|
| 61 |
-
idx = HNSWIndexManager(dimension=
|
| 62 |
|
| 63 |
# Optional cleanup if it's reused
|
| 64 |
idx._id_map = []
|
|
@@ -66,8 +66,8 @@ def test_hnsw_index_add_search():
|
|
| 66 |
if idx._index:
|
| 67 |
idx._index.reset()
|
| 68 |
|
| 69 |
-
vec1 = BinaryHDV.random(
|
| 70 |
-
vec2 = BinaryHDV.random(
|
| 71 |
|
| 72 |
idx.add("test_node_1", vec1.data)
|
| 73 |
idx.add("test_node_2", vec2.data)
|
|
|
|
| 42 |
@pytest.mark.asyncio
|
| 43 |
async def test_text_encoder_normalization():
|
| 44 |
"""Verify BUG-02: Text normalization fixes identical string variances"""
|
| 45 |
+
encoder = TextEncoder(dimension=16384)
|
| 46 |
hdv1 = encoder.encode("Hello World")
|
| 47 |
hdv2 = encoder.encode("hello, world!")
|
| 48 |
|
|
|
|
| 51 |
def test_hnsw_singleton():
|
| 52 |
"""Verify BUG-08: HNSWIndexManager is a thread-safe singleton"""
|
| 53 |
HNSWIndexManager._instance = None
|
| 54 |
+
idx1 = HNSWIndexManager(dimension=16384)
|
| 55 |
+
idx2 = HNSWIndexManager(dimension=16384)
|
| 56 |
assert idx1 is idx2, "HNSWIndexManager is not a singleton"
|
| 57 |
|
| 58 |
def test_hnsw_index_add_search():
|
| 59 |
"""Verify BUG-01 & BUG-03: Vector cache lost / Position mapping"""
|
| 60 |
HNSWIndexManager._instance = None
|
| 61 |
+
idx = HNSWIndexManager(dimension=16384)
|
| 62 |
|
| 63 |
# Optional cleanup if it's reused
|
| 64 |
idx._id_map = []
|
|
|
|
| 66 |
if idx._index:
|
| 67 |
idx._index.reset()
|
| 68 |
|
| 69 |
+
vec1 = BinaryHDV.random(16384)
|
| 70 |
+
vec2 = BinaryHDV.random(16384)
|
| 71 |
|
| 72 |
idx.add("test_node_1", vec1.data)
|
| 73 |
idx.add("test_node_2", vec2.data)
|
src/mnemocore/api/main.py
CHANGED
|
@@ -153,7 +153,7 @@ async def lifespan(app: FastAPI):
|
|
| 153 |
from mnemocore.core.tier_manager import TierManager
|
| 154 |
tier_manager = TierManager(config=config, qdrant_store=container.qdrant_store)
|
| 155 |
engine = HAIMEngine(
|
| 156 |
-
persist_path=
|
| 157 |
config=config,
|
| 158 |
tier_manager=tier_manager,
|
| 159 |
working_memory=container.working_memory,
|
|
@@ -574,6 +574,37 @@ async def get_stats(engine: HAIMEngine = Depends(get_engine)):
|
|
| 574 |
return await engine.get_stats()
|
| 575 |
|
| 576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
# Rate limit info endpoint
|
| 578 |
@app.get("/rate-limits")
|
| 579 |
async def get_rate_limits():
|
|
|
|
| 153 |
from mnemocore.core.tier_manager import TierManager
|
| 154 |
tier_manager = TierManager(config=config, qdrant_store=container.qdrant_store)
|
| 155 |
engine = HAIMEngine(
|
| 156 |
+
persist_path=config.paths.memory_file,
|
| 157 |
config=config,
|
| 158 |
tier_manager=tier_manager,
|
| 159 |
working_memory=container.working_memory,
|
|
|
|
| 574 |
return await engine.get_stats()
|
| 575 |
|
| 576 |
|
| 577 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 578 |
+
# Maintenance Endpoints
|
| 579 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 580 |
+
|
| 581 |
+
@app.post("/maintenance/cleanup", dependencies=[Depends(get_api_key)])
|
| 582 |
+
async def cleanup_maintenance(threshold: float = 0.1, engine: HAIMEngine = Depends(get_engine)):
|
| 583 |
+
"""Remove decayed synapses and stale index nodes."""
|
| 584 |
+
await engine.cleanup_decay(threshold=threshold)
|
| 585 |
+
return {"ok": True, "message": f"Synapse cleanup triggered with threshold {threshold}"}
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
@app.post("/maintenance/consolidate", dependencies=[Depends(get_api_key)])
|
| 589 |
+
async def consolidate_maintenance(engine: HAIMEngine = Depends(get_engine)):
|
| 590 |
+
"""Trigger manual semantic consolidation pulse."""
|
| 591 |
+
if not engine._semantic_worker:
|
| 592 |
+
raise HTTPException(status_code=503, detail="Consolidation worker not initialized")
|
| 593 |
+
|
| 594 |
+
stats = await engine._semantic_worker.run_once()
|
| 595 |
+
return {"ok": True, "stats": stats}
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
@app.post("/maintenance/sweep", dependencies=[Depends(get_api_key)])
|
| 599 |
+
async def sweep_maintenance(engine: HAIMEngine = Depends(get_engine)):
|
| 600 |
+
"""Trigger manual immunology sweep."""
|
| 601 |
+
if not engine._immunology:
|
| 602 |
+
raise HTTPException(status_code=503, detail="Immunology loop not initialized")
|
| 603 |
+
|
| 604 |
+
stats = await engine._immunology.sweep()
|
| 605 |
+
return {"ok": True, "stats": stats}
|
| 606 |
+
|
| 607 |
+
|
| 608 |
# Rate limit info endpoint
|
| 609 |
@app.get("/rate-limits")
|
| 610 |
async def get_rate_limits():
|
src/mnemocore/core/binary_hdv.py
CHANGED
|
@@ -404,9 +404,8 @@ class TextEncoder:
|
|
| 404 |
Each token is bound with its position via XOR(token, permute(position_marker, i)).
|
| 405 |
All position-bound tokens are bundled via majority vote.
|
| 406 |
"""
|
| 407 |
-
#
|
| 408 |
-
|
| 409 |
-
tokens = normalized.split()
|
| 410 |
if not tokens:
|
| 411 |
return BinaryHDV.random(self.dimension)
|
| 412 |
|
|
|
|
| 404 |
Each token is bound with its position via XOR(token, permute(position_marker, i)).
|
| 405 |
All position-bound tokens are bundled via majority vote.
|
| 406 |
"""
|
| 407 |
+
# Improved Tokenization: consistent alphanumeric extraction
|
| 408 |
+
tokens = re.findall(r'\b\w+\b', text.lower())
|
|
|
|
| 409 |
if not tokens:
|
| 410 |
return BinaryHDV.random(self.dimension)
|
| 411 |
|
src/mnemocore/core/hnsw_index.py
CHANGED
|
@@ -108,10 +108,7 @@ class HNSWIndexManager:
|
|
| 108 |
self.VECTOR_PATH = data_dir / "mnemocore_hnsw_vectors.npy"
|
| 109 |
|
| 110 |
if FAISS_AVAILABLE:
|
| 111 |
-
|
| 112 |
-
self._load()
|
| 113 |
-
else:
|
| 114 |
-
self._build_flat_index()
|
| 115 |
|
| 116 |
self._initialized = True
|
| 117 |
|
|
@@ -195,7 +192,6 @@ class HNSWIndexManager:
|
|
| 195 |
return
|
| 196 |
|
| 197 |
self._maybe_upgrade_to_hnsw()
|
| 198 |
-
self._save()
|
| 199 |
|
| 200 |
def remove(self, node_id: str) -> None:
|
| 201 |
"""
|
|
@@ -234,7 +230,6 @@ class HNSWIndexManager:
|
|
| 234 |
self._vector_store = compact_vecs
|
| 235 |
self._stale_count = 0
|
| 236 |
|
| 237 |
-
self._save()
|
| 238 |
except ValueError:
|
| 239 |
pass
|
| 240 |
|
|
@@ -251,7 +246,7 @@ class HNSWIndexManager:
|
|
| 251 |
return []
|
| 252 |
|
| 253 |
# Fetch more to account for deleted (None) entries
|
| 254 |
-
k = min(top_k + self._stale_count, len(self._id_map))
|
| 255 |
if k <= 0:
|
| 256 |
return []
|
| 257 |
|
|
|
|
| 108 |
self.VECTOR_PATH = data_dir / "mnemocore_hnsw_vectors.npy"
|
| 109 |
|
| 110 |
if FAISS_AVAILABLE:
|
| 111 |
+
self._build_flat_index()
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
self._initialized = True
|
| 114 |
|
|
|
|
| 192 |
return
|
| 193 |
|
| 194 |
self._maybe_upgrade_to_hnsw()
|
|
|
|
| 195 |
|
| 196 |
def remove(self, node_id: str) -> None:
|
| 197 |
"""
|
|
|
|
| 230 |
self._vector_store = compact_vecs
|
| 231 |
self._stale_count = 0
|
| 232 |
|
|
|
|
| 233 |
except ValueError:
|
| 234 |
pass
|
| 235 |
|
|
|
|
| 246 |
return []
|
| 247 |
|
| 248 |
# Fetch more to account for deleted (None) entries
|
| 249 |
+
k = min(top_k + self._stale_count + 50, len(self._id_map))
|
| 250 |
if k <= 0:
|
| 251 |
return []
|
| 252 |
|
src/mnemocore/core/qdrant_store.py
CHANGED
|
@@ -9,6 +9,7 @@ Phase 4.3: Temporal Recall - supports time-based filtering and indexing.
|
|
| 9 |
from typing import List, Any, Optional, Tuple, Dict
|
| 10 |
from datetime import datetime
|
| 11 |
import asyncio
|
|
|
|
| 12 |
|
| 13 |
from qdrant_client import AsyncQdrantClient, models
|
| 14 |
from loguru import logger
|
|
@@ -93,39 +94,36 @@ class QdrantStore:
|
|
| 93 |
)
|
| 94 |
)
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
)
|
| 113 |
|
| 114 |
-
|
| 115 |
-
if not await self.client.collection_exists(self.collection_warm):
|
| 116 |
-
logger.info(f"Creating WARM collection: {self.collection_warm}")
|
| 117 |
await self.client.create_collection(
|
| 118 |
-
collection_name=
|
| 119 |
vectors_config=models.VectorParams(
|
| 120 |
size=self.dim,
|
| 121 |
distance=models.Distance.DOT,
|
| 122 |
-
on_disk=
|
| 123 |
),
|
| 124 |
quantization_config=quantization_config,
|
| 125 |
hnsw_config=models.HnswConfigDiff(
|
| 126 |
m=self.hnsw_m,
|
| 127 |
ef_construct=self.hnsw_ef_construct,
|
| 128 |
-
on_disk=
|
| 129 |
)
|
| 130 |
)
|
| 131 |
|
|
@@ -173,24 +171,15 @@ class QdrantStore:
|
|
| 173 |
) -> List[models.ScoredPoint]:
|
| 174 |
"""
|
| 175 |
Async semantic search.
|
| 176 |
-
|
| 177 |
-
Args:
|
| 178 |
-
collection: Collection name to search.
|
| 179 |
-
query_vector: Query embedding vector.
|
| 180 |
-
limit: Maximum number of results.
|
| 181 |
-
score_threshold: Minimum similarity score.
|
| 182 |
-
time_range: Optional (start, end) datetime tuple for temporal filtering.
|
| 183 |
-
Phase 4.3: Enables "memories from last 48 hours" queries.
|
| 184 |
-
|
| 185 |
-
Returns:
|
| 186 |
-
List of scored points (empty list on errors).
|
| 187 |
-
|
| 188 |
-
Note:
|
| 189 |
-
This method returns an empty list on errors rather than raising,
|
| 190 |
-
as search failures should not crash the calling code.
|
| 191 |
"""
|
| 192 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
must_conditions = []
|
|
|
|
| 194 |
if time_range:
|
| 195 |
start_ts = int(time_range[0].timestamp())
|
| 196 |
end_ts = int(time_range[1].timestamp())
|
|
@@ -227,15 +216,32 @@ class QdrantStore:
|
|
| 227 |
)
|
| 228 |
)
|
| 229 |
|
| 230 |
-
|
| 231 |
self.client.query_points,
|
| 232 |
collection_name=collection,
|
| 233 |
-
query=
|
| 234 |
limit=limit,
|
| 235 |
-
score_threshold=score_threshold,
|
| 236 |
query_filter=query_filter,
|
| 237 |
search_params=search_params,
|
| 238 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
except CircuitOpenError:
|
| 240 |
logger.warning(f"Qdrant search blocked for {collection}: circuit breaker open")
|
| 241 |
return []
|
|
|
|
| 9 |
from typing import List, Any, Optional, Tuple, Dict
|
| 10 |
from datetime import datetime
|
| 11 |
import asyncio
|
| 12 |
+
import numpy as np
|
| 13 |
|
| 14 |
from qdrant_client import AsyncQdrantClient, models
|
| 15 |
from loguru import logger
|
|
|
|
| 94 |
)
|
| 95 |
)
|
| 96 |
|
| 97 |
+
for collection_name, on_disk in [
|
| 98 |
+
(self.collection_hot, False),
|
| 99 |
+
(self.collection_warm, True)
|
| 100 |
+
]:
|
| 101 |
+
if await self.client.collection_exists(collection_name):
|
| 102 |
+
# Check for distance mismatch (Phase 4.5 alignment)
|
| 103 |
+
info = await self.client.get_collection(collection_name)
|
| 104 |
+
current_distance = info.config.params.vectors.distance
|
| 105 |
+
if current_distance != models.Distance.DOT:
|
| 106 |
+
logger.warning(
|
| 107 |
+
f"Collection {collection_name} has distance {current_distance}, "
|
| 108 |
+
f"but DOT is required. Recreating collection."
|
| 109 |
+
)
|
| 110 |
+
await self.client.delete_collection(collection_name)
|
| 111 |
+
else:
|
| 112 |
+
continue
|
|
|
|
| 113 |
|
| 114 |
+
logger.info(f"Creating collection: {collection_name} (DOT)")
|
|
|
|
|
|
|
| 115 |
await self.client.create_collection(
|
| 116 |
+
collection_name=collection_name,
|
| 117 |
vectors_config=models.VectorParams(
|
| 118 |
size=self.dim,
|
| 119 |
distance=models.Distance.DOT,
|
| 120 |
+
on_disk=on_disk
|
| 121 |
),
|
| 122 |
quantization_config=quantization_config,
|
| 123 |
hnsw_config=models.HnswConfigDiff(
|
| 124 |
m=self.hnsw_m,
|
| 125 |
ef_construct=self.hnsw_ef_construct,
|
| 126 |
+
on_disk=on_disk
|
| 127 |
)
|
| 128 |
)
|
| 129 |
|
|
|
|
| 171 |
) -> List[models.ScoredPoint]:
|
| 172 |
"""
|
| 173 |
Async semantic search.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
"""
|
| 175 |
try:
|
| 176 |
+
# Transform query to bipolar if it's (0, 1) (Phase 4.5)
|
| 177 |
+
q_vec = np.array(query_vector)
|
| 178 |
+
if np.all((q_vec == 0) | (q_vec == 1)):
|
| 179 |
+
q_vec = q_vec * 2.0 - 1.0
|
| 180 |
+
|
| 181 |
must_conditions = []
|
| 182 |
+
query_filter = None
|
| 183 |
if time_range:
|
| 184 |
start_ts = int(time_range[0].timestamp())
|
| 185 |
end_ts = int(time_range[1].timestamp())
|
|
|
|
| 216 |
)
|
| 217 |
)
|
| 218 |
|
| 219 |
+
response = await qdrant_breaker.call(
|
| 220 |
self.client.query_points,
|
| 221 |
collection_name=collection,
|
| 222 |
+
query=q_vec.tolist(),
|
| 223 |
limit=limit,
|
|
|
|
| 224 |
query_filter=query_filter,
|
| 225 |
search_params=search_params,
|
| 226 |
)
|
| 227 |
+
|
| 228 |
+
# Normalize scores to [0, 1] range (Phase 4.5)
|
| 229 |
+
# For Bipolar DOT, score is in range [-D, D].
|
| 230 |
+
# Similarity = (score + D) / (2 * D) = 0.5 + (score / 2D)
|
| 231 |
+
normalized_points = []
|
| 232 |
+
for hit in response.points:
|
| 233 |
+
sim = 0.5 + (hit.score / (2.0 * self.dim))
|
| 234 |
+
|
| 235 |
+
normalized_points.append(
|
| 236 |
+
models.ScoredPoint(
|
| 237 |
+
id=hit.id,
|
| 238 |
+
version=hit.version,
|
| 239 |
+
score=float(np.clip(sim, 0.0, 1.0)),
|
| 240 |
+
payload=hit.payload,
|
| 241 |
+
vector=hit.vector
|
| 242 |
+
)
|
| 243 |
+
)
|
| 244 |
+
return normalized_points
|
| 245 |
except CircuitOpenError:
|
| 246 |
logger.warning(f"Qdrant search blocked for {collection}: circuit breaker open")
|
| 247 |
return []
|
src/mnemocore/core/subconscious_ai.py
CHANGED
|
@@ -545,17 +545,19 @@ class SubconsciousAIWorker:
|
|
| 545 |
|
| 546 |
# Build prompt for categorization
|
| 547 |
memories_text = "\n".join([
|
| 548 |
-
f"
|
| 549 |
for i, m in enumerate(unsorted[:5])
|
| 550 |
])
|
| 551 |
|
| 552 |
-
prompt = f"""
|
|
|
|
|
|
|
| 553 |
|
| 554 |
-
|
| 555 |
-
{
|
| 556 |
|
| 557 |
-
|
| 558 |
-
{
|
| 559 |
|
| 560 |
response = await self._model_client.generate(prompt, max_tokens=512)
|
| 561 |
output = {"raw_response": response}
|
|
@@ -618,11 +620,12 @@ Return JSON format:
|
|
| 618 |
"""
|
| 619 |
t_start = time.monotonic()
|
| 620 |
|
| 621 |
-
# Find memories with low LTP (weak connections)
|
| 622 |
-
recent = await self.engine.tier_manager.get_hot_recent(
|
|
|
|
| 623 |
weak_memories = [
|
| 624 |
m for m in recent
|
| 625 |
-
if m.ltp_strength < 0.5 and not m.metadata.get("dream_analyzed")
|
| 626 |
][:self.cfg.max_memories_per_cycle]
|
| 627 |
|
| 628 |
if not weak_memories:
|
|
@@ -638,17 +641,18 @@ Return JSON format:
|
|
| 638 |
|
| 639 |
# Build prompt for semantic bridging
|
| 640 |
memories_text = "\n".join([
|
| 641 |
-
f"
|
| 642 |
for i, m in enumerate(weak_memories[:5])
|
| 643 |
])
|
| 644 |
|
| 645 |
-
prompt = f"""
|
|
|
|
| 646 |
|
| 647 |
-
|
| 648 |
-
{
|
| 649 |
|
| 650 |
-
|
| 651 |
-
|
| 652 |
|
| 653 |
response = await self._model_client.generate(prompt, max_tokens=512)
|
| 654 |
output = {"raw_response": response}
|
|
|
|
| 545 |
|
| 546 |
# Build prompt for categorization
|
| 547 |
memories_text = "\n".join([
|
| 548 |
+
f"ID {i+1}: {m.content[:200]}"
|
| 549 |
for i, m in enumerate(unsorted[:5])
|
| 550 |
])
|
| 551 |
|
| 552 |
+
prompt = f"""You are a memory sorting agent for Veristate Systems.
|
| 553 |
+
Categorize these 5 memories into exactly 2 categories from this set: [Market Dynamics, Structural Integrity, Human Entropy, Digital Ascension, Strategic Intent].
|
| 554 |
+
Output ONLY a valid JSON object. No explanation. No markdown.
|
| 555 |
|
| 556 |
+
Format:
|
| 557 |
+
{{"categories": ["category1", "category2"], "memory_tags": {{"1": ["tagA"], "2": ["tagB"], "3": ["tagC"], "4": ["tagD"], "5": ["tagE"]}}}}
|
| 558 |
|
| 559 |
+
Memories:
|
| 560 |
+
{memories_text}"""
|
| 561 |
|
| 562 |
response = await self._model_client.generate(prompt, max_tokens=512)
|
| 563 |
output = {"raw_response": response}
|
|
|
|
| 620 |
"""
|
| 621 |
t_start = time.monotonic()
|
| 622 |
|
| 623 |
+
# Find memories with low LTP (weak connections) or unanalyzed
|
| 624 |
+
recent = await self.engine.tier_manager.get_hot_recent(50)
|
| 625 |
+
# Phase 4.5 Tuning: Allow dreaming on nodes with LTP <= 0.5 (new nodes)
|
| 626 |
weak_memories = [
|
| 627 |
m for m in recent
|
| 628 |
+
if m.ltp_strength <= 0.5 and not m.metadata.get("dream_analyzed")
|
| 629 |
][:self.cfg.max_memories_per_cycle]
|
| 630 |
|
| 631 |
if not weak_memories:
|
|
|
|
| 641 |
|
| 642 |
# Build prompt for semantic bridging
|
| 643 |
memories_text = "\n".join([
|
| 644 |
+
f"ID {i+1}: {m.content[:150]}"
|
| 645 |
for i, m in enumerate(weak_memories[:5])
|
| 646 |
])
|
| 647 |
|
| 648 |
+
prompt = f"""You are a semantic analysis agent. Suggest connection keywords for these 5 memories.
|
| 649 |
+
Output ONLY a valid JSON object. No explanation. No markdown.
|
| 650 |
|
| 651 |
+
Format:
|
| 652 |
+
{{"bridges": {{"1": ["keyword1"], "2": ["keyword2"], "3": ["keyword3"], "4": ["keyword4"], "5": ["keyword5"]}}}}
|
| 653 |
|
| 654 |
+
Memories:
|
| 655 |
+
{memories_text}"""
|
| 656 |
|
| 657 |
response = await self._model_client.generate(prompt, max_tokens=512)
|
| 658 |
output = {"raw_response": response}
|
src/mnemocore/core/tier_manager.py
CHANGED
|
@@ -177,6 +177,10 @@ class TierManager:
|
|
| 177 |
"""Add a new memory node. New memories are always HOT initially."""
|
| 178 |
node.tier = "hot"
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
# Phase 1: Add to HOT tier under lock (no I/O)
|
| 181 |
victim_to_evict = None
|
| 182 |
async with self.lock:
|
|
@@ -444,9 +448,9 @@ class TierManager:
|
|
| 444 |
try:
|
| 445 |
from qdrant_client import models
|
| 446 |
|
| 447 |
-
# Unpack binary vector for Qdrant storage
|
| 448 |
bits = np.unpackbits(node.hdv.data)
|
| 449 |
-
vector = bits.astype(float).tolist()
|
| 450 |
|
| 451 |
point = models.PointStruct(
|
| 452 |
id=node.id,
|
|
|
|
| 177 |
"""Add a new memory node. New memories are always HOT initially."""
|
| 178 |
node.tier = "hot"
|
| 179 |
|
| 180 |
+
# Delta 67.4: Ensure mutual exclusion.
|
| 181 |
+
# If the node exists in WARM, remove it before adding to HOT.
|
| 182 |
+
await self._delete_from_warm(node.id)
|
| 183 |
+
|
| 184 |
# Phase 1: Add to HOT tier under lock (no I/O)
|
| 185 |
victim_to_evict = None
|
| 186 |
async with self.lock:
|
|
|
|
| 448 |
try:
|
| 449 |
from qdrant_client import models
|
| 450 |
|
| 451 |
+
# Unpack binary vector for Qdrant storage (Bipolar Phase 4.5)
|
| 452 |
bits = np.unpackbits(node.hdv.data)
|
| 453 |
+
vector = (bits.astype(float) * 2.0 - 1.0).tolist()
|
| 454 |
|
| 455 |
point = models.PointStruct(
|
| 456 |
id=node.id,
|
sync_qdrant.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from mnemocore.core.engine import HAIMEngine
|
| 6 |
+
from mnemocore.core.config import get_config
|
| 7 |
+
from mnemocore.core.container import build_container
|
| 8 |
+
from mnemocore.core.tier_manager import TierManager
|
| 9 |
+
|
| 10 |
+
async def sync_qdrant():
|
| 11 |
+
config = get_config()
|
| 12 |
+
container = build_container(config)
|
| 13 |
+
|
| 14 |
+
# Initialize TierManager with Qdrant
|
| 15 |
+
tier_manager = TierManager(config=config, qdrant_store=container.qdrant_store)
|
| 16 |
+
|
| 17 |
+
engine = HAIMEngine(
|
| 18 |
+
config=config,
|
| 19 |
+
tier_manager=tier_manager,
|
| 20 |
+
working_memory=container.working_memory,
|
| 21 |
+
episodic_store=container.episodic_store,
|
| 22 |
+
semantic_store=container.semantic_store
|
| 23 |
+
)
|
| 24 |
+
await engine.initialize()
|
| 25 |
+
|
| 26 |
+
print(f"Engine initialized. Memories in HOT: {len(engine.tier_manager.hot)}")
|
| 27 |
+
|
| 28 |
+
# Force sync from memory.jsonl if HOT is empty
|
| 29 |
+
if len(engine.tier_manager.hot) == 0:
|
| 30 |
+
print("HOT is empty, reloading from legacy log...")
|
| 31 |
+
await engine._load_legacy_if_needed()
|
| 32 |
+
print(f"Memories after reload: {len(engine.tier_manager.hot)}")
|
| 33 |
+
|
| 34 |
+
# Consolidation will move them to WARM (Qdrant)
|
| 35 |
+
# But we can also just call _save_to_warm manually for all nodes
|
| 36 |
+
print("Syncing nodes to Qdrant...")
|
| 37 |
+
count = 0
|
| 38 |
+
for node_id, node in list(engine.tier_manager.hot.items()):
|
| 39 |
+
await engine.tier_manager._save_to_warm(node)
|
| 40 |
+
count += 1
|
| 41 |
+
if count % 100 == 0:
|
| 42 |
+
print(f"Synced {count} nodes...")
|
| 43 |
+
|
| 44 |
+
print(f"Total synced: {count}")
|
| 45 |
+
await engine.close()
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
import sys
|
| 49 |
+
sys.path.append(os.path.join(os.getcwd(), "src"))
|
| 50 |
+
asyncio.run(sync_qdrant())
|
test_qdrant_scores.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import asyncio
|
| 3 |
+
import numpy as np
|
| 4 |
+
from mnemocore.core.qdrant_store import QdrantStore
|
| 5 |
+
from mnemocore.core.config import get_config
|
| 6 |
+
|
| 7 |
+
async def test_qdrant_scores():
|
| 8 |
+
config = get_config()
|
| 9 |
+
store = QdrantStore(
|
| 10 |
+
url=config.qdrant.url,
|
| 11 |
+
api_key=None,
|
| 12 |
+
dimensionality=config.dimensionality
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
print(f"Ensuring collections (Migration Check)...")
|
| 16 |
+
await store.ensure_collections()
|
| 17 |
+
|
| 18 |
+
print(f"Searching {config.qdrant.collection_warm}...")
|
| 19 |
+
try:
|
| 20 |
+
info = await store.get_collection_info(config.qdrant.collection_warm)
|
| 21 |
+
print(f"Collection Info: {info}")
|
| 22 |
+
|
| 23 |
+
# Get one point first to have a valid vector
|
| 24 |
+
scroll_res = await store.scroll(config.qdrant.collection_warm, limit=1, with_vectors=True)
|
| 25 |
+
points = scroll_res[0]
|
| 26 |
+
|
| 27 |
+
if not points:
|
| 28 |
+
print("No points found in collection.")
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
target_vec = points[0].vector
|
| 32 |
+
target_id = points[0].id
|
| 33 |
+
print(f"Target Point: ID={target_id}")
|
| 34 |
+
|
| 35 |
+
# Test basic search without search_params
|
| 36 |
+
response = await store.client.query_points(
|
| 37 |
+
collection_name=config.qdrant.collection_warm,
|
| 38 |
+
query=target_vec,
|
| 39 |
+
limit=3
|
| 40 |
+
)
|
| 41 |
+
hits = response.points
|
| 42 |
+
print(f"Basic Search Hits count: {len(hits)}")
|
| 43 |
+
for i, hit in enumerate(hits):
|
| 44 |
+
print(f"Hit {i}: ID={hit.id}, Score={hit.score}")
|
| 45 |
+
|
| 46 |
+
hits = await store.search(config.qdrant.collection_warm, target_vec, limit=3)
|
| 47 |
+
print(f"Store Search Hits count: {len(hits)}")
|
| 48 |
+
for i, hit in enumerate(hits):
|
| 49 |
+
print(f"Hit {i}: ID={hit.id}, Score={hit.score}")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
import traceback
|
| 52 |
+
traceback.print_exc()
|
| 53 |
+
print(f"Error: {e}")
|
| 54 |
+
finally:
|
| 55 |
+
await store.close()
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
import os
|
| 59 |
+
import sys
|
| 60 |
+
sys.path.append(os.path.join(os.getcwd(), "src"))
|
| 61 |
+
asyncio.run(test_qdrant_scores())
|