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

@@ -5,7 +5,9 @@ from datetime import date, datetime, timedelta
from typing import Any
from app.models.financial_record import ExpenseClaim
from app.services.risk_rule_execution_trace import build_risk_rule_execution_trace
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
from app.services.risk_rule_value_compare import compare_numbers, duplicate_text_values, parse_number_value
CITY_CONSISTENCY_SEMANTIC_TYPES = {
"travel_city_consistency",
@@ -14,6 +16,20 @@ CITY_CONSISTENCY_SEMANTIC_TYPES = {
class RiskRuleTemplateExecutor:
def evaluate_with_trace(
self,
manifest: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> dict[str, Any]:
result = self.evaluate(manifest, claim=claim, contexts=contexts)
return {
"hit": result is not None,
"result": result,
"trace": build_risk_rule_execution_trace(manifest, result=result),
}
def evaluate(
self,
manifest: dict[str, Any],
@@ -53,7 +69,7 @@ class RiskRuleTemplateExecutor:
missing = [
field_key
for field_key in required_fields
if not self._has_resolved_value(field_key, claim=claim, contexts=contexts)
if not self._resolve_values(field_key, claim=claim, contexts=contexts)
]
if not missing:
return None
@@ -77,9 +93,10 @@ class RiskRuleTemplateExecutor:
) -> dict[str, Any] | None:
conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
failures: list[dict[str, Any]] = []
for condition in conditions:
for index, condition in enumerate(conditions, start=1):
if not isinstance(condition, dict):
continue
condition_id = str(condition.get("id") or f"condition_{index}").strip()
left_key = str(condition.get("left") or "").strip()
right_key = str(condition.get("right") or "").strip()
operator = str(condition.get("operator") or "not_overlap").strip()
@@ -90,6 +107,7 @@ class RiskRuleTemplateExecutor:
failures.append(
{
"left": left_key,
"id": condition_id,
"operator": operator,
"right": right_key,
"left_values": left_values[:5],
@@ -253,6 +271,12 @@ class RiskRuleTemplateExecutor:
],
"condition_summary": params.get("condition_summary"),
"formula": params.get("formula"),
"condition_results": {
"city_evidence_present": bool(attachment_values and reference_values),
"destination_overlap": has_destination_overlap,
"unexpected_route_city": bool(unexpected_route_cities),
"reasonable_exception": bool(keyword_hits),
},
"city_consistency": {
"attachment_values": attachment_values[:8],
"reference_values": reference_values[:8],
@@ -354,6 +378,17 @@ class RiskRuleTemplateExecutor:
}
if operator == "date_outside_range":
return self._evaluate_date_outside_range(condition, claim=claim, contexts=contexts)
if operator == "numeric_compare":
return self._evaluate_numeric_compare(condition, claim=claim, contexts=contexts)
if operator == "duplicate_value":
values = [
value
for key in fields
for value in self._resolve_values(key, claim=claim, contexts=contexts)
]
duplicates = duplicate_text_values(values)
evidence = {"operator": operator, "fields": fields, "values": values[:8], "duplicates": duplicates[:8]}
return bool(duplicates), evidence
if operator in {"not_contains_any", "contains_any"}:
keywords = self._read_string_list(condition.get("keywords"))
values = self._resolve_group_values(fields, claim=claim, contexts=contexts)
@@ -419,6 +454,35 @@ class RiskRuleTemplateExecutor:
"outside_dates": [item.isoformat() for item in outside],
}
def _evaluate_numeric_compare(
self,
condition: dict[str, Any],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> tuple[bool, dict[str, Any]]:
left_fields = self._read_string_list(condition.get("left_fields") or condition.get("fields"))
right_fields = self._read_string_list(condition.get("right_fields"))
left_numbers = self._resolve_group_numbers(left_fields, claim=claim, contexts=contexts)
right_numbers = self._resolve_group_numbers(right_fields, claim=claim, contexts=contexts)
threshold = parse_number_value(condition.get("threshold") or condition.get("value"))
if threshold is not None:
right_numbers.append(threshold)
compare = str(condition.get("compare") or condition.get("comparator") or "gt").strip().lower()
passed = any(
compare_numbers(left, right, compare)
for left in left_numbers
for right in right_numbers
)
return passed, {
"operator": "numeric_compare",
"compare": compare,
"left_fields": left_fields,
"right_fields": right_fields,
"left_values": left_numbers[:8],
"right_values": right_numbers[:8],
}
def _resolve_group_values(
self,
field_keys: list[str],
@@ -442,7 +506,22 @@ class RiskRuleTemplateExecutor:
for key in field_keys:
for value in self._resolve_values(key, claim=claim, contexts=contexts):
parsed = self._parse_date_value(value)
if parsed and parsed not in values:
if parsed and parsed not in values:
values.append(parsed)
return values
def _resolve_group_numbers(
self,
field_keys: list[str],
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> list[float]:
values: list[float] = []
for key in field_keys:
for value in self._resolve_values(key, claim=claim, contexts=contexts):
parsed = parse_number_value(value)
if parsed is not None and parsed not in values:
values.append(parsed)
return values
@@ -614,15 +693,6 @@ class RiskRuleTemplateExecutor:
}
return any(item in label for item in label_map.get(field_key, ()))
def _has_resolved_value(
self,
field_key: str,
*,
claim: ExpenseClaim,
contexts: list[dict[str, Any]],
) -> bool:
return bool(self._resolve_values(field_key, claim=claim, contexts=contexts))
@staticmethod
def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None:
item_dates = [
@@ -696,7 +766,7 @@ class RiskRuleTemplateExecutor:
normalized.extend(RiskRuleTemplateExecutor._normalize_values(list(value)))
continue
text = re.sub(r"\s+", " ", str(value or "")).strip()
if text and text not in normalized:
if text:
normalized.append(text)
return normalized