Fix in _check_commands, add get_context_by_limit and get_new_gm_text to work with collection history instead of file

This commit is contained in:
dimitrievgs 2026-05-02 00:46:53 +03:00
parent 6ce3c9781b
commit b30ca3cc14
4 changed files with 158 additions and 58 deletions

View File

@ -382,7 +382,7 @@ obsidian_settings_fetched = False
def get_voice_status(): def get_voice_status():
# 2. Вызываем метод у экземпляра voice_inst, а не у модуля # 2. Вызываем метод у экземпляра voice_inst, а не у модуля
return jsonify({ return jsonify({
"transcription": voice_inst.get_last_chars(3000), "transcription": voice_inst.get_context_by_limit(3000, order='reversed'),
"response1": voice_inst.responses["regular"], "response1": voice_inst.responses["regular"],
"response2": voice_inst.responses["commands"][:VOICE_COMMANDS_RESPONSE_TO_STORE], "response2": voice_inst.responses["commands"][:VOICE_COMMANDS_RESPONSE_TO_STORE],
"is_running": voice_inst.is_running, "is_running": voice_inst.is_running,

View File

@ -150,13 +150,19 @@ class VoiceHeartbeatMonitor(threading.Thread):
def __init__(self, voice_instance, generator, get_obsidian_settings_fn): def __init__(self, voice_instance, generator, get_obsidian_settings_fn):
super().__init__(daemon=True) super().__init__(daemon=True)
self.vs = voice_instance # Тот самый экземпляр из api.py self.vs = voice_instance # Тот самый экземпляр из api.py
self.generator = generator 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_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): 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: while True:
try: try:
if self.vs.is_running: if self.vs.is_running:
@ -165,50 +171,69 @@ class VoiceHeartbeatMonitor(threading.Thread):
self._check_commands(obsidian_settings) self._check_commands(obsidian_settings)
# 2. Регулярный запрос # 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._run_regular_analysis(obsidian_settings)
self.last_regular_run = time.time() self.last_regular_run = time.time()
time.sleep(3) # каждые 3 секунды проверяем time.sleep(3) # каждые 3 секунды проверяем
except Exception as e: except Exception as e:
logger.critical(f"❌🎤 Voice Heartbeat: Неожиданная ошибка: {e}") logger.critical(f"❌🎤 Voice Heartbeat: Неожиданная ошибка: {e}")
def _check_commands(self, obsidian_settings): def _check_commands(self, obsidian_settings):
# Получаем данные за последние секунды # 1. Забираем новые реплики
raw_text = self.vs.get_last_chars(obsidian_settings['voiceContextLimit']) new_text, new_index = self.vs.get_new_gm_text(self.last_gm_index)
import re # Добавляем в буфер (через пробел, чтобы не склеились слова)
# Чистим таймстемпы Vosk для LLM (удаляем паттерны [00:00:00]) if new_text:
clean_text = re.sub(r'\[.*?\]', '', raw_text) self.gm_buffer = (self.gm_buffer + " " + new_text).strip()
self.last_gm_index = new_index
pattern = f"{re.escape(obsidian_settings['markerStart'])}(.*?){re.escape(obsidian_settings['markerStop'])}"
matches = re.findall(pattern, clean_text, re.DOTALL)
for match in matches: if not self.gm_buffer:
cmd = match.strip() return
# Проверяем, не обрабатывали ли мы ЭТУ конкретную строку только что
# Используем множество для хранения хешей обработанных команд import re
if not hasattr(self, '_processed_hashes'): self._processed_hashes = set() start_marker = obsidian_settings['markerStart']
stop_marker = obsidian_settings['markerStop']
cmd_hash = hash(cmd)
if cmd_hash not in self._processed_hashes: # Регулярка для поиска самой короткой подходящей пары (нежадный поиск)
print(f"🎤 New Voice Command detected: {cmd}") 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 '')}" last_stop_pos = m.end()
self.generator.add_voice_task_to_queue(
cmd, # ОБРЕЗАЕМ БУФЕР: оставляем только то, что идет за последним стоп-маркером
prompt # (там может быть начало следующей команды "старт команда 3...")
) self.gm_buffer = self.gm_buffer[last_stop_pos:].strip()
else:
self._processed_hashes.add(cmd_hash) # Если матчей нет, но буфер стал слишком огромным (забыли стоп-маркер),
# Ограничиваем размер множества, чтобы не росло бесконечно # стоит его ограничить, чтобы не переполнять память, например, последними 2000 симв.
if len(self._processed_hashes) > 100: self._processed_hashes.clear() # Спорно, очень спорно 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): 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 '')}" prompt = f"{(obsidian_settings.get('systemPrompt') or '')}\n\n{(obsidian_settings.get('promptRegular') or '')}"
self.generator.add_voice_regular_task(context_text, prompt)
# Добавим новый тип 'voice_regular' вTitleGenerator аналогично 'voice_command'
self.generator.add_voice_regular_task(raw_text, prompt)

View File

@ -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" DEFAULT_VOICE_LLM_NAME = "gemini-2.0-flash-lite" # "gemini-3.0-flash-openrouter"
MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков
QUEUE_POLLING_INTERVAL = 4
from app.voice_service import VOICE_COMMANDS_RESPONSE_TO_STORE 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"]) self._process_voice_regular(item["node_id"], item["graph_id"])
else: else:
# Если очередь пуста, ждем немного # Если очередь пуста, ждем немного
time.sleep(5) time.sleep(QUEUE_POLLING_INTERVAL)
except Exception as e: except Exception as e:
print(f"Ошибка при обработке очереди заголовков: {e}") print(f"Ошибка при обработке очереди заголовков: {e}")
time.sleep(10) time.sleep(QUEUE_POLLING_INTERVAL)
def get_next_from_title_queue(self) -> Optional[Dict[str, Any]]: def get_next_from_title_queue(self) -> Optional[Dict[str, Any]]:
"""Получает следующий элемент из очереди для обработки.""" """Получает следующий элемент из очереди для обработки."""

View File

@ -6,6 +6,7 @@ import time
import numpy as np import numpy as np
from vosk import Model, KaldiRecognizer from vosk import Model, KaldiRecognizer
import sounddevice as sd import sounddevice as sd
from collections import deque
VOICE_COMMANDS_RESPONSE_TO_STORE = 15 VOICE_COMMANDS_RESPONSE_TO_STORE = 15
@ -16,27 +17,40 @@ class VoiceService:
self.model = Model(model_path) self.model = Model(model_path)
self.is_running = False self.is_running = False
self.current_file = None self.current_file = None
self.history = []
self.history_lock = threading.Lock()
self.last_processed_timestamp = 0.0 self.last_processed_timestamp = 0.0
self.responses = {"regular": "", "commands": []} self.responses = {"regular": "", "commands": []}
self.lock = threading.Lock() self.lock = threading.Lock()
self._stop_event = threading.Event() self._stop_event = threading.Event()
def get_last_chars(self, n): def _load_history_from_file(self):
"""Метод для получения последних N символов лога.""" """Парсит существующий файл лога в коллекцию self.history."""
if not self.current_file or not os.path.exists(self.current_file): 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: try:
with self.lock: with open(self.current_file, "r", encoding="utf-8") as f:
with open(self.current_file, "r", encoding="utf-8") as f: for line in f:
f.seek(0, os.SEEK_END) match = pattern.match(line.strip())
size = f.tell() if match:
# Читаем последние байты с запасом (UTF-8 до 4 байт на символ) timestamp, role, text = match.groups()
f.seek(max(0, size - n * 4)) new_history.append({
content = f.read() 'role': role,
return content[-n:] '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: except Exception as e:
return f"Ошибка чтения: {str(e)}" print(f"Error loading history from file: {e}")
def start_session(self, continue_last=False): def start_session(self, continue_last=False):
if self.is_running: return if self.is_running: return
@ -46,9 +60,16 @@ class VoiceService:
if continue_last: if continue_last:
files = sorted([f for f in os.listdir(log_dir) if f.endswith(".txt")]) 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: else:
self.current_file = self._create_new_file(log_dir) self.current_file = self._create_new_file(log_dir)
with self.history_lock:
self.history = [] # Сбрасываем историю для новой сессии
self.is_running = True self.is_running = True
self._stop_event.clear() self._stop_event.clear()
@ -96,20 +117,28 @@ class VoiceService:
if np.max(np.abs(indata)) < 0.001: if np.max(np.abs(indata)) < 0.001:
return return
# Важно: indata уже в формате bytes для RawInputStream
if rec.AcceptWaveform(bytes(indata)): if rec.AcceptWaveform(bytes(indata)):
res = json.loads(rec.Result()) res = json.loads(rec.Result())
text = res.get('text', '') text = res.get('text', '')
if 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" entry = f"[{now}] {role}: {text}\n"
# Пишем в файл
try: try:
with self.lock: with self.lock:
with open(self.current_file, "a", encoding="utf-8") as f: with open(self.current_file, "a", encoding="utf-8") as f:
f.write(entry) f.write(entry)
except Exception as e: except Exception as e:
print(f"File write error: {e}") print(f"File write error: {e}")
# 2. ЗАПИСЬ В ПАМЯТЬ
with self.history_lock:
self.history.append({
'role': role,
'text': text,
'time': now
})
return callback_func return callback_func
# 4. Запуск стримов # 4. Запуск стримов
@ -136,4 +165,49 @@ class VoiceService:
print(f"❌🎤 Критическая ошибка транскрибации: {e}") print(f"❌🎤 Критическая ошибка транскрибации: {e}")
finally: finally:
self.is_running = False self.is_running = False
print("🏁🎤 Сессия транскрибации завершена") 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