dsh-data-cleaning-agent 0.8.12 → 0.8.14

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 CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.14] - 2026-09-07
8
+
9
+ ### Changed
10
+ - 质量体检后可直接生成可编辑任务说明,移除手动连接检测、手动调用估算和草稿阶段额度勾选。
11
+ - Host 统一检查所选工具、去重主体及调用上界;中文草稿显示执行范围与账号提示。生成不查询,用户发送后才通过 Agent-owned 工具执行。
12
+ - 候选确认与失败重试保留显式继续查询确认;执行时重新校验工具与范围,保留幂等及原列补空保护。
13
+
14
+ ## [0.8.13] - 2026-09-07
15
+
16
+ ### Added
17
+ - 通过共享 `qcc-field-contracts` 接入实际控制人名称、直接持股比例、总持股比例、表决权比例;新增实控人别名搜索与原列补空。多名、分页和异常不自动选第一人,字段级原因随结果和异常清单导出。
18
+
7
19
  ## [0.8.12] - 2026-09-07
8
20
 
9
21
  ### Fixed
package/README.en.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > A data cleaning & completion agent plugin for DeepSeek Harness: local CSV/XLSX/JSON engine plus optional Qichacha (QCC) MCP enterprise-data enrichment. Initiated and maintained by the Qichacha (QCC) team.
4
4
  >
5
- > Current source version / 当前源码版本: **0.8.12** (stable release)
5
+ > Current source version / 当前源码版本: **0.8.14** (stable release)
6
6
 
7
7
  This release fixes misleading import errors after a successful upload. Files are parsed automatically, with persistent source, filename, and row-count status; text parsing handles only newly pasted content. The same file can be selected again, failed parsing preserves existing data, and pending pasted content must be parsed or cleared before continuing. Automatic mapping for 128 fields, blank-cell enrichment in original columns, QCC-blue menus, the resizable workbench, and native DSH sending remain unchanged. Existing download artifacts are not rewritten.
8
8
 
@@ -110,8 +110,8 @@ Besides local deterministic completion, the plugin supports Qichacha MCP enterpr
110
110
  conversation. The Agent calls `data_cleaning_qcc_run` exactly once; the bridge then dispatches
111
111
  dynamic QCC tools as nested executions using the parent execution token and Session. This path
112
112
  supports batch enrichment, idempotency, candidate-resolution resume, manual retry of retryable
113
- failures, and metadata-only auditing. Command staging requires `confirmPaidCalls:true`. The flag means the current user
114
- confirms use of their own QCC account quota; it does not transfer the charge to the plugin maintainer.
113
+ failures, and metadata-only auditing. Since 0.8.14, initial draft staging automatically preflights tools and scope without a billing checkbox; only sending the readable draft starts queries.
114
+ Candidate resolution, retry and legacy direct-execution APIs still require `confirmPaidCalls:true` for the user's own QCC account quota.
115
115
  Ambiguous candidates are never auto-selected. A loopback-only, fail-closed E2E runner is ready. On 2026-09-01 an isolated rc.2
116
116
  Host passed real OAuth, restart recovery, and 400 QCC calls across 20 public companies. Natural-expiry
117
117
  token refresh, dynamic-tool recovery, a post-refresh real call, and 401/429/quota fault injection also passed.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 在 DeepSeek Harness 中清洗、补全、画像企业名单数据的智能体插件:本地 CSV/XLSX/JSON 引擎 + 可选企查查(Qichacha/QCC)MCP 企业数据补全,由企查查(Qichacha/QCC)团队发起并维护。
4
4
  >
5
- > 当前源码版本 / Current source version: **0.8.12**(正式版本)
5
+ > 当前源码版本 / Current source version: **0.8.14**(正式版本)
6
6
 
7
7
  本版修复文件上传成功后的导入状态误报:文件选择后自动解析,当前来源、文件名和行数持续显示;文本解析仅处理新粘贴内容。支持重新选择同一文件,解析失败保留现有数据,新粘贴内容解析或清空后再进入下一步。保留 128 字段智能映射、原列补空、企查查蓝菜单、可调宽工作台及 DSH 原生发送机制。历史下载文件不自动改写。
8
8
 
@@ -102,8 +102,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
102
102
  Host,并只把不含企业名单的 commandId 类型化意图发送到原生会话;Agent 随后准确调用一次
103
103
  `data_cleaning_qcc_run`,Bridge 使用该父执行的 token/Session 以 nested execution 调度动态 QCC 工具。
104
104
  该路径实现批量补全、请求幂等、多候选人工确认续跑、retryable 失败人工重试与安全审计。
