Files
kandrusyak_bot/assistant_bot/config.py
kandrusyak d1c1ef68d3
Some checks failed
quality / test (3.10) (push) Has been cancelled
quality / test (3.12) (push) Has been cancelled
Improve agent reliability and token efficiency
2026-07-27 19:20:48 +03:00

222 lines
6.5 KiB
Python

import logging
import os
from dataclasses import dataclass
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ENV_FILE = PROJECT_ROOT / ".env"
TOKEN_ENV_NAME = "BOT_TOKEN"
PASSWORD_ENV_NAME = "ASSISTANT_PASSWORD"
DB_ENV_NAME = "ASSISTANT_DB"
MODE_ENV_NAME = "ASSISTANT_MODE"
OLLAMA_BASE_URL_ENV_NAME = "OLLAMA_BASE_URL"
OLLAMA_MODEL_ENV_NAME = "OLLAMA_MODEL"
YANDEX_CLOUD_FOLDER_ENV_NAME = "YANDEX_CLOUD_FOLDER"
YANDEX_CLOUD_MODEL_ENV_NAME = "YANDEX_CLOUD_MODEL"
YANDEX_STT_MODEL_ENV_NAME = "YANDEX_STT_MODEL"
YANDEX_STT_LANGUAGE_ENV_NAME = "YANDEX_STT_LANGUAGE"
TIMEZONE_ENV_NAME = "ASSISTANT_TIMEZONE"
WHISPER_MODEL_ENV_NAME = "WHISPER_MODEL"
WHISPER_DEVICE_ENV_NAME = "WHISPER_DEVICE"
WHISPER_COMPUTE_TYPE_ENV_NAME = "WHISPER_COMPUTE_TYPE"
WHISPER_LANGUAGE_ENV_NAME = "WHISPER_LANGUAGE"
VOICE_MAX_DURATION_ENV_NAME = "VOICE_MAX_DURATION_SECONDS"
DEFAULT_DB_FILE = PROJECT_ROOT / "assistant_data.sqlite3"
DEFAULT_MODE = "local"
DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434"
DEFAULT_OLLAMA_MODEL = "qwen3.5:9b"
DEFAULT_YANDEX_CLOUD_MODEL = "yandexgpt/latest"
DEFAULT_YANDEX_STT_MODEL = "general"
DEFAULT_YANDEX_STT_LANGUAGE = "ru-RU"
DEFAULT_TIMEZONE = "Europe/Moscow"
DEFAULT_WHISPER_MODEL = "large-v3"
DEFAULT_WHISPER_DEVICE = "cuda"
DEFAULT_WHISPER_COMPUTE_TYPE = "int8"
DEFAULT_WHISPER_LANGUAGE = "ru"
DEFAULT_VOICE_MAX_DURATION_SECONDS = 120
REMINDER_POLL_SECONDS = 30
MAX_TELEGRAM_MESSAGE_LENGTH = 3900
MAX_AGENT_STEPS = 5
MAX_AGENT_TOOL_CALLS_PER_STEP = 5
CONVERSATION_HISTORY_LIMIT = 12
@dataclass(frozen=True)
class AppSettings:
bot_token: str
assistant_password: str
db_path: Path
mode: str
timezone: ZoneInfo
voice_max_duration_seconds: int
ollama_base_url: str
ollama_model: str
yandex_cloud_folder: str | None
yandex_cloud_model: str
yandex_stt_model: str
yandex_stt_language: str
whisper_model: str
whisper_device: str
whisper_compute_type: str
whisper_language: str | None
def default_models(self) -> dict[str, str]:
models = {"local": self.ollama_model}
if self.yandex_cloud_folder:
models["yandex"] = (
f"gpt://{self.yandex_cloud_folder}/"
f"{self.yandex_cloud_model}"
)
return models
def load_env_file(path: Path = ENV_FILE) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if not key or key in os.environ:
continue
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
os.environ[key] = value
def load_app_settings() -> AppSettings:
"""Load and validate the complete startup configuration once."""
load_env_file()
token = os.getenv(TOKEN_ENV_NAME)
if not token:
raise RuntimeError(
f"Set {TOKEN_ENV_NAME} in environment or .env file."
)
password = os.getenv(PASSWORD_ENV_NAME, "").strip()
if not password:
raise RuntimeError(
f"Set {PASSWORD_ENV_NAME} in environment or .env file."
)
mode = os.getenv(MODE_ENV_NAME, DEFAULT_MODE).strip().lower()
if mode not in {"local", "yandex"}:
raise RuntimeError(
f"{MODE_ENV_NAME} must be either 'local' or 'yandex', got {mode!r}."
)
raw_db_path = os.getenv(DB_ENV_NAME)
if raw_db_path:
db_path = Path(raw_db_path).expanduser()
if not db_path.is_absolute():
db_path = PROJECT_ROOT / db_path
else:
db_path = DEFAULT_DB_FILE
timezone_name = os.getenv(TIMEZONE_ENV_NAME, DEFAULT_TIMEZONE)
try:
local_timezone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
logger.warning(
"Unknown timezone %s, falling back to UTC",
timezone_name,
)
local_timezone = ZoneInfo("UTC")
raw_voice_duration = os.getenv(
VOICE_MAX_DURATION_ENV_NAME,
str(DEFAULT_VOICE_MAX_DURATION_SECONDS),
)
try:
voice_duration = int(raw_voice_duration)
except ValueError:
voice_duration = 0
if voice_duration <= 0:
logger.warning(
"%s must be a positive integer, using %s",
VOICE_MAX_DURATION_ENV_NAME,
DEFAULT_VOICE_MAX_DURATION_SECONDS,
)
voice_duration = DEFAULT_VOICE_MAX_DURATION_SECONDS
yandex_folder = os.getenv(
YANDEX_CLOUD_FOLDER_ENV_NAME,
"",
).strip().strip("/")
if mode == "yandex" and not yandex_folder:
raise RuntimeError(
f"Set {YANDEX_CLOUD_FOLDER_ENV_NAME} in environment or .env file."
)
yandex_model = os.getenv(
YANDEX_CLOUD_MODEL_ENV_NAME,
DEFAULT_YANDEX_CLOUD_MODEL,
).strip().strip("/")
if not yandex_model:
yandex_model = DEFAULT_YANDEX_CLOUD_MODEL
whisper_language = os.getenv(
WHISPER_LANGUAGE_ENV_NAME,
DEFAULT_WHISPER_LANGUAGE,
).strip()
return AppSettings(
bot_token=token,
assistant_password=password,
db_path=db_path,
mode=mode,
timezone=local_timezone,
voice_max_duration_seconds=voice_duration,
ollama_base_url=os.getenv(
OLLAMA_BASE_URL_ENV_NAME,
DEFAULT_OLLAMA_BASE_URL,
).rstrip("/"),
ollama_model=os.getenv(
OLLAMA_MODEL_ENV_NAME,
DEFAULT_OLLAMA_MODEL,
),
yandex_cloud_folder=yandex_folder or None,
yandex_cloud_model=yandex_model,
yandex_stt_model=os.getenv(
YANDEX_STT_MODEL_ENV_NAME,
DEFAULT_YANDEX_STT_MODEL,
).strip(),
yandex_stt_language=os.getenv(
YANDEX_STT_LANGUAGE_ENV_NAME,
DEFAULT_YANDEX_STT_LANGUAGE,
).strip(),
whisper_model=os.getenv(
WHISPER_MODEL_ENV_NAME,
DEFAULT_WHISPER_MODEL,
).strip(),
whisper_device=os.getenv(
WHISPER_DEVICE_ENV_NAME,
DEFAULT_WHISPER_DEVICE,
).strip(),
whisper_compute_type=os.getenv(
WHISPER_COMPUTE_TYPE_ENV_NAME,
DEFAULT_WHISPER_COMPUTE_TYPE,
).strip(),
whisper_language=(
None
if whisper_language.lower() == "auto"
else whisper_language or None
),
)