refactor(server): 意图识别改 LLM 驱动,规则只做闲聊拦截+resume 兜底

规则不再判断'这是哪个业务场景'——那交给 LLM function call。
规则只保留两个不可替代职责:闲聊拦截(省 LLM 成本)、resume 确定性兜底。

- gate_classify 简化:删掉规则匹配门(94 词 CHOICE 匹配)和 ambiguous 提前判断
- 新增 _is_lightweight_off_topic:只拦 greeting+meaningless,不依赖业务关键词
- HANDLER_ONLY 改为 LLM 输出驱动:LLM 返回 query_travel_standard 后转 handler
- 图拓扑简化:gate_classify 只输出 off_topic|resume|model_intent
- 验证:76 passed;复合场景'出差并且报销招待费'→LLM 返回 2 task
This commit is contained in:
caoxiaozhu
2026-06-26 10:19:04 +08:00
parent 8a2ae6eb75
commit d139a63e64
2 changed files with 84 additions and 40 deletions

View File

@@ -93,7 +93,14 @@
- 验证:后端全量 **76 passed**;端到端 4 场景全部通过——①出差申请(llm_function_call, 1 task)、②再提交(context_resume, 1 task, 5 fields)、③查差旅标准(scene_handler:query_travel_standard)、④闲聊(off_topic)。 - 验证:后端全量 **76 passed**;端到端 4 场景全部通过——①出差申请(llm_function_call, 1 task)、②再提交(context_resume, 1 task, 5 fields)、③查差旅标准(scene_handler:query_travel_standard)、④闲聊(off_topic)。
- 影响LangGraph 图成为唯一编排者,门控收敛到 gate_classify 单一决策点。endpoint 仍保留 hydrate/persist 两个补丁,P3 会收敛。 - 影响LangGraph 图成为唯一编排者,门控收敛到 gate_classify 单一决策点。endpoint 仍保留 hydrate/persist 两个补丁,P3 会收敛。
## TODO - 02:30我把意图识别从"规则驱动"改为"LLM 驱动 + 极轻量规则过滤",规则不再判断"这是哪个业务场景"。
- Git 提交检查:本地与 origin/main 同步。
- 背景gate_classify 用 94 关键词 + scene_registry CHOICE 规则匹配判断业务相关性,每加场景要维护关键词,关键词冲突(出差既可能申请也可能查标准),还导致 off_topic 误杀("下周去上海"被拦)。但 LLM 已经完全具备多场景识别能力(schema enum + prompt_fragment 都从 registry 动态生成),规则匹配门是多余的。
- 修改①(简化 gate_classify删掉规则匹配门(③)和 ambiguous 提前判断(④)。gate_classify 现在只做两件事:①闲聊拦截(极轻量:greeting+meaningless,省 LLM 成本)②resume 确定性兜底。其他全部默认走 model_intent。新增 `_is_lightweight_off_topic` 只调 `_looks_like_greeting`+`_looks_like_meaningless`,不调带 94 词的 `_classify_irrelevant_input`
- 修改②HANDLER_ONLY 改为 LLM 输出驱动):查差旅标准不再由 gate_classify 规则命中,改为 LLM 返回 task_type=query_travel_standard 后,`_route_after_model_intent` 检查 scene.route=HANDLER_ONLY → 转 execute_scene_handler。`execute_scene_handler` 优先使用 LLM 生成的 task(含 ontology_fields)。
- 修改③图拓扑简化gate_classify 只输出 off_topic|resume|model_intent 三种。删掉 handler_only/ambiguous 边。
- 验证:后端 **76 passed**;端到端 7 场景全通过——①你好→off_topic、②123→off_topic、③下周去上海→llm_function_call(之前被误杀!)、④P5武汉住宿标准→scene_handler:query_travel_standard、⑤再提交→context_resume、⑥出差并且报销招待费→llm_function_call 返回 2 task(expense_application+reimbursement)。
- 影响:意图识别全部交给 LLM,规则只保留闲聊拦截和 resume 兜底两个不可替代职责。加新场景只需注册 SceneDescriptor(prompt_fragment+ontology_fields+handler),LLM 的 system prompt 和 schema 自动包含,**不加任何关键词规则,不动门控代码**。
## TODO ## TODO

View File

