feat: 完善差旅票据行程提取与费用明细回填逻辑

增强文档智能识别的票据场景关键词和字段提取能力,优化
会话关联草稿报销单的解析路径,修复费用明细合并和票据
去重边界问题,前端改进报销创建和审批详情交互,补充单
元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 14:24:51 +08:00
parent b183b0bd5e
commit f28d7e6d16
24 changed files with 1565 additions and 433 deletions

View File

@@ -477,9 +477,9 @@ def test_user_agent_model_prompt_supports_contextual_personalization() -> None:
assert '"user_grade": "P5"' in user_prompt
def test_user_agent_guides_generic_expense_request() -> None:
session_factory = build_session_factory()
with session_factory() as db:
def test_user_agent_guides_generic_expense_request() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我要报销",
@@ -506,16 +506,61 @@ def test_user_agent_guides_generic_expense_request() -> None:
"事由说明",
"票据附件",
]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"save_draft",
]
def test_user_agent_guides_implicit_expense_draft_request() -> None:
session_factory = build_session_factory()
with session_factory() as db:
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
]
edit_action = next(
item for item in response.review_payload.confirmation_actions if item.action_type == "edit_review"
)
assert edit_action.label == "选择报销类型"
assert edit_action.emphasis == "primary"
def test_user_agent_asks_for_type_when_trip_context_is_ambiguous() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest-ambiguous-type@example.com",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-ambiguous-type@example.com",
message=message,
ontology=ontology,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["expense_type"].value == ""
assert slot_map["expense_type"].status == "missing"
assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23"
assert slot_map["location"].value == "上海"
assert response.review_payload.can_proceed is False
assert "报销类型" in response.review_payload.missing_slots
assert "选择报销类型" in response.review_payload.body_message
assert "不会重新改判报销类型" in response.review_payload.body_message
edit_action = next(
item for item in response.review_payload.confirmation_actions if item.action_type == "edit_review"
)
assert edit_action.label == "选择报销类型"
assert edit_action.emphasis == "primary"
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
]
def test_user_agent_guides_implicit_expense_draft_request() -> None:
session_factory = build_session_factory()
with session_factory() as db:
today = datetime.now(UTC).date().isoformat()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
@@ -611,6 +656,126 @@ def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
assert "“交通费”" in response.review_payload.intent_summary
def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_context() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-02-20 至 2026-02-23去上海支撑上海电力 服务器部署出差3天"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest-travel-range@example.com",
)
)
initial_response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-travel-range@example.com",
message=message,
ontology=ontology,
tool_payload={"draft_only": True},
)
)
assert initial_response.review_payload is not None
initial_slots = {item.key: item for item in initial_response.review_payload.slot_cards}
assert initial_slots["expense_type"].normalized_value == "travel"
assert initial_slots["time_range"].value == "2026-02-20 至 2026-02-23"
assert initial_slots["location"].value == "上海"
assert "业务发生时间" not in initial_slots["reason"].raw_value
assert not initial_slots["reason"].value.startswith("至 2026-02-23")
followup_context = {
"name": "张三",
"grade": "P4",
"review_action": "link_to_existing_draft",
"review_form_values": {
"expense_type": "差旅费",
"occurred_date": "2026-02-20",
"time_range": "2026-02-20 至 2026-02-23",
"business_time": "2026-02-20 至 2026-02-23",
"business_location": "上海",
"reason": "去上海支撑上海电力服务器部署出差3天",
},
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"attachment_names": ["2月20_武汉-上海.pdf", "2月23_上海-武汉.pdf", "上海酒店发票.pdf"],
"attachment_count": 3,
"ocr_documents": [
{
"filename": "2月20_武汉-上海.pdf",
"document_type": "train_ticket",
"scene_code": "travel",
"scene_label": "差旅票据",
"summary": "铁路电子客票 2026-02-20 武汉-上海 二等座 票价 354 元",
"text": "铁路电子客票 2026-02-20 武汉-上海 二等座 票价 ¥354.00",
"avg_score": 0.95,
"document_fields": [
{"key": "amount", "label": "票价", "value": "354"},
{"key": "route", "label": "行程", "value": "武汉-上海"},
{"key": "date", "label": "日期", "value": "2026-02-20"},
],
"warnings": [],
},
{
"filename": "2月23_上海-武汉.pdf",
"document_type": "train_ticket",
"scene_code": "travel",
"scene_label": "差旅票据",
"summary": "铁路电子客票 2026-02-23 上海-武汉 二等座 票价 354 元",
"text": "铁路电子客票 2026-02-23 上海-武汉 二等座 票价 ¥354.00",
"avg_score": 0.95,
"document_fields": [
{"key": "amount", "label": "票价", "value": "354"},
{"key": "route", "label": "行程", "value": "上海-武汉"},
{"key": "date", "label": "日期", "value": "2026-02-23"},
],
"warnings": [],
},
{
"filename": "上海酒店发票.pdf",
"document_type": "hotel_invoice",
"summary": "上海酒店 住宿 3 晚 金额 1200 元",
"text": "上海酒店 住宿 3 晚 金额 1200 元",
"avg_score": 0.96,
"document_fields": [
{"key": "amount", "label": "金额", "value": "1200"},
{"key": "merchant", "label": "酒店名称", "value": "上海酒店"},
],
"warnings": [],
},
],
}
followup_ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="请把当前上传的票据合并到现有报销草稿中。",
user_id="pytest-travel-range@example.com",
context_json=followup_context,
)
)
followup_response = UserAgentService(db).respond(
UserAgentRequest(
run_id=followup_ontology.run_id,
user_id="pytest-travel-range@example.com",
message="请把当前上传的票据合并到现有报销草稿中。",
ontology=followup_ontology,
context_json=followup_context,
tool_payload={"draft_only": True},
)
)
assert followup_response.review_payload is not None
followup_slots = {item.key: item for item in followup_response.review_payload.slot_cards}
assert followup_slots["expense_type"].value == "差旅费"
assert followup_slots["expense_type"].normalized_value == "travel"
assert followup_slots["time_range"].value == "2026-02-20 至 2026-02-23"
assert followup_slots["location"].value == "上海"
assert followup_slots["reason"].value == "去上海支撑上海电力服务器部署出差3天"
def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None:
session_factory = build_session_factory()
with session_factory() as db:
@@ -1384,6 +1549,7 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
for field in card.fields
]
assert "商户/酒店" not in field_labels
assert "列车出发时间" in field_labels
def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_receipt_is_missing() -> None: