Commit 21170b4b authored by Data Governance Dev's avatar Data Governance Dev

fix(custom-rules): 自定义规则结果「规则」列显示可读 SQL(替代 'tree' 字面量)

用户反馈:自定义规则命中明细表里「规则」列只显示 'tree',没有信息量。

## 改动
- step9_custom_rules.py: 新增 _serialize_rule_human(group) 递归把条件树
  序列化为可读 SQL 形态字符串
  - 叶子: col LIKE '%X%' / col NOT LIKE '%Y%' / col > 10 / col < '2026-01-01'
  - 顶层组: children 用 AND/OR 串接;单 child 不加括号
  - 嵌套组: (children) 加括号让优先级清晰
- 每条 entry 增加 rule_text 字段(common dict);删除无意义的 rule_type 赋值
- ColumnSpec: prop 'rule_type' → 'rule_text';render tag → code(等宽字体)
  ;min_width 120 → 240
- step8_report._md_custom_value_check / docx 版同步:
  - 去掉「关键字」列(v1 字段,v2 已不存在)
  - 「规则」列改用 rule_text

## 端到端验证
mock DB 跑一条含嵌套组的规则:
  输入: AND(contains(X), OR(contains(Y), not_contains(Z)))
  rule_text: 'col LIKE \'%X%\' AND (col LIKE \'%Y%\' OR col NOT LIKE \'%Z%\')'
  真实 SQL: '... WHERE (`id_card` LIKE %s ESCAPE \'!\' AND (`id_card` LIKE %s ESCAPE \'!\' OR `id_card` NOT LIKE %s ESCAPE \'!\')) LIMIT 100'
显示与真实 SQL 语义一致;区别仅是显示用 'col' 占位列名(方便看),真实 SQL 用 quote_ident。

