mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
feat(copilot): add tool response normalization utility
Add `normalize_tool_response` function to standardize tool output formats for consistent frontend display. The function converts various response types (dict, list, string) into a unified structure with success metrics, detailed data arrays, and metadata. This ensures backward compatibility while providing predictable response formats for UI components.
This commit is contained in:
parent
f64daa96cd
commit
4e28adffb6
215
docs/tool-response-format-standard.md
Normal file
215
docs/tool-response-format-standard.md
Normal file
@ -0,0 +1,215 @@
|
||||
# GNS3 Copilot Tool Response Format Standard
|
||||
|
||||
## 概述
|
||||
|
||||
本文档定义了 GNS3 Copilot 工具的标准响应格式,确保所有工具返回统一的数据结构,便于前端处理和美化显示。
|
||||
|
||||
## 标准响应格式
|
||||
|
||||
### 顶层结构
|
||||
|
||||
所有工具应返回以下标准格式:
|
||||
|
||||
```python
|
||||
{
|
||||
"success": bool, # 整体操作是否成功
|
||||
"total": int, # 总操作数量
|
||||
"successful": int, # 成功数量
|
||||
"failed": int, # 失败数量
|
||||
"data": list[dict], # 详细结果列表
|
||||
"error": str, # 全局错误信息(可选,操作完全失败时)
|
||||
"metadata": dict # 元数据(可选)
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `success` | `bool` | 是 | 整体操作是否成功(`failed == 0` 时为 `True`) |
|
||||
| `total` | `int` | 是 | 处理的项目总数 |
|
||||
| `successful` | `int` | 是 | 成功的项目数量 |
|
||||
| `failed` | `int` | 是 | 失败的项目数量 |
|
||||
| `data` | `list[dict]` | 是 | 每个项目的详细结果 |
|
||||
| `error` | `str` | 否 | 全局错误消息(当整个操作失败时) |
|
||||
| `metadata` | `dict` | 否 | 元数据(时间戳、执行时间等) |
|
||||
|
||||
### 单个项目格式
|
||||
|
||||
`data` 数组中的每个项目应遵循以下格式:
|
||||
|
||||
```python
|
||||
{
|
||||
"id": str, # 设备/节点/链接 ID
|
||||
"name": str, # 人类可读的名称
|
||||
"status": "success" | "failed", # 项目状态
|
||||
"result": str, # 成功时的结果或输出
|
||||
"error": str # 失败时的错误信息
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | `str` | 是 | 设备/节点/链接的唯一标识符 |
|
||||
| `name` | `str` | 是 | 人类可读的名称 |
|
||||
| `status` | `str` | 是 | `"success"` 或 `"failed"` |
|
||||
| `result` | `str` | 条件 | 状态为 `success` 时的输出 |
|
||||
| `error` | `str` | 条件 | 状态为 `failed` 时的错误信息 |
|
||||
|
||||
## 示例
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```python
|
||||
# 执行多个设备的显示命令
|
||||
{
|
||||
"success": True,
|
||||
"total": 3,
|
||||
"successful": 2,
|
||||
"failed": 1,
|
||||
"data": [
|
||||
{
|
||||
"id": "R1",
|
||||
"name": "Router1",
|
||||
"status": "success",
|
||||
"result": "Cisco IOS Software...\nRouter1# show version\n..."
|
||||
},
|
||||
{
|
||||
"id": "R2",
|
||||
"name": "Router2",
|
||||
"status": "success",
|
||||
"result": "Cisco IOS Software...\nRouter2# show version\n..."
|
||||
},
|
||||
{
|
||||
"id": "R3",
|
||||
"name": "Router3",
|
||||
"status": "failed",
|
||||
"error": "Connection refused"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"tool_name": "execute_multiple_device_commands",
|
||||
"execution_time": 5.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 完全失败示例
|
||||
|
||||
```python
|
||||
# 整个操作失败(如参数错误)
|
||||
{
|
||||
"success": False,
|
||||
"total": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"data": [],
|
||||
"error": "Invalid project_id format",
|
||||
"metadata": {
|
||||
"tool_name": "execute_multiple_device_commands"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 单个设备操作示例
|
||||
|
||||
```python
|
||||
# 操作单个设备
|
||||
{
|
||||
"success": True,
|
||||
"total": 1,
|
||||
"successful": 1,
|
||||
"failed": 0,
|
||||
"data": [
|
||||
{
|
||||
"id": "PC1",
|
||||
"name": "VPCS-1",
|
||||
"status": "success",
|
||||
"result": "IP configuration updated: 192.168.1.10/24"
|
||||
}
|
||||
],
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用标准化函数
|
||||
|
||||
在 `gns3server.agent.gns3_copilot.utils` 模块中提供了 `normalize_tool_response` 函数,用于将各种格式转换为标准格式:
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.utils import normalize_tool_response
|
||||
|
||||
# 标准化工具响应
|
||||
normalized = normalize_tool_response(raw_response, tool_name="my_tool")
|
||||
```
|
||||
|
||||
该函数支持:
|
||||
- 列表格式(`[{...}, {...}]`)
|
||||
- 字典格式(`{"nodes": [...]}`)
|
||||
- 字符串格式(自动解析 JSON/Python literal)
|
||||
- 混合格式(兼容旧工具)
|
||||
|
||||
## 兼容性
|
||||
|
||||
### 向后兼容
|
||||
|
||||
`normalize_tool_response` 函数设计为向后兼容,可以处理现有工具的各种格式:
|
||||
|
||||
- `status` / `error` 字段
|
||||
- `output` / `result` 字段
|
||||
- `device_name` / `name` 字段
|
||||
- `total_nodes` / `total` 字段
|
||||
|
||||
### 推荐的迁移策略
|
||||
|
||||
1. **新工具**:直接返回标准格式
|
||||
2. **现有工具**:保持不变,使用 `normalize_tool_response` 标准化
|
||||
3. **前端**:依赖标准格式处理显示
|
||||
|
||||
## 前端集成建议
|
||||
|
||||
### 渲染逻辑
|
||||
|
||||
```javascript
|
||||
function renderToolResponse(response) {
|
||||
if (!response.success) {
|
||||
// 显示全局错误
|
||||
showError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示统计摘要
|
||||
showSummary(response.total, response.successful, response.failed);
|
||||
|
||||
// 渲染每个项目
|
||||
response.data.forEach(item => {
|
||||
if (item.status === 'success') {
|
||||
showSuccess(item.name, item.result);
|
||||
} else {
|
||||
showError(item.name, item.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 状态图标
|
||||
|
||||
| 状态 | 图标建议 | 颜色 |
|
||||
|------|----------|------|
|
||||
| `success` | ✓ 绿色 | 绿色 |
|
||||
| `failed` | ✗ 红色 | 红色 |
|
||||
| `unknown` | ? 灰色 | 灰色 |
|
||||
|
||||
## 版本控制
|
||||
|
||||
当前标准版本:`v1.0`
|
||||
|
||||
格式变更时,应更新 `metadata.version` 字段,前端据此适配。
|
||||
|
||||
## 参考
|
||||
|
||||
- 实现:`gns3server/agent/gns3_copilot/utils/parse_tool_content.py`
|
||||
- 消息转换:`gns3server/agent/gns3_copilot/utils/message_converters.py`
|
||||
- 工具示例:`gns3server/agent/gns3_copilot/tools_v2/`
|
||||
@ -13,7 +13,11 @@ Author: Guobin Yue
|
||||
|
||||
# Import main utility functions
|
||||
from .get_gns3_device_port import get_device_ports_from_topology
|
||||
from .parse_tool_content import format_tool_response, parse_tool_content
|
||||
from .parse_tool_content import (
|
||||
format_tool_response,
|
||||
normalize_tool_response,
|
||||
parse_tool_content
|
||||
)
|
||||
|
||||
# Dynamic version management
|
||||
try:
|
||||
@ -33,4 +37,5 @@ __all__ = [
|
||||
"get_device_ports_from_topology",
|
||||
"parse_tool_content",
|
||||
"format_tool_response",
|
||||
"normalize_tool_response",
|
||||
]
|
||||
|
||||
@ -14,6 +14,28 @@ Supported formats:
|
||||
- Error message strings
|
||||
- Plain text output
|
||||
|
||||
Standard Tool Response Format:
|
||||
All tools should follow this standardized format for consistency:
|
||||
|
||||
{
|
||||
"success": bool, # Whether the overall operation succeeded
|
||||
"total": int, # Total number of items processed
|
||||
"successful": int, # Number of successful operations
|
||||
"failed": int, # Number of failed operations
|
||||
"data": list[dict], # Detailed results (one entry per item)
|
||||
"error": str, # Global error message (if operation failed entirely)
|
||||
"metadata": dict # Optional metadata (timestamp, execution_time, etc.)
|
||||
}
|
||||
|
||||
Single item format (for data array items):
|
||||
{
|
||||
"id": str, # Device/node/link ID
|
||||
"name": str, # Human-readable name
|
||||
"status": "success" | "failed", # Item status
|
||||
"result": str, # Success result or output
|
||||
"error": str # Error message (if failed)
|
||||
}
|
||||
|
||||
Author: Guobin Yue
|
||||
"""
|
||||
|
||||
@ -232,6 +254,185 @@ def format_tool_response(
|
||||
return result
|
||||
|
||||
|
||||
def normalize_tool_response(
|
||||
response: dict | list | str,
|
||||
tool_name: str = "unknown"
|
||||
) -> dict:
|
||||
"""
|
||||
Normalize tool response to standard format for consistent frontend display.
|
||||
|
||||
This function converts various tool response formats into a standardized structure
|
||||
that frontend code can rely on. It handles both legacy formats and new formats,
|
||||
ensuring backward compatibility.
|
||||
|
||||
Args:
|
||||
response: Raw tool response (dict, list, or string)
|
||||
tool_name: Name of the tool (for error messages)
|
||||
|
||||
Returns:
|
||||
dict: Normalized response in standard format:
|
||||
{
|
||||
"success": bool,
|
||||
"total": int,
|
||||
"successful": int,
|
||||
"failed": int,
|
||||
"data": list[dict],
|
||||
"error": str (optional),
|
||||
"metadata": dict
|
||||
}
|
||||
|
||||
Examples:
|
||||
>>> normalize_tool_response({"status": "success", "output": "OK"})
|
||||
{'success': True, 'total': 1, 'successful': 1, 'failed': 0, 'data': [{'status': 'success', 'result': 'OK'}], 'metadata': {}}
|
||||
|
||||
>>> normalize_tool_response([{"device_name": "R1", "status": "success"}])
|
||||
{'success': True, 'total': 1, 'successful': 1, 'failed': 0, 'data': [...], 'metadata': {}}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
metadata = {
|
||||
"tool_name": tool_name,
|
||||
"normalized_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(response, dict) and "error" in response and len(response) == 1:
|
||||
return {
|
||||
"success": False,
|
||||
"total": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"data": [],
|
||||
"error": str(response["error"]),
|
||||
"metadata": metadata
|
||||
}
|
||||
|
||||
# Handle empty responses
|
||||
if not response:
|
||||
return {
|
||||
"success": True,
|
||||
"total": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"data": [],
|
||||
"metadata": metadata
|
||||
}
|
||||
|
||||
# Handle list responses (most tools return list of device results)
|
||||
if isinstance(response, list):
|
||||
successful = sum(1 for item in response if isinstance(item, dict) and item.get("status") == "success")
|
||||
failed = len(response) - successful
|
||||
|
||||
normalized_data = []
|
||||
for item in response:
|
||||
if isinstance(item, dict):
|
||||
normalized_item = {
|
||||
"id": item.get("device_id") or item.get("node_id") or item.get("id") or "",
|
||||
"name": item.get("device_name") or item.get("name") or "",
|
||||
"status": item.get("status", "unknown"),
|
||||
}
|
||||
if normalized_item["status"] == "success":
|
||||
normalized_item["result"] = item.get("output") or item.get("result") or ""
|
||||
else:
|
||||
normalized_item["error"] = item.get("error") or item.get("output") or "Unknown error"
|
||||
normalized_data.append(normalized_item)
|
||||
else:
|
||||
# Non-dict items in list
|
||||
normalized_data.append({
|
||||
"id": "",
|
||||
"name": "",
|
||||
"status": "unknown",
|
||||
"result": str(item)
|
||||
})
|
||||
|
||||
return {
|
||||
"success": failed == 0,
|
||||
"total": len(response),
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"data": normalized_data,
|
||||
"metadata": metadata
|
||||
}
|
||||
|
||||
# Handle dict responses (some tools return summary + results)
|
||||
if isinstance(response, dict):
|
||||
# Check if already in standard format
|
||||
if "success" in response and "data" in response:
|
||||
return {
|
||||
"success": response.get("success", True),
|
||||
"total": response.get("total", len(response.get("data", []))),
|
||||
"successful": response.get("successful", 0),
|
||||
"failed": response.get("failed", 0),
|
||||
"data": response.get("data", []),
|
||||
"error": response.get("error"),
|
||||
"metadata": {**metadata, **response.get("metadata", {})}
|
||||
}
|
||||
|
||||
# Legacy format: extract common fields
|
||||
total = response.get("total_nodes") or response.get("total") or response.get("count", 1)
|
||||
successful = response.get("successful_nodes") or response.get("successful") or 0
|
||||
failed = response.get("failed_nodes") or response.get("failed") or 0
|
||||
|
||||
# Extract data from various possible locations
|
||||
data = []
|
||||
if "nodes" in response:
|
||||
data = response["nodes"]
|
||||
elif "results" in response:
|
||||
data = response["results"]
|
||||
elif "data" in response:
|
||||
data = response["data"]
|
||||
elif "output" in response:
|
||||
# Single device response
|
||||
data = [{
|
||||
"name": response.get("device_name", ""),
|
||||
"status": response.get("status", "success"),
|
||||
"result": response["output"]
|
||||
}]
|
||||
|
||||
# If no data found but have status, create single item
|
||||
if not data and "status" in response:
|
||||
data = [{
|
||||
"name": response.get("device_name") or response.get("name") or "",
|
||||
"status": response["status"],
|
||||
"result": response.get("output") or response.get("result") or "",
|
||||
"error": response.get("error") or ""
|
||||
}]
|
||||
|
||||
# Recursively normalize data items
|
||||
if data and isinstance(data, list):
|
||||
return normalize_tool_response(data, tool_name)
|
||||
else:
|
||||
# No data array, return empty but preserve counts
|
||||
return {
|
||||
"success": failed == 0,
|
||||
"total": total,
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"data": [],
|
||||
"metadata": metadata
|
||||
}
|
||||
|
||||
# Handle string responses (parse first)
|
||||
if isinstance(response, str):
|
||||
parsed = parse_tool_content(response, fallback_to_raw=True)
|
||||
return normalize_tool_response(parsed, tool_name)
|
||||
|
||||
# Fallback for unknown types
|
||||
return {
|
||||
"success": True,
|
||||
"total": 1,
|
||||
"successful": 1,
|
||||
"failed": 0,
|
||||
"data": [{
|
||||
"id": "",
|
||||
"name": "",
|
||||
"status": "unknown",
|
||||
"result": str(response)
|
||||
}],
|
||||
"metadata": metadata
|
||||
}
|
||||
|
||||
|
||||
# Test function to verify the implementation
|
||||
def _test_parse_tool_content() -> None:
|
||||
"""Test function to verify parse_tool_content works correctly with all input types"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user