user_profile_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. """用户画像服务 - 从对话中提取用户偏好并持久化到本地md文件
  2. 内存缓存策略(参考 Claude Code 记忆模式):
  3. - 首次读取后缓存到 _profile_cache,避免重复磁盘 I/O
  4. - 通过文件 mtime 检测外部修改,自动刷新缓存
  5. - 写入时同时更新缓存和文件,保证读写一致性
  6. - 每条用户画像使用 frontmatter 记录元数据(更新时间、来源会话)
  7. 画像提取逻辑委托给 ProfileExtractionAgent 子代理执行,
  8. 而非直接调用 LLM,保持多智能体架构一致性。
  9. """
  10. import time
  11. from pathlib import Path
  12. from typing import Optional
  13. from ..agents.profile_extraction_agent import ProfileExtractionAgent
  14. from ..services.llm_service import get_llm
  15. from ..database import get_db
  16. # 用户画像存储目录
  17. PROFILES_DIR = Path(__file__).parent.parent.parent / "user_profiles"
  18. # 内存缓存:user_id -> (profile_text, mtime, cached_at)
  19. # mtime:文件最后修改时间(用于检测外部修改)
  20. # cached_at:缓存写入时间(用于 TTL 过期)
  21. _profile_cache: dict[int, tuple[str, float, float]] = {}
  22. # 会话级快照缓存:session_id -> context_text
  23. # 同一场对话内首条消息固话,后续消息复用,保证 LLM prompt cache 命中
  24. _session_snapshot_cache: dict[int, str] = {}
  25. # 缓存 TTL:300 秒(5 分钟内认为缓存新鲜,无需 stat 文件)
  26. _CACHE_TTL = 300
  27. # 画像提取 Agent 全局实例(惰性初始化)
  28. _profile_extraction_agent: Optional[ProfileExtractionAgent] = None
  29. def _ensure_profiles_dir():
  30. """确保画像目录存在"""
  31. PROFILES_DIR.mkdir(parents=True, exist_ok=True)
  32. def _profile_path(user_id: int) -> Path:
  33. """获取用户画像文件路径"""
  34. return PROFILES_DIR / f"user_{user_id}.md"
  35. def _read_file_with_frontmatter(path: Path) -> tuple[str, str]:
  36. """
  37. 读取 md 文件,分离 frontmatter 和正文
  38. 返回: (frontmatter_yaml, body)
  39. 无 frontmatter 时 frontmatter 返回空字符串
  40. """
  41. if not path.exists():
  42. return "", ""
  43. content = path.read_text(encoding="utf-8").strip()
  44. if content.startswith("---"):
  45. parts = content.split("---", 2)
  46. if len(parts) >= 3:
  47. frontmatter = parts[1].strip()
  48. body = parts[2].strip()
  49. return frontmatter, body
  50. return "", content
  51. def _build_frontmatter(user_id: int) -> str:
  52. """构建 YAML frontmatter"""
  53. now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
  54. return (
  55. f"---\n"
  56. f"user_id: {user_id}\n"
  57. f"updated_at: '{now}'\n"
  58. f"---"
  59. )
  60. def get_profile_extraction_agent() -> ProfileExtractionAgent:
  61. """获取画像提取 Agent 实例(单例,惰性初始化)"""
  62. global _profile_extraction_agent
  63. if _profile_extraction_agent is None:
  64. llm = get_llm()
  65. _profile_extraction_agent = ProfileExtractionAgent(llm)
  66. print(f" ✅ 用户画像提取 Agent 初始化成功")
  67. return _profile_extraction_agent
  68. def _invalidate_cache(user_id: int):
  69. """清除指定用户的缓存"""
  70. _profile_cache.pop(user_id, None)
  71. def _refresh_from_disk(user_id: int) -> str:
  72. """从磁盘加载用户画像正文(跳过 frontmatter),更新缓存"""
  73. path = _profile_path(user_id)
  74. if not path.exists():
  75. _profile_cache[user_id] = ("", 0.0, time.time())
  76. return ""
  77. mtime = path.stat().st_mtime
  78. _, body = _read_file_with_frontmatter(path)
  79. _profile_cache[user_id] = (body, mtime, time.time())
  80. return body
  81. def load_profile_text(user_id: int) -> str:
  82. """
  83. 加载用户画像文本(带内存缓存)
  84. 缓存策略:
  85. 1. 缓存命中且未超过 TTL → 直接返回
  86. 2. 缓存命中但超过 TTL → stat 检查文件 mtime,未变则续期缓存
  87. 3. 缓存未命中或文件已变 → 重新从磁盘读取
  88. Returns:
  89. 用户画像正文(仅 "- " 开头的条目行),不存在则返回空字符串
  90. """
  91. path = _profile_path(user_id)
  92. cached = _profile_cache.get(user_id)
  93. now = time.time()
  94. if cached is not None:
  95. body, mtime, cached_at = cached
  96. # TTL 内:直接返回缓存
  97. if now - cached_at < _CACHE_TTL:
  98. return body
  99. # TTL 已过:检查文件 mtime
  100. if path.exists():
  101. current_mtime = path.stat().st_mtime
  102. if current_mtime == mtime:
  103. # 文件未变,续期缓存
  104. _profile_cache[user_id] = (body, mtime, now)
  105. return body
  106. # 缓存失效或文件变更,从磁盘重新加载
  107. return _refresh_from_disk(user_id)
  108. def save_profile(user_id: int, profile_text: str):
  109. """
  110. 保存用户画像到 md 文件(frontmatter + 正文)
  111. 格式:
  112. ---
  113. user_id: 1
  114. updated_at: '2026-06-04 12:00:00'
  115. ---
  116. # 用户旅行画像
  117. - 条目1
  118. - 条目2
  119. """
  120. _ensure_profiles_dir()
  121. frontmatter = _build_frontmatter(user_id)
  122. content = f"{frontmatter}\n\n# 用户旅行画像\n\n{profile_text}\n"
  123. path = _profile_path(user_id)
  124. # 先写磁盘,再更新缓存(保证缓存与磁盘一致)
  125. path.write_text(content, encoding="utf-8")
  126. mtime = path.stat().st_mtime
  127. # 只缓存有效条目行作为正文
  128. lines = [l for l in profile_text.split("\n") if l.strip().startswith("- ")]
  129. body = "\n".join(lines)
  130. _profile_cache[user_id] = (body, mtime, time.time())
  131. def extract_and_update_profile(
  132. user_id: int,
  133. user_message: str,
  134. history: Optional[list] = None,
  135. cross_session_context: str = "",
  136. ):
  137. """
  138. 从用户消息中提取偏好,与现有画像对比合并(冲突时以最新为准),然后更新保存
  139. 与旧版的关键区别:
  140. 1. 支持传入跨会话上下文(cross_session_context),让 LLM 能理解
  141. 用户在其他会话中表达过的偏好,避免将长期偏好误判为一次性信息
  142. 2. 内存缓存:每次提取后自动更新缓存,后续 load 直接命中
  143. Args:
  144. user_id: 用户ID
  145. user_message: 用户发送的消息
  146. history: 当前会话的最近消息列表(用于理解上下文)
  147. cross_session_context: 跨会话上下文文本(来自其他会话的消息摘要)
  148. """
  149. user_msg = user_message.strip()
  150. # 太短或纯语气词,跳过
  151. if len(user_msg) < 3:
  152. return
  153. # 加载现有画像(走缓存)
  154. existing = load_profile_text(user_id)
  155. # 构建对话上下文
  156. context_parts = []
  157. # 优先注入跨会话上下文
  158. if cross_session_context:
  159. context_parts.append("=== 历史会话摘要 ===\n" + cross_session_context)
  160. # 当前会话的最近消息作为细粒度上下文
  161. if history:
  162. recent = history[-6:] # 最近 3 轮对话(最多 6 条)
  163. context_parts.append("=== 当前会话 ===")
  164. for msg in recent:
  165. role = msg.get("role", "")
  166. content = msg.get("content", "")
  167. if role == "user":
  168. context_parts.append(f"用户:{content[:150]}")
  169. elif role == "assistant":
  170. context_parts.append(f"助手:{content[:150]}")
  171. conversation_context = "\n".join(context_parts) if context_parts else "(无)"
  172. try:
  173. agent = get_profile_extraction_agent()
  174. new_profile = agent.extract(
  175. existing_profile=existing,
  176. conversation_context=conversation_context,
  177. user_message=user_msg[:300],
  178. )
  179. if not new_profile:
  180. return
  181. # 解析有效条目
  182. lines = []
  183. for line in new_profile.split("\n"):
  184. line = line.strip()
  185. if line.startswith("- ") and len(line) > 3:
  186. lines.append(line)
  187. if lines:
  188. save_profile(user_id, "\n".join(lines))
  189. print(f" ✅ 用户 {user_id} 画像更新成功 ({len(lines)} 条)")
  190. except Exception as e:
  191. print(f" ⚠️ 用户画像提取失败: {e}")
  192. def get_profile_context(user_id: int, session_id: int = None) -> str:
  193. """
  194. 获取用户画像上下文文本(用于注入到系统提示词)
  195. 支持会话级快照:传入 session_id 后,同一场对话内首条消息固话画像字符串,
  196. 后续消息无论画像如何更新都复用该字符串,保证 LLM prompt cache 不变。
  197. 缓存层级(从快到慢):
  198. session snapshot → memory cache → disk
  199. Args:
  200. user_id: 用户ID
  201. session_id: 可选,会话ID。传入后启用会话级快照。
  202. Returns:
  203. 格式化的画像上下文,如果不存在则返回空字符串
  204. """
  205. # 1. 会话级快照命中 → 直接返回(零开销)
  206. if session_id is not None and session_id in _session_snapshot_cache:
  207. return _session_snapshot_cache[session_id]
  208. # 2. 加载画像(走内存缓存 → disk)
  209. profile = load_profile_text(user_id)
  210. if not profile:
  211. return ""
  212. context = (
  213. f"\n## 关于用户\n"
  214. f"根据过往对话,我了解到该用户的一些偏好:\n{profile}\n"
  215. f"**注意:用户当前的问题/要求始终优先于历史偏好。"
  216. f"如果用户现在的说法与历史偏好矛盾,以用户现在说的为准。**\n"
  217. )
  218. # 3. 固话到会话级快照(后续同一 session 不再变动)
  219. if session_id is not None:
  220. _session_snapshot_cache[session_id] = context
  221. return context
  222. def get_cross_session_context(user_id: int, max_sessions: int = 5, max_messages: int = 6) -> str:
  223. """
  224. 获取用户跨会话的近期消息摘要(用于提取画像时的跨会话上下文)
  225. 查询该用户最近 N 个会话的前几条消息,拼接为纯文本返回。
  226. 这些文本不用于注入系统提示词,仅作为提取画像时的参考上下文。
  227. Args:
  228. user_id: 用户ID
  229. max_sessions: 最多取多少个会话
  230. max_messages: 每个会话最多取多少条消息
  231. Returns:
  232. 格式化的跨会话上下文文本
  233. """
  234. conn = get_db()
  235. try:
  236. # 获取用户最近的会话
  237. sessions = conn.execute(
  238. """SELECT id, title, created_at FROM chat_sessions
  239. WHERE user_id = ?
  240. ORDER BY updated_at DESC LIMIT ?""",
  241. (user_id, max_sessions)
  242. ).fetchall()
  243. if not sessions:
  244. return ""
  245. parts = []
  246. for sess in sessions:
  247. sess_id = sess["id"]
  248. # 每个会话取前几条消息
  249. messages = conn.execute(
  250. """SELECT role, content FROM chat_messages
  251. WHERE session_id = ?
  252. ORDER BY id ASC LIMIT ?""",
  253. (sess_id, max_messages)
  254. ).fetchall()
  255. if messages:
  256. msg_text = []
  257. for msg in messages:
  258. role_label = "用户" if msg["role"] == "user" else "助手"
  259. content = msg["content"][:100]
  260. msg_text.append(f" {role_label}:{content}")
  261. parts.append(
  262. f"【会话 {sess_id}】\n" + "\n".join(msg_text)
  263. )
  264. return "\n\n".join(parts) if parts else ""
  265. finally:
  266. conn.close()