weaver-work-cli 0.1.8 → 0.1.21

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.
Files changed (271) hide show
  1. package/README.md +25 -17
  2. package/dist/cmd/action.js +76 -0
  3. package/dist/cmd/index.js +2 -0
  4. package/dist/cmd/refs/index.js +103 -0
  5. package/dist/cmd/register.js +31 -0
  6. package/dist/cmd/root.js +1 -0
  7. package/dist/cmd/setup/index.js +25 -5
  8. package/dist/cmd/skills/index.js +37 -5
  9. package/dist/core/connector.js +54 -0
  10. package/dist/core/context.js +2 -1
  11. package/dist/core/input.js +48 -0
  12. package/dist/core/output.js +298 -3
  13. package/dist/core/refs.js +435 -0
  14. package/dist/core/state.js +26 -0
  15. package/dist/internal/e10/auth/commands.js +34 -19
  16. package/dist/internal/e10/auth/session.js +74 -2
  17. package/dist/internal/e10/auth/xiaoe.js +359 -94
  18. package/dist/internal/e10/context.js +18 -6
  19. package/dist/internal/e10/request-runtime.js +8 -1
  20. package/dist/internal/skills/install.js +148 -28
  21. package/dist/internal/skills/reader.js +12 -9
  22. package/dist/shortcuts/blog/continuation.js +74 -0
  23. package/dist/shortcuts/blog/errors.js +60 -0
  24. package/dist/shortcuts/blog/host.js +175 -0
  25. package/dist/shortcuts/blog/index.js +324 -0
  26. package/dist/shortcuts/blog/manifest.js +415 -0
  27. package/dist/shortcuts/blog/operations/comment.js +125 -0
  28. package/dist/shortcuts/blog/operations/comments.js +31 -0
  29. package/dist/shortcuts/blog/operations/edit.js +131 -0
  30. package/dist/shortcuts/blog/operations/export.js +46 -0
  31. package/dist/shortcuts/blog/operations/like.js +80 -0
  32. package/dist/shortcuts/blog/operations/publish.js +157 -0
  33. package/dist/shortcuts/blog/operations/query-user.js +58 -0
  34. package/dist/shortcuts/blog/operations/query.js +96 -0
  35. package/dist/shortcuts/blog/operations/registry.js +54 -0
  36. package/dist/shortcuts/blog/operations/remind.js +176 -0
  37. package/dist/shortcuts/blog/operations/shared.js +330 -0
  38. package/dist/shortcuts/blog/operations/types.js +16 -0
  39. package/dist/shortcuts/blog/operations.js +9 -0
  40. package/dist/shortcuts/caizhilian/continuation.js +70 -0
  41. package/dist/shortcuts/caizhilian/errors.js +53 -0
  42. package/dist/shortcuts/caizhilian/host.js +190 -0
  43. package/dist/shortcuts/caizhilian/index.js +186 -0
  44. package/dist/shortcuts/caizhilian/manifest.js +42 -0
  45. package/dist/shortcuts/caizhilian/operations/api.js +801 -0
  46. package/dist/shortcuts/caizhilian/operations/confirm.js +75 -0
  47. package/dist/shortcuts/caizhilian/operations/files.js +306 -0
  48. package/dist/shortcuts/caizhilian/operations/form.js +170 -0
  49. package/dist/shortcuts/caizhilian/operations/handlers/collab.js +611 -0
  50. package/dist/shortcuts/caizhilian/operations/handlers/document.js +890 -0
  51. package/dist/shortcuts/caizhilian/operations/handlers/file-collection.js +1227 -0
  52. package/dist/shortcuts/caizhilian/operations/handlers/file-upload.js +192 -0
  53. package/dist/shortcuts/caizhilian/operations/handlers/fileops.js +570 -0
  54. package/dist/shortcuts/caizhilian/operations/handlers/folder.js +1024 -0
  55. package/dist/shortcuts/caizhilian/operations/handlers/search.js +318 -0
  56. package/dist/shortcuts/caizhilian/operations/handlers/setting-attach.js +294 -0
  57. package/dist/shortcuts/caizhilian/operations/handlers/setting-backup.js +423 -0
  58. package/dist/shortcuts/caizhilian/operations/handlers/setting-encrypt.js +238 -0
  59. package/dist/shortcuts/caizhilian/operations/handlers/setting-storage.js +364 -0
  60. package/dist/shortcuts/caizhilian/operations/handlers/system.js +106 -0
  61. package/dist/shortcuts/caizhilian/operations/registry.js +151 -0
  62. package/dist/shortcuts/caizhilian/operations/shared.js +408 -0
  63. package/dist/shortcuts/caizhilian/operations/specs.js +698 -0
  64. package/dist/shortcuts/caizhilian/operations/types.js +57 -0
  65. package/dist/shortcuts/caizhilian/operations.js +21 -0
  66. package/dist/shortcuts/contract/continuation.js +70 -0
  67. package/dist/shortcuts/contract/errors.js +62 -0
  68. package/dist/shortcuts/contract/host.js +205 -0
  69. package/dist/shortcuts/contract/index.js +318 -0
  70. package/dist/shortcuts/contract/manifest.js +596 -0
  71. package/dist/shortcuts/contract/operations/clause-reference.js +78 -0
  72. package/dist/shortcuts/contract/operations/form-info.js +371 -0
  73. package/dist/shortcuts/contract/operations/read.js +118 -0
  74. package/dist/shortcuts/contract/operations/registry.js +43 -0
  75. package/dist/shortcuts/contract/operations/request.js +300 -0
  76. package/dist/shortcuts/contract/operations/shared.js +133 -0
  77. package/dist/shortcuts/contract/operations/subject.js +157 -0
  78. package/dist/shortcuts/contract/operations/template.js +81 -0
  79. package/dist/shortcuts/contract/operations/types.js +7 -0
  80. package/dist/shortcuts/contract/operations/workflow.js +52 -0
  81. package/dist/shortcuts/contract/operations/write.js +131 -0
  82. package/dist/shortcuts/contract/operations.js +8 -0
  83. package/dist/shortcuts/hrm/index.js +83 -2
  84. package/dist/shortcuts/hrm/manifest.js +16 -9
  85. package/dist/shortcuts/hrm/operations/employee.js +160 -30
  86. package/dist/shortcuts/hrm/operations/org.js +17 -3
  87. package/dist/shortcuts/hrm/operations/position.js +18 -2
  88. package/dist/shortcuts/hrm/operations/registry.js +2 -0
  89. package/dist/shortcuts/hrm/operations/resolve.js +449 -0
  90. package/dist/shortcuts/index.js +5 -1
  91. package/dist/shortcuts/invoice/index.js +74 -36
  92. package/dist/shortcuts/invoice/manifest.js +674 -4
  93. package/dist/shortcuts/invoice/markdown.js +172 -0
  94. package/dist/shortcuts/invoice/operations/add.js +3 -3
  95. package/dist/shortcuts/invoice/operations/delete.js +2 -2
  96. package/dist/shortcuts/invoice/operations/get.js +10 -2
  97. package/dist/shortcuts/invoice/operations/import.js +2 -2
  98. package/dist/shortcuts/invoice/operations/registry.js +8 -1
  99. package/dist/shortcuts/invoice/operations/reim-auto.js +719 -0
  100. package/dist/shortcuts/invoice/operations/reim.js +85 -2
  101. package/dist/shortcuts/invoice/operations/shared.js +75 -8
  102. package/dist/shortcuts/invoice/operations/update.js +2 -2
  103. package/dist/shortcuts/invoice/operations/upload-and-validate.js +93 -0
  104. package/dist/shortcuts/invoice/operations/validate-preview.js +2 -1
  105. package/dist/shortcuts/invoice/projection.js +328 -0
  106. package/dist/shortcuts/meeting/host.js +18 -1
  107. package/dist/shortcuts/meeting/manifest.js +30 -18
  108. package/dist/shortcuts/meeting/operations/eb/catalog.js +390 -0
  109. package/dist/shortcuts/meeting/operations/eb/conflict.js +389 -0
  110. package/dist/shortcuts/meeting/operations/eb/factory.js +389 -0
  111. package/dist/shortcuts/meeting/operations/eb/index.js +22 -0
  112. package/dist/shortcuts/meeting/operations/env.js +8 -1
  113. package/dist/shortcuts/meeting/operations/registry.js +2 -0
  114. package/dist/shortcuts/meeting/operations/signs.js +216 -2
  115. package/dist/shortcuts/workflow/endpoints.js +2 -1
  116. package/dist/shortcuts/workflow/errors.js +8 -2
  117. package/dist/shortcuts/workflow/host.js +11 -4
  118. package/dist/shortcuts/workflow/operations/form.js +1016 -590
  119. package/dist/shortcuts/workflow/operations/read.js +913 -231
  120. package/dist/shortcuts/workflow/operations/shared.js +21 -0
  121. package/dist/shortcuts/workflow/operations/write.js +750 -52
  122. package/dist/shortcuts/workflow/operations.js +36 -10
  123. package/docs/_catalog.md +6 -0
  124. package/docs/agent-invoice.md +5 -1
  125. package/docs/agent-io-channel-design.md +468 -0
  126. package/docs/agent-skill-install.md +9 -4
  127. package/docs/blog.md +123 -0
  128. package/docs/caizhilian.md +304 -0
  129. package/docs/contract.md +91 -0
  130. package/docs/e10-auth.md +4 -2
  131. package/docs/hrm.md +53 -11
  132. package/docs/invoice.md +5 -1
  133. package/docs/meeting.md +1 -1
  134. package/docs/operation-manual.md +15 -3
  135. package/docs/skill-review-2026-09-19.md +2 -2
  136. package/package.json +1 -1
  137. package/scripts/package-skill.mjs +32 -20
  138. package/scripts/xiaoe_env.py +118 -0
  139. package/skill-template/business-info.json +113 -14
  140. package/skill-template/domains/blog.md +24 -0
  141. package/skill-template/domains/caizhilian.md +22 -0
  142. package/skill-template/domains/hrm.md +2 -1
  143. package/skill-template/domains/jinchengda.md +23 -0
  144. package/skill-template/domains/meeting.md +1 -1
  145. package/skill-template/domains/workflow.md +1 -1
  146. package/skill-template/skill-template.md +1 -1
  147. package/skills/weaver-e10-blog/SKILL.md +111 -0
  148. package/skills/weaver-e10-blog/references/blog-comment.md +51 -0
  149. package/skills/weaver-e10-blog/references/blog-comments.md +39 -0
  150. package/skills/weaver-e10-blog/references/blog-default-permission.md +38 -0
  151. package/skills/weaver-e10-blog/references/blog-disabled-capabilities.md +11 -0
  152. package/skills/weaver-e10-blog/references/blog-edit.md +51 -0
  153. package/skills/weaver-e10-blog/references/blog-export.md +42 -0
  154. package/skills/weaver-e10-blog/references/blog-like.md +50 -0
  155. package/skills/weaver-e10-blog/references/blog-publish.md +52 -0
  156. package/skills/weaver-e10-blog/references/blog-query-user.md +45 -0
  157. package/skills/weaver-e10-blog/references/blog-query.md +49 -0
  158. package/skills/weaver-e10-blog/references/blog-remind.md +51 -0
  159. package/skills/weaver-e10-blog/references/blog-resolve-users.md +38 -0
  160. package/skills/weaver-e10-blog/references/source-manifest.json +1540 -0
  161. package/skills/weaver-e10-caizhilian/SKILL.md +217 -0
  162. package/skills/weaver-e10-caizhilian/references/agent-entry.md +106 -0
  163. package/skills/weaver-e10-caizhilian/references/collaboration.md +112 -0
  164. package/skills/weaver-e10-caizhilian/references/document.md +111 -0
  165. package/skills/weaver-e10-caizhilian/references/file-collection.md +173 -0
  166. package/skills/weaver-e10-caizhilian/references/file-upload.md +309 -0
  167. package/skills/weaver-e10-caizhilian/references/files.md +120 -0
  168. package/skills/weaver-e10-caizhilian/references/folder.md +122 -0
  169. package/skills/weaver-e10-caizhilian/references/org-browser.md +104 -0
  170. package/skills/weaver-e10-caizhilian/references/setting-attach.md +440 -0
  171. package/skills/weaver-e10-caizhilian/references/setting-backup.md +372 -0
  172. package/skills/weaver-e10-caizhilian/references/setting-encrypt.md +399 -0
  173. package/skills/weaver-e10-caizhilian/references/setting-storage.md +603 -0
  174. package/skills/weaver-e10-caizhilian/references/source-manifest.json +5694 -0
  175. package/skills/weaver-e10-caizhilian/references/withheld.md +93 -0
  176. package/skills/weaver-e10-caizhilian/references/write-protocol.md +120 -0
  177. package/skills/weaver-e10-calendar/SKILL.md +4 -3
  178. package/skills/weaver-e10-ebuilder-form/SKILL.md +4 -9
  179. package/skills/weaver-e10-ebuilder-form/references/context-routing.md +0 -9
  180. package/skills/weaver-e10-ebuilder-form/references/custom-api.md +1 -5
  181. package/skills/weaver-e10-ebuilder-form/references/fields-browser.md +1 -5
  182. package/skills/weaver-e10-ebuilder-form/references/list-nlist.md +1 -5
  183. package/skills/weaver-e10-ebuilder-form/references/openapi-read.md +1 -5
  184. package/skills/weaver-e10-ebuilder-form/references/openapi-write.md +1 -5
  185. package/skills/weaver-e10-esb/SKILL.md +2 -2
  186. package/skills/weaver-e10-esb/references/source-manifest.json +9 -9
  187. package/skills/weaver-e10-hrm/SKILL.md +104 -9
  188. package/skills/weaver-e10-hrm/references/employee.md +89 -15
  189. package/skills/weaver-e10-hrm/references/org.md +114 -5
  190. package/skills/weaver-e10-hrm/references/position.md +14 -7
  191. package/skills/weaver-e10-hrm/references/source-manifest.json +1 -1
  192. package/skills/weaver-e10-jinchengda/SKILL.md +167 -0
  193. package/skills/weaver-e10-jinchengda/references/contract-agent-entry.md +82 -0
  194. package/skills/weaver-e10-jinchengda/references/contract-clause-recommender.md +69 -0
  195. package/skills/weaver-e10-jinchengda/references/contract-clause-reference-save.md +78 -0
  196. package/skills/weaver-e10-jinchengda/references/contract-disabled-capabilities.md +27 -0
  197. package/skills/weaver-e10-jinchengda/references/contract-request-status.md +71 -0
  198. package/skills/weaver-e10-jinchengda/references/contract-request-write.md +94 -0
  199. package/skills/weaver-e10-jinchengda/references/contract-signing-subjects.md +89 -0
  200. package/skills/weaver-e10-jinchengda/references/contract-subject-create.md +77 -0
  201. package/skills/weaver-e10-jinchengda/references/contract-template-selector.md +66 -0
  202. package/skills/weaver-e10-jinchengda/references/contract-workflow-discovery.md +94 -0
  203. package/skills/weaver-e10-jinchengda/references/source-manifest.json +259 -0
  204. package/skills/weaver-e10-jiuchuanhui/SKILL.md +5 -11
  205. package/skills/weaver-e10-jucailin/SKILL.md +4 -3
  206. package/skills/weaver-e10-mail/SKILL.md +4 -11
  207. package/skills/weaver-e10-meeting/SKILL.md +66 -32
  208. package/skills/weaver-e10-meeting/references/meeting-change.md +5 -2
  209. package/skills/weaver-e10-meeting/references/meeting-create.md +2 -1
  210. package/skills/weaver-e10-meeting/references/meeting-eb.md +114 -0
  211. package/skills/weaver-e10-meeting/references/meeting-lifecycle.md +9 -8
  212. package/skills/weaver-e10-meeting/references/meeting-receipts-signs.md +35 -7
  213. package/skills/weaver-e10-meeting/references/meeting-update.md +1 -1
  214. package/skills/weaver-e10-okr/SKILL.md +6 -11
  215. package/skills/weaver-e10-okr/references/source-manifest.json +3 -3
  216. package/skills/weaver-e10-plan/SKILL.md +3 -3
  217. package/skills/weaver-e10-plan/references/plan-collect-and-compose.md +1 -1
  218. package/skills/weaver-e10-plan/references/plan-overview-and-routing.md +1 -1
  219. package/skills/weaver-e10-plan/references/source-manifest.json +13 -13
  220. package/skills/weaver-e10-qiyecheng/SKILL.md +4 -10
  221. package/skills/weaver-e10-qiyecheng/references/source-manifest.json +9 -9
  222. package/skills/weaver-e10-shared/SKILL.md +42 -34
  223. package/skills/weaver-e10-shared/references/e10-auth-and-session.md +16 -43
  224. package/skills/weaver-e10-shared/references/json-output-contract.md +20 -11
  225. package/skills/weaver-e10-shared/references/non-interactive-environment.md +21 -14
  226. package/skills/weaver-e10-shijingran/SKILL.md +2 -1
  227. package/skills/weaver-e10-shijingran/references/source-manifest.json +9 -9
  228. package/skills/weaver-e10-skill-maker/SKILL.md +4 -10
  229. package/skills/weaver-e10-skill-maker/references/business-skill-generation.md +5 -3
  230. package/skills/weaver-e10-skill-maker/references/module-cli-generation.md +12 -27
  231. package/skills/weaver-e10-skill-maker/references/module-gap-audit-and-repair.md +17 -5
  232. package/skills/weaver-e10-skill-maker/references/source-update-detection.md +4 -27
  233. package/skills/weaver-e10-skill-maker/references/weaver-skill-style.md +1 -1
  234. package/skills/weaver-e10-wenshuding/SKILL.md +5 -16
  235. package/skills/weaver-e10-wenshuding/references/source-manifest.json +7 -7
  236. package/skills/weaver-e10-workflow/SKILL.md +41 -46
  237. package/skills/weaver-e10-workflow/references/source-manifest.json +39 -75
  238. package/skills/weaver-e10-workflow/references/workflow-batch.md +90 -29
  239. package/skills/weaver-e10-workflow/references/workflow-create.md +83 -109
  240. package/skills/weaver-e10-workflow/references/workflow-memory.md +19 -9
  241. package/skills/weaver-e10-workflow/references/workflow-operate.md +2 -2
  242. package/skills/weaver-e10-workflow/references/workflow-query.md +40 -27
  243. package/skills/weaver-e10-workflow/references/workflow-share.md +18 -2
  244. package/skills/weaver-e10-workflow/references/workflow-view.md +3 -4
  245. package/skills/weaver-e10-yepiaotong/SKILL.md +74 -39
  246. package/skills/weaver-e10-yepiaotong/references/invoice-detail.md +22 -1
  247. package/skills/weaver-e10-yepiaotong/references/invoice-enterprise-list.md +3 -1
  248. package/skills/weaver-e10-yepiaotong/references/invoice-output-view.md +135 -0
  249. package/skills/weaver-e10-yepiaotong/references/invoice-personal-list.md +15 -3
  250. package/skills/weaver-e10-yepiaotong/references/invoice-reim.md +1 -1
  251. package/skills/weaver-e10-yepiaotong/references/invoice-summarize.py +217 -0
  252. package/skills/weaver-e10-yepiaotong/references/invoice-upload-and-validate.md +88 -0
  253. package/skills/weaver-e10-yepiaotong/references/invoice-validation-preview.md +7 -2
  254. package/skills/weaver-e10-yepiaotong/references/reim-api-reference.md +89 -9
  255. package/skills/weaver-e10-yepiaotong/references/reim-examples.md +1 -0
  256. package/skills/weaver-e10-yepiaotong/references/reim-expense-subject-rules.md +70 -49
  257. package/skills/weaver-e10-yepiaotong/references/reim-fast-path.md +119 -0
  258. package/skills/weaver-e10-yepiaotong/references/reim-form-fill-rules.md +55 -36
  259. package/skills/weaver-e10-yepiaotong/references/reim-matching-rules.md +4 -0
  260. package/skills/weaver-e10-yepiaotong/references/reim-phone-subsidy-rules.md +149 -0
  261. package/skills/weaver-e10-yepiaotong/references/reim-workflow.md +125 -5
  262. package/skills/weaver-e10-yepiaotong/references/source-manifest.json +430 -63
  263. package/skills/weaver-e10-yimiaoban/SKILL.md +4 -12
  264. package/skills/weaver-e10-yimiaoban/references/source-manifest.json +3 -3
  265. package/skills/weaver-e10-ziguanjia/SKILL.md +2 -1
  266. package/skills/weaver-e10-ziguanjia/references/ziguanjia-agent-entry.md +3 -3
  267. package/tools/weaver-skill-detector-1.0.13/SKILL.md +9 -9
  268. package/tools/weaver-skill-detector-1.0.13/references/checklist.md +3 -3
  269. package/tools/weaver-skill-detector-1.0.13/scripts/__pycache__/validate_skill.cpython-313.pyc +0 -0
  270. package/tools/weaver-skill-detector-1.0.13/scripts/validate_skill.py +98 -91
  271. package/scripts/sync-connector-skills.mjs +0 -172
