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

Mémoire des membres : fiches jointes et liste des membres

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 3 дней назад
Родитель
Сommit
9fc558667c
4 измененных файлов с 111 добавлено и 15 удалено
  1. 26 6
      bot/claude_client.py
  2. 20 6
      bot/discord_bot.py
  3. 53 1
      bot/memory.py
  4. 12 2
      changelog.txt

+ 26 - 6
bot/claude_client.py

@@ -75,6 +75,9 @@ MEMBER_MEMORY_NOTE = (
     "que d'en ajouter une (« jeu préféré : Zelda » devient « jeu préféré : Elden Ring »). La première ligne "
     "que d'en ajouter une (« jeu préféré : Zelda » devient « jeu préféré : Elden Ring »). La première ligne "
     "d'une fiche est « Surnoms : … » : complète-la dès que tu comprends qu'un surnom désigne ce membre. "
     "d'une fiche est « Surnoms : … » : complète-la dès que tu comprends qu'un surnom désigne ce membre. "
     "Si tu ne sais pas avec certitude de qui on parle, ne note rien, ou demande. "
     "Si tu ne sais pas avec certitude de qui on parle, ne note rien, ou demande. "
+    "Les fiches des membres concernés par un message (auteurs, membres mentionnés, auteur du message "
+    "cité) sont jointes à la fin de ce message ; pour un autre membre de la liste, lis sa fiche avec "
+    "l'outil si tu en as besoin. "
     "Une fiche est limitée à {member_limit} caractères et le fichier général à {server_limit} : si une note "
     "Une fiche est limitée à {member_limit} caractères et le fichier général à {server_limit} : si une note "
     "est refusée faute de place, réécris tout le fichier en plus court (« reecrire »), en fusionnant les "
     "est refusée faute de place, réécris tout le fichier en plus court (« reecrire »), en fusionnant les "
     "lignes et en retirant le moins important."
     "lignes et en retirant le moins important."
@@ -215,13 +218,26 @@ class ClaudeClient:
         return content
         return content
 
 
     async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
     async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
-                  emojis: list[str] | None = None, cache_history: bool = True) -> Answer:
+                  emojis: list[str] | None = None, cache_history: bool = True, member_notes: str = "") -> Answer:
         """Envoie l'historique + le ou les nouveaux messages (texte, image ou PDF), et renvoie la réponse de Claude.
         """Envoie l'historique + le ou les nouveaux messages (texte, image ou PDF), et renvoie la réponse de Claude.
 
 
         emojis : noms des emojis du serveur que Claude peut écrire (voir EMOJI_NOTE).
         emojis : noms des emojis du serveur que Claude peut écrire (voir EMOJI_NOTE).
         cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
         cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
-        reprendra pas (mentions : contexte différent à chaque fois)."""
-        messages = list(history) + [{"role": "user", "content": self.build_user_content(parts)}]
+        reprendra pas (mentions : contexte différent à chaque fois).
+        member_notes : fiches des membres concernés (voir MemoryStore.notes_for), jointes au message
+        mais absentes de l'historique."""
+        content = self.build_user_content(parts)
+        # Les fiches, absentes de l'historique enregistré, viennent après le marqueur de cache :
+        # placées avant, elles empêcheraient le message suivant de relire l'historique en cache.
+        history_marker = cache_history
+        if member_notes:
+            if isinstance(content, str):
+                content = [{"type": "text", "text": content}]
+            if cache_history:
+                content[-1] = {**content[-1], "cache_control": {"type": "ephemeral"}}
+                history_marker = False  # marqueur déjà placé, avant les fiches
+            content.append({"type": "text", "text": member_notes})
+        messages = list(history) + [{"role": "user", "content": content}]
         attachments = {("pdf" if image.is_pdf else "image") for _, image in parts if image is not None}
         attachments = {("pdf" if image.is_pdf else "image") for _, image in parts if image is not None}
 
 
         system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
         system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
