Files
X-Financial/server/tests/test_user_agent_service.py

677 lines
28 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.schemas.ontology import OntologyParseRequest
from app.schemas.user_agent import UserAgentRequest
from app.services.ontology import SemanticOntologyService
from app.services.user_agent import UserAgentService
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 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_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 not None
assert response.answer == response.review_payload.body_message
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] == [
"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:
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 response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"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_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}
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] == [
"cancel_review",
"edit_review",
"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_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] == [
"cancel_review",
"edit_review",
"save_draft",
]
assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards)
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_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] == [
"cancel_review",
"edit_review",
"link_to_existing_draft",
"create_new_claim_from_documents",
]
assert "EXP-202605-008" in response.answer