浏览代码

Mémoire : désactiver sa mémoire ne peut plus vider le fichier général

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 天之前
父节点
当前提交
cf3042f844
共有 4 个文件被更改,包括 46 次插入 和 8 次删除
  1. 1 1
      README.md
  2. 16 5
      bot/memory.py
  3. 6 0
      changelog.txt
  4. 23 2
      tests/test_memory.py

+ 1 - 1
README.md

@@ -38,7 +38,7 @@ Avec `MEMORY=on` :
 
 - **Ce qui est retenu** : goûts, projets, habitudes, y compris ce que les autres membres racontent. Jamais de confidences ni de jugements ; une information sensible (santé, religion, opinions, adresse, argent…) seulement si le membre la donne lui-même, et le bot ne l'évoque ensuite qu'avec lui. Il se sert de ce qu'il sait dans la conversation, mais ne récite pas la fiche d'un membre aux autres.
 - **Présentation** : à son premier message, chaque membre reçoit une seule fois un message privé qui lui présente la mémoire et `/memoire`. Si ses messages privés sont fermés, le message est posté dans le salon et s'efface au bout d'une minute. Rien n'est noté sur un membre avant qu'il ait reçu cette présentation.
-- **`/memoire`** : chacun voit la fiche que le bot tient sur lui, visible par lui seul. Il peut la modifier dans une fenêtre déjà remplie, tout effacer (après confirmation) ou désactiver la mémoire : sa fiche et les lignes du fichier général qui le citent sont alors effacées, et plus rien n'est retenu sur lui, jusqu'à ce qu'il la réactive. Les administrateurs voient, modifient et effacent la fiche de n'importe quel membre avec `/memoire membre:@…` ; seul le membre lui-même peut désactiver la mémoire.
+- **`/memoire`** : chacun voit la fiche que le bot tient sur lui, visible par lui seul. Il peut la modifier dans une fenêtre déjà remplie, tout effacer (après confirmation) ou désactiver la mémoire : sa fiche et les lignes du fichier général qui citent un de ses noms Discord sont alors effacées (au-delà de 3 lignes, aucune n'est retirée et le log le signale à l'administrateur), et plus rien n'est retenu sur lui, jusqu'à ce qu'il la réactive. Les administrateurs voient, modifient et effacent la fiche de n'importe quel membre avec `/memoire membre:@…` ; seul le membre lui-même peut désactiver la mémoire.
 - **`/memoire-serveur`** : le fichier général du serveur, à voir, modifier ou effacer. Réservé aux administrateurs.
 
 ### Modération

+ 16 - 5
bot/memory.py

