feat: 完善差旅票据行程提取与费用明细回填逻辑
增强文档智能识别的票据场景关键词和字段提取能力,优化 会话关联草稿报销单的解析路径,修复费用明细合并和票据 去重边界问题,前端改进报销创建和审批详情交互,补充单 元测试覆盖。
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user