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:
caoxiaozhu
2026-06-24 21:58:35 +08:00
parent 545b31d32f
commit 5311c99d69
25 changed files with 3580 additions and 104 deletions

View File

@@ -22,6 +22,17 @@ def test_application_fact_resolver_extracts_travel_application_fields() -> None:
assert facts["transport_mode"] == "train"
def test_application_fact_resolver_drops_transport_prompt_from_application_reason() -> None:
facts = resolve_application_facts(
"2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交",
"expense_application",
date(2026, 6, 24),
)
assert facts["reason"] == "辅助国网仿生产服务器部署"
assert facts["transport_mode"] == "train"
def test_application_fact_resolver_preserves_reimbursement_transport_semantics() -> None:
facts = resolve_application_facts(
"报销昨天去北京客户现场沟通产生的出租车费用",

View File

@@ -939,7 +939,7 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "submitted"
assert draft_payload["approval_stage"] == "直属领导审批"
assert draft_payload["claim_no"].startswith("AP-")
assert draft_payload["claim_no"].startswith("A")
with session_factory() as db:
claim = db.get(ExpenseClaim, draft_payload["claim_id"])
@@ -1015,7 +1015,7 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
assert draft_payload["status"] == "draft"
assert draft_payload["approval_stage"] == "待提交"
assert draft_payload["claim_id"]
assert draft_payload["claim_no"].startswith("AP-")
assert draft_payload["claim_no"].startswith("A")
with session_factory() as db:
claim = db.get(ExpenseClaim, draft_payload["claim_id"])

View File

@@ -242,6 +242,50 @@ def test_runtime_chat_supports_single_pass_fast_failover(monkeypatch) -> None:
assert calls == [("main", 8), ("backup", 20)]
def test_runtime_chat_complete_with_tool_call_fails_over_to_backup_before_retrying_main(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
calls: list[str] = []
def fake_load_chat_slot(slot: str):
return {
"slot": slot,
"provider": "MiniMax" if slot == "main" else "GLM",
"endpoint": "https://example.com/v1",
"model": "main-model" if slot == "main" else "backup-model",
"apiKey": "secret",
}
def fake_request_chat_tool_call(config, messages, *, tools, tool_choice, max_tokens, temperature, timeout_seconds):
del messages, tools, tool_choice, max_tokens, temperature, timeout_seconds
calls.append(config["slot"])
if config["slot"] == "main":
raise RuntimeError("main tool call unavailable")
return runtime_chat_module.RuntimeChatToolCall(
name="submit_steward_intent_plan",
arguments={"tasks": [{"task_type": "expense_application"}]},
)
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
monkeypatch.setattr(service, "_request_chat_tool_call", fake_request_chat_tool_call)
result = service.complete_with_tool_call(
[{"role": "user", "content": "保存草稿"}],
tools=[{"type": "function", "function": {"name": "submit_steward_intent_plan"}}],
tool_choice={"type": "function", "function": {"name": "submit_steward_intent_plan"}},
max_attempts=3,
use_failure_cooldown=False,
)
assert result.tool_call is not None
assert result.tool_call.name == "submit_steward_intent_plan"
assert result.tool_call.arguments["tasks"][0]["task_type"] == "expense_application"
assert calls == ["main", "backup"]
assert [item.status for item in result.calls] == ["failed", "succeeded"]
def test_runtime_chat_skips_slot_during_cooldown(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
@@ -271,3 +315,51 @@ def test_runtime_chat_skips_slot_during_cooldown(monkeypatch) -> None:
assert service.complete([{"role": "user", "content": "hello"}], max_attempts=1) == "backup answer"
assert service.complete([{"role": "user", "content": "hello again"}], max_attempts=1) == "backup answer"
assert calls == ["main", "backup", "backup"]
def test_runtime_chat_tool_call_can_retry_without_failure_cooldown(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
calls: list[str] = []
def fake_load_chat_slot(slot: str):
return {
"slot": slot,
"provider": slot,
"endpoint": "https://example.com/v1",
"model": f"{slot}-model",
"apiKey": "secret",
}
def fake_request_chat_tool_call(
config,
messages,
*,
tools,
tool_choice,
max_tokens,
temperature,
timeout_seconds,
):
del messages, tools, tool_choice, max_tokens, temperature, timeout_seconds
calls.append(config["slot"])
raise RuntimeError("tool call timeout")
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
monkeypatch.setattr(service, "_request_chat_tool_call", fake_request_chat_tool_call)
monkeypatch.setattr("app.services.runtime_chat.sleep", lambda *_args, **_kwargs: None)
result = service.complete_with_tool_call(
[{"role": "user", "content": "hello"}],
tools=[{"type": "function", "function": {"name": "submit_steward_intent_plan"}}],
tool_choice={"type": "function", "function": {"name": "submit_steward_intent_plan"}},
slot_priority=("main",),
max_attempts=3,
use_failure_cooldown=False,
)
assert result.tool_call is None
assert calls == ["main", "main", "main"]
assert [item.status for item in result.calls] == ["failed", "failed", "failed"]

View File

@@ -0,0 +1,455 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.agent_conversation import AgentConversation
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.services import attachment_association_jobs as attachment_jobs_module
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = build_session_factory()
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def seed_employee(db: Session) -> None:
manager = Employee(
id="steward-action-manager",
employee_no="E90000",
name="李总",
email="leader@example.com",
position="部门负责人",
grade="P7",
)
employee = Employee(
id="steward-action-employee",
employee_no="E90001",
name="张三",
email="zhangsan@example.com",
position="实施工程师",
grade="P4",
manager=manager,
)
db.add_all([manager, employee])
db.commit()
def auth_headers() -> dict[str, str]:
return {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E90001",
"x-auth-role-codes": "user",
"x-auth-position": "Engineer",
"x-auth-grade": "P4",
"x-auth-manager-name": "Leader",
}
def base_application_task(requested_action: str = "save_draft") -> dict[str, object]:
return {
"task_id": "task_app_001",
"task_type": "expense_application",
"assigned_agent": "application_assistant",
"title": "上海出差申请",
"summary": "2026-02-20 至 2026-02-23 去上海出差,辅助国网仿生产服务器部署,火车出行。",
"status": "needs_confirmation",
"confidence": 0.96,
"requested_action": requested_action,
"ontology_fields": {
"expense_type": "travel",
"time_range": "2026-02-20 至 2026-02-23",
"location": "上海",
"reason": "辅助国网仿生产服务器部署",
"transport_mode": "train",
},
"missing_fields": [],
"confirmation_required": requested_action == "submit",
"action_steps": [],
}
def base_reimbursement_task() -> dict[str, object]:
return {
"task_id": "task_reim_001",
"task_type": "reimbursement",
"assigned_agent": "reimbursement_assistant",
"title": "客户现场交通费报销",
"summary": "2026-03-04 打车去客户现场,交通费 32 元。",
"status": "needs_confirmation",
"confidence": 0.9,
"requested_action": "save_draft",
"ontology_fields": {
"expense_type": "transport",
"time_range": "2026-03-04",
"location": "客户现场",
"reason": "客户现场沟通",
"amount": "32元",
"transport_mode": "taxi",
},
"missing_fields": [],
"confirmation_required": False,
"action_steps": [],
}
def claim_count(db: Session) -> int:
return len(db.scalars(select(ExpenseClaim)).all())
def seed_approved_application(db: Session) -> None:
application = ExpenseClaim(
id="application-action-approved",
claim_no="AAPPROVED1",
employee_id="steward-action-employee",
employee_name="张三",
department_id="dept-delivery",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="辅助国网仿生产服务器部署",
location="上海",
amount=3000,
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=None,
status="approved",
approval_stage="已完成",
risk_flags_json=[],
)
db.add(application)
db.commit()
def test_steward_action_executor_rejects_unknown_action_without_creating_claim() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
before_count = claim_count(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "delete_all_claims",
"message": "请执行未知动作",
"task": base_application_task(),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "blocked"
assert payload["action_type"] == "delete_all_claims"
assert "不支持" in payload["message"]
with session_factory() as db:
assert claim_count(db) == before_count
def test_steward_action_executor_blocks_attachment_action_without_receipts() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
before_count = claim_count(db)
task = base_reimbursement_task()
task["ontology_fields"] = {
**task["ontology_fields"],
"attachments": "taxi.png",
}
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "associate_attachments",
"message": "关联附件 taxi.png",
"task": task,
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "blocked"
assert "receipt_id" in payload["message"] or "票据" in payload["message"]
with session_factory() as db:
assert claim_count(db) == before_count
def test_steward_action_executor_records_pending_interrupt_in_conversation_state() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "submit_application",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交",
"conversation_id": "conv-action-submit",
"client_trace_id": "trace-submit-pending",
"task": base_application_task("submit"),
"confirmed": False,
"context_json": {
"precheck_result": {
"status": "ok",
"blocking": False,
}
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "needs_confirmation"
with session_factory() as db:
conversation = db.scalar(
select(AgentConversation).where(
AgentConversation.conversation_id == "conv-action-submit"
)
)
assert conversation is not None
checkpoint = conversation.state_json["steward_action_checkpoint"]
assert checkpoint["pending_interrupt"]["client_trace_id"] == "trace-submit-pending"
assert checkpoint["pending_interrupt"]["action_type"] == "submit_application"
assert checkpoint["actions"]["trace-submit-pending"]["status"] == "needs_confirmation"
def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_duplicate_draft() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
request_payload = {
"action_type": "save_application_draft",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿",
"conversation_id": "conv-action-draft",
"client_trace_id": "trace-save-draft",
"task": base_application_task("save_draft"),
}
first_response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json=request_payload,
)
second_response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json=request_payload,
)
assert first_response.status_code == 200
assert second_response.status_code == 200
first_payload = first_response.json()
second_payload = second_response.json()
assert first_payload["status"] == "succeeded"
assert second_payload["status"] == "succeeded"
assert (
first_payload["result_payload"]["draft_payload"]["claim_id"]
== second_payload["result_payload"]["draft_payload"]["claim_id"]
)
assert second_payload["result_payload"]["idempotent_replay"] is True
with session_factory() as db:
assert claim_count(db) == 1
def test_steward_action_executor_requires_confirmation_before_submit_side_effect() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "submit_application",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交",
"task": base_application_task("submit"),
"confirmed": False,
"context_json": {
"precheck_result": {
"status": "ok",
"blocking": False,
}
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "needs_confirmation"
assert payload["requires_confirmation"] is True
with session_factory() as db:
assert claim_count(db) == 0
def test_steward_action_executor_saves_application_draft_from_action_step() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "save_application_draft",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿",
"task": base_application_task("save_draft"),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
draft_payload = payload["result_payload"]["draft_payload"]
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "draft"
assert draft_payload["claim_no"].startswith("A")
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "draft"
assert claim.reason == "辅助国网仿生产服务器部署"
def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "create_reimbursement_draft",
"message": "2026-03-04打车去客户现场交通费32元保存草稿",
"task": base_reimbursement_task(),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["status"] == "draft"
assert payload["result_payload"]["claim_id"]
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "draft"
assert claim.expense_type == "transport"
assert claim.reason == "客户现场沟通"
def test_steward_action_executor_links_application_when_creating_reimbursement_draft() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
seed_approved_application(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "link_existing_application",
"message": "关联申请单 AAPPROVED1并保存报销草稿",
"task": base_reimbursement_task(),
"context_json": {
"application_claim_id": "application-action-approved",
"application_claim_no": "AAPPROVED1",
"application_reason": "辅助国网仿生产服务器部署",
"application_location": "上海",
"application_business_time": "2026-02-20 至 2026-02-23",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["status"] == "draft"
with session_factory() as db:
claims = db.scalars(select(ExpenseClaim)).all()
reimbursement = next(claim for claim in claims if claim.id != "application-action-approved")
assert reimbursement.status == "draft"
link_flags = [
flag
for flag in list(reimbursement.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "application_link"
]
assert link_flags
assert link_flags[0]["application_claim_no"] == "AAPPROVED1"
def test_steward_action_executor_associates_receipt_attachments(monkeypatch) -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
calls: list[dict[str, object]] = []
def fake_run(self, *, receipt_ids, current_user):
calls.append({
"receipt_ids": list(receipt_ids),
"username": current_user.username,
})
return {
"claim_id": "claim-associated",
"claim_no": "BX-20260220-001",
"uploaded_count": 2,
"skipped_count": 0,
}
monkeypatch.setattr(attachment_jobs_module.AttachmentAssociationJobRunner, "run", fake_run)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "associate_attachments",
"message": "把两张火车票关联到报销草稿",
"task": base_reimbursement_task(),
"context_json": {
"receipt_ids": ["receipt-001", "receipt-002"],
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["claim_no"] == "BX-20260220-001"
assert calls == [
{
"receipt_ids": ["receipt-001", "receipt-002"],
"username": "zhangsan@example.com",
}
]

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
from app.api.v1.endpoints import steward as steward_endpoint
from app.core.config import get_settings
from app.schemas.steward import StewardPlanRequest
from app.services.steward_graph_planner import StewardGraphPlannerService
from app.services.steward_intent_agent import StewardIntentAgentResult
from app.services.steward_planner import StewardPlannerService
class GraphTravelApplicationIntentAgent:
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_type": "火车",
},
"missing_fields": [],
}
],
"attachment_groups": [],
},
model_call_traces=[
{
"slot": "main",
"provider": "MiniMax",
"model": "abab-test",
"attempt": 1,
"status": "succeeded",
}
],
)
class GraphSubmitTravelApplicationIntentAgent:
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": "submit",
"confidence": 0.96,
"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": "MiniMax",
"model": "abab-test",
"attempt": 1,
"status": "succeeded",
}
],
)
class GraphEmptyIntentAgent:
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return None
def test_langgraph_planner_preserves_llm_save_draft_plan() -> None:
intent_agent = GraphTravelApplicationIntentAgent()
service = StewardGraphPlannerService(intent_agent=intent_agent)
result = service.build_plan(
StewardPlanRequest(
message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿",
client_now_iso="2026-02-10T09:00:00+08:00",
)
)
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["transport_mode"] == "train"
assert result.model_call_traces[0]["provider"] == "MiniMax"
def test_langgraph_planner_builds_submit_action_steps_for_application() -> None:
intent_agent = GraphSubmitTravelApplicationIntentAgent()
service = StewardGraphPlannerService(intent_agent=intent_agent)
result = service.build_plan(
StewardPlanRequest(
message="2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交",
client_now_iso="2026-02-10T09:00:00+08:00",
)
)
assert intent_agent.calls == 1
assert result.planning_source == "llm_function_call"
assert result.action_steps[0].action_type == "detect_intent"
assert [step.action_type for step in result.tasks[0].action_steps] == [
"fill_application_fields",
"build_application_preview",
"validate_required_fields",
"run_duplicate_precheck",
"submit_application",
]
assert result.tasks[0].action_steps[0].payload["ontology_fields"]["location"] == "上海"
assert result.tasks[0].action_steps[-1].requires_confirmation is True
assert result.tasks[0].action_steps[-1].status == "pending_confirmation"
def test_langgraph_planner_falls_back_when_model_returns_no_tool_call() -> None:
intent_agent = GraphEmptyIntentAgent()
service = StewardGraphPlannerService(intent_agent=intent_agent)
result = service.build_plan(
StewardPlanRequest(
message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿",
client_now_iso="2026-02-10T09:00:00+08:00",
)
)
assert intent_agent.calls == 1
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["transport_mode"] == "train"
assert result.model_call_traces == []
def test_langgraph_planner_rule_fallback_builds_save_draft_action_steps() -> None:
intent_agent = GraphEmptyIntentAgent()
service = StewardGraphPlannerService(intent_agent=intent_agent)
result = service.build_plan(
StewardPlanRequest(
message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿",
client_now_iso="2026-02-10T09:00:00+08:00",
)
)
assert result.planning_source == "rule_fallback"
assert result.tasks[0].requested_action == "save_draft"
assert [step.action_type for step in result.tasks[0].action_steps] == [
"fill_application_fields",
"build_application_preview",
"validate_required_fields",
"save_application_draft",
]
assert result.tasks[0].action_steps[-1].status == "planned"
def test_build_steward_planner_uses_langgraph_runtime_when_enabled(monkeypatch) -> None:
monkeypatch.setenv("STEWARD_AGENT_RUNTIME", "langgraph")
get_settings.cache_clear()
try:
planner = steward_endpoint._build_steward_planner(db=object())
finally:
get_settings.cache_clear()
assert isinstance(planner, StewardGraphPlannerService)
def test_build_steward_planner_defaults_to_langgraph_runtime(monkeypatch) -> None:
monkeypatch.delenv("STEWARD_AGENT_RUNTIME", raising=False)
get_settings.cache_clear()
try:
planner = steward_endpoint._build_steward_planner(db=object())
finally:
get_settings.cache_clear()
assert isinstance(planner, StewardGraphPlannerService)
def test_build_steward_planner_can_fall_back_to_legacy_runtime(monkeypatch) -> None:
monkeypatch.setenv("STEWARD_AGENT_RUNTIME", "legacy")
get_settings.cache_clear()
try:
planner = steward_endpoint._build_steward_planner(db=object())
finally:
get_settings.cache_clear()
assert isinstance(planner, StewardPlannerService)

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
from typing import Any
import pytest
from app.api.v1.endpoints import steward as steward_endpoint
from app.core.config import get_settings
from app.schemas.steward import (
StewardRuntimeDecisionRequest,
StewardSlotDecisionRequest,
)
from app.services.runtime_chat import (
RuntimeChatCallTrace,
RuntimeChatToolCall,
RuntimeToolCallResult,
)
from app.services.steward_graph_runtime import StewardGraphRuntime
from app.services.steward_runtime_decision_agent import STEWARD_RUNTIME_DECISION_FUNCTION_NAME
from app.services.steward_slot_decision_agent import STEWARD_SLOT_DECISION_FUNCTION_NAME
class _FakeRuntime:
def __init__(
self,
payloads: dict[str, dict[str, Any] | None] | None = None,
*,
fail_functions: set[str] | None = None,
) -> None:
self.payloads = payloads or {}
self.fail_functions = fail_functions or set()
self.called_functions: list[str] = []
self.last_messages: list[dict[str, Any]] = []
def complete_with_tool_call(self, messages, tools, tool_choice, **kwargs):
function_name = str(tool_choice["function"]["name"])
self.called_functions.append(function_name)
self.last_messages = messages
if function_name in self.fail_functions:
raise RuntimeError(f"{function_name} failed")
payload = self.payloads.get(function_name)
if payload is None:
return RuntimeToolCallResult(tool_call=None, calls=[])
return RuntimeToolCallResult(
tool_call=RuntimeChatToolCall(name=function_name, arguments=payload),
calls=[
RuntimeChatCallTrace(
slot=function_name,
provider="fake",
model="fake",
attempt=1,
status="succeeded",
)
],
)
class _FailingGraphRuntime:
def __init__(self, runtime_chat_service) -> None:
self.runtime_chat_service = runtime_chat_service
def decide_slot(self, request):
raise RuntimeError("langgraph runtime unavailable")
def decide_runtime(self, request):
raise RuntimeError("langgraph runtime unavailable")
@pytest.fixture(autouse=True)
def _clear_settings_cache():
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_graph_runtime_routes_slot_decision_through_langgraph_tool_node() -> None:
runtime = _FakeRuntime(
{
STEWARD_SLOT_DECISION_FUNCTION_NAME: {
"next_action": "ask_user",
"required_fields": ["expense_type", "time_range", "location", "reason", "transport_mode"],
"missing_fields": ["transport_mode"],
"question": "请问您这次打算怎么出行?",
"options": [
{"field_key": "transport_mode", "label": "火车", "value": "火车"},
{"field_key": "transport_mode", "label": "飞机", "value": "飞机"},
],
"rationale": "出行方式会影响交通费用测算。",
}
}
)
result = StewardGraphRuntime(runtime).decide_slot(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署",
ontology_fields={
"expense_type": "travel",
"time_range": "2026-02-20 至 2026-02-23",
"location": "上海",
"reason": "国网仿生产服务器部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert runtime.called_functions == [STEWARD_SLOT_DECISION_FUNCTION_NAME]
def test_graph_runtime_slot_graph_falls_back_when_tool_node_fails() -> None:
runtime = _FakeRuntime(fail_functions={STEWARD_SLOT_DECISION_FUNCTION_NAME})
result = StewardGraphRuntime(runtime).decide_slot(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="上海出差,辅助国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"location": "上海",
"reason": "辅助国网仿生产部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert any(
trace.get("slot") == "langgraph_slot_decision"
and trace.get("status") == "failed"
for trace in result.model_call_traces
)
def test_graph_runtime_merges_memory_before_runtime_action_node() -> None:
runtime = _FakeRuntime({STEWARD_RUNTIME_DECISION_FUNCTION_NAME: None})
result = StewardGraphRuntime(runtime).decide_runtime(
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.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"] == []
assert runtime.called_functions == [STEWARD_RUNTIME_DECISION_FUNCTION_NAME]
assert "steward_state" in runtime.last_messages[-1]["content"]
def test_graph_runtime_selected_flow_action_node_skips_model_call() -> None:
runtime = _FakeRuntime()
result = StewardGraphRuntime(runtime).decide_runtime(
StewardRuntimeDecisionRequest(
user_message="补办出差申请",
runtime_state={
"steward_state": {
"active_flow": "",
"pending_flow_confirmation": {
"status": "pending",
"candidate_flows": [
{"flow_id": "travel_application", "label": "补办出差申请"},
{"flow_id": "travel_reimbursement", "label": "发起费用报销"},
],
},
"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"],
}
},
}
},
)
)
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 runtime.called_functions == []
def test_slot_endpoint_helper_falls_back_to_legacy_agent_when_langgraph_runtime_fails(monkeypatch) -> None:
monkeypatch.setenv("STEWARD_AGENT_RUNTIME", "langgraph")
get_settings.cache_clear()
monkeypatch.setattr(steward_endpoint, "StewardGraphRuntime", _FailingGraphRuntime)
runtime = _FakeRuntime({STEWARD_SLOT_DECISION_FUNCTION_NAME: None})
result = steward_endpoint._decide_steward_slot(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="上海出差,辅助国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"location": "上海",
"reason": "辅助国网仿生产部署",
},
missing_fields=["transport_mode"],
),
runtime,
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "ask_user"
assert runtime.called_functions == [STEWARD_SLOT_DECISION_FUNCTION_NAME]
def test_runtime_endpoint_helper_falls_back_to_legacy_agent_when_langgraph_runtime_fails(monkeypatch) -> None:
monkeypatch.setenv("STEWARD_AGENT_RUNTIME", "langgraph")
get_settings.cache_clear()
monkeypatch.setattr(steward_endpoint, "StewardGraphRuntime", _FailingGraphRuntime)
runtime = _FakeRuntime({STEWARD_RUNTIME_DECISION_FUNCTION_NAME: None})
result = steward_endpoint._decide_steward_runtime(
StewardRuntimeDecisionRequest(
user_message="确认",
runtime_state={
"pending_steward_action": {
"message_id": "msg-next-task",
"target_task_id": "task-reimbursement-meal",
}
},
),
runtime,
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_next_task"
assert result.target_message_id == "msg-next-task"
assert runtime.called_functions == [STEWARD_RUNTIME_DECISION_FUNCTION_NAME]

View File

@@ -2,6 +2,24 @@ from app.services.steward_intent_agent import (
STEWARD_INTENT_FUNCTION_NAME,
StewardIntentAgent,
)
from app.schemas.steward import StewardPlanRequest
class _NoToolCallRuntimeChatService:
def __init__(self) -> None:
self.kwargs = {}
def complete_with_tool_call(self, messages, **kwargs):
self.kwargs = kwargs
class _Result:
tool_call = None
@staticmethod
def calls_as_dicts():
return []
return _Result()
def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None:
@@ -12,9 +30,16 @@ def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None
function_schema = schema["function"]
assert function_schema["name"] == STEWARD_INTENT_FUNCTION_NAME
properties = function_schema["parameters"]["properties"]
task_schema = properties["tasks"]["items"]
pending_schema = properties["pending_flow_confirmation"]
candidate_schema = pending_schema["properties"]["candidate_flows"]["items"]
assert task_schema["properties"]["requested_action"]["enum"] == [
"preview",
"save_draft",
"submit",
]
assert "requested_action" in task_schema["required"]
assert "pending_flow_confirmation" in properties
assert pending_schema["properties"]["status"]["enum"] == ["none", "pending"]
assert candidate_schema["properties"]["flow_id"]["enum"] == [
@@ -28,3 +53,18 @@ def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None
"reason",
"transport_mode",
]
def test_steward_intent_agent_uses_ten_second_timeout_and_three_attempts() -> None:
runtime_chat = _NoToolCallRuntimeChatService()
agent = StewardIntentAgent(runtime_chat)
agent.detect(
StewardPlanRequest(message="2026-02-20 至 2026-02-23上海出差火车保存草稿。"),
base_date=__import__("datetime").date(2026, 6, 24),
canonical_fields=["expense_type", "time_range", "location", "reason", "transport_mode"],
)
assert runtime_chat.kwargs["timeout_seconds"] == 10
assert runtime_chat.kwargs["max_attempts"] == 3
assert runtime_chat.kwargs["use_failure_cooldown"] is False

View File

@@ -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(