claude_client.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  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. logger = logging.getLogger("chatbot.claude")
  10. SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
  11. DEFAULT_PERSONALITY = (
  12. "Tu es un membre sympathique d'un serveur Discord francophone. "
  13. "Tu réponds toujours en français, avec un ton naturel, amical et décontracté, "
  14. "et des réponses plutôt courtes, adaptées à une discussion de chat. "
  15. "Chaque message que tu reçois indique la date, l'heure et le pseudo de son auteur ; "
  16. "plusieurs personnes peuvent parler dans la même conversation. "
  17. "Quand on te montre une image, décris ce que tu vois puis réponds à la question s'il y en a une."
  18. )
  19. # Ajouté automatiquement à la fin de la personnalité, quel que soit le contenu du fichier.
  20. MEMORY_NOTE = (
  21. "\n\nNote technique sur ta mémoire : les images ne sont pas conservées d'un message à l'autre. "
  22. "Dans les messages précédents, la mention « [image jointe : …] » signifie qu'une image a bien été envoyée "
  23. "et que tu l'as réellement vue et analysée à ce moment-là ; ta réponse d'alors en décrit le contenu. "
  24. "Ne dis donc jamais que tu ne l'as pas vue, et ne t'excuse pas à ce sujet. "
  25. "Si on te demande un détail de cette image que ta réponse précédente ne mentionnait pas, "
  26. "explique simplement que tu ne gardes pas les images en mémoire et propose qu'on te la renvoie."
  27. )
  28. class ClaudeError(Exception):
  29. """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
  30. def __init__(self, user_message: str):
  31. super().__init__(user_message)
  32. self.user_message = user_message
  33. @dataclass
  34. class ImageData:
  35. media_type: str
  36. data: bytes
  37. class ClaudeClient:
  38. def __init__(self, settings: Settings):
  39. self.settings = settings
  40. # Le SDK réessaie tout seul les erreurs temporaires (surcharge, limite de requêtes…).
  41. self._client = anthropic.AsyncAnthropic(
  42. api_key=settings.anthropic_api_key,
  43. timeout=settings.api_timeout,
  44. max_retries=3,
  45. )
  46. self._personality_cache: tuple[float, str] | None = None
  47. async def close(self) -> None:
  48. await self._client.close()
  49. # ------------------------------------------------------------ personnalité
  50. def personality(self) -> str:
  51. """Lit personality_prompt.txt (relu automatiquement s'il a été modifié)."""
  52. path: Path = self.settings.personality_file
  53. try:
  54. mtime = path.stat().st_mtime
  55. if self._personality_cache is None or self._personality_cache[0] != mtime:
  56. text = path.read_text(encoding="utf-8").strip()
  57. self._personality_cache = (mtime, text or DEFAULT_PERSONALITY)
  58. logger.info("Personnalité chargée depuis %s", path)
  59. return self._personality_cache[1]
  60. except FileNotFoundError:
  61. if self._personality_cache != (0.0, DEFAULT_PERSONALITY):
  62. logger.warning("%s introuvable : utilisation de la personnalité par défaut.", path)
  63. self._personality_cache = (0.0, DEFAULT_PERSONALITY)
  64. return DEFAULT_PERSONALITY
  65. # ------------------------------------------------------------------ appel
  66. @staticmethod
  67. def build_user_content(text: str, image: ImageData | None = None) -> str | list[dict]:
  68. if image is None:
  69. return text
  70. return [
  71. {
  72. "type": "image",
  73. "source": {
  74. "type": "base64",
  75. "media_type": image.media_type,
  76. "data": base64.standard_b64encode(image.data).decode("ascii"),
  77. },
  78. },
  79. {"type": "text", "text": text},
  80. ]
  81. async def ask(self, history: list[dict], user_text: str, image: ImageData | None = None) -> str:
  82. """Envoie l'historique + le nouveau message, et renvoie la réponse de Claude."""
  83. messages = list(history) + [{"role": "user", "content": self.build_user_content(user_text, image)}]
  84. params: dict = {
  85. "model": self.settings.model,
  86. "max_tokens": self.settings.max_tokens,
  87. "system": self.personality() + MEMORY_NOTE,
  88. "messages": messages,
  89. }
  90. # Par défaut, Sonnet 5.5 réfléchit avant de répondre, et cette réflexion consomme
  91. # le budget MAX_TOKENS. "between_tools" désactive la réflexion préalable
  92. # (le bot n'utilisant pas d'outils, il n'y a alors aucune réflexion).
  93. params["thinking"] = {"type": "adaptive"} if self.settings.thinking else {"type": "between_tools"}
  94. if self.settings.effort:
  95. params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
  96. try:
  97. response = await self._client.messages.create(**params)
  98. except anthropic.RateLimitError:
  99. logger.warning("Limite de requêtes Anthropic atteinte.")
  100. raise ClaudeError("Je reçois trop de messages en ce moment, réessaie dans une minute.")
  101. except anthropic.APITimeoutError:
  102. logger.warning("Délai dépassé lors de l'appel à Claude.")
  103. raise ClaudeError("Je mets trop de temps à répondre, réessaie dans un instant.")
  104. except anthropic.APIConnectionError as exc:
  105. logger.error("Connexion à l'API impossible : %s", exc)
  106. raise ClaudeError("Je n'arrive pas à joindre mon cerveau pour l'instant, réessaie plus tard.")
  107. except anthropic.BadRequestError as exc:
  108. logger.error("Requête refusée par l'API : %s", exc)
  109. if image is not None:
  110. raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
  111. raise ClaudeError("Ta demande a été refusée par l'API, essaie de la reformuler.")
  112. except anthropic.AuthenticationError:
  113. logger.critical("Clé API Anthropic invalide : vérifie ANTHROPIC_API_KEY dans le .env.")
  114. raise ClaudeError("Je suis mal configuré, préviens l'administrateur du bot.")
  115. except anthropic.APIStatusError as exc:
  116. logger.error("Erreur de l'API (%s) : %s", exc.status_code, exc)
  117. raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")
  118. text = "".join(block.text for block in response.content if block.type == "text").strip()
  119. self._log_usage(response)
  120. if response.stop_reason == "max_tokens":
  121. logger.warning("Réponse coupée par MAX_TOKENS (%s) : augmente MAX_TOKENS dans le .env "
  122. "si cela arrive souvent.", self.settings.max_tokens)
  123. text += " […]"
  124. if not text:
  125. logger.warning("Réponse vide de Claude (stop_reason=%s).", response.stop_reason)
  126. raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
  127. return text
  128. def _log_usage(self, response) -> None:
  129. usage = response.usage
  130. cost = (usage.input_tokens * self.settings.price_input_per_mtok
  131. + usage.output_tokens * self.settings.price_output_per_mtok) / 1_000_000
  132. logger.info(
  133. "Appel Claude (%s) — entrée : %s tokens, sortie : %s tokens, coût estimé : %.5f $",
  134. response.model, usage.input_tokens, usage.output_tokens, cost,
  135. )