chore: sync local changes
This commit is contained in:
11
.qwen/settings.json
Normal file
11
.qwen/settings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$version": 3,
|
||||
"model": {
|
||||
"name": "coder-model"
|
||||
},
|
||||
"general": {
|
||||
"checkpointing": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
13
11.py
Normal file
13
11.py
Normal file
@@ -0,0 +1,13 @@
|
||||
def f(x, p1, p2):
|
||||
if x == 3:
|
||||
return 1
|
||||
elif x == 38:
|
||||
p1 += 1
|
||||
elif x == 18:
|
||||
p2 += 1
|
||||
else:
|
||||
if p2 >= 1 or p1 >= 1:
|
||||
return f(x - 3, p1, p2) + f(x // 3, p1, p2) + f(x - 5, p1, p2)
|
||||
|
||||
|
||||
print(f(80, 0, 0))
|
||||
@@ -1,7 +1,4 @@
|
||||
"""AI module with pluggable providers."""
|
||||
|
||||
# Модуль общения с искусственным интеллектом.
|
||||
# Обрабатывает запросы пользователя и переводы через выбранный API-провайдер.
|
||||
"""AI module."""
|
||||
|
||||
import json
|
||||
import re
|
||||
@@ -31,8 +28,7 @@ from .config import (
|
||||
|
||||
_HTTP = requests.Session()
|
||||
|
||||
# Системный промпт (инструкция) для AI.
|
||||
# Задает личность ассистента: имя "Александр", стиль общения, краткость.
|
||||
# Системный промпт
|
||||
SYSTEM_PROMPT = """Ты — Александр, умный голосовой ассистент с человеческим поведением.
|
||||
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
|
||||
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
|
||||
@@ -45,8 +41,7 @@ SYSTEM_PROMPT += (
|
||||
'"language":"ru","style":["дружелюбный","естественный","краткий"],"format":"plain"}'
|
||||
)
|
||||
|
||||
# Системный промпт для режима переводчика.
|
||||
# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод...").
|
||||
# Промпт для перевода
|
||||
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
|
||||
Translate from {source} to {target}.
|
||||
Return 2-3 short translation variants only.
|
||||
@@ -69,9 +64,7 @@ _PROVIDER_ALIASES = {
|
||||
"zai": "zai",
|
||||
}
|
||||
|
||||
# В реальном .env у пользователя должен быть активен только один AI-ключ.
|
||||
# Поэтому настройки храним в одном словаре, а ниже отдельно проверяем конфликт
|
||||
# конфигурации, чтобы ассистент не делал "лучшее предположение" молча.
|
||||
# В .env нужен только один AI-ключ
|
||||
_PROVIDER_SETTINGS = {
|
||||
"perplexity": {
|
||||
"provider": "perplexity",
|
||||
@@ -140,8 +133,8 @@ def _normalize_provider_name(provider_name: str) -> str:
|
||||
|
||||
|
||||
def _get_provider_settings():
|
||||
# Сначала ищем реально активные ключи. Это главный источник истины:
|
||||
# если ключ один, используем именно его, даже если AI_PROVIDER указан иначе.
|
||||
"""Определяет какой AI провайдер использовать."""
|
||||
# Ищем активные ключи
|
||||
configured = [
|
||||
cfg
|
||||
for cfg in _PROVIDER_SETTINGS.values()
|
||||
@@ -150,7 +143,11 @@ def _get_provider_settings():
|
||||
if len(configured) == 1:
|
||||
cfg = configured[0]
|
||||
requested = _normalize_provider_name(AI_PROVIDER)
|
||||
if requested and requested in _PROVIDER_SETTINGS and requested != cfg["provider"]:
|
||||
if (
|
||||
requested
|
||||
and requested in _PROVIDER_SETTINGS
|
||||
and requested != cfg["provider"]
|
||||
):
|
||||
print(
|
||||
f"⚠️ AI_PROVIDER={AI_PROVIDER!r} не совпадает с единственным "
|
||||
f"активным ключом {cfg['name']}. Используем {cfg['name']}."
|
||||
@@ -223,6 +220,7 @@ def _build_headers(cfg):
|
||||
|
||||
|
||||
def _split_system_messages(messages):
|
||||
"""Извлекает system prompt из списка сообщений."""
|
||||
system_parts = []
|
||||
chat_messages = []
|
||||
|
||||
@@ -238,7 +236,6 @@ def _split_system_messages(messages):
|
||||
role = "user"
|
||||
chat_messages.append({"role": role, "content": content})
|
||||
|
||||
# Anthropic хранит системную инструкцию отдельно от обычной истории чата.
|
||||
return "\n\n".join(system_parts), chat_messages
|
||||
|
||||
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
"""
|
||||
Response cleaner module.
|
||||
Removes markdown formatting and special characters from AI responses.
|
||||
Handles complex number-to-text conversion for Russian language.
|
||||
"""
|
||||
|
||||
# Модуль очистки текста перед озвучкой.
|
||||
# 1. Убирает Markdown (жирный шрифт, ссылки), который генерирует AI, чтобы робот не читал спецсимволы.
|
||||
# 2. Преобразует числа в слова ("5 мая" -> "пятого мая", "5 рублей" -> "пять рублей").
|
||||
# Это критически важно для качественного русского TTS.
|
||||
"""Text cleaner for TTS."""
|
||||
|
||||
import re
|
||||
import pymorphy3
|
||||
from num2words import num2words
|
||||
from .roman import roman_to_int
|
||||
|
||||
# Инициализация морфологического анализатора (для определения падежей)
|
||||
morph = pymorphy3.MorphAnalyzer()
|
||||
|
||||
# Карта предлогов и падежей.
|
||||
# Помогает понять, в какой падеж ставить число после предлога.
|
||||
# Предлоги и падежи
|
||||
PREPOSITION_CASES = {
|
||||
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
|
||||
"во": "loct",
|
||||
@@ -55,7 +44,7 @@ PREPOSITION_CASES = {
|
||||
"про": "accs",
|
||||
}
|
||||
|
||||
# Соответствие падежей pymorphy и библиотеки num2words
|
||||
# Соответствие падежей
|
||||
PYMORPHY_TO_NUM2WORDS = {
|
||||
"nomn": "nominative",
|
||||
"gent": "genitive",
|
||||
@@ -69,14 +58,14 @@ PYMORPHY_TO_NUM2WORDS = {
|
||||
"loc2": "prepositional",
|
||||
}
|
||||
|
||||
# Соответствие родов pymorphy и num2words
|
||||
# Роды
|
||||
PYMORPHY_TO_GENDER = {
|
||||
"masc": "m",
|
||||
"femn": "f",
|
||||
"neut": "n",
|
||||
}
|
||||
|
||||
# Названия месяцев в родительном падеже (для поиска дат в тексте)
|
||||
# Месяца
|
||||
MONTHS_GENITIVE = [
|
||||
"января",
|
||||
"февраля",
|
||||
@@ -92,10 +81,10 @@ MONTHS_GENITIVE = [
|
||||
"декабря",
|
||||
]
|
||||
|
||||
# Леммы единиц времени (для корректного падежа числительных)
|
||||
# Время
|
||||
TIME_UNIT_LEMMAS = {"час", "минута", "секунда"}
|
||||
|
||||
# Суффиксы порядковых числительных для формата "1968-й", "1968-го", "1968-му", "1968-м" и т.п.
|
||||
# Суффиксы порядковых
|
||||
_ORDINAL_SUFFIX_MAP = {
|
||||
# Masculine
|
||||
"ого": ("genitive", "m"),
|
||||
@@ -126,20 +115,15 @@ _ORDINAL_SUFFIX_MAP = {
|
||||
|
||||
|
||||
def get_case_from_preposition(prep_token):
|
||||
"""Определяет падеж по предлогу."""
|
||||
"""Падеж по предлогу."""
|
||||
if not prep_token:
|
||||
return None
|
||||
return PREPOSITION_CASES.get(prep_token.lower())
|
||||
|
||||
|
||||
def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"):
|
||||
"""
|
||||
Обертка над num2words для конвертации числа в строку.
|
||||
cardinal - количественное (один, два)
|
||||
ordinal - порядковое (первый, второй)
|
||||
"""
|
||||
"""Число в слова."""
|
||||
try:
|
||||
# Обработка дробей (замена запятой на точку)
|
||||
if "." in number_str or "," in number_str:
|
||||
num_val = float(number_str.replace(",", "."))
|
||||
else:
|
||||
@@ -152,20 +136,18 @@ def convert_number(number_str, context_type="cardinal", case="nominative", gende
|
||||
|
||||
|
||||
def numbers_to_words(text: str) -> str:
|
||||
"""
|
||||
Интеллектуальная замена цифр на слова с учетом контекста (даты, года, падежи).
|
||||
"""
|
||||
"""Замена цифр на слова."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
preps_list = "|".join(map(re.escape, PREPOSITION_CASES.keys()))
|
||||
|
||||
# 0. Обработка короткой записи годов с суффиксом: "1968-й", "1968-го", "1968-му", "1968-м", "в 1968-м году"
|
||||
# Года с суффиксом
|
||||
def replace_year_suffix_match(match):
|
||||
prep = match.group(1) # Предлог (в, во, о...)
|
||||
year_str = match.group(2) # Само число
|
||||
suffix = match.group(3) # Суффикс порядкового числительного
|
||||
year_word = match.group(4) # Слово "год", "году" и т.д. (опционально)
|
||||
prep = match.group(1)
|
||||
year_str = match.group(2)
|
||||
suffix = match.group(3)
|
||||
year_word = match.group(4)
|
||||
|
||||
case = None
|
||||
gender = None
|
||||
@@ -191,7 +173,9 @@ def numbers_to_words(text: str) -> str:
|
||||
if not gender:
|
||||
gender = "m"
|
||||
|
||||
words = convert_number(year_str, context_type="ordinal", case=case, gender=gender)
|
||||
words = convert_number(
|
||||
year_str, context_type="ordinal", case=case, gender=gender
|
||||
)
|
||||
|
||||
prefix = f"{prep} " if prep else ""
|
||||
if year_word:
|
||||
@@ -206,25 +190,23 @@ def numbers_to_words(text: str) -> str:
|
||||
text,
|
||||
)
|
||||
|
||||
# 1. Обработка годов: "в 1999 году", "2024 год"
|
||||
# Года
|
||||
def replace_year_match(match):
|
||||
prep = match.group(1) # Предлог (в, с, к...)
|
||||
year_str = match.group(2) # Само число
|
||||
year_word = match.group(3) # Слово "год", "году" и т.д.
|
||||
prep = match.group(1)
|
||||
year_str = match.group(2)
|
||||
year_word = match.group(3)
|
||||
|
||||
# Определяем падеж слова "год" через pymorphy
|
||||
# Падеж
|
||||
parsed = morph.parse(year_word)[0]
|
||||
case_tag = parsed.tag.case
|
||||
|
||||
nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
|
||||
|
||||
# FIX: Pymorphy часто определяет "год" как accs (винительный), что для num2words
|
||||
# превращается в родительный (для одушевленных?), давая "2024 года".
|
||||
# Если предлога нет, принудительно ставим именительный.
|
||||
# Без предлога - именительный
|
||||
if not prep and year_word.lower().startswith("год"):
|
||||
nw_case = "nominative"
|
||||
|
||||
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
|
||||
# Конвертируем
|
||||
words = convert_number(
|
||||
year_str, context_type="ordinal", case=nw_case, gender="m"
|
||||
)
|
||||
@@ -239,7 +221,7 @@ def numbers_to_words(text: str) -> str:
|
||||
text,
|
||||
)
|
||||
|
||||
# 2. Обработка дат: "25 июня", "с 1 мая"
|
||||
# Даты
|
||||
month_regex = "|".join(MONTHS_GENITIVE)
|
||||
|
||||
def replace_date_match(match):
|
||||
@@ -247,7 +229,7 @@ def numbers_to_words(text: str) -> str:
|
||||
day_str = match.group(2)
|
||||
month_word = match.group(3)
|
||||
|
||||
# По умолчанию родительный падеж ("двадцать пятого июня")
|
||||
# По умолчанию родительный
|
||||
case = "genitive"
|
||||
|
||||
if prep:
|
||||
@@ -266,7 +248,7 @@ def numbers_to_words(text: str) -> str:
|
||||
if morph_case:
|
||||
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive")
|
||||
|
||||
# Используем средний род ('n') для дат (число - средний род: пятое, пятого)
|
||||
# Средний род для дат
|
||||
words = convert_number(day_str, context_type="ordinal", case=case, gender="n")
|
||||
|
||||
prefix = f"{prep} " if prep else ""
|
||||
@@ -279,7 +261,7 @@ def numbers_to_words(text: str) -> str:
|
||||
text,
|
||||
)
|
||||
|
||||
# 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
|
||||
# Остальные числа
|
||||
def replace_cardinal_match(match):
|
||||
prep = match.group(1)
|
||||
num_str = match.group(2)
|
||||
@@ -294,7 +276,7 @@ def numbers_to_words(text: str) -> str:
|
||||
if morph_case:
|
||||
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
|
||||
|
||||
# Если есть следующее слово, проверяем его род (для "2 минуты" -> "две")
|
||||
# Проверяем род
|
||||
if next_word:
|
||||
word_clean = next_word.strip()
|
||||
parsed = morph.parse(word_clean)[0]
|
||||
@@ -302,9 +284,7 @@ def numbers_to_words(text: str) -> str:
|
||||
morph_gender = parsed.tag.gender
|
||||
gender = PYMORPHY_TO_GENDER.get(morph_gender, "m")
|
||||
|
||||
# Спец-случай: "на 1 час" -> "на один час" (не "одного")
|
||||
# Для неодушевленных муж./ср. рода в винительном падеже
|
||||
# числительные должны совпадать с именительным.
|
||||
# Спец-случай: "на 1 час"
|
||||
if (
|
||||
prep_clean == "на"
|
||||
and parsed.normal_form in TIME_UNIT_LEMMAS
|
||||
@@ -316,7 +296,7 @@ def numbers_to_words(text: str) -> str:
|
||||
num_str, context_type="cardinal", case=case, gender=gender
|
||||
)
|
||||
|
||||
# Если конвертация вернула пустую строку (сбой?), возвращаем цифры
|
||||
# Если конвертация не удалась - возвращаем цифры
|
||||
if not words:
|
||||
words = num_str
|
||||
|
||||
@@ -336,11 +316,7 @@ def numbers_to_words(text: str) -> str:
|
||||
|
||||
|
||||
def roman_numerals_to_words(text: str) -> str:
|
||||
"""
|
||||
Преобразует римские цифры в порядковые числительные с учетом
|
||||
морфологии предыдущего слова.
|
||||
Пример: "Ивана III" -> "Ивана третьего".
|
||||
"""
|
||||
"""Римские в слова."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
@@ -380,63 +356,53 @@ def roman_numerals_to_words(text: str) -> str:
|
||||
|
||||
|
||||
def clean_response(text: str, language: str = "ru") -> str:
|
||||
"""
|
||||
Основная функция очистки.
|
||||
Убирает Markdown, ссылки, мусор и преобразует числа.
|
||||
|
||||
Args:
|
||||
text: Сырой текст от AI.
|
||||
language: Язык (для конвертации чисел, работает только для ru).
|
||||
"""
|
||||
"""Очистка текста для TTS."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Удаление ссылок на источники [1], [citation needed]
|
||||
# Удаление ссылок
|
||||
text = re.sub(r"\x5B\d+\x5D", "", text)
|
||||
text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\x5Bsource\x5D", "", text, flags=re.IGNORECASE)
|
||||
|
||||
# Удаление жирного шрифта **text** и __text__
|
||||
# Удаление жирного
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
|
||||
# Удаление курсива *text* и _text_
|
||||
# Удаление курсива
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
|
||||
|
||||
# Удаление зачеркнутого ~~text~~
|
||||
# Удаление зачеркнутого
|
||||
text = re.sub(r"~~(.+?)~~", r"\1", text)
|
||||
|
||||
# Удаление заголовков Markdown (# Header)
|
||||
# Заголовки
|
||||
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Удаление картинок  -> удаляем полностью
|
||||
# Картинки
|
||||
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text)
|
||||
|
||||
# Удаление ссылок [text](url) -> оставляем только text
|
||||
# \x5B = [, \x5D = ]
|
||||
# Ссылки
|
||||
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
|
||||
|
||||
# Удаление inline кода `code`
|
||||
# Код
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
|
||||
# Удаление блоков кода ```code```
|
||||
text = re.sub(r"```[\s\S]*?```", "", text)
|
||||
|
||||
# Удаление маркеров списков (-, *, 1.)
|
||||
# Списки
|
||||
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Удаление цитат >
|
||||
# Цитаты
|
||||
text = re.sub(r"^\s*>\s*", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Удаление горизонтальных линий ---
|
||||
# Линии
|
||||
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Удаление HTML тегов
|
||||
# HTML теги
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
|
||||
# Удаление фразы "— это, скорее всего" в корректировках произношения
|
||||
# Корректировки
|
||||
text = re.sub(
|
||||
r"([—-])\s*это,\s*скорее\s*всего\b\s*,?\s*",
|
||||
r"\1 ",
|
||||
@@ -445,7 +411,7 @@ def clean_response(text: str, language: str = "ru") -> str:
|
||||
)
|
||||
text = re.sub(r"[—-]\s*([.!?])", r"\1", text)
|
||||
|
||||
# Remove informal slang greetings at the beginning of sentences/responses
|
||||
# Удаление сленга
|
||||
text = re.sub(
|
||||
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
|
||||
"",
|
||||
@@ -453,13 +419,13 @@ def clean_response(text: str, language: str = "ru") -> str:
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Convert Roman numerals and Arabic digits to words for Russian.
|
||||
# Числа в слова
|
||||
if language == "ru":
|
||||
text = roman_numerals_to_words(text)
|
||||
if re.search(r"\d", text):
|
||||
text = numbers_to_words(text)
|
||||
|
||||
# Remove extra whitespace
|
||||
# Чистка пробелов
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
text = re.sub(r" +", " ", text)
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
Short, human-like responses for small talk.
|
||||
"""
|
||||
"""Small talk responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Timer module."""
|
||||
|
||||
# Модуль таймера.
|
||||
# Отвечает за установку таймеров (в оперативной памяти), их проверку и воспроизведение звука.
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
import json
|
||||
@@ -25,7 +22,7 @@ ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
|
||||
TIMER_FILE = BASE_DIR / "data" / "timers.json"
|
||||
ASK_TIMER_TIME_PROMPT = "На какое время мне поставить таймер?"
|
||||
|
||||
# --- Number words parsing helpers (ru) ---
|
||||
# Числа словами
|
||||
_NUMBER_UNITS = {
|
||||
"ноль": 0,
|
||||
"один": 1,
|
||||
@@ -103,13 +100,19 @@ _UNIT_LEMMAS = {
|
||||
"мин": "minutes",
|
||||
"сек": "seconds",
|
||||
}
|
||||
_UNIT_LEMMAS = {
|
||||
"час": "hours",
|
||||
"минута": "minutes",
|
||||
"секунда": "seconds",
|
||||
"мин": "minutes",
|
||||
"сек": "seconds",
|
||||
}
|
||||
_UNIT_FORMS = {
|
||||
"hours": ("час", "часа", "часов"),
|
||||
"minutes": ("минуту", "минуты", "минут"),
|
||||
"seconds": ("секунду", "секунды", "секунд"),
|
||||
}
|
||||
|
||||
# Optional ordinal formatting for list numbering.
|
||||
try:
|
||||
from num2words import num2words
|
||||
except Exception:
|
||||
@@ -251,12 +254,11 @@ def _format_ordinal_index(index: int) -> str:
|
||||
|
||||
class TimerManager:
|
||||
def __init__(self):
|
||||
# Список активных таймеров: {"end_time": datetime, "label": str}
|
||||
self.timers = []
|
||||
self.load_timers()
|
||||
|
||||
def load_timers(self):
|
||||
"""Загрузка списка таймеров из JSON файла."""
|
||||
"""Загрузка из файла."""
|
||||
if TIMER_FILE.exists():
|
||||
try:
|
||||
with open(TIMER_FILE, "r", encoding="utf-8") as f:
|
||||
@@ -277,7 +279,7 @@ class TimerManager:
|
||||
self.timers = sorted(timers, key=lambda x: x["end_time"])
|
||||
|
||||
def save_timers(self):
|
||||
"""Сохранение списка таймеров в JSON файл."""
|
||||
"""Сохранение в файл."""
|
||||
payload = [
|
||||
{"end_time": t["end_time"].isoformat(), "label": t.get("label", "")}
|
||||
for t in self.timers
|
||||
@@ -289,7 +291,7 @@ class TimerManager:
|
||||
print(f"❌ Ошибка сохранения таймеров: {e}")
|
||||
|
||||
def describe_timers(self) -> str:
|
||||
"""Возвращает текстовое описание активных таймеров."""
|
||||
"""Описание активных таймеров."""
|
||||
if not self.timers:
|
||||
return "Активных таймеров нет."
|
||||
|
||||
@@ -312,38 +314,29 @@ class TimerManager:
|
||||
return "Активные таймеры: " + "; ".join(items) + "."
|
||||
|
||||
def add_timer(self, seconds: int, label: str):
|
||||
"""Добавление нового таймера."""
|
||||
"""Добавить таймер."""
|
||||
end_time = datetime.now() + timedelta(seconds=seconds)
|
||||
self.timers.append({"end_time": end_time, "label": label})
|
||||
# Сортируем, чтобы ближайший был первым
|
||||
self.timers.sort(key=lambda x: x["end_time"])
|
||||
self.save_timers()
|
||||
print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})")
|
||||
print(f"⏳ Таймер: {label} (до {end_time.strftime('%H:%M:%S')})")
|
||||
|
||||
def cancel_all_timers(self):
|
||||
"""Отмена всех таймеров."""
|
||||
"""Отменить все таймеры."""
|
||||
count = len(self.timers)
|
||||
self.timers = []
|
||||
self.save_timers()
|
||||
print(f"🔕 Все таймеры ({count}) отменены.")
|
||||
print(f"🔕 Таймеры отменены: {count}")
|
||||
|
||||
def check_timers(self):
|
||||
"""
|
||||
Проверка: не истек ли какой-то таймер?
|
||||
Вызывается в главном цикле.
|
||||
Возвращает True, если таймер сработал (и был обработан).
|
||||
"""
|
||||
"""Проверка таймеров. Возвращает True если сработал."""
|
||||
if not self.timers:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
# Смотрим первый (самый ранний) таймер
|
||||
# Используем индекс 0, так как список отсортирован
|
||||
first_timer = self.timers[0]
|
||||
|
||||
if now >= first_timer["end_time"]:
|
||||
# Таймер сработал!
|
||||
# Удаляем его из списка
|
||||
label = first_timer["label"]
|
||||
self.timers.pop(0)
|
||||
self.save_timers()
|
||||
@@ -355,36 +348,30 @@ class TimerManager:
|
||||
return False
|
||||
|
||||
def trigger_timer(self, label: str):
|
||||
"""
|
||||
Логика срабатывания таймера.
|
||||
Запускает воспроизведение MP3 и слушает команду "Стоп".
|
||||
"""
|
||||
print(f"🔔 ТАЙМЕР НА {label} СРАБОТАЛ! (Скажите 'Стоп')")
|
||||
"""Срабатывание таймера."""
|
||||
print(f"🔔 ТАЙМЕР {label}!")
|
||||
|
||||
# Запуск плеера mpg123 в бесконечном цикле
|
||||
cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)]
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(cmd)
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123"
|
||||
)
|
||||
print("❌ mpg123 не найден. Установите: sudo apt install mpg123")
|
||||
return
|
||||
|
||||
try:
|
||||
# Цикл ожидания стоп-команды
|
||||
while True:
|
||||
text = listen(timeout_seconds=3.0, detection_timeout=3.0, fast_stop=True)
|
||||
text = listen(
|
||||
timeout_seconds=3.0, detection_timeout=3.0, fast_stop=True
|
||||
)
|
||||
if text:
|
||||
if is_stop_command(text, mode="lenient"):
|
||||
print(f"🛑 Таймер остановлен по команде: '{text}'")
|
||||
print(f"🛑 Остановлен: '{text}'")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка во время таймера: {e}")
|
||||
print(f"❌ Ошибка: {e}")
|
||||
finally:
|
||||
# Обязательно убиваем процесс плеера
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
@@ -393,10 +380,7 @@ class TimerManager:
|
||||
print("🔕 Таймер выключен.")
|
||||
|
||||
def parse_command(self, text: str) -> str | None:
|
||||
"""
|
||||
Парсинг команды установки таймера.
|
||||
Примеры: "таймер на 5 минут", "засеки 10 секунд".
|
||||
"""
|
||||
"""Парсинг команды таймера."""
|
||||
text = _normalize_timer_text(text.lower())
|
||||
|
||||
# Ключевые слова для таймера
|
||||
@@ -413,9 +397,6 @@ class TimerManager:
|
||||
return "Хорошо, все таймеры отменены."
|
||||
|
||||
# Поиск времени
|
||||
# Ищем комбинации: число + (час/мин/сек)
|
||||
# Пример: "1 час 30 минут", "5 минут", "30 секунд"
|
||||
|
||||
total_seconds = 0
|
||||
parts = []
|
||||
hours = None
|
||||
@@ -438,7 +419,7 @@ class TimerManager:
|
||||
if match_seconds:
|
||||
seconds = int(match_seconds.group(1))
|
||||
|
||||
# Дополняем числительные словами (например, "одну минуту")
|
||||
# Числа словами
|
||||
word_values = _extract_word_time_values(text)
|
||||
if hours is None and word_values["hours"] is not None:
|
||||
hours = word_values["hours"]
|
||||
@@ -456,9 +437,7 @@ class TimerManager:
|
||||
|
||||
found_time = any(value is not None for value in [hours, minutes, seconds])
|
||||
if found_time:
|
||||
total_seconds = (
|
||||
(hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
|
||||
)
|
||||
total_seconds = (hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
|
||||
if has_fractional:
|
||||
total_seconds = int(round(total_seconds))
|
||||
h = total_seconds // 3600
|
||||
@@ -481,15 +460,16 @@ class TimerManager:
|
||||
self.add_timer(total_seconds, label)
|
||||
return f"Поставил таймер на {label}."
|
||||
|
||||
# Если попросили поставить таймер, но не назвали время — задаем уточняющий вопрос.
|
||||
if re.search(r"(постав|установ|запусти|включи|засеки)", text) or text.strip() in {
|
||||
# Если время не названо — спрашиваем
|
||||
if re.search(
|
||||
r"(постав|установ|запусти|включи|засеки)", text
|
||||
) or text.strip() in {
|
||||
"таймер",
|
||||
"поставь таймер",
|
||||
}:
|
||||
return ASK_TIMER_TIME_PROMPT
|
||||
|
||||
# Если сказали "таймер", но не нашли время.
|
||||
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
|
||||
return "Я не понял, на сколько поставить таймер."
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
|
||||
185
app/main.py
185
app/main.py
@@ -1,18 +1,8 @@
|
||||
"""
|
||||
Smart Speaker - Main Application
|
||||
Голосовой ассистент с wake word detection, STT, AI и TTS.
|
||||
|
||||
Flow:
|
||||
1. Wait for wake word ("Alexandr")
|
||||
2. Listen to user speech (STT)
|
||||
3. Send query to AI (Perplexity)
|
||||
4. Clean response from markdown
|
||||
5. Speak response (TTS)
|
||||
6. Loop back to step 1
|
||||
"""
|
||||
|
||||
# Главный файл приложения (`main.py`).
|
||||
# Здесь находится основной бесконечный цикл, который связывает все компоненты воедино.
|
||||
import os
|
||||
|
||||
import os
|
||||
import queue
|
||||
@@ -32,7 +22,7 @@ except Exception as exc:
|
||||
else:
|
||||
_MIXER_IMPORT_ERROR = None
|
||||
|
||||
# Импорт наших модулей
|
||||
# Наши модули
|
||||
from .audio.sound_level import parse_volume_text, set_volume
|
||||
from .audio.stt import cleanup as cleanup_stt
|
||||
from .audio.stt import get_recognizer, listen
|
||||
@@ -174,11 +164,10 @@ _CITY_PATTERNS = [
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""
|
||||
Обработчик сигнала Ctrl+C.
|
||||
Позволяет корректно завершить работу программы, освободив ресурсы (микрофон, модели).
|
||||
"""
|
||||
"""Обработчик Ctrl+C."""
|
||||
print("\n\n👋 Завершение работы...")
|
||||
print("\n\n👋 Завершение работы...")
|
||||
try:
|
||||
cleanup_wakeword() # Остановка Porcupine
|
||||
@@ -192,13 +181,8 @@ def signal_handler(sig, frame):
|
||||
|
||||
|
||||
def parse_translation_request(text: str):
|
||||
"""
|
||||
Определяет, является ли фраза запросом на перевод.
|
||||
|
||||
Пример: "Переведи на английский привет мир"
|
||||
Возвращает словарь: {'source_lang': 'ru', 'target_lang': 'en', 'text': 'привет мир'}
|
||||
Или None, если это не запрос перевода.
|
||||
"""
|
||||
"""Проверяет, является ли фраза запросом на перевод."""
|
||||
text_lower = text.lower().strip()
|
||||
text_lower = text.lower().strip()
|
||||
# Список префиксов команд перевода и соответствующих направлений языков.
|
||||
# Важно: более длинные префиксы должны проверяться первыми (например,
|
||||
@@ -217,9 +201,8 @@ def parse_translation_request(text: str):
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Основная функция (точка входа).
|
||||
"""
|
||||
"""Точка входа."""
|
||||
print("=" * 50)
|
||||
print("=" * 50)
|
||||
print("🔊 УМНАЯ КОЛОНКА")
|
||||
print("=" * 50)
|
||||
@@ -231,7 +214,6 @@ def main():
|
||||
# Устанавливаем перехватчик Ctrl+C
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Предварительная инициализация моделей
|
||||
print("⏳ Инициализация моделей...")
|
||||
|
||||
# Инициализация звуковой системы для эффектов (опционально)
|
||||
@@ -262,70 +244,61 @@ def main():
|
||||
cities_game = get_cities_game() # Игра "Города"
|
||||
print()
|
||||
|
||||
# История чата (храним последние 10 обменов репликами для контекста)
|
||||
# История чата
|
||||
chat_history = deque(maxlen=20)
|
||||
|
||||
# Переменная для хранения последнего ответа ассистента
|
||||
# Последний ответ ассистента
|
||||
last_response = None
|
||||
|
||||
# Переменная, указывающая, нужно ли пропускать ожидание wake word
|
||||
# (True = режим диалога, слушаем сразу. False = ждем "Alexandr")
|
||||
# Режим диалога (без wake word)
|
||||
skip_wakeword = False
|
||||
|
||||
# После ответа ассистент ждет продолжение фразы 4 секунды.
|
||||
# Если речи нет, выходим из диалога и снова ждем wake word.
|
||||
followup_idle_timeout_seconds = 4.0
|
||||
|
||||
# Контекст уточнения "на какое время поставить ...".
|
||||
# Может быть: "timer", "alarm".
|
||||
# Контекст уточнения времени для таймера/будильника
|
||||
pending_time_target = None
|
||||
|
||||
# Переменная для отслеживания последней проверки здоровья STT
|
||||
# Проверка здоровья STT
|
||||
last_stt_check = time.time()
|
||||
|
||||
# БЕСКОНЕЧНЫЙ ЦИКЛ РАБОТЫ
|
||||
# ГЛАВНЫЙ ЦИКЛ
|
||||
while True:
|
||||
# Периодическая проверка здоровья STT каждые 10 минут
|
||||
if time.time() - last_stt_check > 600: # 10 минут = 600 секунд
|
||||
# Периодическая проверка STT
|
||||
if time.time() - last_stt_check > 600:
|
||||
try:
|
||||
recognizer = get_recognizer()
|
||||
if hasattr(recognizer, 'check_connection_health'):
|
||||
if hasattr(recognizer, "check_connection_health"):
|
||||
recognizer.check_connection_health()
|
||||
last_stt_check = time.time()
|
||||
except Exception as e:
|
||||
print(f"Ошибка при проверке здоровья STT: {e}")
|
||||
print(f"Ошибка при проверке STT: {e}")
|
||||
try:
|
||||
# Гарантируем, что микрофон детектора wake word освобожден
|
||||
# Освобождаем микрофон wake word
|
||||
stop_wakeword_monitoring()
|
||||
|
||||
# --- Проверка таймеров ---
|
||||
# Проверяем каждую итерацию. Если таймер сработал, он заблокирует выполнение, пока его не выключат.
|
||||
# Проверяем таймеры
|
||||
if timer_manager.check_timers():
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# --- Проверка будильников ---
|
||||
# Проверяем каждую итерацию. Если будильник сработал, он заблокирует выполнение, пока его не выключат.
|
||||
# Проверяем будильники
|
||||
if alarm_clock.check_alarms():
|
||||
# Если будильник прозвенел и был выключен пользователем, сбрасываем режим диалога
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# --- Шаг 1: Активация ---
|
||||
# Ждем wake word
|
||||
if not skip_wakeword:
|
||||
# Ожидание фразы "Alexandr". Используем таймаут 0.5 сек, чтобы чаще проверять будильники.
|
||||
detected = wait_for_wakeword(timeout=0.5)
|
||||
|
||||
# Если время вышло, а фразы не было — начинаем цикл заново (проверяем будильники)
|
||||
# Если время вышло — проверяем будильники
|
||||
if not detected:
|
||||
continue
|
||||
|
||||
# Воспроизводим звук активации
|
||||
# Звук активации
|
||||
if ding_sound:
|
||||
ding_sound.play()
|
||||
|
||||
# Фраза активации услышана:
|
||||
# до 5с ждём начало речи, после начала завершаем STT по 3с тишины.
|
||||
# Слушаем команду
|
||||
try:
|
||||
user_text = listen(timeout_seconds=5.0, fast_stop=True)
|
||||
except Exception as e:
|
||||
@@ -338,12 +311,8 @@ def main():
|
||||
print(f"Ошибка переинициализации STT: {init_error}")
|
||||
continue # Продолжаем цикл
|
||||
else:
|
||||
# Режим диалога (Follow-up): ждем продолжения речи без "Alexandr"
|
||||
print(
|
||||
"👂 Слушаю продолжение диалога "
|
||||
f"({followup_idle_timeout_seconds:.0f} сек)..."
|
||||
)
|
||||
# Ждем начала речи 4 сек. Если начали говорить, слушаем до 7 сек.
|
||||
# Follow-up режим — без wake word
|
||||
print(f"👂 Слушаю ({followup_idle_timeout_seconds:.0f} сек)...")
|
||||
try:
|
||||
user_text = listen(
|
||||
timeout_seconds=7.0,
|
||||
@@ -362,13 +331,12 @@ def main():
|
||||
continue
|
||||
|
||||
if not user_text:
|
||||
# Пользователь промолчал — выходим из режима диалога, засыпаем.
|
||||
# Молчание — возвращаемся к ожиданию
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# --- Шаг 2: Анализ распознанного текста ---
|
||||
# Анализ текста
|
||||
if not user_text:
|
||||
# Пустой ввод: без лишних ответов возвращаемся к ожиданию wake word.
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
@@ -384,13 +352,12 @@ def main():
|
||||
skip_wakeword = False
|
||||
continue
|
||||
print("_" * 50)
|
||||
print("💤 Жду 'Alexandr' для активации...")
|
||||
print("💤 Жду 'Alexandr'...")
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# Проверка на команду "Повтори" / "Еще раз"
|
||||
# Проверка на "Повтори"
|
||||
user_text_lower = user_text.lower().strip()
|
||||
# Проверяем точное совпадение или если фраза начинается с "повтори" (но не "повтори за мной")
|
||||
if user_text_lower in _REPEAT_PHRASES or (
|
||||
user_text_lower.startswith("повтори")
|
||||
and "за мной" not in user_text_lower
|
||||
@@ -400,11 +367,10 @@ def main():
|
||||
speak(last_response)
|
||||
else:
|
||||
speak("Я еще ничего не говорил.")
|
||||
# После повтора остаемся в диалоге
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Короткие ответы на small-talk ("как дела" и т.п.)
|
||||
# Small-talk
|
||||
smalltalk_response = get_smalltalk_response(user_text)
|
||||
if smalltalk_response:
|
||||
clean_smalltalk = clean_response(smalltalk_response, language="ru")
|
||||
@@ -424,7 +390,7 @@ def main():
|
||||
):
|
||||
command_text = f"будильник {command_text}"
|
||||
|
||||
# Проверка команд таймера ("поставь таймер на 6 минут")
|
||||
# Таймеры
|
||||
stopwatch_response = stopwatch_manager.parse_command(command_text)
|
||||
if stopwatch_response:
|
||||
clean_stopwatch_response = clean_response(
|
||||
@@ -435,7 +401,7 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Проверка команд таймера ("поставь таймер на 6 минут")
|
||||
# Таймер
|
||||
timer_response = timer_manager.parse_command(command_text)
|
||||
if timer_response:
|
||||
clean_timer_response = clean_response(timer_response, language="ru")
|
||||
@@ -449,7 +415,7 @@ def main():
|
||||
skip_wakeword = not completed
|
||||
continue
|
||||
|
||||
# Проверка команд будильника ("поставь будильник на 7")
|
||||
# Будильник
|
||||
alarm_response = alarm_clock.parse_command(command_text)
|
||||
if alarm_response:
|
||||
clean_alarm_response = clean_response(alarm_response, language="ru")
|
||||
@@ -461,10 +427,9 @@ def main():
|
||||
skip_wakeword = alarm_response == ASK_ALARM_TIME_PROMPT
|
||||
continue
|
||||
|
||||
# Проверка команды громкости ("громкость 5")
|
||||
# Громкость
|
||||
if user_text.lower().startswith("громкость"):
|
||||
try:
|
||||
# Убираем слово "громкость" и ищем число
|
||||
vol_str = user_text.lower().replace("громкость", "", 1).strip()
|
||||
level = parse_volume_text(vol_str)
|
||||
|
||||
@@ -489,32 +454,31 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Проверка команды "Погода"
|
||||
# Проверяем, содержит ли запрос информацию о конкретном городе
|
||||
# Погода
|
||||
requested_city = None
|
||||
user_text_lower = user_text.lower()
|
||||
|
||||
# Проверяем наличие упоминания города в запросе (например, "погода в Нью-Йорке", "какая погода в Москве")
|
||||
for pattern in _CITY_PATTERNS:
|
||||
match = pattern.search(user_text_lower)
|
||||
if match:
|
||||
potential_city = match.group(1).strip()
|
||||
# Проверяем, что это не местоимение или другое слово, а реально название города
|
||||
if (
|
||||
potential_city
|
||||
and len(potential_city) > 1
|
||||
and not any(word in potential_city for word in _CITY_INVALID_WORDS)
|
||||
and not any(
|
||||
word in potential_city for word in _CITY_INVALID_WORDS
|
||||
)
|
||||
):
|
||||
requested_city = potential_city.title() # Приводим к формату "Нью-Йорк", "Москва"
|
||||
requested_city = potential_city.title()
|
||||
break
|
||||
|
||||
# Проверяем, содержит ли запрос одну из погодных команд
|
||||
has_weather_trigger = any(
|
||||
trigger in user_text_lower for trigger in _WEATHER_TRIGGERS
|
||||
)
|
||||
|
||||
if has_weather_trigger:
|
||||
from .features.weather import get_weather_report
|
||||
|
||||
weather_report = get_weather_report(requested_city)
|
||||
clean_report = clean_response(weather_report, language="ru")
|
||||
speak(clean_report)
|
||||
@@ -522,7 +486,7 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Проверка музыкальных команд ("включи музыку", "пауза", и т.д.)
|
||||
# Музыка
|
||||
music_controller = get_music_controller()
|
||||
music_response = music_controller.parse_command(user_text)
|
||||
if music_response:
|
||||
@@ -532,14 +496,14 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Проверка запроса на перевод
|
||||
# Перевод
|
||||
translation_request = parse_translation_request(user_text)
|
||||
if translation_request:
|
||||
source_lang = translation_request["source_lang"]
|
||||
target_lang = translation_request["target_lang"]
|
||||
text_to_translate = translation_request["text"]
|
||||
|
||||
# Если сказано только "переведи на английский", спрашиваем "что перевести?"
|
||||
# Если сказано только "переведи" — спрашиваем
|
||||
if not text_to_translate:
|
||||
prompt = (
|
||||
"Скажи фразу на английском."
|
||||
@@ -547,7 +511,6 @@ def main():
|
||||
else "Скажи фразу на русском."
|
||||
)
|
||||
speak(prompt)
|
||||
# Слушаем саму фразу на нужном языке
|
||||
try:
|
||||
text_to_translate = listen(
|
||||
timeout_seconds=7.0, detection_timeout=5.0, lang=source_lang
|
||||
@@ -569,31 +532,30 @@ def main():
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# Выполняем перевод через AI
|
||||
# Перевод через AI
|
||||
translated_text = translate_text(
|
||||
text_to_translate, source_lang, target_lang
|
||||
)
|
||||
# Очищаем результат (убираем лишние символы)
|
||||
clean_text = clean_response(translated_text, language=target_lang)
|
||||
|
||||
# Сохраняем для повтора
|
||||
last_response = clean_text
|
||||
|
||||
# Озвучиваем перевод на целевом языке
|
||||
# Озвучиваем
|
||||
completed = speak(
|
||||
clean_text,
|
||||
check_interrupt=check_wakeword_once,
|
||||
language=target_lang,
|
||||
)
|
||||
stop_wakeword_monitoring()
|
||||
skip_wakeword = True # Остаемся в диалоге
|
||||
skip_wakeword = True
|
||||
|
||||
if not completed:
|
||||
print("⏹️ Перевод прерван - слушаю следующий вопрос")
|
||||
print("⏹️ Перевод прерван")
|
||||
continue
|
||||
|
||||
# Игра "Города"
|
||||
cities_response = cities_game.handle(user_text)
|
||||
cities_response = cities_game.handle(user_text)
|
||||
if cities_response:
|
||||
clean_cities_response = clean_response(cities_response, language="ru")
|
||||
speak(clean_cities_response)
|
||||
@@ -601,41 +563,36 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# --- Шаг 3: Запрос к AI (Streaming) ---
|
||||
# Добавляем сообщение пользователя в историю
|
||||
# AI запрос
|
||||
chat_history.append({"role": "user", "content": user_text})
|
||||
|
||||
# Очередь для предложений, которые нужно озвучить
|
||||
# Очередь для TTS
|
||||
tts_q = queue.Queue()
|
||||
# Флаг прерывания для worker-а
|
||||
interrupt_event = threading.Event()
|
||||
|
||||
def tts_worker():
|
||||
"""Фоновый поток, читающий предложения из очереди и озвучивающий их."""
|
||||
"""Фоновый поток для озвучки."""
|
||||
while True:
|
||||
item = tts_q.get()
|
||||
if item is None: # Poison pill (сигнал остановки)
|
||||
if item is None:
|
||||
tts_q.task_done()
|
||||
break
|
||||
|
||||
text, lang = item
|
||||
|
||||
# Если уже было прерывание, просто пропускаем (чистим очередь)
|
||||
if interrupt_event.is_set():
|
||||
tts_q.task_done()
|
||||
continue
|
||||
|
||||
# Озвучиваем
|
||||
completed = speak(
|
||||
text, check_interrupt=check_wakeword_once, language=lang
|
||||
)
|
||||
|
||||
if not completed:
|
||||
interrupt_event.set() # Сообщаем всем, что нас перебили
|
||||
interrupt_event.set()
|
||||
|
||||
tts_q.task_done()
|
||||
|
||||
# Запускаем поток озвучки
|
||||
worker_thread = threading.Thread(target=tts_worker, daemon=True)
|
||||
worker_thread.start()
|
||||
|
||||
@@ -643,13 +600,12 @@ def main():
|
||||
buffer = ""
|
||||
|
||||
try:
|
||||
# Получаем генератор потока от AI
|
||||
# Streaming от AI
|
||||
stream_generator = ask_ai_stream(list(chat_history))
|
||||
|
||||
print("🤖 AI говорит: ", end="", flush=True)
|
||||
print("🤖 AI: ", end="", flush=True)
|
||||
|
||||
for chunk in stream_generator:
|
||||
# Если в процессе генерации нас перебили (на ранних фразах), прерываем получение
|
||||
if interrupt_event.is_set():
|
||||
break
|
||||
|
||||
@@ -657,54 +613,43 @@ def main():
|
||||
full_response += chunk
|
||||
print(chunk, end="", flush=True)
|
||||
|
||||
# Проверяем на конец предложения (. ! ? + пробел или конец строки)
|
||||
# Эвристика: ищем знаки препинания, после которых идет пробел или перевод строки
|
||||
# Конец предложения
|
||||
if re.search(r"[.!?\n]+(?:\s|$)", buffer):
|
||||
# Очищаем и отправляем в очередь
|
||||
clean_chunk = clean_response(buffer, language="ru")
|
||||
if clean_chunk.strip():
|
||||
tts_q.put((clean_chunk, "ru"))
|
||||
buffer = ""
|
||||
|
||||
# Отправляем остаток (если есть)
|
||||
# Остаток
|
||||
if buffer.strip() and not interrupt_event.is_set():
|
||||
clean_chunk = clean_response(buffer, language="ru")
|
||||
if clean_chunk.strip():
|
||||
tts_q.put((clean_chunk, "ru"))
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Ошибка стриминга: {e}")
|
||||
print(f"\n❌ Ошибка: {e}")
|
||||
speak("Произошла ошибка при получении ответа.")
|
||||
|
||||
# Ждем, пока все договорится
|
||||
# Добавляем poison pill, чтобы поток завершился, когда очередь пуста
|
||||
# Ждем окончания озвучки
|
||||
tts_q.put(None)
|
||||
worker_thread.join()
|
||||
|
||||
print() # Перенос строки после вывода AI
|
||||
print()
|
||||
|
||||
# Добавляем полный ответ AI в историю
|
||||
# Сохраняем ответ
|
||||
chat_history.append({"role": "assistant", "content": full_response})
|
||||
|
||||
# Сохраняем для "повтори"
|
||||
last_response = clean_response(full_response, language="ru")
|
||||
|
||||
# После озвучки обязательно закрываем поток микрофона
|
||||
stop_wakeword_monitoring()
|
||||
|
||||
# Включаем режим диалога (следующий запрос можно говорить без имени)
|
||||
skip_wakeword = True
|
||||
|
||||
if interrupt_event.is_set():
|
||||
print("⏹️ Ответ прерван - слушаю следующий вопрос")
|
||||
# Если перебили, цикл перезапустится и skip_wakeword уже True
|
||||
print("⏹️ Ответ прерван")
|
||||
|
||||
print()
|
||||
print("-" * 30)
|
||||
print()
|
||||
|
||||
# --- Шаг 6: Конец итерации, возврат в начало цикла ---
|
||||
|
||||
except KeyboardInterrupt:
|
||||
signal_handler(None, None)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user