2026-04-14 10:08:41 +08:00

176 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
服务层接口定义 - LLM 服务
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Tuple, AsyncIterator, Optional
from loguru import logger
class LLMServiceInterface(ABC):
"""LLM 服务接口 - 所有 LLM 提供者需实现此接口"""
@abstractmethod
async def chat(
self,
messages: List[Dict[str, str]],
session_id: str = "",
turn_id: str = "",
) -> Tuple[str, float]:
"""
调用 LLM 对话
Args:
messages: 消息列表 [{"role": "system|user|assistant", "content": "..."}]
session_id: 会话 ID用于日志
turn_id: 轮次 ID用于日志
Returns:
(回复内容, 耗时秒数)
"""
pass
async def chat_stream(
self,
messages: List[Dict[str, str]],
session_id: str = "",
turn_id: str = "",
) -> AsyncIterator[str]:
"""
流式 LLM按增量产出文本字符/子串)。默认实现回退为整段 chat 一次 yield。
"""
text, _ = await self.chat(messages, session_id=session_id, turn_id=turn_id)
yield text
async def chat_stream_with_tools(
self,
messages: List[Dict[str, Any]],
session_id: str = "",
turn_id: str = "",
) -> AsyncIterator[str]:
"""
带工具调用的流式输出:默认与 chat_stream 相同(无工具的实现)。
DashScope 等提供者可覆盖为「模型 ⟷ 工具」闭环后再按块 yield。
"""
async for chunk in self.chat_stream(
messages, # type: ignore[arg-type]
session_id=session_id,
turn_id=turn_id,
):
yield chunk
@abstractmethod
async def initialize(self) -> bool:
"""
初始化服务(加载模型等)
Returns:
是否成功
"""
pass
@abstractmethod
async def shutdown(self):
"""关闭服务,释放资源"""
pass
def _format_px4_context_block(px4: Dict[str, Any]) -> str:
"""把客户端上报的 PX4 上下文压成给模型看的短条。"""
lines = ["\n【当前载具session.start.client.px4解析飞控意图时必须使用"]
vc = px4.get("vehicle_class") or "unknown"
lines.append(f"- 机型类别 vehicle_class={vc}")
if px4.get("mav_type") is not None:
lines.append(f"- MAV_TYPE={px4['mav_type']}")
if px4.get("px4_version"):
lines.append(f"- PX4 版本={px4['px4_version']}")
if px4.get("airframe_id"):
lines.append(f"- airframe_id={px4['airframe_id']}")
frame = px4.get("default_setpoint_frame") or "local_ned"
lines.append(f"- 默认相对位移 frame={frame}(生成 goto 时优先用此 frame不要擅自改成用户未提及的坐标系")
lines.append(
f"- 能力: offboard_capable={px4.get('offboard_capable', False)}, "
f"mission_capable={px4.get('mission_capable', True)}, "
f"rtl_available={px4.get('rtl_available', True)}, "
f"home_position_valid={px4.get('home_position_valid', False)}"
)
if px4.get("current_nav_state"):
lines.append(f"- 当前导航/模式 current_nav_state={px4['current_nav_state']}")
if px4.get("cruise_alt_m_agl") is not None:
lines.append(f"- 典型巡航相对高度 cruise_alt_m_agl={px4['cruise_alt_m_agl']} m")
extras = px4.get("extras") or {}
if isinstance(extras, dict) and extras:
lines.append(f"- 其它 extras={extras}")
lines.append(
"- 若用户指令与上述能力冲突(例如未 Offboard 却口头要求「速度环可由机载接管」),"
"仍在 JSON 中表达口语意图,并在 summary 写明依赖 offboard / home / mission。"
)
return "\n".join(lines)
_TOOLS_BLOCK = """
【规则 C — 联网工具(仅闲聊 / 事实问答)】
当用户问**实时天气**,或**新闻、百科、赛事、股价**等非飞控且依赖**外部最新信息**的问题时:必须先调用提供的 **get_current_weather** 或 **web_search** 取得事实,再据此用 **12 句简短中文**作答;**禁止**编造气温、赛果或报道细节。
若用户意图属于【规则 A】飞控口令**禁止调用工具**,仍须**只输出一整段 JSON**(仍以「{」开头)。"""
def build_system_prompt(
px4: Optional[Dict[str, Any]] = None,
*,
enable_tools: bool = False,
) -> str:
"""
构建系统提示词
Args:
px4: session.start 中 client.px4 的字典model_dump 后),可选
enable_tools: 是否附加工具调用说明(与 DashScope function calling 对齐)
"""
base = """你是「PX4 飞控」场景下的语音意图助手:用户通过口语控制机载 PX4常配合 MAVROS / Offboard 等由机上计算机转发)。你的输出必须且只能是下列二选一。
【背景 — PX4 与指令(供你理解用户话,勿向用户背教材)】
- 载具类型airframe多旋翼 MC、固定翼 FW、垂起 VTOL、无人车/艇 Rover/Boat 等;同一句话里的「起飞/悬停/降落」在不同机型上由飞控具体执行,你只做语义归类。
- 飞行模式(高层):手动/增稳、高度控制、位置控制、任务 Mission、盘旋 Hold/Loiter、起飞 Takeoff、降落 Land、返航 RTL、Offboard外设定点需机端持续喂设定约 ≥2Hz等。用户说的「返航、悬停、定点、按航线飞」多对应 RTL / Hold / Mission「机载电脑接管、速度位置控制」多对应 Offboard 语义。
- 你输出的 JSON **不是** MAVLink 原文,而是机端可映射的**高层动作序列**:起飞、降落、返航、悬停/保持、相对位移goto、**定时等待wait**。具体 MAVLink 由伴飞桥翻译。
- 若用户指令依赖机型而你无法推断例如固定翼降落航线、VTOL 切换翼态),仍输出 JSON 但 **summary** 写明歧义或假设(例如「按多旋翼理解」)。
【规则 A — 飞控意图 → 仅 JSON须符合 FLIGHT_INTENT_SCHEMA v1
当用户话里包含对本机飞行/任务意图时:**第一个非空白字符必须是「{」**;整条回复只能是**一个**合法 JSON 对象;禁止 Markdown、代码围栏、前缀/后缀;禁止 `//`、`#`、思维链、乱数字串。
**顶层键**不得超出:`is_flight_intent`、`version`、`actions`、`summary`(四者必填)、`trace_id`**可选**string端到端追踪 ID长度 ≤128
- `is_flight_intent`:必须为 `true`。
- `version`:整数 **1**。
- `actions`**非空数组**,严格按口语**时间顺序**;每项**仅有** `type` 与 `args` 两键。
- `summary`:非空中文字符串(播报/日志);**不参与机控**。
**`type` 只能小写且仅能为**`takeoff`、`land`、`return_home`、`hover`、`hold`、`goto`、`wait`。
- `takeoff``args` 为 `{}` **或** `{"relative_altitude_m": 正数}`(米,建议 ≤500不得出现其它键。
- `land`、`return_home`、`hover`、`hold``args` **必须且仅能**为 `{}`。**禁止**在 `hover`/`hold` 的 `args` 里写 `duration`、`seconds`、`timeout` 等**任何**键v1 无此字段;时长用下一步 `wait`)。
- `goto``args` **须含** `frame`;仅可再含 `x`/`y`/`z`(数字或 `null`,米,相对当前位移动);`frame` 只能是 `local_ned` 或 `body_ned`(无载具上下文时默认 `local_ned`)。口语「向前飞」可用 `body_ned` 的 `x` 为正。
- `wait``args` **仅** `{"seconds": 数字}`(键名**必须**是 `seconds`**不得**写成 `duration`),且 **0 < seconds ≤ 3600**。「悬停 N 秒」**必须**拆成:`{"type":"hover","args":{}}` 后紧跟 `{"type":"wait","args":{"seconds":N}}`。
- 「起飞 → 悬停几秒 → 降落」:**takeoff → hover → wait → land**`hover.args` 永不为 `{"duration":…}`。
- **易错(一律非法)**`{"type":"hover","args":{"duration":3}}`。正确为 `hover` 空 `args` + 独立一步 `wait`。
示例(`trace_id` 可省略):
{
"is_flight_intent": true,
"version": 1,
"trace_id": "550e8400-e29b-41d4-a716-446655440000",
"actions": [
{"type": "takeoff", "args": {"relative_altitude_m": 3}},
{"type": "hover", "args": {}},
{"type": "wait", "args": {"seconds": 3}},
{"type": "land", "args": {}}
],
"summary": "起飞至约3米高悬停3秒后降落"
}
【规则 B — 闲聊 → 仅自然短中文】
与当次飞行控制无关的聊天:用 **12 句简短中文**回答,口语化;**不**列举条目标题、**不**长篇解释、**不**主动科普 PX4**不**输出上述 JSON。
若用户问你是谁:一句话说明你是飞控语音指令助手即可。"""
if enable_tools:
base = base + _TOOLS_BLOCK
if px4:
return base + _format_px4_context_block(px4)
return base