Fixes associated to voice commands

This commit is contained in:
dimitrievgs 2026-04-30 18:48:42 +03:00
parent 606cef3040
commit d9dc09bca9
9 changed files with 406 additions and 21 deletions

2
.gitignore vendored
View File

@ -9,3 +9,5 @@ __pycache__
*.db-journal *.db-journal
build/ build/
audio-logs/

16
.vscode/launch.json vendored
View File

@ -22,10 +22,24 @@
"request": "launch", "request": "launch",
"python": "${workspaceFolder}/env/Scripts/python.exe", "python": "${workspaceFolder}/env/Scripts/python.exe",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"program": "${workspaceFolder}/run-transcribe.py", "program": "${workspaceFolder}/run-transcribe-parsing.py",
"justMyCode": true, "justMyCode": true,
"console": "integratedTerminal", "console": "integratedTerminal",
"args": [] "args": []
},
{
"name": "Python: Vosk Transcribe",
"type": "debugpy",
"request": "launch",
"python": "${workspaceFolder}/env/Scripts/python.exe",
"cwd": "${workspaceFolder}",
"program": "${workspaceFolder}/run-transcribe-vosk.py",
"justMyCode": false,
"console": "integratedTerminal",
"env": {
"PYTHONUNBUFFERED": "1"
},
"args": []
} }
] ]
} }

View File

@ -367,3 +367,45 @@ def regenerate_message():
except Exception as e: except Exception as e:
print(f"Ошибка при регенерации сообщения: {e}") print(f"Ошибка при регенерации сообщения: {e}")
return jsonify({"error": str(e)}, 500) return jsonify({"error": str(e)}, 500)
from app.voice_service import VoiceService
# 1. Создаем экземпляр (обязательно укажите путь к вашей модели Vosk)
voice_inst = VoiceService(model_path=r"D:\\Work\\Software_Development\\Projects\\vosk\\vosk-model-small-ru-0.22")
current_settings = {
'interval': 5,
'context_limit': 3000,
'voice_model': 'gemini-2.0-flash',
'marker_start': 'старт',
'marker_stop': 'стоп',
'voice_context_limit': 3000
}
@api.route('/api/voice/status', methods=['GET'])
def get_voice_status():
# 2. Вызываем метод у экземпляра voice_inst, а не у модуля
return jsonify({
"transcription": voice_inst.get_last_chars(3000),
"response1": voice_inst.responses["regular"],
"response2": voice_inst.responses["commands"][-5:],
"is_running": voice_inst.is_running
})
@api.route('/api/voice/control', methods=['POST'])
def control_voice():
action = request.json.get("action")
if action == "start": voice_inst.start_session(False)
elif action == "continue": voice_inst.start_session(True)
elif action == "stop": voice_inst.stop_session()
return jsonify({"status": "ok"})
@api.route('/api/settings/sync', methods=['POST'])
def sync_settings():
global current_settings
data = request.json
if data:
current_settings.update(data)
print(f"⚙️ Settings synced: {current_settings}")
return jsonify({"status": "synced"})

View File

