Просмотр исходного кода

Version 5.3.0

- Accès à Internet (WEB_SEARCH=on, désactivé par défaut) : recherche web et
  lecture de liens via les outils web_search et web_fetch d'Anthropic.
- Seule la réponse finale est envoyée, sans les commentaires de recherche ;
  la mémoire indique au bot qu'il a réellement cherché.
- Cache de prompt (personnalité, outils, historique) : ~7x moins cher par message.
- Coût estimé dans les logs : prise en compte du cache et des recherches web.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 5 дней назад
Родитель
Сommit
f5a48d327e
7 измененных файлов с 154 добавлено и 28 удалено
  1. 10 1
      .env.exemple
  2. 1 1
      bot/__init__.py
  3. 97 22
      bot/claude_client.py
  4. 12 0
      bot/config.py
  5. 6 4
      bot/discord_bot.py
  6. 3 0
      bot/history.py
  7. 25 0
      changelog.txt

+ 10 - 1
.env.exemple

@@ -16,7 +16,14 @@ THINKING=off
 # Niveau d'effort : low, medium ou high. Vide = valeur par défaut du modèle.
 # Niveau d'effort : low, medium ou high. Vide = valeur par défaut du modèle.
 EFFORT=
 EFFORT=
 # Délai maximal d'attente d'une réponse, en secondes
 # Délai maximal d'attente d'une réponse, en secondes
-API_TIMEOUT=60
+API_TIMEOUT=90
+
+# --- Accès à Internet ----------------------------------------------------
+# Recherche web et lecture de liens : off ou on (payant : voir PRICE_WEB_SEARCH_PER_1000).
+# Avec on, prévois un API_TIMEOUT d'au moins 90 secondes.
+WEB_SEARCH=off
+# Nombre maximal de recherches (et de pages lues) par message
+WEB_MAX_USES=3
 
 
 # --- Mémoire -------------------------------------------------------------
 # --- Mémoire -------------------------------------------------------------
 # Nombre de messages gardés en mémoire dans le salon dédié (questions + réponses)
 # Nombre de messages gardés en mémoire dans le salon dédié (questions + réponses)
@@ -37,4 +44,6 @@ TIMEZONE=Europe/Paris
 # Tarifs en dollars par million de tokens, pour l'estimation des coûts dans les logs
 # Tarifs en dollars par million de tokens, pour l'estimation des coûts dans les logs
 PRICE_INPUT_PER_MTOK=2.0
 PRICE_INPUT_PER_MTOK=2.0
 PRICE_OUTPUT_PER_MTOK=10.0
 PRICE_OUTPUT_PER_MTOK=10.0
+# Tarif en dollars pour 1000 recherches web
+PRICE_WEB_SEARCH_PER_1000=10.0
 LOG_FILE=bot.log
 LOG_FILE=bot.log

+ 1 - 1
bot/__init__.py

@@ -1,3 +1,3 @@
 """Chatbot Discord propulsé par Claude."""
 """Chatbot Discord propulsé par Claude."""
 
 
-VERSION = "5.2.0"
+VERSION = "5.3.0"

+ 97 - 22
bot/claude_client.py

@@ -35,6 +35,22 @@ MEMORY_NOTE = (
     "explique simplement que tu ne gardes pas les images en mémoire et propose qu'on te la renvoie."
     "explique simplement que tu ne gardes pas les images en mémoire et propose qu'on te la renvoie."
 )
 )
 
 
