"""与 scripts/qwen_flight_intent_sim.py 对齐:飞控意图 JSON vs 闲聊,供语音主程序内调用。""" from __future__ import annotations import json import os import re from pathlib import Path from typing import Any, Optional, Tuple # 与 qwen_flight_intent_sim._SYSTEM 保持一致 FLIGHT_INTENT_CHAT_SYSTEM = """你是无人机飞控意图助手,只做两件事(必须二选一): 【规则 A — 飞控相关】当用户话里包含对无人机的飞行任务、航线、起降、返航、悬停、等待、速度高度、坐标点、offboard、PX4/MAVROS 等操作意图时: 只输出一行 JSON,且不要有任何其它字符、不要 Markdown、不要代码块。 JSON Schema 含义(见仓库 docs/FLIGHT_INTENT_SCHEMA_v1.md): { "is_flight_intent": true, "version": 1, "actions": [ // 按时间顺序排列 {"type": "takeoff", "args": {}}, {"type": "takeoff", "args": {"relative_altitude_m": number}}, {"type": "goto", "args": {"frame": "local_ned"|"body_ned", "x": number|null, "y": number|null, "z": number|null}}, {"type": "land" | "return_home" | "hover" | "hold", "args": {}}, {"type": "wait", "args": {"seconds": number}} ], "summary": "一句话概括", "trace_id": "可选,简短追踪ID" } - 停多久、延迟多久必须用 wait,例如「悬停 3 秒再降落」应为 hover → wait(3) → land;不要把秒数写进 summary 代替 wait。 - 坐标缺省 frame 时用 "local_ned";无法确定的数字可省略字段或用 null。 - 返程/返航映射为 {"type":"return_home","args":{}}。 - 仅允许小写 type;args 只含规范允许键,禁止多余键。 【规则 B — 非飞控】若只是日常聊天、与无人机任务无关:用正常的自然中文回复,不要输出 JSON,不要用花括号开头。""" def _strip_fenced_json(text: str) -> str: text = text.strip() m = re.match(r"^```(?:json)?\s*\n?(.*)\n?```\s*$", text, re.DOTALL | re.IGNORECASE) if m: return m.group(1).strip() return text def _first_balanced_json_object(text: str) -> Optional[str]: t = _strip_fenced_json(text) start = t.find("{") if start < 0: return None depth = 0 for i in range(start, len(t)): if t[i] == "{": depth += 1 elif t[i] == "}": depth -= 1 if depth == 0: return t[start : i + 1] return None def parse_flight_intent_reply(raw: str) -> Tuple[str, Optional[dict[str, Any]]]: """返回 (模式标签, 若为飞控则 dict 否则 None)。""" chunk = _first_balanced_json_object(raw) if chunk: try: obj = json.loads(chunk) except json.JSONDecodeError: return "闲聊", None if isinstance(obj, dict) and obj.get("is_flight_intent") is True: return "飞控意图JSON", obj return "闲聊", None def default_qwen_gguf_path(project_root: Path) -> Path: """子工程优先本目录 cache/;不存在时回退到上级仓库(同级 rocket_drone_audio/cache/)。""" name = "qwen2.5-1.5b-instruct-q4_k_m.gguf" primary = project_root / "cache" / "qwen25-1.5b-gguf" / name if primary.is_file(): return primary legacy = project_root.parent / "cache" / "qwen25-1.5b-gguf" / name if legacy.is_file(): return legacy return primary def load_llama_qwen( model_path: Path, n_ctx: int = 4096, ): """ llama-cpp-python 封装。可选环境变量(详见 rocket_drone_audio 文件头): ROCKET_LLM_N_THREADS、ROCKET_LLM_N_GPU_LAYERS、ROCKET_LLM_N_BATCH。 """ if not model_path.is_file(): return None try: from llama_cpp import Llama except ImportError: return None opts: dict = { "model_path": str(model_path), "n_ctx": int(n_ctx), "verbose": False, } nt = os.environ.get("ROCKET_LLM_N_THREADS", "").strip() if nt.isdigit() or (nt.startswith("-") and nt[1:].isdigit()): opts["n_threads"] = max(1, int(nt)) ng = os.environ.get("ROCKET_LLM_N_GPU_LAYERS", "").strip() if ng.isdigit(): opts["n_gpu_layers"] = int(ng) nb = os.environ.get("ROCKET_LLM_N_BATCH", "").strip() if nb.isdigit(): opts["n_batch"] = max(1, int(nb)) return Llama(**opts)