dsh-data-cleaning-agent 0.9.15 → 0.9.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 CHANGED
@@ -1,3 +1,16 @@
1
+ # 0.9.17 · 2026-09-18
2
+
3
+ - 工商与简介新增 14 项一企一行字段:省市区及地区代码、四级国标行业、四级企查查行业、主营产品、企业规模;不改变默认选择。
4
+ - 兼容行业由文本改为对象的返回结构,保留旧文本且不推测缺失层级,避免输出对象字符串;主营产品按源顺序合并一格。
5
+ - 向导与工作台同步字段目录、别名搜索、层级/规模说明及动态数量,保留原列回填和客户结果/任务报告分层。
6
+
7
+ # 0.9.16 · 2026-09-15
8
+
9
+ - UX-49:仅清洗入口新建的独立 Session,通过 Host 原生草稿接口预填一次;空草稿、附件/引用、输入就绪、IME、草稿版本与晚到竞态二次核验,不自动发送、建业务任务或展开工作台。
10
+ - 用户编辑/主动清空、旧会话、刷新、重挂载和切回不补回、不清理草稿;保留 UX-48 接纳后开台与 settling→hero 品牌修复。
11
+ - 初始引导采用稳定模板 ID/版本和精确文本指纹;向导排除未改模板,修改后的内容不整体忽略,已有草稿提供替换/追加/取消,异步回填前重新核验。
12
+ - 未补占位符或缺名单/待补字段,在清洗业务 Skill 中优先澄清,不调 OAuth/OCR/MCP 或创建 workflow;不拦截 DSH 全局发送。
13
+
1
14
  # 0.9.15 · 2026-09-14
2
15
 
3
16
  - 保留 0.9.14 的 Host 驱动单向工作流、导航保护、导入核验和紧凑状态区功能。
