#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Generate thinker supervision targets from query candidates.""" from __future__ import annotations import argparse import json import re from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from pathlib import Path from typing import Any, Dict, Iterable, List from common_llm import DEFAULT_BASE_URL, chat_completion, extract_json_object DEFAULT_INPUT = "/workspace/echoloc/Dataset/Novel/query_data/v2_2000/query_candidates.jsonl" DEFAULT_OUTPUT = "/workspace/echoloc/Dataset/Novel/query_data/v2_2000/thinker_targets.jsonl" DEFAULT_CONTROL_DIR = "/workspace/echoloc/Dataset/Novel/query_data/v2_2000/thinker_control2instruct" EMO_TOKEN = "<|EMO_CHANGE|>" def sanitize_id(qid: str) -> str: return re.sub(r"[^A-Za-z0-9_.-]+", "_", qid)[:180] def iter_items(path: Path) -> Iterable[Dict[str, Any]]: with path.open("r", encoding="utf-8") as f: for line in f: if line.strip(): yield json.loads(line) def load_existing_qids(path: Path) -> set: qids = set() if not path.exists(): return qids with path.open("r", encoding="utf-8") as f: for line in f: try: qid = json.loads(line).get("qid") except json.JSONDecodeError: continue if qid: qids.add(qid) return qids def normalize_internal_instruct(text: Any) -> str: text = str(text or "").strip() text = re.sub(r"^\s*请(用|使用|以)?", "", text).strip() text = re.sub(r"^\s*以", "", text).strip() text = re.sub(r",?进行(表达|朗读|演绎)\s*$", "", text).strip() return text def build_messages(item: Dict[str, Any]) -> List[Dict[str, str]]: system = f""" 你是 Omni thinker 监督数据生成 Agent。输入是一条用户 query candidate;你要生成 thinker 应输出给 talker 的控制文本: combined.instruct + combined.txt,以及 segments。 重要约束: 1. 你只能根据 visible_query.text 和未来 query 音频可能真实承载的声学信息来推断,不要把 hidden_context/source_event 里的细节当成用户一定说了出来。 2. hidden_context、source_event、target_contract 只能作为弱参考,帮助理解数据构造意图;如果 visible_query 没表达,就不要幻觉具体人物、关系、事件细节。 3. dialogue 类型:生成高情商 AI 助手回复。回复要接住用户情绪、清晰、有边界,不要角色续写,不要替用户编造事实。 4. instruction 类型:解析用户 TTS 指令,输出要朗读/表演的文本和对应声学控制;不要把“请用...说”整句当作要播报的内容。 5. 如果情绪/语气应发生变化,在 combined.txt 中用 {EMO_TOKEN} 标出自然分段位置。segments[].txt 不包含该 token。 6. 是否加入 {EMO_TOKEN} 由你根据 query 决定;不要为了形式强行加入。 7. combined.instruct 必须是中文自然语言声学控制,但应像 thinker 内部控制描述,不要像用户指令。格式接近:“年轻女性嗓音;平稳温和开场,逐渐转为坚定,最终以明亮上扬收束”。 8. instruct 字段不要使用祈使句,不要以“请、请用、请使用、请以”开头。 9. segments 建议 1-4 段。每段包含 instruct 和 txt。 10. 输出 thinker_reasoning,但它只用于分析,不一定进训练;要简洁说明你如何从 query 可见信息推断。 只输出 JSON,不要 Markdown。 输出格式: {{ "qid": "...", "query_type": "dialogue|instruction", "thinker_reasoning": "简要说明:可见文本/可听声学线索是什么,哪些信息不能幻觉,为什么这样回复/控制", "need_emochange": true, "segments": [ {{"instruct": "句级声学控制", "txt": "该段要说的话"}} ], "combined": {{ "instruct": "整体声学控制", "txt": "完整文本,必要时包含 <|EMO_CHANGE|>" }}, "combined_no_speaker": {{ "instruct": "不含具体音色人设的整体声学控制", "txt": "完整文本,必要时包含 <|EMO_CHANGE|>" }}, "safety_notes": [] }} """ payload = { "qid": item.get("qid"), "query_type": item.get("query_type"), "visible_query": item.get("visible_query"), "query_voice": item.get("query_voice"), "source_event": item.get("source_event"), "hidden_context": item.get("hidden_context"), "target_contract": item.get("target_contract"), } return [ {"role": "system", "content": system.strip()}, {"role": "user", "content": json.dumps(payload, ensure_ascii=False, indent=2)}, ] def normalize_target(item: Dict[str, Any], parsed: Dict[str, Any], model: str) -> Dict[str, Any]: qid = item.get("qid") parsed["qid"] = qid parsed["query_type"] = item.get("query_type") parsed["source_query"] = item.get("visible_query") parsed["source_event"] = item.get("source_event") parsed["generation"] = { "model": model, "prompt_version": "thinker_target_v1_visible_query_grounded", } for segment in parsed.get("segments") or []: if isinstance(segment, dict): segment["instruct"] = normalize_internal_instruct(segment.get("instruct", "")) for key in ["combined", "combined_no_speaker"]: value = parsed.get(key) if isinstance(value, dict): value["instruct"] = normalize_internal_instruct(value.get("instruct", "")) combined = parsed.get("combined") or {} txt = combined.get("txt", "") parsed["need_emochange"] = bool(parsed.get("need_emochange")) or (EMO_TOKEN in txt) return parsed def generate_one(item: Dict[str, Any], args: argparse.Namespace) -> Dict[str, Any]: try: raw = chat_completion( build_messages(item), model=args.model, base_url=args.base_url, api_key_env=args.api_key_env, temperature=args.temperature, enable_thinking=args.enable_thinking, timeout=args.http_timeout, ) parsed = extract_json_object(raw) return normalize_target(item, parsed, args.model) except Exception as exc: return { "qid": item.get("qid"), "query_type": item.get("query_type"), "status": "failed", "error": repr(exc), } def append_jsonl(path: Path, rows: List[Dict[str, Any]]) -> None: with path.open("a", encoding="utf-8") as f: for row in rows: f.write(json.dumps(row, ensure_ascii=False) + "\n") def export_control(target: Dict[str, Any], control_dir: Path) -> None: if target.get("status") == "failed": return qid = target.get("qid") if not qid: return out = control_dir / sanitize_id(qid) out.mkdir(parents=True, exist_ok=True) control = { "segments": target.get("segments", []), "combined": target.get("combined", {}), "combined_no_speaker": target.get("combined_no_speaker", {}), "meta": { "qid": qid, "query_type": target.get("query_type"), "thinker_reasoning": target.get("thinker_reasoning", ""), "source_query": target.get("source_query"), }, } (out / "control2instruct.json").write_text(json.dumps(control, ensure_ascii=False, indent=2), encoding="utf-8") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--input", default=DEFAULT_INPUT) parser.add_argument("--output", default=DEFAULT_OUTPUT) parser.add_argument("--control_dir", default=DEFAULT_CONTROL_DIR) parser.add_argument("--model", default="deepseek-v4-pro") parser.add_argument("--base_url", default=DEFAULT_BASE_URL) parser.add_argument("--api_key_env", default="DASHSCOPE_API_KEY") parser.add_argument("--temperature", type=float, default=0.4) parser.add_argument("--http_timeout", type=int, default=180) parser.add_argument("--num_workers", type=int, default=4) parser.add_argument("--limit", type=int, default=0) parser.add_argument("--resume", action="store_true") parser.add_argument("--enable_thinking", action="store_true") parser.add_argument("--export_controls", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() input_path = Path(args.input) output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) control_dir = Path(args.control_dir) if args.export_controls: control_dir.mkdir(parents=True, exist_ok=True) done = load_existing_qids(output_path) if args.resume else set() items = [x for x in iter_items(input_path) if x.get("qid") not in done] if args.limit > 0: items = items[: args.limit] print(f"[INFO] target_items={len(items)} resume_done={len(done)} output={output_path}", flush=True) with ThreadPoolExecutor(max_workers=max(1, args.num_workers)) as executor: pending = {} it = iter(items) def submit_next() -> bool: try: item = next(it) except StopIteration: return False pending[executor.submit(generate_one, item, args)] = item.get("qid") return True while len(pending) < args.num_workers and submit_next(): pass done_count = 0 while pending: done_futures, _ = wait(pending, return_when=FIRST_COMPLETED) rows = [] for fut in done_futures: pending.pop(fut, None) row = fut.result() rows.append(row) if args.export_controls: export_control(row, control_dir) done_count += 1 submit_next() append_jsonl(output_path, rows) print(f"[PROGRESS] generated={done_count}/{len(items)}", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main())