1
0

config.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  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, field
  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 (repr=False : jamais affichés si la configuration est journalisée)
  92. discord_token: str = field(repr=False)
  93. anthropic_api_key: str = field(repr=False)
  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. memory_member_limit: int # caractères maximum d'une fiche de membre
  113. memory_server_limit: int # caractères maximum du fichier général
  114. memory_max_operations: int # lectures et écritures maximum par réponse
  115. # Images et PDF
  116. max_image_size: int
  117. pdf_max_pages: int # 0 : PDF refusés
  118. # Statut Discord (voir presence.py)
  119. presence: bool
  120. presence_speed: float # pour tester : 10 = statuts 10 fois plus courts
  121. presence_weights: dict[str, int]
  122. presence_night: tuple[int, int] | None # heures de début et de fin ; None = pas de nuit
  123. deezer_playlists: tuple[int, ...]
  124. steam_api_key: str | None = field(repr=False)
  125. # Divers
  126. timezone: ZoneInfo
  127. price_input_per_mtok: float | None # None = modèle absent de la table des tarifs
  128. price_output_per_mtok: float | None
  129. price_web_search_per_1000: float
  130. log_file: Path
  131. def load_settings() -> Settings:
  132. """Lit le fichier .env et renvoie une configuration validée."""
  133. # override=True : le fichier .env est prioritaire sur les variables d'environnement de Windows.
  134. load_dotenv(override=True)
  135. tz_name = _get_str("TIMEZONE", default="Europe/Paris")
  136. try:
  137. timezone = ZoneInfo(tz_name)
  138. # ValueError : nom mal formé (« Europe/Paris/ », « ../Paris ») ; OSError : nom trop long pour un fichier.
  139. except (ZoneInfoNotFoundError, ValueError, OSError):
  140. raise ConfigError(f"Fuseau horaire inconnu dans TIMEZONE : {tz_name!r} (exemple valide : Europe/Paris).")
  141. thinking_raw = (_get_str("THINKING", default="off") or "off").lower()
  142. if thinking_raw not in ("on", "off"):
  143. raise ConfigError(f"THINKING doit valoir « on » ou « off » (valeur actuelle : {thinking_raw!r}).")
  144. thinking = thinking_raw == "on"
  145. model = _get_str("CLAUDE_MODEL", default="claude-sonnet-5-5")
  146. effort = _get_str("EFFORT")
  147. if effort is not None:
  148. effort = effort.lower()
  149. if effort not in EFFORT_LEVELS:
  150. raise ConfigError(f"EFFORT doit valoir {', '.join(EFFORT_LEVELS)} (valeur actuelle : {effort!r}).")
  151. # Couper la réflexion n'est accepté qu'avec un effort high ou moins.
  152. if not thinking and effort in ("xhigh", "max") and thinking_off(model) is not None:
  153. raise ConfigError(f"EFFORT={effort} nécessite THINKING=on avec le modèle {model}.")
  154. # Tarifs du .env s'ils sont renseignés, sinon ceux du modèle choisi.
  155. default_input, default_output = PRICES.get(model, (None, None))
  156. web_search_raw = (_get_str("WEB_SEARCH", default="off") or "off").lower()
  157. if web_search_raw not in ("on", "off"):
  158. raise ConfigError(f"WEB_SEARCH doit valoir « on » ou « off » (valeur actuelle : {web_search_raw!r}).")
  159. memory_raw = (_get_str("MEMORY", default="off") or "off").lower()
  160. if memory_raw not in ("on", "off"):
  161. raise ConfigError(f"MEMORY doit valoir « on » ou « off » (valeur actuelle : {memory_raw!r}).")
  162. history_file = Path(_get_str("HISTORY_FILE", default="conversation_history.json"))
  163. # Vide : à côté de l'historique, comme les autres fichiers du bot (donc sur le même volume avec Docker).
  164. memory_dir = _get_str("MEMORY_DIR")
  165. memory_limits = {name: _get_int(name, default=default, minimum=200)
  166. for name, default in (("MEMORY_MEMBER_LIMIT", 3000), ("MEMORY_SERVER_LIMIT", 5000))}
  167. for name, value in memory_limits.items():
  168. # La fenêtre « Modifier » de /memoire contient au plus 5 zones de 4000 caractères.
  169. if value > 20000:
  170. raise ConfigError(f"{name} ne peut pas dépasser 20000 (valeur actuelle : {value}).")
  171. presence_raw = (_get_str("PRESENCE", default="on") or "on").lower()
  172. if presence_raw not in ("on", "off"):
  173. raise ConfigError(f"PRESENCE doit valoir « on » ou « off » (valeur actuelle : {presence_raw!r}).")
  174. presence_speed = _get_float("PRESENCE_SPEED", default=1.0)
  175. if presence_speed <= 0:
  176. raise ConfigError(f"PRESENCE_SPEED doit être supérieur à 0 (valeur actuelle : {presence_speed}).")
  177. return Settings(
  178. discord_token=_get_str("DISCORD_TOKEN", required=True),
  179. anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
  180. channel_id=_get_int("CHANNEL_ID", required=True),
  181. model=model,
  182. max_tokens=_get_int("MAX_TOKENS", default=1500, minimum=1),
  183. thinking=thinking,
  184. effort=effort,
  185. api_timeout=_get_float("API_TIMEOUT", default=60.0, minimum=1),
  186. web_search=web_search_raw == "on",
  187. web_max_uses=_get_int("WEB_MAX_USES", default=3, minimum=1),
  188. max_history_length=_get_int("MAX_HISTORY_LENGTH", default=10, minimum=2),
  189. context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0),
  190. history_file=history_file,
  191. personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
  192. memory=memory_raw == "on",
  193. memory_dir=Path(memory_dir) if memory_dir else history_file.with_name("memories"),
  194. # Chaque fiche jointe est relue à chaque message : plus elle est longue, plus il coûte.
  195. memory_member_limit=memory_limits["MEMORY_MEMBER_LIMIT"],
  196. memory_server_limit=memory_limits["MEMORY_SERVER_LIMIT"],
  197. memory_max_operations=_get_int("MEMORY_MAX_OPERATIONS", default=5, minimum=1),
  198. # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
  199. max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
  200. # Chaque page coûte son texte et son image (souvent plusieurs milliers de tokens).
  201. pdf_max_pages=_get_int("PDF_MAX_PAGES", default=20, minimum=0),
  202. presence=presence_raw == "on",
  203. presence_speed=presence_speed,
  204. presence_weights=_get_presence_weights(),
  205. presence_night=_get_night(),
  206. deezer_playlists=_get_deezer_playlists(),
  207. steam_api_key=_get_str("STEAM_API_KEY"),
  208. timezone=timezone,
  209. price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=default_input, minimum=0),
  210. price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=default_output, minimum=0),
  211. price_web_search_per_1000=_get_float("PRICE_WEB_SEARCH_PER_1000", default=10.0, minimum=0),
  212. log_file=Path(_get_str("LOG_FILE", default="bot.log")),
  213. )