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,99 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ buildModelUsageRows,
5
+ filterSessionsByDashboardFilters,
6
+ sessionModel
7
+ } from '../src/client/dashboard/model-usage.js';
8
+
9
+ const sessions = [
10
+ {
11
+ device: 'devbox',
12
+ source: 'Codex CLI',
13
+ sessionId: 'codex-a',
14
+ model: 'gpt-5.5',
15
+ lastActivity: '2026-06-10',
16
+ totalTokens: 100,
17
+ costUSD: 0.01
18
+ },
19
+ {
20
+ device: 'devbox',
21
+ source: 'Claude Code',
22
+ sessionId: 'claude-a',
23
+ model: 'claude-opus-4-7',
24
+ lastActivity: '2026-06-10',
25
+ totalTokens: 50,
26
+ costUSD: 0.02
27
+ },
28
+ {
29
+ device: 'laptop',
30
+ source: 'Codex CLI',
31
+ sessionId: 'codex-b',
32
+ pricingModel: 'gpt-5.3-codex',
33
+ lastActivity: '2026-06-09',
34
+ totalTokens: 30,
35
+ costUSD: 0.03
36
+ }
37
+ ];
38
+
39
+ const daily = [
40
+ {
41
+ usageDate: '2026-06-10',
42
+ source: 'Codex CLI',
43
+ model: 'gpt-5.5',
44
+ inputTokens: 60,
45
+ outputTokens: 40,
46
+ cacheReadTokens: 0,
47
+ cacheCreationTokens: 0,
48
+ cachedInputTokens: 0,
49
+ reasoningOutputTokens: 0,
50
+ totalTokens: 100,
51
+ costUSD: 0.01,
52
+ pricingStatus: 'priced'
53
+ },
54
+ {
55
+ usageDate: '2026-06-10',
56
+ source: 'Claude Code',
57
+ model: 'claude-opus-4-7',
58
+ inputTokens: 20,
59
+ outputTokens: 30,
60
+ cacheReadTokens: 0,
61
+ cacheCreationTokens: 0,
62
+ cachedInputTokens: 0,
63
+ reasoningOutputTokens: 0,
64
+ totalTokens: 50,
65
+ costUSD: 0.02,
66
+ pricingStatus: 'priced'
67
+ }
68
+ ];
69
+
70
+ test('sessionModel falls back to pricingModel when API model is absent', () => {
71
+ assert.equal(sessionModel(sessions[2]), 'gpt-5.3-codex');
72
+ });
73
+
74
+ test('filterSessionsByDashboardFilters applies model filters to session data', () => {
75
+ const rows = filterSessionsByDashboardFilters(sessions, {
76
+ startDate: '2026-06-09',
77
+ endDate: '2026-06-10',
78
+ sources: new Set(),
79
+ devices: new Set(),
80
+ models: new Set(['gpt-5.5'])
81
+ });
82
+ assert.deepEqual(rows.map(row => row.sessionId), ['codex-a']);
83
+ });
84
+
85
+ test('buildModelUsageRows aggregates token cost and session counts by model', () => {
86
+ const rows = buildModelUsageRows(daily, sessions);
87
+ const byModel = Object.fromEntries(rows.map(row => [row.model, row]));
88
+
89
+ assert.equal(byModel['gpt-5.5'].totalTokens, 100);
90
+ assert.equal(byModel['gpt-5.5'].sessionCount, 1);
91
+ assert.equal(byModel['gpt-5.5'].dayCount, 1);
92
+ assert.equal(byModel['gpt-5.5'].pricingStatus, '已定价');
93
+
94
+ assert.equal(byModel['claude-opus-4-7'].totalTokens, 50);
95
+ assert.equal(byModel['claude-opus-4-7'].sources[0], 'Claude Code');
96
+
97
+ assert.equal(byModel['gpt-5.3-codex'].totalTokens, 30);
98
+ assert.equal(byModel['gpt-5.3-codex'].sessionCount, 1);
99
+ });
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ calculateCost,
5
+ calculateOfficialCost,
6
+ resolveOfficialPricing
7
+ } from '../src/pricing.mjs';
8
+
9
+ test('calculates OpenAI API standard USD price from official per-token rates', () => {
10
+ const cost = calculateOfficialCost('gpt-5.5', {
11
+ input: 1_000_000,
12
+ cacheRead: 1_000_000,
13
+ output: 1_000_000
14
+ });
15
+
16
+ assert.equal(cost.priced, true);
17
+ assert.equal(cost.provider, 'openai');
18
+ assert.equal(cost.totalUSD, 35.5);
19
+ assert.equal(cost.ratesPerMTok.input, 5);
20
+ assert.equal(cost.ratesPerMTok.cachedInput, 0.5);
21
+ assert.equal(cost.ratesPerMTok.output, 30);
22
+ });
23
+
24
+ test('calculates Claude prompt-cache cost with official 5-minute cache write default', () => {
25
+ const cost = calculateOfficialCost('claude-opus-4-7', {
26
+ input: 1_000_000,
27
+ cacheWrite: 1_000_000,
28
+ cacheRead: 1_000_000,
29
+ output: 1_000_000
30
+ });
31
+
32
+ assert.equal(cost.priced, true);
33
+ assert.equal(cost.provider, 'anthropic');
34
+ assert.equal(cost.totalUSD, 36.75);
35
+ assert.equal(cost.ratesPerMTok.cacheWrite, 6.25);
36
+ });
37
+
38
+ test('supports official DeepSeek and Xiaomi cache-hit pricing', () => {
39
+ const deepseek = calculateOfficialCost('deepseek-v4-pro', {
40
+ input: 1_000_000,
41
+ cacheRead: 1_000_000,
42
+ output: 1_000_000
43
+ });
44
+ const mimo = calculateOfficialCost('mimo-v2.5-pro', {
45
+ input: 1_000_000,
46
+ cacheRead: 1_000_000,
47
+ output: 1_000_000
48
+ });
49
+
50
+ assert.equal(deepseek.totalUSD, 1.308625);
51
+ assert.equal(mimo.totalUSD, 1.3086);
52
+ });
53
+
54
+ test('does not invent prices for research-preview or unknown models', () => {
55
+ const spark = calculateOfficialCost('gpt-5.3-codex-spark', {
56
+ input: 1_000_000,
57
+ output: 1_000_000
58
+ });
59
+ const unknown = calculateCost('made-up-model', { input: 1_000_000, output: 1_000_000 });
60
+
61
+ assert.equal(spark.priced, false);
62
+ assert.equal(spark.totalUSD, 0);
63
+ assert.match(spark.reason, /research preview/);
64
+ assert.equal(unknown, 0);
65
+ });
66
+
67
+ test('resolves dated provider aliases without falling through to shorter model names', () => {
68
+ assert.equal(resolveOfficialPricing('openai/gpt-5.3-codex-spark').priced, false);
69
+ assert.equal(resolveOfficialPricing('claude-opus-4.7-20260420').model, 'claude-opus-4-7');
70
+ });
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildFirstRunState } from '../src/client/dashboard/onboarding.js';
4
+
5
+ test('first-run state points empty databases to demo or import', () => {
6
+ const state = buildFirstRunState({
7
+ daily: [],
8
+ sessions: [],
9
+ budgetProfiles: [],
10
+ advisorActions: [],
11
+ tokenEvents: []
12
+ });
13
+ assert.equal(state.hasUsage, false);
14
+ assert.equal(state.shouldShow, true);
15
+ assert.equal(state.steps[0].status, 'todo');
16
+ assert.equal(state.notices[0].id, 'no-data');
17
+ });
18
+
19
+ test('first-run state asks review users to create advisor actions after usage exists', () => {
20
+ const state = buildFirstRunState({
21
+ daily: [{ usageDate: '2026-06-17', totalTokens: 100 }],
22
+ sessions: [{ sessionId: 's1', totalTokens: 100 }],
23
+ budgetProfiles: [],
24
+ advisorActions: [],
25
+ tokenEvents: []
26
+ });
27
+ assert.equal(state.hasUsage, true);
28
+ assert.equal(state.hasActions, false);
29
+ assert.ok(state.notices.some(notice => notice.id === 'no-actions'));
30
+ });
31
+
32
+ test('first-run state explains budgets without event-level live data', () => {
33
+ const state = buildFirstRunState({
34
+ daily: [{ usageDate: '2026-06-17', totalTokens: 100 }],
35
+ sessions: [{ sessionId: 's1', totalTokens: 100 }],
36
+ budgetProfiles: [{ id: 1, label: 'Codex 1h' }],
37
+ advisorActions: [{ id: 1, status: 'open' }],
38
+ tokenEvents: []
39
+ });
40
+ assert.equal(state.hasBudget, true);
41
+ assert.equal(state.hasLiveEvents, false);
42
+ assert.ok(state.notices.some(notice => notice.id === 'budget-no-live-events'));
43
+ });
44
+
45
+ test('first-run state hides when data, budgets, actions and live events exist', () => {
46
+ const state = buildFirstRunState({
47
+ daily: [{ usageDate: '2026-06-17', totalTokens: 100 }],
48
+ sessions: [{ sessionId: 's1', totalTokens: 100 }],
49
+ budgetProfiles: [{ id: 1, label: 'Codex 1h' }],
50
+ advisorActions: [{ id: 1, status: 'open' }],
51
+ tokenEvents: [{ eventId: 'e1', totalTokens: 100 }]
52
+ });
53
+ assert.equal(state.shouldShow, false);
54
+ assert.deepEqual(state.notices, []);
55
+ });
@@ -0,0 +1,33 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { runPrivacyCheck } from '../src/privacy-check.mjs';
8
+
9
+ function gitFixture() {
10
+ const dir = mkdtempSync(join(tmpdir(), 'token-studio-roi-privacy-'));
11
+ spawnSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
12
+ return dir;
13
+ }
14
+
15
+ test('privacy check passes clean tracked docs', () => {
16
+ const dir = gitFixture();
17
+ writeFileSync(join(dir, 'README.md'), '# Demo\n\nNo private data.\n');
18
+ spawnSync('git', ['add', 'README.md'], { cwd: dir, stdio: 'ignore' });
19
+ const result = runPrivacyCheck({ cwd: dir });
20
+ assert.equal(result.ok, true);
21
+ });
22
+
23
+ test('privacy check blocks real db and personal paths', () => {
24
+ const dir = gitFixture();
25
+ mkdirSync(join(dir, 'data'));
26
+ writeFileSync(join(dir, 'data', 'usage.sqlite'), 'not a real sqlite but blocked by path');
27
+ writeFileSync(join(dir, 'notes.md'), 'Path: C:\\Users\\someone\\secret');
28
+ spawnSync('git', ['add', 'data/usage.sqlite', 'notes.md'], { cwd: dir, stdio: 'ignore' });
29
+ const result = runPrivacyCheck({ cwd: dir });
30
+ assert.equal(result.ok, false);
31
+ assert.equal(result.issues.some(issue => issue.id === 'sqlite-db'), true);
32
+ assert.equal(result.issues.some(issue => issue.id === 'personal-windows-path'), true);
33
+ });
@@ -0,0 +1,99 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ buildReviewClosureProgress,
5
+ hasClosureOutputLink,
6
+ isClosureAttributedSession
7
+ } from '../src/client/review/closure-progress.js';
8
+
9
+ const baseSession = {
10
+ device: 'local',
11
+ source: 'Codex CLI',
12
+ sessionId: 'session-1',
13
+ projectAlias: 'Token Studio',
14
+ taskType: '功能开发',
15
+ outputStatus: '已完成',
16
+ workPurpose: '功能开发',
17
+ workStage: '实现',
18
+ valueLevel: '高',
19
+ totalTokens: 1000,
20
+ costUSD: 1.2,
21
+ model: 'gpt-5.3-codex'
22
+ };
23
+
24
+ test('closure attribution requires project alias plus v3 review labels', () => {
25
+ assert.equal(isClosureAttributedSession(baseSession), true);
26
+ assert.equal(isClosureAttributedSession({ ...baseSession, projectAlias: '' }), false);
27
+ assert.equal(isClosureAttributedSession({ ...baseSession, valueLevel: '未评估' }), false);
28
+ });
29
+
30
+ test('closure output links only count completed or published http urls', () => {
31
+ assert.equal(hasClosureOutputLink({
32
+ ...baseSession,
33
+ outputStatus: '已发布',
34
+ outputUrl: 'https://example.com/pr/1'
35
+ }), true);
36
+ assert.equal(hasClosureOutputLink({
37
+ ...baseSession,
38
+ outputStatus: '进行中',
39
+ outputUrl: 'https://example.com/pr/1'
40
+ }), false);
41
+ assert.equal(hasClosureOutputLink({
42
+ ...baseSession,
43
+ outputStatus: '已完成',
44
+ outputUrl: 'file:///private.txt'
45
+ }), false);
46
+ });
47
+
48
+ test('buildReviewClosureProgress tracks P0 real-data acceptance gates', () => {
49
+ const completeSessions = Array.from({ length: 10 }, (_, index) => ({
50
+ ...baseSession,
51
+ sessionId: `complete-${index + 1}`,
52
+ totalTokens: 1000 + index,
53
+ costUSD: 1 + index / 10,
54
+ outputStatus: index < 3 ? '已发布' : '已完成',
55
+ outputUrl: index < 3 ? `https://example.com/output/${index + 1}` : ''
56
+ }));
57
+
58
+ const progress = buildReviewClosureProgress({
59
+ sessions: [
60
+ ...completeSessions,
61
+ {
62
+ ...baseSession,
63
+ sessionId: 'missing-project',
64
+ projectAlias: '',
65
+ totalTokens: 9000,
66
+ costUSD: 9
67
+ }
68
+ ],
69
+ roiAdvice: [
70
+ { id: 'attribute-high-cost-work', category: '补标注' },
71
+ { id: 'reduce-context-bloat', category: '上下文压缩' }
72
+ ]
73
+ });
74
+
75
+ assert.equal(progress.status, 'complete');
76
+ assert.equal(progress.completedChecks, 4);
77
+ assert.equal(progress.checks.find(check => check.id === 'real-attribution').current, 10);
78
+ assert.equal(progress.checks.find(check => check.id === 'lazy-auto-attribution').complete, true);
79
+ assert.equal(progress.checks.find(check => check.id === 'output-links').current, 3);
80
+ assert.equal(progress.checks.find(check => check.id === 'non-label-advice').current, 1);
81
+ assert.deepEqual(progress.topGaps[0].missingFields, ['项目别名']);
82
+ });
83
+
84
+ test('buildReviewClosureProgress surfaces next actions when gates are not met', () => {
85
+ const progress = buildReviewClosureProgress({
86
+ sessions: [
87
+ { ...baseSession, sessionId: 'missing-value', valueLevel: '未评估', totalTokens: 5000, costUSD: 5 },
88
+ { ...baseSession, sessionId: 'missing-project', projectAlias: '', totalTokens: 7000, costUSD: 7 }
89
+ ],
90
+ roiAdvice: [{ id: 'attribute-high-cost-work', category: '补标注' }]
91
+ });
92
+
93
+ assert.equal(progress.status, 'needs-work');
94
+ assert.equal(progress.remainingChecks, 4);
95
+ assert.equal(progress.topGaps[0].sessionId, 'missing-project');
96
+ assert.match(progress.nextActions.join('\n'), /先处理 missing-project/);
97
+ assert.match(progress.nextActions.join('\n'), /产出链接/);
98
+ assert.match(progress.nextActions.join('\n'), /ROI Advisor/);
99
+ });
@@ -0,0 +1,188 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ buildRoiAdvisor,
5
+ isRoiUnattributed,
6
+ modelTier
7
+ } from '../src/client/review/roi-advisor.js';
8
+
9
+ test('modelTier classifies heavy, mid, light and unpriced models', () => {
10
+ assert.equal(modelTier('gpt-5.5', 'priced'), 'heavy');
11
+ assert.equal(modelTier('claude-opus-4-7', 'priced'), 'heavy');
12
+ assert.equal(modelTier('gpt-5.3-codex', 'priced'), 'mid');
13
+ assert.equal(modelTier('claude-sonnet-4-6', 'priced'), 'mid');
14
+ assert.equal(modelTier('deepseek-v4-pro', 'priced'), 'light');
15
+ assert.equal(modelTier('mimo-v2.5-pro', 'priced'), 'light');
16
+ assert.equal(modelTier('gpt-5.3-codex-spark', 'unpriced'), 'unpriced');
17
+ assert.equal(modelTier('<synthetic>', ''), 'unpriced');
18
+ });
19
+
20
+ test('isRoiUnattributed requires purpose and value, not just task status', () => {
21
+ assert.equal(isRoiUnattributed({
22
+ taskType: '功能开发',
23
+ outputStatus: '已完成',
24
+ workPurpose: '功能开发',
25
+ workStage: '实现',
26
+ valueLevel: '高'
27
+ }), false);
28
+ assert.equal(isRoiUnattributed({
29
+ taskType: '功能开发',
30
+ outputStatus: '已完成',
31
+ workPurpose: '未说明',
32
+ workStage: '实现',
33
+ valueLevel: '高'
34
+ }), true);
35
+ assert.equal(isRoiUnattributed({
36
+ taskType: '功能开发',
37
+ outputStatus: '已完成',
38
+ workPurpose: '功能开发',
39
+ workStage: '未说明',
40
+ valueLevel: '高'
41
+ }), true);
42
+ });
43
+
44
+ test('advisor prioritizes high cost unattributed work', () => {
45
+ const suggestions = buildRoiAdvisor({
46
+ sessions: [{
47
+ sessionId: 's1',
48
+ projectPath: 'D:\\AIResume',
49
+ model: 'gpt-5.5',
50
+ taskType: '未分类',
51
+ outputStatus: '未标注',
52
+ workPurpose: '未说明',
53
+ valueLevel: '未评估',
54
+ inputTokens: 800_000,
55
+ outputTokens: 40_000,
56
+ cacheReadTokens: 0,
57
+ totalTokens: 1_000_000,
58
+ costUSD: 20,
59
+ pricingStatus: 'priced'
60
+ }]
61
+ });
62
+
63
+ assert.equal(suggestions[0].id, 'attribute-high-cost-work');
64
+ assert.equal(suggestions[0].category, '补标注');
65
+ assert.equal(suggestions[0].impact, '高');
66
+ assert.match(suggestions[0].recommendation, /主要目的/);
67
+ });
68
+
69
+ test('advisor recommends light models for testing and exploration on heavy models', () => {
70
+ const suggestions = buildRoiAdvisor({
71
+ sessions: [{
72
+ sessionId: 's1',
73
+ model: 'claude-opus-4-7',
74
+ taskType: '问题修复',
75
+ outputStatus: '进行中',
76
+ workPurpose: '测试验证',
77
+ workStage: '验证',
78
+ valueLevel: '中',
79
+ inputTokens: 100_000,
80
+ outputTokens: 20_000,
81
+ cacheReadTokens: 0,
82
+ totalTokens: 120_000,
83
+ costUSD: 5,
84
+ pricingStatus: 'priced'
85
+ }]
86
+ });
87
+
88
+ const suggestion = suggestions.find(item => item.id === 'use-light-model-for-exploration');
89
+ assert.ok(suggestion);
90
+ assert.equal(suggestion.category, '模型切换');
91
+ });
92
+
93
+ test('advisor flags discarded or low value high cost work as stop-loss risk', () => {
94
+ const suggestions = buildRoiAdvisor({
95
+ sessions: [{
96
+ sessionId: 's1',
97
+ model: 'gpt-5.5',
98
+ taskType: '技术调研',
99
+ outputStatus: '已废弃',
100
+ workPurpose: '技术调研',
101
+ workStage: '探索',
102
+ valueLevel: '低',
103
+ inputTokens: 400_000,
104
+ outputTokens: 20_000,
105
+ cacheReadTokens: 0,
106
+ totalTokens: 500_000,
107
+ costUSD: 15,
108
+ pricingStatus: 'priced'
109
+ }]
110
+ });
111
+
112
+ const suggestion = suggestions.find(item => item.id === 'stop-loss-low-value-work');
113
+ assert.ok(suggestion);
114
+ assert.equal(suggestion.category, '止损');
115
+ });
116
+
117
+ test('advisor preserves high value completed work on light or mid models', () => {
118
+ const suggestions = buildRoiAdvisor({
119
+ sessions: [{
120
+ sessionId: 's1',
121
+ model: 'deepseek-v4-pro',
122
+ taskType: '功能开发',
123
+ outputStatus: '已发布',
124
+ workPurpose: '功能开发',
125
+ workStage: '发布',
126
+ valueLevel: '关键',
127
+ inputTokens: 100_000,
128
+ outputTokens: 50_000,
129
+ cacheReadTokens: 10_000,
130
+ totalTokens: 160_000,
131
+ costUSD: 0.2,
132
+ pricingStatus: 'priced'
133
+ }]
134
+ });
135
+
136
+ const suggestion = suggestions.find(item => item.id === 'keep-high-value-low-cost-pattern');
137
+ assert.ok(suggestion);
138
+ assert.equal(suggestion.category, '保留策略');
139
+ });
140
+
141
+ test('advisor does not preserve high value work when the session is still high cost', () => {
142
+ const suggestions = buildRoiAdvisor({
143
+ sessions: [{
144
+ sessionId: 's1',
145
+ model: 'claude-sonnet-4-6',
146
+ taskType: '功能开发',
147
+ outputStatus: '已发布',
148
+ workPurpose: '功能开发',
149
+ workStage: '发布',
150
+ valueLevel: '关键',
151
+ inputTokens: 900_000,
152
+ outputTokens: 100_000,
153
+ cacheReadTokens: 0,
154
+ totalTokens: 1_000_000,
155
+ costUSD: 20,
156
+ pricingStatus: 'priced'
157
+ }]
158
+ });
159
+
160
+ assert.equal(suggestions.some(item => item.id === 'keep-high-value-low-cost-pattern'), false);
161
+ });
162
+
163
+ test('advisor emits context compression and unpriced model advice without fake cost', () => {
164
+ const suggestions = buildRoiAdvisor({
165
+ sessions: [{
166
+ sessionId: 's1',
167
+ model: 'gpt-5.3-codex-spark',
168
+ taskType: '功能开发',
169
+ outputStatus: '已完成',
170
+ workPurpose: '功能开发',
171
+ workStage: '实现',
172
+ valueLevel: '中',
173
+ inputTokens: 900_000,
174
+ outputTokens: 50_000,
175
+ cacheReadTokens: 0,
176
+ totalTokens: 1_000_000,
177
+ costUSD: 0,
178
+ pricingStatus: 'unpriced'
179
+ }]
180
+ });
181
+
182
+ assert.equal(suggestions.some(item => item.id === 'reduce-context-bloat'), true);
183
+ assert.equal(suggestions.find(item => item.id === 'reduce-context-bloat')?.category, '上下文压缩');
184
+ const unpriced = suggestions.find(item => item.id === 'keep-unpriced-models-out-of-cost-decisions');
185
+ assert.ok(unpriced);
186
+ assert.equal(unpriced.category, '未定价模型');
187
+ assert.match(unpriced.recommendation, /不把 \$0 当成免费/);
188
+ });
@@ -0,0 +1,48 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildRoiEvidence } from '../src/client/review/roi-evidence.js';
4
+
5
+ test('ROI evidence score rewards complete manual attribution and outputs', () => {
6
+ const evidence = buildRoiEvidence({
7
+ workItems: [{ id: 1 }],
8
+ sessions: [
9
+ {
10
+ projectAlias: 'Token Studio ROI',
11
+ taskType: '功能开发',
12
+ outputStatus: '已发布',
13
+ workPurpose: '功能开发',
14
+ workStage: '发布',
15
+ valueLevel: '高',
16
+ annotationSource: 'manual',
17
+ outputUrl: 'https://example.com/pr/1',
18
+ totalTokens: 1000,
19
+ costUSD: 1
20
+ }
21
+ ]
22
+ });
23
+ assert.equal(evidence.evidenceScore, 100);
24
+ assert.equal(evidence.complete, 1);
25
+ assert.equal(evidence.withOutput, 1);
26
+ });
27
+
28
+ test('ROI evidence surfaces high cost gaps when fields are missing', () => {
29
+ const evidence = buildRoiEvidence({
30
+ sessions: [
31
+ {
32
+ projectPath: 'D:/Projects/token-studio-roi',
33
+ taskType: '未分类',
34
+ outputStatus: '未标注',
35
+ workPurpose: '未说明',
36
+ workStage: '未说明',
37
+ valueLevel: '未评估',
38
+ annotationSource: 'auto',
39
+ annotationConfidence: 85,
40
+ sessionId: 's1',
41
+ totalTokens: 5000,
42
+ costUSD: 9
43
+ }
44
+ ]
45
+ });
46
+ assert.equal(evidence.evidenceScore < 50, true);
47
+ assert.equal(evidence.highCostGaps[0].missing.includes('人工确认'), true);
48
+ });
@@ -0,0 +1,101 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ buildProjectRoiRows,
5
+ buildRiskDistribution,
6
+ buildWeeklyReview
7
+ } from '../src/client/dashboard/attribution.js';
8
+
9
+ const sessions = [
10
+ {
11
+ sessionId: 'published',
12
+ projectAlias: 'Token Studio',
13
+ taskType: '功能开发',
14
+ outputStatus: '已发布',
15
+ lastActivity: '2026-06-10',
16
+ totalTokens: 1000,
17
+ costUSD: 1,
18
+ outputUrl: 'https://example.com/published',
19
+ outputLabel: '发布页'
20
+ },
21
+ {
22
+ sessionId: 'completed',
23
+ projectAlias: 'Token Studio',
24
+ taskType: '问题修复',
25
+ outputStatus: '已完成',
26
+ lastActivity: '2026-06-09',
27
+ totalTokens: 500,
28
+ costUSD: 0.5
29
+ },
30
+ {
31
+ sessionId: 'discarded',
32
+ projectPath: 'D:\\Other',
33
+ taskType: '技术调研',
34
+ outputStatus: '已废弃',
35
+ lastActivity: '2026-06-08',
36
+ totalTokens: 300,
37
+ costUSD: 0.3
38
+ },
39
+ {
40
+ sessionId: 'unattributed',
41
+ projectPath: 'D:\\Other',
42
+ taskType: '未分类',
43
+ outputStatus: '进行中',
44
+ lastActivity: '2026-06-07',
45
+ totalTokens: 200,
46
+ costUSD: 0.2
47
+ },
48
+ {
49
+ sessionId: 'old',
50
+ projectAlias: 'Old Project',
51
+ taskType: '功能开发',
52
+ outputStatus: '已发布',
53
+ lastActivity: '2026-05-01',
54
+ totalTokens: 900,
55
+ costUSD: 0.9,
56
+ outputUrl: 'https://example.com/old'
57
+ }
58
+ ];
59
+
60
+ test('buildRiskDistribution aggregates unattributed, in-progress and discarded cost shares', () => {
61
+ const rows = buildRiskDistribution(sessions);
62
+ const byId = Object.fromEntries(rows.map(row => [row.id, row]));
63
+
64
+ assert.equal(byId.unattributed.sessionCount, 1);
65
+ assert.equal(byId.unattributed.totalTokens, 200);
66
+ assert.equal(byId.inProgress.sessionCount, 1);
67
+ assert.equal(byId.inProgress.totalTokens, 200);
68
+ assert.equal(byId.discarded.sessionCount, 1);
69
+ assert.equal(byId.discarded.costUSD, 0.3);
70
+ assert.equal(Number(byId.discarded.share.toFixed(4)), Number((300 / 2900).toFixed(4)));
71
+ });
72
+
73
+ test('buildProjectRoiRows keeps project status buckets consistent with session totals', () => {
74
+ const rows = buildProjectRoiRows(sessions);
75
+ const tokenStudio = rows.find(row => row.project === 'Token Studio');
76
+ const other = rows.find(row => row.project === 'D:\\Other');
77
+
78
+ assert.equal(tokenStudio.sessionCount, 2);
79
+ assert.equal(tokenStudio.totalTokens, 1500);
80
+ assert.equal(tokenStudio.publishedTokens, 1000);
81
+ assert.equal(tokenStudio.completedTokens, 500);
82
+ assert.equal(tokenStudio.productiveShare, 1);
83
+
84
+ assert.equal(other.totalTokens, 500);
85
+ assert.equal(other.discardedTokens, 300);
86
+ assert.equal(other.unattributedTokens, 200);
87
+ assert.equal(other.riskShare, 1);
88
+ });
89
+
90
+ test('buildWeeklyReview uses the last seven days and exposes published output links', () => {
91
+ const weekly = buildWeeklyReview(sessions, { today: '2026-06-10' });
92
+
93
+ assert.equal(weekly.startDate, '2026-06-04');
94
+ assert.equal(weekly.endDate, '2026-06-10');
95
+ assert.equal(weekly.totals.totalTokens, 2000);
96
+ assert.equal(weekly.highCostProjects[0].project, 'Token Studio');
97
+ assert.equal(weekly.discarded.totalTokens, 300);
98
+ assert.deepEqual(weekly.unattributedQueue.map(row => row.sessionId), ['unattributed']);
99
+ assert.equal(weekly.publishedOutputs.length, 1);
100
+ assert.equal(weekly.publishedOutputs[0].outputLabel, '发布页');
101
+ });