优化文档与测试脚本

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 # ChromaDB / data
data/chroma/ data/chroma/
# Playground 测试指令日志(可选保留本地)
log/
# Local config # Local config
.env .env
.env.local .env.local

View File

@@ -46,12 +46,24 @@ export ENABLE_THINKING=true
### 3. RAG 知识库灌入 ### 3. RAG 知识库灌入
首次使用或更新知识库后需执行灌入 **必须先启动 Embedding 服务**(默认 `http://localhost:8090/v1`)。灌入会向该地址请求向量;未启动会出现 `Connection refused`。可与 Chat 一并启动
```bash ```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/` 知识库文件位于 `data/knowledge/`
- `map_db.jsonl`地点坐标location, x, y, z - `map_db.jsonl`地点坐标location, x, y, z
@@ -60,8 +72,10 @@ python -m drone_planning.rag.ingestion
### 4. 启动 API 服务 ### 4. 启动 API 服务
项目根目录下需让 Python 能找到 `src/drone_planning`(已 `pip install -e .` 可省略):
```bash ```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 测试台 ### 5. 启动 Playground 测试台

View File

@@ -50,6 +50,19 @@
"count": "int" "count": "int"
} }
}, },
"approach_target": {
"desc": "接近目标到指定距离时停止(默认 3 米,用于抵近拍照、接近目标等)",
"params": {
"target_class": "string",
"distance": "float默认 3"
}
},
"return_home": {
"desc": "返航回基地/起飞点(不填 home_label 则回默认起飞点)",
"params": {
"home_label": "string可选"
}
},
"report_message": { "report_message": {
"desc": "上报消息", "desc": "上报消息",
"params": { "params": {

View File

@@ -3,3 +3,4 @@
{"document": "广场在喷泉西侧,坐标 x=10, y=15", "location": "广场", "x": 10, "y": 15, "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": "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": "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 from __future__ import annotations
import copy
import json import json
import re
import sys import sys
import time import time
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# 确保 src 在路径中 # 确保 src 在路径中
sys.path.insert(0, str(Path(__file__).resolve().parent / "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 import streamlit as st
from drone_planning.core.blackboard import DroneStateBlackboard 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.composer import _load_node_schema, build_system_prompt
from drone_planning.pipeline.planner import plan from drone_planning.pipeline.planner import plan
from drone_planning.pipeline.router import route from drone_planning.pipeline.router import route
@@ -59,177 +171,221 @@ with st.sidebar:
bb.is_in_air = is_in_air bb.is_in_air = is_in_air
st.info(f"当前: {'已起飞' if is_in_air else '在地面'}") 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 "" run_text = instruction.strip() if instruction else ""
if run_text: if run_text:
st.divider() st.divider()
if st.button("执行规划", type="primary", key="run_plan"): # 执行中时禁用按钮,避免重复点击导致并发请求
# 再次同步 blackboard用户可能在点击前改了侧边栏 if st.session_state.run_in_progress:
bb = DroneStateBlackboard() st.warning("⏳ 正在执行规划,请等待完成...")
bb.is_in_air = is_in_air st.caption("请勿重复点击或刷新页面,当前请求完成后将自动更新。")
else:
if st.button("执行规划", type="primary", key="run_plan"):
# 立即标记为执行中,防止并发请求
st.session_state.run_in_progress = True
# 本轮测试开始前,显式同步 Blackboard确保前后测试互不影响
DroneStateBlackboard().is_in_air = is_in_air
layer1_result = None layer1_result = None
rag_context = None rag_context = None
layer2_prompt = None layer2_prompt = None
layer2_meta = None layer2_meta = None
tool_call_log: list[dict] = [] tool_call_log: list[dict] = []
layer3_json = None layer3_json = None
layer4_ascii = None layer3_json_for_display = None # 后处理前的拷贝,仅用于 Layer 3 展示
fast_path = False layer4_ascii = None
error_msg = None fast_path = False
timing: dict[str, float] = {} error_msg = None
traceback_text: str | None = None
timing: dict[str, float] = {}
try: with st.spinner("正在执行规划,请稍候..."):
# Layer 1: Router try:
t0 = time.perf_counter() # Layer 1: Router
layer1_result = route(run_text) t0 = time.perf_counter()
timing["Layer1_Router"] = (time.perf_counter() - t0) * 1000 layer1_result = route(run_text)
fast_path = layer1_result.is_fast_path timing["Layer1_Router"] = (time.perf_counter() - t0) * 1000
fast_path = layer1_result.is_fast_path
if fast_path: if fast_path:
st.success("✅ Layer 1 触发 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: else:
st.caption("无相似示例") # 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()
# 先拷贝一份供 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)
timing["Layer4_Execution"] = (time.perf_counter() - t0) * 1000
except Exception as e:
error_msg = str(e)
import traceback
traceback_text = traceback.format_exc()
st.error(f"❌ 执行出错: {error_msg}")
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,
)
# -----------------------------------------------------------------------
# 各环节耗时
# -----------------------------------------------------------------------
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: else:
st.caption("RAG 未执行") st.warning("Layer 1 未执行或出错")
# Layer 2 if not fast_path:
with st.expander("**Layer 2 (Composer)** - 精简 Prompt 与选中节点", expanded=True): # RAG 检索结果Layer 1 与 Layer 2 之间)
if layer2_meta: with st.expander("**RAG 检索结果** - 地图坐标、规则、Few-shot 示例", expanded=True):
st.metric("Prompt 长度", f"{layer2_meta.get('prompt_length', 0)} 字符") if rag_context:
st.metric("选中节点", ", ".join(layer2_meta.get("selected_actions", []))) col1, col2 = st.columns(2)
st.metric("基准点坐标", json.dumps(layer2_meta.get("base_location_coords", {}), ensure_ascii=False)) with col1:
if layer2_prompt: st.markdown("**地图坐标 (map_context)**")
preview = layer2_prompt[:2000] + ("..." if len(layer2_prompt) > 2000 else "") if rag_context.get("map_context"):
st.code(preview, language="text") 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 未执行")
# 坐标计算与 Tool Call 日志(仅 LLM 通过 mcp 工具计算) # Layer 2
rel_descs = (rag_context or {}).get("relative_descriptions") or [] with st.expander("**Layer 2 (Composer)** - 精简 Prompt 与选中节点", expanded=True):
has_any = bool(rel_descs) or bool(tool_call_log) if layer2_meta:
with st.expander("**坐标计算与 Tool Call 日志**", expanded=has_any): st.metric("Prompt 长度", f"{layer2_meta.get('prompt_length', 0)} 字符")
if rel_descs: st.metric("选中节点", ", ".join(layer2_meta.get("selected_actions", [])))
st.markdown("**RAG 提供的相对描述**(需 LLM 调用 calculate_relative_coordinate") st.metric("基准点坐标", json.dumps(layer2_meta.get("base_location_coords", {}), ensure_ascii=False))
for rd in rel_descs: if layer2_prompt:
st.json(rd) preview = layer2_prompt[:2000] + ("..." if len(layer2_prompt) > 2000 else "")
if tool_call_log: st.code(preview, language="text")
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 # 坐标计算与 Tool Call 日志(仅 LLM 通过 mcp 工具计算)
with st.expander("**Layer 3 (Planner)** - 原生业务树 JSON", expanded=True): rel_descs = (rag_context or {}).get("relative_descriptions") or []
if layer3_json: has_any = bool(rel_descs) or bool(tool_call_log)
st.json(layer3_json) with st.expander("**坐标计算与 Tool Call 日志**", expanded=has_any):
else: if rel_descs:
st.warning("Layer 3 未执行") 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 4 # Layer 3展示 Planner 原始输出(后处理前的 JSON
with st.expander("**Layer 4 (Execution)** - 安全包装后的 py_trees ASCII 结构", expanded=True): with st.expander("**Layer 3 (Planner)** - 原生业务树 JSON后处理前", expanded=True):
if layer4_ascii: if layer3_json_for_display is not None:
st.text("当 is_in_air=False 时,此处应看到 [Safe_Execution] -> [SystemCheck] -> [Takeoff] -> 业务树") st.json(layer3_json_for_display)
st.code(layer4_ascii, language=None) elif layer3_json:
else: st.json(layer3_json)
st.warning("Layer 4 未执行") 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 未执行")

View File

@@ -8,6 +8,8 @@ cd "$(dirname "$0")"
# llama-server 所在目录(可在该目录下启动模型) # llama-server 所在目录(可在该目录下启动模型)
LLAMA_BIN_DIR="${LLAMA_BIN_DIR:-$HOME/llama.cpp/build/bin}" LLAMA_BIN_DIR="${LLAMA_BIN_DIR:-$HOME/llama.cpp/build/bin}"
# 强制关闭 thinking传递给 Python 侧 LLM 客户端)
export ENABLE_THINKING=false
cleanup() { cleanup() {
echo "" echo ""
@@ -18,8 +20,9 @@ cleanup() {
trap cleanup SIGINT SIGTERM trap cleanup SIGINT SIGTERM
# 启动 Chat 模型 (8081) # 启动 Chat 模型 (8081)
# --reasoning-budget 0强制关闭 Qwen3 思考模式,否则会生成大量 <think> 导致首层 Router 极慢(数分钟)
echo "=== 启动 LLM 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) & (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=$! LLAMA_CHAT_PID=$!
# 启动 Embedding 模型 (8090) # 启动 Embedding 模型 (8090)
@@ -33,4 +36,4 @@ sleep 8
# 启动 DronePlanning API (8000) # 启动 DronePlanning API (8000)
echo "=== 启动 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 fastapi import APIRouter, HTTPException
from pydantic import BaseModel 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.composer import _load_node_schema, build_system_prompt
from drone_planning.pipeline.planner import plan from drone_planning.pipeline.planner import plan
from drone_planning.pipeline.router import route 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 timing["Layer3_Planner"] = (time.perf_counter() - t0) * 1000
# Layer 4: 解析 + 包装 # Layer 4: 后处理 + 解析 + 包装
t0 = time.perf_counter() t0 = time.perf_counter()
root_node = tree_json.get("root") or tree_json root_node = tree_json.get("root") or tree_json
if isinstance(root_node, dict): 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) business_tree = parse_json_to_tree(root_node)
else: else:
raise ValueError("Planner 返回的 root 格式异常") raise ValueError("Planner 返回的 root 格式异常")

View File

@@ -58,6 +58,18 @@ class FlyToWaypointAction(Behaviour):
return common.Status.SUCCESS 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): class GenericAction(Behaviour):
"""通用动作兜底:用于未单独实现的 action 类型""" """通用动作兜底:用于未单独实现的 action 类型"""

View File

@@ -17,11 +17,106 @@ from drone_planning.core.blackboard import DroneStateBlackboard
from drone_planning.execution.nodes import ( from drone_planning.execution.nodes import (
FlyToWaypointAction, FlyToWaypointAction,
GenericAction, GenericAction,
ReturnHomeAction,
SystemCheckCondition, SystemCheckCondition,
TakeoffAction, 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: def parse_json_to_tree(node_dict: dict[str, Any]) -> py_trees.behaviour.Behaviour:
""" """
递归将 JSON 节点转换为 py_trees 对象 递归将 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": if node_type == "Parallel":
policy = params.get("policy", "success_on_all") policy = params.get("policy", "success_on_all")
children = [parse_json_to_tree(c) for c in children_data] 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( return py_trees.composites.Parallel(
name=name, name=name,
policy=py_trees.common.ParallelPolicy.SUCCESS_ON_ALL policy=parallel_policy,
if policy == "success_on_all"
else py_trees.common.ParallelPolicy.SUCCESS_ON_ONE,
children=children, 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)) y = float(params.get("y", 0))
z = float(params.get("z", 0)) z = float(params.get("z", 0))
return FlyToWaypointAction(name=name, x=x, y=y, z=z) 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 兜底 # 其他 action 用 GenericAction 兜底
return GenericAction(name=name, action_type=node_type, params=params) 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_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") 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 通常不需要 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": False}}
EXTRA_BODY = {"chat_template_kwargs": {"enable_thinking": ENABLE_THINKING}} # 请求超时(秒),防止长时间无响应
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
def get_chat_client() -> OpenAI: def get_chat_client() -> OpenAI:
@@ -29,14 +30,16 @@ def get_chat_client() -> OpenAI:
return OpenAI( return OpenAI(
base_url=LLM_CHAT_BASE_URL, base_url=LLM_CHAT_BASE_URL,
api_key=OPENAI_API_KEY, api_key=OPENAI_API_KEY,
timeout=LLM_TIMEOUT,
) )
def chat_completion( def chat_completion(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
model: str = "qwen3", model: str = "qwen3",
temperature: float = 0.3, temperature: float = 0.0,
response_format: dict[str, Any] | None = None, response_format: dict[str, Any] | None = None,
max_tokens: int | None = 2048,
) -> str: ) -> str:
""" """
调用 Chat Completion API 调用 Chat Completion API
@@ -57,6 +60,7 @@ def chat_completion(
"messages": messages, "messages": messages,
"temperature": temperature, "temperature": temperature,
"extra_body": EXTRA_BODY, "extra_body": EXTRA_BODY,
"max_tokens": max_tokens,
} }
if response_format is not None: if response_format is not None:
kwargs["response_format"] = response_format kwargs["response_format"] = response_format
@@ -70,7 +74,8 @@ def chat_completion_with_tools(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
tools: list[dict[str, Any]], tools: list[dict[str, Any]],
model: str = "qwen3", model: str = "qwen3",
temperature: float = 0.2, temperature: float = 0.0,
max_tokens: int = 2048,
) -> Any: ) -> Any:
""" """
调用 Chat Completion支持 toolsFunction Calling 调用 Chat Completion支持 toolsFunction Calling
@@ -85,6 +90,7 @@ def chat_completion_with_tools(
tools=tools, tools=tools,
temperature=temperature, temperature=temperature,
extra_body=EXTRA_BODY, extra_body=EXTRA_BODY,
max_tokens=max_tokens,
) )
@@ -93,7 +99,8 @@ def chat_completion_json(
json_schema: dict[str, Any], json_schema: dict[str, Any],
schema_name: str = "response", schema_name: str = "response",
model: str = "qwen3", model: str = "qwen3",
temperature: float = 0.3, temperature: float = 0.0,
max_tokens: int = 1024,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
调用 Chat Completion并强制返回符合 JSON Schema 的结构化输出 调用 Chat Completion并强制返回符合 JSON Schema 的结构化输出
@@ -126,6 +133,7 @@ def chat_completion_json(
model=model, model=model,
temperature=temperature, temperature=temperature,
response_format=response_format, response_format=response_format,
max_tokens=max_tokens,
) )
except Exception as e: except Exception as e:
# 部分本地服务器可能不支持 json_schema回退到 json_object # 部分本地服务器可能不支持 json_schema回退到 json_object
@@ -135,6 +143,7 @@ def chat_completion_json(
model=model, model=model,
temperature=temperature, temperature=temperature,
response_format={"type": "json_object"}, response_format={"type": "json_object"},
max_tokens=max_tokens,
) )
else: else:
raise e raise e

View File

@@ -19,6 +19,8 @@ INTENT_TO_ACTIONS: dict[str, list[str]] = {
"search_task": ["search_pattern", "rotate_search"], "search_task": ["search_pattern", "rotate_search"],
"track_task": ["track_object"], "track_task": ["track_object"],
"photo_task": ["take_photos"], "photo_task": ["take_photos"],
"approach_task": ["approach_target"],
"return_task": ["return_home"],
"interact_task": ["report_message", "manual_confirmation"], "interact_task": ["report_message", "manual_confirmation"],
} }
@@ -119,6 +121,23 @@ def build_system_prompt(
parts.append(trimmed_schema["_instruction"]) parts.append(trimmed_schema["_instruction"])
parts.append("") 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: if base_location_coords:
parts.append("## 基准点坐标 base_location_coordsENU单位米") 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(json.dumps(trimmed_schema, ensure_ascii=False, indent=2))
parts.append("") 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 区块(阶段二) # RAG 区块(阶段二)
if rag_context: if rag_context:
if rag_context.get("map_context"): if rag_context.get("map_context"):

View File

@@ -107,7 +107,7 @@ def plan(
response = chat_completion_with_tools( response = chat_completion_with_tools(
messages=messages, messages=messages,
tools=PLANNER_TOOLS, tools=PLANNER_TOOLS,
temperature=0.2, temperature=0.0,
) )
except Exception as e: except Exception as e:
# 若模型不支持 tools回退到无工具模式 # 若模型不支持 tools回退到无工具模式
@@ -184,13 +184,14 @@ def _plan_fallback(system_prompt: str, user_text: str) -> dict[str, Any]:
messages=messages, messages=messages,
json_schema=PLANNER_JSON_SCHEMA, json_schema=PLANNER_JSON_SCHEMA,
schema_name="behavior_tree", schema_name="behavior_tree",
temperature=0.2, temperature=0.0,
max_tokens=2048,
) )
return raw return raw
except Exception: except Exception:
content = chat_completion( content = chat_completion(
messages=messages, messages=messages,
temperature=0.2, temperature=0.0,
response_format={"type": "json_object"}, response_format={"type": "json_object"},
) )
text = content.strip() text = content.strip()

View File

@@ -6,6 +6,7 @@ Layer 1意图路由层Stage 1 Intent Router
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any 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"} 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 ALL_VALID_INTENTS = ATOMIC_INTENTS | BUSINESS_INTENTS
# 兜底意图:当 intents 为空或包含未识别标签时使用 # 兜底意图:当 intents 为空或包含未识别标签时使用
@@ -30,7 +31,7 @@ ROUTER_JSON_SCHEMA = {
"intents": { "intents": {
"type": "array", "type": "array",
"items": {"type": "string"}, "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": { "entities": {
"type": "object", "type": "object",
@@ -67,23 +68,38 @@ ROUTER_SYSTEM_PROMPT = """你是指令意图分类器。根据用户自然语言
**业务意图:** **业务意图:**
- fly_task空间移动、路径、巡逻、飞到某地 - fly_task空间移动、路径、巡逻、飞到某地
- search_task搜索、侦查 - search_task搜索、侦查、检测、寻找
- track_task跟踪 - track_task跟踪
- photo_task拍照 - photo_task拍照
- approach_task抵近、接近目标、抵近拍照需使用 approach_target 节点distance 默认 3 米)
- return_task返航仅指回基地/起飞点,不是去具体地点)
- interact_task上报、请求确认 - interact_task上报、请求确认
## 实体要求 ## 实体要求
- locations基地点列表如 ["大门","广场"]。对于「广场东边500米」只填 ["广场"],不要填 "广场东边500米" - locations基地点列表如 ["大门","广场"]。对于「广场东边500米」只填 ["广场"],不要填 "广场东边500米"
- targets目标列表如 ["汽车","行人","公交车"] - targets目标列表如 ["汽车","行人","戴帽子的人","公交车"](对谁/对什么执行 search/photo 就填谁)
- direction东/east、西/west、南/south、北/north、东北/northeast 等,或 front|back|left|right|up|down - direction东/east、西/west、南/south、北/north、东北/northeast 等,或 front|back|left|right|up|down
- distance数字如「东边500米」中的 500 - 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 可为空对象。 1. 若用户只说"起飞""降落""悬停",只输出对应 atomic 意图entities 可为空对象。
2. 若用户说复杂任务(如"飞到大门然后拍照"),输出业务意图,并抽取 locations、targets 等。 2. 若用户说复杂任务(如"飞到大门然后拍照"),输出业务意图,并抽取 locations、targets 等。
3. 若同时包含原子和业务(如"起飞后去广场"),两者都输出,由系统后续处理。 3. 若同时包含原子和业务(如"起飞后去广场"),两者都输出,由系统后续处理。
4. 对于「广场东边500米」「大门北偏东30度100米」等相对描述必须拆分locations=["广场"], direction="", distance=500 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 @dataclass
@@ -114,6 +130,61 @@ class RouterResult:
return [] 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]: def _resolve_conflicts(intents: list[str]) -> list[str]:
""" """
硬编码冲突处理逻辑 硬编码冲突处理逻辑
@@ -160,7 +231,7 @@ def route(user_text: str) -> RouterResult:
messages=messages, messages=messages,
json_schema=ROUTER_JSON_SCHEMA, json_schema=ROUTER_JSON_SCHEMA,
schema_name="router_response", schema_name="router_response",
temperature=0.1, temperature=0.0,
) )
except Exception as e: except Exception as e:
# LLM 调用失败时兜底 # LLM 调用失败时兜底
@@ -177,9 +248,20 @@ def route(user_text: str) -> RouterResult:
if not isinstance(raw_intents, list): if not isinstance(raw_intents, list):
raw_intents = [str(raw_intents)] if raw_intents else [] raw_intents = [str(raw_intents)] if raw_intents else []
# 规范化 entities统一键名与类型避免 LLM 返回畸形键导致日志/下游出错
entities = _normalize_entities(raw_entities)
# 冲突处理 # 冲突处理
resolved_intents = _resolve_conflicts(raw_intents) 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 # 判断 Fast-Pathintents 非空且全部属于 ATOMIC_INTENTS
is_fast_path = ( is_fast_path = (
len(resolved_intents) > 0 len(resolved_intents) > 0
@@ -188,7 +270,7 @@ def route(user_text: str) -> RouterResult:
return RouterResult( return RouterResult(
intents=resolved_intents, intents=resolved_intents,
entities=raw_entities if isinstance(raw_entities, dict) else {}, entities=entities,
is_fast_path=is_fast_path, is_fast_path=is_fast_path,
raw_llm_output=raw, raw_llm_output=raw,
) )

View File

@@ -9,6 +9,8 @@ RAG 数据灌入脚本
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -132,5 +134,21 @@ def run_ingestion(clear_first: bool = True) -> dict[str, int]:
if __name__ == "__main__": if __name__ == "__main__":
counts = run_ingestion() try:
print("灌入完成:", counts) 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