| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- """Mémoire des conversations par salon, sauvegardée dans un fichier JSON."""
- from __future__ import annotations
- import asyncio
- import json
- import logging
- import os
- import shutil
- from datetime import datetime
- from pathlib import Path
- logger = logging.getLogger("chatbot.history")
- FILE_VERSION = 3
- # Remplace une image dans la mémoire (les images elles-mêmes ne sont pas conservées).
- IMAGE_MARKER = "[image jointe : tu l'as vue et analysée à ce moment-là, mais elle n'est plus conservée en mémoire]"
- _OLD_IMAGE_MARKER = "[image jointe]"
- # Ajouté au message de l'utilisateur quand Claude a fait une recherche web pour y répondre
- # (les recherches et leurs résultats ne sont pas conservés).
- 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]"
- # Ajouté à un message modifié sur Discord après avoir été mémorisé.
- EDITED_MARKER = "[message modifié après ta réponse]"
- # Remplace le texte d'un message supprimé de Discord après avoir été mémorisé…
- DELETED_MARKER = "[message supprimé du salon depuis ta réponse]"
- # … et sa citation dans les messages qui y répondaient.
- DELETED_REPLY = "en réponse à un message supprimé depuis"
- # Ce qu'un message supprimé perd dans la mémoire : seuls son identifiant, sa date et son auteur restent.
- _DELETED_KEYS = ("reply_to", "reply", "forward", "content", "edited", "attachments")
- def render_line(line: dict) -> str:
- """Texte d'une ligne de la mémoire, tel qu'il est envoyé à Claude.
- Une ligne est un message Discord (id, date, author, content ou forward, et éventuellement
- reply, reply_to, edited, attachments, ou deleted seul), ou une simple note du bot (content seul)."""
- if "author" not in line:
- return line["content"]
- head = f"{line['date']}, {line['author']}"
- if line.get("deleted"):
- return f"{head} a écrit : {DELETED_MARKER}"
- if "forward" in line:
- text = f"{head} a transféré un message {line['forward']}"
- else:
- reply = f" ({line['reply']})" if line.get("reply") else ""
- text = f"{head} a écrit{reply} : {line['content']}"
- if line.get("edited"):
- text += f" {EDITED_MARKER}"
- return " ".join([text, *line.get("attachments", [])])
- class ConversationHistory:
- """Garde les derniers échanges de chaque salon.
- Chaque message de l'utilisateur garde ses lignes séparées, une par message Discord, avec
- son identifiant : un message modifié ou supprimé sur Discord l'est aussi dans la mémoire.
- Tout est en texte : une image est remplacée par IMAGE_MARKER, ce qui évite de conserver
- des liens Discord qui expirent et de repayer l'image à chaque message.
- """
- def __init__(self, file_path: Path, max_length: int):
- self.file_path = file_path
- self.max_length = max_length
- # Marge au-delà de max_length avant de couper (voir _trim) : la moitié, en échanges entiers.
- self.margin = max(2, max_length // 4 * 2)
- self._data: dict[str, list[dict]] = self._load()
- self._locks: dict[str, asyncio.Lock] = {}
- self._save_lock = asyncio.Lock()
- # ------------------------------------------------------------------ accès
- def lock(self, channel_id: int | str) -> asyncio.Lock:
- """Verrou par salon : les messages d'un même salon sont traités un par un."""
- key = str(channel_id)
- if key not in self._locks:
- self._locks[key] = asyncio.Lock()
- return self._locks[key]
- def get(self, channel_id: int | str) -> list[dict]:
- """Renvoie les messages mémorisés pour ce salon, au format de l'API d'Anthropic."""
- return [self._render(m) for m in self._data.get(str(channel_id), [])]
- async def add_exchange(self, channel_id: int | str, lines: list[dict], assistant_text: str,
- web_used: bool = False) -> None:
- """Ajoute un échange complet (lignes de l'utilisateur + réponse) puis sauvegarde."""
- key = str(channel_id)
- messages = self._data.setdefault(key, [])
- user: dict = {"role": "user", "lines": lines}
- if web_used:
- user["web"] = True
- messages.append(user)
- messages.append({"role": "assistant", "content": assistant_text})
- self._data[key] = self._trim(messages)
- await self._save()
- async def reset(self, channel_id: int | str) -> None:
- """Vide la mémoire du salon, après la fin d'une éventuelle réponse en cours."""
- # Sans le verrou, une réponse en cours réécrirait son échange juste après le reset.
- async with self.lock(channel_id):
- self._data[str(channel_id)] = []
- await self._save()
- async def edit(self, channel_id: int | str, message_id: int, content: str) -> bool:
- """Remplace le texte d'un message modifié sur Discord. Renvoie True s'il était mémorisé."""
- if not self._known(channel_id):
- return False
- # Le verrou attend la fin d'une réponse en cours : le message n'est mémorisé qu'après.
- async with self.lock(channel_id):
- for line in self._lines(channel_id):
- if line.get("id") == message_id and "content" in line and line["content"] != content:
- line["content"] = content
- line["edited"] = True
- await self._save()
- return True
- return False
- async def delete(self, channel_id: int | str, message_ids: set[int]) -> bool:
- """Efface le contenu des messages supprimés sur Discord, ainsi que leurs citations.
- La ligne reste, réduite à sa date et à son auteur, avec la réponse du bot : la
- conversation garde son fil sans conserver ce qui a été supprimé.
- Renvoie True si la mémoire a changé."""
- if not self._known(channel_id):
- return False
- async with self.lock(channel_id):
- changed = False
- for line in self._lines(channel_id):
- if line.get("id") in message_ids and not line.get("deleted"):
- for key in _DELETED_KEYS:
- line.pop(key, None)
- line["deleted"] = True
- changed = True
- elif line.get("reply_to") in message_ids:
- line["reply"] = DELETED_REPLY
- del line["reply_to"]
- changed = True
- if changed:
- await self._save()
- return changed
- # ------------------------------------------------------------ internes
- def _known(self, channel_id: int | str) -> bool:
- """Salon dont la mémoire existe ou est en train d'être écrite."""
- return str(channel_id) in self._data or str(channel_id) in self._locks
- def _lines(self, channel_id: int | str):
- for entry in self._data.get(str(channel_id), []):
- if entry["role"] == "user":
- yield from entry["lines"]
- @staticmethod
- def _render(entry: dict) -> dict:
- if entry["role"] == "assistant":
- return {"role": "assistant", "content": entry["content"]}
- content = "\n".join(render_line(line) for line in entry["lines"])
- if entry.get("web"):
- content += f" {WEB_MARKER}"
- return {"role": "user", "content": content}
- def _trim(self, messages: list[dict]) -> list[dict]:
- """Garde au moins les N derniers messages, en commençant toujours par un message utilisateur.
- La mémoire est coupée par blocs : elle grandit jusqu'à N + marge messages, puis revient à N
- d'un coup. Entre deux coupes, son début ne change pas et reste dans le cache de prompt ;
- couper un échange à chaque message obligerait à réécrire tout l'historique en cache."""
- if len(messages) <= self.max_length + self.margin:
- trimmed = messages
- else:
- trimmed = messages[-self.max_length:]
- while trimmed and trimmed[0]["role"] != "user":
- trimmed = trimmed[1:]
- return trimmed
- def _load(self) -> dict[str, list[dict]]:
- if not self.file_path.exists():
- return {}
- try:
- raw = json.loads(self.file_path.read_text(encoding="utf-8"))
- channels = self._channels(raw)
- except (ValueError, OSError) as exc: # JSON invalide, mauvais encodage ou structure inattendue
- self._set_aside(exc)
- return {}
- if raw.get("version") != FILE_VERSION:
- self._keep_old_copy(raw.get("version") or 1)
- return {str(cid): self._trim(self._sanitize(msgs)) for cid, msgs in channels.items()}
- @staticmethod
- def _channels(raw) -> dict:
- """Messages de chaque salon, quel que soit le format du fichier (ValueError s'il est inattendu)."""
- if not isinstance(raw, dict):
- raise ValueError(f"le fichier contient un {type(raw).__name__} au lieu d'un dictionnaire")
- if raw.get("version") in (2, FILE_VERSION):
- channels = raw.get("channels") or {}
- if not isinstance(channels, dict):
- raise ValueError(f"« channels » est un {type(channels).__name__} au lieu d'un dictionnaire")
- return channels
- return {cid: val.get("messages") for cid, val in raw.items() if isinstance(val, dict)}
- def _set_aside(self, exc: Exception) -> None:
- """Met de côté un fichier illisible, sous un nom daté : il sera remplacé à la prochaine
- sauvegarde, et les copies précédentes ne sont jamais écrasées."""
- stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
- backup = self.file_path.with_name(f"{self.file_path.name}.corrompu-{stamp}")
- logger.error("Fichier d'historique illisible (%s). Copie de secours : %s", exc, backup)
- try:
- os.replace(self.file_path, backup)
- except OSError as err:
- logger.error("Copie de secours impossible : %s", err)
- def _keep_old_copy(self, version: int) -> None:
- """Copie un fichier d'un ancien format avant sa conversion : une version précédente
- du bot ne sait pas lire le nouveau format et repartirait d'une mémoire vide."""
- stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
- backup = self.file_path.with_name(f"{self.file_path.name}.v{version}-{stamp}")
- try:
- shutil.copy2(self.file_path, backup)
- except OSError as err:
- logger.error("Copie de l'ancien historique impossible : %s", err)
- return
- logger.info("Historique au format %s : conversion au format %s. Copie de l'ancien fichier : %s",
- version, FILE_VERSION, backup)
- @classmethod
- def _sanitize(cls, messages) -> list[dict]:
- """Met les messages au format actuel (en convertissant les anciens formats, dont celui
- de Mistral), et fusionne les messages consécutifs d'un même rôle."""
- if not isinstance(messages, list):
- return []
- clean: list[dict] = []
- for msg in messages:
- entry = cls._entry(msg)
- if entry is None:
- continue
- if clean and clean[-1]["role"] == entry["role"]:
- previous = clean[-1]
- if entry["role"] == "user":
- previous["lines"] += entry["lines"]
- if entry.get("web"):
- previous["web"] = True
- else:
- previous["content"] += "\n" + entry["content"]
- else:
- clean.append(entry)
- return clean
- @classmethod
- def _entry(cls, msg) -> dict | None:
- """Un message du fichier au format actuel, ou None s'il est vide ou illisible."""
- if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
- return None
- if msg["role"] == "user" and isinstance(msg.get("lines"), list):
- lines = [line for line in msg["lines"] if cls._readable(line)]
- if not lines:
- return None
- return {"role": "user", "lines": lines, **({"web": True} if msg.get("web") else {})}
- # Anciens formats : un seul texte par message.
- content = msg.get("content")
- if isinstance(content, list):
- parts = []
- for block in content:
- if not isinstance(block, dict):
- continue
- if block.get("type") == "text":
- parts.append(block.get("text", ""))
- elif block.get("type") in ("image", "image_url"):
- parts.append(IMAGE_MARKER)
- content = " ".join(p for p in parts if p)
- if not isinstance(content, str) or not content.strip():
- return None
- if msg["role"] == "assistant":
- return {"role": "assistant", "content": content}
- # Le texte converti reste identique une fois rendu : le cache de prompt est conservé.
- web = content.endswith(f" {WEB_MARKER}")
- if web:
- content = content[: -len(WEB_MARKER) - 1]
- if content.endswith(_OLD_IMAGE_MARKER):
- content = content[: -len(_OLD_IMAGE_MARKER)] + IMAGE_MARKER
- return {"role": "user", "lines": [{"content": content}], **({"web": True} if web else {})}
- @staticmethod
- def _readable(line) -> bool:
- if not isinstance(line, dict):
- return False
- try:
- return bool(render_line(line).strip())
- except (KeyError, TypeError, AttributeError):
- return False
- async def _save(self) -> None:
- payload = {"version": FILE_VERSION, "channels": self._data}
- text = json.dumps(payload, ensure_ascii=False, indent=2)
- async with self._save_lock:
- try:
- await asyncio.to_thread(self._write_atomic, text)
- except OSError as exc:
- logger.error("Impossible de sauvegarder l'historique : %s", exc)
- def _write_atomic(self, text: str) -> None:
- # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
- tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
- tmp.write_text(text, encoding="utf-8")
- os.replace(tmp, self.file_path)
|