fix: удалил мёртвые файлы config, routes, services
This commit is contained in:
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
Централизованная конфигурация приложения Say.
|
||||
|
||||
Все настройки читаются из переменных окружения с разумными значениями по умолчанию.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# =============================================================================
|
||||
# API — единый эндпоинт (aillm.ru)
|
||||
# =============================================================================
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "https://api.aillm.ru")
|
||||
API_KEY = os.environ.get("API_KEY", "") # ⚠️ Без ключа — только через env!
|
||||
|
||||
# =============================================================================
|
||||
# Whisper (распознавание речи)
|
||||
# =============================================================================
|
||||
|
||||
WHISPER_URL = os.environ.get(
|
||||
"WHISPER_URL",
|
||||
f"{API_BASE}/v1/audio/transcriptions",
|
||||
)
|
||||
WHISPER_KEY = os.environ.get("WHISPER_KEY", API_KEY)
|
||||
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "whisper-large-v3")
|
||||
|
||||
# =============================================================================
|
||||
# Три LLM-профиля — каждый со своим именем, моделью и системным промптом
|
||||
# =============================================================================
|
||||
|
||||
LLM_PROFILES = [
|
||||
{
|
||||
"id": "ddm",
|
||||
"name": "DDM-120",
|
||||
"description": "Быстрая модель, общие вопросы",
|
||||
"url": os.environ.get("LLM_URL_DDM", f"{API_BASE}/v1/chat/completions"),
|
||||
"key": os.environ.get("LLM_KEY_DDM", API_KEY),
|
||||
"model": os.environ.get("LLM_MODEL_DDM", "ddm-120"),
|
||||
"system_prompt": os.environ.get(
|
||||
"LLM_SYSTEM_DDM",
|
||||
"Ты — полезный ассистент. Отвечай кратко и по делу на том же языке, что и запрос.",
|
||||
),
|
||||
"temperature": float(os.environ.get("LLM_TEMP_DDM", "0.7")),
|
||||
"max_tokens": int(os.environ.get("LLM_TOKENS_DDM", "1024")),
|
||||
},
|
||||
{
|
||||
"id": "qwen",
|
||||
"name": "Qwen",
|
||||
"description": "Мощная модель, сложные задачи",
|
||||
"url": os.environ.get("LLM_URL_QWEN", f"{API_BASE}/v1/chat/completions"),
|
||||
"key": os.environ.get("LLM_KEY_QWEN", API_KEY),
|
||||
"model": os.environ.get("LLM_MODEL_QWEN", "qwen-plus"),
|
||||
"system_prompt": os.environ.get(
|
||||
"LLM_SYSTEM_QWEN",
|
||||
"Ты — эксперт-аналитик. Отвечай развернуто, структурированно, на языке запроса.",
|
||||
),
|
||||
"temperature": float(os.environ.get("LLM_TEMP_QWEN", "0.7")),
|
||||
"max_tokens": int(os.environ.get("LLM_TOKENS_QWEN", "2048")),
|
||||
},
|
||||
{
|
||||
"id": "deepseek",
|
||||
"name": "DeepSeek",
|
||||
"description": "Глубокая аналитика, код, рассуждения",
|
||||
"url": os.environ.get("LLM_URL_DEEPSEEK", f"{API_BASE}/v1/chat/completions"),
|
||||
"key": os.environ.get("LLM_KEY_DEEPSEEK", API_KEY),
|
||||
"model": os.environ.get("LLM_MODEL_DEEPSEEK", "deepseek-chat"),
|
||||
"system_prompt": os.environ.get(
|
||||
"LLM_SYSTEM_DEEPSEEK",
|
||||
"Ты — глубокий аналитик и программист. Думай step-by-step, отвечай на языке запроса.",
|
||||
),
|
||||
"temperature": float(os.environ.get("LLM_TEMP_DEEPSEEK", "0.7")),
|
||||
"max_tokens": int(os.environ.get("LLM_TOKENS_DEEPSEEK", "4096")),
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Flask
|
||||
# =============================================================================
|
||||
|
||||
FLASK_HOST = os.environ.get("HOST", "0.0.0.0")
|
||||
FLASK_PORT = int(os.environ.get("PORT", "5000"))
|
||||
|
||||
# =============================================================================
|
||||
# Логирование
|
||||
# =============================================================================
|
||||
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
|
||||
@@ -1,11 +0,0 @@
|
||||
"""
|
||||
Роуты приложения Say.
|
||||
|
||||
pages.py — HTML-страницы (index, health).
|
||||
api.py — REST API (список профилей, отправка речи).
|
||||
"""
|
||||
|
||||
from .pages import pages_bp
|
||||
from .api import api_bp
|
||||
|
||||
__all__ = ["pages_bp", "api_bp"]
|
||||
@@ -1,92 +0,0 @@
|
||||
"""
|
||||
API-роуты.
|
||||
|
||||
GET /api/profiles — список доступных LLM-профилей.
|
||||
POST /api/speech/<llm_id> — принять аудио, распознать, отправить в LLM, вернуть ответ.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from services.whisper import whisper_service
|
||||
from services.llm import llm_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@api_bp.route("/profiles")
|
||||
def get_profiles():
|
||||
"""Возвращает список LLM-профилей для отображения на фронтенде."""
|
||||
return jsonify(llm_service.profile_list)
|
||||
|
||||
|
||||
@api_bp.route("/speech/<llm_id>", methods=["POST"])
|
||||
def speech_to_llm(llm_id: str):
|
||||
"""
|
||||
Основной рабочий процесс:
|
||||
|
||||
1. Принимает аудиофайл из формы (поле "audio").
|
||||
2. Отправляет в Whisper → получает распознанный текст.
|
||||
3. Отправляет текст в выбранный LLM-профиль → получает ответ.
|
||||
4. Возвращает JSON: { transcription, response, llm }.
|
||||
|
||||
Args:
|
||||
llm_id: Идентификатор LLM-профиля (ddm, qwen, deepseek, ...).
|
||||
"""
|
||||
# --- Валидация входных данных ---
|
||||
if "audio" not in request.files:
|
||||
return jsonify({"error": "Поле 'audio' обязательно"}), 400
|
||||
|
||||
audio_file = request.files["audio"]
|
||||
audio_bytes = audio_file.read()
|
||||
mime_type = audio_file.mimetype or "audio/webm"
|
||||
|
||||
if len(audio_bytes) == 0:
|
||||
return jsonify({"error": "Аудиофайл пуст"}), 400
|
||||
|
||||
# --- Проверка существования LLM-профиля ---
|
||||
profile = llm_service.get_profile(llm_id)
|
||||
if not profile:
|
||||
return jsonify({
|
||||
"error": f"Неизвестный LLM: '{llm_id}'",
|
||||
"available": [p["id"] for p in llm_service.profile_list],
|
||||
}), 400
|
||||
|
||||
try:
|
||||
# Шаг 1: Whisper — распознавание речи
|
||||
log.info("API /speech/%s: получено %d байт аудио", llm_id, len(audio_bytes))
|
||||
transcription = whisper_service.transcribe(audio_bytes, mime_type)
|
||||
|
||||
if not transcription:
|
||||
return jsonify({
|
||||
"error": "Не удалось распознать речь",
|
||||
"transcription": "",
|
||||
}), 400
|
||||
|
||||
# Шаг 2: LLM — отправка распознанного текста
|
||||
llm_answer = llm_service.chat(llm_id, transcription)
|
||||
|
||||
# Шаг 3: Ответ
|
||||
return jsonify({
|
||||
"transcription": transcription,
|
||||
"response": llm_answer,
|
||||
"llm": {
|
||||
"id": profile["id"],
|
||||
"name": profile["name"],
|
||||
},
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
log.warning("API /speech/%s: bad request — %s", llm_id, e)
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
except RuntimeError as e:
|
||||
log.error("API /speech/%s: runtime error — %s", llm_id, e)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
except Exception as e:
|
||||
log.exception("API /speech/%s: unexpected error", llm_id)
|
||||
return jsonify({"error": f"Внутренняя ошибка: {e}"}), 500
|
||||
@@ -1,22 +0,0 @@
|
||||
"""
|
||||
Роуты HTML-страниц.
|
||||
|
||||
GET / — главная страница (интерфейс с микрофоном).
|
||||
GET /health — проверка живости сервера.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, jsonify
|
||||
|
||||
pages_bp = Blueprint("pages", __name__)
|
||||
|
||||
|
||||
@pages_bp.route("/")
|
||||
def index():
|
||||
"""Главная страница с тремя кнопками записи (по одной на каждый ЛЛМ)."""
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@pages_bp.route("/health")
|
||||
def health():
|
||||
"""Проверка работоспособности сервера."""
|
||||
return jsonify({"status": "ok"})
|
||||
@@ -1,11 +0,0 @@
|
||||
"""
|
||||
Сервисы приложения Say.
|
||||
|
||||
WhisperService — распознавание речи (speech-to-text).
|
||||
LLMService — обращение к языковой модели (text-to-response).
|
||||
"""
|
||||
|
||||
from .whisper import WhisperService
|
||||
from .llm import LLMService
|
||||
|
||||
__all__ = ["WhisperService", "LLMService"]
|
||||
@@ -1,123 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,120 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user