feat: автоматическое извлечение фактов через ИИ + команды /facts и /forget

Новые возможности:
- Автоматическое извлечение фактов каждые 5 сообщений диалога
- ИИ анализирует диалог и извлекает факты по категориям (личное, технологии, проекты, предпочтения)
- Команда /facts — показать все сохранённые факты
- Команда /forget <номер> — удалить факт
- Счётчик сообщений для триггера извлечения (messages_since_fact_extract)

Архитектура:
- Эвристики (мгновенно): простые паттерны типа 'меня зовут...', 'я использую...'
- ИИ (каждые 5 сообщений): анализ последних 10 сообщений, JSON-ответ с фактами
- ChromaDB: все сообщения для семантического поиска
- SQLite (facts): извлечённые факты с категориями и уверенностью

Промпт для ИИ:
- Категории: PERSONAL, TECHNICAL, PROJECT, PREFERENCE, OTHER
- Формат: JSON с type, content, confidence
- Только явные факты из диалога

Version: 0.4.0

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-02-24 23:08:27 +08:00
parent b90b4ed77d
commit ca6721090c
2 changed files with 215 additions and 3 deletions

108
bot.py
View File

@ -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))

View File

@ -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:
"""Сформировать контекст для передачи ИИ."""