优化文档与测试脚本

This commit is contained in:
lulijing
2026-03-23 20:26:13 +08:00
parent 3ddc2e1ff4
commit 2d50522e68
14 changed files with 654 additions and 188 deletions

3
.gitignore vendored
View File

@@ -33,6 +33,9 @@ env/
# ChromaDB / data
data/chroma/
# Playground 测试指令日志(可选保留本地)
log/
# Local config
.env
.env.local

View File

@@ -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 测试台

View File

@@ -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": {

View File

@@ -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}

View File

@@ -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 []}",
]
# 实体:确保可序列化为合法 JSONRouter 偶发畸形键时仍可写日志)
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 未执行")

View File

@@ -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

View File

@@ -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 格式异常")

View File

@@ -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 类型"""

View File

@@ -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.xParallelPolicy 为 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)

View File

@@ -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支持 toolsFunction 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

View File

@@ -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_coordsENU单位米")
@@ -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_searchtarget_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"):

View File

@@ -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()

View File

@@ -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_tasktargets 填 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_taskentities.targets 必须包含该目标描述。
6. 人工确认条件类指令比如"搜索小汽车,搜索到了我确认后再决定要不要拍照。"必须也要包含 interact_task 与 search_task 与 photo_taskentities.targets 必须包含该目标描述。
7. **抵近拍照/接近目标类指令**:如「抵近拍照」「接近汽车再拍」「靠近目标到 5 米」等,必须输出 approach_tasktargets 填目标;若指定距离则 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-Pathintents 非空且全部属于 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,
)

View File

@@ -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