refactor(server): steward 决策链路改用 LangGraph 编排
- 新增 StewardGraphPlannerService,用 LangGraph 状态图编排意图识别→流程判断→模型/规则分支→兜底,替代原 planner 内线性调用 - 新增 StewardGraphRuntimeService 编排运行时决策与槽位决策;StewardActionContracts/Executor 统一动作合约与执行 - steward_intent_agent/application_fact_resolver/runtime_chat 适配图执行器,config 暴露图相关开关 - pyproject/uv.lock 新增 langgraph 依赖 - 新增 graph_planner/graph_runtime/action_executor 测试,更新 intent_agent/planner/fact_resolver/runtime_chat/reimbursement 测试
This commit is contained in:
@@ -135,6 +135,7 @@ class ApplicationFunctionCallingIntentAgent:
|
||||
"task_type": "expense_application",
|
||||
"title": "北京出差申请",
|
||||
"summary": "明天前往北京出差3天,支撑国网仿生产部署。",
|
||||
"requested_action": "save_draft",
|
||||
"confidence": 0.94,
|
||||
"ontology_fields": {
|
||||
"time_range": "明天",
|
||||
@@ -151,6 +152,52 @@ class ApplicationFunctionCallingIntentAgent:
|
||||
)
|
||||
|
||||
|
||||
class SingleTravelApplicationFunctionCallingIntentAgent:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
self.calls += 1
|
||||
return StewardIntentAgentResult(
|
||||
payload={
|
||||
"thinking_events": [
|
||||
{
|
||||
"stage": "task_split",
|
||||
"title": "识别出差申请草稿",
|
||||
"content": "模型识别到用户要创建上海出差申请,并保存草稿。",
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_type": "expense_application",
|
||||
"title": "上海出差申请",
|
||||
"summary": "2026-02-20 至 2026-02-23 前往上海,国网仿生产服务器部署,火车出行。",
|
||||
"requested_action": "save_draft",
|
||||
"confidence": 0.95,
|
||||
"ontology_fields": {
|
||||
"time_range": "2026-02-20 至 2026-02-23",
|
||||
"location": "上海",
|
||||
"expense_type": "差旅",
|
||||
"reason": "国网仿生产服务器部署",
|
||||
"transport_mode": "火车",
|
||||
},
|
||||
"missing_fields": [],
|
||||
}
|
||||
],
|
||||
"attachment_groups": [],
|
||||
},
|
||||
model_call_traces=[
|
||||
{
|
||||
"slot": "main",
|
||||
"provider": "OpenAI Compatible",
|
||||
"model": "gpt-test",
|
||||
"attempt": 1,
|
||||
"status": "succeeded",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class PendingFlowFunctionCallingIntentAgent:
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
return StewardIntentAgentResult(
|
||||
@@ -255,6 +302,17 @@ def _create_steward_test_client_with_db():
|
||||
return TestClient(app), TestingSessionLocal, app
|
||||
|
||||
|
||||
def _build_fast_rule_fallback_steward_planner(_db):
|
||||
return StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent())
|
||||
|
||||
|
||||
def _patch_steward_endpoint_planner(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.endpoints.steward._build_steward_planner",
|
||||
_build_fast_rule_fallback_steward_planner,
|
||||
)
|
||||
|
||||
|
||||
def _build_endpoint_application_claim(
|
||||
*,
|
||||
claim_no: str = "AP-202602-001",
|
||||
@@ -341,6 +399,7 @@ def test_steward_planner_enforces_application_transport_gap_after_function_calli
|
||||
result = StewardPlannerService(intent_agent=ApplicationFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "llm_function_call"
|
||||
assert result.tasks[0].requested_action == "save_draft"
|
||||
assert result.tasks[0].missing_fields == ["transport_mode"]
|
||||
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
|
||||
assert gap_events
|
||||
@@ -356,7 +415,7 @@ def test_steward_planner_returns_pending_flow_confirmation_from_llm() -> None:
|
||||
|
||||
result = StewardPlannerService(intent_agent=PendingFlowFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.planning_source == "llm_function_call"
|
||||
assert result.next_action == "confirm_flow"
|
||||
assert result.plan_status == "needs_flow_confirmation"
|
||||
assert result.pending_flow_confirmation.status == "pending"
|
||||
@@ -364,12 +423,12 @@ def test_steward_planner_returns_pending_flow_confirmation_from_llm() -> None:
|
||||
"travel_application",
|
||||
"travel_reimbursement",
|
||||
]
|
||||
assert result.candidate_flows[0].ontology_fields["time_range"] == "2026-02-20"
|
||||
assert result.candidate_flows[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23"
|
||||
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:
|
||||
def test_steward_planner_tries_llm_before_rule_fallback_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",
|
||||
@@ -379,7 +438,7 @@ def test_steward_planner_skips_llm_for_single_ambiguous_travel_flow() -> None:
|
||||
|
||||
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
|
||||
|
||||
assert intent_agent.calls == 0
|
||||
assert intent_agent.calls == 1
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.next_action == "confirm_flow"
|
||||
assert result.plan_status == "needs_flow_confirmation"
|
||||
@@ -404,6 +463,37 @@ def test_steward_planner_uses_llm_for_multi_financial_demands() -> None:
|
||||
assert result.model_call_traces[0]["status"] == "succeeded"
|
||||
|
||||
|
||||
def test_steward_planner_uses_llm_for_single_explicit_travel_save_draft() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。",
|
||||
client_now_iso="2026-06-24T14:20:00+08:00",
|
||||
)
|
||||
intent_agent = SingleTravelApplicationFunctionCallingIntentAgent()
|
||||
|
||||
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
|
||||
|
||||
assert intent_agent.calls == 1
|
||||
assert result.planning_source == "llm_function_call"
|
||||
assert result.tasks[0].requested_action == "save_draft"
|
||||
assert result.tasks[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23"
|
||||
assert result.tasks[0].ontology_fields["reason"] == "国网仿生产服务器部署"
|
||||
assert result.model_call_traces[0]["status"] == "succeeded"
|
||||
|
||||
|
||||
def test_steward_planner_rule_fallback_keeps_save_draft_action_and_date_range() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。",
|
||||
client_now_iso="2026-06-24T14:20:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.tasks[0].requested_action == "save_draft"
|
||||
assert result.tasks[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23"
|
||||
assert result.tasks[0].ontology_fields["reason"] == "国网仿生产服务器部署"
|
||||
|
||||
|
||||
def test_steward_planner_overrides_llm_direct_application_for_ambiguous_travel_flow() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="2月20-23日去上海出差辅助国网仿生产环境部署",
|
||||
@@ -412,7 +502,7 @@ def test_steward_planner_overrides_llm_direct_application_for_ambiguous_travel_f
|
||||
|
||||
result = StewardPlannerService(intent_agent=AmbiguousApplicationFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.planning_source == "llm_function_call"
|
||||
assert result.next_action == "confirm_flow"
|
||||
assert result.plan_status == "needs_flow_confirmation"
|
||||
assert result.tasks == []
|
||||
@@ -557,6 +647,34 @@ def test_steward_planner_keeps_bare_reimbursement_intent_generic() -> None:
|
||||
assert task.ontology_fields.get("expense_type") == "other"
|
||||
assert "reason" not in task.ontology_fields
|
||||
assert task.missing_fields == ["time_range", "reason"]
|
||||
assert [step.action_type for step in task.action_steps] == [
|
||||
"fill_reimbursement_fields",
|
||||
"build_reimbursement_preview",
|
||||
"validate_required_fields",
|
||||
"create_reimbursement_draft",
|
||||
]
|
||||
assert task.action_steps[-1].status == "blocked"
|
||||
|
||||
|
||||
def test_steward_planner_builds_reimbursement_action_steps() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天客户现场沟通的交通费",
|
||||
user_id="u001",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
context_json={"review_form_values": {"amount": "128.50"}},
|
||||
)
|
||||
|
||||
result = StewardPlannerService().build_plan(payload)
|
||||
|
||||
assert result.tasks[0].task_type == "reimbursement"
|
||||
assert [step.action_type for step in result.tasks[0].action_steps] == [
|
||||
"fill_reimbursement_fields",
|
||||
"build_reimbursement_preview",
|
||||
"validate_required_fields",
|
||||
"create_reimbursement_draft",
|
||||
]
|
||||
assert result.tasks[0].action_steps[0].payload["ontology_fields"]["amount"] == "128.50"
|
||||
assert result.tasks[0].action_steps[-1].status == "planned"
|
||||
|
||||
|
||||
def test_steward_planner_treats_future_travel_without_apply_word_as_application() -> None:
|
||||
@@ -636,7 +754,8 @@ def test_steward_planner_builds_travel_attachment_group_with_exclusions() -> Non
|
||||
assert len(attachment_actions) == 1
|
||||
|
||||
|
||||
def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
|
||||
def test_steward_stream_endpoint_emits_thinking_before_plan(monkeypatch) -> None:
|
||||
_patch_steward_endpoint_planner(monkeypatch)
|
||||
client = TestClient(create_app())
|
||||
|
||||
with client.stream(
|
||||
@@ -660,7 +779,8 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
|
||||
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"
|
||||
|
||||
|
||||
def test_steward_plan_endpoint_persists_application_and_reimbursement_state() -> None:
|
||||
def test_steward_plan_endpoint_persists_application_and_reimbursement_state(monkeypatch) -> None:
|
||||
_patch_steward_endpoint_planner(monkeypatch)
|
||||
client = TestClient(create_app())
|
||||
|
||||
response = client.post(
|
||||
@@ -685,7 +805,8 @@ def test_steward_plan_endpoint_persists_application_and_reimbursement_state() ->
|
||||
assert all("invented_field" not in flow["fields"] for flow in state["flows"].values())
|
||||
|
||||
|
||||
def test_steward_plan_endpoint_queries_applications_before_ambiguous_travel_choice() -> None:
|
||||
def test_steward_plan_endpoint_queries_applications_before_ambiguous_travel_choice(monkeypatch) -> None:
|
||||
_patch_steward_endpoint_planner(monkeypatch)
|
||||
client, SessionLocal, app = _create_steward_test_client_with_db()
|
||||
try:
|
||||
response = client.post(
|
||||
|
||||
Reference in New Issue
Block a user