package/README.en.md CHANGED
@@ -10,7 +10,11 @@ Related agents: [数据清洗补全](https://github.com/duhu2000/dsh-data-cleani
10
10
 
11
11
  > 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.
12
12
  >
13
- > Current source version / 当前源码版本: **0.9.15** (stable release)
13
+ > Current source version / 当前源码版本: **0.9.17** (stable release)
14
+
15
+ 0.9.17 adds 14 region, industry-level, product and enterprise-size fields (150 total), supports both legacy industry text and nested responses, and improves field search and column mapping. Defaults remain unchanged; missing levels are never inferred. Products reflect only the upstream first 10 entries. See [field expansion and verification boundaries](docs/CORE-FIELDS-20260918.md).
16
+
17
+ 0.9.16 implements UX-49: a one-time editable native guide for new cleaning-entry Sessions, with existing text, attachment, IME and asynchronous-race guards. Clearing, refreshing or returning never restores it. Initialization does not send, call tools or open the workbench. Incomplete templates request the missing list and fields first. See the [adoption record](docs/UNIFIED-HOME-1.5.4-ADOPTION.md) for isolated DSH evidence and unverified boundaries.
14
18
 
15
19
  0.9.15 releases the Host-driven forward-only workflow across import, rules, entity matching, enrichment, and download. Future stages provide explicit locked feedback, while completed stages remain viewable without business-state mutations. Imported rows stay visible for full review before the bottom confirmation advances to rules. The compact status area reports stage, source, time, scope, and explicit result counts. The 0.9.13 submission-admission, background-session isolation, and manual-collapse guarantees remain intact. Start a new cleaning Session after upgrading; legacy Session migration is outside this release's acceptance scope.
16
20
 
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  数据清洗补全智能体:面向 Excel/CSV/JSON 企业名单,提供数据清洗、表格清洗、清洗补全、去重、企业数据补全与字段补全,支持企查查 MCP 和结果导出。
10
10
 
11
11
  ```sh
12
- dsh plugin --profile web add dsh-data-cleaning-agent@0.9.15
12
+ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.17
13
13
  ```
14
14
 
15
15
  请先满足下文的 DSH、连接器及侧边栏依赖要求;安装后完整停止并重启对应 Profile。
@@ -27,7 +27,11 @@ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.15
27
27
 
28
28
  > 在 DeepSeek Harness 中清洗、补全、画像企业名单数据的智能体插件:本地 CSV/XLSX/JSON 引擎 + 可选企查查(Qichacha/QCC)MCP 企业数据补全,由企查查(Qichacha/QCC)团队发起并维护。
29
29
  >
30
- > 当前源码版本 / Current source version: **0.9.15**(正式版本)
30
+ > 当前源码版本 / Current source version: **0.9.17**(正式版本)
31
+
32
+ 0.9.17 新增省市区、地区代码、四级国标行业、四级企查查行业、主营产品与企业规模共 14 项字段,目录共 150 项;兼容行业由文本改为分层对象,优化字段搜索与原列映射。默认选择不扩大,缺失层级不推测;主营产品最多为上游返回的前 10 项。详见[字段扩展与验证边界](docs/CORE-FIELDS-20260918.md)。
33
+
34
+ 0.9.16 实施 UX-49:首页仅为清洗入口新建的会话预填一次原生可编辑引导,保护已有文字、附件、中文组合输入与异步竞态。主动清空、刷新或切回不会补回;不自动发送、调用工具或展开工作台。未补全模板先澄清名单和字段。真实 DSH 隔离验收及未验证边界见[规范采用记录](docs/UNIFIED-HOME-1.5.4-ADOPTION.md)。
31
35
 
32
36
  0.9.15 发布 Host 驱动的单向工作流:统一“导入与核验 → 规则与体检 → 主体匹配 → 字段补全 → 结果下载”五步导航。未来节点给出明确锁定反馈,历史节点保留查看但不挂载业务写操作;导入后先核验完整名单,再从表格底部确认进入规则页。紧凑状态区展示节点、来源、时间、任务范围和明确结果计数。0.9.13 的真实提交接纳、后台会话隔离与手动收起保护保持不变。升级后请新建清洗会话验证,旧 Session 迁移不在本版验收范围。
33
37
 
@@ -206,3 +206,10 @@ Host 制品下载按钮。alpha.2 继续只检查 Host/路由/制品 Bridge,
206
206
  XLSX 因此以 Base64 文本落盘。未来切换二进制 seam 必须保留旧 `v1` 制品读取兼容。
207
207
  - v2 已生成制品可跨 Host 重启恢复,但浏览器 runtime 中尚未导出的原始行不会持久化;恢复中途任务
208
208
  仍需用户重新提供输入。制品只在当前工作区,不承诺跨设备同步。
209
+ # UX-49 原生初始草稿兼容边界(0.9.16,2026-09-15)
210
+
211
+ - 采用记录见 `UNIFIED-HOME-1.5.4-ADOPTION.md` 的 v1.5.6 小节。仅新建清洗 Session 初始化,旧 Session/刷新/重挂载只恢复归属,不迁移或补回草稿。
212
+ - 本机核对 DSH 0.1.2-rc.1 的公开 `conversation.input.shell(sessionId)`:`snapshot.draft / draftRev / phase / imageIds / occurrences` 与 `setDraft`。缺失任一必要快照能力时保守跳过,不使用 DOM 空文本推断可写,不读取私有 editor。
213
+ - Host InputState 没有 IME 字段:使用 compositionstart/end 与入口异步期间的 beforeinput/input/paste/drop 事件,仅用于取消本插件初始化,不阻断默认事件、不修改其他产品。公开 shell 可在新 Session 的输入 DOM 挂载前使用;不把 hero/settling 或 session.blank 当草稿就绪证据。
214
+ - 未填写的模板发送由本业务 Skill 澄清;不改 Host submit/beginSubmission 全局语义。UX-48 原有 observed 接纳桥保持独立,草稿写入不产生业务接纳事件。
215
+ - 本轮不升级 DSH、不修改正式 Profile、不请求 QCC/OCR/模型凭据。无法验证的 Host 版本或真实模型决策明确保留为外部边界。
@@ -0,0 +1,50 @@
1
+ # V3.25 核心字段扩展评估与实现(0.9.17,2026-09-18)
2
+
3
+ ## 一手依据
4
+
5
+ - 2026-09-17 V3.25.0 发版通知及核对说明:工商地区/国标行业、简介企查查行业/主营产品/企业规模。
6
+ - qcc_mcp_new 本地 dev@fabceff:apps/mcp/src/tools/company_registration_info.tool.ts、company_profile.tool.ts;工商变更 cf4cfe1,简介 9203e20/f9719a0/a538e16/3b672ec。
7
+ - mcp_web 本地 dev@f2b85a79:apps/web/lib/demo-data.ts 及两个对应 example tests;913882d4、c9fb123a 和 109c9216。两个源仓只读,未修改工作树。
8
+ - 清洗插件基线 origin/main@794201b / npm latest 0.9.16,无在途 PR。独立分支 feat/core-fields-20260918;不变更共享字段包或默认选择。
9
+ - 发版核对说明明确未独立验证生产部署;本次亦未发起收费 MCP 请求。代码/官网示例对齐不能替代线上验收。
10
+
11
+ ## 新增 14 项一对一字段
12
+
13
+ | 来源工具 | 精确返回路径 | 补全字段 ID | 显示名 |
14
+ | --- | --- | --- | --- |
15
+ | get_company_registration_info | 地区信息.省份 | province | 省份 |
16
+ | 同上 | 地区信息.城市 | city | 城市 |
17
+ | 同上 | 地区信息.区域 | district | 区域 |
18
+ | 同上 | 地区信息.地区代码 | area_code | 地区代码 |
19
+ | 同上 | 国标行业.门类 | industry_section | 国标行业门类 |
20
+ | 同上 | 国标行业.大类 | industry_large | 国标行业大类 |
21
+ | 同上 | 国标行业.中类 | industry_middle | 国标行业中类 |
22
+ | 同上 | 国标行业.小类 | industry_small | 国标行业小类 |
23
+ | get_company_profile | 企查查行业.一级 | qcc_industry_level1 | 企查查行业一级 |
24
+ | 同上 | 企查查行业.二级 | qcc_industry_level2 | 企查查行业二级 |
25
+ | 同上 | 企查查行业.三级 | qcc_industry_level3 | 企查查行业三级 |
26
+ | 同上 | 企查查行业.四级 | qcc_industry_level4 | 企查查行业四级 |
27
+ | 同上 | 主营产品 | main_products | 主营产品 |
28
+ | 同上 | 企业规模 | company_scale | 企业规模 |
29
+
30
+ 两个工具均来自 qcc-company。目录 136→150;工商 27→35、简介 3→9。仍按企业+工具去重调用,不为每个层级重复查同一工具。
31
+
32
+ ## 兼容性与数据边界
33
+
34
+ - 旧 industry_category / qcc_industry ID 保留:旧文本原样输出;新对象按明确层级标注串联,禁止直接 String(object)。独立层级只取对应叶子,不把旧文本认定为某层级,也不从其他层级补缺。
35
+ - 地区字段来自嵌套“地区信息”,不从所属地区/注册地址拆分;地区代码保留字符串与前导零。不同主体可能不返回这些字段,缺失留空。
36
+ - 企业规模取简介“企业规模”(上游大型/中型/小型/微型);原 company_size 仍是工商“人员规模”,不能互相回填。
37
+ - 主营产品是名称数组,按原顺序以中文分号合并单元格,不展开企业行、不重排、不去重。当前服务只请求前 10 项,空数组也可能来自不支持主体或补充接口异常,不能据此判断企业没有产品。
38
+ - 原表非空值保护、只回填选定字段、来源审计和客户 XLSX/任务报告分层保持不变;主结果不增加本次未选字段。
39
+
40
+ ## 选择界面
41
+
42
+ 保持两大来源分组及现有全选/清空交互;增加层级、规模和产品边界说明。新增省/市/区县、区域编码、企查查一级行业等别名,搜索和表头映射共用;不把笼统“行业”自动绑定到某级。数量取当前目录,移除过时固定批次数字。默认字段不扩大。
43
+
44
+ ## 验证
45
+
46
+ 自动化覆盖:新结构/旧文本、缺层级、畸形对象、零值和前导零、产品原顺序、两个工具去重、原值保留、未选择字段不输出、前后端目录映射及 UI 数量/搜索。
47
+
48
+ 本地验收:npm run check 全部通过(302 项测试及语法、文档、打包门禁);真实 React/Chromium 亮/暗主题 × 5 种尺寸共 10 组回归通过,包括新增别名搜索、行业层级、银行模板省市区自动映射。XLSX 合成往返测试验证中文列名、前导零、产品单元格和报告分层;未在客户已登录的 3080 会话上发起业务调用。
49
+
50
+ 未验证:生产 MCP 实际部署与权限、真实企业返回完整度;不将合成 fixture 当作真实业务验收。用户后续已授权 commit、push、tag 与 npm 发布,0.9.17 按 PR 检查、合并默认分支、tag/OIDC 流程发布;发布状态以 registry 回读为准。
@@ -18,14 +18,15 @@
18
18
 
19
19
  ## 2. 当前范围
20
20
 
21
- 当前字段目录共 128 项,UI/Host/XLSX 按 8 个来源工具分组:
21
+ 当前字段目录共 150 项,UI/Host/XLSX 按 11 个来源工具分组(0.9.17,2026-09-18 扩展):
22
22
 
23
- - 基础 30 项:`get_company_registration_info`(企业工商信息,27 项)和 `get_company_profile`(企业简介,3 项)。
23
+ - 基础 44 项:`get_company_registration_info`(企业工商信息,35 项)和 `get_company_profile`(企业简介,9 项)。
24
+ - 实际控制人 4 项、受益所有人首条 1 项、主要财务指标 3 项,沿用共享字段契约。
24
25
  - 第一批 40 项:`get_contact_info`(联系方式,6 项)、`get_listing_info`(上市信息,15 项)、`get_tax_invoice_info`(税务开票,8 项)、`get_import_export_credit`(进出口信用,11 项)。
25
26
  - 第二批 58 项:`get_company_risk_scan`(企业自身风险扫描,38 项)和 `get_company_related_risk_scan`(企业关联风险扫描,20 项)。
26
27
  - 基础工商:企业名称、统一社会信用代码、注册号、组织机构代码、纳税人识别号、登记状态、法定代表人、注册/实缴资本、成立日期、企业类型、核准日期、登记机关、纳税人资质、支付系统行号、进出口企业代码、企业简称、英文名。
27
- - 地址与地区:注册地址、通信地址、所属地区原值。
28
- - 经营信息:经营范围、国标行业、企查查行业、营业期限、人员规模、参保人数、分支机构参保人数、企业简介、产业链概览。
28
+ - 地址与地区:注册地址、通信地址、所属地区原值及明确返回的省份、城市、区域、地区代码;不拆地址推测。
29
+ - 经营信息:经营范围、国标行业/企查查行业展示值及各自四级独立字段、主营产品、企业规模、营业期限、人员规模、参保人数、分支机构参保人数、企业简介、产业链概览。企业规模与人员规模独立;主营产品按源顺序合并一格,当前上游最多 10 项,并非完整产品库。详见 [字段扩展核对](CORE-FIELDS-20260918.md)。
29
30
  - 上传列“联系电话”仍可作为本地质量检查字段;QCC 联系方式另以“首选联系电话/邮箱/官网”等字段输出。电话、邮箱和网址全集不拼接进单元格。
30
31
  - 风险字段只输出扫描工具已返回的聚合计数、命中摘要与重点维度命中关联方数;风险明细、关联方名单和下钻结果不进入一企一行主表。
31
32
  - 一级行业、二级行业、省、市、区县、曾用名和知识产权摘要不在当前目录;不得推断、拼接或用其他字段冒充。
@@ -1,4 +1,25 @@
1
- # DSH-UX-001 v1.5.4 采用记录
1
+ # DSH-UX-001 首页规范采用记录
2
+
3
+ ## v1.5.6 / UX49-CLEAN(2026-09-15,0.9.16)
4
+
5
+ 发布授权补充:用户于实施与 PR 验证后明确要求 commit、push、tag、npm,按仓库既有 CI、合并及 OIDC 发布流程推进 0.9.16;原实施阶段仅授权 PR 的边界作为历史记录保留。
6
+
7
+ 依据共享 `DSH智能体开发交互规范方案.md` 的 UX-49、§11.1、§14,并核对 UX-48;协同清单 `DSH-UX-049-四智能体首页初始引导协同.md` 及总账同日交接。这里只记录采用与差异,不复制规范全文。
8
+
9
+ - 审计基线:remote main `fc7d57240b843e1a57998cf0ebbe4f39c086befb`,npm latest 0.9.15 / gitHead `6d8b37a7a0efae1aa2fddc72d61fc5e732f3231f`。原仓 main 的未提交 client/test 修改保持原样,另建独立分支。
10
+ - 已有正确项:独立清洗 Session namespace、宿主原生输入接口、实际接纳后开台、标题 settling→hero 修复、历史来源只读与主结果/报告分层。
11
+ - 真实缺陷:入口无条件 setDraft;普通会话异步恢复时按宽泛签名清理草稿;已有业务入口再次点击会开台;向导缺少已有内容确认与晚到写入检查。以上已收紧。
12
+ - 初始模板 `dsh-initial-draft/data-cleaning/1` / version 1,UTF-8 SHA-256 `2b84b86f669e64e970381e4c7ab8547c77d5e10ee7d5441543c893b9f1272e2b`。仅精确相等才排除;不 trim、不按前后缀忽略用户修改。
13
+ - 初始化仅由本入口成功创建的全新 ID 触发,在打开会话前操作未挂载的原生 shell,避免 Host setDraft 的选区写入抢占已挂载输入焦点。snapshot 缺失或非 plain、draft 非空、draftRev 非零、imageIds/occurrences 非空均跳过。建立一次性记录、订阅后重读、微任务后再次快照;期间用户输入/附件/组合事件、会话切换或卸载使写入失效,无轮询补回。
14
+ - 清洗业务 Skill 将信息充分性检查置于任何工具之前:占位符/缺名单或待补字段先澄清;没有注册全局发送拦截。安全凭证所指暂存来源仍可提供名单和范围,不重复要求上传。
15
+ - 向导按 UX-10 提供替换/追加/取消,默认保留用户内容;同一 Session 未改的向导草稿更新而非重复叠加。异步回填重读草稿版本、附件和当前业务归属。
16
+ - 本地门禁:`npm run check` 通过(296 项测试,语法/文档/营销/69 文件 pack 白名单);Chromium 浅深色 × 5 尺寸 10/10;XLSX 预览 390/800/1440px 3/3。包含附件、IME 事件、晚到修改/清空、卸载、A/B、普通 Session、Tab X/折叠恢复、草稿回填替换/追加/取消与零开台断言。
17
+ - 真实 DSH:`scripts/ux49-native-smoke.mjs`,独立 Profile、无模型/MCP 凭据。DSH 0.1.2-rc.1 + Sidebar 0.18.1 + 清洗候选/招投标 0.5.11/访前尽调 0.1.35/填表 0.2.30 共装通过。原生模板、正确 Hero 标题、不移动焦点、主动清空不补回、刷新保留修改、A/B/普通会话隔离、零业务 API mutation、零可见工作台、零浏览器异常均通过。
18
+ - 实机客户端 SHA-256 `bacfd1faa7eff584ddec3e948ee3f2e19f1d710224de9068d9a62415345a28a1`,测试包 SHA-256 `e93fac1541934a43bc54789283d57c378fbd721fe0d8fc86b922b2a69c5747c5`。原始报告/截图在 `/var/folders/ws/tmsn44b140lf8b89ml5qqjf00000gn/T/cleaning-ux49-native-HtQGy0/`;本地日志 `/private/tmp/ux49-{check,ui,artifact,native}-release-candidate.log`,UI 截图 `_scratch/ui-layout/`。测试包不是 npm 发布物;随后文档和验收脚本路径清理不改变已验收客户端字节。
19
+ - 未验证边界:真实模型执行占位符澄清(仅验证 Skill 优先规则,不冒充实际模型决策)、系统级真实中文输入法(已覆盖组合事件)、收费 QCC/OCR、多账号/其他 Host 版本,以及其余三产品各自 UX49 候选版本。正式 3080/Profile 未修改。
20
+ - 本次仅 commit/push/PR/CI;不合并、不升版本、不 tag/Release/npm publish。
21
+
22
+ ## v1.5.4 历史采用记录
2
23
 
3
24
  日期:2026-09-14。基线:0.9.11 / a0ec971db0d25d42f67f99e4328cca6f49a2bc2f。
4
25
  本轮仅本地代码与验收,不发布,不修改正式 Profile 或全局 DSH。
package/lib/client.js CHANGED
@@ -1605,19 +1605,104 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1605
1605
  return () => { style.remove(); };
1606
1606
  }
1607
1607
 
