decision_engine.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from typing import Protocol
  5. from app.config import Settings
  6. from app.schemas import (
  7. Evidence,
  8. PlanStep,
  9. QualityGrade,
  10. RetrievalPlan,
  11. RouteDecision,
  12. RouteName,
  13. )
  14. USER_ID_PATTERN = re.compile(r"\bU\d{4}\b", re.IGNORECASE)
  15. PRODUCT_NAMES = ("XPhone 15 Pro", "AirBuds Pro", "USB-C Charger", "SmartWatch S")
  16. POLICY_KEYWORDS = {
  17. "return_policy": ("退货", "退款", "七日无理由", "换货", "售后"),
  18. "shipping_policy": ("运费", "包邮", "配送", "物流", "送达"),
  19. "price_protection": ("价保", "保价", "差价", "降价"),
  20. }
  21. def extract_user_id(query: str) -> str:
  22. match = USER_ID_PATTERN.search(query.upper())
  23. return match.group(0) if match else ""
  24. def extract_product_name(query: str) -> str:
  25. lowered = query.lower()
  26. return next((name for name in PRODUCT_NAMES if name.lower() in lowered), "")
  27. def extract_policy_type(query: str) -> str:
  28. for policy_type, keywords in POLICY_KEYWORDS.items():
  29. if any(keyword in query for keyword in keywords):
  30. return policy_type
  31. return ""
  32. class DecisionEngine(Protocol):
  33. def route(self, query: str, max_rounds: int) -> RouteDecision: ...
  34. def plan(self, query: str, decision: RouteDecision) -> RetrievalPlan: ...
  35. def grade(
  36. self,
  37. query: str,
  38. decision: RouteDecision,
  39. evidence: list[Evidence],
  40. current_round: int,
  41. max_rounds: int,
  42. min_score: float,
  43. ) -> QualityGrade: ...
  44. def rewrite(self, query: str, grade: QualityGrade) -> str: ...
  45. def answer(self, query: str, evidence: list[Evidence], partial: bool = False) -> str: ...
  46. class DemoDecisionEngine:
  47. """确定性演示引擎:便于无 API Key 启动、调试和自动化测试。"""
  48. SQL_TERMS = ("订单", "几单", "多少单", "实付", "总额", "金额", "统计", "最近")
  49. DOC_TERMS = ("规则", "政策", "退货", "退款", "运费", "包邮", "价保", "售后")
  50. WEB_TERMS = ("网页", "官网", "公开资料", "最新公告", "新闻", "互联网")
  51. DIRECT_TERMS = ("改写", "润色", "翻译", "缩短", "转成")
  52. def route(self, query: str, max_rounds: int) -> RouteDecision:
  53. stripped = query.strip()
  54. user_id = extract_user_id(stripped)
  55. product_name = extract_product_name(stripped)
  56. policy_type = extract_policy_type(stripped)
  57. # 安全门控和缺失槽位检查必须先于数据源选择,避免先访问再拒绝。
  58. if any(term in stripped for term in ("所有买家手机号", "密码", "支付密钥")):
  59. return RouteDecision(
  60. needs_retrieval=False,
  61. intent="restricted_request",
  62. routes=[RouteName.REFUSE],
  63. confidence="high",
  64. reason_code="SECURITY_POLICY",
  65. max_rounds=max_rounds,
  66. )
  67. if any(term in stripped for term in self.DIRECT_TERMS):
  68. return RouteDecision(
  69. needs_retrieval=False,
  70. intent="text_transformation",
  71. routes=[RouteName.DIRECT_ANSWER],
  72. confidence="high",
  73. reason_code="NO_EXTERNAL_KNOWLEDGE_REQUIRED",
  74. max_rounds=max_rounds,
  75. )
  76. needs_sql = any(term in stripped for term in self.SQL_TERMS)
  77. needs_docs = any(term in stripped for term in self.DOC_TERMS)
  78. needs_web = any(term in stripped for term in self.WEB_TERMS)
  79. if needs_sql and not user_id:
  80. return RouteDecision(
  81. needs_retrieval=False,
  82. intent="missing_authenticated_user",
  83. routes=[RouteName.CLARIFY],
  84. confidence="high",
  85. reason_code="MISSING_USER_ID",
  86. max_rounds=max_rounds,
  87. )
  88. if "这件商品" in stripped and not product_name:
  89. return RouteDecision(
  90. needs_retrieval=False,
  91. intent="missing_product",
  92. routes=[RouteName.CLARIFY],
  93. confidence="high",
  94. reason_code="MISSING_PRODUCT_NAME",
  95. max_rounds=max_rounds,
  96. )
  97. filters = {
  98. key: value
  99. for key, value in {
  100. "user_id": user_id,
  101. "product_name": product_name,
  102. "policy_type": policy_type,
  103. }.items()
  104. if value
  105. }
  106. # routes 是可并行的数据源集合,不使用单标签覆盖复合意图。
  107. routes: list[RouteName] = []
  108. if needs_docs:
  109. routes.append(RouteName.MILVUS_SEARCH)
  110. if needs_sql:
  111. routes.append(RouteName.SQL_QUERY)
  112. if needs_web:
  113. routes.append(RouteName.WEB_SEARCH)
  114. if len(routes) > 1:
  115. return RouteDecision(
  116. needs_retrieval=True,
  117. intent="multi_source_research",
  118. routes=routes,
  119. requires_decomposition=True,
  120. confidence="high" if not needs_sql or user_id else "medium",
  121. reason_code="MULTIPLE_DATA_SOURCES_REQUIRED",
  122. filters=filters,
  123. max_rounds=max_rounds,
  124. )
  125. if needs_sql:
  126. return RouteDecision(
  127. needs_retrieval=True,
  128. intent="order_statistics",
  129. routes=[RouteName.SQL_QUERY],
  130. confidence="high",
  131. reason_code="EXACT_AGGREGATION_REQUIRED",
  132. filters=filters,
  133. max_rounds=max_rounds,
  134. )
  135. if needs_web:
  136. return RouteDecision(
  137. needs_retrieval=True,
  138. intent="public_web_research",
  139. routes=[RouteName.WEB_SEARCH],
  140. confidence="high",
  141. reason_code="CURRENT_PUBLIC_INFORMATION_REQUIRED",
  142. filters=filters,
  143. max_rounds=max_rounds,
  144. )
  145. return RouteDecision(
  146. needs_retrieval=True,
  147. intent="commerce_policy_qa",
  148. routes=[RouteName.MILVUS_SEARCH],
  149. confidence="high" if policy_type else "medium",
  150. reason_code="INTERNAL_DOCUMENT_REQUIRED",
  151. filters=filters,
  152. max_rounds=max_rounds,
  153. )
  154. def plan(self, query: str, decision: RouteDecision) -> RetrievalPlan:
  155. user_id = str(decision.filters.get("user_id", ""))
  156. product_name = str(decision.filters.get("product_name", ""))
  157. policy_type = str(decision.filters.get("policy_type", ""))
  158. steps: list[PlanStep] = []
  159. # 当前三类读取任务互不依赖,因此不填写 depends_on,由 Executor 并行执行。
  160. if RouteName.MILVUS_SEARCH in decision.routes:
  161. steps.append(
  162. PlanStep(
  163. id="search_policy",
  164. tool=RouteName.MILVUS_SEARCH,
  165. query=query,
  166. arguments={"policy_type": policy_type, "top_k": 4},
  167. )
  168. )
  169. if RouteName.SQL_QUERY in decision.routes:
  170. steps.append(
  171. PlanStep(
  172. id="query_statistics",
  173. tool=RouteName.SQL_QUERY,
  174. query=(
  175. f"统计用户 {user_id} 最近 30 天"
  176. f"{product_name or '全部商品'}的有效订单"
  177. ),
  178. arguments={
  179. "user_id": user_id,
  180. "days": 30,
  181. "product_keyword": product_name,
  182. },
  183. )
  184. )
  185. if RouteName.WEB_SEARCH in decision.routes:
  186. steps.append(
  187. PlanStep(
  188. id="search_web",
  189. tool=RouteName.WEB_SEARCH,
  190. query=query,
  191. arguments={"max_results": 5},
  192. )
  193. )
  194. return RetrievalPlan(goal=query, steps=steps)
  195. def grade(
  196. self,
  197. query: str,
  198. decision: RouteDecision,
  199. evidence: list[Evidence],
  200. current_round: int,
  201. max_rounds: int,
  202. min_score: float,
  203. ) -> QualityGrade:
  204. # 先做确定性的来源覆盖检查,再决定是否需要模型参与语义评分。
  205. required_sources = set()
  206. if RouteName.MILVUS_SEARCH in decision.routes:
  207. required_sources.add("milvus")
  208. if RouteName.SQL_QUERY in decision.routes:
  209. required_sources.add("sql")
  210. if RouteName.WEB_SEARCH in decision.routes:
  211. required_sources.add("web")
  212. available_sources = {item.source_type for item in evidence}
  213. missing = sorted(required_sources - available_sources)
  214. vector_evidence = [item for item in evidence if item.source_type == "milvus"]
  215. vector_relevant = any(
  216. item.score is None or item.score >= min_score for item in vector_evidence
  217. )
  218. if "milvus" not in required_sources:
  219. vector_relevant = True
  220. if not missing and vector_relevant:
  221. return QualityGrade(
  222. relevant=True,
  223. sufficient=True,
  224. recommended_action="accept",
  225. reason="所需数据源均返回证据,向量结果达到最低质量门槛。",
  226. )
  227. # 只有预算尚未耗尽时才能 Rewrite;否则必须有界终止。
  228. if current_round < max_rounds:
  229. return QualityGrade(
  230. relevant=bool(evidence),
  231. sufficient=False,
  232. missing_aspects=missing or ["高相关技术文档"],
  233. recommended_action="rewrite_query",
  234. reason="证据不完整或向量结果相关性不足,允许改写后重试。",
  235. )
  236. return QualityGrade(
  237. relevant=bool(evidence),
  238. sufficient=False,
  239. missing_aspects=missing or ["高相关技术文档"],
  240. recommended_action="stop",
  241. reason="达到最大检索轮数,停止继续调用工具。",
  242. )
  243. def rewrite(self, query: str, grade: QualityGrade) -> str:
  244. missing = "、".join(grade.missing_aspects)
  245. suffix = f"电商平台内部政策 订单范围 官网公告 {missing}".strip()
  246. return f"{query};补充检索条件:{suffix}"
  247. def answer(self, query: str, evidence: list[Evidence], partial: bool = False) -> str:
  248. if not evidence:
  249. if any(term in query for term in self.DIRECT_TERMS):
  250. return "该请求不需要外部检索,应由文本处理模型直接完成。"
  251. return "未获得能够支持回答的证据。"
  252. prefix = "当前仅能给出部分结论。" if partial else "基于已检索证据,结论如下。"
  253. lines = [prefix]
  254. for index, item in enumerate(evidence, start=1):
  255. lines.append(f"- {item.content} [{index}]")
  256. return "\n".join(lines)
  257. class DeepSeekDecisionEngine(DemoDecisionEngine):
  258. """使用 DeepSeek 的 OpenAI 兼容接口完成路由、评分和答案生成。"""
  259. def __init__(self, settings: Settings) -> None:
  260. # 凭证只从 Settings 读取,并在创建 SDK 客户端时短暂解密。
  261. api_key = settings.deepseek_api_key.get_secret_value()
  262. if not api_key:
  263. raise RuntimeError("LLM_PROVIDER=deepseek 时必须配置 DEEPSEEK_API_KEY")
  264. try:
  265. from langchain_openai import ChatOpenAI
  266. except ImportError as exc:
  267. raise RuntimeError(
  268. "缺少 DeepSeek 依赖,请执行 uv sync --extra test --extra deepseek"
  269. ) from exc
  270. common_kwargs = {
  271. "model": settings.deepseek_model_name,
  272. "api_key": api_key,
  273. "base_url": settings.deepseek_base_url,
  274. "max_retries": 2,
  275. }
  276. # 最终答案可以使用 Thinking;开关由 .env 控制。
  277. answer_thinking = "enabled" if settings.deepseek_answer_thinking else "disabled"
  278. self.llm = ChatOpenAI(
  279. **common_kwargs,
  280. extra_body={"thinking": {"type": answer_thinking}},
  281. )
  282. # with_structured_output(function_calling) 会发送强制 tool_choice。
  283. # DeepSeek Thinking 模式不接受该组合,因此路由与评分必须关闭 Thinking。
  284. self.structured_llm = ChatOpenAI(
  285. **common_kwargs,
  286. temperature=0,
  287. extra_body={"thinking": {"type": "disabled"}},
  288. )
  289. self.router = self.structured_llm.with_structured_output(
  290. RouteDecision,
  291. method="function_calling",
  292. )
  293. self.grader = self.structured_llm.with_structured_output(
  294. QualityGrade,
  295. method="function_calling",
  296. )
  297. def route(self, query: str, max_rounds: int) -> RouteDecision:
  298. prompt = f"""
  299. 你是电商售后系统的路由器。只输出符合 Schema 的结果。
  300. 可选路径:direct_answer、milvus_search、sql_query、web_search、clarify、refuse。
  301. 内部退货、运费和价保政策使用 milvus_search;订单数量和金额使用 sql_query;
  302. 官网、新闻、最新公告和公开资料使用 web_search;
  303. 多类需求同时存在时返回对应的多条路径,并设置 requires_decomposition=true。
  304. 订单统计缺少受控用户编号时选择 clarify,敏感越权请求选择 refuse。
  305. filters 中只填写已从原问题明确提取的 user_id、product_name、policy_type。
  306. 最大检索轮数:{max_rounds}
  307. 用户问题:{query}
  308. """.strip()
  309. result = self.router.invoke(prompt)
  310. # 部分兼容接口可能返回普通文本而非 Function Call,LangChain 此时返回 None。
  311. # 路由属于关键控制决策,结构化输出缺失时回退到确定性规则,不允许继续空值。
  312. if result is None:
  313. return super().route(query, max_rounds)
  314. # 关键业务槽位使用确定性解析覆盖模型结果,避免模型改写用户身份。
  315. deterministic_filters = {
  316. key: value
  317. for key, value in {
  318. "user_id": extract_user_id(query),
  319. "product_name": extract_product_name(query),
  320. "policy_type": extract_policy_type(query),
  321. }.items()
  322. if value
  323. }
  324. result.filters = {**result.filters, **deterministic_filters}
  325. if RouteName.SQL_QUERY in result.routes and "user_id" not in result.filters:
  326. return RouteDecision(
  327. needs_retrieval=False,
  328. intent="missing_authenticated_user",
  329. routes=[RouteName.CLARIFY],
  330. confidence="high",
  331. reason_code="MISSING_USER_ID",
  332. max_rounds=max_rounds,
  333. )
  334. result.max_rounds = max_rounds
  335. return result
  336. def grade(
  337. self,
  338. query: str,
  339. decision: RouteDecision,
  340. evidence: list[Evidence],
  341. current_round: int,
  342. max_rounds: int,
  343. min_score: float,
  344. ) -> QualityGrade:
  345. prompt = f"""
  346. 判断证据是否足以回答问题。recommended_action 只能是 accept、rewrite_query 或 stop。
  347. 当前轮次:{current_round}/{max_rounds}
  348. 问题:{query}
  349. 路由:{decision.model_dump_json()}
  350. 证据:{json.dumps([item.model_dump() for item in evidence], ensure_ascii=False)}
  351. """.strip()
  352. result = self.grader.invoke(prompt)
  353. # Grader 输出为空时使用确定性来源覆盖、相似度和轮次判断兜底。
  354. if result is None:
  355. return super().grade(
  356. query=query,
  357. decision=decision,
  358. evidence=evidence,
  359. current_round=current_round,
  360. max_rounds=max_rounds,
  361. min_score=min_score,
  362. )
  363. return result
  364. def answer(self, query: str, evidence: list[Evidence], partial: bool = False) -> str:
  365. prompt = f"""
  366. 严格依据证据回答,不得补充证据之外的事实。每个关键结论使用 [序号] 引用。
  367. 问题:{query}
  368. 是否为部分证据:{partial}
  369. 证据:{json.dumps([item.model_dump() for item in evidence], ensure_ascii=False)}
  370. """.strip()
  371. return str(self.llm.invoke(prompt).content)
  372. def create_decision_engine(settings: Settings) -> DecisionEngine:
  373. if settings.llm_provider == "demo":
  374. return DemoDecisionEngine()
  375. if settings.llm_provider == "deepseek":
  376. return DeepSeekDecisionEngine(settings)
  377. raise ValueError(f"不支持的 LLM_PROVIDER: {settings.llm_provider}")