## 踩坑
第一版递归在父循环对子组额外包括号 '(" + walk() + ")',而 walk(is_top=False)
内部对嵌套组已经返回带括号形式 → 嵌套组变成 '((col < 5 OR col = 100))'。
修法:用 is_top 参数让递归本身决定是否加括号,调用方不再重复加。
教训:写递归字符串拼接时给一个 is_root / is_top 参数控制顶层 vs 嵌套行为。
parent 23ebacc6
......@@ -145,6 +145,45 @@ Pydantic `ConnectRequest(**payload)` 解析通过,`sample_limits` 字段类型
- **UI 一致性折中**:card 头 radio 沿用 `__custom__` key(独立 namespace),不与
analysisTree 的 step_id 撞。
### 10. 自定义规则结果表「规则」列:把 tree 改成可读 SQL 串
- **用户反馈**:自定义规则命中明细里「规则」列只显示字面量 `tree`,没价值。
- **旧设计**:step9 v2 之后所有规则的 `rule_type` 都硬编码为 `"tree"`,没有区别度。
- **新设计**:把每条规则的「条件树」序列化成人能直接看懂的 SQL 串,写到 entry 的
`rule_text` 字段;结果表 + Markdown 报告 + Word 报告都用这个字段。
- **实现**([web/core/step_impl/step9_custom_rules.py:67-130](web/core/step_impl/step9_custom_rules.py#L67-L130)):
```python
def _serialize_rule_human(group) -> str:
# 叶子: col LIKE '%X%' / col > 10 / col < '2026-01-01'
# 顶层组: children 用 AND/OR 串接;单 child 时不加括号
# 嵌套组: (children) 加括号;让 OR/AND 优先级清晰
```
- **示例输出**(条件树 = `AND(contains(X), OR(contains(Y), not_contains(Z)))`):
```
col LIKE '%X%' AND (col LIKE '%Y%' OR col NOT LIKE '%Z%')
```
- **ColumnSpec 调整**([web/core/step_impl/step9_custom_rules.py:489-490](web/core/step_impl/step9_custom_rules.py#L489-L490)):
- prop `rule_type` → `rule_text`
- render `tag` → `code`(等宽字体看 SQL 更舒服)
- min_width 120 → 240(条件长时给足宽度)
- **报告同步**([web/core/step_impl/step8_report.py:597-610](web/core/step_impl/step8_report.py#L597-L610)
+ [step8_report.py:1057-1068](web/core/step_impl/step8_report.py#L1057-L1068)):
- Markdown / Word 自定义规则命中明细表去掉「关键字」列(v1 字段,v2 不存在)
- 「规则」列改用 `rule_text`
- **踩坑:嵌套组双括号 bug**
- 第一版递归实现里在父循环对子组又包了一层 `"(" + _walk_rule_node(c) + ")"`,
而 `_walk_rule_node(c, is_top=False)` 内部对嵌套组已经会返回 `"..."` 形式
→ 嵌套 2 层变成 `"((col < 5 OR col = 100))"`。
- 修:父循环不再额外包括号,让递归调用自己决定(is_top 参数控制)。
- 教训:**写递归字符串拼接时,给一个参数(is_top / is_root)控制顶层 vs 嵌套行为**,
由递归本身决定是否加括号,调用方不要重复加。
### 端到端验证
mock DB 跑一条含嵌套组的规则:
- rule_text:`col LIKE '%X%' AND (col LIKE '%Y%' OR col NOT LIKE '%Z%')` ✓
- 实际 SQL(compile_rule 生成):`SELECT \`id_card\` FROM \`t_user\` WHERE (\`id_card\` LIKE %s ESCAPE '!' AND (\`id_card\` LIKE %s ESCAPE '!' OR \`id_card\` NOT LIKE %s ESCAPE '!')) LIMIT 100`
- 显示文本与真实 SQL 语义一致,区别仅是显示用 `col` 占位列名(不 quote,方便看)
### 提交
待 commit。
......
......@@ -595,16 +595,18 @@ def _md_custom_value_check(lines: list[str], data: dict):
return
# 保持 SQL 返回顺序(不再按 matched_rows 排序 —— 没有这个字段了)
lines.append(_md_row(["规则名称", "表名", "字段名", "匹配值", "规则", "关键字", "错误"]))
lines.append("|---|---|---|---|---|---|---|")
# 2026-08-13:v2 规则体系下:
# - "关键字" 列已废弃(v1 单关键字 → v2 条件树,keyword 字段不存在)
# - "规则" 列由 rule_type='tree' 字面量改为 rule_text(序列化后的可读 SQL 串)
lines.append(_md_row(["规则名称", "表名", "字段名", "匹配值", "规则", "错误"]))
lines.append("|---|---|---|---|---|---|")
for m in matches:
lines.append(_md_row([
m.get("rule_name", "") or "-",
m.get("table_name", ""),
m.get("column_name", ""),
"" if m.get("value") is None else str(m.get("value")),
m.get("rule_type", ""),
m.get("keyword", ""),
m.get("rule_text", ""),
m.get("error", "") or "-",
]))
lines.append("")
......@@ -1059,11 +1061,11 @@ def _docx_custom_value_check(doc, data: dict):
m.get("table_name", ""),
m.get("column_name", ""),
"" if m.get("value") is None else str(m.get("value")),
m.get("rule_type", ""),
m.get("keyword", ""),
m.get("rule_text", ""),
m.get("error", "") or "-",
])
_add_table(doc, ["规则名称", "表名", "字段名", "匹配值", "规则", "关键字", "错误"], rows)
# 2026-08-13:v2 规则体系下"关键字"列已废弃;"规则"列用 rule_text(序列化的可读 SQL 串)
_add_table(doc, ["规则名称", "表名", "字段名", "匹配值", "规则", "错误"], rows)
_DOCX_SECTION_HANDLERS = {
......
......@@ -64,6 +64,72 @@ logger = logging.getLogger(__name__)
_DEFAULT_LIMIT = 200 # SQL 单条查询默认值(partial 模式由 orchestrator 注入 sample_limit 覆盖)
# ── 条件树 → 可读字符串 ───────────────────────────────────
# 渲染规则定义到结果表「规则」列:用户提交条件树(v2)后,每个命中行都能看到
# 自己触发了什么条件,例如 `col LIKE '%x%' AND (col > 10 OR col < 5)`。
# 2026-08-13:之前这里显示的是 `rule_type='tree'` 字面量 —— 没有信息量。
def _serialize_rule_human(group: dict | None) -> str:
"""递归把条件树序列化为 SQL 形态字符串。
- 叶子: `col LIKE '%x%'` / `col NOT LIKE '%y%'` / `col > 10` / `col < '2026-01-01'`
- 顶层组:children 用 AND/OR 串接;只有一个 child 时不加括号
- 嵌套组:用 `(...)` 包起来(让 OR/AND 优先级清晰)
- 空 / 非法节点:返回 ""(rule_text 留空,不影响其它列)
"""
if not group or not isinstance(group, dict):
return ""
return _walk_rule_node(group, is_top=True)
def _walk_rule_node(node: dict, is_top: bool = False) -> str:
kind = node.get("kind")
if kind == "leaf":
return _leaf_text(node)
if kind != "group":
return ""
op = (node.get("op") or "AND").upper()
if op not in ("AND", "OR"):
op = "AND"
children = node.get("children") or []
parts: list[str] = []
for c in children:
if not isinstance(c, dict):
continue
ck = c.get("kind")
if ck == "leaf":
parts.append(_leaf_text(c))
elif ck == "group":
# 递归调用本身已按 is_top 决定是否加括号;不要在外面再包一层,
# 否则嵌套组会出现 "((a OR b))" 双重括号。
parts.append(_walk_rule_node(c, is_top=False))
if not parts:
return ""
if len(parts) == 1:
# 单元素组(顶层或嵌套)都直接展开,避免 "(a)" 这种无意义括号
return parts[0]
joined = f" {op} ".join(parts)
return joined if is_top else "(" + joined + ")"
def _leaf_text(leaf: dict) -> str:
cmp = leaf.get("comparator")
val = leaf.get("value")
if cmp in ("contains", "not_contains"):
like_op = "LIKE" if cmp == "contains" else "NOT LIKE"
# 显示层:把 value 直接拼到 '%...%' 里(不二次转义 % _,原样展示用户输入;
# 真实 SQL 走 compile_rule + escape_like,与显示无关)。
s = "" if val is None else str(val)
return f"col {like_op} '%{s}%'"
if cmp in ("gt", "lt", "eq"):
op = {"gt": ">", "lt": "<", "eq": "="}[cmp]
return f"col {op} {val}"
if cmp in ("date_before", "date_after"):
op = "<" if cmp == "date_before" else ">"
return f"col {op} '{val}'"
# 未知 comparator:兜底展示
return f"col ? {val}"
# ── payload 兼容:旧 {rule_type, user_input} → 新 {group: ...} ──
def _normalize_rule(rule: dict) -> dict:
"""如果传入的是旧 payload(无 group 字段),自动包成 AND + contains 的 group。
......@@ -158,7 +224,6 @@ def run_step_custom_value_check(
table_comment = rule.get("table_comment") or ""
column_comment = rule.get("column_comment") or ""
data_type = rule.get("data_type") or ""
rule_type = rule.get("rule_type") or "tree"
group_node = rule.get("group")
user_rule_name = (rule.get("rule_name") or "").strip()
......@@ -176,7 +241,9 @@ def run_step_custom_value_check(
"column_name": column_name,
"column_comment": column_comment,
"data_type": data_type,
"rule_type": rule_type,
# 2026-08-13:rule_type='tree' 永远是字面量,无信息量。
# 改成 rule_text:把条件树序列化为可读 SQL 串,给结果表「规则」列用。
"rule_text": _serialize_rule_human(group_node),
}
# 本规则的命中容器(即使没命中也要建,给 tab 一个空数组让前端渲染空表)
rule_matches_by_idx[f"rule_{rule_index}"] = []
......@@ -420,9 +487,8 @@ def _build_result(
render=RenderSpec(kind="tag")),
ColumnSpec(prop="value", label="匹配值", min_width=240,
render=RenderSpec(kind="code")),
ColumnSpec(prop="rule_type", label="规则", min_width=120,
align="center",
render=RenderSpec(kind="tag")),
ColumnSpec(prop="rule_text", label="规则", min_width=240,
render=RenderSpec(kind="code")),
ColumnSpec(prop="error", label="错误", min_width=200,
render=RenderSpec(kind="text")),
),
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment