from __future__ import annotations """product-data 可迁移脚本的共享逻辑。 这里不依赖 ZK Data Agent 的运行时,便于把整个 skill 复制到其他 Agent 或普通 Python 环境中使用。平台注册工具时,也应该只薄封装这些脚本。 """ import argparse import csv import json import os import re import sys import time from io import StringIO from pathlib import Path from typing import Any DEFAULT_REQUEST_ID = "aabbccdd" DEFAULT_TIMESTAMP_STEP_MS = 60_000 DEFAULT_SYSTEM_PROMPT = "你是小爱同学,中文智能语音助手。" DEFAULT_CONTEXT_FIELDS = ("location", "rag") class ProductDataError(ValueError): """数据格式或参数错误。""" def load_json_payload(argv: list[str] | None = None) -> dict[str, Any]: parser = argparse.ArgumentParser(add_help=True) parser.add_argument("--input", "-i", help="JSON 参数文件;不传则从 stdin 读取") args = parser.parse_args(argv) if args.input: text = Path(args.input).expanduser().read_text(encoding="utf-8") else: text = sys.stdin.read() if not text.strip(): raise ProductDataError("input JSON is required") payload = json.loads(text) if not isinstance(payload, dict): raise ProductDataError("input JSON must be an object") return payload def emit_success(payload: dict[str, Any]) -> None: result = {"ok": True, **payload} print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) def emit_error(exc: BaseException) -> None: print( json.dumps( {"ok": False, "error": str(exc)}, ensure_ascii=False, separators=(",", ":"), ) ) def load_records(payload: dict[str, Any]) -> list[dict[str, Any]]: if "records" in payload: records = payload["records"] elif "records_path" in payload: records = read_records_file(str(payload["records_path"])) else: raise ProductDataError("records or records_path is required") if isinstance(records, str): records = json.loads(records) if not isinstance(records, list): raise ProductDataError("records must be an array") normalized: list[dict[str, Any]] = [] for index, item in enumerate(records): if not isinstance(item, dict): raise ProductDataError(f"records[{index}] must be an object") normalized.append(item) return normalized def read_records_file(path: str) -> list[dict[str, Any]]: file_path = resolve_portable_path(path) text = file_path.read_text(encoding="utf-8") if file_path.suffix.lower() == ".jsonl": records: list[dict[str, Any]] = [] for line_number, line in enumerate(text.splitlines(), start=1): if not line.strip(): continue item = json.loads(line) if not isinstance(item, dict): raise ProductDataError(f"{path}:{line_number} must be a JSON object") records.append(item) return records decoded = json.loads(text) if isinstance(decoded, dict) and isinstance(decoded.get("records"), list): decoded = decoded["records"] if not isinstance(decoded, list): raise ProductDataError(f"{path} must contain a JSON array") return decoded def normalize_dataset_draft( draft_text: str, *, batch_id: str = DEFAULT_REQUEST_ID, source_type: str = "generated", base_timestamp: int | None = None, timestamp_step_ms: int = DEFAULT_TIMESTAMP_STEP_MS, default_request_id: str = DEFAULT_REQUEST_ID, ) -> dict[str, Any]: if source_type not in {"generated", "online", "manual", "mixed"}: raise ProductDataError("source_type is invalid") if not draft_text.strip(): raise ProductDataError("draft_text must be non-empty") if timestamp_step_ms <= 0: raise ProductDataError("timestamp_step_ms must be greater than 0") base = int(time.time() * 1000) if base_timestamp is None else int(base_timestamp) parsed = parse_draft_text(draft_text) records: list[dict[str, Any]] = [] warnings: list[str] = [] for index, case in enumerate(parsed["cases"], start=1): record, case_warnings = case_to_record( case, global_dataset_label=str(parsed.get("dataset_label") or ""), index=index, batch_id=batch_id, source_type=source_type, base_timestamp=base, timestamp_step_ms=timestamp_step_ms, default_request_id=default_request_id, ) records.append(record) warnings.extend(case_warnings) return {"records": records, "warnings": warnings} def parse_draft_text(draft_text: str) -> dict[str, Any]: dataset_label = "" cases: list[dict[str, Any]] = [] current: dict[str, Any] | None = None for raw_line in draft_text.splitlines(): line = raw_line.strip() if not line: continue dataset_match = re.match(r"^#\s*dataset_label\s*[::]\s*(.+)$", line, re.I) if dataset_match: dataset_label = dataset_match.group(1).strip() continue case_match = re.match(r"^#{3,}\s*case\s*[::]\s*(.+)$", line, re.I) if case_match: current = {"case_name": case_match.group(1).strip(), "turns": []} cases.append(current) continue if current is None: continue field_match = re.match( r"^(用户|小爱|target|complex|notes|dataset_label|request_id|timestamp)\s*[::]\s*(.*)$", line, re.I, ) if not field_match: continue key = field_match.group(1).lower() value = field_match.group(2).strip() if key == "用户": current["turns"].append({"role": "user", "text": value}) elif key == "小爱": current["turns"].append({"role": "assistant", "text": value}) else: current[key] = value if not cases: raise ProductDataError('draft_text must contain at least one "### case:" block') return {"dataset_label": dataset_label, "cases": cases} def case_to_record( case: dict[str, Any], *, global_dataset_label: str, index: int, batch_id: str, source_type: str, base_timestamp: int, timestamp_step_ms: int, default_request_id: str, ) -> tuple[dict[str, Any], list[str]]: warnings: list[str] = [] turns = case.get("turns") if not isinstance(turns, list): raise ProductDataError(f"case {index} has invalid turns") user_indexes = [ turn_index for turn_index, turn in enumerate(turns) if isinstance(turn, dict) and turn.get("role") == "user" ] if not user_indexes: raise ProductDataError(f"case {index} must contain at least one 用户 line") final_user_index = user_indexes[-1] current_query = str(turns[final_user_index].get("text") or "").strip() if not current_query: raise ProductDataError(f"case {index} current 用户 line must be non-empty") raw_target, prefixed_complex = split_complex_prefixed_target(str(case.get("target") or "").strip()) target = canonical_target(raw_target) if not target: raise ProductDataError(f"case {index} target is required") complex_value = parse_complex( case.get("complex"), default=False if prefixed_complex is None else prefixed_complex, ) prev_session = build_prev_session(turns[:final_user_index], case_index=index) if len(prev_session) > 10: warnings.append(f"case {index} prev_session has more than 10 turns; keeping the latest 10") prev_session = prev_session[-10:] request_id = str(case.get("request_id") or default_request_id).strip() turn_timestamp = optional_int(case.get("timestamp")) if turn_timestamp is None: turn_timestamp = base_timestamp + (index - 1) * timestamp_step_ms * 20 first_prev_timestamp = turn_timestamp - len(prev_session) * timestamp_step_ms for prev_index, item in enumerate(prev_session): item["timestamp"] = first_prev_timestamp + prev_index * timestamp_step_ms dataset_label = str(case.get("dataset_label") or global_dataset_label or "").strip() record = { "record_id": record_id( source_type=source_type, batch_id=batch_id, request_id=request_id, index=index, ), "source": { "type": source_type, "request_id": request_id, "timestamp": turn_timestamp, }, "turn": { "query": current_query, "timestamp": turn_timestamp, }, "prev_session": prev_session, "context": {}, "label": { "dataset_label": dataset_label, "target": target, "target_type": target_type(target), }, "dimensions": { "complex": complex_value, }, "meta": { "case_name": str(case.get("case_name") or "").strip(), "notes": str(case.get("notes") or "").strip(), }, } return record, warnings def build_prev_session(turns: list[dict[str, Any]], *, case_index: int) -> list[dict[str, Any]]: prev_session: list[dict[str, Any]] = [] index = 0 while index < len(turns): turn = turns[index] if not isinstance(turn, dict) or turn.get("role") != "user": raise ProductDataError(f"case {case_index} prev_session must start with 用户 before 小爱") if index + 1 >= len(turns) or turns[index + 1].get("role") != "assistant": raise ProductDataError(f"case {case_index} each previous 用户 line must be followed by 小爱") query = str(turn.get("text") or "").strip() tts = str(turns[index + 1].get("text") or "").strip() if not query or not tts: raise ProductDataError(f"case {case_index} previous 用户/小爱 lines must be non-empty") prev_session.append({"query": query, "tts": tts}) index += 2 return prev_session def validate_dataset_records(records: list[dict[str, Any]]) -> dict[str, Any]: errors: list[dict[str, str]] = [] warnings: list[dict[str, str]] = [] seen_ids: set[str] = set() for index, record in enumerate(records): prefix = f"records[{index}]" if not isinstance(record, dict): errors.append(issue(prefix, "record must be an object")) continue record_id_value = record.get("record_id") if not non_empty_str(record_id_value): errors.append(issue(f"{prefix}.record_id", "record_id is required")) elif record_id_value in seen_ids: errors.append(issue(f"{prefix}.record_id", "record_id must be unique")) else: seen_ids.add(str(record_id_value)) source = record.get("source") if not isinstance(source, dict): errors.append(issue(f"{prefix}.source", "source is required")) else: if source.get("type") not in {"generated", "online", "manual", "mixed"}: errors.append(issue(f"{prefix}.source.type", "source.type is invalid")) if not non_empty_str(source.get("request_id")): errors.append(issue(f"{prefix}.source.request_id", "source.request_id is required")) if not int_like(source.get("timestamp")): errors.append(issue(f"{prefix}.source.timestamp", "source.timestamp must be an integer")) turn = record.get("turn") if not isinstance(turn, dict): errors.append(issue(f"{prefix}.turn", "turn is required")) turn_timestamp = None else: if not non_empty_str(turn.get("query")): errors.append(issue(f"{prefix}.turn.query", "turn.query is required")) if "tts" in turn: errors.append(issue(f"{prefix}.turn.tts", "current turn must not contain tts")) turn_timestamp = turn.get("timestamp") if not int_like(turn_timestamp): errors.append(issue(f"{prefix}.turn.timestamp", "turn.timestamp must be an integer")) prev_session = record.get("prev_session") prev_timestamps: list[int] = [] if not isinstance(prev_session, list): errors.append(issue(f"{prefix}.prev_session", "prev_session must be a list")) else: if len(prev_session) > 10: errors.append(issue(f"{prefix}.prev_session", "prev_session must contain at most 10 turns")) for prev_index, item in enumerate(prev_session): item_prefix = f"{prefix}.prev_session[{prev_index}]" if not isinstance(item, dict): errors.append(issue(item_prefix, "prev_session item must be an object")) continue if not non_empty_str(item.get("query")): errors.append(issue(f"{item_prefix}.query", "query is required")) if "tts" not in item or not isinstance(item.get("tts"), str): errors.append(issue(f"{item_prefix}.tts", "tts must be a string")) timestamp = item.get("timestamp") if not int_like(timestamp): errors.append(issue(f"{item_prefix}.timestamp", "timestamp must be an integer")) else: prev_timestamps.append(int(timestamp)) if prev_timestamps != sorted(prev_timestamps): errors.append(issue(f"{prefix}.prev_session", "timestamps must be sorted from early to late")) if int_like(turn_timestamp): all_timestamps = [*prev_timestamps, int(turn_timestamp)] for left, right in zip(all_timestamps, all_timestamps[1:]): if right <= left: errors.append(issue(f"{prefix}.timestamp", "turn timestamps must be strictly increasing")) break if right - left > 300_000: warnings.append(issue(f"{prefix}.timestamp", "adjacent turns are more than 5 minutes apart")) if not isinstance(record.get("context"), dict): errors.append(issue(f"{prefix}.context", "context must be an object")) label = record.get("label") if not isinstance(label, dict): errors.append(issue(f"{prefix}.label", "label is required")) else: if not non_empty_str(label.get("dataset_label")): warnings.append(issue(f"{prefix}.label.dataset_label", "dataset_label is empty")) if not non_empty_str(label.get("target")): errors.append(issue(f"{prefix}.label.target", "label.target is required")) if label.get("target_type") not in {"agent", "function", "unknown"}: errors.append(issue(f"{prefix}.label.target_type", "label.target_type is invalid")) dimensions = record.get("dimensions") if not isinstance(dimensions, dict): warnings.append(issue(f"{prefix}.dimensions", "dimensions.complex is missing; false will be used as fallback")) elif "complex" not in dimensions: warnings.append(issue(f"{prefix}.dimensions.complex", "complex is missing; false will be used as fallback")) elif not isinstance(dimensions.get("complex"), bool): errors.append(issue(f"{prefix}.dimensions.complex", "complex must be a boolean")) return { "ok": not errors, "error_count": len(errors), "warning_count": len(warnings), "errors": errors, "warnings": warnings, } def export_dataset_records( records: list[dict[str, Any]], *, output_path: str, output_format: str = "jsonl", require_validation_ok: bool = True, overwrite: bool = True, export_table: bool = True, ) -> dict[str, Any]: if output_format not in {"jsonl", "json"}: raise ProductDataError("output_format must be jsonl or json") validation = validate_dataset_records(records) if require_validation_ok and not validation["ok"]: raise ProductDataError(f"records failed validation with {validation['error_count']} errors") path = Path(output_path).expanduser() table_path = path.with_name("records.csv") if path.exists() and not overwrite: raise ProductDataError(f"output_path already exists: {output_path}") if export_table and table_path.exists() and not overwrite: raise ProductDataError("table output_path already exists: records.csv") path.parent.mkdir(parents=True, exist_ok=True) if output_format == "jsonl": content = "".join( json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" for record in records ) else: content = json.dumps(records, ensure_ascii=False, separators=(",", ":")) + "\n" path.write_text(content, encoding="utf-8") payload = { "output_path": str(path), "output_format": output_format, "record_count": len(records), "bytes_written": len(content.encode("utf-8")), "validation": validation, } if export_table: table_content = render_dataset_records_table_csv(records) table_path.write_text(table_content, encoding="utf-8-sig") payload.update( { "table_output_path": str(table_path), "table_output_format": "csv", "table_bytes_written": len(table_content.encode("utf-8-sig")), } ) return payload def export_dataset_table( records: list[dict[str, Any]], *, output_path: str, require_validation_ok: bool = True, overwrite: bool = True, ) -> dict[str, Any]: validation = validate_dataset_records(records) if require_validation_ok and not validation["ok"]: raise ProductDataError(f"records failed validation with {validation['error_count']} errors") path = Path(output_path).expanduser() if path.exists() and not overwrite: raise ProductDataError(f"output_path already exists: {output_path}") path.parent.mkdir(parents=True, exist_ok=True) content = render_dataset_records_table_csv(records) path.write_text(content, encoding="utf-8-sig") return { "output_path": str(path), "output_format": "csv", "record_count": len(records), "bytes_written": len(content.encode("utf-8-sig")), "validation": validation, } def export_training_jsonl( records: list[dict[str, Any]], *, output_path: str, session_num: int = 5, session_time_minutes: int = 5, context_fields: list[str] | None = None, system_prompt: str = DEFAULT_SYSTEM_PROMPT, require_validation_ok: bool = True, overwrite: bool = True, ) -> dict[str, Any]: if session_num <= 0: raise ProductDataError("session_num must be greater than 0") if session_time_minutes <= 0: raise ProductDataError("session_time_minutes must be greater than 0") validation = validate_dataset_records(records) if require_validation_ok and not validation["ok"]: raise ProductDataError(f"records failed validation with {validation['error_count']} errors") path = Path(output_path).expanduser() if path.exists() and not overwrite: raise ProductDataError(f"output_path already exists: {output_path}") path.parent.mkdir(parents=True, exist_ok=True) fields = normalize_context_fields(context_fields) content = "".join( training_jsonl_line( record, session_num=session_num, session_time_minutes=session_time_minutes, context_fields=fields, system_prompt=system_prompt, ) + "\n" for record in records ) path.write_text(content, encoding="utf-8") return { "output_path": str(path), "output_format": "jsonl", "record_count": len(records), "bytes_written": len(content.encode("utf-8")), "validation": validation, } def export_planning_eval_csv( records: list[dict[str, Any]], *, output_path: str, session_num: int = 5, session_time_minutes: int = 5, context_fields: list[str] | None = None, system_prompt: str = DEFAULT_SYSTEM_PROMPT, complex_default: bool = False, require_validation_ok: bool = True, overwrite: bool = True, ) -> dict[str, Any]: if session_num <= 0: raise ProductDataError("session_num must be greater than 0") if session_time_minutes <= 0: raise ProductDataError("session_time_minutes must be greater than 0") validation = validate_dataset_records(records) if require_validation_ok and not validation["ok"]: raise ProductDataError(f"records failed validation with {validation['error_count']} errors") path = Path(output_path).expanduser() if path.exists() and not overwrite: raise ProductDataError(f"output_path already exists: {output_path}") path.parent.mkdir(parents=True, exist_ok=True) content = render_planning_eval_csv( records, session_num=session_num, session_time_minutes=session_time_minutes, context_fields=context_fields, system_prompt=system_prompt, complex_default=complex_default, ) path.write_text(content, encoding="utf-8-sig") return { "output_path": str(path), "output_format": "csv", "record_count": len(records), "bytes_written": len(content.encode("utf-8-sig")), "validation": validation, } def render_dataset_records_table_csv(records: list[dict[str, Any]]) -> str: output = StringIO() writer = csv.DictWriter(output, fieldnames=dataset_table_columns(), lineterminator="\n") writer.writeheader() for record in records: writer.writerow(dataset_record_table_row(record)) return output.getvalue() def render_planning_eval_csv( records: list[dict[str, Any]], *, session_num: int = 5, session_time_minutes: int = 5, context_fields: list[str] | None = None, system_prompt: str = DEFAULT_SYSTEM_PROMPT, complex_default: bool = False, ) -> str: fields = normalize_context_fields(context_fields) output = StringIO() writer = csv.DictWriter( output, fieldnames=["request_id", "newPrompt", "query", "类别真实标签", "code标签", "complex"], lineterminator="\n", ) writer.writeheader() for record in records: source = record.get("source") if isinstance(record.get("source"), dict) else {} turn = record.get("turn") if isinstance(record.get("turn"), dict) else {} target = record_target(record) writer.writerow( { "request_id": str(source.get("request_id") or ""), "newPrompt": build_planning_prompt( record, session_num=session_num, session_time_minutes=session_time_minutes, context_fields=fields, system_prompt=system_prompt, ), "query": str(turn.get("query") or ""), "类别真实标签": category_label_from_target(target), "code标签": target, "complex": eval_complex_literal(record_complex(record, default=complex_default)), } ) return output.getvalue() def build_training_instruction( record: dict[str, Any], *, session_num: int = 5, session_time_minutes: int = 5, context_fields: list[str] | None = None, ) -> str: turn = record.get("turn") if isinstance(record.get("turn"), dict) else {} query = str(turn.get("query") or "") context = record.get("context") if isinstance(record.get("context"), dict) else {} prev_session = record.get("prev_session") if isinstance(record.get("prev_session"), list) else [] current_ts = optional_int_for_export(turn.get("timestamp")) if current_ts is None: source = record.get("source") if isinstance(record.get("source"), dict) else {} current_ts = optional_int_for_export(source.get("timestamp")) session, last_tts = training_history( prev_session, current_ts=current_ts, session_num=session_num, session_time_minutes=session_time_minutes, ) fields = normalize_context_fields(context_fields) instruction = "请参考用户的[当前query]、[对话历史]、[知识注入]、[系统状态]识别出[当前query]的[function]结果,[function]是python的code形式。\n" instruction += "[知识注入]\n" instruction += f"{context_prompt_block(context, fields)}\n" instruction += "[系统状态]\n" instruction += "{}\n" instruction += "[对话历史]\n" if session: history_parts = [f"用户: {session_query}" for session_query in session] if last_tts is not None: history_parts.append(f"小爱: {last_tts}") instruction += "\n".join(history_parts) instruction += "\n" instruction += "[当前query]\n" instruction += f"用户: {query}\n" instruction += "[function]\n" return instruction def build_planning_prompt( record: dict[str, Any], *, session_num: int = 5, session_time_minutes: int = 5, context_fields: list[str] | None = None, system_prompt: str = DEFAULT_SYSTEM_PROMPT, ) -> str: """把训练 instruction 包成评测侧使用的 chat template。""" instruction = build_training_instruction( record, session_num=session_num, session_time_minutes=session_time_minutes, context_fields=context_fields, ) return ( f"<|im_start|>system\n{system_prompt}<|im_end|>\n" f"<|im_start|>user\n{instruction}<|im_end|>\n" "<|im_start|>assistant\n" ) def training_jsonl_line( record: dict[str, Any], *, session_num: int, session_time_minutes: int, context_fields: list[str], system_prompt: str, ) -> str: payload = { "system": system_prompt, "instruction": build_training_instruction( record, session_num=session_num, session_time_minutes=session_time_minutes, context_fields=context_fields, ), "output": combined_function_label(record), } return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) def training_history( prev_session: list[Any], *, current_ts: int | None, session_num: int, session_time_minutes: int, ) -> tuple[list[str], str | None]: if current_ts is None: return [], None session_time_ms = session_time_minutes * 60 * 1000 valid_items: list[tuple[int, str, str]] = [] for item in prev_session: if not isinstance(item, dict): continue ts = optional_int_for_export(item.get("timestamp")) query = str(item.get("query") or "") tts = str(item.get("tts") or "") if ts is not None and ts > 0 and query: valid_items.append((ts, query, tts)) valid_items.sort(key=lambda item: item[0]) session: list[str] = [] last_tts: str | None = None last_ts = current_ts count = 0 for ts, query, tts in reversed(valid_items): if last_ts - ts <= session_time_ms and last_ts >= ts: session.append(query) if count == 0: last_tts = tts last_ts = ts count += 1 if count >= session_num: break else: break session.reverse() return session, last_tts def context_prompt_block(context: dict[str, Any], fields: list[str]) -> str: lines = ["{"] for index, field in enumerate(fields): comma = "," if index < len(fields) - 1 else "" value = str(context.get(field, "")) lines.append(f'"{field}": {json.dumps(value, ensure_ascii=False)}{comma}') lines.append("}") return "\n".join(lines) def normalize_context_fields(context_fields: list[str] | None) -> list[str]: if context_fields is not None and not isinstance(context_fields, list): raise ProductDataError("context_fields must be an array of strings") fields = context_fields or list(DEFAULT_CONTEXT_FIELDS) normalized = [field.strip() for field in fields if isinstance(field, str) and field.strip()] return normalized or list(DEFAULT_CONTEXT_FIELDS) def optional_int_for_export(value: Any) -> int | None: if isinstance(value, bool) or value is None or value == "": return None try: return int(value) except (TypeError, ValueError): return None def category_label_from_target(target: str) -> str: target = strip_complex_prefix(target) agent_match = re.match(r'''^Agent\s*\(\s*tag\s*=\s*["']([^"']+)["']\s*\)$''', target.strip()) if agent_match: return agent_match.group(1) function_names = re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", target) if function_names: return function_names[-1] return target def dataset_table_columns() -> list[str]: return [ "request_id", "timestamp", "query", "prev_session", "context", "label", "是否迁移Function", "function", ] def dataset_record_table_row(record: dict[str, Any]) -> dict[str, str]: source = record.get("source") if isinstance(record.get("source"), dict) else {} turn = record.get("turn") if isinstance(record.get("turn"), dict) else {} label = record.get("label") if isinstance(record.get("label"), dict) else {} context = record.get("context") if isinstance(record.get("context"), dict) else {} prev_session = record.get("prev_session") if not isinstance(prev_session, list): prev_session = [] return { "request_id": str(source.get("request_id") or ""), "timestamp": str(source.get("timestamp") or turn.get("timestamp") or ""), "query": str(turn.get("query") or ""), "prev_session": json.dumps(table_prev_session(prev_session), ensure_ascii=False, separators=(",", ":")), "context": json.dumps(context, ensure_ascii=False, separators=(",", ":")), "label": str(label.get("dataset_label") or ""), "是否迁移Function": "", "function": combined_function_label(record), } def table_prev_session(prev_session: list[Any]) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for item in prev_session[-10:]: if not isinstance(item, dict): continue rows.append( { "query": str(item.get("query") or ""), "tts": str(item.get("tts") or ""), "timestamp": str(item.get("timestamp") or ""), } ) return rows def default_output_path(payload: dict[str, Any]) -> str: output_format = str(payload.get("output_format") or "jsonl") filename = "records.json" if output_format == "json" else "records.jsonl" if isinstance(payload.get("output_path"), str) and payload["output_path"].strip(): return str(resolve_portable_path(payload["output_path"].strip())) if isinstance(payload.get("output_dir"), str) and payload["output_dir"].strip(): return str(resolve_portable_path(payload["output_dir"].strip()) / filename) return str(default_output_root() / filename) def default_table_output_path(payload: dict[str, Any]) -> str: if isinstance(payload.get("output_path"), str) and payload["output_path"].strip(): return str(resolve_portable_path(payload["output_path"].strip())) if isinstance(payload.get("output_dir"), str) and payload["output_dir"].strip(): return str(resolve_portable_path(payload["output_dir"].strip()) / "records.csv") if isinstance(payload.get("records_path"), str) and payload["records_path"].strip(): return str(resolve_portable_path(payload["records_path"].strip()).with_name("records.csv")) return str(default_output_root() / "records.csv") def default_training_output_path(payload: dict[str, Any]) -> str: if isinstance(payload.get("output_path"), str) and payload["output_path"].strip(): return str(resolve_portable_path(payload["output_path"].strip())) if isinstance(payload.get("output_dir"), str) and payload["output_dir"].strip(): return str(resolve_portable_path(payload["output_dir"].strip()) / "training.jsonl") if isinstance(payload.get("records_path"), str) and payload["records_path"].strip(): return str(resolve_portable_path(payload["records_path"].strip()).with_name("training.jsonl")) return str(default_output_root() / "training.jsonl") def default_planning_eval_output_path(payload: dict[str, Any]) -> str: if isinstance(payload.get("output_path"), str) and payload["output_path"].strip(): return str(resolve_portable_path(payload["output_path"].strip())) if isinstance(payload.get("output_dir"), str) and payload["output_dir"].strip(): return str(resolve_portable_path(payload["output_dir"].strip()) / "eval_planning.csv") if isinstance(payload.get("records_path"), str) and payload["records_path"].strip(): return str(resolve_portable_path(payload["records_path"].strip()).with_name("eval_planning.csv")) return str(default_output_root() / "eval_planning.csv") def default_output_root() -> Path: """返回 portable 脚本默认输出目录。 在 ZK Data Agent 中,python_exec 的 cwd 是当前会话 scratchpad; 通过 PYTHON_EXEC_SCRATCHPAD 可以定位同级 output 目录。离开平台独立 运行时,保持原来的相对 output/ 行为。 """ scratchpad = os.environ.get("PYTHON_EXEC_SCRATCHPAD") if scratchpad: return Path(scratchpad).expanduser().parent / "output" return Path("output") def resolve_portable_path(path: str) -> Path: """解析 portable 脚本路径,并兼容平台逻辑路径。 平台内运行时,把 output/、scratchpad/、input/ 映射到当前会话目录; 其他相对路径仍按当前 cwd 解析,便于普通 Python 环境复用。 """ raw_path = Path(path).expanduser() if raw_path.is_absolute(): return raw_path scratchpad = os.environ.get("PYTHON_EXEC_SCRATCHPAD") if not scratchpad: return raw_path session_root = Path(scratchpad).expanduser().parent parts = raw_path.parts if not parts: return raw_path head, *tail = parts tail_path = Path(*tail) if tail else Path() if head in {"output", "outputs"}: return session_root / "output" / tail_path if head in {"scratchpad", "scratch"}: return Path(scratchpad).expanduser() / tail_path if head in {"input", "inputs"}: return session_root / "input" / tail_path return raw_path def normalize_target_expression(target: str) -> str: """把可能带 complex 前缀的标签表达式收敛为纯 target。""" return canonical_target(strip_complex_prefix(target)) def strip_complex_prefix(target: str) -> str: stripped_target, _complex_value = split_complex_prefixed_target(target) return stripped_target def split_complex_prefixed_target(target: str) -> tuple[str, bool | None]: lines = target.strip().splitlines() if not lines: return "", None first_line = lines[0].strip() match = re.fullmatch(r"complex\s*=\s*(.+)", first_line, flags=re.I) if not match: return target.strip(), None complex_value = parse_complex(match.group(1), default=False) return "\n".join(lines[1:]).strip(), complex_value def parse_complex(value: Any, *, default: bool) -> bool: if value is None or value == "": return default if isinstance(value, bool): return value text = str(value).strip().lower() if text in {"true", "1", "yes", "y", "是", "复杂", "complex"}: return True if text in {"false", "0", "no", "n", "否", "不复杂", "简单", "simple"}: return False raise ProductDataError("complex must be a boolean value such as true/false") def complex_literal(value: bool) -> str: return "true" if value else "false" def eval_complex_literal(value: bool) -> str: return "TRUE" if value else "FALSE" def record_target(record: dict[str, Any]) -> str: label = record.get("label") if isinstance(record.get("label"), dict) else {} return normalize_target_expression(str(label.get("target") or "")) def record_complex(record: dict[str, Any], *, default: bool = False) -> bool: dimensions = record.get("dimensions") if isinstance(dimensions, dict) and "complex" in dimensions: return parse_complex(dimensions.get("complex"), default=default) label = record.get("label") if isinstance(record.get("label"), dict) else {} if "complex" in label: return parse_complex(label.get("complex"), default=default) _target, prefixed_complex = split_complex_prefixed_target(str(label.get("target") or "")) if prefixed_complex is not None: return prefixed_complex return default def combined_function_label(record: dict[str, Any], *, default_complex: bool = False) -> str: target = record_target(record) return f"complex={complex_literal(record_complex(record, default=default_complex))}\n{target}" def target_type(target: str) -> str: target = strip_complex_prefix(target) if re.match(r"^Agent\s*\(\s*tag\s*=", target): return "agent" if target: return "function" return "unknown" def canonical_target(target: str) -> str: target = strip_complex_prefix(target) agent_match = re.match(r'''^Agent\s*\(\s*tag\s*=\s*["']([^"']+)["']\s*\)$''', target) if agent_match: return f'Agent(tag="{agent_match.group(1)}")' return target def record_id(*, source_type: str, batch_id: str, request_id: str, index: int) -> str: if source_type == "generated": return f"gen_{safe_token(batch_id)}_{index:06d}" if source_type == "online": return f"online_{safe_token(request_id)}_{index:06d}" return f"{safe_token(source_type)}_{safe_token(batch_id)}_{index:06d}" def safe_token(value: str) -> str: token = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip()) return token.strip("_") or "unknown" def optional_int(value: Any) -> int | None: if value is None or value == "": return None if isinstance(value, bool): raise ProductDataError("timestamp must be an integer") try: return int(value) except (TypeError, ValueError) as exc: raise ProductDataError("timestamp must be an integer") from exc def non_empty_str(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) def int_like(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) def issue(path: str, message: str) -> dict[str, str]: return {"path": path, "message": message}