@goodandready/dsh-moa 0.2.2 → 0.2.4
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/README.md +14 -0
- package/docs/README.ru.md +14 -0
- package/lib/client.js +55 -3
- package/lib/file-workspace.js +94 -5
- package/lib/history.js +191 -0
- package/lib/index.js +185 -164
- package/lib/moa-runner.js +237 -86
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,10 @@ Single-model AI generation often suffers from blind spots, single-perspective bi
|
|
|
37
37
|
4. **Instant Live Canvas Previewing**: When web applications or UI components are generated, `dsh-moa` integrates seamlessly with `@goodandready/dsh-live-canvas`, automatically spawning sandboxes for 1-click browser previewing.
|
|
38
38
|
5. **Token-Saving Chat Summarization**: Replaces massive code dumps in chat bubbles with compact file listings and clean architectural summaries.
|
|
39
39
|
6. **One-Shot Session Model Restoration**: Executes cleanly as a one-shot turn modifier, automatically reverting back to the user's primary session model immediately after completion.
|
|
40
|
+
7. **Inherent Cost Tracking & Token Estimation**: Embedded pricing calculator for frontier models estimating token usage and run costs directly in the summary badge without external dependencies.
|
|
41
|
+
8. **Refinement Mode (Incremental Edits)**: Automatically detects existing codebase context to generate precise delta modifications instead of destructive full-file rewrites.
|
|
42
|
+
9. **Fast Mode & Custom Judge Criteria**: Ultra-fast single-model preset for quick tasks and customizable evaluation guidelines for the judge.
|
|
43
|
+
10. **Run History & Win-Rate Leaderboard**: Persistent logging with built-in REST endpoints (`/dsh-moa/history` and `/dsh-moa/leaderboard`).
|
|
40
44
|
|
|
41
45
|
---
|
|
42
46
|
|
|
@@ -175,6 +179,16 @@ dsh-moa:
|
|
|
175
179
|
|
|
176
180
|
---
|
|
177
181
|
|
|
182
|
+
## 📊 REST API & Endpoints
|
|
183
|
+
|
|
184
|
+
| Endpoint | Method | Description |
|
|
185
|
+
|:---|:---|:---|
|
|
186
|
+
| `/dsh-moa/presets` | `GET` | Returns list of configured MoA presets |
|
|
187
|
+
| `/dsh-moa/history?limit=20&offset=0` | `GET` | Returns recent MoA runs with candidates, winner, cost, and tokens |
|
|
188
|
+
| `/dsh-moa/leaderboard` | `GET` | Computes model win-rate leaderboard and average execution costs |
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
178
192
|
## 🧪 Testing
|
|
179
193
|
|
|
180
194
|
Run the automated test suite:
|
package/docs/README.ru.md
CHANGED
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
4. **Мгновенный предпросмотр через Live Canvas**: При создании веб-приложений и UI-компонентов `dsh-moa` бесшовно интегрируется с `@goodandready/dsh-live-canvas`, автоматически инициализируя контейнер для предпросмотра в браузере в 1 клик.
|
|
38
38
|
5. **Экономия токенов в чате**: Вместо вывода огромных листингов кода в чат формируется компактный отчет с перечнем созданных файлов и архитектурным резюме.
|
|
39
39
|
6. **Одноразовый модификатор сессии**: Команда выполняется в рамках одного такта и автоматически возвращает исходную модель сессии пользователя сразу после завершения.
|
|
40
|
+
7. **Встроенный подсчет стоимости и токенов**: Собственный калькулятор стоимости моделей оценивает расход токенов и цену запуска напрямую в бейдже без внешних плагинов.
|
|
41
|
+
8. **Режим доработки (Refinement Mode)**: Автоматически считывает контекст существующего проекта и генерирует точечные дельта-правки без перетирания всей кодовой базы.
|
|
42
|
+
9. **Быстрый режим (Fast Mode) и критерии судьи**: Режим для одиночных быстрых задач без судьи и гибкая настройка фокуса оценки (безопасность, производительность, минимализм).
|
|
43
|
+
10. **История запусков и лидерборд моделей**: Персистентное логирование запусков и REST-эндпоинты (`/dsh-moa/history` и `/dsh-moa/leaderboard`).
|
|
40
44
|
|
|
41
45
|
---
|
|
42
46
|
|
|
@@ -175,6 +179,16 @@ dsh-moa:
|
|
|
175
179
|
|
|
176
180
|
---
|
|
177
181
|
|
|
182
|
+
## 📊 REST API эндпоинты
|
|
183
|
+
|
|
184
|
+
| Эндпоинт | Метод | Описание |
|
|
185
|
+
|:---|:---|:---|
|
|
186
|
+
| `/dsh-moa/presets` | `GET` | Возвращает список настроенных пресетов MoA |
|
|
187
|
+
| `/dsh-moa/history?limit=20&offset=0` | `GET` | История запусков с кандидатами, победителем, токенами и ценой |
|
|
188
|
+
| `/dsh-moa/leaderboard` | `GET` | Лидерборд побед моделей и средняя стоимость генерации |
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
178
192
|
## 🧪 Тестирование
|
|
179
193
|
|
|
180
194
|
Запуск автоматического набора тестов:
|
package/lib/client.js
CHANGED
|
@@ -6,6 +6,7 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
let React = require('react')
|
|
7
7
|
|
|
8
8
|
const NS = 'dsh-moa'
|
|
9
|
+
let localeRegistered = false
|
|
9
10
|
|
|
10
11
|
let ChevronIcon = null
|
|
11
12
|
try {
|
|
@@ -621,6 +622,30 @@ window.__ModuleLoader__.load({
|
|
|
621
622
|
})
|
|
622
623
|
),
|
|
623
624
|
React.createElement('div', { className: 'moa-hint' }, t('aggTempHint') || 'Рекомендуется 0.2 – 0.4 для точной проверки и синтеза без галлюцинаций.')
|
|
625
|
+
),
|
|
626
|
+
React.createElement(
|
|
627
|
+
'div',
|
|
628
|
+
{ className: 'moa-field', style: { marginTop: 4, borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 8 } },
|
|
629
|
+
React.createElement('label', { className: 'moa-label', style: { fontSize: 12 } }, t('judgeCriteriaLabel') || 'Критерии оценки судьи (опционально):'),
|
|
630
|
+
React.createElement('textarea', {
|
|
631
|
+
className: 'moa-textarea',
|
|
632
|
+
rows: 2,
|
|
633
|
+
style: {
|
|
634
|
+
width: '100%',
|
|
635
|
+
boxSizing: 'border-box',
|
|
636
|
+
padding: '8px 10px',
|
|
637
|
+
borderRadius: 8,
|
|
638
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
639
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
640
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
641
|
+
fontSize: 13,
|
|
642
|
+
resize: 'vertical',
|
|
643
|
+
outline: 'none',
|
|
644
|
+
},
|
|
645
|
+
placeholder: t('judgeCriteriaPlaceholder') || 'например: Приоритет — производительность, чистый код, минимум зависимостей...',
|
|
646
|
+
value: judgeCriteria,
|
|
647
|
+
onChange: (e) => setJudgeCriteria(e.target.value),
|
|
648
|
+
})
|
|
624
649
|
)
|
|
625
650
|
),
|
|
626
651
|
/* Proposers / Advisors Box with Temperature Slider */
|
|
@@ -911,9 +936,14 @@ window.__ModuleLoader__.load({
|
|
|
911
936
|
)
|
|
912
937
|
}
|
|
913
938
|
|
|
914
|
-
exports.inject = ['slots', 'locale', '
|
|
939
|
+
exports.inject = ['slots', 'locale', 'inputTriggers']
|
|
915
940
|
exports.apply = function apply(ctx) {
|
|
916
|
-
|
|
941
|
+
const registerLocale = () => {
|
|
942
|
+
if (localeRegistered) return
|
|
943
|
+
try {
|
|
944
|
+
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
945
|
+
localeRegistered = true
|
|
946
|
+
return ctx.locale.register(NS, {
|
|
917
947
|
en: {
|
|
918
948
|
title: 'Mixture of Agents (MoA)',
|
|
919
949
|
description: 'Run tasks with multiple parallel proposer models and a synthesizing judge model',
|
|
@@ -954,6 +984,8 @@ window.__ModuleLoader__.load({
|
|
|
954
984
|
noModelsMatch: 'No models found matching query.',
|
|
955
985
|
useCustom: 'Use custom',
|
|
956
986
|
selectModel: 'Select model...',
|
|
987
|
+
judgeCriteriaLabel: 'Custom Judge Evaluation Criteria (optional):',
|
|
988
|
+
judgeCriteriaPlaceholder: 'e.g. Priority: performance, zero external dependencies, robust edge-case handling...',
|
|
957
989
|
},
|
|
958
990
|
ru: {
|
|
959
991
|
title: 'Mixture of Agents (MoA)',
|
|
@@ -995,10 +1027,30 @@ window.__ModuleLoader__.load({
|
|
|
995
1027
|
noModelsMatch: 'Модели не найдены.',
|
|
996
1028
|
useCustom: 'Использовать кастомное',
|
|
997
1029
|
selectModel: 'Выберите модель...',
|
|
1030
|
+
judgeCriteriaLabel: 'Критерии оценки судьи (опционально):',
|
|
1031
|
+
judgeCriteriaPlaceholder: 'например: Приоритет — производительность, минимум зависимостей, обработка граничных случаев...',
|
|
998
1032
|
},
|
|
999
1033
|
})
|
|
1034
|
+
}
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
console.warn('[dsh-moa] locale registration error:', err)
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (typeof ctx.effect === 'function') {
|
|
1041
|
+
ctx.effect(() => registerLocale(), 'dsh-moa: dictionaries')
|
|
1042
|
+
} else {
|
|
1043
|
+
registerLocale()
|
|
1044
|
+
}
|
|
1000
1045
|
|
|
1001
|
-
const t =
|
|
1046
|
+
const t = (key) => {
|
|
1047
|
+
try {
|
|
1048
|
+
if (ctx.locale && typeof ctx.locale.bind === 'function') {
|
|
1049
|
+
return ctx.locale.bind(NS)(key) || key
|
|
1050
|
+
}
|
|
1051
|
+
} catch {}
|
|
1052
|
+
return key
|
|
1053
|
+
}
|
|
1002
1054
|
|
|
1003
1055
|
if (ctx.slots) {
|
|
1004
1056
|
const registerDirectSection = () => {
|
package/lib/file-workspace.js
CHANGED
|
@@ -85,6 +85,89 @@ function sanitizePath(filePath) {
|
|
|
85
85
|
return normalized.replace(/^[\\\/]+/, '')
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Collects readable text files from current workspace to feed as context for candidate models (#6).
|
|
90
|
+
* Ignores node_modules, .git, .moa, binary files, large bundles.
|
|
91
|
+
*/
|
|
92
|
+
export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
|
|
93
|
+
if (!baseDir) return { files: [], totalChars: 0 }
|
|
94
|
+
|
|
95
|
+
const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh'])
|
|
96
|
+
const codeExts = new Set([
|
|
97
|
+
'.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
|
|
98
|
+
'.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml',
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
const collected = []
|
|
102
|
+
let totalChars = 0
|
|
103
|
+
|
|
104
|
+
async function scan(currentDir, relPrefix = '') {
|
|
105
|
+
if (totalChars >= maxCharBudget) return
|
|
106
|
+
try {
|
|
107
|
+
const list = await fs.readdir(currentDir, { withFileTypes: true })
|
|
108
|
+
for (const item of list) {
|
|
109
|
+
if (totalChars >= maxCharBudget) break
|
|
110
|
+
if (item.name.startsWith('.') && item.name !== '.env') continue
|
|
111
|
+
|
|
112
|
+
const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
|
|
113
|
+
const full = path.join(currentDir, item.name)
|
|
114
|
+
|
|
115
|
+
if (item.isDirectory()) {
|
|
116
|
+
if (!ignoreDirs.has(item.name)) {
|
|
117
|
+
await scan(full, rel)
|
|
118
|
+
}
|
|
119
|
+
} else if (item.isFile()) {
|
|
120
|
+
const ext = path.extname(item.name).toLowerCase()
|
|
121
|
+
if (codeExts.has(ext)) {
|
|
122
|
+
try {
|
|
123
|
+
const stat = await fs.stat(full)
|
|
124
|
+
if (stat.size > 100000) continue // skip files > 100kb
|
|
125
|
+
const content = await fs.readFile(full, 'utf8')
|
|
126
|
+
const budgetRemaining = maxCharBudget - totalChars
|
|
127
|
+
const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
|
|
128
|
+
totalChars += truncated.length
|
|
129
|
+
collected.push({ relativePath: rel, content: truncated, size: stat.size })
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await scan(baseDir)
|
|
138
|
+
return { files: collected, totalChars }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Checks if a task prompt is an iterative refinement / modification
|
|
143
|
+
* of an existing project rather than a greenfield project creation (#4).
|
|
144
|
+
*/
|
|
145
|
+
export function isRefinementTask(userPrompt = '', projectFiles = []) {
|
|
146
|
+
if (!Array.isArray(projectFiles) || projectFiles.length === 0) return false
|
|
147
|
+
const p = userPrompt.toLowerCase().trim()
|
|
148
|
+
if (!p) return false
|
|
149
|
+
|
|
150
|
+
// Greenfield creation phrases - ALWAYS fresh task
|
|
151
|
+
const freshPhrases = [
|
|
152
|
+
'с нуля', 'from scratch', 'новый проект', 'новое приложение', 'new project', 'new app',
|
|
153
|
+
'создай проект', 'создай приложение', 'создай игру', 'создай сервис',
|
|
154
|
+
'сделай проект', 'сделай приложение', 'сделай игру',
|
|
155
|
+
'create a new', 'build a new', 'generate a new',
|
|
156
|
+
]
|
|
157
|
+
if (freshPhrases.some((phrase) => p.includes(phrase))) {
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Modification keywords
|
|
162
|
+
const modKeywords = [
|
|
163
|
+
'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали',
|
|
164
|
+
'add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust',
|
|
165
|
+
'style', 'css', 'темн', 'светл', 'color', 'цвет', 'кнопк', 'theme', 'bug', 'баг',
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
return modKeywords.some((kw) => p.includes(kw))
|
|
169
|
+
}
|
|
170
|
+
|
|
88
171
|
/**
|
|
89
172
|
* Writes candidate files into an isolated candidate workspace:
|
|
90
173
|
* <baseDir>/.moa/candidate-<index>/
|
|
@@ -114,7 +197,7 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
|
|
|
114
197
|
* Promotes winning candidate files to baseDir root,
|
|
115
198
|
* then cleans up the temporary .moa folder.
|
|
116
199
|
*/
|
|
117
|
-
export async function promoteCandidateWorkspace(baseDir, winningIndex) {
|
|
200
|
+
export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
|
|
118
201
|
if (!baseDir || winningIndex === null || winningIndex === undefined) return []
|
|
119
202
|
|
|
120
203
|
const winningDir = path.join(baseDir, '.moa', `candidate-${winningIndex}`)
|
|
@@ -123,7 +206,9 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex) {
|
|
|
123
206
|
try {
|
|
124
207
|
const entries = await getFilesRecursively(winningDir)
|
|
125
208
|
if (entries.length === 0) {
|
|
126
|
-
|
|
209
|
+
if (!options.keepMoa) {
|
|
210
|
+
try { await fs.rm(moaRoot, { recursive: true, force: true }) } catch {}
|
|
211
|
+
}
|
|
127
212
|
return []
|
|
128
213
|
}
|
|
129
214
|
|
|
@@ -137,12 +222,16 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex) {
|
|
|
137
222
|
promoted.push(rel)
|
|
138
223
|
}
|
|
139
224
|
|
|
140
|
-
|
|
141
|
-
|
|
225
|
+
if (!options.keepMoa) {
|
|
226
|
+
// Clean up temporary .moa folder
|
|
227
|
+
await fs.rm(moaRoot, { recursive: true, force: true })
|
|
228
|
+
}
|
|
142
229
|
|
|
143
230
|
return promoted
|
|
144
231
|
} catch (err) {
|
|
145
|
-
|
|
232
|
+
if (!options.keepMoa) {
|
|
233
|
+
try { await fs.rm(moaRoot, { recursive: true, force: true }) } catch {}
|
|
234
|
+
}
|
|
146
235
|
return []
|
|
147
236
|
}
|
|
148
237
|
}
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MoA Run History & Analytics Module
|
|
3
|
+
* Records every MoA pipeline execution, tracks model win rates and costs,
|
|
4
|
+
* and provides history search and run replay capabilities.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import crypto from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_HISTORY_DIR = path.join(os.homedir(), '.dsh')
|
|
13
|
+
const DEFAULT_HISTORY_FILE = path.join(DEFAULT_HISTORY_DIR, 'moa-history.jsonl')
|
|
14
|
+
|
|
15
|
+
export function getHistoryFilePath(customDir) {
|
|
16
|
+
if (customDir) {
|
|
17
|
+
return path.join(customDir, '.moa-history.jsonl')
|
|
18
|
+
}
|
|
19
|
+
return DEFAULT_HISTORY_FILE
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Appends a completed MoA run record to the history file.
|
|
24
|
+
*/
|
|
25
|
+
export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
|
|
26
|
+
try {
|
|
27
|
+
const dir = path.dirname(filePath)
|
|
28
|
+
if (!fs.existsSync(dir)) {
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const entry = {
|
|
33
|
+
id: record.id || crypto.randomUUID(),
|
|
34
|
+
timestamp: record.timestamp || new Date().toISOString(),
|
|
35
|
+
prompt: record.prompt || '',
|
|
36
|
+
preset: record.preset || 'default',
|
|
37
|
+
isRefinement: Boolean(record.isRefinement),
|
|
38
|
+
candidates: Array.isArray(record.candidates)
|
|
39
|
+
? record.candidates.map((c) => ({
|
|
40
|
+
provider: c.provider || '',
|
|
41
|
+
model: c.model || '',
|
|
42
|
+
filesCount: Array.isArray(c.files) ? c.files.length : (c.filesCount || 0),
|
|
43
|
+
usage: c.usage || { inputTokens: 0, outputTokens: 0 },
|
|
44
|
+
costUsd: Number(c.costUsd || 0),
|
|
45
|
+
}))
|
|
46
|
+
: [],
|
|
47
|
+
aggregator: record.aggregator
|
|
48
|
+
? {
|
|
49
|
+
provider: record.aggregator.provider || '',
|
|
50
|
+
model: record.aggregator.model || '',
|
|
51
|
+
usage: record.aggregator.usage || { inputTokens: 0, outputTokens: 0 },
|
|
52
|
+
costUsd: Number(record.aggregator.costUsd || 0),
|
|
53
|
+
}
|
|
54
|
+
: null,
|
|
55
|
+
winnerIndex: typeof record.winnerIndex === 'number' ? record.winnerIndex : -1,
|
|
56
|
+
winnerModel: record.winnerModel || '',
|
|
57
|
+
promotedFiles: Array.isArray(record.promotedFiles) ? record.promotedFiles : [],
|
|
58
|
+
totalTokens: Number(record.totalTokens || 0),
|
|
59
|
+
totalCostUsd: Number(record.totalCostUsd || 0),
|
|
60
|
+
durationMs: Number(record.durationMs || 0),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const line = JSON.stringify(entry) + '\n'
|
|
64
|
+
fs.appendFileSync(filePath, line, 'utf8')
|
|
65
|
+
return entry
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn('[dsh-moa] Failed to record run to history:', err)
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reads history runs with pagination (latest first).
|
|
74
|
+
*/
|
|
75
|
+
export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY_FILE) {
|
|
76
|
+
try {
|
|
77
|
+
if (!fs.existsSync(filePath)) {
|
|
78
|
+
return { total: 0, runs: [] }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
82
|
+
const lines = raw.trim().split('\n').filter(Boolean)
|
|
83
|
+
const total = lines.length
|
|
84
|
+
|
|
85
|
+
const sliced = lines
|
|
86
|
+
.slice(Math.max(0, total - offset - limit), total - offset)
|
|
87
|
+
.reverse()
|
|
88
|
+
.map((line) => {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(line)
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
|
|
97
|
+
return { total, runs: sliced }
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn('[dsh-moa] Failed to read history:', err)
|
|
100
|
+
return { total: 0, runs: [] }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Calculates win-rate leaderboard and model performance statistics across history.
|
|
106
|
+
*/
|
|
107
|
+
export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE) {
|
|
108
|
+
try {
|
|
109
|
+
if (!fs.existsSync(filePath)) {
|
|
110
|
+
return { totalRuns: 0, models: [] }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
114
|
+
const lines = raw.trim().split('\n').filter(Boolean)
|
|
115
|
+
const totalRuns = lines.length
|
|
116
|
+
|
|
117
|
+
const modelStats = new Map()
|
|
118
|
+
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
try {
|
|
121
|
+
const item = JSON.parse(line)
|
|
122
|
+
if (!item) continue
|
|
123
|
+
|
|
124
|
+
const winner = item.winnerModel ? String(item.winnerModel).trim() : null
|
|
125
|
+
|
|
126
|
+
if (Array.isArray(item.candidates)) {
|
|
127
|
+
for (const cand of item.candidates) {
|
|
128
|
+
const key = cand.provider && cand.model ? `${cand.provider}:${cand.model}` : (cand.model || 'unknown')
|
|
129
|
+
if (!modelStats.has(key)) {
|
|
130
|
+
modelStats.set(key, {
|
|
131
|
+
modelKey: key,
|
|
132
|
+
provider: cand.provider || '',
|
|
133
|
+
model: cand.model || key,
|
|
134
|
+
runs: 0,
|
|
135
|
+
wins: 0,
|
|
136
|
+
totalTokens: 0,
|
|
137
|
+
totalCostUsd: 0,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
const st = modelStats.get(key)
|
|
141
|
+
st.runs += 1
|
|
142
|
+
const inTok = cand.usage?.inputTokens || 0
|
|
143
|
+
const outTok = cand.usage?.outputTokens || 0
|
|
144
|
+
st.totalTokens += inTok + outTok
|
|
145
|
+
st.totalCostUsd += Number(cand.costUsd || 0)
|
|
146
|
+
|
|
147
|
+
if (winner && (winner === key || winner === cand.model || winner.includes(cand.model))) {
|
|
148
|
+
st.wins += 1
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore malformed line
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const models = Array.from(modelStats.values())
|
|
158
|
+
.map((st) => ({
|
|
159
|
+
...st,
|
|
160
|
+
winRate: st.runs > 0 ? Number(((st.wins / st.runs) * 100).toFixed(1)) : 0,
|
|
161
|
+
avgTokens: st.runs > 0 ? Math.round(st.totalTokens / st.runs) : 0,
|
|
162
|
+
avgCostUsd: st.runs > 0 ? Number((st.totalCostUsd / st.runs).toFixed(4)) : 0,
|
|
163
|
+
}))
|
|
164
|
+
.sort((a, b) => b.wins - a.wins || b.winRate - a.winRate)
|
|
165
|
+
|
|
166
|
+
return { totalRuns, models }
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.warn('[dsh-moa] Failed to calculate leaderboard:', err)
|
|
169
|
+
return { totalRuns: 0, models: [] }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Retrieves a single history run by ID.
|
|
175
|
+
*/
|
|
176
|
+
export function getMoaRunById(runId, filePath = DEFAULT_HISTORY_FILE) {
|
|
177
|
+
try {
|
|
178
|
+
if (!fs.existsSync(filePath)) return null
|
|
179
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
180
|
+
const lines = raw.trim().split('\n').filter(Boolean)
|
|
181
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
182
|
+
try {
|
|
183
|
+
const item = JSON.parse(lines[i])
|
|
184
|
+
if (item && item.id === runId) return item
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
return null
|
|
188
|
+
} catch {
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
}
|