feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保 存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导 航,完善文档中心状态筛选和详情提示,报销创建和审批详情 页优化会话管理和费用明细交互,新增助手应用服务和预设动 作工具函数,补充单元测试覆盖。
This commit is contained in:
@@ -497,7 +497,7 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
|
||||
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
|
||||
with build_session() as db:
|
||||
monkeypatch.setattr(
|
||||
"app.services.agent_assets.resolve_onlyoffice_settings",
|
||||
"app.services.agent_asset_onlyoffice.resolve_onlyoffice_settings",
|
||||
lambda: OnlyOfficeRuntimeConfig(
|
||||
enabled=True,
|
||||
public_url="http://onlyoffice.example.com",
|
||||
|
||||
@@ -2883,6 +2883,133 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
|
||||
)
|
||||
|
||||
|
||||
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="application-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["employee"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-SUBMIT",
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网服务器上线部署",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"message": "旧 AI 预审提示不应保留到申请单提交结果。",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
def fail_ai_review(_claim: ExpenseClaim) -> dict[str, object]:
|
||||
raise AssertionError("费用申请提交不应进入 AI 预审")
|
||||
|
||||
monkeypatch.setattr(service, "_run_ai_submission_review", fail_ai_review)
|
||||
|
||||
submitted = service.submit_claim(claim_id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.invoice_count == 0
|
||||
assert submitted.items == []
|
||||
assert not any(
|
||||
isinstance(flag, dict) and flag.get("source") == "submission_review"
|
||||
for flag in submitted.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "application_submission"
|
||||
and flag.get("event_type") == "expense_application_submission"
|
||||
and flag.get("next_approval_stage") == "直属领导审批"
|
||||
for flag in submitted.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_approve_application_claim_to_completed_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-application-approve@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8112",
|
||||
name="李经理",
|
||||
email="manager-application-approve@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8113",
|
||||
name="张三",
|
||||
email="zhangsan-application-approve@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-APPROVE",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网服务器上线部署",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "审批完成"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("event_type") == "expense_application_approval"
|
||||
and flag.get("opinion") == "业务必要,同意申请。"
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
|
||||
@@ -649,6 +649,40 @@ def test_semantic_ontology_service_requires_attachment_for_meeting_application()
|
||||
assert "attachments" in result.missing_slots
|
||||
|
||||
|
||||
def test_semantic_ontology_service_treats_application_session_as_application_context() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=(
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
),
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"attachment_count": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert any(
|
||||
item.type == "document_type" and item.normalized_value == "expense_application"
|
||||
for item in result.entities
|
||||
)
|
||||
assert any(
|
||||
item.type == "workflow_stage" and item.normalized_value == "pre_approval"
|
||||
for item in result.entities
|
||||
)
|
||||
assert "expense_type" in result.missing_slots
|
||||
assert "amount" in result.missing_slots
|
||||
|
||||
|
||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
@@ -228,6 +229,50 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
|
||||
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
|
||||
|
||||
|
||||
def test_conversation_hydration_preserves_incoming_application_time_context() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = AgentConversationService(db)
|
||||
conversation = service.get_or_create_conversation(
|
||||
conversation_id="conv-application-time-context",
|
||||
user_id="emp-application-time@example.com",
|
||||
source="user_message",
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"business_time_context": {
|
||||
"mode": "single",
|
||||
"start_date": "2026-05-01",
|
||||
"end_date": "2026-05-01",
|
||||
"display_value": "2026-05-01",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
stale_context = service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json={"session_type": "application", "entry_source": "application"},
|
||||
message="apply travel expense",
|
||||
)
|
||||
fresh_context = service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"business_time_context": {
|
||||
"mode": "single",
|
||||
"start_date": "2026-05-25",
|
||||
"end_date": "2026-05-25",
|
||||
"display_value": "2026-05-25",
|
||||
},
|
||||
},
|
||||
message="apply travel expense",
|
||||
)
|
||||
|
||||
assert "business_time_context" not in stale_context
|
||||
assert fresh_context["business_time_context"]["start_date"] == "2026-05-25"
|
||||
|
||||
|
||||
def test_conversation_scope_creates_new_session_for_different_claim() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -543,3 +588,225 @@ def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_ex
|
||||
assert result.get("draft_payload") is None
|
||||
assert "请先在下面选择报销场景" in result["answer"]
|
||||
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]
|
||||
|
||||
|
||||
def test_orchestrator_application_session_does_not_use_reimbursement_scene_prompt(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = OrchestratorService(db).run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-session@example.com",
|
||||
message=message,
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = response.result
|
||||
assert response.status == "blocked"
|
||||
assert response.trace_summary.scenario == "expense"
|
||||
assert "费用申请" in result["answer"]
|
||||
assert "| 发生时间 | 2026-05-25" in result["answer"]
|
||||
assert "请先在下面选择报销场景" not in result["answer"]
|
||||
assert result.get("review_payload") is None
|
||||
|
||||
|
||||
def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
"manager_name": "陈硕",
|
||||
}
|
||||
with session_factory() as db:
|
||||
service = OrchestratorService(db)
|
||||
|
||||
first = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
message=initial_message,
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
second = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
third = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="预计总费用:12000元",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
fourth = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="确认提交",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
assert first.status == "blocked"
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in first.result["answer"]
|
||||
assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
|
||||
assert "当前还需要补充:预计金额/预算" in second.result["answer"]
|
||||
assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer"
|
||||
assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "预计总费用:"
|
||||
|
||||
assert "这是模拟的费用申请结果" in third.result["answer"]
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"]
|
||||
assert "请核对上述信息无误" in third.result["answer"]
|
||||
assert "[确认](#application-submit)" in third.result["answer"]
|
||||
assert third.status == "blocked"
|
||||
assert third.result["requires_confirmation"] is True
|
||||
assert third.result["suggested_actions"] == []
|
||||
|
||||
assert fourth.status == "succeeded"
|
||||
assert fourth.result["clarification_required"] is False
|
||||
assert fourth.result["missing_slots"] == []
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in fourth.result["answer"]
|
||||
assert "当前状态:陈硕审核中" in fourth.result["answer"]
|
||||
assert fourth.result["suggested_actions"] == []
|
||||
application_claims = [
|
||||
claim
|
||||
for claim in db.query(ExpenseClaim).all()
|
||||
if claim.claim_no.startswith("APP-20260525-")
|
||||
]
|
||||
assert len(application_claims) == 1
|
||||
assert application_claims[0].status == "submitted"
|
||||
assert application_claims[0].approval_stage == "直属领导审批"
|
||||
assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no
|
||||
|
||||
|
||||
def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
"manager_name": "陈硕",
|
||||
}
|
||||
with session_factory() as db:
|
||||
service = OrchestratorService(db)
|
||||
|
||||
first = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
message=initial_message,
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
preview = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="预计总费用:12000元",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
def approval_required_parse_for_run(self, request, run_id): # noqa: ANN001
|
||||
return OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
entities=[],
|
||||
permission=OntologyPermission(
|
||||
level="approval_required",
|
||||
allowed=False,
|
||||
reason="操作类请求需要人工审批确认。",
|
||||
),
|
||||
confidence=0.95,
|
||||
missing_slots=[],
|
||||
ambiguity=[],
|
||||
clarification_required=False,
|
||||
clarification_question=None,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.ontology.SemanticOntologyService.parse_for_run",
|
||||
approval_required_parse_for_run,
|
||||
)
|
||||
submitted = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="确认提交",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
assert preview.status == "blocked"
|
||||
assert submitted.status == "succeeded"
|
||||
assert submitted.requires_confirmation is False
|
||||
assert "操作类请求需要人工审批确认" not in submitted.result["answer"]
|
||||
assert "当前仅返回确认摘要" not in submitted.result["answer"]
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"]
|
||||
assert submitted.result["draft_payload"]["status"] == "submitted"
|
||||
|
||||
@@ -364,6 +364,70 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
|
||||
def test_approve_application_endpoint_completes_after_direct_manager_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
id="mgr-application-approve-1",
|
||||
employee_no="E21002",
|
||||
name="李经理",
|
||||
email="manager-application-approve-api@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
id="emp-application-approve-1",
|
||||
employee_no="E11002",
|
||||
name="张三",
|
||||
email="zhangsan-application-approve-api@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-application-approve-1",
|
||||
claim_no="APP-20260525-API001",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id="dept-1",
|
||||
department_name="交付部",
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网服务器上线部署",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 25, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-application-approve-1/approve",
|
||||
json={"opinion": "业务必要,同意申请。"},
|
||||
headers={
|
||||
"X-Auth-Username": "manager-application-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-application-approve-api@example.com",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "approved"
|
||||
assert payload["approval_stage"] == "审批完成"
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["event_type"] == "expense_application_approval"
|
||||
and item["opinion"] == "业务必要,同意申请。"
|
||||
and item["operator"] == "李经理"
|
||||
and item["next_status"] == "approved"
|
||||
and item["next_approval_stage"] == "审批完成"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
|
||||
|
||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||
preview_bytes = b"fake-preview-png"
|
||||
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
||||
|
||||
@@ -29,6 +29,41 @@ def build_session_factory() -> sessionmaker[Session]:
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def build_application_user_agent_response(
|
||||
db: Session,
|
||||
message: str,
|
||||
*,
|
||||
history: list[dict[str, object]] | None = None,
|
||||
context_overrides: dict[str, object] | None = None,
|
||||
):
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"attachment_count": 0,
|
||||
}
|
||||
if context_overrides:
|
||||
context_json.update(context_overrides)
|
||||
if history is not None:
|
||||
context_json["conversation_history"] = history
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
return UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={"clarification_required": ontology.clarification_required},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_user_agent_query_returns_readable_answer_and_actions() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -137,6 +172,216 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
|
||||
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
|
||||
|
||||
|
||||
def test_user_agent_application_context_uses_application_language() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"attachment_count": 0,
|
||||
}
|
||||
with session_factory() as db:
|
||||
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={"clarification_required": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert "费用申请" in response.answer
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||
assert "支持上海国网服务器部署" in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert "请先在下面选择报销场景" not in response.answer
|
||||
assert response.review_payload is None
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = "发生时间:2026-05-25\n去上海出差3天,支撑上海国网服务器部署"
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(db, message)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||
assert "| 地点 | 上海 |" in response.answer
|
||||
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
||||
assert "当前还需要先补充:申请事由" not in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
|
||||
|
||||
def test_user_agent_application_uses_selected_time_and_natural_language_fields() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = "出差上海,支撑国网服务器上线部署"
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"business_time_context": {
|
||||
"mode": "single",
|
||||
"start_date": "2026-05-25",
|
||||
"end_date": "2026-05-25",
|
||||
"display_value": "2026-05-25",
|
||||
},
|
||||
}
|
||||
with session_factory() as db:
|
||||
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={"clarification_required": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 地点 | 上海 |" in response.answer
|
||||
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_asks_amount_after_transport_choice() -> None:
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"飞机",
|
||||
history=[{"role": "user", "content": initial_message}],
|
||||
)
|
||||
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "当前还需要补充:预计金额/预算" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "预计总费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"地点:上海\n事由:支撑国网服务器部署\n天数:3天",
|
||||
)
|
||||
|
||||
assert "当前还需要补充:发生时间、出行方式、预计金额/预算" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "申请时间段:\n出行方式:\n预计总费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"预计总费用:12000元",
|
||||
history=[
|
||||
{"role": "user", "content": initial_message},
|
||||
{"role": "user", "content": "飞机"},
|
||||
],
|
||||
)
|
||||
|
||||
assert "这是模拟的费用申请结果" in response.answer
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in response.answer
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "| 预计总费用 | 12000元 |" in response.answer
|
||||
assert "请核对上述信息无误" in response.answer
|
||||
assert "[确认](#application-submit)" in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
assert response.suggested_actions == []
|
||||
|
||||
|
||||
def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
preview_answer = (
|
||||
"这是模拟的费用申请结果,请核对:\n"
|
||||
"| 字段 | 内容 |\n"
|
||||
"| --- | --- |\n"
|
||||
"| 申请类型 | 差旅费用申请 |\n"
|
||||
"| 发生时间 | 2026-05-25 |\n"
|
||||
"| 地点 | 上海 |\n"
|
||||
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||
"| 天数 | 3天 |\n"
|
||||
"| 出行方式 | 飞机 |\n"
|
||||
"| 预计总费用 | 12000元 |\n\n"
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"确认提交",
|
||||
context_overrides={"manager_name": "陈硕"},
|
||||
history=[
|
||||
{"role": "user", "content": initial_message},
|
||||
{"role": "user", "content": "飞机"},
|
||||
{"role": "user", "content": "预计总费用:12000元"},
|
||||
{"role": "assistant", "content": preview_answer},
|
||||
],
|
||||
)
|
||||
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in response.answer
|
||||
assert "当前状态:陈硕审核中" in response.answer
|
||||
assert "预算占用参考" in response.answer
|
||||
assert "APP-20260525-" in response.answer
|
||||
assert response.suggested_actions == []
|
||||
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("APP-20260525-%")).one()
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert claim.expense_type == "travel_application"
|
||||
assert claim.amount == Decimal("12000.00")
|
||||
assert claim.employee_name == "pytest"
|
||||
|
||||
|
||||
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
Reference in New Issue
Block a user