Переглянути джерело

MAX_TOKENS : la marge de la mémoire tient compte de la réflexion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 22 годин тому
батько
коміт
8bc391b99b
6 змінених файлів з 96 додано та 19 видалено
  1. 6 4
      .env.exemple
  2. 2 0
      CHANGELOG.md
  3. 1 1
      README.md
  4. 31 2
      bot/config.py
  5. 8 12
      bot/discord_bot.py
  6. 48 0
      tests/test_config.py

+ 6 - 4
.env.exemple

@@ -12,9 +12,11 @@ CHANNEL_ID=
 # ou claude-opus-5-5 (plus capable, plus cher). Redémarre le bot après un changement.
 CLAUDE_MODEL=claude-sonnet-5-5
 # Longueur maximale d'une réponse (en tokens, ~0,75 mot par token), réflexion et notes de mémoire
-# comprises. Ne coûte que si elle est atteinte. Avec MEMORY=on, au moins 4000 (le bot prévient au
-# démarrage si c'est trop peu pour MEMORY_SERVER_LIMIT).
-MAX_TOKENS=4000
+# comprises. Ne coûte que si elle est atteinte. Avec MEMORY=on, il faut de la place pour réécrire un
+# fichier plein (MEMORY_SERVER_LIMIT) après la réflexion : 4000 sans réflexion, 6000 avec EFFORT=low
+# ou medium, 8000 avec high ou par défaut, 12000 avec xhigh ou max (pour 5000 caractères). Le bot
+# refuse de démarrer sous le minimum, et prévient dans le message « En ligne » sous la valeur conseillée.
+MAX_TOKENS=6000
 # Réflexion avant de répondre : off (rapide) ou on (plus réfléchi, plus lent et plus cher).
 # Sur claude-opus-5-5, la réflexion ne peut pas être coupée : règle plutôt EFFORT=low.
 THINKING=on
@@ -24,7 +26,7 @@ EFFORT=medium
 # Délai maximal d'attente d'une réponse, en secondes, relances comprises : une requête trop
 # lente n'est pas relancée. Compte environ 1 seconde pour 80 tokens de MAX_TOKENS ; le bot
 # prévient au démarrage si c'est trop court.
-API_TIMEOUT=60
+API_TIMEOUT=90
 
 # --- Accès à Internet ----------------------------------------------------
 # Recherche web et lecture de liens : off ou on (payant : voir PRICE_WEB_SEARCH_PER_1000).

+ 2 - 0
CHANGELOG.md

@@ -20,6 +20,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Une réponse trop lente n'est plus relancée trois fois.** Quand Claude dépassait `API_TIMEOUT`, la requête était recommencée jusqu'à 3 fois, recherches web comprises (et refacturées) : un membre pouvait attendre 4 minutes avant « Je mets trop de temps à répondre ». `API_TIMEOUT` borne maintenant l'attente totale ; une erreur passagère (API surchargée) est toujours réessayée dans cette limite. Le bot prévient au démarrage si `API_TIMEOUT` est trop court pour `MAX_TOKENS` (environ 80 tokens par seconde), et 40 secondes de plus avec `WEB_SEARCH=on`.
 
+- **`MAX_TOKENS` vérifié en tenant compte de la réflexion.** Avec `MEMORY=on`, la réécriture d'un fichier de mémoire plein doit tenir dans `MAX_TOKENS` après la réflexion. La marge prévue était de 2000 tokens quels que soient `THINKING` et `EFFORT` : avec `EFFORT=high`, une note pouvait être perdue sans avertissement. La marge dépend maintenant de l'effort (2000 tokens pour low et medium, 4000 pour high ou par défaut, 8000 pour xhigh et max ; aucune sans réflexion). Sous le strict minimum, le bot refuse de démarrer avec un message clair. Sous la valeur conseillée, il le signale aussi dans le message « En ligne », pas seulement dans le journal. `.env.exemple` passe à `MAX_TOKENS=6000` et `API_TIMEOUT=90`.
+
 - **Une personnalité illisible ne rend plus Rika muette.** Un fichier de personnalité qui n'est pas en UTF-8 (enregistré en ANSI avec le Bloc-notes, par exemple), illisible, ou un `PERSONALITY_FILE` qui désigne un dossier (ConfigMap montée comme dossier sur OpenShift) faisait échouer chaque message sans réponse. Rika garde maintenant la dernière personnalité valide, ou celle par défaut au démarrage, et le journal indique une seule fois le problème et comment le corriger. Le fichier est relu dès qu'il est modifié.
 
 ---

