From 23c2e3b2f78b9836be28f6d275bc0cb223baaf73 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:21:13 +0800 Subject: [PATCH] stripe ros2 schema desc add create-device-skill --- .cursor/skills/create-device-skill/SKILL.md | 327 ++++++++++++++++++ .../scripts/extract_device_actions.py | 200 +++++++++++ .../create-device-skill/scripts/gen_auth.py | 69 ++++ unilabos/registry/devices/liquid_handler.yaml | 80 ----- unilabos/registry/registry.py | 11 +- unilabos/ros/msgs/message_converter.py | 18 +- unilabos/ros/nodes/base_device_node.py | 6 +- unilabos/utils/import_manager.py | 10 +- 8 files changed, 629 insertions(+), 92 deletions(-) create mode 100644 .cursor/skills/create-device-skill/SKILL.md create mode 100644 .cursor/skills/create-device-skill/scripts/extract_device_actions.py create mode 100644 .cursor/skills/create-device-skill/scripts/gen_auth.py diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md new file mode 100644 index 00000000..805519fa --- /dev/null +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -0,0 +1,327 @@ +--- +name: create-device-skill +description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device. +--- + +# 创建设备 Skill 指南 + +本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。 + +## 数据源 + +- **设备注册表**: `unilabos_data/req_device_registry_upload.json` +- **结构**: `{ "resources": [{ "id": "", "class": { "module": "", "action_value_mappings": { ... } } }] }` +- **生成时机**: `unilab` 启动并完成注册表上传后自动生成 +- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为 + +## 创建流程 + +### Step 0 — 收集必备信息(缺一不可,否则询问后终止) + +开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。 + +向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」 + +#### 必备项 ①:ak / sk(认证凭据) + +来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。 + +获取后立即生成 AUTH token: + +```bash +python ./scripts/gen_auth.py +# 或从 config.py 提取 +python ./scripts/gen_auth.py --config +``` + +认证算法:`base64(ak:sk)` → `Authorization: Lab ` + +#### 必备项 ②:--addr(目标环境) + +决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取: + +| `--addr` 值 | BASE URL | +|-------------|----------| +| `test` | `https://uni-lab.test.bohrium.com` | +| `uat` | `https://uni-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://uni-lab.bohrium.com` | +| 其他自定义 URL | 直接使用该 URL | + +#### 必备项 ③:req_device_registry_upload.json(设备注册表) + +数据文件由 `unilab` 启动时自动生成,需要定位它: + +**推断 working_dir**(即 `unilabos_data` 所在目录): + +| 条件 | working_dir 取值 | +|------|------------------| +| 传了 `--working_dir` | `/unilabos_data/`(若子目录已存在则直接用) | +| 仅传了 `--config` | `/unilabos_data/` | +| 都没传 | `<当前工作目录>/unilabos_data/` | + +**按优先级搜索文件**: + +``` +<推断的 working_dir>/unilabos_data/req_device_registry_upload.json +<推断的 working_dir>/req_device_registry_upload.json +/unilabos_data/req_device_registry_upload.json +``` + +也可以直接 Glob 搜索:`**/req_device_registry_upload.json` + +找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。 + +**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。** + +#### 必备项 ④:目标设备 + +用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。 + +如果用户不确定,运行提取脚本列出所有设备供选择: + +```bash +python ./scripts/extract_device_actions.py --registry <找到的文件路径> +``` + +#### 完整示例 + +用户提供: + +``` +--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd +--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b +--addr test +--port 8003 +--disable_browser +``` + +从中提取: +- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."` +- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com` +- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间 +- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi` + +**四项全部就绪后才进入 Step 1。** + +### Step 1 — 列出可用设备 + +运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择: + +```bash +# 自动搜索(默认在 unilabos_data/ 和当前目录查找) +python ./scripts/extract_device_actions.py + +# 指定注册表文件路径 +python ./scripts/extract_device_actions.py --registry +``` + +脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。 + +### Step 2 — 提取 Action Schema + +用户选择设备后,运行提取脚本: + +```bash +python ./scripts/extract_device_actions.py [--registry ] ./skills//actions/ +``` + +脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。 + +每个 action 生成一个 JSON 文件,包含: +- `type` — 作为 API 调用的 `action_type` +- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义) +- `goal` — goal 字段映射(含占位符 `$placeholder`) +- `goal_default` — 默认值 + +### Step 3 — 写 action-index.md + +按模板为每个 action 写条目: + +```markdown +### `` + +<用途描述(一句话)> + +- **Schema**: [`actions/.json`](actions/.json) +- **核心参数**: `param1`, `param2`(从 schema.required 获取) +- **可选参数**: `param3`, `param4` +- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头) +``` + +描述规则: +- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容) +- 从 `schema.required` 区分核心/可选参数 +- 按功能分类(移液、枪头、外设等) +- 标注 `placeholder_keys` 中的字段类型: + - `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点) + - `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device) + - `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点) + - `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找) +- array 类型字段 → `[{id, name, uuid}, ...]` +- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径 + +### Step 4 — 写 SKILL.md + +直接复用 `unilab-device-api` 的 API 模板(10 个 endpoint),修改: +- 设备名称 +- Action 数量 +- 目录列表 +- Session state 中的 `device_name` +- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab `(不要硬编码 `Api` 类型的 key) +- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义 +- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名) + +API 模板结构: + +```markdown +## 设备信息 +- device_id, Python 源码路径, 设备类名 + +## 前置条件(缺一不可) +- ak/sk → AUTH, --addr → BASE URL + +## Session State +- lab_uuid, device_name + +## API Endpoints (10 个) +# 注意: +# - #1 获取 lab 列表需加 ?page=1&page_size=100 +# - #2 创建工作流用 POST /lab/workflow +# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid} + +## Placeholder Slot 填写规则 +- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"} +- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串 +- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串 +- unilabos_class → ClassSlot → "class_name" 字符串 +- 特例:create_resource 的 res_id 允许填不存在的路径 +- 列出本设备所有 Slot 字段、类型及含义 + +## 渐进加载策略 +## 完整工作流 Checklist +``` + +### Step 5 — 验证 + +检查文件完整性: +- [ ] `SKILL.md` 包含 10 个 API endpoint +- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表 +- [ ] `action-index.md` 列出所有 action 并有描述 +- [ ] `actions/` 目录中每个 action 有对应 JSON 文件 +- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段 +- [ ] 描述能让 agent 判断该用哪个 action + +## Action JSON 文件结构 + +```json +{ + "type": "LiquidHandlerTransfer", // → API 的 action_type + "goal": { // goal 字段映射 + "sources": "sources", + "targets": "targets", + "tip_racks": "tip_racks", + "asp_vols": "asp_vols" + }, + "schema": { // ← 直接是 goal 的 schema(已提升) + "type": "object", + "properties": { // 参数定义(即请求中 goal 的字段) + "sources": { "type": "array", "items": { "type": "object" } }, + "targets": { "type": "array", "items": { "type": "object" } }, + "asp_vols": { "type": "array", "items": { "type": "number" } } + }, + "required": [...], + "_unilabos_placeholder_info": { // ← Slot 类型标记 + "sources": "unilabos_resources", + "targets": "unilabos_resources", + "tip_racks": "unilabos_resources" + } + }, + "goal_default": { ... }, // 默认值 + "placeholder_keys": { // ← 汇总所有 Slot 字段 + "sources": "unilabos_resources", // ResourceSlot + "targets": "unilabos_resources", + "tip_racks": "unilabos_resources", + "target_device_id": "unilabos_devices" // DeviceSlot + } +} +``` + +> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。 +> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段。 + +## Placeholder Slot 类型体系 + +`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式: + +| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | +|---------------|-----------|---------|---------| +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name | + +### ResourceSlot(`unilabos_resources`) + +最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等): + +```json +{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"} +``` + +- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}` +- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]` +- `id` 本身是从 parent 计算的路径格式 +- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置) + +> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。 + +### DeviceSlot(`unilabos_devices`) + +填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径: + +``` +"/host_node" +"/bioyond_cell/reaction_station" +``` + +- 只填路径字符串,不需要 `{id, uuid}` 对象 +- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备) + +### NodeSlot(`unilabos_nodes`) + +范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**: + +``` +"/PRCXI/PRCXI_Deck" +``` + +- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`) + +### ClassSlot(`unilabos_class`) + +填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找: + +``` +"container" +``` + +### 通过 API #10 获取资源树 + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` + +注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。 + +## 最终目录结构 + +``` +.// +├── SKILL.md # API 端点 + 渐进加载指引 +├── action-index.md # 动作索引:描述/用途/核心参数 +└── actions/ # 每个 action 的完整 JSON Schema + ├── action1.json + ├── action2.json + └── ... +``` diff --git a/.cursor/skills/create-device-skill/scripts/extract_device_actions.py b/.cursor/skills/create-device-skill/scripts/extract_device_actions.py new file mode 100644 index 00000000..c17f6102 --- /dev/null +++ b/.cursor/skills/create-device-skill/scripts/extract_device_actions.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +从 req_device_registry_upload.json 中提取指定设备的 action schema。 + +用法: + # 列出所有设备及 action 数量(自动搜索注册表文件) + python extract_device_actions.py + + # 指定注册表文件路径 + python extract_device_actions.py --registry + + # 提取指定设备的 action 到目录 + python extract_device_actions.py + python extract_device_actions.py --registry + +示例: + python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json + python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/ +""" +import json +import os +import sys +from datetime import datetime + +REGISTRY_FILENAME = "req_device_registry_upload.json" + +def find_registry(explicit_path=None): + """ + 查找 req_device_registry_upload.json 文件。 + + 搜索优先级: + 1. 用户通过 --registry 显式指定的路径 + 2. /unilabos_data/req_device_registry_upload.json + 3. /req_device_registry_upload.json + 4. /../../.. (workspace根) 下的 unilabos_data/ + 5. 向上逐级搜索父目录(最多 5 层) + """ + if explicit_path: + if os.path.isfile(explicit_path): + return explicit_path + if os.path.isdir(explicit_path): + fp = os.path.join(explicit_path, REGISTRY_FILENAME) + if os.path.isfile(fp): + return fp + print(f"警告: 指定的路径不存在: {explicit_path}") + return None + + candidates = [ + os.path.join("unilabos_data", REGISTRY_FILENAME), + REGISTRY_FILENAME, + ] + + for c in candidates: + if os.path.isfile(c): + return c + + script_dir = os.path.dirname(os.path.abspath(__file__)) + workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", "..")) + for c in candidates: + path = os.path.join(workspace_root, c) + if os.path.isfile(path): + return path + + cwd = os.getcwd() + for _ in range(5): + parent = os.path.dirname(cwd) + if parent == cwd: + break + cwd = parent + for c in candidates: + path = os.path.join(cwd, c) + if os.path.isfile(path): + return path + + return None + +def load_registry(path): + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + +def list_devices(data): + """列出所有包含 action_value_mappings 的设备,同时返回 module 路径""" + resources = data.get('resources', []) + devices = [] + for res in resources: + rid = res.get('id', '') + cls = res.get('class', {}) + avm = cls.get('action_value_mappings', {}) + module = cls.get('module', '') + if avm: + devices.append((rid, len(avm), module)) + return devices + +def flatten_schema_to_goal(action_data): + """将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装""" + schema = action_data.get('schema', {}) + goal_schema = schema.get('properties', {}).get('goal', {}) + if goal_schema: + action_data = dict(action_data) + action_data['schema'] = goal_schema + return action_data + + +def extract_actions(data, device_id, output_dir): + """提取指定设备的 action schema 到独立 JSON 文件""" + resources = data.get('resources', []) + for res in resources: + if res.get('id') == device_id: + cls = res.get('class', {}) + module = cls.get('module', '') + avm = cls.get('action_value_mappings', {}) + if not avm: + print(f"设备 {device_id} 没有 action_value_mappings") + return [] + + if module: + py_path = module.split(":")[0].replace(".", "/") + ".py" + class_name = module.split(":")[-1] if ":" in module else "" + print(f"Python 源码: {py_path}") + if class_name: + print(f"设备类: {class_name}") + + os.makedirs(output_dir, exist_ok=True) + written = [] + for action_name in sorted(avm.keys()): + action_data = flatten_schema_to_goal(avm[action_name]) + filename = action_name.replace('-', '_') + '.json' + filepath = os.path.join(output_dir, filename) + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(action_data, f, indent=2, ensure_ascii=False) + written.append(filename) + print(f" {filepath}") + return written + + print(f"设备 {device_id} 未找到") + return [] + +def main(): + args = sys.argv[1:] + explicit_registry = None + + if "--registry" in args: + idx = args.index("--registry") + if idx + 1 < len(args): + explicit_registry = args[idx + 1] + args = args[:idx] + args[idx + 2:] + else: + print("错误: --registry 需要指定路径") + sys.exit(1) + + registry_path = find_registry(explicit_registry) + if not registry_path: + print(f"错误: 找不到 {REGISTRY_FILENAME}") + print() + print("解决方法:") + print(" 1. 先运行 unilab 启动命令,等待注册表生成") + print(" 2. 用 --registry 指定文件路径:") + print(f" python {sys.argv[0]} --registry ") + print() + print("搜索过的路径:") + for p in [ + os.path.join("unilabos_data", REGISTRY_FILENAME), + REGISTRY_FILENAME, + os.path.join("", "unilabos_data", REGISTRY_FILENAME), + ]: + print(f" - {p}") + sys.exit(1) + + print(f"注册表: {registry_path}") + mtime = os.path.getmtime(registry_path) + gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + size_mb = os.path.getsize(registry_path) / (1024 * 1024) + print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)") + data = load_registry(registry_path) + + if len(args) == 0: + devices = list_devices(data) + print(f"\n找到 {len(devices)} 个设备:") + print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}") + print("-" * 120) + for did, count, module in sorted(devices, key=lambda x: x[0]): + py_path = module.split(":")[0].replace(".", "/") + ".py" if module else "" + print(f"{did:<50} {count:>7} {py_path}") + + elif len(args) == 2: + device_id = args[0] + output_dir = args[1] + print(f"\n提取 {device_id} 的 actions 到 {output_dir}/") + written = extract_actions(data, device_id, output_dir) + if written: + print(f"\n共写入 {len(written)} 个 action 文件") + + else: + print("用法:") + print(" python extract_device_actions.py [--registry ] # 列出设备") + print(" python extract_device_actions.py [--registry ] # 提取 actions") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/.cursor/skills/create-device-skill/scripts/gen_auth.py b/.cursor/skills/create-device-skill/scripts/gen_auth.py new file mode 100644 index 00000000..f0cb9c9b --- /dev/null +++ b/.cursor/skills/create-device-skill/scripts/gen_auth.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +从 ak/sk 生成 UniLab API Authorization header。 + +算法: base64(ak:sk) → "Authorization: Lab " + +用法: + python gen_auth.py + python gen_auth.py --config + +示例: + python gen_auth.py myak mysk + python gen_auth.py --config experiments/config.py +""" +import base64 +import re +import sys + + +def gen_auth(ak: str, sk: str) -> str: + token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8") + return token + + +def extract_from_config(config_path: str) -> tuple: + """从 config.py 中提取 ak 和 sk""" + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content) + sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content) + if not ak_match or not sk_match: + return None, None + return ak_match.group(1), sk_match.group(1) + + +def main(): + args = sys.argv[1:] + + if len(args) == 2 and args[0] == "--config": + ak, sk = extract_from_config(args[1]) + if not ak or not sk: + print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置") + print("期望格式: ak = \"xxx\" sk = \"xxx\"") + sys.exit(1) + print(f"配置文件: {args[1]}") + elif len(args) == 2: + ak, sk = args + else: + print("用法:") + print(" python gen_auth.py ") + print(" python gen_auth.py --config ") + sys.exit(1) + + token = gen_auth(ak, sk) + print(f"ak: {ak}") + print(f"sk: {sk}") + print() + print(f"Authorization header:") + print(f" Authorization: Lab {token}") + print() + print(f"curl 用法:") + print(f' curl -H "Authorization: Lab {token}" ...') + print() + print(f"Shell 变量:") + print(f' AUTH="Authorization: Lab {token}"') + + +if __name__ == "__main__": + main() diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 31872303..4d2f7288 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -866,68 +866,40 @@ liquid_handler: additionalProperties: false properties: category: - description: Field of type type: string children: - description: Field of type items: - description: Field of type type: string type: array config: - description: Field of type type: string data: - description: Field of type type: string id: - description: Field of type type: string name: - description: Field of type type: string parent: - description: Field of type type: string pose: additionalProperties: false - description: Field of type properties: orientation: additionalProperties: false - description: Field of type properties: w: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number x: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number y: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number z: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number @@ -940,24 +912,16 @@ liquid_handler: type: object position: additionalProperties: false - description: Field of type properties: x: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number y: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number z: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number @@ -973,12 +937,8 @@ liquid_handler: title: pose type: object sample_id: - description: Field of type type: string type: - description: Field of type type: string title: plate type: object @@ -9300,68 +9260,40 @@ liquid_handler.prcxi: additionalProperties: false properties: category: - description: Field of type type: string children: - description: Field of type items: - description: Field of type type: string type: array config: - description: Field of type type: string data: - description: Field of type type: string id: - description: Field of type type: string name: - description: Field of type type: string parent: - description: Field of type type: string pose: additionalProperties: false - description: Field of type properties: orientation: additionalProperties: false - description: Field of type properties: w: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number x: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number y: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number z: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number @@ -9374,24 +9306,16 @@ liquid_handler.prcxi: type: object position: additionalProperties: false - description: Field of type properties: x: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number y: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number z: - description: Field of type maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number @@ -9407,12 +9331,8 @@ liquid_handler.prcxi: title: pose type: object sample_id: - description: Field of type type: string type: - description: Field of type type: string title: plate type: object diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 520cbaa0..b21e1a14 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -47,7 +47,6 @@ from unilabos.registry.utils import ( normalize_ast_action_handles, wrap_action_schema, preserve_field_descriptions, - strip_ros_descriptions, resolve_method_params_via_import, SIMPLE_TYPE_MAP, ) @@ -501,12 +500,17 @@ class Registry: json_type = SIMPLE_TYPE_MAP.get(param_type.lower()) if json_type: prop_schema["type"] = json_type + elif ":" in param_type: + type_obj = resolve_type_object(param_type) + if type_obj is not None: + prop_schema = type_to_schema(type_obj) + else: + prop_schema["type"] = "object" elif import_map and param_type in import_map: type_obj = resolve_type_object(import_map[param_type]) if type_obj is not None: prop_schema = type_to_schema(type_obj) else: - # 无法 import 的自定义类型,默认当 object 处理(与 YAML runtime 路径一致) prop_schema["type"] = "object" else: json_type = get_json_schema_type(param_type) @@ -914,7 +918,6 @@ class Registry: logger.debug(f"[AST] device action '{action_name}': Result enrichment failed: {e}") try: schema = ros_action_to_json_schema(action_type_obj) - strip_ros_descriptions(schema) except Exception: pass # 直接从 ROS2 Goal 实例获取默认值 (msgcenterpy) @@ -1826,7 +1829,6 @@ class Registry: pass try: entry_schema = ros_action_to_json_schema(action_type_obj) - strip_ros_descriptions(entry_schema) if old_schema: preserve_field_descriptions(entry_schema, old_schema) if "description" in old_schema: @@ -1914,7 +1916,6 @@ class Registry: action_config["goal_default"] = {} prev_schema = action_config.get("schema", {}) action_config["schema"] = ros_action_to_json_schema(target_type) - strip_ros_descriptions(action_config["schema"]) if prev_schema: preserve_field_descriptions(action_config["schema"], prev_schema) if "description" in prev_schema: diff --git a/unilabos/ros/msgs/message_converter.py b/unilabos/ros/msgs/message_converter.py index 1451ee5c..83e6f456 100644 --- a/unilabos/ros/msgs/message_converter.py +++ b/unilabos/ros/msgs/message_converter.py @@ -717,6 +717,19 @@ def ros_field_type_to_json_schema( # return {'type': 'object', 'description': f'未知类型: {field_type}'} +def _strip_rosidl_descriptions(schema: Any) -> None: + """递归清除 rosidl_parser 自动生成的无意义 description(含内存地址)。""" + if isinstance(schema, dict): + desc = schema.get("description", "") + if isinstance(desc, str) and "rosidl_parser" in desc: + del schema["description"] + for v in schema.values(): + _strip_rosidl_descriptions(v) + elif isinstance(schema, list): + for item in schema: + _strip_rosidl_descriptions(item) + + def ros_message_to_json_schema(msg_class: Any, field_name: str) -> Dict[str, Any]: """ 将 ROS 消息类转换为 JSON Schema @@ -730,7 +743,8 @@ def ros_message_to_json_schema(msg_class: Any, field_name: str) -> Dict[str, Any """ schema = ROS2MessageInstance(msg_class()).get_json_schema() schema["title"] = field_name - schema.pop("description") + schema.pop("description", None) + _strip_rosidl_descriptions(schema) return schema @@ -777,6 +791,8 @@ def ros_action_to_json_schema( "required": ["goal"], } + _strip_rosidl_descriptions(schema) + # 保留之前 schema 中 goal/feedback/result 下一级字段的 description if previous_schema: _preserve_field_descriptions(schema, previous_schema) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 28fe92a5..ffc106c7 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1884,7 +1884,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): continue # 处理单个 ResourceSlot - if arg_type == "unilabos.registry.placeholder_type:ResourceSlot": + _is_resource_slot = isinstance(arg_type, str) and arg_type.endswith(":ResourceSlot") + if _is_resource_slot: resource_data = function_args[arg_name] if isinstance(resource_data, dict) and "id" in resource_data: try: @@ -1898,8 +1899,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 处理 ResourceSlot 列表 elif isinstance(arg_type, tuple) and len(arg_type) == 2: - resource_slot_type = "unilabos.registry.placeholder_type:ResourceSlot" - if arg_type[0] == "list" and arg_type[1] == resource_slot_type: + if arg_type[0] == "list" and isinstance(arg_type[1], str) and arg_type[1].endswith(":ResourceSlot"): resource_list = function_args[arg_name] if isinstance(resource_list, list): try: diff --git a/unilabos/utils/import_manager.py b/unilabos/utils/import_manager.py index ae81a287..df2775b8 100644 --- a/unilabos/utils/import_manager.py +++ b/unilabos/utils/import_manager.py @@ -325,10 +325,11 @@ class ImportManager: return self._get_type_string(signature.return_annotation) def _get_type_string(self, annotation) -> Union[str, Tuple[str, Any]]: - """将类型注解转换为短类名(与 AST _get_annotation_str 对齐)。 + """将类型注解转换为类型字符串。 - 自定义类只返回短名(如 ``"SetLiquidReturn"``),完整路径由 - ``import_map`` 负责解析,保持与 AST 分析一致。 + 非内建类返回 ``module:ClassName`` 全路径(如 + ``"unilabos.registry.placeholder_type:ResourceSlot"``), + 避免短名冲突;内建类型直接返回短名(如 ``"str"``、``"int"``)。 """ if annotation == inspect.Parameter.empty: return "Any" @@ -358,6 +359,9 @@ class ImportManager: else annotation._name.lower() ) if hasattr(annotation, "__name__"): + module = getattr(annotation, "__module__", None) + if module and module != "builtins": + return f"{module}:{annotation.__name__}" return annotation.__name__ elif hasattr(annotation, "_name"): return annotation._name