Say v1.0.0: голосовой ассистент Whisper + 3 LLM

This commit is contained in:
2026-05-31 15:39:33 +03:00
commit a321541578
15 changed files with 1072 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
"""
Сервисы приложения Say.
WhisperService — распознавание речи (speech-to-text).
LLMService — обращение к языковой модели (text-to-response).
"""
from .whisper import WhisperService
from .llm import LLMService
__all__ = ["WhisperService", "LLMService"]
+123
View File
@@ -0,0 +1,123 @@
"""
LLMService — обращение к языковой модели через OpenAI-совместимый API.
Поддерживает несколько профилей (разные модели/промпты) —
пользователь выбирает, к какому ЛЛМ отправить запрос.
"""
import logging
from typing import Dict, Optional
import requests
from config import LLM_PROFILES
log = logging.getLogger(__name__)
class LLMService:
"""
Сервис общения с языковой моделью.
Хранит набор профилей (model + system_prompt + температура + ...).
Метод chat() принимает id профиля и текст пользователя, возвращает ответ.
"""
def __init__(self, profiles: list = None):
"""
Args:
profiles: Список словарей с ключами:
id, name, description, url, key, model,
system_prompt, temperature, max_tokens.
По умолчанию — LLM_PROFILES из config.py.
"""
self._profiles: Dict[str, dict] = {}
for p in (profiles or LLM_PROFILES):
pid = p["id"]
self._profiles[pid] = p
log.info("LLM: зарегистрирован профиль '%s' (модель=%s)", pid, p["model"])
@property
def profile_list(self) -> list:
"""
Возвращает список профилей для фронтенда
(без ключей, только публичная информация).
"""
return [
{
"id": p["id"],
"name": p["name"],
"description": p["description"],
}
for p in self._profiles.values()
]
def get_profile(self, profile_id: str) -> Optional[dict]:
"""Возвращает полный профиль по id или None."""
return self._profiles.get(profile_id)
def chat(self, profile_id: str, user_text: str) -> str:
"""
Отправляет текст пользователя в указанную LLM и возвращает ответ.
Args:
profile_id: Идентификатор профиля (ddm, qwen, deepseek, ...).
user_text: Текст, который нужно отправить модели.
Returns:
Ответ модели (строка).
Raises:
ValueError: Если профиль не найден.
RuntimeError: При ошибке API.
"""
profile = self._profiles.get(profile_id)
if not profile:
available = ", ".join(self._profiles.keys())
raise ValueError(
f"Неизвестный LLM-профиль '{profile_id}'. Доступны: {available}"
)
if not profile["key"]:
raise RuntimeError(f"API-ключ для профиля '{profile_id}' не задан")
log.info(
"LLM [%s]: отправка запроса (модель=%s, %d символов)",
profile_id,
profile["model"],
len(user_text),
)
resp = requests.post(
profile["url"],
headers={
"Authorization": f"Bearer {profile['key']}",
"Content-Type": "application/json",
},
json={
"model": profile["model"],
"messages": [
{"role": "system", "content": profile["system_prompt"]},
{"role": "user", "content": user_text},
],
"temperature": profile["temperature"],
"max_tokens": profile["max_tokens"],
},
timeout=60,
)
if resp.status_code != 200:
log.error("LLM [%s] API error %d: %s", profile_id, resp.status_code, resp.text)
raise RuntimeError(
f"Ошибка LLM ({resp.status_code}): {resp.text[:200]}"
)
answer = resp.json()["choices"][0]["message"]["content"].strip()
log.info("LLM [%s]: получен ответ (%d символов)", profile_id, len(answer))
return answer
# Глобальный экземпляр сервиса
llm_service = LLMService()
+120
View File
@@ -0,0 +1,120 @@
"""
WhisperService — распознавание речи через OpenAI-совместимый API.
Принимает аудио-байты и MIME-тип, возвращает распознанный текст.
"""
import logging
import tempfile
from typing import Optional
import requests
from config import WHISPER_URL, WHISPER_KEY, WHISPER_MODEL
log = logging.getLogger(__name__)
class WhisperService:
"""
Сервис транскрибации аудио в текст.
Использует OpenAI-совместимый эндпоинт /v1/audio/transcriptions.
Поддерживает форматы: webm, wav, mp3, ogg, flac.
"""
def __init__(
self,
api_url: str = WHISPER_URL,
api_key: str = WHISPER_KEY,
model: str = WHISPER_MODEL,
):
"""
Args:
api_url: URL эндпоинта транскрибации.
api_key: API-ключ (Bearer-токен).
model: Название модели Whisper.
"""
self._url = api_url
self._key = api_key
self._model = model
def transcribe(
self,
audio_bytes: bytes,
mime_type: str = "audio/webm",
language: str = "ru",
) -> str:
"""
Отправляет аудио на распознавание и возвращает текст.
Args:
audio_bytes: Сырые байты аудиофайла.
mime_type: MIME-тип аудио (audio/webm, audio/wav, ...).
language: Язык распознавания (ISO 639-1).
Returns:
Распознанный текст (может быть пустой строкой).
Raises:
RuntimeError: При ошибке API или отсутствии ключа.
"""
if not self._key:
raise RuntimeError("API-ключ для Whisper не задан (WHISPER_KEY)")
# Определяем расширение файла по MIME-типу
suffix = self._mime_to_suffix(mime_type)
# Пишем аудио во временный файл (requests требует file-like object)
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(audio_bytes)
tmp.flush()
tmp.seek(0)
log.info(
"Whisper: отправка %d байт (тип=%s, модель=%s)",
len(audio_bytes),
mime_type,
self._model,
)
resp = requests.post(
self._url,
headers={"Authorization": f"Bearer {self._key}"},
files={"file": (f"audio{suffix}", tmp, mime_type)},
data={
"model": self._model,
"language": language,
},
timeout=30,
)
if resp.status_code != 200:
log.error("Whisper API error %d: %s", resp.status_code, resp.text)
raise RuntimeError(
f"Ошибка распознавания ({resp.status_code}): {resp.text[:200]}"
)
result = resp.json()
text = result.get("text", "").strip()
log.info("Whisper: распознано %d символов", len(text))
return text
@staticmethod
def _mime_to_suffix(mime_type: str) -> str:
"""Сопоставляет MIME-тип с расширением файла."""
mapping = {
"audio/webm": ".webm",
"audio/wav": ".wav",
"audio/wave": ".wav",
"audio/mp3": ".mp3",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/flac": ".flac",
}
return mapping.get(mime_type, ".webm")
# Глобальный экземпляр сервиса (переиспользуется между запросами)
whisper_service = WhisperService()