dsh-data-cleaning-agent 0.7.0 → 0.8.1
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 +34 -0
- package/README.en.md +20 -2
- package/README.md +18 -3
- package/docs/COMPATIBILITY.md +22 -0
- package/docs/RELEASE-0.8.0.md +60 -0
- package/docs/RELEASE-0.8.1.md +75 -0
- package/docs/UI-WORKFLOW-V2.md +2 -2
- package/docs/USER-GUIDE.md +29 -3
- package/lib/client.js +367 -37
- package/lib/image-intake.js +581 -0
- package/lib/index.js +1 -0
- package/lib/web.js +77 -0
- package/package.json +4 -2
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图片企业名单接入。
|
|
3
|
+
*
|
|
4
|
+
* Browser 只负责把用户明确选择/粘贴的图片暂存到 Host;真实文字识别由
|
|
5
|
+
* Agent-owned 高层工具在当前会话执行上下文中调用企查查智能文档解析 MCP。
|
|
6
|
+
* 本地图片必须走官方 qcc-document-mcp(file_path),远端 qcc-document 只接受
|
|
7
|
+
* 公网 file_url,不能直接读取 Host 临时文件。图片使用 0600 临时文件,识别
|
|
8
|
+
* 完成、失败、取消或 TTL 到期后立即删除;不会进入 storageDomain 或导出制品。
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const TOOL_IMAGE_EXTRACT = 'data_cleaning_extract_image_companies';
|
|
16
|
+
export const IMAGE_PROVIDER_QCC_DOCUMENT_LOCAL = 'qcc-document-mcp';
|
|
17
|
+
export const QCC_DOCUMENT_REMOTE_PARSE = 'mcp__qcc-document__parse_document';
|
|
18
|
+
export const QCC_DOCUMENT_LOCAL_TOOL_PAIRS = Object.freeze([
|
|
19
|
+
Object.freeze({
|
|
20
|
+
parse: 'mcp__document__parse_document',
|
|
21
|
+
result: 'mcp__document__get_parse_result',
|
|
22
|
+
}),
|
|
23
|
+
Object.freeze({
|
|
24
|
+
parse: 'mcp__qcc-document-mcp__parse_document',
|
|
25
|
+
result: 'mcp__qcc-document-mcp__get_parse_result',
|
|
26
|
+
}),
|
|
27
|
+
Object.freeze({
|
|
28
|
+
parse: 'mcp__qcc-document-local__parse_document',
|
|
29
|
+
result: 'mcp__qcc-document-local__get_parse_result',
|
|
30
|
+
}),
|
|
31
|
+
Object.freeze({
|
|
32
|
+
parse: 'mcp__document-mcp__parse_document',
|
|
33
|
+
result: 'mcp__document-mcp__get_parse_result',
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export const IMAGE_LIMITS = Object.freeze({
|
|
38
|
+
maxBytes: 8 * 1024 * 1024,
|
|
39
|
+
maxEntries: 100,
|
|
40
|
+
ttlMs: 15 * 60 * 1000,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const IMAGE_ROOT = join(tmpdir(), 'dsh-data-cleaning-agent-images');
|
|
44
|
+
|
|
45
|
+
export class ImageIntakeError extends Error {
|
|
46
|
+
constructor(code, message, status = 400, details = {}) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'ImageIntakeError';
|
|
49
|
+
this.code = code;
|
|
50
|
+
this.status = status;
|
|
51
|
+
Object.assign(this, details);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeName(value) {
|
|
56
|
+
return String(value ?? '企业名单图片')
|
|
57
|
+
.replace(/[\u0000-\u001f\u007f/\\]/g, '_')
|
|
58
|
+
.trim()
|
|
59
|
+
.slice(0, 160) || '企业名单图片';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sniffImage(bytes) {
|
|
63
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < 12) return null;
|
|
64
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
65
|
+
return { mimeType: 'image/png', extension: 'png' };
|
|
66
|
+
}
|
|
67
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
68
|
+
return { mimeType: 'image/jpeg', extension: 'jpg' };
|
|
69
|
+
}
|
|
70
|
+
if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') {
|
|
71
|
+
return { mimeType: 'image/webp', extension: 'webp' };
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function decodeImage(content) {
|
|
77
|
+
const raw = String(content ?? '').replace(/^data:image\/[a-z0-9.+-]+;base64,/i, '');
|
|
78
|
+
if (!raw || !/^[A-Za-z0-9+/]*={0,2}$/.test(raw) || raw.length % 4 === 1) {
|
|
79
|
+
throw new ImageIntakeError('DC_IMAGE_BASE64', '图片内容不是有效的 Base64 数据。');
|
|
80
|
+
}
|
|
81
|
+
const bytes = Buffer.from(raw, 'base64');
|
|
82
|
+
if (!bytes.length) throw new ImageIntakeError('DC_IMAGE_EMPTY', '图片内容为空。');
|
|
83
|
+
if (bytes.length > IMAGE_LIMITS.maxBytes) {
|
|
84
|
+
throw new ImageIntakeError('DC_IMAGE_TOO_LARGE', '图片不能超过 8 MiB。', 413);
|
|
85
|
+
}
|
|
86
|
+
const detected = sniffImage(bytes);
|
|
87
|
+
if (!detected) {
|
|
88
|
+
throw new ImageIntakeError('DC_IMAGE_TYPE', '仅支持真实 PNG、JPEG 或 WebP 图片。', 415);
|
|
89
|
+
}
|
|
90
|
+
return { bytes, ...detected };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unwrapProviderValue(result) {
|
|
94
|
+
if (result?.isError === true) {
|
|
95
|
+
const message = result?.error?.message || result?.message || '图片识别 Provider 调用失败。';
|
|
96
|
+
throw new ImageIntakeError('DC_IMAGE_PROVIDER_FAILED', String(message), 502);
|
|
97
|
+
}
|
|
98
|
+
let value = result?.value ?? result;
|
|
99
|
+
if (value?.structuredContent !== undefined) value = value.structuredContent;
|
|
100
|
+
if (value && Array.isArray(value.content)) {
|
|
101
|
+
const text = value.content.filter((item) => item?.type === 'text').map((item) => item.text).join('\n');
|
|
102
|
+
if (text) {
|
|
103
|
+
try { value = JSON.parse(text); } catch { value = { ocr: { full_text: text } }; }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function providerText(result) {
|
|
110
|
+
const value = unwrapProviderValue(result);
|
|
111
|
+
const markdown = [
|
|
112
|
+
...(Array.isArray(value?.details) ? value.details : []),
|
|
113
|
+
value,
|
|
114
|
+
].map((detail) => String(detail?.result_md ?? detail?.resultMd ?? '').trim()).filter(Boolean).join('\n');
|
|
115
|
+
if (markdown) return markdown;
|
|
116
|
+
const lines = Array.isArray(value?.ocr?.lines)
|
|
117
|
+
? value.ocr.lines.map((line) => String(line?.text ?? '').trim()).filter(Boolean)
|
|
118
|
+
: [];
|
|
119
|
+
const text = String(value?.ocr?.full_text ?? lines.join('\n') ?? '').trim();
|
|
120
|
+
if (!text) {
|
|
121
|
+
throw new ImageIntakeError('DC_IMAGE_NO_TEXT', '图片中未识别到可用文字,请换用更清晰的原图。', 422);
|
|
122
|
+
}
|
|
123
|
+
return text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function providerEnvelope(result) {
|
|
127
|
+
const value = unwrapProviderValue(result);
|
|
128
|
+
if (typeof value === 'string') return { status: 'success', details: [{ result_md: value }] };
|
|
129
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
130
|
+
throw new ImageIntakeError('DC_IMAGE_DOCUMENT_CONTRACT', '企查查智能文档解析返回了无法识别的结果。', 502);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function documentStatus(value) {
|
|
136
|
+
const raw = String(value?.status ?? value?.state ?? '').trim().toLowerCase();
|
|
137
|
+
if (['success', 'succeeded', 'completed', 'complete', 'done'].includes(raw)) return 'success';
|
|
138
|
+
if (['failed', 'failure', 'error', 'cancelled', 'canceled'].includes(raw)) return 'failed';
|
|
139
|
+
if (['processing', 'pending', 'queued', 'running', 'accepted', 'submitted'].includes(raw)) return 'processing';
|
|
140
|
+
if (providerTextOrEmpty(value)) return 'success';
|
|
141
|
+
return raw || 'processing';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function providerTextOrEmpty(value) {
|
|
145
|
+
try { return providerText(value); } catch { return ''; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function providerTaskId(value) {
|
|
149
|
+
return String(value?.task_id ?? value?.taskId ?? '').trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function documentFailure(value) {
|
|
153
|
+
const code = String(value?.error?.code ?? value?.code ?? '').trim();
|
|
154
|
+
const auth = /(?:^|\D)(?:401|403)(?:\D|$)|AUTH|TOKEN|CREDENTIAL/i.test(`${code} ${value?.message ?? ''}`);
|
|
155
|
+
if (auth) {
|
|
156
|
+
return new ImageIntakeError(
|
|
157
|
+
'DC_IMAGE_QCC_AUTH_REQUIRED',
|
|
158
|
+
'企查查本地文档解析连接未授权或授权已失效,请配置 qcc-document-mcp 后重试。',
|
|
159
|
+
401,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return new ImageIntakeError(
|
|
163
|
+
'DC_IMAGE_DOCUMENT_FAILED',
|
|
164
|
+
code ? `企查查智能文档解析失败(错误码 ${code}),请检查图片后重试。` : '企查查智能文档解析失败,请检查图片后重试。',
|
|
165
|
+
502,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function delay(ms, signal) {
|
|
170
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('aborted'));
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const finish = () => {
|
|
173
|
+
signal?.removeEventListener('abort', abort);
|
|
174
|
+
resolve();
|
|
175
|
+
};
|
|
176
|
+
const timer = setTimeout(finish, ms);
|
|
177
|
+
const abort = () => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
signal?.removeEventListener('abort', abort);
|
|
180
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
181
|
+
};
|
|
182
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function definitionParameters(definition) {
|
|
187
|
+
return definition?.parameters ?? definition?.schema?.parameters ?? definition?.inputSchema ?? null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function supportsLocalPath(definition, name) {
|
|
191
|
+
const parameters = definitionParameters(definition);
|
|
192
|
+
if (parameters?.properties?.file_path) return true;
|
|
193
|
+
// 官方 npm 服务的稳定 serverName;旧 DSH 投射可能不保留完整 input schema。
|
|
194
|
+
return /(?:qcc-)?document-(?:mcp|local)__parse_document$/.test(String(name ?? ''));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const CREDIT_RE = /\b[0-9A-HJ-NPQRTUWXY]{18}\b/gi;
|
|
198
|
+
const COMPANY_END = '(?:有限责任公司|股份有限公司|集团有限公司|有限公司|集团公司|公司|普通合伙|有限合伙|合伙企业|个人独资企业|农民专业合作社|合作社|事务所|研究院|研究所|中心|商行|工厂|厂)';
|
|
199
|
+
const COMPANY_RE = new RegExp(`[\\p{Script=Han}A-Za-z0-9()()·&++—\\-]{2,72}${COMPANY_END}`, 'gu');
|
|
200
|
+
const HEADER_RE = /^(?:序号|企业名称|公司名称|单位名称|统一社会信用代码|信用代码|注册号|名称|企业名单)$/i;
|
|
201
|
+
|
|
202
|
+
function cleanCell(value) {
|
|
203
|
+
return String(value ?? '')
|
|
204
|
+
.replace(/^\s*(?:[-•·●▪◦]|\d{1,4}[.)、::]?)\s*/, '')
|
|
205
|
+
.replace(/^(?:企业名称|公司名称|单位名称|统一社会信用代码|信用代码|注册号)\s*[::]\s*/i, '')
|
|
206
|
+
.replace(/[\s\u00a0]+/g, '')
|
|
207
|
+
.trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 从 OCR 文本确定性提取一企一行的名称/信用代码,不推断不存在的主体。 */
|
|
211
|
+
export function extractCompanyEntries(text, maxEntries = IMAGE_LIMITS.maxEntries) {
|
|
212
|
+
const entries = [];
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
const push = (name, creditNo) => {
|
|
215
|
+
const cleanName = cleanCell(name);
|
|
216
|
+
const cleanCredit = String(creditNo ?? '').trim().toUpperCase();
|
|
217
|
+
if (!cleanName && !cleanCredit) return;
|
|
218
|
+
if (cleanName && HEADER_RE.test(cleanName)) return;
|
|
219
|
+
const display = [cleanName, cleanCredit].filter(Boolean).join(' | ');
|
|
220
|
+
const key = `${cleanName.toLowerCase()}|${cleanCredit}`;
|
|
221
|
+
if (!seen.has(key) && entries.length < maxEntries) {
|
|
222
|
+
seen.add(key);
|
|
223
|
+
entries.push(display);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
228
|
+
const line = rawLine.trim();
|
|
229
|
+
if (!line) continue;
|
|
230
|
+
const credits = [...line.matchAll(CREDIT_RE)].map((match) => match[0].toUpperCase());
|
|
231
|
+
const names = [];
|
|
232
|
+
for (const cell of line.split(/\t|[||]|\s{2,}|[,,;;]/)) {
|
|
233
|
+
const compact = cleanCell(cell);
|
|
234
|
+
for (const match of compact.matchAll(COMPANY_RE)) names.push(match[0]);
|
|
235
|
+
}
|
|
236
|
+
if (!names.length) {
|
|
237
|
+
const compact = cleanCell(line.replace(CREDIT_RE, ''));
|
|
238
|
+
for (const match of compact.matchAll(COMPANY_RE)) names.push(match[0]);
|
|
239
|
+
}
|
|
240
|
+
if (names.length === 1 && credits.length === 1) push(names[0], credits[0]);
|
|
241
|
+
else {
|
|
242
|
+
for (const name of names) push(name, '');
|
|
243
|
+
for (const credit of credits) push('', credit);
|
|
244
|
+
}
|
|
245
|
+
if (entries.length >= maxEntries) break;
|
|
246
|
+
}
|
|
247
|
+
return entries;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function publicRecord(record, now = Date.now()) {
|
|
251
|
+
return structuredClone({
|
|
252
|
+
commandId: record.commandId,
|
|
253
|
+
state: record.state,
|
|
254
|
+
fileName: record.fileName,
|
|
255
|
+
mimeType: record.mimeType,
|
|
256
|
+
sizeBytes: record.sizeBytes,
|
|
257
|
+
provider: record.provider,
|
|
258
|
+
parseTool: record.parseTool,
|
|
259
|
+
createdAt: record.createdAt,
|
|
260
|
+
updatedAt: record.updatedAt,
|
|
261
|
+
expiresInMs: Math.max(0, record.expiresAt - now),
|
|
262
|
+
result: record.result,
|
|
263
|
+
error: record.error,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function safeFailure(error) {
|
|
268
|
+
if (error instanceof ImageIntakeError && error.code !== 'DC_IMAGE_PROVIDER_FAILED') {
|
|
269
|
+
return { code: error.code, message: error.message };
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
code: 'DC_IMAGE_PROVIDER_FAILED',
|
|
273
|
+
message: '企查查智能文档解析当前不可用或配置无效。请连接本地 qcc-document-mcp,或改用文本/Excel 名单。',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export class ImageIntakeStore {
|
|
278
|
+
constructor({
|
|
279
|
+
tools,
|
|
280
|
+
clock = () => Date.now(),
|
|
281
|
+
ttlMs = IMAGE_LIMITS.ttlMs,
|
|
282
|
+
pollMs = 500,
|
|
283
|
+
maxPolls = 60,
|
|
284
|
+
} = {}) {
|
|
285
|
+
if (!tools || typeof tools.get !== 'function' || typeof tools.execute !== 'function') {
|
|
286
|
+
throw new TypeError('ImageIntakeStore requires ctx.tools get/execute');
|
|
287
|
+
}
|
|
288
|
+
this.tools = tools;
|
|
289
|
+
this.clock = clock;
|
|
290
|
+
this.ttlMs = ttlMs;
|
|
291
|
+
this.pollMs = pollMs;
|
|
292
|
+
this.maxPolls = maxPolls;
|
|
293
|
+
this.records = new Map();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
toolDefinition(name) {
|
|
297
|
+
try { return this.tools.get(name); } catch { return undefined; }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
providerDefinition(preferredParseTool) {
|
|
301
|
+
const pairs = preferredParseTool
|
|
302
|
+
? [...QCC_DOCUMENT_LOCAL_TOOL_PAIRS].sort((left) => left.parse === preferredParseTool ? -1 : 1)
|
|
303
|
+
: QCC_DOCUMENT_LOCAL_TOOL_PAIRS;
|
|
304
|
+
for (const pair of pairs) {
|
|
305
|
+
const parse = this.toolDefinition(pair.parse);
|
|
306
|
+
const result = this.toolDefinition(pair.result);
|
|
307
|
+
if (parse && result && supportsLocalPath(parse, parse.name ?? pair.parse)) {
|
|
308
|
+
return {
|
|
309
|
+
provider: IMAGE_PROVIDER_QCC_DOCUMENT_LOCAL,
|
|
310
|
+
parse,
|
|
311
|
+
result,
|
|
312
|
+
parseName: parse.name ?? pair.parse,
|
|
313
|
+
resultName: result.name ?? pair.result,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
remoteDefinition() {
|
|
321
|
+
const candidates = [
|
|
322
|
+
QCC_DOCUMENT_REMOTE_PARSE,
|
|
323
|
+
...QCC_DOCUMENT_LOCAL_TOOL_PAIRS.map((pair) => pair.parse),
|
|
324
|
+
];
|
|
325
|
+
for (const name of candidates) {
|
|
326
|
+
const definition = this.toolDefinition(name);
|
|
327
|
+
const parameters = definitionParameters(definition);
|
|
328
|
+
if (definition && parameters?.properties?.file_url && !parameters?.properties?.file_path) {
|
|
329
|
+
return definition;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return this.toolDefinition(QCC_DOCUMENT_REMOTE_PARSE);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
capabilities() {
|
|
336
|
+
const definition = this.providerDefinition();
|
|
337
|
+
return {
|
|
338
|
+
ready: Boolean(definition),
|
|
339
|
+
provider: definition?.provider ?? null,
|
|
340
|
+
parseTool: definition?.parseName ?? null,
|
|
341
|
+
resultTool: definition?.resultName ?? null,
|
|
342
|
+
remoteUrlOnlyConnected: Boolean(this.remoteDefinition()),
|
|
343
|
+
localFileConnectorRequired: !definition,
|
|
344
|
+
nativeAttachmentUi: true,
|
|
345
|
+
pasteAndDrop: true,
|
|
346
|
+
formats: ['image/png', 'image/jpeg', 'image/webp'],
|
|
347
|
+
limits: IMAGE_LIMITS,
|
|
348
|
+
persistence: 'ephemeral-host-file',
|
|
349
|
+
billing: 'current-user-qcc-document-account',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
scheduleExpiry(record) {
|
|
354
|
+
if (record.timer) clearTimeout(record.timer);
|
|
355
|
+
const delay = Math.max(1, Math.min(2_147_483_647, record.expiresAt - this.clock()));
|
|
356
|
+
record.timer = setTimeout(() => {
|
|
357
|
+
this.expire(record.commandId).catch(() => {});
|
|
358
|
+
}, delay);
|
|
359
|
+
record.timer.unref?.();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async expire(commandId) {
|
|
363
|
+
const record = this.records.get(commandId);
|
|
364
|
+
if (!record) return;
|
|
365
|
+
if (record.state === 'running') {
|
|
366
|
+
record.expiresAt = this.clock() + 60_000;
|
|
367
|
+
this.scheduleExpiry(record);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (record.timer) clearTimeout(record.timer);
|
|
371
|
+
await this.removeFile(record);
|
|
372
|
+
this.records.delete(commandId);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async cleanup() {
|
|
376
|
+
const now = this.clock();
|
|
377
|
+
for (const [id, record] of this.records) {
|
|
378
|
+
if (record.expiresAt <= now && record.state !== 'running') {
|
|
379
|
+
if (record.timer) clearTimeout(record.timer);
|
|
380
|
+
await this.removeFile(record);
|
|
381
|
+
this.records.delete(id);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async removeFile(record) {
|
|
387
|
+
if (!record?.path) return;
|
|
388
|
+
const path = record.path;
|
|
389
|
+
record.path = null;
|
|
390
|
+
try { await unlink(path); } catch (error) {
|
|
391
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async prepare(input = {}) {
|
|
396
|
+
await this.cleanup();
|
|
397
|
+
const provider = this.providerDefinition();
|
|
398
|
+
if (!provider) {
|
|
399
|
+
const remoteOnly = Boolean(this.remoteDefinition());
|
|
400
|
+
throw new ImageIntakeError(
|
|
401
|
+
remoteOnly ? 'DC_IMAGE_LOCAL_DOCUMENT_PROVIDER_REQUIRED' : 'DC_IMAGE_PROVIDER_UNAVAILABLE',
|
|
402
|
+
remoteOnly
|
|
403
|
+
? '当前只连接了远端 qcc-document,它不能读取本地图片。请在 MCP 连接器中配置本地 qcc-document-mcp。'
|
|
404
|
+
: '当前 DSH 没有可用的企查查本地文档解析连接。请配置 qcc-document-mcp,或改用文本/Excel 名单。',
|
|
405
|
+
503,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const decoded = decodeImage(input.content);
|
|
409
|
+
const commandId = `dci-${randomUUID()}`;
|
|
410
|
+
const at = new Date(this.clock()).toISOString();
|
|
411
|
+
await mkdir(IMAGE_ROOT, { recursive: true, mode: 0o700 });
|
|
412
|
+
const path = join(IMAGE_ROOT, `${commandId}.${decoded.extension}`);
|
|
413
|
+
await writeFile(path, decoded.bytes, { mode: 0o600, flag: 'wx' });
|
|
414
|
+
const record = {
|
|
415
|
+
commandId,
|
|
416
|
+
state: 'prepared',
|
|
417
|
+
fileName: safeName(input.fileName),
|
|
418
|
+
mimeType: decoded.mimeType,
|
|
419
|
+
sizeBytes: decoded.bytes.length,
|
|
420
|
+
provider: provider.provider,
|
|
421
|
+
parseTool: provider.parseName,
|
|
422
|
+
resultTool: provider.resultName,
|
|
423
|
+
path,
|
|
424
|
+
createdAt: at,
|
|
425
|
+
updatedAt: at,
|
|
426
|
+
expiresAt: this.clock() + this.ttlMs,
|
|
427
|
+
result: null,
|
|
428
|
+
error: null,
|
|
429
|
+
promise: null,
|
|
430
|
+
timer: null,
|
|
431
|
+
};
|
|
432
|
+
this.records.set(commandId, record);
|
|
433
|
+
this.scheduleExpiry(record);
|
|
434
|
+
return publicRecord(record, this.clock());
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
require(commandId) {
|
|
438
|
+
const record = this.records.get(String(commandId ?? ''));
|
|
439
|
+
if (!record || (record.expiresAt <= this.clock() && record.state !== 'running')) {
|
|
440
|
+
if (record && record.state !== 'running') this.expire(record.commandId).catch(() => {});
|
|
441
|
+
throw new ImageIntakeError('DC_IMAGE_COMMAND_NOT_FOUND', '图片识别任务不存在或已过期。', 404);
|
|
442
|
+
}
|
|
443
|
+
return record;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
status(commandId) {
|
|
447
|
+
return publicRecord(this.require(commandId), this.clock());
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async run(commandId, exec) {
|
|
451
|
+
const record = this.require(commandId);
|
|
452
|
+
if (!exec?.agent || !exec?.token) {
|
|
453
|
+
throw new ImageIntakeError('DC_IMAGE_AGENT_EXECUTION_REQUIRED', '图片识别必须由当前 DSH Agent 会话执行。', 409);
|
|
454
|
+
}
|
|
455
|
+
if (record.promise) return record.promise;
|
|
456
|
+
if (record.state === 'completed') return { commandId, ...record.result };
|
|
457
|
+
const provider = this.providerDefinition(record.parseTool);
|
|
458
|
+
if (!provider) {
|
|
459
|
+
throw new ImageIntakeError('DC_IMAGE_PROVIDER_UNAVAILABLE', '企查查本地文档解析连接已离线。', 503);
|
|
460
|
+
}
|
|
461
|
+
record.state = 'running';
|
|
462
|
+
record.updatedAt = new Date(this.clock()).toISOString();
|
|
463
|
+
const execute = (name, args) => this.tools.execute({
|
|
464
|
+
name,
|
|
465
|
+
callId: `dc-image-${randomUUID()}`,
|
|
466
|
+
rootCallId: exec.rootCallId,
|
|
467
|
+
parent: exec.token,
|
|
468
|
+
agent: exec.agent,
|
|
469
|
+
signal: exec.signal,
|
|
470
|
+
arguments: args,
|
|
471
|
+
});
|
|
472
|
+
record.promise = execute(provider.parseName, {
|
|
473
|
+
file_path: record.path,
|
|
474
|
+
wait: true,
|
|
475
|
+
}).then(async (parseResult) => {
|
|
476
|
+
let envelope = providerEnvelope(parseResult);
|
|
477
|
+
let status = documentStatus(envelope);
|
|
478
|
+
const taskId = providerTaskId(envelope);
|
|
479
|
+
for (let poll = 0; status === 'processing' && poll < this.maxPolls; poll += 1) {
|
|
480
|
+
if (!taskId) {
|
|
481
|
+
throw new ImageIntakeError('DC_IMAGE_DOCUMENT_CONTRACT', '企查查文档解析未返回 task_id。', 502);
|
|
482
|
+
}
|
|
483
|
+
await delay(this.pollMs, exec.signal);
|
|
484
|
+
envelope = providerEnvelope(await execute(provider.resultName, { task_id: taskId }));
|
|
485
|
+
status = documentStatus(envelope);
|
|
486
|
+
}
|
|
487
|
+
if (status === 'processing') {
|
|
488
|
+
throw new ImageIntakeError('DC_IMAGE_DOCUMENT_TIMEOUT', '企查查文档解析仍在处理中,请稍后重新识别。', 504);
|
|
489
|
+
}
|
|
490
|
+
if (status === 'failed') throw documentFailure(envelope);
|
|
491
|
+
const text = providerText(envelope);
|
|
492
|
+
const entries = extractCompanyEntries(text);
|
|
493
|
+
if (!entries.length) {
|
|
494
|
+
throw new ImageIntakeError('DC_IMAGE_NO_COMPANY', '图片文字已识别,但未提取到企业全称或 18 位统一社会信用代码。', 422);
|
|
495
|
+
}
|
|
496
|
+
record.result = {
|
|
497
|
+
entries,
|
|
498
|
+
entryCount: entries.length,
|
|
499
|
+
truncated: entries.length >= IMAGE_LIMITS.maxEntries,
|
|
500
|
+
};
|
|
501
|
+
record.state = 'completed';
|
|
502
|
+
record.error = null;
|
|
503
|
+
record.updatedAt = new Date(this.clock()).toISOString();
|
|
504
|
+
await this.removeFile(record);
|
|
505
|
+
return { commandId, ...structuredClone(record.result) };
|
|
506
|
+
}).catch(async (error) => {
|
|
507
|
+
record.state = 'failed';
|
|
508
|
+
const failure = safeFailure(error);
|
|
509
|
+
record.error = failure;
|
|
510
|
+
record.updatedAt = new Date(this.clock()).toISOString();
|
|
511
|
+
await this.removeFile(record);
|
|
512
|
+
throw new ImageIntakeError(failure.code, failure.message, 502);
|
|
513
|
+
});
|
|
514
|
+
return record.promise;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async remove(commandId) {
|
|
518
|
+
const record = this.records.get(String(commandId ?? ''));
|
|
519
|
+
if (!record) return false;
|
|
520
|
+
if (record.state === 'running') {
|
|
521
|
+
throw new ImageIntakeError('DC_IMAGE_OPERATION_IN_PROGRESS', '图片正在识别,暂不能移除。', 409);
|
|
522
|
+
}
|
|
523
|
+
if (record.timer) clearTimeout(record.timer);
|
|
524
|
+
await this.removeFile(record);
|
|
525
|
+
this.records.delete(record.commandId);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async dispose() {
|
|
530
|
+
for (const record of this.records.values()) if (record.timer) clearTimeout(record.timer);
|
|
531
|
+
await Promise.all([...this.records.values()].map((record) => this.removeFile(record).catch(() => {})));
|
|
532
|
+
this.records.clear();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function serializeImageExtractionPrompt(command) {
|
|
537
|
+
return [
|
|
538
|
+
'请使用企查查智能文档解析识别我刚刚在向导中安全暂存的企业名单图片,并把结果交回数据清洗补全工作台供我逐条核验。',
|
|
539
|
+
'',
|
|
540
|
+
`图片文件:${command.fileName}。`,
|
|
541
|
+
'识别目标:逐行提取企业全称、18 位统一社会信用代码或注册号;不得猜测模糊字符。',
|
|
542
|
+
'本步骤会使用当前用户自己连接的企查查 qcc-document-mcp;文档解析额度或费用由该账号自行承担。',
|
|
543
|
+
`安全图片凭证:${command.commandId}`,
|
|
544
|
+
'',
|
|
545
|
+
`发送本说明后,请仅调用一次图片名单识别工具(${TOOL_IMAGE_EXTRACT}),参数只传递上述安全图片凭证。`,
|
|
546
|
+
'高层工具会在 Host 内准确调用一次 parse_document,并只在异步处理中查询 get_parse_result;不要绕过高层工具直接调用任何 mcp__qcc-* 工具。',
|
|
547
|
+
].join('\n');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function registerImageIntakeTool(tools, store) {
|
|
551
|
+
return tools.register({
|
|
552
|
+
name: TOOL_IMAGE_EXTRACT,
|
|
553
|
+
description: 'Parse one already-staged local company-list image through QCC qcc-document-mcp. Call only when a visible data-cleaning prompt supplies a dci-* commandId. The Host owns the temporary image, calls parse_document once, and polls get_parse_result only while the returned task is processing.',
|
|
554
|
+
parameters: {
|
|
555
|
+
type: 'object',
|
|
556
|
+
additionalProperties: false,
|
|
557
|
+
properties: { commandId: { type: 'string' } },
|
|
558
|
+
required: ['commandId'],
|
|
559
|
+
},
|
|
560
|
+
output: {
|
|
561
|
+
schema: {
|
|
562
|
+
type: 'object',
|
|
563
|
+
additionalProperties: false,
|
|
564
|
+
properties: {
|
|
565
|
+
commandId: { type: 'string' },
|
|
566
|
+
entries: { type: 'array', items: { type: 'string' } },
|
|
567
|
+
entryCount: { type: 'integer' },
|
|
568
|
+
truncated: { type: 'boolean' },
|
|
569
|
+
},
|
|
570
|
+
required: ['commandId', 'entries', 'entryCount', 'truncated'],
|
|
571
|
+
},
|
|
572
|
+
render: (_args, value) => [{
|
|
573
|
+
type: 'text',
|
|
574
|
+
text: `企查查智能文档解析已识别图片企业名单:${value.entryCount} 条,已同步回数据清洗补全工作台等待核验。`,
|
|
575
|
+
}],
|
|
576
|
+
},
|
|
577
|
+
async execute(args, exec) {
|
|
578
|
+
return store.run(args.commandId, exec);
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
}
|