From cce19e4c40d82f0e5a1c204b7823c8b8732a870b Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 18 Jun 2026 14:15:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(steward):=20=E6=8B=A6=E6=88=AA=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E6=97=A0=E5=85=B3=E8=BE=93=E5=85=A5=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=20off=5Ftopic=20=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schemas/steward.py:StewardPlanResponse 新增 suggested_prompts 字段 - steward_planner.py:新增 STEWARD_BUSINESS_SIGNAL_KEYWORDS 与 _is_business_irrelevant_input 守卫,在 build_plan 入口前置; 新增 _build_off_topic_plan 构造 plan_status=off_topic 的引导计划 - steward_intent_agent.py:system prompt 追加业务无关约束 - test_steward_planner.py:覆盖 123/你好/纯标点走 off_topic, 并验证正常业务输入不受守卫影响 --- server/src/app/schemas/steward.py | 4 ++ .../src/app/services/steward_intent_agent.py | 4 ++ server/src/app/services/steward_planner.py | 69 +++++++++++++++++++ server/tests/test_steward_planner.py | 66 ++++++++++++++++++ 4 files changed, 143 insertions(+) diff --git a/server/src/app/schemas/steward.py b/server/src/app/schemas/steward.py index 9ebd886..c4ae0b5 100644 --- a/server/src/app/schemas/steward.py +++ b/server/src/app/schemas/steward.py @@ -128,6 +128,10 @@ class StewardPlanResponse(BaseModel): ) candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="等待用户确认的候选流程快捷列表。") model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。") + suggested_prompts: list[str] = Field( + default_factory=list, + description="当 plan_status 为 off_topic 等场景时,给用户的推荐话术示例。", + ) class StewardSlotOption(BaseModel): diff --git a/server/src/app/services/steward_intent_agent.py b/server/src/app/services/steward_intent_agent.py index 85849b3..aae8b2e 100644 --- a/server/src/app/services/steward_intent_agent.py +++ b/server/src/app/services/steward_intent_agent.py @@ -115,6 +115,10 @@ class StewardIntentAgent: "如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。" "相对日期必须以 base_date 为准转换为明确日期。" "thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。" + "如果用户输入与出差、费用、报销、申请等财务事项完全无关" + "(例如纯数字、问候、闲聊、无意义字符、单字符重复)," + "必须让 tasks 返回空数组,并在 thinking_events 中明确说明“未识别到财务事项”," + "不要强行把无关输入识别为 expense_application 或 reimbursement 任务。" ), }, { diff --git a/server/src/app/services/steward_planner.py b/server/src/app/services/steward_planner.py index 299b270..a9cc6c6 100644 --- a/server/src/app/services/steward_planner.py +++ b/server/src/app/services/steward_planner.py @@ -49,6 +49,27 @@ CITY_NAMES = ( "无锡", ) +# 业务信号关键词:用于判定输入是否与小财管家支持的财务事项相关。 +# 只要清洗后的消息命中其中任意一个关键词,就视为业务相关;否则进入 off_topic 拦截。 +STEWARD_BUSINESS_SIGNAL_KEYWORDS: tuple[str, ...] = ( + # 动作词 + "申请", "报销", "草稿", "提交", "审批", "保存", "发起", "创建", "核对", "归集", + # 差旅场景 + "出差", "差旅", "费用", "交通", "住宿", "招待", "酒店", "机票", "航班", "高铁", + "动车", "火车", "出租车", "的士", "网约车", "打车", "地铁", "公交", "用餐", "餐饮", "宴请", + # 票据/凭证 + "票据", "发票", "凭证", "行程单", "付款截图", "付款", "小票", "收据", + # 业务对象 + "客户", "项目", "拜访", "会议", "培训", "部署", "实施", "支撑", "支持", "协助", + "调研", "驻场", "上线", "验收", "审核", + # 时间信号 + "昨天", "前天", "明天", "后天", "下周", "下月", "近期", "月底", "今天", "上周", "上月", + # 金额/数量("天"用于"出差3天"等表达) + "金额", "元", "块", "万", "千", "天", + # 复用城市名信号 + *CITY_NAMES, +) + APPLICATION_SPLIT_PATTERN = re.compile(r"(?:^|[,,。;;])[^,,。;;]*?(?:申请|出差申请|差旅申请)[^,,。;;]*") REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报销|报销)([^,,。;;!??!\n]+)") MONTH_DAY_PATTERN = re.compile(r"(?P\d{1,2})\s*月\s*(?P\d{1,2})\s*(?:日|号)?") @@ -107,6 +128,9 @@ class StewardPlannerService: raise ValueError("小财管家需要一段任务描述。") base_date = self._resolve_base_date(request.client_now_iso, request.context_json) + # 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。 + if self._is_business_irrelevant_input(message, request): + return self._build_off_topic_plan(request) model_call_traces: list[dict[str, Any]] = [] fallback_reason = "" if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request): @@ -159,6 +183,51 @@ class StewardPlannerService: return False return self._has_multiple_financial_demands(message) + @staticmethod + def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool: + """判断输入是否与小财管家支持的财务事项完全无关。 + + 判定规则:消息去除所有空白后不含任何业务信号关键词,且没有上传附件, + 即视为业务无关输入(如纯数字、问候、闲聊、乱码)。 + """ + if request.attachments: + return False + compact = re.sub(r"\s+", "", message) + if not compact: + return False + return not any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS) + + def _build_off_topic_plan(self, request: StewardPlanRequest) -> StewardPlanResponse: + """业务无关输入的兜底计划:明确告知用户未识别到财务事项,并给出话术示例。""" + return StewardPlanResponse( + plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}", + plan_status="off_topic", + planning_source="rule_fallback", + next_action="none", + summary="这看起来跟财务任务没什么关系,小财管家没识别到费用申请或费用报销的意图。", + thinking_events=[ + StewardThinkingEvent( + event_id="intent_agent_off_topic", + stage="off_topic", + title="未识别到财务事项", + content=( + "我检查了这句话,没有发现费用申请、报销、出差、交通、招待等财务线索。" + "如果你确实是要处理财务任务,可以参考下面的示例换一种说法。" + ), + ) + ], + tasks=[], + attachment_groups=[], + confirmation_groups=[], + candidate_flows=[], + suggested_prompts=[ + "我想要申请明天去北京出差3天,支撑客户现场实施", + "我要报销昨天的交通费", + "报销上周出差上海的费用", + ], + model_call_traces=[], + ) + def _build_rule_fallback_plan( self, request: StewardPlanRequest, diff --git a/server/tests/test_steward_planner.py b/server/tests/test_steward_planner.py index 1e3a4df..364e978 100644 --- a/server/tests/test_steward_planner.py +++ b/server/tests/test_steward_planner.py @@ -547,3 +547,69 @@ def test_steward_plan_endpoint_persists_application_and_reimbursement_state() -> assert state["flows"]["travel_reimbursement"]["fields"]["time_range"] == "2026-06-03" assert state["flows"]["travel_reimbursement"]["fields"]["expense_type"] == "transport" assert all("invented_field" not in flow["fields"] for flow in state["flows"].values()) + + +def test_steward_planner_returns_off_topic_for_business_irrelevant_input() -> None: + payload = StewardPlanRequest( + message="123", + client_now_iso="2026-06-04T09:30:00+08:00", + ) + + result = StewardPlannerService().build_plan(payload) + + assert result.plan_status == "off_topic" + assert result.next_action == "none" + assert result.tasks == [] + assert result.attachment_groups == [] + assert result.confirmation_groups == [] + assert result.candidate_flows == [] + assert result.planning_source == "rule_fallback" + assert len(result.suggested_prompts) == 3 + assert result.thinking_events[0].stage == "off_topic" + + +def test_steward_planner_returns_off_topic_for_pure_greeting() -> None: + payload = StewardPlanRequest( + message="你好", + client_now_iso="2026-06-04T09:30:00+08:00", + ) + + result = StewardPlannerService().build_plan(payload) + + assert result.plan_status == "off_topic" + assert result.next_action == "none" + assert result.tasks == [] + assert result.candidate_flows == [] + assert result.planning_source == "rule_fallback" + assert len(result.suggested_prompts) == 3 + assert result.thinking_events[0].stage == "off_topic" + + +def test_steward_planner_returns_off_topic_for_pure_punctuation() -> None: + payload = StewardPlanRequest( + message="??? !!!", + client_now_iso="2026-06-04T09:30:00+08:00", + ) + + result = StewardPlannerService().build_plan(payload) + + assert result.plan_status == "off_topic" + assert result.next_action == "none" + assert result.tasks == [] + assert result.candidate_flows == [] + assert result.planning_source == "rule_fallback" + assert len(result.suggested_prompts) == 3 + assert result.thinking_events[0].stage == "off_topic" + + +def test_steward_planner_preserves_normal_business_flow_after_guard() -> None: + payload = StewardPlanRequest( + message="我要报销昨天的交通费", + client_now_iso="2026-06-04T09:30:00+08:00", + ) + + result = StewardPlannerService().build_plan(payload) + + assert result.plan_status != "off_topic" + assert len(result.tasks) >= 1 + assert [task.task_type for task in result.tasks] == ["reimbursement"]