""" backend/api/agent_memory.py — Agent memory CRUD (S354). GAP-MEM-FIX: aggiunta riconciliazione _mem_fallback → Supabase. GAP-SENSITIVE-FIX: implementato masking per le chiavi definite in SENSITIVE. """ import time, asyncio from typing import Any from fastapi import APIRouter, Depends from .auth_guard import require_role, AuthRole from pydantic import BaseModel from .state import _sb, _mem_fallback, SENSITIVE import logging _logger = logging.getLogger("api.agent_memory") # Router protetto a livello MACHINE — richiede X-Internal-Token router = APIRouter(dependencies=[Depends(require_role(AuthRole.MACHINE))]) class MemoryEntry(BaseModel): key: str value: str category: str = 'general' createdAt: int = 0 updatedAt: int = 0 def _mask_value(key: str, value: Any) -> Any: """Maschera il valore se la chiave è presente nel set SENSITIVE.""" if key in SENSITIVE and value: return "[REDACTED]" return value async def _reconcile_fallback() -> int: """GAP-MEM-FIX: sincronizza voci _mem_fallback → Supabase.""" 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 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(): """Lista le voci di memoria, mascherando i segreti.""" if _sb: try: data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).limit(500).execute() entries = [ { 'key': r['key'], 'value': _mask_value(r['key'], 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) entries = [ { 'key': v['key'], 'value': _mask_value(v['key'], v['value']), 'category': v.get('category', 'general'), 'createdAt': v.get('createdAt', 0), 'updatedAt': v.get('updatedAt', 0) } for v in _mem_fallback.values() ] return {'entries': entries} @router.get('/api/memory/agent/{key}') async def get_agent_memory(key: str): """Recupera una singola voce di memoria, mascherando se sensibile.""" val = None if _sb: try: data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute() if data.data: val = data.data[0]['value'] except Exception as e: _logger.warning('[memory] Supabase get error: %s', e) if val is None: entry = _mem_fallback.get(key) val = entry['value'] if entry else None return {'value': _mask_value(key, val)} @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, } _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() 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__) _mem_fallback.pop(key, None) return {'deleted': key}