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: