feat: 新增风险图谱算法与系统仪表盘及操作反馈体系

后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-30 15:46:51 +08:00
parent 4c59941ec6
commit 7989f3a159
314 changed files with 30073 additions and 20626 deletions

View 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,
}

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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",

View 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"

View 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,
)

View 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}",
)
],
)

View File

@@ -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:

View File

@@ -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"] == "服务根检查"

View File

@@ -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"

View 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()

View 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,
},
)
],
)

View 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=[],
)

View 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

View 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

View 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"]

View 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]

View 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",
}

View 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",
)

View 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"]

View File

@@ -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()

View 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

View File

@@ -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()