Add 4th tab in voice panel with quantitive and qualitive metrics
This commit is contained in:
parent
2bffd28c5d
commit
526f4e7b94
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user