feat(steward): off_topic 场景细分与引导回复
- 将业务无关输入细分为 greeting / meaningless / off_business 三类场景 - 新增 StewardOffTopicAgent,用 function calling 生成管家语气引导回复 - steward endpoint 与 user_agent_application 串联 off_topic 引导话术 - 补充 planner 与 user agent 的 off_topic 覆盖测试
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import 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.financial_record import ExpenseClaim
|
||||
from app.schemas.steward import StewardAttachmentInput, StewardPlanRequest
|
||||
from app.services.steward_intent_agent import StewardIntentAgentResult
|
||||
from app.services.steward_planner import StewardPlannerService
|
||||
@@ -226,6 +234,61 @@ class AmbiguousApplicationFunctionCallingIntentAgent:
|
||||
)
|
||||
|
||||
|
||||
def _create_steward_test_client_with_db():
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
app = create_app()
|
||||
|
||||
def override_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return TestClient(app), TestingSessionLocal, app
|
||||
|
||||
|
||||
def _build_endpoint_application_claim(
|
||||
*,
|
||||
claim_no: str = "AP-202602-001",
|
||||
employee_name: str = "张小青",
|
||||
status: str = "approved",
|
||||
) -> ExpenseClaim:
|
||||
return ExpenseClaim(
|
||||
id=claim_no.lower().replace("-", "_"),
|
||||
claim_no=claim_no,
|
||||
employee_name=employee_name,
|
||||
department_name="产品交付部",
|
||||
expense_type="travel_application",
|
||||
reason="辅助国网仿生产服务器部署",
|
||||
location="上海",
|
||||
amount=Decimal("1800.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 2, 19, tzinfo=UTC),
|
||||
status=status,
|
||||
approval_stage="关联单据状态",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "application_detail",
|
||||
"application_detail": {
|
||||
"application_business_time": "2026-02-20 至 2026-02-23",
|
||||
"location": "上海",
|
||||
"reason": "辅助国网仿生产服务器部署",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u5ba2\u6237\u73b0\u573a\u6c9f\u901a\u7684\u4ea4\u901a\u8d39",
|
||||
@@ -393,6 +456,61 @@ def test_steward_planner_rule_fallback_confirms_ambiguous_travel_flow() -> None:
|
||||
assert result.confirmation_groups == []
|
||||
|
||||
|
||||
def test_steward_planner_prefers_application_when_checked_required_application_missing() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="2月20-23日去上海出差辅助国网仿生产服务器部署",
|
||||
client_now_iso="2026-06-15T09:30:00+08:00",
|
||||
context_json={
|
||||
"required_application_gate": {
|
||||
"travel": {
|
||||
"checked": True,
|
||||
"candidate_count": 0,
|
||||
"candidates": [],
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert result.next_action == "confirm_flow"
|
||||
assert result.pending_flow_confirmation.status == "pending"
|
||||
assert [item.flow_id for item in result.candidate_flows] == ["travel_application"]
|
||||
assert result.candidate_flows[0].label == "先发起出差申请"
|
||||
assert "未查到可关联" in result.pending_flow_confirmation.reason
|
||||
assert "先申请" in result.summary
|
||||
|
||||
|
||||
def test_steward_planner_asks_to_link_application_when_checked_required_application_exists() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="2月20-23日去上海出差辅助国网仿生产服务器部署",
|
||||
client_now_iso="2026-06-15T09:30:00+08:00",
|
||||
context_json={
|
||||
"required_application_gate": {
|
||||
"travel": {
|
||||
"checked": True,
|
||||
"candidate_count": 2,
|
||||
"candidates": [
|
||||
{"claim_no": "AP-202602-001"},
|
||||
{"claim_no": "AP-202602-002"},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert [item.flow_id for item in result.candidate_flows] == [
|
||||
"travel_application",
|
||||
"travel_reimbursement",
|
||||
]
|
||||
assert result.candidate_flows[1].label == "关联已有申请单并发起报销"
|
||||
assert "查到 2 个可关联申请单" in result.pending_flow_confirmation.reason
|
||||
assert "关联已有申请单" in result.summary
|
||||
|
||||
|
||||
def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message=(
|
||||
@@ -423,6 +541,24 @@ def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
|
||||
assert all(action.status == "pending" for action in result.confirmation_groups)
|
||||
|
||||
|
||||
def test_steward_planner_keeps_bare_reimbursement_intent_generic() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销",
|
||||
user_id="u001",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService().build_plan(payload)
|
||||
|
||||
assert len(result.tasks) == 1
|
||||
task = result.tasks[0]
|
||||
assert task.task_type == "reimbursement"
|
||||
assert task.assigned_agent == "reimbursement_assistant"
|
||||
assert task.ontology_fields.get("expense_type") == "other"
|
||||
assert "reason" not in task.ontology_fields
|
||||
assert task.missing_fields == ["time_range", "reason"]
|
||||
|
||||
|
||||
def test_steward_planner_treats_future_travel_without_apply_word_as_application() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="明天出差北京3天,支撑国网仿生产部署,并且报销昨天业务招待费",
|
||||
@@ -549,6 +685,59 @@ 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:
|
||||
client, SessionLocal, app = _create_steward_test_client_with_db()
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/steward/plans",
|
||||
json={
|
||||
"message": "2月20-23日去上海出差,辅助国网仿生产服务器部署",
|
||||
"user_id": "zhang.xiaoqing",
|
||||
"client_now_iso": "2026-06-15T09:30:00+08:00",
|
||||
"context_json": {
|
||||
"session_type": "steward",
|
||||
"entry_source": "workbench_ai_inline",
|
||||
"name": "张小青",
|
||||
"username": "zhang.xiaoqing",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [item["flow_id"] for item in payload["candidate_flows"]] == ["travel_application"]
|
||||
assert payload["candidate_flows"][0]["label"] == "先发起出差申请"
|
||||
assert "未查到可关联单据" in payload["pending_flow_confirmation"]["reason"]
|
||||
|
||||
with SessionLocal() as db:
|
||||
db.add(_build_endpoint_application_claim())
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/steward/plans",
|
||||
json={
|
||||
"message": "2月20-23日去上海出差,辅助国网仿生产服务器部署",
|
||||
"user_id": "zhang.xiaoqing",
|
||||
"client_now_iso": "2026-06-15T09:30:00+08:00",
|
||||
"context_json": {
|
||||
"session_type": "steward",
|
||||
"entry_source": "workbench_ai_inline",
|
||||
"name": "张小青",
|
||||
"username": "zhang.xiaoqing",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [item["flow_id"] for item in payload["candidate_flows"]] == [
|
||||
"travel_application",
|
||||
"travel_reimbursement",
|
||||
]
|
||||
assert payload["candidate_flows"][1]["label"] == "关联已有申请单并发起报销"
|
||||
assert "查到 1 个可关联申请单" in payload["pending_flow_confirmation"]["reason"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_steward_planner_returns_off_topic_for_business_irrelevant_input() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="123",
|
||||
@@ -566,9 +755,11 @@ def test_steward_planner_returns_off_topic_for_business_irrelevant_input() -> No
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert len(result.suggested_prompts) == 3
|
||||
assert result.thinking_events[0].stage == "off_topic"
|
||||
# 纯数字应归类为 meaningless 场景
|
||||
assert "未识别到财务事项" in result.thinking_events[0].title
|
||||
|
||||
|
||||
def test_steward_planner_returns_off_topic_for_pure_greeting() -> None:
|
||||
def test_steward_planner_returns_off_topic_with_friendly_greeting_reply() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="你好",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
@@ -582,7 +773,10 @@ def test_steward_planner_returns_off_topic_for_pure_greeting() -> None:
|
||||
assert result.candidate_flows == []
|
||||
assert result.planning_source == "rule_fallback"
|
||||
assert len(result.suggested_prompts) == 3
|
||||
assert result.thinking_events[0].stage == "off_topic"
|
||||
# 问候场景应礼貌回应主人,不使用"抱歉/没识别到"等生硬措辞
|
||||
assert "您好主人" in result.summary
|
||||
assert "很高兴为您服务" in result.summary
|
||||
assert "先回应主人的问候" in result.thinking_events[0].title
|
||||
|
||||
|
||||
def test_steward_planner_returns_off_topic_for_pure_punctuation() -> None:
|
||||
@@ -602,6 +796,86 @@ def test_steward_planner_returns_off_topic_for_pure_punctuation() -> None:
|
||||
assert result.thinking_events[0].stage == "off_topic"
|
||||
|
||||
|
||||
def test_steward_planner_returns_off_topic_for_off_business_with_llm_response() -> None:
|
||||
"""有内容但与业务无关的场景:应优先使用 LLM 生成的引导文案。"""
|
||||
llm_text = (
|
||||
"### 抱歉主人,这句话我暂时帮不上忙\n\n"
|
||||
"主人聊的是天气,目前小财管家只能帮您整理**费用申请**和**费用报销**。"
|
||||
"要不您把想办的财务事项告诉我?"
|
||||
)
|
||||
|
||||
class _FakeOffTopicAgent:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
self.last_call_traces: list[dict[str, object]] = []
|
||||
|
||||
def generate(self, request, *, scenario):
|
||||
self.calls += 1
|
||||
from app.services.steward_off_topic_agent import StewardOffTopicAgentResult
|
||||
|
||||
return StewardOffTopicAgentResult(
|
||||
response_text=llm_text,
|
||||
model_call_traces=[{"slot": "main", "status": "succeeded", "model": "gpt-test"}],
|
||||
)
|
||||
|
||||
agent = _FakeOffTopicAgent()
|
||||
payload = StewardPlanRequest(
|
||||
message="想问候您一下",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(off_topic_agent=agent).build_plan(payload)
|
||||
|
||||
assert agent.calls == 1
|
||||
assert result.plan_status == "off_topic"
|
||||
assert result.summary == llm_text
|
||||
assert result.model_call_traces and result.model_call_traces[0]["status"] == "succeeded"
|
||||
# 思考事件应是 off_business 场景对应文案
|
||||
assert "不在服务范围内" in result.thinking_events[0].title
|
||||
|
||||
|
||||
def test_steward_planner_falls_back_to_template_when_off_topic_agent_raises() -> None:
|
||||
"""LLM 失败时静默 fallback 到规则模板,不阻断业务无关拦截。"""
|
||||
|
||||
class _ExplodingOffTopicAgent:
|
||||
def generate(self, request, *, scenario):
|
||||
raise RuntimeError("模型供应商不可用")
|
||||
|
||||
payload = StewardPlanRequest(
|
||||
message="想问候您一下",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(off_topic_agent=_ExplodingOffTopicAgent()).build_plan(payload)
|
||||
|
||||
assert result.plan_status == "off_topic"
|
||||
# 仍使用 off_business 场景的默认模板
|
||||
assert "抱歉主人" in result.summary
|
||||
assert "不在服务范围内" in result.thinking_events[0].title
|
||||
assert result.model_call_traces == []
|
||||
|
||||
|
||||
def test_steward_planner_skips_off_topic_agent_for_greeting_and_meaningless() -> None:
|
||||
"""问候与无意义场景不走 LLM,节省调用。"""
|
||||
|
||||
class _CallCounterOffTopicAgent:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def generate(self, request, *, scenario):
|
||||
self.calls += 1
|
||||
return None
|
||||
|
||||
agent = _CallCounterOffTopicAgent()
|
||||
service = StewardPlannerService(off_topic_agent=agent)
|
||||
|
||||
for message in ("你好", "123", "???"):
|
||||
result = service.build_plan(StewardPlanRequest(message=message))
|
||||
assert result.plan_status == "off_topic"
|
||||
|
||||
assert agent.calls == 0
|
||||
|
||||
|
||||
def test_steward_planner_preserves_normal_business_flow_after_guard() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天的交通费",
|
||||
|
||||
@@ -753,6 +753,33 @@ def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None
|
||||
assert response.draft_payload is None
|
||||
|
||||
|
||||
def test_user_agent_application_maps_preview_travel_type_label() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"申请出差,2月20-23日上海,火车",
|
||||
context_overrides={
|
||||
"name": "曹笑竹",
|
||||
"department_name": "技术部",
|
||||
"grade": "P5",
|
||||
"application_preview": {
|
||||
"fields": {
|
||||
"applicationType": "travel",
|
||||
"time": "2026-02-20 至 2026-02-23",
|
||||
"location": "上海市",
|
||||
"reason": "",
|
||||
"days": "4天",
|
||||
"transportMode": "火车",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert "| 申请类型 | 差旅费用申请 |" in response.answer
|
||||
assert "| 申请类型 | travel |" not in response.answer
|
||||
|
||||
|
||||
def test_user_agent_application_edit_resubmits_returned_application_claim() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
Reference in New Issue
Block a user