Răsfoiți Sursa

docs(aiclean): 补充 2026-10-22 界面验收用例、样本与截图

- 新增 docs/ai-clean-e2e:6 份可复现 xlsx/csv 样本、生成脚本、界面验收用例
- 记录真实案件「AI清洗验收-20261022」的实测结果:
  自建表类型推断、必填整行拒绝、失败明细弹窗、CSV 探测/入库、标题行与空行定位、
  成功/失败/警告/未处理计数
- 记录本轮发现并修复的两个问题:非必填脏值误卡抽样硬门、单 sheet 文件失败入口
- 明确未覆盖项:真实 LLM/向量、多块/合并表头、跨案件唯一键
cc 2 zile în urmă
părinte
comite
fc072c5af9

+ 178 - 0
docs/ai-clean-e2e/README.md

@@ -0,0 +1,178 @@
+# AI 数据清洗 E2E 验收(2026-10-22)
+
+## 1. 目标
+
+验证本轮改造在真实案件、真实数据源上的表现:
+
+- 行级失败可追溯:每一行为什么没进去、因为哪一列、原值(脱敏)。
+- 必填列坏值整行拒绝;非必填坏值置空、行照常入库。
+- 成功 / 失败 / 警告 / 未处理条数口径正确。
+- 日期、时间、金额、数字、电话、身份证、卡号的类型化与入库。
+- CSV 不再被探测阶段直接 Skip;模板复用不会因列序变化写错列。
+- AI 动态表在结果页/数据页可查,类型正确。
+
+## 2. 环境
+
+| 项 | 值 |
+|---|---|
+| 后端 | `ai-server`,`http://127.0.0.1:8980/js/a` |
+| 前端 | `http://localhost:3100` |
+| 案件 | 新建 **AI清洗验收-20261022**(案件 id 10) |
+| 登录 | 开发环境默认 `admin` |
+| 迁移 | 已执行 `sql/ai_clean_row_stats.sql`(否则 `ai_clean_batch.loaded_rows` 等列不存在,智能清洗 500) |
+
+> **自动化环境限制**:ZCode 内置浏览器不支持系统文件选择框。样本通过页面同源的
+> `POST /dm/preFileUpload`(携带页面登录 token)注入;随后 **智能清洗、导入文件列表、
+> 数据页、失败明细弹窗全部在界面里操作/核对**。在普通浏览器里可直接用「选择文件」。
+
+## 3. 测试样本
+
+样本在 [fixtures](fixtures),可用 [generate_fixtures.py](generate_fixtures.py) 重新生成。
+
+| 文件 | 内容 | 目的 |
+|---|---|---|
+| `01_self_built_types.xlsx` | 标题行 + 空行 + 9 列表头 + 7 行数据(含各类脏值、空行隔断) | 自建表类型推断、日期/金额/数字/证件转换、row_no 缺口 |
+| `02_reordered_headers.xlsx` | 与 01 同表头但倒序 | 模板复用必须同序(单测覆盖,UI 未跑) |
+| `03_messy_title_blank.xlsx` | 单格标题 + 空行 + 表头 + 4 行数据(其中一行坏金额/日期) | 表头定位、标题/空行不阻塞、坏值置空 |
+| `04_csv_types.csv` | 与 01 同数据,UTF-8 CSV | CSV 探测/入库;可选列脏值只置空 |
+| `05_required_bad.xlsx` | 同 9 列表头,20 行;第 1 行身份证 `12345` 非法 | 必填列坏值整行拒绝 + 失败明细 |
+| `06_csv_clean.csv` | 同 9 列表头,7 行全合法 | CSV 正常入库 |
+
+01 的 9 列与脏值覆盖:
+
+- 姓名:`张三/李四/...`
+- 身份证号:18 位合法、15 位老证、坏校验位、`12345`
+- 手机号:`+86 139-0000-1111`、`123`
+- 银行卡号:`6222 0202 ...`、科学计数法 `6.22202E+15`
+- 交易日期:ISO、`2026/1/6`、`2026年1月7日`、`2026-02-30`、Excel 序列号 `45000`、紧凑 `20260108`
+- 交易时间:datetime、ISO、纯时间 `10:30`、`25:00`、epoch `1704067200`
+- 金额:`1,234.50`、`-100.00`、`(200.50)`、`约100元`、`¥2,300.5`、`100元`
+- 数量:整数、`1.5`、`abc`
+
+## 4. 用例与结果
+
+### TC-01 自建表类型推断与脏值置空(01)
+
+- 操作:上传 `01` → 智能清洗(无候选 → 出口 B 自建表)。
+- 预期:7 行入库;无效的非必填值置空;日期/金额/数量落真实类型。
+- 实测:`loadedRows=7, rejectedRows=0, warned=2, selfBuiltCount=1`;AI 表「经营者信息」7 行。
+- 类型实测(`ai_clean_field`):
+  - c1 身份证号 `ID_CARD_NUMBER`、c2 手机号 `PHONE`、c3 银行卡号 `ACCT_CARD_NO`(物理 TEXT)
+  - c4 交易日期 `DATE`、c5 交易时间 `DATE_TIME`(TIMESTAMP)
+  - c6 金额 `MONEY`(DECIMAL(18,2))、c7 数量 `NUMBER`(DECIMAL(38,6))、c8 备注 TEXT
+- 数据实测:
+  - 王五:身份证/手机/卡/纯时间 → NULL;日期 `2026-01-07`;金额 `(200.50)` → `-200.50`;数量 `1.5`。
+  - 赵六:坏日期/时间/金额/数量 → NULL;手机 `13800002222`、卡号正常入库。
+  - 钱七:`45000` → `2023-03-15`;`1704067200` → `2024-01-01 08:00:00`;`¥2,300.5` → `2,300.50`。
+  - 孙八:`20260108` → `2026-01-08`;`100元` → `100.00`。
+  - 周九:空行之后仍入库,`row_no` 保留原文件物理行号。
+- 截图:[ai-table-40-rows.png](screenshots/ai-table-40-rows.png)(含 01 的 7 行)
+
+### TC-02 必填列坏值整行拒绝 + 失败明细(05)
+
+- 前置:把 01 生成的 AI 模板字段 `c1`(身份证号)临时改为 `required=1`。
+- 操作:上传 `05`(20 行,第 1 行身份证 `12345`)→ 智能清洗(指纹命中同一 AI 模板,出口 B 复用)。
+- 预期:19 行入库、1 行拒绝;失败明细能看到行号/列/原因/原值。
+- 实测:`loadedRows=19, rejectedRows=1, unprocessedRows=0`;job `rowTotal=20, rowLoaded=19, rowRejected=1`。
+- 失败明细接口:
+
+  ```json
+  {"row_no":2,"blocking_col_index":1,"blocking_col_name":"c1",
+   "reason":"REQUIRED_TYPE_INVALID",
+   "errors_json":"[{\"colIndex\":1,\"column\":\"c1\",\"reason\":\"REQUIRED_TYPE_INVALID\",\"value\":\"12345\",\"blocking\":true}]"}
+  ```
+
+- 界面:导入文件列表 → 展开文件 → 工作表行「导入失败 1」→ 弹窗显示:
+  - 文件 / 工作表、**成功 19 / 失败 1**
+  - 行号 2、阻塞列 `c1(列 1)`、原因「必填列类型不合法」、原值预览 `12345`
+- 截图:[ai-reject-modal.png](screenshots/ai-reject-modal.png)
+- 后置:`required` 已重置为 0,避免影响后续样本。
+
+### TC-03 CSV 脏数据:CSV 不再 Skip,可选脏值只置空(04)
+
+- 第一次跑:CSV 探测成功、指纹命中 AI 模板(`headerScore=1.0`),但抽样校验因身份证列
+  2/7=28.6% 非法(非必填)判 **待人工**。
+- **本次发现并修复**:非必填列错误不应计入抽样硬门,否则和「非必填置空、行照常入库」冲突。
+  修复后重跑:`loadedRows=7, rejectedRows=0, warned=2`,复用同一 AI 模板。
+- 结论:CSV 探测/入库链路正常;可选脏值进入 `rowWarned`/`detail.load.optional`,不阻塞行。
+
+### TC-04 干净 CSV 正常入库(06)
+
+- 操作:上传 `06` → 智能清洗(指纹命中 AI 模板)。
+- 实测:`loadedRows=7, rejectedRows=0, selfBuiltCount=0`。
+- 结论:CSV 不再被 `SheetProbe` 用 POI 拒之门外。
+
+### TC-05 标题行 + 空行 + 表头定位(03)
+
+- 操作:上传 `03` → 智能清洗(无候选 → 自建表「人员交易」)。
+- 实测:`loadedRows=4, rejectedRows=0`;坏金额/日期两列置空,行保留。
+- 数据页:AI 表「人员交易」4 行。
+- 结论:标题行(1 格)、空行、行号缺口均未阻塞;表头定位正确。
+
+### TC-06 统计口径与界面列表
+
+最终导入文件列表([import-list-5-files.png](screenshots/import-list-5-files.png)):
+
+| 文件 | 导入成功 | 导入失败 |
+|---|---:|---:|
+| 06_csv_clean.csv | 7 | 0 |
+| 03_messy_title_blank.xlsx | 4 | 0 |
+| 04_csv_types.csv | 7 | 0 |
+| 05_required_bad.xlsx | 19 | 1 |
+| 01_self_built_types.xlsx | 7 | 0 |
+
+数据页([ai-table-40-rows.png](screenshots/ai-table-40-rows.png)):
+
+- 「经营者信息」40 行 = 01 的 7 + 05 的 19 + 06 的 7 + 04 的 7。
+- 「人员交易」4 行。
+- 日期/时间/金额/数量在界面上按类型展示,脏值显示为空而不是空白字符串。
+
+### TC-07 单测覆盖
+
+`mvn -pl ai-server -Dtest='com.zsjz.ai.module.aiclean.**.*Test,com.zsjz.ai.module.dm.**.*Test' test`:
+**192 个用例通过**(5 个 live 跳过)。覆盖:
+
+- 行级失败原因、必填整行拒绝、非必填置空计数
+- `AiValueCoercer` 日期/时间/金额/数字/电话/身份证/卡号格式矩阵
+- `HeaderScorer` 同义词/列序/单位/形状/必填覆盖与出口 A 闸门
+- `sameOrderedHeaders` 复用安全
+- 真 DuckDB 的类型化入库、reject 表分页/清理、先写后删
+
+## 5. 本次发现并修复的问题
+
+1. **非必填脏值被当成硬错误**:`AiRuleVerifier` 用 `cleanRow` 的全部错误计算列错误率,
+   可选身份证列 28.6% 非法导致整表转人工。修复:`cleanRow` 只回填阻塞错误,
+   `cleanRowDetailed` 仍保留全部错误供 warned/明细统计(提交 `c4b167b`)。
+2. **单 sheet 文件的失败入口**:文件行原来没有 `aiJobId/cleanMode`,点「导入失败」只能走旧
+   `clean_error_log`。修复:单 sheet 文件把 sheet 的 AI 任务信息带到文件行(提交 `c4b167b`)。
+3. **迁移缺失**:`ai_clean_batch.loaded_rows` 等新列未建时智能清洗 500;必须执行
+   `sql/ai_clean_row_stats.sql`。
+
+## 6. 未覆盖 / 已知限制
+
+- **LLM 匹配/打分/向量**:本次 UI 验收为了速度临时关闭了向量召回和本地 LLM
+  (本地模型单次调用可能数分钟,DashScope 嵌入额度已耗尽)。匹配逻辑由
+  `HeaderScorerTest` / `AiTemplateMatcherTest` 覆盖;恢复 LLM 后建议再跑一次真实模型样本。
+- **`02_reordered_headers.xlsx`**:需要 LLM 重新绑定,本次 UI 未跑;`sameOrderedHeaders`
+  单测已钉住「列序变化不复用旧 colIndex」。
+- **多块 sheet / 合并表头**:本轮未实现,仍只处理 primaryBlock;`02` 之外的乱序/合并样本
+  待 Phase B 后续。
+- **跨案件同表头**:`header_md5` 全局唯一键尚未改案件作用域,本轮未做双案件并发验收。
+- 界面「导入失败」在**文件行**(多 sheet 文件)仍走旧日志;单 sheet 已走 AI 明细。
+
+## 7. 复现步骤
+
+1. 执行 `sql/ai_clean_row_stats.sql`。
+2. 启动后端 `mvn -pl ai-server spring-boot:run`;启动前端 `pnpm dev`。
+3. 新建并打开案件,上传 `fixtures/` 下样本(普通浏览器可直接选择文件)。
+4. 点「清洗数据」→「智能清洗」,或调用 `POST /js/a/aiClean/smartClean`。
+5. 打开「案件数据 → 导入文件」,核对成功/失败数;展开文件点「导入失败」看明细。
+6. 打开「案件数据 → 导入数据」,核对 AI 动态表行数、列名与日期/金额/数字展示。
+7. 必填拒绝用例:先让 `01` 生成 AI 模板,再执行
+
+   ```sql
+   update ai_clean_field set required=1
+   where ai_template_id = :tpl and column_name = 'c1';
+   ```
+
+   上传 `05_required_bad.xlsx` 并智能清洗;验收后把 `required` 重置为 0。

