dsh-data-cleaning-agent 0.7.0 → 0.8.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.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * 图片企业名单接入。
3
+ *
4
+ * Browser 只负责把用户明确选择/粘贴的图片暂存到 Host;真实视觉识别由
5
+ * Agent-owned 高层工具在当前会话执行上下文中调用已探测到的 Provider。
6
+ * 当前已验证 Provider 是 modlens_read_image。图片使用 0600 临时文件,识别
7
+ * 完成、失败、取消或 TTL 到期后立即删除;不会进入 storageDomain 或导出制品。
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { mkdir, unlink, writeFile } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ export const TOOL_IMAGE_EXTRACT = 'data_cleaning_extract_image_companies';
15
+ export const IMAGE_PROVIDER_MODLENS = 'modlens_read_image';
16
+
17
+ export const IMAGE_LIMITS = Object.freeze({
18
+ maxBytes: 8 * 1024 * 1024,
19
+ maxEntries: 100,
20
+ ttlMs: 15 * 60 * 1000,
21
+ });
22
+
23
+ const IMAGE_ROOT = join(tmpdir(), 'dsh-data-cleaning-agent-images');
24
+
25
+ export class ImageIntakeError extends Error {
26
+ constructor(code, message, status = 400, details = {}) {
27
+ super(message);
28
+ this.name = 'ImageIntakeError';
29
+ this.code = code;
30
+ this.status = status;
31
+ Object.assign(this, details);
32
+ }
33
+ }
34
+
35
+ function safeName(value) {
36
+ return String(value ?? '企业名单图片')
37
+ .replace(/[\u0000-\u001f\u007f/\\]/g, '_')
38
+ .trim()
39
+ .slice(0, 160) || '企业名单图片';
40
+ }
41
+
42
+ export function sniffImage(bytes) {
43
+ if (!Buffer.isBuffer(bytes) || bytes.length < 12) return null;
44
+ if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
45
+ return { mimeType: 'image/png', extension: 'png' };
46
+ }
47
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
48
+ return { mimeType: 'image/jpeg', extension: 'jpg' };
49
+ }
50
+ if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') {
51
+ return { mimeType: 'image/webp', extension: 'webp' };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function decodeImage(content) {
57
+ const raw = String(content ?? '').replace(/^data:image\/[a-z0-9.+-]+;base64,/i, '');
58
+ if (!raw || !/^[A-Za-z0-9+/]*={0,2}$/.test(raw) || raw.length % 4 === 1) {
59
+ throw new ImageIntakeError('DC_IMAGE_BASE64', '图片内容不是有效的 Base64 数据。');
60
+ }
61
+ const bytes = Buffer.from(raw, 'base64');
62
+ if (!bytes.length) throw new ImageIntakeError('DC_IMAGE_EMPTY', '图片内容为空。');
63
+ if (bytes.length > IMAGE_LIMITS.maxBytes) {
64
+ throw new ImageIntakeError('DC_IMAGE_TOO_LARGE', '图片不能超过 8 MiB。', 413);
65
+ }
66
+ const detected = sniffImage(bytes);
67
+ if (!detected) {
68
+ throw new ImageIntakeError('DC_IMAGE_TYPE', '仅支持真实 PNG、JPEG 或 WebP 图片。', 415);
69
+ }
70
+ return { bytes, ...detected };
71
+ }
72
+
73
+ function unwrapProviderValue(result) {
74
+ if (result?.isError === true) {
75
+ const message = result?.error?.message || result?.message || '图片识别 Provider 调用失败。';
76
+ throw new ImageIntakeError('DC_IMAGE_PROVIDER_FAILED', String(message), 502);
77
+ }
78
+ let value = result?.value ?? result;
79
+ if (value && Array.isArray(value.content)) {
80
+ const text = value.content.filter((item) => item?.type === 'text').map((item) => item.text).join('\n');
81
+ if (text) {
82
+ try { value = JSON.parse(text); } catch { value = { ocr: { full_text: text } }; }
83
+ }
84
+ }
85
+ return value;
86
+ }
87
+
88
+ export function providerText(result) {
89
+ const value = unwrapProviderValue(result);
90
+ const lines = Array.isArray(value?.ocr?.lines)
91
+ ? value.ocr.lines.map((line) => String(line?.text ?? '').trim()).filter(Boolean)
92
+ : [];
93
+ const text = String(value?.ocr?.full_text ?? lines.join('\n') ?? '').trim();
94
+ if (!text) {
95
+ throw new ImageIntakeError('DC_IMAGE_NO_TEXT', '图片中未识别到可用文字,请换用更清晰的原图。', 422);
96
+ }
97
+ return text;
98
+ }
99
+
100
+ const CREDIT_RE = /\b[0-9A-HJ-NPQRTUWXY]{18}\b/gi;
101
+ const COMPANY_END = '(?:有限责任公司|股份有限公司|集团有限公司|有限公司|集团公司|公司|普通合伙|有限合伙|合伙企业|个人独资企业|农民专业合作社|合作社|事务所|研究院|研究所|中心|商行|工厂|厂)';
102
+ const COMPANY_RE = new RegExp(`[\\p{Script=Han}A-Za-z0-9()()·&++—\\-]{2,72}${COMPANY_END}`, 'gu');
103
+ const HEADER_RE = /^(?:序号|企业名称|公司名称|单位名称|统一社会信用代码|信用代码|注册号|名称|企业名单)$/i;
104
+
105
+ function cleanCell(value) {
106
+ return String(value ?? '')
107
+ .replace(/^\s*(?:[-•·●▪◦]|\d{1,4}[.)、::]?)\s*/, '')
108
+ .replace(/^(?:企业名称|公司名称|单位名称|统一社会信用代码|信用代码|注册号)\s*[::]\s*/i, '')
109
+ .replace(/[\s\u00a0]+/g, '')
110
+ .trim();
111
+ }
112
+
113
+ /** 从 OCR 文本确定性提取一企一行的名称/信用代码,不推断不存在的主体。 */
114
+ export function extractCompanyEntries(text, maxEntries = IMAGE_LIMITS.maxEntries) {
115
+ const entries = [];
116
+ const seen = new Set();
117
+ const push = (name, creditNo) => {
118
+ const cleanName = cleanCell(name);
119
+ const cleanCredit = String(creditNo ?? '').trim().toUpperCase();
120
+ if (!cleanName && !cleanCredit) return;
121
+ if (cleanName && HEADER_RE.test(cleanName)) return;
122
+ const display = [cleanName, cleanCredit].filter(Boolean).join(' | ');
123
+ const key = `${cleanName.toLowerCase()}|${cleanCredit}`;
124
+ if (!seen.has(key) && entries.length < maxEntries) {
125
+ seen.add(key);
126
+ entries.push(display);
127
+ }
128
+ };
129
+
130
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
131
+ const line = rawLine.trim();
132
+ if (!line) continue;
133
+ const credits = [...line.matchAll(CREDIT_RE)].map((match) => match[0].toUpperCase());
134
+ const names = [];
135
+ for (const cell of line.split(/\t|[||]|\s{2,}|[,,;;]/)) {
136
+ const compact = cleanCell(cell);
137
+ for (const match of compact.matchAll(COMPANY_RE)) names.push(match[0]);
138
+ }
139
+ if (!names.length) {
140
+ const compact = cleanCell(line.replace(CREDIT_RE, ''));
141
+ for (const match of compact.matchAll(COMPANY_RE)) names.push(match[0]);
142
+ }
143
+ if (names.length === 1 && credits.length === 1) push(names[0], credits[0]);
144
+ else {
145
+ for (const name of names) push(name, '');
146
+ for (const credit of credits) push('', credit);
147
+ }
148
+ if (entries.length >= maxEntries) break;
149
+ }
150
+ return entries;
151
+ }
152
+
153
+ function publicRecord(record, now = Date.now()) {
154
+ return structuredClone({
155
+ commandId: record.commandId,
156
+ state: record.state,
157
+ fileName: record.fileName,
158
+ mimeType: record.mimeType,
159
+ sizeBytes: record.sizeBytes,
160
+ provider: record.provider,
161
+ createdAt: record.createdAt,
162
+ updatedAt: record.updatedAt,
163
+ expiresInMs: Math.max(0, record.expiresAt - now),
164
+ result: record.result,
165
+ error: record.error,
166
+ });
167
+ }
168
+
169
+ function safeFailure(error) {
170
+ if (error instanceof ImageIntakeError && error.code !== 'DC_IMAGE_PROVIDER_FAILED') {
171
+ return { code: error.code, message: error.message };
172
+ }
173
+ return {
174
+ code: 'DC_IMAGE_PROVIDER_FAILED',
175
+ message: '图片识别 Provider 当前不可用或配置无效。请配置 Modlens 可用视觉通道,或改用文本/Excel 名单。',
176
+ };
177
+ }
178
+
179
+ export class ImageIntakeStore {
180
+ constructor({ tools, clock = () => Date.now(), ttlMs = IMAGE_LIMITS.ttlMs } = {}) {
181
+ if (!tools || typeof tools.get !== 'function' || typeof tools.execute !== 'function') {
182
+ throw new TypeError('ImageIntakeStore requires ctx.tools get/execute');
183
+ }
184
+ this.tools = tools;
185
+ this.clock = clock;
186
+ this.ttlMs = ttlMs;
187
+ this.records = new Map();
188
+ }
189
+
190
+ providerDefinition() {
191
+ try { return this.tools.get(IMAGE_PROVIDER_MODLENS); } catch { return undefined; }
192
+ }
193
+
194
+ capabilities() {
195
+ const definition = this.providerDefinition();
196
+ return {
197
+ ready: Boolean(definition),
198
+ provider: definition ? IMAGE_PROVIDER_MODLENS : null,
199
+ nativeAttachmentUi: true,
200
+ pasteAndDrop: true,
201
+ formats: ['image/png', 'image/jpeg', 'image/webp'],
202
+ limits: IMAGE_LIMITS,
203
+ persistence: 'ephemeral-host-file',
204
+ };
205
+ }
206
+
207
+ scheduleExpiry(record) {
208
+ if (record.timer) clearTimeout(record.timer);
209
+ const delay = Math.max(1, Math.min(2_147_483_647, record.expiresAt - this.clock()));
210
+ record.timer = setTimeout(() => {
211
+ this.expire(record.commandId).catch(() => {});
212
+ }, delay);
213
+ record.timer.unref?.();
214
+ }
215
+
216
+ async expire(commandId) {
217
+ const record = this.records.get(commandId);
218
+ if (!record) return;
219
+ if (record.state === 'running') {
220
+ record.expiresAt = this.clock() + 60_000;
221
+ this.scheduleExpiry(record);
222
+ return;
223
+ }
224
+ if (record.timer) clearTimeout(record.timer);
225
+ await this.removeFile(record);
226
+ this.records.delete(commandId);
227
+ }
228
+
229
+ async cleanup() {
230
+ const now = this.clock();
231
+ for (const [id, record] of this.records) {
232
+ if (record.expiresAt <= now && record.state !== 'running') {
233
+ if (record.timer) clearTimeout(record.timer);
234
+ await this.removeFile(record);
235
+ this.records.delete(id);
236
+ }
237
+ }
238
+ }
239
+
240
+ async removeFile(record) {
241
+ if (!record?.path) return;
242
+ const path = record.path;
243
+ record.path = null;
244
+ try { await unlink(path); } catch (error) {
245
+ if (error?.code !== 'ENOENT') throw error;
246
+ }
247
+ }
248
+
249
+ async prepare(input = {}) {
250
+ await this.cleanup();
251
+ const provider = this.providerDefinition();
252
+ if (!provider) {
253
+ throw new ImageIntakeError(
254
+ 'DC_IMAGE_PROVIDER_UNAVAILABLE',
255
+ '当前 DSH 没有可用的图片文字识别 Provider。请安装并配置 Modlens,或改用文本/Excel 名单。',
256
+ 503,
257
+ );
258
+ }
259
+ const decoded = decodeImage(input.content);
260
+ const commandId = `dci-${randomUUID()}`;
261
+ const at = new Date(this.clock()).toISOString();
262
+ await mkdir(IMAGE_ROOT, { recursive: true, mode: 0o700 });
263
+ const path = join(IMAGE_ROOT, `${commandId}.${decoded.extension}`);
264
+ await writeFile(path, decoded.bytes, { mode: 0o600, flag: 'wx' });
265
+ const record = {
266
+ commandId,
267
+ state: 'prepared',
268
+ fileName: safeName(input.fileName),
269
+ mimeType: decoded.mimeType,
270
+ sizeBytes: decoded.bytes.length,
271
+ provider: IMAGE_PROVIDER_MODLENS,
272
+ path,
273
+ createdAt: at,
274
+ updatedAt: at,
275
+ expiresAt: this.clock() + this.ttlMs,
276
+ result: null,
277
+ error: null,
278
+ promise: null,
279
+ timer: null,
280
+ };
281
+ this.records.set(commandId, record);
282
+ this.scheduleExpiry(record);
283
+ return publicRecord(record, this.clock());
284
+ }
285
+
286
+ require(commandId) {
287
+ const record = this.records.get(String(commandId ?? ''));
288
+ if (!record || (record.expiresAt <= this.clock() && record.state !== 'running')) {
289
+ if (record && record.state !== 'running') this.expire(record.commandId).catch(() => {});
290
+ throw new ImageIntakeError('DC_IMAGE_COMMAND_NOT_FOUND', '图片识别任务不存在或已过期。', 404);
291
+ }
292
+ return record;
293
+ }
294
+
295
+ status(commandId) {
296
+ return publicRecord(this.require(commandId), this.clock());
297
+ }
298
+
299
+ async run(commandId, exec) {
300
+ const record = this.require(commandId);
301
+ if (!exec?.agent || !exec?.token) {
302
+ throw new ImageIntakeError('DC_IMAGE_AGENT_EXECUTION_REQUIRED', '图片识别必须由当前 DSH Agent 会话执行。', 409);
303
+ }
304
+ if (record.promise) return record.promise;
305
+ if (record.state === 'completed') return { commandId, ...record.result };
306
+ const provider = this.providerDefinition();
307
+ if (!provider) {
308
+ throw new ImageIntakeError('DC_IMAGE_PROVIDER_UNAVAILABLE', '图片识别 Provider 已离线。', 503);
309
+ }
310
+ record.state = 'running';
311
+ record.updatedAt = new Date(this.clock()).toISOString();
312
+ record.promise = this.tools.execute({
313
+ name: provider.name ?? IMAGE_PROVIDER_MODLENS,
314
+ callId: `dc-image-${randomUUID()}`,
315
+ rootCallId: exec.rootCallId,
316
+ parent: exec.token,
317
+ agent: exec.agent,
318
+ signal: exec.signal,
319
+ arguments: {
320
+ path: record.path,
321
+ prompt: '完整识别图片中的企业名单。重点逐行转写企业全称、统一社会信用代码或注册号;保留原始文字,不猜测模糊字符。',
322
+ },
323
+ }).then(async (providerResult) => {
324
+ const text = providerText(providerResult);
325
+ const entries = extractCompanyEntries(text);
326
+ if (!entries.length) {
327
+ throw new ImageIntakeError('DC_IMAGE_NO_COMPANY', '图片文字已识别,但未提取到企业全称或 18 位统一社会信用代码。', 422);
328
+ }
329
+ record.result = {
330
+ entries,
331
+ entryCount: entries.length,
332
+ truncated: entries.length >= IMAGE_LIMITS.maxEntries,
333
+ };
334
+ record.state = 'completed';
335
+ record.error = null;
336
+ record.updatedAt = new Date(this.clock()).toISOString();
337
+ await this.removeFile(record);
338
+ return { commandId, ...structuredClone(record.result) };
339
+ }).catch(async (error) => {
340
+ record.state = 'failed';
341
+ const failure = safeFailure(error);
342
+ record.error = failure;
343
+ record.updatedAt = new Date(this.clock()).toISOString();
344
+ await this.removeFile(record);
345
+ throw new ImageIntakeError(failure.code, failure.message, 502);
346
+ });
347
+ return record.promise;
348
+ }
349
+
350
+ async remove(commandId) {
351
+ const record = this.records.get(String(commandId ?? ''));
352
+ if (!record) return false;
353
+ if (record.state === 'running') {
354
+ throw new ImageIntakeError('DC_IMAGE_OPERATION_IN_PROGRESS', '图片正在识别,暂不能移除。', 409);
355
+ }
356
+ if (record.timer) clearTimeout(record.timer);
357
+ await this.removeFile(record);
358
+ this.records.delete(record.commandId);
359
+ return true;
360
+ }
361
+
362
+ async dispose() {
363
+ for (const record of this.records.values()) if (record.timer) clearTimeout(record.timer);
364
+ await Promise.all([...this.records.values()].map((record) => this.removeFile(record).catch(() => {})));
365
+ this.records.clear();
366
+ }
367
+ }
368
+
369
+ export function serializeImageExtractionPrompt(command) {
370
+ return [
371
+ '请识别我刚刚在向导中安全暂存的企业名单图片,并把识别结果交回数据清洗补全工作台供我逐条核验。',
372
+ '',
373
+ `图片文件:${command.fileName}。`,
374
+ '识别目标:逐行提取企业全称、18 位统一社会信用代码或注册号;不得猜测模糊字符。',
375
+ '本步骤只做图片文字识别与名单提取,不调用企查查,不消耗企查查 MCP 额度。',
376
+ `安全图片凭证:${command.commandId}`,
377
+ '',
378
+ `发送本说明后,请仅调用一次图片名单识别工具(${TOOL_IMAGE_EXTRACT}),参数只传递上述安全图片凭证。`,
379
+ '工具完成后立即结束本轮;不要直接调用任何 mcp__qcc-* 工具。',
380
+ ].join('\n');
381
+ }
382
+
383
+ export function registerImageIntakeTool(tools, store) {
384
+ return tools.register({
385
+ name: TOOL_IMAGE_EXTRACT,
386
+ description: 'Recognize one already-staged company-list image. Call only when a visible data-cleaning prompt supplies a dci-* commandId. The Host owns the temporary image and invokes the available vision provider in the current Agent execution.',
387
+ parameters: {
388
+ type: 'object',
389
+ additionalProperties: false,
390
+ properties: { commandId: { type: 'string' } },
391
+ required: ['commandId'],
392
+ },
393
+ output: {
394
+ schema: {
395
+ type: 'object',
396
+ additionalProperties: false,
397
+ properties: {
398
+ commandId: { type: 'string' },
399
+ entries: { type: 'array', items: { type: 'string' } },
400
+ entryCount: { type: 'integer' },
401
+ truncated: { type: 'boolean' },
402
+ },
403
+ required: ['commandId', 'entries', 'entryCount', 'truncated'],
404
+ },
405
+ render: (_args, value) => [{
406
+ type: 'text',
407
+ text: `图片企业名单已识别:${value.entryCount} 条,已同步回数据清洗补全工作台等待核验。`,
408
+ }],
409
+ },
410
+ async execute(args, exec) {
411
+ return store.run(args.commandId, exec);
412
+ },
413
+ });
414
+ }
package/lib/index.js CHANGED
@@ -28,6 +28,7 @@ export function apply(ctx, config) {
28
28
  webMounted: false,
29
29
  webSkipped: false,
30
30
  qccBridgeMounted: false,
31
+ imageIntakeToolRegistered: false,
31
32
  };
