Files
X-Financial/server/tests/test_user_agent_service.py
caoxiaozhu 0c74b4ab4a feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
2026-06-02 16:22:59 +08:00

3188 lines
134 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.core.agent_enums import AgentAssetType
from app.schemas.ontology import OntologyParseRequest
from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief
from app.services.agent_assets import AgentAssetService
from app.services.ontology import SemanticOntologyService
from app.services.user_agent import UserAgentService
from app.services.user_agent_documents import UserAgentDocumentService
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def build_application_user_agent_response(
db: Session,
message: str,
*,
history: list[dict[str, object]] | None = None,
context_overrides: dict[str, object] | None = None,
):
context_json = {
"session_type": "application",
"entry_source": "application",
"attachment_count": 0,
}
if context_overrides:
context_json.update(context_overrides)
if history is not None:
context_json["conversation_history"] = history
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
return UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
def test_user_agent_query_returns_readable_answer_and_actions() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="张三 4 月差旅报销金额是多少",
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="张三 4 月差旅报销金额是多少",
ontology=ontology,
tool_payload={"record_count": 2, "total_amount": 8800.0},
)
)
assert "8800.00" in response.answer
assert len(response.suggested_actions) >= 1
def test_user_agent_returns_readable_query_answer_when_runtime_model_is_skipped(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="张三 4 月差旅报销金额是多少",
user_id="pytest",
)
)
service = UserAgentService(db)
monkeypatch.setattr(service, "_generate_answer_with_model", lambda *args, **kwargs: "这是模型回答")
response = service.respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="张三 4 月差旅报销金额是多少",
ontology=ontology,
tool_payload={"record_count": 2, "total_amount": 8800.0},
)
)
assert "共 2 笔" in response.answer
assert "8800.00" in response.answer
def test_user_agent_sanitizes_model_thinking_blocks() -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = UserAgentService(db)
assert (
service._sanitize_model_answer("<think>内部推理</think>\n最终答复")
== "最终答复"
)
def test_user_agent_rejects_visible_reasoning_drafts() -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = UserAgentService(db)
assert (
service._sanitize_model_answer(
"用户问的是:住宿费怎么算?\n让我分析一下:\n1. 实体识别..."
)
is None
)
def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="住宿费标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
messages = service._build_model_messages(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="住宿费标准是多少?",
ontology=ontology,
tool_payload={"result_type": "knowledge_search", "hits": []},
),
citations=[],
suggested_actions=[],
risk_flags=[],
draft_payload=None,
fallback_answer="",
)
assert "只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence" in messages[0]["content"]
assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"]
assert "不能只依赖排在最前面的片段" in messages[0]["content"]
assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"]
assert "最终答复必须使用 Markdown" in messages[0]["content"]
assert "## 结论" in messages[0]["content"]
assert "如果不能,必须明确说当前知识库没有找到直接依据" in messages[0]["content"]
assert "禁止使用“已命中”“答案整理阶段”“稍后重试”" in messages[0]["content"]
assert "knowledge_evidence_blocks" in messages[0]["content"]
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
def test_user_agent_application_context_uses_application_language() -> None:
session_factory = build_session_factory()
message = (
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
)
context_json = {
"session_type": "application",
"entry_source": "application",
"attachment_count": 0,
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": True},
)
)
assert "费用申请" in response.answer
assert "| 字段 | 内容 |" in response.answer
assert "| 出发时间 | 2026-05-25 |" in response.answer
assert "| 返回时间 | 2026-05-27 |" in response.answer
assert "支持上海国网服务器部署" in response.answer
assert "当前还需要补充:出行方式" in response.answer
assert "请先在下面选择报销场景" not in response.answer
assert response.review_payload is None
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:"
def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None:
session_factory = build_session_factory()
message = "发生时间2026-05-25\n去上海出差3天支撑上海国网服务器部署"
with session_factory() as db:
response = build_application_user_agent_response(db, message)
assert "| 出发时间 | 2026-05-25 |" in response.answer
assert "| 返回时间 | 2026-05-27 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
assert "当前还需要先补充:申请事由" not in response.answer
assert "当前还需要补充:出行方式" in response.answer
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
def test_user_agent_application_normalizes_location_to_region_city() -> None:
session_factory = build_session_factory()
yili_message = (
"发生时间2026-05-25\n"
"地点:伊犁\n"
"事由:支撑新疆电力仿生产部署\n"
"天数3天"
)
beijing_message = (
"发生时间2026-05-25\n"
"地点:北京\n"
"事由:支撑总部系统部署\n"
"天数3天"
)
with session_factory() as db:
yili_response = build_application_user_agent_response(db, yili_message)
beijing_response = build_application_user_agent_response(db, beijing_message)
assert "| 出发时间 | 2026-05-25 |" in yili_response.answer
assert "| 返回时间 | 2026-05-27 |" in yili_response.answer
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
assert "伊犁出差" not in yili_response.answer
assert "| 地点 | 北京市 |" in beijing_response.answer
def test_user_agent_application_uses_selected_time_and_natural_language_fields() -> None:
session_factory = build_session_factory()
message = "出差上海,支撑国网服务器上线部署"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "single",
"start_date": "2026-05-25",
"end_date": "2026-05-25",
"display_value": "2026-05-25",
},
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": True},
)
)
assert "| 出发时间 | 2026-05-25 |" in response.answer
assert "| 返回时间 | 2026-05-25 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
assert "当前还需要补充:出行方式" in response.answer
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
assert response.suggested_actions[0].action_type == "prefill_composer"
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:"
def test_user_agent_application_builds_system_estimate_after_transport_choice() -> None:
session_factory = build_session_factory()
initial_message = (
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
)
with session_factory() as db:
response = build_application_user_agent_response(
db,
"飞机",
history=[{"role": "user", "content": initial_message}],
)
assert "这是费用申请核对结果" in response.answer
assert "| 出行方式 | 飞机 |" in response.answer
assert "| 系统预估费用 |" in response.answer
assert "交通" in response.answer
assert "| 交通费用口径 | 预估交通费用 2,330元 |" in response.answer
assert "2,330元" in response.answer
assert "参考票价" not in response.answer
assert "查询耗时" not in response.answer
assert response.requires_confirmation is True
assert response.suggested_actions == []
def test_user_agent_application_uses_selected_date_range_and_keeps_reason() -> None:
session_factory = build_session_factory()
message = "去上海出差4天支撑国网仿生产环境部署飞机"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 出发时间 | 2026-02-20 |" in response.answer
assert "| 返回时间 | 2026-02-23 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网仿生产环境部署 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 住宿上限/天 | 450元/天 |" in response.answer
assert "| 补贴标准/天 | 100元/天 |" in response.answer
assert "| 规则测算参考 | 交通 2,460元 + 住宿 1,800元 + 补贴 400元 = 4,660元4天 |" in response.answer
assert "| 发生时间 |" not in response.answer
assert "| 事由 | 2026-02-20 至 2026-02-23 |" not in response.answer
def test_user_agent_application_keeps_labeled_reason_in_structured_travel_form() -> None:
session_factory = build_session_factory()
message = (
"发生时间2026-02-20 至 2026-02-23\n"
"地点:上海\n"
"事由:支撑国网仿生产环境建设\n"
"天数4天"
)
context_json = {
"session_type": "application",
"entry_source": "application",
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest-structured-application-reason@example.com",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-structured-application-reason@example.com",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 申请类型 | 差旅费用申请 |" in response.answer
assert "| 出发时间 | 2026-02-20 |" in response.answer
assert "| 返回时间 | 2026-02-23 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网仿生产环境建设 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "申请事由" not in response.answer
assert "当前还需要补充:出行方式" in response.answer
def test_user_agent_application_derives_days_from_selected_date_range() -> None:
session_factory = build_session_factory()
message = "去上海出差,支撑国网仿生产服务器部署,火车"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-02-20 至 2026-02-23",
"location": "上海市",
"reason": "支撑国网仿生产服务器部署",
"days": "待补充",
"transportMode": "火车",
"grade": "P5",
}
},
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 出发时间 | 2026-02-20 |" in response.answer
assert "| 返回时间 | 2026-02-23 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 天数 | 待补充 |" not in response.answer
assert "4天" in response.answer
assert "1天" not in response.answer
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
session_factory = build_session_factory()
with session_factory() as db:
response = build_application_user_agent_response(
db,
"地点:上海\n事由:支撑国网服务器部署\n天数3天",
)
assert "当前还需要补充:出行方式" in response.answer
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
assert response.suggested_actions[0].action_type == "prefill_composer"
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:"
def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
session_factory = build_session_factory()
with session_factory() as db:
response = build_application_user_agent_response(
db,
"去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元",
context_overrides={
"client_now_iso": "2026-05-28T16:30:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
assert "这是费用申请核对结果" in response.answer
assert "| 出发时间 | 2026-05-29 |" in response.answer
assert "| 返回时间 | 2026-05-31 |" in response.answer
assert response.requires_confirmation is True
def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
session_factory = build_session_factory()
initial_message = (
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
)
with session_factory() as db:
response = build_application_user_agent_response(
db,
"预计总费用12000元",
context_overrides={
"name": "张三",
"department_name": "交付部",
"position": "实施经理",
"manager_name": "李文静",
},
history=[
{"role": "user", "content": initial_message},
{"role": "user", "content": "飞机"},
],
)
assert "这是费用申请核对结果" in response.answer
assert "| 字段 | 内容 |" in response.answer
assert "| 姓名 | 张三 |" in response.answer
assert "| 部门 | 交付部 |" in response.answer
assert "| 岗位 | 实施经理 |" in response.answer
assert "| 直属领导 | 李文静 |" in response.answer
assert "| 事由 | 支持上海国网服务器部署 |" in response.answer
assert "| 出行方式 | 飞机 |" in response.answer
assert "| 系统预估费用 | 12000元 |" in response.answer
assert "请核对上述信息无误" in response.answer
assert "[确认](#application-submit)" in response.answer
assert response.requires_confirmation is True
assert response.suggested_actions == []
def test_user_agent_application_preview_uses_employee_grade_profile() -> None:
session_factory = build_session_factory()
initial_message = (
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
)
with session_factory() as db:
employee = Employee(
employee_no="APP-GRADE-001",
name="李文静",
email="pytest-application-grade@example.com",
position="解决方案顾问",
grade="P5",
)
db.add(employee)
db.commit()
response = build_application_user_agent_response(
db,
"预计总费用12000元",
context_overrides={
"name": "李文静",
"manager_name": "王强",
},
history=[
{"role": "user", "content": initial_message},
{"role": "user", "content": "飞机"},
],
)
assert "这是费用申请核对结果" in response.answer
assert "| 姓名 | 李文静 |" in response.answer
assert "| 岗位 | 解决方案顾问 |" in response.answer
assert "| 职级 | P5 |" in response.answer
assert "| 职级 | 待补充 |" not in response.answer
def test_user_agent_application_submit_enters_leader_review() -> None:
session_factory = build_session_factory()
initial_message = (
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
)
preview_answer = (
"这是费用申请核对结果,请核对:\n"
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 出发时间 | 2026-05-25 |\n"
"| 返回时间 | 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
"| 出行方式 | 飞机 |\n"
"| 系统预估费用 | 12000元 |\n\n"
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
)
with session_factory() as db:
response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=[
{"role": "user", "content": initial_message},
{"role": "user", "content": "飞机"},
{"role": "user", "content": "预计总费用12000元"},
{"role": "assistant", "content": preview_answer},
],
)
assert "申请单据已生成,并已进入审批流程" in response.answer
assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in response.answer
assert "下方是简要单据信息" in response.answer
assert "申请信息:" not in response.answer
assert re.search(r"AP-\d{14}-[A-HJ-NP-Z2-9]{8}", response.answer)
assert response.suggested_actions == []
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert claim.expense_type == "travel_application"
assert claim.amount == Decimal("12000.00")
assert claim.employee_name == "pytest"
def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
session_factory = build_session_factory()
initial_message = (
"出发时间2026-05-25\n"
"返回时间2026-05-27\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天\n"
"出行方式:飞机\n"
"预计总费用12000元"
)
preview_answer = (
"这是费用申请核对结果,请核对:\n"
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 出发时间 | 2026-05-25 |\n"
"| 返回时间 | 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
"| 出行方式 | 飞机 |\n"
"| 系统预估费用 | 12000元 |\n\n"
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
)
history = [
{"role": "user", "content": initial_message},
{"role": "assistant", "content": preview_answer},
]
with session_factory() as db:
first_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=history,
)
first_claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
second_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=history,
)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "申请单据已生成" in first_response.answer
assert "已存在申请单" in second_response.answer
assert "系统没有重复创建" in second_response.answer
assert first_claim.claim_no in second_response.answer
assert second_response.draft_payload is None
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="住宿费标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_knowledge_search_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="住宿费标准是多少?",
ontology=ontology,
context_json={"name": "张三"},
tool_payload={
"result_type": "knowledge_search",
"hits": [{"title": "差旅费制度", "content": "住宿费标准正文"}],
},
),
citations=[],
)
assert answer.startswith("张三,您好。")
assert "## 结论" in answer
assert "## 依据" in answer
assert "## 说明" in answer
assert "我先根据当前制度依据给出可以确认的部分" in answer
assert "已命中" not in answer
assert "答案整理阶段本轮没有及时返回" not in answer
assert "《差旅费制度》" in answer
def test_user_agent_knowledge_answer_generation_uses_fast_timeouts(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="住宿费标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
captured: dict[str, object] = {}
def fake_complete(messages, **kwargs):
captured["messages"] = messages
captured.update(kwargs)
return "测试回答"
monkeypatch.setattr(service.runtime_chat_service, "complete", fake_complete)
answer = service._generate_answer_with_model(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="住宿费标准是多少?",
ontology=ontology,
tool_payload={"result_type": "knowledge_search", "hits": []},
),
citations=[],
suggested_actions=[],
risk_flags=[],
draft_payload=None,
fallback_answer="",
)
assert answer == "测试回答"
assert captured["timeout_seconds"] == 30
assert captured["slot_timeouts"] == {"main": 20, "backup": 30}
assert captured["max_attempts"] == 1
def test_user_agent_prefers_structured_table_hit_for_standard_query() -> None:
selected = UserAgentService._select_knowledge_model_hits(
{
"hits": [
{"content": "raw hit 1"},
{"content": "raw hit 2"},
{"content": "# 问答线索补充\n\n- 第二章 报销时限:费用发生后 30 日内提交申请。"},
{"content": "# 结构化表格补充\n\n| 项目 | 餐补 |\n| 其他地区 | 55 |"},
]
},
question="餐补标准是多少?",
)
assert selected[0]["content"].startswith("# 结构化表格补充")
assert any(item["content"].startswith("# 结构化表格补充") for item in selected[:2])
def test_user_agent_prefers_relevant_raw_hit_over_generic_appendix() -> None:
selected = UserAgentService._select_knowledge_model_hits(
{
"hits": [
{"content": "# 章节导航\n\n- 第一章 总则\n- 第二章 职责分工"},
{"content": "# 问答线索补充\n\n- 第二章 职责分工:计划财务部负责财务审核。"},
{"content": "一般性说明文字,没有探亲差旅归口信息。"},
{"content": "附表3支出归口管理部门与归口业务范围\n组织人事部:探亲差旅、条件艰苦及安全风险较高区域补助等支出。"},
]
},
question="探亲差旅归哪个部门管理?",
)
assert "组织人事部" in selected[0]["content"]
def test_user_agent_model_hit_selection_keeps_later_relevant_hits() -> None:
selected = UserAgentService._select_knowledge_model_hits(
{
"hits": [
{"content": "一般说明一"},
{"content": "一般说明二"},
{"content": "一般说明三"},
{"content": "一般说明四"},
{"content": "一般说明五"},
{"content": "一般说明六"},
{"content": "一般说明七"},
{
"content": (
"# 问答线索补充\n\n"
"- 第二章 报销时限:差旅费应在行程结束三个月内提交;逾期不予报销出差补贴。"
)
},
]
},
question="差旅费报销时限是多少?",
)
assert "三个月内提交" in selected[0]["content"]
def test_user_agent_knowledge_terms_keep_accounting_subject_in_long_query() -> None:
terms = UserAgentService._extract_knowledge_query_terms(
"远光软件财务基础知识手册里的常用会计科目是什么?"
)
assert "常用会计科目" in terms
assert "会计科目" in terms
def test_user_agent_knowledge_answer_uses_model_after_retrieval(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="报销时限是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
captured: dict[str, object] = {}
def fake_generate_answer(payload, **kwargs):
captured["payload"] = payload
captured.update(kwargs)
return "## 结论\n\n员工应在费用发生后 30 日内提交报销申请。"
monkeypatch.setattr(service, "_generate_answer_with_model", fake_generate_answer)
response = service.respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="报销时限是多少?",
ontology=ontology,
context_json={
"name": "张三",
"session_type": "knowledge",
"user_input_text": "报销时限是多少?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "费用报销制度",
"content": (
"# 问答线索补充\n\n"
"- 第二章 报销时限:员工应在费用发生后 30 日内提交报销申请。\n"
"- 第二章 报销时限:超过 30 日需补充审批说明。"
),
},
],
},
)
)
assert captured["payload"].ontology.scenario == "knowledge"
assert "费用报销制度" in captured["fallback_answer"]
assert "## 依据" in captured["fallback_answer"]
assert response.answer.startswith("## 结论")
assert "30 日内提交报销申请" in response.answer
assert "答案整理阶段本轮没有及时返回" not in response.answer
def test_user_agent_fast_knowledge_answer_focuses_inline_section_items() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="主要税种介绍",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="主要税种介绍",
ontology=ontology,
context_json={
"session_type": "knowledge",
"user_input_text": "主要税种介绍",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "财务基础知识",
"content": (
"资产类 银行存款 企业存放在银行的款项 负债类 应付账款 "
"因购买商品或接受劳务应付的款项 负债类 应交税费 应缴纳的各种税费 "
"第二部分 税务基础知识 三、主要税种介绍 "
"增值税公司为一般纳税人软件服务适用6%税率软件产品销售适用13%税率。 "
"企业所得税税率为25%高新技术企业享受15%优惠税率。 "
"(三)个人所得税:员工工资薪金由公司代扣代缴。 "
"(四)印花税:购销合同、账簿等按规定缴纳。"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert "主要税种介绍包括:增值税、企业所得税、个人所得税、印花税" in answer
assert "软件服务适用6%税率" in answer
assert "软件产品销售适用13%税率" in answer
assert "高新技术企业享受15%优惠税率" in answer
assert "员工工资薪金由公司代扣代缴" in answer
assert "购销合同、账簿等按规定缴纳" in answer
assert "应付账款" not in answer
assert "银行存款" not in answer
def test_user_agent_fast_knowledge_answer_summarizes_financial_statements() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="三大财务报表 是什么?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="三大财务报表 是什么?",
ontology=ontology,
context_json={
"session_type": "knowledge",
"user_input_text": "三大财务报表 是什么?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "财务基础知识",
"content": (
"第三部分 财务报表解读 四、三大财务报表 "
"(一)资产负债表:反映企业在某一特定日期的财务状况。 "
"(二)利润表:反映企业在一定期间的经营成果。 "
"(三)现金流量表:反映企业在一定期间现金和现金等价物的流入和流出。"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert "三大财务报表包括:资产负债表、利润表、现金流量表" in answer
assert "资产负债表:反映企业在某一特定日期的财务状况" in answer
assert "利润表:反映企业在一定期间的经营成果" in answer
assert "现金流量表:反映企业在一定期间现金和现金等价物的流入和流出" in answer
assert "第三部分 财务报表解读" not in answer
def test_user_agent_fast_knowledge_answer_expands_broad_accounting_table() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="常用会计科目是什么?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="常用会计科目是什么?",
ontology=ontology,
context_json={
"session_type": "knowledge",
"user_input_text": "常用会计科目是什么?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "财务基础知识",
"content": (
"二、常用会计科目\n\n"
"| 科目类别 | 科目名称 | 说明 |\n"
"| --- | --- | --- |\n"
"| 资产类 | 库存现金 | 公司持有的现金 |\n"
"| 资产类 | 银行存款 | 存放在银行的资金 |\n"
"| 资产类 | 应收账款 | 因销售商品或提供劳务应收的款项 |\n"
"| 资产类 | 固定资产 | 使用年限超过一年的有形资产 |\n"
"| 负债类 | 应付账款 | 因购买商品或接受劳务应付的款项 |\n"
"| 负债类 | 应交税费 | 应缴纳的各种税费 |\n"
"| 负债类 | 应付职工薪酬 | 应付给职工的工资、福利等 |\n"
"| 损益类 | 主营业务收入 | 主要经营业务产生的收入 |\n"
"| 损益类 | 管理费用 | 为管理生产经营发生的费用 |\n"
"| 损益类 | 销售费用 | 为销售产品发生的费用 |\n"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert "| 科目类别 | 科目名称 | 说明 |" in answer
assert "| 资产类 | 库存现金 | 公司持有的现金 |" in answer
assert "| 负债类 | 应付职工薪酬 | 应付给职工的工资、福利等 |" in answer
assert "| 损益类 | 销售费用 | 为销售产品发生的费用 |" in answer
def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="餐补标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="餐补标准是多少?",
ontology=ontology,
context_json={
"session_type": "knowledge",
"user_input_text": "餐补标准是多少?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "费用报销制度",
"content": (
"# 结构化表格补充\n\n"
"## 表3 出差补贴标准\n\n"
"| 项目 | 港澳台 | 其他地区 | 国外 |\n"
"| --- | --- | --- | --- |\n"
"| 餐补 | 75 | 55 | 140 |\n"
"| 住宿补贴 | 35 | 35 | 35 |\n"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
assert "| 餐补 | 75 | 55 | 140 |" in answer
assert "餐补的标准为" in answer
assert "## 结论" in answer
assert "## 依据" in answer
def test_user_agent_fast_knowledge_answer_uses_user_grade_for_table_row() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我的住宿费标准是多少?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我的住宿费标准是多少?",
ontology=ontology,
context_json={
"name": "张三",
"grade": "P5",
"position": "实施经理",
"session_type": "knowledge",
"user_input_text": "我的住宿费标准是多少?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "费用报销制度",
"content": (
"# 结构化表格补充\n\n"
"## 国内住宿限额标准\n\n"
"| 职级 | 直辖市/特区/港澳台 | 省会城市 | 其他地区 |\n"
"| --- | --- | --- | --- |\n"
"| 公司领导P8及以上 | 800 | 500 | 400 |\n"
"| 高层经理P7 | 700 | 450 | 400 |\n"
"| 中层经理、基层经理P4P6、外聘专家 | 600 | 400 | 350 |\n"
"| 其他员工 | 500 | 350 | 300 |\n"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert answer.startswith("张三,您好。")
assert "中层经理、基层经理P4P6、外聘专家的标准为" in answer
assert "| 中层经理、基层经理P4P6、外聘专家 | 600 | 400 | 350 |" in answer
assert "| 公司领导P8及以上 | 800 | 500 | 400 |" not in answer
assert "| 高层经理P7 | 700 | 450 | 400 |" not in answer
def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="前往北京出差的报销标准是什么?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="前往北京出差的报销标准是什么?",
ontology=ontology,
context_json={
"session_type": "knowledge",
"user_input_text": "前往北京出差的报销标准是什么?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "费用报销制度",
"content": (
"# 结构化表格补充\n\n"
"## 表3 出差补贴标准\n\n"
"| 项目 | 港澳台 | 直辖市/特区/西藏 | 其他地区 |\n"
"| --- | --- | --- | --- |\n"
"| 餐补 | 75 | 65 | 55 |\n"
"| 基本补贴 | 35 | 35 | 35 |\n"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
assert "## 说明" in answer
assert "## 依据" in answer
def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="出差记录链条中断时,要提供哪些业务佐证材料?",
user_id="pytest",
context_json={"session_type": "knowledge"},
)
)
service = UserAgentService(db)
answer = service._build_fast_knowledge_answer(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="出差记录链条中断时,要提供哪些业务佐证材料?",
ontology=ontology,
context_json={
"session_type": "knowledge",
"user_input_text": "出差记录链条中断时,要提供哪些业务佐证材料?",
},
tool_payload={
"result_type": "knowledge_search",
"hits": [
{
"title": "费用报销制度",
"content": (
"第十三条 差旅费\n\n"
"2出差记录链条中断时应提供业务佐证材料\n"
"① 登机牌、高速道路通行记录、其他道路通行记录、租车记录等。\n"
"② 支付记录。\n"
"③ 出差审批邮件、短信、微信等。"
),
}
],
},
),
citations=[],
)
assert answer is not None
assert "## 结论" in answer
assert "登机牌、高速道路通行记录" in answer
assert "支付记录" in answer
assert "出差审批邮件、短信、微信等" in answer
assert "3" not in answer
assert "## 依据" in answer
def test_user_agent_model_prompt_supports_contextual_personalization() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我能坐什么舱位?",
user_id="pytest",
)
)
service = UserAgentService(db)
messages = service._build_model_messages(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我能坐什么舱位?",
ontology=ontology,
context_json={
"name": "张三",
"position": "财务分析师",
"grade": "P5",
"role": "财务人员",
"role_codes": ["finance"],
},
tool_payload={},
),
citations=[],
suggested_actions=[],
risk_flags=[],
draft_payload=None,
fallback_answer="",
)
system_prompt = messages[0]["content"]
user_prompt = messages[1]["content"]
assert "context.user_grade" in system_prompt
assert "conversation_history" in user_prompt
assert '"user_name": "张三"' in user_prompt
assert '"user_position": "财务分析师"' in user_prompt
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:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我要报销",
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我要报销",
ontology=ontology,
tool_payload={"record_count": 9, "total_amount": 12345.0},
)
)
assert response.review_payload is None
assert response.draft_payload is None
assert "请先在下面选择报销场景" in response.answer
assert [item.action_type for item in response.suggested_actions] == [
"select_expense_type",
"select_expense_type",
"select_expense_type",
"select_expense_type",
"select_expense_type",
"select_expense_type",
]
assert [item.label for item in response.suggested_actions[:3]] == ["差旅费", "交通费", "住宿费"]
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 None
assert response.draft_payload is None
assert "请先在下面选择报销场景" in response.answer
assert "避免系统先入为主" in response.answer
assert [item.label for item in response.suggested_actions] == [
"差旅费",
"交通费",
"住宿费",
"业务招待费",
"会务费",
"办公用品费",
"培训费",
"通讯费",
"福利费",
"其他费用",
]
assert response.suggested_actions[0].payload["original_message"] == message
def test_user_agent_continues_identification_after_expense_type_selection() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=f"{message}\n用户选择报销场景:差旅费",
user_id="pytest-selected-type@example.com",
context_json={
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
},
"review_form_values": {
"expense_type": "差旅费",
},
"user_input_text": message,
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-selected-type@example.com",
message=f"{message}\n用户选择报销场景:差旅费",
ontology=ontology,
context_json={
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
},
"review_form_values": {
"expense_type": "差旅费",
},
"user_input_text": message,
},
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"].normalized_value == "travel"
assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23"
assert slot_map["location"].value == "上海"
assert "报销测算参考:" in response.answer
assert "| 项目 | 测算口径 | 金额 |" in response.answer
assert "| 住宿标准 |" in response.answer
assert "| 出差补贴 |" in response.answer
assert "| 参考合计 |" in response.answer
def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "请生成差旅费报销草稿"
context_json = {
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202606-001",
},
"review_form_values": {
"expense_type": "差旅费",
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202606-001",
"application_reason": "支撑国网仿生产环境部署",
"application_location": "北京",
"application_amount": "3000元",
"application_business_time": "2026-06-01 至 2026-06-03",
"application_transport_mode": "火车",
},
"user_input_text": message,
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest-linked-application-review@example.com",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-linked-application-review@example.com",
message=message,
ontology=ontology,
context_json=context_json,
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["reason"].value == "支撑国网仿生产环境部署"
assert slot_map["location"].value == "北京"
assert slot_map["amount"].value == "3000.00元"
assert slot_map["time_range"].value == "2026-06-01 至 2026-06-03"
assert UserAgentService._resolve_review_form_values(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-linked-application-review@example.com",
message=message,
ontology=ontology,
context_json=context_json,
)
)["transport_mode"] == "火车"
assert "事由说明" not in response.review_payload.missing_slots
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(
query="我今天去客户现场招待了客户花销了1000元",
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我今天去客户现场招待了客户花销了1000元",
ontology=ontology,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
assert response.answer == response.review_payload.body_message
assert response.review_payload.intent_summary.startswith("识别到您希望报销一笔“业务招待费”费用:")
assert "基础信息识别结果:" in response.review_payload.intent_summary
assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["expense_type"].value == "业务招待费"
assert slot_map["time_range"].value == today
assert slot_map["time_range"].raw_value == "今天"
assert slot_map["location"].value == "客户现场"
assert slot_map["amount"].value == "1000.00元"
def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我前天请客户吃饭花了200元",
user_id="pytest",
context_json={
"client_now_iso": "2026-05-12T16:30:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我前天请客户吃饭花了200元",
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["time_range"].raw_value == "前天"
assert slot_map["time_range"].value == "2026-05-11"
assert "时间2026-05-11" in response.review_payload.intent_summary
def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
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"].normalized_value == "transport"
assert slot_map["time_range"].value == "2026-03-04"
assert slot_map["reason"].value == "送客户去林萃小区办事,请报销乘车费用"
assert "业务发生时间" not in slot_map["reason"].raw_value
assert "“交通费”" in response.review_payload.intent_summary
def test_user_agent_keeps_taxi_ticket_for_customer_dropoff_as_transport_expense() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "送客户去机场,报销的士票"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
tool_payload={"draft_only": True},
)
)
assert ontology.intent == "draft"
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"].normalized_value == "transport"
assert slot_map["reason"].value == "送客户去机场,报销的士票"
assert "业务招待费" not in response.review_payload.intent_summary
assert "客户名称" not in response.review_payload.missing_slots
assert "参与人员" not in response.review_payload.missing_slots
edit_field_keys = {item.key for item in response.review_payload.edit_fields}
assert "merchant_name" not in edit_field_keys
assert "participants" not in edit_field_keys
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天"
followup_risk_text = "\n".join(
f"{item.title}\n{item.content}\n{item.detail}"
for item in followup_response.review_payload.risk_briefs
)
assert "票据城市与申报目的地不一致" not in followup_risk_text
assert "差旅目的地与票据城市不一致" not in followup_risk_text
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:
message = "业务发生时间:2026-03-04送客户去林萃小区办事打车花了32元请报销乘车费用"
context_json = {
"name": "赵六",
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
"document_fields": [
{"key": "amount", "label": "支付金额", "value": "32.00"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={
"draft_only": True,
"claim_id": "claim-1",
"claim_no": "EXP-202603-001",
"status": "draft",
"message": (
"已创建报销草稿 EXP-202603-001当前状态为 draft。"
"你可以继续补充费用明细、客户单位和票据附件。"
),
},
)
)
assert response.review_payload is not None
assert response.review_payload.can_proceed is True
assert "客户名称" not in response.review_payload.missing_slots
assert "参与人员" not in response.review_payload.missing_slots
assert "票据附件" not in response.review_payload.missing_slots
risk_text = "\n".join(
f"{item.title}\n{item.content}" for item in response.review_payload.risk_briefs
)
assert "AI预审未通过" not in risk_text
assert "已创建报销草稿" not in risk_text
def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。",
user_id="pytest",
context_json={
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 订单金额 32 元",
"text": "滴滴出行 订单金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
}
],
"user_input_text": "",
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。\n附件名称didi-trip.png",
ontology=ontology,
context_json={
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 订单金额 32 元",
"text": "滴滴出行 订单金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
}
],
"user_input_text": "",
},
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["reason"].value == "交通出行"
assert slot_map["reason"].status == "inferred"
def test_user_agent_transport_flow_infers_reason_and_does_not_require_location_or_merchant() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了交通票据,帮我生成报销草稿",
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我上传了交通票据,帮我生成报销草稿",
ontology=ontology,
context_json={
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32 元",
"text": "滴滴出行 支付金额 32 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
"scene_label": "交通票据",
}
],
},
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}
document_card = response.review_payload.document_cards[0]
assert document_card.scene_label == "出租车/网约车票据"
assert document_card.suggested_expense_type == "transport"
assert slot_map["reason"].value == "交通出行"
assert slot_map["reason"].status == "inferred"
assert "酒店/商户" not in response.review_payload.missing_slots
assert "地点" not in response.review_payload.missing_slots
assert "事由说明" not in response.review_payload.missing_slots
def test_user_agent_risk_response_includes_rule_citations() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="检查重复报销风险",
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="检查重复报销风险",
ontology=ontology,
tool_payload={"risk_flags": ["duplicate_expense"]},
)
)
assert response.risk_flags == ["duplicate_expense"]
assert any(item.source_type == "rule" for item in response.citations)
assert "duplicate_expense" in response.answer
def test_user_agent_draft_returns_structured_payload() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="帮我生成张三4月差旅报销草稿",
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="帮我生成张三4月差旅报销草稿",
ontology=ontology,
tool_payload={"draft_only": True},
)
)
assert response.draft_payload is not None
assert response.draft_payload.confirmation_required is True
assert response.review_payload is not None
assert response.review_payload.can_proceed is False
assert response.review_payload.missing_slots == ["金额", "事由说明", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
assert response.answer == response.review_payload.body_message
def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="请按当前识别信息保存报销草稿",
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="请按当前识别信息保存报销草稿",
ontology=ontology,
context_json={"review_action": "save_draft"},
tool_payload={
"draft_limit_reached": True,
"message": "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。",
"status": "blocked",
},
)
)
assert (
response.answer
== "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
)
def test_user_agent_save_draft_answer_guides_followup_to_existing_draft() -> None:
session_factory = build_session_factory()
with session_factory() as db:
context_json = {"review_action": "save_draft"}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="请按当前识别信息保存报销草稿",
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="请按当前识别信息保存报销草稿",
ontology=ontology,
context_json=context_json,
tool_payload={
"claim_id": "claim-1",
"claim_no": "BX202605220001",
"status": "draft",
"approval_stage": "待提交",
},
)
)
assert response.draft_payload is not None
assert response.draft_payload.claim_no == "BX202605220001"
assert "已按您当前确认的信息保存为草稿 BX202605220001" in response.answer
assert "系统已完成草稿规则校验" in response.answer
assert "继续在当前对话上传" in response.answer
assert "请关联这张草稿" not in response.answer
assert "继续保存草稿" not in response.answer
def test_user_agent_returns_submitted_draft_payload_for_review_next_step() -> None:
session_factory = build_session_factory()
with session_factory() as db:
context_json = {
"review_action": "next_step",
"draft_claim_id": "claim-1",
"attachment_count": 1,
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我已核对右侧识别结果,请进入下一步。",
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我已核对右侧识别结果,请进入下一步。",
ontology=ontology,
context_json=context_json,
tool_payload={
"claim_id": "claim-1",
"claim_no": "BX202605200001",
"status": "submitted",
"approval_stage": "直属领导审批",
},
)
)
assert response.draft_payload is not None
assert response.draft_payload.status == "submitted"
assert response.draft_payload.confirmation_required is False
assert "当前节点为 直属领导审批" in response.answer
def test_user_agent_document_service_normalizes_ocr_fields_and_scene() -> None:
document_service = UserAgentDocumentService()
fields = document_service.extract_document_fields(
{
"filename": "北京南站火车票.png",
"document_type": "train_ticket",
"scene_code": "travel",
"summary": "电子发票 2026-03-04 广州南至北京南 二等座 票价 ¥560.00 中国铁路",
"text": "电子发票 2026-03-04 广州南至北京南 二等座 票价 ¥560.00 中国铁路",
"document_fields": [
{"key": "amount", "label": "票价", "value": "¥560.00"},
{"key": "date", "label": "业务发生时间", "value": "2026-03-04"},
{"key": "merchant_name", "label": "商户", "value": "中国铁路"},
],
}
)
classified = document_service.classify_document(
{"filename": "客户餐饮发票.jpg", "summary": "餐饮发票 客户招待 金额 320 元"},
expense_type_code="entertainment",
has_customer=True,
)
assert fields["金额"] == "560.00元"
assert fields["列车出发时间"] == "2026-03-04"
assert "商户/酒店" not in fields
assert document_service.extract_amount_text_from_value("滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678") == "13.40元"
taxi_classified = document_service.classify_document({"filename": "行程单_的士票.jpg", "summary": "的士 车费 48 元"})
assert taxi_classified["document_type"] == "taxi_receipt"
assert taxi_classified["expense_type"] == "transport"
assert taxi_classified["scene_label"] == "出租车/网约车票据"
ship_classified = document_service.classify_document({"filename": "轮船票.jpg", "summary": "轮船 船票 金额 180 元"})
assert ship_classified["document_type"] == "ship_ticket"
assert ship_classified["scene_label"] == "轮船票"
assert classified["document_type"] == "meal_receipt"
assert classified["expense_type"] == "meal"
assert document_service.infer_expense_type_from_documents(
[{"filename": "客户餐饮发票.jpg", "summary": "餐饮发票 客户招待 金额 320 元"}],
expense_type_code="entertainment",
has_customer=True,
) == "业务招待费"
def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None:
session_factory = build_session_factory()
with session_factory() as db:
yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我昨天去上海出差还请客户A吃饭帮我生成报销草稿",
user_id="pytest",
context_json={
"attachment_names": ["机票行程单.png", "餐饮发票.jpg"],
"attachment_count": 2,
"ocr_documents": [
{
"filename": "机票行程单.png",
"summary": "机票行程单 上海-北京 金额 680 元",
"text": "机票行程单 上海-北京 金额 680 元",
"avg_score": 0.93,
"warnings": [],
},
{
"filename": "餐饮发票.jpg",
"summary": "餐饮发票 客户招待 金额 320 元",
"text": "餐饮发票 客户招待 金额 320 元",
"avg_score": 0.91,
"warnings": [],
},
],
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我昨天去上海出差还请客户A吃饭帮我生成报销草稿",
ontology=ontology,
context_json={
"name": "张三",
"attachment_names": ["机票行程单.png", "餐饮发票.jpg"],
"attachment_count": 2,
"ocr_documents": [
{
"filename": "机票行程单.png",
"summary": "机票行程单 上海-北京 金额 680 元",
"text": "机票行程单 上海-北京 金额 680 元",
"avg_score": 0.93,
"warnings": [],
},
{
"filename": "餐饮发票.jpg",
"summary": "餐饮发票 客户招待 金额 320 元",
"text": "餐饮发票 客户招待 金额 320 元",
"avg_score": 0.91,
"warnings": [],
},
],
},
tool_payload={"draft_only": True, "claim_no": "EXP-202605-009", "status": "draft"},
)
)
assert response.review_payload is not None
assert len(response.review_payload.document_cards) == 2
assert len(response.review_payload.claim_groups) == 2
assert response.review_payload.missing_slots == ["参与人员", "酒店的报销票据待上传(必须)"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
assert any(item.scene_label == "餐饮发票" for item in response.review_payload.document_cards)
assert all(item.scene_label != "业务招待费" for item in response.review_payload.document_cards)
assert any(item.scene_label == "业务招待费" for item in response.review_payload.claim_groups)
assert f"时间:{yesterday}" in response.review_payload.intent_summary
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["time_range"].value == yesterday
assert slot_map["time_range"].raw_value == "昨天"
def test_user_agent_sums_multi_document_amounts_from_synonym_fields() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了两张交通票据,帮我生成报销草稿",
user_id="pytest",
context_json={
"attachment_names": ["滴滴行程单.png", "停车票.jpg"],
"attachment_count": 2,
"ocr_documents": [
{
"filename": "滴滴行程单.png",
"summary": "滴滴出行电子行程单",
"text": "滴滴出行 订单金额 ¥32.50",
"avg_score": 0.94,
"document_fields": [
{"key": "amount", "label": "支付金额", "value": "32.50"},
],
"warnings": [],
},
{
"filename": "停车票.jpg",
"summary": "停车票",
"text": "停车费 合计 18 元",
"avg_score": 0.92,
"document_fields": [
{"key": "total_amount", "label": "合计金额", "value": "18"},
],
"warnings": [],
},
],
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我上传了两张交通票据,帮我生成报销草稿",
ontology=ontology,
context_json={
"attachment_names": ["滴滴行程单.png", "停车票.jpg"],
"attachment_count": 2,
"ocr_documents": [
{
"filename": "滴滴行程单.png",
"summary": "滴滴出行电子行程单",
"text": "滴滴出行 订单金额 ¥32.50",
"avg_score": 0.94,
"document_fields": [
{"key": "amount", "label": "支付金额", "value": "32.50"},
],
"warnings": [],
},
{
"filename": "停车票.jpg",
"summary": "停车票",
"text": "停车费 合计 18 元",
"avg_score": 0.92,
"document_fields": [
{"key": "total_amount", "label": "合计金额", "value": "18"},
],
"warnings": [],
},
],
},
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["amount"].value == "50.50元"
document_field_labels = [
field.label
for card in response.review_payload.document_cards
for field in card.fields
]
assert "金额" in document_field_labels
def test_user_agent_prefers_larger_decimal_amount_from_ocr_text_candidates() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了打车票据,帮我生成报销草稿",
user_id="pytest",
context_json={
"attachment_names": ["滴滴行程单.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "滴滴行程单.png",
"summary": "滴滴出行电子行程单",
"text": "滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678",
"avg_score": 0.94,
"warnings": [],
},
],
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我上传了打车票据,帮我生成报销草稿",
ontology=ontology,
context_json={
"attachment_names": ["滴滴行程单.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "滴滴行程单.png",
"summary": "滴滴出行电子行程单",
"text": "滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678",
"avg_score": 0.94,
"warnings": [],
},
],
},
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["amount"].value == "13.40元"
def test_user_agent_review_payload_keeps_document_preview_data() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了打车票据,帮我生成报销草稿",
user_id="pytest",
context_json={
"attachment_names": ["滴滴行程单.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "滴滴行程单.png",
"summary": "滴滴出行电子行程单",
"text": "滴滴出行 实付 13.4 元",
"avg_score": 0.94,
"preview_kind": "image",
"preview_data_url": "data:image/png;base64,ZmFrZQ==",
"warnings": [],
},
],
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我上传了打车票据,帮我生成报销草稿",
ontology=ontology,
context_json={
"attachment_names": ["滴滴行程单.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "滴滴行程单.png",
"summary": "滴滴出行电子行程单",
"text": "滴滴出行 实付 13.4 元",
"avg_score": 0.94,
"preview_kind": "image",
"preview_data_url": "data:image/png;base64,ZmFrZQ==",
"warnings": [],
},
],
},
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
assert response.review_payload.document_cards[0].preview_kind == "image"
assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,")
def test_user_agent_review_payload_prechecks_travel_receipts_against_policy_and_hides_old_briefs(
monkeypatch,
) -> None:
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
employee_no="E-TRAVEL-001",
name="张三",
email="pytest-travel@example.com",
position="实施顾问",
grade="P4",
)
db.add(employee)
db.flush()
db.add(
ExpenseClaim(
claim_no="EXP-HISTORY-001",
employee_id=employee.id,
employee_name=employee.name,
department_name="交付部",
expense_type="travel",
reason="历史差旅记录",
location="北京",
amount=Decimal("680.00"),
invoice_count=1,
occurred_at=datetime.now(UTC) - timedelta(days=7),
status="draft",
risk_flags_json=[{"label": "历史风险"}],
)
)
db.commit()
query = "我去北京出差住酒店,上传了北京酒店发票,帮我生成差旅费报销草稿"
context = {
"name": "张三",
"attachment_names": ["北京酒店发票.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "北京酒店发票.png",
"document_type": "hotel_invoice",
"summary": "北京中心酒店 住宿 1 晚 金额 680 元",
"text": "北京中心酒店 住宿 1 晚 金额 680 元",
"avg_score": 0.96,
"document_fields": [
{"key": "amount", "label": "金额", "value": "680"},
{"key": "merchant", "label": "酒店", "value": "北京中心酒店"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-travel@example.com",
context_json=context,
)
)
service = UserAgentService(db)
monkeypatch.setattr(
service,
"_build_citations",
lambda payload: [
UserAgentCitation(
source_type="rule",
code="rule.expense.travel_risk_control_standard",
title="差旅报销风险管控制度",
version="v1.1.0",
excerpt="住宿费按职级和城市分级限额执行。",
)
],
)
response = service.respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-travel@example.com",
message=query,
ontology=ontology,
context_json=context,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
titles = [item.title for item in response.review_payload.risk_briefs]
assert "历史报销画像" not in titles
assert "制度注意事项" not in titles
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
combined = f"{hotel_brief.title}\n{hotel_brief.content}\n{hotel_brief.detail}\n{hotel_brief.suggestion}"
assert "北京酒店发票.png" in combined
assert "P4-P5" in combined
assert "680.00" in combined
assert "450.00" in combined
assert "公司差旅费报销规则" in combined
assert "补充超标说明" in combined
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["merchant_name"].value == "北京中心酒店"
def test_user_agent_review_payload_prefers_hotel_invoice_for_hotel_name() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我去北京出差,上传了火车票和酒店发票,帮我生成差旅费报销草稿"
context = {
"name": "张三",
"attachment_names": ["北京南站火车票.png", "北京中心酒店发票.png"],
"attachment_count": 2,
"ocr_documents": [
{
"filename": "北京南站火车票.png",
"document_type": "train_ticket",
"summary": "广州南至北京南 高铁二等座 金额 560 元",
"text": "广州南至北京南 高铁二等座 金额 560 元",
"avg_score": 0.95,
"document_fields": [
{"key": "amount", "label": "金额", "value": "560"},
],
"warnings": [],
},
{
"filename": "北京中心酒店发票.png",
"document_type": "hotel_invoice",
"summary": "北京中心酒店 住宿 1 晚 金额 450 元",
"text": "北京中心酒店 住宿 1 晚 金额 450 元",
"avg_score": 0.96,
"document_fields": [
{"key": "amount", "label": "金额", "value": "450"},
{"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"},
],
"warnings": [],
},
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-travel-hotel-name@example.com",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-travel-hotel-name@example.com",
message=query,
ontology=ontology,
context_json=context,
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["merchant_name"].value == "北京中心酒店"
def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我去北京出差,先上传一张火车票,酒店发票还没上传,帮我生成差旅费报销草稿"
context = {
"name": "张三",
"grade": "P4",
"attachment_names": ["北京南站火车票.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "北京南站火车票.png",
"document_type": "train_ticket",
"scene_code": "travel",
"scene_label": "差旅票据",
"summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元 中国铁路",
"text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00 中国铁路祝您旅途愉快",
"avg_score": 0.95,
"document_fields": [
{"key": "amount", "label": "金额", "value": "560"},
{"key": "route", "label": "行程", "value": "广州南-北京南"},
{"key": "date", "label": "业务发生时间", "value": "2026-03-04"},
{"key": "merchant_name", "label": "商户", "value": "中国铁路"},
],
"warnings": [],
},
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-train-only-hotel-name@example.com",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-train-only-hotel-name@example.com",
message=query,
ontology=ontology,
context_json=context,
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["merchant_name"].value == ""
assert "酒店/商户" not in response.review_payload.missing_slots
assert "酒店的报销票据待上传(必须)" in response.review_payload.missing_slots
assert response.review_payload.can_proceed is False
assert [item.action_type for item in response.review_payload.confirmation_actions if item.emphasis == "primary"] == [
"save_draft"
]
assert "继续下一步" not in [item.label for item in response.review_payload.confirmation_actions]
assert "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer
assert "市内交通/乘车票据(非必须" not in response.answer
assert "只能保存为草稿" in response.answer or "保存为草稿" in response.answer
assert "已识别信息:" in response.answer
assert "酒店住宿发票/住宿清单" in response.answer
assert "职级P4" in response.answer
assert "目的地:北京" in response.answer
assert "已提交火车560.00 元" in response.answer
field_labels = [
field.label
for card in response.review_payload.document_cards
for field in card.fields
]
assert "商户/酒店" not in field_labels
assert "列车出发时间" in field_labels
def test_user_agent_review_payload_does_not_prompt_when_only_optional_ride_receipt_is_missing() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我去北京出差,上传了火车票和酒店票,帮我生成差旅费报销草稿"
context = {
"name": "张三",
"grade": "P4",
"review_form_values": {"occurred_date": "2026-03-04"},
"attachment_names": ["北京南站火车票.png", "北京酒店发票.png"],
"attachment_count": 2,
"ocr_documents": [
{
"filename": "北京南站火车票.png",
"document_type": "train_ticket",
"scene_code": "travel",
"scene_label": "差旅票据",
"summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元",
"text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00",
"avg_score": 0.95,
"document_fields": [
{"key": "amount", "label": "金额", "value": "560"},
{"key": "route", "label": "行程", "value": "广州南-北京南"},
],
"warnings": [],
},
{
"filename": "北京酒店发票.png",
"document_type": "hotel_invoice",
"summary": "北京中心酒店 住宿 1 晚 金额 450 元",
"text": "北京中心酒店 住宿 1 晚 金额 450 元",
"avg_score": 0.96,
"document_fields": [
{"key": "amount", "label": "金额", "value": "450"},
{"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"},
],
"warnings": [],
},
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-travel-optional-ride@example.com",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-travel-optional-ride@example.com",
message=query,
ontology=ontology,
context_json=context,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
assert response.review_payload.can_proceed is True
assert response.review_payload.missing_slots == []
assert not any(item.title == "差旅票据待补充" for item in response.review_payload.risk_briefs)
action_types = [item.action_type for item in response.review_payload.confirmation_actions]
assert "save_draft" in action_types
assert "next_step" in action_types
assert "市内交通/乘车票据(非必须" not in response.answer
assert "继续下一步" in response.answer
def test_user_agent_review_payload_allows_next_step_after_required_travel_receipts_are_complete() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我去北京出差,上传了火车票、酒店票和打车票,帮我生成差旅费报销草稿"
context = {
"name": "张三",
"grade": "P4",
"review_form_values": {"occurred_date": "2026-03-04"},
"attachment_names": ["北京南站火车票.png", "北京酒店发票.png", "北京打车票.png"],
"attachment_count": 3,
"ocr_documents": [
{
"filename": "北京南站火车票.png",
"document_type": "train_ticket",
"scene_code": "travel",
"scene_label": "差旅票据",
"summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元",
"text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00",
"avg_score": 0.95,
"document_fields": [
{"key": "amount", "label": "金额", "value": "560"},
{"key": "route", "label": "行程", "value": "广州南-北京南"},
{"key": "date", "label": "日期", "value": "2026-03-04"},
],
"warnings": [],
},
{
"filename": "北京酒店发票.png",
"document_type": "hotel_invoice",
"summary": "北京中心酒店 住宿 1 晚 金额 450 元",
"text": "北京中心酒店 住宿 1 晚 金额 450 元",
"avg_score": 0.96,
"document_fields": [
{"key": "amount", "label": "金额", "value": "450"},
{"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"},
],
"warnings": [],
},
{
"filename": "北京打车票.png",
"document_type": "taxi_receipt",
"summary": "北京网约车 打车票 支付金额 32 元",
"text": "北京网约车 打车票 支付金额 32 元",
"avg_score": 0.94,
"document_fields": [
{"key": "amount", "label": "支付金额", "value": "32"},
],
"warnings": [],
},
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-travel-complete@example.com",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-travel-complete@example.com",
message=query,
ontology=ontology,
context_json=context,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
assert response.review_payload.can_proceed is True
assert response.review_payload.missing_slots == []
action_types = [item.action_type for item in response.review_payload.confirmation_actions]
assert "save_draft" in action_types
assert "next_step" in action_types
assert not any(item.title == "差旅票据待补充" for item in response.review_payload.risk_briefs)
assert "无需继续上传票据" in response.answer
assert "当前信息已较完整" in response.answer
def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我去北京出差,上传了一张打车票,帮我生成差旅费报销草稿"
context = {
"name": "张三",
"grade": "P4",
"attachment_names": ["北京打车票.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "北京打车票.png",
"document_type": "taxi_receipt",
"summary": "北京网约车 打车票 支付金额 360 元",
"text": "北京网约车 打车票 支付金额 360 元",
"avg_score": 0.95,
"document_fields": [
{"key": "amount", "label": "支付金额", "value": "360"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=query,
ontology=ontology,
context_json=context,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
amount_brief = next(item for item in response.review_payload.risk_briefs if "交通费金额超标" in item.title)
combined = f"{amount_brief.title}\n{amount_brief.content}\n{amount_brief.detail}\n{amount_brief.suggestion}"
assert "北京打车票.png" in combined
assert "360.00" in combined
assert "300.00" in combined
assert "单笔交通金额" in combined
assert "报销场景提交与附件标准" in combined
assert amount_brief.level == "high"
measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算异常")
assert measurement.level == "warning"
assert "超出标准" in measurement.detail
def test_user_agent_review_payload_does_not_mark_compliant_taxi_amount_as_low_risk() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我上传一张的士票59.10元,帮我生成交通费报销草稿"
context = {
"name": "张三",
"attachment_names": ["的士1.jpg"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "的士1.jpg",
"document_type": "taxi_receipt",
"summary": "出租车/网约车票据 支付金额 59.10 元",
"text": "的士 车费 59.10 元",
"avg_score": 0.95,
"document_fields": [
{"key": "amount", "label": "支付金额", "value": "59.10"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-taxi-pass@example.com",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-taxi-pass@example.com",
message=query,
ontology=ontology,
context_json=context,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
risk_titles = [item.title for item in response.review_payload.risk_briefs]
risk_details = "\n".join(item.detail for item in response.review_payload.risk_briefs)
assert "附件金额测算结果" not in risk_titles
assert "附件金额测算异常" not in risk_titles
assert "测算通过" not in risk_details
def test_user_agent_review_payload_uses_finance_spreadsheet_hotel_amount_standard() -> None:
session_factory = build_session_factory()
with session_factory() as db:
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
employee = Employee(
employee_no="E-TRAVEL-XLSX-001",
name="测算员工",
email="pytest-travel-xlsx@example.com",
position="基层经理",
grade="P4",
)
db.add(employee)
db.commit()
query = "测算员工去北京出差住宿,上传了北京酒店发票,帮我生成差旅费报销草稿"
context = {
"name": "测算员工",
"attachment_names": ["北京酒店发票.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "北京酒店发票.png",
"document_type": "hotel_invoice",
"summary": "北京酒店 住宿 1 晚 金额 480 元",
"text": "北京酒店 住宿 1 晚 金额 480 元",
"avg_score": 0.96,
"document_fields": [
{"key": "amount", "label": "金额", "value": "480"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-travel-xlsx@example.com",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-travel-xlsx@example.com",
message=query,
ontology=ontology,
context_json=context,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
combined = f"{hotel_brief.content}\n{hotel_brief.detail}"
assert "480.00" in combined
assert "450.00" in combined
assert "公司差旅费报销规则" in combined
def test_user_agent_review_payload_uses_spreadsheet_city_hotel_standard_not_default_tier() -> None:
session_factory = build_session_factory()
with session_factory() as db:
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
query = "我去张家口出差住宿,上传了张家口酒店发票,帮我生成差旅费报销草稿"
context = {
"name": "张三",
"grade": "P4",
"attachment_names": ["张家口酒店发票.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "张家口酒店发票.png",
"document_type": "hotel_invoice",
"summary": "张家口酒店 住宿 1 晚 金额 320 元",
"text": "张家口酒店 住宿 1 晚 金额 320 元",
"avg_score": 0.96,
"document_fields": [
{"key": "amount", "label": "金额", "value": "320"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-travel-city@example.com",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-travel-city@example.com",
message=query,
ontology=ontology,
context_json=context,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title)
combined = f"{hotel_brief.content}\n{hotel_brief.detail}"
assert "320.00" in combined
assert "300.00" in combined
assert "公司差旅费报销规则" in combined
def test_user_agent_review_payload_uses_finance_spreadsheet_meal_allowance_standard() -> None:
session_factory = build_session_factory()
with session_factory() as db:
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
query = "我去北京出差,上传了一张餐饮发票,帮我生成差旅费报销草稿"
context = {
"name": "张三",
"grade": "P4",
"attachment_names": ["北京餐饮发票.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "北京餐饮发票.png",
"document_type": "meal_receipt",
"summary": "北京餐饮发票 金额 90 元",
"text": "北京餐饮发票 金额 90 元",
"avg_score": 0.96,
"document_fields": [
{"key": "amount", "label": "金额", "value": "90"},
],
"warnings": [],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest-travel-meal@example.com",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-travel-meal@example.com",
message=query,
ontology=ontology,
context_json=context,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
meal_brief = next(item for item in response.review_payload.risk_briefs if "伙食补助标准" in item.title)
combined = f"{meal_brief.title}\n{meal_brief.content}\n{meal_brief.detail}\n{meal_brief.suggestion}"
assert "北京餐饮发票.png" in combined
assert "90.00" in combined
assert "65.00" in combined
assert "直辖市/特区" in combined
assert "公司差旅费报销规则" in combined
assert meal_brief.level == "high"
measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算异常")
assert "伙食补助标准 65.00" in measurement.detail
assert "超出标准" in measurement.detail
def test_user_agent_filters_deprecated_review_risk_briefs() -> None:
filtered = UserAgentService._filter_deprecated_review_risk_briefs(
[
UserAgentReviewRiskBrief(title="历史报销画像", level="info", content="旧画像"),
UserAgentReviewRiskBrief(title="用户画像", level="info", content="旧画像"),
UserAgentReviewRiskBrief(title="制度注意事项", level="info", content="旧制度提示"),
UserAgentReviewRiskBrief(title="住宿超标待说明", level="high", content="保留"),
]
)
assert [item.title for item in filtered] == ["住宿超标待说明"]
def test_user_agent_submission_blocked_risk_level_only_marks_amount_reasons_high() -> None:
assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high"
assert UserAgentService._resolve_submission_blocked_risk_level("缺少直属领导或参与人员信息") == "warning"
assert UserAgentService._is_submission_exception_explanation_reason("住宿金额超出当前职级差标,且未补充超标说明。")
assert not UserAgentService._is_submission_exception_explanation_reason("缺少直属领导或参与人员信息")
def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我去北京出差住酒店,帮我生成差旅费报销草稿并进入下一步提交"
context = {
"name": "张三",
"attachment_names": ["北京酒店发票.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "北京酒店发票.png",
"document_type": "hotel_invoice",
"summary": "北京酒店 住宿 1 晚 金额 680 元",
"text": "北京酒店 住宿 1 晚 金额 680 元",
"avg_score": 0.94,
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=query,
user_id="pytest",
context_json=context,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=query,
ontology=ontology,
context_json=context,
tool_payload={
"submission_blocked": True,
"submission_blocked_reasons": ["住宿金额超出当前职级差标,且未补充超标说明。"],
},
)
)
assert response.review_payload is not None
assert response.answer == response.review_payload.body_message
assert response.answer.startswith("检测到当前单据存在需要说明的超标风险")
assert "票据会先正常归集到单据中" in response.answer
assert "附加说明" in response.answer
assert response.review_payload.can_proceed is False
blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示")
assert blocked_brief.level == "high"
assert "不是票据归集阻断条件" in blocked_brief.detail
assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs)
def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上传了两张票据,帮我生成报销草稿",
user_id="pytest",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message="我上传了两张票据,帮我生成报销草稿",
ontology=ontology,
context_json={
"attachment_names": ["滴滴行程单.png", "餐饮发票.jpg"],
"attachment_count": 2,
"ocr_documents": [
{"filename": "滴滴行程单.png", "summary": "滴滴出行 金额 32 元", "text": "滴滴出行 金额 32 元"},
{"filename": "餐饮发票.jpg", "summary": "餐饮发票 金额 68 元", "text": "餐饮发票 金额 68 元"},
],
},
tool_payload={
"pending_association_decision": True,
"association_candidate_claim_no": "EXP-202605-008",
},
)
)
assert response.review_payload is not None
assert response.review_payload.can_proceed is False
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"link_to_existing_draft",
"create_new_claim_from_documents",
]
assert "EXP-202605-008" in response.answer
def test_user_agent_reports_duplicate_invoice_block_when_linking_draft() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="把这张票据关联到已有草稿",
user_id="pytest-duplicate",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-duplicate",
message="把这张票据关联到已有草稿",
ontology=ontology,
context_json={
"review_action": "link_to_existing_draft",
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32.50 元",
"text": "滴滴出行 支付金额 32.50 元",
}
],
},
tool_payload={
"review_action": "link_to_existing_draft",
"duplicate_attachment_blocked": True,
"duplicate_invoice_blocked": True,
"submission_blocked": True,
"submission_blocked_reasons": [
"检测到本次上传的票据与草稿 EXP-202605-021 中已有票据重复didi-trip.png。请重新上传不同的票据后再归集。"
],
"message": "检测到本次上传的票据与草稿 EXP-202605-021 中已有票据重复didi-trip.png。请重新上传不同的票据后再归集。",
"claim_id": "claim-duplicate",
"claim_no": "EXP-202605-021",
"status": "blocked",
},
)
)
assert "重复" in response.answer
assert "重新上传不同的票据" in response.answer
assert "已将本次上传" not in response.answer
assert response.review_payload is not None
assert response.review_payload.can_proceed is False