Commit b95def37 authored by Data Governance Dev's avatar Data Governance Dev

feat(web): 字段匹配规则外置到 YAML + 检查项自定义输入框 + 卡片折叠

匹配规则外置:
- 新增 web/configs/standards_match.yaml(44 个 indicator + 14 条 length 规则)
- 新增 web/core/match_config.py:线程安全加载 + get_indicator_match / get_length_rules / merge_user_override
- step6 / step7 走 YAML + 用户运行时 override 三级 fallback(用户输入追加而非替换)
- ConnectRequest 新增 match_overrides: dict[str, dict[str, list[str]]]
- 新增 GET /api/match-config:返回前端输入框默认值(逗号分隔格式)

前端检查项 2 个输入框:
- 每行 leaf 后挂「字段名 / 字段注释」2 个 el-input
- loadMatchConfig() 启动时拉 YAML 默认值灌进 form.match_overrides
- buildMatchOverridesForSubmit() 提交时把字符串转 list(Pydantic 期望 list[str])
- 跨字段 indicator(IND-301/302/901/902)UI 隐藏输入框(_skip: true)

卡片折叠 + 动画:
- 连接数据库 / 分析配置 / 数据字典浏览器 三张卡都加折叠按钮 + Vue transition
- startJob() 成功后三张卡自动折叠,焦点落到「分析进度」
- el-card__body 折叠时 padding 上下 1px(默认 20px),padding 加 0.28s 过渡
- el-card__header 折叠时 padding 6px,margin-bottom 折叠时 8px
- i 图标移到输入框右侧
- progress-block margin 改为 0

Bug fix:
- testConnection 也走 buildMatchOverridesForSubmit()(否则 names/comments 是 string → 422)
- inline <style> 全局 CSS 不能用 :deep(),全部改为直接选择器