32
33
  const disposers = [];
33
34
 
package/lib/web.js CHANGED
@@ -16,6 +16,13 @@ import { PHASE3_BATCH_LIMITS, Phase3BatchService, Phase3RunStore } from './qcc-p
16
16
  import { publicWorkflowContract } from './workflow-contract.js';
17
17
  import { DataCleaningWorkflowStore, WorkflowError } from './workflow.js';
18
18
  import { ArtifactError, WorkflowArtifactStore } from './artifacts.js';
19
+ import {
20
+ ImageIntakeError,
21
+ ImageIntakeStore,
22
+ registerImageIntakeTool,
23
+ serializeImageExtractionPrompt,
24
+ TOOL_IMAGE_EXTRACT,
25
+ } from './image-intake.js';
19
26
 
20
27
  const MAX_BODY = 16 * 1024 * 1024; // 16 MiB 上传上限(MVP)
21
28
 
@@ -119,6 +126,17 @@ function writeWorkflowError(res, error) {
119
126
  });
120
127
  }
121
128
 
129
+ function writeImageError(res, error) {
130
+ if (error instanceof ImageIntakeError) {
131
+ return writeJson(res, error.status, { ok: false, code: error.code, message: error.message });
132
+ }
133
+ return writeJson(res, 500, {
134
+ ok: false,
135
+ code: 'DC_IMAGE_INTERNAL',
136
+ message: '图片名单接入请求失败。',
137
+ });
138
+ }
139
+
122
140
  /** 从 JSON 协议解析上传:{ filename, content }。content 为字符串;xlsx 时为 base64。 */
