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.
@@ -0,0 +1,191 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import {
3
+ QCC_PHASE2_COMPANY_TOOLS,
4
+ QCC_PHASE2_HISTORY_TOOLS,
5
+ qccToolRuntimeCandidates,
6
+ } from './qcc-phase2.js';
7
+
8
+ export const QCC_PHASE2_ACCEPTANCE_SCHEMA_VERSION = 1;
9
+ export const QCC_PHASE2_EVIDENCE_KIND = 'qcc-phase2-real-tool-transcript';
10
+ export const QCC_PHASE2_ACCEPTANCE_FLOORS = Object.freeze({
11
+ minimumRecords: 20,
12
+ minimumCurrentDimensions: 15,
13
+ requiredHistoryDimensions: Object.keys(QCC_PHASE2_HISTORY_TOOLS).length,
14
+ });
15
+
16
+ const CURRENT_TOOLS = new Map(Object.entries(QCC_PHASE2_COMPANY_TOOLS));
17
+ const HISTORY_TOOLS = new Map(Object.entries(QCC_PHASE2_HISTORY_TOOLS));
18
+ const DELIVERED_STATUSES = new Set(['resolved', 'no_data']);
19
+ const ALLOWED_STATUSES = new Set([
20
+ ...DELIVERED_STATUSES,
21
+ 'permission_required',
22
+ 'not_available',
23
+ 'error',
24
+ ]);
25
+ const OPAQUE_REFERENCE = /^row-[0-9]{3,6}$/;
26
+
27
+ function hasOwn(value, key) {
28
+ return Object.prototype.hasOwnProperty.call(value, key);
29
+ }
30
+
31
+ function hasMeaningfulValue(value) {
32
+ if (value === null || value === undefined) return false;
33
+ if (typeof value === 'string') return value.trim().length > 0;
34
+ if (Array.isArray(value)) return value.length > 0;
35
+ if (typeof value === 'object') return Object.keys(value).length > 0;
36
+ return true;
37
+ }
38
+
39
+ function evaluateField(field) {
40
+ if (!field || typeof field !== 'object' || Array.isArray(field)) return false;
41
+ if (typeof field.key !== 'string' || field.key.length === 0) return false;
42
+ if (!hasOwn(field, 'value') || !hasOwn(field, 'sourceValue')) return false;
43
+ if (!hasMeaningfulValue(field.value) || !hasMeaningfulValue(field.sourceValue)) return false;
44
+ return isDeepStrictEqual(field.value, field.sourceValue);
45
+ }
46
+
47
+ function evaluateDimension(dimension, knownTools) {
48
+ const failures = [];
49
+ if (!dimension || typeof dimension !== 'object' || Array.isArray(dimension)) {
50
+ return { id: null, delivered: false, failures: ['DIMENSION_INVALID'] };
51
+ }
52
+ const id = typeof dimension.id === 'string' ? dimension.id : null;
53
+ const expectedTool = id ? knownTools.get(id) : null;
54
+ if (!expectedTool) failures.push('DIMENSION_UNKNOWN');
55
+ if (!qccToolRuntimeCandidates(expectedTool ?? '').includes(dimension.sourceTool)) {
56
+ failures.push('SOURCE_TOOL_MISMATCH');
57
+ }
58
+ if (!ALLOWED_STATUSES.has(dimension.status)) failures.push('DIMENSION_STATUS_INVALID');
59
+
60
+ const fieldsAreArray = Array.isArray(dimension.fields);
61
+ const fields = fieldsAreArray ? dimension.fields : [];
62
+ if (!fieldsAreArray) failures.push('FIELDS_ARRAY_REQUIRED');
63
+ if (dimension.status === 'resolved') {
64
+ if (fields.length === 0) failures.push('RESOLVED_FIELDS_REQUIRED');
65
+ if (!fields.every(evaluateField)) failures.push('VALUE_NOT_VERBATIM');
66
+ }
67
+ if (dimension.status === 'no_data' && fields.length > 0) {
68
+ failures.push('NO_DATA_MUST_NOT_CARRY_VALUES');
69
+ }
70
+
71
+ return {
72
+ id,
73
+ delivered: failures.length === 0 && DELIVERED_STATUSES.has(dimension.status),
74
+ failures,
75
+ };
76
+ }
77
+
78
+ function evaluateRecord(record, index, { requireHistory }) {
79
+ const safeReference = OPAQUE_REFERENCE.test(record?.reference ?? '')
80
+ ? record.reference
81
+ : `row-${String(index + 1).padStart(3, '0')}`;
82
+ const failures = [];
83
+ if (!OPAQUE_REFERENCE.test(record?.reference ?? '')) failures.push('REFERENCE_NOT_OPAQUE');
84
+ if (record?.entityStatus !== 'resolved') failures.push('ENTITY_NOT_RESOLVED');
85
+
86
+ const dimensions = Array.isArray(record?.dimensions) ? record.dimensions : [];
87
+ if (!Array.isArray(record?.dimensions)) failures.push('DIMENSIONS_REQUIRED');
88
+
89
+ const seen = new Set();
90
+ const deliveredStatuses = new Map();
91
+ let currentDelivered = 0;
92
+ let historyDelivered = 0;
93
+ for (const dimension of dimensions) {
94
+ const domain = dimension?.domain;
95
+ const tools = domain === 'history' ? HISTORY_TOOLS : domain === 'company' ? CURRENT_TOOLS : null;
96
+ if (!tools) {
97
+ failures.push('DIMENSION_DOMAIN_INVALID');
98
+ continue;
99
+ }
100
+ const uniqueKey = `${domain}:${dimension?.id ?? ''}`;
101
+ if (seen.has(uniqueKey)) {
102
+ failures.push('DIMENSION_DUPLICATE');
103
+ continue;
104
+ }
105
+ seen.add(uniqueKey);
106
+ const result = evaluateDimension(dimension, tools);
107
+ failures.push(...result.failures);
108
+ if (result.delivered) deliveredStatuses.set(uniqueKey, dimension.status);
109
+ if (result.delivered && domain === 'company') currentDelivered += 1;
110
+ if (result.delivered && domain === 'history') historyDelivered += 1;
111
+ }
112
+
113
+ if (
114
+ deliveredStatuses.get('company:resolveEntity') !== 'resolved'
115
+ || deliveredStatuses.get('company:registration') !== 'resolved'
116
+ ) {
117
+ failures.push('IDENTITY_EVIDENCE_REQUIRED');
118
+ }
119
+ if (currentDelivered < QCC_PHASE2_ACCEPTANCE_FLOORS.minimumCurrentDimensions) {
120
+ failures.push('CURRENT_DIMENSION_FLOOR_NOT_MET');
121
+ }
122
+ if (requireHistory && historyDelivered < QCC_PHASE2_ACCEPTANCE_FLOORS.requiredHistoryDimensions) {
123
+ failures.push('HISTORY_DIMENSION_FLOOR_NOT_MET');
124
+ }
125
+
126
+ return {
127
+ reference: safeReference,
128
+ passed: failures.length === 0,
129
+ currentDelivered,
130
+ historyDelivered,
131
+ failures: [...new Set(failures)].sort(),
132
+ entityStatus: ['resolved', 'ambiguous', 'unresolved'].includes(record?.entityStatus)
133
+ ? record.entityStatus
134
+ : 'invalid',
135
+ };
136
+ }
137
+
138
+ /**
139
+ * 评估 0.4.0 真实 E2E 证据。返回值严格不携带企业名、信用代码或字段值。
140
+ */
141
+ export function evaluateQccPhase2Evidence(evidence, { requireHistory = false } = {}) {
142
+ const globalFailures = [];
143
+ if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) {
144
+ globalFailures.push('EVIDENCE_INVALID');
145
+ }
146
+ if (evidence?.schemaVersion !== QCC_PHASE2_ACCEPTANCE_SCHEMA_VERSION) {
147
+ globalFailures.push('SCHEMA_VERSION_UNSUPPORTED');
148
+ }
149
+ if (evidence?.evidenceKind !== QCC_PHASE2_EVIDENCE_KIND) {
150
+ globalFailures.push('EVIDENCE_KIND_UNVERIFIED');
151
+ }
152
+ // 真实 E2E 证据必须显式声明 synthetic:false;缺省值也按未验证处理,保持 fail-closed。
153
+ if (evidence?.synthetic !== false) globalFailures.push('SYNTHETIC_EVIDENCE_REJECTED');
154
+ if (!Array.isArray(evidence?.records)) globalFailures.push('RECORDS_REQUIRED');
155
+ const records = Array.isArray(evidence?.records) ? evidence.records : [];
156
+ if (records.length < QCC_PHASE2_ACCEPTANCE_FLOORS.minimumRecords) {
157
+ globalFailures.push('RECORD_FLOOR_NOT_MET');
158
+ }
159
+ if (requireHistory && evidence?.historyAccess !== 'enterprise-certified') {
160
+ globalFailures.push('ENTERPRISE_HISTORY_ACCESS_NOT_VERIFIED');
161
+ }
162
+
163
+ const recordReports = records.map((record, index) => evaluateRecord(record, index, { requireHistory }));
164
+ const references = recordReports.map((record) => record.reference);
165
+ if (new Set(references).size !== references.length) globalFailures.push('REFERENCE_DUPLICATE');
166
+
167
+ const failedRecords = recordReports.filter((record) => !record.passed);
168
+ const summary = {
169
+ recordCount: recordReports.length,
170
+ passedRecords: recordReports.length - failedRecords.length,
171
+ failedRecords: failedRecords.length,
172
+ ambiguousRecords: recordReports.filter((record) => record.entityStatus === 'ambiguous').length,
173
+ unresolvedRecords: recordReports.filter((record) => record.entityStatus === 'unresolved').length,
174
+ minimumCurrentDimensions: recordReports.length === 0
175
+ ? 0
176
+ : Math.min(...recordReports.map((record) => record.currentDelivered)),
177
+ minimumHistoryDimensions: recordReports.length === 0
178
+ ? 0
179
+ : Math.min(...recordReports.map((record) => record.historyDelivered)),
180
+ };
181
+
182
+ return {
183
+ schemaVersion: QCC_PHASE2_ACCEPTANCE_SCHEMA_VERSION,
184
+ passed: globalFailures.length === 0 && failedRecords.length === 0,
185
+ requireHistory,
186
+ floors: QCC_PHASE2_ACCEPTANCE_FLOORS,
187
+ summary,
188
+ globalFailures: [...new Set(globalFailures)].sort(),
189
+ failures: failedRecords.map(({ reference, failures }) => ({ reference, codes: failures })),
190
+ };
191
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * QCC 0.4.0 二期工具契约。
3
+ *
4
+ * 这里只固化已经在本地 QCC MCP 一手源码注册表中核对过的工具名。
5
+ * 不固化上游响应字段:方案 A 由模型中介解读工具返回,并必须保留原值。
6
+ */
7
+
8
+ const companyTool = (name) => `mcp__qcc-company__${name}`;
9
+ const historyTool = (name) => `mcp__qcc-history__${name}`;
10
+
11
+ /**
12
+ * qcc-dsh-mcp-oauth 0.1.7 把 serverKey 直接作为 serverName,因而注册为
13
+ * `mcp__company__*` / `mcp__history__*`;修复版与手工配置使用文档约定的
14
+ * `mcp__qcc-company__*` / `mcp__qcc-history__*`。Bridge 同时兼容两者,
15
+ * 但始终把带 qcc- 前缀的名称作为规范契约。
16
+ */
17
+ export function qccToolRuntimeCandidates(canonicalName) {
18
+ const name = String(canonicalName ?? '');
19
+ const legacy = name.replace(/^mcp__qcc-(company|risk|ipr|operation|history|executive)__/, 'mcp__$1__');
20
+ return legacy === name ? Object.freeze([name]) : Object.freeze([name, legacy]);
21
+ }
22
+
23
+ export const QCC_PHASE2_COMPANY_TOOLS = Object.freeze({
24
+ resolveEntity: companyTool('get_company_by_query'),
25
+ registration: companyTool('get_company_registration_info'),
26
+ profile: companyTool('get_company_profile'),
27
+ verifyIdentity: companyTool('verify_company_accuracy'),
28
+ actualController: companyTool('get_actual_controller'),
29
+ beneficialOwners: companyTool('get_beneficial_owners'),
30
+ shareholders: companyTool('get_shareholder_info'),
31
+ externalInvestments: companyTool('get_external_investments'),
32
+ branches: companyTool('get_branches'),
33
+ keyPersonnel: companyTool('get_key_personnel'),
34
+ changes: companyTool('get_change_records'),
35
+ annualReports: companyTool('get_annual_reports'),
36
+ contact: companyTool('get_contact_info'),
37
+ taxInvoice: companyTool('get_tax_invoice_info'),
38
+ listing: companyTool('get_listing_info'),
39
+ financial: companyTool('get_financial_data'),
40
+ });
41
+
42
+ export const QCC_PHASE2_HISTORY_TOOLS = Object.freeze({
43
+ shareholders: historyTool('get_historical_shareholders'),
44
+ legalRepresentative: historyTool('get_historical_legal_rep'),
45
+ executives: historyTool('get_historical_executives'),
46
+ registration: historyTool('get_historical_registration'),
47
+ });
48
+
49
+ /**
50
+ * 用户可选的维度组。identity 是任何任务的必需步骤;其余组按用户意图调用。
51
+ * 字段缺失、无权或上游不可用时,应保留状态而不是补造值。
52
+ */
53
+ export const QCC_PHASE2_DIMENSION_GROUPS = Object.freeze({
54
+ identity: Object.freeze({
55
+ label: '主体锚定与核验',
56
+ access: 'basic',
57
+ tools: Object.freeze([
58
+ QCC_PHASE2_COMPANY_TOOLS.resolveEntity,
59
+ QCC_PHASE2_COMPANY_TOOLS.registration,
60
+ QCC_PHASE2_COMPANY_TOOLS.verifyIdentity,
61
+ ]),
62
+ }),
63
+ panorama: Object.freeze({
64
+ label: '企业全景',
65
+ access: 'basic',
66
+ tools: Object.freeze([
67
+ QCC_PHASE2_COMPANY_TOOLS.profile,
68
+ QCC_PHASE2_COMPANY_TOOLS.contact,
69
+ QCC_PHASE2_COMPANY_TOOLS.taxInvoice,
70
+ QCC_PHASE2_COMPANY_TOOLS.listing,
71
+ QCC_PHASE2_COMPANY_TOOLS.financial,
72
+ ]),
73
+ }),
74
+ ownership: Object.freeze({
75
+ label: '股权穿透',
76
+ access: 'basic',
77
+ tools: Object.freeze([
78
+ QCC_PHASE2_COMPANY_TOOLS.actualController,
79
+ QCC_PHASE2_COMPANY_TOOLS.beneficialOwners,
80
+ QCC_PHASE2_COMPANY_TOOLS.shareholders,
81
+ QCC_PHASE2_COMPANY_TOOLS.externalInvestments,
82
+ ]),
83
+ }),
84
+ governance: Object.freeze({
85
+ label: '组织与沿革',
86
+ access: 'basic',
87
+ tools: Object.freeze([
88
+ QCC_PHASE2_COMPANY_TOOLS.branches,
89
+ QCC_PHASE2_COMPANY_TOOLS.keyPersonnel,
90
+ QCC_PHASE2_COMPANY_TOOLS.changes,
91
+ QCC_PHASE2_COMPANY_TOOLS.annualReports,
92
+ ]),
93
+ }),
94
+ history: Object.freeze({
95
+ label: '历史工商',
96
+ access: 'enterprise-certified',
97
+ tools: Object.freeze(Object.values(QCC_PHASE2_HISTORY_TOOLS)),
98
+ }),
99
+ });
@@ -0,0 +1,322 @@
1
+ /**
2
+ * G5 Host 内存态:候选续跑、人工重试和请求幂等。
3
+ *
4
+ * 原始/补全行只保存在当前 Host 进程,不写 storageDomain;重启后 run 明确失效。
5
+ * 幂等缓存保存请求指纹与同源响应,避免客户端超时重发导致重复计费。
6
+ */
7
+ import { createHash, randomUUID } from 'node:crypto';
8
+ import { QccBridgeError } from './qcc.js';
9
+ import { safeAuditEvent } from './qcc-safety.js';
10
+
11
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
12
+ const DEFAULT_MAX_RUNS = 50;
13
+ const DEFAULT_MAX_AUDIT = 200;
14
+ const DEFAULT_MAX_IDEMPOTENCY = 200;
15
+
16
+ function isRecord(value) {
17
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
18
+ }
19
+
20
+ function canonicalize(value) {
21
+ if (Array.isArray(value)) return value.map(canonicalize);
22
+ if (!isRecord(value)) return value;
23
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
24
+ }
25
+
26
+ export function fingerprintRequest(operation, payload) {
27
+ return createHash('sha256')
28
+ .update(JSON.stringify({ operation: String(operation), payload: canonicalize(payload) }))
29
+ .digest('hex');
30
+ }
31
+
32
+ export function validateIdempotencyKey(value) {
33
+ const key = String(value ?? '').trim();
34
+ if (!key) {
35
+ throw new QccBridgeError('QCC_IDEMPOTENCY_REQUIRED', 'A unique idempotencyKey is required before paid QCC calls');
36
+ }
37
+ if (!/^[A-Za-z0-9._:-]{8,128}$/.test(key)) {
38
+ throw new QccBridgeError(
39
+ 'QCC_IDEMPOTENCY_INVALID',
40
+ 'idempotencyKey must be 8-128 characters using letters, numbers, dot, underscore, colon or hyphen',
41
+ );
42
+ }
43
+ return key;
44
+ }
45
+
46
+ function deriveState(record) {
47
+ if (record.reviewQueue.length > 0) return 'awaiting-review';
48
+ if (record.errors.some((item) => item.error?.retryable)) return 'needs-retry';
49
+ if (record.errors.length > 0) return 'completed-with-errors';
50
+ return 'completed';
51
+ }
52
+
53
+ function recomputeSummary(record) {
54
+ const count = (status) => record.rows.filter((row) => row.qcc_match_status === status).length;
55
+ record.summary = {
56
+ ...record.summary,
57
+ totalRows: record.rows.length,
58
+ enriched: count('enriched'),
59
+ ambiguous: count('ambiguous'),
60
+ unresolved: count('unresolved'),
61
+ failed: count('failed'),
62
+ missingName: count('missing-name'),
63
+ includeRisk: Boolean(record.includeRisk),
64
+ };
65
+ record.state = deriveState(record);
66
+ }
67
+
68
+ function clone(value) {
69
+ return structuredClone(value);
70
+ }
71
+
72
+ export class G5RunStore {
73
+ constructor({
74
+ clock = () => Date.now(),
75
+ runIdFactory = () => `g5-${randomUUID()}`,
76
+ ttlMs = DEFAULT_TTL_MS,
77
+ maxRuns = DEFAULT_MAX_RUNS,
78
+ maxAudit = DEFAULT_MAX_AUDIT,
79
+ maxIdempotency = DEFAULT_MAX_IDEMPOTENCY,
80
+ } = {}) {
81
+ this.clock = clock;
82
+ this.runIdFactory = runIdFactory;
83
+ this.ttlMs = ttlMs;
84
+ this.maxRuns = maxRuns;
85
+ this.maxAudit = maxAudit;
86
+ this.maxIdempotency = maxIdempotency;
87
+ this.runs = new Map();
88
+ this.idempotency = new Map();
89
+ this.locks = new Set();
90
+ }
91
+
92
+ nowIso() {
93
+ return new Date(this.clock()).toISOString();
94
+ }
95
+
96
+ cleanup() {
97
+ const cutoff = this.clock() - this.ttlMs;
98
+ for (const [id, run] of this.runs) {
99
+ if (run.touchedAtMs < cutoff) this.runs.delete(id);
100
+ }
101
+ for (const [key, entry] of this.idempotency) {
102
+ // 进行中的付费请求必须保留幂等屏障:即使执行时间超过 TTL,也不能让
103
+ // 同一 key 的重试绕过首个 Promise,否则可能重复计费。
104
+ if (entry.settled && entry.touchedAtMs < cutoff) this.idempotency.delete(key);
105
+ }
106
+ while (this.runs.size > this.maxRuns) this.runs.delete(this.runs.keys().next().value);
107
+ }
108
+
109
+ async executeOnce({ key: rawKey, fingerprint, operation }) {
110
+ const key = validateIdempotencyKey(rawKey);
111
+ this.cleanup();
112
+ const existing = this.idempotency.get(key);
113
+ if (existing) {
114
+ existing.touchedAtMs = this.clock();
115
+ if (existing.fingerprint !== fingerprint) {
116
+ throw new QccBridgeError(
117
+ 'QCC_IDEMPOTENCY_CONFLICT',
118
+ 'idempotencyKey was already used with a different request',
119
+ );
120
+ }
121
+ return { value: await existing.promise, replayed: true };
122
+ }
123
+ if (this.idempotency.size >= this.maxIdempotency) {
124
+ throw new QccBridgeError(
125
+ 'QCC_IDEMPOTENCY_CAPACITY',
126
+ 'G5 idempotency cache is full; wait for older requests to expire before starting another paid call',
127
+ { retryable: true },
128
+ );
129
+ }
130
+
131
+ const entry = {
132
+ fingerprint,
133
+ promise: null,
134
+ touchedAtMs: this.clock(),
135
+ settled: false,
136
+ };
137
+ entry.promise = Promise.resolve()
138
+ .then(operation)
139
+ .finally(() => {
140
+ entry.settled = true;
141
+ });
142
+ this.idempotency.set(key, entry);
143
+ return { value: await entry.promise, replayed: false };
144
+ }
145
+
146
+ createRun({ headers, nameField, includeRisk, concurrency, result, audit = [] }) {
147
+ this.cleanup();
148
+ const id = this.runIdFactory();
149
+ const at = this.nowIso();
150
+ const record = {
151
+ id,
152
+ state: 'completed',
153
+ version: 1,
154
+ createdAt: at,
155
+ updatedAt: at,
156
+ touchedAtMs: this.clock(),
157
+ headers: Array.isArray(headers) ? headers.map(String) : [],
158
+ nameField: String(nameField ?? 'name'),
159
+ includeRisk: Boolean(includeRisk),
160
+ concurrency: Number(concurrency ?? 2),
161
+ summary: clone(result.summary),
162
+ rows: clone(result.rows),
163
+ reviewQueue: clone(result.reviewQueue),
164
+ errors: clone(result.errors),
165
+ audit: audit.map(safeAuditEvent).slice(-this.maxAudit),
166
+ };
167
+ recomputeSummary(record);
168
+ this.runs.set(id, record);
169
+ this.cleanup();
170
+ return this.snapshot(record);
171
+ }
172
+
173
+ requireRun(id) {
174
+ this.cleanup();
175
+ const record = this.runs.get(String(id ?? ''));
176
+ if (!record) {
177
+ throw new QccBridgeError(
178
+ 'QCC_RUN_NOT_FOUND',
179
+ 'G5 run was not found or expired; start a new enrichment run',
180
+ { retryable: false },
181
+ );
182
+ }
183
+ record.touchedAtMs = this.clock();
184
+ return record;
185
+ }
186
+
187
+ get(id) {
188
+ return this.snapshot(this.requireRun(id));
189
+ }
190
+
191
+ snapshot(record) {
192
+ return clone({
193
+ runId: record.id,
194
+ state: record.state,
195
+ version: record.version,
196
+ createdAt: record.createdAt,
197
+ updatedAt: record.updatedAt,
198
+ headers: record.headers,
199
+ summary: record.summary,
200
+ rows: record.rows,
201
+ reviewQueue: record.reviewQueue,
202
+ errors: record.errors,
203
+ audit: record.audit,
204
+ expiresInMs: this.ttlMs,
205
+ persistence: 'host-memory',
206
+ });
207
+ }
208
+
209
+ appendAudit(record, event) {
210
+ record.audit.push(safeAuditEvent(event));
211
+ if (record.audit.length > this.maxAudit) record.audit.splice(0, record.audit.length - this.maxAudit);
212
+ record.touchedAtMs = this.clock();
213
+ }
214
+
215
+ touch(record) {
216
+ record.version += 1;
217
+ record.updatedAt = this.nowIso();
218
+ record.touchedAtMs = this.clock();
219
+ recomputeSummary(record);
220
+ }
221
+
222
+ patchCompany(record, companyName, rowIndexes, result) {
223
+ record.reviewQueue = record.reviewQueue.filter((item) => item.companyName !== companyName);
224
+ record.errors = record.errors.filter((item) => item.companyName !== companyName);
225
+ for (const index of rowIndexes) {
226
+ const row = record.rows[index] ?? {};
227
+ if (result.status === 'enriched') {
228
+ record.rows[index] = {
229
+ ...row,
230
+ ...result.fields,
231
+ qcc_match_status: 'enriched',
232
+ qcc_source: 'qcc-mcp',
233
+ };
234
+ } else {
235
+ record.rows[index] = { ...row, qcc_match_status: result.status };
236
+ }
237
+ }
238
+ if (result.status === 'ambiguous') {
239
+ record.reviewQueue.push({ companyName, rowIndexes: [...rowIndexes], candidates: clone(result.candidates) });
240
+ }
241
+ if (result.status === 'failed') {
242
+ record.errors.push({ companyName, rowIndexes: [...rowIndexes], error: clone(result.error) });
243
+ }
244
+ this.touch(record);
245
+ }
246
+
247
+ async resolveCandidate(runId, { companyName, selectedCreditNo }, bridge, options = {}) {
248
+ const record = this.requireRun(runId);
249
+ const name = String(companyName ?? '').trim();
250
+ const creditNo = String(selectedCreditNo ?? '').trim();
251
+ const queued = record.reviewQueue.find((item) => item.companyName === name);
252
+ if (!queued) {
253
+ throw new QccBridgeError('QCC_REVIEW_NOT_PENDING', 'This company is not awaiting candidate review');
254
+ }
255
+ const candidate = queued.candidates.find((item) => item.creditNo === creditNo);
256
+ if (!creditNo || !candidate) {
257
+ throw new QccBridgeError('QCC_CANDIDATE_INVALID', 'Selected credit number is not in the pending candidate list');
258
+ }
259
+
260
+ const lock = `${record.id}:resolve:${name}`;
261
+ if (this.locks.has(lock)) {
262
+ throw new QccBridgeError('QCC_OPERATION_IN_PROGRESS', 'Candidate resolution is already in progress', { retryable: true });
263
+ }
264
+ this.locks.add(lock);
265
+ try {
266
+ const result = await bridge.enrichLockedCompany({
267
+ companyName: candidate.companyName || name,
268
+ creditNo,
269
+ }, {
270
+ ...options,
271
+ includeRisk: record.includeRisk,
272
+ onAudit: (event) => this.appendAudit(record, event),
273
+ });
274
+ this.patchCompany(record, name, queued.rowIndexes, result);
275
+ return this.snapshot(record);
276
+ } finally {
277
+ this.locks.delete(lock);
278
+ }
279
+ }
280
+
281
+ async retryCompanies(runId, companyNames, bridge, options = {}) {
282
+ const record = this.requireRun(runId);
283
+ const names = [...new Set((Array.isArray(companyNames) ? companyNames : []).map((name) => String(name).trim()).filter(Boolean))];
284
+ if (names.length === 0) {
285
+ throw new QccBridgeError('QCC_RETRY_EMPTY', 'At least one failed company must be selected for manual retry');
286
+ }
287
+ const selected = names.map((name) => {
288
+ const item = record.errors.find((error) => error.companyName === name);
289
+ if (!item) throw new QccBridgeError('QCC_RETRY_NOT_FAILED', 'Selected company is not in the failed queue');
290
+ if (!item.error?.retryable) throw new QccBridgeError('QCC_RETRY_NOT_ALLOWED', 'Selected failure is not retryable');
291
+ return item;
292
+ });
293
+ const locks = selected.map((item) => `${record.id}:retry:${item.companyName}`);
294
+ if (locks.some((lock) => this.locks.has(lock))) {
295
+ throw new QccBridgeError('QCC_OPERATION_IN_PROGRESS', 'A selected retry is already in progress', { retryable: true });
296
+ }
297
+ locks.forEach((lock) => this.locks.add(lock));
298
+
299
+ try {
300
+ for (const item of selected) {
301
+ let result;
302
+ try {
303
+ result = await bridge.enrichCompany(item.companyName, {
304
+ ...options,
305
+ includeRisk: record.includeRisk,
306
+ onAudit: (event) => this.appendAudit(record, event),
307
+ });
308
+ } catch (error) {
309
+ if (error?.code === 'QCC_ABORTED') throw error;
310
+ const normalized = error instanceof QccBridgeError
311
+ ? error
312
+ : new QccBridgeError('QCC_RUNTIME_ERROR', 'QCC enrichment failed', { retryable: true });
313
+ result = { status: 'failed', error: normalized.toJSON() };
314
+ }
315
+ this.patchCompany(record, item.companyName, item.rowIndexes, result);
316
+ }
317
+ return this.snapshot(record);
318
+ } finally {
319
+ locks.forEach((lock) => this.locks.delete(lock));
320
+ }
321
+ }
322
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * G5 安全输出工具。
3
+ *
4
+ * 仅用于日志、审计和 E2E 报告;同源业务响应仍由调用方按契约返回。
5
+ * 这里不尝试“识别所有秘密”,而是采用两层防线:敏感键整值抹除,字符串再做
6
+ * Bearer/JWT/OAuth 参数、信用代码、邮箱、手机号和已知企业名替换。
7
+ */
8
+
9
+ const SECRET_KEY = /(?:^|_)(?:access_token|refresh_token|id_token|token|authorization|cookie|secret|client_secret|code_verifier|api_key|apikey|password)(?:$|_)/i;
10
+ const CREDIT_NO = /\b[0-9A-Z]{18}\b/g;
11
+ const PHONE = /(?<!\d)1[3-9]\d{9}(?!\d)/g;
12
+ const EMAIL = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
13
+ const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
14
+ const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
15
+ const URL_SECRET = /([?&](?:code|token|access_token|refresh_token|id_token|client_secret)=)[^&\s]+/gi;
16
+
17
+ function escapeRegExp(value) {
18
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+ }
20
+
21
+ function companyAliases(companyNames) {
22
+ const seen = new Set();
23
+ return (Array.isArray(companyNames) ? companyNames : [])
24
+ .map((name) => String(name ?? '').trim())
25
+ .filter((name) => name && !seen.has(name) && seen.add(name))
26
+ .sort((a, b) => b.length - a.length)
27
+ .map((name, index) => ({
28
+ pattern: new RegExp(escapeRegExp(name), 'g'),
29
+ replacement: `[COMPANY_${String(index + 1).padStart(2, '0')}]`,
30
+ }));
31
+ }
32
+
33
+ export function redactSensitiveText(value, options = {}) {
34
+ let text = String(value ?? '');
35
+ text = text
36
+ .replace(BEARER, 'Bearer [REDACTED]')
37
+ .replace(JWT, '[JWT_REDACTED]')
38
+ .replace(URL_SECRET, '$1[REDACTED]')
39
+ .replace(CREDIT_NO, '[CREDIT_NO_REDACTED]')
40
+ .replace(PHONE, '[PHONE_REDACTED]')
41
+ .replace(EMAIL, '[EMAIL_REDACTED]');
42
+ for (const alias of companyAliases(options.companyNames)) {
43
+ text = text.replace(alias.pattern, alias.replacement);
44
+ }
45
+ return text;
46
+ }
47
+
48
+ export function redactSensitive(value, options = {}) {
49
+ const seen = new WeakSet();
50
+
51
+ const visit = (input, key = '') => {
52
+ if (SECRET_KEY.test(key)) return '[REDACTED]';
53
+ if (typeof input === 'string') return redactSensitiveText(input, options);
54
+ if (input === null || typeof input !== 'object') return input;
55
+ if (seen.has(input)) return '[CIRCULAR]';
56
+ seen.add(input);
57
+ if (Array.isArray(input)) return input.map((item) => visit(item));
58
+ return Object.fromEntries(Object.entries(input).map(([childKey, child]) => [childKey, visit(child, childKey)]));
59
+ };
60
+
61
+ return visit(value);
62
+ }
63
+
64
+ export function safeAuditEvent(event) {
65
+ const source = event && typeof event === 'object' ? event : {};
66
+ return {
67
+ at: String(source.at ?? new Date().toISOString()),
68
+ event: 'qcc-tool-call',
69
+ toolName: String(source.toolName ?? ''),
70
+ callId: String(source.callId ?? ''),
71
+ attempt: Number(source.attempt ?? 0),
72
+ outcome: String(source.outcome ?? 'unknown'),
73
+ code: source.code ? String(source.code) : null,
74
+ upstreamCode: source.upstreamCode ? String(source.upstreamCode) : null,
75
+ durationMs: Math.max(0, Number(source.durationMs ?? 0)),
76
+ };
77
+ }