105
- 暂存命令前要求 `confirmPaidCalls:true`;该字段表示当前用户确认
106
- 使用自己的 QCC 账号额度,不代表插件开发者代客户付款;多候选绝不自动选择。
105
+ 0.8.14 起初始草稿无需额度勾选:Host 自动检查工具与调用范围,用户阅读并发送说明后才查询。
106
+ 候选确认、失败重试及旧版直接执行接口仍要求 `confirmPaidCalls:true`;使用用户自有账号额度,多候选绝不自动选择。
107
107
  默认关闭的本机 E2E Runner 已就绪;2026-09-01 已在隔离 rc.2 Host 完成真实 OAuth、跨重启恢复和
108
108
  20 家公开企业的 400 次 QCC 调用;自然过期 token 的真实刷新、动态工具恢复及 1 行续期后调用也已通过。
109
109
  401 / 429 / 配额耗尽使用 Web→Bridge→ToolRuntime 故障注入验证,不额外消耗真实付费批次。
@@ -53,8 +53,9 @@
53
53
 
54
54
  ### Agent-owned 命令(正式工作台路径)
55
55
 
56
- - `POST /data-cleaning/api/g5/commands`:要求 `confirmPaidCalls:true`,把 enrich/resolve/retry 输入暂存
57
- 在 Host;返回 commandId 与不含企业名单的可见会话提示,本步骤 `paidCalls:false`。
56
+ - `POST /data-cleaning/api/g5/commands`:0.8.14 起 enrich 暂存不要求额度勾选,Host 自动检查所选工具、行数和调用上界;
57
+ resolve/retry 暂存仍要求 `confirmPaidCalls:true`。返回 commandId、Host estimate 与不含企业名单的可编辑提示,本步骤 `paidCalls:false`。
58
+ 生成说明不代表已执行,用户发送可见说明后才由 Agent-owned 工具执行。执行阶段重新检查工具和上限,草稿不伪造“已确认额度”状态。
58
59
  - `data_cleaning_qcc_run({commandId})`:只能在 Agent tool execution 内运行,缺少父执行 token/Session
59
60
  时返回 `QCC_AGENT_EXECUTION_REQUIRED`。
60
61
  - `GET /data-cleaning/api/g5/commands/<commandId>`:同源读取 prepared/running/completed/failed 和完成后的
