chat_routes.py 11 KB

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