summarize_memory.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. """对话记忆总结服务(定时扫描)
  2. 从数据库读取需要总结的用户对话,使用 LLM 生成总结并更新记忆。
  3. Prerequisites:
  4. - Python >= 3.7
  5. - 配置文件:summarize.conf(与脚本同目录)
  6. - 必需配置:[database] 下的数据库配置
  7. - 必需配置:[ai] 下的 DashScope API Key
  8. 运行: python summarize_memory.py
  9. """
  10. import asyncio
  11. import configparser
  12. import os
  13. import sys
  14. import logging
  15. from datetime import datetime, timedelta
  16. from pathlib import Path
  17. from typing import List, Dict, Optional
  18. from openai import OpenAI
  19. from sqlalchemy import select, func
  20. # 加载配置文件
  21. config_file = Path(__file__).parent / "summarize.conf"
  22. config = configparser.ConfigParser()
  23. if config_file.exists():
  24. config.read(config_file, encoding='utf-8')
  25. else:
  26. raise FileNotFoundError(f"配置文件不存在: {config_file}")
  27. # 导入数据库相关模块(需要从 backend 目录导入)
  28. backend_dir = Path(__file__).parent.parent / "backend"
  29. sys.path.insert(0, str(backend_dir))
  30. from database import DatabaseManager
  31. from models_db import User, Message, ConversationSummary
  32. # 导入记忆服务
  33. sys.path.insert(0, str(Path(__file__).parent))
  34. from memory_service import (
  35. load_memory,
  36. save_memory,
  37. extract_and_update_memory_session_level,
  38. build_session_snippet,
  39. )
  40. logger = logging.getLogger(__name__)
  41. # ------- 从配置文件读取参数 -------
  42. # 数据库配置
  43. MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
  44. MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
  45. MYSQL_USER = config.get("database", "mysql_user", fallback="").strip()
  46. MYSQL_PASSWORD = config.get("database", "mysql_password", fallback="").strip()
  47. MYSQL_DATABASE = config.get("database", "mysql_database", fallback="").strip()
  48. # AI配置
  49. DASHSCOPE_API_KEY = config.get("ai", "dashscope_api_key", fallback="").strip()
  50. AI_MODEL = config.get("ai", "model", fallback="qwen-flash-2025-07-28").strip()
  51. # 服务配置
  52. POLL_INTERVAL = float(config.get("service", "poll_interval", fallback="60.0"))
  53. SUMMARY_DELAY_HOURS = float(config.get("service", "summary_delay_hours", fallback="1.0"))
  54. # 初始化 OpenAI 客户端(DashScope兼容模式)
  55. client = None
  56. if DASHSCOPE_API_KEY:
  57. client = OpenAI(
  58. api_key=DASHSCOPE_API_KEY,
  59. base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
  60. )
  61. async def find_users_need_summary(db_manager: DatabaseManager) -> List[Dict]:
  62. """
  63. 查找需要总结的用户
  64. 条件:
  65. 1. last_active_at 距离现在 >= SUMMARY_DELAY_HOURS 小时
  66. 2. total_rounds > 上一次总结的 end_round(有未总结的对话)
  67. Returns:
  68. 需要总结的用户列表,每个元素包含 user_id, last_summary_end_round, total_rounds
  69. """
  70. try:
  71. cutoff_time = datetime.now() - timedelta(hours=SUMMARY_DELAY_HOURS)
  72. async with db_manager.get_session() as session:
  73. # 查询符合条件的用户(last_active_at < cutoff_time,确保至少1小时过去)
  74. users_query = await session.execute(
  75. select(User)
  76. .where(
  77. User.last_active_at.isnot(None),
  78. User.last_active_at < cutoff_time, # ✅ 使用 < 确保至少1小时过去
  79. User.total_rounds > 0
  80. )
  81. )
  82. users = users_query.scalars().all()
  83. if not users:
  84. return []
  85. result = []
  86. for user in users:
  87. user_id = user.user_id
  88. total_rounds = user.total_rounds or 0
  89. # 查询该用户的最新总结
  90. summary_query = await session.execute(
  91. select(ConversationSummary)
  92. .where(ConversationSummary.user_id == user_id)
  93. .order_by(ConversationSummary.end_round.desc())
  94. .limit(1)
  95. )
  96. latest_summary = summary_query.scalar_one_or_none()
  97. last_summary_end_round = 0
  98. if latest_summary:
  99. last_summary_end_round = latest_summary.end_round
  100. # 如果有未总结的对话
  101. if total_rounds > last_summary_end_round:
  102. result.append({
  103. "user_id": user_id,
  104. "last_summary_end_round": last_summary_end_round,
  105. "total_rounds": total_rounds,
  106. "last_active_at": user.last_active_at,
  107. })
  108. return result
  109. except Exception as e:
  110. logger.error(f"❌ 查找需要总结的用户失败: {e}", exc_info=True)
  111. return []
  112. async def get_messages_for_summary(
  113. db_manager: DatabaseManager,
  114. user_id: str,
  115. start_round: int,
  116. end_round: int
  117. ) -> List[Dict]:
  118. """
  119. 获取需要总结的消息
  120. Args:
  121. db_manager: 数据库管理器
  122. user_id: 用户ID
  123. start_round: 起始轮次(不包含,即 start_round + 1)
  124. end_round: 结束轮次(包含)
  125. Returns:
  126. 消息列表,格式: [{"role": "user|assistant", "content": "...", "round_number": 1}, ...]
  127. """
  128. try:
  129. async with db_manager.get_session() as session:
  130. messages_query = await session.execute(
  131. select(Message)
  132. .where(
  133. Message.user_id == user_id,
  134. Message.round_number > start_round,
  135. Message.round_number <= end_round
  136. )
  137. .order_by(Message.round_number.asc(), Message.id.asc())
  138. )
  139. messages_list = messages_query.scalars().all()
  140. messages = [
  141. {
  142. "role": msg.role,
  143. "content": msg.content,
  144. "round_number": msg.round_number,
  145. }
  146. for msg in messages_list
  147. ]
  148. return messages
  149. except Exception as e:
  150. logger.error(f"❌ 获取消息失败: user_id={user_id}, error={e}", exc_info=True)
  151. return []
  152. async def process_user_summary(
  153. user_id: str,
  154. last_summary_end_round: int,
  155. total_rounds: int,
  156. db_manager: DatabaseManager
  157. ) -> bool:
  158. """
  159. 处理单个用户的总结
  160. Args:
  161. user_id: 用户ID
  162. last_summary_end_round: 上一次总结的结束轮次
  163. total_rounds: 用户当前总轮次
  164. db_manager: 数据库管理器
  165. Returns:
  166. 是否成功
  167. """
  168. try:
  169. start_round = last_summary_end_round
  170. end_round = total_rounds
  171. logger.info(f"🔄 开始处理用户总结: user_id={user_id}, rounds={start_round + 1}-{end_round}")
  172. # 1. 获取需要总结的消息
  173. messages = await get_messages_for_summary(
  174. db_manager, user_id, start_round, end_round
  175. )
  176. if not messages:
  177. logger.warning(f"⚠️ 没有消息需要总结: user_id={user_id}, rounds={start_round + 1}-{end_round}")
  178. return False
  179. logger.info(f"📊 获取到 {len(messages)} 条消息需要总结")
  180. # 2. 加载现有记忆
  181. current_memory = load_memory(user_id)
  182. # 3. 构建消息列表(用于记忆提取)
  183. # 需要转换为 extract_and_update_memory_session_level 需要的格式
  184. messages_for_memory = [
  185. {"role": msg["role"], "content": msg["content"]}
  186. for msg in messages
  187. ]
  188. # 4. 调用记忆提取和更新
  189. if not client:
  190. logger.error("❌ OpenAI 客户端未初始化,请检查配置文件中的 dashscope_api_key")
  191. return False
  192. new_memory = extract_and_update_memory_session_level(
  193. client=client,
  194. model=AI_MODEL,
  195. user_id=user_id,
  196. messages=messages_for_memory,
  197. current_memory=current_memory,
  198. max_turns=80,
  199. )
  200. # 5. 获取 meta.memory_summary(增量摘要)
  201. memory_summary = new_memory.get("meta.memory_summary", "")
  202. # 6. 保存到 conversation_summaries 表(完整总结)
  203. # 使用 memory_summary 作为总结文本(这是增量摘要)
  204. summary_id = f"summary_{user_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
  205. async with db_manager.get_session() as session:
  206. # 检查是否已存在相同范围的总结
  207. existing_query = await session.execute(
  208. select(ConversationSummary)
  209. .where(
  210. ConversationSummary.user_id == user_id,
  211. ConversationSummary.start_round == start_round + 1,
  212. ConversationSummary.end_round == end_round
  213. )
  214. )
  215. existing = existing_query.scalar_one_or_none()
  216. if existing:
  217. logger.info(f"⏭️ 总结已存在,跳过: user_id={user_id}, rounds={start_round + 1}-{end_round}")
  218. return True
  219. # 创建新总结记录
  220. summary = ConversationSummary(
  221. summary_id=summary_id,
  222. user_id=user_id,
  223. start_round=start_round + 1,
  224. end_round=end_round,
  225. summary_text=memory_summary or f"对话总结(第{start_round + 1}-{end_round}轮)",
  226. summary_type="auto",
  227. message_count=len(messages),
  228. token_count=len(memory_summary) // 2 if memory_summary else 0
  229. )
  230. session.add(summary)
  231. await session.commit()
  232. logger.info(f"✅ 用户总结完成: user_id={user_id}, rounds={start_round + 1}-{end_round}, messages={len(messages)}")
  233. return True
  234. except Exception as e:
  235. logger.error(f"❌ 处理用户总结失败: user_id={user_id}, error={e}", exc_info=True)
  236. return False
  237. async def process_summaries(db_manager: DatabaseManager):
  238. """扫描并处理需要总结的用户"""
  239. if not DASHSCOPE_API_KEY:
  240. logger.error("❌ DashScope API Key 配置缺失,请在 summarize.conf 文件中设置 dashscope_api_key")
  241. return 0, 0
  242. try:
  243. # 查找需要总结的用户
  244. users_to_summarize = await find_users_need_summary(db_manager)
  245. if not users_to_summarize:
  246. return 0, 0
  247. logger.info(f"📋 找到 {len(users_to_summarize)} 个用户需要总结")
  248. success_count = 0
  249. fail_count = 0
  250. for user_info in users_to_summarize:
  251. user_id = user_info["user_id"]
  252. last_summary_end_round = user_info["last_summary_end_round"]
  253. total_rounds = user_info["total_rounds"]
  254. success = await process_user_summary(
  255. user_id=user_id,
  256. last_summary_end_round=last_summary_end_round,
  257. total_rounds=total_rounds,
  258. db_manager=db_manager
  259. )
  260. if success:
  261. success_count += 1
  262. else:
  263. fail_count += 1
  264. if success_count > 0 or fail_count > 0:
  265. logger.info(f"📊 处理完成: 成功 {success_count} 个, 失败 {fail_count} 个")
  266. return success_count, fail_count
  267. except Exception as e:
  268. logger.error(f"❌ 处理总结时出错: {e}", exc_info=True)
  269. return 0, 0
  270. async def main_loop():
  271. """主循环:定时扫描并处理总结"""
  272. # 设置数据库配置到环境变量(DatabaseManager 从环境变量读取)
  273. if MYSQL_HOST:
  274. os.environ["MYSQL_HOST"] = MYSQL_HOST
  275. if MYSQL_PORT:
  276. os.environ["MYSQL_PORT"] = MYSQL_PORT
  277. if MYSQL_USER:
  278. os.environ["MYSQL_USER"] = MYSQL_USER
  279. if MYSQL_PASSWORD:
  280. os.environ["MYSQL_PASSWORD"] = MYSQL_PASSWORD
  281. if MYSQL_DATABASE:
  282. os.environ["MYSQL_DATABASE"] = MYSQL_DATABASE
  283. db_manager = DatabaseManager()
  284. try:
  285. # 初始化数据库连接
  286. await db_manager.connect()
  287. logger.info("✅ 数据库连接成功")
  288. logger.info(f"🚀 开始记忆总结服务(轮询间隔: {POLL_INTERVAL}秒,延迟: {SUMMARY_DELAY_HOURS}小时)...")
  289. # 主循环:持续轮询
  290. while True:
  291. try:
  292. await process_summaries(db_manager)
  293. except Exception as e:
  294. logger.error(f"❌ 处理总结时出错: {e}", exc_info=True)
  295. # 等待下次轮询
  296. await asyncio.sleep(POLL_INTERVAL)
  297. except KeyboardInterrupt:
  298. logger.info("🛑 收到中断信号,正在关闭...")
  299. except Exception as e:
  300. logger.error(f"❌ 服务异常: {e}", exc_info=True)
  301. sys.exit(1)
  302. finally:
  303. await db_manager.disconnect()
  304. logger.info("✅ 服务已关闭")
  305. def setup_logging():
  306. """配置日志:正常日志输出到 summarize_memory.log,错误日志输出到 summarize_memory_error.log"""
  307. log_dir = Path(__file__).parent
  308. log_file = log_dir / "logs" / "summarize_memory.log"
  309. error_log_file = log_dir / "logs" / "summarize_memory_error.log"
  310. # 确保日志目录存在
  311. log_dir.mkdir(parents=True, exist_ok=True)
  312. (log_dir / "logs").mkdir(parents=True, exist_ok=True)
  313. # 创建格式化器
  314. formatter = logging.Formatter(
  315. '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  316. datefmt='%Y-%m-%d %H:%M:%S'
  317. )
  318. # 配置根日志记录器
  319. root_logger = logging.getLogger()
  320. root_logger.setLevel(logging.INFO)
  321. # 清除已有的处理器
  322. root_logger.handlers.clear()
  323. # 1. 控制台处理器(INFO及以上)
  324. console_handler = logging.StreamHandler(sys.stdout)
  325. console_handler.setLevel(logging.INFO)
  326. console_handler.setFormatter(formatter)
  327. root_logger.addHandler(console_handler)
  328. # 2. 文件处理器(所有日志,INFO及以上)
  329. file_handler = logging.FileHandler(log_file, encoding='utf-8')
  330. file_handler.setLevel(logging.INFO)
  331. file_handler.setFormatter(formatter)
  332. root_logger.addHandler(file_handler)
  333. # 3. 错误日志文件处理器(ERROR及以上)
  334. error_file_handler = logging.FileHandler(error_log_file, encoding='utf-8')
  335. error_file_handler.setLevel(logging.ERROR)
  336. error_file_handler.setFormatter(formatter)
  337. root_logger.addHandler(error_file_handler)
  338. # 4. 设置 SQLAlchemy 连接池日志级别为 WARNING(避免 DEBUG 日志)
  339. logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
  340. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  341. logger.info(f"✅ 日志配置完成:正常日志 -> {log_file}, 错误日志 -> {error_log_file}")
  342. if __name__ == "__main__":
  343. setup_logging()
  344. asyncio.run(main_loop())