diff --git a/README.md b/README.md index 4259d9d..c33aefe 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ `Alexander Smart Speaker` слушает ключевое слово `Alexandr`, распознает речь, маршрутизирует команду в нужный модуль и озвучивает ответ. Проект оптимизирован под русский язык, но поддерживает RU/EN сценарии (включая перевод и mixed-language TTS). +Проект собран как локальная голосовая колонка под Linux: активация по wake word, распознавание речи, маршрутизация команд, ответ через AI или встроенные модули и затем озвучка результата. + ## Возможности - Активация по wake word `Alexandr` (Porcupine). @@ -45,6 +47,13 @@ flowchart TD F --> G[Follow-up режим или ожидание wake word] ``` +## Что Важно В Этой Реализации + +- Контекст диалога хранится в памяти текущей сессии, поэтому после первого вопроса можно продолжать разговор без потери нити. +- Системная роль ассистента и `ROLE_JSON` сохраняются для всех поддерживаемых AI-провайдеров. +- Для AI используется строго один активный API key. Если в `.env` оставить несколько ключей, ассистент покажет ошибку конфигурации вместо случайного выбора. +- Поддержка провайдеров сделана внутри одного модуля, но с разным форматом запросов для OpenAI-compatible API и Anthropic. + ## Быстрый старт ### 1) Системные зависимости (Ubuntu/Debian) @@ -86,6 +95,19 @@ PORCUPINE_ACCESS_KEY=... Если одновременно оставить несколько AI API key, ассистент вернет ошибку: он не будет выбирать провайдера наугад. +Пример: + +```ini +# правильно +OPENAI_API_KEY=sk-... +# GEMINI_API_KEY=... +# ANTHROPIC_API_KEY=... + +# неправильно +OPENAI_API_KEY=sk-... +GEMINI_API_KEY=AIza... +``` + ### 4) Запуск ```bash @@ -146,6 +168,15 @@ python run.py Память текущего диалога, история сообщений и `ROLE_JSON` системной роли сохраняются для всех поддерживаемых AI-провайдеров. +## Как Выбирается AI-Провайдер + +1. Приложение проверяет, какие AI API key реально активны в `.env`. +2. Если активен ровно один ключ, используется именно он. +3. Если активны несколько ключей, ассистент возвращает ошибку конфигурации. +4. Если активных ключей нет, приложение ориентируется на `AI_PROVIDER`, но без ключа работать не сможет. + +Такое поведение сделано специально, чтобы конфигурация была предсказуемой и при демонстрации не возникало скрытого переключения между сервисами. + ## Полезные команды | Команда | Что делает | @@ -179,6 +210,7 @@ alexander_smart-speaker/ | STT не распознает речь | `DEEPGRAM_API_KEY`, сетевой доступ, выбранный микрофон | | Нет звука | корректное аудиоустройство и доступность `pactl`/`amixer` | | Будильник/таймер не звонит | наличие `mpg123` в системе | +| Ошибка про несколько AI API | в `.env` должен остаться только один незакомментированный AI ключ | | Spotify не управляется | заполнены `SPOTIFY_*`, есть активное устройство, Premium-аккаунт | ## Лицензия diff --git a/app/core/ai.py b/app/core/ai.py index 90861ab..2305769 100644 --- a/app/core/ai.py +++ b/app/core/ai.py @@ -69,6 +69,9 @@ _PROVIDER_ALIASES = { "zai": "zai", } +# В реальном .env у пользователя должен быть активен только один AI-ключ. +# Поэтому настройки храним в одном словаре, а ниже отдельно проверяем конфликт +# конфигурации, чтобы ассистент не делал "лучшее предположение" молча. _PROVIDER_SETTINGS = { "perplexity": { "provider": "perplexity", @@ -137,6 +140,8 @@ def _normalize_provider_name(provider_name: str) -> str: def _get_provider_settings(): + # Сначала ищем реально активные ключи. Это главный источник истины: + # если ключ один, используем именно его, даже если AI_PROVIDER указан иначе. configured = [ cfg for cfg in _PROVIDER_SETTINGS.values() @@ -233,11 +238,13 @@ def _split_system_messages(messages): role = "user" chat_messages.append({"role": role, "content": content}) + # Anthropic хранит системную инструкцию отдельно от обычной истории чата. return "\n\n".join(system_parts), chat_messages def _build_payload(cfg, messages, max_tokens, temperature, stream): if cfg["protocol"] == "anthropic": + # У Claude схема чуть отличается: system не живет внутри messages. system_prompt, chat_messages = _split_system_messages(messages) payload = { "model": cfg["model"], @@ -303,6 +310,7 @@ def _iter_openai_compatible_stream(response): yield content continue + # Некоторые OpenAI-compatible API присылают контент кусками-объектами. if isinstance(content, list): for item in content: if isinstance(item, dict): @@ -326,6 +334,7 @@ def _iter_anthropic_stream(response): continue chunk_type = data_json.get("type") + # Claude отдает поток событиями разных типов, нас интересует только текст. if chunk_type == "content_block_start": content_block = data_json.get("content_block") or {} text = content_block.get("text") @@ -374,6 +383,7 @@ def _send_request(messages, max_tokens, temperature, error_text): return config_error try: + # Обычный запрос нужен для перевода и мест, где стриминг не требуется. response = _HTTP.post( cfg["api_url"], headers=_build_headers(cfg), @@ -454,6 +464,7 @@ def ask_ai_stream(messages_history: list): messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history) try: + # В голосовом режиме удобнее говорить частями, как только они приходят от API. response = _HTTP.post( cfg["api_url"], headers=_build_headers(cfg),