后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
192 lines
7.4 KiB
Python
192 lines
7.4 KiB
Python
# ruff: noqa: E501
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@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
|
|
|
|
|
|
@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",
|
|
),
|
|
}
|
|
|
|
def render(self, spec: RiskRuleFlowDiagramSpec) -> str:
|
|
title = self._truncate(spec.title, 26)
|
|
palette = self._palette(spec.severity)
|
|
|
|
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" 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>
|
|
</defs>
|
|
<rect width="760" height="280" fill="#ffffff"/>
|
|
<rect x="18" y="18" width="724" height="244" 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, 48, 118, 124, 60)}
|
|
{self._node("字段取数", "读取字段证据", 214, 118, 132, 60)}
|
|
{self._diamond("判断依据", spec.decision, 392, 92, 112, 112)}
|
|
{self._node("继续流转", spec.pass_text, 562, 74, 126, 60)}
|
|
{self._node("进入复核", spec.fail_text, 562, 190, 126, 62, palette=palette)}
|
|
{self._note(spec.basis, 214, 218, 290, 36)}
|
|
<line x1="172" y1="148" x2="214" y2="148" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
|
|
<line x1="346" y1="148" x2="392" y2="148" stroke="{self._NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
|
|
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
|
|
<text x="534" y="119" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="400">否</text>
|
|
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="{self._NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
|
|
<text x="534" y="195" text-anchor="middle" fill="{self._MUTED}" font-family="{self._FONT}" font-size="10.5" font-weight="600">是</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 _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 _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"])
|