dsh-data-cleaning-agent 0.8.17 → 0.8.19

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,21 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.19] - 2026-09-09
8
+
9
+ - 受益人与主营业务收入增加待确认候选,明确首条限制及财务口径差异,不自动替换。
10
+ - 映射依次展示搜索、大类、可见字段列表;组合筛选保留已有映射。
11
+ - 区分去重主体处理数与输入行补全数,结果卡片和工作台提供原生预览入口。
12
+ - 零异常时不展示空异常清单;有待核验记录时保留下载。
13
+ - 245 项测试及 10 组浏览器布局回归通过;实际 DSH 宿主点击链路仍需升级后验收。
14
+
15
+ ## [0.8.18] - 2026-09-09
16
+
17
+ - 新增受益所有人首条名称及首报告期营业总收入、利润总额、总资产,共 136 个字段;共享契约 0.2.0。
18
+ - 映射支持大类与关键词联合筛选,统一十一类排序,保留既有选择。
19
+ - 执行期间同步 Host 任务状态和已处理主体数,展示来源、条目数与补全字段数;状态徽标仅反映当前任务。
20
+ - 工具结果返回真实 XLSX 预览/下载入口,绑定 taskId;安全预览前 50 行,不修改源文件、不重复收费查询。
21
+
7
22
  ## [0.8.17] - 2026-09-08
8
23
 
9
24
  ### Fixed
