Spaces:
Running
Running
| """ | |
| 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 | |
| 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} | |
| 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)} | |
| 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} | |
| 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} | |