dsh-data-cleaning-agent 0.2.1 → 0.4.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.
- package/CHANGELOG.md +44 -0
- package/README.en.md +21 -7
- package/README.md +23 -6
- package/docs/COMPATIBILITY.md +31 -4
- package/docs/G5-E2E-RUNBOOK.md +110 -0
- package/docs/G5-HOST-BRIDGE.md +136 -0
- package/docs/PHASE2-ACCEPTANCE.md +133 -0
- package/docs/QCC-ENRICHMENT-DESIGN.md +241 -0
- package/docs/QCC-PHASES-ROADMAP.md +254 -0
- package/docs/RELEASE-0.4.0.md +57 -0
- package/docs/USER-GUIDE.md +53 -1
- package/lib/index.js +5 -0
- package/lib/qcc-phase2-acceptance.js +191 -0
- package/lib/qcc-phase2.js +99 -0
- package/lib/qcc-runs.js +322 -0
- package/lib/qcc-safety.js +77 -0
- package/lib/qcc.js +702 -0
- package/lib/skill-enrich.js +63 -0
- package/lib/web.js +189 -1
- package/package.json +12 -3
package/lib/qcc.js
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G5 Host Bridge:通过 DSH 公共 ToolRuntime 程序化调用 QCC MCP 工具。
|
|
3
|
+
*
|
|
4
|
+
* 安全与兼容边界:
|
|
5
|
+
* - 只允许 qcc_oauth_* 与 mcp__qcc-*__*;不能把它变成任意工具代理。
|
|
6
|
+
* - 每次调用都重新 ctx.tools.get(),不缓存 ToolDefinition / mcp-client 内部对象。
|
|
7
|
+
* - 原样转发 AbortSignal,统一超时、取消和 ToolRuntime isError 结果。
|
|
8
|
+
* - 批量补全只在 Host/Web 内处理明细;模型仍只接收摘要。
|
|
9
|
+
*/
|
|
10
|
+
import { safeAuditEvent } from './qcc-safety.js';
|
|
11
|
+
import {
|
|
12
|
+
QCC_PHASE2_COMPANY_TOOLS,
|
|
13
|
+
QCC_PHASE2_HISTORY_TOOLS,
|
|
14
|
+
qccToolRuntimeCandidates,
|
|
15
|
+
} from './qcc-phase2.js';
|
|
16
|
+
|
|
17
|
+
export const QCC_TOOL_NAMES = Object.freeze({
|
|
18
|
+
oauthConnect: 'qcc_oauth_connect',
|
|
19
|
+
oauthStatus: 'qcc_oauth_status',
|
|
20
|
+
entityLookup: 'mcp__qcc-company__get_company_by_query',
|
|
21
|
+
registration: 'mcp__qcc-company__get_company_registration_info',
|
|
22
|
+
riskScan: 'mcp__qcc-risk__get_company_risk_scan',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const EXACT_MATCH = '唯一精确匹配';
|
|
26
|
+
const MULTI_MATCH = '多候选';
|
|
27
|
+
const NO_MATCH = '未匹配';
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
29
|
+
const DEFAULT_TOOL_WAIT_MS = 1_500;
|
|
30
|
+
const DEFAULT_POLL_MS = 50;
|
|
31
|
+
const DEFAULT_MAX_ROWS = 100;
|
|
32
|
+
const MAX_CONCURRENCY = 4;
|
|
33
|
+
|
|
34
|
+
function isRecord(value) {
|
|
35
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function textBlocks(content) {
|
|
39
|
+
return Array.isArray(content)
|
|
40
|
+
? content.filter((block) => block?.type === 'text').map((block) => String(block.text ?? ''))
|
|
41
|
+
: [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tryJson(text) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 把 MCP CallToolResult 或结构化输出归一为业务 JSON。 */
|
|
53
|
+
export function decodeQccToolValue(value) {
|
|
54
|
+
if (value?.structuredContent !== undefined) return value.structuredContent;
|
|
55
|
+
|
|
56
|
+
const texts = textBlocks(value?.content);
|
|
57
|
+
if (texts.length === 1) {
|
|
58
|
+
const parsed = tryJson(texts[0]);
|
|
59
|
+
return parsed === undefined ? texts[0] : parsed;
|
|
60
|
+
}
|
|
61
|
+
if (texts.length > 1) {
|
|
62
|
+
const parsed = texts.map(tryJson);
|
|
63
|
+
return parsed.every((item) => item !== undefined) ? parsed : texts.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isAllowedQccTool(name) {
|
|
70
|
+
return /^qcc_oauth_[a-z0-9_]+$/.test(name)
|
|
71
|
+
|| /^mcp__qcc-[a-z0-9-]+__[a-z0-9_]+$/.test(name)
|
|
72
|
+
|| /^mcp__(?:company|risk|ipr|operation|history|executive)__[a-z0-9_]+$/.test(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function delay(ms, signal) {
|
|
76
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('aborted'));
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const cleanup = () => signal?.removeEventListener('abort', onAbort);
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
cleanup();
|
|
81
|
+
resolve();
|
|
82
|
+
}, ms);
|
|
83
|
+
const onAbort = () => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
cleanup();
|
|
86
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
87
|
+
};
|
|
88
|
+
// 该 Promise 被业务流程显式 await,定时器必须保持事件循环存活。
|
|
89
|
+
// Node 22 下 unref 会让程序在重注册等待期间提前退出。
|
|
90
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function linkedSignal(parent, timeoutMs) {
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
let timedOut = false;
|
|
97
|
+
const onAbort = () => controller.abort(parent?.reason ?? new Error('aborted'));
|
|
98
|
+
if (parent?.aborted) onAbort();
|
|
99
|
+
else parent?.addEventListener('abort', onAbort, { once: true });
|
|
100
|
+
|
|
101
|
+
const timer = Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
102
|
+
? setTimeout(() => {
|
|
103
|
+
timedOut = true;
|
|
104
|
+
controller.abort(new Error(`QCC tool timed out after ${timeoutMs}ms`));
|
|
105
|
+
}, timeoutMs)
|
|
106
|
+
: null;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
timedOut: () => timedOut,
|
|
111
|
+
cleanup() {
|
|
112
|
+
if (timer) clearTimeout(timer);
|
|
113
|
+
parent?.removeEventListener('abort', onAbort);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class QccBridgeError extends Error {
|
|
119
|
+
constructor(code, message, options = {}) {
|
|
120
|
+
super(message, options.cause ? { cause: options.cause } : undefined);
|
|
121
|
+
this.name = 'QccBridgeError';
|
|
122
|
+
this.code = code;
|
|
123
|
+
this.toolName = options.toolName ?? null;
|
|
124
|
+
this.upstreamCode = options.upstreamCode ?? null;
|
|
125
|
+
this.retryable = Boolean(options.retryable);
|
|
126
|
+
this.connectRequired = Boolean(options.connectRequired);
|
|
127
|
+
this.details = options.details ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
toJSON() {
|
|
131
|
+
return {
|
|
132
|
+
code: this.code,
|
|
133
|
+
message: this.message,
|
|
134
|
+
toolName: this.toolName,
|
|
135
|
+
upstreamCode: this.upstreamCode,
|
|
136
|
+
retryable: this.retryable,
|
|
137
|
+
connectRequired: this.connectRequired,
|
|
138
|
+
details: this.details,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function upstreamFailureCode(result) {
|
|
144
|
+
return String(
|
|
145
|
+
result?.error?.info?.code
|
|
146
|
+
?? result?.error?.info?.status
|
|
147
|
+
?? result?.error?.info?.httpStatus
|
|
148
|
+
?? '',
|
|
149
|
+
).trim().toUpperCase();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function failureDetails(result) {
|
|
153
|
+
const retryAfter = Number(result?.error?.info?.retryAfterMs ?? result?.error?.info?.retry_after_ms);
|
|
154
|
+
return Number.isFinite(retryAfter) && retryAfter >= 0 ? { retryAfterMs: retryAfter } : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizedFailure(result, toolName, state) {
|
|
158
|
+
const upstreamCode = upstreamFailureCode(result);
|
|
159
|
+
if (state.timedOut()) {
|
|
160
|
+
return new QccBridgeError('QCC_TIMEOUT', `QCC tool timed out: ${toolName}`, {
|
|
161
|
+
toolName,
|
|
162
|
+
upstreamCode: upstreamCode || null,
|
|
163
|
+
retryable: true,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (state.parentSignal?.aborted || upstreamCode === 'ABORTED' || upstreamCode === 'ABORTED_BEFORE_DISPATCH') {
|
|
167
|
+
return new QccBridgeError('QCC_ABORTED', `QCC tool call was cancelled: ${toolName}`, {
|
|
168
|
+
toolName,
|
|
169
|
+
upstreamCode: upstreamCode || null,
|
|
170
|
+
retryable: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (/^(?:401|UNAUTHORIZED|AUTH_REQUIRED|INVALID_TOKEN|TOKEN_EXPIRED)$/.test(upstreamCode)) {
|
|
174
|
+
return new QccBridgeError('QCC_AUTH_REQUIRED', 'QCC authorization is missing or expired; reconnect before retrying', {
|
|
175
|
+
toolName,
|
|
176
|
+
upstreamCode,
|
|
177
|
+
retryable: true,
|
|
178
|
+
connectRequired: true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (/^(?:403|FORBIDDEN|PERMISSION_DENIED|RESOURCE_NOT_AUTHORIZED)$/.test(upstreamCode)) {
|
|
182
|
+
return new QccBridgeError('QCC_PERMISSION_DENIED', 'The connected QCC account is not authorized for this resource', {
|
|
183
|
+
toolName,
|
|
184
|
+
upstreamCode,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (/^(?:429|RATE_LIMITED|TOO_MANY_REQUESTS)$/.test(upstreamCode)) {
|
|
188
|
+
return new QccBridgeError('QCC_RATE_LIMITED', 'QCC rate limit reached; retry manually after the indicated delay', {
|
|
189
|
+
toolName,
|
|
190
|
+
upstreamCode,
|
|
191
|
+
retryable: true,
|
|
192
|
+
details: failureDetails(result),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (/^(?:QUOTA_EXHAUSTED|INSUFFICIENT_QUOTA|QUOTA_LIMIT)$/.test(upstreamCode)) {
|
|
196
|
+
return new QccBridgeError('QCC_QUOTA_EXHAUSTED', 'QCC quota is exhausted; increase quota before retrying', {
|
|
197
|
+
toolName,
|
|
198
|
+
upstreamCode,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (/^(?:TOOL_TIMEOUT|TIMEOUT|DEADLINE_EXCEEDED)$/.test(upstreamCode)) {
|
|
202
|
+
return new QccBridgeError('QCC_TIMEOUT', `QCC tool timed out: ${toolName}`, {
|
|
203
|
+
toolName,
|
|
204
|
+
upstreamCode,
|
|
205
|
+
retryable: true,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (/^(?:UNKNOWN_TOOL|TOOL_UNAVAILABLE)$/.test(upstreamCode)) {
|
|
209
|
+
return new QccBridgeError('QCC_TOOL_UNAVAILABLE', 'QCC tool disappeared during OAuth refresh; retry manually when ready', {
|
|
210
|
+
toolName,
|
|
211
|
+
upstreamCode,
|
|
212
|
+
retryable: true,
|
|
213
|
+
connectRequired: true,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (/^(?:5\d\d|SERVICE_UNAVAILABLE|UPSTREAM_UNAVAILABLE|CONNECTION_ERROR)$/.test(upstreamCode)) {
|
|
217
|
+
return new QccBridgeError('QCC_UPSTREAM_UNAVAILABLE', 'QCC upstream service is temporarily unavailable', {
|
|
218
|
+
toolName,
|
|
219
|
+
upstreamCode,
|
|
220
|
+
retryable: true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (/^(?:400|BAD_REQUEST|INVALID_ARGUMENT|VALIDATION_ERROR)$/.test(upstreamCode)) {
|
|
224
|
+
return new QccBridgeError('QCC_UPSTREAM_REJECTED', 'QCC rejected the tool request contract', {
|
|
225
|
+
toolName,
|
|
226
|
+
upstreamCode,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return new QccBridgeError('QCC_TOOL_FAILED', `QCC tool failed: ${toolName}`, {
|
|
230
|
+
toolName,
|
|
231
|
+
upstreamCode: upstreamCode || null,
|
|
232
|
+
retryable: false,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function emitAudit(options, event) {
|
|
237
|
+
try {
|
|
238
|
+
options?.onAudit?.(safeAuditEvent(event));
|
|
239
|
+
} catch {
|
|
240
|
+
// 审计 sink 失败不能改变计费工具调用的业务结果。
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function candidateView(candidate) {
|
|
245
|
+
const item = isRecord(candidate) ? candidate : {};
|
|
246
|
+
return {
|
|
247
|
+
companyName: String(item.企业名称 ?? ''),
|
|
248
|
+
creditNo: String(item.统一社会信用代码 ?? ''),
|
|
249
|
+
establishDate: String(item.成立日期 ?? ''),
|
|
250
|
+
legalRep: Array.isArray(item.法定代表人名称)
|
|
251
|
+
? item.法定代表人名称.map(String)
|
|
252
|
+
: item.法定代表人名称 ? [String(item.法定代表人名称)] : [],
|
|
253
|
+
status: String(item.状态 ?? ''),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function classifyEntityMatch(value) {
|
|
258
|
+
if (!isRecord(value)) {
|
|
259
|
+
throw new QccBridgeError('QCC_CONTRACT_MISMATCH', 'QCC entity lookup returned a non-object result');
|
|
260
|
+
}
|
|
261
|
+
const kind = String(value.匹配结果 ?? '');
|
|
262
|
+
if (kind === EXACT_MATCH) {
|
|
263
|
+
const entity = isRecord(value.企业信息) ? value.企业信息 : {};
|
|
264
|
+
const companyName = String(entity.企业名称 ?? '').trim();
|
|
265
|
+
const creditNo = String(entity.统一社会信用代码 ?? '').trim();
|
|
266
|
+
if (!companyName && !creditNo) {
|
|
267
|
+
throw new QccBridgeError('QCC_CONTRACT_MISMATCH', 'QCC exact match omitted entity identity');
|
|
268
|
+
}
|
|
269
|
+
return { status: 'exact', companyName, creditNo };
|
|
270
|
+
}
|
|
271
|
+
if (kind === MULTI_MATCH) {
|
|
272
|
+
const candidates = Array.isArray(value.企业信息) ? value.企业信息.map(candidateView) : [];
|
|
273
|
+
if (candidates.length === 0) {
|
|
274
|
+
throw new QccBridgeError('QCC_CONTRACT_MISMATCH', 'QCC multi-match result omitted candidates');
|
|
275
|
+
}
|
|
276
|
+
return { status: 'ambiguous', candidates };
|
|
277
|
+
}
|
|
278
|
+
if (kind === NO_MATCH || value.无匹配项 !== undefined) return { status: 'unresolved' };
|
|
279
|
+
throw new QccBridgeError('QCC_CONTRACT_MISMATCH', `Unknown QCC match result: ${kind || '(empty)'}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function mapRegistrationFields(value, fallback = {}) {
|
|
283
|
+
if (!isRecord(value)) {
|
|
284
|
+
throw new QccBridgeError('QCC_CONTRACT_MISMATCH', 'QCC registration tool returned a non-object result');
|
|
285
|
+
}
|
|
286
|
+
if (value.无匹配项 !== undefined) {
|
|
287
|
+
throw new QccBridgeError('QCC_ENTITY_NOT_FOUND', 'Locked QCC entity could not be resolved by registration tool');
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
credit_no: String(value.统一社会信用代码 ?? fallback.creditNo ?? ''),
|
|
291
|
+
legal_rep: String(value.法定代表人 ?? value.负责人 ?? value.经营者 ?? ''),
|
|
292
|
+
reg_capital: String(
|
|
293
|
+
value.注册资本 ?? value.注册资金 ?? value.开办资金 ?? value.成员出资总额 ?? value.资金数额 ?? '',
|
|
294
|
+
),
|
|
295
|
+
establish_date: String(value.成立日期 ?? ''),
|
|
296
|
+
reg_status: String(value.登记状态 ?? value.执业状态 ?? value.证书状态 ?? ''),
|
|
297
|
+
biz_status: String(value.经营状态 ?? ''),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function mapRiskTags(value) {
|
|
302
|
+
if (!isRecord(value)) return '';
|
|
303
|
+
const rows = Array.isArray(value.风险因子扫描) ? value.风险因子扫描 : [];
|
|
304
|
+
return rows
|
|
305
|
+
.filter((row) => Number(row?.条目数) > 0)
|
|
306
|
+
.map((row) => `${String(row?.风险因子 ?? '')}:${String(row?.条目数 ?? '')}`)
|
|
307
|
+
.filter((item) => !item.startsWith(':'))
|
|
308
|
+
.join(';');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function mapConcurrent(items, concurrency, worker) {
|
|
312
|
+
const output = new Array(items.length);
|
|
313
|
+
let cursor = 0;
|
|
314
|
+
const run = async () => {
|
|
315
|
+
while (cursor < items.length) {
|
|
316
|
+
const index = cursor;
|
|
317
|
+
cursor += 1;
|
|
318
|
+
output[index] = await worker(items[index], index);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
|
|
322
|
+
return output;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export class QccHostBridge {
|
|
326
|
+
constructor({
|
|
327
|
+
tools,
|
|
328
|
+
logger,
|
|
329
|
+
callIdFactory,
|
|
330
|
+
defaultTimeoutMs = DEFAULT_TIMEOUT_MS,
|
|
331
|
+
toolWaitMs = DEFAULT_TOOL_WAIT_MS,
|
|
332
|
+
pollMs = DEFAULT_POLL_MS,
|
|
333
|
+
}) {
|
|
334
|
+
if (!tools || typeof tools.get !== 'function' || typeof tools.execute !== 'function') {
|
|
335
|
+
throw new TypeError('QccHostBridge requires ctx.tools get/execute');
|
|
336
|
+
}
|
|
337
|
+
this.tools = tools;
|
|
338
|
+
this.logger = logger ?? console;
|
|
339
|
+
this.sequence = 0;
|
|
340
|
+
this.callIdFactory = callIdFactory ?? ((name) => {
|
|
341
|
+
this.sequence += 1;
|
|
342
|
+
const suffix = name.split('__').at(-1)?.slice(0, 24) || 'oauth';
|
|
343
|
+
return `dc-qcc-${Date.now()}-${this.sequence}-${suffix}`;
|
|
344
|
+
});
|
|
345
|
+
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
346
|
+
this.toolWaitMs = toolWaitMs;
|
|
347
|
+
this.pollMs = pollMs;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
has(name) {
|
|
351
|
+
return Boolean(this.definitionFor(name));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
definitionFor(name) {
|
|
355
|
+
for (const candidate of qccToolRuntimeCandidates(name)) {
|
|
356
|
+
try {
|
|
357
|
+
const definition = this.tools.get(candidate);
|
|
358
|
+
if (definition) return definition;
|
|
359
|
+
} catch {
|
|
360
|
+
// 一个候选在动态重注册窗口内失败时,仍继续探测已验证的兼容名称。
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
capabilities() {
|
|
367
|
+
const capabilities = {
|
|
368
|
+
oauthConnect: this.has(QCC_TOOL_NAMES.oauthConnect),
|
|
369
|
+
oauthStatus: this.has(QCC_TOOL_NAMES.oauthStatus),
|
|
370
|
+
entityLookup: this.has(QCC_TOOL_NAMES.entityLookup),
|
|
371
|
+
registration: this.has(QCC_TOOL_NAMES.registration),
|
|
372
|
+
riskScan: this.has(QCC_TOOL_NAMES.riskScan),
|
|
373
|
+
};
|
|
374
|
+
const ready = capabilities.entityLookup && capabilities.registration;
|
|
375
|
+
return {
|
|
376
|
+
...capabilities,
|
|
377
|
+
ready,
|
|
378
|
+
phase2: this.phase2Capabilities(),
|
|
379
|
+
state: ready ? 'ready' : capabilities.oauthConnect || capabilities.oauthStatus
|
|
380
|
+
? 'not-connected-or-refreshing'
|
|
381
|
+
: 'oauth-plugin-missing',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 只读取 ToolRuntime 注册表,不执行 QCC 工具、不产生付费调用。 */
|
|
386
|
+
phase2Capabilities() {
|
|
387
|
+
const describe = ([id, name]) => {
|
|
388
|
+
const definition = this.definitionFor(name);
|
|
389
|
+
return { id, name, runtimeName: definition?.name ?? null, registered: Boolean(definition) };
|
|
390
|
+
};
|
|
391
|
+
const companyTools = Object.entries(QCC_PHASE2_COMPANY_TOOLS).map(describe);
|
|
392
|
+
const historyTools = Object.entries(QCC_PHASE2_HISTORY_TOOLS).map(describe);
|
|
393
|
+
const companyRegistered = companyTools.filter((tool) => tool.registered).length;
|
|
394
|
+
const historyRegistered = historyTools.filter((tool) => tool.registered).length;
|
|
395
|
+
const companyReady = companyRegistered === companyTools.length;
|
|
396
|
+
const historyToolsReady = historyRegistered === historyTools.length;
|
|
397
|
+
const oauthPluginPresent = this.has(QCC_TOOL_NAMES.oauthConnect) || this.has(QCC_TOOL_NAMES.oauthStatus);
|
|
398
|
+
return {
|
|
399
|
+
companyTools,
|
|
400
|
+
historyTools,
|
|
401
|
+
companyRegistered,
|
|
402
|
+
companyTotal: companyTools.length,
|
|
403
|
+
historyRegistered,
|
|
404
|
+
historyTotal: historyTools.length,
|
|
405
|
+
companyReady,
|
|
406
|
+
historyToolsReady,
|
|
407
|
+
historyAuthorizationVerified: false,
|
|
408
|
+
state: companyReady
|
|
409
|
+
? historyToolsReady ? 'tool-surface-ready' : 'current-ready-history-tools-missing'
|
|
410
|
+
: oauthPluginPresent ? 'not-connected-or-refreshing' : 'oauth-plugin-missing',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async waitForTool(name, { signal, waitMs = this.toolWaitMs } = {}) {
|
|
415
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
416
|
+
do {
|
|
417
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted');
|
|
418
|
+
try {
|
|
419
|
+
const definition = this.definitionFor(name);
|
|
420
|
+
if (definition) return definition;
|
|
421
|
+
} catch {
|
|
422
|
+
// 动态 entry 更新窗口内 get 可能短暂失败,继续按预算等待。
|
|
423
|
+
}
|
|
424
|
+
if (Date.now() >= deadline) break;
|
|
425
|
+
await delay(Math.min(this.pollMs, Math.max(1, deadline - Date.now())), signal);
|
|
426
|
+
} while (Date.now() <= deadline);
|
|
427
|
+
|
|
428
|
+
throw new QccBridgeError('QCC_TOOL_UNAVAILABLE', `QCC tool is unavailable: ${name}`, {
|
|
429
|
+
toolName: name,
|
|
430
|
+
retryable: true,
|
|
431
|
+
connectRequired: name.startsWith('mcp__qcc-'),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async call(name, args = {}, options = {}) {
|
|
436
|
+
if (!isAllowedQccTool(name)) {
|
|
437
|
+
throw new QccBridgeError('QCC_TOOL_NOT_ALLOWED', `Tool is outside the QCC bridge allowlist: ${name}`, {
|
|
438
|
+
toolName: name,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
if (!isRecord(args)) throw new QccBridgeError('QCC_INVALID_ARGS', 'QCC tool arguments must be an object');
|
|
442
|
+
|
|
443
|
+
const parentSignal = options.signal;
|
|
444
|
+
const state = linkedSignal(parentSignal, options.timeoutMs ?? this.defaultTimeoutMs);
|
|
445
|
+
let callId = null;
|
|
446
|
+
let activeToolName = name;
|
|
447
|
+
let attempt = 0;
|
|
448
|
+
let attemptStarted = 0;
|
|
449
|
+
let attemptAudited = false;
|
|
450
|
+
try {
|
|
451
|
+
while (attempt < 2) {
|
|
452
|
+
attempt += 1;
|
|
453
|
+
callId = null;
|
|
454
|
+
activeToolName = name;
|
|
455
|
+
attemptStarted = Date.now();
|
|
456
|
+
attemptAudited = false;
|
|
457
|
+
try {
|
|
458
|
+
const definition = await this.waitForTool(name, {
|
|
459
|
+
signal: state.signal,
|
|
460
|
+
waitMs: options.waitForToolMs ?? this.toolWaitMs,
|
|
461
|
+
});
|
|
462
|
+
activeToolName = definition?.name ?? name;
|
|
463
|
+
callId = this.callIdFactory(activeToolName);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
emitAudit(options, {
|
|
466
|
+
toolName: activeToolName,
|
|
467
|
+
callId,
|
|
468
|
+
attempt,
|
|
469
|
+
outcome: 'not-dispatched',
|
|
470
|
+
code: error?.code ?? 'QCC_TOOL_UNAVAILABLE',
|
|
471
|
+
upstreamCode: error?.upstreamCode,
|
|
472
|
+
durationMs: Date.now() - attemptStarted,
|
|
473
|
+
});
|
|
474
|
+
attemptAudited = true;
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
const result = await this.tools.execute({
|
|
478
|
+
name: activeToolName,
|
|
479
|
+
callId,
|
|
480
|
+
signal: state.signal,
|
|
481
|
+
arguments: args,
|
|
482
|
+
});
|
|
483
|
+
if (result?.isError !== true) {
|
|
484
|
+
emitAudit(options, {
|
|
485
|
+
toolName: activeToolName,
|
|
486
|
+
callId,
|
|
487
|
+
attempt,
|
|
488
|
+
outcome: 'success',
|
|
489
|
+
durationMs: Date.now() - attemptStarted,
|
|
490
|
+
});
|
|
491
|
+
attemptAudited = true;
|
|
492
|
+
return { callId, toolName: activeToolName, data: decodeQccToolValue(result?.value) };
|
|
493
|
+
}
|
|
494
|
+
const upstreamCode = upstreamFailureCode(result);
|
|
495
|
+
if (upstreamCode === 'UNKNOWN_TOOL' && attempt === 1 && !state.signal.aborted) {
|
|
496
|
+
emitAudit(options, {
|
|
497
|
+
toolName: activeToolName,
|
|
498
|
+
callId,
|
|
499
|
+
attempt,
|
|
500
|
+
outcome: 'refresh-race',
|
|
501
|
+
code: 'QCC_TOOL_UNAVAILABLE',
|
|
502
|
+
upstreamCode,
|
|
503
|
+
durationMs: Date.now() - attemptStarted,
|
|
504
|
+
});
|
|
505
|
+
attemptAudited = true;
|
|
506
|
+
await delay(this.pollMs, state.signal);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const failure = normalizedFailure(result, activeToolName, { ...state, parentSignal });
|
|
510
|
+
emitAudit(options, {
|
|
511
|
+
toolName: activeToolName,
|
|
512
|
+
callId,
|
|
513
|
+
attempt,
|
|
514
|
+
outcome: 'failed',
|
|
515
|
+
code: failure.code,
|
|
516
|
+
upstreamCode: failure.upstreamCode,
|
|
517
|
+
durationMs: Date.now() - attemptStarted,
|
|
518
|
+
});
|
|
519
|
+
attemptAudited = true;
|
|
520
|
+
throw failure;
|
|
521
|
+
}
|
|
522
|
+
throw new QccBridgeError('QCC_TOOL_UNAVAILABLE', `QCC tool disappeared during refresh: ${name}`, {
|
|
523
|
+
toolName: name,
|
|
524
|
+
retryable: true,
|
|
525
|
+
connectRequired: true,
|
|
526
|
+
});
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error instanceof QccBridgeError) throw error;
|
|
529
|
+
let normalized;
|
|
530
|
+
if (state.timedOut()) {
|
|
531
|
+
normalized = new QccBridgeError('QCC_TIMEOUT', `QCC tool timed out: ${name}`, {
|
|
532
|
+
toolName: name,
|
|
533
|
+
retryable: true,
|
|
534
|
+
cause: error,
|
|
535
|
+
});
|
|
536
|
+
} else if (parentSignal?.aborted || state.signal.aborted) {
|
|
537
|
+
normalized = new QccBridgeError('QCC_ABORTED', `QCC tool call was cancelled: ${name}`, {
|
|
538
|
+
toolName: name,
|
|
539
|
+
cause: error,
|
|
540
|
+
});
|
|
541
|
+
} else {
|
|
542
|
+
normalized = new QccBridgeError('QCC_RUNTIME_ERROR', `QCC tool runtime failed: ${name}`, {
|
|
543
|
+
toolName: name,
|
|
544
|
+
retryable: true,
|
|
545
|
+
cause: error,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if (!attemptAudited) emitAudit(options, {
|
|
549
|
+
toolName: activeToolName,
|
|
550
|
+
callId,
|
|
551
|
+
attempt,
|
|
552
|
+
outcome: 'failed',
|
|
553
|
+
code: normalized.code,
|
|
554
|
+
durationMs: Date.now() - attemptStarted,
|
|
555
|
+
});
|
|
556
|
+
throw normalized;
|
|
557
|
+
} finally {
|
|
558
|
+
state.cleanup();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async enrichCompany(companyName, options = {}) {
|
|
563
|
+
const lookup = await this.call(
|
|
564
|
+
QCC_TOOL_NAMES.entityLookup,
|
|
565
|
+
{ searchKey: companyName },
|
|
566
|
+
options,
|
|
567
|
+
);
|
|
568
|
+
const match = classifyEntityMatch(lookup.data);
|
|
569
|
+
if (match.status !== 'exact') return match;
|
|
570
|
+
|
|
571
|
+
const lockedKey = match.creditNo || match.companyName;
|
|
572
|
+
const registration = await this.call(
|
|
573
|
+
QCC_TOOL_NAMES.registration,
|
|
574
|
+
{ searchKey: lockedKey },
|
|
575
|
+
options,
|
|
576
|
+
);
|
|
577
|
+
const fields = mapRegistrationFields(registration.data, match);
|
|
578
|
+
if (options.includeRisk) {
|
|
579
|
+
const risk = await this.call(QCC_TOOL_NAMES.riskScan, { searchKey: lockedKey }, options);
|
|
580
|
+
fields.risk_tags = mapRiskTags(risk.data);
|
|
581
|
+
} else {
|
|
582
|
+
fields.risk_tags = '';
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
status: 'enriched',
|
|
586
|
+
companyName: match.companyName,
|
|
587
|
+
fields,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async enrichLockedCompany(selection, options = {}) {
|
|
592
|
+
const companyName = String(selection?.companyName ?? '').trim();
|
|
593
|
+
const creditNo = String(selection?.creditNo ?? '').trim();
|
|
594
|
+
if (!creditNo) {
|
|
595
|
+
throw new QccBridgeError('QCC_CANDIDATE_INVALID', 'A selected QCC candidate must include a credit number');
|
|
596
|
+
}
|
|
597
|
+
const registration = await this.call(
|
|
598
|
+
QCC_TOOL_NAMES.registration,
|
|
599
|
+
{ searchKey: creditNo },
|
|
600
|
+
options,
|
|
601
|
+
);
|
|
602
|
+
const fields = mapRegistrationFields(registration.data, { companyName, creditNo });
|
|
603
|
+
if (options.includeRisk) {
|
|
604
|
+
const risk = await this.call(QCC_TOOL_NAMES.riskScan, { searchKey: creditNo }, options);
|
|
605
|
+
fields.risk_tags = mapRiskTags(risk.data);
|
|
606
|
+
} else {
|
|
607
|
+
fields.risk_tags = '';
|
|
608
|
+
}
|
|
609
|
+
return { status: 'enriched', companyName, fields };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async enrichRows(rows, options = {}) {
|
|
613
|
+
if (!Array.isArray(rows)) throw new QccBridgeError('QCC_INVALID_ROWS', 'rows must be an array');
|
|
614
|
+
const maxRows = Math.max(1, Math.trunc(options.maxRows ?? DEFAULT_MAX_ROWS));
|
|
615
|
+
if (rows.length > maxRows) {
|
|
616
|
+
throw new QccBridgeError('QCC_BATCH_TOO_LARGE', `QCC batch exceeds ${maxRows} rows`, {
|
|
617
|
+
details: { maxRows, receivedRows: rows.length },
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const requiredTools = [QCC_TOOL_NAMES.entityLookup, QCC_TOOL_NAMES.registration];
|
|
622
|
+
if (options.includeRisk) requiredTools.push(QCC_TOOL_NAMES.riskScan);
|
|
623
|
+
try {
|
|
624
|
+
await Promise.all(requiredTools.map((name) => this.waitForTool(name, {
|
|
625
|
+
signal: options.signal,
|
|
626
|
+
waitMs: options.waitForToolMs ?? this.toolWaitMs,
|
|
627
|
+
})));
|
|
628
|
+
} catch (error) {
|
|
629
|
+
if (options.signal?.aborted) throw new QccBridgeError('QCC_ABORTED', 'QCC batch was cancelled');
|
|
630
|
+
const missingTools = requiredTools.filter((name) => !this.has(name));
|
|
631
|
+
throw new QccBridgeError('QCC_NOT_CONNECTED', 'QCC MCP tools are not ready; connect QCC or wait for refresh', {
|
|
632
|
+
connectRequired: true,
|
|
633
|
+
retryable: true,
|
|
634
|
+
details: { missingTools },
|
|
635
|
+
cause: error,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const nameField = String(options.nameField ?? 'name');
|
|
640
|
+
const normalized = rows.map((row, index) => ({
|
|
641
|
+
index,
|
|
642
|
+
row: isRecord(row) ? { ...row } : {},
|
|
643
|
+
companyName: String(isRecord(row) ? row[nameField] ?? '' : '').trim(),
|
|
644
|
+
}));
|
|
645
|
+
const names = [...new Set(normalized.map((item) => item.companyName).filter(Boolean))];
|
|
646
|
+
const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.trunc(options.concurrency ?? 2)));
|
|
647
|
+
let completedUnique = 0;
|
|
648
|
+
|
|
649
|
+
const resolutions = await mapConcurrent(names, concurrency, async (companyName) => {
|
|
650
|
+
if (options.signal?.aborted) throw new QccBridgeError('QCC_ABORTED', 'QCC batch was cancelled');
|
|
651
|
+
let result;
|
|
652
|
+
try {
|
|
653
|
+
result = await this.enrichCompany(companyName, options);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (options.signal?.aborted || error?.code === 'QCC_ABORTED') throw error;
|
|
656
|
+
const normalizedError = error instanceof QccBridgeError
|
|
657
|
+
? error
|
|
658
|
+
: new QccBridgeError('QCC_RUNTIME_ERROR', 'QCC enrichment failed', { cause: error, retryable: true });
|
|
659
|
+
result = { status: 'failed', error: normalizedError.toJSON() };
|
|
660
|
+
}
|
|
661
|
+
completedUnique += 1;
|
|
662
|
+
options.onProgress?.({ completedUnique, totalUnique: names.length });
|
|
663
|
+
return [companyName, result];
|
|
664
|
+
});
|
|
665
|
+
const byName = new Map(resolutions);
|
|
666
|
+
|
|
667
|
+
const outputRows = normalized.map(({ row, companyName }) => {
|
|
668
|
+
if (!companyName) return { ...row, qcc_match_status: 'missing-name' };
|
|
669
|
+
const result = byName.get(companyName);
|
|
670
|
+
if (result?.status === 'enriched') {
|
|
671
|
+
return {
|
|
672
|
+
...row,
|
|
673
|
+
...result.fields,
|
|
674
|
+
qcc_match_status: 'enriched',
|
|
675
|
+
qcc_source: 'qcc-mcp',
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
return { ...row, qcc_match_status: result?.status ?? 'failed' };
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const indexesFor = (name) => normalized.filter((item) => item.companyName === name).map((item) => item.index);
|
|
682
|
+
const reviewQueue = resolutions
|
|
683
|
+
.filter(([, result]) => result.status === 'ambiguous')
|
|
684
|
+
.map(([companyName, result]) => ({ companyName, rowIndexes: indexesFor(companyName), candidates: result.candidates }));
|
|
685
|
+
const errors = resolutions
|
|
686
|
+
.filter(([, result]) => result.status === 'failed')
|
|
687
|
+
.map(([companyName, result]) => ({ companyName, rowIndexes: indexesFor(companyName), error: result.error }));
|
|
688
|
+
|
|
689
|
+
const summary = {
|
|
690
|
+
totalRows: rows.length,
|
|
691
|
+
uniqueCompanies: names.length,
|
|
692
|
+
enriched: outputRows.filter((row) => row.qcc_match_status === 'enriched').length,
|
|
693
|
+
ambiguous: outputRows.filter((row) => row.qcc_match_status === 'ambiguous').length,
|
|
694
|
+
unresolved: outputRows.filter((row) => row.qcc_match_status === 'unresolved').length,
|
|
695
|
+
failed: outputRows.filter((row) => row.qcc_match_status === 'failed').length,
|
|
696
|
+
missingName: outputRows.filter((row) => row.qcc_match_status === 'missing-name').length,
|
|
697
|
+
includeRisk: Boolean(options.includeRisk),
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
return { summary, rows: outputRows, reviewQueue, errors };
|
|
701
|
+
}
|
|
702
|
+
}
|