dsh-data-cleaning-agent 0.8.16 → 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 +16 -0
- package/README.en.md +1 -1
- package/README.md +3 -3
- package/docs/RELEASE-0.8.17.md +26 -0
- package/docs/RELEASE-0.8.18.md +15 -0
- package/lib/artifacts.js +1 -0
- package/lib/client.js +212 -53
- package/lib/qcc-command.js +31 -6
- package/lib/qcc-field-catalog.js +3 -2
- package/lib/qcc-runs.js +1 -0
- package/lib/qcc.js +10 -1
- package/lib/skill-enrich.js +3 -1
- package/lib/web.js +16 -2
- package/lib/workflow-contract.js +1 -1
- package/lib/workflow-execution.js +74 -0
- package/lib/workflow.js +1 -1
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
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
|
+
|
|
14
|
+
## [0.8.17] - 2026-09-08
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- 提示词向导和工作台统一生成 Host 执行凭证;发送已确认范围后不再重复询问额度、源文件路径或写回确认。
|
|
18
|
+
- Host 在高层工具内完成工作流与新 XLSX/异常清单交付,不依赖对话模型编写脚本或页面持续轮询;原始文件不覆盖。
|
|
19
|
+
- 独立清洗会话切换、刷新可恢复业务归属;侧栏入口重开当前工作台,保存任务绑定以访问下载。持久化绑定仅包含会话 ID 与任务 ID。
|
|
20
|
+
- 版本冲突阻止旧说明再次查询,命令重放幂等;候选歧义仍需人工选择,部分失败提供下载并允许显式重试。
|
|
21
|
+
- 修复后台刷新覆盖任务历史导航,并覆盖 DSH root/session 分离实例的事件交接。
|
|
22
|
+
|
|
7
23
|
## [0.8.16] - 2026-09-08
|
|
8
24
|
|
|
9
25
|
### Changed
|
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.
|
|
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
|
-
|
|
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.
|
|
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.16
|
|
|
25
25
|
|
|
26
26
|
> 在 DeepSeek Harness 中清洗、补全、画像企业名单数据的智能体插件:本地 CSV/XLSX/JSON 引擎 + 可选企查查(Qichacha/QCC)MCP 企业数据补全,由企查查(Qichacha/QCC)团队发起并维护。
|
|
27
27
|
>
|
|
28
|
-
> 当前源码版本 / Current source version: **0.8.
|
|
28
|
+
> 当前源码版本 / Current source version: **0.8.18**(正式版本)
|
|
29
29
|
|
|
30
30
|
本版上传表格后展示完整清单及空白数量,用绿色、琥珀色、红色及文字区分已通过、待确认和未匹配映射。已映射字段自动纳入补全范围,额外字段默认折叠;多个原列可经确认使用同一非主体字段,分别补空并保留非空原值。修复 Host 草稿重置为默认五字段的问题,增加前后端版本错配提示。生成说明不查询,发送才执行;升级后需完整重启 DSH,历史下载文件不自动改写。
|
|
31
31
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dsh-data-cleaning-agent v0.8.17
|
|
2
|
+
|
|
3
|
+
## 本版改进
|
|
4
|
+
|
|
5
|
+
发送在向导或右侧工作台确认的任务说明后,由同一个 Host 高层工具负责匹配、补全、生成新的 XLSX 与异常清单,并将下载绑定到任务。无需再次确认额度或提供源文件路径;不覆盖上传的原始文件,不由模型运行 Bash 拼装替代报表。
|
|
6
|
+
|
|
7
|
+
- 保留已确认原列、多列共用补全字段、仅补空白及非空 0/false 保护。
|
|
8
|
+
- 真正的多候选在工作台确认;授权、限流等异常不自动重试或扩大范围。
|
|
9
|
+
- 过期任务 revision 在查询前阻断;同一命令重放不重复扣量。
|
|
10
|
+
- 刷新与切换后恢复独立清洗会话;侧栏重新打开当前工作台。Host 生成文件不依赖页面存在,已保存结果可跨 Host 重启下载。
|
|
11
|
+
- 只在浏览器保存会话 ID → 任务 ID 绑定,不持久化原始名单。执行中的 Host 进程不可强制重启;尚未生成的命令、候选与运行明细仍可能过期,不能自动重新查询。
|
|
12
|
+
- 未选外部字段时不发起 QCC 调用,可使用工作台本地清洗与导出。
|
|
13
|
+
|
|
14
|
+
## 验证
|
|
15
|
+
|
|
16
|
+
241 项自动化测试、10 组浏览器回归通过。使用合成数据和隔离的 ToolRuntime/Chromium,不调用真实企查查账号、不修改客户测试文件。覆盖真实 XLSX 内容回读、双列补空、原值保护、重放幂等、候选续跑、部分失败重试、磁盘写入失败及恢复下载。浏览器覆盖浅色/深色与五种视口,分离 root/session 实例验证向导凭证交接。
|
|
17
|
+
|
|
18
|
+
## 升级
|
|
19
|
+
|
|
20
|
+
保存未完成任务后安装:
|
|
21
|
+
|
|
22
|
+
`dsh plugin --profile web add dsh-data-cleaning-agent@0.8.17`
|
|
23
|
+
|
|
24
|
+
完整重启对应 DSH Profile 并刷新页面;仅刷新浏览器不会更新 Host。旧对话中的普通提示词不会自动改成新凭证,请在升级后的工作台重新生成。历史导出不会被改写。本次发布不自动重启或安装到本机正在运行的 DSH。
|
|
25
|
+
|
|
26
|
+
回滚可安装 0.8.16,但会失去本版 Host 自动交付与会话恢复逻辑。共享字段内核、AI 填表版本均不变。
|
|
@@ -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
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([
|
|
@@ -1231,10 +1235,27 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1231
1235
|
];
|
|
1232
1236
|
const CLEANING_SESSION_EVENT = 'dsh:data-cleaning-session-ownership';
|
|
1233
1237
|
let activeCleaningSessionId = null;
|
|
1238
|
+
const TASK_BINDINGS_KEY = 'dsh.data-cleaning-agent.task-bindings.v1';
|
|
1239
|
+
const ownedSessionId = (id) => /^session-dsh-data-cleaning-agent-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(id || ''));
|
|
1240
|
+
function readTaskBindings() {
|
|
1241
|
+
try {
|
|
1242
|
+
const value = JSON.parse(window.localStorage?.getItem(TASK_BINDINGS_KEY) || '{}');
|
|
1243
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
1244
|
+
} catch { return {}; }
|
|
1245
|
+
}
|
|
1246
|
+
function bindTask(sessionId, taskId) {
|
|
1247
|
+
if (!ownedSessionId(sessionId)) return;
|
|
1248
|
+
try {
|
|
1249
|
+
const bindings = readTaskBindings();
|
|
1250
|
+
delete bindings[sessionId];
|
|
1251
|
+
bindings[sessionId] = taskId;
|
|
1252
|
+
window.localStorage?.setItem(TASK_BINDINGS_KEY, JSON.stringify(Object.fromEntries(Object.entries(bindings).slice(-200))));
|
|
1253
|
+
} catch {}
|
|
1254
|
+
}
|
|
1234
1255
|
|
|
1235
1256
|
try {
|
|
1236
1257
|
// 旧版本持久化过 sessionId;但 DSH 会复用空白新会话,无法仅凭 ID 判断入口来源。
|
|
1237
|
-
//
|
|
1258
|
+
// 清除旧的无前缀归属标记;新的独立会话前缀可安全恢复,不认领其它智能体会话。
|
|
1238
1259
|
for (const key of CLEANING_SESSION_STORAGE_KEYS) window.sessionStorage?.removeItem(key);
|
|
1239
1260
|
} catch (_error) {
|
|
1240
1261
|
// sessionStorage 可能被禁用;内存态不受影响。
|
|
@@ -1331,10 +1352,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1331
1352
|
};
|
|
1332
1353
|
const leaveSubsystem = () => {
|
|
1333
1354
|
const sessionId = activeCleaningSessionId;
|
|
1334
|
-
if (sessionId) clearCleaningDraft(ctx, sessionId, true);
|
|
1355
|
+
if (sessionId && !ownedSessionId(sessionId)) clearCleaningDraft(ctx, sessionId, true);
|
|
1335
1356
|
deactivateCleaningSession(sessionId);
|
|
1336
1357
|
};
|
|
1337
|
-
//
|
|
1358
|
+
// Only our independently-created native session namespace may regain ownership.
|
|
1359
|
+
const initialSession = ctx.sessions?.list?.getSnapshot?.()?.current;
|
|
1360
|
+
if (ownedSessionId(initialSession)) markCleaningSession(initialSession);
|
|
1338
1361
|
clearStaleDefaultDraft();
|
|
1339
1362
|
// DSH 会在插件 apply() 之后异步恢复 composer draft。在首屏稳定期内
|
|
1340
1363
|
// 监听 DOM 变化并仅清理完全匹配本插件默认文案,或同时命中
|
|
@@ -1359,6 +1382,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1359
1382
|
const nextSessionId = selected.current;
|
|
1360
1383
|
if (activeCleaningSessionId && nextSessionId !== activeCleaningSessionId) {
|
|
1361
1384
|
leaveSubsystem();
|
|
1385
|
+
}
|
|
1386
|
+
if (!activeCleaningSessionId && ownedSessionId(nextSessionId)) {
|
|
1387
|
+
markCleaningSession(nextSessionId);
|
|
1362
1388
|
} else if (!activeCleaningSessionId) {
|
|
1363
1389
|
clearStaleDefaultDraft();
|
|
1364
1390
|
}
|
|
@@ -1408,14 +1434,15 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1408
1434
|
lines.push(`需要补全的字段 / 维度:${enrichLabels.length ? enrichLabels.join('、') : '不调用外部补全,仅输出本地清洗结果'}。`);
|
|
1409
1435
|
if (mode === 'excel' && config.headers?.length) {
|
|
1410
1436
|
const pairs = config.mappings || [];
|
|
1411
|
-
lines.push(`原表共 ${config.headers.length}
|
|
1437
|
+
lines.push(`原表共 ${config.headers.length} 列。新文件原列补空映射:${pairs.map((item) => `「${item.sourceField}」←${resultFieldLabel(item.targetField)}`).join(';') || '暂无'}。`);
|
|
1412
1438
|
const skipped = config.headers.filter((header) => !pairs.some((item) => item.sourceField === header));
|
|
1413
1439
|
if (skipped.length) lines.push(`暂不补全的原列:${skipped.join('、')};保留原值,不猜测或用相近指标替代。`);
|
|
1414
1440
|
const added = (config.enrichmentKeys || []).filter((id) => !pairs.some((item) => item.targetField === id));
|
|
1415
1441
|
lines.push(`仅补已确认原列的空白单元格,保留非空值(含 0)。多个原列可共用一个补全字段,查询按主体与工具去重。额外新增字段:${added.map(resultFieldLabel).join('、') || '无'}。`);
|
|
1416
1442
|
}
|
|
1417
1443
|
lines.push(`处理规则:优先使用${anchorLabels.length ? anchorLabels.join('、') : '企业名称、统一社会信用代码或注册号'}精确匹配;${config.matchRules?.manualReviewAmbiguous === false ? '精确匹配失败时直接输出未匹配记录' : '精确匹配失败时再进入模糊候选,存在多个候选必须暂停并让我确认,不得默认选择第一项'}。`);
|
|
1418
|
-
lines.push('
|
|
1444
|
+
lines.push('发送即确认执行工作台已配置的范围,不再重复确认额度或写回。企查查连接、套餐额度和费用均由当前用户自己的账号承担。缺失或无权限字段请留空并标记原因,不得编造。');
|
|
1445
|
+
lines.push('交付方式:Host 自动生成新的清洗补全结果 XLSX,在工作台下载。不得修改上传源文件,不索要本地路径,不使用 Bash 或另行编写导出脚本。');
|
|
1419
1446
|
lines.push('完成后保留来源原值、标准主体、匹配状态与字段来源,并提供结果和待复核清单的导出。');
|
|
1420
1447
|
return lines.join('\n\n');
|
|
1421
1448
|
}
|
|
@@ -1453,6 +1480,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1453
1480
|
}
|
|
1454
1481
|
|
|
1455
1482
|
const MAPPING_ALIASES = {
|
|
1483
|
+
beneficial_owner_first_name: ['首条受益所有人名称'],
|
|
1456
1484
|
actual_controller_name: ['实控人', '实际控制人', '企业实控人名称', '企业实际控制人', '企业实控人名称(自然人请填写姓名)'],
|
|
1457
1485
|
actual_controller_direct_ratio: ['实际控制人直接持股比例'],
|
|
1458
1486
|
actual_controller_total_ratio: ['实际控制人总持股比例'],
|
|
@@ -1469,6 +1497,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1469
1497
|
};
|
|
1470
1498
|
// 语义不完全等同:只推荐,绝不冒充精确匹配或发起调用。
|
|
1471
1499
|
const MAPPING_HINTS = {
|
|
1500
|
+
受益所有人: ['beneficial_owner_first_name'], 受益所有人名称: ['beneficial_owner_first_name'], UBO: ['beneficial_owner_first_name'],
|
|
1472
1501
|
地址: ['registered_address', 'mailing_address', 'invoice_address'],
|
|
1473
1502
|
address: ['registered_address', 'mailing_address'],
|
|
1474
1503
|
企业地址: ['registered_address', 'mailing_address'], 公司地址: ['registered_address', 'mailing_address'],
|
|
@@ -1533,6 +1562,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1533
1562
|
|
|
1534
1563
|
function MappingPicker({ sourceField, mappings, groups, recommendation, onChange }) {
|
|
1535
1564
|
const [query, setQuery] = react.useState('');
|
|
1565
|
+
const [category, setCategory] = react.useState('');
|
|
1536
1566
|
const value = mappings.find((item) => item.sourceField === sourceField)?.targetField || '';
|
|
1537
1567
|
const occupied = (id) => isAnchorField(id) && mappings.some((item) => item.sourceField !== sourceField && item.targetField === id);
|
|
1538
1568
|
const fields = groups.flatMap(([, , items]) => items);
|
|
@@ -1548,6 +1578,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1548
1578
|
}
|
|
1549
1579
|
} },
|
|
1550
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))),
|
|
1551
1584
|
h('input', { className: 'dcAgentField', value: query, placeholder: '搜索字段名称 / 英文键 / 别名', 'aria-label': `${sourceField} 搜索映射字段`, onChange: (event) => setQuery(event.target.value) }),
|
|
1552
1585
|
h('select', { className: 'dcAgentField', value, 'aria-label': `${sourceField} 字段映射`, onChange: (event) => {
|
|
1553
1586
|
onChange(sourceField, event.target.value);
|
|
@@ -1555,11 +1588,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1555
1588
|
if (details) { details.open = false; details.querySelector('summary')?.focus(); }
|
|
1556
1589
|
} },
|
|
1557
1590
|
h('option', { value: '' }, '不参与匹配 / 补全'),
|
|
1558
|
-
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]) =>
|
|
1559
1592
|
h('option', { key, value: key, disabled: occupied(key) }, `${name}${occupied(key) ? '(已被其他列使用)' : ''}`)))),
|
|
1560
1593
|
),
|
|
1561
|
-
query && !
|
|
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,
|
|
1562
1595
|
),
|
|
1596
|
+
value === 'beneficial_owner_first_name' || value.startsWith('financial_') ? h('p', { className: 'dcAgentHint' }, value === 'beneficial_owner_first_name' ? '仅取返回首条受益所有人,不代表唯一或最大受益人。' : '取返回首个报告期,不跨期补值;报告期保留在结果取值说明中。') : null,
|
|
1563
1597
|
h('small', { className: 'dcAgentMappingStatus', 'data-status': value ? 'confirmed' : recommendation.candidates.length ? 'review' : 'unmatched',
|
|
1564
1598
|
style: { display: 'block', padding: '6px 0' } },
|
|
1565
1599
|
value ? `${recommendation.automatic && recommendation.candidates[0]?.[0] === value ? '自动通过' : '已确认'}:${label};可手动调整` : recommendation.candidates.length ? `待人工确认:${recommendation.conflict ? '重复列可确认使用同一补全字段;主体标识只选一列' : recommendation.reason}` : '未匹配:保留原列,可手动选择字段'),
|
|
@@ -1647,10 +1681,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1647
1681
|
return requestJson(path, body === undefined ? 'GET' : 'POST', body);
|
|
1648
1682
|
}
|
|
1649
1683
|
|
|
1650
|
-
async function waitForQccCommand(commandId, attempts = 180) {
|
|
1684
|
+
async function waitForQccCommand(commandId, attempts = 180, onStatus) {
|
|
1651
1685
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1652
1686
|
const response = await api(`/data-cleaning/api/g5/commands/${encodeURIComponent(commandId)}`);
|
|
1653
1687
|
const command = response?.command;
|
|
1688
|
+
await onStatus?.(command);
|
|
1654
1689
|
if (command?.state === 'completed' || command?.state === 'failed') return command;
|
|
1655
1690
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1656
1691
|
}
|
|
@@ -1662,6 +1697,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1662
1697
|
function cacheWorkflowTask(actions, sessionId, task) {
|
|
1663
1698
|
if (!task) return null;
|
|
1664
1699
|
if (sessionId) workflowTaskBySession.set(String(sessionId), task);
|
|
1700
|
+
bindTask(sessionId, task.id);
|
|
1665
1701
|
actions.setWorkflowTask(task);
|
|
1666
1702
|
if (task.title) actions.setTaskTitle(task.title);
|
|
1667
1703
|
if (Array.isArray(task.objectives)) actions.setObjectives(task.objectives);
|
|
@@ -1692,10 +1728,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1692
1728
|
return cacheWorkflowTask(actions, sessionId, response.task);
|
|
1693
1729
|
}
|
|
1694
1730
|
|
|
1695
|
-
async function ensureWorkflowTask(actions, sessionId, draft = {}) {
|
|
1731
|
+
async function ensureWorkflowTask(actions, sessionId, draft = {}, restoreStage = true) {
|
|
1696
1732
|
const key = String(sessionId || 'unassigned');
|
|
1697
1733
|
const cached = workflowTaskBySession.get(key);
|
|
1698
1734
|
if (cached) return cacheWorkflowTask(actions, sessionId, cached);
|
|
1735
|
+
const saved = readTaskBindings()[key];
|
|
1736
|
+
if (ownedSessionId(key) && /^dcw-[a-zA-Z0-9-]+$/.test(saved || '')) {
|
|
1737
|
+
const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(saved)}`);
|
|
1738
|
+
const task = cacheWorkflowTask(actions, sessionId, response.task);
|
|
1739
|
+
if (restoreStage) actions.setStep(task.stage || 'upload');
|
|
1740
|
+
return task;
|
|
1741
|
+
}
|
|
1699
1742
|
return createWorkflowTask(actions, sessionId, draft);
|
|
1700
1743
|
}
|
|
1701
1744
|
|
|
@@ -1748,6 +1791,73 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1748
1791
|
return cacheWorkflowTask(actions, sessionId, response.task);
|
|
1749
1792
|
}
|
|
1750
1793
|
|
|
1794
|
+
async function stageConfirmedWorkflow(actions, sessionId, expected = null) {
|
|
1795
|
+
const host = await requestJson('/data-cleaning/api/g5/capabilities');
|
|
1796
|
+
if (host?.workflowExecutionVersion !== 1) throw new Error('页面与 Host 版本不一致。请保存任务后升级并完整重启 DSH;当前 Host 不支持自动生成结果文件。');
|
|
1797
|
+
let task = await ensureWorkflowTask(actions, sessionId);
|
|
1798
|
+
if (expected) {
|
|
1799
|
+
const sameSet = (a, b) => JSON.stringify([...(a || [])].sort()) === JSON.stringify([...(b || [])].sort());
|
|
1800
|
+
if (!sameSet(task.fieldSelection, expected.enrichmentKeys)
|
|
1801
|
+
|| (expected.mode === 'excel' && (task.source?.fileName !== expected.fileName
|
|
1802
|
+
|| task.source?.rowCount !== expected.entryCount
|
|
1803
|
+
|| JSON.stringify(task.mappings) !== JSON.stringify(expected.mappings)))) {
|
|
1804
|
+
throw new Error('工作台保存的范围与向导不一致,请重试确认;未生成执行凭证。');
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
const runtime = runtimeFor(task.id, false);
|
|
1808
|
+
if (!task.fieldSelection?.length) throw new Error('当前未选择企查查补全字段。请在规则页选择补全字段,或使用工作台本地清洗与结果导出;不会发起企查查调用。');
|
|
1809
|
+
if (!runtime?.rows?.length) throw new Error('Host 名单尚未载入,请先解析数据。');
|
|
1810
|
+
if (task.state === 'uploaded') task = await workflowAction(actions, sessionId, task, 'rules', {
|
|
1811
|
+
objectives: task.objectives, fieldSelection: task.fieldSelection, mappings: task.mappings, matchRules: task.matchRules,
|
|
1812
|
+
});
|
|
1813
|
+
if (task.state === 'rules_confirmed') task = await workflowAction(actions, sessionId, task, 'quality', {
|
|
1814
|
+
summary: qualitySummaryFor(runtime.rows, task.mappings),
|
|
1815
|
+
});
|
|
1816
|
+
const response = await requestJson('/data-cleaning/api/g5/commands', 'POST', {
|
|
1817
|
+
kind: 'enrich', workflowOwned: true, expectedRevision: task.revision, taskId: task.id,
|
|
1818
|
+
rows: runtime.rows, headers: runtime.headers, fieldSelection: task.fieldSelection,
|
|
1819
|
+
});
|
|
1820
|
+
if (!response.command?.commandId || !response.command?.prompt) throw new Error('Host 未返回执行凭证;未发送任务。');
|
|
1821
|
+
return response.command;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
async function refreshHostedTask(actions, sessionId, taskId, run, navigate = true) {
|
|
1825
|
+
const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(taskId)}`);
|
|
1826
|
+
const selectedTask = workflowTaskBySession.get(String(sessionId));
|
|
1827
|
+
if (selectedTask && selectedTask.id !== taskId) return response.task;
|
|
1828
|
+
if (selectedTask && selectedTask.revision > response.task.revision) return selectedTask;
|
|
1829
|
+
workflowTaskBySession.set(String(sessionId), response.task);
|
|
1830
|
+
bindTask(sessionId, taskId);
|
|
1831
|
+
if (isCleaningSession(sessionId)) {
|
|
1832
|
+
if (!run && response.task.qccRunId && ['review_required', 'partial'].includes(response.task.state)) {
|
|
1833
|
+
try { run = await requestJson(`/data-cleaning/api/g5/run/${encodeURIComponent(response.task.qccRunId)}`); }
|
|
1834
|
+
catch { actions.setError('Host 中的候选或重试数据已失效。已有结果仍可下载;重新查询需新建任务,不会自动重复扣量。'); }
|
|
1835
|
+
}
|
|
1836
|
+
if (!isCleaningSession(sessionId) || workflowTaskBySession.get(String(sessionId))?.id !== taskId) return response.task;
|
|
1837
|
+
cacheWorkflowTask(actions, sessionId, response.task);
|
|
1838
|
+
if (run) {
|
|
1839
|
+
actions.setQccRun(run);
|
|
1840
|
+
const runtime = runtimeFor(taskId);
|
|
1841
|
+
runtime.resultRows.qcc = run.rows;
|
|
1842
|
+
if (!runtime.rows.length) { runtime.rows = run.rows; runtime.headers = response.task.source?.headers || run.headers || []; }
|
|
1843
|
+
}
|
|
1844
|
+
if (navigate) actions.setStep(response.task.stage || 'upload');
|
|
1845
|
+
}
|
|
1846
|
+
return response.task;
|
|
1847
|
+
}
|
|
1848
|
+
function watchHostedCommand(actions, sessionId, command) {
|
|
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) => {
|
|
1854
|
+
await refreshHostedTask(actions, sessionId, command.taskId, completed.run);
|
|
1855
|
+
if (completed.state === 'failed') throw new Error(completed.error?.message || 'Host 执行失败,请在工作台查看状态。');
|
|
1856
|
+
}).catch((error) => {
|
|
1857
|
+
if (isCleaningSession(sessionId)) actions.setError(error.message || String(error));
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1751
1861
|
/** 工作台 store:入口按钮与工作台共享 open,并保存当前 taskId 的可持久化 UI 元数据。 */
|
|
1752
1862
|
function createWorkbenchStore() {
|
|
1753
1863
|
return defineStore({
|
|
@@ -1799,6 +1909,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1799
1909
|
setError: (draft, error) => { draft.error = error; },
|
|
1800
1910
|
setInput: (draft, input) => { draft.input = input; },
|
|
1801
1911
|
setWorkflowContract: (draft, value) => { draft.workflowContract = value; },
|
|
1912
|
+
setCommandProgress: (draft, value) => { draft.commandProgress = value; },
|
|
1802
1913
|
setWorkflowTask: (draft, value) => { draft.workflowTask = value ?? null; },
|
|
1803
1914
|
setWorkflowTasks: (draft, value) => { draft.workflowTasks = Array.isArray(value) ? value : []; },
|
|
1804
1915
|
setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
|
|
@@ -1944,6 +2055,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1944
2055
|
async function startCleaningSession(ctx) {
|
|
1945
2056
|
const workspace = ctx.workspaces.list.getSnapshot();
|
|
1946
2057
|
const current = ctx.sessions.list.getSnapshot().current;
|
|
2058
|
+
if (ownedSessionId(current)) {
|
|
2059
|
+
markCleaningSession(current);
|
|
2060
|
+
requestWorkbenchOpen(workflowTaskBySession.get(String(current))?.stage || 'upload', current);
|
|
2061
|
+
return current;
|
|
2062
|
+
}
|
|
1947
2063
|
const items = Array.isArray(workspace.items) ? workspace.items : [];
|
|
1948
2064
|
const currentWorkspace = current === undefined
|
|
1949
2065
|
? undefined
|
|
@@ -2040,6 +2156,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2040
2156
|
const WORKBENCH_OPEN_EVENT = 'dsh:data-cleaning-workbench-open';
|
|
2041
2157
|
const WORKBENCH_DATASET_EVENT = 'dsh:data-cleaning-workbench-dataset';
|
|
2042
2158
|
const WORKBENCH_DRAFT_EVENT = 'dsh:data-cleaning-workbench-draft';
|
|
2159
|
+
const WORKBENCH_PREPARE_EVENT = 'dsh:data-cleaning-workbench-prepare';
|
|
2043
2160
|
|
|
2044
2161
|
// session scope 不能复用 root scope 的 store handle。DSH 当前会分别物化 root/session
|
|
2045
2162
|
// 插槽组件,因此以 document 事件跨 scope 通知;闭包引用仅作为无 DOM 测试降级。
|
|
@@ -2057,6 +2174,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2057
2174
|
if (rootWorkbenchActions) openWorkbench(rootWorkbenchActions, step, sessionId);
|
|
2058
2175
|
}
|
|
2059
2176
|
|
|
2177
|
+
function requestPreparedWorkflow(sessionId, config) {
|
|
2178
|
+
return new Promise((resolve, reject) => {
|
|
2179
|
+
const event = document.createEvent('CustomEvent');
|
|
2180
|
+
event.initCustomEvent(WORKBENCH_PREPARE_EVENT, false, true, { sessionId, config, resolve, reject });
|
|
2181
|
+
if (document.dispatchEvent(event)) reject(new Error('工作台尚未就绪,请稍后重试。'));
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2060
2185
|
/** 把提示词生成器解析出的完整表格交给 root 工作台,不把整表塞进模型上下文。 */
|
|
2061
2186
|
function requestWorkbenchDataset(result, sessionId, source) {
|
|
2062
2187
|
if (typeof document !== 'undefined'
|
|
@@ -2667,7 +2792,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2667
2792
|
imageCommandId: imageCommand?.commandId ?? '',
|
|
2668
2793
|
imageState: imageCommand?.state ?? null,
|
|
2669
2794
|
});
|
|
2670
|
-
const generatePrompt = () => {
|
|
2795
|
+
const generatePrompt = async () => {
|
|
2671
2796
|
const sourceError = validateSource();
|
|
2672
2797
|
if (sourceError) { setError(sourceError); return; }
|
|
2673
2798
|
if (!anchorKeys.length) { setError('请至少选择一个企业主体匹配主键。'); return; }
|
|
@@ -2687,17 +2812,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2687
2812
|
removeImage(sessionId, imageAttachment);
|
|
2688
2813
|
setImageAttachment(null);
|
|
2689
2814
|
}
|
|
2690
|
-
const prompt = buildTaskPrompt(config);
|
|
2691
|
-
try {
|
|
2692
|
-
inputActions.setDraft(prompt);
|
|
2693
|
-
const updatedInput = getInputSnapshot?.(sessionId);
|
|
2694
|
-
if (updatedInput && updatedInput.draft !== prompt) throw new Error('当前对话尚未接收任务说明,请稍后重试回填。');
|
|
2695
|
-
} catch (draftError) {
|
|
2696
|
-
setError(draftError instanceof Error ? draftError.message : String(draftError));
|
|
2697
|
-
return;
|
|
2698
|
-
}
|
|
2699
2815
|
if (mode === 'text') {
|
|
2700
2816
|
requestWorkbenchDataset(entriesToDataset(config.entries), sessionId, { type: 'text', fileName: '' });
|
|
2817
|
+
} else if (mode === 'excel' && spreadsheetData) {
|
|
2818
|
+
requestWorkbenchDataset(spreadsheetData, sessionId, { type: spreadsheetData.fmt || 'xlsx', fileName: spreadsheetFileName });
|
|
2701
2819
|
}
|
|
2702
2820
|
requestWorkbenchDraft({
|
|
2703
2821
|
title: `企业数据清洗补全任务 · ${config.entryCount || '待解析'} 条`,
|
|
@@ -2707,6 +2825,20 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2707
2825
|
...(mode === 'excel' ? { mappings: spreadsheetMappings } : {}),
|
|
2708
2826
|
source: { type: mode === 'excel' ? 'xlsx' : mode, fileName: config.fileName || '', rowCount: config.entryCount || 0 },
|
|
2709
2827
|
}, sessionId);
|
|
2828
|
+
setBusy(true);
|
|
2829
|
+
try {
|
|
2830
|
+
let prompt = buildTaskPrompt(config);
|
|
2831
|
+
if (mode !== 'image' || imageCommand?.state === 'completed') {
|
|
2832
|
+
const command = await requestPreparedWorkflow(sessionId, config);
|
|
2833
|
+
prompt = command.prompt + '\n\n' + buildTaskPrompt(config);
|
|
2834
|
+
}
|
|
2835
|
+
inputActions.setDraft(prompt);
|
|
2836
|
+
const updatedInput = getInputSnapshot?.(sessionId);
|
|
2837
|
+
if (updatedInput && updatedInput.draft !== prompt) throw new Error('当前对话尚未接收任务说明,请稍后重试回填。');
|
|
2838
|
+
} catch (draftError) {
|
|
2839
|
+
setError(draftError instanceof Error ? draftError.message : String(draftError));
|
|
2840
|
+
return;
|
|
2841
|
+
} finally { setBusy(false); }
|
|
2710
2842
|
setError(null);
|
|
2711
2843
|
setOpen(false);
|
|
2712
2844
|
setTimeout(() => {
|
|
@@ -3314,6 +3446,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3314
3446
|
const selectedDomains = useStore((state) => state.selectedDomains);
|
|
3315
3447
|
const qccCapabilities = useStore((state) => state.qccCapabilities);
|
|
3316
3448
|
const qccRun = useStore((state) => state.qccRun);
|
|
3449
|
+
const commandProgress = useStore((state) => state.commandProgress);
|
|
3317
3450
|
const paidConfirmed = useStore((state) => state.paidConfirmed);
|
|
3318
3451
|
const jobs = useStore((state) => state.jobs);
|
|
3319
3452
|
const activeSessionId = useStore((state) => state.activeSessionId);
|
|
@@ -3355,6 +3488,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3355
3488
|
});
|
|
3356
3489
|
}
|
|
3357
3490
|
};
|
|
3491
|
+
const handlePrepare = (event) => {
|
|
3492
|
+
event.preventDefault();
|
|
3493
|
+
const { sessionId, config, resolve, reject } = event.detail;
|
|
3494
|
+
queueWorkflowOperation(sessionId, () => stageConfirmedWorkflow(actions, sessionId, config)).then((command) => {
|
|
3495
|
+
watchHostedCommand(actions, sessionId, command);
|
|
3496
|
+
resolve(command);
|
|
3497
|
+
}, reject);
|
|
3498
|
+
};
|
|
3358
3499
|
const handleDraft = (event) => {
|
|
3359
3500
|
event?.preventDefault?.();
|
|
3360
3501
|
const detail = event?.detail ?? {};
|
|
@@ -3374,12 +3515,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3374
3515
|
document.addEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
|
|
3375
3516
|
document.addEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
|
|
3376
3517
|
document.addEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
|
|
3518
|
+
document.addEventListener(WORKBENCH_PREPARE_EVENT, handlePrepare);
|
|
3377
3519
|
}
|
|
3378
3520
|
return () => {
|
|
3379
3521
|
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
3380
3522
|
document.removeEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
|
|
3381
3523
|
document.removeEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
|
|
3382
3524
|
document.removeEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
|
|
3525
|
+
document.removeEventListener(WORKBENCH_PREPARE_EVENT, handlePrepare);
|
|
3383
3526
|
}
|
|
3384
3527
|
stopJobsPolling();
|
|
3385
3528
|
if (rootWorkbenchActions === actions) rootWorkbenchActions = null;
|
|
@@ -3393,14 +3536,34 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3393
3536
|
if (!disposed) actions.setWorkflowContract(response.contract);
|
|
3394
3537
|
}).catch(() => {});
|
|
3395
3538
|
loadWorkflowTasks(actions);
|
|
3396
|
-
if (activeSessionId
|
|
3397
|
-
queueWorkflowOperation(activeSessionId,
|
|
3539
|
+
if (activeSessionId) {
|
|
3540
|
+
queueWorkflowOperation(activeSessionId, async () => {
|
|
3541
|
+
const task = await ensureWorkflowTask(actions, activeSessionId, {}, step !== 'history');
|
|
3542
|
+
await refreshHostedTask(actions, activeSessionId, task.id, null, false);
|
|
3543
|
+
}).catch((taskError) => {
|
|
3398
3544
|
if (!disposed) actions.setError(taskError instanceof Error ? taskError.message : String(taskError));
|
|
3399
3545
|
});
|
|
3400
3546
|
}
|
|
3401
3547
|
return () => { disposed = true; };
|
|
3402
3548
|
}, [open, activeSessionId, actions]);
|
|
3403
3549
|
|
|
3550
|
+
// Reopening during execution follows Host metadata, even when the original
|
|
3551
|
+
// command watcher was lost on page reload. This performs no QCC calls.
|
|
3552
|
+
react.useEffect(() => {
|
|
3553
|
+
if (!open || !workflowTask?.id || !['matching', 'enriching'].includes(workflowTask.state)) return undefined;
|
|
3554
|
+
let disposed = false, timer;
|
|
3555
|
+
const poll = async () => {
|
|
3556
|
+
try {
|
|
3557
|
+
const task = await refreshHostedTask(actions, activeSessionId, workflowTask.id, null, false);
|
|
3558
|
+
if (disposed) return;
|
|
3559
|
+
if (['matching', 'enriching'].includes(task.state)) timer = setTimeout(poll, 2000);
|
|
3560
|
+
else if (step === 'match' || step === 'enrich') actions.setStep(task.stage || step);
|
|
3561
|
+
} catch (error) { if (!disposed) actions.setError(error.message); }
|
|
3562
|
+
};
|
|
3563
|
+
timer = setTimeout(poll, 2000);
|
|
3564
|
+
return () => { disposed = true; clearTimeout(timer); };
|
|
3565
|
+
}, [open, workflowTask?.id, workflowTask?.state, activeSessionId, step, actions]);
|
|
3566
|
+
|
|
3404
3567
|
// 非模态右栏会覆盖 Host 的中央画布;按实际重叠量把 composer 能力栏左移,
|
|
3405
3568
|
// 保证 Mockup 中的五个入口在窄桌面视口仍可见、可点击。展开态专注工作台,
|
|
3406
3569
|
// 不强行挤压到不足一栏的剩余区域。
|
|
@@ -3624,6 +3787,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3624
3787
|
const reviewRequired = Number(summary.ambiguous ?? run.reviewQueue?.length ?? 0);
|
|
3625
3788
|
const currentTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
|
|
3626
3789
|
let current = currentTask;
|
|
3790
|
+
if (currentTask?.qccRunId === run.runId && ['completed', 'partial', 'review_required', 'export_ready'].includes(currentTask.state)) {
|
|
3791
|
+
actions.setStep(currentTask.stage || 'download');
|
|
3792
|
+
return currentTask;
|
|
3793
|
+
}
|
|
3627
3794
|
// partial 表示上一轮仍有失败项。重试成功后可直接重进 enrichment,
|
|
3628
3795
|
// 无需伪造一次新的 match 转换;若重试产生候选,再回到人工核验。
|
|
3629
3796
|
if (currentTask?.state !== 'partial' || reviewRequired > 0) {
|
|
@@ -3664,14 +3831,16 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3664
3831
|
if (!activeSessionId || typeof sendSessionCommand !== 'function') {
|
|
3665
3832
|
throw new Error('当前会话无法提交数据清洗补全企查查任务');
|
|
3666
3833
|
}
|
|
3834
|
+
const latest = workflowTaskBySession.get(String(activeSessionId)) ?? cachedTask;
|
|
3667
3835
|
const prepared = await api('/data-cleaning/api/g5/commands', {
|
|
3668
|
-
...payload,
|
|
3836
|
+
...payload, workflowOwned: true, expectedRevision: latest.revision,
|
|
3669
3837
|
...(payload.kind === 'enrich' ? {} : { confirmPaidCalls: true }),
|
|
3670
3838
|
});
|
|
3671
3839
|
const command = prepared?.command;
|
|
3672
3840
|
if (!command?.commandId || !command?.prompt) throw new Error('Host 未返回有效的企查查任务命令');
|
|
3673
3841
|
await sendSessionCommand(activeSessionId, command.prompt);
|
|
3674
3842
|
const completedCommand = await waitForQccCommand(command.commandId);
|
|
3843
|
+
await refreshHostedTask(actions, activeSessionId, command.taskId, completedCommand.run);
|
|
3675
3844
|
if (completedCommand.state === 'failed') {
|
|
3676
3845
|
const commandError = new Error(completedCommand.error?.message || '智能体企查查任务执行失败');
|
|
3677
3846
|
commandError.code = completedCommand.error?.code;
|
|
@@ -3681,40 +3850,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3681
3850
|
return completedCommand.run;
|
|
3682
3851
|
};
|
|
3683
3852
|
|
|
3684
|
-
const prepareEditableQccCommand = async (
|
|
3853
|
+
const prepareEditableQccCommand = async () => {
|
|
3685
3854
|
if (!activeSessionId || typeof setSessionDraft !== 'function') {
|
|
3686
3855
|
throw new Error('当前会话无法回填可编辑的数据清洗补全任务说明');
|
|
3687
3856
|
}
|
|
3688
|
-
const
|
|
3689
|
-
if (host?.draftPreparationVersion !== 2 || host?.multipleOutputColumns !== true) {
|
|
3690
|
-
throw new Error('页面与 Host 版本不一致。请先保存或导出现有任务,再升级并重启 DSH、刷新页面。生成任务说明不需要勾选费用确认。');
|
|
3691
|
-
}
|
|
3692
|
-
const prepared = await api('/data-cleaning/api/g5/commands', {
|
|
3693
|
-
...payload,
|
|
3694
|
-
});
|
|
3695
|
-
const command = prepared?.command;
|
|
3696
|
-
if (!command?.commandId || !command?.prompt) throw new Error('Host 未返回有效的企查查任务命令');
|
|
3697
|
-
actions.setQccCapabilities({ capabilities: { ready: true } });
|
|
3857
|
+
const command = await queueWorkflowOperation(activeSessionId, () => stageConfirmedWorkflow(actions, activeSessionId));
|
|
3698
3858
|
setSessionDraft(activeSessionId, command.prompt);
|
|
3699
3859
|
actions.close();
|
|
3700
|
-
|
|
3701
|
-
// 发送权仍在用户手中:草稿回填不会调用 QCC。用户发送后,
|
|
3702
|
-
// Agent-owned 工具改变 command 状态;本地安全轮询只读取结果并回写工作流。
|
|
3703
|
-
void waitForQccCommand(command.commandId, 1800).then(async (completedCommand) => {
|
|
3704
|
-
if (completedCommand.state === 'failed') {
|
|
3705
|
-
const commandError = new Error(completedCommand.error?.message || '智能体企查查任务执行失败');
|
|
3706
|
-
commandError.code = completedCommand.error?.code;
|
|
3707
|
-
throw commandError;
|
|
3708
|
-
}
|
|
3709
|
-
if (!completedCommand.run) throw new Error('智能体企查查任务完成但结果已失效');
|
|
3710
|
-
await queueWorkflowOperation(activeSessionId, () => applyQccRun(completedCommand.run));
|
|
3711
|
-
}).catch(async (commandError) => {
|
|
3712
|
-
if (['QCC_NOT_CONNECTED', 'QCC_TOOL_UNAVAILABLE', 'QCC_AUTH_REQUIRED'].includes(commandError?.code)) {
|
|
3713
|
-
const latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
|
|
3714
|
-
try { await workflowAction(actions, activeSessionId, latest, 'authorization-required'); } catch (_workflowError) {}
|
|
3715
|
-
}
|
|
3716
|
-
actions.setError(commandError instanceof Error ? commandError.message : String(commandError));
|
|
3717
|
-
});
|
|
3860
|
+
watchHostedCommand(actions, activeSessionId, command);
|
|
3718
3861
|
return command;
|
|
3719
3862
|
};
|
|
3720
3863
|
|
|
@@ -3842,7 +3985,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3842
3985
|
}
|
|
3843
3986
|
};
|
|
3844
3987
|
|
|
3845
|
-
const resumeWorkflowTask = (task) => {
|
|
3988
|
+
const resumeWorkflowTask = async (task) => {
|
|
3989
|
+
try {
|
|
3990
|
+
workflowTaskBySession.set(String(activeSessionId), task);
|
|
3991
|
+
task = await refreshHostedTask(actions, activeSessionId, task.id);
|
|
3992
|
+
if (task.qccRunId) {
|
|
3993
|
+
try {
|
|
3994
|
+
const response = await api(`/data-cleaning/api/g5/run/${encodeURIComponent(task.qccRunId)}`);
|
|
3995
|
+
actions.setQccRun(response);
|
|
3996
|
+
} catch { /* Expired run data does not prevent persisted artifact downloads. */ }
|
|
3997
|
+
}
|
|
3998
|
+
} catch (error) { actions.setError(error.message); return; }
|
|
3846
3999
|
workflowTaskBySession.set(String(activeSessionId || 'unassigned'), task);
|
|
3847
4000
|
actions.setWorkflowTask(task);
|
|
3848
4001
|
actions.setTaskTitle(task.title);
|
|
@@ -4321,7 +4474,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4321
4474
|
className: 'dcAgentQccBadge',
|
|
4322
4475
|
title: qccRun ? `任务 ${qccRun.runId}` : '仅在当前用户确认使用自己的企查查账号后调用',
|
|
4323
4476
|
}, qccRun ? `QCC · ${qccRun.state}` : qccCapabilities ? (qccCapabilities.capabilities?.ready ? 'QCC · 工具已就绪' : 'QCC · 能力不完整') : 'QCC · 自动校验'),
|
|
4324
|
-
h('span', { className: 'dcAgentJobsPill', 'data-state':
|
|
4477
|
+
h('span', { className: 'dcAgentJobsPill', 'data-state': cachedTask?.state || 'idle', title: '当前清洗任务状态' }, cachedTask ? WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state : '尚未开始'),
|
|
4325
4478
|
h('button', {
|
|
4326
4479
|
className: 'dcAgentWbClose',
|
|
4327
4480
|
type: 'button',
|
|
@@ -4360,6 +4513,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4360
4513
|
h('span', { className: 'dcAgentStepLabel' }, st.label))),
|
|
4361
4514
|
) : null,
|
|
4362
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,
|
|
4363
4522
|
error ? h('div', { className: 'dcAgentError', role: 'alert' }, error) : null,
|
|
4364
4523
|
step !== 'history' ? h('details', { className: 'dcAgentSection' },
|
|
4365
4524
|
h('summary', null, '使用说明与任务流程'),
|
package/lib/qcc-command.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* and lets this high-level tool perform the paid calls with exec.token.
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { WorkflowError } from './workflow.js';
|
|
10
11
|
import { QccBridgeError } from './qcc.js';
|
|
11
12
|
import { fieldLabel, normalizeFieldSelection } from './workflow-contract.js';
|
|
12
13
|
|
|
@@ -21,6 +22,7 @@ function clone(value) {
|
|
|
21
22
|
|
|
22
23
|
function safeError(error) {
|
|
23
24
|
if (error instanceof QccBridgeError) return error.toJSON();
|
|
25
|
+
if (error instanceof WorkflowError) return { code: error.code, message: error.message, retryable: false };
|
|
24
26
|
return new QccBridgeError('QCC_COMMAND_FAILED', 'Data-cleaning QCC command failed', {
|
|
25
27
|
retryable: false,
|
|
26
28
|
}).toJSON();
|
|
@@ -45,6 +47,8 @@ function normalizedInput(input = {}) {
|
|
|
45
47
|
return {
|
|
46
48
|
kind,
|
|
47
49
|
taskId,
|
|
50
|
+
workflowOwned: input.workflowOwned === true,
|
|
51
|
+
workflowRevision: input.workflowRevision,
|
|
48
52
|
rows: clone(rows),
|
|
49
53
|
headers: Array.isArray(input.headers) ? input.headers.map(String) : [],
|
|
50
54
|
nameField: String(input.nameField ?? 'name'),
|
|
@@ -59,6 +63,8 @@ function normalizedInput(input = {}) {
|
|
|
59
63
|
kind,
|
|
60
64
|
taskId,
|
|
61
65
|
runId,
|
|
66
|
+
workflowOwned: input.workflowOwned === true,
|
|
67
|
+
workflowRevision: input.workflowRevision,
|
|
62
68
|
companyName: requiredText(input.companyName, 'QCC_REVIEW_NOT_PENDING', 'A company name is required'),
|
|
63
69
|
selectedCreditNo: requiredText(input.selectedCreditNo, 'QCC_CANDIDATE_INVALID', 'A selected credit number is required'),
|
|
64
70
|
};
|
|
@@ -66,7 +72,7 @@ function normalizedInput(input = {}) {
|
|
|
66
72
|
const companyNames = [...new Set((Array.isArray(input.companyNames) ? input.companyNames : [])
|
|
67
73
|
.map((name) => String(name).trim()).filter(Boolean))];
|
|
68
74
|
if (companyNames.length === 0) throw new QccBridgeError('QCC_RETRY_EMPTY', 'At least one failed company must be selected');
|
|
69
|
-
return { kind, taskId, runId, companyNames };
|
|
75
|
+
return { kind, taskId, runId, companyNames, workflowOwned: input.workflowOwned === true, workflowRevision: input.workflowRevision };
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
export function serializeQccCommandPrompt(command) {
|
|
@@ -98,12 +104,17 @@ export function serializeQccCommandPrompt(command) {
|
|
|
98
104
|
`安全任务凭证:${command.commandId}`,
|
|
99
105
|
'',
|
|
100
106
|
`发送本说明后,请仅调用一次数据清洗补全执行工具(${TOOL_QCC_COMMAND}),参数只传递上述安全任务凭证。`,
|
|
107
|
+
'完成后请在对话中列出工具返回的 XLSX 文件名、预览和下载链接;只使用实际返回的链接,不编造文件或要求源文件路径。',
|
|
101
108
|
'不要要求重复粘贴企业名单,不要直接调用 mcp__qcc-* 工具,不要重试、扩大名单或追加字段。',
|
|
109
|
+
input.workflowOwned
|
|
110
|
+
? '发送即执行已确认方案,不再询问额度或写回确认。Host 会自动生成新的清洗补全结果 XLSX,原始上传文件不修改。不要寻找源文件路径、运行 Bash、编写导出脚本或另行覆盖源文件。'
|
|
111
|
+
: '这是兼容模式命令,返回工作台查看结果并导出。不要另行寻找或覆盖源文件;如需自动交付新 Excel,请在最新版工作台重新生成任务说明。',
|
|
112
|
+
'高层工具返回后只汇报真实状态,并提示返回数据清洗补全工作台下载;主体多候选或真实授权异常才需要人工处理。',
|
|
102
113
|
].join('\n');
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
export class QccCommandStore {
|
|
106
|
-
constructor({ bridge, runs, clock = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxCommands = DEFAULT_MAX_COMMANDS } = {}) {
|
|
117
|
+
constructor({ bridge, runs, clock = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxCommands = DEFAULT_MAX_COMMANDS, lifecycle } = {}) {
|
|
107
118
|
if (!bridge || !runs) throw new TypeError('QccCommandStore requires bridge and runs');
|
|
108
119
|
this.bridge = bridge;
|
|
109
120
|
this.runs = runs;
|
|
@@ -111,6 +122,7 @@ export class QccCommandStore {
|
|
|
111
122
|
this.ttlMs = ttlMs;
|
|
112
123
|
this.maxCommands = maxCommands;
|
|
113
124
|
this.commands = new Map();
|
|
125
|
+
this.lifecycle = lifecycle;
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
cleanup(reserveSlot = false) {
|
|
@@ -171,6 +183,9 @@ export class QccCommandStore {
|
|
|
171
183
|
runId: record.runId,
|
|
172
184
|
error: record.error,
|
|
173
185
|
estimate: record.estimate,
|
|
186
|
+
progress: record.progress ?? null,
|
|
187
|
+
workflowOwned: record.input.workflowOwned === true,
|
|
188
|
+
delivery: record.delivery ?? null,
|
|
174
189
|
expiresInMs: this.ttlMs,
|
|
175
190
|
});
|
|
176
191
|
}
|
|
@@ -190,15 +205,18 @@ export class QccCommandStore {
|
|
|
190
205
|
if (record.promise) return record.promise;
|
|
191
206
|
record.state = 'running';
|
|
192
207
|
record.updatedAt = new Date(this.clock()).toISOString();
|
|
193
|
-
record.promise = this.
|
|
194
|
-
.then((
|
|
208
|
+
record.promise = Promise.resolve().then(() => this.lifecycle?.beforeRun(record))
|
|
209
|
+
.then(() => this.execute(record, execution))
|
|
210
|
+
.then(async (run) => {
|
|
195
211
|
record.runId = run.runId;
|
|
212
|
+
record.delivery = await this.lifecycle?.complete(record, run);
|
|
196
213
|
record.state = 'completed';
|
|
197
214
|
record.updatedAt = new Date(this.clock()).toISOString();
|
|
198
215
|
record.touchedAtMs = this.clock();
|
|
199
216
|
return this.toolResult(record, run);
|
|
200
217
|
})
|
|
201
|
-
.catch((error) => {
|
|
218
|
+
.catch(async (error) => {
|
|
219
|
+
try { await this.lifecycle?.failed(record, error); } catch { /* Preserve the original execution failure. */ }
|
|
202
220
|
record.error = safeError(error);
|
|
203
221
|
record.state = 'failed';
|
|
204
222
|
record.updatedAt = new Date(this.clock()).toISOString();
|
|
@@ -217,6 +235,7 @@ export class QccCommandStore {
|
|
|
217
235
|
return this.runs.retryCompanies(record.input.runId, record.input.companyNames, this.bridge, options);
|
|
218
236
|
}
|
|
219
237
|
const audit = [];
|
|
238
|
+
record.progress = { completedUnique: 0, totalUnique: record.estimate?.uniqueCompanies || 0 };
|
|
220
239
|
const result = await this.bridge.enrichRows(record.input.rows, {
|
|
221
240
|
nameField: record.input.nameField,
|
|
222
241
|
includeRisk: record.input.includeRisk,
|
|
@@ -225,6 +244,7 @@ export class QccCommandStore {
|
|
|
225
244
|
maxRows: 100,
|
|
226
245
|
execution,
|
|
227
246
|
onAudit: (event) => audit.push(event),
|
|
247
|
+
onProgress: (progress) => { record.progress = progress; },
|
|
228
248
|
});
|
|
229
249
|
return this.runs.createRun({
|
|
230
250
|
headers: record.input.headers,
|
|
@@ -244,6 +264,8 @@ export class QccCommandStore {
|
|
|
244
264
|
runId: run.runId,
|
|
245
265
|
state: run.state,
|
|
246
266
|
summary: run.summary,
|
|
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)}` })) } : {}),
|
|
247
269
|
});
|
|
248
270
|
}
|
|
249
271
|
}
|
|
@@ -267,6 +289,9 @@ export function registerQccCommandTool(tools, commands) {
|
|
|
267
289
|
taskId: { type: 'string' },
|
|
268
290
|
runId: { type: 'string' },
|
|
269
291
|
state: { type: 'string' },
|
|
292
|
+
deliveryState: { type: 'string' },
|
|
293
|
+
artifactCount: { type: 'integer' },
|
|
294
|
+
artifacts: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { fileName: { type: 'string' }, url: { type: 'string' } }, required: ['fileName', 'url'] } },
|
|
270
295
|
summary: {
|
|
271
296
|
type: 'object',
|
|
272
297
|
additionalProperties: false,
|
|
@@ -288,7 +313,7 @@ export function registerQccCommandTool(tools, commands) {
|
|
|
288
313
|
},
|
|
289
314
|
render: (_args, value) => [{
|
|
290
315
|
type: 'text',
|
|
291
|
-
text:
|
|
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')}`,
|
|
292
317
|
}],
|
|
293
318
|
},
|
|
294
319
|
async execute(args, exec) {
|
package/lib/qcc-field-catalog.js
CHANGED
|
@@ -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/skill-enrich.js
CHANGED
|
@@ -56,7 +56,9 @@ export function registerEnrichSkill(skills) {
|
|
|
56
56
|
'',
|
|
57
57
|
'Workbench execution request (highest priority):',
|
|
58
58
|
'- If the visible user message is the readable data-cleaning task summary generated by the workbench, contains a `安全任务凭证` beginning with `dcq-`, and explicitly requests `data_cleaning_qcc_run`, treat that credential as `commandId`, call the high-level tool exactly once with only `commandId`, then stop.',
|
|
59
|
-
'- The Host already holds the rows, billing confirmation and field selection. Do not ask the user to paste rows, do not call any `mcp__qcc-*` tool directly, do not retry, and do not expand the batch.',
|
|
59
|
+
'- The Host already holds the rows, billing confirmation and field selection. Sending the visible staged scope is the execution confirmation: do not ask again about quota or writing a file. Do not ask the user to paste rows, do not call any `mcp__qcc-*` tool directly, do not retry, and do not expand the batch.',
|
|
60
|
+
'- The Host automatically creates a NEW enriched Excel and attaches downloads to this workflow task. Never use Bash, search the filesystem, request the original path, overwrite the source workbook, or create substitute CSV reports for a workbench task. Report delivery only from the high-level tool result; unresolved entity candidates are confirmed in the workbench.',
|
|
61
|
+
'- If a message says the workbench holds its source but has no valid dcq- credential, return to the workbench to regenerate the task description. Do not fall back to free-form MCP or filesystem execution.',
|
|
60
62
|
'',
|
|
61
63
|
'Workflow:',
|
|
62
64
|
'1. Check QCC availability first: run `qcc_oauth_status`. If not connected, tell the user to run `qcc_oauth_connect` first and stop. If the token is expired, guide the user to `qcc_oauth_connect` (it reuses the grant and refreshes without a new authorization page).',
|
package/lib/web.js
CHANGED
|
@@ -16,6 +16,7 @@ import { PHASE3_BATCH_LIMITS, Phase3BatchService, Phase3RunStore } from './qcc-p
|
|
|
16
16
|
import { publicWorkflowContract } from './workflow-contract.js';
|
|
17
17
|
import { DataCleaningWorkflowStore, WorkflowError } from './workflow.js';
|
|
18
18
|
import { ArtifactError, WorkflowArtifactStore } from './artifacts.js';
|
|
19
|
+
import { createWorkflowExecution } from './workflow-execution.js';
|
|
19
20
|
import {
|
|
20
21
|
ImageIntakeError,
|
|
21
22
|
ImageIntakeStore,
|
|
@@ -305,7 +306,8 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
305
306
|
const qccBridge = new QccHostBridge({ tools, logger });
|
|
306
307
|
const imageIntake = new ImageIntakeStore({ tools });
|
|
307
308
|
const g5Runs = new G5RunStore();
|
|
308
|
-
const
|
|
309
|
+
const execution = createWorkflowExecution({ getWorkflow: () => getWorkflow(), artifacts: wctx.fs ? new WorkflowArtifactStore({ fs: wctx.fs }) : null });
|
|
310
|
+
const qccCommands = new QccCommandStore({ bridge: qccBridge, runs: g5Runs, lifecycle: execution });
|
|
309
311
|
if (typeof tools?.register === 'function') {
|
|
310
312
|
disposers.push(registerQccCommandTool(tools, qccCommands));
|
|
311
313
|
disposers.push(registerImageIntakeTool(tools, imageIntake));
|
|
@@ -557,6 +559,16 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
557
559
|
const artifact = current.artifacts.find((item) => item.id === artifactId);
|
|
558
560
|
if (!artifact) throw new ArtifactError('DC_ARTIFACT_NOT_FOUND', 'Artifact not found for this task.', 404);
|
|
559
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 => ({ '&': '&', '<': '<', '>': '>', '\"': '"' }[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
|
+
}
|
|
560
572
|
const fallback = artifact.format === 'xlsx' ? 'export.xlsx' : 'export.csv';
|
|
561
573
|
const asciiName = String(artifact.fileName || fallback).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
562
574
|
res.writeHead(200, {
|
|
@@ -620,6 +632,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
620
632
|
draftPreparationVersion: 2,
|
|
621
633
|
draftPaidCallConfirmationRequired: false,
|
|
622
634
|
multipleOutputColumns: true,
|
|
635
|
+
workflowExecutionVersion: 1,
|
|
623
636
|
idempotencyRequired: true,
|
|
624
637
|
candidateResume: true,
|
|
625
638
|
manualRetry: true,
|
|
@@ -650,7 +663,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
650
663
|
ok: true,
|
|
651
664
|
marker: 'g5-agent-command',
|
|
652
665
|
paidCalls: false,
|
|
653
|
-
command: qccCommands.prepare(payload),
|
|
666
|
+
command: qccCommands.prepare(payload.workflowOwned ? await execution.prepare(payload) : payload),
|
|
654
667
|
});
|
|
655
668
|
}
|
|
656
669
|
if (rest.length === 1 && req.method === 'GET') {
|
|
@@ -665,6 +678,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
665
678
|
if (error instanceof SyntaxError) {
|
|
666
679
|
return writeJson(res, 400, { ok: false, code: 'DC_BAD_JSON', message: 'Request body must be valid JSON.' });
|
|
667
680
|
}
|
|
681
|
+
if (error instanceof WorkflowError) return writeWorkflowError(res, error);
|
|
668
682
|
return writeQccError(res, error);
|
|
669
683
|
}
|
|
670
684
|
});
|
package/lib/workflow-contract.js
CHANGED
|
@@ -88,7 +88,7 @@ const TRANSITIONS = Object.freeze({
|
|
|
88
88
|
export_ready: ['enriching', 'completed', 'failed', 'cancelled'],
|
|
89
89
|
parse_failed: ['draft', 'uploaded', 'cancelled'],
|
|
90
90
|
authorization_required: ['matching', 'enriching', 'failed', 'cancelled'],
|
|
91
|
-
partial: ['review_required', 'enriching', 'export_ready', 'authorization_required', 'completed', 'failed', 'cancelled'],
|
|
91
|
+
partial: ['matching', 'review_required', 'enriching', 'export_ready', 'authorization_required', 'completed', 'failed', 'cancelled'],
|
|
92
92
|
failed: ['draft', 'uploaded', 'rules_confirmed', 'diagnosed', 'matching', 'enriching', 'cancelled'],
|
|
93
93
|
completed: [],
|
|
94
94
|
cancelled: [],
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { WorkflowError } from './workflow.js';
|
|
2
|
+
|
|
3
|
+
// Execution owns finalization: closing a browser must not interrupt result delivery.
|
|
4
|
+
export function createWorkflowExecution({ getWorkflow, artifacts }) {
|
|
5
|
+
const requireStore = async () => {
|
|
6
|
+
const store = await getWorkflow();
|
|
7
|
+
if (!store || !artifacts) throw new WorkflowError('DC_DELIVERY_UNAVAILABLE', 'Host 任务存储或下载服务不可用,请升级 DSH。', 503);
|
|
8
|
+
return store;
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
async prepare(payload) {
|
|
12
|
+
const store = await requireStore();
|
|
13
|
+
const task = await store.require(payload.taskId);
|
|
14
|
+
if (!['rules_confirmed', 'diagnosed', 'review_required', 'partial'].includes(task.state)) {
|
|
15
|
+
throw new WorkflowError('DC_EXECUTION_STATE', '请先在工作台确认规则;进行中或已完成任务不能重复启动。', 409);
|
|
16
|
+
}
|
|
17
|
+
if (Number(payload.expectedRevision) !== task.revision) throw new WorkflowError('DC_WORKFLOW_REVISION_CONFLICT', '任务已更新,请重新生成说明。', 409);
|
|
18
|
+
if ((!payload.kind || payload.kind === 'enrich') && !['rules_confirmed', 'diagnosed'].includes(task.state)) {
|
|
19
|
+
throw new WorkflowError('DC_EXECUTION_STATE', '请通过候选确认或失败重试继续当前任务。', 409);
|
|
20
|
+
}
|
|
21
|
+
if (payload.kind && payload.kind !== 'enrich' && payload.runId !== task.qccRunId) throw new WorkflowError('DC_RUN_MISMATCH', '执行结果不属于当前任务。', 409);
|
|
22
|
+
if (!payload.kind || payload.kind === 'enrich') {
|
|
23
|
+
if (!Array.isArray(payload.rows) || payload.rows.length !== task.source?.rowCount) throw new WorkflowError('DC_SOURCE_MISMATCH', '任务名单数量不一致,请重新载入。', 409);
|
|
24
|
+
}
|
|
25
|
+
if (!task.fieldSelection.length) throw new WorkflowError('DC_FIELDS_REQUIRED', '未选择外部补全字段,请使用本地清洗与导出。', 400);
|
|
26
|
+
const primary = task.mappings.find((m) => m.targetField === 'company_name')
|
|
27
|
+
?? task.mappings.find((m) => m.targetField === 'credit_no')
|
|
28
|
+
?? task.mappings.find((m) => m.targetField === 'reg_no');
|
|
29
|
+
return { ...payload, workflowOwned: true, workflowRevision: task.revision,
|
|
30
|
+
fieldSelection: task.fieldSelection, headers: task.source?.headers || [],
|
|
31
|
+
nameField: primary?.sourceField || payload.nameField };
|
|
32
|
+
},
|
|
33
|
+
async beforeRun(record) {
|
|
34
|
+
if (!record.input.workflowOwned) return;
|
|
35
|
+
const store = await requireStore();
|
|
36
|
+
const task = await store.require(record.taskId);
|
|
37
|
+
if (task.revision !== record.input.workflowRevision) throw new WorkflowError('DC_WORKFLOW_REVISION_CONFLICT', '说明已失效,请返回工作台重新生成;未执行查询。', 409);
|
|
38
|
+
await store.startMatch(task.id, { expectedRevision: task.revision });
|
|
39
|
+
record.workflowStarted = true;
|
|
40
|
+
},
|
|
41
|
+
async failed(record, error) {
|
|
42
|
+
if (!record.input.workflowOwned || !record.workflowStarted) return;
|
|
43
|
+
const store = await requireStore();
|
|
44
|
+
const task = await store.require(record.taskId);
|
|
45
|
+
if (['completed', 'cancelled', 'failed'].includes(task.state)) return;
|
|
46
|
+
if (['QCC_AUTH_REQUIRED', 'QCC_NOT_CONNECTED', 'QCC_TOOL_UNAVAILABLE'].includes(error.code)) {
|
|
47
|
+
await store.requireAuthorization(task.id, { expectedRevision: task.revision });
|
|
48
|
+
} else {
|
|
49
|
+
await store.recordFailure(task.id, { expectedRevision: task.revision, code: error.code || 'DC_DELIVERY_FAILED' });
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
async complete(record, run) {
|
|
53
|
+
if (!record.input.workflowOwned) return;
|
|
54
|
+
const store = await requireStore();
|
|
55
|
+
let task = await store.require(record.taskId);
|
|
56
|
+
const s = run.summary || {};
|
|
57
|
+
const total = Number(s.totalRows ?? run.rows.length);
|
|
58
|
+
const review = Number(s.ambiguous ?? 0);
|
|
59
|
+
task = await store.recordMatch(task.id, { expectedRevision: task.revision, qccRunId: run.runId,
|
|
60
|
+
summary: { total, exact: Number(s.enriched || 0) + Number(s.fieldReview || 0), candidate: review,
|
|
61
|
+
unresolved: Number(s.unresolved || 0) + Number(s.missingName || 0), failed: Number(s.failed || 0), reviewRequired: review } });
|
|
62
|
+
if (review) return { state: task.state, artifacts: [], taskId: task.id };
|
|
63
|
+
task = await store.startEnrichment(task.id, { expectedRevision: task.revision });
|
|
64
|
+
task = await store.recordEnrichment(task.id, { expectedRevision: task.revision, qccRunId: run.runId,
|
|
65
|
+
summary: { total, completed: Number(s.enriched || 0), unchanged: Number(s.unresolved || 0) + Number(s.missingName || 0),
|
|
66
|
+
failed: Number(s.failed || 0), reviewRequired: Number(s.fieldReview || 0), callsUsed: run.audit?.length || 0 } });
|
|
67
|
+
const bundle = await artifacts.createBundle(task.id, { rows: run.rows, headers: task.source?.headers || run.headers,
|
|
68
|
+
mappings: task.mappings, fieldSelection: task.fieldSelection,
|
|
69
|
+
baseName: (task.source?.fileName || task.title).replace(/\.[^.]+$/, '') + '-清洗补全结果' });
|
|
70
|
+
task = await store.recordExport(task.id, { expectedRevision: task.revision, artifacts: bundle, preservePartial: true });
|
|
71
|
+
return { state: task.state, artifacts: task.artifacts, taskId: task.id };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
package/lib/workflow.js
CHANGED
|
@@ -384,7 +384,7 @@ export class DataCleaningWorkflowStore {
|
|
|
384
384
|
throw new WorkflowError('DC_WORKFLOW_ARTIFACT_REQUIRED', 'At least one Host artifact reference is required.', 400);
|
|
385
385
|
}
|
|
386
386
|
return {
|
|
387
|
-
state: 'completed',
|
|
387
|
+
state: input.preservePartial === true && record.state === 'partial' ? 'partial' : 'completed',
|
|
388
388
|
stage: 'download',
|
|
389
389
|
artifacts: [...record.artifacts, ...artifacts].slice(-20),
|
|
390
390
|
error: null,
|
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.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",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"docs/RELEASE-0.8.0.md",
|
|
40
40
|
"docs/RELEASE-0.8.1.md",
|
|
41
41
|
"docs/RELEASE-0.8.16.md",
|
|
42
|
+
"docs/RELEASE-0.8.17.md",
|
|
43
|
+
"docs/RELEASE-0.8.18.md",
|
|
42
44
|
"docs/TEMPLATE-MAPPING-ACCEPTANCE.md",
|
|
43
45
|
"docs/UI-WORKFLOW-V2.md",
|
|
44
46
|
"docs/UI-WORKFLOW-V2-MIGRATION.md",
|
|
@@ -49,7 +51,7 @@
|
|
|
49
51
|
"scripts": {
|
|
50
52
|
"test:ui": "node scripts/ui-layout-regression.mjs",
|
|
51
53
|
"test": "node --test",
|
|
52
|
-
"lint": "node --check lib/index.js && node --check lib/engine.js && node --check lib/artifacts.js && node --check lib/image-intake.js && node --check lib/tools.js && node --check lib/skill.js && node --check lib/qcc-field-catalog.js && node --check lib/qcc-phase2.js && node --check lib/qcc-phase3.js && node --check lib/qcc-phase3-batch.js && node --check lib/qcc-phase2-acceptance.js && node --check lib/skill-enrich.js && node --check lib/jobs.js && node --check lib/workflow-contract.js && node --check lib/workflow.js && node --check lib/qcc-safety.js && node --check lib/qcc.js && node --check lib/qcc-command.js && node --check lib/qcc-runs.js && node --check lib/web.js && node --check lib/client.js && node --check scripts/check-readme-version.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/g5-e2e.mjs && node --check scripts/phase3-e2e.mjs && node --check scripts/phase2-acceptance.mjs",
|
|
54
|
+
"lint": "node --check lib/index.js && node --check lib/engine.js && node --check lib/artifacts.js && node --check lib/image-intake.js && node --check lib/tools.js && node --check lib/skill.js && node --check lib/qcc-field-catalog.js && node --check lib/qcc-phase2.js && node --check lib/qcc-phase3.js && node --check lib/qcc-phase3-batch.js && node --check lib/qcc-phase2-acceptance.js && node --check lib/skill-enrich.js && node --check lib/jobs.js && node --check lib/workflow-contract.js && node --check lib/workflow.js && node --check lib/workflow-execution.js && node --check lib/qcc-safety.js && node --check lib/qcc.js && node --check lib/qcc-command.js && node --check lib/qcc-runs.js && node --check lib/web.js && node --check lib/client.js && node --check scripts/check-readme-version.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/g5-e2e.mjs && node --check scripts/phase3-e2e.mjs && node --check scripts/phase2-acceptance.mjs",
|
|
53
55
|
"docs:check": "node scripts/check-readme-version.mjs",
|
|
54
56
|
"marketing:check": "node scripts/check-marketing.mjs",
|
|
55
57
|
"verify-pack": "node scripts/verify-pack.mjs",
|
|
@@ -116,7 +118,7 @@
|
|
|
116
118
|
"node": ">=20"
|
|
117
119
|
},
|
|
118
120
|
"dependencies": {
|
|
119
|
-
"qcc-field-contracts": "0.
|
|
121
|
+
"qcc-field-contracts": "0.2.0",
|
|
120
122
|
"xlsx": "^0.18.5"
|
|
121
123
|
},
|
|
122
124
|
"peerDependencies": {
|