history.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  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. import shutil
  8. from datetime import datetime
  9. from pathlib import Path
  10. from .text_utils import strip_reasoning
  11. logger = logging.getLogger("chatbot.history")
  12. FILE_VERSION = 3
  13. # Remplace une image dans la mémoire (les images elles-mêmes ne sont pas conservées).
  14. IMAGE_MARKER = "[image jointe : tu l'as vue et analysée à ce moment-là, mais elle n'est plus conservée en mémoire]"
  15. _OLD_IMAGE_MARKER = "[image jointe]"
  16. # Ajouté au message de l'utilisateur quand Claude a fait une recherche web pour y répondre
  17. # (les recherches et leurs résultats ne sont pas conservés).
  18. 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]"
  19. # Ajouté à un message modifié sur Discord après avoir été mémorisé.
  20. EDITED_MARKER = "[message modifié après ta réponse]"
  21. # Remplace le texte d'un message supprimé de Discord après avoir été mémorisé…
  22. DELETED_MARKER = "[message supprimé du salon depuis ta réponse]"
  23. # … et sa citation dans les messages qui y répondaient.
  24. DELETED_REPLY = "en réponse à un message supprimé depuis"
  25. # Ce qu'un message supprimé perd dans la mémoire : seuls son identifiant, sa date et son auteur restent.
  26. _DELETED_KEYS = ("reply_to", "reply", "forward", "content", "edited", "attachments")
  27. def images_marker(count: int) -> str:
  28. """Remplace les images d'un message dans la mémoire : un seul repère, quel que soit leur nombre."""
  29. if count == 1:
  30. return IMAGE_MARKER
  31. return f"[{count} images jointes : tu les as vues et analysées à ce moment-là, mais elles ne sont plus conservées en mémoire]"
  32. def render_line(line: dict) -> str:
  33. """Texte d'une ligne de la mémoire, tel qu'il est envoyé à Claude.
  34. Une ligne est un message Discord (id, date, author, content ou forward, et éventuellement
  35. reply, reply_to, edited, attachments, ou deleted seul), ou une simple note du bot (content seul)."""
  36. if "author" not in line:
  37. return line["content"]
  38. head = f"{line['date']}, {line['author']}"
  39. if line.get("deleted"):
  40. return f"{head} a écrit : {DELETED_MARKER}"
  41. if "forward" in line:
  42. text = f"{head} a transféré un message {line['forward']}"
  43. else:
  44. reply = f" ({line['reply']})" if line.get("reply") else ""
  45. text = f"{head} a écrit{reply} : {line['content']}"
  46. if line.get("edited"):
  47. text += f" {EDITED_MARKER}"
  48. return " ".join([text, *line.get("attachments", [])])
  49. class ConversationHistory:
  50. """Garde les derniers échanges de chaque salon.
  51. Chaque message de l'utilisateur garde ses lignes séparées, une par message Discord, avec
  52. son identifiant : un message modifié ou supprimé sur Discord l'est aussi dans la mémoire.
  53. Tout est en texte : une image est remplacée par IMAGE_MARKER, ce qui évite de conserver
  54. des liens Discord qui expirent et de repayer l'image à chaque message.
  55. """
  56. def __init__(self, file_path: Path, max_length: int):
  57. self.file_path = file_path
  58. self.max_length = max_length
  59. # Marge au-delà de max_length avant de couper (voir _trim) : la moitié, en échanges entiers.
  60. self.margin = max(2, max_length // 4 * 2)
  61. self._data: dict[str, list[dict]] = self._load()
  62. self._locks: dict[str, asyncio.Lock] = {}
  63. self._save_lock = asyncio.Lock()
  64. # ------------------------------------------------------------------ accès
  65. def lock(self, channel_id: int | str) -> asyncio.Lock:
  66. """Verrou par salon : les messages d'un même salon sont traités un par un."""
  67. key = str(channel_id)
  68. if key not in self._locks:
  69. self._locks[key] = asyncio.Lock()
  70. return self._locks[key]
  71. def get(self, channel_id: int | str) -> list[dict]:
  72. """Renvoie les messages mémorisés pour ce salon, au format de l'API d'Anthropic."""
  73. return [self._render(m) for m in self._data.get(str(channel_id), [])]
  74. async def add_exchange(self, channel_id: int | str, lines: list[dict], assistant_text: str,
  75. web_used: bool = False) -> None:
  76. """Ajoute un échange complet (lignes de l'utilisateur + réponse) puis sauvegarde."""
  77. key = str(channel_id)
  78. messages = self._data.setdefault(key, [])
  79. user: dict = {"role": "user", "lines": lines}
  80. if web_used:
  81. user["web"] = True
  82. messages.append(user)
  83. messages.append({"role": "assistant", "content": assistant_text})
  84. self._data[key] = self._trim(messages)
  85. await self._save()
  86. async def reset(self, channel_id: int | str) -> None:
  87. """Vide la mémoire du salon, après la fin d'une éventuelle réponse en cours."""
  88. # Sans le verrou, une réponse en cours réécrirait son échange juste après le reset.
  89. async with self.lock(channel_id):
  90. self._data[str(channel_id)] = []
  91. await self._save()
  92. async def edit(self, channel_id: int | str, message_id: int, content: str) -> bool:
  93. """Remplace le texte d'un message modifié sur Discord. Renvoie True s'il était mémorisé."""
  94. if not self._known(channel_id):
  95. return False
  96. # Le verrou attend la fin d'une réponse en cours : le message n'est mémorisé qu'après.
  97. async with self.lock(channel_id):
  98. for line in self._lines(channel_id):
  99. if line.get("id") == message_id and "content" in line and line["content"] != content:
  100. line["content"] = content
  101. line["edited"] = True
  102. await self._save()
  103. return True
  104. return False
  105. async def delete(self, channel_id: int | str, message_ids: set[int]) -> bool:
  106. """Efface le contenu des messages supprimés sur Discord, ainsi que leurs citations.
  107. La ligne reste, réduite à sa date et à son auteur, avec la réponse du bot : la
  108. conversation garde son fil sans conserver ce qui a été supprimé.
  109. Renvoie True si la mémoire a changé."""
  110. if not self._known(channel_id):
  111. return False
  112. async with self.lock(channel_id):
  113. changed = False
  114. for line in self._lines(channel_id):
  115. if line.get("id") in message_ids and not line.get("deleted"):
  116. for key in _DELETED_KEYS:
  117. line.pop(key, None)
  118. line["deleted"] = True
  119. changed = True
  120. elif line.get("reply_to") in message_ids:
  121. line["reply"] = DELETED_REPLY
  122. del line["reply_to"]
  123. changed = True
  124. if changed:
  125. await self._save()
  126. return changed
  127. # ------------------------------------------------------------ internes
  128. def _known(self, channel_id: int | str) -> bool:
  129. """Salon dont la mémoire existe ou est en train d'être écrite."""
  130. return str(channel_id) in self._data or str(channel_id) in self._locks
  131. def _lines(self, channel_id: int | str):
  132. for entry in self._data.get(str(channel_id), []):
  133. if entry["role"] == "user":
  134. yield from entry["lines"]
  135. @staticmethod
  136. def _render(entry: dict) -> dict:
  137. if entry["role"] == "assistant":
  138. return {"role": "assistant", "content": entry["content"]}
  139. content = "\n".join(render_line(line) for line in entry["lines"])
  140. if entry.get("web"):
  141. content += f" {WEB_MARKER}"
  142. return {"role": "user", "content": content}
  143. def _trim(self, messages: list[dict]) -> list[dict]:
  144. """Garde au moins les N derniers messages, en commençant toujours par un message utilisateur.
  145. La mémoire est coupée par blocs : elle grandit jusqu'à N + marge messages, puis revient à N
  146. d'un coup. Entre deux coupes, son début ne change pas et reste dans le cache de prompt ;
  147. couper un échange à chaque message obligerait à réécrire tout l'historique en cache."""
  148. if len(messages) <= self.max_length + self.margin:
  149. trimmed = messages
  150. else:
  151. trimmed = messages[-self.max_length:]
  152. while trimmed and trimmed[0]["role"] != "user":
  153. trimmed = trimmed[1:]
  154. return trimmed
  155. def _load(self) -> dict[str, list[dict]]:
  156. if not self.file_path.exists():
  157. return {}
  158. try:
  159. raw = json.loads(self.file_path.read_text(encoding="utf-8"))
  160. channels = self._channels(raw)
  161. except (ValueError, OSError) as exc: # JSON invalide, mauvais encodage ou structure inattendue
  162. self._set_aside(exc)
  163. return {}
  164. if raw.get("version") != FILE_VERSION:
  165. self._keep_old_copy(raw.get("version") or 1)
  166. return {str(cid): self._trim(self._sanitize(msgs)) for cid, msgs in channels.items()}
  167. @staticmethod
  168. def _channels(raw) -> dict:
  169. """Messages de chaque salon, quel que soit le format du fichier (ValueError s'il est inattendu)."""
  170. if not isinstance(raw, dict):
  171. raise ValueError(f"le fichier contient un {type(raw).__name__} au lieu d'un dictionnaire")
  172. if raw.get("version") in (2, FILE_VERSION):
  173. channels = raw.get("channels") or {}
  174. if not isinstance(channels, dict):
  175. raise ValueError(f"« channels » est un {type(channels).__name__} au lieu d'un dictionnaire")
  176. return channels
  177. return {cid: val.get("messages") for cid, val in raw.items() if isinstance(val, dict)}
  178. def _set_aside(self, exc: Exception) -> None:
  179. """Met de côté un fichier illisible, sous un nom daté : il sera remplacé à la prochaine
  180. sauvegarde, et les copies précédentes ne sont jamais écrasées."""
  181. stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
  182. backup = self.file_path.with_name(f"{self.file_path.name}.corrompu-{stamp}")
  183. logger.error("Fichier d'historique illisible (%s). Copie de secours : %s", exc, backup)
  184. try:
  185. os.replace(self.file_path, backup)
  186. except OSError as err:
  187. logger.error("Copie de secours impossible : %s", err)
  188. def _keep_old_copy(self, version: int) -> None:
  189. """Copie un fichier d'un ancien format avant sa conversion : une version précédente
  190. du bot ne sait pas lire le nouveau format et repartirait d'une mémoire vide."""
  191. stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
  192. backup = self.file_path.with_name(f"{self.file_path.name}.v{version}-{stamp}")
  193. try:
  194. shutil.copy2(self.file_path, backup)
  195. except OSError as err:
  196. logger.error("Copie de l'ancien historique impossible : %s", err)
  197. return
  198. logger.info("Historique au format %s : conversion au format %s. Copie de l'ancien fichier : %s",
  199. version, FILE_VERSION, backup)
  200. @classmethod
  201. def _sanitize(cls, messages) -> list[dict]:
  202. """Met les messages au format actuel (en convertissant les anciens formats, dont celui
  203. de Mistral), et fusionne les messages consécutifs d'un même rôle."""
  204. if not isinstance(messages, list):
  205. return []
  206. clean: list[dict] = []
  207. for msg in messages:
  208. entry = cls._entry(msg)
  209. if entry is None:
  210. continue
  211. if clean and clean[-1]["role"] == entry["role"]:
  212. previous = clean[-1]
  213. if entry["role"] == "user":
  214. previous["lines"] += entry["lines"]
  215. if entry.get("web"):
  216. previous["web"] = True
  217. else:
  218. previous["content"] += "\n" + entry["content"]
  219. else:
  220. clean.append(entry)
  221. return clean
  222. @classmethod
  223. def _entry(cls, msg) -> dict | None:
  224. """Un message du fichier au format actuel, ou None s'il est vide ou illisible."""
  225. if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
  226. return None
  227. if msg["role"] == "user" and isinstance(msg.get("lines"), list):
  228. lines = [line for line in msg["lines"] if cls._readable(line)]
  229. if not lines:
  230. return None
  231. return {"role": "user", "lines": lines, **({"web": True} if msg.get("web") else {})}
  232. # Anciens formats : un seul texte par message.
  233. content = msg.get("content")
  234. if isinstance(content, list):
  235. parts = []
  236. for block in content:
  237. if not isinstance(block, dict):
  238. continue
  239. if block.get("type") == "text":
  240. parts.append(block.get("text", ""))
  241. elif block.get("type") in ("image", "image_url"):
  242. parts.append(IMAGE_MARKER)
  243. content = " ".join(p for p in parts if p)
  244. if not isinstance(content, str) or not content.strip():
  245. return None
  246. if msg["role"] == "assistant":
  247. # Réflexion affichée par erreur avant la 5.8 : le modèle l'imiterait en la relisant.
  248. content = strip_reasoning(content)
  249. return {"role": "assistant", "content": content} if content else None
  250. # Le texte converti reste identique une fois rendu : le cache de prompt est conservé.
  251. web = content.endswith(f" {WEB_MARKER}")
  252. if web:
  253. content = content[: -len(WEB_MARKER) - 1]
  254. if content.endswith(_OLD_IMAGE_MARKER):
  255. content = content[: -len(_OLD_IMAGE_MARKER)] + IMAGE_MARKER
  256. return {"role": "user", "lines": [{"content": content}], **({"web": True} if web else {})}
  257. @staticmethod
  258. def _readable(line) -> bool:
  259. if not isinstance(line, dict):
  260. return False
  261. try:
  262. return bool(render_line(line).strip())
  263. except (KeyError, TypeError, AttributeError):
  264. return False
  265. async def _save(self) -> None:
  266. payload = {"version": FILE_VERSION, "channels": self._data}
  267. text = json.dumps(payload, ensure_ascii=False, indent=2)
  268. async with self._save_lock:
  269. try:
  270. await asyncio.to_thread(self._write_atomic, text)
  271. except OSError as exc:
  272. logger.error("Impossible de sauvegarder l'historique : %s", exc)
  273. def _write_atomic(self, text: str) -> None:
  274. # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
  275. tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
  276. tmp.write_text(text, encoding="utf-8")
  277. os.replace(tmp, self.file_path)