DroneMind/voice_drone/core/qwen_intent_chat.py
2026-04-14 09:54:26 +08:00

116 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""与 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":{}}。
- 仅允许小写 typeargs 只含规范允许键,禁止多余键。
【规则 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)