| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- """对话记忆总结服务(定时扫描)
- 从数据库读取需要总结的用户对话,使用 LLM 生成总结并写入 conversation_summaries。
- 总结 Prompt 与《Backend-Prompt提示词逻辑 20260404.md》第 5 节一致:
- - 首次总结:§5.1
- - 有历史总结时:§5.2 累积总结
- Prerequisites:
- - Python >= 3.7
- - 配置文件:summarize.conf(与脚本同目录)
- - 必需配置:[database] 下的数据库配置
- - 必需配置:[ai] 下的 DashScope API Key
- 运行: python summarize_memory.py
- """
- import asyncio
- import configparser
- import os
- import sys
- import logging
- from datetime import datetime, timedelta
- from pathlib import Path
- from typing import List, Dict, Optional, Any
- from openai import OpenAI
- from sqlalchemy import select
- # 加载配置文件
- config_file = Path(__file__).parent / "summarize.conf"
- config = configparser.ConfigParser()
- if config_file.exists():
- config.read(config_file, encoding='utf-8')
- else:
- raise FileNotFoundError(f"配置文件不存在: {config_file}")
- # 导入数据库相关模块(需要从 backend 目录导入)
- backend_dir = Path(__file__).parent.parent / "backend"
- sys.path.insert(0, str(backend_dir))
- from database import DatabaseManager
- from models_db import User, Message, ConversationSummary
- logger = logging.getLogger(__name__)
- # 与 backend/ai_service.py generate_summary 中 system 角色一致
- _SUMMARY_SYSTEM_MESSAGE = "你是一位专业的英语学习助手,擅长总结学习对话的关键信息。"
- # ------- 从配置文件读取参数 -------
- # 数据库配置
- MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
- MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
- MYSQL_USER = config.get("database", "mysql_user", fallback="").strip()
- MYSQL_PASSWORD = config.get("database", "mysql_password", fallback="").strip()
- MYSQL_DATABASE = config.get("database", "mysql_database", fallback="").strip()
- # AI配置
- DASHSCOPE_API_KEY = config.get("ai", "dashscope_api_key", fallback="").strip()
- AI_MODEL = config.get("ai", "model", fallback="qwen-flash-2025-07-28").strip()
- # 服务配置
- POLL_INTERVAL = float(config.get("service", "poll_interval", fallback="60.0"))
- SUMMARY_DELAY_HOURS = float(config.get("service", "summary_delay_hours", fallback="1.0"))
- # 初始化 OpenAI 客户端(DashScope兼容模式)
- client = None
- if DASHSCOPE_API_KEY:
- client = OpenAI(
- api_key=DASHSCOPE_API_KEY,
- base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
- )
- def _build_conversation_lines(messages: List[Dict[str, Any]]) -> str:
- lines: List[str] = []
- for msg in messages:
- role = msg.get("role", "")
- content = msg.get("content", "") or ""
- round_num = msg.get("round_number", "")
- role_label = "用户" if role == "user" else "助手"
- lines.append(f"[第{round_num}轮] {role_label}: {content}")
- return "\n".join(lines)
- def _build_summary_user_prompt(
- messages: List[Dict[str, Any]],
- previous_summaries: Optional[List[str]] = None,
- ) -> str:
- """
- 与 backend/services/summary_service._generate_summary_with_llm 中用户 Prompt 对齐
- (文档 §5.1 首次总结 / §5.2 累积总结)。
- """
- conversation = _build_conversation_lines(messages)
- prev = [s.strip() for s in (previous_summaries or []) if s and str(s).strip()]
- if prev:
- previous_summaries_text = "\n\n".join(
- f"【历史总结{i + 1}】\n{summary}" for i, summary in enumerate(prev)
- )
- return f"""请基于以下历史总结和新的对话内容,生成一个更新的累积总结。
- **历史总结:**
- {previous_summaries_text}
- **新的对话内容(第{messages[0].get('round_number', 1)}-{messages[-1].get('round_number', 1)}轮):**
- {conversation}
- 请整合历史总结和新对话内容,更新以下两类关键信息:
- **第一类:用户个人信息**
- 请提取并整理用户在对话中透露的个人信息,包括但不限于:
- - 姓名、昵称
- - 年龄
- - 家庭住址、所在城市
- - 职业、工作
- - 兴趣爱好
- - 家庭成员信息
- - 其他个人背景信息
- 如果某些信息未在对话中提及,请标注"未提及"。
- **第二类:用户英语水平评估**
- 请评估用户当前的英语水平,包括:
- - 词汇量水平(基础/中级/高级)
- - 语法掌握程度(能使用哪些时态、句型)
- - 口语流利度(是否流畅、是否有较多停顿)
- - 发音准确性(是否有明显发音问题)
- - 表达能力(能否清晰表达想法)
- - 当前薄弱环节(需要重点提升的方面)
- 请按照以下格式输出总结(每部分100-150字):
- **用户个人信息:**
- [整合后的用户个人信息,未提及的标注"未提及"]
- **英语水平评估:**
- [对用户当前英语水平的详细评估]"""
- return f"""请从以下英语口语练习对话中提炼两类关键信息:
- **第一类:用户个人信息**
- 请提取并整理用户在对话中透露的个人信息,包括但不限于:
- - 姓名、昵称
- - 年龄
- - 家庭住址、所在城市
- - 职业、工作
- - 兴趣爱好
- - 家庭成员信息
- - 其他个人背景信息
- 如果某些信息未在对话中提及,请标注"未提及"。
- **第二类:用户英语水平评估**
- 请评估用户当前的英语水平,包括:
- - 词汇量水平(基础/中级/高级)
- - 语法掌握程度(能使用哪些时态、句型)
- - 口语流利度(是否流畅、是否有较多停顿)
- - 发音准确性(是否有明显发音问题)
- - 表达能力(能否清晰表达想法)
- - 当前薄弱环节(需要重点提升的方面)
- 对话内容:
- {conversation}
- 请按照以下格式输出总结(每部分100-150字):
- **用户个人信息:**
- [提取的用户个人信息,未提及的标注"未提及"]
- **英语水平评估:**
- [对用户当前英语水平的详细评估]"""
- def _call_summary_llm(prompt: str) -> str:
- """同步调用 LLM;由 asyncio.to_thread 包装以免阻塞事件循环。"""
- if not client:
- return ""
- try:
- resp = client.chat.completions.create(
- model=AI_MODEL,
- messages=[
- {"role": "system", "content": _SUMMARY_SYSTEM_MESSAGE},
- {"role": "user", "content": prompt},
- ],
- temperature=0.5,
- max_tokens=512,
- )
- return (resp.choices[0].message.content or "").strip()
- except Exception as e:
- logger.error(f"❌ LLM 总结生成失败: {e}", exc_info=True)
- return ""
- async def fetch_previous_summaries_for_cumulative(
- db_manager: DatabaseManager,
- user_id: str,
- new_chunk_start_round: int,
- ) -> List[str]:
- """
- 获取「本轮新对话」之前的所有总结文本,供 §5.2 使用。
- 条件与 SummaryService.generate_summary 一致:end_round < new_chunk_start_round。
- """
- if new_chunk_start_round <= 1:
- return []
- try:
- async with db_manager.get_session() as session:
- q = await session.execute(
- select(ConversationSummary)
- .where(
- ConversationSummary.user_id == user_id,
- ConversationSummary.end_round < new_chunk_start_round,
- )
- .order_by(ConversationSummary.end_round.asc())
- )
- rows = q.scalars().all()
- return [s.summary_text for s in rows if s.summary_text]
- except Exception as e:
- logger.error(f"❌ 读取历史总结失败: user_id={user_id}, error={e}", exc_info=True)
- return []
- async def find_users_need_summary(db_manager: DatabaseManager) -> List[Dict]:
- """
- 查找需要总结的用户
-
- 条件:
- 1. last_active_at 距离现在 >= SUMMARY_DELAY_HOURS 小时
- 2. total_rounds > 上一次总结的 end_round(有未总结的对话)
-
- Returns:
- 需要总结的用户列表,每个元素包含 user_id, last_summary_end_round, total_rounds
- """
- try:
- cutoff_time = datetime.now() - timedelta(hours=SUMMARY_DELAY_HOURS)
-
- async with db_manager.get_session() as session:
- # 查询符合条件的用户(last_active_at < cutoff_time,确保至少1小时过去)
- users_query = await session.execute(
- select(User)
- .where(
- User.last_active_at.isnot(None),
- User.last_active_at < cutoff_time, # ✅ 使用 < 确保至少1小时过去
- User.total_rounds > 0
- )
- )
- users = users_query.scalars().all()
-
- if not users:
- return []
-
- result = []
-
- for user in users:
- user_id = user.user_id
- total_rounds = user.total_rounds or 0
-
- # 查询该用户的最新总结
- summary_query = await session.execute(
- select(ConversationSummary)
- .where(ConversationSummary.user_id == user_id)
- .order_by(ConversationSummary.end_round.desc())
- .limit(1)
- )
- latest_summary = summary_query.scalar_one_or_none()
-
- last_summary_end_round = 0
- if latest_summary:
- last_summary_end_round = latest_summary.end_round
-
- # 如果有未总结的对话
- if total_rounds > last_summary_end_round:
- result.append({
- "user_id": user_id,
- "last_summary_end_round": last_summary_end_round,
- "total_rounds": total_rounds,
- "last_active_at": user.last_active_at,
- })
-
- return result
-
- except Exception as e:
- logger.error(f"❌ 查找需要总结的用户失败: {e}", exc_info=True)
- return []
- async def get_messages_for_summary(
- db_manager: DatabaseManager,
- user_id: str,
- start_round: int,
- end_round: int
- ) -> List[Dict]:
- """
- 获取需要总结的消息
-
- Args:
- db_manager: 数据库管理器
- user_id: 用户ID
- start_round: 起始轮次(不包含,即 start_round + 1)
- end_round: 结束轮次(包含)
-
- Returns:
- 消息列表,格式: [{"role": "user|assistant", "content": "...", "round_number": 1}, ...]
- """
- try:
- async with db_manager.get_session() as session:
- messages_query = await session.execute(
- select(Message)
- .where(
- Message.user_id == user_id,
- Message.round_number > start_round,
- Message.round_number <= end_round
- )
- .order_by(Message.round_number.asc(), Message.id.asc())
- )
- messages_list = messages_query.scalars().all()
-
- messages = [
- {
- "role": msg.role,
- "content": msg.content,
- "round_number": msg.round_number,
- }
- for msg in messages_list
- ]
-
- return messages
-
- except Exception as e:
- logger.error(f"❌ 获取消息失败: user_id={user_id}, error={e}", exc_info=True)
- return []
- async def process_user_summary(
- user_id: str,
- last_summary_end_round: int,
- total_rounds: int,
- db_manager: DatabaseManager
- ) -> bool:
- """
- 处理单个用户的总结
-
- Args:
- user_id: 用户ID
- last_summary_end_round: 上一次总结的结束轮次
- total_rounds: 用户当前总轮次
- db_manager: 数据库管理器
-
- Returns:
- 是否成功
- """
- try:
- start_round = last_summary_end_round
- end_round = total_rounds
-
- logger.info(f"🔄 开始处理用户总结: user_id={user_id}, rounds={start_round + 1}-{end_round}")
-
- # 1. 获取需要总结的消息
- messages = await get_messages_for_summary(
- db_manager, user_id, start_round, end_round
- )
-
- if not messages:
- logger.warning(f"⚠️ 没有消息需要总结: user_id={user_id}, rounds={start_round + 1}-{end_round}")
- return False
-
- logger.info(f"📊 获取到 {len(messages)} 条消息需要总结")
-
- if not client:
- logger.error("❌ OpenAI 客户端未初始化,请检查配置文件中的 dashscope_api_key")
- return False
-
- first_new_round = start_round + 1
- previous_texts = await fetch_previous_summaries_for_cumulative(
- db_manager, user_id, first_new_round
- )
- if previous_texts:
- logger.info(
- f"📚 累积总结:载入 {len(previous_texts)} 段历史总结(§5.2),"
- f"新对话轮次 {first_new_round}-{end_round}"
- )
- else:
- logger.info(f"📚 首次总结(§5.1),轮次 {first_new_round}-{end_round}")
-
- user_prompt = _build_summary_user_prompt(messages, previous_texts)
- summary_text = await asyncio.to_thread(_call_summary_llm, user_prompt)
-
- if not summary_text:
- lo = messages[0].get("round_number", first_new_round)
- hi = messages[-1].get("round_number", end_round)
- summary_text = (
- f"**用户个人信息:**\n未提及\n\n**英语水平评估:**\n"
- f"对话总结(第{lo}-{hi}轮):共 {len(messages)} 条消息的英语口语练习对话(LLM 未返回内容)。"
- )
- logger.warning(f"⚠️ LLM 返回空总结,已写入占位文案: user_id={user_id}")
-
- summary_id = f"summary_{user_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
-
- async with db_manager.get_session() as session:
- # 检查是否已存在相同范围的总结
- existing_query = await session.execute(
- select(ConversationSummary)
- .where(
- ConversationSummary.user_id == user_id,
- ConversationSummary.start_round == start_round + 1,
- ConversationSummary.end_round == end_round
- )
- )
- existing = existing_query.scalar_one_or_none()
-
- if existing:
- logger.info(f"⏭️ 总结已存在,跳过: user_id={user_id}, rounds={start_round + 1}-{end_round}")
- return True
-
- # 创建新总结记录
- summary = ConversationSummary(
- summary_id=summary_id,
- user_id=user_id,
- start_round=start_round + 1,
- end_round=end_round,
- summary_text=summary_text,
- summary_type="auto",
- message_count=len(messages),
- token_count=len(summary_text) // 2 if summary_text else 0
- )
- session.add(summary)
- await session.commit()
-
- logger.info(f"✅ 用户总结完成: user_id={user_id}, rounds={start_round + 1}-{end_round}, messages={len(messages)}")
- return True
-
- except Exception as e:
- logger.error(f"❌ 处理用户总结失败: user_id={user_id}, error={e}", exc_info=True)
- return False
- async def process_summaries(db_manager: DatabaseManager):
- """扫描并处理需要总结的用户"""
- if not DASHSCOPE_API_KEY:
- logger.error("❌ DashScope API Key 配置缺失,请在 summarize.conf 文件中设置 dashscope_api_key")
- return 0, 0
-
- try:
- # 查找需要总结的用户
- users_to_summarize = await find_users_need_summary(db_manager)
-
- if not users_to_summarize:
- return 0, 0
-
- logger.info(f"📋 找到 {len(users_to_summarize)} 个用户需要总结")
-
- success_count = 0
- fail_count = 0
-
- for user_info in users_to_summarize:
- user_id = user_info["user_id"]
- last_summary_end_round = user_info["last_summary_end_round"]
- total_rounds = user_info["total_rounds"]
-
- success = await process_user_summary(
- user_id=user_id,
- last_summary_end_round=last_summary_end_round,
- total_rounds=total_rounds,
- db_manager=db_manager
- )
-
- if success:
- success_count += 1
- else:
- fail_count += 1
-
- if success_count > 0 or fail_count > 0:
- logger.info(f"📊 处理完成: 成功 {success_count} 个, 失败 {fail_count} 个")
-
- return success_count, fail_count
-
- except Exception as e:
- logger.error(f"❌ 处理总结时出错: {e}", exc_info=True)
- return 0, 0
- async def main_loop():
- """主循环:定时扫描并处理总结"""
- # 设置数据库配置到环境变量(DatabaseManager 从环境变量读取)
- if MYSQL_HOST:
- os.environ["MYSQL_HOST"] = MYSQL_HOST
- if MYSQL_PORT:
- os.environ["MYSQL_PORT"] = MYSQL_PORT
- if MYSQL_USER:
- os.environ["MYSQL_USER"] = MYSQL_USER
- if MYSQL_PASSWORD:
- os.environ["MYSQL_PASSWORD"] = MYSQL_PASSWORD
- if MYSQL_DATABASE:
- os.environ["MYSQL_DATABASE"] = MYSQL_DATABASE
-
- db_manager = DatabaseManager()
-
- try:
- # 初始化数据库连接
- await db_manager.connect()
- logger.info("✅ 数据库连接成功")
-
- logger.info(f"🚀 开始记忆总结服务(轮询间隔: {POLL_INTERVAL}秒,延迟: {SUMMARY_DELAY_HOURS}小时)...")
-
- # 主循环:持续轮询
- while True:
- try:
- await process_summaries(db_manager)
- except Exception as e:
- logger.error(f"❌ 处理总结时出错: {e}", exc_info=True)
-
- # 等待下次轮询
- await asyncio.sleep(POLL_INTERVAL)
-
- except KeyboardInterrupt:
- logger.info("🛑 收到中断信号,正在关闭...")
- except Exception as e:
- logger.error(f"❌ 服务异常: {e}", exc_info=True)
- sys.exit(1)
- finally:
- await db_manager.disconnect()
- logger.info("✅ 服务已关闭")
- def setup_logging():
- """配置日志:正常日志输出到 summarize_memory.log,错误日志输出到 summarize_memory_error.log"""
- log_dir = Path(__file__).parent
- log_file = log_dir / "logs" / "summarize_memory.log"
- error_log_file = log_dir / "logs" / "summarize_memory_error.log"
-
- # 确保日志目录存在
- log_dir.mkdir(parents=True, exist_ok=True)
- (log_dir / "logs").mkdir(parents=True, exist_ok=True)
-
- # 创建格式化器
- formatter = logging.Formatter(
- '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
- datefmt='%Y-%m-%d %H:%M:%S'
- )
-
- # 配置根日志记录器
- root_logger = logging.getLogger()
- root_logger.setLevel(logging.INFO)
-
- # 清除已有的处理器
- root_logger.handlers.clear()
-
- # 1. 控制台处理器(INFO及以上)
- console_handler = logging.StreamHandler(sys.stdout)
- console_handler.setLevel(logging.INFO)
- console_handler.setFormatter(formatter)
- root_logger.addHandler(console_handler)
-
- # 2. 文件处理器(所有日志,INFO及以上)
- file_handler = logging.FileHandler(log_file, encoding='utf-8')
- file_handler.setLevel(logging.INFO)
- file_handler.setFormatter(formatter)
- root_logger.addHandler(file_handler)
-
- # 3. 错误日志文件处理器(ERROR及以上)
- error_file_handler = logging.FileHandler(error_log_file, encoding='utf-8')
- error_file_handler.setLevel(logging.ERROR)
- error_file_handler.setFormatter(formatter)
- root_logger.addHandler(error_file_handler)
-
- # 4. 设置 SQLAlchemy 连接池日志级别为 WARNING(避免 DEBUG 日志)
- logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
- logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
-
- logger.info(f"✅ 日志配置完成:正常日志 -> {log_file}, 错误日志 -> {error_log_file}")
- if __name__ == "__main__":
- setup_logging()
- asyncio.run(main_loop())
|