diff --git a/bot.py b/bot.py index 58d293c..80acb97 100644 --- a/bot.py +++ b/bot.py @@ -317,6 +317,7 @@ class UserState: editing_server: Optional[str] = None # Имя сервера, который редактируем ai_chat_mode: bool = False # Режим чата с ИИ агентом ai_chat_history: List[str] = field(default_factory=list) # История диалога с ИИ + messages_since_fact_extract: int = 0 # Счётчик для извлечения фактов class StateManager: @@ -2013,6 +2014,14 @@ async def handle_ai_task(update: Update, text: str): if len(full_output) > 3500: full_output = full_output[:3500] + "\n... (вывод обрезан)" + # Автоматическое извлечение фактов каждые 5 сообщений + state.messages_since_fact_extract += 1 + if state.messages_since_fact_extract >= 5: + logger.info(f"Запуск извлечения фактов через ИИ для пользователя {user_id}") + dialog_context = "\n".join(state.ai_chat_history[-10:]) # Последние 10 сообщений + asyncio.create_task(hybrid_memory_manager.extract_facts_with_ai(user_id, dialog_context)) + state.messages_since_fact_extract = 0 + # Формируем сообщение с информацией о контексте (как в qwen-code) context_info = f"📊 Контекст: {context_percent}%" response_text = f"{full_output}\n\n_{context_info}_" @@ -2900,6 +2909,8 @@ async def post_init(application: Application): BotCommand("stop", "Прервать SSH-сессию"), BotCommand("ai", "Задача для Qwen Code AI"), BotCommand("memory", "Статистика памяти ИИ"), + BotCommand("facts", "Показать сохранённые факты"), + BotCommand("forget", "Удалить факт по номеру"), ] await application.bot.set_my_commands(commands) @@ -3046,10 +3057,105 @@ async def memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE): ) text += "\n_Память использует SQLite + ChromaDB с семантическим поиском._" + + await update.message.reply_text(text, parse_mode="Markdown") + + +@check_access +async def facts_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /facts — показать сохранённые факты.""" + user_id = update.effective_user.id + + # Получаем факты из SQLite + from memory_system import memory_manager + facts = memory_manager.storage.get_facts(user_id) + + if not facts: + await update.message.reply_text( + "📋 *Ваши факты*\n\n" + "Пока нет сохранённых фактов.\n" + "Общайтесь с ИИ в чате — он автоматически запомнит важное!", + parse_mode="Markdown" + ) + return + + # Группируем по типам + from memory_system import FactType + grouped = {} + for fact in facts: + type_name = fact.fact_type.value + if type_name not in grouped: + grouped[type_name] = [] + grouped[type_name].append(fact) + + # Формируем сообщение + type_names_ru = { + "personal": "👤 Личное", + "technical": "💻 Технологии", + "project": "📁 Проекты", + "preference": "⭐ Предпочтения", + "other": "📌 Другое" + } + + text = "📋 *Ваши сохранённые факты:*\n\n" + + for type_name, type_facts in grouped.items(): + type_title = type_names_ru.get(type_name, type_name) + text += f"*{type_title}* ({len(type_facts)}):\n" + + for i, fact in enumerate(type_facts, 1): + # Обрезаем длинные факты + content = fact.content + if len(content) > 100: + content = content[:100] + "..." + text += f" {i}. {content}\n" + + text += "\n" + + text += f"_Всего: {len(facts)} фактов_\n" + text += "_Для удаления факта используйте `/forget <номер>`_" await update.message.reply_text(text, parse_mode="Markdown") +@check_access +async def forget_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /forget — удалить факт.""" + user_id = update.effective_user.id + + if not context.args or not context.args[0].isdigit(): + await update.message.reply_text( + "❌ *Использование:*\n" + "`/forget <номер>`\n\n" + "Сначала вызовите `/facts` чтобы увидеть список.", + parse_mode="Markdown" + ) + return + + # Получаем факты + from memory_system import memory_manager + facts = memory_manager.storage.get_facts(user_id) + + fact_index = int(context.args[0]) - 1 + + if fact_index < 0 or fact_index >= len(facts): + await update.message.reply_text( + f"❌ Факт с номером {fact_index + 1} не найден.\n" + f"Всего фактов: {len(facts)}", + parse_mode="Markdown" + ) + return + + # Удаляем факт + fact_to_delete = facts[fact_index] + memory_manager.storage.update_fact(fact_to_delete.id, is_active=False) + + await update.message.reply_text( + f"✅ Факт удалён:\n_{fact_to_delete.content}_", + parse_mode="Markdown" + ) + + def main(): """Точка входа.""" # Чтение токена только из переменной окружения @@ -3113,6 +3219,8 @@ def main(): application.add_handler(CommandHandler("menu", menu_command)) application.add_handler(CommandHandler("stop", stop_command)) application.add_handler(CommandHandler("memory", memory_command)) + application.add_handler(CommandHandler("facts", facts_command)) + application.add_handler(CommandHandler("forget", forget_command)) application.add_handler(CallbackQueryHandler(menu_callback)) application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message)) application.add_handler(CommandHandler("ai", ai_command)) diff --git a/vector_memory.py b/vector_memory.py index 7ed3799..9e89728 100644 --- a/vector_memory.py +++ b/vector_memory.py @@ -391,14 +391,14 @@ class HybridMemoryManager: return profile def extract_and_save_facts(self, user_id: int, message: str, response: str = None): - """Извлечь факты из сообщения и сохранить.""" + """Извлечь факты из сообщения (эвристики + ИИ).""" import re from memory_system import Fact, FactType extracted = [] message_lower = message.lower() - # Имя + # Эвристики для быстрых простых фактов if "меня зовут" in message_lower: parts = message.split("меня зовут") if len(parts) > 1: @@ -436,7 +436,111 @@ class HybridMemoryManager: extracted.append(fact) if extracted: - logger.info(f"Извлечено {len(extracted)} фактов для пользователя {user_id}") + logger.info(f"Извлечено {len(extracted)} фактов (эвристики) для пользователя {user_id}") + + async def extract_facts_with_ai(self, user_id: int, dialog_context: str) -> List: + """ + Извлечь факты из диалога с помощью ИИ. + + Args: + user_id: ID пользователя + dialog_context: Текст диалога для анализа + + Returns: + Список сохранённых фактов + """ + from memory_system import Fact, FactType + import json + import re + + # Промпт для извлечения фактов + prompt = f""" +Проанализируй диалог и извлеки факты о пользователе. + +Диалог: +{dialog_context} + +Извлеки факты по категориям: +1. **PERSONAL** — имя, возраст, город, профессия, предпочтения +2. **TECHNICAL** — технологии, языки программирования, инструменты, стек +3. **PROJECT** — проекты, репозитории, директории, домены +4. **PREFERENCE** — предпочтения в коде, стиле, инструментах + +Верни ответ ТОЛЬКО в формате JSON: +{{ + "facts": [ + {{"type": "personal", "content": "Пользователя зовут Владимир", "confidence": 0.9}}, + {{"type": "technical", "content": "Использует Python и Docker", "confidence": 0.8}}, + {{"type": "project", "content": "Проект telegram-cli-bot в ~/git/", "confidence": 0.7}} + ] +}} + +Если фактов нет — верни {{"facts": []}} +Не выдумывай факты. Только то, что явно указано в диалоге. +""" + + try: + # Импортируем qwen_manager + from qwen_integration import qwen_manager + + output_buffer = [] + def on_output(text): + output_buffer.append(text) + + # Выполняем задачу + await qwen_manager.run_task(user_id, prompt, on_output, lambda x: None) + + result = "".join(output_buffer).strip() + + # Парсим JSON из ответа + json_match = re.search(r'\{.*\}', result, re.DOTALL) + if not json_match: + logger.warning(f"Не найден JSON в ответе ИИ: {result[:200]}") + return [] + + data = json.loads(json_match.group(0)) + facts_data = data.get("facts", []) + + if not facts_data: + logger.info(f"ИИ не нашёл фактов для пользователя {user_id}") + return [] + + # Сохраняем факты + extracted = [] + for fact_item in facts_data: + fact_type_str = fact_item.get("type", "other").lower() + + # Маппинг типов + type_mapping = { + "personal": FactType.PERSONAL, + "technical": FactType.TECHNICAL, + "project": FactType.PROJECT, + "preference": FactType.PREFERENCE, + "other": FactType.OTHER + } + fact_type = type_mapping.get(fact_type_str, FactType.OTHER) + + content = fact_item.get("content", "").strip() + confidence = float(fact_item.get("confidence", 0.5)) + + if content and len(content) > 5: # Пропускаем слишком короткие + fact = Fact( + id=None, + user_id=user_id, + fact_type=fact_type, + content=content, + source_message=dialog_context[:500], # Первые 500 символов + confidence=confidence + ) + self.sqlite.save_fact(fact) + extracted.append(fact) + + logger.info(f"Извлечено {len(extracted)} фактов (ИИ) для пользователя {user_id}") + return extracted + + except Exception as e: + logger.error(f"Ошибка извлечения фактов через ИИ: {e}") + return [] def format_context_for_ai(self, user_id: int, query: str = None) -> str: """Сформировать контекст для передачи ИИ."""