ソースを参照

Dossiers de sortie vérifiés au démarrage

Un dossier absent ou en lecture seule pour LOG_FILE, HISTORY_FILE ou
MEMORY_DIR arrête le bot avec « Erreur de configuration » au lieu d'une
trace Python ou de sauvegardes qui échouent sans bruit. L'historique est
enregistré avec files.write_atomic (dossier créé, fsync).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 22 時間 前
親
コミット
6c86123701
4 ファイル変更、64 行追加、8 行削除
  1. 2 0
      CHANGELOG.md
  2. 25 0
      bot/config.py
  3. 2 7
      bot/history.py
  4. 35 1
      tests/test_config.py

+ 2 - 0
CHANGELOG.md

@@ -24,6 +24,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Une personnalité illisible ne rend plus Rika muette.** Un fichier de personnalité qui n'est pas en UTF-8 (enregistré en ANSI avec le Bloc-notes, par exemple), illisible, ou un `PERSONALITY_FILE` qui désigne un dossier (ConfigMap montée comme dossier sur OpenShift) faisait échouer chaque message sans réponse. Rika garde maintenant la dernière personnalité valide, ou celle par défaut au démarrage, et le journal indique une seule fois le problème et comment le corriger. Le fichier est relu dès qu'il est modifié.
 
+- **Un dossier absent est signalé au démarrage.** Si le dossier de `LOG_FILE`, de `HISTORY_FILE` ou de `MEMORY_DIR` n'existe pas (volume Docker mal monté, faute de frappe) ou n'est pas accessible en écriture, le bot refuse de démarrer avec un message « Erreur de configuration » clair, au lieu d'une trace Python (`LOG_FILE`) ou de tourner sans jamais rien sauvegarder (`HISTORY_FILE`). L'historique est aussi enregistré comme les autres fichiers : envoyé jusqu'au disque avant de remplacer l'ancien, pour ne pas finir vide après une coupure de courant.
+
 ---
 
 ## Version 5.11

+ 25 - 0
bot/config.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import math
 import os
+import tempfile
 from dataclasses import dataclass, field
 from pathlib import Path
 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -66,6 +67,22 @@ def _get_float(name: str, default: float | None = None, minimum: float | None =
     return value
 
 
+def _check_output(name: str, path: Path, folder_only: bool = False) -> None:
+    """Vérifie que le bot pourra écrire path (ou dans le dossier path si folder_only). Le dossier n'est pas
+    créé : avec Docker, un dossier absent signale en général un volume mal monté, et les fichiers écrits
+    dans le conteneur seraient perdus au redémarrage."""
+    if not folder_only and path.is_dir():
+        raise ConfigError(f"{name} désigne un dossier, pas un fichier : {path}.")
+    folder = path if folder_only else path.parent
+    if not folder.is_dir():
+        raise ConfigError(f"Le dossier de {name} n'existe pas : {folder.resolve()} (volume non monté ?).")
+    try:
+        with tempfile.TemporaryFile(dir=folder):
+            pass
+    except OSError as exc:
+        raise ConfigError(f"Impossible d'écrire dans le dossier de {name} ({folder.resolve()}) : {exc.strerror or exc}.")
+
+
 def _get_presence_weights() -> dict[str, int]:
     """PRESENCE_WEIGHTS, par exemple « anime=20,musique=20,… » ; les statuts absents gardent leur valeur par défaut."""
     weights = dict(DEFAULT_PRESENCE_WEIGHTS)
@@ -255,6 +272,14 @@ def load_settings() -> Settings:
         price_web_search_per_1000=_get_float("PRICE_WEB_SEARCH_PER_1000", default=10.0, minimum=0),
         log_file=Path(_get_str("LOG_FILE", default="bot.log")),
     )
