Node.js rewrite: Express + микросервисная архитектура
This commit is contained in:
+1
-6
@@ -1,6 +1 @@
|
|||||||
__pycache__/
|
node_modules/
|
||||||
*.pyc
|
|
||||||
.env
|
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
*.egg-info/
|
|
||||||
|
|||||||
+5
-10
@@ -1,16 +1,11 @@
|
|||||||
FROM python:3.12-slim
|
FROM node:23-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Зависимости
|
COPY package.json .
|
||||||
COPY requirements.txt .
|
RUN npm install --production
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Код
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Порт
|
EXPOSE 3000
|
||||||
EXPOSE 5000
|
CMD ["node", "src/index.js"]
|
||||||
|
|
||||||
# Запуск
|
|
||||||
CMD ["python", "app.py"]
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"""
|
|
||||||
Say — голосовой ассистент с Whisper + LLM.
|
|
||||||
|
|
||||||
Точка входа. Создаёт Flask-приложение, регистрирует Blueprints,
|
|
||||||
настраивает логирование и запускает сервер.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
from config import FLASK_HOST, FLASK_PORT, LOG_LEVEL
|
|
||||||
from routes import pages_bp, api_bp
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
|
||||||
"""
|
|
||||||
Фабрика Flask-приложения.
|
|
||||||
|
|
||||||
Собирает приложение из Blueprint'ов:
|
|
||||||
- pages_bp — HTML-страницы (/, /health)
|
|
||||||
- api_bp — REST API (/api/*)
|
|
||||||
"""
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
# --- Blueprints ---
|
|
||||||
app.register_blueprint(pages_bp)
|
|
||||||
app.register_blueprint(api_bp)
|
|
||||||
|
|
||||||
# --- Логирование ---
|
|
||||||
logging.basicConfig(
|
|
||||||
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
||||||
stream=sys.stdout,
|
|
||||||
)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Запуск
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
application = create_app()
|
|
||||||
logging.info("Say server starting on %s:%s", FLASK_HOST, FLASK_PORT)
|
|
||||||
application.run(
|
|
||||||
host=FLASK_HOST,
|
|
||||||
port=FLASK_PORT,
|
|
||||||
debug=False,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# WSGI-сервер (gunicorn) импортирует `application`
|
|
||||||
application = create_app()
|
|
||||||
@@ -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")
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "say",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Say — голосовой ассистент Whisper + LLM",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-fetch": "^2.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
flask>=3.0
|
|
||||||
requests>=2.31
|
|
||||||
gunicorn>=21.2
|
|
||||||
@@ -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"]
|
|
||||||
-123
@@ -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()
|
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Express-приложение Say.
|
||||||
|
* Собирает middleware, роуты, отдаёт статику и шаблоны.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const pagesRouter = require('./routes/pages');
|
||||||
|
const apiRouter = require('./routes/api');
|
||||||
|
const errorHandler = require('./middleware/errorHandler');
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// --- Статика (CSS, лого, favicon) ---
|
||||||
|
app.use('/static', express.static(path.join(__dirname, '..', 'static')));
|
||||||
|
|
||||||
|
// --- Роуты ---
|
||||||
|
app.use('/', pagesRouter);
|
||||||
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
|
// --- Обработка ошибок ---
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createApp;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Централизованная конфигурация приложения Say.
|
||||||
|
* Все секреты — из переменных окружения, без хардкода.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT, 10) || 3000;
|
||||||
|
|
||||||
|
// --- API (единый эндпоинт aillm.ru) ---
|
||||||
|
const API_BASE = process.env.API_BASE || 'https://api.aillm.ru';
|
||||||
|
const API_KEY = process.env.API_KEY || '';
|
||||||
|
|
||||||
|
// --- Whisper ---
|
||||||
|
const WHISPER_URL = process.env.WHISPER_URL || `${API_BASE}/v1/audio/transcriptions`;
|
||||||
|
const WHISPER_MODEL = process.env.WHISPER_MODEL || 'whisper-large-v3';
|
||||||
|
|
||||||
|
// --- Три LLM-профиля ---
|
||||||
|
const LLM_PROFILES = [
|
||||||
|
{
|
||||||
|
id: 'ddm',
|
||||||
|
name: 'DDM-120',
|
||||||
|
description: 'Быстрая модель, общие вопросы',
|
||||||
|
url: process.env.LLM_URL_DDM || `${API_BASE}/v1/chat/completions`,
|
||||||
|
model: process.env.LLM_MODEL_DDM || 'ddm-120',
|
||||||
|
systemPrompt: process.env.LLM_SYSTEM_DDM || 'Ты — полезный ассистент. Отвечай кратко и по делу на том же языке, что и запрос.',
|
||||||
|
temperature: parseFloat(process.env.LLM_TEMP_DDM || '0.7'),
|
||||||
|
maxTokens: parseInt(process.env.LLM_TOKENS_DDM, 10) || 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qwen',
|
||||||
|
name: 'Qwen',
|
||||||
|
description: 'Мощная модель, сложные задачи',
|
||||||
|
url: process.env.LLM_URL_QWEN || `${API_BASE}/v1/chat/completions`,
|
||||||
|
model: process.env.LLM_MODEL_QWEN || 'qwen-plus',
|
||||||
|
systemPrompt: process.env.LLM_SYSTEM_QWEN || 'Ты — эксперт-аналитик. Отвечай развернуто, структурированно, на языке запроса.',
|
||||||
|
temperature: parseFloat(process.env.LLM_TEMP_QWEN || '0.7'),
|
||||||
|
maxTokens: parseInt(process.env.LLM_TOKENS_QWEN, 10) || 2048,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deepseek',
|
||||||
|
name: 'DeepSeek',
|
||||||
|
description: 'Глубокая аналитика, код, рассуждения',
|
||||||
|
url: process.env.LLM_URL_DEEPSEEK || `${API_BASE}/v1/chat/completions`,
|
||||||
|
model: process.env.LLM_MODEL_DEEPSEEK || 'deepseek-chat',
|
||||||
|
systemPrompt: process.env.LLM_SYSTEM_DEEPSEEK || 'Ты — глубокий аналитик и программист. Думай step-by-step, отвечай на языке запроса.',
|
||||||
|
temperature: parseFloat(process.env.LLM_TEMP_DEEPSEEK || '0.7'),
|
||||||
|
maxTokens: parseInt(process.env.LLM_TOKENS_DEEPSEEK, 10) || 4096,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PORT, API_BASE, API_KEY,
|
||||||
|
WHISPER_URL, WHISPER_MODEL,
|
||||||
|
LLM_PROFILES,
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Точка входа. Запускает HTTP-сервер.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const createApp = require('./app');
|
||||||
|
const { PORT } = require('./config');
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Say server listening on :${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Централизованный обработчик ошибок Express.
|
||||||
|
*
|
||||||
|
* Ловит все ошибки, проброшенные через next(err),
|
||||||
|
* возвращает JSON с кодом и сообщением.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function errorHandler(err, _req, res, _next) {
|
||||||
|
console.error('ERROR:', err.message || err);
|
||||||
|
|
||||||
|
// Определяем HTTP-статус
|
||||||
|
const status = err.status || err.statusCode || 500;
|
||||||
|
|
||||||
|
res.status(status).json({
|
||||||
|
error: err.message || 'Внутренняя ошибка сервера',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = errorHandler;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* API-роуты.
|
||||||
|
*
|
||||||
|
* GET /api/profiles — список LLM-профилей.
|
||||||
|
* POST /api/speech/:llmId — аудио → Whisper → LLM → ответ.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { Router } = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
|
||||||
|
const { transcribe } = require('../services/whisper');
|
||||||
|
const { getPublicProfiles, chat } = require('../services/llm');
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Загрузка аудио в память (файлы маленькие — webm с микрофона)
|
||||||
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/profiles
|
||||||
|
* Возвращает публичную информацию о доступных LLM.
|
||||||
|
*/
|
||||||
|
router.get('/profiles', (_req, res) => {
|
||||||
|
res.json(getPublicProfiles());
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/speech/:llmId
|
||||||
|
*
|
||||||
|
* Принимает multipart/form-data с полем "audio".
|
||||||
|
* 1. Whisper → текст.
|
||||||
|
* 2. LLM (по profileId) → ответ.
|
||||||
|
* 3. JSON: { transcription, response, llm: { id, name } }
|
||||||
|
*/
|
||||||
|
router.post('/speech/:llmId', upload.single('audio'), async (req, res, next) => {
|
||||||
|
const { llmId } = req.params;
|
||||||
|
|
||||||
|
// --- Валидация ---
|
||||||
|
if (!req.file || !req.file.buffer || req.file.size === 0) {
|
||||||
|
return res.status(400).json({ error: 'Аудиофайл пуст или отсутствует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = req.file.buffer;
|
||||||
|
const mimeType = req.file.mimetype || 'audio/webm';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Шаг 1: Whisper
|
||||||
|
const transcription = await transcribe(audioBuffer, mimeType);
|
||||||
|
|
||||||
|
if (!transcription) {
|
||||||
|
return res.status(400).json({ error: 'Не удалось распознать речь', transcription: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 2: LLM
|
||||||
|
const answer = await chat(llmId, transcription);
|
||||||
|
|
||||||
|
// Шаг 3: Ответ
|
||||||
|
const { getPublicProfiles } = require('../services/llm');
|
||||||
|
const profile = getPublicProfiles().find(p => p.id === llmId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
transcription,
|
||||||
|
response: answer,
|
||||||
|
llm: {
|
||||||
|
id: profile ? profile.id : llmId,
|
||||||
|
name: profile ? profile.name : llmId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Роуты HTML-страниц.
|
||||||
|
* GET / — главная страница (интерфейс с микрофоном).
|
||||||
|
* GET /health — проверка живости.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { Router } = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', '..', 'templates', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* LLMService — обращение к языковой модели.
|
||||||
|
*
|
||||||
|
* Поддерживает несколько профилей (разные модели/промпты).
|
||||||
|
* Пользователь выбирает профиль по id, сюда приходит текст — возвращается ответ.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
const { LLM_PROFILES, API_KEY } = require('../config');
|
||||||
|
|
||||||
|
/** @type {Map<string, object>} */
|
||||||
|
const profiles = new Map();
|
||||||
|
for (const p of LLM_PROFILES) {
|
||||||
|
profiles.set(p.id, p);
|
||||||
|
console.log(`LLM: зарегистрирован профиль '${p.id}' (${p.model})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичный список профилей для фронтенда (без чувствительных полей).
|
||||||
|
*/
|
||||||
|
function getPublicProfiles() {
|
||||||
|
return LLM_PROFILES.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} profileId — id профиля (ddm, qwen, deepseek)
|
||||||
|
* @param {string} userText — текст пользователя
|
||||||
|
* @returns {Promise<string>} — ответ модели
|
||||||
|
*/
|
||||||
|
async function chat(profileId, userText) {
|
||||||
|
const profile = profiles.get(profileId);
|
||||||
|
if (!profile) {
|
||||||
|
const available = [...profiles.keys()].join(', ');
|
||||||
|
throw new Error(`Неизвестный LLM-профиль '${profileId}'. Доступны: ${available}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = API_KEY;
|
||||||
|
if (!key) throw new Error('API_KEY не задан в переменных окружения');
|
||||||
|
|
||||||
|
console.log(`LLM [${profileId}]: отправка (${userText.length} символов)`);
|
||||||
|
|
||||||
|
const resp = await fetch(profile.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: profile.model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: profile.systemPrompt },
|
||||||
|
{ role: 'user', content: userText },
|
||||||
|
],
|
||||||
|
temperature: profile.temperature,
|
||||||
|
max_tokens: profile.maxTokens,
|
||||||
|
}),
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`LLM ошибка ${resp.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
const answer = data.choices[0].message.content.trim();
|
||||||
|
|
||||||
|
console.log(`LLM [${profileId}]: ответ (${answer.length} символов)`);
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPublicProfiles, chat };
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* WhisperService — распознавание речи (speech-to-text).
|
||||||
|
*
|
||||||
|
* Принимает буфер аудио + MIME-тип,
|
||||||
|
* отправляет на OpenAI-совместимый /v1/audio/transcriptions,
|
||||||
|
* возвращает распознанный текст.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
|
||||||
|
const { WHISPER_URL, WHISPER_MODEL, API_KEY } = require('../config');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer} audioBuffer — сырые байты аудио
|
||||||
|
* @param {string} mimeType — MIME-тип (audio/webm, audio/wav, ...)
|
||||||
|
* @param {string} language — язык распознавания (ISO 639-1)
|
||||||
|
* @returns {Promise<string>} — распознанный текст
|
||||||
|
*/
|
||||||
|
async function transcribe(audioBuffer, mimeType = 'audio/webm', language = 'ru') {
|
||||||
|
if (!API_KEY) throw new Error('API_KEY не задан в переменных окружения');
|
||||||
|
|
||||||
|
const suffix = mimeToExt(mimeType);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', audioBuffer, {
|
||||||
|
filename: `audio${suffix}`,
|
||||||
|
contentType: mimeType,
|
||||||
|
});
|
||||||
|
form.append('model', WHISPER_MODEL);
|
||||||
|
form.append('language', language);
|
||||||
|
|
||||||
|
console.log(`Whisper: отправка ${audioBuffer.length} байт (${mimeType})`);
|
||||||
|
|
||||||
|
const resp = await fetch(WHISPER_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${API_KEY}`,
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`Whisper ошибка ${resp.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
const result = (data.text || '').trim();
|
||||||
|
|
||||||
|
console.log(`Whisper: распознано ${result.length} символов`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MIME → расширение файла */
|
||||||
|
function mimeToExt(mime) {
|
||||||
|
const map = {
|
||||||
|
'audio/webm': '.webm',
|
||||||
|
'audio/wav': '.wav',
|
||||||
|
'audio/wave': '.wav',
|
||||||
|
'audio/mp3': '.mp3',
|
||||||
|
'audio/mpeg': '.mp3',
|
||||||
|
'audio/ogg': '.ogg',
|
||||||
|
'audio/flac': '.flac',
|
||||||
|
};
|
||||||
|
return map[mime] || '.webm';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { transcribe };
|
||||||
Reference in New Issue
Block a user