+# Ajouté quand WEB_SEARCH=on.
+WEB_NOTE = (
+    "\n\nTu as accès à Internet (recherche web et lecture de pages). "
+    "Ne t'en sers que si la question porte sur l'actualité, des informations récentes ou précises "
+    "que tu ne connais pas, ou un lien partagé dans la conversation ; sinon réponds directement. "
+    "Garde des réponses courtes ; quand c'est utile, nomme simplement ta source dans ta phrase "
+    "(par exemple « selon Famitsu »), sans recopier d'adresse. "
+    "Ne commente pas tes recherches (pas de « je vais vérifier », « je regarde le résultat »…) : donne directement la réponse. "
+    "Dans les messages précédents, la mention « [recherche web : …] » signifie que tu as bien cherché sur Internet "
+    "pour y répondre : ta réponse d'alors s'appuyait sur de vrais résultats, même si tu ne les vois plus. "
+    "Ne prétends donc jamais avoir inventé une réponse marquée ainsi."
+)
+
+# Nombre maximal de relances quand l'API met la réponse en pause pendant les recherches.
+MAX_CONTINUATIONS = 3
+
 
 
 class ClaudeError(Exception):
 class ClaudeError(Exception):
     """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
     """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
@@ -50,6 +66,12 @@ class ImageData:
     data: bytes
     data: bytes
 
 
 
 
+@dataclass
+class Answer:
+    text: str
+    web_used: bool  # Claude a fait au moins une recherche web ou lu une page
+
+
 class ClaudeClient:
 class ClaudeClient:
     def __init__(self, settings: Settings):
     def __init__(self, settings: Settings):
         self.settings = settings
         self.settings = settings
@@ -100,25 +122,63 @@ class ClaudeClient:
             {"type": "text", "text": text},
             {"type": "text", "text": text},
         ]
         ]
 
 
-    async def ask(self, history: list[dict], user_text: str, image: ImageData | None = None) -> str:
+    async def ask(self, history: list[dict], user_text: str, image: ImageData | None = None) -> Answer:
         """Envoie l'historique + le nouveau message, et renvoie la réponse de Claude."""
         """Envoie l'historique + le nouveau message, et renvoie la réponse de Claude."""
         messages = list(history) + [{"role": "user", "content": self.build_user_content(user_text, image)}]
         messages = list(history) + [{"role": "user", "content": self.build_user_content(user_text, image)}]
 
 
+        system = self.personality() + MEMORY_NOTE
+        if self.settings.web_search:
+            system += WEB_NOTE
+
+        # Cache de prompt : les outils et la personnalité, identiques d'un message à l'autre, sont
+        # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
+        # Le marqueur sur "system" couvre ce bloc fixe ; celui de premier niveau couvre en plus
+        # l'historique, qui change à chaque message mais reprend le début de la requête précédente.
         params: dict = {
         params: dict = {
             "model": self.settings.model,
             "model": self.settings.model,
             "max_tokens": self.settings.max_tokens,
             "max_tokens": self.settings.max_tokens,
-            "system": self.personality() + MEMORY_NOTE,
+            "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
             "messages": messages,
             "messages": messages,
+            "cache_control": {"type": "ephemeral"},
         }
         }
         # Par défaut, Sonnet 5.5 réfléchit avant de répondre, et cette réflexion consomme
         # Par défaut, Sonnet 5.5 réfléchit avant de répondre, et cette réflexion consomme
         # le budget MAX_TOKENS. "between_tools" désactive la réflexion préalable
         # le budget MAX_TOKENS. "between_tools" désactive la réflexion préalable
-        # (le bot n'utilisant pas d'outils, il n'y a alors aucune réflexion).
+        # (il ne reste qu'une courte réflexion entre deux recherches web, si elles sont activées).
         params["thinking"] = {"type": "adaptive"} if self.settings.thinking else {"type": "between_tools"}
         params["thinking"] = {"type": "adaptive"} if self.settings.thinking else {"type": "between_tools"}
         if self.settings.effort:
         if self.settings.effort:
             params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
             params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