@ -12,10 +12,12 @@ logger.setLevel(logging.INFO)
# Добавляем обработчик, если его еще нет (чтобы логи выводились в консоль) # Добавляем обработчик, если его еще нет (чтобы логи выводились в консоль)
if not logger.handlers: if not logger.handlers:
handler = logging.StreamHandler() handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.addHandler(handler) logger.addHandler(handler)
class HeartbeatMonitor: class HeartbeatMonitor:
""" """
Мониторит доступность заданных внешних эндпоинтов путем периодических HTTP-запросов. Мониторит доступность заданных внешних эндпоинтов путем периодических HTTP-запросов.
@ -31,7 +33,9 @@ class HeartbeatMonitor:
self._running = False self._running = False
self._thread = None self._thread = None
self._last_check_results = {} self._last_check_results = {}
logger.info(f"HeartbeatMonitor инициализирован с интервалом {interval_minutes} минут.") logger.info(
f"HeartbeatMonitor инициализирован с интервалом {interval_minutes} минут."
)
logger.info(f"Мониторинг URL: {', '.join(self._target_urls)}") logger.info(f"Мониторинг URL: {', '.join(self._target_urls)}")
# Вывод call stack # Вывод call stack
@ -50,25 +54,48 @@ class HeartbeatMonitor:
for url in self._target_urls: for url in self._target_urls:
start_time = time.time() start_time = time.time()
try: try:
response = requests.get(url, timeout=10) # Таймаут 10 секунд response = requests.get(url,
timeout=10) # Таймаут 10 секунд
if 200 <= response.status_code < 300: if 200 <= response.status_code < 300:
status = {"healthy": True, "status_code": response.status_code, "message": "OK"} status = {
logger.info(f"✅ Heartbeat {url}: OK (HTTP {response.status_code})") "healthy": True,
"status_code": response.status_code,
"message": "OK"
}
logger.info(
f"✅ Heartbeat {url}: OK (HTTP {response.status_code})"
)
else: else:
status = {"healthy": False, "status_code": response.status_code, "message": f"Non-2xx status code"} status = {
logger.warning(f"⚠️ Heartbeat {url}: Ошибка (HTTP {response.status_code})") "healthy": False,
"status_code": response.status_code,
"message": f"Non-2xx status code"
}
logger.warning(
f"⚠️ Heartbeat {url}: Ошибка (HTTP {response.status_code})"
)
overall_healthy = False overall_healthy = False
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
status = {"healthy": False, "status_code": None, "message": f"Request failed: {e}"} status = {
"healthy": False,
"status_code": None,
"message": f"Request failed: {e}"
}
logger.error(f"❌ Heartbeat {url}: Ошибка запроса: {e}") logger.error(f"❌ Heartbeat {url}: Ошибка запроса: {e}")
overall_healthy = False overall_healthy = False
except Exception as e: except Exception as e:
status = {"healthy": False, "status_code": None, "message": f"Unexpected error: {e}"} status = {
logger.critical(f"🔥 Heartbeat {url}: Неожиданная ошибка: {e}") "healthy": False,
"status_code": None,
"message": f"Unexpected error: {e}"
}
logger.critical(
f"🔥 Heartbeat {url}: Неожиданная ошибка: {e}")
overall_healthy = False overall_healthy = False
finally: finally:
status["timestamp"] = datetime.datetime.now().isoformat() status["timestamp"] = datetime.datetime.now().isoformat()
status["response_time_ms"] = int((time.time() - start_time) * 1000) status["response_time_ms"] = int(
(time.time() - start_time) * 1000)
current_results[url] = status current_results[url] = status
self._last_check_results = current_results self._last_check_results = current_results
@ -87,7 +114,8 @@ class HeartbeatMonitor:
self._running = True self._running = True
# daemon=True позволяет приложению завершиться, даже если этот поток еще работает. # daemon=True позволяет приложению завершиться, даже если этот поток еще работает.
# Python автоматически завершит daemon-потоки при выходе из основной программы. # Python автоматически завершит daemon-потоки при выходе из основной программы.
self._thread = threading.Thread(target=self._run_heartbeat_loop, daemon=True) self._thread = threading.Thread(target=self._run_heartbeat_loop,
daemon=True)
self._thread.start() self._thread.start()
logger.info("HeartbeatMonitor запущен.") logger.info("HeartbeatMonitor запущен.")
else: else:
@ -101,9 +129,12 @@ class HeartbeatMonitor:
logger.info("Остановка HeartbeatMonitor...") logger.info("Остановка HeartbeatMonitor...")
self._running = False self._running = False
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
self._thread.join(timeout=5) # Даем потоку 5 секунд на завершение self._thread.join(
timeout=5) # Даем потоку 5 секунд на завершение
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("HeartbeatMonitor поток не завершился в течение таймаута.") logger.warning(
"HeartbeatMonitor поток не завершился в течение таймаута."
)
logger.info("HeartbeatMonitor остановлен.") logger.info("HeartbeatMonitor остановлен.")
else: else:
logger.warning("HeartbeatMonitor не был запущен.") logger.warning("HeartbeatMonitor не был запущен.")
@ -113,3 +144,65 @@ class HeartbeatMonitor:
Возвращает результаты последней проверки (для возможного использования внутри приложения). Возвращает результаты последней проверки (для возможного использования внутри приложения).
""" """
return self._last_check_results return self._last_check_results
class VoiceHeartbeatMonitor(threading.Thread):
def __init__(self, voice_instance, generator, settings_fn):
super().__init__(daemon=True)
self.vs = voice_instance # Тот самый экземпляр из api.py
self.generator = generator
self.get_settings = settings_fn # Функция, возвращающая текущий конфиг
self.last_regular_run = time.time()
self.last_command_time = ""
def run(self):
while True:
if self.vs.is_running:
s = self.get_settings()
# 1. Проверка команд <start>...<stop>
self._check_commands(s)
# 2. Регулярный запрос
if time.time() - self.last_regular_run > s['interval'] * 60:
self._run_regular_analysis(s)
self.last_regular_run = time.time()
time.sleep(10)
def _check_commands(self, s):
# Получаем данные за последние секунды
raw_text = self.vs.get_last_chars(s['context_limit'])
import re
# Чистим таймстемпы Vosk для LLM (удаляем паттерны [00:00:00])
clean_text = re.sub(r'\[.*?\]', '', raw_text)
pattern = f"{re.escape(s['marker_start'])}(.*?){re.escape(s['marker_stop'])}"
matches = re.findall(pattern, clean_text, re.DOTALL)
for match in matches:
cmd = match.strip()
# Проверяем, не обрабатывали ли мы ЭТУ конкретную строку только что
# Используем множество для хранения хешей обработанных команд
if not hasattr(self, '_processed_hashes'): self._processed_hashes = set()
cmd_hash = hash(cmd)
if cmd_hash not in self._processed_hashes:
print(f"🎤 New Voice Command detected: {cmd}")
# ОТПРАВЛЯЕМ В LLM через TitleGenerator
self.generator.add_voice_task_to_queue(
cmd,
s['prompt_commands'] # Это путь к .md файлу из настроек
)
self._processed_hashes.add(cmd_hash)
# Ограничиваем размер множества, чтобы не росло бесконечно
if len(self._processed_hashes) > 100: self._processed_hashes.clear()
def _run_regular_analysis(self, s):
raw_text = self.vs.get_last_chars(s['voice_context_limit'])
if not raw_text.strip(): return
# Добавим новый тип 'voice_regular' вTitleGenerator аналогично 'voice_command'
self.generator.add_voice_regular_task(raw_text, s['prompt_regular'])

