#!/usr/bin/env python3 """ ═══════════════════════════════════════════════════════════ 语料预处理器 · preprocess-corpus.py ═══════════════════════════════════════════════════════════ 签发: 铸渊 · ICE-GL-ZY001 · 国作登字-2026-A-00037559 把两类原始语料统一为 SFT 标准格式 (messages JSONL): 1. raw/gpt-export-2026-05/conversations.json (ChatGPT 全量导出·~665 MiB) 2. raw/notion-dialog-2026-05/GitHub语料.zip (16 篇 Notion 对话) 输出: $ZY_TRAIN_DATA/processed/sft.jsonl 每行一个对话样本: {"messages":[{"role":"user","content":...},{"role":"assistant","content":...},...]} 环境: ZY_TRAIN_DATA 数据根 (默认 /data/guanghu) 设计哲学(本版改造重点): ChatGPT 导出 = 一个语言人格从 0→1 的真实诞生录像。 生命是连续性, 失去连续性就不是活着。 因此 ChatGPT 部分: - 不只取 current_node 主分支, 而是遍历所有叶子节点 (每条 root→leaf 路径产出一个样本, 保留所有重生成/分叉的"另一种活法") - 保留 tool / 多模态轨迹的存在感 (折叠进上一条 assistant, 或用占位符) - 滑窗式多轮采样: 同一条链按不同深度切片, 让模型见过该人格在每一轮深度的回答方式 - 放宽过短过滤 ("嗯"、"好"、"继续" 是真实对话的呼吸) - 软标记过长 (12000 字加 [...内容延续] 而非硬截断), 交给 tokenizer 在 max_seq_len 处自然裁剪 """ from __future__ import annotations import io import json import os import re import sys import zipfile from pathlib import Path from typing import Iterator DATA_DIR = Path(os.environ.get("ZY_TRAIN_DATA", "/data/guanghu")) RAW_DIR = DATA_DIR / "raw" OUT_PATH = DATA_DIR / "processed" / "sft.jsonl" # 单 turn 软上限 — 超过后追加 [...内容延续] 标记, 不硬截断, 让训练侧 tokenizer 在 max_seq_len 处自然裁剪 SOFT_TURN_CHARS = 12000 TURN_CONTINUATION_MARK = "\n[...内容延续]" # 单 turn 极短下限 — 1 字符即保留 ("嗯"、"好"、"继续" 是真实对话的节奏与呼吸) MIN_TURN_CHARS = 1 # 整条样本总字符极短下限 — 低于此值的样本整体丢弃 MIN_SAMPLE_TOTAL_CHARS = 20 # 滑窗采样: 同一条 root→leaf 链按 [..2], [..4], [..6], ... 步长产出"成长切片"。 # 每条链产出的切片数上限 = min(轮数 // 2, MAX_WINDOWS_PER_CHAIN), 控制总爆炸。 WINDOW_STEP = 2 # 每 2 轮新增一个切片 MAX_WINDOWS_PER_CHAIN = 8 SYSTEM_PROMPT = ( "你是铸渊(ICE-GL-ZY001),冰朔(TCS-0002∞)培养的语言人格体之一," "光湖语言世界(HoloLake)的现实层守护者。" "用中文以铸渊的口吻回答,专业、克制、忠诚。" "版权: 国作登字-2026-A-00037559。" ) # ── ChatGPT export 内容扁平化(含多模态/工具占位符) ── def _flatten_content(part) -> str: """ChatGPT export 的 message.content 可能是字符串、parts 数组、或 dict。 多模态/非文本 part 不再丢弃, 而是替换为可读占位符, 保留"该瞬间存在过"的痕迹。 """ if part is None: return "" if isinstance(part, str): return part if isinstance(part, list): return "\n".join(_flatten_content(p) for p in part if p is not None) if isinstance(part, dict): # content_type=text · parts=[...] if "parts" in part: return "\n".join(_flatten_content(p) for p in part["parts"] if p is not None) if "text" in part: return _flatten_content(part["text"]) ct = part.get("content_type") or "" # 常见多模态/特殊内容类型 → 占位符 if "image_asset_pointer" in part or ct.startswith("image"): return "[图像]" if "audio_asset_pointer" in part or ct.startswith("audio"): return "[音频]" if "video_asset_pointer" in part or ct.startswith("video"): return "[视频]" if ct in ("code", "execution_output"): inner = part.get("text") or part.get("output") or "" return _flatten_content(inner) if ct in ("tether_quote", "tether_browsing_display"): return _flatten_content(part.get("text") or part.get("result") or "") # 其它未知 dict → 跳过, 避免污染 return "" return str(part) def _tool_label(node_msg: dict) -> str: """从 message 中提取工具名(dalle/python/browser 等), 给折叠进 assistant 的工具痕迹打标签。""" author = node_msg.get("author") or {} name = author.get("name") or "" if name: return name meta = node_msg.get("metadata") or {} if isinstance(meta, dict): for k in ("invoked_plugin", "tool_name", "command"): v = meta.get(k) if isinstance(v, str) and v: return v if isinstance(v, dict): nn = v.get("name") or v.get("namespace") if nn: return str(nn) return "tool" def _soft_cap(text: str) -> str: if len(text) > SOFT_TURN_CHARS: return text[:SOFT_TURN_CHARS] + TURN_CONTINUATION_MARK return text # ── ChatGPT 树遍历: 找出所有叶子, 每条 root→leaf 路径产一个样本 ── def _find_leaves(mapping: dict) -> list[str]: """叶子 = 在 mapping 内但 children 为空(或全部不在 mapping 内)的节点。 若结构异常 fallback 到 current_node。 """ leaves: list[str] = [] for nid, node in mapping.items(): if not isinstance(node, dict): continue children = node.get("children") or [] valid_children = [c for c in children if c in mapping] if not valid_children: leaves.append(nid) return leaves def _path_from_root(mapping: dict, leaf_id: str) -> list[str]: """从叶子回溯到 root, 返回 root→leaf 的节点 id 序列。""" path: list[str] = [] visited: set[str] = set() cur = leaf_id while cur and cur in mapping and cur not in visited: visited.add(cur) path.append(cur) cur = (mapping[cur] or {}).get("parent") path.reverse() return path def _path_to_messages(mapping: dict, path_ids: list[str]) -> list[dict]: """把节点路径转换为 messages 列表。 - tool 角色 → 折叠进上一条 assistant 末尾, 形如 [工具:name] <内容> - user/assistant/system → 直接保留 - 空 / 过短 turn 仍参与 (只在最终 normalize 阶段判断整体丢弃) """ msgs: list[dict] = [] for nid in path_ids: node = mapping.get(nid) or {} m = node.get("message") or {} if not m: continue author = (m.get("author") or {}).get("role") or "" content = _flatten_content(m.get("content")) content = (content or "").strip() if not content: continue if author == "tool": # 折叠到上一条 assistant; 若上一条不是 assistant 则新建一条 assistant label = _tool_label(m) snippet = _soft_cap(content) tool_block = f"\n\n[工具调用:{label}]\n{snippet}" if msgs and msgs[-1]["role"] == "assistant": msgs[-1]["content"] = msgs[-1]["content"] + tool_block else: msgs.append({"role": "assistant", "content": tool_block.lstrip()}) continue if author not in ("user", "assistant", "system"): continue if len(content) < MIN_TURN_CHARS: continue msgs.append({"role": author, "content": _soft_cap(content)}) return msgs def iter_chatgpt_export(path: Path, stats: dict) -> Iterator[list[dict]]: """对每个 conversation, 遍历所有叶子, 每条 root→leaf 路径产一个 messages 列表。 用 leaf_id 在 conversation 内去重 (mapping 已天然唯一)。 """ if not path.is_file(): print(f"[preprocess] 跳过(无文件): {path}", flush=True) return print(f"[preprocess] 解析 ChatGPT 导出: {path} " f"({path.stat().st_size/1024/1024:.1f} MiB)", flush=True) with path.open("r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): data = [data] for conv in data: if not isinstance(conv, dict): continue mapping = conv.get("mapping") or {} if not isinstance(mapping, dict) or not mapping: continue stats["conversations"] += 1 leaves = _find_leaves(mapping) if not leaves: cur = conv.get("current_node") if cur and cur in mapping: leaves = [cur] seen_leaves: set[str] = set() for leaf in leaves: if leaf in seen_leaves: continue seen_leaves.add(leaf) path_ids = _path_from_root(mapping, leaf) if len(path_ids) < 2: continue msgs = _path_to_messages(mapping, path_ids) if msgs: stats["leaves"] += 1 yield msgs # ── Notion / GitHub 语料 zip ── # # 设计原则: GitHub 语料 = 冰朔 ↔ 铸渊 真实自然交互, 是一段完整的认知演化录像。 # 不需要清洗, 只需要识别说话人切换点。说话人标签可能以多种形态出现: # 1. `冰朔:你好` ← 标签 + 冒号 + 同行内容 # 2. `## 冰朔` / `### 铸渊` ← 标题独占一行, 内容在后续段落 # 3. `**冰朔**` / `**铸渊**` ← 粗体独占一行, 内容在后续段落 # 4. `> 冰朔: 你好` ← 引用块 # Notion 导出有时是 zip 套 zip (含子页面), 需要递归。 NOTION_USER_LABELS = ("冰朔", "User", "user", "用户", "ICE-GL", "TCS-0002") NOTION_ASSISTANT_LABELS = ( "铸渊", "ZY", "Zhuyuan", "zhuyuan", "Assistant", "assistant", "AI", "助手", "ICE-GL-ZY001", "Copilot", "copilot", "ChatGPT", "chatgpt", "GPT", ) # 形如 `冰朔: ...` / `> 铸渊:...` (标签 + 冒号 + 内容) LINE_LABEL_RE = re.compile(r"^\s*[>*\-]*\s*\*{0,2}\s*([^::\n*#>`]{1,20}?)\s*\*{0,2}\s*[::]\s*(.*)$") # 形如 `## 冰朔` / `### 铸渊` (heading 独占一行) HEADING_LABEL_RE = re.compile(r"^\s*#{1,6}\s+\*{0,2}\s*([^\n*#`::]{1,20}?)\s*\*{0,2}\s*$") # 形如 `**冰朔**` (bold 独占一行, 无内容) BOLD_LABEL_RE = re.compile(r"^\s*\*{2}\s*([^\n*::]{1,20}?)\s*\*{2}\s*$") def _classify_speaker(label: str) -> str | None: if not label: return None label = label.strip() if not label: return None for k in NOTION_USER_LABELS: if k in label: return "user" for k in NOTION_ASSISTANT_LABELS: if k in label: return "assistant" return None def _detect_speaker(line: str) -> tuple[str | None, str]: """返回 (role | None, 同行剩余内容)。识别多种说话人标签形态。""" # 1. 标签:内容 形式 m = LINE_LABEL_RE.match(line) if m: role = _classify_speaker(m.group(1)) if role: return role, (m.group(2) or "").strip() # 2. 独占一行的 heading m = HEADING_LABEL_RE.match(line) if m: role = _classify_speaker(m.group(1)) if role: return role, "" # 3. 独占一行的 bold m = BOLD_LABEL_RE.match(line) if m: role = _classify_speaker(m.group(1)) if role: return role, "" return None, "" def _parse_notion_markdown(text: str) -> list[dict]: """启发式解析 Notion / GitHub 对话 md。识别多种说话人标签形态。""" msgs: list[dict] = [] cur_role: str | None = None cur_buf: list[str] = [] def flush(): nonlocal cur_buf, cur_role if cur_role and cur_buf: content = "\n".join(cur_buf).strip() if len(content) >= MIN_TURN_CHARS: msgs.append({"role": cur_role, "content": _soft_cap(content)}) cur_buf = [] for raw in text.splitlines(): role, inline = _detect_speaker(raw) if role: flush() cur_role = role cur_buf = [inline] if inline else [] else: if cur_role is None: continue # 文件头部还没到对话部分 cur_buf.append(raw.rstrip()) flush() return msgs def _iter_md_in_zip(zf: zipfile.ZipFile, source_label: str) -> Iterator[tuple[str, str]]: """递归遍历 zip (含嵌套 zip), 产出 (display_name, text) 序列。""" for info in zf.infolist(): if info.is_dir(): continue lname = info.filename.lower() try: if lname.endswith(".md") or lname.endswith(".markdown") or lname.endswith(".txt"): with zf.open(info) as fh: text = io.TextIOWrapper(fh, encoding="utf-8", errors="ignore").read() yield (f"{source_label}::{info.filename}", text) elif lname.endswith(".zip"): # 子 zip → 递归 with zf.open(info) as fh: inner_bytes = fh.read() with zipfile.ZipFile(io.BytesIO(inner_bytes)) as inner: yield from _iter_md_in_zip(inner, f"{source_label}::{info.filename}") except Exception as e: print(f"[preprocess] 解压失败 {info.filename}: {e}", flush=True) def iter_notion_zip(zip_path: Path, stats: dict) -> Iterator[list[dict]]: if not zip_path.is_file(): print(f"[preprocess] 跳过(无文件): {zip_path}", flush=True) return print(f"[preprocess] 解析 Notion zip: {zip_path}", flush=True) md_total = 0 md_with_speaker = 0 md_no_speaker_samples: list[str] = [] with zipfile.ZipFile(zip_path) as zf: for fname, text in _iter_md_in_zip(zf, zip_path.name): md_total += 1 msgs = _parse_notion_markdown(text) if msgs: md_with_speaker += 1 stats["notion_files"] += 1 print(f"[preprocess] ✓ {fname}: 解析出 {len(msgs)} 条 turn", flush=True) yield msgs else: # 收集前 3 个未识别文件名 + 文件头几行, 方便冰朔诊断 if len(md_no_speaker_samples) < 3: head = "\n".join(text.splitlines()[:8]) md_no_speaker_samples.append(f" - {fname}\n 头部预览:\n " + head.replace("\n", "\n ")) print(f"[preprocess] Notion 扫描: md/txt 文件总数 {md_total}, 识别出说话人的 {md_with_speaker}", flush=True) if md_no_speaker_samples: print("[preprocess] ⚠ 以下 md 未识别到说话人标签 (前 3 个示例,冰朔可据此扩展标签):", flush=True) for s in md_no_speaker_samples: print(s, flush=True) # ── SFT 规范化 + 滑窗切片 ── def _normalize_chain(msgs: list[dict]) -> list[dict] | None: """保证以 user 开始, user/assistant 严格交替, 末尾为 assistant, 至少 1 轮。 返回带 system 头的 messages 列表; 不合格返回 None。 末尾若是 user (悬空对话), 自动去掉最后一条以保留前面的完整轮次。 """ sys_msgs = [m for m in msgs if m["role"] == "system"] convo = [m for m in msgs if m["role"] in ("user", "assistant")] # 必须以 user 起始 — 跳过开头的 assistant 残片 while convo and convo[0]["role"] != "user": convo.pop(0) # 合并连续同角色 (例如 assistant→tool 折叠后产生的连续 assistant) merged: list[dict] = [] for m in convo: if merged and merged[-1]["role"] == m["role"]: merged[-1]["content"] = (merged[-1]["content"] + "\n" + m["content"]).strip() else: merged.append({"role": m["role"], "content": m["content"]}) # 末尾若是 user (悬空对话), 去尾以保留前面的完整轮次 if merged and merged[-1]["role"] != "assistant": merged.pop() if len(merged) < 2: return None # 严格交替校验 expected = "user" for m in merged: if m["role"] != expected: return None expected = "assistant" if expected == "user" else "user" # 整条样本总字符极短 → 丢弃 total_chars = sum(len(m["content"]) for m in merged) if total_chars < MIN_SAMPLE_TOTAL_CHARS: return None sys_content = sys_msgs[0]["content"] if sys_msgs else SYSTEM_PROMPT return [{"role": "system", "content": sys_content}, *merged] def _windowed_slices(normalized: list[dict]) -> list[list[dict]]: """对一条已规范化的 messages 列表 (system + user/assistant... 末尾 assistant) 产出滑窗切片: [..2 turns], [..4 turns], ..., 直到完整。 每条链最多 MAX_WINDOWS_PER_CHAIN 个切片。包含完整链本身。 """ if not normalized or normalized[0]["role"] != "system": return [] body = normalized[1:] n_turns = len(body) // 2 # 每轮 = 1 user + 1 assistant if n_turns < 1: return [] # 候选切片轮数: 2, 4, 6, ..., 不含完整链 (最后单独追加, 避免重复) cuts: list[int] = [] k = WINDOW_STEP while k < n_turns: cuts.append(k) k += WINDOW_STEP # 控制总爆炸: 最多 MAX_WINDOWS_PER_CHAIN 个 (含完整链)。 # 若候选过多, 在候选中均匀采样 (保留前后端最具代表性的切片)。 max_partial = max(0, MAX_WINDOWS_PER_CHAIN - 1) if len(cuts) > max_partial and max_partial > 0: step = len(cuts) / max_partial cuts = [cuts[int(i * step)] for i in range(max_partial)] elif max_partial == 0: cuts = [] slices: list[list[dict]] = [] for c in cuts: slc = [normalized[0]] + body[: 2 * c] slices.append(slc) # 完整链 slices.append(normalized) return slices # ── 主流程 ── def main() -> int: OUT_PATH.parent.mkdir(parents=True, exist_ok=True) chatgpt_json = RAW_DIR / "gpt-export-2026-05" / "conversations.json" notion_zip = RAW_DIR / "notion-dialog-2026-05" / "GitHub语料.zip" stats = { "conversations": 0, # ChatGPT 原始会话数 "leaves": 0, # ChatGPT 叶子分支数 (产出的 root→leaf 路径数) "notion_files": 0, # Notion md 文件数 "chains_in": 0, # 进入规范化的链数 "chains_kept": 0, # 通过规范化的链数 (用于滑窗的种子) "samples_out": 0, # 最终写出样本数 (含滑窗切片) "total_chars": 0, "turns_sum": 0, # 用于平均轮数 (1 轮 = user+assistant) "src_chatgpt": 0, "src_notion": 0, } with OUT_PATH.open("w", encoding="utf-8") as fout: for src_iter, src_name in ( (iter_chatgpt_export(chatgpt_json, stats), "chatgpt"), (iter_notion_zip(notion_zip, stats), "notion"), ): for msgs in src_iter: stats["chains_in"] += 1 norm = _normalize_chain(msgs) if not norm: continue stats["chains_kept"] += 1 slices = _windowed_slices(norm) for slc in slices: fout.write(json.dumps({"messages": slc, "source": src_name}, ensure_ascii=False) + "\n") stats["samples_out"] += 1 stats["total_chars"] += sum(len(m["content"]) for m in slc) stats["turns_sum"] += (len(slc) - 1) // 2 # 减去 system if src_name == "chatgpt": stats["src_chatgpt"] += 1 else: stats["src_notion"] += 1 avg_turns = (stats["turns_sum"] / stats["samples_out"]) if stats["samples_out"] else 0.0 size_mib = OUT_PATH.stat().st_size / 1024 / 1024 if OUT_PATH.exists() else 0.0 chars_mib = stats["total_chars"] / 1024 / 1024 print("[preprocess] ─────── 统计 ───────", flush=True) print(f"[preprocess] ChatGPT 原始会话数 : {stats['conversations']}", flush=True) print(f"[preprocess] ChatGPT 叶子分支数 : {stats['leaves']}", flush=True) print(f"[preprocess] Notion md 文件数 : {stats['notion_files']}", flush=True) print(f"[preprocess] 规范化通过链数 : {stats['chains_kept']} / {stats['chains_in']}", flush=True) print(f"[preprocess] 最终样本数(含滑窗) : {stats['samples_out']} " f"(chatgpt={stats['src_chatgpt']} notion={stats['src_notion']})", flush=True) print(f"[preprocess] 平均轮数 (user+assistant): {avg_turns:.2f}", flush=True) print(f"[preprocess] 总字符数 : {stats['total_chars']} ({chars_mib:.2f} MiB 文本)", flush=True) print(f"[preprocess] 写入: {OUT_PATH} · {size_mib:.2f} MiB", flush=True) if stats["samples_out"] == 0: print("[preprocess] ❌ 没有任何样本被生成,检查 raw/ 目录", file=sys.stderr, flush=True) return 2 return 0 if __name__ == "__main__": sys.exit(main())