chat_routes.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. # -*- coding: utf-8 -*-
  2. """
  3. 对话式数据解读接口 —— 统一入口,SSE 流式
  4. 根据用户意图自动路由到不同智能体:
  5. - 配风计划审查(上传PDF) → 审查管线
  6. - 数据解读/需风量计算/其他 → dialog_agent(统一通风对话助手)
  7. """
  8. import asyncio
  9. import re
  10. from fastapi import APIRouter, Depends, File, Form, UploadFile
  11. from fastapi.responses import StreamingResponse
  12. from pydantic import BaseModel, Field
  13. from api.auth import get_current_user
  14. from api.sse_core import sse_event_generator
  15. from api.intent import classify_intent
  16. from api.chat_model import get_chat_model
  17. from agents.vent_agent import get_dialog_agent
  18. from agents.review_agent import stream_review
  19. from db.chat_store import (
  20. get_messages, create_session, update_session_title,
  21. get_session_mode, set_session_mode, get_user_preferences,
  22. get_session_info,
  23. )
  24. router = APIRouter()
  25. # ── 权限模式 → LangGraph interrupt 配置 ──
  26. # 基于 DeepAgents / LangGraph 官方 API:
  27. # - interrupt_before: 在执行指定节点前暂停,等待人工审批
  28. # - interrupt_after: 在执行指定节点后暂停,让用户审查结果
  29. MODE_INTERRUPT = {
  30. "plan": {}, # 计划模式:由 request_plan_approval 工具触发程序化中断
  31. "full": {}, # 完全访问:无中断
  32. }
  33. class ResumeRequest(BaseModel):
  34. """中断恢复请求"""
  35. session_id: str = Field(..., description="会话 ID")
  36. thread_id: str | None = Field(None, description="LangGraph 线程 ID")
  37. action: str = Field("approve", description="approve 或 reject")
  38. # ── 会话标题生成(LLM)──
  39. async def generate_session_title(message: str) -> str:
  40. """使用 LLM 将用户首条消息压缩为会话标题(不超过20字)。
  41. 仅在开启新会话、且 messages 表中仅此一条用户消息时调用。
  42. 参考 classify_intent 的实现模式,使用 SUMMARY_MODEL(低延迟)。
  43. Args:
  44. message: 用户的首条消息文本
  45. Returns:
  46. 压缩后的标题字符串,不超过20字
  47. """
  48. try:
  49. model = get_chat_model(fast_mode=True)
  50. prompt = (
  51. "你是一个会话标题生成器。根据用户的第一条消息,生成一个简短的会话标题。\n"
  52. "要求:\n"
  53. "- 标题不超过20个字\n"
  54. "- 保留核心含义,去掉语气词和冗余描述\n"
  55. "- 只回复标题本身,不要加任何说明、引号或标点\n"
  56. "\n"
  57. f"用户消息:{message[:200]}\n"
  58. "标题:"
  59. )
  60. loop = asyncio.get_event_loop()
  61. result = await loop.run_in_executor(
  62. None,
  63. lambda: model.invoke([{"role": "user", "content": prompt}])
  64. )
  65. raw = result.content if hasattr(result, "content") else str(result)
  66. title = raw.strip().strip('"').strip("'").strip("《》").strip()
  67. if len(title) > 20:
  68. title = title[:20]
  69. return title if title else message[:20]
  70. except Exception as e:
  71. print(f"[标题生成] LLM 生成失败,回退到截断: {e}")
  72. return message[:20] + ("..." if len(message) > 20 else "")
  73. # ── POST /api/chat ──
  74. @router.post("/chat")
  75. async def chat(
  76. message: str = Form(...),
  77. session_id: str | None = Form(default=None),
  78. thread_id: str | None = Form(default=None),
  79. mode: str | None = Form(default=None),
  80. file: UploadFile | None = File(default=None),
  81. user_info: dict = Depends(get_current_user),
  82. ):
  83. """统一对话接口(SSE 流式)—— 支持文本对话和 PDF 配风计划审查。
  84. 根据用户意图自动路由到不同智能体:
  85. - 配风计划审查(上传PDF) → 审查管线(form-reviewer + data-checker + calc-verifier)
  86. - 数据解读/需风量计算/其他 → dialog_agent(统一通风对话助手)
  87. 请求头:
  88. - x-access-token: 必填,通风系统登录令牌
  89. 请求体: multipart/form-data
  90. - message: 用户消息(必填)
  91. - session_id: 可选,会话ID
  92. - thread_id: 可选,LangGraph 线程ID
  93. - mode: 可选,权限模式(plan | full),仅在新建会话时生效
  94. - file: 可选,附件(PDF/文档等)
  95. 响应: SSE 流(text/event-stream)
  96. """
  97. # ── 会话管理 ──
  98. is_new = not session_id
  99. user_name = user_info.get("username", "admin")
  100. session_id = session_id or create_session(user_name=user_name)
  101. thread_id = thread_id or session_id
  102. # 设置当前用户上下文(供偏好工具等获取调用者身份)
  103. from tools.vent_tools import _current_user, _current_session
  104. _current_user.set(user_name)
  105. _current_session.set(session_id)
  106. # 保存用户原始消息(后续注入偏好/计划前缀前),用于写入数据库
  107. original_message = message
  108. # 新建会话时,若前端传了 mode 参数则应用(否则沿用 DB 默认值)
  109. if is_new and mode in ("plan", "full"):
  110. set_session_mode(session_id, mode)
  111. # 读取会话权限模式
  112. # ⚠️ interrupt_before / interrupt_after 是 astream() 的独立 kwargs,
  113. # 不能放在 config dict 里,否则 LangGraph 不识别
  114. current_mode = get_session_mode(session_id)
  115. interrupt_kwargs = MODE_INTERRUPT.get(current_mode, {})
  116. config = {"configurable": {"thread_id": thread_id}}
  117. print(f"[模式] 会话 {session_id} 权限模式: {current_mode} → interrupt: {interrupt_kwargs}")
  118. # 提取附件文件名(如有)
  119. filename = file.filename if file else None
  120. # 自动设置会话标题(仅首轮对话触发,使用 LLM 压缩,不超过20字)
  121. messages = get_messages(session_id)
  122. if len(messages) <= 1:
  123. title = await generate_session_title(message)
  124. update_session_title(session_id, title)
  125. # ── 注入用户偏好记忆(自动触发)──
  126. prefs = get_user_preferences(user_name)
  127. if prefs:
  128. pref_lines = "\n".join(
  129. f"- (id:{p['id']}) [{p['category']}] {p['content']}"
  130. for p in prefs
  131. )
  132. message = (
  133. f"[用户偏好记忆]\n"
  134. f"以下是用户「{user_name}」保存的习惯和偏好,请在回复时主动参考:\n"
  135. f"{pref_lines}\n"
  136. f"\n"
  137. f"用户消息:{message}"
  138. )
  139. print(f"[偏好] 已为用户 {user_name} 注入 {len(prefs)} 条偏好记忆")
  140. # ── 注入引用会话内容(检测 #session:<uuid> 前缀)──
  141. # 注意:用 original_message(原始用户输入)匹配,避免偏好注入等前缀干扰
  142. ref_match = re.match(r'^#session:([a-f0-9-]+)\s*(.*)', original_message, re.IGNORECASE)
  143. if ref_match:
  144. ref_session_id = ref_match.group(1)
  145. user_actual_message = ref_match.group(2) or original_message
  146. try:
  147. ref_msgs = get_messages(ref_session_id, limit=100)
  148. ref_info = get_session_info(ref_session_id)
  149. ref_title = (ref_info.get("title") or "") if ref_info else ""
  150. if not ref_title:
  151. ref_title = ref_session_id[:8]
  152. # 始终更新聊天记录为干净的标题引用格式(无论是否有消息)
  153. original_message = f"[📋 引用会话「{ref_title}」] {user_actual_message}"
  154. if ref_msgs:
  155. ref_lines = []
  156. for m in ref_msgs:
  157. role_label = "用户" if m["role"] == "user" else "助手"
  158. ref_lines.append(f"[{role_label}]: {m['content']}")
  159. ref_text = "\n".join(ref_lines)
  160. message = (
  161. f"[引用的会话内容]\n"
  162. f"以下是用户引用的会话「{ref_title}」的完整历史对话:\n"
  163. f"{ref_text}\n\n"
  164. f"当前用户问题:{user_actual_message}"
  165. )
  166. print(f"[会话引用] 已注入会话 {ref_session_id}({ref_title})的 {len(ref_msgs)} 条消息")
  167. else:
  168. print(f"[会话引用] 会话 {ref_session_id}({ref_title})无消息,仅保存标题引用")
  169. except Exception as e:
  170. print(f"[会话引用] 注入失败: {e}")
  171. # ── Agent 推理意图(含附件文件名辅助判断)──
  172. intent = await classify_intent(message, filename)
  173. # ── 按意图路由 ──
  174. if intent == "review" and file is not None:
  175. # 配风计划审查 + 有文件 → 审查管线
  176. print(f"[路由] 意图: 配风计划审查 → stream_review (file={filename})")
  177. return StreamingResponse(
  178. stream_review(file, message, session_id),
  179. media_type="text/event-stream",
  180. headers={
  181. "Cache-Control": "no-cache",
  182. "Connection": "keep-alive",
  183. "X-Accel-Buffering": "no",
  184. "X-Session-Id": session_id,
  185. },
  186. )
  187. # ── 统一走通风对话助手(处理数据解读、需风量计算、审查无文件等所有场景)──
  188. agent = get_dialog_agent()
  189. agent_cn_name = "通风对话助手"
  190. if intent == "review" and file is None:
  191. # 审查意图但无文件 → 提示用户上传
  192. print(f"[路由] 意图: 配风计划审查 → 提示用户上传 PDF")
  193. message = f"用户想进行配风计划审查,但未上传附件。请提示用户上传配风计划PDF文件。用户原始消息:{message}"
  194. else:
  195. print(f"[路由] 意图: {intent} → dialog_agent(统一)")
  196. # 如有附件:保存到临时目录,将文件路径注入消息供 Agent 工具读取
  197. if filename and file is not None:
  198. from tools.pdf_tools import save_upload_file
  199. saved_path = await save_upload_file(file)
  200. message = f"用户上传了文件「{filename}」,文件临时路径:{saved_path}。{message}"
  201. print(f"[文件] 已保存上传文件: {saved_path}")
  202. # ── 计划模式:注入"先规划 → 审批 → 执行"指令 ──
  203. if current_mode == "plan" and agent_cn_name == "通风对话助手":
  204. plan_prefix = (
  205. "【系统指令:当前处于计划模式】\n"
  206. "你需要分三步工作:\n"
  207. "1. 规划阶段:自由使用工具获取信息、读取技能、查询数据,制定详细执行计划。\n"
  208. "2. 提交审批:计划制定完毕后,必须调用 request_plan_approval 工具提交计划摘要,等待人工审批。\n"
  209. " 在此之前严禁执行任何最终操作(如生成报告、写入文件、修改数据等)。\n"
  210. "3. 执行阶段:审批通过后,按照计划逐步执行任务,无需再次申请审批。\n"
  211. "\n用户问题:"
  212. )
  213. message = plan_prefix + message
  214. print(f"[计划模式] 已注入计划指令前缀 ({len(plan_prefix)} 字符)")
  215. return StreamingResponse(
  216. sse_event_generator(agent, message, thread_id, session_id, config, agent_cn_name,
  217. original_user_message=original_message,
  218. interrupt_before=interrupt_kwargs.get("interrupt_before"),
  219. interrupt_after=interrupt_kwargs.get("interrupt_after")),
  220. media_type="text/event-stream",
  221. headers={
  222. "Cache-Control": "no-cache",
  223. "Connection": "keep-alive",
  224. "X-Accel-Buffering": "no",
  225. "X-Session-Id": session_id,
  226. },
  227. )
  228. # ── POST /api/chat/resume ──
  229. @router.post("/chat/resume")
  230. async def resume_chat(
  231. req: ResumeRequest,
  232. user_info: dict = Depends(get_current_user),
  233. ):
  234. """恢复被中断的 LangGraph 对话(Human-in-the-Loop 审批)。
  235. 当计划模式(plan)时,Agent 调用 request_plan_approval 工具后暂停,
  236. 前端展示审批 UI。用户点击批准/拒绝后调用此接口继续执行。
  237. 请求头:
  238. - x-access-token: 必填,通风系统登录令牌
  239. """
  240. from api.sse_core import resume_stream
  241. session_id = req.session_id
  242. thread_id = req.thread_id or session_id
  243. # 设置当前会话上下文
  244. from tools.vent_tools import _current_session
  245. _current_session.set(session_id)
  246. # plan 模式审批后清除所有配置式中断(只审批一次,之后执行到底)
  247. # ⚠️ 必须 copy,否则 pop 会原地修改模块级常量 MODE_INTERRUPT
  248. mode = get_session_mode(session_id)
  249. interrupt_kwargs = dict(MODE_INTERRUPT.get(mode, {}))
  250. if mode == "plan":
  251. interrupt_kwargs.pop("interrupt_before", None)
  252. interrupt_kwargs.pop("interrupt_after", None)
  253. config = {"configurable": {"thread_id": thread_id}}
  254. return StreamingResponse(
  255. resume_stream(
  256. get_dialog_agent(),
  257. session_id,
  258. thread_id,
  259. config,
  260. agent_cn_name="通风对话助手",
  261. action=req.action,
  262. interrupt_before=interrupt_kwargs.get("interrupt_before"),
  263. interrupt_after=interrupt_kwargs.get("interrupt_after"),
  264. ),
  265. media_type="text/event-stream",
  266. headers={
  267. "Cache-Control": "no-cache",
  268. "Connection": "keep-alive",
  269. "X-Accel-Buffering": "no",
  270. "X-Session-Id": session_id,
  271. },
  272. )