weaver-work-cli 0.1.28 → 0.1.29

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 (40) hide show
  1. package/dist/shortcuts/invoice/host.js +2 -1
  2. package/dist/shortcuts/invoice/manifest.js +2 -1
  3. package/dist/shortcuts/invoice/operations/issuing-qys.js +132 -36
  4. package/dist/shortcuts/workflow/index.js +3 -0
  5. package/dist/shortcuts/workflow/operations/read.js +13 -6
  6. package/dist/shortcuts/workflow/operations/registry.js +2 -0
  7. package/dist/shortcuts/workflow/operations/todoSmartConvert/businessRules.js +331 -0
  8. package/dist/shortcuts/workflow/operations/todoSmartConvert/detailFieldRules.js +133 -0
  9. package/dist/shortcuts/workflow/operations/todoSmartConvert/summaryCache.js +326 -0
  10. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoDetailCache.js +113 -0
  11. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoDetailFetcher.js +259 -0
  12. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoListRows.js +64 -0
  13. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoOutputPlanner.js +28 -0
  14. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.aggregate.js +70 -0
  15. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.collect.js +267 -0
  16. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.core.js +392 -0
  17. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.detail.js +165 -0
  18. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.group.js +448 -0
  19. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.groupSummary.js +348 -0
  20. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.js +222 -0
  21. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.pipeline.js +66 -0
  22. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.smartGroup.js +502 -0
  23. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.summary.js +540 -0
  24. package/docs/_catalog.md +1 -0
  25. package/docs/invoice.md +3 -3
  26. package/docs/todo-smart-convert/README.md +171 -0
  27. package/docs/todo-smart-convert/data-contract.md +236 -0
  28. package/docs/todo-smart-convert/mock/ai-todo-query-target.json +209 -0
  29. package/docs/todo-smart-convert/subgroup-rules.md +245 -0
  30. package/package.json +1 -1
  31. package/scripts/package-skill.mjs +1 -1
  32. package/skill-template/domains/yepiaotong.md +2 -2
  33. package/skills/weaver-e10-workflow/SKILL.md +23 -1
  34. package/skills/weaver-e10-workflow/references/todo-smart-convert.md +282 -0
  35. package/skills/weaver-e10-workflow/scripts/todo-summary.cjs +695 -0
  36. package/skills/weaver-e10-yepiaotong/SKILL.md +6 -6
  37. package/skills/weaver-e10-yepiaotong/references/invoice-issuing-examples.md +10 -7
  38. package/skills/weaver-e10-yepiaotong/references/invoice-issuing-form-fill-rules.md +41 -14
  39. package/skills/weaver-e10-yepiaotong/references/invoice-issuing-workflow.md +54 -26
  40. package/skills/weaver-e10-yepiaotong/references/source-manifest.json +2 -2
