1
0

claude_client.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. """Communication avec l'API de Claude (Anthropic)."""
  2. from __future__ import annotations
  3. import base64
  4. import logging
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. import anthropic
  8. from .config import Settings
  9. from .models import thinking_off
  10. logger = logging.getLogger("chatbot.claude")
  11. SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
  12. DEFAULT_PERSONALITY = (
  13. "Tu es un membre sympathique d'un serveur Discord francophone. "
  14. "Tu réponds toujours en français, avec un ton naturel, amical et décontracté, "
  15. "et des réponses plutôt courtes, adaptées à une discussion de chat. "
  16. "Chaque message que tu reçois indique la date, l'heure et le pseudo de son auteur ; "
  17. "plusieurs personnes peuvent parler dans la même conversation. "
  18. "Quand on te montre une image, décris ce que tu vois puis réponds à la question s'il y en a une."
  19. )
  20. # Ajouté automatiquement à la fin de la personnalité, quel que soit le contenu du fichier.
  21. MEMORY_NOTE = (
  22. "\n\nNote technique sur ta mémoire : les images ne sont pas conservées d'un message à l'autre. "
  23. "Dans les messages précédents, la mention « [image jointe : …] » signifie qu'une image a bien été envoyée "
  24. "et que tu l'as réellement vue et analysée à ce moment-là ; ta réponse d'alors en décrit le contenu. "
  25. "Ne dis donc jamais que tu ne l'as pas vue, et ne t'excuse pas à ce sujet. "
  26. "Si on te demande un détail de cette image que ta réponse précédente ne mentionnait pas, "
  27. "explique simplement que tu ne gardes pas les images en mémoire et propose qu'on te la renvoie."
  28. )
  29. # Ajouté lui aussi automatiquement : les messages reçus pendant une réponse sont regroupés.
  30. GROUP_NOTE = (
  31. "\n\nUn même message peut regrouper plusieurs messages Discord, un par ligne, envoyés "
  32. "pendant que tu répondais au précédent : réponds à l'ensemble en une seule réponse, "
  33. "naturellement, sans signaler qu'ils ont été regroupés."
  34. )
  35. # Ajouté lui aussi automatiquement. Formulation générique recommandée par Anthropic : nommer les
  36. # balises de réflexion, ou interdire de réfléchir, rend au contraire leur apparition plus probable.
  37. OUTPUT_NOTE = "\n\nN'inclus pas de balises XML internes ou système dans ta réponse."
  38. # Ajouté quand WEB_SEARCH=on.
  39. WEB_NOTE = (
  40. "\n\nTu as accès à Internet (recherche web et lecture de pages). "
  41. "Ne t'en sers que si la question porte sur l'actualité, des informations récentes ou précises "
  42. "que tu ne connais pas, ou un lien partagé dans la conversation ; sinon réponds directement. "
  43. "Garde des réponses courtes ; quand c'est utile, nomme simplement ta source dans ta phrase "
  44. "(par exemple « selon Famitsu »), sans recopier d'adresse. "
  45. # Le texte écrit avant un appel d'outil n'est jamais affiché (voir _final_text). L'interdire
  46. # pousse au contraire le modèle à écrire ses appels d'outils sous forme de texte.
  47. "Quand tu utilises un outil, tu peux d'abord dire une courte phrase. "
  48. "Si aucun outil ne permet de faire ce qu'on te demande, dis-le au lieu de deviner. "
  49. "Dans les messages précédents, la mention « [recherche web : …] » signifie que tu as bien cherché sur Internet "
  50. "pour y répondre : ta réponse d'alors s'appuyait sur de vrais résultats, même si tu ne les vois plus. "
  51. "Ne prétends donc jamais avoir inventé une réponse marquée ainsi."
  52. )
  53. # Nombre maximal de relances quand l'API met la réponse en pause pendant les recherches.
  54. MAX_CONTINUATIONS = 3
  55. class ClaudeError(Exception):
  56. """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
  57. def __init__(self, user_message: str):
  58. super().__init__(user_message)
  59. self.user_message = user_message
  60. @dataclass
  61. class ImageData:
  62. media_type: str
  63. data: bytes
  64. @dataclass
  65. class Answer:
  66. text: str
  67. web_used: bool # Claude a fait au moins une recherche web ou lu une page
  68. class ClaudeClient:
  69. def __init__(self, settings: Settings):
  70. self.settings = settings
  71. # Le SDK réessaie tout seul les erreurs temporaires (surcharge, limite de requêtes…).
  72. self._client = anthropic.AsyncAnthropic(
  73. api_key=settings.anthropic_api_key,
  74. timeout=settings.api_timeout,
  75. max_retries=3,
  76. )
  77. self._personality_cache: tuple[float, str] | None = None
  78. async def close(self) -> None:
  79. await self._client.close()
  80. # ------------------------------------------------------------ personnalité
  81. def personality(self) -> str:
  82. """Lit personality_prompt.txt (relu automatiquement s'il a été modifié)."""
  83. path: Path = self.settings.personality_file
  84. try:
  85. mtime = path.stat().st_mtime
  86. if self._personality_cache is None or self._personality_cache[0] != mtime:
  87. text = path.read_text(encoding="utf-8").strip()
  88. self._personality_cache = (mtime, text or DEFAULT_PERSONALITY)
  89. logger.info("Personnalité chargée depuis %s", path)
  90. return self._personality_cache[1]
  91. except FileNotFoundError:
  92. if self._personality_cache != (0.0, DEFAULT_PERSONALITY):
  93. logger.warning("%s introuvable : utilisation de la personnalité par défaut.", path)
  94. self._personality_cache = (0.0, DEFAULT_PERSONALITY)
  95. return DEFAULT_PERSONALITY
  96. # ------------------------------------------------------------------ appel
  97. @staticmethod
  98. def build_user_content(parts: list[tuple[str, ImageData | None]]) -> str | list[dict]:
  99. """Un ou plusieurs messages à la suite, chacun précédé de son image éventuelle."""
  100. if all(image is None for _, image in parts):
  101. return "\n".join(text for text, _ in parts)
  102. content: list[dict] = []
  103. for text, image in parts:
  104. if image is not None:
  105. content.append({
  106. "type": "image",
  107. "source": {
  108. "type": "base64",
  109. "media_type": image.media_type,
  110. "data": base64.standard_b64encode(image.data).decode("ascii"),
  111. },
  112. })
  113. content.append({"type": "text", "text": text})
  114. return content
  115. async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
  116. cache_history: bool = True) -> Answer:
  117. """Envoie l'historique + le ou les nouveaux messages (texte, image), et renvoie la réponse de Claude.
  118. cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
  119. reprendra pas (mentions : contexte différent à chaque fois)."""
  120. messages = list(history) + [{"role": "user", "content": self.build_user_content(parts)}]
  121. has_image = any(image is not None for _, image in parts)
  122. system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
  123. if self.settings.web_search:
  124. system += WEB_NOTE
  125. # Cache de prompt : les outils et la personnalité, identiques d'un message à l'autre, sont
  126. # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
  127. # Le marqueur sur "system" couvre ce bloc fixe ; celui de premier niveau couvre en plus
  128. # l'historique, qui change à chaque message mais reprend le début de la requête précédente.
  129. # Une écriture en cache coûte 25 % de plus : inutile si elle ne sera jamais relue.
  130. params: dict = {
  131. "model": self.settings.model,
  132. "max_tokens": self.settings.max_tokens,
  133. "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
  134. "messages": messages,
  135. }
  136. if cache_history:
  137. params["cache_control"] = {"type": "ephemeral"}
  138. # Par défaut, les modèles récents réfléchissent avant de répondre, et cette réflexion
  139. # consomme le budget MAX_TOKENS. Avec THINKING=off, on la coupe de la façon propre au
  140. # modèle (voir models.py) ; sur Opus 5.5, impossible : seul EFFORT la réduit.
  141. if self.settings.thinking:
  142. params["thinking"] = {"type": "adaptive"}
  143. elif (off := thinking_off(self.settings.model)) is not None:
  144. params["thinking"] = off
  145. if self.settings.effort:
  146. params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
  147. if self.settings.web_search:
  148. # Outils exécutés par Anthropic : pas de boucle d'outils à gérer de notre côté.
  149. params["tools"] = [
  150. {"type": "web_search_20260209", "name": "web_search", "max_uses": self.settings.web_max_uses},
  151. {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
  152. ]
  153. responses = [await self._call(params, has_image)]
  154. # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
  155. # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
  156. while responses[-1].stop_reason == "pause_turn" and len(responses) <= MAX_CONTINUATIONS:
  157. messages = messages + [{"role": "assistant", "content": responses[-1].content}]
  158. responses.append(await self._call({**params, "messages": messages}, has_image))
  159. text = self._final_text(responses)
  160. web_used = self._log_usage(responses)
  161. stop_reason = responses[-1].stop_reason
  162. if stop_reason == "max_tokens":
  163. logger.warning("Réponse coupée par MAX_TOKENS (%s) : augmente MAX_TOKENS dans le .env "
  164. "si cela arrive souvent.", self.settings.max_tokens)
  165. text += " […]"
  166. elif stop_reason == "pause_turn":
  167. logger.warning("Recherche web toujours en cours après %s relances : réponse partielle.", MAX_CONTINUATIONS)
  168. if not text:
  169. logger.warning("Réponse vide de Claude (stop_reason=%s).", stop_reason)
  170. raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
  171. return Answer(text, web_used)
  172. async def _call(self, params: dict, has_image: bool):
  173. try:
  174. return await self._client.messages.create(**params)
  175. except anthropic.RateLimitError:
  176. logger.warning("Limite de requêtes Anthropic atteinte.")
  177. raise ClaudeError("Je reçois trop de messages en ce moment, réessaie dans une minute.")
  178. except anthropic.APITimeoutError:
  179. logger.warning("Délai dépassé lors de l'appel à Claude.")
  180. raise ClaudeError("Je mets trop de temps à répondre, réessaie dans un instant.")
  181. except anthropic.APIConnectionError as exc:
  182. logger.error("Connexion à l'API impossible : %s", exc)
  183. raise ClaudeError("Je n'arrive pas à joindre mon cerveau pour l'instant, réessaie plus tard.")
  184. except anthropic.BadRequestError as exc:
  185. logger.error("Requête refusée par l'API : %s", exc)
  186. if has_image:
  187. raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
  188. raise ClaudeError("Ta demande a été refusée par l'API, essaie de la reformuler.")
  189. except anthropic.AuthenticationError:
  190. logger.critical("Clé API Anthropic invalide : vérifie ANTHROPIC_API_KEY dans le .env.")
  191. raise ClaudeError("Je suis mal configuré, préviens l'administrateur du bot.")
  192. except anthropic.APIStatusError as exc:
  193. logger.error("Erreur de l'API (%s) : %s", exc.status_code, exc)
  194. raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")
  195. @staticmethod
  196. def _final_text(responses) -> str:
  197. """Texte de la réponse finale, sans les commentaires écrits entre deux recherches."""
  198. parts: list[str] = []
  199. for response in responses:
  200. for block in response.content:
  201. if block.type == "text":
  202. parts.append(block.text)
  203. elif block.type not in ("thinking", "redacted_thinking"):
  204. # Appel ou résultat d'outil : le texte écrit avant n'était qu'un commentaire.
  205. parts = []
  206. return "".join(parts).strip()
  207. def _log_usage(self, responses) -> bool:
  208. """Journalise la consommation et indique si Claude a utilisé Internet."""
  209. input_tokens = sum(r.usage.input_tokens for r in responses)
  210. cache_write = sum(r.usage.cache_creation_input_tokens or 0 for r in responses)
  211. cache_read = sum(r.usage.cache_read_input_tokens or 0 for r in responses)
  212. output_tokens = sum(r.usage.output_tokens for r in responses)
  213. searches = fetches = 0
  214. for r in responses:
  215. if r.usage.server_tool_use:
  216. searches += r.usage.server_tool_use.web_search_requests or 0
  217. fetches += r.usage.server_tool_use.web_fetch_requests or 0
  218. price_input, price_output = self.settings.price_input_per_mtok, self.settings.price_output_per_mtok
  219. if price_input is None or price_output is None:
  220. cost = "inconnu (renseigne PRICE_INPUT_PER_MTOK et PRICE_OUTPUT_PER_MTOK dans le .env)"
  221. else:
  222. # Écriture en cache : 125 % du prix d'entrée ; lecture depuis le cache : 10 %.
  223. amount = ((input_tokens + cache_write * 1.25 + cache_read * 0.1) * price_input
  224. + output_tokens * price_output) / 1_000_000
  225. amount += searches * self.settings.price_web_search_per_1000 / 1000
  226. cost = f"{amount:.5f} $"
  227. web = f", recherches web : {searches}, pages lues : {fetches}" if searches or fetches else ""
  228. logger.info(
  229. "Appel Claude (%s) — entrée : %s tokens (+ cache : %s lus, %s écrits), sortie : %s tokens%s, "
  230. "coût estimé : %s",
  231. responses[-1].model, input_tokens, cache_read, cache_write, output_tokens, web, cost,
  232. )
  233. return bool(searches or fetches)