123
141
  async function parseUpload(body) {
124
142
  let payload;
@@ -285,13 +303,17 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
285
303
  const skills = wctx.skills;
286
304
  const disposers = [];
287
305
  const qccBridge = new QccHostBridge({ tools, logger });
306
+ const imageIntake = new ImageIntakeStore({ tools });
288
307
  const g5Runs = new G5RunStore();
289
308
  const qccCommands = new QccCommandStore({ bridge: qccBridge, runs: g5Runs });
290
309
  if (typeof tools?.register === 'function') {
291
310
  disposers.push(registerQccCommandTool(tools, qccCommands));
311
+ disposers.push(registerImageIntakeTool(tools, imageIntake));
292
312
  report.qccCommandToolRegistered = true;
313
+ report.imageIntakeToolRegistered = true;
293
314
  } else {
294
315
  report.qccCommandToolRegistered = false;
316
+ report.imageIntakeToolRegistered = false;
295
317
  }
296
318
  const phase3Service = new Phase3BatchService(qccBridge);
297
319
  const phase3Runs = new Phase3RunStore();
@@ -347,11 +369,64 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
347
369
  workflowV2: Boolean(wctx.storageDomain),
348
370
  durableArtifacts: Boolean(artifactStore),
349
371
  artifactBinaryStrategy: artifactStore ? 'xlsx-base64-over-writeText' : 'unavailable',
372
+ imageIntake: imageIntake.capabilities(),
350
373
  qccBridge: qccBridge.capabilities(),
351
374
  },
