dsh-data-cleaning-agent 0.5.3 → 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 +46 -0
- package/README.en.md +8 -3
- package/README.md +8 -3
- package/docs/COMPATIBILITY.md +24 -4
- package/docs/RELEASE-0.5.3.md +10 -3
- 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 +11 -8
- package/lib/artifacts.js +239 -0
- package/lib/client.js +1037 -259
- 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 +6 -2
package/docs/USER-GUIDE.md
CHANGED
|
@@ -30,10 +30,11 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
30
30
|
中央业务首页,右侧工作台保持关闭;输入框下方的五个入口分别定位到上传清洗、质量体检、匹配核验、
|
|
31
31
|
字段补全和任务历史。处理步骤为:
|
|
32
32
|
|
|
33
|
-
1.
|
|
34
|
-
2.
|
|
35
|
-
3.
|
|
36
|
-
4.
|
|
33
|
+
1. **上传数据**:粘贴或上传 CSV / XLSX / JSON,预览列和行;
|
|
34
|
+
2. **规则确认**:映射企业名称/统一社会信用代码/注册号,选择清洗目标与补全字段;
|
|
35
|
+
3. **数据匹配**:运行质量体检与主体匹配,多候选由人工确认;
|
|
36
|
+
4. **清洗补全**:执行本地确定性清洗;需要 QCC 时先零调用估算,再由当前用户确认使用自己的账号额度;
|
|
37
|
+
5. **下载数据**:生成结果 CSV/XLSX 与异常清单 CSV/XLSX,后续可从任务历史恢复下载。
|
|
37
38
|
|
|
38
39
|
输入框左上角的「提示词生成」提供三种名单录入方式:
|
|
39
40
|
|
|
@@ -52,7 +53,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
52
53
|
### 2.3 web 界面
|
|
53
54
|
|
|
54
55
|
打开 DeepSeek Harness 后访问插件的同源界面(`/data-cleaning/`),可上传 CSV/XLSX/JSON,
|
|
55
|
-
|
|
56
|
+
执行解析、清洗、补全与导出。旧 MVP 路由前缀为 `/data-cleaning/api/mvp/*`;0.6.0 五步任务和
|
|
57
|
+
耐久制品使用 `/data-cleaning/api/workflow/*`。
|
|
56
58
|
|
|
57
59
|
## 3. 能力说明
|
|
58
60
|
|
|
@@ -134,7 +136,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
134
136
|
4. 点击“估算调用量”。估算为上界且不执行 QCC 工具;
|
|
135
137
|
5. 只有在核对企业数、工具数、估算调用量和 `maxCalls` 后,才勾选“确认使用当前用户的企查查账号额度”;
|
|
136
138
|
6. 多候选逐项人工选择;失败项只在明确点击重试时重放;
|
|
137
|
-
7.
|
|
139
|
+
7. 生成并下载结果/异常清单的 CSV 或 XLSX;需要重新核验的行会进入异常清单。
|
|
138
140
|
|
|
139
141
|
同源 API 为 `/data-cleaning/api/phase3/*`。单批最多 100 行,并发上限 4,硬调用上限 2000;
|
|
140
142
|
默认调用上限 500。`enrich` / `resolve` / `retry` 均要求 `confirmPaidCalls:true` 和唯一幂等键。
|
|
@@ -164,5 +166,6 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
164
166
|
[QCC-ENRICHMENT-DESIGN.md](QCC-ENRICHMENT-DESIGN.md)。
|
|
165
167
|
- **Q:可以在后台批量补全吗?** A:可以。0.4.0 G5 工商批量已发布并完成真实 OAuth/token 验收;
|
|
166
168
|
0.5.0 风险/知产/经营三域已发布,先用 estimate 核对调用上限,再显式确认使用当前用户自己的 QCC 账号额度。
|
|
167
|
-
- **Q:刷新页面或重启后还能恢复吗?** A
|
|
168
|
-
|
|
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
|
+
});
|