feat: refine assistant logic and update docs

This commit is contained in:
future
2026-04-09 21:03:02 +03:00
parent ebe79c3692
commit 42c064a274
19 changed files with 1958 additions and 492 deletions

View File

@@ -7,7 +7,12 @@ from typing import Optional
import requests
from .config import (
AI_CHAT_MAX_CHARS,
AI_PROVIDER,
AI_CHAT_MAX_TOKENS,
AI_CHAT_TEMPERATURE,
AI_INTENT_TEMPERATURE,
AI_TRANSLATION_TEMPERATURE,
ANTHROPIC_API_KEY,
ANTHROPIC_API_URL,
ANTHROPIC_API_VERSION,
@@ -31,15 +36,25 @@ from .config import (
)
_HTTP = requests.Session()
_CITATION_SQUARE_RE = re.compile(r"(?:\s*\[\d+\])+")
_CITATION_FULLWIDTH_RE = re.compile(r"\d+[^】]*】")
_PUNCT_SPACING_RE = re.compile(r"\s+([,.;:!?…])")
_SENTENCE_BOUNDARY_RE = re.compile(r"([.!?…])\s+")
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…])\s+")
# Системный промпт
_wake_word_aliases_text = ", ".join(WAKE_WORD_ALIASES)
SYSTEM_PROMPT = f"""Ты — умный голосовой ассистент с человеческим поведением.
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
Отвечай кратко и по существу, на русском языке.
Отвечай на русском языке кратко и по существу: обычно 1-2 коротких предложения.
Если пользователь явно просит подробнее, можно до 4 коротких предложений без повторов и лишних вводных.
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
Не добавляй ссылки, сноски и маркеры источников (например, [1], [2], URL).
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
Понимай юмор, иронию, сарказм, образные выражения, намеки и переносный смысл фраз.
Если пользователь шутит или говорит образно, сначала правильно восстанови его реальное намерение, затем ответь естественно и по смыслу.
Если в шутке или метафоре скрыта команда или просьба, трактуй ее по смыслу, а не буквально.
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные.
Тебя активируют словом "{WAKE_WORD}". Никогда не произноси это слово и его варианты ({_wake_word_aliases_text}) ни в каком ответе.
Если пользователь спрашивает, как тебя зовут или как к тебе обращаться, отвечай нейтрально: "Я ваш голосовой ассистент"."""
@@ -73,7 +88,18 @@ INTENT_SYSTEM_PROMPT = """Ты NLU-модуль голосовой колонк
- Для "что играет" = music_action=current.
- Для "включи жанр X" = music_action=play_genre, music_query=X.
- Для "включи папку X" = music_action=play_folder, music_query=X.
- Если это будильник, ставь intent=alarm и нормализуй команду в одну из форм:
1) Создание/изменение: "поставь будильник на HH:MM [по будням|по выходным|каждый день|по <дням>]"
2) Показ списка: "покажи активные будильники"
3) Удаление конкретного: "удали будильник на HH:MM [по будням|по выходным|по <дням>]"
4) Удаление всех: "отмени все будильники"
- Если пользователь просит поставить/удалить будильник, но время не названо, normalized_command должен быть:
"поставь будильник" или "удали будильник".
- normalized_command должен быть пригоден для командного парсера (без лишних слов).
- Понимай разговорные, шутливые, переносные, косвенные и ироничные формулировки.
- Восстанавливай намерение по смыслу, а не только по буквальным словам.
- Если в фразе есть скрытая прикладная команда для колонки, верни соответствующий intent и normalized_command.
- Если пользователь просто шутит или разговаривает без прикладной команды, выбирай smalltalk или chat, а не случайную системную команду.
- Если уверенность низкая, ставь intent=none, music_action=none, confidence <= 0.4."""
_PROVIDER_ALIASES = {
@@ -442,6 +468,60 @@ def _extract_json_object(raw_text: str) -> Optional[dict]:
return None
def _sanitize_chat_response(text: str) -> str:
cleaned = str(text or "")
if not cleaned:
return ""
cleaned = _CITATION_SQUARE_RE.sub("", cleaned)
cleaned = _CITATION_FULLWIDTH_RE.sub("", cleaned)
cleaned = _PUNCT_SPACING_RE.sub(r"\1", cleaned)
cleaned = re.sub(r"[ \t]+", " ", cleaned)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned.strip()
def _truncate_chat_response(text: str, max_chars: int) -> str:
cleaned = str(text or "").strip()
if not cleaned:
return ""
safe_limit = max(120, int(max_chars))
if len(cleaned) <= safe_limit:
return cleaned
sentences = [part.strip() for part in _SENTENCE_SPLIT_RE.split(cleaned) if part.strip()]
if sentences:
selected = []
current_length = 0
for sentence in sentences:
projected = current_length + len(sentence) + (1 if selected else 0)
if projected > safe_limit:
break
selected.append(sentence)
current_length = projected
if selected:
result = " ".join(selected).rstrip(" ,;:-")
if result and result[-1] not in ".!?…":
result += "."
return result
# Если первое предложение слишком длинное, режем аккуратно по слову.
first = sentences[0]
else:
first = cleaned
clipped = first[:safe_limit].rstrip()
word_boundary = clipped.rfind(" ")
if word_boundary >= int(safe_limit * 0.6):
clipped = clipped[:word_boundary].rstrip()
clipped = clipped.rstrip(" ,;:-")
if clipped.endswith((".", "!", "?", "")):
return clipped
return f"{clipped}..."
def _send_request(messages, max_tokens, temperature, error_text):
"""
Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру.
@@ -512,7 +592,7 @@ def interpret_assistant_intent(text: str) -> dict:
response = _send_request(
messages,
max_tokens=220,
temperature=0.0,
temperature=AI_INTENT_TEMPERATURE,
error_text="",
)
payload = _extract_json_object(response)
@@ -596,10 +676,12 @@ def ask_ai(messages_history: list) -> str:
response = _send_request(
messages,
max_tokens=500,
temperature=1.0, # Высокая температура для более живого общения
max_tokens=AI_CHAT_MAX_TOKENS,
temperature=AI_CHAT_TEMPERATURE,
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
)
response = _sanitize_chat_response(response)
response = _truncate_chat_response(response, AI_CHAT_MAX_CHARS)
if response:
print(f"💬 Ответ AI: {response[:100]}...")
@@ -610,6 +692,7 @@ def ask_ai_stream(messages_history: list):
"""
Generator that yields chunks of the AI response as they arrive.
"""
response = None
cfg, selection_error = _get_provider_settings()
if selection_error:
yield selection_error
@@ -637,14 +720,46 @@ def ask_ai_stream(messages_history: list):
response = _HTTP.post(
cfg["api_url"],
headers=_build_headers(cfg),
json=_build_payload(cfg, messages, 500, 1.0, stream=True),
json=_build_payload(
cfg,
messages,
AI_CHAT_MAX_TOKENS,
AI_CHAT_TEMPERATURE,
stream=True,
),
timeout=15,
stream=True,
)
response.raise_for_status()
# Для устойчивости TTS сначала собираем поток, затем чистим и аккуратно
# ограничиваем длину по границе предложения.
raw_parts = []
for chunk in _iter_stream_chunks(cfg, response):
yield chunk
if chunk:
raw_parts.append(chunk)
full_text = _sanitize_chat_response("".join(raw_parts))
full_text = _truncate_chat_response(full_text, AI_CHAT_MAX_CHARS)
if not full_text:
return
# Отдаем кусками по предложениям, чтобы main.py мог начинать озвучку раньше.
parts = _SENTENCE_BOUNDARY_RE.split(full_text)
if not parts:
yield full_text
return
sentence = ""
for part in parts:
if not part:
continue
sentence += part
if part in ".!?…":
yield sentence.strip() + " "
sentence = ""
if sentence.strip():
yield sentence.strip()
except requests.exceptions.Timeout:
yield f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже."
except requests.exceptions.RequestException as error:
@@ -653,6 +768,12 @@ def ask_ai_stream(messages_history: list):
except Exception as error:
print(f"❌ Streaming Error ({cfg['name']}): {error}")
yield "Произошла ошибка связи."
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def translate_text(text: str, source_lang: str, target_lang: str) -> str:
@@ -683,17 +804,18 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
response = _send_request(
messages,
max_tokens=160,
temperature=0.2, # Низкая температура для точности перевода
temperature=AI_TRANSLATION_TEMPERATURE,
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
)
cleaned = response.strip()
cleaned = _sanitize_chat_response(response).strip()
cleaned = re.sub(r"[*_`]+", "", cleaned)
if not cleaned:
return cleaned
# Normalize to 2-3 variants separated by " / "
parts = []
for chunk in re.split(r"(?:\s*/\s*|\n|;|\|)", cleaned):
item = chunk.strip(" \t-•")
item = chunk.strip(" \t-•\"'“”«»")
if item:
parts.append(item)
if not parts:

View File

@@ -130,11 +130,9 @@ class AudioManager:
if match_idx is not None:
return match_idx
raise RuntimeError(
"Audio input initialization failed: could not find an input device "
f"matching AUDIO_INPUT_DEVICE_NAME={AUDIO_INPUT_DEVICE_NAME!r}. "
"Available input devices:\n"
+ self.describe_input_devices()
print(
"⚠️ AUDIO_INPUT_DEVICE_NAME was set but no matching input device was found: "
f"{AUDIO_INPUT_DEVICE_NAME!r}. Falling back to default input selection."
)
# Default input device (if PortAudio has one).
@@ -176,11 +174,9 @@ class AudioManager:
)
if match_idx is not None:
return match_idx
raise RuntimeError(
"Audio output initialization failed: could not find an output device "
f"matching AUDIO_OUTPUT_DEVICE_NAME={AUDIO_OUTPUT_DEVICE_NAME!r}. "
"Available output devices:\n"
+ self.describe_output_devices()
print(
"⚠️ AUDIO_OUTPUT_DEVICE_NAME was set but no matching output device was found: "
f"{AUDIO_OUTPUT_DEVICE_NAME!r}. Falling back to default output selection."
)
default_idx = self._get_default_output_index()

View File

@@ -342,6 +342,7 @@ def numbers_to_words(text: str) -> str:
case = "nominative"
gender = "m"
prep_clean = prep.strip().lower() if prep else None
parsed = None
if prep_clean:
morph_case = get_case_from_preposition(prep_clean)
@@ -359,6 +360,7 @@ def numbers_to_words(text: str) -> str:
# Спец-случай: "на 1 час"
if (
prep_clean == "на"
and parsed is not None
and parsed.normal_form in TIME_UNIT_LEMMAS
and parsed.tag.gender in ("masc", "neut")
):

View File

@@ -4,6 +4,9 @@ Command parsing helpers.
import re
from .config import WAKE_WORD, WAKE_WORD_ALIASES
from ..audio.sound_level import is_volume_command, parse_volume_text
_STOP_WORDS_STRICT = {
"стоп",
"хватит",
@@ -31,6 +34,28 @@ _STOP_PATTERNS_LENIENT = [
r"\остаточно\b",
]
_STOP_PATTERNS_LENIENT_COMPILED = [re.compile(p) for p in _STOP_PATTERNS_LENIENT]
_FAST_WEATHER_PHRASES = {
"какая погода",
"какая погода на улице",
"какая сейчас погода",
"какая сейчас погода на улице",
"что по погоде",
"погода",
"погода на улице",
"что на улице",
"что там на улице",
"че там на улице",
}
_FAST_MUSIC_PHRASES = {
"включи музыку",
"поставь музыку",
"играй музыку",
"play music",
}
_WAKEWORD_PREFIX_RE = re.compile(
rf"^(?:{'|'.join(re.escape(alias) for alias in sorted({WAKE_WORD.lower(), *WAKE_WORD_ALIASES}, key=len, reverse=True))})(?:\s+|$)",
re.IGNORECASE,
)
def _normalize_text(text: str) -> str:
@@ -40,6 +65,13 @@ def _normalize_text(text: str) -> str:
return text
def normalize_command_text(text: str) -> str:
normalized = _normalize_text(text)
if not normalized:
return ""
return _WAKEWORD_PREFIX_RE.sub("", normalized, count=1).strip()
def is_stop_command(text: str, mode: str = "strict") -> bool:
"""
Detect stop commands in text.
@@ -64,3 +96,27 @@ def is_stop_command(text: str, mode: str = "strict") -> bool:
return True
return False
def is_fast_command(text: str) -> bool:
"""
Detect short commands that can stop STT early without waiting
for full utterance finalization.
"""
normalized = normalize_command_text(text)
if not normalized:
return False
if is_stop_command(normalized, mode="strict"):
return True
if normalized in _FAST_WEATHER_PHRASES:
return True
if normalized in _FAST_MUSIC_PHRASES:
return True
if is_volume_command(normalized) and parse_volume_text(normalized) is not None:
return True
return False

View File

@@ -7,15 +7,49 @@ Loads environment variables from .env file.
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
import os
import re
import time
from io import StringIO
from pathlib import Path
from dotenv import load_dotenv
from dotenv import dotenv_values
# Базовая директория проекта (корневая папка, где лежит .env)
BASE_DIR = Path(__file__).resolve().parents[2]
# Загружаем переменные из файла .env в корневом каталоге
load_dotenv(BASE_DIR / ".env")
def _load_project_env(env_path: Path) -> None:
"""
Загружает .env, игнорируя строковый "шум" без формата KEY=VALUE.
Это делает конфиг устойчивым к человеческим комментариям без символа '#'.
"""
if not env_path.exists():
return
raw_text = env_path.read_text(encoding="utf-8")
sanitized_lines = []
for line in raw_text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
sanitized_lines.append(line)
continue
if "=" in line:
key = line.split("=", 1)[0].strip()
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key):
sanitized_lines.append(line)
continue
# Игнорируем невалидные строки, чтобы dotenv не шумел warning'ами.
sanitized_lines.append(f"# ignored invalid env line: {line}")
parsed = dotenv_values(stream=StringIO("\n".join(sanitized_lines)))
for key, value in parsed.items():
if key and value is not None and os.getenv(key) is None:
os.environ[key] = value
# Загружаем переменные из .env в корневом каталоге
_load_project_env(BASE_DIR / ".env")
# --- Настройки AI ---
# AI_PROVIDER опционален. Приоритет у единственного активного AI API key.
@@ -29,6 +63,22 @@ OPENROUTER_API_URL = os.getenv(
"OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions"
)
def _read_clamped_float_env(name: str, default: str, minimum: float, maximum: float) -> float:
try:
value = float(os.getenv(name, default))
except Exception:
value = float(default)
return max(minimum, min(maximum, value))
def _read_clamped_int_env(name: str, default: str, minimum: int, maximum: int) -> int:
try:
value = int(os.getenv(name, default))
except Exception:
value = int(default)
return max(minimum, min(maximum, value))
# OpenAI
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
@@ -65,6 +115,13 @@ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
OLLAMA_API_URL = os.getenv(
"OLLAMA_API_URL", "http://localhost:11434/v1/chat/completions"
)
AI_CHAT_TEMPERATURE = _read_clamped_float_env("AI_CHAT_TEMPERATURE", "0.9", 0.0, 2.0)
AI_CHAT_MAX_TOKENS = _read_clamped_int_env("AI_CHAT_MAX_TOKENS", "220", 80, 700)
AI_CHAT_MAX_CHARS = _read_clamped_int_env("AI_CHAT_MAX_CHARS", "320", 120, 1200)
AI_INTENT_TEMPERATURE = _read_clamped_float_env("AI_INTENT_TEMPERATURE", "0.0", 0.0, 1.0)
AI_TRANSLATION_TEMPERATURE = _read_clamped_float_env(
"AI_TRANSLATION_TEMPERATURE", "0.2", 0.0, 1.0
)
# --- Настройки распознавания речи (Deepgram) ---
# Ключ для облачного STT (Speech-to-Text)
@@ -86,6 +143,42 @@ WAKE_WORD_ALIASES = (
PORCUPINE_KEYWORD_PATH = BASE_DIR / "assets" / "models" / "Waltron_en_linux_v4_0_0.ppn"
# Чувствительность wake word (0..1). Выше = ловит легче, но больше ложных срабатываний.
PORCUPINE_SENSITIVITY = float(os.getenv("PORCUPINE_SENSITIVITY", "0.8"))
# Антифантомный фильтр wake word по RMS-сигналу.
# Чем выше WAKEWORD_MIN_RMS / WAKEWORD_RMS_MULTIPLIER, тем меньше ложных срабатываний,
# но тем выше риск не распознать очень тихую активацию.
try:
WAKEWORD_MIN_RMS = float(os.getenv("WAKEWORD_MIN_RMS", "120"))
except Exception:
WAKEWORD_MIN_RMS = 120.0
WAKEWORD_MIN_RMS = max(0.0, WAKEWORD_MIN_RMS)
try:
WAKEWORD_RMS_MULTIPLIER = float(os.getenv("WAKEWORD_RMS_MULTIPLIER", "1.7"))
except Exception:
WAKEWORD_RMS_MULTIPLIER = 1.7
WAKEWORD_RMS_MULTIPLIER = max(1.0, WAKEWORD_RMS_MULTIPLIER)
try:
WAKEWORD_HIT_COOLDOWN_SECONDS = float(
os.getenv("WAKEWORD_HIT_COOLDOWN_SECONDS", "1.2")
)
except Exception:
WAKEWORD_HIT_COOLDOWN_SECONDS = 1.2
WAKEWORD_HIT_COOLDOWN_SECONDS = max(0.0, WAKEWORD_HIT_COOLDOWN_SECONDS)
try:
WAKEWORD_REOPEN_GRACE_SECONDS = float(
os.getenv("WAKEWORD_REOPEN_GRACE_SECONDS", "0.45")
)
except Exception:
WAKEWORD_REOPEN_GRACE_SECONDS = 0.45
WAKEWORD_REOPEN_GRACE_SECONDS = max(0.0, WAKEWORD_REOPEN_GRACE_SECONDS)
WAKEWORD_ENABLE_FALLBACK_STT = (
os.getenv("WAKEWORD_ENABLE_FALLBACK_STT", "0").strip().lower()
in {"1", "true", "yes", "on"}
)
# При активации wake word музыка приглушается до указанного процента от текущего уровня.
WAKEWORD_MUSIC_DUCK_PERCENT = _read_clamped_int_env(
"WAKEWORD_MUSIC_DUCK_PERCENT", "20", 1, 100
)
WAKEWORD_MUSIC_DUCK_RATIO = WAKEWORD_MUSIC_DUCK_PERCENT / 100.0
# --- Параметры аудио ---
# Частота дискретизации для микрофона (стандарт для распознавания речи)
@@ -134,17 +227,17 @@ _stt_sfx_default = BASE_DIR / "assets" / "sounds" / "alisa-golosovoj-pomoschnik.
if not _stt_sfx_default.exists():
_stt_sfx_default = Path.home() / "Music" / "alisa-golosovoj-pomoschnik.mp3"
STT_START_SOUND_PATH = os.getenv("STT_START_SOUND_PATH", "").strip() or str(_stt_sfx_default)
try:
STT_START_SOUND_VOLUME = float(os.getenv("STT_START_SOUND_VOLUME", "0.25"))
except Exception:
STT_START_SOUND_VOLUME = 0.25
STT_START_SOUND_VOLUME = max(0.0, min(1.0, STT_START_SOUND_VOLUME))
# Звук старта STT всегда на 100% громкости, чтобы по уровню был как обычный TTS-ответ.
STT_START_SOUND_VOLUME = 1.0
# Голос для русского языка (eugene - мужской голос)
TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene
# Голос для английского языка
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
# Частота дискретизации для воспроизведения (качество звука)
TTS_SAMPLE_RATE = 48000
# Скорость TTS: 1.0 = обычная, <1.0 = медленнее, >1.0 = быстрее.
# По умолчанию чуть медленнее для более разборчивой речи.
TTS_SPEED = _read_clamped_float_env("TTS_SPEED", "0.96", 0.85, 1.15)
# --- Настройки погоды ---
WEATHER_LAT = os.getenv("WEATHER_LAT")