AI 数据治理框架 A: findings/rules 两表 + D1 唯一索引重复探测器 #162

Closed
opened 2026-09-19 23:47:40 +08:00 by tanzk · 1 comment
Owner

背景

AI 数据治理框架第 A 步(设计: docs/design/AI数据治理框架-20260919.md §三/§四):
建 governance_findings(探测结果)+ governance_rules(判断规则沉淀)两表,
并实现 D1 探测器(唯一索引重复)。

为什么需要这个框架(用户 2026-09-19 提出):
数据治理现状是「人发现坏数据 → 写脚本 → 人工判断 → 改数据」,每次都走开发流程,成本高且易误判。
C-P1-7 实证:人工扫描 5 个候选索引,4 个是误报(把「业务允许多个 NULL」当成重复)。
应由系统 AI 机制自动分析 → 给治理建议 → 分级执行。

影响范围

新增两表 + D1 探测器服务 + 单测;不改现有表结构与业务逻辑。

处理动作 / 设计要求

  • governance_findings 表(§3.1):detector/table_name/target/severity_raw/metrics(JSONB)/
    samples(JSONB,脱敏≤10条)/repro_sql/status + AI 研判产物字段(verdict/verdict_reason/
    suggestion/confidence/rule_id)
  • governance_rules 表(§3.2):detector/table_name/target_pattern/match_metrics/verdict/
    reason/disposition/disposition_level/hit_count/source_finding_id
  • D1 探测器(唯一索引重复)——必须复现索引真实表达式:
    解析 pg_indexes.indexdef(含 WHERE 部分条件、函数如 date(...)、COALESCE(...)),
    生成等价 GROUP BY ... HAVING count(*)>1,而非用 indkey 裸列名
  • 产出 repro_sql(可审计、可人工复核)+ 样本行(脱敏)
  • 单测:误报回归(manual_refunds 的表达式索引不得误报)+ 漏报回归(构造 NULL 列重复能被检出)

