feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
80
server/tests/test_agent_feedback_service.py
Normal file
80
server/tests/test_agent_feedback_service.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.agent_feedback import AgentFeedbackCreate
|
||||
from app.services.agent_feedback import AgentFeedbackService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_agent_feedback_service_records_rating_and_low_reason() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentFeedbackService(db)
|
||||
|
||||
feedback = service.create_feedback(
|
||||
AgentFeedbackCreate(
|
||||
run_id="run-feedback-001",
|
||||
conversation_id="conv-feedback-001",
|
||||
user_id="wenjing.li",
|
||||
agent="user_agent",
|
||||
source="user_message",
|
||||
session_type="application",
|
||||
operation_type="submit_application",
|
||||
operation_status="succeeded",
|
||||
rating=2,
|
||||
reason="意图识别不准",
|
||||
context_json={"route_reason": "model_route"},
|
||||
)
|
||||
)
|
||||
summary = service.summarize_feedback(agent="user_agent", session_type="application")
|
||||
|
||||
assert feedback.feedback_id.startswith("fb_")
|
||||
assert feedback.rating == 2
|
||||
assert feedback.reason == "意图识别不准"
|
||||
assert summary.total_feedback == 1
|
||||
assert summary.average_rating == 2.0
|
||||
assert summary.low_rating_count == 1
|
||||
assert summary.rating_distribution["2"] == 1
|
||||
assert summary.recent_low_feedback[0]["run_id"] == "run-feedback-001"
|
||||
|
||||
|
||||
def test_agent_feedback_summary_keeps_five_star_distribution() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentFeedbackService(db)
|
||||
for rating in (5, 4, 5):
|
||||
service.create_feedback(
|
||||
AgentFeedbackCreate(
|
||||
run_id=f"run-rating-{rating}",
|
||||
user_id="wenjing.li",
|
||||
agent="user_agent",
|
||||
session_type="expense",
|
||||
operation_status="succeeded",
|
||||
rating=rating,
|
||||
)
|
||||
)
|
||||
|
||||
summary = service.summarize_feedback(session_type="expense")
|
||||
|
||||
assert summary.total_feedback == 3
|
||||
assert summary.average_rating == 4.67
|
||||
assert summary.low_rating_count == 0
|
||||
assert summary.rating_distribution == {
|
||||
"1": 0,
|
||||
"2": 0,
|
||||
"3": 0,
|
||||
"4": 1,
|
||||
"5": 2,
|
||||
}
|
||||
@@ -104,3 +104,43 @@ def test_agent_run_service_updates_existing_tool_call() -> None:
|
||||
assert len(fetched.tool_calls) == 1
|
||||
assert fetched.tool_calls[0].status == "succeeded"
|
||||
assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"}
|
||||
|
||||
|
||||
def test_agent_run_service_summarizes_model_and_tool_failures() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
run = service.create_run(
|
||||
agent=AgentName.ORCHESTRATOR.value,
|
||||
source=AgentRunSource.USER_MESSAGE.value,
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
ontology_json={
|
||||
"parse_strategy": "rule_fallback",
|
||||
"model_invocation_summary": {
|
||||
"model_guardrail_reason": "model_conflicts_with_application_stage_signal"
|
||||
},
|
||||
},
|
||||
)
|
||||
service.record_tool_call(
|
||||
run_id=run.run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="semantic_ontology.main",
|
||||
request_json={"stage": "semantic_parse"},
|
||||
response_json={"model_guardrail_reason": "model_conflicts_with_application_stage_signal"},
|
||||
status="failed",
|
||||
duration_ms=18,
|
||||
error_message="model_conflicts_with_application_stage_signal",
|
||||
)
|
||||
|
||||
stats = service.summarize_runs(agent=AgentName.ORCHESTRATOR.value, limit=20)
|
||||
|
||||
assert stats.total_runs >= 1
|
||||
assert stats.tool_call_count >= 1
|
||||
assert stats.failed_tool_call_count >= 1
|
||||
assert stats.llm_call_count >= 1
|
||||
assert stats.failed_llm_call_count >= 1
|
||||
assert stats.model_fallback_count >= 1
|
||||
assert stats.model_guardrail_count >= 1
|
||||
assert any(
|
||||
item.get("tool_name") == "semantic_ontology.main"
|
||||
for item in stats.recent_errors
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.models.employee import Employee
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.user_session_metric import UserSessionMetric
|
||||
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_scheduler import HermesScheduler
|
||||
@@ -167,6 +168,36 @@ def _build_claim(
|
||||
)
|
||||
|
||||
|
||||
def add_closed_user_session(
|
||||
db: Session,
|
||||
*,
|
||||
session_id: str,
|
||||
username: str,
|
||||
display_name: str = "",
|
||||
employee_no: str = "",
|
||||
duration_ms: int = 30 * 60 * 1000,
|
||||
) -> None:
|
||||
logout_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
login_at = logout_at - timedelta(milliseconds=duration_ms)
|
||||
db.add(
|
||||
UserSessionMetric(
|
||||
session_id=session_id,
|
||||
username=username,
|
||||
display_name=display_name or username,
|
||||
employee_no=employee_no,
|
||||
email=username if "@" in username else "",
|
||||
login_at=login_at,
|
||||
logout_at=logout_at,
|
||||
last_activity_at=logout_at,
|
||||
duration_ms=duration_ms,
|
||||
activity_event_count=8,
|
||||
logout_reason="manual",
|
||||
status="closed",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_service_scans_snapshots_and_filters_approval_scene() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -238,6 +269,18 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_profile_data(db)
|
||||
EmployeeBehaviorProfileService(db).refresh_employee_profiles(
|
||||
employee_id="emp-main",
|
||||
window_days=(90,),
|
||||
expense_type_scope="overall",
|
||||
)
|
||||
add_closed_user_session(
|
||||
db,
|
||||
session_id="session-employee-current",
|
||||
username="zhangsan@example.com",
|
||||
display_name="张三",
|
||||
employee_no="E1001",
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -266,6 +309,9 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None:
|
||||
assert {item["profile_type"] for item in payload["profiles"]} >= {"expense", "ai_usage"}
|
||||
ai_profile = next(item for item in payload["profiles"] if item["profile_type"] == "ai_usage")
|
||||
assert ai_profile["metrics"]["ai_run_duration_ms"] == 120
|
||||
assert ai_profile["metrics"]["online_duration_ms"] == 30 * 60 * 1000
|
||||
assert ai_profile["metrics"]["usage_duration_ms"] == 30 * 60 * 1000
|
||||
assert ai_profile["metrics"]["usage_duration_mode"] == "online_session"
|
||||
assert payload["profile_tags"]
|
||||
assert payload["radar"]["dimensions"]
|
||||
|
||||
@@ -336,6 +382,98 @@ def test_current_admin_profile_endpoint_returns_account_usage_profile() -> None:
|
||||
assert payload["radar"]["dimensions"]
|
||||
|
||||
|
||||
def test_current_admin_profile_endpoint_uses_online_session_without_agent_runs() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
add_closed_user_session(
|
||||
db,
|
||||
session_id="session-admin-online",
|
||||
username="admin",
|
||||
display_name="admin",
|
||||
duration_ms=12 * 60 * 1000,
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/employee-profiles/me/latest",
|
||||
params={
|
||||
"scene": "operations",
|
||||
"window_days": 90,
|
||||
"expense_type_scope": "overall",
|
||||
},
|
||||
headers={"x-auth-username": "admin", "x-auth-name": "admin", "x-auth-is-admin": "true"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["employee_id"] == "admin"
|
||||
assert payload["empty_reason"] == ""
|
||||
metrics = payload["profiles"][0]["metrics"]
|
||||
assert metrics["ai_run_count"] == 0
|
||||
assert metrics["online_duration_ms"] == 12 * 60 * 1000
|
||||
assert metrics["usage_duration_ms"] == 12 * 60 * 1000
|
||||
assert metrics["usage_duration_mode"] == "online_session"
|
||||
|
||||
|
||||
def test_finish_session_endpoint_closes_active_session() -> None:
|
||||
session_factory = build_session_factory()
|
||||
login_at = datetime.now(UTC) - timedelta(minutes=9)
|
||||
with session_factory() as db:
|
||||
db.add(
|
||||
UserSessionMetric(
|
||||
session_id="session-active-finish",
|
||||
username="zhangsan@example.com",
|
||||
display_name="张三",
|
||||
employee_no="E1001",
|
||||
email="zhangsan@example.com",
|
||||
login_at=login_at,
|
||||
last_activity_at=login_at,
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/auth/sessions/session-active-finish/finish",
|
||||
json={
|
||||
"reason": "manual",
|
||||
"lastActivityAt": datetime.now(UTC).isoformat(),
|
||||
"activityEventCount": 5,
|
||||
"pagePath": "/workbench",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["sessionId"] == "session-active-finish"
|
||||
assert payload["durationMs"] > 0
|
||||
with session_factory() as db:
|
||||
session = db.query(UserSessionMetric).filter_by(session_id="session-active-finish").one()
|
||||
assert session.status == "closed"
|
||||
assert session.activity_event_count == 5
|
||||
|
||||
|
||||
def test_hermes_scheduler_parses_weekly_profile_cron() -> None:
|
||||
scheduler = HermesScheduler()
|
||||
|
||||
|
||||
@@ -2489,7 +2489,7 @@ def test_list_claims_scopes_to_current_user_id_even_when_names_duplicate() -> No
|
||||
assert claims[0].claim_no == "EXP-DUP-001"
|
||||
|
||||
|
||||
def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
def test_list_claims_limits_finance_to_personal_records() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
@@ -2501,8 +2501,8 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-101",
|
||||
employee_name="甲",
|
||||
claim_no="EXP-FIN-OWN",
|
||||
employee_name="财务",
|
||||
department_name="A部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
@@ -2518,7 +2518,7 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-102",
|
||||
claim_no="EXP-FIN-OTHER-DRAFT",
|
||||
employee_name="乙",
|
||||
department_name="B部",
|
||||
project_code="PRJ-B",
|
||||
@@ -2529,9 +2529,9 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="completed",
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
@@ -2541,10 +2541,10 @@ def test_list_claims_allows_finance_to_view_all_records() -> None:
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-FIN-101"
|
||||
assert claims[0].claim_no == "EXP-FIN-OWN"
|
||||
|
||||
|
||||
def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
def test_list_claims_limits_executive_to_personal_records() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive@example.com",
|
||||
name="高管",
|
||||
@@ -2556,8 +2556,8 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-EXE-101",
|
||||
employee_name="甲",
|
||||
claim_no="EXP-EXE-OWN",
|
||||
employee_name="高管",
|
||||
department_name="A部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
@@ -2573,7 +2573,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-EXE-102",
|
||||
claim_no="EXP-EXE-OTHER-DRAFT",
|
||||
employee_name="乙",
|
||||
department_name="B部",
|
||||
project_code="PRJ-B",
|
||||
@@ -2584,9 +2584,9 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="completed",
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
@@ -2596,7 +2596,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-EXE-101"
|
||||
assert claims[0].claim_no == "EXP-EXE-OWN"
|
||||
|
||||
|
||||
def test_list_claims_keeps_own_archived_claim_for_finance_applicant() -> None:
|
||||
@@ -3411,7 +3411,20 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "application_detail",
|
||||
"application_detail": {
|
||||
"application_type": "差旅费用申请",
|
||||
"time": "2026-05-25 至 2026-05-27",
|
||||
"location": "上海",
|
||||
"reason": "支撑国网服务器上线部署",
|
||||
"days": "3 天",
|
||||
"transport_mode": "高铁",
|
||||
"amount": "12000.00",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
@@ -3475,6 +3488,10 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
and flag.get("source") == "application_handoff"
|
||||
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
||||
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
|
||||
and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海"
|
||||
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
|
||||
and flag.get("application_detail", {}).get("application_days") == "3 天"
|
||||
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
|
||||
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
||||
and flag.get("budget_opinion") == "预算额度可承接,同意。"
|
||||
for flag in generated_draft.risk_flags_json
|
||||
@@ -3493,6 +3510,114 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-budget-monitor-application@example.com",
|
||||
name="李预算经理",
|
||||
role_codes=["manager", "budget_monitor", "executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
budget_role = _seed_budget_monitor_role(db)
|
||||
department = OrganizationUnit(
|
||||
unit_code="DELIVERY-BUDGET-MERGED",
|
||||
name="交付部",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="E8112-MERGED",
|
||||
name="李预算经理",
|
||||
email="manager-budget-monitor-application@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8113-MERGED",
|
||||
name="张三",
|
||||
email="zhangsan-budget-monitor-application@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([department, manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-MERGED",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id=department.id,
|
||||
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,
|
||||
manager_user,
|
||||
opinion="业务必要且预算可承接,同意申请。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "审批完成"
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("event_type") == "expense_application_approval"
|
||||
and flag.get("label") == "领导及预算审核通过"
|
||||
and flag.get("opinion") == "业务必要且预算可承接,同意申请。"
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
and flag.get("budget_approval_merged") is True
|
||||
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_monitor"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
assert generated_draft.status == "draft"
|
||||
assert generated_draft.expense_type == "travel"
|
||||
reviewer_claims = ExpenseClaimService(db).list_claims(manager_user)
|
||||
assert all(claim.claim_no != generated_draft.claim_no for claim in reviewer_claims)
|
||||
applicant_claims = ExpenseClaimService(db).list_claims(
|
||||
CurrentUserContext(
|
||||
username="zhangsan-budget-monitor-application@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
)
|
||||
assert any(claim.claim_no == generated_draft.claim_no for claim in applicant_claims)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "application_handoff"
|
||||
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
||||
and flag.get("application_claim_no") == "APP-20260525-MERGED"
|
||||
and flag.get("leader_opinion") == "业务必要且预算可承接,同意申请。"
|
||||
and flag.get("budget_opinion") == "业务必要且预算可承接,同意申请。"
|
||||
for flag in generated_draft.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_return_application_claim_records_return_node_and_opinion() -> None:
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-application-return@example.com",
|
||||
|
||||
155
server/tests/test_finance_dashboard_service.py
Normal file
155
server/tests/test_finance_dashboard_service.py
Normal file
@@ -0,0 +1,155 @@
|
||||
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.budget import BudgetAllocation, BudgetTransaction
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.services.finance_dashboard import FinanceDashboardService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="CLM-DASH-001",
|
||||
employee_name="陈雨晴",
|
||||
department_name="财务部",
|
||||
expense_type="travel",
|
||||
reason="项目差旅",
|
||||
location="广州",
|
||||
amount=Decimal("1200.00"),
|
||||
invoice_count=2,
|
||||
occurred_at=now - timedelta(hours=4),
|
||||
submitted_at=now - timedelta(hours=3),
|
||||
status="submitted",
|
||||
approval_stage="finance_review",
|
||||
risk_flags_json=[],
|
||||
hermes_risk_flag=False,
|
||||
created_at=now - timedelta(hours=4),
|
||||
updated_at=now - timedelta(hours=3),
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="CLM-DASH-002",
|
||||
employee_name="顾成宇",
|
||||
department_name="研发中心",
|
||||
expense_type="meal",
|
||||
reason="客户招待",
|
||||
location="深圳",
|
||||
amount=Decimal("800.00"),
|
||||
invoice_count=1,
|
||||
occurred_at=now - timedelta(days=1, hours=2),
|
||||
submitted_at=now - timedelta(days=1, hours=1),
|
||||
status="paid",
|
||||
approval_stage="payment",
|
||||
risk_flags_json=[{"label": "招待费超标"}],
|
||||
hermes_risk_flag=False,
|
||||
created_at=now - timedelta(days=1, hours=2),
|
||||
updated_at=now - timedelta(days=1),
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="CLM-DASH-003",
|
||||
employee_name="李文静",
|
||||
department_name="行政部",
|
||||
expense_type="office",
|
||||
reason="办公用品",
|
||||
location="珠海",
|
||||
amount=Decimal("5000.00"),
|
||||
invoice_count=3,
|
||||
occurred_at=now - timedelta(hours=1),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage=None,
|
||||
risk_flags_json=[],
|
||||
hermes_risk_flag=False,
|
||||
created_at=now - timedelta(hours=1),
|
||||
updated_at=now - timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.add(
|
||||
RiskObservation(
|
||||
observation_key="risk-dashboard-001",
|
||||
subject_type="expense_claim",
|
||||
subject_key="CLM-DASH-002",
|
||||
subject_label="CLM-DASH-002",
|
||||
claim_no="CLM-DASH-002",
|
||||
risk_type="policy",
|
||||
risk_signal="amount_outlier",
|
||||
title="金额异常",
|
||||
risk_level="high",
|
||||
status="pending_review",
|
||||
created_at=now - timedelta(hours=2),
|
||||
updated_at=now - timedelta(hours=2),
|
||||
)
|
||||
)
|
||||
allocation = BudgetAllocation(
|
||||
budget_no="BUD-DASH-001",
|
||||
fiscal_year=now.year,
|
||||
period_type="year",
|
||||
period_key=f"{now.year}",
|
||||
department_name="财务部",
|
||||
subject_code="travel",
|
||||
subject_name="差旅费",
|
||||
original_amount=Decimal("10000.00"),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="warn",
|
||||
)
|
||||
db.add(allocation)
|
||||
db.flush()
|
||||
db.add(
|
||||
BudgetTransaction(
|
||||
transaction_no="BTX-DASH-001",
|
||||
allocation_id=allocation.id,
|
||||
source_type="expense_claim",
|
||||
source_id="CLM-DASH-002",
|
||||
source_no="CLM-DASH-002",
|
||||
transaction_type="consume",
|
||||
amount=Decimal("4000.00"),
|
||||
before_available_amount=Decimal("10000.00"),
|
||||
after_available_amount=Decimal("6000.00"),
|
||||
operator="finance",
|
||||
reason="测试消耗",
|
||||
created_at=now - timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
dashboard = FinanceDashboardService(db).build_dashboard(
|
||||
range_key="近10日",
|
||||
trend_range="近7天",
|
||||
department_range="本月",
|
||||
)
|
||||
|
||||
assert dashboard.has_real_data is True
|
||||
assert dashboard.totals["pendingCount"] == 1
|
||||
assert dashboard.totals["pendingAmount"] == 1200.0
|
||||
assert dashboard.totals["riskCount"] == 1
|
||||
assert dashboard.trend["applications"][-1] >= 1
|
||||
assert dashboard.spend_by_category[0]["value"] == 1200.0
|
||||
assert dashboard.department_ranking[0]["name"] == "财务部"
|
||||
assert dashboard.department_ranking[0]["amount"] == 1200.0
|
||||
assert dashboard.budget_summary["ratio"] == 40.0
|
||||
assert dashboard.budget_summary["used"] == "¥4,000"
|
||||
818
server/tests/test_financial_risk_graph_algorithm.py
Normal file
818
server/tests/test_financial_risk_graph_algorithm.py
Normal file
@@ -0,0 +1,818 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.algorithem.risk_graph import (
|
||||
RiskGraphClaimItemSnapshot,
|
||||
RiskGraphClaimSnapshot,
|
||||
RiskGraphEvaluationContext,
|
||||
RiskHistoryStats,
|
||||
evaluate_financial_risk_graph,
|
||||
map_ontology_to_risk_graph,
|
||||
)
|
||||
from app.algorithem.risk_graph.anomaly_models import AnomalyPoint, MultiModelAnomalyDetector
|
||||
from app.algorithem.risk_graph.control_effect import ControlEffectAnalyzer
|
||||
from app.algorithem.risk_graph.counterfactual import CounterfactualRiskAdvisor
|
||||
from app.algorithem.risk_graph.engine import _apply_evidence_source_gate
|
||||
from app.algorithem.risk_graph.entity_resolution import (
|
||||
CanonicalEntityRegistry,
|
||||
FinancialEntityResolver,
|
||||
)
|
||||
from app.algorithem.risk_graph.evaluation_cases import default_risk_evaluation_cases
|
||||
from app.algorithem.risk_graph.features import HeterogeneousRiskGraphFeatureBuilder
|
||||
from app.algorithem.risk_graph.lineage import RiskDataLineageBuilder
|
||||
from app.algorithem.risk_graph.models import RiskEvidence, RiskGraphEdge
|
||||
from app.algorithem.risk_graph.policy_knowledge_contract import (
|
||||
PolicyKnowledgeItem,
|
||||
PolicyKnowledgeOrganizingReport,
|
||||
PolicySourceRef,
|
||||
build_policy_ref,
|
||||
)
|
||||
from app.algorithem.risk_graph.process_mining import (
|
||||
ConformanceRiskDetector,
|
||||
ObjectCentricProcessMiner,
|
||||
)
|
||||
from app.algorithem.risk_graph.rule_discovery import CandidateRiskRuleDiscovery
|
||||
from app.algorithem.risk_graph.temporal import TemporalRiskGraphMonitor
|
||||
from app.db.base import Base
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||
from app.models.hermes_report import HermesRiskReport
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.schemas.risk_observation import RiskObservationFeedbackCreate
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
|
||||
def test_risk_graph_engine_combines_rule_anomaly_graph_policy_and_history() -> None:
|
||||
target = _snapshot(
|
||||
"c-risk",
|
||||
"BX-001",
|
||||
amount="12000",
|
||||
risk_flags=[{"risk_signal": "preapproval_absent", "severity": "high"}],
|
||||
items=[_item("i-risk", "hotel", "12000", invoice_id="INV-001")],
|
||||
)
|
||||
duplicate = _snapshot(
|
||||
"c-dup",
|
||||
"BX-002",
|
||||
amount="900",
|
||||
employee_name="李四",
|
||||
items=[_item("i-dup", "hotel", "900", invoice_id="INV-001")],
|
||||
)
|
||||
peers = [
|
||||
_snapshot(
|
||||
f"c-peer-{index}",
|
||||
f"BX-10{index}",
|
||||
amount=str(amount),
|
||||
employee_name=f"同事{index}",
|
||||
)
|
||||
for index, amount in enumerate([700, 800, 900, 1000], start=1)
|
||||
]
|
||||
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[target, duplicate, *peers],
|
||||
target_claim_ids={"c-risk"},
|
||||
history_stats=[
|
||||
RiskHistoryStats(
|
||||
risk_signal="duplicate_invoice",
|
||||
expense_type="travel",
|
||||
similar_case_count=10,
|
||||
confirmed_count=7,
|
||||
false_positive_count=1,
|
||||
returned_count=2,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.observations) == 1
|
||||
observation = result.observations[0]
|
||||
assert observation.risk_signal == "duplicate_invoice"
|
||||
assert observation.risk_level == "high"
|
||||
assert observation.risk_score >= 80
|
||||
assert observation.automation_mode == "semi_auto_review"
|
||||
assert observation.contribution_scores["S_rule"] == 82
|
||||
assert observation.contribution_scores["S_anomaly"] >= 90
|
||||
assert observation.contribution_scores["S_graph"] >= 95
|
||||
assert observation.contribution_scores["S_policy"] > 0
|
||||
assert observation.contribution_scores["S_history"] > 0
|
||||
evidence_sources = {item.source for item in observation.evidence}
|
||||
assert len(evidence_sources) >= 2
|
||||
assert observation.decision_trace["raw_risk_score"] >= observation.risk_score
|
||||
assert observation.decision_trace["evidence_source_count"] >= 2
|
||||
assert observation.decision_trace["evidence_source_gate"] == "passed"
|
||||
assert observation.decision_trace["algorithm_version"] == "financial_risk_graph.v1"
|
||||
assert observation.decision_trace["decision_row"] == "high:70<=score<90"
|
||||
assert observation.decision_trace["feature_contributions_json"][0]["feature"] == "S_rule"
|
||||
assert observation.decision_trace["explanation_template_key"] == "risk.duplicate_invoice.high"
|
||||
assert observation.decision_trace["sampling_strategy"]["strategy"] == "focused_review"
|
||||
assert observation.decision_trace["sampling_strategy"]["replay_bucket"] == "high_risk"
|
||||
assert "c-dup" in observation.similar_case_claim_ids or any(
|
||||
"c-dup" in key["target_key"] for key in observation.graph_edge_keys
|
||||
)
|
||||
node_payloads = [node.as_dict() for node in result.nodes]
|
||||
assert all("canonical_id" in item for item in node_payloads)
|
||||
assert all("ontology_parse_id" in item for item in node_payloads)
|
||||
assert all("ontology_version" in item for item in node_payloads)
|
||||
edge_payloads = [edge.as_dict() for edge in result.edges]
|
||||
assert all(item["source"] for item in edge_payloads)
|
||||
|
||||
|
||||
def test_high_risk_score_is_capped_when_only_one_evidence_source() -> None:
|
||||
score, gate = _apply_evidence_source_gate(
|
||||
92,
|
||||
[
|
||||
RiskEvidence(
|
||||
code="rule_signal",
|
||||
title="Rule signal",
|
||||
detail="Only one evidence source is present.",
|
||||
source="rule",
|
||||
score=92,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert score == 69
|
||||
assert gate == "capped_high_risk_single_source"
|
||||
|
||||
|
||||
def test_heterogeneous_graph_feature_builder_outputs_core_features() -> None:
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[
|
||||
_snapshot(
|
||||
"c-risk",
|
||||
"BX-001",
|
||||
amount="12000",
|
||||
items=[_item("i-risk", "hotel", "12000", invoice_id="INV-001")],
|
||||
),
|
||||
_snapshot(
|
||||
"c-dup",
|
||||
"BX-002",
|
||||
amount="900",
|
||||
employee_name="李四",
|
||||
items=[_item("i-dup", "hotel", "900", invoice_id="INV-001")],
|
||||
),
|
||||
],
|
||||
target_claim_ids={"c-risk"},
|
||||
)
|
||||
)
|
||||
|
||||
features = HeterogeneousRiskGraphFeatureBuilder().build(
|
||||
result.nodes,
|
||||
result.edges,
|
||||
risk_node_keys={"claim:c-risk"},
|
||||
)
|
||||
|
||||
assert features.node_type_counts["claim"] == 2
|
||||
assert features.edge_type_counts["claim_duplicate_invoice"] >= 1
|
||||
assert features.meta_path_counts
|
||||
assert features.clusters[0]["size"] >= 2
|
||||
assert features.neighbor_risk_density["claim:c-dup"] > 0
|
||||
|
||||
|
||||
def test_temporal_risk_graph_monitor_detects_edge_changes() -> None:
|
||||
previous_edges = [
|
||||
RiskGraphEdge(
|
||||
source_key="employee:e1",
|
||||
target_key="claim:c1",
|
||||
edge_type="employee_submits_claim",
|
||||
),
|
||||
]
|
||||
current_edges = [
|
||||
RiskGraphEdge(
|
||||
source_key="employee:e1",
|
||||
target_key=f"claim:c{index}",
|
||||
edge_type="employee_submits_claim",
|
||||
)
|
||||
for index in range(2, 5)
|
||||
]
|
||||
|
||||
diff = TemporalRiskGraphMonitor().monitor(
|
||||
previous_edges,
|
||||
current_edges,
|
||||
risk_node_keys={"claim:c2"},
|
||||
)
|
||||
change_types = {item.change_type for item in diff.changes}
|
||||
|
||||
assert "relationship_added" in change_types
|
||||
assert "relationship_removed" in change_types
|
||||
assert "relationship_surge" in change_types
|
||||
assert "target_migration" in change_types
|
||||
assert "risk_propagation" in change_types
|
||||
assert diff.edge_type_delta["employee_submits_claim"] == 2
|
||||
|
||||
|
||||
def test_financial_entity_resolver_and_registry_merge_aliases() -> None:
|
||||
resolver = FinancialEntityResolver()
|
||||
registry = CanonicalEntityRegistry()
|
||||
|
||||
first = resolver.resolve("supplier", " 上海 差旅-供应商 ", source="invoice")
|
||||
second = resolver.resolve("merchant", "上海差旅供应商", source="receipt")
|
||||
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert first.canonical_id == second.canonical_id
|
||||
saved = registry.upsert(first)
|
||||
saved = registry.upsert(second)
|
||||
confirmed = registry.confirm(saved.canonical_id, actor="auditor")
|
||||
|
||||
assert len(registry.all()) == 1
|
||||
assert confirmed is not None
|
||||
assert confirmed.confirmed_by == "auditor"
|
||||
assert set(confirmed.aliases) == {"上海 差旅-供应商", "上海差旅供应商"}
|
||||
|
||||
|
||||
def test_multi_model_anomaly_detector_combines_deterministic_signals() -> None:
|
||||
points = [
|
||||
AnomalyPoint(
|
||||
key=f"peer-{index}",
|
||||
amount=Decimal(str(amount)),
|
||||
occurred_at=occurred_at,
|
||||
segment="travel",
|
||||
)
|
||||
for index, (amount, occurred_at) in enumerate(
|
||||
[
|
||||
(800, datetime(2026, 5, 4, tzinfo=UTC)),
|
||||
(820, datetime(2026, 5, 11, tzinfo=UTC)),
|
||||
(790, datetime(2026, 5, 12, tzinfo=UTC)),
|
||||
(810, datetime(2026, 5, 13, tzinfo=UTC)),
|
||||
(830, datetime(2026, 5, 14, tzinfo=UTC)),
|
||||
],
|
||||
start=1,
|
||||
)
|
||||
]
|
||||
points.append(
|
||||
AnomalyPoint(
|
||||
key="target",
|
||||
amount=Decimal("3200"),
|
||||
occurred_at=datetime(2026, 5, 18, tzinfo=UTC),
|
||||
segment="travel",
|
||||
)
|
||||
)
|
||||
|
||||
signals = MultiModelAnomalyDetector().detect(points, target_key="target")
|
||||
methods = {item.method for item in signals}
|
||||
|
||||
assert "robust_statistics" in methods
|
||||
assert "isolation_forest_proxy" in methods
|
||||
assert "local_outlier_factor_proxy" in methods
|
||||
assert "temporal_jump" in methods
|
||||
assert "periodic_deviation" in methods
|
||||
assert max(item.score for item in signals) >= 90
|
||||
|
||||
|
||||
def test_object_centric_process_miner_builds_replayable_events() -> None:
|
||||
claim = _snapshot(
|
||||
"c-process",
|
||||
"BX-PROCESS",
|
||||
amount="1200",
|
||||
risk_flags=[{"risk_signal": "preapproval_absent"}],
|
||||
items=[_item("i-process", "hotel", "1200", invoice_id="INV-PROCESS")],
|
||||
)
|
||||
|
||||
events = ObjectCentricProcessMiner().build_from_claims([claim])
|
||||
event_types = {item.event_type for item in events}
|
||||
invoice_event = next(item for item in events if item.event_type == "invoice_attached")
|
||||
|
||||
assert {"expense_occurred", "claim_submitted", "expense_item_recorded"} <= event_types
|
||||
assert "invoice_attached" in event_types
|
||||
assert "risk_flagged" in event_types
|
||||
assert invoice_event.object_refs["claim"] == ["c-process"]
|
||||
assert invoice_event.object_refs["invoice"] == ["INV-PROCESS"]
|
||||
|
||||
|
||||
def test_conformance_risk_detector_finds_process_violations() -> None:
|
||||
rows = [
|
||||
_event_row("e-payment", "payment_completed", "2026-05-01T09:00:00+00:00", "c-flow"),
|
||||
_event_row("e-submit-1", "claim_submitted", "2026-05-02T09:00:00+00:00", "c-flow"),
|
||||
_event_row("e-approve", "approval_approved", "2026-05-03T09:00:00+00:00", "c-flow"),
|
||||
_event_row("e-return-1", "claim_returned", "2026-05-04T09:00:00+00:00", "c-flow"),
|
||||
_event_row("e-submit-2", "claim_submitted", "2026-05-05T09:00:00+00:00", "c-flow"),
|
||||
_event_row("e-return-2", "claim_returned", "2026-05-06T09:00:00+00:00", "c-flow"),
|
||||
_event_row("e-approval-only", "approval_approved", "2026-05-01T09:00:00+00:00", "c-bypass"),
|
||||
_event_row("e-invoice-only", "invoice_attached", "2026-05-01T09:00:00+00:00", "c-invoice"),
|
||||
]
|
||||
|
||||
events = ObjectCentricProcessMiner().build_from_dicts(rows)
|
||||
risks = ConformanceRiskDetector().detect(events)
|
||||
risk_codes = {item.risk_code for item in risks}
|
||||
|
||||
assert "payment_before_approval" in risk_codes
|
||||
assert "rework_loop" in risk_codes
|
||||
assert "approval_bypass" in risk_codes
|
||||
assert "process_bypass" in risk_codes
|
||||
|
||||
|
||||
def test_risk_data_lineage_builder_collects_source_assets() -> None:
|
||||
lineage = RiskDataLineageBuilder().build_from_observation(
|
||||
{
|
||||
"observation_key": "risk:c1:duplicate_invoice",
|
||||
"claim_id": "c1",
|
||||
"run_id": "agent-run-1",
|
||||
"algorithm_version": "financial_risk_graph.v1",
|
||||
"ontology_json": {"ontology_version": "ontology.v1"},
|
||||
"evidence": [
|
||||
{
|
||||
"source": "ocr",
|
||||
"metadata": {
|
||||
"document_id": "doc-1",
|
||||
"ocr_job_id": "ocr-1",
|
||||
"tool_call_id": "tool-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"source": "rule_center",
|
||||
"metadata": {"rule_version": "rule.v2"},
|
||||
},
|
||||
],
|
||||
"decision_trace": {
|
||||
"evidence_source_gate": "passed",
|
||||
"data_quality_gate": "capped_missing_required_fields",
|
||||
"sampling_strategy": {"strategy": "uncertainty_sample"},
|
||||
},
|
||||
},
|
||||
source_event_ids=["event-1"],
|
||||
)
|
||||
|
||||
assert {"risk_observations", "expense_claims", "expense_claim_items"} <= set(
|
||||
lineage.data_tables
|
||||
)
|
||||
assert lineage.document_ids == ["doc-1"]
|
||||
assert lineage.ocr_job_ids == ["ocr-1"]
|
||||
assert lineage.agent_run_ids == ["agent-run-1"]
|
||||
assert lineage.tool_call_ids == ["tool-1"]
|
||||
assert lineage.rule_versions == ["rule.v2"]
|
||||
assert lineage.ontology_version == "ontology.v1"
|
||||
assert lineage.algorithm_version == "financial_risk_graph.v1"
|
||||
assert lineage.source_event_ids == ["event-1"]
|
||||
assert lineage.quality_gates == ["capped_missing_required_fields", "uncertainty_sample"]
|
||||
|
||||
|
||||
def test_policy_knowledge_organizing_report_exposes_risk_policy_refs() -> None:
|
||||
source = PolicySourceRef(
|
||||
source_id="doc-travel-policy",
|
||||
title="差旅报销风险管控制度",
|
||||
location="第三章",
|
||||
)
|
||||
report = PolicyKnowledgeOrganizingReport(
|
||||
summary="整理差旅预审批制度。",
|
||||
categories=["差旅", "事前申请"],
|
||||
knowledge_items=[
|
||||
PolicyKnowledgeItem(
|
||||
policy_ref=build_policy_ref("travel", "preapproval_absent"),
|
||||
title="差旅事前申请",
|
||||
summary="差旅报销需保留事前审批依据。",
|
||||
expense_type="travel",
|
||||
control_stage="reimbursement",
|
||||
trigger_conditions=["preapproval_absent"],
|
||||
source_refs=[source],
|
||||
review_status="confirmed",
|
||||
)
|
||||
],
|
||||
source_refs=[source],
|
||||
)
|
||||
|
||||
payload = report.as_dict()
|
||||
assert payload["risk_policy_refs"] == ["policy.travel.preapproval_absent"]
|
||||
assert payload["knowledge_items"][0]["source_refs"][0]["source_id"] == "doc-travel-policy"
|
||||
|
||||
|
||||
def test_counterfactual_risk_advisor_returns_actionable_reductions() -> None:
|
||||
actions = CounterfactualRiskAdvisor().advise(
|
||||
{
|
||||
"contribution_scores": {"S_rule": 82, "S_anomaly": 90, "S_graph": 95},
|
||||
"evidence": [{"code": "duplicate_invoice_graph"}],
|
||||
"decision_trace": {"data_quality_gate": "capped_missing_required_fields"},
|
||||
}
|
||||
)
|
||||
action_keys = {item.action_key for item in actions}
|
||||
|
||||
assert "complete_preapproval_or_required_attachment" in action_keys
|
||||
assert "align_amount_with_peer_baseline" in action_keys
|
||||
assert "replace_duplicate_or_conflicting_invoice" in action_keys
|
||||
assert "supplement_missing_risk_data" in action_keys
|
||||
assert all(item.expected_score_delta < 0 for item in actions)
|
||||
|
||||
|
||||
def test_candidate_risk_rule_discovery_outputs_review_only_candidates() -> None:
|
||||
candidates = CandidateRiskRuleDiscovery().discover_from_feedback(
|
||||
observations=[
|
||||
{
|
||||
"observation_key": "risk:c1:duplicate_invoice",
|
||||
"risk_signal": "duplicate_invoice",
|
||||
"confidence_score": 0.82,
|
||||
"evidence": [{"code": "duplicate_invoice_graph", "source": "graph"}],
|
||||
}
|
||||
],
|
||||
feedback_items=[
|
||||
{
|
||||
"observation_key": "risk:c1:duplicate_invoice",
|
||||
"feedback_type": "comment",
|
||||
"action": "rewrite",
|
||||
"decision": "candidate_rule_rewrite",
|
||||
"candidate_rule_source": "risk_observation_feedback",
|
||||
"confidence_score": 0.77,
|
||||
"comment": "建议沉淀重复票据候选规则。",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert len(candidates) == 1
|
||||
candidate = candidates[0]
|
||||
assert candidate.rule_code == "candidate.risk.duplicate_invoice"
|
||||
assert candidate.status == "candidate_review"
|
||||
assert candidate.source == "risk_observation_feedback"
|
||||
assert candidate.confidence_score == 0.77
|
||||
assert any(item["source"] == "graph" for item in candidate.evidence)
|
||||
assert any(item["source"] == "risk_observation_feedback" for item in candidate.evidence)
|
||||
|
||||
|
||||
def test_control_effect_analyzer_compares_before_and_after_windows() -> None:
|
||||
summary = ControlEffectAnalyzer().compare(
|
||||
before=[
|
||||
{"risk_score": 90, "risk_level": "critical", "feedback_status": "false_positive"},
|
||||
{"risk_score": 80, "risk_level": "high", "feedback_status": "confirmed"},
|
||||
],
|
||||
after=[
|
||||
{"risk_score": 62, "risk_level": "medium", "feedback_status": "confirmed"},
|
||||
{"risk_score": 55, "risk_level": "medium", "feedback_status": "confirmed"},
|
||||
],
|
||||
)
|
||||
|
||||
assert summary.before_count == 2
|
||||
assert summary.after_count == 2
|
||||
assert summary.average_score_delta < 0
|
||||
assert summary.high_rate_delta < 0
|
||||
assert summary.confirmation_rate_delta > 0
|
||||
assert summary.false_positive_rate_delta < 0
|
||||
|
||||
|
||||
def test_risk_data_quality_gate_caps_strong_conclusion_for_low_quality_claim() -> None:
|
||||
target = _snapshot(
|
||||
"c-low-quality",
|
||||
"BX-005",
|
||||
amount="12000",
|
||||
employee_name="",
|
||||
risk_flags=[{"risk_signal": "preapproval_absent", "severity": "critical"}],
|
||||
items=[_item("i-low-quality", "hotel", "900", invoice_id="INV-LOW")],
|
||||
)
|
||||
peers = [
|
||||
_snapshot(
|
||||
f"c-peer-quality-{index}",
|
||||
f"BX-30{index}",
|
||||
amount=str(amount),
|
||||
employee_name=f"同事{index}",
|
||||
)
|
||||
for index, amount in enumerate([700, 800, 900, 1000], start=1)
|
||||
]
|
||||
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[target, *peers],
|
||||
target_claim_ids={"c-low-quality"},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.observations) == 1
|
||||
observation = result.observations[0]
|
||||
assert observation.decision_trace["data_quality_gate"] == "capped_missing_required_fields"
|
||||
assert observation.decision_trace["data_quality"]["passed"] is False
|
||||
assert "employee" in observation.decision_trace["data_quality"]["missing_fields"]
|
||||
assert observation.decision_trace["sampling_strategy"]["strategy"] == "uncertainty_sample"
|
||||
assert observation.decision_trace["sampling_strategy"]["replay_bucket"] == "data_quality_gate"
|
||||
assert "score_capped_by_gate" in observation.decision_trace["uncertainty_reasons_json"]
|
||||
assert "data_quality_gate_not_passed" in observation.decision_trace["uncertainty_reasons_json"]
|
||||
assert observation.risk_score == 69
|
||||
assert observation.risk_level == "medium"
|
||||
|
||||
|
||||
def test_default_risk_evaluation_cases_cover_required_categories() -> None:
|
||||
cases = default_risk_evaluation_cases()
|
||||
categories = {item.category for item in cases}
|
||||
|
||||
assert {
|
||||
"positive",
|
||||
"negative",
|
||||
"counterfactual",
|
||||
"noise",
|
||||
"historical_false_positive",
|
||||
} <= categories
|
||||
assert all(item.case_id and item.description for item in cases)
|
||||
|
||||
|
||||
def test_risk_graph_engine_avoids_false_risk_when_baseline_and_signals_are_missing() -> None:
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[_snapshot("c-clean", "BX-003", amount="300")],
|
||||
target_claim_ids={"c-clean"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.observations == []
|
||||
assert any(node.key == "claim:c-clean" for node in result.nodes)
|
||||
|
||||
|
||||
def test_risk_graph_engine_detects_multi_evidence_and_spatiotemporal_mismatch() -> None:
|
||||
target = _snapshot(
|
||||
"c-mismatch",
|
||||
"BX-004",
|
||||
amount="8000",
|
||||
invoice_count=2,
|
||||
items=[
|
||||
_item(
|
||||
"i-mismatch",
|
||||
"hotel",
|
||||
"900",
|
||||
invoice_id="INV-MISMATCH",
|
||||
item_location="北京",
|
||||
item_date=date(2026, 4, 1),
|
||||
)
|
||||
],
|
||||
)
|
||||
peers = [
|
||||
_snapshot(
|
||||
f"c-peer-mismatch-{index}",
|
||||
f"BX-20{index}",
|
||||
amount=str(amount),
|
||||
employee_name=f"同事{index}",
|
||||
)
|
||||
for index, amount in enumerate([700, 800, 900, 1000], start=1)
|
||||
]
|
||||
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[target, *peers],
|
||||
target_claim_ids={"c-mismatch"},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.observations) == 1
|
||||
observation = result.observations[0]
|
||||
evidence_codes = {item.code for item in observation.evidence}
|
||||
evidence_sources = {item.source for item in observation.evidence}
|
||||
assert "document_amount_mismatch" in evidence_codes
|
||||
assert "invoice_count_mismatch" in evidence_codes
|
||||
assert "date_outside_claim_window" in evidence_codes
|
||||
assert "location_mismatch_graph" in evidence_codes
|
||||
assert {"multi_evidence", "spatiotemporal"} <= evidence_sources
|
||||
assert observation.risk_signal in {"date_outside_trip", "document_expense_mismatch"}
|
||||
|
||||
|
||||
def test_ontology_mapping_normalizes_signals_and_uses_confidence_gate() -> None:
|
||||
mapping = map_ontology_to_risk_graph(
|
||||
{
|
||||
"run_id": "run-ontology-1",
|
||||
"scenario": "expense",
|
||||
"intent": "risk_check",
|
||||
"confidence": 0.49,
|
||||
"entities": [
|
||||
{
|
||||
"type": "employee",
|
||||
"value": "张三",
|
||||
"normalized_value": "E001",
|
||||
"role": "target",
|
||||
"confidence": 0.8,
|
||||
},
|
||||
{
|
||||
"type": "expense_type",
|
||||
"value": "差旅费",
|
||||
"normalized_value": "travel",
|
||||
"role": "filter",
|
||||
"confidence": 0.9,
|
||||
},
|
||||
],
|
||||
"constraints": [{"field": "amount", "operator": ">", "value": 5000}],
|
||||
"risk_flags": ["city_mismatch"],
|
||||
},
|
||||
ontology_version="ontology.test",
|
||||
)
|
||||
|
||||
assert mapping.gate == "candidate_only"
|
||||
assert mapping.canonical_subject_key == "employee:e001"
|
||||
assert [item.code for item in mapping.risk_signals] == ["location_mismatch"]
|
||||
node_payloads = [node.as_dict() for node in mapping.nodes]
|
||||
assert all(item["canonical_id"] for item in node_payloads)
|
||||
assert {item["ontology_parse_id"] for item in node_payloads} == {"run-ontology-1"}
|
||||
assert {item["ontology_version"] for item in node_payloads} == {"ontology.test"}
|
||||
assert {edge.edge_type for edge in mapping.edges} <= {
|
||||
"ontology_extracts",
|
||||
"ontology_constrains",
|
||||
"ontology_signals",
|
||||
}
|
||||
assert {edge.as_dict()["source"] for edge in mapping.edges} == {"ontology"}
|
||||
|
||||
|
||||
def test_hermes_risk_scanner_persists_algorithm_reports() -> None:
|
||||
with _build_session() as db:
|
||||
config = HermesTaskConfig(
|
||||
task_type="global_risk_scan",
|
||||
cron_expression="0 0 * * *",
|
||||
is_enabled=True,
|
||||
)
|
||||
db.add(config)
|
||||
db.flush()
|
||||
log = HermesTaskExecutionLog(config_id=config.id, status="running")
|
||||
db.add(log)
|
||||
|
||||
target = _claim_orm(
|
||||
"c-risk",
|
||||
"BX-001",
|
||||
amount=Decimal("12000"),
|
||||
risk_flags=[{"risk_signal": "preapproval_absent", "severity": "high"}],
|
||||
)
|
||||
target.items.append(
|
||||
_claim_item_orm("item-risk", "c-risk", Decimal("12000"), invoice_id="INV-001")
|
||||
)
|
||||
duplicate = _claim_orm("c-dup", "BX-002", amount=Decimal("900"), employee_name="李四")
|
||||
duplicate.items.append(
|
||||
_claim_item_orm("item-dup", "c-dup", Decimal("900"), invoice_id="INV-001")
|
||||
)
|
||||
peers = [
|
||||
_claim_orm(
|
||||
f"c-peer-{index}",
|
||||
f"BX-10{index}",
|
||||
amount=Decimal(str(amount)),
|
||||
employee_name=f"同事{index}",
|
||||
)
|
||||
for index, amount in enumerate([700, 800, 900, 1000], start=1)
|
||||
]
|
||||
historical = _claim_orm("c-history", "BX-HIST", amount=Decimal("1000"))
|
||||
historical.status = "approved"
|
||||
db.add_all([target, duplicate, *peers, historical])
|
||||
db.flush()
|
||||
|
||||
observation_service = RiskObservationService(db)
|
||||
observation_service.upsert_observation(
|
||||
{
|
||||
"observation_key": "risk:c-history:duplicate_invoice",
|
||||
"subject_type": "expense_claim",
|
||||
"subject_key": "claim:c-history",
|
||||
"subject_label": "BX-HIST",
|
||||
"claim_id": "c-history",
|
||||
"claim_no": "BX-HIST",
|
||||
"risk_type": "duplicate_invoice",
|
||||
"risk_signal": "duplicate_invoice",
|
||||
"title": "Historical duplicate invoice risk",
|
||||
"description": "Confirmed historical duplicate invoice risk.",
|
||||
"risk_score": 82,
|
||||
"risk_level": "high",
|
||||
"source": "financial_risk_graph",
|
||||
"algorithm_version": "financial_risk_graph.test",
|
||||
"contribution_scores": {"S_rule": 82},
|
||||
}
|
||||
)
|
||||
observation_service.create_feedback(
|
||||
"risk:c-history:duplicate_invoice",
|
||||
RiskObservationFeedbackCreate(feedback_type="confirm", actor="auditor"),
|
||||
)
|
||||
|
||||
summary = HermesRiskScannerService(db).scan_global_risks(log_id=log.id)
|
||||
reports = list(db.scalars(select(HermesRiskReport)).all())
|
||||
observations = list(db.scalars(select(RiskObservation)).all())
|
||||
target_observation = next(item for item in observations if item.claim_id == "c-risk")
|
||||
refreshed_target = db.get(ExpenseClaim, "c-risk")
|
||||
|
||||
assert summary["risk_observation_count"] >= 1
|
||||
assert any(report.risk_type == "duplicate_invoice" for report in reports)
|
||||
assert any(item.risk_signal == "duplicate_invoice" for item in observations)
|
||||
assert target_observation.execution_log_id == log.id
|
||||
assert target_observation.contribution_scores_json.get("S_history", 0) > 0
|
||||
assert refreshed_target is not None
|
||||
assert refreshed_target.hermes_risk_flag is True
|
||||
assert any(
|
||||
isinstance(flag, dict) and flag.get("source") == "financial_risk_graph"
|
||||
for flag in refreshed_target.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(
|
||||
claim_id: str,
|
||||
claim_no: str,
|
||||
*,
|
||||
amount: str,
|
||||
employee_name: str = "张三",
|
||||
department_name: str = "销售部",
|
||||
employee_grade: str = "P7",
|
||||
expense_type: str = "travel",
|
||||
location: str = "上海",
|
||||
invoice_count: int = 0,
|
||||
occurred_at: datetime | None = None,
|
||||
submitted_at: datetime | None = None,
|
||||
risk_flags: list | None = None,
|
||||
items: list[RiskGraphClaimItemSnapshot] | None = None,
|
||||
) -> RiskGraphClaimSnapshot:
|
||||
occurred = occurred_at or datetime(2026, 5, 20, tzinfo=UTC)
|
||||
return RiskGraphClaimSnapshot(
|
||||
claim_id=claim_id,
|
||||
claim_no=claim_no,
|
||||
employee_id=employee_name,
|
||||
employee_name=employee_name,
|
||||
department_id=department_name,
|
||||
department_name=department_name,
|
||||
employee_grade=employee_grade,
|
||||
expense_type=expense_type,
|
||||
amount=Decimal(amount),
|
||||
invoice_count=invoice_count,
|
||||
occurred_at=occurred,
|
||||
submitted_at=submitted_at or occurred + timedelta(hours=1),
|
||||
status="submitted",
|
||||
location=location,
|
||||
risk_flags=risk_flags or [],
|
||||
items=items or [],
|
||||
)
|
||||
|
||||
|
||||
def _item(
|
||||
item_id: str,
|
||||
item_type: str,
|
||||
amount: str,
|
||||
*,
|
||||
invoice_id: str | None = None,
|
||||
item_location: str = "上海",
|
||||
item_date: date | None = None,
|
||||
) -> RiskGraphClaimItemSnapshot:
|
||||
return RiskGraphClaimItemSnapshot(
|
||||
item_id=item_id,
|
||||
item_type=item_type,
|
||||
item_amount=Decimal(amount),
|
||||
item_location=item_location,
|
||||
item_date=item_date or date(2026, 5, 20),
|
||||
invoice_id=invoice_id,
|
||||
)
|
||||
|
||||
|
||||
def _event_row(event_id: str, event_type: str, occurred_at: str, claim_id: str) -> dict:
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"event_type": event_type,
|
||||
"occurred_at": occurred_at,
|
||||
"object_refs": {"claim": [claim_id]},
|
||||
"source": "test",
|
||||
}
|
||||
|
||||
|
||||
def _build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def _claim_orm(
|
||||
claim_id: str,
|
||||
claim_no: str,
|
||||
*,
|
||||
amount: Decimal,
|
||||
employee_name: str = "张三",
|
||||
risk_flags: list | None = None,
|
||||
) -> ExpenseClaim:
|
||||
now = datetime(2026, 5, 20, tzinfo=UTC)
|
||||
return ExpenseClaim(
|
||||
id=claim_id,
|
||||
claim_no=claim_no,
|
||||
employee_id=employee_name,
|
||||
employee_name=employee_name,
|
||||
department_id="sales",
|
||||
department_name="销售部",
|
||||
expense_type="travel",
|
||||
reason="客户拜访",
|
||||
location="上海",
|
||||
amount=amount,
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=now,
|
||||
submitted_at=now + timedelta(hours=1),
|
||||
status="submitted",
|
||||
approval_stage="manager_review",
|
||||
risk_flags_json=risk_flags or [],
|
||||
)
|
||||
|
||||
|
||||
def _claim_item_orm(
|
||||
item_id: str,
|
||||
claim_id: str,
|
||||
amount: Decimal,
|
||||
*,
|
||||
invoice_id: str,
|
||||
) -> ExpenseClaimItem:
|
||||
return ExpenseClaimItem(
|
||||
id=item_id,
|
||||
claim_id=claim_id,
|
||||
item_date=date(2026, 5, 20),
|
||||
item_type="hotel",
|
||||
item_reason="客户拜访住宿",
|
||||
item_location="上海",
|
||||
item_amount=amount,
|
||||
invoice_id=invoice_id,
|
||||
)
|
||||
124
server/tests/test_hermes_employee_profile_baselines.py
Normal file
124
server/tests/test_hermes_employee_profile_baselines.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, 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.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
|
||||
|
||||
def test_hermes_employee_profile_scan_returns_profile_baseline_summary() -> None:
|
||||
session_factory = _build_session_factory()
|
||||
with session_factory() as db:
|
||||
_seed_scan_data(db)
|
||||
|
||||
summary = HermesEmployeeProfileScannerService(db).scan_employee_profiles(log_id=None)
|
||||
|
||||
assert summary["target_employee_count"] == 3
|
||||
assert db.query(EmployeeBehaviorProfileSnapshot).count() >= 12
|
||||
baseline_summary = summary["baseline_summary"]
|
||||
assert baseline_summary["dimension_counts"]["employee"] == 3
|
||||
assert baseline_summary["dimension_counts"]["department"] == 1
|
||||
assert baseline_summary["dimension_counts"]["supplier"] == 2
|
||||
assert baseline_summary["dimension_counts"]["expense_type"] == 2
|
||||
assert any(
|
||||
bucket["dimension"] == "supplier" and bucket["key"] == "s-hotel"
|
||||
for bucket in baseline_summary["buckets"]
|
||||
)
|
||||
|
||||
|
||||
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 _seed_scan_data(db: Session) -> None:
|
||||
org = OrganizationUnit(
|
||||
id="dept-sales",
|
||||
unit_code="SALES",
|
||||
name="市场部",
|
||||
unit_type="department",
|
||||
)
|
||||
employees = [
|
||||
Employee(
|
||||
id=f"emp-{index}",
|
||||
employee_no=f"E10{index}",
|
||||
name=f"员工{index}",
|
||||
email=f"emp{index}@example.com",
|
||||
position="客户经理",
|
||||
grade="P5",
|
||||
organization_unit=org,
|
||||
)
|
||||
for index in range(1, 4)
|
||||
]
|
||||
db.add(org)
|
||||
db.add_all(employees)
|
||||
now = datetime.now(UTC)
|
||||
claims = [
|
||||
_claim("c1", employees[0], "travel", "600", "s-hotel", "Hotel A", now),
|
||||
_claim("c2", employees[1], "travel", "900", "s-hotel", "Hotel A", now),
|
||||
_claim("c3", employees[2], "meal", "300", "s-meal", "Meal B", now),
|
||||
]
|
||||
db.add_all(claims)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _claim(
|
||||
claim_id: str,
|
||||
employee: Employee,
|
||||
expense_type: str,
|
||||
amount: str,
|
||||
supplier_id: str,
|
||||
supplier_name: str,
|
||||
now: datetime,
|
||||
) -> ExpenseClaim:
|
||||
return ExpenseClaim(
|
||||
id=claim_id,
|
||||
claim_no=f"EXP-{claim_id}",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id="dept-sales",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-001",
|
||||
expense_type=expense_type,
|
||||
reason="客户拜访",
|
||||
location="北京",
|
||||
amount=Decimal(amount),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=now - timedelta(days=5),
|
||||
submitted_at=now - timedelta(days=5),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"supplier_id": supplier_id,
|
||||
"supplier_name": supplier_name,
|
||||
}
|
||||
],
|
||||
items=[
|
||||
ExpenseClaimItem(
|
||||
id=f"item-{claim_id}",
|
||||
claim_id=claim_id,
|
||||
item_date=date.today(),
|
||||
item_type=expense_type,
|
||||
item_reason="客户拜访",
|
||||
item_location="北京",
|
||||
item_amount=Decimal(amount),
|
||||
invoice_id=f"invoice-{claim_id}",
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -8,10 +8,12 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus
|
||||
from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
|
||||
from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatResult
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
@@ -283,6 +285,61 @@ def test_semantic_ontology_service_extracts_budget_query_fields() -> None:
|
||||
assert {"available_amount", "reserved_amount"}.issubset(metric_names)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query",
|
||||
[
|
||||
"申请出差",
|
||||
"申请差旅",
|
||||
"去国网出差3天,协助仿生产环境部署",
|
||||
"去北京出差3天,支撑国网仿生产环境部署",
|
||||
"下周去上海出差支撑客户系统上线,预计3天",
|
||||
"安排去深圳客户现场验收项目,出差两天",
|
||||
"准备去国网现场做仿生产环境部署,差旅3天",
|
||||
],
|
||||
)
|
||||
def test_semantic_ontology_service_treats_apply_for_travel_as_expense_application(query: str) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=query,
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
|
||||
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||
entity_types = {item.type for item in result.entities}
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert result.permission.level == "draft_write"
|
||||
assert entity_map["document_type"] == "expense_application"
|
||||
assert entity_map["workflow_stage"] == "pre_approval"
|
||||
assert entity_map["expense_type"] == "travel"
|
||||
assert "employee" not in entity_types
|
||||
assert "amount" in result.missing_slots
|
||||
assert "time_range" in result.missing_slots
|
||||
|
||||
|
||||
def test_semantic_ontology_service_keeps_explicit_travel_reimbursement_as_reimbursement_draft() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我要报销去北京出差的费用",
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
|
||||
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert entity_map["expense_type"] == "travel"
|
||||
assert "document_type" not in entity_map
|
||||
assert "workflow_stage" not in entity_map
|
||||
|
||||
|
||||
def test_semantic_ontology_service_extracts_budget_edit_fields() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -438,20 +495,24 @@ def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_parse_with_model",
|
||||
lambda **kwargs: LlmOntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="draft",
|
||||
confidence=0.91,
|
||||
clarification_required=True,
|
||||
clarification_question="请补充招待对象和票据附件。",
|
||||
missing_slots=["participants", "attachments"],
|
||||
ambiguity=[],
|
||||
entity_hints=[],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_parse_with_model",
|
||||
lambda **kwargs: (
|
||||
LlmOntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="draft",
|
||||
confidence=0.91,
|
||||
clarification_required=True,
|
||||
clarification_question="请补充招待对象和票据附件。",
|
||||
missing_slots=["participants", "attachments"],
|
||||
ambiguity=[],
|
||||
entity_hints=[],
|
||||
),
|
||||
[],
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.parse(
|
||||
OntologyParseRequest(
|
||||
@@ -809,20 +870,33 @@ def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch)
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_parse_with_model",
|
||||
lambda **kwargs: LlmOntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="draft",
|
||||
confidence=0.91,
|
||||
clarification_required=True,
|
||||
clarification_question="请补充费用类型、金额和票据附件。",
|
||||
missing_slots=["expense_type", "amount", "attachments"],
|
||||
ambiguity=[],
|
||||
entity_hints=[],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_parse_with_model",
|
||||
lambda **kwargs: (
|
||||
LlmOntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="draft",
|
||||
confidence=0.91,
|
||||
clarification_required=True,
|
||||
clarification_question="请补充费用类型、金额和票据附件。",
|
||||
missing_slots=["expense_type", "amount", "attachments"],
|
||||
ambiguity=[],
|
||||
entity_hints=[],
|
||||
),
|
||||
[
|
||||
{
|
||||
"slot": "main",
|
||||
"provider": "MiniMax",
|
||||
"model": "intent-model",
|
||||
"attempt": 1,
|
||||
"status": "succeeded",
|
||||
"duration_ms": 8,
|
||||
}
|
||||
],
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.parse(
|
||||
OntologyParseRequest(
|
||||
@@ -836,7 +910,103 @@ def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch)
|
||||
assert result.parse_strategy == "llm_primary"
|
||||
assert result.clarification_required is True
|
||||
assert "expense_type" in result.missing_slots
|
||||
assert result.clarification_question == "请补充费用类型、金额和票据附件。"
|
||||
assert result.clarification_question == "请补充费用类型、金额和票据附件。"
|
||||
|
||||
|
||||
def test_semantic_ontology_service_falls_back_when_model_conflicts_with_application_signal(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service.runtime_chat_service,
|
||||
"complete_with_trace",
|
||||
lambda *args, **kwargs: RuntimeChatResult(
|
||||
text=(
|
||||
'{"scenario":"knowledge","intent":"query","confidence":0.91,'
|
||||
'"clarification_required":false,"missing_slots":[],'
|
||||
'"ambiguity":[],"entity_hints":[]}'
|
||||
),
|
||||
calls=[
|
||||
RuntimeChatCallTrace(
|
||||
slot="main",
|
||||
provider="MiniMax",
|
||||
model="intent-model",
|
||||
attempt=1,
|
||||
status="succeeded",
|
||||
duration_ms=11,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
result = service.parse(
|
||||
OntologyParseRequest(
|
||||
query="去国网出差3天,协助仿生产环境部署",
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
fetched = service.run_service.get_run(result.run_id)
|
||||
|
||||
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert result.parse_strategy == "rule_fallback"
|
||||
assert entity_map["document_type"] == "expense_application"
|
||||
assert fetched is not None
|
||||
assert fetched.tool_calls[0].status == "failed"
|
||||
assert fetched.tool_calls[0].error_message == "model_conflicts_with_application_stage_signal"
|
||||
|
||||
|
||||
def test_semantic_ontology_service_records_model_call_errors_for_statistics(monkeypatch) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
run = service.run_service.create_run(
|
||||
agent=AgentName.ORCHESTRATOR.value,
|
||||
source=AgentRunSource.USER_MESSAGE.value,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service.runtime_chat_service,
|
||||
"complete_with_trace",
|
||||
lambda *args, **kwargs: RuntimeChatResult(
|
||||
text=None,
|
||||
calls=[
|
||||
RuntimeChatCallTrace(
|
||||
slot="main",
|
||||
provider="MiniMax",
|
||||
model="intent-model",
|
||||
attempt=1,
|
||||
status="failed",
|
||||
duration_ms=15,
|
||||
error_message="incorrect api key",
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
result = service.parse_for_run(
|
||||
OntologyParseRequest(
|
||||
query="去北京出差3天,支撑国网仿生产环境部署",
|
||||
user_id="pytest",
|
||||
),
|
||||
run_id=run.run_id,
|
||||
)
|
||||
fetched = service.run_service.get_run(run.run_id)
|
||||
stats = service.run_service.summarize_runs(limit=20)
|
||||
|
||||
assert result.parse_strategy == "rule_fallback"
|
||||
assert fetched is not None
|
||||
assert len(fetched.tool_calls) == 1
|
||||
assert fetched.tool_calls[0].tool_name == "semantic_ontology.main"
|
||||
assert fetched.tool_calls[0].status == "failed"
|
||||
assert fetched.tool_calls[0].error_message == "incorrect api key"
|
||||
assert stats.failed_llm_call_count >= 1
|
||||
|
||||
|
||||
def test_parse_ontology_endpoint_returns_eight_fields_and_writes_trace() -> None:
|
||||
|
||||
@@ -13,6 +13,8 @@ def test_openapi_schema_includes_documented_backend_routes() -> None:
|
||||
assert any(tag["name"] == "ocr" for tag in schema["tags"])
|
||||
assert any(tag["name"] == "ontology" for tag in schema["tags"])
|
||||
assert any(tag["name"] == "orchestrator" for tag in schema["tags"])
|
||||
assert any(tag["name"] == "agent-feedback" for tag in schema["tags"])
|
||||
assert any(tag["name"] == "analytics" for tag in schema["tags"])
|
||||
|
||||
agent_assets_post = schema["paths"]["/api/v1/agent-assets"]["post"]
|
||||
assert agent_assets_post["summary"] == "创建 Agent 资产"
|
||||
@@ -40,5 +42,12 @@ def test_openapi_schema_includes_documented_backend_routes() -> None:
|
||||
assert orchestrator_run_post["summary"] == "运行 Orchestrator 统一调度"
|
||||
assert "application/json" in orchestrator_run_post["requestBody"]["content"]
|
||||
|
||||
feedback_post = schema["paths"]["/api/v1/agent-feedback"]["post"]
|
||||
assert feedback_post["summary"] == "记录 Agent 操作评价"
|
||||
assert "application/json" in feedback_post["requestBody"]["content"]
|
||||
|
||||
analytics_get = schema["paths"]["/api/v1/analytics/system-dashboard"]["get"]
|
||||
assert analytics_get["summary"] == "查询系统看板真实指标"
|
||||
|
||||
root_get = schema["paths"]["/"]["get"]
|
||||
assert root_get["summary"] == "服务根检查"
|
||||
|
||||
@@ -9,6 +9,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
@@ -35,6 +37,85 @@ def skip_agent_foundation_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("task_type", "code", "method_path", "summary", "expected_text"),
|
||||
[
|
||||
(
|
||||
"global_risk_scan",
|
||||
"task.hermes.global_risk_scan",
|
||||
"app.services.hermes_risk_scanner.HermesRiskScannerService.scan_global_risks",
|
||||
{"scanned_claim_count": 2, "risk_observation_count": 1},
|
||||
"生成 1 条风险观察",
|
||||
),
|
||||
(
|
||||
"employee_behavior_profile_scan",
|
||||
"task.hermes.employee_behavior_profile_scan",
|
||||
"app.services.hermes_employee_profile_scanner.HermesEmployeeProfileScannerService.scan_employee_profiles",
|
||||
{
|
||||
"target_employee_count": 3,
|
||||
"snapshot_count": 9,
|
||||
"high_attention_employee_count": 1,
|
||||
},
|
||||
"生成 9 条快照",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_schedule_digital_employee_task_runs_real_service(
|
||||
monkeypatch,
|
||||
task_type,
|
||||
code,
|
||||
method_path,
|
||||
summary,
|
||||
expected_text,
|
||||
) -> None:
|
||||
def parse_for_run(self, request, run_id): # noqa: ANN001
|
||||
return OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="risk_check",
|
||||
entities=[],
|
||||
permission=OntologyPermission(level="read", allowed=True, 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", parse_for_run)
|
||||
monkeypatch.setattr(method_path, lambda self, **kwargs: dict(summary))
|
||||
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
task = AgentAsset(
|
||||
asset_type="task",
|
||||
code=code,
|
||||
name="数字员工任务",
|
||||
description="",
|
||||
domain="system",
|
||||
scenario_json=["schedule"],
|
||||
owner="pytest",
|
||||
status="active",
|
||||
current_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
config_json={"agent": "hermes", "task_type": task_type},
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
|
||||
response = OrchestratorService(db).run(
|
||||
OrchestratorRequest(source="schedule", task_id=task.id, message=task.name)
|
||||
)
|
||||
|
||||
run = db.query(AgentRun).filter_by(run_id=response.run_id).one()
|
||||
assert response.status == "succeeded"
|
||||
assert response.result["report_type"] == task_type
|
||||
assert expected_text in response.result["message"]
|
||||
assert run.route_json["job_type"] == task_type
|
||||
assert run.route_json["task_code"] == code
|
||||
|
||||
|
||||
def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
@@ -707,8 +788,8 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
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 "申请单据已生成,并已进入审批流程" in fourth.result["answer"]
|
||||
assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in fourth.result["answer"]
|
||||
assert fourth.result["suggested_actions"] == []
|
||||
application_claims = [
|
||||
claim
|
||||
@@ -808,5 +889,5 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
assert submitted.requires_confirmation is False
|
||||
assert "操作类请求需要人工审批确认" not in submitted.result["answer"]
|
||||
assert "当前仅返回确认摘要" not in submitted.result["answer"]
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"]
|
||||
assert "申请单据已生成,并已进入审批流程" in submitted.result["answer"]
|
||||
assert submitted.result["draft_payload"]["status"] == "submitted"
|
||||
|
||||
69
server/tests/test_receipt_folder_service.py
Normal file
69
server/tests/test_receipt_folder_service.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.ocr import OcrRecognizeDocumentRead
|
||||
from app.services.receipt_folder import ReceiptFolderService
|
||||
|
||||
|
||||
def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
current_user = CurrentUserContext(
|
||||
username="pytest",
|
||||
name="Py Test",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
service = ReceiptFolderService()
|
||||
receipt = service.save_receipt(
|
||||
filename="2月23_上海-武汉.pdf",
|
||||
content=b"%PDF-1.4 fake",
|
||||
media_type="application/pdf",
|
||||
current_user=current_user,
|
||||
document=OcrRecognizeDocumentRead(
|
||||
filename="2月23_上海-武汉.pdf",
|
||||
media_type="application/pdf",
|
||||
text=(
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26319166100006175398\n"
|
||||
"电子客票号:E1234567890123\n"
|
||||
"开票日期:2026-02-18\n"
|
||||
"上海虹桥站\n"
|
||||
"武汉站\n"
|
||||
"G456\n"
|
||||
"二等座\n"
|
||||
"06车01B号\n"
|
||||
"2026-02-20 08:30开\n"
|
||||
"票价:¥354.00\n"
|
||||
"1101011990****1234\n"
|
||||
"张三"
|
||||
),
|
||||
summary="铁路电子客票,上海虹桥至武汉,票价 354 元。",
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅票据",
|
||||
),
|
||||
)
|
||||
|
||||
assert receipt.document_date == "2026-02-18"
|
||||
assert receipt.merchant_name == "中国铁路"
|
||||
assert receipt.amount == "354.00元"
|
||||
|
||||
detail = service.get_receipt(receipt.id, current_user)
|
||||
fields = {field.label: field.value for field in detail.fields}
|
||||
assert fields["开票日期"] == "2026-02-18"
|
||||
assert fields["乘车人"] == "张三"
|
||||
assert fields["出发地点"] == "上海虹桥"
|
||||
assert fields["到达地点"] == "武汉"
|
||||
assert fields["车次"] == "G456"
|
||||
assert fields["电子客票号"] == "E1234567890123"
|
||||
assert fields["身份证号"] == "1101011990****1234"
|
||||
assert fields["席别"] == "二等座"
|
||||
assert fields["车厢"] == "06车"
|
||||
assert fields["座位号"] == "01B"
|
||||
assert fields["列车出发时间"] == "2026-02-20 08:30"
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
87
server/tests/test_risk_graph_profile_baselines.py
Normal file
87
server/tests/test_risk_graph_profile_baselines.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.algorithem.risk_graph import RiskGraphClaimItemSnapshot, RiskGraphClaimSnapshot
|
||||
from app.algorithem.risk_graph.profile_baselines import ProfileBaselineUpdater
|
||||
|
||||
|
||||
def test_profile_baseline_updater_outputs_core_dimensions() -> None:
|
||||
snapshot = ProfileBaselineUpdater().build_from_claims(
|
||||
[
|
||||
_claim(
|
||||
"c1",
|
||||
employee_id="e1",
|
||||
department_id="d1",
|
||||
expense_type="travel",
|
||||
amount="600",
|
||||
supplier_id="s-hotel",
|
||||
supplier_name="Hotel A",
|
||||
),
|
||||
_claim(
|
||||
"c2",
|
||||
employee_id="e1",
|
||||
department_id="d1",
|
||||
expense_type="travel",
|
||||
amount="900",
|
||||
supplier_id="s-hotel",
|
||||
supplier_name="Hotel A",
|
||||
),
|
||||
_claim(
|
||||
"c3",
|
||||
employee_id="e2",
|
||||
department_id="d2",
|
||||
expense_type="meal",
|
||||
amount="300",
|
||||
supplier_id="s-meal",
|
||||
supplier_name="Meal B",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert snapshot.dimension_counts == {
|
||||
"employee": 2,
|
||||
"department": 2,
|
||||
"supplier": 2,
|
||||
"expense_type": 2,
|
||||
}
|
||||
hotel = next(bucket for bucket in snapshot.buckets if bucket.key == "s-hotel")
|
||||
assert hotel.dimension == "supplier"
|
||||
assert hotel.sample_size == 2
|
||||
assert hotel.claim_count == 2
|
||||
assert hotel.total_amount == Decimal("1500")
|
||||
assert hotel.p75_amount == Decimal("825")
|
||||
assert hotel.as_dict()["total_amount"] == "1500"
|
||||
|
||||
|
||||
def _claim(
|
||||
claim_id: str,
|
||||
*,
|
||||
employee_id: str,
|
||||
department_id: str,
|
||||
expense_type: str,
|
||||
amount: str,
|
||||
supplier_id: str,
|
||||
supplier_name: str,
|
||||
) -> RiskGraphClaimSnapshot:
|
||||
return RiskGraphClaimSnapshot(
|
||||
claim_id=claim_id,
|
||||
claim_no=f"BX-{claim_id}",
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_id,
|
||||
department_id=department_id,
|
||||
department_name=department_id,
|
||||
expense_type=expense_type,
|
||||
amount=Decimal(amount),
|
||||
items=[
|
||||
RiskGraphClaimItemSnapshot(
|
||||
item_id=f"item-{claim_id}",
|
||||
item_type=expense_type,
|
||||
item_amount=Decimal(amount),
|
||||
metadata={
|
||||
"supplier_id": supplier_id,
|
||||
"supplier_name": supplier_name,
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
338
server/tests/test_risk_observations_service.py
Normal file
338
server/tests/test_risk_observations_service.py
Normal file
@@ -0,0 +1,338 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.schemas.risk_observation import RiskObservationFeedbackCreate
|
||||
from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
|
||||
def test_risk_observation_service_upserts_and_summarizes_dashboard() -> None:
|
||||
with _build_session() as db:
|
||||
db.add(_employee_orm())
|
||||
db.add_all([_claim_orm("c1", "BX-001"), _claim_orm("c2", "BX-002")])
|
||||
db.flush()
|
||||
service = RiskObservationService(db)
|
||||
service.upsert_observation(_observation_payload("risk:c1:duplicate_invoice"))
|
||||
service.upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:c2:preapproval_absent"),
|
||||
"claim_id": "c2",
|
||||
"claim_no": "BX-002",
|
||||
"risk_signal": "preapproval_absent",
|
||||
"risk_type": "preapproval_absent",
|
||||
"risk_score": 72,
|
||||
"risk_level": "high",
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
feedback = service.create_feedback(
|
||||
"risk:c1:duplicate_invoice",
|
||||
RiskObservationFeedbackCreate(feedback_type="confirm", actor="auditor"),
|
||||
)
|
||||
dashboard = service.summarize_dashboard(window_days=30)
|
||||
history = service.build_history_stats(risk_signals={"duplicate_invoice"})
|
||||
refreshed = service.get_observation("risk:c1:duplicate_invoice")
|
||||
|
||||
assert feedback.feedback_type == "confirm"
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "confirmed"
|
||||
assert refreshed.source == "financial_risk_graph"
|
||||
assert refreshed.algorithm_version == "financial_risk_graph.v1"
|
||||
assert refreshed.sampling_strategy["strategy"] == "focused_review"
|
||||
assert refreshed.evaluation_case_id == "case-duplicate-invoice"
|
||||
assert refreshed.ontology_parse_id == "parse-1"
|
||||
assert refreshed.ontology_version == "ontology.v1"
|
||||
assert refreshed.domain == "expense"
|
||||
assert refreshed.scenario == "reimbursement"
|
||||
assert refreshed.intent == "risk_check"
|
||||
assert refreshed.ontology_entities_json == [{"type": "claim", "value": "c1"}]
|
||||
assert refreshed.risk_signals_json == [{"code": "duplicate_invoice"}]
|
||||
assert refreshed.canonical_subject_key == "claim:c1"
|
||||
assert dashboard.total_observations == 2
|
||||
assert dashboard.high_or_above_count == 2
|
||||
assert dashboard.confirmed_count == 1
|
||||
assert dashboard.total_amount == 2400.0
|
||||
assert dashboard.level_distribution["high"] == 2
|
||||
assert dashboard.signal_distribution["duplicate_invoice"] == 1
|
||||
assert dashboard.department_distribution["风控部"] == 2
|
||||
assert dashboard.expense_type_distribution["travel"] == 2
|
||||
assert dashboard.employee_grade_distribution["P6"] == 2
|
||||
assert dashboard.supplier_distribution["上海差旅供应商"] == 2
|
||||
assert dashboard.top_departments[0]["name"] == "风控部"
|
||||
assert dashboard.top_departments[0]["amount"] == 2400.0
|
||||
assert dashboard.top_employees[0]["name"] == "风险员工"
|
||||
assert dashboard.top_suppliers[0]["name"] == "上海差旅供应商"
|
||||
assert dashboard.top_expense_types[0]["name"] == "travel"
|
||||
assert dashboard.top_rules[0]["name"] == "policy.duplicate_invoice"
|
||||
assert dashboard.top_risk_signals[0]["name"] in {
|
||||
"duplicate_invoice",
|
||||
"preapproval_absent",
|
||||
}
|
||||
assert dashboard.daily_trend
|
||||
assert history[0].risk_signal == "duplicate_invoice"
|
||||
assert history[0].confirmed_count == 1
|
||||
|
||||
|
||||
def test_platform_rule_flags_are_persisted_as_risk_observations() -> None:
|
||||
with _build_session() as db:
|
||||
claim = _claim_orm("c-platform", "BX-PLATFORM")
|
||||
db.add(claim)
|
||||
db.flush()
|
||||
|
||||
observations = RiskObservationService(db).upsert_platform_risk_flags(
|
||||
claim,
|
||||
[
|
||||
{
|
||||
"hit_source": "rule_center",
|
||||
"rule_type": "risk",
|
||||
"rule_code": "risk.invoice.duplicate_invoice",
|
||||
"rule_version": "v1.2.0",
|
||||
"severity": "critical",
|
||||
"action": "block",
|
||||
"label": "重复发票校验",
|
||||
"message": "票据号码已在其他报销单中出现。",
|
||||
"evidence": {"invoice_no": "INV-001"},
|
||||
}
|
||||
],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert len(observations) == 1
|
||||
persisted = db.query(RiskObservation).filter_by(claim_id="c-platform").one()
|
||||
assert persisted.risk_signal == "duplicate_invoice"
|
||||
assert persisted.risk_level == "critical"
|
||||
assert persisted.source == "rule_center"
|
||||
assert persisted.algorithm_version == "v1.2.0"
|
||||
assert persisted.contribution_scores_json == {"S_rule": 100}
|
||||
|
||||
|
||||
def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback() -> None:
|
||||
client, session_factory = _build_client()
|
||||
with session_factory() as db:
|
||||
service = RiskObservationService(db)
|
||||
service.upsert_observation(
|
||||
_observation_payload("risk:c1:duplicate_invoice"),
|
||||
execution_log_id="exec-1",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
list_response = client.get("/api/v1/risk-observations", params={"risk_level": "high"})
|
||||
execution_log_response = client.get("/api/v1/risk-observations/execution-log/exec-1")
|
||||
detail_response = client.get("/api/v1/risk-observations/risk:c1:duplicate_invoice")
|
||||
dashboard_response = client.get("/api/v1/risk-observations/dashboard")
|
||||
feedback_response = client.post(
|
||||
"/api/v1/risk-observations/risk:c1:duplicate_invoice/feedback",
|
||||
json={"feedback_type": "false_positive", "actor": "auditor", "comment": "误报"},
|
||||
)
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["total"] == 1
|
||||
assert execution_log_response.status_code == 200
|
||||
assert len(execution_log_response.json()) == 1
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.json()["risk_signal"] == "duplicate_invoice"
|
||||
assert dashboard_response.status_code == 200
|
||||
assert dashboard_response.json()["total_observations"] == 1
|
||||
assert "top_departments" in dashboard_response.json()
|
||||
assert feedback_response.status_code == 200
|
||||
assert feedback_response.json()["feedback_type"] == "false_positive"
|
||||
|
||||
updated_detail_response = client.get("/api/v1/risk-observations/risk:c1:duplicate_invoice")
|
||||
assert updated_detail_response.status_code == 200
|
||||
assert updated_detail_response.json()["feedback_items"][0]["feedback_type"] == "false_positive"
|
||||
|
||||
with session_factory() as db:
|
||||
observation = db.query(RiskObservation).filter_by(
|
||||
observation_key="risk:c1:duplicate_invoice"
|
||||
).one()
|
||||
assert observation.status == "false_positive"
|
||||
assert observation.feedback_status == "false_positive"
|
||||
|
||||
|
||||
def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None:
|
||||
with _build_session() as db:
|
||||
service = RiskObservationService(db)
|
||||
service.upsert_observation(_observation_payload("risk:c1:duplicate_invoice"))
|
||||
db.commit()
|
||||
|
||||
feedback = service.create_feedback(
|
||||
"risk:c1:duplicate_invoice",
|
||||
RiskObservationFeedbackCreate(
|
||||
feedback_type="comment",
|
||||
action="rewrite",
|
||||
actor="auditor",
|
||||
comment="建议生成候选规则",
|
||||
payload_json={
|
||||
"decision": "candidate_rule_rewrite",
|
||||
"candidate_rule_source": "risk_observation_feedback",
|
||||
"confidence_score": 0.76,
|
||||
"escalation_target": "finance_manager",
|
||||
"supplement_required": True,
|
||||
},
|
||||
),
|
||||
)
|
||||
observation = service.get_observation("risk:c1:duplicate_invoice")
|
||||
assert observation is not None
|
||||
|
||||
replay_set = AlgorithmReplaySetBuilder().build_from_observations(
|
||||
"replay-set-1",
|
||||
[
|
||||
{
|
||||
"observation_key": observation.observation_key,
|
||||
"claim_id": observation.claim_id,
|
||||
"risk_signal": observation.risk_signal,
|
||||
"risk_score": observation.risk_score,
|
||||
"risk_level": observation.risk_level,
|
||||
"algorithm_version": observation.algorithm_version,
|
||||
"feedback_status": observation.feedback_status,
|
||||
"ontology_json": observation.ontology_json,
|
||||
"decision_trace": observation.decision_trace_json,
|
||||
}
|
||||
],
|
||||
created_at=datetime(2026, 5, 30, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert feedback.decision == "candidate_rule_rewrite"
|
||||
assert feedback.candidate_rule_source == "risk_observation_feedback"
|
||||
assert feedback.confidence_score == 0.76
|
||||
assert feedback.escalation_target == "finance_manager"
|
||||
assert feedback.supplement_required is True
|
||||
assert replay_set.replay_set_id == "replay-set-1"
|
||||
assert replay_set.cases[0].claim_id == "c1"
|
||||
assert replay_set.cases[0].ontology_version == "ontology.v1"
|
||||
assert replay_set.cases[0].algorithm_version == "financial_risk_graph.v1"
|
||||
|
||||
|
||||
def _build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def _build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
app = FastAPI()
|
||||
app.include_router(risk_observations_router, prefix="/api/v1")
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return TestClient(app), session_factory
|
||||
|
||||
|
||||
def _observation_payload(observation_key: str) -> dict:
|
||||
return {
|
||||
"observation_key": observation_key,
|
||||
"subject_type": "expense_claim",
|
||||
"subject_key": "claim:c1",
|
||||
"subject_label": "BX-001",
|
||||
"claim_id": "c1",
|
||||
"claim_no": "BX-001",
|
||||
"risk_type": "duplicate_invoice",
|
||||
"risk_signal": "duplicate_invoice",
|
||||
"title": "Duplicate invoice risk",
|
||||
"description": "Same invoice appears in multiple claims.",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high",
|
||||
"confidence_score": "0.81",
|
||||
"control_stage": "reimbursement",
|
||||
"control_mode": "risk_observation",
|
||||
"automation_mode": "semi_auto_review",
|
||||
"source": "financial_risk_graph",
|
||||
"algorithm_version": "financial_risk_graph.v1",
|
||||
"contribution_scores": {"S_rule": 82, "S_graph": 95},
|
||||
"baseline": {"scope": "expense_type", "sample_size": 4},
|
||||
"evidence": [
|
||||
{
|
||||
"code": "duplicate_invoice_graph",
|
||||
"source": "graph",
|
||||
"metadata": {"vendor_name": "上海差旅供应商"},
|
||||
}
|
||||
],
|
||||
"graph_node_keys": ["claim:c1", "vendor:上海差旅供应商"],
|
||||
"graph_edge_keys": [],
|
||||
"policy_refs": ["policy.duplicate_invoice"],
|
||||
"similar_case_claim_ids": ["c2"],
|
||||
"ontology_json": {
|
||||
"gate": "review",
|
||||
"ontology_parse_id": "parse-1",
|
||||
"ontology_version": "ontology.v1",
|
||||
"domain": "expense",
|
||||
"scenario": "reimbursement",
|
||||
"intent": "risk_check",
|
||||
"ontology_entities_json": [{"type": "claim", "value": "c1"}],
|
||||
"risk_signals_json": [{"code": "duplicate_invoice"}],
|
||||
"canonical_subject_key": "claim:c1",
|
||||
},
|
||||
"decision_trace": {
|
||||
"formula": "weighted",
|
||||
"sampling_strategy": {"strategy": "focused_review", "threshold": 70},
|
||||
"evaluation_case_id": "case-duplicate-invoice",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _employee_orm() -> Employee:
|
||||
return Employee(
|
||||
id="emp-risk",
|
||||
employee_no="E-RISK",
|
||||
name="风险员工",
|
||||
email="risk.employee@example.com",
|
||||
position="高级专员",
|
||||
grade="P6",
|
||||
)
|
||||
|
||||
|
||||
def _claim_orm(claim_id: str, claim_no: str) -> ExpenseClaim:
|
||||
now = datetime(2026, 5, 20, tzinfo=UTC)
|
||||
return ExpenseClaim(
|
||||
id=claim_id,
|
||||
claim_no=claim_no,
|
||||
employee_id="emp-risk",
|
||||
employee_name="风险员工",
|
||||
department_id="dept-risk",
|
||||
department_name="风控部",
|
||||
expense_type="travel",
|
||||
reason="客户拜访",
|
||||
location="上海",
|
||||
amount=Decimal("1200"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=now,
|
||||
submitted_at=now,
|
||||
status="submitted",
|
||||
approval_stage="manager_review",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
148
server/tests/test_risk_rule_dsl_examples.py
Normal file
148
server/tests/test_risk_rule_dsl_examples.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.services.risk_rule_dsl_examples import (
|
||||
get_risk_rule_dsl_example,
|
||||
list_risk_rule_dsl_examples,
|
||||
)
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
from app.services.risk_rule_generation_ontology import FIELD_ONTOLOGY
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
def test_dsl_examples_pass_validator() -> None:
|
||||
examples = list_risk_rule_dsl_examples()
|
||||
|
||||
assert {example["code"] for example in examples} == {
|
||||
"travel_city_mismatch",
|
||||
"lodging_date_outside_range",
|
||||
"budget_threshold",
|
||||
"duplicate_invoice",
|
||||
"entertainment_per_capita_over_limit",
|
||||
}
|
||||
for example in examples:
|
||||
manifest = example["manifest"]
|
||||
normalized = validate_risk_rule_draft(
|
||||
manifest["params"],
|
||||
fields=list(FIELD_ONTOLOGY),
|
||||
natural_language=example["natural_language"],
|
||||
)
|
||||
|
||||
assert normalized["template_key"] == COMPOSITE_RULE_TEMPLATE_KEY
|
||||
assert normalized["dsl_validation"]["status"] == "passed"
|
||||
assert normalized["conditions"]
|
||||
assert not normalized.get("keywords")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "hit_contexts", "pass_contexts"),
|
||||
[
|
||||
(
|
||||
"travel_city_mismatch",
|
||||
[{"document_info": {"route_cities": ["武汉", "上海"]}}],
|
||||
[{"document_info": {"route_cities": ["武汉", "北京"]}}],
|
||||
),
|
||||
(
|
||||
"lodging_date_outside_range",
|
||||
[{"document_info": {"stay_start_date": "2026-05-08", "stay_end_date": "2026-05-13"}}],
|
||||
[{"document_info": {"stay_start_date": "2026-05-10", "stay_end_date": "2026-05-12"}}],
|
||||
),
|
||||
(
|
||||
"budget_threshold",
|
||||
[{"budget_context": {"remaining_amount": "1000.00"}}],
|
||||
[{"budget_context": {"remaining_amount": "2000.00"}}],
|
||||
),
|
||||
(
|
||||
"duplicate_invoice",
|
||||
[
|
||||
{"document_info": {"invoice_no": "INV-20260530-001"}},
|
||||
{"document_info": {"invoice_no": "INV-20260530-001"}},
|
||||
],
|
||||
[
|
||||
{"document_info": {"invoice_no": "INV-20260530-001"}},
|
||||
{"document_info": {"invoice_no": "INV-20260530-002"}},
|
||||
],
|
||||
),
|
||||
(
|
||||
"entertainment_per_capita_over_limit",
|
||||
[],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_dsl_examples_execute_hit_and_pass(
|
||||
code: str,
|
||||
hit_contexts: list[dict],
|
||||
pass_contexts: list[dict],
|
||||
) -> None:
|
||||
example = get_risk_rule_dsl_example(code)
|
||||
assert example is not None
|
||||
executor = RiskRuleTemplateExecutor()
|
||||
claim = _claim(amount=Decimal("1200.00"))
|
||||
if code == "entertainment_per_capita_over_limit":
|
||||
claim.expense_type = "业务招待费"
|
||||
claim.reason = "客户接待餐费"
|
||||
claim.attendee_count = 2
|
||||
claim.per_capita_amount = Decimal("600.00")
|
||||
|
||||
hit_result = executor.evaluate(example["manifest"], claim=claim, contexts=hit_contexts)
|
||||
if code == "entertainment_per_capita_over_limit":
|
||||
claim.per_capita_amount = Decimal("400.00")
|
||||
pass_result = executor.evaluate(example["manifest"], claim=claim, contexts=pass_contexts)
|
||||
|
||||
assert hit_result is not None
|
||||
assert pass_result is None
|
||||
|
||||
|
||||
def test_duplicate_invoice_example_reports_duplicate_evidence() -> None:
|
||||
example = get_risk_rule_dsl_example("duplicate_invoice")
|
||||
assert example is not None
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
example["manifest"],
|
||||
claim=_claim(),
|
||||
contexts=[
|
||||
{"document_info": {"invoice_no": "INV-DUP-001"}},
|
||||
{"document_info": {"invoice_no": "INV-DUP-001"}},
|
||||
],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
condition = result["evidence"]["conditions"][0]
|
||||
assert condition["operator"] == "duplicate_value"
|
||||
assert condition["duplicates"] == ["inv-dup-001"]
|
||||
|
||||
|
||||
def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-RISK-RULE-DSL",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="北京出差项目支持",
|
||||
location="北京",
|
||||
amount=amount,
|
||||
currency="CNY",
|
||||
invoice_count=2,
|
||||
occurred_at=datetime(2026, 5, 10, tzinfo=UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.trip_start_date = date(2026, 5, 10)
|
||||
claim.trip_end_date = date(2026, 5, 12)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date(2026, 5, 10),
|
||||
item_type="travel",
|
||||
item_reason="北京出差",
|
||||
item_location="北京",
|
||||
item_amount=amount,
|
||||
invoice_id="INV-ITEM-001",
|
||||
)
|
||||
]
|
||||
return claim
|
||||
124
server/tests/test_risk_rule_dsl_validator.py
Normal file
124
server/tests/test_risk_rule_dsl_validator.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_generation_ontology import RiskRuleField
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
FIELDS = [
|
||||
RiskRuleField("claim.location", "申报地点", "text", "claim", ("目的地", "城市")),
|
||||
RiskRuleField("attachment.hotel_city", "住宿城市", "text", "attachment", ("酒店城市",)),
|
||||
RiskRuleField("attachment.route_cities", "行程城市", "list", "attachment", ("交通票城市",)),
|
||||
RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额",)),
|
||||
RiskRuleField("budget.remaining_amount", "预算可用余额", "number", "budget", ("预算余额",)),
|
||||
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由",)),
|
||||
RiskRuleField("attachment.ocr_text", "票据全文", "text", "attachment", ("OCR",)),
|
||||
]
|
||||
|
||||
|
||||
def test_validator_rewrites_city_keyword_rule_to_structured_compare() -> None:
|
||||
draft = {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": ["attachment.hotel_city", "attachment.route_cities", "claim.location"],
|
||||
"keywords": ["绕行", "跨城办事"],
|
||||
"condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
|
||||
}
|
||||
|
||||
normalized = validate_risk_rule_draft(
|
||||
draft,
|
||||
fields=FIELDS,
|
||||
natural_language="差旅报销时,住宿或交通票据城市必须与申报目的地一致,未说明绕行时进入复核。",
|
||||
)
|
||||
|
||||
assert normalized["template_key"] == "field_compare_v1"
|
||||
assert normalized["semantic_type"] == "travel_route_city_consistency"
|
||||
assert normalized["keywords"] == []
|
||||
assert "city_rule_normalized_to_structured_compare" in normalized["dsl_validation"]["issues"]
|
||||
|
||||
|
||||
def test_validator_rewrites_budget_keyword_rule_to_numeric_compare() -> None:
|
||||
draft = {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": ["claim.amount", "budget.remaining_amount", "claim.reason"],
|
||||
"keywords": ["超预算"],
|
||||
"condition_summary": "检查金额字段是否出现预算风险关键词",
|
||||
}
|
||||
|
||||
normalized = validate_risk_rule_draft(
|
||||
draft,
|
||||
fields=FIELDS,
|
||||
natural_language="费用申请时,若申报金额超过预算可用余额,则提示风险并要求补充审批说明。",
|
||||
)
|
||||
|
||||
assert normalized["template_key"] == "composite_rule_v1"
|
||||
assert normalized["keywords"] == []
|
||||
assert normalized["conditions"][0]["operator"] == "numeric_compare"
|
||||
assert normalized["conditions"][0]["left_fields"] == ["claim.amount"]
|
||||
assert normalized["conditions"][0]["right_fields"] == ["budget.remaining_amount"]
|
||||
assert "风险关键词" not in normalized["condition_summary"]
|
||||
|
||||
|
||||
def test_validator_builds_numeric_condition_for_empty_composite_fallback() -> None:
|
||||
normalized = validate_risk_rule_draft(
|
||||
{"template_key": "composite_rule_v1", "field_keys": ["claim.amount", "budget.remaining_amount"]},
|
||||
fields=FIELDS,
|
||||
natural_language="费用申请时,若申报金额超过预算可用余额,则提示风险。",
|
||||
)
|
||||
|
||||
assert normalized["template_key"] == "composite_rule_v1"
|
||||
assert normalized["conditions"][0]["operator"] == "numeric_compare"
|
||||
assert normalized["hit_logic"] == {"all": ["amount_exceeds_budget"]}
|
||||
assert "empty_composite_rule_built_from_structured_fields" in normalized["dsl_validation"]["issues"]
|
||||
|
||||
|
||||
def test_numeric_compare_condition_executes_against_budget_context() -> None:
|
||||
manifest = {
|
||||
"template_key": "composite_rule_v1",
|
||||
"params": {
|
||||
"template_key": "composite_rule_v1",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_budget",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": ["claim.amount"],
|
||||
"right_fields": ["budget.remaining_amount"],
|
||||
"compare": "gt",
|
||||
}
|
||||
],
|
||||
"hit_logic": {"all": ["amount_exceeds_budget"]},
|
||||
"message_template": "申报金额超过预算可用余额。",
|
||||
},
|
||||
}
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-BUDGET-RISK",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="北京出差",
|
||||
location="北京",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, tzinfo=UTC),
|
||||
status="draft",
|
||||
)
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[{"budget_context": {"remaining_amount": "1000.00"}}],
|
||||
)
|
||||
assert result is not None
|
||||
assert result["message"] == "申报金额超过预算可用余额。"
|
||||
assert result["evidence"]["condition_results"]["amount_exceeds_budget"] is True
|
||||
|
||||
claim.amount = Decimal("800.00")
|
||||
assert RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[{"budget_context": {"remaining_amount": "1000.00"}}],
|
||||
) is None
|
||||
119
server/tests/test_risk_rule_explainability.py
Normal file
119
server/tests/test_risk_rule_explainability.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
AgentAssetRiskRuleSimulationRequest,
|
||||
)
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
|
||||
|
||||
class NullRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_generated_risk_rule_contains_semantic_plan_and_flow_model(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
asset_id = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅票据城市一致性校验",
|
||||
natural_language=(
|
||||
"差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;"
|
||||
"未说明绕行、跨城或改签原因时标记高风险。"
|
||||
),
|
||||
requires_attachment=True,
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
payload = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
|
||||
assert payload["semantic_plan"]["required_fields"]
|
||||
assert payload["semantic_plan"]["risk_action"]["risk_level"] == payload["outcomes"]["fail"]["severity"]
|
||||
assert payload["flow_model"]["source"] == "json_dsl"
|
||||
assert payload["flow_model"]["nodes"][0]["id"] == "start"
|
||||
assert any(node["type"] == "risk" for node in payload["flow_model"]["nodes"])
|
||||
assert payload["metadata"]["flow_model"]["nodes"] == payload["flow_model"]["nodes"]
|
||||
assert payload["flow_diagram_svg"].startswith("<svg")
|
||||
assert "风险关键词" not in payload["semantic_plan"]["judgment_steps"][0]["description"]
|
||||
|
||||
|
||||
def test_simulation_returns_execution_trace_for_ticket_city_mismatch(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
asset_id = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="当前差旅票据城市一致性规则",
|
||||
natural_language=(
|
||||
"差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;"
|
||||
"未说明绕行、跨城或改签原因时标记高风险。"
|
||||
),
|
||||
requires_attachment=True,
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
service = AgentAssetService(db)
|
||||
service.rule_library_manager = manager
|
||||
|
||||
simulation = service.simulate_risk_rule_message(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleSimulationRequest(
|
||||
message="去北京出差3天",
|
||||
attachments=[
|
||||
{
|
||||
"name": "train-ticket.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"ocr_text": "武汉 到 上海",
|
||||
"summary": "高铁票 武汉-上海",
|
||||
"document_fields": [
|
||||
{"key": "route", "label": "行程路线", "value": "武汉-上海"}
|
||||
],
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
assert simulation.ready is True
|
||||
assert simulation.hit is True
|
||||
assert simulation.normalized_fields["claim.location"] == "北京"
|
||||
assert simulation.trace["matched"] is True
|
||||
assert "hit" in simulation.trace["path_node_ids"]
|
||||
assert simulation.trace["steps"]
|
||||
108
server/tests/test_risk_rule_flow_diagram_model.py
Normal file
108
server/tests/test_risk_rule_flow_diagram_model.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramField,
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
build_risk_rule_flow_diagram_spec,
|
||||
)
|
||||
|
||||
|
||||
FIELDS = (
|
||||
RiskRuleFlowDiagramField(key="claim.amount", label="申请金额"),
|
||||
RiskRuleFlowDiagramField(key="budget.remaining_amount", label="可用预算"),
|
||||
RiskRuleFlowDiagramField(key="claim.reason", label="申请事由"),
|
||||
)
|
||||
|
||||
|
||||
def test_flow_diagram_spec_prefers_flow_model_nodes() -> None:
|
||||
spec = build_risk_rule_flow_diagram_spec(
|
||||
{"name": "预算余额校验", "metadata": {"condition_summary": "legacy summary"}},
|
||||
fields=FIELDS,
|
||||
domain_label="差旅费",
|
||||
severity="high",
|
||||
severity_label="高风险",
|
||||
flow_model={
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "title": "开始", "description": "费用申请提交"},
|
||||
{
|
||||
"id": "evidence",
|
||||
"type": "evidence",
|
||||
"title": "字段事实",
|
||||
"description": "读取申请金额与可用预算",
|
||||
"fields": ["claim.amount", "budget.remaining_amount"],
|
||||
},
|
||||
{
|
||||
"id": "amount_exceeds_budget",
|
||||
"type": "decision",
|
||||
"title": "金额超过预算",
|
||||
"description": "申请金额大于可用预算余额",
|
||||
},
|
||||
{"id": "pass", "type": "pass", "description": "预算充足,继续流转"},
|
||||
{"id": "hit", "type": "risk", "description": "进入预算复核"},
|
||||
],
|
||||
"metadata": {"hit_logic": "amount_exceeds_budget"},
|
||||
},
|
||||
)
|
||||
|
||||
assert spec.start == "费用申请提交"
|
||||
assert spec.fact_lines[:2] == (
|
||||
"A=申请金额[claim.amount]",
|
||||
"B=可用预算[budget.remaining_amount]",
|
||||
)
|
||||
assert spec.condition_lines == ("金额超过预算: 申请金额大于可用预算余额",)
|
||||
assert spec.hit_logic == "amount_exceeds_budget"
|
||||
svg = RiskRuleFlowDiagramRenderer().render(spec)
|
||||
assert "金额超过预算" in svg
|
||||
assert "#dc2626" in svg
|
||||
|
||||
|
||||
def test_flow_diagram_spec_falls_back_to_dsl_when_flow_model_missing() -> None:
|
||||
spec = build_risk_rule_flow_diagram_spec(
|
||||
{
|
||||
"name": "重复发票校验",
|
||||
"params": {
|
||||
"conditions": [
|
||||
{
|
||||
"id": "same_invoice_no_repeated",
|
||||
"operator": "duplicate_value",
|
||||
"fields": ["claim.reason"],
|
||||
}
|
||||
],
|
||||
"hit_logic": {"all": ["same_invoice_no_repeated"]},
|
||||
},
|
||||
"metadata": {"condition_summary": "发票号重复时命中"},
|
||||
},
|
||||
fields=FIELDS,
|
||||
domain_label="通用",
|
||||
severity="medium",
|
||||
severity_label="中风险",
|
||||
flow_model={},
|
||||
)
|
||||
|
||||
assert spec.condition_lines == ("same_invoice_no_repeated: 申请事由 出现重复值",)
|
||||
assert spec.hit_logic == "same_invoice_no_repeated"
|
||||
assert "发票号重复时命中" in spec.basis
|
||||
|
||||
|
||||
def test_flow_diagram_spec_compresses_too_many_decision_nodes() -> None:
|
||||
nodes = [{"id": "start", "type": "start", "description": "提交单据"}]
|
||||
nodes.extend(
|
||||
{
|
||||
"id": f"condition_{index}",
|
||||
"type": "decision",
|
||||
"title": f"判断{index}",
|
||||
"description": f"第{index}个判断条件",
|
||||
}
|
||||
for index in range(1, 7)
|
||||
)
|
||||
spec = build_risk_rule_flow_diagram_spec(
|
||||
{"name": "复杂规则"},
|
||||
fields=FIELDS,
|
||||
domain_label="通用",
|
||||
severity="low",
|
||||
severity_label="低风险",
|
||||
flow_model={"nodes": nodes},
|
||||
)
|
||||
|
||||
assert len(spec.condition_lines) == 4
|
||||
assert "另有 2 个判断节点" in spec.condition_lines[-1]
|
||||
149
server/tests/test_risk_rule_revision_endpoints.py
Normal file
149
server/tests/test_risk_rule_revision_endpoints.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
|
||||
|
||||
class NullRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return TestClient(app), session_factory
|
||||
|
||||
|
||||
def test_update_risk_rule_draft_endpoint_updates_unpublished_rule(tmp_path) -> None:
|
||||
client, session_factory = build_client()
|
||||
asset_id = _create_rule(session_factory, tmp_path)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/agent-assets/{asset_id}/risk-rules/draft",
|
||||
headers=_finance_headers(),
|
||||
json={
|
||||
"rule_title": "差旅绕行说明校验",
|
||||
"expense_category": "travel",
|
||||
"natural_language": "差旅报销存在绕行但未说明原因时,进入风险复核。",
|
||||
"requires_attachment": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["name"] == "差旅绕行说明校验"
|
||||
assert payload["description"] == "差旅报销存在绕行但未说明原因时,进入风险复核。"
|
||||
assert payload["scenario_json"] == ["差旅费"]
|
||||
assert payload["config_json"]["requires_attachment"] is True
|
||||
assert payload["config_json"]["generation_status"] == "draft_updated"
|
||||
assert payload["config_json"]["last_operation"]["action"] == "update_draft"
|
||||
|
||||
|
||||
def test_update_published_risk_rule_draft_endpoint_is_blocked(tmp_path) -> None:
|
||||
client, session_factory = build_client()
|
||||
asset_id = _create_rule(session_factory, tmp_path)
|
||||
_mark_rule_published(session_factory, asset_id)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/agent-assets/{asset_id}/risk-rules/draft",
|
||||
headers=_finance_headers(),
|
||||
json={"natural_language": "已上线规则不能被草稿接口直接覆盖。"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "未上线" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_create_risk_rule_revision_endpoint_keeps_active_version(tmp_path) -> None:
|
||||
client, session_factory = build_client()
|
||||
asset_id = _create_rule(session_factory, tmp_path)
|
||||
_mark_rule_published(session_factory, asset_id)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/agent-assets/{asset_id}/risk-rules/revisions",
|
||||
headers=_finance_headers(),
|
||||
json={
|
||||
"rule_title": "票据城市一致性复核",
|
||||
"natural_language": "票据城市与申报目的地不一致时,要求补充说明。",
|
||||
"requires_attachment": True,
|
||||
"change_reason": "补充城市一致性判断。",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
revision = payload["config_json"]["revision_draft"]
|
||||
assert payload["status"] == AgentAssetStatus.ACTIVE.value
|
||||
assert payload["published_version"] == "v0.1.0"
|
||||
assert payload["working_version"] == "v0.1.1"
|
||||
assert revision["version"] == "v0.1.1"
|
||||
assert revision["base_version"] == "v0.1.0"
|
||||
assert revision["generation_request"]["natural_language"] == "票据城市与申报目的地不一致时,要求补充说明。"
|
||||
assert payload["config_json"]["last_operation"]["action"] == "create_revision"
|
||||
|
||||
|
||||
def _create_rule(session_factory: sessionmaker[Session], tmp_path) -> str:
|
||||
with session_factory() as db:
|
||||
return RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅规则草稿",
|
||||
natural_language="差旅报销事由缺失时,提示补充说明。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
|
||||
def _mark_rule_published(session_factory: sessionmaker[Session], asset_id: str) -> None:
|
||||
with session_factory() as db:
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
asset.current_version = "v0.1.0"
|
||||
asset.published_version = "v0.1.0"
|
||||
asset.working_version = "v0.1.0"
|
||||
db.add(asset)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _finance_headers() -> dict[str, str]:
|
||||
return {
|
||||
"x-auth-username": "finance",
|
||||
"x-auth-name": "finance",
|
||||
"x-auth-role-codes": "finance",
|
||||
"x-actor": "finance",
|
||||
}
|
||||
123
server/tests/test_risk_rule_revision_service.py
Normal file
123
server/tests/test_risk_rule_revision_service.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleDraftUpdate,
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
AgentAssetRiskRuleRevisionCreate,
|
||||
)
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
|
||||
|
||||
|
||||
class NullRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def build_session() -> 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_update_unpublished_risk_rule_draft_updates_business_fields(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
asset_id = _create_rule(db, tmp_path)
|
||||
updated = AgentAssetRiskRuleRevisionService(db).update_unpublished_draft(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleDraftUpdate(
|
||||
rule_title="差旅绕行说明校验",
|
||||
expense_category="travel",
|
||||
natural_language="差旅报销存在绕行但未说明原因时,进入风险复核。",
|
||||
requires_attachment=True,
|
||||
),
|
||||
actor="finance",
|
||||
)
|
||||
|
||||
assert updated.name == "差旅绕行说明校验"
|
||||
assert updated.description == "差旅报销存在绕行但未说明原因时,进入风险复核。"
|
||||
assert updated.scenario_json == ["差旅费"]
|
||||
assert updated.config_json["requires_attachment"] is True
|
||||
assert updated.config_json["generation_request"]["natural_language"] == updated.description
|
||||
assert updated.config_json["last_operation"]["action"] == "update_draft"
|
||||
|
||||
|
||||
def test_update_published_rule_requires_revision(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
asset_id = _create_rule(db, tmp_path)
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
asset.published_version = asset.current_version
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
AgentAssetRiskRuleRevisionService(db).update_unpublished_draft(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleDraftUpdate(natural_language="已上线规则不能直接覆盖。"),
|
||||
actor="finance",
|
||||
)
|
||||
|
||||
|
||||
def test_create_revision_draft_for_published_rule_does_not_overwrite_active_version(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
asset_id = _create_rule(db, tmp_path)
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
asset.published_version = "v0.1.0"
|
||||
asset.current_version = "v0.1.0"
|
||||
asset.working_version = "v0.1.0"
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
|
||||
updated = AgentAssetRiskRuleRevisionService(db).create_revision_draft(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleRevisionCreate(
|
||||
rule_title="差旅票据城市复核",
|
||||
natural_language="票据城市与申报目的地不一致时,要求补充说明。",
|
||||
requires_attachment=True,
|
||||
change_reason="补充城市一致性判断。",
|
||||
),
|
||||
actor="manager",
|
||||
)
|
||||
|
||||
revision = updated.config_json["revision_draft"]
|
||||
assert updated.status == AgentAssetStatus.ACTIVE.value
|
||||
assert updated.published_version == "v0.1.0"
|
||||
assert updated.working_version == "v0.1.1"
|
||||
assert revision["version"] == "v0.1.1"
|
||||
assert revision["base_version"] == "v0.1.0"
|
||||
assert revision["generation_request"]["natural_language"] == "票据城市与申报目的地不一致时,要求补充说明。"
|
||||
assert updated.config_json["last_operation"]["action"] == "create_revision"
|
||||
assert db.query(AgentAssetVersion).filter_by(asset_id=asset_id, version="v0.1.1").one()
|
||||
|
||||
|
||||
def _create_rule(db: Session, tmp_path) -> str:
|
||||
return RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
).generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅规则草稿",
|
||||
natural_language="差旅报销事由缺失时,提示补充说明。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
195
server/tests/test_risk_rule_semantic_plan_generation.py
Normal file
195
server/tests/test_risk_rule_semantic_plan_generation.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages
|
||||
from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload
|
||||
|
||||
|
||||
class SemanticPlanEnvelopeRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"semantic_plan": {
|
||||
"rule_intent": "费用申请金额不得超过可用预算",
|
||||
"judgment_steps": [
|
||||
"读取申请金额",
|
||||
"读取可用预算余额",
|
||||
"比较申请金额是否大于可用预算",
|
||||
],
|
||||
},
|
||||
"dsl": {
|
||||
"name": "预算余额超额校验",
|
||||
"description": "申请金额超过当前可用预算时提示风险。",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "budget_available_balance_check",
|
||||
"field_keys": ["claim.amount", "budget.remaining_amount"],
|
||||
"condition_summary": "claim.amount > budget.remaining_amount",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_budget",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": ["claim.amount"],
|
||||
"right_fields": ["budget.remaining_amount"],
|
||||
"compare": "gt",
|
||||
}
|
||||
],
|
||||
"hit_logic": {"all": ["amount_exceeds_budget"]},
|
||||
"message_template": "申请金额超过当前可用预算余额。",
|
||||
"keywords": [],
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
class SemanticPlanOnlyRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"semantic_plan": {
|
||||
"rule_intent": "费用申请金额超过可用预算余额时提示风险",
|
||||
"required_fields": [
|
||||
{"field": "claim.amount", "label": "申请金额"},
|
||||
{"field": "budget.remaining_amount", "label": "可用预算余额"},
|
||||
],
|
||||
"judgment_steps": [
|
||||
"读取申请金额 claim.amount",
|
||||
"读取可用预算余额 budget.remaining_amount",
|
||||
"若申请金额超过可用预算余额则命中预算风险",
|
||||
],
|
||||
"risk_action": {"message": "要求补充预算审批说明"},
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def build_session() -> 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_prompt_requires_semantic_plan_then_dsl() -> None:
|
||||
messages = build_risk_rule_compiler_messages(
|
||||
domain="expense",
|
||||
domain_label="报销",
|
||||
business_stage="expense_application",
|
||||
business_stage_label="费用申请",
|
||||
expense_category="travel",
|
||||
expense_category_label="差旅费",
|
||||
natural_language="申请金额超过预算余额时提示风险。",
|
||||
available_fields=[{"key": "claim.amount", "label": "申请金额", "type": "number", "source": "claim"}],
|
||||
)
|
||||
|
||||
request_payload = json.loads(messages[1]["content"])
|
||||
required_shape = request_payload["required_json_shape"]
|
||||
assert "semantic_plan" in required_shape
|
||||
assert "dsl" in required_shape
|
||||
assert "semantic_plan 和 dsl" in messages[0]["content"]
|
||||
|
||||
|
||||
def test_semantic_plan_envelope_is_unwrapped_and_persisted(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
service = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=SemanticPlanEnvelopeRuntimeChatService(),
|
||||
)
|
||||
asset_id = service.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
business_stage="expense_application",
|
||||
expense_category="travel",
|
||||
rule_title="预算余额超额校验",
|
||||
natural_language="费用申请时,如果申请金额超过当前可用预算余额,则提示预算风险。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
payload = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
|
||||
assert payload["template_key"] == "composite_rule_v1"
|
||||
assert payload["params"]["conditions"][0]["operator"] == "numeric_compare"
|
||||
assert payload["metadata"]["model_semantic_plan"]["rule_intent"] == "费用申请金额不得超过可用预算"
|
||||
assert payload["semantic_plan"]["judgment_steps"]
|
||||
|
||||
|
||||
def test_semantic_plan_only_response_can_generate_standard_dsl(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
service = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=SemanticPlanOnlyRuntimeChatService(),
|
||||
)
|
||||
asset_id = service.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
business_stage="expense_application",
|
||||
expense_category="travel",
|
||||
rule_title="预算余额语义计划校验",
|
||||
natural_language="费用申请金额超过可用预算余额时提示风险,并要求补充审批说明。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
payload = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
|
||||
assert payload["params"]["conditions"][0]["operator"] == "numeric_compare"
|
||||
assert payload["params"]["conditions"][0]["left_fields"] == ["claim.amount"]
|
||||
assert payload["params"]["conditions"][0]["right_fields"] == ["budget.remaining_amount"]
|
||||
assert payload["metadata"]["model_semantic_plan"]["required_fields"]
|
||||
|
||||
|
||||
def test_unwrap_semantic_plan_payload_keeps_legacy_payload_compatible() -> None:
|
||||
legacy = {"template_key": "field_required_v1", "field_keys": ["claim.reason"]}
|
||||
assert unwrap_semantic_plan_payload(legacy) == legacy
|
||||
|
||||
wrapped = unwrap_semantic_plan_payload(
|
||||
{
|
||||
"semantic_plan": {"rule_intent": "预算校验"},
|
||||
"dsl": {"template_key": "composite_rule_v1", "field_keys": ["claim.amount"]},
|
||||
}
|
||||
)
|
||||
assert wrapped["template_key"] == "composite_rule_v1"
|
||||
assert wrapped["model_semantic_plan"]["rule_intent"] == "预算校验"
|
||||
|
||||
plan_only = unwrap_semantic_plan_payload(
|
||||
{
|
||||
"semantic_plan": {
|
||||
"rule_intent": "预算校验",
|
||||
"required_fields": [{"field": "claim.amount"}],
|
||||
"judgment_steps": ["申请金额超过预算余额"],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert plan_only["template_key"] == "composite_rule_v1"
|
||||
assert plan_only["field_keys"] == ["claim.amount"]
|
||||
@@ -55,6 +55,39 @@ def test_runtime_chat_fails_over_to_backup_before_retrying_main(monkeypatch) ->
|
||||
assert calls == ["main", "backup"]
|
||||
|
||||
|
||||
def test_runtime_chat_complete_with_trace_records_slot_failover(monkeypatch) -> None:
|
||||
_clear_runtime_chat_cooldown()
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = RuntimeChatService(db)
|
||||
|
||||
def fake_load_chat_slot(slot: str):
|
||||
return {
|
||||
"slot": slot,
|
||||
"provider": "MiniMax" if slot == "main" else "GLM",
|
||||
"endpoint": "https://example.com/v1",
|
||||
"model": "main-model" if slot == "main" else "backup-model",
|
||||
"apiKey": "secret",
|
||||
}
|
||||
|
||||
def fake_request_chat_completion(config, messages, *, max_tokens, temperature, timeout_seconds):
|
||||
del messages, max_tokens, temperature, timeout_seconds
|
||||
if config["slot"] == "main":
|
||||
raise RuntimeError("incorrect api key")
|
||||
return "backup answer"
|
||||
|
||||
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
|
||||
monkeypatch.setattr(service, "_request_chat_completion", fake_request_chat_completion)
|
||||
|
||||
result = service.complete_with_trace([{"role": "user", "content": "hello"}])
|
||||
|
||||
assert result.text == "backup answer"
|
||||
assert [item.status for item in result.calls] == ["failed", "succeeded"]
|
||||
assert result.calls[0].provider == "MiniMax"
|
||||
assert result.calls[0].error_message == "incorrect api key"
|
||||
assert result.calls_as_dicts()[1]["model"] == "backup-model"
|
||||
|
||||
|
||||
def test_runtime_chat_does_not_rehit_failed_slots_during_cooldown(monkeypatch) -> None:
|
||||
_clear_runtime_chat_cooldown()
|
||||
session_factory = build_session_factory()
|
||||
|
||||
149
server/tests/test_system_dashboard_service.py
Normal file
149
server/tests/test_system_dashboard_service.py
Normal file
@@ -0,0 +1,149 @@
|
||||
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.models.agent_feedback import AgentOperationFeedback
|
||||
from app.models.agent_run import AgentRun, AgentToolCall
|
||||
from app.models.user_session_metric import UserSessionMetric
|
||||
from app.services.system_dashboard import SystemDashboardService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_system_dashboard_service_aggregates_real_runtime_metrics() -> None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
AgentRun(
|
||||
run_id="run-dashboard-001",
|
||||
agent="orchestrator",
|
||||
source="user_message",
|
||||
user_id="chen.yuqing@example.com",
|
||||
status="succeeded",
|
||||
started_at=now - timedelta(hours=3),
|
||||
finished_at=now - timedelta(hours=3, minutes=-1),
|
||||
tool_calls=[
|
||||
AgentToolCall(
|
||||
run_id="run-dashboard-001",
|
||||
tool_type="llm",
|
||||
tool_name="expense_claim.review",
|
||||
request_json={"prompt_tokens": 120},
|
||||
response_json={"completion_tokens": 80},
|
||||
status="succeeded",
|
||||
duration_ms=1800,
|
||||
created_at=now - timedelta(hours=3),
|
||||
),
|
||||
AgentToolCall(
|
||||
run_id="run-dashboard-001",
|
||||
tool_type="ocr",
|
||||
tool_name="invoice.ocr",
|
||||
request_json={"image_count": 1},
|
||||
response_json={"message": "识别失败"},
|
||||
status="failed",
|
||||
duration_ms=2600,
|
||||
error_message="low confidence",
|
||||
created_at=now - timedelta(hours=2),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AgentRun(
|
||||
run_id="run-dashboard-002",
|
||||
agent="hermes",
|
||||
source="schedule",
|
||||
user_id="gu.chengyu@example.com",
|
||||
status="failed",
|
||||
started_at=now - timedelta(hours=1),
|
||||
finished_at=now,
|
||||
tool_calls=[
|
||||
AgentToolCall(
|
||||
run_id="run-dashboard-002",
|
||||
tool_type="knowledge",
|
||||
tool_name="policy.rag",
|
||||
request_json={"total_tokens": 90},
|
||||
response_json={},
|
||||
status="succeeded",
|
||||
duration_ms=900,
|
||||
created_at=now - timedelta(hours=1),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
UserSessionMetric(
|
||||
session_id="session-dashboard-001",
|
||||
username="chen.yuqing@example.com",
|
||||
display_name="陈雨晴",
|
||||
email="chen.yuqing@example.com",
|
||||
login_at=now - timedelta(hours=4),
|
||||
logout_at=now - timedelta(hours=3),
|
||||
duration_ms=60 * 60 * 1000,
|
||||
activity_event_count=16,
|
||||
status="closed",
|
||||
),
|
||||
UserSessionMetric(
|
||||
session_id="session-dashboard-002",
|
||||
username="gu.chengyu@example.com",
|
||||
display_name="顾成宇",
|
||||
email="gu.chengyu@example.com",
|
||||
login_at=now - timedelta(minutes=25),
|
||||
last_activity_at=now - timedelta(minutes=5),
|
||||
activity_event_count=9,
|
||||
status="active",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
AgentOperationFeedback(
|
||||
run_id="run-dashboard-001",
|
||||
user_id="chen.yuqing@example.com",
|
||||
agent="orchestrator",
|
||||
rating=5,
|
||||
created_at=now - timedelta(hours=2),
|
||||
),
|
||||
AgentOperationFeedback(
|
||||
run_id="run-dashboard-002",
|
||||
user_id="gu.chengyu@example.com",
|
||||
agent="hermes",
|
||||
rating=2,
|
||||
created_at=now - timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
dashboard = SystemDashboardService(db).build_dashboard(days=7)
|
||||
|
||||
assert dashboard.has_real_data is True
|
||||
assert dashboard.totals["toolCalls"] == 3
|
||||
assert dashboard.totals["modelTokens"] >= 290
|
||||
assert dashboard.totals["onlineUsers"] == 1
|
||||
assert dashboard.totals["executionSuccessRate"] == 50.0
|
||||
assert dashboard.totals["positiveFeedback"] == 1
|
||||
assert dashboard.totals["negativeFeedback"] == 1
|
||||
assert dashboard.user_token_usage[0]["tokens"] >= 200
|
||||
assert "陈雨晴" in {item["name"] for item in dashboard.user_token_usage}
|
||||
assert dashboard.accuracy_comparison["correct"][
|
||||
dashboard.accuracy_comparison["categories"].index("报销预审")
|
||||
] == 1
|
||||
assert dashboard.accuracy_comparison["wrong"][
|
||||
dashboard.accuracy_comparison["categories"].index("异常诊断")
|
||||
] == 1
|
||||
@@ -209,7 +209,7 @@ def test_user_agent_application_context_uses_application_language() -> None:
|
||||
|
||||
assert "费用申请" in response.answer
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer
|
||||
assert "支持上海国网服务器部署" in response.answer
|
||||
assert "当前还需要补充:出行方式、用户预估费用" in response.answer
|
||||
assert "请先在下面选择报销场景" not in response.answer
|
||||
@@ -224,7 +224,7 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(db, message)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
||||
assert "当前还需要先补充:申请事由" not in response.answer
|
||||
@@ -250,7 +250,7 @@ def test_user_agent_application_normalizes_location_to_region_city() -> None:
|
||||
yili_response = build_application_user_agent_response(db, yili_message)
|
||||
beijing_response = build_application_user_agent_response(db, beijing_message)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in yili_response.answer
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
|
||||
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
|
||||
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
|
||||
assert "伊犁出差" not in yili_response.answer
|
||||
@@ -328,10 +328,27 @@ def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
|
||||
"地点:上海\n事由:支撑国网服务器部署\n天数:3天",
|
||||
)
|
||||
|
||||
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出行方式:\n用户预估费用:"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"去北京出差3天,支撑国网仿生产环境部署,飞机,预计费用12000元",
|
||||
context_overrides={
|
||||
"client_now_iso": "2026-05-28T16:30:00.000Z",
|
||||
"client_timezone_offset_minutes": -480,
|
||||
},
|
||||
)
|
||||
|
||||
assert "这是模拟的费用申请结果" in response.answer
|
||||
assert "| 发生时间 | 2026-05-29 至 2026-05-31 |" in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
|
||||
|
||||
def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
|
||||
@@ -397,9 +414,10 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
],
|
||||
)
|
||||
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in response.answer
|
||||
assert "当前状态:陈硕审核中" in response.answer
|
||||
assert "预算占用参考" in response.answer
|
||||
assert "申请单据已生成,并已进入审批流程" in response.answer
|
||||
assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in response.answer
|
||||
assert "下方是简要单据信息" in response.answer
|
||||
assert "申请信息:" not in response.answer
|
||||
assert re.search(r"AP-\d{14}-[A-HJ-NP-Z2-9]{8}", response.answer)
|
||||
assert response.suggested_actions == []
|
||||
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
|
||||
|
||||
Reference in New Issue
Block a user