package/lib/artifacts.js CHANGED
@@ -21,6 +21,7 @@ const SYSTEM_FIELD_LABELS = Object.freeze({
21
21
  biz_status: '经营状态',
22
22
  risk_tags: '风险标签',
23
23
  qcc_match_status: '匹配状态',
24
+ qcc_field_issues: '字段补全待核验原因',
24
25
  qcc_source: '数据来源',
25
26
  qcc_error: '错误原因',
26
27
  match_status: '匹配状态',
@@ -115,6 +116,7 @@ function normalizeRows(value) {
115
116
  }
116
117
 
117
118
  function exceptionReason(row) {
119
+ if (row?.qcc_field_issues) return String(row.qcc_field_issues).slice(0, 2000);
118
120
  const status = String(
119
121
  row?.qcc_match_status
120
122
  ?? row?.match_status
package/lib/client.js CHANGED
@@ -199,7 +199,7 @@ window.__ModuleLoader__.load({
199
199
  const needle = String(query || '').trim().toLowerCase();
200
200
  const [, label, fields, tool] = group;
201
201
  if (!needle || `${label} ${tool || ''}`.toLowerCase().includes(needle)) return fields;
202
- return fields.filter(([id, fieldLabel]) => `${id} ${fieldLabel}`.toLowerCase().includes(needle));
202
+ return fields.filter(([id, fieldLabel]) => `${id} ${fieldLabel} ${(MAPPING_ALIASES[id] || []).join(' ')}`.toLowerCase().includes(needle));
203
203
  }
204
204
 
205
205
  /** 客户端所需服务:与 mcp-connector 对齐(槽位 + 会话/工作区/输入机)。 */
@@ -1139,6 +1139,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1139
1139
  ['equity_freeze', '股权冻结'],
1140
1140
  ];
1141
1141
  const FIELD_GROUPS = [
1142
+ // Shared contract mirror: checked against qcc-field-contracts in tests.
1143
+ ['actual_controller', '实际控制人', [
1144
+ ['actual_controller_name', '实际控制人名称'],
1145
+ ['actual_controller_direct_ratio', '实控人直接持股比例'],
1146
+ ['actual_controller_total_ratio', '实控人总持股比例'],
1147
+ ['actual_controller_voting_ratio', '实控人表决权比例'],
1148
+ ], 'get_actual_controller'],
1142
1149
  ['company_registration', '企业工商信息', [
1143
1150
  ['company_name', '企业名称'], ['credit_no', '统一社会信用代码'], ['reg_no', '注册号'],
1144
1151
  ['org_no', '组织机构代码'], ['tax_no', '纳税人识别号'], ['reg_status', '登记状态'],
@@ -1203,6 +1210,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1203
1210
  ['risk_tags', '风险标签'],
1204
1211
  ['qcc_match_status', '匹配状态'],
1205
1212
  ['qcc_source', '数据来源'],
1213
+ ['qcc_field_issues', '字段补全待核验原因'],
1206
1214
  ['qcc_error', '错误原因'],
1207
1215
  ['match_status', '匹配状态'],
1208
1216
  ['input_company', '原始企业名称'],
@@ -1434,6 +1442,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1434
1442
  }
1435
1443
 
1436
1444
  const MAPPING_ALIASES = {
1445
+ actual_controller_name: ['实控人', '实际控制人', '企业实控人名称', '企业实际控制人', '企业实控人名称(自然人请填写姓名)'],
1446
+ actual_controller_direct_ratio: ['实际控制人直接持股比例'],
1447
+ actual_controller_total_ratio: ['实际控制人总持股比例'],
1448
+ actual_controller_voting_ratio: ['实际控制人表决权比例'],
1437
1449
  company_name: ['name', 'company', '公司名称', '单位名称', '企业全称', '公司全称', '主体标识'],
1438
1450
  credit_no: ['creditCode', 'unified_credit_code', '信用代码', '社会信用代码', '统一信用代码', 'USCC'],
1439
1451
  reg_no: ['registration_no', '工商注册号'],
@@ -3233,7 +3245,6 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3233
3245
  const nameField = useStore((state) => state.nameField);
3234
3246
  const selectedDomains = useStore((state) => state.selectedDomains);
3235
3247
  const qccCapabilities = useStore((state) => state.qccCapabilities);
3236
- const qccEstimate = useStore((state) => state.qccEstimate);
3237
3248
  const qccRun = useStore((state) => state.qccRun);
3238
3249
  const paidConfirmed = useStore((state) => state.paidConfirmed);
3239
3250
  const jobs = useStore((state) => state.jobs);
@@ -3534,39 +3545,6 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3534
3545
  }
3535
3546
  };
3536
3547
 
3537
- const loadQccCapabilities = async () => {
3538
- if (busy) return;
3539
- actions.setBusy(true);
3540
- actions.setError(null);
3541
- try {
3542
- const r = await api('/data-cleaning/api/g5/capabilities');
3543
- if (r && r.ok !== false) actions.setQccCapabilities(r);
3544
- else actions.setError((r && (r.message || r.error)) || '企查查能力检测失败');
3545
- } catch (err) {
3546
- actions.setError(err instanceof Error ? err.message : String(err));
3547
- } finally {
3548
- actions.setBusy(false);
3549
- }
3550
- };
3551
-
3552
- const estimateQcc = async () => {
3553
- const uniqueCompanies = new Set(runtime.rows.map((row) => String(row?.[nameField] ?? '').trim()).filter(Boolean)).size;
3554
- const selected = fieldSelection.length ? fieldSelection : DEFAULT_ENRICHMENT_KEYS;
3555
- const selectedGroups = uiFieldGroups.filter(([, , fields]) => fields.some(([id]) => selected.includes(id)));
3556
- const sourceTools = [...new Set(selectedGroups.map(([, , , sourceTool]) => sourceTool).filter(Boolean))];
3557
- const callsPerCompany = 1 + sourceTools.length;
3558
- const estimatedCalls = uniqueCompanies * callsPerCompany;
3559
- actions.setQccEstimate({
3560
- uniqueCompanies,
3561
- tools: ['主体查询', ...selectedGroups.map(([, label]) => label)],
3562
- sourceTools,
3563
- estimatedCalls,
3564
- maxCalls: 300,
3565
- withinLimit: runtime.rows.length <= 100 && estimatedCalls <= 300,
3566
- estimateType: 'upper-bound',
3567
- });
3568
- };
3569
-
3570
3548
  const applyQccRun = async (run) => {
3571
3549
  actions.setQccRun(run);
3572
3550
  runtime.rows = Array.isArray(run.rows) ? run.rows : runtime.rows;
@@ -3584,7 +3562,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3584
3562
  qccRunId: run.runId,
3585
3563
  summary: {
3586
3564
  total: Number(summary.totalRows ?? runtime.rows.length),
3587
- exact: Number(summary.enriched ?? 0),
3565
+ exact: Number(summary.enriched ?? 0) + Number(summary.fieldReview ?? 0),
3588
3566
  candidate: reviewRequired,
3589
3567
  confirmed: 0,
3590
3568
  unresolved: Number(summary.unresolved ?? summary.missingName ?? 0),
@@ -3605,7 +3583,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3605
3583
  completed: Number(summary.enriched ?? 0),
3606
3584
  unchanged: Number(summary.unresolved ?? 0) + Number(summary.missingName ?? 0),
3607
3585
  failed: Number(summary.failed ?? 0),
3608
- reviewRequired: 0,
3586
+ reviewRequired: Number(summary.fieldReview ?? 0),
3609
3587
  callsUsed: Array.isArray(run.audit) ? run.audit.length : 0,
3610
3588
  },
3611
3589
  });
@@ -3619,7 +3597,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3619
3597
  }
3620
3598
  const prepared = await api('/data-cleaning/api/g5/commands', {
3621
3599
  ...payload,
3622
- confirmPaidCalls: true,
3600
+ ...(payload.kind === 'enrich' ? {} : { confirmPaidCalls: true }),
3623
3601
  });
3624
3602
  const command = prepared?.command;
3625
3603
  if (!command?.commandId || !command?.prompt) throw new Error('Host 未返回有效的企查查任务命令');
@@ -3640,10 +3618,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3640
3618
  }
3641
3619
  const prepared = await api('/data-cleaning/api/g5/commands', {
3642
3620
  ...payload,
3643
- confirmPaidCalls: true,
3644
3621
  });
3645
3622
  const command = prepared?.command;
3646
3623
  if (!command?.commandId || !command?.prompt) throw new Error('Host 未返回有效的企查查任务命令');
3624
+ actions.setQccCapabilities({ capabilities: { ready: true } });
3647
3625
  setSessionDraft(activeSessionId, command.prompt);
3648
3626
  actions.close();
3649
3627
 
@@ -3668,7 +3646,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3668
3646
  };
3669
3647
 
3670
3648
  const runQcc = async () => {
3671
- if (busy || !qccEstimate || !paidConfirmed || !qccEstimate.withinLimit) return;
3649
+ if (busy || !cachedTask?.id || !hasData) return;
3672
3650
  actions.setBusy(true);
3673
3651
  actions.setError(null);
3674
3652
  try {
@@ -3904,7 +3882,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3904
3882
  ? qccRun.summary?.enriched ?? 0
3905
3883
  : cachedTask?.enrichmentSummary?.completed;
3906
3884
  const qccReviewCount = qccRun
3907
- ? qccRun.summary?.ambiguous ?? 0
3885
+ ? (qccRun.summary?.ambiguous ?? 0) + (qccRun.summary?.fieldReview ?? 0)
3908
3886
  : cachedTask?.enrichmentSummary?.reviewRequired
3909
3887
  ?? cachedTask?.matchSummary?.reviewRequired;
3910
3888
  const stat = (label, value, tone) => h('div', { className: 'dcAgentCard' },
@@ -4071,36 +4049,22 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4071
4049
  h('p', { className: 'dcAgentHint' }, '统一社会信用代码等强标识优先;名称匹配不明确时进入人工核验队列。'),
4072
4050
  ),
4073
4051
  h('section', { className: 'dcAgentSection' },
4074
- h('h3', null, '企查查一企一行补全能力'),
4052
+ h('h3', null, '准备任务说明'),
4053
+ h('p', { className: 'dcAgentHint' }, `已导入 ${dataset?.rowCount ?? runtime.rows.length} 条记录,已选择 ${fieldSelection.length} 个补全字段。Host 自动检查所需工具并计算去重主体与调用上界。`),
4054
+ h('p', { className: 'dcAgentHint' }, '回填后可修改;点击发送才开始查询,使用你已连接的企查查 MCP 账号及额度。'),
4075
4055
  h('div', { className: 'dcAgentRow' },
4076
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, onClick: loadQccCapabilities }, busy ? '检测中…' : '检测企查查连接'),
4077
- qccCapabilities ? h('span', { className: 'dcAgentHint' }, qccCapabilities.capabilities?.ready ? '主体查询与工商信息工具已就绪' : '连接未就绪或基础工具不完整') : null,
4056
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData || !cachedTask?.id, onClick: runQcc }, busy ? '检查并生成中…' : '生成可编辑任务说明'),
4078
4057
  ),
