Add coordinate tool flow and tighten tool usage

Introduce ENU offset tool support with controlled tool-call handling, update prompts to gate coordinate tooling, and enable jinja tools in startup.
This commit is contained in:
2026-01-19 15:01:04 +08:00
parent b68883e4e0
commit ffb9aee730
5 changed files with 230 additions and 6 deletions

View File

@@ -233,5 +233,13 @@
**仅当指令涉及前往“具体地点”(如广场、大门)的偏移位置时,才需计算绝对坐标并使用`fly_to_waypoint`**
当指令包含“具体地点 + 方向 + 距离”的偏移如“广场西边200米”**必须**先调用工具`calc_offset_enu`计算绝对坐标,再使用`fly_to_waypoint`。工具参数:
- `base`: 参考地点的ENU坐标含x/y/z
- `direction`: east/west/north/south/up/down
- `distance`: 偏移距离(米)
当指令只有“方向 + 距离”且**没有具体地点名词**时,**禁止**调用`calc_offset_enu`,必须使用`move_direction`。
当指令描述“附近/边上/区域内”等模糊位置且**无方向+距离**时,视为到该地点本身,不做偏移计算。
## 八、输出要求
仅输出1个严格符合上述所有规则的JSON对象。

View File

@@ -10,6 +10,7 @@ from openai import OpenAIError
import jsonschema
import requests
import platform # 新增:用于选择合适的中文字体
from .tools.coordinate_tools import calc_offset_enu
# --- 自定义远程嵌入函数 (与ingest.py中定义一致) ---
from chromadb.api.types import Documents, EmbeddingFunction, Embeddings, Embeddable
@@ -644,6 +645,99 @@ class PyTreeGenerator:
logging.error(f"提示词文件未找到 -> {file_name}")
return ""
def _get_tool_definitions(self) -> list[dict]:
return [
{
"type": "function",
"function": {
"name": "calc_offset_enu",
"description": "根据ENU坐标系基准点、方向和距离计算偏移后的坐标。",
"parameters": {
"type": "object",
"properties": {
"base": {
"type": "object",
"properties": {
"x": {"type": "number"},
"y": {"type": "number"},
"z": {"type": "number"}
},
"required": ["x", "y", "z"]
},
"direction": {
"type": "string",
"enum": [
"east", "west", "north", "south",
"up", "down"
]
},
"distance": {"type": "number", "minimum": 0}
},
"required": ["base", "direction", "distance"]
}
}
}
]
def _normalize_tool_calls(self, tool_calls: list) -> list[dict]:
normalized = []
for call in tool_calls:
if hasattr(call, "function"):
normalized.append(
{
"id": getattr(call, "id", None),
"name": call.function.name,
"arguments": call.function.arguments,
}
)
elif isinstance(call, dict):
normalized.append(
{
"id": call.get("id"),
"name": (call.get("function") or {}).get("name"),
"arguments": (call.get("function") or {}).get("arguments"),
}
)
return normalized
def _execute_tool_calls(self, tool_calls: list) -> list[dict]:
tool_messages = []
normalized = self._normalize_tool_calls(tool_calls)
for call in normalized:
tool_name = call.get("name")
tool_args = call.get("arguments")
tool_id = call.get("id")
if not tool_name:
logging.warning("工具调用缺少名称,已忽略。")
continue
try:
args = json.loads(tool_args or "{}")
except json.JSONDecodeError:
logging.warning(f"工具调用参数解析失败: {tool_args}")
continue
try:
if tool_name == "calc_offset_enu":
result = calc_offset_enu(**args)
else:
result = {"error": f"unsupported tool: {tool_name}"}
tool_messages.append(
{
"role": "tool",
"tool_call_id": tool_id,
"content": json.dumps(result, ensure_ascii=False)
}
)
except Exception as exc:
logging.warning(f"工具调用执行失败({tool_name}): {exc}")
tool_messages.append(
{
"role": "tool",
"tool_call_id": tool_id,
"content": json.dumps({"error": str(exc)}, ensure_ascii=False)
}
)
return tool_messages
def _retrieve_context(self, query: str) -> Optional[str]:
logging.info("--- 开始从向量数据库检索上下文 ---")
try:
@@ -661,6 +755,33 @@ class PyTreeGenerator:
logging.error(f"从向量数据库检索时发生错误: {e}")
return None
def _should_enable_tools(self, user_prompt: str, retrieved_context: Optional[str]) -> bool:
if not user_prompt:
return False
direction_patterns = [
"东边", "西边", "南边", "北边",
"东侧", "西侧", "南侧", "北侧",
"往东", "往西", "往南", "往北",
"向东", "向西", "向南", "向北",
]
has_direction = any(pat in user_prompt for pat in direction_patterns)
has_distance = re.search(r"\d+(\.\d+)?\s*(米|m)", user_prompt) is not None
if not (has_direction and has_distance):
return False
if not retrieved_context:
return False
place_candidates: list[str] = []
for line in retrieved_context.splitlines():
if "地点:" in line or "别名:" in line:
place_candidates.extend(re.findall(r"'([^']+)'", line))
if not place_candidates:
return False
return any(place in user_prompt for place in place_candidates)
def _save_history(self, prompt: str, result_dict: dict):
"""保存请求和响应历史记录"""
import datetime
@@ -740,37 +861,91 @@ class PyTreeGenerator:
# System Prompt: use_prompt不包含RAG结果
# User Prompt: final_user_prompt包含原始user_prompt + RAG检索结果
final_prompt = f"=== System Prompt ===\n{use_prompt}\n\n=== User Prompt ===\n{final_user_prompt}"
tool_enabled = self._should_enable_tools(user_prompt, retrieved_context)
for attempt in range(3):
logging.info(f"--- 第 {attempt + 1}/3 次尝试生成Pytree ---")
try:
# 简单/复杂分流到不同模型与提示词
client = self.simple_llm_client if mode == "simple" else self.complex_llm_client
model_name = self.simple_model if mode == "simple" else self.complex_model
messages = [
{"role": "system", "content": use_prompt},
{"role": "user", "content": final_user_prompt}
]
# 始终强制JSON响应并禁用思考功能
response_kwargs = {
"model": model_name,
"messages": [
{"role": "system", "content": use_prompt},
{"role": "user", "content": final_user_prompt}
],
"messages": messages,
"temperature": 0.1 if mode == "complex" else 0.0,
"response_format": {"type": "json_object"}, # 始终强制JSON输出禁用思考功能
# 禁用 Qwen3 模型的思考功能(通过 extra_body 传递)
# 注意:如果 API 服务器不支持此参数,会忽略
"extra_body": {"chat_template_kwargs": {"enable_thinking": False}}
}
if tool_enabled:
response_kwargs["tools"] = self._get_tool_definitions()
response_kwargs["tool_choice"] = "auto"
# 基于模式设定最大输出token数直接在代码中配置
response_kwargs["max_tokens"] = self.simple_max_tokens if mode == "simple" else self.complex_max_tokens
response = client.chat.completions.create(**response_kwargs)
# 工具调用处理执行工具并回填后强制模型输出JSON
for tool_round in range(3):
try:
first_msg = response.choices[0].message
tool_calls = getattr(first_msg, "tool_calls", None)
except Exception:
first_msg = response.choices[0].get("message") if isinstance(response.choices[0], dict) else None
tool_calls = (first_msg or {}).get("tool_calls")
if not tool_calls:
break
logging.info("检测到工具调用,执行第 %d 轮工具回填。", tool_round + 1)
tool_messages = self._execute_tool_calls(tool_calls)
if not tool_messages:
break
tool_calls_for_messages = []
for call in self._normalize_tool_calls(tool_calls):
if not call.get("id") or not call.get("name"):
continue
tool_calls_for_messages.append(
{
"id": call["id"],
"type": "function",
"function": {
"name": call["name"],
"arguments": call.get("arguments", "")
}
}
)
messages = messages + [
{
"role": "assistant",
"content": getattr(first_msg, "content", "") if first_msg else "",
"tool_calls": tool_calls_for_messages
}
] + tool_messages
followup_kwargs = dict(response_kwargs)
followup_kwargs["messages"] = messages
followup_kwargs.pop("tools", None)
followup_kwargs.pop("tool_choice", None)
response = client.chat.completions.create(**followup_kwargs)
break
# 兼容可能存在的 reasoning_content 字段
try:
msg = response.choices[0].message
msg_content = getattr(msg, "content", None)
msg_reasoning = getattr(msg, "reasoning_content", None)
remaining_tool_calls = getattr(msg, "tool_calls", None)
except Exception:
msg = response.choices[0]["message"] if isinstance(response.choices[0], dict) else None
msg_content = (msg or {}).get("content") if isinstance(msg, dict) else None
msg_reasoning = (msg or {}).get("reasoning_content") if isinstance(msg, dict) else None
remaining_tool_calls = (msg or {}).get("tool_calls") if isinstance(msg, dict) else None
if (msg_content is None or str(msg_content).strip() == "") and remaining_tool_calls:
logging.warning("模型仍在请求工具调用未返回JSON内容重试下一次。")
continue
combined_text = ""
if isinstance(msg_reasoning, str) and msg_reasoning.strip():

