Make product-data skill portable
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# product-data
|
||||||
|
|
||||||
|
`product-data` 是产品/标签定义到标准数据记录的可迁移 skill 包。
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
|
||||||
|
- `SKILL.md` 描述流程、review 门禁和 Agent 使用方式。
|
||||||
|
- `knowledge/` 保存数据格式、生成协议和可维护知识。
|
||||||
|
- `scripts/` 保存可独立执行的脚本能力。
|
||||||
|
- `tools.yaml` 描述脚本如何被平台注册成工具;没有注册能力的 Agent 也可以直接调用脚本。
|
||||||
|
|
||||||
|
## 可迁移工具约定
|
||||||
|
|
||||||
|
脚本能力是本体,平台注册是适配层。
|
||||||
|
|
||||||
|
每个脚本都支持两种输入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python skills/product-data/scripts/<tool>.py --input input.json
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat input.json | python skills/product-data/scripts/<tool>.py
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本统一输出 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败时输出:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok":false,"error":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前脚本
|
||||||
|
|
||||||
|
| 脚本 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `scripts/normalize_dataset_draft.py` | 把 dataset draft text v1 转成 canonical records |
|
||||||
|
| `scripts/validate_dataset_records.py` | 校验 canonical records |
|
||||||
|
| `scripts/export_dataset_records.py` | 导出紧凑 JSONL/JSON |
|
||||||
|
|
||||||
|
## 平台内优先级
|
||||||
|
|
||||||
|
在 ZK Data Agent 里,仍优先使用已注册的 `data_agent_*` 工具,因为它们带有平台级 review 状态门禁和 session 输出路由。
|
||||||
|
|
||||||
|
当运行环境没有这些工具,或其他 Agent 迁移使用本 skill 时,再使用 `scripts/` 里的 portable scripts。
|
||||||
@@ -3,13 +3,46 @@ name: product-data
|
|||||||
description: 从产品/标签定义、手写边界规则或示例 query 中提取标签边界,并生成可 review 的数据计划、dataset draft text 和 canonical metadata records。
|
description: 从产品/标签定义、手写边界规则或示例 query 中提取标签边界,并生成可 review 的数据计划、dataset draft text 和 canonical metadata records。
|
||||||
when_to_use: 当用户提供产品定义、标签规则、路由边界文档、示例 query、手写标签边界,并希望生成训练/评测/专项数据时使用。
|
when_to_use: 当用户提供产品定义、标签规则、路由边界文档、示例 query、手写标签边界,并希望生成训练/评测/专项数据时使用。
|
||||||
aliases: definition-data, label-data
|
aliases: definition-data, label-data
|
||||||
allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question, data_agent_load_input_sources, data_agent_render_source_context, data_agent_extract_case_evidence, data_agent_prepare_generation_goal, data_agent_confirm_generation_goal, data_agent_prepare_generation_plan, data_agent_show_generation_plan, data_agent_update_generation_plan, data_agent_confirm_generation_plan, data_agent_normalize_dataset_draft, data_agent_validate_dataset_records, data_agent_export_dataset_records
|
allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question, python_exec, data_agent_load_input_sources, data_agent_render_source_context, data_agent_extract_case_evidence, data_agent_prepare_generation_goal, data_agent_confirm_generation_goal, data_agent_prepare_generation_plan, data_agent_show_generation_plan, data_agent_update_generation_plan, data_agent_confirm_generation_plan, data_agent_normalize_dataset_draft, data_agent_validate_dataset_records, data_agent_export_dataset_records
|
||||||
---
|
---
|
||||||
|
|
||||||
使用这个 skill 作为“产品/标签定义/手写规则/示例 query -> 输入文本化 -> generation goal 草案 -> 人工 review -> 生成计划 review -> dataset draft text -> canonical metadata records”的统一入口。
|
使用这个 skill 作为“产品/标签定义/手写规则/示例 query -> 输入文本化 -> generation goal 草案 -> 人工 review -> 生成计划 review -> dataset draft text -> canonical metadata records”的统一入口。
|
||||||
|
|
||||||
本 skill 内置数据记录生成协议。其他数据开发 skill 如果需要生成或整理标准数据,可以复用这里的“交互门禁、dataset draft text v1、canonical record v1”规则。
|
本 skill 内置数据记录生成协议。其他数据开发 skill 如果需要生成或整理标准数据,可以复用这里的“交互门禁、dataset draft text v1、canonical record v1”规则。
|
||||||
|
|
||||||
|
## 能力组织
|
||||||
|
|
||||||
|
本 skill 按 portable skill contract 组织,能力本体在 skill 目录内:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/product-data/
|
||||||
|
SKILL.md
|
||||||
|
tools.yaml
|
||||||
|
requirements.txt
|
||||||
|
knowledge/
|
||||||
|
dataset_draft_v1.md
|
||||||
|
canonical_record_v1.md
|
||||||
|
portable_skill_contract.md
|
||||||
|
schemas/
|
||||||
|
scripts/
|
||||||
|
normalize_dataset_draft.py
|
||||||
|
validate_dataset_records.py
|
||||||
|
export_dataset_records.py
|
||||||
|
```
|
||||||
|
|
||||||
|
在 ZK Data Agent 平台里,优先使用已注册的 `data_agent_*` 工具,因为它们带有平台级 review 状态、确认门禁和会话 output 路由。
|
||||||
|
|
||||||
|
迁移到其他 Agent 或没有平台注册能力时,可以直接执行 `scripts/` 下的 portable scripts。脚本支持 `--input input.json` 或 stdin JSON,stdout 只输出一个 JSON 对象。`tools.yaml` 描述了这些脚本如何被平台注册为工具;如果平台不支持 `tools.yaml`,不影响脚本独立使用。
|
||||||
|
|
||||||
|
脚本能力和平台注册的关系是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
脚本能力是本体
|
||||||
|
平台注册是适配层
|
||||||
|
```
|
||||||
|
|
||||||
|
portable scripts 不包含人类 review 状态机。生成式数据仍必须先由 Agent 按本 skill 的 review 门禁获得用户确认,再调用脚本做格式转换、校验和导出。
|
||||||
|
|
||||||
## 输入假设
|
## 输入假设
|
||||||
|
|
||||||
用户可能会提供:
|
用户可能会提供:
|
||||||
@@ -272,7 +305,7 @@ canonical records 落盘必须使用 `data_agent_export_dataset_records`,默
|
|||||||
|
|
||||||
现在已经有代码级 goal review 门禁:不要手写 goal 后直接进入 plan,必须先调用 `data_agent_prepare_generation_goal`,并在用户确认后用 `data_agent_confirm_generation_goal` 取得 `confirmed_goal_id`。
|
现在已经有代码级 goal review 门禁:不要手写 goal 后直接进入 plan,必须先调用 `data_agent_prepare_generation_goal`,并在用户确认后用 `data_agent_confirm_generation_goal` 取得 `confirmed_goal_id`。
|
||||||
|
|
||||||
## 计划中的专用工具
|
## 输入文本化平台工具
|
||||||
|
|
||||||
当前前链路只保留三个工具。不要再假设有 `data_agent_load_definition_source`、`data_agent_extract_target_definitions` 这类更细工具。
|
当前前链路只保留三个工具。不要再假设有 `data_agent_load_definition_source`、`data_agent_extract_target_definitions` 这类更细工具。
|
||||||
|
|
||||||
@@ -288,6 +321,72 @@ canonical records 落盘必须使用 `data_agent_export_dataset_records`,默
|
|||||||
|
|
||||||
从 badcase、评测表、走查表里识别 query、上下文、预期标签、模型预测、类型和备注。字段不明确时,它会返回 `required_questions`,此时必须向用户确认字段含义。
|
从 badcase、评测表、走查表里识别 query、上下文、预期标签、模型预测、类型和备注。字段不明确时,它会返回 `required_questions`,此时必须向用户确认字段含义。
|
||||||
|
|
||||||
|
## Portable Scripts
|
||||||
|
|
||||||
|
当平台注册工具不可用时,使用 `python_exec` 执行这些脚本。不要用 `bash` 执行 Python。
|
||||||
|
|
||||||
|
### `product_data_normalize_dataset_draft`
|
||||||
|
|
||||||
|
脚本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/product-data/scripts/normalize_dataset_draft.py
|
||||||
|
```
|
||||||
|
|
||||||
|
输入示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"draft_text": "# dataset_label: 地图餐饮边界\n\n### case: 找附近美食\n用户: 附近有什么好吃的\ntarget: Agent(tag=\"餐饮服务\")",
|
||||||
|
"batch_id": "aabbccdd",
|
||||||
|
"source_type": "generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用途:把 dataset draft text v1 转成 canonical records。
|
||||||
|
|
||||||
|
### `product_data_validate_dataset_records`
|
||||||
|
|
||||||
|
脚本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/product-data/scripts/validate_dataset_records.py
|
||||||
|
```
|
||||||
|
|
||||||
|
输入支持:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"records": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"records_path": "output/records.jsonl"}
|
||||||
|
```
|
||||||
|
|
||||||
|
用途:校验 canonical records。
|
||||||
|
|
||||||
|
### `product_data_export_dataset_records`
|
||||||
|
|
||||||
|
脚本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/product-data/scripts/export_dataset_records.py
|
||||||
|
```
|
||||||
|
|
||||||
|
输入支持:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"records": [],
|
||||||
|
"output_dir": "output",
|
||||||
|
"output_format": "jsonl"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用途:导出紧凑 JSONL/JSON。默认文件名是 `records.jsonl` 或 `records.json`。
|
||||||
|
|
||||||
## 当前可用工具
|
## 当前可用工具
|
||||||
|
|
||||||
- `read_file`:读取用户提供的产品定义、标签定义、样例 query 文件。
|
- `read_file`:读取用户提供的产品定义、标签定义、样例 query 文件。
|
||||||
@@ -296,6 +395,7 @@ canonical records 落盘必须使用 `data_agent_export_dataset_records`,默
|
|||||||
- `grep_search`:在项目中搜索已有标签定义、历史数据样例或相关文档。
|
- `grep_search`:在项目中搜索已有标签定义、历史数据样例或相关文档。
|
||||||
- `glob_search`:按路径模式查找定义文件、样例文件或历史产物。
|
- `glob_search`:按路径模式查找定义文件、样例文件或历史产物。
|
||||||
- `ask_user_question`:需要用户明确选择或补充关键信息时使用;如果不可用,就用普通回复提问并停止。
|
- `ask_user_question`:需要用户明确选择或补充关键信息时使用;如果不可用,就用普通回复提问并停止。
|
||||||
|
- `python_exec`:仅在已注册 `data_agent_*` 工具不可用、或需要迁移验证 portable scripts 时使用;优先执行 `skills/product-data/scripts/` 下脚本。
|
||||||
- `data_agent_load_input_sources`:读取用户给的目录或文件,把 docx/xlsx/pdf 等输入统一抽成段落、表格和 source refs。
|
- `data_agent_load_input_sources`:读取用户给的目录或文件,把 docx/xlsx/pdf 等输入统一抽成段落、表格和 source refs。
|
||||||
- `data_agent_render_source_context`:把结构化输入渲染成大模型可读文本,支持 `max_chars`、表格行数和关键词过滤,用于后续模型语义抽取。
|
- `data_agent_render_source_context`:把结构化输入渲染成大模型可读文本,支持 `max_chars`、表格行数和关键词过滤,用于后续模型语义抽取。
|
||||||
- `data_agent_extract_case_evidence`:从 badcase/评测/走查表中抽取 query、预期标签、模型预测、上下文和备注;字段歧义会返回需要确认的问题。
|
- `data_agent_extract_case_evidence`:从 badcase/评测/走查表中抽取 query、预期标签、模型预测、上下文和备注;字段歧义会返回需要确认的问题。
|
||||||
@@ -315,5 +415,6 @@ canonical records 落盘必须使用 `data_agent_export_dataset_records`,默
|
|||||||
- 如果 `ask_user_question` 不可用,使用普通回复向用户提问并停止,不要自己替用户确认。
|
- 如果 `ask_user_question` 不可用,使用普通回复向用户提问并停止,不要自己替用户确认。
|
||||||
- canonical records 通过校验前,不要生成最终导出格式。
|
- canonical records 通过校验前,不要生成最终导出格式。
|
||||||
- canonical records 需要落盘时,必须用 `data_agent_export_dataset_records`;不要自己拼接 JSON/JSONL;不要创建数据集子目录或自定义 records 文件名。
|
- canonical records 需要落盘时,必须用 `data_agent_export_dataset_records`;不要自己拼接 JSON/JSONL;不要创建数据集子目录或自定义 records 文件名。
|
||||||
|
- 如果使用 portable scripts,它们只负责格式转换、校验和导出,不替代 `generation_goal` / `generation_plan` 的用户 review。
|
||||||
- 除非用户明确要求,否则不要把“修改标签定义”和“生成数据”混在一起做。
|
- 除非用户明确要求,否则不要把“修改标签定义”和“生成数据”混在一起做。
|
||||||
- 不要因为用户说“生成一些数据”就跳过边界总结和 generation plan review。
|
- 不要因为用户说“生成一些数据”就跳过边界总结和 generation plan review。
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Canonical Record v1
|
||||||
|
|
||||||
|
canonical record 是产品数据生成链路的中间元数据格式。它不是最终训练、评测或展示格式。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"record_id": "gen_aabbccdd_000001",
|
||||||
|
"source": {
|
||||||
|
"type": "generated",
|
||||||
|
"request_id": "aabbccdd",
|
||||||
|
"timestamp": 1755567930500
|
||||||
|
},
|
||||||
|
"turn": {
|
||||||
|
"query": "本轮 query",
|
||||||
|
"timestamp": 1755567930500
|
||||||
|
},
|
||||||
|
"prev_session": [
|
||||||
|
{
|
||||||
|
"query": "前一轮 query",
|
||||||
|
"tts": "前一轮 tts",
|
||||||
|
"timestamp": 1755567870500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"context": {},
|
||||||
|
"label": {
|
||||||
|
"dataset_label": "数据集或专题名称",
|
||||||
|
"target": "Agent(tag=\"xxx\")",
|
||||||
|
"target_type": "agent"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"case_name": "case名称",
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 校验重点
|
||||||
|
|
||||||
|
- `turn.query` 必须有值。
|
||||||
|
- 当前轮 `turn` 不包含 `tts`。
|
||||||
|
- `prev_session` 最多 10 轮,且按时间从早到晚。
|
||||||
|
- 相邻轮时间间隔超过 5 分钟时给 warning。
|
||||||
|
- `label.target` 必须有值。
|
||||||
|
- `target_type` 只能是 `agent`、`function` 或 `unknown`。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Dataset Draft Text v1
|
||||||
|
|
||||||
|
模型生成数据样本时,先写便于人工 review 的 draft text,不直接写 canonical JSON。
|
||||||
|
|
||||||
|
## 格式
|
||||||
|
|
||||||
|
```text
|
||||||
|
# dataset_label: 数据集或专题名称
|
||||||
|
|
||||||
|
### case: case名称
|
||||||
|
用户: 本轮 query
|
||||||
|
target: Agent(tag="xxx")
|
||||||
|
notes: 可选,说明覆盖的问题或边界
|
||||||
|
|
||||||
|
### case: 多轮 case 名称
|
||||||
|
用户: 前一轮 query
|
||||||
|
小爱: 前一轮 tts
|
||||||
|
用户: 本轮 query
|
||||||
|
target: Agent(tag="xxx")
|
||||||
|
notes: 可选,说明覆盖的问题或边界
|
||||||
|
```
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
|
||||||
|
- 每条数据用一个 `### case:` 开始。
|
||||||
|
- `用户:` 表示用户 query。
|
||||||
|
- `小爱:` 表示小爱回复 tts。
|
||||||
|
- 最后一个 `用户:` 是本轮 query。
|
||||||
|
- `target:` 是本轮 query 对应的监督标签,必须存在。
|
||||||
|
- 单轮数据只需要写一行 `用户:`,然后写 `target:`。
|
||||||
|
- 多轮数据需要按照时间顺序写多组 `用户:` / `小爱:`。
|
||||||
|
- 多轮数据的最后一轮只写 `用户:` 和 `target:`,不要写最后一轮 `小爱:`。
|
||||||
|
- 如果 `target` 无法确定,不要编造,必须向用户确认。
|
||||||
|
- 不要手写 `record_id`、`request_id`、`timestamp`、`context`。
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Portable Skill Contract
|
||||||
|
|
||||||
|
本约定用于让 skill 能在不同 Agent 平台之间迁移。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
能力归 skill,运行适配归平台。
|
||||||
|
|
||||||
|
- 脚本必须能独立执行。
|
||||||
|
- 平台工具注册只做薄封装,不承载业务逻辑。
|
||||||
|
- 没有工具注册能力的 Agent,可以阅读 `SKILL.md` 并直接执行脚本。
|
||||||
|
- 脚本输入输出都使用 JSON,避免自然语言解析。
|
||||||
|
|
||||||
|
## 脚本输入
|
||||||
|
|
||||||
|
脚本必须支持:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/<name>.py --input input.json
|
||||||
|
```
|
||||||
|
|
||||||
|
也必须支持从 stdin 读取 JSON:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat input.json | python scripts/<name>.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 脚本输出
|
||||||
|
|
||||||
|
stdout 只输出一个 JSON 对象。
|
||||||
|
|
||||||
|
成功:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"result": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "错误原因"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输出路径
|
||||||
|
|
||||||
|
默认写入当前会话 `output/`,或调用方明确传入的输出目录。
|
||||||
|
|
||||||
|
脚本不得写项目源码目录、随机临时目录或用户未确认的外部路径。
|
||||||
|
|
||||||
|
## 平台注册
|
||||||
|
|
||||||
|
`tools.yaml` 是平台适配层:
|
||||||
|
|
||||||
|
- `name` 是平台注册后的工具名。
|
||||||
|
- `script` 指向真正执行能力。
|
||||||
|
- `input_schema` / `output_schema` 供平台生成工具 schema。
|
||||||
|
- `fallback` 告诉无注册环境如何手动调用。
|
||||||
|
|
||||||
|
如果平台不支持 `tools.yaml`,不影响脚本独立使用。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# 当前 portable scripts 只依赖 Python 标准库。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"records": {"type": "array"},
|
||||||
|
"records_path": {"type": "string"},
|
||||||
|
"output_path": {"type": "string"},
|
||||||
|
"output_dir": {"type": "string"},
|
||||||
|
"output_format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["jsonl", "json"],
|
||||||
|
"default": "jsonl"
|
||||||
|
},
|
||||||
|
"require_validation_ok": {"type": "boolean", "default": true},
|
||||||
|
"overwrite": {"type": "boolean", "default": true}
|
||||||
|
},
|
||||||
|
"anyOf": [
|
||||||
|
{"required": ["records"]},
|
||||||
|
{"required": ["records_path"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {"type": "boolean"},
|
||||||
|
"output_path": {"type": "string"},
|
||||||
|
"output_format": {"type": "string"},
|
||||||
|
"record_count": {"type": "integer"},
|
||||||
|
"bytes_written": {"type": "integer"},
|
||||||
|
"validation": {"type": "object"},
|
||||||
|
"error": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["ok"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"draft_text": {"type": "string"},
|
||||||
|
"draft_path": {"type": "string"},
|
||||||
|
"batch_id": {"type": "string", "default": "aabbccdd"},
|
||||||
|
"source_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["generated", "online", "manual", "mixed"],
|
||||||
|
"default": "generated"
|
||||||
|
},
|
||||||
|
"base_timestamp": {"type": "integer"},
|
||||||
|
"timestamp_step_ms": {"type": "integer", "minimum": 1, "default": 60000},
|
||||||
|
"default_request_id": {"type": "string", "default": "aabbccdd"}
|
||||||
|
},
|
||||||
|
"anyOf": [
|
||||||
|
{"required": ["draft_text"]},
|
||||||
|
{"required": ["draft_path"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {"type": "boolean"},
|
||||||
|
"records": {"type": "array"},
|
||||||
|
"warnings": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"error": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["ok"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"records": {"type": "array"},
|
||||||
|
"records_path": {"type": "string"}
|
||||||
|
},
|
||||||
|
"anyOf": [
|
||||||
|
{"required": ["records"]},
|
||||||
|
{"required": ["records_path"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {"type": "boolean"},
|
||||||
|
"error_count": {"type": "integer"},
|
||||||
|
"warning_count": {"type": "integer"},
|
||||||
|
"errors": {"type": "array"},
|
||||||
|
"warnings": {"type": "array"},
|
||||||
|
"error": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["ok"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from product_data_portable import (
|
||||||
|
default_output_path,
|
||||||
|
emit_error,
|
||||||
|
emit_success,
|
||||||
|
export_dataset_records,
|
||||||
|
load_json_payload,
|
||||||
|
load_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
payload = load_json_payload()
|
||||||
|
records = load_records(payload)
|
||||||
|
output_format = str(payload.get("output_format") or "jsonl")
|
||||||
|
result = export_dataset_records(
|
||||||
|
records,
|
||||||
|
output_path=default_output_path(payload),
|
||||||
|
output_format=output_format,
|
||||||
|
require_validation_ok=bool(payload.get("require_validation_ok", True)),
|
||||||
|
overwrite=bool(payload.get("overwrite", True)),
|
||||||
|
)
|
||||||
|
emit_success(result)
|
||||||
|
return 0
|
||||||
|
except Exception as exc: # noqa: BLE001 - CLI 需要把错误稳定转成 JSON
|
||||||
|
emit_error(exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from product_data_portable import (
|
||||||
|
DEFAULT_REQUEST_ID,
|
||||||
|
DEFAULT_TIMESTAMP_STEP_MS,
|
||||||
|
emit_error,
|
||||||
|
emit_success,
|
||||||
|
load_json_payload,
|
||||||
|
normalize_dataset_draft,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
payload = load_json_payload()
|
||||||
|
draft_text = str(payload.get("draft_text") or "")
|
||||||
|
if not draft_text and payload.get("draft_path"):
|
||||||
|
draft_text = Path(str(payload["draft_path"])).expanduser().read_text(encoding="utf-8")
|
||||||
|
result = normalize_dataset_draft(
|
||||||
|
draft_text,
|
||||||
|
batch_id=str(payload.get("batch_id") or DEFAULT_REQUEST_ID),
|
||||||
|
source_type=str(payload.get("source_type") or "generated"),
|
||||||
|
base_timestamp=payload.get("base_timestamp"),
|
||||||
|
timestamp_step_ms=int(payload.get("timestamp_step_ms") or DEFAULT_TIMESTAMP_STEP_MS),
|
||||||
|
default_request_id=str(payload.get("default_request_id") or DEFAULT_REQUEST_ID),
|
||||||
|
)
|
||||||
|
emit_success(result)
|
||||||
|
return 0
|
||||||
|
except Exception as exc: # noqa: BLE001 - CLI 需要把错误稳定转成 JSON
|
||||||
|
emit_error(exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""product-data 可迁移脚本的共享逻辑。
|
||||||
|
|
||||||
|
这里不依赖 ZK Data Agent 的运行时,便于把整个 skill 复制到其他 Agent
|
||||||
|
或普通 Python 环境中使用。平台注册工具时,也应该只薄封装这些脚本。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_REQUEST_ID = "aabbccdd"
|
||||||
|
DEFAULT_TIMESTAMP_STEP_MS = 60_000
|
||||||
|
|
||||||
|
|
||||||
|
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 = Path(path).expanduser()
|
||||||
|
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 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|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")
|
||||||
|
|
||||||
|
target = str(case.get("target") or "").strip()
|
||||||
|
if not target:
|
||||||
|
raise ProductDataError(f"case {index} target is required")
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
"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"))
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> 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()
|
||||||
|
if path.exists() and not overwrite:
|
||||||
|
raise ProductDataError(f"output_path already exists: {output_path}")
|
||||||
|
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")
|
||||||
|
return {
|
||||||
|
"output_path": str(path),
|
||||||
|
"output_format": output_format,
|
||||||
|
"record_count": len(records),
|
||||||
|
"bytes_written": len(content.encode("utf-8")),
|
||||||
|
"validation": validation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 payload["output_path"].strip()
|
||||||
|
if isinstance(payload.get("output_dir"), str) and payload["output_dir"].strip():
|
||||||
|
return str(Path(payload["output_dir"]).expanduser() / filename)
|
||||||
|
return str(Path("output") / filename)
|
||||||
|
|
||||||
|
|
||||||
|
def target_type(target: str) -> str:
|
||||||
|
if re.match(r"^Agent\s*\(\s*tag\s*=", target):
|
||||||
|
return "agent"
|
||||||
|
if target:
|
||||||
|
return "function"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from product_data_portable import emit_error, emit_success, load_json_payload, load_records, validate_dataset_records
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
payload = load_json_payload()
|
||||||
|
records = load_records(payload)
|
||||||
|
emit_success(validate_dataset_records(records))
|
||||||
|
return 0
|
||||||
|
except Exception as exc: # noqa: BLE001 - CLI 需要把错误稳定转成 JSON
|
||||||
|
emit_error(exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
version: 1
|
||||||
|
skill: product-data
|
||||||
|
description: Product-data portable tool manifest. Scripts are the source of capability; platform tool registration is only an adapter.
|
||||||
|
runtime:
|
||||||
|
language: python
|
||||||
|
min_python: "3.10"
|
||||||
|
dependencies: []
|
||||||
|
tools:
|
||||||
|
- name: product_data_normalize_dataset_draft
|
||||||
|
description: Parse dataset draft text v1 into canonical records with generated ids, timestamps, source metadata, context and labels.
|
||||||
|
script: scripts/normalize_dataset_draft.py
|
||||||
|
input_schema: schemas/normalize_dataset_draft.input.schema.json
|
||||||
|
output_schema: schemas/normalize_dataset_draft.output.schema.json
|
||||||
|
fallback:
|
||||||
|
command: python skills/product-data/scripts/normalize_dataset_draft.py --input <input.json>
|
||||||
|
stdin: true
|
||||||
|
- name: product_data_validate_dataset_records
|
||||||
|
description: Validate canonical product-data records for required fields, labels, timestamp order and prompt stitching constraints.
|
||||||
|
script: scripts/validate_dataset_records.py
|
||||||
|
input_schema: schemas/validate_dataset_records.input.schema.json
|
||||||
|
output_schema: schemas/validate_dataset_records.output.schema.json
|
||||||
|
fallback:
|
||||||
|
command: python skills/product-data/scripts/validate_dataset_records.py --input <input.json>
|
||||||
|
stdin: true
|
||||||
|
- name: product_data_export_dataset_records
|
||||||
|
description: Validate canonical records and write compact JSONL or JSON files without asking the model to hand-write JSON.
|
||||||
|
script: scripts/export_dataset_records.py
|
||||||
|
input_schema: schemas/export_dataset_records.input.schema.json
|
||||||
|
output_schema: schemas/export_dataset_records.output.schema.json
|
||||||
|
fallback:
|
||||||
|
command: python skills/product-data/scripts/export_dataset_records.py --input <input.json>
|
||||||
|
stdin: true
|
||||||
Reference in New Issue
Block a user