352
375
  });
353
376
  });
354
377
 
378
+ register('/data-cleaning/api/images/capabilities', (req, res) => {
379
+ if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
380
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'GET required' });
381
+ writeJson(res, 200, {
382
+ ok: true,
383
+ marker: 'data-cleaning-image-intake-v1',
384
+ tool: TOOL_IMAGE_EXTRACT,
385
+ toolRegistered: report.imageIntakeToolRegistered === true,
386
+ capabilities: imageIntake.capabilities(),
387
+ qccCalls: false,
388
+ });
389
+ });
390
+
391
+ register('/data-cleaning/api/images/commands', async (req, res) => {
392
+ if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
393
+ try {
394
+ const pathname = new URL(req.url ?? '/data-cleaning/api/images/commands', 'http://127.0.0.1').pathname;
395
+ const segments = pathname.split('/').filter(Boolean);
396
+ const commandsIndex = segments.indexOf('commands');
397
+ const rest = commandsIndex >= 0 ? segments.slice(commandsIndex + 1) : [];
398
+ if (rest.length === 0 && req.method === 'POST') {
399
+ if (report.imageIntakeToolRegistered !== true) {
400
+ throw new ImageIntakeError('DC_IMAGE_TOOL_UNAVAILABLE', '当前 DSH Host 无法注册图片名单高层工具。', 503);
401
+ }
402
+ const payload = JSON.parse((await readBody(req)).toString('utf8'));
403
+ const command = await imageIntake.prepare(payload);
404
+ return writeJson(res, 201, {
405
+ ok: true,
406
+ marker: 'data-cleaning-image-intake-v1',
407
+ command: { ...command, prompt: serializeImageExtractionPrompt(command) },
408
+ });
409
+ }
410
+ if (rest.length === 1 && req.method === 'GET') {
411
+ return writeJson(res, 200, {
412
+ ok: true,
413
+ marker: 'data-cleaning-image-intake-v1',
414
+ command: imageIntake.status(decodeURIComponent(rest[0])),
415
+ });
416
+ }
417
+ if (rest.length === 1 && req.method === 'DELETE') {
418
+ await imageIntake.remove(decodeURIComponent(rest[0]));
419
+ return writeJson(res, 200, { ok: true, marker: 'data-cleaning-image-intake-v1', removed: true });
420
+ }
421
+ return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'POST a command, GET its status, or DELETE it.' });
422
+ } catch (error) {
423
+ if (error instanceof SyntaxError) {
424
+ return writeJson(res, 400, { ok: false, code: 'DC_BAD_JSON', message: 'Request body must be valid JSON.' });
425
+ }
426
+ return writeImageError(res, error);
427
+ }
428
+ });
429
+
355
430
  register('/data-cleaning/api/workflow/contract', (req, res) => {
356
431
  if (!isTrusted(req)) return writeJson(res, 403, { ok: false, error: 'untrusted origin' });
357
432
  if (req.method !== 'GET') {
@@ -955,6 +1030,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
955
1030
  return () => {
956
1031
  if (state) { state.dispose().catch(() => {}); }
957
1032
  if (workflow) { workflow.dispose().catch(() => {}); }
1033
+ imageIntake.dispose().catch(() => {});
958
1034
  for (const dispose of disposers) dispose();
959
1035
  };
960
1036
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-data-cleaning-agent",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Clean, complete, and profile enterprise name lists in DeepSeek Harness — a data cleaning & completion agent plugin with local CSV/XLSX/JSON engine and optional Qichacha (QCC) MCP enrichment. Maintained by Qichacha/QCC.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -36,6 +36,7 @@
36
36
  "docs/RELEASE-0.6.2.md",
37
37
  "docs/RELEASE-0.6.3.md",
38
38
  "docs/RELEASE-0.7.0.md",
39
+ "docs/RELEASE-0.8.0.md",
39
40
  "docs/UI-WORKFLOW-V2.md",
40
41
  "docs/UI-WORKFLOW-V2-MIGRATION.md",
41
42
  "docs/UI-WORKFLOW-V2-ACCEPTANCE.md",
@@ -44,7 +45,7 @@
44
45
  ],
45
46
  "scripts": {
46
47
  "test": "node --test",
47
- "lint": "node --check lib/index.js && node --check lib/engine.js && node --check lib/artifacts.js && node --check lib/tools.js && node --check lib/skill.js && node --check lib/qcc-field-catalog.js && node --check lib/qcc-phase2.js && node --check lib/qcc-phase3.js && node --check lib/qcc-phase3-batch.js && node --check lib/qcc-phase2-acceptance.js && node --check lib/skill-enrich.js && node --check lib/jobs.js && node --check lib/workflow-contract.js && node --check lib/workflow.js && node --check lib/qcc-safety.js && node --check lib/qcc.js && node --check lib/qcc-command.js && node --check lib/qcc-runs.js && node --check lib/web.js && node --check lib/client.js && node --check scripts/check-readme-version.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/g5-e2e.mjs && node --check scripts/phase3-e2e.mjs && node --check scripts/phase2-acceptance.mjs",
48
+ "lint": "node --check lib/index.js && node --check lib/engine.js && node --check lib/artifacts.js && node --check lib/image-intake.js && node --check lib/tools.js && node --check lib/skill.js && node --check lib/qcc-field-catalog.js && node --check lib/qcc-phase2.js && node --check lib/qcc-phase3.js && node --check lib/qcc-phase3-batch.js && node --check lib/qcc-phase2-acceptance.js && node --check lib/skill-enrich.js && node --check lib/jobs.js && node --check lib/workflow-contract.js && node --check lib/workflow.js && node --check lib/qcc-safety.js && node --check lib/qcc.js && node --check lib/qcc-command.js && node --check lib/qcc-runs.js && node --check lib/web.js && node --check lib/client.js && node --check scripts/check-readme-version.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/g5-e2e.mjs && node --check scripts/phase3-e2e.mjs && node --check scripts/phase2-acceptance.mjs",
48
49
  "docs:check": "node scripts/check-readme-version.mjs",
49
50
  "marketing:check": "node scripts/check-marketing.mjs",
50
51
  "verify-pack": "node scripts/verify-pack.mjs",