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