@@ -236,11 +252,15 @@ class ClaudeClient:
             system += PRESENCE_NOTE
             system += PRESENCE_NOTE
         if emojis:
         if emojis:
             system += EMOJI_NOTE.format(example=emojis[0], names=" ".join(f":{name}:" for name in emojis))
             system += EMOJI_NOTE.format(example=emojis[0], names=" ".join(f":{name}:" for name in emojis))
+        if self.memory:
+            # En dernier : la liste des membres et le fichier général sont ce qui change le plus souvent.
+            system += self.memory.system_context()
 
 
         # Cache de prompt : les outils et la personnalité, identiques d'un message à l'autre, sont
         # Cache de prompt : les outils et la personnalité, identiques d'un message à l'autre, sont
         # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
         # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
-        # Le marqueur sur "system" couvre ce bloc fixe ; celui de premier niveau couvre en plus
-        # l'historique, qui change à chaque message mais reprend le début de la requête précédente.
+        # Le marqueur sur "system" couvre ce bloc fixe ; celui de premier niveau (ou celui placé avant
+        # les fiches) couvre en plus l'historique, qui change à chaque message mais reprend le début
+        # de la requête précédente.
         # Une écriture en cache coûte 25 % de plus : inutile si elle ne sera jamais relue.
         # Une écriture en cache coûte 25 % de plus : inutile si elle ne sera jamais relue.
         params: dict = {
         params: dict = {
             "model": self.settings.model,
             "model": self.settings.model,
@@ -248,7 +268,7 @@ class ClaudeClient:
             "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
             "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
             "messages": messages,
             "messages": messages,
         }
         }
-        if cache_history:
+        if history_marker:
             params["cache_control"] = {"type": "ephemeral"}
             params["cache_control"] = {"type": "ephemeral"}
         # Par défaut, les modèles récents réfléchissent avant de répondre, et cette réflexion
         # Par défaut, les modèles récents réfléchissent avant de répondre, et cette réflexion
         # consomme le budget MAX_TOKENS. Avec THINKING=off, on la coupe de la façon propre au
         # consomme le budget MAX_TOKENS. Avec THINKING=off, on la coupe de la façon propre au

+ 20 - 6
bot/discord_bot.py

@@ -232,11 +232,24 @@ class ChatBot(commands.Bot):
         elif mentioned:
         elif mentioned:
             await self.handle_mention(message)
             await self.handle_mention(message)
 
 
+    async def concerned_members(self, message: discord.Message) -> list[discord.User | discord.Member]:
+        """Membres concernés par un message, sans les bots : auteur, membres mentionnés, auteur du message cité."""
+        users = [message.author, *message.mentions]
+        if (cited := await self.fetch_cited(message)) is not None:
+            users.append(cited.author)
+        unique = {user.id: user for user in users if not user.bot}
+        return list(unique.values())
+
+    async def member_notes(self, messages: list[discord.Message]) -> str:
+        """Fiches des membres concernés par ces messages, à joindre pour Claude (vide sans mémoire)."""
+        if not self.memory:
+            return ""
+        ids = [user.id for message in messages for user in await self.concerned_members(message)]
+        return self.memory.notes_for(ids)
+
     async def register_members(self, message: discord.Message) -> None:
     async def register_members(self, message: discord.Message) -> None:
