1
0

config.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """Chargement et validation de la configuration depuis le fichier .env."""
  2. from __future__ import annotations
  3. import math
  4. import os
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
  8. from dotenv import load_dotenv
  9. from .models import PRICES, thinking_off
  10. EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
  11. # Chances (relatives) de chaque statut Discord lors d'un changement de statut.
  12. DEFAULT_PRESENCE_WEIGHTS = {
  13. "anime": 20, "musique": 20, "pensee": 10, "jeu": 20, "en_ligne": 20, "ne_pas_deranger": 5, "absent": 5,
  14. }
  15. # Tops Deezer officiels : Top Worldwide, Top France, Top Japan.
  16. DEFAULT_DEEZER_PLAYLISTS = "3155776842,1109890291,1362508955"
  17. class ConfigError(Exception):
  18. """Erreur de configuration (variable manquante ou invalide)."""
  19. def _get_str(name: str, default: str | None = None, required: bool = False) -> str | None:
  20. value = os.getenv(name)
  21. if value is None or value.strip() == "":
  22. if required:
  23. raise ConfigError(f"La variable {name} est obligatoire mais absente du fichier .env.")
  24. return default
  25. return value.strip()
  26. def _get_int(name: str, default: int | None = None, required: bool = False, minimum: int | None = None) -> int | None:
  27. raw = _get_str(name, required=required)
  28. if raw is None:
  29. return default
  30. try:
  31. value = int(raw)
  32. except ValueError:
  33. raise ConfigError(f"La variable {name} doit être un nombre entier (valeur actuelle : {raw!r}).")
  34. if minimum is not None and value < minimum:
  35. raise ConfigError(f"La variable {name} doit être supérieure ou égale à {minimum} (valeur actuelle : {value}).")
  36. return value
  37. def _get_float(name: str, default: float | None = None, minimum: float | None = None) -> float | None:
  38. raw = _get_str(name)
  39. if raw is None:
  40. return default
  41. try:
  42. value = float(raw.replace(",", "."))
  43. except ValueError:
  44. raise ConfigError(f"La variable {name} doit être un nombre (valeur actuelle : {raw!r}).")
  45. if not math.isfinite(value): # float() accepte « nan » et « inf »
  46. raise ConfigError(f"La variable {name} doit être un nombre (valeur actuelle : {raw!r}).")
  47. if minimum is not None and value < minimum:
  48. raise ConfigError(f"La variable {name} doit être supérieure ou égale à {minimum:g} (valeur actuelle : {value:g}).")
  49. return value
  50. def _get_presence_weights() -> dict[str, int]:
  51. """PRESENCE_WEIGHTS, par exemple « anime=20,musique=20,… » ; les statuts absents gardent leur valeur par défaut."""
  52. weights = dict(DEFAULT_PRESENCE_WEIGHTS)
  53. raw = _get_str("PRESENCE_WEIGHTS")
  54. if raw is None:
  55. return weights
  56. for item in raw.split(","):
  57. name, _, value = item.partition("=")
  58. name = name.strip().lower()
  59. if name not in weights:
  60. raise ConfigError(f"Statut inconnu dans PRESENCE_WEIGHTS : {name!r} (statuts possibles : {', '.join(weights)}).")
  61. try:
  62. weights[name] = int(value)
  63. except ValueError:
  64. raise ConfigError(f"PRESENCE_WEIGHTS : la valeur de {name} doit être un nombre entier (valeur actuelle : {value.strip()!r}).")
  65. if weights[name] < 0:
  66. raise ConfigError(f"PRESENCE_WEIGHTS : la valeur de {name} ne peut pas être négative.")
  67. if sum(weights.values()) == 0:
  68. raise ConfigError("PRESENCE_WEIGHTS : au moins un statut doit avoir une valeur supérieure à 0.")
  69. return weights
  70. def _get_night() -> tuple[int, int] | None:
  71. """PRESENCE_NIGHT, par exemple « 2-6 » (de 2 h à 6 h) ; « off » = pas de nuit."""
  72. raw = _get_str("PRESENCE_NIGHT", default="2-6")
  73. if raw.lower() == "off":
  74. return None
  75. start, _, end = raw.partition("-")
  76. try:
  77. hours = (int(start), int(end))
  78. except ValueError:
  79. hours = None
  80. if hours is None or not all(0 <= h <= 23 for h in hours) or hours[0] == hours[1]:
  81. raise ConfigError(f"PRESENCE_NIGHT doit être de la forme « 2-6 » (heures de 0 à 23) ou « off » (valeur actuelle : {raw!r}).")
  82. return hours
  83. def _get_deezer_playlists() -> tuple[int, ...]:
  84. raw = _get_str("DEEZER_PLAYLISTS", default=DEFAULT_DEEZER_PLAYLISTS)
  85. try:
  86. return tuple(int(p) for p in raw.split(",") if p.strip())
  87. except ValueError:
  88. raise ConfigError(f"DEEZER_PLAYLISTS doit être une liste de numéros séparés par des virgules (valeur actuelle : {raw!r}).")
  89. @dataclass(frozen=True)
  90. class Settings:
  91. # Identifiants
  92. discord_token: str
  93. anthropic_api_key: str
  94. channel_id: int
  95. # Claude
  96. model: str
  97. max_tokens: int
  98. thinking: bool # réflexion préalable avant de répondre (plus lent et plus cher)
  99. effort: str | None # None = valeur par défaut du modèle
  100. api_timeout: float
  101. # Accès à Internet (outils web_search et web_fetch exécutés par Anthropic)
  102. web_search: bool
  103. web_max_uses: int # nombre maximal de recherches (et de pages lues) par message
  104. # Mémoire et contexte
  105. max_history_length: int
  106. context_message_limit: int
  107. history_file: Path
  108. personality_file: Path
  109. # Mémoire des membres : fiches tenues par Claude (voir members.py)
  110. memory: bool
  111. memory_dir: Path
  112. # Images et PDF
  113. max_image_size: int
  114. pdf_max_pages: int # 0 : PDF refusés
  115. # Statut Discord (voir presence.py)
  116. presence: bool
  117. presence_speed: float # pour tester : 10 = statuts 10 fois plus courts
  118. presence_weights: dict[str, int]
  119. presence_night: tuple[int, int] | None # heures de début et de fin ; None = pas de nuit
  120. deezer_playlists: tuple[int, ...]
  121. steam_api_key: str | None
  122. # Divers
  123. timezone: ZoneInfo
  124. price_input_per_mtok: float | None # None = modèle absent de la table des tarifs
  125. price_output_per_mtok: float | None
  126. price_web_search_per_1000: float
  127. log_file: Path
  128. def load_settings() -> Settings:
  129. """Lit le fichier .env et renvoie une configuration validée."""
  130. # override=True : le fichier .env est prioritaire sur les variables d'environnement de Windows.
  131. load_dotenv(override=True)
  132. tz_name = _get_str("TIMEZONE", default="Europe/Paris")
  133. try:
  134. timezone = ZoneInfo(tz_name)
  135. except ZoneInfoNotFoundError:
  136. raise ConfigError(f"Fuseau horaire inconnu dans TIMEZONE : {tz_name!r} (exemple valide : Europe/Paris).")
  137. thinking_raw = (_get_str("THINKING", default="off") or "off").lower()
  138. if thinking_raw not in ("on", "off"):
  139. raise ConfigError(f"THINKING doit valoir « on » ou « off » (valeur actuelle : {thinking_raw!r}).")
  140. thinking = thinking_raw == "on"
  141. model = _get_str("CLAUDE_MODEL", default="claude-sonnet-5-5")
  142. effort = _get_str("EFFORT")
  143. if effort is not None:
  144. effort = effort.lower()
  145. if effort not in EFFORT_LEVELS:
  146. raise ConfigError(f"EFFORT doit valoir {', '.join(EFFORT_LEVELS)} (valeur actuelle : {effort!r}).")
  147. # Couper la réflexion n'est accepté qu'avec un effort high ou moins.
  148. if not thinking and effort in ("xhigh", "max") and thinking_off(model) is not None:
  149. raise ConfigError(f"EFFORT={effort} nécessite THINKING=on avec le modèle {model}.")
  150. # Tarifs du .env s'ils sont renseignés, sinon ceux du modèle choisi.
  151. default_input, default_output = PRICES.get(model, (None, None))
  152. web_search_raw = (_get_str("WEB_SEARCH", default="off") or "off").lower()
  153. if web_search_raw not in ("on", "off"):
  154. raise ConfigError(f"WEB_SEARCH doit valoir « on » ou « off » (valeur actuelle : {web_search_raw!r}).")
  155. memory_raw = (_get_str("MEMORY", default="off") or "off").lower()
  156. if memory_raw not in ("on", "off"):
  157. raise ConfigError(f"MEMORY doit valoir « on » ou « off » (valeur actuelle : {memory_raw!r}).")
  158. history_file = Path(_get_str("HISTORY_FILE", default="conversation_history.json"))
  159. # Vide : à côté de l'historique, comme les autres fichiers du bot (donc sur le même volume avec Docker).
  160. memory_dir = _get_str("MEMORY_DIR")
  161. presence_raw = (_get_str("PRESENCE", default="on") or "on").lower()
  162. if presence_raw not in ("on", "off"):
  163. raise ConfigError(f"PRESENCE doit valoir « on » ou « off » (valeur actuelle : {presence_raw!r}).")
  164. presence_speed = _get_float("PRESENCE_SPEED", default=1.0)
  165. if presence_speed <= 0:
  166. raise ConfigError(f"PRESENCE_SPEED doit être supérieur à 0 (valeur actuelle : {presence_speed}).")
  167. return Settings(
  168. discord_token=_get_str("DISCORD_TOKEN", required=True),
  169. anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
  170. channel_id=_get_int("CHANNEL_ID", required=True),
  171. model=model,
  172. max_tokens=_get_int("MAX_TOKENS", default=1500, minimum=1),
  173. thinking=thinking,
  174. effort=effort,
  175. api_timeout=_get_float("API_TIMEOUT", default=60.0, minimum=1),
  176. web_search=web_search_raw == "on",
  177. web_max_uses=_get_int("WEB_MAX_USES", default=3, minimum=1),
  178. max_history_length=_get_int("MAX_HISTORY_LENGTH", default=10, minimum=2),
  179. context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0),
  180. history_file=history_file,
  181. personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
  182. memory=memory_raw == "on",
  183. memory_dir=Path(memory_dir) if memory_dir else history_file.with_name("memories"),
  184. # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
  185. max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
  186. # Chaque page coûte son texte et son image (souvent plusieurs milliers de tokens).
  187. pdf_max_pages=_get_int("PDF_MAX_PAGES", default=20, minimum=0),
  188. presence=presence_raw == "on",
  189. presence_speed=presence_speed,
  190. presence_weights=_get_presence_weights(),
  191. presence_night=_get_night(),
  192. deezer_playlists=_get_deezer_playlists(),
  193. steam_api_key=_get_str("STEAM_API_KEY"),
  194. timezone=timezone,
  195. price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=default_input, minimum=0),
  196. price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=default_output, minimum=0),
  197. price_web_search_per_1000=_get_float("PRICE_WEB_SEARCH_PER_1000", default=10.0, minimum=0),
  198. log_file=Path(_get_str("LOG_FILE", default="bot.log")),
  199. )