dsh-data-cleaning-agent 0.8.10 → 0.8.12
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 +14 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/docs/USER-GUIDE.md +2 -0
- package/lib/artifacts.js +9 -3
- package/lib/client.js +181 -39
- package/lib/engine.js +43 -2
- package/lib/qcc-runs.js +1 -1
- package/lib/qcc.js +1 -1
- package/lib/web.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.12] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- 导入页以已解析数据集显示当前来源、文件名与行数,不再依赖可能重建的原生文件选择框。文件选择后自动解析;文本按钮仅处理新粘贴内容,避免成功上传后误报「请粘贴或上传数据」。
|
|
11
|
+
- 支持重新选择同一文件,失败替换保留现有数据;新粘贴内容需先解析或清空,避免带着未生效输入进入下一步。仅有历史摘要而无明细时明确要求重新导入。
|
|
12
|
+
|
|
13
|
+
## [0.8.11] - 2026-09-07
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- 补全结果按已确认映射填入原列空白单元格,保留非空值和列顺序;工作台预览、CSV/XLSX 和异常清单一致,不再为已映射列追加「(补全)」副列。首次调用及候选续跑均保护原表英文键的非空值。
|
|
17
|
+
- 字段映射覆盖当前 128 个可补全字段的标准中文名、英文键和明确别名;移除未知首列强制映射企业名称的兜底。
|
|
18
|
+
- 参照 AI 填表的推荐确认方式,为多义表头提供推荐、折叠分组选择和搜索;重复目标需人工选列,保留已有选择及输出字段范围。
|
|
19
|
+
- CSV/XLSX 重复表头生成唯一列键,防止法定代表人等同名列相互覆盖。旧任务已丢失的原值需重新导入原文件恢复。
|
|
20
|
+
|
|
7
21
|
## [0.8.10] - 2026-09-07
|
|
8
22
|
|
|
9
23
|
### Changed
|
package/README.en.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
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.
|
|
5
|
+
> Current source version / 当前源码版本: **0.8.12** (stable release)
|
|
6
6
|
|
|
7
|
-
This release
|
|
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
|
|
|
9
9
|
[](https://github.com/duhu2000/dsh-data-cleaning-agent/actions/workflows/ci.yml)
|
|
10
10
|
[](https://www.npmjs.com/package/dsh-data-cleaning-agent)
|
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> 在 DeepSeek Harness 中清洗、补全、画像企业名单数据的智能体插件:本地 CSV/XLSX/JSON 引擎 + 可选企查查(Qichacha/QCC)MCP 企业数据补全,由企查查(Qichacha/QCC)团队发起并维护。
|
|
4
4
|
>
|
|
5
|
-
> 当前源码版本 / Current source version: **0.8.
|
|
5
|
+
> 当前源码版本 / Current source version: **0.8.12**(正式版本)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
本版修复文件上传成功后的导入状态误报:文件选择后自动解析,当前来源、文件名和行数持续显示;文本解析仅处理新粘贴内容。支持重新选择同一文件,解析失败保留现有数据,新粘贴内容解析或清空后再进入下一步。保留 128 字段智能映射、原列补空、企查查蓝菜单、可调宽工作台及 DSH 原生发送机制。历史下载文件不自动改写。
|
|
8
8
|
|
|
9
9
|
[](https://github.com/duhu2000/dsh-data-cleaning-agent/actions/workflows/ci.yml)
|
|
10
10
|
[](https://www.npmjs.com/package/dsh-data-cleaning-agent)
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -45,6 +45,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/duhu2000/dsh-data-cleaning-a
|
|
|
45
45
|
4. **清洗补全**:执行本地确定性清洗;需要 QCC 时先零调用估算,再由当前用户确认使用自己的账号额度;
|
|
46
46
|
5. **下载数据**:生成结果 CSV/XLSX 与异常清单 CSV/XLSX,后续可从任务历史恢复下载。
|
|
47
47
|
|
|
48
|
+
0.8.11 起的原列回填规则:规则确认时同时检查「字段映射」和「输出字段」。已选字段优先填入已映射原列的空白单元格,非空原值保持不变,原表没有对应映射的已选字段才新增列。地址、网址、开业时间等推荐需确认含义;重复列需明确选定一列。未返回的数据不编造。结果预览、XLSX/CSV 及异常清单遵循同一规则;历史已生成文件不会自动改写。
|
|
49
|
+
|
|
48
50
|
输入框左上角的「提示词生成」提供三种名单录入方式:
|
|
49
51
|
|
|
50
52
|
- **粘贴名单**:每行一个企业名称或统一社会信用代码;
|
package/lib/artifacts.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { createHash, randomUUID } from 'node:crypto';
|
|
9
9
|
import XLSX from 'xlsx';
|
|
10
10
|
import { FIELD_LABELS, normalizeFieldSelection } from './workflow-contract.js';
|
|
11
|
+
import { projectCompletionResult } from './engine.js';
|
|
11
12
|
|
|
12
13
|
const ROOT = '.dsh-data-cleaning-artifacts/v1';
|
|
13
14
|
const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
@@ -218,12 +219,17 @@ export class WorkflowArtifactStore {
|
|
|
218
219
|
|
|
219
220
|
async createBundle(taskId, input = {}) {
|
|
220
221
|
assertId(taskId, 'taskId');
|
|
221
|
-
const
|
|
222
|
-
|
|
222
|
+
const projected = projectCompletionResult({
|
|
223
|
+
rows: normalizeRows(input.rows), headers: input.headers || [],
|
|
224
|
+
mappings: input.mappings || [], fieldSelection: normalizeFieldSelection(input.fieldSelection),
|
|
225
|
+
});
|
|
226
|
+
const rows = projected.rows;
|
|
227
|
+
const headers = normalizeHeaders(projected.headers, rows);
|
|
223
228
|
if (!headers.length) throw new ArtifactError('DC_ARTIFACT_HEADERS_REQUIRED', 'At least one export column is required.', 400);
|
|
224
229
|
const exceptions = input.exceptionRows === undefined
|
|
225
230
|
? deriveExceptionRows(rows)
|
|
226
|
-
: normalizeRows(input.exceptionRows)
|
|
231
|
+
: projectCompletionResult({ rows: normalizeRows(input.exceptionRows), headers: input.headers || [],
|
|
232
|
+
mappings: input.mappings || [], fieldSelection: normalizeFieldSelection(input.fieldSelection) }).rows;
|
|
227
233
|
const exceptionHeaders = normalizeHeaders([...headers, '_exception_reason'], exceptions);
|
|
228
234
|
const baseName = safeFilePart(input.baseName, '数据清洗补全结果');
|
|
229
235
|
const timestamp = this.nowFn();
|
package/lib/client.js
CHANGED
|
@@ -944,6 +944,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
944
944
|
.dcAgentMappingRow { display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(180px, 1fr); align-items: center; gap: 8px; margin-top: 7px; font-size: 12px; }
|
|
945
945
|
.dcAgentMappingRow > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
946
946
|
.dcAgentMappingRow > b { text-align: center; color: light-dark(#8792a3, #8290a5); }
|
|
947
|
+
.dcAgentMappingPicker summary { cursor: pointer; overflow-wrap: anywhere; }
|
|
948
|
+
.dcAgentMappingPicker[open] { padding: 8px; border: 1px solid var(--dc-border); border-radius: 8px; }
|
|
949
|
+
.dcAgentMappingPicker input, .dcAgentMappingPicker select { width: 100%; margin-top: 8px; max-width: 100%; min-width: 0; }
|
|
950
|
+
.dcAgentMappingRow .dcAgentButton { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
|
|
951
|
+
.dcAgentMappingRow small { display: block; margin: 4px 0; overflow-wrap: anywhere; }
|
|
947
952
|
.dcAgentRulesGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
948
953
|
.dcAgentFieldGroup { display: grid; gap: 7px; margin-top: 12px; }
|
|
949
954
|
.dcAgentFieldGroupHead { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
|
@@ -1428,30 +1433,128 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1428
1433
|
return RESULT_FIELD_LABELS.get(String(key ?? '')) ?? String(key ?? '');
|
|
1429
1434
|
}
|
|
1430
1435
|
|
|
1431
|
-
const
|
|
1432
|
-
['
|
|
1433
|
-
['
|
|
1434
|
-
['
|
|
1435
|
-
['
|
|
1436
|
-
['
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1436
|
+
const MAPPING_ALIASES = {
|
|
1437
|
+
company_name: ['name', 'company', '公司名称', '单位名称', '企业全称', '公司全称', '主体标识'],
|
|
1438
|
+
credit_no: ['creditCode', 'unified_credit_code', '信用代码', '社会信用代码', '统一信用代码', 'USCC'],
|
|
1439
|
+
reg_no: ['registration_no', '工商注册号'],
|
|
1440
|
+
legal_rep: ['法定代表', '法人代表', 'legalRepresentative', 'legal_person'],
|
|
1441
|
+
reg_capital: ['注册资金'], establish_date: ['成立时间', '企业成立日期', 'establishmentDate'],
|
|
1442
|
+
reg_status: ['工商登记状态'], registered_address: ['企业注册地址', '公司注册地址'],
|
|
1443
|
+
phone: ['mobile', 'tel', 'telephone', '电话', '手机号码', '手机号'],
|
|
1444
|
+
contact_official_website: ['官网', '企业官网', '官方网站地址'],
|
|
1445
|
+
contact_preferred_email: ['首选电子邮箱'],
|
|
1446
|
+
};
|
|
1447
|
+
// 语义不完全等同:只推荐,绝不冒充精确匹配或发起调用。
|
|
1448
|
+
const MAPPING_HINTS = {
|
|
1449
|
+
地址: ['registered_address', 'mailing_address', 'invoice_address'],
|
|
1450
|
+
address: ['registered_address', 'mailing_address'],
|
|
1451
|
+
企业地址: ['registered_address', 'mailing_address'], 公司地址: ['registered_address', 'mailing_address'],
|
|
1452
|
+
网址: ['contact_official_website'], website: ['contact_official_website'],
|
|
1453
|
+
开业时间: ['establish_date'], 开业日期: ['establish_date'],
|
|
1454
|
+
行业: ['industry_category', 'qcc_industry'],
|
|
1455
|
+
法人: ['legal_rep'], 邮箱: ['contact_preferred_email'], email: ['contact_preferred_email'],
|
|
1456
|
+
};
|
|
1457
|
+
function normalizeMappingLabel(value) {
|
|
1458
|
+
return String(value ?? '').normalize('NFKC').toLowerCase().replace(/[\s_\-·]/g, '');
|
|
1459
|
+
}
|
|
1460
|
+
function mappingRecommendations(headers, fields = [...ENRICHMENT_OPTIONS, ...INPUT_ONLY_MAPPING_OPTIONS]) {
|
|
1461
|
+
const rows = (Array.isArray(headers) ? headers : []).map((sourceField) => {
|
|
1462
|
+
const label = normalizeMappingLabel(String(sourceField).replace(/(重复列 \d+)$/, ''));
|
|
1463
|
+
let candidates = fields.filter(([id, name]) => [id, name].some((value) => normalizeMappingLabel(value) === label));
|
|
1464
|
+
let reason = '标准字段名匹配';
|
|
1465
|
+
if (!candidates.length) {
|
|
1466
|
+
candidates = fields.filter(([id]) => (MAPPING_ALIASES[id] || []).some((value) => normalizeMappingLabel(value) === label));
|
|
1467
|
+
reason = '常用别名匹配';
|
|
1468
|
+
}
|
|
1469
|
+
const exact = candidates.length === 1;
|
|
1470
|
+
if (!candidates.length) {
|
|
1471
|
+
const hints = Object.entries(MAPPING_HINTS).find(([key]) => normalizeMappingLabel(key) === label)?.[1] || [];
|
|
1472
|
+
candidates = hints.flatMap((key) => fields.filter(([id]) => id === key));
|
|
1473
|
+
reason = candidates.length ? '含义需确认,点击推荐后采用' : '未识别表头,请手动选择';
|
|
1474
|
+
}
|
|
1475
|
+
return { sourceField: String(sourceField), candidates, automatic: exact, reason };
|
|
1476
|
+
});
|
|
1477
|
+
for (const row of rows) {
|
|
1478
|
+
if (row.automatic && rows.filter((other) => other.automatic && other.candidates[0][0] === row.candidates[0][0]).length > 1) {
|
|
1479
|
+
row.conflict = true;
|
|
1480
|
+
row.reason = '多列推荐同一字段,请选择其中一列;其他列保留原值';
|
|
1447
1481
|
}
|
|
1448
1482
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1483
|
+
return rows.map((row) => ({ ...row, automatic: row.automatic && !row.conflict }));
|
|
1484
|
+
}
|
|
1485
|
+
function guessMappings(headers, fields) {
|
|
1486
|
+
return mappingRecommendations(headers, fields).filter((row) => row.automatic)
|
|
1487
|
+
.map((row) => ({ sourceField: row.sourceField, targetField: row.candidates[0][0] }));
|
|
1488
|
+
}
|
|
1489
|
+
function mergeRecommendedMappings(headers, mappings, fields) {
|
|
1490
|
+
const output = [...mappings];
|
|
1491
|
+
for (const item of guessMappings(headers, fields)) {
|
|
1492
|
+
if (!output.some((current) => current.sourceField === item.sourceField || current.targetField === item.targetField)) output.push(item);
|
|
1451
1493
|
}
|
|
1452
1494
|
return output;
|
|
1453
1495
|
}
|
|
1454
1496
|
|
|
1497
|
+
function MappingPicker({ sourceField, mappings, groups, recommendation, onChange }) {
|
|
1498
|
+
const [query, setQuery] = react.useState('');
|
|
1499
|
+
const value = mappings.find((item) => item.sourceField === sourceField)?.targetField || '';
|
|
1500
|
+
const occupied = (id) => mappings.some((item) => item.sourceField !== sourceField && item.targetField === id);
|
|
1501
|
+
const fields = groups.flatMap(([, , items]) => items);
|
|
1502
|
+
const label = fields.find(([id]) => id === value)?.[1] || '不参与匹配 / 补全';
|
|
1503
|
+
const matches = (id, name) => normalizeMappingLabel([id, name, ...(MAPPING_ALIASES[id] || [])].join(' ')).includes(normalizeMappingLabel(query));
|
|
1504
|
+
return h('div', { className: 'dcAgentMappingRow' },
|
|
1505
|
+
h('span', { title: sourceField }, sourceField), h('b', { 'aria-hidden': 'true' }, '→'),
|
|
1506
|
+
h('div', { style: { minWidth: 0 } },
|
|
1507
|
+
h('details', { className: 'dcAgentMappingPicker', onKeyDown: (event) => {
|
|
1508
|
+
if (event.key === 'Escape' && event.currentTarget.open) {
|
|
1509
|
+
event.preventDefault(); event.stopPropagation();
|
|
1510
|
+
event.currentTarget.open = false; event.currentTarget.querySelector('summary')?.focus();
|
|
1511
|
+
}
|
|
1512
|
+
} },
|
|
1513
|
+
h('summary', { className: 'dcAgentField', 'aria-label': `${sourceField} 当前映射` }, label),
|
|
1514
|
+
h('input', { className: 'dcAgentField', value: query, placeholder: '搜索字段名称 / 英文键 / 别名', 'aria-label': `${sourceField} 搜索映射字段`, onChange: (event) => setQuery(event.target.value) }),
|
|
1515
|
+
h('select', { className: 'dcAgentField', value, 'aria-label': `${sourceField} 字段映射`, onChange: (event) => {
|
|
1516
|
+
onChange(sourceField, event.target.value);
|
|
1517
|
+
const details = event.currentTarget.closest('details');
|
|
1518
|
+
if (details) { details.open = false; details.querySelector('summary')?.focus(); }
|
|
1519
|
+
} },
|
|
1520
|
+
h('option', { value: '' }, '不参与匹配 / 补全'),
|
|
1521
|
+
groups.map(([id, title, items]) => h('optgroup', { key: id, label: title }, items.filter(([key, name]) => key === value || matches(key, name)).map(([key, name]) =>
|
|
1522
|
+
h('option', { key, value: key, disabled: occupied(key) }, `${name}${occupied(key) ? '(已被其他列使用)' : ''}`)))),
|
|
1523
|
+
),
|
|
1524
|
+
query && !fields.some(([id, name]) => matches(id, name)) ? h('p', { className: 'dcAgentHint', role: 'status' }, '无匹配字段;当前选择保持不变。') : null,
|
|
1525
|
+
),
|
|
1526
|
+
h('small', { className: 'dcAgentHint' }, value ? `已映射:${label};可手动调整` : recommendation.reason),
|
|
1527
|
+
!value && recommendation.candidates.length ? h('div', { className: 'dcAgentRow', 'aria-label': `${sourceField} 推荐映射` }, recommendation.candidates.map(([id, name]) =>
|
|
1528
|
+
h('button', { key: id, type: 'button', className: 'dcAgentButton', disabled: occupied(id), onClick: () => onChange(sourceField, id) }, `推荐:${name}${occupied(id) ? '(已占用)' : ''}`))) : null,
|
|
1529
|
+
),
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function projectCompletionResult({ rows = [], headers = [], mappings = [], fieldSelection = [] } = {}) {
|
|
1534
|
+
const sourceHeaders = [...new Set(headers)];
|
|
1535
|
+
const selected = [...new Set(fieldSelection)];
|
|
1536
|
+
const pairs = mappings.filter((item) => sourceHeaders.includes(item.sourceField) && selected.includes(item.targetField)
|
|
1537
|
+
&& mappings.filter((other) => other.sourceField === item.sourceField).length === 1
|
|
1538
|
+
&& mappings.filter((other) => other.targetField === item.targetField).length === 1);
|
|
1539
|
+
const hidden = new Set(pairs.filter((item) => item.sourceField !== item.targetField && !sourceHeaders.includes(item.targetField)).map((item) => item.targetField));
|
|
1540
|
+
const blank = (value) => value == null || (typeof value === 'string' && value.trim() === '');
|
|
1541
|
+
const output = rows.map((row) => {
|
|
1542
|
+
const next = { ...row };
|
|
1543
|
+
const status = row.qcc_match_status;
|
|
1544
|
+
if (!status || ['enriched', 'exact'].includes(status)) {
|
|
1545
|
+
for (const { sourceField, targetField } of pairs) {
|
|
1546
|
+
if (blank(row[sourceField]) && !blank(row[targetField])) next[sourceField] = row[targetField];
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
for (const key of hidden) delete next[key];
|
|
1550
|
+
return next;
|
|
1551
|
+
});
|
|
1552
|
+
return {
|
|
1553
|
+
rows: output,
|
|
1554
|
+
headers: [...new Set([...sourceHeaders, ...selected, ...output.flatMap((row) => Object.keys(row))])].filter((key) => !hidden.has(key)),
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1455
1558
|
function qualitySummaryFor(rows, mappings) {
|
|
1456
1559
|
const list = Array.isArray(rows) ? rows : [];
|
|
1457
1560
|
const anchors = (Array.isArray(mappings) ? mappings : []).filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
|
|
@@ -1663,7 +1766,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1663
1766
|
setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
|
|
1664
1767
|
setMappings: (draft, value) => { draft.mappings = Array.isArray(value) ? value : []; },
|
|
1665
1768
|
setMapping: (draft, sourceField, targetField) => {
|
|
1666
|
-
|
|
1769
|
+
if (targetField && draft.mappings.some((item) => item.sourceField !== sourceField && item.targetField === targetField)) return;
|
|
1770
|
+
const mappings = draft.mappings.filter((item) => item.sourceField !== sourceField);
|
|
1667
1771
|
if (targetField) mappings.push({ sourceField, targetField });
|
|
1668
1772
|
draft.mappings = mappings;
|
|
1669
1773
|
},
|
|
@@ -2950,6 +3054,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2950
3054
|
}
|
|
2951
3055
|
|
|
2952
3056
|
/** 文本/JSON 直通 parse(与 web.js 内联页同构,含 JSON 数组直通)。 */
|
|
3057
|
+
function intakeState(dataset, sourceRows, input, source) {
|
|
3058
|
+
const count = Number(dataset?.rowCount || 0);
|
|
3059
|
+
const available = count > 0 && Array.isArray(sourceRows) && sourceRows.length === count;
|
|
3060
|
+
return {
|
|
3061
|
+
available,
|
|
3062
|
+
pendingText: Boolean(String(input ?? '').trim()),
|
|
3063
|
+
fileName: source?.fileName || (source?.type === 'image' ? '图片识别名单' : '粘贴 / 向导导入名单'),
|
|
3064
|
+
count,
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
|
|
2953
3068
|
async function parseText(text, actions) {
|
|
2954
3069
|
const trimmed = (text ?? '').trim();
|
|
2955
3070
|
if (!trimmed) {
|
|
@@ -3257,6 +3372,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3257
3372
|
const runtime = runtimeFor(runtimeKey);
|
|
3258
3373
|
const stagedSource = runtimeFor(`session:${activeSessionId || 'unassigned'}`, false);
|
|
3259
3374
|
const sourceRows = stagedSource?.sourceRows ?? runtime.sourceRows ?? runtime.rows;
|
|
3375
|
+
const intake = intakeState(dataset, sourceRows, input, runtime.source || stagedSource?.source || cachedTask?.source);
|
|
3260
3376
|
const lastCsv = runtime.lastCsv;
|
|
3261
3377
|
const fieldByPattern = (pattern) => runtime.headers.find((field) => pattern.test(field)) ?? null;
|
|
3262
3378
|
const phoneField = fieldByPattern(/^(phone|mobile|tel|telephone|联系电话|手机号码|手机号)$/i);
|
|
@@ -3276,13 +3392,20 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3276
3392
|
|
|
3277
3393
|
const handleParse = async () => {
|
|
3278
3394
|
if (busy) return;
|
|
3395
|
+
if (!intake.pendingText) {
|
|
3396
|
+
if (intake.available) actions.setError(null);
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3279
3399
|
actions.setBusy(true);
|
|
3280
3400
|
actions.setError(null);
|
|
3281
3401
|
try {
|
|
3282
3402
|
const result = await parseText(input, actions);
|
|
3283
|
-
if (result) {
|
|
3403
|
+
if (result && result.ok !== false) {
|
|
3284
3404
|
applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv' });
|
|
3285
3405
|
await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv' }, cachedTask);
|
|
3406
|
+
actions.setInput('');
|
|
3407
|
+
} else if (result) {
|
|
3408
|
+
actions.setError(result.message || result.error || '解析失败,已导入的数据保持不变。');
|
|
3286
3409
|
}
|
|
3287
3410
|
} catch (err) {
|
|
3288
3411
|
actions.setError(err instanceof Error ? err.message : String(err));
|
|
@@ -3300,6 +3423,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3300
3423
|
return true;
|
|
3301
3424
|
};
|
|
3302
3425
|
const handleFile = async (event) => {
|
|
3426
|
+
if (busy) return;
|
|
3303
3427
|
const file = event.target && event.target.files && event.target.files[0];
|
|
3304
3428
|
if (!file) return;
|
|
3305
3429
|
if (String(file.type ?? '').startsWith('image/')) {
|
|
@@ -3314,11 +3438,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3314
3438
|
if (result && result.ok !== false) {
|
|
3315
3439
|
applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 });
|
|
3316
3440
|
await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 }, cachedTask);
|
|
3441
|
+
actions.setInput('');
|
|
3317
3442
|
}
|
|
3318
3443
|
else actions.setError((result && (result.message || result.error)) || '解析失败');
|
|
3319
3444
|
} catch (err) {
|
|
3320
3445
|
actions.setError(err instanceof Error ? err.message : String(err));
|
|
3321
3446
|
} finally {
|
|
3447
|
+
event.target.value = '';
|
|
3322
3448
|
actions.setBusy(false);
|
|
3323
3449
|
}
|
|
3324
3450
|
};
|
|
@@ -3792,6 +3918,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3792
3918
|
? workflowContract.inputOnlyMappingFields.map((field) => [field.id, field.label])
|
|
3793
3919
|
: INPUT_ONLY_MAPPING_OPTIONS;
|
|
3794
3920
|
const targetFields = [...uiFieldGroups.flatMap(([, , fields]) => fields), ...inputOnlyMappingFields];
|
|
3921
|
+
const mappingHints = mappingRecommendations(dataset?.headers || [], targetFields);
|
|
3795
3922
|
const setWorkbenchFieldGroup = (fields, enabledGroup) => {
|
|
3796
3923
|
const selected = new Set(fieldSelection);
|
|
3797
3924
|
for (const [id] of fields) {
|
|
@@ -3842,19 +3969,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3842
3969
|
h('section', { className: 'dcAgentSection' },
|
|
3843
3970
|
h('h3', null, '字段映射'),
|
|
3844
3971
|
h('p', { className: 'dcAgentHint' }, '至少映射企业名称、统一社会信用代码或注册号其中一项。未映射列会原样保留,但不参与匹配。'),
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
onChange: (event) => actions.setMapping(sourceField, event.target.value),
|
|
3853
|
-
},
|
|
3854
|
-
h('option', { value: '' }, '不参与匹配 / 补全'),
|
|
3855
|
-
targetFields.map(([id, label]) => h('option', { key: id, value: id }, label)),
|
|
3856
|
-
),
|
|
3857
|
-
)),
|
|
3972
|
+
h('p', { className: 'dcAgentHint' }, `已映射 ${mappings.length} / ${dataset?.headers?.length || 0} 列。标准字段名和明确别名自动匹配;多义表头、重复列请确认推荐。仅在本机匹配表头,不调用模型或企查查,不改变下方输出字段。`),
|
|
3973
|
+
h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setMappings(mergeRecommendedMappings(dataset?.headers || [], mappings, targetFields)) }, '补充自动映射(保留已有选择)'),
|
|
3974
|
+
mappingHints.map((recommendation, index) => h(MappingPicker, {
|
|
3975
|
+
key: `${index}:${recommendation.sourceField}`, sourceField: recommendation.sourceField,
|
|
3976
|
+
mappings, groups: [...uiFieldGroups, ['input-only', '输入校验字段', inputOnlyMappingFields]],
|
|
3977
|
+
recommendation, onChange: actions.setMapping,
|
|
3978
|
+
})),
|
|
3858
3979
|
),
|
|
3859
3980
|
h('section', { className: 'dcAgentSection' },
|
|
3860
3981
|
h('h3', null, '任务目标'),
|
|
@@ -3872,6 +3993,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3872
3993
|
),
|
|
3873
3994
|
h('section', { className: 'dcAgentSection' },
|
|
3874
3995
|
h('h3', null, `输出字段 · 已选 ${fieldSelection.length}`),
|
|
3996
|
+
h('p', { className: 'dcAgentHint' }, '补全结果优先填入已映射原列的空白单元格,保留非空原值;没有对应映射列的已选字段新增列。重复或多义列请先确认映射。'),
|
|
3875
3997
|
h('div', { className: 'dcAgentRow', 'aria-label': '补全字段批次概览' },
|
|
3876
3998
|
h('span', { className: 'dcAgentChip' }, '基础字段 30'),
|
|
3877
3999
|
h('span', { className: 'dcAgentChip' }, '第一批 40'),
|
|
@@ -3983,7 +4105,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3983
4105
|
stat('失败', qccRun.summary?.failed ?? 0, (qccRun.summary?.failed ?? 0) > 0 ? 'bad' : null),
|
|
3984
4106
|
),
|
|
3985
4107
|
Array.isArray(qccRun.rows) && qccRun.rows.length ? h(DatasetReview, {
|
|
3986
|
-
key: qccRun.runId, rows: qccRun.rows, headers: runtime.headers,
|
|
4108
|
+
key: qccRun.runId, ...projectCompletionResult({ rows: qccRun.rows, headers: runtime.headers, mappings: cachedTask?.mappings || mappings, fieldSelection: cachedTask?.fieldSelection || fieldSelection }),
|
|
3987
4109
|
title: '企查查匹配补全结果', expectedCount: qccRun.summary?.totalRows,
|
|
3988
4110
|
onError: actions.setError,
|
|
3989
4111
|
}) : null,
|
|
@@ -4059,13 +4181,26 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4059
4181
|
} else {
|
|
4060
4182
|
pane = h('div', { className: 'dcAgentPane' },
|
|
4061
4183
|
h('p', { className: 'dcAgentHint' }, '上传 CSV / XLSX / XLS / JSON,或粘贴数据。也可选择图片,通过企查查智能文档解析提取名单后继续清洗补全。'),
|
|
4062
|
-
h('
|
|
4184
|
+
dataset ? h('section', { className: 'dcAgentSection', 'aria-label': '当前导入数据', role: 'status' },
|
|
4185
|
+
h('h3', null, intake.available ? `已解析 ${intake.count} 条数据` : '已保存导入摘要'),
|
|
4186
|
+
h('p', { className: 'dcAgentHint', style: { overflowWrap: 'anywhere' } }, `当前来源:${intake.fileName}`),
|
|
4187
|
+
h('p', { className: 'dcAgentHint' }, intake.available
|
|
4188
|
+
? '文件选择后已自动解析,无需再次点击解析。核对下方名单后可直接进入规则与体检;重新选择文件会替换当前导入数据。'
|
|
4189
|
+
: '当前页面没有完整原始数据。请重新导入原文件;历史下载制品仍可从任务历史获取。'),
|
|
4190
|
+
) : null,
|
|
4191
|
+
h('div', { className: 'dcAgentRow' },
|
|
4192
|
+
h('button', { type: 'button', className: 'dcAgentButton', disabled: busy,
|
|
4193
|
+
onClick: (event) => event.currentTarget.nextElementSibling?.click() },
|
|
4194
|
+
dataset ? '重新选择文件(自动解析)' : '选择文件(自动解析)'),
|
|
4195
|
+
h('input', {
|
|
4063
4196
|
type: 'file',
|
|
4064
4197
|
accept: '.csv,.json,.xlsx,.xls,image/png,image/jpeg,image/webp',
|
|
4065
4198
|
className: 'dcAgentField',
|
|
4199
|
+
style: { display: 'none' },
|
|
4200
|
+
disabled: busy,
|
|
4066
4201
|
'aria-label': '选择数据文件',
|
|
4067
4202
|
onChange: handleFile,
|
|
4068
|
-
}),
|
|
4203
|
+
})),
|
|
4069
4204
|
h('button', { type: 'button', className: 'dcAgentButton', disabled: busy,
|
|
4070
4205
|
onClick: () => openImageIntake() }, '图片识别(粘贴 / 选择)'),
|
|
4071
4206
|
h('textarea', {
|
|
@@ -4073,6 +4208,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4073
4208
|
placeholder: '粘贴 CSV 文本,或 JSON 数组(例如 [{"name":"某公司","amount":"100"}])…',
|
|
4074
4209
|
'aria-label': '粘贴数据',
|
|
4075
4210
|
value: input,
|
|
4211
|
+
disabled: busy,
|
|
4076
4212
|
onPaste: (event) => {
|
|
4077
4213
|
const files = imageFilesFromTransfer(event.clipboardData);
|
|
4078
4214
|
if (files.length && openImageIntake(files[0])) {
|
|
@@ -4080,7 +4216,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4080
4216
|
event.stopPropagation?.();
|
|
4081
4217
|
}
|
|
4082
4218
|
},
|
|
4083
|
-
onInput: (event) => actions.setInput(event.target.value),
|
|
4219
|
+
onInput: (event) => { actions.setInput(event.target.value); actions.setError(null); },
|
|
4084
4220
|
}),
|
|
4085
4221
|
dataset ? h('div', { className: 'dcAgentChips' },
|
|
4086
4222
|
h('span', { className: 'dcAgentChip' }, `格式 ${dataset.fmt}`),
|
|
@@ -4096,8 +4232,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4096
4232
|
}) : null,
|
|
4097
4233
|
hasData ? h('p', { className: 'dcAgentHint' }, '请核对完整名单。识别清单不代表已完成企查查匹配。核对后进入「规则与体检」,确认规则生成质量体检;再进入「主体匹配」估算调用量并确认账号额度,点击「生成可编辑任务说明」自动回填中央对话框,无需手工复制任务凭证。') : null,
|
|
4098
4234
|
h('div', { className: 'dcAgentRow' },
|
|
4099
|
-
h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, 'aria-label': '解析数据', onClick: handleParse }, busy ? '解析中…' : '
|
|
4100
|
-
hasData ? h('
|
|
4235
|
+
h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !intake.pendingText, 'aria-label': '解析数据', onClick: handleParse }, busy ? '解析中…' : '解析粘贴内容'),
|
|
4236
|
+
intake.pendingText && hasData ? h('p', { className: 'dcAgentHint', role: 'status' }, '有尚未解析的新粘贴内容,请先解析或清空后再继续。') : null,
|
|
4237
|
+
hasData ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !intake.available || intake.pendingText, onClick: () => { actions.setError(null); actions.setStep('rules'); } }, '已核对清单,下一步:字段映射与规则') : null,
|
|
4101
4238
|
),
|
|
4102
4239
|
);
|
|
4103
4240
|
}
|
|
@@ -4314,6 +4451,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4314
4451
|
isImagePathEntry,
|
|
4315
4452
|
requestPromptImage,
|
|
4316
4453
|
guessMappings,
|
|
4454
|
+
intakeState,
|
|
4455
|
+
projectCompletionResult,
|
|
4456
|
+
mappingRecommendations,
|
|
4457
|
+
mergeRecommendedMappings,
|
|
4458
|
+
MappingPicker,
|
|
4317
4459
|
deactivateCleaningSession,
|
|
4318
4460
|
installSessionOwnershipBridge,
|
|
4319
4461
|
installNewSessionBridge,
|
package/lib/engine.js
CHANGED
|
@@ -9,6 +9,47 @@
|
|
|
9
9
|
* - 引擎层返回完整明细(供下载/入库),模型工具层只回摘要(安全边界在工具层)。
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
// 重复表头必须获得稳定、无碰撞的键,否则行对象会覆盖前一列原值。
|
|
13
|
+
function uniqueHeaders(values) {
|
|
14
|
+
const bases = Array.from(values, (value, i) => String(value ?? '').trim() || `col_${i + 1}`);
|
|
15
|
+
const reserved = new Set(bases);
|
|
16
|
+
const used = new Set();
|
|
17
|
+
return bases.map((base) => {
|
|
18
|
+
let key = base;
|
|
19
|
+
let ordinal = 2;
|
|
20
|
+
// 原表也可能包含同名的重复列标记,不能覆盖它。
|
|
21
|
+
while (used.has(key) || (key !== base && reserved.has(key))) key = `${base}(重复列 ${ordinal++})`;
|
|
22
|
+
used.add(key);
|
|
23
|
+
return key;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 原列补空投影:由已确认映射决定目的列,非空原值(包括 0/false)不覆盖。 */
|
|
28
|
+
export function projectCompletionResult({ rows = [], headers = [], mappings = [], fieldSelection = [] } = {}) {
|
|
29
|
+
const sourceHeaders = [...new Set(headers)];
|
|
30
|
+
const selected = [...new Set(fieldSelection)];
|
|
31
|
+
const pairs = mappings.filter((item) => sourceHeaders.includes(item.sourceField) && selected.includes(item.targetField)
|
|
32
|
+
&& mappings.filter((other) => other.sourceField === item.sourceField).length === 1
|
|
33
|
+
&& mappings.filter((other) => other.targetField === item.targetField).length === 1);
|
|
34
|
+
const hidden = new Set(pairs.filter((item) => item.sourceField !== item.targetField && !sourceHeaders.includes(item.targetField)).map((item) => item.targetField));
|
|
35
|
+
const blank = (value) => value == null || (typeof value === 'string' && value.trim() === '');
|
|
36
|
+
const output = rows.map((row) => {
|
|
37
|
+
const next = { ...row };
|
|
38
|
+
const status = row.qcc_match_status;
|
|
39
|
+
if (!status || ['enriched', 'exact'].includes(status)) {
|
|
40
|
+
for (const { sourceField, targetField } of pairs) {
|
|
41
|
+
if (blank(row[sourceField]) && !blank(row[targetField])) next[sourceField] = row[targetField];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const key of hidden) delete next[key];
|
|
45
|
+
return next;
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
rows: output,
|
|
49
|
+
headers: [...new Set([...sourceHeaders, ...selected, ...output.flatMap((row) => Object.keys(row))])].filter((key) => !hidden.has(key)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
12
53
|
/** 去掉 BOM、按 CSV/RFC4180 子集解析为 { headers, rows }。 */
|
|
13
54
|
export function parseCsv(text) {
|
|
14
55
|
const src = String(text ?? '').replace(/^\uFEFF/, '');
|
|
@@ -48,7 +89,7 @@ export function parseCsv(text) {
|
|
|
48
89
|
const nonEmpty = records.filter((r) => r.some((c) => c.trim() !== ''));
|
|
49
90
|
if (nonEmpty.length === 0) return { headers: [], rows: [] };
|
|
50
91
|
|
|
51
|
-
const headers = nonEmpty[0]
|
|
92
|
+
const headers = uniqueHeaders(nonEmpty[0]);
|
|
52
93
|
const rows = nonEmpty.slice(1).map((r) => {
|
|
53
94
|
const o = {};
|
|
54
95
|
headers.forEach((h, i) => { o[h] = r[i] === undefined ? '' : String(r[i]); });
|
|
@@ -72,7 +113,7 @@ export async function parseXlsx(buffer) {
|
|
|
72
113
|
if (!sheetName) return { headers: [], rows: [] };
|
|
73
114
|
const aoa = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { header: 1, defval: '', raw: false });
|
|
74
115
|
if (!aoa.length) return { headers: [], rows: [] };
|
|
75
|
-
const headers = aoa[0]
|
|
116
|
+
const headers = uniqueHeaders(aoa[0]);
|
|
76
117
|
const rows = aoa.slice(1).map((r) => {
|
|
77
118
|
const o = {};
|
|
78
119
|
headers.forEach((h, i) => { o[h] = r[i] === undefined || r[i] === null ? '' : String(r[i]); });
|
package/lib/qcc-runs.js
CHANGED
|
@@ -229,7 +229,7 @@ export class G5RunStore {
|
|
|
229
229
|
if (result.status === 'enriched') {
|
|
230
230
|
record.rows[index] = {
|
|
231
231
|
...row,
|
|
232
|
-
...result.fields,
|
|
232
|
+
...Object.fromEntries(Object.entries(result.fields || {}).filter(([key]) => row[key] == null || (typeof row[key] === 'string' && row[key].trim() === ''))),
|
|
233
233
|
qcc_match_status: 'enriched',
|
|
234
234
|
qcc_source: 'qcc-mcp',
|
|
235
235
|
};
|
package/lib/qcc.js
CHANGED
|
@@ -1027,7 +1027,7 @@ export class QccHostBridge {
|
|
|
1027
1027
|
if (result?.status === 'enriched') {
|
|
1028
1028
|
return {
|
|
1029
1029
|
...row,
|
|
1030
|
-
...result.fields,
|
|
1030
|
+
...Object.fromEntries(Object.entries(result.fields || {}).filter(([key]) => row[key] == null || (typeof row[key] === 'string' && row[key].trim() === ''))),
|
|
1031
1031
|
qcc_match_status: 'enriched',
|
|
1032
1032
|
qcc_source: 'qcc-mcp',
|
|
1033
1033
|
};
|
package/lib/web.js
CHANGED
|
@@ -537,6 +537,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
537
537
|
rows: payload.rows,
|
|
538
538
|
headers: payload.headers,
|
|
539
539
|
fieldSelection: readyTask.fieldSelection,
|
|
540
|
+
mappings: readyTask.mappings,
|
|
540
541
|
exceptionRows: payload.exceptionRows,
|
|
541
542
|
baseName: payload.baseName || readyTask.title,
|
|
542
543
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-data-cleaning-agent",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.12",
|
|
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",
|