package/README.en.md CHANGED
@@ -8,7 +8,7 @@ Related agents: [数据清洗补全](https://github.com/duhu2000/dsh-data-cleani
8
8
 
9
9
  > 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.
10
10
  >
11
- > Current source version / 当前源码版本: **0.8.17** (stable release)
11
+ > Current source version / 当前源码版本: **0.8.19** (stable release)
12
12
 
13
13
  This release unifies spreadsheet previews, column mapping, and enrichment scope across the prompt wizard and workbench. Mapping statuses use color and text; mapped fields automatically enter the scope, with optional extra fields collapsed. Multiple confirmed output columns may share a non-identity field while preserving non-empty values. It fixes saved drafts resetting the scope to five defaults and detects stale Host versions. Preparing a draft does not query QCC; sending starts execution. Fully restart DSH after upgrading. Existing download artifacts are not rewritten.
14
14
 
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # dsh-data-cleaning-agent
2
2
 
3
- 本版统一向导与工作台的 Host 执行闭环:发送后自动生成新 Excel,原文件不修改,工作台入口可恢复;发布记录见 [发布说明](docs/RELEASE-0.8.17.md)。
3
+ 本版统一向导与工作台的 Host 执行闭环:发送后自动生成新 Excel,原文件不修改,工作台入口可恢复;发布记录见 [发布说明](docs/RELEASE-0.8.19.md)。
4
4
 
5
5
  ## 安装与三分钟上手
6
6
 
7
7
  数据清洗补全智能体:面向 Excel/CSV/JSON 企业名单,提供数据清洗、表格清洗、清洗补全、去重、企业数据补全与字段补全,支持企查查 MCP 和结果导出。
8
8
 
9
9
  ```sh
10
- dsh plugin --profile web add dsh-data-cleaning-agent@0.8.17
10
+ dsh plugin --profile web add dsh-data-cleaning-agent@0.8.19
11
11
  ```
12
12
 
13
13
  请先满足下文的 DSH、连接器及侧边栏依赖要求;安装后完整停止并重启对应 Profile。
@@ -25,7 +25,7 @@ dsh plugin --profile web add dsh-data-cleaning-agent@0.8.17
25
25
 
26
26
  > 在 DeepSeek Harness 中清洗、补全、画像企业名单数据的智能体插件:本地 CSV/XLSX/JSON 引擎 + 可选企查查(Qichacha/QCC)MCP 企业数据补全,由企查查(Qichacha/QCC)团队发起并维护。
27
27
  >
28
- > 当前源码版本 / Current source version: **0.8.17**(正式版本)
28
+ > 当前源码版本 / Current source version: **0.8.19**(正式版本)
29
29
 
30
30
  本版上传表格后展示完整清单及空白数量,用绿色、琥珀色、红色及文字区分已通过、待确认和未匹配映射。已映射字段自动纳入补全范围,额外字段默认折叠;多个原列可经确认使用同一非主体字段,分别补空并保留非空原值。修复 Host 草稿重置为默认五字段的问题,增加前后端版本错配提示。生成说明不查询,发送才执行;升级后需完整重启 DSH,历史下载文件不自动改写。
31
31
 
@@ -0,0 +1,13 @@
1
+ # 0.8.19 发布说明
2
+
3
+ 本版修复字段候选、搜索展示和结果交付体验。
4
+
5
+ - “受益人”推荐首条受益所有人;“主营业务收入”推荐营业总收入并提示口径不同,均需人工确认。
6
+ - 映射选择器按当前映射、搜索、大类、字段清单展示;筛选不修改已选映射。
7
+ - 已处理主体数包含未匹配或失败,不代表已补全;同时展示按输入行统计的补全与待核验数。
8
+ - QCC 工具结果卡片与工作台增加原生文件链接,不只依赖模型生成 Markdown。
9
+ - 空异常清单不展示;实际存在异常时保留。
10
+
11
+ 验证:245 项自动测试、10 组明暗色与响应式浏览器回归通过。未发起付费 QCC 查询,未修改上传源文件。实际 DSH 宿主中的文件点击仍需升级后验收。
12
+
13
+ 升级至 0.8.19 后完整重启 DSH。历史对话文本不会自动重写;历史制品可从工作台获取。
package/lib/artifacts.js CHANGED
@@ -23,6 +23,7 @@ const SYSTEM_FIELD_LABELS = Object.freeze({
23
23
  qcc_match_status: '匹配状态',
24
24
  qcc_field_issues: '字段补全待核验原因',
25
25
  qcc_source: '数据来源',
26
+ qcc_snapshot_note: '取值口径与报告期',
26
27
  qcc_error: '错误原因',
27
28
  match_status: '匹配状态',
28
29
  error: '错误原因',
package/lib/client.js CHANGED
@@ -1141,7 +1141,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1141
1141
  ['tax_violation', '税收违法'], ['judgment_debtor', '被执行人'], ['terminated_case', '终本案件'],
1142
1142
  ['equity_freeze', '股权冻结'],
1143
1143
  ];
1144
+ const FIELD_GROUP_ORDER = ['company_registration', 'contact_info', 'actual_controller', 'beneficial_owners', 'company_profile', 'financial_data', 'tax_invoice_info', 'listing_info', 'import_export_credit', 'company_risk_scan', 'company_related_risk_scan'];
1144
1145
  const FIELD_GROUPS = [
1146
+ ['beneficial_owners', '受益所有人', [['beneficial_owner_first_name', '受益所有人名称(首条)']], 'get_beneficial_owners'],
1147
+ ['financial_data', '财务数据', [['financial_total_revenue', '营业总收入'], ['financial_total_profit', '利润总额'], ['financial_total_assets', '总资产']], 'get_financial_data'],
1145
1148
  // Shared contract mirror: checked against qcc-field-contracts in tests.
1146
1149
  ['actual_controller', '实际控制人', [
1147
1150
  ['actual_controller_name', '实际控制人名称'],
@@ -1204,6 +1207,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1204
1207
  ...RELATED_RISK_KEY_FIELD_OPTIONS.map(([id, label]) => [`related_risk_${id}_party_count`, `关联风险-${label}命中关联方数`]),
1205
1208
  ], 'get_company_related_risk_scan'],
1206
1209
  ];
1210
+ FIELD_GROUPS.sort((a, b) => FIELD_GROUP_ORDER.indexOf(a[0]) - FIELD_GROUP_ORDER.indexOf(b[0]));
1207
1211
  const ENRICHMENT_OPTIONS = FIELD_GROUPS.flatMap(([, , fields]) => fields);
1208
1212
  const INPUT_ONLY_MAPPING_OPTIONS = [['phone', '联系电话']];
1209
1213
  const RESULT_FIELD_LABELS = new Map([
@@ -1476,6 +1480,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1476
1480
  }
1477
1481
 
1478
1482
  const MAPPING_ALIASES = {
1483
+ beneficial_owner_first_name: ['首条受益所有人名称'],
1479
1484
  actual_controller_name: ['实控人', '实际控制人', '企业实控人名称', '企业实际控制人', '企业实控人名称(自然人请填写姓名)'],
1480
1485
  actual_controller_direct_ratio: ['实际控制人直接持股比例'],
1481
1486
  actual_controller_total_ratio: ['实际控制人总持股比例'],
@@ -1492,6 +1497,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1492
1497
  };
1493
1498
  // 语义不完全等同:只推荐,绝不冒充精确匹配或发起调用。
1494
1499
  const MAPPING_HINTS = {
1500
+ 受益人: ['beneficial_owner_first_name'], 受益所有人: ['beneficial_owner_first_name'], 受益所有人名称: ['beneficial_owner_first_name'], UBO: ['beneficial_owner_first_name'],
1501
+ 主营业务收入: ['financial_total_revenue'],
1495
1502
  地址: ['registered_address', 'mailing_address', 'invoice_address'],
1496
1503
  address: ['registered_address', 'mailing_address'],
1497
1504
  企业地址: ['registered_address', 'mailing_address'], 公司地址: ['registered_address', 'mailing_address'],
@@ -1518,6 +1525,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1518
1525
  const hints = Object.entries(MAPPING_HINTS).find(([key]) => normalizeMappingLabel(key) === label)?.[1] || [];
1519
1526
  candidates = hints.flatMap((key) => fields.filter(([id]) => id === key));
1520
1527
  reason = candidates.length ? '含义需确认,点击推荐后采用' : '未识别表头,请手动选择';
1528
+ if (candidates.length && label === normalizeMappingLabel('主营业务收入')) reason = '主营业务收入与营业总收入口径不同;确认接受该口径后采用';
1529
+ if (candidates.length && label === normalizeMappingLabel('受益人')) reason = '受益人含义需确认;仅取首条受益所有人,不代表唯一或最大受益人';
1521
1530
  }
1522
1531
  return { sourceField: String(sourceField), candidates, automatic: exact, reason };
1523
1532
  });
@@ -1556,11 +1565,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1556
1565
 
1557
1566
  function MappingPicker({ sourceField, mappings, groups, recommendation, onChange }) {
1558
1567
  const [query, setQuery] = react.useState('');
1568
+ const [category, setCategory] = react.useState('');
1559
1569
  const value = mappings.find((item) => item.sourceField === sourceField)?.targetField || '';
1560
1570
  const occupied = (id) => isAnchorField(id) && mappings.some((item) => item.sourceField !== sourceField && item.targetField === id);
1561
1571
  const fields = groups.flatMap(([, , items]) => items);
1562
1572
  const label = fields.find(([id]) => id === value)?.[1] || '不参与匹配 / 补全';
1563
- const matches = (id, name) => normalizeMappingLabel([id, name, ...(MAPPING_ALIASES[id] || [])].join(' ')).includes(normalizeMappingLabel(query));
1573
+ const matches = (id, name) => normalizeMappingLabel([id, name, ...(MAPPING_ALIASES[id] || []), ...Object.entries(MAPPING_HINTS).filter(([, ids]) => ids.includes(id)).map(([hint]) => hint)].join(' ')).includes(normalizeMappingLabel(query));
1574
+ const filteredGroups = groups.filter(([id]) => !category || id === category)
1575
+ .map(([id, title, items]) => [id, title, items.filter(([key, name]) => matches(key, name))])
1576
+ .filter(([, , items]) => items.length);
1577
+ const filteredFields = filteredGroups.flatMap(([, , items]) => items);
1578
+ const hiddenSelection = value && !filteredFields.some(([id]) => id === value);
1564
1579
  return h('div', { className: 'dcAgentMappingRow' },
1565
1580
  h('span', { title: sourceField }, sourceField), h('b', { 'aria-hidden': 'true' }, '→'),
1566
1581
  h('div', { style: { minWidth: 0 } },
@@ -1571,18 +1586,23 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1571
1586
  }
1572
1587
  } },
1573
1588
  h('summary', { className: 'dcAgentField', 'aria-label': `${sourceField} 当前映射` }, label),
1574
- h('input', { className: 'dcAgentField', value: query, placeholder: '搜索字段名称 / 英文键 / 别名', 'aria-label': `${sourceField} 搜索映射字段`, onChange: (event) => setQuery(event.target.value) }),
1575
- h('select', { className: 'dcAgentField', value, 'aria-label': `${sourceField} 字段映射`, onChange: (event) => {
1589
+ h('input', { className: 'dcAgentField', value: query, placeholder: '模糊搜索字段名称 / 英文键 / 别名', 'aria-label': `${sourceField} 搜索映射字段`, onChange: (event) => setQuery(event.target.value) }),
1590
+ h('select', { className: 'dcAgentField', value: category, 'aria-label': `${sourceField} 映射大类`, onChange: (event) => setCategory(event.target.value) },
1591
+ h('option', { value: '' }, '全部维度'),
1592
+ groups.map(([id, title]) => h('option', { key: id, value: id }, title))),
1593
+ h('select', { className: 'dcAgentField', size: 8, style: { height: 'auto', minHeight: '180px' }, value, 'aria-label': `${sourceField} 字段映射`, onChange: (event) => {
1576
1594
  onChange(sourceField, event.target.value);
1577
1595
  const details = event.currentTarget.closest('details');
1578
1596
  if (details) { details.open = false; details.querySelector('summary')?.focus(); }
1579
1597
  } },
1580
1598
  h('option', { value: '' }, '不参与匹配 / 补全'),
1581
- groups.map(([id, title, items]) => h('optgroup', { key: id, label: title }, items.filter(([key, name]) => key === value || matches(key, name)).map(([key, name]) =>
1599
+ hiddenSelection ? h('option', { value, disabled: true }, `当前选择:${label}(不符合筛选,保持不变)`) : null,
1600
+ filteredGroups.map(([id, title, items]) => h('optgroup', { key: id, label: title }, items.map(([key, name]) =>
1582
1601
  h('option', { key, value: key, disabled: occupied(key) }, `${name}${occupied(key) ? '(已被其他列使用)' : ''}`)))),
1583
1602
  ),
1584
- query && !fields.some(([id, name]) => matches(id, name)) ? h('p', { className: 'dcAgentHint', role: 'status' }, '无匹配字段;当前选择保持不变。') : null,
1603
+ h('p', { className: 'dcAgentHint', role: 'status' }, filteredFields.length ? `匹配 ${filteredFields.length} 个字段;选择后采用。` : '无匹配字段;当前选择保持不变。'),
1585
1604
  ),
1605
+ value === 'beneficial_owner_first_name' || value.startsWith('financial_') ? h('p', { className: 'dcAgentHint' }, value === 'beneficial_owner_first_name' ? '仅取返回首条受益所有人,不代表唯一或最大受益人。' : '取返回首个报告期,不跨期补值;报告期保留在结果取值说明中。') : null,
1586
1606
  h('small', { className: 'dcAgentMappingStatus', 'data-status': value ? 'confirmed' : recommendation.candidates.length ? 'review' : 'unmatched',
1587
1607
  style: { display: 'block', padding: '6px 0' } },
1588
1608
  value ? `${recommendation.automatic && recommendation.candidates[0]?.[0] === value ? '自动通过' : '已确认'}:${label};可手动调整` : recommendation.candidates.length ? `待人工确认:${recommendation.conflict ? '重复列可确认使用同一补全字段;主体标识只选一列' : recommendation.reason}` : '未匹配:保留原列,可手动选择字段'),
@@ -1670,10 +1690,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1670
1690
  return requestJson(path, body === undefined ? 'GET' : 'POST', body);
1671
1691
  }
1672
1692
 
1673
- async function waitForQccCommand(commandId, attempts = 180) {
1693
+ async function waitForQccCommand(commandId, attempts = 180, onStatus) {
1674
1694
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1675
1695
  const response = await api(`/data-cleaning/api/g5/commands/${encodeURIComponent(commandId)}`);
1676
1696
  const command = response?.command;
1697
+ await onStatus?.(command);
1677
1698
  if (command?.state === 'completed' || command?.state === 'failed') return command;
1678
1699
  await new Promise((resolve) => setTimeout(resolve, 1000));
1679
1700
  }
@@ -1834,7 +1855,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1834
1855
  return response.task;
1835
1856
  }
1836
1857
  function watchHostedCommand(actions, sessionId, command) {
1837
- void waitForQccCommand(command.commandId, 1800).then(async (completed) => {
1858
+ void waitForQccCommand(command.commandId, 1800, async (status) => {
1859
+ if (!status || status.state === 'prepared') return;
1860
+ await refreshHostedTask(actions, sessionId, command.taskId, null, true).catch(() => {});
1861
+ if (isCleaningSession(sessionId) && workflowTaskBySession.get(String(sessionId))?.id === command.taskId) actions.setCommandProgress?.({ taskId: command.taskId, ...status.progress });
1862
+ }).then(async (completed) => {
1838
1863
  await refreshHostedTask(actions, sessionId, command.taskId, completed.run);
1839
1864
  if (completed.state === 'failed') throw new Error(completed.error?.message || 'Host 执行失败,请在工作台查看状态。');
1840
1865
  }).catch((error) => {
@@ -1893,6 +1918,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1893
1918
  setError: (draft, error) => { draft.error = error; },
1894
1919
  setInput: (draft, input) => { draft.input = input; },
1895
1920
  setWorkflowContract: (draft, value) => { draft.workflowContract = value; },
1921
+ setCommandProgress: (draft, value) => { draft.commandProgress = value; },
1896
1922
  setWorkflowTask: (draft, value) => { draft.workflowTask = value ?? null; },
1897
1923
  setWorkflowTasks: (draft, value) => { draft.workflowTasks = Array.isArray(value) ? value : []; },
1898
1924
  setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
@@ -3148,6 +3174,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3148
3174
 
3149
3175
  /** 模型工具 tool.call.toolview 卡片元数据(wire 名 → 展示文案)。 */
3150
3176
  const TOOL_VIEW_META = {
3177
+ data_cleaning_qcc_run: { icon: '🧩', label: '企查查清洗补全结果', hint: '已处理包含未匹配或失败;不等于已补全。' },
3151
3178
  data_clean_rows: { icon: '🧹', label: '数据清洗', hint: '去重 · 剔除非法金额 · 缺失补 0' },
3152
3179
  data_complete_rows: { icon: '🧩', label: '数据补全', hint: '名称 / 金额 / 手机号归一' },
3153
3180
  data_profile: { icon: '🩺', label: '数据体检', hint: '缺失率 · 去重值 · 金额分布' },
@@ -3186,6 +3213,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3186
3213
  ),
3187
3214
  meta.hint ? h('div', { className: 'dcAgentToolCardHint' }, meta.hint) : null,
3188
3215
  summary ? h('div', { className: 'dcAgentToolCardBody' }, summary) : null,
3216
+ toolName === 'data_cleaning_qcc_run' ? h('div', { className: 'dcAgentArtifactList' },
3217
+ [...summary.matchAll(/\[([^\]]+)\]\((\/data-cleaning\/api\/workflow\/tasks\/[a-zA-Z0-9%_-]+\/artifacts\/[a-zA-Z0-9%_-]+(?:\?preview=1)?)\)/g)].map((match, index) =>
3218
+ h('a', { key: index, className: 'dcAgentButton', href: match[2], target: '_blank', rel: 'noopener noreferrer' }, match[1]))) : null,
3189
3219
  );
3190
3220
  }
3191
3221
 
@@ -3429,6 +3459,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3429
3459
  const selectedDomains = useStore((state) => state.selectedDomains);
3430
3460
  const qccCapabilities = useStore((state) => state.qccCapabilities);
3431
3461
  const qccRun = useStore((state) => state.qccRun);
3462
+ const commandProgress = useStore((state) => state.commandProgress);
3432
3463
  const paidConfirmed = useStore((state) => state.paidConfirmed);
3433
3464
  const jobs = useStore((state) => state.jobs);
3434
3465
  const activeSessionId = useStore((state) => state.activeSessionId);
@@ -4325,10 +4356,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4325
4356
  h('div', { className: 'dcAgentRow' }, h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => actions.setStep('download') }, '进入下载数据')),
4326
4357
  );
4327
4358
  } else if (step === 'download') {
4328
- const availableArtifacts = cachedTask?.artifacts || [];
4359
+ const availableArtifacts = (cachedTask?.artifacts || []).filter(artifact => artifact.kind !== 'review' || artifact.rowCount > 0);
4329
4360
  const canCreateArtifacts = Boolean(exportRows()?.length) && ['rules_confirmed', 'diagnosed', 'export_ready', 'partial'].includes(cachedTask?.state);
4330
4361
  pane = h('div', { className: 'dcAgentPane' },
4331
- h('p', { className: 'dcAgentHint' }, '结果与异常清单由 Host 生成 CSV/XLSX 四件套并耐久保存。任务或插件重启后,可从任务历史继续下载;无需再次调用企查查。'),
4362
+ h('p', { className: 'dcAgentHint' }, '结果文件由 Host 保存;仅在有待核验记录时展示异常清单。任务或插件重启后,可从任务历史继续下载;无需再次调用企查查。'),
4332
4363
  h('div', { className: 'dcAgentGrid' },
4333
4364
  stat('输入行数', dataset ? dataset.rowCount : workflowTask?.source?.rowCount ?? '—'),
4334
4365
  stat('匹配补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
@@ -4336,6 +4367,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4336
4367
  stat('任务状态', WORKFLOW_STATE_LABELS[cachedTask?.state] || cachedTask?.state || '进行中'),
4337
4368
  ),
4338
4369
  availableArtifacts.length ? h('div', { className: 'dcAgentArtifactList' },
4370
+ availableArtifacts.filter(artifact => artifact.format === 'xlsx').map(artifact => h('a', {
4371
+ key: 'preview-' + artifact.id, className: 'dcAgentButton',
4372
+ href: `/data-cleaning/api/workflow/tasks/${encodeURIComponent(cachedTask.id)}/artifacts/${encodeURIComponent(artifact.id)}?preview=1`,
4373
+ target: '_blank', rel: 'noopener noreferrer',
4374
+ }, `预览 / 打开:${artifact.fileName}`)),
4339
4375
  availableArtifacts.map((artifact) => h('button', {
4340
4376
  key: artifact.id,
4341
4377
  type: 'button',
@@ -4348,8 +4384,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4348
4384
  h('div', { className: 'dcAgentRow' },
4349
4385
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('complete', 'xlsx') }, '生成并下载结果 XLSX'),
4350
4386
  h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('complete', 'csv') }, '生成并下载结果 CSV'),
4351
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'xlsx') }, '生成并下载异常清单 XLSX'),
4352
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'csv') }, '生成并下载异常清单 CSV'),
4387
+ qccReviewCount > 0 ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'xlsx') }, '生成并下载异常清单 XLSX') : null,
4388
+ qccReviewCount > 0 ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'csv') }, '生成并下载异常清单 CSV') : null,
4353
4389
  ),
