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()