history.py 14 KB

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