@@ -32,6 +32,9 @@ NOTE_TAGS = re.compile(r"<\s*/?\s*(liste_des_membres|fichier_general)\b[^>]*>",
 # Noms plus courts ignorés pour retirer du fichier général les lignes qui citent un membre (forget) :
 # « Al » ou « Jo » retireraient des lignes sans rapport avec lui.
 MIN_CITED_NAME = 3
+# Au-delà, forget ne retire aucune ligne du fichier général et le signale à l'administrateur : un pseudo
+# qui est un mot courant (« Soleil ») viderait le fichier de lignes sans rapport avec le membre.
+MAX_FORGOTTEN_LINES = 3
 # Fiches jointes au plus à un message : un message qui mentionne 90 membres n'en joint pas 90.
 MAX_ATTACHED_NOTES = 5
 
@@ -308,16 +311,18 @@ 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, puis désactive la mémoire (by : son nom, pour les logs). Renvoie un message
-        d'erreur, ou None.
+        un de ses noms Discord, puis désactive la mémoire (by : son nom, pour les logs). Renvoie un
+        message d'erreur, ou None.
+
+        Les surnoms de la fiche ne comptent pas : le membre les écrit lui-même avec /memoire, sans que
+        personne le voie (« Surnoms : les, est, une » viderait le fichier). Plus de MAX_FORGOTTEN_LINES
+        lignes citées : aucune n'est retirée, l'administrateur vérifie.
 
         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"]
+            names = [entry[key] for key in NAME_KEYS if entry[key]] + entry["former_names"] if entry is not None else []
             names = sorted({n for n in names if len(n) >= MIN_CITED_NAME}, key=len, reverse=True)
             # Mot entier : « Bob » ne retire pas une ligne sur « Bobine ».
             cites = (re.compile(r"(?<!\w)(?:" + "|".join(map(re.escape, names)) + r")(?!\w)", re.IGNORECASE)
@@ -329,6 +334,12 @@ class MemoryStore:
                 self._report(SERVER_FILE, exc)
                 return "Effacement impossible pour l'instant, à cause d'une erreur du bot."
             kept = [line for line in server if cites is None or not cites.search(line)]
+            if len(server) - len(kept) > MAX_FORGOTTEN_LINES:
+                # Les noms trouvés seulement, pas les lignes : l'administrateur les retrouve dans le fichier.
+                found = sorted({m.group(0) for line in server for m in cites.finditer(line)}, key=str.casefold)
+                logger.warning("Mémoire : %d lignes de %s citent %s (%s) : aucune retirée, à vérifier avec "
+                               "/memoire-serveur.", len(server) - len(kept), SERVER_FILE, name, ", ".join(found))
+                kept = server
             try:
                 (self.root / name).unlink(missing_ok=True)
                 self._indexed(name, "")

+ 6 - 0
changelog.txt

@@ -75,6 +75,12 @@ Corrections
   sont regroupées. Avant, elles étaient ignorées sans rien dire.
 - Mémoire : le chemin « memories/membres/… » (sans « / » au début) est
   accepté par l'outil, comme « /memories/membres/… ».
+- Mémoire : en désactivant la mémoire, un membre ne peut plus vider le
+  fichier général. Seuls ses noms Discord servent à retrouver les
+  lignes qui le citent, plus les surnoms de sa fiche, qu'il écrit
+  lui-même. Si plus de 3 lignes le citent (pseudo qui est un mot
+  courant, comme « Soleil »), aucune n'est retirée et le log demande à
+  l'administrateur de vérifier avec /memoire-serveur.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 23 - 2
tests/test_memory.py

@@ -6,7 +6,8 @@ import unittest
 from pathlib import Path
 
 from bot.members import MemberRegistry, with_username
-from bot.memory import MAX_ATTACHED_NOTES, MAX_NICKNAME_LENGTH, MAX_NICKNAMES, SERVER_FILE, MemoryStore
+from bot.memory import (MAX_ATTACHED_NOTES, MAX_FORGOTTEN_LINES, MAX_NICKNAME_LENGTH, MAX_NICKNAMES,
+                        SERVER_FILE, MemoryStore)
 
 MEMBER_ID = 111
 
@@ -257,13 +258,33 @@ class ForgetTest(MemoryTestCase):
             "soirée jeux le vendredi",
             "Bob : adore les lasagnes (d'après Alice)",
             "BOBBY a gagné le tournoi",
-            "le grand b ramène les chips",
             "concours de bobines en mai",
         ]))
         self.assertIsNone(await self.store.forget(MEMBER_ID, by="bob"))
         self.assertFalse((self.root / "memories" / "membres" / f"{MEMBER_ID}.md").exists())
         self.assertEqual(self.server_file(), "soirée jeux le vendredi\nconcours de bobines en mai")
 
+    async def test_file_nicknames_ignored(self):
+        # Le membre écrit lui-même ses surnoms avec /memoire : ils ne servent pas au nettoyage.
+        self.write(f"membres/{MEMBER_ID}.md", "Surnoms : les, est, une")
+        self.write(SERVER_FILE, "soirée jeux tous les vendredis\nla blague du canard est récurrente")
+        await self.store.forget(MEMBER_ID, by="bob")
+        self.assertEqual(self.server_file(), "soirée jeux tous les vendredis\nla blague du canard est récurrente")
+
+    async def test_too_many_lines_nothing_removed(self):
+        await self.members.see(MEMBER_ID, "soleil", "Soleil")
+        lines = [f"le soleil se lève à {hour} h" for hour in range(5, 5 + MAX_FORGOTTEN_LINES + 1)]
+        self.write(SERVER_FILE, "\n".join(lines))
+        self.write(f"membres/{MEMBER_ID}.md", "habite à Lyon")
+        with self.assertLogs("chatbot.memory", "WARNING") as logs:
+            self.assertIsNone(await self.store.forget(MEMBER_ID, by="soleil"))
+        self.assertEqual(self.server_file(), "\n".join(lines))
+        self.assertTrue(any("aucune retirée" in line and "soleil" in line for line in logs.output))
+        self.assertFalse(any("se lève" in line for line in logs.output))
+        # Le reste de la désactivation a lieu.
+        self.assertFalse((self.root / "memories" / "membres" / f"{MEMBER_ID}.md").exists())
+        self.assertFalse(self.members.memory_enabled(MEMBER_ID))
+
     async def test_short_names_ignored(self):
         await self.members.see(MEMBER_ID, "jo", None)
         self.write(SERVER_FILE, "Jo et Max au cinéma")