config.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. """Chargement et validation de la configuration depuis le fichier .env."""
  2. from __future__ import annotations
  3. import os
  4. from dataclasses import dataclass
  5. from pathlib import Path
  6. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
  7. from dotenv import load_dotenv
  8. EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
  9. class ConfigError(Exception):
  10. """Erreur de configuration (variable manquante ou invalide)."""
  11. def _get_str(name: str, default: str | None = None, required: bool = False) -> str | None:
  12. value = os.getenv(name)
  13. if value is None or value.strip() == "":
  14. if required:
  15. raise ConfigError(f"La variable {name} est obligatoire mais absente du fichier .env.")
  16. return default
  17. return value.strip()
  18. def _get_int(name: str, default: int | None = None, required: bool = False, minimum: int | None = None) -> int | None:
  19. raw = _get_str(name, required=required)
  20. if raw is None:
  21. return default
  22. try:
  23. value = int(raw)
  24. except ValueError:
  25. raise ConfigError(f"La variable {name} doit être un nombre entier (valeur actuelle : {raw!r}).")
  26. if minimum is not None and value < minimum:
  27. raise ConfigError(f"La variable {name} doit être supérieure ou égale à {minimum} (valeur actuelle : {value}).")
  28. return value
  29. def _get_float(name: str, default: float | None = None) -> float | None:
  30. raw = _get_str(name)
  31. if raw is None:
  32. return default
  33. try:
  34. return float(raw.replace(",", "."))
  35. except ValueError:
  36. raise ConfigError(f"La variable {name} doit être un nombre (valeur actuelle : {raw!r}).")
  37. @dataclass(frozen=True)
  38. class Settings:
  39. # Identifiants
  40. discord_token: str
  41. anthropic_api_key: str
  42. channel_id: int
  43. # Claude
  44. model: str
  45. max_tokens: int
  46. thinking: bool # réflexion préalable avant de répondre (plus lent et plus cher)
  47. effort: str | None # None = valeur par défaut du modèle
  48. api_timeout: float
  49. # Mémoire et contexte
  50. max_history_length: int
  51. context_message_limit: int
  52. history_file: Path
  53. personality_file: Path
  54. # Images
  55. max_image_size: int
  56. # Divers
  57. timezone: ZoneInfo
  58. price_input_per_mtok: float
  59. price_output_per_mtok: float
  60. log_file: Path
  61. def load_settings() -> Settings:
  62. """Lit le fichier .env et renvoie une configuration validée."""
  63. # override=True : le fichier .env est prioritaire sur les variables d'environnement de Windows.
  64. load_dotenv(override=True)
  65. tz_name = _get_str("TIMEZONE", default="Europe/Paris")
  66. try:
  67. timezone = ZoneInfo(tz_name)
  68. except ZoneInfoNotFoundError:
  69. raise ConfigError(f"Fuseau horaire inconnu dans TIMEZONE : {tz_name!r} (exemple valide : Europe/Paris).")
  70. thinking_raw = (_get_str("THINKING", default="off") or "off").lower()
  71. if thinking_raw not in ("on", "off"):
  72. raise ConfigError(f"THINKING doit valoir « on » ou « off » (valeur actuelle : {thinking_raw!r}).")
  73. thinking = thinking_raw == "on"
  74. effort = _get_str("EFFORT")
  75. if effort is not None:
  76. effort = effort.lower()
  77. if effort not in EFFORT_LEVELS:
  78. raise ConfigError(f"EFFORT doit valoir {', '.join(EFFORT_LEVELS)} (valeur actuelle : {effort!r}).")
  79. if not thinking and effort in ("xhigh", "max"):
  80. raise ConfigError(f"EFFORT={effort} nécessite THINKING=on.")
  81. return Settings(
  82. discord_token=_get_str("DISCORD_TOKEN", required=True),
  83. anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
  84. channel_id=_get_int("CHANNEL_ID", required=True),
  85. model=_get_str("CLAUDE_MODEL", default="claude-sonnet-5-5"),
  86. max_tokens=_get_int("MAX_TOKENS", default=1500, minimum=1),
  87. thinking=thinking,
  88. effort=effort,
  89. api_timeout=_get_float("API_TIMEOUT", default=60.0),
  90. max_history_length=_get_int("MAX_HISTORY_LENGTH", default=10, minimum=2),
  91. context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0),
  92. history_file=Path(_get_str("HISTORY_FILE", default="conversation_history.json")),
  93. personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
  94. # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
  95. max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
  96. timezone=timezone,
  97. price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=2.0),
  98. price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=10.0),
  99. log_file=Path(_get_str("LOG_FILE", default="bot.log")),
  100. )