Initial commit: 无人机行为规划后端系统

Made-with: Cursor
This commit is contained in:
2026-03-17 10:51:32 +08:00
commit 3ddc2e1ff4
35 changed files with 3219 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
# 无人机行为规划后端系统(重建版)需求说明
> 目标:基于端侧 Qwen3-4Bllama-server和行为树重建一个从自然语言到无人机行为树 JSON 的后端系统,支持未来扩展 RAG 和动态语义地图。
## 一、总体架构理念(务必遵守)
1. **大小脑解耦**
- 大模型4B小模型 = 「大脑」:只做语义理解、意图拆解、行为树结构生成,不直接管起飞/降落/避障。
- Python + py_trees + PX4/仿真 = 「小脑」:负责状态机管理、物理安全、控制执行(起飞、悬停、返航等)。
2. **四层流水线结构**
- 🔴 Layer 0感知层Perception & State
纯代码。维护一个全局状态黑板 `DroneStateBlackboard`,至少包含:
- `is_in_air: bool`
- `position: {x, y, z}`ENU
- 后续可扩展电量、模式等。
- 🟡 Layer 1意图路由层Stage 1 Intent Router
调用 LLM 做**极简意图分类和实体抽取**,只输出:
- `intents: list[str]`
- `entities: dict`
并实现 **Fast-Path 短路机制**
- 若指令是纯原子控制(如“起飞”、“降落”、“悬停”),直接由代码执行,不进入规划阶段。
- 🟢 Layer 2动态组装与外部计算Dynamic Composer & MCP/RAG
纯代码。
- 根据 `intents``entities`,从本地 `node_schema.json` 裁剪出**最小必要的节点定义**(行为树节点)。
- 使用工具(后续接 RAG解析地点到坐标ENU大模型不做算术。
- 生成发给 Stage 2 LLM 的 **精简 System Prompt**
- 🔵 Layer 3宏观行为规划Stage 2 Macro Planner
调用 LLMQwen3-4B
- 输入Layer 2 生成的 System Prompt + 用户原文(含 RAG 上下文)。
- 输出:业务行为树 JSON**不包含起飞/降落**)。
- 🟣 Layer 4执行包装与安全逻辑Execution Wrapper
纯代码 + py_trees
- 将业务树解析为 py_trees 对象。
- 如果 `is_in_air == False`,自动在业务树前面**插入 `[SystemCheck -> Takeoff]` 子树**。
- 提供 tick 循环和未来的中断(用户打断、动态重规划)预留。
---
## 二、节点定义(给 LLM 的行为树节点池)
请在 `config/node_schema.json` 定义一个简洁的行为节点池LLM 可见的动作),结构类似:
```json
{
"_instruction": "作为行为树规划器,你只能使用以下定义的节点和参数。",
"actions": {
"fly_to_waypoint": { "desc": "...", "params": { "x": "float", "y": "float", "z": "float" } },
"fly_sequence": { "desc": "...", "params": { "waypoints": "array of {x,y,z}" } },
"move_direction": { "desc": "...", "params": { "direction": "front|back|left|right|up|down", "distance": "float" } },
"search_pattern": { "desc": "...", "params": { "pattern_type": "spiral|grid", "radius": "float", "target_class": "string" } },
"rotate_search": { "desc": "...", "params": { "target_class": "string" } },
"track_object": { "desc": "...", "params": { "target_class": "string", "track_time": "float" } },
"take_photos": { "desc": "...", "params": { "target_class": "string", "count": "int" } },
"report_message": { "desc": "...", "params": { "message": "string" } },
"manual_confirmation": { "desc": "...", "params": { "prompt_message": "string" } }
},
"conditions": {
"object_detected": { "desc": "...", "params": { "target_class": "string" } }
},
"control_flow": {
"Sequence": { "desc": "...", "params": {}, "requires_children": true },
"Selector": { "desc": "...", "params": {}, "requires_children": true },
"Parallel": { "desc": "...", "params": { "policy": "success_on_one|success_on_all" }, "requires_children": true }
},
"decorators": {
"Timeout": { "desc": "...", "params": { "max_time": "float" }, "requires_child": true },
"Repeat": { "desc": "...", "params": { "times": "int" }, "requires_child": true }
}
}
```
> 注意:**起飞/降落等底层动作不要出现在这里**,由 Layer 4 包装硬编码处理。
---
## 三、Stage 1 Intent Router 的设计(重点)
### 1. LLM 提示词Router System Prompt
要求使用简洁、正交的 Intent 集合(避免同义词过多):
- 原子意图Atomic Intents仅用于 Fast-Path
- `atomic_takeoff`
- `atomic_land`
- `atomic_hover`
- 业务意图Business Intents
- `fly_task`:所有涉及空间移动/路径/巡逻
- `search_task`:搜索/侦查
- `track_task`:跟踪
- `photo_task`:拍照
- `interact_task`:上报/请求确认
**实体提取要求:**
- 地点实体统一用 `"locations"`,且必须为列表:
- `{"locations": ["大门"]}`
- `{"locations": ["大门", "广场"]}`支持“先去A再去B”
- 目标实体用 `"targets"` 列表:
- `{"targets": ["汽车", "行人"]}`
- 其他参数如 `"direction"`, `"distance"` 直接提取。
### 2. Python 层的“硬编码冲突处理”逻辑
`pipeline/router.py` 中:
- 定义集合:
```python
ATOMIC_INTENTS = {"atomic_takeoff", "atomic_land", "atomic_hover"}
BUSINESS_INTENTS = {"fly_task", "search_task", "track_task", "photo_task", "interact_task"}
```
- 处理流程:
-`intents` 非空且 **全部属于 ATOMIC_INTENTS**
→ 允许 Fast-Path直接执行原子命令
-`intents` 同时包含 atomic 和 business`["atomic_takeoff", "fly_task"]`
**删除所有 atomic**,仅保留 business走正常规划起飞由 Layer 4 自动包装)。
-`intents` 为空或包含未识别标签(小模型幻觉):
→ 兜底为 `["fly_task", "search_task"]`,保证系统不崩。
---
## 四、RAG 模块设计Layer 2 可选扩展)
### 1. 目录结构
`src/drone_planning/rag/`
- `embedding_client.py`:封装 Qwen Embeddingllama-server 8090
- `vector_store.py`ChromaDB 客户端,管理三个 Collection
- `map_db`
- `rule_db`
- `few_shot_db`
- `retriever.py``RAGRetriever`
- `dynamic_memory`:热数据表占位(未来语义地图)。
- `retrieve_context(intents, entities, user_text) -> dict`
- `ingestion.py`:从 `data/knowledge/*.jsonl` 读 NDJSON 灌入 ChromaDB。
- `schemas.py`(可选):约定 JSONL 字段结构。
### 2. JSONL 格式约定(统一用 `.jsonl`,一行一 JSON
- `data/knowledge/map_db.jsonl`
```json
{"document": "喷泉在广场正中央,坐标 x=10, y=20", "location": "喷泉", "x": 10, "y": 20, "z": 0}
{"document": "A区是禁飞区不得进入", "location": "A区", "zone_type": "no_fly"}
```
- `data/knowledge/rule_db.jsonl`
```json
{"document": "夜间巡逻必须开启热成像", "intent": "search_task", "scene": "night"}
{"document": "起飞必须先做系统检查", "intent": "fly_task", "priority": "high"}
```
- `data/knowledge/few_shot_db.jsonl`
```json
{"document": "飞到大门然后拍照", "intent": "fly_task", "tree_json": "{\"root\": {...}}"}
```
### 3. 检索策略(在 `retriever.py`
- 热数据优先Dynamic Semantic Map
- `dynamic_memory = {"locations": {...}, "targets": {...}}`
- 若某 location 在热内存中有坐标,直接返回,**不要查 ChromaDB**。
- 冷库次之ChromaDB
- 根据 `entities["locations"]``intents` 用 metadata + 向量检索,从 `map_db``rule_db` 获取上下文。
- 使用 `user_text``few_shot_db` 中查 12 个相似任务的行为树,作为 few-shot 示例。
RAG 返回统一结构:
```python
{
"map_context": "若干行文本或结构化信息",
"rule_context": "规则文本汇总",
"few_shot_examples": [
{"instruction": "...", "tree_json": {...}},
...
]
}
```
### 4. 与 Composer 集成(`pipeline/composer.py`
-`build_system_prompt(...)` 增加参数 `rag_context: dict | None`
- 在 Prompt 中插入三个区块(若非空):
- `## RAG 地图上下文`
- `## RAG 规则约束`
- `## RAG 示例行为树`
- 坐标解析优先级:
1. 动态内存(语义地图,未来接入)。
2. RAG 地图map_db 的 x,y,z
3. geo.py 的硬编码 `landmark_to_enu()`(兜底,不删除,作为最后一层 fallback
---
## 五、执行包装层Layer 4`execution`
### 1. 行为节点实现(`execution/nodes.py`
实现继承自 `py_trees.behaviour.Behaviour` 的 Mock 节点:
- `SystemCheckCondition`
- `TakeoffAction`
- `LandAction`
- `FlyToWaypointAction`
- `GenericAction`(兜底)
目前只打印日志并返回 `SUCCESS`,后续预留 ROS2/PX4 接口。
### 2. 树包装与 Tick 循环(`execution/tree_wrapper.py`
- `parse_json_to_tree(node_dict)`:递归将 Planner 的 JSON 转换为 py_trees 树。
- `wrap_and_build_tree(business_tree)`
- 读取 `DroneStateBlackboard.is_in_air`
-`False`:创建 `Sequence("Safe_Execution")`,依次添加:
- `SystemCheckCondition()`
- `TakeoffAction()`
- `business_tree`
-`True`:直接返回 `business_tree`
---
## 六、API 接口与 Orchestrator
- 使用 FastAPI 暴露 `POST /api/plan`
- 输入:`{"text": "用户自然语言指令"}`
- 调用顺序:
1. Layer 1 Router若 Fast-Path直接返回执行结果。
2. 否则,调用 RAG → Composer → Planner → Tree Wrapper返回行为树 JSON 和日志。
---
## 七、请 Cursor 的工作顺序建议
1. 生成项目目录和基础依赖(`requirements.txt``pyproject.toml`)。
2. 实现:
- `core/blackboard.py`
- `llm_client/client.py`(指向 `http://localhost:8080/v1` for chat`http://localhost:8090/v1` for embeddings 可在 embedding_client 中配置)
3. 实现 `config/node_schema.json`
4. 实现 `pipeline/router.py`(含 intents/entities 提取与 Fast-Path 短路 + 冲突处理)。
5. 实现 `tools/geo.py`(仅作为 fallback 静态坐标表)。
6. 实现 `rag/*`embedding_client, vector_store, ingestion, retriever
7. 实现 `pipeline/composer.py``pipeline/planner.py`(加入 rag_context
8. 实现 `execution/nodes.py``execution/tree_wrapper.py`
9. 实现 `api/routes.py``main.py`
10. 最后补充简单的 `tests/` 或手动 curl 示例。
请从目录结构 + 模块职责开始规划,确认后再逐步生成代码。
```

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
*.egg
# Virtual environments
.venv/
venv/
ENV/
env/
# IDE
.idea/
.vscode/
.cursor/
# ChromaDB / data
data/chroma/
# Local config
.env
.env.local
*.local
# OS
.DS_Store
Thumbs.db

181
README.md Normal file
View File

@@ -0,0 +1,181 @@
# 无人机行为规划后端系统
从自然语言指令到行为树 JSON 的四层流水线系统,支持 RAG 检索、Function Calling 坐标计算、Fast-Path 短路等能力。
## 功能概览
- **Layer 1 (Router)**:意图分类与实体抽取,支持原子指令(起飞/降落/悬停Fast-Path 短路
- **RAG 检索**地图基准点坐标、规则约束、Few-shot 示例(不负责相对坐标计算)
- **Layer 2 (Composer)**:根据意图裁剪 schema组装精简 System Prompt
- **Layer 3 (Planner)**LLM 生成行为树 JSON**仅 LLM 通过 mcp 工具计算坐标**(绝对地点用 base_location_coords相对位置必须调用工具禁止心算
- **Layer 4 (Execution)**:解析 JSON 为 py_trees根据 `is_in_air` 自动插入起飞逻辑
## 环境要求
- Python 3.10+
- 本地 LLM 服务llama-server 或兼容 OpenAI API 的服务):
- **Chat**:默认 `http://localhost:8081/v1`
- **Embedding**:默认 `http://localhost:8090/v1`RAG 用)
## 快速部署
### 1. 克隆与依赖
```bash
cd DronePlanningV2
pip install -e .
# 或
pip install -r requirements.txt
```
### 2. 配置 LLM 服务
确保 Chat 和 Embedding 服务已启动。可通过环境变量覆盖默认地址:
```bash
export LLM_CHAT_BASE_URL="http://localhost:8081/v1"
export LLM_EMBEDDING_BASE_URL="http://localhost:8090/v1"
export OPENAI_API_KEY="not-needed" # 本地部署通常不需要
```
可选:开启模型思考模式(默认关闭以降低延迟):
```bash
export ENABLE_THINKING=true
```
### 3. RAG 知识库灌入
首次使用或更新知识库后需执行灌入:
```bash
python -m drone_planning.rag.ingestion
```
知识库文件位于 `data/knowledge/`
- `map_db.jsonl`地点坐标location, x, y, z
- `rule_db.jsonl`规则约束intent, document
- `few_shot_db.jsonl`Few-shot 示例instruction, tree_json
### 4. 启动 API 服务
```bash
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### 5. 启动 Playground 测试台
```bash
streamlit run playground.py
```
## 使用方式
### API 调用
```bash
# 复杂任务(走完整流水线)
curl -X POST http://localhost:8000/api/plan \
-H "Content-Type: application/json" \
-d '{"text": "飞到大门然后拍照"}'
# 原子指令Fast-Path 短路)
curl -X POST http://localhost:8000/api/plan \
-H "Content-Type: application/json" \
-d '{"text": "起飞"}'
# 相对描述RAG 提供基准点LLM 调用 mcp 计算)
curl -X POST http://localhost:8000/api/plan \
-H "Content-Type: application/json" \
-d '{"text": "飞到广场东边500米"}'
```
响应示例(含各环节耗时 `timing_ms`
```json
{
"success": true,
"fast_path": false,
"intents": ["fly_task", "photo_task"],
"entities": {"locations": ["大门"], "targets": []},
"rag_context": {...},
"tool_call_log": [...],
"tree_json": {"root": {...}},
"tree_ascii": "...",
"timing_ms": {
"Layer1_Router": 120,
"RAG_检索": 45,
"Layer2_Composer": 2,
"Layer3_Planner": 3500,
"Layer4_Execution": 1
}
}
```
### Playground 测试
1. 打开 `http://localhost:8501`
2. 左侧边栏:切换「是否在空中」模拟起飞状态
3. 主界面:输入自然语言指令,点击「执行规划」
4. 查看各环节耗时、Layer 1~4 输出、RAG 检索结果、LLM Tool Call 日志(坐标仅由 LLM 通过 mcp 计算)
### 命令行测试 Router
```bash
python -m drone_planning.pipeline.router "飞到大门然后拍照"
```
## 项目结构
```
DronePlanningV2/
├── config/
│ └── node_schema.json # 行为树节点定义
├── data/
│ └── knowledge/ # RAG 知识库 jsonl
│ ├── map_db.jsonl
│ ├── rule_db.jsonl
│ └── few_shot_db.jsonl
├── src/drone_planning/
│ ├── api/ # FastAPI 路由
│ ├── core/ # Blackboard 黑板
│ ├── execution/ # py_trees 解析与包装
│ ├── llm_client/ # Chat / Embedding 客户端
│ ├── pipeline/ # Router / Composer / Planner
│ ├── rag/ # 向量存储、检索、灌入
│ └── tools/ # mcp_calc 坐标计算
├── main.py # API 入口
├── playground.py # Streamlit 测试台
├── requirements.txt
└── pyproject.toml
```
## 环境变量汇总
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `LLM_CHAT_BASE_URL` | `http://localhost:8081/v1` | Chat API 地址 |
| `LLM_EMBEDDING_BASE_URL` | `http://localhost:8090/v1` | Embedding API 地址 |
| `OPENAI_API_KEY` | `not-needed` | API Key |
| `ENABLE_THINKING` | `false` | 是否开启模型思考模式 |
| `CHROMA_PERSIST_PATH` | `./data/chroma` | ChromaDB 持久化路径 |
| `EMBEDDING_MODEL` | `qwen3-embedding` | Embedding 模型名 |
## 常见问题
**Q: ChromaDB 报错?**
A: 确保已安装 `chromadb>=0.4.0`,首次运行需执行 `python -m drone_planning.rag.ingestion`
**Q: LLM 调用超时?**
A: 检查 `LLM_CHAT_BASE_URL` 是否可达,模型是否支持 `json_schema` / `tools`
**Q: 相对坐标未计算?**
A: 确保 `map_db.jsonl` 中有基准点如「广场」Router 正确抽取 `direction``distance`,且 LLM 支持 Function Calling 调用 `calculate_relative_coordinate`
**Q: 如何扩展知识库?**
A: 编辑 `data/knowledge/*.jsonl` 后重新执行 `python -m drone_planning.rag.ingestion`
## 详细流程文档
完整项目流程、各 Stage 对应代码、RAG 调用、工具调用、提示词组织等详见:[docs/项目流程详解.md](docs/项目流程详解.md)

109
config/node_schema.json Normal file
View File

@@ -0,0 +1,109 @@
{
"_instruction": "作为行为树规划器,你只能使用以下定义的节点和参数。不要使用起飞、降落等底层动作,它们由系统自动包装。",
"actions": {
"fly_to_waypoint": {
"desc": "飞往指定坐标点ENU 坐标系)",
"params": {
"x": "float",
"y": "float",
"z": "float"
}
},
"fly_sequence": {
"desc": "按顺序飞往多个航点",
"params": {
"waypoints": "array of {x,y,z}"
}
},
"move_direction": {
"desc": "沿指定方向移动固定距离",
"params": {
"direction": "front|back|left|right|up|down",
"distance": "float"
}
},
"search_pattern": {
"desc": "按螺旋或网格模式搜索目标",
"params": {
"pattern_type": "spiral|grid",
"radius": "float",
"target_class": "string"
}
},
"rotate_search": {
"desc": "原地旋转搜索目标",
"params": {
"target_class": "string"
}
},
"track_object": {
"desc": "跟踪指定类型的目标",
"params": {
"target_class": "string",
"track_time": "float"
}
},
"take_photos": {
"desc": "对目标拍照",
"params": {
"target_class": "string",
"count": "int"
}
},
"report_message": {
"desc": "上报消息",
"params": {
"message": "string"
}
},
"manual_confirmation": {
"desc": "请求用户手动确认",
"params": {
"prompt_message": "string"
}
}
},
"conditions": {
"object_detected": {
"desc": "检测到指定类型的目标",
"params": {
"target_class": "string"
}
}
},
"control_flow": {
"Sequence": {
"desc": "顺序执行子节点,全部成功才成功",
"params": {},
"requires_children": true
},
"Selector": {
"desc": "选择执行子节点,有一个成功即成功",
"params": {},
"requires_children": true
},
"Parallel": {
"desc": "并行执行子节点",
"params": {
"policy": "success_on_one|success_on_all"
},
"requires_children": true
}
},
"decorators": {
"Timeout": {
"desc": "限制子节点最大执行时间",
"params": {
"max_time": "float"
},
"requires_child": true
},
"Repeat": {
"desc": "重复执行子节点指定次数",
"params": {
"times": "int"
},
"requires_child": true
}
}
}

View File

@@ -0,0 +1,3 @@
{"document": "飞到大门然后拍照", "intent": "fly_task", "tree_json": "{\"root\": {\"type\": \"Sequence\", \"children\": [{\"type\": \"fly_to_waypoint\", \"params\": {\"x\": 0, \"y\": 0, \"z\": 5}}, {\"type\": \"take_photos\", \"params\": {\"target_class\": \"unknown\", \"count\": 1}}]}}"}
{"document": "先去广场再去喷泉", "intent": "fly_task", "tree_json": "{\"root\": {\"type\": \"Sequence\", \"children\": [{\"type\": \"fly_to_waypoint\", \"params\": {\"x\": 10, \"y\": 15, \"z\": 5}}, {\"type\": \"fly_to_waypoint\", \"params\": {\"x\": 10, \"y\": 20, \"z\": 5}}]}}"}
{"document": "在A区搜索汽车", "intent": "search_task", "tree_json": "{\"root\": {\"type\": \"Sequence\", \"children\": [{\"type\": \"fly_to_waypoint\", \"params\": {\"x\": 5, \"y\": 5, \"z\": 5}}, {\"type\": \"search_pattern\", \"params\": {\"pattern_type\": \"spiral\", \"radius\": 10, \"target_class\": \"汽车\"}}]}}"}

View File

@@ -0,0 +1,5 @@
{"document": "喷泉在广场正中央,坐标 x=10, y=20", "location": "喷泉", "x": 10, "y": 20, "z": 0}
{"document": "大门位于入口处,坐标 x=0, y=0", "location": "大门", "x": 0, "y": 0, "z": 0}
{"document": "广场在喷泉西侧,坐标 x=10, y=15", "location": "广场", "x": 10, "y": 15, "z": 0}
{"document": "A区是工作区坐标 x=5, y=5", "location": "A区", "x": 5, "y": 5, "z": 0}
{"document": "B区在东北角坐标 x=20, y=10", "location": "B区", "x": 20, "y": 10, "z": 0}

View File

@@ -0,0 +1,5 @@
{"document": "夜间巡逻必须开启热成像", "intent": "search_task", "scene": "night"}
{"document": "起飞必须先做系统检查", "intent": "fly_task", "priority": "high"}
{"document": "搜索任务需保持安全高度 5 米以上", "intent": "search_task", "min_altitude": 5}
{"document": "拍照任务需先悬停稳定", "intent": "photo_task", "pre_stabilize": true}
{"document": "跟踪移动目标时保持 3 米距离", "intent": "track_task", "safe_distance": 3}

391
docs/项目流程详解.md Normal file
View File

@@ -0,0 +1,391 @@
# 无人机行为规划系统 - 项目流程详解
---
## 1. 整体架构
```mermaid
flowchart TB
subgraph Input [输入]
UserText[用户自然语言指令]
end
subgraph Layer1 [Layer 1 - Router]
Router[意图路由]
FastPath{Fast-Path?}
end
subgraph RAG [RAG 检索]
MapDB[map_db]
RuleDB[rule_db]
FewShotDB[few_shot_db]
Retriever[RAGRetriever]
end
subgraph Layer2 [Layer 2 - Composer]
Composer[动态组装 Prompt]
end
subgraph Layer3 [Layer 3 - Planner]
Planner[LLM 规划]
ToolCall[Function Calling]
MCPCalc[calculate_relative_coordinate]
end
subgraph Layer4 [Layer 4 - Execution]
Parse[parse_json_to_tree]
Wrap[wrap_and_build_tree]
Tree[py_trees 行为树]
end
UserText --> Router
Router --> FastPath
FastPath -->|是| Output1[直接返回原子意图]
FastPath -->|否| Retriever
Retriever --> MapDB
Retriever --> RuleDB
Retriever --> FewShotDB
Retriever --> Composer
Composer --> Planner
Planner --> ToolCall
ToolCall --> MCPCalc
MCPCalc --> Planner
Planner --> Parse
Parse --> Wrap
Wrap --> Tree
```
---
## 2. 数据流与各 Stage 总览
| Stage | 模块 | 对应代码 | 是否调用 RAG | 是否调用工具 | 是否调用 LLM |
|-------|------|----------|--------------|--------------|--------------|
| Layer 1 | Router | `pipeline/router.py` | 否 | 否 | 是 |
| RAG | Retriever | `rag/retriever.py` | 是map/rule/few_shot | 否 | 否 |
| Layer 2 | Composer | `pipeline/composer.py` | 否(消费 RAG 结果) | 否 | 否 |
| Layer 3 | Planner | `pipeline/planner.py` | 否(消费 Composer 输出) | 是LLM 调用 mcp | 是 |
| Layer 4 | Execution | `execution/tree_wrapper.py` | 否 | 否 | 否 |
---
## 3. Stage 1Layer 1 - Router意图路由
### 3.1 职责
- 对用户自然语言做意图分类和实体抽取
- 判断是否为 Fast-Path仅原子意图起飞/降落/悬停)
- 冲突处理:同时含原子+业务意图时,保留业务意图
### 3.2 对应代码
- **入口函数**`route(user_text: str) -> RouterResult`
- **文件**[`src/drone_planning/pipeline/router.py`](../src/drone_planning/pipeline/router.py)
### 3.3 是否调用 RAG
**否**。Router 仅依赖 LLM不访问 RAG。
### 3.4 是否调用工具
**否**。仅调用 LLM 的 `chat_completion_json`,无 Function Calling。
### 3.5 提示词组织
**System Prompt**`ROUTER_SYSTEM_PROMPT`
- 角色:指令意图分类器
- 意图集合原子意图atomic_takeoff/atomic_land/atomic_hover、业务意图fly_task/search_task/track_task/photo_task/interact_task
- 实体要求locations、targets、direction、distance
- 规则相对描述如「广场东边500米」必须拆分为 `locations=["广场"]`, `direction="东"`, `distance=500`
**User Message**:用户原始指令。
**输出 Schema**`ROUTER_JSON_SCHEMA`
```json
{
"intents": ["string"],
"entities": {
"locations": ["string"],
"targets": ["string"],
"direction": "string",
"distance": number
}
}
```
### 3.6 输出与短路逻辑
- `RouterResult.intents`:解析后的意图列表
- `RouterResult.entities`:实体字典
- `RouterResult.is_fast_path`:若 `intents` 非空且全部属于 `ATOMIC_INTENTS`,则为 `True`,后续 RAG/Composer/Planner/Execution 不执行
---
## 4. Stage 2RAG 检索
### 4.1 职责
- 根据 Router 的 `intents``entities``user_text` 检索上下文
- 地图坐标热表优先冷库ChromaDB map_db兜底
- 规则约束:按 intent 查 rule_db
- Few-shot 示例:用 user_text 向量检索 few_shot_db
- **仅提供基准点坐标**`base_location_coords`),不负责相对坐标计算;相对描述由 LLM 通过 mcp 工具计算
### 4.2 对应代码
- **类**`RAGRetriever`
- **方法**`retrieve_context(intents, entities, user_text) -> dict`
- **文件**[`src/drone_planning/rag/retriever.py`](../src/drone_planning/rag/retriever.py)
### 4.3 调用的 RAG 库
| 库名 | 用途 | 检索方式 |
|------|------|----------|
| **map_db** | 基准点坐标 | metadata 精确匹配 `where={"location": base_loc}` |
| **rule_db** | 规则约束 | metadata 精确匹配 `where={"intent": intent}` |
| **few_shot_db** | Few-shot 示例 | 向量相似度 `query(query_texts=[user_text], n_results=2)` |
### 4.4 是否调用工具
**否**。RAG 不调用 mcp_calc仅提供 `base_location_coords``relative_descriptions`,坐标计算由 LLM 通过 Function Calling 完成。
### 4.5 输出结构
```python
{
"map_context": str,
"rule_context": str,
"few_shot_examples": [...],
"base_location_coords": {loc: {x,y,z}}, # 基准点坐标,不计算相对位置
"relative_descriptions": [ # 相对描述,供 LLM 调用工具
{"target": "广场东边500米", "base": "广场", "direction": "", "distance": 500},
...
]
}
```
---
## 5. Stage 3Layer 2 - Composer动态组装
### 5.1 职责
- 根据 `intents` 裁剪 `node_schema.json`,只保留相关 actions
- 将 RAG 的 `base_location_coords``relative_descriptions``map_context``rule_context``few_shot_examples` 拼入 System Prompt
- 生成发给 Planner LLM 的精简 Prompt
### 5.2 对应代码
- **函数**`build_system_prompt(intents, entities, schema_json, rag_context) -> (str, dict)`
- **文件**[`src/drone_planning/pipeline/composer.py`](../src/drone_planning/pipeline/composer.py)
### 5.3 是否调用 RAG
**否**。Composer 不直接调用 RAG只消费 Retriever 返回的 `rag_context`
### 5.4 是否调用工具
**否**
### 5.5 提示词组织
Prompt 由以下部分顺序拼接:
1. **指令**`_instruction`(来自 node_schema
2. **基准点坐标**:若 `base_location_coords` 非空,输出 `## 基准点坐标 base_location_coordsENU单位米`
3. **可用节点**:裁剪后的 schema JSONactions、conditions、control_flow、decorators
4. **RAG 地图上下文**`rag_context["map_context"]`
5. **RAG 规则约束**`rag_context["rule_context"]`
6. **RAG 示例行为树**`rag_context["few_shot_examples"]` 前 2 条
7. **坐标计算规则**:若用户只说绝对地点,可直接使用 base_location_coords若用户说相对位置必须调用 calculate_relative_coordinate禁止心算
### 5.6 意图到 Actions 映射
```python
INTENT_TO_ACTIONS = {
"fly_task": ["fly_to_waypoint", "fly_sequence", "move_direction"],
"search_task": ["search_pattern", "rotate_search"],
"track_task": ["track_object"],
"photo_task": ["take_photos"],
"interact_task": ["report_message", "manual_confirmation"],
}
```
---
## 6. Stage 4Layer 3 - Planner宏观规划
### 6.1 职责
- 根据 Composer 的 System Prompt 和用户文本,生成行为树 JSON
- 支持 Function CallingLLM 可自主调用 `calculate_relative_coordinate` 计算相对坐标
- 最多 5 轮 Tool 循环,无工具调用时解析最终 JSON 返回
### 6.2 对应代码
- **函数**`plan(system_prompt, user_text, tool_call_log) -> dict`
- **文件**[`src/drone_planning/pipeline/planner.py`](../src/drone_planning/pipeline/planner.py)
### 6.3 是否调用 RAG
**否**。Planner 只消费 Composer 输出的 `system_prompt`,其中已包含 RAG 上下文。
### 6.4 是否调用工具
**是**。坐标计算**仅由 LLM 负责**,通过 OpenAI Function Calling 调用 `calculate_relative_coordinate`mcp 工具。Planner 执行工具并追加结果到 messages继续下一轮推理。
**工具定义**`CALC_COORDINATE_TOOL`
```json
{
"type": "function",
"function": {
"name": "calculate_relative_coordinate",
"description": "根据基准点坐标和方向、距离,计算目标点的绝对 ENU 坐标...",
"parameters": {
"base_x": "number",
"base_y": "number",
"direction_str": "string",
"distance": "number"
}
}
}
```
**工具实现**[`src/drone_planning/tools/mcp_calc.py`](../src/drone_planning/tools/mcp_calc.py) 中的 `calculate_relative_coordinate`
### 6.5 提示词组织
- **System**Composer 生成的完整 Prompt含 schema、坐标、RAG 上下文、工具说明)
- **User**:用户原始指令
- 若 LLM 返回 `tool_calls`:追加 assistant message + 各 tool 结果,继续调用 `chat_completion_with_tools`
- 若 LLM 返回纯文本:解析 JSON 作为行为树
### 6.6 回退逻辑
- 若模型不支持 tools使用 `chat_completion_json` + `PLANNER_JSON_SCHEMA` 无工具模式
- 若 JSON 解析失败:使用 `chat_completion` + `response_format={"type": "json_object"}`
---
## 7. Stage 5Layer 4 - Execution执行包装
### 7.1 职责
- 将 Planner 的 JSON 递归解析为 py_trees 对象
- 根据 Blackboard `is_in_air` 判断:若在地面,自动在业务树前插入 `[SystemCheck -> Takeoff]` 子树
- 输出 ASCII 树结构供展示
### 7.2 对应代码
- **函数**`parse_json_to_tree`, `wrap_and_build_tree`, `tree_to_ascii`
- **文件**[`src/drone_planning/execution/tree_wrapper.py`](../src/drone_planning/execution/tree_wrapper.py)
- **依赖**[`core/blackboard.py`](../src/drone_planning/core/blackboard.py)、[`execution/nodes.py`](../src/drone_planning/execution/nodes.py)
### 7.3 是否调用 RAG
**否**
### 7.4 是否调用工具
**否**
### 7.5 节点映射
| JSON type | py_trees 实现 |
|-----------|---------------|
| Sequence | `py_trees.composites.Sequence` |
| Selector | `py_trees.composites.Selector` |
| Parallel | `py_trees.composites.Parallel` |
| fly_to_waypoint | `FlyToWaypointAction` |
| 其他 action | `GenericAction` |
---
## 8. 坐标计算流程(仅 LLM 负责)
```mermaid
flowchart LR
subgraph RAG [RAG 仅提供基准点]
Router1[Router 抽取 direction+distance]
Retriever1[Retriever 解析复合地点]
MapDB1[查 map_db 得基准坐标]
Composer1[Composer 将 base_location_coords 写入 Prompt]
end
subgraph LLM [LLM 调用 mcp 计算]
Planner1[Planner 收到 Prompt]
LLM1[LLM 调用 calculate_relative_coordinate]
Tool1[mcp 工具]
Result1[工具结果写回 messages]
end
Router1 --> Retriever1
Retriever1 --> MapDB1
MapDB1 --> Composer1
Composer1 --> Planner1
Planner1 --> LLM1
LLM1 --> Tool1
Tool1 --> Result1
```
- **RAG**:仅提供 `base_location_coords`(基准点坐标)和 `relative_descriptions`(相对描述列表),不调用 mcp_calc
- **LLM**:若用户说绝对地点,直接使用 base_location_coords若用户说相对位置必须调用 calculate_relative_coordinate禁止心算
---
## 9. RAG 数据格式
### 9.1 map_db.jsonl
每行一个 JSON字段示例
```json
{"document": "大门位于入口处,坐标 x=0, y=0", "location": "大门", "x": 0, "y": 0, "z": 0}
```
### 9.2 rule_db.jsonl
```json
{"document": "起飞必须先做系统检查", "intent": "fly_task", "priority": "high"}
```
### 9.3 few_shot_db.jsonl
```json
{"document": "飞到大门然后拍照", "intent": "fly_task", "tree_json": "{\"root\": {...}}"}
```
---
## 10. 环境变量与配置
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `LLM_CHAT_BASE_URL` | `http://localhost:8081/v1` | Chat API |
| `LLM_EMBEDDING_BASE_URL` | `http://localhost:8090/v1` | Embedding APIRAG |
| `ENABLE_THINKING` | `false` | 模型思考模式 |
| `CHROMA_PERSIST_PATH` | `./data/chroma` | ChromaDB 存储路径 |
---
## 11. 文件索引
| 功能 | 文件路径 |
|------|----------|
| Router | `src/drone_planning/pipeline/router.py` |
| Composer | `src/drone_planning/pipeline/composer.py` |
| Planner | `src/drone_planning/pipeline/planner.py` |
| RAG Retriever | `src/drone_planning/rag/retriever.py` |
| RAG 向量存储 | `src/drone_planning/rag/vector_store.py` |
| RAG 灌入 | `src/drone_planning/rag/ingestion.py` |
| 坐标计算工具 | `src/drone_planning/tools/mcp_calc.py` |
| 执行包装 | `src/drone_planning/execution/tree_wrapper.py` |
| 节点实现 | `src/drone_planning/execution/nodes.py` |
| 黑板 | `src/drone_planning/core/blackboard.py` |
| LLM 客户端 | `src/drone_planning/llm_client/client.py` |
| API 路由 | `src/drone_planning/api/routes.py` |
| 节点 Schema | `config/node_schema.json` |
| 知识库 | `data/knowledge/*.jsonl` |

