dsh-data-cleaning-agent 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.en.md +20 -10
- package/README.md +17 -8
- package/docs/COMPATIBILITY.md +33 -2
- package/docs/RELEASE-0.5.2.md +6 -2
- package/docs/RELEASE-0.5.3.md +66 -0
- package/docs/RELEASE-0.6.0.md +46 -0
- package/docs/UI-WORKFLOW-V2-ACCEPTANCE.md +84 -0
- package/docs/UI-WORKFLOW-V2-MIGRATION.md +62 -0
- package/docs/UI-WORKFLOW-V2.md +174 -0
- package/docs/USER-GUIDE.md +25 -10
- package/lib/artifacts.js +239 -0
- package/lib/client.js +1620 -192
- package/lib/index.js +1 -1
- package/lib/web.js +214 -0
- package/lib/workflow-contract.js +263 -0
- package/lib/workflow.js +452 -0
- package/package.json +7 -2
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# 数据清洗补全智能体 v2 · 工作流与 Host 契约
|
|
2
|
+
|
|
3
|
+
> 状态:T0~T9 已完成实现与隔离验收,归入 `0.6.0`。
|
|
4
|
+
> 开发基线:`main@0be4de3` / 已发布 `0.5.3`。
|
|
5
|
+
> 本文记录实现契约;外部发布状态以 `docs/RELEASE-0.6.0.md` 和 npm/GitHub 为准。
|
|
6
|
+
|
|
7
|
+
## 1. 产品主流程
|
|
8
|
+
|
|
9
|
+
业务主流程与企查查专业版“数据清洗补全”一致,固定为五步:
|
|
10
|
+
|
|
11
|
+
1. **上传数据**:文本、CSV、XLSX、JSON;图片入口需等待已验证的智能文档解析工具后接通。
|
|
12
|
+
2. **规则确认**:字段映射、清洗目标、匹配规则、补全字段选择。
|
|
13
|
+
3. **数据匹配**:以企业名称、统一社会信用代码或注册号作为主体锚点;精确、候选、已确认、未匹配、失败分流。
|
|
14
|
+
4. **清洗补全**:本地确定性清洗优先;需要 QCC 数据时,先估算调用,再由当前用户确认使用自己的 QCC 账号额度。
|
|
15
|
+
5. **下载数据**:生成 Host 制品引用,后续 UI 提供原始数据、清洗结果、补全结果与异常清单下载。
|
|
16
|
+
|
|
17
|
+
任务设置(提示词生成)、质量体检和任务历史是横向能力,不占用五步编号。
|
|
18
|
+
|
|
19
|
+
## 2. 当前范围
|
|
20
|
+
|
|
21
|
+
当前字段目录:
|
|
22
|
+
|
|
23
|
+
- 基础工商:企业名称、统一社会信用代码、注册号、组织机构代码、登记状态、法定代表人、注册/实缴资本、成立日期、企业类型、登记机关、曾用名、英文名。
|
|
24
|
+
- 地址与联系方式:注册地址、省市区、电话、邮箱、官网。
|
|
25
|
+
- 经营信息:经营范围、国标及一二级行业、营业期限、企业规模、企业简介。
|
|
26
|
+
- 风险摘要、知识产权摘要:仅在 Host 能力探针确认对应 QCC 工具可用时开放。
|
|
27
|
+
|
|
28
|
+
历史域、人员域、招投标域已明确延期,不进入当前实现或字段目录。
|
|
29
|
+
|
|
30
|
+
## 3. 工作流状态
|
|
31
|
+
|
|
32
|
+
正常路径:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
draft → uploaded → rules_confirmed → diagnosed(可选)
|
|
36
|
+
→ matching → review_required(可选) → matched
|
|
37
|
+
→ enriching → export_ready → completed
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
异常或暂停状态:`parse_failed`、`authorization_required`、`partial`、`failed`、`cancelled`。
|
|
41
|
+
|
|
42
|
+
每次写入增加 `revision`。Client 必须带上最后读取的 `expectedRevision`,过期写入返回
|
|
43
|
+
`409 DC_WORKFLOW_REVISION_CONFLICT`,防止两个会话互相覆盖。
|
|
44
|
+
|
|
45
|
+
## 4. 字段映射与匹配契约
|
|
46
|
+
|
|
47
|
+
- 至少映射一个主体锚点:`company_name`、`credit_no`、`reg_no`。
|
|
48
|
+
- 同一目标字段不能被多个输入列重复映射。
|
|
49
|
+
- 同一输入列不能重复映射到多个目标;未知目标字段按契约错误处理,不静默忽略。
|
|
50
|
+
- 企业名称可以结合省份、地址、电话辅助人工核验,但辅助字段不能取代主体锚点。
|
|
51
|
+
- 匹配结果只保存状态、数量汇总、运行引用和可审计依据;不得生成或展示无来源的置信度百分比。
|
|
52
|
+
- 多候选必须进入 `review_required`;人工确认完成后才能进入补全。
|
|
53
|
+
- `exact`、`candidate`、`confirmed`、`unresolved`、`failed` 是互斥数量,合计不得超过 `total`。
|
|
54
|
+
|
|
55
|
+
契约实现位于 `lib/workflow-contract.js`,UI 应通过只读接口获取目录,避免在 Client 重复维护字段清单。
|
|
56
|
+
|
|
57
|
+
## 5. Host 持久化与隐私边界
|
|
58
|
+
|
|
59
|
+
`lib/workflow.js` 使用 DSH `storageDomain`:
|
|
60
|
+
|
|
61
|
+
| 项 | 值 |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| domain | `dc_workflows_v2` |
|
|
64
|
+
| domain version | `1` |
|
|
65
|
+
| table | `tasks` |
|
|
66
|
+
| record schema | `2` |
|
|
67
|
+
|
|
68
|
+
允许持久化:任务标题、阶段/状态、输入文件元数据、表头、字段映射、选中字段、数字汇总、QCC run 引用、导出制品引用、时间和 revision。
|
|
69
|
+
|
|
70
|
+
禁止持久化:原始数据行、企业名称清单、匹配候选详情、QCC 原始响应、OAuth token、Key、真实付费调用证据。
|
|
71
|
+
|
|
72
|
+
原始数据行按 taskId 隔离在当前浏览器 runtime,Host KV 只保存来源元数据、映射、规则、质量/匹配/
|
|
73
|
+
补全摘要和制品引用。导出时,Client 把最终结果行一次性提交给同源 Host 制品端点;Host 通过 `ctx.fs`
|
|
74
|
+
写入工作区 `.dsh-data-cleaning-artifacts/v1/`。CSV 直接保存为 UTF-8,XLSX 保存为 Base64 文本并在下载
|
|
75
|
+
时恢复真实字节;读取时验证 SHA-256。原始行不会进入 `storageDomain`。
|
|
76
|
+
|
|
77
|
+
已完成任务可在浏览器 runtime 丢失原始行后,按 taskId 重新读取制品引用并跨 Host 重启下载;尚未生成
|
|
78
|
+
制品的中途任务仍需用户重新上传输入。制品只保存在用户当前工作区,不跨设备同步。
|
|
79
|
+
|
|
80
|
+
## 6. 同源 API
|
|
81
|
+
|
|
82
|
+
所有接口继续使用回环同源守卫;以下接口不会调用 QCC,不产生费用:
|
|
83
|
+
|
|
84
|
+
| 方法 | 路径 | 作用 |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| GET | `/data-cleaning/api/workflow/contract` | 获取五步、状态、字段目录和隐私契约 |
|
|
87
|
+
| GET | `/data-cleaning/api/workflow/tasks` | 任务列表 |
|
|
88
|
+
| POST | `/data-cleaning/api/workflow/tasks` | 新建草稿 |
|
|
89
|
+
| GET | `/data-cleaning/api/workflow/tasks/:id` | 按 taskId 恢复 |
|
|
90
|
+
| PATCH | `/data-cleaning/api/workflow/tasks/:id` | 更新未锁定草稿 |
|
|
91
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/upload` | 记录上传元数据 |
|
|
92
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/rules` | 确认规则 |
|
|
93
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/quality` | 记录质量汇总 |
|
|
94
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/match-start` | 进入匹配中 |
|
|
95
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/match` | 记录匹配汇总 |
|
|
96
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/enrich-start` | 进入补全中 |
|
|
97
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/enrichment` | 记录补全汇总 |
|
|
98
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/local-export-ready` | 本地确定性流程进入可导出状态 |
|
|
99
|
+
| GET | `/data-cleaning/api/workflow/tasks/:id/artifacts` | 列出已登记的 Host 耐久制品 |
|
|
100
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/artifacts` | 生成结果/异常清单的 CSV 与 XLSX,并完成任务 |
|
|
101
|
+
| GET | `/data-cleaning/api/workflow/tasks/:id/artifacts/:artifactId` | 校验 checksum 并下载制品 |
|
|
102
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/export` | 兼容记录已有制品引用并完成 |
|
|
103
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/parse-failed` | 记录解析失败(仅保存错误码) |
|
|
104
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/authorization-required` | 暂停并提示连接 QCC |
|
|
105
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/fail` | 记录失败(仅保存安全错误码) |
|
|
106
|
+
| POST | `/data-cleaning/api/workflow/tasks/:id/actions/cancel` | 取消任务 |
|
|
107
|
+
|
|
108
|
+
`/match` 与 `/enrichment` 记录的是已有执行结果摘要,不自行触发 QCC。后续编排器接入真实 QCC 时仍需沿用现有
|
|
109
|
+
`estimate → confirmPaidCalls:true → idempotencyKey → maxCalls` 安全门。
|
|
110
|
+
|
|
111
|
+
## 7. T0~T9 验收矩阵
|
|
112
|
+
|
|
113
|
+
| 门 | 验收项 | 结论 |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| T0 | 0.5.3 `main` 基线干净,建立 `feat/ui-workflow-v2` | 通过 |
|
|
116
|
+
| T0 | 基线 `npm run check` | 138/138 通过 |
|
|
117
|
+
| T1 | 五步、字段目录、映射锚点、状态转换、无虚构置信度 | 自动化覆盖 |
|
|
118
|
+
| T2 | taskId 隔离、revision 并发保护、重启恢复 | 自动化覆盖 |
|
|
119
|
+
| T2 | 原始行/企业名/候选/QCC 响应不进入 KV | 自动化覆盖 |
|
|
120
|
+
| T2 | 同源守卫、无 storageDomain 降级、零 QCC 调用 | 自动化覆盖 |
|
|
121
|
+
| T2 | DSH `0.1.1-rc.2` 真实 Host 写入与跨重启恢复 | 43180 隔离 Profile 通过 |
|
|
122
|
+
| T3 | 上传/粘贴解析、数据预览、自动字段映射、任务目标、规则与字段选择全部绑定 taskId | 自动化 + rc.2 浏览器通过 |
|
|
123
|
+
| T3 | 规则确认后自动质量体检并推进 `diagnosed / match` | revision 5 与质量摘要实测通过 |
|
|
124
|
+
| T4 | 四步提示词向导:数据来源、匹配规则、清洗与补全、确认描述 | rc.2 浏览器通过 |
|
|
125
|
+
| T4 | 文本/文件数据经事件桥进入同一任务,生成描述回填原生 Composer;并发创建收敛为单 taskId | 自动化覆盖 |
|
|
126
|
+
| T5 | 中央七阶段业务首页、输入框下五能力入口、右侧五步工作台与最近任务恢复 | rc.2 浏览器通过 |
|
|
127
|
+
| T5 | 当前基础企业 G5 匹配/补全、零调用估算、BYO-QCC 确认 | 自动化覆盖;未执行真实 QCC |
|
|
128
|
+
| T6 | Host 四类耐久制品、真实 XLSX、异常清单、checksum 与安全路径 | 自动化 + 双基线通过 |
|
|
129
|
+
| T6 | rc.2 / alpha.2 隔离安装、Host 重启恢复及真实 XLSX 反向解析 | 43190 / 43191 通过 |
|
|
130
|
+
| T6 | rc.2 深色、浅色、820×900 窄屏无横向溢出 | 真实浏览器通过 |
|
|
131
|
+
| T7 | 多候选人工核验、`partial` 显式重试、补全后回到 `export_ready` | 自动化覆盖 |
|
|
132
|
+
| T8 | 最近任务携带原 taskId 恢复、无原始 runtime 行时下载四类 Host 制品 | 自动化 + rc.2 浏览器通过 |
|
|
133
|
+
| T9 | 迁移/回滚、兼容矩阵、发布检查和版本决策 | 文档完成;建议 0.6.0,尚未发布 |
|
|
134
|
+
|
|
135
|
+
### 真实 Host 证据(2026-09-03)
|
|
136
|
+
|
|
137
|
+
- 将当前 41 文件 tarball 临时装入仓库内隔离 Profile,启动 DSH `0.1.1-rc.2` 于 `127.0.0.1:43180`。
|
|
138
|
+
- `GET /workflow/contract` 返回 schema 2 和完整五步,明确 `executesTools:false`、`paidCalls:false`。
|
|
139
|
+
- 新建测试任务后依次记录上传与规则确认,状态为 `rules_confirmed`、阶段为 `match`、revision 为 3。
|
|
140
|
+
- 停止并重启 Host 后,使用同一 taskId 读回上述状态、映射和 revision,确认 `storageDomain` 跨进程恢复有效。
|
|
141
|
+
- 测试 Host 已停止;隔离 Profile 的原插件目录已恢复。未触碰生产端口 `43120`,未安装/调用 QCC。
|
|
142
|
+
|
|
143
|
+
### T3~T5 真实 UI 证据(2026-09-03)
|
|
144
|
+
|
|
145
|
+
- 将最新本地 tarball 安装到隔离 DSH `0.1.1-rc.2` Profile,并启动于 `127.0.0.1:43182`。
|
|
146
|
+
- 中央首页显示七阶段流程,五能力按钮位于原生 Composer 下方;右侧工作台显示 Host taskId 和五步状态。
|
|
147
|
+
- 粘贴 2 行 CSV 后自动映射“企业名称”和“统一社会信用代码”,确认规则后自动生成质量报告。
|
|
148
|
+
- Host 读回任务 `state=diagnosed`、`stage=match`、`revision=5`,`qualitySummary` 与页面统计一致。
|
|
149
|
+
- 四步提示词向导在真实页面完整渲染;同一会话并发事件创建经 coalescing/队列保护后只生成一个任务。
|
|
150
|
+
- 实测过程中未检测或调用 QCC,未使用真实企业数据;43182 隔离 Host 已停止。
|
|
151
|
+
|
|
152
|
+
### T6~T9 真实 Host / UI 证据(2026-09-04)
|
|
153
|
+
|
|
154
|
+
- 将最新本地 tarball 分别安装到隔离 DSH `0.1.1-rc.2`(43190)和
|
|
155
|
+
`0.1.2-alpha.2`(43191)Profile。
|
|
156
|
+
- 两条基线均创建同一结构的完成任务和四类制品;XLSX 文件头为 `PK`,反向解析工作表为
|
|
157
|
+
“清洗补全结果”。停止并重启后按原 taskId 和 artifactId 下载仍通过。
|
|
158
|
+
- rc.2 真实页面完成浅色、深色与 820×900 窄屏回归;窄屏 `scrollWidth` 与视口宽度相同。
|
|
159
|
+
- 最近任务恢复实测显示原完成 taskId、输入行数和四个下载按钮,没有创建新草稿。
|
|
160
|
+
- 全程使用合成数据,未触碰生产端口 43120,未连接或调用 QCC。
|
|
161
|
+
|
|
162
|
+
## 8. 当前实现与发布顺序
|
|
163
|
+
|
|
164
|
+
1. T3(完成):上传解析、字段映射、任务设置和规则确认接入 v2 taskId API。
|
|
165
|
+
2. T4(完成):提示词生成器四步向导接入 taskId 工作流,支持文本/本地文件/图片 Bridge。
|
|
166
|
+
3. T5(完成):中央业务首页、五能力入口、右侧工作台、基础企业匹配/补全和任务历史统一到 taskId。
|
|
167
|
+
4. T6(完成):Host 耐久下载制品、XLSX 与异常清单、双基线、视觉回归、迁移与回滚。
|
|
168
|
+
5. T7(完成):候选人工核验、部分失败重试、匹配与补全状态闭环。
|
|
169
|
+
6. T8(完成):四类制品导出、最近任务恢复及跨 Host 重启下载。
|
|
170
|
+
7. T9(发布准备完成):建议下一版本使用 `0.6.0`;待最终代码审查与维护者另行批准 commit、push、
|
|
171
|
+
Tag、npm 和 GitHub Release。
|
|
172
|
+
|
|
173
|
+
详细验收见 `docs/UI-WORKFLOW-V2-ACCEPTANCE.md`,升级/回滚见
|
|
174
|
+
`docs/UI-WORKFLOW-V2-MIGRATION.md`。
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -24,14 +24,27 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
24
24
|
|
|
25
25
|
插件会加载内嵌 Skill `data-cleaning`,自动按 `data_profile → data_clean_rows → data_complete_rows` 工作流调度。
|
|
26
26
|
|
|
27
|
-
### 2.2
|
|
27
|
+
### 2.2 应用内入口(侧边栏「数据清洗补全」)
|
|
28
28
|
|
|
29
|
-
重启后,DeepSeek Harness
|
|
29
|
+
重启后,DeepSeek Harness 侧栏顶部的「新会话」与「工作区」之间会出现「数据清洗补全」。点击后先进入
|
|
30
|
+
中央业务首页,右侧工作台保持关闭;输入框下方的五个入口分别定位到上传清洗、质量体检、匹配核验、
|
|
31
|
+
字段补全和任务历史。处理步骤为:
|
|
30
32
|
|
|
31
|
-
1.
|
|
32
|
-
2.
|
|
33
|
-
3.
|
|
34
|
-
4.
|
|
33
|
+
1. **上传数据**:粘贴或上传 CSV / XLSX / JSON,预览列和行;
|
|
34
|
+
2. **规则确认**:映射企业名称/统一社会信用代码/注册号,选择清洗目标与补全字段;
|
|
35
|
+
3. **数据匹配**:运行质量体检与主体匹配,多候选由人工确认;
|
|
36
|
+
4. **清洗补全**:执行本地确定性清洗;需要 QCC 时先零调用估算,再由当前用户确认使用自己的账号额度;
|
|
37
|
+
5. **下载数据**:生成结果 CSV/XLSX 与异常清单 CSV/XLSX,后续可从任务历史恢复下载。
|
|
38
|
+
|
|
39
|
+
输入框左上角的「提示词生成」提供三种名单录入方式:
|
|
40
|
+
|
|
41
|
+
- **粘贴名单**:每行一个企业名称或统一社会信用代码;
|
|
42
|
+
- **上传 Excel**:支持 XLSX/XLS/CSV/JSON,识别企业名称/信用代码列并把完整数据载入右侧工作台;
|
|
43
|
+
- **上传图片**:把 PNG/JPEG/WebP 附加到当前原生对话,并生成“使用当前已连接且可用的企查查智能文档解析 MCP”要求。
|
|
44
|
+
|
|
45
|
+
随后可选择名称规范、信用代码校验、去重、模糊候选复核等清洗动作,以及工商字段或已支持的维度组。
|
|
46
|
+
点击「生成并回填」后,任务描述进入原生对话框;发送前仍可人工修改。图片能力不可用时,请改用输入框
|
|
47
|
+
原生“+”或文本/Excel。提示词生成器不会硬编码未验证的 QCC 工具名,也不会绕过付费确认门。
|
|
35
48
|
|
|
36
49
|
模型在对话中调用 `data_clean_rows` / `data_complete_rows` / `data_profile` 时,
|
|
37
50
|
对话内会渲染对应的工具结果卡片(含运行中 / 已完成 / 失败状态);工作台头部用任务 pill
|
|
@@ -40,7 +53,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
40
53
|
### 2.3 web 界面
|
|
41
54
|
|
|
42
55
|
打开 DeepSeek Harness 后访问插件的同源界面(`/data-cleaning/`),可上传 CSV/XLSX/JSON,
|
|
43
|
-
|
|
56
|
+
执行解析、清洗、补全与导出。旧 MVP 路由前缀为 `/data-cleaning/api/mvp/*`;0.6.0 五步任务和
|
|
57
|
+
耐久制品使用 `/data-cleaning/api/workflow/*`。
|
|
44
58
|
|
|
45
59
|
## 3. 能力说明
|
|
46
60
|
|
|
@@ -122,7 +136,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
122
136
|
4. 点击“估算调用量”。估算为上界且不执行 QCC 工具;
|
|
123
137
|
5. 只有在核对企业数、工具数、估算调用量和 `maxCalls` 后,才勾选“确认使用当前用户的企查查账号额度”;
|
|
124
138
|
6. 多候选逐项人工选择;失败项只在明确点击重试时重放;
|
|
125
|
-
7.
|
|
139
|
+
7. 生成并下载结果/异常清单的 CSV 或 XLSX;需要重新核验的行会进入异常清单。
|
|
126
140
|
|
|
127
141
|
同源 API 为 `/data-cleaning/api/phase3/*`。单批最多 100 行,并发上限 4,硬调用上限 2000;
|
|
128
142
|
默认调用上限 500。`enrich` / `resolve` / `retry` 均要求 `confirmPaidCalls:true` 和唯一幂等键。
|
|
@@ -152,5 +166,6 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
152
166
|
[QCC-ENRICHMENT-DESIGN.md](QCC-ENRICHMENT-DESIGN.md)。
|
|
153
167
|
- **Q:可以在后台批量补全吗?** A:可以。0.4.0 G5 工商批量已发布并完成真实 OAuth/token 验收;
|
|
154
168
|
0.5.0 风险/知产/经营三域已发布,先用 estimate 核对调用上限,再显式确认使用当前用户自己的 QCC 账号额度。
|
|
155
|
-
- **Q:刷新页面或重启后还能恢复吗?** A
|
|
156
|
-
|
|
169
|
+
- **Q:刷新页面或重启后还能恢复吗?** A:五步任务元数据与已生成制品可按 taskId 跨 Host 重启恢复;
|
|
170
|
+
尚未导出的浏览器原始行不会持久化,需要重新上传。QCC 的临时 `runId` 仍只在同一 Host 进程保留
|
|
171
|
+
30 分钟,但完成任务的 CSV/XLSX 不依赖该内存 run。
|
package/lib/artifacts.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 耐久导出制品。
|
|
3
|
+
*
|
|
4
|
+
* DSH rc.2 / alpha.2 已验证的 fs seam 只提供原子 writeText 与有界
|
|
5
|
+
* readBytes,没有稳定的二进制写接口。因此 CSV 以 UTF-8 文本保存,XLSX
|
|
6
|
+
* 先生成真实工作簿,再以 Base64 文本保存,下载时还原为原始字节。
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
9
|
+
import XLSX from 'xlsx';
|
|
10
|
+
|
|
11
|
+
const ROOT = '.dsh-data-cleaning-artifacts/v1';
|
|
12
|
+
const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
13
|
+
// Base64 最坏会把二进制扩大到 4/3;readBytes 必须允许读取完整编码文本,
|
|
14
|
+
// 再对解码后的真实制品执行 MAX_ARTIFACT_BYTES 限制。
|
|
15
|
+
const MAX_STORED_BYTES = Math.ceil(MAX_ARTIFACT_BYTES / 3) * 4 + 4;
|
|
16
|
+
const SAFE_ID = /^(?:dcw|dca)-[a-zA-Z0-9-]{8,80}$/;
|
|
17
|
+
|
|
18
|
+
export class ArtifactError extends Error {
|
|
19
|
+
constructor(code, message, status = 400) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'ArtifactError';
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeFilePart(value, fallback) {
|
|
28
|
+
const text = String(value ?? '').trim()
|
|
29
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
|
|
30
|
+
.replace(/\s+/g, '-')
|
|
31
|
+
.replace(/-+/g, '-')
|
|
32
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
33
|
+
.slice(0, 80);
|
|
34
|
+
return text || fallback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertId(value, label) {
|
|
38
|
+
const id = String(value ?? '');
|
|
39
|
+
if (!SAFE_ID.test(id)) throw new ArtifactError('DC_ARTIFACT_ID_INVALID', `${label} is invalid.`, 400);
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sha256(buffer) {
|
|
44
|
+
return createHash('sha256').update(buffer).digest('hex');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeHeaders(headers, rows) {
|
|
48
|
+
const fromInput = Array.isArray(headers) ? headers.map((item) => String(item ?? '').trim()).filter(Boolean) : [];
|
|
49
|
+
const discovered = [];
|
|
50
|
+
const seen = new Set(fromInput);
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
for (const key of Object.keys(row ?? {})) {
|
|
53
|
+
if (!seen.has(key)) {
|
|
54
|
+
seen.add(key);
|
|
55
|
+
discovered.push(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...fromInput, ...discovered].slice(0, 256);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeRows(value) {
|
|
63
|
+
if (!Array.isArray(value)) throw new ArtifactError('DC_ARTIFACT_ROWS_REQUIRED', 'Export rows must be an array.', 400);
|
|
64
|
+
if (value.length > 100_000) throw new ArtifactError('DC_ARTIFACT_ROWS_TOO_MANY', 'Export is limited to 100,000 rows.', 413);
|
|
65
|
+
const normalizeCell = (cell) => {
|
|
66
|
+
if (cell === null || cell === undefined) return '';
|
|
67
|
+
if (cell instanceof Date) return cell.toISOString();
|
|
68
|
+
if (typeof cell === 'number' || typeof cell === 'boolean') return cell;
|
|
69
|
+
if (typeof cell === 'string') return cell.slice(0, 32_767);
|
|
70
|
+
try {
|
|
71
|
+
const serialized = JSON.stringify(cell);
|
|
72
|
+
return String(serialized ?? cell).slice(0, 32_767);
|
|
73
|
+
} catch {
|
|
74
|
+
return String(cell).slice(0, 32_767);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return value.map((row) => {
|
|
78
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return { value: normalizeCell(row) };
|
|
79
|
+
return Object.fromEntries(Object.entries(row).slice(0, 256).map(([key, cell]) => [String(key), normalizeCell(cell)]));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function exceptionReason(row) {
|
|
84
|
+
const status = String(
|
|
85
|
+
row?.qcc_match_status
|
|
86
|
+
?? row?.match_status
|
|
87
|
+
?? row?.['匹配状态']
|
|
88
|
+
?? '',
|
|
89
|
+
).trim().toLowerCase();
|
|
90
|
+
const error = row?.qcc_error ?? row?.error ?? row?.['错误原因'];
|
|
91
|
+
if (error) return String(error).slice(0, 500);
|
|
92
|
+
if (['candidate', 'ambiguous', 'review_required'].includes(status)) return '存在多个候选主体,需人工核验';
|
|
93
|
+
if (['unresolved', 'not_found'].includes(status)) return '未匹配到可验证主体';
|
|
94
|
+
if (['failed', 'error', 'partial'].includes(status)) return '匹配或补全未完成';
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function deriveExceptionRows(rows) {
|
|
99
|
+
return rows.flatMap((row) => {
|
|
100
|
+
const reason = exceptionReason(row);
|
|
101
|
+
return reason ? [{ ...row, _exception_reason: reason }] : [];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function workbookBytes(rows, headers, sheetName) {
|
|
106
|
+
const workbook = XLSX.utils.book_new();
|
|
107
|
+
const worksheet = XLSX.utils.json_to_sheet(rows, { header: headers, skipHeader: false });
|
|
108
|
+
worksheet['!cols'] = headers.map((header) => ({ wch: Math.min(42, Math.max(12, String(header).length * 2 + 4)) }));
|
|
109
|
+
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31));
|
|
110
|
+
workbook.Props = {
|
|
111
|
+
Title: sheetName,
|
|
112
|
+
Subject: 'DeepSeek Harness 数据清洗补全智能体导出',
|
|
113
|
+
Author: 'dsh-data-cleaning-agent',
|
|
114
|
+
Company: 'QCC',
|
|
115
|
+
};
|
|
116
|
+
return Buffer.from(XLSX.write(workbook, {
|
|
117
|
+
type: 'buffer',
|
|
118
|
+
bookType: 'xlsx',
|
|
119
|
+
compression: true,
|
|
120
|
+
cellDates: true,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function csvBytes(rows, headers) {
|
|
125
|
+
const escapeCell = (value) => {
|
|
126
|
+
let text = String(value ?? '');
|
|
127
|
+
// 防止 Excel / LibreOffice 将外部数据解释为公式。数字类型不经过此前缀;
|
|
128
|
+
// 以危险字符开头的文本保留原值但加前导单引号。
|
|
129
|
+
if (/^[\u0009\u000d\u000a ]*[=+\-@]/.test(text)) text = `'${text}`;
|
|
130
|
+
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
131
|
+
};
|
|
132
|
+
const lines = [
|
|
133
|
+
headers.map(escapeCell).join(','),
|
|
134
|
+
...rows.map((row) => headers.map((header) => escapeCell(row?.[header])).join(',')),
|
|
135
|
+
];
|
|
136
|
+
return Buffer.from(`\uFEFF${lines.join('\r\n')}`, 'utf8');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function artifactDescriptor({ id, kind, format, fileName, rowCount, bytes, createdAt }) {
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
kind,
|
|
143
|
+
format,
|
|
144
|
+
fileName,
|
|
145
|
+
rowCount,
|
|
146
|
+
sizeBytes: bytes.length,
|
|
147
|
+
checksum: `sha256:${sha256(bytes)}`,
|
|
148
|
+
mediaType: format === 'xlsx'
|
|
149
|
+
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
150
|
+
: 'text/csv; charset=utf-8',
|
|
151
|
+
createdAt,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class WorkflowArtifactStore {
|
|
156
|
+
constructor({ fs, nowFn = () => new Date().toISOString(), idFactory = () => `dca-${randomUUID()}` }) {
|
|
157
|
+
if (!fs) throw new ArtifactError('DC_ARTIFACT_UNAVAILABLE', 'DSH fs service unavailable.', 503);
|
|
158
|
+
this.fs = fs;
|
|
159
|
+
this.nowFn = nowFn;
|
|
160
|
+
this.idFactory = idFactory;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pathFor(taskId, artifact) {
|
|
164
|
+
const safeTaskId = assertId(taskId, 'taskId');
|
|
165
|
+
const artifactId = assertId(artifact.id, 'artifactId');
|
|
166
|
+
const suffix = artifact.format === 'xlsx' ? 'xlsx.b64' : 'csv';
|
|
167
|
+
return `${ROOT}/${safeTaskId}/${artifactId}.${suffix}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async write(taskId, descriptor, bytes) {
|
|
171
|
+
if (bytes.length > MAX_ARTIFACT_BYTES) {
|
|
172
|
+
throw new ArtifactError('DC_ARTIFACT_TOO_LARGE', 'Generated artifact exceeds the 32 MiB limit.', 413);
|
|
173
|
+
}
|
|
174
|
+
const target = await this.fs.resolve(this.pathFor(taskId, descriptor));
|
|
175
|
+
const content = descriptor.format === 'xlsx' ? bytes.toString('base64') : bytes.toString('utf8');
|
|
176
|
+
await this.fs.writeText(target, content);
|
|
177
|
+
return descriptor;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async createBundle(taskId, input = {}) {
|
|
181
|
+
assertId(taskId, 'taskId');
|
|
182
|
+
const rows = normalizeRows(input.rows);
|
|
183
|
+
const headers = normalizeHeaders(input.headers, rows);
|
|
184
|
+
if (!headers.length) throw new ArtifactError('DC_ARTIFACT_HEADERS_REQUIRED', 'At least one export column is required.', 400);
|
|
185
|
+
const exceptions = input.exceptionRows === undefined
|
|
186
|
+
? deriveExceptionRows(rows)
|
|
187
|
+
: normalizeRows(input.exceptionRows);
|
|
188
|
+
const exceptionHeaders = normalizeHeaders([...headers, '_exception_reason'], exceptions);
|
|
189
|
+
const baseName = safeFilePart(input.baseName, '数据清洗补全结果');
|
|
190
|
+
const timestamp = this.nowFn();
|
|
191
|
+
const definitions = [
|
|
192
|
+
{ kind: 'complete', format: 'csv', fileName: `${baseName}.csv`, rows, headers, sheet: '清洗补全结果' },
|
|
193
|
+
{ kind: 'complete', format: 'xlsx', fileName: `${baseName}.xlsx`, rows, headers, sheet: '清洗补全结果' },
|
|
194
|
+
{ kind: 'review', format: 'csv', fileName: `${baseName}-异常清单.csv`, rows: exceptions, headers: exceptionHeaders, sheet: '异常清单' },
|
|
195
|
+
{ kind: 'review', format: 'xlsx', fileName: `${baseName}-异常清单.xlsx`, rows: exceptions, headers: exceptionHeaders, sheet: '异常清单' },
|
|
196
|
+
];
|
|
197
|
+
const artifacts = [];
|
|
198
|
+
for (const definition of definitions) {
|
|
199
|
+
const bytes = definition.format === 'xlsx'
|
|
200
|
+
? workbookBytes(definition.rows, definition.headers, definition.sheet)
|
|
201
|
+
: csvBytes(definition.rows, definition.headers);
|
|
202
|
+
const descriptor = artifactDescriptor({
|
|
203
|
+
id: this.idFactory(),
|
|
204
|
+
kind: definition.kind,
|
|
205
|
+
format: definition.format,
|
|
206
|
+
fileName: definition.fileName,
|
|
207
|
+
rowCount: definition.rows.length,
|
|
208
|
+
bytes,
|
|
209
|
+
createdAt: timestamp,
|
|
210
|
+
});
|
|
211
|
+
await this.write(taskId, descriptor, bytes);
|
|
212
|
+
artifacts.push(descriptor);
|
|
213
|
+
}
|
|
214
|
+
return artifacts;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async read(taskId, artifact) {
|
|
218
|
+
const target = await this.fs.resolve(this.pathFor(taskId, artifact));
|
|
219
|
+
const storedLimit = artifact.format === 'xlsx' ? MAX_STORED_BYTES : MAX_ARTIFACT_BYTES;
|
|
220
|
+
const stored = Buffer.from(await this.fs.readBytes(target, undefined, storedLimit));
|
|
221
|
+
const bytes = artifact.format === 'xlsx'
|
|
222
|
+
? Buffer.from(stored.toString('utf8'), 'base64')
|
|
223
|
+
: stored;
|
|
224
|
+
if (bytes.length > MAX_ARTIFACT_BYTES) {
|
|
225
|
+
throw new ArtifactError('DC_ARTIFACT_TOO_LARGE', 'Stored artifact exceeds the 32 MiB limit.', 413);
|
|
226
|
+
}
|
|
227
|
+
const actual = `sha256:${sha256(bytes)}`;
|
|
228
|
+
if (artifact.checksum && actual !== artifact.checksum) {
|
|
229
|
+
throw new ArtifactError('DC_ARTIFACT_CHECKSUM', 'Stored artifact checksum verification failed.', 409);
|
|
230
|
+
}
|
|
231
|
+
return bytes;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export const ARTIFACT_STORAGE = Object.freeze({
|
|
236
|
+
root: ROOT,
|
|
237
|
+
maxBytes: MAX_ARTIFACT_BYTES,
|
|
238
|
+
maxStoredBytes: MAX_STORED_BYTES,
|
|
239
|
+
});
|