@@ -0,0 +1,165 @@
1
+ import { fetchRequestDetailsBatch } from './todoDetailFetcher.js';
2
+ import { readBoundedInt } from './todoSmartConvert.core.js';
3
+ import { openDetailCache } from './todoDetailCache.js';
4
+ /**
5
+ * 把详情字段拼成喂模型的文本。
6
+ *
7
+ * 直接拼装详情取数阶段已经清洗过的字段值。
8
+ */
9
+ export function contextOfDetail(detail) {
10
+ return detail.summaryFields.map((field) => `${field.fieldName}:${field.value}`).join(';');
11
+ }
12
+ const EMPTY_DETAIL_STATS = {
13
+ requested: 0,
14
+ fetched: 0,
15
+ cacheHit: 0,
16
+ contextAvailable: 0,
17
+ contextEmpty: 0,
18
+ failed: 0,
19
+ concurrency: 0,
20
+ durationMs: 0,
21
+ };
22
+ /** 缓存里存的、也是 `DetailFact` 用的字段裁剪口径:最多 8 个、每个值 80 字。 */
23
+ export const DETAIL_MATERIAL_FIELD_LIMIT = 8;
24
+ export const DETAIL_MATERIAL_VALUE_LIMIT = 80;
25
+ /**
26
+ * 从条目集合里建 `requestId → lastFeedBackDateTime`。
27
+ *
28
+ * 为什么必须有它:详情缓存的键是 `requestId__lastFeedBackDateTime`,**不传 stamps 就永远是
29
+ * miss**(等于缓存白建)。这里统一从条目本身取时间戳,各阶段只要手上有 rows 就能建。
30
+ * 同一 requestId 出现多次(叠加视图)时取第一个非空值——它对同一条流程是同一个值。
31
+ */
32
+ export function stampsOfRows(rows) {
33
+ const stamps = new Map();
34
+ for (const row of rows) {
35
+ const requestId = String(row?.requestId ?? '').trim();
36
+ if (!requestId || stamps.has(requestId))
37
+ continue;
38
+ const stamp = String(row?.lastFeedBackDateTime ?? '').trim();
39
+ if (stamp)
40
+ stamps.set(requestId, stamp);
41
+ else
42
+ stamps.set(requestId, '');
43
+ }
44
+ return stamps;
45
+ }
46
+ function materialFieldsOf(detail) {
47
+ return detail.summaryFields
48
+ .filter((field) => field.value.length <= 200)
49
+ .slice(0, DETAIL_MATERIAL_FIELD_LIMIT)
50
+ .map((field) => ({ fieldName: field.fieldName, value: field.value.slice(0, DETAIL_MATERIAL_VALUE_LIMIT) }));
51
+ }
52
+ /**
53
+ * 取流程详情数据:**缓存优先**(键 = `requestId__lastFeedBackDateTime`,7 天),
54
+ * 缓存没有再并发调 `/getWorkflowRequest`,取到后写回缓存(详情为空也写)。
55
+ *
56
+ * 失败不致命:返回已拿到的部分 + warning,调用方按「无详情」降级。
57
+ */
58
+ export async function fetchDetailFacts(env, requestIds, args, stamps) {
59
+ const unique = [...new Set(requestIds.filter(Boolean))];
60
+ const facts = new Map();
61
+ const warnings = [];
62
+ const stats = { ...EMPTY_DETAIL_STATS, requested: unique.length };
63
+ if (unique.length === 0)
64
+ return { facts, stats, warnings };
65
+ const startedAt = Date.now();
66
+ const cache = await openDetailCache(env.ctx);
67
+ stats.cacheFile = cache.file;
68
+ // ① 缓存命中:不发请求
69
+ const missing = [];
70
+ for (const requestId of unique) {
71
+ const hit = cache.get(requestId, stamps?.get(requestId));
72
+ if (!hit) {
73
+ missing.push(requestId);
74
+ continue;
75
+ }
76
+ facts.set(requestId, {
77
+ context: hit.context,
78
+ contextAvailable: hit.contextAvailable,
79
+ metrics: hit.metrics,
80
+ fields: hit.fields,
81
+ });
82
+ stats.cacheHit += 1;
83
+ }
84
+ // ② 未命中:现场拉取并写缓存
85
+ if (missing.length > 0) {
86
+ let written = 0;
87
+ try {
88
+ const { details, stats: fetchStats } = await fetchRequestDetailsBatch(env, missing, {
89
+ concurrency: readBoundedInt(args.detailConcurrency, 'detailConcurrency', 10, 1, 32),
90
+ timeoutMs: readBoundedInt(args.detailTimeoutMs, 'detailTimeoutMs', 10_000, 1_000, 60_000),
91
+ });
92
+ stats.failed = fetchStats.failed;
93
+ stats.concurrency = fetchStats.concurrency;
94
+ for (const [requestId, detail] of details) {
95
+ const fact = {
96
+ context: contextOfDetail(detail),
97
+ contextAvailable: detail.summaryFields.length > 0,
98
+ metrics: detail.metrics ?? {},
99
+ fields: materialFieldsOf(detail),
100
+ };
101
+ facts.set(requestId, fact);
102
+ stats.fetched += 1;
103
+ if (fact.contextAvailable)
104
+ stats.contextAvailable += 1;
105
+ else
106
+ stats.contextEmpty += 1;
107
+ // 详情为空也写缓存:否则这类条目每次都要重拉
108
+ if (cache.set(requestId, stamps?.get(requestId), {
109
+ context: fact.context,
110
+ contextAvailable: fact.contextAvailable,
111
+ metrics: fact.metrics,
112
+ fields: fact.fields,
113
+ }))
114
+ written += 1;
115
+ }
116
+ }
117
+ catch (error) {
118
+ warnings.push(`详情取数失败(${String(error?.message ?? error)}),本次按「无详情」降级处理`);
119
+ }
120
+ // 阶段末落盘一次(与摘要缓存一致),跨进程仍可复用
121
+ if (written > 0) {
122
+ const file = await cache.save();
123
+ if (file)
124
+ stats.cacheFile = file;
125
+ }
126
+ }
127
+ stats.durationMs = Date.now() - startedAt;
128
+ if (stats.failed > 0) {
129
+ warnings.push(`有 ${stats.failed} 条流程详情数据请求失败(已重试);这些条目按「无详情」处理`);
130
+ }
131
+ return { facts, stats, warnings };
132
+ }
133
+ /**
134
+ * 详情上下文提供器:**同进程内存 → 详情缓存 → 现场拉取**,三级取数。
135
+ *
136
+ * `prefetched` 是同一进程里刚拉过的结果(`smartGroup + withDetailContext` 合并调用时复用)。
137
+ */
138
+ export async function loadDetailContextProvider(env, requestIds, args, prefetched, stamps) {
139
+ const unique = [...new Set(requestIds.filter(Boolean))];
140
+ const fromMemory = unique.filter((id) => prefetched?.has(id));
141
+ const rest = unique.filter((id) => !prefetched?.has(id));
142
+ const reused = fromMemory.length;
143
+ const fetched = rest.length > 0
144
+ ? await fetchDetailFacts(env, rest, args, stamps)
145
+ : { facts: new Map(), stats: { ...EMPTY_DETAIL_STATS }, warnings: [] };
146
+ const all = new Map(prefetched ?? []);
147
+ for (const [requestId, fact] of fetched.facts)
148
+ all.set(requestId, fact);
149
+ const fallback = { context: '', contextAvailable: false };
150
+ return {
151
+ fetchedFromNetwork: fetched.stats.fetched,
152
+ reused,
153
+ stats: fetched.stats,
154
+ warnings: fetched.warnings,
155
+ get: (requestId) => {
156
+ const fact = all.get(requestId);
157
+ if (!fact)
158
+ return fallback;
159
+ return {
160
+ context: fact.context,
161
+ contextAvailable: fact.contextAvailable,
162
+ };
163
+ },
164
+ };
165
+ }
@@ -0,0 +1,448 @@
1
+ import { workflowValidationError } from '../../errors.js';
2
+ import { UNKNOWN_GROUP, groupKeyOf, readGroupBy, readItems, readTopN, assertKnownKeys, } from './todoSmartConvert.core.js';
3
+ import { intentAppliesToKind, } from './businessRules.js';
4
+ /** 紧急程度以 requestLevel=2 或显示文本「紧急」为准;1 级是「重要」。 */
5
+ const URGENT_PATTERN = /紧急/u;
6
+ export const PRIORITY_GROUP_ID = 'priority';
7
+ export const PRIORITY_GROUP_TITLE = '建议优先处理的流程';
8
+ /** 少于 3 条的工作流组不生成二次汇总项。 */
9
+ export const GROUP_MIN_ITEMS_FOR_SUBGROUP = 3;
10
+ /**
11
+ * 判断用户意图是否作用于这个业务类型(2026-10-26 换成 INTENT_RULES 表驱动)。
12
+ *
13
+ * 专属意图只作用于对应类型;通用意图只作用于内部留言与其他类;都不命中 ⇒ 视为没有意图。
14
+ * 实现见 `businessRules.ts` 的 `intentAppliesToKind`。
15
+ */
16
+ export function focusAppliesToKind(focus, kind) {
17
+ return intentAppliesToKind(focus, kind);
18
+ }
19
+ /** 只用于接收时间区间比较;无法识别的时间不参与计算。 */
20
+ export function timeOf(value) {
21
+ if (!value)
22
+ return undefined;
23
+ const text = value.trim();
24
+ if (/^\d{13}$/u.test(text))
25
+ return Number(text);
26
+ if (/^\d{10}$/u.test(text))
27
+ return Number(text) * 1000;
28
+ const parsed = Date.parse(text.replace(/\//gu, '-'));
29
+ return Number.isNaN(parsed) ? undefined : parsed;
30
+ }
31
+ /**
32
+ * 是否「紧急」。
33
+ *
34
+ * requestLevel 的码位为 0 正常、1 重要、2 紧急;文本只作兜底。
35
+ */
36
+ export function isUrgentRow(row) {
37
+ if (row.requestLevel >= 2)
38
+ return true;
39
+ return URGENT_PATTERN.test(row.requestLevelShow);
40
+ }
41
+ /** `userOverTime` 是显示层文案(`昨天到期` / `05-07到期` / `11-30到期`),不一定代表「已超时」。 */
42
+ const USER_OVER_TIME_PATTERN = /^(?:(\d{1,2})[-/](\d{1,2})\s*到期|昨天到期|已超时|今日到期)$/u;
43
+ /** 判定「今年同月日」还是「去年同月日」:差超过半年,按去年算(跨年时 `12-20` 不该被当成年底未到)。 */
44
+ const OVER_TIME_YEAR_WINDOW_MS = 180 * 24 * 60 * 60 * 1000;
45
+ /**
46
+ * `userOverTime` 文本是否表示**已经**过期。
47
+ *
48
+ * 对只有月日的文案按当前年补齐;跨年时再回退到上一年。
49
+ * 认不出来的文案一律返回 `false` —— **不猜**,宁可漏一条也不把「未到期」误报成超时。
50
+ */
51
+ export function isExpiredUserOverTime(text, now = Date.now()) {
52
+ const value = (text ?? '').trim();
53
+ if (!value)
54
+ return false;
55
+ if (/^(?:昨天到期|已超时)$/u.test(value))
56
+ return true;
57
+ const matched = USER_OVER_TIME_PATTERN.exec(value);
58
+ if (!matched || !matched[1])
59
+ return false;
60
+ const month = Number(matched[1]);
61
+ const day = Number(matched[2]);
62
+ if (month < 1 || month > 12 || day < 1 || day > 31)
63
+ return false;
64
+ const base = new Date(now);
65
+ let stamp = new Date(base.getFullYear(), month - 1, day).getTime();
66
+ if (stamp - now > OVER_TIME_YEAR_WINDOW_MS) {
67
+ stamp = new Date(base.getFullYear() - 1, month - 1, day).getTime();
68
+ }
69
+ return stamp <= now;
70
+ }
71
+ /**
72
+ * 是否「超时」。
73
+ *
74
+ * 以接口算好的 `overTime`(displayData 的 `overdue`)为准;它缺失时用 `userOverTime` 文案兜底。
75
+ * 两个都用而不是只看文本:`11-30到期` 在 10-17 明显还没到期,按文本裸判会误收。
76
+ */
77
+ export function isOverdueRow(row) {
78
+ if (row.overTime)
79
+ return true;
80
+ return isExpiredUserOverTime(row.userOverTime);
81
+ }
82
+ /**
83
+ * `userOverTime` 文本是否表示**今天到期**(还没过期)。
84
+ *
85
+ * 与 `isExpiredUserOverTime` 互补:`今日到期` 或 `MM-DD到期` 且该日期就是今天。
86
+ * 已经 `overTime=true` 的条目归「已过期」,这里返回 false,避免同一条被两边重复计入。
87
+ */
88
+ function isExpiringTodayUserOverTime(text, now = Date.now()) {
89
+ const value = (text ?? '').trim();
90
+ if (!value)
91
+ return false;
92
+ if (/^今日到期$/u.test(value))
93
+ return true;
94
+ const matched = USER_OVER_TIME_PATTERN.exec(value);
95
+ if (!matched || !matched[1])
96
+ return false;
97
+ const month = Number(matched[1]);
98
+ const day = Number(matched[2]);
99
+ if (month < 1 || month > 12 || day < 1 || day > 31)
100
+ return false;
101
+ const base = new Date(now);
102
+ let stamp = new Date(base.getFullYear(), month - 1, day).getTime();
103
+ if (stamp - now > OVER_TIME_YEAR_WINDOW_MS) {
104
+ stamp = new Date(base.getFullYear() - 1, month - 1, day).getTime();
105
+ }
106
+ const target = new Date(stamp);
107
+ return target.getFullYear() === base.getFullYear()
108
+ && target.getMonth() === base.getMonth()
109
+ && target.getDate() === base.getDate();
110
+ }
111
+ /** 是否**今天到期**(未过期;已过期用 `isOverdueRow`)。 */
112
+ export function isExpiringTodayRow(row, now = Date.now()) {
113
+ if (row.overTime)
114
+ return false;
115
+ return isExpiringTodayUserOverTime(row.userOverTime, now);
116
+ }
117
+ /**
118
+ * 「建议优先处理的流程」筛选规则:
119
+ *
120
+ * 1. **超时** 必收(`userOverTime` / `overTime` 判定,与紧急程度无关);
121
+ * 2. **紧急程度**按 `requestLevel` 取:整批有 `=2`(紧急)就只要 2;没有 2 才退到 `=1`(重要);
122
+ * `=0`(正常)**不因等级入选**——降级链到 1 就停,不倒到 0;
123
+ * 3. 整批连 1 级都没有(全 0)时,等级条件不生效,只按超时筛;此时若一条都没超时,
124
+ * 优先组为空("实在没有紧急的就算了"),不硬凑。
125
+ *
126
+ * 注意这是**筛选**,不是视图叠加:入选条目的 `requestLevel` 只有 2(或 2 缺失时的 1)与超时两类。
127
+ */
128
+ export function selectPriorityRows(rows) {
129
+ let maxLevel = 0;
130
+ for (const row of rows) {
131
+ if (row.requestLevel > maxLevel)
132
+ maxLevel = row.requestLevel;
133
+ }
134
+ const targetLevel = maxLevel >= 2 ? 2 : maxLevel >= 1 ? 1 : undefined;
135
+ const selected = [];
136
+ let overdueCount = 0;
137
+ let levelCount = 0;
138
+ let bothCount = 0;
139
+ for (const row of rows) {
140
+ const overdue = isOverdueRow(row);
141
+ const byLevel = targetLevel !== undefined && row.requestLevel >= targetLevel;
142
+ if (overdue)
143
+ overdueCount += 1;
144
+ if (byLevel)
145
+ levelCount += 1;
146
+ if (overdue && byLevel)
147
+ bothCount += 1;
148
+ if (overdue || byLevel)
149
+ selected.push(row);
150
+ }
151
+ return { rows: selected, targetLevel, overdueCount, levelCount, bothCount };
152
+ }
153
+ /**
154
+ * 优先组内部排序:先「既超时又高级」的,再「仅超时」,最后「仅等级」;
155
+ * 同档按接收时间升序(越早收到越该先处理),时间取不到排后。
156
+ */
157
+ export function sortPriorityRows(rows) {
158
+ const rankOf = (row) => {
159
+ const overdue = isOverdueRow(row);
160
+ if (overdue && row.requestLevel >= 2)
161
+ return 0;
162
+ if (overdue)
163
+ return 1;
164
+ return 2;
165
+ };
166
+ return [...rows].sort((a, b) => {
167
+ const rank = rankOf(a) - rankOf(b);
168
+ if (rank !== 0)
169
+ return rank;
170
+ const at = timeOf(a.receivedTime) ?? Number.MAX_SAFE_INTEGER;
171
+ const bt = timeOf(b.receivedTime) ?? Number.MAX_SAFE_INTEGER;
172
+ if (at !== bt)
173
+ return at - bt;
174
+ return a.requestId.localeCompare(b.requestId);
175
+ });
176
+ }
177
+ /** 分组维度用的紧急程度文本:优先显示层文本,没有文本时按码位给出「紧急」,仍取不到就归未分类。 */
178
+ function urgencyOf(row) {
179
+ if (row.requestLevelShow)
180
+ return row.requestLevelShow;
181
+ return row.requestLevel >= 2 ? '紧急' : UNKNOWN_GROUP;
182
+ }
183
+ /** 内部行 → 目标 requestData:**只挑目标字段**,内部辅助位(workflowName/receivedTime/amount…)不外泄。 */
184
+ export function toRequestData(row) {
185
+ return {
186
+ requestId: row.requestId,
187
+ workflowId: row.workflowId,
188
+ userMasterId: row.userMasterId,
189
+ isremark: row.isremark,
190
+ nodeId: row.nodeId,
191
+ overTime: row.overTime,
192
+ userOverTime: row.userOverTime,
193
+ requestLevel: row.requestLevel,
194
+ requestLevelShow: row.requestLevelShow,
195
+ requestNameTitle: row.requestNameTitle,
196
+ creater: row.creater,
197
+ createrName: row.createrName,
198
+ lastFeedBackDateTime: row.lastFeedBackDateTime,
199
+ summary: row.summary ?? '',
200
+ };
201
+ }
202
+ export function statsOf(rows) {
203
+ let overdueCount = 0;
204
+ let urgentCount = 0;
205
+ let earliest = null;
206
+ let latest = null;
207
+ for (const row of rows) {
208
+ if (isOverdueRow(row))
209
+ overdueCount += 1;
210
+ if (isUrgentRow(row))
211
+ urgentCount += 1;
212
+ const stamp = timeOf(row.receivedTime);
213
+ if (stamp !== undefined && row.receivedTime) {
214
+ if (!earliest || stamp < earliest.time)
215
+ earliest = { text: row.receivedTime, time: stamp };
216
+ if (!latest || stamp > latest.time)
217
+ latest = { text: row.receivedTime, time: stamp };
218
+ }
219
+ }
220
+ return {
221
+ overdueCount,
222
+ urgentCount,
223
+ receivedRange: earliest && latest ? { earliest: earliest.text, latest: latest.text } : null,
224
+ };
225
+ }
226
+ function markdownCell(value) {
227
+ return value.replace(/\|/gu, '\\|');
228
+ }
229
+ export function renderGroupMarkdown(params) {
230
+ const { groupBy, total, groups, restGroupCount, restCount } = params;
231
+ const dimension = {
232
+ workflow: '按工作流名称',
233
+ urgency: '按紧急程度',
234
+ date: '按接收日期',
235
+ none: '不分组',
236
+ };
237
+ const lines = [
238
+ `待办分组(${dimension[groupBy]},共 ${total} 条,${groups.length + restGroupCount} 组)`,
239
+ '',
240
+ '| 分组 | 条数 | 超时 | 紧急 |',
241
+ '| --- | --- | --- | --- |',
242
+ ];
243
+ for (const group of groups) {
244
+ lines.push(`| ${markdownCell(group.title)} | ${group.count} | ${group.stats.overdueCount} | ${group.stats.urgentCount} |`);
245
+ }
246
+ if (restGroupCount > 0) {
247
+ lines.push(`| 其余 ${restGroupCount} 组 | ${restCount} | — | — |`);
248
+ }
249
+ return lines.join('\n');
250
+ }
251
+ /**
252
+ * 读取用户意图顺序(`groupOrder`):模型按提示词里「先看请假、满意度放最后」这类意图
253
+ * 给出的分组顺序,元素可以是 `workflowName` 也可以是分组 id。
254
+ *
255
+ * 为什么不自己做「意图识别」:排序意图只能用自然语言表达,CLI 猜不准;
256
+ * 而模型已经读过提示词,让它**显式把顺序写出来**、CLI 只做重排与校验,
257
+ * 才不会出现「同一句话两次跑出两种顺序」。
258
+ */
259
+ export function readGroupOrder(args) {
260
+ const raw = args.groupOrder;
261
+ if (raw === undefined || raw === null)
262
+ return [];
263
+ if (!Array.isArray(raw)) {
264
+ throw workflowValidationError('group_order_invalid', 'groupOrder 必须是字符串数组:按**期望展示顺序**列出 workflowName 或分组 id(没列到的组按源数据顺序排在后面)', { retryAllowed: true });
265
+ }
266
+ const order = [];
267
+ for (const item of raw) {
268
+ const text = String(item ?? '').trim();
269
+ if (text && !order.includes(text))
270
+ order.push(text);
271
+ }
272
+ return order;
273
+ }
274
+ /**
275
+ * 按 `groupOrder` 重排分组:**优先组永远第一**(它是筛选出来的置顶视图,不属于工作流清单),
276
+ * 其余按给定顺序在前、未提到的保持源数据顺序在后。
277
+ *
278
+ * 匹配不上的名字不报错、只回传 `unknown`(模型可能用了「请假类」这种口径名而不是流程名);
279
+ * 全都不匹配时结果与不传等价 —— 顺序意图给错了,最多「没生效」,不该让整次汇总失败。
280
+ */
281
+ export function orderGroupsByIntent(groups, order) {
282
+ if (order.length === 0)
283
+ return { groups, applied: 0, unknown: [] };
284
+ const pinned = groups.filter((group) => group.topPriority === true);
285
+ const rest = groups.filter((group) => group.topPriority !== true);
286
+ const chosen = [];
287
+ const unknown = [];
288
+ for (const key of order) {
289
+ const hit = rest.find((group) => !chosen.includes(group) && (group.id === key || group.title === key));
290
+ if (hit)
291
+ chosen.push(hit);
292
+ else
293
+ unknown.push(key);
294
+ }
295
+ return {
296
+ groups: [...pinned, ...chosen, ...rest.filter((group) => !chosen.includes(group))],
297
+ applied: chosen.length,
298
+ unknown,
299
+ };
300
+ }
301
+ /**
302
+ * 分组阶段:`items` → 目标的 `wfIdGroup`。
303
+ *
304
+ * 口径(都是确定值,不要交给模型算):
305
+ * - **计数唯一规则**:任何分组的 `count` 都等于该组内 displayData 的行数,
306
+ * 有几条数据就计几;没有第二套口径(不按发起人、不按流程、不按金额派生计数)。
307
+ * 跨页重叠的同一 `requestId` 视为同一行、只计一次(此时 `duplicateCount` 会说明原始条数),
308
+ * 因为那是同一行数据被取了两遍,不是两条待办。
309
+ * - 分组键默认 `workflowName`(`groupBy=workflow`;`workflowId : workflowName` 是多对一,
310
+ * 多版本必须合并成一组);`id` 用 workflowId,缺 id 时用 `name:<名称>`;
311
+ * - **优先组**:按 `selectPriorityRows` 筛出的条目单独成一组 `id=priority`,固定第一;
312
+ * 它是**叠加视图**而不是筛选——入选条目**仍保留在各自的工作流分组里**(优先组只是
313
+ * 置顶入口,方便先看要紧的几条)。因此 `Σcount` 会大于 `totalCount`,
314
+ * **`totalCount` 一律按去重后的条数计算,不做分组求和**;
315
+ * - 排序:优先组固定第一;其余**跟随源数据**——`getTodoListData` 返回里 `workflowName`
316
+ * 的首次出现顺序,不按条数、不按名称重排;
317
+ * 用户提示词里有明确顺序意图时,由模型把期望顺序写进 `groupOrder`,CLI 只按它重排;
318
+ * - `topN` **只裁剪 markdown**,`wfIdGroup` 永远全量——下一步要按组遍历,不能因展示折叠丢数据。
319
+ */
320
+ export function runGroup(args) {
321
+ assertKnownKeys(args);
322
+ const { rawCount, rows, duplicateCount, unreadableCount } = readItems(args.items);
323
+ const groupBy = readGroupBy(args.groupBy);
324
+ const topN = readTopN(args.topN);
325
+ // 优先组是叠加视图:筛出的条目**不从**工作流分组剔除,两个视图各自完整。
326
+ const priority = selectPriorityRows(rows);
327
+ const priorityRows = sortPriorityRows(priority.rows);
328
+ const restRows = rows;
329
+ const buckets = new Map();
330
+ for (const row of restRows) {
331
+ const key = groupKeyOf(row, groupBy);
332
+ let bucket = buckets.get(key);
333
+ if (!bucket) {
334
+ // 目标用 workflowId 当分组 id;其它维度没有业务 id,直接用键值。
335
+ const id = groupBy === 'workflow' ? (row.workflowId || `name:${key}`) : key;
336
+ bucket = { id, title: key, rows: [], ids: [] };
337
+ buckets.set(key, bucket);
338
+ }
339
+ if (groupBy === 'workflow' && row.workflowId && !bucket.ids.includes(row.workflowId)) {
340
+ bucket.ids.push(row.workflowId);
341
+ }
342
+ bucket.rows.push(row);
343
+ }
344
+ // 顺序跟随源数据——`getTodoListData` 返回里 `workflowName`
345
+ // 首次出现的顺序,即 `buckets` 的插入顺序。不做条数降序、不做名称排序:
346
+ // 清单顺序应当和人在待办列表里看到的顺序一致,重排会让「同一批数据两次跑出不同位置」。
347
+ // 有明确顺序意图时由 `groupOrder` 显式给出,这里只做重排。
348
+ const ordered = orderGroupsByIntent([...buckets.values()], readGroupOrder(args));
349
+ const workflowGroups = ordered.groups.map((bucket) => ({
350
+ id: bucket.id,
351
+ topPriority: false,
352
+ title: bucket.title,
353
+ count: bucket.rows.length,
354
+ requestDatas: bucket.rows.map((row) => toRequestData(row)),
355
+ stats: statsOf(bucket.rows),
356
+ ...(bucket.ids.length > 1 ? { workflowIds: bucket.ids } : {}),
357
+ }));
358
+ const groups = priorityRows.length > 0 && groupBy === 'workflow'
359
+ ? [{
360
+ id: PRIORITY_GROUP_ID,
361
+ topPriority: true,
362
+ title: PRIORITY_GROUP_TITLE,
363
+ count: priorityRows.length,
364
+ requestDatas: priorityRows.map((row) => toRequestData(row)),
365
+ stats: statsOf(priorityRows),
366
+ }, ...workflowGroups]
367
+ : workflowGroups;
368
+ const displayedWorkflow = workflowGroups.slice(0, topN);
369
+ const displayed = priorityRows.length > 0 && groupBy === 'workflow'
370
+ ? [groups[0], ...displayedWorkflow]
371
+ : displayedWorkflow;
372
+ const rest = workflowGroups.slice(topN);
373
+ const restCount = rest.reduce((sum, group) => sum + group.count, 0);
374
+ const overall = statsOf(rows);
375
+ const warnings = [];
376
+ if (duplicateCount > 0) {
377
+ warnings.push(`已按 requestId 去重:原始 ${rawCount} 条,唯一 ${rows.length} 条,重复 ${duplicateCount} 条`);
378
+ }
379
+ if (unreadableCount > 0) {
380
+ warnings.push(`有 ${unreadableCount} 条不是对象结构,已跳过`);
381
+ }
382
+ const unknownGroupCount = buckets.get(UNKNOWN_GROUP)?.rows.length ?? 0;
383
+ if (unknownGroupCount > 0) {
384
+ warnings.push(groupBy === 'workflow'
385
+ ? `有 ${unknownGroupCount} 条缺少工作流名称,已归入「${UNKNOWN_GROUP}」`
386
+ : `有 ${unknownGroupCount} 条缺少分组字段,已归入「${UNKNOWN_GROUP}」`);
387
+ }
388
+ if (priorityRows.length > 0 && groupBy === 'workflow') {
389
+ const levelText = priority.targetLevel === 2
390
+ ? '紧急(requestLevel=2)'
391
+ : priority.targetLevel === 1
392
+ ? '重要(requestLevel=1;本次无 2 级数据,已按规则降级取 1,0 级不取)'
393
+ : '无等级(本次整批没有 ≥1 级的条目,只按超时筛)';
394
+ warnings.push(`「${PRIORITY_GROUP_TITLE}」筛出 ${priorityRows.length} 条:超时 ${priority.overdueCount} 条 / ` +
395
+ `等级命中 ${priority.levelCount} 条(两者都满足 ${priority.bothCount} 条),等级口径=${levelText};` +
396
+ `这 ${priorityRows.length} 条同时保留在各自工作流分组中(叠加视图:Σcount 会大于 totalCount,` +
397
+ `totalCount 按去重条数计 = ${rows.length})`);
398
+ }
399
+ else if (priorityRows.length === 0 && groupBy === 'workflow') {
400
+ warnings.push(`本次没有超时条目,也没有 requestLevel≥1 的条目,故不输出「${PRIORITY_GROUP_TITLE}」分组`);
401
+ }
402
+ if (rest.length > 0) {
403
+ warnings.push(`markdown 只展示前 ${topN} 个工作流分组,其余 ${rest.length} 组共 ${restCount} 条仍在 wfIdGroup 中(未截断)`);
404
+ }
405
+ if (ordered.applied > 0) {
406
+ warnings.push(`已按 groupOrder 重排分组顺序(命中 ${ordered.applied} 组);未提到的组按接口返回的 workflowName 顺序排在后面`);
407
+ }
408
+ if (ordered.unknown.length > 0) {
409
+ warnings.push(`groupOrder 里有 ${ordered.unknown.length} 项没有匹配到任何分组(${ordered.unknown.join('、')}):`
410
+ + '请用 workflowName 或分组 id;这些项已忽略,其余顺序照常');
411
+ }
412
+ // workflowId : workflowName 是**多对一**(工作流有多版本),所以同名必须合并、不能按 id 拆开。
413
+ const multiVersionGroups = workflowGroups.filter((group) => (group.workflowIds?.length ?? 0) > 1);
414
+ if (multiVersionGroups.length > 0) {
415
+ const detail = multiVersionGroups
416
+ .map((group) => `${group.title}(${group.workflowIds.length} 个版本)`)
417
+ .join('、');
418
+ warnings.push(`检测到同一工作流名称对应多个 workflowId:${detail};已按 workflowName 合并为一组,` +
419
+ `id 取第一个版本,完整版本列表见该组 workflowIds`);
420
+ }
421
+ return {
422
+ stage: 'group',
423
+ groupBy,
424
+ smartSummaryTitle: `您当前有 ${rows.length} 条待办,清单如下:`,
425
+ totalInput: rawCount,
426
+ total: rows.length,
427
+ duplicateCount,
428
+ groupCount: groups.length,
429
+ totals: {
430
+ totalCount: rows.length,
431
+ overdueCount: overall.overdueCount,
432
+ urgentCount: overall.urgentCount,
433
+ },
434
+ wfIdGroup: groups,
435
+ /** 第二部分(非优先组)的总条数与组数,前端折叠/分页直接用它,不用自己减 */
436
+ restTotal: restRows.length,
437
+ restGroupCount: workflowGroups.length,
438
+ restGroups: rest.length > 0 ? { groupCount: rest.length, count: restCount } : null,
439
+ warnings,
440
+ markdown: renderGroupMarkdown({
441
+ groupBy,
442
+ total: rows.length,
443
+ groups: displayed,
444
+ restGroupCount: rest.length,
445
+ restCount,
446
+ }),
447
+ };
448
+ }