+ 1 - 1
README.md

@@ -104,7 +104,7 @@ Tous les réglages se trouvent dans le `.env`, et chacun est expliqué dans `.en
 | `CLAUDE_MODEL` | Modèle utilisé : `claude-sonnet-5-5` (recommandé), `claude-haiku-5-5` (bien moins cher) ou `claude-opus-5-5` (plus capable, plus cher) | `claude-sonnet-5-5` |
 | `THINKING` | Réflexion avant de répondre : `on` (plus réfléchi, plus lent et plus cher) ou `off` | `on` |
 | `EFFORT` | Niveau d'effort : `low`, `medium`, `high`, `xhigh` ou `max` ; vide = valeur du modèle | `medium` |
-| `MAX_TOKENS` | Longueur maximale d'une réponse (réflexion et notes de mémoire comprises), en tokens ; ne coûte que si elle est atteinte. Avec la mémoire, au moins 4000 : le bot prévient au démarrage si c'est trop peu | `4000` |
+| `MAX_TOKENS` | Longueur maximale d'une réponse (réflexion et notes de mémoire comprises), en tokens ; ne coûte que si elle est atteinte. Avec la mémoire, de quoi réécrire un fichier plein après la réflexion (voir `.env.exemple`) : le bot refuse de démarrer sous le minimum, et prévient dans le message « En ligne » si la réflexion risque de ne pas tenir | `4000` |
 | `MAX_HISTORY_LENGTH` | Messages gardés en mémoire dans le salon dédié (jusqu'à la moitié de plus avant d'y revenir d'un coup, ce qui garde la mémoire en cache) | `10` |
 | `CONTEXT_MESSAGE_LIMIT` | Messages lus autour d'une mention | `20` |
 | `API_TIMEOUT` | Attente maximale d'une réponse, en secondes, relances comprises (une requête trop lente n'est pas relancée) ; le bot prévient au démarrage s'il est trop court pour `MAX_TOKENS` | `60` |

+ 31 - 2
bot/config.py

@@ -13,6 +13,9 @@ from dotenv import load_dotenv
 from .models import PRICES, thinking_off
 
 EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
+# Tokens de réflexion à prévoir avant la réécriture d'un fichier de mémoire, selon EFFORT
+# (None : valeur par défaut du modèle, soit high).
+REFLECTION_TOKENS = {"low": 2000, "medium": 2000, "high": 4000, None: 4000, "xhigh": 8000, "max": 8000}
 
 # Chances (relatives) de chaque statut Discord lors d'un changement de statut.
 DEFAULT_PRESENCE_WEIGHTS = {
@@ -213,13 +216,13 @@ def load_settings() -> Settings:
     if presence_speed <= 0:
         raise ConfigError(f"PRESENCE_SPEED doit être supérieur à 0 (valeur actuelle : {presence_speed}).")
 
-    return Settings(
+    settings = Settings(
         discord_token=_get_str("DISCORD_TOKEN", required=True),
         anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
         channel_id=_get_int("CHANNEL_ID", required=True),
         model=model,
         # Une limite haute ne coûte que si elle est atteinte ; trop basse, elle coupe la réécriture
-        # d'un fichier de mémoire plein (voir la vérification au démarrage, dans discord_bot.py).
+        # d'un fichier de mémoire plein (voir memory_max_tokens).
         max_tokens=_get_int("MAX_TOKENS", default=4000, minimum=1),
         thinking=thinking,
         effort=effort,
@@ -252,3 +255,29 @@ def load_settings() -> Settings:
         price_web_search_per_1000=_get_float("PRICE_WEB_SEARCH_PER_1000", default=10.0, minimum=0),
         log_file=Path(_get_str("LOG_FILE", default="bot.log")),
     )
+    if settings.memory and settings.max_tokens < (minimum := memory_max_tokens(settings)[0]):
+        raise ConfigError(f"MAX_TOKENS ({settings.max_tokens}) est trop petit pour la mémoire des membres : la "
+                          f"réécriture d'un fichier plein serait coupée et la note perdue. Mets au moins {minimum}, "
+                          f"ou baisse MEMORY_SERVER_LIMIT et MEMORY_MEMBER_LIMIT.")
+    return settings
+
+
+def memory_max_tokens(settings: Settings) -> tuple[int, int]:
+    """MAX_TOKENS nécessaire pour réécrire un fichier de mémoire plein d'un seul appel d'outil :
+    (minimum sans réflexion, valeur conseillée avec la réflexion attendue)."""
+    # Environ 2,5 caractères par token en français (estimation prudente), plus 2000 pour le reste de l'appel.
+    minimum = max(settings.memory_member_limit, settings.memory_server_limit) * 2 // 5 + 2000
+    # Sans THINKING=on, le modèle réfléchit quand même s'il ne permet pas de couper la réflexion.
+    if not settings.thinking and thinking_off(settings.model) is not None:
+        return minimum, minimum
+    return minimum, minimum + REFLECTION_TOKENS[settings.effort]
+
+
+def memory_warning(settings: Settings) -> str | None:
+    """Avertissement à afficher au démarrage quand MAX_TOKENS laisse trop peu de place à la réflexion
+    pour réécrire un fichier de mémoire plein (None : rien à signaler)."""
+    if not settings.memory or settings.max_tokens >= (advised := memory_max_tokens(settings)[1]):
+        return None
+    return (f"MAX_TOKENS ({settings.max_tokens}) laisse peu de place à la réflexion (effort "
+            f"{settings.effort or 'par défaut'}) : la réécriture d'un fichier de mémoire plein pourrait être "
+            f"coupée et la note perdue. Mets au moins {advised} dans le .env.")

+ 8 - 12
bot/discord_bot.py

@@ -18,7 +18,7 @@ from discord.ext import commands
 
 from . import VERSION
 from .claude_client import ClaudeClient, ClaudeError, ImageData
-from .config import Settings
+from .config import Settings, memory_warning
 from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
 from .history import ConversationHistory, images_marker, render_line
 from .images import ImageError, prepare_image
@@ -156,7 +156,8 @@ class ChatBot(commands.Bot):
         self.history = ConversationHistory(settings.history_file, settings.max_history_length)
         self._pending: dict[int, list[PendingMessage]] = {}  # messages en attente de réponse, par salon
         self._announced = False
-        self.presence = PresenceManager(self, self.claude, settings) if settings.presence else None
+        self._memory_warning = memory_warning(settings) if self.memory else None
+        self.presence =PresenceManager(self, self.claude, settings) if settings.presence else None
         # Absent ou en « Ne pas déranger » : réponses différées, par salon.
         self._waiters: dict[int, asyncio.Task] = {}  # attente en cours
         self._mentions: dict[int, list[discord.Message]] = {}  # mentions en attente (hors salon dédié)
@@ -200,16 +201,8 @@ class ChatBot(commands.Bot):
             logger.warning("API_TIMEOUT (%g s) est court pour MAX_TOKENS=%s%s : une longue réponse serait "
                            "abandonnée (« Je mets trop de temps à répondre »). Mets au moins %s dans le .env.",
                            s.api_timeout, s.max_tokens, " avec la recherche web" if s.web_search else "", needed)
-        if self.memory:
-            # Un fichier plein se réécrit d'un seul appel d'outil, qui doit tenir dans MAX_TOKENS avec
-            # la réflexion : environ 2,5 caractères par token en français (estimation prudente),
-            # plus une marge pour la réflexion et la réponse.
-            needed = max(self.memory.member_limit, self.memory.server_limit) * 2 // 5 + 2000
-            if s.max_tokens < needed:
-                logger.warning("MAX_TOKENS (%s) est trop petit pour la mémoire des membres : la réécriture "
-                               "d'un fichier plein serait coupée et la note perdue. Mets au moins %s dans "
-                               "le .env, ou baisse MEMORY_SERVER_LIMIT et MEMORY_MEMBER_LIMIT.",
-                               s.max_tokens, needed)
+        if self._memory_warning:
+            logger.warning("%s", self._memory_warning)  # rappelé dans le message « En ligne »
         if self.presence:
             self.presence.start()
 
@@ -268,6 +261,9 @@ class ChatBot(commands.Bot):
         embed.add_field(name="Version", value=f"`{VERSION}`")
         embed.add_field(name="Modèle", value=display_name(self.settings.model))
         embed.add_field(name="Démarrage", value=discord.utils.format_dt(started, "R"))
+        if self._memory_warning:
+            # Sur OpenShift, personne ne lit le journal de démarrage.
+            embed.add_field(name="⚠️ Réglage à revoir", value=self._memory_warning, inline=False)
         try:
             await channel.send(embed=embed)
         except discord.HTTPException as exc:

+ 48 - 0
tests/test_config.py

@@ -0,0 +1,48 @@
+"""Tests de la configuration (MAX_TOKENS et mémoire). Lancer depuis la racine du projet : python -m unittest"""
+
+import unittest
+from unittest import mock
+
+from bot.config import ConfigError, load_settings, memory_max_tokens, memory_warning
+
+BASE = {"DISCORD_TOKEN": "x", "ANTHROPIC_API_KEY": "x", "CHANNEL_ID": "1",
+        "CLAUDE_MODEL": "claude-sonnet-5-5", "MEMORY": "on", "MEMORY_SERVER_LIMIT": "5000"}
+
+
+def settings(**env):
+    # Sans le .env du projet : seulement les variables données.
+    with mock.patch.dict("os.environ", {**BASE, **env}, clear=True), mock.patch("bot.config.load_dotenv"):
+        return load_settings()
+
+
+class MemoryMaxTokensTest(unittest.TestCase):
+    def test_margin_follows_effort(self):
+        # 5000 caractères : 2000 tokens de contenu, plus 2000 pour le reste de l'appel.
+        self.assertEqual(memory_max_tokens(settings(THINKING="off")), (4000, 4000))
+        self.assertEqual(memory_max_tokens(settings(THINKING="on", EFFORT="medium")), (4000, 6000))
+        self.assertEqual(memory_max_tokens(settings(THINKING="on")), (4000, 8000))
+        self.assertEqual(memory_max_tokens(settings(THINKING="on", EFFORT="max")), (4000, 12000))
+
+    def test_opus_thinks_even_with_thinking_off(self):
+        s = settings(CLAUDE_MODEL="claude-opus-5-5", THINKING="off", EFFORT="low")
+        self.assertEqual(memory_max_tokens(s), (4000, 6000))
+
+    def test_too_small_refused(self):
+        with self.assertRaises(ConfigError) as ctx:
+            settings(MAX_TOKENS="3999")
+        self.assertIn("au moins 4000", str(ctx.exception))
+
+    def test_memory_off_not_checked(self):
+        self.assertEqual(settings(MEMORY="off", MAX_TOKENS="1000").max_tokens, 1000)
+        self.assertIsNone(memory_warning(settings(MEMORY="off", MAX_TOKENS="1000", THINKING="on")))
+
+    def test_warning_when_reflection_does_not_fit(self):
+        # Le scénario de l'audit : pile le minimum, mais la réflexion à effort high n'y tient pas.
+        s = settings(MAX_TOKENS="4000", THINKING="on", EFFORT="high")
+        self.assertIn("au moins 8000", memory_warning(s))
+        self.assertIsNone(memory_warning(settings(MAX_TOKENS="8000", THINKING="on", EFFORT="high")))
+        self.assertIsNone(memory_warning(settings(MAX_TOKENS="4000", THINKING="off")))
+
+
+if __name__ == "__main__":
+    unittest.main()