text_utils.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. """Outils de mise en forme du texte pour Discord."""
  2. from __future__ import annotations
  3. import re
  4. DISCORD_LIMIT = 2000
  5. _FENCE_RE = re.compile(r"^\s*```(\S*)")
  6. # Au-delà, le mot collé à ``` n'est pas un nom de langage : le bloc est rouvert avec ``` seul.
  7. _MAX_LANGUAGE = 20
  8. # Bloc dans lequel le modèle écrit parfois sa réflexion au lieu de la garder pour lui.
  9. _REASONING_BLOCK = re.compile(r"\s*<(think|thinking|reasoning|function_calls|invoke)\b[^>]*>.*?</\1\s*>",
  10. re.S | re.I)
  11. def strip_reasoning(text: str) -> str:
  12. """Retire la réflexion écrite au début de la réponse (<think>…</think>, faux appel d'outil…).
  13. Seul un bloc complet tout au début est retiré : c'est là que le modèle l'écrit, et une
  14. balise plus loin dans le texte (exemple, code) peut être voulue."""
  15. while match := _REASONING_BLOCK.match(text):
  16. text = text[match.end():]
  17. return text.strip()
  18. def split_message(text: str, limit: int = DISCORD_LIMIT) -> list[str]:
  19. """Découpe un long texte en morceaux de `limit` caractères maximum.
  20. - coupe de préférence entre les lignes, puis entre les mots ;
  21. - si un bloc de code (```) est coupé, il est refermé à la fin du morceau
  22. et rouvert (avec le même langage) au début du suivant.
  23. """
  24. text = text.strip()
  25. if not text:
  26. return []
  27. if len(text) <= limit:
  28. return [text]
  29. chunks: list[str] = []
  30. current = ""
  31. open_fence: str | None = None # marqueur du bloc de code en cours, ex. "```python" (sans le texte qui suit)
  32. def closing_len() -> int:
  33. return len("\n```") if open_fence is not None else 0
  34. def flush() -> None:
  35. nonlocal current
  36. body = current.rstrip("\n")
  37. if open_fence is not None:
  38. head, _, last = body.rpartition("\n")
  39. if last.strip() == open_fence:
  40. # Bloc ouvert en fin de morceau, encore vide : sa ligne d'ouverture passe au morceau suivant.
  41. body = head
  42. else:
  43. body += "\n```"
  44. if body.strip():
  45. chunks.append(body)
  46. current = (open_fence + "\n") if open_fence is not None else ""
  47. for line in text.split("\n"):
  48. match = _FENCE_RE.match(line)
  49. new_fence = _fence(match) if match and open_fence is None else None
  50. # Une ligne trop longue à elle seule est découpée en morceaux plus petits, assez courts pour
  51. # tenir avec la réouverture du bloc (en cours ou ouvert par cette ligne) et sa fermeture.
  52. fence = new_fence or open_fence
  53. pieces = _split_long_line(line, limit - 10 - (len(fence) if fence else 0))
  54. # Une ligne qui ouvre un bloc doit laisser la place de le refermer. Celle qui le ferme n'en a pas
  55. # besoin, sauf si elle est découpée : le bloc reste alors ouvert entre ses morceaux.
  56. if new_fence:
  57. room_to_close = len("\n```")
  58. elif match and len(pieces) == 1:
  59. room_to_close = 0
  60. else:
  61. room_to_close = closing_len()
  62. for piece in pieces:
  63. addition = piece + "\n"
  64. if len(current) + len(addition) + room_to_close > limit:
  65. flush()
  66. current += addition
  67. # Le bloc est ouvert dès le premier morceau de sa ligne d'ouverture : si elle est découpée,
  68. # la suite est dans le bloc, à refermer en fin de morceau.
  69. if new_fence:
  70. open_fence = new_fence
  71. if match and not new_fence:
  72. open_fence = None
  73. # Dernier morceau : pas de bloc à refermer artificiellement s'il est déjà fermé.
  74. body = current.rstrip("\n")
  75. if body.strip() and body.strip() != (open_fence or ""):
  76. chunks.append(body)
  77. return chunks
  78. def _fence(match: re.Match[str]) -> str:
  79. """Marqueur d'ouverture d'un bloc (« ```py »), sans le texte qui suit sur la même ligne."""
  80. language = match.group(1)
  81. return "```" + (language if len(language) <= _MAX_LANGUAGE else "")
  82. def _split_long_line(line: str, limit: int) -> list[str]:
  83. # Au moins un caractère par morceau, même si la place manque : sinon la ligne ne raccourcit jamais.
  84. limit = max(limit, 1)
  85. if len(line) <= limit:
  86. return [line]
  87. pieces: list[str] = []
  88. while len(line) > limit:
  89. cut = line.rfind(" ", 0, limit)
  90. if cut <= limit // 2: # pas d'espace raisonnable : coupe nette
  91. cut = limit
  92. pieces.append(line[:cut].rstrip())
  93. line = line[cut:].lstrip()
  94. if line:
  95. pieces.append(line)
  96. return pieces