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

Réflexion affichée : bloc de début retiré, mémoire nettoyée

Sonnet 5.5 commence parfois sa réponse par sa réflexion entre balises
(<think>…</think>), qui s'affichait sur Discord. Gardée en mémoire,
elle était relue et imitée : une fuite en entraînait d'autres.

Un bloc complet (<think>, <thinking>, <reasoning>, faux appel d'outil)
placé tout au début de la réponse est retiré avant l'envoi et la mise
en mémoire, et le log le signale. Une balise plus loin dans le texte,
dans du code ou incomplète reste affichée : elle peut être voulue.
Les réponses déjà en mémoire sont nettoyées de la même façon au
chargement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 3 днів тому
батько
коміт
41b4752f46
4 змінених файлів з 33 додано та 1 видалено
  1. 5 0
      bot/claude_client.py
  2. 5 1
      bot/history.py
  3. 14 0
      bot/text_utils.py
  4. 9 0
      changelog.txt

+ 5 - 0
bot/claude_client.py

@@ -12,6 +12,7 @@ import anthropic
 
 from .config import Settings
 from .models import thinking_off
+from .text_utils import strip_reasoning
 
 logger = logging.getLogger("chatbot.claude")
 
@@ -235,6 +236,10 @@ class ClaudeClient:
 
         text = self._final_text(responses)
         web_used = self._log_usage(responses)
+        # Sans ce nettoyage, la réflexion affichée resterait en mémoire et le modèle l'imiterait ensuite.
+        if (cleaned := strip_reasoning(text)) != text:
+            logger.warning("Réflexion écrite dans la réponse, retirée avant l'envoi : %r", text[:500])
+            text = cleaned
 
         stop_reason = responses[-1].stop_reason
         if stop_reason == "max_tokens":

+ 5 - 1
bot/history.py

@@ -10,6 +10,8 @@ import shutil
 from datetime import datetime
 from pathlib import Path
 
+from .text_utils import strip_reasoning
+
 logger = logging.getLogger("chatbot.history")
 
 FILE_VERSION = 3
@@ -271,7 +273,9 @@ class ConversationHistory:
         if not isinstance(content, str) or not content.strip():
             return None
         if msg["role"] == "assistant":
-            return {"role": "assistant", "content": content}
+            # Réflexion affichée par erreur avant la 5.8 : le modèle l'imiterait en la relisant.
+            content = strip_reasoning(content)
+            return {"role": "assistant", "content": content} if content else None
         # Le texte converti reste identique une fois rendu : le cache de prompt est conservé.
         web = content.endswith(f" {WEB_MARKER}")
         if web:

+ 14 - 0
bot/text_utils.py

@@ -8,6 +8,20 @@ DISCORD_LIMIT = 2000
 
 _FENCE_RE = re.compile(r"^\s*```(\S*)")
 
+# Bloc dans lequel le modèle écrit parfois sa réflexion au lieu de la garder pour lui.
+_REASONING_BLOCK = re.compile(r"\s*<(think|thinking|reasoning|function_calls|invoke)\b[^>]*>.*?</\1\s*>",
+                              re.S | re.I)
+
+
+def strip_reasoning(text: str) -> str:
+    """Retire la réflexion écrite au début de la réponse (<think>…</think>, faux appel d'outil…).
+
+    Seul un bloc complet tout au début est retiré : c'est là que le modèle l'écrit, et une
+    balise plus loin dans le texte (exemple, code) peut être voulue."""
+    while match := _REASONING_BLOCK.match(text):
+        text = text[match.end():]
+    return text.strip()
+
 
 def split_message(text: str, limit: int = DISCORD_LIMIT) -> list[str]:
     """Découpe un long texte en morceaux de `limit` caractères maximum.

+ 9 - 0
changelog.txt

@@ -24,6 +24,15 @@ Nouveautés
   remettre cette copie à la place du fichier : la 5.7 ne sait pas lire
   le nouveau format et repartirait d'une mémoire vide.
 
+Corrections
+- Réflexion affichée : quand le modèle commence sa réponse par sa
+  réflexion (<think>…</think>, <reasoning>…, faux appel d'outil), elle
+  est retirée avant l'envoi, et le log le signale. Seul un bloc complet
+  tout au début est retiré : une balise plus loin dans le texte ou dans
+  du code reste affichée. Celle déjà présente dans la mémoire est
+  nettoyée au démarrage : en la relisant, le modèle l'imitait, et une
+  fuite en entraînait d'autres.
+
 
 Version 5.7
 -----------