Procházet zdrojové kódy

Réflexion écrite dans la réponse : retirée avant l'envoi

Le modèle écrit parfois sa réflexion dans le texte de sa réponse (faux
appel d'outil <invoke name="think">, ou raisonnement entre balises
<reasoning>). Elle est retirée du message envoyé et de la mémoire ; si
la réponse ne contenait que ça, le bot redemande une réponse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet před 5 dny
rodič
revize
ba0d8e1a76
2 změnil soubory, kde provedl 43 přidání a 11 odebrání
  1. 39 11
      bot/claude_client.py
  2. 4 0
      changelog.txt

+ 39 - 11
bot/claude_client.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import base64
 import logging
+import re
 from dataclasses import dataclass
 from pathlib import Path
 
@@ -59,6 +60,19 @@ WEB_NOTE = (
 # Nombre maximal de relances quand l'API met la réponse en pause pendant les recherches.
 MAX_CONTINUATIONS = 3
 
+# Il arrive que le modèle écrive sa réflexion dans sa réponse au lieu de réfléchir pour de vrai :
+# faux appel d'outil (<invoke name="think">…) ou raisonnement entre balises (<reasoning>…).
+# Ce n'est que du texte, à ne jamais afficher. Une balise de raisonnement jamais refermée
+# (réponse coupée) emporte tout ce qui la suit. Seul le préfixe « antml: » est accepté, pour
+# ne pas confondre avec un émoji Discord comme <a:think:123>.
+_LEAKED_TAGS = r"invoke|function_calls|reasoning|thinking|think"
+LEAKED_REASONING = re.compile(
+    rf"<(?:antml:)?({_LEAKED_TAGS})\b[^>]*>.*?</(?:antml:)?\1\s*>"
+    r"|<(?:antml:)?(?:reasoning|thinking|think)\b[^>]*>.*"
+    rf"|</?(?:antml:)?(?:{_LEAKED_TAGS}|parameter)\b[^>]*>",
+    re.DOTALL,
+)
+
 
 class ClaudeError(Exception):
     """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
@@ -175,15 +189,15 @@ class ClaudeClient:
                 {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
             ]
 
-        responses = [await self._call(params, has_image)]
-        # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
-        # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
-        while responses[-1].stop_reason == "pause_turn" and len(responses) <= MAX_CONTINUATIONS:
-            messages = messages + [{"role": "assistant", "content": responses[-1].content}]
-            responses.append(await self._call({**params, "messages": messages}, has_image))
-
-        text = self._final_text(responses)
+        responses = await self._answer(params, messages, has_image)
+        text, leaked = self._final_text(responses)
         web_used = self._log_usage(responses)
+        if leaked and not text:
+            # Réponse faite uniquement de réflexion écrite : on redemande une fois.
+            logger.warning("Réponse sans texte (réflexion écrite dans la réponse) : nouvel essai.")
+            responses = await self._answer(params, messages, has_image)
+            text, _ = self._final_text(responses)
+            web_used = self._log_usage(responses) or web_used
 
         stop_reason = responses[-1].stop_reason
         if stop_reason == "max_tokens":
@@ -197,6 +211,15 @@ class ClaudeClient:
             raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
         return Answer(text, web_used)
 
+    async def _answer(self, params: dict, messages: list[dict], has_image: bool) -> list:
+        responses = [await self._call(params, has_image)]
+        # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
+        # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
+        while responses[-1].stop_reason == "pause_turn" and len(responses) <= MAX_CONTINUATIONS:
+            messages = messages + [{"role": "assistant", "content": responses[-1].content}]
+            responses.append(await self._call({**params, "messages": messages}, has_image))
+        return responses
+
     async def _call(self, params: dict, has_image: bool):
         try:
             return await self._client.messages.create(**params)
@@ -222,8 +245,9 @@ class ClaudeClient:
             raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")
 
     @staticmethod
-    def _final_text(responses) -> str:
-        """Texte de la réponse finale, sans les commentaires écrits entre deux recherches."""
+    def _final_text(responses) -> tuple[str, bool]:
+        """Texte de la réponse finale, sans les commentaires écrits entre deux recherches
+        ni la réflexion écrite dans la réponse ; indique aussi si de la réflexion a été retirée."""
         parts: list[str] = []
         for response in responses:
             for block in response.content:
@@ -232,7 +256,11 @@ class ClaudeClient:
                 elif block.type not in ("thinking", "redacted_thinking"):
                     # Appel ou résultat d'outil : le texte écrit avant n'était qu'un commentaire.
                     parts = []
-        return "".join(parts).strip()
+        text, removed = LEAKED_REASONING.subn("", "".join(parts))
+        if removed:
+            logger.warning("Réflexion écrite retirée de la réponse (%s balise(s)).", removed)
+            text = re.sub(r"\n{3,}", "\n\n", text)
+        return text.strip(), bool(removed)
 
     def _log_usage(self, responses) -> bool:
         """Journalise la consommation et indique si Claude a utilisé Internet."""

+ 4 - 0
changelog.txt

@@ -9,6 +9,10 @@ Corrections
 - Les avertissements de discord.py sur le vocal (PyNaCl et davey non
   installés) n'apparaissent plus à chaque démarrage : le bot n'utilise pas
   le vocal.
+- Réflexion affichée : quand le modèle écrit sa réflexion dans sa réponse
+  (faux appel d'outil <invoke name="think">… ou raisonnement entre balises
+  <reasoning>…), elle est retirée du message envoyé et de la mémoire. Si la
+  réponse ne contenait que ça, le bot redemande une réponse.
 
 
 Version 5.4.0 — 29/10/2026