Refine travel reimbursement steward flow
Align planner, runtime rules, and policy assets so travel guidance matches the updated reimbursement workflow.
This commit is contained in:
@@ -38,8 +38,14 @@ from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
)
|
||||
from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
|
||||
@@ -64,6 +70,9 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
|
||||
real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY
|
||||
for file_name in (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
):
|
||||
@@ -197,12 +206,36 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||
assert communication_config["scenario_category"] == "通信费"
|
||||
assert communication_config["ai_review_category"] == "通信费"
|
||||
assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
|
||||
assert preapproval_config["tag"] == "财务规则"
|
||||
assert travel_config["tag"] == "基础规则"
|
||||
assert communication_config["tag"] == "基础规则"
|
||||
assert preapproval_config["tag"] == "申请规则"
|
||||
assert preapproval_config["finance_rule_code"] == "expense.preapproval.policy"
|
||||
assert preapproval_config["finance_rule_sheet"] == "费用申请审批规则"
|
||||
assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"]
|
||||
assert preapproval_config["rule_document"]["file_name"] == COMPANY_PREAPPROVAL_RULE_FILENAME
|
||||
|
||||
grade_mapping_rule = next(
|
||||
item for item in rules if item.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE
|
||||
)
|
||||
season_mapping_rule = next(
|
||||
item for item in rules if item.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE
|
||||
)
|
||||
transport_estimate_rule = next(
|
||||
item for item in rules if item.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE
|
||||
)
|
||||
assert grade_mapping_rule.config_json["tag"] == "基础规则"
|
||||
assert grade_mapping_rule.config_json["rule_document"]["file_name"] == (
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
|
||||
)
|
||||
assert season_mapping_rule.config_json["tag"] == "基础规则"
|
||||
assert season_mapping_rule.config_json["rule_document"]["file_name"] == (
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
|
||||
)
|
||||
assert transport_estimate_rule.config_json["tag"] == "基础规则"
|
||||
assert transport_estimate_rule.config_json["rule_document"]["file_name"] == (
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
|
||||
)
|
||||
|
||||
|
||||
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
|
||||
with build_session() as db:
|
||||
@@ -743,15 +776,15 @@ def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -
|
||||
|
||||
assert catalog.travel_policy is not None
|
||||
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则"
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500
|
||||
assert catalog.travel_policy.standard_rule_name == "差旅住宿报销标准"
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["P0"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["P4"] == 450
|
||||
assert catalog.travel_policy.hotel_city_limits["北京"]["P8"] == 500
|
||||
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
|
||||
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
|
||||
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
|
||||
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1
|
||||
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1
|
||||
assert catalog.travel_policy.transport_limits["P7"]["flight"] == 1
|
||||
assert catalog.travel_policy.transport_limits["P8"]["train"] == 2
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
|
||||
@@ -777,18 +810,23 @@ def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> N
|
||||
),
|
||||
)
|
||||
|
||||
assert result.rule_name == "公司差旅费报销规则"
|
||||
assert result.rule_name == "差旅住宿报销标准"
|
||||
assert result.grade == "P4"
|
||||
assert result.grade_band == "mid"
|
||||
assert result.grade_band == "P4"
|
||||
assert result.matched_city == "北京"
|
||||
assert result.hotel_rate == 450
|
||||
assert result.hotel_amount == 1350
|
||||
assert result.allowance_region == "直辖市/特区"
|
||||
assert result.total_allowance_rate == 100
|
||||
assert result.allowance_amount == 300
|
||||
assert result.total_amount == 1650
|
||||
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text
|
||||
assert "参考可报销总金额为 1650.00 元" in result.summary_text
|
||||
assert result.transport_estimated_amount == 1040
|
||||
assert result.transport_estimate_source == "basic_rule_transport_estimate"
|
||||
assert result.total_amount == 2690
|
||||
assert (
|
||||
"交通 1040.00 + 住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 2690.00"
|
||||
== result.formula_text
|
||||
)
|
||||
assert "申请预算占用参考总金额为 2690.00 元" in result.summary_text
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
|
||||
@@ -821,7 +859,8 @@ def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_lo
|
||||
assert result.allowance_region == "其他地区"
|
||||
assert result.total_allowance_rate == 90
|
||||
assert result.allowance_amount == 180
|
||||
assert result.total_amount == 940
|
||||
assert result.transport_estimated_amount == 720
|
||||
assert result.total_amount == 1660
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:
|
||||
|
||||
138
server/tests/test_steward_flow_state.py
Normal file
138
server/tests/test_steward_flow_state.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.steward import (
|
||||
StewardCandidateFlow,
|
||||
StewardFlowStatePatch,
|
||||
StewardPendingFlowConfirmation,
|
||||
StewardPlanResponse,
|
||||
)
|
||||
from app.services.steward_flow_state import StewardFlowStateService
|
||||
|
||||
|
||||
def test_state_merge_keeps_application_and_reimbursement_flows() -> None:
|
||||
service = StewardFlowStateService()
|
||||
|
||||
state = service.merge_state(
|
||||
{},
|
||||
StewardFlowStatePatch(
|
||||
active_flow="travel_application",
|
||||
flow_id="travel_application",
|
||||
intent="travel_application_create",
|
||||
fields={"expense_type": "travel", "location": "上海", "reason": "客户现场支撑"},
|
||||
missing_fields=["transport_mode"],
|
||||
),
|
||||
)
|
||||
state = service.merge_state(
|
||||
state,
|
||||
StewardFlowStatePatch(
|
||||
active_flow="travel_reimbursement",
|
||||
flow_id="travel_reimbursement",
|
||||
intent="travel_reimbursement_draft",
|
||||
fields={"amount": "708.00", "invoice_no": "NO-1"},
|
||||
linked_application_claim_id="claim-app-001",
|
||||
),
|
||||
)
|
||||
|
||||
assert state["active_flow"] == "travel_reimbursement"
|
||||
assert state["flows"]["travel_application"]["fields"]["location"] == "上海"
|
||||
assert state["flows"]["travel_application"]["missing_fields"] == ["transport_mode"]
|
||||
assert state["flows"]["travel_reimbursement"]["fields"]["amount"] == "708.00"
|
||||
assert state["flows"]["travel_reimbursement"]["linked_application_claim_id"] == "claim-app-001"
|
||||
|
||||
|
||||
def test_state_merge_filters_non_ontology_fields() -> None:
|
||||
service = StewardFlowStateService()
|
||||
|
||||
state = service.merge_state(
|
||||
{},
|
||||
StewardFlowStatePatch(
|
||||
active_flow="travel_application",
|
||||
flow_id="travel_application",
|
||||
intent="travel_application_create",
|
||||
fields={
|
||||
"location": "上海",
|
||||
"invented_field": "x",
|
||||
"occurred_date": "2026-06-15",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
assert state["flows"]["travel_application"]["fields"] == {
|
||||
"location": "上海",
|
||||
"time_range": "2026-06-15",
|
||||
}
|
||||
|
||||
|
||||
def test_state_merge_appends_traceable_events() -> None:
|
||||
service = StewardFlowStateService()
|
||||
|
||||
state = service.merge_state(
|
||||
{},
|
||||
StewardFlowStatePatch(
|
||||
active_flow="travel_application",
|
||||
flow_id="travel_application",
|
||||
intent="travel_application_create",
|
||||
fields={"location": "北京"},
|
||||
evidence=[{"source": "user_message", "field": "location", "text": "去北京出差"}],
|
||||
),
|
||||
)
|
||||
|
||||
assert len(state["events"]) == 1
|
||||
assert state["events"][0]["flow_id"] == "travel_application"
|
||||
assert state["events"][0]["intent"] == "travel_application_create"
|
||||
assert state["events"][0]["fields"] == {"location": "北京"}
|
||||
assert state["events"][0]["evidence"][0]["text"] == "去北京出差"
|
||||
|
||||
|
||||
def test_state_merge_plan_keeps_pending_flow_confirmation() -> None:
|
||||
service = StewardFlowStateService()
|
||||
|
||||
state = service.merge_plan(
|
||||
{},
|
||||
StewardPlanResponse(
|
||||
plan_id="steward_plan_pending",
|
||||
plan_status="needs_flow_confirmation",
|
||||
planning_source="llm_function_call",
|
||||
next_action="confirm_flow",
|
||||
summary="需要先确认是申请还是报销。",
|
||||
pending_flow_confirmation=StewardPendingFlowConfirmation(
|
||||
status="pending",
|
||||
source_message="2月20-23日去上海出差辅助国网仿生产环境部署",
|
||||
reason="缺少申请或报销动作词。",
|
||||
candidate_flows=[
|
||||
StewardCandidateFlow(
|
||||
flow_id="travel_application",
|
||||
label="补办出差申请",
|
||||
confidence=0.52,
|
||||
reason="可能是补办申请。",
|
||||
ontology_fields={
|
||||
"time_range": "2026-02-20",
|
||||
"location": "上海",
|
||||
"expense_type": "travel",
|
||||
"reason": "辅助国网仿生产环境部署",
|
||||
},
|
||||
missing_fields=["transport_mode"],
|
||||
),
|
||||
StewardCandidateFlow(
|
||||
flow_id="travel_reimbursement",
|
||||
label="发起费用报销",
|
||||
confidence=0.48,
|
||||
reason="可能是发起报销。",
|
||||
ontology_fields={
|
||||
"time_range": "2026-02-20",
|
||||
"location": "上海",
|
||||
"expense_type": "travel",
|
||||
"reason": "辅助国网仿生产环境部署",
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert state["active_flow"] == ""
|
||||
assert state["pending_flow_confirmation"]["status"] == "pending"
|
||||
assert state["flows"]["travel_application"]["status"] == "pending_flow_confirmation"
|
||||
assert state["flows"]["travel_application"]["fields"]["location"] == "上海"
|
||||
assert state["flows"]["travel_application"]["missing_fields"] == ["transport_mode"]
|
||||
assert state["flows"]["travel_reimbursement"]["fields"]["time_range"] == "2026-02-20"
|
||||
30
server/tests/test_steward_intent_agent.py
Normal file
30
server/tests/test_steward_intent_agent.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from app.services.steward_intent_agent import (
|
||||
STEWARD_INTENT_FUNCTION_NAME,
|
||||
StewardIntentAgent,
|
||||
)
|
||||
|
||||
|
||||
def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None:
|
||||
schema = StewardIntentAgent._build_intent_tool_schema(
|
||||
["expense_type", "time_range", "location", "reason", "transport_mode"]
|
||||
)
|
||||
|
||||
function_schema = schema["function"]
|
||||
assert function_schema["name"] == STEWARD_INTENT_FUNCTION_NAME
|
||||
properties = function_schema["parameters"]["properties"]
|
||||
pending_schema = properties["pending_flow_confirmation"]
|
||||
candidate_schema = pending_schema["properties"]["candidate_flows"]["items"]
|
||||
|
||||
assert "pending_flow_confirmation" in properties
|
||||
assert pending_schema["properties"]["status"]["enum"] == ["none", "pending"]
|
||||
assert candidate_schema["properties"]["flow_id"]["enum"] == [
|
||||
"travel_application",
|
||||
"travel_reimbursement",
|
||||
]
|
||||
assert candidate_schema["properties"]["missing_fields"]["items"]["enum"] == [
|
||||
"expense_type",
|
||||
"time_range",
|
||||
"location",
|
||||
"reason",
|
||||
"transport_mode",
|
||||
]
|
||||
@@ -63,6 +63,24 @@ class FakeFunctionCallingIntentAgent:
|
||||
)
|
||||
|
||||
|
||||
class CountingFunctionCallingIntentAgent(FakeFunctionCallingIntentAgent):
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
self.calls += 1
|
||||
return super().detect(request, base_date=base_date, canonical_fields=canonical_fields)
|
||||
|
||||
|
||||
class CountingNoResultIntentAgent:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
self.calls += 1
|
||||
return None
|
||||
|
||||
|
||||
class EmptyFunctionCallingIntentAgent:
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
return None
|
||||
@@ -125,9 +143,92 @@ class ApplicationFunctionCallingIntentAgent:
|
||||
)
|
||||
|
||||
|
||||
class PendingFlowFunctionCallingIntentAgent:
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
return StewardIntentAgentResult(
|
||||
payload={
|
||||
"thinking_events": [
|
||||
{
|
||||
"stage": "flow_confirmation",
|
||||
"title": "识别到出差事项但动作不明确",
|
||||
"content": "用户提供了时间、地点和事由,但没有明确要补办申请还是发起报销。",
|
||||
}
|
||||
],
|
||||
"pending_flow_confirmation": {
|
||||
"status": "pending",
|
||||
"source_message": request.message,
|
||||
"reason": "缺少申请或报销动作词,需要用户确认流程方向。",
|
||||
"candidate_flows": [
|
||||
{
|
||||
"flow_id": "travel_application",
|
||||
"label": "补办出差申请",
|
||||
"confidence": 0.52,
|
||||
"reason": "这句话可以理解为补办出差申请。",
|
||||
"ontology_fields": {
|
||||
"time_range": "2月20日",
|
||||
"location": "上海",
|
||||
"expense_type": "差旅",
|
||||
"reason": "辅助国网仿生产环境部署",
|
||||
},
|
||||
"missing_fields": ["transport_mode"],
|
||||
},
|
||||
{
|
||||
"flow_id": "travel_reimbursement",
|
||||
"label": "发起费用报销",
|
||||
"confidence": 0.48,
|
||||
"reason": "这句话也可能是在为已发生出差发起报销。",
|
||||
"ontology_fields": {
|
||||
"time_range": "2月20日",
|
||||
"location": "上海",
|
||||
"expense_type": "差旅",
|
||||
"reason": "辅助国网仿生产环境部署",
|
||||
},
|
||||
"missing_fields": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
"tasks": [],
|
||||
"attachment_groups": [],
|
||||
},
|
||||
model_call_traces=[],
|
||||
)
|
||||
|
||||
|
||||
class AmbiguousApplicationFunctionCallingIntentAgent:
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
return StewardIntentAgentResult(
|
||||
payload={
|
||||
"thinking_events": [
|
||||
{
|
||||
"stage": "task_split",
|
||||
"title": "模型直接判定为申请",
|
||||
"content": "模型误把无动作词的历史出差描述直接判定为申请。",
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_type": "expense_application",
|
||||
"title": "上海出差申请",
|
||||
"summary": "2月20-23日去上海出差辅助国网仿生产环境部署。",
|
||||
"confidence": 0.9,
|
||||
"ontology_fields": {
|
||||
"time_range": "2月20日",
|
||||
"location": "上海",
|
||||
"expense_type": "差旅",
|
||||
"reason": "辅助国网仿生产环境部署",
|
||||
},
|
||||
"missing_fields": ["transport_mode"],
|
||||
}
|
||||
],
|
||||
"attachment_groups": [],
|
||||
},
|
||||
model_call_traces=[{"status": "succeeded"}],
|
||||
)
|
||||
|
||||
|
||||
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天客户现场沟通的交通费",
|
||||
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u5ba2\u6237\u73b0\u573a\u6c9f\u901a\u7684\u4ea4\u901a\u8d39",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
attachments=[
|
||||
StewardAttachmentInput(name="出租车票.png"),
|
||||
@@ -157,7 +258,7 @@ def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None
|
||||
|
||||
def test_steward_planner_normalizes_llm_business_entertainment_expense_type() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="报销昨天业务招待费",
|
||||
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u62a5\u9500\u6628\u5929\u4e1a\u52a1\u62db\u5f85\u8d39",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
@@ -170,7 +271,7 @@ def test_steward_planner_normalizes_llm_business_entertainment_expense_type() ->
|
||||
|
||||
def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="明天出差北京3天,支撑国网仿生产部署",
|
||||
message="\u6211\u60f3\u7533\u8bf7\u660e\u5929\u51fa\u5dee\u5317\u4eac\u0033\u5929\uff0c\u652f\u6491\u56fd\u7f51\u4eff\u751f\u4ea7\u90e8\u7f72\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
@@ -184,19 +285,114 @@ def test_steward_planner_enforces_application_transport_gap_after_function_calli
|
||||
assert "火车、飞机或轮船" in gap_events[0].content
|
||||
|
||||
|
||||
def test_steward_planner_returns_pending_flow_confirmation_from_llm() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="2月20-23日去上海出差辅助国网仿生产环境部署",
|
||||
client_now_iso="2026-06-15T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=PendingFlowFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.next_action == "confirm_flow"
|
||||
assert result.plan_status == "needs_flow_confirmation"
|
||||
assert result.pending_flow_confirmation.status == "pending"
|
||||
assert [item.flow_id for item in result.candidate_flows] == [
|
||||
"travel_application",
|
||||
"travel_reimbursement",
|
||||
]
|
||||
assert result.candidate_flows[0].ontology_fields["time_range"] == "2026-02-20"
|
||||
assert result.candidate_flows[0].ontology_fields["location"] == "上海"
|
||||
assert "申请" in result.summary and "报销" in result.summary
|
||||
|
||||
|
||||
def test_steward_planner_skips_llm_for_single_ambiguous_travel_flow() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="\u0032\u6708\u0032\u0030-\u0032\u0033\u65e5\u53bb\u4e0a\u6d77\u51fa\u5dee\u8f85\u52a9\u56fd\u7f51\u4eff\u751f\u4ea7\u73af\u5883\u90e8\u7f72",
|
||||
client_now_iso="2026-06-15T09:30:00+08:00",
|
||||
)
|
||||
|
||||
intent_agent = CountingNoResultIntentAgent()
|
||||
|
||||
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
|
||||
|
||||
assert intent_agent.calls == 0
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.next_action == "confirm_flow"
|
||||
assert result.plan_status == "needs_flow_confirmation"
|
||||
assert result.model_call_traces == []
|
||||
assert [item.flow_id for item in result.candidate_flows] == [
|
||||
"travel_application",
|
||||
"travel_reimbursement",
|
||||
]
|
||||
|
||||
|
||||
def test_steward_planner_uses_llm_for_multi_financial_demands() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
intent_agent = CountingFunctionCallingIntentAgent()
|
||||
|
||||
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
|
||||
|
||||
assert intent_agent.calls == 1
|
||||
assert result.planning_source == "llm_function_call"
|
||||
assert result.model_call_traces[0]["status"] == "succeeded"
|
||||
|
||||
|
||||
def test_steward_planner_overrides_llm_direct_application_for_ambiguous_travel_flow() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="2月20-23日去上海出差辅助国网仿生产环境部署",
|
||||
client_now_iso="2026-06-15T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=AmbiguousApplicationFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.next_action == "confirm_flow"
|
||||
assert result.plan_status == "needs_flow_confirmation"
|
||||
assert result.tasks == []
|
||||
assert [item.flow_id for item in result.candidate_flows] == [
|
||||
"travel_application",
|
||||
"travel_reimbursement",
|
||||
]
|
||||
|
||||
|
||||
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天的交通费",
|
||||
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
|
||||
assert [task.task_type for task in result.tasks] == ["expense_application", "reimbursement"]
|
||||
assert result.tasks[0].ontology_fields["time_range"] == "2026-07-02"
|
||||
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
|
||||
assert result.thinking_events[0].stage == "rule_fallback"
|
||||
|
||||
|
||||
def test_steward_planner_rule_fallback_confirms_ambiguous_travel_flow() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="2月20-23日去上海出差辅助国网仿生产环境部署",
|
||||
client_now_iso="2026-06-15T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.next_action == "confirm_flow"
|
||||
assert result.pending_flow_confirmation.status == "pending"
|
||||
assert [item.flow_id for item in result.candidate_flows] == [
|
||||
"travel_application",
|
||||
"travel_reimbursement",
|
||||
]
|
||||
assert result.tasks == []
|
||||
assert result.confirmation_groups == []
|
||||
|
||||
|
||||
def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message=(
|
||||
@@ -326,3 +522,28 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
|
||||
assert events[0]["data"]["stage"] == "stream_start"
|
||||
assert events[-1]["event"] == "plan"
|
||||
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"
|
||||
|
||||
|
||||
def test_steward_plan_endpoint_persists_application_and_reimbursement_state() -> None:
|
||||
client = TestClient(create_app())
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/steward/plans",
|
||||
json={
|
||||
"message": "我想申请7月2日去北京出差,并且我要报销昨天的交通费",
|
||||
"user_id": "u-steward-state",
|
||||
"client_now_iso": "2026-06-04T09:30:00+08:00",
|
||||
"context_json": {"session_type": "steward", "entry_source": "personal_workbench"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["conversation_id"].startswith("conv_")
|
||||
state = payload["steward_state"]
|
||||
assert state["active_flow"] == "travel_reimbursement"
|
||||
assert state["flows"]["travel_application"]["fields"]["location"] == "北京"
|
||||
assert state["flows"]["travel_application"]["fields"]["time_range"] == "2026-07-02"
|
||||
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())
|
||||
|
||||
@@ -94,3 +94,154 @@ def test_steward_runtime_decision_fallback_keeps_current_context():
|
||||
assert result.next_action == "continue_next_task"
|
||||
assert result.target_message_id == "msg-next-task"
|
||||
assert result.target_task_id == "task-reimbursement-meal"
|
||||
|
||||
|
||||
def test_steward_runtime_decision_fallback_reads_persisted_steward_state():
|
||||
runtime = _FakeRuntime(None)
|
||||
|
||||
result = StewardRuntimeDecisionAgent(runtime).decide(
|
||||
StewardRuntimeDecisionRequest(
|
||||
user_message="我坐高铁",
|
||||
runtime_state={},
|
||||
context_json={
|
||||
"conversation_state": {
|
||||
"steward_state": {
|
||||
"active_flow": "travel_application",
|
||||
"flows": {
|
||||
"travel_application": {
|
||||
"flow_id": "travel_application",
|
||||
"intent": "travel_application_create",
|
||||
"fields": {
|
||||
"expense_type": "travel",
|
||||
"time_range": "2026-07-02",
|
||||
"location": "北京",
|
||||
"reason": "客户现场支撑",
|
||||
},
|
||||
"missing_fields": ["transport_mode"],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decision_source == "rule_fallback"
|
||||
assert result.next_action == "fill_current_slot"
|
||||
assert result.target_task_id == "travel_application"
|
||||
assert result.field_key == "transport_mode"
|
||||
assert result.field_value == "我坐高铁"
|
||||
assert result.steward_state["flows"]["travel_application"]["fields"]["transport_mode"] == "我坐高铁"
|
||||
assert result.steward_state["flows"]["travel_application"]["missing_fields"] == []
|
||||
|
||||
|
||||
def test_steward_runtime_decision_fallback_confirms_selected_flow():
|
||||
runtime = _FakeRuntime(None)
|
||||
|
||||
result = StewardRuntimeDecisionAgent(runtime).decide(
|
||||
StewardRuntimeDecisionRequest(
|
||||
user_message="补办出差申请",
|
||||
runtime_state={},
|
||||
context_json={
|
||||
"conversation_state": {
|
||||
"steward_state": {
|
||||
"version": "steward.flow_state.v2",
|
||||
"active_flow": "",
|
||||
"pending_flow_confirmation": {
|
||||
"status": "pending",
|
||||
"source_message": "2月20-23日去上海出差辅助国网仿生产环境部署",
|
||||
"reason": "缺少申请或报销动作词。",
|
||||
"candidate_flows": [
|
||||
{
|
||||
"flow_id": "travel_application",
|
||||
"label": "补办出差申请",
|
||||
"confidence": 0.52,
|
||||
},
|
||||
{
|
||||
"flow_id": "travel_reimbursement",
|
||||
"label": "发起费用报销",
|
||||
"confidence": 0.48,
|
||||
},
|
||||
],
|
||||
},
|
||||
"flows": {
|
||||
"travel_application": {
|
||||
"flow_id": "travel_application",
|
||||
"intent": "travel_application_create",
|
||||
"status": "pending_flow_confirmation",
|
||||
"fields": {
|
||||
"time_range": "2026-02-20",
|
||||
"location": "上海",
|
||||
"expense_type": "travel",
|
||||
"reason": "辅助国网仿生产环境部署",
|
||||
},
|
||||
"missing_fields": ["transport_mode"],
|
||||
},
|
||||
"travel_reimbursement": {
|
||||
"flow_id": "travel_reimbursement",
|
||||
"intent": "travel_reimbursement_draft",
|
||||
"status": "pending_flow_confirmation",
|
||||
"fields": {
|
||||
"time_range": "2026-02-20",
|
||||
"location": "上海",
|
||||
"expense_type": "travel",
|
||||
"reason": "辅助国网仿生产环境部署",
|
||||
},
|
||||
"missing_fields": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decision_source == "rule_fallback"
|
||||
assert result.next_action == "continue_selected_flow"
|
||||
assert result.target_task_id == "travel_application"
|
||||
assert result.steward_state["active_flow"] == "travel_application"
|
||||
assert result.steward_state["pending_flow_confirmation"]["status"] == "confirmed"
|
||||
assert result.steward_state["flows"]["travel_application"]["status"] == "collecting"
|
||||
|
||||
|
||||
def test_steward_runtime_decision_fallback_confirms_reimbursement_flow():
|
||||
runtime = _FakeRuntime(None)
|
||||
|
||||
result = StewardRuntimeDecisionAgent(runtime).decide(
|
||||
StewardRuntimeDecisionRequest(
|
||||
user_message="发起费用报销",
|
||||
runtime_state={
|
||||
"steward_state": {
|
||||
"version": "steward.flow_state.v2",
|
||||
"active_flow": "",
|
||||
"pending_flow_confirmation": {
|
||||
"status": "pending",
|
||||
"candidate_flows": [
|
||||
{"flow_id": "travel_application", "label": "补办出差申请"},
|
||||
{"flow_id": "travel_reimbursement", "label": "发起费用报销"},
|
||||
],
|
||||
},
|
||||
"flows": {
|
||||
"travel_reimbursement": {
|
||||
"flow_id": "travel_reimbursement",
|
||||
"intent": "travel_reimbursement_draft",
|
||||
"status": "pending_flow_confirmation",
|
||||
"fields": {
|
||||
"time_range": "2026-02-20",
|
||||
"location": "上海",
|
||||
"expense_type": "travel",
|
||||
"reason": "辅助国网仿生产环境部署",
|
||||
},
|
||||
"missing_fields": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decision_source == "rule_fallback"
|
||||
assert result.next_action == "continue_selected_flow"
|
||||
assert result.target_task_id == "travel_reimbursement"
|
||||
assert result.steward_state["active_flow"] == "travel_reimbursement"
|
||||
assert result.steward_state["pending_flow_confirmation"]["status"] == "confirmed"
|
||||
|
||||
Reference in New Issue
Block a user