1
0

text_utils.py 2.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  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. body += "\n```"
  37. if body.strip():
  38. chunks.append(body)
  39. current = (open_fence + "\n") if open_fence is not None else ""
  40. for line in text.split("\n"):
  41. # Une ligne trop longue à elle seule est découpée en morceaux plus petits.
  42. for piece in _split_long_line(line, limit - 10 - (len(open_fence) if open_fence else 0)):
  43. addition = piece + "\n"
  44. if len(current) + len(addition) + closing_len() > limit:
  45. flush()
  46. current += addition
  47. match = _FENCE_RE.match(line)
  48. if match:
  49. open_fence = None if open_fence is not None else line.strip()
  50. # Dernier morceau : pas de bloc à refermer artificiellement s'il est déjà fermé.
  51. body = current.rstrip("\n")
  52. if body.strip() and body.strip() != (open_fence or ""):
  53. chunks.append(body)
  54. return chunks
  55. def _split_long_line(line: str, limit: int) -> list[str]:
  56. if len(line) <= limit:
  57. return [line]
  58. pieces: list[str] = []
  59. while len(line) > limit:
  60. cut = line.rfind(" ", 0, limit)
  61. if cut <= limit // 2: # pas d'espace raisonnable : coupe nette
  62. cut = limit
  63. pieces.append(line[:cut].rstrip())
  64. line = line[cut:].lstrip()
  65. if line:
  66. pieces.append(line)
  67. return pieces