1044 lines
39 KiB
Python
Executable File
1044 lines
39 KiB
Python
Executable File
"""Translate a generic model plan into domain adapter inputs without semantic rewriting."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
import re
|
|
from typing import Any
|
|
|
|
from cognitive_agent.application.native_can_intent import resolve_ch1_default_target
|
|
from cognitive_agent.application.seat_calibration import (
|
|
ANGLE_CALIBRATIONS,
|
|
angle_to_position_pct,
|
|
)
|
|
from cognitive_agent.application.vehicle_profile import VehicleProfile
|
|
|
|
|
|
SEAT_CAPABILITIES = {
|
|
"seat.set_ventilation": "ventilation",
|
|
"seat.set_heating": "heating",
|
|
}
|
|
SEAT_MOTION_CAPABILITIES = {
|
|
"seat.set_longitudinal_position": "longitudinal",
|
|
"seat.set_cushion_tilt": "cushion_tilt",
|
|
"seat.set_backrest_recline": "backrest_recline",
|
|
"seat.set_legrest_height": "legrest_height",
|
|
"seat.set_legrest_extension": "legrest_extension",
|
|
}
|
|
SEAT_RELATIVE_PHYSICAL_PARAMETERS = {"distance_mm", "angle_delta_deg"}
|
|
SAVE_PRESET_CAPABILITY = "seat.save_posture_preset"
|
|
APPLY_PRESET_CAPABILITY = "seat.apply_posture_preset"
|
|
DYNAMIC_MODE_CAPABILITY = "seat.start_dynamic_mode"
|
|
DYNAMIC_MODE_STOP_CAPABILITY = "seat.stop_dynamic_mode"
|
|
DYNAMIC_MODE_MAX_RUNTIME_MS = 600_000
|
|
TIMED_MOTION_MIN_DURATION_MS = 300
|
|
TIMED_AXIS_LIMITS = {
|
|
"longitudinal": {"max_duration_ms": 30_000, "ms_per_pct": 120},
|
|
"cushion_tilt": {"max_duration_ms": 20_000, "ms_per_pct": 80},
|
|
"backrest_recline": {"max_duration_ms": 30_000, "ms_per_pct": 120},
|
|
"legrest_height": {"max_duration_ms": 25_000, "ms_per_pct": 100},
|
|
}
|
|
HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
|
NATIVE_CAN_ONLY_CAPABILITIES = {
|
|
"climate.set_control_mode",
|
|
"roof.set_shade_motion",
|
|
}
|
|
DEFAULT_DRIVER_NATIVE_SEAT_CAPABILITIES = {
|
|
"seat.set_ventilation": "seat.set_driver_ventilation_native",
|
|
"seat.set_heating": "seat.set_driver_heating_native",
|
|
}
|
|
EXPLICIT_DRIVER_SEAT_PATTERN = re.compile(
|
|
r"主驾|驾驶位|司机位|左前(?:的)?(?:座椅|座位)|"
|
|
r"seat\s+(?:driver|front-left)",
|
|
re.IGNORECASE,
|
|
)
|
|
EXPLICIT_NON_DRIVER_SEAT_PATTERN = re.compile(
|
|
r"副驾|乘客|右前|后排|前排|所有座椅|全部座椅|"
|
|
r"seat\s+(?:passenger|front-right)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
class ModelPlanAdapter:
|
|
"""Perform structural capability routing; user-intent decisions remain model-owned."""
|
|
|
|
def __init__(self, capability_context: dict[str, Any]) -> None:
|
|
self.capabilities: dict[str, tuple[str, str, dict[str, Any]]] = {}
|
|
for domain in capability_context.get("domains", []):
|
|
for capability in domain.get("capabilities", []):
|
|
self.capabilities[capability["name"]] = (
|
|
domain["domain"],
|
|
domain["availability"],
|
|
capability,
|
|
)
|
|
|
|
def adapt(
|
|
self,
|
|
plan: dict[str, Any],
|
|
context: dict[str, Any] | None = None,
|
|
vehicle_profile: VehicleProfile | None = None,
|
|
utterance: str = "",
|
|
) -> dict[str, Any]:
|
|
actions: list[dict[str, Any]] = []
|
|
domain_actions: list[dict[str, Any]] = []
|
|
deferred: list[dict[str, Any]] = []
|
|
preset_actions: list[dict[str, Any]] = []
|
|
preset_events: list[dict[str, Any]] = []
|
|
ambiguities = list(plan.get("ambiguities", []))
|
|
context = context or {}
|
|
|
|
for index, raw_request in enumerate(plan.get("requests", [])):
|
|
request = self._apply_ch1_default_target(raw_request, utterance)
|
|
capability_name = str(request.get("capability"))
|
|
registered = self.capabilities.get(capability_name)
|
|
if registered is None or registered[0] != request.get("domain"):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].capability",
|
|
"question": "模型请求的领域与能力注册表不一致。",
|
|
}
|
|
)
|
|
continue
|
|
|
|
domain, domain_availability, capability = registered
|
|
parameters = {
|
|
str(item["name"]): item.get("value")
|
|
for item in request.get("parameters", [])
|
|
}
|
|
allowed_parameters = {item["name"] for item in capability.get("parameters", [])}
|
|
unknown_parameters = set(parameters) - allowed_parameters
|
|
if (
|
|
domain == "seat"
|
|
and capability_name in SEAT_MOTION_CAPABILITIES
|
|
):
|
|
unknown_parameters -= SEAT_RELATIVE_PHYSICAL_PARAMETERS
|
|
if unknown_parameters:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters",
|
|
"question": (
|
|
f"能力 {capability_name} 不支持参数 "
|
|
f"{', '.join(sorted(unknown_parameters))}。"
|
|
),
|
|
}
|
|
)
|
|
continue
|
|
|
|
availability = (
|
|
"native_can"
|
|
if capability_name in NATIVE_CAN_ONLY_CAPABILITIES
|
|
else domain_availability
|
|
)
|
|
native_only_request = (
|
|
domain == "seat"
|
|
and capability_name in SEAT_CAPABILITIES
|
|
and (
|
|
request.get("target_scope") in {"left_rear", "right_rear"}
|
|
or parameters.get("level") in {4, 5}
|
|
)
|
|
) or (
|
|
capability_name == "climate.set_outlet_mode"
|
|
and (
|
|
request.get("target_scope") == "vehicle"
|
|
or parameters.get("mode") not in {"face", "feet"}
|
|
)
|
|
)
|
|
if availability != "executable" or native_only_request:
|
|
values = [value for value in parameters.values() if value is not None]
|
|
deferred.append(
|
|
{
|
|
"domain": domain,
|
|
"capability": capability_name,
|
|
"target_scope": request["target_scope"],
|
|
"desired_change": request["operation"],
|
|
"requested_value": values[0] if len(values) == 1 else None,
|
|
"parameters": parameters,
|
|
"reason": request["reason"],
|
|
"execution_backend": (
|
|
"native_can"
|
|
if availability == "native_can" or native_only_request
|
|
else availability
|
|
),
|
|
}
|
|
)
|
|
continue
|
|
|
|
if domain != "seat":
|
|
generic_action = self._generic_action(
|
|
index,
|
|
request,
|
|
parameters,
|
|
capability,
|
|
ambiguities,
|
|
)
|
|
if generic_action is not None:
|
|
domain_actions.append(generic_action)
|
|
continue
|
|
if capability_name == SAVE_PRESET_CAPABILITY:
|
|
preset_action = self._preset_action(
|
|
index, request, parameters, ambiguities
|
|
)
|
|
if preset_action is not None:
|
|
preset_actions.append(preset_action)
|
|
continue
|
|
if capability_name == APPLY_PRESET_CAPABILITY:
|
|
preset_action = self._preset_action(
|
|
index, request, parameters, ambiguities
|
|
)
|
|
if preset_action is None:
|
|
continue
|
|
preset = self._find_preset(
|
|
context.get("presets", []),
|
|
preset_action["target"],
|
|
preset_action["preset_name"],
|
|
)
|
|
if preset is None:
|
|
available = [
|
|
str(item.get("name"))
|
|
for item in context.get("presets", [])
|
|
if item.get("target") == preset_action["target"]
|
|
]
|
|
suffix = (
|
|
f"当前可用预设:{', '.join(available)}。"
|
|
if available
|
|
else "当前座位尚未保存任何预设。"
|
|
)
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.preset_name",
|
|
"question": (
|
|
f"未找到 {preset_action['target']} 的预设"
|
|
f"“{preset_action['preset_name']}”。{suffix}"
|
|
),
|
|
}
|
|
)
|
|
continue
|
|
for operation in preset["function_preset"]["spec"]["operations"]:
|
|
operation_parameters = dict(operation["parameters"])
|
|
action = self._seat_action(
|
|
index,
|
|
{
|
|
"capability": operation["capability"],
|
|
"target_scope": preset_action["target"],
|
|
"operation": "set",
|
|
},
|
|
operation_parameters,
|
|
ambiguities,
|
|
context,
|
|
vehicle_profile,
|
|
)
|
|
if action is not None:
|
|
actions.append(action)
|
|
preset_events.append(
|
|
{
|
|
"action": "apply",
|
|
"target": preset_action["target"],
|
|
"preset_name": preset_action["preset_name"],
|
|
}
|
|
)
|
|
continue
|
|
if capability_name == DYNAMIC_MODE_STOP_CAPABILITY:
|
|
dynamic_action = self._dynamic_mode_stop_action(
|
|
index, request, parameters, ambiguities
|
|
)
|
|
if dynamic_action is not None:
|
|
actions.append(dynamic_action)
|
|
continue
|
|
if capability_name == DYNAMIC_MODE_CAPABILITY:
|
|
dynamic_action = self._dynamic_mode_action(
|
|
index, request, parameters, ambiguities
|
|
)
|
|
if dynamic_action is not None:
|
|
actions.append(dynamic_action)
|
|
continue
|
|
action = self._seat_action(
|
|
index, request, parameters, ambiguities, context, vehicle_profile
|
|
)
|
|
if action is not None:
|
|
actions.append(action)
|
|
|
|
return {
|
|
"detected_domains": list(plan.get("detected_domains", ["seat"])),
|
|
"actions": actions,
|
|
"domain_actions": domain_actions,
|
|
"preset_actions": preset_actions,
|
|
"preset_events": preset_events,
|
|
"deferred_requests": deferred,
|
|
"ambiguities": ambiguities,
|
|
"decision_summary": list(plan.get("decision_summary", [])),
|
|
"confidence": float(plan.get("confidence", 0)),
|
|
"model_requests": deepcopy(plan.get("requests", [])),
|
|
}
|
|
|
|
@staticmethod
|
|
def _apply_ch1_default_target(
|
|
raw_request: dict[str, Any], utterance: str
|
|
) -> dict[str, Any]:
|
|
"""Normalize omitted model targets to driver-console CH1 defaults."""
|
|
|
|
request = deepcopy(raw_request)
|
|
capability = str(request.get("capability", ""))
|
|
native_seat_capability = DEFAULT_DRIVER_NATIVE_SEAT_CAPABILITIES.get(
|
|
capability
|
|
)
|
|
if native_seat_capability is not None:
|
|
explicit_non_driver = bool(
|
|
EXPLICIT_NON_DRIVER_SEAT_PATTERN.search(utterance)
|
|
)
|
|
explicit_driver = bool(EXPLICIT_DRIVER_SEAT_PATTERN.search(utterance))
|
|
implicit_driver = (
|
|
resolve_ch1_default_target(native_seat_capability, utterance)
|
|
== "driver"
|
|
)
|
|
if not explicit_non_driver and (explicit_driver or implicit_driver):
|
|
request["capability"] = native_seat_capability
|
|
request["target_scope"] = "driver"
|
|
capability = native_seat_capability
|
|
|
|
default_target = resolve_ch1_default_target(capability, utterance)
|
|
if default_target is not None:
|
|
request["target_scope"] = default_target
|
|
return request
|
|
|
|
@staticmethod
|
|
def _generic_action(
|
|
index: int,
|
|
request: dict[str, Any],
|
|
parameters: dict[str, Any],
|
|
capability: dict[str, Any],
|
|
ambiguities: list[dict[str, str]],
|
|
) -> dict[str, Any] | None:
|
|
target = request.get("target_scope")
|
|
if target not in capability.get("target_scopes", []):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": (
|
|
f"{request['capability']} 不支持目标范围 {target!r}。"
|
|
),
|
|
}
|
|
)
|
|
return None
|
|
|
|
parameter_specs = {
|
|
item["name"]: item for item in capability.get("parameters", [])
|
|
}
|
|
state_spec = parameter_specs.get("state")
|
|
if state_spec is not None and parameters.get("state") is None:
|
|
inferred_state = {
|
|
"activate": "on",
|
|
"deactivate": "off",
|
|
}.get(request.get("operation"))
|
|
if inferred_state is not None:
|
|
parameters["state"] = inferred_state
|
|
|
|
for name, spec in parameter_specs.items():
|
|
value = parameters.get(name)
|
|
if spec.get("required") and value is None:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.{name}",
|
|
"question": (
|
|
f"{request['capability']} 需要模型给出参数 {name} 的绝对目标值。"
|
|
),
|
|
}
|
|
)
|
|
return None
|
|
if value is None:
|
|
continue
|
|
expected_type = spec.get("type")
|
|
numeric = (
|
|
isinstance(value, (int, float))
|
|
and not isinstance(value, bool)
|
|
)
|
|
type_valid = {
|
|
"number": numeric,
|
|
"integer": numeric and float(value).is_integer(),
|
|
"string": isinstance(value, str),
|
|
"boolean": isinstance(value, bool),
|
|
"object": isinstance(value, dict),
|
|
"array": isinstance(value, list),
|
|
}.get(str(expected_type), False)
|
|
if not type_valid:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.{name}",
|
|
"question": f"参数 {name} 的类型必须是 {expected_type}。",
|
|
}
|
|
)
|
|
return None
|
|
if numeric and (
|
|
("minimum" in spec and float(value) < float(spec["minimum"]))
|
|
or ("maximum" in spec and float(value) > float(spec["maximum"]))
|
|
):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.{name}",
|
|
"question": (
|
|
f"参数 {name} 必须位于 "
|
|
f"{spec.get('minimum', '-∞')} 到 {spec.get('maximum', '+∞')} 之间。"
|
|
),
|
|
}
|
|
)
|
|
return None
|
|
if spec.get("allowed_values") and value not in spec["allowed_values"]:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.{name}",
|
|
"question": (
|
|
f"参数 {name} 必须是 "
|
|
f"{', '.join(map(str, spec['allowed_values']))} 之一。"
|
|
),
|
|
}
|
|
)
|
|
return None
|
|
if name == "color_hex" and not HEX_COLOR.fullmatch(str(value)):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.color_hex",
|
|
"question": "氛围灯颜色必须是 #RRGGBB 格式。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
normalized = {}
|
|
for name, value in parameters.items():
|
|
spec = parameter_specs[name]
|
|
normalized[name] = (
|
|
int(value)
|
|
if spec.get("type") == "integer"
|
|
else value.upper()
|
|
if name == "color_hex"
|
|
else value
|
|
)
|
|
return {
|
|
"domain": request["domain"],
|
|
"capability": request["capability"],
|
|
"target": str(target),
|
|
"parameters": normalized,
|
|
"operation": request.get("operation", "set"),
|
|
"reason": request.get("reason", ""),
|
|
}
|
|
|
|
@staticmethod
|
|
def _preset_action(
|
|
index: int,
|
|
request: dict[str, Any],
|
|
parameters: dict[str, Any],
|
|
ambiguities: list[dict[str, str]],
|
|
) -> dict[str, str] | None:
|
|
target = request.get("target_scope")
|
|
if target != "passenger":
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": "当前座椅能力仅控制副驾,请将目标指定为 passenger。",
|
|
}
|
|
)
|
|
return None
|
|
preset_name = parameters.get("preset_name")
|
|
if (
|
|
not isinstance(preset_name, str)
|
|
or not preset_name.strip()
|
|
or len(preset_name.strip()) > 64
|
|
):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.preset_name",
|
|
"question": "保存或调用座椅姿态预设需要提供 1-64 个字符的名称。",
|
|
}
|
|
)
|
|
return None
|
|
return {"target": target, "preset_name": preset_name.strip()}
|
|
|
|
@staticmethod
|
|
def _dynamic_mode_action(
|
|
index: int,
|
|
request: dict[str, Any],
|
|
parameters: dict[str, Any],
|
|
ambiguities: list[dict[str, str]],
|
|
) -> dict[str, Any] | None:
|
|
target = request.get("target_scope")
|
|
if target != "passenger":
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": "当前动态座椅模式仅控制副驾。",
|
|
}
|
|
)
|
|
return None
|
|
mode_id = parameters.get("mode_id")
|
|
if (
|
|
not isinstance(mode_id, (int, float))
|
|
or isinstance(mode_id, bool)
|
|
or not float(mode_id).is_integer()
|
|
or int(mode_id) not in {1, 2}
|
|
):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.mode_id",
|
|
"question": "动态座椅模式编号必须是 1(木马模式)或 2(动态摇篮)。",
|
|
}
|
|
)
|
|
return None
|
|
normalized_mode_id = int(mode_id)
|
|
return {
|
|
"action_type": "dynamic_mode",
|
|
"target": target,
|
|
"mode_id": normalized_mode_id,
|
|
"mode_name": (
|
|
"木马模式"
|
|
if normalized_mode_id == 1
|
|
else "动态摇篮模式"
|
|
),
|
|
"repeat": "until_stopped_or_timeout",
|
|
"max_runtime_ms": DYNAMIC_MODE_MAX_RUNTIME_MS,
|
|
}
|
|
|
|
@staticmethod
|
|
def _dynamic_mode_stop_action(
|
|
index: int,
|
|
request: dict[str, Any],
|
|
parameters: dict[str, Any],
|
|
ambiguities: list[dict[str, str]],
|
|
) -> dict[str, Any] | None:
|
|
target = request.get("target_scope")
|
|
if target != "passenger":
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": "当前动态座椅模式仅控制副驾。",
|
|
}
|
|
)
|
|
return None
|
|
if parameters:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters",
|
|
"question": "停止动态座椅模式不接受额外参数。",
|
|
}
|
|
)
|
|
return None
|
|
return {
|
|
"action_type": "dynamic_mode_stop",
|
|
"target": target,
|
|
}
|
|
|
|
@staticmethod
|
|
def _find_preset(
|
|
presets: list[dict[str, Any]],
|
|
target: str,
|
|
preset_name: str,
|
|
) -> dict[str, Any] | None:
|
|
expected = preset_name.casefold()
|
|
return next(
|
|
(
|
|
preset
|
|
for preset in presets
|
|
if preset.get("target") == target
|
|
and str(preset.get("name", "")).casefold() == expected
|
|
),
|
|
None,
|
|
)
|
|
|
|
@staticmethod
|
|
def _seat_action(
|
|
index: int,
|
|
request: dict[str, Any],
|
|
parameters: dict[str, Any],
|
|
ambiguities: list[dict[str, str]],
|
|
context: dict[str, Any],
|
|
vehicle_profile: VehicleProfile | None = None,
|
|
) -> dict[str, Any] | None:
|
|
target = request.get("target_scope")
|
|
if target != "passenger":
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": "当前座椅能力仅控制副驾,请将目标指定为 passenger。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
capability = request["capability"]
|
|
if capability in SEAT_MOTION_CAPABILITIES:
|
|
axis = SEAT_MOTION_CAPABILITIES[capability]
|
|
if request.get("duration_ms") is not None:
|
|
return ModelPlanAdapter._timed_motion_action(
|
|
index,
|
|
request,
|
|
ambiguities,
|
|
context,
|
|
target,
|
|
axis,
|
|
vehicle_profile,
|
|
)
|
|
physical_action = ModelPlanAdapter._relative_physical_motion_action(
|
|
index,
|
|
request,
|
|
parameters,
|
|
ambiguities,
|
|
context,
|
|
target,
|
|
axis,
|
|
vehicle_profile,
|
|
)
|
|
if physical_action is not None:
|
|
return physical_action
|
|
angle = parameters.get("angle_deg")
|
|
if angle is not None:
|
|
if not isinstance(angle, (int, float)) or isinstance(angle, bool):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.angle_deg",
|
|
"question": f"{capability} 的夹角必须是数字。",
|
|
}
|
|
)
|
|
return None
|
|
profile_motor = (
|
|
vehicle_profile.seat_motors.get(axis)
|
|
if vehicle_profile is not None
|
|
else None
|
|
)
|
|
if profile_motor is not None and profile_motor.display_angle:
|
|
if not profile_motor.contains_display_angle(float(angle)):
|
|
display_angle = profile_motor.display_angle
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.angle_deg",
|
|
"question": (
|
|
f"{capability} 的夹角必须在 "
|
|
f"{display_angle['zero_deg']:g}-{display_angle['max_deg']:g} 度之间。"
|
|
),
|
|
}
|
|
)
|
|
return None
|
|
position_pct = profile_motor.position_pct_for_display_angle(
|
|
float(angle)
|
|
)
|
|
elif capability in ANGLE_CALIBRATIONS:
|
|
calibration = ANGLE_CALIBRATIONS[capability]
|
|
if not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.angle_deg",
|
|
"question": (
|
|
f"{capability} 的夹角必须在 "
|
|
f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
|
|
),
|
|
}
|
|
)
|
|
return None
|
|
position_pct = angle_to_position_pct(capability, float(angle))
|
|
else:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.position_pct",
|
|
"question": "当前车型未给出该座椅轴的角度标定,需要 0-100 的绝对百分比。",
|
|
}
|
|
)
|
|
return None
|
|
exact_angle: int | float = (
|
|
int(angle) if float(angle).is_integer() else float(angle)
|
|
)
|
|
action = {
|
|
"action_type": "position",
|
|
"target": target,
|
|
"axis": axis,
|
|
"position_pct": position_pct,
|
|
"angle_deg": exact_angle,
|
|
}
|
|
return ModelPlanAdapter._preserve_relative_motion(
|
|
index, request, action, context, ambiguities
|
|
)
|
|
position = parameters.get("position_pct")
|
|
if (
|
|
not isinstance(position, (int, float))
|
|
or isinstance(position, bool)
|
|
or not float(position).is_integer()
|
|
or not 0 <= float(position) <= 100
|
|
):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.position_pct",
|
|
"question": "座椅位置动作需要模型给出 0-100 的绝对整数目标。",
|
|
}
|
|
)
|
|
return None
|
|
action = {
|
|
"action_type": "position",
|
|
"target": target,
|
|
"axis": axis,
|
|
"position_pct": int(position),
|
|
}
|
|
return ModelPlanAdapter._preserve_relative_motion(
|
|
index, request, action, context, ambiguities
|
|
)
|
|
|
|
state = parameters.get("state")
|
|
if state is None:
|
|
state = {"activate": "on", "deactivate": "off"}.get(request.get("operation"))
|
|
if state not in {"on", "off"}:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.state",
|
|
"question": "座椅动作需要明确的 on/off 目标状态。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
level = parameters.get("level")
|
|
return {
|
|
"target": target,
|
|
"function": SEAT_CAPABILITIES[capability],
|
|
"state": state,
|
|
"requested_level": (
|
|
int(level)
|
|
if isinstance(level, (int, float)) and not isinstance(level, bool)
|
|
else None
|
|
),
|
|
}
|
|
|
|
@staticmethod
|
|
def _timed_motion_action(
|
|
index: int,
|
|
request: dict[str, Any],
|
|
ambiguities: list[dict[str, str]],
|
|
context: dict[str, Any],
|
|
target: str,
|
|
axis: str,
|
|
vehicle_profile: VehicleProfile | None,
|
|
) -> dict[str, Any] | None:
|
|
duration_ms = request.get("duration_ms")
|
|
if (
|
|
not isinstance(duration_ms, (int, float))
|
|
or isinstance(duration_ms, bool)
|
|
or not float(duration_ms).is_integer()
|
|
or float(duration_ms) < 0
|
|
):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].duration_ms",
|
|
"question": "座椅按时长移动需要非负的整数毫秒数。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
direction = str(request.get("operation", "set"))
|
|
if direction not in {"increase", "decrease"}:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].operation",
|
|
"question": "座椅按时长移动需要明确 increase 或 decrease 方向。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
timing = TIMED_AXIS_LIMITS.get(axis)
|
|
if timing is None:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].capability",
|
|
"question": "该座椅轴当前不支持按时长运动。",
|
|
}
|
|
)
|
|
return None
|
|
previous = (
|
|
context.get("devices", {})
|
|
.get(target, {})
|
|
.get("position", {})
|
|
.get(axis)
|
|
)
|
|
if not isinstance(previous, dict):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": "缺少当前座椅位置,无法安全生成按时长运动命令。",
|
|
}
|
|
)
|
|
return None
|
|
current_pct = previous.get("position_pct")
|
|
if (
|
|
not isinstance(current_pct, (int, float))
|
|
or isinstance(current_pct, bool)
|
|
or not 0 <= float(current_pct) <= 100
|
|
):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": "当前座椅位置无效,无法安全执行按时长运动。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
requested_duration_ms = int(duration_ms)
|
|
maximum_duration_ms = int(timing["max_duration_ms"])
|
|
applied_duration_ms = min(
|
|
maximum_duration_ms,
|
|
max(TIMED_MOTION_MIN_DURATION_MS, requested_duration_ms),
|
|
)
|
|
estimated_delta_pct = applied_duration_ms / int(timing["ms_per_pct"])
|
|
signed_delta = estimated_delta_pct if direction == "increase" else -estimated_delta_pct
|
|
raw_target = float(current_pct) + signed_delta
|
|
target_pct = round(max(0.0, min(100.0, raw_target)))
|
|
motion_intent: dict[str, Any] = {
|
|
"mode": "timed",
|
|
"direction": direction,
|
|
"start_position_pct": round(float(current_pct)),
|
|
"delta_position_pct": abs(target_pct - round(float(current_pct))),
|
|
"requested_duration_ms": requested_duration_ms,
|
|
"applied_duration_ms": applied_duration_ms,
|
|
"max_duration_ms": maximum_duration_ms,
|
|
"min_duration_ms": TIMED_MOTION_MIN_DURATION_MS,
|
|
"duration_clamped": applied_duration_ms != requested_duration_ms,
|
|
}
|
|
if raw_target >= 100:
|
|
motion_intent["endpoint_expected"] = "far"
|
|
elif raw_target <= 0:
|
|
motion_intent["endpoint_expected"] = "near"
|
|
|
|
action: dict[str, Any] = {
|
|
"action_type": "position",
|
|
"target": target,
|
|
"axis": axis,
|
|
"position_pct": target_pct,
|
|
"motion_intent": motion_intent,
|
|
"user_notice": f"座椅轴将按时长执行{applied_duration_ms / 1000:g}秒。",
|
|
}
|
|
motor = (
|
|
vehicle_profile.seat_motors.get(axis)
|
|
if vehicle_profile is not None
|
|
else None
|
|
)
|
|
if motor is not None and motor.display_angle is not None:
|
|
angle = motor.display_angle_for_pct(target_pct)
|
|
if angle is not None:
|
|
action["angle_deg"] = _clean_number(float(angle))
|
|
return action
|
|
|
|
@staticmethod
|
|
def _relative_physical_motion_action(
|
|
index: int,
|
|
request: dict[str, Any],
|
|
parameters: dict[str, Any],
|
|
ambiguities: list[dict[str, str]],
|
|
context: dict[str, Any],
|
|
target: str,
|
|
axis: str,
|
|
vehicle_profile: VehicleProfile | None,
|
|
) -> dict[str, Any] | None:
|
|
physical_parameters = [
|
|
name
|
|
for name in ("distance_mm", "angle_delta_deg")
|
|
if parameters.get(name) is not None
|
|
]
|
|
if not physical_parameters:
|
|
return None
|
|
if len(physical_parameters) > 1:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters",
|
|
"question": "单个座椅轴相对移动只能提供一种物理增量。",
|
|
}
|
|
)
|
|
return None
|
|
if vehicle_profile is None:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.position_pct",
|
|
"question": "缺少车型座椅量程,无法把相对物理移动换算为绝对百分比。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
parameter_name = physical_parameters[0]
|
|
delta = parameters[parameter_name]
|
|
if (
|
|
not isinstance(delta, (int, float))
|
|
or isinstance(delta, bool)
|
|
or float(delta) <= 0
|
|
):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.{parameter_name}",
|
|
"question": "座椅相对物理移动量必须是正数。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
motor = vehicle_profile.seat_motors.get(axis)
|
|
if motor is None:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].capability",
|
|
"question": f"车型 {vehicle_profile.name} 未配置 {axis} 轴量程。",
|
|
}
|
|
)
|
|
return None
|
|
expected_unit = "mm" if parameter_name == "distance_mm" else "degree"
|
|
if motor.unit != expected_unit:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].parameters.{parameter_name}",
|
|
"question": (
|
|
f"{axis} 轴使用 {motor.unit} 标定,不能用 {expected_unit} 增量换算。"
|
|
),
|
|
}
|
|
)
|
|
return None
|
|
|
|
direction = str(request.get("operation", "set"))
|
|
if direction not in {"increase", "decrease"}:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].operation",
|
|
"question": "相对物理移动需要 increase 或 decrease 方向。",
|
|
}
|
|
)
|
|
return None
|
|
previous = (
|
|
context.get("devices", {})
|
|
.get(target, {})
|
|
.get("position", {})
|
|
.get(axis)
|
|
)
|
|
if not isinstance(previous, dict):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": "缺少当前座椅姿态,无法安全执行相对物理移动。",
|
|
}
|
|
)
|
|
return None
|
|
current_pct = previous.get("position_pct")
|
|
if (
|
|
not isinstance(current_pct, (int, float))
|
|
or isinstance(current_pct, bool)
|
|
):
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].target_scope",
|
|
"question": "当前座椅百分比位置无效,无法安全执行相对物理移动。",
|
|
}
|
|
)
|
|
return None
|
|
|
|
position_pct = vehicle_profile.relative_target_pct(
|
|
axis,
|
|
current_pct=current_pct,
|
|
delta=float(delta),
|
|
direction=direction,
|
|
)
|
|
action: dict[str, Any] = {
|
|
"action_type": "position",
|
|
"target": target,
|
|
"axis": axis,
|
|
"position_pct": position_pct,
|
|
}
|
|
if parameter_name == "angle_delta_deg":
|
|
action["angle_deg"] = ModelPlanAdapter._relative_target_angle(
|
|
previous,
|
|
motor,
|
|
position_pct,
|
|
float(delta),
|
|
direction,
|
|
)
|
|
return ModelPlanAdapter._preserve_relative_motion(
|
|
index, request, action, context, ambiguities
|
|
)
|
|
|
|
@staticmethod
|
|
def _relative_target_angle(
|
|
previous: dict[str, Any],
|
|
motor: Any,
|
|
position_pct: int,
|
|
delta_deg: float,
|
|
direction: str,
|
|
) -> int | float:
|
|
start_angle = previous.get("angle_deg")
|
|
if (
|
|
isinstance(start_angle, (int, float))
|
|
and not isinstance(start_angle, bool)
|
|
):
|
|
signed_delta = delta_deg if direction == "increase" else -delta_deg
|
|
return _clean_number(float(start_angle) + signed_delta)
|
|
display_angle = motor.display_angle_for_pct(position_pct)
|
|
if display_angle is None:
|
|
return _clean_number(delta_deg)
|
|
return _clean_number(display_angle)
|
|
|
|
@staticmethod
|
|
def _preserve_relative_motion(
|
|
index: int,
|
|
request: dict[str, Any],
|
|
action: dict[str, Any],
|
|
context: dict[str, Any],
|
|
ambiguities: list[dict[str, str]],
|
|
) -> dict[str, Any] | None:
|
|
"""Keep the model's relative intent after it resolves an absolute target.
|
|
|
|
The absolute target remains the public capability value. This context is
|
|
also needed by the hardware compiler to choose the bounded timed/Hall path
|
|
when the controller has not yet acquired an absolute endpoint calibration.
|
|
"""
|
|
|
|
requested_operation = str(request.get("operation", "set"))
|
|
if requested_operation not in {"increase", "decrease"}:
|
|
return action
|
|
|
|
target = str(action["target"])
|
|
axis = str(action["axis"])
|
|
previous = (
|
|
context.get("devices", {})
|
|
.get(target, {})
|
|
.get("position", {})
|
|
.get(axis)
|
|
)
|
|
if not isinstance(previous, dict):
|
|
return action
|
|
start_position = previous.get("position_pct")
|
|
if not isinstance(start_position, (int, float)) or isinstance(
|
|
start_position, bool
|
|
):
|
|
return action
|
|
|
|
target_position = int(action["position_pct"])
|
|
signed_delta = target_position - int(start_position)
|
|
expected_sign = 1 if requested_operation == "increase" else -1
|
|
if signed_delta * expected_sign < 0:
|
|
ambiguities.append(
|
|
{
|
|
"field": f"requests[{index}].operation",
|
|
"question": (
|
|
"模型给出的相对移动方向与其解析出的绝对目标相互矛盾,"
|
|
"无法安全生成电机方向。"
|
|
),
|
|
}
|
|
)
|
|
return None
|
|
|
|
motion_intent: dict[str, Any] = {
|
|
"mode": "relative",
|
|
"direction": requested_operation,
|
|
"start_position_pct": int(start_position),
|
|
"delta_position_pct": abs(signed_delta),
|
|
}
|
|
start_angle = previous.get("angle_deg")
|
|
target_angle = action.get("angle_deg")
|
|
if (
|
|
isinstance(start_angle, (int, float))
|
|
and not isinstance(start_angle, bool)
|
|
and isinstance(target_angle, (int, float))
|
|
and not isinstance(target_angle, bool)
|
|
):
|
|
motion_intent["start_angle_deg"] = float(start_angle)
|
|
motion_intent["delta_angle_deg"] = abs(
|
|
float(target_angle) - float(start_angle)
|
|
)
|
|
action["motion_intent"] = motion_intent
|
|
return action
|
|
|
|
|
|
def _clean_number(value: float) -> int | float:
|
|
return int(value) if float(value).is_integer() else round(float(value), 1)
|