""" Module de gestion de la mémoire utilisateur pour le bot Superviseur. - Fichiers JSON par utilisateur (dossier configurable) - Historique récent (liste) + résumé global - Écriture atomique pour éviter la corruption - Compression automatique de l'historique via une fonction de "summarize" (placeholder) Usage minimal : from ia.memoire import add_interaction, build_context_for_model # Avant de générer la réponse IA : context_prompt = await build_context_for_model(user_id, max_recent=12) # inclure `context_prompt` dans le prompt envoyé à ton modèle # Après avoir généré la réponse IA : await add_interaction(user_id, channel_id, user_message, bot_response) Ce module est asynchrone-friendly mais fonctionne aussi en sync pour l'accès disque. """ from __future__ import annotations import os import json import tempfile import asyncio import aiofiles import time try: import redis.asyncio as aioredis except Exception: # optional dependency aioredis = None import logging import re from datetime import datetime, timezone from typing import Any, Dict, List, Optional # Config MEMORY_DIR = os.environ.get("SUPERVISEUR_MEM_DIR", "memoires") MAX_RECENT = int(os.environ.get("SUPERVISEUR_MAX_RECENT", "20")) # garder X messages avant résumé KEEP_AFTER_SUMMARIZE = int(os.environ.get("SUPERVISEUR_KEEP_RECENT_AFTER_SUMMARY", "8")) # Validation des configs if MAX_RECENT <= 0 or KEEP_AFTER_SUMMARIZE < 0 or KEEP_AFTER_SUMMARIZE >= MAX_RECENT: raise ValueError("Configurations invalides: vérifiez SUPERVISEUR_MAX_RECENT et SUPERVISEUR_KEEP_RECENT_AFTER_SUMMARY") # Logger logger = logging.getLogger(__name__) # Métriques simples metrics = { "total_interactions": 0, "total_summaries": 0, "memory_files": 0 } # Extended metrics metrics.setdefault("cache_hits", 0) metrics.setdefault("cache_misses", 0) metrics.setdefault("redis_reads", 0) metrics.setdefault("redis_writes", 0) metrics.setdefault("file_reads", 0) metrics.setdefault("file_writes", 0) # Optional redis client _redis_client = None # Format d'une entrée d'historique # { # "timestamp": "2025-11-25T22:00:00Z", # "channel_id": 123456789, # "user_message": "...", # "bot_message": "..." # } def _ensure_memory_dir() -> None: os.makedirs(MEMORY_DIR, exist_ok=True) logger.debug(f"Répertoire mémoire assuré: {MEMORY_DIR}") def _user_filepath(user_identifier: int | str, guild_id: str | None = None) -> str: _ensure_memory_dir() base_dir = MEMORY_DIR if guild_id: # Create per-guild subdirectory base_dir = os.path.join(MEMORY_DIR, str(guild_id)) os.makedirs(base_dir, exist_ok=True) if isinstance(user_identifier, str): # Sanitize for filename: replace invalid chars and spaces with _ sanitized = re.sub(r'[\/\\:*?"<>|\s]', '_', user_identifier) return os.path.join(base_dir, f"{sanitized}.json") else: return os.path.join(base_dir, f"{user_identifier}.json") def _redis_key(user_id: int | str, guild_id: str | None = None) -> str: guild_part = f"_{guild_id}" if guild_id else "" return f"mem{guild_part}:{user_id}" def _use_redis() -> bool: return bool(os.environ.get("SUPERVISEUR_REDIS_URL")) and aioredis is not None def _ensure_redis(): global _redis_client if not _use_redis(): return None if _redis_client is None: url = os.environ.get("SUPERVISEUR_REDIS_URL") _redis_client = aioredis.from_url(url) return _redis_client async def _atomic_write_async(path: str, data: Dict[str, Any]) -> None: # Écriture atomique via fichier temporaire + replace en async dirpath = os.path.dirname(path) fd, tmp_path = tempfile.mkstemp(dir=dirpath) try: # Since aiofiles doesn't support os.fdopen directly, write via path async with aiofiles.open(tmp_path, "w", encoding="utf-8") as f: await f.write(json.dumps(data, ensure_ascii=False, indent=2)) await f.flush() # Ensure durability at OS level try: with open(tmp_path, "rb") as f: os.fsync(f.fileno()) except Exception: pass os.replace(tmp_path, path) finally: if os.path.exists(tmp_path): try: os.remove(tmp_path) except Exception: pass # In-memory write-back cache to avoid disk I/O on every interaction memory_cache: Dict[str, Dict[str, Any]] = {} # Dirty flags for cached items _dirty: Dict[str, bool] = {} # Per-user locks to avoid concurrent writes _locks: Dict[str, asyncio.Lock] = {} async def _get_lock_for(user_id: str) -> asyncio.Lock: key = str(user_id) if key not in _locks: _locks[key] = asyncio.Lock() return _locks[key] async def _background_persist(user_id: str, guild_id: str | None) -> None: """Persist a cached user memory to disk if dirty.""" key = f"{guild_id or ''}_{user_id}" lock = await _get_lock_for(key) async with lock: if not _dirty.get(key): return data = memory_cache.get(key) if data is None: return path = _user_filepath(user_id, guild_id) try: if _use_redis(): client = _ensure_redis() await client.set(_redis_key(user_id, guild_id), json.dumps(data)) metrics["redis_writes"] += 1 _dirty[key] = False logger.debug(f"Persisted memory for {key} to redis") else: await _atomic_write_async(path, data) _dirty[key] = False metrics["file_writes"] += 1 logger.debug(f"Persisted memory for {key} to disk") except Exception as e: logger.exception(f"Erreur lors de la sauvegarde asynchrone pour {key}: {e}") async def load_user_memory_async(user_id: int | str, guild_id: str | None = None) -> Dict[str, Any]: """Charge (ou crée) la mémoire d'un utilisateur. Structure renvoyée : { "resume_global": str, "historique_recent": [ { ... }, ... ] } """ key = f"{guild_id or ''}_{user_id}" # Check in-memory cache first if key in memory_cache: metrics["cache_hits"] += 1 return memory_cache[key] path = _user_filepath(user_id, guild_id) # Try redis if enabled if _use_redis(): client = _ensure_redis() try: raw = await client.get(_redis_key(user_id, guild_id)) if raw: metrics["redis_reads"] += 1 data = json.loads(raw) memory_cache[key] = data _dirty[key] = False metrics["cache_hits"] += 1 return data except Exception as e: logger.warning(f"Redis load error for {key}: {e}") if not os.path.exists(path): # Prime cache memory_cache[key] = {"resume_global": "", "historique_recent": []} _dirty[key] = False metrics["cache_misses"] += 1 return memory_cache[key] try: async with aiofiles.open(path, "r", encoding="utf-8") as f: content = await f.read() data = json.loads(content) memory_cache[key] = data _dirty[key] = False metrics["file_reads"] += 1 return data except Exception as e: logger.warning(f"Erreur lors du chargement de la mémoire pour {user_id} (guild {guild_id}): {e}") # Si fichier corrompu, on sauvegarde une copie et on recrée backup = path + ".corrompu." + datetime.now(timezone.utc).isoformat() try: os.rename(path, backup) except Exception: pass return {"resume_global": "", "historique_recent": []} async def save_user_memory_async(user_id: int | str, data: Dict[str, Any], guild_id: str | None = None, persist_immediately: bool = False) -> None: key = f"{guild_id or ''}_{user_id}" memory_cache[key] = data _dirty[key] = True # If immediate persist requested, write now, else schedule background persist if persist_immediately: await _background_persist(user_id, guild_id) else: # Schedule a background persist with small delay to group writes async def _delayed(): await asyncio.sleep(0.3) await _background_persist(user_id, guild_id) asyncio.create_task(_delayed()) logger.debug(f"Mémoire marquée dirty pour utilisateur {user_id} (guild {guild_id})") async def summarize_messages(messages: List[Dict[str, Any]]) -> str: """ Fonction de résumé améliorée. Utilise une extraction de mots-clés et de phrases clés pour un résumé plus intelligent. Placeholder pour intégration IA future. Cette fonction est `async` pour faciliter un remplacement par un appel réseau/IA. """ if not messages: return "" # Extraire tous les textes utilisateur et bot all_texts = [] for m in messages: um = m.get("user_message", "") bm = m.get("bot_message", "") all_texts.append(um + " " + bm) combined_text = " ".join(all_texts) # Extraction simple de mots-clés (mots fréquents, >3 chars) words = re.findall(r'\b\w{4,}\b', combined_text.lower()) word_freq = {} for word in words: word_freq[word] = word_freq.get(word, 0) + 1 # Top 10 mots-clés top_keywords = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:10] keywords_str = ", ".join([kw for kw, _ in top_keywords]) # Extraire phrases clés (premières phrases des messages importants) key_phrases = [] for m in messages: um = m.get("user_message", "").strip() bm = m.get("bot_message", "").strip() if um: sentences = re.split(r'[.!?]', um)[:1] # première phrase key_phrases.extend(sentences) if bm: sentences = re.split(r'[.!?]', bm)[:1] key_phrases.extend(sentences) phrases_str = " | ".join(key_phrases[:5]) # limiter à 5 phrases # Construire le résumé summary = f"Mots-clés: {keywords_str}\nPhrases clés: {phrases_str}" # Tronquer si trop long if len(summary) > 1500: summary = summary[:1497] + "..." await asyncio.sleep(0) # point d'await pour remplacer par un vrai appel return summary async def _maybe_summarize(user_id: int | str, data: Dict[str, Any]) -> Dict[str, Any]: """Si historique_recent dépasse MAX_RECENT, on en résume une partie dans resume_global.""" hist: List[Dict[str, Any]] = data.get("historique_recent", []) if len(hist) <= MAX_RECENT: return data # On prend les plus anciennes entrées à résumer to_summarize = hist[: len(hist) - KEEP_AFTER_SUMMARIZE] remaining = hist[len(hist) - KEEP_AFTER_SUMMARIZE :] summary_text = await summarize_messages(to_summarize) # Concatène proprement le résumé au resume_global existing = data.get("resume_global", "") if existing: new_resume = existing + "\n\n" + summary_text else: new_resume = summary_text # tronquer si trop long if len(new_resume) > 20000: new_resume = new_resume[-20000:] data["resume_global"] = new_resume data["historique_recent"] = remaining metrics["total_summaries"] += 1 logger.info(f"Résumé effectué pour utilisateur {user_id}: {len(to_summarize)} messages résumés") return data async def add_interaction( user_id: int | str, channel_id: int | str, user_message: str, bot_message: str, guild_id: str | None = None, timestamp: Optional[str] = None, ) -> None: """Ajoute une interaction à la mémoire utilisateur et sauvegarde. timestamp : isoformat UTC string ; si non fourni, généré automatiquement. """ if timestamp is None: timestamp = datetime.now(timezone.utc).isoformat() data = await load_user_memory_async(user_id, guild_id) hist = data.get("historique_recent", []) hist.append( { "timestamp": timestamp, "channel_id": int(channel_id) if channel_id is not None else None, "user_message": user_message, "bot_message": bot_message, } ) data["historique_recent"] = hist # éventuellement résumer en arrière-plan data = await _maybe_summarize(user_id, data) # Sauvegarde asynchrone (mise en cache + flush en arrière-plan) await save_user_memory_async(user_id, data, guild_id) async def build_context_for_model( user_id: int | str, max_recent: int = 12, include_resume: bool = True, guild_id: str | None = None, ) -> str: """Construit la portion de prompt à passer au modèle, en respectant la mémoire. - resume_global (optionnel) en tête - derniers messages récents (jusqu'à max_recent) Retour : chaîne prête à concaténer avec le prompt courant. """ data = await load_user_memory_async(user_id, guild_id) parts: List[str] = [] if include_resume: resume = data.get("resume_global", "") if resume: parts.append("Contexte (résumé des conversations précédentes) :\n" + resume) recent = data.get("historique_recent", []) # On prend les N derniers recent_tail = recent[-max_recent:] if recent else [] if recent_tail: parts.append("Historique de la conversation :") for m in recent_tail: um = m.get("user_message", "").replace("\n", " ") bm = m.get("bot_message", "").replace("\n", " ") parts.append(f"Utilisateur: {um}") parts.append(f"Bot: {bm}") # Contrainte : ne pas renvoyer un prompt de plus de ~6000 caractères (ajustable) joined = "\n".join(parts) if len(joined) > 6000: return joined[-6000:] return joined async def get_structured_history( user_id: int | str, max_recent: int = 12, guild_id: str | None = None, ) -> tuple[str, List[Dict[str, str]]]: """Retourne le résumé global et l'historique structuré (utile pour l'API Chat).""" data = await load_user_memory_async(user_id, guild_id) resume = data.get("resume_global", "") recent = data.get("historique_recent", []) recent_tail = recent[-max_recent:] if recent else [] structured = [] import datetime for m in recent_tail: um = m.get("user_message", "").strip() bm = m.get("bot_message", "").strip() ts = m.get("timestamp") time_prefix = "" if ts: try: time_str = datetime.datetime.fromtimestamp(float(ts)).strftime("%H:%M:%S") time_prefix = f"[{time_str}] " except (ValueError, TypeError): pass if um: structured.append({"role": "user", "content": f"{time_prefix}{um}"}) if bm: structured.append({"role": "assistant", "content": f"{time_prefix}{bm}"}) return resume, structured async def purge_old_memories(days: int = 30) -> int: """ Supprime les fichiers de mémoire plus vieux que X jours pour conformité RGPD. Retourne le nombre de fichiers supprimés. """ _ensure_memory_dir() now = time.time() cutoff = now - (days * 24 * 3600) purged_count = 0 # Parcourir récursivement si guilds, sinon juste MEMORY_DIR for root, dirs, files in os.walk(MEMORY_DIR): for name in files: if name.endswith(".json"): path = os.path.join(root, name) try: mtime = os.path.getmtime(path) if mtime < cutoff: os.remove(path) purged_count += 1 logger.info(f"RGPD: Fichier expiré supprimé: {path}") except Exception as e: logger.error(f"Erreur purge RGPD {path}: {e}") return purged_count # Utilitaires optionnels def list_memory_files() -> List[str]: _ensure_memory_dir() files = [f for f in os.listdir(MEMORY_DIR) if f.endswith(".json")] metrics["memory_files"] = len(files) return files def delete_user_memory(user_id: int | str) -> None: path = _user_filepath(user_id) try: os.remove(path) except FileNotFoundError: pass # Fonction pour obtenir les métriques def get_metrics() -> Dict[str, Any]: return metrics.copy() async def flush_all() -> None: """Persist all dirty cached memories to disk (used at shutdown).""" keys = [k for k, v in _dirty.items() if v] if not keys: return tasks = [] for key in keys: # Parse key: "guild_user" or "_user" for None guild if '_' in key: guild_str, user_str = key.split('_', 1) guild_id = guild_str if guild_str else None else: guild_id = None user_str = key tasks.append(_background_persist(user_str, guild_id)) await asyncio.gather(*tasks, return_exceptions=True) # Tests unitaires simples def run_tests(): """Exécute des tests basiques pour vérifier le fonctionnement.""" import tempfile import shutil # Créer un répertoire temporaire pour les tests test_dir = tempfile.mkdtemp() global MEMORY_DIR original_dir = MEMORY_DIR MEMORY_DIR = test_dir try: # Test load/save (async) uid = "test_user" data = {"resume_global": "test", "historique_recent": []} import asyncio as _asyncio _asyncio.run(save_user_memory_async(uid, data, persist_immediately=True)) loaded = _asyncio.run(load_user_memory_async(uid)) assert loaded == data, "Load/save failed" # Test add_interaction import asyncio as _asyncio _asyncio.run(add_interaction(uid, 123, "Hello", "Hi")) loaded = _asyncio.run(load_user_memory_async(uid)) assert len(loaded["historique_recent"]) == 1, "Add interaction failed" # Test build_context ctx = _asyncio.run(build_context_for_model(uid)) assert "Hello" in ctx, "Build context failed" print("Tous les tests passent !") finally: # Nettoyer shutil.rmtree(test_dir) MEMORY_DIR = original_dir # Si tu veux tester vite : if __name__ == "__main__": import asyncio async def demo(): uid = 123456 await add_interaction(uid, 111, "Salut, tu peux modérer ?", "Oui, quel canal ?") await add_interaction(uid, 111, "Le canal #raids", "Ok je surveille") ctx = await build_context_for_model(uid) print("Contexte construit:") print(ctx) print("\nMétriques:", get_metrics()) asyncio.run(demo()) run_tests()