优化文档与测试脚本
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,6 +33,9 @@ env/
|
||||
# ChromaDB / data
|
||||
data/chroma/
|
||||
|
||||
# Playground 测试指令日志(可选保留本地)
|
||||
log/
|
||||
|
||||
# Local config
|
||||
.env
|
||||
.env.local
|
||||
|
||||
20
README.md
20
README.md
@@ -46,12 +46,24 @@ export ENABLE_THINKING=true
|
||||
|
||||
### 3. RAG 知识库灌入
|
||||
|
||||
首次使用或更新知识库后需执行灌入:
|
||||
**必须先启动 Embedding 服务**(默认 `http://localhost:8090/v1`)。灌入会向该地址请求向量;未启动会出现 `Connection refused`。可与 Chat 一并启动:
|
||||
|
||||
```bash
|
||||
python -m drone_planning.rag.ingestion
|
||||
bash run_api.sh # 含 8081 Chat + 8090 Embedding,待终端出现 listening 后再灌入
|
||||
```
|
||||
|
||||
**包位于 `src/` 下**,灌入命令任选其一:
|
||||
|
||||
```bash
|
||||
# 方式 A:已执行 pip install -e . 时
|
||||
python -m drone_planning.rag.ingestion
|
||||
|
||||
# 方式 B:未安装包时,需指定 PYTHONPATH(与 run_api.sh 一致)
|
||||
PYTHONPATH=src python -m drone_planning.rag.ingestion
|
||||
```
|
||||
|
||||
Embedding 地址可通过环境变量覆盖:`export LLM_EMBEDDING_BASE_URL=http://主机:端口/v1`
|
||||
|
||||
知识库文件位于 `data/knowledge/`:
|
||||
|
||||
- `map_db.jsonl`:地点坐标(location, x, y, z)
|
||||
@@ -60,8 +72,10 @@ python -m drone_planning.rag.ingestion
|
||||
|
||||
### 4. 启动 API 服务
|
||||
|
||||
项目根目录下需让 Python 能找到 `src/drone_planning`(已 `pip install -e .` 可省略):
|
||||
|
||||
```bash
|
||||
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
PYTHONPATH=src python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 5. 启动 Playground 测试台
|
||||
|
||||
@@ -50,6 +50,19 @@
|
||||
"count": "int"
|
||||
}
|
||||
},
|
||||
"approach_target": {
|
||||
"desc": "接近目标到指定距离时停止(默认 3 米,用于抵近拍照、接近目标等)",
|
||||
"params": {
|
||||
"target_class": "string",
|
||||
"distance": "float(米,默认 3)"
|
||||
}
|
||||
},
|
||||
"return_home": {
|
||||
"desc": "返航回基地/起飞点(不填 home_label 则回默认起飞点)",
|
||||
"params": {
|
||||
"home_label": "string(可选)"
|
||||
}
|
||||
},
|
||||
"report_message": {
|
||||
"desc": "上报消息",
|
||||
"params": {
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
{"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}
|
||||
{"document": "施工区域在广场边上,坐标 x=15, y=20", "location": "施工区域", "x": 15, "y": 5, "z": 0}
|
||||
|
||||
176
playground.py
176
playground.py
@@ -7,18 +7,130 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# 确保 src 在路径中
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
||||
|
||||
# 可视化测试指令日志:保存到项目下的 log 目录(纯文本)
|
||||
PLAYGROUND_LOG_DIR = Path(__file__).resolve().parent / "log"
|
||||
PLAYGROUND_LOG_FILE = PLAYGROUND_LOG_DIR / "playground_instructions.log"
|
||||
|
||||
|
||||
def _append_playground_log(
|
||||
instruction: str,
|
||||
success: bool,
|
||||
fast_path: bool = False,
|
||||
intents: list[str] | None = None,
|
||||
entities: dict | None = None,
|
||||
error: str | None = None,
|
||||
traceback_text: str | None = None,
|
||||
timing: dict | None = None,
|
||||
layer2_meta: dict | None = None,
|
||||
layer2_prompt_preview: str | None = None,
|
||||
layer3_json: dict | None = None,
|
||||
layer4_ascii: str | None = None,
|
||||
rag_context: dict | None = None,
|
||||
tool_call_log: list | None = None,
|
||||
layer1_raw_output: dict | None = None,
|
||||
) -> None:
|
||||
"""将本次测试的完整内容以纯文本追加到 log 目录下。"""
|
||||
PLAYGROUND_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
lines = [
|
||||
"",
|
||||
"=" * 60,
|
||||
f" {ts}",
|
||||
"=" * 60,
|
||||
"",
|
||||
"【指令】",
|
||||
instruction,
|
||||
"",
|
||||
"【结果】",
|
||||
f" 成功: {success}",
|
||||
f" Fast-Path: {fast_path}",
|
||||
f" 意图 intents: {intents or []}",
|
||||
]
|
||||
# 实体:确保可序列化为合法 JSON(Router 偶发畸形键时仍可写日志)
|
||||
try:
|
||||
ent = entities or {}
|
||||
if isinstance(ent, dict):
|
||||
ent = {str(k): v for k, v in ent.items()}
|
||||
lines.append(f" 实体 entities: {json.dumps(ent, ensure_ascii=False, indent=2)}")
|
||||
except (TypeError, ValueError):
|
||||
lines.append(f" 实体 entities: (无法序列化) {repr(entities)[:200]}")
|
||||
lines.append("")
|
||||
if error:
|
||||
lines.extend(["【错误】", error, ""])
|
||||
if traceback_text:
|
||||
lines.append("【堆栈】")
|
||||
lines.append(traceback_text)
|
||||
lines.append("")
|
||||
if layer1_raw_output is not None:
|
||||
lines.append("【Layer 1 原始路由输出(raw_llm_output)】")
|
||||
lines.append(json.dumps(layer1_raw_output, ensure_ascii=False, indent=2))
|
||||
lines.append("")
|
||||
if timing:
|
||||
lines.append("【各环节耗时 (ms)】")
|
||||
for name, ms in timing.items():
|
||||
lines.append(f" {name}: {ms:.0f}")
|
||||
lines.append(f" 总计: {sum(timing.values()):.0f}")
|
||||
lines.append("")
|
||||
if layer2_meta:
|
||||
lines.append("【Layer 2 Composer】")
|
||||
lines.append(f" 选中节点: {layer2_meta.get('selected_actions', [])}")
|
||||
lines.append(f" 基准点坐标: {json.dumps(layer2_meta.get('base_location_coords', {}), ensure_ascii=False)}")
|
||||
if layer2_prompt_preview:
|
||||
lines.append(" System Prompt 预览:")
|
||||
for line in layer2_prompt_preview.strip().split("\n")[:30]:
|
||||
lines.append(" " + line)
|
||||
if layer2_prompt_preview.count("\n") >= 30:
|
||||
lines.append(" ...")
|
||||
lines.append("")
|
||||
if tool_call_log:
|
||||
lines.append("【Tool Call 日志】")
|
||||
for entry in tool_call_log:
|
||||
lines.append(f" 第{entry.get('round', '?')}次 {entry.get('tool', '')}")
|
||||
lines.append(f" {json.dumps(entry.get('arguments', {}), ensure_ascii=False)}")
|
||||
lines.append(f" 结果: {json.dumps(entry.get('result', {}), ensure_ascii=False)}")
|
||||
lines.append("")
|
||||
if rag_context:
|
||||
lines.append("【RAG 检索】")
|
||||
if rag_context.get("map_context"):
|
||||
lines.append(" 地图: " + (rag_context["map_context"][:500] + "..." if len(rag_context.get("map_context", "")) > 500 else rag_context.get("map_context", "")))
|
||||
if rag_context.get("rule_context"):
|
||||
lines.append(" 规则: " + (rag_context["rule_context"][:300] + "..." if len(rag_context.get("rule_context", "")) > 300 else rag_context.get("rule_context", "")))
|
||||
lines.append("")
|
||||
if layer3_json is not None:
|
||||
lines.append("【Layer 3 行为树 JSON(后处理后的执行树)】")
|
||||
lines.append(json.dumps(layer3_json, ensure_ascii=False, indent=2))
|
||||
lines.append("")
|
||||
if layer4_ascii:
|
||||
lines.append("【Layer 4 执行树 ASCII】")
|
||||
# 去掉终端颜色码,便于在 .log 文本中阅读
|
||||
layer4_plain = re.sub(r"\x1b\[[0-9;]*m", "", layer4_ascii)
|
||||
lines.append(layer4_plain)
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
with open(PLAYGROUND_LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
|
||||
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.execution.tree_wrapper import (
|
||||
enforce_search_object_detected,
|
||||
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
|
||||
@@ -59,17 +171,29 @@ with st.sidebar:
|
||||
bb.is_in_air = is_in_air
|
||||
st.info(f"当前: {'已起飞' if is_in_air else '在地面'}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 防重复点击:每次测试独立,不并发
|
||||
# ---------------------------------------------------------------------------
|
||||
if "run_in_progress" not in st.session_state:
|
||||
st.session_state.run_in_progress = False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主界面:输入与执行
|
||||
# ---------------------------------------------------------------------------
|
||||
instruction = st.text_area("输入自然语言指令", height=80, placeholder="例如:飞到大门然后拍照、起飞、飞到广场然后搜索汽车")
|
||||
instruction = st.text_area("输入自然语言指令", height=80, placeholder="例如:飞到大门然后拍照、起飞、飞到广场然后搜索汽车", key="instruction_input")
|
||||
run_text = instruction.strip() if instruction else ""
|
||||
if run_text:
|
||||
st.divider()
|
||||
# 执行中时禁用按钮,避免重复点击导致并发请求
|
||||
if st.session_state.run_in_progress:
|
||||
st.warning("⏳ 正在执行规划,请等待完成...")
|
||||
st.caption("请勿重复点击或刷新页面,当前请求完成后将自动更新。")
|
||||
else:
|
||||
if st.button("执行规划", type="primary", key="run_plan"):
|
||||
# 再次同步 blackboard(用户可能在点击前改了侧边栏)
|
||||
bb = DroneStateBlackboard()
|
||||
bb.is_in_air = is_in_air
|
||||
# 立即标记为执行中,防止并发请求
|
||||
st.session_state.run_in_progress = True
|
||||
# 本轮测试开始前,显式同步 Blackboard,确保前后测试互不影响
|
||||
DroneStateBlackboard().is_in_air = is_in_air
|
||||
|
||||
layer1_result = None
|
||||
rag_context = None
|
||||
@@ -77,11 +201,14 @@ if run_text:
|
||||
layer2_meta = None
|
||||
tool_call_log: list[dict] = []
|
||||
layer3_json = None
|
||||
layer3_json_for_display = None # 后处理前的拷贝,仅用于 Layer 3 展示
|
||||
layer4_ascii = None
|
||||
fast_path = False
|
||||
error_msg = None
|
||||
traceback_text: str | None = None
|
||||
timing: dict[str, float] = {}
|
||||
|
||||
with st.spinner("正在执行规划,请稍候..."):
|
||||
try:
|
||||
# Layer 1: Router
|
||||
t0 = time.perf_counter()
|
||||
@@ -122,9 +249,13 @@ if run_text:
|
||||
)
|
||||
timing["Layer3_Planner"] = (time.perf_counter() - t0) * 1000
|
||||
|
||||
# Layer 4: 解析 + 包装(会读取 blackboard.is_in_air)
|
||||
# Layer 4: 后处理 + 解析 + 包装(会读取 blackboard.is_in_air)
|
||||
t0 = time.perf_counter()
|
||||
# 先拷贝一份供 Layer 3 展示「Planner 原生」;后处理在原树上做,保证 Layer 4 / log 与 API 一致
|
||||
layer3_json_for_display = copy.deepcopy(layer3_json)
|
||||
root_node = layer3_json.get("root") or layer3_json
|
||||
if isinstance(root_node, dict):
|
||||
enforce_search_object_detected(root_node)
|
||||
business_tree = parse_json_to_tree(root_node)
|
||||
final_tree = wrap_and_build_tree(business_tree)
|
||||
layer4_ascii = tree_to_ascii(final_tree)
|
||||
@@ -133,8 +264,31 @@ if run_text:
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
import traceback
|
||||
traceback_text = traceback.format_exc()
|
||||
st.error(f"❌ 执行出错: {error_msg}")
|
||||
st.code(traceback.format_exc(), language="text")
|
||||
st.code(traceback_text, language="text")
|
||||
finally:
|
||||
# 无论成功或异常,都解除执行中状态,允许下一次测试
|
||||
st.session_state.run_in_progress = False
|
||||
|
||||
# 写入 log
|
||||
_append_playground_log(
|
||||
instruction=run_text,
|
||||
success=(error_msg is None),
|
||||
fast_path=fast_path,
|
||||
intents=list(layer1_result.intents) if layer1_result else None,
|
||||
entities=dict(layer1_result.entities) if layer1_result else None,
|
||||
error=error_msg,
|
||||
traceback_text=traceback_text if error_msg else None,
|
||||
timing=timing or None,
|
||||
layer2_meta=layer2_meta,
|
||||
layer2_prompt_preview=layer2_prompt[:3000] if layer2_prompt else None,
|
||||
layer3_json=layer3_json,
|
||||
layer4_ascii=layer4_ascii,
|
||||
rag_context=rag_context,
|
||||
tool_call_log=tool_call_log if tool_call_log else None,
|
||||
layer1_raw_output=layer1_result.raw_llm_output if layer1_result else None,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 各环节耗时
|
||||
@@ -219,9 +373,11 @@ if run_text:
|
||||
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:
|
||||
# Layer 3:展示 Planner 原始输出(后处理前的 JSON)
|
||||
with st.expander("**Layer 3 (Planner)** - 原生业务树 JSON(后处理前)", expanded=True):
|
||||
if layer3_json_for_display is not None:
|
||||
st.json(layer3_json_for_display)
|
||||
elif layer3_json:
|
||||
st.json(layer3_json)
|
||||
else:
|
||||
st.warning("Layer 3 未执行")
|
||||
|
||||
@@ -8,6 +8,8 @@ cd "$(dirname "$0")"
|
||||
|
||||
# llama-server 所在目录(可在该目录下启动模型)
|
||||
LLAMA_BIN_DIR="${LLAMA_BIN_DIR:-$HOME/llama.cpp/build/bin}"
|
||||
# 强制关闭 thinking(传递给 Python 侧 LLM 客户端)
|
||||
export ENABLE_THINKING=false
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
@@ -18,8 +20,9 @@ cleanup() {
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# 启动 Chat 模型 (8081)
|
||||
# --reasoning-budget 0:强制关闭 Qwen3 思考模式,否则会生成大量 <think> 导致首层 Router 极慢(数分钟)
|
||||
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) &
|
||||
(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 --ctx_size 16384 --reasoning-budget 0) &
|
||||
LLAMA_CHAT_PID=$!
|
||||
|
||||
# 启动 Embedding 模型 (8090)
|
||||
@@ -33,4 +36,4 @@ sleep 8
|
||||
|
||||
# 启动 DronePlanning API (8000)
|
||||
echo "=== 启动 DronePlanning API (端口 8000) ==="
|
||||
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
PYTHONPATH="$(pwd)/src:$PYTHONPATH" python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
@@ -12,7 +12,12 @@ 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.execution.tree_wrapper import (
|
||||
enforce_search_object_detected,
|
||||
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
|
||||
@@ -84,10 +89,12 @@ def api_plan(body: PlanRequest) -> dict[str, Any]:
|
||||
)
|
||||
timing["Layer3_Planner"] = (time.perf_counter() - t0) * 1000
|
||||
|
||||
# Layer 4: 解析 + 包装
|
||||
# Layer 4: 后处理 + 解析 + 包装
|
||||
t0 = time.perf_counter()
|
||||
root_node = tree_json.get("root") or tree_json
|
||||
if isinstance(root_node, dict):
|
||||
# 后处理:在搜索节点后 / take_photos|track_object 前自动插入 object_detected
|
||||
enforce_search_object_detected(root_node)
|
||||
business_tree = parse_json_to_tree(root_node)
|
||||
else:
|
||||
raise ValueError("Planner 返回的 root 格式异常")
|
||||
|
||||
@@ -58,6 +58,18 @@ class FlyToWaypointAction(Behaviour):
|
||||
return common.Status.SUCCESS
|
||||
|
||||
|
||||
class ReturnHomeAction(Behaviour):
|
||||
"""返航回基地/起飞点"""
|
||||
|
||||
def __init__(self, name: str = "ReturnHome", home_label: str = ""):
|
||||
super().__init__(name)
|
||||
self.home_label = home_label or "起飞点"
|
||||
|
||||
def update(self) -> common.Status:
|
||||
self.logger.info(f"[ReturnHome] 返航回 {self.home_label}... OK")
|
||||
return common.Status.SUCCESS
|
||||
|
||||
|
||||
class GenericAction(Behaviour):
|
||||
"""通用动作兜底:用于未单独实现的 action 类型"""
|
||||
|
||||
|
||||
@@ -17,11 +17,106 @@ from drone_planning.core.blackboard import DroneStateBlackboard
|
||||
from drone_planning.execution.nodes import (
|
||||
FlyToWaypointAction,
|
||||
GenericAction,
|
||||
ReturnHomeAction,
|
||||
SystemCheckCondition,
|
||||
TakeoffAction,
|
||||
)
|
||||
|
||||
|
||||
def _get_children(node: dict[str, Any]) -> list[Any]:
|
||||
"""兼容 children / child 两种键,且保证返回 list 引用便于原地修改。"""
|
||||
ch = node.get("children") or node.get("child")
|
||||
if isinstance(ch, list):
|
||||
return ch
|
||||
return []
|
||||
|
||||
|
||||
def _node_type(node: dict[str, Any]) -> str:
|
||||
"""统一节点类型字符串,便于比较(小写、去空格)。"""
|
||||
t = node.get("type")
|
||||
return (t or "").strip().lower() if isinstance(t, str) else ""
|
||||
|
||||
|
||||
def enforce_search_object_detected(node_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
后处理:
|
||||
1) 搜索节点后自动插入 object_detected(若缺失);
|
||||
2) take_photos/track_object 前自动插入 object_detected(若缺失);
|
||||
3) 若存在 object_detected → take_photos/track_object 但前面没有搜索节点,则自动在 object_detected 前插入 rotate_search,保证「先搜索再检测再执行」。
|
||||
"""
|
||||
|
||||
def _make_detector(target_class: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object_detected",
|
||||
"name": "object_detected",
|
||||
"params": {"target_class": target_class or "target"},
|
||||
}
|
||||
|
||||
def _make_search(target_class: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "rotate_search",
|
||||
"name": "rotate_search",
|
||||
"params": {"target_class": target_class or "target"},
|
||||
}
|
||||
|
||||
def _walk(node: dict[str, Any]) -> None:
|
||||
children = _get_children(node)
|
||||
if not children:
|
||||
return
|
||||
i = 0
|
||||
while i < len(children):
|
||||
child = children[i]
|
||||
if not isinstance(child, dict):
|
||||
i += 1
|
||||
continue
|
||||
ty = _node_type(child)
|
||||
# 1) 搜索节点后必须有 object_detected
|
||||
if ty in ("search_pattern", "rotate_search"):
|
||||
target_class = (child.get("params") or {}).get("target_class", "")
|
||||
next_idx = i + 1
|
||||
has_detected = (
|
||||
next_idx < len(children)
|
||||
and isinstance(children[next_idx], dict)
|
||||
and _node_type(children[next_idx]) == "object_detected"
|
||||
)
|
||||
if not has_detected:
|
||||
children.insert(i + 1, _make_detector(target_class))
|
||||
i += 1
|
||||
# 2) take_photos / track_object 前若同一 Sequence 中没有任何 object_detected,则插入(兜底)
|
||||
# 若已有 object_detected(如 search→object_detected→manual_confirmation→take_photos),则不再插入
|
||||
elif ty in ("take_photos", "track_object"):
|
||||
target_class = (child.get("params") or {}).get("target_class", "")
|
||||
has_any_detected_before = any(
|
||||
_node_type(children[j]) == "object_detected"
|
||||
for j in range(i)
|
||||
if isinstance(children[j], dict)
|
||||
)
|
||||
if target_class and not has_any_detected_before:
|
||||
children.insert(i, _make_detector(target_class))
|
||||
i += 1
|
||||
# 3) object_detected 后紧跟 take_photos/track_object 但前面没有搜索节点 → 在 object_detected 前插入搜索节点
|
||||
elif ty == "object_detected":
|
||||
next_idx = i + 1
|
||||
if next_idx < len(children) and isinstance(children[next_idx], dict):
|
||||
next_ty = _node_type(children[next_idx])
|
||||
if next_ty in ("take_photos", "track_object"):
|
||||
has_search_before = any(
|
||||
_node_type(children[j]) in ("search_pattern", "rotate_search")
|
||||
for j in range(i)
|
||||
if isinstance(children[j], dict)
|
||||
)
|
||||
if not has_search_before:
|
||||
target_class = (child.get("params") or {}).get("target_class", "target")
|
||||
children.insert(i, _make_search(target_class))
|
||||
_walk(children[i])
|
||||
i += 1
|
||||
_walk(child)
|
||||
i += 1
|
||||
|
||||
_walk(node_dict)
|
||||
return node_dict
|
||||
|
||||
|
||||
def parse_json_to_tree(node_dict: dict[str, Any]) -> py_trees.behaviour.Behaviour:
|
||||
"""
|
||||
递归将 JSON 节点转换为 py_trees 对象
|
||||
@@ -47,11 +142,15 @@ def parse_json_to_tree(node_dict: dict[str, Any]) -> py_trees.behaviour.Behaviou
|
||||
if node_type == "Parallel":
|
||||
policy = params.get("policy", "success_on_all")
|
||||
children = [parse_json_to_tree(c) for c in children_data]
|
||||
# py_trees 2.x:ParallelPolicy 为 SuccessOnAll / SuccessOnOne 类实例,不再使用 SUCCESS_ON_ALL 常量
|
||||
parallel_policy = (
|
||||
py_trees.common.ParallelPolicy.SuccessOnAll()
|
||||
if policy == "success_on_all"
|
||||
else py_trees.common.ParallelPolicy.SuccessOnOne()
|
||||
)
|
||||
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,
|
||||
policy=parallel_policy,
|
||||
children=children,
|
||||
)
|
||||
|
||||
@@ -61,6 +160,9 @@ def parse_json_to_tree(node_dict: dict[str, Any]) -> py_trees.behaviour.Behaviou
|
||||
y = float(params.get("y", 0))
|
||||
z = float(params.get("z", 0))
|
||||
return FlyToWaypointAction(name=name, x=x, y=y, z=z)
|
||||
if node_type == "return_home":
|
||||
home_label = (params.get("home_label") or "").strip() or "起飞点"
|
||||
return ReturnHomeAction(name=name, home_label=home_label)
|
||||
|
||||
# 其他 action 用 GenericAction 兜底
|
||||
return GenericAction(name=name, action_type=node_type, params=params)
|
||||
|
||||
@@ -19,9 +19,10 @@ 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}}
|
||||
# 思考模式:代码层强制关闭(避免长时间思考导致前端无响应)
|
||||
EXTRA_BODY = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
# 请求超时(秒),防止长时间无响应
|
||||
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
|
||||
|
||||
|
||||
def get_chat_client() -> OpenAI:
|
||||
@@ -29,14 +30,16 @@ def get_chat_client() -> OpenAI:
|
||||
return OpenAI(
|
||||
base_url=LLM_CHAT_BASE_URL,
|
||||
api_key=OPENAI_API_KEY,
|
||||
timeout=LLM_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def chat_completion(
|
||||
messages: list[dict[str, Any]],
|
||||
model: str = "qwen3",
|
||||
temperature: float = 0.3,
|
||||
temperature: float = 0.0,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
max_tokens: int | None = 2048,
|
||||
) -> str:
|
||||
"""
|
||||
调用 Chat Completion API
|
||||
@@ -57,6 +60,7 @@ def chat_completion(
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"extra_body": EXTRA_BODY,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if response_format is not None:
|
||||
kwargs["response_format"] = response_format
|
||||
@@ -70,7 +74,8 @@ def chat_completion_with_tools(
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
model: str = "qwen3",
|
||||
temperature: float = 0.2,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: int = 2048,
|
||||
) -> Any:
|
||||
"""
|
||||
调用 Chat Completion,支持 tools(Function Calling)
|
||||
@@ -85,6 +90,7 @@ def chat_completion_with_tools(
|
||||
tools=tools,
|
||||
temperature=temperature,
|
||||
extra_body=EXTRA_BODY,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +99,8 @@ def chat_completion_json(
|
||||
json_schema: dict[str, Any],
|
||||
schema_name: str = "response",
|
||||
model: str = "qwen3",
|
||||
temperature: float = 0.3,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: int = 1024,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
调用 Chat Completion,并强制返回符合 JSON Schema 的结构化输出
|
||||
@@ -126,6 +133,7 @@ def chat_completion_json(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
response_format=response_format,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
except Exception as e:
|
||||
# 部分本地服务器可能不支持 json_schema,回退到 json_object
|
||||
@@ -135,6 +143,7 @@ def chat_completion_json(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
response_format={"type": "json_object"},
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
@@ -19,6 +19,8 @@ INTENT_TO_ACTIONS: dict[str, list[str]] = {
|
||||
"search_task": ["search_pattern", "rotate_search"],
|
||||
"track_task": ["track_object"],
|
||||
"photo_task": ["take_photos"],
|
||||
"approach_task": ["approach_target"],
|
||||
"return_task": ["return_home"],
|
||||
"interact_task": ["report_message", "manual_confirmation"],
|
||||
}
|
||||
|
||||
@@ -119,6 +121,23 @@ def build_system_prompt(
|
||||
parts.append(trimmed_schema["_instruction"])
|
||||
parts.append("")
|
||||
|
||||
# 本次意图与实体(供生成节点时使用)
|
||||
parts.append("## 本次指令的意图与实体")
|
||||
parts.append(f"- 意图: {', '.join(intents)}")
|
||||
locations = entities.get("locations") or []
|
||||
if not isinstance(locations, list):
|
||||
locations = [locations] if locations else []
|
||||
targets = entities.get("targets") or []
|
||||
if not isinstance(targets, list):
|
||||
targets = [targets] if targets else []
|
||||
if locations:
|
||||
parts.append(f"- 地点 locations: {json.dumps(locations, ensure_ascii=False)}")
|
||||
if targets:
|
||||
parts.append(f"- 目标 targets: {json.dumps(targets, ensure_ascii=False)}(生成 search_pattern/rotate_search/take_photos/track_object 时,target_class 从此列表取,如 \"戴帽子的人\")")
|
||||
if not locations and not targets:
|
||||
parts.append("- 无地点/目标实体")
|
||||
parts.append("")
|
||||
|
||||
# 基准点坐标(若有)
|
||||
if base_location_coords:
|
||||
parts.append("## 基准点坐标 base_location_coords(ENU,单位米)")
|
||||
@@ -131,6 +150,32 @@ def build_system_prompt(
|
||||
parts.append(json.dumps(trimmed_schema, ensure_ascii=False, indent=2))
|
||||
parts.append("")
|
||||
|
||||
# 规划约束:搜索与拍照的先后顺序
|
||||
has_search = "search_task" in intents
|
||||
has_photo = "photo_task" in intents
|
||||
has_fly = "fly_task" in intents
|
||||
if has_search and has_photo and targets:
|
||||
parts.append("## 规划约束(必须遵守)")
|
||||
parts.append("本指令包含「对目标拍照」:必须先执行搜索、再检测到目标、再拍照;三者缺一不可。")
|
||||
parts.append("- 行为树中必须包含搜索节点(search_pattern 或 rotate_search),且 target_class 使用上面 targets 中的描述。")
|
||||
parts.append("- 顺序必须为:搜索节点 → object_detected(条件)→ take_photos。")
|
||||
# parts.append("- 拍照节点(take_photos)必须出现在搜索节点与 object_detected 之后(同一 Sequence 内:先搜索,再 object_detected,再拍照)。")
|
||||
if has_fly and locations:
|
||||
parts.append("- 若包含飞往某地:先 fly_to_waypoint 到 locations,再执行上述 搜索→object_detected→拍照 顺序。")
|
||||
parts.append("")
|
||||
elif has_search and targets:
|
||||
parts.append("## 规划约束(必须遵守)")
|
||||
parts.append("本指令包含搜索/检测目标:必须使用搜索节点(search_pattern 或 rotate_search),target_class 使用上面 targets 中的描述。")
|
||||
parts.append("")
|
||||
if "return_task" in intents:
|
||||
parts.append("## 规划约束(返航)")
|
||||
parts.append("本指令包含返航:须使用 return_home 节点表示回基地/起飞点,不要用 fly_to_waypoint 代替返航;若为「先做任务再返航」,顺序为任务序列末尾接 return_home。")
|
||||
parts.append("")
|
||||
if "approach_task" in intents:
|
||||
parts.append("## 规划约束(抵近/接近目标)")
|
||||
parts.append("本指令包含抵近拍照或接近目标:须使用 approach_target 节点,target_class 从 targets 取,distance 默认 3 米(若 entities 有 distance 则用该值);顺序为:搜索→object_detected→approach_target→take_photos。")
|
||||
parts.append("")
|
||||
|
||||
# RAG 区块(阶段二)
|
||||
if rag_context:
|
||||
if rag_context.get("map_context"):
|
||||
|
||||
@@ -107,7 +107,7 @@ def plan(
|
||||
response = chat_completion_with_tools(
|
||||
messages=messages,
|
||||
tools=PLANNER_TOOLS,
|
||||
temperature=0.2,
|
||||
temperature=0.0,
|
||||
)
|
||||
except Exception as e:
|
||||
# 若模型不支持 tools,回退到无工具模式
|
||||
@@ -184,13 +184,14 @@ def _plan_fallback(system_prompt: str, user_text: str) -> dict[str, Any]:
|
||||
messages=messages,
|
||||
json_schema=PLANNER_JSON_SCHEMA,
|
||||
schema_name="behavior_tree",
|
||||
temperature=0.2,
|
||||
temperature=0.0,
|
||||
max_tokens=2048,
|
||||
)
|
||||
return raw
|
||||
except Exception:
|
||||
content = chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.2,
|
||||
temperature=0.0,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
text = content.strip()
|
||||
|
||||
@@ -6,6 +6,7 @@ Layer 1:意图路由层(Stage 1 Intent Router)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +16,7 @@ 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"}
|
||||
BUSINESS_INTENTS = {"fly_task", "search_task", "track_task", "photo_task", "approach_task", "return_task", "interact_task"}
|
||||
ALL_VALID_INTENTS = ATOMIC_INTENTS | BUSINESS_INTENTS
|
||||
|
||||
# 兜底意图:当 intents 为空或包含未识别标签时使用
|
||||
@@ -30,7 +31,7 @@ ROUTER_JSON_SCHEMA = {
|
||||
"intents": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "意图列表,仅使用 atomic_takeoff/atomic_land/atomic_hover 或 fly_task/search_task/track_task/photo_task/interact_task",
|
||||
"description": "意图列表,仅使用 atomic_takeoff/atomic_land/atomic_hover 或 fly_task/search_task/track_task/photo_task/approach_task/return_task/interact_task",
|
||||
},
|
||||
"entities": {
|
||||
"type": "object",
|
||||
@@ -67,23 +68,38 @@ ROUTER_SYSTEM_PROMPT = """你是指令意图分类器。根据用户自然语言
|
||||
|
||||
**业务意图:**
|
||||
- fly_task:空间移动、路径、巡逻、飞到某地
|
||||
- search_task:搜索、侦查
|
||||
- search_task:搜索、侦查、检测、寻找
|
||||
- track_task:跟踪
|
||||
- photo_task:拍照
|
||||
- approach_task:抵近、接近目标、抵近拍照(需使用 approach_target 节点,distance 默认 3 米)
|
||||
- return_task:返航(仅指回基地/起飞点,不是去具体地点)
|
||||
- interact_task:上报、请求确认
|
||||
|
||||
## 实体要求
|
||||
- locations:基地点列表,如 ["大门","广场"]。对于「广场东边500米」,只填 ["广场"],不要填 "广场东边500米"
|
||||
- targets:目标列表,如 ["汽车","行人","公交车"]
|
||||
- targets:目标列表,如 ["汽车","行人","戴帽子的人","公交车"](对谁/对什么执行 search/photo 就填谁)
|
||||
- direction:东/east、西/west、南/south、北/north、东北/northeast 等,或 front|back|left|right|up|down
|
||||
- distance:数字(米),如「东边500米」中的 500
|
||||
|
||||
## 隐含任务链(重要)
|
||||
以下类型指令必须拆出全部隐含意图,否则无法正确生成行为树:
|
||||
- **「对 X 拍照 / 给 X 拍照 / 拍 X」**:必须先找到 X 才能拍 → 必须同时输出 **search_task + photo_task**,targets 中要有 X(如 "戴帽子的人"、"汽车")。
|
||||
- **「回到/飞到 某地,对 X 拍照」**:隐含 飞行→搜索→拍照 → 输出 **fly_task + search_task + photo_task**,locations 填地点,targets 填 X。注意:「回到广场」「回到大门」等是 fly_task(飞往该地点),不是 return_task。
|
||||
- **「在某地搜索/找 X」**:fly_task(若涉及去某地)+ search_task,targets 填 X。
|
||||
- **「跟踪 X」**:通常先要发现 X → 若有「找」的含义,可同时输出 search_task + track_task;若明确已发现则仅 track_task。
|
||||
|
||||
总结:凡涉及「对某一具体目标拍照」的,一律补上 search_task 和对应 targets;凡涉及「去某地再做某事」的(含「回到广场」「回到大门」),补上 fly_task 和 locations。**return_task 仅用于「返航」「回基地」「回起飞点」**(回无人机基地),不用于「回到广场」「回到大门」等去具体地点的指令——后者用 fly_task + fly_to_waypoint。凡涉及「抵近拍照」「接近目标」「靠近 X 再拍」等,必须输出 approach_task。
|
||||
|
||||
## 规则
|
||||
1. 若用户只说"起飞"、"降落"、"悬停",只输出对应 atomic 意图,entities 可为空对象。
|
||||
2. 若用户说复杂任务(如"飞到大门然后拍照"),输出业务意图,并抽取 locations、targets 等。
|
||||
3. 若同时包含原子和业务(如"起飞后去广场"),两者都输出,由系统后续处理。
|
||||
4. 对于「广场东边500米」「大门北偏东30度100米」等相对描述,必须拆分:locations=["广场"], direction="东", distance=500
|
||||
5. 不要输出未在意图集合中的标签。"""
|
||||
5. **对某目标拍照类指令**:intents 必须包含 search_task 与 photo_task,entities.targets 必须包含该目标描述。
|
||||
6. 人工确认条件类指令比如"搜索小汽车,搜索到了我确认后再决定要不要拍照。"必须也要包含 interact_task 与 search_task 与 photo_task,entities.targets 必须包含该目标描述。
|
||||
7. **抵近拍照/接近目标类指令**:如「抵近拍照」「接近汽车再拍」「靠近目标到 5 米」等,必须输出 approach_task,targets 填目标;若指定距离则 entities.distance 填该值,否则默认 3 米。
|
||||
8. **区分 return_task 与 fly_task**:「回到广场」「回到大门」「去广场」等 = fly_task(飞往该地点,用 fly_to_waypoint);「返航」「回基地」「回起飞点」等 = return_task(回无人机基地,用 return_home)。不要将「回到某具体地点」误判为 return_task。
|
||||
9. 不要输出未在意图集合中的标签。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -114,6 +130,61 @@ class RouterResult:
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_entities(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""规范化 entities 键名与类型,避免 LLM 返回畸形键导致下游/日志异常。"""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
def to_str_list(v: Any) -> list[str]:
|
||||
if isinstance(v, list):
|
||||
return [str(x) for x in v if x is not None]
|
||||
if v is None:
|
||||
return []
|
||||
return [str(v)]
|
||||
|
||||
def norm_key(k: str) -> str:
|
||||
s = (k or "").strip().lower()
|
||||
for c in '\\"\'[]:':
|
||||
s = s.replace(c, "")
|
||||
return s
|
||||
|
||||
for k, v in raw.items():
|
||||
nk = norm_key(k)
|
||||
if nk == "locations":
|
||||
out["locations"] = to_str_list(v)
|
||||
elif nk == "targets" or nk == "target":
|
||||
# 兼容 "target" 单数或 "targets" 列表
|
||||
out["targets"] = to_str_list(v)
|
||||
elif nk == "direction" and isinstance(v, (str, int, float)):
|
||||
out["direction"] = str(v).strip()
|
||||
elif nk == "distance" and isinstance(v, (int, float)):
|
||||
out["distance"] = float(v)
|
||||
out.setdefault("locations", [])
|
||||
out.setdefault("targets", [])
|
||||
return out
|
||||
|
||||
|
||||
def _fallback_targets_from_instruction(user_text: str) -> list[str]:
|
||||
"""当 Router 未抽出 targets 时,从指令中简单抽取「对 X 拍照」中的 X。"""
|
||||
if not (user_text and user_text.strip()):
|
||||
return []
|
||||
text = user_text.strip()
|
||||
# 对X拍照 / 给X拍照 / 拍X / 对X拍张照 / 对X进行拍照
|
||||
for pat in [
|
||||
r"对\s*[「『]?(.+?)[」』]?\s*(?:进行)?拍(?:张)?照",
|
||||
r"给\s*(.+?)\s*拍(?:张)?照",
|
||||
r"拍\s*(.+?)(?:\s|,|。|$)",
|
||||
r"对\s*(.+?)\s*拍",
|
||||
]:
|
||||
m = re.search(pat, text)
|
||||
if m:
|
||||
x = m.group(1).strip()
|
||||
if x and len(x) <= 30:
|
||||
return [x]
|
||||
return []
|
||||
|
||||
|
||||
def _resolve_conflicts(intents: list[str]) -> list[str]:
|
||||
"""
|
||||
硬编码冲突处理逻辑
|
||||
@@ -160,7 +231,7 @@ def route(user_text: str) -> RouterResult:
|
||||
messages=messages,
|
||||
json_schema=ROUTER_JSON_SCHEMA,
|
||||
schema_name="router_response",
|
||||
temperature=0.1,
|
||||
temperature=0.0,
|
||||
)
|
||||
except Exception as e:
|
||||
# LLM 调用失败时兜底
|
||||
@@ -177,9 +248,20 @@ def route(user_text: str) -> RouterResult:
|
||||
if not isinstance(raw_intents, list):
|
||||
raw_intents = [str(raw_intents)] if raw_intents else []
|
||||
|
||||
# 规范化 entities:统一键名与类型,避免 LLM 返回畸形键导致日志/下游出错
|
||||
entities = _normalize_entities(raw_entities)
|
||||
|
||||
# 冲突处理
|
||||
resolved_intents = _resolve_conflicts(raw_intents)
|
||||
|
||||
# 有 search_task / photo_task 但 targets 为空时,从指令中兜底抽取
|
||||
if not entities.get("targets") and (
|
||||
"search_task" in resolved_intents or "photo_task" in resolved_intents
|
||||
):
|
||||
fallback = _fallback_targets_from_instruction(user_text)
|
||||
if fallback:
|
||||
entities = {**entities, "targets": fallback}
|
||||
|
||||
# 判断 Fast-Path:intents 非空且全部属于 ATOMIC_INTENTS
|
||||
is_fast_path = (
|
||||
len(resolved_intents) > 0
|
||||
@@ -188,7 +270,7 @@ def route(user_text: str) -> RouterResult:
|
||||
|
||||
return RouterResult(
|
||||
intents=resolved_intents,
|
||||
entities=raw_entities if isinstance(raw_entities, dict) else {},
|
||||
entities=entities,
|
||||
is_fast_path=is_fast_path,
|
||||
raw_llm_output=raw,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ RAG 数据灌入脚本
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
@@ -132,5 +134,21 @@ def run_ingestion(clear_first: bool = True) -> dict[str, int]:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
counts = run_ingestion()
|
||||
print("灌入完成:", counts)
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if "connection" in err or "refused" in err or "connect" in err:
|
||||
emb_url = os.getenv("LLM_EMBEDDING_BASE_URL", "http://localhost:8090/v1")
|
||||
print(
|
||||
"\n[错误] 无法连接 Embedding 服务,灌入需要向量接口。\n"
|
||||
f" 当前地址: {emb_url}\n"
|
||||
" 请先启动 llama-server 的 Embedding 端(默认端口 8090),例如:\n"
|
||||
" bash run_api.sh\n"
|
||||
" 或单独启动 Embedding 后再执行本脚本。\n"
|
||||
" 若服务在其他地址: export LLM_EMBEDDING_BASE_URL=http://主机:端口/v1\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user