diff --git a/webui/results_scan.py b/webui/results_scan.py
index 8f08305..7b7888d 100644
--- a/webui/results_scan.py
+++ b/webui/results_scan.py
@@ -304,6 +304,69 @@ def compare_models(
rows.sort(key=lambda x: x['score'], reverse=True)
ranking.append({'benchmark': b, 'rows': rows})
+ # Capability-domain aggregation: mean score over selected benches in each category
+ bench_set = set(bench_list)
+ active_categories = []
+ for cat in overview['categories']:
+ items = [b for b in cat['items'] if b in bench_set]
+ if items:
+ active_categories.append({
+ 'id': cat['id'],
+ 'name': cat['name'],
+ 'items': items,
+ })
+
+ category_labels = [c['name'] for c in active_categories]
+ category_series = []
+ for folder in selected:
+ scores = []
+ details = []
+ for cat in active_categories:
+ vals = []
+ for b in cat['items']:
+ cell = overview['matrix'].get(folder, {}).get(b)
+ if cell and cell.get('score') is not None:
+ vals.append(float(cell['score']))
+ if vals:
+ avg = sum(vals) / len(vals)
+ scores.append(round(avg, 6))
+ else:
+ avg = None
+ scores.append(None)
+ details.append({
+ 'category': cat['name'],
+ 'score': avg,
+ 'n_benchmarks': len(vals),
+ 'benchmarks': cat['items'],
+ })
+ model_meta = next((m for m in overview['models'] if m['folder'] == folder), None)
+ category_series.append({
+ 'folder': folder,
+ 'label': model_meta['model_name'] if model_meta else folder,
+ 'display': folder,
+ 'scores': scores,
+ 'details': details,
+ })
+
+ category_ranking = []
+ for i, cat in enumerate(active_categories):
+ rows = []
+ for s in category_series:
+ if s['scores'][i] is not None:
+ rows.append({
+ 'folder': s['folder'],
+ 'label': s['label'],
+ 'score': s['scores'][i],
+ 'n_benchmarks': s['details'][i]['n_benchmarks'],
+ })
+ rows.sort(key=lambda x: x['score'], reverse=True)
+ category_ranking.append({
+ 'category': cat['name'],
+ 'id': cat['id'],
+ 'items': cat['items'],
+ 'rows': rows,
+ })
+
return {
'output_dir': overview['output_dir'],
'benchmarks': bench_list,
@@ -311,6 +374,9 @@ def compare_models(
'models': [m for m in overview['models'] if m['folder'] in selected],
'series': series,
'ranking': ranking,
+ 'category_labels': category_labels,
+ 'category_series': category_series,
+ 'category_ranking': category_ranking,
'matrix': {
f: {b: overview['matrix'].get(f, {}).get(b) for b in bench_list}
for f in selected
diff --git a/webui/server.py b/webui/server.py
index 45cdc8f..8f04dc7 100644
--- a/webui/server.py
+++ b/webui/server.py
@@ -35,10 +35,45 @@ JOBS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_OUTPUT_DIR = Path(run_module.DEFAULT_OUTPUT_DIR)
+CUSTOM_SUITES_PATH = DATA_DIR / 'custom_suites.json'
app = FastAPI(title='EvalStone Launch Panel', version='1.0.0')
+def _load_custom_suites() -> Dict[str, List[str]]:
+ if not CUSTOM_SUITES_PATH.exists():
+ return {}
+ try:
+ data = json.loads(CUSTOM_SUITES_PATH.read_text(encoding='utf-8'))
+ if not isinstance(data, dict):
+ return {}
+ out: Dict[str, List[str]] = {}
+ for name, items in data.items():
+ if not isinstance(name, str) or not name.strip():
+ continue
+ if not isinstance(items, list):
+ continue
+ cleaned = [str(x).strip() for x in items if str(x).strip()]
+ if cleaned:
+ out[name.strip()] = cleaned
+ return out
+ except Exception:
+ return {}
+
+
+def _save_custom_suites(suites: Dict[str, List[str]]) -> None:
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ CUSTOM_SUITES_PATH.write_text(
+ json.dumps(suites, ensure_ascii=False, indent=2),
+ encoding='utf-8',
+ )
+
+
+class CustomSuiteRequest(BaseModel):
+ name: str = Field(..., min_length=1)
+ datasets: List[str] = Field(..., min_length=1)
+
+
# ---------------------------------------------------------------------------
# Job store
# ---------------------------------------------------------------------------
@@ -208,6 +243,7 @@ def _meta() -> dict:
}
return {
'suites': suites,
+ 'custom_suites': _load_custom_suites(),
'benchmarks': all_benchmarks,
'categories': categories,
'multi_run': run_module.MULTI_RUN_CONFIG,
@@ -530,6 +566,44 @@ async def stream_logs(job_id: str, offset: int = 0):
return StreamingResponse(event_gen(), media_type='text/event-stream')
+@app.get('/api/custom-suites')
+async def list_custom_suites():
+ return {'suites': _load_custom_suites()}
+
+
+@app.post('/api/custom-suites')
+async def upsert_custom_suite(req: CustomSuiteRequest):
+ name = req.name.strip()
+ if not name:
+ raise HTTPException(status_code=400, detail='组合名称不能为空')
+ if name in run_module.SUITES:
+ raise HTTPException(status_code=400, detail=f'名称与内置 suite 冲突: {name}')
+ datasets = []
+ seen = set()
+ for item in req.datasets:
+ d = str(item).strip()
+ if not d or d in seen:
+ continue
+ seen.add(d)
+ datasets.append(d)
+ if not datasets:
+ raise HTTPException(status_code=400, detail='请至少选择一个 benchmark')
+ suites = _load_custom_suites()
+ suites[name] = datasets
+ _save_custom_suites(suites)
+ return {'ok': True, 'name': name, 'datasets': datasets, 'suites': suites}
+
+
+@app.delete('/api/custom-suites/{name}')
+async def delete_custom_suite(name: str):
+ suites = _load_custom_suites()
+ if name not in suites:
+ raise HTTPException(status_code=404, detail='自定义组合不存在')
+ suites.pop(name, None)
+ _save_custom_suites(suites)
+ return {'ok': True, 'suites': suites}
+
+
@app.get('/api/results/overview')
async def results_overview(output_dir: Optional[str] = None):
root = Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR
diff --git a/webui/static/app.js b/webui/static/app.js
index 10bc99c..0faf16d 100644
--- a/webui/static/app.js
+++ b/webui/static/app.js
@@ -2,7 +2,9 @@
const state = {
meta: null,
selectionMode: 'suite',
+ suiteKind: 'builtin', // builtin | custom
suite: 'official',
+ customSuites: {},
activeJobId: null,
pollTimer: null,
logOffset: 0,
@@ -48,6 +50,16 @@
$('judge_api_url').value = d.judge_api_url || '';
$('judge_max_tokens').value = d.judge_max_tokens || '';
state.suite = d.suite || 'official';
+ state.suiteKind = 'builtin';
+ state.customSuites = meta.custom_suites || {};
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
}
function renderSuites() {
@@ -58,18 +70,87 @@
const info = suites[name];
const btn = document.createElement('button');
btn.type = 'button';
- btn.className = `suite-card${state.suite === name ? ' active' : ''}`;
- btn.innerHTML = `${name}${info.all.length} benchmarks
${info.all.slice(0, 6).join(', ')}${info.all.length > 6 ? '…' : ''}`;
+ btn.className = `suite-card${state.suiteKind === 'builtin' && state.suite === name ? ' active' : ''}`;
+ const names = info.all.map(escapeHtml).join(', ');
+ btn.innerHTML = `
+ ${escapeHtml(name)}
+ ${info.all.length} benchmarks
+