This commit is contained in:
2026-02-20 23:04:09 +08:00
parent 43d69d5a99
commit 5d8412bcb6
244 changed files with 9304 additions and 1463 deletions

View File

@@ -0,0 +1 @@

View 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。")

View 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

View 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

View File

@@ -0,0 +1 @@

View 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

View 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)

View 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("✅ 成功生成并验证了PytreePipeline")
return payload

View File

@@ -0,0 +1 @@

View 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---"

View 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)]

View File

@@ -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()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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 []

View 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()},
}

View File

@@ -0,0 +1 @@

View 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

View 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)