dsh-data-cleaning-agent 0.5.3 → 0.6.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/lib/engine.js CHANGED
@@ -221,7 +221,7 @@ export function completeRows(rows, opts = {}) {
221
221
 
222
222
  return {
223
223
  total: input.length,
224
- completed: completed.length,
224
+ completedCount: completed.length,
225
225
  incompleteCount: incomplete.length,
226
226
  fillStats,
227
227
  incomplete,
package/lib/index.js CHANGED
@@ -59,12 +59,17 @@ export function apply(ctx, config) {
59
59
 
60
60
  // 3. web 半区(仅 web 组合存在;headless 组合无 webServer/webRuntime,inject 会失败)
61
61
  try {
62
- ctx.inject(['webServer', 'webRuntime', 'tools', 'skills', 'jobs', 'storageDomain'], (wctx) => {
63
- const dispose = mountWebRoutes(wctx, { logger: ctx.logger, report, TOOL_NAME: TOOL_CLEAN, SKILL_NAME });
64
- if (typeof dispose === 'function' && typeof ctx.effect === 'function') {
65
- ctx.effect(() => () => dispose(), 'data-cleaning-agent: web routes');
62
+ ctx.inject(['webServer', 'webRuntime', 'tools', 'skills', 'jobs', 'storageDomain', 'fs'], (wctx) => {
63
+ try {
64
+ const dispose = mountWebRoutes(wctx, { logger: ctx.logger, report, TOOL_NAME: TOOL_CLEAN, SKILL_NAME });
65
+ if (typeof dispose === 'function' && typeof ctx.effect === 'function') {
66
+ ctx.effect(() => () => dispose(), 'data-cleaning-agent: web routes');
67
+ }
68
+ report.webMounted = true;
69
+ } catch (error) {
70
+ report.webSkipped = true;
71
+ console.warn(`[dc-agent] web half failed during deferred mount: ${error instanceof Error ? error.stack : String(error)}`);
66
72
  }
67
- report.webMounted = true;
68
73
  });
69
74
  } catch (error) {
70
75
  report.webSkipped = true;
package/lib/jobs.js CHANGED
@@ -35,7 +35,7 @@ export function runSync(kind, rows, opts = {}) {
35
35
  }
36
36
  case 'complete': {
37
37
  const r = completeRows(rows, opts);
38
- return { kind, summary: { total: r.total, completed: r.completed, incompleteCount: r.incompleteCount, name: r.fillStats.name, amount: r.fillStats.amount, phoneNormalized: r.fillStats.phoneNormalized }, rows: r.completed, headers: opts.headers ?? [] };
38
+ return { kind, summary: { total: r.total, completed: r.completedCount, incompleteCount: r.incompleteCount, name: r.fillStats.name, amount: r.fillStats.amount, phoneNormalized: r.fillStats.phoneNormalized }, rows: r.completed, headers: opts.headers ?? [] };
39
39
  }
40
40
  case 'profile': {
41
41
  const r = profileRows(rows, opts);
@@ -0,0 +1,276 @@
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
+
12
+ export const TOOL_QCC_COMMAND = 'data_cleaning_qcc_run';
13
+
14
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
15
+ const DEFAULT_MAX_COMMANDS = 50;
16
+
17
+ function clone(value) {
18
+ return structuredClone(value);
19
+ }
20
+
21
+ function safeError(error) {
22
+ if (error instanceof QccBridgeError) return error.toJSON();
23
+ return new QccBridgeError('QCC_COMMAND_FAILED', 'Data-cleaning QCC command failed', {
24
+ retryable: false,
25
+ }).toJSON();
26
+ }
27
+
28
+ function requiredText(value, code, message) {
29
+ const text = String(value ?? '').trim();
30
+ if (!text) throw new QccBridgeError(code, message);
31
+ return text;
32
+ }
33
+
34
+ function normalizedInput(input = {}) {
35
+ const kind = String(input.kind ?? 'enrich');
36
+ if (!['enrich', 'resolve', 'retry'].includes(kind)) {
37
+ throw new QccBridgeError('QCC_COMMAND_KIND_INVALID', 'Unsupported data-cleaning QCC command kind');
38
+ }
39
+ const taskId = requiredText(input.taskId, 'QCC_COMMAND_TASK_REQUIRED', 'A workflow taskId is required');
40
+ if (kind === 'enrich') {
41
+ const rows = Array.isArray(input.rows) ? input.rows : [];
42
+ if (rows.length === 0) throw new QccBridgeError('QCC_INVALID_ROWS', 'At least one row is required');
43
+ if (rows.length > 100) throw new QccBridgeError('QCC_BATCH_TOO_LARGE', 'QCC batch exceeds 100 rows');
44
+ return {
45
+ kind,
46
+ taskId,
47
+ rows: clone(rows),
48
+ headers: Array.isArray(input.headers) ? input.headers.map(String) : [],
49
+ nameField: String(input.nameField ?? 'name'),
50
+ includeRisk: input.includeRisk === true,
51
+ concurrency: Math.min(4, Math.max(1, Math.trunc(Number(input.concurrency ?? 2)))),
52
+ };
53
+ }
54
+ const runId = requiredText(input.runId, 'QCC_RUN_NOT_FOUND', 'A G5 runId is required');
55
+ if (kind === 'resolve') {
56
+ return {
57
+ kind,
58
+ taskId,
59
+ runId,
60
+ companyName: requiredText(input.companyName, 'QCC_REVIEW_NOT_PENDING', 'A company name is required'),
61
+ selectedCreditNo: requiredText(input.selectedCreditNo, 'QCC_CANDIDATE_INVALID', 'A selected credit number is required'),
62
+ };
63
+ }
64
+ const companyNames = [...new Set((Array.isArray(input.companyNames) ? input.companyNames : [])
65
+ .map((name) => String(name).trim()).filter(Boolean))];
66
+ if (companyNames.length === 0) throw new QccBridgeError('QCC_RETRY_EMPTY', 'At least one failed company must be selected');
67
+ return { kind, taskId, runId, companyNames };
68
+ }
69
+
70
+ export function serializeQccCommandPrompt(command) {
71
+ const visible = {
72
+ schemaVersion: 1,
73
+ commandId: command.commandId,
74
+ taskId: command.taskId,
75
+ kind: command.kind,
76
+ };
77
+ return [
78
+ '请执行数据清洗补全企查查任务。',
79
+ '',
80
+ '类型化任务意图(schemaVersion 1):',
81
+ '```json',
82
+ JSON.stringify(visible, null, 2),
83
+ '```',
84
+ '',
85
+ `请准确调用一次 ${TOOL_QCC_COMMAND} 并只传递 commandId。`,
86
+ '企业名单和字段选择已安全暂存在本机 Host,不得要求用户在对话中重复粘贴,不得直接调用任何 mcp__qcc-* 工具。',
87
+ '高层工具返回后立即结束本轮;不得重试、扩大名单或追加字段。',
88
+ ].join('\n');
89
+ }
90
+
91
+ export class QccCommandStore {
92
+ constructor({ bridge, runs, clock = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxCommands = DEFAULT_MAX_COMMANDS } = {}) {
93
+ if (!bridge || !runs) throw new TypeError('QccCommandStore requires bridge and runs');
94
+ this.bridge = bridge;
95
+ this.runs = runs;
96
+ this.clock = clock;
97
+ this.ttlMs = ttlMs;
98
+ this.maxCommands = maxCommands;
99
+ this.commands = new Map();
100
+ }
101
+
102
+ cleanup(reserveSlot = false) {
103
+ const cutoff = this.clock() - this.ttlMs;
104
+ for (const [id, command] of this.commands) {
105
+ if (command.state !== 'running' && command.touchedAtMs < cutoff) this.commands.delete(id);
106
+ }
107
+ const limit = reserveSlot ? this.maxCommands - 1 : this.maxCommands;
108
+ while (this.commands.size > limit) {
109
+ const removable = [...this.commands].find(([, command]) => command.state !== 'running');
110
+ if (!removable) throw new QccBridgeError('QCC_COMMAND_CAPACITY', 'QCC command queue is full', { retryable: true });
111
+ this.commands.delete(removable[0]);
112
+ }
113
+ }
114
+
115
+ prepare(input) {
116
+ this.cleanup(true);
117
+ const normalized = normalizedInput(input);
118
+ const commandId = `dcq-${randomUUID()}`;
119
+ const at = new Date(this.clock()).toISOString();
120
+ const record = {
121
+ commandId,
122
+ taskId: normalized.taskId,
123
+ kind: normalized.kind,
124
+ state: 'prepared',
125
+ createdAt: at,
126
+ updatedAt: at,
127
+ touchedAtMs: this.clock(),
128
+ input: normalized,
129
+ runId: null,
130
+ error: null,
131
+ promise: null,
132
+ };
133
+ this.commands.set(commandId, record);
134
+ return { ...this.publicRecord(record), prompt: serializeQccCommandPrompt(record) };
135
+ }
136
+
137
+ require(commandId) {
138
+ this.cleanup();
139
+ const record = this.commands.get(String(commandId ?? ''));
140
+ if (!record) throw new QccBridgeError('QCC_COMMAND_NOT_FOUND', 'QCC command was not found or expired');
141
+ record.touchedAtMs = this.clock();
142
+ return record;
143
+ }
144
+
145
+ publicRecord(record) {
146
+ return clone({
147
+ commandId: record.commandId,
148
+ taskId: record.taskId,
149
+ kind: record.kind,
150
+ state: record.state,
151
+ createdAt: record.createdAt,
152
+ updatedAt: record.updatedAt,
153
+ runId: record.runId,
154
+ error: record.error,
155
+ expiresInMs: this.ttlMs,
156
+ });
157
+ }
158
+
159
+ status(commandId) {
160
+ const record = this.require(commandId);
161
+ const output = this.publicRecord(record);
162
+ if (record.runId) output.run = this.runs.get(record.runId);
163
+ return output;
164
+ }
165
+
166
+ async run(commandId, execution) {
167
+ const record = this.require(commandId);
168
+ if (!execution?.agent || !execution?.token) {
169
+ throw new QccBridgeError('QCC_AGENT_EXECUTION_REQUIRED', 'QCC commands require an Agent-owned DSH tool execution');
170
+ }
171
+ if (record.promise) return record.promise;
172
+ record.state = 'running';
173
+ record.updatedAt = new Date(this.clock()).toISOString();
174
+ record.promise = this.execute(record, execution)
175
+ .then((run) => {
176
+ record.runId = run.runId;
177
+ record.state = 'completed';
178
+ record.updatedAt = new Date(this.clock()).toISOString();
179
+ record.touchedAtMs = this.clock();
180
+ return this.toolResult(record, run);
181
+ })
182
+ .catch((error) => {
183
+ record.error = safeError(error);
184
+ record.state = 'failed';
185
+ record.updatedAt = new Date(this.clock()).toISOString();
186
+ record.touchedAtMs = this.clock();
187
+ throw error;
188
+ });
189
+ return record.promise;
190
+ }
191
+
192
+ async execute(record, execution) {
193
+ const options = { execution };
194
+ if (record.kind === 'resolve') {
195
+ return this.runs.resolveCandidate(record.input.runId, record.input, this.bridge, options);
196
+ }
197
+ if (record.kind === 'retry') {
198
+ return this.runs.retryCompanies(record.input.runId, record.input.companyNames, this.bridge, options);
199
+ }
200
+ const audit = [];
201
+ const result = await this.bridge.enrichRows(record.input.rows, {
202
+ nameField: record.input.nameField,
203
+ includeRisk: record.input.includeRisk,
204
+ concurrency: record.input.concurrency,
205
+ maxRows: 100,
206
+ execution,
207
+ onAudit: (event) => audit.push(event),
208
+ });
209
+ return this.runs.createRun({
210
+ headers: record.input.headers,
211
+ nameField: record.input.nameField,
212
+ includeRisk: record.input.includeRisk,
213
+ concurrency: record.input.concurrency,
214
+ result,
215
+ audit,
216
+ });
217
+ }
218
+
219
+ toolResult(record, run) {
220
+ return clone({
221
+ commandId: record.commandId,
222
+ taskId: record.taskId,
223
+ runId: run.runId,
224
+ state: run.state,
225
+ summary: run.summary,
226
+ });
227
+ }
228
+ }
229
+
230
+ export function registerQccCommandTool(tools, commands) {
231
+ return tools.register({
232
+ name: TOOL_QCC_COMMAND,
233
+ 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.',
234
+ parameters: {
235
+ type: 'object',
236
+ additionalProperties: false,
237
+ properties: { commandId: { type: 'string' } },
238
+ required: ['commandId'],
239
+ },
240
+ output: {
241
+ schema: {
242
+ type: 'object',
243
+ additionalProperties: false,
244
+ properties: {
245
+ commandId: { type: 'string' },
246
+ taskId: { type: 'string' },
247
+ runId: { type: 'string' },
248
+ state: { type: 'string' },
249
+ summary: {
250
+ type: 'object',
251
+ additionalProperties: false,
252
+ properties: {
253
+ totalRows: { type: 'integer' },
254
+ uniqueCompanies: { type: 'integer' },
255
+ enriched: { type: 'integer' },
256
+ ambiguous: { type: 'integer' },
257
+ unresolved: { type: 'integer' },
258
+ failed: { type: 'integer' },
259
+ missingName: { type: 'integer' },
260
+ includeRisk: { type: 'boolean' },
261
+ },
262
+ required: ['totalRows', 'uniqueCompanies', 'enriched', 'ambiguous', 'unresolved', 'failed', 'missingName', 'includeRisk'],
263
+ },
264
+ },
265
+ required: ['commandId', 'taskId', 'runId', 'state', 'summary'],
266
+ },
267
+ render: (_args, value) => [{
268
+ type: 'text',
269
+ text: `数据清洗补全企查查任务已完成:${value.summary?.enriched ?? 0}/${value.summary?.totalRows ?? 0} 条已补全,状态 ${value.state}。`,
270
+ }],
271
+ },
272
+ async execute(args, exec) {
273
+ return commands.run(args.commandId, exec);
274
+ },
275
+ });
276
+ }
package/lib/qcc.js CHANGED
@@ -145,13 +145,31 @@ export class QccBridgeError extends Error {
145
145
  }
146
146
  }
147
147
 
148
+ function messageFailureCode(message) {
149
+ const text = String(message ?? '').trim();
150
+ if (!text) return '';
151
+ const cases = [
152
+ [/only\s+[`'\"]?run_code|requires task-based execution|direct(?:ly)?[^.]{0,48}(?:not allowed|not callable|denied)/i, 'DSH_EXECUTION_DENIED'],
153
+ [/(?:^|\D)401(?:\D|$)|unauthori[sz]ed|auth(?:entication|orization)? required|invalid token|token expired/i, '401'],
154
+ [/(?:^|\D)403(?:\D|$)|forbidden|permission denied|resource not authorized/i, '403'],
155
+ [/(?:^|\D)429(?:\D|$)|rate[ -]?limit|too many requests/i, '429'],
156
+ [/quota[^.]{0,32}(?:exhausted|insufficient|limit)|insufficient quota/i, 'QUOTA_EXHAUSTED'],
157
+ [/unknown tool|tool unavailable|method not found|mcp error\s*-32601/i, 'UNKNOWN_TOOL'],
158
+ [/timed?\s*out|deadline exceeded/i, 'TIMEOUT'],
159
+ [/mcp error\s*-32602|invalid (?:argument|parameter)|validation error|bad request/i, 'INVALID_ARGUMENT'],
160
+ [/(?:^|\D)5\d\d(?:\D|$)|service unavailable|upstream unavailable|connection (?:error|failed)/i, 'UPSTREAM_UNAVAILABLE'],
161
+ ];
162
+ return cases.find(([pattern]) => pattern.test(text))?.[1] ?? 'UNCLASSIFIED_TOOL_ERROR';
163
+ }
164
+
148
165
  function upstreamFailureCode(result) {
149
- return String(
166
+ const structured = String(
150
167
  result?.error?.info?.code
151
168
  ?? result?.error?.info?.status
152
169
  ?? result?.error?.info?.httpStatus
153
170
  ?? '',
154
171
  ).trim().toUpperCase();
172
+ return structured || messageFailureCode(result?.error?.message);
155
173
  }
156
174
 
157
175
  function failureDetails(result) {
@@ -218,6 +236,13 @@ function normalizedFailure(result, toolName, state) {
218
236
  connectRequired: true,
219
237
  });
220
238
  }
239
+ if (upstreamCode === 'DSH_EXECUTION_DENIED') {
240
+ return new QccBridgeError('QCC_EXECUTION_DENIED', 'DSH denied this programmatic QCC tool execution', {
241
+ toolName,
242
+ upstreamCode,
243
+ retryable: false,
244
+ });
245
+ }
221
246
  if (/^(?:5\d\d|SERVICE_UNAVAILABLE|UPSTREAM_UNAVAILABLE|CONNECTION_ERROR)$/.test(upstreamCode)) {
222
247
  return new QccBridgeError('QCC_UPSTREAM_UNAVAILABLE', 'QCC upstream service is temporarily unavailable', {
223
248
  toolName,
@@ -507,11 +532,24 @@ export class QccHostBridge {
507
532
  attemptAudited = true;
508
533
  throw error;
509
534
  }
535
+ const execution = options.execution;
536
+ if (execution && (!execution.agent || !execution.token)) {
537
+ throw new QccBridgeError(
538
+ 'QCC_AGENT_EXECUTION_REQUIRED',
539
+ 'Nested QCC calls require an Agent-owned DSH tool execution',
540
+ { toolName: activeToolName },
541
+ );
542
+ }
510
543
  const result = await this.tools.execute({
511
544
  name: activeToolName,
512
545
  callId,
513
546
  signal: state.signal,
514
547
  arguments: args,
548
+ ...(execution ? {
549
+ rootCallId: execution.rootCallId,
550
+ parent: execution.token,
551
+ agent: execution.agent,
552
+ } : {}),
515
553
  });
516
554
  if (result?.isError !== true) {
517
555
  emitAudit(options, {
@@ -6,9 +6,10 @@
6
6
  * - `enterprise-enrichment`:用企查查 MCP 工具按最新工商信息补全企业名单
7
7
  * (依赖 `qcc-dsh-mcp-oauth` 已连接;本 Skill 不重造 OAuth)。
8
8
  *
9
- * 方案 A(模型中介式):模型亲自调用 `mcp__qcc-company__*` / `mcp__qcc-risk__*`
9
+ * 方案 A(自由对话):模型亲自调用 `mcp__qcc-company__*` / `mcp__qcc-risk__*`
10
10
  * / `mcp__qcc-ipr__*` / `mcp__qcc-operation__*` 完成消歧 → 工商详情 → 各域维度,
11
- * 再组装结果。本插件零后端改动(Host Bridge 批量是独立于本 Skill 的方案 B)。
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.completed,
103
+ completed: r.completedCount,
104
104
  incompleteCount: r.incompleteCount,
105
105
  name: r.fillStats.name,
106
106
  amount: r.fillStats.amount,