Files
X-Financial/server/src/app/services/risk_rule_flow_diagram.py
caoxiaozhu 7989f3a159 feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
2026-05-30 15:46:51 +08:00

499 lines
21 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.
# ruff: noqa: E501
from __future__ import annotations
import html
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class RiskRuleFlowDiagramField:
key: str
label: str
@dataclass(frozen=True)
class RiskRuleFlowDiagramSpec:
title: str
domain_label: str
severity: str
severity_label: str
fields: tuple[RiskRuleFlowDiagramField, ...]
start: str
evidence: str
decision: str
basis: str
pass_text: str
fail_text: str
fact_lines: tuple[str, ...] = ()
condition_lines: tuple[str, ...] = ()
hit_logic: str = ""
@dataclass(frozen=True)
class RiskRuleFlowDiagramPalette:
accent: str
accent_dark: str
border: str
surface: str
class RiskRuleFlowDiagramRenderer:
"""按 fireworks-tech-graph Style 7 OpenAI Official 生成只读流程 SVG。"""
_FONT = (
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, "
"'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
)
_TEXT = "#0d0d0d"
_MUTED = "#6e6e80"
_NEUTRAL_LINE = "#cbd5e1"
_NEUTRAL_BORDER = "#e2e8f0"
_NEUTRAL_SURFACE = "#ffffff"
_PALETTES = {
"low": RiskRuleFlowDiagramPalette(
accent="#2563eb",
accent_dark="#1d4ed8",
border="#bfdbfe",
surface="#eff6ff",
),
"medium": RiskRuleFlowDiagramPalette(
accent="#f97316",
accent_dark="#c2410c",
border="#fed7aa",
surface="#fff7ed",
),
"high": RiskRuleFlowDiagramPalette(
accent="#dc2626",
accent_dark="#b91c1c",
border="#fecaca",
surface="#fef2f2",
),
"critical": RiskRuleFlowDiagramPalette(
accent="#991b1b",
accent_dark="#7f1d1d",
border="#fca5a5",
surface="#fff1f2",
),
}
def render(self, spec: RiskRuleFlowDiagramSpec) -> str:
title = self._truncate(spec.title, 26)
palette = self._palette(spec.severity)
fact_lines = spec.fact_lines or self._field_lines(spec.fields)
condition_lines = spec.condition_lines or (spec.basis,)
hit_logic = spec.hit_logic or spec.basis
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="860" height="360" viewBox="0 0 860 360" data-risk-flow-style="review-node-only" data-risk-flow-detail="logic-v2" role="img" aria-labelledby="risk-flow-title risk-flow-desc">
<title id="risk-flow-title">{self._escape(title)}流程说明</title>
<desc id="risk-flow-desc">风险规则只读流程图,展示字段事实、集合交集、日期范围、例外说明和命中路径。</desc>
<defs>
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="{self._NEUTRAL_LINE}"/>
</marker>
<marker id="arrow-risk" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="{palette.accent}"/>
</marker>
</defs>
<rect width="860" height="360" fill="#ffffff"/>
<rect x="18" y="18" width="824" height="324" rx="8" ry="8" fill="none" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="34" y="43" fill="{self._MUTED}" font-family="{self._FONT}" font-size="11" font-weight="500">RULE FLOW</text>
{self._node("业务输入", spec.start, 38, 142, 120, 62)}
{self._panel("字段事实", fact_lines, 196, 64, 240, 128)}
{self._panel("判断条件", condition_lines, 196, 216, 382, 104)}
{self._diamond("命中逻辑", hit_logic, 494, 80, 122, 122)}
{self._node("继续流转", spec.pass_text, 688, 76, 122, 60)}
{self._node("进入复核", spec.fail_text, 688, 226, 122, 68, palette=palette)}
<path d="M 158 173 H 176 V 128 H 196" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
<line x1="316" y1="192" x2="316" y2="216" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" marker-end="url(#arrow-neutral)"/>
<path d="M 436 128 H 466 V 141 H 494" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
<line x1="555" y1="216" x2="555" y2="202" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" stroke-linecap="round" marker-end="url(#arrow-neutral)"/>
<path d="M 616 125 H 648 V 106 H 688" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-neutral)"/>
<text x="651" y="119" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="500">否</text>
<path d="M 616 166 H 648 V 260 H 688" fill="none" stroke="{palette.accent}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-risk)"/>
<text x="651" y="214" text-anchor="middle" fill="{palette.accent_dark}" font-family="{self._FONT}" font-size="10.5" font-weight="700">是</text>
</svg>"""
def _node(
self,
title: str,
body: str,
x: int,
y: int,
width: int,
height: int,
palette: RiskRuleFlowDiagramPalette | None = None,
) -> str:
body_lines = self._wrap(body, 10 if width <= 126 else 11, 1)
border = palette.border if palette else self._NEUTRAL_BORDER
stripe = palette.accent if palette else self._NEUTRAL_LINE
surface = palette.surface if palette else self._NEUTRAL_SURFACE
return f"""<g>
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="{surface}" stroke="{border}" stroke-width="1.2"/>
<rect x="{x}" y="{y}" width="3.5" height="{height}" rx="1.75" ry="1.75" fill="{stripe}"/>
<text x="{x + width / 2:.0f}" y="{y + 24}" text-anchor="middle" fill="{self._TEXT}" font-family="{self._FONT}" font-size="13" font-weight="600">{self._escape(title)}</text>
{self._text_lines(body_lines, x + width / 2, y + 43, "middle", self._MUTED, 11)}
</g>"""
def _diamond(
self,
title: str,
body: str,
x: int,
y: int,
width: int,
height: int,
) -> str:
cx = x + width / 2
cy = y + height / 2
points = f"{cx},{y} {x + width},{cy} {cx},{y + height} {x},{cy}"
body_lines = self._wrap(body, 8, 2)
return f"""<g>
<polygon points="{points}" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1.25"/>
<text x="{cx:.0f}" y="{cy - 10:.0f}" text-anchor="middle" fill="{self._TEXT}" font-family="{self._FONT}" font-size="12.5" font-weight="600">{self._escape(title)}</text>
{self._text_lines(body_lines, cx, cy + 11, "middle", self._MUTED, 10.2)}
</g>"""
def _panel(
self,
title: str,
lines: tuple[str, ...],
x: int,
y: int,
width: int,
height: int,
) -> str:
visible = [self._truncate(line, 36) for line in list(lines)[:4]]
if not visible:
visible = ["读取规则字段并归一化为判断事实"]
rows = "\n ".join(
f'<text x="{x + 16}" y="{y + 48 + index * 18}" fill="{self._TEXT}" font-family="{self._FONT}" font-size="11" font-weight="400">{self._escape(line)}</text>'
for index, line in enumerate(visible)
)
return f"""<g>
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1.2"/>
<text x="{x + 16}" y="{y + 26}" fill="{self._TEXT}" font-family="{self._FONT}" font-size="13" font-weight="650">{self._escape(title)}</text>
{rows}
</g>"""
def _note(
self,
body: str,
x: int,
y: int,
width: int,
height: int,
) -> str:
lines = self._wrap(body, 22, 1)
return f"""<g>
<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="7" ry="7" fill="#ffffff" stroke="{self._NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="{x + 12}" y="{y + 22}" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10" font-weight="500">BASIS</text>
{self._text_lines(lines, x + 54, y + 22, "start", self._TEXT, 10.2)}
</g>"""
def _field_lines(self, fields: tuple[RiskRuleFlowDiagramField, ...]) -> tuple[str, ...]:
rows = []
for index, field in enumerate(fields[:4]):
label = field.label or field.key
rows.append(f"{chr(65 + index)}={label}[{field.key}]")
return tuple(rows)
def _text_lines(
self,
lines: list[str],
x: float,
y: float,
anchor: str,
color: str,
font_size: float,
) -> str:
return "\n ".join(
f'<text x="{x:.0f}" y="{y + index * (font_size + 5):.0f}" text-anchor="{anchor}" fill="{color}" font-family="{self._FONT}" font-size="{font_size}" font-weight="400">{self._escape(line)}</text>'
for index, line in enumerate(lines)
)
@staticmethod
def _wrap(value: str, width: int, max_lines: int) -> list[str]:
text = str(value or "").strip()
if not text:
return [""]
lines = [text[index : index + width] for index in range(0, len(text), width)]
if len(lines) > max_lines:
lines = lines[:max_lines]
lines[-1] = f"{lines[-1][: max(0, width - 1)]}"
return lines
@staticmethod
def _truncate(value: str, length: int) -> str:
text = str(value or "").strip()
return text if len(text) <= length else f"{text[: length - 1]}"
@staticmethod
def _escape(value: str) -> str:
return html.escape(str(value or ""), quote=True)
@classmethod
def _palette(cls, severity: str) -> RiskRuleFlowDiagramPalette:
return cls._PALETTES.get(str(severity or "").strip().lower(), cls._PALETTES["medium"])
def build_risk_rule_flow_diagram_details(
payload: dict[str, Any],
fields: list[RiskRuleFlowDiagramField],
) -> dict[str, tuple[str, ...] | str]:
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
rule_ir = params.get("rule_ir") if isinstance(params.get("rule_ir"), dict) else {}
facts = rule_ir.get("facts") if isinstance(rule_ir.get("facts"), list) else []
fact_lines = _build_fact_lines(facts, fields)
condition_lines = _build_condition_lines(params, fields)
hit_logic = _format_hit_logic(params.get("hit_logic")) or str(
params.get("formula") or params.get("condition_summary") or ""
).strip()
return {
"fact_lines": tuple(fact_lines),
"condition_lines": tuple(condition_lines),
"hit_logic": hit_logic,
}
def build_risk_rule_flow_diagram_spec(
payload: dict[str, Any],
*,
fields: tuple[RiskRuleFlowDiagramField, ...],
domain_label: str,
severity: str,
severity_label: str,
flow_model: dict[str, Any] | None = None,
) -> RiskRuleFlowDiagramSpec:
model_spec = _spec_from_flow_model(
payload,
fields=fields,
domain_label=domain_label,
severity=severity,
severity_label=severity_label,
flow_model=flow_model or {},
)
if model_spec:
return model_spec
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {}
details = build_risk_rule_flow_diagram_details(payload, list(fields))
summary = str(metadata.get("condition_summary") or "").strip()
return RiskRuleFlowDiagramSpec(
title=str(payload.get("name") or "").strip() or "风险规则判断流程",
domain_label=domain_label,
severity=severity,
severity_label=severity_label,
fields=fields,
start=str(flow.get("start") or "").strip() or "业务单据提交",
evidence=str(flow.get("evidence") or "").strip() or "读取规则字段",
decision=str(flow.get("decision") or "").strip() or summary or "判断是否命中风险",
basis=summary or str(flow.get("decision") or "").strip() or "根据规则字段判断",
pass_text=str(flow.get("pass") or "").strip() or "未命中风险,继续流转",
fail_text=str(flow.get("fail") or "").strip() or f"命中{severity_label},进入人工复核",
fact_lines=details["fact_lines"],
condition_lines=details["condition_lines"],
hit_logic=str(details["hit_logic"] or ""),
)
def _spec_from_flow_model(
payload: dict[str, Any],
*,
fields: tuple[RiskRuleFlowDiagramField, ...],
domain_label: str,
severity: str,
severity_label: str,
flow_model: dict[str, Any],
) -> RiskRuleFlowDiagramSpec | None:
nodes = flow_model.get("nodes") if isinstance(flow_model, dict) else []
if not isinstance(nodes, list) or not nodes:
return None
by_type: dict[str, list[dict[str, Any]]] = {}
for node in nodes:
if isinstance(node, dict):
by_type.setdefault(str(node.get("type") or "").strip(), []).append(node)
decisions = by_type.get("decision") or []
if not decisions:
return None
start = _node_description(by_type.get("start"), "业务单据提交")
evidence = _node_description(by_type.get("evidence"), "读取规则字段")
pass_text = _node_description(by_type.get("pass"), "未命中风险,继续流转")
fail_text = _node_description(by_type.get("risk"), f"命中{severity_label},进入人工复核")
condition_lines = _condition_lines_from_flow_nodes(decisions)
basis = condition_lines[0] if condition_lines else _node_description(decisions, "判断是否命中风险")
return RiskRuleFlowDiagramSpec(
title=str(payload.get("name") or "").strip() or "风险规则判断流程",
domain_label=domain_label,
severity=severity,
severity_label=severity_label,
fields=fields,
start=start,
evidence=evidence,
decision=_node_description(decisions, basis),
basis=basis,
pass_text=pass_text,
fail_text=fail_text,
fact_lines=tuple(_field_lines_from_flow_nodes(by_type.get("evidence"), fields)),
condition_lines=tuple(condition_lines),
hit_logic=_hit_logic_from_flow_model(flow_model, condition_lines),
)
def _node_description(nodes: list[dict[str, Any]] | None, fallback: str) -> str:
node = nodes[0] if nodes else {}
return str(node.get("description") or node.get("title") or fallback).strip()
def _condition_lines_from_flow_nodes(nodes: list[dict[str, Any]]) -> list[str]:
visible = [
f"{str(node.get('title') or node.get('id') or '判断').strip()}: {str(node.get('description') or '').strip()}"
for node in nodes[:4]
]
if len(nodes) > 4:
visible[-1] = f"{visible[-1]};另有 {len(nodes) - 4} 个判断节点按命中逻辑汇总"
return visible
def _field_lines_from_flow_nodes(
nodes: list[dict[str, Any]] | None,
fields: tuple[RiskRuleFlowDiagramField, ...],
) -> list[str]:
field_keys = _read_string_list((nodes[0] if nodes else {}).get("fields"))
if not field_keys:
return [
f"{chr(65 + index)}={field.label or field.key}[{field.key}]"
for index, field in enumerate(fields[:4])
]
label_by_key = {field.key: field.label or field.key for field in fields}
return [
f"{chr(65 + index)}={label_by_key.get(key, key)}[{key}]"
for index, key in enumerate(field_keys[:4])
]
def _hit_logic_from_flow_model(flow_model: dict[str, Any], condition_lines: list[str]) -> str:
metadata = flow_model.get("metadata") if isinstance(flow_model.get("metadata"), dict) else {}
logic = str(metadata.get("hit_logic") or "").strip()
if logic:
return logic
return " AND ".join(line.split(":", 1)[0] for line in condition_lines[:4] if line)
def _build_fact_lines(
facts: list[Any],
fields: list[RiskRuleFlowDiagramField],
) -> list[str]:
label_by_key = {field.key: field.label or field.key for field in fields}
rows: list[str] = []
for fact in facts[:4]:
if not isinstance(fact, dict):
continue
fact_id = str(fact.get("id") or "").strip()
label = str(fact.get("label") or fact_id or "事实").strip()
field_keys = _read_string_list(fact.get("fields"))
field_text = "".join(label_by_key.get(key, key) for key in field_keys[:3])
rows.append(f"{fact_id + '=' if fact_id else ''}{label}: {field_text or '规则字段'}")
if rows:
return rows
return [
f"{chr(65 + index)}={field.label or field.key}[{field.key}]"
for index, field in enumerate(fields[:4])
]
def _build_condition_lines(
params: dict[str, Any],
fields: list[RiskRuleFlowDiagramField],
) -> list[str]:
label_by_key = {field.key: field.label or field.key for field in fields}
rows: list[str] = []
conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
for index, condition in enumerate(conditions[:4], start=1):
if not isinstance(condition, dict):
continue
rows.append(_format_condition(condition, label_by_key, index))
if rows:
return rows
summary = str(params.get("condition_summary") or "").strip()
return [summary] if summary else []
def _format_condition(condition: dict[str, Any], label_by_key: dict[str, str], index: int) -> str:
operator = str(condition.get("operator") or "").strip()
condition_id = str(condition.get("id") or f"C{index}").strip()
prefix = f"{condition_id}: "
if operator in {"not_in_scope", "not_in_set", "not_overlap"}:
left = _field_group(condition.get("left_fields"), label_by_key)
right = _field_group(condition.get("right_fields"), label_by_key)
return f"{prefix}{left}{right} = ∅"
if operator in {"in_scope", "overlap"}:
left = _field_group(condition.get("left_fields"), label_by_key)
right = _field_group(condition.get("right_fields"), label_by_key)
return f"{prefix}{left}{right} ≠ ∅"
if operator == "date_outside_range":
dates = _field_group(condition.get("date_fields"), label_by_key)
start = _field_group(condition.get("range_start_fields"), label_by_key)
end = _field_group(condition.get("range_end_fields"), label_by_key)
return f"{prefix}{dates} 不在 [{start}, {end}]"
if operator == "numeric_compare":
left = _field_group(condition.get("left_fields") or condition.get("fields"), label_by_key)
right = _field_group(condition.get("right_fields"), label_by_key)
compare = str(condition.get("compare") or "gt").strip().upper()
target = right or str(condition.get("threshold") or condition.get("value") or "阈值").strip()
return f"{prefix}{left} {compare} {target}"
if operator == "duplicate_value":
fields = _field_group(condition.get("fields"), label_by_key)
return f"{prefix}{fields} 出现重复值"
if operator in {"contains_any", "not_contains_any"}:
fields = _field_group(condition.get("fields"), label_by_key)
keywords = "".join(_read_string_list(condition.get("keywords"))[:4])
verb = "不含" if operator == "not_contains_any" else "包含"
return f"{prefix}{fields} {verb} {keywords or '关键词'}"
if operator in {"exists_any", "exists_all", "all_present"}:
fields = _field_group(condition.get("fields"), label_by_key)
verb = "任一有值" if operator == "exists_any" else "全部有值"
return f"{prefix}{fields} {verb}"
left = str(condition.get("left") or "").strip()
right = str(condition.get("right") or "").strip()
if left or right:
return f"{prefix}{label_by_key.get(left, left)} {operator or 'compare'} {label_by_key.get(right, right)}"
return f"{prefix}{operator or '规则条件'}"
def _field_group(value: Any, label_by_key: dict[str, str]) -> str:
keys = _read_string_list(value)
if not keys:
return "字段集合"
return "".join(label_by_key.get(key, key) for key in keys[:3])
def _format_hit_logic(value: Any) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, list):
return " AND ".join(_format_hit_logic(item) for item in value if _format_hit_logic(item))
if not isinstance(value, dict):
return ""
if isinstance(value.get("all"), list):
return " AND ".join(_wrap_logic_part(item) for item in value["all"])
if isinstance(value.get("any"), list):
return " OR ".join(_wrap_logic_part(item) for item in value["any"])
if "not" in value:
return f"NOT {_wrap_logic_part(value.get('not'))}"
return ""
def _wrap_logic_part(value: Any) -> str:
text = _format_hit_logic(value)
if isinstance(value, dict) and text:
return f"({text})"
return text
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()]