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.
- package/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
const DEFAULT_TASK_TYPE = '未分类';
|
|
2
|
+
const DEFAULT_OUTPUT_STATUS = '未标注';
|
|
3
|
+
const DEFAULT_WORK_PURPOSE = '未说明';
|
|
4
|
+
const DEFAULT_WORK_STAGE = '未说明';
|
|
5
|
+
const DEFAULT_VALUE_LEVEL = '未评估';
|
|
6
|
+
|
|
7
|
+
const HEAVY_MODEL_PATTERNS = [/^gpt-5\.5/i, /claude-opus/i];
|
|
8
|
+
const MID_MODEL_PATTERNS = [/^gpt-5\.3-codex$/i, /claude-sonnet/i];
|
|
9
|
+
const LIGHT_MODEL_PATTERNS = [/claude-haiku/i, /deepseek/i, /mimo/i];
|
|
10
|
+
const EXPLORATION_PURPOSES = new Set(['测试验证', '上下文整理']);
|
|
11
|
+
const EXPLORATION_STAGES = new Set(['探索', '验证']);
|
|
12
|
+
const PRODUCTIVE_STATUSES = new Set(['已完成', '已发布']);
|
|
13
|
+
const LOW_VALUE_LEVELS = new Set(['低']);
|
|
14
|
+
const HIGH_VALUE_LEVELS = new Set(['高', '关键']);
|
|
15
|
+
|
|
16
|
+
export function buildRoiAdvisor({ sessions = [], daily = [] } = {}) {
|
|
17
|
+
const total = aggregateRows(sessions.length ? sessions : daily);
|
|
18
|
+
const suggestions = [
|
|
19
|
+
buildAttributionSuggestion(sessions, total),
|
|
20
|
+
buildHeavyModelExplorationSuggestion(sessions, total),
|
|
21
|
+
buildWasteSuggestion(sessions, total),
|
|
22
|
+
buildHighValueKeepSuggestion(sessions, total),
|
|
23
|
+
buildInputRatioSuggestion(sessions, daily, total),
|
|
24
|
+
buildCacheSuggestion(sessions, daily, total),
|
|
25
|
+
buildUnpricedSuggestion(sessions, daily, total)
|
|
26
|
+
].filter(Boolean);
|
|
27
|
+
|
|
28
|
+
return suggestions
|
|
29
|
+
.sort((a, b) => b.score - a.score)
|
|
30
|
+
.slice(0, 5)
|
|
31
|
+
.map(({ score, ...suggestion }) => suggestion);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function modelTier(model, pricingStatus = '') {
|
|
35
|
+
const name = String(model || '').trim();
|
|
36
|
+
if (!name || name === '<synthetic>' || pricingStatus === 'unpriced') return 'unpriced';
|
|
37
|
+
if (HEAVY_MODEL_PATTERNS.some(pattern => pattern.test(name))) return 'heavy';
|
|
38
|
+
if (MID_MODEL_PATTERNS.some(pattern => pattern.test(name))) return 'mid';
|
|
39
|
+
if (LIGHT_MODEL_PATTERNS.some(pattern => pattern.test(name))) return 'light';
|
|
40
|
+
return 'unknown';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isRoiUnattributed(session = {}) {
|
|
44
|
+
return (session.taskType || DEFAULT_TASK_TYPE) === DEFAULT_TASK_TYPE
|
|
45
|
+
|| (session.outputStatus || DEFAULT_OUTPUT_STATUS) === DEFAULT_OUTPUT_STATUS
|
|
46
|
+
|| (session.workPurpose || DEFAULT_WORK_PURPOSE) === DEFAULT_WORK_PURPOSE
|
|
47
|
+
|| (session.workStage || DEFAULT_WORK_STAGE) === DEFAULT_WORK_STAGE
|
|
48
|
+
|| (session.valueLevel || DEFAULT_VALUE_LEVEL) === DEFAULT_VALUE_LEVEL;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildAttributionSuggestion(sessions, total) {
|
|
52
|
+
const rows = sessions.filter(isRoiUnattributed)
|
|
53
|
+
.sort(compareCostThenTokens);
|
|
54
|
+
if (!rows.length) return null;
|
|
55
|
+
const aggregate = aggregateRows(rows);
|
|
56
|
+
const top = rows[0];
|
|
57
|
+
return suggestion({
|
|
58
|
+
id: 'attribute-high-cost-work',
|
|
59
|
+
category: '补标注',
|
|
60
|
+
impact: '高',
|
|
61
|
+
tone: 'risk',
|
|
62
|
+
title: '先补齐高成本会话的用途和价值',
|
|
63
|
+
recommendation: '把当前最高成本的未归因 session 标上主要目的、工作阶段和产出价值。',
|
|
64
|
+
reason: '没有用途和价值字段时,系统只能知道花了多少 token,无法判断这笔投入是否值得继续。',
|
|
65
|
+
evidence: withAttributionEvidence(`${rows.length} 个 session 仍缺少任务/状态/目的/阶段/价值标注,占本期 ${pct(aggregate.totalTokens, total.totalTokens)} tokens;最高一条是 ${labelSession(top)},官方价 ${money(top.costUSD)}。`, rows),
|
|
66
|
+
action: '在看板按模型或项目筛选后使用“批量归因当前筛选”,先处理 token 最高的前 5 条。',
|
|
67
|
+
score: 100 + shareScore(aggregate.totalTokens, total.totalTokens)
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildHeavyModelExplorationSuggestion(sessions, total) {
|
|
72
|
+
const rows = sessions.filter(session => {
|
|
73
|
+
const tier = modelTier(session.model || session.pricingModel, session.pricingStatus);
|
|
74
|
+
return tier === 'heavy'
|
|
75
|
+
&& (EXPLORATION_PURPOSES.has(session.workPurpose) || EXPLORATION_STAGES.has(session.workStage));
|
|
76
|
+
}).sort(compareCostThenTokens);
|
|
77
|
+
if (!rows.length) return null;
|
|
78
|
+
const aggregate = aggregateRows(rows);
|
|
79
|
+
return suggestion({
|
|
80
|
+
id: 'use-light-model-for-exploration',
|
|
81
|
+
category: '模型切换',
|
|
82
|
+
impact: aggregate.costUSD > total.costUSD * 0.1 ? '高' : '中',
|
|
83
|
+
tone: 'optimize',
|
|
84
|
+
title: '测试和探索阶段优先改用轻量模型',
|
|
85
|
+
recommendation: '把测试验证、上下文整理和探索阶段默认切到 Haiku、DeepSeek 或 MiMo,确认方向后再升级到重模型。',
|
|
86
|
+
reason: '这些任务通常需要快速试错,不需要一开始就使用最高单价模型。',
|
|
87
|
+
evidence: withAttributionEvidence(`${rows.length} 个探索/验证类 session 使用了重模型,合计 ${compact(aggregate.totalTokens)} tokens、官方价 ${money(aggregate.costUSD)}。`, rows),
|
|
88
|
+
action: '把这类任务的默认模型改成轻量模型;只有进入实现收口、复杂调试或发布前审查时再切回重模型。',
|
|
89
|
+
score: 88 + shareScore(aggregate.costUSD, total.costUSD)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildWasteSuggestion(sessions, total) {
|
|
94
|
+
const rows = sessions.filter(session =>
|
|
95
|
+
session.outputStatus === '已废弃' || LOW_VALUE_LEVELS.has(session.valueLevel)
|
|
96
|
+
).sort(compareCostThenTokens);
|
|
97
|
+
if (!rows.length) return null;
|
|
98
|
+
const aggregate = aggregateRows(rows);
|
|
99
|
+
return suggestion({
|
|
100
|
+
id: 'stop-loss-low-value-work',
|
|
101
|
+
category: '止损',
|
|
102
|
+
impact: aggregate.costUSD > total.costUSD * 0.15 ? '高' : '中',
|
|
103
|
+
tone: 'risk',
|
|
104
|
+
title: '低价值或废弃任务要先轻量试错',
|
|
105
|
+
recommendation: '对低价值和可能废弃的方向设置 token 止损线,先用轻量模型验证可行性。',
|
|
106
|
+
reason: '这类投入即使完成,也不一定转化为长期产出;重模型成本应该留给高价值实现和审查。',
|
|
107
|
+
evidence: withAttributionEvidence(`${rows.length} 个低价值/废弃 session 合计 ${compact(aggregate.totalTokens)} tokens、官方价 ${money(aggregate.costUSD)}。`, rows),
|
|
108
|
+
action: '后续同类任务先用轻量模型做 1-2 轮方案验证,再决定是否进入实现阶段。',
|
|
109
|
+
score: 82 + shareScore(aggregate.costUSD, total.costUSD)
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildHighValueKeepSuggestion(sessions, total) {
|
|
114
|
+
const rows = sessions.filter(session => {
|
|
115
|
+
const tier = modelTier(session.model || session.pricingModel, session.pricingStatus);
|
|
116
|
+
return HIGH_VALUE_LEVELS.has(session.valueLevel)
|
|
117
|
+
&& PRODUCTIVE_STATUSES.has(session.outputStatus)
|
|
118
|
+
&& isLowCostSession(session, total)
|
|
119
|
+
&& ['light', 'mid'].includes(tier);
|
|
120
|
+
}).sort(compareCostThenTokens);
|
|
121
|
+
if (!rows.length) return null;
|
|
122
|
+
const aggregate = aggregateRows(rows);
|
|
123
|
+
return suggestion({
|
|
124
|
+
id: 'keep-high-value-low-cost-pattern',
|
|
125
|
+
category: '保留策略',
|
|
126
|
+
impact: '中',
|
|
127
|
+
tone: 'good',
|
|
128
|
+
title: '保留高价值低成本的模型策略',
|
|
129
|
+
recommendation: '这类高价值产出已经能用中轻量模型完成,后续相似任务优先复用同样模型组合。',
|
|
130
|
+
reason: 'ROI 最高的模式不是最便宜,而是能稳定交付高价值产出且成本可控。',
|
|
131
|
+
evidence: withAttributionEvidence(`${rows.length} 个高价值且已完成/已发布 session 使用中轻量模型,合计官方价 ${money(aggregate.costUSD)}。`, rows),
|
|
132
|
+
action: '把这些 session 的项目、任务类型和模型作为后续默认模板。',
|
|
133
|
+
score: 60 + shareScore(aggregate.totalTokens, total.totalTokens)
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isLowCostSession(session, total) {
|
|
138
|
+
const cost = session.costUSD || 0;
|
|
139
|
+
return cost <= 1 || (total.costUSD > 0 && cost / total.costUSD <= 0.05);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildInputRatioSuggestion(sessions, daily, total) {
|
|
143
|
+
const aggregate = aggregateRows(sessions.length ? sessions : daily);
|
|
144
|
+
const ratio = aggregate.outputTokens ? aggregate.inputTokens / aggregate.outputTokens : 0;
|
|
145
|
+
if (ratio < 4 || aggregate.inputTokens < 100_000) return null;
|
|
146
|
+
return suggestion({
|
|
147
|
+
id: 'reduce-context-bloat',
|
|
148
|
+
category: '上下文压缩',
|
|
149
|
+
impact: ratio >= 8 ? '高' : '中',
|
|
150
|
+
tone: 'optimize',
|
|
151
|
+
title: '压缩上下文,减少大段输入',
|
|
152
|
+
recommendation: '把长上下文改成“目标 + 相关文件 + 约束 + 验收标准”,不要每轮重复喂全部背景。',
|
|
153
|
+
reason: 'Input / Output 比过高通常说明输入上下文过大,模型在读材料上消耗了大量 token。',
|
|
154
|
+
evidence: withAttributionEvidence(`本期 Input / Output 比为 ${ratio.toFixed(1)}:1,输入 ${compact(aggregate.inputTokens)},输出 ${compact(aggregate.outputTokens)}。`, sessions),
|
|
155
|
+
action: '为高频项目沉淀 README/任务摘要;每轮只附与当前问题直接相关的文件和错误信息。',
|
|
156
|
+
score: 76 + Math.min(20, ratio)
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildCacheSuggestion(sessions, daily, total) {
|
|
161
|
+
const aggregate = aggregateRows(sessions.length ? sessions : daily);
|
|
162
|
+
const cacheRate = aggregate.totalTokens ? aggregate.cacheReadTokens / aggregate.totalTokens : 0;
|
|
163
|
+
if (cacheRate > 0.2 || aggregate.inputTokens < 100_000) return null;
|
|
164
|
+
return suggestion({
|
|
165
|
+
id: 'improve-cache-and-task-continuity',
|
|
166
|
+
category: '上下文压缩',
|
|
167
|
+
impact: '中',
|
|
168
|
+
tone: 'optimize',
|
|
169
|
+
title: '提高上下文连续性,减少重新读项目',
|
|
170
|
+
recommendation: '把同一项目的相关任务集中处理,减少频繁开新上下文。',
|
|
171
|
+
reason: 'cache 命中低且输入高,说明模型经常重新读取类似背景。',
|
|
172
|
+
evidence: withAttributionEvidence(`本期 cache 命中约 ${(cacheRate * 100).toFixed(1)}%,输入 ${compact(aggregate.inputTokens)} tokens。`, sessions),
|
|
173
|
+
action: '同一项目尽量连续完成“方案-实现-验证”,并用项目摘要替代重复粘贴长上下文。',
|
|
174
|
+
score: 70
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildUnpricedSuggestion(sessions, daily, total) {
|
|
179
|
+
const rows = (sessions.length ? sessions : daily).filter(row =>
|
|
180
|
+
row.totalTokens > 0 && (row.pricingStatus === 'unpriced' || modelTier(row.model || row.pricingModel, row.pricingStatus) === 'unpriced')
|
|
181
|
+
);
|
|
182
|
+
if (!rows.length) return null;
|
|
183
|
+
const aggregate = aggregateRows(rows);
|
|
184
|
+
const models = Array.from(new Set(rows.map(row => row.model || row.pricingModel).filter(Boolean))).slice(0, 3);
|
|
185
|
+
return suggestion({
|
|
186
|
+
id: 'keep-unpriced-models-out-of-cost-decisions',
|
|
187
|
+
category: '未定价模型',
|
|
188
|
+
impact: '中',
|
|
189
|
+
tone: 'neutral',
|
|
190
|
+
title: '未定价模型不要参与成本决策',
|
|
191
|
+
recommendation: '把未公开官方美元价的模型单独标记,只用 token 量观察,不把 $0 当成免费。',
|
|
192
|
+
reason: '没有官方公开单价时,强行换算会污染 ROI 判断。',
|
|
193
|
+
evidence: withAttributionEvidence(`${models.join('、')} 等模型合计 ${compact(aggregate.totalTokens)} tokens,目前没有纳入官方价成本。`, rows),
|
|
194
|
+
action: '涉及未定价模型时,用产出状态和价值判断是否继续,而不是用账单金额排序。',
|
|
195
|
+
score: 58 + shareScore(aggregate.totalTokens, total.totalTokens)
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function suggestion(value) {
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function withAttributionEvidence(text, rows = []) {
|
|
204
|
+
const autoCount = rows.filter(row => row.annotationSource === 'auto').length;
|
|
205
|
+
if (!autoCount) return text;
|
|
206
|
+
return `${text} 其中 ${autoCount} 条基于自动归因,建议抽查高成本项。`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function aggregateRows(rows = []) {
|
|
210
|
+
return rows.reduce((acc, row) => {
|
|
211
|
+
acc.sessionCount += 1;
|
|
212
|
+
acc.inputTokens += row.inputTokens || 0;
|
|
213
|
+
acc.outputTokens += row.outputTokens || 0;
|
|
214
|
+
acc.cacheReadTokens += row.cacheReadTokens || 0;
|
|
215
|
+
acc.totalTokens += row.totalTokens || 0;
|
|
216
|
+
acc.costUSD += row.costUSD || 0;
|
|
217
|
+
return acc;
|
|
218
|
+
}, {
|
|
219
|
+
sessionCount: 0,
|
|
220
|
+
inputTokens: 0,
|
|
221
|
+
outputTokens: 0,
|
|
222
|
+
cacheReadTokens: 0,
|
|
223
|
+
totalTokens: 0,
|
|
224
|
+
costUSD: 0
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function compareCostThenTokens(a, b) {
|
|
229
|
+
return (b.costUSD || 0) - (a.costUSD || 0)
|
|
230
|
+
|| (b.totalTokens || 0) - (a.totalTokens || 0);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function labelSession(session = {}) {
|
|
234
|
+
return session.projectAlias || session.projectPath || session.sessionId || '未命名会话';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function shareScore(value, total) {
|
|
238
|
+
return Math.round(Math.min(30, total ? (value / total) * 100 : 0));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function pct(value, total) {
|
|
242
|
+
return total ? `${((value / total) * 100).toFixed(1)}%` : '0%';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function money(value) {
|
|
246
|
+
return `$${Number(value || 0).toFixed(value && value < 1 ? 4 : 2)}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function compact(value) {
|
|
250
|
+
const v = Number(value || 0);
|
|
251
|
+
const abs = Math.abs(v);
|
|
252
|
+
if (abs >= 1e8) return `${(v / 1e8).toFixed(2).replace(/\.?0+$/, '')} 亿`;
|
|
253
|
+
if (abs >= 1e4) return `${(v / 1e4).toFixed(1).replace(/\.0$/, '')} 万`;
|
|
254
|
+
return String(Math.round(v));
|
|
255
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const MISSING_TASK = '未分类';
|
|
2
|
+
const MISSING_STATUS = '未标注';
|
|
3
|
+
const MISSING_PURPOSE = '未说明';
|
|
4
|
+
const MISSING_STAGE = '未说明';
|
|
5
|
+
const MISSING_VALUE = '未评估';
|
|
6
|
+
|
|
7
|
+
export function buildRoiEvidence({ sessions = [], workItems = [] } = {}) {
|
|
8
|
+
const sessionCount = sessions.length;
|
|
9
|
+
const totalTokens = sum(sessions, 'totalTokens');
|
|
10
|
+
const officialCostUSD = sum(sessions, 'costUSD');
|
|
11
|
+
const manualConfirmed = sessions.filter(isManual).length;
|
|
12
|
+
const withOutput = sessions.filter(session => Boolean(session.outputUrl)).length;
|
|
13
|
+
const complete = sessions.filter(isEvidenceComplete).length;
|
|
14
|
+
const incompleteCostUSD = sum(sessions.filter(session => !isEvidenceComplete(session)), 'costUSD');
|
|
15
|
+
const highCostGaps = sessions
|
|
16
|
+
.filter(session => !isEvidenceComplete(session))
|
|
17
|
+
.sort((a, b) => (b.costUSD || b.totalTokens || 0) - (a.costUSD || a.totalTokens || 0))
|
|
18
|
+
.slice(0, 5)
|
|
19
|
+
.map(session => ({
|
|
20
|
+
project: session.projectAlias || session.projectPath || '未归属项目',
|
|
21
|
+
sessionId: session.sessionId,
|
|
22
|
+
totalTokens: session.totalTokens || 0,
|
|
23
|
+
costUSD: session.costUSD || 0,
|
|
24
|
+
missing: missingFields(session)
|
|
25
|
+
}));
|
|
26
|
+
const evidenceScore = sessionCount
|
|
27
|
+
? Math.round((
|
|
28
|
+
(complete / sessionCount) * 0.45
|
|
29
|
+
+ (manualConfirmed / sessionCount) * 0.25
|
|
30
|
+
+ (withOutput / sessionCount) * 0.20
|
|
31
|
+
+ (workItems.length ? 0.10 : 0)
|
|
32
|
+
) * 100)
|
|
33
|
+
: 0;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
evidenceScore,
|
|
37
|
+
sessionCount,
|
|
38
|
+
totalTokens,
|
|
39
|
+
officialCostUSD,
|
|
40
|
+
manualConfirmed,
|
|
41
|
+
autoOrMissing: Math.max(0, sessionCount - manualConfirmed),
|
|
42
|
+
withOutput,
|
|
43
|
+
complete,
|
|
44
|
+
workItemCount: workItems.length,
|
|
45
|
+
incompleteCostUSD,
|
|
46
|
+
highCostGaps
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isEvidenceComplete(session = {}) {
|
|
51
|
+
return Boolean(session.projectAlias || session.projectPath)
|
|
52
|
+
&& (session.taskType || MISSING_TASK) !== MISSING_TASK
|
|
53
|
+
&& (session.outputStatus || MISSING_STATUS) !== MISSING_STATUS
|
|
54
|
+
&& (session.workPurpose || MISSING_PURPOSE) !== MISSING_PURPOSE
|
|
55
|
+
&& (session.workStage || MISSING_STAGE) !== MISSING_STAGE
|
|
56
|
+
&& (session.valueLevel || MISSING_VALUE) !== MISSING_VALUE
|
|
57
|
+
&& isManual(session);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isManual(session = {}) {
|
|
61
|
+
return session.annotationSource === 'manual' || session.annotationSource === 'imported';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function missingFields(session = {}) {
|
|
65
|
+
const fields = [];
|
|
66
|
+
if (!session.projectAlias && !session.projectPath) fields.push('项目');
|
|
67
|
+
if ((session.taskType || MISSING_TASK) === MISSING_TASK) fields.push('任务');
|
|
68
|
+
if ((session.outputStatus || MISSING_STATUS) === MISSING_STATUS) fields.push('产出状态');
|
|
69
|
+
if ((session.workPurpose || MISSING_PURPOSE) === MISSING_PURPOSE) fields.push('目的');
|
|
70
|
+
if ((session.workStage || MISSING_STAGE) === MISSING_STAGE) fields.push('阶段');
|
|
71
|
+
if ((session.valueLevel || MISSING_VALUE) === MISSING_VALUE) fields.push('价值');
|
|
72
|
+
if (!isManual(session)) fields.push('人工确认');
|
|
73
|
+
return fields;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sum(rows, field) {
|
|
77
|
+
return rows.reduce((acc, row) => acc + Number(row[field] || 0), 0);
|
|
78
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { calculateOfficialCost } from '../../pricing.mjs';
|
|
2
|
+
import { modelTier } from './roi-advisor.js';
|
|
3
|
+
|
|
4
|
+
const PRODUCTIVE_STATUSES = new Set(['已完成', '已发布']);
|
|
5
|
+
const HIGH_VALUE_LEVELS = new Set(['高', '关键']);
|
|
6
|
+
const LOW_VALUE_LEVELS = new Set(['低']);
|
|
7
|
+
const EXPLORATION_PURPOSES = new Set(['需求澄清', '技术调研', '测试验证', '上下文整理']);
|
|
8
|
+
const EXPLORATION_STAGES = new Set(['探索', '验证']);
|
|
9
|
+
const CONTEXT_PURPOSES = new Set(['上下文整理']);
|
|
10
|
+
const TARGET_MODELS = {
|
|
11
|
+
light: [
|
|
12
|
+
{ model: 'deepseek-v4-flash', provider: 'deepseek', label: 'DeepSeek V4 Flash' },
|
|
13
|
+
{ model: 'mimo-v2.5', provider: 'xiaomi', label: 'MiMo v2.5' },
|
|
14
|
+
{ model: 'claude-haiku-4-5', provider: 'anthropic', label: 'Claude Haiku 4.5' }
|
|
15
|
+
],
|
|
16
|
+
mid: [
|
|
17
|
+
{ model: 'gpt-5.3-codex', provider: 'openai', label: 'GPT-5.3 Codex' },
|
|
18
|
+
{ model: 'claude-sonnet-4-6', provider: 'anthropic', label: 'Claude Sonnet 4.6' },
|
|
19
|
+
{ model: 'deepseek-v4-pro', provider: 'deepseek', label: 'DeepSeek V4 Pro' },
|
|
20
|
+
{ model: 'mimo-v2.5-pro', provider: 'xiaomi', label: 'MiMo v2.5 Pro' }
|
|
21
|
+
]
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function buildSavingsSimulation({ sessions = [], daily = [], pricingMeta = null } = {}) {
|
|
25
|
+
const rows = sessions.length ? sessions : daily;
|
|
26
|
+
const totalCostUSD = rows.reduce((sum, row) => sum + pricedCost(row), 0);
|
|
27
|
+
const totalTokens = rows.reduce((sum, row) => sum + tokensFor(row).total, 0);
|
|
28
|
+
const unpricedSessions = rows
|
|
29
|
+
.filter(row => isUnpriced(row) && tokensFor(row).total > 0)
|
|
30
|
+
.map(row => ({
|
|
31
|
+
sessionId: row.sessionId || row.id || '',
|
|
32
|
+
model: row.model || row.pricingModel || 'unknown',
|
|
33
|
+
totalTokens: tokensFor(row).total,
|
|
34
|
+
reason: row.pricingReason || '未配置官方公开美元价'
|
|
35
|
+
}))
|
|
36
|
+
.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
37
|
+
|
|
38
|
+
const grouped = new Map();
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
const candidate = classifyCandidate(row);
|
|
41
|
+
if (!candidate) continue;
|
|
42
|
+
|
|
43
|
+
const currentCostUSD = pricedCost(row);
|
|
44
|
+
if (currentCostUSD <= 0 || isUnpriced(row)) continue;
|
|
45
|
+
|
|
46
|
+
const simulated = simulateTargetCost(row, candidate.targetTier);
|
|
47
|
+
if (!simulated || simulated.costUSD >= currentCostUSD) continue;
|
|
48
|
+
|
|
49
|
+
const key = candidate.id;
|
|
50
|
+
if (!grouped.has(key)) {
|
|
51
|
+
grouped.set(key, {
|
|
52
|
+
...candidate,
|
|
53
|
+
sessionCount: 0,
|
|
54
|
+
totalTokens: 0,
|
|
55
|
+
currentCostUSD: 0,
|
|
56
|
+
simulatedCostUSD: 0,
|
|
57
|
+
savingsUSD: 0,
|
|
58
|
+
targetModels: new Map(),
|
|
59
|
+
sampleSessions: []
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const target = grouped.get(key);
|
|
64
|
+
const tokens = tokensFor(row);
|
|
65
|
+
target.sessionCount += 1;
|
|
66
|
+
target.totalTokens += tokens.total;
|
|
67
|
+
target.currentCostUSD += currentCostUSD;
|
|
68
|
+
target.simulatedCostUSD += simulated.costUSD;
|
|
69
|
+
target.savingsUSD += currentCostUSD - simulated.costUSD;
|
|
70
|
+
target.targetModels.set(simulated.model, simulated.label);
|
|
71
|
+
if (target.sampleSessions.length < 3) {
|
|
72
|
+
target.sampleSessions.push({
|
|
73
|
+
sessionId: row.sessionId || row.id || '',
|
|
74
|
+
project: projectLabel(row),
|
|
75
|
+
model: row.model || row.pricingModel || 'unknown',
|
|
76
|
+
totalTokens: tokens.total,
|
|
77
|
+
costUSD: currentCostUSD
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const suggestions = Array.from(grouped.values())
|
|
83
|
+
.map(row => ({
|
|
84
|
+
id: row.id,
|
|
85
|
+
title: row.title,
|
|
86
|
+
recommendation: row.recommendation,
|
|
87
|
+
currentTier: row.currentTier,
|
|
88
|
+
suggestedTier: row.targetTier,
|
|
89
|
+
suggestedModels: Array.from(row.targetModels.values()),
|
|
90
|
+
sessionCount: row.sessionCount,
|
|
91
|
+
totalTokens: row.totalTokens,
|
|
92
|
+
currentCostUSD: row.currentCostUSD,
|
|
93
|
+
simulatedCostUSD: row.simulatedCostUSD,
|
|
94
|
+
savingsUSD: row.savingsUSD,
|
|
95
|
+
savingsShare: row.currentCostUSD ? row.savingsUSD / row.currentCostUSD : 0,
|
|
96
|
+
why: row.why,
|
|
97
|
+
action: row.action,
|
|
98
|
+
sampleSessions: row.sampleSessions,
|
|
99
|
+
score: row.savingsUSD * 100 + row.totalTokens / 1000 + row.priority
|
|
100
|
+
}))
|
|
101
|
+
.sort((a, b) => b.score - a.score)
|
|
102
|
+
.slice(0, 5);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
generatedAt: new Date().toISOString(),
|
|
106
|
+
mode: 'official-price-simulation',
|
|
107
|
+
totalCostUSD,
|
|
108
|
+
totalTokens,
|
|
109
|
+
potentialSavingsUSD: suggestions.reduce((sum, row) => sum + row.savingsUSD, 0),
|
|
110
|
+
suggestions,
|
|
111
|
+
unpriced: {
|
|
112
|
+
sessionCount: unpricedSessions.length,
|
|
113
|
+
totalTokens: unpricedSessions.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
114
|
+
models: Array.from(new Set(unpricedSessions.map(row => row.model))).slice(0, 8),
|
|
115
|
+
sampleSessions: unpricedSessions.slice(0, 5)
|
|
116
|
+
},
|
|
117
|
+
pricingMeta
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function classifyCandidate(row = {}) {
|
|
122
|
+
const currentTier = modelTier(row.model || row.pricingModel, row.pricingStatus);
|
|
123
|
+
if (!['heavy', 'mid'].includes(currentTier)) return null;
|
|
124
|
+
if (isHighValueProductive(row)) return null;
|
|
125
|
+
|
|
126
|
+
if (isLowValueOrWaste(row)) {
|
|
127
|
+
return {
|
|
128
|
+
id: `${currentTier}-low-value-to-light`,
|
|
129
|
+
currentTier,
|
|
130
|
+
targetTier: 'light',
|
|
131
|
+
priority: currentTier === 'heavy' ? 90 : 70,
|
|
132
|
+
title: '低价值或废弃任务先切轻量模型',
|
|
133
|
+
recommendation: '把低价值、已废弃或高不确定性的工作先用轻量模型试错,再决定是否升级。',
|
|
134
|
+
why: '这类 session 的产出价值或状态已经显示风险,继续使用高单价模型会放大沉没成本。',
|
|
135
|
+
action: '为同类任务设置轻量模型默认值和 token 止损线,确认方向后再升级。'
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (isExplorationOrValidation(row)) {
|
|
140
|
+
return {
|
|
141
|
+
id: `${currentTier}-exploration-to-light`,
|
|
142
|
+
currentTier,
|
|
143
|
+
targetTier: 'light',
|
|
144
|
+
priority: currentTier === 'heavy' ? 82 : 62,
|
|
145
|
+
title: '探索、测试和上下文整理优先轻量化',
|
|
146
|
+
recommendation: '需求澄清、技术调研、测试验证、上下文整理默认使用轻量模型。',
|
|
147
|
+
why: '这些工作更像快速判断方向,不应该一开始就使用重模型完整推理。',
|
|
148
|
+
action: '把探索/验证阶段的默认模型切到轻量层;进入复杂实现或发布审查再升级。'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (currentTier === 'heavy' && row.workStage === '实现') {
|
|
153
|
+
return {
|
|
154
|
+
id: 'heavy-implementation-to-mid',
|
|
155
|
+
currentTier,
|
|
156
|
+
targetTier: 'mid',
|
|
157
|
+
priority: 45,
|
|
158
|
+
title: '普通实现阶段先用中模型承接',
|
|
159
|
+
recommendation: '非关键价值的实现任务先用中模型,关键审查和复杂收口再上重模型。',
|
|
160
|
+
why: '实现阶段通常需要稳定代码能力,但不一定每轮都需要最高成本模型。',
|
|
161
|
+
action: '把普通功能开发默认切到中模型;只在关键产出、架构风险或发布审查时使用重模型。'
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (currentTier === 'heavy' && CONTEXT_PURPOSES.has(row.workPurpose)) {
|
|
166
|
+
return {
|
|
167
|
+
id: 'heavy-context-to-light',
|
|
168
|
+
currentTier,
|
|
169
|
+
targetTier: 'light',
|
|
170
|
+
priority: 75,
|
|
171
|
+
title: '上下文整理不要占用重模型预算',
|
|
172
|
+
recommendation: '上下文整理、摘要和文件筛选先交给轻量模型完成。',
|
|
173
|
+
why: '整理材料的目标是减少后续上下文,不应本身成为高成本入口。',
|
|
174
|
+
action: '把项目摘要、文件清单、错误归纳沉淀为轻量模型任务。'
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function simulateTargetCost(row, tier) {
|
|
182
|
+
const tokens = tokensFor(row);
|
|
183
|
+
const candidates = orderedTargetModels(tier, row)
|
|
184
|
+
.map(candidate => ({
|
|
185
|
+
...candidate,
|
|
186
|
+
costUSD: calculateOfficialCost(candidate.model, tokens, { provider: candidate.provider }).totalUSD
|
|
187
|
+
}))
|
|
188
|
+
.filter(candidate => candidate.costUSD > 0)
|
|
189
|
+
.sort((a, b) => a.costUSD - b.costUSD);
|
|
190
|
+
return candidates[0] || null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function orderedTargetModels(tier, row = {}) {
|
|
194
|
+
const candidates = TARGET_MODELS[tier] || [];
|
|
195
|
+
const provider = String(row.pricingProvider || providerFromSource(row.source) || '').toLowerCase();
|
|
196
|
+
const preferred = candidates.filter(candidate => candidate.provider === provider);
|
|
197
|
+
const rest = candidates.filter(candidate => candidate.provider !== provider);
|
|
198
|
+
return [...preferred, ...rest];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function tokensFor(row = {}) {
|
|
202
|
+
const input = positive(row.inputTokens ?? row.input_tokens);
|
|
203
|
+
const output = positive(row.outputTokens ?? row.output_tokens);
|
|
204
|
+
const cacheRead = positive(row.cacheReadTokens ?? row.cache_read_tokens);
|
|
205
|
+
const cacheWrite = positive(row.cacheCreationTokens ?? row.cache_creation_tokens);
|
|
206
|
+
const reasoning = positive(row.reasoningOutputTokens ?? row.reasoningTokens ?? row.reasoning_output_tokens);
|
|
207
|
+
const total = positive(row.totalTokens ?? row.total_tokens)
|
|
208
|
+
|| input + output + cacheRead + cacheWrite + reasoning;
|
|
209
|
+
return { input, output, cacheRead, cacheWrite, reasoning, total };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function pricedCost(row = {}) {
|
|
213
|
+
const value = Number(row.costUSD ?? row.cost_usd ?? 0);
|
|
214
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isUnpriced(row = {}) {
|
|
218
|
+
return row.pricingStatus === 'unpriced'
|
|
219
|
+
|| modelTier(row.model || row.pricingModel, row.pricingStatus) === 'unpriced';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isHighValueProductive(row = {}) {
|
|
223
|
+
return HIGH_VALUE_LEVELS.has(row.valueLevel)
|
|
224
|
+
&& PRODUCTIVE_STATUSES.has(row.outputStatus);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isLowValueOrWaste(row = {}) {
|
|
228
|
+
return row.outputStatus === '已废弃' || LOW_VALUE_LEVELS.has(row.valueLevel);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isExplorationOrValidation(row = {}) {
|
|
232
|
+
return EXPLORATION_PURPOSES.has(row.workPurpose)
|
|
233
|
+
|| EXPLORATION_STAGES.has(row.workStage);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function projectLabel(row = {}) {
|
|
237
|
+
return row.projectAlias || row.projectPath || row.sessionId || row.source || 'unknown';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function providerFromSource(source) {
|
|
241
|
+
const value = String(source || '').toLowerCase();
|
|
242
|
+
if (value.includes('claude') || value.includes('anthropic')) return 'anthropic';
|
|
243
|
+
if (value.includes('codex') || value.includes('openai')) return 'openai';
|
|
244
|
+
if (value.includes('deepseek')) return 'deepseek';
|
|
245
|
+
if (value.includes('mimo') || value.includes('xiaomi')) return 'xiaomi';
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function positive(value) {
|
|
250
|
+
const number = Number(value ?? 0);
|
|
251
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
252
|
+
}
|