Add 4th tab in voice panel with quantitive and qualitive metrics

This commit is contained in:
dimitrievgs 2026-05-11 00:42:28 +03:00
parent 2bffd28c5d
commit 526f4e7b94
6 changed files with 199 additions and 13 deletions

View File

@ -385,6 +385,7 @@ def get_voice_status():
"transcription": voice_inst.get_context_by_limit(current_obsidian_settings.get('voiceDisplayLimit', 0), order='reversed'), "transcription": voice_inst.get_context_by_limit(current_obsidian_settings.get('voiceDisplayLimit', 0), order='reversed'),
"response1": list(voice_inst.responses["regular"]), "response1": list(voice_inst.responses["regular"]),
"response2": list(voice_inst.responses["commands"]), "response2": list(voice_inst.responses["commands"]),
"metrics": voice_inst.metrics_data,
"is_running": voice_inst.is_running, "is_running": voice_inst.is_running,
"obsidian_settings_fetched": obsidian_settings_fetched "obsidian_settings_fetched": obsidian_settings_fetched
}) })

View File

@ -154,6 +154,7 @@ 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_metrics_run = time.time()
with self.vs.history_lock: with self.vs.history_lock:
self.last_gm_index = len(self.vs.history) - 1 self.last_gm_index = len(self.vs.history) - 1
self.gm_buffer = "" self.gm_buffer = ""
@ -175,6 +176,15 @@ class VoiceHeartbeatMonitor(threading.Thread):
'voiceInterval'] * 60: '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()
# 3. НОВОЕ: Интервал метрик (Графики + Вкладка 4 LLM)
if time.time() - self.last_metrics_run > obsidian_settings.get('metricsInterval', 15) * 60:
# Запускаем питоновские графики
threading.Thread(target=self.vs.generate_metrics_graphs, daemon=True).start()
# Запускаем LLM анализ качественных метрик
self._run_metrics_analysis(obsidian_settings)
self.last_metrics_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}")
@ -237,3 +247,13 @@ class VoiceHeartbeatMonitor(threading.Thread):
print(f"🎤 Запуск регулярного анализа контекста ({len(context_text)} симв.)") 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) self.generator.add_voice_regular_task(context_text, prompt)
# Метод для вызова LLM для аналитики
def _run_metrics_analysis(self, obsidian_settings):
context_text = self.vs.get_context_by_limit(obsidian_settings.get('voiceContextLimit', 30000))
if not context_text.strip(): return
prompt = obsidian_settings.get('promptMetrics')
if prompt:
print("🎤 Запуск LLM анализа качественных метрик сессии...")
self.generator.add_voice_metrics_task(context_text, prompt)

View File

