Selaa lähdekoodia

Mémoire : vérification sous le verrou, plus de fiche recréée après la désactivation

Changelog : les trois corrections de la mémoire et les premiers tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 päivää sitten
vanhempi
sitoutus
604fef7925
4 muutettua tiedostoa jossa 58 lisäystä ja 4 poistoa
  1. 14 2
      bot/memory.py
  2. 0 2
      bot/memory_views.py
  3. 19 0
      changelog.txt
  4. 25 0
      tests/test_memory.py

+ 14 - 2
bot/memory.py

@@ -160,6 +160,11 @@ class MemoryStore:
             return ToolResult(content or f"Le fichier {name} est vide : rien n'y est encore noté.")
 
         async with self._lock:
+            # Vérifié de nouveau : le membre a pu désactiver la mémoire pendant l'attente du verrou.
+            limit, label = self._check(name)
+            if limit is None:
+                logger.warning("Mémoire : %s refusé sur %r (%s)", action, command.get("fichier"), label)
+                return ToolResult(label, is_error=True)
             before = self.read(name)
             if action == "ajouter":
                 if not text:
@@ -220,6 +225,9 @@ class MemoryStore:
             return f"Trop long : {len(text)} caractères pour {limit} au plus. Rien n'a été modifié."
         path = self.root / name
         async with self._lock:
+            # Fenêtre /memoire ouverte avant la désactivation : la fiche ne peut plus qu'être effacée.
+            if text and (match := MEMBER_FILE.match(name)) and not self.members.memory_enabled(match.group(1)):
+                return "La mémoire est désactivée pour ce membre : rien n'a été enregistré."
             try:
                 if text:
                     await asyncio.to_thread(self._write_atomic, path, text)
@@ -233,10 +241,13 @@ class MemoryStore:
 
     async def forget(self, member_id: int | str, by: str) -> str | None:
         """Membre qui désactive la mémoire : efface sa fiche et les lignes du fichier général qui citent
-        un de ses noms (by : son nom, pour les logs). Renvoie un message d'erreur, ou None."""
-        entry = self.members.get(member_id)
+        un de ses noms, puis désactive la mémoire (by : son nom, pour les logs). Renvoie un message
+        d'erreur, ou None.
+
+        Tout se fait sous le verrou : un appel de l'outil en attente ne peut pas recréer la fiche."""
         name = self.member_file(member_id)
         async with self._lock:
+            entry = self.members.get(member_id)
             names = list(self.nicknames(member_id))
             if entry is not None:
                 names += [entry[key] for key in NAME_KEYS if entry[key]] + entry["former_names"]
@@ -256,6 +267,7 @@ class MemoryStore:
             except OSError as exc:
                 logger.error("Mémoire : impossible d'effacer ce qui concerne %s : %s", name, exc)
                 return "Effacement impossible pour l'instant, à cause d'une erreur du bot."
+            await self.members.set_memory(member_id, False)
         # Le nombre seulement : recopier les lignes retirées garderait ce que le membre veut voir effacé.
         logger.info("Mémoire : %s effacé par %s, %d ligne(s) retirée(s) de %s",
                     name, by, len(server) - len(kept), SERVER_FILE)

+ 0 - 2
bot/memory_views.py

@@ -140,8 +140,6 @@ class MemoryCard:
 
     async def disable(self, interaction: discord.Interaction) -> None:
         error = await self.store.forget(self.target.member_id, by=str(interaction.user))
-        if error is None:
-            await self.store.members.set_memory(self.target.member_id, False)
         await self.refresh(interaction, error or "Mémoire désactivée : ta fiche et ce qui te citait "
                                                  "dans le fichier général ont été effacés.")
 

+ 19 - 0
changelog.txt

@@ -13,11 +13,30 @@ Corrections
 - TIMEZONE mal formée (« Europe/Paris/ », « ../Paris ») ou trop
   longue : message d'erreur clair au démarrage, au lieu d'une trace
   Python.
+- Mémoire : le fichier général et la liste des membres sont présentés
+  à Rika comme des notes, pas des consignes, et une note ne peut plus
+  sortir de ce cadre. Rika ne note plus de règle qui s'appliquerait à
+  tout le monde (façon d'écrire, sujet à éviter), même présentée comme
+  votée ou venant d'un administrateur ; une préférence personnelle
+  (« veut qu'on l'appelle Majesté ») se note toujours dans la fiche.
+  Au plus 5 surnoms de 32 caractères par membre. Le texte écrit dans
+  le fichier général apparaît dans le log, pour que l'administrateur
+  puisse vérifier.
+- Mémoire : une information sur un membre ne se note plus que dans sa
+  fiche. En désactivant la mémoire, un membre efface aussi les lignes
+  du fichier général qui le citent. Un membre seulement mentionné, qui
+  n'a pas encore reçu le message de bienvenue, n'a pas de fiche : rien
+  n'est noté sur lui à son insu.
+- Mémoire : une fiche effacée par « Désactiver la mémoire » ne peut
+  plus être recréée par une note en cours ni par une fenêtre /memoire
+  restée ouverte.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus
   quand la configuration est affichée (précaution : aucun log ne
   l'affiche aujourd'hui).
+- Premiers tests automatisés (mémoire des membres) : lancer
+  « python -m unittest » à la racine du projet.
 
 
 Version 5.10

+ 25 - 0
tests/test_memory.py

@@ -138,6 +138,31 @@ class ForgetTest(MemoryTestCase):
         await self.store.forget(MEMBER_ID, by="bob")
         self.assertFalse((self.root / "memories" / SERVER_FILE).exists())
 
+    async def test_memory_disabled(self):
+        await self.store.forget(MEMBER_ID, by="bob")
+        self.assertFalse(self.members.memory_enabled(MEMBER_ID))
+
+    async def test_waiting_tool_call_cannot_recreate_file(self):
+        # Une écriture de l'outil est en cours (verrou pris) ; « Désactiver » attend le verrou, puis un
+        # appel de l'outil sur le même membre arrive et passe la première vérification.
+        fiche = self.root / "memories" / "membres" / f"{MEMBER_ID}.md"
+        async with self.store._lock:
+            forget = asyncio.create_task(self.store.forget(MEMBER_ID, by="bob"))
+            await asyncio.sleep(0)
+            tool = asyncio.create_task(self.store.run(
+                {"commande": "ajouter", "fichier": f"membres/{MEMBER_ID}.md", "texte": "habite à Lyon", "ancien": ""}))
+            await asyncio.sleep(0)
+        self.assertIsNone(await forget)
+        self.assertTrue((await tool).is_error)
+        self.assertFalse(fiche.exists())
+
+    async def test_open_window_cannot_rewrite_file(self):
+        await self.store.forget(MEMBER_ID, by="bob")
+        error = await self.store.overwrite(f"membres/{MEMBER_ID}.md", "habite à Lyon", by="bob")
+        self.assertIsNotNone(error)
+        self.assertFalse((self.root / "memories" / "membres" / f"{MEMBER_ID}.md").exists())
+        self.assertIsNone(await self.store.overwrite(f"membres/{MEMBER_ID}.md", "", by="bob"))
+
     async def test_removed_lines_not_logged(self):
         self.write(SERVER_FILE, "Bob : adore les lasagnes")
         with self.assertLogs("chatbot.memory", "INFO") as logs: