dsh-data-cleaning-agent 0.8.17 → 0.8.18

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,13 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.18] - 2026-09-09
8
+
9
+ - 新增受益所有人首条名称及首报告期营业总收入、利润总额、总资产,共 136 个字段;共享契约 0.2.0。
10
+ - 映射支持大类与关键词联合筛选,统一十一类排序,保留既有选择。
11
+ - 执行期间同步 Host 任务状态和已处理主体数,展示来源、条目数与补全字段数;状态徽标仅反映当前任务。
12
+ - 工具结果返回真实 XLSX 预览/下载入口,绑定 taskId;安全预览前 50 行,不修改源文件、不重复收费查询。
13
+
7
14
  ## [0.8.17] - 2026-09-08
8
15
 
9
16
  ### 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.18** (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.18.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.18
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.18**(正式版本)
29
29
 
30
30
  本版上传表格后展示完整清单及空白数量,用绿色、琥珀色、红色及文字区分已通过、待确认和未匹配映射。已映射字段自动纳入补全范围,额外字段默认折叠;多个原列可经确认使用同一非主体字段,分别补空并保留非空原值。修复 Host 草稿重置为默认五字段的问题,增加前后端版本错配提示。生成说明不查询,发送才执行;升级后需完整重启 DSH,历史下载文件不自动改写。
31
31
 
@@ -0,0 +1,15 @@
1
+ # 0.8.18
2
+
3
+ 新增共享首条快照契约(qcc-field-contracts 0.2.0),目录共 136 个字段。
4
+
5
+ - 受益所有人仅取工具返回首条名称,不代表唯一或最大受益人;不以经营管理人员替代。
6
+ - 财务三项取同一个首返回报告期的主要财务指标,缺项留空,不跨期拼接;保留 0、原始精度、报告期与披露等级。
7
+ - 映射大类与关键词联合筛选,当前选择不因过滤丢失。十一类按工商、联系方式、实控人、受益所有人、简介、财务、税务、上市、进出口、自身风险、关联风险排序。
8
+ - 执行中的任务每秒读取 Host 命令状态与任务元数据。显示来源方式、文件名、输入条数、补全字段数、去重后已处理主体数;已处理不等于成功补全。
9
+ - 对话工具结果包含实际任务的 XLSX 链接:浏览器安全预览前 50 行、下载完整新文件。原上传文件不修改。
10
+
11
+ ## 验证
12
+
13
+ 使用合成返回测试首条规则、缺失和精度;验证原列补空、多列映射及 Host 跨重挂载下载。浏览器回归覆盖明暗主题及五种视口。无真实企查查收费查询。
14
+
15
+ 升级后完整重启 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,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1492
1497
  };
1493
1498
  // 语义不完全等同:只推荐,绝不冒充精确匹配或发起调用。
1494
1499
  const MAPPING_HINTS = {
1500
+ 受益所有人: ['beneficial_owner_first_name'], 受益所有人名称: ['beneficial_owner_first_name'], UBO: ['beneficial_owner_first_name'],
1495
1501
  地址: ['registered_address', 'mailing_address', 'invoice_address'],
1496
1502
  address: ['registered_address', 'mailing_address'],
1497
1503
  企业地址: ['registered_address', 'mailing_address'], 公司地址: ['registered_address', 'mailing_address'],
@@ -1556,6 +1562,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1556
1562
 
1557
1563
  function MappingPicker({ sourceField, mappings, groups, recommendation, onChange }) {
1558
1564
  const [query, setQuery] = react.useState('');
1565
+ const [category, setCategory] = react.useState('');
1559
1566
  const value = mappings.find((item) => item.sourceField === sourceField)?.targetField || '';
1560
1567
  const occupied = (id) => isAnchorField(id) && mappings.some((item) => item.sourceField !== sourceField && item.targetField === id);
1561
1568
  const fields = groups.flatMap(([, , items]) => items);
@@ -1571,6 +1578,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1571
1578
  }
1572
1579
  } },
1573
1580
  h('summary', { className: 'dcAgentField', 'aria-label': `${sourceField} 当前映射` }, label),
1581
+ h('select', { className: 'dcAgentField', value: category, 'aria-label': `${sourceField} 映射大类`, onChange: (event) => setCategory(event.target.value) },
1582
+ h('option', { value: '' }, '全部维度'),
1583
+ groups.map(([id, title]) => h('option', { key: id, value: id }, title))),
1574
1584
  h('input', { className: 'dcAgentField', value: query, placeholder: '搜索字段名称 / 英文键 / 别名', 'aria-label': `${sourceField} 搜索映射字段`, onChange: (event) => setQuery(event.target.value) }),
1575
1585
  h('select', { className: 'dcAgentField', value, 'aria-label': `${sourceField} 字段映射`, onChange: (event) => {
1576
1586
  onChange(sourceField, event.target.value);
@@ -1578,11 +1588,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1578
1588
  if (details) { details.open = false; details.querySelector('summary')?.focus(); }
1579
1589
  } },
1580
1590
  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]) =>
1591
+ groups.filter(([id, , items]) => !category || id === category || items.some(([key]) => key === value)).map(([id, title, items]) => h('optgroup', { key: id, label: title }, items.filter(([key, name]) => key === value || ((!category || category === id) && matches(key, name))).map(([key, name]) =>
1582
1592
  h('option', { key, value: key, disabled: occupied(key) }, `${name}${occupied(key) ? '(已被其他列使用)' : ''}`)))),
1583
1593
  ),
1584
- query && !fields.some(([id, name]) => matches(id, name)) ? h('p', { className: 'dcAgentHint', role: 'status' }, '无匹配字段;当前选择保持不变。') : null,
1594
+ (query || category) && !groups.filter(([id]) => !category || id === category).flatMap(([, , items]) => items).some(([id, name]) => matches(id, name)) ? h('p', { className: 'dcAgentHint', role: 'status' }, '无匹配字段;当前选择保持不变。') : null,
1585
1595
  ),
1596
+ value === 'beneficial_owner_first_name' || value.startsWith('financial_') ? h('p', { className: 'dcAgentHint' }, value === 'beneficial_owner_first_name' ? '仅取返回首条受益所有人,不代表唯一或最大受益人。' : '取返回首个报告期,不跨期补值;报告期保留在结果取值说明中。') : null,
1586
1597
  h('small', { className: 'dcAgentMappingStatus', 'data-status': value ? 'confirmed' : recommendation.candidates.length ? 'review' : 'unmatched',
1587
1598
  style: { display: 'block', padding: '6px 0' } },
1588
1599
  value ? `${recommendation.automatic && recommendation.candidates[0]?.[0] === value ? '自动通过' : '已确认'}:${label};可手动调整` : recommendation.candidates.length ? `待人工确认:${recommendation.conflict ? '重复列可确认使用同一补全字段;主体标识只选一列' : recommendation.reason}` : '未匹配:保留原列,可手动选择字段'),
@@ -1670,10 +1681,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1670
1681
  return requestJson(path, body === undefined ? 'GET' : 'POST', body);
1671
1682
  }
1672
1683
 
1673
- async function waitForQccCommand(commandId, attempts = 180) {
1684
+ async function waitForQccCommand(commandId, attempts = 180, onStatus) {
1674
1685
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1675
1686
  const response = await api(`/data-cleaning/api/g5/commands/${encodeURIComponent(commandId)}`);
1676
1687
  const command = response?.command;
1688
+ await onStatus?.(command);
1677
1689
  if (command?.state === 'completed' || command?.state === 'failed') return command;
1678
1690
  await new Promise((resolve) => setTimeout(resolve, 1000));
1679
1691
  }
@@ -1834,7 +1846,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1834
1846
  return response.task;
1835
1847
  }
