1
0

claude_client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  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. # Ajouté quand WEB_SEARCH=on.
  29. WEB_NOTE = (
  30. "\n\nTu as accès à Internet (recherche web et lecture de pages). "
  31. "Ne t'en sers que si la question porte sur l'actualité, des informations récentes ou précises "
  32. "que tu ne connais pas, ou un lien partagé dans la conversation ; sinon réponds directement. "
  33. "Garde des réponses courtes ; quand c'est utile, nomme simplement ta source dans ta phrase "
  34. "(par exemple « selon Famitsu »), sans recopier d'adresse. "
  35. "Ne commente pas tes recherches (pas de « je vais vérifier », « je regarde le résultat »…) : donne directement la réponse. "
  36. "Dans les messages précédents, la mention « [recherche web : …] » signifie que tu as bien cherché sur Internet "
  37. "pour y répondre : ta réponse d'alors s'appuyait sur de vrais résultats, même si tu ne les vois plus. "
  38. "Ne prétends donc jamais avoir inventé une réponse marquée ainsi."
  39. )
  40. # Nombre maximal de relances quand l'API met la réponse en pause pendant les recherches.
  41. MAX_CONTINUATIONS = 3
  42. class ClaudeError(Exception):
  43. """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
  44. def __init__(self, user_message: str):
  45. super().__init__(user_message)
  46. self.user_message = user_message
  47. @dataclass
  48. class ImageData:
  49. media_type: str
  50. data: bytes
  51. @dataclass
  52. class Answer:
  53. text: str
  54. web_used: bool # Claude a fait au moins une recherche web ou lu une page
  55. class ClaudeClient:
  56. def __init__(self, settings: Settings):
  57. self.settings = settings
  58. # Le SDK réessaie tout seul les erreurs temporaires (surcharge, limite de requêtes…).
  59. self._client = anthropic.AsyncAnthropic(
  60. api_key=settings.anthropic_api_key,
  61. timeout=settings.api_timeout,
  62. max_retries=3,
  63. )
  64. self._personality_cache: tuple[float, str] | None = None
  65. async def close(self) -> None:
  66. await self._client.close()
  67. # ------------------------------------------------------------ personnalité
  68. def personality(self) -> str:
  69. """Lit personality_prompt.txt (relu automatiquement s'il a été modifié)."""
  70. path: Path = self.settings.personality_file
  71. try:
  72. mtime = path.stat().st_mtime
  73. if self._personality_cache is None or self._personality_cache[0] != mtime:
  74. text = path.read_text(encoding="utf-8").strip()
  75. self._personality_cache = (mtime, text or DEFAULT_PERSONALITY)
  76. logger.info("Personnalité chargée depuis %s", path)
  77. return self._personality_cache[1]
  78. except FileNotFoundError:
  79. if self._personality_cache != (0.0, DEFAULT_PERSONALITY):
  80. logger.warning("%s introuvable : utilisation de la personnalité par défaut.", path)
  81. self._personality_cache = (0.0, DEFAULT_PERSONALITY)
  82. return DEFAULT_PERSONALITY
  83. # ------------------------------------------------------------------ appel
  84. @staticmethod
  85. def build_user_content(text: str, image: ImageData | None = None) -> str | list[dict]:
  86. if image is None:
  87. return text
  88. return [
  89. {
  90. "type": "image",
  91. "source": {
  92. "type": "base64",
  93. "media_type": image.media_type,
  94. "data": base64.standard_b64encode(image.data).decode("ascii"),
  95. },
  96. },
  97. {"type": "text", "text": text},
  98. ]
  99. async def ask(self, history: list[dict], user_text: str, image: ImageData | None = None) -> Answer:
  100. """Envoie l'historique + le nouveau message, et renvoie la réponse de Claude."""
  101. messages = list(history) + [{"role": "user", "content": self.build_user_content(user_text, image)}]
  102. system = self.personality() + MEMORY_NOTE
  103. if self.settings.web_search:
  104. system += WEB_NOTE
  105. # Cache de prompt : les outils et la personnalité, identiques d'un message à l'autre, sont
  106. # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
  107. # Le marqueur sur "system" couvre ce bloc fixe ; celui de premier niveau couvre en plus
  108. # l'historique, qui change à chaque message mais reprend le début de la requête précédente.
  109. params: dict = {
  110. "model": self.settings.model,
  111. "max_tokens": self.settings.max_tokens,
  112. "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
  113. "messages": messages,
  114. "cache_control": {"type": "ephemeral"},
  115. }
  116. # Par défaut, Sonnet 5.5 réfléchit avant de répondre, et cette réflexion consomme
  117. # le budget MAX_TOKENS. "between_tools" désactive la réflexion préalable
  118. # (il ne reste qu'une courte réflexion entre deux recherches web, si elles sont activées).
  119. params["thinking"] = {"type": "adaptive"} if self.settings.thinking else {"type": "between_tools"}
  120. if self.settings.effort:
  121. params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
  122. if self.settings.web_search:
  123. # Outils exécutés par Anthropic : pas de boucle d'outils à gérer de notre côté.
  124. params["tools"] = [
  125. {"type": "web_search_20260209", "name": "web_search", "max_uses": self.settings.web_max_uses},
  126. {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
  127. ]
  128. responses = [await self._call(params, image)]
  129. # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
  130. # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
  131. while responses[-1].stop_reason == "pause_turn" and len(responses) <= MAX_CONTINUATIONS:
  132. messages = messages + [{"role": "assistant", "content": responses[-1].content}]
  133. responses.append(await self._call({**params, "messages": messages}, image))
  134. text = self._final_text(responses)
  135. web_used = self._log_usage(responses)
  136. stop_reason = responses[-1].stop_reason
  137. if stop_reason == "max_tokens":
  138. logger.warning("Réponse coupée par MAX_TOKENS (%s) : augmente MAX_TOKENS dans le .env "
  139. "si cela arrive souvent.", self.settings.max_tokens)
  140. text += " […]"
  141. elif stop_reason == "pause_turn":
  142. logger.warning("Recherche web toujours en cours après %s relances : réponse partielle.", MAX_CONTINUATIONS)
  143. if not text:
  144. logger.warning("Réponse vide de Claude (stop_reason=%s).", stop_reason)
  145. raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
  146. return Answer(text, web_used)
  147. async def _call(self, params: dict, image: ImageData | None):
  148. try:
  149. return await self._client.messages.create(**params)
  150. except anthropic.RateLimitError:
  151. logger.warning("Limite de requêtes Anthropic atteinte.")
  152. raise ClaudeError("Je reçois trop de messages en ce moment, réessaie dans une minute.")
  153. except anthropic.APITimeoutError:
  154. logger.warning("Délai dépassé lors de l'appel à Claude.")
  155. raise ClaudeError("Je mets trop de temps à répondre, réessaie dans un instant.")
  156. except anthropic.APIConnectionError as exc:
  157. logger.error("Connexion à l'API impossible : %s", exc)
  158. raise ClaudeError("Je n'arrive pas à joindre mon cerveau pour l'instant, réessaie plus tard.")
  159. except anthropic.BadRequestError as exc:
  160. logger.error("Requête refusée par l'API : %s", exc)
  161. if image is not None:
  162. raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
  163. raise ClaudeError("Ta demande a été refusée par l'API, essaie de la reformuler.")
  164. except anthropic.AuthenticationError:
  165. logger.critical("Clé API Anthropic invalide : vérifie ANTHROPIC_API_KEY dans le .env.")
  166. raise ClaudeError("Je suis mal configuré, préviens l'administrateur du bot.")
  167. except anthropic.APIStatusError as exc:
  168. logger.error("Erreur de l'API (%s) : %s", exc.status_code, exc)
  169. raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")
  170. @staticmethod
  171. def _final_text(responses) -> str:
  172. """Texte de la réponse finale, sans les commentaires écrits entre deux recherches."""
  173. parts: list[str] = []
  174. for response in responses:
  175. for block in response.content:
  176. if block.type == "text":
  177. parts.append(block.text)
  178. elif block.type not in ("thinking", "redacted_thinking"):
  179. # Appel ou résultat d'outil : le texte écrit avant n'était qu'un commentaire.
  180. parts = []
  181. return "".join(parts).strip()
  182. def _log_usage(self, responses) -> bool:
  183. """Journalise la consommation et indique si Claude a utilisé Internet."""
  184. input_tokens = sum(r.usage.input_tokens for r in responses)
  185. cache_write = sum(r.usage.cache_creation_input_tokens or 0 for r in responses)
  186. cache_read = sum(r.usage.cache_read_input_tokens or 0 for r in responses)
  187. output_tokens = sum(r.usage.output_tokens for r in responses)
  188. searches = fetches = 0
  189. for r in responses:
  190. if r.usage.server_tool_use:
  191. searches += r.usage.server_tool_use.web_search_requests or 0
  192. fetches += r.usage.server_tool_use.web_fetch_requests or 0
  193. # Écriture en cache : 125 % du prix d'entrée ; lecture depuis le cache : 10 %.
  194. cost = ((input_tokens + cache_write * 1.25 + cache_read * 0.1) * self.settings.price_input_per_mtok
  195. + output_tokens * self.settings.price_output_per_mtok) / 1_000_000
  196. cost += searches * self.settings.price_web_search_per_1000 / 1000
  197. web = f", recherches web : {searches}, pages lues : {fetches}" if searches or fetches else ""
  198. logger.info(
  199. "Appel Claude (%s) — entrée : %s tokens (+ cache : %s lus, %s écrits), sortie : %s tokens%s, "
  200. "coût estimé : %.5f $",
  201. responses[-1].model, input_tokens, cache_read, cache_write, output_tokens, web, cost,
  202. )
  203. return bool(searches or fetches)