1608
- // 原始数据不进入 Host storageDomain 或模型上下文;只在当前页面按 taskId 隔离。
1608
+ // 原始数据不进入 Host storageDomain 或模型上下文;仅在当前浏览器页签内按 taskId 隔离。
1609
+ // sessionStorage 只用于刷新恢复,关闭页签后不承诺保留;写入失败时降级为同源重新载入。
1610
+ const RUNTIME_SESSION_CACHE_KEY = 'dsh.data-cleaning-agent.runtime-tasks.v1';
1611
+ const RUNTIME_SESSION_CACHE_LIMIT = 20;
1612
+ const RUNTIME_SESSION_CACHE_MAX_ROWS = 5_000;
1613
+ const RUNTIME_SESSION_CACHE_MAX_CHARS = 2 * 1024 * 1024;
1609
1614
  const runtimeTasks = new Map();
1610
1615
  const workflowTaskBySession = new Map();
1611
1616
  const workflowTaskCreationBySession = new Map();
1612
1617
  const workflowOperationBySession = new Map();
1613
1618
 
1614
- function runtimeFor(taskId, create = true) {
1619
+ function readRuntimeSessionCache() {
1620
+ try {
1621
+ const value = JSON.parse(window.sessionStorage?.getItem(RUNTIME_SESSION_CACHE_KEY) || '{}');
1622
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1623
+ } catch { return {}; }
1624
+ }
1625
+
1626
+ function runtimeFromSession(taskId, expectedChecksum = '') {
1615
1627
  const key = String(taskId || 'unassigned');
1628
+ const value = readRuntimeSessionCache()[key];
1629
+ const rows = Array.isArray(value?.rows)
1630
+ ? value.rows.filter((row) => row && typeof row === 'object' && !Array.isArray(row))
1631
+ : [];
1632
+ const checksum = String(value?.source?.checksum || '');
1633
+ if (!rows.length || !expectedChecksum || checksum !== expectedChecksum) return null;
1634
+ return {
1635
+ rows,
1636
+ sourceRows: rows,
1637
+ headers: Array.isArray(value.headers) ? value.headers.map(String) : [],
1638
+ source: value.source && typeof value.source === 'object' ? value.source : null,
1639
+ restoredFromSession: true,
1640
+ lastCsv: { clean: null, complete: null, qcc: null, review: null },
1641
+ resultRows: { clean: null, complete: null, qcc: null },
1642
+ };
1643
+ }
1644
+
1645
+ function persistRuntimeSession(taskId, runtime, removeTaskId = null) {
1646
+ const rows = Array.isArray(runtime?.sourceRows) && runtime.sourceRows.length
1647
+ ? runtime.sourceRows
1648
+ : Array.isArray(runtime?.rows) ? runtime.rows : [];
1649
+ if (!rows.length || rows.length > RUNTIME_SESSION_CACHE_MAX_ROWS
1650
+ || typeof window.sessionStorage?.setItem !== 'function') return false;
1651
+ try {
1652
+ const key = String(taskId || 'unassigned');
1653
+ const cache = readRuntimeSessionCache();
1654
+ if (removeTaskId !== null) delete cache[String(removeTaskId || 'unassigned')];
1655
+ delete cache[key];
1656
+ cache[key] = {
1657
+ rows,
1658
+ headers: Array.isArray(runtime.headers) ? runtime.headers : [],
1659
+ source: runtime.source && typeof runtime.source === 'object' ? runtime.source : null,
1660
+ };
1661
+ const entries = Object.entries(cache).slice(-RUNTIME_SESSION_CACHE_LIMIT);
1662
+ while (entries.length) {
1663
+ const serialized = JSON.stringify(Object.fromEntries(entries));
1664
+ if (serialized.length <= RUNTIME_SESSION_CACHE_MAX_CHARS) {
1665
+ window.sessionStorage?.setItem(RUNTIME_SESSION_CACHE_KEY, serialized);
1666
+ return true;
1667
+ }
1668
+ entries.shift();
1669
+ }
1670
+ return false;
1671
+ } catch { return false; }
1672
+ }
1673
+
1674
+ function removeRuntimeSession(taskId) {
1675
+ try {
1676
+ const key = String(taskId || 'unassigned');
1677
+ const cache = readRuntimeSessionCache();
1678
+ if (!(key in cache)) return;
1679
+ delete cache[key];
1680
+ window.sessionStorage?.setItem(RUNTIME_SESSION_CACHE_KEY, JSON.stringify(cache));
1681
+ } catch {}
1682
+ }
1683
+
1684
+ function removeRuntime(taskId) {
1685
+ runtimeTasks.delete(String(taskId || 'unassigned'));
1686
+ removeRuntimeSession(taskId);
1687
+ }
1688
+
1689
+ function runtimeFor(taskId, create = true, expectedChecksum = '') {
1690
+ const key = String(taskId || 'unassigned');
1691
+ const current = runtimeTasks.get(key);
1692
+ if (current && expectedChecksum && current.source?.checksum !== expectedChecksum) {
1693
+ runtimeTasks.delete(key);
1694
+ removeRuntimeSession(key);
1695
+ }
1696
+ if (!runtimeTasks.has(key)) {
1697
+ const restored = runtimeFromSession(key, expectedChecksum);
1698
+ if (restored) runtimeTasks.set(key, restored);
1699
+ }
1616
1700
  if (!runtimeTasks.has(key) && create) {
1617
1701
  runtimeTasks.set(key, {
1618
1702
  rows: [],
1619
1703
  headers: [],
1620
1704
  source: null,
1705
+ restoredFromSession: false,
1621
1706
  lastCsv: { clean: null, complete: null, qcc: null, review: null },
1622
1707
  resultRows: { clean: null, complete: null, qcc: null },
1623
1708
  });
@@ -1633,6 +1718,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1633
1718
  target.sourceRows = from.sourceRows;
1634
1719
  target.headers = from.headers;
1635
1720
  target.source = from.source;
1721
+ target.restoredFromSession = from.restoredFromSession;
1636
1722
  target.lastCsv = from.lastCsv;
1637
1723
  target.resultRows = from.resultRows;
1638
1724
  runtimeTasks.delete(String(fromId || 'unassigned'));
@@ -1714,12 +1800,19 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1714
1800
  ['taxpayer_qualification', '纳税人资质'], ['payment_line_no', '支付系统行号'],
1715
1801
  ['import_export_company_code', '进出口企业代码'], ['short_name', '企业简称'], ['english_name', '英文名'],
1716
1802
  ['registered_address', '注册地址'], ['mailing_address', '通信地址'], ['region', '所属地区'],
1803
+ ['province', '省份'], ['city', '城市'], ['district', '区域'], ['area_code', '地区代码'],
1717
1804
  ['business_scope', '经营范围'], ['industry_category', '国标行业'],
1805
+ ['industry_section', '国标行业门类'], ['industry_large', '国标行业大类'],
1806
+ ['industry_middle', '国标行业中类'], ['industry_small', '国标行业小类'],
1718
1807
  ['operating_period', '营业期限'], ['company_size', '人员规模'], ['insured_count', '参保人数'],
1719
1808
  ['branch_insured_count', '分支机构参保人数'],
1720
1809
  ], 'get_company_registration_info'],
1721
1810
  ['company_profile', '企业简介', [
1722
- ['qcc_industry', '企查查行业'], ['company_profile', '企业简介'],
1811
+ ['qcc_industry', '企查查行业'],
1812
+ ['qcc_industry_level1', '企查查行业一级'], ['qcc_industry_level2', '企查查行业二级'],
1813
+ ['qcc_industry_level3', '企查查行业三级'], ['qcc_industry_level4', '企查查行业四级'],
1814
+ ['main_products', '主营产品'], ['company_scale', '企业规模'],
1815
+ ['company_profile', '企业简介'],
1723
1816
  ['industry_chain_overview', '产业链概览'],
1724
1817
  ], 'get_company_profile'],
1725
1818
  ['contact_info', '联系方式', [
@@ -1762,6 +1855,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1762
1855
  ];
1763
1856
  FIELD_GROUPS.sort((a, b) => FIELD_GROUP_ORDER.indexOf(a[0]) - FIELD_GROUP_ORDER.indexOf(b[0]));
1764
1857
  const ENRICHMENT_OPTIONS = FIELD_GROUPS.flatMap(([, , fields]) => fields);
1858
+ const CORE_FIELD_GUIDE = '可分别选择省市区、地区代码、四级国标行业与四级企查查行业;未分级行业列保留完整层级展示。企业规模不等于人员规模;主营产品按原顺序合并一格(上游最多返回 10 项,非完整产品库)。缺失值留空,不推测。';
1765
1859
  const INPUT_ONLY_MAPPING_OPTIONS = [['phone', '联系电话']];
1766
1860
  const RESULT_FIELD_LABELS = new Map([
1767
1861
  ...ENRICHMENT_OPTIONS,
@@ -1781,7 +1875,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1781
1875
  ];
1782
1876
  const DEFAULT_CLEANING_KEYS = ['clean_name', 'deduplicate', 'validate_identity', 'complete_fields'];
1783
1877
  const DEFAULT_ENRICHMENT_KEYS = ['credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status'];
1784
- const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。可点击输入框左上角「提示词生成」录入名单、上传 Excel 或图片,也可直接修改本段任务说明后开始。';
1878
+ const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。请粘贴企业名称,或上传 Excel / 名单图片,并说明需要补全的字段;也可点击左上角「提示词生成」设置匹配与输出。例如:补全【企业名单】的统一社会信用代码、法定代表人和联系方式,结果导出为 Excel。';
1879
+ const INITIAL_DRAFT_TEMPLATE = Object.freeze({ id: 'dsh-initial-draft/data-cleaning/1', version: 1,
1880
+ fingerprint: 'sha256:2b84b86f669e64e970381e4c7ab8547c77d5e10ee7d5441543c893b9f1272e2b', text: DEFAULT_SESSION_PROMPT });
1881
+ const INITIAL_DRAFT_LEDGER = Symbol.for('dsh.data-cleaning.initial-draft.v1');
1882
+ const compositionState = document[Symbol.for('dsh.data-cleaning.composition.v1')] ||= { active: false };
1883
+ let initialDraftEpoch = 0;
1785
1884
  const CLEANING_SESSION_STORAGE_KEYS = [
1786
1885
  'dsh.data-cleaning-agent.active-session.v2',
1787
1886
  'dsh.data-cleaning-agent.sessions.v1',
@@ -1827,28 +1926,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1827
1926
  }
1828
1927
 
1829
1928
  function isKnownCleaningDraft(value) {
1830
- if (value === DEFAULT_SESSION_PROMPT) return true;
1831
- if (typeof value !== 'string') return false;
1832
- const wizardDraft = value.startsWith('请执行一项企业名单数据清洗补全任务。')
1833
- && value.includes('企查查连接、套餐额度和费用均由当前用户自己的账号承担。')
1834
- && value.includes('提供结果和待复核清单的导出。');
1835
- const executionDraft = value.startsWith('请执行已在「数据清洗补全工作台」确认的企业数据任务。')
1836
- && value.includes('安全任务凭证:dcq-')
1837
- && value.includes('data_cleaning_qcc_run');
1838
- const imageDraft = value.startsWith('请识别我刚刚在向导中安全暂存的企业名单图片')
1839
- && value.includes('安全图片凭证:dci-')
1840
- && value.includes('data_cleaning_extract_image_companies');
1841
- return wizardDraft || executionDraft || imageDraft;
1929
+ // Exact text is the fingerprint: no trimming, substring or fuzzy recognition.
1930
+ return value === INITIAL_DRAFT_TEMPLATE.text;
1842
1931
  }
1843
1932
 
1844
- function clearCleaningDraft(ctx, sessionId, onlyDefault = false) {
1845
- if (!sessionId) return false;
1846
- const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
1847
- const shell = conversation?.input?.shell?.(sessionId);
1848
- if (!shell || typeof shell.setDraft !== 'function') return false;
1849
- if (onlyDefault && !isKnownCleaningDraft(shell.snapshot?.draft)) return false;
1850
- shell.setDraft('');
1851
- return true;
1933
+ function wizardListEntries(value) {
1934
+ if (isKnownCleaningDraft(value)) return [];
1935
+ return String(value || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1936
+ }
1937
+
1938
+ function invalidListInstruction(values) {
1939
+ return values.some((value) => /【[^】]*】|^(?:请|例如[::]|输入来源[::]|结果导出|需要补全)/u.test(value));
1852
1940
  }
1853
1941
 
1854
1942
  function deactivateCleaningSession(sessionId = activeCleaningSessionId) {
@@ -1889,43 +1977,19 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1889
1977
  */
1890
1978
  function installSessionOwnershipBridge(ctx) {
1891
1979
  const releaseNewSessionBridge = installNewSessionBridge(ctx);
1892
- let staleDraftObserver = null;
1893
- let staleDraftObserverTimer = null;
1894
- const clearStaleDefaultDraft = () => {
1895
- if (activeCleaningSessionId) return false;
1896
- const currentSessionId = ctx.sessions?.list?.getSnapshot?.()?.current;
1897
- const cleared = clearCleaningDraft(ctx, currentSessionId, true);
1898
- if (cleared && staleDraftObserver) {
1899
- staleDraftObserver.disconnect();
1900
- staleDraftObserver = null;
1901
- }
1902
- return cleared;
1903
- };
1980
+ // Observe composition only; never read/write another product's draft.
1981
+ const compositionStart = () => { compositionState.active = true; };
1982
+ const compositionEnd = () => { compositionState.active = false; };
1983
+ document.addEventListener?.('compositionstart', compositionStart, true);
1984
+ document.addEventListener?.('compositionend', compositionEnd, true);
1904
1985
  const leaveSubsystem = () => {
1905
1986
  const sessionId = activeCleaningSessionId;
1906
- if (sessionId && !ownedSessionId(sessionId)) clearCleaningDraft(ctx, sessionId, true);
1907
1987
  deactivateCleaningSession(sessionId);
1908
1988
  };
1909
1989
  // Only our independently-created native session namespace may regain ownership.
1910
1990
  const initialSession = ctx.sessions?.list?.getSnapshot?.()?.current;
1911
1991
  if (ownedSessionId(initialSession)) markCleaningSession(initialSession);
1912
- clearStaleDefaultDraft();
1913
- // DSH 会在插件 apply() 之后异步恢复 composer draft。在首屏稳定期内
1914
- // 监听 DOM 变化并仅清理完全匹配本插件默认文案,或同时命中
1915
- // 提示词向导固定首尾与费用声明签名的草稿;不触碰用户自写内容。
1916
- if (typeof MutationObserver === 'function' && document.documentElement) {
1917
- staleDraftObserver = new MutationObserver(clearStaleDefaultDraft);
1918
- staleDraftObserver.observe(document.documentElement, {
1919
- childList: true,
1920
- characterData: true,
1921
- subtree: true,
1922
- });
1923
- staleDraftObserverTimer = setTimeout(() => {
1924
- staleDraftObserver?.disconnect();
1925
- staleDraftObserver = null;
1926
- staleDraftObserverTimer = null;
1927
- }, 10_000);
1928
- }
1992
+ // Restore ownership only. Refresh/remount/navigation must never seed or clear drafts.
1929
1993
  const unsubscribe = ctx.sessions?.list?.subscribe?.((snapshot) => {
1930
1994
  // Store subscriptions may signal without a payload; read authoritative state.
1931
1995
  const selected = ctx.sessions?.list?.getSnapshot?.() ?? snapshot;
@@ -1936,14 +2000,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1936
2000
  }
1937
2001
  if (!activeCleaningSessionId && ownedSessionId(nextSessionId)) {
1938
2002
  markCleaningSession(nextSessionId);
1939
- } else if (!activeCleaningSessionId) {
1940
- clearStaleDefaultDraft();
1941
2003
  }
1942
2004
  });
1943
2005
  return () => {
2006
+ initialDraftEpoch++;
1944
2007
  releaseNewSessionBridge();
1945
- staleDraftObserver?.disconnect();
1946
- if (staleDraftObserverTimer !== null) clearTimeout(staleDraftObserverTimer);
2008
+ document.removeEventListener?.('compositionstart', compositionStart, true);
2009
+ document.removeEventListener?.('compositionend', compositionEnd, true);
1947
2010
  if (typeof unsubscribe === 'function') unsubscribe();
1948
2011
  };
1949
2012
  }
@@ -2031,6 +2094,17 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2031
2094
  }
2032
2095
 
2033
2096
  const MAPPING_ALIASES = {
2097
+ province: ['省', '所属省份'], city: ['市', '所属市', '所属城市'],
2098
+ district: ['区县', '所属区县', '所属区域'], area_code: ['区域编码', '地区编码', '行政区划代码'],
2099
+ industry_section: ['国标行业一级', '国标行业门类名称'],
2100
+ industry_large: ['国标行业二级', '国标行业大类名称'],
2101
+ industry_middle: ['国标行业三级', '国标行业中类名称'],
2102
+ industry_small: ['国标行业四级', '国标行业小类名称'],
2103
+ qcc_industry_level1: ['企查查一级行业', '企查查行业一级分类'],
2104
+ qcc_industry_level2: ['企查查二级行业', '企查查行业二级分类'],
2105
+ qcc_industry_level3: ['企查查三级行业', '企查查行业三级分类'],
2106
+ qcc_industry_level4: ['企查查四级行业', '企查查行业四级分类'],
2107
+ main_products: ['主要产品'], company_scale: ['企业规模分类'],
2034
2108
  beneficial_owner_first_name: ['首条受益所有人名称'],
2035
2109
  actual_controller_name: ['实控人', '实际控制人', '企业实控人名称', '企业实际控制人', '企业实控人名称(自然人请填写姓名)'],
2036
2110
  actual_controller_direct_ratio: ['实际控制人直接持股比例'],
@@ -2406,6 +2480,32 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2406
2480
  return cacheWorkflowTask(actions, sessionId, response.task, reason);
2407
2481
  }
2408
2482
 
2483
+ async function verifyRestoredRuntime(actions, sessionId, task, runtime) {
2484
+ if (!runtime?.restoredFromSession) return runtime;
2485
+ if (!task?.id || !task.source?.checksum || !runtime.sourceRows?.length) {
2486
+ if (task?.id) removeRuntime(task.id);
2487
+ actions.setDataset?.(null);
2488
+ throw new Error('刷新缓存缺少同源校验信息,请重新载入原文件或原文本。');
2489
+ }
2490
+ const response = await requestJson(
2491
+ `/data-cleaning/api/workflow/tasks/${encodeURIComponent(task.id)}/verify-source`,
2492
+ 'POST',
2493
+ {
2494
+ originSessionId: sessionId,
2495
+ originWorkspaceId: task.originWorkspaceId,
2496
+ headers: runtime.headers,
2497
+ rows: runtime.sourceRows,
2498
+ },
2499
+ );
2500
+ if (response.verified !== true) {
2501
+ removeRuntime(task.id);
2502
+ actions.setDataset?.(null);
2503
+ throw new Error('刷新缓存与原任务不一致,已停止本次操作。请重新载入原文件或新建任务。');
2504
+ }
2505
+ runtime.restoredFromSession = false;
2506
+ return runtime;
2507
+ }
2508
+
2409
2509
  async function stageConfirmedWorkflow(actions, sessionId, expected = null) {
2410
2510
  const host = await requestJson('/data-cleaning/api/g5/capabilities');
2411
2511
  if (host?.workflowExecutionVersion !== 1) throw new Error('页面与 Host 版本不一致。请保存任务后升级并完整重启 DSH;当前 Host 不支持自动生成结果文件。');
@@ -2427,9 +2527,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2427
2527
  throw new Error('工作台保存的范围与向导不一致,请重试确认;未生成执行凭证。');
2428
2528
  }
2429
2529
  }
2430
- const runtime = runtimeFor(task.id, false);
2530
+ const runtime = runtimeFor(task.id, false, task.source?.checksum || '');
2431
2531
  if (!task.fieldSelection?.length) throw new Error('当前未选择企查查补全字段。请在规则页选择补全字段,或使用工作台本地清洗与结果导出;不会发起企查查调用。');
2432
2532
  if (!runtime?.rows?.length) throw new Error('Host 名单尚未载入,请先解析数据。');
2533
+ await verifyRestoredRuntime(actions, sessionId, task, runtime);
2433
2534
  if (task.state === 'uploaded') task = await workflowAction(actions, sessionId, task, 'rules', {
2434
2535
  objectives: task.objectives, fieldSelection: task.fieldSelection, mappings: task.mappings, matchRules: task.matchRules,
2435
2536
  });
@@ -2725,13 +2826,45 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2725
2826
  return `${CLEANING_SESSION_ID_PREFIX}${globalThis.crypto.randomUUID()}`;
2726
2827
  }
2727
2828
 
2829
+ function emptyInitialInput(snapshot) {
2830
+ // Missing Host capabilities fail closed. draftRev also detects type-then-clear.
2831
+ return snapshot?.phase === 'plain' && snapshot.draft === ''
2832
+ && snapshot.draftRev === 0 && Array.isArray(snapshot.imageIds) && snapshot.imageIds.length === 0
2833
+ && Array.isArray(snapshot.occurrences) && snapshot.occurrences.length === 0;
2834
+ }
2835
+
2836
+ async function initializeCleaningDraft(ctx, sessionId, entry) {
2837
+ const ledger = document[INITIAL_DRAFT_LEDGER] ||= new Map();
2838
+ if (!ownedSessionId(sessionId) || entry?.createdId !== sessionId || ledger.has(sessionId)) return false;
2839
+ // Reserve before yielding: no duplicate initialization, even on plugin remount.
2840
+ ledger.set(sessionId, { templateId: INITIAL_DRAFT_TEMPLATE.id, state: 'skipped' });
2841
+ const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
2842
+ const shell = conversation?.input?.shell?.(sessionId);
2843
+ const eligible = () => !compositionState.active && !entry.interrupted()
2844
+ && ctx.sessions.list.getSnapshot().current === entry.previousSession
2845
+ && typeof shell?.setDraft === 'function' && typeof shell.state?.subscribe === 'function'
2846
+ && emptyInitialInput(shell.snapshot);
2847
+ if (!eligible()) return false;
2848
+ const revision = shell.snapshot.draftRev;
2849
+ let changed = false;
2850
+ const release = shell.state?.subscribe?.(() => { if (!emptyInitialInput(shell.snapshot)) changed = true; });
2851
+ try {
2852
+ await Promise.resolve();
2853
+ // Authoritative second snapshot, not captured render props. No await between CAS and write.
2854
+ if (changed || !eligible() || shell.snapshot.draftRev !== revision) return false;
2855
+ shell.setDraft(INITIAL_DRAFT_TEMPLATE.text);
2856
+ const accepted = shell.snapshot?.draft === INITIAL_DRAFT_TEMPLATE.text;
2857
+ ledger.set(sessionId, { templateId: INITIAL_DRAFT_TEMPLATE.id, state: accepted ? 'initialized' : 'skipped' });
2858
+ return accepted;
2859
+ } finally { if (typeof release === 'function') release(); }
2860
+ }
2861
+
2728
2862
  /** 使用 DSH 原生工作区、会话和输入机打开中央对话,不构造第二套聊天界面。 */
2729
2863
  async function startCleaningSession(ctx) {
2730
2864
  const workspace = ctx.workspaces.list.getSnapshot();
2731
2865
  const current = ctx.sessions.list.getSnapshot().current;
2732
2866
  if (ownedSessionId(current)) {
2733
2867
  markCleaningSession(current);
2734
- requestWorkbenchOpen(workflowTaskBySession.get(String(current))?.stage || 'upload', current);
2735
2868
  return current;
2736
2869
  }
2737
2870
  const items = Array.isArray(workspace.items) ? workspace.items : [];
@@ -2752,16 +2885,34 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2752
2885
  throw new Error('当前 DSH 版本没有可用的独立会话创建能力');
2753
2886
  }
2754
2887
  const requestedId = createCleaningSessionId();
2755
- const sessionId = await ctx.sessions.create({ workspaceId: targetWorkspace.workspaceId, sessionId: requestedId });
2756
- if (sessionId !== requestedId) {
2757
- throw new Error('当前 DSH 版本不支持独立会话创建,请升级 DSH 后再打开数据清洗补全智能体');
2888
+ const entryEpoch = initialDraftEpoch;
2889
+ let interrupted = compositionState.active;
2890
+ const interrupt = () => { interrupted = true; };
2891
+ const events = ['beforeinput', 'input', 'paste', 'drop', 'compositionstart'];
2892
+ for (const event of events) document.addEventListener?.(event, interrupt, true);
2893
+ const stopNavigation = ctx.sessions.list.subscribe?.(() => {
2894
+ if (ctx.sessions.list.getSnapshot().current !== current) interrupt();
2895
+ });
2896
+ let sessionId;
2897
+ try {
2898
+ sessionId = await ctx.sessions.create({ workspaceId: targetWorkspace.workspaceId, sessionId: requestedId });
2899
+ if (sessionId !== requestedId) {
2900
+ throw new Error('当前 DSH 版本不支持独立会话创建,请升级 DSH 后再打开数据清洗补全智能体');
2901
+ }
2902
+ await initializeCleaningDraft(ctx, sessionId, {
2903
+ createdId: requestedId, previousSession: current,
2904
+ interrupted: () => interrupted || entryEpoch !== initialDraftEpoch,
2905
+ });
2906
+ // A late create must not bring the user back from another Session/product.
2907
+ if (!interrupted && entryEpoch === initialDraftEpoch && ctx.sessions.list.getSnapshot().current === current) {
2908
+ ctx.sessions.open(sessionId);
2909
+ markCleaningSession(sessionId);
2910
+ }
2911
+ return sessionId;
2912
+ } finally {
2913
+ if (typeof stopNavigation === 'function') stopNavigation();
2914
+ for (const event of events) document.removeEventListener?.(event, interrupt, true);
2758
2915
  }
2759
- const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
2760
- if (!conversation) throw new Error('DSH 对话服务尚未就绪,请稍后重试');
2761
- conversation.input.shell(sessionId).setDraft(DEFAULT_SESSION_PROMPT);
2762
- ctx.sessions.open(sessionId);
2763
- markCleaningSession(sessionId);
2764
- return sessionId;
2765
2916
  }
2766
2917
 
2767
2918
  /**
@@ -3385,6 +3536,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3385
3536
  return rows.map((row) => {
3386
3537
  const name = nameField ? String(row?.[nameField] ?? '').trim() : '';
3387
3538
  const credit = creditField ? String(row?.[creditField] ?? '').trim() : '';
3539
+ if (isKnownCleaningDraft(name)) return '';
3540
+ if (invalidListInstruction([name, credit])) {
3541
+ throw new Error('名单包含占位符、示例或任务说明,请提供实际企业名称或信用代码后再继续。');
3542
+ }
3388
3543
  return [name, credit].filter(Boolean).join(' | ');
3389
3544
  }).filter(Boolean).slice(0, 50);
3390
3545
  }
@@ -3465,6 +3620,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3465
3620
  });
3466
3621
  const [busy, setBusy] = react.useState(false);
3467
3622
  const [error, setError] = react.useState(null);
3623
+ const [draftPolicy, setDraftPolicy] = react.useState('cancel');
3624
+ const lastWizardDraft = react.useRef(null);
3625
+
3626
+ react.useEffect(() => { setDraftPolicy('cancel'); lastWizardDraft.current = null; }, [sessionId]);
3468
3627
 
3469
3628
  react.useEffect(() => {
3470
3629
  const commandId = imageCommand?.commandId;
@@ -3625,11 +3784,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3625
3784
  }
3626
3785
  };
3627
3786
  const selectedEntries = () => mode === 'text'
3628
- ? rawText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
3787
+ ? wizardListEntries(rawText)
3629
3788
  : entries;
3630
3789
  const validateSource = () => {
3631
3790
  const values = selectedEntries();
3632
3791
  if (mode === 'text' && !values.length) return '请先录入企业名称或统一社会信用代码,每行一条。';
3792
+ if (mode === 'text' && invalidListInstruction(values)) return '请提供实际企业名单,每行一个名称或信用代码;请勿将【企业名单】、示例或任务说明作为企业。';
3633
3793
  if (mode === 'text' && values.some(isImagePathEntry)) return '检测到图片文件路径,不是企业名单。请切换「上传图片识别」,重新选择或粘贴图片。';
3634
3794
  if (mode === 'excel' && !values.length) return '请先上传并解析 Excel / CSV / JSON 文件。';
3635
3795
  if (mode === 'image' && !imageCommand) return '请先选择、粘贴或拖入一张包含企业名单的图片。';
@@ -3661,16 +3821,26 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3661
3821
  return;
3662
3822
  }
3663
3823
  const inputSnapshot = getInputSnapshot?.(sessionId);
3664
- if (['adjudicating', 'submitting'].includes(inputSnapshot?.phase)) {
3824
+ if (!inputSnapshot || inputSnapshot.phase !== 'plain' || compositionState.active) {
3665
3825
  setError('当前对话正在提交,请等待完成后再回填。图片与字段选择已保留。');
3666
3826
  return;
3667
3827
  }
3828
+ const unchangedWizard = lastWizardDraft.current?.sessionId === sessionId
3829
+ && lastWizardDraft.current.draft === inputSnapshot.draft;
3830
+ const userDraft = inputSnapshot.draft && !isKnownCleaningDraft(inputSnapshot.draft) && !unchangedWizard;
3831
+ if (userDraft && draftPolicy === 'cancel') {
3832
+ setError('已保留当前草稿。请选择“替换”或“追加”后回填,或关闭向导取消。'); return;
3833
+ }
3834
+ const prefix = unchangedWizard ? lastWizardDraft.current.prefix
3835
+ : userDraft && draftPolicy === 'append' ? inputSnapshot.draft + '\n\n' : '';
3668
3836
  const config = promptConfig();
3669
3837
  if (mode === 'image' && imageCommand?.state === 'prepared' && imageAttachment && typeof removeImage === 'function') {
3670
3838
  // qcc-document-mcp 只读取 Host 暂存文件;发送前释放原生附件,文本模型不会收到图片。
3671
3839
  removeImage(sessionId, imageAttachment);
3672
3840
  setImageAttachment(null);
3673
3841
  }
3842
+ const attachmentSnapshot = JSON.stringify({imageIds:getInputSnapshot?.(sessionId)?.imageIds,
3843
+ occurrences:getInputSnapshot?.(sessionId)?.occurrences});
3674
3844
  setBusy(true);
3675
3845
  try {
3676
3846
  if (mode === 'text') {
@@ -3697,9 +3867,18 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3697
3867
  prompt = mode === 'image' && imageCommand?.state === 'prepared' ? command.prompt : command.prompt + '\n\n' + buildTaskPrompt(config);
3698
3868
  if (mode === 'image' && imageCommand?.state === 'prepared') setImageCommand(command);
3699
3869
  }
3870
+ const latest = getInputSnapshot?.(sessionId);
3871
+ if (!isCleaningSession(sessionId) || compositionState.active || latest?.phase !== 'plain'
3872
+ || latest.draft !== inputSnapshot.draft || latest.draftRev !== inputSnapshot.draftRev
3873
+ || JSON.stringify({imageIds:latest.imageIds,occurrences:latest.occurrences}) !== attachmentSnapshot) {
3874
+ throw new Error('草稿或会话已变化,未覆盖您的内容;请确认后再次回填。');
3875
+ }
3876
+ prompt = prefix + prompt;
3700
3877
  inputActions.setDraft(prompt);
3701
3878
  const updatedInput = getInputSnapshot?.(sessionId);
3702
3879
  if (updatedInput && updatedInput.draft !== prompt) throw new Error('当前对话尚未接收任务说明,请稍后重试回填。');
3880
+ lastWizardDraft.current = {sessionId,draft:prompt,prefix};
3881
+ setDraftPolicy('cancel');
3703
3882
  } catch (draftError) {
3704
3883
  setError(draftError instanceof Error ? draftError.message : String(draftError));
3705
3884
  return;
@@ -3963,6 +4142,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3963
4142
  ),
3964
4143
  h('details', { className: 'dcAgentExtraFields', open: mode !== 'excel' },
3965
4144
  h('summary', null, `可选:追加补全字段(共 ${ENRICHMENT_OPTIONS.length} 项)`),
4145
+ h('p', { className: 'dcAgentHint' }, CORE_FIELD_GUIDE),
3966
4146
  h('label', { className: 'dcAgentPromptField' },
3967
4147
  h('span', null, `查找补全字段 · 已选 ${enrichmentKeys.length}`),
3968
4148
  h('input', { type: 'search', className: 'dcAgentField', value: fieldQuery, 'aria-label': '查找补全字段',
@@ -3990,6 +4170,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3990
4170
  wizardStep === 4 ? h('div', { className: 'dcAgentWizardPane' },
3991
4171
  h('h4', null, '确认任务描述'),
3992
4172
  h('p', null, '回填后仍可在对话框继续编辑;回填本身不会调用企查查 MCP。'),
4173
+ h('label', null, '已有草稿时的回填方式 ',
4174
+ h('select', { 'aria-label':'已有草稿时的回填方式', value:draftPolicy, onChange:event=>setDraftPolicy(event.target.value) },
4175
+ h('option', {value:'cancel'}, '保留草稿(取消回填)'),
4176
+ h('option', {value:'replace'}, '替换已有草稿'),
4177
+ h('option', {value:'append'}, '追加到已有草稿'))),
3993
4178
  h('pre', { className: 'dcAgentPromptPreview' }, buildTaskPrompt(promptConfig())),
3994
4179
  ) : null,
3995
4180
  h('p', { className: 'dcAgentPromptNote' }, '向导只生成任务描述并保存任务设置。只有你在任务中明确确认后,才会使用当前客户自己的企查查 MCP 连接、账号和额度。'),
@@ -4185,6 +4370,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4185
4370
  sizeBytes: source.sizeBytes || 0,
4186
4371
  checksum: result.checksum || source.checksum || '',
4187
4372
  };
4373
+ runtime.restoredFromSession = false;
4188
4374
  const mappings = guessMappings(runtime.headers);
4189
4375
  const guessedNameField = mappings.find((mapping) => mapping.targetField === 'company_name')?.sourceField
4190
4376
  ?? mappings.find((mapping) => mapping.targetField === 'credit_no')?.sourceField
@@ -4214,6 +4400,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4214
4400
  sizeBytes: source.sizeBytes || task.source?.sizeBytes || 0,
4215
4401
  checksum: result.checksum || '',
4216
4402
  };
4403
+ runtime.restoredFromSession = false;
4217
4404
  actions.setDataset({
4218
4405
  fmt: result.fmt ?? task.source?.type ?? 'csv',
4219
4406
  headers: runtime.headers,
@@ -4222,6 +4409,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4222
4409
  });
4223
4410
  const primary = (task.mappings || []).find((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
4224
4411
  if (primary) actions.setNameField(primary.sourceField);
4412
+ persistRuntimeSession(task.id, runtime);
4225
4413
  return runtime;
4226
4414
  }
4227
4415
 
@@ -4244,6 +4432,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4244
4432
  sizeBytes: source.sizeBytes || 0,
4245
4433
  checksum: result.checksum || source.checksum || '',
4246
4434
  };
4435
+ target.restoredFromSession = false;
4247
4436
  if (['draft', 'uploaded', 'parse_failed'].includes(current.state)) {
4248
4437
  current = await workflowAction(actions, sessionId, current, 'upload', {
4249
4438
  source: {
@@ -4258,6 +4447,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4258
4447
  }, 'import');
4259
4448
  }
4260
4449
  if (current.state === 'uploaded') current = await updateWorkflowTask(actions, sessionId, current, importedDraft);
4450
+ persistRuntimeSession(current.id, target, stagingKey);
4261
4451
  return current;
4262
4452
  }
4263
4453
 
@@ -4443,7 +4633,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4443
4633
  const objectives = useStore((state) => state.objectives);
4444
4634
  const fieldSelection = useStore((state) => state.fieldSelection);
4445
4635
  const matchRules = useStore((state) => state.matchRules);
4446
- const dataset = useStore((state) => state.dataset);
4636
+ const storedDataset = useStore((state) => state.dataset);
4447
4637
  const profile = useStore((state) => state.profile);
4448
4638
  const clean = useStore((state) => state.clean);
4449
4639
  const complete = useStore((state) => state.complete);
@@ -4455,11 +4645,11 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4455
4645
  const paidConfirmed = useStore((state) => state.paidConfirmed);
4456
4646
  const activeSessionId = useStore((state) => state.activeSessionId);
4457
4647
  const [fieldQuery, setFieldQuery] = react.useState('');
4458
- const [intakeEditorOpen, setIntakeEditorOpen] = react.useState(() => dataset == null);
4648
+ const [intakeEditorOpen, setIntakeEditorOpen] = react.useState(() => storedDataset == null);
4459
4649
 
4460
4650
  react.useEffect(() => {
4461
- setIntakeEditorOpen(dataset == null);
4462
- }, [dataset]);
4651
+ setIntakeEditorOpen(storedDataset == null);
4652
+ }, [storedDataset]);
4463
4653
 
4464
4654
  react.useEffect(() => {
4465
4655
  if (!open || !visible) return;
@@ -4514,13 +4704,19 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4514
4704
 
4515
4705
  // Host 决定 Tab 挂载/可见性;内容不再按私有 open flag 隐藏。
4516
4706
 
4517
- const hasData = dataset !== null && dataset.rowCount > 0;
4518
- const requiresQcc = objectives.includes('validate_identity') || objectives.includes('complete_fields');
4519
4707
  const cachedTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? workflowTask;
4520
4708
  const runtimeKey = cachedTask?.id ?? `session:${activeSessionId || 'unassigned'}`;
4521
- const runtime = runtimeFor(runtimeKey);
4709
+ const runtime = runtimeFor(runtimeKey, true, cachedTask?.source?.checksum || '');
4522
4710
  const stagedSource = runtimeFor(`session:${activeSessionId || 'unassigned'}`, false);
4523
4711
  const sourceRows = stagedSource?.sourceRows ?? runtime.sourceRows ?? runtime.rows;
4712
+ const dataset = storedDataset ?? (sourceRows.length ? {
4713
+ fmt: runtime.source?.type || cachedTask?.source?.type || 'csv',
4714
+ headers: runtime.headers.length ? runtime.headers : cachedTask?.source?.headers || [],
4715
+ rowCount: cachedTask?.source?.rowCount ?? sourceRows.length,
4716
+ preview: sourceRows.slice(0, 5),
4717
+ } : null);
4718
+ const hasData = dataset !== null && dataset.rowCount > 0;
4719
+ const requiresQcc = objectives.includes('validate_identity') || objectives.includes('complete_fields');
4524
4720
  const intake = intakeState(dataset, sourceRows, input, runtime.source || stagedSource?.source || cachedTask?.source);
4525
4721
  const normalizedStage = normalizeViewStage(step);
4526
4722
  const flowCompatible = cachedTask?.flow?.flowVersion === SUPPORTED_FLOW_VERSION;
@@ -4542,8 +4738,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4542
4738
  const showIntakeEditor = canAction('import-data') && (!dataset || intakeEditorOpen);
4543
4739
  const runtimeRecoveryNeeded = Boolean(cachedTask?.source?.rowCount) && runtime.rows.length === 0;
4544
4740
  const runtimeRecoveryAllowed = runtimeRecoveryNeeded
4545
- && isCurrentView
4546
- && ['uploaded', 'rules_confirmed', 'diagnosed'].includes(cachedTask?.state)
4741
+ && ((isCurrentView && ['uploaded', 'rules_confirmed', 'diagnosed'].includes(cachedTask?.state))
4742
+ || (normalizedStage === 'upload' && viewAccess === 'read'))
4547
4743
  && cachedTask?.source?.type !== 'image';
4548
4744
  const lastCsv = runtime.lastCsv;
4549
4745
  const fieldByPattern = (pattern) => runtime.headers.find((field) => pattern.test(field)) ?? null;
@@ -4665,6 +4861,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4665
4861
  };
4666
4862
 
4667
4863
  const performProfile = async (task) => {
4864
+ await verifyRestoredRuntime(actions, activeSessionId, task, runtime);
4668
4865
  const r = await api('/data-cleaning/api/mvp/profile', {
4669
4866
  rows: runtime.rows,
4670
4867
  headers: runtime.headers,
@@ -4700,6 +4897,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4700
4897
  actions.setBusy(true);
4701
4898
  actions.setError(null);
4702
4899
  try {
4900
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4703
4901
  const r = await api('/data-cleaning/api/mvp/clean', {
4704
4902
  rows: runtime.rows,
4705
4903
  headers: runtime.headers,
@@ -4728,6 +4926,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4728
4926
  actions.setBusy(true);
4729
4927
  actions.setError(null);
4730
4928
  try {
4929
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4731
4930
  const r = await api('/data-cleaning/api/mvp/complete', {
4732
4931
  rows: runtime.rows,
4733
4932
  headers: runtime.headers,
@@ -4900,6 +5099,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4900
5099
  actions.setBusy(true);
4901
5100
  actions.setError(null);
4902
5101
  try {
5102
+ await verifyRestoredRuntime(actions, activeSessionId, cachedTask, runtime);
4903
5103
  let current = await ensureWorkflowTask(actions, activeSessionId, {
4904
5104
  title: taskTitle, objectives, fieldSelection, mappings, matchRules,
4905
5105
  });
@@ -4957,9 +5157,9 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4957
5157
  actions.setError('当前会话的任务向导尚未就绪或正在解析图片,请稍后重试。');
4958
5158
  return;
4959
5159
  }
4960
- if (cachedTask?.id) runtimeTasks.delete(String(cachedTask.id));
4961
- runtimeTasks.delete(`session:${activeSessionId || 'unassigned'}`);
4962
- runtimeTasks.delete('unassigned');
5160
+ if (cachedTask?.id) removeRuntime(cachedTask.id);
5161
+ removeRuntime(`session:${activeSessionId || 'unassigned'}`);
5162
+ removeRuntime('unassigned');
4963
5163
  workflowTaskBySession.delete(String(activeSessionId || 'unassigned'));
4964
5164
  actions.resetWorkflow();
4965
5165
  actions.close();
@@ -4977,6 +5177,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4977
5177
  if (Array.isArray(latest.artifacts) && latest.artifacts.length) return latest;
4978
5178
  const rows = exportRows();
4979
5179
  if (!rows?.length) throw new Error('当前页面没有可导出的明细。若任务来自历史记录,请重新上传源文件并继续处理。');
5180
+ await verifyRestoredRuntime(actions, activeSessionId, latest, runtime);
4980
5181
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts`, 'POST', {
4981
5182
  originSessionId: activeSessionId, originWorkspaceId: latest.originWorkspaceId,
4982
5183
  expectedRevision: latest.revision,
@@ -5256,10 +5457,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5256
5457
  h('details', { className: 'dcAgentExtraFields' },
5257
5458
  h('summary', null, `可选:追加补全字段(共 ${targetFields.filter(([id]) => id !== 'phone').length} 项)`),
5258
5459
  h('div', { className: 'dcAgentRow', 'aria-label': '补全字段批次概览' },
5259
- h('span', { className: 'dcAgentChip' }, '基础字段 30'),
5260
- h('span', { className: 'dcAgentChip' }, '第一批 40'),
5261
- h('span', { className: 'dcAgentChip' }, '第二批 58'),
5460
+ h('span', { className: 'dcAgentChip' }, `工商与简介 ${uiFieldGroups.filter(([id]) => ['company_registration', 'company_profile'].includes(id)).reduce((sum, [, , fields]) => sum + fields.length, 0)} 项`),
5461
+ h('span', { className: 'dcAgentChip' }, `共 ${targetFields.filter(([id]) => id !== 'phone').length} 项可选字段`),
5262
5462
  ),
5463
+ h('p', { className: 'dcAgentHint' }, CORE_FIELD_GUIDE),
5263
5464
  h('label', { className: 'dcAgentPromptField' }, h('span', null, '查找补全字段'),
5264
5465
  h('input', { type: 'search', className: 'dcAgentField', value: fieldQuery, 'aria-label': '查找补全字段',
5265
5466
  placeholder: '搜索维度、字段或工具名称', onChange: (event) => setFieldQuery(event.target.value) })),
@@ -5287,22 +5488,30 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5287
5488
  ),
5288
5489
  );
5289
5490
  } else if (step === 'profile') {
5491
+ const persistedQuality = cachedTask?.qualitySummary;
5290
5492
  pane = h('div', { className: 'dcAgentPane dcAgentProfilePane' },
5291
5493
  h('section', { className: 'dcAgentSection dcAgentProfileSurface', 'aria-label': '质量体检报告' },
5292
5494
  h('header', { className: 'dcAgentProfileHeader' },
5293
5495
  h('h3', null, '质量体检报告'),
5294
5496
  h('p', { className: 'dcAgentHint' }, '基于已解析数据生成本地质量画像(缺失率、去重值、金额分布)。本步骤不发起企查查调用。'),
5295
5497
  ),
5296
- profile ? h('div', { className: 'dcAgentProfileReport' },
5498
+ profile || persistedQuality ? h('div', { className: 'dcAgentProfileReport' },
5499
+ !profile ? h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的体检摘要;字段级明细未持久化。') : null,
5297
5500
  h('div', { className: 'dcAgentGrid dcAgentProfileMetrics', 'aria-label': '体检摘要' },
5298
- stat('行数', profile.rowCount ?? '—'),
5299
- stat('列数', profile.columnCount ?? '—'),
5300
- profile.amountStats ? stat('金额 最小', profile.amountStats.min) : null,
5301
- profile.amountStats ? stat('金额 最大', profile.amountStats.max) : null,
5302
- profile.amountStats ? stat('金额 总和', profile.amountStats.sum) : null,
5303
- profile.amountStats ? stat('金额 均值', typeof profile.amountStats.mean === 'number' ? profile.amountStats.mean.toFixed(2) : profile.amountStats.mean) : null,
5501
+ stat('行数', profile?.rowCount ?? persistedQuality?.total ?? cachedTask?.source?.rowCount ?? '—'),
5502
+ stat('列数', profile?.columnCount ?? cachedTask?.source?.columnCount ?? '—'),
5503
+ profile?.amountStats ? stat('金额 最小', profile.amountStats.min) : null,
5504
+ profile?.amountStats ? stat('金额 最大', profile.amountStats.max) : null,
5505
+ profile?.amountStats ? stat('金额 总和', profile.amountStats.sum) : null,
5506
+ profile?.amountStats ? stat('金额 均值', typeof profile.amountStats.mean === 'number' ? profile.amountStats.mean.toFixed(2) : profile.amountStats.mean) : null,
5507
+ !profile && persistedQuality ? stat('有效主体', persistedQuality.valid, persistedQuality.valid > 0 ? 'good' : null) : null,
5508
+ !profile && persistedQuality ? stat('缺少主体标识', persistedQuality.missingAnchor, persistedQuality.missingAnchor > 0 ? 'bad' : null) : null,
5509
+ !profile && persistedQuality ? stat('重复主体', persistedQuality.duplicates, persistedQuality.duplicates > 0 ? 'warn' : null) : null,
5510
+ !profile && persistedQuality ? stat('信用代码异常', persistedQuality.invalidCreditNo, persistedQuality.invalidCreditNo > 0 ? 'warn' : null) : null,
5511
+ !profile && persistedQuality ? stat('联系电话异常', persistedQuality.invalidPhone, persistedQuality.invalidPhone > 0 ? 'warn' : null) : null,
5512
+ !profile && persistedQuality ? stat('空白字段', persistedQuality.emptyFields, persistedQuality.emptyFields > 0 ? 'warn' : null) : null,
5304
5513
  ),
5305
- Array.isArray(profile.columns) && profile.columns.length ? h('section', { className: 'dcAgentProfileTableSection', 'aria-label': '字段质量明细' },
5514
+ Array.isArray(profile?.columns) && profile.columns.length ? h('section', { className: 'dcAgentProfileTableSection', 'aria-label': '字段质量明细' },
5306
5515
  h('div', { className: 'dcAgentProfileTableHead' },
5307
5516
  h('h4', null, '字段质量明细'),
5308
5517
  h('span', { className: 'dcAgentHint' }, `共 ${profile.columns.length} 列`),
@@ -5393,9 +5602,22 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5393
5602
  canAction('resolve-candidate') ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !paidConfirmed, onClick: () => resolveCandidate(item, candidate) }, '确认此主体') : null,
5394
5603
  )),
5395
5604
  )),
5605
+ ) : cachedTask?.matchSummary ? h('section', { className: 'dcAgentSection', 'aria-label': '主体匹配结果摘要' },
5606
+ h('h3', null, '主体匹配结果'),
5607
+ h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的任务摘要;逐行结果和候选详情未持久化。'),
5608
+ h('div', { className: 'dcAgentGrid' },
5609
+ stat('总数', cachedTask.matchSummary.total),
5610
+ stat('精确匹配', (cachedTask.matchSummary.exact ?? 0) + (cachedTask.matchSummary.confirmed ?? 0), 'good'),
5611
+ stat('候选记录', cachedTask.matchSummary.candidate, cachedTask.matchSummary.candidate > 0 ? 'warn' : null),
5612
+ stat('待核验', cachedTask.matchSummary.reviewRequired, cachedTask.matchSummary.reviewRequired > 0 ? 'warn' : null),
5613
+ stat('未匹配', cachedTask.matchSummary.unresolved, cachedTask.matchSummary.unresolved > 0 ? 'warn' : null),
5614
+ stat('失败', cachedTask.matchSummary.failed, cachedTask.matchSummary.failed > 0 ? 'bad' : null),
5615
+ ),
5396
5616
  ) : null,
5397
5617
  );
5398
5618
  } else if (step === 'enrich') {
5619
+ const persistedEnrichment = cachedTask?.enrichmentSummary;
5620
+ const hasRuntimeEnrichment = Boolean(clean || complete || qccRun);
5399
5621
  pane = h('div', { className: 'dcAgentPane' },
5400
5622
  h('p', { className: 'dcAgentHint' }, '执行本地确定性清洗并核对本任务所选补全字段。外部字段以当前用户企查查 MCP 的真实返回为准,缺失值不会被编造。'),
5401
5623
  h('section', { className: 'dcAgentSection' },
@@ -5414,13 +5636,23 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5414
5636
  stat('重复', clean.badDuplicate, clean.badDuplicate > 0 ? 'warn' : null),
5415
5637
  ) : null,
5416
5638
  ),
5417
- h('div', { className: 'dcAgentGrid' },
5418
- stat('输入行数', dataset ? dataset.rowCount : '—'),
5419
- stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
5420
- stat('本地补全', complete ? complete.completed : '—', complete && complete.completed > 0 ? 'good' : null),
5421
- stat('QCC 已补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
5422
- stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
5423
- ),
5639
+ persistedEnrichment && !hasRuntimeEnrichment ? h('section', { className: 'dcAgentSection', 'aria-label': '字段补全结果摘要' },
5640
+ h('h3', null, '字段补全结果'),
5641
+ h('p', { className: 'dcAgentHint', role: 'status' }, '以下为 Host 保存的任务摘要;逐行补全结果请从结果下载查看。'),
5642
+ h('div', { className: 'dcAgentGrid' },
5643
+ stat('总数', persistedEnrichment.total),
5644
+ stat('已补全', persistedEnrichment.completed, persistedEnrichment.completed > 0 ? 'good' : null),
5645
+ stat('原值保留', persistedEnrichment.unchanged),
5646
+ stat('待核验', persistedEnrichment.reviewRequired, persistedEnrichment.reviewRequired > 0 ? 'warn' : null),
5647
+ stat('失败', persistedEnrichment.failed, persistedEnrichment.failed > 0 ? 'bad' : null),
5648
+ ),
5649
+ ) : h('div', { className: 'dcAgentGrid' },
5650
+ stat('输入行数', dataset ? dataset.rowCount : '—'),
5651
+ stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
5652
+ stat('本地补全', complete ? complete.completed : '—', complete && complete.completed > 0 ? 'good' : null),
5653
+ stat('QCC 已补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
5654
+ stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
5655
+ ),
5424
5656
  canAction('create-local-artifacts') ? h('div', { className: 'dcAgentRow' }, h('button', {
5425
5657
  type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !exportRows()?.length,
5426
5658
  onClick: () => createAndDownload('complete', 'xlsx'),
@@ -6114,7 +6346,6 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
6114
6346
  DatasetReview,
6115
6347
  reviewColumns,
6116
6348
  reviewCsv,
6117
- clearCleaningDraft,
6118
6349
  entriesToDataset,
6119
6350
  extractPromptEntries,
6120
6351
  imageFilesFromTransfer,
@@ -6123,6 +6354,8 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
6123
6354
  guessMappings,
6124
6355
  intakeState,
6125
6356
  restoreParsedRuntime,
6357
+ runtimeFor,
6358
+ verifyRestoredRuntime,
6126
6359
  projectCompletionResult,
6127
6360
  mappingRecommendations,
6128
6361
  mappedOutputFields,
@@ -6135,6 +6368,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
6135
6368
  installNewSessionBridge,
6136
6369
  isCleaningSession,
6137
6370
  isKnownCleaningDraft,
6371
+ wizardListEntries,
6372
+ invalidListInstruction,
6373
+ INITIAL_DRAFT_TEMPLATE,
6374
+ initializeCleaningDraft,
6375
+ emptyInitialInput,
6376
+ startCleaningSession,
6138
6377
  markCleaningSession,
6139
6378
  qualitySummaryFor,
6140
6379
  plainEntityListDataset,
@@ -102,8 +102,16 @@ export const QCC_FIELD_CATALOG = Object.freeze([
102
102
  field('registered_address', '注册地址', { defaultSelected: true }),
103
103
  field('mailing_address', '通信地址'),
104
104
  field('region', '所属地区', { matchAuxiliary: true }),
105
+ field('province', '省份', { sourcePath: '地区信息.省份' }),
106
+ field('city', '城市', { sourcePath: '地区信息.城市' }),
107
+ field('district', '区域', { sourcePath: '地区信息.区域' }),
108
+ field('area_code', '地区代码', { sourcePath: '地区信息.地区代码' }),
105
109
  field('business_scope', '经营范围'),
106
110
  field('industry_category', '国标行业'),
111
+ field('industry_section', '国标行业门类', { sourcePath: '国标行业.门类' }),
112
+ field('industry_large', '国标行业大类', { sourcePath: '国标行业.大类' }),
113
+ field('industry_middle', '国标行业中类', { sourcePath: '国标行业.中类' }),
114
+ field('industry_small', '国标行业小类', { sourcePath: '国标行业.小类' }),
107
115
  field('operating_period', '营业期限'),
108
116
  field('company_size', '人员规模'),
109
117
  field('insured_count', '参保人数'),
@@ -111,6 +119,12 @@ export const QCC_FIELD_CATALOG = Object.freeze([
111
119
  ], { releaseBatch: 'current' }),
112
120
  group('company_profile', '企业简介', 'get_company_profile', [
113
121
  field('qcc_industry', '企查查行业'),
122
+ field('qcc_industry_level1', '企查查行业一级', { sourcePath: '企查查行业.一级' }),
123
+ field('qcc_industry_level2', '企查查行业二级', { sourcePath: '企查查行业.二级' }),
124
+ field('qcc_industry_level3', '企查查行业三级', { sourcePath: '企查查行业.三级' }),
125
+ field('qcc_industry_level4', '企查查行业四级', { sourcePath: '企查查行业.四级' }),
126
+ field('main_products', '主营产品', { sourcePath: '主营产品' }),
127
+ field('company_scale', '企业规模', { sourcePath: '企业规模' }),
114
128
  field('company_profile', '企业简介'),
115
129
  field('industry_chain_overview', '产业链概览'),
116
130
  ], { releaseBatch: 'current' }),
package/lib/qcc.js CHANGED
@@ -375,8 +375,16 @@ export function mapRegistrationFields(value, fallback = {}) {
375
375
  registered_address: text('注册地址', '住所', '经营场所'),
376
376
  mailing_address: text('通信地址'),
377
377
  region: text('所属地区'),
378
+ province: coreFieldText(value.地区信息?.省份),
379
+ city: coreFieldText(value.地区信息?.城市),
380
+ district: coreFieldText(value.地区信息?.区域),
381
+ area_code: coreFieldText(value.地区信息?.地区代码),
378
382
  business_scope: text('经营范围'),
379
- industry_category: text('国标行业'),
383
+ industry_category: industryDisplay(value.国标行业, ['门类', '大类', '中类', '小类']),
384
+ industry_section: coreFieldText(value.国标行业?.门类),
385
+ industry_large: coreFieldText(value.国标行业?.大类),
386
+ industry_middle: coreFieldText(value.国标行业?.中类),
387
+ industry_small: coreFieldText(value.国标行业?.小类),
380
388
  operating_period: text('营业期限', '经营期限'),
381
389
  company_size: text('人员规模'),
382
390
  insured_count: text('参保人数'),
@@ -384,16 +392,36 @@ export function mapRegistrationFields(value, fallback = {}) {
384
392
  };
385
393
  }
386
394
 
395
+ // Explicit leaves only: old flat text must not be guessed into industry levels.
396
+ function coreFieldText(value) {
397
+ return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
398
+ }
399
+
400
+ function industryDisplay(value, levels) {
401
+ if (!isRecord(value)) return coreFieldText(value);
402
+ return levels.map(level => {
403
+ const text = coreFieldText(value[level]);
404
+ return text.trim() ? `${level}:${text}` : '';
405
+ }).filter(Boolean).join(';');
406
+ }
407
+
387
408
  export function mapProfileFields(value) {
388
409
  if (!isRecord(value)) {
389
410
  throw new QccBridgeError('QCC_CONTRACT_MISMATCH', 'QCC profile tool returned a non-object result');
390
411
  }
391
412
  if (value.无匹配项 !== undefined) return {};
392
413
  return {
393
- // 工具只返回“企查查行业”这一最细层级展示值,不得猜测为一级或二级行业。
394
- qcc_industry: String(value.企查查行业 ?? ''),
395
- company_profile: String(value.企业简介 ?? value.简介 ?? ''),
396
- industry_chain_overview: String(value.产业链概览 ?? ''),
414
+ qcc_industry: industryDisplay(value.企查查行业, ['一级', '二级', '三级', '四级']),
415
+ qcc_industry_level1: coreFieldText(value.企查查行业?.一级),
416
+ qcc_industry_level2: coreFieldText(value.企查查行业?.二级),
417
+ qcc_industry_level3: coreFieldText(value.企查查行业?.三级),
418
+ qcc_industry_level4: coreFieldText(value.企查查行业?.四级),
419
+ main_products: Array.isArray(value.主营产品)
420
+ ? value.主营产品.filter(item => typeof item === 'string' && item.trim()).join(';')
421
+ : coreFieldText(value.主营产品),
422
+ company_scale: coreFieldText(value.企业规模),
423
+ company_profile: coreFieldText(value.企业简介 ?? value.简介),
424
+ industry_chain_overview: coreFieldText(value.产业链概览),
397
425
  };
398
426
  }
399
427
 
@@ -52,6 +52,7 @@ export function registerEnrichSkill(skills) {
52
52
  'When the user gives company names and asks to enrich / complete them with Qichacha (企查查), including registration fields, company panorama, ownership penetration, governance, historical changes, risk tags, intellectual property, or operational / bidding dimensions.',
53
53
  source: 'dsh-data-cleaning-agent',
54
54
  content: [
55
+ 'UX-49 最高优先级:先检查本业务请求的信息是否充分,再考虑以下任何工具或执行规则。若仍含未补全的【】占位符,或没有实际企业名单/Excel/名单图片与待补字段,先用中文要求提供名单或文件及待补字段;零工具调用,包括 OAuth 检查、体检、OCR、MCP、创建 workflow。例子、说明、【企业名单】均不是企业。用户改过的初始模板按普通内容理解,不得整体忽略。有真实暂存来源和已选字段的安全任务凭证可提供这些信息,不要求重复提供。此规则只作用于数据清洗补全业务,不得全局拦截 DSH 发送或其它产品。',
55
56
  'You are an enterprise-list enrichment assistant. You fill company lists with Qichacha (QCC) data by calling QCC MCP tools. Never invent, pad, or fabricate any field.',
56
57
  '',
57
58
  'Workbench execution request (highest priority):',
package/lib/skill.js CHANGED
@@ -15,6 +15,7 @@ export function registerSkill(skills) {
15
15
  'When the user asks to clean, complete, validate, de-duplicate, or summarize a batch of raw data rows, CSV records, or table-like data.',
16
16
  source: 'dsh-data-cleaning-agent',
17
17
  content: [
18
+ 'UX-49 — Highest priority, before ANY tool: within this data-cleaning business request, if the message still contains an unfilled 【】 placeholder, or lacks actual rows/list/file and requested completion fields, ask the user to provide the actual enterprise list or Excel/list image and the fields to complete. Make ZERO tool calls (including profile, OAuth checks, OCR, MCP and workflow creation). Do not treat examples, instructions or 【企业名单】 as enterprises. Do not intercept other DSH products or the global send action. A valid staged-source task credential can supply the list/field scope; do not demand the same data twice. An edited initial template is ordinary user content, never discard it wholesale.',
18
19
  'You are a data cleaning and completion assistant. Work only on the rows the user actually provided; never invent, pad, or fabricate extra rows.',
19
20
  '',
20
21
  'Workflow:',
package/lib/web.js CHANGED
@@ -653,6 +653,22 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
653
653
  return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'Artifact route requires GET or POST.' });
654
654
  }
655
655
 
656
+ if (rest.length === 2 && rest[1] === 'verify-source') {
657
+ if (req.method !== 'POST') {
658
+ return writeJson(res, 405, { ok: false, code: 'DC_METHOD', message: 'Source verification requires POST.' });
659
+ }
660
+ const body = await readBody(req);
661
+ const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
662
+ const current = await workflow.assertRequestOrigin(taskId, payload);
663
+ const verified = Boolean(current.source?.checksum)
664
+ && sourceVerifier(payload.headers, payload.rows, current.source.checksum) === current.source.checksum;
665
+ return writeJson(res, 200, {
666
+ ok: true,
667
+ marker: 'data-cleaning-source-verifier-v1',
668
+ verified,
669
+ });
670
+ }
671
+
656
672
  if (rest.length !== 3 || rest[1] !== 'actions' || req.method !== 'POST') {
657
673
  return writeJson(res, 404, { ok: false, code: 'DC_WORKFLOW_ROUTE', message: 'Workflow route not found.' });
658
674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-data-cleaning-agent",
3
- "version": "0.9.15",
3
+ "version": "0.9.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",
@@ -10,6 +10,7 @@
10
10
  "./package.json": "./package.json"
11
11
  },
12
12
  "files": [
13
+ "docs/CORE-FIELDS-20260918.md",
13
14
  "lib",
14
15
  "cordis.patch.yml",
15
16
  "README.md",