Sfoglia il codice sorgente

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 3 giorni fa
parent
commit
9fc558667c
4 ha cambiato i file con 111 aggiunte e 15 eliminazioni
  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 "
     "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. "
+    "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 "
     "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."
@@ -215,13 +218,26 @@ class ClaudeClient:
         return content
 
     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.
 
         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
-        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}
 
         system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
@@ -236,11 +252,15 @@ class ClaudeClient:
             system += PRESENCE_NOTE
         if 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
         # 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.
         params: dict = {
             "model": self.settings.model,
@@ -248,7 +268,7 @@ class ClaudeClient:
             "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
             "messages": messages,
         }
-        if cache_history:
+        if history_marker:
             params["cache_control"] = {"type": "ephemeral"}
         # 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

+ 20 - 6
bot/discord_bot.py

@@ -232,11 +232,24 @@ class ChatBot(commands.Bot):
         elif mentioned:
             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:
-        """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.
             in_guild = isinstance(user, discord.Member)
             await self.members.see(user.id, user.name, user.global_name,
@@ -347,8 +360,9 @@ class ChatBot(commands.Bot):
             try:
                 async with typing(channel):
                     try:
+                        notes = await self.member_notes([p.message for p in batch])
                         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:
                         await self.safe_reply(last, exc.user_message)
                         return
@@ -437,7 +451,7 @@ class ChatBot(commands.Bot):
         async with typing(message.channel):
             try:
                 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:
                 await self.safe_reply(message, exc.user_message)
                 return

+ 53 - 1
bot/memory.py

@@ -13,12 +13,14 @@ import re
 from dataclasses import dataclass
 from pathlib import Path
 
-from .members import MemberRegistry, display_name
+from .members import NAME_KEYS, MemberRegistry, display_name
 
 logger = logging.getLogger("chatbot.memory")
 
 SERVER_FILE = "serveur.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 = {
     "name": "memoire",
@@ -74,6 +76,56 @@ class MemoryStore:
     def member_file(self, member_id: int | str) -> str:
         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:
         """Exécute une commande de l'outil « memoire »."""
         action = command.get("commande")

+ 12 - 2
changelog.txt

@@ -7,8 +7,8 @@ Version 5.10
 
 Nouveautés
 - 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
   5 derniers anciens pseudos, mis à jour à chaque message. Le registre
   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
   que celui d'Anthropic, qui lui ferait relire tout le dossier avant
   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
 - /reset et /skip sont réservés aux membres qui ont la permission