dsh-data-cleaning-agent 0.6.3 → 0.8.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,414 @@
1
+ /**
2
+ * 图片企业名单接入。
3
+ *
4
+ * Browser 只负责把用户明确选择/粘贴的图片暂存到 Host;真实视觉识别由
5
+ * Agent-owned 高层工具在当前会话执行上下文中调用已探测到的 Provider。
6
+ * 当前已验证 Provider 是 modlens_read_image。图片使用 0600 临时文件,识别
7
+ * 完成、失败、取消或 TTL 到期后立即删除;不会进入 storageDomain 或导出制品。
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { mkdir, unlink, writeFile } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ export const TOOL_IMAGE_EXTRACT = 'data_cleaning_extract_image_companies';
15
+ export const IMAGE_PROVIDER_MODLENS = 'modlens_read_image';
16
+
17
+ export const IMAGE_LIMITS = Object.freeze({
18
+ maxBytes: 8 * 1024 * 1024,
19
+ maxEntries: 100,
20
+ ttlMs: 15 * 60 * 1000,
21
+ });
22
+
23
+ const IMAGE_ROOT = join(tmpdir(), 'dsh-data-cleaning-agent-images');
24
+
25
+ export class ImageIntakeError extends Error {
26
+ constructor(code, message, status = 400, details = {}) {
27
+ super(message);
28
+ this.name = 'ImageIntakeError';
29
+ this.code = code;
30
+ this.status = status;
31
+ Object.assign(this, details);
32
+ }
33
+ }
34
+
35
+ function safeName(value) {
36
+ return String(value ?? '企业名单图片')
37
+ .replace(/[\u0000-\u001f\u007f/\\]/g, '_')
38
+ .trim()
39
+ .slice(0, 160) || '企业名单图片';
40
+ }
41
+
42
+ export function sniffImage(bytes) {
43
+ if (!Buffer.isBuffer(bytes) || bytes.length < 12) return null;
44
+ if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
45
+ return { mimeType: 'image/png', extension: 'png' };
46
+ }
47
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
48
+ return { mimeType: 'image/jpeg', extension: 'jpg' };
49
+ }
50
+ if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') {
51
+ return { mimeType: 'image/webp', extension: 'webp' };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function decodeImage(content) {
57
+ const raw = String(content ?? '').replace(/^data:image\/[a-z0-9.+-]+;base64,/i, '');
58
+ if (!raw || !/^[A-Za-z0-9+/]*={0,2}$/.test(raw) || raw.length % 4 === 1) {
59
+ throw new ImageIntakeError('DC_IMAGE_BASE64', '图片内容不是有效的 Base64 数据。');
60
+ }
61
+ const bytes = Buffer.from(raw, 'base64');
62
+ if (!bytes.length) throw new ImageIntakeError('DC_IMAGE_EMPTY', '图片内容为空。');
63
+ if (bytes.length > IMAGE_LIMITS.maxBytes) {
64
+ throw new ImageIntakeError('DC_IMAGE_TOO_LARGE', '图片不能超过 8 MiB。', 413);
65
+ }
66
+ const detected = sniffImage(bytes);
67
+ if (!detected) {
68
+ throw new ImageIntakeError('DC_IMAGE_TYPE', '仅支持真实 PNG、JPEG 或 WebP 图片。', 415);
69
+ }
70
+ return { bytes, ...detected };
71
+ }
72
+
73
+ function unwrapProviderValue(result) {
74
+ if (result?.isError === true) {
75
+ const message = result?.error?.message || result?.message || '图片识别 Provider 调用失败。';
76
+ throw new ImageIntakeError('DC_IMAGE_PROVIDER_FAILED', String(message), 502);
77
+ }
78
+ let value = result?.value ?? result;
79
+ if (value && Array.isArray(value.content)) {
80
+ const text = value.content.filter((item) => item?.type === 'text').map((item) => item.text).join('\n');
81
+ if (text) {
82
+ try { value = JSON.parse(text); } catch { value = { ocr: { full_text: text } }; }
83
+ }
84
+ }
85
+ return value;
86
+ }
87
+
88
+ export function providerText(result) {
89
+ const value = unwrapProviderValue(result);
90
+ const lines = Array.isArray(value?.ocr?.lines)
91
+ ? value.ocr.lines.map((line) => String(line?.text ?? '').trim()).filter(Boolean)
92
+ : [];
93
+ const text = String(value?.ocr?.full_text ?? lines.join('\n') ?? '').trim();
94
+ if (!text) {
95
+ throw new ImageIntakeError('DC_IMAGE_NO_TEXT', '图片中未识别到可用文字,请换用更清晰的原图。', 422);
96
+ }
97
+ return text;
98
+ }
99
+
100
+ const CREDIT_RE = /\b[0-9A-HJ-NPQRTUWXY]{18}\b/gi;
101
+ const COMPANY_END = '(?:有限责任公司|股份有限公司|集团有限公司|有限公司|集团公司|公司|普通合伙|有限合伙|合伙企业|个人独资企业|农民专业合作社|合作社|事务所|研究院|研究所|中心|商行|工厂|厂)';
102
+ const COMPANY_RE = new RegExp(`[\\p{Script=Han}A-Za-z0-9()()·&++—\\-]{2,72}${COMPANY_END}`, 'gu');
103
+ const HEADER_RE = /^(?:序号|企业名称|公司名称|单位名称|统一社会信用代码|信用代码|注册号|名称|企业名单)$/i;
104
+
105
+ function cleanCell(value) {
106
+ return String(value ?? '')
107
+ .replace(/^\s*(?:[-•·●▪◦]|\d{1,4}[.)、::]?)\s*/, '')
108
+ .replace(/^(?:企业名称|公司名称|单位名称|统一社会信用代码|信用代码|注册号)\s*[::]\s*/i, '')
109
+ .replace(/[\s\u00a0]+/g, '')
110
+ .trim();
111
+ }
112
+
113
+ /** 从 OCR 文本确定性提取一企一行的名称/信用代码,不推断不存在的主体。 */
114
+ export function extractCompanyEntries(text, maxEntries = IMAGE_LIMITS.maxEntries) {
115
+ const entries = [];
116
+ const seen = new Set();
117
+ const push = (name, creditNo) => {
118
+ const cleanName = cleanCell(name);
119
+ const cleanCredit = String(creditNo ?? '').trim().toUpperCase();
120
+ if (!cleanName && !cleanCredit) return;
121
+ if (cleanName && HEADER_RE.test(cleanName)) return;
122
+ const display = [cleanName, cleanCredit].filter(Boolean).join(' | ');
123
+ const key = `${cleanName.toLowerCase()}|${cleanCredit}`;
124
+ if (!seen.has(key) && entries.length < maxEntries) {
125
+ seen.add(key);
126
+ entries.push(display);
127
+ }
128
+ };
129
+
130
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
131
+ const line = rawLine.trim();
132
+ if (!line) continue;
133
+ const credits = [...line.matchAll(CREDIT_RE)].map((match) => match[0].toUpperCase());
134
+ const names = [];
135
+ for (const cell of line.split(/\t|[||]|\s{2,}|[,,;;]/)) {
136
+ const compact = cleanCell(cell);
137
+ for (const match of compact.matchAll(COMPANY_RE)) names.push(match[0]);
138
+ }
139
+ if (!names.length) {
140
+ const compact = cleanCell(line.replace(CREDIT_RE, ''));
141
+ for (const match of compact.matchAll(COMPANY_RE)) names.push(match[0]);
142
+ }
143
+ if (names.length === 1 && credits.length === 1) push(names[0], credits[0]);
144
+ else {
145
+ for (const name of names) push(name, '');
146
+ for (const credit of credits) push('', credit);
147
+ }
148
+ if (entries.length >= maxEntries) break;
149
+ }
150
+ return entries;
151
+ }
152
+
153
+ function publicRecord(record, now = Date.now()) {
154
+ return structuredClone({
155
+ commandId: record.commandId,
156
+ state: record.state,
157
+ fileName: record.fileName,
158
+ mimeType: record.mimeType,
159
+ sizeBytes: record.sizeBytes,
160
+ provider: record.provider,
161
+ createdAt: record.createdAt,
162
+ updatedAt: record.updatedAt,
163
+ expiresInMs: Math.max(0, record.expiresAt - now),
164
+ result: record.result,
165
+ error: record.error,
166
+ });
167
+ }
168
+
169
+ function safeFailure(error) {
170
+ if (error instanceof ImageIntakeError && error.code !== 'DC_IMAGE_PROVIDER_FAILED') {
171
+ return { code: error.code, message: error.message };
172
+ }
173
+ return {
174
+ code: 'DC_IMAGE_PROVIDER_FAILED',
175
+ message: '图片识别 Provider 当前不可用或配置无效。请配置 Modlens 可用视觉通道,或改用文本/Excel 名单。',
176
+ };
177
+ }
178
+
179
+ export class ImageIntakeStore {
180
+ constructor({ tools, clock = () => Date.now(), ttlMs = IMAGE_LIMITS.ttlMs } = {}) {
181
+ if (!tools || typeof tools.get !== 'function' || typeof tools.execute !== 'function') {
182
+ throw new TypeError('ImageIntakeStore requires ctx.tools get/execute');
183
+ }
184
+ this.tools = tools;
185
+ this.clock = clock;
186
+ this.ttlMs = ttlMs;
187
+ this.records = new Map();
188
+ }
189
+
190
+ providerDefinition() {
191
+ try { return this.tools.get(IMAGE_PROVIDER_MODLENS); } catch { return undefined; }
192
+ }
193
+
194
+ capabilities() {
195
+ const definition = this.providerDefinition();
196
+ return {
197
+ ready: Boolean(definition),
198
+ provider: definition ? IMAGE_PROVIDER_MODLENS : null,
199
+ nativeAttachmentUi: true,
200
+ pasteAndDrop: true,
201
+ formats: ['image/png', 'image/jpeg', 'image/webp'],
202
+ limits: IMAGE_LIMITS,
203
+ persistence: 'ephemeral-host-file',
204
+ };
205
+ }
206
+
207
+ scheduleExpiry(record) {
208
+ if (record.timer) clearTimeout(record.timer);
209
+ const delay = Math.max(1, Math.min(2_147_483_647, record.expiresAt - this.clock()));
210
+ record.timer = setTimeout(() => {
211
+ this.expire(record.commandId).catch(() => {});
212
+ }, delay);
213
+ record.timer.unref?.();
214
+ }
215
+
216
+ async expire(commandId) {
217
+ const record = this.records.get(commandId);
218
+ if (!record) return;
219
+ if (record.state === 'running') {
220
+ record.expiresAt = this.clock() + 60_000;
221
+ this.scheduleExpiry(record);
222
+ return;
223
+ }
224
+ if (record.timer) clearTimeout(record.timer);
225
+ await this.removeFile(record);
226
+ this.records.delete(commandId);
227
+ }
228
+
229
+ async cleanup() {
230
+ const now = this.clock();
231
+ for (const [id, record] of this.records) {
232
+ if (record.expiresAt <= now && record.state !== 'running') {
233
+ if (record.timer) clearTimeout(record.timer);
234
+ await this.removeFile(record);
235
+ this.records.delete(id);
236
+ }
237
+ }
238
+ }
239
+
240
+ async removeFile(record) {
241
+ if (!record?.path) return;
242
+ const path = record.path;
243
+ record.path = null;
244
+ try { await unlink(path); } catch (error) {
245
+ if (error?.code !== 'ENOENT') throw error;
246
+ }
247
+ }
248
+
249
+ async prepare(input = {}) {
250
+ await this.cleanup();
251
+ const provider = this.providerDefinition();
252
+ if (!provider) {
253
+ throw new ImageIntakeError(
254
+ 'DC_IMAGE_PROVIDER_UNAVAILABLE',
255
+ '当前 DSH 没有可用的图片文字识别 Provider。请安装并配置 Modlens,或改用文本/Excel 名单。',
256
+ 503,
257
+ );
258
+ }
259
+ const decoded = decodeImage(input.content);
260
+ const commandId = `dci-${randomUUID()}`;
261
+ const at = new Date(this.clock()).toISOString();
262
+ await mkdir(IMAGE_ROOT, { recursive: true, mode: 0o700 });
263
+ const path = join(IMAGE_ROOT, `${commandId}.${decoded.extension}`);
264
+ await writeFile(path, decoded.bytes, { mode: 0o600, flag: 'wx' });
265
+ const record = {
266
+ commandId,
267
+ state: 'prepared',
268
+ fileName: safeName(input.fileName),
269
+ mimeType: decoded.mimeType,
270
+ sizeBytes: decoded.bytes.length,
271
+ provider: IMAGE_PROVIDER_MODLENS,
272
+ path,
273
+ createdAt: at,
274
+ updatedAt: at,
275
+ expiresAt: this.clock() + this.ttlMs,
276
+ result: null,
277
+ error: null,
278
+ promise: null,
279
+ timer: null,
280
+ };
281
+ this.records.set(commandId, record);
282
+ this.scheduleExpiry(record);
283
+ return publicRecord(record, this.clock());
284
+ }
285
+
286
+ require(commandId) {
287
+ const record = this.records.get(String(commandId ?? ''));
288
+ if (!record || (record.expiresAt <= this.clock() && record.state !== 'running')) {
289
+ if (record && record.state !== 'running') this.expire(record.commandId).catch(() => {});
290
+ throw new ImageIntakeError('DC_IMAGE_COMMAND_NOT_FOUND', '图片识别任务不存在或已过期。', 404);
291
+ }
292
+ return record;
293
+ }
294
+
295
+ status(commandId) {
296
+ return publicRecord(this.require(commandId), this.clock());
297
+ }
298
+
299
+ async run(commandId, exec) {
300
+ const record = this.require(commandId);
301
+ if (!exec?.agent || !exec?.token) {
302
+ throw new ImageIntakeError('DC_IMAGE_AGENT_EXECUTION_REQUIRED', '图片识别必须由当前 DSH Agent 会话执行。', 409);
303
+ }
304
+ if (record.promise) return record.promise;
305
+ if (record.state === 'completed') return { commandId, ...record.result };
306
+ const provider = this.providerDefinition();
307
+ if (!provider) {
308
+ throw new ImageIntakeError('DC_IMAGE_PROVIDER_UNAVAILABLE', '图片识别 Provider 已离线。', 503);
309
+ }
310
+ record.state = 'running';
311
+ record.updatedAt = new Date(this.clock()).toISOString();
312
+ record.promise = this.tools.execute({
313
+ name: provider.name ?? IMAGE_PROVIDER_MODLENS,
314
+ callId: `dc-image-${randomUUID()}`,
315
+ rootCallId: exec.rootCallId,
316
+ parent: exec.token,
317
+ agent: exec.agent,
318
+ signal: exec.signal,
319
+ arguments: {
320
+ path: record.path,
321
+ prompt: '完整识别图片中的企业名单。重点逐行转写企业全称、统一社会信用代码或注册号;保留原始文字,不猜测模糊字符。',
322
+ },
323
+ }).then(async (providerResult) => {
324
+ const text = providerText(providerResult);
325
+ const entries = extractCompanyEntries(text);
326
+ if (!entries.length) {
327
+ throw new ImageIntakeError('DC_IMAGE_NO_COMPANY', '图片文字已识别,但未提取到企业全称或 18 位统一社会信用代码。', 422);
328
+ }
329
+ record.result = {
330
+ entries,
331
+ entryCount: entries.length,
332
+ truncated: entries.length >= IMAGE_LIMITS.maxEntries,
333
+ };
334
+ record.state = 'completed';
335
+ record.error = null;
336
+ record.updatedAt = new Date(this.clock()).toISOString();
337
+ await this.removeFile(record);
338
+ return { commandId, ...structuredClone(record.result) };
339
+ }).catch(async (error) => {
340
+ record.state = 'failed';
341
+ const failure = safeFailure(error);
342
+ record.error = failure;
343
+ record.updatedAt = new Date(this.clock()).toISOString();
344
+ await this.removeFile(record);
345
+ throw new ImageIntakeError(failure.code, failure.message, 502);
346
+ });
347
+ return record.promise;
348
+ }
349
+
350
+ async remove(commandId) {
351
+ const record = this.records.get(String(commandId ?? ''));
352
+ if (!record) return false;
353
+ if (record.state === 'running') {
354
+ throw new ImageIntakeError('DC_IMAGE_OPERATION_IN_PROGRESS', '图片正在识别,暂不能移除。', 409);
355
+ }
356
+ if (record.timer) clearTimeout(record.timer);
357
+ await this.removeFile(record);
358
+ this.records.delete(record.commandId);
359
+ return true;
360
+ }
361
+
362
+ async dispose() {
363
+ for (const record of this.records.values()) if (record.timer) clearTimeout(record.timer);
364
+ await Promise.all([...this.records.values()].map((record) => this.removeFile(record).catch(() => {})));
365
+ this.records.clear();
366
+ }
367
+ }
368
+
369
+ export function serializeImageExtractionPrompt(command) {
370
+ return [
371
+ '请识别我刚刚在向导中安全暂存的企业名单图片,并把识别结果交回数据清洗补全工作台供我逐条核验。',
372
+ '',
373
+ `图片文件:${command.fileName}。`,
374
+ '识别目标:逐行提取企业全称、18 位统一社会信用代码或注册号;不得猜测模糊字符。',
375
+ '本步骤只做图片文字识别与名单提取,不调用企查查,不消耗企查查 MCP 额度。',
376
+ `安全图片凭证:${command.commandId}`,
377
+ '',
378
+ `发送本说明后,请仅调用一次图片名单识别工具(${TOOL_IMAGE_EXTRACT}),参数只传递上述安全图片凭证。`,
379
+ '工具完成后立即结束本轮;不要直接调用任何 mcp__qcc-* 工具。',
380
+ ].join('\n');
381
+ }
382
+
383
+ export function registerImageIntakeTool(tools, store) {
384
+ return tools.register({
385
+ name: TOOL_IMAGE_EXTRACT,
386
+ description: 'Recognize one already-staged company-list image. Call only when a visible data-cleaning prompt supplies a dci-* commandId. The Host owns the temporary image and invokes the available vision provider in the current Agent execution.',
387
+ parameters: {
388
+ type: 'object',
389
+ additionalProperties: false,
390
+ properties: { commandId: { type: 'string' } },
391
+ required: ['commandId'],
392
+ },
393
+ output: {
394
+ schema: {
395
+ type: 'object',
396
+ additionalProperties: false,
397
+ properties: {
398
+ commandId: { type: 'string' },
399
+ entries: { type: 'array', items: { type: 'string' } },
400
+ entryCount: { type: 'integer' },
401
+ truncated: { type: 'boolean' },
402
+ },
403
+ required: ['commandId', 'entries', 'entryCount', 'truncated'],
404
+ },
405
+ render: (_args, value) => [{
406
+ type: 'text',
407
+ text: `图片企业名单已识别:${value.entryCount} 条,已同步回数据清洗补全工作台等待核验。`,
408
+ }],
409
+ },
410
+ async execute(args, exec) {
411
+ return store.run(args.commandId, exec);
412
+ },
413
+ });
414
+ }
package/lib/index.js CHANGED
@@ -28,6 +28,7 @@ export function apply(ctx, config) {
28
28
  webMounted: false,
29
29
  webSkipped: false,
30
30
  qccBridgeMounted: false,
31
+ imageIntakeToolRegistered: false,
31
32
  };
