Files
X-Financial/server/tests/test_user_agent_service.py
caoxiaozhu 68f663f2f4 feat: 重构知识库系统,移除Hermes集成,增强RAG和同步功能
主要变更:
- 移除Hermes智能体及相关回调服务
- 新增知识库RAG、同步、调度、规范化和索引任务服务
- 重构orchestrator服务,增强运行时聊天功能
- 更新前端聊天、政策制度、设置等页面样式和逻辑
- 更新expense_claims和document_intelligence服务
- 删除llm_wiki相关服务和测试文件
- 更新docker-compose配置和启动脚本
2026-05-17 08:38:41 +00:00

997 lines
41 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
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_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 "knowledge_evidence_blocks" in messages[0]["content"]
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
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
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"] == 5
assert captured["slot_timeouts"] == {"main": 3, "backup": 5}
assert captured["max_attempts"] == 1
def test_user_agent_prefers_structured_knowledge_hit_for_answer_generation() -> None:
selected = UserAgentService._select_knowledge_model_hits(
{
"hits": [
{"content": "raw hit 1"},
{"content": "raw hit 2"},
{"content": "# 问答线索补充\n\n- 第二章 报销时限:费用发生后 30 日内提交申请。"},
{"content": "# 结构化表格补充\n\n| 项目 | 金额 |"},
]
}
)
assert selected[0]["content"].startswith("# 问答线索补充")
assert selected[1]["content"].startswith("# 结构化表格补充")
assert selected[2]["content"] == "raw hit 1"
def test_user_agent_uses_fast_knowledge_answer_without_model(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)
monkeypatch.setattr(
service,
"_generate_answer_with_model",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model should not be called")),
)
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 response.answer.startswith("张三,您好。")
assert "当前能直接确认的是" in response.answer
assert "30 日内提交报销申请" in response.answer
assert "答案整理阶段本轮没有及时返回" not in response.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
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
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 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