feat: 小财管家意图规划与报销提交编排增强
- 完善管家意图识别、模型计划构建与规划器调度 - 重构差旅报销提交编排器与管家计划流程前端交互 - 优化报销消息项样式与文档中心视图 - 新增小财管家与附件上传风险前置复核设计文档 - 补充管家规划器与文档中心测试覆盖
This commit is contained in:
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse
|
||||
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse, StewardThinkingEvent
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.steward_intent_agent import StewardIntentAgent
|
||||
from app.services.steward_planner import StewardPlannerService
|
||||
@@ -55,6 +55,18 @@ async def _iter_steward_plan_events(
|
||||
payload: StewardPlanRequest,
|
||||
planner: StewardPlannerService,
|
||||
) -> AsyncIterator[str]:
|
||||
yield _encode_stream_event(
|
||||
"thinking",
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_agent_stream_start",
|
||||
stage="stream_start",
|
||||
title="意图识别智能体接管",
|
||||
content="已收到任务描述,正在调用小财管家意图识别智能体拆解申请、报销和附件线索。",
|
||||
status="running",
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
try:
|
||||
plan = planner.build_plan(payload)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -42,7 +42,7 @@ class StewardIntentAgent:
|
||||
},
|
||||
max_tokens=1800,
|
||||
temperature=0.1,
|
||||
timeout_seconds=18,
|
||||
timeout_seconds=45,
|
||||
max_attempts=1,
|
||||
)
|
||||
self.last_call_traces = result.calls_as_dicts()
|
||||
@@ -105,6 +105,9 @@ class StewardIntentAgent:
|
||||
"你必须通过 function calling 输出结构化计划,不能只返回普通文本。"
|
||||
"当前版本只支持 expense_application 和 reimbursement 两类任务;"
|
||||
"你只做识别、拆解、归集和确认点规划,不能执行入库、绑定附件或提交审批。"
|
||||
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
|
||||
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
|
||||
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。"
|
||||
"所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields;"
|
||||
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
|
||||
"相对日期必须以 base_date 为准转换为明确日期。"
|
||||
|
||||
@@ -307,7 +307,7 @@ class StewardModelPlanBuilder:
|
||||
return "travel"
|
||||
if normalized in {"transport", "traffic", "交通", "交通费", "打车", "出租车"}:
|
||||
return "transport"
|
||||
if normalized in {"entertainment", "meal", "招待", "接待", "餐饮", "业务招待"}:
|
||||
if normalized in {"entertainment", "meal", "招待", "招待费", "接待", "接待费", "餐饮", "业务招待", "业务招待费"}:
|
||||
return "entertainment"
|
||||
if normalized in {"office", "办公", "办公用品"}:
|
||||
return "office"
|
||||
|
||||
@@ -148,7 +148,7 @@ class StewardPlannerService:
|
||||
drafts: list[PlannedTaskDraft] = []
|
||||
first_reimbursement = self._find_first_reimbursement_index(message)
|
||||
application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message
|
||||
if self._looks_like_application(application_source):
|
||||
if self._looks_like_application(application_source) or self._looks_like_future_travel_application(application_source):
|
||||
drafts.append(
|
||||
PlannedTaskDraft(
|
||||
task_type="expense_application",
|
||||
@@ -180,6 +180,31 @@ class StewardPlannerService:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
return bool(compact) and "申请" in compact and bool(re.search(r"出差|差旅|费用|交通|住宿|采购|会务|会议", compact))
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_future_travel_application(text: str) -> bool:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
if not compact or "报销" in compact:
|
||||
return False
|
||||
business_signal = re.search(
|
||||
r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收",
|
||||
compact,
|
||||
)
|
||||
route_signal = re.search(
|
||||
fr"(?:去|到|赴|前往)({'|'.join(CITY_NAMES)})",
|
||||
compact,
|
||||
)
|
||||
time_signal = re.search(
|
||||
r"明天|后天|下周|下月|近期|月底|\d{1,2}月\d{1,2}(?:日|号)?|"
|
||||
r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?|[0-9一二两三四五六七八九十]+天",
|
||||
compact,
|
||||
)
|
||||
planned_route_signal = re.search(
|
||||
r"(?:去|到|赴|前往).{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|"
|
||||
r"(?:出差|差旅).{0,24}(?:[0-9一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)",
|
||||
compact,
|
||||
)
|
||||
return bool((business_signal or route_signal) and (time_signal or planned_route_signal))
|
||||
|
||||
def _build_task(
|
||||
self,
|
||||
draft: PlannedTaskDraft,
|
||||
@@ -352,7 +377,12 @@ class StewardPlannerService:
|
||||
@staticmethod
|
||||
def _resolve_task_confidence(segment: str, fields: dict[str, str], task_type: str) -> float:
|
||||
compact = re.sub(r"\s+", "", segment)
|
||||
intent_score = 1.0 if ("申请" in compact if task_type == "expense_application" else "报销" in compact) else 0.45
|
||||
if task_type == "expense_application":
|
||||
intent_score = 1.0 if (
|
||||
"申请" in compact or StewardPlannerService._looks_like_future_travel_application(compact)
|
||||
) else 0.45
|
||||
else:
|
||||
intent_score = 1.0 if "报销" in compact else 0.45
|
||||
time_score = 1.0 if fields.get("time_range") else 0.0
|
||||
location_score = 1.0 if fields.get("location") else 0.2
|
||||
scene_score = 1.0 if fields.get("expense_type") and fields["expense_type"] != "other" else 0.35
|
||||
|
||||
@@ -68,6 +68,31 @@ class EmptyFunctionCallingIntentAgent:
|
||||
return None
|
||||
|
||||
|
||||
class EntertainmentFunctionCallingIntentAgent:
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
return StewardIntentAgentResult(
|
||||
payload={
|
||||
"thinking_events": [],
|
||||
"tasks": [
|
||||
{
|
||||
"task_type": "reimbursement",
|
||||
"title": "业务招待费报销",
|
||||
"summary": "报销昨天业务招待费。",
|
||||
"confidence": 0.9,
|
||||
"ontology_fields": {
|
||||
"time_range": "昨天",
|
||||
"expense_type": "业务招待费",
|
||||
"reason": "业务招待",
|
||||
},
|
||||
"missing_fields": [],
|
||||
}
|
||||
],
|
||||
"attachment_groups": [],
|
||||
},
|
||||
model_call_traces=[],
|
||||
)
|
||||
|
||||
|
||||
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天客户现场沟通的交通费",
|
||||
@@ -98,6 +123,19 @@ def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None
|
||||
assert result.thinking_events[0].stage == "llm_function_call"
|
||||
|
||||
|
||||
def test_steward_planner_normalizes_llm_business_entertainment_expense_type() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="报销昨天业务招待费",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=EntertainmentFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "llm_function_call"
|
||||
assert result.tasks[0].ontology_fields["expense_type"] == "entertainment"
|
||||
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
|
||||
|
||||
|
||||
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天的交通费",
|
||||
@@ -141,6 +179,29 @@ 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_treats_future_travel_without_apply_word_as_application() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="明天出差北京3天,支撑国网仿生产部署,并且报销昨天业务招待费",
|
||||
user_id="u001",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService().build_plan(payload)
|
||||
|
||||
assert [task.task_type for task in result.tasks] == [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
]
|
||||
assert result.tasks[0].assigned_agent == "application_assistant"
|
||||
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-05"
|
||||
assert result.tasks[0].ontology_fields["location"] == "北京"
|
||||
assert result.tasks[0].ontology_fields["expense_type"] == "travel"
|
||||
assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署"
|
||||
assert result.tasks[1].assigned_agent == "reimbursement_assistant"
|
||||
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
|
||||
assert result.tasks[1].ontology_fields["expense_type"] == "entertainment"
|
||||
|
||||
|
||||
def test_steward_planner_outputs_only_canonical_ontology_fields() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天的交通费",
|
||||
@@ -210,5 +271,6 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
|
||||
]
|
||||
|
||||
assert [event["event"] for event in events][:2] == ["thinking", "thinking"]
|
||||
assert events[0]["data"]["stage"] == "stream_start"
|
||||
assert events[-1]["event"] == "plan"
|
||||
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"
|
||||
|
||||
Reference in New Issue
Block a user