273 lines
10 KiB
Python
Executable File
273 lines
10 KiB
Python
Executable File
"""Seat-specific semantic validation beyond JSON Schema."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
SUPPORTED_TARGETS = {"passenger"}
|
|
SUPPORTED_FUNCTIONS = {"ventilation", "heating"}
|
|
SUPPORTED_STATES = {"on", "off"}
|
|
SUPPORTED_MOTION_AXES = {
|
|
"longitudinal",
|
|
"cushion_tilt",
|
|
"backrest_recline",
|
|
"legrest_height",
|
|
"legrest_extension",
|
|
}
|
|
ANGLE_RANGES = {
|
|
"backrest_recline": (95, 163),
|
|
"legrest_height": (95, 167),
|
|
}
|
|
|
|
|
|
def validate_semantics(
|
|
parsed: dict[str, Any], function_config: dict[str, Any] | None
|
|
) -> list[dict[str, str]]:
|
|
issues: list[dict[str, str]] = []
|
|
|
|
detected_domains = set(parsed.get("detected_domains", ["seat"]))
|
|
if parsed.get("actions") and "seat" not in detected_domains:
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "DOMAIN_ACTION_MISMATCH",
|
|
"path": "detected_domains",
|
|
"message": "GPT 生成了座椅动作,但 detected_domains 中没有 seat。",
|
|
}
|
|
)
|
|
|
|
for index, deferred in enumerate(parsed.get("deferred_requests", [])):
|
|
domain = str(deferred.get("domain"))
|
|
capability = str(deferred.get("capability"))
|
|
# An N7-synchronised scene may use a vehicle-owned capability namespace
|
|
# that intentionally differs from its routing domain (for example
|
|
# ``domain=local_vehicle`` with ``capability=local_scene.seat_preset``).
|
|
# It is not executed by this validator's local backend: the original
|
|
# action is forwarded unchanged to ``transto_n7/ctl``. Keep the strict
|
|
# namespace check for every ordinary deferred request, while allowing
|
|
# this explicitly marked transfer backend to retain the source contract.
|
|
n7_transfer = (
|
|
deferred.get("execution_backend") == "n7_vehicle_transfer"
|
|
and deferred.get("rejection_code") == "PC_CAPABILITY_UNAVAILABLE"
|
|
)
|
|
if domain not in detected_domains or (
|
|
not n7_transfer and not capability.startswith(f"{domain}.")
|
|
):
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "DEFERRED_REQUEST_MISMATCH",
|
|
"path": f"deferred_requests[{index}]",
|
|
"message": "暂缓请求的领域、能力名和 detected_domains 不一致。",
|
|
}
|
|
)
|
|
issues.append(
|
|
{
|
|
"severity": "warning",
|
|
"code": "UNAVAILABLE_DOMAIN",
|
|
"path": f"deferred_requests[{index}]",
|
|
"message": f"已理解 {capability} 目标,但 {domain} 域当前仅有能力目录,未接入执行器。",
|
|
}
|
|
)
|
|
|
|
for ambiguity in parsed.get("ambiguities", []):
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "AMBIGUOUS_INTENT",
|
|
"path": ambiguity["field"],
|
|
"message": ambiguity["question"],
|
|
}
|
|
)
|
|
|
|
seen: dict[tuple[str, str], str] = {}
|
|
active_functions: dict[str, set[str]] = {}
|
|
has_motion = False
|
|
for index, action in enumerate(parsed.get("actions", [])):
|
|
path = f"actions[{index}]"
|
|
if action.get("target") not in SUPPORTED_TARGETS:
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "UNSUPPORTED_TARGET",
|
|
"path": f"{path}.target",
|
|
"message": "当前所有座椅能力仅支持前排副驾。",
|
|
}
|
|
)
|
|
if action.get("action_type") == "dynamic_mode_stop":
|
|
has_motion = True
|
|
continue
|
|
if action.get("action_type") == "dynamic_mode":
|
|
has_motion = True
|
|
if action.get("mode_id") not in {1, 2}:
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "UNSUPPORTED_DYNAMIC_MODE",
|
|
"path": f"{path}.mode_id",
|
|
"message": "动态座椅模式编号必须是 1 或 2。",
|
|
}
|
|
)
|
|
continue
|
|
if action.get("action_type") == "position":
|
|
has_motion = True
|
|
axis = action.get("axis")
|
|
position = action.get("position_pct")
|
|
if axis not in SUPPORTED_MOTION_AXES:
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "UNSUPPORTED_MOTION_AXIS",
|
|
"path": f"{path}.axis",
|
|
"message": "请求的座椅位置轴未在当前能力目录中注册。",
|
|
}
|
|
)
|
|
if (
|
|
not isinstance(position, int)
|
|
or isinstance(position, bool)
|
|
or not 0 <= position <= 100
|
|
):
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "UNSUPPORTED_POSITION_RANGE",
|
|
"path": f"{path}.position_pct",
|
|
"message": "座椅电机绝对目标必须是 0-100 的整数百分比。",
|
|
}
|
|
)
|
|
angle = action.get("angle_deg")
|
|
if angle is not None:
|
|
angle_range = ANGLE_RANGES.get(str(axis))
|
|
if (
|
|
angle_range is None
|
|
or not isinstance(angle, (int, float))
|
|
or isinstance(angle, bool)
|
|
or not angle_range[0] <= float(angle) <= angle_range[1]
|
|
):
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "UNSUPPORTED_ANGLE_RANGE",
|
|
"path": f"{path}.angle_deg",
|
|
"message": "座椅夹角超出当前虚拟标定范围。",
|
|
}
|
|
)
|
|
key = (str(action.get("target")), str(axis))
|
|
previous_position = seen.get(key)
|
|
if previous_position is not None and previous_position != str(position):
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "CONFLICTING_OPERATIONS",
|
|
"path": path,
|
|
"message": "同一个座椅电机轴不能在一份配置中包含不同目标位置。",
|
|
}
|
|
)
|
|
seen[key] = str(position)
|
|
continue
|
|
if action.get("function") not in SUPPORTED_FUNCTIONS:
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "UNSUPPORTED_CAPABILITY",
|
|
"path": f"{path}.function",
|
|
"message": "当前只支持座椅通风和加热。",
|
|
}
|
|
)
|
|
if action.get("state") not in SUPPORTED_STATES:
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "UNSUPPORTED_STATE",
|
|
"path": f"{path}.state",
|
|
"message": "当前控制信号只支持打开或关闭。",
|
|
}
|
|
)
|
|
requested_level = action.get("requested_level")
|
|
if requested_level is not None and requested_level not in {1, 2, 3}:
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "UNSUPPORTED_LEVEL_RANGE",
|
|
"path": f"{path}.requested_level",
|
|
"message": "当前舒适推断和 0x4FA 虚拟回读仅支持 1-3 档。",
|
|
}
|
|
)
|
|
elif requested_level is not None:
|
|
issues.append(
|
|
{
|
|
"severity": "warning",
|
|
"code": "LEVEL_COMMAND_SIMULATED",
|
|
"path": f"{path}.requested_level",
|
|
"message": (
|
|
"目标档位已写入 FunctionConfig 和会话状态;"
|
|
"0x42A 仅编译开关信号,档位回读仅在 dry-run 中模拟。"
|
|
),
|
|
}
|
|
)
|
|
key = (str(action.get("target")), str(action.get("function")))
|
|
previous_state = seen.get(key)
|
|
if previous_state is not None and previous_state != action.get("state"):
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "CONFLICTING_OPERATIONS",
|
|
"path": path,
|
|
"message": "同一个座椅功能不能在一份配置中同时打开和关闭。",
|
|
}
|
|
)
|
|
seen[key] = str(action.get("state"))
|
|
if action.get("state") == "on":
|
|
active_functions.setdefault(str(action.get("target")), set()).add(
|
|
str(action.get("function"))
|
|
)
|
|
|
|
if has_motion:
|
|
issues.append(
|
|
{
|
|
"severity": "warning",
|
|
"code": "SYNTHETIC_MOTION_CAN",
|
|
"path": "platform.reference_vehicle.seat_motion",
|
|
"message": "座椅位置控制和反馈 CAN 矩阵完全为演示虚构,仅用于验证模型与配置链路。",
|
|
}
|
|
)
|
|
for target, functions in active_functions.items():
|
|
if functions == {"ventilation", "heating"}:
|
|
issues.append(
|
|
{
|
|
"severity": "warning",
|
|
"code": "COMBINED_THERMAL_SIMULATED",
|
|
"path": f"actions.{target}",
|
|
"message": (
|
|
"同一座位的加热与通风组合目标仅用于 dry-run;"
|
|
"实车前必须确认 ECU 是否允许两功能同时开启。"
|
|
),
|
|
}
|
|
)
|
|
|
|
if (
|
|
function_config is None
|
|
and not parsed.get("deferred_requests")
|
|
and not parsed.get("ambiguities")
|
|
and not parsed.get("preset_actions")
|
|
):
|
|
issues.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "NO_EXECUTABLE_OPERATION",
|
|
"path": "spec.operations",
|
|
"message": "没有生成可执行的座椅操作。",
|
|
}
|
|
)
|
|
|
|
issues.append(
|
|
{
|
|
"severity": "warning",
|
|
"code": "VIRTUAL_EXECUTION_ONLY",
|
|
"path": "platform.reference_vehicle",
|
|
"message": "CAN 元数据不完整,当前仅允许虚拟执行,禁止实车下发。",
|
|
}
|
|
)
|
|
return issues
|