From d9dc09bca9489bf4af762163f43eb5019f8e88fe Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Thu, 30 Apr 2026 18:48:42 +0300 Subject: [PATCH] Fixes associated to voice commands --- .gitignore | 4 +- .vscode/launch.json | 18 ++++- app/api.py | 42 ++++++++++++ app/heartbeat_monitor.py | 123 ++++++++++++++++++++++++++++++----- app/llm_client.py | 13 ++++ app/title_generator.py | 70 +++++++++++++++++++- app/voice_service.py | 137 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 6 +- run.py | 14 +++- 9 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 app/voice_service.py diff --git a/.gitignore b/.gitignore index 0666b91..557b785 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ __pycache__ *.db *.db-journal -build/ \ No newline at end of file +build/ + +audio-logs/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 871ea5b..01903ed 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,10 +22,24 @@ "request": "launch", "python": "${workspaceFolder}/env/Scripts/python.exe", "cwd": "${workspaceFolder}", - "program": "${workspaceFolder}/run-transcribe.py", + "program": "${workspaceFolder}/run-transcribe-parsing.py", "justMyCode": true, "console": "integratedTerminal", "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": [] } ] -} \ No newline at end of file +} diff --git a/app/api.py b/app/api.py index 1793369..82c3bd6 100644 --- a/app/api.py +++ b/app/api.py @@ -367,3 +367,45 @@ def regenerate_message(): except Exception as e: print(f"Ошибка при регенерации сообщения: {e}") 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"}) \ No newline at end of file diff --git a/app/heartbeat_monitor.py b/app/heartbeat_monitor.py index e993127..89204c1 100644 --- a/app/heartbeat_monitor.py +++ b/app/heartbeat_monitor.py @@ -12,10 +12,12 @@ logger.setLevel(logging.INFO) # Добавляем обработчик, если его еще нет (чтобы логи выводились в консоль) if not logger.handlers: 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) logger.addHandler(handler) + class HeartbeatMonitor: """ Мониторит доступность заданных внешних эндпоинтов путем периодических HTTP-запросов. @@ -31,7 +33,9 @@ class HeartbeatMonitor: self._running = False self._thread = None self._last_check_results = {} - logger.info(f"HeartbeatMonitor инициализирован с интервалом {interval_minutes} минут.") + logger.info( + f"HeartbeatMonitor инициализирован с интервалом {interval_minutes} минут." + ) logger.info(f"Мониторинг URL: {', '.join(self._target_urls)}") # Вывод call stack @@ -50,27 +54,50 @@ class HeartbeatMonitor: for url in self._target_urls: start_time = time.time() try: - response = requests.get(url, timeout=10) # Таймаут 10 секунд + response = requests.get(url, + timeout=10) # Таймаут 10 секунд if 200 <= response.status_code < 300: - status = {"healthy": True, "status_code": response.status_code, "message": "OK"} - logger.info(f"✅ Heartbeat {url}: OK (HTTP {response.status_code})") + status = { + "healthy": True, + "status_code": response.status_code, + "message": "OK" + } + logger.info( + f"✅ Heartbeat {url}: OK (HTTP {response.status_code})" + ) else: - status = {"healthy": False, "status_code": response.status_code, "message": f"Non-2xx status code"} - logger.warning(f"⚠️ Heartbeat {url}: Ошибка (HTTP {response.status_code})") + status = { + "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 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}") overall_healthy = False except Exception as e: - status = {"healthy": False, "status_code": None, "message": f"Unexpected error: {e}"} - logger.critical(f"🔥 Heartbeat {url}: Неожиданная ошибка: {e}") + status = { + "healthy": False, + "status_code": None, + "message": f"Unexpected error: {e}" + } + logger.critical( + f"🔥 Heartbeat {url}: Неожиданная ошибка: {e}") overall_healthy = False finally: 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 - + self._last_check_results = current_results if overall_healthy: logger.info("✔ Все Heartbeat-проверки успешно пройдены.") @@ -87,7 +114,8 @@ class HeartbeatMonitor: self._running = True # daemon=True позволяет приложению завершиться, даже если этот поток еще работает. # 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() logger.info("HeartbeatMonitor запущен.") else: @@ -101,9 +129,12 @@ class HeartbeatMonitor: logger.info("Остановка HeartbeatMonitor...") self._running = False 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(): - logger.warning("HeartbeatMonitor поток не завершился в течение таймаута.") + logger.warning( + "HeartbeatMonitor поток не завершился в течение таймаута." + ) logger.info("HeartbeatMonitor остановлен.") else: logger.warning("HeartbeatMonitor не был запущен.") @@ -113,3 +144,65 @@ class HeartbeatMonitor: Возвращает результаты последней проверки (для возможного использования внутри приложения). """ 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. Проверка команд ... + 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']) \ No newline at end of file diff --git a/app/llm_client.py b/app/llm_client.py index e639423..dd34f8a 100644 --- a/app/llm_client.py +++ b/app/llm_client.py @@ -88,6 +88,19 @@ MODELS: Dict[str, Dict[str, Any]] = { "stream": True, "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": { "name": "gemini-3.1-pro-preview", "provider": "openai", # Изменено на "openai" diff --git a/app/title_generator.py b/app/title_generator.py index 899e2f0..81f8656 100644 --- a/app/title_generator.py +++ b/app/title_generator.py @@ -10,6 +10,7 @@ from typing import Any, Dict, Optional from nodes import DEFAULT_SUMMARIZATION_LLM_NAME from llm_client import get_llm from langchain_core.messages import SystemMessage, HumanMessage +import os MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков @@ -128,6 +129,11 @@ class TitleGenerator: self._generate_graph_title(item["graph_id"]) elif item["item_type"] == "node": 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: # Если очередь пуста, ждем немного time.sleep(5) @@ -337,4 +343,66 @@ class TitleGenerator: time.sleep(0.5) if not ack_received.is_set(): - print(f"❌ Не удалось доставить {event_name} после {max_retries} попыток: {data}") \ No newline at end of file + 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"Команда: {text}
Ответ: {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}") \ No newline at end of file diff --git a/app/voice_service.py b/app/voice_service.py new file mode 100644 index 0000000..09461df --- /dev/null +++ b/app/voice_service.py @@ -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("🏁 Сессия транскрибации завершена") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 84bad0e..b970ad2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,8 @@ flask_socketio mistralai==1.9.10 -selenium \ No newline at end of file +selenium + +vosk==0.3.45 +sounddevice==0.5.1 +numpy==1.26.4 \ No newline at end of file diff --git a/run.py b/run.py index e60b203..03ea559 100644 --- a/run.py +++ b/run.py @@ -12,8 +12,9 @@ logger = logging.getLogger(__name__) if __name__ == "__main__": # Добавляем папку app в Python path 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.workflows import graph_history_manager logger.info("🚀 Запуск LLM Agent Backend сервера...") logger.info("📍 API будет доступно на: http://localhost:5000/api") @@ -31,6 +32,17 @@ if __name__ == "__main__": atexit.register(heartbeat_monitor.stop) 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( debug=True, port=5000,