验收标准 / 关闭条件

  1. 两表迁移 upgrade/downgrade 通过;现有表不受影响
  2. pytest backend/tests/test_governance_detector.py 全过,含:
    • manual_refunds 场景(表达式索引含 date/COALESCE/部分条件)不误报(#161 误报回归)
    • 构造 device_sn NULL 重复场景能检出(#160 场景回归)
  3. repro_sql 可直接在生产库执行并复现 metrics 数字(凭证贴实测)

关联

  • docs/design/AI数据治理框架-20260919.md §三/§四
  • 依赖:无(第一步)
## 背景 AI 数据治理框架第 A 步(设计: docs/design/AI数据治理框架-20260919.md §三/§四): 建 `governance_findings`(探测结果)+ `governance_rules`(判断规则沉淀)两表, 并实现 D1 探测器(唯一索引重复)。 **为什么需要这个框架**(用户 2026-09-19 提出): 数据治理现状是「人发现坏数据 → 写脚本 → 人工判断 → 改数据」,每次都走开发流程,成本高且易误判。 C-P1-7 实证:人工扫描 5 个候选索引,**4 个是误报**(把「业务允许多个 NULL」当成重复)。 应由系统 AI 机制自动分析 → 给治理建议 → 分级执行。 ## 影响范围 新增两表 + D1 探测器服务 + 单测;不改现有表结构与业务逻辑。 ## 处理动作 / 设计要求 - [x] `governance_findings` 表(§3.1):detector/table_name/target/severity_raw/metrics(JSONB)/ samples(JSONB,脱敏≤10条)/repro_sql/status + AI 研判产物字段(verdict/verdict_reason/ suggestion/confidence/rule_id) - [x] `governance_rules` 表(§3.2):detector/table_name/target_pattern/match_metrics/verdict/ reason/disposition/disposition_level/hit_count/source_finding_id - [x] **D1 探测器(唯一索引重复)**——**必须复现索引真实表达式**: 解析 `pg_indexes.indexdef`(含 `WHERE` 部分条件、函数如 `date(...)`、`COALESCE(...)`), 生成等价 `GROUP BY ... HAVING count(*)>1`,而非用 `indkey` 裸列名 - [x] 产出 `repro_sql`(可审计、可人工复核)+ 样本行(脱敏) - [x] 单测:**误报回归**(`manual_refunds` 的表达式索引不得误报)+ **漏报回归**(构造 NULL 列重复能被检出) ## 验收标准 / 关闭条件 1. 两表迁移 upgrade/downgrade 通过;现有表不受影响 2. `pytest backend/tests/test_governance_detector.py` 全过,含: - `manual_refunds` 场景(表达式索引含 date/COALESCE/部分条件)**不误报**(#161 误报回归) - 构造 device_sn NULL 重复场景**能检出**(#160 场景回归) 3. `repro_sql` 可直接在生产库执行并复现 metrics 数字(凭证贴实测) ## 关联 - docs/design/AI数据治理框架-20260919.md §三/§四 - 依赖:无(第一步)
Author
Owner

关闭凭证核验 (2026-09-20)

验收标准 核验方式 结果
1. 两表迁移 upgrade/downgrade 通过;现有表不受影响 临时库 csctalon_migtest:alembic stamp h3i4j5k6l7m8 → upgrade m3n4o5p6q7r8 → 两表 + 9/8 索引 → downgrade h3i4j5k6l7m8(剩余 governance 表 0)→ 再 upgrade head 幂等通过;生产 alembic_version = m3n4o5p6q7r8,refunds=184987 / wallet=28852 / ai_reports=188 不变 ✅
2a. manual_refunds 表达式索引不误报(#161 回归) tests/test_governance_detector.py::test_d1_no_false_positive_on_partial_expr_index(同构 date()/COALESCE/WHERE order_no IS NOT NULL 索引 + 2 行 order_no NULL 且 device_sn/registrant 均 NULL → 断言 findings == []) ✅
2b. 构造 NULL 列重复能检出(#160 回归) ::test_d1_detects_null_column_dups(device_sn NULL 两行 → 1 finding / redundant_rows=1 / gap_kind=protection_gap);另 ::test_d1_detects_dups_inside_partial_expr_index(部分索引覆盖内重复仍检出,repro_sql 含 date(reg_dt)) ✅
3. repro_sql 可在生产库执行并复现 metrics 见下方「生产实测」第 2 步:repro_sql 跑出 1 组 / 1 行 == metrics 1 组 / 1 行;本地开发库(未治理)跑出 1240 组 / 15601 行 == metrics ✅

交付物

  • commit 6e610d1 feat(governance): 治理框架 A —— findings/rules 两表 + D1 唯一索引重复探测器 #162
  • 生产版本 git-20260920024952(upgrade.sh --git,健康检查通过)
  • 新增:app/models/governance_finding.py / governance_rule.py、app/services/governance_detector.py、
    alembic/versions/m3n4o5p6q7r8_governance_findings_rules.py、tests/test_governance_detector.py

实现要点

两表(设计 §3.1/§3.2)

  • governance_findings:只存证据,不存结论。metrics / samples(脱敏)/ repro_sql 三件套保证可审计;
    verdict / suggestion / confidence / rule_id 等研判字段由 #164 回填 —— 探测器与研判解耦,探测可高频跑。
  • governance_rules:研判结论沉淀,命中即跳过 LLM(§5.4)。auto_level L0/L1/L2 三级,
    MANUAL_ONLY_LEVELS = (L2,) 把「不可逆必须人工确认」这条红线写成常量(设计 §六)。

D1 探测器(设计 §四)—— 两条铁律写在模块文件头,由两轮真实事故换来的:

  1. 逐字符复现索引真实语义:列清单取 pg_get_indexdef 括号内原文(含 date(x)/COALESCE),
    部分索引带 WHERE indpred。✘ 用 indkey 裸列名会误报(manual_refunds 曾凭空报「3062 行重复」)。
  2. 重复 = NULL 视为相等(GROUP BY 语义),但排除「整组全 NULL」:
    PG 普通唯一索引用 = 比较 → 含 NULL 的行永不冲突(即不受保护),而 GROUP BY 把 NULL 归一组
    → 报出的正是「索引拦不住」的行。但要再分两种:
    • 整组全 NULL(ai_reports.task_id 176 行、dashboards.code 2 行)→ 设计如此,排除
    • 部分列有值、仅某列 NULL(wallet:仅 device_sn NULL)→ ⚠️ 真缺口(C-P1-7 本质)

脱敏:敏感列(account/acct/device_sn/order_no/name/sn…)字符串值 → 确定性 SHA256 前 8 位
(保留「组内是否相等」可判、原文不可还原);非敏感列(金额/时间/短枚举)保留原文供研判。
样本值统一过 _json_safe(datetime/Decimal 直落 JSONB 会 TypeError —— 实测踩到并修掉)。

落库幂等:同 (detector, table_name, target) 已有 open finding 时刷新证据而非新增行
(同 #166 教训:无节流的重复留痕会淹没真实变化);已 resolved/ignored 的不影响新 finding(问题复现应重新暴露)。

脚本收敛:scripts/governance_scan_unique_dup.py 改为薄封装(只调服务),
消除「脚本一套判据 + 服务一套判据」的漂移风险。

测试

tests/test_governance_detector.py 5 passed
  1. test_d1_no_false_positive_on_partial_expr_index   表达式部分索引不误报(#161 回归)
  2. test_d1_detects_dups_inside_partial_expr_index    部分索引覆盖内重复仍检出 + repro_sql 含 date()
  3. test_d1_detects_null_column_dups                  device_sn NULL 重复检出(#160 回归) + 脱敏断言
  4. test_d1_excludes_all_null_group_but_keeps_partial 整组全 NULL 排除 / 部分列有值仍检出
  5. test_d1_run_scan_persists_and_is_idempotent       落库 open finding + 重复扫描不堆叠
全量: 1581 passed / 0 failed / 0 errors (ruff 通过)

造重复用例的前提(写测试时踩到的坑):唯一索引下只有「含 NULL 列」的组才可能存在重复
(非 NULL 值相等会被索引直接拒绝);另外 timestamp AT TIME ZONE 是 STABLE 不是 IMMUTABLE,
索引表达式必须建在 timestamptz 列上(生产 manual_refunds.register_time 正是 timestamptz)。

生产实测(脚本 /tmp/prod_verify162.py,容器内执行)

[1] 全库 D1 扫描(落库)
    候选含可空列/表达式的唯一索引: 19
    发现真重复: 0  (生产已由 #161 治理, 期望 0)
    落库: 0
    ✅ 生产无真重复(wallet 已由部分+NND 索引治理)

[2] 生产 DB 上构造 wallet 场景验证检出路径(用后即删, 不落库)
    检出: prod_verify162_tmp/prod_verify162_tmp_biz kind=protection_gap sev=high
    repro_sql 跑出: 1 组 / 1 行
    metrics:        1 组 / 1 行

[3] governance_findings 落库核验
    governance_findings 行数: 0
    列: 18 个

✅ #162 生产实测通过: 两表迁移 + D1 检出/repro_sql 复现/脱敏/落库全链路

本地开发库(未治理,含 #160 遗留重复)对照实测 —— 同一实现两种环境结论正确区分:

候选: 16 | 真重复: 1
· wallet_withdrawals / uq_wallet_withdrawals_business  [severity=high kind=protection_gap]
    指标: 1240 组 / 15601 冗余行 / 覆盖 40284 行 / 占比 38.7%
    样本(脱敏): {'account_id': '#ed178cc4', 'op_type': '用户提现', 'op_time': '2026-08-28T23:39:15+00:00',
                'operator': '用户提现', 'op_amount': 34.0, 'device_sn': None} ×24

repro_sql 在该库跑出 1240 组 / 15601 行,与 metrics 完全一致。

发现但未处理(不在本工单范围,供排期)

全量迁移链路从零不可重放:临时库从空库 alembic upgrade head 在更早的迁移处失败
(constraint "uq_cs_record_business" of relation "cs_records" does not exist)——
说明历史迁移链存在缺口,无法在空库一键重建 schema(生产/测试库都是增量演进过来的,未暴露)。
与本工单无关,建议另立工单核查。

## 关闭凭证核验 (2026-09-20) | 验收标准 | 核验方式 | 结果 | |---|---|---| | 1. 两表迁移 upgrade/downgrade 通过;现有表不受影响 | 临时库 `csctalon_migtest`:`alembic stamp h3i4j5k6l7m8` → `upgrade m3n4o5p6q7r8` → 两表 + 9/8 索引 → `downgrade h3i4j5k6l7m8`(剩余 governance 表 **0**)→ 再 `upgrade head` 幂等通过;生产 `alembic_version = m3n4o5p6q7r8`,`refunds=184987 / wallet=28852 / ai_reports=188` 不变 | ✅ | | 2a. `manual_refunds` 表达式索引**不误报**(#161 回归) | `tests/test_governance_detector.py::test_d1_no_false_positive_on_partial_expr_index`(同构 `date()/COALESCE/WHERE order_no IS NOT NULL` 索引 + 2 行 order_no NULL 且 device_sn/registrant 均 NULL → 断言 findings == []) | ✅ | | 2b. 构造 NULL 列重复**能检出**(#160 回归) | `::test_d1_detects_null_column_dups`(device_sn NULL 两行 → 1 finding / redundant_rows=1 / gap_kind=protection_gap);另 `::test_d1_detects_dups_inside_partial_expr_index`(部分索引覆盖内重复仍检出,repro_sql 含 `date(reg_dt)`) | ✅ | | 3. `repro_sql` 可在生产库执行并复现 metrics | 见下方「生产实测」第 2 步:`repro_sql` 跑出 `1 组 / 1 行` == metrics `1 组 / 1 行`;本地开发库(未治理)跑出 `1240 组 / 15601 行` == metrics | ✅ | ### 交付物 - commit `6e610d1` `feat(governance): 治理框架 A —— findings/rules 两表 + D1 唯一索引重复探测器 #162` - 生产版本 `git-20260920024952`(`upgrade.sh --git`,健康检查通过) - 新增:`app/models/governance_finding.py` / `governance_rule.py`、`app/services/governance_detector.py`、 `alembic/versions/m3n4o5p6q7r8_governance_findings_rules.py`、`tests/test_governance_detector.py` ### 实现要点 **两表**(设计 §3.1/§3.2) - `governance_findings`:**只存证据,不存结论**。metrics / samples(脱敏)/ repro_sql 三件套保证可审计; verdict / suggestion / confidence / rule_id 等研判字段由 #164 回填 —— 探测器与研判解耦,探测可高频跑。 - `governance_rules`:研判结论沉淀,命中即跳过 LLM(§5.4)。`auto_level` L0/L1/L2 三级, `MANUAL_ONLY_LEVELS = (L2,)` 把「不可逆必须人工确认」这条红线写成常量(设计 §六)。 **D1 探测器**(设计 §四)—— 两条铁律写在模块文件头,由两轮真实事故换来的: 1. **逐字符复现索引真实语义**:列清单取 `pg_get_indexdef` 括号内**原文**(含 `date(x)`/`COALESCE`), 部分索引带 `WHERE indpred`。✘ 用 `indkey` 裸列名会误报(`manual_refunds` 曾凭空报「3062 行重复」)。 2. **重复 = NULL 视为相等(`GROUP BY` 语义),但排除「整组全 NULL」**: PG 普通唯一索引用 `=` 比较 → 含 NULL 的行永不冲突(即不受保护),而 `GROUP BY` 把 NULL 归一组 → 报出的正是「索引拦不住」的行。但要再分两种: - **整组全 NULL**(`ai_reports.task_id` 176 行、`dashboards.code` 2 行)→ **设计如此**,排除 - **部分列有值、仅某列 NULL**(wallet:仅 `device_sn` NULL)→ ⚠️ **真缺口**(C-P1-7 本质) **脱敏**:敏感列(account/acct/device_sn/order_no/name/sn…)字符串值 → 确定性 SHA256 前 8 位 (保留「组内是否相等」可判、原文不可还原);非敏感列(金额/时间/短枚举)保留原文供研判。 样本值统一过 `_json_safe`(datetime/Decimal 直落 JSONB 会 `TypeError` —— 实测踩到并修掉)。 **落库幂等**:同 `(detector, table_name, target)` 已有 open finding 时**刷新证据而非新增行** (同 #166 教训:无节流的重复留痕会淹没真实变化);已 resolved/ignored 的不影响新 finding(问题复现应重新暴露)。 **脚本收敛**:`scripts/governance_scan_unique_dup.py` 改为**薄封装**(只调服务), 消除「脚本一套判据 + 服务一套判据」的漂移风险。 ### 测试 ``` tests/test_governance_detector.py 5 passed 1. test_d1_no_false_positive_on_partial_expr_index 表达式部分索引不误报(#161 回归) 2. test_d1_detects_dups_inside_partial_expr_index 部分索引覆盖内重复仍检出 + repro_sql 含 date() 3. test_d1_detects_null_column_dups device_sn NULL 重复检出(#160 回归) + 脱敏断言 4. test_d1_excludes_all_null_group_but_keeps_partial 整组全 NULL 排除 / 部分列有值仍检出 5. test_d1_run_scan_persists_and_is_idempotent 落库 open finding + 重复扫描不堆叠 全量: 1581 passed / 0 failed / 0 errors (ruff 通过) ``` > 造重复用例的前提(写测试时踩到的坑):**唯一索引下只有「含 NULL 列」的组才可能存在重复** > (非 NULL 值相等会被索引直接拒绝);另外 `timestamp AT TIME ZONE` 是 STABLE 不是 IMMUTABLE, > 索引表达式必须建在 `timestamptz` 列上(生产 `manual_refunds.register_time` 正是 timestamptz)。 ### 生产实测(脚本 `/tmp/prod_verify162.py`,容器内执行) ``` [1] 全库 D1 扫描(落库) 候选含可空列/表达式的唯一索引: 19 发现真重复: 0 (生产已由 #161 治理, 期望 0) 落库: 0 ✅ 生产无真重复(wallet 已由部分+NND 索引治理) [2] 生产 DB 上构造 wallet 场景验证检出路径(用后即删, 不落库) 检出: prod_verify162_tmp/prod_verify162_tmp_biz kind=protection_gap sev=high repro_sql 跑出: 1 组 / 1 行 metrics: 1 组 / 1 行 [3] governance_findings 落库核验 governance_findings 行数: 0 列: 18 个 ✅ #162 生产实测通过: 两表迁移 + D1 检出/repro_sql 复现/脱敏/落库全链路 ``` 本地开发库(**未治理**,含 #160 遗留重复)对照实测 —— 同一实现两种环境结论正确区分: ``` 候选: 16 | 真重复: 1 · wallet_withdrawals / uq_wallet_withdrawals_business [severity=high kind=protection_gap] 指标: 1240 组 / 15601 冗余行 / 覆盖 40284 行 / 占比 38.7% 样本(脱敏): {'account_id': '#ed178cc4', 'op_type': '用户提现', 'op_time': '2026-08-28T23:39:15+00:00', 'operator': '用户提现', 'op_amount': 34.0, 'device_sn': None} ×24 ``` `repro_sql` 在该库跑出 `1240 组 / 15601 行`,与 metrics **完全一致**。 ### 发现但未处理(不在本工单范围,供排期) **全量迁移链路从零不可重放**:临时库从空库 `alembic upgrade head` 在更早的迁移处失败 (`constraint "uq_cs_record_business" of relation "cs_records" does not exist`)—— 说明历史迁移链存在缺口,无法在空库一键重建 schema(生产/测试库都是增量演进过来的,未暴露)。 与本工单无关,建议另立工单核查。
tanzk closed this issue 2026-09-20 02:52:58 +08:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tanzk/csctalon#162