-        """Ajoute au registre l'auteur et les membres mentionnés (sauf les bots), ou met leurs noms à jour."""
-        for user in (message.author, *message.mentions):
-            if user.bot:
-                continue
+        """Ajoute au registre les membres concernés par le message, ou met leurs noms à jour."""
+        for user in await self.concerned_members(message):
             # En message privé, l'auteur n'est pas un membre du serveur : son pseudo n'est pas connu.
             # En message privé, l'auteur n'est pas un membre du serveur : son pseudo n'est pas connu.
             in_guild = isinstance(user, discord.Member)
             in_guild = isinstance(user, discord.Member)
             await self.members.see(user.id, user.name, user.global_name,
             await self.members.see(user.id, user.name, user.global_name,
@@ -347,8 +360,9 @@ class ChatBot(commands.Bot):
             try:
             try:
                 async with typing(channel):
                 async with typing(channel):
                     try:
                     try:
+                        notes = await self.member_notes([p.message for p in batch])
                         answer = await self.claude.ask(self.history.get(channel.id), parts,
                         answer = await self.claude.ask(self.history.get(channel.id), parts,
-                                                       emojis=emoji_names(last.guild))
+                                                       emojis=emoji_names(last.guild), member_notes=notes)
                     except ClaudeError as exc:
                     except ClaudeError as exc:
                         await self.safe_reply(last, exc.user_message)
                         await self.safe_reply(last, exc.user_message)
                         return
                         return
@@ -437,7 +451,7 @@ class ChatBot(commands.Bot):
         async with typing(message.channel):
         async with typing(message.channel):
             try:
             try:
                 answer = await self.claude.ask([], [(prompt, image)], emojis=emoji_names(message.guild),
                 answer = await self.claude.ask([], [(prompt, image)], emojis=emoji_names(message.guild),
-                                               cache_history=False)
+                                               cache_history=False, member_notes=await self.member_notes([message]))
             except ClaudeError as exc:
             except ClaudeError as exc:
                 await self.safe_reply(message, exc.user_message)
                 await self.safe_reply(message, exc.user_message)
                 return
                 return

+ 53 - 1
bot/memory.py

@@ -13,12 +13,14 @@ import re
 from dataclasses import dataclass
 from dataclasses import dataclass
 from pathlib import Path
 from pathlib import Path
 
 
-from .members import MemberRegistry, display_name
+from .members import NAME_KEYS, MemberRegistry, display_name
 
 
 logger = logging.getLogger("chatbot.memory")
 logger = logging.getLogger("chatbot.memory")
 
 
 SERVER_FILE = "serveur.md"
 SERVER_FILE = "serveur.md"
 MEMBER_FILE = re.compile(r"^membres/(\d+)\.md$")
 MEMBER_FILE = re.compile(r"^membres/(\d+)\.md$")
+# Première ligne d'une fiche : les surnoms du membre, repris dans la liste des membres.
+NICKNAMES_LINE = re.compile(r"^surnoms\s*:\s*(.*)$", re.IGNORECASE)
 
 
 MEMORY_TOOL = {
 MEMORY_TOOL = {
     "name": "memoire",
     "name": "memoire",
@@ -74,6 +76,56 @@ class MemoryStore:
     def member_file(self, member_id: int | str) -> str:
     def member_file(self, member_id: int | str) -> str:
         return f"membres/{member_id}.md"
         return f"membres/{member_id}.md"
 
 
+    def nicknames(self, member_id: int | str) -> list[str]:
+        """Surnoms notés sur la première ligne de la fiche (« Surnoms : pnt, le Pent »)."""
+        first_line = self.read(self.member_file(member_id)).partition("\n")[0]
+        match = NICKNAMES_LINE.match(first_line.strip())
+        if match is None:
+            return []
+        return [name.strip() for name in re.split(r"[,;]", match.group(1)) if name.strip()]
+
+    def system_context(self) -> str:
+        """Liste des membres (tous leurs noms et le chemin de leur fiche) et fichier général, pour le
+        prompt système. Ils changent rarement : le prompt reste presque toujours en cache."""
+        lines = []
+        for member_id, entry in self.members.members().items():
+            if not entry["memory"]:
+                continue  # mémoire désactivée : Rika ne doit rien noter sur ce membre
+            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(member_id):
+                if other:
+                    others.setdefault(other.casefold(), other)
+            others = list(others.values())[1:]
+            details = []
+            if others:
+                details.append("aussi : " + ", ".join(others))
+            if entry["former_names"]:
+                details.append("anciens pseudos : " + ", ".join(entry["former_names"]))
+            suffix = f" ({' ; '.join(details)})" if details else ""
+            lines.append(f"- {name}{suffix} → {self.member_file(member_id)}")
+        members = "\n".join(lines) or "(aucun membre pour l'instant)"
+        server = self.read(SERVER_FILE) or "(vide pour l'instant)"
+        return (f"\n\nListe des membres, avec tous leurs noms et le chemin de leur fiche :\n{members}"
+                f"\n\nTon fichier général ({SERVER_FILE}) :\n{server}")
+
+    def notes_for(self, member_ids: list[int]) -> str:
+        """Fiches des membres concernés par un message, jointes à ce message (vide s'il n'y en a aucune).
+        Les membres inconnus du registre ou qui ont désactivé la mémoire sont ignorés."""
+        sections = []
+        for member_id in dict.fromkeys(member_ids):
+            entry = self.members.get(member_id)
+            if entry is None or not entry["memory"]:
+                continue
+            path = self.member_file(member_id)
+            content = self.read(path) or "(rien de noté pour l'instant)"
+            sections.append(f"=== {display_name(entry)} ({path}) ===\n{content}")
+        if not sections:
+            return ""
+        return ("[Tes fiches sur les membres concernés par ce message, jointes automatiquement : "
+                "elles ne font pas partie du message.]\n\n" + "\n\n".join(sections))
+
     async def run(self, command: dict) -> ToolResult:
     async def run(self, command: dict) -> ToolResult:
         """Exécute une commande de l'outil « memoire »."""
         """Exécute une commande de l'outil « memoire »."""
         action = command.get("commande")
         action = command.get("commande")

+ 12 - 2
changelog.txt

@@ -7,8 +7,8 @@ Version 5.10
 
 
 Nouveautés
 Nouveautés
 - Mémoire des membres (MEMORY=on, en construction) : le bot tient un
 - Mémoire des membres (MEMORY=on, en construction) : le bot tient un
-  registre des membres qu'il voit, auteurs des messages qu'il lit et
-  membres mentionnés, sans les autres bots. Pour chacun, il garde le
+  registre des membres qu'il voit : auteurs des messages qu'il lit,
+  membres mentionnés et auteurs des messages cités, sans les bots. Pour chacun, il garde le
   nom d'utilisateur, le nom d'affichage, le pseudo sur le serveur et les
   nom d'utilisateur, le nom d'affichage, le pseudo sur le serveur et les
   5 derniers anciens pseudos, mis à jour à chaque message. Le registre
   5 derniers anciens pseudos, mis à jour à chaque message. Le registre
   est dans memories/registre.json, à côté de l'historique (MEMORY_DIR
   est dans memories/registre.json, à côté de l'historique (MEMORY_DIR
@@ -26,6 +26,16 @@ Nouveautés
   (permission « Ajouter des réactions » nécessaire). Outil maison plutôt
   (permission « Ajouter des réactions » nécessaire). Outil maison plutôt
   que celui d'Anthropic, qui lui ferait relire tout le dossier avant
   que celui d'Anthropic, qui lui ferait relire tout le dossier avant
   chaque réponse (un appel de plus à chaque message).
   chaque réponse (un appel de plus à chaque message).
+  Le bot joint lui-même à chaque message les fiches des membres
+  concernés (auteurs, membres mentionnés, auteur du message cité), sans
+  appel supplémentaire ; elles ne sont pas enregistrées dans
+  l'historique, et le cache s'arrête juste avant elles pour que
+  l'historique reste relu en cache au message suivant. Le prompt
+  contient aussi la liste des membres, avec tous leurs noms (pseudo,
+  nom d'utilisateur, surnoms de la fiche, anciens pseudos) et le chemin
+  de leur fiche, ce qui permet à Rika de noter une info sur quelqu'un
+  cité par son seul nom ou surnom ; ainsi que le fichier général. Les
+  membres qui ont désactivé la mémoire n'y figurent pas.
 
 
 Divers
 Divers
 - /reset et /skip sont réservés aux membres qui ont la permission
 - /reset et /skip sont réservés aux membres qui ont la permission