소스 검색

Découpage des messages : une longue ligne d'ouverture de bloc ne fige plus le bot

Quand une ligne ouvrait un bloc de code, split_message gardait toute la
ligne comme marqueur à rouvrir (line.strip()), y compris le texte qui
suit « ```py ». Pour les lignes suivantes, la place laissée à chaque
morceau (limit - 10 - len(open_fence)) devenait nulle ou négative dès que
cette ligne dépassait limit - 10 caractères, et _split_long_line ne
raccourcissait plus la ligne : boucle infinie. split_message est appelé
sans thread, donc tout le bot se figeait (plus de heartbeat Discord)
jusqu'au redémarrage. Un membre pouvait le déclencher en demandant une
telle ligne à Rika. Le bug existe depuis la 5.1.

- open_fence ne garde que le marqueur et le langage (_fence), et ``` seul
  si le « langage » dépasse 20 caractères ;
- la place des morceaux tient compte du bloc ouvert par la ligne elle-même,
  et ce bloc est ouvert dès le premier morceau de sa ligne d'ouverture ;
- _split_long_line coupe au moins un caractère par morceau, quelle que
  soit la limite.

Les tests lancent split_message dans un fil avec un délai de 2 s, pour
échouer au lieu de bloquer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 6 시간 전
부모
커밋
5a5031e6bc
3개의 변경된 파일과 67개의 추가작업 그리고 6개의 파일을 삭제
  1. 2 0
      CHANGELOG.md
  2. 23 6
      bot/text_utils.py
  3. 42 0
      tests/test_text_utils.py

+ 2 - 0
CHANGELOG.md

@@ -56,6 +56,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Une date sans fuseau dans `presence_state.json` n'empêche plus le démarrage.** Après une modification à la main, une date comme `2026-11-03T10:00:00` arrêtait le bot dès le lancement. Le fichier est maintenant ignoré, avec un avertissement dans le journal, et Rika tire un nouveau statut.
 
+- **Une ligne de code très longue ne fige plus le bot.** Une réponse dont un bloc de code s'ouvrait sur une ligne de près de 2000 caractères (« ```py » suivi de texte) figeait tout le bot, dans tous les salons, jusqu'au redémarrage. Elle est maintenant découpée normalement.
+
 ---
 
 ## Version 5.11

+ 23 - 6
bot/text_utils.py

@@ -7,6 +7,8 @@ import re
 DISCORD_LIMIT = 2000
 
 _FENCE_RE = re.compile(r"^\s*```(\S*)")
+# Au-delà, le mot collé à ``` n'est pas un nom de langage : le bloc est rouvert avec ``` seul.
+_MAX_LANGUAGE = 20
 
 # 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*>",
@@ -38,7 +40,7 @@ def split_message(text: str, limit: int = DISCORD_LIMIT) -> list[str]:
 
     chunks: list[str] = []
     current = ""
-    open_fence: str | None = None  # ligne d'ouverture du bloc de code en cours, ex. "```python"
+    open_fence: str | None = None  # marqueur du bloc de code en cours, ex. "```python" (sans le texte qui suit)
 
     def closing_len() -> int:
         return len("\n```") if open_fence is not None else 0
@@ -59,11 +61,14 @@ def split_message(text: str, limit: int = DISCORD_LIMIT) -> list[str]:
 
     for line in text.split("\n"):
         match = _FENCE_RE.match(line)
-        # Une ligne trop longue à elle seule est découpée en morceaux plus petits.
-        pieces = _split_long_line(line, limit - 10 - (len(open_fence) if open_fence else 0))
+        new_fence = _fence(match) if match and open_fence is None else None
+        # Une ligne trop longue à elle seule est découpée en morceaux plus petits, assez courts pour
+        # tenir avec la réouverture du bloc (en cours ou ouvert par cette ligne) et sa fermeture.
+        fence = new_fence or open_fence
+        pieces = _split_long_line(line, limit - 10 - (len(fence) if fence else 0))
         # Une ligne qui ouvre un bloc doit laisser la place de le refermer. Celle qui le ferme n'en a pas
         # besoin, sauf si elle est découpée : le bloc reste alors ouvert entre ses morceaux.
-        if match and open_fence is None:
+        if new_fence:
             room_to_close = len("\n```")
         elif match and len(pieces) == 1:
             room_to_close = 0
@@ -74,9 +79,13 @@ def split_message(text: str, limit: int = DISCORD_LIMIT) -> list[str]:
             if len(current) + len(addition) + room_to_close > limit:
                 flush()
             current += addition
