1
0

history.py 5.7 KB

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