220 lines
6.5 KiB
JavaScript
220 lines
6.5 KiB
JavaScript
|
|
/**
|
|||
|
|
* scripts/grid-db/extract-training-samples.js
|
|||
|
|
*
|
|||
|
|
* 交互记录 → 训练样本提取脚本
|
|||
|
|
*
|
|||
|
|
* 职责:
|
|||
|
|
* - 扫描 grid-db/interactions/ 和 grid-db/training-lake/raw/ 中的 JSONL 文件
|
|||
|
|
* - 按 quality_score 分级提取训练样本:
|
|||
|
|
* - quality_score >= 7 → curated/(高质量 A 级)
|
|||
|
|
* - quality_score 4-6 → raw/ 保留(B 级,需复审)
|
|||
|
|
* - quality_score < 4 → 不提取(C 级,低质量/无关闲聊)
|
|||
|
|
* - 将合格交互转换为标准训练样本格式
|
|||
|
|
* - 按 session 分组,生成多轮对话训练样本
|
|||
|
|
*
|
|||
|
|
* 训练样本格式:
|
|||
|
|
* {
|
|||
|
|
* "sample_id": "TS-YYYYMMDD-NNN",
|
|||
|
|
* "source_session": "sess-XXX",
|
|||
|
|
* "source_dev": "DEV-XXX",
|
|||
|
|
* "source_persona": "PER-XXXXXX",
|
|||
|
|
* "sample_type": "coding-guidance",
|
|||
|
|
* "quality_tier": "A|B|C",
|
|||
|
|
* "turns": [...],
|
|||
|
|
* "metadata": { topic_tags, emotion_arc, persona_adaptation, outcome, total_turns, duration_minutes }
|
|||
|
|
* }
|
|||
|
|
*
|
|||
|
|
* 守护: PER-ZY001 铸渊
|
|||
|
|
* 系统: SYS-GLW-0001
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
const GRID_DB = path.join(__dirname, '../../grid-db');
|
|||
|
|
const INTERACTIONS = path.join(GRID_DB, 'interactions');
|
|||
|
|
const TRAINING_RAW = path.join(GRID_DB, 'training-lake/raw');
|
|||
|
|
const TRAINING_CURATED = path.join(GRID_DB, 'training-lake/curated');
|
|||
|
|
const CATALOG_PATH = path.join(GRID_DB, 'training-lake/metadata/catalog.json');
|
|||
|
|
|
|||
|
|
function getDateStr() {
|
|||
|
|
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseJsonlFile(filePath) {
|
|||
|
|
if (!fs.existsSync(filePath)) return [];
|
|||
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|||
|
|
return content.trim().split('\n')
|
|||
|
|
.filter(line => line.trim())
|
|||
|
|
.map(line => {
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(line);
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
.filter(Boolean);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function groupBySession(records) {
|
|||
|
|
const sessions = {};
|
|||
|
|
for (const record of records) {
|
|||
|
|
const sid = record.session_id || record.source_session || 'unknown';
|
|||
|
|
if (!sessions[sid]) {
|
|||
|
|
sessions[sid] = [];
|
|||
|
|
}
|
|||
|
|
sessions[sid].push(record);
|
|||
|
|
}
|
|||
|
|
return sessions;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function assessQuality(turns) {
|
|||
|
|
// Calculate average quality score from turns that have one
|
|||
|
|
const scores = turns
|
|||
|
|
.map(t => (t.metadata && t.metadata.quality_score) || (t.quality_score) || null)
|
|||
|
|
.filter(s => s !== null);
|
|||
|
|
|
|||
|
|
if (scores.length === 0) return 5; // Default to medium if no scores
|
|||
|
|
return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getQualityTier(score) {
|
|||
|
|
if (score >= 7) return 'A';
|
|||
|
|
if (score >= 4) return 'B';
|
|||
|
|
return 'C';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractEmotionArc(turns) {
|
|||
|
|
return turns
|
|||
|
|
.map(t => (t.metadata && t.metadata.emotion) || t.emotion || null)
|
|||
|
|
.filter(Boolean);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractTopicTags(turns) {
|
|||
|
|
const tags = new Set();
|
|||
|
|
for (const t of turns) {
|
|||
|
|
if (t.tags) t.tags.forEach(tag => tags.add(tag));
|
|||
|
|
if (t.metadata && t.metadata.topic) tags.add(t.metadata.topic);
|
|||
|
|
}
|
|||
|
|
return [...tags];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function generateSampleId(dateStr, counter) {
|
|||
|
|
const timeStr = Date.now().toString(36);
|
|||
|
|
return `TS-${dateStr}-${timeStr}-${String(counter).padStart(3, '0')}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function main() {
|
|||
|
|
const dateStr = getDateStr();
|
|||
|
|
console.log(`[extract-training-samples] Starting extraction: ${dateStr}`);
|
|||
|
|
|
|||
|
|
// Collect all JSONL files from interactions/
|
|||
|
|
const devDirs = fs.readdirSync(INTERACTIONS)
|
|||
|
|
.filter(d => d.startsWith('DEV-') && fs.statSync(path.join(INTERACTIONS, d)).isDirectory());
|
|||
|
|
|
|||
|
|
let allRecords = [];
|
|||
|
|
for (const devDir of devDirs) {
|
|||
|
|
const devPath = path.join(INTERACTIONS, devDir);
|
|||
|
|
const jsonlFiles = fs.readdirSync(devPath).filter(f => f.endsWith('.jsonl'));
|
|||
|
|
|
|||
|
|
for (const file of jsonlFiles) {
|
|||
|
|
const records = parseJsonlFile(path.join(devPath, file));
|
|||
|
|
allRecords = allRecords.concat(records);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Also scan training-lake/raw/ for unprocessed batches
|
|||
|
|
const rawFiles = fs.readdirSync(TRAINING_RAW).filter(f => f.endsWith('.jsonl'));
|
|||
|
|
for (const file of rawFiles) {
|
|||
|
|
const records = parseJsonlFile(path.join(TRAINING_RAW, file));
|
|||
|
|
// These may already be in sample format; check and add raw interaction records
|
|||
|
|
for (const r of records) {
|
|||
|
|
if (r.turns) {
|
|||
|
|
// Already a sample, skip
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
allRecords.push(r);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (allRecords.length === 0) {
|
|||
|
|
console.log('[extract-training-samples] No interaction records found');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`[extract-training-samples] Found ${allRecords.length} total records`);
|
|||
|
|
|
|||
|
|
// Group by session
|
|||
|
|
const sessions = groupBySession(allRecords);
|
|||
|
|
const sessionIds = Object.keys(sessions);
|
|||
|
|
console.log(`[extract-training-samples] Found ${sessionIds.length} sessions`);
|
|||
|
|
|
|||
|
|
let sampleCount = 0;
|
|||
|
|
let curatedCount = 0;
|
|||
|
|
let rawCount = 0;
|
|||
|
|
let skippedCount = 0;
|
|||
|
|
|
|||
|
|
for (const sid of sessionIds) {
|
|||
|
|
const turns = sessions[sid];
|
|||
|
|
if (turns.length < 2) continue; // Need at least 2 turns for a training sample
|
|||
|
|
|
|||
|
|
const qualityScore = assessQuality(turns);
|
|||
|
|
const tier = getQualityTier(qualityScore);
|
|||
|
|
|
|||
|
|
if (tier === 'C') {
|
|||
|
|
skippedCount++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sampleCount++;
|
|||
|
|
const sampleId = generateSampleId(dateStr, sampleCount);
|
|||
|
|
|
|||
|
|
const devId = turns[0].dev_id || 'unknown';
|
|||
|
|
const personaId = turns[0].persona_id || 'unknown';
|
|||
|
|
|
|||
|
|
const sample = {
|
|||
|
|
schema_version: '1.0',
|
|||
|
|
sample_id: sampleId,
|
|||
|
|
source_session: sid,
|
|||
|
|
source_dev: devId,
|
|||
|
|
source_persona: personaId,
|
|||
|
|
sample_type: 'coding-guidance',
|
|||
|
|
quality_tier: tier,
|
|||
|
|
turns: turns.map(t => ({
|
|||
|
|
role: t.role || 'system',
|
|||
|
|
text: t.content || t.text || '',
|
|||
|
|
timestamp: t.timestamp || t.ts || null,
|
|||
|
|
strategy: t.strategy || null
|
|||
|
|
})),
|
|||
|
|
metadata: {
|
|||
|
|
topic_tags: extractTopicTags(turns),
|
|||
|
|
emotion_arc: extractEmotionArc(turns),
|
|||
|
|
persona_adaptation: null,
|
|||
|
|
outcome: null,
|
|||
|
|
total_turns: turns.length,
|
|||
|
|
duration_minutes: null
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sampleLine = JSON.stringify(sample);
|
|||
|
|
|
|||
|
|
if (tier === 'A') {
|
|||
|
|
const curatedFile = path.join(TRAINING_CURATED, `${dateStr}-curated.jsonl`);
|
|||
|
|
fs.appendFileSync(curatedFile, sampleLine + '\n');
|
|||
|
|
curatedCount++;
|
|||
|
|
} else {
|
|||
|
|
const rawFile = path.join(TRAINING_RAW, `${dateStr}-extracted.jsonl`);
|
|||
|
|
fs.appendFileSync(rawFile, sampleLine + '\n');
|
|||
|
|
rawCount++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`[extract-training-samples] Extraction complete:`);
|
|||
|
|
console.log(` Total samples: ${sampleCount}`);
|
|||
|
|
console.log(` Curated (A): ${curatedCount}`);
|
|||
|
|
console.log(` Raw (B): ${rawCount}`);
|
|||
|
|
console.log(` Skipped (C): ${skippedCount}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main();
|