43
main.py Normal file
View File

@@ -0,0 +1,43 @@
"""
无人机行为规划后端 - FastAPI 入口
启动(请用 python -m 确保使用当前 conda 环境的依赖):
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
RAG 数据灌入(首次使用或更新知识库后需执行):
python -m drone_planning.rag.ingestion
curl 示例:
curl -X POST http://localhost:8000/api/plan -H "Content-Type: application/json" -d '{"text": "飞到大门然后拍照"}'
curl -X POST http://localhost:8000/api/plan -H "Content-Type: application/json" -d '{"text": "起飞"}'
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from drone_planning.api.routes import router
app = FastAPI(
title="无人机行为规划后端",
description="从自然语言到行为树 JSON 的四层流水线",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
@app.get("/")
def root():
return {"service": "drone-planning", "status": "ok"}
@app.get("/health")
def health():
return {"status": "healthy"}

235
playground.py Normal file
View File

@@ -0,0 +1,235 @@
"""
可视化交互测试台 - Streamlit
左侧边栏:切换 Blackboard.is_in_air模拟在地面/空中)
主界面:输入自然语言指令,分步展示四层流水线输出。
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
# 确保 src 在路径中
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
import streamlit as st
from drone_planning.core.blackboard import DroneStateBlackboard
from drone_planning.execution.tree_wrapper import parse_json_to_tree, tree_to_ascii, wrap_and_build_tree
from drone_planning.pipeline.composer import _load_node_schema, build_system_prompt
from drone_planning.pipeline.planner import plan
from drone_planning.pipeline.router import route
from drone_planning.rag.retriever import RAGRetriever
st.set_page_config(page_title="无人机行为规划测试台", page_icon="🚁", layout="wide")
# 缩小环节耗时、intents、entities 等数据的字体
st.markdown("""
<style>
/* 环节耗时、intents、entities 等 metric 字体 */
div[data-testid="stMetric"] { font-size: 0.8rem !important; }
div[data-testid="stMetric"] label { font-size: 0.75rem !important; }
div[data-testid="stMetric"] [data-testid="stMetricValue"] { font-size: 0.85rem !important; }
/* JSON、code 块字体 */
div[data-testid="stJson"], .stJson { font-size: 0.8rem !important; }
div[data-testid="stCode"] code, .stCode code { font-size: 0.8rem !important; }
/* expander 内标题与内容 */
.streamlit-expanderHeader { font-size: 0.9rem !important; }
.streamlit-expanderContent { font-size: 0.85rem !important; }
/* caption 字体 */
.stCaptionContainer { font-size: 0.8rem !important; }
/* subheader 略小 */
h3 { font-size: 1rem !important; }
</style>
""", unsafe_allow_html=True)
st.title("🚁 无人机行为规划 - 四层流水线测试台")
st.caption("输入自然语言指令,观察 Layer 1~4 的逐步输出。切换「是否在空中」可验证 Layer 4 的起飞包装逻辑。")
# ---------------------------------------------------------------------------
# 左侧边栏Blackboard 状态
# ---------------------------------------------------------------------------
with st.sidebar:
st.header("⚙️ 黑板状态")
is_in_air = st.toggle("是否在空中 (is_in_air)", value=False, help="关=在地面,开=已起飞。关掉时 Layer 4 会在业务树前插入 SystemCheck + Takeoff")
bb = DroneStateBlackboard()
bb.is_in_air = is_in_air
st.info(f"当前: {'已起飞' if is_in_air else '在地面'}")
# ---------------------------------------------------------------------------
# 主界面:输入与执行
# ---------------------------------------------------------------------------
instruction = st.text_area("输入自然语言指令", height=80, placeholder="例如:飞到大门然后拍照、起飞、飞到广场然后搜索汽车")
run_text = instruction.strip() if instruction else ""
if run_text:
st.divider()
if st.button("执行规划", type="primary", key="run_plan"):
# 再次同步 blackboard用户可能在点击前改了侧边栏
bb = DroneStateBlackboard()
bb.is_in_air = is_in_air
layer1_result = None
rag_context = None
layer2_prompt = None
layer2_meta = None
tool_call_log: list[dict] = []
layer3_json = None
layer4_ascii = None
fast_path = False
error_msg = None
timing: dict[str, float] = {}
try:
# Layer 1: Router
t0 = time.perf_counter()
layer1_result = route(run_text)
timing["Layer1_Router"] = (time.perf_counter() - t0) * 1000
fast_path = layer1_result.is_fast_path
if fast_path:
st.success("✅ Layer 1 触发 Fast-Path 短路,后续层不执行")
else:
# RAG 检索(阶段二)
t0 = time.perf_counter()
retriever = RAGRetriever()
rag_context = retriever.retrieve_context(
intents=layer1_result.intents,
entities=layer1_result.entities,
user_text=run_text,
)
timing["RAG_检索"] = (time.perf_counter() - t0) * 1000
# Layer 2: Composer
t0 = time.perf_counter()
schema_json = _load_node_schema()
layer2_prompt, layer2_meta = build_system_prompt(
intents=layer1_result.intents,
entities=layer1_result.entities,
schema_json=schema_json,
rag_context=rag_context,
)
timing["Layer2_Composer"] = (time.perf_counter() - t0) * 1000
# Layer 3: Planner支持 Tool 循环)
t0 = time.perf_counter()
layer3_json = plan(
system_prompt=layer2_prompt,
user_text=run_text,
tool_call_log=tool_call_log,
)
timing["Layer3_Planner"] = (time.perf_counter() - t0) * 1000
# Layer 4: 解析 + 包装(会读取 blackboard.is_in_air
t0 = time.perf_counter()
root_node = layer3_json.get("root") or layer3_json
business_tree = parse_json_to_tree(root_node)
final_tree = wrap_and_build_tree(business_tree)
layer4_ascii = tree_to_ascii(final_tree)
timing["Layer4_Execution"] = (time.perf_counter() - t0) * 1000
except Exception as e:
error_msg = str(e)
import traceback
st.error(f"❌ 执行出错: {error_msg}")
st.code(traceback.format_exc(), language="text")
# -----------------------------------------------------------------------
# 各环节耗时
# -----------------------------------------------------------------------
if timing:
st.subheader("⏱️ 各环节耗时 (ms)")
cols = st.columns(len(timing))
for i, (name, ms) in enumerate(timing.items()):
with cols[i]:
st.metric(name, f"{ms:.0f} ms")
st.caption(f"总计: {sum(timing.values()):.0f} ms")
# -----------------------------------------------------------------------
# 分步展示
# -----------------------------------------------------------------------
st.subheader("📋 各层输出")
# Layer 1
with st.expander("**Layer 1 (Router)** - 意图与实体", expanded=True):
if layer1_result:
col1, col2, col3 = st.columns(3)
with col1:
st.metric("intents", ", ".join(layer1_result.intents) or "-")
with col2:
ent_str = json.dumps(layer1_result.entities, ensure_ascii=False)
st.metric("entities", ent_str[:60] + "..." if len(ent_str) > 60 else ent_str or "-")
with col3:
st.metric("Fast-Path", "" if fast_path else "")
st.json(layer1_result.entities)
else:
st.warning("Layer 1 未执行或出错")
if not fast_path:
# RAG 检索结果Layer 1 与 Layer 2 之间)
with st.expander("**RAG 检索结果** - 地图坐标、规则、Few-shot 示例", expanded=True):
if rag_context:
col1, col2 = st.columns(2)
with col1:
st.markdown("**地图坐标 (map_context)**")
if rag_context.get("map_context"):
st.code(rag_context["map_context"], language=None)
else:
st.caption("无匹配")
with col2:
st.markdown("**规则约束 (rule_context)**")
if rag_context.get("rule_context"):
st.code(rag_context["rule_context"], language=None)
else:
st.caption("无匹配")
st.markdown("**Few-shot 示例**")
if rag_context.get("few_shot_examples"):
for i, ex in enumerate(rag_context["few_shot_examples"]):
st.json({"instruction": ex.get("instruction"), "tree_json": ex.get("tree_json")})
else:
st.caption("无相似示例")
else:
st.caption("RAG 未执行")
# Layer 2
with st.expander("**Layer 2 (Composer)** - 精简 Prompt 与选中节点", expanded=True):
if layer2_meta:
st.metric("Prompt 长度", f"{layer2_meta.get('prompt_length', 0)} 字符")
st.metric("选中节点", ", ".join(layer2_meta.get("selected_actions", [])))
st.metric("基准点坐标", json.dumps(layer2_meta.get("base_location_coords", {}), ensure_ascii=False))
if layer2_prompt:
preview = layer2_prompt[:2000] + ("..." if len(layer2_prompt) > 2000 else "")
st.code(preview, language="text")
# 坐标计算与 Tool Call 日志(仅 LLM 通过 mcp 工具计算)
rel_descs = (rag_context or {}).get("relative_descriptions") or []
has_any = bool(rel_descs) or bool(tool_call_log)
with st.expander("**坐标计算与 Tool Call 日志**", expanded=has_any):
if rel_descs:
st.markdown("**RAG 提供的相对描述**(需 LLM 调用 calculate_relative_coordinate")
for rd in rel_descs:
st.json(rd)
if tool_call_log:
st.markdown("**LLM Tool Call**(大模型调用 mcp 计算)")
for entry in tool_call_log:
st.markdown(f"{entry.get('round', '?')} 次 `{entry.get('tool', '')}`")
st.json({"arguments": entry.get("arguments"), "result": entry.get("result")})
if not rel_descs and not tool_call_log:
st.caption("无相对描述(指令仅含绝对地点时,直接使用 base_location_coords")
# Layer 3
with st.expander("**Layer 3 (Planner)** - 原生业务树 JSON", expanded=True):
if layer3_json:
st.json(layer3_json)
else:
st.warning("Layer 3 未执行")
# Layer 4
with st.expander("**Layer 4 (Execution)** - 安全包装后的 py_trees ASCII 结构", expanded=True):
if layer4_ascii:
st.text("当 is_in_air=False 时,此处应看到 [Safe_Execution] -> [SystemCheck] -> [Takeoff] -> 业务树")
st.code(layer4_ascii, language=None)
else:
st.warning("Layer 4 未执行")