BIN
docs/ai-clean-e2e/fixtures/01_self_built_types.xlsx


BIN
docs/ai-clean-e2e/fixtures/02_reordered_headers.xlsx


BIN
docs/ai-clean-e2e/fixtures/03_messy_title_blank.xlsx


+ 9 - 0
docs/ai-clean-e2e/fixtures/04_csv_types.csv

@@ -0,0 +1,9 @@
+姓名,身份证号,手机号,银行卡号,交易日期,交易时间,金额,数量,备注
+张三,11010519491231002X,13800000000,6222020200001234,2026-01-05,2026-01-05 10:30:00,"1,234.50",3,正常
+李四,110105491231002,+86 139-0000-1111,6222 0202 0000 1235,2026/1/6,2026-01-06T11:00:00,-100.00,2,负数金额
+王五,12345,123,6.22202E+15,2026年1月7日,10:30,(200.50),1.5,坏证件/电话/卡/纯时间
+赵六,110105194912310021,13800002222,6222020200001236,2026-02-30,25:00,约100元,abc,坏日期/时间/金额/数量
+钱七,11010519491231002X,13800003333,6222020200001237,45000,1704067200,"¥2,300.5",10,序列号/epoch
+孙八,11010519491231002X,13800004444,6222020200001238,20260108,2026-01-08 09:00:00,100元,8,紧凑日期/带元
+,,,,,,,,
+周九,11010519491231002X,13800005555,6222020200001239,2026-01-09,2026-01-09 12:00:00,300.00,5,空行后的行

