# 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""" {self._escape(title)}流程说明 风险规则只读流程图,展示从业务单据提交到风险复核的判断路径。 RULE FLOW {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)} """ 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""" {self._escape(title)} {self._text_lines(body_lines, x + width / 2, y + 43, "middle", self._MUTED, 11)} """ 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""" {self._escape(title)} {self._text_lines(body_lines, cx, cy + 11, "middle", self._MUTED, 10.2)} """ def _note( self, body: str, x: int, y: int, width: int, height: int, ) -> str: lines = self._wrap(body, 22, 1) return f""" BASIS {self._text_lines(lines, x + 54, y + 22, "start", self._TEXT, 10.2)} """ def _text_lines( self, lines: list[str], x: float, y: float, anchor: str, color: str, font_size: float, ) -> str: return "\n ".join( f'{self._escape(line)}' 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"])