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 # Имя сервера, который редактируем
|
||||
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))
|
||||
|
|
|
|||
110
vector_memory.py
110
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:
|
||||
"""Сформировать контекст для передачи ИИ."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue