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