@@ -8,7 +8,7 @@
8
8
  // (saveSearchIndex / findIndexedItem,由 CLI 托管,本文件不读写索引文件);
9
9
  // 查看/操作类 operation 允许用 requestId 或 group+index 定位(findIndexedItem)。
10
10
  import { workflowSuccess, schemaOf, SCHEMA_REQUEST_ID, SCHEMA_CATEGORY, SCHEMA_INDEX, SCHEMA_PAGE_SIZE, } from './types.js';
11
- import { asString, asInt, asStringArray, asBool, compact, responseData, detailDataOf, toArray, resolveMemoryAppId, withLeadingMeta, STDOUT_SAFE_BYTES, stdoutBytesOf, } from './shared.js';
11
+ import { asString, asInt, asStringArray, asBool, compact, responseData, detailDataOf, toArray, resolveMemoryAppId, withLeadingMeta, STDOUT_SAFE_BYTES, stdoutBytesOf, parseNameWithId, } from './shared.js';
12
12
  import { workflowValidationError, WorkflowCliError, workflowClarificationError, CLARIFICATION_SUBTYPE, } from '../errors.js';
13
13
  import { WORKFLOW_ENDPOINTS } from '../endpoints.js';
14
14
  import { loadSearchIndex, saveSearchIndex, findIndexedItem, readStateFile, writeStateFileBestEffort, } from '../state.js';
@@ -225,21 +225,6 @@ function cardWorkflowOpenUrl(card, baseUrl) {
225
225
  ? `${base}?secondEmpId=${encodeURIComponent(userMasterId)}&requestId=${encodeURIComponent(requestId)}`
226
226
  : base;
227
227
  }
228
- /** 解析 "名称(ID)" 或纯 ID / 纯名称 → (name, id)。ID 必须是纯数字才认定为 ID。 */
229
- function parseNameWithId(value) {
230
- const text = (value || '').trim();
231
- if (!text)
232
- return { name: '' };
233
- const match = /^(.+?)[((]([^()()]*)[))]\s*$/.exec(text);
234
- if (match) {
235
- const name = match[1].trim();
236
- const idPart = match[2].trim();
237
- if (name && idPart && /^[0-9]+$/.test(idPart)) {
238
- return { name, id: idPart };
239
- }
240
- }
241
- return { name: text };
242
- }
243
228
  function splitCsv(value) {
244
229
  if (value === undefined || value === null)
245
230
  return [];
@@ -1178,7 +1163,61 @@ function isActionableRemark(value) {
1178
1163
  return false;
1179
1164
  return Number(text) < 100;
1180
1165
  }
1181
- function normalizeListResponse(response, category, categoryName, baseUrl = '') {
1166
+ /**
1167
+ * `isremark` 码位 → **当前用户在该节点上的参与身份名称**(列表卡片用)。
1168
+ *
1169
+ * 为什么需要本地映射:**待办列表接口(`getTodoListData`)的原始记录只有数字 `isremark`
1170
+ * (外加 `isremarktype` 等辅助位),没有身份名称**(2026-09-20 用 fetch 探针抓原始响应确认:
1171
+ * 一行 100+ 字段里只有 `isremark = 0`)。而分组计数接口(`getTodoGroupCount`,即 todoStat)
1172
+ * **会返回 `isRemarkName`** —— 本表就是按那套名称词表建的。
1173
+ *
1174
+ * 码位语义来源:V11.3 `scripts/e10_workflow.py` 的 `is_actionable_remark` 注释(逐条对齐)——
1175
+ * `0/1` 正常提交、`10/11` 意见征询(走 consulReply)、`20/21` 传阅需批示、`30/31` 抄送需提交、
1176
+ * `40/41` 转发需批注、`60/61` 传阅不需批示、`70/71` 抄送不需提交、`80/81` 转发不需批注;
1177
+ * **尾位 +1 即「(委托)」变体**(V11.3 全部以 `n/n+1` 成对书写);`100` 不可操作(非身份)。
1178
+ * 名称词表来源:V11.3 `references/workflow-search.md`「参与身份说明」的 20 条身份,并与真机
1179
+ * `isRemarkName` 实测值逐条一致(本租户 2026-09-20 实测:`0`=节点操作者、`30`=抄送接收人、
1180
+ * `40`=转发接收人、`70`=抄送接收人不需反馈)。
1181
+ *
1182
+ * ⚠️ **只覆盖有据可查的码位**:「超时指定干预人」「异常处理指定干预人」两族身份的码位在
1183
+ * V11.3 注释与实测里都没有出现,**不猜**——**未覆盖的码位一律返回空串**(模型据此不得臆断身份,
1184
+ * 需要身份时以 todoStat 的 `isRemarkName` 为准)。
1185
+ */
1186
+ const REMARK_IDENTITY_NAMES = {
1187
+ '0': '节点操作者',
1188
+ '1': '节点操作者(委托)',
1189
+ '10': '意见征询接收人',
1190
+ '11': '意见征询接收人(委托)',
1191
+ '20': '传阅接收人',
1192
+ '21': '传阅接收人(委托)',
1193
+ '30': '抄送接收人',
1194
+ '31': '抄送接收人(委托)',
1195
+ '40': '转发接收人',
1196
+ '41': '转发接收人(委托)',
1197
+ '60': '传阅接收人不需反馈',
1198
+ '61': '传阅接收人不需反馈(委托)',
1199
+ '70': '抄送接收人不需反馈',
1200
+ '71': '抄送接收人不需反馈(委托)',
1201
+ '80': '转发接收人不需反馈',
1202
+ '81': '转发接收人不需反馈(委托)',
1203
+ };
1204
+ /** 取参与身份名称:优先用服务端下发的名称(若该接口将来下发),否则按码位表映射;未覆盖码位返回空串 */
1205
+ function remarkIdentityName(rawName, remarkValue) {
1206
+ const fromServer = asString(rawName) ?? '';
1207
+ if (fromServer)
1208
+ return fromServer;
1209
+ const text = remarkValue === undefined || remarkValue === null ? '' : String(remarkValue).trim();
1210
+ if (!/^-?\d+$/.test(text))
1211
+ return '';
1212
+ return REMARK_IDENTITY_NAMES[String(Number(text))] ?? '';
1213
+ }
1214
+ /** 列表响应归一化(来源:Python normalize_workflow_list_response)。
1215
+ *
1216
+ * `includeSummary` 决定卡片是否渲染摘要行(见 `renderCardMarkdown`):legacy / 低版本模式传
1217
+ * `true`(标题/摘要/辅助三行),smart / 智能分组模式与 `batchType` 批量取数保持 `false`
1218
+ * (标题/辅助两行)。
1219
+ */
1220
+ function normalizeListResponse(response, category, categoryName, baseUrl = '', includeSummary = false) {
1182
1221
  const record = responseData(response);
1183
1222
  const data = record?.data && typeof record.data === 'object' ? record.data : {};
1184
1223
  const records = toArray(record?.data?.displayData ?? record?.displayData ?? record?.data?.list ?? record?.list ?? []);
@@ -1214,6 +1253,9 @@ function normalizeListResponse(response, category, categoryName, baseUrl = '') {
1214
1253
  flowStatus,
1215
1254
  requestMark: pickText(recordObj, 'requestmark', 'requestMark', 'requestCode'),
1216
1255
  isRemark: isRemarkRaw === undefined || isRemarkRaw === null ? '' : String(isRemarkRaw).trim(),
1256
+ // 参与身份名称:列表接口原始记录没有该字段 ⇒ 按 isremark 码位本地映射(映射出处见
1257
+ // REMARK_IDENTITY_NAMES);若将来服务端在该接口下发 `isremarkname`,以服务端值为准。
1258
+ isRemarkName: remarkIdentityName(pickText(recordObj, 'isremarkname', 'isRemarkName'), isRemarkRaw),
1217
1259
  canSubmitRejectTransfer: isActionableRemark(isRemarkRaw),
1218
1260
  stayStartAt,
1219
1261
  stayDuration: category === 'todo' ? humanizeDurationSince(stayStartAt) : '',
@@ -1225,7 +1267,12 @@ function normalizeListResponse(response, category, categoryName, baseUrl = '') {
1225
1267
  recordObj.requestLevel),
1226
1268
  badges: uniqueBadges,
1227
1269
  badgeObjects: buildBadgeObjects(uniqueBadges),
1270
+ // 摘要槽位初始值(来源:Python normalize_workflow_list_response 的 item 构造):
1271
+ // 逐卡补详情(enrichWorkflowCardSummaries → applyCardSummaryResult)会原地改写这三个键。
1272
+ summaryFields: [],
1273
+ summarySource: 'pendingDetailSummary',
1228
1274
  };
1275
+ item.cardSummary = '摘要暂未生成,可打开流程查看';
1229
1276
  item.cardMeta = buildCardMeta(item, category);
1230
1277
  return item;
1231
1278
  });
@@ -1247,6 +1294,11 @@ function normalizeListResponse(response, category, categoryName, baseUrl = '') {
1247
1294
  const card = {
1248
1295
  index: item.index,
1249
1296
  title: item.flowName,
1297
+ summary: item.cardSummary,
1298
+ summarySource: item.summarySource,
1299
+ summaryFields: item.summaryFields,
1300
+ // 发起人姓名等禁止进入摘要的词(脚本生成摘要时过滤,见 generateBusinessSummary)
1301
+ summaryForbiddenTerms: listSummaryForbiddenTerms(item),
1250
1302
  badges: item.badges,
1251
1303
  badgeObjects: item.badgeObjects,
1252
1304
  meta: item.cardMeta,
@@ -1269,7 +1321,7 @@ function normalizeListResponse(response, category, categoryName, baseUrl = '') {
1269
1321
  },
1270
1322
  };
1271
1323
  card.url = cardWorkflowOpenUrl(card, baseUrl);
1272
- card.renderMarkdown = renderCardMarkdown(card);
1324
+ card.renderMarkdown = renderCardMarkdown(card, includeSummary);
1273
1325
  return card;
1274
1326
  }),
