feat: 完善 N7 智能座舱交互与控制链路

This commit is contained in:
vee
2026-09-07 15:34:59 +08:00
parent 32d179143f
commit c96970f068
100 changed files with 30088 additions and 1174 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -384,6 +384,10 @@ class ModeProfile:
)
def public_dict(self) -> dict[str, Any]:
is_cyclic = self.timeline is not None or any(
operation.capability == "seat.start_dynamic_mode"
for operation in self.operations
)
result = {
"id": self.id,
"name": self.name,
@@ -396,7 +400,7 @@ class ModeProfile:
"common": self.common,
"vehicle_ids": list(self.vehicle_ids),
},
"behavior": "cyclic" if self.timeline is not None else "static",
"behavior": "cyclic" if is_cyclic else "static",
"origin": "user" if self.user_defined else "preset",
"deletable": self.user_defined,
"instruction": self.instruction,
@@ -48,12 +48,13 @@ DEFAULT_DRIVER_NATIVE_SEAT_CAPABILITIES = {
"seat.set_heating": "seat.set_driver_heating_native",
}
EXPLICIT_DRIVER_SEAT_PATTERN = re.compile(
r"主驾|驾驶位|司机位|左前(?:的)?(?:座椅|座位)|"
r"主驾|驾驶位|驾驶员|司机位|左前(?:的)?(?:座椅|座位)|"
r"seat\s+(?:driver|front-left)",
re.IGNORECASE,
)
EXPLICIT_NON_DRIVER_SEAT_PATTERN = re.compile(
r"副驾|乘客|右前|后排|前排|所有座椅|全部座椅|"
r"副驾|乘客|乘员|右前|后排|后座|二排|第二排|中排|第三排|"
r"前排|前舱|后舱|左侧|右侧|左边|右边|所有座椅|全部座椅|全车座椅|"
r"seat\s+(?:passenger|front-right)",
re.IGNORECASE,
)
@@ -277,7 +278,18 @@ class ModelPlanAdapter:
"""Normalize omitted model targets to driver-console CH1 defaults."""
request = deepcopy(raw_request)
# Repository-backed mode and speech payloads already carry a validated
# structured target. An empty utterance is not authorization to
# reinterpret that persisted scope as a driver-console default.
if not utterance.strip():
return request
capability = str(request.get("capability", ""))
current_target = str(request.get("target_scope") or "").strip().lower()
if current_target not in {"", "unknown", "unspecified", "default"}:
# The plan guard has already tied this explicit scope to the
# server-owned utterance. Rewriting it afterwards is a TOCTOU
# semantic change and can widen a local request to another zone.
return request
native_seat_capability = DEFAULT_DRIVER_NATIVE_SEAT_CAPABILITIES.get(
capability
)
@@ -13,6 +13,7 @@ from cognitive_agent.application.chinese_values import (
normalize_chinese_unit_values,
parse_percentage_value,
)
from cognitive_agent.application.semantic_ir import resolve_explicit_seat_scopes
WINDOW_TARGETS = {
"全部": "all",
@@ -20,7 +21,19 @@ WINDOW_TARGETS = {
"四个": "all",
"全车": "all",
"前排": "front",
"前座": "front",
"后排": "rear",
"后座": "rear",
"二排": "rear",
"第二排": "rear",
"后排左侧": "rear_left",
"后座左侧": "rear_left",
"二排左侧": "rear_left",
"第二排左侧": "rear_left",
"后排右侧": "rear_right",
"后座右侧": "rear_right",
"二排右侧": "rear_right",
"第二排右侧": "rear_right",
"左前": "front_left",
"前左": "front_left",
"右前": "front_right",
@@ -54,8 +67,17 @@ OUTLET_TARGETS = {
LIGHT_TARGETS = {
"全部": "all",
"所有": "all",
"前排": "front",
"后排": "rear",
"左前": "front_left",
"右前": "front_right",
"主驾驶": "front_left",
"驾驶位": "front_left",
"司机位": "front_left",
"主驾": "front_left",
"副驾驶": "front_right",
"乘客位": "front_right",
"副驾": "front_right",
"中排左旋钮": "middle_left_rotary",
"中排右旋钮": "middle_right_rotary",
"中排左": "middle_left",
@@ -63,6 +85,13 @@ LIGHT_TARGETS = {
"左后": "rear_left",
"右后": "rear_right",
}
_EXPLICIT_LOCATION_HINT = re.compile(
r"全部|所有|四个|全车|整车|前排|前座|后排|后座|二排|第二排|"
r"中排|第三排|主驾|主驾驶|驾驶位|驾驶员|司机位|副驾|副驾驶|"
r"乘客位|乘员|左前|前左|右前|前右|左后|后左|右后|后右|"
r"左侧|右侧|左边|右边"
)
_WHOLE_VEHICLE_HINT = re.compile(r"全部|所有|四个|全车|整车")
CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
OUTLET_MODE_DISABLE_PATTERN = re.compile(
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
@@ -100,6 +129,150 @@ def _feature_context(text: str, feature: str, radius: int = 24) -> str:
return f"{prefix}{match.group(0)}{suffix}"
def _allows_vehicle_wide_target(text: str) -> bool:
"""Allow a whole-vehicle action only when no narrower scope was stated."""
return bool(_WHOLE_VEHICLE_HINT.search(text)) or not bool(
_EXPLICIT_LOCATION_HINT.search(text)
)
_CLIMATE_TEMPERATURE = re.compile(
r"(?:空调(?:温度)?|温度)(?:都)?"
r"(?:并且|并|然后|同时|再)?"
r"(?:调整到|调整为|调整至|调整成|调节到|调节为|调节至|"
r"调到|调成|调为|调至|设到|设为|设成|设置到|设置为|设置至|到|为)?"
r"(1[89]|2\d|3[0-2])(?:摄氏)?(?:℃|度)?"
r"(?![\d.]|小时|分钟|秒|天|档|级)"
)
_CLIMATE_NUMERIC_REQUEST = re.compile(
r"(?:空调(?:温度)?|温度)(?:都)?"
r"(?:并且|并|然后|同时|再)?"
r"(?:调整到|调整为|调整至|调整成|调节到|调节为|调节至|"
r"调到|调成|调为|调至|设到|设为|设成|设置到|设置为|设置至|到|为)?"
r"(?P<value>\d+(?:\.\d+)?)"
r"(?P<unit>摄氏度|摄氏℃|℃|度|小时|分钟|秒|天|档|级)?"
)
_UNSUPPORTED_EXACT_HVAC_ZONE = re.compile(
r"第三排|三排|前舱|后舱|左前|右前|前左|前右|左后|右后|后左|后右|"
r"后排左|后排右|后座左|后座右|二排左|二排右|第二排左|第二排右|"
r"左侧|右侧|左边|右边|左右|两侧"
)
def _climate_temperature_ambiguities(text: str) -> list[dict[str, Any]]:
"""Keep malformed or multiply-valued HVAC numbers from being half-saved."""
compact = re.sub(r"\s+", "", normalize_chinese_unit_values(text))
ambiguities: list[dict[str, Any]] = []
for clause in (item for item in CLAUSE_SEPARATOR.split(compact) if item):
request = _CLIMATE_NUMERIC_REQUEST.search(clause)
if request is None:
continue
value = request.group("value")
unit = request.group("unit") or ""
request_tail = clause[request.start() :]
degree_values = re.findall(
r"(?<!\d)(\d+(?:\.\d+)?)(?:摄氏)?(?:℃|度)",
request_tail,
)
if len(degree_values) > 1:
ambiguities.append(
{
"field": "climate.temperature_c",
"question": "同一空调温度设置包含多个目标值,请只保留一个温度。",
"reason": "native_can_temperature_multiple_values",
}
)
elif "." in value:
ambiguities.append(
{
"field": "climate.temperature_c",
"question": "当前空调控制仅支持整数温度,请改为18到32度之间的整数。",
"reason": "native_can_temperature_fraction_unsupported",
}
)
elif unit in {"小时", "分钟", "秒", "天", "档", "级"}:
ambiguities.append(
{
"field": "climate.temperature_c",
"question": "空调数字的单位不是温度,无法安全生成温度动作,请明确写成例如24度。",
"reason": "native_can_temperature_invalid_unit",
}
)
elif not 18 <= int(value) <= 32:
ambiguities.append(
{
"field": "climate.temperature_c",
"question": "空调目标温度超出当前支持的18到32度范围。",
"reason": "native_can_temperature_out_of_range",
}
)
if _UNSUPPORTED_EXACT_HVAC_ZONE.search(clause):
ambiguities.append(
{
"field": "climate.target",
"question": "该空调温区不能安全映射,请明确指定主驾、副驾、前排、后排或全车。",
"reason": "native_can_temperature_target_unsupported",
}
)
return ambiguities
def _parse_climate_temperature_actions(text: str) -> list[dict[str, Any]]:
"""Bind every HVAC temperature value to the target in its own clause."""
actions: list[dict[str, Any]] = []
for clause in (item for item in CLAUSE_SEPARATOR.split(text) if item):
matches = list(_CLIMATE_TEMPERATURE.finditer(clause))
explicit_degree_values = re.findall(
r"(?<!\d)(\d+(?:\.\d+)?)(?:摄氏)?(?:℃|度)",
clause,
)
# Multiple values without a clause boundary cannot be paired safely.
if len(matches) != 1 or len(explicit_degree_values) > 1:
continue
temperature = int(matches[0][1])
if _UNSUPPORTED_EXACT_HVAC_ZONE.search(clause):
targets: tuple[str, ...] = ()
elif re.search(r"全车|整车", clause):
targets = ("driver", "passenger", "rear")
elif (
re.search(r"前排|前座|主副驾", clause)
or ("主驾" in clause and "副驾" in clause)
):
targets = ("driver", "passenger")
elif re.search(r"副驾|副驾驶|乘客|乘员", clause):
targets = ("passenger",)
elif re.search(r"后排|后座|二排|第二排", clause):
targets = ("rear",)
elif re.search(r"主驾|主驾驶|驾驶位|驾驶员|司机位", clause):
targets = ("driver",)
elif _EXPLICIT_LOCATION_HINT.search(clause):
targets = ()
else:
targets = ("driver",)
explicit_target = bool(_EXPLICIT_LOCATION_HINT.search(clause))
reason = (
"用户指定了空调温区和温度。"
if explicit_target
else "用户未指定空调温区,按驾驶台默认选择主驾温区。"
)
actions.extend(
_action(
"climate",
"climate.set_temperature",
target,
{"temperature_c": temperature},
reason,
)
for target in targets
)
return actions
def _grade_alias(text: str, *, include_numeric: bool = True) -> str | None:
"""Normalize common low/middle/high wording without guessing a value."""
@@ -163,32 +336,44 @@ CH1_DEFAULT_TARGETS = {
_EXPLICIT_TARGET_PATTERNS = {
"window": re.compile(
r"(?:全部|所有|四个|全车|前排|后排|左前|前左|右前|前右|左后|后左|右后|后右|"
r"主驾驶|驾驶位|司机位|主驾|副驾驶|乘客位|副驾)"
r"(?:全部|所有|四个|全车|前排|前座|后排|后座|二排|第二排|"
r"左前|前左|右前|前右|左后|后左|右后|后右|左侧|右侧|"
r"主驾驶|驾驶位|驾驶员|司机位|主驾|副驾驶|乘客位|乘员|副驾)"
r"(?:的)?(?:车窗|窗)|window\s+(?:all|front|rear|front-left|front-right|rear-left|rear-right)",
re.IGNORECASE,
),
"climate": re.compile(
r"(?:主驾|驾驶位|司机位|副驾|乘客|后排|前排|全车)(?:的)?.{0,4}(?:空调|温度)|"
r"(?:空调|温度).{0,4}(?:主驾|驾驶位|司机位|副驾|乘客|后排|前排|全车)|"
r"(?:主驾|驾驶位|驾驶员|司机位|副驾|乘客|乘员|后排|后座|二排|第二排|"
r"前排|前座|全车|整车|左前|右前|左后|右后|前左|前右|后左|后右|"
r"后排左|后排右|第三排|三排|前舱|后舱|左侧|右侧|左边|右边)"
r"(?:的)?.{0,4}(?:空调|温度)|"
r"(?:空调|温度).{0,4}(?:主驾|驾驶位|驾驶员|司机位|副驾|乘客|乘员|"
r"后排|后座|二排|第二排|前排|前座|全车|整车|左前|右前|"
r"左后|右后|前左|前右|后左|后右|后排左|后排右|第三排|三排|"
r"前舱|后舱|左侧|右侧|左边|右边)|"
r"ac\s+temp\s+(?:driver|passenger|rear)",
re.IGNORECASE,
),
"outlet": re.compile(
r"(?:主驾左|主驾右|副驾左|副驾右)(?:的)?风口|"
r"(?:主驾|主驾驶|驾驶位|驾驶员|司机位|"
r"副驾|副驾驶|乘客位|乘员|前排|后排|后座|二排|"
r"第二排|第三排|左侧|右侧)(?:左|右)?(?:的)?(?:出)?风口|"
r"outlet\s+(?:driver-left|driver-right|passenger-left|passenger-right)",
re.IGNORECASE,
),
"seat": re.compile(
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|驾驶座(?:椅)?|"
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶员|驾驶席|驾驶座(?:椅)?|"
r"司机位|司机座(?:椅)?|左前(?:座|座椅|座位)?|"
r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|"
r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|"
r"副驾(?:驾驶|位)?|乘客(?:位|座|座椅|座位)?|乘员(?:位|座|座椅|座位)?|"
r"右前(?:座|座椅|座位)?|前排|后排|后座|二排|第二排|中排|第三排|"
r"左后|右后|所有座椅|全部座椅|全车座椅|"
r"seat\s+(?:driver|front-left|passenger|front-right)",
re.IGNORECASE,
),
"interior_light": re.compile(
r"(?:全部|所有|左前|右前|中排左|中排右|左后|右后)(?:的)?阅读灯|"
r"(?:全部|所有|前排|后排|后座|二排|第二排|主驾|主驾驶|驾驶位|"
r"驾驶员|司机位|副驾|副驾驶|乘客位|乘员|左前|右前|中排左|"
r"中排右|左后|右后|左侧|右侧)(?:的)?阅读灯|"
r"light\s+reading\s+(?:all|front-left|front-right)",
re.IGNORECASE,
),
@@ -273,6 +458,10 @@ def _window_targets(text: str) -> tuple[list[str], bool]:
return ([target for target in explicit if target not in exclusions], True)
if broad:
return ([broad], True)
if _EXPLICIT_LOCATION_HINT.search(text):
# An explicit but unknown or unsupported location must never inherit
# the driver-window default.
return ([], True)
return (["front_left"], False)
@@ -304,6 +493,19 @@ def _looks_like_seat_request(text: str) -> bool:
def _implicit_driver_seat_request(text: str) -> bool:
return _looks_like_seat_request(text) and not _EXPLICIT_TARGET_PATTERNS["seat"].search(text)
def _seat_target_scope(text: str) -> str | None:
"""Resolve seat words without ever widening an explicit scope."""
scopes = resolve_explicit_seat_scopes(text)
if scopes == {"driver"}:
return "driver"
if scopes == {"passenger"}:
return "passenger"
return "unsupported" if scopes else None
def _action(
domain: str,
capability: str,
@@ -565,13 +767,10 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
clause = f"{clause}{continuation}"
continuation_index += 1
index = continuation_index - 1
if re.search(
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|"
r"驾驶座(?:椅)?|司机位|司机座(?:椅)?|左前座椅",
clause,
):
scope = _seat_target_scope(clause)
if scope == "driver":
inherited_driver_scope = True
elif re.search(r"副驾|副驾驶|乘客|右前", clause):
elif scope is not None:
inherited_driver_scope = False
elif inherited_driver_scope:
clause = f"主驾座椅{clause}"
@@ -592,21 +791,10 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
index += 1
return combined_actions
if re.search(
r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|"
r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|"
r"seat\s+(?:passenger|front-right)",
compact,
re.IGNORECASE,
):
seat_scope = _seat_target_scope(compact)
if seat_scope in {"passenger", "unsupported"}:
return []
explicit_driver = bool(
re.search(
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|"
r"驾驶座(?:椅)?|司机位|司机座(?:椅)?|左前座椅",
compact,
)
)
explicit_driver = seat_scope == "driver"
if not explicit_driver and not _looks_like_seat_request(compact):
return []
default_reason = "用户未指定座椅目标,按驾驶台 CH1 安全默认选择主驾座椅。"
@@ -625,13 +813,20 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
if memory is not None:
slot_token = memory.group("after") or memory.group("before")
slot = {"一": 1, "二": 2, "三": 3}.get(slot_token, slot_token)
memory_capability = (
"seat.recall_driver_memory_native"
if "召回" in compact
else "seat.set_driver_position_native"
)
actions.append(_action(
"seat",
"seat.set_driver_position_native",
memory_capability,
"driver",
{"slot": int(slot)},
(
"用户明确指定了 CH1 主驾座椅记忆位置。"
"用户明确要求召回 CH1 主驾座椅记忆位置。"
if memory_capability == "seat.recall_driver_memory_native"
else "用户明确指定了 CH1 主驾座椅记忆位置。"
if explicit_driver
else default_reason
),
@@ -977,61 +1172,69 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
climate_text = _scoped_text(
compact,
r"空调|温度|风量|风速|内循环|外循环|循环模式|吹面|吹脸|吹脚|除霜|(?:AC|A/C)",
r"空调|压缩机|温度|风量|风速|内循环|外循环|循环模式|吹面|吹脸|吹脚|除霜|(?:AC|A/C)",
)
if (
"空调" in climate_text
or "压缩机" in climate_text
or re.search(r"(?:AC|A/C)", text, re.IGNORECASE)
or re.search(
r"温度|风量|风速|内循环|外循环|循环模式|吹面|吹脸|吹脚|除霜",
climate_text,
)
):
if re.search(r"(?:打开|开启|启动)空调", climate_text):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "on"}, "用户要求打开空调。"))
elif re.search(r"(?:关闭|关掉|关上)空调", climate_text):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "off"}, "用户要求关闭空调。"))
temperature = re.search(
r"(?:空调(?:温度)?|温度)(?:都)?"
r"(?:调整到|调整为|调到|调成|调为|设到|设为|设置为|调至|到|为)?"
r"(1[89]|2\d|3[0-2])(?:摄氏)?(?:℃|度)?(?![\d.])",
compressor_on = re.search(
r"(?:(?:打开|开启|启动)(?:空调)?(?:的)?(?:压缩机|A/?C)|"
r"(?:空调)?(?:的)?(?:压缩机|A/?C)(?:设为|设置为|调为|切换为)?"
r"(?:打开|开启|启动|开|on)(?:状态)?)",
climate_text,
re.IGNORECASE,
)
if temperature:
if (
re.search(r"全车|整车|前排|主副驾", climate_text)
or ("主驾" in climate_text and "副驾" in climate_text)
):
temperature_targets = ("driver", "passenger")
elif re.search(r"副驾|副驾驶|乘客", climate_text):
temperature_targets = ("passenger",)
elif "后排" in climate_text:
temperature_targets = ("rear",)
else:
temperature_targets = ("driver",)
explicit_temperature_target = bool(
re.search(
r"主驾|副驾|副驾驶|乘客|后排|前排|全车|整车|主副驾",
climate_text,
)
)
reason = (
"用户指定了空调温区和温度。"
if explicit_temperature_target
else "用户未指定空调温区,按驾驶台默认选择主驾温区。"
)
actions.extend(
compressor_off = re.search(
r"(?:(?:关闭|关掉|关上|停止)(?:空调)?(?:的)?(?:压缩机|A/?C)|"
r"(?:空调)?(?:的)?(?:压缩机|A/?C)(?:设为|设置为|调为|切换为)?"
r"(?:关闭|关掉|关上|停止|关|off)(?:状态)?)",
climate_text,
re.IGNORECASE,
)
compressor_text = _feature_context(
climate_text,
r"压缩机|A/?C",
)
compressor_vehicle_wide = _allows_vehicle_wide_target(compressor_text)
if compressor_on and not compressor_off and compressor_vehicle_wide:
actions.append(
_action(
"climate",
"climate.set_temperature",
target,
{"temperature_c": int(temperature[1])},
reason,
"climate.set_ac",
"vehicle",
{"state": "on"},
"用户要求开启空调压缩机。",
)
for target in temperature_targets
)
elif compressor_off and not compressor_on and compressor_vehicle_wide:
actions.append(
_action(
"climate",
"climate.set_ac",
"vehicle",
{"state": "off"},
"用户要求关闭空调压缩机。",
)
)
power_text = _feature_context(climate_text, r"空调(?!的?压缩机)")
if re.search(
r"(?:打开|开启|启动)空调(?!的?压缩机)", climate_text
) and _allows_vehicle_wide_target(power_text):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "on"}, "用户要求打开空调。"))
elif re.search(
r"(?:关闭|关掉|关上)空调(?!的?压缩机)", climate_text
) and _allows_vehicle_wide_target(power_text):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "off"}, "用户要求关闭空调。"))
actions.extend(_parse_climate_temperature_actions(climate_text))
fan = re.search(
r"(?:风量|风速)(?:调整到|调整为|调到|调为|设为|设置为|到)?"
@@ -1044,17 +1247,20 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
fan_level = {"low": 1, "medium": 4, "high": 7}.get(
fan_grade or ""
)
if fan_level is not None:
fan_text = _feature_context(climate_text, r"风量|风速")
if fan_level is not None and _allows_vehicle_wide_target(fan_text):
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": fan_level}, "用户指定了空调风量。"))
if re.search(r"自动(?:控制|模式)", climate_text):
control_mode_text = _feature_context(climate_text, r"自动(?:控制|模式)|手动(?:控制|模式)")
if re.search(r"自动(?:控制|模式)", climate_text) and _allows_vehicle_wide_target(control_mode_text):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "auto"}, "用户指定空调自动控制。"))
elif re.search(r"手动(?:控制|模式)", climate_text):
elif re.search(r"手动(?:控制|模式)", climate_text) and _allows_vehicle_wide_target(control_mode_text):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "manual"}, "用户指定空调手动控制。"))
if re.search(r"内循环", climate_text):
cycle_text = _feature_context(climate_text, r"内循环|外循环")
if re.search(r"内循环", climate_text) and _allows_vehicle_wide_target(cycle_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "internal"}, "用户指定空调内循环。"))
elif re.search(r"外循环", climate_text):
elif re.search(r"外循环", climate_text) and _allows_vehicle_wide_target(cycle_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
# A phrase such as “打开副驾左吹面出风口” controls one motorized
@@ -1086,7 +1292,12 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
if has_feet
else None
)
if outlet_mode:
outlet_mode_text = _feature_context(
climate_mode_text,
r"吹面|吹脸|吹脚|除霜",
radius=48,
)
if outlet_mode and _allows_vehicle_wide_target(outlet_mode_text):
actions.append(
_action(
"climate",
@@ -1102,9 +1313,9 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
door_text = _scoped_text(compact, r"门锁|车门|后门|锁车|解锁车辆")
rear_lock_request = bool(
re.search(
r"(?:后门|后排车门|后排门锁|二排车门|二排门锁)", door_text
r"(?:后门|后排车门|后座车门|后排门锁|二排车门|二排门锁)", door_text
)
)
) and not bool(re.search(r"左后|右后|后排左|后排右|后座左|后座右", door_text))
if rear_lock_request:
state = (
"unlock"
@@ -1124,7 +1335,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
door_text,
)
)
if vehicle_lock_request:
if vehicle_lock_request and _allows_vehicle_wide_target(door_text):
state = (
"unlock"
if re.search(r"解锁|开锁", door_text)
@@ -1136,7 +1347,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
actions.append(_action("door", "door.set_vehicle_lock", "vehicle", {"state": state}, "用户指定了整车门锁状态。"))
shade_text = _scoped_text(compact, r"遮阳帘|天幕帘")
if shade_text:
if shade_text and _allows_vehicle_wide_target(shade_text):
position = re.search(
r"(?:位置|开度|调整到|调到|设为|设置为|开到|打开(?:到)?|开启(?:到)?)(\d{1,3})%",
shade_text,
@@ -1158,13 +1369,18 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
for label, capability in (("室内灯", "interior_light.set_indoor_power"), ("车内灯", "interior_light.set_indoor_power")):
if label in indoor_text:
state = "off" if re.search(r"关闭|关掉|关上", indoor_text) else "on" if re.search(r"打开|开启", indoor_text) else None
if state:
if state and _allows_vehicle_wide_target(indoor_text):
actions.append(_action("interior_light", capability, "vehicle", {"state": state}, "用户指定了车内灯状态。"))
break
reading_text = _scoped_text(compact, r"阅读灯")
if reading_text:
target = next((value for label, value in LIGHT_TARGETS.items() if label in reading_text), "all")
target = next(
(value for label, value in LIGHT_TARGETS.items() if label in reading_text),
None,
)
if target is None:
target = "unsupported" if _EXPLICIT_LOCATION_HINT.search(reading_text) else "all"
state = "off" if re.search(r"关闭|关掉|关上", reading_text) else "on" if re.search(r"打开|开启", reading_text) else None
if state and target == "all":
actions.append(_action("interior_light", "interior_light.set_reading_power", target, {"state": state}, "用户指定了阅读灯区域和状态。"))
@@ -1174,7 +1390,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
r"氛围灯",
r"^(?:亮度|强度)(?:调整|调节|调|设置|设)?",
)
if ambient_text:
if ambient_text and _allows_vehicle_wide_target(ambient_text):
state = (
"off"
if re.search(r"关闭|关掉|熄灭", ambient_text)
@@ -1218,6 +1434,49 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
if brightness_level is not None:
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": brightness_level}, "用户指定了已验证的氛围灯亮度档位。"))
# “全部灯光关闭” is a common scene-building phrase. It names the
# supported cabin-light group rather than one individual lamp, so expand
# only the explicit all+off form into the three verified lighting
# capabilities. Other generic “灯光” requests stay unresolved instead of
# being widened to exterior lighting or an arbitrary single lamp.
generic_light_clauses = [
clause
for clause in CLAUSE_SEPARATOR.split(compact)
if "灯光" in clause
and not re.search(r"室内灯|车内灯|阅读灯|氛围灯", clause)
]
for light_clause in generic_light_clauses:
if not (
re.search(r"全部|所有|全车", light_clause)
and re.search(r"关闭|关掉|关上|熄灭", light_clause)
):
continue
actions.extend(
(
_action(
"interior_light",
"interior_light.set_indoor_power",
"vehicle",
{"state": "off"},
"用户明确要求关闭全部受支持的座舱灯光。",
),
_action(
"interior_light",
"interior_light.set_reading_power",
"all",
{"state": "off"},
"用户明确要求关闭全部受支持的座舱灯光。",
),
_action(
"ambient_light",
"ambient_light.set_power",
"vehicle",
{"state": "off"},
"用户明确要求关闭全部受支持的座舱灯光。",
),
)
)
outlet_text = _scoped_text(compact, r"风口")
outlet_target = next(
(
@@ -1269,7 +1528,12 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
"用户指定了已标定风口的双轴原始坐标。",
)
)
elif outlet_text and outlet_position and all(int(value) <= 100 for value in outlet_position.groups()):
elif (
outlet_text
and outlet_position
and all(int(value) <= 100 for value in outlet_position.groups())
and (outlet_target is not None or not _EXPLICIT_LOCATION_HINT.search(outlet_text))
):
target = outlet_target or "driver_left"
reason = (
"用户指定了已标定风口的双轴百分比位置。"
@@ -1432,6 +1696,7 @@ def merge_native_can_intent(
relative_actions = list(relative_actions or [])
relative_ambiguities = list(relative_ambiguities or [])
relative_ambiguities.extend(_climate_temperature_ambiguities(utterance))
relative_claimed_controls = set(relative_claimed_controls or set())
def is_claimed(candidate: dict[str, Any]) -> bool:
@@ -34,10 +34,19 @@ CHINESE_DIGITS = {
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10,
}
WINDOW_TARGET_LABELS = {
"后排左侧": ("rear_left",), "后座左侧": ("rear_left",),
"二排左侧": ("rear_left",), "第二排左侧": ("rear_left",),
"后排右侧": ("rear_right",), "后座右侧": ("rear_right",),
"二排右侧": ("rear_right",), "第二排右侧": ("rear_right",),
"左前": ("front_left",), "前左": ("front_left",),
"右前": ("front_right",), "前右": ("front_right",),
"左后": ("rear_left",), "后左": ("rear_left",),
"右后": ("rear_right",), "后右": ("rear_right",),
"主驾驶": ("front_left",), "驾驶位": ("front_left",),
"驾驶员": ("front_left",), "司机位": ("front_left",),
"主驾": ("front_left",), "副驾驶": ("front_right",),
"乘客位": ("front_right",), "乘员": ("front_right",),
"副驾": ("front_right",),
"前排": ("front_left", "front_right"),
"后排": ("rear_left", "rear_right"),
"全部": ("front_left", "front_right", "rear_left", "rear_right"),
@@ -45,6 +54,16 @@ WINDOW_TARGET_LABELS = {
"四个": ("front_left", "front_right", "rear_left", "rear_right"),
"全车": ("front_left", "front_right", "rear_left", "rear_right"),
}
NARROW_LOCATION = re.compile(
r"主驾|主驾驶|驾驶位|驾驶员|司机位|副驾|副驾驶|乘客位|乘员|"
r"前排|前座|后排|后座|二排|第二排|中排|第三排|三排|前舱|后舱|"
r"左前|前左|右前|前右|左后|后左|右后|后右|左侧|右侧|左边|右边"
)
UNSUPPORTED_SINGLE_HVAC_ZONE = re.compile(
r"第三排|三排|前舱|后舱|左前|前左|右前|前右|左后|后左|右后|后右|"
r"后排左|后排右|后座左|后座右|二排左|二排右|第二排左|第二排右|"
r"左侧|右侧|左边|右边"
)
@dataclass(frozen=True)
@@ -296,14 +315,29 @@ class NativeCanRelativeResolver:
if operation is None:
return
direction, match = operation
target = "passenger" if re.search(r"副驾|乘客位", clause) else "driver"
if UNSUPPORTED_SINGLE_HVAC_ZONE.search(clause) or re.search(r"前排|前座", clause):
self._ambiguity(
resolution,
field_name="climate.target",
question="该空调温区没有可保持原目标的相对控制绑定,请指定主驾、副驾或后排。",
reason="native_can_relative_target_unsupported",
)
return
target = (
"passenger"
if re.search(r"副驾|副驾驶|乘客位|乘员", clause)
else "rear"
if re.search(r"后排|后座|二排|第二排", clause)
else "driver"
)
self._claim(resolution, "climate.set_temperature", target)
zone = state.get("climate", {}).get("zones", {}).get(target, {})
target_label = {"passenger": "副驾", "rear": "后排"}.get(target, "主驾")
current = self._value(
resolution,
{"value": zone.get("temperature_c"), "feedback_source": state.get("climate", {}).get("feedback_source")},
field_name=f"climate.{target}_temperature_c",
label=f"{'副驾' if target == 'passenger' else '主驾'}温度",
label=f"{target_label}温度",
require_physical=self._require_physical(state),
)
if current is None:
@@ -319,6 +353,14 @@ class NativeCanRelativeResolver:
operation = self._operation(clause, GENERIC_INCREASE, GENERIC_DECREASE)
if operation is None:
return
if NARROW_LOCATION.search(clause):
self._ambiguity(
resolution,
field_name="climate.target",
question="风量物理控制仅支持全车目标,不会把局部温区请求扩大到全车。",
reason="native_can_relative_target_unsupported",
)
return
direction, match = operation
self._claim(resolution, "climate.set_fan_level", "vehicle")
climate = state.get("climate", {})
@@ -336,6 +378,14 @@ class NativeCanRelativeResolver:
operation = self._operation(clause, AMBIENT_INCREASE, AMBIENT_DECREASE)
if operation is None:
return
if NARROW_LOCATION.search(clause):
self._ambiguity(
resolution,
field_name="ambient_light.target",
question="氛围灯亮度物理控制仅支持全车目标,不会扩大局部请求。",
reason="native_can_relative_target_unsupported",
)
return
direction, match = operation
capability = "ambient_light.set_brightness_level"
self._claim(resolution, capability, "vehicle")
@@ -358,6 +408,14 @@ class NativeCanRelativeResolver:
operation = self._operation(clause, OPENING_INCREASE, OPENING_DECREASE)
if operation is None:
return
if NARROW_LOCATION.search(clause):
self._ambiguity(
resolution,
field_name="roof.target",
question="遮阳帘物理控制仅支持整帘目标,不会扩大局部请求。",
reason="native_can_relative_target_unsupported",
)
return
direction, match = operation
self._claim(resolution, "roof.set_shade_position", "vehicle")
roof = state.get("roof", {})
@@ -374,7 +432,9 @@ class NativeCanRelativeResolver:
@staticmethod
def _window_targets(clause: str) -> tuple[str, ...]:
individual = []
for label, targets in tuple(WINDOW_TARGET_LABELS.items())[:8]:
for label, targets in WINDOW_TARGET_LABELS.items():
if len(targets) != 1:
continue
if label in clause and targets[0] not in individual:
individual.append(targets[0])
if individual:
@@ -6,6 +6,7 @@ import hashlib
import json
import os
import time
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
@@ -73,9 +74,65 @@ DRIVER_SEAT_CAPABILITIES = frozenset(
}
)
DRIVER_SEAT_TARGETS = frozenset({"driver", "front_left"})
NATIVE_TARGET_SCOPES: dict[str, frozenset[str]] = {
"ambient_light.set_brightness_level": frozenset({"vehicle"}),
"ambient_light.set_color_index": frozenset({"vehicle"}),
"ambient_light.set_power": frozenset({"vehicle"}),
"climate.set_ac": frozenset({"vehicle"}),
"climate.set_control_mode": frozenset({"vehicle"}),
"climate.set_cycle_mode": frozenset({"vehicle"}),
"climate.set_fan_level": frozenset({"vehicle"}),
"climate.set_outlet_mode": frozenset({"vehicle"}),
"climate.set_power": frozenset({"vehicle"}),
"climate.set_temperature": frozenset(
{"driver", "passenger", "rear", "rear_row", "vehicle", "front_row"}
),
"door.set_rear_lock": frozenset({"rear"}),
"interior_light.set_indoor_power": frozenset({"vehicle"}),
"interior_light.set_reading_power": frozenset({"all"}),
"outlet.set_calibrated_position": frozenset(
{"driver_left", "driver_right", "passenger_left", "passenger_right"}
),
"outlet.set_position_percent": frozenset(
{"driver_left", "driver_right", "passenger_left", "passenger_right"}
),
"outlet.set_switch_state": frozenset(
{"driver_left", "driver_right", "passenger_left", "passenger_right"}
),
"roof.set_shade_motion": frozenset({"vehicle"}),
"roof.set_shade_position": frozenset({"vehicle"}),
"seat.recall_driver_memory_native": DRIVER_SEAT_TARGETS,
"seat.set_driver_axis_native": DRIVER_SEAT_TARGETS,
"seat.set_driver_heating_native": DRIVER_SEAT_TARGETS,
"seat.set_driver_massage_native": DRIVER_SEAT_TARGETS,
"seat.set_driver_position_native": DRIVER_SEAT_TARGETS,
"seat.set_driver_ventilation_native": DRIVER_SEAT_TARGETS,
"window.set_motion": frozenset(
{
"all",
"front",
"rear",
"front_left",
"front_right",
"rear_left",
"rear_right",
}
),
"window.set_position": frozenset(
{"front_left", "front_right", "rear_left", "rear_right"}
),
}
UNSUPPORTED_CH1_DOOR_LOCK_CAPABILITIES = frozenset(
{"door.set_vehicle_lock"}
)
UNVERIFIED_NATIVE_CAPABILITIES = frozenset(
{
# The current generated CH1 rule aliases this to HVAC power bit 28.
# The source matrix defines a distinct A/C request at bit 8 with 0x0DB
# feedback, but that path has not completed vehicle HIL verification.
"climate.set_ac",
}
)
def _unsupported_ch1_door_lock_message(capability: str) -> str:
@@ -117,19 +174,24 @@ class NativeCanRuntime:
@property
def supported_capabilities(self) -> frozenset[str]:
return CAN1_SUPPORTED_CAPABILITIES
return CAN1_SUPPORTED_CAPABILITIES - UNVERIFIED_NATIVE_CAPABILITIES
def target_scopes_for(self, capability: str) -> frozenset[str]:
"""Return only targets the byte-level CH1 compiler can preserve exactly."""
if capability not in self.supported_capabilities:
return frozenset()
return NATIVE_TARGET_SCOPES.get(capability, frozenset())
def supports(self, action: dict[str, Any]) -> bool:
capability = str(action.get("capability"))
if capability not in self.supported_capabilities:
return False
if capability in DRIVER_SEAT_CAPABILITIES:
target = str(action.get("target", action.get("target_scope", "")))
return target in DRIVER_SEAT_TARGETS
if capability == "interior_light.set_reading_power":
target = str(action.get("target", action.get("target_scope", "")))
return target == "all"
return True
raw_target = action.get("target", action.get("target_scope", ""))
if isinstance(raw_target, Mapping):
raw_target = raw_target.get("zone", raw_target.get("instance", ""))
target = str(raw_target)
return target in self.target_scopes_for(capability)
def build_function_config(
self,
@@ -150,10 +212,13 @@ class NativeCanRuntime:
f"CAN_DEV does not implement {capability_name}"
)
capability = self.capabilities[capability_name]
target = str(action.get("target", action.get("target_scope", "vehicle")))
if target not in capability.get("target_scopes", []):
raw_target = action.get("target", action.get("target_scope", "vehicle"))
if isinstance(raw_target, Mapping):
raw_target = raw_target.get("zone", raw_target.get("instance", ""))
target = str(raw_target)
if target not in self.target_scopes_for(capability_name):
raise NativeCanCompileError(
f"{capability_name} does not support target {target}"
f"{capability_name} has no exact CH1 binding for target {target}"
)
suffix = capability_name.split(".", 1)[1]
operation_id = f"{target}_{suffix}_{index}"[:63]
@@ -477,6 +542,10 @@ class NativeCanRuntime:
def _frame_specs(self, operation: dict[str, Any]) -> list[NativeFrameSpec]:
capability = str(operation["capability"])
if capability in UNVERIFIED_NATIVE_CAPABILITIES:
raise NativeCanCompileError(
f"{capability} has no HIL-verified physical CH1 binding"
)
if capability in UNSUPPORTED_CH1_DOOR_LOCK_CAPABILITIES:
raise NativeCanCompileError(
_unsupported_ch1_door_lock_message(capability)
@@ -498,7 +567,7 @@ class NativeCanRuntime:
)
]
if capability in {"climate.set_power", "climate.set_ac"}:
if capability == "climate.set_power":
state = self._enum(parameters, "state", {"on": 1, "off": 2})
payload = bytearray(8)
_set_bits(payload, 28, 2, state)
+577 -52
View File
@@ -38,6 +38,9 @@ from seat_domain import ( # noqa: E402
validate_semantics,
)
from cognitive_agent.application.assistant_plan_guard import ( # noqa: E402
guard_assistant_plan,
)
from cognitive_agent.application.capability_registry import CapabilityRegistry # noqa: E402
from cognitive_agent.application.conversation_state import ConversationStateStore # noqa: E402
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402
@@ -68,6 +71,7 @@ from cognitive_agent.application.native_can_relative import ( # noqa: E402
)
from cognitive_agent.application.semantic_ir import ( # noqa: E402
apply_language_safety_guards,
resolve_explicit_seat_scopes,
)
from cognitive_agent.application.native_can_feedback import ( # noqa: E402
VehicleSafetyPreflightError,
@@ -79,6 +83,7 @@ from cognitive_agent.application.native_can_feedback import ( # noqa: E402
from cognitive_agent.application.native_can_runtime import ( # noqa: E402
NativeCanExecutor,
NativeCanRuntime,
UNVERIFIED_NATIVE_CAPABILITIES,
)
from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402
from cognitive_agent.application.vehicle_profile import ( # noqa: E402
@@ -134,12 +139,12 @@ MODE_EDITOR_REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]{6,100}$")
SEAT_SCOPE_PATTERN = re.compile(r"\bseats?\b|座椅|坐椅", re.IGNORECASE)
DRIVER_SEAT_SCOPE_PATTERN = re.compile(
r"\bdriver\b|\bfront[-_ ]?left\b|\bleft[-_ ]?front\b|"
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|驾驶座(?:椅)?|"
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶员|驾驶席|驾驶座(?:椅)?|"
r"司机位|司机座(?:椅)?|左前",
re.IGNORECASE,
)
PASSENGER_SEAT_SCOPE_PATTERN = re.compile(
r"\bpassenger\b|\bfront[-_ ]?right\b|\bright[-_ ]?front\b|副驾|乘客|右前",
r"\bpassenger\b|\bfront[-_ ]?right\b|\bright[-_ ]?front\b|副驾|乘客|乘员|右前",
re.IGNORECASE,
)
WIRED_CAN_TRANSPORT = "wired_can"
@@ -258,10 +263,38 @@ SAVE_MODE_PROFILE_NAME = re.compile(
r"(?P<name>[^,。,.!!\s]{1,64}?模式)"
)
CREATE_MODE_PROFILE_NAME = re.compile(
r"^(?:请(?:帮我)?|帮我)?(?:构建|创建|新建|新增|生成|添加)"
r"(?:一个|个|一套|套)?"
r"(?P<name>[^,。,.!!\s]{1,64}?模式)"
r"(?:配置)?(?:并(?:添加|加入)(?:到)?功能模式(?:栏)?)?[。.!!]?$"
r"^\s*(?:(?:请|麻烦)\s*(?:帮我|帮忙|给我)?\s*|"
r"(?:帮我|帮忙|给我)\s*)?"
r"(?:构建|创建|新建|新增|生成|添加)\s*(?:一下)?\s*"
r"(?:一个|个|一套|套)?\s*"
r"(?:名为|叫做|叫)?\s*[“\"'「『]?"
r"(?P<name>[^,。,.!!::\s“”\"'「」『』]{1,64}?模式)"
r"[”\"'」』]?"
r"\s*(?:配置)?(?:并(?:添加|加入)(?:到)?功能模式(?:栏)?)?"
r"(?:[,,::]\s*(?P<instruction>[\s\S]{1,800}))?[。.!!]?\s*$"
)
# ``、`` is used both as a command separator (``车窗关闭、空调24度``)
# and as an enumeration separator (``主驾、副驾车窗关闭``). Treat it as a
# boundary only when both sides already look like self-contained controls;
# otherwise keep it intact for the target/feature parsers below.
CONTROL_CLAUSE_SUBJECT_PATTERN = re.compile(
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|滑轨|"
r"通风|加热|按摩|零重力|半躺|女王副驾|休闲躺椅|"
r"遮阳帘|天幕帘|天幕|车窗|窗户|"
r"灯光|室内灯|车内灯|阅读灯|氛围灯|"
r"空调|压缩机|温度|风量|风速|内循环|外循环|"
r"车门|门锁|后备箱|前大灯|近光灯|远光灯|雾灯|转向灯|双闪|"
r"出风口"
)
CONTROL_CLAUSE_PREDICATE_PATTERN = re.compile(
r"打开|开启|启动|关闭|关掉|关上|停止|暂停|取消|"
r"调整|调节|调到|调成|调为|调至|设置|设为|设到|切换|"
r"移动|移到|移至|前移|后移|上移|下移|升高|降低|抬起|放倒|"
r"调直|立直|竖直|归零|复位|锁上|上锁|落锁|解锁|"
r"零重力|半躺|最大|最小|最高|最低|最前|最后|到底|"
r"(?:\d+(?:\.\d+)?|[零一二三四五六七八九十百两半]+)"
r"(?:%|%|度|℃|档|级|秒|毫秒)"
)
UPDATE_MODE_PROFILE_PREFIX = re.compile(
r"^(?:请(?:帮我)?|帮我)?(?:修改一下|修改|调整|更改|更新|改一下)"
@@ -430,9 +463,6 @@ class SeatDemoService:
return normalized
def runtime_info(self) -> dict[str, Any]:
catalog = yaml.safe_load(
(self.domain_root / "config" / "capabilities.yaml").read_text(encoding="utf-8")
)
domain_runtime_info = [
runtime.runtime_info() for runtime in self.domain_runtimes.values()
]
@@ -756,6 +786,30 @@ class SeatDemoService:
"request_id"
) != clean_request_id:
raise ValueError("控制指令流来源结果不完整,请重新生成。")
if source_result.get("status") != "succeeded":
raise ValueError("控制指令流没有完整通过校验,请重新生成。")
source_vehicle = source_result.get("vehicle")
source_vehicle_id = (
str(source_vehicle.get("id") or "").strip()
if isinstance(source_vehicle, dict)
else ""
)
if source_vehicle_id and source_vehicle_id != resolved_vehicle_id:
raise ValueError("控制指令流不属于当前车型,请重新生成。")
source_draft = source_result.get("mode_profile_draft")
if isinstance(source_draft, dict):
draft_request_id = str(source_draft.get("source_request_id") or "").strip()
draft_vehicle_id = str(source_draft.get("vehicle_id") or "").strip()
draft_instruction = str(source_draft.get("instruction") or "").strip()
draft_name = str(source_draft.get("name") or "").strip()
if draft_request_id != clean_request_id:
raise ValueError("功能模式草稿与控制指令流不匹配,请重新生成。")
if draft_vehicle_id and draft_vehicle_id != resolved_vehicle_id:
raise ValueError("功能模式草稿不属于当前车型,请重新生成。")
if draft_instruction and draft_instruction != clean_instruction:
raise ValueError("自然语言指令已修改,请重新生成模式预览。")
if draft_name and draft_name != clean_name:
raise ValueError("功能模式名称已修改,请重新生成模式预览。")
recorded_instruction = str(source_result.get("utterance", "")).strip()
if not recorded_instruction:
source_spans = {
@@ -1353,6 +1407,7 @@ class SeatDemoService:
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
assistant_plan: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Validate and execute deterministic controls before hosted parsing."""
@@ -1390,12 +1445,15 @@ class SeatDemoService:
reasoning_effort=reasoning_effort,
control_transport=control_transport,
_on_commands_validated=on_commands_validated,
_assistant_plan=assistant_plan,
)
result.update(
{
"phase": "control_plane_complete",
"preview_request_id": result["request_id"],
"unresolved_clauses": [],
"unresolved_clauses": list(
result.get("unresolved_clauses") or []
),
"supplement_required": False,
}
)
@@ -1444,6 +1502,7 @@ class SeatDemoService:
_local_preview=True,
_allow_empty_control_result=True,
_on_commands_validated=publish_local_commands,
_assistant_plan=assistant_plan,
)
elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
supplement_required = bool(
@@ -1889,6 +1948,7 @@ class SeatDemoService:
_direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
_direct_mode_profile_id: str | None = None,
_speech_understanding: dict[str, Any] | None = None,
_assistant_plan: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -1916,6 +1976,7 @@ class SeatDemoService:
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
assistant_plan=_assistant_plan,
)
if control_transport == WIRELESS_MQTT_TRANSPORT:
# The head unit owns both physical CAN channels in wireless mode.
@@ -1939,6 +2000,7 @@ class SeatDemoService:
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
assistant_plan=_assistant_plan,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -1962,6 +2024,7 @@ class SeatDemoService:
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
assistant_plan=_assistant_plan,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -1984,6 +2047,7 @@ class SeatDemoService:
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
assistant_plan=_assistant_plan,
)
def _process_once(
@@ -2005,6 +2069,7 @@ class SeatDemoService:
direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
direct_mode_profile_id: str | None = None,
speech_understanding: dict[str, Any] | None = None,
assistant_plan: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -2073,6 +2138,7 @@ class SeatDemoService:
simulation_fault,
mode_create_name,
control_transport,
assistant_plan=assistant_plan,
)
request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
@@ -2258,18 +2324,44 @@ class SeatDemoService:
reasoning_effort=reasoning_effort,
local_preview=local_preview,
model_parse_source=model_parse_source,
assistant_plan=assistant_plan,
)
dynamic_mode_stop = self._is_dynamic_mode_stop_request(utterance)
if dynamic_mode_stop:
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
# Reuse the same conservative clause segmentation for the final
# native pass. Without this normalization a Chinese enumeration
# comma between independent controls can leak an early target
# (for example ``副驾``) into a later HVAC clause and can also
# hide otherwise valid window/temperature actions.
segmented_utterance = ",".join(
self._split_intent_clauses(utterance)
)
relative_resolution = (
self.native_can_relative_resolver.resolve(utterance, context)
if self.native_can_relative_resolver.matches(utterance)
self.native_can_relative_resolver.resolve(
segmented_utterance,
context,
)
if self.native_can_relative_resolver.matches(
segmented_utterance
)
else None
)
native_merge_utterance = segmented_utterance
if any(
request.get("capability") in {SAVE_PRESET_CAPABILITY, APPLY_PRESET_CAPABILITY}
for request in parsed.get("model_requests", [])
):
# These registered capabilities currently have one executable
# target (the passenger-seat posture store). Keep that
# adapter-resolved target from being discarded by the native
# parser's general unscoped-seat => driver default. The
# original utterance is still used by the language safety
# guards below.
native_merge_utterance = f"副驾座椅,{utterance}"
parsed = merge_native_can_intent(
parsed,
utterance,
native_merge_utterance,
relative_actions=(
relative_resolution.actions if relative_resolution else None
),
@@ -2299,8 +2391,25 @@ class SeatDemoService:
"saved_mode_command_flow",
)
if local_preview:
parsed["ambiguities"] = []
parsed["unresolved_spans"] = []
# A preview may discard ordinary model-supplement questions, but
# target and safety conflicts are execution gates. Keeping these
# makes the assistant path no less strict than normal process().
parsed["ambiguities"] = [
item
for item in parsed.get("ambiguities", [])
if str(item.get("field", ""))
in {"target", "target_scope", "mode_profile", "risk", "safety", "utterance"}
or str(item.get("reason", "")).startswith(
("mode_target_", "safety_", "risk_", "mixed_")
)
]
parsed["unresolved_spans"] = [
item
for item in parsed.get("unresolved_spans", [])
if str(item.get("reason", "")).startswith(
("mode_target_", "safety_", "risk_", "mixed_")
)
]
parsed["model_requests"] = []
if excluded_control_signatures:
parsed = self._filter_excluded_controls(
@@ -2332,9 +2441,18 @@ class SeatDemoService:
]
has_control = any(
parsed.get(key)
for key in ("actions", "domain_actions", "native_can_actions")
for key in (
"actions",
"domain_actions",
"native_can_actions",
"preset_actions",
)
)
if allow_empty_control_result and not has_control:
if (
allow_empty_control_result
and not has_control
and not parsed.get("ambiguities")
):
result["provider_used"] = provider_used
result["decision_summary"] = [
*parsed.get("decision_summary", []),
@@ -3367,6 +3485,47 @@ class SeatDemoService:
]
return rejected
target_conflict = self._explicit_mode_target_conflict(
matched_modes[0],
utterance,
)
if target_conflict is not None:
rejected = deepcopy(parsed)
for key in (
"actions",
"domain_actions",
"native_can_actions",
"preset_actions",
"preset_events",
"deferred_requests",
"model_requests",
):
rejected[key] = []
requested_scope, supported_scopes = target_conflict
rejected["ambiguities"] = [
*rejected.get("ambiguities", []),
{
"field": "mode_profile",
"question": (
f"该模式的座椅目标为 {', '.join(supported_scopes)},"
f"与用户指定的 {requested_scope} 不一致,请重新确认。"
),
"reason": "mode_target_scope_mismatch",
},
]
rejected["unresolved_spans"] = [
*rejected.get("unresolved_spans", []),
{
"text": utterance,
"reason": "mode_target_scope_mismatch",
},
]
rejected["decision_summary"] = [
*rejected.get("decision_summary", []),
"用户显式座位与功能模式登记目标不一致,已停止所有模式动作。",
]
return rejected
parsed = self._apply_implicit_mode_target_policy(
parsed,
matched_modes,
@@ -3449,6 +3608,46 @@ class SeatDemoService:
)
return merged
@staticmethod
def _explicit_mode_target_conflict(
profile: ModeProfile,
utterance: str,
) -> tuple[str, tuple[str, ...]] | None:
"""Reject a named mode when its persisted seat scope contradicts text."""
aliases = (profile.id, profile.name, *profile.aliases)
mode_clause = next(
(
clause
for clause in re.split(r"[,,;;。!?!?\n]+", utterance)
if any(
re.sub(r"\s+", "", alias.casefold())
in re.sub(r"\s+", "", clause.casefold())
for alias in aliases
if alias
)
),
utterance,
)
requested = resolve_explicit_seat_scopes(
mode_clause,
assume_seat_context=True,
)
if requested is None:
return None
normalized_scopes = {
"driver" if operation.target_scope == "front_left" else
"passenger" if operation.target_scope == "front_right" else
"rear" if operation.target_scope in {"rear", "rear_left", "rear_right"} else
operation.target_scope
for operation in profile.operations
if operation.domain == "seat"
}
if requested == normalized_scopes:
return None
return ",".join(sorted(requested)), tuple(sorted(normalized_scopes))
@staticmethod
def _apply_implicit_mode_target_policy(
parsed: dict[str, Any],
@@ -4071,6 +4270,114 @@ class SeatDemoService:
except ValueError:
return str(path)
@staticmethod
def _mode_feedback_text(value: object, *, limit: int = 180) -> str:
text = re.sub(r"[\x00-\x1f\x7f]+", " ", str(value or ""))
return re.sub(r"\s+", " ", text).strip(" ,,。;;::")[:limit]
@classmethod
def _mode_failure_issue(
cls,
clause: str,
parsed: dict[str, Any],
) -> dict[str, str]:
display_clause = cls._mode_feedback_text(
re.sub(r"^(?:副驾座椅|主驾座椅)[,,]", "", clause),
limit=120,
)
ambiguities = [
item
for item in parsed.get("ambiguities", [])
if isinstance(item, dict) and str(item.get("field") or "") != "capability"
]
ambiguity = ambiguities[0] if ambiguities else None
field = str(ambiguity.get("field") or "") if ambiguity else ""
question = cls._mode_feedback_text(
ambiguity.get("question") if ambiguity else "",
limit=220,
)
# Parser questions may contain both the diagnosis and a concrete
# correction. Split those parts so every public failure has the same
# reason/next-step contract.
reason_from_question = question
next_from_question = ""
for separator in (";请", "。请"):
if separator in question:
reason_from_question, suffix = question.split(separator, 1)
next_from_question = f"请{suffix}"
break
if reason_from_question and not re.search(
r"(?:CAN|CH[01]|JSON|schema|0x[0-9a-f]+|内部路径)",
reason_from_question,
re.IGNORECASE,
):
reason = f"设置“{display_clause}”未通过:{reason_from_question}"
else:
reason = (
f"设置“{display_clause}”未被识别为当前支持的座舱控制动作"
)
if next_from_question:
next_step = next_from_question
elif "temperature" in field or "温度" in display_clause:
next_step = (
"请只保留一个18至32℃的整数温度,例如“空调调整到24度”,然后重试"
)
elif field == "target" or "target_scope" in field:
next_step = (
"请改用当前支持的副驾座椅目标,并明确部件与位置,例如"
"“副驾腿托调整到80%”"
)
elif "position" in field or re.search(
r"座椅|靠背|椅背|坐垫|坐盆|腿托|脚托|滑轨",
display_clause,
):
next_step = (
"请按“副驾 + 座椅部件 + 方向或0到100%位置”重述,例如"
"“副驾腿托向上调整到最大位置”或“副驾腿托调整到80%”"
)
else:
next_step = (
"请按“设备 + 动作 + 明确目标”重述,例如“关闭所有车窗”、"
"“副驾靠背调整到80%”或“空调调整到24度”"
)
return {
"clause": display_clause,
"reason": reason,
"next_step": cls._mode_feedback_text(next_step, limit=260),
}
@classmethod
def _mode_creation_failure_from_clauses(
cls,
clause_results: list[tuple[str, dict[str, Any], str]],
unresolved_clauses: list[str],
) -> dict[str, Any]:
unresolved = set(unresolved_clauses)
issues = [
cls._mode_failure_issue(clause, parsed)
for clause, parsed, status in clause_results
if status != "complete" and clause in unresolved
][:3]
if not issues:
issues = [
cls._mode_failure_issue(clause, {})
for clause in unresolved_clauses[:3]
]
reason = ";".join(dict.fromkeys(item["reason"] for item in issues))
next_step = ";".join(
dict.fromkeys(item["next_step"] for item in issues)
)
return {
"code": "MODE_PROFILE_DRAFT_INCOMPLETE",
"reason": reason or "部分座舱设置没有转换为可保存的控制动作",
"next_step": next_step
or "请将未识别设置改写为明确的设备、动作和目标值后重试",
"unresolved_clauses": [item["clause"] for item in issues],
"issues": issues,
}
def _process_mode_profile_create(
self,
utterance: str,
@@ -4082,17 +4389,23 @@ class SeatDemoService:
simulation_fault: str,
mode_name: str,
control_transport: str,
assistant_plan: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build, verify, and persist a new named mode in one user turn.
"""Build and verify a named-mode draft for explicit user confirmation.
A request such as ``构建一个K歌模式`` used to be interpreted as an
ordinary temporary scene. Although that scene could execute
successfully, it never reached the mode registry. Run the generated
scene first so only validated successful actions are persisted, then
use the existing control-plane save path to create the YAML profile.
ordinary temporary scene. Generate the scene through the normal
dry-run path, but leave persistence to the existing mode-card API so
the assistant can show the exact actions before the user saves them.
"""
source_utterance = (
create_match = CREATE_MODE_PROFILE_NAME.fullmatch(utterance)
requested_instruction = (
str(create_match.group("instruction") or "").strip(" ,,::。.!!")
if create_match is not None
else ""
)
source_utterance = requested_instruction or (
f"进入{mode_name},请根据该场景名称使用当前可控原子能力构建并执行场景"
)
source_result = self._process_once(
@@ -4104,8 +4417,67 @@ class SeatDemoService:
vehicle_profile.id,
simulation_fault,
control_transport=control_transport,
assistant_plan=assistant_plan,
)
if source_result.get("status") not in {"succeeded", "partially_succeeded"}:
draft_context = self._context_for_vehicle(
self.conversations.snapshot(conversation_id),
vehicle_profile,
)
draft_context["mode_profiles"] = self.mode_profiles.public_modes(
vehicle_profile.id
)
local_clause_results = self._local_clause_results(
source_utterance,
draft_context,
)
unresolved_draft_clauses = [
clause
for clause, _parsed, status in local_clause_results
if status != "complete"
and not self.mode_profiles.match_text(
clause,
vehicle_id=vehicle_profile.id,
)
]
if provider == "local" and assistant_plan is None and unresolved_draft_clauses:
source_result["status"] = "needs_confirmation"
source_result["unresolved_clauses"] = unresolved_draft_clauses
source_result["mode_creation_failure"] = (
self._mode_creation_failure_from_clauses(
local_clause_results,
unresolved_draft_clauses,
)
)
source_result["supplement_required"] = False
source_result["validation"] = [
*list(source_result.get("validation", [])),
{
"severity": "error",
"code": "MODE_PROFILE_DRAFT_INCOMPLETE",
"path": "mode_profile_draft.instruction",
"message": (
"以下模式设置尚未完整解析:"
+ ";".join(unresolved_draft_clauses)
),
},
]
source_result["decision_summary"] = [
f"未能完整构建“{mode_name}”,因此没有生成可保存草稿。",
*list(source_result.get("decision_summary", [])),
"请补充为当前支持的明确设备动作后重试。",
]
self._persist(source_result)
return source_result
if source_result.get("status") != "succeeded":
source_result["mode_creation_failure"] = {
"code": "MODE_PROFILE_ACTION_VALIDATION_FAILED",
"reason": "场景动作未通过车辆能力或安全校验",
"next_step": (
"请检查每项设备目标是否在当前支持范围内;仍失败时可分条输入后重试"
),
"unresolved_clauses": [],
"issues": [],
}
source_result["decision_summary"] = [
f"未能完成“{mode_name}”的场景动作验证,因此没有创建长期模式。",
*list(source_result.get("decision_summary", [])),
@@ -4113,40 +4485,75 @@ class SeatDemoService:
self._persist(source_result)
return source_result
saved_result = self._process_once(
f"这个模式不错,固化为{mode_name}",
"local",
conversation_id,
"dry_run",
None,
vehicle_profile.id,
"normal",
control_transport=control_transport,
intent_ir = source_result.get("intent_ir")
intent_spec = intent_ir.get("spec") if isinstance(intent_ir, dict) else None
raw_intent_actions = (
intent_spec.get("actions", []) if isinstance(intent_spec, dict) else []
)
if saved_result.get("status") != "succeeded" or not saved_result.get(
"mode_profile"
):
return saved_result
reusable_actions = [
item for item in raw_intent_actions if isinstance(item, dict)
] if isinstance(raw_intent_actions, list) else []
if not reusable_actions:
source_result["status"] = "needs_confirmation"
source_result["mode_creation_failure"] = {
"code": "MODE_PROFILE_DRAFT_EMPTY",
"reason": "没有识别到可保存的座舱控制动作",
"next_step": (
"请按“设备 + 动作 + 明确目标”补充,例如“关闭所有车窗,空调调整到24度”"
),
"unresolved_clauses": [],
"issues": [],
}
source_result["validation"] = [
{
"severity": "error",
"code": "MODE_PROFILE_DRAFT_EMPTY",
"path": "intent_ir.spec.actions",
"message": "没有生成可保存的功能模式动作,请补充更明确的座舱设置。",
}
]
source_result["decision_summary"] = [
f"未能为“{mode_name}”生成可保存的场景动作。",
*list(source_result.get("decision_summary", [])),
]
self._persist(source_result)
return source_result
saved_result["mode_creation"] = {
domains = list(
dict.fromkeys(
str(item.get("domain") or "")
for item in reusable_actions
if str(item.get("domain") or "")
)
)
source_request_id = str(source_result.get("request_id") or "")
source_result["mode_profile_draft"] = {
"name": mode_name,
"instruction": source_utterance,
"source_request_id": source_request_id,
"vehicle_id": vehicle_profile.id,
"domains": domains,
"operation_count": len(reusable_actions),
}
source_result["mode_creation"] = {
"requested_utterance": utterance,
"source_utterance": source_utterance,
"source_request_id": source_result.get("request_id"),
"source_request_id": source_request_id,
"source_status": source_result.get("status"),
"source_provider": source_result.get("provider_used"),
"operation_count": saved_result["mode_profile"].get(
"operation_count", 0
),
"operation_count": len(reusable_actions),
}
source_summary = list(source_result.get("decision_summary", []))
saved_result["decision_summary"] = [
source_result["orchestration_route"] = "mode_profile_create_preview"
source_result["decision_summary"] = [
f"已构建并验证“{mode_name}”场景。",
*source_summary,
*list(saved_result.get("decision_summary", [])),
f"“{mode_name}”已加入功能模式,可直接点击执行。",
*list(source_result.get("decision_summary", [])),
"请确认动作后保存为功能模式,或先进行一次模拟试运行。",
]
self._persist(saved_result)
return saved_result
# Overwrite the persisted source result with the draft metadata. The
# mode-card endpoint later re-reads this server-owned artifact and
# refuses any client-side instruction or request-id substitution.
self._persist(source_result)
return source_result
def _process_mode_profile_save(
self,
@@ -4415,6 +4822,21 @@ class SeatDemoService:
for action in candidate_native_actions
}
virtual_actions: list[dict[str, Any]] = []
demoted_signatures: set[tuple[str, str, str, str]] = set()
for action in candidate_native_actions:
if str(action.get("capability")) not in UNVERIFIED_NATIVE_CAPABILITIES:
continue
demoted = dict(action)
demoted["execution_backend"] = "configured_domain"
virtual_actions.append(demoted)
demoted_signatures.add(
(
str(action.get("domain")),
str(action.get("capability")),
str(action.get("target", action.get("target_scope", "vehicle"))),
json.dumps(action.get("parameters", {}), sort_keys=True, ensure_ascii=False),
)
)
for action in result.get("domain_actions", []):
signature = (
str(action.get("domain")),
@@ -4458,7 +4880,16 @@ class SeatDemoService:
for item in result.get("deferred_requests", [])
if not (
item.get("execution_backend") == "native_can"
and self.native_can_runtime.supports(item)
and (
self.native_can_runtime.supports(item)
or (
str(item.get("domain")),
str(item.get("capability")),
str(item.get("target", item.get("target_scope", "vehicle"))),
json.dumps(item.get("parameters", {}), sort_keys=True, ensure_ascii=False),
)
in demoted_signatures
)
)
]
for item in deferred_requests:
@@ -5480,13 +5911,62 @@ class SeatDemoService:
@staticmethod
def _split_intent_clauses(utterance: str) -> list[str]:
fragments = [
def looks_like_control(value: str) -> bool:
return bool(
CONTROL_CLAUSE_SUBJECT_PATTERN.search(value)
and CONTROL_CLAUSE_PREDICATE_PATTERN.search(value)
)
def split_independent_controls(
value: str,
separator_pattern: str,
) -> list[str]:
tokens = re.split(f"({separator_pattern})", value)
current = tokens[0].strip()
separated: list[str] = []
for index in range(1, len(tokens), 2):
separator = tokens[index]
next_item = tokens[index + 1].strip()
if looks_like_control(current) and looks_like_control(next_item):
if current:
separated.append(current)
current = next_item
else:
current = f"{current}{separator}{next_item}".strip()
if current:
separated.append(current)
return separated
coarse_fragments = [
item.strip()
for item in re.split(r"[,,。;;::!!??]+", utterance)
for item in re.split(r"[,,。;;::!!??\r\n]+", utterance)
if item.strip()
]
fragments: list[str] = []
for coarse_fragment in coarse_fragments:
list_fragments = split_independent_controls(coarse_fragment, r"、")
for list_fragment in list_fragments:
fragments.extend(
split_independent_controls(
list_fragment,
r"然后|随后|接着|最后|同时|并且|以及|再|并",
)
)
clauses: list[str] = []
for fragment in fragments:
fragment = re.sub(
r"^(?:然后|随后|接着|最后|同时|并且|以及|并)",
"",
fragment,
).strip()
fragment = re.sub(
r"^再(?=(?:打开|开启|启动|关闭|关掉|关上|停止|暂停|取消))",
"",
fragment,
).strip()
if not fragment:
continue
# A massage mode/strength phrase may omit both "主驾" and "按摩"
# after the first clause. Keep the whole request atomic so the ON
# fallback does not emit relax before an explicit later mode.
@@ -5755,6 +6235,7 @@ class SeatDemoService:
reasoning_effort: str | None = None,
local_preview: bool = False,
model_parse_source: str = "cloud_model",
assistant_plan: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str, str | None]:
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
clause_results = self._local_clause_results(utterance, context)
@@ -5774,6 +6255,50 @@ class SeatDemoService:
for key in ("actions", "domain_actions", "native_can_actions")
)
safe_fallback = trusted_local if trusted_controls else all_local
# A Codex-authored plan is an untrusted normalization hint for only
# the clauses the deterministic parser could not finish. The guard
# binds every proposed capability back to that server-owned text and
# prevents it from replacing a locally resolved capability/target.
# ModelPlanAdapter then applies the same target, parameter, range and
# runtime-availability checks as the established hosted-model path.
if assistant_plan is not None and fallback_clauses:
fallback_utterance = ",".join(fallback_clauses)
guarded = guard_assistant_plan(
assistant_plan,
fallback_text=fallback_utterance,
capability_context=self.capability_context,
executable_capabilities={
str(capability.get("name") or "")
for domain in self.capability_context.get("domains", [])
for capability in domain.get("capabilities", [])
if domain.get("availability") == "executable"
or str(capability.get("name") or "")
in self.native_can_runtime.supported_capabilities
},
fallback_intents=[
parsed
for _, parsed, status in clause_results
if status != "complete"
],
trusted_local=trusted_local,
)
model = self.model_plan_adapter.adapt(
guarded.plan,
context,
vehicle_profile,
utterance=fallback_utterance,
)
model = self._mark_control_parse_source(
model,
model_parse_source,
overwrite=True,
)
return (
self._merge_local_and_model_intent(trusted_local, model),
"local_rules+codex_app_server",
"本地规则结果优先;Codex 结构化计划仅补足未解析子句,并已重新通过能力注册表校验。",
)
if provider == "local" or not fallback_clauses:
return (
(
+279 -42
View File
@@ -62,21 +62,56 @@ _ACTION_SYNONYM_GROUPS = (
("白色", "白灯", "白光", "white"),
)
_RESERVED_VEHICLE_DEVICE_TERMS = {
"空调",
"座椅",
"主驾座椅",
"副驾座椅",
"车窗",
"车门",
"后门",
"遮阳帘",
"氛围灯",
"阅读灯",
"室内灯",
"出风口",
"风口",
}
_RESERVED_VEHICLE_DEVICE_TERMS = frozenset(
{
"ac",
"airconditioner",
"ambientlight",
"door",
"interiorlight",
"light",
"readinglight",
"seat",
"shade",
"window",
"空调",
"空调压缩机",
"压缩机",
"空调出风口",
"座椅",
"主驾座椅",
"副驾座椅",
"驾驶员座椅",
"乘客座椅",
"全车座椅",
"车窗",
"前排车窗",
"后排车窗",
"左前车窗",
"右前车窗",
"左后车窗",
"右后车窗",
"车门",
"门锁",
"后门",
"后排车门",
"遮阳帘",
"天幕帘",
"智能天幕",
"天幕",
"氛围灯",
"阅读灯",
"室内灯",
"车内灯",
"车内灯光",
"灯光",
"出风口",
"风口",
}
)
_VEHICLE_PHRASE_PRIORITY_TERMS = frozenset(
term for term in _RESERVED_VEHICLE_DEVICE_TERMS if not term.isascii()
)
class GatewayError(RuntimeError):
@@ -186,7 +221,9 @@ class InfraredSlotCatalog:
"slot": int(row["slot"]),
"device_id": str(row["device_id"] or ""),
"name": str(row["name"]),
"object_names": cls._stored_names(row["object_names_json"]),
"object_names": _safe_peripheral_identity_names(
cls._stored_names(row["object_names_json"])
),
"action_names": cls._stored_names(row["action_names_json"]),
"source": str(row["source"]),
"learned_at": str(row["learned_at"]),
@@ -263,8 +300,10 @@ class InfraredSlotCatalog:
r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", device_id
):
continue
object_names = self._stored_names(
json.dumps(capability.get("object_names") or [])
object_names = _safe_peripheral_identity_names(
self._stored_names(
json.dumps(capability.get("object_names") or [])
)
)
action_names = self._stored_names(
json.dumps(capability.get("action_names") or [])
@@ -324,7 +363,20 @@ class InfraredSlotCatalog:
except json.JSONDecodeError:
continue
if isinstance(record, dict):
capabilities.append(record)
device_name = str(record.get("device_name") or "").strip()
if not _peripheral_identity_name_is_safe(
device_name,
strip_display_index=True,
):
continue
sanitized = dict(record)
sanitized["device_aliases"] = _safe_peripheral_identity_names(
record.get("device_aliases")
)
sanitized["object_names"] = _safe_peripheral_identity_names(
record.get("object_names")
)
capabilities.append(sanitized)
return capabilities
def sync_capabilities(
@@ -355,9 +407,12 @@ class InfraredSlotCatalog:
).fetchone()
if slot_row is not None:
stored_device_id = str(slot_row["device_id"] or "").strip()
stored_objects = self._stored_names(
raw_stored_objects = self._stored_names(
slot_row["object_names_json"]
)
stored_objects = _safe_peripheral_identity_names(
raw_stored_objects
)
stored_actions = self._stored_names(
slot_row["action_names_json"]
)
@@ -370,6 +425,7 @@ class InfraredSlotCatalog:
if (
not stored_device_id
or not stored_objects
or stored_objects != raw_stored_objects
or not stored_actions
):
connection.execute(
@@ -464,6 +520,10 @@ class InfraredSlotCatalog:
normalized_objects = _normalized_aliases(
object_names, field="控制对象名称"
)
_validate_peripheral_identity_names(
normalized_objects,
field="控制对象名称",
)
normalized_actions = _normalized_aliases(
action_names, field="执行动作"
)
@@ -769,6 +829,138 @@ def _normalized_aliases(
return aliases[:24]
def _identity_name_variants(value: Any, *, strip_display_index: bool = False) -> set[str]:
raw_name = str(value or "").strip()
variants = {_compact_match_text(raw_name)}
if strip_display_index:
variants.add(
_compact_match_text(
re.sub(r"^\s*#?\d+\s*[·.、:_-]*\s*", "", raw_name)
)
)
variants.discard("")
return variants
def _peripheral_identity_name_is_safe(
value: Any,
*,
strip_display_index: bool = False,
) -> bool:
"""Reject identities that can match a vehicle actuator without extra text."""
variants = _identity_name_variants(
value,
strip_display_index=strip_display_index,
)
if not variants:
return False
for identity in variants:
contains_cjk = bool(re.search(r"[\u4e00-\u9fff]", identity))
minimum_length = 2 if contains_cjk else 3
if len(identity) < minimum_length or identity.isdigit():
return False
if any(identity in reserved for reserved in _RESERVED_VEHICLE_DEVICE_TERMS):
return False
return True
def _validate_peripheral_identity_names(
values: list[str],
*,
field: str,
strip_display_index: bool = False,
) -> None:
for value in values:
if not _peripheral_identity_name_is_safe(
value,
strip_display_index=strip_display_index,
):
raise GatewayError(
f"{field}“{value}”过短或与车端设备保留名称冲突,"
"请使用可唯一识别外设的完整名称。"
)
def _safe_peripheral_identity_names(value: Any) -> list[str]:
"""Sanitize legacy persisted aliases instead of returning them to a router."""
if isinstance(value, str):
value = re.split(r"[,,、;;|]", value)
if not isinstance(value, list):
return []
names: list[str] = []
for raw_name in value:
name = str(raw_name or "").strip()
if (
name
and name not in names
and _peripheral_identity_name_is_safe(name)
):
names.append(name)
return names[:24]
def _capability_identity_variants(capability: dict[str, Any]) -> set[str]:
variants: set[str] = set()
raw_name = str(capability.get("device_name") or "")
for candidate in _identity_name_variants(raw_name, strip_display_index=True):
if _peripheral_identity_name_is_safe(candidate):
variants.add(candidate)
for field in ("device_aliases", "object_names"):
values = capability.get(field, [])
if isinstance(values, str):
values = re.split(r"[,,、;;|]", values)
if not isinstance(values, list):
continue
for value in values:
if _peripheral_identity_name_is_safe(value):
variants.update(_identity_name_variants(value))
return variants
def _has_unshadowed_vehicle_device_phrase(
utterance: str,
candidates: list[dict[str, Any]],
) -> bool:
"""Return true when a native vehicle phrase is not inside an explicit IR name."""
spans: list[tuple[int, int, str]] = []
for capability in candidates:
for identity in _capability_identity_variants(capability):
start = utterance.find(identity)
while start >= 0:
spans.append((start, start + len(identity), identity))
start = utterance.find(identity, start + 1)
for vehicle_term in _VEHICLE_PHRASE_PRIORITY_TERMS:
start = utterance.find(vehicle_term)
while start >= 0:
end = start + len(vehicle_term)
covered_by_explicit_peripheral = any(
identity != vehicle_term
and len(identity) > len(vehicle_term)
and identity_start <= start
and identity_end >= end
for identity_start, identity_end, identity in spans
)
if not covered_by_explicit_peripheral:
return True
start = utterance.find(vehicle_term, start + 1)
return False
def _peripheral_execution_identity_authorized(
utterance: str,
capability: dict[str, Any],
) -> bool:
# Saved modes execute an immutable, previously validated command flow and
# are rebound to the same live NFC/device/slot tuple before reaching here.
if capability.get("parse_source") == "saved_mode_command_flow":
return True
return bool(_device_match_score(_compact_match_text(utterance), capability))
def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
"""Validate one browser-synchronized learned infrared capability."""
@@ -820,6 +1012,19 @@ def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
action_names = _normalized_aliases(
[action, *action_aliases], field="执行动作"
)
_validate_peripheral_identity_names(
[device_name],
field="设备名称",
strip_display_index=True,
)
_validate_peripheral_identity_names(
device_aliases,
field="设备别名",
)
_validate_peripheral_identity_names(
object_names,
field="控制对象名称",
)
return {
"id": capability_id,
@@ -843,26 +1048,7 @@ def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
def _device_match_score(utterance: str, capability: dict[str, Any]) -> int:
raw_name = str(capability.get("device_name") or "")
raw_aliases = capability.get("device_aliases", [])
if isinstance(raw_aliases, str):
raw_aliases = re.split(r"[,,、;;|]", raw_aliases)
if not isinstance(raw_aliases, list):
raw_aliases = []
object_names = capability.get("object_names", [])
if isinstance(object_names, str):
object_names = re.split(r"[,,、;;|]", object_names)
if not isinstance(object_names, list):
object_names = []
name_variants = {
_compact_match_text(raw_name),
_compact_match_text(
re.sub(r"^\s*#?\d+\s*[·.、:_-]*\s*", "", raw_name)
),
*(_compact_match_text(alias) for alias in raw_aliases),
*(_compact_match_text(name) for name in object_names),
}
name_variants.difference_update(_RESERVED_VEHICLE_DEVICE_TERMS)
name_variants = _capability_identity_variants(capability)
return max(
(len(name) for name in name_variants if name and name in utterance),
default=0,
@@ -917,7 +1103,27 @@ def _deterministic_peripheral_steps(
]
if not compact_utterance or len(learned) < 2:
return []
if len({str(item.get("device_id") or "") for item in learned}) != 1:
explicit = [
item for item in learned if _device_match_score(compact_utterance, item)
]
if not explicit or _has_unshadowed_vehicle_device_phrase(
compact_utterance,
explicit,
):
return []
explicit_device_ids = {
str(item.get("device_id") or "") for item in explicit
}
if len(explicit_device_ids) != 1:
return []
selected_device_id = next(iter(explicit_device_ids))
learned = [
item
for item in learned
if str(item.get("device_id") or "") == selected_device_id
and _capability_identity_variants(item)
]
if len(learned) < 2:
return []
events: list[tuple[int, int, dict[str, Any]]] = []
@@ -1084,6 +1290,11 @@ def _resolve_bound_peripheral_command(
if not matches:
return None
if _has_unshadowed_vehicle_device_phrase(
compact_utterance,
[item[2] for item in matches],
):
return None
matches.sort(key=lambda item: (item[0], item[1]), reverse=True)
best_score = matches[0][:2]
best_matches = [item for item in matches if item[:2] == best_score]
@@ -1182,6 +1393,14 @@ def partition_active_peripheral_intent(
for item in explicit_bound_candidates
if item.get("enabled", True) is not False
]
if explicit_bound_candidates and _has_unshadowed_vehicle_device_phrase(
compact_clause,
explicit_bound_candidates,
):
residual.append(clause)
contextual_device_id = None
contextual_device_name = ""
continue
clause_candidates = explicit_candidates
bound_clause_candidates = explicit_bound_candidates
resolution_text = clause
@@ -1216,7 +1435,7 @@ def partition_active_peripheral_intent(
else:
mentions_vehicle_device = any(
_compact_match_text(term) in compact_clause
for term in _RESERVED_VEHICLE_DEVICE_TERMS
for term in _VEHICLE_PHRASE_PRIORITY_TERMS
)
requires_ordered_plan = bool(
re.search(
@@ -1357,6 +1576,8 @@ def active_peripheral_plan_catalog(
item["slot"] = validate_slot(item.get("slot"))
item["card_uid"] = active_uid
active.append(item)
if _has_unshadowed_vehicle_device_phrase(compact_utterance, active):
return []
return active
@@ -1805,6 +2026,7 @@ class TcpGateway(BaseGateway):
"gateway_acknowledged": False,
"physical_state_confirmed": False,
}
return result
class MqttGateway(BaseGateway):
@@ -2168,6 +2390,10 @@ class SelfExtensionService:
) -> dict[str, Any]:
"""Execute a live-NFC peripheral match through the main request API."""
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
"外设名称未在原始指令中被明确提及,已拒绝红外执行。"
)
slot = validate_slot(capability.get("slot"))
request_id = f"req-extension-{uuid.uuid4().hex}"
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
@@ -2374,6 +2600,10 @@ class SelfExtensionService:
raise ExtensionIntegrationError(
"GPT 计划引用了不在当前 NFC 目录中的能力。"
)
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
"GPT 计划引用的外设未在原始指令中被明确提及。"
)
slot = validate_slot(capability.get("slot"))
operation_ref = f"{capability_id}-{index + 1}"
compiled_peripheral_steps[index] = (
@@ -2701,6 +2931,13 @@ class SelfExtensionService:
peripherals = [
item for item in peripherals[:MAX_CATALOG_ITEMS] if isinstance(item, dict)
]
compact_utterance = _compact_match_text(utterance)
if _has_unshadowed_vehicle_device_phrase(compact_utterance, peripherals):
peripherals = [
item
for item in peripherals
if _device_match_score(compact_utterance, item)
]
deterministic_steps = _deterministic_peripheral_steps(utterance, peripherals)
if deterministic_steps and not vehicle_devices:
return {
+298 -4
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from copy import deepcopy
from typing import Any
@@ -39,6 +41,96 @@ DRIVER_SEAT_THERMAL_MIDDLE = re.compile(
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
)
META_NON_EXECUTION = re.compile(
r"复述|引用|翻译|改写|总结|解释|分析|造句|"
r"故事|剧本|小说|作文|角色|主角|假设|假如|如果|"
r"示例|举例|这句话|以下内容"
)
_EXECUTION_LIST_FIELDS = (
"actions",
"domain_actions",
"native_can_actions",
"deferred_requests",
"model_requests",
"preset_actions",
"preset_events",
)
_SEAT_CONTEXT = re.compile(
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|"
r"座椅通风|座椅加热|按摩|座椅记忆|零重力|木马|摇篮"
)
_SEAT_DRIVER_SCOPE = re.compile(
r"\bdriver\b|\bfront[-_ ]?left\b|主驾|主驾驶|驾驶位|驾驶员|"
r"驾驶席|驾驶座(?:椅)?|司机位|司机座(?:椅)?|左前"
)
_SEAT_PASSENGER_SCOPE = re.compile(
r"\bpassenger\b|\bfront[-_ ]?right\b|副驾|副驾驶|乘客|乘员|右前"
)
_SEAT_UNSUPPORTED_SCOPE = re.compile(
r"前排|后排|后座|二排|第二排|中排|第三排|三排|"
r"左后|右后|前舱|后舱|左侧|右侧|左边|右边|"
r"所有座椅|全部座椅|全车座椅"
)
def resolve_explicit_seat_scopes(
utterance: str,
*,
assume_seat_context: bool = False,
) -> frozenset[str] | None:
"""Resolve scopes only from clauses that actually describe a seat action."""
scopes: set[str] = set()
relevant_clause_seen = False
clauses = [item for item in CLAUSE_SEPARATOR.split(utterance) if item.strip()]
for clause in clauses or [utterance]:
if not assume_seat_context and not _SEAT_CONTEXT.search(clause):
continue
relevant_clause_seen = True
if _SEAT_UNSUPPORTED_SCOPE.search(clause):
scopes.add("unsupported")
continue
if _SEAT_DRIVER_SCOPE.search(clause):
scopes.add("driver")
if _SEAT_PASSENGER_SCOPE.search(clause):
scopes.add("passenger")
if not relevant_clause_seen or not scopes:
return None
return frozenset(scopes)
def _filter_explicit_seat_scope(
result: dict[str, Any],
requested_scopes: frozenset[str],
) -> bool:
"""Remove seat operations outside the user's explicit scope."""
allowed: set[str] = set()
if "driver" in requested_scopes:
allowed.update(("driver", "front_left"))
if "passenger" in requested_scopes:
allowed.update(("passenger", "front_right"))
if "unsupported" in requested_scopes:
allowed.clear()
removed = False
for key in _EXECUTION_LIST_FIELDS:
kept: list[Any] = []
for item in result.get(key, []):
if not isinstance(item, dict):
kept.append(item)
continue
capability = str(item.get("capability", ""))
is_seat = item.get("domain") == "seat" or capability.startswith("seat.")
if not is_seat:
kept.append(item)
continue
target = str(item.get("target", item.get("target_scope", "")))
if target in allowed:
kept.append(item)
else:
removed = True
result[key] = kept
return removed
def _span(utterance: str, match: re.Match[str]) -> dict[str, Any]:
@@ -145,6 +237,125 @@ def _remove_capability(parsed: dict[str, Any], capability: str) -> None:
]
def _parameter_mapping(item: Mapping[str, Any]) -> dict[str, Any]:
parameters: object = item.get("parameters", {})
if isinstance(parameters, list):
return {
str(parameter.get("name")): parameter.get("value")
for parameter in parameters
if isinstance(parameter, dict) and parameter.get("name")
}
if isinstance(parameters, Mapping):
normalized = {str(name): value for name, value in parameters.items()}
if normalized:
return normalized
# The passenger-seat domain predates the capability-shaped actions used by
# the other domains. Normalize its legacy action fields here so the shared
# conflict gate runs before FunctionConfig compilation and execution.
legacy: dict[str, Any] = {}
for source, target in (
("state", "state"),
("requested_level", "level"),
("position_pct", "position_pct"),
("angle_deg", "angle_deg"),
):
if item.get(source) is not None:
legacy[target] = item.get(source)
return legacy
def _control_capability(item: Mapping[str, Any]) -> str:
capability = str(item.get("capability", ""))
if capability:
return capability
function = str(item.get("function", ""))
if function in {"ventilation", "heating"}:
return f"seat.set_{function}"
if item.get("action_type") == "position":
return {
"longitudinal": "seat.set_longitudinal",
"cushion_tilt": "seat.set_cushion_tilt",
"backrest_recline": "seat.set_backrest_recline",
"legrest_height": "seat.set_legrest_height",
}.get(str(item.get("axis", "")), "")
return ""
def _control_conflict_key(
item: Mapping[str, Any],
parameters: Mapping[str, Any],
) -> tuple[str, str, str, str]:
target: object = item.get("target", item.get("target_scope", ""))
if isinstance(target, Mapping):
target = target.get("zone", target.get("device", ""))
capability = _control_capability(item)
domain = str(item.get("domain", "")) or (
capability.split(".", 1)[0] if "." in capability else ""
)
selector = ""
if capability == "seat.set_driver_axis_native":
selector = f"axis:{parameters.get('axis', '')}"
elif capability == "seat.set_driver_massage_native":
selector = f"action:{parameters.get('action', '')}"
return domain, capability, str(target), selector
def _conflicting_control_keys(
result: dict[str, Any],
) -> set[tuple[str, str, str, str]]:
"""Find contradictory values, while allowing complementary subcommands.
Some capabilities intentionally emit more than one operation for the same
target: massage mode plus strength, or ambient colour plus brightness.
Those parameter sets are complementary. A conflict exists only when the
same setting parameter receives multiple values. ``action`` and ``axis``
select a subcommand and therefore are not themselves setting values.
"""
values: dict[tuple[str, str, str, str], dict[str, set[str]]] = {}
# ``model_requests`` is audit metadata and can still contain a built-in
# mode's superseded defaults. The executable action lists below have
# already applied explicit-user-value precedence, so conflict decisions
# must be based on those lists only.
for field in ("actions", "domain_actions", "native_can_actions"):
for item in result.get(field, []):
if not isinstance(item, dict) or not _control_capability(item):
continue
parameters = _parameter_mapping(item)
if not parameters:
continue
key = _control_conflict_key(item, parameters)
settings = values.setdefault(key, {})
for name, value in parameters.items():
if str(name) in {"action", "axis"}:
continue
settings.setdefault(str(name), set()).add(
json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
)
return {
key
for key, settings in values.items()
if any(len(candidates) > 1 for candidates in settings.values())
}
def _remove_conflicting_controls(
result: dict[str, Any],
conflicts: set[tuple[str, str, str, str]],
) -> None:
for field in _EXECUTION_LIST_FIELDS:
kept: list[Any] = []
for item in result.get(field, []):
if not isinstance(item, dict) or not _control_capability(item):
kept.append(item)
continue
key = _control_conflict_key(item, _parameter_mapping(item))
if key not in conflicts:
kept.append(item)
result[field] = kept
def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]:
"""Preserve negation and unresolved control language without producing CAN."""
@@ -152,6 +363,60 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
constraints = list(result.get("semantic_constraints", []))
unresolved = list(result.get("unresolved_spans", []))
meta_match = META_NON_EXECUTION.search(utterance)
if meta_match is not None:
for key in _EXECUTION_LIST_FIELDS:
result[key] = []
constraints.append(
{
"type": "prohibit_meta_language_execution",
"source_span": _span(utterance, meta_match),
}
)
unresolved.append(
{
"text": utterance,
"reason": "meta_language_is_not_vehicle_authorization",
}
)
result["ambiguities"] = []
result["decision_summary"] = [
*result.get("decision_summary", []),
"检测到复述、翻译或创作类元语言;未将其中的控制词当作车辆授权。",
]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
explicit_seat_scopes = resolve_explicit_seat_scopes(utterance)
if explicit_seat_scopes is not None:
removed = _filter_explicit_seat_scope(result, explicit_seat_scopes)
if removed:
constraints.append(
{
"type": "restrict_explicit_seat_scope",
"domain": "seat",
"target": ",".join(sorted(explicit_seat_scopes)),
}
)
result["decision_summary"] = [
*result.get("decision_summary", []),
"已移除与用户显式座位不一致的座椅动作,未扩大控制范围。",
]
if "unsupported" in explicit_seat_scopes:
unresolved.append(
{
"text": utterance,
"reason": "explicit_seat_scope_is_not_safely_callable",
}
)
result["ambiguities"] = [
*result.get("ambiguities", []),
{
"field": "target_scope",
"question": "当前请求的座位范围不能在不扩大目标的前提下执行。",
"reason": "explicit_seat_scope_is_not_safely_callable",
},
]
for match in PRESERVE_SEAT.finditer(utterance):
target = "driver" if match.group("target") == "主驾" else "passenger"
constraints.append(
@@ -167,10 +432,7 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
negated = list(NEGATED_OPERATION.finditer(utterance))
if negated:
constraints.extend(_constraint(utterance, item) for item in negated)
for key in (
"actions", "domain_actions", "native_can_actions", "deferred_requests",
"model_requests", "preset_actions", "preset_events",
):
for key in _EXECUTION_LIST_FIELDS:
result[key] = []
result["ambiguities"] = []
if len(CONTROL_VERB.findall(utterance)) > len(negated):
@@ -297,6 +559,38 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
}
)
conflicts = _conflicting_control_keys(result)
if conflicts:
_remove_conflicting_controls(result, conflicts)
for domain, capability, target, selector in sorted(conflicts):
constraint = {
"type": "reject_conflicting_absolute_values",
"domain": domain,
"capability": capability,
"target": target,
}
if selector:
constraint["selector"] = selector
constraints.append(constraint)
unresolved.append(
{
"text": utterance,
"reason": "conflicting_values_for_same_capability_target",
}
)
result["ambiguities"] = [
*result.get("ambiguities", []),
{
"field": "parameters",
"question": "同一设备目标包含互相冲突的设定值,已停止相关操作,请明确唯一值。",
"reason": "conflicting_values_for_same_capability_target",
},
]
result["decision_summary"] = [
*result.get("decision_summary", []),
"检测到同一能力与目标的冲突值,相关动作均未下发。",
]
result["semantic_constraints"] = constraints
result["unresolved_spans"] = unresolved
return result