+            # Le bloc est ouvert dès le premier morceau de sa ligne d'ouverture : si elle est découpée,
+            # la suite est dans le bloc, à refermer en fin de morceau.
+            if new_fence:
+                open_fence = new_fence
 
-        if match:
-            open_fence = None if open_fence is not None else line.strip()
+        if match and not new_fence:
+            open_fence = None
 
     # Dernier morceau : pas de bloc à refermer artificiellement s'il est déjà fermé.
     body = current.rstrip("\n")
@@ -85,7 +94,15 @@ def split_message(text: str, limit: int = DISCORD_LIMIT) -> list[str]:
     return chunks
 
 
+def _fence(match: re.Match[str]) -> str:
+    """Marqueur d'ouverture d'un bloc (« ```py »), sans le texte qui suit sur la même ligne."""
+    language = match.group(1)
+    return "```" + (language if len(language) <= _MAX_LANGUAGE else "")
+
+
 def _split_long_line(line: str, limit: int) -> list[str]:
+    # Au moins un caractère par morceau, même si la place manque : sinon la ligne ne raccourcit jamais.
+    limit = max(limit, 1)
     if len(line) <= limit:
         return [line]
     pieces: list[str] = []

+ 42 - 0
tests/test_text_utils.py

@@ -1,5 +1,6 @@
 """Tests du découpage des messages et du retrait de la réflexion. Lancer depuis la racine du projet : python -m unittest"""
 
+import threading
 import unittest
 
 from bot.text_utils import DISCORD_LIMIT, split_message, strip_reasoning
@@ -150,6 +151,47 @@ class SplitMessageTest(unittest.TestCase):
                 for chunk in split_message(text, 40):
                     self.assertLessEqual(len(chunk), 40, chunk)
 
+    def split_in_time(self, text: str, limit: int, delay: float = 2) -> list[str]:
+        """Lance split_message dans un fil à part : une boucle infinie fait échouer le test au lieu de le bloquer.
+
+        Le fil est « daemon » pour ne pas empêcher la fin des tests s'il ne s'arrête jamais."""
+        result: list[list[str]] = []
+        thread = threading.Thread(target=lambda: result.append(split_message(text, limit)), daemon=True)
+        thread.start()
+        thread.join(delay)
+        if thread.is_alive():
+            self.fail(f"split_message ne rend pas la main en {delay} s (limite {limit})")
+        return result[0]
+
+    def test_long_opening_fence_line(self):
+        # Une ligne qui ouvre un bloc puis continue sur des centaines de caractères (« ```py » suivi de
+        # texte) : seul « ```py » est rouvert au début des morceaux suivants, et le découpage se termine.
+        for limit, size in ((DISCORD_LIMIT, 1000), (40, 23)):
+            openings = {
+                "un long mot": "```py " + "w" * (2 * size),
+                "avec des espaces": "```py " + "w " * size,
+                "sans aucun espace": "```" + "w" * (3 * size),
+            }
+            for name, opening in openings.items():
+                with self.subTest(limit=limit, ouverture=name):
+                    chunks = self.split_in_time(opening + "\ncode\n```\nfin", limit)
+                    self.assert_valid(chunks, limit)
+                    self.assertTrue(any("code" in chunk.split("\n") for chunk in chunks), chunks)
+                    self.assertTrue(chunks[-1].endswith("fin"), chunks[-1])
+                    # Un bloc rouvert ne reprend que « ```py » (ou « ``` »), pas le texte de la ligne d'ouverture.
+                    for chunk in chunks[1:]:
+                        first = chunk.split("\n")[0]
+                        if first.startswith("```"):
+                            self.assertLessEqual(len(first), len("```py"), chunk)
+
+    def test_limit_too_small_for_fence(self):
+        # Une limite plus petite que « ```python » et sa fermeture ne peut pas être tenue, mais le
+        # découpage doit quand même se terminer, sans rien perdre du code (la ligne « ```python » elle-même
+        # est coupée lettre par lettre, d'où « python » devant).
+        chunks = self.split_in_time("```python\n" + "abcdef" * 3 + "\n```", 8)
+        self.assertEqual("".join(line for chunk in chunks for line in chunk.split("\n") if "`" not in line),
+                         "python" + "abcdef" * 3)
+
 
 class StripReasoningTest(unittest.TestCase):
     def test_think_block_at_start_removed(self):