1
0

history.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. """Mémoire des conversations par salon, sauvegardée dans un fichier JSON."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import os
  7. from datetime import datetime
  8. from pathlib import Path
  9. logger = logging.getLogger("chatbot.history")
  10. FILE_VERSION = 2
  11. # Remplace une image dans la mémoire (les images elles-mêmes ne sont pas conservées).
  12. IMAGE_MARKER = "[image jointe : tu l'as vue et analysée à ce moment-là, mais elle n'est plus conservée en mémoire]"
  13. _OLD_IMAGE_MARKER = "[image jointe]"
  14. # Ajouté au message de l'utilisateur quand Claude a fait une recherche web pour y répondre
  15. # (les recherches et leurs résultats ne sont pas conservés).
  16. WEB_MARKER = "[recherche web : tu as réellement cherché sur Internet pour répondre à ce message, mais les résultats ne sont plus conservés en mémoire]"
  17. class ConversationHistory:
  18. """Garde les derniers échanges de chaque salon.
  19. Les messages sont stockés au format de l'API d'Anthropic, en texte uniquement :
  20. une image est remplacée par IMAGE_MARKER, ce qui évite de conserver des liens
  21. Discord qui expirent et de repayer l'image à chaque message.
  22. """
  23. def __init__(self, file_path: Path, max_length: int):
  24. self.file_path = file_path
  25. self.max_length = max_length
  26. # Marge au-delà de max_length avant de couper (voir _trim) : la moitié, en échanges entiers.
  27. self.margin = max(2, max_length // 4 * 2)
  28. self._data: dict[str, list[dict]] = self._load()
  29. self._locks: dict[str, asyncio.Lock] = {}
  30. self._save_lock = asyncio.Lock()
  31. # ------------------------------------------------------------------ accès
  32. def lock(self, channel_id: int | str) -> asyncio.Lock:
  33. """Verrou par salon : les messages d'un même salon sont traités un par un."""
  34. key = str(channel_id)
  35. if key not in self._locks:
  36. self._locks[key] = asyncio.Lock()
  37. return self._locks[key]
  38. def get(self, channel_id: int | str) -> list[dict]:
  39. """Renvoie une copie des messages mémorisés pour ce salon."""
  40. return [dict(m) for m in self._data.get(str(channel_id), [])]
  41. async def add_exchange(self, channel_id: int | str, user_text: str, assistant_text: str) -> None:
  42. """Ajoute un échange complet (question + réponse) puis sauvegarde."""
  43. key = str(channel_id)
  44. messages = self._data.setdefault(key, [])
  45. messages.append({"role": "user", "content": user_text})
  46. messages.append({"role": "assistant", "content": assistant_text})
  47. self._data[key] = self._trim(messages)
  48. await self._save()
  49. async def reset(self, channel_id: int | str) -> None:
  50. """Vide la mémoire du salon, après la fin d'une éventuelle réponse en cours."""
  51. # Sans le verrou, une réponse en cours réécrirait son échange juste après le reset.
  52. async with self.lock(channel_id):
  53. self._data[str(channel_id)] = []
  54. await self._save()
  55. # ------------------------------------------------------------ internes
  56. def _trim(self, messages: list[dict]) -> list[dict]:
  57. """Garde au moins les N derniers messages, en commençant toujours par un message utilisateur.
  58. La mémoire est coupée par blocs : elle grandit jusqu'à N + marge messages, puis revient à N
  59. d'un coup. Entre deux coupes, son début ne change pas et reste dans le cache de prompt ;
  60. couper un échange à chaque message obligerait à réécrire tout l'historique en cache."""
  61. if len(messages) <= self.max_length + self.margin:
  62. trimmed = messages
  63. else:
  64. trimmed = messages[-self.max_length:]
  65. while trimmed and trimmed[0]["role"] != "user":
  66. trimmed = trimmed[1:]
  67. return trimmed
  68. def _load(self) -> dict[str, list[dict]]:
  69. if not self.file_path.exists():
  70. return {}
  71. try:
  72. channels = self._channels(json.loads(self.file_path.read_text(encoding="utf-8")))
  73. except (ValueError, OSError) as exc: # JSON invalide, mauvais encodage ou structure inattendue
  74. self._set_aside(exc)
  75. return {}
  76. return {str(cid): self._trim(self._sanitize(msgs)) for cid, msgs in channels.items()}
  77. @staticmethod
  78. def _channels(raw) -> dict:
  79. """Messages de chaque salon, quel que soit le format du fichier (ValueError s'il est inattendu)."""
  80. if not isinstance(raw, dict):
  81. raise ValueError(f"le fichier contient un {type(raw).__name__} au lieu d'un dictionnaire")
  82. if raw.get("version") == FILE_VERSION:
  83. channels = raw.get("channels") or {}
  84. if not isinstance(channels, dict):
  85. raise ValueError(f"« channels » est un {type(channels).__name__} au lieu d'un dictionnaire")
  86. return channels
  87. logger.info("Ancien format d'historique détecté : conversion automatique.")
  88. return {cid: val.get("messages") for cid, val in raw.items() if isinstance(val, dict)}
  89. def _set_aside(self, exc: Exception) -> None:
  90. """Met de côté un fichier illisible, sous un nom daté : il sera remplacé à la prochaine
  91. sauvegarde, et les copies précédentes ne sont jamais écrasées."""
  92. stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
  93. backup = self.file_path.with_name(f"{self.file_path.name}.corrompu-{stamp}")
  94. logger.error("Fichier d'historique illisible (%s). Copie de secours : %s", exc, backup)
  95. try:
  96. os.replace(self.file_path, backup)
  97. except OSError as err:
  98. logger.error("Copie de secours impossible : %s", err)
  99. @staticmethod
  100. def _sanitize(messages) -> list[dict]:
  101. """Convertit des messages (éventuellement de l'ancien format Mistral) en texte simple,
  102. et fusionne les messages consécutifs d'un même rôle."""
  103. if not isinstance(messages, list):
  104. return []
  105. clean: list[dict] = []
  106. for msg in messages:
  107. if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
  108. continue
  109. content = msg.get("content")
  110. if isinstance(content, list):
  111. parts = []
  112. for block in content:
  113. if not isinstance(block, dict):
  114. continue
  115. if block.get("type") == "text":
  116. parts.append(block.get("text", ""))
  117. elif block.get("type") in ("image", "image_url"):
  118. parts.append(IMAGE_MARKER)
  119. content = " ".join(p for p in parts if p)
  120. if not isinstance(content, str) or not content.strip():
  121. continue
  122. if msg["role"] == "user" and content.endswith(_OLD_IMAGE_MARKER):
  123. content = content[: -len(_OLD_IMAGE_MARKER)] + IMAGE_MARKER
  124. if clean and clean[-1]["role"] == msg["role"]:
  125. clean[-1]["content"] += "\n" + content
  126. else:
  127. clean.append({"role": msg["role"], "content": content})
  128. return clean
  129. async def _save(self) -> None:
  130. payload = {"version": FILE_VERSION, "channels": self._data}
  131. text = json.dumps(payload, ensure_ascii=False, indent=2)
  132. async with self._save_lock:
  133. try:
  134. await asyncio.to_thread(self._write_atomic, text)
  135. except OSError as exc:
  136. logger.error("Impossible de sauvegarder l'historique : %s", exc)
  137. def _write_atomic(self, text: str) -> None:
  138. # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
  139. tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
  140. tmp.write_text(text, encoding="utf-8")
  141. os.replace(tmp, self.file_path)