Bladeren bron

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 dagen geleden
bovenliggende
commit
f5a48d327e
7 gewijzigde bestanden met toevoegingen van 154 en 28 verwijderingen
  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.
 EFFORT=
 # 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 -------------------------------------------------------------
 # 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
 PRICE_INPUT_PER_MTOK=2.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

+ 1 - 1
bot/__init__.py

@@ -1,3 +1,3 @@
 """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."
 )
 
+# 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):
     """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
@@ -50,6 +66,12 @@ class ImageData:
     data: bytes
 
 
+@dataclass
+class Answer:
+    text: str
+    web_used: bool  # Claude a fait au moins une recherche web ou lu une page
+
+
 class ClaudeClient:
     def __init__(self, settings: Settings):
         self.settings = settings
@@ -100,25 +122,63 @@ class ClaudeClient:
             {"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."""
         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 = {
             "model": self.settings.model,
             "max_tokens": self.settings.max_tokens,
-            "system": self.personality() + MEMORY_NOTE,
+            "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
             "messages": messages,
+            "cache_control": {"type": "ephemeral"},
         }
         # 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 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"}
         if 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:
-            response = await self._client.messages.create(**params)
+            return await self._client.messages.create(**params)
         except anthropic.RateLimitError:
             logger.warning("Limite de requêtes Anthropic atteinte.")
             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)
             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(
-            "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
     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
     max_history_length: int
     context_message_limit: int
@@ -76,6 +80,7 @@ class Settings:
     timezone: ZoneInfo
     price_input_per_mtok: float
     price_output_per_mtok: float
+    price_web_search_per_1000: float
     log_file: Path
 
 
@@ -103,6 +108,10 @@ def load_settings() -> Settings:
         if not thinking and effort in ("xhigh", "max"):
             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(
         discord_token=_get_str("DISCORD_TOKEN", required=True),
         anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
@@ -112,6 +121,8 @@ def load_settings() -> Settings:
         thinking=thinking,
         effort=effort,
         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),
         context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0),
         history_file=Path(_get_str("HISTORY_FILE", default="conversation_history.json")),
@@ -121,5 +132,6 @@ def load_settings() -> Settings:
         timezone=timezone,
         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_web_search_per_1000=_get_float("PRICE_WEB_SEARCH_PER_1000", default=10.0),
         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 .claude_client import ClaudeClient, ClaudeError, ImageData
 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 .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 from .text_utils import split_message
@@ -146,8 +146,10 @@ class ChatBot(commands.Bot):
                     logger.exception("Erreur inattendue pendant le traitement d'un message")
                     await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
                     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:
         """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")
                 await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
                 return
-        await self.send_answer(message, answer)
+        await self.send_answer(message, answer.text)
 
     # ------------------------------------------------------------- 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).
 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]"
 
 
 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
 --------------------------