26
pyproject.toml Normal file
View File

@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "drone-planning"
version = "0.1.0"
description = "无人机行为规划后端系统 - 从自然语言到行为树"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.109.0",
"uvicorn>=0.27.0",
"httpx>=0.26.0",
"py_trees>=2.2.0",
"openai>=1.0.0",
"pydantic>=2.5.0",
"streamlit>=1.29.0",
]
[project.optional-dependencies]
dev = ["pytest>=7.0"]
[tool.setuptools.packages.find]
where = ["src"]
include = ["drone_planning*"]

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
# 无人机行为规划后端系统 - 依赖清单
# 阶段一 + 阶段二 RAG
fastapi>=0.109.0
uvicorn>=0.27.0
httpx>=0.26.0
py_trees>=2.2.0
openai>=1.0.0
pydantic>=2.5.0
streamlit>=1.29.0
chromadb>=0.4.0

36
run_api.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# 启动 LLM 服务 + DronePlanning API
# - Chat 模型8081
# - Embedding 模型8090
# - API8000
cd "$(dirname "$0")"
# llama-server 所在目录(可在该目录下启动模型)
LLAMA_BIN_DIR="${LLAMA_BIN_DIR:-$HOME/llama.cpp/build/bin}"
cleanup() {
echo ""
echo "正在停止服务..."
kill $LLAMA_CHAT_PID $LLAMA_EMBED_PID 2>/dev/null
exit 0
}
trap cleanup SIGINT SIGTERM
# 启动 Chat 模型 (8081)
echo "=== 启动 LLM Chat 服务 (端口 8081) ==="
(cd "$LLAMA_BIN_DIR" && ./llama-server -m ~/models/gguf/Qwen3/Qwen3-4B/Qwen3-4B-Q5_K_M.gguf --port 8081 --gpu_layers 36 --host 0.0.0.0) &
LLAMA_CHAT_PID=$!
# 启动 Embedding 模型 (8090)
echo "=== 启动 LLM Embedding 服务 (端口 8090) ==="
(cd "$LLAMA_BIN_DIR" && ./llama-server -m ~/models/gguf/Qwen3/Qwen3-Embedding-4B/Qwen3-Embedding-4B-Q5_K_M.gguf --gpu_layers 36 --embeddings --port 8090 --host 0.0.0.0) &
LLAMA_EMBED_PID=$!
# 等待 LLM 服务就绪
echo "等待 LLM 服务启动..."
sleep 8
# 启动 DronePlanning API (8000)
echo "=== 启动 DronePlanning API (端口 8000) ==="
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,6 @@
"""
无人机行为规划后端系统
从自然语言到无人机行为树 JSON 的四层流水线
"""
__version__ = "0.1.0"