BIN
docs/ai-clean-e2e/fixtures/05_required_bad.xlsx


+ 8 - 0
docs/ai-clean-e2e/fixtures/06_csv_clean.csv

@@ -0,0 +1,8 @@
+姓名,身份证号,手机号,银行卡号,交易日期,交易时间,金额,数量,备注
+CSV正常1,11010519491231002X,13800000000,6222020200001234,2026-01-05,2026-01-05 10:30:00,100.00,1,ok
+CSV正常2,11010519491231002X,13800000000,6222020200001234,2026-01-05,2026-01-05 10:30:00,100.00,1,ok
+CSV正常3,11010519491231002X,13800000000,6222020200001234,2026-01-05,2026-01-05 10:30:00,100.00,1,ok
+CSV正常4,11010519491231002X,13800000000,6222020200001234,2026-01-05,2026-01-05 10:30:00,100.00,1,ok
+CSV正常5,11010519491231002X,13800000000,6222020200001234,2026-01-05,2026-01-05 10:30:00,100.00,1,ok
+CSV正常6,11010519491231002X,13800000000,6222020200001234,2026-01-05,2026-01-05 10:30:00,100.00,1,ok
+CSV正常7,11010519491231002X,13800000000,6222020200001234,2026-01-05,2026-01-05 10:30:00,100.00,1,ok

