Просмотр исходного кода

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 дней назад
Родитель
Сommit
83141fef75
5 измененных файлов с 67 добавлено и 6 удалено
  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 |
 | `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 |
 | `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/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) |
 | `bot.log` | Logs (5 Mo par fichier, 2 anciens fichiers gardés) |
 
 
 Le détail de chaque version est dans `changelog.txt`.
 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(memory_command)
         self.tree.add_command(server_memory_command)
         self.tree.add_command(server_memory_command)
         await self.sync_commands()
         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
         s = self.settings
         logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s, mémoire des membres : %s",
         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",
                     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.server_limit = server_limit
         self._lock = asyncio.Lock()  # deux réponses en parallèle ne modifient pas un fichier en même temps
         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
         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
     # ------------------------------------------------------------------ accès
 
 
@@ -118,6 +123,17 @@ class MemoryStore:
         names = [name.strip() for name in re.split(r"[,;]", match.group(1)) if name.strip()]
         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]
         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:
     def system_context(self) -> str:
         """Liste des membres qui ont une fiche (tous leurs noms et le chemin de leur fiche) et fichier
         """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
         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():
         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
             # Mémoire désactivée : Rika ne doit rien noter sur ce membre. Pas encore prévenu (membre
             # seulement mentionné) : pas de fiche.
             # 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
                 continue
             name = display_name(entry)
             name = display_name(entry)
             # Sans doublon, majuscules comprises (« penta » et « Penta » : un seul nom).
             # Sans doublon, majuscules comprises (« penta » et « Penta » : un seul nom).
             others: dict[str, str] = {name.casefold(): name}
             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:
                 if other:
                     others.setdefault(other.casefold(), other)
                     others.setdefault(other.casefold(), other)
             others = list(others.values())[1:]
             others = list(others.values())[1:]
@@ -249,6 +262,7 @@ class MemoryStore:
                 return ToolResult(f"Aucun changement : {name} contenait déjà cela.")
                 return ToolResult(f"Aucun changement : {name} contenait déjà cela.")
             try:
             try:
                 await asyncio.to_thread(self._write_atomic, self.root / name, after)
                 await asyncio.to_thread(self._write_atomic, self.root / name, after)
+                self._indexed(name, after)
             except OSError as exc:
             except OSError as exc:
                 logger.error("Mémoire : impossible d'écrire %s : %s", name, 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)
                 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)
                     await asyncio.to_thread(self._write_atomic, path, text)
                 else:
                 else:
                     path.unlink(missing_ok=True)
                     path.unlink(missing_ok=True)
+                self._indexed(name, text)
             except OSError as exc:
             except OSError as exc:
                 logger.error("Mémoire : impossible de modifier %s : %s", name, exc)
                 logger.error("Mémoire : impossible de modifier %s : %s", name, exc)
                 return "Modification impossible pour l'instant, à cause d'une erreur du bot."
                 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)]
             kept = [line for line in server if cites is None or not cites.search(line)]
             try:
             try:
                 (self.root / name).unlink(missing_ok=True)
                 (self.root / name).unlink(missing_ok=True)
+                self._indexed(name, "")
                 if len(kept) < len(server):
                 if len(kept) < len(server):
                     if kept:
                     if kept:
                         await asyncio.to_thread(self._write_atomic, self.root / SERVER_FILE, "\n".join(kept))
                         await asyncio.to_thread(self._write_atomic, self.root / SERVER_FILE, "\n".join(kept))
@@ -335,6 +351,14 @@ class MemoryStore:
         except FileNotFoundError:
         except FileNotFoundError:
             return ""
             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:
     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)."""
         """Signale un fichier illisible, une seule fois tant qu'il le reste (il est relu à chaque message)."""
         if name not in self._unreadable:
         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é
   « � » à la place des caractères invalides, et n'est plus modifié
   tant qu'il n'est pas corrigé. Une fiche enregistrée avec BOM
   tant qu'il n'est pas corrigé. Une fiche enregistrée avec BOM
   (Bloc-notes) garde sa ligne « Surnoms ».
   (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
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus
 - 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)
         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):
 class UnreadableFileTest(MemoryTestCase):
     """Un fichier illisible ne bloque pas les réponses, et n'est pas écrasé par une note."""
     """Un fichier illisible ne bloque pas les réponses, et n'est pas écrasé par une note."""