Forráskód Böngészése

Journal réglable : LOG_LEVEL et LOG_FILE=off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 19 órája
szülő
commit
266c39b6ed
7 módosított fájl, 40 hozzáadás és 10 törlés
  1. 3 0
      .env.exemple
  2. 2 0
      CHANGELOG.md
  3. 1 1
      README.md
  4. 19 5
      bot/config.py
  5. 3 0
      bot/discord_bot.py
  6. 4 4
      main.py
  7. 8 0
      tests/test_config.py

+ 3 - 0
.env.exemple

@@ -98,4 +98,7 @@ PRICE_INPUT_PER_MTOK=
 PRICE_OUTPUT_PER_MTOK=
 # Tarif en dollars pour 1000 recherches web
 PRICE_WEB_SEARCH_PER_1000=10.0
+# Fichier de journal ; off = console seulement (en conteneur, elle est déjà collectée)
 LOG_FILE=bot.log
+# Détail du journal : DEBUG (pour enquêter), INFO, WARNING ou ERROR
+LOG_LEVEL=INFO

+ 2 - 0
CHANGELOG.md

@@ -10,6 +10,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 ### Changements
 
+- **Journal réglable.** `LOG_LEVEL` choisit le détail du journal (`DEBUG` pour enquêter, `INFO` par défaut). `LOG_FILE=off` n'écrit que sur la console, utile en conteneur où elle est déjà collectée. Le démarrage affiche aussi `MAX_TOKENS`, `API_TIMEOUT` et les chemins complets du journal, de l'historique et de la personnalité.
+
 - **Plusieurs images dans un même message.** On peut envoyer jusqu'à 4 images à la fois : Rika les voit toutes, dans l'ordre, et peut les comparer ou parler de « la deuxième ». Les images d'un message cité ou transféré sont lues elles aussi, jusqu'à 4. Si une image pose problème, le message d'erreur dit laquelle (la première dans l'ordre du message si plusieurs échouent). Dans la mémoire, les images d'un message tiennent en un seul repère (« 4 images jointes »). Un PDF reste seul, sans image.
 
 - **Un message de démarrage plus soigné.** Le bloc « Bot en ligne » affiche maintenant l'avatar du bot, sa version, le modèle Claude utilisé (« Sonnet 5.5 ») et l'heure du démarrage, sur le vert du statut « en ligne » de Discord.

+ 1 - 1
README.md

@@ -154,7 +154,7 @@ Chaque message envoyé à Claude est facturé selon le nombre de tokens lus (per
 - chaque page de PDF coûte souvent plusieurs milliers de tokens, et chaque recherche web est payante en plus ;
 - avec `MEMORY=on`, les fiches jointes (5 au plus par message) sont relues à chaque message, hors cache : environ 850 tokens par fiche pleine de 3000 caractères. Le prompt ne liste que les membres qui ont une fiche, mais chaque nouvelle fiche, changement de pseudo ou note dans le fichier général fait réécrire la conversation en cache au message suivant. Chaque note demande en plus un court appel, la conversation étant alors relue en cache.
 
-Le fichier `bot.log` (`LOG_FILE`) garde l'activité du bot, avec pour chaque appel les tokens utilisés et le coût estimé en dollars. Les tarifs de Sonnet, Haiku et Opus 5.5 sont connus du bot ; pour un autre modèle, renseigne `PRICE_INPUT_PER_MTOK` et `PRICE_OUTPUT_PER_MTOK`.
+Le fichier `bot.log` (`LOG_FILE`) garde l'activité du bot, avec pour chaque appel les tokens utilisés et le coût estimé en dollars. `LOG_LEVEL=DEBUG` le rend plus détaillé pour enquêter sur un problème. En conteneur, `LOG_FILE=off` n'écrit que sur la console, déjà collectée par Docker ou OpenShift. Au démarrage, le journal rappelle les réglages effectifs : modèle, `MAX_TOKENS`, `API_TIMEOUT`, et chemins complets des fichiers. Les tarifs de Sonnet, Haiku et Opus 5.5 sont connus du bot ; pour un autre modèle, renseigne `PRICE_INPUT_PER_MTOK` et `PRICE_OUTPUT_PER_MTOK`.
 
 ## Fichiers créés par le bot
 

+ 19 - 5
bot/config.py

@@ -30,10 +30,11 @@ class ConfigError(Exception):
     """Erreur de configuration (variable manquante ou invalide). Celle de load_settings rassemble toutes les
     erreurs trouvées (errors) et indique le fichier de journal s'il est utilisable (log_file, sinon None)."""
 
-    def __init__(self, *errors: str, log_file: Path | None = None):
+    def __init__(self, *errors: str, log_file: Path | None = None, log_level: str = "INFO"):
         super().__init__(*errors)
         self.errors = list(errors)
         self.log_file = log_file
+        self.log_level = log_level
 
     def __str__(self) -> str:
         return "\n".join(self.errors)
@@ -201,7 +202,11 @@ class Settings:
     price_input_per_mtok: float | None   # None = modèle absent de la table des tarifs
     price_output_per_mtok: float | None
     price_web_search_per_1000: float
-    log_file: Path
+    log_file: Path | None  # None = LOG_FILE=off, journal sur la console seulement
+    log_level: str
+
+
+LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
 
 
 def load_settings() -> Settings:
@@ -267,6 +272,13 @@ def load_settings() -> Settings:
     if presence_speed is not None and presence_speed <= 0:
         errors.append(f"PRESENCE_SPEED doit être supérieur à 0 (valeur actuelle : {presence_speed}).")
 
+    log_level = (_get_str("LOG_LEVEL", default="INFO") or "INFO").upper()
+    if log_level not in LOG_LEVELS:
+        errors.append(f"LOG_LEVEL doit valoir {', '.join(LOG_LEVELS)} (valeur actuelle : {log_level!r}).")
+        log_level = "INFO"
+    # off : en conteneur, la console suffit (OpenShift et Docker la collectent déjà).
+    log_file_raw = _get_str("LOG_FILE", default="bot.log")
+
     # Une valeur invalide vaut None ici : l'erreur est notée, et la configuration n'est pas renvoyée.
     settings = Settings(
         discord_token=check(_get_str, "DISCORD_TOKEN", required=True),
@@ -309,10 +321,12 @@ def load_settings() -> Settings:
         price_input_per_mtok=check(_get_float, "PRICE_INPUT_PER_MTOK", default=default_input, minimum=0),
         price_output_per_mtok=check(_get_float, "PRICE_OUTPUT_PER_MTOK", default=default_output, minimum=0),
         price_web_search_per_1000=check(_get_float, "PRICE_WEB_SEARCH_PER_1000", default=10.0, minimum=0),
-        log_file=Path(_get_str("LOG_FILE", default="bot.log")),
+        log_file=None if log_file_raw.lower() == "off" else Path(log_file_raw),
+        log_level=log_level,
     )
     count = len(errors)
-    check(_check_output, "LOG_FILE", settings.log_file)
+    if settings.log_file is not None:
+        check(_check_output, "LOG_FILE", settings.log_file)
     # Journal utilisable : main.py y écrit les erreurs de configuration, pas seulement sur la console.
     log_file = settings.log_file if len(errors) == count else None
     check(_check_output, "HISTORY_FILE", settings.history_file)
@@ -334,7 +348,7 @@ def load_settings() -> Settings:
                       f"réécriture d'un fichier plein serait coupée et la note perdue. Mets au moins {minimum}, "
                       f"ou baisse MEMORY_SERVER_LIMIT et MEMORY_MEMBER_LIMIT.")
     if errors:
