token-studio 4.8.0

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 (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
@@ -0,0 +1,401 @@
1
+ import {
2
+ buildProjectRoiRows,
3
+ buildReviewUnattributedSessions,
4
+ buildRiskDistribution,
5
+ buildUnattributedSessions,
6
+ sessionProjectLabel
7
+ } from '../dashboard/attribution.js';
8
+ import { buildRoiEvidence } from './roi-evidence.js';
9
+
10
+ const PRODUCTIVE_STATUSES = new Set(['已完成', '已发布']);
11
+
12
+ export function buildMarkdownReviewReport({
13
+ period,
14
+ daily = [],
15
+ sessions = [],
16
+ workItems = [],
17
+ roiAdvice = [],
18
+ savingsSimulation = null,
19
+ advisorActions = [],
20
+ insights = [],
21
+ generatedAt = new Date()
22
+ } = {}) {
23
+ const totals = aggregateDaily(daily);
24
+ const projectRows = buildProjectRoiRows(sessions).slice(0, 8);
25
+ const modelRows = buildModelRows(daily).slice(0, 8);
26
+ const outputRows = buildOutputRows(sessions).slice(0, 12);
27
+ const riskRows = buildRiskDistribution(sessions);
28
+ const attributionGapRows = buildAttributionGapRows(sessions).slice(0, 10);
29
+ const attributionBreakdown = buildAttributionBreakdown(sessions);
30
+ const roiEvidence = buildRoiEvidence({ sessions, workItems });
31
+ const actionItems = buildActionItems({ roiAdvice, sessions, outputRows });
32
+ const actionStatusRows = buildAdvisorActionRows(advisorActions, period);
33
+
34
+ return [
35
+ '# Token Studio Weekly Review',
36
+ '',
37
+ `- 生成时间:${safeText(formatDateTime(generatedAt))}`,
38
+ `- 复盘周期:${safeText(period?.pretty || `${period?.start || ''} - ${period?.end || ''}` || '当前筛选周期')}`,
39
+ `- 数据口径:本地结构化用量、人工标注、自动归因和产出链接;不包含对话正文。`,
40
+ '',
41
+ '## 1. 本期总览',
42
+ '',
43
+ table(
44
+ ['指标', '数值'],
45
+ [
46
+ ['Session 数', formatInt(sessions.length)],
47
+ ['总 tokens', compactCN(totals.totalTokens)],
48
+ ['输入 tokens', compactCN(totals.inputTokens)],
49
+ ['输出 tokens', compactCN(totals.outputTokens)],
50
+ ['Cache read tokens', compactCN(totals.cacheReadTokens)],
51
+ ['官方价换算', money(totals.costUSD)],
52
+ ['未归因 session', formatInt(buildUnattributedSessions(sessions).length)],
53
+ ['人工确认归因', formatInt(attributionBreakdown.manual)],
54
+ ['自动高置信归因', formatInt(attributionBreakdown.autoHigh)],
55
+ ['自动低置信 / 待确认', formatInt(attributionBreakdown.autoLow + attributionBreakdown.missing)],
56
+ ['ROI 证据完整度', `${roiEvidence.evidenceScore}/100`],
57
+ ['Work items', formatInt(roiEvidence.workItemCount)]
58
+ ]
59
+ ),
60
+ '',
61
+ '## 2. 成本最高项目',
62
+ '',
63
+ projectRows.length ? table(
64
+ ['项目', 'Sessions', 'Tokens', '官方价', '完成/发布占比', '风险占比'],
65
+ projectRows.map(row => [
66
+ row.project,
67
+ row.sessionCount,
68
+ compactCN(row.totalTokens),
69
+ money(row.costUSD),
70
+ pct(row.productiveShare),
71
+ pct(row.riskShare)
72
+ ])
73
+ ) : '本期没有项目数据。',
74
+ '',
75
+ '## 3. 模型使用分布',
76
+ '',
77
+ modelRows.length ? table(
78
+ ['模型', '来源', 'Tokens', '官方价', '占比'],
79
+ modelRows.map(row => [
80
+ row.model,
81
+ row.source,
82
+ compactCN(row.totalTokens),
83
+ row.costUSD > 0 ? money(row.costUSD) : '未定价/无官方价',
84
+ pct(row.share)
85
+ ])
86
+ ) : '本期没有模型数据。',
87
+ '',
88
+ '## 4. 已完成 / 已发布产出',
89
+ '',
90
+ outputRows.length ? table(
91
+ ['状态', '类型', '标签', '项目', '链接'],
92
+ outputRows.map(row => [
93
+ row.outputStatus,
94
+ row.outputType || '未分类',
95
+ row.outputLabel || row.outputUrl || row.sessionId,
96
+ sessionProjectLabel(row),
97
+ markdownLink(row.outputLabel || row.outputUrl || '产出链接', row.outputUrl)
98
+ ])
99
+ ) : '本期没有已完成/已发布的产出链接。建议先给高价值 session 补 PR、commit、文章、部署、文档或截图链接。',
100
+ '',
101
+ '## 5. 风险成本',
102
+ '',
103
+ riskRows.length ? table(
104
+ ['风险类型', 'Sessions', 'Tokens', '官方价', '占比'],
105
+ riskRows.map(row => [
106
+ row.label,
107
+ row.sessionCount,
108
+ compactCN(row.totalTokens),
109
+ money(row.costUSD),
110
+ pct(row.share)
111
+ ])
112
+ ) : '本期没有明显风险成本。',
113
+ '',
114
+ '### 高成本待补齐归因',
115
+ '',
116
+ attributionGapRows.length ? table(
117
+ ['优先级', '项目', 'Session', '缺失字段', '归因来源', 'Tokens', '官方价', '最后活动'],
118
+ attributionGapRows.map((row, index) => [
119
+ index + 1,
120
+ row.project,
121
+ row.sessionId,
122
+ row.missingFields.join('、'),
123
+ row.attributionLabel,
124
+ compactCN(row.totalTokens),
125
+ row.costUSD > 0 ? money(row.costUSD) : '未定价/无官方价',
126
+ row.lastActivity || ''
127
+ ])
128
+ ) : '本期没有待补齐的高成本归因 session。',
129
+ '',
130
+ '## 6. 节省模拟',
131
+ '',
132
+ savingsSimulation?.suggestions?.length ? [
133
+ '官方价换算节省模拟只用于比较模型策略,不是供应商账单。',
134
+ '',
135
+ table(
136
+ ['建议', '当前层级', '建议层级', 'Sessions', 'Tokens', '当前官方价', '模拟后官方价', '可节省', '原因'],
137
+ savingsSimulation.suggestions.slice(0, 5).map(row => [
138
+ row.title,
139
+ tierLabel(row.currentTier),
140
+ tierLabel(row.suggestedTier),
141
+ row.sessionCount,
142
+ compactCN(row.totalTokens),
143
+ money(row.currentCostUSD),
144
+ money(row.simulatedCostUSD),
145
+ money(row.savingsUSD),
146
+ row.why
147
+ ])
148
+ )
149
+ ].join('\n') : '本期没有触发可计算的官方价节省建议。高价值已完成/已发布任务不会被建议降级模型。',
150
+ '',
151
+ savingsSimulation?.unpriced?.sessionCount ? `未纳入成本决策:${formatInt(savingsSimulation.unpriced.sessionCount)} 个 session、${compactCN(savingsSimulation.unpriced.totalTokens)} tokens 没有公开官方美元价,模型包括 ${safeText(savingsSimulation.unpriced.models.join('、') || 'unknown')}。` : '',
152
+ '',
153
+ '## 7. ROI Advisor 建议',
154
+ '',
155
+ roiAdvice.length ? roiAdvice.map((item, index) => [
156
+ `### ${index + 1}. ${safeText(item.title)}`,
157
+ '',
158
+ `- 建议分类:${safeText(item.category || '未分类')}`,
159
+ `- 影响级别:${safeText(item.impact || '未标注')}`,
160
+ `- 建议:${safeText(item.recommendation)}`,
161
+ `- 原因:${safeText(item.reason)}`,
162
+ `- 证据:${safeText(item.evidence)}`,
163
+ `- 建议动作:${safeText(item.action)}`
164
+ ].join('\n')).join('\n\n') : '本期没有触发 ROI Advisor 建议。',
165
+ '',
166
+ '## 8. 本周行动状态',
167
+ '',
168
+ actionStatusRows.length ? table(
169
+ ['状态', '分类', '建议', '行动'],
170
+ actionStatusRows.map(row => [
171
+ statusLabel(row.status),
172
+ row.category,
173
+ row.title,
174
+ row.action
175
+ ])
176
+ ) : '本期还没有加入行动清单的 Advisor / 节省模拟建议。',
177
+ '',
178
+ '说明:完成行动只表示复盘流程状态;报告只展示行动前后同类 token / 官方价趋势,不证明真实因果节省。',
179
+ '',
180
+ '## 9. 下周行动清单',
181
+ '',
182
+ actionItems.length ? actionItems.map(item => `- ${safeText(item)}`).join('\n') : '- 保持当前模型和上下文使用策略,继续补充真实产出链接。',
183
+ '',
184
+ '## 10. 口径说明',
185
+ '',
186
+ '- 金额为官方公开 token 单价换算,不是供应商账单或财务对账结果。',
187
+ '- 节省模拟使用当前 token 结构和已配置官方价模型做策略比较,不承诺真实账单节省。',
188
+ '- ChatGPT 套餐额度、企业折扣、税费、区域价、Batch/Flex/Priority 和特殊长上下文计费不会自动套用。',
189
+ '- 未公开官方美元价的模型保持“未定价”,不会按 $0 参与成本决策。',
190
+ '- 自动归因是基于结构化元数据的规则推断,不等同人工确认;高成本自动项建议抽查。',
191
+ '- 报告只使用本地结构化用量、人工标注、自动归因和产出链接,不读取、不导出对话正文。',
192
+ '- 产出链接只记录 URL、标签和类型;Token Studio 不抓取链接内容。'
193
+ ].join('\n');
194
+ }
195
+
196
+ export function buildReviewReportFilename(period, today = new Date()) {
197
+ const suffix = period?.end || formatDate(today);
198
+ return `token-studio-review-${suffix}.md`;
199
+ }
200
+
201
+ export function buildModelRows(daily = []) {
202
+ const rows = new Map();
203
+ const totalTokens = daily.reduce((sum, row) => sum + (row.totalTokens || 0), 0);
204
+ for (const row of daily) {
205
+ const model = row.model || '<unknown>';
206
+ const source = row.source || 'unknown';
207
+ const key = `${model}::${source}`;
208
+ if (!rows.has(key)) {
209
+ rows.set(key, { model, source, totalTokens: 0, costUSD: 0 });
210
+ }
211
+ const acc = rows.get(key);
212
+ acc.totalTokens += row.totalTokens || 0;
213
+ acc.costUSD += row.costUSD || 0;
214
+ }
215
+ return Array.from(rows.values())
216
+ .map(row => ({
217
+ ...row,
218
+ share: totalTokens ? row.totalTokens / totalTokens : 0
219
+ }))
220
+ .sort((a, b) => b.totalTokens - a.totalTokens);
221
+ }
222
+
223
+ function buildOutputRows(sessions = []) {
224
+ return sessions
225
+ .filter(session => PRODUCTIVE_STATUSES.has(session.outputStatus) && session.outputUrl)
226
+ .sort((a, b) => (b.totalTokens || 0) - (a.totalTokens || 0));
227
+ }
228
+
229
+ function buildAttributionGapRows(sessions = []) {
230
+ return buildReviewUnattributedSessions(sessions).map(session => ({
231
+ project: sessionProjectLabel(session),
232
+ sessionId: session.sessionId || '',
233
+ missingFields: missingAttributionFields(session),
234
+ attributionLabel: attributionLabel(session),
235
+ totalTokens: session.totalTokens || 0,
236
+ costUSD: session.costUSD || 0,
237
+ lastActivity: session.lastActivity || session.lastSeenAt || session.updatedAt || ''
238
+ }));
239
+ }
240
+
241
+ function buildAttributionBreakdown(sessions = []) {
242
+ return sessions.reduce((acc, session) => {
243
+ if (session.annotationSource === 'auto') {
244
+ if (Number(session.annotationConfidence || 0) >= 80) acc.autoHigh += 1;
245
+ else acc.autoLow += 1;
246
+ } else if (session.annotationSource === 'manual' || session.annotationSource === 'imported') {
247
+ acc.manual += 1;
248
+ } else {
249
+ acc.missing += 1;
250
+ }
251
+ return acc;
252
+ }, { manual: 0, autoHigh: 0, autoLow: 0, missing: 0 });
253
+ }
254
+
255
+ function attributionLabel(session = {}) {
256
+ if (session.annotationSource === 'auto') return `auto ${Number(session.annotationConfidence || 0)}%`;
257
+ if (session.annotationSource === 'manual') return 'manual';
258
+ if (session.annotationSource === 'imported') return 'imported';
259
+ return 'missing';
260
+ }
261
+
262
+ function missingAttributionFields(session = {}) {
263
+ const fields = [];
264
+ if ((session.taskType || '未分类') === '未分类') fields.push('任务类型');
265
+ if ((session.outputStatus || '未标注') === '未标注') fields.push('产出状态');
266
+ if ((session.workPurpose || '未说明') === '未说明') fields.push('工作目的');
267
+ if ((session.workStage || '未说明') === '未说明') fields.push('工作阶段');
268
+ if ((session.valueLevel || '未评估') === '未评估') fields.push('产出价值');
269
+ return fields;
270
+ }
271
+
272
+ function buildActionItems({ roiAdvice = [], sessions = [], outputRows = [] }) {
273
+ const items = roiAdvice.slice(0, 3).map(item => item.action).filter(Boolean);
274
+ const unattributed = buildUnattributedSessions(sessions).slice(0, 3);
275
+ for (const session of unattributed) {
276
+ items.push(`补齐 ${sessionProjectLabel(session)} 的 session 标注:任务、目的、阶段、价值和产出状态。`);
277
+ }
278
+ if (!outputRows.length) {
279
+ items.push('给已完成或已发布的高价值 session 补充 PR、commit、文章、部署、文档或截图链接。');
280
+ }
281
+ return Array.from(new Set(items)).slice(0, 8);
282
+ }
283
+
284
+ function buildAdvisorActionRows(actions = [], period = {}) {
285
+ return actions
286
+ .filter(action => !period?.start || (
287
+ action.periodStart === period.start && action.periodEnd === period.end
288
+ ))
289
+ .sort((a, b) => statusRank(a.status) - statusRank(b.status) || String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
290
+ .slice(0, 12);
291
+ }
292
+
293
+ function statusRank(status) {
294
+ if (status === 'open') return 0;
295
+ if (status === 'done') return 1;
296
+ return 2;
297
+ }
298
+
299
+ function statusLabel(status) {
300
+ if (status === 'done') return '已完成';
301
+ if (status === 'dismissed') return '已忽略';
302
+ return '行动中';
303
+ }
304
+
305
+ function aggregateDaily(daily = []) {
306
+ return daily.reduce((acc, row) => {
307
+ acc.totalTokens += row.totalTokens || 0;
308
+ acc.inputTokens += row.inputTokens || 0;
309
+ acc.outputTokens += row.outputTokens || 0;
310
+ acc.cacheReadTokens += row.cacheReadTokens || 0;
311
+ acc.cacheCreationTokens += row.cacheCreationTokens || 0;
312
+ acc.reasoningOutputTokens += row.reasoningOutputTokens || 0;
313
+ acc.costUSD += row.costUSD || 0;
314
+ return acc;
315
+ }, {
316
+ totalTokens: 0,
317
+ inputTokens: 0,
318
+ outputTokens: 0,
319
+ cacheReadTokens: 0,
320
+ cacheCreationTokens: 0,
321
+ reasoningOutputTokens: 0,
322
+ costUSD: 0
323
+ });
324
+ }
325
+
326
+ function table(headers, rows) {
327
+ return [
328
+ `| ${headers.map(safeCell).join(' | ')} |`,
329
+ `| ${headers.map(() => '---').join(' | ')} |`,
330
+ ...rows.map(row => `| ${row.map(safeCell).join(' | ')} |`)
331
+ ].join('\n');
332
+ }
333
+
334
+ function safeCell(value) {
335
+ const text = safeText(value);
336
+ const formulaSafe = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
337
+ return formulaSafe.replace(/\|/g, '\\|');
338
+ }
339
+
340
+ function safeText(value) {
341
+ return String(value ?? '')
342
+ .replace(/\r?\n/g, ' ')
343
+ .replace(/\s+/g, ' ')
344
+ .trim();
345
+ }
346
+
347
+ function markdownLink(label, url) {
348
+ const text = safeText(label || url || '链接');
349
+ const href = safeText(url);
350
+ if (!/^https?:\/\//i.test(href)) return text || '—';
351
+ return `[${text.replace(/[[\]]/g, '')}](${href.replace(/[()]/g, encodeURIComponent)})`;
352
+ }
353
+
354
+ function compactCN(value) {
355
+ const v = Number(value || 0);
356
+ const abs = Math.abs(v);
357
+ if (abs >= 1e8) return `${(v / 1e8).toFixed(2).replace(/\.?0+$/, '')} 亿`;
358
+ if (abs >= 1e4) return `${(v / 1e4).toFixed(1).replace(/\.0$/, '')} 万`;
359
+ return formatInt(v);
360
+ }
361
+
362
+ function formatInt(value) {
363
+ return new Intl.NumberFormat('zh-CN').format(Math.round(Number(value || 0)));
364
+ }
365
+
366
+ function money(value) {
367
+ return new Intl.NumberFormat('en-US', {
368
+ style: 'currency',
369
+ currency: 'USD',
370
+ minimumFractionDigits: 2,
371
+ maximumFractionDigits: 2
372
+ }).format(Number(value || 0));
373
+ }
374
+
375
+ function pct(value) {
376
+ return `${(Number(value || 0) * 100).toFixed(1)}%`;
377
+ }
378
+
379
+ function tierLabel(tier) {
380
+ if (tier === 'heavy') return '重模型';
381
+ if (tier === 'mid') return '中模型';
382
+ if (tier === 'light') return '轻量模型';
383
+ if (tier === 'unpriced') return '未定价';
384
+ return '未分层';
385
+ }
386
+
387
+ function formatDate(value) {
388
+ const d = value instanceof Date ? value : new Date(value);
389
+ if (Number.isNaN(d.getTime())) return '';
390
+ return [
391
+ d.getFullYear(),
392
+ String(d.getMonth() + 1).padStart(2, '0'),
393
+ String(d.getDate()).padStart(2, '0')
394
+ ].join('-');
395
+ }
396
+
397
+ function formatDateTime(value) {
398
+ const d = value instanceof Date ? value : new Date(value);
399
+ if (Number.isNaN(d.getTime())) return '';
400
+ return `${formatDate(d)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
401
+ }
@@ -0,0 +1,273 @@
1
+ import { modelTier } from './roi-advisor.js';
2
+
3
+ const DEFAULT_TASK_TYPE = '未分类';
4
+ const DEFAULT_WORK_PURPOSE = '未说明';
5
+ const DEFAULT_WORK_STAGE = '未说明';
6
+ const DEFAULT_VALUE_LEVEL = '未评估';
7
+ const PRODUCTIVE_STATUSES = new Set(['已完成', '已发布']);
8
+ const EXPLORATION_TASKS = new Set(['技术调研', '测试验证']);
9
+ const EXPLORATION_PURPOSES = new Set(['需求澄清', '测试验证', '技术调研', '上下文整理']);
10
+ const EXPLORATION_STAGES = new Set(['探索', '验证']);
11
+ const IMPLEMENTATION_TASKS = new Set(['功能开发', '问题修复']);
12
+ const IMPLEMENTATION_PURPOSES = new Set(['功能开发', '调试修复']);
13
+ const IMPLEMENTATION_STAGES = new Set(['实现', '维护']);
14
+ const REVIEW_TASKS = new Set(['代码审查']);
15
+ const REVIEW_PURPOSES = new Set(['代码审查', '部署运维']);
16
+ const REVIEW_STAGES = new Set(['发布']);
17
+ const HIGH_VALUE_LEVELS = new Set(['高', '关键']);
18
+ const LOW_VALUE_LEVELS = new Set(['低']);
19
+
20
+ export function buildModelStrategy({ sessions = [] } = {}) {
21
+ const annotated = sessions.filter(hasStrategyAnnotation);
22
+ const total = aggregateSessions(sessions);
23
+ const annotatedTotal = aggregateSessions(annotated);
24
+ const modelRows = buildModelRowsFromSessions(sessions);
25
+
26
+ return {
27
+ coverage: {
28
+ sessionCount: sessions.length,
29
+ annotatedSessionCount: annotated.length,
30
+ annotatedShare: sessions.length ? annotated.length / sessions.length : 0,
31
+ totalTokens: total.totalTokens,
32
+ annotatedTokens: annotatedTotal.totalTokens,
33
+ annotatedTokenShare: total.totalTokens ? annotatedTotal.totalTokens / total.totalTokens : 0
34
+ },
35
+ byTaskType: buildDimensionRows(annotated, 'taskType', DEFAULT_TASK_TYPE),
36
+ byStage: buildDimensionRows(annotated, 'workStage', DEFAULT_WORK_STAGE),
37
+ byValue: buildDimensionRows(annotated, 'valueLevel', DEFAULT_VALUE_LEVEL),
38
+ playbook: buildModelPolicyRows(annotated),
39
+ riskModels: buildRiskModelRows(annotated),
40
+ modelRows,
41
+ recommendations: buildStrategyRecommendations({ sessions, annotated, modelRows })
42
+ };
43
+ }
44
+
45
+ export function hasStrategyAnnotation(session = {}) {
46
+ return (session.taskType || DEFAULT_TASK_TYPE) !== DEFAULT_TASK_TYPE
47
+ || (session.workPurpose || DEFAULT_WORK_PURPOSE) !== DEFAULT_WORK_PURPOSE
48
+ || (session.workStage || DEFAULT_WORK_STAGE) !== DEFAULT_WORK_STAGE
49
+ || (session.valueLevel || DEFAULT_VALUE_LEVEL) !== DEFAULT_VALUE_LEVEL;
50
+ }
51
+
52
+ export function buildModelRowsFromSessions(sessions = []) {
53
+ const rows = new Map();
54
+ const totalTokens = sessions.reduce((sum, session) => sum + (session.totalTokens || 0), 0);
55
+ for (const session of sessions) {
56
+ const model = session.model || session.pricingModel || '<unknown>';
57
+ if (!rows.has(model)) {
58
+ rows.set(model, {
59
+ model,
60
+ tier: modelTier(model, session.pricingStatus),
61
+ sessionCount: 0,
62
+ totalTokens: 0,
63
+ costUSD: 0,
64
+ productiveTokens: 0,
65
+ riskTokens: 0,
66
+ discardedTokens: 0,
67
+ highValueTokens: 0,
68
+ lowValueTokens: 0
69
+ });
70
+ }
71
+ const row = rows.get(model);
72
+ const tokens = session.totalTokens || 0;
73
+ row.sessionCount += 1;
74
+ row.totalTokens += tokens;
75
+ row.costUSD += session.costUSD || 0;
76
+ if (PRODUCTIVE_STATUSES.has(session.outputStatus)) row.productiveTokens += tokens;
77
+ if (session.outputStatus === '已废弃' || LOW_VALUE_LEVELS.has(session.valueLevel)) row.riskTokens += tokens;
78
+ if (session.outputStatus === '已废弃') row.discardedTokens += tokens;
79
+ if (HIGH_VALUE_LEVELS.has(session.valueLevel)) row.highValueTokens += tokens;
80
+ if (LOW_VALUE_LEVELS.has(session.valueLevel)) row.lowValueTokens += tokens;
81
+ }
82
+ return Array.from(rows.values())
83
+ .map(row => ({
84
+ ...row,
85
+ share: totalTokens ? row.totalTokens / totalTokens : 0,
86
+ productiveShare: row.totalTokens ? row.productiveTokens / row.totalTokens : 0,
87
+ riskShare: row.totalTokens ? row.riskTokens / row.totalTokens : 0
88
+ }))
89
+ .sort((a, b) => b.totalTokens - a.totalTokens);
90
+ }
91
+
92
+ function buildDimensionRows(sessions, field, defaultValue) {
93
+ const rows = new Map();
94
+ for (const session of sessions) {
95
+ const key = session[field] || defaultValue;
96
+ if (key === defaultValue) continue;
97
+ if (!rows.has(key)) {
98
+ rows.set(key, {
99
+ key,
100
+ sessionCount: 0,
101
+ totalTokens: 0,
102
+ costUSD: 0,
103
+ models: new Map()
104
+ });
105
+ }
106
+ const row = rows.get(key);
107
+ const model = session.model || session.pricingModel || '<unknown>';
108
+ const tokens = session.totalTokens || 0;
109
+ row.sessionCount += 1;
110
+ row.totalTokens += tokens;
111
+ row.costUSD += session.costUSD || 0;
112
+ row.models.set(model, (row.models.get(model) || 0) + tokens);
113
+ }
114
+ return Array.from(rows.values())
115
+ .map(row => ({
116
+ ...row,
117
+ topModel: topModel(row.models),
118
+ models: Array.from(row.models.entries()).sort((a, b) => b[1] - a[1])
119
+ }))
120
+ .sort((a, b) => b.totalTokens - a.totalTokens);
121
+ }
122
+
123
+ function buildRiskModelRows(sessions) {
124
+ return buildModelRowsFromSessions(sessions.filter(session =>
125
+ session.outputStatus === '已废弃' || LOW_VALUE_LEVELS.has(session.valueLevel)
126
+ )).slice(0, 5);
127
+ }
128
+
129
+ function buildModelPolicyRows(sessions) {
130
+ return [
131
+ {
132
+ id: 'light-default',
133
+ label: '轻量默认',
134
+ title: '测试验证、探索和上下文整理',
135
+ targetTier: 'light',
136
+ action: '默认用 Haiku、DeepSeek 或 MiMo 快速试错,方向确认后再升级。',
137
+ sessions: sessions.filter(isLightPolicyWork)
138
+ },
139
+ {
140
+ id: 'mid-implementation',
141
+ label: '中模型实现',
142
+ title: '功能开发、调试修复和维护实现',
143
+ targetTier: 'mid',
144
+ action: '复杂实现和调试默认用 Sonnet 或 Codex 中模型,兼顾上下文理解和成本。',
145
+ sessions: sessions.filter(isMidPolicyWork)
146
+ },
147
+ {
148
+ id: 'heavy-review',
149
+ label: '重模型审查',
150
+ title: '关键价值、代码审查和发布前确认',
151
+ targetTier: 'heavy',
152
+ action: '只在关键发布、复杂审查或高价值收口时使用 Opus / GPT 重模型。',
153
+ sessions: sessions.filter(isHeavyPolicyWork)
154
+ }
155
+ ].map(({ sessions: matching, ...row }) => {
156
+ const aggregate = aggregateSessions(matching);
157
+ const modelRows = buildModelRowsFromSessions(matching);
158
+ const top = modelRows[0];
159
+ return {
160
+ ...row,
161
+ sessionCount: aggregate.sessionCount,
162
+ totalTokens: aggregate.totalTokens,
163
+ costUSD: aggregate.costUSD,
164
+ topModel: top?.model || '待标注验证',
165
+ observedTier: top?.tier || 'unknown',
166
+ evidenceState: aggregate.sessionCount ? '已观察' : '待标注验证'
167
+ };
168
+ });
169
+ }
170
+
171
+ function isLightPolicyWork(session = {}) {
172
+ return EXPLORATION_TASKS.has(session.taskType)
173
+ || EXPLORATION_PURPOSES.has(session.workPurpose)
174
+ || EXPLORATION_STAGES.has(session.workStage);
175
+ }
176
+
177
+ function isMidPolicyWork(session = {}) {
178
+ return IMPLEMENTATION_TASKS.has(session.taskType)
179
+ || IMPLEMENTATION_PURPOSES.has(session.workPurpose)
180
+ || IMPLEMENTATION_STAGES.has(session.workStage);
181
+ }
182
+
183
+ function isHeavyPolicyWork(session = {}) {
184
+ return REVIEW_TASKS.has(session.taskType)
185
+ || REVIEW_PURPOSES.has(session.workPurpose)
186
+ || REVIEW_STAGES.has(session.workStage)
187
+ || HIGH_VALUE_LEVELS.has(session.valueLevel);
188
+ }
189
+
190
+ function buildStrategyRecommendations({ sessions, annotated, modelRows }) {
191
+ const recommendations = [];
192
+ const coverage = sessions.length ? annotated.length / sessions.length : 0;
193
+ if (sessions.length && coverage < 0.5) {
194
+ recommendations.push({
195
+ id: 'label-before-model-policy',
196
+ title: '先补齐标注再固化模型策略',
197
+ detail: `当前只有 ${(coverage * 100).toFixed(0)}% session 有任务、阶段或价值标注,模型策略结论还不够稳。`,
198
+ action: '优先标注最高成本的 10 个 session,再观察不同任务和阶段的模型表现。'
199
+ });
200
+ }
201
+
202
+ const heavyExploration = annotated.filter(session => {
203
+ const tier = modelTier(session.model || session.pricingModel, session.pricingStatus);
204
+ return tier === 'heavy'
205
+ && (EXPLORATION_TASKS.has(session.taskType) || EXPLORATION_STAGES.has(session.workStage));
206
+ });
207
+ if (heavyExploration.length) {
208
+ const agg = aggregateSessions(heavyExploration);
209
+ recommendations.push({
210
+ id: 'light-model-for-exploration',
211
+ title: '探索和验证默认用轻量模型',
212
+ detail: `${heavyExploration.length} 个探索/验证 session 使用了重模型,合计 ${compactCN(agg.totalTokens)} tokens。`,
213
+ action: '测试验证、上下文整理、技术调研先用 Haiku、DeepSeek 或 MiMo,进入复杂实现再升级。'
214
+ });
215
+ }
216
+
217
+ const reusable = annotated.filter(session => {
218
+ const tier = modelTier(session.model || session.pricingModel, session.pricingStatus);
219
+ return PRODUCTIVE_STATUSES.has(session.outputStatus)
220
+ && HIGH_VALUE_LEVELS.has(session.valueLevel)
221
+ && ['light', 'mid'].includes(tier);
222
+ });
223
+ if (reusable.length) {
224
+ const models = Array.from(new Set(reusable.map(session => session.model || session.pricingModel).filter(Boolean))).slice(0, 3);
225
+ recommendations.push({
226
+ id: 'keep-high-value-pattern',
227
+ title: '保留高价值低成本模型组合',
228
+ detail: `${reusable.length} 个高价值完成/发布 session 使用中轻量模型完成,代表可复用模式。`,
229
+ action: `相似任务优先复用 ${models.join('、') || '当前中轻量模型'},重模型留给关键审查和复杂调试。`
230
+ });
231
+ }
232
+
233
+ const risky = modelRows.filter(row => row.riskShare > 0.2 && row.totalTokens > 0).slice(0, 2);
234
+ for (const row of risky) {
235
+ recommendations.push({
236
+ id: `risk-${row.model}`,
237
+ title: `${row.model} 的低价值/废弃占比较高`,
238
+ detail: `${row.model} 有 ${(row.riskShare * 100).toFixed(0)}% tokens 落在低价值或废弃任务上。`,
239
+ action: '后续同类任务先设 token 止损线,用轻量模型验证方向后再升级。'
240
+ });
241
+ }
242
+
243
+ return recommendations.slice(0, 5);
244
+ }
245
+
246
+ function aggregateSessions(sessions = []) {
247
+ return sessions.reduce((acc, session) => {
248
+ acc.sessionCount += 1;
249
+ acc.totalTokens += session.totalTokens || 0;
250
+ acc.costUSD += session.costUSD || 0;
251
+ return acc;
252
+ }, { sessionCount: 0, totalTokens: 0, costUSD: 0 });
253
+ }
254
+
255
+ function topModel(models) {
256
+ let best = '—';
257
+ let bestTokens = -1;
258
+ for (const [model, tokens] of models) {
259
+ if (tokens > bestTokens) {
260
+ best = model;
261
+ bestTokens = tokens;
262
+ }
263
+ }
264
+ return best;
265
+ }
266
+
267
+ function compactCN(value) {
268
+ const v = Number(value || 0);
269
+ const abs = Math.abs(v);
270
+ if (abs >= 1e8) return `${(v / 1e8).toFixed(2).replace(/\.?0+$/, '')} 亿`;
271
+ if (abs >= 1e4) return `${(v / 1e4).toFixed(1).replace(/\.0$/, '')} 万`;
272
+ return String(Math.round(v));
273
+ }