493 lines
19 KiB
JavaScript
493 lines
19 KiB
JavaScript
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 🧠 COS训练触发器 · 端到端训练管线
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 签发: 铸渊 · ICE-GL-ZY001
|
||
* 版权: 国作登字-2026-A-00037559
|
||
*
|
||
* 扫描COS桶中的新语料 → 解压/转换TCS格式 → 启动训练会话
|
||
*
|
||
* 设计:
|
||
* 1. 扫描cold桶,列出所有文件(含非压缩文件和文件夹)
|
||
* 2. 对比tcs-structured/目录,找出未处理的语料
|
||
* 3. 自动提取/转换为TCS结构化格式
|
||
* 4. 启动训练会话,用LLM分析和分类
|
||
* 5. 输出处理结果,写入日志
|
||
*
|
||
* 运行方式:
|
||
* node scripts/cos-training-trigger.js [scan|extract|train|full]
|
||
*
|
||
* scan — 仅扫描,输出未处理语料列表
|
||
* extract — 扫描并解压/转换为TCS格式
|
||
* train — 对已有TCS语料启动训练
|
||
* full — 完整流程: 扫描 → 提取 → 训练
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
// ─── 路径 ───
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
const COS_MODULE = path.join(ROOT, 'server', 'age-os', 'mcp-server', 'cos');
|
||
const EXTRACTOR_MODULE = path.join(ROOT, 'server', 'age-os', 'mcp-server', 'tools', 'corpus-extractor-ops');
|
||
const TRAINING_MODULE = path.join(ROOT, 'server', 'age-os', 'mcp-server', 'tools', 'training-agent-ops');
|
||
|
||
// ─── 延迟加载模块(允许在CI中跳过数据库依赖) ───
|
||
let cos, extractor, trainer;
|
||
|
||
function loadModules() {
|
||
cos = require(COS_MODULE);
|
||
extractor = require(EXTRACTOR_MODULE);
|
||
trainer = require(TRAINING_MODULE);
|
||
}
|
||
|
||
// ─── 配置 ───
|
||
const DEFAULT_BUCKET = 'cold';
|
||
const DEFAULT_PERSONA = 'zhuyuan';
|
||
const PROCESSED_PREFIX = 'tcs-structured/';
|
||
const MAX_EXTRACT_PER_RUN = 20; // 每次最多处理文件数
|
||
const MAX_TRAIN_PER_RUN = 5; // 每次最多训练文件数
|
||
const MAX_EXTRACT_FILE_SIZE = 200 * 1024 * 1024; // 200MB — 超过此阈值的文件使用分块策略
|
||
|
||
// ─── 排除路径(不视为语料的目录/文件) ───
|
||
const EXCLUDED_PREFIXES = [
|
||
'tcs-structured/',
|
||
'training-sessions/',
|
||
'training-results/',
|
||
'training-memory/',
|
||
];
|
||
|
||
// ─── 支持的语料文件扩展名(含非压缩格式) ───
|
||
const CORPUS_EXTENSIONS = [
|
||
'.zip', '.gz', '.tar.gz', '.tgz', '.json.gz', // 压缩格式
|
||
'.json', '.jsonl', '.md', '.txt', '.csv', // 非压缩格式
|
||
];
|
||
|
||
/**
|
||
* 判断文件是否为语料文件
|
||
*/
|
||
function isCorpusFile(key) {
|
||
// 排除处理结果目录
|
||
for (const prefix of EXCLUDED_PREFIXES) {
|
||
if (key.startsWith(prefix)) return false;
|
||
}
|
||
// 匹配扩展名
|
||
const lower = key.toLowerCase();
|
||
return CORPUS_EXTENSIONS.some(ext => lower.endsWith(ext));
|
||
}
|
||
|
||
/**
|
||
* 判断是否为语料目录(如 repo-archive/)
|
||
*/
|
||
function isCorpusDirectory(key) {
|
||
for (const prefix of EXCLUDED_PREFIXES) {
|
||
if (key.startsWith(prefix)) return false;
|
||
}
|
||
return key.endsWith('/');
|
||
}
|
||
|
||
/**
|
||
* 从已处理列表中判断某文件是否已处理
|
||
*/
|
||
function isProcessed(rawKey, processedFiles) {
|
||
// 从rawKey提取基础文件名(处理多重扩展名如.tar.gz)
|
||
let baseName = rawKey.split('/').pop();
|
||
// 移除所有已知的语料文件扩展名
|
||
for (const ext of ['.tar.gz', '.json.gz', '.tgz', '.zip', '.gz', '.jsonl', '.json', '.md', '.txt', '.csv']) {
|
||
if (baseName.toLowerCase().endsWith(ext)) {
|
||
baseName = baseName.slice(0, -ext.length);
|
||
break;
|
||
}
|
||
}
|
||
return processedFiles.some(f => f.key.includes(baseName));
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 命令: scan — 扫描未处理语料
|
||
// ═══════════════════════════════════════════
|
||
|
||
async function cmdScan(bucket) {
|
||
console.log('═══ COS训练触发器 · 语料扫描 ═══\n');
|
||
|
||
const bucketName = bucket || DEFAULT_BUCKET;
|
||
|
||
// 列出所有文件
|
||
const allFiles = await cos.list(bucketName, '', 500);
|
||
// 列出已处理文件
|
||
const processed = await cos.list(bucketName, PROCESSED_PREFIX, 500);
|
||
|
||
const processedFiles = processed.files.filter(f => f.key.endsWith('.tcs.json'));
|
||
|
||
// 分类
|
||
const corpusFiles = [];
|
||
const corpusDirs = [];
|
||
|
||
for (const file of allFiles.files) {
|
||
if (isCorpusFile(file.key)) {
|
||
const alreadyProcessed = isProcessed(file.key, processedFiles);
|
||
corpusFiles.push({
|
||
key: file.key,
|
||
size_bytes: file.size_bytes,
|
||
processed: alreadyProcessed
|
||
});
|
||
} else if (isCorpusDirectory(file.key)) {
|
||
corpusDirs.push({ key: file.key });
|
||
}
|
||
}
|
||
|
||
const pending = corpusFiles.filter(f => !f.processed);
|
||
|
||
console.log(`桶: ${bucketName}`);
|
||
console.log(`总文件数: ${allFiles.files.length}`);
|
||
console.log(`语料文件: ${corpusFiles.length}`);
|
||
console.log(`语料目录: ${corpusDirs.length}`);
|
||
console.log(`已处理: ${corpusFiles.length - pending.length}`);
|
||
console.log(`待处理: ${pending.length}`);
|
||
console.log(`已生成TCS: ${processedFiles.length}`);
|
||
|
||
if (pending.length > 0) {
|
||
console.log('\n📋 待处理语料:');
|
||
for (const f of pending) {
|
||
console.log(` 📄 ${f.key} (${formatBytes(f.size_bytes)})`);
|
||
}
|
||
}
|
||
|
||
if (corpusDirs.length > 0) {
|
||
console.log('\n📁 语料目录:');
|
||
for (const d of corpusDirs) {
|
||
console.log(` 📂 ${d.key}`);
|
||
}
|
||
|
||
// 扫描目录内的文件
|
||
for (const dir of corpusDirs) {
|
||
try {
|
||
const dirFiles = await cos.list(bucketName, dir.key, 100);
|
||
const dirCorpus = dirFiles.files.filter(f => isCorpusFile(f.key));
|
||
if (dirCorpus.length > 0) {
|
||
console.log(` └── ${dir.key} 内含 ${dirCorpus.length} 个语料文件`);
|
||
for (const f of dirCorpus) {
|
||
const alreadyProcessed = isProcessed(f.key, processedFiles);
|
||
if (!alreadyProcessed) {
|
||
pending.push({
|
||
key: f.key,
|
||
size_bytes: f.size_bytes,
|
||
processed: false
|
||
});
|
||
console.log(` 📄 ${f.key} (${formatBytes(f.size_bytes)}) [待处理]`);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.log(` └── ${dir.key} 扫描失败: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 写入GitHub Actions输出
|
||
if (process.env.GITHUB_OUTPUT) {
|
||
const outputLines = [
|
||
`pending=${pending.length}`,
|
||
`total_corpus=${corpusFiles.length}`,
|
||
`processed=${processedFiles.length}`,
|
||
`has_new_corpus=${pending.length > 0 ? 'true' : 'false'}`,
|
||
`pending_files=${pending.map(f => f.key).join(',')}`
|
||
];
|
||
fs.appendFileSync(process.env.GITHUB_OUTPUT, outputLines.join('\n') + '\n');
|
||
}
|
||
|
||
return { pending, processedFiles, corpusDirs };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 命令: extract — 提取/转换语料为TCS格式
|
||
// ═══════════════════════════════════════════
|
||
|
||
async function cmdExtract(bucket) {
|
||
console.log('═══ COS训练触发器 · 语料提取 ═══\n');
|
||
|
||
const bucketName = bucket || DEFAULT_BUCKET;
|
||
const { pending } = await cmdScan(bucketName);
|
||
|
||
if (pending.length === 0) {
|
||
console.log('\n✅ 无待处理语料');
|
||
writeGitHubOutput('extracted=0', 'extract_status=skipped');
|
||
return { extracted: 0, errors: 0 };
|
||
}
|
||
|
||
const toProcess = pending.slice(0, MAX_EXTRACT_PER_RUN);
|
||
console.log(`\n🔄 开始提取 ${toProcess.length}/${pending.length} 个文件...\n`);
|
||
|
||
let extracted = 0;
|
||
let errors = 0;
|
||
let skipped = 0;
|
||
const results = [];
|
||
|
||
for (const file of toProcess) {
|
||
try {
|
||
// 大文件预警
|
||
const sizeMB = (file.size_bytes / 1024 / 1024).toFixed(1);
|
||
if (file.size_bytes > MAX_EXTRACT_FILE_SIZE) {
|
||
console.log(` 📦 处理: ${file.key} (${sizeMB}MB · 超大文件,使用分块策略)...`);
|
||
} else {
|
||
console.log(` 📦 处理: ${file.key} (${sizeMB}MB)...`);
|
||
}
|
||
|
||
const result = await extractor.cosExtractCorpus({
|
||
bucket: bucketName,
|
||
key: file.key,
|
||
output_bucket: bucketName,
|
||
output_prefix: PROCESSED_PREFIX
|
||
});
|
||
|
||
// 根据返回状态分类计数
|
||
if (result.status === 'zip_detected') {
|
||
skipped++;
|
||
results.push({ key: file.key, status: 'skipped', reason: 'zip_needs_special_tool' });
|
||
console.log(` ⏭️ 跳过: ${file.key} — ZIP文件需要专用工具处理`);
|
||
} else if (result.status === 'skipped_too_large') {
|
||
skipped++;
|
||
results.push({ key: file.key, status: 'skipped', reason: 'too_large', size_mb: result.size_mb });
|
||
console.log(` ⏭️ 跳过: ${file.key} — ${result.message}`);
|
||
} else if (result.status === 'partial_extract') {
|
||
extracted++;
|
||
results.push({ key: file.key, status: 'partial', output: result.output?.key, message: result.message });
|
||
console.log(` 🔶 部分提取: ${result.message}`);
|
||
} else {
|
||
extracted++;
|
||
results.push({ key: file.key, status: 'success', output: result.output?.key });
|
||
console.log(` ✅ 完成: ${result.output?.key || '已处理'} (${result.entries || 0} 条目)`);
|
||
}
|
||
} catch (err) {
|
||
errors++;
|
||
results.push({ key: file.key, status: 'error', error: err.message });
|
||
console.log(` ❌ 失败: ${file.key} — ${err.message}`);
|
||
}
|
||
}
|
||
|
||
console.log(`\n═══ 提取完毕 ═══`);
|
||
console.log(`✅ 成功: ${extracted}`);
|
||
console.log(`⏭️ 跳过: ${skipped}`);
|
||
console.log(`❌ 失败: ${errors}`);
|
||
console.log(`⏳ 剩余: ${pending.length - toProcess.length}`);
|
||
|
||
writeGitHubOutput(
|
||
`extracted=${extracted}`,
|
||
`extract_skipped=${skipped}`,
|
||
`extract_errors=${errors}`,
|
||
`extract_status=${errors > 0 ? 'partial' : 'success'}`
|
||
);
|
||
|
||
return { extracted, errors, skipped, results };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 命令: train — 对TCS语料启动训练
|
||
// ═══════════════════════════════════════════
|
||
|
||
async function cmdTrain(bucket, personaId) {
|
||
console.log('═══ COS训练触发器 · 训练处理 ═══\n');
|
||
|
||
const bucketName = bucket || DEFAULT_BUCKET;
|
||
const persona = personaId || DEFAULT_PERSONA;
|
||
|
||
// 列出可用的TCS语料
|
||
const processed = await cos.list(bucketName, PROCESSED_PREFIX, 500);
|
||
const tcsFiles = processed.files.filter(f => f.key.endsWith('.tcs.json'));
|
||
|
||
if (tcsFiles.length === 0) {
|
||
console.log('⚠️ 无TCS结构化语料可训练。请先运行 extract 命令。');
|
||
writeGitHubOutput('trained=0', 'train_status=no_corpus');
|
||
return { trained: 0, errors: 0 };
|
||
}
|
||
|
||
console.log(`📚 找到 ${tcsFiles.length} 个TCS语料文件`);
|
||
|
||
// 检查已有训练结果,避免重复处理
|
||
let existingResults = [];
|
||
try {
|
||
const existing = await cos.list(bucketName, `training-results/${persona}/`, 100);
|
||
existingResults = existing.files.filter(f => f.key.endsWith('.json'));
|
||
} catch (err) {
|
||
console.log(`⚠️ 无法读取已有训练结果: ${err.message}`);
|
||
}
|
||
|
||
// 启动训练会话
|
||
console.log(`\n🧠 启动训练会话 · 人格体: ${persona}`);
|
||
|
||
let session;
|
||
try {
|
||
session = await trainer.trainingStartSession({
|
||
persona_id: persona,
|
||
corpus_bucket: bucketName,
|
||
corpus_prefix: PROCESSED_PREFIX,
|
||
session_name: `自动训练-${new Date().toISOString().slice(0, 10)}`
|
||
});
|
||
console.log(`✅ 会话已启动: ${session.session_id}`);
|
||
console.log(` 可用模型: ${session.models.available.map(m => m.name).join(', ') || '无'}`);
|
||
} catch (err) {
|
||
console.log(`❌ 训练会话启动失败: ${err.message}`);
|
||
writeGitHubOutput('trained=0', `train_status=session_error`, `train_error=${err.message}`);
|
||
return { trained: 0, errors: 1, error: err.message };
|
||
}
|
||
|
||
// 检查是否有可用的LLM模型
|
||
if (!session.models.available || session.models.available.length === 0) {
|
||
console.log('⚠️ 无可用LLM模型(需要配置 ZY_DEEPSEEK_API_KEY 等密钥)');
|
||
console.log(' 训练会话已记录,等待LLM密钥配置后再次运行。');
|
||
writeGitHubOutput('trained=0', 'train_status=no_llm_keys');
|
||
return { trained: 0, errors: 0, note: '无LLM密钥' };
|
||
}
|
||
|
||
// 处理语料
|
||
const toTrain = tcsFiles.slice(0, MAX_TRAIN_PER_RUN);
|
||
let trained = 0;
|
||
let trainErrors = 0;
|
||
const trainResults = [];
|
||
|
||
for (const tcsFile of toTrain) {
|
||
try {
|
||
console.log(` 🔬 训练处理: ${tcsFile.key}...`);
|
||
const result = await trainer.trainingProcessCorpus({
|
||
corpus_bucket: bucketName,
|
||
corpus_key: tcsFile.key,
|
||
persona_id: persona,
|
||
max_entries: 10
|
||
});
|
||
trained++;
|
||
trainResults.push({ key: tcsFile.key, status: 'success', ...result });
|
||
console.log(` ✅ 完成: ${result.classified}/${result.total} 分类成功`);
|
||
} catch (err) {
|
||
trainErrors++;
|
||
trainResults.push({ key: tcsFile.key, status: 'error', error: err.message });
|
||
console.log(` ❌ 失败: ${tcsFile.key} — ${err.message}`);
|
||
}
|
||
}
|
||
|
||
console.log(`\n═══ 训练完毕 ═══`);
|
||
console.log(`✅ 成功: ${trained}`);
|
||
console.log(`❌ 失败: ${trainErrors}`);
|
||
|
||
writeGitHubOutput(
|
||
`trained=${trained}`,
|
||
`train_errors=${trainErrors}`,
|
||
`train_status=${trainErrors > 0 ? 'partial' : 'success'}`
|
||
);
|
||
|
||
return { trained, errors: trainErrors, results: trainResults, session_id: session.session_id };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 命令: full — 完整流程
|
||
// ═══════════════════════════════════════════
|
||
|
||
async function cmdFull(bucket, personaId) {
|
||
console.log('╔═══════════════════════════════════════════╗');
|
||
console.log('║ COS训练触发器 · 完整训练管线 ║');
|
||
console.log('║ 铸渊 · ICE-GL-ZY001 ║');
|
||
console.log('╚═══════════════════════════════════════════╝\n');
|
||
|
||
const bucketName = bucket || DEFAULT_BUCKET;
|
||
const persona = personaId || DEFAULT_PERSONA;
|
||
const startTime = Date.now();
|
||
|
||
// 第一步: 提取语料
|
||
console.log('📍 第一步: 提取语料\n');
|
||
const extractResult = await cmdExtract(bucketName);
|
||
|
||
// 第二步: 训练处理
|
||
console.log('\n📍 第二步: 训练处理\n');
|
||
const trainResult = await cmdTrain(bucketName, persona);
|
||
|
||
// 汇总
|
||
const duration = Date.now() - startTime;
|
||
console.log('\n╔═══════════════════════════════════════════╗');
|
||
console.log('║ 完整训练管线 · 运行完毕 ║');
|
||
console.log('╚═══════════════════════════════════════════╝');
|
||
console.log(` 提取: ${extractResult.extracted} 成功 / ${extractResult.skipped || 0} 跳过 / ${extractResult.errors} 失败`);
|
||
console.log(` 训练: ${trainResult.trained} 成功 / ${trainResult.errors} 失败`);
|
||
console.log(` 耗时: ${(duration / 1000).toFixed(1)}s`);
|
||
|
||
writeGitHubOutput(
|
||
`pipeline_status=${(extractResult.errors + trainResult.errors) > 0 ? 'partial' : 'success'}`,
|
||
`pipeline_duration_ms=${duration}`
|
||
);
|
||
|
||
return { extract: extractResult, train: trainResult, duration_ms: duration };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 辅助函数
|
||
// ═══════════════════════════════════════════
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes < 1024) return `${bytes}B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||
}
|
||
|
||
function writeGitHubOutput(...lines) {
|
||
if (process.env.GITHUB_OUTPUT) {
|
||
fs.appendFileSync(process.env.GITHUB_OUTPUT, lines.join('\n') + '\n');
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// CLI 入口
|
||
// ═══════════════════════════════════════════
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
const command = args[0] || 'scan';
|
||
const bucket = args.find(a => a.startsWith('--bucket='))?.split('=')[1] || DEFAULT_BUCKET;
|
||
const persona = args.find(a => a.startsWith('--persona='))?.split('=')[1] || DEFAULT_PERSONA;
|
||
|
||
// 加载模块
|
||
try {
|
||
loadModules();
|
||
} catch (err) {
|
||
console.error(`❌ 模块加载失败: ${err.message}`);
|
||
console.error(' 请确保 server/age-os/mcp-server/ 依赖已安装');
|
||
process.exit(1);
|
||
}
|
||
|
||
switch (command) {
|
||
case 'scan':
|
||
await cmdScan(bucket);
|
||
break;
|
||
case 'extract':
|
||
await cmdExtract(bucket);
|
||
break;
|
||
case 'train':
|
||
await cmdTrain(bucket, persona);
|
||
break;
|
||
case 'full':
|
||
await cmdFull(bucket, persona);
|
||
break;
|
||
default:
|
||
console.log('COS训练触发器 · 铸渊 · ICE-GL-ZY001');
|
||
console.log('');
|
||
console.log('用法:');
|
||
console.log(' node scripts/cos-training-trigger.js scan — 扫描未处理语料');
|
||
console.log(' node scripts/cos-training-trigger.js extract — 提取/转换为TCS格式');
|
||
console.log(' node scripts/cos-training-trigger.js train — 启动训练处理');
|
||
console.log(' node scripts/cos-training-trigger.js full — 完整流程');
|
||
console.log('');
|
||
console.log('选项:');
|
||
console.log(' --bucket=cold|hot|team — 指定COS桶(默认cold)');
|
||
console.log(' --persona=zhuyuan — 指定人格体(默认zhuyuan)');
|
||
break;
|
||
}
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('COS训练触发器异常:', err.message);
|
||
if (process.env.GITHUB_OUTPUT) {
|
||
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'pipeline_status=error\n');
|
||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `pipeline_error=${err.message}\n`);
|
||
}
|
||
process.exit(1);
|
||
});
|