@@ -158,36 +158,36 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
message = str(state.get("message") or "").strip() message = str(state.get("message") or "").strip()
steward_state = state.get("steward_state") or {} steward_state = state.get("steward_state") or {}
# ① resume 门(优先:即使消息不命中业务关键词,只要是"再提交"类话术就尝试恢复) # ① 闲聊拦截(极轻量:greeting + meaningless,省 LLM 成本,不依赖业务关键词)
# 不用 _classify_irrelevant_input(那套带 94 词判断,会误杀"下周去上海"等正常业务输入)
if self._is_lightweight_off_topic(message, request):
return {"gate_decision": "off_topic", "gate_scene_id": None}
# ② resume 门(用户说"再提交" + state 有可恢复 flow → 确定性恢复)
resume_scene = should_resume_recent_task(message, steward_state) resume_scene = should_resume_recent_task(message, steward_state)
if resume_scene: if resume_scene:
return {"gate_decision": "resume", "gate_scene_id": resume_scene} return {"gate_decision": "resume", "gate_scene_id": resume_scene}
# ② off_topic 门:复用成熟的 _classify_irrelevant_input(含城市名/时间词/金额词等 94 词 + greeting/meaningless 细分) # ③ 其他全部走 LLM(不再有规则匹配门;LLM function call 是唯一的意图识别者)
scenario = self._classify_irrelevant_input(message, request)
if scenario is not None:
return {"gate_decision": "off_topic", "gate_scene_id": None}
# ③ 规则匹配门(按 priority 遍历,命中 CHOICE 规则的)
for scene in REGISTRY.scenes_sorted_by_priority():
if scene.gate != GateRule.CHOICE:
continue
if not scene.signal_keywords:
continue
compact = _compact_text(message)
if any(kw in compact for kw in scene.signal_keywords):
route = _scene_route_to_gate_decision(scene.route)
return {"gate_decision": route, "gate_scene_id": scene.scene_id}
# ④ LLM 门(规则未命中)
# 走 model_intent 时,如 state 已有 active_flow 且 LLM 准备 fallback,可优先做 candidate_flow
if self._looks_like_ambiguous_travel_flow(
message, self._resolve_base_date_from_request(request), request
):
return {"gate_decision": "ambiguous", "gate_scene_id": None}
return {"gate_decision": "model_intent", "gate_scene_id": None} return {"gate_decision": "model_intent", "gate_scene_id": None}
def _is_lightweight_off_topic(self, message: str, request: StewardPlanRequest) -> bool:
"""极轻量闲聊拦截:只拦 greeting 和 meaningless,不做业务相关性判断。
有附件时一定不是闲聊(附件意味着用户有业务诉求)。
业务相关性交给 LLM 判断,规则只挡掉绝对无关的输入省 LLM 成本。
"""
if request.attachments:
return False
compact = _compact_text(message)
if not compact:
return True
if self._looks_like_greeting(compact):
return True
if self._looks_like_meaningless(compact):
return True
return False
def _execute_scene_handler(self, state: StewardGraphState) -> dict[str, Any]: def _execute_scene_handler(self, state: StewardGraphState) -> dict[str, Any]:
"""HANDLER_ONLY 路由:不调 LLM,直接执行 scene 的 handler。 """HANDLER_ONLY 路由:不调 LLM,直接执行 scene 的 handler。
@@ -205,7 +205,12 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
) )
import time import time
# scene_id 优先从 gate_scene_id 取(兼容旧行为),否则从 plan.tasks[0].task_type 取(LLM 驱动)
scene_id = state.get("gate_scene_id") scene_id = state.get("gate_scene_id")
if not scene_id:
plan = state.get("plan")
if isinstance(plan, StewardPlanResponse) and plan.tasks:
scene_id = str(plan.tasks[0].task_type or "").strip()
scene = REGISTRY.get(scene_id or "") if scene_id else None scene = REGISTRY.get(scene_id or "") if scene_id else None
if scene is None or scene.handler is None: if scene is None or scene.handler is None:
plan = self._build_rule_fallback_graph_plan(state) plan = self._build_rule_fallback_graph_plan(state)
@@ -217,9 +222,15 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
return {"plan": plan} return {"plan": plan}
# 构造 handler 期望的 StewardActionExecuteRequest # 构造 handler 期望的 StewardActionExecuteRequest
from app.services.steward_action_contracts import StewardActionPlanBuilder # 优先使用 LLM 已生成的 task(含 ontology_fields),否则构造最小 task
builder = StewardActionPlanBuilder() existing_plan = state.get("plan")
if scene.action_steps_builder is not None: llm_task = None
if isinstance(existing_plan, StewardPlanResponse) and existing_plan.tasks:
llm_task = existing_plan.tasks[0]
if llm_task is not None:
task = llm_task
elif scene.action_steps_builder is not None:
task = StewardTask( task = StewardTask(
task_id=f"task_handler_{int(time.time() * 1000)}", task_id=f"task_handler_{int(time.time() * 1000)}",
task_type=scene.scene_id, task_type=scene.scene_id,
@@ -232,27 +243,43 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
missing_fields=[], missing_fields=[],
confirmation_required=False, confirmation_required=False,
) )
action_steps = scene.action_steps_builder(task)
else: else:
action_steps = [] task = StewardTask(
task_id=f"task_handler_{int(time.time() * 1000)}",
task_type=scene.scene_id,
assigned_agent=scene.assigned_agent or "policy_query_assistant",
title=scene.label,
summary=str(request.message or "").strip(),
status="delegated",
requested_action="preview",
ontology_fields={},
missing_fields=[],
confirmation_required=False,
)
# 构造一个最小 action step 用于构造 handler request # 构造 action steps(优先用 scene 的 builder,否则最小 step)
action_steps: list[StewardActionStep] = []
if scene.action_steps_builder is not None:
try:
action_steps = list(scene.action_steps_builder(task) or [])
except Exception:
action_steps = []
if not action_steps: if not action_steps:
action_steps = [StewardActionStep( action_steps = [StewardActionStep(
step_id=f"handler_{int(time.time() * 1000)}", step_id=f"handler_{int(time.time() * 1000)}",
action_type=scene.side_effect_actions[0] if scene.side_effect_actions else scene.scene_id, action_type=scene.side_effect_actions[0] if scene.side_effect_actions else scene.scene_id,
label=scene.label, label=scene.label,
target_task_id="", target_task_id=task.task_id,
status="planned", status="planned",
requires_confirmation=False, requires_confirmation=False,
payload={}, payload={"task_id": task.task_id, "ontology_fields": task.ontology_fields},
)] )]
step = action_steps[0] step = action_steps[0]
action_request = StewardActionExecuteRequest( action_request = StewardActionExecuteRequest(
action_type=step.action_type, action_type=step.action_type,
message=str(request.message or "").strip(), message=str(request.message or "").strip(),
task=task if scene.action_steps_builder else None, task=task,
) )
try: try:
@@ -459,7 +486,7 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
graph.add_node("build_pending_flow_plan", self._build_pending_flow_fallback_graph_plan) graph.add_node("build_pending_flow_plan", self._build_pending_flow_fallback_graph_plan)
graph.add_node("attach_action_steps", self._attach_action_steps) graph.add_node("attach_action_steps", self._attach_action_steps)
# 拓扑 # 拓扑(P2:LLM 驱动,gate_classify 只输出 off_topic|resume|model_intent)
graph.add_edge(START, "load_context") graph.add_edge(START, "load_context")
graph.add_edge("load_context", "gate_classify") graph.add_edge("load_context", "gate_classify")
graph.add_conditional_edges( graph.add_conditional_edges(
@@ -467,13 +494,10 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
self._route_after_gate_classify, self._route_after_gate_classify,
{ {
"off_topic": "build_off_topic_plan", "off_topic": "build_off_topic_plan",
"handler_only": "execute_scene_handler",
"resume": "resume_recent_task", "resume": "resume_recent_task",
"ambiguous": "build_pending_flow_plan",
"model_intent": "prepare_context", "model_intent": "prepare_context",
}, },
) )
graph.add_edge("execute_scene_handler", "attach_action_steps")
graph.add_edge("resume_recent_task", "attach_action_steps") graph.add_edge("resume_recent_task", "attach_action_steps")
graph.add_conditional_edges( graph.add_conditional_edges(
"prepare_context", "prepare_context",
@@ -484,18 +508,21 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
"fallback": "build_rule_fallback_plan", "fallback": "build_rule_fallback_plan",
}, },
) )
# detect_model_intent 成功后:HANDLER_ONLY 类(task_type 对应 scene.route=HANDLER_ONLY)
# 转 execute_scene_handler;其他转 attach_action_steps
graph.add_conditional_edges( graph.add_conditional_edges(
"detect_model_intent", "detect_model_intent",
self._route_after_model_intent, self._route_after_model_intent,
{ {
"done": "attach_action_steps", "done": "attach_action_steps",
"handler_only": "execute_scene_handler",
"off_topic": "build_off_topic_plan", "off_topic": "build_off_topic_plan",
"fallback": "build_rule_fallback_plan", "fallback": "build_rule_fallback_plan",
}, },
) )
graph.add_edge("execute_scene_handler", "attach_action_steps")
graph.add_edge("build_off_topic_plan", "attach_action_steps") graph.add_edge("build_off_topic_plan", "attach_action_steps")
graph.add_edge("build_rule_fallback_plan", "attach_action_steps") graph.add_edge("build_rule_fallback_plan", "attach_action_steps")
graph.add_edge("build_pending_flow_plan", "attach_action_steps")
graph.add_edge("attach_action_steps", END) graph.add_edge("attach_action_steps", END)
return graph.compile() return graph.compile()
@@ -589,7 +616,17 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr
@staticmethod @staticmethod
def _route_after_model_intent(state: StewardGraphState) -> str: def _route_after_model_intent(state: StewardGraphState) -> str:
if isinstance(state.get("plan"), StewardPlanResponse): plan = state.get("plan")
if isinstance(plan, StewardPlanResponse):
# LLM 成功:检查第一个 task 是否对应 HANDLER_ONLY 场景(查询类,直接执行 handler)
if plan.tasks:
from app.services.scenes import REGISTRY
from app.services.scenes.gate_rules import SceneRoute
first_task_type = str(plan.tasks[0].task_type or "").strip()
scene = REGISTRY.get(first_task_type)
if scene is not None and scene.route == SceneRoute.HANDLER_ONLY:
return "handler_only"
return "done" return "done"
if state.get("scenario") is not None: if state.get("scenario") is not None:
return "off_topic" return "off_topic"