1
0

config.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. """Chargement et validation de la configuration depuis le fichier .env."""
  2. from __future__ import annotations
  3. import math
  4. import os
  5. import tempfile
  6. from dataclasses import dataclass, field
  7. from pathlib import Path
  8. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
  9. from dotenv import load_dotenv
  10. from .models import PRICES, thinking_off
  11. EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
  12. # Tokens de réflexion à prévoir avant la réécriture d'un fichier de mémoire, selon EFFORT
  13. # (None : valeur par défaut du modèle, soit high).
  14. REFLECTION_TOKENS = {"low": 2000, "medium": 2000, "high": 4000, None: 4000, "xhigh": 8000, "max": 8000}
  15. # Chances (relatives) de chaque statut Discord lors d'un changement de statut.
  16. DEFAULT_PRESENCE_WEIGHTS = {
  17. "anime": 20, "musique": 20, "pensee": 10, "jeu": 20, "en_ligne": 20, "ne_pas_deranger": 5, "absent": 5,
  18. }
  19. # Tops Deezer officiels : Top Worldwide, Top France, Top Japan.
  20. DEFAULT_DEEZER_PLAYLISTS = "3155776842,1109890291,1362508955"
  21. class ConfigError(Exception):
  22. """Erreur de configuration (variable manquante ou invalide). Celle de load_settings rassemble toutes les
  23. erreurs trouvées (errors) et indique le fichier de journal s'il est utilisable (log_file, sinon None)."""
  24. def __init__(self, *errors: str, log_file: Path | None = None, log_level: str = "INFO"):
  25. super().__init__(*errors)
  26. self.errors = list(errors)
  27. self.log_file = log_file
  28. self.log_level = log_level
  29. def __str__(self) -> str:
  30. return "\n".join(self.errors)
  31. def _get_str(name: str, default: str | None = None, required: bool = False) -> str | None:
  32. value = (os.getenv(name) or "").strip()
  33. # docker run --env-file garde les guillemets que python-dotenv retire : DISCORD_TOKEN="abc".
  34. if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
  35. value = value[1:-1].strip()
  36. # Après les guillemets : DISCORD_TOKEN="" est vide, pas une valeur.
  37. if not value:
  38. if required:
  39. raise ConfigError(f"La variable {name} est obligatoire mais absente ou vide dans le fichier .env.")
  40. return default
  41. return value
  42. def _get_int(name: str, default: int | None = None, required: bool = False, minimum: int | None = None,
  43. maximum: int | None = None) -> int | None:
  44. raw = _get_str(name, required=required)
  45. if raw is None:
  46. return default
  47. try:
  48. value = int(raw)
  49. except ValueError:
  50. raise ConfigError(f"La variable {name} doit être un nombre entier (valeur actuelle : {raw!r}).")
  51. if minimum is not None and value < minimum:
  52. raise ConfigError(f"La variable {name} doit être supérieure ou égale à {minimum} (valeur actuelle : {value}).")
  53. if maximum is not None and value > maximum:
  54. raise ConfigError(f"La variable {name} ne peut pas dépasser {maximum} (valeur actuelle : {value}).")
  55. return value
  56. def _get_float(name: str, default: float | None = None, minimum: float | None = None,
  57. maximum: float | None = None) -> float | None:
  58. raw = _get_str(name)
  59. if raw is None:
  60. return default
  61. try:
  62. value = float(raw.replace(",", "."))
  63. except ValueError:
  64. raise ConfigError(f"La variable {name} doit être un nombre (valeur actuelle : {raw!r}).")
  65. if not math.isfinite(value): # float() accepte « nan » et « inf »
  66. raise ConfigError(f"La variable {name} doit être un nombre (valeur actuelle : {raw!r}).")
  67. if minimum is not None and value < minimum:
  68. raise ConfigError(f"La variable {name} doit être supérieure ou égale à {minimum:g} (valeur actuelle : {value:g}).")
  69. if maximum is not None and value > maximum:
  70. raise ConfigError(f"La variable {name} ne peut pas dépasser {maximum:g} (valeur actuelle : {value:g}).")
  71. return value
  72. def _check_output(name: str, path: Path, folder_only: bool = False) -> None:
  73. """Vérifie que le bot pourra écrire path (ou dans le dossier path si folder_only). Le dossier n'est pas
  74. créé : avec Docker, un dossier absent signale en général un volume mal monté, et les fichiers écrits
  75. dans le conteneur seraient perdus au redémarrage."""
  76. if not folder_only and path.is_dir():
  77. raise ConfigError(f"{name} désigne un dossier, pas un fichier : {path}.")
  78. folder = path if folder_only else path.parent
  79. if not folder.is_dir():
  80. raise ConfigError(f"Le dossier de {name} n'existe pas : {folder.resolve()} (volume non monté ?).")
  81. try:
  82. with tempfile.TemporaryFile(dir=folder):
  83. pass
  84. except OSError as exc:
  85. raise ConfigError(f"Impossible d'écrire dans le dossier de {name} ({folder.resolve()}) : {exc.strerror or exc}.")
  86. def _get_presence_weights() -> dict[str, int]:
  87. """PRESENCE_WEIGHTS, par exemple « anime=20,musique=20,… » ; les statuts absents gardent leur valeur par défaut."""
  88. weights = dict(DEFAULT_PRESENCE_WEIGHTS)
  89. raw = _get_str("PRESENCE_WEIGHTS")
  90. if raw is None:
  91. return weights
  92. seen = set()
  93. for item in raw.split(","):
  94. if not item.strip():
  95. continue # virgule finale ou doublée
  96. name, _, value = item.partition("=")
  97. name = name.strip().lower()
  98. if name not in weights:
  99. raise ConfigError(f"Statut inconnu dans PRESENCE_WEIGHTS : {name!r} (statuts possibles : {', '.join(weights)}).")
  100. if name in seen:
  101. raise ConfigError(f"PRESENCE_WEIGHTS : le statut {name} apparaît plusieurs fois.")
  102. seen.add(name)
  103. try:
  104. weights[name] = int(value)
  105. except ValueError:
  106. raise ConfigError(f"PRESENCE_WEIGHTS : la valeur de {name} doit être un nombre entier (valeur actuelle : {value.strip()!r}).")
  107. if weights[name] < 0:
  108. raise ConfigError(f"PRESENCE_WEIGHTS : la valeur de {name} ne peut pas être négative.")
  109. if sum(weights.values()) == 0:
  110. raise ConfigError("PRESENCE_WEIGHTS : au moins un statut doit avoir une valeur supérieure à 0.")
  111. return weights
  112. def _get_night() -> tuple[int, int] | None:
  113. """PRESENCE_NIGHT, par exemple « 2-6 » (de 2 h à 6 h) ; « off » = pas de nuit."""
  114. raw = _get_str("PRESENCE_NIGHT", default="2-6")
  115. if raw.lower() == "off":
  116. return None
  117. start, _, end = raw.partition("-")
  118. try:
  119. hours = (int(start), int(end))
  120. except ValueError:
  121. hours = None
  122. if hours is None or not all(0 <= h <= 23 for h in hours) or hours[0] == hours[1]:
  123. raise ConfigError(f"PRESENCE_NIGHT doit être de la forme « 2-6 » (heures de 0 à 23) ou « off » (valeur actuelle : {raw!r}).")
  124. return hours
  125. def _get_deezer_playlists() -> tuple[int, ...]:
  126. raw = _get_str("DEEZER_PLAYLISTS", default=DEFAULT_DEEZER_PLAYLISTS)
  127. try:
  128. return tuple(int(p) for p in raw.split(",") if p.strip())
  129. except ValueError:
  130. raise ConfigError(f"DEEZER_PLAYLISTS doit être une liste de numéros séparés par des virgules (valeur actuelle : {raw!r}).")
  131. @dataclass(frozen=True)
  132. class Settings:
  133. # Identifiants (repr=False : jamais affichés si la configuration est journalisée)
  134. discord_token: str = field(repr=False)
  135. anthropic_api_key: str = field(repr=False)
  136. channel_id: int
  137. # Claude
  138. model: str
  139. max_tokens: int
  140. thinking: bool # réflexion préalable avant de répondre (plus lent et plus cher)
  141. effort: str | None # None = valeur par défaut du modèle
  142. api_timeout: float
  143. # Accès à Internet (outils web_search et web_fetch exécutés par Anthropic)
  144. web_search: bool
  145. web_max_uses: int # nombre maximal de recherches (et de pages lues) par message
  146. # Mémoire et contexte
  147. max_history_length: int
  148. context_message_limit: int
  149. history_file: Path
  150. personality_file: Path
  151. # Mémoire des membres : fiches tenues par Claude (voir members.py)
  152. memory: bool
  153. memory_dir: Path
  154. memory_member_limit: int # caractères maximum d'une fiche de membre
  155. memory_server_limit: int # caractères maximum du fichier général
  156. memory_max_operations: int # lectures et écritures maximum par réponse
  157. # Images et PDF
  158. max_image_size: int
  159. pdf_max_pages: int # 0 : PDF refusés
  160. # Statut Discord (voir presence.py)
  161. presence: bool
  162. presence_speed: float # pour tester : 10 = statuts 10 fois plus courts
  163. presence_weights: dict[str, int]
  164. presence_night: tuple[int, int] | None # heures de début et de fin ; None = pas de nuit
  165. deezer_playlists: tuple[int, ...]
  166. steam_api_key: str | None = field(repr=False)
  167. # Divers
  168. timezone: ZoneInfo
  169. price_input_per_mtok: float | None # None = modèle absent de la table des tarifs
  170. price_output_per_mtok: float | None
  171. price_web_search_per_1000: float
  172. log_file: Path | None # None = LOG_FILE=off, journal sur la console seulement
  173. log_level: str
  174. LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
  175. def load_settings() -> Settings:
  176. """Lit le fichier .env et renvoie une configuration validée. Toutes les erreurs sont rassemblées dans une
  177. seule ConfigError : avec trois variables fausses, un seul redémarrage suffit pour les voir."""
  178. # override=True : le fichier .env est prioritaire sur les variables d'environnement de Windows.
  179. load_dotenv(override=True)
  180. errors: list[str] = []
  181. def check(read, *args, **kwargs):
  182. """Appelle read ; en cas d'erreur, la note et renvoie None (les vérifications croisées l'ignorent)."""
  183. try:
  184. return read(*args, **kwargs)
  185. except ConfigError as exc:
  186. errors.extend(exc.errors)
  187. return None
  188. tz_name = _get_str("TIMEZONE", default="Europe/Paris")
  189. try:
  190. timezone = ZoneInfo(tz_name)
  191. # ValueError : nom mal formé (« Europe/Paris/ », « ../Paris ») ; OSError : nom trop long pour un fichier.
  192. except (ZoneInfoNotFoundError, ValueError, OSError):
  193. timezone = None
  194. errors.append(f"Fuseau horaire inconnu dans TIMEZONE : {tz_name!r} (exemple valide : Europe/Paris).")
  195. thinking_raw = (_get_str("THINKING", default="off") or "off").lower()
  196. if thinking_raw not in ("on", "off"):
  197. errors.append(f"THINKING doit valoir « on » ou « off » (valeur actuelle : {thinking_raw!r}).")
  198. thinking = thinking_raw == "on"
  199. model = _get_str("CLAUDE_MODEL", default="claude-sonnet-5-5")
  200. effort = _get_str("EFFORT")
  201. if effort is not None:
  202. effort = effort.lower()
  203. if effort not in EFFORT_LEVELS:
  204. errors.append(f"EFFORT doit valoir {', '.join(EFFORT_LEVELS)} (valeur actuelle : {effort!r}).")
  205. # memory_max_tokens lit REFLECTION_TOKENS[effort] : une valeur inconnue y lèverait KeyError.
  206. effort = None
  207. # Couper la réflexion n'est accepté qu'avec un effort high ou moins.
  208. elif thinking_raw == "off" and effort in ("xhigh", "max") and thinking_off(model) is not None:
  209. errors.append(f"EFFORT={effort} nécessite THINKING=on avec le modèle {model}.")
  210. # Tarifs du .env s'ils sont renseignés, sinon ceux du modèle choisi.
  211. default_input, default_output = PRICES.get(model, (None, None))
  212. web_search_raw = (_get_str("WEB_SEARCH", default="off") or "off").lower()
  213. if web_search_raw not in ("on", "off"):
  214. errors.append(f"WEB_SEARCH doit valoir « on » ou « off » (valeur actuelle : {web_search_raw!r}).")
  215. memory_raw = (_get_str("MEMORY", default="off") or "off").lower()
  216. if memory_raw not in ("on", "off"):
  217. errors.append(f"MEMORY doit valoir « on » ou « off » (valeur actuelle : {memory_raw!r}).")
  218. history_file = Path(_get_str("HISTORY_FILE", default="conversation_history.json"))
  219. # Vide : à côté de l'historique, comme les autres fichiers du bot (donc sur le même volume avec Docker).
  220. memory_dir = _get_str("MEMORY_DIR")
  221. # La fenêtre « Modifier » de /memoire contient au plus 5 zones de 4000 caractères.
  222. memory_limits = {name: check(_get_int, name, default=default, minimum=200, maximum=20000)
  223. for name, default in (("MEMORY_MEMBER_LIMIT", 3000), ("MEMORY_SERVER_LIMIT", 5000))}
  224. presence_raw = (_get_str("PRESENCE", default="on") or "on").lower()
  225. if presence_raw not in ("on", "off"):
  226. errors.append(f"PRESENCE doit valoir « on » ou « off » (valeur actuelle : {presence_raw!r}).")
  227. # Au-delà, le statut change trop souvent (écriture de presence_state.json, limites de débit de Discord).
  228. presence_speed = check(_get_float, "PRESENCE_SPEED", default=1.0, maximum=100)
  229. if presence_speed is not None and presence_speed <= 0:
  230. errors.append(f"PRESENCE_SPEED doit être supérieur à 0 (valeur actuelle : {presence_speed}).")
  231. log_level = (_get_str("LOG_LEVEL", default="INFO") or "INFO").upper()
  232. if log_level not in LOG_LEVELS:
  233. errors.append(f"LOG_LEVEL doit valoir {', '.join(LOG_LEVELS)} (valeur actuelle : {log_level!r}).")
  234. log_level = "INFO"
  235. # off : en conteneur, la console suffit (OpenShift et Docker la collectent déjà).
  236. log_file_raw = _get_str("LOG_FILE", default="bot.log")
  237. # Une valeur invalide vaut None ici : l'erreur est notée, et la configuration n'est pas renvoyée.
  238. settings = Settings(
  239. discord_token=check(_get_str, "DISCORD_TOKEN", required=True),
  240. anthropic_api_key=check(_get_str, "ANTHROPIC_API_KEY", required=True),
  241. channel_id=check(_get_int, "CHANNEL_ID", required=True),
  242. model=model,
  243. # Une limite haute ne coûte que si elle est atteinte ; trop basse, elle coupe la réécriture
  244. # d'un fichier de mémoire plein (voir memory_max_tokens).
  245. # Au-delà de 64000, l'API refuse la requête (limite de sortie des modèles).
  246. max_tokens=check(_get_int, "MAX_TOKENS", default=4000, minimum=1, maximum=64000),
  247. thinking=thinking,
  248. effort=effort,
  249. api_timeout=check(_get_float, "API_TIMEOUT", default=60.0, minimum=1),
  250. web_search=web_search_raw == "on",
  251. # Chaque recherche est facturée : au-delà de 10, une seule question peut coûter cher.
  252. web_max_uses=check(_get_int, "WEB_MAX_USES", default=3, minimum=1, maximum=10),
  253. max_history_length=check(_get_int, "MAX_HISTORY_LENGTH", default=10, minimum=2),
  254. # Lus à chaque mention puis envoyés à Claude : au-delà, chaque mention coûte cher (ou dépasse la limite).
  255. context_message_limit=check(_get_int, "CONTEXT_MESSAGE_LIMIT", default=20, minimum=0, maximum=100),
  256. history_file=history_file,
  257. personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
  258. memory=memory_raw == "on",
  259. memory_dir=Path(memory_dir) if memory_dir else history_file.with_name("memories"),
  260. # Chaque fiche jointe est relue à chaque message : plus elle est longue, plus il coûte.
  261. memory_member_limit=memory_limits["MEMORY_MEMBER_LIMIT"],
  262. memory_server_limit=memory_limits["MEMORY_SERVER_LIMIT"],
  263. memory_max_operations=check(_get_int, "MEMORY_MAX_OPERATIONS", default=5, minimum=1, maximum=20),
  264. # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
  265. max_image_size=check(_get_int, "MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
  266. # Chaque page coûte son texte et son image (souvent plusieurs milliers de tokens).
  267. # L'API refuse les PDF de plus de 100 pages.
  268. pdf_max_pages=check(_get_int, "PDF_MAX_PAGES", default=20, minimum=0, maximum=100),
  269. presence=presence_raw == "on",
  270. presence_speed=presence_speed,
  271. presence_weights=check(_get_presence_weights),
  272. presence_night=check(_get_night),
  273. deezer_playlists=check(_get_deezer_playlists),
  274. steam_api_key=_get_str("STEAM_API_KEY"),
  275. timezone=timezone,
  276. price_input_per_mtok=check(_get_float, "PRICE_INPUT_PER_MTOK", default=default_input, minimum=0),
  277. price_output_per_mtok=check(_get_float, "PRICE_OUTPUT_PER_MTOK", default=default_output, minimum=0),
  278. price_web_search_per_1000=check(_get_float, "PRICE_WEB_SEARCH_PER_1000", default=10.0, minimum=0),
  279. log_file=None if log_file_raw.lower() == "off" else Path(log_file_raw),
  280. log_level=log_level,
  281. )
  282. count = len(errors)
  283. if settings.log_file is not None:
  284. check(_check_output, "LOG_FILE", settings.log_file)
  285. # Journal utilisable : main.py y écrit les erreurs de configuration, pas seulement sur la console.
  286. log_file = settings.log_file if len(errors) == count else None
  287. check(_check_output, "HISTORY_FILE", settings.history_file)
  288. if settings.memory:
  289. # Le dossier de la mémoire se crée au premier enregistrement : son parent doit déjà exister.
  290. if settings.memory_dir.is_dir():
  291. check(_check_output, "MEMORY_DIR", settings.memory_dir, folder_only=True)
  292. else:
  293. check(_check_output, "MEMORY_DIR", settings.memory_dir)
  294. # Sans clé Steam, « jeu » est retiré au tirage : s'il était le seul statut possible, le bot resterait
  295. # « en ligne » sans activité.
  296. if settings.presence and settings.presence_weights and not settings.steam_api_key and not any(
  297. weight for name, weight in settings.presence_weights.items() if name != "jeu"):
  298. errors.append("PRESENCE_WEIGHTS : le statut jeu est le seul possible, mais il nécessite STEAM_API_KEY. "
  299. "Renseigne STEAM_API_KEY ou donne une valeur supérieure à 0 à un autre statut.")
  300. if (settings.memory and None not in (settings.max_tokens, *memory_limits.values())
  301. and settings.max_tokens < (minimum := memory_max_tokens(settings)[0])):
  302. errors.append(f"MAX_TOKENS ({settings.max_tokens}) est trop petit pour la mémoire des membres : la "
  303. f"réécriture d'un fichier plein serait coupée et la note perdue. Mets au moins {minimum}, "
  304. f"ou baisse MEMORY_SERVER_LIMIT et MEMORY_MEMBER_LIMIT.")
  305. if errors:
  306. raise ConfigError(*errors, log_file=log_file, log_level=log_level)
  307. return settings
  308. def memory_max_tokens(settings: Settings) -> tuple[int, int]:
  309. """MAX_TOKENS nécessaire pour réécrire un fichier de mémoire plein d'un seul appel d'outil :
  310. (minimum sans réflexion, valeur conseillée avec la réflexion attendue)."""
  311. # Environ 2,5 caractères par token en français (estimation prudente), plus 2000 pour le reste de l'appel.
  312. minimum = max(settings.memory_member_limit, settings.memory_server_limit) * 2 // 5 + 2000
  313. # Sans THINKING=on, le modèle réfléchit quand même s'il ne permet pas de couper la réflexion.
  314. if not settings.thinking and thinking_off(settings.model) is not None:
  315. return minimum, minimum
  316. return minimum, minimum + REFLECTION_TOKENS[settings.effort]
  317. def memory_warning(settings: Settings) -> str | None:
  318. """Avertissement à afficher au démarrage quand MAX_TOKENS laisse trop peu de place à la réflexion
  319. pour réécrire un fichier de mémoire plein (None : rien à signaler)."""
  320. if not settings.memory or settings.max_tokens >= (advised := memory_max_tokens(settings)[1]):
  321. return None
  322. return (f"MAX_TOKENS ({settings.max_tokens}) laisse peu de place à la réflexion (effort "
  323. f"{settings.effort or 'par défaut'}) : la réécriture d'un fichier de mémoire plein pourrait être "
  324. f"coupée et la note perdue. Mets au moins {advised} dans le .env.")