feat: 小财管家意图规划与报销提交编排增强

- 完善管家意图识别、模型计划构建与规划器调度
- 重构差旅报销提交编排器与管家计划流程前端交互
- 优化报销消息项样式与文档中心视图
- 新增小财管家与附件上传风险前置复核设计文档
- 补充管家规划器与文档中心测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 14:25:14 +08:00
parent 1cbf3fee44
commit f60cebadb8
19 changed files with 2337 additions and 196 deletions

View File

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

View File

@@ -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 为准转换为明确日期。"

View File

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

View File

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