4079
- h('div', { className: 'dcAgentRow' },
4080
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !runtime.rows.length, onClick: estimateQcc }, '估算调用量'),
4081
- ),
4082
- qccEstimate ? h('div', null,
4083
- h('div', { className: 'dcAgentGrid' },
4084
- stat('唯一企业', qccEstimate.uniqueCompanies),
4085
- stat('所选工具', qccEstimate.tools.length),
4086
- stat('调用上界', qccEstimate.estimatedCalls, qccEstimate.withinLimit ? 'good' : 'bad'),
4087
- stat('调用上限', qccEstimate.maxCalls),
4088
- ),
4089
- h('label', { className: 'dcAgentCheck' },
4090
- h('input', { type: 'checkbox', checked: paidConfirmed, 'aria-label': '确认使用当前用户的企查查账号额度', onChange: (event) => actions.setPaidConfirmed(event.target.checked) }),
4091
- '我已核对企业数量和调用上界,并确认使用自己连接的企查查 MCP 账号;额度或费用由该账号自行承担',
4092
- ),
4093
- h('p', { className: 'dcAgentHint' }, '下一步只会把中文任务说明回填到中央对话框。你可先阅读、补充备注,点击发送后才开始企查查调用。'),
4094
- h('div', { className: 'dcAgentRow' },
4095
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !paidConfirmed || !qccEstimate.withinLimit, onClick: runQcc }, busy ? '生成中…' : '生成可编辑任务说明'),
4096
- ),
4097
- ) : null,
4098
4058
  ),
