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();
|