11 Commits
19 changed files with 40 additions and 829 deletions
+1 -6
View File
@@ -1,6 +1 @@
__pycache__/
*.pyc
.env
venv/
.venv/
*.egg-info/
node_modules/
-16
View File
@@ -1,16 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
# Зависимости
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Код
COPY . .
# Порт
EXPOSE 5000
# Запуск
CMD ["python", "app.py"]
+3
View File
@@ -0,0 +1,3 @@
# Say
Voice assistant: mic → Whisper STT → LLM response
-55
View File
@@ -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()
-87
View File
@@ -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")
+15
View File
@@ -0,0 +1,15 @@
{
"name": "say",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "say",
"version": "1.0.0",
"engines": {
"node": ">=16"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "say",
"version": "1.0.0",
"description": "Voice assistant: mic → Whisper STT → LLM response",
"main": "server.js",
"engines": { "node": ">=16" },
"scripts": { "start": "node server.js" },
"dependencies": {}
}

Before

Width:  |  Height:  |  Size: 1017 B

After

Width:  |  Height:  |  Size: 1017 B

-3
View File
@@ -1,3 +0,0 @@
flask>=3.0
requests>=2.31
gunicorn>=21.2
-11
View File
@@ -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"]
-92
View File
@@ -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
-22
View File
@@ -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"})
+12
View File
@@ -0,0 +1,12 @@
const http = require('http');
const PORT = process.env.PORT || 3000;
http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"status":"ok"}');
} else {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Say OK');
}
}).listen(PORT, () => console.log(':' + PORT));
-11
View File
@@ -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
View File
@@ -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()
-120
View File
@@ -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()
-283
View File
@@ -1,283 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Say — Голосовой ассистент | Nubes</title>
<link rel="icon" href="/static/favicon.png" type="image/png">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- ================================================================== -->
<!-- Header — точь-в-точь как в ipwhitelist-app -->
<!-- ================================================================== -->
<header class="topbar">
<div class="topbar-inner">
<a href="/" class="topbar-logo" title="Say — Главная">
<img src="/static/nubes-logo.svg" alt="Nubes" width="130" height="28">
</a>
<span class="topbar-sep">|</span>
<span class="topbar-title">Say — Голосовой ассистент</span>
</div>
</header>
<!-- ================================================================== -->
<!-- Контент -->
<!-- ================================================================== -->
<div class="page">
<!-- Карточка: три LLM на выбор -->
<div class="card">
<div class="card-header">🤖 Выберите модель — нажмите и говорите</div>
<div class="card-body" id="llmCards">
<!-- Заполняется JS после загрузки профилей с /api/profiles -->
<div class="loading">Загрузка моделей...</div>
</div>
</div>
<!-- Карточка: результат -->
<div class="card" id="resultCard" style="display:none">
<div class="card-header">📋 Результат</div>
<div class="card-body">
<div class="result-section">
<div class="result-label">📝 Распознано (Whisper)</div>
<div class="result-text" id="transcriptionText"></div>
</div>
<div class="result-section">
<div class="result-label" id="responseLabel">🤖 Ответ LLM</div>
<div class="result-text" id="responseText"></div>
</div>
</div>
</div>
<!-- Ошибка -->
<div class="alert alert-error" id="errorBox" style="display:none"></div>
</div>
<!-- ================================================================== -->
<!-- Footer -->
<!-- ================================================================== -->
<div class="footer">Say v1.0.0 · Whisper + LLM · Nubes</div>
<!-- ================================================================== -->
<!-- JavaScript -->
<!-- ================================================================== -->
<script>
// ===========================================================================
// DOM-элементы
// ===========================================================================
const llmCards = document.getElementById('llmCards');
const resultCard = document.getElementById('resultCard');
const transEl = document.getElementById('transcriptionText');
const respEl = document.getElementById('responseText');
const respLabel = document.getElementById('responseLabel');
const errorBox = document.getElementById('errorBox');
// ===========================================================================
// Состояние записи (одна активная кнопка в моменте)
// ===========================================================================
let activeStream = null; // MediaStream
let activeRecorder = null; // MediaRecorder
let activeBtn = null; // DOM-кнопка
let audioChunks = [];
let activeLlmId = null;
// ===========================================================================
// Шаблон карточки LLM
// ===========================================================================
function buildLlmCard(profile) {
return `
<div class="llm-card" data-llm="${profile.id}">
<div class="llm-info">
<div class="llm-name">${escapeHtml(profile.name)}</div>
<div class="llm-desc">${escapeHtml(profile.description)}</div>
</div>
<button class="btn btn-primary record-btn" data-llm="${profile.id}">
<span class="btn-icon">🎤</span>
<span class="btn-label">Записать</span>
</button>
<span class="rec-status" data-llm="${profile.id}"></span>
</div>
`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ===========================================================================
// Загрузка профилей с сервера
// ===========================================================================
async function loadProfiles() {
try {
const resp = await fetch('/api/profiles');
if (!resp.ok) throw new Error('Ошибка загрузки профилей: ' + resp.status);
const profiles = await resp.json();
llmCards.innerHTML = profiles.map(buildLlmCard).join('');
// Вешаем обработчики на все кнопки записи
document.querySelectorAll('.record-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var llmId = btn.dataset.llm;
if (activeRecorder && activeRecorder.state === 'recording') {
stopRecording(); // повторное нажатие = стоп
} else {
startRecording(llmId, btn);
}
});
});
} catch (err) {
llmCards.innerHTML = '<div class="alert alert-error">' + escapeHtml(err.message) + '</div>';
}
}
// ===========================================================================
// Старт записи
// ===========================================================================
async function startRecording(llmId, btn) {
// Сбрасываем предыдущий результат/ошибку
resultCard.style.display = 'none';
errorBox.style.display = 'none';
// Если уже идёт запись на другой кнопке — стоп
if (activeRecorder && activeRecorder.state === 'recording') {
stopRecording();
}
try {
activeStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
showError('Нет доступа к микрофону. Разрешите запись в браузере.');
return;
}
var mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
activeRecorder = new MediaRecorder(activeStream, { mimeType: mimeType });
audioChunks = [];
activeLlmId = llmId;
activeBtn = btn;
activeRecorder.ondataavailable = function(e) {
if (e.data.size > 0) audioChunks.push(e.data);
};
activeRecorder.onstop = function() { sendToServer(); };
activeRecorder.start();
// UI: кнопка → состояние «запись»
setButtonRecording(btn, true);
setAllButtonsDisabled(true, btn);
}
// ===========================================================================
// Стоп записи
// ===========================================================================
function stopRecording() {
if (activeRecorder && activeRecorder.state === 'recording') {
activeRecorder.stop();
}
if (activeStream) {
activeStream.getTracks().forEach(function(t) { t.stop(); });
activeStream = null;
}
// UI: кнопка → «обработка...»
if (activeBtn) {
setButtonProcessing(activeBtn);
}
}
// ===========================================================================
// Отправка аудио на сервер
// ===========================================================================
async function sendToServer() {
var blob = new Blob(audioChunks, { type: 'audio/webm' });
var formData = new FormData();
formData.append('audio', blob, 'recording.webm');
try {
var resp = await fetch('/api/speech/' + activeLlmId, {
method: 'POST',
body: formData,
});
var data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || 'Ошибка сервера (' + resp.status + ')');
}
// Успех — показываем результат
transEl.textContent = data.transcription;
respEl.textContent = data.response;
respLabel.textContent = '🤖 Ответ (' + data.llm.name + ')';
resultCard.style.display = '';
errorBox.style.display = 'none';
} catch (err) {
showError(err.message);
} finally {
resetButtons();
}
}
// ===========================================================================
// UI-хелперы
// ===========================================================================
function setButtonRecording(btn, rec) {
var icon = btn.querySelector('.btn-icon');
var label = btn.querySelector('.btn-label');
if (rec) {
icon.textContent = '⏺️';
label.textContent = 'Стоп';
btn.classList.add('recording');
}
}
function setButtonProcessing(btn) {
var icon = btn.querySelector('.btn-icon');
var label = btn.querySelector('.btn-label');
icon.textContent = '⏳';
label.textContent = 'Обработка...';
btn.disabled = true;
}
function setAllButtonsDisabled(dis, exceptBtn) {
document.querySelectorAll('.record-btn').forEach(function(b) {
if (b !== exceptBtn) b.disabled = dis;
});
}
function resetButtons() {
document.querySelectorAll('.record-btn').forEach(function(b) {
b.disabled = false;
b.classList.remove('recording');
b.querySelector('.btn-icon').textContent = '🎤';
b.querySelector('.btn-label').textContent = 'Записать';
});
activeBtn = null;
activeRecorder = null;
activeLlmId = null;
}
function showError(msg) {
errorBox.textContent = '❌ ' + msg;
errorBox.style.display = '';
resultCard.style.display = 'none';
}
// ===========================================================================
// Инициализация
// ===========================================================================
loadProfiles();
</script>
</body>
</html>