View File

@ -88,6 +88,19 @@ MODELS: Dict[str, Dict[str, Any]] = {
"stream": True, "stream": True,
"capabilities": ["vision"], "capabilities": ["vision"],
}, },
"gemini-3.0-flash-openrouter": {
"name": "google/gemini-3-flash-preview",
"provider": "openai",
"model_name": "google/gemini-3-flash-preview",
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
"apiKey":
"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",
"stream": True,
"capabilities": ["vision", "reasoning"],
"model_kwargs": {
"include_reasoning": True
}
},
"gemini-3.1-pro-preview-bh": { "gemini-3.1-pro-preview-bh": {
"name": "gemini-3.1-pro-preview", "name": "gemini-3.1-pro-preview",
"provider": "openai", # Изменено на "openai" "provider": "openai", # Изменено на "openai"

View File

@ -10,6 +10,7 @@ from typing import Any, Dict, Optional
from nodes import DEFAULT_SUMMARIZATION_LLM_NAME from nodes import DEFAULT_SUMMARIZATION_LLM_NAME
from llm_client import get_llm from llm_client import get_llm
from langchain_core.messages import SystemMessage, HumanMessage from langchain_core.messages import SystemMessage, HumanMessage
import os
MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков
@ -128,6 +129,11 @@ class TitleGenerator:
self._generate_graph_title(item["graph_id"]) self._generate_graph_title(item["graph_id"])
elif item["item_type"] == "node": elif item["item_type"] == "node":
self._generate_node_title(item["graph_id"], item["node_id"]) self._generate_node_title(item["graph_id"], item["node_id"])
# ОБРАБОТКА ГОЛОСА
elif item["item_type"] == "voice_command":
self._process_voice_command(item["node_id"], item["graph_id"])
elif item["item_type"] == "voice_regular":
self._process_voice_regular(item["node_id"], item["graph_id"])
else: else:
# Если очередь пуста, ждем немного # Если очередь пуста, ждем немного
time.sleep(5) time.sleep(5)
@ -338,3 +344,65 @@ class TitleGenerator:
if not ack_received.is_set(): if not ack_received.is_set():
print(f"Не удалось доставить {event_name} после {max_retries} попыток: {data}") print(f"Не удалось доставить {event_name} после {max_retries} попыток: {data}")
# VOICE
def add_voice_task_to_queue(self, command_text, prompt, priority=20):
"""Добавляет задачу на обработку голосовой команды в очередь."""
with self._get_connection() as conn:
cursor = conn.cursor()
# Используем node_id для хранения текста команды,
# а item_type 'voice_command' для идентификации
cursor.execute(
"INSERT INTO title_generation_queue (item_type, graph_id, node_id, priority) VALUES (?, ?, ?, ?)",
("voice_command", prompt, command_text, priority))
conn.commit()
# 2. Добавьте метод постановки регулярной задачи
def add_voice_regular_task(self, transcription_text, prompt, priority=15):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO title_generation_queue (item_type, graph_id, node_id, priority) VALUES (?, ?, ?, ?)",
("voice_regular", prompt, transcription_text, priority))
conn.commit()
# 3. Добавьте метод обработки регулярной задачи
def _process_voice_regular(self, text, prompt):
try:
print(f"📡 Анализ транскрибации (Prompt 1)...")
llm_messages = [
SystemMessage(content=prompt),
HumanMessage(content=f"Последний фрагмент диалога для анализа: {text}")
]
response = self.llm.invoke(llm_messages)
import app.api as api
api.voice_inst.responses["regular"] = response # Записываем в Response 1
print(f"✅ Регулярный анализ завершен.")
except Exception as e:
print(f"Ошибка регулярного анализа: {e}")
# 4. Поправьте _process_voice_command (запись команды в Response 2)
def _process_voice_command(self, text, prompt):
try:
print(f"🤖 Обработка голосовой команды (Prompt 2)...")
llm_messages = [
SystemMessage(content=prompt),
HumanMessage(content=f"Выполни команду из транскрибации: {text}")
]
response = self.llm.invoke(llm_messages)
import app.api as api
# Форматируем для вкладки Response 2
formatted_res = f"<b>Команда:</b> {text}<br><b>Ответ:</b> {response}"
api.voice_inst.responses["commands"].append(formatted_res)
# Ограничиваем историю команд, чтобы не раздувать память
if len(api.voice_inst.responses["commands"]) > 20:
api.voice_inst.responses["commands"].pop(0)
print(f"✅ Команда обработана.")
except Exception as e:
print(f"Ошибка обработки команды: {e}")

137
app/voice_service.py Normal file
View File

@ -0,0 +1,137 @@
import os
import json
import threading
import datetime
import time
import numpy as np
from vosk import Model, KaldiRecognizer
import sounddevice as sd
class VoiceService:
def __init__(self, model_path, samplerate=48000):
self.model_path = model_path
self.samplerate = samplerate
self.model = Model(model_path)
self.is_running = False
self.current_file = None
self.last_processed_timestamp = 0.0
self.responses = {"regular": "", "commands": []}
self.lock = threading.Lock()
self._stop_event = threading.Event()
def get_last_chars(self, n):
"""Метод для получения последних N символов лога."""
if not self.current_file or not os.path.exists(self.current_file):
return "Лог-файл пуст или не создан."
try:
with self.lock:
with open(self.current_file, "r", encoding="utf-8") as f:
f.seek(0, os.SEEK_END)
size = f.tell()
# Читаем последние байты с запасом (UTF-8 до 4 байт на символ)
f.seek(max(0, size - n * 4))
content = f.read()
return content[-n:]
except Exception as e:
return f"Ошибка чтения: {str(e)}"
def start_session(self, continue_last=False):
if self.is_running: return
log_dir = "audio-logs"
if not os.path.exists(log_dir): os.makedirs(log_dir)
if continue_last:
files = sorted([f for f in os.listdir(log_dir) if f.endswith(".txt")])
self.current_file = os.path.join(log_dir, files[-1]) if files else self._create_new_file(log_dir)
else:
self.current_file = self._create_new_file(log_dir)
self.is_running = True
self._stop_event.clear()
threading.Thread(target=self._run_transcription, daemon=True).start()
def _create_new_file(self, log_dir):
name = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S.txt")
path = os.path.join(log_dir, name)
with open(path, "w", encoding="utf-8") as f:
f.write(f"--- Session Start: {name} ---\n\n")
return path
def stop_session(self):
self.is_running = False
self._stop_event.set()
def _run_transcription(self):
# 1. Поиск устройств (строго перед запуском потока)
id_gm, id_pcs = None, None
devices = sd.query_devices()
hostapis = sd.query_hostapis()
mme_api_index = next((i for i, h in enumerate(hostapis) if h['name'] == 'MME'), 0)
for i, d in enumerate(devices):
# Проверяем, что устройство относится к MME и содержит нужное имя
if d['hostapi'] == mme_api_index:
if "Voicemeeter Out B1" in d['name']: id_gm = i
if "Voicemeeter Out B2" in d['name']: id_pcs = i
if id_gm is None or id_pcs is None:
# Если MME не нашел, пробуем любой
for i, d in enumerate(devices):
if "Voicemeeter Out B1" in d['name']: id_gm = i
if "Voicemeeter Out B2" in d['name']: id_pcs = i
# 2. Создаем рекогнайзеры ВНУТРИ потока
rec_gm = KaldiRecognizer(self.model, self.samplerate)
rec_pcs = KaldiRecognizer(self.model, self.samplerate)
# 3. Фабрика колбэков
def create_callback(rec, role):
def callback_func(indata, frames, time_info, status):
if status:
print(f"Audio Status ({role}): {status}")
if np.max(np.abs(indata)) < 0.001:
return
# Важно: indata уже в формате bytes для RawInputStream
if rec.AcceptWaveform(bytes(indata)):
res = json.loads(rec.Result())
text = res.get('text', '')
if text:
now = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
entry = f"[{now}] {role}: {text}\n"
# Пишем в файл
try:
with self.lock:
with open(self.current_file, "a", encoding="utf-8") as f:
f.write(entry)
except Exception as e:
print(f"File write error: {e}")
return callback_func
# 4. Запуск стримов
try:
# Используем те же параметры, что в рабочем демо
with sd.RawInputStream(samplerate=self.samplerate,
blocksize=8000,
device=id_gm,
dtype='int16',
channels=1,
callback=create_callback(rec_gm, "GM")), \
sd.RawInputStream(samplerate=self.samplerate,
blocksize=8000,
device=id_pcs,
dtype='int16',
channels=1,
callback=create_callback(rec_pcs, "PCs")):
print(f"✅ Транскрибация запущена (IDs: {id_gm}, {id_pcs})")
while not self._stop_event.is_set():
self._stop_event.wait(1.0) # Экономнее чем sleep
except Exception as e:
print(f"🔥 Критическая ошибка транскрибации: {e}")
finally:
self.is_running = False
print("🏁 Сессия транскрибации завершена")

View File

@ -13,3 +13,7 @@ flask_socketio
mistralai==1.9.10 mistralai==1.9.10
selenium selenium
vosk==0.3.45
sounddevice==0.5.1
numpy==1.26.4

14
run.py
View File

@ -12,8 +12,9 @@ logger = logging.getLogger(__name__)
if __name__ == "__main__": if __name__ == "__main__":
# Добавляем папку app в Python path # Добавляем папку app в Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app'))
from app.api import api from app.api import api, voice_inst, current_settings
from app.heartbeat_monitor import HeartbeatMonitor from app.heartbeat_monitor import HeartbeatMonitor
from app.workflows import graph_history_manager
logger.info("🚀 Запуск LLM Agent Backend сервера...") logger.info("🚀 Запуск LLM Agent Backend сервера...")
logger.info("📍 API будет доступно на: http://localhost:5000/api") logger.info("📍 API будет доступно на: http://localhost:5000/api")
@ -31,6 +32,17 @@ if __name__ == "__main__":
atexit.register(heartbeat_monitor.stop) atexit.register(heartbeat_monitor.stop)
logger.info("HeartbeatMonitor запущен.") logger.info("HeartbeatMonitor запущен.")
from app.heartbeat_monitor import VoiceHeartbeatMonitor
v_monitor = VoiceHeartbeatMonitor(
voice_inst,
graph_history_manager.title_generator,
lambda: current_settings # Теперь берет настройки из api.py
)
v_monitor.start()
api.run( api.run(
debug=True, debug=True,
port=5000, port=5000,