Browse Source

Mémoire : fiches lues une fois au démarrage, plus à chaque message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 ngày trước cách đây
mục cha
commit
83141fef75
5 tập tin đã thay đổi với 67 bổ sung và 6 xóa
  1. 1 1
      README.md
  2. 3 0
      bot/discord_bot.py
  3. 29 5
      bot/memory.py
  4. 6 0
      changelog.txt
  5. 28 0
      tests/test_memory.py

+ 1 - 1
README.md

@@ -160,7 +160,7 @@ Le fichier `bot.log` (`LOG_FILE`) garde l'activité du bot, avec pour chaque app
 | `presence_state.json` | Statut Discord en cours, repris après un redémarrage |
 | `slash_commands.sha256` | Empreinte des commandes slash, pour ne les envoyer à Discord que quand elles changent |
 | `memories/registre.json` | Avec `MEMORY=on` : membres vus par le bot, leurs noms et anciens pseudos, et leurs réglages de la mémoire. Si le fichier est illisible, il est mis de côté sous un nom daté |
-| `memories/membres/<id>.md`, `memories/serveur.md` | Avec `MEMORY=on` : fiche de chaque membre et fichier général du serveur, tenus par Claude, en UTF-8. Un fichier illisible (enregistré dans un autre encodage, par exemple) est signalé dans le log : le bot continue de répondre, mais n'y écrit plus rien tant qu'il n'est pas corrigé |
+| `memories/membres/<id>.md`, `memories/serveur.md` | Avec `MEMORY=on` : fiche de chaque membre et fichier général du serveur, tenus par Claude, en UTF-8. Un fichier illisible (enregistré dans un autre encodage, par exemple) est signalé dans le log : le bot continue de répondre, mais n'y écrit plus rien tant qu'il n'est pas corrigé. Après une modification directe sur le disque, redémarre le bot pour que la liste des membres (fiches existantes et surnoms) en tienne compte ; `/memoire` n'a pas besoin de redémarrage |
 | `bot.log` | Logs (5 Mo par fichier, 2 anciens fichiers gardés) |
 
 Le détail de chaque version est dans `changelog.txt`.

+ 3 - 0
bot/discord_bot.py

@@ -157,6 +157,9 @@ class ChatBot(commands.Bot):
         self.tree.add_command(memory_command)
         self.tree.add_command(server_memory_command)
         await self.sync_commands()
+        if self.memory:
+            # Lecture de toutes les fiches, avant la connexion : elle ne bloque aucun message.
+            await asyncio.to_thread(self.memory.index)
         s = self.settings
         logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s, mémoire des membres : %s",
                     s.model, "on" if s.thinking else "off", s.effort or "par défaut",

+ 29 - 5
bot/memory.py

@@ -76,6 +76,11 @@ class MemoryStore:
         self.server_limit = server_limit
         self._lock = asyncio.Lock()  # deux réponses en parallèle ne modifient pas un fichier en même temps
         self._unreadable: set[str] = set()  # fichiers illisibles déjà signalés dans le log
+        # Membres dont la fiche n'est pas vide, avec leurs surnoms : lu une fois sur le disque, puis tenu
+        # à jour à chaque écriture (toutes passent par ici). Sans lui, la liste des membres relirait
+        # toutes les fiches à chaque message, en bloquant le bot. Une fiche modifiée directement sur
+        # le disque n'y est prise en compte qu'au redémarrage.
+        self._index: dict[str, list[str]] | None = None
 
     # ------------------------------------------------------------------ accès
 
@@ -118,6 +123,17 @@ class MemoryStore:
         names = [name.strip() for name in re.split(r"[,;]", match.group(1)) if name.strip()]
         return [name for name in names if len(name) <= MAX_NICKNAME_LENGTH][:MAX_NICKNAMES]
 