+        if self.settings.web_search:
+            # Outils exécutés par Anthropic : pas de boucle d'outils à gérer de notre côté.
+            params["tools"] = [
+                {"type": "web_search_20260209", "name": "web_search", "max_uses": self.settings.web_max_uses},
+                {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
+            ]
+
+        responses = [await self._call(params, image)]
+        # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
+        # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
+        while responses[-1].stop_reason == "pause_turn" and len(responses) <= MAX_CONTINUATIONS:
+            messages = messages + [{"role": "assistant", "content": responses[-1].content}]
+            responses.append(await self._call({**params, "messages": messages}, image))
+
+        text = self._final_text(responses)
+        web_used = self._log_usage(responses)
+
+        stop_reason = responses[-1].stop_reason
+        if stop_reason == "max_tokens":
+            logger.warning("Réponse coupée par MAX_TOKENS (%s) : augmente MAX_TOKENS dans le .env "
+                           "si cela arrive souvent.", self.settings.max_tokens)
+            text += " […]"
+        elif stop_reason == "pause_turn":
+            logger.warning("Recherche web toujours en cours après %s relances : réponse partielle.", MAX_CONTINUATIONS)
+        if not text:
+            logger.warning("Réponse vide de Claude (stop_reason=%s).", stop_reason)
+            raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
+        return Answer(text, web_used)
 
 
+    async def _call(self, params: dict, image: ImageData | None):
         try:
         try:
-            response = await self._client.messages.create(**params)
+            return await self._client.messages.create(**params)
         except anthropic.RateLimitError:
         except anthropic.RateLimitError:
             logger.warning("Limite de requêtes Anthropic atteinte.")
             logger.warning("Limite de requêtes Anthropic atteinte.")
             raise ClaudeError("Je reçois trop de messages en ce moment, réessaie dans une minute.")
             raise ClaudeError("Je reçois trop de messages en ce moment, réessaie dans une minute.")
@@ -140,23 +200,38 @@ class ClaudeClient:
             logger.error("Erreur de l'API (%s) : %s", exc.status_code, exc)
             logger.error("Erreur de l'API (%s) : %s", exc.status_code, exc)
             raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")
             raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")
 
 
-        text = "".join(block.text for block in response.content if block.type == "text").strip()
-        self._log_usage(response)
-
-        if response.stop_reason == "max_tokens":
-            logger.warning("Réponse coupée par MAX_TOKENS (%s) : augmente MAX_TOKENS dans le .env "
-                           "si cela arrive souvent.", self.settings.max_tokens)
-            text += " […]"
-        if not text:
-            logger.warning("Réponse vide de Claude (stop_reason=%s).", response.stop_reason)
-            raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
-        return text
-
-    def _log_usage(self, response) -> None:
-        usage = response.usage
-        cost = (usage.input_tokens * self.settings.price_input_per_mtok
-                + usage.output_tokens * self.settings.price_output_per_mtok) / 1_000_000
+    @staticmethod
+    def _final_text(responses) -> str:
+        """Texte de la réponse finale, sans les commentaires écrits entre deux recherches."""
+        parts: list[str] = []
+        for response in responses:
+            for block in response.content:
+                if block.type == "text":
+                    parts.append(block.text)
+                elif block.type not in ("thinking", "redacted_thinking"):
+                    # Appel ou résultat d'outil : le texte écrit avant n'était qu'un commentaire.
+                    parts = []
+        return "".join(parts).strip()
+
+    def _log_usage(self, responses) -> bool:
+        """Journalise la consommation et indique si Claude a utilisé Internet."""
+        input_tokens = sum(r.usage.input_tokens for r in responses)
+        cache_write = sum(r.usage.cache_creation_input_tokens or 0 for r in responses)
+        cache_read = sum(r.usage.cache_read_input_tokens or 0 for r in responses)
+        output_tokens = sum(r.usage.output_tokens for r in responses)
+        searches = fetches = 0
+        for r in responses:
+            if r.usage.server_tool_use:
+                searches += r.usage.server_tool_use.web_search_requests or 0
+                fetches += r.usage.server_tool_use.web_fetch_requests or 0
+        # Écriture en cache : 125 % du prix d'entrée ; lecture depuis le cache : 10 %.
+        cost = ((input_tokens + cache_write * 1.25 + cache_read * 0.1) * self.settings.price_input_per_mtok
+                + output_tokens * self.settings.price_output_per_mtok) / 1_000_000
+        cost += searches * self.settings.price_web_search_per_1000 / 1000
+        web = f", recherches web : {searches}, pages lues : {fetches}" if searches or fetches else ""
         logger.info(
         logger.info(
-            "Appel Claude (%s) — entrée : %s tokens, sortie : %s tokens, coût estimé : %.5f $",
-            response.model, usage.input_tokens, usage.output_tokens, cost,
+            "Appel Claude (%s) — entrée : %s tokens (+ cache : %s lus, %s écrits), sortie : %s tokens%s, "
+            "coût estimé : %.5f $",
+            responses[-1].model, input_tokens, cache_read, cache_write, output_tokens, web, cost,
         )
         )
+        return bool(searches or fetches)

+ 12 - 0
bot/config.py

@@ -63,6 +63,10 @@ class Settings:
     effort: str | None      # None = valeur par défaut du modèle
     effort: str | None      # None = valeur par défaut du modèle
     api_timeout: float
     api_timeout: float
 
 
+    # Accès à Internet (outils web_search et web_fetch exécutés par Anthropic)
+    web_search: bool
+    web_max_uses: int       # nombre maximal de recherches (et de pages lues) par message
+
     # Mémoire et contexte
     # Mémoire et contexte
     max_history_length: int
     max_history_length: int
     context_message_limit: int
     context_message_limit: int
@@ -76,6 +80,7 @@ class Settings:
     timezone: ZoneInfo
     timezone: ZoneInfo
     price_input_per_mtok: float
     price_input_per_mtok: float
     price_output_per_mtok: float
     price_output_per_mtok: float
+    price_web_search_per_1000: float
     log_file: Path
     log_file: Path
 
 
 
 
@@ -103,6 +108,10 @@ def load_settings() -> Settings:
         if not thinking and effort in ("xhigh", "max"):
         if not thinking and effort in ("xhigh", "max"):
             raise ConfigError(f"EFFORT={effort} nécessite THINKING=on.")
             raise ConfigError(f"EFFORT={effort} nécessite THINKING=on.")
 
 
+    web_search_raw = (_get_str("WEB_SEARCH", default="off") or "off").lower()
+    if web_search_raw not in ("on", "off"):
+        raise ConfigError(f"WEB_SEARCH doit valoir « on » ou « off » (valeur actuelle : {web_search_raw!r}).")
+
     return Settings(
     return Settings(
         discord_token=_get_str("DISCORD_TOKEN", required=True),
         discord_token=_get_str("DISCORD_TOKEN", required=True),
         anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
         anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
@@ -112,6 +121,8 @@ def load_settings() -> Settings:
         thinking=thinking,
         thinking=thinking,
         effort=effort,
         effort=effort,
         api_timeout=_get_float("API_TIMEOUT", default=60.0),
         api_timeout=_get_float("API_TIMEOUT", default=60.0),
+        web_search=web_search_raw == "on",
+        web_max_uses=_get_int("WEB_MAX_USES", default=3, minimum=1),
         max_history_length=_get_int("MAX_HISTORY_LENGTH", default=10, minimum=2),
         max_history_length=_get_int("MAX_HISTORY_LENGTH", default=10, minimum=2),
         context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0),
         context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0),
         history_file=Path(_get_str("HISTORY_FILE", default="conversation_history.json")),
         history_file=Path(_get_str("HISTORY_FILE", default="conversation_history.json")),
@@ -121,5 +132,6 @@ def load_settings() -> Settings:
         timezone=timezone,
         timezone=timezone,
         price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=2.0),
         price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=2.0),
         price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=10.0),
         price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=10.0),