32
33
  const disposers = [];
33
34
 
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 数据清洗补全的一企一行字段目录。
3
+ *
4
+ * 这里只收录已核对 QCC MCP 一手实现、能够稳定投影为单个 Excel 单元格的字段。
5
+ * 任何明细列表(电话全集、海关资质、风险关联方等)都不得进入本目录。
6
+ */
7
+
8
+ const field = (id, label, options = {}) => Object.freeze({ id, label, ...options });
9
+ const group = (id, label, sourceTool, fields, options = {}) => Object.freeze({
10
+ id,
11
+ label,
12
+ sourceTool,
13
+ ...options,
14
+ fields: Object.freeze(fields),
15
+ });
16
+
17
+ export const SELF_RISK_FACTORS = Object.freeze([
18
+ ['dishonest', '失信信息'],
19
+ ['judgment_debtor', '被执行人'],
20
+ ['consumption_restriction', '限制高消费'],
21
+ ['terminated_case', '终本案件'],
22
+ ['judicial_document', '裁判文书'],
23
+ ['case_filing', '立案信息'],
24
+ ['hearing_announcement', '开庭公告'],
25
+ ['court_announcement', '法院公告'],
26
+ ['service_notice', '送达公告'],
27
+ ['bankruptcy_reorganization', '破产重整'],
28
+ ['equity_freeze', '股权冻结'],
29
+ ['judicial_auction', '司法拍卖'],
30
+ ['valuation_inquiry', '询价评估'],
31
+ ['pre_litigation_mediation', '诉前调解'],
32
+ ['exit_restriction', '限制出境'],
33
+ ['administrative_penalty', '行政处罚'],
34
+ ['operating_exception', '经营异常'],
35
+ ['serious_violation', '严重违法'],
36
+ ['environmental_penalty', '环保处罚'],
37
+ ['abnormal_taxpayer', '税务非正常户'],
38
+ ['tax_arrears', '欠税公告'],
39
+ ['tax_violation', '税收违法'],
40
+ ['disciplinary_list', '惩戒名单'],
41
+ ['default_matter', '违约事项'],
42
+ ['guarantee', '担保信息'],
43
+ ['equity_pledge_registration', '股权出质'],
44
+ ['stock_pledge', '股权质押'],
45
+ ['chattel_mortgage', '动产抵押'],
46
+ ['land_mortgage', '土地抵押'],
47
+ ['simple_cancellation', '简易注销'],
48
+ ['cancellation_filing', '注销备案'],
49
+ ['liquidation', '清算信息'],
50
+ ['labor_arbitration', '劳动仲裁'],
51
+ ['public_notice', '公示催告'],
52
+ ['property_reward_notice', '财产悬赏公告'],
53
+ ].map(Object.freeze));
54
+
55
+ export const RELATED_RISK_FACTORS = Object.freeze([
56
+ ['dishonest', '失信被执行人'],
57
+ ['judgment_debtor', '被执行人'],
58
+ ['consumption_restriction', '限制高消费'],
59
+ ['serious_violation', '严重违法'],
60
+ ['tax_violation', '税收违法'],
61
+ ['administrative_penalty', '行政处罚'],
62
+ ['bankruptcy_reorganization', '破产重整'],
63
+ ['terminated_case', '终本案件'],
64
+ ['equity_freeze', '股权冻结'],
65
+ ['tax_arrears', '欠税公告'],
66
+ ['operating_exception', '经营异常'],
67
+ ].map(Object.freeze));
68
+
69
+ export const RELATED_RISK_KEY_FACTORS = Object.freeze([
70
+ ['dishonest', '失信被执行人'],
71
+ ['serious_violation', '严重违法'],
72
+ ['bankruptcy_reorganization', '破产重整'],
73
+ ['tax_violation', '税收违法'],
74
+ ['judgment_debtor', '被执行人'],
75
+ ['terminated_case', '终本案件'],
76
+ ['equity_freeze', '股权冻结'],
77
+ ].map(Object.freeze));
78
+
79
+ export const RISK_FACTOR_CATALOG_VERSION = 'qcc-risk-snapshot-2026-09-05';
80
+
81
+ export const QCC_FIELD_CATALOG = Object.freeze([
82
+ group('company_registration', '企业工商信息', 'get_company_registration_info', [
83
+ field('company_name', '企业名称', { inputAnchor: true, defaultSelected: true }),
84
+ field('credit_no', '统一社会信用代码', { inputAnchor: true, defaultSelected: true }),
85
+ field('reg_no', '注册号', { inputAnchor: true }),
86
+ field('org_no', '组织机构代码'),
87
+ field('tax_no', '纳税人识别号'),
88
+ field('reg_status', '登记状态', { defaultSelected: true }),
89
+ field('legal_rep', '法定代表人', { defaultSelected: true }),
90
+ field('reg_capital', '注册资本', { defaultSelected: true }),
91
+ field('paid_capital', '实缴资本'),
92
+ field('establish_date', '成立日期', { defaultSelected: true }),
93
+ field('company_type', '企业类型'),
94
+ field('approval_date', '核准日期'),
95
+ field('registration_authority', '登记机关'),
96
+ field('taxpayer_qualification', '纳税人资质'),
97
+ field('payment_line_no', '支付系统行号'),
98
+ field('import_export_company_code', '进出口企业代码'),
99
+ field('short_name', '企业简称'),
100
+ field('english_name', '英文名'),
101
+ field('registered_address', '注册地址', { defaultSelected: true }),
102
+ field('mailing_address', '通信地址'),
103
+ field('region', '所属地区', { matchAuxiliary: true }),
104
+ field('business_scope', '经营范围'),
105
+ field('industry_category', '国标行业'),
106
+ field('operating_period', '营业期限'),
107
+ field('company_size', '人员规模'),
108
+ field('insured_count', '参保人数'),
109
+ field('branch_insured_count', '分支机构参保人数'),
110
+ ], { releaseBatch: 'current' }),
111
+ group('company_profile', '企业简介', 'get_company_profile', [
112
+ field('qcc_industry', '企查查行业'),
113
+ field('company_profile', '企业简介'),
114
+ field('industry_chain_overview', '产业链概览'),
115
+ ], { releaseBatch: 'current' }),
116
+ group('contact_info', '联系方式', 'get_contact_info', [
117
+ field('contact_preferred_phone', '首选联系电话'),
118
+ field('contact_phone_invalid_flag', '首选电话无效标记'),
119
+ field('contact_phone_tags', '首选电话标签'),
120
+ field('contact_preferred_email', '首选邮箱'),
121
+ field('contact_official_website', '官方网站'),
122
+ field('contact_official_website_icp', '官网 ICP 备案'),
123
+ ], { releaseBatch: 'batch-1' }),
124
+ group('listing_info', '上市信息', 'get_listing_info', [
125
+ field('listing_date', '上市日期'),
126
+ field('listing_short_name', '股票简称'),
127
+ field('listing_stock_code', '股票代码'),
128
+ field('listing_exchange', '上市交易所'),
129
+ field('listing_board', '上市板块'),
130
+ field('listing_former_short_name', '上市曾用名'),
131
+ field('listing_total_market_value', '总市值'),
132
+ field('listing_total_shares', '总股本'),
133
+ field('listing_predicted_pe', '预测市盈率'),
134
+ field('listing_float_market_value', '流通值'),
135
+ field('listing_float_shares', '流通股'),
136
+ field('listing_pb_ratio', '市净率'),
137
+ field('listing_eps', 'EPS'),
138
+ field('listing_voting_rights_difference', '表决权差异'),
139
+ field('listing_registration_based', '是否注册制'),
140
+ ], { releaseBatch: 'batch-1', selectionNote: '首个 A 股或红筹证券快照' }),
141
+ group('tax_invoice_info', '税务开票信息', 'get_tax_invoice_info', [
142
+ field('tax_company_name', '税务主体名称'),
143
+ field('tax_identification_no', '税务纳税人识别号'),
144
+ field('tax_company_type', '税务企业类型'),
145
+ field('tax_business_status', '税务经营状态'),
146
+ field('invoice_address', '开票地址'),
147
+ field('invoice_phone', '开票联系电话'),
148
+ field('invoice_bank', '开户行'),
149
+ field('invoice_bank_account', '开户行账号'),
150
+ ], { releaseBatch: 'batch-1' }),
151
+ group('import_export_credit', '进出口信用', 'get_import_export_credit', [
152
+ field('import_export_credit_no', '进出口统一社会信用代码'),
153
+ field('import_export_customs', '所在地海关'),
154
+ field('import_export_admin_division', '进出口行政区划'),
155
+ field('import_export_address', '进出口备案地址'),
156
+ field('import_export_economic_area', '经济区划'),
157
+ field('import_export_trade_type', '经营类别'),
158
+ field('import_export_statistical_economic_area', '统计经济区划'),
159
+ field('import_export_industry', '进出口行业种类'),
160
+ field('import_export_ecommerce_type', '跨境贸易电子商务类型'),
161
+ field('import_export_credit_grade', '海关信用等级'),
162
+ field('import_export_filing_date', '进出口备案日期'),
163
+ ], { releaseBatch: 'batch-1' }),
164
+ group('company_risk_scan', '企业自身风险扫描', 'get_company_risk_scan', [
165
+ field('risk_recorded_factor_count', '风险有记录因子数'),
166
+ field('risk_no_record_factor_count', '风险无记录因子数'),
167
+ field('risk_hit_summary', '企业自身风险命中摘要'),
168
+ ...SELF_RISK_FACTORS.map(([id, label]) => field(`risk_${id}_count`, `${label}条目数`)),
169
+ ], { releaseBatch: 'batch-2', catalogVersion: RISK_FACTOR_CATALOG_VERSION }),
170
+ group('company_related_risk_scan', '企业关联风险扫描', 'get_company_related_risk_scan', [
171
+ field('related_risk_party_count', '有风险关联方数'),
172
+ field('related_risk_summary', '企业关联风险摘要'),
173
+ ...RELATED_RISK_FACTORS.map(([id, label]) => field(`related_risk_${id}_count`, `关联风险-${label}条目数`)),
174
+ ...RELATED_RISK_KEY_FACTORS.map(([id, label]) => field(`related_risk_${id}_party_count`, `关联风险-${label}命中关联方数`)),
175
+ ], { releaseBatch: 'batch-2', catalogVersion: RISK_FACTOR_CATALOG_VERSION }),
176
+ ]);
177
+
178
+ export const QCC_FIELD_SOURCE_TOOL = Object.freeze(Object.fromEntries(
179
+ QCC_FIELD_CATALOG.flatMap((entry) => entry.fields.map((item) => [item.id, entry.sourceTool])),
180
+ ));
181
+
182
+ export function selectedSourceTools(fieldSelection, fallbackFields = []) {
183
+ const fields = Array.isArray(fieldSelection) && fieldSelection.length ? fieldSelection : fallbackFields;
184
+ return [...new Set(fields.map((id) => QCC_FIELD_SOURCE_TOOL[String(id)]).filter(Boolean))];
185
+ }
package/lib/qcc-safety.js CHANGED
@@ -63,7 +63,11 @@ export function redactSensitive(value, options = {}) {
63
63
 
64
64
  export function safeAuditEvent(event) {
65
65
  const source = event && typeof event === 'object' ? event : {};
66
- return {
66
+ const safeCatalogLabels = (value) => (Array.isArray(value) ? value : [])
67
+ .slice(0, 64)
68
+ .map((item) => redactSensitiveText(String(item ?? '')).slice(0, 128))
69
+ .filter(Boolean);
70
+ const safe = {
67
71
  at: String(source.at ?? new Date().toISOString()),
68
72
  event: 'qcc-tool-call',
69
73
  toolName: String(source.toolName ?? ''),
@@ -74,4 +78,10 @@ export function safeAuditEvent(event) {
74
78
  upstreamCode: source.upstreamCode ? String(source.upstreamCode) : null,
75
79
  durationMs: Math.max(0, Number(source.durationMs ?? 0)),
76
80
  };
81
+ if (source.catalogVersion || Array.isArray(source.missing) || Array.isArray(source.unknown)) {
82
+ safe.catalogVersion = source.catalogVersion ? String(source.catalogVersion).slice(0, 64) : null;
83
+ safe.missing = safeCatalogLabels(source.missing);
84
+ safe.unknown = safeCatalogLabels(source.unknown);
85
+ }
86
+ return safe;
77
87
  }