后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
819 lines
30 KiB
Python
819 lines
30 KiB
Python
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,
|
|
)
|