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.
@@ -30,10 +30,11 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
30
30
  中央业务首页,右侧工作台保持关闭;输入框下方的五个入口分别定位到上传清洗、质量体检、匹配核验、
31
31
  字段补全和任务历史。处理步骤为:
32
32
 
33
- 1. **上传与映射**:粘贴或上传 CSV / XLSX / JSON,确认企业名称字段;
34
- 2. **数据体检**:查看列概览、缺失和金额分布;
35
- 3. **匹配核验**:先做本地清洗,再选择风险/知产/经营域;多候选由人工确认;
36
- 4. **补全与导出**:恢复任务、重试可重试失败,下载结果 CSV 和复核 CSV。
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
- 执行解析、清洗、补全、导出 CSV。web 界面的后端路由前缀为 `/data-cleaning/api/mvp/*`。
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. 下载补全结果 CSV,必要时下载候选复核 CSV。
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:同一 Host 进程内可用 `runId` 恢复,默认保留 30 分钟;
168
- Host 重启后内存 run 失效,但已下载 CSV 和本地源文件不受影响。
169
+ - **Q:刷新页面或重启后还能恢复吗?** A:五步任务元数据与已生成制品可按 taskId Host 重启恢复;
170
+ 尚未导出的浏览器原始行不会持久化,需要重新上传。QCC 的临时 `runId` 仍只在同一 Host 进程保留
171
+ 30 分钟,但完成任务的 CSV/XLSX 不依赖该内存 run。
@@ -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
+ });