255 lines
8.4 KiB
JavaScript
255 lines
8.4 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 训练实时仪表盘渲染器 · render-readme.js
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 签发: 铸渊 · ICE-GL-ZY001
|
||
* 版权: 国作登字-2026-A-00037559 · TCS-0002∞
|
||
*
|
||
* 读 data/training/state.json → 渲染 README.md 中
|
||
* <!-- TRAINING_DASHBOARD_START --> ... <!-- TRAINING_DASHBOARD_END -->
|
||
* 之间的内容。
|
||
*
|
||
* 不修改标记之外的任何内容。
|
||
*
|
||
* 用法:
|
||
* node scripts/training/render-readme.js # 写回 README.md
|
||
* node scripts/training/render-readme.js --dry # 只输出,不写
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ROOT = path.resolve(__dirname, '..', '..');
|
||
const STATE_PATH = path.join(ROOT, 'data', 'training', 'state.json');
|
||
const README_PATH = path.join(ROOT, 'README.md');
|
||
const START_MARK = '<!-- TRAINING_DASHBOARD_START -->';
|
||
const END_MARK = '<!-- TRAINING_DASHBOARD_END -->';
|
||
|
||
const PHASE_BADGE = {
|
||
idle: '⚪ 等待启动',
|
||
bootstrapping: '🛠️ 环境配置中',
|
||
'downloading-corpus': '📥 下载语料',
|
||
preprocessing: '🔧 数据预处理',
|
||
training: '🔥 训练进行中',
|
||
evaluating: '📊 评估中',
|
||
done: '✅ 训练完成',
|
||
error: '❌ 异常'
|
||
};
|
||
|
||
const HEALTH_BADGE = {
|
||
ok: '🟢 正常',
|
||
warning: '🟡 注意',
|
||
error: '🔴 异常',
|
||
idle: '⚪ 待命'
|
||
};
|
||
|
||
function fmtNum(n, digits = 4) {
|
||
if (n === null || n === undefined || Number.isNaN(n)) return '—';
|
||
if (typeof n !== 'number') return String(n);
|
||
if (Number.isInteger(n)) return n.toLocaleString('en-US');
|
||
return Number(n).toFixed(digits);
|
||
}
|
||
|
||
function fmtDuration(seconds) {
|
||
if (!seconds || seconds < 0) return '—';
|
||
const s = Math.floor(seconds);
|
||
const h = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
const ss = s % 60;
|
||
if (h > 0) return `${h}h ${m}m ${ss}s`;
|
||
if (m > 0) return `${m}m ${ss}s`;
|
||
return `${ss}s`;
|
||
}
|
||
|
||
function fmtTimestamp(iso) {
|
||
if (!iso) return '—';
|
||
return iso.replace('T', ' ').replace(/\..*Z$/, ' UTC').replace(/Z$/, ' UTC');
|
||
}
|
||
|
||
function progressBar(percent, width = 24) {
|
||
const pct = Math.max(0, Math.min(100, Number(percent) || 0));
|
||
const filled = Math.round((pct / 100) * width);
|
||
return '`' + '█'.repeat(filled) + '░'.repeat(width - filled) + '` ' + pct.toFixed(1) + '%';
|
||
}
|
||
|
||
function renderHeader(state) {
|
||
const phase = PHASE_BADGE[state.phase] || `⚪ ${state.phase}`;
|
||
const health = HEALTH_BADGE[state.health?.status] || '⚪ unknown';
|
||
return [
|
||
'## 🔥 训练实时仪表盘 · ZY-TRAIN-001',
|
||
'',
|
||
`> ${state.task?.name || ''}`,
|
||
`> **基座模型**: \`${state.task?.model_base || '—'}\` · **任务 ID**: \`${state.task?.id || '—'}\``,
|
||
`> **最后更新**: ${fmtTimestamp(state.task?.updated_at)} · **副将心跳**: ${fmtTimestamp(state.health?.last_heartbeat_at)}`,
|
||
'',
|
||
`| 当前阶段 | 健康 | 训练用时 |`,
|
||
`|---------|------|---------|`,
|
||
`| **${phase}** · ${state.phase_label || ''} | **${health}** · ${state.health?.message || ''} | ${fmtDuration(state.progress?.elapsed_seconds)} |`,
|
||
''
|
||
].join('\n');
|
||
}
|
||
|
||
function renderProgress(state) {
|
||
const p = state.progress || {};
|
||
const pct = p.total_steps > 0 ? (p.step / p.total_steps) * 100 : (p.percent || 0);
|
||
return [
|
||
'### 📈 训练进度',
|
||
'',
|
||
progressBar(pct),
|
||
'',
|
||
`| 指标 | 值 |`,
|
||
`|------|----|`,
|
||
`| Step | **${fmtNum(p.step)} / ${fmtNum(p.total_steps)}** |`,
|
||
`| Epoch | ${fmtNum(p.epoch)} / ${fmtNum(p.total_epochs)} |`,
|
||
`| Loss | ${fmtNum(p.loss)} |`,
|
||
`| Learning Rate | ${fmtNum(p.learning_rate, 6)} |`,
|
||
`| 吞吐 | ${p.throughput_samples_per_sec ? fmtNum(p.throughput_samples_per_sec, 2) + ' samples/s' : '—'} |`,
|
||
`| 预估剩余 | ${fmtDuration(p.eta_seconds)} |`,
|
||
''
|
||
].join('\n');
|
||
}
|
||
|
||
function renderGpu(state) {
|
||
const m = state.gpu_metrics || {};
|
||
if (!Array.isArray(m.devices) || m.devices.length === 0) {
|
||
return [
|
||
'### 🎮 GPU 状态',
|
||
'',
|
||
`> 暂无 GPU 实时数据 · 等待 progress-reporter 心跳。`,
|
||
''
|
||
].join('\n');
|
||
}
|
||
const lines = [
|
||
'### 🎮 GPU 状态',
|
||
'',
|
||
`> 采样时间: ${fmtTimestamp(m.snapshot_at)}`,
|
||
'',
|
||
`| GPU | 利用率 | 显存 | 温度 | 功率 |`,
|
||
`|-----|--------|------|------|------|`
|
||
];
|
||
for (const d of m.devices) {
|
||
lines.push(
|
||
`| ${d.index ?? '?'} · ${d.name || 'V100'} | ${d.util_percent ?? '—'}% | ${d.memory_used_mib ?? '—'} / ${d.memory_total_mib ?? '—'} MiB | ${d.temperature_c ?? '—'}°C | ${d.power_w ?? '—'} W |`
|
||
);
|
||
}
|
||
lines.push('');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function renderServer(state) {
|
||
const s = state.server || {};
|
||
const c = state.cos || {};
|
||
return [
|
||
'### 🖥️ 服务器 · COS',
|
||
'',
|
||
`| 项 | 值 |`,
|
||
`|----|----|`,
|
||
`| 实例 | \`${s.instance_name || '—'}\` · ${s.instance_type || '—'} |`,
|
||
`| 公网 IP | \`${s.host || '—'}\` |`,
|
||
`| 地域 | ${s.region || '—'} |`,
|
||
`| GPU | ${s.gpu || '—'} |`,
|
||
`| 系统 | ${s.os || '—'} · CUDA ${s.cuda || '—'} · cuDNN ${s.cudnn || '—'} |`,
|
||
`| COS 桶 | \`${c.bucket || '—'}\` (${c.region || '—'}) |`,
|
||
`| 语料文件 | raw=${fmtNum(c.raw_files)} · processed=${fmtNum(c.processed_files)} · checkpoints=${fmtNum(c.checkpoints)} |`,
|
||
''
|
||
].join('\n');
|
||
}
|
||
|
||
function renderTimeline(state) {
|
||
const tl = Array.isArray(state.timeline) ? state.timeline : [];
|
||
const recent = tl.slice(-10).reverse();
|
||
const lines = [
|
||
'### 🕐 最近事件(最新 10 条)',
|
||
''
|
||
];
|
||
if (recent.length === 0) {
|
||
lines.push('> 暂无事件。', '');
|
||
return lines.join('\n');
|
||
}
|
||
lines.push('| 时间 (UTC) | 阶段 | 等级 | 事件 |', '|-----------|------|------|------|');
|
||
for (const e of recent) {
|
||
const lvl = e.level === 'error' ? '🔴' : e.level === 'warning' ? '🟡' : 'ℹ️';
|
||
// 转义 Markdown 表格元字符: 先转义反斜杠,再转义管道,最后压成单行
|
||
const msg = String(e.message || '')
|
||
.replace(/\\/g, '\\\\')
|
||
.replace(/\|/g, '\\|')
|
||
.replace(/[\r\n]+/g, ' ');
|
||
lines.push(`| ${fmtTimestamp(e.at)} | ${e.phase || '—'} | ${lvl} | ${msg} |`);
|
||
}
|
||
lines.push('');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function renderAsk() {
|
||
return [
|
||
'### 💬 问铸渊副将',
|
||
'',
|
||
'> 冰朔可以**直接打开 Issue 问副将**,副将会读 `data/training/state.json` 并回答你「训练正不正常」「卡在哪里」「下一步该做什么」。',
|
||
'',
|
||
'📌 **[点击向副将提问 →](../../issues/new?template=ask-zhuyuan-training.md)**',
|
||
'',
|
||
'> 留言带 `deputy-message-board` 标签即可触发副将自动回复(已有 LLM 多模型降级链)。',
|
||
''
|
||
].join('\n');
|
||
}
|
||
|
||
function renderDashboard(state) {
|
||
const updated = state.task?.updated_at ? `· 更新: ${fmtTimestamp(state.task.updated_at)}` : '';
|
||
return [
|
||
`_铸渊副将守护 · ZY-DEPUTY-001 · 服务器动他也动 ${updated}_`,
|
||
'',
|
||
renderHeader(state),
|
||
renderProgress(state),
|
||
renderGpu(state),
|
||
renderServer(state),
|
||
renderTimeline(state),
|
||
renderAsk()
|
||
].join('\n');
|
||
}
|
||
|
||
function spliceMarkers(readme, body) {
|
||
const startIdx = readme.indexOf(START_MARK);
|
||
const endIdx = readme.indexOf(END_MARK);
|
||
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
|
||
throw new Error(`README 中未找到训练仪表盘标记 ${START_MARK} / ${END_MARK}`);
|
||
}
|
||
const before = readme.slice(0, startIdx + START_MARK.length);
|
||
const after = readme.slice(endIdx);
|
||
return `${before}\n\n${body}\n${after}`;
|
||
}
|
||
|
||
function main() {
|
||
const dry = process.argv.includes('--dry');
|
||
const state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
||
const body = renderDashboard(state);
|
||
|
||
if (dry) {
|
||
process.stdout.write(body + '\n');
|
||
return;
|
||
}
|
||
|
||
const readme = fs.readFileSync(README_PATH, 'utf8');
|
||
const next = spliceMarkers(readme, body);
|
||
if (next === readme) {
|
||
console.log('[render-readme] no change');
|
||
return;
|
||
}
|
||
fs.writeFileSync(README_PATH, next);
|
||
console.log('[render-readme] README.md updated');
|
||
}
|
||
|
||
if (require.main === module) {
|
||
try {
|
||
main();
|
||
} catch (err) {
|
||
console.error('[render-readme] error:', err.message);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
module.exports = { renderDashboard, spliceMarkers };
|