4099
4059
  qccRun ? h('section', { className: 'dcAgentSection' },
4100
4060
  h('h3', null, `任务 ${qccRun.runId} · ${qccRun.state}`),
4061
+ ((qccRun.reviewQueue || []).length || (qccRun.errors || []).some((item) => item.error?.retryable)) ? h('label', { className: 'dcAgentCheck' },
4062
+ h('input', { type: 'checkbox', checked: paidConfirmed, 'aria-label': '确认候选或重试的企查查调用', onChange: (event) => actions.setPaidConfirmed(event.target.checked) }),
4063
+ '确认主体或重试会继续查询,使用当前连接的企查查账号;额度或费用由该账号自行承担。',
4064
+ ) : null,
4101
4065
  h('div', { className: 'dcAgentGrid' },
4102
4066
  stat('已补全', qccRun.summary?.enriched ?? 0, 'good'),
4103
- stat('待核验', qccRun.summary?.ambiguous ?? 0, (qccRun.summary?.ambiguous ?? 0) > 0 ? 'warn' : null),
4067
+ stat('待核验', (qccRun.summary?.ambiguous ?? 0) + (qccRun.summary?.fieldReview ?? 0), (qccRun.summary?.ambiguous || qccRun.summary?.fieldReview) > 0 ? 'warn' : null),
4104
4068
  stat('未匹配', qccRun.summary?.unresolved ?? 0, (qccRun.summary?.unresolved ?? 0) > 0 ? 'warn' : null),
4105
4069
  stat('失败', qccRun.summary?.failed ?? 0, (qccRun.summary?.failed ?? 0) > 0 ? 'bad' : null),
4106
4070
  ),
@@ -4230,7 +4194,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4230
4194
  title: runtime.source?.type === 'image' ? '图片识别原始名单(尚未匹配)' : '导入原始清单',
4231
4195
  onError: actions.setError,
4232
4196
  }) : null,
4233
- hasData ? h('p', { className: 'dcAgentHint' }, '请核对完整名单。识别清单不代表已完成企查查匹配。核对后进入「规则与体检」,确认规则生成质量体检;再进入「主体匹配」估算调用量并确认账号额度,点击「生成可编辑任务说明」自动回填中央对话框,无需手工复制任务凭证。') : null,
4197
+ hasData ? h('p', { className: 'dcAgentHint' }, '请核对完整名单。识别清单不代表已完成企查查匹配。核对后进入「规则与体检」,确认规则生成质量体检;再进入「主体匹配」直接生成可编辑任务说明。Host 自动检查工具和调用范围,回填中央对话框后点击发送才开始查询。') : null,
4234
4198
  h('div', { className: 'dcAgentRow' },
4235
4199
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !intake.pendingText, 'aria-label': '解析数据', onClick: handleParse }, busy ? '解析中…' : '解析粘贴内容'),
4236
4200
  intake.pendingText && hasData ? h('p', { className: 'dcAgentHint', role: 'status' }, '有尚未解析的新粘贴内容,请先解析或清空后再继续。') : null,
@@ -4279,7 +4243,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4279
4243
  h('span', {
4280
4244
  className: 'dcAgentQccBadge',
4281
4245
  title: qccRun ? `任务 ${qccRun.runId}` : '仅在当前用户确认使用自己的企查查账号后调用',
4282
- }, qccRun ? `QCC · ${qccRun.state}` : qccCapabilities ? (qccCapabilities.capabilities?.ready ? 'QCC · 已连接' : 'QCC · 能力不完整') : 'QCC · 待检测'),
4246
+ }, qccRun ? `QCC · ${qccRun.state}` : qccCapabilities ? (qccCapabilities.capabilities?.ready ? 'QCC · 工具已就绪' : 'QCC · 能力不完整') : 'QCC · 自动校验'),
4283
4247
  h('span', { className: 'dcAgentJobsPill', 'data-state': jobsPill(jobs).state, title: '后台任务状态' }, jobsPill(jobs).label),
