lumencode 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/report.js ADDED
@@ -0,0 +1,1446 @@
1
+ import { Table } from './table.js';
2
+
3
+ export function generateAutoSummary(usageStats, gitStats, prevStats, period, start, end) {
4
+ const periodName = period === 'daily' ? '今日' : period === 'weekly' ? '本周' : '本月';
5
+ const prevName = period === 'daily' ? '昨日' : period === 'weekly' ? '上周' : '上月';
6
+ const parts = [];
7
+
8
+ // 1. 核心指标叙述
9
+ const coreLine = buildCoreNarrative(usageStats, periodName);
10
+ parts.push(coreLine);
11
+
12
+ // 2. 环比变化
13
+ if (prevStats) {
14
+ const changeLine = buildChangeNarrative(usageStats, prevStats, periodName, prevName);
15
+ if (changeLine) parts.push(changeLine);
16
+ }
17
+
18
+ // 3. 项目亮点
19
+ const projLine = buildProjectNarrative(usageStats, periodName);
20
+ if (projLine) parts.push(projLine);
21
+
22
+ // 4. 场景与模型
23
+ const sceneLine = buildSceneNarrative(usageStats, periodName);
24
+ if (sceneLine) parts.push(sceneLine);
25
+
26
+ // 5. 缓存效率
27
+ const cacheLine = buildCacheNarrative(usageStats);
28
+ if (cacheLine) parts.push(cacheLine);
29
+
30
+ // 6. Git 产出(如有)— 叙事式
31
+ if (gitStats && gitStats.commits > 0) {
32
+ const gitLine = buildGitNarrative(gitStats, periodName);
33
+ parts.push(gitLine);
34
+
35
+ // 追加一行的成果摘要
36
+ if (gitStats.commitList?.length) {
37
+ const summary = buildGitSummaryLine(gitStats.commitList);
38
+ if (summary) parts.push(summary);
39
+ }
40
+ }
41
+
42
+ return { paragraphs: parts, periodName, prevName };
43
+ }
44
+
45
+ function buildCoreNarrative(stats, periodName) {
46
+ const reqStr = fmtN(stats.requestCount);
47
+ const tokenStr = fmtToken(stats.totalTokens);
48
+ const sessionStr = fmtN(stats.sessionCount);
49
+ const costStr = stats.estimatedCost ? `$${stats.estimatedCost.toFixed(2)}` : null;
50
+ const projCount = Object.keys(stats.projects).length;
51
+
52
+ let line = `${periodName}共发起 ${reqStr} 次 AI 交互(${sessionStr} 个会话),消耗 ${tokenStr} Token`;
53
+ if (projCount > 1) line += `,覆盖 ${projCount} 个项目`;
54
+ if (costStr) line += `,预估费用 ${costStr}`;
55
+ line += '。';
56
+ return line;
57
+ }
58
+
59
+ function buildChangeNarrative(stats, prev, periodName, prevName) {
60
+ const changes = [];
61
+
62
+ const reqPct = pctChange(stats.requestCount, prev.requestCount);
63
+ if (reqPct !== null) changes.push(`交互量${formatPct(reqPct)}`);
64
+
65
+ const tokenPct = pctChange(stats.totalTokens, prev.totalTokens);
66
+ if (tokenPct !== null) changes.push(`Token 消耗${formatPct(tokenPct)}`);
67
+
68
+ const costPct = pctChange(stats.estimatedCost, prev.estimatedCost);
69
+ if (costPct !== null) changes.push(`费用${formatPct(costPct)}`);
70
+
71
+ if (changes.length === 0) return null;
72
+ return `相比${prevName},${changes.join('、')}。`;
73
+ }
74
+
75
+ function buildProjectNarrative(stats, periodName) {
76
+ const projects = Object.entries(stats.projects)
77
+ .filter(([, d]) => d.requests > 0)
78
+ .sort((a, b) => b[1].requests - a[1].requests);
79
+ if (projects.length === 0) return null;
80
+
81
+ const totalReqs = projects.reduce((s, [, d]) => s + d.requests, 0);
82
+ const top = projects[0];
83
+ const topPct = Math.round(top[1].requests / totalReqs * 100);
84
+ const topName = simplifyPath(top[0]);
85
+
86
+ if (projects.length === 1) {
87
+ return `主要工作集中在 ${topName} 项目,共 ${fmtN(top[1].requests)} 次交互。`;
88
+ }
89
+
90
+ const second = projects[1];
91
+ const secondName = simplifyPath(second[0]);
92
+ return `主要工作集中在 ${topName}(占 ${topPct}%)和 ${secondName}(${fmtN(second[1].requests)} 次)。`;
93
+ }
94
+
95
+ function buildSceneNarrative(stats, periodName) {
96
+ const scenarios = Object.entries(stats.scenarios || {})
97
+ .filter(([, v]) => v > 0)
98
+ .sort((a, b) => b[1] - a[1]);
99
+ if (scenarios.length === 0) return null;
100
+
101
+ const total = scenarios.reduce((s, [, v]) => s + v, 0);
102
+ const topScenes = scenarios.slice(0, 2).map(([name, count]) => {
103
+ const pct = Math.round(count / total * 100);
104
+ return `${name}(${pct}%)`;
105
+ });
106
+ const sceneStr = topScenes.join('、');
107
+
108
+ // 模型信息
109
+ const models = Object.entries(stats.models || {}).sort((a, b) => b[1].count - a[1].count);
110
+ let modelStr = '';
111
+ if (models.length > 0) {
112
+ const topModel = models[0][0].replace('claude-', '');
113
+ const modelPct = Math.round(models[0][1].count / stats.requestCount * 100);
114
+ modelStr = `,以 ${topModel} 模型为主(${modelPct}%)`;
115
+ }
116
+
117
+ return `使用场景以 ${sceneStr} 为主${modelStr}。`;
118
+ }
119
+
120
+ function buildCacheNarrative(stats) {
121
+ const total = stats.cacheRead + stats.inputTokens;
122
+ if (total === 0) return null;
123
+ const cacheRate = Math.round(stats.cacheRead / total * 100);
124
+ if (cacheRate < 5) return null;
125
+ return `缓存命中率 ${cacheRate}%,${cacheRate > 60 ? '缓存利用良好' : cacheRate > 30 ? '缓存利用中等' : '可考虑增加上下文复用'}。`;
126
+ }
127
+
128
+ function buildGitNarrative(git, periodName) {
129
+ const parts = [];
130
+ parts.push(`${fmtN(git.commits)} 次提交`);
131
+ if (git.linesAdded > 0 || git.linesDeleted > 0) {
132
+ const net = git.linesAdded - git.linesDeleted;
133
+ parts.push(`+${fmtN(git.linesAdded)}/-${fmtN(git.linesDeleted)} 行`);
134
+ if (net > 0) parts.push(`净增 ${fmtN(net)} 行`);
135
+ }
136
+ if (git.filesChanged > 0) {
137
+ parts.push(`变更 ${fmtN(git.filesChanged)} 个文件`);
138
+ }
139
+ let line = `代码产出:${parts.join('、')}。`;
140
+
141
+ // AI 参与度总结(与 UI summary card 对齐)
142
+ if (git.aiContribution && git.commits > 0) {
143
+ const ai = git.aiContribution;
144
+ const totalLines = ai.totalLinesChanged || 1;
145
+ const aiLinePct = Math.round((ai.aiLinesChanged / totalLines) * 100);
146
+ const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / git.commits)) * 100);
147
+ line += ` 其中 ${aiLinePct}% 代码变更有 AI 参与,${ai.aiCommits}/${git.commits} 提交使用 AI (${commitPct}%)。`;
148
+ }
149
+
150
+ return line;
151
+ }
152
+
153
+ function buildAttributionSummaryLine(summary) {
154
+ if (!summary) return null;
155
+ const total = summary.totalLinesChanged || 0;
156
+ if (total <= 0) return null;
157
+ const confirmedPct = Math.round((summary.confirmedAILines / total) * 100);
158
+ const upperPct = Math.round(((summary.confirmedAILines + summary.probableAILines + summary.possibleAILines) / total) * 100);
159
+ const unknownPct = Math.round((summary.unknownLines / total) * 100);
160
+ return `AI 归因汇总:确认 AI ${confirmedPct}% / 可能 AI 上限 ${upperPct}% / 未知 ${unknownPct}%。`;
161
+ }
162
+
163
+ function buildUnknownReasonLine(summary) {
164
+ if (!summary?.unknownReasons?.length) return null;
165
+ return `未归因原因:${summary.unknownReasons.join('、')}。`;
166
+ }
167
+
168
+ // 一行式成果摘要:从 commitList 提取 feat/fix 数量和代表性描述
169
+ function buildGitSummaryLine(commitList) {
170
+ const feats = commitList.filter(c => c.type === 'feat');
171
+ const fixes = commitList.filter(c => c.type === 'fix');
172
+ const refactors = commitList.filter(c => c.type === 'refactor');
173
+ const parts = [];
174
+ if (feats.length > 0) {
175
+ const examples = feats.slice(0, 2).map(c => cleanSubject(c.subject, c.scope)).filter(Boolean);
176
+ const suffix = feats.length > 2 ? ` 等 ${feats.length} 项` : '';
177
+ parts.push(`新功能 ${examples.join('、')}${suffix}`);
178
+ }
179
+ if (fixes.length > 0) {
180
+ const examples = fixes.slice(0, 2).map(c => cleanSubject(c.subject, c.scope)).filter(Boolean);
181
+ const suffix = fixes.length > 2 ? ` 等 ${fixes.length} 项` : '';
182
+ parts.push(`修复 ${examples.join('、')}${suffix}`);
183
+ }
184
+ if (refactors.length > 0) {
185
+ parts.push(`重构 ${refactors.length} 处`);
186
+ }
187
+ if (parts.length === 0) return null;
188
+ return `主要工作:${parts.join(';')}。`;
189
+ }
190
+
191
+ // 环比深度对比:项目活跃度变化、会话效率变化
192
+ function buildDeepChangeNarrative(curr, prev) {
193
+ const changes = [];
194
+
195
+ // 项目活跃度变化
196
+ const currProjects = new Set(Object.keys(curr.projects || {}));
197
+ const prevProjects = new Set(Object.keys(prev.projects || {}));
198
+ const newProjects = [...currProjects].filter(p => !prevProjects.has(p));
199
+ const droppedProjects = [...prevProjects].filter(p => !currProjects.has(p));
200
+ if (newProjects.length > 0) {
201
+ changes.push(`新增活跃项目 ${newProjects.map(simplifyPath).join('、')}`);
202
+ }
203
+ if (droppedProjects.length > 0) {
204
+ changes.push(`${droppedProjects.map(simplifyPath).join('、')} 项目本期无活动`);
205
+ }
206
+
207
+ // 会话效率:avg requests per session
208
+ const currAvg = curr.sessionCount > 0 ? (curr.requestCount / curr.sessionCount).toFixed(1) : 0;
209
+ const prevAvg = prev.sessionCount > 0 ? (prev.requestCount / prev.sessionCount).toFixed(1) : 0;
210
+ if (currAvg > 0 && prevAvg > 0) {
211
+ const diff = currAvg - prevAvg;
212
+ if (Math.abs(diff) >= 0.5) {
213
+ changes.push(`会话平均交互 ${currAvg} 次(${diff > 0 ? '↑' : '↓'}${Math.abs(diff).toFixed(1)})`);
214
+ }
215
+ }
216
+
217
+ // Token 效率:output/input ratio
218
+ const currRatio = curr.inputTokens > 0 ? (curr.outputTokens / curr.inputTokens).toFixed(2) : 0;
219
+ const prevRatio = prev.inputTokens > 0 ? (prev.outputTokens / prev.inputTokens).toFixed(2) : 0;
220
+ if (currRatio > 0 && prevRatio > 0) {
221
+ const diff = currRatio - prevRatio;
222
+ if (Math.abs(diff) >= 0.1) {
223
+ changes.push(`输出/输入比 ${currRatio}(${diff > 0 ? '↑' : '↓'}${Math.abs(diff).toFixed(2)})`);
224
+ }
225
+ }
226
+
227
+ if (changes.length === 0) return null;
228
+ return `**效率变化**:${changes.join(';')}。`;
229
+ }
230
+
231
+ // ── Commit 叙事提取 ──
232
+
233
+ const TYPE_LABELS = {
234
+ feat: '新功能',
235
+ fix: '缺陷修复',
236
+ refactor: '重构',
237
+ perf: '性能优化',
238
+ docs: '文档',
239
+ test: '测试',
240
+ chore: '工程维护',
241
+ style: '代码风格',
242
+ ci: 'CI/CD',
243
+ build: '构建',
244
+ revert: '回退',
245
+ other: '其他',
246
+ };
247
+
248
+ // 从 commitList 生成按 type 分组的叙事
249
+ export function buildCommitNarrative(commitList, { projectGroup = false, maxItems = 8 } = {}) {
250
+ if (!commitList?.length) return null;
251
+
252
+ const typeOrder = ['feat', 'fix', 'refactor', 'perf', 'docs', 'test', 'chore', 'style', 'ci', 'build', 'revert', 'other'];
253
+
254
+ // 按 project 分组(可选)
255
+ if (projectGroup) {
256
+ const byProject = groupBy(commitList, c => c.repo || '');
257
+ const results = [];
258
+ for (const [repo, commits] of byProject) {
259
+ const narrative = buildSingleProjectNarrative(commits, typeOrder, maxItems);
260
+ if (narrative) {
261
+ narrative.project = simplifyPath(repo);
262
+ results.push(narrative);
263
+ }
264
+ }
265
+ return results.length > 0 ? results : null;
266
+ }
267
+
268
+ return buildSingleProjectNarrative(commitList, typeOrder, maxItems);
269
+ }
270
+
271
+ function buildSingleProjectNarrative(commitList, typeOrder, maxItems) {
272
+ const byType = groupBy(commitList, c => c.type || 'other');
273
+ const sections = [];
274
+
275
+ for (const type of typeOrder) {
276
+ const commits = byType.get(type);
277
+ if (!commits?.length) continue;
278
+
279
+ const label = TYPE_LABELS[type] || type;
280
+ const subjects = commits.map(c => cleanSubject(c.subject, c.scope)).filter(Boolean);
281
+ if (subjects.length === 0) continue;
282
+
283
+ const display = subjects.slice(0, maxItems);
284
+ const overflow = subjects.length > maxItems ? subjects.length - maxItems : 0;
285
+
286
+ sections.push({
287
+ type,
288
+ label,
289
+ count: commits.length,
290
+ items: display,
291
+ overflow,
292
+ aiCount: commits.filter(c => c.isAI).length,
293
+ });
294
+ }
295
+
296
+ return sections.length > 0 ? { sections, totalCommits: commitList.length } : null;
297
+ }
298
+
299
+ function cleanSubject(subject, scope) {
300
+ if (!subject) return null;
301
+ // 去掉 conventional commit 前缀
302
+ let s = subject.replace(/^(feat|fix|refactor|docs|test|chore|perf|style|ci|build|revert)(\([^)]+\))?!?:\s*/i, '');
303
+ if (!s.trim()) return null;
304
+ // 去掉 emoji 前缀(覆盖常见 emoji 区块 + Dingbats + Symbols)
305
+ s = s.replace(/^[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{FE00}-\u{FE0F}\u{200D}]\s*/u, '').trim();
306
+ // 截断过长 subject
307
+ if (s.length > 60) s = s.slice(0, 57) + '...';
308
+ // 如果有 scope,附加在前面
309
+ if (scope) s = `[${scope}] ${s}`;
310
+ return s;
311
+ }
312
+
313
+ function groupBy(arr, keyFn) {
314
+ const map = new Map();
315
+ for (const item of arr) {
316
+ const key = keyFn(item);
317
+ if (!map.has(key)) map.set(key, []);
318
+ map.get(key).push(item);
319
+ }
320
+ return map;
321
+ }
322
+
323
+ // AI 贡献明细:按 attributionType 拆分 commit,汇总 AI 涉及的文件
324
+ export function buildAIContributionDetail(commitList) {
325
+ if (!commitList?.length) return null;
326
+
327
+ const explicit = []; // Co-Authored-By / Generated with
328
+ const sessionStrong = []; // session_strong / session_strong_file_overlap
329
+ const fileOverlap = []; // session_file_overlap / session_file_overlap_dominant
330
+ const aiFiles = new Set();
331
+ let totalAIFileAdded = 0;
332
+ let totalAIFileDeleted = 0;
333
+
334
+ for (const c of commitList) {
335
+ if (c.aiConfidence !== 'high' && c.aiConfidence !== 'medium') continue;
336
+ const subject = cleanSubject(c.subject, c.scope) || c.subject?.slice(0, 40);
337
+ if (!subject) continue;
338
+
339
+ if (c.attributionType === 'explicit') {
340
+ explicit.push(subject);
341
+ } else if (c.attributionType?.startsWith('session_strong')) {
342
+ sessionStrong.push(subject);
343
+ } else if (c.attributionType?.startsWith('session_file_overlap')) {
344
+ fileOverlap.push(subject);
345
+ }
346
+
347
+ // 汇总 AI 涉及的文件
348
+ const matched = c.aiEvidenceDetails?.matchedFiles || [];
349
+ for (const f of matched) aiFiles.add(f);
350
+ // 对于 explicit 类型,所有文件都算
351
+ if (c.attributionType === 'explicit' && !matched.length) {
352
+ for (const f of c.files || []) aiFiles.add(f.path);
353
+ }
354
+
355
+ // 累计文件级行数
356
+ const matchSet = matched.length > 0 ? new Set(matched) : null;
357
+ for (const f of c.files || []) {
358
+ if (!matchSet || matchSet.has(f.path)) {
359
+ totalAIFileAdded += f.added || 0;
360
+ totalAIFileDeleted += f.deleted || 0;
361
+ }
362
+ }
363
+ }
364
+
365
+ const total = explicit.length + sessionStrong.length + fileOverlap.length;
366
+ if (total === 0) return null;
367
+
368
+ return {
369
+ explicit,
370
+ sessionStrong,
371
+ fileOverlap,
372
+ aiFiles: [...aiFiles].sort(),
373
+ totalAIFileAdded,
374
+ totalAIFileDeleted,
375
+ };
376
+ }
377
+
378
+ function simplifyPath(p) {
379
+ const parts = p.replace(/\\/g, '/').split('/');
380
+ return parts[parts.length - 1] || p;
381
+ }
382
+
383
+ function fmtN(n) {
384
+ if (n >= 10_000) return (n / 10_000).toFixed(1) + '万';
385
+ return n.toLocaleString('zh-CN');
386
+ }
387
+
388
+ // ── 洞察生成器 ──
389
+
390
+ function buildCoreInsight(stats, periodName) {
391
+ if (stats.requestCount === 0) return null;
392
+ const insights = [];
393
+ const avgPerDay = stats.activeDays > 0 ? (stats.requestCount / stats.activeDays) : 0;
394
+ if (stats.activeDays > 1 && avgPerDay > 30) {
395
+ insights.push(`日均 ${avgPerDay.toFixed(0)} 次交互,使用强度较高`);
396
+ } else if (stats.activeDays > 1 && avgPerDay < 5) {
397
+ insights.push(`日均 ${avgPerDay.toFixed(1)} 次交互,使用频率较低`);
398
+ }
399
+ const outputRatio = stats.totalTokens > 0 ? stats.outputTokens / stats.totalTokens : 0;
400
+ if (outputRatio > 0.6) insights.push('输出 Token 占比偏高,提示词可进一步精炼');
401
+ return insights.length > 0 ? insights.join(';') + '。' : null;
402
+ }
403
+
404
+ function buildProjectInsight(projects) {
405
+ const entries = Object.entries(projects).filter(([, d]) => d.requests > 0);
406
+ if (entries.length < 2) return null;
407
+ const total = entries.reduce((s, [, d]) => s + d.requests, 0);
408
+ const topPct = Math.round((entries[0][1].requests / total) * 100);
409
+ if (topPct > 80) return `工作高度集中于单一项目(${simplifyPath(entries[0][0])},${topPct}%),可考虑是否需要多项目并行。`;
410
+ if (entries.length >= 4) return `涉及 ${entries.length} 个项目,注意上下文切换对效率的影响。`;
411
+ return null;
412
+ }
413
+
414
+ function buildScenarioInsight(scenarios) {
415
+ const entries = Object.entries(scenarios).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
416
+ if (entries.length === 0) return null;
417
+ const total = entries.reduce((s, [, v]) => s + v, 0);
418
+ const topName = entries[0][0];
419
+ const topPct = Math.round((entries[0][1] / total) * 100);
420
+ if (topPct > 70) return `使用场景以${topName}为绝对主导(${topPct}%),可拓展其他场景以提升 AI 辅助覆盖面。`;
421
+ // 检查是否有编码+研究组合
422
+ const names = new Set(entries.map(([k]) => k));
423
+ if (names.has('编码') && names.has('阅读/研究') && entries.length >= 3) {
424
+ return '编码与研读均衡,AI 在理解与实现两个环节均有参与。';
425
+ }
426
+ return null;
427
+ }
428
+
429
+ function buildGitInsight(git) {
430
+ if (!git || git.commits === 0) return null;
431
+ const insights = [];
432
+ if (git.aiContribution) {
433
+ const ai = git.aiContribution;
434
+ const ratio = ai.aiCommitRatio ?? (ai.aiCommits ? ai.aiCommits / git.commits : 0);
435
+ const commitPct = Math.round(ratio * 100);
436
+ if (!isNaN(commitPct)) {
437
+ if (commitPct > 80) insights.push('AI 参与度极高,核心代码产出几乎全程 AI 辅助');
438
+ else if (commitPct > 50) insights.push('AI 参与度较高,超过半数提交有 AI 辅助');
439
+ else if (commitPct > 0) insights.push(`AI 参与 ${commitPct}% 的提交,人机协作比例适中`);
440
+ }
441
+ }
442
+ const netLines = git.linesAdded - git.linesDeleted;
443
+ if (git.linesAdded > 0) {
444
+ const addDelRatio = git.linesDeleted > 0 ? (git.linesAdded / git.linesDeleted).toFixed(1) : null;
445
+ if (addDelRatio && parseFloat(addDelRatio) > 3) insights.push('以新增代码为主,处于功能开发阶段');
446
+ else if (addDelRatio && parseFloat(addDelRatio) < 1) insights.push('删除多于新增,可能处于重构或清理阶段');
447
+ }
448
+ return insights.length > 0 ? insights.join(';') + '。' : null;
449
+ }
450
+
451
+ function buildCostInsight(stats) {
452
+ if (!stats.estimatedCost || stats.estimatedCost <= 0) return null;
453
+ if (stats.activeDays > 0) {
454
+ const dailyCost = stats.estimatedCost / stats.activeDays;
455
+ if (dailyCost > 10) return '日均费用较高,建议关注高消耗模型的使用场景优化。';
456
+ if (dailyCost < 1) return '日均费用较低,AI 辅助工具的性价比表现良好。';
457
+ }
458
+ return null;
459
+ }
460
+
461
+ function buildDailyTrendInsight(dailyStats, period) {
462
+ const dates = Object.keys(dailyStats).sort();
463
+ if (dates.length < 2) return null;
464
+
465
+ const lines = [];
466
+ const periodLabel = period === 'weekly' ? '本周' : '本月';
467
+
468
+ // 峰值日
469
+ let peakDate = dates[0], peakReqs = 0;
470
+ for (const d of dates) {
471
+ if (dailyStats[d].requests > peakReqs) {
472
+ peakReqs = dailyStats[d].requests;
473
+ peakDate = d;
474
+ }
475
+ }
476
+
477
+ // 活跃分布
478
+ const activeDays = dates.length;
479
+ const totalDays = period === 'weekly' ? 7 : 30;
480
+ const coverage = Math.round((activeDays / totalDays) * 100);
481
+
482
+ if (peakReqs > 0) {
483
+ lines.push(`**${periodLabel}活跃趋势**:${activeDays} 天有活动(覆盖率 ${coverage}%),峰值在 ${peakDate}(${fmtN(peakReqs)} 次交互)。`);
484
+ }
485
+
486
+ // 连续活跃检测
487
+ let maxStreak = 1, streak = 1;
488
+ for (let i = 1; i < dates.length; i++) {
489
+ const prev = new Date(dates[i - 1]);
490
+ const curr = new Date(dates[i]);
491
+ const diff = (curr - prev) / (1000 * 60 * 60 * 24);
492
+ if (diff === 1) {
493
+ streak++;
494
+ maxStreak = Math.max(maxStreak, streak);
495
+ } else {
496
+ streak = 1;
497
+ }
498
+ }
499
+ if (maxStreak >= 3) {
500
+ lines.push(`最长连续活跃 ${maxStreak} 天。`);
501
+ }
502
+
503
+ // 请求量趋势(前半 vs 后半)
504
+ if (dates.length >= 4) {
505
+ const mid = Math.floor(dates.length / 2);
506
+ const firstHalfReqs = dates.slice(0, mid).reduce((s, d) => s + dailyStats[d].requests, 0);
507
+ const secondHalfReqs = dates.slice(mid).reduce((s, d) => s + dailyStats[d].requests, 0);
508
+ if (firstHalfReqs > 0 && secondHalfReqs > 0) {
509
+ const trend = secondHalfReqs > firstHalfReqs * 1.3 ? '呈上升趋势' :
510
+ secondHalfReqs < firstHalfReqs * 0.7 ? '呈下降趋势' : '基本平稳';
511
+ lines.push(`交互量${trend}。`);
512
+ }
513
+ }
514
+
515
+ return lines.length > 0 ? lines.join('') : null;
516
+ }
517
+
518
+ function fmtToken(n) {
519
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
520
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
521
+ return String(n);
522
+ }
523
+
524
+ function pctChange(curr, prev) {
525
+ if (!prev || prev === 0) return curr > 0 ? 100 : null;
526
+ return Math.round((curr - prev) / prev * 100);
527
+ }
528
+
529
+ function formatPct(pct) {
530
+ if (pct > 0) return `↑${pct}%`;
531
+ if (pct < 0) return `↓${Math.abs(pct)}%`;
532
+ return '持平';
533
+ }
534
+
535
+ export function generateReport(usageData, gitData, period, startDate, endDate) {
536
+ const lines = [];
537
+
538
+ lines.push('');
539
+ lines.push(`════════════════════════════════════════════════════════════════════`);
540
+ lines.push(` AI 编码助手使用报告 - ${formatPeriodTitle(period, startDate, endDate)}`);
541
+ lines.push(`════════════════════════════════════════════════════════════════════`);
542
+ lines.push('');
543
+
544
+ // 1. 使用概览
545
+ lines.push('┌─────────────────────────────────────┐');
546
+ lines.push('│ 一、使用概览 │');
547
+ lines.push('└─────────────────────────────────────┘');
548
+ lines.push('');
549
+
550
+ const overviewTable = new Table({
551
+ columns: [
552
+ { title: '指标', width: 20 },
553
+ { title: '数值', width: 15 },
554
+ { title: '说明', width: 25 },
555
+ ],
556
+ });
557
+
558
+ const totalSessions = usageData.sessionCount;
559
+ const totalRequests = usageData.requestCount;
560
+ const totalUserMessages = usageData.userMessageCount;
561
+ const avgPerDay = usageData.activeDays > 0 ? (totalRequests / usageData.activeDays).toFixed(1) : '0';
562
+ const activeDays = usageData.activeDays;
563
+ const avgMsgPerSession = totalSessions > 0 ? (totalUserMessages / totalSessions).toFixed(1) : '0';
564
+ const avgReqPerSession = totalSessions > 0 ? (totalRequests / totalSessions).toFixed(1) : '0';
565
+
566
+ overviewTable.addRow(['会话数', formatInt(totalSessions), '独立对话数']);
567
+ overviewTable.addRow(['用户消息数', formatInt(totalUserMessages), '用户主动发出的消息']);
568
+ overviewTable.addRow(['总请求数', formatInt(totalRequests), '含 assistant 响应']);
569
+ overviewTable.addRow(['活跃天数', `${activeDays} 天`, period === 'daily' ? '' : `日均请求 ${avgPerDay} 次`]);
570
+ overviewTable.addRow(['Token 总消耗', formatNumber(usageData.totalTokens), `≈ ${formatNumber(Math.round(usageData.totalTokens / (totalRequests || 1)))}/请求`]);
571
+ overviewTable.addRow(['输入 Token', formatNumber(usageData.inputTokens), '']);
572
+ overviewTable.addRow(['输出 Token', formatNumber(usageData.outputTokens), '']);
573
+ overviewTable.addRow(['Cache 命中', formatNumber(usageData.cacheRead), formatPercent(usageData.cacheRead, usageData.cacheRead + usageData.inputTokens)]);
574
+ lines.push(overviewTable.render());
575
+ lines.push('');
576
+
577
+ // 2. 效率指标
578
+ if (gitData && (gitData.commits > 0 || gitData.filesChanged > 0)) {
579
+ lines.push('┌─────────────────────────────────────┐');
580
+ lines.push('│ 二、效率指标(Git) │');
581
+ lines.push('└─────────────────────────────────────┘');
582
+ lines.push('');
583
+
584
+ const gitTable = new Table({
585
+ columns: [
586
+ { title: '指标', width: 20 },
587
+ { title: '数值', width: 15 },
588
+ { title: '日均', width: 15 },
589
+ ],
590
+ });
591
+
592
+ const days = usageData.activeDays || 1;
593
+ gitTable.addRow(['提交次数', String(gitData.commits), (gitData.commits / days).toFixed(1)]);
594
+ gitTable.addRow(['变更文件数', String(gitData.filesChanged), (gitData.filesChanged / days).toFixed(1)]);
595
+ gitTable.addRow(['新增行数', String(gitData.linesAdded), (gitData.linesAdded / days).toFixed(1)]);
596
+ gitTable.addRow(['删除行数', String(gitData.linesDeleted), (gitData.linesDeleted / days).toFixed(1)]);
597
+ gitTable.addRow(['净增行数', String(gitData.linesAdded - gitData.linesDeleted), ((gitData.linesAdded - gitData.linesDeleted) / days).toFixed(1)]);
598
+ if (gitData.aiContribution && gitData.commits > 0) {
599
+ const ai = gitData.aiContribution;
600
+ const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / gitData.commits)) * 100);
601
+ gitTable.addRow(['高/中置信 AI 提交', `${ai.aiCommits}/${gitData.commits}`, `${commitPct}%`]);
602
+ gitTable.addRow(['高置信提交', String(ai.highConfidenceCommits), '']);
603
+ gitTable.addRow(['AI 命中文件新增行', String(ai.aiFileLinesAdded), '']);
604
+ gitTable.addRow(['AI 命中文件删除行', String(ai.aiFileLinesDeleted), '']);
605
+ gitTable.addRow(['低置信关联提交', String(ai.lowConfidenceCommits), '']);
606
+ }
607
+ lines.push(gitTable.render());
608
+ lines.push('');
609
+
610
+ // 日维度拆解
611
+ if (period !== 'daily' && gitData.commitsByDate) {
612
+ const dates = Object.keys(gitData.commitsByDate).sort();
613
+ if (dates.length > 0) {
614
+ lines.push(' 每日提交趋势:');
615
+ lines.push('');
616
+ const trendTable = new Table({
617
+ columns: [
618
+ { title: '日期', width: 12 },
619
+ { title: '提交', width: 8 },
620
+ { title: '文件', width: 8 },
621
+ { title: '+行', width: 10 },
622
+ { title: '-行', width: 10 },
623
+ ],
624
+ });
625
+ for (const d of dates) {
626
+ const ld = gitData.linesByDate?.[d] || { added: 0, deleted: 0, files: 0 };
627
+ trendTable.addRow([
628
+ d,
629
+ String(gitData.commitsByDate[d]),
630
+ String(ld.files),
631
+ String(ld.added),
632
+ String(ld.deleted),
633
+ ]);
634
+ }
635
+ lines.push(trendTable.render());
636
+ lines.push('');
637
+ }
638
+ }
639
+
640
+ // 2b 提交类型分布
641
+ if (gitData.commitTypes) {
642
+ const typeEntries = Object.entries(gitData.commitTypes)
643
+ .filter(([, v]) => v > 0)
644
+ .sort((a, b) => b[1] - a[1]);
645
+ if (typeEntries.length > 0) {
646
+ const typeTotal = typeEntries.reduce((s, [, v]) => s + v, 0);
647
+ lines.push(' 提交类型分布:');
648
+ lines.push('');
649
+ const typeTable = new Table({
650
+ columns: [
651
+ { title: '类型', width: 12 },
652
+ { title: '数量', width: 8 },
653
+ { title: '占比', width: 8 },
654
+ { title: '可视化', width: 32 },
655
+ ],
656
+ });
657
+ for (const [type, count] of typeEntries) {
658
+ const pct = (count / typeTotal) * 100;
659
+ const bar = makeBar(pct, 20);
660
+ typeTable.addRow([type, formatInt(count), pct.toFixed(1) + '%', bar + ` ${pct.toFixed(0)}%`]);
661
+ }
662
+ lines.push(typeTable.render());
663
+ lines.push('');
664
+ }
665
+ }
666
+
667
+ // 2c 文件热点 Top 10
668
+ if (gitData.fileHotspots?.length) {
669
+ lines.push(' 文件热点 Top 10:');
670
+ lines.push('');
671
+ const hotTable = new Table({
672
+ columns: [
673
+ { title: '文件', width: 40 },
674
+ { title: '触碰', width: 6 },
675
+ { title: '+行', width: 8 },
676
+ { title: '-行', width: 8 },
677
+ ],
678
+ });
679
+ for (const h of gitData.fileHotspots) {
680
+ const display = h.path.length > 38 ? '...' + h.path.slice(-35) : h.path;
681
+ hotTable.addRow([display, String(h.touches), String(h.added), String(h.deleted)]);
682
+ }
683
+ lines.push(hotTable.render());
684
+ lines.push('');
685
+ }
686
+ }
687
+
688
+ // 3. 使用场景分布
689
+ if (usageData.scenarios) {
690
+ lines.push('┌─────────────────────────────────────┐');
691
+ lines.push('│ 三、使用场景分布 │');
692
+ lines.push('└─────────────────────────────────────┘');
693
+ lines.push('');
694
+
695
+ const scenarioTable = new Table({
696
+ columns: [
697
+ { title: '场景', width: 15 },
698
+ { title: '次数', width: 10 },
699
+ { title: '占比', width: 8 },
700
+ { title: '可视化', width: 32 },
701
+ ],
702
+ });
703
+
704
+ const total = Object.values(usageData.scenarios).reduce((s, v) => s + v, 0) || 1;
705
+ const sorted = Object.entries(usageData.scenarios)
706
+ .filter(([, v]) => v > 0)
707
+ .sort((a, b) => b[1] - a[1]);
708
+
709
+ for (const [name, count] of sorted) {
710
+ const pct = (count / total) * 100;
711
+ const bar = makeBar(pct, 20);
712
+ scenarioTable.addRow([name, formatInt(count), pct.toFixed(1) + '%', bar + ` ${pct.toFixed(0)}%`]);
713
+ }
714
+ lines.push(scenarioTable.render());
715
+ lines.push('');
716
+ }
717
+
718
+ // 4. 模型使用分布
719
+ if (usageData.models && Object.keys(usageData.models).length > 0) {
720
+ lines.push('┌─────────────────────────────────────┐');
721
+ lines.push('│ 四、模型使用分布 │');
722
+ lines.push('└─────────────────────────────────────┘');
723
+ lines.push('');
724
+
725
+ const modelTotal = Object.values(usageData.models).reduce((s, v) => s + v.count, 0);
726
+ const modelTable = new Table({
727
+ columns: [
728
+ { title: '模型', width: 22 },
729
+ { title: '请求次数', width: 10 },
730
+ { title: '占比', width: 8 },
731
+ { title: '输出 Token', width: 14 },
732
+ ],
733
+ });
734
+
735
+ for (const [model, data] of Object.entries(usageData.models).sort((a, b) => b[1].count - a[1].count)) {
736
+ modelTable.addRow([model, formatInt(data.count), formatPercent(data.count, modelTotal), formatNumber(data.outputTokens)]);
737
+ }
738
+ lines.push(modelTable.render());
739
+ lines.push('');
740
+ }
741
+
742
+ // 5. 项目维度
743
+ if (usageData.projects && Object.keys(usageData.projects).length > 0) {
744
+ lines.push('┌─────────────────────────────────────┐');
745
+ lines.push('│ 五、项目使用分布 │');
746
+ lines.push('└─────────────────────────────────────┘');
747
+ lines.push('');
748
+
749
+ const totalProjRequests = Object.values(usageData.projects).reduce((s, v) => s + v.requests, 0);
750
+ const projTable = new Table({
751
+ columns: [
752
+ { title: '项目', width: 38 },
753
+ { title: '会话', width: 8 },
754
+ { title: '请求', width: 8 },
755
+ { title: '占比', width: 8 },
756
+ ],
757
+ });
758
+
759
+ const activeProjects = Object.entries(usageData.projects)
760
+ .filter(([, data]) => data.sessions > 0 || data.requests > 0)
761
+ .sort((a, b) => b[1].requests - a[1].requests);
762
+
763
+ for (const [proj, data] of activeProjects) {
764
+ const displayName = proj.length > 36 ? '...' + proj.slice(-33) : proj;
765
+ projTable.addRow([displayName, formatInt(data.sessions), formatInt(data.requests), formatPercent(data.requests, totalProjRequests)]);
766
+ }
767
+ lines.push(projTable.render());
768
+ lines.push('');
769
+ }
770
+
771
+ // 6. 工具调用排行
772
+ if (usageData.tools && Object.keys(usageData.tools).length > 0) {
773
+ lines.push('┌─────────────────────────────────────┐');
774
+ lines.push('│ 六、工具调用排行 (Top 10) │');
775
+ lines.push('└─────────────────────────────────────┘');
776
+ lines.push('');
777
+
778
+ const totalToolCalls = Object.values(usageData.tools).reduce((s, v) => s + v, 0);
779
+ const toolTable = new Table({
780
+ columns: [
781
+ { title: '工具', width: 28 },
782
+ { title: '调用次数', width: 10 },
783
+ { title: '占比', width: 8 },
784
+ ],
785
+ });
786
+
787
+ const sortedTools = Object.entries(usageData.tools)
788
+ .sort((a, b) => b[1] - a[1])
789
+ .slice(0, 10);
790
+
791
+ for (const [name, count] of sortedTools) {
792
+ toolTable.addRow([name, formatInt(count), formatPercent(count, totalToolCalls)]);
793
+ }
794
+ lines.push(toolTable.render());
795
+ lines.push('');
796
+ }
797
+
798
+ lines.push('════════════════════════════════════════════════════════════════════');
799
+ lines.push('');
800
+
801
+ return lines.join('\n');
802
+ }
803
+
804
+ function formatPeriodTitle(period, start, end) {
805
+ switch (period) {
806
+ case 'daily': return `日报 ${start}`;
807
+ case 'weekly': return `周报 ${start} ~ ${end}`;
808
+ case 'monthly': return `月报 ${start.slice(0, 7)}`;
809
+ default: return `${start} ~ ${end}`;
810
+ }
811
+ }
812
+
813
+ function formatNumber(n) {
814
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + ' M';
815
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + ' K';
816
+ return n.toLocaleString('zh-CN');
817
+ }
818
+
819
+ function formatInt(n) {
820
+ return n.toLocaleString('zh-CN');
821
+ }
822
+
823
+ function formatPercent(n, total) {
824
+ if (total === 0) return '0.0%';
825
+ return ((n / total) * 100).toFixed(1) + '%';
826
+ }
827
+
828
+ function makeBar(pct, width = 20) {
829
+ const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
830
+ const empty = width - filled;
831
+ return '█'.repeat(filled) + '░'.repeat(empty);
832
+ }
833
+
834
+ // ── 平台输出适配 ──
835
+
836
+ function adaptPlatformOutput(markdown, platform) {
837
+ if (platform === 'dingtalk') return adaptDingtalk(markdown);
838
+ if (platform === 'feishu') return adaptFeishu(markdown);
839
+ return markdown;
840
+ }
841
+
842
+ function adaptDingtalk(md) {
843
+ // 钉钉 Markdown 限制:不支持表格、## 标题渲染不佳
844
+ let out = md;
845
+ // ## 标题 → 加粗 + 分割线
846
+ out = out.replace(/^## (.+)$/gm, '**$1**\n---');
847
+ // Markdown 表格 → 列表(简化处理:整块替换)
848
+ out = out.replace(/\|[-| ]+\|\n/g, ''); // 去掉分隔行
849
+ out = out.replace(/^\|(.+)\|$/gm, (_, content) => {
850
+ const cells = content.split('|').map(c => c.trim());
851
+ if (cells.length >= 2) return `- ${cells.join(':')}`;
852
+ return `- ${content.trim()}`;
853
+ });
854
+ // 反引号路径 → 引号
855
+ out = out.replace(/`([^`]+)`/g, '"$1"');
856
+ return out;
857
+ }
858
+
859
+ function adaptFeishu(md) {
860
+ // 飞书:去掉 Markdown 表格,改用列表
861
+ let out = md;
862
+ out = out.replace(/\|[-| ]+\|\n/g, '');
863
+ out = out.replace(/^\|(.+)\|$/gm, (_, content) => {
864
+ const cells = content.split('|').map(c => c.trim());
865
+ if (cells.length >= 2) return `- ${cells.join(':')}`;
866
+ return `- ${content.trim()}`;
867
+ });
868
+ return out;
869
+ }
870
+
871
+ // ── 飞书卡片 JSON 生成 ──
872
+
873
+ export function generateFeishuCard(usageData, gitData, period, startDate, endDate, tool) {
874
+ const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : '月报';
875
+ const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'weekly' ? `${startDate} ~ ${endDate}` : startDate;
876
+
877
+ const summary = generateAutoSummary(usageData, gitData, null, period, startDate, endDate);
878
+ const elements = [];
879
+
880
+ // 核心叙事
881
+ elements.push({ tag: 'div', text: { tag: 'lark_md', content: summary.paragraphs[0] || '' } });
882
+ elements.push({ tag: 'hr' });
883
+
884
+ // 指标字段
885
+ const fields = [];
886
+ if (usageData.requestCount > 0) {
887
+ fields.push({ is_short: true, text: { tag: 'lark_md', content: `**交互数**\n${formatInt(usageData.requestCount)} 次` } });
888
+ }
889
+ if (usageData.sessionCount > 0) {
890
+ fields.push({ is_short: true, text: { tag: 'lark_md', content: `**会话数**\n${formatInt(usageData.sessionCount)}` } });
891
+ }
892
+ if (gitData?.commits > 0) {
893
+ fields.push({ is_short: true, text: { tag: 'lark_md', content: `**提交数**\n${formatInt(gitData.commits)} 次` } });
894
+ if (gitData.attributionSummary) {
895
+ const s = gitData.attributionSummary;
896
+ const total = s.totalLinesChanged || 1;
897
+ const confirmedPct = Math.round((s.confirmedAILines / total) * 100);
898
+ fields.push({ is_short: true, text: { tag: 'lark_md', content: `**确认 AI**\n${confirmedPct}%` } });
899
+ }
900
+ if (gitData.aiContribution) {
901
+ const ai = gitData.aiContribution;
902
+ const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / gitData.commits)) * 100);
903
+ fields.push({ is_short: true, text: { tag: 'lark_md', content: `**AI 代码改写占比**\n${commitPct}%` } });
904
+ }
905
+ }
906
+ if (usageData.estimatedCost) {
907
+ fields.push({ is_short: true, text: { tag: 'lark_md', content: `**费用**\n$${usageData.estimatedCost.toFixed(2)}` } });
908
+ }
909
+ if (fields.length > 0) {
910
+ elements.push({ tag: 'div', fields });
911
+ elements.push({ tag: 'hr' });
912
+ }
913
+
914
+ // 工作成果
915
+ if (gitData?.commitList?.length) {
916
+ const workLine = buildGitSummaryLine(gitData.commitList);
917
+ if (workLine) {
918
+ elements.push({ tag: 'div', text: { tag: 'lark_md', content: workLine } });
919
+ }
920
+ }
921
+
922
+ return {
923
+ config: { wide_screen_mode: true },
924
+ header: { title: { tag: 'plain_text', content: `${tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手'} 工作${periodLabel} - ${dateLabel}` } },
925
+ elements,
926
+ };
927
+ }
928
+
929
+ // ── 简报生成器 ──
930
+
931
+ export function generateBriefReport(usageData, gitData, period, startDate, endDate, prevData, platform = 'default', tool) {
932
+ const periodName = period === 'daily' ? '今日' : period === 'weekly' ? '本周' : '本月';
933
+ const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : '月报';
934
+ const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'weekly' ? `${startDate} ~ ${endDate}` : startDate;
935
+ const titlePrefix = tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手';
936
+
937
+ const isDingtalk = platform === 'dingtalk';
938
+ const isFeishu = platform === 'feishu';
939
+
940
+ // 钉钉用纯文本+emoji分隔,飞书/标准用 markdown
941
+ const h2 = (text) => isDingtalk ? `─── ${text} ───` : `## ${text}`;
942
+ const bullet = (text) => isDingtalk ? `• ${text}` : `- ${text}`;
943
+ const bold = (text) => `**${text}**`;
944
+ const code = (text) => `\`${text}\``;
945
+
946
+ const lines = [];
947
+
948
+ // 标题
949
+ if (isDingtalk) {
950
+ lines.push(`${titlePrefix} 工作${periodLabel} - ${dateLabel}`);
951
+ } else {
952
+ lines.push(`# ${titlePrefix} 工作${periodLabel} - ${dateLabel}`);
953
+ }
954
+ lines.push('');
955
+
956
+ // 1. 核心叙事(复用 generateAutoSummary)
957
+ const summary = generateAutoSummary(usageData, gitData, prevData, period, startDate, endDate);
958
+ if (summary.paragraphs[0]) {
959
+ lines.push(summary.paragraphs[0]);
960
+ lines.push('');
961
+ }
962
+
963
+ // 2. 核心指标区
964
+ const coreMetrics = [];
965
+ coreMetrics.push(`交互:${fmtN(usageData.requestCount)} 次(${fmtN(usageData.sessionCount)} 会话)`);
966
+ coreMetrics.push(`Token:${fmtToken(usageData.totalTokens)}(输入 ${fmtToken(usageData.inputTokens)} / 输出 ${fmtToken(usageData.outputTokens)})`);
967
+ const projCount = Object.keys(usageData.projects).length;
968
+ if (projCount > 0) coreMetrics.push(`项目:${projCount} 个`);
969
+ if (usageData.estimatedCost) {
970
+ let costLabel = `等效费用:$${usageData.estimatedCost.toFixed(2)}`;
971
+ if (usageData.costMeta?.unknownModels?.length) {
972
+ costLabel += `(不含 ${usageData.costMeta.unknownModels.join('、')},无定价数据)`;
973
+ } else if (!usageData.costMeta?.hasActualCost) {
974
+ costLabel += '(按定价表估算)';
975
+ }
976
+ coreMetrics.push(costLabel);
977
+ }
978
+
979
+ if (coreMetrics.length > 0) {
980
+ lines.push(h2('核心指标'));
981
+ lines.push('');
982
+ for (const m of coreMetrics) lines.push(bullet(m));
983
+ lines.push('');
984
+ }
985
+
986
+ // 3. 项目亮点
987
+ const projects = Object.entries(usageData.projects)
988
+ .filter(([, d]) => d.requests > 0)
989
+ .sort((a, b) => b[1].requests - a[1].requests);
990
+ if (projects.length > 0) {
991
+ const totalReqs = projects.reduce((s, [, d]) => s + d.requests, 0);
992
+ const top = projects[0];
993
+ const topPct = Math.round(top[1].requests / totalReqs * 100);
994
+ const topName = simplifyPath(top[0]);
995
+
996
+ lines.push(h2('项目亮点'));
997
+ lines.push('');
998
+
999
+ if (projects.length === 1) {
1000
+ lines.push(bullet(`主要工作集中在 ${code(topName)},共 ${fmtN(top[1].requests)} 次交互`));
1001
+ } else {
1002
+ lines.push(bullet(`主要工作集中在 ${code(topName)}(占 ${topPct}%,${fmtN(top[1].requests)} 次)`));
1003
+ const others = projects.slice(1, 3).map(([name, data]) => `${code(simplifyPath(name))}(${fmtN(data.requests)} 次)`);
1004
+ if (others.length > 0) {
1005
+ lines.push(bullet(`其他:${others.join('、')}`));
1006
+ }
1007
+ }
1008
+ lines.push('');
1009
+ }
1010
+
1011
+ // 4. 工作成果(Git) — 动态裁剪:提交数少时简化
1012
+ if (gitData && gitData.commits > 0) {
1013
+ lines.push(h2('代码产出'));
1014
+ lines.push('');
1015
+
1016
+ const filesChanged = gitData.filesChanged || 0;
1017
+ const linesAdded = gitData.linesAdded || 0;
1018
+ const linesDeleted = gitData.linesDeleted || 0;
1019
+ lines.push(bullet(`提交 ${fmtN(gitData.commits)} 次,变更 ${fmtN(filesChanged)} 个文件,+${fmtN(linesAdded)}/-${fmtN(linesDeleted)} 行`));
1020
+
1021
+ // AI 参与度概要(3+ 提交时才展示百分比)
1022
+ if (gitData.aiContribution && gitData.commits >= 3) {
1023
+ const ai = gitData.aiContribution;
1024
+ const totalLines = ai.totalLinesChanged || 1;
1025
+ const aiLinePct = Math.round((ai.aiLinesChanged / totalLines) * 100);
1026
+ const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / gitData.commits)) * 100);
1027
+ lines.push(bullet(`${aiLinePct}% 代码变更有 AI 参与,${ai.aiCommits}/${gitData.commits} 提交使用 AI (${commitPct}%)`));
1028
+ }
1029
+
1030
+ if (gitData.commitList?.length) {
1031
+ const workLine = buildGitSummaryLine(gitData.commitList);
1032
+ if (workLine) {
1033
+ const content = workLine.replace(/^主要工作:/, '');
1034
+ const items = content.split(';').filter(Boolean);
1035
+ for (const item of items) {
1036
+ lines.push(bullet(item.trim()));
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ // 归因摘要(3+ 提交时才展示)
1042
+ if (gitData.commits >= 3) {
1043
+ const attributionLine = buildAttributionSummaryLine(gitData.attributionSummary);
1044
+ if (attributionLine) {
1045
+ lines.push(bullet(attributionLine));
1046
+ }
1047
+ }
1048
+
1049
+ // AI 代码改写占比(3+ 提交时才展示)
1050
+ if (gitData.aiContribution && gitData.commits >= 3) {
1051
+ const ai = gitData.aiContribution;
1052
+ const linePct = Math.round(((ai.aiLineRatio ?? ai.aiRatio) || 0) * 100);
1053
+ if ((ai.aiLineRatio ?? ai.aiRatio ?? 0) > 0) {
1054
+ const aiAdded = ai.aiFileLinesAdded || 0;
1055
+ const aiDeleted = ai.aiFileLinesDeleted || 0;
1056
+ lines.push(bullet(`AI 代码改写占比 ${linePct}%,涉及 +${formatInt(aiAdded)}/-${formatInt(aiDeleted)} 行`));
1057
+ }
1058
+ }
1059
+ lines.push('');
1060
+ }
1061
+
1062
+ // 5. 环比变化
1063
+ if (prevData) {
1064
+ const changes = [];
1065
+ const reqPct = pctChange(usageData.requestCount, prevData.requestCount);
1066
+ if (reqPct !== null) changes.push(`交互量${formatPct(reqPct)}`);
1067
+ const tokenPct = pctChange(usageData.totalTokens, prevData.totalTokens);
1068
+ if (tokenPct !== null) changes.push(`Token 消耗${formatPct(tokenPct)}`);
1069
+ const costPct = pctChange(usageData.estimatedCost, prevData.estimatedCost);
1070
+ if (costPct !== null) changes.push(`费用${formatPct(costPct)}`);
1071
+
1072
+ if (changes.length > 0) {
1073
+ const prevName = period === 'daily' ? '昨日' : period === 'weekly' ? '上周' : '上月';
1074
+ lines.push(h2('环比变化'));
1075
+ lines.push('');
1076
+ lines.push(bullet(`相比${prevName},${changes.join('、')}`));
1077
+ lines.push('');
1078
+ }
1079
+ }
1080
+
1081
+ // 6. 效率与成本
1082
+ const efficiencyLines = [];
1083
+ if (usageData.estimatedCost && usageData.estimatedCost > 0) {
1084
+ if (usageData.activeDays > 0) {
1085
+ const dailyCost = (usageData.estimatedCost / usageData.activeDays).toFixed(2);
1086
+ const monthlyEst = (usageData.estimatedCost / usageData.activeDays * 30).toFixed(2);
1087
+ efficiencyLines.push(`日均等效费用 $${dailyCost},月度预估 $${monthlyEst}`);
1088
+ }
1089
+ }
1090
+ const cacheTotal = usageData.cacheRead + usageData.inputTokens;
1091
+ if (cacheTotal > 0) {
1092
+ const cacheRate = Math.round(usageData.cacheRead / cacheTotal * 100);
1093
+ if (cacheRate >= 5) {
1094
+ const cacheText = cacheRate > 60 ? '缓存利用良好' : cacheRate > 30 ? '缓存利用中等' : '可考虑增加上下文复用';
1095
+ efficiencyLines.push(`缓存命中率 ${cacheRate}%,${cacheText}`);
1096
+ }
1097
+ }
1098
+ if (efficiencyLines.length > 0) {
1099
+ lines.push(h2('效率提示'));
1100
+ lines.push('');
1101
+ for (const l of efficiencyLines) lines.push(bullet(l));
1102
+ lines.push('');
1103
+ }
1104
+
1105
+ let result = lines.join('\n').trim();
1106
+
1107
+ // 平台适配
1108
+ if (isDingtalk) {
1109
+ result = adaptDingtalkBrief(result);
1110
+ } else if (isFeishu) {
1111
+ result = adaptFeishuBrief(result);
1112
+ }
1113
+
1114
+ return result;
1115
+ }
1116
+
1117
+ // 钉钉简报适配:去掉 markdown 语法,使用纯文本+emoji
1118
+ function adaptDingtalkBrief(text) {
1119
+ return text
1120
+ .replace(/^#+ /gm, '') // 去掉 markdown 标题
1121
+ .replace(/\*\*(.+?)\*\*/g, '$1') // 去掉加粗
1122
+ .replace(/`([^`]+)`/g, '$1') // 去掉代码标记
1123
+ .replace(/^─── (.+) ───$/gm, '━ $1 ━') // 分隔线风格统一
1124
+ .trim();
1125
+ }
1126
+
1127
+ // 飞书简报适配:保留 markdown,简化表格
1128
+ function adaptFeishuBrief(text) {
1129
+ return text.trim();
1130
+ }
1131
+
1132
+ // ── 详报生成器(支持简报/详报分级 + 平台适配)──
1133
+
1134
+ const TOOL_LABELS = {
1135
+ claude: 'Claude Code',
1136
+ codex: 'Codex',
1137
+ opencode: 'OpenCode',
1138
+ };
1139
+
1140
+ function toolTitle(tool) {
1141
+ return TOOL_LABELS[tool] || 'AI 编码助手';
1142
+ }
1143
+
1144
+ export function generateWorkReport(usageData, gitData, period, startDate, endDate, prevData, options) {
1145
+ // 向后兼容:options 可以是字符串(旧 platform 参数)
1146
+ const opts = typeof options === 'string'
1147
+ ? { level: 'detailed', platform: options }
1148
+ : { level: 'detailed', platform: 'default', ...options };
1149
+ const { level, platform: fmt, tool } = opts;
1150
+ const titlePrefix = tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手';
1151
+
1152
+ // 简报路由
1153
+ if (level === 'brief') {
1154
+ return generateBriefReport(usageData, gitData, period, startDate, endDate, prevData, fmt, tool);
1155
+ }
1156
+ const lines = [];
1157
+ const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : '月报';
1158
+ const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'weekly' ? `${startDate} ~ ${endDate}` : startDate;
1159
+
1160
+ lines.push(`# ${titlePrefix} 工作${periodLabel} - ${dateLabel}`);
1161
+ lines.push('');
1162
+
1163
+ // 动态编号:收集要渲染的板块,最后统一编号
1164
+ const sections = [];
1165
+
1166
+ // 板块 1:工作概述(始终存在)
1167
+ {
1168
+ const sectionLines = [];
1169
+ const summary = generateAutoSummary(usageData, gitData, prevData, period, startDate, endDate);
1170
+ for (const p of summary.paragraphs) {
1171
+ sectionLines.push(p);
1172
+ sectionLines.push('');
1173
+ }
1174
+ const coreInsight = buildCoreInsight(usageData, summary.periodName);
1175
+ if (coreInsight) {
1176
+ sectionLines.push(`> ${coreInsight}`);
1177
+ sectionLines.push('');
1178
+ }
1179
+ if (prevData) {
1180
+ const deepChange = buildDeepChangeNarrative(usageData, prevData);
1181
+ if (deepChange) {
1182
+ sectionLines.push(deepChange);
1183
+ sectionLines.push('');
1184
+ }
1185
+ }
1186
+ if (period !== 'daily' && usageData.dailyStats) {
1187
+ const trendLine = buildDailyTrendInsight(usageData.dailyStats, period);
1188
+ if (trendLine) {
1189
+ sectionLines.push(trendLine);
1190
+ sectionLines.push('');
1191
+ }
1192
+ }
1193
+ sections.push({ title: '工作概述', lines: sectionLines });
1194
+ }
1195
+
1196
+ // 板块 2:项目进展
1197
+ const activeProjects = Object.entries(usageData.projects)
1198
+ .filter(([, data]) => data.sessions > 0 || data.requests > 0)
1199
+ .sort((a, b) => b[1].requests - a[1].requests);
1200
+
1201
+ if (activeProjects.length > 0) {
1202
+ const sectionLines = [];
1203
+ const totalProjRequests = Object.values(usageData.projects).reduce((s, v) => s + v.requests, 0);
1204
+
1205
+ sectionLines.push('| 项目 | 会话数 | 请求数 | 占比 |');
1206
+ sectionLines.push('|------|--------|--------|------|');
1207
+ for (const [proj, data] of activeProjects) {
1208
+ const displayName = proj.length > 40 ? '...' + proj.slice(-37) : proj;
1209
+ sectionLines.push(`| ${displayName} | ${formatInt(data.sessions)} | ${formatInt(data.requests)} | ${formatPercent(data.requests, totalProjRequests)} |`);
1210
+ }
1211
+ sectionLines.push('');
1212
+ const projInsight = buildProjectInsight(usageData.projects);
1213
+ if (projInsight) {
1214
+ sectionLines.push(`> ${projInsight}`);
1215
+ sectionLines.push('');
1216
+ }
1217
+ sections.push({ title: '项目进展', lines: sectionLines });
1218
+ }
1219
+
1220
+ // 板块 3:工作类型分布
1221
+ if (usageData.scenarios) {
1222
+ const sectionLines = [];
1223
+ const total = Object.values(usageData.scenarios).reduce((s, v) => s + v, 0) || 1;
1224
+ const sorted = Object.entries(usageData.scenarios)
1225
+ .filter(([, v]) => v > 0)
1226
+ .sort((a, b) => b[1] - a[1]);
1227
+
1228
+ for (const [name, count] of sorted) {
1229
+ const pct = (count / total) * 100;
1230
+ sectionLines.push(`- **${name}**:${pct.toFixed(1)}%(${formatInt(count)} 次)`);
1231
+ }
1232
+ sectionLines.push('');
1233
+ const sceneInsight = buildScenarioInsight(usageData.scenarios);
1234
+ if (sceneInsight) {
1235
+ sectionLines.push(`> ${sceneInsight}`);
1236
+ sectionLines.push('');
1237
+ }
1238
+ sections.push({ title: '工作类型分布', lines: sectionLines });
1239
+ }
1240
+
1241
+ // 板块 4:代码产出
1242
+ if (gitData && (gitData.commits > 0 || gitData.filesChanged > 0)) {
1243
+ const sectionLines = [];
1244
+
1245
+ // 4a. 工作成果叙事
1246
+ if (gitData.commitList?.length) {
1247
+ const narrative = buildCommitNarrative(gitData.commitList, { projectGroup: true, maxItems: 6 });
1248
+ if (narrative) {
1249
+ for (const proj of narrative) {
1250
+ if (narrative.length > 1) {
1251
+ sectionLines.push(`### ${proj.project}`);
1252
+ sectionLines.push('');
1253
+ }
1254
+ for (const sec of proj.sections) {
1255
+ const aiTag = sec.aiCount > 0 ? `(含 AI 辅助 ${sec.aiCount} 项)` : '';
1256
+ sectionLines.push(`**${sec.label}**${aiTag}:`);
1257
+ for (const item of sec.items) {
1258
+ sectionLines.push(` - ${item}`);
1259
+ }
1260
+ if (sec.overflow > 0) {
1261
+ sectionLines.push(` - ...及其他 ${sec.overflow} 项`);
1262
+ }
1263
+ sectionLines.push('');
1264
+ }
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ // 4b. 数字概要
1270
+ sectionLines.push(`> 提交 ${formatInt(gitData.commits)} 次,变更 ${formatInt(gitData.filesChanged)} 个文件,+${formatInt(gitData.linesAdded)}/-${formatInt(gitData.linesDeleted)} 行`);
1271
+ sectionLines.push('');
1272
+
1273
+ const attributionLine = buildAttributionSummaryLine(gitData.attributionSummary);
1274
+ if (attributionLine) {
1275
+ sectionLines.push(`- ${attributionLine.replace('AI 归因汇总:', '**AI 归因汇总**:')}`);
1276
+ }
1277
+ const unknownReasonLine = buildUnknownReasonLine(gitData.attributionSummary);
1278
+ if (unknownReasonLine) {
1279
+ sectionLines.push(`- ${unknownReasonLine}`);
1280
+ }
1281
+ if (attributionLine || unknownReasonLine) sectionLines.push('');
1282
+
1283
+ // 4c. AI 贡献明细
1284
+ if (gitData.commitList?.length) {
1285
+ const aiDetail = buildAIContributionDetail(gitData.commitList);
1286
+ if (aiDetail) {
1287
+ sectionLines.push('**AI 协作详情**:');
1288
+ sectionLines.push('');
1289
+ const totalCommits = gitData.commits;
1290
+ const totalAI = aiDetail.explicit.length + aiDetail.sessionStrong.length + aiDetail.fileOverlap.length;
1291
+ const aiLinePct = Math.round(((gitData.aiContribution?.aiLineRatio ?? gitData.aiContribution?.aiRatio) || 0) * 100);
1292
+ sectionLines.push(`- 高/中置信 AI 提交 **${totalAI}/${totalCommits}**(${aiLinePct}%),涉及 +${formatInt(aiDetail.totalAIFileAdded)}/-${formatInt(aiDetail.totalAIFileDeleted)} 行`);
1293
+
1294
+ if (aiDetail.explicit.length > 0) {
1295
+ sectionLines.push(`- **显式 AI**(${aiDetail.explicit.length} 项):${aiDetail.explicit.map(s => `\`${s}\``).join('、')}`);
1296
+ }
1297
+ if (aiDetail.sessionStrong.length > 0) {
1298
+ sectionLines.push(`- **强关联**(${aiDetail.sessionStrong.length} 项):${aiDetail.sessionStrong.map(s => `\`${s}\``).join('、')}`);
1299
+ }
1300
+ if (aiDetail.fileOverlap.length > 0) {
1301
+ sectionLines.push(`- **文件重叠**(${aiDetail.fileOverlap.length} 项):${aiDetail.fileOverlap.map(s => `\`${s}\``).join('、')}`);
1302
+ }
1303
+ if (aiDetail.aiFiles.length > 0) {
1304
+ const topFiles = aiDetail.aiFiles.slice(0, 8).map(f => `\`${f}\``).join('、');
1305
+ const overflow = aiDetail.aiFiles.length > 8 ? ` 等 ${aiDetail.aiFiles.length} 个` : '';
1306
+ sectionLines.push(`- **AI 涉及文件**:${topFiles}${overflow}`);
1307
+ }
1308
+ sectionLines.push('');
1309
+ }
1310
+ }
1311
+ const gitInsight = buildGitInsight(gitData);
1312
+ if (gitInsight) {
1313
+ sectionLines.push(`> ${gitInsight}`);
1314
+ sectionLines.push('');
1315
+ }
1316
+ sections.push({ title: '代码产出', lines: sectionLines });
1317
+ }
1318
+
1319
+ // 板块:成本与效率
1320
+ if (usageData.estimatedCost) {
1321
+ const sectionLines = [];
1322
+ sectionLines.push(`- **预估等效费用**:$${usageData.estimatedCost.toFixed(2)}`);
1323
+ if (usageData.activeDays > 0) {
1324
+ const dailyCost = (usageData.estimatedCost / usageData.activeDays).toFixed(2);
1325
+ const monthlyEst = (usageData.estimatedCost / usageData.activeDays * 30).toFixed(2);
1326
+ sectionLines.push(`- **日均费用**:$${dailyCost}`);
1327
+ sectionLines.push(`- **月度预估**:$${monthlyEst}`);
1328
+ }
1329
+ // 费用准确性声明
1330
+ if (usageData.costMeta) {
1331
+ const meta = usageData.costMeta;
1332
+ const notes = [];
1333
+ if (meta.hasActualCost) notes.push('部分数据来自 API 实际计费');
1334
+ const estimatedModels = Object.entries(meta.modelPricingStatus || {})
1335
+ .filter(([, v]) => v === 'estimated')
1336
+ .map(([k]) => k);
1337
+ if (estimatedModels.length > 0) notes.push(`${estimatedModels.join('、')} 按官方定价表估算`);
1338
+ if (meta.unknownModels?.length > 0) notes.push(`${meta.unknownModels.join('、')} 无定价数据,未计入费用`);
1339
+ if (notes.length > 0) {
1340
+ sectionLines.push(`- **计费说明**:${notes.join(';')}`);
1341
+ }
1342
+ }
1343
+ // 优化建议
1344
+ const suggestions = buildSuggestions(usageData);
1345
+ if (suggestions.length > 0) {
1346
+ sectionLines.push('');
1347
+ sectionLines.push('**优化建议**:');
1348
+ for (const s of suggestions) {
1349
+ sectionLines.push(`- ${s}`);
1350
+ }
1351
+ }
1352
+ sectionLines.push('');
1353
+ const costInsight = buildCostInsight(usageData);
1354
+ if (costInsight) {
1355
+ sectionLines.push(`> ${costInsight}`);
1356
+ sectionLines.push('');
1357
+ }
1358
+ sections.push({ title: '成本与效率', lines: sectionLines });
1359
+ }
1360
+
1361
+ // 板块:工具使用模式
1362
+ if (usageData.tools && Object.keys(usageData.tools).length > 0) {
1363
+ const sectionLines = [];
1364
+ const sortedTools = Object.entries(usageData.tools).sort((a, b) => b[1] - a[1]);
1365
+ const totalCalls = sortedTools.reduce((s, [, v]) => s + v, 0) || 1;
1366
+
1367
+ // 分类统计
1368
+ const categories = { '代码编辑': 0, '代码阅读': 0, '执行/运行': 0, '规划管理': 0, '搜索研究': 0, '其他': 0 };
1369
+ const TOOL_CATEGORIES = {
1370
+ Write: '代码编辑', Edit: '代码编辑', NotebookEdit: '代码编辑', replace_symbol_body: '代码编辑',
1371
+ replace_content: '代码编辑', insert_before_symbol: '代码编辑', insert_after_symbol: '代码编辑',
1372
+ write: '代码编辑', edit: '代码编辑',
1373
+ Read: '代码阅读', Glob: '代码阅读', Grep: '代码阅读', find_symbol: '代码阅读',
1374
+ find_declaration: '代码阅读', find_referencing_symbols: '代码阅读', find_implementations: '代码阅读',
1375
+ get_symbols_overview: '代码阅读', initial_instructions: '代码阅读', get_diagnostics_for_file: '代码阅读',
1376
+ glob: '代码阅读', read: '代码阅读',
1377
+ Bash: '执行/运行', shell_command: '执行/运行', bash: '执行/运行',
1378
+ TaskCreate: '规划管理', TaskUpdate: '规划管理', TaskList: '规划管理', Agent: '规划管理',
1379
+ EnterPlanMode: '规划管理', ExitPlanMode: '规划管理', update_plan: '规划管理', todowrite: '规划管理',
1380
+ WebSearch: '搜索研究', WebFetch: '搜索研究', view_image: '代码阅读',
1381
+ };
1382
+
1383
+ for (const [name, count] of sortedTools) {
1384
+ let cat = TOOL_CATEGORIES[name];
1385
+ if (!cat) {
1386
+ // MCP 工具按前缀分类
1387
+ if (name.startsWith('mcp__Playwright') || name.startsWith('browser_')) cat = '执行/运行';
1388
+ else if (name.startsWith('mcp__context7') || name.startsWith('mcp__open-websearch') || name.startsWith('mcp__mcp-deepwiki') || name.startsWith('mcp__web_reader')) cat = '搜索研究';
1389
+ else if (name.startsWith('mcp__serena')) cat = '代码编辑';
1390
+ else cat = '其他';
1391
+ }
1392
+ categories[cat] += count;
1393
+ }
1394
+
1395
+ const activeCats = Object.entries(categories).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
1396
+ for (const [cat, count] of activeCats) {
1397
+ const pct = Math.round((count / totalCalls) * 100);
1398
+ sectionLines.push(`- **${cat}**:${pct}%(${formatInt(count)} 次)`);
1399
+ }
1400
+
1401
+ // Top 3 工具
1402
+ const top3 = sortedTools.slice(0, 3);
1403
+ if (top3.length > 0) {
1404
+ sectionLines.push('');
1405
+ sectionLines.push(`> 使用最频繁的工具:${top3.map(([n, c]) => `${n}(${formatInt(c)} 次)`).join('、')}。`);
1406
+ }
1407
+ sectionLines.push('');
1408
+ sections.push({ title: '工具使用模式', lines: sectionLines });
1409
+ }
1410
+
1411
+ // 统一渲染板块,动态编号
1412
+ const cnNums = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
1413
+ for (let i = 0; i < sections.length; i++) {
1414
+ const num = cnNums[i] || (i + 1);
1415
+ lines.push(`## ${num}、${sections[i].title}`);
1416
+ lines.push('');
1417
+ for (const l of sections[i].lines) {
1418
+ lines.push(l);
1419
+ }
1420
+ }
1421
+
1422
+ return adaptPlatformOutput(lines.join('\n'), fmt);
1423
+ }
1424
+
1425
+ function buildSuggestions(stats) {
1426
+ const tips = [];
1427
+ // 缓存命中率
1428
+ const cacheTotal = stats.cacheRead + stats.inputTokens;
1429
+ if (cacheTotal > 0) {
1430
+ const cacheRate = Math.round(stats.cacheRead / cacheTotal * 100);
1431
+ if (cacheRate < 20) tips.push('缓存命中率较低(' + cacheRate + '%),建议增加上下文复用以降低费用');
1432
+ }
1433
+ // 输出占比
1434
+ if (stats.totalTokens > 0) {
1435
+ const outputRatio = stats.outputTokens / stats.totalTokens;
1436
+ if (outputRatio > 0.6) tips.push('输出 Token 占比较高(' + Math.round(outputRatio * 100) + '%),考虑更精确的提示词以减少冗余输出');
1437
+ }
1438
+ // 子 agent 占比
1439
+ if (stats.subagentTokens > 0 && stats.totalTokens > 0) {
1440
+ const subRatio = stats.subagentTokens / stats.totalTokens;
1441
+ if (subRatio > 0.4) tips.push('子 agent 消耗占 ' + Math.round(subRatio * 100) + '%,关注是否存在过度拆分任务的情况');
1442
+ }
1443
+ return tips;
1444
+ }
1445
+
1446
+