+ 104 - 0
docs/ai-clean-e2e/generate_fixtures.py

@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""生成 AI 数据清洗 E2E 验收用的 xlsx/csv 样本。
+
+依赖:openpyxl
+用法:python generate_fixtures.py   # 输出到同目录 fixtures/
+"""
+from __future__ import annotations
+
+import csv
+import os
+
+from openpyxl import Workbook
+from openpyxl.styles import Font
+
+OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures")
+os.makedirs(OUT, exist_ok=True)
+
+HEADERS = ["姓名", "身份证号", "手机号", "银行卡号", "交易日期", "交易时间", "金额", "数量", "备注"]
+
+ROWS = [
+    ["张三", "11010519491231002X", "13800000000", "6222020200001234",
+     "2026-01-05", "2026-01-05 10:30:00", "1,234.50", "3", "正常"],
+    ["李四", "110105491231002", "+86 139-0000-1111", "6222 0202 0000 1235",
+     "2026/1/6", "2026-01-06T11:00:00", "-100.00", "2", "负数金额"],
+    ["王五", "12345", "123", "6.22202E+15",
+     "2026年1月7日", "10:30", "(200.50)", "1.5", "坏证件/电话/卡/纯时间"],
+    ["赵六", "110105194912310021", "13800002222", "6222020200001236",
+     "2026-02-30", "25:00", "约100元", "abc", "坏日期/时间/金额/数量"],
+    ["钱七", "11010519491231002X", "13800003333", "6222020200001237",
+     "45000", "1704067200", "¥2,300.5", "10", "序列号/epoch"],
+    ["孙八", "11010519491231002X", "13800004444", "6222020200001238",
+     "20260108", "2026-01-08 09:00:00", "100元", "8", "紧凑日期/带元"],
+    [],
+    ["周九", "11010519491231002X", "13800005555", "6222020200001239",
+     "2026-01-09", "2026-01-09 12:00:00", "300.00", "5", "空行后的行"],
+]
+
+
+def save_xlsx(name: str, sheet_name: str, rows, headers=None, title=None, start_row=1,
+              positions=None):
+    wb = Workbook()
+    ws = wb.active
+    ws.title = sheet_name
+    row = start_row
+    if title:
+        ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers or HEADERS))
+        ws.cell(row=1, column=1, value=title).font = Font(bold=True)
+        row = 3
+    for c, h in enumerate(headers or HEADERS, 1):
+        ws.cell(row=row, column=c, value=h).font = Font(bold=True)
+    for i, values in enumerate(rows):
+        target = positions[i] if positions else row + 1 + i
+        for c, v in enumerate(values, 1):
+            ws.cell(row=target, column=c, value=v)
+    wb.save(os.path.join(OUT, name))
+
+
+def save_csv(name: str, rows):
+    with open(os.path.join(OUT, name), "w", newline="", encoding="utf-8") as f:
+        w = csv.writer(f)
+        w.writerow(HEADERS)
+        for values in rows:
+            w.writerow(values if values else [""] * len(HEADERS))
+
+
+def main():
+    # 01:自建表类型推断(标题行 + 空行 + 表头 + 空行隔断)
+    save_xlsx("01_self_built_types.xlsx", "经营者信息", ROWS, title="经营者信息表")
+    # 02:与 01 同表头但倒序(验证复用必须同序)
+    save_xlsx("02_reordered_headers.xlsx", "乱序经营者",
+              [list(reversed(r)) for r in ROWS[:3]], headers=list(reversed(HEADERS)))
+    # 03:两列标题 + 空行 + 表头 + 空行隔断,验证表头定位与 row_no 缺口
+    save_xlsx("03_messy_title_blank.xlsx", "人员交易", [
+        ["张三", "1,000.00", "2026-01-01"],
+        ["李四", "2,000.00", "2026-01-02"],
+        ["王五", "3,000.00", "2026-01-03"],
+        ["赵六", "坏金额", "坏日期"],
+    ], headers=["姓名", "金额", "日期"], title="2026年人员交易明细",
+        positions=[4, 5, 7, 8])
+    # 04:CSV 版脏数据(验证 CSV 不再 Skip;可选列脏值只置空)
+    save_csv("04_csv_types.csv", ROWS)
+    # 05:同表头 20 行,1 行身份证非法(配合把 ai_clean_field.required 临时置 1,
+    #     验证必填列坏值整行拒绝 + 失败明细)
+    rows05 = []
+    for i in range(1, 21):
+        bad = i == 1
+        rows05.append([
+            "坏证件行" if bad else f"正常{i}",
+            "12345" if bad else "11010519491231002X",
+            "13800000000", "6222020200001234", "2026-01-05",
+            "2026-01-05 10:30:00", "100.00", "1", "ok",
+        ])
+    save_xlsx("05_required_bad.xlsx", "经营者信息", rows05)
+    # 06:干净 CSV(验证 CSV 正常入库)
+    save_csv("06_csv_clean.csv", [
+        [f"CSV正常{i}", "11010519491231002X", "13800000000", "6222020200001234",
+         "2026-01-05", "2026-01-05 10:30:00", "100.00", "1", "ok"]
+        for i in range(1, 8)
+    ])
+    print("fixtures written to", OUT)
+
+
+if __name__ == "__main__":
+    main()

BIN
docs/ai-clean-e2e/screenshots/ai-reject-modal.png


BIN
docs/ai-clean-e2e/screenshots/ai-table-40-rows.png


BIN
docs/ai-clean-e2e/screenshots/import-list-5-files.png