116 lines
4.2 KiB
Python
116 lines
4.2 KiB
Python
"""与 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)
|