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。
|
||||
when_to_use: 当用户提供产品定义、标签规则、路由边界文档、示例 query、手写标签边界,并希望生成训练/评测/专项数据时使用。
|
||||
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 内置数据记录生成协议。其他数据开发 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`。
|
||||
|
||||
## 计划中的专用工具
|
||||
## 输入文本化平台工具
|
||||
|
||||
当前前链路只保留三个工具。不要再假设有 `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`,此时必须向用户确认字段含义。
|
||||
|
||||
## 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 文件。
|
||||
@@ -296,6 +395,7 @@ canonical records 落盘必须使用 `data_agent_export_dataset_records`,默
|
||||
- `grep_search`:在项目中搜索已有标签定义、历史数据样例或相关文档。
|
||||
- `glob_search`:按路径模式查找定义文件、样例文件或历史产物。
|
||||
- `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_render_source_context`:把结构化输入渲染成大模型可读文本,支持 `max_chars`、表格行数和关键词过滤,用于后续模型语义抽取。
|
||||
- `data_agent_extract_case_evidence`:从 badcase/评测/走查表中抽取 query、预期标签、模型预测、上下文和备注;字段歧义会返回需要确认的问题。
|
||||
@@ -315,5 +415,6 @@ canonical records 落盘必须使用 `data_agent_export_dataset_records`,默
|
||||
- 如果 `ask_user_question` 不可用,使用普通回复向用户提问并停止,不要自己替用户确认。
|
||||
- canonical records 通过校验前,不要生成最终导出格式。
|
||||
- canonical records 需要落盘时,必须用 `data_agent_export_dataset_records`;不要自己拼接 JSON/JSONL;不要创建数据集子目录或自定义 records 文件名。
|
||||
- 如果使用 portable scripts,它们只负责格式转换、校验和导出,不替代 `generation_goal` / `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