+        price_web_search_per_1000=_get_float("PRICE_WEB_SEARCH_PER_1000", default=10.0),
         log_file=Path(_get_str("LOG_FILE", default="bot.log")),
         log_file=Path(_get_str("LOG_FILE", default="bot.log")),
     )
     )

+ 6 - 4
bot/discord_bot.py

@@ -14,7 +14,7 @@ from discord.ext import commands
 from . import VERSION
 from . import VERSION
 from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .config import Settings
 from .config import Settings
-from .history import IMAGE_MARKER, ConversationHistory
+from .history import IMAGE_MARKER, WEB_MARKER, ConversationHistory
 from .images import ImageError, prepare_image
 from .images import ImageError, prepare_image
 from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 from .text_utils import split_message
 from .text_utils import split_message
@@ -146,8 +146,10 @@ class ChatBot(commands.Bot):
                     logger.exception("Erreur inattendue pendant le traitement d'un message")
                     logger.exception("Erreur inattendue pendant le traitement d'un message")
                     await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
                     await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
                     return
                     return
-            await self.history.add_exchange(message.channel.id, stored_text, answer)
-        await self.send_answer(message, answer)
+            if answer.web_used:
+                stored_text += f" {WEB_MARKER}"
+            await self.history.add_exchange(message.channel.id, stored_text, answer.text)
+        await self.send_answer(message, answer.text)
 
 
     async def handle_mention(self, message: discord.Message) -> None:
     async def handle_mention(self, message: discord.Message) -> None:
         """Hors du salon dédié : répond à une mention en lisant les messages récents."""
         """Hors du salon dédié : répond à une mention en lisant les messages récents."""