4284
4248
  h('button', {
4285
4249
  className: 'dcAgentWbClose',
@@ -91,7 +91,8 @@ export function serializeQccCommandPrompt(command) {
91
91
  `任务编号:${command.taskId}。`,
92
92
  `处理数量:${rowCount} 条企业记录。`,
93
93
  ...(fields.length ? [`需要补全:${fields.join('、')}。`] : []),
94
- '执行边界:名单、字段和额度确认已安全暂存在本机 Host;使用当前用户自己连接的企查查 MCP 账号。',
94
+ ...(command.estimate ? [`Host 调用估算:${command.estimate.uniqueCompanies} 家去重主体,最多 ${command.estimate.estimatedCalls} 次;本次调用上限 ${command.estimate.maxCalls} 次。`] : []),
95
+ '执行边界:名单与字段范围已安全暂存在本机 Host。生成说明不会查询;发送本说明即确认按上述范围使用自己连接的企查查 MCP 账号及额度,额度或费用由该账号自行承担。',
95
96
  '',
96
97
  '你可以在发送前补充任务备注。如需修改名单、匹配规则或补全字段,请先返回工作台调整后重新生成。',
97
98
  `安全任务凭证:${command.commandId}`,
@@ -128,6 +129,9 @@ export class QccCommandStore {
128
129
  prepare(input) {
129
130
  this.cleanup(true);
130
131
  const normalized = normalizedInput(input);
132
+ const estimate = normalized.kind === 'enrich'
133
+ ? this.bridge.previewEnrichment(normalized.rows, normalized)
134
+ : null;
131
135
  const commandId = `dcq-${randomUUID()}`;
132
136
  const at = new Date(this.clock()).toISOString();
133
137
  const record = {
@@ -139,6 +143,7 @@ export class QccCommandStore {
139
143
  updatedAt: at,
140
144
  touchedAtMs: this.clock(),
141
145
  input: normalized,
146
+ estimate,
142
147
  runId: null,
143
148
  error: null,
144
149
  promise: null,
@@ -165,6 +170,7 @@ export class QccCommandStore {
165
170
  updatedAt: record.updatedAt,
166
171
  runId: record.runId,
167
172
  error: record.error,
173
+ estimate: record.estimate,
168
174
  expiresInMs: this.ttlMs,
169
175
  });
170
176
  }
@@ -268,6 +274,7 @@ export function registerQccCommandTool(tools, commands) {
268
274
  totalRows: { type: 'integer' },
269
275
  uniqueCompanies: { type: 'integer' },
270
276
  enriched: { type: 'integer' },
277
+ fieldReview: { type: 'integer' },
271
278
  ambiguous: { type: 'integer' },
272
279
  unresolved: { type: 'integer' },
273
280
  failed: { type: 'integer' },
@@ -5,6 +5,7 @@
5
5
  * 任何明细列表(电话全集、海关资质、风险关联方等)都不得进入本目录。
6
6
  */
7
7
 
8
+ import { ACTUAL_CONTROLLER_GROUP } from 'qcc-field-contracts';
8
9
  const field = (id, label, options = {}) => Object.freeze({ id, label, ...options });
9
10
  const group = (id, label, sourceTool, fields, options = {}) => Object.freeze({
10
11
  id,
@@ -173,6 +174,7 @@ export const QCC_FIELD_CATALOG = Object.freeze([
173
174
  ...RELATED_RISK_FACTORS.map(([id, label]) => field(`related_risk_${id}_count`, `关联风险-${label}条目数`)),
174
175
  ...RELATED_RISK_KEY_FACTORS.map(([id, label]) => field(`related_risk_${id}_party_count`, `关联风险-${label}命中关联方数`)),
175
176
  ], { releaseBatch: 'batch-2', catalogVersion: RISK_FACTOR_CATALOG_VERSION }),
177
+ ACTUAL_CONTROLLER_GROUP,
176
178
  ]);
177
179
 
178
180
  export const QCC_FIELD_SOURCE_TOOL = Object.freeze(Object.fromEntries(
package/lib/qcc-runs.js CHANGED
@@ -47,6 +47,7 @@ function deriveState(record) {
47
47
  if (record.reviewQueue.length > 0) return 'awaiting-review';
48
48
  if (record.errors.some((item) => item.error?.retryable)) return 'needs-retry';
49
49
  if (record.errors.length > 0) return 'completed-with-errors';
50
+ if (record.rows.some(row => row.qcc_field_issues)) return 'completed-with-errors';
50
51
  return 'completed';
51
52
  }
52
53
 
@@ -55,7 +56,8 @@ function recomputeSummary(record) {
55
56
  record.summary = {
56
57
  ...record.summary,
57
58
  totalRows: record.rows.length,
58
- enriched: count('enriched'),
59
+ enriched: record.rows.filter(row => row.qcc_match_status === 'enriched' && !row.qcc_field_issues).length,
60
+ ...(record.summary.fieldReview !== undefined || record.rows.some(row => row.qcc_field_issues) ? { fieldReview: record.rows.filter(row => row.qcc_field_issues).length } : {}),
59
61
  ambiguous: count('ambiguous'),
60
62
  unresolved: count('unresolved'),
61
63
  failed: count('failed'),
@@ -231,6 +233,7 @@ export class G5RunStore {
231
233
  ...row,
232
234
  ...Object.fromEntries(Object.entries(result.fields || {}).filter(([key]) => row[key] == null || (typeof row[key] === 'string' && row[key].trim() === ''))),
233
235
  qcc_match_status: 'enriched',
236
+ ...(row.qcc_field_issues || result.fieldIssues ? { qcc_field_issues: Object.entries(result.fieldIssues || {}).map(([field, issue]) => field + ': ' + issue.message).join(';') } : {}),
234
237
  qcc_source: 'qcc-mcp',
235
238
  };
236
239
  } else {
package/lib/qcc.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * - 批量补全只在 Host/Web 内处理明细;模型仍只接收摘要。
9
9
  */
10
10
  import { safeAuditEvent } from './qcc-safety.js';
11
+ import { projectActualController } from 'qcc-field-contracts';
11
12
  import {
12
13
  QCC_PHASE2_COMPANY_TOOLS,
13
14
  QCC_PHASE2_HISTORY_TOOLS,
@@ -32,6 +33,7 @@ export const QCC_TOOL_NAMES = Object.freeze({
32
33
  entityLookup: 'mcp__qcc-company__get_company_by_query',
33
34
  registration: 'mcp__qcc-company__get_company_registration_info',
34
35
  profile: 'mcp__qcc-company__get_company_profile',
36
+ actualController: 'mcp__qcc-company__get_actual_controller',
35
37
  contact: 'mcp__qcc-company__get_contact_info',
36
38
  listing: 'mcp__qcc-company__get_listing_info',
37
39
  taxInvoice: 'mcp__qcc-company__get_tax_invoice_info',
@@ -548,7 +550,15 @@ export function mapCompanyRelatedRiskScanFields(value) {
548
550
  const LEGACY_ENRICHMENT_FIELDS = Object.freeze([
549
551
  'credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status',
550
552
  ]);
553
+ export function mapActualControllerFields(value, match) {
554
+ if (value?.企业名称 && match?.companyName && value.企业名称 !== match.companyName) {
555
+ throw new QccBridgeError('QCC_ENTITY_MISMATCH', '实控人返回企业与已确认主体不一致');
556
+ }
557
+ const result=projectActualController(value);
558
+ return result.values;
559
+ }
551
560
  const SOURCE_TOOL_CONFIG = Object.freeze({
561
+ get_actual_controller: Object.freeze({ name: QCC_TOOL_NAMES.actualController, map: mapActualControllerFields }),
552
562
  get_company_registration_info: Object.freeze({ name: QCC_TOOL_NAMES.registration, map: mapRegistrationFields }),
553
563
  get_company_profile: Object.freeze({ name: QCC_TOOL_NAMES.profile, map: mapProfileFields }),
554
564
  get_contact_info: Object.freeze({ name: QCC_TOOL_NAMES.contact, map: mapContactFields, args: { excludeInvalidPhone: false } }),
@@ -917,6 +927,7 @@ export class QccHostBridge {
917
927
  const lockedKey = String(match?.creditNo || match?.companyName || '').trim();
918
928
  const sourceTools = sourceToolsForFieldSelection(options.fieldSelection, options.includeRisk);
919
929
  let mapped = {};
930
+ const fieldIssues = {};
920
931
  let legacyRiskTags = '';
921
932
  for (const sourceTool of sourceTools) {
922
933
  const config = SOURCE_TOOL_CONFIG[sourceTool];
@@ -924,6 +935,10 @@ export class QccHostBridge {
924
935
  const response = await this.call(config.name, { searchKey: lockedKey, ...(config.args ?? {}) }, options);
925
936
  const fields = config.map(response.data, match);
926
937
  mapped = mergeMappedFields(mapped, fields);
938
+ if (sourceTool === 'get_actual_controller') {
939
+ Object.assign(fieldIssues, Object.fromEntries(Object.entries(projectActualController(response.data).issues)
940
+ .filter(([field]) => options.fieldSelection?.includes(field))));
941
+ }
927
942
  if (sourceTool === 'get_company_risk_scan') legacyRiskTags = mapRiskTags(response.data);
928
943
  if (config.inspect) {
929
944
  const catalog = config.inspect(response.data);
@@ -942,7 +957,8 @@ export class QccHostBridge {
942
957
  }
943
958
  const fields = projectSelectedFields(mapped, options.fieldSelection);
944
959
  if (options.includeRisk) fields.risk_tags = legacyRiskTags;
945
- return { status: 'enriched', companyName: String(match?.companyName ?? ''), fields };
960
+ return { status: 'enriched', companyName: String(match?.companyName ?? ''), fields,
961
+ ...(Object.keys(fieldIssues).length ? { fieldIssues } : {}) };
946
962
  }
947
963
 
948
964
  async enrichLockedCompany(selection, options = {}) {
@@ -954,7 +970,7 @@ export class QccHostBridge {
954
970
  return this.enrichMatchedCompany({ companyName, creditNo }, options);
955
971
  }
956
972
 
957
- async enrichRows(rows, options = {}) {
973
+ planEnrichment(rows, options = {}) {
958
974
  if (!Array.isArray(rows)) throw new QccBridgeError('QCC_INVALID_ROWS', 'rows must be an array');
959
975
  const maxRows = Math.max(1, Math.trunc(options.maxRows ?? DEFAULT_MAX_ROWS));
960
976
  if (rows.length > maxRows) {
@@ -984,6 +1000,21 @@ export class QccHostBridge {
984
1000
  .map((sourceTool) => SOURCE_TOOL_CONFIG[sourceTool]?.name)
985
1001
  .filter(Boolean),
986
1002
  ];
1003
+ return { normalized, names, estimate, maxRows, maxCalls, requiredTools };
1004
+ }
1005
+
1006
+ previewEnrichment(rows, options = {}) {
1007
+ const plan = this.planEnrichment(rows, options);
1008
+ if (!plan.names.length) throw new QccBridgeError('QCC_INVALID_ROWS', '请先确认企业名称或统一社会信用代码的匹配映射。');
1009
+ const missingTools = plan.requiredTools.filter((name) => !this.has(name));
1010
+ if (missingTools.length) throw new QccBridgeError('QCC_NOT_CONNECTED', '企查查连接或所选字段工具未就绪,请在 MCP 连接器完成配置后重新生成任务说明。', {
1011
+ connectRequired: true, retryable: true, details: { missingTools },
1012
+ });
1013
+ return { ...plan.estimate, rowCount: rows.length, maxRows: plan.maxRows, maxCalls: plan.maxCalls, withinLimit: true };
1014
+ }
1015
+
1016
+ async enrichRows(rows, options = {}) {
1017
+ const { normalized, names, estimate, requiredTools } = this.planEnrichment(rows, options);
987
1018
  try {
988
1019
  await Promise.all(requiredTools.map((name) => this.waitForTool(name, {
989
1020
  signal: options.signal,
@@ -1030,6 +1061,7 @@ export class QccHostBridge {
1030
1061
  ...Object.fromEntries(Object.entries(result.fields || {}).filter(([key]) => row[key] == null || (typeof row[key] === 'string' && row[key].trim() === ''))),
1031
1062
  qcc_match_status: 'enriched',
1032
1063
  qcc_source: 'qcc-mcp',
1064
+ ...(row.qcc_field_issues || result.fieldIssues ? { qcc_field_issues: Object.entries(result.fieldIssues || {}).map(([field, issue]) => field + ': ' + issue.message).join(';') } : {}),
1033
1065
  };
1034
1066
  }
1035
1067
  return { ...row, qcc_match_status: result?.status ?? 'failed' };
@@ -1046,7 +1078,8 @@ export class QccHostBridge {
1046
1078
  const summary = {
1047
1079
  totalRows: rows.length,
1048
1080
  uniqueCompanies: names.length,
1049
- enriched: outputRows.filter((row) => row.qcc_match_status === 'enriched').length,
1081
+ enriched: outputRows.filter((row) => row.qcc_match_status === 'enriched' && !row.qcc_field_issues).length,
1082
+ ...(outputRows.some(row => row.qcc_field_issues) ? { fieldReview: outputRows.filter(row => row.qcc_field_issues).length } : {}),
1050
1083
  ambiguous: outputRows.filter((row) => row.qcc_match_status === 'ambiguous').length,
1051
1084
  unresolved: outputRows.filter((row) => row.qcc_match_status === 'unresolved').length,
1052
1085
  failed: outputRows.filter((row) => row.qcc_match_status === 'failed').length,
package/lib/web.js CHANGED
@@ -642,7 +642,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
642
642
  );
643
643
  }
644
644
  const payload = JSON.parse((await readBody(req)).toString('utf8'));
645
- requirePaidConfirmation(payload);
645
+ if (payload.kind && payload.kind !== 'enrich') requirePaidConfirmation(payload);
646
646
  return writeJson(res, 201, {
647
647
  ok: true,
648
648
  marker: 'g5-agent-command',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-data-cleaning-agent",
3
- "version": "0.8.12",
3
+ "version": "0.8.14",
4
4
  "description": "Clean, complete, and profile enterprise name lists in DeepSeek Harness — a data cleaning & completion agent plugin with local CSV/XLSX/JSON engine and optional Qichacha (QCC) MCP enrichment. Maintained by Qichacha/QCC.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -99,6 +99,7 @@
99
99
  "node": ">=20"
100
100
  },
101
101
  "dependencies": {
102
+ "qcc-field-contracts": "0.1.0",
102
103
  "xlsx": "^0.18.5"
103
104
  },
104
105
  "peerDependencies": {