dsh-data-cleaning-agent 0.3.0 → 0.5.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 +71 -0
- package/README.en.md +48 -7
- package/README.md +44 -6
- package/docs/COMPATIBILITY.md +54 -2
- 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/PHASE3-ACCEPTANCE.md +129 -0
- package/docs/QCC-ENRICHMENT-DESIGN.md +5 -2
- package/docs/QCC-PHASES-ROADMAP.md +33 -6
- package/docs/RELEASE-0.4.0.md +66 -0
- package/docs/RELEASE-0.5.0.md +84 -0
- package/docs/USER-GUIDE.md +85 -6
- package/lib/client.js +1132 -16
- package/lib/index.js +2 -0
- package/lib/qcc-phase2-acceptance.js +191 -0
- package/lib/qcc-phase2.js +99 -0
- package/lib/qcc-phase3-batch.js +437 -0
- package/lib/qcc-phase3.js +224 -0
- package/lib/qcc-runs.js +322 -0
- package/lib/qcc-safety.js +77 -0
- package/lib/qcc.js +735 -0
- package/lib/skill-enrich.js +54 -9
- package/lib/web.js +351 -2
- package/package.json +23 -4
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.5.0 三域批量服务:只通过 QccHostBridge 的公共 ToolRuntime 调用面执行。
|
|
3
|
+
* 原始行和工具结果只返回给同源 Web 工作台,不进入模型上下文。
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { QCC_TOOL_NAMES, QccBridgeError, classifyEntityMatch } from './qcc.js';
|
|
7
|
+
import {
|
|
8
|
+
QCC_PHASE3_TOOL_NAMES,
|
|
9
|
+
canonicalizePhase3Tool,
|
|
10
|
+
canonicalPhase3ToolName,
|
|
11
|
+
requiredInputsFor,
|
|
12
|
+
} from './qcc-phase3.js';
|
|
13
|
+
import { safeAuditEvent } from './qcc-safety.js';
|
|
14
|
+
|
|
15
|
+
export const PHASE3_BATCH_LIMITS = Object.freeze({ maxRows: 100, maxConcurrency: 4, defaultMaxCalls: 500, hardMaxCalls: 2_000 });
|
|
16
|
+
const DETAIL_TOOL = 'mcp__qcc-risk__get_judicial_document_detail';
|
|
17
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
function isRecord(value) {
|
|
20
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clone(value) {
|
|
24
|
+
return structuredClone(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function uniqueNames(rows, nameField) {
|
|
28
|
+
return [...new Set(rows.map((row) => String(isRecord(row) ? row[nameField] ?? '' : '').trim()).filter(Boolean))];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeError(error, fallback = 'QCC_PHASE3_FAILED') {
|
|
32
|
+
if (error instanceof QccBridgeError) return error.toJSON();
|
|
33
|
+
return new QccBridgeError(fallback, 'QCC phase-3 batch operation failed', { retryable: true }).toJSON();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizePhase3Selection(input = {}) {
|
|
37
|
+
const domains = [...new Set((Array.isArray(input.domains) ? input.domains : []).map(String))];
|
|
38
|
+
for (const domain of domains) {
|
|
39
|
+
if (!Object.hasOwn(QCC_PHASE3_TOOL_NAMES, domain)) {
|
|
40
|
+
throw new QccBridgeError('QCC_PHASE3_DOMAIN_INVALID', `Unknown phase-3 domain: ${domain}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const selected = [];
|
|
44
|
+
for (const domain of domains) {
|
|
45
|
+
selected.push(...QCC_PHASE3_TOOL_NAMES[domain].map((name) => canonicalPhase3ToolName(domain, name)));
|
|
46
|
+
}
|
|
47
|
+
for (const raw of Array.isArray(input.tools) ? input.tools : []) {
|
|
48
|
+
const canonical = canonicalizePhase3Tool(raw);
|
|
49
|
+
if (!canonical) throw new QccBridgeError('QCC_PHASE3_TOOL_INVALID', `Tool is outside the phase-3 contract: ${String(raw)}`);
|
|
50
|
+
selected.push(canonical);
|
|
51
|
+
}
|
|
52
|
+
const tools = [...new Set(selected)];
|
|
53
|
+
if (tools.length === 0) {
|
|
54
|
+
throw new QccBridgeError('QCC_PHASE3_SELECTION_REQUIRED', 'Select at least one risk, IPR or operation tool');
|
|
55
|
+
}
|
|
56
|
+
return { domains, tools };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function estimatePhase3Batch(rows, input = {}) {
|
|
60
|
+
if (!Array.isArray(rows)) throw new QccBridgeError('QCC_INVALID_ROWS', 'rows must be an array');
|
|
61
|
+
const maxRows = Math.min(PHASE3_BATCH_LIMITS.maxRows, Math.max(1, Math.trunc(input.maxRows ?? PHASE3_BATCH_LIMITS.maxRows)));
|
|
62
|
+
if (rows.length > maxRows) {
|
|
63
|
+
throw new QccBridgeError('QCC_BATCH_TOO_LARGE', `QCC batch exceeds ${maxRows} rows`, {
|
|
64
|
+
details: { maxRows, receivedRows: rows.length },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const nameField = String(input.nameField ?? 'name');
|
|
68
|
+
const selection = normalizePhase3Selection(input);
|
|
69
|
+
const names = uniqueNames(rows, nameField);
|
|
70
|
+
const lookupCalls = names.length;
|
|
71
|
+
const enrichmentCalls = names.length * selection.tools.length;
|
|
72
|
+
const estimatedCalls = lookupCalls + enrichmentCalls;
|
|
73
|
+
const requestedMax = Math.trunc(input.maxCalls ?? PHASE3_BATCH_LIMITS.defaultMaxCalls);
|
|
74
|
+
const maxCalls = Math.min(PHASE3_BATCH_LIMITS.hardMaxCalls, Math.max(1, requestedMax));
|
|
75
|
+
return {
|
|
76
|
+
...selection,
|
|
77
|
+
totalRows: rows.length,
|
|
78
|
+
uniqueCompanies: names.length,
|
|
79
|
+
missingNameRows: rows.length - rows.filter((row) => String(isRecord(row) ? row[nameField] ?? '' : '').trim()).length,
|
|
80
|
+
lookupCalls,
|
|
81
|
+
enrichmentCalls,
|
|
82
|
+
estimatedCalls,
|
|
83
|
+
maxCalls,
|
|
84
|
+
withinLimit: estimatedCalls <= maxCalls,
|
|
85
|
+
estimateType: 'upper-bound',
|
|
86
|
+
detailDependencies: selection.tools.includes(DETAIL_TOOL) ? ['documentId'] : [],
|
|
87
|
+
executesTools: false,
|
|
88
|
+
paidCalls: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function mapConcurrent(items, concurrency, worker) {
|
|
93
|
+
const output = new Array(items.length);
|
|
94
|
+
let cursor = 0;
|
|
95
|
+
const run = async () => {
|
|
96
|
+
while (cursor < items.length) {
|
|
97
|
+
const index = cursor++;
|
|
98
|
+
output[index] = await worker(items[index], index);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
|
|
102
|
+
return output;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function rowStatus(result) {
|
|
106
|
+
if (!result) return 'failed';
|
|
107
|
+
return result.status;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function summarizeRows(rows, selectedTools, actualCalls, estimate) {
|
|
111
|
+
const count = (status) => rows.filter((row) => row.qcc_match_status === status).length;
|
|
112
|
+
return {
|
|
113
|
+
totalRows: rows.length,
|
|
114
|
+
uniqueCompanies: estimate.uniqueCompanies,
|
|
115
|
+
enriched: count('enriched'),
|
|
116
|
+
partial: count('partial'),
|
|
117
|
+
ambiguous: count('ambiguous'),
|
|
118
|
+
unresolved: count('unresolved'),
|
|
119
|
+
failed: count('failed'),
|
|
120
|
+
missingName: count('missing-name'),
|
|
121
|
+
selectedTools: selectedTools.length,
|
|
122
|
+
estimatedCalls: estimate.estimatedCalls,
|
|
123
|
+
actualCalls,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class Phase3BatchService {
|
|
128
|
+
constructor(bridge) {
|
|
129
|
+
this.bridge = bridge;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
estimate(rows, input) {
|
|
133
|
+
return estimatePhase3Batch(rows, input);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async callBudgeted(toolName, args, options, budget) {
|
|
137
|
+
if (budget.used >= budget.max) {
|
|
138
|
+
throw new QccBridgeError('QCC_CALL_LIMIT_REACHED', 'QCC call limit reached before dispatch', {
|
|
139
|
+
details: { maxCalls: budget.max, actualCalls: budget.used },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
budget.used += 1;
|
|
143
|
+
return this.bridge.call(toolName, args, options);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async enrichLocked(selection, selectedTools, input, budget, options = {}) {
|
|
147
|
+
const companyName = String(selection.companyName ?? '').trim();
|
|
148
|
+
const lockedKey = String(selection.creditNo ?? selection.companyName ?? '').trim();
|
|
149
|
+
const toolResults = [];
|
|
150
|
+
const errors = [];
|
|
151
|
+
for (const toolName of selectedTools) {
|
|
152
|
+
const shortName = toolName.split('__').at(-1);
|
|
153
|
+
const extraArgs = isRecord(input.toolArguments?.[toolName])
|
|
154
|
+
? input.toolArguments[toolName]
|
|
155
|
+
: isRecord(input.toolArguments?.[shortName]) ? input.toolArguments[shortName] : {};
|
|
156
|
+
const required = requiredInputsFor(toolName);
|
|
157
|
+
const args = { searchKey: lockedKey, ...extraArgs };
|
|
158
|
+
const missing = required.filter((key) => args[key] === undefined || args[key] === null || args[key] === '');
|
|
159
|
+
if (missing.length) {
|
|
160
|
+
const error = new QccBridgeError('QCC_DEPENDENCY_REQUIRED', `Required input is missing for ${shortName}`, {
|
|
161
|
+
toolName,
|
|
162
|
+
details: { missing },
|
|
163
|
+
}).toJSON();
|
|
164
|
+
toolResults.push({ sourceTool: toolName, status: 'dependency-required', error });
|
|
165
|
+
errors.push({ toolName, error });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const called = await this.callBudgeted(toolName, args, options, budget);
|
|
170
|
+
toolResults.push({ sourceTool: toolName, runtimeTool: called.toolName, status: 'success', value: called.data });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error?.code === 'QCC_ABORTED' || error?.code === 'QCC_CALL_LIMIT_REACHED') throw error;
|
|
173
|
+
const normalized = safeError(error);
|
|
174
|
+
toolResults.push({ sourceTool: toolName, status: 'failed', error: normalized });
|
|
175
|
+
errors.push({ toolName, error: normalized });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const successes = toolResults.filter((item) => item.status === 'success').length;
|
|
179
|
+
return {
|
|
180
|
+
status: successes === selectedTools.length ? 'enriched' : successes > 0 ? 'partial' : 'failed',
|
|
181
|
+
companyName,
|
|
182
|
+
creditNo: String(selection.creditNo ?? ''),
|
|
183
|
+
lockedKey,
|
|
184
|
+
toolResults,
|
|
185
|
+
errors,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async enrichCompany(companyName, selectedTools, input, budget, options = {}) {
|
|
190
|
+
const lookup = await this.callBudgeted(QCC_TOOL_NAMES.entityLookup, { searchKey: companyName }, options, budget);
|
|
191
|
+
const match = classifyEntityMatch(lookup.data);
|
|
192
|
+
if (match.status !== 'exact') return { ...match, companyName };
|
|
193
|
+
return this.enrichLocked(match, selectedTools, input, budget, options);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async run(rows, input = {}, options = {}) {
|
|
197
|
+
const estimate = this.estimate(rows, input);
|
|
198
|
+
if (!estimate.withinLimit) {
|
|
199
|
+
throw new QccBridgeError('QCC_CALL_LIMIT_EXCEEDED', 'Estimated QCC calls exceed maxCalls; narrow the tool or row selection', {
|
|
200
|
+
details: { estimatedCalls: estimate.estimatedCalls, maxCalls: estimate.maxCalls },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const requiredTools = [QCC_TOOL_NAMES.entityLookup, ...estimate.tools.filter((tool) => tool !== DETAIL_TOOL || input.toolArguments)];
|
|
204
|
+
const missingTools = requiredTools.filter((name) => !this.bridge.has(name));
|
|
205
|
+
if (missingTools.length) {
|
|
206
|
+
throw new QccBridgeError('QCC_NOT_CONNECTED', 'Selected QCC tools are not ready; connect QCC or narrow the selection', {
|
|
207
|
+
connectRequired: true,
|
|
208
|
+
retryable: true,
|
|
209
|
+
details: { missingTools },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const nameField = String(input.nameField ?? 'name');
|
|
214
|
+
const normalized = rows.map((row, index) => ({
|
|
215
|
+
index,
|
|
216
|
+
row: isRecord(row) ? { ...row } : {},
|
|
217
|
+
companyName: String(isRecord(row) ? row[nameField] ?? '' : '').trim(),
|
|
218
|
+
}));
|
|
219
|
+
const names = uniqueNames(rows, nameField);
|
|
220
|
+
const concurrency = Math.min(PHASE3_BATCH_LIMITS.maxConcurrency, Math.max(1, Math.trunc(input.concurrency ?? 2)));
|
|
221
|
+
const budget = { used: 0, max: estimate.maxCalls };
|
|
222
|
+
const audit = [];
|
|
223
|
+
const callOptions = { ...options, onAudit: (event) => { audit.push(safeAuditEvent(event)); options.onAudit?.(event); } };
|
|
224
|
+
let completedUnique = 0;
|
|
225
|
+
const pairs = await mapConcurrent(names, concurrency, async (companyName) => {
|
|
226
|
+
let result;
|
|
227
|
+
try {
|
|
228
|
+
result = await this.enrichCompany(companyName, estimate.tools, input, budget, callOptions);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error?.code === 'QCC_ABORTED' || error?.code === 'QCC_CALL_LIMIT_REACHED') throw error;
|
|
231
|
+
result = { status: 'failed', companyName, error: safeError(error), errors: [{ toolName: error?.toolName ?? null, error: safeError(error) }] };
|
|
232
|
+
}
|
|
233
|
+
completedUnique += 1;
|
|
234
|
+
options.onProgress?.({ completedUnique, totalUnique: names.length, actualCalls: budget.used });
|
|
235
|
+
return [companyName, result];
|
|
236
|
+
});
|
|
237
|
+
const companyResults = Object.fromEntries(pairs);
|
|
238
|
+
const outputRows = normalized.map(({ row, companyName }) => {
|
|
239
|
+
if (!companyName) return { ...row, qcc_match_status: 'missing-name' };
|
|
240
|
+
const result = companyResults[companyName];
|
|
241
|
+
const next = { ...row, qcc_match_status: rowStatus(result) };
|
|
242
|
+
if (Array.isArray(result?.toolResults)) {
|
|
243
|
+
next.qcc_phase3_json = JSON.stringify(result.toolResults);
|
|
244
|
+
if (result.toolResults.some((item) => item.status === 'success')) next.qcc_source = 'qcc-mcp';
|
|
245
|
+
}
|
|
246
|
+
return next;
|
|
247
|
+
});
|
|
248
|
+
const indexesFor = (name) => normalized.filter((item) => item.companyName === name).map((item) => item.index);
|
|
249
|
+
const reviewQueue = pairs.filter(([, result]) => result.status === 'ambiguous').map(([companyName, result]) => ({
|
|
250
|
+
companyName,
|
|
251
|
+
rowIndexes: indexesFor(companyName),
|
|
252
|
+
candidates: result.candidates,
|
|
253
|
+
}));
|
|
254
|
+
const errors = pairs.flatMap(([companyName, result]) => {
|
|
255
|
+
const items = Array.isArray(result.errors) ? result.errors : result.error ? [{ toolName: null, error: result.error }] : [];
|
|
256
|
+
return items.map((item) => ({ companyName, rowIndexes: indexesFor(companyName), ...item }));
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
estimate,
|
|
260
|
+
selectedTools: estimate.tools,
|
|
261
|
+
summary: summarizeRows(outputRows, estimate.tools, budget.used, estimate),
|
|
262
|
+
rows: outputRows,
|
|
263
|
+
reviewQueue,
|
|
264
|
+
errors,
|
|
265
|
+
companyResults,
|
|
266
|
+
audit,
|
|
267
|
+
actualCalls: budget.used,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function deriveState(record) {
|
|
273
|
+
if (record.reviewQueue.length) return 'awaiting-review';
|
|
274
|
+
if (record.errors.some((item) => item.error?.retryable)) return 'needs-retry';
|
|
275
|
+
if (record.errors.length) return 'completed-with-errors';
|
|
276
|
+
return 'completed';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export class Phase3RunStore {
|
|
280
|
+
constructor({ clock = () => Date.now(), runIdFactory = () => `phase3-${randomUUID()}`, ttlMs = DEFAULT_TTL_MS } = {}) {
|
|
281
|
+
this.clock = clock;
|
|
282
|
+
this.runIdFactory = runIdFactory;
|
|
283
|
+
this.ttlMs = ttlMs;
|
|
284
|
+
this.runs = new Map();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
cleanup() {
|
|
288
|
+
const cutoff = this.clock() - this.ttlMs;
|
|
289
|
+
for (const [id, record] of this.runs) if (record.touchedAtMs < cutoff) this.runs.delete(id);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
create({ headers, nameField, input, result }) {
|
|
293
|
+
this.cleanup();
|
|
294
|
+
const now = new Date(this.clock()).toISOString();
|
|
295
|
+
const record = {
|
|
296
|
+
id: this.runIdFactory(), version: 1, createdAt: now, updatedAt: now, touchedAtMs: this.clock(),
|
|
297
|
+
headers: Array.isArray(headers) ? headers.map(String) : [], nameField: String(nameField ?? 'name'),
|
|
298
|
+
input: clone(input), estimate: clone(result.estimate), selectedTools: clone(result.selectedTools),
|
|
299
|
+
summary: clone(result.summary), rows: clone(result.rows), reviewQueue: clone(result.reviewQueue),
|
|
300
|
+
errors: clone(result.errors), companyResults: clone(result.companyResults), audit: clone(result.audit),
|
|
301
|
+
actualCalls: result.actualCalls,
|
|
302
|
+
};
|
|
303
|
+
record.state = deriveState(record);
|
|
304
|
+
this.runs.set(record.id, record);
|
|
305
|
+
return this.snapshot(record);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
require(id) {
|
|
309
|
+
this.cleanup();
|
|
310
|
+
const record = this.runs.get(String(id ?? ''));
|
|
311
|
+
if (!record) throw new QccBridgeError('QCC_RUN_NOT_FOUND', 'Phase-3 run was not found or expired');
|
|
312
|
+
record.touchedAtMs = this.clock();
|
|
313
|
+
return record;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
get(id) { return this.snapshot(this.require(id)); }
|
|
317
|
+
|
|
318
|
+
recompute(record) {
|
|
319
|
+
const count = (status) => record.rows.filter((row) => row.qcc_match_status === status).length;
|
|
320
|
+
record.summary = {
|
|
321
|
+
...record.summary,
|
|
322
|
+
enriched: count('enriched'), partial: count('partial'), ambiguous: count('ambiguous'),
|
|
323
|
+
unresolved: count('unresolved'), failed: count('failed'), missingName: count('missing-name'),
|
|
324
|
+
actualCalls: record.actualCalls,
|
|
325
|
+
};
|
|
326
|
+
record.state = deriveState(record);
|
|
327
|
+
record.version += 1;
|
|
328
|
+
record.updatedAt = new Date(this.clock()).toISOString();
|
|
329
|
+
record.touchedAtMs = this.clock();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
patchCompany(record, companyName, rowIndexes, result, audit, actualCalls) {
|
|
333
|
+
record.companyResults[companyName] = clone(result);
|
|
334
|
+
record.reviewQueue = record.reviewQueue.filter((item) => item.companyName !== companyName);
|
|
335
|
+
record.errors = record.errors.filter((item) => item.companyName !== companyName);
|
|
336
|
+
for (const index of rowIndexes) {
|
|
337
|
+
const row = { ...(record.rows[index] ?? {}), qcc_match_status: result.status };
|
|
338
|
+
if (Array.isArray(result.toolResults)) {
|
|
339
|
+
row.qcc_phase3_json = JSON.stringify(result.toolResults);
|
|
340
|
+
if (result.toolResults.some((item) => item.status === 'success')) row.qcc_source = 'qcc-mcp';
|
|
341
|
+
}
|
|
342
|
+
record.rows[index] = row;
|
|
343
|
+
}
|
|
344
|
+
if (result.status === 'ambiguous') {
|
|
345
|
+
record.reviewQueue.push({ companyName, rowIndexes: [...rowIndexes], candidates: clone(result.candidates) });
|
|
346
|
+
}
|
|
347
|
+
for (const item of Array.isArray(result.errors) ? result.errors : []) {
|
|
348
|
+
record.errors.push({ companyName, rowIndexes: [...rowIndexes], ...clone(item) });
|
|
349
|
+
}
|
|
350
|
+
record.audit.push(...audit.map(safeAuditEvent));
|
|
351
|
+
record.actualCalls += actualCalls;
|
|
352
|
+
this.recompute(record);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async resolve(runId, { companyName, selectedCreditNo }, service) {
|
|
356
|
+
const record = this.require(runId);
|
|
357
|
+
const name = String(companyName ?? '').trim();
|
|
358
|
+
const creditNo = String(selectedCreditNo ?? '').trim();
|
|
359
|
+
const queued = record.reviewQueue.find((item) => item.companyName === name);
|
|
360
|
+
if (!queued) throw new QccBridgeError('QCC_REVIEW_NOT_PENDING', 'This company is not awaiting candidate review');
|
|
361
|
+
const candidate = queued.candidates.find((item) => item.creditNo === creditNo);
|
|
362
|
+
if (!candidate || !creditNo) throw new QccBridgeError('QCC_CANDIDATE_INVALID', 'Selected credit number is not in the pending candidate list');
|
|
363
|
+
const remaining = record.estimate.maxCalls - record.actualCalls;
|
|
364
|
+
if (remaining < record.selectedTools.length) {
|
|
365
|
+
throw new QccBridgeError('QCC_CALL_LIMIT_REACHED', 'Run call limit has no room for candidate enrichment');
|
|
366
|
+
}
|
|
367
|
+
const budget = { used: 0, max: remaining };
|
|
368
|
+
const audit = [];
|
|
369
|
+
const result = await service.enrichLocked(
|
|
370
|
+
{ companyName: candidate.companyName || name, creditNo },
|
|
371
|
+
record.selectedTools,
|
|
372
|
+
record.input,
|
|
373
|
+
budget,
|
|
374
|
+
{ onAudit: (event) => audit.push(event) },
|
|
375
|
+
);
|
|
376
|
+
this.patchCompany(record, name, queued.rowIndexes, result, audit, budget.used);
|
|
377
|
+
return this.snapshot(record);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async retry(runId, companyNames, service) {
|
|
381
|
+
const record = this.require(runId);
|
|
382
|
+
const names = [...new Set((Array.isArray(companyNames) ? companyNames : []).map((name) => String(name).trim()).filter(Boolean))];
|
|
383
|
+
if (!names.length) throw new QccBridgeError('QCC_RETRY_EMPTY', 'Select at least one failed company');
|
|
384
|
+
for (const name of names) {
|
|
385
|
+
const failed = record.errors.filter((item) => item.companyName === name && item.error?.retryable);
|
|
386
|
+
if (!failed.length) throw new QccBridgeError('QCC_RETRY_NOT_ALLOWED', 'Selected company has no retryable phase-3 failure');
|
|
387
|
+
const previous = record.companyResults[name];
|
|
388
|
+
const remaining = record.estimate.maxCalls - record.actualCalls;
|
|
389
|
+
const retryTools = previous?.lockedKey
|
|
390
|
+
? [...new Set(failed.map((item) => item.toolName).filter(Boolean))]
|
|
391
|
+
: record.selectedTools;
|
|
392
|
+
const needed = retryTools.length + (previous?.lockedKey ? 0 : 1);
|
|
393
|
+
if (remaining < needed) throw new QccBridgeError('QCC_CALL_LIMIT_REACHED', 'Run call limit has no room for retry');
|
|
394
|
+
const budget = { used: 0, max: remaining };
|
|
395
|
+
const audit = [];
|
|
396
|
+
let result;
|
|
397
|
+
if (previous?.lockedKey) {
|
|
398
|
+
const retried = await service.enrichLocked(
|
|
399
|
+
{ companyName: previous.companyName || name, creditNo: previous.creditNo || previous.lockedKey },
|
|
400
|
+
retryTools,
|
|
401
|
+
record.input,
|
|
402
|
+
budget,
|
|
403
|
+
{ onAudit: (event) => audit.push(event) },
|
|
404
|
+
);
|
|
405
|
+
const replacement = new Map(retried.toolResults.map((item) => [item.sourceTool, item]));
|
|
406
|
+
const merged = previous.toolResults.map((item) => replacement.get(item.sourceTool) ?? item);
|
|
407
|
+
const errors = merged.filter((item) => item.status !== 'success').map((item) => ({ toolName: item.sourceTool, error: item.error }));
|
|
408
|
+
const successes = merged.filter((item) => item.status === 'success').length;
|
|
409
|
+
result = {
|
|
410
|
+
...previous,
|
|
411
|
+
status: successes === record.selectedTools.length ? 'enriched' : successes > 0 ? 'partial' : 'failed',
|
|
412
|
+
toolResults: merged,
|
|
413
|
+
errors,
|
|
414
|
+
};
|
|
415
|
+
} else {
|
|
416
|
+
result = await service.enrichCompany(name, record.selectedTools, record.input, budget, {
|
|
417
|
+
onAudit: (event) => audit.push(event),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const indexes = record.rows.map((row, index) => ({ row, index }))
|
|
421
|
+
.filter(({ row }) => String(row[record.nameField] ?? '').trim() === name)
|
|
422
|
+
.map(({ index }) => index);
|
|
423
|
+
this.patchCompany(record, name, indexes, result, audit, budget.used);
|
|
424
|
+
}
|
|
425
|
+
return this.snapshot(record);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
snapshot(record) {
|
|
429
|
+
return clone({
|
|
430
|
+
runId: record.id, state: record.state, version: record.version, createdAt: record.createdAt,
|
|
431
|
+
updatedAt: record.updatedAt, headers: record.headers, estimate: record.estimate,
|
|
432
|
+
selectedTools: record.selectedTools, summary: record.summary, rows: record.rows,
|
|
433
|
+
reviewQueue: record.reviewQueue, errors: record.errors, audit: record.audit,
|
|
434
|
+
expiresInMs: this.ttlMs, persistence: 'host-memory',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QCC 0.5.0 三期工具契约:风险 / 知产 / 经营 三大域。
|
|
3
|
+
*
|
|
4
|
+
* 工具名与分类来源:一手注册表 mcp_web/packages/shared/src/lib/tool-category.js
|
|
5
|
+
* (185 工具 = 工商 16 + 风险 38 + 知产 18 + 经营 35 + 历史 34 + 董监高 44),
|
|
6
|
+
* 并经过真实 ToolRuntime preflight(本机 QCC MCP 六端点 streamable-http)核对。
|
|
7
|
+
*
|
|
8
|
+
* 契约只固化四类事实,不固化上游响应字段:
|
|
9
|
+
* 1. 精确工具名(canonical `mcp__qcc-<domain>__<name>`)与 legacy 运行时名映射;
|
|
10
|
+
* 2. 必需输入 schema(除 get_judicial_document_detail 需 searchKey+documentId 外,全部仅需 searchKey);
|
|
11
|
+
* 3. 权限(三域均为 basic 基础授权,区别于 history 的企业认证);
|
|
12
|
+
* 4. 付费语义(三域均为按次计费,调用前须确认)。
|
|
13
|
+
*
|
|
14
|
+
* 方案 A 延续:模型中介解读工具返回,必须保留原值;本模块不固化上游响应字段。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { qccToolRuntimeCandidates } from './qcc-phase2.js';
|
|
18
|
+
|
|
19
|
+
const riskTool = (name) => `mcp__qcc-risk__${name}`;
|
|
20
|
+
const iprTool = (name) => `mcp__qcc-ipr__${name}`;
|
|
21
|
+
const operationTool = (name) => `mcp__qcc-operation__${name}`;
|
|
22
|
+
|
|
23
|
+
// canonical↔legacy 映射复用二期单一实现(覆盖全部六域),避免双份漂移。
|
|
24
|
+
export { qccToolRuntimeCandidates };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 三大域的权限与付费语义。0.5.0 只覆盖基础授权域;
|
|
28
|
+
* history 域的企业认证语义在 0.6.0 引入,不在此表。
|
|
29
|
+
*/
|
|
30
|
+
export const QCC_PHASE3_DOMAIN_META = Object.freeze({
|
|
31
|
+
risk: Object.freeze({
|
|
32
|
+
label: '风险信息',
|
|
33
|
+
access: 'basic',
|
|
34
|
+
paid: true,
|
|
35
|
+
requiresConfirmation: true,
|
|
36
|
+
}),
|
|
37
|
+
ipr: Object.freeze({
|
|
38
|
+
label: '知识产权',
|
|
39
|
+
access: 'basic',
|
|
40
|
+
paid: true,
|
|
41
|
+
requiresConfirmation: true,
|
|
42
|
+
}),
|
|
43
|
+
operation: Object.freeze({
|
|
44
|
+
label: '经营信息',
|
|
45
|
+
access: 'basic',
|
|
46
|
+
paid: true,
|
|
47
|
+
requiresConfirmation: true,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** 精确工具名(短名,按字母序,与 tool-category.js TOOLS_BY_CATEGORY 一一对应)。 */
|
|
52
|
+
export const QCC_PHASE3_TOOL_NAMES = Object.freeze({
|
|
53
|
+
// 风险信息 · 38 个
|
|
54
|
+
risk: Object.freeze([
|
|
55
|
+
'get_administrative_penalty',
|
|
56
|
+
'get_bankruptcy_reorganization',
|
|
57
|
+
'get_business_exception',
|
|
58
|
+
'get_cancellation_record_info',
|
|
59
|
+
'get_case_filing_info',
|
|
60
|
+
'get_chattel_mortgage_info',
|
|
61
|
+
'get_company_related_risk_scan',
|
|
62
|
+
'get_company_risk_scan',
|
|
63
|
+
'get_court_notice',
|
|
64
|
+
'get_default_info',
|
|
65
|
+
'get_disciplinary_list',
|
|
66
|
+
'get_dishonest_info',
|
|
67
|
+
'get_environmental_penalty',
|
|
68
|
+
'get_equity_freeze',
|
|
69
|
+
'get_equity_pledge_info',
|
|
70
|
+
'get_exit_restriction',
|
|
71
|
+
'get_guarantee_info',
|
|
72
|
+
'get_hearing_notice',
|
|
73
|
+
'get_high_consumption_restriction',
|
|
74
|
+
'get_judgment_debtor_info',
|
|
75
|
+
'get_judicial_auction',
|
|
76
|
+
'get_judicial_document_detail',
|
|
77
|
+
'get_judicial_documents',
|
|
78
|
+
'get_land_mortgage_info',
|
|
79
|
+
'get_liquidation_info',
|
|
80
|
+
'get_pre_litigation_mediation',
|
|
81
|
+
'get_property_asset_announcement',
|
|
82
|
+
'get_public_exhortation',
|
|
83
|
+
'get_serious_violation',
|
|
84
|
+
'get_service_announcement',
|
|
85
|
+
'get_service_notice',
|
|
86
|
+
'get_simple_cancellation_info',
|
|
87
|
+
'get_stock_pledge_info',
|
|
88
|
+
'get_tax_abnormal',
|
|
89
|
+
'get_tax_arrears_notice',
|
|
90
|
+
'get_tax_violation',
|
|
91
|
+
'get_terminated_cases',
|
|
92
|
+
'get_valuation_inquiry',
|
|
93
|
+
]),
|
|
94
|
+
// 知识产权 · 18 个
|
|
95
|
+
ipr: Object.freeze([
|
|
96
|
+
'get_app_info',
|
|
97
|
+
'get_commercial_franchise',
|
|
98
|
+
'get_copyright_work_info',
|
|
99
|
+
'get_douyin_account',
|
|
100
|
+
'get_integrated_circuit_layout',
|
|
101
|
+
'get_international_patent',
|
|
102
|
+
'get_internet_service_info',
|
|
103
|
+
'get_ipr_pledge',
|
|
104
|
+
'get_kuaishou_account',
|
|
105
|
+
'get_mini_program',
|
|
106
|
+
'get_online_store',
|
|
107
|
+
'get_patent_info',
|
|
108
|
+
'get_software_copyright_info',
|
|
109
|
+
'get_standard_info',
|
|
110
|
+
'get_trademark_document',
|
|
111
|
+
'get_trademark_info',
|
|
112
|
+
'get_wechat_official_account',
|
|
113
|
+
'get_weibo_account',
|
|
114
|
+
]),
|
|
115
|
+
// 经营信息 · 35 个
|
|
116
|
+
operation: Object.freeze([
|
|
117
|
+
'get_administrative_license',
|
|
118
|
+
'get_advertising_review',
|
|
119
|
+
'get_asset_auction',
|
|
120
|
+
'get_bidding_info',
|
|
121
|
+
'get_company_announcement',
|
|
122
|
+
'get_counterfeit_cosmetics',
|
|
123
|
+
'get_credit_commitments',
|
|
124
|
+
'get_credit_evaluation',
|
|
125
|
+
'get_entry_denied',
|
|
126
|
+
'get_financing_lease_info',
|
|
127
|
+
'get_financing_records',
|
|
128
|
+
'get_food_safety',
|
|
129
|
+
'get_game_approval',
|
|
130
|
+
'get_government_announcement',
|
|
131
|
+
'get_government_interview',
|
|
132
|
+
'get_honor_info',
|
|
133
|
+
'get_import_export_credit',
|
|
134
|
+
'get_investment_institution',
|
|
135
|
+
'get_land_grant_info',
|
|
136
|
+
'get_land_transfer_info',
|
|
137
|
+
'get_news_sentiment',
|
|
138
|
+
'get_private_fund_manager',
|
|
139
|
+
'get_product_recall',
|
|
140
|
+
'get_product_spot_check',
|
|
141
|
+
'get_property_rights_transaction',
|
|
142
|
+
'get_qualifications',
|
|
143
|
+
'get_random_check',
|
|
144
|
+
'get_ranking_list_info',
|
|
145
|
+
'get_recruitment_info',
|
|
146
|
+
'get_related_announcement',
|
|
147
|
+
'get_software_violation',
|
|
148
|
+
'get_spot_check_info',
|
|
149
|
+
'get_taxpayer_qualification',
|
|
150
|
+
'get_tech_achievement',
|
|
151
|
+
'get_telecom_license',
|
|
152
|
+
]),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** 规范名构造:短名 → `mcp__qcc-<domain>__<name>`。 */
|
|
156
|
+
export function canonicalPhase3ToolName(domain, name) {
|
|
157
|
+
if (domain === 'risk') return riskTool(name);
|
|
158
|
+
if (domain === 'ipr') return iprTool(name);
|
|
159
|
+
if (domain === 'operation') return operationTool(name);
|
|
160
|
+
throw new RangeError(`Unknown QCC phase-3 domain: ${domain}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 全量规范名(risk+ipr+operation,91 个),用于迭代与预检。 */
|
|
164
|
+
export const QCC_PHASE3_ALL_CANONICAL_TOOLS = Object.freeze(
|
|
165
|
+
Object.entries(QCC_PHASE3_TOOL_NAMES).flatMap(([domain, names]) =>
|
|
166
|
+
names.map((name) => canonicalPhase3ToolName(domain, name)),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 必需输入参数(required 键,按工具短名)。
|
|
172
|
+
* 契约事实:三域 91 个工具里,90 个仅需 `searchKey`(企业名称或统一社会信用代码);
|
|
173
|
+
* 唯一例外 get_judicial_document_detail(风险·裁判文书详情)额外要求 `documentId`。
|
|
174
|
+
* 其余输入均为可选项(分页游标 / 年份 / 角色 / 状态 / 日期过滤等),不在必需契约内。
|
|
175
|
+
*/
|
|
176
|
+
export const QCC_PHASE3_REQUIRED_INPUTS = Object.freeze({
|
|
177
|
+
'get_judicial_document_detail': Object.freeze(['searchKey', 'documentId']),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
export const QCC_PHASE3_DEFAULT_REQUIRED_INPUTS = Object.freeze(['searchKey']);
|
|
181
|
+
|
|
182
|
+
/** 返回某工具的必需输入键(规范名或短名均可)。 */
|
|
183
|
+
export function requiredInputsFor(toolName) {
|
|
184
|
+
const name = String(toolName ?? '').split('__').at(-1) ?? '';
|
|
185
|
+
return QCC_PHASE3_REQUIRED_INPUTS[name] ?? QCC_PHASE3_DEFAULT_REQUIRED_INPUTS;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const PHASE3_SHORT_NAMES = new Set(
|
|
189
|
+
Object.values(QCC_PHASE3_TOOL_NAMES).flat(),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
/** 判断一个规范名/legacy 名/短名是否属于 0.5.0 三大域契约。 */
|
|
193
|
+
export function isPhase3Tool(toolName) {
|
|
194
|
+
const name = String(toolName ?? '');
|
|
195
|
+
const short = name.split('__').at(-1) ?? '';
|
|
196
|
+
return PHASE3_SHORT_NAMES.has(short);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 把任意合法写法归一为规范名:
|
|
201
|
+
* - 规范名 `mcp__qcc-<domain>__<name>` → 原样返回;
|
|
202
|
+
* - legacy 名 `mcp__<domain>__<name>` → `mcp__qcc-<domain>__<name>`;
|
|
203
|
+
* - 短名 `get_<name>` → 在三大域中定位并构造规范名。
|
|
204
|
+
* 不在契约内(未知短名 / 非三域前缀)返回 null。
|
|
205
|
+
*/
|
|
206
|
+
export function canonicalizePhase3Tool(toolName) {
|
|
207
|
+
const name = String(toolName ?? '');
|
|
208
|
+
const canonical = /^mcp__qcc-(risk|ipr|operation)__([a-z0-9_]+)$/.exec(name);
|
|
209
|
+
if (canonical) {
|
|
210
|
+
return QCC_PHASE3_TOOL_NAMES[canonical[1]].includes(canonical[2]) ? name : null;
|
|
211
|
+
}
|
|
212
|
+
const legacy = /^mcp__(risk|ipr|operation)__([a-z0-9_]+)$/.exec(name);
|
|
213
|
+
if (legacy) {
|
|
214
|
+
return QCC_PHASE3_TOOL_NAMES[legacy[1]].includes(legacy[2])
|
|
215
|
+
? canonicalPhase3ToolName(legacy[1], legacy[2])
|
|
216
|
+
: null;
|
|
217
|
+
}
|
|
218
|
+
if (/^get_[a-z0-9_]+$/.test(name) && PHASE3_SHORT_NAMES.has(name)) {
|
|
219
|
+
for (const domain of Object.keys(QCC_PHASE3_TOOL_NAMES)) {
|
|
220
|
+
if (QCC_PHASE3_TOOL_NAMES[domain].includes(name)) return canonicalPhase3ToolName(domain, name);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|