memory.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. # 记忆是被 ChatService 调用的辅助模块,不是独立服务。
  2. # 启动 S4.2:创建空记忆 → 注入数据库连接 → 恢复偏好、查询历史和短期消息。
  3. # 主流程 3/11:add_message → 截取最近消息 → 尝试写入数据库。
  4. # 主流程 4/6:读取历史和偏好;查询类意图另外记录整段查询和当前任务。
  5. # 旁路:网页保存偏好或清空记忆时才调用对应方法;下面各方法不是依次全执行。
  6. # 当前对象和数据库表均未使用 user_id/session_id 区分用户;current_task 仅保存在内存。
  7. """
  8. 需求:实现SmartBizTrip的记忆管理模块,包括短期对话记忆、用户偏好记忆和当前任务上下文
  9. 支持 MySQL 数据库持久化,服务重启后自动恢复记忆数据
  10. 架构图:
  11. ┌────────────────────────────────────────────────────────────────────────────┐
  12. │ ConversationMemory 类 │
  13. │ │
  14. │ ┌─────────────────── 内存层 (Memory) ───────────────────┐ │
  15. │ │ │ │
  16. │ │ short_term_messages[] ── 短期对话 (最多10条) │ │
  17. │ │ user_profile{} ── 用户偏好 (座位/舱位等) │ │
  18. │ │ current_task{} ── 当前任务上下文 (类型/城市) │ │
  19. │ │ entity_history[] ── 提取的实体历史 (最多20条) │ │
  20. │ └────────────────────┬──────────────────────────────────┘ │
  21. │ │ add / update / get │
  22. │ ▼ │
  23. │ ┌─────────────────── 持久层 (MySQL) ────────────────────┐ │
  24. │ │ │ │
  25. │ │ _db_conn ── mysql.connector 连接 │ │
  26. │ │ set_db_connection() / _ensure_db() 连接管理 │ │
  27. │ │ │ │
  28. │ │ ┌──────────────────────────────────────────────┐ │ │
  29. │ │ │ user_profiles 表 │ │ │
  30. │ │ │ (profile_key PK, profile_value) │ │ │
  31. │ │ │ save_profile_to_db() ← INSERT ... UPSERT │ │ │
  32. │ │ │ load_profile_from_db() ← SELECT │ │ │
  33. │ │ └──────────────────────────────────────────────┘ │ │
  34. │ │ ┌──────────────────────────────────────────────┐ │ │
  35. │ │ │ query_history 表 │ │ │
  36. │ │ │ (id PK, intent_type, query_content, │ │ │
  37. │ │ │ query_time) │ │ │
  38. │ │ │ save_entity_to_db() ← INSERT │ │ │
  39. │ │ │ load_entities_from_db() ← SELECT ORDER BY │ │ │
  40. │ │ └──────────────────────────────────────────────┘ │ │
  41. │ │ ┌──────────────────────────────────────────────┐ │ │
  42. │ │ │ short_term_messages 表 │ │ │
  43. │ │ │ (id PK, role, content, message_time, │ │ │
  44. │ │ │ message_order) │ │ │
  45. │ │ │ save_messages_to_db() ← DELETE + INSERT │ │ │
  46. │ │ │ load_messages_from_db() ← SELECT ORDER BY │ │ │
  47. │ │ └──────────────────────────────────────────────┘ │ │
  48. │ │ │ │
  49. │ │ clear_all_from_db() ← 清空三张表 │ │
  50. │ └────────────────────────────────────────────────────────┘ │
  51. │ │
  52. │ ┌─────────────────── 序列化层 ──────────────────────────┐ │
  53. │ │ │ │
  54. │ │ to_dict() → 导出记忆为 dict (实体仅取最近5条) │ │
  55. │ │ from_dict() ← 从 dict 恢复记忆 (@classmethod) │ │
  56. │ └────────────────────────────────────────────────────────┘ │
  57. └────────────────────────────────────────────────────────────────────────────┘
  58. 数据流向:
  59. 用户消息 → add_message() → short_term_messages[] → save_messages_to_db()
  60. → MySQL: short_term_messages 表
  61. → (DELETE + 批量 INSERT)
  62. 偏好更新 → update_profile() → user_profile{} → save_profile_to_db()
  63. → MySQL: user_profiles 表
  64. → (INSERT ... ON DUPLICATE KEY UPDATE)
  65. 实体提取 → extract_entities() → entity_history[] → save_entity_to_db()
  66. → MySQL: query_history 表
  67. → (单条 INSERT)
  68. 服务重启 → load_profile_from_db() → 恢复 user_profile{}
  69. load_messages_from_db() → 恢复 short_term_messages[]
  70. load_entities_from_db() → 恢复 entity_history[]
  71. 方法分类:
  72. 写操作 (Write) 读操作 (Read) 序列化 (Serialize)
  73. ────────────────── ────────────────── ──────────────────
  74. add_message() get_short_term_text() to_dict()
  75. update_profile() get_profile_text() from_dict()
  76. update_task_context()
  77. extract_entities()
  78. clear()
  79. 持久化 (DB Write) 加载 (DB Read) 连接管理
  80. ────────────────── ────────────────── ──────────────────
  81. save_messages_to_db() load_messages_from_db() set_db_connection()
  82. save_profile_to_db() load_profile_from_db() _ensure_db()
  83. save_entity_to_db() load_entities_from_db()
  84. clear_all_from_db()
  85. """
  86. from datetime import datetime
  87. import json
  88. import mysql.connector
  89. import pytz
  90. class ConversationMemory:
  91. """管理对话记忆的类,包括短期对话、用户偏好和任务上下文"""
  92. def __init__(self, short_term_limit: int = 10):
  93. # 【记忆 M1:初始化】先创建空容器,数据库连接由 ChatService 稍后注入。
  94. # 由 ChatService 创建。short_term_messages 按时间顺序保存消息,user_profile 按键保存偏好。
  95. # current_task 保存最近正在处理的任务字段,entity_history 保存查询记录;它们用途不同,不会自动相互同步。
  96. # _db_conn 初始为 None,表示尚未启用持久化;内存操作仍可以先进行。
  97. self.short_term_messages = [] # 短期对话消息列表,最多保留short_term_limit条
  98. self.user_profile = {} # 用户偏好,如 {"座位喜好": "二等座", "cabin_type": "经济舱"}
  99. self.current_task = {} # 当前任务上下文,如 {"type": "train", "departure_city": "北京", "arrival_city": "上海"}
  100. self.short_term_limit = short_term_limit # 短期记忆最大长度
  101. self.entity_history = [] # 历史提取的关键实体
  102. self._db_conn = None # 数据库连接
  103. def set_db_connection(self, db_conn):
  104. """设置数据库连接(由 ChatService 注入)"""
  105. # 【记忆 M2:连接注入】保存已有连接,不在此方法里创建数据库连接。
  106. # 调用方传入已经建立的 MySQL 连接,也可以传 None 表示只使用内存。
  107. # 这是保存引用:后面的读写方法使用同一个连接对象,而不是每次复制一条连接。
  108. self._db_conn = db_conn
  109. def _ensure_db(self):
  110. """确保数据库连接有效,断开则自动重连"""
  111. # 【持久化内部步骤 1】每次读写前检查连接;有连接对象时通过 ping 尝试重连。
  112. # 由各个数据库读写方法在执行 SQL 前调用;正常完成不需要返回数据。
  113. # 没有连接就抛 RuntimeError;已有连接则 ping 检查并允许驱动重连,失败时转成统一错误。
  114. if self._db_conn is None:
  115. raise RuntimeError("数据库连接未初始化")
  116. try:
  117. self._db_conn.ping(reconnect=True)
  118. except Exception:
  119. raise RuntimeError("数据库连接已断开")
  120. def add_message(self, role: str, content: str):
  121. """添加消息到短期记忆,并持久化到数据库"""
  122. # 【主流程 3/11,记忆内部步骤 1】追加角色、文本和时间;用户消息和助手消息分别调用一次。
  123. # 输入 role 是 user/assistant,content 是要保存的全文;本方法通过修改列表产生作用,没有业务返回值。
  124. # 用户输入时调用一次,助手最终回复时再调用一次;时间戳用于显示消息记录时间。
  125. self.short_term_messages.append({
  126. "role": role,
  127. "content": content,
  128. "timestamp": datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%H:%M:%S')
  129. })
  130. # 超过限制则移除最旧的消息
  131. # 【记忆内部步骤 2】只保留最近 short_term_limit 条消息;“条”按消息计,不按对话轮数计。
  132. if len(self.short_term_messages) > self.short_term_limit:
  133. # 负数切片从末尾向前数,保留最后 N 条;不是按角色各保留 N 条,也不按自然日划分。
  134. self.short_term_messages = self.short_term_messages[-self.short_term_limit:]
  135. # 持久化到数据库
  136. self.save_messages_to_db()
  137. def get_short_term_text(self) -> str:
  138. """获取短期对话的文本格式,用于意图识别和代理调用"""
  139. # 【主流程 4/6】按消息列表顺序拼成 User/Assistant 文本,供模型和子助手理解上下文。
  140. # 输入来自实例里的短期消息列表,输出一个用换行连接的字符串。
  141. # 例如两条消息变成“User: 北京明天天气
  142. # Assistant: ...”;模型收到的是这段文本,不是数据库游标。
  143. lines = []
  144. for msg in self.short_term_messages:
  145. role_label = "User" if msg["role"] == "user" else "Assistant"
  146. lines.append(f"{role_label}: {msg['content']}")
  147. return '\n'.join(lines)
  148. def update_profile(self, profile_update: dict, replace: bool = False):
  149. """更新用户偏好,并持久化到数据库"""
  150. # 【偏好内部步骤 1】先留存旧值,再按 replace 选择整体替换或增量合并。
  151. # 调用方给出 profile_update 字典;replace=True 是整体替换,False 是按键合并。
  152. # 例如旧值 {"座席": "二等座", "舱位": "经济舱"},只提交新座席时,合并保留舱位,替换会移除舱位。
  153. # copy 保存旧字典的浅拷贝,用于写入过程抛出异常时恢复;不是再次向数据库读取旧值。
  154. previous = self.user_profile.copy()
  155. if replace:
  156. self.user_profile = dict(profile_update)
  157. else:
  158. self.user_profile.update(profile_update)
  159. try:
  160. # 【偏好内部步骤 2】尝试写库;若异常向外抛出,就恢复内存中的旧偏好。
  161. # 当前保存方法只有 replace=True 时会继续抛出写库异常;合并模式失败只打印信息。
  162. self.save_profile_to_db(replace=replace)
  163. except Exception:
  164. # 恢复内存后用单独的 raise 把原异常继续向上抛,让 API 调用方知道保存失败。
  165. self.user_profile = previous
  166. raise
  167. def update_task_context(self, task_update: dict):
  168. """更新当前任务上下文"""
  169. # 【主流程 6,记忆旁路】合并当前任务字段;此方法不写数据库,也不调用模型提取字段。
  170. # dict.update 将本次字段覆盖到 current_task;未提交的旧键仍保留。
  171. # 当前 ChatService 通常只提交 type/query,不能把它理解为已自动提取出完整行程字段。
  172. self.current_task.update(task_update)
  173. def extract_entities(self, intent_type: str, query: str):
  174. """记录意图类型和查询文本到历史,并持久化到数据库。"""
  175. # 【主流程 6,查询历史内部步骤 1】实际保存的是意图类型和整段 query。
  176. # 方法名虽然叫 extract_entities,这里没有再解析城市、日期,也没有额外模型调用。
  177. # 输入 intent_type 记录是哪类查询,query 记录该次查询文本。
  178. # 先追加内存历史,再裁剪为最多 20 条,最后单独向 query_history 表追加记录。
  179. self.entity_history.append({
  180. "type": intent_type,
  181. "query": query,
  182. "timestamp": datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S')
  183. })
  184. # 只保留最近20条实体记录
  185. if len(self.entity_history) > 20:
  186. self.entity_history = self.entity_history[-20:]
  187. # 持久化单条到数据库
  188. self.save_entity_to_db(intent_type, query)
  189. def get_profile_text(self) -> str:
  190. """获取用户偏好的文本描述,用于注入到prompt中"""
  191. # 【主流程 4】把偏好键值对转成可放入提示词的文本;没有偏好时返回固定说明。
  192. # 字典非空时逐对转换为“键: 值”,再用中文逗号连接;返回给 intent_agent 放进提示模板。
  193. if not self.user_profile:
  194. return "无已知的用户偏好"
  195. items = [f"{k}: {v}" for k, v in self.user_profile.items()]
  196. return ",".join(items)
  197. def clear(self):
  198. """清空所有记忆,同时清除数据库数据"""
  199. # 【清空内部步骤 1】先清空四类内存状态,再尝试删除数据库中的三张记忆表数据。
  200. # 本方法先清内存再调用 clear_all_from_db;数据库清理失败时该方法不会自动恢复原内存。
  201. # 因此这是代码当前执行顺序,不是跨内存和数据库的一笔原子事务。
  202. self.short_term_messages = []
  203. self.user_profile = {}
  204. self.current_task = {}
  205. self.entity_history = []
  206. self.clear_all_from_db()
  207. def to_dict(self) -> dict:
  208. """导出记忆为字典,用于序列化"""
  209. # 【辅助导出】把当前内存整理成字典,查询历史仅取最近 5 条;不是完整数据库备份。
  210. # 返回便于传递的普通字典;列表和字典多数仍引用原对象,没有在此处做完整深拷贝。
  211. return {
  212. "short_term_messages": self.short_term_messages,
  213. "user_profile": self.user_profile,
  214. "current_task": self.current_task,
  215. "entity_history": self.entity_history[-5:] # 只导出最近5条
  216. }
  217. @classmethod
  218. def from_dict(cls, data: dict) -> 'ConversationMemory':
  219. """从字典导入记忆"""
  220. # 【辅助导入 1 → 2】创建新对象并填入字典中的数据,不建立连接,也不自动写回数据库。
  221. # @classmethod 使第一个参数 cls 指向类;cls() 创建一个新记忆实例。
  222. # 与普通方法的 self 不同,这里无需先有实例;缺失字段使用空列表或空字典补齐。
  223. memory = cls()
  224. memory.short_term_messages = data.get("short_term_messages", [])
  225. memory.user_profile = data.get("user_profile", {})
  226. memory.current_task = data.get("current_task", {})
  227. memory.entity_history = data.get("entity_history", [])
  228. return memory
  229. # ==================== 数据库持久化 ====================
  230. def save_profile_to_db(self, replace: bool = False):
  231. """将用户偏好持久化到数据库(UPSERT)"""
  232. # 【偏好内部步骤 2】无连接则仅保留内存;有连接时检查连接并开始写偏好。
  233. # 调用者 update_profile 已修改内存,此处负责同步偏好表。
  234. # 每个键对应一条记录;数据库用唯一键判断 INSERT 还是 UPDATE,最后统一 commit。
  235. if self._db_conn is None:
  236. return
  237. # 先给 cursor 一个明确初值;即使创建游标之前就失败,finally 也能判断是否需要关闭。
  238. cursor = None
  239. try:
  240. self._ensure_db()
  241. cursor = self._db_conn.cursor()
  242. # 替换模式先删除旧偏好,保证网页删除的键不会残留;合并模式只做 UPSERT。
  243. if replace:
  244. cursor.execute("DELETE FROM user_profiles")
  245. for key, value in self.user_profile.items():
  246. cursor.execute(
  247. "INSERT INTO user_profiles (profile_key, profile_value) "
  248. "VALUES (%s, %s) ON DUPLICATE KEY UPDATE profile_value = %s",
  249. # 三个参数依次对应 INSERT 的键、INSERT 的值、冲突时 UPDATE 的值。
  250. # value 转成字符串写库,因此重新加载后不会自动恢复为原来的数字或列表类型。
  251. (key, str(value), str(value))
  252. )
  253. # 【偏好内部步骤 3】本轮写入提交后才完成;异常分支 rollback,finally 关闭游标。
  254. self._db_conn.commit()
  255. except Exception as e:
  256. # 回滚本次尚未提交的数据库修改;后面的 if replace 决定是否继续向调用方抛出异常。
  257. self._db_conn.rollback()
  258. if replace:
  259. raise
  260. print(f"保存用户偏好到数据库失败: {e}")
  261. # finally 在正常和异常路径都会执行;只负责释放游标,不改变本轮成功或失败的结果。
  262. finally:
  263. if cursor is not None:
  264. cursor.close()
  265. def load_profile_from_db(self):
  266. """从数据库加载用户偏好"""
  267. # 【启动恢复 1】读取数据库偏好,重建内存中的 user_profile 字典。
  268. # 服务初始化时调用。返回的 rows 是字典列表,最后的字典推导式把它改成“偏好键→偏好值”。
  269. # 这里只 SELECT,不需要提交写事务;加载结果会整体替换当前 user_profile。
  270. if self._db_conn is None:
  271. return
  272. try:
  273. self._ensure_db()
  274. cursor = self._db_conn.cursor(dictionary=True)
  275. cursor.execute("SELECT profile_key, profile_value FROM user_profiles")
  276. rows = cursor.fetchall()
  277. cursor.close()
  278. self.user_profile = {row["profile_key"]: row["profile_value"] for row in rows}
  279. except Exception as e:
  280. print(f"从数据库加载用户偏好失败: {e}")
  281. def save_entity_to_db(self, intent_type: str, query: str):
  282. """将单条查询实体持久化到数据库"""
  283. # 【查询历史内部步骤 2】每次新增一条历史记录;内存只保留 20 条不代表表中也只存 20 条。
  284. # 输入是本次查询类型和文本;query_content 保存成 JSON,query_time 单独保存完整日期时间。
  285. # 此处只追加一条记录,不会因为内存裁剪而删除旧数据库历史。
  286. if self._db_conn is None:
  287. return
  288. try:
  289. self._ensure_db()
  290. cursor = self._db_conn.cursor()
  291. now = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S')
  292. cursor.execute(
  293. "INSERT INTO query_history (intent_type, query_content, query_time) "
  294. "VALUES (%s, %s, %s)",
  295. (intent_type, json.dumps({"query": query}, ensure_ascii=False), now)
  296. )
  297. self._db_conn.commit()
  298. cursor.close()
  299. except Exception as e:
  300. print(f"保存查询实体到数据库失败: {e}")
  301. def load_entities_from_db(self, limit: int = 20):
  302. """从数据库加载查询历史,按时间倒序取最近N条"""
  303. # 【启动恢复 2】数据库先取最新的 limit 条,再反转为从旧到新的阅读顺序。
  304. # limit 限制加载条数,默认 20。先取最新记录,再恢复为最旧在前、最新在后的列表。
  305. # 每条 query_content 单独尝试 json.loads;某条解析失败时,该条 query 使用空串,继续处理其余记录。
  306. if self._db_conn is None:
  307. return
  308. try:
  309. self._ensure_db()
  310. cursor = self._db_conn.cursor(dictionary=True)
  311. cursor.execute(
  312. "SELECT intent_type, query_content, query_time FROM query_history "
  313. "ORDER BY query_time DESC LIMIT %s",
  314. # 逗号表示只有一个元素的元组,用作 SQL 参数容器;(limit) 仍只是普通数值。
  315. (limit,)
  316. )
  317. rows = cursor.fetchall()
  318. cursor.close()
  319. # 按时间正序排列(倒序取出来后翻转)
  320. # SQL 用 DESC 取最近记录,reverse 原地反转列表,恢复聊天中习惯的时间正序。
  321. rows.reverse()
  322. self.entity_history = []
  323. for row in rows:
  324. try:
  325. query_data = json.loads(row["query_content"])
  326. query_text = query_data.get("query", "")
  327. except Exception:
  328. query_text = ""
  329. self.entity_history.append({
  330. "type": row["intent_type"],
  331. "query": query_text,
  332. "timestamp": row["query_time"].strftime('%Y-%m-%d %H:%M:%S') if row["query_time"] else ""
  333. })
  334. except Exception as e:
  335. print(f"从数据库加载查询历史失败: {e}")
  336. def save_messages_to_db(self):
  337. """将短期对话覆盖写入数据库(先删除旧数据,再写入当前列表)"""
  338. # 【主流程 3/11,记忆内部步骤 3】无连接时跳过;有连接时用当前短期列表覆盖表内数据。
  339. # 保存的是截取后的最近消息,不是完整聊天记录;message_order 用于恢复原顺序。
  340. # 保存策略是当前短期消息的整表快照:DELETE 旧快照 → 逐条 INSERT → commit。
  341. # 与 query_history 的“每次追加”不同,short_term_messages 表不会在此路径保留无限历史。
  342. if self._db_conn is None:
  343. return
  344. try:
  345. self._ensure_db()
  346. cursor = self._db_conn.cursor()
  347. # 【记忆内部步骤 4】先删旧列表,再按序插入当前列表,最后 commit。
  348. cursor.execute("DELETE FROM short_term_messages")
  349. # enumerate 同时给出下标 i 和消息 msg;i 从 0 开始,写入 message_order 供加载时排序。
  350. for i, msg in enumerate(self.short_term_messages):
  351. cursor.execute(
  352. "INSERT INTO short_term_messages (role, content, message_time, message_order) "
  353. "VALUES (%s, %s, %s, %s)",
  354. (msg["role"], msg["content"], msg["timestamp"], i)
  355. )
  356. self._db_conn.commit()
  357. cursor.close()
  358. except Exception as e:
  359. print(f"保存短期对话到数据库失败: {e}")
  360. def load_messages_from_db(self):
  361. """从数据库加载短期对话"""
  362. # 【启动恢复 3】按 message_order 恢复短期消息;随后 ChatService 同步到自己的 messages。
  363. # 按 message_order 升序还原保存时的列表次序,而不是按格式化后的时间字符串排序。
  364. # 若没有查到记录,列表推导式得到 [],短期记忆恢复为空列表。
  365. if self._db_conn is None:
  366. return
  367. try:
  368. self._ensure_db()
  369. cursor = self._db_conn.cursor(dictionary=True)
  370. cursor.execute(
  371. "SELECT role, content, message_time FROM short_term_messages ORDER BY message_order ASC"
  372. )
  373. rows = cursor.fetchall()
  374. cursor.close()
  375. self.short_term_messages = [
  376. {"role": row["role"], "content": row["content"], "timestamp": row["message_time"]}
  377. for row in rows
  378. ]
  379. except Exception as e:
  380. print(f"从数据库加载短期对话失败: {e}")
  381. def clear_all_from_db(self):
  382. """清空数据库中所有记忆数据"""
  383. # 【清空内部步骤 2】删除三张记忆表的记录并提交;不删除天气、票务等业务数据。
  384. # 三条 DELETE 依次清除短期消息、查询历史、用户偏好,最后统一提交。
  385. # 这三条语句没有 WHERE 用户条件,影响的是这些表中的所有记录。
  386. if self._db_conn is None:
  387. return
  388. try:
  389. self._ensure_db()
  390. cursor = self._db_conn.cursor()
  391. cursor.execute("DELETE FROM short_term_messages")
  392. cursor.execute("DELETE FROM query_history")
  393. cursor.execute("DELETE FROM user_profiles")
  394. self._db_conn.commit()
  395. cursor.close()
  396. except Exception as e:
  397. print(f"清空数据库记忆失败: {e}")