"""backend/api/agent_memory.py — Agent memory CRUD (S354). GAP-MEM-FIX: aggiunta riconciliazione _mem_fallback → Supabase. Problema confermato: quando Supabase è temporaneamente offline, le voci finiscono solo in _mem_fallback (dict in-process). Al restart del backend (HF Space free-tier riavvia spesso) il fallback viene perso completamente. Fix: dopo ogni write Supabase riuscita, schedula un tentativo di sync del fallback — se ci sono voci orfane le pubblica su Supabase e le rimuove dal fallback locale. Nessun job periodico (troppo pesante su free-tier) — lazy reconciliation al primo write riuscito dopo un periodo di downtime Supabase. """ import time, asyncio from fastapi import APIRouter from pydantic import BaseModel from .state import _sb, _mem_fallback import logging _logger = logging.getLogger("api.agent_memory") router = APIRouter() class MemoryEntry(BaseModel): key: str value: str category: str = 'general' createdAt: int = 0 updatedAt: int = 0 async def _reconcile_fallback() -> int: """GAP-MEM-FIX: sincronizza voci _mem_fallback → Supabase. Chiama dopo ogni write Supabase riuscita: se ci sono voci scritte solo in fallback (es. dopo un periodo di downtime Supabase), le pubblica. Ritorna il numero di voci sincronizzate. Non solleva mai eccezioni — fire-and-forget. """ if not _sb or not _mem_fallback: return 0 synced = 0 for key, entry in list(_mem_fallback.items()): try: _sb.table('agent_memory').upsert({ 'key': entry['key'], 'value': entry['value'], 'category': entry.get('category', 'general'), 'created_at': entry.get('createdAt', 0), 'updated_at': entry.get('updatedAt', 0), }, on_conflict='key').execute() synced += 1 except Exception as _e: _logger.debug("[memory] reconcile stopped at key=%s: %s", key, _e) break # Supabase non disponibile — interrompi, riprova al prossimo write if synced: _logger.info("[memory] GAP-MEM-FIX: reconciled %d fallback entries to Supabase", synced) return synced @router.get('/api/memory/agent') async def list_agent_memory(): if _sb: try: data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).execute() entries = [ {'key': r['key'], 'value': r['value'], 'category': r.get('category', 'general'), 'createdAt': r.get('created_at', 0), 'updatedAt': r.get('updated_at', 0)} for r in (data.data or []) ] return {'entries': entries} except Exception as e: _logger.warning('[memory] Supabase list error: %s', e) return {'entries': list(_mem_fallback.values())} @router.get('/api/memory/agent/{key}') async def get_agent_memory(key: str): if _sb: try: data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute() if data.data: return {'value': data.data[0]['value']} except Exception as e: _logger.warning('[memory] Supabase get error: %s', e) entry = _mem_fallback.get(key) return {'value': entry['value'] if entry else None} @router.post('/api/memory/agent') async def set_agent_memory(entry: MemoryEntry): now = int(time.time() * 1000) record = { 'key': entry.key, 'value': entry.value, 'category': entry.category, 'createdAt': entry.createdAt or now, 'updatedAt': entry.updatedAt or now, } # Sempre scrivi in fallback prima (garanzia immediata) _mem_fallback[entry.key] = record if _sb: try: _sb.table('agent_memory').upsert({ 'key': entry.key, 'value': entry.value, 'category': entry.category, 'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now, }, on_conflict='key').execute() # GAP-MEM-FIX: Supabase disponibile → schedula riconciliazione fallback orfano # (voci scritte solo in fallback durante downtime precedente) if len(_mem_fallback) > 1: asyncio.create_task(_reconcile_fallback()) except Exception as _e: _logger.warning('[memory] Supabase write error (fallback attivo): %s', _e) return {'ok': True, 'key': entry.key} @router.delete('/api/memory/agent/{key}') async def delete_agent_memory(key: str): if _sb: try: _sb.table('agent_memory').delete().eq('key', key).execute() except Exception as _exc: _logger.debug("[agent_memory] silenced %s", type(_exc).__name__) # noqa: BLE001 _mem_fallback.pop(key, None) return {'deleted': key}