-        raise ConfigError(*errors, log_file=log_file)
+        raise ConfigError(*errors, log_file=log_file, log_level=log_level)
     return settings
 
 

+ 3 - 0
bot/discord_bot.py

@@ -227,6 +227,9 @@ class ChatBot(commands.Bot):
         logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s, mémoire des membres : %s",
                     s.model, thinking, s.effort or "par défaut",
                     "on" if s.web_search else "off", memory)
+        logger.info("MAX_TOKENS : %s, API_TIMEOUT : %g s, journal : %s (niveau %s)", s.max_tokens, s.api_timeout,
+                    s.log_file.resolve() if s.log_file else "console seulement", s.log_level)
+        logger.info("Historique : %s, personnalité : %s", s.history_file.resolve(), s.personality_file.resolve())
         if not is_known(s.model):
             # Un nom mal écrit ne serait sinon découvert qu'au premier message (erreur 404).
             prices = ""

+ 4 - 4
main.py

@@ -12,11 +12,11 @@ from bot.config import ConfigError, load_settings
 from bot.discord_bot import ChatBot
 
 
-def setup_logging(log_file: Path | None) -> None:
+def setup_logging(log_file: Path | None, level: str = "INFO") -> None:
     """Journal sur la console et dans log_file (seulement sur la console si log_file vaut None)."""
     formatter = logging.Formatter("[%(asctime)s] %(levelname)s %(name)s : %(message)s", "%Y-%m-%d %H:%M:%S")
     root = logging.getLogger()
-    root.setLevel(logging.INFO)
+    root.setLevel(level)
 
     handlers = [logging.StreamHandler()]
     if log_file is not None:
@@ -36,7 +36,7 @@ def main() -> None:
         settings = load_settings()
     except ConfigError as exc:
         # Aussi dans le fichier de journal quand LOG_FILE est utilisable : un redémarrage raté y laisse une trace.
-        setup_logging(exc.log_file)
+        setup_logging(exc.log_file, exc.log_level)
         if len(exc.errors) == 1:
             logging.critical("Erreur de configuration : %s", exc)
         else:
@@ -44,7 +44,7 @@ def main() -> None:
                              "\n".join(f"  - {error}" for error in exc.errors))
         sys.exit(1)
 
-    setup_logging(settings.log_file)
+    setup_logging(settings.log_file, settings.log_level)
     # Rika n'utilise pas le vocal : inutile de signaler l'absence de PyNaCl et de davey.
     discord.VoiceClient.warn_nacl = False
     discord.VoiceClient.warn_dave = False

+ 8 - 0
tests/test_config.py

@@ -118,6 +118,14 @@ class AllErrorsTest(unittest.TestCase):
                 settings(LOG_FILE=str(Path(folder) / "absent" / "bot.log"))
             self.assertIsNone(ctx.exception.log_file)
 
+    def test_log_file_off_and_level(self):
+        s = settings(LOG_FILE="OFF", LOG_LEVEL="debug")
+        self.assertIsNone(s.log_file)
+        self.assertEqual(s.log_level, "DEBUG")
+        with self.assertRaises(ConfigError) as ctx:
+            settings(LOG_LEVEL="bavard")
+        self.assertIn("LOG_LEVEL", str(ctx.exception))
+
 
 class PresenceWeightsTest(unittest.TestCase):
     ONLY_GAME = "anime=0,musique=0,pensee=0,jeu=100,en_ligne=0,ne_pas_deranger=0,absent=0"