4354
4390
  !canCreateArtifacts ? h('p', { className: 'dcAgentHint' }, '请先完成规则确认与清洗补全;历史任务若尚未生成制品,需要重新上传源文件。') : null,
4355
4391
  ),
@@ -4456,7 +4492,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4456
4492
  className: 'dcAgentQccBadge',
4457
4493
  title: qccRun ? `任务 ${qccRun.runId}` : '仅在当前用户确认使用自己的企查查账号后调用',
4458
4494
  }, qccRun ? `QCC · ${qccRun.state}` : qccCapabilities ? (qccCapabilities.capabilities?.ready ? 'QCC · 工具已就绪' : 'QCC · 能力不完整') : 'QCC · 自动校验'),
4459
- h('span', { className: 'dcAgentJobsPill', 'data-state': jobsPill(jobs).state, title: '后台任务状态' }, jobsPill(jobs).label),
4495
+ h('span', { className: 'dcAgentJobsPill', 'data-state': cachedTask?.state || 'idle', title: '当前清洗任务状态' }, cachedTask ? WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state : '尚未开始'),
4460
4496
  h('button', {
4461
4497
  className: 'dcAgentWbClose',
4462
4498
  type: 'button',
@@ -4495,6 +4531,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4495
4531
  h('span', { className: 'dcAgentStepLabel' }, st.label))),