+    def index(self) -> dict[str, list[str]]:
+        """Identifiants des membres dont la fiche n'est pas vide, avec leurs surnoms (voir _index)."""
+        if self._index is None:
+            self._index = {}
+            directory = self.root / "membres"
+            for path in directory.glob("*.md") if directory.is_dir() else []:
+                name = f"membres/{path.name}"
+                if MEMBER_FILE.match(name):
+                    self._indexed(name, self.read(name))
+        return self._index
+
     def system_context(self) -> str:
         """Liste des membres qui ont une fiche (tous leurs noms et le chemin de leur fiche) et fichier
         général, pour le prompt système. Les autres membres n'y sont pas : la liste ne grandit qu'avec
@@ -127,15 +143,12 @@ class MemoryStore:
         for member_id, entry in self.members.members().items():
             # Mémoire désactivée : Rika ne doit rien noter sur ce membre. Pas encore prévenu (membre
             # seulement mentionné) : pas de fiche.
-            if not entry["memory"] or not entry["notified"]:
-                continue
-            content = self.read(self.member_file(member_id))
-            if not content:
+            if not entry["memory"] or not entry["notified"] or member_id not in self.index():
                 continue
             name = display_name(entry)
             # Sans doublon, majuscules comprises (« penta » et « Penta » : un seul nom).
             others: dict[str, str] = {name.casefold(): name}
-            for other in [entry[key] for key in NAME_KEYS] + self._nicknames(content):
+            for other in [entry[key] for key in NAME_KEYS] + self.index()[member_id]:
                 if other:
                     others.setdefault(other.casefold(), other)
             others = list(others.values())[1:]
@@ -249,6 +262,7 @@ class MemoryStore:
                 return ToolResult(f"Aucun changement : {name} contenait déjà cela.")
             try:
                 await asyncio.to_thread(self._write_atomic, self.root / name, after)
+                self._indexed(name, after)
             except OSError as exc:
                 logger.error("Mémoire : impossible d'écrire %s : %s", name, exc)
                 return ToolResult("Écriture impossible pour l'instant, à cause d'une erreur du bot.", is_error=True)
@@ -280,6 +294,7 @@ class MemoryStore:
                     await asyncio.to_thread(self._write_atomic, path, text)
                 else:
                     path.unlink(missing_ok=True)
+                self._indexed(name, text)
             except OSError as exc:
                 logger.error("Mémoire : impossible de modifier %s : %s", name, exc)
                 return "Modification impossible pour l'instant, à cause d'une erreur du bot."
@@ -311,6 +326,7 @@ class MemoryStore:
             kept = [line for line in server if cites is None or not cites.search(line)]
             try:
                 (self.root / name).unlink(missing_ok=True)
+                self._indexed(name, "")
                 if len(kept) < len(server):
                     if kept:
                         await asyncio.to_thread(self._write_atomic, self.root / SERVER_FILE, "\n".join(kept))
@@ -335,6 +351,14 @@ class MemoryStore:
         except FileNotFoundError:
             return ""
 
+    def _indexed(self, name: str, text: str) -> None:
+        """Met à jour l'index après l'écriture (text) ou la suppression (text vide) d'un fichier."""
+        if self._index is not None and (match := MEMBER_FILE.match(name)):
+            if text:
+                self._index[match.group(1)] = self._nicknames(text)
+            else:
+                self._index.pop(match.group(1), None)
+
     def _report(self, name: str, exc: Exception) -> None:
         """Signale un fichier illisible, une seule fois tant qu'il le reste (il est relu à chaque message)."""
         if name not in self._unreadable:

+ 6 - 0
changelog.txt

@@ -49,6 +49,12 @@ Corrections
   « � » à la place des caractères invalides, et n'est plus modifié
   tant qu'il n'est pas corrigé. Une fiche enregistrée avec BOM
   (Bloc-notes) garde sa ligne « Surnoms ».
+- Le bot ne relit plus toutes les fiches à chaque message pour dresser
+  la liste des membres : elles sont lues une fois au démarrage, puis
+  suivies à chaque note. Avec 500 membres, chaque message bloquait le
+  bot près d'une seconde (mesuré sous Windows). Une fiche modifiée
+  directement sur le disque n'est prise en compte dans la liste qu'au
+  redémarrage.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 28 - 0
tests/test_memory.py

@@ -139,6 +139,34 @@ class SizeLimitsTest(MemoryTestCase):
         self.assertIn("membre210 : membres/210.md", notes)
 
 
+class IndexTest(MemoryTestCase):
+    """La liste des membres ne relit pas les fiches à chaque message, mais suit chaque écriture."""
+
+    async def test_only_server_file_read_once_indexed(self):
+        self.write(f"membres/{MEMBER_ID}.md", "Surnoms : Bobby")
+        self.store.system_context()
+        read = []
+        original = self.store.read
+        self.store.read = lambda name: read.append(name) or original(name)
+        self.assertIn("Bobby", self.store.system_context())
+        self.assertEqual(read, [SERVER_FILE])
+
+    async def test_follows_writes(self):
+        fiche = f"membres/{MEMBER_ID}.md"
+        self.assertNotIn(fiche, self.store.system_context())
+        await self.tool(fichier=fiche, texte="Surnoms : Bobby")
+        self.assertIn("Bobby", self.store.system_context())
+        await self.tool(commande="remplacer", fichier=fiche, ancien="Bobby", texte="le Grand B")
+        self.assertIn("le Grand B", self.store.system_context())
+        self.assertIsNone(await self.store.overwrite(fiche, "", by="admin"))
+        self.assertNotIn(fiche, self.store.system_context())
+        self.assertIsNone(await self.store.overwrite(fiche, "habite à Lyon", by="admin"))
+        self.assertIn(fiche, self.store.system_context())
+        await self.store.forget(MEMBER_ID, by="bob")
+        await self.members.set_memory(MEMBER_ID, True)
+        self.assertNotIn(fiche, self.store.system_context())
+
+
 class UnreadableFileTest(MemoryTestCase):
     """Un fichier illisible ne bloque pas les réponses, et n'est pas écrasé par une note."""