history.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  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. self._data: dict[str, list[dict]] = self._load()
  27. self._locks: dict[str, asyncio.Lock] = {}
  28. self._save_lock = asyncio.Lock()
  29. # ------------------------------------------------------------------ accès
  30. def lock(self, channel_id: int | str) -> asyncio.Lock:
  31. """Verrou par salon : les messages d'un même salon sont traités un par un."""
  32. key = str(channel_id)
  33. if key not in self._locks:
  34. self._locks[key] = asyncio.Lock()
  35. return self._locks[key]
  36. def get(self, channel_id: int | str) -> list[dict]:
  37. """Renvoie une copie des messages mémorisés pour ce salon."""
  38. return [dict(m) for m in self._data.get(str(channel_id), [])]
  39. async def add_exchange(self, channel_id: int | str, user_text: str, assistant_text: str) -> None:
  40. """Ajoute un échange complet (question + réponse) puis sauvegarde."""
  41. key = str(channel_id)
  42. messages = self._data.setdefault(key, [])
  43. messages.append({"role": "user", "content": user_text})
  44. messages.append({"role": "assistant", "content": assistant_text})
  45. self._data[key] = self._trim(messages)
  46. await self._save()
  47. async def reset(self, channel_id: int | str) -> None:
  48. """Vide la mémoire du salon, après la fin d'une éventuelle réponse en cours."""
  49. # Sans le verrou, une réponse en cours réécrirait son échange juste après le reset.
  50. async with self.lock(channel_id):
  51. self._data[str(channel_id)] = []
  52. await self._save()
  53. # ------------------------------------------------------------ internes
  54. def _trim(self, messages: list[dict]) -> list[dict]:
  55. """Garde les N derniers messages, en commençant toujours par un message utilisateur."""
  56. trimmed = messages[-self.max_length:]
  57. while trimmed and trimmed[0]["role"] != "user":
  58. trimmed = trimmed[1:]
  59. return trimmed
  60. def _load(self) -> dict[str, list[dict]]:
  61. if not self.file_path.exists():
  62. return {}
  63. try:
  64. channels = self._channels(json.loads(self.file_path.read_text(encoding="utf-8")))
  65. except (ValueError, OSError) as exc: # JSON invalide, mauvais encodage ou structure inattendue
  66. self._set_aside(exc)
  67. return {}
  68. return {str(cid): self._trim(self._sanitize(msgs)) for cid, msgs in channels.items()}
  69. @staticmethod
  70. def _channels(raw) -> dict:
  71. """Messages de chaque salon, quel que soit le format du fichier (ValueError s'il est inattendu)."""
  72. if not isinstance(raw, dict):
  73. raise ValueError(f"le fichier contient un {type(raw).__name__} au lieu d'un dictionnaire")
  74. if raw.get("version") == FILE_VERSION:
  75. channels = raw.get("channels") or {}
  76. if not isinstance(channels, dict):
  77. raise ValueError(f"« channels » est un {type(channels).__name__} au lieu d'un dictionnaire")
  78. return channels
  79. logger.info("Ancien format d'historique détecté : conversion automatique.")
  80. return {cid: val.get("messages") for cid, val in raw.items() if isinstance(val, dict)}
  81. def _set_aside(self, exc: Exception) -> None:
  82. """Met de côté un fichier illisible, sous un nom daté : il sera remplacé à la prochaine
  83. sauvegarde, et les copies précédentes ne sont jamais écrasées."""
  84. stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
  85. backup = self.file_path.with_name(f"{self.file_path.name}.corrompu-{stamp}")
  86. logger.error("Fichier d'historique illisible (%s). Copie de secours : %s", exc, backup)
  87. try:
  88. os.replace(self.file_path, backup)
  89. except OSError as err:
  90. logger.error("Copie de secours impossible : %s", err)
  91. @staticmethod
  92. def _sanitize(messages) -> list[dict]:
  93. """Convertit des messages (éventuellement de l'ancien format Mistral) en texte simple,
  94. et fusionne les messages consécutifs d'un même rôle."""
  95. if not isinstance(messages, list):
  96. return []
  97. clean: list[dict] = []
  98. for msg in messages:
  99. if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
  100. continue
  101. content = msg.get("content")
  102. if isinstance(content, list):
  103. parts = []
  104. for block in content:
  105. if not isinstance(block, dict):
  106. continue
  107. if block.get("type") == "text":
  108. parts.append(block.get("text", ""))
  109. elif block.get("type") in ("image", "image_url"):
  110. parts.append(IMAGE_MARKER)
  111. content = " ".join(p for p in parts if p)
  112. if not isinstance(content, str) or not content.strip():
  113. continue
  114. if msg["role"] == "user" and content.endswith(_OLD_IMAGE_MARKER):
  115. content = content[: -len(_OLD_IMAGE_MARKER)] + IMAGE_MARKER
  116. if clean and clean[-1]["role"] == msg["role"]:
  117. clean[-1]["content"] += "\n" + content
  118. else:
  119. clean.append({"role": msg["role"], "content": content})
  120. return clean
  121. async def _save(self) -> None:
  122. payload = {"version": FILE_VERSION, "channels": self._data}
  123. text = json.dumps(payload, ensure_ascii=False, indent=2)
  124. async with self._save_lock:
  125. try:
  126. await asyncio.to_thread(self._write_atomic, text)
  127. except OSError as exc:
  128. logger.error("Impossible de sauvegarder l'historique : %s", exc)
  129. def _write_atomic(self, text: str) -> None:
  130. # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
  131. tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
  132. tmp.write_text(text, encoding="utf-8")
  133. os.replace(tmp, self.file_path)