AI重构
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
backend_service/src/llm/__init__.py
Normal file
1
backend_service/src/llm/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
backend_service/src/llm/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend_service/src/llm/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend_service/src/llm/__pycache__/gateway.cpython-313.pyc
Normal file
BIN
backend_service/src/llm/__pycache__/gateway.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend_service/src/llm/__pycache__/tool_runtime.cpython-313.pyc
Normal file
BIN
backend_service/src/llm/__pycache__/tool_runtime.cpython-313.pyc
Normal file
Binary file not shown.
66
backend_service/src/llm/gateway.py
Normal file
66
backend_service/src/llm/gateway.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from openai import OpenAIError
|
||||
|
||||
from .response_parser import parse_json_payload, unpack_message
|
||||
|
||||
|
||||
class LLMGateway:
|
||||
def __init__(self, generator: Any):
|
||||
self.generator = generator
|
||||
self.stage1_enable_thinking = os.getenv("STAGE1_ENABLE_THINKING", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
def classify_scene(self, user_prompt: str) -> str:
|
||||
scene_mode = "scene1"
|
||||
try:
|
||||
classifier_resp = self.generator.classifier_client.chat.completions.create(
|
||||
model=self.generator.classifier_model,
|
||||
messages=[
|
||||
{"role": "system", "content": self.generator.scene_classifier_prompt or "你是一个分类器,只输出JSON。"},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.0,
|
||||
response_format={"type": "json_object"},
|
||||
max_tokens=self.generator.classifier_max_tokens,
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": self.stage1_enable_thinking}},
|
||||
)
|
||||
class_str = classifier_resp.choices[0].message.content
|
||||
class_obj = json.loads(class_str or "{}")
|
||||
if isinstance(class_obj, dict) and class_obj.get("mode") in ("simple", "scene1", "scene4"):
|
||||
scene_mode = class_obj.get("mode")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.warning("场景分类失败,默认按scene1处理: %s", exc)
|
||||
return scene_mode
|
||||
|
||||
def generate_json(self, scene_mode: str, system_prompt: str, user_prompt: str, max_attempts: int = 3) -> Tuple[Dict[str, Any], Optional[str], str]:
|
||||
is_simple = scene_mode == "simple"
|
||||
client = self.generator.simple_llm_client if is_simple else self.generator.complex_llm_client
|
||||
model_name = self.generator.simple_model if is_simple else self.generator.complex_model
|
||||
max_tokens = self.generator.simple_max_tokens if is_simple else self.generator.complex_max_tokens
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
|
||||
temperature=0.0 if is_simple else 0.1,
|
||||
response_format={"type": "json_object"},
|
||||
max_tokens=max_tokens,
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
||||
)
|
||||
message = response.choices[0].message if hasattr(response.choices[0], "message") else response.choices[0].get("message")
|
||||
content, reasoning_content, tool_calls = unpack_message(message)
|
||||
if (content is None or str(content).strip() == "") and tool_calls:
|
||||
logging.warning("模型仍在请求工具调用,尝试下一次生成。")
|
||||
continue
|
||||
parsed, reasoning_text, raw_text = parse_json_payload(content, reasoning_content)
|
||||
return parsed, reasoning_text, raw_text
|
||||
except (OpenAIError, json.JSONDecodeError, ValueError) as exc:
|
||||
logging.warning("第 %d/%d 次生成失败: %s", attempt + 1, max_attempts, exc)
|
||||
raise RuntimeError("在3次尝试后,仍未能生成一个有效的Pytree。")
|
||||
|
||||
37
backend_service/src/llm/response_parser.py
Normal file
37
backend_service/src/llm/response_parser.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
|
||||
def unpack_message(message: Any) -> Tuple[Optional[str], Optional[str], Any]:
|
||||
if message is None:
|
||||
return None, None, None
|
||||
if hasattr(message, "content"):
|
||||
return getattr(message, "content", None), getattr(message, "reasoning_content", None), getattr(message, "tool_calls", None)
|
||||
if isinstance(message, dict):
|
||||
return message.get("content"), message.get("reasoning_content"), message.get("tool_calls")
|
||||
return None, None, None
|
||||
|
||||
|
||||
def parse_json_payload(content: Optional[str], reasoning_content: Optional[str]) -> Tuple[Dict[str, Any], Optional[str], str]:
|
||||
combined_text = ""
|
||||
if isinstance(reasoning_content, str) and reasoning_content.strip():
|
||||
combined_text += f"<think>\n{reasoning_content}\n</think>\n"
|
||||
if isinstance(content, str) and content.strip():
|
||||
combined_text += content
|
||||
|
||||
raw_text = combined_text or (content or "")
|
||||
reasoning_text = None
|
||||
payload_text = raw_text
|
||||
match = re.search(r"<think>([\s\S]*?)</think>", raw_text)
|
||||
if match:
|
||||
reasoning_text = match.group(1).strip()
|
||||
payload_text = re.sub(r"<think>[\s\S]*?</think>", "", raw_text).strip()
|
||||
|
||||
parsed = json.loads(payload_text)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("LLM output must be a JSON object")
|
||||
return parsed, reasoning_text, raw_text
|
||||
|
||||
143
backend_service/src/llm/tool_runtime.py
Normal file
143
backend_service/src/llm/tool_runtime.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..tools.coordinate_tools import calc_offset_enu, calc_offset_esu_direction_text
|
||||
|
||||
|
||||
class ToolRuntime:
|
||||
def get_tool_definitions(self) -> List[Dict[str, Any]]:
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calc_offset_esu_direction_text",
|
||||
"description": "根据ESU坐标系基准点、中文方位与距离计算偏移后的坐标。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base": {
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "number"}, "y": {"type": "number"}, "z": {"type": "number"}},
|
||||
"required": ["x", "y", "z"],
|
||||
},
|
||||
"direction_text": {"type": "string"},
|
||||
"distance": {"type": "number", "minimum": 0},
|
||||
},
|
||||
"required": ["base", "direction_text", "distance"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
def normalize_tool_calls(self, tool_calls: List[Any]) -> List[Dict[str, Any]]:
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
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[Any]) -> List[Dict[str, Any]]:
|
||||
tool_messages: List[Dict[str, Any]] = []
|
||||
for call in self.normalize_tool_calls(tool_calls):
|
||||
tool_name = call.get("name")
|
||||
tool_args = call.get("arguments")
|
||||
tool_id = call.get("id")
|
||||
if not tool_name:
|
||||
continue
|
||||
try:
|
||||
args = json.loads(tool_args or "{}")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
try:
|
||||
if tool_name == "calc_offset_enu":
|
||||
result = calc_offset_enu(**args)
|
||||
elif tool_name == "calc_offset_esu_direction_text":
|
||||
result = calc_offset_esu_direction_text(**args)
|
||||
else:
|
||||
result = {"error": f"unsupported tool: {tool_name}"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = {"error": str(exc)}
|
||||
tool_messages.append({"role": "tool", "tool_call_id": tool_id, "content": json.dumps(result, ensure_ascii=False)})
|
||||
return tool_messages
|
||||
|
||||
def should_enable_tools(self, user_prompt: str, location_context: str) -> bool:
|
||||
if not user_prompt or not location_context:
|
||||
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
|
||||
return has_direction and has_distance
|
||||
|
||||
def build_precomputed_waypoint(self, user_prompt: str, location_context: str) -> Optional[Dict[str, float]]:
|
||||
if not self.should_enable_tools(user_prompt, location_context):
|
||||
return None
|
||||
base_match = re.search(
|
||||
r"[\"']?x[\"']?\s*[:=]\s*(-?\d+(?:\.\d+)?)\D+[\"']?y[\"']?\s*[:=]\s*(-?\d+(?:\.\d+)?)\D+[\"']?z[\"']?\s*[:=]\s*(-?\d+(?:\.\d+)?)",
|
||||
location_context,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if not base_match:
|
||||
return None
|
||||
base = {"x": float(base_match.group(1)), "y": float(base_match.group(2)), "z": float(base_match.group(3))}
|
||||
dist_match = re.search(r"(\d+(?:\.\d+)?)\s*(米|m)", user_prompt)
|
||||
if not dist_match:
|
||||
return None
|
||||
distance = float(dist_match.group(1))
|
||||
|
||||
simple_direction_map = {
|
||||
"东": "east",
|
||||
"西": "west",
|
||||
"南": "south",
|
||||
"北": "north",
|
||||
"上": "up",
|
||||
"下": "down",
|
||||
}
|
||||
for zh, en in simple_direction_map.items():
|
||||
if f"{zh}边" in user_prompt or f"{zh}侧" in user_prompt or f"往{zh}" in user_prompt or f"向{zh}" in user_prompt:
|
||||
return calc_offset_enu(base=base, direction=en, distance=distance)
|
||||
|
||||
complex_patterns = ["东南", "西南", "东北", "西北", "南偏东", "南偏西", "北偏东", "北偏西"]
|
||||
for pattern in complex_patterns:
|
||||
if pattern in user_prompt:
|
||||
direction_match = re.search(r"(东南|西南|东北|西北|南偏东\d+(?:\.\d+)?度?|南偏西\d+(?:\.\d+)?度?|北偏东\d+(?:\.\d+)?度?|北偏西\d+(?:\.\d+)?度?)", user_prompt)
|
||||
if direction_match:
|
||||
return calc_offset_esu_direction_text(base=base, direction_text=direction_match.group(1), distance=distance)
|
||||
return None
|
||||
|
||||
1
backend_service/src/pipeline/__init__.py
Normal file
1
backend_service/src/pipeline/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend_service/src/pipeline/__pycache__/stages.cpython-313.pyc
Normal file
BIN
backend_service/src/pipeline/__pycache__/stages.cpython-313.pyc
Normal file
Binary file not shown.
38
backend_service/src/pipeline/contracts.py
Normal file
38
backend_service/src/pipeline/contracts.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
SceneMode = Literal["simple", "scene1", "scene4"]
|
||||
|
||||
|
||||
class TaskUnderstanding(BaseModel):
|
||||
scene_mode: SceneMode = "scene1"
|
||||
intent_type: str = "generic_mission"
|
||||
requires_relative_target: bool = False
|
||||
entities: Dict[str, Any] = Field(default_factory=dict)
|
||||
risk_flags: List[str] = Field(default_factory=list)
|
||||
constraints: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ContextBinding(BaseModel):
|
||||
location_context: str = ""
|
||||
pattern_context: str = ""
|
||||
rules_context: str = ""
|
||||
citations: Dict[str, List[str]] = Field(default_factory=dict)
|
||||
resolved_refs: Dict[str, Any] = Field(default_factory=dict)
|
||||
precomputed_waypoints: List[Dict[str, float]] = Field(default_factory=list)
|
||||
relative_refs: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
required_actions: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BTDraft(BaseModel):
|
||||
system_prompt: str
|
||||
user_prompt: str
|
||||
allowed_nodes: Dict[str, List[str]] = Field(default_factory=dict)
|
||||
llm_raw_json: Dict[str, Any]
|
||||
reasoning_text: Optional[str] = None
|
||||
final_prompt: str
|
||||
|
||||
17
backend_service/src/pipeline/orchestrator.py
Normal file
17
backend_service/src/pipeline/orchestrator.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .stages import PipelineStages
|
||||
|
||||
|
||||
class GenerationOrchestrator:
|
||||
def __init__(self, generator: Any):
|
||||
self.stages = PipelineStages(generator)
|
||||
|
||||
async def generate(self, user_prompt: str) -> Dict:
|
||||
understanding = self.stages.stage1_task_understanding(user_prompt)
|
||||
context = self.stages.stage2_context_binding(user_prompt, understanding)
|
||||
draft = self.stages.stage3_bt_planning(user_prompt, understanding, context)
|
||||
return self.stages.stage4_validate_and_postprocess(user_prompt, understanding, context, draft)
|
||||
|
||||
165
backend_service/src/pipeline/stages.py
Normal file
165
backend_service/src/pipeline/stages.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .contracts import BTDraft, ContextBinding, TaskUnderstanding
|
||||
|
||||
|
||||
def _infer_intent_type(user_prompt: str, scene_mode: str) -> str:
|
||||
text = user_prompt or ""
|
||||
if scene_mode == "simple":
|
||||
return "single_action"
|
||||
if any(k in text for k in ["拍照", "拍个照片", "拍张照"]):
|
||||
return "search_and_photo"
|
||||
if any(k in text for k in ["监控", "巡视", "巡查", "侦察"]):
|
||||
return "patrol_or_monitor"
|
||||
if any(k in text for k in ["返航", "回到", "降落"]):
|
||||
return "return_or_land"
|
||||
return "generic_mission"
|
||||
|
||||
|
||||
def _extract_risk_flags(user_prompt: str) -> List[str]:
|
||||
flags: List[str] = []
|
||||
if any(k in user_prompt for k in ["我确认", "等待确认", "经允许", "我通过后"]):
|
||||
flags.append("needs_manual_confirmation")
|
||||
if any(k in user_prompt for k in ["贴近", "靠近", "飞近"]):
|
||||
flags.append("needs_approach_target")
|
||||
if any(k in user_prompt for k in ["紧急", "立即返航"]):
|
||||
flags.append("needs_emergency_return_rule")
|
||||
if any(k in user_prompt for k in ["面前", "左边", "右边", "后方", "前方", "这栋楼"]):
|
||||
flags.append("relative_reference_detected")
|
||||
return flags
|
||||
|
||||
|
||||
def _derive_required_actions(intent_type: str, scene_mode: str, risk_flags: List[str]) -> List[str]:
|
||||
actions = {"takeoff", "land", "fly_to_waypoint", "move_direction"}
|
||||
if scene_mode != "simple":
|
||||
actions.update({"rotate_search", "object_detect", "object_detected", "take_photos", "Sequence", "Selector"})
|
||||
if intent_type == "return_or_land":
|
||||
actions.add("return_emergency")
|
||||
if "needs_manual_confirmation" in risk_flags:
|
||||
actions.add("manual_confirmation")
|
||||
if "needs_approach_target" in risk_flags:
|
||||
actions.add("approach_target")
|
||||
return sorted(actions)
|
||||
|
||||
|
||||
def _extract_relative_refs(user_prompt: str) -> List[Dict[str, Any]]:
|
||||
refs: List[Dict[str, Any]] = []
|
||||
relation_map = {"面前": "front", "前方": "front", "左边": "left", "右边": "right", "后方": "behind"}
|
||||
for token, relation in relation_map.items():
|
||||
if token in user_prompt:
|
||||
distance = None
|
||||
match = re.search(rf"{token}[^0-9]*(\d+(?:\.\d+)?)\s*(米|m)", user_prompt)
|
||||
if match:
|
||||
distance = float(match.group(1))
|
||||
refs.append(
|
||||
{
|
||||
"anchor": "front_building" if "楼" in user_prompt else "current_heading_ref",
|
||||
"relation": relation,
|
||||
"distance_m": distance,
|
||||
}
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
class PipelineStages:
|
||||
def __init__(self, generator: Any):
|
||||
self.generator = generator
|
||||
|
||||
def stage1_task_understanding(self, user_prompt: str) -> TaskUnderstanding:
|
||||
scene_mode = self.generator.llm_gateway.classify_scene(user_prompt)
|
||||
intent_type = _infer_intent_type(user_prompt, scene_mode)
|
||||
risk_flags = _extract_risk_flags(user_prompt)
|
||||
requires_relative = "relative_reference_detected" in risk_flags
|
||||
entities = {"raw_prompt": user_prompt}
|
||||
return TaskUnderstanding(
|
||||
scene_mode=scene_mode,
|
||||
intent_type=intent_type,
|
||||
requires_relative_target=requires_relative,
|
||||
entities=entities,
|
||||
risk_flags=risk_flags,
|
||||
constraints={},
|
||||
)
|
||||
|
||||
def stage2_context_binding(self, user_prompt: str, understanding: TaskUnderstanding) -> ContextBinding:
|
||||
scopes = ["location"]
|
||||
if understanding.scene_mode != "simple":
|
||||
scopes.append("pattern")
|
||||
if understanding.risk_flags:
|
||||
scopes.append("rules")
|
||||
|
||||
retrieved = self.generator.retriever.retrieve(user_prompt, scopes=scopes, n_results=3)
|
||||
precomputed = []
|
||||
waypoint = self.generator.tool_runtime.build_precomputed_waypoint(user_prompt, retrieved.get("location_context", ""))
|
||||
if waypoint:
|
||||
precomputed.append(waypoint)
|
||||
relative_refs = _extract_relative_refs(user_prompt) if understanding.requires_relative_target else []
|
||||
required_actions = _derive_required_actions(understanding.intent_type, understanding.scene_mode, understanding.risk_flags)
|
||||
|
||||
return ContextBinding(
|
||||
location_context=retrieved.get("location_context", ""),
|
||||
pattern_context=retrieved.get("pattern_context", ""),
|
||||
rules_context=retrieved.get("rules_context", ""),
|
||||
citations=retrieved.get("citations", {}),
|
||||
resolved_refs={},
|
||||
precomputed_waypoints=precomputed,
|
||||
relative_refs=relative_refs,
|
||||
required_actions=required_actions,
|
||||
)
|
||||
|
||||
def stage3_bt_planning(self, user_prompt: str, understanding: TaskUnderstanding, context: ContextBinding) -> BTDraft:
|
||||
include_extra_examples = understanding.intent_type == "generic_mission"
|
||||
package = self.generator.prompt_composer.compose(
|
||||
scene_mode=understanding.scene_mode,
|
||||
intent_type=understanding.intent_type,
|
||||
required_actions=context.required_actions,
|
||||
risk_flags=understanding.risk_flags,
|
||||
context_blocks={
|
||||
"location": context.location_context,
|
||||
"pattern": context.pattern_context,
|
||||
"rules": context.rules_context,
|
||||
},
|
||||
include_extra_examples=include_extra_examples,
|
||||
)
|
||||
final_user_prompt = user_prompt + (package.user_augmentation or "")
|
||||
payload, reasoning_text, _raw_text = self.generator.llm_gateway.generate_json(
|
||||
scene_mode=understanding.scene_mode,
|
||||
system_prompt=package.system_prompt,
|
||||
user_prompt=final_user_prompt,
|
||||
)
|
||||
final_prompt = f"=== System Prompt ===\n{package.system_prompt}\n\n=== User Prompt ===\n{final_user_prompt}"
|
||||
return BTDraft(
|
||||
system_prompt=package.system_prompt,
|
||||
user_prompt=final_user_prompt,
|
||||
allowed_nodes=package.allowed_nodes,
|
||||
llm_raw_json=payload,
|
||||
reasoning_text=reasoning_text,
|
||||
final_prompt=final_prompt,
|
||||
)
|
||||
|
||||
def stage4_validate_and_postprocess(self, user_prompt: str, understanding: TaskUnderstanding, context: ContextBinding, draft: BTDraft) -> Dict[str, Any]:
|
||||
payload = dict(draft.llm_raw_json)
|
||||
if context.relative_refs and understanding.scene_mode != "simple":
|
||||
payload.setdefault("context", {})
|
||||
payload["context"]["relative_refs"] = context.relative_refs
|
||||
if context.precomputed_waypoints:
|
||||
payload["context"]["resolved_refs"] = {
|
||||
"strategy": "backend_static_resolution",
|
||||
"waypoints": context.precomputed_waypoints,
|
||||
}
|
||||
|
||||
self.generator.validator.validate(understanding.scene_mode, payload)
|
||||
payload["plan_id"] = str(uuid.uuid4())
|
||||
payload["visualization_url"] = self.generator.render_visualization(payload, understanding.scene_mode)
|
||||
payload["final_prompt"] = draft.final_prompt
|
||||
if draft.reasoning_text:
|
||||
self.generator.save_reasoning_content(draft.reasoning_text)
|
||||
self.generator._save_history(user_prompt, payload)
|
||||
logging.info("✅ 成功生成并验证了Pytree(Pipeline)")
|
||||
return payload
|
||||
|
||||
1
backend_service/src/prompting/__init__.py
Normal file
1
backend_service/src/prompting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
129
backend_service/src/prompting/composer.py
Normal file
129
backend_service/src/prompting/composer.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Sequence, Set
|
||||
|
||||
from .manifest_loader import ManifestPromptLoader
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptPackage:
|
||||
system_prompt: str
|
||||
user_augmentation: str
|
||||
allowed_nodes: Dict[str, List[str]]
|
||||
|
||||
|
||||
class PromptComposer:
|
||||
def __init__(self, prompts_dir: str):
|
||||
self.loader = ManifestPromptLoader(prompts_dir)
|
||||
self.prompts_dir = prompts_dir
|
||||
self._simple_prompt = self.loader.load_text_file("simple_mode_prompt.txt")
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _cached_scene_parts(self, scene_mode: str) -> tuple[str, str, str, str]:
|
||||
# 固定骨架
|
||||
header = self._load_partial("header.txt")
|
||||
required_fields = self._load_partial("required_fields.txt")
|
||||
standard_template = self._load_partial("standard_template.txt")
|
||||
examples = self._load_partial("scene1_examples.txt" if scene_mode == "scene1" else "scene4_examples.txt")
|
||||
return header, required_fields, standard_template, examples
|
||||
|
||||
def _load_partial(self, file_name: str) -> str:
|
||||
path = os.path.join(self.prompts_dir, "partials", file_name)
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
return self.loader.load_fragment_text(path).strip("\n")
|
||||
|
||||
def _load_nodes_payload(self) -> Dict[str, List[Dict[str, str]]]:
|
||||
core_path = os.path.join(self.prompts_dir, "partials", "core_nodes.json")
|
||||
if not os.path.exists(core_path):
|
||||
return {"actions": [], "conditions": [], "control_flow": [], "decorators": []}
|
||||
with open(core_path, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
return payload
|
||||
|
||||
def _slice_nodes(self, required_actions: Sequence[str], risk_flags: Sequence[str], scene_mode: str) -> Dict[str, List[str]]:
|
||||
payload = self._load_nodes_payload()
|
||||
base_actions: Set[str] = {"takeoff", "land", "fly_to_waypoint", "move_direction"}
|
||||
base_conditions: Set[str] = {"object_detected"}
|
||||
if scene_mode != "simple":
|
||||
base_actions.update({"rotate_search", "take_photos"})
|
||||
action_names = {a.get("name") for a in payload.get("actions", []) if isinstance(a, dict)}
|
||||
condition_names = {c.get("name") for c in payload.get("conditions", []) if isinstance(c, dict)}
|
||||
|
||||
selected_actions = set(required_actions) | (base_actions & action_names)
|
||||
selected_conditions = base_conditions & condition_names
|
||||
if "needs_manual_confirmation" in risk_flags and "manual_confirmation" in action_names:
|
||||
selected_actions.add("manual_confirmation")
|
||||
if "needs_emergency_return_rule" in risk_flags and "return_emergency" in action_names:
|
||||
selected_actions.add("return_emergency")
|
||||
if "needs_approach_target" in risk_flags and "approach_target" in action_names:
|
||||
selected_actions.add("approach_target")
|
||||
|
||||
ordered_actions = sorted(selected_actions)[:30]
|
||||
ordered_conditions = sorted(selected_conditions)[:30]
|
||||
return {"actions": ordered_actions, "conditions": ordered_conditions}
|
||||
|
||||
def _build_nodes_snippet(self, selected: Dict[str, List[str]]) -> str:
|
||||
payload = self._load_nodes_payload()
|
||||
selected_action_set = set(selected.get("actions", []))
|
||||
selected_condition_set = set(selected.get("conditions", []))
|
||||
slim_payload = {
|
||||
"actions": [a for a in payload.get("actions", []) if a.get("name") in selected_action_set],
|
||||
"conditions": [c for c in payload.get("conditions", []) if c.get("name") in selected_condition_set],
|
||||
"control_flow": payload.get("control_flow", []),
|
||||
"decorators": payload.get("decorators", []),
|
||||
}
|
||||
return "\n".join(
|
||||
[
|
||||
"## 一、核心节点定义(裁剪后)",
|
||||
"#### 1. 可用节点定义 (必须遵守)",
|
||||
"```json",
|
||||
json.dumps(slim_payload, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
]
|
||||
)
|
||||
|
||||
def compose(
|
||||
self,
|
||||
scene_mode: str,
|
||||
intent_type: str,
|
||||
required_actions: Sequence[str],
|
||||
risk_flags: Sequence[str],
|
||||
context_blocks: Dict[str, str],
|
||||
include_extra_examples: bool,
|
||||
) -> PromptPackage:
|
||||
if scene_mode == "simple":
|
||||
user_aug = self._build_user_augmentation(context_blocks)
|
||||
return PromptPackage(system_prompt=self._simple_prompt, user_augmentation=user_aug, allowed_nodes={})
|
||||
|
||||
header, required_fields, standard_template, examples = self._cached_scene_parts(scene_mode)
|
||||
selected_nodes = self._slice_nodes(required_actions, risk_flags, scene_mode)
|
||||
node_snippet = self._build_nodes_snippet(selected_nodes)
|
||||
|
||||
parts: List[str] = [header, node_snippet, required_fields, standard_template, examples]
|
||||
common_rules = self._load_partial("common_rules.txt")
|
||||
if common_rules:
|
||||
parts.append(common_rules)
|
||||
if include_extra_examples:
|
||||
extra = self._load_partial("system_extra_examples.txt")
|
||||
if extra:
|
||||
parts.append(extra)
|
||||
parts.append(f"## 任务意图标签\n- intent_type: `{intent_type}`")
|
||||
system_prompt = "\n\n".join(p for p in parts if p).strip()
|
||||
return PromptPackage(system_prompt=system_prompt, user_augmentation=self._build_user_augmentation(context_blocks), allowed_nodes=selected_nodes)
|
||||
|
||||
def _build_user_augmentation(self, context_blocks: Dict[str, str]) -> str:
|
||||
ordered = [("地点知识", "location"), ("任务模式", "pattern"), ("规则知识", "rules")]
|
||||
chunks: List[str] = []
|
||||
for label, key in ordered:
|
||||
value = (context_blocks.get(key) or "").strip()
|
||||
if value:
|
||||
chunks.append(f"【{label}】\n{value}")
|
||||
if not chunks:
|
||||
return ""
|
||||
return "\n\n---\n参考知识:\n" + "\n\n".join(chunks) + "\n---"
|
||||
|
||||
79
backend_service/src/prompting/manifest_loader.py
Normal file
79
backend_service/src/prompting/manifest_loader.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class ManifestPromptLoader:
|
||||
def __init__(self, prompts_dir: str):
|
||||
self.prompts_dir = prompts_dir
|
||||
self._manifest_cache: Optional[dict] = None
|
||||
self._manifest_mtime: Optional[float] = None
|
||||
|
||||
def load_manifest(self) -> Optional[dict]:
|
||||
manifest_path = os.path.join(self.prompts_dir, "prompt_manifest.yaml")
|
||||
try:
|
||||
mtime = os.path.getmtime(manifest_path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
if self._manifest_cache is not None and self._manifest_mtime == mtime:
|
||||
return self._manifest_cache
|
||||
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
self._manifest_cache = manifest
|
||||
self._manifest_mtime = mtime
|
||||
return manifest
|
||||
|
||||
def load_text_file(self, file_name: str) -> str:
|
||||
path = os.path.join(self.prompts_dir, file_name)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
def resolve_fragment(self, fragment: str) -> str:
|
||||
if os.path.isabs(fragment):
|
||||
return fragment
|
||||
if "/" in fragment:
|
||||
return os.path.join(self.prompts_dir, fragment)
|
||||
partial_path = os.path.join(self.prompts_dir, "partials", fragment)
|
||||
if os.path.exists(partial_path):
|
||||
return partial_path
|
||||
return os.path.join(self.prompts_dir, fragment)
|
||||
|
||||
def load_fragment_text(self, fragment_path: str) -> str:
|
||||
if fragment_path.lower().endswith(".json"):
|
||||
with open(fragment_path, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
json_text = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if os.path.basename(fragment_path) == "core_nodes.json":
|
||||
return "\n".join(
|
||||
[
|
||||
"## 一、核心节点定义(格式不可修改,确保后端解析)",
|
||||
"#### 1. 可用节点定义 (必须遵守)",
|
||||
"你必须严格从以下JSON定义的列表中选择节点构建行为树,不允许使用未定义节点:",
|
||||
"```json",
|
||||
json_text,
|
||||
"```",
|
||||
]
|
||||
)
|
||||
return "\n".join(["```json", json_text, "```"])
|
||||
with open(fragment_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def load_scene_fragments(self, scene_key: str) -> List[str]:
|
||||
manifest = self.load_manifest() or {}
|
||||
scene_map = manifest.get("scenes", manifest)
|
||||
fragments = scene_map.get(scene_key, [])
|
||||
if not isinstance(fragments, list):
|
||||
return []
|
||||
return [f for f in fragments if isinstance(f, str)]
|
||||
|
||||
@@ -6,15 +6,20 @@ import re
|
||||
from typing import Dict, Any, Optional, Set, List
|
||||
import chromadb
|
||||
import openai
|
||||
from openai import OpenAIError
|
||||
import jsonschema
|
||||
import requests
|
||||
import platform # 新增:用于选择合适的中文字体
|
||||
import yaml
|
||||
from .tools.coordinate_tools import calc_offset_enu, calc_offset_esu_direction_text
|
||||
from .pipeline.orchestrator import GenerationOrchestrator
|
||||
from .llm.gateway import LLMGateway
|
||||
from .llm.tool_runtime import ToolRuntime
|
||||
from .prompting.composer import PromptComposer
|
||||
from .retrieval.adapters.chroma_adapter import ChromaAdapter
|
||||
from .retrieval.retriever import UnifiedRetriever
|
||||
from .validation.schema_provider import SchemaProvider
|
||||
from .validation.validator import PytreeValidator
|
||||
|
||||
# --- 自定义远程嵌入函数 (与ingest.py中定义一致) ---
|
||||
from chromadb.api.types import Documents, EmbeddingFunction, Embeddings, Embeddable
|
||||
from chromadb.api.types import EmbeddingFunction, Embeddings, Embeddable
|
||||
class RemoteEmbeddingFunction(EmbeddingFunction[Embeddable]):
|
||||
def __init__(self, api_url: str):
|
||||
self._api_url = api_url
|
||||
@@ -45,6 +50,17 @@ logging.basicConfig(
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _NullRetriever:
|
||||
def retrieve(self, query: str, scopes: List[str], n_results: int = 3) -> Dict[str, Any]:
|
||||
_ = (query, scopes, n_results)
|
||||
return {
|
||||
"location_context": "",
|
||||
"pattern_context": "",
|
||||
"rules_context": "",
|
||||
"citations": {},
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# VALIDATION LOGIC (from utils/validation.py)
|
||||
# ==============================================================================
|
||||
@@ -392,34 +408,6 @@ def _generate_simple_mode_schema(allowed_actions: set) -> dict:
|
||||
}
|
||||
return schema
|
||||
|
||||
def _validate_pytree_with_schema(pytree_instance: dict, schema: dict) -> bool:
|
||||
"""
|
||||
使用JSON Schema验证给定的Pytree实例。
|
||||
"""
|
||||
try:
|
||||
jsonschema.validate(instance=pytree_instance, schema=schema)
|
||||
logging.info("✅ JSON Schema验证成功")
|
||||
|
||||
return True
|
||||
except jsonschema.ValidationError as e:
|
||||
logging.warning("❌ Pytree验证失败")
|
||||
logging.warning(f"错误信息: {e.message}")
|
||||
error_path = list(e.path)
|
||||
logging.warning(f"错误路径: {' -> '.join(map(str, error_path)) if error_path else '根节点'}")
|
||||
|
||||
# 提供更具体的错误信息
|
||||
if "object_detect" in str(e.message) or "object_detected" in str(e.message):
|
||||
logging.warning("💡 提示: 请确保目标类别是预定义列表中的有效值")
|
||||
elif "battery_above" in str(e.message):
|
||||
logging.warning("💡 提示: 电池阈值必须在0.0到1.0之间")
|
||||
elif "gps_status" in str(e.message):
|
||||
logging.warning("💡 提示: 最小卫星数量必须在6到15之间")
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"进行JSON Schema验证时发生未知错误: {e}")
|
||||
return False
|
||||
|
||||
# ==============================================================================
|
||||
# VISUALIZATION LOGIC (from utils/visualization.py)
|
||||
# ==============================================================================
|
||||
@@ -628,20 +616,32 @@ class PyTreeGenerator:
|
||||
|
||||
# --- ChromaDB Client Setup ---
|
||||
vector_store_path = os.path.abspath(os.path.join(self.base_dir, '..', '..', 'tools', 'rag','vector_store'))
|
||||
self.chroma_client = chromadb.PersistentClient(path=vector_store_path)
|
||||
|
||||
# Explicitly use the remote embedding function for queries
|
||||
embedding_api_url = f"http://{self.orin_ip}:8090/v1/embeddings"
|
||||
embedding_func = RemoteEmbeddingFunction(api_url=embedding_api_url)
|
||||
self.collection = self.chroma_client.get_collection(
|
||||
name="drone_docs",
|
||||
embedding_function=embedding_func
|
||||
)
|
||||
self.chroma_client = None
|
||||
self.collection = None
|
||||
try:
|
||||
self.chroma_client = chromadb.PersistentClient(path=vector_store_path)
|
||||
self.collection = self.chroma_client.get_collection(
|
||||
name="drone_docs",
|
||||
embedding_function=embedding_func
|
||||
)
|
||||
except BaseException as exc:
|
||||
logging.error(f"ChromaDB 初始化失败,将使用空检索器: {exc}")
|
||||
|
||||
# 使用复杂模式提示词作为节点来源,确保Schema稳定
|
||||
allowed_actions, allowed_conditions = _parse_allowed_nodes_from_prompt(self.complex_prompt)
|
||||
self.schema = _generate_pytree_schema(allowed_actions, allowed_conditions)
|
||||
self.simple_schema = _generate_simple_mode_schema(allowed_actions)
|
||||
self.llm_gateway = LLMGateway(self)
|
||||
self.tool_runtime = ToolRuntime()
|
||||
self.prompt_composer = PromptComposer(self.prompts_dir)
|
||||
if self.chroma_client is not None:
|
||||
self.retriever = UnifiedRetriever(ChromaAdapter(self.chroma_client), embedding_func)
|
||||
else:
|
||||
self.retriever = _NullRetriever()
|
||||
self.validator = PytreeValidator(SchemaProvider(self.schema, self.simple_schema))
|
||||
self.orchestrator = GenerationOrchestrator(self)
|
||||
|
||||
def _load_prompt(self, file_name: str) -> str:
|
||||
try:
|
||||
@@ -742,174 +742,6 @@ class PyTreeGenerator:
|
||||
|
||||
return "\n\n".join(contents).strip("\n")
|
||||
|
||||
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"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calc_offset_esu_direction_text",
|
||||
"description": "根据ESU坐标系基准点、中文方位与距离计算偏移后的坐标。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"type": "number"},
|
||||
"y": {"type": "number"},
|
||||
"z": {"type": "number"}
|
||||
},
|
||||
"required": ["x", "y", "z"]
|
||||
},
|
||||
"direction_text": {
|
||||
"type": "string",
|
||||
"description": "中文方位,如“南偏东10度”“北偏东10度”“东南”“西北”等"
|
||||
},
|
||||
"distance": {"type": "number", "minimum": 0}
|
||||
},
|
||||
"required": ["base", "direction_text", "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)
|
||||
elif tool_name == "calc_offset_esu_direction_text":
|
||||
result = calc_offset_esu_direction_text(**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:
|
||||
results = self.collection.query(query_texts=[query], n_results=5)
|
||||
retrieved_docs = results.get("documents", [[]])[0]
|
||||
if not retrieved_docs:
|
||||
logging.warning("在向量数据库中没有找到相关的上下文信息。")
|
||||
return None
|
||||
context_str = "\n\n".join(retrieved_docs)
|
||||
logging.info("--- 成功检索到上下文信息 ---")
|
||||
# 打印检索到的上下文内容
|
||||
logging.info(f"📚 检索到的上下文内容:\n{context_str}")
|
||||
return context_str
|
||||
except Exception as e:
|
||||
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
|
||||
@@ -934,316 +766,31 @@ class PyTreeGenerator:
|
||||
except Exception as e:
|
||||
logging.error(f"保存历史记录失败: {e}")
|
||||
|
||||
async def generate(self, user_prompt: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Generates a py_tree.json structure based on the user's prompt.
|
||||
"""
|
||||
logging.info(f"接收到用户请求: {user_prompt}")
|
||||
|
||||
# 第一步:场景分类(simple/scene1/scene4)
|
||||
scene_mode = "scene1"
|
||||
try:
|
||||
classifier_resp = self.classifier_client.chat.completions.create(
|
||||
model=self.classifier_model,
|
||||
messages=[
|
||||
{"role": "system", "content": self.scene_classifier_prompt or "你是一个分类器,只输出JSON。"},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
temperature=0.0,
|
||||
response_format={"type": "json_object"}, # 强制JSON输出,禁用思考功能
|
||||
max_tokens=self.classifier_max_tokens,
|
||||
# 禁用 Qwen3 模型的思考功能(通过 extra_body 传递)
|
||||
# 注意:如果 API 服务器不支持此参数,会忽略
|
||||
extra_body={"chat_template_kwargs": {"enable_thinking": False}}
|
||||
)
|
||||
class_str = classifier_resp.choices[0].message.content
|
||||
class_obj = json.loads(class_str)
|
||||
if isinstance(class_obj, dict) and class_obj.get("mode") in ("simple", "scene1", "scene4"):
|
||||
scene_mode = class_obj.get("mode")
|
||||
logging.info(f"场景分类结果: {scene_mode}")
|
||||
except Exception as e:
|
||||
logging.warning(f"场景分类失败,默认按scene1处理: {e}")
|
||||
|
||||
# 第二步:根据模式准备提示词与上下文(简单与复杂都执行检索增强)
|
||||
# 基于场景选择提示词;非simple时追加强制规则,避免模型误输出简单结构
|
||||
def render_visualization(self, payload: Dict[str, Any], scene_mode: str) -> str:
|
||||
vis_filename = "py_tree.png"
|
||||
vis_path = os.path.join(self.vis_dir, vis_filename)
|
||||
root_node = payload.get("root", {})
|
||||
if scene_mode == "simple":
|
||||
use_prompt = self.simple_prompt
|
||||
elif scene_mode == "scene4":
|
||||
use_prompt = self.scene4_prompt
|
||||
_visualize_pytree(root_node, os.path.splitext(vis_path)[0])
|
||||
else:
|
||||
use_prompt = self.scene1_prompt
|
||||
if scene_mode != "simple":
|
||||
use_prompt = (
|
||||
(use_prompt or self.complex_prompt or "") +
|
||||
"\n\n【强制规则】仅生成包含root的复杂行为树JSON,不得输出简单模式(不得包含mode字段或仅有action节点)。"
|
||||
)
|
||||
final_user_prompt = user_prompt
|
||||
retrieved_context = self._retrieve_context(user_prompt)
|
||||
if retrieved_context:
|
||||
augmentation = (
|
||||
"\n\n---\n"
|
||||
"参考知识:\n"
|
||||
"以下是从知识库中检索到的、与当前任务最相关的信息,请优先参考这些信息来生成结果:\n"
|
||||
f"{retrieved_context}"
|
||||
"\n---"
|
||||
)
|
||||
final_user_prompt += augmentation
|
||||
else:
|
||||
logging.warning("未检索到上下文或检索失败,将使用原始用户提示词。")
|
||||
|
||||
# 构建完整的 final_prompt(准确反映实际发送给大模型的内容结构)
|
||||
# 注意:RAG检索结果被添加到 user prompt 中,而不是 system prompt
|
||||
# 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:
|
||||
# 简单/复杂分流到不同模型与提示词
|
||||
is_simple = scene_mode == "simple"
|
||||
client = self.simple_llm_client if is_simple else self.complex_llm_client
|
||||
model_name = self.simple_model if is_simple else self.complex_model
|
||||
messages = [
|
||||
{"role": "system", "content": use_prompt},
|
||||
{"role": "user", "content": final_user_prompt}
|
||||
]
|
||||
# 始终强制JSON响应并禁用思考功能
|
||||
response_kwargs = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"temperature": 0.0 if is_simple else 0.1,
|
||||
"response_format": {"type": "json_object"}, # 始终强制JSON输出,禁用思考功能
|
||||
"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 is_simple else self.complex_max_tokens
|
||||
response = client.chat.completions.create(**response_kwargs)
|
||||
_visualize_pytree(root_node, os.path.splitext(vis_path)[0])
|
||||
return f"/static/{vis_filename}"
|
||||
|
||||
# 工具调用处理:执行工具并回填后,强制模型输出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")
|
||||
def save_reasoning_content(self, reasoning_text: str) -> None:
|
||||
try:
|
||||
reasoning_path = os.path.join(self.reasoning_dir, "reasoning_content.md")
|
||||
with open(reasoning_path, "w", encoding="utf-8") as rf:
|
||||
rf.write(reasoning_text)
|
||||
logging.info(f"📝 推理链已保存: {reasoning_path}")
|
||||
lines = reasoning_text.splitlines()
|
||||
preview = "\n".join(lines[: self.reasoning_preview_lines])
|
||||
logging.info("🧠 推理链预览(前%d行):\n%s", self.reasoning_preview_lines, preview)
|
||||
except Exception as e:
|
||||
logging.warning(f"保存推理链Markdown失败: {e}")
|
||||
|
||||
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():
|
||||
# 将 reasoning_content 包装为 <think>,便于统一解析
|
||||
combined_text += f"<think>\n{msg_reasoning}\n</think>\n"
|
||||
if isinstance(msg_content, str) and msg_content.strip():
|
||||
combined_text += msg_content
|
||||
pytree_str = combined_text if combined_text else (msg_content or "")
|
||||
raw_full_text_for_logging = pytree_str # 保存完整原文(含 <think>)以便失败时完整打印
|
||||
|
||||
# 提取 <think> 推理链内容(若有)
|
||||
reasoning_text = None
|
||||
try:
|
||||
think_match = re.search(r"<think>([\s\S]*?)</think>", pytree_str)
|
||||
if think_match:
|
||||
reasoning_text = think_match.group(1).strip()
|
||||
# 去除推理文本后再尝试解析JSON
|
||||
pytree_str = re.sub(r"<think>[\s\S]*?</think>", "", pytree_str).strip()
|
||||
except Exception:
|
||||
reasoning_text = None
|
||||
# 单独捕获JSON解析错误并打印原始响应
|
||||
try:
|
||||
pytree_dict = json.loads(pytree_str)
|
||||
except json.JSONDecodeError as e:
|
||||
logging.error(f"❌ JSON解析失败(第 {attempt + 1}/3 次)。\n—— 完整原始文本(含<think>) ——\n{raw_full_text_for_logging}")
|
||||
# 尝试打印响应对象的完整结构
|
||||
try:
|
||||
raw_response_dump = None
|
||||
if hasattr(response, 'model_dump_json'):
|
||||
raw_response_dump = response.model_dump_json(indent=2, exclude_none=False)
|
||||
elif hasattr(response, 'dict'):
|
||||
raw_response_dump = json.dumps(response.dict(), ensure_ascii=False, indent=2, default=str)
|
||||
else:
|
||||
# 兜底:尝试将choices与关键字段展开
|
||||
safe_obj = {
|
||||
"id": getattr(response, 'id', None),
|
||||
"model": getattr(response, 'model', None),
|
||||
"object": getattr(response, 'object', None),
|
||||
"usage": getattr(response, 'usage', None),
|
||||
"choices": [
|
||||
{
|
||||
"index": getattr(c, 'index', None),
|
||||
"finish_reason": getattr(c, 'finish_reason', None),
|
||||
"message": {
|
||||
"role": getattr(getattr(c, 'message', None), 'role', None),
|
||||
"content": getattr(getattr(c, 'message', None), 'content', None),
|
||||
"reasoning_content": getattr(getattr(c, 'message', None), 'reasoning_content', None)
|
||||
} if getattr(c, 'message', None) is not None else None
|
||||
}
|
||||
for c in getattr(response, 'choices', [])
|
||||
] if hasattr(response, 'choices') else None
|
||||
}
|
||||
raw_response_dump = json.dumps(safe_obj, ensure_ascii=False, indent=2, default=str)
|
||||
logging.error(f"—— 完整响应对象 ——\n{raw_response_dump}")
|
||||
except Exception as dump_e:
|
||||
try:
|
||||
logging.error(f"响应对象转储失败,repr如下:\n{repr(response)}")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# 简单/复杂分别验证与返回
|
||||
if scene_mode == "simple":
|
||||
try:
|
||||
jsonschema.validate(instance=pytree_dict, schema=self.simple_schema)
|
||||
# 手动检查:简单模式的root节点不能有children(或children必须是空数组)
|
||||
root_node = pytree_dict.get('root', {})
|
||||
if 'children' in root_node:
|
||||
children = root_node.get('children', [])
|
||||
if isinstance(children, list) and len(children) > 0:
|
||||
logging.warning(f"❌ 简单模式验证失败: root节点不能有children,但发现 {len(children)} 个子节点")
|
||||
continue
|
||||
logging.info("✅ 简单模式JSON Schema验证成功")
|
||||
except jsonschema.ValidationError as e:
|
||||
logging.warning(f"❌ 简单模式验证失败: {e.message}")
|
||||
continue
|
||||
# 附加元信息并生成简单可视化(单动作)
|
||||
plan_id = str(uuid.uuid4())
|
||||
pytree_dict['plan_id'] = plan_id
|
||||
# 简单模式可视化:使用root节点(已经是action类型)
|
||||
try:
|
||||
vis_filename = "py_tree.png"
|
||||
vis_path = os.path.join(self.vis_dir, vis_filename)
|
||||
# 简单模式的root节点就是action节点,直接使用
|
||||
root_node = pytree_dict.get('root', {})
|
||||
_visualize_pytree(root_node, os.path.splitext(vis_path)[0])
|
||||
pytree_dict['visualization_url'] = f"/static/{vis_filename}"
|
||||
except Exception as e:
|
||||
logging.warning(f"简单模式可视化失败: {e}")
|
||||
|
||||
# 保存推理链(若有)
|
||||
try:
|
||||
if reasoning_text:
|
||||
reasoning_path = os.path.join(self.reasoning_dir, "reasoning_content.md")
|
||||
with open(reasoning_path, 'w', encoding='utf-8') as rf:
|
||||
rf.write(reasoning_text)
|
||||
logging.info(f"📝 推理链已保存: {reasoning_path}")
|
||||
# 终端预览(最多N行)
|
||||
try:
|
||||
lines = reasoning_text.splitlines()
|
||||
preview = "\n".join(lines[: self.reasoning_preview_lines])
|
||||
logging.info("🧠 推理链预览(前%d行):\n%s", self.reasoning_preview_lines, preview)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logging.info("未在模型输出中发现 <think> 推理链片段。若需捕获,请设置 ENABLE_REASONING_CAPTURE=true 以放宽JSON强制格式。")
|
||||
except Exception as e:
|
||||
logging.warning(f"保存推理链Markdown失败: {e}")
|
||||
# 添加 final_prompt 到返回结果
|
||||
pytree_dict['final_prompt'] = final_prompt
|
||||
|
||||
# 保存历史记录
|
||||
self._save_history(user_prompt, pytree_dict)
|
||||
|
||||
return pytree_dict
|
||||
|
||||
# 验证生成的复杂行为树
|
||||
if _validate_pytree_with_schema(pytree_dict, self.schema):
|
||||
logging.info("✅ 成功生成并验证了Pytree")
|
||||
plan_id = str(uuid.uuid4())
|
||||
pytree_dict['plan_id'] = plan_id
|
||||
|
||||
# Generate visualization to a static path
|
||||
vis_filename = "py_tree.png"
|
||||
vis_path = os.path.join(self.vis_dir, vis_filename)
|
||||
_visualize_pytree(pytree_dict['root'], os.path.splitext(vis_path)[0])
|
||||
pytree_dict['visualization_url'] = f"/static/{vis_filename}"
|
||||
|
||||
# 保存推理链(若有)
|
||||
try:
|
||||
if reasoning_text:
|
||||
reasoning_path = os.path.join(self.reasoning_dir, "reasoning_content.md")
|
||||
with open(reasoning_path, 'w', encoding='utf-8') as rf:
|
||||
rf.write(reasoning_text)
|
||||
logging.info(f"📝 推理链已保存: {reasoning_path}")
|
||||
# 终端预览(最多N行)
|
||||
try:
|
||||
lines = reasoning_text.splitlines()
|
||||
preview = "\n".join(lines[: self.reasoning_preview_lines])
|
||||
logging.info("🧠 推理链预览(前%d行):\n%s", self.reasoning_preview_lines, preview)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logging.info("未在模型输出中发现 <think> 推理链片段。若需捕获,请设置 ENABLE_REASONING_CAPTURE=true 以放宽JSON强制格式。")
|
||||
except Exception as e:
|
||||
logging.warning(f"保存推理链Markdown失败: {e}")
|
||||
# 添加 final_prompt 到返回结果
|
||||
pytree_dict['final_prompt'] = final_prompt
|
||||
|
||||
# 保存历史记录
|
||||
self._save_history(user_prompt, pytree_dict)
|
||||
|
||||
return pytree_dict
|
||||
else:
|
||||
# 打印未通过验证的Pytree以便排查
|
||||
preview = json.dumps(pytree_dict, ensure_ascii=False, indent=2)
|
||||
logging.warning(f"❌ 未通过验证的Pytree(第 {attempt + 1}/3 次尝试):\n{preview}")
|
||||
logging.warning("生成的Pytree验证失败,正在重试...")
|
||||
except OpenAIError as e:
|
||||
logging.error(f"生成Pytree时发生错误: {e}")
|
||||
|
||||
raise RuntimeError("在3次尝试后,仍未能生成一个有效的Pytree。")
|
||||
async def generate(self, user_prompt: str) -> Dict[str, Any]:
|
||||
logging.info(f"接收到用户请求: {user_prompt}")
|
||||
return await self.orchestrator.generate(user_prompt)
|
||||
|
||||
# Create a single instance for the application
|
||||
py_tree_generator = PyTreeGenerator()
|
||||
|
||||
1
backend_service/src/retrieval/__init__.py
Normal file
1
backend_service/src/retrieval/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
1
backend_service/src/retrieval/adapters/__init__.py
Normal file
1
backend_service/src/retrieval/adapters/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
32
backend_service/src/retrieval/adapters/chroma_adapter.py
Normal file
32
backend_service/src/retrieval/adapters/chroma_adapter.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import chromadb
|
||||
|
||||
|
||||
class ChromaAdapter:
|
||||
def __init__(self, client: chromadb.PersistentClient):
|
||||
self.client = client
|
||||
|
||||
def get_collection(self, name: str, embedding_function: Any) -> Optional[Any]:
|
||||
try:
|
||||
return self.client.get_collection(name=name, embedding_function=embedding_function)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.warning("集合 %s 不可用: %s", name, exc)
|
||||
return None
|
||||
|
||||
def query(self, collection: Any, query: str, n_results: int = 3, where: Optional[Dict[str, Any]] = None) -> List[str]:
|
||||
if collection is None:
|
||||
return []
|
||||
try:
|
||||
kwargs: Dict[str, Any] = {"query_texts": [query], "n_results": n_results}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
results = collection.query(**kwargs)
|
||||
return results.get("documents", [[]])[0]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.warning("检索失败: %s", exc)
|
||||
return []
|
||||
|
||||
47
backend_service/src/retrieval/retriever.py
Normal file
47
backend_service/src/retrieval/retriever.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .adapters.chroma_adapter import ChromaAdapter
|
||||
|
||||
|
||||
class UnifiedRetriever:
|
||||
def __init__(self, adapter: ChromaAdapter, embedding_function: Any):
|
||||
self.adapter = adapter
|
||||
self.location_collection = adapter.get_collection("location_kb", embedding_function)
|
||||
self.pattern_collection = adapter.get_collection("pattern_kb", embedding_function)
|
||||
self.rules_collection = adapter.get_collection("rules_kb", embedding_function)
|
||||
self.fallback_collection = adapter.get_collection("drone_docs", embedding_function)
|
||||
|
||||
def _query_scope(self, scope: str, query: str, n_results: int = 3) -> List[str]:
|
||||
if scope == "location":
|
||||
docs = self.adapter.query(self.location_collection, query, n_results=n_results)
|
||||
if docs:
|
||||
return docs
|
||||
return self.adapter.query(self.fallback_collection, query, n_results=n_results, where={"kb_type": "location"})
|
||||
if scope == "pattern":
|
||||
docs = self.adapter.query(self.pattern_collection, query, n_results=n_results)
|
||||
if docs:
|
||||
return docs
|
||||
return self.adapter.query(self.fallback_collection, query, n_results=n_results, where={"kb_type": "pattern"})
|
||||
if scope == "rules":
|
||||
docs = self.adapter.query(self.rules_collection, query, n_results=n_results)
|
||||
if docs:
|
||||
return docs
|
||||
return self.adapter.query(self.fallback_collection, query, n_results=n_results, where={"kb_type": "rules"})
|
||||
return []
|
||||
|
||||
def retrieve(self, query: str, scopes: List[str], n_results: int = 3) -> Dict[str, Any]:
|
||||
results: Dict[str, List[str]] = {"location": [], "pattern": [], "rules": []}
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = {scope: executor.submit(self._query_scope, scope, query, n_results) for scope in scopes}
|
||||
for scope, future in futures.items():
|
||||
results[scope] = future.result()
|
||||
return {
|
||||
"location_context": "\n\n".join(results.get("location", [])),
|
||||
"pattern_context": "\n\n".join(results.get("pattern", [])),
|
||||
"rules_context": "\n\n".join(results.get("rules", [])),
|
||||
"citations": {k: v for k, v in results.items()},
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
1
backend_service/src/validation/__init__.py
Normal file
1
backend_service/src/validation/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
10
backend_service/src/validation/schema_provider.py
Normal file
10
backend_service/src/validation/schema_provider.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class SchemaProvider:
|
||||
def __init__(self, complex_schema: Dict[str, Any], simple_schema: Dict[str, Any]):
|
||||
self.complex_schema = complex_schema
|
||||
self.simple_schema = simple_schema
|
||||
|
||||
23
backend_service/src/validation/validator.py
Normal file
23
backend_service/src/validation/validator.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
import jsonschema
|
||||
|
||||
from .schema_provider import SchemaProvider
|
||||
|
||||
|
||||
class PytreeValidator:
|
||||
def __init__(self, schema_provider: SchemaProvider):
|
||||
self.schema_provider = schema_provider
|
||||
|
||||
def validate(self, scene_mode: str, payload: Dict[str, Any]) -> None:
|
||||
if scene_mode == "simple":
|
||||
jsonschema.validate(instance=payload, schema=self.schema_provider.simple_schema)
|
||||
root = payload.get("root", {})
|
||||
children = root.get("children", [])
|
||||
if isinstance(children, list) and len(children) > 0:
|
||||
raise jsonschema.ValidationError("simple模式下 root 不能包含 children")
|
||||
return
|
||||
jsonschema.validate(instance=payload, schema=self.schema_provider.complex_schema)
|
||||
|
||||
Reference in New Issue
Block a user