text_utils.py 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  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. # Bloc dans lequel le modèle écrit parfois sa réflexion au lieu de la garder pour lui.
  7. _REASONING_BLOCK = re.compile(r"\s*<(think|thinking|reasoning|function_calls|invoke)\b[^>]*>.*?</\1\s*>",
  8. re.S | re.I)
  9. def strip_reasoning(text: str) -> str:
  10. """Retire la réflexion écrite au début de la réponse (<think>…</think>, faux appel d'outil…).
  11. Seul un bloc complet tout au début est retiré : c'est là que le modèle l'écrit, et une
  12. balise plus loin dans le texte (exemple, code) peut être voulue."""
  13. while match := _REASONING_BLOCK.match(text):
  14. text = text[match.end():]
  15. return text.strip()
  16. def split_message(text: str, limit: int = DISCORD_LIMIT) -> list[str]:
  17. """Découpe un long texte en morceaux de `limit` caractères maximum.
  18. - coupe de préférence entre les lignes, puis entre les mots ;
  19. - si un bloc de code (```) est coupé, il est refermé à la fin du morceau
  20. et rouvert (avec le même langage) au début du suivant.
  21. """
  22. text = text.strip()
  23. if not text:
  24. return []
  25. if len(text) <= limit:
  26. return [text]
  27. chunks: list[str] = []
  28. current = ""
  29. open_fence: str | None = None # ligne d'ouverture du bloc de code en cours, ex. "```python"
  30. def closing_len() -> int:
  31. return len("\n```") if open_fence is not None else 0
  32. def flush() -> None:
  33. nonlocal current
  34. body = current.rstrip("\n")
  35. if open_fence is not None:
  36. head, _, last = body.rpartition("\n")
  37. if last.strip() == open_fence:
  38. # Bloc ouvert en fin de morceau, encore vide : sa ligne d'ouverture passe au morceau suivant.
  39. body = head
  40. else:
  41. body += "\n```"
  42. if body.strip():
  43. chunks.append(body)
  44. current = (open_fence + "\n") if open_fence is not None else ""
  45. for line in text.split("\n"):
  46. match = _FENCE_RE.match(line)
  47. # Une ligne qui ouvre un bloc doit laisser la place de le refermer.
  48. room_to_close = len("\n```") if match and open_fence is None else closing_len()
  49. # Une ligne trop longue à elle seule est découpée en morceaux plus petits.
  50. for piece in _split_long_line(line, limit - 10 - (len(open_fence) if open_fence else 0)):
  51. addition = piece + "\n"
  52. if len(current) + len(addition) + room_to_close > limit:
  53. flush()
  54. current += addition
  55. if match:
  56. open_fence = None if open_fence is not None else line.strip()
  57. # Dernier morceau : pas de bloc à refermer artificiellement s'il est déjà fermé.
  58. body = current.rstrip("\n")
  59. if body.strip() and body.strip() != (open_fence or ""):
  60. chunks.append(body)
  61. return chunks
  62. def _split_long_line(line: str, limit: int) -> list[str]:
  63. if len(line) <= limit:
  64. return [line]
  65. pieces: list[str] = []
  66. while len(line) > limit:
  67. cut = line.rfind(" ", 0, limit)
  68. if cut <= limit // 2: # pas d'espace raisonnable : coupe nette
  69. cut = limit
  70. pieces.append(line[:cut].rstrip())
  71. line = line[cut:].lstrip()
  72. if line:
  73. pieces.append(line)
  74. return pieces