2026-05-12 01:24:39 +00:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-05-12 07:20:59 +00:00
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
|
2026-05-12 01:24:39 +00:00
|
|
|
|
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_prefers_runtime_model_answer_when_available(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 response.answer == "这是模型回答"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_agent_sanitizes_model_thinking_blocks() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
service = UserAgentService(db)
|
|
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
|
service._sanitize_model_answer("<think>内部推理</think>\n最终答复")
|
|
|
|
|
|
== "最终答复"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_agent_guides_generic_expense_request() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query="我要报销",
|
|
|
|
|
|
user_id="pytest",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
response = UserAgentService(db).respond(
|
|
|
|
|
|
UserAgentRequest(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id="pytest",
|
|
|
|
|
|
message="我要报销",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
tool_payload={"record_count": 9, "total_amount": 12345.0},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-12 07:20:59 +00:00
|
|
|
|
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",
|
|
|
|
|
|
]
|
2026-05-12 01:24:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_agent_guides_implicit_expense_draft_request() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
2026-05-12 07:20:59 +00:00
|
|
|
|
today = datetime.now(UTC).date().isoformat()
|
2026-05-12 01:24:39 +00:00
|
|
|
|
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},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-12 07:20:59 +00:00
|
|
|
|
assert response.review_payload is not None
|
|
|
|
|
|
assert response.answer == response.review_payload.body_message
|
2026-05-12 15:13:57 +00:00
|
|
|
|
assert response.review_payload.intent_summary.startswith("我理解你这次想报销业务招待费。")
|
2026-05-12 07:20:59 +00:00
|
|
|
|
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 == "业务招待费"
|
2026-05-12 15:13:57 +00:00
|
|
|
|
assert slot_map["time_range"].value == today
|
|
|
|
|
|
assert slot_map["time_range"].raw_value == "今天"
|
2026-05-12 07:20:59 +00:00
|
|
|
|
assert slot_map["location"].value == "客户现场"
|
|
|
|
|
|
assert slot_map["amount"].value == "1000.00元"
|
2026-05-12 01:24:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 15:13:57 +00: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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 01:24:39 +00:00
|
|
|
|
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
|
2026-05-12 07:20:59 +00:00
|
|
|
|
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-12 06:37:59 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None:
|
|
|
|
|
|
session_factory = build_session_factory()
|
|
|
|
|
|
with session_factory() as db:
|
2026-05-12 07:20:59 +00:00
|
|
|
|
yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
|
2026-05-12 06:37:59 +00:00
|
|
|
|
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
|
2026-05-12 07:20:59 +00:00
|
|
|
|
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)
|
2026-05-12 15:13:57 +00:00
|
|
|
|
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 == "昨天"
|