@@ -198,7 +200,7 @@ class ChatBot(commands.Bot):
                 logger.exception("Erreur inattendue pendant le traitement d'une mention")
                 logger.exception("Erreur inattendue pendant le traitement d'une mention")
                 await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
                 await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
                 return
                 return
-        await self.send_answer(message, answer)
+        await self.send_answer(message, answer.text)
 
 
     # ------------------------------------------------------------- réactions
     # ------------------------------------------------------------- réactions
 
 

+ 3 - 0
bot/history.py

@@ -15,6 +15,9 @@ FILE_VERSION = 2
 # Remplace une image dans la mémoire (les images elles-mêmes ne sont pas conservées).
 # 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]"
 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]"
 _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]"
 
 
 
 
 class ConversationHistory:
 class ConversationHistory:

+ 25 - 0
changelog.txt

@@ -2,6 +2,31 @@ CHANGELOG — Chatbot Discord
 ===========================
 ===========================
 
 
 
 
+Version 5.3.0 — 29/10/2026
+--------------------------
+
+Nouveautés
+- Accès à Internet (désactivé par défaut) : avec WEB_SEARCH=on dans le .env,
+  le bot peut faire des recherches web et lire les liens partagés dans la
+  conversation (outils web_search et web_fetch d'Anthropic).
+  - Il ne cherche que si la question porte sur l'actualité ou une info qu'il
+    ne connaît pas, et nomme sa source dans sa réponse (sans lien).
+  - WEB_MAX_USES limite le nombre de recherches (et de pages lues) par message.
+  - Seule la réponse finale est envoyée sur Discord, sans les commentaires
+    écrits pendant les recherches.
+  - Dans la mémoire, une mention indique au bot qu'il a réellement cherché,
+    pour qu'il ne croie pas avoir inventé sa réponse au message suivant.
+  - Avec la recherche activée, prévoir API_TIMEOUT=90.
+
+Améliorations
+- Cache de prompt : la personnalité, les outils et l'historique sont relus
+  depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque
+  message). Avec la recherche web activée, un message simple passe d'environ
+  0,015 $ à 0,002 $.
+- Le coût estimé dans les logs tient compte du cache (tokens lus et écrits)
+  et des recherches web (PRICE_WEB_SEARCH_PER_1000).
+
+
 Version 5.2.0 — 28/10/2026
 Version 5.2.0 — 28/10/2026
 --------------------------
 --------------------------