@ -114,7 +114,20 @@ MODELS: Dict[str, Dict[str, Any]] = {
"include_reasoning": True "include_reasoning": True
} }
}, },
"gemini-3.1-pro-preview-bh": { "gemini-3.1-pro-openrouter": {
"name": "google/gemini-3.1-pro-preview",
"provider": "openai",
"model_name": "google/gemini-3.1-pro-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-bh": {
"name": "gemini-3.1-pro-preview", "name": "gemini-3.1-pro-preview",
"provider": "openai", # Изменено на "openai" "provider": "openai", # Изменено на "openai"
"model_name": "model_name":

View File

@ -140,6 +140,8 @@ class TitleGenerator:
self._process_voice_command(item["node_id"], item["graph_id"]) self._process_voice_command(item["node_id"], item["graph_id"])
elif item["item_type"] == "voice_regular": elif item["item_type"] == "voice_regular":
self._process_voice_regular(item["node_id"], item["graph_id"]) self._process_voice_regular(item["node_id"], item["graph_id"])
elif item["item_type"] == "voice_metrics":
self._process_voice_metrics(item["node_id"], item["graph_id"])
else: else:
# Если очередь пуста, ждем немного # Если очередь пуста, ждем немного
time.sleep(QUEUE_POLLING_INTERVAL) time.sleep(QUEUE_POLLING_INTERVAL)
@ -418,3 +420,29 @@ class TitleGenerator:
print(f"✅🎤 Голосовая команда обработана.") print(f"✅🎤 Голосовая команда обработана.")
except Exception as e: except Exception as e:
print(f"❌🎤 Ошибка обработки команды: {e}") print(f"❌🎤 Ошибка обработки команды: {e}")
# metrics
def add_voice_metrics_task(self, transcription_text, prompt, priority=10):
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_metrics", prompt, transcription_text, priority)) # graph_id = prompt, node_id = context
conn.commit()
def _process_voice_metrics(self, text, prompt):
try:
print(f"🎤 Запрос качественного анализа к LLM...")
llm_messages = [
SystemMessage(content=prompt),
HumanMessage(content=f"Транскрипция для анализа: {text}")
]
response = self.voice_llm.invoke(llm_messages)
import app.api as api
# Передаем текст в инстанс
api.voice_inst.metrics_data["text"] = response
print(f"✅🎤 Качественный анализ сессии завершен.")
except Exception as e:
print(f"❌🎤 Ошибка качественного анализа: {e}")

View File

@ -8,6 +8,14 @@ from vosk import Model, KaldiRecognizer
import sounddevice as sd import sounddevice as sd
import uuid import uuid
from collections import deque from collections import deque
import io
import base64
# Для генерации графиков на сервере без UI!
import matplotlib
matplotlib.use('Agg') # Для работы без GUI
import matplotlib.pyplot as plt
import pandas as pd
VOICE_COMMANDS_RESPONSE_TO_STORE = 3 VOICE_COMMANDS_RESPONSE_TO_STORE = 3
@ -25,6 +33,10 @@ class VoiceService:
"regular": deque(maxlen=VOICE_COMMANDS_RESPONSE_TO_STORE), "regular": deque(maxlen=VOICE_COMMANDS_RESPONSE_TO_STORE),
"commands": deque(maxlen=VOICE_COMMANDS_RESPONSE_TO_STORE) "commands": deque(maxlen=VOICE_COMMANDS_RESPONSE_TO_STORE)
} }
self.metrics_data = {
"graphs": [], # Массив Base64 строчек с HTML-декором
"text": "Анализ пока не выполнялся или недостаточно данных..."
}
self.lock = threading.Lock() self.lock = threading.Lock()
self._stop_event = threading.Event() self._stop_event = threading.Event()
self.abs_logs_path = None self.abs_logs_path = None
@ -39,8 +51,8 @@ class VoiceService:
return return
import re import re
# Регулярка для парсинга строки типа: [10:20:30.123] GM: текст реплики # Паттерн для: [10:20:30.123 -> 10:20:35.456] GM: текст
pattern = re.compile(r"\[(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\s+(GM|PCs):\s+(.*)") pattern = re.compile(r"\[(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s*(?:->|-->)\s*(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\s+(GM|PCs):\s+(.*)")
new_history = [] new_history = []
try: try:
@ -48,13 +60,13 @@ class VoiceService:
for line in f: for line in f:
match = pattern.match(line.strip()) match = pattern.match(line.strip())
if match: if match:
timestamp, role, text = match.groups() start_time, end_time, role, text = match.groups()
new_history.append({ new_history.append({
'role': role, 'role': role,
'text': text, 'text': text,
'time': timestamp 'startTime': start_time,
'endTime': end_time
}) })
with self.history_lock: with self.history_lock:
self.history = new_history self.history = new_history
print(f"Loaded {len(self.history)} events from existing log.") print(f"Loaded {len(self.history)} events from existing log.")
@ -64,6 +76,12 @@ class VoiceService:
def start_session(self, continue_last=False): def start_session(self, continue_last=False):
if self.is_running: return if self.is_running: return
# Очистка вкладок при новом старте
if not continue_last:
self.responses["regular"].clear()
self.responses["commands"].clear()
self.metrics_data = {"graphs": [], "text": "Ожидание достаточного количества данных..."}
log_dir = self.abs_logs_path if self.abs_logs_path else "audio-logs" log_dir = self.abs_logs_path if self.abs_logs_path else "audio-logs"
if not os.path.exists(log_dir): os.makedirs(log_dir) if not os.path.exists(log_dir): os.makedirs(log_dir)
@ -118,6 +136,11 @@ class VoiceService:
rec_gm = KaldiRecognizer(self.model, self.samplerate) rec_gm = KaldiRecognizer(self.model, self.samplerate)
rec_pcs = KaldiRecognizer(self.model, self.samplerate) rec_pcs = KaldiRecognizer(self.model, self.samplerate)
rec_states = {
"GM": {"start": datetime.datetime.now()},
"PCs": {"start": datetime.datetime.now()}
}
# 3. Фабрика колбэков # 3. Фабрика колбэков
def create_callback(rec, role): def create_callback(rec, role):
def callback_func(indata, frames, time_info, status): def callback_func(indata, frames, time_info, status):
@ -130,10 +153,18 @@ class VoiceService:
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") end_dt = datetime.datetime.now()
start_dt = rec_states[role]["start"]
# 1. Запись в файл (как и было) # Форматируем с миллисекундами
entry = f"[{now}] {role}: {text}\n" start_str = start_dt.strftime("%H:%M:%S.%f")[:-3]
end_str = end_dt.strftime("%H:%M:%S.%f")[:-3]
# Обновляем старт для следующей фразы
rec_states[role]["start"] = end_dt
# 1. Запись в файл с двумя метками
entry = f"[{start_str} --> {end_str}] {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:
@ -141,12 +172,13 @@ class VoiceService:
except Exception as e: except Exception as e:
print(f"File write error: {e}") print(f"File write error: {e}")
# 2. ЗАПИСЬ В ПАМЯТЬ # 2. Запись в память
with self.history_lock: with self.history_lock:
self.history.append({ self.history.append({
'role': role, 'role': role,
'text': text, 'text': text,
'time': now 'startTime': start_str,
'endTime': end_str
}) })
return callback_func return callback_func
@ -220,3 +252,92 @@ class VoiceService:
] ]
return " ".join(new_phrases), current_max_index return " ".join(new_phrases), current_max_index
def generate_metrics_graphs(self):
"""Анализирует history и генерирует графики (Matplotlib -> Base64)"""
with self.history_lock:
if not self.history: return
history_copy = list(self.history)
if len(history_copy) < 3:
return
try:
df = pd.DataFrame(history_copy)
df['tokens'] = df['text'].apply(lambda x: len(str(x).split()))
df['chars'] = df['text'].apply(lambda x: len(str(x)))
stats = df.groupby('role').agg(
chars_total=('chars', 'sum'),
tokens_total=('tokens', 'sum'),
replica_count=('text', 'count')
)
total_tokens = stats['tokens_total'].sum()
stats['percent_tokens'] = (stats['tokens_total'] / total_tokens * 100).fillna(0)
stats['avg_len'] = (stats['tokens_total'] / stats['replica_count']).fillna(0)
# Расчет WPM (Скорость речи)
wpm_data = {}
for role in stats.index:
role_df = df[df['role'] == role]
total_duration_sec = 0.0
for _, row in role_df.iterrows():
try:
t_format = "%H:%M:%S.%f" if "." in row['startTime'] else "%H:%M:%S"
e_format = "%H:%M:%S.%f" if "." in row['endTime'] else "%H:%M:%S"
t1 = datetime.datetime.strptime(row['startTime'], t_format)
t2 = datetime.datetime.strptime(row['endTime'], e_format)
total_duration_sec += (t2 - t1).total_seconds()
except: pass
duration_min = total_duration_sec / 60.0
wpm_data[role] = stats.loc[role, 'tokens_total'] / max(duration_min, 0.05)
generated_images = []
def fig_to_base64(fig):
buf = io.BytesIO()
fig.savefig(buf, format='png', bbox_inches='tight', dpi=100)
plt.close(fig)
buf.seek(0)
return base64.b64encode(buf.read()).decode('utf-8')
# --- ПЕРВАЯ КАРТИНКА: Распределение (слева) и Средняя длина (справа) ---
fig1, (ax1_1, ax1_2) = plt.subplots(1, 2, figsize=(12, 5))
# Слева: Распределение реплик (%)
ax1_1.barh(stats.index, stats["percent_tokens"], color="skyblue", alpha=0.7)
ax1_1.set_title("Распределение реплик (по словам, %)")
ax1_1.set_xlim(0, 100)
ax1_1.grid(axis="x", linestyle="--", alpha=0.6)
# Справа: Средняя длина
ax1_2.bar(stats.index, stats["avg_len"], color="teal", alpha=0.6)
ax1_2.set_title("Средняя длина (слов на реплику)")
fig1.tight_layout()
generated_images.append(f'<img src="data:image/png;base64,{fig_to_base64(fig1)}" style="max-width:100%; border-radius: 8px; margin-bottom: 12px;"/>')
# --- ВТОРАЯ КАРТИНКА: Общее кол-во (слева) и Скорость речи (справа) ---
fig2, (ax2_1, ax2_2) = plt.subplots(1, 2, figsize=(12, 5))
# Слева: Общее количество реплик
ax2_1.bar(stats.index, stats["replica_count"], color="coral", alpha=0.6)
ax2_1.set_title("Общее кол-во реплик")
# Справа: Скорость речи (WPM)
roles_list = stats.index.tolist()
wpm_values = [wpm_data.get(r, 0) for r in roles_list]
ax2_2.bar(roles_list, wpm_values, color='#9b59b6', alpha=0.7)
ax2_2.set_title("Скорость речи (WPM)")
ax2_2.set_ylabel("Слов в минуту")
ax2_2.grid(axis='y', linestyle='--', alpha=0.6)
fig2.tight_layout()
generated_images.append(f'<img src="data:image/png;base64,{fig_to_base64(fig2)}" style="max-width:100%; border-radius: 8px; margin-bottom: 12px;"/>')
self.metrics_data["graphs"] = generated_images
print("✅ Аналитика (сдвоенные графики) обновлена.")
except Exception as e:
print(f"❌ Ошибка генерации графиков: {e}")

View File

@ -17,3 +17,6 @@ selenium
vosk==0.3.45 vosk==0.3.45
sounddevice==0.5.1 sounddevice==0.5.1
numpy==1.26.4 numpy==1.26.4
matplotlib==3.8.4
pandas==2.2.2