diff --git a/app/api.py b/app/api.py
index 9a5e390..1c95645 100644
--- a/app/api.py
+++ b/app/api.py
@@ -385,6 +385,7 @@ def get_voice_status():
"transcription": voice_inst.get_context_by_limit(current_obsidian_settings.get('voiceDisplayLimit', 0), order='reversed'),
"response1": list(voice_inst.responses["regular"]),
"response2": list(voice_inst.responses["commands"]),
+ "metrics": voice_inst.metrics_data,
"is_running": voice_inst.is_running,
"obsidian_settings_fetched": obsidian_settings_fetched
})
diff --git a/app/heartbeat_monitor.py b/app/heartbeat_monitor.py
index 831bd1e..125c1d4 100644
--- a/app/heartbeat_monitor.py
+++ b/app/heartbeat_monitor.py
@@ -154,6 +154,7 @@ class VoiceHeartbeatMonitor(threading.Thread):
self.generator = generator
self.get_obsidian_settings = get_obsidian_settings_fn # Функция, возвращающая текущий конфиг
self.last_regular_run = time.time()
+ self.last_metrics_run = time.time()
with self.vs.history_lock:
self.last_gm_index = len(self.vs.history) - 1
self.gm_buffer = ""
@@ -175,6 +176,15 @@ class VoiceHeartbeatMonitor(threading.Thread):
'voiceInterval'] * 60:
self._run_regular_analysis(obsidian_settings)
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 секунды проверяем
except Exception as e:
logger.critical(f"❌🎤 Voice Heartbeat: Неожиданная ошибка: {e}")
@@ -237,3 +247,13 @@ class VoiceHeartbeatMonitor(threading.Thread):
print(f"🎤 Запуск регулярного анализа контекста ({len(context_text)} симв.)")
prompt = f"{(obsidian_settings.get('systemPrompt') or '')}\n\n{(obsidian_settings.get('promptRegular') or '')}"
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)
\ No newline at end of file
diff --git a/app/llm_client.py b/app/llm_client.py
index 15c71bc..91594ee 100644
--- a/app/llm_client.py
+++ b/app/llm_client.py
@@ -114,7 +114,20 @@ MODELS: Dict[str, Dict[str, Any]] = {
"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",
"provider": "openai", # Изменено на "openai"
"model_name":
diff --git a/app/title_generator.py b/app/title_generator.py
index f9f652a..994ff0f 100644
--- a/app/title_generator.py
+++ b/app/title_generator.py
@@ -140,6 +140,8 @@ class TitleGenerator:
self._process_voice_command(item["node_id"], item["graph_id"])
elif item["item_type"] == "voice_regular":
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:
# Если очередь пуста, ждем немного
time.sleep(QUEUE_POLLING_INTERVAL)
@@ -417,4 +419,30 @@ class TitleGenerator:
print(f"✅🎤 Голосовая команда обработана.")
except Exception as e:
- print(f"❌🎤 Ошибка обработки команды: {e}")
\ No newline at end of file
+ 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}")
\ No newline at end of file
diff --git a/app/voice_service.py b/app/voice_service.py
index 504c42a..8e3ca89 100644
--- a/app/voice_service.py
+++ b/app/voice_service.py
@@ -8,6 +8,14 @@ from vosk import Model, KaldiRecognizer
import sounddevice as sd
import uuid
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
@@ -25,6 +33,10 @@ class VoiceService:
"regular": 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._stop_event = threading.Event()
self.abs_logs_path = None
@@ -39,8 +51,8 @@ class VoiceService:
return
import re
- # Регулярка для парсинга строки типа: [10:20:30.123] GM: текст реплики
- pattern = re.compile(r"\[(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\s+(GM|PCs):\s+(.*)")
+ # Паттерн для: [10:20:30.123 -> 10:20:35.456] GM: текст
+ 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 = []
try:
@@ -48,13 +60,13 @@ class VoiceService:
for line in f:
match = pattern.match(line.strip())
if match:
- timestamp, role, text = match.groups()
+ start_time, end_time, role, text = match.groups()
new_history.append({
'role': role,
'text': text,
- 'time': timestamp
+ 'startTime': start_time,
+ 'endTime': end_time
})
-
with self.history_lock:
self.history = new_history
print(f"Loaded {len(self.history)} events from existing log.")
@@ -64,6 +76,12 @@ class VoiceService:
def start_session(self, continue_last=False):
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"
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_pcs = KaldiRecognizer(self.model, self.samplerate)
+ rec_states = {
+ "GM": {"start": datetime.datetime.now()},
+ "PCs": {"start": datetime.datetime.now()}
+ }
+
# 3. Фабрика колбэков
def create_callback(rec, role):
def callback_func(indata, frames, time_info, status):
@@ -130,10 +153,18 @@ class VoiceService:
res = json.loads(rec.Result())
text = res.get('text', '')
if text:
- now = datetime.datetime.now().strftime("%H:%M:%S")
+ end_dt = datetime.datetime.now()
+ start_dt = rec_states[role]["start"]
+
+ # Форматируем с миллисекундами
+ start_str = start_dt.strftime("%H:%M:%S.%f")[:-3]
+ end_str = end_dt.strftime("%H:%M:%S.%f")[:-3]
- # 1. Запись в файл (как и было)
- entry = f"[{now}] {role}: {text}\n"
+ # Обновляем старт для следующей фразы
+ rec_states[role]["start"] = end_dt
+
+ # 1. Запись в файл с двумя метками
+ entry = f"[{start_str} --> {end_str}] {role}: {text}\n"
try:
with self.lock:
with open(self.current_file, "a", encoding="utf-8") as f:
@@ -141,12 +172,13 @@ class VoiceService:
except Exception as e:
print(f"File write error: {e}")
- # 2. ЗАПИСЬ В ПАМЯТЬ
+ # 2. Запись в память
with self.history_lock:
self.history.append({
'role': role,
'text': text,
- 'time': now
+ 'startTime': start_str,
+ 'endTime': end_str
})
return callback_func
@@ -220,3 +252,92 @@ class VoiceService:
]
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'
')
+
+ # --- ВТОРАЯ КАРТИНКА: Общее кол-во (слева) и Скорость речи (справа) ---
+ 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'
')
+
+ self.metrics_data["graphs"] = generated_images
+ print("✅ Аналитика (сдвоенные графики) обновлена.")
+
+ except Exception as e:
+ print(f"❌ Ошибка генерации графиков: {e}")
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index b970ad2..0a2353a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,4 +16,7 @@ selenium
vosk==0.3.45
sounddevice==0.5.1
-numpy==1.26.4
\ No newline at end of file
+numpy==1.26.4
+
+matplotlib==3.8.4
+pandas==2.2.2
\ No newline at end of file