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:
@@ -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(
|
||||
"报销昨天去北京客户现场沟通产生的出租车费用",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
455
server/tests/test_steward_action_executor.py
Normal file
455
server/tests/test_steward_action_executor.py
Normal 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",
|
||||
}
|
||||
]
|
||||
234
server/tests/test_steward_graph_planner.py
Normal file
234
server/tests/test_steward_graph_planner.py
Normal 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)
|
||||
268
server/tests/test_steward_graph_runtime.py
Normal file
268
server/tests/test_steward_graph_runtime.py
Normal 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]
|
||||
@@ -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
|
||||
|
||||
@@ -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