dsh-data-cleaning-agent 0.8.15 → 0.8.17
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 +20 -0
- package/README.en.md +2 -2
- package/README.md +4 -4
- package/docs/RELEASE-0.8.16.md +24 -0
- package/docs/RELEASE-0.8.17.md +26 -0
- package/docs/TEMPLATE-MAPPING-ACCEPTANCE.md +29 -0
- package/lib/client.js +279 -64
- package/lib/engine.js +1 -2
- package/lib/qcc-command.js +25 -6
- package/lib/skill-enrich.js +3 -1
- package/lib/web.js +9 -2
- package/lib/workflow-contract.js +2 -2
- package/lib/workflow-execution.js +74 -0
- package/lib/workflow.js +1 -1
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.17] - 2026-09-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- 提示词向导和工作台统一生成 Host 执行凭证;发送已确认范围后不再重复询问额度、源文件路径或写回确认。
|
|
11
|
+
- Host 在高层工具内完成工作流与新 XLSX/异常清单交付,不依赖对话模型编写脚本或页面持续轮询;原始文件不覆盖。
|
|
12
|
+
- 独立清洗会话切换、刷新可恢复业务归属;侧栏入口重开当前工作台,保存任务绑定以访问下载。持久化绑定仅包含会话 ID 与任务 ID。
|
|
13
|
+
- 版本冲突阻止旧说明再次查询,命令重放幂等;候选歧义仍需人工选择,部分失败提供下载并允许显式重试。
|
|
14
|
+
- 修复后台刷新覆盖任务历史导航,并覆盖 DSH root/session 分离实例的事件交接。
|
|
15
|
+
|
|
16
|
+
## [0.8.16] - 2026-09-08
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- 表格提示词向导展示完整解析清单与空白单元格数,并与右侧工作台统一原列映射、人工候选确认及补全范围。
|
|
20
|
+
- 映射用绿色(自动通过/已确认)、琥珀色(待人工确认)、红色(未匹配)和文字共同标识;已映射字段自动进入补全范围,额外字段选择默认折叠。
|
|
21
|
+
- 允许多个原列经确认映射同一非主体字段,只补空白、保留非空原值;企业名称、信用代码、注册号各保留唯一匹配主列。
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- 修复 Host 草稿回传将新映射的补全范围重置为默认五字段的问题。
|
|
25
|
+
- 生成任务说明前检查前后端协议能力,旧 Host 配新页面时显示中文升级与安全重启指引;不伪造额度确认,不提前执行查询。
|
|
26
|
+
|
|
7
27
|
## [0.8.15] - 2026-09-08
|
|
8
28
|
|
|
9
29
|
- 改善搜索元数据、安装说明、能力边界与四产品互链;无运行时或依赖变更。
|
package/README.en.md
CHANGED
|
@@ -8,9 +8,9 @@ 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.17** (stable release)
|
|
12
12
|
|
|
13
|
-
This release
|
|
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
|
|
|
15
15
|
[](https://github.com/duhu2000/dsh-data-cleaning-agent/actions/workflows/ci.yml)
|
|
16
16
|
[](https://www.npmjs.com/package/dsh-data-cleaning-agent)
|
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# dsh-data-cleaning-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
本版统一向导与工作台的 Host 执行闭环:发送后自动生成新 Excel,原文件不修改,工作台入口可恢复;发布记录见 [发布说明](docs/RELEASE-0.8.17.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.17
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
请先满足下文的 DSH、连接器及侧边栏依赖要求;安装后完整停止并重启对应 Profile。
|
|
@@ -25,9 +25,9 @@ dsh plugin --profile web add dsh-data-cleaning-agent@0.8.15
|
|
|
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.17**(正式版本)
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
本版上传表格后展示完整清单及空白数量,用绿色、琥珀色、红色及文字区分已通过、待确认和未匹配映射。已映射字段自动纳入补全范围,额外字段默认折叠;多个原列可经确认使用同一非主体字段,分别补空并保留非空原值。修复 Host 草稿重置为默认五字段的问题,增加前后端版本错配提示。生成说明不查询,发送才执行;升级后需完整重启 DSH,历史下载文件不自动改写。
|
|
31
31
|
|
|
32
32
|
[](https://github.com/duhu2000/dsh-data-cleaning-agent/actions/workflows/ci.yml)
|
|
33
33
|
[](https://www.npmjs.com/package/dsh-data-cleaning-agent)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# dsh-data-cleaning-agent v0.8.16
|
|
2
|
+
|
|
3
|
+
## 本版改进
|
|
4
|
+
|
|
5
|
+
- 提示词向导与右侧工作台统一:上传显示完整分页清单及空白数量,确认原列映射后自动形成补全范围。
|
|
6
|
+
- 映射状态同时提供颜色和文字:绿色已通过、琥珀色待人工确认、红色未匹配。模糊候选不自动当作已确认。
|
|
7
|
+
- 多个原列可确认使用同一非主体字段;查询字段去重,分别补空,保留非空值、0 与 false。企业名称、信用代码、注册号各保留唯一匹配主列。
|
|
8
|
+
- 已映射字段无需重复选择;额外字段目录默认折叠。修复 Host 草稿把新范围覆盖成默认五字段的问题。
|
|
9
|
+
- 草稿生成前检查 Host 协议能力,发现新页面配旧进程时提供中文升级提示;生成不查询,发送才执行。
|
|
10
|
+
|
|
11
|
+
## 验收与边界
|
|
12
|
+
|
|
13
|
+
233 项自动化测试通过,深浅色 × 5 种视口的 10 组隔离 React/Chromium 回归通过。使用合成数据,不以此声称已完成真实 QCC 查询或本机安装验收。详见 [映射验收](TEMPLATE-MAPPING-ACCEPTANCE.md)。
|
|
14
|
+
|
|
15
|
+
本版只改数据清洗补全仓库,不改 AI 填表及共享字段契约包版本。未知或不支持的字段保留原列,不猜测填充。
|
|
16
|
+
|
|
17
|
+
## 升级与回滚
|
|
18
|
+
|
|
19
|
+
1. 保存或导出现有任务,备份任务目录,并记录现有精确版本。
|
|
20
|
+
2. 停止对应 DSH Profile,安装:`dsh plugin --profile web add dsh-data-cleaning-agent@0.8.16`。
|
|
21
|
+
3. 完整重启 DSH 并刷新页面;仅刷新浏览器不足以更新旧内存 Host。
|
|
22
|
+
4. 回滚可安装 `dsh-data-cleaning-agent@0.8.15` 并恢复升级前任务副本。旧版不支持新增多列共享字段映射,不能直接用旧版编辑新版多列映射任务。
|
|
23
|
+
|
|
24
|
+
历史导出文件不会被自动改写。
|
|
@@ -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,29 @@
|
|
|
1
|
+
# 原表补全与统一映射验收(v0.8.16)
|
|
2
|
+
|
|
3
|
+
本次范围:数据清洗补全的提示词向导与右侧工作台;未修改 AI 填表或共享内核仓库。
|
|
4
|
+
|
|
5
|
+
## 交互与数据规则
|
|
6
|
+
|
|
7
|
+
- 表格上传后展示完整分页清单、列数、空白单元格数,并提供原始清单下载。
|
|
8
|
+
- 标准表头及明确别名自动通过(绿色);歧义与重复列给出候选(琥珀色),人工选择后转绿色;未知字段为未匹配(红色),原列保留。颜色同时配有文字。
|
|
9
|
+
- 已确认映射自动构成补全范围,不再要求重复勾选默认五字段。额外字段目录默认折叠,只用于新增原表之外的字段。
|
|
10
|
+
- 多个原列可人工确认映射同一非主体字段。查询字段去重,结果分别补空,保留非空原值及数值 0、布尔值 false。
|
|
11
|
+
- 企业名称、统一社会信用代码、注册号分别只允许一个匹配主列;一个原列不能同时映射多个字段。
|
|
12
|
+
- 映射阶段只匹配字段语义,不代表企业主体已匹配或字段数据已查询。没有当前目录支持的字段不猜测、不替换为近似口径。
|
|
13
|
+
- 最终任务说明包含原列映射、补空策略、未映射列及额外新增字段。用户仍可在中央对话框编辑,发送才执行查询。
|
|
14
|
+
|
|
15
|
+
## 回归证据
|
|
16
|
+
|
|
17
|
+
使用与反馈表头同结构的合成测试数据,不复制客户工作簿到仓库、不调用真实 QCC:
|
|
18
|
+
|
|
19
|
+
- 23 列、1 行、22 个空白单元格:9 列自动通过;确认重复列、企业状态、行业候选后,15 列映射至 13 个不同输出字段,8 列未匹配保留。
|
|
20
|
+
- 提示词回填后,Host 保存 15 个映射及 13 个字段,不被旧草稿默认五字段覆盖。
|
|
21
|
+
- CSV/XLSX 结果及异常清单覆盖多个原列共享同一补全字段、原值保护与异常行保护。
|
|
22
|
+
- 自动化检查覆盖契约、Host 路由、草稿不需要费用勾选、实际执行边界,以及深浅色 × 5 种视口的真实 React/Chromium 交互。
|
|
23
|
+
- 浏览器验证旧 Host 能力缺失时显示中文版本错配提示,且不创建或执行 QCC 命令。
|
|
24
|
+
|
|
25
|
+
## 本机升级注意
|
|
26
|
+
|
|
27
|
+
本次排查发现正在运行的 DSH 进程启动时间早于插件磁盘更新,可能出现新页面配旧内存 Host。新增能力标记 `draftPreparationVersion: 2`、`multipleOutputColumns: true` 用于提前识别这一情况。
|
|
28
|
+
|
|
29
|
+
发布安装后需先保存或导出现有任务,再重启 DSH 并刷新页面。仅刷新浏览器不能替换内存中的旧 Host。开发验收未安装或重启本机 DSH,也未改动用户源文件。
|
package/lib/client.js
CHANGED
|
@@ -949,6 +949,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
949
949
|
.dcAgentMappingPicker input, .dcAgentMappingPicker select { width: 100%; margin-top: 8px; max-width: 100%; min-width: 0; }
|
|
950
950
|
.dcAgentMappingRow .dcAgentButton { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
|
|
951
951
|
.dcAgentMappingRow small { display: block; margin: 4px 0; overflow-wrap: anywhere; }
|
|
952
|
+
.dcAgentMappingStatus[data-status="confirmed"] { color: light-dark(#15803d, #86efac); }
|
|
953
|
+
.dcAgentMappingStatus[data-status="review"] { color: light-dark(#a16207, #fcd34d); }
|
|
954
|
+
.dcAgentMappingStatus[data-status="unmatched"] { color: light-dark(#b91c1c, #fca5a5); }
|
|
952
955
|
.dcAgentRulesGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
953
956
|
.dcAgentFieldGroup { display: grid; gap: 7px; margin-top: 12px; }
|
|
954
957
|
.dcAgentFieldGroupHead { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
|
@@ -1228,10 +1231,27 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1228
1231
|
];
|
|
1229
1232
|
const CLEANING_SESSION_EVENT = 'dsh:data-cleaning-session-ownership';
|
|
1230
1233
|
let activeCleaningSessionId = null;
|
|
1234
|
+
const TASK_BINDINGS_KEY = 'dsh.data-cleaning-agent.task-bindings.v1';
|
|
1235
|
+
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 || ''));
|
|
1236
|
+
function readTaskBindings() {
|
|
1237
|
+
try {
|
|
1238
|
+
const value = JSON.parse(window.localStorage?.getItem(TASK_BINDINGS_KEY) || '{}');
|
|
1239
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
1240
|
+
} catch { return {}; }
|
|
1241
|
+
}
|
|
1242
|
+
function bindTask(sessionId, taskId) {
|
|
1243
|
+
if (!ownedSessionId(sessionId)) return;
|
|
1244
|
+
try {
|
|
1245
|
+
const bindings = readTaskBindings();
|
|
1246
|
+
delete bindings[sessionId];
|
|
1247
|
+
bindings[sessionId] = taskId;
|
|
1248
|
+
window.localStorage?.setItem(TASK_BINDINGS_KEY, JSON.stringify(Object.fromEntries(Object.entries(bindings).slice(-200))));
|
|
1249
|
+
} catch {}
|
|
1250
|
+
}
|
|
1231
1251
|
|
|
1232
1252
|
try {
|
|
1233
1253
|
// 旧版本持久化过 sessionId;但 DSH 会复用空白新会话,无法仅凭 ID 判断入口来源。
|
|
1234
|
-
//
|
|
1254
|
+
// 清除旧的无前缀归属标记;新的独立会话前缀可安全恢复,不认领其它智能体会话。
|
|
1235
1255
|
for (const key of CLEANING_SESSION_STORAGE_KEYS) window.sessionStorage?.removeItem(key);
|
|
1236
1256
|
} catch (_error) {
|
|
1237
1257
|
// sessionStorage 可能被禁用;内存态不受影响。
|
|
@@ -1328,10 +1348,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1328
1348
|
};
|
|
1329
1349
|
const leaveSubsystem = () => {
|
|
1330
1350
|
const sessionId = activeCleaningSessionId;
|
|
1331
|
-
if (sessionId) clearCleaningDraft(ctx, sessionId, true);
|
|
1351
|
+
if (sessionId && !ownedSessionId(sessionId)) clearCleaningDraft(ctx, sessionId, true);
|
|
1332
1352
|
deactivateCleaningSession(sessionId);
|
|
1333
1353
|
};
|
|
1334
|
-
//
|
|
1354
|
+
// Only our independently-created native session namespace may regain ownership.
|
|
1355
|
+
const initialSession = ctx.sessions?.list?.getSnapshot?.()?.current;
|
|
1356
|
+
if (ownedSessionId(initialSession)) markCleaningSession(initialSession);
|
|
1335
1357
|
clearStaleDefaultDraft();
|
|
1336
1358
|
// DSH 会在插件 apply() 之后异步恢复 composer draft。在首屏稳定期内
|
|
1337
1359
|
// 监听 DOM 变化并仅清理完全匹配本插件默认文案,或同时命中
|
|
@@ -1356,6 +1378,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1356
1378
|
const nextSessionId = selected.current;
|
|
1357
1379
|
if (activeCleaningSessionId && nextSessionId !== activeCleaningSessionId) {
|
|
1358
1380
|
leaveSubsystem();
|
|
1381
|
+
}
|
|
1382
|
+
if (!activeCleaningSessionId && ownedSessionId(nextSessionId)) {
|
|
1383
|
+
markCleaningSession(nextSessionId);
|
|
1359
1384
|
} else if (!activeCleaningSessionId) {
|
|
1360
1385
|
clearStaleDefaultDraft();
|
|
1361
1386
|
}
|
|
@@ -1403,8 +1428,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1403
1428
|
}
|
|
1404
1429
|
lines.push(`清洗要求:${cleanLabels.length ? cleanLabels.join('、') : '仅解析,不自动修改'}。`);
|
|
1405
1430
|
lines.push(`需要补全的字段 / 维度:${enrichLabels.length ? enrichLabels.join('、') : '不调用外部补全,仅输出本地清洗结果'}。`);
|
|
1431
|
+
if (mode === 'excel' && config.headers?.length) {
|
|
1432
|
+
const pairs = config.mappings || [];
|
|
1433
|
+
lines.push(`原表共 ${config.headers.length} 列。新文件原列补空映射:${pairs.map((item) => `「${item.sourceField}」←${resultFieldLabel(item.targetField)}`).join(';') || '暂无'}。`);
|
|
1434
|
+
const skipped = config.headers.filter((header) => !pairs.some((item) => item.sourceField === header));
|
|
1435
|
+
if (skipped.length) lines.push(`暂不补全的原列:${skipped.join('、')};保留原值,不猜测或用相近指标替代。`);
|
|
1436
|
+
const added = (config.enrichmentKeys || []).filter((id) => !pairs.some((item) => item.targetField === id));
|
|
1437
|
+
lines.push(`仅补已确认原列的空白单元格,保留非空值(含 0)。多个原列可共用一个补全字段,查询按主体与工具去重。额外新增字段:${added.map(resultFieldLabel).join('、') || '无'}。`);
|
|
1438
|
+
}
|
|
1406
1439
|
lines.push(`处理规则:优先使用${anchorLabels.length ? anchorLabels.join('、') : '企业名称、统一社会信用代码或注册号'}精确匹配;${config.matchRules?.manualReviewAmbiguous === false ? '精确匹配失败时直接输出未匹配记录' : '精确匹配失败时再进入模糊候选,存在多个候选必须暂停并让我确认,不得默认选择第一项'}。`);
|
|
1407
|
-
lines.push('
|
|
1440
|
+
lines.push('发送即确认执行工作台已配置的范围,不再重复确认额度或写回。企查查连接、套餐额度和费用均由当前用户自己的账号承担。缺失或无权限字段请留空并标记原因,不得编造。');
|
|
1441
|
+
lines.push('交付方式:Host 自动生成新的清洗补全结果 XLSX,在工作台下载。不得修改上传源文件,不索要本地路径,不使用 Bash 或另行编写导出脚本。');
|
|
1408
1442
|
lines.push('完成后保留来源原值、标准主体、匹配状态与字段来源,并提供结果和待复核清单的导出。');
|
|
1409
1443
|
return lines.join('\n\n');
|
|
1410
1444
|
}
|
|
@@ -1463,6 +1497,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1463
1497
|
企业地址: ['registered_address', 'mailing_address'], 公司地址: ['registered_address', 'mailing_address'],
|
|
1464
1498
|
网址: ['contact_official_website'], website: ['contact_official_website'],
|
|
1465
1499
|
开业时间: ['establish_date'], 开业日期: ['establish_date'],
|
|
1500
|
+
企业状态: ['reg_status'], 所属行业: ['industry_category', 'qcc_industry'],
|
|
1466
1501
|
行业: ['industry_category', 'qcc_industry'],
|
|
1467
1502
|
法人: ['legal_rep'], 邮箱: ['contact_preferred_email'], email: ['contact_preferred_email'],
|
|
1468
1503
|
};
|
|
@@ -1471,7 +1506,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1471
1506
|
}
|
|
1472
1507
|
function mappingRecommendations(headers, fields = [...ENRICHMENT_OPTIONS, ...INPUT_ONLY_MAPPING_OPTIONS]) {
|
|
1473
1508
|
const rows = (Array.isArray(headers) ? headers : []).map((sourceField) => {
|
|
1474
|
-
const label = normalizeMappingLabel(String(sourceField).replace(/(重复列 \d+)$/, ''));
|
|
1509
|
+
const label = normalizeMappingLabel(String(sourceField).replace(/(重复列 \d+)$/, '').replace(/\s*YYYY[-/]MM[-/]DD\s*$/i, ''));
|
|
1475
1510
|
let candidates = fields.filter(([id, name]) => [id, name].some((value) => normalizeMappingLabel(value) === label));
|
|
1476
1511
|
let reason = '标准字段名匹配';
|
|
1477
1512
|
if (!candidates.length) {
|
|
@@ -1506,10 +1541,23 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1506
1541
|
return output;
|
|
1507
1542
|
}
|
|
1508
1543
|
|
|
1544
|
+
const isAnchorField = (id) => ['company_name', 'credit_no', 'reg_no'].includes(id);
|
|
1545
|
+
function mappedOutputFields(mappings) {
|
|
1546
|
+
return [...new Set(mappings.map((item) => item.targetField))].filter((id) => ENRICHMENT_OPTIONS.some(([key]) => key === id));
|
|
1547
|
+
}
|
|
1548
|
+
function syncMappedSelection(previous, next, selected) {
|
|
1549
|
+
const old = new Set(mappedOutputFields(previous));
|
|
1550
|
+
return [...new Set([...selected.filter((id) => !old.has(id)), ...mappedOutputFields(next)])];
|
|
1551
|
+
}
|
|
1552
|
+
function updateColumnMapping(mappings, sourceField, targetField) {
|
|
1553
|
+
if (isAnchorField(targetField) && mappings.some((item) => item.sourceField !== sourceField && item.targetField === targetField)) return mappings;
|
|
1554
|
+
return [...mappings.filter((item) => item.sourceField !== sourceField), ...(targetField ? [{ sourceField, targetField }] : [])];
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1509
1557
|
function MappingPicker({ sourceField, mappings, groups, recommendation, onChange }) {
|
|
1510
1558
|
const [query, setQuery] = react.useState('');
|
|
1511
1559
|
const value = mappings.find((item) => item.sourceField === sourceField)?.targetField || '';
|
|
1512
|
-
const occupied = (id) => mappings.some((item) => item.sourceField !== sourceField && item.targetField === id);
|
|
1560
|
+
const occupied = (id) => isAnchorField(id) && mappings.some((item) => item.sourceField !== sourceField && item.targetField === id);
|
|
1513
1561
|
const fields = groups.flatMap(([, , items]) => items);
|
|
1514
1562
|
const label = fields.find(([id]) => id === value)?.[1] || '不参与匹配 / 补全';
|
|
1515
1563
|
const matches = (id, name) => normalizeMappingLabel([id, name, ...(MAPPING_ALIASES[id] || [])].join(' ')).includes(normalizeMappingLabel(query));
|
|
@@ -1535,7 +1583,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1535
1583
|
),
|
|
1536
1584
|
query && !fields.some(([id, name]) => matches(id, name)) ? h('p', { className: 'dcAgentHint', role: 'status' }, '无匹配字段;当前选择保持不变。') : null,
|
|
1537
1585
|
),
|
|
1538
|
-
h('small', { className: '
|
|
1586
|
+
h('small', { className: 'dcAgentMappingStatus', 'data-status': value ? 'confirmed' : recommendation.candidates.length ? 'review' : 'unmatched',
|
|
1587
|
+
style: { display: 'block', padding: '6px 0' } },
|
|
1588
|
+
value ? `${recommendation.automatic && recommendation.candidates[0]?.[0] === value ? '自动通过' : '已确认'}:${label};可手动调整` : recommendation.candidates.length ? `待人工确认:${recommendation.conflict ? '重复列可确认使用同一补全字段;主体标识只选一列' : recommendation.reason}` : '未匹配:保留原列,可手动选择字段'),
|
|
1539
1589
|
!value && recommendation.candidates.length ? h('div', { className: 'dcAgentRow', 'aria-label': `${sourceField} 推荐映射` }, recommendation.candidates.map(([id, name]) =>
|
|
1540
1590
|
h('button', { key: id, type: 'button', className: 'dcAgentButton', disabled: occupied(id), onClick: () => onChange(sourceField, id) }, `推荐:${name}${occupied(id) ? '(已占用)' : ''}`))) : null,
|
|
1541
1591
|
),
|
|
@@ -1546,8 +1596,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1546
1596
|
const sourceHeaders = [...new Set(headers)];
|
|
1547
1597
|
const selected = [...new Set(fieldSelection)];
|
|
1548
1598
|
const pairs = mappings.filter((item) => sourceHeaders.includes(item.sourceField) && selected.includes(item.targetField)
|
|
1549
|
-
&& mappings.filter((other) => other.sourceField === item.sourceField).length === 1
|
|
1550
|
-
&& mappings.filter((other) => other.targetField === item.targetField).length === 1);
|
|
1599
|
+
&& mappings.filter((other) => other.sourceField === item.sourceField).length === 1);
|
|
1551
1600
|
const hidden = new Set(pairs.filter((item) => item.sourceField !== item.targetField && !sourceHeaders.includes(item.targetField)).map((item) => item.targetField));
|
|
1552
1601
|
const blank = (value) => value == null || (typeof value === 'string' && value.trim() === '');
|
|
1553
1602
|
const output = rows.map((row) => {
|
|
@@ -1636,11 +1685,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1636
1685
|
function cacheWorkflowTask(actions, sessionId, task) {
|
|
1637
1686
|
if (!task) return null;
|
|
1638
1687
|
if (sessionId) workflowTaskBySession.set(String(sessionId), task);
|
|
1688
|
+
bindTask(sessionId, task.id);
|
|
1639
1689
|
actions.setWorkflowTask(task);
|
|
1640
1690
|
if (task.title) actions.setTaskTitle(task.title);
|
|
1641
1691
|
if (Array.isArray(task.objectives)) actions.setObjectives(task.objectives);
|
|
1692
|
+
if (Array.isArray(task.mappings)) actions.setMappings(task.mappings);
|
|
1642
1693
|
if (Array.isArray(task.fieldSelection)) actions.setFieldSelection(task.fieldSelection);
|
|
1643
|
-
if (Array.isArray(task.mappings) && task.mappings.length) actions.setMappings(task.mappings);
|
|
1644
1694
|
return task;
|
|
1645
1695
|
}
|
|
1646
1696
|
|
|
@@ -1666,10 +1716,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1666
1716
|
return cacheWorkflowTask(actions, sessionId, response.task);
|
|
1667
1717
|
}
|
|
1668
1718
|
|
|
1669
|
-
async function ensureWorkflowTask(actions, sessionId, draft = {}) {
|
|
1719
|
+
async function ensureWorkflowTask(actions, sessionId, draft = {}, restoreStage = true) {
|
|
1670
1720
|
const key = String(sessionId || 'unassigned');
|
|
1671
1721
|
const cached = workflowTaskBySession.get(key);
|
|
1672
1722
|
if (cached) return cacheWorkflowTask(actions, sessionId, cached);
|
|
1723
|
+
const saved = readTaskBindings()[key];
|
|
1724
|
+
if (ownedSessionId(key) && /^dcw-[a-zA-Z0-9-]+$/.test(saved || '')) {
|
|
1725
|
+
const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(saved)}`);
|
|
1726
|
+
const task = cacheWorkflowTask(actions, sessionId, response.task);
|
|
1727
|
+
if (restoreStage) actions.setStep(task.stage || 'upload');
|
|
1728
|
+
return task;
|
|
1729
|
+
}
|
|
1673
1730
|
return createWorkflowTask(actions, sessionId, draft);
|
|
1674
1731
|
}
|
|
1675
1732
|
|
|
@@ -1722,6 +1779,69 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1722
1779
|
return cacheWorkflowTask(actions, sessionId, response.task);
|
|
1723
1780
|
}
|
|
1724
1781
|
|
|
1782
|
+
async function stageConfirmedWorkflow(actions, sessionId, expected = null) {
|
|
1783
|
+
const host = await requestJson('/data-cleaning/api/g5/capabilities');
|
|
1784
|
+
if (host?.workflowExecutionVersion !== 1) throw new Error('页面与 Host 版本不一致。请保存任务后升级并完整重启 DSH;当前 Host 不支持自动生成结果文件。');
|
|
1785
|
+
let task = await ensureWorkflowTask(actions, sessionId);
|
|
1786
|
+
if (expected) {
|
|
1787
|
+
const sameSet = (a, b) => JSON.stringify([...(a || [])].sort()) === JSON.stringify([...(b || [])].sort());
|
|
1788
|
+
if (!sameSet(task.fieldSelection, expected.enrichmentKeys)
|
|
1789
|
+
|| (expected.mode === 'excel' && (task.source?.fileName !== expected.fileName
|
|
1790
|
+
|| task.source?.rowCount !== expected.entryCount
|
|
1791
|
+
|| JSON.stringify(task.mappings) !== JSON.stringify(expected.mappings)))) {
|
|
1792
|
+
throw new Error('工作台保存的范围与向导不一致,请重试确认;未生成执行凭证。');
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
const runtime = runtimeFor(task.id, false);
|
|
1796
|
+
if (!task.fieldSelection?.length) throw new Error('当前未选择企查查补全字段。请在规则页选择补全字段,或使用工作台本地清洗与结果导出;不会发起企查查调用。');
|
|
1797
|
+
if (!runtime?.rows?.length) throw new Error('Host 名单尚未载入,请先解析数据。');
|
|
1798
|
+
if (task.state === 'uploaded') task = await workflowAction(actions, sessionId, task, 'rules', {
|
|
1799
|
+
objectives: task.objectives, fieldSelection: task.fieldSelection, mappings: task.mappings, matchRules: task.matchRules,
|
|
1800
|
+
});
|
|
1801
|
+
if (task.state === 'rules_confirmed') task = await workflowAction(actions, sessionId, task, 'quality', {
|
|
1802
|
+
summary: qualitySummaryFor(runtime.rows, task.mappings),
|
|
1803
|
+
});
|
|
1804
|
+
const response = await requestJson('/data-cleaning/api/g5/commands', 'POST', {
|
|
1805
|
+
kind: 'enrich', workflowOwned: true, expectedRevision: task.revision, taskId: task.id,
|
|
1806
|
+
rows: runtime.rows, headers: runtime.headers, fieldSelection: task.fieldSelection,
|
|
1807
|
+
});
|
|
1808
|
+
if (!response.command?.commandId || !response.command?.prompt) throw new Error('Host 未返回执行凭证;未发送任务。');
|
|
1809
|
+
return response.command;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
async function refreshHostedTask(actions, sessionId, taskId, run, navigate = true) {
|
|
1813
|
+
const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(taskId)}`);
|
|
1814
|
+
const selectedTask = workflowTaskBySession.get(String(sessionId));
|
|
1815
|
+
if (selectedTask && selectedTask.id !== taskId) return response.task;
|
|
1816
|
+
if (selectedTask && selectedTask.revision > response.task.revision) return selectedTask;
|
|
1817
|
+
workflowTaskBySession.set(String(sessionId), response.task);
|
|
1818
|
+
bindTask(sessionId, taskId);
|
|
1819
|
+
if (isCleaningSession(sessionId)) {
|
|
1820
|
+
if (!run && response.task.qccRunId && ['review_required', 'partial'].includes(response.task.state)) {
|
|
1821
|
+
try { run = await requestJson(`/data-cleaning/api/g5/run/${encodeURIComponent(response.task.qccRunId)}`); }
|
|
1822
|
+
catch { actions.setError('Host 中的候选或重试数据已失效。已有结果仍可下载;重新查询需新建任务,不会自动重复扣量。'); }
|
|
1823
|
+
}
|
|
1824
|
+
if (!isCleaningSession(sessionId) || workflowTaskBySession.get(String(sessionId))?.id !== taskId) return response.task;
|
|
1825
|
+
cacheWorkflowTask(actions, sessionId, response.task);
|
|
1826
|
+
if (run) {
|
|
1827
|
+
actions.setQccRun(run);
|
|
1828
|
+
const runtime = runtimeFor(taskId);
|
|
1829
|
+
runtime.resultRows.qcc = run.rows;
|
|
1830
|
+
if (!runtime.rows.length) { runtime.rows = run.rows; runtime.headers = response.task.source?.headers || run.headers || []; }
|
|
1831
|
+
}
|
|
1832
|
+
if (navigate) actions.setStep(response.task.stage || 'upload');
|
|
1833
|
+
}
|
|
1834
|
+
return response.task;
|
|
1835
|
+
}
|
|
1836
|
+
function watchHostedCommand(actions, sessionId, command) {
|
|
1837
|
+
void waitForQccCommand(command.commandId, 1800).then(async (completed) => {
|
|
1838
|
+
await refreshHostedTask(actions, sessionId, command.taskId, completed.run);
|
|
1839
|
+
if (completed.state === 'failed') throw new Error(completed.error?.message || 'Host 执行失败,请在工作台查看状态。');
|
|
1840
|
+
}).catch((error) => {
|
|
1841
|
+
if (isCleaningSession(sessionId)) actions.setError(error.message || String(error));
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1725
1845
|
/** 工作台 store:入口按钮与工作台共享 open,并保存当前 taskId 的可持久化 UI 元数据。 */
|
|
1726
1846
|
function createWorkbenchStore() {
|
|
1727
1847
|
return defineStore({
|
|
@@ -1776,11 +1896,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1776
1896
|
setWorkflowTask: (draft, value) => { draft.workflowTask = value ?? null; },
|
|
1777
1897
|
setWorkflowTasks: (draft, value) => { draft.workflowTasks = Array.isArray(value) ? value : []; },
|
|
1778
1898
|
setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
|
|
1779
|
-
setMappings: (draft, value) => {
|
|
1899
|
+
setMappings: (draft, value) => {
|
|
1900
|
+
const next = Array.isArray(value) ? value : [];
|
|
1901
|
+
draft.fieldSelection = syncMappedSelection(draft.mappings, next, draft.fieldSelection);
|
|
1902
|
+
draft.mappings = next;
|
|
1903
|
+
},
|
|
1780
1904
|
setMapping: (draft, sourceField, targetField) => {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
if (targetField) mappings.push({ sourceField, targetField });
|
|
1905
|
+
const mappings = updateColumnMapping(draft.mappings, sourceField, targetField);
|
|
1906
|
+
draft.fieldSelection = syncMappedSelection(draft.mappings, mappings, draft.fieldSelection);
|
|
1784
1907
|
draft.mappings = mappings;
|
|
1785
1908
|
},
|
|
1786
1909
|
setObjectives: (draft, value) => { draft.objectives = Array.isArray(value) ? value : []; },
|
|
@@ -1915,6 +2038,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
1915
2038
|
async function startCleaningSession(ctx) {
|
|
1916
2039
|
const workspace = ctx.workspaces.list.getSnapshot();
|
|
1917
2040
|
const current = ctx.sessions.list.getSnapshot().current;
|
|
2041
|
+
if (ownedSessionId(current)) {
|
|
2042
|
+
markCleaningSession(current);
|
|
2043
|
+
requestWorkbenchOpen(workflowTaskBySession.get(String(current))?.stage || 'upload', current);
|
|
2044
|
+
return current;
|
|
2045
|
+
}
|
|
1918
2046
|
const items = Array.isArray(workspace.items) ? workspace.items : [];
|
|
1919
2047
|
const currentWorkspace = current === undefined
|
|
1920
2048
|
? undefined
|
|
@@ -2011,6 +2139,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2011
2139
|
const WORKBENCH_OPEN_EVENT = 'dsh:data-cleaning-workbench-open';
|
|
2012
2140
|
const WORKBENCH_DATASET_EVENT = 'dsh:data-cleaning-workbench-dataset';
|
|
2013
2141
|
const WORKBENCH_DRAFT_EVENT = 'dsh:data-cleaning-workbench-draft';
|
|
2142
|
+
const WORKBENCH_PREPARE_EVENT = 'dsh:data-cleaning-workbench-prepare';
|
|
2014
2143
|
|
|
2015
2144
|
// session scope 不能复用 root scope 的 store handle。DSH 当前会分别物化 root/session
|
|
2016
2145
|
// 插槽组件,因此以 document 事件跨 scope 通知;闭包引用仅作为无 DOM 测试降级。
|
|
@@ -2028,6 +2157,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2028
2157
|
if (rootWorkbenchActions) openWorkbench(rootWorkbenchActions, step, sessionId);
|
|
2029
2158
|
}
|
|
2030
2159
|
|
|
2160
|
+
function requestPreparedWorkflow(sessionId, config) {
|
|
2161
|
+
return new Promise((resolve, reject) => {
|
|
2162
|
+
const event = document.createEvent('CustomEvent');
|
|
2163
|
+
event.initCustomEvent(WORKBENCH_PREPARE_EVENT, false, true, { sessionId, config, resolve, reject });
|
|
2164
|
+
if (document.dispatchEvent(event)) reject(new Error('工作台尚未就绪,请稍后重试。'));
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2031
2168
|
/** 把提示词生成器解析出的完整表格交给 root 工作台,不把整表塞进模型上下文。 */
|
|
2032
2169
|
function requestWorkbenchDataset(result, sessionId, source) {
|
|
2033
2170
|
if (typeof document !== 'undefined'
|
|
@@ -2055,6 +2192,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2055
2192
|
if (rootWorkbenchActions) {
|
|
2056
2193
|
rootWorkbenchActions.setTaskTitle(draft.title);
|
|
2057
2194
|
rootWorkbenchActions.setObjectives(draft.objectives);
|
|
2195
|
+
if (draft.mappings) rootWorkbenchActions.setMappings(draft.mappings);
|
|
2058
2196
|
rootWorkbenchActions.setFieldSelection(draft.fieldSelection);
|
|
2059
2197
|
}
|
|
2060
2198
|
}
|
|
@@ -2456,6 +2594,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2456
2594
|
const [entries, setEntries] = react.useState([]);
|
|
2457
2595
|
const [entryCount, setEntryCount] = react.useState(0);
|
|
2458
2596
|
const [spreadsheetFileName, setSpreadsheetFileName] = react.useState('');
|
|
2597
|
+
const [spreadsheetData, setSpreadsheetData] = react.useState(null);
|
|
2598
|
+
const [spreadsheetMappings, setSpreadsheetMappings] = react.useState([]);
|
|
2459
2599
|
const [imageFileName, setImageFileName] = react.useState('');
|
|
2460
2600
|
const [imageSizeBytes, setImageSizeBytes] = react.useState(0);
|
|
2461
2601
|
const [imageAttachment, setImageAttachment] = react.useState(null);
|
|
@@ -2593,14 +2733,19 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2593
2733
|
setEntries(extracted);
|
|
2594
2734
|
setEntryCount(Number.isFinite(result.rowCount) ? result.rowCount : (Array.isArray(result.rows) ? result.rows.length : extracted.length));
|
|
2595
2735
|
setSpreadsheetFileName(file.name);
|
|
2736
|
+
setSpreadsheetData(result);
|
|
2737
|
+
const suggested = guessMappings(result.headers || []);
|
|
2738
|
+
setSpreadsheetMappings(suggested);
|
|
2739
|
+
setEnrichmentKeys(mappedOutputFields(suggested));
|
|
2740
|
+
setAnchorKeys(suggested.filter((item) => isAnchorField(item.targetField)).map((item) => item.targetField));
|
|
2596
2741
|
requestWorkbenchDataset(result, sessionId, {
|
|
2597
2742
|
type: result.fmt || 'xlsx', fileName: file.name, sizeBytes: file.size || 0,
|
|
2598
2743
|
});
|
|
2599
2744
|
} catch (fileError) {
|
|
2745
|
+
setSpreadsheetData(null); setSpreadsheetMappings([]); setSpreadsheetFileName(''); setEntries([]); setEntryCount(0);
|
|
2600
2746
|
setError(fileError instanceof Error ? fileError.message : String(fileError));
|
|
2601
2747
|
} finally {
|
|
2602
2748
|
setBusy(false);
|
|
2603
|
-
if (event.target) event.target.value = '';
|
|
2604
2749
|
}
|
|
2605
2750
|
};
|
|
2606
2751
|
const selectedEntries = () => mode === 'text'
|
|
@@ -2626,10 +2771,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2626
2771
|
enrichmentKeys,
|
|
2627
2772
|
anchorKeys,
|
|
2628
2773
|
matchRules,
|
|
2774
|
+
...(mode === 'excel' ? { headers: spreadsheetData?.headers || [], mappings: spreadsheetMappings } : {}),
|
|
2629
2775
|
imageCommandId: imageCommand?.commandId ?? '',
|
|
2630
2776
|
imageState: imageCommand?.state ?? null,
|
|
2631
2777
|
});
|
|
2632
|
-
const generatePrompt = () => {
|
|
2778
|
+
const generatePrompt = async () => {
|
|
2633
2779
|
const sourceError = validateSource();
|
|
2634
2780
|
if (sourceError) { setError(sourceError); return; }
|
|
2635
2781
|
if (!anchorKeys.length) { setError('请至少选择一个企业主体匹配主键。'); return; }
|
|
@@ -2649,25 +2795,33 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2649
2795
|
removeImage(sessionId, imageAttachment);
|
|
2650
2796
|
setImageAttachment(null);
|
|
2651
2797
|
}
|
|
2652
|
-
const prompt = buildTaskPrompt(config);
|
|
2653
|
-
try {
|
|
2654
|
-
inputActions.setDraft(prompt);
|
|
2655
|
-
const updatedInput = getInputSnapshot?.(sessionId);
|
|
2656
|
-
if (updatedInput && updatedInput.draft !== prompt) throw new Error('当前对话尚未接收任务说明,请稍后重试回填。');
|
|
2657
|
-
} catch (draftError) {
|
|
2658
|
-
setError(draftError instanceof Error ? draftError.message : String(draftError));
|
|
2659
|
-
return;
|
|
2660
|
-
}
|
|
2661
2798
|
if (mode === 'text') {
|
|
2662
2799
|
requestWorkbenchDataset(entriesToDataset(config.entries), sessionId, { type: 'text', fileName: '' });
|
|
2800
|
+
} else if (mode === 'excel' && spreadsheetData) {
|
|
2801
|
+
requestWorkbenchDataset(spreadsheetData, sessionId, { type: spreadsheetData.fmt || 'xlsx', fileName: spreadsheetFileName });
|
|
2663
2802
|
}
|
|
2664
2803
|
requestWorkbenchDraft({
|
|
2665
2804
|
title: `企业数据清洗补全任务 · ${config.entryCount || '待解析'} 条`,
|
|
2666
2805
|
objectives: cleaningKeys,
|
|
2667
2806
|
fieldSelection: enrichmentKeys,
|
|
2668
2807
|
matchRules,
|
|
2808
|
+
...(mode === 'excel' ? { mappings: spreadsheetMappings } : {}),
|
|
2669
2809
|
source: { type: mode === 'excel' ? 'xlsx' : mode, fileName: config.fileName || '', rowCount: config.entryCount || 0 },
|
|
2670
2810
|
}, sessionId);
|
|
2811
|
+
setBusy(true);
|
|
2812
|
+
try {
|
|
2813
|
+
let prompt = buildTaskPrompt(config);
|
|
2814
|
+
if (mode !== 'image' || imageCommand?.state === 'completed') {
|
|
2815
|
+
const command = await requestPreparedWorkflow(sessionId, config);
|
|
2816
|
+
prompt = command.prompt + '\n\n' + buildTaskPrompt(config);
|
|
2817
|
+
}
|
|
2818
|
+
inputActions.setDraft(prompt);
|
|
2819
|
+
const updatedInput = getInputSnapshot?.(sessionId);
|
|
2820
|
+
if (updatedInput && updatedInput.draft !== prompt) throw new Error('当前对话尚未接收任务说明,请稍后重试回填。');
|
|
2821
|
+
} catch (draftError) {
|
|
2822
|
+
setError(draftError instanceof Error ? draftError.message : String(draftError));
|
|
2823
|
+
return;
|
|
2824
|
+
} finally { setBusy(false); }
|
|
2671
2825
|
setError(null);
|
|
2672
2826
|
setOpen(false);
|
|
2673
2827
|
setTimeout(() => {
|
|
@@ -2737,6 +2891,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2737
2891
|
event.preventDefault();
|
|
2738
2892
|
discardCurrentImage();
|
|
2739
2893
|
setMode('text'); setRawText(''); setEntries([]); setEntryCount(0); setSpreadsheetFileName('');
|
|
2894
|
+
setSpreadsheetData(null); setSpreadsheetMappings([]);
|
|
2740
2895
|
setCleaningKeys(DEFAULT_CLEANING_KEYS); setEnrichmentKeys(DEFAULT_ENRICHMENT_KEYS);
|
|
2741
2896
|
setAnchorKeys(['company_name', 'credit_no']);
|
|
2742
2897
|
setPromptMatchRules({ normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true });
|
|
@@ -2776,7 +2931,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2776
2931
|
const groupIds = new Set(fields.map(([id]) => id));
|
|
2777
2932
|
const selected = new Set(enrichmentKeys);
|
|
2778
2933
|
for (const id of groupIds) {
|
|
2779
|
-
if (enabledGroup) selected.add(id); else selected.delete(id);
|
|
2934
|
+
if (enabledGroup || (mode === 'excel' && mappedOutputFields(spreadsheetMappings).includes(id))) selected.add(id); else selected.delete(id);
|
|
2780
2935
|
}
|
|
2781
2936
|
const catalogOrder = FIELD_GROUPS.flatMap(([, , groupFields]) => groupFields.map(([id]) => id));
|
|
2782
2937
|
setEnrichmentKeys(catalogOrder.filter((id) => selected.has(id)));
|
|
@@ -2884,11 +3039,30 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2884
3039
|
? h('b', null, `${mode === 'excel' ? spreadsheetFileName : imageFileName}${mode === 'excel' ? ` · ${entryCount} 条` : ' · 已附加'}`)
|
|
2885
3040
|
: null,
|
|
2886
3041
|
),
|
|
3042
|
+
mode === 'excel' && spreadsheetData ? h(DatasetReview, {
|
|
3043
|
+
rows: spreadsheetData.rows || [], headers: spreadsheetData.headers || [],
|
|
3044
|
+
title: `已载入:${spreadsheetFileName} · ${entryCount} 条 / ${spreadsheetData.headers?.length || 0} 列`,
|
|
3045
|
+
expectedCount: entryCount, onError: setError,
|
|
3046
|
+
}) : null,
|
|
3047
|
+
mode === 'excel' && spreadsheetData ? h('p', { className: 'dcAgentHint' },
|
|
3048
|
+
`原表空白单元格:${(spreadsheetData.rows || []).reduce((n, row) => n + (spreadsheetData.headers || []).filter((key) => row[key] == null || String(row[key]).trim() === '').length, 0)} 个。下一步确认原列映射;文件已保存在本次任务中。`) : null,
|
|
2887
3049
|
) : null,
|
|
2888
3050
|
wizardStep === 2 ? h('div', { className: 'dcAgentWizardPane' },
|
|
2889
3051
|
h('h4', null, '确认匹配规则'),
|
|
2890
3052
|
h('p', null, '强标识优先;名称定位不明确时生成候选,不自动写回。'),
|
|
2891
|
-
|
|
3053
|
+
mode === 'excel' ? h('div', null,
|
|
3054
|
+
h('p', { className: 'dcAgentHint' }, '绿色=自动通过 / 已确认;黄色=待人工确认;红色=未匹配。这里是原列与字段的映射,不是企业查询结果。确认映射即选入补全范围,无需再重复勾选。'),
|
|
3055
|
+
mappingRecommendations(spreadsheetData?.headers || []).map((recommendation) => h(MappingPicker, {
|
|
3056
|
+
key: recommendation.sourceField, sourceField: recommendation.sourceField, mappings: spreadsheetMappings,
|
|
3057
|
+
groups: [...FIELD_GROUPS, ['input-only', '输入校验字段', INPUT_ONLY_MAPPING_OPTIONS]], recommendation,
|
|
3058
|
+
onChange: (sourceField, targetField) => {
|
|
3059
|
+
const next = updateColumnMapping(spreadsheetMappings, sourceField, targetField);
|
|
3060
|
+
setEnrichmentKeys(syncMappedSelection(spreadsheetMappings, next, enrichmentKeys));
|
|
3061
|
+
setSpreadsheetMappings(next);
|
|
3062
|
+
setAnchorKeys(next.filter((item) => isAnchorField(item.targetField)).map((item) => item.targetField));
|
|
3063
|
+
},
|
|
3064
|
+
})),
|
|
3065
|
+
) : h('div', { className: 'dcAgentPromptChoices' }, choice(MATCH_ANCHOR_OPTIONS, anchorKeys, setAnchorKeys)),
|
|
2892
3066
|
h('div', { className: 'dcAgentPromptRules' },
|
|
2893
3067
|
[['normalizeNames', '匹配前统一企业名称格式'], ['preferCreditNo', '有信用代码时优先精确匹配'], ['deduplicate', '匹配前合并重复主体'], ['manualReviewAmbiguous', '多候选必须人工确认']].map(([key, label]) => h('label', { key },
|
|
2894
3068
|
h('input', { type: 'checkbox', checked: matchRules[key], onChange: (event) => setPromptMatchRules({ ...matchRules, [key]: event.target.checked }) }), label,
|
|
@@ -2897,11 +3071,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2897
3071
|
) : null,
|
|
2898
3072
|
wizardStep === 3 ? h('div', { className: 'dcAgentWizardPane' },
|
|
2899
3073
|
h('h4', null, '选择清洗目标和补全字段'),
|
|
2900
|
-
h('p', null,
|
|
3074
|
+
h('p', null, `共 ${ENRICHMENT_OPTIONS.length} 个一企一行字段。最终可用字段以当前用户连接的 QCC MCP 能力为准。`),
|
|
3075
|
+
mode === 'excel' ? h('p', { className: 'dcAgentHint' }, `已按确认映射选择 ${mappedOutputFields(spreadsheetMappings).length} 个原列补全字段,无需重复选择。以下仅用于追加原表之外的字段;默认只补空白、保留原值。`) : null,
|
|
2901
3076
|
h('div', { className: 'dcAgentPromptGroup' },
|
|
2902
3077
|
h('b', null, '清洗目标'),
|
|
2903
3078
|
h('div', { className: 'dcAgentPromptChoices' }, choice(CLEANING_OPTIONS, cleaningKeys, setCleaningKeys)),
|
|
2904
3079
|
),
|
|
3080
|
+
h('details', { className: 'dcAgentExtraFields', open: mode !== 'excel' },
|
|
3081
|
+
h('summary', null, `可选:追加补全字段(共 ${ENRICHMENT_OPTIONS.length} 项)`),
|
|
2905
3082
|
h('label', { className: 'dcAgentPromptField' },
|
|
2906
3083
|
h('span', null, `查找补全字段 · 已选 ${enrichmentKeys.length}`),
|
|
2907
3084
|
h('input', { type: 'search', className: 'dcAgentField', value: fieldQuery, 'aria-label': '查找补全字段',
|
|
@@ -2920,10 +3097,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
2920
3097
|
),
|
|
2921
3098
|
h('div', { className: 'dcAgentPromptFieldGrid' }, visibleCatalogFields([groupId, label, fields, sourceTool], fieldQuery).map(([id, fieldLabel]) => h('label', {
|
|
2922
3099
|
key: id, className: `dcAgentPromptChoice${enrichmentKeys.includes(id) ? ' is-selected' : ''}`,
|
|
2923
|
-
}, h('input', { type: 'checkbox', checked: enrichmentKeys.includes(id),
|
|
3100
|
+
}, h('input', { type: 'checkbox', checked: enrichmentKeys.includes(id), disabled: mode === 'excel' && mappedOutputFields(spreadsheetMappings).includes(id),
|
|
2924
3101
|
onChange: (event) => setEnrichmentKeys((current) => event.target.checked ? [...new Set([...current, id])] : current.filter((key) => key !== id)),
|
|
2925
3102
|
}), fieldLabel))),
|
|
2926
3103
|
)),
|
|
3104
|
+
),
|
|
2927
3105
|
) : null,
|
|
2928
3106
|
wizardStep === 4 ? h('div', { className: 'dcAgentWizardPane' },
|
|
2929
3107
|
h('h4', null, '确认任务描述'),
|
|
@@ -3118,6 +3296,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3118
3296
|
?? 'name';
|
|
3119
3297
|
actions.setNameField(guessedNameField);
|
|
3120
3298
|
actions.setMappings(mappings);
|
|
3299
|
+
if (result.fmt !== 'text') actions.setFieldSelection(mappedOutputFields(mappings));
|
|
3121
3300
|
actions.setDataset({
|
|
3122
3301
|
fmt: result.fmt ?? 'csv',
|
|
3123
3302
|
headers: runtime.headers,
|
|
@@ -3128,7 +3307,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3128
3307
|
}
|
|
3129
3308
|
|
|
3130
3309
|
async function persistParsedWorkflow(actions, sessionId, result, source = {}, task) {
|
|
3131
|
-
|
|
3310
|
+
const importedMappings = guessMappings(result.headers || []);
|
|
3311
|
+
const importedDraft = { mappings: importedMappings,
|
|
3312
|
+
fieldSelection: result.fmt === 'text' ? DEFAULT_ENRICHMENT_KEYS : mappedOutputFields(importedMappings) };
|
|
3313
|
+
let current = await ensureEditableWorkflowTask(actions, sessionId, importedDraft, task);
|
|
3132
3314
|
const stagingKey = `session:${sessionId || 'unassigned'}`;
|
|
3133
3315
|
moveRuntime(stagingKey, current.id);
|
|
3134
3316
|
// handleParse 可能先把新数据写入旧 task runtime。无论是否新建 taskId,
|
|
@@ -3154,6 +3336,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3154
3336
|
},
|
|
3155
3337
|
});
|
|
3156
3338
|
}
|
|
3339
|
+
if (current.state === 'uploaded') current = await updateWorkflowTask(actions, sessionId, current, importedDraft);
|
|
3157
3340
|
return current;
|
|
3158
3341
|
}
|
|
3159
3342
|
|
|
@@ -3287,6 +3470,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3287
3470
|
});
|
|
3288
3471
|
}
|
|
3289
3472
|
};
|
|
3473
|
+
const handlePrepare = (event) => {
|
|
3474
|
+
event.preventDefault();
|
|
3475
|
+
const { sessionId, config, resolve, reject } = event.detail;
|
|
3476
|
+
queueWorkflowOperation(sessionId, () => stageConfirmedWorkflow(actions, sessionId, config)).then((command) => {
|
|
3477
|
+
watchHostedCommand(actions, sessionId, command);
|
|
3478
|
+
resolve(command);
|
|
3479
|
+
}, reject);
|
|
3480
|
+
};
|
|
3290
3481
|
const handleDraft = (event) => {
|
|
3291
3482
|
event?.preventDefault?.();
|
|
3292
3483
|
const detail = event?.detail ?? {};
|
|
@@ -3294,6 +3485,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3294
3485
|
if (detail.sessionId) actions.setActiveSession(detail.sessionId);
|
|
3295
3486
|
if (draft.title) actions.setTaskTitle(draft.title);
|
|
3296
3487
|
if (draft.objectives) actions.setObjectives(draft.objectives);
|
|
3488
|
+
if (draft.mappings) actions.setMappings(draft.mappings);
|
|
3297
3489
|
if (draft.fieldSelection) actions.setFieldSelection(draft.fieldSelection);
|
|
3298
3490
|
for (const [key, value] of Object.entries(draft.matchRules || {})) actions.setMatchRule(key, value);
|
|
3299
3491
|
void queueWorkflowOperation(detail.sessionId, async () => {
|
|
@@ -3305,12 +3497,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3305
3497
|
document.addEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
|
|
3306
3498
|
document.addEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
|
|
3307
3499
|
document.addEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
|
|
3500
|
+
document.addEventListener(WORKBENCH_PREPARE_EVENT, handlePrepare);
|
|
3308
3501
|
}
|
|
3309
3502
|
return () => {
|
|
3310
3503
|
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
3311
3504
|
document.removeEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
|
|
3312
3505
|
document.removeEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
|
|
3313
3506
|
document.removeEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
|
|
3507
|
+
document.removeEventListener(WORKBENCH_PREPARE_EVENT, handlePrepare);
|
|
3314
3508
|
}
|
|
3315
3509
|
stopJobsPolling();
|
|
3316
3510
|
if (rootWorkbenchActions === actions) rootWorkbenchActions = null;
|
|
@@ -3324,14 +3518,34 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3324
3518
|
if (!disposed) actions.setWorkflowContract(response.contract);
|
|
3325
3519
|
}).catch(() => {});
|
|
3326
3520
|
loadWorkflowTasks(actions);
|
|
3327
|
-
if (activeSessionId
|
|
3328
|
-
queueWorkflowOperation(activeSessionId,
|
|
3521
|
+
if (activeSessionId) {
|
|
3522
|
+
queueWorkflowOperation(activeSessionId, async () => {
|
|
3523
|
+
const task = await ensureWorkflowTask(actions, activeSessionId, {}, step !== 'history');
|
|
3524
|
+
await refreshHostedTask(actions, activeSessionId, task.id, null, false);
|
|
3525
|
+
}).catch((taskError) => {
|
|
3329
3526
|
if (!disposed) actions.setError(taskError instanceof Error ? taskError.message : String(taskError));
|
|
3330
3527
|
});
|
|
3331
3528
|
}
|
|
3332
3529
|
return () => { disposed = true; };
|
|
3333
3530
|
}, [open, activeSessionId, actions]);
|
|
3334
3531
|
|
|
3532
|
+
// Reopening during execution follows Host metadata, even when the original
|
|
3533
|
+
// command watcher was lost on page reload. This performs no QCC calls.
|
|
3534
|
+
react.useEffect(() => {
|
|
3535
|
+
if (!open || !workflowTask?.id || !['matching', 'enriching'].includes(workflowTask.state)) return undefined;
|
|
3536
|
+
let disposed = false, timer;
|
|
3537
|
+
const poll = async () => {
|
|
3538
|
+
try {
|
|
3539
|
+
const task = await refreshHostedTask(actions, activeSessionId, workflowTask.id, null, false);
|
|
3540
|
+
if (disposed) return;
|
|
3541
|
+
if (['matching', 'enriching'].includes(task.state)) timer = setTimeout(poll, 2000);
|
|
3542
|
+
else if (step === 'match' || step === 'enrich') actions.setStep(task.stage || step);
|
|
3543
|
+
} catch (error) { if (!disposed) actions.setError(error.message); }
|
|
3544
|
+
};
|
|
3545
|
+
timer = setTimeout(poll, 2000);
|
|
3546
|
+
return () => { disposed = true; clearTimeout(timer); };
|
|
3547
|
+
}, [open, workflowTask?.id, workflowTask?.state, activeSessionId, step, actions]);
|
|
3548
|
+
|
|
3335
3549
|
// 非模态右栏会覆盖 Host 的中央画布;按实际重叠量把 composer 能力栏左移,
|
|
3336
3550
|
// 保证 Mockup 中的五个入口在窄桌面视口仍可见、可点击。展开态专注工作台,
|
|
3337
3551
|
// 不强行挤压到不足一栏的剩余区域。
|
|
@@ -3555,6 +3769,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3555
3769
|
const reviewRequired = Number(summary.ambiguous ?? run.reviewQueue?.length ?? 0);
|
|
3556
3770
|
const currentTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
|
|
3557
3771
|
let current = currentTask;
|
|
3772
|
+
if (currentTask?.qccRunId === run.runId && ['completed', 'partial', 'review_required', 'export_ready'].includes(currentTask.state)) {
|
|
3773
|
+
actions.setStep(currentTask.stage || 'download');
|
|
3774
|
+
return currentTask;
|
|
3775
|
+
}
|
|
3558
3776
|
// partial 表示上一轮仍有失败项。重试成功后可直接重进 enrichment,
|
|
3559
3777
|
// 无需伪造一次新的 match 转换;若重试产生候选,再回到人工核验。
|
|
3560
3778
|
if (currentTask?.state !== 'partial' || reviewRequired > 0) {
|
|
@@ -3595,14 +3813,16 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3595
3813
|
if (!activeSessionId || typeof sendSessionCommand !== 'function') {
|
|
3596
3814
|
throw new Error('当前会话无法提交数据清洗补全企查查任务');
|
|
3597
3815
|
}
|
|
3816
|
+
const latest = workflowTaskBySession.get(String(activeSessionId)) ?? cachedTask;
|
|
3598
3817
|
const prepared = await api('/data-cleaning/api/g5/commands', {
|
|
3599
|
-
...payload,
|
|
3818
|
+
...payload, workflowOwned: true, expectedRevision: latest.revision,
|
|
3600
3819
|
...(payload.kind === 'enrich' ? {} : { confirmPaidCalls: true }),
|
|
3601
3820
|
});
|
|
3602
3821
|
const command = prepared?.command;
|
|
3603
3822
|
if (!command?.commandId || !command?.prompt) throw new Error('Host 未返回有效的企查查任务命令');
|
|
3604
3823
|
await sendSessionCommand(activeSessionId, command.prompt);
|
|
3605
3824
|
const completedCommand = await waitForQccCommand(command.commandId);
|
|
3825
|
+
await refreshHostedTask(actions, activeSessionId, command.taskId, completedCommand.run);
|
|
3606
3826
|
if (completedCommand.state === 'failed') {
|
|
3607
3827
|
const commandError = new Error(completedCommand.error?.message || '智能体企查查任务执行失败');
|
|
3608
3828
|
commandError.code = completedCommand.error?.code;
|
|
@@ -3612,36 +3832,14 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3612
3832
|
return completedCommand.run;
|
|
3613
3833
|
};
|
|
3614
3834
|
|
|
3615
|
-
const prepareEditableQccCommand = async (
|
|
3835
|
+
const prepareEditableQccCommand = async () => {
|
|
3616
3836
|
if (!activeSessionId || typeof setSessionDraft !== 'function') {
|
|
3617
3837
|
throw new Error('当前会话无法回填可编辑的数据清洗补全任务说明');
|
|
3618
3838
|
}
|
|
3619
|
-
const
|
|
3620
|
-
...payload,
|
|
3621
|
-
});
|
|
3622
|
-
const command = prepared?.command;
|
|
3623
|
-
if (!command?.commandId || !command?.prompt) throw new Error('Host 未返回有效的企查查任务命令');
|
|
3624
|
-
actions.setQccCapabilities({ capabilities: { ready: true } });
|
|
3839
|
+
const command = await queueWorkflowOperation(activeSessionId, () => stageConfirmedWorkflow(actions, activeSessionId));
|
|
3625
3840
|
setSessionDraft(activeSessionId, command.prompt);
|
|
3626
3841
|
actions.close();
|
|
3627
|
-
|
|
3628
|
-
// 发送权仍在用户手中:草稿回填不会调用 QCC。用户发送后,
|
|
3629
|
-
// Agent-owned 工具改变 command 状态;本地安全轮询只读取结果并回写工作流。
|
|
3630
|
-
void waitForQccCommand(command.commandId, 1800).then(async (completedCommand) => {
|
|
3631
|
-
if (completedCommand.state === 'failed') {
|
|
3632
|
-
const commandError = new Error(completedCommand.error?.message || '智能体企查查任务执行失败');
|
|
3633
|
-
commandError.code = completedCommand.error?.code;
|
|
3634
|
-
throw commandError;
|
|
3635
|
-
}
|
|
3636
|
-
if (!completedCommand.run) throw new Error('智能体企查查任务完成但结果已失效');
|
|
3637
|
-
await queueWorkflowOperation(activeSessionId, () => applyQccRun(completedCommand.run));
|
|
3638
|
-
}).catch(async (commandError) => {
|
|
3639
|
-
if (['QCC_NOT_CONNECTED', 'QCC_TOOL_UNAVAILABLE', 'QCC_AUTH_REQUIRED'].includes(commandError?.code)) {
|
|
3640
|
-
const latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
|
|
3641
|
-
try { await workflowAction(actions, activeSessionId, latest, 'authorization-required'); } catch (_workflowError) {}
|
|
3642
|
-
}
|
|
3643
|
-
actions.setError(commandError instanceof Error ? commandError.message : String(commandError));
|
|
3644
|
-
});
|
|
3842
|
+
watchHostedCommand(actions, activeSessionId, command);
|
|
3645
3843
|
return command;
|
|
3646
3844
|
};
|
|
3647
3845
|
|
|
@@ -3769,7 +3967,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3769
3967
|
}
|
|
3770
3968
|
};
|
|
3771
3969
|
|
|
3772
|
-
const resumeWorkflowTask = (task) => {
|
|
3970
|
+
const resumeWorkflowTask = async (task) => {
|
|
3971
|
+
try {
|
|
3972
|
+
workflowTaskBySession.set(String(activeSessionId), task);
|
|
3973
|
+
task = await refreshHostedTask(actions, activeSessionId, task.id);
|
|
3974
|
+
if (task.qccRunId) {
|
|
3975
|
+
try {
|
|
3976
|
+
const response = await api(`/data-cleaning/api/g5/run/${encodeURIComponent(task.qccRunId)}`);
|
|
3977
|
+
actions.setQccRun(response);
|
|
3978
|
+
} catch { /* Expired run data does not prevent persisted artifact downloads. */ }
|
|
3979
|
+
}
|
|
3980
|
+
} catch (error) { actions.setError(error.message); return; }
|
|
3773
3981
|
workflowTaskBySession.set(String(activeSessionId || 'unassigned'), task);
|
|
3774
3982
|
actions.setWorkflowTask(task);
|
|
3775
3983
|
actions.setTaskTitle(task.title);
|
|
@@ -3900,7 +4108,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3900
4108
|
const setWorkbenchFieldGroup = (fields, enabledGroup) => {
|
|
3901
4109
|
const selected = new Set(fieldSelection);
|
|
3902
4110
|
for (const [id] of fields) {
|
|
3903
|
-
if (enabledGroup) selected.add(id); else selected.delete(id);
|
|
4111
|
+
if (enabledGroup || mappedOutputFields(mappings).includes(id)) selected.add(id); else selected.delete(id);
|
|
3904
4112
|
}
|
|
3905
4113
|
const catalogOrder = uiFieldGroups.flatMap(([, , groupFields]) => groupFields.map(([id]) => id));
|
|
3906
4114
|
actions.setFieldSelection(catalogOrder.filter((id) => selected.has(id)));
|
|
@@ -3947,7 +4155,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3947
4155
|
h('section', { className: 'dcAgentSection' },
|
|
3948
4156
|
h('h3', null, '字段映射'),
|
|
3949
4157
|
h('p', { className: 'dcAgentHint' }, '至少映射企业名称、统一社会信用代码或注册号其中一项。未映射列会原样保留,但不参与匹配。'),
|
|
3950
|
-
h('p', { className: 'dcAgentHint' }, `已映射 ${mappings.length} / ${dataset?.headers?.length || 0}
|
|
4158
|
+
h('p', { className: 'dcAgentHint' }, `已映射 ${mappings.length} / ${dataset?.headers?.length || 0} 列。绿色=通过;黄色=待确认;红色=未匹配。确认映射自动选入补全范围,不调用模型或企查查。多个原列可确认使用同一补全字段,主体匹配标识只选一列。`),
|
|
3951
4159
|
h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setMappings(mergeRecommendedMappings(dataset?.headers || [], mappings, targetFields)) }, '补充自动映射(保留已有选择)'),
|
|
3952
4160
|
mappingHints.map((recommendation, index) => h(MappingPicker, {
|
|
3953
4161
|
key: `${index}:${recommendation.sourceField}`, sourceField: recommendation.sourceField,
|
|
@@ -3970,8 +4178,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3970
4178
|
),
|
|
3971
4179
|
),
|
|
3972
4180
|
h('section', { className: 'dcAgentSection' },
|
|
3973
|
-
h('h3', null,
|
|
4181
|
+
h('h3', null, `补全范围 · 已选 ${fieldSelection.length}(映射自动带入 ${mappedOutputFields(mappings).length})`),
|
|
4182
|
+
h('p', { className: 'dcAgentHint' }, '已映射字段无需重复勾选;如需取消,请调整上方映射。以下可选目录用于追加字段。'),
|
|
3974
4183
|
h('p', { className: 'dcAgentHint' }, '补全结果优先填入已映射原列的空白单元格,保留非空原值;没有对应映射列的已选字段新增列。重复或多义列请先确认映射。'),
|
|
4184
|
+
h('details', { className: 'dcAgentExtraFields' },
|
|
4185
|
+
h('summary', null, `可选:追加补全字段(共 ${targetFields.filter(([id]) => id !== 'phone').length} 项)`),
|
|
3975
4186
|
h('div', { className: 'dcAgentRow', 'aria-label': '补全字段批次概览' },
|
|
3976
4187
|
h('span', { className: 'dcAgentChip' }, '基础字段 30'),
|
|
3977
4188
|
h('span', { className: 'dcAgentChip' }, '第一批 40'),
|
|
@@ -3993,9 +4204,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
3993
4204
|
),
|
|
3994
4205
|
),
|
|
3995
4206
|
h('div', { className: 'dcAgentChips' }, visibleCatalogFields([groupId, label, fields, sourceTool], fieldQuery).map(([key, fieldLabel]) => h('label', { key, className: `dcAgentChip${fieldSelection.includes(key) ? ' is-selected' : ''}` },
|
|
3996
|
-
h('input', { type: 'checkbox', checked: fieldSelection.includes(key), onChange: () => actions.toggleField(key) }), ` ${fieldLabel}`,
|
|
4207
|
+
h('input', { type: 'checkbox', checked: fieldSelection.includes(key), disabled: mappedOutputFields(mappings).includes(key), onChange: () => actions.toggleField(key) }), ` ${fieldLabel}`,
|
|
3997
4208
|
))),
|
|
3998
4209
|
)),
|
|
4210
|
+
),
|
|
3999
4211
|
),
|
|
4000
4212
|
h('div', { className: 'dcAgentRow' },
|
|
4001
4213
|
h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, onClick: saveTaskSettings }, '保存草稿'),
|
|
@@ -4418,6 +4630,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
|
|
|
4418
4630
|
intakeState,
|
|
4419
4631
|
projectCompletionResult,
|
|
4420
4632
|
mappingRecommendations,
|
|
4633
|
+
mappedOutputFields,
|
|
4634
|
+
syncMappedSelection,
|
|
4635
|
+
updateColumnMapping,
|
|
4421
4636
|
mergeRecommendedMappings,
|
|
4422
4637
|
MappingPicker,
|
|
4423
4638
|
deactivateCleaningSession,
|
package/lib/engine.js
CHANGED
|
@@ -29,8 +29,7 @@ export function projectCompletionResult({ rows = [], headers = [], mappings = []
|
|
|
29
29
|
const sourceHeaders = [...new Set(headers)];
|
|
30
30
|
const selected = [...new Set(fieldSelection)];
|
|
31
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);
|
|
32
|
+
&& mappings.filter((other) => other.sourceField === item.sourceField).length === 1);
|
|
34
33
|
const hidden = new Set(pairs.filter((item) => item.sourceField !== item.targetField && !sourceHeaders.includes(item.targetField)).map((item) => item.targetField));
|
|
35
34
|
const blank = (value) => value == null || (typeof value === 'string' && value.trim() === '');
|
|
36
35
|
const output = rows.map((row) => {
|
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) {
|
|
@@ -99,11 +105,15 @@ export function serializeQccCommandPrompt(command) {
|
|
|
99
105
|
'',
|
|
100
106
|
`发送本说明后,请仅调用一次数据清洗补全执行工具(${TOOL_QCC_COMMAND}),参数只传递上述安全任务凭证。`,
|
|
101
107
|
'不要要求重复粘贴企业名单,不要直接调用 mcp__qcc-* 工具,不要重试、扩大名单或追加字段。',
|
|
108
|
+
input.workflowOwned
|
|
109
|
+
? '发送即执行已确认方案,不再询问额度或写回确认。Host 会自动生成新的清洗补全结果 XLSX,原始上传文件不修改。不要寻找源文件路径、运行 Bash、编写导出脚本或另行覆盖源文件。'
|
|
110
|
+
: '这是兼容模式命令,返回工作台查看结果并导出。不要另行寻找或覆盖源文件;如需自动交付新 Excel,请在最新版工作台重新生成任务说明。',
|
|
111
|
+
'高层工具返回后只汇报真实状态,并提示返回数据清洗补全工作台下载;主体多候选或真实授权异常才需要人工处理。',
|
|
102
112
|
].join('\n');
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
export class QccCommandStore {
|
|
106
|
-
constructor({ bridge, runs, clock = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxCommands = DEFAULT_MAX_COMMANDS } = {}) {
|
|
116
|
+
constructor({ bridge, runs, clock = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxCommands = DEFAULT_MAX_COMMANDS, lifecycle } = {}) {
|
|
107
117
|
if (!bridge || !runs) throw new TypeError('QccCommandStore requires bridge and runs');
|
|
108
118
|
this.bridge = bridge;
|
|
109
119
|
this.runs = runs;
|
|
@@ -111,6 +121,7 @@ export class QccCommandStore {
|
|
|
111
121
|
this.ttlMs = ttlMs;
|
|
112
122
|
this.maxCommands = maxCommands;
|
|
113
123
|
this.commands = new Map();
|
|
124
|
+
this.lifecycle = lifecycle;
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
cleanup(reserveSlot = false) {
|
|
@@ -171,6 +182,8 @@ export class QccCommandStore {
|
|
|
171
182
|
runId: record.runId,
|
|
172
183
|
error: record.error,
|
|
173
184
|
estimate: record.estimate,
|
|
185
|
+
workflowOwned: record.input.workflowOwned === true,
|
|
186
|
+
delivery: record.delivery ?? null,
|
|
174
187
|
expiresInMs: this.ttlMs,
|
|
175
188
|
});
|
|
176
189
|
}
|
|
@@ -190,15 +203,18 @@ export class QccCommandStore {
|
|
|
190
203
|
if (record.promise) return record.promise;
|
|
191
204
|
record.state = 'running';
|
|
192
205
|
record.updatedAt = new Date(this.clock()).toISOString();
|
|
193
|
-
record.promise = this.
|
|
194
|
-
.then((
|
|
206
|
+
record.promise = Promise.resolve().then(() => this.lifecycle?.beforeRun(record))
|
|
207
|
+
.then(() => this.execute(record, execution))
|
|
208
|
+
.then(async (run) => {
|
|
195
209
|
record.runId = run.runId;
|
|
210
|
+
record.delivery = await this.lifecycle?.complete(record, run);
|
|
196
211
|
record.state = 'completed';
|
|
197
212
|
record.updatedAt = new Date(this.clock()).toISOString();
|
|
198
213
|
record.touchedAtMs = this.clock();
|
|
199
214
|
return this.toolResult(record, run);
|
|
200
215
|
})
|
|
201
|
-
.catch((error) => {
|
|
216
|
+
.catch(async (error) => {
|
|
217
|
+
try { await this.lifecycle?.failed(record, error); } catch { /* Preserve the original execution failure. */ }
|
|
202
218
|
record.error = safeError(error);
|
|
203
219
|
record.state = 'failed';
|
|
204
220
|
record.updatedAt = new Date(this.clock()).toISOString();
|
|
@@ -244,6 +260,7 @@ export class QccCommandStore {
|
|
|
244
260
|
runId: run.runId,
|
|
245
261
|
state: run.state,
|
|
246
262
|
summary: run.summary,
|
|
263
|
+
...(record.delivery ? { deliveryState: record.delivery.state, artifactCount: record.delivery.artifacts.length } : {}),
|
|
247
264
|
});
|
|
248
265
|
}
|
|
249
266
|
}
|
|
@@ -267,6 +284,8 @@ export function registerQccCommandTool(tools, commands) {
|
|
|
267
284
|
taskId: { type: 'string' },
|
|
268
285
|
runId: { type: 'string' },
|
|
269
286
|
state: { type: 'string' },
|
|
287
|
+
deliveryState: { type: 'string' },
|
|
288
|
+
artifactCount: { type: 'integer' },
|
|
270
289
|
summary: {
|
|
271
290
|
type: 'object',
|
|
272
291
|
additionalProperties: false,
|
|
@@ -288,7 +307,7 @@ export function registerQccCommandTool(tools, commands) {
|
|
|
288
307
|
},
|
|
289
308
|
render: (_args, value) => [{
|
|
290
309
|
type: 'text',
|
|
291
|
-
text:
|
|
310
|
+
text: `数据清洗补全:${value.summary?.enriched ?? 0}/${value.summary?.totalRows ?? 0} 条已补全。${value.artifactCount ? 'Host 已生成新的结果 XLSX,请返回工作台「结果下载」。源文件未修改,无需再次确认或提供路径。' : value.deliveryState === 'review_required' ? '存在多候选,请返回工作台确认主体。' : '请返回工作台查看任务状态。'}`,
|
|
292
311
|
}],
|
|
293
312
|
},
|
|
294
313
|
async execute(args, exec) {
|
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));
|
|
@@ -617,6 +619,10 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
617
619
|
capabilities: qccBridge.capabilities(),
|
|
618
620
|
limits: { maxRows: 100, maxConcurrency: 4 },
|
|
619
621
|
paidCallConfirmationRequired: true,
|
|
622
|
+
draftPreparationVersion: 2,
|
|
623
|
+
draftPaidCallConfirmationRequired: false,
|
|
624
|
+
multipleOutputColumns: true,
|
|
625
|
+
workflowExecutionVersion: 1,
|
|
620
626
|
idempotencyRequired: true,
|
|
621
627
|
candidateResume: true,
|
|
622
628
|
manualRetry: true,
|
|
@@ -647,7 +653,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
647
653
|
ok: true,
|
|
648
654
|
marker: 'g5-agent-command',
|
|
649
655
|
paidCalls: false,
|
|
650
|
-
command: qccCommands.prepare(payload),
|
|
656
|
+
command: qccCommands.prepare(payload.workflowOwned ? await execution.prepare(payload) : payload),
|
|
651
657
|
});
|
|
652
658
|
}
|
|
653
659
|
if (rest.length === 1 && req.method === 'GET') {
|
|
@@ -662,6 +668,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
|
|
|
662
668
|
if (error instanceof SyntaxError) {
|
|
663
669
|
return writeJson(res, 400, { ok: false, code: 'DC_BAD_JSON', message: 'Request body must be valid JSON.' });
|
|
664
670
|
}
|
|
671
|
+
if (error instanceof WorkflowError) return writeWorkflowError(res, error);
|
|
665
672
|
return writeQccError(res, error);
|
|
666
673
|
}
|
|
667
674
|
});
|
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: [],
|
|
@@ -139,7 +139,7 @@ export function validateMappings(value) {
|
|
|
139
139
|
const targets = new Set();
|
|
140
140
|
const sources = new Set();
|
|
141
141
|
for (const mapping of mappings) {
|
|
142
|
-
if (targets.has(mapping.targetField)) {
|
|
142
|
+
if (MATCH_ANCHORS.includes(mapping.targetField) && targets.has(mapping.targetField)) {
|
|
143
143
|
throw new WorkflowContractError('DC_WORKFLOW_DUPLICATE_MAPPING', `Duplicate target mapping: ${mapping.targetField}`);
|
|
144
144
|
}
|
|
145
145
|
if (sources.has(mapping.sourceField)) {
|
|
@@ -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.17",
|
|
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",
|
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
"docs/RELEASE-0.7.0.md",
|
|
39
39
|
"docs/RELEASE-0.8.0.md",
|
|
40
40
|
"docs/RELEASE-0.8.1.md",
|
|
41
|
+
"docs/RELEASE-0.8.16.md",
|
|
42
|
+
"docs/RELEASE-0.8.17.md",
|
|
43
|
+
"docs/TEMPLATE-MAPPING-ACCEPTANCE.md",
|
|
41
44
|
"docs/UI-WORKFLOW-V2.md",
|
|
42
45
|
"docs/UI-WORKFLOW-V2-MIGRATION.md",
|
|
43
46
|
"docs/UI-WORKFLOW-V2-ACCEPTANCE.md",
|
|
@@ -47,7 +50,7 @@
|
|
|
47
50
|
"scripts": {
|
|
48
51
|
"test:ui": "node scripts/ui-layout-regression.mjs",
|
|
49
52
|
"test": "node --test",
|
|
50
|
-
"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",
|
|
53
|
+
"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",
|
|
51
54
|
"docs:check": "node scripts/check-readme-version.mjs",
|
|
52
55
|
"marketing:check": "node scripts/check-marketing.mjs",
|
|
53
56
|
"verify-pack": "node scripts/verify-pack.mjs",
|