386 lines
13 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* Corpus Manual Importer · GPT / Notion 等手动导出语料的标准化处理器
*
* 系统底层标识: SYS-GLW-0001 / TCS-0002
* 版权号: 国作登字-2026-A-00037559
* 作者: 冰朔 (ICE-GL) · 实现: 铸渊 (ICE-GL-ZY001)
*
* 架构引用: HLDP-ARCH-002 § · factory/training/README.md
*
* 输入:
* ./corpus/raw/gpt/conversations.json GPT 官方导出格式
* ./corpus/raw/notion/ Notion 批量导出 .md 子目录
* ./corpus/raw/other/ 其他散户语料
*
* 处理:
* 1. 解析每种来源的格式 统一五元组 {role, content, timestamp, channel, persona}
* 2. 脱敏API key / 邮箱 / 手机 / token
* 3. 去重 + 简单质检信息密度估算 + 长度阈值
* 4. 按对话块切分保留语义边界
* 5. 按人格体分桶{persona}-dialog.jsonl
*
* 输出:
* ./corpus/output/training-fulltext.jsonl 全量纯文本M0 CPT
* ./corpus/output/training-dialog.jsonl 对话格式M0 SFT
* ./corpus/output/persona/{id}-dialog.jsonl 按人格分桶MP 微调用
* ./corpus/output/import-manifest.json 本次导入元数据 + 字数/token 估算
*
* 用法:
* node scripts/corpus-harvester/manual-import.js
* node scripts/corpus-harvester/manual-import.js --raw ./corpus/raw --out ./corpus/output
*
* 状态: 骨架GPT JSON 解析已实现 / Notion MD 解析为占位 / 真正落地等冰朔上传到 COS 后接通
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ARGS = parseArgs(process.argv.slice(2));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const RAW_DIR = ARGS.raw || path.join(REPO_ROOT, 'corpus', 'raw');
const OUT_DIR = ARGS.out || path.join(REPO_ROOT, 'corpus', 'output');
const COPYRIGHT = {
registration: '国作登字-2026-A-00037559',
sovereign: '冰朔 · TCS-0002∞ · ICE-GL∞',
system_root: 'SYS-GLW-0001',
arch_ref: 'HLDP-ARCH-002',
license_note:
'本语料仅供冰朔本人用于训练曜冥语言人格核 / 铸渊 / 相关人格体微调使用。' +
'未经冰朔本人书面授权,禁止用于任何第三方模型训练、商业用途或数据集发布。',
};
// ───── helpers ─────
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const key = a.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith('--')) {
out[key] = next;
i++;
} else {
out[key] = true;
}
}
return out;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function redact(text) {
if (!text) return text;
return String(text)
.replace(/sk-[A-Za-z0-9]{20,}/g, '<REDACTED_API_KEY>')
.replace(/ghp_[A-Za-z0-9]{20,}/g, '<REDACTED_GH_TOKEN>')
.replace(/secret_[A-Za-z0-9]{32,}/g, '<REDACTED_NOTION_SECRET>')
.replace(/Bearer\s+[A-Za-z0-9._\-]{20,}/g, 'Bearer <REDACTED>')
.replace(/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g, '<REDACTED_EMAIL>')
.replace(/\b1[3-9]\d{9}\b/g, '<REDACTED_PHONE>');
}
/** 粗略 token 估算: 中文 1 字 ≈ 1.5 token, 英文按 4 字符 ≈ 1 token */
function estimateTokens(text) {
if (!text) return 0;
const s = String(text);
// CJK 常见区块CJK 基本+扩展A / 平假名 / 片假名 / 谚文 / 兼容汉字 / 半角片假名
const cjkRegex =
/[\u3040-\u309f\u30a0-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff66-\uff9f]/g;
const cjk = (s.match(cjkRegex) || []).length;
const others = s.length - cjk;
return Math.round(cjk * 1.5 + others / 4);
}
function sha256(buf) {
return crypto.createHash('sha256').update(buf).digest('hex');
}
// ───── source 1: GPT conversations.json ─────
/**
* GPT 官方导出格式conversations.json结构精简描述:
* 每个对话是一个 {title, create_time, mapping: { node_id: {message: {author, content, create_time}, parent, children} }}
* 实际格式可能因导出时间略有差异铸渊在真实数据上线时根据样本调整
*/
function parseGPTConversations(filePath) {
if (!fs.existsSync(filePath)) {
return { dialogs: [], skipped: `not found: ${filePath}` };
}
let raw;
try {
raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (err) {
return { dialogs: [], skipped: `parse error: ${err.message}` };
}
const dialogs = [];
const conversations = Array.isArray(raw) ? raw : raw.conversations || [];
for (const conv of conversations) {
const title = conv.title || '(untitled)';
const mapping = conv.mapping || {};
// 按 create_time 把消息线性化
const msgs = [];
for (const node of Object.values(mapping)) {
if (!node || !node.message) continue;
const m = node.message;
const role =
(m.author && m.author.role) ||
(m.recipient === 'all' ? 'assistant' : 'unknown');
const partsRaw = m.content && m.content.parts;
const text = Array.isArray(partsRaw) ? partsRaw.join('\n') : '';
if (!text || !text.trim()) continue;
msgs.push({
role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system',
content: redact(text.trim()),
ts: m.create_time ? new Date(m.create_time * 1000).toISOString() : null,
});
}
msgs.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
if (msgs.length === 0) continue;
dialogs.push({
source: 'gpt',
channel: title,
messages: msgs,
created_at: conv.create_time
? new Date(conv.create_time * 1000).toISOString()
: null,
});
}
return { dialogs, skipped: null };
}
// ───── source 2: Notion markdown 批量导出 ─────
/**
* Notion 批量导出的 markdown 格式比较自由骨架阶段只做最简处理:
* - 把整个 .md 文件作为一段 system_or_corpus 文本
* - 文件名作为 channel
* 真实接入时如果有结构化前后端可以再加 frontmatter 解析
*/
function parseNotionDir(dir) {
if (!fs.existsSync(dir)) {
return { dialogs: [], skipped: `not found: ${dir}` };
}
const dialogs = [];
function walk(d) {
for (const name of fs.readdirSync(d)) {
const full = path.join(d, name);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
walk(full);
continue;
}
if (!name.endsWith('.md')) continue;
const text = redact(fs.readFileSync(full, 'utf8'));
if (!text.trim()) continue;
const rel = path.relative(dir, full);
dialogs.push({
source: 'notion',
channel: rel,
messages: [{ role: 'system', content: text.trim(), ts: null }],
created_at: null,
});
}
}
walk(dir);
return { dialogs, skipped: null };
}
// ───── 输出生成 ─────
function dialogToTrainingTextLine(dialog) {
// CPT 用纯文本:把对话拼成 <user>...</user><assistant>...</assistant> 风格
const parts = dialog.messages.map(
(m) => `<|${m.role}|>\n${m.content}\n<|end|>`
);
return JSON.stringify({ text: parts.join('\n') });
}
function dialogToSFTLine(dialog) {
// OpenAI / DeepSeek 风格 messages 格式
return JSON.stringify({
messages: dialog.messages.map((m) => ({ role: m.role, content: m.content })),
metadata: {
source: dialog.source,
channel: dialog.channel,
created_at: dialog.created_at,
},
});
}
function classifyPersona(dialog) {
// 占位实现:实际由铸渊在真实数据上做更精细的分桶。
// 全部统一返回 ID 格式(与 agent-registry 编号体系对齐),避免文件名不一致。
// 没有正式 ID 的人格暂用 PER-{name} 占位,待 registry 分配正式编号。
const text = dialog.messages.map((m) => m.content).join('\n');
if (/铸渊|ICE-GL-ZY001|guanghulab/.test(text)) return 'ICE-GL-ZY001';
if (/译典|AG-YD-A05/.test(text)) return 'AG-YD-A05';
if (/培园|AG-PY-A04/.test(text)) return 'AG-PY-A04';
if (/录册|AG-LC-A02/.test(text)) return 'AG-LC-A02';
if (/霜砚/.test(text)) return 'PER-shuangyan';
if (/知秋|chenxi/i.test(text)) return 'PER-chenxi';
return 'general';
}
// ───── 主流程 ─────
function main() {
console.log('═'.repeat(64));
console.log('Corpus Manual Importer · 手动导出语料标准化处理器');
console.log('灵魂印记:', JSON.stringify(COPYRIGHT, null, 0));
console.log('═'.repeat(64));
console.log(`raw_dir: ${RAW_DIR}`);
console.log(`out_dir: ${OUT_DIR}`);
console.log('─'.repeat(64));
ensureDir(OUT_DIR);
ensureDir(path.join(OUT_DIR, 'persona'));
const allDialogs = [];
// 1. GPT
const gptPath = path.join(RAW_DIR, 'gpt', 'conversations.json');
const gpt = parseGPTConversations(gptPath);
if (gpt.skipped) console.log(`[gpt] 跳过: ${gpt.skipped}`);
else console.log(`[gpt] 解析对话 ${gpt.dialogs.length}`);
allDialogs.push(...gpt.dialogs);
// 2. Notion
const notionDir = path.join(RAW_DIR, 'notion');
const notion = parseNotionDir(notionDir);
if (notion.skipped) console.log(`[notion] 跳过: ${notion.skipped}`);
else console.log(`[notion] 解析文档 ${notion.dialogs.length}`);
allDialogs.push(...notion.dialogs);
if (allDialogs.length === 0) {
console.log('\n⚠ 无可导入数据。请先把语料放到:');
console.log(` ${path.join(RAW_DIR, 'gpt', 'conversations.json')}`);
console.log(` ${path.join(RAW_DIR, 'notion/')}`);
console.log('或运行 cos-fetch.js 从 COS 拉取后再来。');
}
// 3. 输出全量训练文本
const fulltextPath = path.join(OUT_DIR, 'training-fulltext.jsonl');
const dialogPath = path.join(OUT_DIR, 'training-dialog.jsonl');
const personaBuckets = new Map();
let totalChars = 0;
let totalTokens = 0;
const fullStream = fs.createWriteStream(fulltextPath, 'utf8');
const dialogStream = fs.createWriteStream(dialogPath, 'utf8');
for (const d of allDialogs) {
fullStream.write(dialogToTrainingTextLine(d) + '\n');
if (d.messages.some((m) => m.role !== 'system')) {
dialogStream.write(dialogToSFTLine(d) + '\n');
}
const persona = classifyPersona(d);
if (!personaBuckets.has(persona)) {
personaBuckets.set(
persona,
fs.createWriteStream(
path.join(OUT_DIR, 'persona', `${persona}-dialog.jsonl`),
'utf8'
)
);
}
personaBuckets.get(persona).write(dialogToSFTLine(d) + '\n');
for (const m of d.messages) {
totalChars += (m.content || '').length;
totalTokens += estimateTokens(m.content);
}
}
fullStream.end();
dialogStream.end();
for (const s of personaBuckets.values()) s.end();
// 4. manifest
const manifest = {
schema: 'manual-import-manifest/v1',
imported_at: new Date().toISOString(),
raw_dir: RAW_DIR,
out_dir: OUT_DIR,
sources: {
gpt: {
path: gptPath,
dialogs: gpt.dialogs.length,
skipped: gpt.skipped,
},
notion: {
path: notionDir,
documents: notion.dialogs.length,
skipped: notion.skipped,
},
},
stats: {
total_dialogs: allDialogs.length,
total_chars: totalChars,
estimated_tokens: totalTokens,
persona_buckets: Object.fromEntries(
[...personaBuckets.keys()].map((k) => [k, true])
),
},
outputs: {
fulltext: { path: fulltextPath, exists: fs.existsSync(fulltextPath) },
dialog: { path: dialogPath, exists: fs.existsSync(dialogPath) },
},
soul_marker: COPYRIGHT,
};
const manifestPath = path.join(OUT_DIR, 'import-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
console.log(`📄 manifest: ${manifestPath}`);
// 5. 诚实的语料量评估提示
console.log('─'.repeat(64));
console.log(`总字数: ${totalChars.toLocaleString()}`);
console.log(`估算 token 数: ${totalTokens.toLocaleString()}`);
console.log(`对话/文档段数: ${allDialogs.length.toLocaleString()}`);
console.log('─'.repeat(64));
if (totalTokens < 5_000_000) {
console.log(
'⚠️ 语料量偏小 (<5M tokens)' +
'\n - 7B 模型全参 CPT 不可行(容易过拟合或欠拟合)' +
'\n - 1.5B 模型 LoRA / QLoRA 微调 可行' +
'\n - 建议先走 1.5B 微调路径(路径 X等积累更多语料再考虑 M0 全参' +
'\n - 详见 factory/docs/CORPUS-DECISION-MATRIX.md'
);
} else if (totalTokens < 200_000_000) {
console.log(
'🟡 语料量中等 (5M-200M tokens)' +
'\n - 1.5B 全参微调 OK路径 Y' +
'\n - 7B LoRA 微调 OK' +
'\n - 7B 全参 CPT 仍不建议'
);
} else if (totalTokens < 1_000_000_000) {
console.log(
'✅ 语料量充足 (200M-1B tokens)' +
'\n - 路径 Z · 7B CPT + 1.5B 蒸馏 推荐' +
'\n - 详见 factory/docs/CORPUS-DECISION-MATRIX.md'
);
} else {
console.log(
'🚀 语料量极大 (>1B tokens)' +
'\n - 路径 W · 完整 ARCH-002 原方案 强烈推荐' +
'\n - 7B 全参 CPT + 1.5B 蒸馏全套'
);
}
}
main();