Spaces:
Running
Running
File size: 13,186 Bytes
7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd d66556b 7d580fd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | """
manager.py β Unified Memory Manager
Coordina i 4 layer: Working, Episodic, Semantic, Reflection.
TAM (Token-Aware Memory) β get_context usa algoritmo Waterfall con budget token dinamico.
"""
from .working import WorkingMemory
from .episodic import EpisodicMemory
from .semantic import SemanticMemory
from .reflection import ReflectionMemory
import logging
_logger = logging.getLogger("memory.manager")
class MemoryManager:
def __init__(self, total_token_budget: int = 4000):
self.working = WorkingMemory(max_entries=80) # QF-5: 40β80 β sessioni lunghe multi-file
self.episodic = EpisodicMemory()
self.semantic = SemanticMemory()
self.reflection = ReflectionMemory()
# TAM: budget totale in token per get_context()
self.total_token_budget = total_token_budget
# Distribuzione percentuale iniziale (Waterfall: Reflection β Episodic β Semantic β Working)
self._budget_distribution = {
"reflection": 0.10,
"episodic": 0.20,
"semantic": 0.30,
"working": 0.40,
}
async def init(self):
# S274-BUG2: semantic.init() apre connessioni Supabase/ChromaDB β I/O bloccante.
# asyncio.to_thread scarica sul thread pool per non bloccare l'event loop FastAPI.
import asyncio as _asyncio
await _asyncio.to_thread(self.episodic.init)
await _asyncio.to_thread(self.semantic.init)
# Auto-restore: se la memoria Γ¨ vuota, carica l'ultimo snapshot da GitHub
await self._auto_restore_semantic()
async def close(self):
self.episodic.close()
# ββ TAM helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _estimate_tokens(self, text: str) -> int:
"""Stima rapida dei token: caratteri / 4."""
return len(text) // 4
def _fill_layer(self, header: str, entries: list, budget: int) -> tuple[str, int]:
"""Riempie un layer rispettando il budget token.
Restituisce (testo_layer, token_usati).
Se il layer Γ¨ vuoto o il budget Γ¨ zero, restituisce ("", 0).
Se una singola entry supera il budget, la tronca invece di scartarla.
Il budget residuo non usato viene ceduto al layer successivo tramite il chiamante.
"""
if not entries or budget <= 0:
return "", 0
header_str = f"--- {header} ---"
current_text = header_str
current_tokens = self._estimate_tokens(header_str)
used_entries = 0
for entry in entries:
entry_str = str(entry)
entry_tokens = self._estimate_tokens(entry_str)
if current_tokens + entry_tokens + 1 > budget:
if used_entries == 0:
# Prima entry troppo lunga: tronca intelligentemente
allowed_chars = (budget - current_tokens - 5) * 4
if allowed_chars > 100:
truncated = entry_str[:allowed_chars] + "β¦"
current_text += "\n" + truncated
current_tokens += self._estimate_tokens(truncated)
# Budget esaurito β passa il residuo al layer successivo
break
current_text += "\n" + entry_str
current_tokens += entry_tokens + 1
used_entries += 1
return current_text, current_tokens
# ββ get_context β algoritmo Waterfall TAM ββββββββββββββββββββββββββββββββββ
async def get_context(self, query: str, code_length: int = 0) -> str:
"""Assembla il contesto dai 4 layer con Waterfall Token Budget.
TAM β Token-Aware Memory:
Il budget non usato da un layer viene ceduto al successivo.
Nessun layer puΓ² mai sforare il budget totale.
Budget adattivo per code_length (prompt giΓ grandi su iPhone):
code_length > 8000 β 2000 token (stringente)
code_length > 4000 β 3000 token (medio)
default β total_token_budget (4000)
"""
if code_length > 8000:
effective_budget = 2000
elif code_length > 4000:
effective_budget = 3000
else:
effective_budget = self.total_token_budget
remaining_budget = effective_budget
context_parts = []
# ββ Layer 1: Reflection (10%) ββββββββββββββββββββββββββββββββββββββββββ
reflect_alloc = int(effective_budget * self._budget_distribution["reflection"])
lessons = self.reflection.get_relevant_lessons(query, n=5)
lesson_lines = []
for l in lessons:
if l["type"] == "failure":
lesson_lines.append(f"EVITA: {l['avoid'][:300]}")
else:
lesson_lines.append(f"STRATEGIA: {l['strategy'][:300]}")
reflect_text, reflect_used = self._fill_layer("Lezioni passate", lesson_lines, reflect_alloc)
if reflect_text:
context_parts.append(reflect_text)
remaining_budget -= reflect_used
# ββ Layer 2: Episodic (20% + residuo reflection) βββββββββββββββββββββββ
episodic_alloc = int(effective_budget * self._budget_distribution["episodic"]) + (reflect_alloc - reflect_used)
episodes = self.episodic.search_text(query, n=5)
episode_lines = [
f"{ep.task} β {ep.output[:300]}"
for ep in episodes
]
episodic_text, episodic_used = self._fill_layer("Episodi passati", episode_lines, episodic_alloc)
if episodic_text:
context_parts.append(episodic_text)
remaining_budget -= episodic_used
# ββ Layer 3: Semantic (30% + residuo episodic) βββββββββββββββββββββββββ
semantic_alloc = int(effective_budget * self._budget_distribution["semantic"]) + (episodic_alloc - episodic_used)
semantic_used = 0
if self.semantic.available:
semantic_hits = self.semantic.search(query, n_results=8)
semantic_lines = [
f"- {h['content'][:300]}"
for h in semantic_hits
if h["similarity"] > 0.3
]
semantic_text, semantic_used = self._fill_layer("Conoscenza rilevante", semantic_lines, semantic_alloc)
if semantic_text:
context_parts.append(semantic_text)
remaining_budget -= semantic_used
# ββ Layer 4: Working (tutto il budget residuo β layer piΓΉ importante) ββ
working_budget = remaining_budget
working_ctx = self.working.get_context_string(n=15)
if working_ctx:
working_tokens = self._estimate_tokens(working_ctx)
if working_tokens <= working_budget:
context_parts.append(working_ctx)
else:
# Tronca preservando inizio (piΓΉ recente = in coda, ma tronco i caratteri extra)
allowed_chars = working_budget * 4
context_parts.append(working_ctx[:allowed_chars] + "β¦")
final_context = "\n\n".join(context_parts) if context_parts else ""
_logger.info(
"[MemoryManager] TAM context: %d/%d token (code_length=%d, layers=%d)",
self._estimate_tokens(final_context), effective_budget, code_length, len(context_parts),
)
return final_context
# ββ Salvataggio dati βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def save_exchange(self, messages: list, response: str):
"""Salva uno scambio chat nella memoria."""
user_msg = next((m["content"] for m in reversed(messages) if m["role"] == "user"), "")
# Working: aggiungi utente + risposta
if user_msg:
self.working.add("user", user_msg)
self.working.add("assistant", response)
# Episodic: salva la coppia β S571: 500β2000
self.episodic.add("chat", user_msg[:500], response[:2000], True)
# Semantic: indicizza per similarity search futura β S571: combined 600β1100 chars
if self.semantic.available and user_msg and len(response) > 50:
combined = f"Q: {user_msg[:500]} A: {response[:800]}"
self.semantic.add(combined, {"type": "chat", "query": user_msg[:300]})
async def save_episode(self, type_: str, task: str, output: str, success: bool, tags: list | None = None):
self.episodic.add(type_, task, output, success, tags)
if self.semantic.available and task:
self.semantic.add(task, {"type": type_, "success": success})
async def search(self, query: str, n: int = 5, layer: str | None = None) -> list[dict]:
results = []
if layer in (None, "semantic") and self.semantic.available:
for h in self.semantic.search(query, n_results=n):
results.append({**h, "layer": "semantic"})
if layer in (None, "episodic"):
for ep in self.episodic.search_text(query, n=n):
results.append({
"content": f"{ep.task} β {ep.output[:300]}",
"layer": "episodic",
"type": ep.type,
"success": ep.success,
})
if layer == "reflection":
lessons = self.reflection.get_relevant_lessons(query, n=n)
results.extend([{**l, "layer": "reflection"} for l in lessons])
return results[:n]
async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
if success:
self.reflection.record_success(task, output[:500])
await self.save_episode("fix", task, output, True)
else:
self.reflection.record_failure(task, error or output[:500])
await self.save_episode("error", task, error or output[:500], False)
return {
"recorded": True,
"top_patterns": self.reflection.get_top_patterns(5),
"lessons": self.reflection.get_relevant_lessons(task, 4),
}
# ββ Auto-backup semantica cross-restart βββββββββββββββββββββββββββββββββββββ
async def _auto_restore_semantic(self) -> None:
"""Auto-restore: se la semantic memory Γ¨ vuota, carica l'ultimo snapshot da GitHub.
Chiamato dopo init() β garantisce continuitΓ cross-restart (ChromaDB ephemeral + Supabase).
Non-blocking: fallisce silenziosamente se GitHub non raggiungibile o snapshot assente.
"""
import asyncio as _asyncio, os
if not self.semantic.available:
return
count = await _asyncio.to_thread(self.semantic.count)
if count > 0:
return # giΓ popolata β Supabase ha i dati persistenti
token = os.environ.get("GITHUB_TOKEN", "")
if not token:
return
try:
import urllib.request as _urq, json as _json, base64 as _b64
req = _urq.Request(
"https://api.github.com/repos/Baida98/AI/contents/data/semantic_snapshot.json",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "agente-ai-backend",
},
)
with _urq.urlopen(req, timeout=10) as resp:
meta = _json.loads(resp.read())
records = _json.loads(_b64.b64decode(meta["content"]).decode("utf-8"))
if not records:
return
result = await _asyncio.to_thread(self.semantic.import_all, records)
_logger.info(
"[MemoryManager] β Auto-restore semantica: %d record da GitHub snapshot (skip: %d)",
result["imported"], result["skipped"],
)
except Exception as exc:
_logger.debug(
"[MemoryManager] Auto-restore semantica: snapshot non disponibile (%s)",
exc.__class__.__name__,
)
def stats(self) -> dict:
return {
"working": self.working.stats(),
"episodic": self.episodic.stats(),
"semantic": self.semantic.stats(),
"reflection": self.reflection.stats(),
}
async def clear(self, layer: str | None = None):
if layer in (None, "working"):
self.working.clear()
if layer in (None, "episodic"):
import sqlite3
if self.episodic._db:
self.episodic._db.execute("DELETE FROM episodes")
self.episodic._db.commit()
|