1836
1848
  function watchHostedCommand(actions, sessionId, command) {
1837
- void waitForQccCommand(command.commandId, 1800).then(async (completed) => {
1849
+ void waitForQccCommand(command.commandId, 1800, async (status) => {
1850
+ if (!status || status.state === 'prepared') return;
1851
+ await refreshHostedTask(actions, sessionId, command.taskId, null, true).catch(() => {});
1852
+ if (isCleaningSession(sessionId) && workflowTaskBySession.get(String(sessionId))?.id === command.taskId) actions.setCommandProgress?.({ taskId: command.taskId, ...status.progress });
1853
+ }).then(async (completed) => {
1838
1854
  await refreshHostedTask(actions, sessionId, command.taskId, completed.run);
1839
1855
  if (completed.state === 'failed') throw new Error(completed.error?.message || 'Host 执行失败,请在工作台查看状态。');
1840
1856
  }).catch((error) => {
@@ -1893,6 +1909,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1893
1909
  setError: (draft, error) => { draft.error = error; },
1894
1910
  setInput: (draft, input) => { draft.input = input; },
1895
1911
  setWorkflowContract: (draft, value) => { draft.workflowContract = value; },
1912
+ setCommandProgress: (draft, value) => { draft.commandProgress = value; },
1896
1913
  setWorkflowTask: (draft, value) => { draft.workflowTask = value ?? null; },
1897
1914
  setWorkflowTasks: (draft, value) => { draft.workflowTasks = Array.isArray(value) ? value : []; },
1898
1915
  setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
@@ -3429,6 +3446,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3429
3446
  const selectedDomains = useStore((state) => state.selectedDomains);
3430
3447
  const qccCapabilities = useStore((state) => state.qccCapabilities);
3431
3448
  const qccRun = useStore((state) => state.qccRun);
3449
+ const commandProgress = useStore((state) => state.commandProgress);
3432
3450
  const paidConfirmed = useStore((state) => state.paidConfirmed);
3433
3451
  const jobs = useStore((state) => state.jobs);
3434
3452
  const activeSessionId = useStore((state) => state.activeSessionId);
@@ -4456,7 +4474,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4456
4474
  className: 'dcAgentQccBadge',
4457
4475
  title: qccRun ? `任务 ${qccRun.runId}` : '仅在当前用户确认使用自己的企查查账号后调用',
4458
4476
  }, 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),
4477
+ h('span', { className: 'dcAgentJobsPill', 'data-state': cachedTask?.state || 'idle', title: '当前清洗任务状态' }, cachedTask ? WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state : '尚未开始'),
4460
4478
  h('button', {
4461
4479
  className: 'dcAgentWbClose',
4462
4480
  type: 'button',
@@ -4495,6 +4513,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4495
4513
  h('span', { className: 'dcAgentStepLabel' }, st.label))),
4496
4514
  ) : null,
4497
4515
  h('div', { className: 'dcAgentWbBody' },
4516
+ cachedTask && step !== 'history' ? h('section', { className: 'dcAgentSection', 'aria-label': '当前任务进展', 'aria-live': 'polite' },
4517
+ h('b', null, WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state),
4518
+ h('p', { className: 'dcAgentHint' }, `任务:${cachedTask.id}`),
4519
+ h('p', null, `${cachedTask.source?.type === 'image' ? '图片识别' : cachedTask.source?.type === 'text' ? '粘贴名单' : '上传表格'} · ${cachedTask.source?.fileName || ''} · ${cachedTask.source?.rowCount || 0} 条 · ${cachedTask.fieldSelection?.length || 0} 个补全字段`),
4520
+ commandProgress?.taskId === cachedTask.id && Number.isFinite(commandProgress.totalUnique) ? h('p', null, `已处理主体 ${commandProgress.completedUnique}/${commandProgress.totalUnique}(去重后,含未匹配或失败)`) : null,
4521
+ ) : null,
4498
4522
  error ? h('div', { className: 'dcAgentError', role: 'alert' }, error) : null,
4499
4523
  step !== 'history' ? h('details', { className: 'dcAgentSection' },
4500
4524
  h('summary', null, '使用说明与任务流程'),
@@ -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').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?.enriched ?? 0}/${value.summary?.totalRows ?? 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.18",
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.18.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": {