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():
# 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,

View File

@ -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)
self.generator.add_voice_regular_task(context_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"
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]]:
"""Получает следующий элемент из очереди для обработки."""

View File

@ -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("🏁🎤 Сессия транскрибации завершена")
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