|
|
@@ -0,0 +1,613 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+import re
|
|
|
+from typing import Protocol
|
|
|
+
|
|
|
+from app.config import Settings
|
|
|
+from app.schemas import (
|
|
|
+ Evidence,
|
|
|
+ KnowledgeType,
|
|
|
+ PlanStep,
|
|
|
+ QualityGrade,
|
|
|
+ RetrievalPlan,
|
|
|
+ RouteDecision,
|
|
|
+ RouteName,
|
|
|
+ SOURCE_TO_KNOWLEDGE,
|
|
|
+ TaskType,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+SERVICE_NAME_PATTERN = re.compile(
|
|
|
+ r"aliyun[_ -]?ssl[_ -]?vpn|SSL[-_ ]?VPN", re.IGNORECASE
|
|
|
+)
|
|
|
+# \b 在 Python re 与中文字符之间不构成 word boundary,因此中文部分走纯子串匹配。
|
|
|
+ERROR_TYPE_PATTERN = re.compile(
|
|
|
+ r"auth[_-]?timeout|认证超时", re.IGNORECASE
|
|
|
+)
|
|
|
+CLIENT_OS_PATTERN = re.compile(r"Windows|macOS|Linux", re.IGNORECASE)
|
|
|
+TIME_RANGE_PATTERN = re.compile(r"(\d+)\s*(?:天|d)\b", re.IGNORECASE)
|
|
|
+USER_ID_PATTERN = re.compile(r"\bU\d{4}\b", re.IGNORECASE)
|
|
|
+
|
|
|
+RESTRICTED_TERMS = (
|
|
|
+ "所有员工手机号",
|
|
|
+ "员工密码",
|
|
|
+ "员工薪资",
|
|
|
+ "导出全部",
|
|
|
+ "删除所有",
|
|
|
+ "关闭防火墙",
|
|
|
+ "禁用杀毒",
|
|
|
+)
|
|
|
+DIRECT_TERMS = ("改写", "润色", "翻译", "缩短", "转成")
|
|
|
+
|
|
|
+
|
|
|
+# 本作业固定只处理 aliyun_ssl_vpn 与 auth_timeout;不在白名单的服务/故障一律拒绝或澄清。
|
|
|
+ALLOWED_SERVICE = "aliyun_ssl_vpn"
|
|
|
+ALLOWED_ERROR_TYPE = "auth_timeout"
|
|
|
+VENDOR_DOMAIN = "help.aliyun.com"
|
|
|
+
|
|
|
+
|
|
|
+def extract_service_name(query: str) -> str:
|
|
|
+ """把 SSL-VPN 的所有变体(含"阿里云 SSL-VPN"、"SSL-VPN"、"aliyun_ssl_vpn")映射到规范名。"""
|
|
|
+ if SERVICE_NAME_PATTERN.search(query):
|
|
|
+ return ALLOWED_SERVICE
|
|
|
+ return ""
|
|
|
+
|
|
|
+
|
|
|
+def extract_error_type(query: str) -> str:
|
|
|
+ """识别认证超时类故障并返回规范名 `auth_timeout`。"""
|
|
|
+ if ERROR_TYPE_PATTERN.search(query):
|
|
|
+ return ALLOWED_ERROR_TYPE
|
|
|
+ return ""
|
|
|
+
|
|
|
+
|
|
|
+def extract_client_os(query: str) -> str:
|
|
|
+ match = CLIENT_OS_PATTERN.search(query)
|
|
|
+ return match.group(0) if match else ""
|
|
|
+
|
|
|
+
|
|
|
+def extract_time_range_days(query: str) -> int:
|
|
|
+ match = TIME_RANGE_PATTERN.search(query)
|
|
|
+ if not match:
|
|
|
+ return 30
|
|
|
+ days = int(match.group(1))
|
|
|
+ # 与 SQL 层的 days ∈ [1,365] 对齐;超过 365 直接夹紧。
|
|
|
+ return max(1, min(days, 365))
|
|
|
+
|
|
|
+
|
|
|
+class DecisionEngine(Protocol):
|
|
|
+ def route(self, query: str, max_rounds: int) -> RouteDecision: ...
|
|
|
+
|
|
|
+ def plan(self, query: str, decision: RouteDecision) -> RetrievalPlan: ...
|
|
|
+
|
|
|
+ def grade(
|
|
|
+ self,
|
|
|
+ query: str,
|
|
|
+ decision: RouteDecision,
|
|
|
+ evidence: list[Evidence],
|
|
|
+ current_round: int,
|
|
|
+ max_rounds: int,
|
|
|
+ min_score: float,
|
|
|
+ ) -> QualityGrade: ...
|
|
|
+
|
|
|
+ def rewrite(self, query: str, grade: QualityGrade) -> str: ...
|
|
|
+
|
|
|
+ def answer(self, query: str, evidence: list[Evidence], partial: bool = False) -> str: ...
|
|
|
+
|
|
|
+
|
|
|
+class DemoDecisionEngine:
|
|
|
+ """确定性 IT 域演示引擎,便于无 API Key 启动、调试和自动化测试。"""
|
|
|
+
|
|
|
+ SQL_TERMS = (
|
|
|
+ "几次", "多少", "发生次数", "统计", "工单", "次数", "几天", "天里",
|
|
|
+ )
|
|
|
+ DOC_TERMS = (
|
|
|
+ "排查", "排障", "手册", "步骤", "怎么", "如何", "流程", "顺序",
|
|
|
+ )
|
|
|
+ WEB_TERMS = (
|
|
|
+ "官网", "厂商", "公告", "最新", "公共服务", "服务状态", "运维事件",
|
|
|
+ "公共异常", "服务异常", "服务公告",
|
|
|
+ )
|
|
|
+
|
|
|
+ def route(self, query: str, max_rounds: int) -> RouteDecision:
|
|
|
+ stripped = query.strip()
|
|
|
+
|
|
|
+ # 安全门控:敏感请求或被禁用的写操作一律 REFUSE。
|
|
|
+ if any(term in stripped for term in RESTRICTED_TERMS):
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=False,
|
|
|
+ intent="restricted_request",
|
|
|
+ task_type=TaskType.UNKNOWN,
|
|
|
+ routes=[RouteName.REFUSE],
|
|
|
+ confidence="high",
|
|
|
+ reason_code="SECURITY_POLICY",
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+
|
|
|
+ # 文本改写类请求无需任何检索。
|
|
|
+ if any(term in stripped for term in DIRECT_TERMS):
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=False,
|
|
|
+ intent="text_transformation",
|
|
|
+ task_type=TaskType.TEXT_TRANSFORM,
|
|
|
+ routes=[RouteName.DIRECT_ANSWER],
|
|
|
+ confidence="high",
|
|
|
+ reason_code="NO_EXTERNAL_KNOWLEDGE_REQUIRED",
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+
|
|
|
+ service_name = extract_service_name(stripped)
|
|
|
+ error_type = extract_error_type(stripped)
|
|
|
+ client_os = extract_client_os(stripped)
|
|
|
+ time_range_days = extract_time_range_days(stripped)
|
|
|
+
|
|
|
+ # 防御性检查:业务用户编号不应出现在 IT 域问题里(防止模板残留)。
|
|
|
+ if USER_ID_PATTERN.search(stripped):
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=False,
|
|
|
+ intent="invalid_credential",
|
|
|
+ task_type=TaskType.UNKNOWN,
|
|
|
+ routes=[RouteName.REFUSE],
|
|
|
+ confidence="high",
|
|
|
+ reason_code="USER_ID_NOT_APPLICABLE",
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+
|
|
|
+ needs_sql = any(term in stripped for term in self.SQL_TERMS)
|
|
|
+ needs_docs = any(term in stripped for term in self.DOC_TERMS)
|
|
|
+ needs_web = any(term in stripped for term in self.WEB_TERMS)
|
|
|
+
|
|
|
+ # 任何路由都要求 service_name 与 error_type 都已显式给出;否则 CLARIFY。
|
|
|
+ if (needs_sql or needs_docs or needs_web) and not service_name:
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=False,
|
|
|
+ intent="missing_service_name",
|
|
|
+ task_type=TaskType.UNKNOWN,
|
|
|
+ routes=[RouteName.CLARIFY],
|
|
|
+ confidence="high",
|
|
|
+ reason_code="MISSING_SERVICE_NAME",
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+ if (needs_sql or needs_docs or needs_web) and not error_type:
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=False,
|
|
|
+ intent="missing_error_type",
|
|
|
+ task_type=TaskType.UNKNOWN,
|
|
|
+ routes=[RouteName.CLARIFY],
|
|
|
+ confidence="high",
|
|
|
+ reason_code="MISSING_ERROR_TYPE",
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+
|
|
|
+ # 路由决策:每个意图独立判断后取并集,三类需求齐备即三源并行。
|
|
|
+ routes: list[RouteName] = []
|
|
|
+ knowledge_types: list[KnowledgeType] = []
|
|
|
+ if needs_sql:
|
|
|
+ routes.append(RouteName.SQL_QUERY)
|
|
|
+ knowledge_types.append(KnowledgeType.STRUCTURED)
|
|
|
+ if needs_docs:
|
|
|
+ routes.append(RouteName.MILVUS_SEARCH)
|
|
|
+ knowledge_types.append(KnowledgeType.INTERNAL_DOC)
|
|
|
+ if needs_web:
|
|
|
+ routes.append(RouteName.WEB_SEARCH)
|
|
|
+ knowledge_types.append(KnowledgeType.REALTIME)
|
|
|
+
|
|
|
+ if not routes:
|
|
|
+ # 没有命中任何意图关键词 → 直接要求澄清,避免随机路由。
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=False,
|
|
|
+ intent="unclear_intent",
|
|
|
+ task_type=TaskType.UNKNOWN,
|
|
|
+ routes=[RouteName.CLARIFY],
|
|
|
+ confidence="high",
|
|
|
+ reason_code="INTENT_UNCLEAR",
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+
|
|
|
+ filters = {
|
|
|
+ "service_name": service_name,
|
|
|
+ "error_type": error_type,
|
|
|
+ "client_os": client_os,
|
|
|
+ "days": time_range_days,
|
|
|
+ }
|
|
|
+ filters = {key: value for key, value in filters.items() if value}
|
|
|
+
|
|
|
+ if len(routes) >= 2:
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=True,
|
|
|
+ intent="multi_source_diagnosis",
|
|
|
+ task_type=TaskType.DIAGNOSE,
|
|
|
+ routes=routes,
|
|
|
+ knowledge_types=knowledge_types,
|
|
|
+ requires_decomposition=True,
|
|
|
+ confidence="high",
|
|
|
+ reason_code="MULTIPLE_DATA_SOURCES_REQUIRED",
|
|
|
+ filters=filters,
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+ if needs_sql:
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=True,
|
|
|
+ intent="incident_statistics",
|
|
|
+ task_type=TaskType.HISTORICAL_QUERY,
|
|
|
+ routes=routes,
|
|
|
+ knowledge_types=knowledge_types,
|
|
|
+ confidence="high",
|
|
|
+ reason_code="EXACT_AGGREGATION_REQUIRED",
|
|
|
+ filters=filters,
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+ if needs_web:
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=True,
|
|
|
+ intent="vendor_status_check",
|
|
|
+ task_type=TaskType.VENDOR_STATUS,
|
|
|
+ routes=routes,
|
|
|
+ knowledge_types=knowledge_types,
|
|
|
+ confidence="high",
|
|
|
+ reason_code="CURRENT_PUBLIC_INFORMATION_REQUIRED",
|
|
|
+ filters=filters,
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=True,
|
|
|
+ intent="internal_doc_qa",
|
|
|
+ task_type=TaskType.DOCS_QA,
|
|
|
+ routes=routes,
|
|
|
+ knowledge_types=knowledge_types,
|
|
|
+ confidence="high" if client_os else "medium",
|
|
|
+ reason_code="INTERNAL_DOCUMENT_REQUIRED",
|
|
|
+ filters=filters,
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+
|
|
|
+ def plan(self, query: str, decision: RouteDecision) -> RetrievalPlan:
|
|
|
+ service_name = str(decision.filters.get("service_name", ALLOWED_SERVICE))
|
|
|
+ error_type = str(decision.filters.get("error_type", ALLOWED_ERROR_TYPE))
|
|
|
+ client_os = str(decision.filters.get("client_os", ""))
|
|
|
+ days = int(decision.filters.get("days", 30))
|
|
|
+ steps: list[PlanStep] = []
|
|
|
+
|
|
|
+ # 三类只读任务互不依赖,全部并行执行;depends_on 留空即可。
|
|
|
+ if RouteName.MILVUS_SEARCH in decision.routes:
|
|
|
+ steps.append(
|
|
|
+ PlanStep(
|
|
|
+ id="search_troubleshooting_doc",
|
|
|
+ tool=RouteName.MILVUS_SEARCH,
|
|
|
+ # 直接用服务名 + 错误类型组合检索,比原始用户问题更聚焦。
|
|
|
+ query=f"{service_name} {error_type} 排查步骤 {client_os}".strip(),
|
|
|
+ arguments={
|
|
|
+ "service_name": service_name,
|
|
|
+ "client_os": client_os,
|
|
|
+ "doc_type": "troubleshooting_guide",
|
|
|
+ "top_k": 4,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if RouteName.SQL_QUERY in decision.routes:
|
|
|
+ steps.append(
|
|
|
+ PlanStep(
|
|
|
+ id="query_incident_statistics",
|
|
|
+ tool=RouteName.SQL_QUERY,
|
|
|
+ query=(
|
|
|
+ f"统计 {service_name} 最近 {days} 天"
|
|
|
+ f"{error_type} 的有效工单"
|
|
|
+ ),
|
|
|
+ arguments={
|
|
|
+ "service_name": service_name,
|
|
|
+ "error_type": error_type,
|
|
|
+ "days": days,
|
|
|
+ "client_os": client_os,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if RouteName.WEB_SEARCH in decision.routes:
|
|
|
+ # 受控搜索词:固定 site 限定 + 产品名 + 故障类型 + 最新状态诉求,
|
|
|
+ # 不直接把用户自然语言送出去。
|
|
|
+ vendor_status_query = (
|
|
|
+ f'site:{VENDOR_DOMAIN}/zh/vpn 阿里云 SSL-VPN '
|
|
|
+ f'{error_type} 运维事件 最新公告'
|
|
|
+ )
|
|
|
+ steps.append(
|
|
|
+ PlanStep(
|
|
|
+ id="search_vendor_status",
|
|
|
+ tool=RouteName.WEB_SEARCH,
|
|
|
+ query=vendor_status_query,
|
|
|
+ arguments={"max_results": 3},
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return RetrievalPlan(goal=query, steps=steps)
|
|
|
+
|
|
|
+ def grade(
|
|
|
+ self,
|
|
|
+ query: str,
|
|
|
+ decision: RouteDecision,
|
|
|
+ evidence: list[Evidence],
|
|
|
+ current_round: int,
|
|
|
+ max_rounds: int,
|
|
|
+ min_score: float,
|
|
|
+ ) -> QualityGrade:
|
|
|
+ # 先做确定性来源覆盖检查,再判断语义质量与冲突。
|
|
|
+ required_sources = set()
|
|
|
+ if RouteName.SQL_QUERY in decision.routes:
|
|
|
+ required_sources.add("sql")
|
|
|
+ if RouteName.MILVUS_SEARCH in decision.routes:
|
|
|
+ required_sources.add("milvus")
|
|
|
+ if RouteName.WEB_SEARCH in decision.routes:
|
|
|
+ required_sources.add("web")
|
|
|
+
|
|
|
+ available_sources = {item.source_type for item in evidence}
|
|
|
+ missing = sorted(required_sources - available_sources)
|
|
|
+
|
|
|
+ vector_evidence = [item for item in evidence if item.source_type == "milvus"]
|
|
|
+ # 至少一条向量结果得分不低于 min_score 才视为相关。
|
|
|
+ vector_relevant = any(
|
|
|
+ item.score is not None and item.score >= min_score
|
|
|
+ for item in vector_evidence
|
|
|
+ )
|
|
|
+ if "milvus" not in required_sources:
|
|
|
+ vector_relevant = True
|
|
|
+
|
|
|
+ # 厂商状态缺失时必须返回 partial:答案不能排除公共服务异常。
|
|
|
+ if "web" in required_sources and "web" not in available_sources:
|
|
|
+ return QualityGrade(
|
|
|
+ relevant=bool(evidence),
|
|
|
+ sufficient=False,
|
|
|
+ missing_aspects=["厂商官网状态"],
|
|
|
+ conflict=False,
|
|
|
+ recommended_action="partial",
|
|
|
+ reason="厂商官网状态缺失,证据不完整,必须以部分结论给出。",
|
|
|
+ )
|
|
|
+
|
|
|
+ # 厂商与内部文档冲突时标记冲突,但不阻塞终止。
|
|
|
+ conflict = self._detect_conflict(evidence)
|
|
|
+
|
|
|
+ if not missing and vector_relevant:
|
|
|
+ action = "accept" if not conflict else "accept"
|
|
|
+ return QualityGrade(
|
|
|
+ relevant=True,
|
|
|
+ sufficient=True,
|
|
|
+ missing_aspects=[],
|
|
|
+ conflict=conflict,
|
|
|
+ recommended_action=action,
|
|
|
+ reason=(
|
|
|
+ "所需数据源均返回证据,且 Milvus 命中相关结果。"
|
|
|
+ if not conflict
|
|
|
+ else "证据完整但厂商公告与内部手册结论冲突,需在答案中并列两条路径。"
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ # 缺失关键证据但还有预算 → 改写 Query 重新检索。
|
|
|
+ if current_round < max_rounds:
|
|
|
+ return QualityGrade(
|
|
|
+ relevant=bool(evidence),
|
|
|
+ sufficient=False,
|
|
|
+ missing_aspects=missing or ["相关排障手册"],
|
|
|
+ conflict=conflict,
|
|
|
+ recommended_action="rewrite_query",
|
|
|
+ reason="证据不完整,允许改写后重试。",
|
|
|
+ )
|
|
|
+
|
|
|
+ return QualityGrade(
|
|
|
+ relevant=bool(evidence),
|
|
|
+ sufficient=False,
|
|
|
+ missing_aspects=missing or ["相关排障手册"],
|
|
|
+ conflict=conflict,
|
|
|
+ recommended_action="stop",
|
|
|
+ reason="达到最大检索轮数,停止继续调用工具。",
|
|
|
+ )
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _detect_conflict(evidence: list[Evidence]) -> bool:
|
|
|
+ """轻量级冲突检测:厂商公告与内部结论出现对立关键词。"""
|
|
|
+ has_vendor_issue = False
|
|
|
+ has_vendor_normal = False
|
|
|
+ for item in evidence:
|
|
|
+ if item.source_type != "web":
|
|
|
+ continue
|
|
|
+ text = item.content.lower()
|
|
|
+ if "服务异常" in text or "故障公告" in text or "中断" in text:
|
|
|
+ has_vendor_issue = True
|
|
|
+ if "未发现" in text or "无异常" in text or "正常" in text:
|
|
|
+ has_vendor_normal = True
|
|
|
+ # 没有任何厂商证据时不应判为冲突。
|
|
|
+ return has_vendor_issue and has_vendor_normal
|
|
|
+
|
|
|
+ def rewrite(self, query: str, grade: QualityGrade) -> str:
|
|
|
+ missing = "、".join(grade.missing_aspects) if grade.missing_aspects else "相关资料"
|
|
|
+ suffix = f"阿里云 SSL-VPN auth_timeout 排障手册 厂商公告 {missing}".strip()
|
|
|
+ return f"{query};补充检索条件:{suffix}"
|
|
|
+
|
|
|
+ def answer(self, query: str, evidence: list[Evidence], partial: bool = False) -> str:
|
|
|
+ if not evidence:
|
|
|
+ return "未获得能够支持回答的证据。请补充服务名称或故障现象后重试。"
|
|
|
+
|
|
|
+ prefix = "当前仅能给出部分结论,公共服务状态仍待确认。" if partial else "基于已检索证据,结论如下。"
|
|
|
+ lines = [prefix]
|
|
|
+ sql_items = [item for item in evidence if item.source_type == "sql"]
|
|
|
+ milvus_items = [item for item in evidence if item.source_type == "milvus"]
|
|
|
+ web_items = [item for item in evidence if item.source_type == "web"]
|
|
|
+
|
|
|
+ index = 1
|
|
|
+ if sql_items:
|
|
|
+ for item in sql_items:
|
|
|
+ lines.append(f"- 历史工单:{item.content} [{index}]")
|
|
|
+ index += 1
|
|
|
+ if milvus_items:
|
|
|
+ for item in milvus_items:
|
|
|
+ lines.append(f"- 内部手册:{item.content} [{index}]")
|
|
|
+ index += 1
|
|
|
+ if web_items:
|
|
|
+ for item in web_items:
|
|
|
+ lines.append(f"- 厂商公告:{item.content} [{index}]")
|
|
|
+ index += 1
|
|
|
+
|
|
|
+ # 三类证据齐备时给出综合判断,让用户拿到行动指引而不是单纯堆事实。
|
|
|
+ # query 同时含 SQL/Milvus/Web 时,意味着用户要的是"本地 vs 公共服务"的判断,
|
|
|
+ # 我们就追加一句结论。
|
|
|
+ if (
|
|
|
+ not partial
|
|
|
+ and sql_items
|
|
|
+ and milvus_items
|
|
|
+ and web_items
|
|
|
+ ):
|
|
|
+ lines.append(
|
|
|
+ "- 综合判断:历史工单有可复现的本地处置记录(证书刷新/重建 Profile),"
|
|
|
+ "厂商侧公告显示服务运行正常,倾向本地配置问题;请优先按内部手册"
|
|
|
+ "步骤排查,公共服务异常可暂不升级。"
|
|
|
+ )
|
|
|
+ return "\n".join(lines)
|
|
|
+
|
|
|
+
|
|
|
+class DeepSeekDecisionEngine(DemoDecisionEngine):
|
|
|
+ """使用 DeepSeek 的 OpenAI 兼容接口完成路由、评分和答案生成。"""
|
|
|
+
|
|
|
+ def __init__(self, settings: Settings) -> None:
|
|
|
+ # 凭证只从 Settings 读取,并在创建 SDK 客户端时短暂解密。
|
|
|
+ api_key = settings.deepseek_api_key.get_secret_value()
|
|
|
+ if not api_key:
|
|
|
+ raise RuntimeError("LLM_PROVIDER=deepseek 时必须配置 DEEPSEEK_API_KEY")
|
|
|
+ try:
|
|
|
+ from langchain_openai import ChatOpenAI
|
|
|
+ except ImportError as exc:
|
|
|
+ raise RuntimeError(
|
|
|
+ "缺少 DeepSeek 依赖,请执行 uv sync --extra test --extra deepseek"
|
|
|
+ ) from exc
|
|
|
+
|
|
|
+ common_kwargs = {
|
|
|
+ "model": settings.deepseek_model_name,
|
|
|
+ "api_key": api_key,
|
|
|
+ "base_url": settings.deepseek_base_url,
|
|
|
+ "max_retries": 2,
|
|
|
+ }
|
|
|
+
|
|
|
+ # 最终答案可以使用 Thinking;开关由 .env 控制。
|
|
|
+ answer_thinking = "enabled" if settings.deepseek_answer_thinking else "disabled"
|
|
|
+ self.llm = ChatOpenAI(
|
|
|
+ **common_kwargs,
|
|
|
+ extra_body={"thinking": {"type": answer_thinking}},
|
|
|
+ )
|
|
|
+
|
|
|
+ # with_structured_output(function_calling) 会发送强制 tool_choice。
|
|
|
+ # DeepSeek Thinking 模式不接受该组合,因此路由与评分必须关闭 Thinking。
|
|
|
+ self.structured_llm = ChatOpenAI(
|
|
|
+ **common_kwargs,
|
|
|
+ temperature=0,
|
|
|
+ extra_body={"thinking": {"type": "disabled"}},
|
|
|
+ )
|
|
|
+ self.router = self.structured_llm.with_structured_output(
|
|
|
+ RouteDecision,
|
|
|
+ method="function_calling",
|
|
|
+ )
|
|
|
+ self.grader = self.structured_llm.with_structured_output(
|
|
|
+ QualityGrade,
|
|
|
+ method="function_calling",
|
|
|
+ )
|
|
|
+
|
|
|
+ def route(self, query: str, max_rounds: int) -> RouteDecision:
|
|
|
+ prompt = f"""
|
|
|
+你是企业 IT 故障诊断助手路由器。只能输出符合 Schema 的结果。
|
|
|
+可选路径:direct_answer、milvus_search、sql_query、web_search、clarify、refuse。
|
|
|
+历史工单次数、统计、影响终端使用 sql_query;
|
|
|
+内部排障手册使用 milvus_search;
|
|
|
+厂商官网最新公告/服务状态使用 web_search(必须配合 site:help.aliyun.com 限定)。
|
|
|
+filters 中只填写从原问题明确提取的 service_name、error_type、client_os、days。
|
|
|
+缺少 service_name 或 error_type 时返回 clarify;越权请求返回 refuse。
|
|
|
+最大检索轮数:{max_rounds}
|
|
|
+用户问题:{query}
|
|
|
+""".strip()
|
|
|
+ result = self.router.invoke(prompt)
|
|
|
+ # 结构化输出缺失时回退到确定性规则,路由不允许 None 继续走图。
|
|
|
+ if result is None:
|
|
|
+ return super().route(query, max_rounds)
|
|
|
+ # 关键业务槽位使用确定性解析覆盖模型结果,避免模型改写服务身份。
|
|
|
+ deterministic_filters = {
|
|
|
+ key: value
|
|
|
+ for key, value in {
|
|
|
+ "service_name": extract_service_name(query),
|
|
|
+ "error_type": extract_error_type(query),
|
|
|
+ "client_os": extract_client_os(query),
|
|
|
+ "days": extract_time_range_days(query),
|
|
|
+ }.items()
|
|
|
+ if value
|
|
|
+ }
|
|
|
+ result.filters = {**result.filters, **deterministic_filters}
|
|
|
+ if (
|
|
|
+ RouteName.SQL_QUERY in result.routes
|
|
|
+ and "service_name" not in result.filters
|
|
|
+ ):
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=False,
|
|
|
+ intent="missing_service_name",
|
|
|
+ task_type=TaskType.UNKNOWN,
|
|
|
+ routes=[RouteName.CLARIFY],
|
|
|
+ confidence="high",
|
|
|
+ reason_code="MISSING_SERVICE_NAME",
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ RouteName.MILVUS_SEARCH in result.routes
|
|
|
+ and "error_type" not in result.filters
|
|
|
+ ):
|
|
|
+ return RouteDecision(
|
|
|
+ needs_retrieval=False,
|
|
|
+ intent="missing_error_type",
|
|
|
+ task_type=TaskType.UNKNOWN,
|
|
|
+ routes=[RouteName.CLARIFY],
|
|
|
+ confidence="high",
|
|
|
+ reason_code="MISSING_ERROR_TYPE",
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ )
|
|
|
+ result.max_rounds = max_rounds
|
|
|
+ # 知识类型与数据源严格对齐,避免模型漏报。
|
|
|
+ result.knowledge_types = [
|
|
|
+ SOURCE_TO_KNOWLEDGE[route] for route in result.routes if route in SOURCE_TO_KNOWLEDGE
|
|
|
+ ]
|
|
|
+ return result
|
|
|
+
|
|
|
+ def grade(
|
|
|
+ self,
|
|
|
+ query: str,
|
|
|
+ decision: RouteDecision,
|
|
|
+ evidence: list[Evidence],
|
|
|
+ current_round: int,
|
|
|
+ max_rounds: int,
|
|
|
+ min_score: float,
|
|
|
+ ) -> QualityGrade:
|
|
|
+ prompt = f"""
|
|
|
+判断证据是否足以回答 IT 故障诊断问题。recommended_action 只能是 accept、rewrite_query、stop 或 partial。
|
|
|
+当前轮次:{current_round}/{max_rounds}
|
|
|
+问题:{query}
|
|
|
+路由:{decision.model_dump_json()}
|
|
|
+证据:{json.dumps([item.model_dump() for item in evidence], ensure_ascii=False)}
|
|
|
+""".strip()
|
|
|
+ result = self.grader.invoke(prompt)
|
|
|
+ if result is None:
|
|
|
+ return super().grade(
|
|
|
+ query=query,
|
|
|
+ decision=decision,
|
|
|
+ evidence=evidence,
|
|
|
+ current_round=current_round,
|
|
|
+ max_rounds=max_rounds,
|
|
|
+ min_score=min_score,
|
|
|
+ )
|
|
|
+ return result
|
|
|
+
|
|
|
+ def answer(self, query: str, evidence: list[Evidence], partial: bool = False) -> str:
|
|
|
+ prompt = f"""
|
|
|
+严格依据证据回答 IT 故障诊断问题,不得补充证据之外的事实。
|
|
|
+每个关键结论必须使用 [序号] 引用对应证据。
|
|
|
+若厂商公告缺失必须明确告知"无法确认公共服务状态"。
|
|
|
+问题:{query}
|
|
|
+是否为部分证据:{partial}
|
|
|
+证据:{json.dumps([item.model_dump() for item in evidence], ensure_ascii=False)}
|
|
|
+""".strip()
|
|
|
+ return str(self.llm.invoke(prompt).content)
|
|
|
+
|
|
|
+
|
|
|
+def create_decision_engine(settings: Settings) -> DecisionEngine:
|
|
|
+ if settings.llm_provider == "demo":
|
|
|
+ return DemoDecisionEngine()
|
|
|
+ if settings.llm_provider == "deepseek":
|
|
|
+ return DeepSeekDecisionEngine(settings)
|
|
|
+ raise ValueError(f"不支持的 LLM_PROVIDER: {settings.llm_provider}")
|