WORKLOG 详细记录所有决策点。
parent a0c39eb1
......@@ -2,8 +2,323 @@
> 任务做完一次记一次。最近的在最上面。
## 2026-08-12 · 布局调整 + 还原字段名/注释关键字匹配 + 删除右表格 checkbox
## 2026-08-12 · 字段匹配规则外置 + 检查项自定义输入框
### 需求
> "针对每一个分析项目,在页面上的每个项目(具体分析项目)后面加两个输入框,一个框用来输入表字段信息,另一个用来输入表字段注释,在匹配分析对象的时候用这两个输入框里面的内容去匹配,同时维护一个本地文件配置,在打开页面的时候两个输入框的默认值用这个配置文件的值,把现在的匹配对象移植到这个配置文件里面来"
把 step6 / step7 里硬编码的字段匹配规则外置到 YAML 配置文件,并在前端每个具体检查项后挂 2 个输入框(字段名 / 字段注释),让用户运行时可追加匹配目标。
### 改动
#### 1. 新增 `web/configs/standards_match.yaml`(核心交付物)
迁移原 `step7_standards.py` 里 46 个 indicator 的 `applies_to_fields` + `comment_keywords`,以及 `step6_length_check.py` 里 14 条 length 规则,全部写到 YAML。
结构:
```yaml
step7_standards:
IND-001-a:
applies_to_fields: [id_card, id_card_no, ...]
comment_keywords: []
IND-301: # 跨字段/跨表 indicator
_skip: true # → UI 隐藏输入框
...
step6_length:
rules:
- name_pattern: "^(id_?card|id_?number|identity_?card)$"
comment_keywords: ["身份证", "公民身份"]
expected_length: 18
standard: "GB 11643-1999"
description: "身份证号 18 位"
...
```
跨字段 indicator(IND-301 / 302 / 901 / 902 共 4 个)用 `_skip: true` 标记,UI 据此隐藏输入框。
#### 2. 新增 `web/core/match_config.py`
YAML 加载 + 查询接口,线程安全(`threading.Lock`),文件缺失/解析失败时返回空配置(绝不抛异常)。
对外 API:
- `get_indicator_match(standard_id) → dict`:返回 `{applies_to_fields, comment_keywords}` 或 `{_skip: True}`
- `get_length_rules() → list[dict] | None`:返回 step6 规则列表;YAML 缺失时返回 None
- `merge_user_override(base_atf, base_cmk, user_override) → (list, list)`:把用户运行时 override 追加到基础列表(去重保序)
- `reset_cache_for_tests()`:清缓存(测试用)
#### 3. 后端 step 实现走 YAML 配置 + 用户 override(保留向后兼容)
- [web/core/models.py](web/core/models.py) — `ConnectRequest` 新增 `match_overrides: dict[str, dict[str, list[str]]] = Field(default_factory=dict)`
- [web/core/step_impl/step7_standards.py](web/core/step_impl/step7_standards.py)
- 新增 `_effective_match(std_instance, match_override)`:优先用 match_override,其次 YAML 配置,再次类属性 fallback
- `_run_round1_one` / `_run_round1` / `run_step7_for_indicator` 都接受 `match_override` / `match_overrides`
- `has_name / has_cmt` 守卫改用 `_effective_match`,对 `_skip` 指示器返回 True 跳过
- [web/core/step_impl/step6_length_check.py](web/core/step_impl/step6_length_check.py)
- 新增 `_build_effective_rules(match_override)`:YAML → 代码内置 → 用户 override 三级兜底
- 用户 names → 当 substring regex(`re.escape`)追加一条 `expected_length=18` 的 `USER_OVERRIDE` 规则
- 用户 comments → 追加到每条现有规则的 `comment_keywords` 集合
- 新增 `_rule_from_dict(d)` 容错构造
#### 4. 后端 orchestrator / job_manager 透传
- [web/core/orchestrator.py](web/core/orchestrator.py):6 个 step runner 全部加 `match_overrides` 参数;`run_governance_workflow` 签名加 `match_overrides`;主循环 lambda 把 match_overrides 传给 `sd.fn(...)`。`_run_standards_one` 同时支持按 `standard_id` 和 `step_id` 两种 key 解析 override。
- [web/core/job_manager.py](web/core/job_manager.py):`_run_job` 用 `getattr(job.req, "match_overrides", None)` 透传(向前兼容旧前端)。
#### 5. 新增 `GET /api/match-config` 接口
[web/api/routes.py](web/api/routes.py) — 加载 YAML 后按 step_id 返回前端 UI 需要的格式:
```json
{
"steps": {
"std_ind_001_a": {"names": "id_card, id_card_no, ...", "comments": "", "skip": false},
"std_ind_301": {"names": "", "comments": "", "skip": true},
"length_check": {"names": "id_?card, ...", "comments": "身份证, ...", "skip": false},
...
}
}
```
- 聚合 step6 的所有 length 规则到 `length_check` 一个 entry(alternation regex 拆分后剥分组括号,UI 显示干净)
- 跨字段 indicator (`_skip: true`) 直接返 skip
- 文件加载失败返 `{}`,前端按「空 = 不预填」处理
#### 6. 前端 `web/static/index.html`
- 新增 `.match-inputs` 样式(紧凑布局、12px 等宽字体)
- `form` reactive 新增 `match_overrides: {}`(保存 step_id → `{names, comments}` 字符串)
- `flatCheckRows` 不变;leaf 模板新增「2 个输入框」块:
```html
<span v-if="row.step_id && matchConfig[row.step_id] && !matchConfig[row.step_id].skip"
class="match-inputs">
<el-input v-model="form.match_overrides[row.step_id].names"
placeholder="字段名(逗号分隔)" clearable />
<el-input v-model="form.match_overrides[row.step_id].comments"
placeholder="字段注释(逗号分隔)" clearable />
</span>
```
- `onMounted` 加 `loadMatchConfig()`:拉 YAML 默认值并塞进 `form.match_overrides`
- `startJob()` 改用 `buildMatchOverridesForSubmit()` 把字符串转成 `list[str]` 后再放进 payload;空字符串 entry 跳过(后端走 YAML 默认值)
### 三级 fallback 总结
| 优先级 | 来源 | 用途 |
|---|---|---|
| 1 | 用户运行时输入框(逗号分隔) | 临时追加匹配目标 |
| 2 | YAML 配置(web/configs/standards_match.yaml) | 默认匹配规则,可被用户 override 追加 |
| 3 | 代码内置(`LENGTH_RULES` / `BaseStandard.applies_to_fields`) | YAML 缺失时的最终兜底 |
合并策略是**追加**(不去重覆盖)—— YAML 的默认匹配永远保留,用户的输入叠加上去。这样:
- 不填 → 走 YAML 默认值
- 填了新名字 → 在 YAML 默认基础上**追加**,覆盖范围变广
- 删默认值 → 没法做(前端没提供「删除 YAML 项」UI,避免误操作)
### 跨字段 indicator 的处理
IND-301/302/901/902 是「法人 / 经办人 / 对公账户 / 单位」类跨字段比对,不存在「字段名匹配」,所以 UI 隐藏输入框、后端 `_skip: true` 也跳过 `has_name/has_cmt` 守卫。
### 不改的
- 没动 LLM 调用、没动 SQL 模板、没动数据库 schema
- 没新增 / 删除任何 step,只补透传
- 没动前端布局(分析配置还在原位置)
### 验证
```bash
PYTHONIOENCODING=utf-8 python -c "
from web.core.match_config import _load, get_indicator_match, get_length_rules, merge_user_override
print(len(_load()['step7_standards'])) # 44
print(len(get_length_rules())) # 14
print(get_indicator_match('IND-001-a')) # {'applies_to_fields': [...], 'comment_keywords': []}
print(get_indicator_match('IND-301')) # {'_skip': True}
"
```
`GET /api/match-config` 实测返回 49 个 step entry,含 4 个 skip 项(IND-301/302/901/902)。
### Bug fix:testConnection 也走 buildMatchOverridesForSubmit()
初次提交后用户实测报 `POST /api/connect/test 422 Unprocessable Entity`。根因:
- `form.match_overrides[sid].names / .comments` 在 UI 里是逗号分隔字符串(输入框格式)。
- `loadMatchConfig()` 用 YAML 默认值灌进 form 时也是字符串。
- `startJob()` 走 `buildMatchOverridesForSubmit()` 把字符串转 list 后再发,**OK**。
- 但 `testConnection()` 之前直接 `JSON.stringify({ ...form, tables: [] })`,把字符串格式的 match_overrides 直接发给后端,Pydantic v2 期望 `list[str]` → 422。
修复([web/static/index.html](web/static/index.html)):`testConnection()` 也走 `buildMatchOverridesForSubmit()`,与 `startJob()` 行为一致。
```js
body: JSON.stringify({
...form,
tables: [],
match_overrides: buildMatchOverridesForSubmit(), // ← 必加
}),
```
### 调整:i 图标移到输入框右侧 + 分析配置 / 数据字典浏览器支持折叠
#### 需求
> "icon 放两个输入框的右侧吧,然后点击开始分析之后把分析配置和数据字典浏览器整体折叠(点击右上角还是可以展开)"
#### 改动([web/static/index.html](web/static/index.html))
**1. i 图标位置调整**
之前每行 leaf: `[checkbox] [i] [字段名输入框] [字段注释输入框]`
现在: `[checkbox] [字段名输入框] [字段注释输入框] [i]`
i 图标 tooltip 内容(purpose / target / check / format)原封不动;只是把它从 checkbox 后挪到输入框后。
**2. 卡片折叠**
- 新增两个 ref:`analysisConfigCollapsed` / `dataDictCollapsed`(初始 false)。
- 卡片 header 右上角加「折叠 / 展开」按钮(Element Plus `Fold` / `Expand` 图标)。
- 卡片 body 包在 `<div v-show="!analysisConfigCollapsed">` 里 —— 用 `v-show` 而不是 `v-if`,避免反复创建/销毁内部状态(保留已展开的组、勾选项等)。
- `startJob()` 拿到 `job_id` 后立即把两个 ref 设为 `true`,让焦点自然落到「分析进度」卡片上。
- `resetAndStart()` 复用 `startJob()`,所以「重新分析」也会自动折叠。
#### 行为
- 默认展开 → 用户勾选 / 编辑输入框 → 点「开始分析」 → 两个卡片折叠、进度卡显现
- 想改配置再跑?点卡片右上「展开」即可恢复 → 重新勾选 → 「重新分析」
### 调整:连接数据库也折叠 + 折叠后卡片更矮 + 折叠动画
#### 需求
> "连接数据库也折叠,折叠后的项目高度可以再矮一些,然后折叠加一个动画效果"
#### 改动([web/static/index.html](web/static/index.html))
**1. 连接数据库卡片也加折叠按钮**
- 新增 `connectionCollapsed` ref(初始 false)
- 卡片 header 右上角同款「折叠 / 展开」按钮
- 折叠状态不会自动触发(用户没要求)—— 用户手动点按钮折叠
**2. 折叠后卡片高度变矮**
新增 CSS:
```css
.section-card.is-collapsed :deep(.el-card__header) {
padding: 8px 20px !important; /* 默认 18px → 8px */
}
.section-card.is-collapsed .card-header {
font-size: 14px; /* 默认 16px → 14px */
}
```
- 卡片应用 `:class="{ 'is-collapsed': collapsed }"` 绑定折叠状态
- 折叠后 header 从 ~60px 降到 ~38px,三个卡片堆叠更紧凑
**3. 折叠动画**
用 Vue 3 `<transition name="collapse">` 包裹三个卡片的 body,新增 CSS:
```css
.collapse-enter-active, .collapse-leave-active {
transition: max-height 0.28s ease, opacity 0.22s ease;
overflow: hidden;
}
.collapse-enter-from, .collapse-leave-to {
max-height: 0; opacity: 0;
}
.collapse-enter-to, .collapse-leave-from {
max-height: 1800px; opacity: 1; /* 够大,确保完整展开 */
}
```
- max-height 过渡:折叠时 0 → 0(被 v-show 隐藏),展开时 0 → 1800px 平滑增长
- opacity 同时淡入淡出,过渡更柔和
- 三个卡片都套上 transition:连接数据库 / 分析配置 / 数据字典浏览器
### 调整:折叠更紧凑(header / body / 间距)+ 开始分析后连接数据库也自动折叠
#### 需求
> "1. 折叠后每个板块的 el-card__body 不显示,el-card__header 的高度调小,el-card 之间的间距调小,2. 开始检测后数据库连接也自动变成折叠状态"
#### 改动([web/static/index.html](web/static/index.html))
**1. 折叠时样式进一步收紧**
```css
.section-card.is-collapsed :deep(.el-card__header) {
padding: 6px 20px !important; /* 8px → 6px */
}
.section-card.is-collapsed :deep(.el-card__body) {
display: none !important; /* 兜底:避免 Element Plus 默认 body padding 留空白 */
}
.section-card.is-collapsed {
margin-bottom: 8px !important; /* 20px → 8px */
border-radius: 6px;
}
.section-card.is-collapsed:last-of-type {
margin-bottom: 0 !important;
}
```
效果:折叠后 header 从 ~38px 降到 ~28px,卡片间距从 20px 降到 8px,堆叠更紧凑。
**2. `startJob()` 后连接数据库也自动折叠**
[web/static/index.html](web/static/index.html) 在拿到 `job_id` 之后立即:
```js
connectionCollapsed.value = true; // ← 新增
analysisConfigCollapsed.value = true;
dataDictCollapsed.value = true;
```
三张卡片折叠后整个上半部分只占约 90px 高度,「分析进度 / 日志 / 结果」成为视觉重心。
### 调整:折叠时 el-card__body 上下 padding 改为 1px,展开时 20px
#### 需求
> "在折叠状态把 el-card__body 的上下 padding 改为 1,展开的时候改为 20"
#### 改动([web/static/index.html](web/static/index.html))
把上一版的 `display: none !important` 改为 padding 控制高度 —— body 始终在 DOM(方便 transition 平滑过渡),只是 padding 极小让高度接近 0:
```css
.section-card :deep(.el-card__body) {
padding: 20px;
transition: padding 0.28s ease; /* 跟 .collapse 动画同步 */
}
.section-card.is-collapsed :deep(.el-card__body) {
padding-top: 1px !important;
padding-bottom: 1px !important;
}
```
效果:
- 折叠时 body 上下 padding 仅 1px(左右保留 20px 不影响布局),整张折叠卡片高度 ≈ header(28px) + body(2px) = 30px
- 展开时 body 上下 padding 恢复到 20px(默认),与 Element Plus 一致
- padding 变化也加了 0.28s 过渡,跟 `.collapse-enter-active` 同步,过渡更顺滑
### Bug fix:`:deep()` 在 inline `<style>` 里不生效
#### 现象
用户实测:折叠后 el-card__body 的 padding 仍是 20px(用 DevTools 看 box model 确认),卡片之间间距仍很大。
#### 根因
[web/static/index.html](web/static/index.html) 的 `<style>` 块是**普通 HTML 里的 inline 全局样式**,不是 Vue SFC。`:deep(.el-card__body)` 是 Vue 3 SFC scoped CSS 的语法(编译时会把 `.el-card__body` 加 `[data-v-xxx]` 后缀),在普通 CSS 里它就只是个无效选择器 —— 匹配不到任何元素,规则被静默忽略。
我的 4 条折叠规则全部用了 `:deep()`,所以全部没生效:
- `.section-card.is-collapsed :deep(.el-card__header)` → 没生效
- `.section-card :deep(.el-card__body)` → 没生效
- `.section-card.is-collapsed :deep(.el-card__body)` → 没生效
#### 修复
全部去掉 `:deep()`,直接写选择器(inline `<style>` 是全局 CSS,可以直接穿透):
```css
.section-card.is-collapsed .el-card__header { padding: 6px 20px !important; }
.section-card .el-card__body { padding: 20px; transition: padding 0.28s ease; }
.section-card.is-collapsed .el-card__body { padding-top: 1px !important; padding-bottom: 1px !important; }
```
并在注释里写明:「这是 inline `<style>` 全局 CSS(不是 Vue SFC),不能用 :deep() —— 直接写选择器即可」,避免后续编辑再踩坑。
---
## 2026-08-12 · 布局调整 + 还原字段名/注释关键字匹配 + 删除右表格 checkbox
### 需求
> "1. 修改页面布局,把分析配置放到连接数据库正下方,数据字典浏览器的上方,2. 针对每一个分析配置,还是改成用字段名和字段注释模糊匹配的形式(之前的版本可以参考)"
......
......@@ -190,6 +190,89 @@ async def analysis_tree():
return {"groups": out_groups}
# ── 字段匹配规则配置(前端 UI 输入框默认值来源)──
# GET /api/match-config → 返回 YAML 配置(按 step_id 组织)。
# key = step_id(如 "length_check" / "std_ind_001_a")
# value = {
# "names": str, # 字段名(逗号分隔,UI 输入框格式)
# "comments": str, # 字段注释(逗号分隔)
# "skip": bool, # 跨字段/跨表 indicator → UI 隐藏输入框
# }
# 加载失败时返回空 dict,前端按「空 = 不预填」处理。
@router.get("/match-config")
async def get_match_config():
from ..core.match_config import _load
from ..core.orchestrator import get_step_defs
cfg = _load()
step7 = (cfg.get("step7_standards") or {}) if isinstance(cfg, dict) else {}
step6_rules = ((cfg.get("step6_length") or {}).get("rules") or []) if isinstance(cfg, dict) else []
# 把所有 step_id 收集起来:保证 UI 上每个 leaf 都有一份默认配置
# step_id → step 标识
# standard_id → indicator 的原始 ID(用于 YAML 反查;可能为 None)
by_step: dict[str, dict] = {}
# ── Step 7:每个 indicator 的 step_id(如 "std_ind_001_a")────
# YAML 配置里的 key 是 standard_id(如 "IND-001-a"),需要 map 到 step_id。
# 约定:step_id = "std_ind_" + standard_id.lower().replace("-", "_")
for sid, meta in getattr(_list_std_step_ids_safe(), "__iter__", lambda: [])() or []:
# meta: {"id": "IND-001-a", "name": "...", ...}
std_id = meta.get("id", "")
rec = step7.get(std_id) or {}
if rec.get("_skip"):
by_step[sid] = {"names": "", "comments": "", "skip": True}
else:
atf = rec.get("applies_to_fields") or []
cmk = rec.get("comment_keywords") or []
by_step[sid] = {
"names": ", ".join(atf),
"comments": ", ".join(cmk),
"skip": False,
}
# ── Step 6:length_check 聚合所有规则的 names / comments ──────
all_names: list[str] = []
all_comments: list[str] = []
for r in step6_rules:
# 从 regex pattern 抽出可读的 alternation(仅做显示,不强求一致)
# 例:^(id_?card|id_?number)$ → "id_card, id_number"
# 简单实现:拿 | 分隔的 alternation 串
pat = r.get("name_pattern") or ""
body = pat.strip("^$")
for piece in body.split("|"):
p = piece.strip().rstrip("$").lstrip("^")
# 剥掉分组括号((?:...)/(...) 等)→ UI 显示干净
p = p.strip("()")
# 把正则简化为可读示例(id_?card → id_card / id_card;这里只列原文)
if p:
all_names.append(p)
for kw in (r.get("comment_keywords") or []):
if kw not in all_comments:
all_comments.append(kw)
by_step["length_check"] = {
"names": ", ".join(all_names),
"comments": ", ".join(all_comments),
"skip": False,
}
# ── 其余 step(merge / empty / missing_comments 等)无需输入框 ──
# 前端按 step_id 找不到时按 skip=True 处理即可
return {"steps": by_step}
def _list_std_step_ids_safe():
"""延迟加载 standards 包;失败时返回空 list-like。"""
try:
from standards.registry import list_step_ids as _list
return _list()
except Exception as e:
logger.warning(f"加载 standard step_ids 失败: {e}")
return []
# ── 列出已注册国标标准 ──
@router.get("/standards", response_model=StandardsListResponse)
async def list_standards():
......
# 字段匹配规则配置(2026-08-12 迁移自代码硬编码)
# --------------------------------------------------------------
# 历史:原本写在 standards/ind_*.py 的 BaseStandard 子类(applies_to_fields /
# comment_keywords 类属性)和 web/core/step_impl/step6_length_check.py
# 的 LENGTH_RULES 列表常量里。前端做「字段名/字段注释」输入框后,用户
# 改的输入会作为运行时 override 追加到这里定义的规则集上。
#
# 字段匹配策略:
# applies_to_fields: 精确字段名匹配(任一即命中)
# comment_keywords: 字段注释 substring 匹配(任一出现即命中,不区分大小写)
# 两者并集 = 命中字段集合。
#
# 用户 override:UI 输入框内容会被序列化为
# { "step_id": { "names": [...], "comments": [...] } }
# 提交时按 step_id 匹配本配置;names 追加到 applies_to_fields,comments 追加到
# comment_keywords(不替换,仅扩展匹配范围)。
#
# 跨字段 / 跨表 indicator(IND-301 / 302 / 901 / 902)不走字段匹配(用固定列
# 配对),此处用 _skip: true 标记,UI 不显示输入框。
# ── Step 7:每个 indicator 一组规则 ──────────────────────────
step7_standards:
# ── 国标字段规范 ──
IND-001-a: # GB 11643-1999 身份证号格式
applies_to_fields: [id_card, id_card_no, id_number, identity_card]
comment_keywords: []
IND-001-b: # GB 11643-1999 身份证校验位
applies_to_fields: [id_card, id_card_no, id_number, identity_card, sfz_hm]
comment_keywords: []
IND-001-c: # GB 11643-1999 身份证出生日期
applies_to_fields: [id_card, id_card_no, id_number, identity_card]
comment_keywords: []
IND-001-d: # GB 11643-1989 身份证 15 位老证提示
applies_to_fields: [id_card, id_card_no, id_number, identity_card]
comment_keywords: []
IND-002-a: # GB 32100-2015 USCC 格式
applies_to_fields: [uscc, credit_code, social_credit_code, unified_social_credit_code]
comment_keywords: []
IND-002-b: # GB 32100-2015 USCC 校验位
applies_to_fields: [uscc, credit_code, social_credit_code, unified_social_credit_code]
comment_keywords: []
IND-002-c: # GB 32100-2015 老代码兼容转换
applies_to_fields:
- uscc
- credit_code
- social_credit_code
- unified_social_credit_code
- org_code
- organization_code
- zzjgdm
- jgdm
comment_keywords: []
IND-003-a: # 工信部 手机号格式
applies_to_fields:
- mobile
- mobile_phone
- phone
- tel
- telephone
- contact_phone
- customer_mobile
- legal_person_mobile
- notify_phone
- maintainer_phone
- manager_phone
- receiver_phone
comment_keywords:
- 手机号码
- 手机号
- 联系手机
- 联系电话
- 法人手机
- 法人联系电话
- 经办人手机
- 经办人联系电话
- 单位手机
- 单位联系人手机
IND-003-b: # 工信部 手机号号段
applies_to_fields:
- mobile
- mobile_phone
- phone
- tel
- telephone
- contact_phone
- customer_mobile
- legal_person_mobile
- notify_phone
- maintainer_phone
- manager_phone
- receiver_phone
comment_keywords: []
IND-004-a: # GB/T 2260 行政区划代码格式
applies_to_fields:
- xzqhbm
- xzqhjb
- adcode
- district_code
- province_code
- city_code
- region_code
comment_keywords: []
IND-004-b: # GB/T 2260 行政区划编码存在性
applies_to_fields:
- xzqhbm
- xzqhjb
- adcode
- district_code
- province_code
- city_code
- region_code
comment_keywords: []
IND-005-a: # GB/T 15835 固定电话格式
applies_to_fields:
- fixed_phone
- office_phone
- tel
- fax
- home_phone
- work_phone
- company_phone
- phone_office
comment_keywords:
- 固定电话
- 办公电话
- 公司电话
- 单位电话
- 工作电话
- 联系电话
- 传真
- 座机
- 单位联系电话
- 经办人电话
- 法人电话
# ── 业务字段规范 ──
IND-006-a: # 通讯地址 格式
applies_to_fields:
- address
- mailing_address
- home_address
- contact_address
- register_address
- company_address
- work_address
- live_address
- addr
- postal_address
comment_keywords:
- 通讯地址
- 联系地址
- 户籍地址
- 居住地址
- 住址
- 现住址
- 工作地址
- 单位地址
- 公司地址
- 注册地址
- 办公地址
- 收件地址
- 邮寄地址
- 送达地址
- 通讯地点
- 法人地址
- 经办人地址
IND-006-b: # GB/T 23705 邮政编码 格式
applies_to_fields: [postal_code, postcode, zip, zip_code, zipcode]
comment_keywords: [邮编, 邮政编码, 单位邮编]
IND-007-a: # 个人公积金账号 格式(位数)
applies_to_fields: []
comment_keywords:
- 公积金账号
- 个人公积金
- 公积金个人账号
- 住房公积金账号
- 个人公积金账号
- 公积金编号
- 公积金账户
- 个人公积金账户
IND-008-a: # 首次参加工作年月 格式
applies_to_fields:
- first_work_date
- first_work_month
- work_start_date
- first_job_date
- ccgzrq
- scgzrq
comment_keywords:
- 首次参加工作
- 首次工作
- 参加工作年月
- 参加工作时间
- 入职时间
- 入职年月
- 工龄起算
- 起始工作时间
- 首次参加工作日期
- 首次工作日期
IND-009-a: # 用工类型 值域
applies_to_fields: [employment_type, employment_form, employment_nature, yglx, ygxz]
comment_keywords:
- 用工类型
- 用工形式
- 用工性质
- 就业类型
- 就业形式
- 劳动合同类型
- 用工方式
IND-010-a: # 本地户籍标记 值域
applies_to_fields: [local_household, is_local, local_resident, bdsf, bdshj, sfbds]
comment_keywords:
- 本地户籍
- 本市户籍
- 本市常住
- 本地常住
- 户籍本地
- 本地户口
- 本市户口
- 户籍地
- 是否本地
- 是否本市
IND-011-a: # 职工状态 值域
applies_to_fields: [employee_status, staff_status, worker_status, account_status, zgzt, zhzt]
comment_keywords:
- 职工状态
- 员工状态
- 账户状态
- 公积金状态
- 人员状态
- 缴存状态
- 参保状态
# ── 单位基本信息 ──
IND-012-a: # 单位名称 格式
applies_to_fields:
- company_name
- corp_name
- enterprise_name
- unit_name
- dwname
- dwmc
- gsmc
comment_keywords:
- 单位名称
- 公司名称
- 企业名称
- 机构名称
- 单位名
- 公司名
- 企业名
- 机构名
- 缴存单位名称
- 开户单位名称
- 参保单位名称
IND-013-a: # GB/T 12402 单位类型 值域
applies_to_fields: [unit_type, corp_type, company_type, enterprise_type, dwlx, dwlb, qylx]
comment_keywords:
- 单位类型
- 单位类别
- 公司类型
- 企业类型
- 单位性质
- 机构类型
- 市场主体类型
- 单位代码
- 机构代码
IND-013-b: # 国统字〔2011〕86 号 经济类型 值域
applies_to_fields: [economic_type, econ_type, jjlx, jjxz]
comment_keywords:
- 经济类型
- 经济性质
- 登记注册类型
- 企业注册类型
- 企业经济类型
- 单位经济类型
IND-013-c: # GB/T 4754 行业代码 格式
applies_to_fields:
- industry_code
- industry
- trade_code
- hy_code
- hybm
- industry_category
comment_keywords:
- 行业代码
- 行业类别
- 行业分类
- 国民经济行业
- 所属行业
IND-014-a: # GB/T 7408 单位设立日期 格式
applies_to_fields:
- establishment_date
- establish_date
- founded_date
- set_up_date
- registration_date
- reg_date
- qyslrq
- slrq
comment_keywords:
- 单位设立日期
- 设立日期
- 成立日期
- 注册日期
- 登记日期
- 开业日期
- 企业开业日期
- 公司成立日期
- 企业成立日期
IND-014-b: # GB/T 7408 启缴年月 格式
applies_to_fields: [payment_start_month, pay_start_month, start_pay_month, qjyf, qjyrm, kcny]
comment_keywords:
- 启缴年月
- 启缴月份
- 缴费起始月
- 起缴年月
- 参保年月
- 单位启缴年月
- 单位启缴月份
IND-015-a: # 发薪日 值域
applies_to_fields: [pay_day, salary_day, payment_day, wage_day, fxr, gfr, gzffr]
comment_keywords:
- 发薪日
- 工资发放日
- 发放日
- 工资日
- 薪资发放日
# ── 单位对公账户 ──
IND-016-a: # CNAPS 联行号 格式
applies_to_fields:
- cnaps
- bank_code
- bank_id
- payee_bank_code
- payee_bank_id
- opening_bank_code
- union_pay_code
- lhh
- lhdm
- jjh
comment_keywords:
- 联行号
- 开户银行行号
- 银行行号
- 支付行号
- 收款行号
- CNAPS
- 开户行联行号
- 银行联行号
- 支付系统行号
- 开户网点行号
- 银行编号
IND-016-b: # 银行账号 格式
applies_to_fields:
- bank_account
- bank_account_no
- account_no
- acct_no
- bankcard_no
- card_no
- yhh
- yhzh
- zh
- zhdm
comment_keywords:
- 银行账号
- 银行账户
- 账号
- 账户号
- 卡号
- 对公账户
- 对私账户
- 银行卡号
- 账户编号
- 银行账号(单位)
- 单位银行账号
# ── 证件信息 ──
IND-101: # 证件类型枚举
applies_to_fields: [id_type, cert_type, document_type, zjlb]
comment_keywords:
- 证件类型
- 证件种类
- 法人证件类型
- 经办人证件类型
- 法人证件种类
- 经办人证件种类
IND-201: # 证件号码-居民身份证 18 位
applies_to_fields: [id_no, cert_no, zjhm, document_no]
comment_keywords:
- 法人证件号
- 法人证件号码
- 经办人证件号
- 经办人证件号码
- 证件号码
- 身份证号码
- 身份证号
IND-202: # 证件号码-中国护照
applies_to_fields: [passport_no, hz_no, huzhao_no]
comment_keywords:
- 护照号
- 护照号码
- 个人护照号
- 法人护照号
IND-203: # 港澳台居住证
applies_to_fields:
- hk_residence_no
- mo_residence_no
- tw_residence_no
- gat_residence_no
- gatjzz_no
comment_keywords:
- 港澳台居住证
- 港澳居住证
- 台居住证
- 个人港澳台居住证
- 法人港澳台居住证
IND-204: # 外国人永居证
applies_to_fields: [foreign_residence_no, yd_no, ywjzj_no, prc_residence_no]
comment_keywords:
- 永居证
- 外国人永居证
- 外国人在中国永久居留证
- 外国人居留证
- 外国人身份证
# ── 跨字段 / 跨表(不走字段匹配;UI 不显示输入框) ──
IND-301: { _skip: true }
IND-302: { _skip: true }
# ── 身份信息 ──
IND-401: # 姓名字符集
applies_to_fields:
- name
- real_name
- customer_name
- user_name
- xm
- user_real_name
- person_name
comment_keywords:
- 姓名
- 客户姓名
- 用户姓名
- 真实姓名
- 法人姓名
- 法人代表姓名
- 经办人姓名
- 联系人姓名
IND-402: # 姓名长度
applies_to_fields:
- name
- real_name
- customer_name
- user_name
- xm
- user_real_name
- person_name
comment_keywords:
- 姓名
- 客户姓名
- 用户姓名
- 真实姓名
- 法人姓名
- 法人代表姓名
- 经办人姓名
- 联系人姓名
IND-501: # 出生日期格式
applies_to_fields: [birth_date, birthday, csrq, date_of_birth]
comment_keywords: [出生日期, 生日]
IND-502: # 出生日期合理性
applies_to_fields: [birth_date, birthday, csrq, date_of_birth]
comment_keywords: [出生日期, 生日]
# ── 民政信息 ──
IND-601: # GB/T 2260-2007 行政区划代码
applies_to_fields:
- hjszd
- hjsxdm
- hukou_xzqh
- registered_district
- native_district_code
- current_district_code
comment_keywords: [行政区划代码, 户籍地址行政区划, 籍贯]
IND-602: # GB/T 3304-1991 民族代码
applies_to_fields: [ethnicity, nation, mz, minzu, ethnic]
comment_keywords: []
IND-603: # GB/T 2261.2-2003 婚姻状况代码
applies_to_fields: [marital_status, marriage, hyzk]
comment_keywords: []
# ── 跨字段 / 跨表(不走字段匹配) ──
IND-901: { _skip: true }
IND-902: { _skip: true }
# ── Step 6:字段长度检查 ───────────────────────────────
# 字段名走正则(精确的 alternation),注释走 substring。
# 用户 UI override:names 会作为额外 substring 规则追加(每条 input 都触发,
# expected_length 默认 18 —— 用户改实际长度时同步把 YAML 改对);
# comments 作为额外 comment_keywords 追加到所有现有规则。
step6_length:
rules:
- name_pattern: "^(id_?card|id_?number|identity_?card)$"
comment_keywords: [身份证, 公民身份]
expected_length: 18
standard: "GB 11643-1999"
description: "身份证号 18 位"
- name_pattern: "^(id_?card_?no|_?sfz_?hm)$"
comment_keywords: [身份证]
expected_length: 18
standard: "GB 11643-1999"
description: "身份证号 18 位"
- name_pattern: "^(uscc|credit_?code|social_?credit_?code)$"
comment_keywords: [统一社会信用代码, 社会信用代码, 信用代码]
expected_length: 18
standard: "GB 32100-2015"
description: "统一社会信用代码 18 位"
- name_pattern: "^mobile(_?phone)?$"
comment_keywords: [手机号, 手机号码, 移动电话]
expected_length: 11
standard: "YD/T 1313"
description: "手机号 11 位"
- name_pattern: "^phone(_?no)?$"
comment_keywords: [联系电话]
expected_length: 11
standard: "YD/T 1313"
description: "手机号 11 位"
- name_pattern: "^tel(ephone)?$"
comment_keywords: [电话]
expected_length: 11
standard: "YD/T 1313"
description: "手机号 11 位"
- name_pattern: "^(xzqhbm|adcode|district_?code)$"
comment_keywords: [行政区划, 行政区划代码, 地区编码]
expected_length: 6
standard: "GB/T 2260"
description: "行政区划代码 6 位"
- name_pattern: "^province_?code$"
comment_keywords: [省级代码, 省代码, 省份编码]
expected_length: 2
standard: "GB/T 2260"
description: "省级代码 2 位"
- name_pattern: "^city_?code$"
comment_keywords: [市级代码, 市代码, 城市编码]
expected_length: 4
standard: "GB/T 2260"
description: "市级代码 4 位"
- name_pattern: "^region_?code$"
comment_keywords: [区县级代码, 区县代码, 区县编码]
expected_length: 6
standard: "GB/T 2260"
description: "区县级代码 6 位"
- name_pattern: "^zip_?code$"
comment_keywords: [邮政编码, 邮编]
expected_length: 6
standard: "GB/T 23703"
description: "邮政编码 6 位"
- name_pattern: "^post_?code$"
comment_keywords: [邮政编码, 邮编]
expected_length: 6
standard: "GB/T 23703"
description: "邮政编码 6 位"
- name_pattern: "^email$"
comment_keywords: [邮箱, 电子邮件, e-mail]
expected_length: 50
standard: "RFC 5321"
description: "电子邮件 ≤254 位,常用 ≤50"
- name_pattern: "^bank_?card(_?no)?$"
comment_keywords: [银行卡号, 银行卡]
expected_length: 19
standard: "JR/T 0002"
description: "银行卡号 ≤19 位"
\ No newline at end of file
......@@ -231,6 +231,7 @@ class JobManager:
tables=job.req.tables,
run_dir=run_dir,
enable_llm=job.req.enable_llm,
match_overrides=getattr(job.req, "match_overrides", None),
on_log=lambda entry: self._sync_log(job, entry),
on_step_start=lambda step_id, title: self._sync_step_start(job, step_id, title),
on_step_done=lambda step_id, title, ok: self._sync_step_done(job, step_id, title, ok),
......
"""字段匹配规则配置加载(2026-08-12 起替代代码硬编码)
职责:
- 从 web/configs/standards_match.yaml 加载匹配规则(启动时缓存一次)
- 提供 step6 / step7 的查询接口(带 fallback 到代码硬编码的兜底语义)
- 提供用户运行时 override 的合并工具
使用:
- web/core/step_impl/step6_length_check.py 启动时按本文件构建 LENGTH_RULES
- web/core/step_impl/step7_standards.py 每次跑 indicator 时调
`get_indicator_match(standard_id)` 拿匹配规则
- web/api/routes.py 在 GET /api/match-config 把当前配置返回给前端做默认值
设计原则:
- 文件缺失 / 解析失败:返回空 dict;调用方回退到代码内置默认值
- 不抛异常(治理任务启动时不应被配置文件卡住)
"""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 配置路径 ──────────────────────────────────────────────
_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "configs" / "standards_match.yaml"
)
# ── 进程级缓存(启动加载一次) ───────────────────────────────
_cache: dict[str, Any] | None = None
_cache_lock = threading.Lock()
def _load() -> dict[str, Any]:
"""读 YAML 并缓存;失败返回空 dict。"""
global _cache
if _cache is not None:
return _cache
with _cache_lock:
if _cache is not None:
return _cache
if not _CONFIG_PATH.exists():
logger.info(
f"match config 缺失({_CONFIG_PATH}),"
"step6/7 将回退到代码内置默认值"
)
_cache = {}
return _cache
try:
import yaml # 延迟导入;缺 PyYAML 时降级
text = _CONFIG_PATH.read_text(encoding="utf-8")
data = yaml.safe_load(text)
if not isinstance(data, dict):
logger.warning(
f"match config 顶层不是 dict({type(data).__name__}),"
"回退到空配置"
)
_cache = {}
return _cache
_cache = data
n_ind = len(data.get("step7_standards") or {})
n_len = len(data.get("step6_length", {}).get("rules") or [])
logger.info(
f"match config 已加载: step7_standards {n_ind} 个 indicator, "
f"step6_length {n_len} 条规则"
)
return _cache
except Exception as e:
logger.warning(f"match config 加载失败: {e},回退到空配置")
_cache = {}
return _cache
def reset_cache_for_tests() -> None:
"""测试用:清掉缓存,下次 load() 重新读盘。"""
global _cache
with _cache_lock:
_cache = None
# ── Step 7 查询接口 ────────────────────────────────────────────
def get_indicator_match(standard_id: str) -> dict[str, list[str]]:
"""获取某个 indicator 的匹配规则。
返回 {"applies_to_fields": [...], "comment_keywords": [...]}
其中任何 key 不存在时为空 list。
配置文件里没写该 indicator(首次跑 / 配置缺失)→ 返回空 dict;
调用方应继续 fallback 到 BaseStandard 子类的类属性。
"""
cfg = _load()
section = cfg.get("step7_standards") or {}
if not isinstance(section, dict):
return {}
rec = section.get(standard_id) or {}
if not isinstance(rec, dict):
return {}
if rec.get("_skip"):
return {"_skip": True} # 跨字段/跨表 indicator
atf = list(rec.get("applies_to_fields") or [])
cmk = list(rec.get("comment_keywords") or [])
return {"applies_to_fields": atf, "comment_keywords": cmk}
# ── Step 6 查询接口 ────────────────────────────────────────────
def get_length_rules() -> list[dict] | None:
"""获取 step6 的 LENGTH_RULES 配置。
返回 list[dict](每条含 name_pattern / comment_keywords / expected_length /
standard / description),配置文件缺失或 step6_length.rules 为空时返回 None
—— 表示使用代码内置 LENGTH_RULES。
"""
cfg = _load()
rules = ((cfg.get("step6_length") or {}).get("rules")) or None
if not rules:
return None
return rules
# ── 用户 override 合并 ────────────────────────────────────────
def merge_user_override(
base_atf: list[str] | None,
base_cmk: list[str] | None,
user_override: dict[str, list[str]] | None,
) -> tuple[list[str], list[str]]:
"""合并用户运行时 override 到基础匹配规则。
Args:
base_atf: 基础 applies_to_fields(来自配置或类属性)
base_cmk: 基础 comment_keywords
user_override: {"names": [...], "comments": [...]}
任一为 None/空都不会改基础集合
Returns:
(merged_atf, merged_cmk) —— 去重,顺序:基础在前 + override 在后
"""
atf = list(base_atf or [])
cmk = list(base_cmk or [])
if not user_override:
return atf, cmk
extra_names = [n for n in (user_override.get("names") or []) if n]
extra_cmts = [c for c in (user_override.get("comments") or []) if c]
if extra_names:
seen = set(atf)
for n in extra_names:
if n not in seen:
atf.append(n)
seen.add(n)
if extra_cmts:
seen = set(cmk)
for c in extra_cmts:
if c not in seen:
cmk.append(c)
seen.add(c)
return atf, cmk
\ No newline at end of file
......@@ -31,6 +31,13 @@ class ConnectRequest(BaseModel):
enable_llm: bool = True
# 可选:报告标题
report_title: Optional[str] = None
# 可选:每个 step 的「字段名 / 字段注释」用户运行时 override
# key = step_id(如 "length_check" / "std_ind_001_a")
# value = {"names": ["id_card", ...], "comments": ["身份证", ...]}
# 语义:追加到 web/configs/standards_match.yaml 的同名 indicator 规则上,
# 用于本次任务(不写回配置文件)。
# 跨字段 / 跨表 indicator(IND-301/302/901/902)即使提交了 override 也会被忽略。
match_overrides: dict[str, dict[str, list[str]]] = Field(default_factory=dict)
class TestConnectionRequest(ConnectRequest):
......
......@@ -95,46 +95,58 @@ def get_step_defs() -> list[StepDef]:
return sorted(_STEPS.values(), key=lambda s: s.order)
# ── Step 函数:参数统一 (cfg, dict_data, llm, log, cancel_event, table_filter) ──
# ── Step 函数:参数统一 (cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides) ──
# 每个 _run_* 接收 orchestrator 提供的统一上下文,做最薄的适配(调真正的 step_impl)
# match_overrides: dict[step_id] -> {"names": [...], "comments": [...]}
# 仅 step6(length_check)+ step7 单值 indicator 会消费;其他 step 忽略。
def _run_merge_redundancy(*, cfg, dict_data, llm, log, cancel_event, table_filter):
def _run_merge_redundancy(*, cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides):
from .step_impl.step2_merge_redundancy import run_step2
data = run_step2(dict_data, llm=llm, log=log, table_filter=table_filter)
return {"section_key": "merge_candidates", "data": data}
def _run_empty_fields(*, cfg, dict_data, llm, log, cancel_event, table_filter):
def _run_empty_fields(*, cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides):
from .step_impl.step4_empty_fields import run_step4
data = run_step4(cfg, log=log, dict_data=dict_data, table_filter=table_filter)
return {"section_key": "empty_fields", "data": data}
def _run_missing_comments(*, cfg, dict_data, llm, log, cancel_event, table_filter): # noqa: ARG001
def _run_missing_comments(*, cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides): # noqa: ARG001
"""缺失注释字段检查(纯规则,不需要 LLM,llm 参数保留仅为签名统一)"""
from .step_impl.step5_missing_comments import run_step5
data = run_step5(dict_data, log=log, table_filter=table_filter)
return {"section_key": "missing_comments", "data": data}
def _run_length_check(*, cfg, dict_data, llm, log, cancel_event, table_filter):
def _run_length_check(*, cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides):
from .step_impl.step6_length_check import run_step6
data = run_step6(dict_data, log=log, table_filter=table_filter)
override = (match_overrides or {}).get("length_check")
data = run_step6(dict_data, log=log, table_filter=table_filter,
match_override=override)
return {"section_key": "length_issues", "data": data}
def _run_standards_one(standard_id: str):
"""返回单个 indicator step 的 runner(捕获 standard_id 闭包)。"""
def _runner(*, cfg, dict_data, llm, log, cancel_event, table_filter):
def _runner(*, cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides):
from .step_impl.step7_standards import run_step7_for_indicator
# step_id 是 std_ind_xxx,但前端 match_overrides 按 standard_id(如 IND-001-a)组织;
# 这里两种 key 都接受,向后兼容。
override = None
if match_overrides:
override = match_overrides.get(standard_id) or match_overrides.get(
f"std_ind_{standard_id.lower().replace('-', '_')}"
)
return run_step7_for_indicator(
standard_id, cfg, dict_data=dict_data, log=log, table_filter=table_filter,
match_override=override,
)
return _runner
def _run_standards(*, cfg, dict_data, llm, log, cancel_event, table_filter): # noqa: ARG001
def _run_standards(*, cfg, dict_data, llm, log, cancel_event, table_filter, match_overrides): # noqa: ARG001
"""旧版「国家标准校验」聚合入口 —— 已被 per-indicator 模式取代,保留以兼容历史调用。
实际 orchestrator 不再注册此 step;用户勾选的是 std_ind_* 单 indicator。
"""
......@@ -575,6 +587,7 @@ async def run_governance_workflow(
tables: list[str],
run_dir: Path,
enable_llm: bool = True,
match_overrides: dict[str, dict[str, list[str]]] | None = None,
on_log: Callable[[dict], None] | None = None,
on_step_start: Callable[[str, str], None] | None = None,
on_step_done: Callable[[str, str, bool], None] | None = None,
......@@ -719,6 +732,7 @@ async def run_governance_workflow(
log=log,
cancel_event=cancel_event,
table_filter=table_filter,
match_overrides=match_overrides,
),
)
section_key = output.get("section_key")
......
......@@ -229,11 +229,17 @@ def _match_rule(col_name: str, col_comment: str, rule: LengthRule) -> tuple[bool
def run_step6(dict_data: dict, log: Callable | None = None,
table_filter: set[str] | None = None) -> dict:
table_filter: set[str] | None = None,
match_override: dict | None = None) -> dict:
"""字段长度检查(纯规则匹配)。
Args:
table_filter: 前端勾选的表名集合(None/空 = 全部)。
match_override: 用户运行时 override
{"names": [...], "comments": [...]}
追加到 YAML 配置 + 内置 LENGTH_RULES 上。
names:每个 name 当作 substring regex(re.escape)追加一条规则;
comments:追加到每条现有规则的 comment_keywords 集合里。
"""
columns = dict_data.get("data_dictionary", [])
if table_filter:
......@@ -243,9 +249,12 @@ def run_step6(dict_data: dict, log: Callable | None = None,
log("WARN", "未获取到任何字段元数据,跳过", step="length_check")
return _wrap({"summary": {"total_issues": 0}, "issues": []})
# ── 构造有效规则集:YAML 配置(若有) + 内置兜底 + 用户 override ──
rules = _build_effective_rules(match_override)
if log:
log("INFO",
f"内置长度规则: {len(LENGTH_RULES)} 条, 待扫描字段: {len(columns)}",
f"长度规则: {len(rules)} 条(YAML/内置 + 用户 override), "
f"待扫描字段: {len(columns)}",
step="length_check")
issues = []
......@@ -260,7 +269,7 @@ def run_step6(dict_data: dict, log: Callable | None = None,
if dt not in ("varchar", "char") or not max_len:
continue
for rule in LENGTH_RULES:
for rule in rules:
hit, basis = _match_rule(col_name, col_comment, rule)
if not hit:
continue
......@@ -298,10 +307,82 @@ def run_step6(dict_data: dict, log: Callable | None = None,
return _wrap({
"summary": {
"total_issues": len(issues),
"rules_applied": len(LENGTH_RULES),
"rules_applied": len(rules),
"matched_by_name": match_by_name,
"matched_by_comment": match_by_comment,
"wasted_bytes_total": sum(i.get("wasted_bytes_per_row") or 0 for i in issues),
},
"issues": issues[:200],
})
\ No newline at end of file
})
def _build_effective_rules(
match_override: dict | None,
) -> list[LengthRule]:
"""构造本次运行的 LENGTH_RULES 列表。
顺序:
1) YAML 配置(web/configs/standards_match.yaml)→ 缺失则用代码内置
2) 用户运行时 override
- names → 每个 name 当 substring regex 追加(默认 expected_length=18)
- comments → 追加到每条现有规则的 comment_keywords
"""
from ..match_config import get_length_rules, merge_user_override
cfg_rules = get_length_rules()
if cfg_rules:
base_rules = [_rule_from_dict(r) for r in cfg_rules]
source = "YAML 配置"
else:
base_rules = list(LENGTH_RULES)
source = "代码内置(YAML 缺失)"
if not match_override:
return base_rules
# 用户 override —— 追加到基础规则集
user_names = [n for n in (match_override.get("names") or []) if n]
user_comments = [c for c in (match_override.get("comments") or []) if c]
# 1) 用户 names:每个 name 追加一条 substring 规则
for name in user_names:
base_rules.append(LengthRule(
name_pattern=re.compile(re.escape(name), re.I),
comment_keywords=tuple(user_comments),
expected_length=18, # 默认身份证口径 —— 用户可改 YAML 自定义
standard="USER_OVERRIDE",
description=f"用户自定义匹配({name})",
))
# 2) 用户 comments:每条现有规则都追加
if user_comments:
for i, rule in enumerate(base_rules):
existing = list(rule.comment_keywords)
new_kw = list(merge_user_override(
base_atf=None, base_cmk=existing,
user_override={"names": None, "comments": user_comments},
)[1])
base_rules[i] = LengthRule(
name_pattern=rule.name_pattern,
comment_keywords=tuple(new_kw),
expected_length=rule.expected_length,
standard=rule.standard,
description=rule.description,
)
logger.info(
f"length_check 规则来源: {source}; "
f"用户 override names={len(user_names)} comments={len(user_comments)}"
)
return base_rules
def _rule_from_dict(d: dict) -> LengthRule:
"""YAML dict → LengthRule(容错:缺字段时退化)。"""
return LengthRule(
name_pattern=re.compile(d["name_pattern"], re.I),
comment_keywords=tuple(d.get("comment_keywords") or []),
expected_length=int(d.get("expected_length") or 0),
standard=d.get("standard") or "",
description=d.get("description") or "",
)
\ No newline at end of file
......@@ -466,6 +466,8 @@ def _empty_indicator_data() -> dict:
def _collect_hit_columns(
std_instance: BaseStandard,
all_columns: list[dict],
applies_to_fields: list[str] | None = None,
comment_keywords: list[str] | None = None,
) -> tuple[list[dict], str]:
"""按 applies_to_fields(字段名) + comment_keywords(字段注释 substring,
不区分大小写)两路并集命中字段;返回 (命中字段列表, 命中原因描述)。
......@@ -473,9 +475,17 @@ def _collect_hit_columns(
2026-08-12 恢复:用户决定走回关键字匹配方案,每个 indicator 用自身
的 applies_to_fields(精确字段名)+ comment_keywords(注释 substring)
自动推断「应该检查哪些字段」。前端不再需要逐字段勾选。
参数优先级:显式传入 > std_instance 类属性(向后兼容)。
"""
applicable = list(getattr(std_instance, "applies_to_fields", []) or [])
comment_kws = [k.strip().lower() for k in (getattr(std_instance, "comment_keywords", []) or []) if k]
if applies_to_fields is None:
applicable = list(getattr(std_instance, "applies_to_fields", []) or [])
else:
applicable = list(applies_to_fields)
if comment_keywords is None:
comment_kws = [k.strip().lower() for k in (getattr(std_instance, "comment_keywords", []) or []) if k]
else:
comment_kws = [k.strip().lower() for k in comment_keywords if k]
applicable_set = set(applicable)
hits: list[dict] = []
......@@ -516,11 +526,45 @@ def _collect_hit_columns(
return hits, " / ".join(reason_bits) or "(无命中条件)"
def _effective_match(
std_instance: BaseStandard,
match_override: dict | None,
) -> tuple[list[str], list[str], bool]:
"""算出一个 indicator 在本次运行中的有效匹配集(合并所有来源)。
合并顺序:
1) YAML 配置(standards_match.yaml)→ 缺失则用类属性兜底
2) 用户运行时 override(UI 输入框)→ 追加
Returns:
(applies_to_fields, comment_keywords, is_cross_field)
is_cross_field=True 表示该 indicator 走跨字段/跨表,UI 不显示输入框。
"""
from ..match_config import get_indicator_match, merge_user_override
std_id = std_instance.standard_id
cfg_match = get_indicator_match(std_id)
if cfg_match.get("_skip"):
return [], [], True # 跨字段 / 跨表 indicator
# 基础 = YAML 配置(若存在)或类属性兜底
base_atf = cfg_match.get("applies_to_fields")
base_cmk = cfg_match.get("comment_keywords")
if base_atf is None and base_cmk is None:
# YAML 没写该 indicator(首次跑 / 新加 indicator)→ 回退到类属性
base_atf = list(getattr(std_instance, "applies_to_fields", []) or [])
base_cmk = list(getattr(std_instance, "comment_keywords", []) or [])
# 用户 override 追加
return merge_user_override(base_atf, base_cmk, match_override) + (False,)
def _run_round1_one(
cfg: DBConfig,
all_columns: list[dict],
std_instance: BaseStandard,
log: Callable | None,
match_override: dict | None = None,
) -> dict:
"""单值校验:跑一个 indicator 实例(适用于 22 个单值类 indicator)。
......@@ -536,8 +580,17 @@ def _run_round1_one(
f" · {std_id} ({getattr(std_instance, 'group', '通用')}) 启动",
step=indicator_step_id(std_id))
# 算有效匹配集(YAML 配置 + 用户 override);类属性作为 YAML 缺失时的兜底
effective_atf, effective_cmk, _is_cross = _effective_match(
std_instance, match_override,
)
# 找该 indicator 命中的所有字段(applies_to_fields ∪ comment_keywords)
hit_cols, hit_reason = _collect_hit_columns(std_instance, all_columns)
hit_cols, hit_reason = _collect_hit_columns(
std_instance, all_columns,
applies_to_fields=effective_atf,
comment_keywords=effective_cmk,
)
# 单 (table, field) 命中后仍按 MAX_TABLES_PER_FIELD 截断同一字段的表数;
# 不同表名 → 不同字段对 → 都保留
......@@ -552,8 +605,8 @@ def _run_round1_one(
fields_to_check.append(c)
if not fields_to_check:
applicable = list(getattr(std_instance, "applies_to_fields", []) or [])
cmt_kws = [k for k in (getattr(std_instance, "comment_keywords", []) or []) if k]
applicable = effective_atf
cmt_kws = effective_cmk
if log:
log("DEBUG",
f" · {std_id} 未匹配任何字段(字段名={applicable} / "
......@@ -695,6 +748,7 @@ def _run_round1(
all_columns: list[dict],
standards_by_field: dict[str, BaseStandard],
log: Callable | None,
match_overrides: dict[str, dict] | None = None,
) -> dict:
"""Round 1:单值校验(按 group 聚合结果)—— 跑全部 indicator,按 group 输出。"""
per_group: dict[str, dict] = {
......@@ -721,7 +775,8 @@ def _run_round1(
step="standards")
for std_id, std_instance in seen_instances.items():
single = _run_round1_one(cfg, all_columns, std_instance, log)
override = (match_overrides or {}).get(std_id)
single = _run_round1_one(cfg, all_columns, std_instance, log, match_override=override)
group = getattr(std_instance, "group", "通用")
bucket = per_group.setdefault(group, _empty_group_data())
# 合并:violations_by_indicator + violations
......@@ -1429,6 +1484,7 @@ def run_step7_for_indicator(
dict_data: dict,
log: Callable | None = None,
table_filter: set[str] | None = None,
match_override: dict | None = None,
) -> dict:
"""跑单个 indicator(orchestrator 每个 step 调一次)。
......@@ -1474,16 +1530,19 @@ def run_step7_for_indicator(
data = _run_round3_cross_field_one(cfg, by_name, log)
else:
# 单值类:字段名 OR 注释命中都可(applies_to_fields / comment_keywords 二选一非空)
has_name = bool(getattr(std_instance, "applies_to_fields", None))
has_cmt = bool(getattr(std_instance, "comment_keywords", None))
if not (has_name or has_cmt):
# 用 _effective_match 合并 YAML + 用户 override 后再判定
eff_atf, eff_cmk, _ = _effective_match(std_instance, match_override)
if not (eff_atf or eff_cmk):
if log:
log("WARN",
f"{standard_id} 没有 applies_to_fields / comment_keywords 也没匹配 "
f"IND-301/302 模板,跳过",
step=indicator_step_id(standard_id))
return _empty_indicator_step_result(standard_id)
data = _run_round1_one(cfg, columns, std_instance, log)
data = _run_round1_one(
cfg, columns, std_instance, log,
match_override=match_override,
)
return {"section_key": section_key, "data": _wrap_single_indicator(std_instance, data)}
......
......@@ -12,6 +12,64 @@
.field-link--active code { color: #fff !important; }
/* 数据字典浏览器样式见 style.css —— 避免此处重复 */
.dict-toolbar .el-input { flex: 1; }
/* 检查项行:字段名 / 注释匹配输入框(每行 2 个,紧贴勾选项右侧) */
.tree-row--leaf .match-inputs {
display: inline-flex;
gap: 6px;
margin-left: 12px;
vertical-align: middle;
}
.tree-row--leaf .match-inputs .el-input { width: 220px; }
.tree-row--leaf .match-inputs .el-input__inner { font-family: Menlo, Consolas, monospace; font-size: 12px; }
.tree-row--leaf .match-inputs .match-hint {
font-size: 11px;
color: #909399;
align-self: center;
margin-right: 4px;
}
/* 折叠动画:max-height + opacity 平滑过渡 */
.collapse-enter-active,
.collapse-leave-active {
transition: max-height 0.28s ease, opacity 0.22s ease;
overflow: hidden;
}
.collapse-enter-from,
.collapse-leave-to {
max-height: 0;
opacity: 0;
}
.collapse-enter-to,
.collapse-leave-from {
max-height: 1800px; /* 够大的值,确保分析配置 / 数据字典浏览器完整展开 */
opacity: 1;
}
/* 折叠后卡片紧凑化:
- body padding 上下 1px(默认 20px):保证 body 高度仅 2px,与折叠过渡一起平滑收尾
- header padding 减小(18px → 6px)
- 卡片间距缩小(20px → 8px)
注:这是 inline <style> 全局 CSS(不是 Vue SFC),不能用 :deep() —— 直接写选择器即可 */
.section-card.is-collapsed .el-card__header {
padding: 6px 20px !important;
}
.section-card.is-collapsed .card-header {
font-size: 14px;
}
.section-card .el-card__body {
padding: 20px;
transition: padding 0.28s ease; /* 跟折叠动画同步 */
}
.section-card.is-collapsed .el-card__body {
padding-top: 1px !important; /* 上下 1px,左右保持 20px */
padding-bottom: 1px !important;
}
.section-card.is-collapsed {
margin-bottom: 8px !important; /* 20px → 8px */
border-radius: 6px;
}
/* 多个连续折叠的卡片,最底下的一个不要 margin-bottom */
.section-card.is-collapsed:last-of-type {
margin-bottom: 0 !important;
}
</style>
<!-- 前端依赖全部本地化(避免 CDN 被墙/慢) -->
<!-- Element Plus CSS -->
......@@ -43,14 +101,28 @@
<el-main class="app-main">
<!-- 卡片 1:连接数据库 -->
<el-card class="section-card" shadow="hover">
<el-card class="section-card" :class="{ 'is-collapsed': connectionCollapsed }" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><Connection /></el-icon>
<span class="card-title">连接数据库</span>
<div style="margin-left: auto">
<el-button
size="small"
text
@click="connectionCollapsed = !connectionCollapsed"
>
<el-icon>
<component :is="connectionCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
{{ connectionCollapsed ? '展开' : '折叠' }}
</el-button>
</div>
</div>
</template>
<el-form :model="form" label-width="100px" :inline="false">
<transition name="collapse">
<div v-show="!connectionCollapsed">
<el-form :model="form" label-width="100px" :inline="false">
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="数据库类型">
......@@ -113,16 +185,32 @@
</el-form-item>
<el-alert v-if="testResult" :title="testResult.msg" :type="testResult.ok ? 'success' : 'error'" :closable="false" show-icon />
</el-form>
</div>
</transition>
</el-card>
<!-- 卡片 2:分析配置 + 启动 -->
<el-card v-if="connected && dataDict.length > 0" class="section-card" shadow="hover">
<el-card v-if="connected && dataDict.length > 0" class="section-card" :class="{ 'is-collapsed': analysisConfigCollapsed }" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><Setting /></el-icon>
<span class="card-title">分析配置</span>
<div style="margin-left: auto">
<el-button
size="small"
text
@click="analysisConfigCollapsed = !analysisConfigCollapsed"
>
<el-icon>
<component :is="analysisConfigCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
{{ analysisConfigCollapsed ? '展开' : '折叠' }}
</el-button>
</div>
</div>
</template>
<transition name="collapse">
<div v-show="!analysisConfigCollapsed">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="要跑的检查">
......@@ -186,6 +274,43 @@
<el-tag v-if="row.llm_mode === 'required'" size="small" type="warning" effect="plain" class="badge">需 LLM</el-tag>
<el-tag v-if="row.required" size="small" type="danger" effect="dark" class="badge">必选</el-tag>
</el-checkbox>
<!-- 字段匹配输入框:仅在 step 命中 YAML 配置时显示;跨字段/跨表 indicator 不显示 -->
<span
v-if="row.step_id && matchConfig[row.step_id] && !matchConfig[row.step_id].skip"
class="match-inputs"
>
<el-tooltip placement="top" effect="dark">
<template #content>
<div style="max-width: 280px; line-height: 1.5">
字段名匹配:逗号分隔,子串匹配(大小写不敏感)。<br>
留空 = 用 YAML 默认值。
</div>
</template>
<el-input
v-model="form.match_overrides[row.step_id].names"
size="small"
placeholder="字段名(逗号分隔)"
clearable
@input="onMatchOverrideInput(row.step_id)"
/>
</el-tooltip>
<el-tooltip placement="top" effect="dark">
<template #content>
<div style="max-width: 280px; line-height: 1.5">
字段注释匹配:逗号分隔,子串匹配(大小写不敏感)。<br>
留空 = 用 YAML 默认值。
</div>
</template>
<el-input
v-model="form.match_overrides[row.step_id].comments"
size="small"
placeholder="字段注释(逗号分隔)"
clearable
@input="onMatchOverrideInput(row.step_id)"
/>
</el-tooltip>
</span>
<!-- 说明 i 图标:放在最右侧(输入框之后) -->
<el-tooltip v-if="row.detail" placement="top" effect="dark">
<template #content>
<div class="step-detail">
......@@ -230,10 +355,12 @@
</el-form-item>
<el-alert v-if="!connected" type="info" :closable="false" show-icon :title="'请先测试连接'" />
<el-alert v-if="connected && form.tables.length === 0" type="warning" :closable="false" show-icon :title="'请在「数据字典浏览器」中勾选至少一张表'" />
</div>
</transition>
</el-card>
<!-- 卡片 3:数据字典浏览器(连接成功后才有数据) -->
<el-card v-if="connected && dataDict.length > 0" class="section-card" shadow="hover">
<el-card v-if="connected && dataDict.length > 0" class="section-card" :class="{ 'is-collapsed': dataDictCollapsed }" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><Document /></el-icon>
......@@ -246,9 +373,21 @@
<el-button size="small" @click="selectAllTables" :disabled="form.tables.length === tablesAll.length">全选</el-button>
<el-button size="small" @click="selectNoTables" :disabled="form.tables.length === 0">清空</el-button>
<el-button size="small" @click="selectTablesInvert">反选</el-button>
<el-button
size="small"
text
@click="dataDictCollapsed = !dataDictCollapsed"
>
<el-icon>
<component :is="dataDictCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
{{ dataDictCollapsed ? '展开' : '折叠' }}
</el-button>
</div>
</div>
</template>
<transition name="collapse">
<div v-show="!dataDictCollapsed">
<div class="dict-pane">
<!-- 左:表列表(带勾选) -->
......@@ -334,6 +473,8 @@
</el-table>
</div>
</div>
</div>
</transition>
</el-card>
<!-- 卡片 4:进度 + 日志 -->
......@@ -673,6 +814,10 @@
connect_timeout: 10,
steps: [],
tables: [],
// 字段匹配用户 override(来自输入框;step_id → {names, comments})
// names / comments 用逗号分隔的字符串(UI 输入框格式);
// 提交时由 buildMatchOverridesForSubmit() 转成后端要的 list 结构。
match_overrides: {},
});
const testing = ref(false);
......@@ -738,6 +883,15 @@
current_step_title: '',
error: null,
});
// ── 卡片折叠状态 ──
// 默认展开;点击「开始分析」成功后由 startJob() 自动折叠
// - 分析配置卡片:折叠后只剩标题行,看不到 checkbox 和匹配输入框
// - 数据字典浏览器:折叠后只剩标题行,看不到表/字段列表
// 用户可点击卡片右上角的「展开 / 折叠」按钮重新展开
const analysisConfigCollapsed = ref(false);
const dataDictCollapsed = ref(false);
const connectionCollapsed = ref(false);
const logs = ref([]);
const logExpanded = ref(false); // 2026-08-12 改:默认折叠实时日志
const showStandardsDialog = ref(false);
......@@ -1177,6 +1331,43 @@
}
}
// ── 字段匹配配置(每项检查的输入框默认值来源) ──
// step_id → {names: str, comments: str, skip: bool}
// skip = true → 该 indicator 是跨字段/跨表,不显示输入框
// 加载后立即把 YAML 默认值灌进 form.match_overrides;用户编辑后再覆盖
const matchConfig = ref({});
async function loadMatchConfig() {
try {
const r = await fetch('/api/match-config');
if (!r.ok) return;
const d = await r.json();
matchConfig.value = d.steps || {};
// 用 YAML 默认值初始化 form.match_overrides(仅 names / comments,跳过 skip 项)
// 用户编辑后再调 onMatchOverrideInput(),这里不做合并 —— 保留 YAML 默认即可
for (const [sid, cfg] of Object.entries(matchConfig.value)) {
if (cfg.skip) continue;
// 仅在用户尚未编辑过时初始化;编辑过的保留
if (!form.match_overrides[sid]) {
form.match_overrides[sid] = {
names: cfg.names || '',
comments: cfg.comments || '',
};
}
}
console.log('[match-config] 已应用', Object.keys(matchConfig.value).length, '项默认配置');
} catch (e) {
console.warn('[match-config] 加载失败:', e);
}
}
// 输入框编辑时同步记录(保证 v-model 写入的字段被保留,
// 即便用户清空也要保留 key —— 后端据此判断「用户已编辑」)
function onMatchOverrideInput(stepId) {
if (!form.match_overrides[stepId]) {
form.match_overrides[stepId] = { names: '', comments: '' };
}
// Vue reactive 已自动追踪 v-model 写入;这里只保留 key 不被 GC 回收
}
// ── 加载标准 ──
async function loadStandards() {
try {
......@@ -1196,10 +1387,16 @@
jobResult.value = null;
try {
// 测连接时不传 tables(后端会忽略)
// 注意:match_overrides 也要走 buildMatchOverridesForSubmit() 转 list,
// 否则后端 Pydantic 会因为 names/comments 是 string 而 422
const r = await fetch('/api/connect/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, tables: [] }),
body: JSON.stringify({
...form,
tables: [],
match_overrides: buildMatchOverridesForSubmit(),
}),
}).then(r => r.json());
testResult.value = { ok: r.ok, msg: r.message };
connected.value = r.ok;
......@@ -1239,11 +1436,16 @@
form.steps.push(s.id);
}
}
// 构造提交用的 payload:match_overrides 由 string → list 转换(后端要 list)
const payload = {
...form,
match_overrides: buildMatchOverridesForSubmit(),
};
try {
const r = await fetch('/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
body: JSON.stringify(payload),
}).then(r => r.json());
if (!r || !r.job_id) {
ElementPlus.ElMessage.error(r?.detail || '启动失败');
......@@ -1251,6 +1453,10 @@
return;
}
currentJob.value = r.job_id;
// 启动成功后折叠 3 张配置卡,让焦点落到「分析进度」上
connectionCollapsed.value = true;
analysisConfigCollapsed.value = true;
dataDictCollapsed.value = true;
subscribeLogs(r.job_id);
pollStatus(r.job_id);
} catch (e) {
......@@ -1260,6 +1466,30 @@
}
}
// ── 提交时把 match_overrides 的字符串字段转成 list ──
// 输入:form.match_overrides = { step_id: { names: "a,b", comments: "c" } }
// 输出:{ step_id: { names: ["a","b"], comments: ["c"] } }
// names / comments 全空(或仅空白)→ 整个 entry 不送(后端走 YAML 默认值)
// names / comments 缺一 → 仅送非空那一项(后端 merge_user_override 会补默认)
function buildMatchOverridesForSubmit() {
const out = {};
for (const [sid, ov] of Object.entries(form.match_overrides || {})) {
if (!ov) continue;
const names = splitCsv(ov.names);
const comments = splitCsv(ov.comments);
if (names.length === 0 && comments.length === 0) continue;
out[sid] = {
names: names.length ? names : [],
comments: comments.length ? comments : [],
};
}
return out;
}
function splitCsv(s) {
if (!s || typeof s !== 'string') return [];
return s.split(',').map(x => x.trim()).filter(Boolean);
}
// ── 订阅日志 ──
function subscribeLogs(jobId) {
if (logEventSource) logEventSource.close();
......@@ -1535,6 +1765,7 @@
loadDbDefaults(); // 先拉默认值(覆盖 form 初始值)
loadSteps();
loadAnalysisTree();
loadMatchConfig(); // 字段匹配输入框的 YAML 默认值
loadStandards();
});
......@@ -1560,6 +1791,9 @@
downloadReport,
loadStandards,
loadDbDefaults,
loadMatchConfig, matchConfig, onMatchOverrideInput,
buildMatchOverridesForSubmit,
analysisConfigCollapsed, dataDictCollapsed, connectionCollapsed,
onDbTypeChange,
selectedField, selectFieldFilter, clearFieldFilter,
};
......
......@@ -356,7 +356,7 @@ body {
/* ── 进度条(上) ── */
.progress-block {
margin-bottom: 20px;
margin: 0;
}
.progress-block .el-progress {
......
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