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:
parent
6ce3c9781b
commit
b30ca3cc14
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -154,9 +154,15 @@ class VoiceHeartbeatMonitor(threading.Thread):
|
||||||
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,7 +171,8 @@ 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 секунды проверяем
|
||||||
|
|
@ -173,42 +180,60 @@ class VoiceHeartbeatMonitor(threading.Thread):
|
||||||
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)
|
||||||
|
|
||||||
|
# Добавляем в буфер (через пробел, чтобы не склеились слова)
|
||||||
|
if new_text:
|
||||||
|
self.gm_buffer = (self.gm_buffer + " " + new_text).strip()
|
||||||
|
self.last_gm_index = new_index
|
||||||
|
|
||||||
|
if not self.gm_buffer:
|
||||||
|
return
|
||||||
|
|
||||||
import re
|
import re
|
||||||
# Чистим таймстемпы Vosk для LLM (удаляем паттерны [00:00:00])
|
start_marker = obsidian_settings['markerStart']
|
||||||
clean_text = re.sub(r'\[.*?\]', '', raw_text)
|
stop_marker = obsidian_settings['markerStop']
|
||||||
|
|
||||||
pattern = f"{re.escape(obsidian_settings['markerStart'])}(.*?){re.escape(obsidian_settings['markerStop'])}"
|
# Регулярка для поиска самой короткой подходящей пары (нежадный поиск)
|
||||||
matches = re.findall(pattern, clean_text, re.DOTALL)
|
pattern = f"{re.escape(start_marker)}(.*?){re.escape(stop_marker)}"
|
||||||
|
|
||||||
for match in matches:
|
last_stop_pos = 0
|
||||||
cmd = match.strip()
|
# finditer позволяет получить позиции вхождений
|
||||||
# Проверяем, не обрабатывали ли мы ЭТУ конкретную строку только что
|
matches = list(re.finditer(pattern, self.gm_buffer, re.DOTALL))
|
||||||
# Используем множество для хранения хешей обработанных команд
|
|
||||||
if not hasattr(self, '_processed_hashes'): self._processed_hashes = set()
|
|
||||||
|
|
||||||
cmd_hash = hash(cmd)
|
if matches:
|
||||||
if cmd_hash not in self._processed_hashes:
|
for m in matches:
|
||||||
print(f"🎤 New Voice Command detected: {cmd}")
|
cmd = m.group(1).strip()
|
||||||
|
if cmd:
|
||||||
# ОТПРАВЛЯЕМ В LLM через TitleGenerator
|
print(f"🎤 New GM Voice Command detected: {cmd}")
|
||||||
prompt = f"{(obsidian_settings.get('systemPrompt') or '')}\n\n{(obsidian_settings.get('promptCommands') or '')}"
|
prompt = f"{(obsidian_settings.get('systemPrompt') or '')}\n\n{(obsidian_settings.get('promptCommands') or '')}"
|
||||||
self.generator.add_voice_task_to_queue(
|
self.generator.add_voice_task_to_queue(cmd, prompt)
|
||||||
cmd,
|
|
||||||
prompt
|
|
||||||
)
|
|
||||||
|
|
||||||
self._processed_hashes.add(cmd_hash)
|
# Запоминаем позицию конца последнего найденного стоп-маркера
|
||||||
# Ограничиваем размер множества, чтобы не росло бесконечно
|
last_stop_pos = m.end()
|
||||||
if len(self._processed_hashes) > 100: self._processed_hashes.clear() # Спорно, очень спорно
|
|
||||||
|
# ОБРЕЗАЕМ БУФЕР: оставляем только то, что идет за последним стоп-маркером
|
||||||
|
# (там может быть начало следующей команды "старт команда 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):
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -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]]:
|
||||||
"""Получает следующий элемент из очереди для обработки."""
|
"""Получает следующий элемент из очереди для обработки."""
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
f.seek(0, os.SEEK_END)
|
for line in f:
|
||||||
size = f.tell()
|
match = pattern.match(line.strip())
|
||||||
# Читаем последние байты с запасом (UTF-8 до 4 байт на символ)
|
if match:
|
||||||
f.seek(max(0, size - n * 4))
|
timestamp, role, text = match.groups()
|
||||||
content = f.read()
|
new_history.append({
|
||||||
return content[-n:]
|
'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:
|
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:
|
else:
|
||||||
self.current_file = self._create_new_file(log_dir)
|
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.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. Запуск стримов
|
||||||
|
|
@ -137,3 +166,48 @@ class VoiceService:
|
||||||
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user