другая структура проекта + beads + александр повтори + комментарии везде + readme
This commit is contained in:
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
127
app/core/ai.py
Normal file
127
app/core/ai.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""AI module for Perplexity API integration."""
|
||||
|
||||
# Модуль общения с искусственным интеллектом (Perplexity API).
|
||||
# Обрабатывает запросы пользователя и переводы.
|
||||
|
||||
import requests
|
||||
from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL
|
||||
|
||||
|
||||
# Системный промпт (инструкция) для AI.
|
||||
# Задает личность ассистента: имя "Александр", стиль общения, краткость.
|
||||
SYSTEM_PROMPT = """Ты — Александр, умный голосовой ассистент с человеческим поведением.
|
||||
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
|
||||
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
|
||||
Отвечай кратко и по существу, на русском языке.
|
||||
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
|
||||
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
|
||||
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные."""
|
||||
|
||||
# Системный промпт для режима переводчика.
|
||||
# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод...").
|
||||
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
|
||||
Translate from {source} to {target}.
|
||||
Return only the translated text, without quotes, comments, or explanations."""
|
||||
|
||||
|
||||
def _send_request(messages, max_tokens, temperature, error_text):
|
||||
"""
|
||||
Внутренняя функция для отправки HTTP-запроса к Perplexity API.
|
||||
|
||||
Args:
|
||||
messages: Список сообщений (история чата).
|
||||
max_tokens: Максимальная длина ответа.
|
||||
temperature: "Креативность" (0.2 - строго, 1.0 - креативно).
|
||||
error_text: Текст ошибки для пользователя в случае сбоя.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": PERPLEXITY_MODEL,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30
|
||||
)
|
||||
response.raise_for_status() # Проверка на ошибки HTTP (4xx, 5xx)
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except requests.exceptions.Timeout:
|
||||
return "Извините, сервер не отвечает. Попробуйте позже."
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Ошибка API: {e}")
|
||||
return error_text
|
||||
except (KeyError, IndexError) as e:
|
||||
print(f"❌ Ошибка парсинга ответа: {e}")
|
||||
return "Не удалось обработать ответ от AI."
|
||||
|
||||
|
||||
def ask_ai(messages_history: list) -> str:
|
||||
"""
|
||||
Запрос к AI в режиме чата.
|
||||
Принимает историю переписки, добавляет SYSTEM_PROMPT и отправляет запрос.
|
||||
"""
|
||||
if not messages_history:
|
||||
return "Извините, я не расслышал вашу команду."
|
||||
|
||||
# Логирование последнего запроса
|
||||
last_user_message = "Unknown"
|
||||
for msg in reversed(messages_history):
|
||||
if msg["role"] == "user":
|
||||
last_user_message = msg["content"]
|
||||
break
|
||||
print(f"🤖 Запрос к AI: {last_user_message}")
|
||||
|
||||
# Формируем полный список сообщений с системной инструкцией в начале
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history)
|
||||
|
||||
response = _send_request(
|
||||
messages,
|
||||
max_tokens=500,
|
||||
temperature=1.0, # Высокая температура для более живого общения
|
||||
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
|
||||
)
|
||||
|
||||
if response:
|
||||
print(f"💬 Ответ AI: {response[:100]}...")
|
||||
return response
|
||||
|
||||
|
||||
def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
||||
"""
|
||||
Запрос к AI в режиме перевода.
|
||||
Использует специальный промпт для переводчика.
|
||||
"""
|
||||
if not text:
|
||||
return "Извините, я не расслышал текст для перевода."
|
||||
|
||||
lang_names = {"ru": "Russian", "en": "English"}
|
||||
source_name = lang_names.get(source_lang, source_lang)
|
||||
target_name = lang_names.get(target_lang, target_lang)
|
||||
|
||||
print(f"🌍 Перевод: {source_name} -> {target_name}: {text[:60]}...")
|
||||
|
||||
# Формируем промпт с подстановкой языков
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": TRANSLATION_SYSTEM_PROMPT.format(
|
||||
source=source_name, target=target_name
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
|
||||
response = _send_request(
|
||||
messages,
|
||||
max_tokens=400,
|
||||
temperature=0.2, # Низкая температура для точности перевода
|
||||
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
|
||||
)
|
||||
return response.strip()
|
||||
279
app/core/cleaner.py
Normal file
279
app/core/cleaner.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
import re
|
||||
import pymorphy3
|
||||
from num2words import num2words
|
||||
|
||||
# Инициализация морфологического анализатора (для определения падежей)
|
||||
morph = pymorphy3.MorphAnalyzer()
|
||||
|
||||
# Карта предлогов и падежей.
|
||||
# Помогает понять, в какой падеж ставить число после предлога.
|
||||
PREPOSITION_CASES = {
|
||||
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
|
||||
"во": "loct",
|
||||
"на": "accs", # На какое число? (Винительный) - для дат.
|
||||
"о": "loct",
|
||||
"об": "loct",
|
||||
"обо": "loct",
|
||||
"при": "loct",
|
||||
"у": "gent", # У кого/чего? (Родительный)
|
||||
"от": "gent",
|
||||
"до": "gent",
|
||||
"из": "gent",
|
||||
"с": "gent", # Или Творительный. Но чаще Родительный (с 5 числа).
|
||||
"со": "gent",
|
||||
"без": "gent",
|
||||
"для": "gent",
|
||||
"вокруг": "gent",
|
||||
"после": "gent",
|
||||
"к": "datv", # К кому/чему? (Дательный)
|
||||
"ко": "datv",
|
||||
"по": "datv",
|
||||
"над": "ablt", # Над кем/чем? (Творительный)
|
||||
"под": "ablt",
|
||||
"перед": "ablt",
|
||||
"за": "ablt",
|
||||
"между": "ablt",
|
||||
}
|
||||
|
||||
# Соответствие падежей pymorphy и библиотеки num2words
|
||||
PYMORPHY_TO_NUM2WORDS = {
|
||||
"nomn": "nominative",
|
||||
"gent": "genitive",
|
||||
"datv": "dative",
|
||||
"accs": "accusative",
|
||||
"ablt": "instrumental",
|
||||
"loct": "prepositional",
|
||||
"voct": "nominative",
|
||||
"gen2": "genitive",
|
||||
"acc2": "accusative",
|
||||
"loc2": "prepositional",
|
||||
}
|
||||
|
||||
# Названия месяцев в родительном падеже (для поиска дат в тексте)
|
||||
MONTHS_GENITIVE = [
|
||||
"января",
|
||||
"февраля",
|
||||
"марта",
|
||||
"апреля",
|
||||
"мая",
|
||||
"июня",
|
||||
"июля",
|
||||
"августа",
|
||||
"сентября",
|
||||
"октября",
|
||||
"ноября",
|
||||
"декабря",
|
||||
]
|
||||
|
||||
|
||||
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:
|
||||
num_val = int(number_str)
|
||||
|
||||
return num2words(num_val, lang="ru", to=context_type, case=case, gender=gender)
|
||||
except Exception as e:
|
||||
print(f"Error converting number {number_str}: {e}")
|
||||
return number_str
|
||||
|
||||
|
||||
def numbers_to_words(text: str) -> str:
|
||||
"""
|
||||
Интеллектуальная замена цифр на слова с учетом контекста (даты, года, падежи).
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. Обработка годов: "в 1999 году", "2024 год"
|
||||
def replace_year_match(match):
|
||||
full_str = match.group(0)
|
||||
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")
|
||||
|
||||
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
|
||||
words = convert_number(
|
||||
year_str, context_type="ordinal", case=nw_case, gender="m"
|
||||
)
|
||||
|
||||
prefix = f"{prep} " if prep else ""
|
||||
return f"{prefix}{words} {year_word}"
|
||||
|
||||
# Регулярка для годов
|
||||
text = re.sub(
|
||||
r"(?i)\b((?:в|с|к|до|от)\s+)?(\d{3,4})\s+(год[а-я]*)\b",
|
||||
replace_year_match,
|
||||
text,
|
||||
)
|
||||
|
||||
# 2. Обработка дат: "25 июня", "с 1 мая"
|
||||
month_regex = "|".join(MONTHS_GENITIVE)
|
||||
|
||||
def replace_date_match(match):
|
||||
prep = match.group(1)
|
||||
day_str = match.group(2)
|
||||
month_word = match.group(3)
|
||||
|
||||
# По умолчанию родительный падеж ("двадцать пятого июня")
|
||||
case = "genitive"
|
||||
|
||||
if prep:
|
||||
prep_clean = prep.strip().lower()
|
||||
# Специфичные правила для дат
|
||||
if prep_clean == "на":
|
||||
case = "accusative" # на пятое мая
|
||||
elif prep_clean == "по":
|
||||
case = "accusative" # по пятое
|
||||
elif prep_clean == "к":
|
||||
case = "dative" # к пятому
|
||||
elif prep_clean in ["с", "до", "от"]:
|
||||
case = "genitive" # с пятого
|
||||
else:
|
||||
morph_case = get_case_from_preposition(prep_clean)
|
||||
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 ""
|
||||
return f"{prefix}{words} {month_word}"
|
||||
|
||||
# Конкатенация regex для месяцев (ВАЖНО: month_regex должен быть вставлен в строку)
|
||||
text = re.sub(
|
||||
r"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{1,2})\s+({month_regex})\b",
|
||||
replace_date_match,
|
||||
text,
|
||||
)
|
||||
|
||||
# 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
|
||||
def replace_cardinal_match(match):
|
||||
prep = match.group(1)
|
||||
num_str = match.group(2)
|
||||
|
||||
case = "nominative"
|
||||
if prep:
|
||||
morph_case = get_case_from_preposition(prep.strip())
|
||||
if morph_case:
|
||||
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
|
||||
|
||||
words = convert_number(num_str, context_type="cardinal", case=case)
|
||||
|
||||
prefix = f"{prep} " if prep else ""
|
||||
return f"{prefix}{words}"
|
||||
|
||||
text = re.sub(
|
||||
r"(?i)\b((?:в|на|о|об|обо|при|у|от|до|из|с|со|без|для|вокруг|после|к|ко|по|над|под|перед|за|между)\s+)?(\d+(?:[.,]\d+)?)\b",
|
||||
replace_cardinal_match,
|
||||
text,
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def clean_response(text: str, language: str = "ru") -> str:
|
||||
"""
|
||||
Основная функция очистки.
|
||||
Убирает Markdown, ссылки, мусор и преобразует числа.
|
||||
|
||||
Args:
|
||||
text: Сырой текст от AI.
|
||||
language: Язык (для конвертации чисел, работает только для ru).
|
||||
"""
|
||||
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](url) -> оставляем только text
|
||||
# \x5B = [, \x5D = ]
|
||||
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
|
||||
|
||||
# Удаление картинок  -> удаляем полностью
|
||||
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", 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 тегов
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
|
||||
# Remove informal slang greetings at the beginning of sentences/responses
|
||||
text = re.sub(
|
||||
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
|
||||
"",
|
||||
text,
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Convert numbers to words only for Russian, and only if digits exist
|
||||
if language == "ru" and 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)
|
||||
|
||||
return text.strip()
|
||||
58
app/core/config.py
Normal file
58
app/core/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Configuration module for smart speaker.
|
||||
Loads environment variables from .env file.
|
||||
"""
|
||||
|
||||
# Этот модуль отвечает за конфигурацию всего проекта.
|
||||
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Базовая директория проекта (корневая папка, где лежит .env)
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Загружаем переменные из файла .env в корневом каталоге
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
# --- Настройки AI (Perplexity) ---
|
||||
# API ключ для доступа к нейросети
|
||||
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY")
|
||||
# Модель, которую будем использовать (по умолчанию llama-3.1-sonar-small-128k-chat)
|
||||
PERPLEXITY_MODEL = os.getenv("PERPLEXITY_MODEL", "llama-3.1-sonar-small-128k-chat")
|
||||
PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"
|
||||
|
||||
# --- Настройки распознавания речи (Deepgram) ---
|
||||
# Ключ для облачного STT (Speech-to-Text)
|
||||
DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
|
||||
|
||||
# --- Настройки активации голосом (Porcupine) ---
|
||||
# Ключ доступа PicoVoice
|
||||
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY")
|
||||
# Путь к файлу модели ключевого слова (.ppn), который лежит в папке assets/models
|
||||
PORCUPINE_KEYWORD_PATH = BASE_DIR / "assets" / "models" / "Alexandr_en_linux_v4_0_0.ppn"
|
||||
|
||||
# --- Настройки локального распознавания (Vosk) ---
|
||||
# Используется для стоп-команд и будильника, когда не нужен интернет
|
||||
VOSK_MODEL_PATH = BASE_DIR / "assets" / "models" / "vosk-model-ru-0.42"
|
||||
|
||||
# --- Параметры аудио ---
|
||||
# Частота дискретизации для микрофона (стандарт для распознавания речи)
|
||||
SAMPLE_RATE = 16000
|
||||
CHANNELS = 1
|
||||
|
||||
# --- Настройка времени ---
|
||||
# Устанавливаем часовой пояс на Москву, чтобы будильник работал корректно
|
||||
import time
|
||||
|
||||
os.environ["TZ"] = "Europe/Moscow"
|
||||
time.tzset()
|
||||
|
||||
# --- Настройки синтеза речи (TTS) ---
|
||||
# Голос для русского языка (eugene - мужской голос)
|
||||
TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene
|
||||
# Голос для английского языка
|
||||
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
|
||||
# Частота дискретизации для воспроизведения (качество звука)
|
||||
TTS_SAMPLE_RATE = 48000
|
||||
Reference in New Issue
Block a user