Files
X-Financial/server/src/app/services/risk_rule_manifest_normalizer.py
caoxiaozhu 34457f9c3e feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
2026-06-03 15:46:56 +08:00

201 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from typing import Any
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramField,
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
)
from app.services.risk_rule_generation_semantics import (
CITY_ATTACHMENT_FIELDS,
CITY_CONSISTENCY_SEMANTIC_TYPE,
CITY_CONSISTENCY_SEMANTIC_TYPES,
CITY_EXCEPTION_FIELDS,
CITY_EXCEPTION_KEYWORDS,
CITY_HOME_FIELDS,
CITY_REFERENCE_FIELDS,
build_city_consistency_params,
is_city_consistency_rule,
)
RISK_LEVEL_LABELS = {
"low": "低风险",
"medium": "中风险",
"high": "高风险",
"critical": "极高风险",
}
CITY_ROUTE_CONDITION_SUMMARY = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
"则命中目的地不一致/中途周转异常风险。"
)
CITY_ROUTE_FLOW_DECISION = (
"附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市"
)
CITY_ROUTE_FLOW_EVIDENCE = (
"读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
)
def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]:
"""把历史误编译的城市一致性规则规范为受控语义 DSL。"""
if not isinstance(manifest, dict) or not _looks_like_city_consistency_manifest(manifest):
return manifest
payload = dict(manifest)
metadata = dict(payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {})
params = dict(payload.get("params") if isinstance(payload.get("params"), dict) else {})
exception_keywords = _read_string_list(
payload.get("exception_keywords") or params.get("exception_keywords")
) or list(CITY_EXCEPTION_KEYWORDS)
field_keys = _resolve_city_field_keys(payload, params)
severity = _resolve_severity(payload)
severity_label = RISK_LEVEL_LABELS.get(severity, "中风险")
payload["template_key"] = "field_compare_v1"
payload["semantic_type"] = CITY_CONSISTENCY_SEMANTIC_TYPE
payload["keywords"] = []
payload["exception_keywords"] = exception_keywords
params.update(
build_city_consistency_params(
{
"exception_keywords": exception_keywords,
}
)
)
params["template_key"] = "field_compare_v1"
params["field_keys"] = field_keys
params["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
payload["params"] = params
payload["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
flow = dict(metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {})
flow["evidence"] = CITY_ROUTE_FLOW_EVIDENCE
flow["decision"] = CITY_ROUTE_FLOW_DECISION
flow.setdefault(
"start",
"差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
)
flow.setdefault(
"pass",
"票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
)
flow["fail"] = (
f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改"
)
metadata["condition_summary"] = CITY_ROUTE_CONDITION_SUMMARY
metadata["flow"] = flow
payload["metadata"] = metadata
payload["flow_diagram_svg"] = _build_city_flow_svg(payload, field_keys, severity, severity_label)
return payload
def _looks_like_city_consistency_manifest(manifest: dict[str, Any]) -> bool:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
semantic_type = str(manifest.get("semantic_type") or params.get("semantic_type") or "").strip()
if semantic_type in CITY_CONSISTENCY_SEMANTIC_TYPES:
return True
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
text = "\n".join(
str(value or "")
for value in (
metadata.get("natural_language"),
params.get("natural_language"),
manifest.get("description"),
metadata.get("condition_summary"),
params.get("condition_summary"),
)
)
if is_city_consistency_rule(text):
return True
field_keys = set(_resolve_city_field_keys(manifest, params))
has_attachment_city = bool(field_keys & set(CITY_ATTACHMENT_FIELDS))
has_reference_city = bool(field_keys & set(CITY_REFERENCE_FIELDS))
return has_attachment_city and has_reference_city and "风险关键词" in text
def _resolve_city_field_keys(manifest: dict[str, Any], params: dict[str, Any]) -> list[str]:
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
known = {
str(item.get("key") or "").strip()
for item in input_fields
if isinstance(item, dict) and str(item.get("key") or "").strip()
}
candidates = [
*_read_string_list(manifest.get("field_keys")),
*_read_string_list(params.get("field_keys") or params.get("search_fields")),
*CITY_ATTACHMENT_FIELDS,
*CITY_REFERENCE_FIELDS,
*CITY_HOME_FIELDS,
*CITY_EXCEPTION_FIELDS,
]
resolved: list[str] = []
for key in candidates:
if known and key not in known and key not in {
*CITY_ATTACHMENT_FIELDS,
*CITY_REFERENCE_FIELDS,
*CITY_HOME_FIELDS,
*CITY_EXCEPTION_FIELDS,
}:
continue
if key not in resolved:
resolved.append(key)
return resolved
def _build_city_flow_svg(
payload: dict[str, Any],
field_keys: list[str],
severity: str,
severity_label: str,
) -> str:
inputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
input_fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
label_by_key = {
str(item.get("key") or "").strip(): str(item.get("label") or item.get("key") or "").strip()
for item in input_fields
if isinstance(item, dict) and str(item.get("key") or "").strip()
}
fields = tuple(
RiskRuleFlowDiagramField(key=key, label=label_by_key.get(key) or key)
for key in field_keys[:8]
)
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
return RiskRuleFlowDiagramRenderer().render(
RiskRuleFlowDiagramSpec(
title=str(payload.get("name") or "风险规则判断流程").strip(),
domain_label=str(payload.get("risk_category") or "差旅费").strip(),
severity=severity,
severity_label=severity_label,
fields=fields,
start=str(flow.get("start") or "差旅报销单据提交").strip(),
evidence=CITY_ROUTE_FLOW_EVIDENCE,
decision=CITY_ROUTE_FLOW_DECISION,
basis=CITY_ROUTE_CONDITION_SUMMARY,
pass_text=str(flow.get("pass") or "未命中风险,继续流转").strip(),
fail_text=str(flow.get("fail") or f"命中{severity_label},进入人工复核").strip(),
)
)
def _resolve_severity(payload: dict[str, Any]) -> str:
outcomes = payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {}
fail = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
severity = str(fail.get("severity") or payload.get("severity") or "").strip().lower()
return severity if severity in RISK_LEVEL_LABELS else "medium"
def _read_string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [str(item or "").strip() for item in value if str(item or "").strip()]