from __future__ import annotations from datetime import UTC, datetime, timedelta from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base from app.schemas.ontology import OntologyParseRequest from app.schemas.user_agent import UserAgentRequest from app.services.ontology import SemanticOntologyService from app.services.user_agent import UserAgentService def build_session_factory() -> sessionmaker[Session]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) return sessionmaker(bind=engine, autoflush=False, autocommit=False) def test_user_agent_query_returns_readable_answer_and_actions() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="张三 4 月差旅报销金额是多少", user_id="pytest", ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="张三 4 月差旅报销金额是多少", ontology=ontology, tool_payload={"record_count": 2, "total_amount": 8800.0}, ) ) assert "8800.00" in response.answer assert len(response.suggested_actions) >= 1 def test_user_agent_returns_readable_query_answer_when_runtime_model_is_skipped(monkeypatch) -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="张三 4 月差旅报销金额是多少", user_id="pytest", ) ) service = UserAgentService(db) monkeypatch.setattr(service, "_generate_answer_with_model", lambda *args, **kwargs: "这是模型回答") response = service.respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="张三 4 月差旅报销金额是多少", ontology=ontology, tool_payload={"record_count": 2, "total_amount": 8800.0}, ) ) assert "共 2 笔" in response.answer assert "8800.00" in response.answer def test_user_agent_sanitizes_model_thinking_blocks() -> None: session_factory = build_session_factory() with session_factory() as db: service = UserAgentService(db) assert ( service._sanitize_model_answer("内部推理\n最终答复") == "最终答复" ) def test_user_agent_rejects_visible_reasoning_drafts() -> None: session_factory = build_session_factory() with session_factory() as db: service = UserAgentService(db) assert ( service._sanitize_model_answer( "用户问的是:住宿费怎么算?\n让我分析一下:\n1. 实体识别..." ) is None ) def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="住宿费标准是多少?", user_id="pytest", context_json={"session_type": "knowledge"}, ) ) service = UserAgentService(db) messages = service._build_model_messages( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="住宿费标准是多少?", ontology=ontology, tool_payload={"result_type": "knowledge_search", "hits": []}, ), citations=[], suggested_actions=[], risk_flags=[], draft_payload=None, fallback_answer="", ) assert "只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence" in messages[0]["content"] assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"] assert "不能只依赖排在最前面的片段" in messages[0]["content"] assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"] assert "knowledge_evidence_blocks" in messages[0]["content"] assert '"knowledge_answer_evidence": []' in messages[1]["content"] def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="住宿费标准是多少?", user_id="pytest", context_json={"session_type": "knowledge"}, ) ) service = UserAgentService(db) answer = service._build_knowledge_search_answer( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="住宿费标准是多少?", ontology=ontology, context_json={"name": "张三"}, tool_payload={ "result_type": "knowledge_search", "hits": [{"title": "差旅费制度", "content": "住宿费标准正文"}], }, ), citations=[], ) assert answer.startswith("张三,您好。") assert "答案整理阶段本轮没有及时返回" in answer assert "先给你当前最直接的依据" in answer assert "《差旅费制度》" in answer def test_user_agent_knowledge_answer_generation_uses_fast_timeouts(monkeypatch) -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="住宿费标准是多少?", user_id="pytest", context_json={"session_type": "knowledge"}, ) ) service = UserAgentService(db) captured: dict[str, object] = {} def fake_complete(messages, **kwargs): captured["messages"] = messages captured.update(kwargs) return "测试回答" monkeypatch.setattr(service.runtime_chat_service, "complete", fake_complete) answer = service._generate_answer_with_model( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="住宿费标准是多少?", ontology=ontology, tool_payload={"result_type": "knowledge_search", "hits": []}, ), citations=[], suggested_actions=[], risk_flags=[], draft_payload=None, fallback_answer="", ) assert answer == "测试回答" assert captured["timeout_seconds"] == 5 assert captured["slot_timeouts"] == {"main": 3, "backup": 5} assert captured["max_attempts"] == 1 def test_user_agent_prefers_structured_table_hit_for_standard_query() -> None: selected = UserAgentService._select_knowledge_model_hits( { "hits": [ {"content": "raw hit 1"}, {"content": "raw hit 2"}, {"content": "# 问答线索补充\n\n- 第二章 报销时限:费用发生后 30 日内提交申请。"}, {"content": "# 结构化表格补充\n\n| 项目 | 餐补 |\n| 其他地区 | 55 |"}, ] }, question="餐补标准是多少?", ) assert selected[0]["content"].startswith("# 结构化表格补充") assert any(item["content"].startswith("# 结构化表格补充") for item in selected[:2]) def test_user_agent_prefers_relevant_raw_hit_over_generic_appendix() -> None: selected = UserAgentService._select_knowledge_model_hits( { "hits": [ {"content": "# 章节导航\n\n- 第一章 总则\n- 第二章 职责分工"}, {"content": "# 问答线索补充\n\n- 第二章 职责分工:计划财务部负责财务审核。"}, {"content": "一般性说明文字,没有探亲差旅归口信息。"}, {"content": "附表3:支出归口管理部门与归口业务范围\n组织人事部:探亲差旅、条件艰苦及安全风险较高区域补助等支出。"}, ] }, question="探亲差旅归哪个部门管理?", ) assert "组织人事部" in selected[0]["content"] def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="报销时限是多少?", user_id="pytest", context_json={"session_type": "knowledge"}, ) ) service = UserAgentService(db) monkeypatch.setattr( service, "_generate_answer_with_model", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model should not be called")), ) response = service.respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="报销时限是多少?", ontology=ontology, context_json={ "name": "张三", "session_type": "knowledge", "user_input_text": "报销时限是多少?", }, tool_payload={ "result_type": "knowledge_search", "hits": [ { "title": "费用报销制度", "content": ( "# 问答线索补充\n\n" "- 第二章 报销时限:员工应在费用发生后 30 日内提交报销申请。\n" "- 第二章 报销时限:超过 30 日需补充审批说明。" ), }, ], }, ) ) assert response.answer.startswith("张三,您好。") assert "当前能直接确认的是" in response.answer assert "30 日内提交报销申请" in response.answer assert "## 依据" not in response.answer 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 assert "## 依据" not in answer def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="前往北京出差的报销标准是什么?", user_id="pytest", context_json={"session_type": "knowledge"}, ) ) service = UserAgentService(db) answer = service._build_fast_knowledge_answer( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="前往北京出差的报销标准是什么?", ontology=ontology, context_json={ "session_type": "knowledge", "user_input_text": "前往北京出差的报销标准是什么?", }, tool_payload={ "result_type": "knowledge_search", "hits": [ { "title": "费用报销制度", "content": ( "# 结构化表格补充\n\n" "## 表3 出差补贴标准\n\n" "| 项目 | 港澳台 | 直辖市/特区/西藏 | 其他地区 |\n" "| --- | --- | --- | --- |\n" "| 餐补 | 75 | 65 | 55 |\n" "| 基本补贴 | 35 | 35 | 35 |\n" ), } ], }, ), citations=[], ) assert answer is not None assert "没有直接写出“北京”对应的地区档位或映射关系" in answer assert "## 依据" 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 def test_user_agent_model_prompt_supports_contextual_personalization() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我能坐什么舱位?", user_id="pytest", ) ) service = UserAgentService(db) messages = service._build_model_messages( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我能坐什么舱位?", ontology=ontology, context_json={ "name": "张三", "position": "财务分析师", "grade": "P5", "role": "财务人员", "role_codes": ["finance"], }, tool_payload={}, ), citations=[], suggested_actions=[], risk_flags=[], draft_payload=None, fallback_answer="", ) system_prompt = messages[0]["content"] user_prompt = messages[1]["content"] assert "context.user_grade" in system_prompt assert "conversation_history" in user_prompt assert '"user_name": "张三"' in user_prompt assert '"user_position": "财务分析师"' in user_prompt assert '"user_grade": "P5"' in user_prompt def test_user_agent_guides_generic_expense_request() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我要报销", user_id="pytest", ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我要报销", ontology=ontology, tool_payload={"record_count": 9, "total_amount": 12345.0}, ) ) assert response.review_payload is not None assert response.answer == response.review_payload.body_message assert response.review_payload.can_proceed is False assert response.review_payload.missing_slots == [ "报销类型", "发生时间", "金额", "事由说明", "票据附件", ] assert [item.action_type for item in response.review_payload.confirmation_actions] == [ "cancel_review", "edit_review", "save_draft", ] def test_user_agent_guides_implicit_expense_draft_request() -> None: session_factory = build_session_factory() with session_factory() as db: today = datetime.now(UTC).date().isoformat() ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我今天去客户现场,招待了客户,花销了1000元", user_id="pytest", ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我今天去客户现场,招待了客户,花销了1000元", ontology=ontology, tool_payload={"draft_only": True}, ) ) assert response.review_payload is not None assert response.answer == response.review_payload.body_message assert response.review_payload.intent_summary.startswith("识别到您希望报销一笔“业务招待费”费用。") assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"] assert [item.action_type for item in response.review_payload.confirmation_actions] == [ "cancel_review", "edit_review", "save_draft", ] slot_map = {item.key: item for item in response.review_payload.slot_cards} assert slot_map["expense_type"].value == "业务招待费" assert slot_map["time_range"].value == today assert slot_map["time_range"].raw_value == "今天" assert slot_map["location"].value == "客户现场" assert slot_map["amount"].value == "1000.00元" def test_user_agent_guides_narrative_with_day_before_yesterday() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我前天请客户吃饭花了200元", user_id="pytest", context_json={ "client_now_iso": "2026-05-12T16:30:00.000Z", "client_timezone_offset_minutes": -480, }, ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我前天请客户吃饭花了200元", ontology=ontology, tool_payload={"draft_only": True}, ) ) assert response.review_payload is not None slot_map = {item.key: item for item in response.review_payload.slot_cards} assert slot_map["time_range"].raw_value == "前天" assert slot_map["time_range"].value == "2026-05-11" assert "时间为 2026-05-11" in response.review_payload.intent_summary def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。", user_id="pytest", context_json={ "attachment_names": ["didi-trip.png"], "attachment_count": 1, "ocr_documents": [ { "filename": "didi-trip.png", "summary": "滴滴出行 订单金额 32 元", "text": "滴滴出行 订单金额 32 元", "document_type": "taxi_receipt", "scene_code": "transport", } ], "user_input_text": "", }, ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。\n附件名称:didi-trip.png", ontology=ontology, context_json={ "attachment_names": ["didi-trip.png"], "attachment_count": 1, "ocr_documents": [ { "filename": "didi-trip.png", "summary": "滴滴出行 订单金额 32 元", "text": "滴滴出行 订单金额 32 元", "document_type": "taxi_receipt", "scene_code": "transport", } ], "user_input_text": "", }, tool_payload={"draft_only": True}, ) ) assert response.review_payload is not None slot_map = {item.key: item for item in response.review_payload.slot_cards} assert slot_map["reason"].value == "交通出行" assert slot_map["reason"].status == "inferred" def test_user_agent_transport_flow_infers_reason_and_does_not_require_location_or_merchant() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我上传了交通票据,帮我生成报销草稿", user_id="pytest", ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我上传了交通票据,帮我生成报销草稿", ontology=ontology, context_json={ "attachment_names": ["didi-trip.png"], "attachment_count": 1, "ocr_documents": [ { "filename": "didi-trip.png", "summary": "滴滴出行 支付金额 32 元", "text": "滴滴出行 支付金额 32 元", "document_type": "taxi_receipt", "scene_code": "transport", "scene_label": "交通票据", } ], }, tool_payload={"draft_only": True}, ) ) assert response.review_payload is not None slot_map = {item.key: item for item in response.review_payload.slot_cards} assert slot_map["reason"].value == "交通出行" assert slot_map["reason"].status == "inferred" assert "酒店/商户" not in response.review_payload.missing_slots assert "地点" not in response.review_payload.missing_slots assert "事由说明" not in response.review_payload.missing_slots def test_user_agent_risk_response_includes_rule_citations() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="检查重复报销风险", user_id="pytest", ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="检查重复报销风险", ontology=ontology, tool_payload={"risk_flags": ["duplicate_expense"]}, ) ) assert response.risk_flags == ["duplicate_expense"] assert any(item.source_type == "rule" for item in response.citations) assert "duplicate_expense" in response.answer def test_user_agent_draft_returns_structured_payload() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="帮我生成张三4月差旅报销草稿", user_id="pytest", ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="帮我生成张三4月差旅报销草稿", ontology=ontology, tool_payload={"draft_only": True}, ) ) assert response.draft_payload is not None assert response.draft_payload.confirmation_required is True assert response.review_payload is not None assert response.review_payload.can_proceed is False assert response.review_payload.missing_slots == ["金额", "事由说明", "票据附件"] assert [item.action_type for item in response.review_payload.confirmation_actions] == [ "cancel_review", "edit_review", "save_draft", ] assert response.answer == response.review_payload.body_message def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="请按当前识别信息保存报销草稿", user_id="pytest", ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="请按当前识别信息保存报销草稿", ontology=ontology, context_json={"review_action": "save_draft"}, tool_payload={ "draft_limit_reached": True, "message": "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。", "status": "blocked", }, ) ) assert ( response.answer == "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" ) def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None: session_factory = build_session_factory() with session_factory() as db: yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat() ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我昨天去上海出差,还请客户A吃饭,帮我生成报销草稿", user_id="pytest", context_json={ "attachment_names": ["机票行程单.png", "餐饮发票.jpg"], "attachment_count": 2, "ocr_documents": [ { "filename": "机票行程单.png", "summary": "机票行程单 上海-北京 金额 680 元", "text": "机票行程单 上海-北京 金额 680 元", "avg_score": 0.93, "warnings": [], }, { "filename": "餐饮发票.jpg", "summary": "餐饮发票 客户招待 金额 320 元", "text": "餐饮发票 客户招待 金额 320 元", "avg_score": 0.91, "warnings": [], }, ], }, ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我昨天去上海出差,还请客户A吃饭,帮我生成报销草稿", ontology=ontology, context_json={ "name": "张三", "attachment_names": ["机票行程单.png", "餐饮发票.jpg"], "attachment_count": 2, "ocr_documents": [ { "filename": "机票行程单.png", "summary": "机票行程单 上海-北京 金额 680 元", "text": "机票行程单 上海-北京 金额 680 元", "avg_score": 0.93, "warnings": [], }, { "filename": "餐饮发票.jpg", "summary": "餐饮发票 客户招待 金额 320 元", "text": "餐饮发票 客户招待 金额 320 元", "avg_score": 0.91, "warnings": [], }, ], }, tool_payload={"draft_only": True, "claim_no": "EXP-202605-009", "status": "draft"}, ) ) assert response.review_payload is not None assert len(response.review_payload.document_cards) == 2 assert len(response.review_payload.claim_groups) == 2 assert response.review_payload.missing_slots == ["参与人员"] assert [item.action_type for item in response.review_payload.confirmation_actions] == [ "cancel_review", "edit_review", "save_draft", ] assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards) assert f"时间为 {yesterday}" in response.review_payload.intent_summary slot_map = {item.key: item for item in response.review_payload.slot_cards} assert slot_map["time_range"].value == yesterday assert slot_map["time_range"].raw_value == "昨天" def test_user_agent_sums_multi_document_amounts_from_synonym_fields() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我上传了两张交通票据,帮我生成报销草稿", user_id="pytest", context_json={ "attachment_names": ["滴滴行程单.png", "停车票.jpg"], "attachment_count": 2, "ocr_documents": [ { "filename": "滴滴行程单.png", "summary": "滴滴出行电子行程单", "text": "滴滴出行 订单金额 ¥32.50", "avg_score": 0.94, "document_fields": [ {"key": "amount", "label": "支付金额", "value": "32.50"}, ], "warnings": [], }, { "filename": "停车票.jpg", "summary": "停车票", "text": "停车费 合计 18 元", "avg_score": 0.92, "document_fields": [ {"key": "total_amount", "label": "合计金额", "value": "18"}, ], "warnings": [], }, ], }, ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我上传了两张交通票据,帮我生成报销草稿", ontology=ontology, context_json={ "attachment_names": ["滴滴行程单.png", "停车票.jpg"], "attachment_count": 2, "ocr_documents": [ { "filename": "滴滴行程单.png", "summary": "滴滴出行电子行程单", "text": "滴滴出行 订单金额 ¥32.50", "avg_score": 0.94, "document_fields": [ {"key": "amount", "label": "支付金额", "value": "32.50"}, ], "warnings": [], }, { "filename": "停车票.jpg", "summary": "停车票", "text": "停车费 合计 18 元", "avg_score": 0.92, "document_fields": [ {"key": "total_amount", "label": "合计金额", "value": "18"}, ], "warnings": [], }, ], }, tool_payload={"draft_only": True}, ) ) assert response.review_payload is not None slot_map = {item.key: item for item in response.review_payload.slot_cards} assert slot_map["amount"].value == "50.50元" document_field_labels = [ field.label for card in response.review_payload.document_cards for field in card.fields ] assert "金额" in document_field_labels def test_user_agent_prefers_larger_decimal_amount_from_ocr_text_candidates() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我上传了打车票据,帮我生成报销草稿", user_id="pytest", context_json={ "attachment_names": ["滴滴行程单.png"], "attachment_count": 1, "ocr_documents": [ { "filename": "滴滴行程单.png", "summary": "滴滴出行电子行程单", "text": "滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678", "avg_score": 0.94, "warnings": [], }, ], }, ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我上传了打车票据,帮我生成报销草稿", ontology=ontology, context_json={ "attachment_names": ["滴滴行程单.png"], "attachment_count": 1, "ocr_documents": [ { "filename": "滴滴行程单.png", "summary": "滴滴出行电子行程单", "text": "滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678", "avg_score": 0.94, "warnings": [], }, ], }, tool_payload={"draft_only": True}, ) ) assert response.review_payload is not None slot_map = {item.key: item for item in response.review_payload.slot_cards} assert slot_map["amount"].value == "13.40元" def test_user_agent_review_payload_keeps_document_preview_data() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我上传了打车票据,帮我生成报销草稿", user_id="pytest", context_json={ "attachment_names": ["滴滴行程单.png"], "attachment_count": 1, "ocr_documents": [ { "filename": "滴滴行程单.png", "summary": "滴滴出行电子行程单", "text": "滴滴出行 实付 13.4 元", "avg_score": 0.94, "preview_kind": "image", "preview_data_url": "data:image/png;base64,ZmFrZQ==", "warnings": [], }, ], }, ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我上传了打车票据,帮我生成报销草稿", ontology=ontology, context_json={ "attachment_names": ["滴滴行程单.png"], "attachment_count": 1, "ocr_documents": [ { "filename": "滴滴行程单.png", "summary": "滴滴出行电子行程单", "text": "滴滴出行 实付 13.4 元", "avg_score": 0.94, "preview_kind": "image", "preview_data_url": "data:image/png;base64,ZmFrZQ==", "warnings": [], }, ], }, tool_payload={"draft_only": True}, ) ) assert response.review_payload is not None assert response.review_payload.document_cards[0].preview_kind == "image" assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,") def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我上传了两张票据,帮我生成报销草稿", user_id="pytest", ) ) response = UserAgentService(db).respond( UserAgentRequest( run_id=ontology.run_id, user_id="pytest", message="我上传了两张票据,帮我生成报销草稿", ontology=ontology, context_json={ "attachment_names": ["滴滴行程单.png", "餐饮发票.jpg"], "attachment_count": 2, "ocr_documents": [ {"filename": "滴滴行程单.png", "summary": "滴滴出行 金额 32 元", "text": "滴滴出行 金额 32 元"}, {"filename": "餐饮发票.jpg", "summary": "餐饮发票 金额 68 元", "text": "餐饮发票 金额 68 元"}, ], }, tool_payload={ "pending_association_decision": True, "association_candidate_claim_no": "EXP-202605-008", }, ) ) assert response.review_payload is not None assert response.review_payload.can_proceed is False assert [item.action_type for item in response.review_payload.confirmation_actions] == [ "cancel_review", "edit_review", "link_to_existing_draft", "create_new_claim_from_documents", ] assert "EXP-202605-008" in response.answer