1275
1327
  };
@@ -1302,6 +1354,7 @@ const SLIM_INDEX_ITEM_KEYS = [
1302
1354
  'workflowName',
1303
1355
  'canSubmitRejectTransfer',
1304
1356
  'isRemark',
1357
+ 'isRemarkName',
1305
1358
  'finished',
1306
1359
  'overdue',
1307
1360
  ];
@@ -1480,7 +1533,6 @@ const workflowSearch = {
1480
1533
  pageSize: SCHEMA_PAGE_SIZE,
1481
1534
  conditionType: { type: 'string', enum: ['and', 'or'], default: 'and' },
1482
1535
  totalOnly: { type: 'boolean', default: false },
1483
- mode: { type: 'string', enum: ['auto', 'legacy', 'smart'], default: 'auto', description: '执行模式强制覆盖,仅回归验证用;默认按 OA 版本自动路由' },
1484
1536
  batchType: {
1485
1537
  type: 'string',
1486
1538
  enum: ['submit', 'urge', 'read', 'share', 'forward', 'reject'],
@@ -1494,16 +1546,18 @@ const workflowSearch = {
1494
1546
  if (plan && typeof plan === 'object') {
1495
1547
  // plan 批量编排仅智能分组模式支持(来源:Python cmd_workflow_search_plan 首行的
1496
1548
  // resolve_workflow_search_mode 门禁)——该模式下 todoStat 的统计口径才成立;低版本 OA 上
1497
- // 直接拒绝,并引导改用单条查询,避免静默按错口径分组。--mode smart 仍可强制(回归验证用)。
1498
- const planMode = await resolveSearchMode(env, args);
1549
+ // 直接拒绝,并引导改用单条查询,避免静默按错口径分组。
1550
+ // 门禁与 memoryPrompt 的 oaContext.smartGrouping 同源(同一常量 + 同一比较函数),并且**没有
1551
+ // 强制覆盖开关**:模型拿到的布尔值就是这里的判据,不会出现「模型以为能用、这里却拒绝」的错位。
1552
+ const planMode = await resolveSearchMode(env);
1499
1553
  if (planMode !== 'smart') {
1500
- throw workflowValidationError('plan_requires_smart_mode', `plan 批量编排模式仅支持智能分组模式(OA ≥ ${formatOaVersion(SMART_GROUPING_MIN_OA_VERSION)});低版本请改用单条 workflow.search`, { retryAllowed: false, renderMode: planMode });
1554
+ throw workflowValidationError('plan_requires_smart_mode', `plan 批量编排模式仅支持智能分组模式(OA ≥ ${formatOaVersion(SMART_GROUPING_MIN_OA_VERSION)});低版本请改用单条 workflow.search`, { retryAllowed: false });
1501
1555
  }
1502
1556
  // 顶层 pageSize 即 plan 的展示窗口 page_cap(来源:Python cmd_workflow_search_plan 的 page_cap)
1503
1557
  return executePlanSearch(env, plan, asInt(args.pageSize) ?? 10);
1504
1558
  }
1505
1559
  const category = (asString(args.category) || 'todo');
1506
- const searchMode = await resolveSearchMode(env, args);
1560
+ const searchMode = await resolveSearchMode(env);
1507
1561
  const batchType = asString(args.batchType);
1508
1562
  const totalOnly = asBool(args.totalOnly, false);
1509
1563
  // legacy 待办查询走 CLI 侧分组(V11.2 语义;来源:Python execute_workflow_search_legacy 的
@@ -1576,19 +1630,28 @@ const workflowSearch = {
1576
1630
  if (totalOnly) {
1577
1631
  return workflowSuccess('workflow.search', {
1578
1632
  type: 'workflowSearch',
1579
- renderMode: searchMode,
1580
1633
  category,
1581
1634
  categoryName: CATEGORY_NAMES[category],
1582
1635
  total,
1583
1636
  finalMarkdown: buildSearchFinalMarkdown(null, probes, total, category, true, searchMode === 'smart', waitReadTotal),
1584
1637
  });
1585
1638
  }
1586
- const normalized = normalizeListResponse(listResponse, category, CATEGORY_NAMES[category], session.baseUrl ?? '');
1639
+ // 卡片摘要行只在 **legacy(低版本模式)** 生成——契约见 references/workflow-query.md
1640
+ // 「执行模式与返回形态」的「卡片摘要行」一行(低版本=标题/摘要/辅助三行;智能分组=标题/辅助
1641
+ // 两行)。`batchType` 批量取数只关心 requestId,跳过摘要、不白跑 N 次详情接口
1642
+ // (来源:Python execute_workflow_search_legacy 的 `include_summary=True` 与 batch 分支
1643
+ // 强制 `no_card_summaries`)。
1644
+ const withCardSummaries = searchMode === 'legacy' && !batchType;
1645
+ const normalized = normalizeListResponse(listResponse, category, CATEGORY_NAMES[category], session.baseUrl ?? '', withCardSummaries);
1587
1646
  normalized.total = total;
1647
+ // 先把当前页卡片补上摘要,再落索引——这样序号定位拿到的 items 也带摘要(V11.3 同步了
1648
+ // sessionMapPayload,本实现改为「补完再落盘」,语义等价)。
1649
+ if (withCardSummaries) {
1650
+ await enrichWorkflowCardSummaries(env, normalized, asInt(args.pageSize) ?? normalized.pageSize);
1651
+ }
1588
1652
  const index = await saveListIndex(env, normalized);
1589
1653
  return workflowSuccess('workflow.search', await enforceSearchStdoutBudget({
1590
1654
  type: 'workflowSearch',
1591
- renderMode: searchMode,
1592
1655
  category,
1593
1656
  categoryName: CATEGORY_NAMES[category],
1594
1657
  total,
@@ -1598,7 +1661,7 @@ const workflowSearch = {
1598
1661
  // 条目只输出机器可读的定位/标识/可操作性字段——渲染一律用 finalMarkdown,
1599
1662
  // 不再回显渲染字段(此前 cards / cardItems / renderMarkdown 三份互相重复)。
1600
1663
  cards: normalized.items.map(slimIndexItem),
1601
- finalMarkdown: buildSearchFinalMarkdown(normalized, probes, total, category, false, searchMode === 'smart', waitReadTotal),
1664
+ finalMarkdown: buildSearchFinalMarkdown(normalized, probes, total, category, false, searchMode === 'smart', waitReadTotal, withCardSummaries),
1602
1665
  queryId: index.queryId,
1603
1666
  sessionMap: { queryId: index.queryId, itemCount: normalized.items.length },
1604
1667
  }, env, { json: 'last_search.json', md: 'last_search.md' }));
@@ -1629,11 +1692,13 @@ function isVersionAtLeast(actual, min) {
1629
1692
  }
1630
1693
  return true;
1631
1694
  }
1632
- /** 解析查询执行模式:版本 ≥ 基线走 smart;版本未知/运行时不可用 → legacy 兜底 */
1633
- async function resolveSearchMode(env, args) {
1634
- const forced = (asString(args.mode) ?? '').toLowerCase();
1635
- if (forced === 'smart' || forced === 'legacy')
1636
- return forced;
1695
+ /**
1696
+ * 解析查询执行模式:版本 ≥ 基线走 smart;版本未知/运行时不可用 → legacy 兜底。
1697
+ * **不接受任何参数强制覆盖**——与 `memoryPrompt` 的 `oaContext.smartGrouping` 完全同源(同一常量 +
1698
+ * 同一比较函数),这样模型读到的布尔值与服务端门禁不可能漂移。
1699
+ * (09-21 用户裁定移除 `mode` 强制开关:它只是临时调试用,暴露出去反而会诱导模型「试一试」。)
1700
+ */
1701
+ async function resolveSearchMode(env) {
1637
1702
  try {
1638
1703
  const session = await env.host.getSession();
1639
1704
  const version = asString(session.version) ?? '';
@@ -1663,7 +1728,7 @@ function todoProbeNotice(probes) {
1663
1728
  return `另外,该条件下有${parts.join('、')},建议优先关注。`;
1664
1729
  }
1665
1730
  /** workflow.search 最终 Markdown(来源:Python build_workflow_search_final_markdown) */
1666
- function buildSearchFinalMarkdown(normalized, probes, total, category, totalOnly, smartMode, waitReadTotal = null) {
1731
+ function buildSearchFinalMarkdown(normalized, probes, total, category, totalOnly, smartMode, waitReadTotal = null, includeSummary = false) {
1667
1732
  const label = CATEGORY_NAMES[category];
1668
1733
  const lines = [];
1669
1734
  if (totalOnly) {
@@ -1686,7 +1751,7 @@ function buildSearchFinalMarkdown(normalized, probes, total, category, totalOnly
1686
1751
  lines.push(`另有 ${asInt(waitReadTotal)} 条待阅流程。`);
1687
1752
  }
1688
1753
  }
1689
- const body = normalized ? renderListMarkdown(normalized) : '';
1754
+ const body = normalized ? renderListMarkdown(normalized, includeSummary) : '';
1690
1755
  if (body)
1691
1756
  lines.push(body);
1692
1757
  return lines.filter(Boolean).join('\n\n');
@@ -1757,19 +1822,27 @@ function todoTabName(tabid) {
1757
1822
  return '待处理';
1758
1823
  }
1759
1824
  /** 取一组待办明细(list 接口)并按请求窗口归一化(来源:Python normalize_todo_group_list)。 */
1760
- async function fetchLegacyTodoGroupList(env, query, total, baseUrl) {
1825
+ /** 取一个待办分组的分页明细(来源:Python normalize_todo_group_list,L11987)。
1826
+ *
1827
+ * 低版本模式(legacy)走 CLI 侧分组,卡片同样是**三行**(契约见 references/workflow-query.md
1828
+ * 「卡片摘要行」一行)——这里就地补当前页前 `summaryLimit` 张卡片的摘要
1829
+ * (来源:Python `normalize_todo_group_list` 内的 `enrich_workflow_card_summaries`)。
1830
+ */
1831
+ async function fetchLegacyTodoGroupList(env, query, total, baseUrl, summaryLimit = 0) {
1761
1832
  const response = await env.host.workflowRequest({
1762
1833
  method: 'POST',
1763
1834
  path: WORKFLOW_ENDPOINTS.TODO_LIST,
1764
1835
  operation: 'workflow.search.group.list',
1765
1836
  body: query,
1766
1837
  });
1767
- const normalized = normalizeListResponse(response, 'todo', CATEGORY_NAMES.todo, baseUrl);
1838
+ const normalized = normalizeListResponse(response, 'todo', CATEGORY_NAMES.todo, baseUrl, true);
1768
1839
  const window = (query.customDisplayPage || {});
1769
1840
  normalized.current = asInt(window.current) ?? normalized.current;
1770
1841
  normalized.pageSize = asInt(window.size) ?? normalized.pageSize;
1771
1842
  normalized.total = total;
1772
1843
  normalized.hasMore = (asInt(normalized.pageSize) ?? 0) * (asInt(normalized.current) ?? 1) < total;
1844
+ if (summaryLimit > 0)
1845
+ await enrichWorkflowCardSummaries(env, normalized, summaryLimit);
1773
1846
  return normalized;
1774
1847
  }
1775
1848
  /** 跨组去重:剔除已被更高优先级分组展示过的流程(来源:Python filter_group_normalized_to_unseen)。 */
@@ -1825,7 +1898,7 @@ function combineLegacyTodoGroups(groupResults) {
1825
1898
  for (const card of cards) {
1826
1899
  const rid = asString(card?.internal?.requestId) ?? '';
1827
1900
  card.index = groupIndex;
1828
- card.renderMarkdown = renderCardMarkdown(card);
1901
+ card.renderMarkdown = renderCardMarkdown(card, true);
1829
1902
  const item = itemByRid.get(rid);
1830
1903
  if (item) {
1831
1904
  item.index = groupIndex;
@@ -1911,6 +1984,11 @@ async function executeLegacyGroupedTodoSearch(env, args) {
1911
1984
  const baseUrl = session.baseUrl ?? '';
1912
1985
  const mainSize = Math.max(1, Math.min(asInt(args.pageSize) ?? 10, 100));
1913
1986
  const auxSize = Math.min(mainSize, 5);
1987
+ // 摘要补全条数:主组 = min(主列表页大小, 页大小),特殊组 = min(3, 页大小)
1988
+ // (来源:Python execute_grouped_todo_search 的 summary_limit_main / summary_limit_aux)
1989
+ const summaryItemLimit = asInt(args.pageSize) ?? mainSize;
1990
+ const summaryLimitMain = Math.min(mainSize, summaryItemLimit);
1991
+ const summaryLimitAux = Math.min(3, summaryItemLimit);
1914
1992
  const mainTabid = tabid || SUB_CATEGORY_TABS.todo['待处理'];
1915
1993
  const mainQuery = todoQueryForTab(query, mainTabid, mainSize);
1916
1994
  const personalQuery = todoQueryForTab(query, '7', mainSize);
@@ -1983,12 +2061,13 @@ async function executeLegacyGroupedTodoSearch(env, args) {
1983
2061
  const mainListQuery = fallbackToAll ? allQuery : mainQuery;
1984
2062
  const mainListTotal = fallbackToAll ? allTotal : mainTotal;
1985
2063
  const listJobs = {
1986
- main_list: fetchLegacyTodoGroupList(env, mainListQuery, mainListTotal, baseUrl),
2064
+ main_list: fetchLegacyTodoGroupList(env, mainListQuery, mainListTotal, baseUrl, summaryLimitMain),
1987
2065
  };
1988
- if (showOverdue)
1989
- listJobs.overdue_list = fetchLegacyTodoGroupList(env, overdueQuery, overdueTotal, baseUrl);
2066
+ if (showOverdue) {
2067
+ listJobs.overdue_list = fetchLegacyTodoGroupList(env, overdueQuery, overdueTotal, baseUrl, summaryLimitAux);
2068
+ }
1990
2069
  if (showUrgent) {
1991
- listJobs.urgent_list = fetchLegacyTodoGroupList(env, urgentQuery, urgentTotal, baseUrl);
2070
+ listJobs.urgent_list = fetchLegacyTodoGroupList(env, urgentQuery, urgentTotal, baseUrl, summaryLimitAux);
1992
2071
  }
1993
2072
  const listPairs = await Promise.all(Object.entries(listJobs).map(async ([key, task]) => [key, await task]));
1994
2073
  const lists = {};
@@ -2037,7 +2116,6 @@ async function executeLegacyGroupedTodoSearch(env, args) {
2037
2116
  .join('\n\n');
2038
2117
  return workflowSuccess('workflow.search', await enforceSearchStdoutBudget({
2039
2118
  type: 'workflowSearch',
2040
- renderMode: 'legacy',
2041
2119
  category: 'todo',
2042
2120
  categoryName: CATEGORY_NAMES.todo,
2043
2121
  displayMode: 'groupedCards',
@@ -2149,11 +2227,17 @@ function extractTotal(response) {
2149
2227
  }
2150
2228
  return null;
2151
2229
  }
2152
- /** 单张卡片的三行 Markdown:标题行(含徽标)+ 元信息行(来源:Python render_card_markdown)。
2230
+ /** 单张卡片的 Markdown:标题行(含徽标)+ **可选**摘要行 + 元信息行(来源:Python render_card_markdown)。
2153
2231
  *
2154
- * smart(智能分组)模式不渲染摘要行——与 Python `include_summary=False` 一致。
2232
+ * `includeSummary` 决定是否渲染摘要行(第 2 行)——**契约**见 `references/workflow-query.md`
2233
+ * 「执行模式与返回形态」的「卡片摘要行」一行:
2234
+ * - `true`(legacy / 低版本模式)→ **标题/摘要/辅助三行**,摘要由 `enrichWorkflowCardSummaries`
2235
+ * 逐卡补详情(REQUEST_DETAIL)后生成;
2236
+ * - `false`(smart / 智能分组模式,以及 `batchType` 批量取数)→ **标题/辅助两行**。
2237
+ *
2238
+ * 默认 `false`:避免未经富化的卡片被渲染出一行占位摘要(`batchType` 与 plan 卡片都不补摘要)。
2155
2239
  */
2156
- function renderCardMarkdown(card) {
2240
+ function renderCardMarkdown(card, includeSummary = false) {
2157
2241
  const badgeTexts = (card.badgeObjects || [])
2158
2242
  .map((badge) => stripHtml(badge?.text))
2159
2243
  .filter(Boolean)
@@ -2171,15 +2255,25 @@ function renderCardMarkdown(card) {
2171
2255
  const url = asString(card.url) ?? '';
2172
2256
  const title = url ? `[${stripHtml(titleText)}](${url})` : stripHtml(titleText);
2173
2257
  const lines = [`**${title}**${badges} `];
2258
+ if (includeSummary) {
2259
+ let summaryText = stripHtml(card.summary ?? '');
2260
+ // 脚本自动摘要信息量不足时被标记为 needsModelSummary,摘要行给占位提示,
2261
+ // 模型基于 summaryContext 生成后替换(来源:Python render_card_markdown 的占位分支)。
2262
+ if (card.needsModelSummary && (!summaryText || summaryText === '摘要暂未生成,可打开流程查看')) {
2263
+ summaryText = '(摘要待模型生成)';
2264
+ }
2265
+ lines.push(`${summaryText} `);
2266
+ }
2174
2267
  if (meta)
2175
2268
  lines.push(meta);
2176
2269
  return lines.join('\n');
2177
2270
  }
2178
- function renderListMarkdown(normalized) {
2271
+ /** 卡片列表 Markdown:`includeSummary` 透传给单卡渲染(见 `renderCardMarkdown`)。 */
2272
+ function renderListMarkdown(normalized, includeSummary = false) {
2179
2273
  const items = normalized.cardItems || [];
2180
2274
  if (!items.length)
2181
2275
  return '无匹配流程';
2182
- return items.map((card) => renderCardMarkdown(card)).join('\n\n---\n\n');
2276
+ return items.map((card) => renderCardMarkdown(card, includeSummary)).join('\n\n---\n\n');
2183
2277
  }
2184
2278
  // plan 取数分页口径(来源:Python _PLAN_GROUP_FETCH_PAGE_SIZE / _PLAN_GROUP_FETCH_MAX_PAGES
2185
2279
  // 与 _plan_execute_slot 的 pageSize=100 / max_pages=500)
@@ -2883,9 +2977,9 @@ const workflowTodoStat = {
2883
2977
  // 来源:Python 无独立 todoStat operation,只有 cmd_workflow_search_plan 的 smart 门禁 +
2884
2978
  // execute_workflow_search_legacy 的分组输出——低版本待办统计由 workflow.search 的 legacy 分组输出承担。
2885
2979
  // 低版本直接拒绝,避免模型拿到"全部(0)"口径统计(与 legacy"待处理(7)"口径矛盾)后按错误口径规划分组。
2886
- const mode = await resolveSearchMode(env, args);
2980
+ const mode = await resolveSearchMode(env);
2887
2981
  if (mode !== 'smart') {
2888
- throw workflowValidationError('todo_stat_requires_smart_mode', `workflow.todoStat 仅支持智能分组模式(OA ≥ ${formatOaVersion(SMART_GROUPING_MIN_OA_VERSION)});低版本请直接走 workflow.search(CLI 会自动输出分组统计)`, { retryAllowed: false, renderMode: mode });
2982
+ throw workflowValidationError('todo_stat_requires_smart_mode', `workflow.todoStat 仅支持智能分组模式(OA ≥ ${formatOaVersion(SMART_GROUPING_MIN_OA_VERSION)});低版本请直接走 workflow.search(CLI 会自动输出分组统计)`, { retryAllowed: false });
2889
2983
  }
2890
2984
  const { query } = await buildSearchQuery(env, { ...args, category: 'todo' }, 'stat');
2891
2985
  const response = await env.host.workflowRequest({
@@ -2983,7 +3077,7 @@ const workflowDetail = {
2983
3077
  '表单内容区只展示核心业务字段,跳过空值、技术字段、可编辑、操作按钮、岗位、部门、最后反馈时间等非必要字段',
2984
3078
  '表单字段值必须原样展示 value,不能摘要、改写、删减;字段含 valueHtml/links 时优先渲染为可点击超链接,不要把长 URL 直接作为普通文本换行展示',
2985
3079
  'displayMode=longTextBlock 或 fullWidth=true 的表单字段必须全宽展示,保留原文、标点和换行;不要保留接口富文本里的背景色、字号和大段空白',
2986
- '签字意见是查看流程内容的固定组成部分,必须以意见块展示,不要压成表格或和表单字段混在一起;每条按三行展示(操作人/方式/时间、接收人、签字意见内容)',
3080
+ '签字意见是查看流程内容的固定组成部分,必须以意见块展示,不要压成表格或和表单字段混在一起;每条按两行展示(操作人/方式/时间、签字意见内容)',
2987
3081
  '签字意见区标题写成「签字意见({{paginationText}})」;只有 requestLog.hasMore=true 时才显示 nextPagePrompt',
2988
3082
  '不要展示 requestId;不要展示 workflowName,用户明确询问所属工作流时才展示为「工作流名称」;不要翻译、音译或改写接口返回文本',
2989
3083
  '详情面板后不要输出固定建议列表或「如果想进一步操作」类尾巴;用户要求摘要/总结时改为围绕表单业务内容提炼,不要渲染详情表',
@@ -3043,7 +3137,7 @@ function normalizeDetailResponse(response) {
3043
3137
  warnings.push('接口未返回该流程的任何详情内容(流程标题/编号/发起人/发起时间/当前节点/表单字段全为空)。'
3044
3138
  + '这不代表"流程是空的",也不代表流程一定不存在——最可能是这个 requestId 在本环境查不到对应流程'
3045
3139
  + '(编号抄错、来自其他环境、已被删除)。'
3046
- + '请先用 `workflow.search` / `workflow.createList` 在本环境确认该流程可见、并直接从那一次的'
3140
+ + '请先用 `workflow.search` 在本环境确认该流程可见、并直接从那一次的'
3047
3141
  + '`cards[].requestId` 取值,再重试;若确认流程存在却仍取不到,请如实告知用户"详情不可见",'
3048
3142
  + '不要编造流程内容。');
3049
3143
  }
@@ -3204,19 +3298,14 @@ function extractFormFields(data) {
3204
3298
  // V12 早期版本把"递归"退化成"枚举 4 条固定路径"(`data.list` / `data.records` …),而
3205
3299
  // `getRequestLog` 的真实响应是 `{message:{errcode,errmsg}, data:[…]}` —— **`data` 直接是数组**
3206
3300
  // ⇒ 四条路径全 miss ⇒ 签字意见恒为空。真机探针实测同一份响应:V11.3 解出 8 条、V12 解出 0 条。
3207
- // 归一化侧的 `display_text_from_value` 一族(对象取值 / 用户名映射 / 时间戳格式化 / 接收人解析)
3208
- // V12 同样缺失 ⇒ 一并补齐。
3301
+ // 归一化侧的 `display_text_from_value` 一族(对象取值 / 时间戳格式化)V12 同样缺失 ⇒ 一并补齐。
3302
+ // (接收人解析链已于 2026-09-20 按要求整体删除,不再取 `receivedPersonIds` / `userDataMap`。)
3209
3303
  // ---------------------------------------------------------------------------
3210
3304
  /** 展示文本候选键(来源:V11.3 `DISPLAY_TEXT_KEYS`)。 */
3211
3305
  const LOG_DISPLAY_TEXT_KEYS = [
3212
3306
  'showValue', 'displayValue', 'name', 'showName', 'lastName', 'lastname', 'userName',
3213
3307
  'username', 'label', 'title', 'text', 'content', 'value',
3214
3308
  ];
3215
- /** 用户 ID 候选键(来源:V11.3 `USER_ID_KEYS`)。 */
3216
- const LOG_USER_ID_KEYS = [
3217
- 'id', 'userId', 'userid', 'uid', 'resourceId', 'resourceid', 'masterId', 'masterid',
3218
- 'identityId', 'identityid',
3219
- ];
3220
3309
  /** 日志容器候选键(来源:V11.3 `collect_detail_logs_from_obj`)。 */
3221
3310
  const LOG_CONTAINER_KEYS = [
3222
3311
  'requestLogList', 'requestLogs', 'logs', 'logList', 'operatorList', 'signList', 'remarkList',
@@ -3253,13 +3342,6 @@ const LOG_OPINION_KEYS = [
3253
3342
  'opinion', 'remark', 'remarks', 'signRemark', 'signremark', 'content', 'comment', 'message',
3254
3343
  'requestLogContent', 'commentContent', 'commentcontent',
3255
3344
  ];
3256
- const LOG_RECEIVER_ID_KEYS = [
3257
- 'receivedPersonIds', 'receivedpersonids', 'receivedPersonId', 'receivePersonIds', 'receiverIds',
3258
- 'recipientIds',
3259
- ];
3260
- const LOG_RECEIVER_TEXT_KEYS = [
3261
- 'receivedPersons', 'receivedPersonNames', 'receiverNames', 'receivers', 'recipients',
3262
- ];
3263
3345
  /** 宽松取键:先精确匹配、再忽略大小写(来源:V11.3 `pick_raw`)。 */
3264
3346
  function pickRawLoose(source, ...keys) {
3265
3347
  if (!source || typeof source !== 'object' || Array.isArray(source))
@@ -3301,8 +3383,8 @@ function displayTextFromValue(value, depth = 0) {
3301
3383
  const record = value;
3302
3384
  if (Object.keys(record).length === 0)
3303
3385
  return '';
3304
- // ★ 必须走 `pickRawLoose`(大小写不敏感):实测 `userDataMap` 的键是 `UserName`(大写 U+N),
3305
- // 而候选键写的是 `userName`/`username` —— 用 `in` 精确匹配会取不到,接收人就只能显示裸 ID。
3386
+ // ★ 必须走 `pickRawLoose`(大小写不敏感):实测服务端回的对象键常与候选键大小写不一致
3387
+ // (如 `UserName` 对上候选键 `userName`)—— 用 `in` 精确匹配会取不到值。
3306
3388
  for (const key of LOG_DISPLAY_TEXT_KEYS) {
3307
3389
  const raw = pickRawLoose(record, key);
3308
3390
  if (raw === undefined)
@@ -3332,125 +3414,6 @@ function pickDisplayText(source, ...keys) {
3332
3414
  }
3333
3415
  return '';
3334
3416
  }
3335
- /** 值 → 去重 ID 列表(来源:V11.3 `value_to_id_list`,ID 串按 `,,;;空白` 切分)。 */
3336
- function valueToIdList(value) {
3337
- const ids = [];
3338
- const pushText = (text) => {
3339
- for (const part of stripHtml(text).split(/[,,;;\s]+/u)) {
3340
- if (part)
3341
- ids.push(part);
3342
- }
3343
- };
3344
- const push = (input) => {
3345
- if (input === null || input === undefined)
3346
- return;
3347
- if (Array.isArray(input)) {
3348
- input.forEach(push);
3349
- return;
3350
- }
3351
- if (typeof input === 'object') {
3352
- const record = input;
3353
- for (const key of [...LOG_USER_ID_KEYS, 'value']) {
3354
- const raw = pickRawLoose(record, key);
3355
- if (raw !== null && raw !== undefined && raw !== '') {
3356
- push(raw);
3357
- return;
3358
- }
3359
- }
3360
- return;
3361
- }
3362
- pushText(String(input));
3363
- };
3364
- push(value);
3365
- return [...new Set(ids)];
3366
- }
3367
- /** 用户映射里按 id 查姓名(来源:V11.3 `user_name_from_map`)。 */
3368
- function userNameFromMap(map, userId) {
3369
- if (!map || !userId)
3370
- return '';
3371
- const direct = displayTextFromValue(map[userId]);
3372
- if (direct)
3373
- return direct;
3374
- for (const [key, candidate] of Object.entries(map)) {
3375
- if (String(key) === String(userId)) {
3376
- const text = displayTextFromValue(candidate);
3377
- if (text)
3378
- return text;
3379
- }
3380
- if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
3381
- continue;
3382
- const candidateIds = LOG_USER_ID_KEYS.flatMap((idKey) => valueToIdList(pickRawLoose(candidate, idKey)));
3383
- if (candidateIds.some((id) => String(id) === String(userId))) {
3384
- const text = displayTextFromValue(candidate);
3385
- if (text)
3386
- return text;
3387
- }
3388
- }
3389
- return '';
3390
- }
3391
- /**
3392
- * 收集 `userDataMap` 并建 `id → 用户对象` 索引
3393
- * (来源:V11.3 `extract_user_data_map` / `normalize_user_data_map` / `add_user_map_item`)。
3394
- *
3395
- * 真机实测:`getRequestLog` 的 `userDataMap`(`[{UserName, userId}, …]`,90+ 条)挂在**每条日志项内部**
3396
- * 而不是响应顶层 ⇒ 除了遍历响应,还要逐项收一遍,否则接收人只能显示裸 ID。
3397
- */
3398
- function collectUserDataMap(source) {
3399
- const mapped = {};
3400
- const addItem = (item, fallbackKey) => {
3401
- if (fallbackKey !== undefined && fallbackKey !== '')
3402
- mapped[String(fallbackKey)] = item;
3403
- if (!item || typeof item !== 'object' || Array.isArray(item))
3404
- return;
3405
- const record = item;
3406
- for (const key of LOG_USER_ID_KEYS) {
3407
- const rawId = pickRawLoose(record, key);
3408
- if (rawId !== null && rawId !== undefined && rawId !== '')
3409
- mapped[String(rawId)] = item;
3410
- }
3411
- for (const nestedKey of ['user', 'userInfo', 'userBaseInfo', 'resource', 'resourceInfo', 'hrmResource', 'person', 'identity']) {
3412
- const nested = record[nestedKey];
3413
- if (nested && typeof nested === 'object' && !Array.isArray(nested))
3414
- addItem(nested);
3415
- }
3416
- };
3417
- const addMapLike = (value) => {
3418
- if (Array.isArray(value)) {
3419
- value.forEach((item) => addItem(item));
3420
- return;
3421
- }
3422
- if (value && typeof value === 'object') {
3423
- for (const [key, item] of Object.entries(value)) {
3424
- if (Array.isArray(item))
3425
- item.forEach((nested) => addItem(nested));
3426
- else
3427
- addItem(item, key);
3428
- }
3429
- }
3430
- };
3431
- const walk = (value, depth = 0) => {
3432
- if (!value || typeof value !== 'object' || depth > 2)
3433
- return;
3434
- if (Array.isArray(value)) {
3435
- value.forEach((item) => {
3436
- if (item && typeof item === 'object') {
3437
- const direct = pickRawLoose(item, 'userDataMap', 'userdataMap', 'userMap', 'userInfoMap', 'resourceDataMap');
3438
- if (direct !== undefined)
3439
- addMapLike(direct);
3440
- }
3441
- });
3442
- return;
3443
- }
3444
- const record = value;
3445
- const direct = pickRawLoose(record, 'userDataMap', 'userdataMap', 'userMap', 'userInfoMap', 'resourceDataMap');
3446
- if (direct !== undefined)
3447
- addMapLike(direct);
3448
- for (const nestedKey of ['data', 'result', 'payload'])
3449
- walk(record[nestedKey], depth + 1);
3450
- };
3451
- walk(source);
3452
- return mapped;
3453
- }
3454
3417
  /** 数字串 → 本地可读时间(`YYYY-MM-DD HH:mm:ss`)。 */
3455
3418
  function formatLogTimestamp(ms) {
3456
3419
  const date = new Date(ms);
@@ -3479,26 +3442,18 @@ function stripWeaverMteTokens(text) {
3479
3442
  return text;
3480
3443
  return text.replace(/_weaverMte_\S+/gu, ' ').replace(/\s+/gu, ' ').trim();
3481
3444
  }
3482
- /** 接收人(ID 列表 + 显示串)。来源:V11.3 `resolve_received_persons`。 */
3483
- function resolveReceivedPersons(item, userDataMap) {
3484
- const ids = valueToIdList(pickRawLoose(item, ...LOG_RECEIVER_ID_KEYS));
3485
- let display = ids.map((id) => userNameFromMap(userDataMap, id) || id).join('、');
3486
- if (!display)
3487
- display = pickDisplayText(item, ...LOG_RECEIVER_TEXT_KEYS);
3488
- return { ids, display };
3489
- }
3490
3445
  /**
3491
3446
  * 递归收集日志条目(来源:V11.3 `collect_detail_logs_from_obj`)。
3492
3447
  *
3493
3448
  * ★ 关键在"递归":`data` 可能是数组、也可能是对象里某个键下面挂着数组。V12 早期只枚举四条固定路径,
3494
3449
  * 遇到 `getRequestLog` 的"`data` 是数组"形态就整条归零。
3495
3450
  */
3496
- function collectDetailLogsFromObj(obj, logs, userDataMap, limit) {
3451
+ function collectDetailLogsFromObj(obj, logs, limit) {
3497
3452
  if (logs.length >= limit)
3498
3453
  return;
3499
3454
  if (Array.isArray(obj)) {
3500
3455
  for (const item of obj) {
3501
- collectDetailLogsFromObj(item, logs, userDataMap, limit);
3456
+ collectDetailLogsFromObj(item, logs, limit);
3502
3457
  if (logs.length >= limit)
3503
3458
  break;
3504
3459
  }
@@ -3507,19 +3462,19 @@ function collectDetailLogsFromObj(obj, logs, userDataMap, limit) {
3507
3462
  if (!obj || typeof obj !== 'object')
3508
3463
  return;
3509
3464
  const record = obj;
3510
- const normalized = normalizeLogItem(record, userDataMap);
3465
+ const normalized = normalizeLogItem(record);
3511
3466
  if (detailLogHasContent(normalized))
3512
3467
  logs.push(normalized);
3513
3468
  for (const key of LOG_CONTAINER_KEYS) {
3514
3469
  if (key in record)
3515
- collectDetailLogsFromObj(record[key], logs, userDataMap, limit);
3470
+ collectDetailLogsFromObj(record[key], logs, limit);
3516
3471
  }
3517
3472
  }
3518
3473
  /**
3519
3474
  * 从接口响应里取出签字意见条目(来源:V11.3 `extract_request_log_items`)。
3520
3475
  *
3521
3476
  * 取 `response.data` 作为源(为空则回退整个 response),递归收集后按
3522
- * `node/operator/time/action/receivers/opinion` 六元组去重。
3477
+ * `node/operator/time/action/opinion` 五元组去重。
3523
3478
  */
3524
3479
  function extractRequestLogItems(response, limit = 500) {
3525
3480
  const wrapper = response && typeof response === 'object' && !Array.isArray(response)
@@ -3531,13 +3486,12 @@ function extractRequestLogItems(response, limit = 500) {
3531
3486
  || (typeof source === 'object' && !Array.isArray(source) && Object.keys(source).length === 0);
3532
3487
  if (emptySource)
3533
3488
  source = response;
3534
- const userDataMap = collectUserDataMap(response);
3535
3489
  const logs = [];
3536
- collectDetailLogsFromObj(source, logs, userDataMap, limit);
3490
+ collectDetailLogsFromObj(source, logs, limit);
3537
3491
  const seen = new Set();
3538
3492
  const unique = [];
3539
3493
  for (const log of logs) {
3540
- const key = [log.node, log.operator, log.time, log.action, log.receivers, log.opinion]
3494
+ const key = [log.node, log.operator, log.time, log.action, log.opinion]
3541
3495
  .map((part) => String(part ?? ''))
3542
3496
  .join('\u0001');
3543
3497
  if (seen.has(key))
@@ -3554,7 +3508,6 @@ function extractRequestLogItems(response, limit = 500) {
3554
3508
  function extractRequestLogs(data) {
3555
3509
  if (!data || typeof data !== 'object')
3556
3510
  return [];
3557
- const userDataMap = collectUserDataMap(data);
3558
3511
  const logs = [];
3559
3512
  for (const [key, value] of Object.entries(data)) {
3560
3513
  if (!LOG_CONTAINER_KEY_PATTERN.test(key))
@@ -3563,12 +3516,12 @@ function extractRequestLogs(data) {
3563
3516
  continue;
3564
3517
  if (Array.isArray(value) && value.length === 0)
3565
3518
  continue;
3566
- collectDetailLogsFromObj(value, logs, userDataMap, 40);
3519
+ collectDetailLogsFromObj(value, logs, 40);
3567
3520
  }
3568
3521
  return logs;
3569
3522
  }
3570
3523
  function detailLogHasContent(log) {
3571
- return Boolean(log && (log.node || log.operator || log.action || log.time || log.opinion || log.receivers));
3524
+ return Boolean(log && (log.node || log.operator || log.action || log.time || log.opinion));
3572
3525
  }
3573
3526
  // ---------------------------------------------------------------------------
3574
3527
  // 签字意见归一化(来源:normalize_detail_log_item / fetch_request_log_page)
@@ -3656,17 +3609,17 @@ async function fetchRequestLogPage(env, requestId, current, pageSize) {
3656
3609
  /**
3657
3610
  * 签字意见单条归一化(来源:V11.3 `normalize_detail_log_item`)。
3658
3611
  *
3659
- * 三处「V11.3 有、V12 缺」的处理,缺一个就会显示成空白或乱码:
3612
+ * 两处「V11.3 有、V12 缺」的处理,缺一个就会显示成空白或乱码:
3660
3613
  * ① **操作人取 `commentor`** —— 真机实测该字段是**对象**(`{id, name:'孙一一', username}`),
3661
3614
  * 必须走 `displayTextFromValue`;旧写法用 `pickText` 只会把对象 `String()` 成 `[object Object]`。
3662
3615
  * ② **时间走 `normalizeLogTimeText`** —— 接口给的是 **毫秒时间戳**(`1743082059000`)。
3663
- * ③ **接收人走 `resolveReceivedPersons`** —— `receivedPersonIds`(id 串)+ `userDataMap`(id→姓名)解析;
3664
- * 实测某条签字意见的接收人有 90+ 人,不解析就只能显示一串裸 ID。
3616
+ *
3617
+ * 接收人(`receivedPersonIds` + `userDataMap` 解析)已按要求整体移除:解析链(`resolveReceivedPersons` /
3618
+ * `userNameFromMap` / `collectUserDataMap` / `valueToIdList` 与 `LOG_RECEIVER_*_KEYS`)一并删除。
3665
3619
  */
3666
- function normalizeLogItem(item, userDataMap = {}) {
3620
+ function normalizeLogItem(item) {
3667
3621
  if (!item || typeof item !== 'object')
3668
3622
  return {};
3669
- const mergedMap = { ...userDataMap, ...collectUserDataMap(item) };
3670
3623
  let node = pickDisplayText(item, ...LOG_NODE_KEYS);
3671
3624
  if (/^\d{12,}$/u.test(stripHtml(node)))
3672
3625
  node = '';
@@ -3675,7 +3628,6 @@ function normalizeLogItem(item, userDataMap = {}) {
3675
3628
  const time = normalizeLogTimeText(pickDisplayText(item, ...LOG_TIME_KEYS));
3676
3629
  const action = pickDisplayText(item, ...LOG_ACTION_KEYS);
3677
3630
  const opinion = stripWeaverMteTokens(pickDisplayText(item, ...LOG_OPINION_KEYS));
3678
- const receivers = resolveReceivedPersons(item, mergedMap);
3679
3631
  const operatorDisplay = operator || '未返回';
3680
3632
  const actionDisplay = action || '未返回';
3681
3633
  const timeDisplay = time || '未返回';
@@ -3690,17 +3642,13 @@ function normalizeLogItem(item, userDataMap = {}) {
3690
3642
  actionDisplay,
3691
3643
  operatorAction: `${operatorDisplay} · ${actionDisplay}`,
3692
3644
  operatorActionTime: `${operatorDisplay} · ${actionDisplay} · ${timeDisplay}`,
3693
- receiverIds: receivers.ids,
3694
- receivers: receivers.display,
3695
- receiverDisplay: receivers.display || '未返回',
3696
3645
  opinion,
3697
3646
  opinionDisplay,
3698
3647
  displayLines: {
3699
3648
  line1: `${operatorDisplay} · ${actionDisplay} · ${timeDisplay}`,
3700
- line2: `接收人:${receivers.display || '未返回'}`,
3701
3649
  line3: opinionDisplay,
3702
3650
  },
3703
- displayLayout: 'threeLineSignatureOpinion',
3651
+ displayLayout: 'twoLineSignatureOpinion',
3704
3652
  };
3705
3653
  }
3706
3654
  /** 详情字段取值:有 links 时优先渲染为可点击超链接(来源:Python detail_field_markdown_value) */
@@ -3766,7 +3714,6 @@ function renderDetailMarkdown(normalized) {
3766
3714
  for (const it of logItems) {
3767
3715
  const dl = it.displayLines || {};
3768
3716
  let line1 = asString(dl.line1) ?? '';
3769
- let line2 = asString(dl.line2) ?? '';
3770
3717
  let line3 = asString(dl.line3) ?? '';
3771
3718
  if (!line1) {
3772
3719
  const operator = asString(it.operatorDisplay) ?? '未返回';
@@ -3774,13 +3721,11 @@ function renderDetailMarkdown(normalized) {
3774
3721
  const timeText = asString(it.timeDisplay) ?? asString(it.createdAt) ?? '未返回';
3775
3722
  line1 = `${operator} · ${action} · ${timeText}`;
3776
3723
  }
3777
- if (!line2)
3778
- line2 = `接收人:${asString(it.receiverDisplay) ?? '未返回'}`;
3779
3724
  if (!line3) {
3780
3725
  const opinion = asString(it.opinionDisplay) ?? asString(it.contentDisplay) ?? asString(it.content) ?? '未填写意见';
3781
3726
  line3 = `签字意见:${opinion}`;
3782
3727
  }
3783
- lines.push(`> **${line1}**`, '>', `> ${line2}`, '>', `> ${line3}`, '>');
3728
+ lines.push(`> **${line1}**`, '>', `> ${line3}`, '>');
3784
3729
  }
3785
3730
  if (lines[lines.length - 1] === '>')
3786
3731
  lines.pop();
@@ -3868,13 +3813,627 @@ const DETAIL_SUMMARY_BOOL_QUESTION_PATTERN = /^(是否|是否为|是否包含|
3868
3813
  const DETAIL_SUMMARY_REGISTER_META_PATTERN = /(备案|环境|版本|基线|编号|序号|编码|流水号|工单|紧急|优先级|级别|程度|渠道|来源|状态)/;
3869
3814
  const DETAIL_SUMMARY_WORKFLOW_META_PATTERN = /^(发起|提交|创建|制单|填报).*(人|时间)$|(当前|审批|处理|流转|流程|待办).*(节点|状态)|(停留|超时|到达当前节点).*(时间|时长)|^(节点|状态|审批中|已结束|草稿)$/;
3870
3815
  const SUMMARY_MEANINGLESS_VALUES = new Set(['0', '无', '暂无', '无内容', '-', '--', 'null', 'none', 'undefined', '请填写', '请选择']);
3816
+ // 内容信息量评分的取值信号(来源:Python SUMMARY_STRUCTURED_LIST_ITEM_PATTERN 等,L941-944)
3817
+ const SUMMARY_STRUCTURED_LIST_ITEM_PATTERN = /(?:^|[\n;;])\s*[1-9一二三四五六七八九十]\s*[、..]/g;
3818
+ const SUMMARY_VALUE_LABEL_PATTERN = /(需求概述|事项|说明|原因|背景|价值|目标|方案|内容|描述)\s*[::]/;
3819
+ const SUMMARY_VALUE_AMOUNT_PATTERN = /\d+(?:\.\d+)?\s*(?:元|天|小时|工时|日|万|%|%)/;
3820
+ const SUMMARY_VALUE_DATE_PATTERN = /\d{4}[-/年]\d{1,2}/;
3821
+ // 语义角色的补充模式(来源:Python SUMMARY_START_FIELD_PATTERN / SUMMARY_END_FIELD_PATTERN /
3822
+ // SUMMARY_ROLE_WORD_PATTERN,L1088-1097)
3823
+ const SUMMARY_START_FIELD_PATTERN = /(开始|起始|起算|生效|启用)/;
3824
+ const SUMMARY_END_FIELD_PATTERN = /(结束|截止|终止|到期|完成)/;
3825
+ const SUMMARY_ROLE_WORD_PATTERN = /(事由|原因|说明|概述|描述|内容|背景|价值|需求|事项|金额|总额|合计|预算|成本|收入|付款|收款|借款|费用|类型|类别|分类|方式|性质|用途|开始|起始|起算|生效|启用|结束|截止|终止|到期|完成|日期|时间|预计|天数|人天|工时|时长|期限|周期|负责人|责任人|主管|审批人|经手人)/g;
3826
+ // 描述类字段的提炼预算与模型兜底阈值(来源:Python L1240-1249 / L1261-1262)
3827
+ const SUMMARY_REASON_TEXT_BUDGET = 75;
3828
+ const SUMMARY_MODEL_FALLBACK_MIN_LEN = 10;
3829
+ const SUMMARY_MODEL_INSTRUCTION = '该卡片脚本自动摘要信息量不足,请基于 summaryContext 理解表单字段数据后生成中文摘要,'
3830
+ + '优先体现申请事项、金额、时间、类别、原因、说明、核心需求/描述等业务重点;'
3831
+ + '不要只复述流程标题,不要编造,不要出现发起人姓名;字数控制在 100 字以内。';
3832
+ /** 标签优先级(主标签「需求概述/事项/说明」排前,来源:Python SUMMARY_LABEL_PRIORITY,L1327) */
3833
+ const SUMMARY_LABEL_PRIORITY = ['需求概述', '事项', '说明', '原因', '背景', '价值'];
3834
+ /** 逐卡补详情时的并发上限。
3835
+ *
3836
+ * 来源:V11.3 Python `enrich_workflow_card_summaries` 的 `max_workers=min(len,4)`。
3837
+ * **本实现有意上调为 5**(09-24 用户决策):默认 `pageSize=5` 时 4 个 worker 必然把当前页 5 张卡
3838
+ * 拆成 **4+1 两轮**(多一个 RTT);5 个 worker 正好一轮打完。**改回 4 之前先确认为什么要退回。**
3839
+ */
3840
+ const SUMMARY_ENRICH_MAX_WORKERS = 5;
3841
+ /** 详情接口在「列表卡片补摘要」链路里的短超时。
3842
+ *
3843
+ * 来源:V11.3 `http_timeout_seconds("REQUEST_DETAIL")` 返回 **5s**(其余接口 10s)——列表补摘要
3844
+ * 属于尽力而为,单个详情卡顿不得拖垮整条查询。
3845
+ */
3846
+ const SUMMARY_DETAIL_TIMEOUT_MS = 5_000;
3847
+ /** 按**字符**数截断,优先在中文标点边界收尾(来源:Python truncate_text,L848)。
3848
+ *
3849
+ * 与 `truncateUtf8Text`(按字节硬截,服务 stdout 预算)是两件事:本函数按字符计数,并在 limit
3850
+ * 内最后一个中文标点处收尾,避免把句子/词截成半截(如「…是流程引擎的待…」)。`stripHtml`
3851
+ * 已完成空白折叠与首尾裁剪,与 Python 里先 `re.sub(r"\s+"," ")` 再 `strip()` 等价。
3852
+ */
3853
+ function truncateText(value, limit = 120) {
3854
+ const text = stripHtml(value);
3855
+ if (text.length <= limit)
3856
+ return text;
3857
+ let cut = limit;
3858
+ for (const sep of ['。', '!', '?', ';', ',', '、', ':']) {
3859
+ const idx = text.lastIndexOf(sep, limit - 1);
3860
+ if (idx >= Math.floor(limit * 0.5) && idx + 1 > cut)
3861
+ cut = idx + 1;
3862
+ }
3863
+ return `${text.slice(0, cut).replace(/[ ,、;:。!?]+$/g, '')}…`;
3864
+ }
3865
+ /** 字段内容信息量评分(来源:Python score_summary_field_value,L947)。
3866
+ *
3867
+ * 内容理解优先、字段名仅作权重:结构化编号列表 > 标签结构 > 长文本 > 业务数字/日期。
3868
+ * 解决「表单顺序第一个命中描述模式」让短泛化字段挤掉核心长文本的问题。
3869
+ */
3870
+ function scoreSummaryFieldValue(name, value) {
3871
+ const text = (value ?? '').trim();
3872
+ if (!text)
3873
+ return 0;
3874
+ let score = 0;
3875
+ const numberedItems = text.split(SUMMARY_STRUCTURED_LIST_ITEM_PATTERN).length - 1;
3876
+ if (numberedItems >= 2)
3877
+ score += 12;
3878
+ else if (numberedItems)
3879
+ score += 6;
3880
+ if (SUMMARY_VALUE_LABEL_PATTERN.test(text))
3881
+ score += 8;
3882
+ if (text.length >= 200)
3883
+ score += 10;
3884
+ else if (text.length >= 50)
3885
+ score += 6;
3886
+ else if (text.length >= 20)
3887
+ score += 3;
3888
+ if (SUMMARY_VALUE_AMOUNT_PATTERN.test(text))
3889
+ score += 5;
3890
+ else if (SUMMARY_VALUE_DATE_PATTERN.test(text))
3891
+ score += 4;
3892
+ if (/(概述|描述|事项|说明)/.test(name))
3893
+ score += 4;
3894
+ else if (/(原因|背景|价值|需求|内容)/.test(name))
3895
+ score += 2;
3896
+ return score;
3897
+ }
3898
+ /** 描述类字段按信息量降序(来源:Python best_reason_fields,L1100)。稳定排序,同分保持表单顺序。 */
3899
+ function bestReasonFields(fields) {
3900
+ const candidates = [];
3901
+ for (const field of fields) {
3902
+ const name = stripHtml(field?.fieldName ?? '');
3903
+ const value = stripHtml(field?.value ?? '');
3904
+ if (!name || !value)
3905
+ continue;
3906
+ if (!SUMMARY_REASON_FIELD_PATTERN.test(name))
3907
+ continue;
3908
+ candidates.push({ fieldName: name, value, componentKey: asString(field?.componentKey) ?? '' });
3909
+ }
3910
+ candidates.sort((a, b) => scoreSummaryFieldValue(b.fieldName, b.value) - scoreSummaryFieldValue(a.fieldName, a.value));
3911
+ return candidates;
3912
+ }
3913
+ /** 字段归一为通用业务语义角色(来源:Python summary_semantic_role,L1127)。不依赖具体流程模板。 */
3914
+ function summarySemanticRole(field) {
3915
+ const name = stripHtml(field?.fieldName ?? '');
3916
+ const component = stripHtml(field?.componentKey ?? '').toLowerCase();
3917
+ if (!name)
3918
+ return 'other';
3919
+ if (SUMMARY_REASON_FIELD_PATTERN.test(name))
3920
+ return 'reason';
3921
+ if (SUMMARY_AMOUNT_FIELD_PATTERN.test(name))
3922
+ return 'amount';
3923
+ if (SUMMARY_TYPE_FIELD_PATTERN.test(name))
3924
+ return 'type';
3925
+ const isTemporal = SUMMARY_DATE_FIELD_PATTERN.test(name) || component.includes('date') || component.includes('time');
3926
+ if (isTemporal && SUMMARY_START_FIELD_PATTERN.test(name))
3927
+ return 'start';
3928
+ if (isTemporal && SUMMARY_END_FIELD_PATTERN.test(name))
3929
+ return 'end';
3930
+ if (SUMMARY_DAY_FIELD_PATTERN.test(name))
3931
+ return 'duration';
3932
+ if (SUMMARY_PERSON_FIELD_PATTERN.test(name))
3933
+ return 'person';
3934
+ if (isTemporal)
3935
+ return 'date';
3936
+ return 'other';
3937
+ }
3938
+ /** 提取字段业务前缀,让「报销原因/报销类型」等同域字段优先组合(来源:Python summary_field_stem,L1153)。 */
3939
+ function summaryFieldStem(name) {
3940
+ return stripHtml(name)
3941
+ .replace(SUMMARY_ROLE_WORD_PATTERN, '')
3942
+ .replace(/^[()()【】\[\]_\-\u2014::/ ]+|[()()【】\[\]_\-\u2014::/ ]+$/g, '');
3943
+ }
3944
+ /** 单字段在指定语义角色下的得分(来源:Python semantic_field_score,L1158)。 */
3945
+ function semanticFieldScore(field, role, anchorStem = '') {
3946
+ const name = stripHtml(field?.fieldName ?? '');
3947
+ const value = stripHtml(field?.value ?? '');
3948
+ const component = stripHtml(field?.componentKey ?? '').toLowerCase();
3949
+ let score = 0;
3950
+ if (role === 'reason') {
3951
+ score += scoreSummaryFieldValue(name, value);
3952
+ }
3953
+ else if (role === 'start' || role === 'end' || role === 'date') {
3954
+ if (/\d{4}[-/.年]\d{1,2}/.test(value))
3955
+ score += 8;
3956
+ else if (/\d{1,2}:\d{2}/.test(value))
3957
+ score += 3;
3958
+ if (component.includes('date'))
3959
+ score += 2;
3960
+ }
3961
+ else if (role === 'type') {
3962
+ score += /^[\d.]+$/.test(value) ? 0.5 : 4;
3963
+ score += Math.min(name.length, 8) * 0.2;
3964
+ }
3965
+ else if (role === 'amount' || role === 'duration') {
3966
+ score += /\d/.test(value) ? 4 : 1;
3967
+ }
3968
+ else {
3969
+ score += Math.min(value.length, 20) * 0.1;
3970
+ }
3971
+ const stem = summaryFieldStem(name);
3972
+ if (anchorStem && stem && (stem === anchorStem || stem.includes(anchorStem) || anchorStem.includes(stem))) {
3973
+ score += 8;
3974
+ }
3975
+ return score;
3976
+ }
3977
+ /** 指定语义角色下得分最高的字段;同分取表单顺序更靠前的(来源:Python best_semantic_field,L1185)。 */
3978
+ function bestSemanticField(fields, role, anchorStem = '') {
3979
+ const candidates = fields.filter((field) => summarySemanticRole(field) === role);
3980
+ if (!candidates.length)
3981
+ return null;
3982
+ return candidates.reduce((best, field) => semanticFieldScore(field, role, anchorStem) > semanticFieldScore(best, role, anchorStem) ? field : best);
3983
+ }
3984
+ /** 起止/日期片段(来源:Python summary_temporal_fragment,L1196)。 */
3985
+ function summaryTemporalFragment(fields, anchorStem = '') {
3986
+ const start = bestSemanticField(fields, 'start', anchorStem);
3987
+ const end = bestSemanticField(fields, 'end', anchorStem);
3988
+ if (start) {
3989
+ const startName = stripHtml(start.fieldName);
3990
+ const startValue = stripHtml(start.value);
3991
+ const endValue = end ? stripHtml(end.value) : '';
3992
+ if (endValue && endValue !== startValue)
3993
+ return `${startName}${startValue}至${endValue}`;
3994
+ return `${startName}${startValue}`;
3995
+ }
3996
+ const dateField = bestSemanticField(fields, 'date', anchorStem);
3997
+ if (dateField)
3998
+ return `${stripHtml(dateField.fieldName)}${stripHtml(dateField.value)}`;
3999
+ return '';
4000
+ }
4001
+ /** 值补单位(来源:Python value_with_unit,L1212)。 */
4002
+ function valueWithUnit(value, unit) {
4003
+ const text = stripHtml(value);
4004
+ if (!text)
4005
+ return '';
4006
+ return text.endsWith(unit) ? text : `${text}${unit}`;
4007
+ }
4008
+ /** 按句末符号取前 N 句(来源:Python truncate_by_sentences,L1219)。
4009
+ *
4010
+ * 分隔符**不含英文句点**——否则版本号(`2.164.0.RELEASE`)、小数、编号会被当成句末,
4011
+ * 摘要被截成「发布版本2.164」。
4012
+ */
4013
+ function truncateBySentences(text, maxSentences = 2) {
4014
+ if (!text)
4015
+ return '';
4016
+ const keep = [];
4017
+ for (const part of text.split(/(?<=[。!?;;\n])/)) {
4018
+ if (!part.trim())
4019
+ continue;
4020
+ keep.push(part);
4021
+ if (keep.length >= maxSentences)
4022
+ break;
4023
+ }
4024
+ return keep.join('').replace(/^[,;\s ]+|[,;\s ]+$/g, '');
4025
+ }
4026
+ /** 提炼描述类字段的核心内容(来源:Python summarize_reason_text,L1265)。
4027
+ *
4028
+ * 先按句级(。!?;)取前 2 句;仍超预算则按分句级(,;)累计直到接近预算,保证在语义边界
4029
+ * 收尾;分句累计不足一半预算时退回标点边界截断。分句不含顿号「、」,否则「1、2、」等序号会被
4030
+ * 切成独立分句,产生「…审批;2」这类残词结尾。
4031
+ */
4032
+ function summarizeReasonText(text, maxLen = SUMMARY_REASON_TEXT_BUDGET) {
4033
+ const value = stripHtml(text).replace(/^[ ,。;;]+|[ ,。;;]+$/g, '');
4034
+ if (!value)
4035
+ return '';
4036
+ if (value.length <= maxLen)
4037
+ return value;
4038
+ const sentences = value.split(/(?<=[。!?;;])/).filter((sentence) => sentence.trim());
4039
+ if (sentences.length > 1) {
4040
+ const joined = sentences.slice(0, 2).join('').replace(/^[ ,。;;]+|[ ,。;;]+$/g, '');
4041
+ if (joined.length <= maxLen)
4042
+ return joined;
4043
+ }
4044
+ let joined = '';
4045
+ for (const clause of value.split(/(?<=[,;])/)) {
4046
+ if (joined.length + clause.length > maxLen)
4047
+ break;
4048
+ joined += clause;
4049
+ }
4050
+ joined = joined.replace(/^[ ,、;]+|[ ,、;]+$/g, '');
4051
+ // 补全未闭合括号(分句常切在括号内容中间)
4052
+ if ((joined.match(/(/g) ?? []).length > (joined.match(/)/g) ?? []).length)
4053
+ joined += ')';
4054
+ if (joined.length >= Math.floor(maxLen * 0.5))
4055
+ return joined;
4056
+ return truncateText(value, maxLen);
4057
+ }
4058
+ /** 清洗并裁剪自动摘要(来源:Python clean_generated_summary,L1301)。
4059
+ *
4060
+ * `splitSentences=false` 时不做整体切句——reason 分支已单独提炼过描述片段,拼接后切句会把
4061
+ * 补充字段(金额/时间)当第 3 句切掉;此时只按长度 + 标点边界收尾。
4062
+ */
4063
+ function cleanGeneratedSummary(text, forbiddenTerms = null, limit = 100, splitSentences = true) {
4064
+ let value = stripHtml(text).replace(/^[ ,。;;]+|[ ,。;;]+$/g, '');
4065
+ for (const term of forbiddenTerms ?? []) {
4066
+ const cleanTerm = stripHtml(term);
4067
+ if (cleanTerm)
4068
+ value = value.split(cleanTerm).join('');
4069
+ }
4070
+ value = value.replace(/\s+/g, ' ').replace(/^[ ,。;;]+|[ ,。;;]+$/g, '');
4071
+ if (splitSentences)
4072
+ value = truncateBySentences(value, 2);
4073
+ return truncateText(value, limit);
4074
+ }
4075
+ /** 剥离标签前的中文修饰词(来源:Python trim_label_prefix,L1330)。
4076
+ *
4077
+ * 「需求背景」中的「背景」被识别为低优先级标签时,标签 start 落在「背」字,前面的「需求」
4078
+ * 会残留在上一段末尾;若 endPos 前紧邻中文,回退剥离最多 4 个修饰字。
4079
+ */
4080
+ function trimLabelPrefix(text, endPos) {
4081
+ let i = endPos - 1;
4082
+ while (i >= 0 && endPos - i <= 4) {
4083
+ const code = text.charCodeAt(i);
4084
+ if (code < 0x4e00 || code > 0x9fff)
4085
+ break;
4086
+ i -= 1;
4087
+ }
4088
+ return i + 1;
4089
+ }
4090
+ /** 从带标签的长文本中提取核心描述内容(来源:Python extract_labeled_summary_value,L1343)。
4091
+ *
4092
+ * 先定位所有「标签:」出现位置(允许「需求背景」中的「背景」被识别为低优先级标签),按标签
4093
+ * 优先级取最高优先级的标签;内容 = 该标签冒号之后、到下一个标签之前的切片。这样「需求概述」
4094
+ * 存在时必取「需求概述」内容,「需求背景」不会抢位。过短的截断残句(长度 < 2)丢弃。
4095
+ */
4096
+ function extractLabeledSummaryValue(value) {
4097
+ const text = stripHtml(value);
4098
+ if (!text)
4099
+ return '';
4100
+ const labelRe = new RegExp(`(${SUMMARY_LABEL_PRIORITY.join('|')})\\s*[::]`, 'g');
4101
+ const matches = [];
4102
+ let matched = labelRe.exec(text);
4103
+ while (matched !== null) {
4104
+ matches.push({ label: matched[1], start: matched.index, end: matched.index + matched[0].length });
4105
+ if (matched.index === labelRe.lastIndex)
4106
+ labelRe.lastIndex += 1;
4107
+ matched = labelRe.exec(text);
4108
+ }
4109
+ if (!matches.length)
4110
+ return text;
4111
+ const ordered = matches
4112
+ .slice()
4113
+ .sort((a, b) => SUMMARY_LABEL_PRIORITY.indexOf(a.label) - SUMMARY_LABEL_PRIORITY.indexOf(b.label));
4114
+ const laterPositions = matches.filter((item) => item.start > ordered[0].start).map((item) => item.start);
4115
+ let endPos = laterPositions.length ? Math.min(...laterPositions) : text.length;
4116
+ if (laterPositions.length)
4117
+ endPos = trimLabelPrefix(text, endPos);
4118
+ const candidate = text.slice(ordered[0].end, endPos).trim();
4119
+ // 只有「过短」才判为截断残句;较长的「…」结尾(完整编号列表)仍保留
4120
+ if (candidate.length >= 2 && (!candidate.endsWith('…') || candidate.length >= 20))
4121
+ return candidate;
4122
+ for (const match of ordered.slice(1)) {
4123
+ const later2 = laterPositions.filter((position) => position > match.start);
4124
+ let end2 = later2.length ? Math.min(...later2) : text.length;
4125
+ if (later2.length)
4126
+ end2 = trimLabelPrefix(text, end2);
4127
+ const cand2 = text.slice(match.end, end2).trim();
4128
+ if (cand2.length >= 2 && (!cand2.endsWith('…') || cand2.length >= 20))
4129
+ return cand2;
4130
+ }
4131
+ return text;
4132
+ }
4133
+ /** 摘要上下文(来源:Python build_summary_context_text,L1072)。
4134
+ *
4135
+ * 卡片 `needsModelSummary=true` 时,模型据此理解表单字段、生成摘要;`summaryContext` 随卡片
4136
+ * 保留在归一化结果里(本项目的输出只保留 `finalMarkdown`,模型直接按占位提示改写该行即可)。
4137
+ */
4138
+ function buildSummaryContextText(fields, limit = 600) {
4139
+ const parts = fields
4140
+ .filter((field) => field?.fieldName && field?.value)
4141
+ .map((field) => `${field.fieldName}:${field.value}`);
4142
+ return truncateText(parts.join(';'), limit);
4143
+ }
4144
+ /** 摘要禁用词(发起人姓名等,来源:Python list_summary_forbidden_terms,L1514)。 */
4145
+ function listSummaryForbiddenTerms(item) {
4146
+ const terms = [];
4147
+ const creatorName = stripHtml(item?.creatorName ?? '');
4148
+ if (creatorName)
4149
+ terms.push(creatorName);
4150
+ for (const metaItem of item?.cardMeta ?? []) {
4151
+ if (!metaItem || typeof metaItem !== 'object')
4152
+ continue;
4153
+ const label = stripHtml(metaItem.label);
4154
+ const value = stripHtml(metaItem.value);
4155
+ if (['发起人', '申请人', '发起人/申请人'].includes(label) && value)
4156
+ terms.push(value);
4157
+ }
4158
+ const deduped = [];
4159
+ const seen = new Set();
4160
+ for (const term of terms) {
4161
+ if (term.length < 2 || seen.has(term))
4162
+ continue;
4163
+ seen.add(term);
4164
+ deduped.push(term);
4165
+ }
4166
+ return deduped;
4167
+ }
4168
+ /** 二次过滤:剔除人员类字段与命中禁用词的字段(来源:Python filter_card_summary_fields,L3341)。 */
4169
+ function filterCardSummaryFields(card, fields) {
4170
+ const forbidden = new Set((card?.summaryForbiddenTerms ?? []).map((term) => stripHtml(term)).filter(Boolean));
4171
+ const filtered = [];
4172
+ for (const field of fields) {
4173
+ const name = stripHtml(field?.fieldName ?? '');
4174
+ const value = stripHtml(field?.value ?? '');
4175
+ if (!name || !value)
4176
+ continue;
4177
+ if (['发起人', '申请人', '提交人', '创建人', '制单人', '填报人', '报销人员'].includes(name))
4178
+ continue;
4179
+ if (forbidden.has(value))
4180
+ continue;
4181
+ filtered.push(field);
4182
+ }
4183
+ return filtered;
4184
+ }
4185
+ /** 详情取不到表单数据时的卡片提示(来源:Python detail_summary_unavailable_message,L3289)。 */
4186
+ function detailSummaryUnavailableMessage(formData) {
4187
+ if (formData && formData.available === false)
4188
+ return '详情接口未返回表单数据,可打开流程查看';
4189
+ return '该流程暂无表单数据,暂时无法生成摘要';
4190
+ }
4191
+ /** 详情接口失败时的卡片提示(来源:Python detail_summary_unavailable_from_error,L3535)。
4192
+ *
4193
+ * ⚠️ 与 V11.3 的唯一偏差:V11.3 把超时文案写死成「10 秒内未返回」,而它自己的
4194
+ * `http_timeout_seconds("REQUEST_DETAIL")` 返回的是 **5s**(文案与实现不一致)。这里由常量派生,
4195
+ * 保证文案与实际超时一致。
4196
+ */
4197
+ function detailSummaryUnavailableFromError(error) {
4198
+ const reason = String(error?.stopReason ?? '').trim();
4199
+ if (reason === 'http_timeout')
4200
+ return `详情接口 ${SUMMARY_DETAIL_TIMEOUT_MS / 1000} 秒内未返回,摘要暂不可用,可打开流程查看`;
4201
+ if (reason === 'login_expired')
4202
+ return '系统认证信息已失效,摘要暂不可用';
4203
+ if (reason === 'api_business_error')
4204
+ return '详情接口返回业务错误,摘要暂不可用';
4205
+ return '该流程暂无表单数据,暂时无法生成摘要';
4206
+ }
4207
+ /** 把补摘要链路里的异常归一为 V11.3 的 `stopReason` 词表(供文案分流)。
4208
+ *
4209
+ * 映射:网络超时 → `http_timeout`;登录失效 → `login_expired`;接口业务/HTTP 错误 →
4210
+ * `api_business_error`。其余(协议异常、程序缺陷)走默认文案。
4211
+ */
4212
+ function summaryStopReasonOf(error) {
4213
+ const structured = error;
4214
+ if (structured?.type === 'network')
4215
+ return structured.subtype === 'timeout' ? 'http_timeout' : 'http_status';
4216
+ if (structured?.type === 'authentication')
4217
+ return 'login_expired';
4218
+ if (structured?.type === 'api')
4219
+ return 'api_business_error';
4220
+ return 'unknown';
4221
+ }
4222
+ /** 单卡详情补全(来源:Python fetch_card_summary_result,L3546)。
4223
+ *
4224
+ * 取该卡片 requestId 的详情表单字段 → 摘要候选筛选 → 返回待生成的字段清单。**不改未读状态**
4225
+ * (`isReadRequest=false`)。失败不抛错,返回「摘要不可用」的降级结果。
4226
+ */
4227
+ async function fetchCardSummaryResult(env, card, userId) {
4228
+ const requestId = asString(card?.internal?.requestId) ?? '';
4229
+ if (!requestId) {
4230
+ return {
4231
+ card,
4232
+ requested: false,
4233
+ summary: '详情接口未返回 requestId,摘要不可用',
4234
+ source: 'workflowDetailUnavailable',
4235
+ fields: [],
4236
+ error: { stopReason: 'missing_request_id' },
4237
+ };
4238
+ }
4239
+ let detailResponse;
4240
+ try {
4241
+ const payload = {
4242
+ requestId,
4243
+ isGetRequestBaseInfo: true,
4244
+ isGetRequestLog: false,
4245
+ isGetFormData: true,
4246
+ // 列表卡片补摘要不得改变未读状态——只有用户主动查看流程内容时 isReadRequest 才为 true
4247
+ isReadRequest: false,
4248
+ };
4249
+ if (userId)
4250
+ payload.userId = userId;
4251
+ detailResponse = await env.host.workflowRequest({
4252
+ method: 'POST',
4253
+ path: WORKFLOW_ENDPOINTS.REQUEST_DETAIL,
4254
+ operation: 'workflow.search.summary.detail',
4255
+ body: payload,
4256
+ timeoutMs: SUMMARY_DETAIL_TIMEOUT_MS,
4257
+ });
4258
+ }
4259
+ catch (error) {
4260
+ const stopReason = summaryStopReasonOf(error);
4261
+ return {
4262
+ card,
4263
+ requested: true,
4264
+ summary: detailSummaryUnavailableFromError({ stopReason }),
4265
+ source: 'workflowDetailUnavailable',
4266
+ fields: [],
4267
+ error: { requestId, stopReason },
4268
+ };
4269
+ }
4270
+ const detail = normalizeDetailResponse(detailResponse);
4271
+ const formFields = Array.isArray(detail?.formData?.fields) ? detail.formData.fields : [];
4272
+ const selectedFields = filterCardSummaryFields(card, selectSummaryFields(formFields, 12));
4273
+ if (selectedFields.length) {
4274
+ return {
4275
+ card,
4276
+ requested: true,
4277
+ summary: '',
4278
+ source: 'workflowDetailFormDataAutoSummary',
4279
+ fields: selectedFields,
4280
+ error: null,
4281
+ };
4282
+ }
4283
+ return {
4284
+ card,
4285
+ requested: true,
4286
+ summary: detailSummaryUnavailableMessage(detail?.formData ?? {}),
4287
+ source: 'workflowDetailUnavailable',
4288
+ fields: [],
4289
+ error: null,
4290
+ };
4291
+ }
4292
+ /** 把补摘要结果写回卡片与索引条目(来源:Python apply_card_summary_result,L3588)。
4293
+ *
4294
+ * 摘要行渲染统一走 `renderCardMarkdown(card, true)`——补到摘要的卡片按三行渲染。
4295
+ */
4296
+ function applyCardSummaryResult(normalized, result) {
4297
+ const card = result?.card;
4298
+ if (!card || typeof card !== 'object')
4299
+ return;
4300
+ let summary = String(result.summary ?? '');
4301
+ const source = String(result.source ?? 'workflowDetailUnavailable');
4302
+ const selectedFields = Array.isArray(result.fields) ? result.fields : [];
4303
+ const summaryContext = buildSummaryContextText(selectedFields);
4304
+ let needsModel = false;
4305
+ if (source === 'workflowDetailFormDataAutoSummary') {
4306
+ const forbiddenTerms = (card.summaryForbiddenTerms ?? [])
4307
+ .map((term) => stripHtml(term))
4308
+ .filter(Boolean);
4309
+ summary = generateBusinessSummary(selectedFields, forbiddenTerms) || summaryContext;
4310
+ // 脚本自动摘要信息量不足(过短/空)→ 标记 needsModelSummary,保留
4311
+ // summaryFields/summaryContext/summaryInstruction 交模型理解字段数据后生成
4312
+ if (stripHtml(summary).length < SUMMARY_MODEL_FALLBACK_MIN_LEN) {
4313
+ summary = '摘要暂未生成,可打开流程查看';
4314
+ needsModel = true;
4315
+ }
4316
+ }
4317
+ card.summary = summary;
4318
+ card.summarySource = source;
4319
+ card.summaryFields = selectedFields;
4320
+ card.summaryContext = summaryContext;
4321
+ card.needsModelSummary = needsModel;
4322
+ if (needsModel)
4323
+ card.summaryInstruction = SUMMARY_MODEL_INSTRUCTION;
4324
+ else
4325
+ delete card.summaryInstruction;
4326
+ delete card.renderMarkdownTemplate;
4327
+ delete card.renderHtml;
4328
+ delete card.renderHtmlTemplate;
4329
+ card.renderMarkdown = renderCardMarkdown(card, true);
4330
+ updateItemSummary(normalized, card.index, summary, source, selectedFields, needsModel);
4331
+ }
4332
+ /** 同步摘要到索引条目(来源:Python update_item_summary,L3320)。
4333
+ *
4334
+ * V11.3 还会同步 `sessionMapPayload.items`;本实现在 `workflow.search` 里是
4335
+ * **先补摘要、再 `saveListIndex`**,索引落盘的就是这份已原地改写的 `items`,语义等价。
4336
+ */
4337
+ function updateItemSummary(normalized, index, summary, summarySource, fields, needsModel = false) {
4338
+ const items = normalized.items;
4339
+ if (!Array.isArray(items))
4340
+ return;
4341
+ for (const item of items) {
4342
+ if (!item || typeof item !== 'object')
4343
+ continue;
4344
+ if (String(item.index) !== String(index))
4345
+ continue;
4346
+ item.cardSummary = summary;
4347
+ item.summarySource = summarySource;
4348
+ item.summaryFields = fields;
4349
+ item.needsModelSummary = needsModel;
4350
+ item.summaryContext = buildSummaryContextText(fields);
4351
+ return;
4352
+ }
4353
+ }
4354
+ /** 逐卡补摘要(来源:Python enrich_workflow_card_summaries,L3625)。
4355
+ *
4356
+ * 只为**当前页前 maxItems 张**卡片补详情,不为未展示页预取;并发上限 4 + 5s 短超时,单卡失败
4357
+ * 只降级为该卡片的「摘要不可用」提示,不影响整条查询。
4358
+ */
4359
+ async function enrichWorkflowCardSummaries(env, normalized, maxItems = 10) {
4360
+ const cardItems = normalized.cardItems;
4361
+ if (!Array.isArray(cardItems) || maxItems <= 0) {
4362
+ normalized.summaryEnrichment = {
4363
+ enabled: false,
4364
+ reason: Array.isArray(cardItems) ? 'max_items_lte_zero' : 'no_card_items',
4365
+ };
4366
+ return normalized;
4367
+ }
4368
+ const targetCards = cardItems.slice(0, maxItems).filter((card) => card && typeof card === 'object');
4369
+ // 会话只取一次(V11.3 把 runtime 传进每个 worker),避免 N 张卡重复解析登录态
4370
+ const session = await env.host.getSession();
4371
+ const userId = session.userId || undefined;
4372
+ let completed = 0;
4373
+ let unavailable = 0;
4374
+ let detailsRequested = 0;
4375
+ const errors = [];
4376
+ const maxWorkers = Math.max(1, Math.min(targetCards.length, SUMMARY_ENRICH_MAX_WORKERS));
4377
+ let cursor = 0;
4378
+ const worker = async () => {
4379
+ while (cursor < targetCards.length) {
4380
+ const card = targetCards[cursor];
4381
+ cursor += 1;
4382
+ let result;
4383
+ try {
4384
+ result = await fetchCardSummaryResult(env, card, userId);
4385
+ }
4386
+ catch (error) {
4387
+ result = {
4388
+ card: {},
4389
+ requested: true,
4390
+ summary: '详情接口异常,摘要暂不可用',
4391
+ source: 'workflowDetailUnavailable',
4392
+ fields: [],
4393
+ error: { stopReason: 'summary_worker_exception', error: errorText(error) },
4394
+ };
4395
+ }
4396
+ if (result.requested)
4397
+ detailsRequested += 1;
4398
+ if (result.source === 'workflowDetailFormDataAutoSummary')
4399
+ completed += 1;
4400
+ else
4401
+ unavailable += 1;
4402
+ if (result.error && typeof result.error === 'object')
4403
+ errors.push(result.error);
4404
+ applyCardSummaryResult(normalized, result);
4405
+ }
4406
+ };
4407
+ await Promise.all(Array.from({ length: maxWorkers }, () => worker()));
4408
+ normalized.summaryEnrichment = {
4409
+ enabled: true,
4410
+ source: 'REQUEST_DETAIL',
4411
+ requested: detailsRequested,
4412
+ completed,
4413
+ unavailable,
4414
+ maxItems,
4415
+ mode: 'parallel',
4416
+ maxWorkers,
4417
+ errors: errors.slice(0, 10),
4418
+ rules: [
4419
+ '列表接口 displayData 只提供流程系统字段;summaryContext 来自详情接口表单字段',
4420
+ '最终卡片摘要已由命令根据表单业务字段生成,不混入发起人、发起时间、当前节点、流程状态、停留时长等辅助信息或流程元信息',
4421
+ '每个卡片的 summaryForbiddenTerms 记录了发起人姓名等禁止进入摘要的词;脚本生成摘要时会过滤这些词',
4422
+ '最终回复直接输出 renderMarkdown,不要重新生成摘要、不要重写模板结构、不要把 cardItems 改写成普通编号段落或连续纯文本',
4423
+ '只为当前页卡片补摘要,不为未展示页预取详情',
4424
+ '详情接口未返回表单数据时,只在对应卡片展示不可用提示,不使用系统字段伪造摘要',
4425
+ ],
4426
+ };
4427
+ return normalized;
4428
+ }
3871
4429
  function normalizeDetailSummaryValue(_name, value) {
3872
4430
  let text = stripHtml(value);
3873
4431
  text = text.replace(/https?:\/\/\S+/gi, '').trim();
3874
- text = text.replace(/\s+/g, ' ').replace(/[::;;,.,。]/g, '').trim();
3875
- if (text.length > 180)
3876
- text = text.slice(0, 180);
3877
- return text;
4432
+ // 与 V11.3 一致:标点只裁**首尾**(Python `.strip(" ::;;,.,。")`),不删正文里的标点——
4433
+ // 旧实现用 `/[...]/g` 删掉了「金额:1000」这类值里的冒号,摘要读起来是「金额1000」。
4434
+ text = text.replace(/\s+/g, ' ').replace(/^[ ::;;,.,。]+|[ ::;;,.,。]+$/g, '');
4435
+ // 截断同样走 V11.3 的标点边界口径(旧实现是硬 slice,会把词截成半截)
4436
+ return truncateText(text, 180);
3878
4437
  }
3879
4438
  function isMeaninglessSummaryValue(value) {
3880
4439
  const text = (value || '').trim();
@@ -3954,10 +4513,98 @@ function selectSummaryFields(fields, limit = 12) {
3954
4513
  }
3955
4514
  return candidates;
3956
4515
  }
3957
- function generateBusinessSummary(fields) {
3958
- if (!fields.length)
3959
- return '该流程暂无可用表单数据,暂时无法生成摘要';
3960
- return fields.map((f) => `${f.fieldName}:${f.value}`).join(';');
4516
+ /** 业务摘要生成(来源:Python generate_business_summary,L1385)。
4517
+ *
4518
+ * 把任意工作流的表单字段归一为「原因 / 金额 / 类型 / 起止时间 / 时长 / 负责人」等通用语义角色,
4519
+ * 按固定优先级拼成一句话(最多 3 个片段、总长 100 字内),**不依赖具体流程模板**。语义角色凑不齐
4520
+ * 时用尚未使用的业务字段兜底补齐。信息量不足(< `SUMMARY_MODEL_FALLBACK_MIN_LEN`)时返回空串,
4521
+ * 由调用方标记 `needsModelSummary` 交模型基于 `summaryContext` 理解生成。
4522
+ *
4523
+ * `forbiddenTerms` 是发起人姓名等禁止进入摘要的词(见 `listSummaryForbiddenTerms`)。
4524
+ */
4525
+ function generateBusinessSummary(fields, forbiddenTerms = null) {
4526
+ const cleanFields = [];
4527
+ for (const field of fields) {
4528
+ const name = stripHtml(field?.fieldName ?? '');
4529
+ const value = stripHtml(field?.value ?? '');
4530
+ if (!name || !value)
4531
+ continue;
4532
+ if ((forbiddenTerms ?? []).some((term) => stripHtml(term) && value.includes(stripHtml(term))))
4533
+ continue;
4534
+ cleanFields.push({ fieldName: name, value, componentKey: asString(field?.componentKey) ?? '' });
4535
+ }
4536
+ if (!cleanFields.length)
4537
+ return '';
4538
+ const reasonCandidates = bestReasonFields(cleanFields);
4539
+ let reasonField = reasonCandidates.length ? reasonCandidates[0] : null;
4540
+ const anchorStem = reasonField ? summaryFieldStem(reasonField.fieldName) : '';
4541
+ const amountField = bestSemanticField(cleanFields, 'amount', anchorStem);
4542
+ const typeField = bestSemanticField(cleanFields, 'type', anchorStem);
4543
+ const durationField = bestSemanticField(cleanFields, 'duration', anchorStem);
4544
+ const personField = bestSemanticField(cleanFields, 'person', anchorStem);
4545
+ const temporalFragment = summaryTemporalFragment(cleanFields, anchorStem);
4546
+ const fragments = [];
4547
+ const usedPairs = new Set();
4548
+ const pairKey = (field) => `${stripHtml(field?.fieldName)}\u0000${stripHtml(field?.value)}`;
4549
+ const appendFragment = (text, field) => {
4550
+ const value = stripHtml(text);
4551
+ if (!value || fragments.length >= 3)
4552
+ return;
4553
+ let pair = null;
4554
+ if (field) {
4555
+ pair = pairKey(field);
4556
+ if (usedPairs.has(pair))
4557
+ return;
4558
+ }
4559
+ if (fragments.includes(value))
4560
+ return;
4561
+ fragments.push(value);
4562
+ if (pair)
4563
+ usedPairs.add(pair);
4564
+ };
4565
+ let reasonText = '';
4566
+ let reasonScore = 0;
4567
+ if (reasonField) {
4568
+ reasonScore = scoreSummaryFieldValue(stripHtml(reasonField.fieldName), stripHtml(reasonField.value));
4569
+ reasonText = summarizeReasonText(extractLabeledSummaryValue(stripHtml(reasonField.value)));
4570
+ // 主字段提炼后过短(< 8 字)→ 顺延到信息量次高的描述字段
4571
+ if (reasonText.length < 8) {
4572
+ for (const alternative of reasonCandidates.slice(1)) {
4573
+ const alternativeText = summarizeReasonText(extractLabeledSummaryValue(stripHtml(alternative.value)));
4574
+ if (alternativeText.length > reasonText.length) {
4575
+ reasonField = alternative;
4576
+ reasonText = alternativeText;
4577
+ reasonScore = scoreSummaryFieldValue(stripHtml(alternative.fieldName), stripHtml(alternative.value));
4578
+ break;
4579
+ }
4580
+ }
4581
+ }
4582
+ }
4583
+ if (reasonText && reasonScore >= 4)
4584
+ appendFragment(reasonText, reasonField);
4585
+ if (amountField)
4586
+ appendFragment(`${stripHtml(amountField.fieldName)}${valueWithUnit(stripHtml(amountField.value), '元')}`, amountField);
4587
+ if (typeField)
4588
+ appendFragment(`${stripHtml(typeField.fieldName)}${stripHtml(typeField.value)}`, typeField);
4589
+ appendFragment(temporalFragment);
4590
+ if (durationField)
4591
+ appendFragment(`${stripHtml(durationField.fieldName)}${stripHtml(durationField.value)}`, durationField);
4592
+ if (reasonText && reasonScore < 4) {
4593
+ appendFragment(reasonField ? `${stripHtml(reasonField.fieldName)}${reasonText}` : reasonText, reasonField);
4594
+ }
4595
+ if (personField)
4596
+ appendFragment(`${stripHtml(personField.fieldName)}${stripHtml(personField.value)}`, personField);
4597
+ // 语义角色仍不足时,用尚未使用的业务字段补齐(通用兜底,不按工作流写特例)
4598
+ for (const field of cleanFields) {
4599
+ if (usedPairs.has(`${field.fieldName}\u0000${field.value}`))
4600
+ continue;
4601
+ if (field.value && fragments.some((fragment) => fragment.includes(field.value)))
4602
+ continue;
4603
+ appendFragment(`${field.fieldName}${field.value}`, field);
4604
+ if (fragments.length >= 3)
4605
+ break;
4606
+ }
4607
+ return cleanGeneratedSummary(fragments.join(','), forbiddenTerms, 100, false);
3961
4608
  }
3962
4609
  // ---------------------------------------------------------------------------
3963
4610
  // workflow.open(来源:cmd_workflow_open / build_workflow_open_url)
@@ -4192,8 +4839,8 @@ const workflowMemoryPrompt = {
4192
4839
  };
4193
4840
  if (!result.available)
4194
4841
  result.diagnosis = { code: body?.code, status: body?.status, fail: body?.fail, dataEmpty: !promptText };
4195
- // oaContext:OA 版本 + 当前用户身份(岗位/部门/分部)。原 Python 由登录技能 whoami
4196
- // 提供;CLI 侧等价信息由会话自身承载,无需额外请求。
4842
+ // oaContext:OA 版本 + 当前用户身份(岗位/部门/分部)。原 Python 由旧登录链路
4843
+ // 的 whoami 提供;CLI 侧等价信息由会话自身承载,无需额外请求。
4197
4844
  const session = await env.host.getSession();
4198
4845
  const user = {};
4199
4846
  const userCandidates = [
@@ -4213,7 +4860,14 @@ const workflowMemoryPrompt = {
4213
4860
  oaContext.version = version;
4214
4861
  if (Object.keys(user).length)
4215
4862
  oaContext.user = user;
4216
- const hasOaContext = Object.keys(oaContext).length > 0;
4863
+ // ★ 当前环境是否支持「智能分组」查询 —— 由 CLI 按 OA 版本判定(与 todoStat / planJson 的门禁同源)。
4864
+ // 模型直接按它分流即可:true ⇒ 先 todoStat 统计再带 planJson 取数;false ⇒ 直接 workflow.search,
4865
+ // CLI 自身输出分组统计与卡片。**不必自己比版本号**(比法容易出错),也**不需要"先空跑一次
4866
+ // workflow.search 看模式"**。版本未知(会话取不到)⇒ 按不支持处理。
4867
+ oaContext.smartGrouping = Boolean(version)
4868
+ && isVersionAtLeast(oaVersionTuple(version), SMART_GROUPING_MIN_OA_VERSION);
4869
+ // 注意:判据只看「有没有版本/身份」——smartGrouping 恒有值,不能让它把 oaContextMissing 变成永远 false。
4870
+ const hasOaContext = Boolean(version) || Object.keys(user).length > 0;
4217
4871
  result.oaContext = hasOaContext ? oaContext : null;
4218
4872
  result.oaContextMissing = !hasOaContext;
4219
4873
  if (!hasOaContext) {
@@ -4234,3 +4888,31 @@ export const workflowReadOperations = [
4234
4888
  workflowPageOpen,
4235
4889
  workflowMemoryPrompt,
4236
4890
  ];
4891
+ /**
4892
+ * 供**批量范围驱动**复用:该大分类的「全部」子分类 tabid(todo=0 / done=50 / mine=100 / all=300)。
4893
+ *
4894
+ * 为什么批量范围驱动需要它:legacy(低版本)模式下 `workflow.search` 对未显式指定子分类的 `todo`
4895
+ * 默认查「待处理(7)」(见 `buildSearchQuery` 步骤 2.5,V11.2 语义)。而**批量过滤列表端点**
4896
+ * (`…/list/data/getTodoListData/batch/batch<X>`)在 tabid=7 下只返待处理 ⇒ 当范围里全是
4897
+ * 抄送/传阅/转发类流程时**恒 0 条**,模型会把"取不到"报成"范围内没有数据"(2026-09-20 真实事故)。
4898
+ * 范围驱动的语义是「该 scope 下的全部流程,再由 `batchType` 过滤可批量操作性」,故未指定子分类时
4899
+ * 显式取「全部」;scope 显式给了 `subCategory`/`tabName`/`tabid` 则原样尊重。
4900
+ */
4901
+ export function defaultTabIdOf(category) {
4902
+ return DEFAULT_TABID[category] ?? DEFAULT_TABID.todo;
4903
+ }
4904
+ /**
4905
+ * 供**批量操作的范围驱动**复用:以与 `workflow.search` **完全相同的口径**取数(返回 `data`)。
4906
+ *
4907
+ * 为什么要复用同一个 operation,而不是在批量里另写一条取数路径:批量门禁的放行依据是
4908
+ * 「最近一次查询结果」的快照(`search-index.json`,由 search 内部**无条件**刷新)——批量自己
4909
+ * 另写一条取数路径就会造成"查一个口径、门禁另一个口径"的分叉(V12 早期真实事故:模型按文档
4910
+ * 查出来的 requestId 全被门禁判成"不在最近一次查询结果里",只能反复重查反复试)。
4911
+ *
4912
+ * 调用方(`write.ts` 的 `runScopeDrivenBatch`)只消费 `total` 与 `cards[].requestId`;超线时
4913
+ * `cards` 会被体积守卫移出 stdout,调用方需回退到刚刷新的索引快照取 ID。
4914
+ */
4915
+ export async function runWorkflowSearch(args, env) {
4916
+ const envelope = await workflowSearch.handler(args, env);
4917
+ return (envelope?.data ?? {});
4918
+ }