feat: refine assistant logic and update docs
This commit is contained in:
140
app/core/ai.py
140
app/core/ai.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user