diff --git a/.gitignore b/.gitignore index 25d98d7..116bbc9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ env/ # ChromaDB / data data/chroma/ +# Playground 测试指令日志(可选保留本地) +log/ + # Local config .env .env.local diff --git a/README.md b/README.md index f85d84b..c5c8912 100644 --- a/README.md +++ b/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 测试台 diff --git a/config/node_schema.json b/config/node_schema.json index 492ec8b..167f357 100644 --- a/config/node_schema.json +++ b/config/node_schema.json @@ -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": { diff --git a/data/knowledge/map_db.jsonl b/data/knowledge/map_db.jsonl index 7863409..8c0480b 100644 --- a/data/knowledge/map_db.jsonl +++ b/data/knowledge/map_db.jsonl @@ -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} diff --git a/playground.py b/playground.py index ef92bf9..6fcafd8 100644 --- a/playground.py +++ b/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,177 +171,221 @@ 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.button("执行规划", type="primary", key="run_plan"): - # 再次同步 blackboard(用户可能在点击前改了侧边栏) - bb = DroneStateBlackboard() - bb.is_in_air = is_in_air + # 执行中时禁用按钮,避免重复点击导致并发请求 + if st.session_state.run_in_progress: + st.warning("⏳ 正在执行规划,请等待完成...") + 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 - rag_context = None - layer2_prompt = None - layer2_meta = None - tool_call_log: list[dict] = [] - layer3_json = None - layer4_ascii = None - fast_path = False - error_msg = None - timing: dict[str, float] = {} + layer1_result = None + rag_context = None + layer2_prompt = None + 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] = {} - try: - # Layer 1: Router - t0 = time.perf_counter() - layer1_result = route(run_text) - timing["Layer1_Router"] = (time.perf_counter() - t0) * 1000 - fast_path = layer1_result.is_fast_path + with st.spinner("正在执行规划,请稍候..."): + try: + # Layer 1: Router + t0 = time.perf_counter() + layer1_result = route(run_text) + timing["Layer1_Router"] = (time.perf_counter() - t0) * 1000 + fast_path = layer1_result.is_fast_path - if fast_path: - st.success("✅ Layer 1 触发 Fast-Path 短路,后续层不执行") - else: - # RAG 检索(阶段二) - t0 = time.perf_counter() - retriever = RAGRetriever() - rag_context = retriever.retrieve_context( - intents=layer1_result.intents, - entities=layer1_result.entities, - user_text=run_text, - ) - timing["RAG_检索"] = (time.perf_counter() - t0) * 1000 - - # Layer 2: Composer - t0 = time.perf_counter() - schema_json = _load_node_schema() - layer2_prompt, layer2_meta = build_system_prompt( - intents=layer1_result.intents, - entities=layer1_result.entities, - schema_json=schema_json, - rag_context=rag_context, - ) - timing["Layer2_Composer"] = (time.perf_counter() - t0) * 1000 - - # Layer 3: Planner(支持 Tool 循环) - t0 = time.perf_counter() - layer3_json = plan( - system_prompt=layer2_prompt, - user_text=run_text, - tool_call_log=tool_call_log, - ) - timing["Layer3_Planner"] = (time.perf_counter() - t0) * 1000 - - # Layer 4: 解析 + 包装(会读取 blackboard.is_in_air) - t0 = time.perf_counter() - root_node = layer3_json.get("root") or layer3_json - business_tree = parse_json_to_tree(root_node) - final_tree = wrap_and_build_tree(business_tree) - layer4_ascii = tree_to_ascii(final_tree) - timing["Layer4_Execution"] = (time.perf_counter() - t0) * 1000 - - except Exception as e: - error_msg = str(e) - import traceback - st.error(f"❌ 执行出错: {error_msg}") - st.code(traceback.format_exc(), language="text") - - # ----------------------------------------------------------------------- - # 各环节耗时 - # ----------------------------------------------------------------------- - if timing: - st.subheader("⏱️ 各环节耗时 (ms)") - cols = st.columns(len(timing)) - for i, (name, ms) in enumerate(timing.items()): - with cols[i]: - st.metric(name, f"{ms:.0f} ms") - st.caption(f"总计: {sum(timing.values()):.0f} ms") - - # ----------------------------------------------------------------------- - # 分步展示 - # ----------------------------------------------------------------------- - st.subheader("📋 各层输出") - - # Layer 1 - with st.expander("**Layer 1 (Router)** - 意图与实体", expanded=True): - if layer1_result: - col1, col2, col3 = st.columns(3) - with col1: - st.metric("intents", ", ".join(layer1_result.intents) or "-") - with col2: - ent_str = json.dumps(layer1_result.entities, ensure_ascii=False) - st.metric("entities", ent_str[:60] + "..." if len(ent_str) > 60 else ent_str or "-") - with col3: - st.metric("Fast-Path", "是" if fast_path else "否") - st.json(layer1_result.entities) - else: - st.warning("Layer 1 未执行或出错") - - if not fast_path: - # RAG 检索结果(Layer 1 与 Layer 2 之间) - with st.expander("**RAG 检索结果** - 地图坐标、规则、Few-shot 示例", expanded=True): - if rag_context: - col1, col2 = st.columns(2) - with col1: - st.markdown("**地图坐标 (map_context)**") - if rag_context.get("map_context"): - st.code(rag_context["map_context"], language=None) - else: - st.caption("无匹配") - with col2: - st.markdown("**规则约束 (rule_context)**") - if rag_context.get("rule_context"): - st.code(rag_context["rule_context"], language=None) - else: - st.caption("无匹配") - st.markdown("**Few-shot 示例**") - if rag_context.get("few_shot_examples"): - for i, ex in enumerate(rag_context["few_shot_examples"]): - st.json({"instruction": ex.get("instruction"), "tree_json": ex.get("tree_json")}) + if fast_path: + st.success("✅ Layer 1 触发 Fast-Path 短路,后续层不执行") 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: - st.caption("RAG 未执行") + st.warning("Layer 1 未执行或出错") - # Layer 2 - with st.expander("**Layer 2 (Composer)** - 精简 Prompt 与选中节点", expanded=True): - if layer2_meta: - st.metric("Prompt 长度", f"{layer2_meta.get('prompt_length', 0)} 字符") - st.metric("选中节点", ", ".join(layer2_meta.get("selected_actions", []))) - st.metric("基准点坐标", json.dumps(layer2_meta.get("base_location_coords", {}), ensure_ascii=False)) - if layer2_prompt: - preview = layer2_prompt[:2000] + ("..." if len(layer2_prompt) > 2000 else "") - st.code(preview, language="text") + if not fast_path: + # RAG 检索结果(Layer 1 与 Layer 2 之间) + with st.expander("**RAG 检索结果** - 地图坐标、规则、Few-shot 示例", expanded=True): + if rag_context: + col1, col2 = st.columns(2) + with col1: + st.markdown("**地图坐标 (map_context)**") + if rag_context.get("map_context"): + st.code(rag_context["map_context"], language=None) + else: + st.caption("无匹配") + with col2: + st.markdown("**规则约束 (rule_context)**") + if rag_context.get("rule_context"): + st.code(rag_context["rule_context"], language=None) + else: + st.caption("无匹配") + st.markdown("**Few-shot 示例**") + if rag_context.get("few_shot_examples"): + for i, ex in enumerate(rag_context["few_shot_examples"]): + st.json({"instruction": ex.get("instruction"), "tree_json": ex.get("tree_json")}) + else: + st.caption("无相似示例") + else: + st.caption("RAG 未执行") - # 坐标计算与 Tool Call 日志(仅 LLM 通过 mcp 工具计算) - rel_descs = (rag_context or {}).get("relative_descriptions") or [] - has_any = bool(rel_descs) or bool(tool_call_log) - with st.expander("**坐标计算与 Tool Call 日志**", expanded=has_any): - if rel_descs: - st.markdown("**RAG 提供的相对描述**(需 LLM 调用 calculate_relative_coordinate)") - for rd in rel_descs: - st.json(rd) - if tool_call_log: - st.markdown("**LLM Tool Call**(大模型调用 mcp 计算)") - for entry in tool_call_log: - st.markdown(f"第 {entry.get('round', '?')} 次 `{entry.get('tool', '')}`") - st.json({"arguments": entry.get("arguments"), "result": entry.get("result")}) - if not rel_descs and not tool_call_log: - st.caption("无相对描述(指令仅含绝对地点时,直接使用 base_location_coords)") + # Layer 2 + with st.expander("**Layer 2 (Composer)** - 精简 Prompt 与选中节点", expanded=True): + if layer2_meta: + st.metric("Prompt 长度", f"{layer2_meta.get('prompt_length', 0)} 字符") + st.metric("选中节点", ", ".join(layer2_meta.get("selected_actions", []))) + st.metric("基准点坐标", json.dumps(layer2_meta.get("base_location_coords", {}), ensure_ascii=False)) + if layer2_prompt: + preview = layer2_prompt[:2000] + ("..." if len(layer2_prompt) > 2000 else "") + st.code(preview, language="text") - # Layer 3 - with st.expander("**Layer 3 (Planner)** - 原生业务树 JSON", expanded=True): - if layer3_json: - st.json(layer3_json) - else: - st.warning("Layer 3 未执行") + # 坐标计算与 Tool Call 日志(仅 LLM 通过 mcp 工具计算) + rel_descs = (rag_context or {}).get("relative_descriptions") or [] + has_any = bool(rel_descs) or bool(tool_call_log) + with st.expander("**坐标计算与 Tool Call 日志**", expanded=has_any): + if rel_descs: + st.markdown("**RAG 提供的相对描述**(需 LLM 调用 calculate_relative_coordinate)") + for rd in rel_descs: + st.json(rd) + if tool_call_log: + st.markdown("**LLM Tool Call**(大模型调用 mcp 计算)") + for entry in tool_call_log: + st.markdown(f"第 {entry.get('round', '?')} 次 `{entry.get('tool', '')}`") + st.json({"arguments": entry.get("arguments"), "result": entry.get("result")}) + if not rel_descs and not tool_call_log: + st.caption("无相对描述(指令仅含绝对地点时,直接使用 base_location_coords)") - # Layer 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 未执行") + # 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 未执行") + + # 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 未执行") diff --git a/run_api.sh b/run_api.sh index 5321c65..9554cb5 100755 --- a/run_api.sh +++ b/run_api.sh @@ -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 思考模式,否则会生成大量 导致首层 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 diff --git a/src/drone_planning/api/routes.py b/src/drone_planning/api/routes.py index aca405f..1e7ec82 100644 --- a/src/drone_planning/api/routes.py +++ b/src/drone_planning/api/routes.py @@ -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 格式异常") diff --git a/src/drone_planning/execution/nodes.py b/src/drone_planning/execution/nodes.py index 4936c41..8fcebb8 100644 --- a/src/drone_planning/execution/nodes.py +++ b/src/drone_planning/execution/nodes.py @@ -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 类型""" diff --git a/src/drone_planning/execution/tree_wrapper.py b/src/drone_planning/execution/tree_wrapper.py index a091906..6c16590 100644 --- a/src/drone_planning/execution/tree_wrapper.py +++ b/src/drone_planning/execution/tree_wrapper.py @@ -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) diff --git a/src/drone_planning/llm_client/client.py b/src/drone_planning/llm_client/client.py index 5c98863..66af940 100644 --- a/src/drone_planning/llm_client/client.py +++ b/src/drone_planning/llm_client/client.py @@ -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 diff --git a/src/drone_planning/pipeline/composer.py b/src/drone_planning/pipeline/composer.py index 7cb1da1..12bdf5d 100644 --- a/src/drone_planning/pipeline/composer.py +++ b/src/drone_planning/pipeline/composer.py @@ -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"): diff --git a/src/drone_planning/pipeline/planner.py b/src/drone_planning/pipeline/planner.py index 609af66..d74b8fc 100644 --- a/src/drone_planning/pipeline/planner.py +++ b/src/drone_planning/pipeline/planner.py @@ -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() diff --git a/src/drone_planning/pipeline/router.py b/src/drone_planning/pipeline/router.py index 099ff2b..018fedc 100644 --- a/src/drone_planning/pipeline/router.py +++ b/src/drone_planning/pipeline/router.py @@ -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, ) diff --git a/src/drone_planning/rag/ingestion.py b/src/drone_planning/rag/ingestion.py index 15d69d6..a03a6c1 100644 --- a/src/drone_planning/rag/ingestion.py +++ b/src/drone_planning/rag/ingestion.py @@ -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__": - counts = run_ingestion() - print("灌入完成:", counts) + 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