4496
4532
  ) : null,
4497
4533
  h('div', { className: 'dcAgentWbBody' },
4534
+ cachedTask && step !== 'history' ? h('section', { className: 'dcAgentSection', 'aria-label': '当前任务进展', 'aria-live': 'polite' },
4535
+ h('b', null, WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state),
4536
+ h('p', { className: 'dcAgentHint' }, `任务:${cachedTask.id}`),
4537
+ h('p', null, `${cachedTask.source?.type === 'image' ? '图片识别' : cachedTask.source?.type === 'text' ? '粘贴名单' : '上传表格'} · ${cachedTask.source?.fileName || ''} · ${cachedTask.source?.rowCount || 0} 条 · ${cachedTask.fieldSelection?.length || 0} 个补全字段`),
4538
+ commandProgress?.taskId === cachedTask.id && Number.isFinite(commandProgress.totalUnique) ? h('p', null, `已处理主体 ${commandProgress.completedUnique}/${commandProgress.totalUnique}(去重后,含未匹配或失败,不代表补全成功)`) : null,
4539
+ h('p', null, `已补全 ${qccEnrichedCount} 条 · 待核验 ${qccReviewCount} 条(按输入行统计)`),
4540
+ ) : null,
4498
4541
  error ? h('div', { className: 'dcAgentError', role: 'alert' }, error) : null,
4499
4542
  step !== 'history' ? h('details', { className: 'dcAgentSection' },
4500
4543
  h('summary', null, '使用说明与任务流程'),
@@ -4588,7 +4631,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4588
4631
 
4589
4632
  // 模型工具的 tool.call.toolview 富化卡片(keyed by wire name,替代裸 JSON 摘要)。
4590
4633
  // 说明:分三条独立 inject(而非 generator)——测试 shim 对 inject 回调仅执行一次并 push 其返回值。
4591
- const toolviewKeys = ['data_clean_rows', 'data_complete_rows', 'data_profile', 'data_cleaning_extract_image_companies'];
4634
+ const toolviewKeys = ['data_clean_rows', 'data_complete_rows', 'data_profile', 'data_cleaning_extract_image_companies', 'data_cleaning_qcc_run'];
4592
4635
  for (const key of toolviewKeys) {
4593
4636
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
4594
4637
  name: 'tool.call.toolview',
@@ -104,6 +104,7 @@ export function serializeQccCommandPrompt(command) {
104
104
  `安全任务凭证:${command.commandId}`,
105
105
  '',
106
106
  `发送本说明后,请仅调用一次数据清洗补全执行工具(${TOOL_QCC_COMMAND}),参数只传递上述安全任务凭证。`,
107
+ '完成后请在对话中列出工具返回的 XLSX 文件名、预览和下载链接;只使用实际返回的链接,不编造文件或要求源文件路径。',
107
108
  '不要要求重复粘贴企业名单,不要直接调用 mcp__qcc-* 工具,不要重试、扩大名单或追加字段。',
108
109
  input.workflowOwned
109
110
  ? '发送即执行已确认方案,不再询问额度或写回确认。Host 会自动生成新的清洗补全结果 XLSX,原始上传文件不修改。不要寻找源文件路径、运行 Bash、编写导出脚本或另行覆盖源文件。'
@@ -182,6 +183,7 @@ export class QccCommandStore {
182
183
  runId: record.runId,
183
184
  error: record.error,
184
185
  estimate: record.estimate,
186
+ progress: record.progress ?? null,
185
187
  workflowOwned: record.input.workflowOwned === true,
186
188
  delivery: record.delivery ?? null,
187
189
  expiresInMs: this.ttlMs,
@@ -233,6 +235,7 @@ export class QccCommandStore {
233
235
  return this.runs.retryCompanies(record.input.runId, record.input.companyNames, this.bridge, options);
234
236
  }
235
237
  const audit = [];
238
+ record.progress = { completedUnique: 0, totalUnique: record.estimate?.uniqueCompanies || 0 };
236
239
  const result = await this.bridge.enrichRows(record.input.rows, {
237
240
  nameField: record.input.nameField,
238
241
  includeRisk: record.input.includeRisk,
@@ -241,6 +244,7 @@ export class QccCommandStore {
241
244
  maxRows: 100,
242
245
  execution,
243
246
  onAudit: (event) => audit.push(event),
247
+ onProgress: (progress) => { record.progress = progress; },
244
248
  });
245
249
  return this.runs.createRun({
246
250
  headers: record.input.headers,
@@ -260,7 +264,8 @@ export class QccCommandStore {
260
264
  runId: run.runId,
261
265
  state: run.state,
262
266
  summary: run.summary,
263
- ...(record.delivery ? { deliveryState: record.delivery.state, artifactCount: record.delivery.artifacts.length } : {}),
267
+ ...(record.delivery ? { deliveryState: record.delivery.state, artifactCount: record.delivery.artifacts.length,
268
+ artifacts: record.delivery.artifacts.filter(a => a.format === 'xlsx' && (a.kind !== 'review' || a.rowCount > 0)).map(a => ({ fileName: a.fileName, url: `/data-cleaning/api/workflow/tasks/${encodeURIComponent(record.taskId)}/artifacts/${encodeURIComponent(a.id)}` })) } : {}),
264
269
  });
265
270
  }
266
271
  }
@@ -286,6 +291,7 @@ export function registerQccCommandTool(tools, commands) {
286
291
  state: { type: 'string' },
287
292
  deliveryState: { type: 'string' },
288
293
  artifactCount: { type: 'integer' },
294
+ artifacts: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { fileName: { type: 'string' }, url: { type: 'string' } }, required: ['fileName', 'url'] } },
289
295
  summary: {
290
296
  type: 'object',
291
297
  additionalProperties: false,
@@ -307,7 +313,7 @@ export function registerQccCommandTool(tools, commands) {
307
313
  },
308
314
  render: (_args, value) => [{
309
315
  type: 'text',
310
- text: `数据清洗补全:${value.summary?.enriched ?? 0}/${value.summary?.totalRows ?? 0} 条已补全。${value.artifactCount ? 'Host 已生成新的结果 XLSX,请返回工作台「结果下载」。源文件未修改,无需再次确认或提供路径。' : value.deliveryState === 'review_required' ? '存在多候选,请返回工作台确认主体。' : '请返回工作台查看任务状态。'}`,
316
+ text: `处理结束:已处理 ${value.summary?.uniqueCompanies ?? 0} 个去重主体(含未匹配或失败);按输入行统计:${value.summary?.enriched ?? 0}/${value.summary?.totalRows ?? 0} 条已补全。未匹配 ${value.summary?.unresolved ?? 0} 条、失败 ${value.summary?.failed ?? 0} 条、多候选 ${value.summary?.ambiguous ?? 0} 条、字段待核验 ${value.summary?.fieldReview ?? 0} 条。不得仅凭补全为 0 推断授权异常;请依据实际错误记录。${value.artifactCount ? 'Host 已生成新的结果 XLSX,请返回工作台「结果下载」。源文件未修改,无需再次确认或提供路径。' : value.deliveryState === 'review_required' ? '存在多候选,请返回工作台确认主体。' : '请返回工作台查看任务状态。'}\n${(value.artifacts || []).map(a => `${String(a.fileName).replace(/[^a-zA-Z0-9\u4e00-\u9fff._ -]/g, '_')}:[预览 / 打开](${a.url}?preview=1) · [下载 Excel](${a.url})`).join('\n')}`,
311
317
  }],
312
318
  },
313
319
  async execute(args, exec) {
@@ -5,7 +5,7 @@
5
5
  * 任何明细列表(电话全集、海关资质、风险关联方等)都不得进入本目录。
6
6
  */
7
7
 
8
- import { ACTUAL_CONTROLLER_GROUP } from 'qcc-field-contracts';
8
+ import { ACTUAL_CONTROLLER_GROUP, SNAPSHOT_GROUPS, SNAPSHOT_GROUP_ORDER } from 'qcc-field-contracts';
9
9
  const field = (id, label, options = {}) => Object.freeze({ id, label, ...options });
10
10
  const group = (id, label, sourceTool, fields, options = {}) => Object.freeze({
11
11
  id,
@@ -175,7 +175,8 @@ export const QCC_FIELD_CATALOG = Object.freeze([
175
175
  ...RELATED_RISK_KEY_FACTORS.map(([id, label]) => field(`related_risk_${id}_party_count`, `关联风险-${label}命中关联方数`)),
176
176
  ], { releaseBatch: 'batch-2', catalogVersion: RISK_FACTOR_CATALOG_VERSION }),
177
177
  ACTUAL_CONTROLLER_GROUP,
178
- ]);
178
+ ...SNAPSHOT_GROUPS,
179
+ ].sort((a, b) => SNAPSHOT_GROUP_ORDER.indexOf(a.id) - SNAPSHOT_GROUP_ORDER.indexOf(b.id)));
179
180
 
180
181
  export const QCC_FIELD_SOURCE_TOOL = Object.freeze(Object.fromEntries(
181
182
  QCC_FIELD_CATALOG.flatMap((entry) => entry.fields.map((item) => [item.id, entry.sourceTool])),
package/lib/qcc-runs.js CHANGED
@@ -235,6 +235,7 @@ export class G5RunStore {
235
235
  qcc_match_status: 'enriched',
236
236
  ...(row.qcc_field_issues || result.fieldIssues ? { qcc_field_issues: Object.entries(result.fieldIssues || {}).map(([field, issue]) => field + ': ' + issue.message).join(';') } : {}),
237
237
  qcc_source: 'qcc-mcp',
238
+ ...(result.snapshotNote ? { qcc_snapshot_note: result.snapshotNote } : {}),
238
239
  };
239
240
  } else {
240
241
  record.rows[index] = { ...row, qcc_match_status: result.status };
package/lib/qcc.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * - 批量补全只在 Host/Web 内处理明细;模型仍只接收摘要。
9
9
  */
10
10
  import { safeAuditEvent } from './qcc-safety.js';
11
- import { projectActualController } from 'qcc-field-contracts';
11
+ import { projectActualController, projectFirstSnapshot } from 'qcc-field-contracts';
12
12
  import {
13
13
  QCC_PHASE2_COMPANY_TOOLS,
14
14
  QCC_PHASE2_HISTORY_TOOLS,
@@ -558,6 +558,7 @@ export function mapActualControllerFields(value, match) {
558
558
  return result.values;
559
559
  }
560
560
  const SOURCE_TOOL_CONFIG = Object.freeze({
561
+ ...Object.fromEntries(['get_beneficial_owners', 'get_financial_data'].map(tool => [tool, { name: `mcp__qcc-company__${tool}`, map: data => projectFirstSnapshot(data, tool).values }])),
561
562
  get_actual_controller: Object.freeze({ name: QCC_TOOL_NAMES.actualController, map: mapActualControllerFields }),
562
563
  get_company_registration_info: Object.freeze({ name: QCC_TOOL_NAMES.registration, map: mapRegistrationFields }),
563
564
  get_company_profile: Object.freeze({ name: QCC_TOOL_NAMES.profile, map: mapProfileFields }),
@@ -928,6 +929,7 @@ export class QccHostBridge {
928
929
  const sourceTools = sourceToolsForFieldSelection(options.fieldSelection, options.includeRisk);
929
930
  let mapped = {};
930
931
  const fieldIssues = {};
932
+ const snapshotNotes = [];
931
933
  let legacyRiskTags = '';
932
934
  for (const sourceTool of sourceTools) {
933
935
  const config = SOURCE_TOOL_CONFIG[sourceTool];
@@ -935,6 +937,11 @@ export class QccHostBridge {
935
937
  const response = await this.call(config.name, { searchKey: lockedKey, ...(config.args ?? {}) }, options);
936
938
  const fields = config.map(response.data, match);
937
939
  mapped = mergeMappedFields(mapped, fields);
940
+ if (['get_beneficial_owners', 'get_financial_data'].includes(sourceTool)) {
941
+ const projection = projectFirstSnapshot(response.data, sourceTool);
942
+ Object.assign(fieldIssues, Object.fromEntries(Object.entries(projection.issues).filter(([id]) => options.fieldSelection?.includes(id))));
943
+ snapshotNotes.push(sourceTool === 'get_financial_data' ? `get_financial_data:返回首期 ${projection.provenance.reportPeriod || '未返回报告期'};${projection.provenance.disclosure || '未返回披露等级'};金额原值未换算` : `get_beneficial_owners:仅取首条;返回 ${projection.provenance.recordCount} 条,不代表唯一受益人`);
944
+ }
938
945
  if (sourceTool === 'get_actual_controller') {
939
946
  Object.assign(fieldIssues, Object.fromEntries(Object.entries(projectActualController(response.data).issues)
940
947
  .filter(([field]) => options.fieldSelection?.includes(field))));
@@ -958,6 +965,7 @@ export class QccHostBridge {
958
965
  const fields = projectSelectedFields(mapped, options.fieldSelection);
959
966
  if (options.includeRisk) fields.risk_tags = legacyRiskTags;
960
967
  return { status: 'enriched', companyName: String(match?.companyName ?? ''), fields,
968
+ ...(snapshotNotes.length ? { snapshotNote: snapshotNotes.join(';') } : {}),
961
969
  ...(Object.keys(fieldIssues).length ? { fieldIssues } : {}) };
962
970
  }
963
971
 
@@ -1061,6 +1069,7 @@ export class QccHostBridge {
1061
1069
  ...Object.fromEntries(Object.entries(result.fields || {}).filter(([key]) => row[key] == null || (typeof row[key] === 'string' && row[key].trim() === ''))),
1062
1070
  qcc_match_status: 'enriched',
1063
1071
  qcc_source: 'qcc-mcp',
1072
+ ...(result.snapshotNote ? { qcc_snapshot_note: result.snapshotNote } : {}),
1064
1073
  ...(row.qcc_field_issues || result.fieldIssues ? { qcc_field_issues: Object.entries(result.fieldIssues || {}).map(([field, issue]) => field + ': ' + issue.message).join(';') } : {}),
1065
1074
  };
1066
1075
  }
package/lib/web.js CHANGED
@@ -559,6 +559,16 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
559
559
  const artifact = current.artifacts.find((item) => item.id === artifactId);
560
560
  if (!artifact) throw new ArtifactError('DC_ARTIFACT_NOT_FOUND', 'Artifact not found for this task.', 404);
561
561
  const bytes = await artifactStore.read(taskId, artifact);
562
+ if (artifact.format === 'xlsx' && new URL(req.url, 'http://127.0.0.1').searchParams.get('preview') === '1') {
563
+ const parsed = await parseXlsx(bytes);
564
+ const escape = value => String(value ?? '').replace(/[&<>\"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));
565
+ const columns = parsed.headers || [];
566
+ const rows = (parsed.rows || []).slice(0, 50);
567
+ const download = `/data-cleaning/api/workflow/tasks/${encodeURIComponent(taskId)}/artifacts/${encodeURIComponent(artifactId)}`;
568
+ const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(artifact.fileName)}</title><style>body{font:14px system-ui;padding:24px;color:#243044}table{border-collapse:collapse}td,th{border:1px solid #dde5ef;padding:8px;min-width:100px;white-space:pre-wrap}th{background:#e5f3ff}a{color:#0875ce}</style><h1>${escape(artifact.fileName)}</h1><p>任务 ${escape(taskId)} · 共 ${artifact.rowCount} 行,预览前 50 行;源文件未修改。</p><a href="${download}">下载完整 Excel</a><table><thead><tr>${columns.map(c => `<th>${escape(c)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(c => `<td>${escape(row[c])}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
569
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'", 'x-content-type-options': 'nosniff' });
570
+ return res.end(html);
571
+ }
562
572
  const fallback = artifact.format === 'xlsx' ? 'export.xlsx' : 'export.csv';
563
573
  const asciiName = String(artifact.fileName || fallback).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
564
574
  res.writeHead(200, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-data-cleaning-agent",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
4
4
  "description": "Data cleaning and data enrichment for CSV/XLSX/JSON enterprise lists in DeepSeek Harness, including spreadsheet cleaning, deduplication, profiling, optional Qichacha MCP and exports.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -40,6 +40,7 @@
40
40
  "docs/RELEASE-0.8.1.md",
41
41
  "docs/RELEASE-0.8.16.md",
42
42
  "docs/RELEASE-0.8.17.md",
43
+ "docs/RELEASE-0.8.19.md",
43
44
  "docs/TEMPLATE-MAPPING-ACCEPTANCE.md",
44
45
  "docs/UI-WORKFLOW-V2.md",
45
46
  "docs/UI-WORKFLOW-V2-MIGRATION.md",
@@ -117,7 +118,7 @@
117
118
  "node": ">=20"
118
119
  },
119
120
  "dependencies": {
120
- "qcc-field-contracts": "0.1.0",
121
+ "qcc-field-contracts": "0.2.0",
121
122
  "xlsx": "^0.18.5"
122
123
  },
123
124
  "peerDependencies": {