1
0

history.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  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 pathlib import Path
  8. logger = logging.getLogger("chatbot.history")
  9. FILE_VERSION = 2
  10. # Remplace une image dans la mémoire (les images elles-mêmes ne sont pas conservées).
  11. IMAGE_MARKER = "[image jointe : tu l'as vue et analysée à ce moment-là, mais elle n'est plus conservée en mémoire]"
  12. _OLD_IMAGE_MARKER = "[image jointe]"
  13. # Ajouté au message de l'utilisateur quand Claude a fait une recherche web pour y répondre
  14. # (les recherches et leurs résultats ne sont pas conservés).
  15. 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]"
  16. class ConversationHistory:
  17. """Garde les derniers échanges de chaque salon.
  18. Les messages sont stockés au format de l'API d'Anthropic, en texte uniquement :
  19. une image est remplacée par IMAGE_MARKER, ce qui évite de conserver des liens
  20. Discord qui expirent et de repayer l'image à chaque message.
  21. """
  22. def __init__(self, file_path: Path, max_length: int):
  23. self.file_path = file_path
  24. self.max_length = max_length
  25. self._data: dict[str, list[dict]] = self._load()
  26. self._locks: dict[str, asyncio.Lock] = {}
  27. self._save_lock = asyncio.Lock()
  28. # ------------------------------------------------------------------ accès
  29. def lock(self, channel_id: int | str) -> asyncio.Lock:
  30. """Verrou par salon : les messages d'un même salon sont traités un par un."""
  31. key = str(channel_id)
  32. if key not in self._locks:
  33. self._locks[key] = asyncio.Lock()
  34. return self._locks[key]
  35. def get(self, channel_id: int | str) -> list[dict]:
  36. """Renvoie une copie des messages mémorisés pour ce salon."""
  37. return [dict(m) for m in self._data.get(str(channel_id), [])]
  38. async def add_exchange(self, channel_id: int | str, user_text: str, assistant_text: str) -> None:
  39. """Ajoute un échange complet (question + réponse) puis sauvegarde."""
  40. key = str(channel_id)
  41. messages = self._data.setdefault(key, [])
  42. messages.append({"role": "user", "content": user_text})
  43. messages.append({"role": "assistant", "content": assistant_text})
  44. self._data[key] = self._trim(messages)
  45. await self._save()
  46. async def reset(self, channel_id: int | str) -> None:
  47. self._data[str(channel_id)] = []
  48. await self._save()
  49. # ------------------------------------------------------------ internes
  50. def _trim(self, messages: list[dict]) -> list[dict]:
  51. """Garde les N derniers messages, en commençant toujours par un message utilisateur."""
  52. trimmed = messages[-self.max_length:]
  53. while trimmed and trimmed[0]["role"] != "user":
  54. trimmed = trimmed[1:]
  55. return trimmed
  56. def _load(self) -> dict[str, list[dict]]:
  57. if not self.file_path.exists():
  58. return {}
  59. try:
  60. raw = json.loads(self.file_path.read_text(encoding="utf-8"))
  61. except (json.JSONDecodeError, OSError) as exc:
  62. backup = self.file_path.with_suffix(self.file_path.suffix + ".corrompu")
  63. logger.error("Fichier d'historique illisible (%s). Copie de secours : %s", exc, backup)
  64. try:
  65. os.replace(self.file_path, backup)
  66. except OSError:
  67. pass
  68. return {}
  69. if isinstance(raw, dict) and raw.get("version") == FILE_VERSION:
  70. channels = raw.get("channels", {})
  71. else:
  72. logger.info("Ancien format d'historique détecté : conversion automatique.")
  73. channels = {cid: (val or {}).get("messages", []) for cid, val in (raw or {}).items()
  74. if isinstance(val, dict)}
  75. return {str(cid): self._trim(self._sanitize(msgs)) for cid, msgs in channels.items()}
  76. @staticmethod
  77. def _sanitize(messages: list) -> list[dict]:
  78. """Convertit des messages (éventuellement de l'ancien format Mistral) en texte simple,
  79. et fusionne les messages consécutifs d'un même rôle."""
  80. clean: list[dict] = []
  81. for msg in messages:
  82. if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
  83. continue
  84. content = msg.get("content")
  85. if isinstance(content, list):
  86. parts = []
  87. for block in content:
  88. if not isinstance(block, dict):
  89. continue
  90. if block.get("type") == "text":
  91. parts.append(block.get("text", ""))
  92. elif block.get("type") in ("image", "image_url"):
  93. parts.append(IMAGE_MARKER)
  94. content = " ".join(p for p in parts if p)
  95. if not isinstance(content, str) or not content.strip():
  96. continue
  97. if msg["role"] == "user" and content.endswith(_OLD_IMAGE_MARKER):
  98. content = content[: -len(_OLD_IMAGE_MARKER)] + IMAGE_MARKER
  99. if clean and clean[-1]["role"] == msg["role"]:
  100. clean[-1]["content"] += "\n" + content
  101. else:
  102. clean.append({"role": msg["role"], "content": content})
  103. return clean
  104. async def _save(self) -> None:
  105. payload = {"version": FILE_VERSION, "channels": self._data}
  106. text = json.dumps(payload, ensure_ascii=False, indent=2)
  107. async with self._save_lock:
  108. try:
  109. await asyncio.to_thread(self._write_atomic, text)
  110. except OSError as exc:
  111. logger.error("Impossible de sauvegarder l'historique : %s", exc)
  112. def _write_atomic(self, text: str) -> None:
  113. # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
  114. tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
  115. tmp.write_text(text, encoding="utf-8")
  116. os.replace(tmp, self.file_path)