unified_chat.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. # backend/api/v1/unified_chat.py
  2. # 统一 AI 助手入口:意图识别 → 路由决策可视化 → Agent 执行
  3. #
  4. # SSE 事件类型:
  5. # routing_decision 路由决策结果(agent_type / confidence / reason / execution_mode)
  6. # progress QA 流水线进度提示(检索知识库...等)
  7. # token QA Agent 流式 token
  8. # guidance 非 QA 意图的引导消息(含跳转链接)
  9. # pipeline_plan 多 Agent 协同计划(steps 数组,含各步 label/desc/action_url)
  10. # meta QA 回答完毕后的元数据(answer_mode / sources 等)
  11. # done 流结束信号
  12. # error 异常
  13. import json
  14. import re
  15. from dataclasses import dataclass
  16. from fastapi import APIRouter, Depends
  17. from pydantic import BaseModel, Field
  18. from sse_starlette.sse import EventSourceResponse
  19. from langchain_core.messages import HumanMessage
  20. from backend.core.orchestrator import AgentType, ExecutionMode # 8.2 定义的枚举
  21. from backend.core.memory import build_thread_id # 5.10 的 thread_id 工具
  22. from backend.core.llm_factory import get_llm # 3.4 的 LLM 工厂
  23. from backend.dependencies import get_current_user # 3.6 的认证依赖
  24. from backend.core.logger import get_logger
  25. router = APIRouter()
  26. logger = get_logger(__name__)
  27. # ── Agent 中文名映射(路由卡片展示用)──────────────────────────
  28. _AGENT_DISPLAY = {
  29. AgentType.QA: "智能问答",
  30. AgentType.BID_REVIEW: "投标文件评分",
  31. AgentType.QUALIFICATION: "资质审查",
  32. AgentType.DEFENSE: "供应商答辩",
  33. }
  34. # ── 非 QA 意图的引导消息和跳转路径 ─────────────────────────────
  35. # bid_review/qualification/defense 需要专门的页面(上传文件/选岗位),统一入口不直接执行,
  36. # 而是返回引导卡片,让前端把用户导到对应功能页。
  37. _GUIDANCE = {
  38. AgentType.BID_REVIEW: {
  39. "message": "检测到您需要进行投标文件评分。请前往「投标文件评分」页面上传 Word 格式答卷,AI 将自动完成三轨评分并分析知识薄弱点。",
  40. "action_label": "前往投标文件评分",
  41. "action_url": "/bid_review",
  42. },
  43. AgentType.QUALIFICATION: {
  44. "message": "检测到您需要进行资质审查。请前往「资质审查」页面上传 PDF 格式资质材料,AI 将从六个维度进行评分并给出改进建议。",
  45. "action_label": "前往资质审查",
  46. "action_url": "/qualification",
  47. },
  48. AgentType.DEFENSE: {
  49. "message": "检测到您需要进行供应商答辩。请前往「供应商答辩」页面选择目标岗位和技术方向,AI 将进行技术原理与项目深挖双轨考察。",
  50. "action_label": "前往供应商答辩",
  51. "action_url": "/defense",
  52. },
  53. }
  54. # ── QA Agent 节点进度提示(与 qa.py 保持一致,8.5 用到)────────
  55. _PROGRESS_LABELS = {
  56. "classify_query": "理解问题中...",
  57. "load_memory_and_embed": "检索知识库...",
  58. "retrieve": "召回相关文档...",
  59. "rerank": "精排中...",
  60. "hyde_generate": "理解问题中...",
  61. "multi_query_rewrite": "改写查询中...",
  62. "generate_general": "思考中...",
  63. }
  64. _GENERATE_NODES = {"generate_rag", "generate_direct", "generate_general"} # QA 的三个生成节点
  65. # ── 去除末尾标点空白,用于关键词精确比对 ──────────────────────────
  66. _STRIP_TAIL_RE = re.compile(r"[\s!!??。~~,.,。]+$")
  67. # ── 类别一:问候(你好 / 在吗)────────────────────────────────────
  68. _HELLO_KEYWORDS = frozenset([
  69. "你好", "您好", "hi", "hello", "hey", "哈喽", "嗨",
  70. "在吗", "在不在", "在线吗", "有人吗",
  71. ])
  72. # ── 类别二:感谢(谢谢 / 辛苦了 / 太棒了)────────────────────────
  73. _THANKS_KEYWORDS = frozenset([
  74. "谢谢", "感谢", "多谢", "谢了", "非常感谢", "万分感谢",
  75. "辛苦了", "辛苦", "麻烦了", "不好意思",
  76. "太棒了", "太好了", "厉害", "厉害了", "牛", "牛啊", "牛逼",
  77. "好的好的", "明白了", "懂了", "知道了", "收到",
  78. ])
  79. # ── 类别三:道别(再见 / bye)──────────────────────────────────────
  80. _BYE_KEYWORDS = frozenset([
  81. "再见", "拜拜", "拜", "88", "886", "bye", "goodbye", "byebye",
  82. "下次见", "下次再聊", "先走了", "先撤了", "溜了", "闪了",
  83. ])
  84. # ── 类别四:身份询问(你是谁 / 介绍你自己)── 正则匹配 ──────────────
  85. _IDENTITY_RE = re.compile(
  86. r"(你|您)(是谁|叫什么|的名字|是什么|是.*AI|是.*机器人|是.*助手)"
  87. r"|介绍.{0,4}(你自己|自己|一下)"
  88. r"|你是谁"
  89. r"|你叫(啥|什么名)",
  90. re.IGNORECASE,
  91. )
  92. # ── 类别五:功能询问(你能做什么 / 怎么用)── 正则匹配 ──────────────
  93. _CAPABILITY_RE = re.compile(
  94. r"(你|您)(能|可以|会).{0,6}(做|帮|干)"
  95. r"|(你|您).{0,4}(功能|用途|能力|特点)"
  96. r"|怎么(用|使用)(你|您|这个)?"
  97. r"|(使用说明|帮助菜单|help|usage)"
  98. r"|你能帮(我|忙)吗",
  99. re.IGNORECASE,
  100. )
  101. # ── 多 Agent 能力说明(HELLO / IDENTITY / CAPABILITY 共享结尾)────
  102. _MULTI_AGENT_TIP = (
  103. "- **多 Agent 协同**:描述综合需求(如「帮我准备投标」),"
  104. "AI 将自动串联资质审查 + 供应商答辩两个 Agent 协同为您服务\n"
  105. )
  106. # ── 五类回复模板(节选问候,其余结构相同)──────────────────────────
  107. _REPLY_HELLO = (
  108. "您好!我是 TenderAgent AI 助手。\n\n"
  109. "我可以帮您:\n"
  110. "- **技术问答**:直接输入问题,我会从知识库中检索并解答\n"
  111. "- **投标文件评分**:告诉我「我要提交投标文件」,AI 完成三轨评分和知识薄弱点分析\n"
  112. "- **资质审查**:告诉我「帮我看看资质材料」,AI 给出六维度评分与改进建议\n"
  113. "- **供应商答辩**:告诉我「我要供应商答辩」,开启技术双轨考察\n"
  114. + _MULTI_AGENT_TIP
  115. + "\n请问有什么可以帮到您?"
  116. )
  117. _REPLY_THANKS = (
  118. "不客气,很高兴能帮到您!\n\n"
  119. "如果还有其他问题,随时告诉我,我随时在线。"
  120. )
  121. _REPLY_BYE = (
  122. "再见!希望今天的学习对您有所帮助,期待下次与您交流。\n\n"
  123. "祝您学习顺利!"
  124. )
  125. _REPLY_IDENTITY = (
  126. "我是 **TenderAgent AI 助手**,一个面向 IT 招投标培训的智能学习辅助系统。\n\n"
  127. "我由多个专业 Agent 协同构成:\n"
  128. "- **智能问答 Agent**:基于 RAG 知识库,7x24 即时解答技术问题\n"
  129. "- **投标文件评分 Agent**:AI 三轨并行评分 + 知识薄弱点分析\n"
  130. "- **供应商资质审查 Agent**:六维度质量评审,提供原文定位的修改建议\n"
  131. "- **供应商答辩 Agent**:技术原理 + 项目深挖双轨考察,生成结构化报告\n"
  132. + _MULTI_AGENT_TIP
  133. + "\n有什么可以帮到您吗?"
  134. )
  135. _REPLY_CAPABILITY = (
  136. "我能为您提供以下功能:\n\n"
  137. "**单 Agent 直达**\n"
  138. "- 直接输入技术问题 → 智能问答(RAG 知识库检索)\n"
  139. "- 「提交投标文件评分」 → 上传 Word 答卷,AI 完成评分并分析薄弱点\n"
  140. "- 「审查我的资质材料」 → 上传 PDF 资质材料,六维度评分 + 改进建议\n"
  141. "- 「开始供应商答辩」 → 选择岗位方向,进入双轨答辩考察\n\n"
  142. "**多 Agent 协同**\n"
  143. + _MULTI_AGENT_TIP
  144. + "\n直接告诉我您的需求,我会自动路由到最合适的 Agent。"
  145. )
  146. def _pre_filter(text: str) -> str | None:
  147. """
  148. 规则前置拦截,返回模板回复字符串;无命中返回 None(继续正常路由)。
  149. 五类社交/元场景均为零 Token 消耗(不调 LLM)。
  150. """
  151. t = text.strip()
  152. # print(f"t-->{t}")# 去首尾空白
  153. t_lower = _STRIP_TAIL_RE.sub("", t.lower()) # 转小写 + 去末尾标点,得到比对用文本
  154. # print(f't_lower-->{t_lower}')
  155. # 类别一:问候(精确匹配整句)
  156. if t_lower in _HELLO_KEYWORDS:
  157. return _REPLY_HELLO
  158. # 类别二:感谢(精确匹配整句)
  159. if t_lower in _THANKS_KEYWORDS:
  160. return _REPLY_THANKS
  161. # 类别三:道别(精确匹配整句)
  162. if t_lower in _BYE_KEYWORDS:
  163. return _REPLY_BYE
  164. # 类别四:身份询问(正则,用原文 t 而非去标点的 t_lower)
  165. if _IDENTITY_RE.search(t):
  166. return _REPLY_IDENTITY
  167. # 类别五:功能询问(正则)
  168. if _CAPABILITY_RE.search(t):
  169. return _REPLY_CAPABILITY
  170. return None # 五类都没命中 → 交给 LLM 路由
  171. # ── LLM 路由 Prompt:让模型把用户输入归到 6 类之一,返回 JSON ──
  172. _ROUTE_PROMPT = """判断用户需求应路由到哪个功能。
  173. 可选功能:
  174. - qa : 技术知识问答(用户直接提问,不涉及文件上传)
  175. - bid_review : 投标文件/作业评分(需上传 Word 答卷,用户提到"评分""作业""提交投标文件"等)
  176. - qualification : 资质审查(需上传 PDF 资质材料,用户提到"资质材料""帮我看看资质材料"等)
  177. - defense : 供应商答辩(用户提到"答辩""练习答辩""供应商答辩"等)
  178. - multi_agent : 综合投标准备(用户同时提到资质材料 + 答辩,或"全套""一条龙""投标准备"等)
  179. - clarify : 意图不明确,无法判断,需要追问
  180. 严格按以下 JSON 格式返回,不要有其他内容:
  181. {{"label": "功能名", "reason": "一句话说明判断依据"}}
  182. 用户输入:{message}"""
  183. # label → AgentType 映射(multi_agent / clarify 用 QA 占位)
  184. _LABEL_TO_AGENT: dict[str, AgentType] = {
  185. "qa": AgentType.QA,
  186. "bid_review": AgentType.BID_REVIEW,
  187. "qualification": AgentType.QUALIFICATION,
  188. "defense": AgentType.DEFENSE,
  189. "multi_agent": AgentType.QA, # pipeline 入口,以 QA 作占位
  190. "clarify": AgentType.QA,
  191. }
  192. # label → ExecutionMode 映射
  193. _LABEL_TO_MODE: dict[str, ExecutionMode] = {
  194. "qa": ExecutionMode.SINGLE,
  195. "bid_review": ExecutionMode.SINGLE,
  196. "qualification": ExecutionMode.SINGLE,
  197. "defense": ExecutionMode.SINGLE,
  198. "multi_agent": ExecutionMode.PIPELINE,
  199. "clarify": ExecutionMode.CLARIFY,
  200. }
  201. _VALID_LABELS = frozenset(_LABEL_TO_AGENT.keys()) # 合法 label 集合(校验 LLM 输出用)
  202. @dataclass
  203. class _RouteResult:
  204. """LLM 路由结果,对齐前端展示需要的字段。"""
  205. label: str # "qa"|"bid_review"|"qualification"|"defense"|"multi_agent"|"clarify"
  206. agent_type: AgentType # 由 label 映射
  207. execution_mode: ExecutionMode # 由 label 映射
  208. confidence: float # LLM 路由固定返回 0.85(无置信度概念,仅供前端展示)
  209. reason: str # 一句话判断依据
  210. async def _llm_route(message: str) -> _RouteResult:
  211. """
  212. 调用 LLM 对用户输入做跨 Agent 路由判断。
  213. 替代原 IntentRouter(MiniLM 7 分类 + LLM 兜底)——跨 Agent 路由直接用 LLM 判断,
  214. 无需本地分类模型(这个路径调用频率低,LLM 开销可接受)。
  215. 异常时降级返回 qa 路由,不阻断 SSE 流。
  216. 参数:message(用户原始输入)
  217. 返回:_RouteResult(label + agent_type + execution_mode + confidence + reason)
  218. """
  219. try:
  220. llm = get_llm("intent", temperature=0) # 取"intent"专用模型(3.4 配置),温度0求稳定
  221. resp = await llm.ainvoke([ # 调 LLM
  222. HumanMessage(content=_ROUTE_PROMPT.format(message=message))
  223. ])
  224. raw = resp.text.strip()
  225. parsed = json.loads(raw) # 解析 LLM 返回的 JSON
  226. label = parsed.get("label", "qa").strip().lower() # 取 label(缺省 qa)
  227. reason = parsed.get("reason", "LLM 路由判断") # 取判断依据
  228. if label not in _VALID_LABELS: # LLM 返回了非法 label → 降级 qa
  229. logger.warning("unified_chat.llm_route_unknown_label", label=label, fallback="qa")
  230. label = "qa"
  231. logger.info("unified_chat.llm_route_result", label=label, reason=reason)
  232. except Exception as e: # 调用/解析失败 → 降级 qa,不阻断流
  233. logger.warning("unified_chat.llm_route_failed", error=str(e), fallback="qa")
  234. label = "qa"
  235. reason = "路由判断异常,默认转入智能问答"
  236. return _RouteResult( # 组装路由结果
  237. label=label,
  238. agent_type=_LABEL_TO_AGENT[label], # label → AgentType
  239. execution_mode=_LABEL_TO_MODE[label], # label → ExecutionMode
  240. confidence=0.85, # 固定值,仅供前端展示
  241. reason=reason,
  242. )
  243. class UnifiedChatRequest(BaseModel):
  244. session_id: str = Field(..., description="会话 ID")
  245. message: str = Field(..., min_length=1, max_length=2000, description="用户输入") # 非空、限长
  246. def _sse(data: dict) -> dict:
  247. """把 dict 包成 sse_starlette 要的格式:{"data": "<JSON字符串>"}。ensure_ascii=False 保留中文。"""
  248. return {"data": json.dumps(data, ensure_ascii=False)}
  249. @router.post("/stream")
  250. async def unified_chat_stream(
  251. req: UnifiedChatRequest, # 请求体:session_id + message
  252. current_user: dict = Depends(get_current_user), # 认证依赖(3.6)
  253. ):
  254. """
  255. 统一 AI 助手流式接口(SSE)。
  256. 请求:{session_id, message}
  257. 响应:text/event-stream,事件序列随路由结果不同而不同
  258. 流程:
  259. 1. 规则前置拦截(五类社交/元场景,零 Token 直接返回)
  260. 2. LLM 路由判断(_llm_route,DeepSeek 直接分类,无本地模型)
  261. 3. 推送 routing_decision 事件(前端显示路由卡片)
  262. 4a. qa 路由 → 流式执行 QA Agent
  263. 4b. bid_review/qualification/defense → 推送 guidance 引导跳转
  264. 4c. multi_agent → 推送投标全链路 pipeline 计划
  265. 4d. clarify → 推送追问提示
  266. """
  267. async def event_generator(): # 异步生成器:逐个 yield SSE 事件
  268. # ── Step 0:规则前置拦截(零 Token,五类模板回复)──────────
  269. pre_reply = _pre_filter(req.message) # 命中则返回模板字符串,否则 None
  270. if pre_reply is not None:
  271. yield _sse({"type": "token", "content": pre_reply}) # 模板内容当作一个 token 事件推出
  272. yield _sse({"type": "done"}) # 直接结束
  273. return # 不再走路由(省 LLM)
  274. # ── Step 1:LLM 路由判断 ────────────────────────────
  275. decision = await _llm_route(req.message) # 得到 _RouteResult(label/agent_type/mode/...)
  276. # ── Step 2:推送路由决策卡片(前端显示"已转接到 XX")──────
  277. yield _sse({
  278. "type": "routing_decision",
  279. "agent_type": decision.agent_type.value,
  280. "agent_display": _AGENT_DISPLAY.get(decision.agent_type, ""), # 中文名
  281. "confidence": round(decision.confidence, 4),
  282. "reason": decision.reason,
  283. "execution_mode": decision.execution_mode.value,
  284. })
  285. # ── Step 3:按路由结果分发处理 ──────────────────────
  286. label = decision.label # 用 label 决定走哪条分支
  287. # ── 3a:qa → 流式执行 QA Agent ──────────────────────
  288. if label == "qa":
  289. async for event in _stream_qa_agent(req, current_user): # 把 QA 的流式事件透传出去
  290. yield event
  291. # ── 3b:bid_review / qualification / defense → 引导跳转 ────────
  292. elif label in ("bid_review", "qualification", "defense"):
  293. guidance = _GUIDANCE.get(decision.agent_type, {}) # 取对应引导文案
  294. yield _sse({
  295. "type": "guidance",
  296. "message": guidance.get("message", "请前往对应功能页面操作"),
  297. "action_label": guidance.get("action_label", ""),
  298. "action_url": guidance.get("action_url", "/dashboard"),
  299. })
  300. # ── 3c:multi_agent → 投标全链路 Pipeline 计划 ──────
  301. elif label == "multi_agent":
  302. yield _sse({
  303. "type": "pipeline_plan",
  304. "title": "投标全链路",
  305. "intro": "已为您规划「投标全链路」,建议按顺序完成以下两个步骤,"
  306. "资质审查结果将作为供应商答辩的参考依据。",
  307. "steps": [
  308. {
  309. "step": 1,
  310. "agent_type": "qualification",
  311. "label": "资质审查",
  312. "desc": "上传 PDF 资质材料,AI 从六个维度评分并给出带原文定位的改进建议",
  313. "action_label": "开始资质审查",
  314. "action_url": "/qualification",
  315. "tip": "建议先完成此步骤,审查结果将作为答辩的参考依据",
  316. },
  317. {
  318. "step": 2,
  319. "agent_type": "defense",
  320. "label": "供应商答辩",
  321. "desc": "基于资质材料背景,进行技术原理 + 项目深挖双轨考察,生成结构化雷达图报告",
  322. "action_label": "开始供应商答辩",
  323. "action_url": "/defense",
  324. "tip": "完成资质审查后进行效果更佳",
  325. },
  326. ],
  327. })
  328. # ── 3d:clarify → 追问提示 ───────────────────────────
  329. else:
  330. yield _sse({
  331. "type": "guidance",
  332. "message": "您的问题我还不太确定应该用哪个功能来帮您,能否描述得更具体一些?"
  333. "例如:您是想提问技术知识、提交投标文件评分、审查资质材料,还是进行供应商答辩?",
  334. "action_label": "",
  335. "action_url": "",
  336. })
  337. yield _sse({"type": "done"}) # 所有分支最终都推一个 done 收尾
  338. return EventSourceResponse(event_generator()) # 用 SSE 响应包装生成器
  339. async def _stream_qa_agent(req: UnifiedChatRequest, current_user: dict):
  340. """
  341. 在统一入口中流式执行 QA Agent,复用 qa.py 的 astream_events 逻辑。
  342. 读取:req.message / req.session_id、current_user
  343. yield:progress / token / meta / error 四类 SSE 事件
  344. """
  345. from backend.core.orchestrator import get_orchestrator # 懒加载取编排器单例
  346. orchestrator = get_orchestrator()
  347. graph = orchestrator._get_agent_graph(AgentType.QA) # 复用编排器缓存的 QA 图(不重复 build)
  348. thread_id = build_thread_id(current_user["user_id"], req.session_id) # 拼检查点 key
  349. initial_state = { # 构造 QA 图输入 State
  350. "messages": [HumanMessage(content=req.message)],
  351. "bidder_id": current_user["user_id"],
  352. "tenant_id": current_user["tenant_id"],
  353. "session_id": req.session_id,
  354. "tender_document_id": None,
  355. "query_type": "PRECISE", # QA 的查询类型(见第 5 章)
  356. }
  357. config = {"configurable": {"thread_id": thread_id}}
  358. answer_mode = None
  359. confidence = 0.0
  360. sources: list[str] = [] # 引用来源
  361. try:
  362. # astream_events 流式跑 QA 图,逐事件处理
  363. async for event in graph.astream_events(initial_state, config=config, version="v2"):
  364. evt = event["event"] # 事件类型
  365. node = event.get("metadata", {}).get("langgraph_node", "") # 来自哪个节点
  366. # ① 节点开始且在进度表里 → 推 progress("检索知识库..."等)
  367. if evt == "on_chain_start" and node in _PROGRESS_LABELS:
  368. yield _sse({"type": "progress", "stage": _PROGRESS_LABELS[node]})
  369. # ② 生成节点的 token 流 → 推 token(答案逐字)
  370. elif evt == "on_chat_model_stream" and node in _GENERATE_NODES:
  371. chunk = event["data"].get("chunk")
  372. if chunk and chunk.content:
  373. yield _sse({"type": "token", "content": chunk.content})
  374. # ③ 生成节点结束 → 回填 answer_mode / sources / confidence(供最后 meta 用)
  375. elif evt == "on_chain_end" and node in _GENERATE_NODES:
  376. output = event["data"].get("output", {})
  377. # 仅在值非空时更新,防止后续 on_chain_end 覆盖已捕获的 "rag" 值
  378. if output.get("answer_mode"):
  379. answer_mode = output["answer_mode"]
  380. if output.get("sources"):
  381. sources = output["sources"]
  382. confidence = (output.get("structured_output") or {}).get("confidence", confidence)
  383. except Exception as e: # QA 执行异常 → 推 error 并结束
  384. logger.error("unified_chat.qa_stream_error", error=str(e), exc_info=True)
  385. yield _sse({"type": "error", "message": "问答服务异常,请稍后重试"})
  386. return
  387. # ④ QA 答完 → 推 meta(答案模式 / 来源 / 置信度),前端据此显示"引用了 N 篇"
  388. if answer_mode:
  389. yield _sse({"type": "meta", "answer_mode": answer_mode, "confidence": confidence, "sources": sources})
  390. if __name__ == '__main__':
  391. print(_pre_filter(text="你好."))