chat_store.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. """会话与消息存取。
  2. MySQL 是会话历史的**事实源**:图状态(checkpoint)丢了可以从这里补种,
  3. 反过来不行。所以每次对话结束都必须有一条 assistant 消息落库。
  4. 约定:``messages`` 里的 ``created_at`` 只到秒级且同秒可能重复,因此所有
  5. 「取最近 N 条」的查询都带上 ``rowid``/``id`` 的次级排序,保证分页稳定。
  6. """
  7. from __future__ import annotations
  8. import json
  9. from typing import Any, Sequence
  10. from db import db_conn
  11. from obs import get_logger
  12. logger = get_logger(__name__)
  13. def _dumps(value: Any) -> str | None:
  14. """把结构化字段序列化成 JSON 字符串;None 保持 None。"""
  15. if value is None:
  16. return None
  17. if isinstance(value, str):
  18. return value
  19. return json.dumps(value, ensure_ascii=False)
  20. def _loads(value: Any, default: Any) -> Any:
  21. """反序列化 JSON 字段;空值或坏数据回落默认值(不抛异常)。"""
  22. if value is None or value == "":
  23. return default
  24. if isinstance(value, (list, dict)):
  25. return value
  26. try:
  27. return json.loads(value)
  28. except (TypeError, ValueError):
  29. return default
  30. # ============================================================
  31. # 会话
  32. # ============================================================
  33. def create_session(user_name: str, mode: str = "full", title: str = "") -> dict[str, Any]:
  34. """新建会话并返回其完整行。会话 ID 即 LangGraph thread_id。"""
  35. session_id = db_conn.new_id()
  36. stamp = db_conn.fmt_dt()
  37. with db_conn.transaction():
  38. db_conn.execute(
  39. "INSERT INTO sessions (id, title, user_name, mode, pinned, created_at, updated_at)"
  40. " VALUES (?, ?, ?, ?, 0, ?, ?)",
  41. (session_id, title, user_name, mode, stamp, stamp),
  42. )
  43. logger.info("新建会话 session_id=%s user=%s mode=%s", session_id, user_name, mode)
  44. return get_session(session_id) or {}
  45. def get_session(session_id: str) -> dict[str, Any] | None:
  46. """按 ID 取会话;不存在返回 None。"""
  47. row = db_conn.query_one("SELECT * FROM sessions WHERE id = ?", (session_id,))
  48. return _hydrate_session(row)
  49. def get_session_for_user(session_id: str, user_name: str) -> dict[str, Any] | None:
  50. """取会话并校验归属。用于 400/404 判定前的存在性与越权检查。"""
  51. session = get_session(session_id)
  52. if session and session.get("user_name") != user_name:
  53. return None
  54. return session
  55. def list_sessions(user_name: str, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
  56. """列出某用户的会话,置顶优先、更新时间倒序。"""
  57. rows = db_conn.query(
  58. "SELECT * FROM sessions WHERE user_name = ?"
  59. " ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ? OFFSET ?",
  60. (user_name, limit, offset),
  61. )
  62. return [_hydrate_session(row) for row in rows]
  63. def search_sessions(user_name: str, keyword: str, limit: int = 50) -> list[dict[str, Any]]:
  64. """按标题模糊搜索会话。"""
  65. rows = db_conn.query(
  66. "SELECT * FROM sessions WHERE user_name = ? AND title LIKE ?"
  67. " ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ?",
  68. (user_name, f"%{keyword}%", limit),
  69. )
  70. return [_hydrate_session(row) for row in rows]
  71. def update_session_title(session_id: str, title: str) -> None:
  72. """更新会话标题。"""
  73. db_conn.execute(
  74. "UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
  75. (title, db_conn.fmt_dt(), session_id),
  76. )
  77. def update_session_mode(session_id: str, mode: str) -> None:
  78. """更新会话的权限模式(plan | full)。"""
  79. db_conn.execute(
  80. "UPDATE sessions SET mode = ?, updated_at = ? WHERE id = ?",
  81. (mode, db_conn.fmt_dt(), session_id),
  82. )
  83. def update_session_pin(session_id: str, pinned: bool) -> None:
  84. """设置/取消会话置顶。"""
  85. db_conn.execute(
  86. "UPDATE sessions SET pinned = ?, updated_at = ? WHERE id = ?",
  87. (1 if pinned else 0, db_conn.fmt_dt(), session_id),
  88. )
  89. def touch_session(session_id: str) -> None:
  90. """刷新会话的 updated_at,让最近活跃的会话排在列表前面。"""
  91. db_conn.execute(
  92. "UPDATE sessions SET updated_at = ? WHERE id = ?", (db_conn.fmt_dt(), session_id)
  93. )
  94. def delete_session(session_id: str) -> None:
  95. """删除会话及其全部消息、用量、压缩存档。
  96. 注意:调用方还须清除 LangGraph checkpoint 线程,否则图状态会残留并
  97. 在下次复用同 ID 时「诈尸」。
  98. """
  99. with db_conn.transaction():
  100. db_conn.execute("DELETE FROM business_session_agents WHERE session_id = ?", (session_id,))
  101. db_conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
  102. db_conn.execute("DELETE FROM session_context_usage WHERE session_id = ?", (session_id,))
  103. db_conn.execute("DELETE FROM context_archives WHERE session_id = ?", (session_id,))
  104. db_conn.execute("DELETE FROM execution_runs WHERE session_id = ?", (session_id,))
  105. db_conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
  106. logger.info("删除会话 session_id=%s", session_id)
  107. # ============================================================
  108. # 消息
  109. # ============================================================
  110. def save_message(
  111. session_id: str,
  112. role: str,
  113. content: str,
  114. tool_calls: Any = None,
  115. citations: Any = None,
  116. ) -> str:
  117. """落一条消息,返回消息 ID。user 与 assistant 都走这里。"""
  118. message_id = db_conn.new_id()
  119. stamp = db_conn.fmt_dt()
  120. with db_conn.transaction():
  121. db_conn.execute(
  122. "INSERT INTO messages (id, session_id, msg_role, content, tool_calls, citations, created_at)"
  123. " VALUES (?, ?, ?, ?, ?, ?, ?)",
  124. (
  125. message_id,
  126. session_id,
  127. role,
  128. content or "",
  129. _dumps(tool_calls),
  130. _dumps(citations),
  131. stamp,
  132. ),
  133. )
  134. db_conn.execute(
  135. "UPDATE sessions SET updated_at = ? WHERE id = ?", (stamp, session_id)
  136. )
  137. return message_id
  138. def get_messages(
  139. session_id: str, limit: int | None = None, offset: int = 0
  140. ) -> list[dict[str, Any]]:
  141. """取会话消息,按时间正序返回。
  142. ``limit`` 给定时取**最近** limit 条(先倒序截断再正序还原),这正是
  143. 冷启动补种图状态需要的语义。
  144. """
  145. if limit is None:
  146. rows = db_conn.query(
  147. "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, id ASC"
  148. " LIMIT ? OFFSET ?",
  149. (session_id, 100000, offset),
  150. )
  151. elif offset == 0:
  152. rows = db_conn.query(
  153. "SELECT * FROM (SELECT * FROM messages WHERE session_id = ?"
  154. " ORDER BY created_at DESC, id DESC LIMIT ?) AS recent"
  155. " ORDER BY created_at ASC, id ASC",
  156. (session_id, limit),
  157. )
  158. else:
  159. # 带偏移的分页:先按正序定位窗口
  160. rows = db_conn.query(
  161. "SELECT * FROM messages WHERE session_id = ?"
  162. " ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?",
  163. (session_id, limit, offset),
  164. )
  165. return [_hydrate_message(row) for row in rows]
  166. def count_messages(session_id: str) -> int:
  167. """会话消息条数。"""
  168. return int(db_conn.scalar("SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,), 0) or 0)
  169. def delete_message(message_id: str) -> bool:
  170. """删除单条消息,返回是否命中。"""
  171. return db_conn.execute("DELETE FROM messages WHERE id = ?", (message_id,)) > 0
  172. # ============================================================
  173. # 上下文用量与压缩存档
  174. # ============================================================
  175. def save_context_usage(
  176. session_id: str,
  177. turn: int,
  178. input_tokens: int,
  179. output_tokens: int,
  180. cache_read_tokens: int = 0,
  181. usage_source: str = "provider",
  182. compacted: bool = False,
  183. compact_count: int = 0,
  184. snapshot: Any = None,
  185. ) -> str:
  186. """记录一轮对话的上下文用量。"""
  187. row_id = db_conn.new_id()
  188. total = int(input_tokens) + int(output_tokens)
  189. db_conn.execute(
  190. "INSERT INTO session_context_usage (id, session_id, turn, input_tokens, output_tokens,"
  191. " cache_read_tokens, total_tokens, usage_source, compacted, compact_count, snapshot, created_at)"
  192. " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
  193. (
  194. row_id,
  195. session_id,
  196. int(turn),
  197. int(input_tokens),
  198. int(output_tokens),
  199. int(cache_read_tokens),
  200. total,
  201. usage_source,
  202. 1 if compacted else 0,
  203. int(compact_count),
  204. _dumps(snapshot),
  205. db_conn.fmt_dt(),
  206. ),
  207. )
  208. return row_id
  209. def get_context_usage(session_id: str, limit: int = 50) -> dict[str, Any]:
  210. """取会话的用量明细(倒序)与会话累计压缩次数。"""
  211. rows = db_conn.query(
  212. "SELECT * FROM session_context_usage WHERE session_id = ?"
  213. " ORDER BY created_at DESC, id DESC LIMIT ?",
  214. (session_id, limit),
  215. )
  216. history = []
  217. for row in rows:
  218. item = dict(row)
  219. item["snapshot"] = _loads(item.get("snapshot"), {})
  220. item["compacted"] = bool(item.get("compacted"))
  221. history.append(item)
  222. compact_count = int(
  223. db_conn.scalar(
  224. "SELECT MAX(compact_count) FROM session_context_usage WHERE session_id = ?",
  225. (session_id,),
  226. 0,
  227. )
  228. or 0
  229. )
  230. latest = history[0] if history else None
  231. return {
  232. "session_id": session_id,
  233. "compact_count": compact_count,
  234. "latest": latest,
  235. "history": history,
  236. }
  237. def save_context_archive(
  238. session_id: str, turn: int, file_path: str, tokens: int = 0
  239. ) -> str:
  240. """登记一次「压缩前原文」的落盘位置,供事后追溯。"""
  241. row_id = db_conn.new_id()
  242. db_conn.execute(
  243. "INSERT INTO context_archives (id, session_id, turn, file_path, tokens, created_at)"
  244. " VALUES (?, ?, ?, ?, ?, ?)",
  245. (row_id, session_id, int(turn), file_path, int(tokens), db_conn.fmt_dt()),
  246. )
  247. return row_id
  248. def list_context_archives(session_id: str) -> list[dict[str, Any]]:
  249. """列出会话的压缩存档索引。"""
  250. return db_conn.query(
  251. "SELECT * FROM context_archives WHERE session_id = ? ORDER BY created_at ASC",
  252. (session_id,),
  253. )
  254. # ============================================================
  255. # 用户偏好
  256. # ============================================================
  257. def save_preference(user_name: str, key: str, value: str) -> None:
  258. """写入/覆盖一条用户偏好(先查后写,避免方言相关的 upsert 语法)。"""
  259. existing = db_conn.query_one(
  260. "SELECT id FROM user_preferences WHERE user_name = ? AND pref_key = ?",
  261. (user_name, key),
  262. )
  263. stamp = db_conn.fmt_dt()
  264. if existing:
  265. db_conn.execute(
  266. "UPDATE user_preferences SET pref_value = ?, updated_at = ? WHERE id = ?",
  267. (value, stamp, existing["id"]),
  268. )
  269. else:
  270. db_conn.execute(
  271. "INSERT INTO user_preferences (id, user_name, pref_key, pref_value, updated_at)"
  272. " VALUES (?, ?, ?, ?, ?)",
  273. (db_conn.new_id(), user_name, key, value, stamp),
  274. )
  275. def list_preferences(user_name: str) -> list[dict[str, Any]]:
  276. """列出某用户的全部偏好。"""
  277. return db_conn.query(
  278. "SELECT pref_key, pref_value, updated_at FROM user_preferences"
  279. " WHERE user_name = ? ORDER BY pref_key ASC",
  280. (user_name,),
  281. )
  282. def delete_preference(user_name: str, key: str) -> bool:
  283. """删除一条偏好,返回是否命中。"""
  284. return db_conn.execute(
  285. "DELETE FROM user_preferences WHERE user_name = ? AND pref_key = ?",
  286. (user_name, key),
  287. ) > 0
  288. def preferences_as_text(user_name: str, limit: int = 20) -> str:
  289. """把偏好渲染成可注入提示词的一行文本;无偏好返回空串。"""
  290. rows = list_preferences(user_name)[:limit]
  291. if not rows:
  292. return ""
  293. pairs = [f"{row['pref_key']}={row['pref_value']}" for row in rows]
  294. return ";".join(pairs)
  295. # ============================================================
  296. # 行归一化
  297. # ============================================================
  298. def _hydrate_session(row: dict[str, Any] | None) -> dict[str, Any] | None:
  299. if not row:
  300. return None
  301. session = dict(row)
  302. session["pinned"] = bool(session.get("pinned"))
  303. return session
  304. def _hydrate_message(row: dict[str, Any]) -> dict[str, Any]:
  305. message = dict(row)
  306. message["tool_calls"] = _loads(message.get("tool_calls"), [])
  307. message["citations"] = _loads(message.get("citations"), [])
  308. return message