ソースを参照

Mémoire : effacement annoncé tel qu'il est, nom retiré des sources à la désactivation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 日 前
親
コミット
9861e4a68b
5 ファイル変更、125 行追加、14 行削除
  1. 1 1
      README.md
  2. 41 6
      bot/memory.py
  3. 16 7
      bot/memory_views.py
  4. 7 0
      changelog.txt
  5. 60 0
      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 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`** : 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, l'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), son nom est remplacé par « un membre » là où il est cité comme source dans les autres fiches (« d'après … », « selon … »), et plus rien n'est noté sur lui, jusqu'à ce qu'il la réactive. Dans les deux cas, ses derniers messages restent dans l'historique de la conversation. 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

+ 41 - 6
bot/memory.py

@@ -39,6 +39,9 @@ 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
+# Mots qui introduisent la source d'une information (« d'après Toto », voir la consigne), suivis du nom
+# que forget remplace par « un membre » dans les fiches des autres.
+SOURCE_WORDS = r"\b(?:d['’]\s*après|selon)"
 # Signes « = » en début de ligne, retirés du contenu des fiches jointes : une fiche ne peut pas imiter
 # l'en-tête d'une autre (« === Alice : membres/222.md, auteur du message === »).
 NOTE_HEADER = re.compile(r"^[ \t]*[==]+", re.MULTILINE)
@@ -322,8 +325,9 @@ 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 Discord, puis désactive la mémoire (by : son nom, pour les logs). Renvoie un
-        message d'erreur, ou None.
+        un de ses noms Discord, remplace ce nom par « un membre » là où il est cité comme source dans
+        les autres fiches (« d'après Bob »), 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
@@ -335,9 +339,12 @@ class MemoryStore:
             entry = self.members.get(member_id)
             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)
+            alternatives = "|".join(map(re.escape, names))
             # Mot entier : « Bob » ne retire pas une ligne sur « Bobine ».
-            cites = (re.compile(r"(?<!\w)(?:" + "|".join(map(re.escape, names)) + r")(?!\w)", re.IGNORECASE)
-                     if names else None)
+            cites = re.compile(rf"(?<!\w)(?:{alternatives})(?!\w)", re.IGNORECASE) if names else None
+            # Nom d'utilisateur entre parenthèses compris, comme Claude voit les auteurs (« Bob (bob) »).
+            sources = re.compile(rf"({SOURCE_WORDS}\s+)(?:{alternatives})(?!\w)(?:\s*\((?:{alternatives})\))?",
+                                 re.IGNORECASE) if names else None
             try:
                 server = self._read_for_update(SERVER_FILE).splitlines()
             except (OSError, UnicodeDecodeError) as exc:
@@ -359,13 +366,21 @@ class MemoryStore:
                         await asyncio.to_thread(self._write_atomic, self.root / SERVER_FILE, "\n".join(kept))
                     else:
                         (self.root / SERVER_FILE).unlink(missing_ok=True)
+                # Dans un thread : toutes les fiches sont relues, ce qui bloquerait le bot sur un grand serveur.
+                changed, unreadable = (await asyncio.to_thread(self._anonymize_sources, sources)
+                                       if sources else ({}, []))
             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."
+            for other, text in changed.items():
+                self._indexed(other, text)
+            for other, exc in unreadable:
+                self._report(other, exc)
+                logger.warning("Mémoire : %s n'a pas pu être retiré des sources de %s, illisible.", name, other)
             saved = 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)
+        logger.info("Mémoire : %s effacé par %s, %d ligne(s) retirée(s) de %s, nom retiré des sources de "
+                    "%d fiche(s)", name, by, len(server) - len(kept), SERVER_FILE, len(changed))
         if not saved:
             # Effacement fait, désactivation appliquée : seul son enregistrement manque.
             return NOT_SAVED.format(setting="la désactivation")
@@ -381,6 +396,26 @@ class MemoryStore:
         except FileNotFoundError:
             return ""
 
+    def _anonymize_sources(self, sources: re.Pattern) -> tuple[dict[str, str], list[tuple[str, Exception]]]:
+        """Remplace par « un membre » le nom cité comme source dans les fiches (voir forget). Renvoie les
+        fiches modifiées avec leur nouveau texte, et les fiches illisibles, laissées telles quelles."""
+        changed: dict[str, str] = {}
+        unreadable: list[tuple[str, Exception]] = []
+        directory = self.root / "membres"
+        for path in sorted(directory.glob("*.md")) if directory.is_dir() else []:
+            name = f"membres/{path.name}"
+            if not MEMBER_FILE.match(name):
+                continue
+            try:
+                text = self._read_for_update(name)
+            except (OSError, UnicodeDecodeError) as exc:
+                unreadable.append((name, exc))
+                continue
+            if (new := sources.sub(r"\1un membre", text)) != text:
+                self._write_atomic(path, new)
+                changed[name] = new
+        return changed, unreadable
+
     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)):

+ 16 - 7
bot/memory_views.py

@@ -94,8 +94,8 @@ class MemoryCard:
         view = OwnerView(self.owner_id)
         content = self.store.read(self.target.file)
         add_button(view, "Modifier", "✏️", discord.ButtonStyle.primary, self.edit, disabled=not self.memory_enabled)
-        add_button(view, "Tout effacer", "🗑️", discord.ButtonStyle.secondary, self.ask_erase,
-                   disabled=not content or not self.memory_enabled)
+        add_button(view, "Tout effacer" if self.target.member_id is None else "Effacer la fiche", "🗑️",
+                   discord.ButtonStyle.secondary, self.ask_erase, disabled=not content or not self.memory_enabled)
         # Seul le membre lui-même choisit d'activer ou non la mémoire.
         if self.target.member_id is not None and self.target.is_self:
             if self.memory_enabled:
@@ -125,7 +125,14 @@ class MemoryCard:
         await self.refresh(interaction, "C'est enregistré." if text.strip() else "C'est effacé.")
 
     async def ask_erase(self, interaction: discord.Interaction) -> None:
-        question = f"Effacer tout ce que {self.bot_name} a retenu {self.about()} ? C'est définitif."
+        # Seule la fiche est effacée (pas le fichier général, ni la conversation) : la question ne promet pas plus.
+        if self.target.member_id is None:
+            question = "Effacer tout le fichier général du serveur ? C'est définitif."
+        else:
+            whose = "ta fiche" if self.target.is_self else f"la fiche de {self.target.name}"
+            said = "tu as dit" if self.target.is_self else f"{self.target.name} a dit"
+            question = (f"Effacer {whose} ? C'est définitif. Seule la fiche est effacée : ce que {said} "
+                        f"récemment reste dans la conversation, et {self.bot_name} peut encore s'en souvenir.")
         await confirm(interaction, self, question, self.erase)
 
     async def erase(self, interaction: discord.Interaction) -> None:
@@ -134,14 +141,16 @@ class MemoryCard:
 
     async def ask_disable(self, interaction: discord.Interaction) -> None:
         question = (f"Si tu désactives la mémoire, ta fiche et les lignes du fichier général de "
-                    f"{self.bot_name} qui te citent seront définitivement effacées, et plus rien ne sera "
-                    f"retenu sur toi. Tu pourras la réactiver plus tard avec /memoire.")
+                    f"{self.bot_name} qui te citent seront définitivement effacées, et ton nom sera retiré "
+                    f"des sources (« d'après … ») dans les fiches des autres. {self.bot_name} ne notera plus "
+                    f"rien sur toi, mais ce que tu as dit récemment reste dans la conversation. "
+                    f"Tu pourras la réactiver plus tard avec /memoire.")
         await confirm(interaction, self, question, self.disable)
 
     async def disable(self, interaction: discord.Interaction) -> None:
         error = await self.store.forget(self.target.member_id, by=str(interaction.user))
-        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.")
+        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, et ton nom retiré des sources.")
 
     async def enable(self, interaction: discord.Interaction) -> None:
         saved = await self.store.members.set_memory(self.target.member_id, True)

+ 7 - 0
changelog.txt

@@ -99,6 +99,13 @@ Corrections
   aussi ignorés. Le log signale au démarrage les autres serveurs où se
   trouve le bot, et le README explique comment décocher Public Bot et
   User Install dans le portail Discord.
+- /memoire ne promet plus d'effacer plus qu'il ne le fait. Le bouton
+  « Tout effacer » d'une fiche devient « Effacer la fiche », et la
+  confirmation précise que ce qui a été dit récemment reste dans la
+  conversation. « Désactiver la mémoire » ne dit plus que « plus rien
+  ne sera retenu », et remplace maintenant le nom du membre par « un
+  membre » là où il est cité comme source dans les fiches des autres
+  (« d'après Bob », « selon Bob »).
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 60 - 0
tests/test_memory.py

@@ -7,6 +7,7 @@ from pathlib import Path
 from unittest import mock
 
 from bot.members import MemberRegistry, with_username
+from bot.memory_views import MemoryCard, Target
 from bot.memory import (MAX_ATTACHED_NOTES, MAX_FORGOTTEN_LINES, MAX_NICKNAME_LENGTH, MAX_NICKNAMES,
                         SERVER_FILE, MemoryStore)
 
@@ -362,6 +363,36 @@ class ForgetTest(MemoryTestCase):
         self.assertFalse((self.root / "memories" / "membres" / f"{MEMBER_ID}.md").exists())
         self.assertFalse(self.members.memory_enabled(MEMBER_ID))
 
+    async def test_name_removed_from_sources(self):
+        # Bob ne doit plus apparaître comme source dans les fiches des autres ; l'info sur eux reste.
+        await self.members.see(MEMBER_ID, "bob", "Bobby")
+        self.write("membres/222.md", "\n".join([
+            "part à Rome en mai (d'après Bob)",
+            "joue à Zelda, selon Bobby (bob)",
+            "D’après BOB : adore le chocolat",
+            "d'après Bobine : fan de couture",
+            "a battu Bob aux échecs",
+        ]))
+        self.write("membres/333.md", "habite à Lyon")
+        self.assertIsNone(await self.store.forget(MEMBER_ID, by="bob"))
+        self.assertEqual(self.store.read("membres/222.md"), "\n".join([
+            "part à Rome en mai (d'après un membre)",
+            "joue à Zelda, selon un membre",
+            "D’après un membre : adore le chocolat",
+            "d'après Bobine : fan de couture",
+            "a battu Bob aux échecs",
+        ]))
+        self.assertEqual(self.store.read("membres/333.md"), "habite à Lyon")
+
+    async def test_unreadable_file_does_not_block_sources(self):
+        self.write("membres/222.md", "part à Rome (d'après Bob)")
+        (self.root / "memories" / "membres" / "333.md").write_bytes("d'après Bob \xe0 Lyon".encode("latin-1"))
+        with self.assertLogs("chatbot.memory", "WARNING") as logs:
+            self.assertIsNone(await self.store.forget(MEMBER_ID, by="bob"))
+        self.assertEqual(self.store.read("membres/222.md"), "part à Rome (d'après un membre)")
+        self.assertTrue(any("membres/333.md" in line and "sources" in line for line in logs.output))
+        self.assertFalse(self.members.memory_enabled(MEMBER_ID))
+
     async def test_removed_lines_not_logged(self):
         self.write(SERVER_FILE, "Bob : adore les lasagnes")
         with self.assertLogs("chatbot.memory", "INFO") as logs:
@@ -370,5 +401,34 @@ class ForgetTest(MemoryTestCase):
         self.assertTrue(any("1 ligne(s) retirée(s)" in line for line in logs.output))
 
 
+class ConfirmationTextsTest(MemoryTestCase):
+    """Les questions de confirmation de /memoire ne promettent pas plus que ce qui est effacé."""
+
+    async def question(self, target: Target, ask: str) -> str:
+        interaction = mock.Mock()
+        interaction.response.edit_message = mock.AsyncMock()
+        card = MemoryCard(self.store, target, owner_id=MEMBER_ID, bot_name="Rika")
+        await getattr(card, ask)(interaction)
+        return interaction.response.edit_message.call_args.kwargs["embeds"][0].description
+
+    async def test_erase_own_file(self):
+        text = await self.question(Target(f"membres/{MEMBER_ID}.md", MEMBER_ID, "Bob", is_self=True), "ask_erase")
+        self.assertIn("Effacer ta fiche ?", text)
+        self.assertIn("reste dans la conversation", text)
+
+    async def test_erase_other_file(self):
+        text = await self.question(Target("membres/222.md", 222, "Alice"), "ask_erase")
+        self.assertIn("la fiche de Alice", text)
+
+    async def test_erase_server_file(self):
+        text = await self.question(Target(SERVER_FILE, None), "ask_erase")
+        self.assertIn("fichier général", text)
+
+    async def test_disable(self):
+        text = await self.question(Target(f"membres/{MEMBER_ID}.md", MEMBER_ID, "Bob", is_self=True), "ask_disable")
+        self.assertIn("retiré des sources", text)
+        self.assertNotIn("plus rien ne sera retenu", text)
+
+
 if __name__ == "__main__":
     unittest.main()