dsh-data-cleaning-agent 0.6.0 → 0.6.2
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 +29 -0
- package/README.en.md +12 -6
- package/README.md +11 -7
- package/docs/COMPATIBILITY.md +7 -6
- package/docs/G5-HOST-BRIDGE.md +34 -8
- package/docs/QCC-ENRICHMENT-DESIGN.md +4 -2
- package/docs/QCC-PHASES-ROADMAP.md +3 -2
- package/docs/RELEASE-0.6.0.md +14 -3
- package/docs/RELEASE-0.6.1.md +64 -0
- package/docs/RELEASE-0.6.2.md +40 -0
- package/docs/UI-WORKFLOW-V2.md +2 -3
- package/lib/artifacts.js +47 -8
- package/lib/client.js +283 -66
- package/lib/engine.js +1 -1
- package/lib/index.js +9 -4
- package/lib/jobs.js +1 -1
- package/lib/qcc-command.js +280 -0
- package/lib/qcc-runs.js +5 -1
- package/lib/qcc.js +142 -15
- package/lib/skill-enrich.js +7 -2
- package/lib/tools.js +1 -1
- package/lib/web.js +55 -1
- package/lib/workflow-contract.js +9 -0
- package/package.json +4 -2
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-owned QCC command bridge.
|
|
3
|
+
*
|
|
4
|
+
* DSH Code Mode only permits dynamic MCP calls as nested executions of an
|
|
5
|
+
* Agent-owned tool call. The Web workbench therefore stages rows under an
|
|
6
|
+
* opaque command id, sends only that id through the visible conversation,
|
|
7
|
+
* and lets this high-level tool perform the paid calls with exec.token.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { QccBridgeError } from './qcc.js';
|
|
11
|
+
import { normalizeFieldSelection } from './workflow-contract.js';
|
|
12
|
+
|
|
13
|
+
export const TOOL_QCC_COMMAND = 'data_cleaning_qcc_run';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
16
|
+
const DEFAULT_MAX_COMMANDS = 50;
|
|
17
|
+
|
|
18
|
+
function clone(value) {
|
|
19
|
+
return structuredClone(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function safeError(error) {
|
|
23
|
+
if (error instanceof QccBridgeError) return error.toJSON();
|
|
24
|
+
return new QccBridgeError('QCC_COMMAND_FAILED', 'Data-cleaning QCC command failed', {
|
|
25
|
+
retryable: false,
|
|
26
|
+
}).toJSON();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requiredText(value, code, message) {
|
|
30
|
+
const text = String(value ?? '').trim();
|
|
31
|
+
if (!text) throw new QccBridgeError(code, message);
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizedInput(input = {}) {
|
|
36
|
+
const kind = String(input.kind ?? 'enrich');
|
|
37
|
+
if (!['enrich', 'resolve', 'retry'].includes(kind)) {
|
|
38
|
+
throw new QccBridgeError('QCC_COMMAND_KIND_INVALID', 'Unsupported data-cleaning QCC command kind');
|
|
39
|
+
}
|
|
40
|
+
const taskId = requiredText(input.taskId, 'QCC_COMMAND_TASK_REQUIRED', 'A workflow taskId is required');
|
|
41
|
+
if (kind === 'enrich') {
|
|
42
|
+
const rows = Array.isArray(input.rows) ? input.rows : [];
|
|
43
|
+
if (rows.length === 0) throw new QccBridgeError('QCC_INVALID_ROWS', 'At least one row is required');
|
|
44
|
+
if (rows.length > 100) throw new QccBridgeError('QCC_BATCH_TOO_LARGE', 'QCC batch exceeds 100 rows');
|
|
45
|
+
return {
|
|
46
|
+
kind,
|
|
47
|
+
taskId,
|
|
48
|
+
rows: clone(rows),
|
|
49
|
+
headers: Array.isArray(input.headers) ? input.headers.map(String) : [],
|
|
50
|
+
nameField: String(input.nameField ?? 'name'),
|
|
51
|
+
fieldSelection: normalizeFieldSelection(input.fieldSelection),
|
|
52
|
+
includeRisk: input.includeRisk === true,
|
|
53
|
+
concurrency: Math.min(4, Math.max(1, Math.trunc(Number(input.concurrency ?? 2)))),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const runId = requiredText(input.runId, 'QCC_RUN_NOT_FOUND', 'A G5 runId is required');
|
|
57
|
+
if (kind === 'resolve') {
|
|
58
|
+
return {
|
|
59
|
+
kind,
|
|
60
|
+
taskId,
|
|
61
|
+
runId,
|
|
62
|
+
companyName: requiredText(input.companyName, 'QCC_REVIEW_NOT_PENDING', 'A company name is required'),
|
|
63
|
+
selectedCreditNo: requiredText(input.selectedCreditNo, 'QCC_CANDIDATE_INVALID', 'A selected credit number is required'),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const companyNames = [...new Set((Array.isArray(input.companyNames) ? input.companyNames : [])
|
|
67
|
+
.map((name) => String(name).trim()).filter(Boolean))];
|
|
68
|
+
if (companyNames.length === 0) throw new QccBridgeError('QCC_RETRY_EMPTY', 'At least one failed company must be selected');
|
|
69
|
+
return { kind, taskId, runId, companyNames };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function serializeQccCommandPrompt(command) {
|
|
73
|
+
const visible = {
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
commandId: command.commandId,
|
|
76
|
+
taskId: command.taskId,
|
|
77
|
+
kind: command.kind,
|
|
78
|
+
};
|
|
79
|
+
return [
|
|
80
|
+
'请执行数据清洗补全企查查任务。',
|
|
81
|
+
'',
|
|
82
|
+
'类型化任务意图(schemaVersion 1):',
|
|
83
|
+
'```json',
|
|
84
|
+
JSON.stringify(visible, null, 2),
|
|
85
|
+
'```',
|
|
86
|
+
'',
|
|
87
|
+
`请准确调用一次 ${TOOL_QCC_COMMAND} 并只传递 commandId。`,
|
|
88
|
+
'企业名单和字段选择已安全暂存在本机 Host,不得要求用户在对话中重复粘贴,不得直接调用任何 mcp__qcc-* 工具。',
|
|
89
|
+
'高层工具返回后立即结束本轮;不得重试、扩大名单或追加字段。',
|
|
90
|
+
].join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class QccCommandStore {
|
|
94
|
+
constructor({ bridge, runs, clock = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxCommands = DEFAULT_MAX_COMMANDS } = {}) {
|
|
95
|
+
if (!bridge || !runs) throw new TypeError('QccCommandStore requires bridge and runs');
|
|
96
|
+
this.bridge = bridge;
|
|
97
|
+
this.runs = runs;
|
|
98
|
+
this.clock = clock;
|
|
99
|
+
this.ttlMs = ttlMs;
|
|
100
|
+
this.maxCommands = maxCommands;
|
|
101
|
+
this.commands = new Map();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
cleanup(reserveSlot = false) {
|
|
105
|
+
const cutoff = this.clock() - this.ttlMs;
|
|
106
|
+
for (const [id, command] of this.commands) {
|
|
107
|
+
if (command.state !== 'running' && command.touchedAtMs < cutoff) this.commands.delete(id);
|
|
108
|
+
}
|
|
109
|
+
const limit = reserveSlot ? this.maxCommands - 1 : this.maxCommands;
|
|
110
|
+
while (this.commands.size > limit) {
|
|
111
|
+
const removable = [...this.commands].find(([, command]) => command.state !== 'running');
|
|
112
|
+
if (!removable) throw new QccBridgeError('QCC_COMMAND_CAPACITY', 'QCC command queue is full', { retryable: true });
|
|
113
|
+
this.commands.delete(removable[0]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
prepare(input) {
|
|
118
|
+
this.cleanup(true);
|
|
119
|
+
const normalized = normalizedInput(input);
|
|
120
|
+
const commandId = `dcq-${randomUUID()}`;
|
|
121
|
+
const at = new Date(this.clock()).toISOString();
|
|
122
|
+
const record = {
|
|
123
|
+
commandId,
|
|
124
|
+
taskId: normalized.taskId,
|
|
125
|
+
kind: normalized.kind,
|
|
126
|
+
state: 'prepared',
|
|
127
|
+
createdAt: at,
|
|
128
|
+
updatedAt: at,
|
|
129
|
+
touchedAtMs: this.clock(),
|
|
130
|
+
input: normalized,
|
|
131
|
+
runId: null,
|
|
132
|
+
error: null,
|
|
133
|
+
promise: null,
|
|
134
|
+
};
|
|
135
|
+
this.commands.set(commandId, record);
|
|
136
|
+
return { ...this.publicRecord(record), prompt: serializeQccCommandPrompt(record) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
require(commandId) {
|
|
140
|
+
this.cleanup();
|
|
141
|
+
const record = this.commands.get(String(commandId ?? ''));
|
|
142
|
+
if (!record) throw new QccBridgeError('QCC_COMMAND_NOT_FOUND', 'QCC command was not found or expired');
|
|
143
|
+
record.touchedAtMs = this.clock();
|
|
144
|
+
return record;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
publicRecord(record) {
|
|
148
|
+
return clone({
|
|
149
|
+
commandId: record.commandId,
|
|
150
|
+
taskId: record.taskId,
|
|
151
|
+
kind: record.kind,
|
|
152
|
+
state: record.state,
|
|
153
|
+
createdAt: record.createdAt,
|
|
154
|
+
updatedAt: record.updatedAt,
|
|
155
|
+
runId: record.runId,
|
|
156
|
+
error: record.error,
|
|
157
|
+
expiresInMs: this.ttlMs,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
status(commandId) {
|
|
162
|
+
const record = this.require(commandId);
|
|
163
|
+
const output = this.publicRecord(record);
|
|
164
|
+
if (record.runId) output.run = this.runs.get(record.runId);
|
|
165
|
+
return output;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async run(commandId, execution) {
|
|
169
|
+
const record = this.require(commandId);
|
|
170
|
+
if (!execution?.agent || !execution?.token) {
|
|
171
|
+
throw new QccBridgeError('QCC_AGENT_EXECUTION_REQUIRED', 'QCC commands require an Agent-owned DSH tool execution');
|
|
172
|
+
}
|
|
173
|
+
if (record.promise) return record.promise;
|
|
174
|
+
record.state = 'running';
|
|
175
|
+
record.updatedAt = new Date(this.clock()).toISOString();
|
|
176
|
+
record.promise = this.execute(record, execution)
|
|
177
|
+
.then((run) => {
|
|
178
|
+
record.runId = run.runId;
|
|
179
|
+
record.state = 'completed';
|
|
180
|
+
record.updatedAt = new Date(this.clock()).toISOString();
|
|
181
|
+
record.touchedAtMs = this.clock();
|
|
182
|
+
return this.toolResult(record, run);
|
|
183
|
+
})
|
|
184
|
+
.catch((error) => {
|
|
185
|
+
record.error = safeError(error);
|
|
186
|
+
record.state = 'failed';
|
|
187
|
+
record.updatedAt = new Date(this.clock()).toISOString();
|
|
188
|
+
record.touchedAtMs = this.clock();
|
|
189
|
+
throw error;
|
|
190
|
+
});
|
|
191
|
+
return record.promise;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async execute(record, execution) {
|
|
195
|
+
const options = { execution };
|
|
196
|
+
if (record.kind === 'resolve') {
|
|
197
|
+
return this.runs.resolveCandidate(record.input.runId, record.input, this.bridge, options);
|
|
198
|
+
}
|
|
199
|
+
if (record.kind === 'retry') {
|
|
200
|
+
return this.runs.retryCompanies(record.input.runId, record.input.companyNames, this.bridge, options);
|
|
201
|
+
}
|
|
202
|
+
const audit = [];
|
|
203
|
+
const result = await this.bridge.enrichRows(record.input.rows, {
|
|
204
|
+
nameField: record.input.nameField,
|
|
205
|
+
includeRisk: record.input.includeRisk,
|
|
206
|
+
fieldSelection: record.input.fieldSelection,
|
|
207
|
+
concurrency: record.input.concurrency,
|
|
208
|
+
maxRows: 100,
|
|
209
|
+
execution,
|
|
210
|
+
onAudit: (event) => audit.push(event),
|
|
211
|
+
});
|
|
212
|
+
return this.runs.createRun({
|
|
213
|
+
headers: record.input.headers,
|
|
214
|
+
nameField: record.input.nameField,
|
|
215
|
+
includeRisk: record.input.includeRisk,
|
|
216
|
+
fieldSelection: record.input.fieldSelection,
|
|
217
|
+
concurrency: record.input.concurrency,
|
|
218
|
+
result,
|
|
219
|
+
audit,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
toolResult(record, run) {
|
|
224
|
+
return clone({
|
|
225
|
+
commandId: record.commandId,
|
|
226
|
+
taskId: record.taskId,
|
|
227
|
+
runId: run.runId,
|
|
228
|
+
state: run.state,
|
|
229
|
+
summary: run.summary,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function registerQccCommandTool(tools, commands) {
|
|
235
|
+
return tools.register({
|
|
236
|
+
name: TOOL_QCC_COMMAND,
|
|
237
|
+
description: 'Execute one already-staged data-cleaning QCC command. Call only when a visible typed intent supplies commandId. The Host owns rows, billing confirmation, idempotency and result artifacts.',
|
|
238
|
+
parameters: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
additionalProperties: false,
|
|
241
|
+
properties: { commandId: { type: 'string' } },
|
|
242
|
+
required: ['commandId'],
|
|
243
|
+
},
|
|
244
|
+
output: {
|
|
245
|
+
schema: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
properties: {
|
|
249
|
+
commandId: { type: 'string' },
|
|
250
|
+
taskId: { type: 'string' },
|
|
251
|
+
runId: { type: 'string' },
|
|
252
|
+
state: { type: 'string' },
|
|
253
|
+
summary: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
additionalProperties: false,
|
|
256
|
+
properties: {
|
|
257
|
+
totalRows: { type: 'integer' },
|
|
258
|
+
uniqueCompanies: { type: 'integer' },
|
|
259
|
+
enriched: { type: 'integer' },
|
|
260
|
+
ambiguous: { type: 'integer' },
|
|
261
|
+
unresolved: { type: 'integer' },
|
|
262
|
+
failed: { type: 'integer' },
|
|
263
|
+
missingName: { type: 'integer' },
|
|
264
|
+
includeRisk: { type: 'boolean' },
|
|
265
|
+
},
|
|
266
|
+
required: ['totalRows', 'uniqueCompanies', 'enriched', 'ambiguous', 'unresolved', 'failed', 'missingName', 'includeRisk'],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
required: ['commandId', 'taskId', 'runId', 'state', 'summary'],
|
|
270
|
+
},
|
|
271
|
+
render: (_args, value) => [{
|
|
272
|
+
type: 'text',
|
|
273
|
+
text: `数据清洗补全企查查任务已完成:${value.summary?.enriched ?? 0}/${value.summary?.totalRows ?? 0} 条已补全,状态 ${value.state}。`,
|
|
274
|
+
}],
|
|
275
|
+
},
|
|
276
|
+
async execute(args, exec) {
|
|
277
|
+
return commands.run(args.commandId, exec);
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
package/lib/qcc-runs.js
CHANGED
|
@@ -143,7 +143,7 @@ export class G5RunStore {
|
|
|
143
143
|
return { value: await entry.promise, replayed: false };
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
createRun({ headers, nameField, includeRisk, concurrency, result, audit = [] }) {
|
|
146
|
+
createRun({ headers, nameField, fieldSelection, includeRisk, concurrency, result, audit = [] }) {
|
|
147
147
|
this.cleanup();
|
|
148
148
|
const id = this.runIdFactory();
|
|
149
149
|
const at = this.nowIso();
|
|
@@ -156,6 +156,7 @@ export class G5RunStore {
|
|
|
156
156
|
touchedAtMs: this.clock(),
|
|
157
157
|
headers: Array.isArray(headers) ? headers.map(String) : [],
|
|
158
158
|
nameField: String(nameField ?? 'name'),
|
|
159
|
+
fieldSelection: Array.isArray(fieldSelection) ? fieldSelection.map(String) : [],
|
|
159
160
|
includeRisk: Boolean(includeRisk),
|
|
160
161
|
concurrency: Number(concurrency ?? 2),
|
|
161
162
|
summary: clone(result.summary),
|
|
@@ -196,6 +197,7 @@ export class G5RunStore {
|
|
|
196
197
|
createdAt: record.createdAt,
|
|
197
198
|
updatedAt: record.updatedAt,
|
|
198
199
|
headers: record.headers,
|
|
200
|
+
fieldSelection: record.fieldSelection,
|
|
199
201
|
summary: record.summary,
|
|
200
202
|
rows: record.rows,
|
|
201
203
|
reviewQueue: record.reviewQueue,
|
|
@@ -269,6 +271,7 @@ export class G5RunStore {
|
|
|
269
271
|
}, {
|
|
270
272
|
...options,
|
|
271
273
|
includeRisk: record.includeRisk,
|
|
274
|
+
fieldSelection: record.fieldSelection,
|
|
272
275
|
onAudit: (event) => this.appendAudit(record, event),
|
|
273
276
|
});
|
|
274
277
|
this.patchCompany(record, name, queued.rowIndexes, result);
|
|
@@ -303,6 +306,7 @@ export class G5RunStore {
|
|
|
303
306
|
result = await bridge.enrichCompany(item.companyName, {
|
|
304
307
|
...options,
|
|
305
308
|
includeRisk: record.includeRisk,
|
|
309
|
+
fieldSelection: record.fieldSelection,
|
|
306
310
|
onAudit: (event) => this.appendAudit(record, event),
|
|
307
311
|
});
|
|
308
312
|
} catch (error) {
|
package/lib/qcc.js
CHANGED
|
@@ -24,6 +24,7 @@ export const QCC_TOOL_NAMES = Object.freeze({
|
|
|
24
24
|
oauthStatus: 'qcc_oauth_status',
|
|
25
25
|
entityLookup: 'mcp__qcc-company__get_company_by_query',
|
|
26
26
|
registration: 'mcp__qcc-company__get_company_registration_info',
|
|
27
|
+
profile: 'mcp__qcc-company__get_company_profile',
|
|
27
28
|
riskScan: 'mcp__qcc-risk__get_company_risk_scan',
|
|
28
29
|
});
|
|
29
30
|
|
|
@@ -145,13 +146,31 @@ export class QccBridgeError extends Error {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
function messageFailureCode(message) {
|
|
150
|
+
const text = String(message ?? '').trim();
|
|
151
|
+
if (!text) return '';
|
|
152
|
+
const cases = [
|
|
153
|
+
[/only\s+[`'\"]?run_code|requires task-based execution|direct(?:ly)?[^.]{0,48}(?:not allowed|not callable|denied)/i, 'DSH_EXECUTION_DENIED'],
|
|
154
|
+
[/(?:^|\D)401(?:\D|$)|unauthori[sz]ed|auth(?:entication|orization)? required|invalid token|token expired/i, '401'],
|
|
155
|
+
[/(?:^|\D)403(?:\D|$)|forbidden|permission denied|resource not authorized/i, '403'],
|
|
156
|
+
[/(?:^|\D)429(?:\D|$)|rate[ -]?limit|too many requests/i, '429'],
|
|
157
|
+
[/quota[^.]{0,32}(?:exhausted|insufficient|limit)|insufficient quota/i, 'QUOTA_EXHAUSTED'],
|
|
158
|
+
[/unknown tool|tool unavailable|method not found|mcp error\s*-32601/i, 'UNKNOWN_TOOL'],
|
|
159
|
+
[/timed?\s*out|deadline exceeded/i, 'TIMEOUT'],
|
|
160
|
+
[/mcp error\s*-32602|invalid (?:argument|parameter)|validation error|bad request/i, 'INVALID_ARGUMENT'],
|
|
161
|
+
[/(?:^|\D)5\d\d(?:\D|$)|service unavailable|upstream unavailable|connection (?:error|failed)/i, 'UPSTREAM_UNAVAILABLE'],
|
|
162
|
+
];
|
|
163
|
+
return cases.find(([pattern]) => pattern.test(text))?.[1] ?? 'UNCLASSIFIED_TOOL_ERROR';
|
|
164
|
+
}
|
|
165
|
+
|
|
148
166
|
function upstreamFailureCode(result) {
|
|
149
|
-
|
|
167
|
+
const structured = String(
|
|
150
168
|
result?.error?.info?.code
|
|
151
169
|
?? result?.error?.info?.status
|
|
152
170
|
?? result?.error?.info?.httpStatus
|
|
153
171
|
?? '',
|
|
154
172
|
).trim().toUpperCase();
|
|
173
|
+
return structured || messageFailureCode(result?.error?.message);
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
function failureDetails(result) {
|
|
@@ -218,6 +237,13 @@ function normalizedFailure(result, toolName, state) {
|
|
|
218
237
|
connectRequired: true,
|
|
219
238
|
});
|
|
220
239
|
}
|
|
240
|
+
if (upstreamCode === 'DSH_EXECUTION_DENIED') {
|
|
241
|
+
return new QccBridgeError('QCC_EXECUTION_DENIED', 'DSH denied this programmatic QCC tool execution', {
|
|
242
|
+
toolName,
|
|
243
|
+
upstreamCode,
|
|
244
|
+
retryable: false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
221
247
|
if (/^(?:5\d\d|SERVICE_UNAVAILABLE|UPSTREAM_UNAVAILABLE|CONNECTION_ERROR)$/.test(upstreamCode)) {
|
|
222
248
|
return new QccBridgeError('QCC_UPSTREAM_UNAVAILABLE', 'QCC upstream service is temporarily unavailable', {
|
|
223
249
|
toolName,
|
|
@@ -291,18 +317,98 @@ export function mapRegistrationFields(value, fallback = {}) {
|
|
|
291
317
|
if (value.无匹配项 !== undefined) {
|
|
292
318
|
throw new QccBridgeError('QCC_ENTITY_NOT_FOUND', 'Locked QCC entity could not be resolved by registration tool');
|
|
293
319
|
}
|
|
320
|
+
const text = (...keys) => {
|
|
321
|
+
for (const key of keys) {
|
|
322
|
+
const item = value[key];
|
|
323
|
+
if (item !== undefined && item !== null && String(item).trim()) return String(item);
|
|
324
|
+
}
|
|
325
|
+
return '';
|
|
326
|
+
};
|
|
327
|
+
const region = text('所属地区', '所属地域', '地区');
|
|
328
|
+
const location = splitAdministrativeRegion(region);
|
|
329
|
+
return {
|
|
330
|
+
company_name: text('企业名称') || String(fallback.companyName ?? ''),
|
|
331
|
+
credit_no: text('统一社会信用代码', '信用代码') || String(fallback.creditNo ?? ''),
|
|
332
|
+
reg_no: text('工商注册号', '注册号'),
|
|
333
|
+
org_no: text('组织机构代码'),
|
|
334
|
+
reg_status: text('登记状态', '执业状态', '证书状态'),
|
|
335
|
+
legal_rep: text('法定代表人', '负责人', '经营者'),
|
|
336
|
+
reg_capital: text('注册资本', '注册资金', '开办资金', '成员出资总额', '资金数额'),
|
|
337
|
+
paid_capital: text('实缴资本'),
|
|
338
|
+
establish_date: text('成立日期'),
|
|
339
|
+
company_type: text('企业类型', '公司类型'),
|
|
340
|
+
registration_authority: text('登记机关'),
|
|
341
|
+
english_name: text('英文名', '英文名称'),
|
|
342
|
+
registered_address: text('注册地址', '住所', '经营场所'),
|
|
343
|
+
province: text('省份地区', '省份', '所属省份') || location.province,
|
|
344
|
+
city: text('城市', '所属城市', '市') || location.city,
|
|
345
|
+
district: text('区县', '所属区县') || location.district,
|
|
346
|
+
business_scope: text('经营范围'),
|
|
347
|
+
industry_category: text('国标行业'),
|
|
348
|
+
industry_large: text('一级行业', '企查查一级行业'),
|
|
349
|
+
industry_middle: text('二级行业', '企查查二级行业'),
|
|
350
|
+
operating_period: text('营业期限', '经营期限'),
|
|
351
|
+
company_size: text('企业规模', '人员规模'),
|
|
352
|
+
biz_status: text('经营状态'),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function splitAdministrativeRegion(value) {
|
|
357
|
+
const region = String(value ?? '').trim().replace(/\s+/g, '');
|
|
358
|
+
if (!region) return { province: '', city: '', district: '' };
|
|
359
|
+
const municipality = region.match(/^((?:北京|上海|天津|重庆)市)(.*)$/);
|
|
360
|
+
if (municipality) {
|
|
361
|
+
const district = municipality[2].match(/^(.+?(?:区|县))/)?.[1] ?? '';
|
|
362
|
+
return { province: municipality[1], city: municipality[1], district };
|
|
363
|
+
}
|
|
364
|
+
const provinceMatch = region.match(/^(.+?(?:省|自治区|特别行政区))/);
|
|
365
|
+
const province = provinceMatch?.[1] ?? '';
|
|
366
|
+
const remainder = province ? region.slice(province.length) : region;
|
|
367
|
+
const city = remainder.match(/^(.+?(?:市|自治州|地区|盟))/)?.[1] ?? '';
|
|
368
|
+
const districtRemainder = city ? remainder.slice(city.length) : remainder;
|
|
369
|
+
const district = districtRemainder.match(/^(.+?(?:区|县|旗|市))/)?.[1] ?? '';
|
|
370
|
+
return { province, city, district };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function mapProfileFields(value) {
|
|
374
|
+
if (!isRecord(value)) {
|
|
375
|
+
throw new QccBridgeError('QCC_CONTRACT_MISMATCH', 'QCC profile tool returned a non-object result');
|
|
376
|
+
}
|
|
377
|
+
if (value.无匹配项 !== undefined) return {};
|
|
294
378
|
return {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
),
|
|
300
|
-
establish_date: String(value.成立日期 ?? ''),
|
|
301
|
-
reg_status: String(value.登记状态 ?? value.执业状态 ?? value.证书状态 ?? ''),
|
|
302
|
-
biz_status: String(value.经营状态 ?? ''),
|
|
379
|
+
// “企查查行业”没有声明一/二级语义,不得猜测塞入层级列。
|
|
380
|
+
industry_large: String(value.一级行业 ?? value.企查查一级行业 ?? ''),
|
|
381
|
+
industry_middle: String(value.二级行业 ?? value.企查查二级行业 ?? ''),
|
|
382
|
+
company_profile: String(value.企业简介 ?? value.简介 ?? ''),
|
|
303
383
|
};
|
|
304
384
|
}
|
|
305
385
|
|
|
386
|
+
const LEGACY_ENRICHMENT_FIELDS = Object.freeze([
|
|
387
|
+
'credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status', 'biz_status',
|
|
388
|
+
]);
|
|
389
|
+
const PROFILE_FIELDS = new Set(['industry_large', 'industry_middle', 'company_profile']);
|
|
390
|
+
|
|
391
|
+
function requiresProfile(fieldSelection) {
|
|
392
|
+
return Array.isArray(fieldSelection) && fieldSelection.some((field) => PROFILE_FIELDS.has(field));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function mergeMappedFields(...sources) {
|
|
396
|
+
const output = {};
|
|
397
|
+
for (const source of sources) {
|
|
398
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
399
|
+
if (!Object.hasOwn(output, key) || (value !== '' && value !== null && value !== undefined)) output[key] = value;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return output;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function projectSelectedFields(fields, fieldSelection) {
|
|
406
|
+
const selected = Array.isArray(fieldSelection) && fieldSelection.length
|
|
407
|
+
? [...new Set(fieldSelection.map(String))]
|
|
408
|
+
: LEGACY_ENRICHMENT_FIELDS;
|
|
409
|
+
return Object.fromEntries(selected.map((field) => [field, fields[field] ?? '']));
|
|
410
|
+
}
|
|
411
|
+
|
|
306
412
|
export function mapRiskTags(value) {
|
|
307
413
|
if (!isRecord(value)) return '';
|
|
308
414
|
const rows = Array.isArray(value.风险因子扫描) ? value.风险因子扫描 : [];
|
|
@@ -374,6 +480,7 @@ export class QccHostBridge {
|
|
|
374
480
|
oauthStatus: this.has(QCC_TOOL_NAMES.oauthStatus),
|
|
375
481
|
entityLookup: this.has(QCC_TOOL_NAMES.entityLookup),
|
|
376
482
|
registration: this.has(QCC_TOOL_NAMES.registration),
|
|
483
|
+
profile: this.has(QCC_TOOL_NAMES.profile),
|
|
377
484
|
riskScan: this.has(QCC_TOOL_NAMES.riskScan),
|
|
378
485
|
};
|
|
379
486
|
const ready = capabilities.entityLookup && capabilities.registration;
|
|
@@ -507,11 +614,24 @@ export class QccHostBridge {
|
|
|
507
614
|
attemptAudited = true;
|
|
508
615
|
throw error;
|
|
509
616
|
}
|
|
617
|
+
const execution = options.execution;
|
|
618
|
+
if (execution && (!execution.agent || !execution.token)) {
|
|
619
|
+
throw new QccBridgeError(
|
|
620
|
+
'QCC_AGENT_EXECUTION_REQUIRED',
|
|
621
|
+
'Nested QCC calls require an Agent-owned DSH tool execution',
|
|
622
|
+
{ toolName: activeToolName },
|
|
623
|
+
);
|
|
624
|
+
}
|
|
510
625
|
const result = await this.tools.execute({
|
|
511
626
|
name: activeToolName,
|
|
512
627
|
callId,
|
|
513
628
|
signal: state.signal,
|
|
514
629
|
arguments: args,
|
|
630
|
+
...(execution ? {
|
|
631
|
+
rootCallId: execution.rootCallId,
|
|
632
|
+
parent: execution.token,
|
|
633
|
+
agent: execution.agent,
|
|
634
|
+
} : {}),
|
|
515
635
|
});
|
|
516
636
|
if (result?.isError !== true) {
|
|
517
637
|
emitAudit(options, {
|
|
@@ -607,12 +727,15 @@ export class QccHostBridge {
|
|
|
607
727
|
{ searchKey: lockedKey },
|
|
608
728
|
options,
|
|
609
729
|
);
|
|
610
|
-
|
|
730
|
+
let mapped = mapRegistrationFields(registration.data, match);
|
|
731
|
+
if (requiresProfile(options.fieldSelection)) {
|
|
732
|
+
const profile = await this.call(QCC_TOOL_NAMES.profile, { searchKey: lockedKey }, options);
|
|
733
|
+
mapped = mergeMappedFields(mapped, mapProfileFields(profile.data));
|
|
734
|
+
}
|
|
735
|
+
const fields = projectSelectedFields(mapped, options.fieldSelection);
|
|
611
736
|
if (options.includeRisk) {
|
|
612
737
|
const risk = await this.call(QCC_TOOL_NAMES.riskScan, { searchKey: lockedKey }, options);
|
|
613
738
|
fields.risk_tags = mapRiskTags(risk.data);
|
|
614
|
-
} else {
|
|
615
|
-
fields.risk_tags = '';
|
|
616
739
|
}
|
|
617
740
|
return {
|
|
618
741
|
status: 'enriched',
|
|
@@ -632,12 +755,15 @@ export class QccHostBridge {
|
|
|
632
755
|
{ searchKey: creditNo },
|
|
633
756
|
options,
|
|
634
757
|
);
|
|
635
|
-
|
|
758
|
+
let mapped = mapRegistrationFields(registration.data, { companyName, creditNo });
|
|
759
|
+
if (requiresProfile(options.fieldSelection)) {
|
|
760
|
+
const profile = await this.call(QCC_TOOL_NAMES.profile, { searchKey: creditNo }, options);
|
|
761
|
+
mapped = mergeMappedFields(mapped, mapProfileFields(profile.data));
|
|
762
|
+
}
|
|
763
|
+
const fields = projectSelectedFields(mapped, options.fieldSelection);
|
|
636
764
|
if (options.includeRisk) {
|
|
637
765
|
const risk = await this.call(QCC_TOOL_NAMES.riskScan, { searchKey: creditNo }, options);
|
|
638
766
|
fields.risk_tags = mapRiskTags(risk.data);
|
|
639
|
-
} else {
|
|
640
|
-
fields.risk_tags = '';
|
|
641
767
|
}
|
|
642
768
|
return { status: 'enriched', companyName, fields };
|
|
643
769
|
}
|
|
@@ -652,6 +778,7 @@ export class QccHostBridge {
|
|
|
652
778
|
}
|
|
653
779
|
|
|
654
780
|
const requiredTools = [QCC_TOOL_NAMES.entityLookup, QCC_TOOL_NAMES.registration];
|
|
781
|
+
if (requiresProfile(options.fieldSelection)) requiredTools.push(QCC_TOOL_NAMES.profile);
|
|
655
782
|
if (options.includeRisk) requiredTools.push(QCC_TOOL_NAMES.riskScan);
|
|
656
783
|
try {
|
|
657
784
|
await Promise.all(requiredTools.map((name) => this.waitForTool(name, {
|
package/lib/skill-enrich.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* - `enterprise-enrichment`:用企查查 MCP 工具按最新工商信息补全企业名单
|
|
7
7
|
* (依赖 `qcc-dsh-mcp-oauth` 已连接;本 Skill 不重造 OAuth)。
|
|
8
8
|
*
|
|
9
|
-
* 方案 A
|
|
9
|
+
* 方案 A(自由对话):模型亲自调用 `mcp__qcc-company__*` / `mcp__qcc-risk__*`
|
|
10
10
|
* / `mcp__qcc-ipr__*` / `mcp__qcc-operation__*` 完成消歧 → 工商详情 → 各域维度,
|
|
11
|
-
*
|
|
11
|
+
* 再组装结果。工作台批量路径则只调用 Agent-owned 高层工具,避免 Code Mode
|
|
12
|
+
* 拒绝 Web Host 的无父执行调用。
|
|
12
13
|
*/
|
|
13
14
|
import {
|
|
14
15
|
QCC_PHASE2_COMPANY_TOOLS,
|
|
@@ -53,6 +54,10 @@ export function registerEnrichSkill(skills) {
|
|
|
53
54
|
content: [
|
|
54
55
|
'You are an enterprise-list enrichment assistant. You fill company lists with Qichacha (QCC) data by calling QCC MCP tools. Never invent, pad, or fabricate any field.',
|
|
55
56
|
'',
|
|
57
|
+
'Typed workbench command (highest priority):',
|
|
58
|
+
'- If the visible user message contains a typed data-cleaning intent with `commandId` and explicitly requests `data_cleaning_qcc_run`, call that high-level tool exactly once with only `commandId`, then stop.',
|
|
59
|
+
'- The Host already holds the rows, billing confirmation and field selection. Do not ask the user to paste rows, do not call any `mcp__qcc-*` tool directly, do not retry, and do not expand the batch.',
|
|
60
|
+
'',
|
|
56
61
|
'Workflow:',
|
|
57
62
|
'1. Check QCC availability first: run `qcc_oauth_status`. If not connected, tell the user to run `qcc_oauth_connect` first and stop. If the token is expired, guide the user to `qcc_oauth_connect` (it reuses the grant and refreshes without a new authorization page).',
|
|
58
63
|
'2. Parse the company-name list from what the user gave (pasted text / CSV / JSON / inline list). Keep only the distinct company-name column.',
|
package/lib/tools.js
CHANGED
|
@@ -100,7 +100,7 @@ export function registerTools(tools) {
|
|
|
100
100
|
const r = completeRows(Array.isArray(args.rows) ? args.rows : []);
|
|
101
101
|
return {
|
|
102
102
|
total: r.total,
|
|
103
|
-
completed: r.
|
|
103
|
+
completed: r.completedCount,
|
|
104
104
|
incompleteCount: r.incompleteCount,
|
|
105
105
|
name: r.fillStats.name,
|
|
106
106
|
amount: r.fillStats.amount,
|