+    _check_output("LOG_FILE", settings.log_file)
+    _check_output("HISTORY_FILE", settings.history_file)
+    if settings.memory:
+        # Le dossier de la mémoire se crée au premier enregistrement : son parent doit déjà exister.
+        if settings.memory_dir.is_dir():
+            _check_output("MEMORY_DIR", settings.memory_dir, folder_only=True)
+        else:
+            _check_output("MEMORY_DIR", settings.memory_dir)
     if settings.memory and settings.max_tokens < (minimum := memory_max_tokens(settings)[0]):
         raise ConfigError(f"MAX_TOKENS ({settings.max_tokens}) est trop petit pour la mémoire des membres : la "
                           f"réécriture d'un fichier plein serait coupée et la note perdue. Mets au moins {minimum}, "

+ 2 - 7
bot/history.py

@@ -10,6 +10,7 @@ import shutil
 from datetime import datetime
 from pathlib import Path
 
+from .files import write_atomic
 from .text_utils import strip_reasoning
 
 logger = logging.getLogger("chatbot.history")
@@ -305,12 +306,6 @@ class ConversationHistory:
         text = json.dumps(payload, ensure_ascii=False, indent=2)
         async with self._save_lock:
             try:
-                await asyncio.to_thread(self._write_atomic, text)
+                await asyncio.to_thread(write_atomic, self.file_path, text)
             except OSError as exc:
                 logger.error("Impossible de sauvegarder l'historique : %s", exc)
-
-    def _write_atomic(self, text: str) -> None:
-        # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
-        tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
-        tmp.write_text(text, encoding="utf-8")
-        os.replace(tmp, self.file_path)

+ 35 - 1
tests/test_config.py

@@ -1,6 +1,8 @@
-"""Tests de la configuration (MAX_TOKENS et mémoire). Lancer depuis la racine du projet : python -m unittest"""
+"""Tests de la configuration (MAX_TOKENS, mémoire et fichiers écrits). Lancer depuis la racine du projet : python -m unittest"""
 
+import tempfile
 import unittest
+from pathlib import Path
 from unittest import mock
 
 from bot.config import ConfigError, load_settings, memory_max_tokens, memory_warning
@@ -44,5 +46,37 @@ class MemoryMaxTokensTest(unittest.TestCase):
         self.assertIsNone(memory_warning(settings(MAX_TOKENS="4000", THINKING="off")))
 
 
+
+class OutputPathsTest(unittest.TestCase):
+    def setUp(self):
+        tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(tmp.cleanup)
+        self.root = Path(tmp.name)
+
+    def test_existing_folders_accepted(self):
+        s = settings(LOG_FILE=str(self.root / "bot.log"), HISTORY_FILE=str(self.root / "h.json"))
+        self.assertEqual(s.memory_dir, self.root / "memories")
+
+    def test_missing_folder_refused(self):
+        # Le scénario de l'audit : volume non monté sur /data.
+        for name in ("LOG_FILE", "HISTORY_FILE", "MEMORY_DIR"):
+            with self.subTest(name), self.assertRaises(ConfigError) as ctx:
+                settings(**{name: str(self.root / "absent" / "x")})
+            self.assertIn(f"Le dossier de {name} n'existe pas", str(ctx.exception))
+
+    def test_memory_dir_unchecked_when_off(self):
+        settings(MEMORY="off", MEMORY_DIR=str(self.root / "absent" / "x"))
+
+    def test_file_path_is_folder_refused(self):
+        with self.assertRaises(ConfigError) as ctx:
+            settings(HISTORY_FILE=str(self.root))
+        self.assertIn("HISTORY_FILE désigne un dossier", str(ctx.exception))
+
+    def test_unwritable_folder_refused(self):
+        with mock.patch("bot.config.tempfile.TemporaryFile", side_effect=PermissionError(13, "Permission denied")),                 self.assertRaises(ConfigError) as ctx:
+            settings()
+        self.assertIn("Impossible d'écrire dans le dossier de LOG_FILE", str(ctx.exception))
+
+
 if __name__ == "__main__":
     unittest.main()