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,267 @@
1
+ import { WORKFLOW_ENDPOINTS } from '../../endpoints.js';
2
+ import { saveSearchIndex } from '../../state.js';
3
+ import { buildSearchQuery } from '../read.js';
4
+ import { projectDisplayRows } from './todoListRows.js';
5
+ import { responseData } from '../shared.js';
6
+ import { openSummaryCache, SUMMARY_CACHE_FILE_NAME } from './summaryCache.js';
7
+ import { detectBusinessKind } from './businessRules.js';
8
+ import { fetchDetailFacts, stampsOfRows } from './todoSmartConvert.detail.js';
9
+ import { focusAppliesToKind, GROUP_MIN_ITEMS_FOR_SUBGROUP, isExpiringTodayRow, isOverdueRow, runGroup, } from './todoSmartConvert.group.js';
10
+ import { DEFAULT_TABID, FETCH_PAGE_SIZE, LIST_ENDPOINT_BY_CATEGORY, MAX_FETCH_LIMIT, MAX_FETCH_PAGES, guardListOnlyHost, readBoundedInt, readCategory, readFetchLimit, readPageSize, rowFromAny, } from './todoSmartConvert.core.js';
11
+ const DEFAULT_REQUEST_SUMMARY_MAX = 50;
12
+ export async function collectRawTodos(args, env) {
13
+ const category = readCategory(args.category);
14
+ const { limit, cappedFrom } = readFetchLimit(args.limit);
15
+ const pageSize = readPageSize(limit);
16
+ // 条数 → 请求次数;按页取,不按条数精确裁剪。
17
+ const plannedPages = Math.max(1, Math.min(Math.ceil(limit / pageSize), MAX_FETCH_PAGES));
18
+ // 取数阶段只允许 getTodoListData,getTodo* 分页/统计接口一律拦掉。
19
+ const listEnv = guardListOnlyHost(env);
20
+ const searchArgs = { category };
21
+ for (const key of ['tabid', 'tabName', 'subCategory', 'workflow', 'creator', 'requestName', 'receiveTime', 'requestLevel']) {
22
+ const value = args[key];
23
+ if (value !== undefined && value !== null && value !== '')
24
+ searchArgs[key] = value;
25
+ }
26
+ // 显式给 tabid 才走平铺路径(见 DEFAULT_TABID 注释);用户另有子分类条件时原样尊重。
27
+ const explicitTab = ['tabid', 'tabName', 'subCategory'].some((key) => searchArgs[key] !== undefined && String(searchArgs[key]).trim() !== '');
28
+ if (!explicitTab)
29
+ searchArgs.tabid = DEFAULT_TABID;
30
+ const warnings = [];
31
+ if (cappedFrom !== undefined) {
32
+ // 超过上限时显式告知,避免调用方误以为已取全量。
33
+ warnings.push(`单次最多只能取 ${MAX_FETCH_LIMIT} 条(${MAX_FETCH_PAGES} 次请求 × ${FETCH_PAGE_SIZE} 条),`
34
+ + `你要求的 ${cappedFrom} 条已按上限 ${MAX_FETCH_LIMIT} 条处理;`
35
+ + '如需覆盖更多,请缩小筛选条件(如按工作流、时间范围分批)后再汇总');
36
+ }
37
+ const requestedPageSize = args.pageSize;
38
+ if (requestedPageSize !== undefined && requestedPageSize !== null && requestedPageSize !== ''
39
+ && Number(requestedPageSize) !== pageSize) {
40
+ warnings.push(`单页条数已按 limit 推导为 ${pageSize}(最多 ${MAX_FETCH_PAGES} 次请求),`
41
+ + `忽略传入的 pageSize=${String(requestedPageSize)}`);
42
+ }
43
+ const seen = new Set();
44
+ const items = [];
45
+ let duplicateCount = 0;
46
+ let totalAvailable = null;
47
+ let pages = 0;
48
+ let stoppedEarly = false;
49
+ const listPath = LIST_ENDPOINT_BY_CATEGORY[category] ?? WORKFLOW_ENDPOINTS.TODO_LIST;
50
+ for (let pageNo = 1; pageNo <= plannedPages; pageNo += 1) {
51
+ const { query } = await buildSearchQuery(listEnv, { ...searchArgs, current: pageNo, pageSize }, 'list');
52
+ const response = await listEnv.host.workflowRequest({
53
+ method: 'POST',
54
+ path: listPath,
55
+ operation: 'workflow.todoSmartConvert.list',
56
+ body: query,
57
+ });
58
+ pages += 1;
59
+ if (totalAvailable === null) {
60
+ // 列表接口的 total 字段不稳定(实测 `data.total` 可能是 0 或缺失)。取数按页驱动、不依赖总数,
61
+ // 所以这里**只做展示用**:拿不到正数就当未知,不让 0 冒充「共 0 条」。
62
+ const record = responseData(response);
63
+ const candidates = [record?.data?.total, record?.data?.totalCount, record?.total, record?.totalCount];
64
+ for (const candidate of candidates) {
65
+ const total = Number(candidate);
66
+ if (Number.isFinite(total) && total > 0) {
67
+ totalAvailable = total;
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ const pageRows = projectDisplayRows(response)
73
+ .map((row) => rowFromAny(row))
74
+ .filter((row) => row !== undefined);
75
+ if (pageRows.length === 0) {
76
+ stoppedEarly = true;
77
+ break;
78
+ }
79
+ let added = 0;
80
+ for (const row of pageRows) {
81
+ if (row.requestId) {
82
+ if (seen.has(row.requestId)) {
83
+ duplicateCount += 1;
84
+ continue;
85
+ }
86
+ seen.add(row.requestId);
87
+ }
88
+ items.push(row);
89
+ added += 1;
90
+ }
91
+ if (added === 0)
92
+ break;
93
+ // 累计到目标条数即停:`limit` 是阈值,不是「最多取一页」。
94
+ if (items.length >= limit)
95
+ break;
96
+ // 这一页不满 pageSize ⇒ 已经没有下一页,不必再请求。
97
+ if (pageRows.length < pageSize)
98
+ break;
99
+ }
100
+ // 刷新本地查询快照(本地写文件,不联网):批量写门禁以「最近一次查询结果」为准,
101
+ // 不写会让 todoSmartConvert 取到的 ID 在 workflow.batch* 那里被判成「不在快照里」。
102
+ await saveSearchIndex({
103
+ items: items.map((row, index) => ({
104
+ index: index + 1,
105
+ requestId: row.requestId,
106
+ requestName: row.requestNameTitle,
107
+ workflowId: row.workflowId,
108
+ workflowName: row.workflowName,
109
+ creator: row.createrName,
110
+ userMasterId: row.userMasterId,
111
+ isremark: row.isremark,
112
+ isRemark: row.isremark,
113
+ nodeid: row.nodeId,
114
+ nodeId: row.nodeId,
115
+ cardSummary: row.summary,
116
+ })),
117
+ summary: { type: 'todoSmartConvert', category, total: totalAvailable, pages },
118
+ }, env.ctx).catch(() => undefined);
119
+ if (duplicateCount > 0) {
120
+ warnings.push(`跨页重复 ${duplicateCount} 条,已按 requestId 去重`);
121
+ }
122
+ if (stoppedEarly && items.length === 0) {
123
+ warnings.push('列表接口没有返回任何明细,请确认筛选条件是否过窄');
124
+ }
125
+ if (totalAvailable !== null && items.length < totalAvailable) {
126
+ warnings.push(`共 ${totalAvailable} 条待办,本次按 ${pages} 次请求(每次最多 ${FETCH_PAGE_SIZE} 条)取到 ${items.length} 条;`
127
+ + `已到本次 limit=${limit} 的阈值——`
128
+ + '需要覆盖更多请调大 limit,或缩小筛选条件(工作流 / 时间范围)分批汇总');
129
+ }
130
+ const scope = searchArgs.tabid ? `tabid ${searchArgs.tabid}` : '';
131
+ const header = `已获取待办原始数据 ${items.length} 条`
132
+ + (totalAvailable !== null ? `(共 ${totalAvailable} 条)` : '');
133
+ // collect 同时生成第一次分组,并只读取摘要缓存;详情在后续阶段按需获取。
134
+ let wfIdGroup = [];
135
+ let groupingBrief = [];
136
+ let groupInfo = null;
137
+ let detailStats = null;
138
+ if (items.length > 0) {
139
+ const grouped = runGroup({
140
+ items,
141
+ ...(args.groupBy !== undefined ? { groupBy: args.groupBy } : {}),
142
+ ...(args.topN !== undefined ? { topN: args.topN } : {}),
143
+ });
144
+ wfIdGroup = (grouped.wfIdGroup ?? []);
145
+ // 摘要缓存 key 为 requestId 与最后反馈时间;反馈变化会自然失效。
146
+ const summaryCache = await openSummaryCache(env.ctx);
147
+ // 调用方指定摘要字数时不能命中其他字数口径的旧条目。
148
+ const collectSummaryMaxChars = readBoundedInt(args.summaryMaxChars, "summaryMaxChars", DEFAULT_REQUEST_SUMMARY_MAX, 10, 500);
149
+ const cachedSummaryIds = new Set();
150
+ for (const row of items) {
151
+ const cached = summaryCache.get(row.requestId, row.lastFeedBackDateTime, collectSummaryMaxChars);
152
+ if (!cached)
153
+ continue;
154
+ row.summary = cached;
155
+ cachedSummaryIds.add(row.requestId);
156
+ }
157
+ // 详情是流水线中间数据,smartGroup / summaryContext 需要时现场拉取(带缓存)。
158
+ // 例外:**有意图时**,ai二次分组要先看到表单字段,这里就把素材准备好(见下)。
159
+ detailStats = {
160
+ deferred: true,
161
+ note: '详情不预取:smartGroup / summaryContext 会在需要时现场拉取(详情缓存优先)',
162
+ summaryCached: cachedSummaryIds.size,
163
+ summaryCacheFile: summaryCache.file,
164
+ summaryCachePruned: summaryCache.pruned,
165
+ };
166
+ // 有意图时把**流程详情数据**作为 ai二次分组素材交给模型。
167
+ // 范围按意图表判定(专属意图只作用于请假/财务/问题支持,通用意图只作用于内部留言/其他),
168
+ // 走详情缓存(键 `requestId__lastFeedBackDateTime`),与后续摘要阶段共用同一份数据。
169
+ const focusText = typeof args.groupFocus === 'string' ? args.groupFocus.trim() : '';
170
+ const fieldsById = new Map();
171
+ if (focusText) {
172
+ const targetRows = wfIdGroup
173
+ .filter((group) => !group.topPriority && focusAppliesToKind(focusText, detectBusinessKind(group.title)))
174
+ .flatMap((group) => group.requestDatas ?? []);
175
+ const targetIds = [...new Set(targetRows.map((row) => row.requestId).filter(Boolean))];
176
+ if (targetIds.length > 0) {
177
+ const { facts, stats } = await fetchDetailFacts(env, targetIds, args, stampsOfRows(targetRows));
178
+ for (const [requestId, fact] of facts) {
179
+ if (fact.fields.length > 0)
180
+ fieldsById.set(requestId, fact.fields);
181
+ }
182
+ detailStats.groupingMaterial = {
183
+ requested: targetIds.length,
184
+ cacheHit: stats.cacheHit,
185
+ fetched: stats.fetched,
186
+ failed: stats.failed,
187
+ withFields: fieldsById.size,
188
+ };
189
+ }
190
+ }
191
+ // 给模型的紧凑视图使用组内序号,aiGroups 可用 seq 引用。
192
+ groupingBrief = wfIdGroup.map((group) => ({
193
+ groupId: group.id,
194
+ title: group.title,
195
+ count: group.count,
196
+ topPriority: group.topPriority,
197
+ items: (group.requestDatas ?? []).map((row, index) => ({
198
+ idx: index + 1,
199
+ requestId: row.requestId,
200
+ title: row.requestNameTitle ?? '',
201
+ // 客户问题支持类无侧重点时按这两个布尔分组(模型不用自己解析日期文案)
202
+ overdue: isOverdueRow(row),
203
+ expiringToday: isExpiringTodayRow(row),
204
+ ...(row.userOverTime ? { userOverTime: row.userOverTime } : {}),
205
+ ...(row.summary ? { summary: row.summary } : {}),
206
+ // `summaryCached:true` = 这条已有本地摘要(按 requestId__lastFeedBackDateTime 命中),
207
+ // 分组仍要做(聚类看标题),但**不要再给它写摘要**。
208
+ ...(cachedSummaryIds.has(row.requestId) ? { summaryCached: true } : {}),
209
+ ...(fieldsById.has(row.requestId) ? { fields: fieldsById.get(row.requestId) } : {}),
210
+ })),
211
+ }));
212
+ // 用户侧重点原样回显:分组/排序都按「标题语义 + 侧重点」,模型照它决定子组与顺序。
213
+ const groupingFocus = typeof args.groupFocus === 'string' ? args.groupFocus.trim() : '';
214
+ groupInfo = {
215
+ smartSummaryTitle: grouped.smartSummaryTitle,
216
+ groupBy: grouped.groupBy,
217
+ groupCount: grouped.groupCount,
218
+ total: grouped.total,
219
+ totals: grouped.totals,
220
+ restTotal: grouped.restTotal,
221
+ restGroupCount: grouped.restGroupCount,
222
+ warnings: grouped.warnings,
223
+ ...(groupingFocus ? { groupFocus: groupingFocus } : {}),
224
+ // 二次分组门槛:组内少于 3 条直接单条展示。
225
+ minItemsForSubgroup: GROUP_MIN_ITEMS_FOR_SUBGROUP,
226
+ };
227
+ if (cachedSummaryIds.size > 0) {
228
+ warnings.push(`有 ${cachedSummaryIds.size} 条命中本地摘要缓存(key=requestId__lastFeedBackDateTime),`
229
+ + '这些条目**不用再写摘要**,summary 已从缓存回填;本次也不会为它们发详情请求'
230
+ + `(缓存文件:${summaryCache.file ?? SUMMARY_CACHE_FILE_NAME})`);
231
+ }
232
+ if (!summaryCache.inSkillDir) {
233
+ warnings.push('没有找到本机 Agent 的 weaver-e10-workflow skill 目录,摘要缓存已退回 CLI 状态目录;'
234
+ + '如需缓存落在 skill 目录,可用 WEAVER_WORK_CLI_SKILL_DIR 指定');
235
+ }
236
+ }
237
+ return {
238
+ stage: 'collect',
239
+ query: {
240
+ category,
241
+ ...(scope ? { tabid: searchArgs.tabid } : {}),
242
+ /** 用户原始诉求;超过上限时保留原值,实际按上限取。 */
243
+ requestedLimit: cappedFrom ?? limit,
244
+ /** 计划请求次数(最多 5 页)。 */
245
+ plannedPages,
246
+ /** 实际发出的列表请求次数 */
247
+ pages,
248
+ /** 单页大小(limit ≤ 100 时等于 limit,否则固定 100) */
249
+ pageSize,
250
+ /** 计划能覆盖的条数上限 = 计划页数 × 单页(实际可能更少,取决于服务端还有没有数据) */
251
+ appliedLimit: plannedPages * pageSize,
252
+ totalAvailable,
253
+ /** 取数只用了这一个接口(照实写出来,便于核对「有没有偷偷调别的」) */
254
+ endpoint: listPath,
255
+ filters: Object.fromEntries(Object.entries(searchArgs).filter(([key]) => !['category', 'tabid'].includes(key))),
256
+ },
257
+ fetched: items.length,
258
+ items,
259
+ wfIdGroup,
260
+ groupingBrief,
261
+ groupInfo,
262
+ /** 详情状态;详情由后续阶段按需获取。 */
263
+ detailStats,
264
+ warnings,
265
+ markdown: header,
266
+ };
267
+ }
@@ -0,0 +1,392 @@
1
+ import { workflowValidationError } from '../../errors.js';
2
+ import { WORKFLOW_ENDPOINTS } from '../../endpoints.js';
3
+ import { workflowSuccess as envelopeSuccess } from '../types.js';
4
+ export const GROUP_BY_VALUES = ['workflow', 'urgency', 'date', 'none'];
5
+ export const UNKNOWN_GROUP = '未分类';
6
+ export const UNKNOWN_DATE = '未知日期';
7
+ export const DEFAULT_TOP_N = 10;
8
+ export const STAGE_VALUES = [
9
+ 'auto', 'collect', 'group', 'smartGroup',
10
+ 'summaryContext', 'summary', 'groupSummaryContext', 'groupSummary',
11
+ 'aggregate',
12
+ ];
13
+ export function asRecord(value) {
14
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
15
+ }
16
+ /** 统一去重告警,并同时写入 meta 与兼容的信封级字段。 */
17
+ export function workflowSuccess(action, data, meta, warnings) {
18
+ const list = [...new Set((warnings ?? []).filter((item) => typeof item === 'string' && item.trim() !== ''))];
19
+ const mergedMeta = list.length > 0 ? { ...(meta ?? {}), warnings: list } : meta;
20
+ const base = envelopeSuccess(action, data, mergedMeta);
21
+ return (list.length > 0 ? { ...base, warnings: list } : base);
22
+ }
23
+ /** 跨 CLI 回灌累计耗时;AI 耗时由 skill 在模型调用前后测量后传入。 */
24
+ export function buildSkillTiming(stage, input, startedAt) {
25
+ const value = input && typeof input === 'object' ? input : {};
26
+ const previous = Array.isArray(value.steps)
27
+ ? value.steps.filter((step) => step && typeof step.name === 'string'
28
+ && (step.kind === 'cli' || step.kind === 'ai' || step.kind === 'stage')
29
+ && Number.isFinite(step.durationMs) && step.durationMs >= 0)
30
+ .map((step) => ({ name: step.name, kind: step.kind, durationMs: Math.round(step.durationMs) }))
31
+ : [];
32
+ const cliDurationMs = Math.max(0, Date.now() - startedAt);
33
+ const aiDurationMs = Number.isFinite(value.aiDurationMs) && (value.aiDurationMs ?? 0) >= 0
34
+ ? Math.round(value.aiDurationMs)
35
+ : 0;
36
+ const steps = [
37
+ ...previous,
38
+ ...(aiDurationMs > 0 ? [{ name: stage, kind: 'ai', durationMs: aiDurationMs }] : []),
39
+ { name: stage, kind: 'cli', durationMs: cliDurationMs },
40
+ ];
41
+ const measuredSum = steps.reduce((sum, step) => sum + step.durationMs, 0);
42
+ const wallElapsedMs = typeof value.startedAt === 'string' ? Date.now() - Date.parse(value.startedAt) : NaN;
43
+ return {
44
+ startedAt: typeof value.startedAt === 'string' ? value.startedAt : new Date(startedAt).toISOString(),
45
+ finishedAt: new Date().toISOString(),
46
+ stage,
47
+ steps,
48
+ durationMs: Math.max(measuredSum, Number.isFinite(wallElapsedMs) && wallElapsedMs >= 0 ? wallElapsedMs : 0),
49
+ };
50
+ }
51
+ /** 金额可能是 "1,234.50"、"¥1234"、数字或空;只保留数字与小数点后重试,失败按「缺金额」处理。 */
52
+ function parseAmount(value) {
53
+ if (typeof value === 'number')
54
+ return Number.isFinite(value) ? value : undefined;
55
+ if (typeof value !== 'string')
56
+ return undefined;
57
+ const cleaned = value.replace(/[^\d.-]/gu, '');
58
+ if (!cleaned || cleaned === '-' || cleaned === '.')
59
+ return undefined;
60
+ const parsed = Number(cleaned);
61
+ return Number.isFinite(parsed) ? parsed : undefined;
62
+ }
63
+ function toDateKey(date) {
64
+ if (Number.isNaN(date.getTime()))
65
+ return UNKNOWN_DATE;
66
+ const year = date.getFullYear();
67
+ const month = String(date.getMonth() + 1).padStart(2, '0');
68
+ const day = String(date.getDate()).padStart(2, '0');
69
+ return `${year}-${month}-${day}`;
70
+ }
71
+ /** 时间表示不唯一:毫秒/秒时间戳、`YYYY-MM-DD`、`YYYY/MM/DD`;识别不了归「未知日期」,不猜。 */
72
+ function dateBucket(value) {
73
+ if (!value)
74
+ return UNKNOWN_DATE;
75
+ const text = value.trim();
76
+ if (/^\d{13}$/u.test(text))
77
+ return toDateKey(new Date(Number(text)));
78
+ if (/^\d{10}$/u.test(text))
79
+ return toDateKey(new Date(Number(text) * 1000));
80
+ const matched = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/u);
81
+ if (matched) {
82
+ const [, year, month, day] = matched;
83
+ return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
84
+ }
85
+ const parsed = new Date(text);
86
+ return Number.isNaN(parsed.getTime()) ? UNKNOWN_DATE : toDateKey(parsed);
87
+ }
88
+ export function readGroupBy(value) {
89
+ if (value === undefined || value === null || value === '')
90
+ return 'workflow';
91
+ const text = String(value);
92
+ if (GROUP_BY_VALUES.includes(text))
93
+ return text;
94
+ throw workflowValidationError('group_by_invalid', `groupBy 只支持 ${GROUP_BY_VALUES.join(' / ')},收到 ${text}`, { retryAllowed: false, details: { allowed: GROUP_BY_VALUES } });
95
+ }
96
+ export function readTopN(value) {
97
+ if (value === undefined || value === null || value === '')
98
+ return DEFAULT_TOP_N;
99
+ const parsed = Number(value);
100
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 50) {
101
+ throw workflowValidationError('top_n_invalid', `topN 必须是 1~50 的整数,收到 ${String(value)}`);
102
+ }
103
+ return parsed;
104
+ }
105
+ export function renderMarkdown(params) {
106
+ const { groupBy, total, groups, restGroupCount, restCount, hasAmount } = params;
107
+ const dimension = {
108
+ workflow: '按工作流',
109
+ urgency: '按紧急程度',
110
+ date: '按接收日期',
111
+ none: '不分组',
112
+ };
113
+ const lines = [
114
+ `待办汇总(${dimension[groupBy]},共 ${total} 条,${groups.length + restGroupCount} 组)`,
115
+ '',
116
+ hasAmount ? '| 分组 | 条数 | 金额合计 |' : '| 分组 | 条数 |',
117
+ hasAmount ? '| --- | --- | --- |' : '| --- | --- |',
118
+ ];
119
+ for (const group of groups) {
120
+ if (!hasAmount) {
121
+ lines.push(`| ${group.key} | ${group.count} |`);
122
+ continue;
123
+ }
124
+ const amountCell = group.amountCount === 0 ? '—' : group.amountSum.toFixed(2);
125
+ lines.push(`| ${group.key} | ${group.count} | ${amountCell} |`);
126
+ }
127
+ if (restGroupCount > 0) {
128
+ lines.push(`| 其余 ${restGroupCount} 组 | ${restCount} |${hasAmount ? ' — |' : ''}`);
129
+ }
130
+ return lines.join('\n');
131
+ }
132
+ export const ALLOWED_INPUT_KEYS = [
133
+ 'stage',
134
+ 'items',
135
+ 'aiGroups',
136
+ 'aiSummaries',
137
+ 'aiGroupSummaries',
138
+ 'withDetailContext',
139
+ 'withGroupSummaryContext',
140
+ 'detailPrefetch',
141
+ 'maxCharsPerBatch',
142
+ 'maxItemsPerBatch',
143
+ 'summaryMaxChars',
144
+ 'groupSummaryMaxChars',
145
+ 'groupFocus',
146
+ 'detailConcurrency',
147
+ 'detailTimeoutMs',
148
+ 'groupBy',
149
+ 'groupOrder',
150
+ 'topN',
151
+ 'limit',
152
+ 'pageSize',
153
+ 'category',
154
+ 'tabid',
155
+ 'tabName',
156
+ 'subCategory',
157
+ 'workflow',
158
+ 'creator',
159
+ 'requestName',
160
+ 'receiveTime',
161
+ 'requestLevel',
162
+ 'skillTiming',
163
+ ];
164
+ /** 允许把上一阶段的 data 原样回灌到下一阶段。 */
165
+ export const RESUME_INPUT_KEYS = [
166
+ 'query', 'fetched', 'warnings', 'markdown',
167
+ 'smartSummaryTitle', 'totalCount', 'wfIdGroup', 'summaryBatches', 'summaryStats',
168
+ 'groupSummaryBatches', 'groupSummaryStats', 'groupInfo', 'groupingBrief', 'detailStats',
169
+ 'skillTiming',
170
+ 'smartSmmaryTitle',
171
+ ];
172
+ /** collect 的分页上限与默认值。按 current 递增翻页。 */
173
+ export const DEFAULT_FETCH_LIMIT = 200;
174
+ /** 与下游 `readItems` 的 items 上限(500)对齐:一次汇总最多吃 500 条。 */
175
+ export const MAX_FETCH_LIMIT = 500;
176
+ /** 列表接口单页上限。 */
177
+ export const FETCH_PAGE_SIZE = 100;
178
+ /** 安全上限:最多 5 次请求(`current` 1..5),覆盖 500 条。 */
179
+ export const MAX_FETCH_PAGES = 5;
180
+ /** 分类 → 列表接口。collect 只使用列表接口,不调总数或详情接口。 */
181
+ export const LIST_ENDPOINT_BY_CATEGORY = {
182
+ todo: WORKFLOW_ENDPOINTS.TODO_LIST,
183
+ done: WORKFLOW_ENDPOINTS.DONE_LIST,
184
+ mine: WORKFLOW_ENDPOINTS.MINE_LIST,
185
+ all: WORKFLOW_ENDPOINTS.ALL_LIST,
186
+ };
187
+ export const DEFAULT_CATEGORY = 'todo';
188
+ /** 默认「全部待办」使用 tabid 0,确保列表接口返回平铺数据。 */
189
+ export const DEFAULT_TABID = '0';
190
+ export const CATEGORY_VALUES = ['todo', 'done', 'mine', 'all'];
191
+ export function readFetchLimit(value) {
192
+ if (value === undefined || value === null || value === '')
193
+ return { limit: DEFAULT_FETCH_LIMIT };
194
+ const parsed = Number(value);
195
+ if (!Number.isInteger(parsed) || parsed < 1) {
196
+ throw workflowValidationError('limit_invalid', `limit 必须是 ≥1 的整数,收到 ${String(value)}`);
197
+ }
198
+ if (parsed > MAX_FETCH_LIMIT)
199
+ return { limit: MAX_FETCH_LIMIT, cappedFrom: parsed };
200
+ return { limit: parsed };
201
+ }
202
+ export function readCategory(value) {
203
+ if (value === undefined || value === null || value === '')
204
+ return DEFAULT_CATEGORY;
205
+ const text = String(value);
206
+ if (!CATEGORY_VALUES.includes(text)) {
207
+ throw workflowValidationError('category_invalid', `category 只支持 ${CATEGORY_VALUES.join(' / ')},收到 ${text}`, { retryAllowed: false, details: { allowed: CATEGORY_VALUES } });
208
+ }
209
+ return text;
210
+ }
211
+ /**
212
+ * 单页大小:**由 limit 推导,不再接受外部指定**。
213
+ *
214
+ * - `limit ≤ 100` → 单页就是 limit(用户说 30 条就只请求 30 条,不多拿 70 条);
215
+ * - `limit > 100` → 固定 100,靠 `current` 递增翻页直到累计到 limit(上限 `MAX_FETCH_PAGES` 页)。
216
+ *
217
+ * 显式传 `pageSize` 不采用:页大小必须和「累计到 limit 即停」配套,否则同一份 plan 会翻错页。
218
+ */
219
+ export function readPageSize(limit) {
220
+ return limit <= FETCH_PAGE_SIZE ? limit : FETCH_PAGE_SIZE;
221
+ }
222
+ /** collect 阶段只允许列表数据接口,避免隐式增加总数或辅助统计请求。 */
223
+ export function guardListOnlyHost(env) {
224
+ const host = env.host;
225
+ const wrapped = new Proxy(host, {
226
+ get(target, prop, receiver) {
227
+ if (prop === 'workflowRequest') {
228
+ return async (request) => {
229
+ const path = String(request?.path ?? '');
230
+ if (/getTodo(?!ListData)/u.test(path)) {
231
+ throw workflowValidationError('forbidden_endpoint', `取数阶段只允许调用 getTodoListData;${path} 属于 getTodo* 分页/统计辅助接口,已禁止`, { retryAllowed: false, details: { path } });
232
+ }
233
+ return target.workflowRequest(request);
234
+ };
235
+ }
236
+ const value = Reflect.get(target, prop, receiver);
237
+ return typeof value === 'function' ? value.bind(target) : value;
238
+ },
239
+ });
240
+ return { ...env, host: wrapped };
241
+ }
242
+ /** 去重键:有 requestId 用它,没有就回落「标题 + 接收时间」。 */
243
+ export function rowKeyOf(row) {
244
+ if (row.requestId)
245
+ return `id:${row.requestId}`;
246
+ return `tx:${row.requestNameTitle}|${row.receivedTime ?? ''}`;
247
+ }
248
+ /** 将显示层紧急程度归一为可分组文本。 */
249
+ function urgencyOf(row) {
250
+ if (row.requestLevelShow)
251
+ return row.requestLevelShow;
252
+ return row.requestLevel >= 2 ? '紧急' : UNKNOWN_GROUP;
253
+ }
254
+ /** 分组维度取值。`urgency` 取显示层字段,取不到就进「未分类」,不用占位值凑。 */
255
+ export function groupKeyOf(row, groupBy) {
256
+ switch (groupBy) {
257
+ case 'workflow':
258
+ return row.workflowName || UNKNOWN_GROUP;
259
+ case 'urgency':
260
+ return urgencyOf(row);
261
+ case 'date':
262
+ return dateBucket(row.receivedTime);
263
+ case 'none':
264
+ return '全部待办';
265
+ default:
266
+ return UNKNOWN_GROUP;
267
+ }
268
+ }
269
+ /** displayData 里 `requestlevelShow` 这类列是富文本(`<span style=...>正常</span>`);
270
+ * 汇总只要文本,标签与折叠空白一并清掉,避免同一等级在分组时被 HTML 差异拆成两组。 */
271
+ function stripDisplayHtml(value) {
272
+ return value
273
+ .replace(/<[^>]*>/gu, ' ')
274
+ .replace(/&nbsp;/gu, ' ')
275
+ .replace(/\s+/gu, ' ')
276
+ .trim();
277
+ }
278
+ /**
279
+ * 把**任意来源**的一行归一成 `RawTodoRow`:displayData 原始行(snake 键)、取数阶段输出的
280
+ * items(目标键)、以及历史 `aggregate` 入参(`title`/`creator`/`urgency` 这类旧键)都认。
281
+ *
282
+ * 只认显示层字段名,**不回落**技术行字段;取不到就留空/0,不猜。
283
+ */
284
+ export function rowFromAny(raw) {
285
+ const record = asRecord(raw);
286
+ if (!record)
287
+ return undefined;
288
+ const pick = (keys, sanitize = false) => {
289
+ for (const key of keys) {
290
+ const value = record[key];
291
+ if (typeof value === 'string' && value.trim()) {
292
+ const text = sanitize ? stripDisplayHtml(value) : value.trim();
293
+ if (text)
294
+ return text;
295
+ }
296
+ if (typeof value === 'number' && Number.isFinite(value))
297
+ return String(value);
298
+ }
299
+ return undefined;
300
+ };
301
+ const pickNumber = (keys) => {
302
+ for (const key of keys) {
303
+ const value = record[key];
304
+ if (typeof value === 'number' && Number.isFinite(value))
305
+ return value;
306
+ if (typeof value === 'string' && value.trim()) {
307
+ const parsed = Number(value.trim());
308
+ if (Number.isFinite(parsed))
309
+ return parsed;
310
+ }
311
+ }
312
+ return 0;
313
+ };
314
+ return {
315
+ requestId: pick(['requestId', 'requestid', 'request_id', 'id']) ?? '',
316
+ workflowId: pick(['workflowId', 'workflowid', 'workflowtype']) ?? '',
317
+ userMasterId: pick(['userMasterId', 'usermasterid', 'userid']) ?? '',
318
+ isremark: pickNumber(['isremark', 'isRemark']),
319
+ nodeId: pick(['nodeId', 'nodeid', 'currentNodeId']) ?? '',
320
+ overTime: record.overdue === true || record.overTime === true || record.overtime === true,
321
+ userOverTime: pick(['userOverTime', 'userOvertime', 'userovertime']) ?? '',
322
+ requestLevel: pickNumber(['requestLevel', 'requestlevel']),
323
+ // requestlevelTitle 是纯文本("正常"),requestlevelShow 带 span;两者都清洗后再取。
324
+ requestLevelShow: pick(['requestlevelTitle', 'requestLevelShow', 'requestlevelShow', 'requestLevelText', 'urgency'], true) ?? '',
325
+ requestNameTitle: pick(['requestNameTitle', 'requestnameTitle', 'requestnametitle', 'requestname', 'title', 'flowName'], true) ?? '',
326
+ creater: pick(['creater', 'createrId', 'creatorId']) ?? '',
327
+ createrName: pick(['createrName', 'creatername', 'creatorName', 'creator']) ?? '',
328
+ // 最后反馈时间:接口原始键是全小写 `lastfeedbackdatetime`;本模块自己的输出键是
329
+ // `lastFeedBackDateTime`(大写 B)。**两种拼写都要认**——数据在 CLI 各阶段之间回灌时
330
+ // 会重新走一遍归一,只认接口拼法会让这个字段在第二阶段悄悄变成空串。
331
+ lastFeedBackDateTime: pick([
332
+ 'lastFeedBackDateTime', 'lastfeedbackdatetime', 'lastFeedbackDateTime', 'lastfeedbacktime', 'lastFeedbackTime',
333
+ ]) ?? '',
334
+ summary: pick(['summary']),
335
+ workflowName: pick(['workflowName', 'workflowname', 'workflowTypeName'], true),
336
+ receivedTime: pick(['receivedTime', 'receivedtime', 'receivedatetime', 'operatedatetime', 'stayStartAt']),
337
+ flowStatus: pick(['flowStatus', 'flowstatusShow', 'flowStatusShow']),
338
+ requestMark: pick(['requestMark', 'requestmark']),
339
+ amount: parseAmount(record.amount ?? record.money ?? record.totalAmount ?? record.totalamount ?? record.sum ?? record.fee),
340
+ };
341
+ }
342
+ export function assertKnownKeys(args) {
343
+ const known = [...ALLOWED_INPUT_KEYS, ...RESUME_INPUT_KEYS];
344
+ const unknown = Object.keys(args).filter((key) => !known.includes(key));
345
+ if (unknown.length > 0) {
346
+ throw workflowValidationError('input_unknown_key', `workflow.todoSmartConvert 不接受字段:${unknown.join(', ')};可用字段:${ALLOWED_INPUT_KEYS.join(', ')}`, { retryAllowed: false, details: { unknown, allowed: ALLOWED_INPUT_KEYS } });
347
+ }
348
+ }
349
+ /** `items` 的公共校验 + 去重(`group` 与 `aggregate` 共用,保证两段口径一致)。 */
350
+ export function readItems(rawItems) {
351
+ if (!Array.isArray(rawItems)) {
352
+ throw workflowValidationError('items_invalid', 'items 必须是数组(通常是 workflow.todoSmartConvert 取数阶段返回的 items,或用户直接给出的待办条目)');
353
+ }
354
+ if (rawItems.length === 0) {
355
+ throw workflowValidationError('items_empty', 'items 为空数组,没有可汇总的待办');
356
+ }
357
+ if (rawItems.length > 500) {
358
+ throw workflowValidationError('items_too_many', `单次最多 500 条,收到 ${rawItems.length} 条;请先分页缩减范围`);
359
+ }
360
+ const seen = new Set();
361
+ const rows = [];
362
+ let duplicateCount = 0;
363
+ let unreadableCount = 0;
364
+ for (const raw of rawItems) {
365
+ const row = rowFromAny(raw);
366
+ if (!row) {
367
+ unreadableCount += 1;
368
+ continue;
369
+ }
370
+ const key = rowKeyOf(row);
371
+ if (seen.has(key)) {
372
+ duplicateCount += 1;
373
+ continue;
374
+ }
375
+ seen.add(key);
376
+ rows.push(row);
377
+ }
378
+ if (rows.length === 0) {
379
+ throw workflowValidationError('items_unreadable', 'items 里没有可解析的条目(每项都必须是对象)', { details: { received: rawItems.length, unreadable: unreadableCount } });
380
+ }
381
+ return { rawCount: rawItems.length, rows, duplicateCount, unreadableCount };
382
+ }
383
+ /** 读取带上下限的整数选项。 */
384
+ export function readBoundedInt(value, name, fallback, min, max) {
385
+ if (value === undefined || value === null || value === '')
386
+ return fallback;
387
+ const parsed = Number(value);
388
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
389
+ throw workflowValidationError(`${name}_invalid`, `${name} 必须是 ${min}~${max} 的整数,收到 ${String(value)}`, { retryAllowed: false });
390
+ }
391
+ return parsed;
392
+ }