#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Build Qwen3-Omni thinker SFT jsonl from aligned Novel query data.""" from __future__ import annotations import argparse import collections import datetime as dt import json import random import re import shutil from pathlib import Path from typing import Any, Dict, Iterable, List, Tuple from prompt_loader import render_prompt DEFAULT_INPUT = Path("/workspace/echoloc/Dataset/Novel/query_data/v2_2000/eval_inputs/e2e_input_aligned.jsonl") DEFAULT_OUT_DIR = Path("/workspace/echoloc/Dataset/Novel/query_data/v2_2000/sft/thinker_emochange") EMO_TOKEN = "<|EMO_CHANGE|>" def normalize_emo_change(text: str, language: str) -> str: text = (text or "").replace("", EMO_TOKEN) if language == "en": text = re.sub(r"\s*<\|EMO_CHANGE\|>\s*", f" {EMO_TOKEN} ", text) text = re.sub(r" {2,}", " ", text) return text.strip() return re.sub(r"\s*<\|EMO_CHANGE\|>\s*", EMO_TOKEN, text).strip() def iter_jsonl(path: Path) -> Iterable[Dict[str, Any]]: with path.open("r", encoding="utf-8") as handle: for line in handle: line = line.strip() if line: yield json.loads(line) def write_jsonl(path: Path, rows: Iterable[Dict[str, Any]]) -> int: path.parent.mkdir(parents=True, exist_ok=True) count = 0 with path.open("w", encoding="utf-8") as handle: for row in rows: handle.write(json.dumps(row, ensure_ascii=False) + "\n") count += 1 return count def build_sample(row: Dict[str, Any]) -> Dict[str, Any]: language = row.get("language", "zh") or "zh" query_type = row.get("query_type", "unknown") or "unknown" style = (row.get("oracle_thinker_style") or "").strip() text = normalize_emo_change(row.get("oracle_thinker_text") or "", language) answer = render_prompt("thinker_sft.answer_template", "Style: __STYLE__\n\nText: __TEXT__", {"STYLE": style, "TEXT": text}) return { "id": row["id"], "task": "novel_query_emochange", "audio_url": row["query_audio_path"], "language": language, "ability": f"novel/{query_type}", "query_type": query_type, "answer": answer, "thinking": "", "qid": row.get("qid", row.get("id")), "source_query": row.get("query_text", ""), "query_audio_control_path": row.get("query_audio_control_path", ""), "oracle_response_text": row.get("oracle_response_text", ""), "source_event": row.get("source_event", ""), "emo_change_spacing_rule": "en:space_around; zh:no_space_around", } def split_by_query_type(samples: List[Dict[str, Any]], val_ratio: float, seed: int) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: by_type: Dict[str, List[Dict[str, Any]]] = collections.defaultdict(list) for sample in samples: by_type[sample.get("query_type") or "unknown"].append(sample) rng = random.Random(seed) train: List[Dict[str, Any]] = [] val: List[Dict[str, Any]] = [] for key in sorted(by_type): group = by_type[key] rng.shuffle(group) n_val = max(1, round(len(group) * val_ratio)) val.extend(group[:n_val]) train.extend(group[n_val:]) rng.shuffle(train) rng.shuffle(val) return train, val def spacing_stats(rows: Iterable[Dict[str, Any]]) -> Dict[str, int]: counts: collections.Counter[Tuple[str, bool, bool]] = collections.Counter() for row in rows: answer = row.get("answer", "") start = 0 while True: idx = answer.find(EMO_TOKEN, start) if idx < 0: break left = answer[idx - 1] if idx > 0 else "" right_pos = idx + len(EMO_TOKEN) right = answer[right_pos] if right_pos < len(answer) else "" counts[(row.get("language", ""), left == " ", right == " ")] += 1 start = right_pos return {str(key): value for key, value in sorted(counts.items())} def validate_spacing(rows: Iterable[Dict[str, Any]]) -> List[str]: errors: List[str] = [] for row in rows: answer = row.get("answer", "") start = 0 while True: idx = answer.find(EMO_TOKEN, start) if idx < 0: break left = answer[idx - 1] if idx > 0 else "" right_pos = idx + len(EMO_TOKEN) right = answer[right_pos] if right_pos < len(answer) else "" language = row.get("language", "") if language == "zh" and (left == " " or right == " "): errors.append(f"{row.get('id')}: zh token has surrounding space") if language == "en" and not (left == " " and right == " "): errors.append(f"{row.get('id')}: en token lacks surrounding spaces") start = right_pos return errors def backup_existing(out_dir: Path) -> str: existing = [out_dir / name for name in ("train.jsonl", "val.jsonl", "manifest.json") if (out_dir / name).exists()] if not existing: return "" stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") backup_dir = out_dir / f"backup_before_sft_rebuild_{stamp}" backup_dir.mkdir(parents=True, exist_ok=True) for path in existing: shutil.copy2(path, backup_dir / path.name) return str(backup_dir) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--input", type=Path, default=DEFAULT_INPUT) parser.add_argument("--out_dir", type=Path, default=DEFAULT_OUT_DIR) parser.add_argument("--val_ratio", type=float, default=0.05) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--no_backup", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() samples = [build_sample(row) for row in iter_jsonl(args.input)] train, val = split_by_query_type(samples, args.val_ratio, args.seed) errors = validate_spacing(train) + validate_spacing(val) if errors: for error in errors[:20]: print(f"[ERROR] {error}") raise SystemExit(f"invalid EMO_CHANGE spacing: {len(errors)} errors") args.out_dir.mkdir(parents=True, exist_ok=True) (args.out_dir / "logs").mkdir(exist_ok=True) (args.out_dir / "output").mkdir(exist_ok=True) backup_dir = "" if args.no_backup else backup_existing(args.out_dir) write_jsonl(args.out_dir / "train.jsonl", train) write_jsonl(args.out_dir / "val.jsonl", val) summary = { "created_at": dt.datetime.now().isoformat(timespec="seconds"), "source": str(args.input), "output_dir": str(args.out_dir), "backup_before_rebuild": backup_dir, "split_seed": args.seed, "split_method": "stratified_by_query_type", "val_ratio": args.val_ratio, "emo_change_spacing_rule": "en: add one space before and after token; zh: remove spaces around token", "total": len(samples), "train": len(train), "val": len(val), "query_type_total": dict(collections.Counter(s.get("query_type") for s in samples)), "query_type_train": dict(collections.Counter(s.get("query_type") for s in train)), "query_type_val": dict(collections.Counter(s.get("query_type") for s in val)), "emo_change_total": sum(EMO_TOKEN in s["answer"] for s in samples), "emo_change_train": sum(EMO_TOKEN in s["answer"] for s in train), "emo_change_val": sum(EMO_TOKEN in s["answer"] for s in val), "spacing_train": spacing_stats(train), "spacing_val": spacing_stats(val), } (args.out_dir / "manifest.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps(summary, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())