View File

@@ -0,0 +1 @@
"""API 模块"""

View File

@@ -0,0 +1,115 @@
"""
API 路由 - POST /api/plan
全链路Router -> (Fast-Path 短路) | Composer -> Planner -> Tree Wrapper
"""
from __future__ import annotations
import time
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from drone_planning.execution.tree_wrapper import parse_json_to_tree, tree_to_ascii, wrap_and_build_tree
from drone_planning.pipeline.composer import _load_node_schema, build_system_prompt
from drone_planning.pipeline.planner import plan
from drone_planning.pipeline.router import route
from drone_planning.rag.retriever import RAGRetriever
router = APIRouter(prefix="/api", tags=["planning"])
class PlanRequest(BaseModel):
text: str = ""
@router.post("/plan")
def api_plan(body: PlanRequest) -> dict[str, Any]:
"""
自然语言 -> 行为树规划
请求体: {"text": "用户自然语言指令"}
"""
user_text = body.text or ""
timing: dict[str, float] = {}
try:
# Layer 1: 意图路由
t0 = time.perf_counter()
router_result = route(user_text)
timing["Layer1_Router"] = (time.perf_counter() - t0) * 1000
if router_result.is_fast_path:
return {
"success": True,
"fast_path": True,
"intents": router_result.intents,
"entities": router_result.entities,
"message": "已短路,直接执行原子命令",
"tree_json": None,
"tree_ascii": None,
"timing_ms": timing,
}
# RAG 检索(阶段二)
t0 = time.perf_counter()
retriever = RAGRetriever()
rag_context = retriever.retrieve_context(
intents=router_result.intents,
entities=router_result.entities,
user_text=user_text,
)
timing["RAG_检索"] = (time.perf_counter() - t0) * 1000
# Layer 2: Composer
t0 = time.perf_counter()
schema_json = _load_node_schema()
system_prompt, composer_meta = build_system_prompt(
intents=router_result.intents,
entities=router_result.entities,
schema_json=schema_json,
rag_context=rag_context,
)
timing["Layer2_Composer"] = (time.perf_counter() - t0) * 1000
# Layer 3: Planner支持 Tool 循环)
t0 = time.perf_counter()
tool_call_log: list[dict[str, Any]] = []
tree_json = plan(
system_prompt=system_prompt,
user_text=user_text,
tool_call_log=tool_call_log,
)
timing["Layer3_Planner"] = (time.perf_counter() - t0) * 1000
# Layer 4: 解析 + 包装
t0 = time.perf_counter()
root_node = tree_json.get("root") or tree_json
if isinstance(root_node, dict):
business_tree = parse_json_to_tree(root_node)
else:
raise ValueError("Planner 返回的 root 格式异常")
final_tree = wrap_and_build_tree(business_tree)
tree_ascii = tree_to_ascii(final_tree)
timing["Layer4_Execution"] = (time.perf_counter() - t0) * 1000
return {
"success": True,
"fast_path": False,
"intents": router_result.intents,
"entities": router_result.entities,
"rag_context": rag_context,
"tool_call_log": tool_call_log,
"tree_json": tree_json,
"tree_ascii": tree_ascii,
"composer_meta": composer_meta,
"timing_ms": timing,
}
except FileNotFoundError as e:
raise HTTPException(status_code=500, detail=f"配置缺失: {e}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"规划失败: {e}")

View File

@@ -0,0 +1 @@
"""核心模块:黑板、状态等"""

View File

@@ -0,0 +1,97 @@
"""
Layer 0感知层 - 全局状态黑板
维护无人机当前状态,供 Layer 4 执行包装层判断是否需要自动插入起飞逻辑。
后续可扩展:电量、模式、传感器状态等。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class Position:
"""ENU 坐标系下的位置(东-北-天)"""
x: float
y: float
z: float
def to_dict(self) -> dict[str, float]:
"""转换为字典,便于 JSON 序列化"""
return {"x": self.x, "y": self.y, "z": self.z}
@classmethod
def from_dict(cls, d: dict[str, Any]) -> Position:
"""从字典创建"""
return cls(x=float(d["x"]), y=float(d["y"]), z=float(d["z"]))
class DroneStateBlackboard:
"""
无人机状态黑板(全局单例)
供 Layer 4 的 wrap_and_build_tree 判断:
- 若 is_in_air == False自动在业务树前插入 [SystemCheck -> Takeoff] 子树
- 若 is_in_air == True直接执行业务树
"""
_instance: DroneStateBlackboard | None = None
def __new__(cls) -> DroneStateBlackboard:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self) -> None:
# 避免重复初始化覆盖已有状态
if hasattr(self, "_initialized") and self._initialized:
return
self._initialized = True
self._is_in_air: bool = False
self._position: Position = Position(0.0, 0.0, 0.0)
# 预留扩展字段
self._extra: dict[str, Any] = {}
@property
def is_in_air(self) -> bool:
"""是否在空中True=已起飞False=在地面)"""
return self._is_in_air
@is_in_air.setter
def is_in_air(self, value: bool) -> None:
self._is_in_air = value
@property
def position(self) -> Position:
"""当前 ENU 坐标"""
return self._position
@position.setter
def position(self, value: Position) -> None:
self._position = value
def set_position(self, x: float, y: float, z: float) -> None:
"""便捷设置位置"""
self._position = Position(x=x, y=y, z=z)
def get_position_dict(self) -> dict[str, float]:
"""获取位置字典 {x, y, z}"""
return self._position.to_dict()
def set_extra(self, key: str, value: Any) -> None:
"""设置扩展字段(如 battery, mode"""
self._extra[key] = value
def get_extra(self, key: str, default: Any = None) -> Any:
"""获取扩展字段"""
return self._extra.get(key, default)
def reset(self) -> None:
"""重置为地面初始状态(用于测试或仿真重置)"""
self._is_in_air = False
self._position = Position(0.0, 0.0, 0.0)
self._extra.clear()

View File

@@ -0,0 +1 @@
"""执行层py_trees 节点与树包装"""

View File

@@ -0,0 +1,71 @@
"""
Layer 4执行层动作库 - Mock 节点实现
继承 py_trees.behaviour.Behaviourupdate() 中打印中文日志并返回 SUCCESS。
后续预留 ROS2/PX4 接口。
"""
from __future__ import annotations
from py_trees import common
from py_trees.behaviour import Behaviour
class SystemCheckCondition(Behaviour):
"""系统检查条件:模拟起飞前自检"""
def __init__(self, name: str = "SystemCheck"):
super().__init__(name)
def update(self) -> common.Status:
self.logger.info("[SystemCheck] 执行系统检查:电池、传感器、通信... OK")
return common.Status.SUCCESS
class TakeoffAction(Behaviour):
"""起飞动作"""
def __init__(self, name: str = "Takeoff"):
super().__init__(name)
def update(self) -> common.Status:
self.logger.info("[Takeoff] 执行起飞... OK")
return common.Status.SUCCESS
class LandAction(Behaviour):
"""降落动作"""
def __init__(self, name: str = "Land"):
super().__init__(name)
def update(self) -> common.Status:
self.logger.info("[Land] 执行降落... OK")
return common.Status.SUCCESS
class FlyToWaypointAction(Behaviour):
"""飞往航点动作"""
def __init__(self, name: str, x: float, y: float, z: float):
super().__init__(name)
self.x = x
self.y = y
self.z = z
def update(self) -> common.Status:
self.logger.info(f"[FlyToWaypoint] 飞往 ({self.x}, {self.y}, {self.z})... OK")
return common.Status.SUCCESS
class GenericAction(Behaviour):
"""通用动作兜底:用于未单独实现的 action 类型"""
def __init__(self, name: str, action_type: str, params: dict | None = None):
super().__init__(name)
self.action_type = action_type
self.params = params or {}
def update(self) -> common.Status:
self.logger.info(f"[GenericAction] {self.action_type} params={self.params}... OK")
return common.Status.SUCCESS

View File

@@ -0,0 +1,107 @@
"""
Layer 4执行包装与安全逻辑
- parse_json_to_tree: 将 Planner 的 JSON 递归解析为 py_trees 对象
- wrap_and_build_tree: 根据 is_in_air 自动插入 [SystemCheck -> Takeoff] 子树
- tree_to_ascii: 将树以 ASCII/Unicode 文本形式打印
"""
from __future__ import annotations
from typing import Any
import py_trees
from py_trees import display
from drone_planning.core.blackboard import DroneStateBlackboard
from drone_planning.execution.nodes import (
FlyToWaypointAction,
GenericAction,
SystemCheckCondition,
TakeoffAction,
)
def parse_json_to_tree(node_dict: dict[str, Any]) -> py_trees.behaviour.Behaviour:
"""
递归将 JSON 节点转换为 py_trees 对象
Args:
node_dict: 单节点 dict含 type、name、params、children
Returns:
py_trees.Behaviour 实例
"""
node_type = node_dict.get("type", "GenericAction")
name = node_dict.get("name") or node_type
params = node_dict.get("params") or {}
children_data = node_dict.get("children") or []
# 控制流节点
if node_type == "Sequence":
children = [parse_json_to_tree(c) for c in children_data]
return py_trees.composites.Sequence(name=name, memory=True, children=children)
if node_type == "Selector":
children = [parse_json_to_tree(c) for c in children_data]
return py_trees.composites.Selector(name=name, memory=True, children=children)
if node_type == "Parallel":
policy = params.get("policy", "success_on_all")
children = [parse_json_to_tree(c) for c in children_data]
return py_trees.composites.Parallel(
name=name,
policy=py_trees.common.ParallelPolicy.SUCCESS_ON_ALL
if policy == "success_on_all"
else py_trees.common.ParallelPolicy.SUCCESS_ON_ONE,
children=children,
)
# 动作节点
if node_type == "fly_to_waypoint":
x = float(params.get("x", 0))
y = float(params.get("y", 0))
z = float(params.get("z", 0))
return FlyToWaypointAction(name=name, x=x, y=y, z=z)
# 其他 action 用 GenericAction 兜底
return GenericAction(name=name, action_type=node_type, params=params)
def wrap_and_build_tree(business_tree: py_trees.behaviour.Behaviour) -> py_trees.behaviour.Behaviour:
"""
安全包装:若未在空中,自动在业务树前插入 [SystemCheck -> Takeoff]
Args:
business_tree: Planner 生成的业务树(已解析为 py_trees
Returns:
最终可执行的完整树
"""
bb = DroneStateBlackboard()
if bb.is_in_air:
return business_tree
# 在地面:必须先生成系统检查 -> 起飞 -> 业务树
safe_sequence = py_trees.composites.Sequence(
name="Safe_Execution",
memory=True,
children=[
SystemCheckCondition(),
TakeoffAction(),
business_tree,
],
)
return safe_sequence
def tree_to_ascii(root: py_trees.behaviour.Behaviour, show_status: bool = True) -> str:
"""
将行为树以 ASCII/Unicode 文本形式打印
Args:
root: 树根节点
show_status: 是否显示状态
Returns:
可打印的字符串
"""
return display.unicode_tree(root=root, show_status=show_status)

View File

@@ -0,0 +1 @@
"""LLM 客户端:对接 llama-serverOpenAI 兼容 API"""

View File

@@ -0,0 +1,152 @@
"""
LLM 客户端 - 对接 llama-serverOpenAI 兼容 API
- Chat 推理:默认 http://localhost:8081/v1
- 支持 response_format 的 json_schema 结构化输出
- 环境变量LLM_BASE_URL默认 8081、OPENAI_API_KEY可为空本地部署通常不需要
"""
from __future__ import annotations
import json
import os
from typing import Any
from openai import OpenAI
# 环境变量配置,带默认值
LLM_CHAT_BASE_URL = os.getenv("LLM_CHAT_BASE_URL", "http://localhost:8081/v1")
LLM_EMBEDDING_BASE_URL = os.getenv("LLM_EMBEDDING_BASE_URL", "http://localhost:8090/v1")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "not-needed") # 本地 llama-server 通常不需要
# 思考模式:默认关闭,测试时减少延迟
ENABLE_THINKING = os.getenv("ENABLE_THINKING", "false").lower() in ("true", "1", "yes")
EXTRA_BODY = {"chat_template_kwargs": {"enable_thinking": ENABLE_THINKING}}
def get_chat_client() -> OpenAI:
"""获取 Chat 推理客户端8081 端口)"""
return OpenAI(
base_url=LLM_CHAT_BASE_URL,
api_key=OPENAI_API_KEY,
)
def chat_completion(
messages: list[dict[str, Any]],
model: str = "qwen3",
temperature: float = 0.3,
response_format: dict[str, Any] | None = None,
) -> str:
"""
调用 Chat Completion API
Args:
messages: 消息列表 [{"role": "system/user/assistant", "content": "..."}]
model: 模型名llama-server 通常忽略,可传任意值
temperature: 温度,低值更确定性
response_format: 可选,如 {"type": "json_schema", "json_schema": {...}}
用于强制输出符合 JSON Schema 的结构
Returns:
assistant 的 content 文本
"""
client = get_chat_client()
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"extra_body": EXTRA_BODY,
}
if response_format is not None:
kwargs["response_format"] = response_format
response = client.chat.completions.create(**kwargs)
content = response.choices[0].message.content
return content or ""
def chat_completion_with_tools(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
model: str = "qwen3",
temperature: float = 0.2,
) -> Any:
"""
调用 Chat Completion支持 toolsFunction Calling
返回完整 response 对象,便于检查 tool_calls。
注意:使用 tools 时通常不能同时使用 response_format。
"""
client = get_chat_client()
return client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
temperature=temperature,
extra_body=EXTRA_BODY,
)
def chat_completion_json(
messages: list[dict[str, str]],
json_schema: dict[str, Any],
schema_name: str = "response",
model: str = "qwen3",
temperature: float = 0.3,
) -> dict[str, Any]:
"""
调用 Chat Completion并强制返回符合 JSON Schema 的结构化输出
使用 OpenAI SDK 的 response_format={"type": "json_schema"} 确保输出格式正确。
若 llama-server 不支持 json_schema会回退到 json_object 模式并手动解析。
Args:
messages: 消息列表
json_schema: JSON Schema 定义(符合 OpenAI Structured Outputs 规范)
schema_name: schema 名称
model: 模型名
temperature: 温度
Returns:
解析后的 dict
"""
response_format = {
"type": "json_schema",
"json_schema": {
"name": schema_name,
"strict": True,
"schema": json_schema,
},
}
try:
content = chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=response_format,
)
except Exception as e:
# 部分本地服务器可能不支持 json_schema回退到 json_object
if "json_schema" in str(e).lower() or "response_format" in str(e).lower():
content = chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format={"type": "json_object"},
)
else:
raise e
# 去除可能的 markdown 代码块包裹
text = content.strip()
if text.startswith("```"):
lines = text.split("\n")
if lines[0].startswith("```json"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines)
return json.loads(text)

View File

@@ -0,0 +1 @@
"""流水线模块:意图路由、动态组装、宏观规划"""

View File

@@ -0,0 +1,171 @@
"""
Layer 2动态组装与外部计算Dynamic Composer
根据 intents 和 entities 裁剪 node_schema解析地点坐标生成发给 Stage 2 LLM 的精简 System Prompt。
阶段一rag_context 默认 None阶段二接入 RAG 后传入上下文。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from drone_planning.pipeline.router import BUSINESS_INTENTS
# 意图 -> 相关 actions 映射(用于裁剪 schema
INTENT_TO_ACTIONS: dict[str, list[str]] = {
"fly_task": ["fly_to_waypoint", "fly_sequence", "move_direction"],
"search_task": ["search_pattern", "rotate_search"],
"track_task": ["track_object"],
"photo_task": ["take_photos"],
"interact_task": ["report_message", "manual_confirmation"],
}
# 默认包含的 control_flow 和 decoratorsLLM 规划必需)
DEFAULT_CONTROL_FLOW = ["Sequence", "Selector", "Parallel"]
DEFAULT_DECORATORS = ["Timeout", "Repeat"]
DEFAULT_CONDITIONS = ["object_detected"]
def _load_node_schema() -> dict[str, Any]:
"""加载 config/node_schema.json"""
base = Path(__file__).resolve().parent.parent.parent.parent
schema_path = base / "config" / "node_schema.json"
if not schema_path.exists():
raise FileNotFoundError(f"node_schema.json 未找到: {schema_path}")
with open(schema_path, "r", encoding="utf-8") as f:
return json.load(f)
def extract_actions_for_intents(intents: list[str], schema_json: dict[str, Any]) -> dict[str, Any]:
"""
根据 intents 裁剪出最小必要的 actions放入最终 schema
Args:
intents: 业务意图列表
schema_json: 原始 node_schema.json 内容
Returns:
裁剪后的 schema 字典,包含 _instruction、actions、conditions、control_flow、decorators
"""
actions_pool = schema_json.get("actions", {})
conditions_pool = schema_json.get("conditions", {})
control_flow_pool = schema_json.get("control_flow", {})
decorators_pool = schema_json.get("decorators", {})
# 收集需要的 action 名称
needed_actions: set[str] = set()
for intent in intents:
if intent in BUSINESS_INTENTS and intent in INTENT_TO_ACTIONS:
needed_actions.update(INTENT_TO_ACTIONS[intent])
if not needed_actions:
# 兜底:至少包含 fly_task 相关
needed_actions = set(INTENT_TO_ACTIONS.get("fly_task", ["fly_to_waypoint"]))
# 裁剪 actions
trimmed_actions = {k: v for k, v in actions_pool.items() if k in needed_actions}
if not trimmed_actions:
trimmed_actions = {"fly_to_waypoint": actions_pool.get("fly_to_waypoint", {})}
# 保留完整的 control_flow、decorators、conditions
trimmed_control = {k: control_flow_pool[k] for k in DEFAULT_CONTROL_FLOW if k in control_flow_pool}
trimmed_decorators = {k: decorators_pool[k] for k in DEFAULT_DECORATORS if k in decorators_pool}
trimmed_conditions = {k: conditions_pool[k] for k in DEFAULT_CONDITIONS if k in conditions_pool}
return {
"_instruction": schema_json.get("_instruction", "作为行为树规划器,你只能使用以下定义的节点和参数。"),
"actions": trimmed_actions,
"conditions": trimmed_conditions,
"control_flow": trimmed_control,
"decorators": trimmed_decorators,
}
def build_system_prompt(
intents: list[str],
entities: dict[str, Any],
schema_json: dict[str, Any],
rag_context: dict[str, Any] | None = None,
) -> tuple[str, dict[str, Any]]:
"""
构建发给 Stage 2 LLM 的精简 System Prompt
Args:
intents: 业务意图列表
entities: 实体字典(含 locations、targets 等)
schema_json: 原始 node_schema
rag_context: RAG 检索上下文,阶段一默认 None
Returns:
(prompt_text, metadata) 其中 metadata 含 selected_actions、location_coords 等
"""
metadata: dict[str, Any] = {"selected_actions": [], "base_location_coords": {}}
# 1. 裁剪 schema
trimmed_schema = extract_actions_for_intents(intents, schema_json)
metadata["selected_actions"] = list(trimmed_schema.get("actions", {}).keys())
# 2. 基准点坐标RAG 仅提供,不计算相对位置)
base_coords = (rag_context or {}).get("base_location_coords") or {}
base_location_coords: dict[str, dict[str, float]] = {k: dict(v) for k, v in base_coords.items()}
metadata["base_location_coords"] = base_location_coords
relative_descriptions = (rag_context or {}).get("relative_descriptions") or []
# 3. 拼接 prompt 文本
parts: list[str] = []
# 指令
parts.append(trimmed_schema["_instruction"])
parts.append("")
# 基准点坐标(若有)
if base_location_coords:
parts.append("## 基准点坐标 base_location_coordsENU单位米")
for loc, coord in base_location_coords.items():
parts.append(f"- {loc}: x={coord['x']}, y={coord['y']}, z={coord['z']}")
parts.append("")
# 节点定义
parts.append("## 可用节点")
parts.append(json.dumps(trimmed_schema, ensure_ascii=False, indent=2))
parts.append("")
# RAG 区块(阶段二)
if rag_context:
if rag_context.get("map_context"):
parts.append("## RAG 地图上下文")
parts.append(rag_context["map_context"])
parts.append("")
if rag_context.get("rule_context"):
parts.append("## RAG 规则约束")
parts.append(rag_context["rule_context"])
parts.append("")
if rag_context.get("few_shot_examples"):
parts.append("## RAG 示例行为树")
for ex in rag_context["few_shot_examples"][:2]:
parts.append(f"指令: {ex.get('instruction', '')}")
parts.append(f"树: {json.dumps(ex.get('tree_json', {}), ensure_ascii=False)}")
parts.append("")
# 坐标计算规则(仅 LLM 通过 mcp 工具计算RAG 不负责)
parts.append("## 坐标计算规则")
parts.append(
"若用户只说绝对地点(如'去广场'),可直接使用 base_location_coords 中的坐标生成行为树。"
"若用户说相对位置(如'广场东边500米''大门北偏东30度100米'"
"必须调用 calculate_relative_coordinate 工具,禁止心算。"
)
if relative_descriptions:
parts.append("")
parts.append("以下为相对描述,需调用工具计算:")
for rd in relative_descriptions:
base = rd.get("base", "")
coord = base_location_coords.get(base, {})
x, y = coord.get("x", 0), coord.get("y", 0)
parts.append(f"- {rd.get('target', '')}:基准 {base}(x={x}, y={y}),方向 {rd.get('direction', '')},距离 {rd.get('distance', 0)}")
parts.append("")
parts.append("请根据用户指令,输出合法的行为树 JSON根节点为 root格式如 {\"type\": \"Sequence\", \"children\": [...]}")
prompt_text = "\n".join(parts)
metadata["prompt_length"] = len(prompt_text)
return prompt_text, metadata

View File

@@ -0,0 +1,204 @@
"""
Layer 3宏观行为规划Stage 2 Macro Planner
调用 LLM 根据 Composer 的 System Prompt 和用户文本,生成业务行为树 JSON。
支持 Function Calling大模型可自主调用 calculate_relative_coordinate 进行相对坐标计算。
"""
from __future__ import annotations
import json
from typing import Any, Callable
from drone_planning.llm_client.client import chat_completion, chat_completion_json, chat_completion_with_tools
from drone_planning.tools.mcp_calc import calculate_relative_coordinate
# OpenAI tools 定义:坐标计算工具
CALC_COORDINATE_TOOL = {
"type": "function",
"function": {
"name": "calculate_relative_coordinate",
"description": "根据基准点坐标和方向、距离,计算目标点的绝对 ENU 坐标。用于处理「广场东边500米」「大门北偏东30度100米」等相对描述。",
"parameters": {
"type": "object",
"properties": {
"base_x": {"type": "number", "description": "基准点东向坐标(米)"},
"base_y": {"type": "number", "description": "基准点北向坐标(米)"},
"direction_str": {
"type": "string",
"description": "方向,如 east/东、northeast/东北、北偏东30度",
},
"distance": {"type": "number", "description": "距离(米)"},
},
"required": ["base_x", "base_y", "direction_str", "distance"],
},
},
}
PLANNER_TOOLS = [CALC_COORDINATE_TOOL]
# 回退模式用的 JSON Schema
PLANNER_JSON_SCHEMA = {
"type": "object",
"properties": {
"root": {
"type": "object",
"properties": {
"type": {"type": "string"},
"name": {"type": ["string", "null"]},
"params": {"type": "object"},
"children": {"type": "array", "items": {"type": "object"}},
},
"required": ["type"],
"additionalProperties": True,
},
},
"required": ["root"],
"additionalProperties": False,
}
# 工具名 -> 执行函数
TOOL_HANDLERS: dict[str, Callable[..., Any]] = {
"calculate_relative_coordinate": lambda **kw: calculate_relative_coordinate(
base_x=kw["base_x"],
base_y=kw["base_y"],
direction_str=kw["direction_str"],
distance=kw["distance"],
),
}
def _execute_tool(name: str, arguments: dict[str, Any]) -> Any:
"""执行工具调用,返回结果"""
handler = TOOL_HANDLERS.get(name)
if not handler:
return {"error": f"未知工具: {name}"}
try:
return handler(**arguments)
except Exception as e:
return {"error": str(e)}
def plan(
system_prompt: str,
user_text: str,
tool_call_log: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""
调用 LLM 生成行为树 JSON支持 Tool 循环
Args:
system_prompt: Composer 生成的 System Prompt
user_text: 用户自然语言指令
tool_call_log: 可选,用于收集 Tool Call 交互日志(供 Playground 展示)
Returns:
行为树 dict格式 {"root": {"type": "...", "children": [...], ...}}
"""
log = tool_call_log if tool_call_log is not None else []
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text.strip() or "请生成行为树"},
]
max_tool_rounds = 5
for _ in range(max_tool_rounds):
try:
response = chat_completion_with_tools(
messages=messages,
tools=PLANNER_TOOLS,
temperature=0.2,
)
except Exception as e:
# 若模型不支持 tools回退到无工具模式
return _plan_fallback(system_prompt, user_text)
choice = response.choices[0]
msg = choice.message
if msg.tool_calls:
# 有工具调用:执行并追加 assistant + 各 tool 结果
messages.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in msg.tool_calls
],
})
for tc in msg.tool_calls:
name = tc.function.name
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
result = _execute_tool(name, args)
log.append({
"round": len(log) + 1,
"tool": name,
"arguments": args,
"result": result,
})
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result, ensure_ascii=False),
})
continue
# 无工具调用:解析最终行为树
content = msg.content or ""
if not content.strip():
return _plan_fallback(system_prompt, user_text)
text = content.strip()
if text.startswith("```"):
lines = text.split("\n")
if lines and "json" in lines[0].lower():
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines)
try:
return json.loads(text)
except json.JSONDecodeError:
return _plan_fallback(system_prompt, user_text)
# 超过最大轮次,回退
return _plan_fallback(system_prompt, user_text)
def _plan_fallback(system_prompt: str, user_text: str) -> dict[str, Any]:
"""回退:无工具模式"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text.strip() or "请生成行为树"},
]
try:
raw = chat_completion_json(
messages=messages,
json_schema=PLANNER_JSON_SCHEMA,
schema_name="behavior_tree",
temperature=0.2,
)
return raw
except Exception:
content = chat_completion(
messages=messages,
temperature=0.2,
response_format={"type": "json_object"},
)
text = content.strip()
if text.startswith("```"):
lines = text.split("\n")
if lines and "json" in lines[0].lower():
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines)
return json.loads(text)

