2026-05-21 09:28:33 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-16 06:14:08 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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最终答复")
|
|
|
|
|
|
== "最终答复"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 08:38:41 +00:00
|
|
|
|
def test_user_agent_rejects_visible_reasoning_drafts() -> None:
|
2026-05-16 06:14:08 +00:00
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
service = UserAgentService(db)
|
|
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
|
service._sanitize_model_answer(
|
|
|
|
|
|
"用户问的是:住宿费怎么算?\n让我分析一下:\n1. 实体识别..."
|
|
|
|
|
|
)
|
|
|
|
|
|
is None
|
2026-05-17 08:38:41 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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="",
|
2026-05-16 06:14:08 +00:00
|
|
|
|
)
|
2026-05-17 08:38:41 +00:00
|
|
|
|
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"]
|
2026-05-16 06:14:08 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 08:38:41 +00:00
|
|
|
|
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
|
2026-05-12 01:24:39 +00:00
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
2026-05-16 06:14:08 +00:00
|
|
|
|
query="住宿费标准是多少?",
|
2026-05-12 01:24:39 +00:00
|
|
|
|
user_id="pytest",
|
2026-05-16 06:14:08 +00:00
|
|
|
|
context_json={"session_type": "knowledge"},
|
2026-05-12 01:24:39 +00:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
service = UserAgentService(db)
|
2026-05-17 08:38:41 +00:00
|
|
|
|
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(
|
2026-05-14 15:42:33 +00:00
|
|
|
|
UserAgentRequest(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id="pytest",
|
2026-05-16 06:14:08 +00:00
|
|
|
|
message="住宿费标准是多少?",
|
2026-05-14 15:42:33 +00:00
|
|
|
|
ontology=ontology,
|
2026-05-16 06:14:08 +00:00
|
|
|
|
tool_payload={"result_type": "knowledge_search", "hits": []},
|
|
|
|
|
|
),
|
|
|
|
|
|
citations=[],
|
|
|
|
|
|
suggested_actions=[],
|
|
|
|
|
|
risk_flags=[],
|
|
|
|
|
|
draft_payload=None,
|
|
|
|
|
|
fallback_answer="",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-17 08:38:41 +00:00
|
|
|
|
assert answer == "测试回答"
|
|
|
|
|
|
assert captured["timeout_seconds"] == 5
|
|
|
|
|
|
assert captured["slot_timeouts"] == {"main": 3, "backup": 5}
|
|
|
|
|
|
assert captured["max_attempts"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 02:50:32 +00:00
|
|
|
|
def test_user_agent_prefers_structured_table_hit_for_standard_query() -> None:
|
2026-05-17 08:38:41 +00:00
|
|
|
|
selected = UserAgentService._select_knowledge_model_hits(
|
|
|
|
|
|
{
|
|
|
|
|
|
"hits": [
|
|
|
|
|
|
{"content": "raw hit 1"},
|
|
|
|
|
|
{"content": "raw hit 2"},
|
|
|
|
|
|
{"content": "# 问答线索补充\n\n- 第二章 报销时限:费用发生后 30 日内提交申请。"},
|
2026-05-18 02:50:32 +00:00
|
|
|
|
{"content": "# 结构化表格补充\n\n| 项目 | 餐补 |\n| 其他地区 | 55 |"},
|
2026-05-17 08:38:41 +00:00
|
|
|
|
]
|
2026-05-18 02:50:32 +00:00
|
|
|
|
},
|
|
|
|
|
|
question="餐补标准是多少?",
|
2026-05-17 08:38:41 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-18 02:50:32 +00:00
|
|
|
|
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"]
|
2026-05-17 08:38:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-18 02:50:32 +00:00
|
|
|
|
assert "## 依据" not in response.answer
|
2026-05-17 08:38:41 +00:00
|
|
|
|
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
|
2026-05-18 02:50:32 +00:00
|
|
|
|
assert "## 依据" not in answer
|
2026-05-17 08:38:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-18 02:50:32 +00:00
|
|
|
|
assert "## 依据" not 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 "## 依据" not in answer
|
2026-05-16 06:14:08 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_agent_model_prompt_supports_contextual_personalization() -> None:
|
2026-05-17 08:38:41 +00:00
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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"]
|
2026-05-17 08:38:41 +00:00
|
|
|
|
assert "context.user_grade" in system_prompt
|
|
|
|
|
|
assert "conversation_history" in user_prompt
|
2026-05-16 06:14:08 +00:00
|
|
|
|
assert '"user_name": "张三"' in user_prompt
|
|
|
|
|
|
assert '"user_position": "财务分析师"' in user_prompt
|
|
|
|
|
|
assert '"user_grade": "P5"' in user_prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 14:24:51 +08:00
|
|
|
|
def test_user_agent_guides_generic_expense_request() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
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",
|
2026-05-21 14:24:51 +08:00
|
|
|
|
]
|
2026-05-21 16:09:47 +08:00
|
|
|
|
assert [item.label for item in response.suggested_actions[:3]] == ["差旅费", "交通费", "住宿费"]
|
2026-05-21 14:24:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
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={
|
|
|
|
|
|
"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={
|
|
|
|
|
|
"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},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-21 14:24:51 +08:00
|
|
|
|
assert response.review_payload is not None
|
|
|
|
|
|
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
2026-05-21 16:09:47 +08:00
|
|
|
|
assert slot_map["expense_type"].value == "差旅费"
|
|
|
|
|
|
assert slot_map["expense_type"].normalized_value == "travel"
|
2026-05-21 14:24:51 +08:00
|
|
|
|
assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23"
|
|
|
|
|
|
assert slot_map["location"].value == "上海"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_agent_guides_implicit_expense_draft_request() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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元"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08: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(
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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}
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 14:24:51 +08:00
|
|
|
|
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天"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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(
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
|
def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
|
assert (
|
|
|
|
|
|
response.answer
|
|
|
|
|
|
== "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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_builds_review_payload_for_multi_document_expense_flow() -> None:
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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元"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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(
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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 == "北京中心酒店"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
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 "市内交通/乘车票据(非必须" in response.answer
|
|
|
|
|
|
assert "只能保存为草稿" in response.answer or "保存为草稿" 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
|
2026-05-21 14:24:51 +08:00
|
|
|
|
assert "列车出发时间" in field_labels
|
2026-05-21 10:57:06 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_agent_review_payload_allows_next_step_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 == []
|
|
|
|
|
|
receipt_brief = next(item for item in response.review_payload.risk_briefs if item.title == "差旅票据待补充")
|
|
|
|
|
|
assert receipt_brief.level == "info"
|
|
|
|
|
|
assert "市内交通/乘车票据可继续上传(非必须)" in receipt_brief.content
|
|
|
|
|
|
assert "酒店的报销票据待上传(必须)" not in receipt_brief.content
|
|
|
|
|
|
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 "市内交通/乘车票据(非必须" 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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"
|
|
|
|
|
|
assert any(item.title == "附件金额测算结果" for item in response.review_payload.risk_briefs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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("AI预审未通过:住宿金额超出当前职级差标")
|
|
|
|
|
|
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 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(
|
2026-05-16 06:14:08 +00:00
|
|
|
|
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
|