View File

@@ -0,0 +1 @@
"""工具模块包。"""

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Dict, Tuple
_DIRECTION_DELTAS: Dict[str, Tuple[int, int, int]] = {
"east": (1, 0, 0),
"west": (-1, 0, 0),
"north": (0, 1, 0),
"south": (0, -1, 0),
"up": (0, 0, 1),
"down": (0, 0, -1),
}
def calc_offset_enu(base: Dict[str, float], direction: str, distance: float) -> Dict[str, float]:
"""在ENU坐标系下按方向偏移固定距离。"""
if distance < 0:
raise ValueError("distance must be non-negative")
if not isinstance(base, dict):
raise ValueError("base must be an object with x/y/z")
direction_key = (direction or "").strip().lower()
if direction_key not in _DIRECTION_DELTAS:
raise ValueError(f"unsupported direction: {direction}")
try:
x = float(base["x"])
y = float(base["y"])
z = float(base["z"])
except Exception as exc:
raise ValueError("base must contain numeric x/y/z") from exc
dx, dy, dz = _DIRECTION_DELTAS[direction_key]
offset_x = x + dx * distance
offset_y = y + dy * distance
offset_z = z + dz * distance
return {"x": offset_x, "y": offset_y, "z": offset_z}

View File

@@ -235,6 +235,7 @@ start_services() {
--gpu-layers 36 \
--host 0.0.0.0 \
-c 8192 \
--jinja \
> "${LOG_DIR}/inference_model.log" 2>&1 &
local inference_pid=$!
echo $inference_pid >> "${PID_FILE}"