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:
parent
b90b4ed77d
commit
ca6721090c
108
bot.py
108
bot.py
|
|
@ -317,6 +317,7 @@ class UserState:
|
||||||
editing_server: Optional[str] = None # Имя сервера, который редактируем
|
editing_server: Optional[str] = None # Имя сервера, который редактируем
|
||||||
ai_chat_mode: bool = False # Режим чата с ИИ агентом
|
ai_chat_mode: bool = False # Режим чата с ИИ агентом
|
||||||
ai_chat_history: List[str] = field(default_factory=list) # История диалога с ИИ
|
ai_chat_history: List[str] = field(default_factory=list) # История диалога с ИИ
|
||||||
|
messages_since_fact_extract: int = 0 # Счётчик для извлечения фактов
|
||||||
|
|
||||||
|
|
||||||
class StateManager:
|
class StateManager:
|
||||||
|
|
@ -2013,6 +2014,14 @@ async def handle_ai_task(update: Update, text: str):
|
||||||
if len(full_output) > 3500:
|
if len(full_output) > 3500:
|
||||||
full_output = full_output[:3500] + "\n... (вывод обрезан)"
|
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)
|
# Формируем сообщение с информацией о контексте (как в qwen-code)
|
||||||
context_info = f"📊 Контекст: {context_percent}%"
|
context_info = f"📊 Контекст: {context_percent}%"
|
||||||
response_text = f"{full_output}\n\n_{context_info}_"
|
response_text = f"{full_output}\n\n_{context_info}_"
|
||||||
|
|
@ -2900,6 +2909,8 @@ async def post_init(application: Application):
|
||||||
BotCommand("stop", "Прервать SSH-сессию"),
|
BotCommand("stop", "Прервать SSH-сессию"),
|
||||||
BotCommand("ai", "Задача для Qwen Code AI"),
|
BotCommand("ai", "Задача для Qwen Code AI"),
|
||||||
BotCommand("memory", "Статистика памяти ИИ"),
|
BotCommand("memory", "Статистика памяти ИИ"),
|
||||||
|
BotCommand("facts", "Показать сохранённые факты"),
|
||||||
|
BotCommand("forget", "Удалить факт по номеру"),
|
||||||
]
|
]
|
||||||
await application.bot.set_my_commands(commands)
|
await application.bot.set_my_commands(commands)
|
||||||
|
|
||||||
|
|
@ -3050,6 +3061,101 @@ async def memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
await update.message.reply_text(text, parse_mode="Markdown")
|
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():
|
def main():
|
||||||
"""Точка входа."""
|
"""Точка входа."""
|
||||||
# Чтение токена только из переменной окружения
|
# Чтение токена только из переменной окружения
|
||||||
|
|
@ -3113,6 +3219,8 @@ def main():
|
||||||
application.add_handler(CommandHandler("menu", menu_command))
|
application.add_handler(CommandHandler("menu", menu_command))
|
||||||
application.add_handler(CommandHandler("stop", stop_command))
|
application.add_handler(CommandHandler("stop", stop_command))
|
||||||
application.add_handler(CommandHandler("memory", memory_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(CallbackQueryHandler(menu_callback))
|
||||||
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message))
|
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message))
|
||||||
application.add_handler(CommandHandler("ai", ai_command))
|
application.add_handler(CommandHandler("ai", ai_command))
|
||||||
|
|
|
||||||
110
vector_memory.py
110
vector_memory.py
|
|
@ -391,14 +391,14 @@ class HybridMemoryManager:
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
def extract_and_save_facts(self, user_id: int, message: str, response: str = None):
|
def extract_and_save_facts(self, user_id: int, message: str, response: str = None):
|
||||||
"""Извлечь факты из сообщения и сохранить."""
|
"""Извлечь факты из сообщения (эвристики + ИИ)."""
|
||||||
import re
|
import re
|
||||||
from memory_system import Fact, FactType
|
from memory_system import Fact, FactType
|
||||||
|
|
||||||
extracted = []
|
extracted = []
|
||||||
message_lower = message.lower()
|
message_lower = message.lower()
|
||||||
|
|
||||||
# Имя
|
# Эвристики для быстрых простых фактов
|
||||||
if "меня зовут" in message_lower:
|
if "меня зовут" in message_lower:
|
||||||
parts = message.split("меня зовут")
|
parts = message.split("меня зовут")
|
||||||
if len(parts) > 1:
|
if len(parts) > 1:
|
||||||
|
|
@ -436,7 +436,111 @@ class HybridMemoryManager:
|
||||||
extracted.append(fact)
|
extracted.append(fact)
|
||||||
|
|
||||||
if extracted:
|
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:
|
def format_context_for_ai(self, user_id: int, query: str = None) -> str:
|
||||||
"""Сформировать контекст для передачи ИИ."""
|
"""Сформировать контекст для передачи ИИ."""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue