diff --git a/app/api.py b/app/api.py index 520a701..98ad6c2 100644 --- a/app/api.py +++ b/app/api.py @@ -382,7 +382,7 @@ obsidian_settings_fetched = False def get_voice_status(): # 2. Вызываем метод у экземпляра voice_inst, а не у модуля return jsonify({ - "transcription": voice_inst.get_last_chars(3000), + "transcription": voice_inst.get_context_by_limit(3000, order='reversed'), "response1": voice_inst.responses["regular"], "response2": voice_inst.responses["commands"][:VOICE_COMMANDS_RESPONSE_TO_STORE], "is_running": voice_inst.is_running, diff --git a/app/heartbeat_monitor.py b/app/heartbeat_monitor.py index 4cfb45d..831bd1e 100644 --- a/app/heartbeat_monitor.py +++ b/app/heartbeat_monitor.py @@ -150,13 +150,19 @@ class VoiceHeartbeatMonitor(threading.Thread): def __init__(self, voice_instance, generator, get_obsidian_settings_fn): super().__init__(daemon=True) - self.vs = voice_instance # Тот самый экземпляр из api.py + self.vs = voice_instance # Тот самый экземпляр из api.py self.generator = generator - self.get_obsidian_settings = get_obsidian_settings_fn # Функция, возвращающая текущий конфиг + self.get_obsidian_settings = get_obsidian_settings_fn # Функция, возвращающая текущий конфиг self.last_regular_run = time.time() - self.last_command_time = "" + with self.vs.history_lock: + self.last_gm_index = len(self.vs.history) - 1 + self.gm_buffer = "" def run(self): + if self.last_gm_index == -1: + with self.vs.history_lock: + self.last_gm_index = len(self.vs.history) - 1 + while True: try: if self.vs.is_running: @@ -165,50 +171,69 @@ class VoiceHeartbeatMonitor(threading.Thread): self._check_commands(obsidian_settings) # 2. Регулярный запрос - if time.time() - self.last_regular_run > obsidian_settings['voiceInterval'] * 60: + if time.time() - self.last_regular_run > obsidian_settings[ + 'voiceInterval'] * 60: self._run_regular_analysis(obsidian_settings) self.last_regular_run = time.time() - time.sleep(3) # каждые 3 секунды проверяем + time.sleep(3) # каждые 3 секунды проверяем except Exception as e: logger.critical(f"❌🎤 Voice Heartbeat: Неожиданная ошибка: {e}") def _check_commands(self, obsidian_settings): - # Получаем данные за последние секунды - raw_text = self.vs.get_last_chars(obsidian_settings['voiceContextLimit']) + # 1. Забираем новые реплики + new_text, new_index = self.vs.get_new_gm_text(self.last_gm_index) - import re - # Чистим таймстемпы Vosk для LLM (удаляем паттерны [00:00:00]) - clean_text = re.sub(r'\[.*?\]', '', raw_text) - - pattern = f"{re.escape(obsidian_settings['markerStart'])}(.*?){re.escape(obsidian_settings['markerStop'])}" - matches = re.findall(pattern, clean_text, re.DOTALL) + # Добавляем в буфер (через пробел, чтобы не склеились слова) + if new_text: + self.gm_buffer = (self.gm_buffer + " " + new_text).strip() + self.last_gm_index = new_index - 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}") + if not self.gm_buffer: + return + + import re + start_marker = obsidian_settings['markerStart'] + stop_marker = obsidian_settings['markerStop'] + + # Регулярка для поиска самой короткой подходящей пары (нежадный поиск) + pattern = f"{re.escape(start_marker)}(.*?){re.escape(stop_marker)}" + + last_stop_pos = 0 + # finditer позволяет получить позиции вхождений + matches = list(re.finditer(pattern, self.gm_buffer, re.DOTALL)) + + if matches: + for m in matches: + cmd = m.group(1).strip() + if cmd: + print(f"🎤 New GM Voice Command detected: {cmd}") + prompt = f"{(obsidian_settings.get('systemPrompt') or '')}\n\n{(obsidian_settings.get('promptCommands') or '')}" + self.generator.add_voice_task_to_queue(cmd, prompt) - # ОТПРАВЛЯЕМ В LLM через TitleGenerator - prompt = f"{(obsidian_settings.get('systemPrompt') or '')}\n\n{(obsidian_settings.get('promptCommands') or '')}" - self.generator.add_voice_task_to_queue( - cmd, - prompt - ) - - self._processed_hashes.add(cmd_hash) - # Ограничиваем размер множества, чтобы не росло бесконечно - if len(self._processed_hashes) > 100: self._processed_hashes.clear() # Спорно, очень спорно + # Запоминаем позицию конца последнего найденного стоп-маркера + last_stop_pos = m.end() + + # ОБРЕЗАЕМ БУФЕР: оставляем только то, что идет за последним стоп-маркером + # (там может быть начало следующей команды "старт команда 3...") + self.gm_buffer = self.gm_buffer[last_stop_pos:].strip() + else: + # Если матчей нет, но буфер стал слишком огромным (забыли стоп-маркер), + # стоит его ограничить, чтобы не переполнять память, например, последними 2000 симв. + if len(self.gm_buffer) > 2000: + # Ищем последний старт-маркер, чтобы не отрезать начало потенциальной команды + last_start = self.gm_buffer.rfind(start_marker) + if last_start != -1: + self.gm_buffer = self.gm_buffer[last_start:] + else: + self.gm_buffer = "" # Маркеров старта тоже нет - чистим def _run_regular_analysis(self, obsidian_settings): - raw_text = self.vs.get_last_chars(obsidian_settings['voiceContextLimit']) - if not raw_text.strip(): return + # ТЕПЕРЬ БЕРЕМ ИЗ ПАМЯТИ: хронологический список реплик всех ролей + context_text = self.vs.get_context_by_limit(obsidian_settings['voiceContextLimit']) + if not context_text.strip(): + return + + print(f"🎤 Запуск регулярного анализа контекста ({len(context_text)} симв.)") prompt = f"{(obsidian_settings.get('systemPrompt') or '')}\n\n{(obsidian_settings.get('promptRegular') or '')}" - - # Добавим новый тип 'voice_regular' вTitleGenerator аналогично 'voice_command' - self.generator.add_voice_regular_task(raw_text, prompt) \ No newline at end of file + self.generator.add_voice_regular_task(context_text, prompt) diff --git a/app/title_generator.py b/app/title_generator.py index f06a2db..75a14fe 100644 --- a/app/title_generator.py +++ b/app/title_generator.py @@ -15,6 +15,7 @@ DEFAULT_SUMMARIZATION_LLM_NAME = "gemini-2.0-flash-lite" # "mistral-small-latest DEFAULT_VOICE_LLM_NAME = "gemini-2.0-flash-lite" # "gemini-3.0-flash-openrouter" MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков +QUEUE_POLLING_INTERVAL = 4 from app.voice_service import VOICE_COMMANDS_RESPONSE_TO_STORE @@ -141,11 +142,11 @@ class TitleGenerator: self._process_voice_regular(item["node_id"], item["graph_id"]) else: # Если очередь пуста, ждем немного - time.sleep(5) + time.sleep(QUEUE_POLLING_INTERVAL) except Exception as e: print(f"Ошибка при обработке очереди заголовков: {e}") - time.sleep(10) + time.sleep(QUEUE_POLLING_INTERVAL) def get_next_from_title_queue(self) -> Optional[Dict[str, Any]]: """Получает следующий элемент из очереди для обработки.""" diff --git a/app/voice_service.py b/app/voice_service.py index 6565125..2cc835a 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -6,6 +6,7 @@ import time import numpy as np from vosk import Model, KaldiRecognizer import sounddevice as sd +from collections import deque VOICE_COMMANDS_RESPONSE_TO_STORE = 15 @@ -16,27 +17,40 @@ class VoiceService: self.model = Model(model_path) self.is_running = False self.current_file = None + self.history = [] + self.history_lock = threading.Lock() 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 символов лога.""" + def _load_history_from_file(self): + """Парсит существующий файл лога в коллекцию self.history.""" if not self.current_file or not os.path.exists(self.current_file): - return "Лог-файл пуст или не создан." - + return + + import re + # Регулярка для парсинга строки типа: [10:20:30.123] GM: текст реплики + pattern = re.compile(r"\[(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\s+(GM|PCs):\s+(.*)") + + new_history = [] 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:] + with open(self.current_file, "r", encoding="utf-8") as f: + for line in f: + match = pattern.match(line.strip()) + if match: + timestamp, role, text = match.groups() + new_history.append({ + 'role': role, + 'text': text, + 'time': timestamp + }) + + with self.history_lock: + self.history = new_history + print(f"Loaded {len(self.history)} events from existing log.") except Exception as e: - return f"Ошибка чтения: {str(e)}" + print(f"Error loading history from file: {e}") def start_session(self, continue_last=False): if self.is_running: return @@ -46,9 +60,16 @@ class VoiceService: 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) + if files: + self.current_file = os.path.join(log_dir, files[-1]) + # Загружаем старые реплики в память + self._load_history_from_file() + else: + self.current_file = self._create_new_file(log_dir) else: self.current_file = self._create_new_file(log_dir) + with self.history_lock: + self.history = [] # Сбрасываем историю для новой сессии self.is_running = True self._stop_event.clear() @@ -96,20 +117,28 @@ class VoiceService: 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] + now = datetime.datetime.now().strftime("%H:%M:%S") + + # 1. Запись в файл (как и было) 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}") + + # 2. ЗАПИСЬ В ПАМЯТЬ + with self.history_lock: + self.history.append({ + 'role': role, + 'text': text, + 'time': now + }) return callback_func # 4. Запуск стримов @@ -136,4 +165,49 @@ class VoiceService: print(f"❌🎤 Критическая ошибка транскрибации: {e}") finally: self.is_running = False - print("🏁🎤 Сессия транскрибации завершена") \ No newline at end of file + print("🏁🎤 Сессия транскрибации завершена") + + def get_context_by_limit(self, limit, separator = '', order='normal'): + """ + Собирает реплики с конца, пока не превысит лимит символов. + Возвращает склеенную строку всех ролей. + """ + with self.history_lock: + if not self.history: + return "" + + selected_phrases = [] + current_length = 0 + + # Идем с конца списка + for item in reversed(self.history): + phrase = f"{item['role']}: {item['text']}\n" + selected_phrases.append(phrase) + current_length += len(phrase) + + # Если превысили лимит, останавливаемся + if current_length >= limit: + break + + if order == 'normal': + # Возвращаем в хронологическом порядке (как в чате) + return separator.join(reversed(selected_phrases)) + else: + # Возвращаем как есть (новые сверху) + return separator.join(selected_phrases) + + def get_new_gm_text(self, start_index): + """Возвращает склеенный текст GM и новый индекс последнего элемента.""" + with self.history_lock: + current_max_index = len(self.history) - 1 + if start_index >= current_max_index: + return "", start_index + + # Собираем только реплики GM, начиная с start_index + 1 + new_phrases = [ + item['text'] + for item in self.history[start_index + 1:] + if item['role'] == "GM" + ] + + return " ".join(new_phrases), current_max_index