summary_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. """
  2. 对话总结服务(SQLAlchemy ORM 版本)
  3. """
  4. import uuid
  5. from typing import Awaitable, Callable, List, Dict, Optional
  6. import logging
  7. from sqlalchemy import select
  8. import redis.asyncio as aioredis
  9. logger = logging.getLogger(__name__)
  10. class SummaryService:
  11. """对话总结服务"""
  12. def __init__(self, db_manager):
  13. """
  14. 初始化总结服务
  15. Args:
  16. db_manager: 数据库管理器实例
  17. """
  18. self.db = db_manager
  19. self.redis = None # 🔥 延迟获取Redis
  20. self._redis_provider: Optional[Callable[[], Awaitable[aioredis.Redis]]] = (
  21. db_manager.get_redis if db_manager else None
  22. )
  23. async def _ensure_redis(self):
  24. """确保Redis连接可用"""
  25. if not self._redis_provider:
  26. if not self.db:
  27. raise RuntimeError("Redis 未初始化,缺少数据库管理器")
  28. self._redis_provider = self.db.get_redis
  29. redis_client = await self._redis_provider()
  30. if not redis_client:
  31. raise RuntimeError("Redis 客户端不可用")
  32. self.redis = redis_client
  33. return redis_client
  34. async def generate_summary(
  35. self,
  36. user_id: str,
  37. start_round: int,
  38. end_round: int
  39. ):
  40. """
  41. 生成对话总结(异步执行)
  42. 逻辑:
  43. - 如果是第一次总结(start_round=1):只总结当前15轮消息
  44. - 如果是后续总结:获取之前的总结 + 当前15轮消息,进行累积总结
  45. Args:
  46. user_id: 用户ID
  47. start_round: 起始轮次
  48. end_round: 结束轮次
  49. """
  50. try:
  51. # 🔥 确保Redis连接可用
  52. await self._ensure_redis()
  53. logger.info(f"🔄 开始生成总结: user={user_id}, rounds={start_round}-{end_round}")
  54. # 0. 参数验证
  55. if end_round <= 0:
  56. logger.warning(f"⚠️ 无效的总结范围: user={user_id}, start_round={start_round}, end_round={end_round}")
  57. return
  58. if start_round > end_round:
  59. logger.warning(f"⚠️ 总结范围错误: user={user_id}, start_round={start_round}, end_round={end_round}")
  60. return
  61. # 使用 ORM 查询
  62. from database import db_manager
  63. from models_db import ConversationSummary, Message
  64. # 1. 检查是否已存在总结
  65. async with db_manager.get_session() as session:
  66. existing_query = await session.execute(
  67. select(ConversationSummary)
  68. .where(ConversationSummary.user_id == user_id)
  69. .where(ConversationSummary.start_round == start_round)
  70. .where(ConversationSummary.end_round == end_round)
  71. )
  72. existing = existing_query.scalar_one_or_none()
  73. if existing:
  74. logger.info(f"⏭️ 总结已存在,跳过: user={user_id}, rounds={start_round}-{end_round}")
  75. return
  76. # 2. 获取之前的总结(如果有)
  77. previous_summaries = []
  78. if start_round > 1:
  79. previous_query = await session.execute(
  80. select(ConversationSummary)
  81. .where(ConversationSummary.user_id == user_id)
  82. .where(ConversationSummary.end_round < start_round)
  83. .order_by(ConversationSummary.end_round.asc())
  84. )
  85. previous_summaries_list = previous_query.scalars().all()
  86. previous_summaries = [s.summary_text for s in previous_summaries_list]
  87. logger.info(f"📚 获取到 {len(previous_summaries)} 个历史总结")
  88. # 3. 获取待总结的消息
  89. logger.info(f"🔍 [总结查询] user={user_id}, start_round={start_round}, end_round={end_round}")
  90. messages_query = await session.execute(
  91. select(Message)
  92. .where(Message.user_id == user_id)
  93. .where(Message.round_number >= start_round)
  94. .where(Message.round_number <= end_round)
  95. .order_by(Message.round_number.asc(), Message.id.asc())
  96. )
  97. messages_list = messages_query.scalars().all()
  98. messages = [
  99. {
  100. "role": msg.role,
  101. "content": msg.content,
  102. "round_number": msg.round_number,
  103. }
  104. for msg in messages_list
  105. ]
  106. logger.info(f"📊 [总结查询结果] user={user_id}, 查询到 {len(messages)} 条消息")
  107. # 如果查询不到消息,检查数据库中实际有哪些轮次
  108. if not messages:
  109. # 诊断:查询该用户的所有消息轮次
  110. async with db_manager.get_session() as session:
  111. from sqlalchemy import func
  112. all_rounds_query = await session.execute(
  113. select(Message.round_number, func.count(Message.id).label('count'))
  114. .where(Message.user_id == user_id)
  115. .group_by(Message.round_number)
  116. .order_by(Message.round_number.asc())
  117. )
  118. all_rounds_list = all_rounds_query.all()
  119. all_rounds = [
  120. {"round_number": r.round_number, "count": r.count}
  121. for r in all_rounds_list
  122. ]
  123. # 过滤掉 round_number = 0 的记录(可能是历史错误数据)
  124. valid_rounds = [r['round_number'] for r in all_rounds if r['round_number'] > 0] if all_rounds else []
  125. logger.warning(
  126. f"⚠️ 没有消息可总结: user={user_id}, "
  127. f"查询范围={start_round}-{end_round}, "
  128. f"数据库中实际轮次={valid_rounds if valid_rounds else '无消息'}, "
  129. f"总记录数={len(all_rounds) if all_rounds else 0}"
  130. )
  131. return
  132. # 记录查询到的消息轮次范围
  133. if messages:
  134. actual_start = min(m['round_number'] for m in messages)
  135. actual_end = max(m['round_number'] for m in messages)
  136. logger.info(f"✅ [总结查询] 实际消息轮次范围: {actual_start}-{actual_end}, 共 {len(messages)} 条")
  137. # 4. 调用LLM生成总结(包含之前的总结)
  138. summary_text = await self._generate_summary_with_llm(messages, previous_summaries)
  139. if not summary_text:
  140. logger.warning(f"⚠️ 总结生成失败: user={user_id}")
  141. return
  142. # 4. 保存总结到MySQL(使用 ORM)
  143. summary_id = str(uuid.uuid4())
  144. async with db_manager.get_session() as session:
  145. summary = ConversationSummary(
  146. summary_id=summary_id,
  147. user_id=user_id,
  148. start_round=start_round,
  149. end_round=end_round,
  150. summary_text=summary_text,
  151. summary_type="auto",
  152. message_count=len(messages),
  153. token_count=len(summary_text) // 2
  154. )
  155. session.add(summary)
  156. await session.commit()
  157. logger.info(f"✅ 总结保存成功: user={user_id}, messages={len(messages)}")
  158. # 5. 更新Redis缓存
  159. await self._update_summary_cache(user_id, summary_text)
  160. # 6. 更新元数据
  161. meta_key = f"user:{user_id}:meta"
  162. await self.redis.hset(meta_key, 'last_summary_round', end_round)
  163. await self.redis.expire(meta_key, 86400)
  164. logger.info(f"🎉 总结完成: user={user_id}, rounds={start_round}-{end_round}")
  165. except Exception as e:
  166. logger.error(f"❌ 生成总结失败: user={user_id}, error={e}")
  167. import traceback
  168. traceback.print_exc()
  169. async def _generate_summary_with_llm(
  170. self,
  171. messages: List[Dict],
  172. previous_summaries: List[str] = None
  173. ) -> str:
  174. """
  175. 调用LLM生成总结
  176. 总结内容聚焦于:
  177. 1. 用户个人信息(姓名、年龄、家庭住址等)- 用于后续精准回答用户问题
  178. 2. 用户当前英语水平评估 - 用于逐步引导用户提高英语水平
  179. Args:
  180. messages: 消息列表
  181. previous_summaries: 之前的总结列表(用于累积总结)
  182. Returns:
  183. 总结文本(包含用户信息和英语水平评估)
  184. """
  185. try:
  186. # 构建对话内容
  187. conversation_lines = []
  188. for msg in messages:
  189. role = msg['role']
  190. content = msg['content']
  191. round_num = msg.get('round_number', '')
  192. role_label = "用户" if role == "user" else "助手"
  193. conversation_lines.append(f"[第{round_num}轮] {role_label}: {content}")
  194. conversation = "\n".join(conversation_lines)
  195. # 构建总结Prompt
  196. if previous_summaries:
  197. # 有历史总结:进行累积总结
  198. previous_summaries_text = "\n\n".join([
  199. f"【历史总结{i+1}】\n{summary}"
  200. for i, summary in enumerate(previous_summaries)
  201. ])
  202. prompt = f"""请基于以下历史总结和新的对话内容,生成一个更新的累积总结。
  203. **历史总结:**
  204. {previous_summaries_text}
  205. **新的对话内容(第{messages[0].get('round_number', 1)}-{messages[-1].get('round_number', 15)}轮):**
  206. {conversation}
  207. 请整合历史总结和新对话内容,更新以下两类关键信息:
  208. **第一类:用户个人信息**
  209. 请提取并整理用户在对话中透露的个人信息,包括但不限于:
  210. - 姓名、昵称
  211. - 年龄
  212. - 家庭住址、所在城市
  213. - 职业、工作
  214. - 兴趣爱好
  215. - 家庭成员信息
  216. - 其他个人背景信息
  217. 如果某些信息未在对话中提及,请标注"未提及"。
  218. **第二类:用户英语水平评估**
  219. 请评估用户当前的英语水平,包括:
  220. - 词汇量水平(基础/中级/高级)
  221. - 语法掌握程度(能使用哪些时态、句型)
  222. - 口语流利度(是否流畅、是否有较多停顿)
  223. - 发音准确性(是否有明显发音问题)
  224. - 表达能力(能否清晰表达想法)
  225. - 当前薄弱环节(需要重点提升的方面)
  226. 请按照以下格式输出总结(每部分100-150字):
  227. **用户个人信息:**
  228. [整合后的用户个人信息,未提及的标注"未提及"]
  229. **英语水平评估:**
  230. [对用户当前英语水平的详细评估]"""
  231. else:
  232. # 第一次总结:只总结当前对话
  233. prompt = f"""请从以下英语口语练习对话中提炼两类关键信息:
  234. **第一类:用户个人信息**
  235. 请提取并整理用户在对话中透露的个人信息,包括但不限于:
  236. - 姓名、昵称
  237. - 年龄
  238. - 家庭住址、所在城市
  239. - 职业、工作
  240. - 兴趣爱好
  241. - 家庭成员信息
  242. - 其他个人背景信息
  243. 如果某些信息未在对话中提及,请标注"未提及"。
  244. **第二类:用户英语水平评估**
  245. 请评估用户当前的英语水平,包括:
  246. - 词汇量水平(基础/中级/高级)
  247. - 语法掌握程度(能使用哪些时态、句型)
  248. - 口语流利度(是否流畅、是否有较多停顿)
  249. - 发音准确性(是否有明显发音问题)
  250. - 表达能力(能否清晰表达想法)
  251. - 当前薄弱环节(需要重点提升的方面)
  252. 对话内容:
  253. {conversation}
  254. 请按照以下格式输出总结(每部分100-150字):
  255. **用户个人信息:**
  256. [提取的用户个人信息,未提及的标注"未提及"]
  257. **英语水平评估:**
  258. [对用户当前英语水平的详细评估]"""
  259. # 调用AI服务生成总结
  260. from ai_service import AIService
  261. ai_service = AIService()
  262. # 使用非流式调用
  263. summary = await ai_service.generate_summary(prompt)
  264. return summary.strip()
  265. except Exception as e:
  266. logger.error(f"❌ LLM总结生成失败: {e}")
  267. # 降级方案:简单拼接
  268. return f"**用户个人信息:**\n未提及\n\n**英语水平评估:**\n对话总结(第{messages[0].get('round_number', 1)}-{messages[-1].get('round_number', 15)}轮):共{len(messages)}条消息的英语口语练习对话。"
  269. async def _update_summary_cache(self, user_id: str, summary_text: str):
  270. """
  271. 更新Redis中的总结缓存
  272. Args:
  273. user_id: 用户ID
  274. summary_text: 总结文本
  275. """
  276. try:
  277. redis = await self._ensure_redis()
  278. cache_key = f"user:{user_id}:latest_summary"
  279. await redis.setex(cache_key, 86400, summary_text)
  280. logger.debug(f"✅ 总结已缓存: user={user_id}")
  281. except Exception as e:
  282. logger.warning(f"⚠️ 缓存总结失败: user={user_id}, error={e}")
  283. async def get_latest_summary(self, user_id: str) -> str:
  284. """
  285. 获取用户最新的总结
  286. Args:
  287. user_id: 用户ID
  288. Returns:
  289. 总结文本或空字符串
  290. """
  291. try:
  292. redis = await self._ensure_redis()
  293. # 先查Redis
  294. cache_key = f"user:{user_id}:latest_summary"
  295. cached = await redis.get(cache_key)
  296. if cached:
  297. return cached
  298. # 再查MySQL(使用 ORM)
  299. from database import db_manager
  300. from models_db import ConversationSummary
  301. async with db_manager.get_session() as session:
  302. result_query = await session.execute(
  303. select(ConversationSummary)
  304. .where(ConversationSummary.user_id == user_id)
  305. .order_by(ConversationSummary.end_round.desc())
  306. .limit(1)
  307. )
  308. summary = result_query.scalar_one_or_none()
  309. if summary:
  310. summary_text = summary.summary_text
  311. # 回填缓存
  312. await redis.setex(cache_key, 86400, summary_text)
  313. return summary_text
  314. return ""
  315. except Exception as e:
  316. logger.error(f"❌ 获取最新总结失败: user={user_id}, error={e}")
  317. return ""