View File

@@ -0,0 +1,205 @@
"""
Layer 1意图路由层Stage 1 Intent Router
调用 LLM 做极简意图分类和实体抽取,实现 Fast-Path 短路与冲突处理。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from drone_planning.llm_client.client import chat_completion_json
# ---------------------------------------------------------------------------
# 意图集合定义(与需求文档保持一致)
# ---------------------------------------------------------------------------
ATOMIC_INTENTS = {"atomic_takeoff", "atomic_land", "atomic_hover"}
BUSINESS_INTENTS = {"fly_task", "search_task", "track_task", "photo_task", "interact_task"}
ALL_VALID_INTENTS = ATOMIC_INTENTS | BUSINESS_INTENTS
# 兜底意图:当 intents 为空或包含未识别标签时使用
FALLBACK_INTENTS = ["fly_task", "search_task"]
# ---------------------------------------------------------------------------
# Router 输出结构
# ---------------------------------------------------------------------------
ROUTER_JSON_SCHEMA = {
"type": "object",
"properties": {
"intents": {
"type": "array",
"items": {"type": "string"},
"description": "意图列表,仅使用 atomic_takeoff/atomic_land/atomic_hover 或 fly_task/search_task/track_task/photo_task/interact_task",
},
"entities": {
"type": "object",
"properties": {
"locations": {
"type": "array",
"items": {"type": "string"},
"description": "地点实体列表,如 ['大门','广场']",
},
"targets": {
"type": "array",
"items": {"type": "string"},
"description": "目标实体列表,如 ['汽车','行人']",
},
"direction": {"type": "string", "description": "方向:东/east、西/west、南/south、北/north、东北/northeast 等,或 front|back|left|right|up|down"},
"distance": {"type": "number", "description": "距离如「东边500米」中的 500"},
},
"additionalProperties": True,
"description": "抽取的实体",
},
},
"required": ["intents", "entities"],
"additionalProperties": False,
}
ROUTER_SYSTEM_PROMPT = """你是指令意图分类器。根据用户自然语言,输出意图和实体。
## 意图集合(只能使用以下标签,不要自创)
**原子意图(仅用于简单控制指令):**
- atomic_takeoff起飞
- atomic_land降落
- atomic_hover悬停
**业务意图:**
- fly_task空间移动、路径、巡逻、飞到某地
- search_task搜索、侦查
- track_task跟踪
- photo_task拍照
- interact_task上报、请求确认
## 实体要求
- locations基地点列表如 ["大门","广场"]。对于「广场东边500米」只填 ["广场"],不要填 "广场东边500米"
- targets目标列表如 ["汽车","行人","公交车"]
- direction东/east、西/west、南/south、北/north、东北/northeast 等,或 front|back|left|right|up|down
- distance数字如「东边500米」中的 500
## 规则
1. 若用户只说"起飞""降落""悬停",只输出对应 atomic 意图entities 可为空对象。
2. 若用户说复杂任务(如"飞到大门然后拍照"),输出业务意图,并抽取 locations、targets 等。
3. 若同时包含原子和业务(如"起飞后去广场"),两者都输出,由系统后续处理。
4. 对于「广场东边500米」「大门北偏东30度100米」等相对描述必须拆分locations=["广场"], direction="", distance=500
5. 不要输出未在意图集合中的标签。"""
@dataclass
class RouterResult:
"""意图路由结果"""
intents: list[str] = field(default_factory=list)
entities: dict[str, Any] = field(default_factory=dict)
is_fast_path: bool = False
raw_llm_output: dict[str, Any] | None = None
def get_locations(self) -> list[str]:
"""获取地点列表,保证返回 list"""
locs = self.entities.get("locations")
if isinstance(locs, list):
return [str(x) for x in locs]
if locs is not None:
return [str(locs)]
return []
def get_targets(self) -> list[str]:
"""获取目标列表,保证返回 list"""
tgs = self.entities.get("targets")
if isinstance(tgs, list):
return [str(x) for x in tgs]
if tgs is not None:
return [str(tgs)]
return []
def _resolve_conflicts(intents: list[str]) -> list[str]:
"""
硬编码冲突处理逻辑
- 若 intents 同时包含 atomic 和 business删除所有 atomic仅保留 business
- 若 intents 全为 atomic保持不变由上层判断 Fast-Path
- 若 intents 为空或包含未识别标签:兜底为 FALLBACK_INTENTS
"""
if not intents:
return list(FALLBACK_INTENTS)
# 过滤掉未识别的标签
valid = [i for i in intents if i in ALL_VALID_INTENTS]
if not valid:
return list(FALLBACK_INTENTS)
has_atomic = any(i in ATOMIC_INTENTS for i in valid)
has_business = any(i in BUSINESS_INTENTS for i in valid)
# 若同时包含 atomic 和 business删除 atomic仅保留 business
if has_atomic and has_business:
return [i for i in valid if i in BUSINESS_INTENTS]
return valid
def route(user_text: str) -> RouterResult:
"""
意图路由主入口
Args:
user_text: 用户自然语言指令
Returns:
RouterResult包含 intents、entities、is_fast_path
"""
messages = [
{"role": "system", "content": ROUTER_SYSTEM_PROMPT},
{"role": "user", "content": user_text.strip() or "请分析意图"},
]
try:
raw = chat_completion_json(
messages=messages,
json_schema=ROUTER_JSON_SCHEMA,
schema_name="router_response",
temperature=0.1,
)
except Exception as e:
# LLM 调用失败时兜底
return RouterResult(
intents=list(FALLBACK_INTENTS),
entities={},
is_fast_path=False,
raw_llm_output={"error": str(e)},
)
raw_intents = raw.get("intents") or []
raw_entities = raw.get("entities") or {}
if not isinstance(raw_intents, list):
raw_intents = [str(raw_intents)] if raw_intents else []
# 冲突处理
resolved_intents = _resolve_conflicts(raw_intents)
# 判断 Fast-Pathintents 非空且全部属于 ATOMIC_INTENTS
is_fast_path = (
len(resolved_intents) > 0
and all(i in ATOMIC_INTENTS for i in resolved_intents)
)
return RouterResult(
intents=resolved_intents,
entities=raw_entities if isinstance(raw_entities, dict) else {},
is_fast_path=is_fast_path,
raw_llm_output=raw,
)
if __name__ == "__main__":
# 本地测试python -m drone_planning.pipeline.router
import sys
text = sys.argv[1] if len(sys.argv) > 1 else "飞到大门然后拍照"
result = route(text)
print("intents:", result.intents)
print("entities:", result.entities)
print("is_fast_path:", result.is_fast_path)

View File

@@ -0,0 +1 @@
"""RAG 模块Embedding、向量存储、检索、灌入"""

View File

@@ -0,0 +1,63 @@
"""
RAG Embedding 客户端 - 实现 ChromaDB EmbeddingFunction 接口
使用 OpenAI SDK 连接本地 llama-server 的 Embedding 服务8090 端口),
模型名可通过环境变量 EMBEDDING_MODEL 配置。
"""
from __future__ import annotations
import os
from typing import Any, List
from chromadb.api.types import Documents, EmbeddingFunction, Embeddings
from openai import OpenAI
# 环境变量配置
EMBEDDING_BASE_URL = os.getenv("LLM_EMBEDDING_BASE_URL", "http://localhost:8090/v1")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "qwen3-embedding")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "not-needed")
class QwenEmbeddingFunction(EmbeddingFunction[Documents]):
"""
基于 Qwen Embeddingllama-server 8090的 ChromaDB EmbeddingFunction
实现 ChromaDB 的 EmbeddingFunction 协议,供 vector_store 使用。
"""
def __init__(
self,
base_url: str | None = None,
model: str | None = None,
api_key: str | None = None,
) -> None:
"""
Args:
base_url: Embedding API 地址,默认从 LLM_EMBEDDING_BASE_URL 读取
model: 模型名,默认从 EMBEDDING_MODEL 读取
api_key: API Key本地部署通常用 "not-needed"
"""
self._base_url = base_url or EMBEDDING_BASE_URL
self._model = model or EMBEDDING_MODEL
self._api_key = api_key or OPENAI_API_KEY
self._client = OpenAI(base_url=self._base_url, api_key=self._api_key)
def __call__(self, input: Documents) -> Embeddings:
"""
将文本列表转换为向量列表ChromaDB EmbeddingFunction 接口)
Args:
input: 文本列表,每项为 str
Returns:
向量列表,每项为 list[float]
"""
if not input:
return []
texts = [t if isinstance(t, str) else str(t) for t in input]
response = self._client.embeddings.create(model=self._model, input=texts)
# 按 order 排序API 可能乱序返回)
sorted_data = sorted(response.data, key=lambda x: x.index)
return [item.embedding for item in sorted_data]

View File

@@ -0,0 +1,136 @@
"""
RAG 数据灌入脚本
从 data/knowledge/*.jsonl 读取 NDJSON提取 document 作为文本,
其他结构化字段作为 metadata灌入对应的 ChromaDB Collection。
可独立执行python -m drone_planning.rag.ingestion
"""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from drone_planning.rag.vector_store import RAGVectorStore
# 知识库路径(相对于项目根)
KNOWLEDGE_DIR = Path(__file__).resolve().parent.parent.parent.parent / "data" / "knowledge"
MAP_DB_FILE = KNOWLEDGE_DIR / "map_db.jsonl"
RULE_DB_FILE = KNOWLEDGE_DIR / "rule_db.jsonl"
FEW_SHOT_DB_FILE = KNOWLEDGE_DIR / "few_shot_db.jsonl"
def _load_jsonl(path: Path) -> list[dict]:
"""加载 jsonl 文件,每行一个 JSON"""
if not path.exists():
return []
records = []
with open(path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError as e:
raise ValueError(f"{path}:{i + 1} JSON 解析失败: {e}")
return records
def _prepare_map_records(records: list[dict]) -> tuple[list[str], list[str], list[dict]]:
"""
准备 map_db 灌入数据
document 作为文本location/x/y/z 等作为 metadata。
"""
ids, documents, metadatas = [], [], []
for i, r in enumerate(records):
doc = r.get("document", "")
if not doc:
continue
meta = {k: v for k, v in r.items() if k != "document"}
# ChromaDB metadata 值需为 str, int, float, bool
meta = {k: (v if isinstance(v, (str, int, float, bool)) else str(v)) for k, v in meta.items()}
ids.append(r.get("id", f"map_{i}_{uuid.uuid4().hex[:8]}"))
documents.append(doc)
metadatas.append(meta)
return ids, documents, metadatas
def _prepare_rule_records(records: list[dict]) -> tuple[list[str], list[str], list[dict]]:
"""准备 rule_db 灌入数据"""
ids, documents, metadatas = [], [], []
for i, r in enumerate(records):
doc = r.get("document", "")
if not doc:
continue
meta = {k: v for k, v in r.items() if k != "document"}
meta = {k: (v if isinstance(v, (str, int, float, bool)) else str(v)) for k, v in meta.items()}
ids.append(r.get("id", f"rule_{i}_{uuid.uuid4().hex[:8]}"))
documents.append(doc)
metadatas.append(meta)
return ids, documents, metadatas
def _prepare_few_shot_records(records: list[dict]) -> tuple[list[str], list[str], list[dict]]:
"""准备 few_shot_db 灌入数据"""
ids, documents, metadatas = [], [], []
for i, r in enumerate(records):
doc = r.get("document", "")
if not doc:
continue
meta = {k: v for k, v in r.items() if k != "document"}
meta = {k: (v if isinstance(v, (str, int, float, bool)) else str(v)) for k, v in meta.items()}
ids.append(r.get("id", f"fewshot_{i}_{uuid.uuid4().hex[:8]}"))
documents.append(doc)
metadatas.append(meta)
return ids, documents, metadatas
def run_ingestion(clear_first: bool = True) -> dict[str, int]:
"""
执行数据灌入
Args:
clear_first: 是否先清空已有数据再灌入
Returns:
各 Collection 灌入数量 {"map_db": n, "rule_db": n, "few_shot_db": n}
"""
store = RAGVectorStore()
if clear_first:
store.clear_all()
counts = {"map_db": 0, "rule_db": 0, "few_shot_db": 0}
# map_db
if MAP_DB_FILE.exists():
records = _load_jsonl(MAP_DB_FILE)
if records:
ids, docs, metas = _prepare_map_records(records)
store.add_map_records(ids, docs, metas)
counts["map_db"] = len(ids)
# rule_db
if RULE_DB_FILE.exists():
records = _load_jsonl(RULE_DB_FILE)
if records:
ids, docs, metas = _prepare_rule_records(records)
store.add_rule_records(ids, docs, metas)
counts["rule_db"] = len(ids)
# few_shot_db
if FEW_SHOT_DB_FILE.exists():
records = _load_jsonl(FEW_SHOT_DB_FILE)
if records:
ids, docs, metas = _prepare_few_shot_records(records)
store.add_few_shot_records(ids, docs, metas)
counts["few_shot_db"] = len(ids)
return counts
if __name__ == "__main__":
counts = run_ingestion()
print("灌入完成:", counts)

View File

@@ -0,0 +1,203 @@
"""
RAG 检索核心 - 冷热双态检索
- 热表dynamic_memory占位未来接入语义地图
- 冷库ChromaDBmap_db、rule_db、few_shot_db
- 仅提供基准点坐标base_location_coords不负责相对坐标计算相对位置由 LLM 调用 mcp 工具计算
"""
from __future__ import annotations
import re
from typing import Any
from drone_planning.rag.vector_store import RAGVectorStore
def _parse_relative_location(loc: str) -> tuple[str | None, str | None, float | None]:
"""
解析「广场东边500米」「大门北偏东30度100米」类复合地点
Returns:
(base_location, direction_str, distance) 或 (None, None, None)
"""
s = str(loc).strip()
# 广场东边500米、广场东侧500米、广场东500米
m = re.search(r"^(.+?)([东西南北])(?:边|侧)?\s*(\d+(?:\.\d+)?)\s*米", s)
if m:
return m.group(1).strip(), m.group(2), float(m.group(3))
# 大门北偏东30度100米
m = re.search(r"^(.+?)(北偏东|东偏北|南偏东|东偏南|北偏西|西偏北|南偏西|西偏南)\s*(\d+(?:\.\d+)?)\s*度\s*(\d+(?:\.\d+)?)\s*米", s)
if m:
return m.group(1).strip(), f"{m.group(2)}{m.group(3)}", float(m.group(4))
return None, None, None
class RAGRetriever:
"""
RAG 检索器
实现冷热双态检索逻辑:
- 地图:先查热表 dynamic_memory没有再查 map_dbmetadata 精确匹配)
- 规则:根据 intents 查 rule_db向量 + metadata
- 示例:用 user_text 查 few_shot_db向量相似度取 top 1~2
"""
def __init__(self, vector_store: RAGVectorStore | None = None) -> None:
"""
Args:
vector_store: 向量存储实例,默认新建
"""
self._store = vector_store or RAGVectorStore()
# 热数据占位:未来接入动态语义地图
# 格式 {"locations": {"大门": {"x": 0, "y": 0, "z": 0}, ...}, "targets": {...}}
self.dynamic_memory: dict[str, Any] = {"locations": {}, "targets": {}}
def retrieve_context(
self,
intents: list[str],
entities: dict[str, Any],
user_text: str,
) -> dict[str, Any]:
"""
检索 RAG 上下文
Args:
intents: 意图列表
entities: 实体字典(含 locations、targets 等)
user_text: 用户原始文本
Returns:
{
"map_context": str,
"rule_context": str,
"few_shot_examples": [...],
"base_location_coords": {loc: {x,y,z}}, # 基准点坐标,不计算相对位置
"relative_descriptions": [{target, base, direction, distance}], # 相对描述,供 LLM 调用工具
}
"""
result: dict[str, Any] = {
"map_context": "",
"rule_context": "",
"few_shot_examples": [],
"base_location_coords": {}, # 基准点坐标(仅从 map_db 查,不计算相对坐标)
"relative_descriptions": [], # 相对描述列表,供 LLM 调用工具
}
locations = entities.get("locations") or []
if not isinstance(locations, list):
locations = [locations] if locations else []
direction = entities.get("direction")
distance = entities.get("distance")
if distance is not None:
try:
distance = float(distance)
except (TypeError, ValueError):
distance = None
# 1. 地图:热表优先,冷库兜底;仅提供基准点坐标,不调用 mcp 计算
map_lines: list[str] = []
for loc in locations:
loc = str(loc).strip()
if not loc:
continue
# 解析「广场东边500米」或使用 entities 的 direction/distance
base_loc, dir_str, dist = _parse_relative_location(loc)
if base_loc and (dir_str or direction) and (dist is not None or distance is not None):
use_relative = True
dir_str = dir_str or direction or ""
dist = float(dist) if dist is not None else float(distance)
elif direction and distance is not None:
use_relative = True
base_loc = loc
dir_str = str(direction)
dist = float(distance)
loc = f"{loc}{dir_str}{int(dist)}" # 合成目标点名称
else:
use_relative = False
base_loc = loc
# 先查热表
hot_coords = self.dynamic_memory.get("locations", {}).get(base_loc)
if hot_coords is not None:
x, y, z = hot_coords.get("x", 0), hot_coords.get("y", 0), hot_coords.get("z", 0)
result["base_location_coords"][base_loc] = {"x": x, "y": y, "z": z}
if use_relative:
map_lines.append(f"{base_loc}(基准点): x={x}, y={y}, z={z};相对描述需 LLM 调用工具计算")
result["relative_descriptions"].append({
"target": loc,
"base": base_loc,
"direction": dir_str,
"distance": dist,
})
else:
map_lines.append(f"{loc}: x={x}, y={y}, z={z} (热表)")
continue
# 冷库metadata 精确查找
try:
got = self._store.map_db.get(
where={"location": base_loc},
include=["documents", "metadatas"],
)
if got and got["metadatas"]:
m = got["metadatas"][0]
x, y, z = m.get("x", 0), m.get("y", 0), m.get("z", 0)
result["base_location_coords"][base_loc] = {"x": x, "y": y, "z": z}
if use_relative:
map_lines.append(f"{base_loc}(基准点): x={x}, y={y}, z={z};相对描述需 LLM 调用工具计算")
result["relative_descriptions"].append({
"target": loc,
"base": base_loc,
"direction": dir_str,
"distance": dist,
})
else:
map_lines.append(f"{loc}: x={x}, y={y}, z={z} (ChromaDB)")
except Exception:
pass
if map_lines:
result["map_context"] = "\n".join(map_lines)
# 2. 规则:根据 intents 查 rule_db
rule_lines: list[str] = []
for intent in intents:
try:
got = self._store.rule_db.get(
where={"intent": intent},
include=["documents"],
)
if got and got["documents"]:
rule_lines.extend(got["documents"])
except Exception:
pass
if rule_lines:
result["rule_context"] = "\n".join(rule_lines)
# 3. 示例user_text 向量检索 few_shot_db取 top 2
if user_text.strip():
try:
import json as _json
got = self._store.few_shot_db.query(
query_texts=[user_text.strip()],
n_results=2,
include=["documents", "metadatas"],
)
docs_list = (got.get("documents") or [[]])[0]
metas_list = (got.get("metadatas") or [[]])[0]
for i, meta in enumerate(metas_list or []):
doc = docs_list[i] if i < len(docs_list) else ""
tree_str = meta.get("tree_json", "{}")
try:
tree_obj = _json.loads(tree_str) if isinstance(tree_str, str) else tree_str
except Exception:
tree_obj = {}
result["few_shot_examples"].append({
"instruction": doc or "",
"tree_json": tree_obj,
})
except Exception:
pass
return result

View File

@@ -0,0 +1,149 @@
"""
RAG 向量存储 - ChromaDB 封装
管理 map_db、rule_db、few_shot_db 三个 Collection
提供将 jsonl 数据写入对应表的方法。
存储路径:./data/chroma项目根目录下
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import chromadb
from chromadb.config import Settings
from drone_planning.rag.embedding_client import QwenEmbeddingFunction
# 默认存储路径(相对于项目根目录)
DEFAULT_PERSIST_PATH = os.getenv("CHROMA_PERSIST_PATH", "./data/chroma")
COLLECTION_MAP_DB = "map_db"
COLLECTION_RULE_DB = "rule_db"
COLLECTION_FEW_SHOT_DB = "few_shot_db"
def _resolve_persist_path() -> str:
"""解析 ChromaDB 持久化路径为绝对路径"""
base = Path(__file__).resolve().parent.parent.parent.parent
path = Path(DEFAULT_PERSIST_PATH)
if not path.is_absolute():
path = base / path
path.mkdir(parents=True, exist_ok=True)
return str(path)
class RAGVectorStore:
"""
RAG 向量存储客户端
初始化时创建/获取三个 Collectionmap_db、rule_db、few_shot_db。
使用 Qwen Embedding 作为向量化函数。
"""
def __init__(
self,
persist_path: str | None = None,
embedding_function: Any | None = None,
) -> None:
"""
Args:
persist_path: ChromaDB 持久化目录,默认 ./data/chroma
embedding_function: 自定义 EmbeddingFunction默认使用 QwenEmbeddingFunction
"""
self._persist_path = persist_path or _resolve_persist_path()
self._ef = embedding_function or QwenEmbeddingFunction()
self._client = chromadb.PersistentClient(
path=self._persist_path,
settings=Settings(anonymized_telemetry=False),
)
# 获取或创建三个 Collection
self._map_db = self._client.get_or_create_collection(
name=COLLECTION_MAP_DB,
embedding_function=self._ef,
metadata={"description": "地图地点坐标库"},
)
self._rule_db = self._client.get_or_create_collection(
name=COLLECTION_RULE_DB,
embedding_function=self._ef,
metadata={"description": "规则约束库"},
)
self._few_shot_db = self._client.get_or_create_collection(
name=COLLECTION_FEW_SHOT_DB,
embedding_function=self._ef,
metadata={"description": "Few-shot 示例库"},
)
@property
def map_db(self):
"""地图 Collection"""
return self._map_db
@property
def rule_db(self):
"""规则 Collection"""
return self._rule_db
@property
def few_shot_db(self):
"""Few-shot 示例 Collection"""
return self._few_shot_db
def add_map_records(
self,
ids: list[str],
documents: list[str],
metadatas: list[dict[str, Any]],
) -> None:
"""
向 map_db 添加记录
Args:
ids: 文档 ID 列表
documents: 文档文本列表(用于向量化)
metadatas: 元数据列表,应含 location、x、y、z 等
"""
self._map_db.add(ids=ids, documents=documents, metadatas=metadatas)
def add_rule_records(
self,
ids: list[str],
documents: list[str],
metadatas: list[dict[str, Any]],
) -> None:
"""向 rule_db 添加记录"""
self._rule_db.add(ids=ids, documents=documents, metadatas=metadatas)
def add_few_shot_records(
self,
ids: list[str],
documents: list[str],
metadatas: list[dict[str, Any]],
) -> None:
"""向 few_shot_db 添加记录"""
self._few_shot_db.add(ids=ids, documents=documents, metadatas=metadatas)
def clear_all(self) -> None:
"""清空三个 Collection用于重新灌入"""
self._client.delete_collection(COLLECTION_MAP_DB)
self._client.delete_collection(COLLECTION_RULE_DB)
self._client.delete_collection(COLLECTION_FEW_SHOT_DB)
# 重新创建
self._map_db = self._client.get_or_create_collection(
name=COLLECTION_MAP_DB,
embedding_function=self._ef,
metadata={"description": "地图地点坐标库"},
)
self._rule_db = self._client.get_or_create_collection(
name=COLLECTION_RULE_DB,
embedding_function=self._ef,
metadata={"description": "规则约束库"},
)
self._few_shot_db = self._client.get_or_create_collection(
name=COLLECTION_FEW_SHOT_DB,
embedding_function=self._ef,
metadata={"description": "Few-shot 示例库"},
)

View File

@@ -0,0 +1 @@
"""工具模块:坐标解析、地理信息等"""

View File

@@ -0,0 +1,38 @@
"""
坐标解析工具 - 已废弃硬编码兜底
架构升级:所有基准地点坐标一律只从 RAG (map_db) 中获取。
本模块保留空壳,供可能的外部调用兼容;新逻辑不再依赖此处。
"""
from __future__ import annotations
from typing import Any
def landmark_to_enu(landmark: str) -> dict[str, float] | None:
"""
已废弃:不再提供硬编码坐标。
坐标解析统一由 RAG map_db 提供Composer 中已移除对本函数的兜底调用。
"""
return None
def landmarks_to_enu(landmarks: list[str]) -> list[dict[str, float]]:
"""
已废弃:不再提供硬编码坐标。
返回空列表或 None 占位,调用方应改用 RAG 检索的 location_coords。
"""
return []
def add_landmark(name: str, x: float, y: float, z: float = 0.0) -> None:
"""已废弃:无硬编码表可写。"""
pass
def get_all_landmarks() -> dict[str, dict[str, float]]:
"""已废弃:返回空字典。"""
return {}

View File

@@ -0,0 +1,142 @@
"""
MCP 坐标计算工具 - 相对坐标计算
根据基准点 (base_x, base_y) 和方向、距离,计算目标点的绝对 ENU 坐标。
供大模型通过 Function Calling 自主调用实现「广场东边500米」等相对描述。
"""
from __future__ import annotations
import math
import re
from typing import Any
# 方向别名 -> 角度0=东90=北ENU 坐标系)
DIRECTION_ANGLES: dict[str, float] = {
"east": 0,
"": 0,
"东边": 0,
"东侧": 0,
"west": 180,
"西": 180,
"西边": 180,
"西侧": 180,
"north": 90,
"": 90,
"北边": 90,
"北侧": 90,
"south": 270,
"": 270,
"南边": 270,
"南侧": 270,
"northeast": 45,
"东北": 45,
"东北方": 45,
"northwest": 135,
"西北": 135,
"西北方": 135,
"southeast": 315,
"东南": 315,
"东南方": 315,
"southwest": 225,
"西南": 225,
"西南方": 225,
}
def _parse_direction_angle(direction_str: str) -> float | None:
"""
解析方向字符串为角度(度)
支持:
- 英文/中文方向词east, 东, northeast, 东北
- 角度描述北偏东30度、东偏北45度、30度
"""
s = direction_str.strip()
if not s:
return None
s_lower = s.lower()
# 先匹配角度描述避免「北偏东30度」被误匹配为「东」
m = re.search(r"北偏东\s*(\d+(?:\.\d+)?)\s*度?", s, re.I)
if m:
return 90 - float(m.group(1)) # 北为90°偏东减角度
m = re.search(r"东偏北\s*(\d+(?:\.\d+)?)\s*度?", s, re.I)
if m:
return float(m.group(1)) # 东为0°偏北加角度
m = re.search(r"南偏东\s*(\d+(?:\.\d+)?)\s*度?", s, re.I)
if m:
return 270 + float(m.group(1)) # 南270°偏东加
m = re.search(r"东偏南\s*(\d+(?:\.\d+)?)\s*度?", s, re.I)
if m:
return 360 - float(m.group(1))
m = re.search(r"北偏西\s*(\d+(?:\.\d+)?)\s*度?", s, re.I)
if m:
return 90 + float(m.group(1))
m = re.search(r"南偏西\s*(\d+(?:\.\d+)?)\s*度?", s, re.I)
if m:
return 270 - float(m.group(1))
# 纯数字角度
m = re.search(r"(\d+(?:\.\d+)?)\s*度?", s_lower)
if m:
return float(m.group(1)) % 360
# 最后查简单方向别名(精确匹配优先)
for key, angle in DIRECTION_ANGLES.items():
if s_lower == key or (len(s_lower) <= 4 and key in s_lower and key not in ("", "西", "", "")):
return angle
for short in ("", "西", "", ""):
if s_lower == short or s == short:
return DIRECTION_ANGLES.get(short, 0)
return None
def calculate_relative_coordinate(
base_x: float,
base_y: float,
direction_str: str,
distance: float,
base_z: float = 0.0,
) -> dict[str, Any]:
"""
计算相对坐标
ENU 坐标系:东=x+,北=y+,上=z+。
角度0°=东90°=北180°=西270°=南。
Args:
base_x: 基准点东向坐标(米)
base_y: 基准点北向坐标(米)
direction_str: 方向描述,如 "east", "", "northeast", "北偏东30度"
distance: 距离(米)
base_z: 基准点高度,默认 0输出时保持
Returns:
{"x": float, "y": float, "z": float, "angle_deg": float}
"""
angle = _parse_direction_angle(direction_str)
if angle is None:
return {
"x": base_x,
"y": base_y,
"z": base_z,
"angle_deg": None,
"error": f"无法解析方向: {direction_str}",
}
rad = math.radians(angle)
dx = distance * math.cos(rad)
dy = distance * math.sin(rad)
return {
"x": round(base_x + dx, 2),
"y": round(base_y + dy, 2),
"z": base_z,
"angle_deg": angle,
}