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,502 @@
1
+ import { workflowValidationError } from '../../errors.js';
2
+ import { BUSINESS_KIND_LABEL, detectBusinessKind, FINANCE_AMOUNT_THRESHOLD, FINANCE_GROUP_TITLE, formatFinanceGroupSummary, formatIssueSupportGroupSummary, formatLeaveGroupSummary, isDeterministicGroupKind, ISSUE_SUPPORT_GROUP_TITLE, LEAVE_DAY_THRESHOLD, LEAVE_GROUP_TITLE, normalizeCreators, } from './businessRules.js';
3
+ import { asRecord, readBoundedInt, } from './todoSmartConvert.core.js';
4
+ import { focusAppliesToKind, isExpiringTodayRow, isExpiredUserOverTime, PRIORITY_GROUP_ID, PRIORITY_GROUP_TITLE, runGroup, GROUP_MIN_ITEMS_FOR_SUBGROUP, } from './todoSmartConvert.group.js';
5
+ import { fetchDetailFacts, stampsOfRows } from './todoSmartConvert.detail.js';
6
+ import { openSummaryCache } from './summaryCache.js';
7
+ const DEFAULT_REQUEST_SUMMARY_MAX = 50;
8
+ /** 把一批条目包成「单独展示」项(优先组、以及普通组里没同类可合并的条目都用它)。 */
9
+ export function requestItemsOf(rows) {
10
+ return rows.map((row) => ({ type: 'request', requestData: row }));
11
+ }
12
+ /** 一个分组下的所有条目(按 `displayItems` 顺序摊平)。 */
13
+ export function rowsOfGroup(group) {
14
+ return group.displayItems.flatMap((item) => (item.type === 'group' ? item.requestDatas : [item.requestData]));
15
+ }
16
+ /** 一个分组下的汇总项(`type:"group"`)。 */
17
+ export function groupItemsOf(group) {
18
+ return group.displayItems.filter((item) => item.type === 'group');
19
+ }
20
+ /**
21
+ * 校验并索引 `aiGroups`。
22
+ *
23
+ * 入参形态(扁平数组,比「按组嵌套的对象」更省 token、也更好报错定位):
24
+ * ```json
25
+ * [ { "groupId": "1003655203465764898", "title": "<AI 起的组名>", "requestIds": ["1326409832331878420"] } ]
26
+ * ```
27
+ * `groupId` 就是 `stage=group` 输出里 `wfIdGroup[i].id`。
28
+ * **id 一律按字符串处理**:真实 workflowId / requestId 是 19 位,用数字存会末位失真。
29
+ */
30
+ export function readAiGroups(value) {
31
+ if (!Array.isArray(value)) {
32
+ throw workflowValidationError('ai_groups_invalid', "aiGroups 必须是数组,每项形如 { groupId, title, requestIds };groupId 取 stage=group 输出里的 wfIdGroup[i].id", { retryAllowed: false });
33
+ }
34
+ const map = new Map();
35
+ value.forEach((raw, index) => {
36
+ const label = `aiGroups[${index}]`;
37
+ const record = asRecord(raw);
38
+ if (!record) {
39
+ throw workflowValidationError('ai_groups_invalid', `${label} 必须是对象 { groupId, title, requestIds }`, { retryAllowed: false });
40
+ }
41
+ const unknownKeys = Object.keys(record).filter((key) => !['groupId', 'title', 'requestIds', 'seq'].includes(key));
42
+ if (unknownKeys.length > 0) {
43
+ throw workflowValidationError('ai_groups_invalid', `${label} 不接受字段:${unknownKeys.join(', ')};只支持 groupId / title / requestIds / seq`, { retryAllowed: false, details: { unknownKeys } });
44
+ }
45
+ const groupId = typeof record.groupId === 'string' ? record.groupId.trim() : '';
46
+ if (!groupId) {
47
+ throw workflowValidationError('ai_groups_invalid', `${label}.groupId 必填且必须是字符串(19 位 workflowId 用数字会失真;值取 stage=group 的 wfIdGroup[i].id)`, { retryAllowed: false });
48
+ }
49
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
50
+ if (!title) {
51
+ throw workflowValidationError('ai_groups_invalid', `${label}.title 必填:这是 AI 给该子分组起的名字,结构里要原样输出`, { retryAllowed: false });
52
+ }
53
+ const hasIds = Array.isArray(record.requestIds) && record.requestIds.length > 0;
54
+ const hasSeq = Array.isArray(record.seq) && record.seq.length > 0;
55
+ if (hasIds && hasSeq) {
56
+ throw workflowValidationError('ai_groups_invalid', `${label} 不能同时给 requestIds 与 seq:用 seq(组内序号,推荐)或 requestIds(显式 ID)二选一`, { retryAllowed: false });
57
+ }
58
+ if (!hasIds && !hasSeq) {
59
+ throw workflowValidationError('ai_groups_invalid', `${label} 需要 requestIds 或 seq(组内 1-based 序号,见 collect 的 groupingBrief)`, { retryAllowed: false });
60
+ }
61
+ const seqNumbers = [];
62
+ if (hasSeq) {
63
+ for (const item of record.seq) {
64
+ const n = typeof item === 'number' ? item : Number(item);
65
+ if (!Number.isInteger(n) || n < 1) {
66
+ throw workflowValidationError('ai_groups_invalid', `${label}.seq 只接受 ≥1 的整数序号,收到 ${String(item)}`, { retryAllowed: false });
67
+ }
68
+ seqNumbers.push(n);
69
+ }
70
+ }
71
+ const ids = [];
72
+ for (const item of (hasIds ? record.requestIds : [])) {
73
+ const id = typeof item === 'string' ? item.trim() : typeof item === 'number' ? String(item) : '';
74
+ if (!id) {
75
+ throw workflowValidationError('ai_groups_invalid', `${label}.requestIds 只接受非空的 requestId 字符串`, { retryAllowed: false });
76
+ }
77
+ ids.push(id);
78
+ }
79
+ const bucket = map.get(groupId) ?? [];
80
+ bucket.push({ groupId, title, requestIds: [...new Set(ids)], seq: [...new Set(seqNumbers)] });
81
+ map.set(groupId, bucket);
82
+ });
83
+ return map;
84
+ }
85
+ /** 从详情事实里抽 metrics(确定性分组用)。 */
86
+ export function metricsOfFacts(facts) {
87
+ const metrics = new Map();
88
+ for (const [requestId, fact] of facts)
89
+ metrics.set(requestId, fact.metrics);
90
+ return metrics;
91
+ }
92
+ /** 从详情事实里造「空详情」判断器。 */
93
+ export function ctxEmptyOf(facts) {
94
+ return (requestId) => {
95
+ const fact = facts.get(requestId);
96
+ return fact !== undefined && !fact.contextAvailable;
97
+ };
98
+ }
99
+ /**
100
+ * 一条待办的业务指标:**只来自流程详情数据**(Q3:去掉标题兜底)。
101
+ * 取不到就是空对象,调用方据此把该条平铺、不做 ai二次分组。
102
+ */
103
+ function rowMetricsOf(row, metrics) {
104
+ return metrics?.get(row.requestId) ?? {};
105
+ }
106
+ /**
107
+ * 确定性路径的二次分组汇总项使用固定组摘要;请假/财务保留各自专用模板,其他类型使用标题/发起人模板。
108
+ *
109
+ * 金额算不出来时(表单没有金额字段、标题里也没有 `X元`)退回「标题清单」模板,
110
+ * 而不是编一个 `¥0 ~ ¥0` —— 半截统计比不给更糟。
111
+ */
112
+ function deterministicGroupSummary(kind, rows, metrics, restCount = 0) {
113
+ if (!isDeterministicGroupKind(kind))
114
+ return null;
115
+ const creators = normalizeCreators(rows.map((row) => row.createrName));
116
+ // 客户问题支持类:固定「N 条已过期或今日到期 + 其余 M 条单条」口径,条数由 CLI 算。
117
+ if (kind === 'issue-support') {
118
+ return formatIssueSupportGroupSummary({ count: rows.length, restCount, creators });
119
+ }
120
+ if (kind === 'leave') {
121
+ const items = rows.map((row) => rowMetricsOf(row, metrics));
122
+ return formatLeaveGroupSummary({
123
+ count: rows.length,
124
+ leaveTypes: items.map((item) => item.leaveType),
125
+ creators,
126
+ });
127
+ }
128
+ if (kind === 'finance') {
129
+ const items = rows.map((row) => rowMetricsOf(row, metrics));
130
+ const amounts = items
131
+ .map((item) => item.amount)
132
+ .filter((value) => typeof value === 'number' && value > 0);
133
+ // 防御:能进阈值组的前提就是「金额 ≤100」,所以这里必然有金额;
134
+ // 万一真的一个都没有,留空交 AI 写,绝不编一个 ¥0 ~ ¥0。
135
+ if (amounts.length === 0)
136
+ return null;
137
+ const sum = Number(amounts.reduce((total, value) => total + value, 0).toFixed(2));
138
+ return formatFinanceGroupSummary({
139
+ min: Math.min(...amounts),
140
+ max: Math.max(...amounts),
141
+ sum,
142
+ categories: items.map((item) => item.feeCategory),
143
+ creators,
144
+ });
145
+ }
146
+ // 其他类不再产汇总项(isDeterministicGroupKind 已排除),走到这里直接返回 null。
147
+ return null;
148
+ }
149
+ /**
150
+ * 最终结构里的 `totalCount`:**去重后的待办条数**(= collect 阶段取到的全部条数)。
151
+ *
152
+ * 为什么**不能**用 Σ `wfIdGroup[].count`:优先组是叠加视图,同一条待办会同时出现在
153
+ * 「建议优先处理的流程」和它的工作流分组里,按组求和会把这类条目算两遍。
154
+ * 这里按 `requestId` 去重后计数,与 collect 的 `fetched` 同源同值。
155
+ *
156
+ * 兜底:万一结构里没有 `displayItems`(理论上不会,`readFinalGroups` 已归一),
157
+ * 才退回按 `count` 求和,避免返回 0。
158
+ */
159
+ export function totalTodoCount(groups) {
160
+ const seen = new Set();
161
+ let sawRow = false;
162
+ for (const group of groups) {
163
+ for (const item of group.displayItems ?? []) {
164
+ const rows = item.type === 'group' ? (item.requestDatas ?? []) : [item.requestData];
165
+ for (const row of rows) {
166
+ sawRow = true;
167
+ seen.add(rowKeyForCount(row));
168
+ }
169
+ }
170
+ }
171
+ if (!sawRow)
172
+ return groups.reduce((sum, group) => sum + (Number(group.count) || 0), 0);
173
+ return seen.size;
174
+ }
175
+ /** 计数的去重键:优先 `requestId`;缺 id 时用「标题 + 发起人 + 节点」兜底。 */
176
+ function rowKeyForCount(row) {
177
+ if (!row)
178
+ return 'missing';
179
+ const id = String(row.requestId ?? '').trim();
180
+ if (id)
181
+ return `id:${id}`;
182
+ return `tx:${row.requestNameTitle ?? ''}|${row.creater ?? ''}|${row.nodeId ?? ''}`;
183
+ }
184
+ export async function runSmartGroup(args, env, options = {}) {
185
+ const base = runGroup(args);
186
+ const groups = (base.wfIdGroup ?? []);
187
+ /** `null` = 确定性模式;数组(可能为空)= 兼容模式 */
188
+ const plans = args.aiGroups === undefined || args.aiGroups === null
189
+ ? undefined
190
+ : readAiGroups(args.aiGroups);
191
+ // 注意:`aiGroups: []` 与「未传」现在等价(默认不分组),不再需要用空数组区分语义。
192
+ // 有侧重点时分组 = 标题语义 + 侧重点;子组顺序沿用模型返回顺序。
193
+ // 无侧重点时普通组按条数降序,请假/财务使用阈值规则。
194
+ const groupFocus = typeof args.groupFocus === 'string' ? args.groupFocus.trim() : '';
195
+ const hasFocus = groupFocus.length > 0;
196
+ // 摘要字数口径:缓存命中要按口径过滤(10 字要求不能吃 50 字的旧摘要)。
197
+ const summaryMaxChars = readBoundedInt(args.summaryMaxChars, "summaryMaxChars", DEFAULT_REQUEST_SUMMARY_MAX, 10, 500);
198
+ // 详情用来算阈值指标 + 判定空上下文;摘要缓存只省 AI,不省详情(阈值指标只认流程详情数据)。
199
+ // withDetailContext=false 时只生成结构、不获取详情(此时没有指标,阈值规则自然不生效)。
200
+ const skipDetail = args.withDetailContext === false;
201
+ const summaryCache = await openSummaryCache(env.ctx);
202
+ const cachedIds = new Set();
203
+ const allRows = [];
204
+ for (const group of groups) {
205
+ for (const row of group.requestDatas ?? []) {
206
+ allRows.push(row);
207
+ if (summaryCache.get(row.requestId, row.lastFeedBackDateTime, summaryMaxChars))
208
+ cachedIds.add(row.requestId);
209
+ }
210
+ }
211
+ // 详情缓存已经按 `requestId__lastFeedBackDateTime` 落盘,命中时零请求;
212
+ // 所以「摘要命中」不再等于「可以不拉详情」——否则阈值分组会因为没有指标而失效(回归 10.2)。
213
+ const detailIds = [...new Set(allRows.map((row) => row.requestId).filter(Boolean))];
214
+ // stamps 决定详情缓存的键:不传就永远 miss(缓存白建)。
215
+ const stamps = stampsOfRows(allRows);
216
+ const facts = skipDetail
217
+ ? new Map()
218
+ : (options.facts ?? (await fetchDetailFacts(env, detailIds, args, stamps)).facts);
219
+ const metrics = skipDetail ? undefined : (options.metrics ?? metricsOfFacts(facts));
220
+ const knownGroupIds = new Set(groups.map((group) => group.id));
221
+ for (const groupId of plans?.keys() ?? []) {
222
+ if (!knownGroupIds.has(groupId)) {
223
+ throw workflowValidationError('ai_group_unknown_group', `aiGroups 里的 groupId「${groupId}」不在本次分组结果中;可用的 groupId:${[...knownGroupIds].join(', ')}`, { retryAllowed: true, details: { availableGroupIds: [...knownGroupIds] } });
224
+ }
225
+ if (groupId === PRIORITY_GROUP_ID) {
226
+ throw workflowValidationError('ai_group_priority_unsupported', `「${PRIORITY_GROUP_TITLE}」不做二次分组:目标结构里它只有 displayItems(全是单条展示),没有汇总项;请把它的条目从 aiGroups 里去掉`, { retryAllowed: true });
227
+ }
228
+ }
229
+ // markdown 是中间态的东西,最终结构里没有,相关提示一并丢掉,免得误导调用方。
230
+ const warnings = (base.warnings ?? [])
231
+ .filter((text) => !text.startsWith('markdown 只展示前'));
232
+ if (skipDetail) {
233
+ warnings.push('withDetailContext=false:本次未拉取任何详情——请假/财务阈值改为按标题识别、跳过整组空判定,也不产 summaryBatches');
234
+ }
235
+ else if (cachedIds.size > 0) {
236
+ warnings.push(`有 ${cachedIds.size} 条命中本地摘要缓存(这些条目不用再调 AI);`
237
+ + '详情仍按缓存优先取一次——阈值指标只认流程详情数据,跳过详情会让阈值分组失效');
238
+ }
239
+ const finalGroups = groups.map((group) => {
240
+ if (group.id === PRIORITY_GROUP_ID) {
241
+ return {
242
+ id: group.id,
243
+ topPriority: true,
244
+ title: group.title,
245
+ count: group.count,
246
+ // 只有优先组默认展开,其余分组一律收缩。
247
+ defaultExpanded: true,
248
+ displayItems: requestItemsOf(group.requestDatas),
249
+ };
250
+ }
251
+ const byRequestId = new Map(group.requestDatas.map((item) => [item.requestId, item]));
252
+ const used = new Set();
253
+ const subGroups = [];
254
+ /**
255
+ * 「平铺」= 没有 ai二次分组:条目作为 `displayItems[]` 的 `type:"request"` 单条。
256
+ * 注意:不存在 `other` 兜底汇总项(2026-10-26 用户定稿),漏分 / 子组解散一律落到这里。
257
+ */
258
+ const singles = [];
259
+ const kind = detectBusinessKind(group.title);
260
+ const rawPlans = plans?.get(group.id) ?? [];
261
+ /** 意图是否适用于本组(Q15 专属/通用意图表):不适用 = 按「没有意图」处理。 */
262
+ const groupHasFocus = hasFocus && focusAppliesToKind(groupFocus, kind);
263
+ /**
264
+ * aiGroups 什么时候被采纳:
265
+ * - 意图适用于本组:只有本组有计划才分;没给计划 = 视为无意图,退回确定性规则(Q8)。
266
+ * - 内部留言:有没有意图都采纳(无意图按流程标题分、有意图按详情+意图分,素材由 collect 准备)。
267
+ * - 其余类型:意图不适用就一律忽略(Q9)。
268
+ */
269
+ const plansAllowed = groupHasFocus || kind === 'internal-message';
270
+ const groupPlans = plansAllowed ? rawPlans : [];
271
+ if (rawPlans.length > 0 && !plansAllowed) {
272
+ // 没有意图时不要说成「意图不适用于本类型」——那是误导,真实原因是本类型默认不做二次分组。
273
+ warnings.push(hasFocus
274
+ ? `「${group.title}」意图「${groupFocus}」不适用于本类型,已忽略 aiGroups,按无意图规则处理`
275
+ : `「${group.title}」${BUSINESS_KIND_LABEL[kind]}默认不按标题二次分组,已忽略 aiGroups(要按侧重点分组请在 groupFocus 里说明)`);
276
+ }
277
+ /** 该组整体平铺(不进入 ai二次分组)。 */
278
+ const flatReturn = () => ({
279
+ id: group.id,
280
+ topPriority: false,
281
+ title: group.title,
282
+ count: group.count,
283
+ defaultExpanded: false,
284
+ displayItems: requestItemsOf(group.requestDatas),
285
+ });
286
+ // D0 满意度类:不做 ai二次分组,也不与 AI 交互。
287
+ if (kind === 'satisfaction') {
288
+ if (rawPlans.length > 0)
289
+ warnings.push(`「${group.title}」满意度类不做 ai二次分组,已忽略 aiGroups`);
290
+ warnings.push(`「${group.title}」属于满意度类,全部平铺单条;summary 由标题兜底(不调 AI)`);
291
+ return flatReturn();
292
+ }
293
+ // D1 硬门槛:组内 <3 条一律平铺(即使给了 aiGroups 也忽略,不与 AI 交互)。
294
+ if (group.requestDatas.length < GROUP_MIN_ITEMS_FOR_SUBGROUP) {
295
+ if (rawPlans.length > 0)
296
+ warnings.push(`「${group.title}」不足 3 条(硬门槛),忽略 aiGroups 并平铺`);
297
+ return flatReturn();
298
+ }
299
+ // D2 意图适用但没给本组计划 = 视为没有意图(Q8:按阈值组/过期组等确定性规则)。
300
+ // 但**内部留言例外**:它 ≥3 条时没有「平铺」这个第三态(见下面的强制校验)。
301
+ if (groupHasFocus && groupPlans.length === 0 && kind !== 'internal-message') {
302
+ warnings.push(`「${group.title}」意图适用但模型未给本组计划,按无意图处理`);
303
+ }
304
+ /**
305
+ * 内部留言(Q1/Q16,2026-10-27 用户明确):**≥3 条必然做 ai二次分组**,没有「平铺」这个第三态。
306
+ *
307
+ * 只有两种维度,且都必须由模型给出计划(CLI 不替模型分组):
308
+ * - 侧重点适用于内部留言 ⇒ 按「侧重点 + 流程详情数据」分组;
309
+ * - 没有侧重点,或侧重点不适用 ⇒ **按流程标题分组**。
310
+ *
311
+ * 「没给计划」不是合法输入:静默平铺会把 200+ 条留言堆成一片(五年那批实测 223 条全平铺),
312
+ * 而且 warning 会被误读成「技术上无法分组」。这里直接要求补计划,保持规则单一确定性。
313
+ *
314
+ * 注意:门槛在前面 D1 已经判过(<3 条直接平铺),所以走到这里一定 ≥3 条。
315
+ */
316
+ if (kind === 'internal-message' && groupPlans.length === 0) {
317
+ throw workflowValidationError('ai_groups_required', `「${group.title}」属于内部留言且不少于 ${GROUP_MIN_ITEMS_FOR_SUBGROUP} 条,必须给本组 aiGroups:`
318
+ + (groupHasFocus
319
+ ? `用户侧重点「${groupFocus}」适用于内部留言,请按侧重点结合流程详情数据分组`
320
+ : '当前没有适用于内部留言的侧重点,请**按流程标题**分组(同主题的通知/公告归到同一子组)')
321
+ + `;可用 groupId:${group.id},组内序号 1..${group.requestDatas.length}`, {
322
+ retryAllowed: true,
323
+ details: { groupId: group.id, count: group.requestDatas.length, groupFocus: groupHasFocus ? groupFocus : '' },
324
+ });
325
+ }
326
+ if (groupPlans.length === 0) {
327
+ // 无意图(或退回无意图):只有四类确定性规则,其余一律平铺。
328
+ if (kind === 'leave' || kind === 'finance') {
329
+ // 请假/财务:最多一个固定组;指标**只来自流程详情数据**,取不到的条目单独平铺(Q3/Q11)。
330
+ const eligible = [];
331
+ let unknown = 0;
332
+ for (const row of group.requestDatas) {
333
+ const metric = rowMetricsOf(row, metrics);
334
+ const value = kind === 'leave' ? metric.leaveDays : metric.amount;
335
+ const hit = kind === 'leave'
336
+ ? (typeof value === 'number' && value <= LEAVE_DAY_THRESHOLD)
337
+ : (typeof value === 'number' && value > 0 && value <= FINANCE_AMOUNT_THRESHOLD);
338
+ if (hit)
339
+ eligible.push(row);
340
+ else {
341
+ singles.push(row);
342
+ if (typeof value !== 'number')
343
+ unknown += 1;
344
+ }
345
+ }
346
+ if (eligible.length > 0) {
347
+ subGroups.push({
348
+ title: kind === 'leave' ? LEAVE_GROUP_TITLE : FINANCE_GROUP_TITLE,
349
+ requestDatas: eligible,
350
+ });
351
+ warnings.push(`「${group.title}」按${kind === 'leave' ? '请假类 1天以内' : '财务类 100元以内'}确定性汇总,其余条目平铺`);
352
+ }
353
+ if (unknown > 0) {
354
+ warnings.push(`「${group.title}」有 ${unknown} 条流程详情数据取不到${kind === 'leave' ? '请假天数' : '报销金额'},不做 ai二次分组、按单条平铺`);
355
+ }
356
+ }
357
+ else if (kind === 'issue-support') {
358
+ // 问题支持类:判据是**原始列表数据**的超时字段 userOverTime(Q4);无命中则整组平铺。
359
+ const urgentRows = [];
360
+ const rest = [];
361
+ for (const row of group.requestDatas) {
362
+ if (isExpiredUserOverTime(row.userOverTime) || isExpiringTodayRow(row))
363
+ urgentRows.push(row);
364
+ else
365
+ rest.push(row);
366
+ }
367
+ singles.push(...rest);
368
+ if (urgentRows.length > 0) {
369
+ subGroups.push({ title: ISSUE_SUPPORT_GROUP_TITLE, requestDatas: urgentRows });
370
+ warnings.push(`「${group.title}」按客户问题支持类口径:${urgentRows.length} 条已过期或今日到期,其余 ${rest.length} 条平铺`);
371
+ }
372
+ else {
373
+ warnings.push(`「${group.title}」没有已过期或今日到期的条目(userOverTime 未命中),全部平铺`);
374
+ }
375
+ }
376
+ else {
377
+ singles.push(...group.requestDatas);
378
+ warnings.push(`「${group.title}」默认不做 ai二次分组,${group.requestDatas.length} 条平铺`);
379
+ }
380
+ }
381
+ else {
382
+ // 采纳 aiGroups:按计划切,之后统一做「子组 <2 解散」与「漏分平铺」。
383
+ /**
384
+ * 保留标题(`1天以内` / `100元以内`)是 **CLI 自己造的阈值组名**,带「≤阈值」的语义。
385
+ * 模型若拿它当子组名,CLI 必须逐条核阈值——否则 2000 元的报销会出现在「100元以内」里。
386
+ * 超阈值的条目剔出、转单条展示(一条都不丢),并点名。
387
+ */
388
+ const reservedTitle = kind === 'leave'
389
+ ? LEAVE_GROUP_TITLE
390
+ : kind === 'finance' ? FINANCE_GROUP_TITLE : '';
391
+ const droppedByThreshold = [];
392
+ for (const plan of groupPlans) {
393
+ const datas = [];
394
+ // `seq`(组内 1-based 序号)→ requestId:映射口径与 `groupingBrief[].items[].idx` 一致。
395
+ const planRequestIds = plan.requestIds.length > 0
396
+ ? plan.requestIds
397
+ : plan.seq.map((n) => {
398
+ const row = group.requestDatas[n - 1];
399
+ if (!row) {
400
+ throw workflowValidationError('ai_group_seq_out_of_range', `aiGroups 给「${group.title}」的 seq 里有 ${n},但该组只有 ${group.requestDatas.length} 条(序号从 1 开始)`, { retryAllowed: true, details: { groupId: group.id, seq: n, count: group.requestDatas.length } });
401
+ }
402
+ return row.requestId;
403
+ });
404
+ for (const requestId of planRequestIds) {
405
+ const data = byRequestId.get(requestId);
406
+ if (!data) {
407
+ throw workflowValidationError('ai_group_unknown_request', `aiGroups 把 requestId「${requestId}」分到了「${group.title}」,但该组没有这条数据(可能组 id 串了,或 id 不是字符串)`, { retryAllowed: true, details: { groupId: group.id, requestId } });
408
+ }
409
+ if (used.has(requestId)) {
410
+ throw workflowValidationError('ai_group_duplicate_request', `requestId「${requestId}」在「${group.title}」里被分进了多个子组,每条数据只能属于一个汇总项`, { retryAllowed: true, details: { groupId: group.id, requestId } });
411
+ }
412
+ used.add(requestId);
413
+ datas.push(data);
414
+ }
415
+ // 保留标题 ⇒ 逐条核阈值(超阈值的剔出,稍后平铺)
416
+ let groupRows = datas;
417
+ if (reservedTitle && plan.title === reservedTitle) {
418
+ const kept = [];
419
+ const dropped = [];
420
+ for (const row of datas) {
421
+ const metric = rowMetricsOf(row, metrics);
422
+ const value = kind === 'leave' ? metric.leaveDays : metric.amount;
423
+ const hit = kind === 'leave'
424
+ ? (typeof value === 'number' && value <= LEAVE_DAY_THRESHOLD)
425
+ : (typeof value === 'number' && value > 0 && value <= FINANCE_AMOUNT_THRESHOLD);
426
+ (hit ? kept : dropped).push(row);
427
+ }
428
+ if (dropped.length > 0) {
429
+ droppedByThreshold.push(...dropped);
430
+ warnings.push(`「${group.title}」的「${plan.title}」子组有 ${dropped.length} 条不满足阈值,已剔除并改为单条展示`);
431
+ }
432
+ groupRows = kept;
433
+ }
434
+ subGroups.push({ title: plan.title, requestDatas: groupRows });
435
+ }
436
+ // 子组 <2 不成立(含有意图的子组):解散,条目平铺(Q5)。
437
+ const kept = subGroups.filter((sub) => sub.requestDatas.length >= 2);
438
+ const dissolved = subGroups.length - kept.length;
439
+ if (dissolved > 0) {
440
+ for (const sub of subGroups) {
441
+ if (sub.requestDatas.length < 2)
442
+ singles.push(...sub.requestDatas);
443
+ }
444
+ subGroups.length = 0;
445
+ subGroups.push(...kept);
446
+ warnings.push(`「${group.title}」有 ${dissolved} 个子组不足 2 条,已解散并平铺这些条目`);
447
+ }
448
+ // 保留标题里被阈值剔除的条目:平铺,且不能再被算成「漏分」。
449
+ if (droppedByThreshold.length > 0)
450
+ singles.push(...droppedByThreshold);
451
+ // AI 漏分的条目:直接平铺,**不建 other 汇总项**(Q12)。
452
+ const uncovered = group.requestDatas.filter((item) => !used.has(item.requestId));
453
+ if (uncovered.length > 0) {
454
+ singles.push(...uncovered);
455
+ warnings.push(`「${group.title}」有 ${uncovered.length} 条未被 AI 分组,按单条平铺(不造 other 子组)`);
456
+ }
457
+ }
458
+ // 条数降序;sort 稳定,同条数保持 AI 给出的先后顺序。
459
+ // 有侧重点时沿用模型顺序;没有侧重点时按条数降序。
460
+ if (!groupHasFocus)
461
+ subGroups.sort((a, b) => b.requestDatas.length - a.requestDatas.length);
462
+ const groupItems = subGroups.map((sub, index) => ({
463
+ // 键顺序照新目标结构:type → id → title → count → summary → defaultExpanded → requestDatas
464
+ type: 'group',
465
+ id: `${group.id}-${index + 1}`,
466
+ title: sub.title,
467
+ count: sub.requestDatas.length,
468
+ // 组摘要口径不变(Q4):只有 CLI 自己造的三类固定组用固定文案;AI 子组一律留空交 groupSummary。
469
+ summary: ((kind === 'leave' && sub.title === LEAVE_GROUP_TITLE)
470
+ || (kind === 'finance' && sub.title === FINANCE_GROUP_TITLE)
471
+ || (kind === 'issue-support' && sub.title === ISSUE_SUPPORT_GROUP_TITLE))
472
+ ? (deterministicGroupSummary(kind, sub.requestDatas, metrics, singles.length) ?? '')
473
+ : '',
474
+ // 汇总项自身默认收缩(与大分组的展开状态互相独立)
475
+ defaultExpanded: false,
476
+ requestDatas: sub.requestDatas,
477
+ }));
478
+ return {
479
+ id: group.id,
480
+ topPriority: false,
481
+ title: group.title,
482
+ count: group.count,
483
+ defaultExpanded: false,
484
+ // 汇总项在前、单独条目在后。
485
+ displayItems: [...groupItems, ...requestItemsOf(singles)],
486
+ };
487
+ });
488
+ // 分组顺序完全沿用第一次 workflowName 分组顺序。
489
+ const orderedGroups = finalGroups;
490
+ if (plans === undefined) {
491
+ warnings.push('smartGroup 未收到 aiGroups:已按业务类型执行确定性分组;需要语义侧重点时再传入本轮 aiGroups');
492
+ }
493
+ return {
494
+ data: {
495
+ smartSummaryTitle: base.smartSummaryTitle,
496
+ totalCount: totalTodoCount(orderedGroups),
497
+ wfIdGroup: orderedGroups,
498
+ },
499
+ warnings,
500
+ facts,
501
+ };
502
+ }