优化了副驾座椅控制
This commit is contained in:
@@ -239,6 +239,9 @@ class ConversationStateStore:
|
||||
with self._lock:
|
||||
state = self._state(conversation_id)
|
||||
passenger = state["devices"]["passenger"]
|
||||
feedback_source = str(physical_state.get("source", "physical_can"))
|
||||
if feedback_source not in {"physical_can", "wireless_mqtt_ch0"}:
|
||||
feedback_source = "physical_can"
|
||||
for axis, feedback in physical_state.get("position", {}).items():
|
||||
if axis not in MOTION_INITIAL_POSITIONS or not isinstance(
|
||||
feedback, dict
|
||||
@@ -260,7 +263,7 @@ class ConversationStateStore:
|
||||
position_state["position_valid"] = bool(
|
||||
feedback["position_valid"]
|
||||
)
|
||||
position_state["feedback_source"] = "physical_can"
|
||||
position_state["feedback_source"] = feedback_source
|
||||
passenger["position"][str(axis)] = position_state
|
||||
|
||||
for feature in THERMAL_FUNCTIONS:
|
||||
@@ -277,7 +280,20 @@ class ConversationStateStore:
|
||||
passenger[feature] = {
|
||||
"state": state_value,
|
||||
"level": level,
|
||||
"feedback_source": "physical_can",
|
||||
"feedback_source": feedback_source,
|
||||
}
|
||||
|
||||
dynamic_mode = physical_state.get("dynamic_mode")
|
||||
if isinstance(dynamic_mode, dict):
|
||||
dynamic_state = str(dynamic_mode.get("state", "idle"))
|
||||
passenger["dynamic_mode"] = {
|
||||
**deepcopy(dynamic_mode),
|
||||
"state": (
|
||||
"running"
|
||||
if dynamic_state in {"entry", "running"}
|
||||
else dynamic_state
|
||||
),
|
||||
"feedback_source": feedback_source,
|
||||
}
|
||||
|
||||
state["physical_state_updated_at"] = str(
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Deterministic batches submitted by the vehicle-device control panel.
|
||||
|
||||
The browser sends only canonical commands from the checked CAN vocabulary.
|
||||
This module validates the complete batch and builds the same intermediate
|
||||
actions used by the normal compiler, without natural-language or model parsing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from cognitive_agent.application.native_can_intent import (
|
||||
parse_exact_native_can_command,
|
||||
)
|
||||
|
||||
|
||||
MAX_DIRECT_CAN_COMMANDS = 32
|
||||
MAX_DIRECT_CAN_COMMAND_LENGTH = 160
|
||||
|
||||
_PASSENGER_POSITION_COMMAND = re.compile(
|
||||
r"seat passenger "
|
||||
r"(horizontal|basin|backrest|legrest|longitudinal|height) "
|
||||
r"position (\d+)"
|
||||
)
|
||||
_PASSENGER_RELATIVE_COMMAND = re.compile(
|
||||
r"seat passenger "
|
||||
r"(horizontal|basin|backrest|legrest|longitudinal|height) "
|
||||
r"relative (forward|backward|up|down) (\d+)"
|
||||
)
|
||||
_PASSENGER_THERMAL_COMMAND = re.compile(
|
||||
r"seat passenger (vent|heat) (off|low|medium|high)"
|
||||
)
|
||||
_PASSENGER_DYNAMIC_COMMAND = re.compile(
|
||||
r"seat passenger dynamic (?:(?:start )?(rocking-horse|cradle)|(stop))"
|
||||
)
|
||||
|
||||
_PASSENGER_AXES = {
|
||||
"horizontal": "longitudinal",
|
||||
"basin": "cushion_tilt",
|
||||
"longitudinal": "longitudinal",
|
||||
"height": "cushion_tilt",
|
||||
"backrest": "backrest_recline",
|
||||
"legrest": "legrest_height",
|
||||
}
|
||||
|
||||
_PASSENGER_RELATIVE_DIRECTIONS = {
|
||||
"horizontal": {"forward": "decrease", "backward": "increase"},
|
||||
"longitudinal": {"forward": "decrease", "backward": "increase"},
|
||||
"basin": {"up": "decrease", "down": "increase"},
|
||||
"height": {"up": "decrease", "down": "increase"},
|
||||
"backrest": {"forward": "decrease", "backward": "increase"},
|
||||
"legrest": {"up": "increase", "down": "decrease"},
|
||||
}
|
||||
|
||||
_PASSENGER_THERMAL_LEVELS = {"low": 1, "medium": 2, "high": 3}
|
||||
|
||||
|
||||
def _empty_direct_intent() -> dict[str, Any]:
|
||||
return {
|
||||
"actions": [],
|
||||
"domain_actions": [],
|
||||
"native_can_actions": [],
|
||||
"preset_actions": [],
|
||||
"preset_events": [],
|
||||
"deferred_requests": [],
|
||||
"model_requests": [],
|
||||
"ambiguities": [],
|
||||
"decision_summary": [],
|
||||
"semantic_constraints": [],
|
||||
"unresolved_spans": [],
|
||||
"detected_domains": [],
|
||||
"confidence": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def build_direct_can_intent(
|
||||
commands: object,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
"""Validate a canonical command batch and return compiler-ready actions."""
|
||||
|
||||
if not isinstance(commands, list) or not commands:
|
||||
raise ValueError("can_commands must be a non-empty array")
|
||||
if len(commands) > MAX_DIRECT_CAN_COMMANDS:
|
||||
raise ValueError(
|
||||
f"can_commands must contain at most {MAX_DIRECT_CAN_COMMANDS} commands"
|
||||
)
|
||||
|
||||
normalized_commands: list[str] = []
|
||||
parsed = _empty_direct_intent()
|
||||
seen_commands: set[str] = set()
|
||||
|
||||
for raw_command in commands:
|
||||
if not isinstance(raw_command, str):
|
||||
raise ValueError("every can command must be a string")
|
||||
command = " ".join(raw_command.strip().lower().split())
|
||||
if not command or len(command) > MAX_DIRECT_CAN_COMMAND_LENGTH:
|
||||
raise ValueError(
|
||||
"every can command must contain 1 to "
|
||||
f"{MAX_DIRECT_CAN_COMMAND_LENGTH} characters"
|
||||
)
|
||||
if command in seen_commands:
|
||||
continue
|
||||
|
||||
native_actions = parse_exact_native_can_command(command)
|
||||
if native_actions:
|
||||
for action in native_actions:
|
||||
direct_action = dict(action)
|
||||
direct_action["parse_source"] = "direct_control"
|
||||
direct_action["source_span"] = command
|
||||
parsed["native_can_actions"].append(direct_action)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交白名单命令:{command}"
|
||||
)
|
||||
domain = str(direct_action.get("domain", "vehicle"))
|
||||
if domain not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append(domain)
|
||||
else:
|
||||
passenger_position = _PASSENGER_POSITION_COMMAND.fullmatch(command)
|
||||
if passenger_position:
|
||||
position = int(passenger_position[2])
|
||||
if not 0 <= position <= 100:
|
||||
raise ValueError(
|
||||
f"CAN0 passenger-seat position is out of range: {command}"
|
||||
)
|
||||
parsed["actions"].append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": _PASSENGER_AXES[passenger_position[1]],
|
||||
"position_pct": position,
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
else:
|
||||
relative = _PASSENGER_RELATIVE_COMMAND.fullmatch(command)
|
||||
if relative:
|
||||
semantic_axis = relative[1]
|
||||
axis = _PASSENGER_AXES[semantic_axis]
|
||||
direction = relative[2]
|
||||
operation = _PASSENGER_RELATIVE_DIRECTIONS.get(
|
||||
semantic_axis, {}
|
||||
).get(direction)
|
||||
delta = int(relative[3])
|
||||
if operation is None:
|
||||
raise ValueError(
|
||||
f"CAN0 passenger-seat direction is invalid: {command}"
|
||||
)
|
||||
if not 1 <= delta <= 100:
|
||||
raise ValueError(
|
||||
f"CAN0 passenger-seat relative amount is out of range: {command}"
|
||||
)
|
||||
position_state = (
|
||||
(context or {})
|
||||
.get("devices", {})
|
||||
.get("passenger", {})
|
||||
.get("position", {})
|
||||
.get(axis, {})
|
||||
)
|
||||
current = position_state.get("position_pct")
|
||||
if (
|
||||
isinstance(current, bool)
|
||||
or not isinstance(current, (int, float))
|
||||
or not 0 <= float(current) <= 100
|
||||
):
|
||||
raise ValueError(
|
||||
"CAN0 passenger-seat relative control requires a valid "
|
||||
f"current {axis} position"
|
||||
)
|
||||
signed_delta = delta if operation == "increase" else -delta
|
||||
raw_target = round(float(current)) + signed_delta
|
||||
target = max(0, min(100, raw_target))
|
||||
applied_delta = abs(target - round(float(current)))
|
||||
parsed["actions"].append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": target,
|
||||
"motion_intent": {
|
||||
"mode": "relative",
|
||||
"direction": operation,
|
||||
"start_position_pct": round(float(current)),
|
||||
"delta_position_pct": applied_delta,
|
||||
**(
|
||||
{
|
||||
"requested_delta_position_pct": delta,
|
||||
"range_clamped": True,
|
||||
}
|
||||
if applied_delta != delta
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 相对移动白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
else:
|
||||
thermal = _PASSENGER_THERMAL_COMMAND.fullmatch(command)
|
||||
if thermal:
|
||||
setting = thermal[2]
|
||||
action = {
|
||||
"target": "passenger",
|
||||
"function": (
|
||||
"ventilation" if thermal[1] == "vent" else "heating"
|
||||
),
|
||||
"state": "off" if setting == "off" else "on",
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
if setting != "off":
|
||||
action["requested_level"] = _PASSENGER_THERMAL_LEVELS[
|
||||
setting
|
||||
]
|
||||
parsed["actions"].append(action)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 热舒适白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
normalized_commands.append(command)
|
||||
seen_commands.add(command)
|
||||
continue
|
||||
|
||||
dynamic = _PASSENGER_DYNAMIC_COMMAND.fullmatch(command)
|
||||
if dynamic and dynamic[1]:
|
||||
mode = dynamic[1]
|
||||
parsed["actions"].append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "dynamic_mode",
|
||||
"mode_id": 1 if mode == "rocking-horse" else 2,
|
||||
"mode_name": (
|
||||
"木马模式"
|
||||
if mode == "rocking-horse"
|
||||
else "动态摇篮模式"
|
||||
),
|
||||
"repeat": "until_stopped_or_timeout",
|
||||
"max_runtime_ms": 600_000,
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
elif dynamic and dynamic[2] == "stop":
|
||||
parsed["actions"].append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "dynamic_mode_stop",
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"CAN control command is not in the exact whitelist: {command}"
|
||||
)
|
||||
|
||||
normalized_commands.append(command)
|
||||
seen_commands.add(command)
|
||||
|
||||
if not normalized_commands:
|
||||
raise ValueError("can_commands must contain at least one unique command")
|
||||
return normalized_commands, parsed
|
||||
@@ -76,19 +76,43 @@ class MqttOtaSeatExecutor:
|
||||
capability = str(operation["capability"])
|
||||
parameters = operation.get("parameters", {})
|
||||
axes = {
|
||||
"seat.set_longitudinal_position": "longitudinal",
|
||||
"seat.set_cushion_tilt": "height",
|
||||
"seat.set_longitudinal_position": "horizontal",
|
||||
"seat.set_cushion_tilt": "basin",
|
||||
"seat.set_backrest_recline": "backrest",
|
||||
"seat.set_legrest_height": "legrest",
|
||||
}
|
||||
if capability in axes:
|
||||
motion = operation.get("motion", {})
|
||||
relative_directions = {
|
||||
"horizontal": {"increase": "backward", "decrease": "forward"},
|
||||
"basin": {"increase": "down", "decrease": "up"},
|
||||
"backrest": {"increase": "backward", "decrease": "forward"},
|
||||
"legrest": {"increase": "up", "decrease": "down"},
|
||||
}
|
||||
direction = relative_directions[axes[capability]].get(
|
||||
str(motion.get("direction", ""))
|
||||
)
|
||||
delta = motion.get("delta_position_pct")
|
||||
if motion.get("mode") == "relative" and direction and delta is not None:
|
||||
return (
|
||||
f"seat passenger {axes[capability]} relative "
|
||||
f"{direction} {int(delta)}"
|
||||
)
|
||||
return (
|
||||
f"seat passenger {axes[capability]} position "
|
||||
f"{parameters['position_pct']}"
|
||||
)
|
||||
if capability in {"seat.set_ventilation", "seat.set_heating"}:
|
||||
feature = "vent" if capability.endswith("ventilation") else "heat"
|
||||
if parameters.get("state") == "off":
|
||||
return f"seat passenger {feature} off"
|
||||
level = {1: "low", 2: "medium", 3: "high"}.get(
|
||||
int(parameters.get("level", 1)), "low"
|
||||
)
|
||||
return f"seat passenger {feature} {level}"
|
||||
if capability == "seat.start_dynamic_mode":
|
||||
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
|
||||
return f"seat passenger dynamic start {mode}"
|
||||
return f"seat passenger dynamic {mode}"
|
||||
if capability == "seat.stop_dynamic_mode":
|
||||
return "seat passenger dynamic stop"
|
||||
return capability
|
||||
@@ -384,6 +408,7 @@ class MqttOtaSeatExecutor:
|
||||
try:
|
||||
bus = self.bus_factory()
|
||||
previous_offset = 0
|
||||
thermal_publication_start: int | None = None
|
||||
for command in command_plan["spec"]["commands"]:
|
||||
offset = int(command.get("send_offset_ms", 0))
|
||||
if offset > previous_offset:
|
||||
@@ -393,8 +418,10 @@ class MqttOtaSeatExecutor:
|
||||
operations[item]
|
||||
for item in command["operation_ref"].split(",")
|
||||
]
|
||||
publication_start = len(
|
||||
getattr(bus, "published_messages", ())
|
||||
publication_start = (
|
||||
thermal_publication_start
|
||||
if thermal_publication_start is not None
|
||||
else len(getattr(bus, "published_messages", ()))
|
||||
)
|
||||
set_debug_command = getattr(bus, "set_debug_command", None)
|
||||
if callable(set_debug_command):
|
||||
@@ -404,24 +431,40 @@ class MqttOtaSeatExecutor:
|
||||
for item in referenced
|
||||
)
|
||||
)
|
||||
if command["message"] != "T2G_Seat_Move_Command_300":
|
||||
raise HardwareExecutionError(
|
||||
"seat-ota firmware does not provide wireless thermal control; "
|
||||
f"{command['message']} was not sent"
|
||||
if command["message"] == "T2G_Seat_Thermal_Mode_3CE":
|
||||
encoded = self.status_executor.matrix.encode(
|
||||
command["message"], command["signals"]
|
||||
)
|
||||
opcode = int(command["signals"]["Opcode"])
|
||||
if opcode in {0x0E, 0x0F}:
|
||||
motion_armed = opcode == 0x0E
|
||||
command_results = self._execute_dynamic(
|
||||
bus.send(
|
||||
int(encoded["frame_id"], 16),
|
||||
bytes.fromhex(encoded["data_hex"]),
|
||||
)
|
||||
thermal_publication_start = publication_start
|
||||
continue
|
||||
if command["message"] == "T2G_Seat_Thermal_Control_29E":
|
||||
command_results = self.status_executor._execute_thermal( # noqa: SLF001
|
||||
bus, command, referenced
|
||||
)
|
||||
motion_armed = False
|
||||
thermal_publication_start = None
|
||||
elif command["message"] == "T2G_Seat_Move_Command_300":
|
||||
opcode = int(command["signals"]["Opcode"])
|
||||
if opcode in {0x0E, 0x0F}:
|
||||
motion_armed = opcode == 0x0E
|
||||
command_results = self._execute_dynamic(
|
||||
bus, command, referenced
|
||||
)
|
||||
motion_armed = False
|
||||
else:
|
||||
motion_armed = True
|
||||
command_results = self._execute_move(
|
||||
bus, command, referenced
|
||||
)
|
||||
motion_armed = False
|
||||
else:
|
||||
motion_armed = True
|
||||
command_results = self._execute_move(
|
||||
bus, command, referenced
|
||||
raise HardwareExecutionError(
|
||||
"wireless CAN0 command is not in the passenger-seat runtime "
|
||||
f"whitelist: {command['message']}"
|
||||
)
|
||||
motion_armed = False
|
||||
mqtt_messages = list(
|
||||
getattr(bus, "published_messages", ())[
|
||||
publication_start:
|
||||
@@ -491,6 +534,7 @@ class MqttOtaSeatExecutor:
|
||||
"labels": {
|
||||
"simulation": "false",
|
||||
"feedback": "wireless_mqtt_ch0",
|
||||
"seat_feedback": "wireless_mqtt_ch0",
|
||||
},
|
||||
},
|
||||
"spec": spec,
|
||||
|
||||
@@ -60,6 +60,65 @@ LIGHT_TARGETS = {
|
||||
"右后": "rear_right",
|
||||
}
|
||||
CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
|
||||
OUTLET_MODE_DISABLE_PATTERN = re.compile(
|
||||
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
|
||||
r"(?P<feature>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?|"
|
||||
r"(?P<feature_after>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?"
|
||||
r"(?:关闭|关掉|停止|取消)"
|
||||
)
|
||||
DRIVER_SEAT_THERMAL_MIDDLE_PATTERN = re.compile(
|
||||
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
|
||||
r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
|
||||
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
|
||||
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
|
||||
)
|
||||
_FEATURE_BOUNDARY = re.compile(
|
||||
r"并且|同时|然后|随后|接着|以及|并|再|和|与|、|,"
|
||||
)
|
||||
|
||||
|
||||
def _feature_context(text: str, feature: str, radius: int = 24) -> str:
|
||||
"""Keep modifiers attached to one seat feature instead of a neighbour."""
|
||||
|
||||
match = re.search(feature, text)
|
||||
if match is None:
|
||||
return ""
|
||||
prefix = text[max(0, match.start() - radius) : match.start()]
|
||||
suffix = text[match.end() : match.end() + radius]
|
||||
prefix = _FEATURE_BOUNDARY.split(prefix)[-1]
|
||||
suffix = _FEATURE_BOUNDARY.split(suffix)[0]
|
||||
return f"{prefix}{match.group(0)}{suffix}"
|
||||
|
||||
|
||||
def _grade_alias(text: str, *, include_numeric: bool = True) -> str | None:
|
||||
"""Normalize common low/middle/high wording without guessing a value."""
|
||||
|
||||
low = (
|
||||
r"最小(?:档|级)?|最低(?:档|级)?|最弱(?:档|级)?|弱(?:档|级)?|"
|
||||
r"低(?:档|级)?|调低"
|
||||
)
|
||||
middle = (
|
||||
r"中等(?:档|级)?|中档|中级|中间(?:档|级)?|适中|"
|
||||
r"(?:调到|调为|调|设为|设置为|调整到|调整为)中(?:档|级)?"
|
||||
)
|
||||
high = (
|
||||
r"最大(?:档|级)?|最高(?:档|级)?|最强(?:档|级)?|"
|
||||
r"强(?!度)(?:档|级)?|高(?:档|级)?|调高"
|
||||
)
|
||||
if re.search(low, text):
|
||||
return "low"
|
||||
if re.search(high, text):
|
||||
return "high"
|
||||
if re.search(middle, text):
|
||||
return "medium"
|
||||
if include_numeric:
|
||||
if re.search(r"(?:1|一)(?:档|级)", text):
|
||||
return "low"
|
||||
if re.search(r"(?:2|二)(?:档|级)", text):
|
||||
return "medium"
|
||||
if re.search(r"(?:3|三)(?:档|级)", text):
|
||||
return "high"
|
||||
return None
|
||||
|
||||
# Defaults are intentionally scoped to the driver console and verified CH1
|
||||
# body-CAN vocabulary. A missing target must never become the legacy
|
||||
@@ -463,6 +522,36 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
through to the legacy CH0/T2G passenger-seat implementation.
|
||||
"""
|
||||
|
||||
seat_clauses = [
|
||||
clause for clause in CLAUSE_SEPARATOR.split(compact) if clause
|
||||
]
|
||||
if len(seat_clauses) > 1:
|
||||
inherited_driver_scope = False
|
||||
combined_actions: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str, tuple[tuple[str, str], ...]]] = set()
|
||||
for clause in seat_clauses:
|
||||
if re.search(r"主驾|驾驶位|司机位|左前座椅", clause):
|
||||
inherited_driver_scope = True
|
||||
elif re.search(r"副驾|副驾驶|乘客|右前", clause):
|
||||
inherited_driver_scope = False
|
||||
elif inherited_driver_scope:
|
||||
clause = f"主驾座椅{clause}"
|
||||
for action in _parse_chinese_driver_seat_commands(clause):
|
||||
signature = (
|
||||
str(action.get("capability", "")),
|
||||
str(action.get("target", "")),
|
||||
tuple(
|
||||
sorted(
|
||||
(str(key), repr(value))
|
||||
for key, value in action.get("parameters", {}).items()
|
||||
)
|
||||
),
|
||||
)
|
||||
if signature not in seen:
|
||||
seen.add(signature)
|
||||
combined_actions.append(action)
|
||||
return combined_actions
|
||||
|
||||
if re.search(
|
||||
r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|"
|
||||
r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|"
|
||||
@@ -508,14 +597,20 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
present_thermal = [item for item in thermal_features if item[0] in compact]
|
||||
if len(present_thermal) == 1:
|
||||
label, capability = present_thermal[0]
|
||||
thermal_text = _feature_context(compact, label)
|
||||
thermal_grade = _grade_alias(thermal_text, include_numeric=False)
|
||||
parameters: dict[str, Any] | None = None
|
||||
if re.search(r"关闭|关掉|停止|取消|不要|别开", compact):
|
||||
if re.search(r"关闭|关掉|停止|取消|不要|别开", thermal_text):
|
||||
parameters = {"state": "off"}
|
||||
elif re.search(r"高档|高速|最高|强档|调高|1档", compact):
|
||||
elif thermal_grade == "high" or re.search(
|
||||
r"高速|(?:1|一)(?:档|级)", thermal_text
|
||||
):
|
||||
parameters = {"state": "on", "level": 1}
|
||||
elif re.search(r"低档|低速|最低|弱档|调低|2档", compact):
|
||||
elif thermal_grade == "low" or re.search(
|
||||
r"低速|(?:2|二)(?:档|级)", thermal_text
|
||||
):
|
||||
parameters = {"state": "on", "level": 2}
|
||||
elif re.search(r"打开|开启|启动", compact):
|
||||
elif re.search(r"打开|开启|启动|启用", thermal_text):
|
||||
parameters = {"state": "on", "level": 2}
|
||||
if parameters is not None:
|
||||
actions.append(_action(
|
||||
@@ -531,9 +626,10 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
))
|
||||
|
||||
if "按摩" in compact:
|
||||
massage: dict[str, Any] | None = None
|
||||
if re.search(r"关闭|关掉|停止|取消", compact):
|
||||
massage = {"action": "off"}
|
||||
massage_text = _feature_context(compact, "按摩")
|
||||
massage_actions: list[dict[str, Any]] = []
|
||||
if re.search(r"关闭|关掉|停止|取消|不要|别开", massage_text):
|
||||
massage_actions.append({"action": "off"})
|
||||
else:
|
||||
modes = (
|
||||
(r"波浪", "wave"),
|
||||
@@ -543,67 +639,122 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
(r"舒展|放松", "relax"),
|
||||
)
|
||||
mode = next(
|
||||
(value for pattern, value in modes if re.search(pattern, compact)),
|
||||
(
|
||||
value
|
||||
for pattern, value in modes
|
||||
if re.search(pattern, massage_text)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mode is not None:
|
||||
massage = {"action": "mode", "mode": mode}
|
||||
elif re.search(r"强度|力度|弱|中等|中档|强|[123]档", compact):
|
||||
strength = (
|
||||
1
|
||||
if re.search(r"弱|低|1档", compact)
|
||||
else 2
|
||||
if re.search(r"中等|中档|2档", compact)
|
||||
else 3
|
||||
if re.search(r"强(?!度)|高|3档", compact)
|
||||
else 2
|
||||
massage_actions.append({"action": "mode", "mode": mode})
|
||||
elif re.search(r"打开|开启|启动|启用", massage_text):
|
||||
# The verified vocabulary has no state-only ON frame. Starting
|
||||
# the conservative relax mode is the deterministic ON action.
|
||||
massage_actions.append({"action": "mode", "mode": "relax"})
|
||||
|
||||
massage_grade = _grade_alias(massage_text)
|
||||
strength_requested = bool(
|
||||
re.search(
|
||||
r"强度|力度|档位|等级|最小|最大|最低|最高|最弱|最强|"
|
||||
r"弱|中等|中档|中级|中间|适中|强(?!度)|"
|
||||
r"(?:[123一二三])(?:档|级)",
|
||||
massage_text,
|
||||
)
|
||||
massage = {"action": "strength", "strength": strength}
|
||||
if massage is not None:
|
||||
actions.append(_action(
|
||||
"seat",
|
||||
"seat.set_driver_massage_native",
|
||||
"driver",
|
||||
massage,
|
||||
(
|
||||
"用户明确指定了 CH1 主驾座椅按摩控制。"
|
||||
if explicit_driver
|
||||
else default_reason
|
||||
),
|
||||
))
|
||||
)
|
||||
if strength_requested and massage_grade is not None:
|
||||
massage_actions.append(
|
||||
{
|
||||
"action": "strength",
|
||||
"strength": {"low": 1, "medium": 2, "high": 3}[
|
||||
massage_grade
|
||||
],
|
||||
}
|
||||
)
|
||||
for massage in massage_actions:
|
||||
actions.append(
|
||||
_action(
|
||||
"seat",
|
||||
"seat.set_driver_massage_native",
|
||||
"driver",
|
||||
massage,
|
||||
(
|
||||
"用户明确指定了 CH1 主驾座椅按摩控制。"
|
||||
if explicit_driver
|
||||
else default_reason
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
stop_all_axes = bool(
|
||||
re.search(
|
||||
r"(?:停止|停下|暂停)(?:主驾)?(?:座椅)?(?:所有)?(?:移动|调节|动作)|"
|
||||
r"(?:主驾)?座椅(?:停止|停下|暂停)(?:移动|调节|动作)",
|
||||
compact,
|
||||
)
|
||||
)
|
||||
if stop_all_axes:
|
||||
for stopped_axis in ("horizontal", "height", "backrest", "basin"):
|
||||
actions.append(
|
||||
_action(
|
||||
"seat",
|
||||
"seat.set_driver_axis_native",
|
||||
"driver",
|
||||
{
|
||||
"axis": stopped_axis,
|
||||
"direction": "stop",
|
||||
"duration_ms": 500,
|
||||
},
|
||||
(
|
||||
"用户明确要求停止 CH1 主驾座椅全部四轴动作。"
|
||||
if explicit_driver
|
||||
else default_reason
|
||||
),
|
||||
)
|
||||
)
|
||||
return actions
|
||||
|
||||
axis: str | None = None
|
||||
direction: str | None = None
|
||||
if re.search(r"坐盆|坐垫", compact):
|
||||
if re.search(r"坐盆|坐垫|座垫", compact):
|
||||
axis = "basin"
|
||||
direction = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "up" if re.search(r"抬高|升高|向上|往上", compact)
|
||||
else "down" if re.search(r"降低|下降|向下|往下", compact)
|
||||
else "up" if re.search(r"抬高|抬起|升高|翘起|上调|向上|往上", compact)
|
||||
else "down" if re.search(r"降低|下降|降下|放低|下调|向下|往下", compact)
|
||||
else None
|
||||
)
|
||||
elif "靠背" in compact:
|
||||
elif re.search(r"靠背|椅背", compact):
|
||||
axis = "backrest"
|
||||
direction = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "backward" if re.search(r"后仰|向后|往后", compact)
|
||||
else "forward" if re.search(r"前倾|向前|往前|立直", compact)
|
||||
else "backward" if re.search(r"后仰|放倒|躺倒|调躺|后倾|向后|往后|朝后", compact)
|
||||
else "forward" if re.search(r"前倾|调直|立直|竖直|竖起|立起|向前|往前|朝前", compact)
|
||||
else None
|
||||
)
|
||||
elif re.search(r"座椅高度|整椅|座椅升高|座椅降低|座椅调高|座椅调低", compact):
|
||||
elif re.search(
|
||||
r"座椅高度|整椅|座椅升高|座椅降低|座椅调高|座椅调低|"
|
||||
r"座椅(?:向|往|朝)?(?:上|下)(?:移|移动|调|调整)?|座椅上移|座椅下移",
|
||||
compact,
|
||||
):
|
||||
axis = "height"
|
||||
direction = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "up" if re.search(r"抬高|升高|上升|向上|往上|调高", compact)
|
||||
else "down" if re.search(r"降低|下降|向下|往下|调低", compact)
|
||||
else "up" if re.search(r"抬高|抬起|升高|上升|上移|向上|往上|朝上|调高", compact)
|
||||
else "down" if re.search(r"降低|下降|降下|下移|向下|往下|朝下|调低|放低", compact)
|
||||
else None
|
||||
)
|
||||
elif re.search(r"座椅前后|水平|前移|后移|向前移动|向后移动|座椅(?:向|往)?(?:前|后)", compact):
|
||||
elif re.search(
|
||||
r"座椅前后|水平|滑轨|前移|后移|前进|后退|前推|后拉|"
|
||||
r"(?:向|往|朝)(?:前|后)(?:移动|挪动|挪|调节|调整|调|滑动)?|"
|
||||
r"座椅(?:向|往|朝)?(?:前|后)",
|
||||
compact,
|
||||
):
|
||||
axis = "horizontal"
|
||||
direction = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "forward" if re.search(r"前移|向前|往前", compact)
|
||||
else "backward" if re.search(r"后移|向后|往后", compact)
|
||||
else "forward" if re.search(r"前移|前进|前推|向前|往前|朝前", compact)
|
||||
else "backward" if re.search(r"后移|后退|后拉|向后|往后|朝后", compact)
|
||||
else None
|
||||
)
|
||||
if axis is not None and direction is not None:
|
||||
@@ -624,14 +775,22 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
second_match = re.search(
|
||||
r"(\d+(?:\.\d+)?)(?:秒|s)", compact, re.IGNORECASE
|
||||
)
|
||||
chinese_second_match = re.search(r"(半|一|二|两)(?:秒|s)", compact)
|
||||
unsupported_duration = "分钟" in compact
|
||||
if millisecond_match and second_match:
|
||||
if sum(
|
||||
bool(item)
|
||||
for item in (millisecond_match, second_match, chinese_second_match)
|
||||
) > 1:
|
||||
unsupported_duration = True
|
||||
duration = 500
|
||||
if direction != "stop" and millisecond_match:
|
||||
duration = int(millisecond_match[1])
|
||||
elif direction != "stop" and second_match:
|
||||
duration = round(float(second_match[1]) * 1000)
|
||||
elif direction != "stop" and chinese_second_match:
|
||||
duration = {"半": 500, "一": 1000, "二": 2000, "两": 2000}[
|
||||
chinese_second_match[1]
|
||||
]
|
||||
if (
|
||||
not unsupported_absolute_goal
|
||||
and not unsupported_duration
|
||||
@@ -652,6 +811,111 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
return actions
|
||||
|
||||
|
||||
def _positive_outlet_feature(text: str, pattern: str) -> bool:
|
||||
"""Return whether an outlet feature is requested rather than disabled."""
|
||||
|
||||
for match in re.finditer(pattern, text):
|
||||
prefix = text[max(0, match.start() - 16) : match.start()]
|
||||
suffix = text[match.end() : match.end() + 12]
|
||||
disabled_before = re.search(
|
||||
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?$",
|
||||
prefix,
|
||||
)
|
||||
disabled_after = re.match(
|
||||
r"(?:出风口|出风|模式)?(?:关闭|关掉|停止|取消)",
|
||||
suffix,
|
||||
)
|
||||
if disabled_before is None and disabled_after is None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parse_chinese_window_commands(compact: str) -> list[dict[str, Any]]:
|
||||
"""Parse each window clause independently so targets and values cannot leak."""
|
||||
|
||||
actions: list[dict[str, Any]] = []
|
||||
clauses = [clause for clause in CLAUSE_SEPARATOR.split(compact) if clause]
|
||||
exclusion_pattern = re.compile(
|
||||
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
|
||||
r"左前|前左|右前|前右|左后|后左|右后|后右)"
|
||||
r"(?:车窗)?(?:除外|除去|不包括|不含)$"
|
||||
)
|
||||
for index, clause in enumerate(clauses):
|
||||
if "车窗" not in clause:
|
||||
continue
|
||||
window_text = clause
|
||||
if index + 1 < len(clauses) and exclusion_pattern.search(
|
||||
clauses[index + 1]
|
||||
):
|
||||
window_text = f"{window_text},{clauses[index + 1]}"
|
||||
|
||||
targets, explicit_window_target = _window_targets(window_text)
|
||||
position = re.search(
|
||||
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)"
|
||||
r"(\d{1,3})%",
|
||||
window_text,
|
||||
)
|
||||
half_position = bool(
|
||||
re.search(
|
||||
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
|
||||
window_text,
|
||||
)
|
||||
)
|
||||
position_pct = (
|
||||
int(position[1]) if position else 50 if half_position else None
|
||||
)
|
||||
if (
|
||||
position_pct is not None
|
||||
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
|
||||
and position_pct <= 100
|
||||
):
|
||||
reason = (
|
||||
"用户指定了单个车窗的绝对开度。"
|
||||
if explicit_window_target
|
||||
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
|
||||
)
|
||||
actions.extend(
|
||||
_action(
|
||||
"window",
|
||||
"window.set_position",
|
||||
target,
|
||||
{"position_pct": position_pct},
|
||||
reason,
|
||||
)
|
||||
for target in targets
|
||||
)
|
||||
continue
|
||||
if position_pct is not None:
|
||||
# Absolute feedback is verified for one window only.
|
||||
continue
|
||||
motion = (
|
||||
"stop"
|
||||
if re.search(r"停止|停下|暂停", window_text)
|
||||
else "close"
|
||||
if re.search(r"关闭|关上|升起|关窗", window_text)
|
||||
else "open"
|
||||
if re.search(r"打开|开启|降下|开窗", window_text)
|
||||
else None
|
||||
)
|
||||
if motion:
|
||||
reason = (
|
||||
"用户指定了车窗目标和动作。"
|
||||
if explicit_window_target
|
||||
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
|
||||
)
|
||||
actions.extend(
|
||||
_action(
|
||||
"window",
|
||||
"window.set_motion",
|
||||
target,
|
||||
{"motion": motion},
|
||||
reason,
|
||||
)
|
||||
for target in targets
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
seat_text = _scoped_text(
|
||||
@@ -726,8 +990,14 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
|
||||
r"([1-7])(?:档|级)?(?![\d.])",
|
||||
climate_text,
|
||||
)
|
||||
if fan:
|
||||
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": int(fan[1])}, "用户指定了空调风量。"))
|
||||
fan_level = int(fan[1]) if fan else None
|
||||
if fan_level is None and re.search(r"风量|风速", climate_text):
|
||||
fan_grade = _grade_alias(climate_text, include_numeric=False)
|
||||
fan_level = {"low": 1, "medium": 4, "high": 7}.get(
|
||||
fan_grade or ""
|
||||
)
|
||||
if fan_level is not None:
|
||||
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": fan_level}, "用户指定了空调风量。"))
|
||||
|
||||
if re.search(r"自动(?:控制|模式)", climate_text):
|
||||
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "auto"}, "用户指定空调自动控制。"))
|
||||
@@ -739,9 +1009,9 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
|
||||
elif re.search(r"外循环", climate_text):
|
||||
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
|
||||
|
||||
has_face = bool(re.search(r"吹面|吹脸", climate_text))
|
||||
has_feet = "吹脚" in climate_text
|
||||
has_defrost = "除霜" in climate_text
|
||||
has_face = _positive_outlet_feature(climate_text, r"吹面|吹脸")
|
||||
has_feet = _positive_outlet_feature(climate_text, r"吹脚")
|
||||
has_defrost = _positive_outlet_feature(climate_text, r"除霜")
|
||||
outlet_mode = (
|
||||
"face_feet_defrost"
|
||||
if has_face and has_feet and has_defrost
|
||||
@@ -770,60 +1040,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
|
||||
)
|
||||
)
|
||||
|
||||
window_text = _scoped_text(
|
||||
compact,
|
||||
r"车窗",
|
||||
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
|
||||
r"左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗)?(?:除外|除去|不包括|不含)$",
|
||||
)
|
||||
if window_text:
|
||||
targets, explicit_window_target = _window_targets(window_text)
|
||||
position = re.search(
|
||||
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)(\d{1,3})%",
|
||||
window_text,
|
||||
)
|
||||
half_position = bool(
|
||||
re.search(
|
||||
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
|
||||
window_text,
|
||||
)
|
||||
)
|
||||
position_pct = int(position[1]) if position else 50 if half_position else None
|
||||
if (
|
||||
position_pct is not None
|
||||
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
|
||||
and position_pct <= 100
|
||||
):
|
||||
reason = (
|
||||
"用户指定了单个车窗的绝对开度。"
|
||||
if explicit_window_target
|
||||
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
|
||||
)
|
||||
actions.extend(
|
||||
_action("window", "window.set_position", target, {"position_pct": position_pct}, reason)
|
||||
for target in targets
|
||||
)
|
||||
elif position_pct is not None:
|
||||
# Absolute position feedback is verified for one window only. Do
|
||||
# not turn a half-open request for a broad scope into full motion.
|
||||
pass
|
||||
else:
|
||||
motion = (
|
||||
"stop" if re.search(r"停止|停下|暂停", window_text)
|
||||
else "close" if re.search(r"关闭|关上|升起|关窗", window_text)
|
||||
else "open" if re.search(r"打开|开启|降下|开窗", window_text)
|
||||
else None
|
||||
)
|
||||
if motion:
|
||||
reason = (
|
||||
"用户指定了车窗目标和动作。"
|
||||
if explicit_window_target
|
||||
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
|
||||
)
|
||||
actions.extend(
|
||||
_action("window", "window.set_motion", target, {"motion": motion}, reason)
|
||||
for target in targets
|
||||
)
|
||||
actions.extend(_parse_chinese_window_commands(compact))
|
||||
|
||||
door_text = _scoped_text(compact, r"门锁|车门|后门|锁车|解锁车辆")
|
||||
rear_lock_request = bool(
|
||||
@@ -931,8 +1148,18 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
|
||||
)
|
||||
if color_match and 1 <= int(color_match[1]) <= 127:
|
||||
actions.append(_action("ambient_light", "ambient_light.set_color_index", "vehicle", {"color_index": int(color_match[1])}, "用户指定了已验证的氛围灯颜色索引。"))
|
||||
if brightness_match:
|
||||
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": int(brightness_match[1])}, "用户指定了已验证的氛围灯亮度档位。"))
|
||||
brightness_level = int(brightness_match[1]) if brightness_match else None
|
||||
if brightness_level is None and re.search(r"亮度|强度", ambient_text):
|
||||
brightness_grade = _grade_alias(
|
||||
ambient_text, include_numeric=False
|
||||
)
|
||||
brightness_level = {
|
||||
"low": 1,
|
||||
"medium": 3,
|
||||
"high": 5,
|
||||
}.get(brightness_grade or "")
|
||||
if brightness_level is not None:
|
||||
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": brightness_level}, "用户指定了已验证的氛围灯亮度档位。"))
|
||||
|
||||
outlet_text = _scoped_text(compact, r"风口")
|
||||
outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in outlet_text), None)
|
||||
@@ -1204,6 +1431,89 @@ def merge_native_can_intent(
|
||||
unresolved=not has_default_driver_seat_action,
|
||||
)
|
||||
if not actions and not relative_ambiguities:
|
||||
unsupported_thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE_PATTERN.search(
|
||||
utterance
|
||||
)
|
||||
if unsupported_thermal_middle is not None and not re.search(
|
||||
r"副驾|副驾驶|乘客|右前", utterance
|
||||
):
|
||||
result = dict(parsed)
|
||||
feature = (
|
||||
"通风"
|
||||
if "通风" in unsupported_thermal_middle.group(0)
|
||||
else "加热"
|
||||
)
|
||||
capability = (
|
||||
"seat.set_driver_ventilation_native"
|
||||
if feature == "通风"
|
||||
else "seat.set_driver_heating_native"
|
||||
)
|
||||
constraint = {
|
||||
"type": "unsupported_discrete_level",
|
||||
"domain": "seat",
|
||||
"capability": capability,
|
||||
"parameters": {"requested_level": "medium"},
|
||||
"source_span": {
|
||||
"text": unsupported_thermal_middle.group(0),
|
||||
"start": unsupported_thermal_middle.start(),
|
||||
"end": unsupported_thermal_middle.end(),
|
||||
},
|
||||
}
|
||||
constraints = list(result.get("semantic_constraints", []))
|
||||
if not any(
|
||||
item.get("type") == constraint["type"]
|
||||
and item.get("capability") == capability
|
||||
and item.get("parameters") == constraint["parameters"]
|
||||
for item in constraints
|
||||
):
|
||||
constraints.append(constraint)
|
||||
result["semantic_constraints"] = constraints
|
||||
result["ambiguities"] = [
|
||||
item
|
||||
for item in result.get("ambiguities", [])
|
||||
if not (
|
||||
str(item.get("field", "")) in {"target", "target_scope"}
|
||||
and "仅控制副驾" in str(item.get("question", ""))
|
||||
)
|
||||
]
|
||||
result["decision_summary"] = [
|
||||
item
|
||||
for item in result.get("decision_summary", [])
|
||||
if not _is_stale_passenger_seat_summary(item)
|
||||
]
|
||||
domains = list(result.get("detected_domains", []))
|
||||
if "seat" not in domains:
|
||||
domains.append("seat")
|
||||
result["detected_domains"] = domains
|
||||
result["confidence"] = max(float(result.get("confidence", 0)), 0.99)
|
||||
return result
|
||||
disabled_outlet = OUTLET_MODE_DISABLE_PATTERN.search(utterance)
|
||||
if disabled_outlet is not None:
|
||||
feature = (
|
||||
disabled_outlet.group("feature")
|
||||
or disabled_outlet.group("feature_after")
|
||||
)
|
||||
result = dict(parsed)
|
||||
result["semantic_constraints"] = [
|
||||
*result.get("semantic_constraints", []),
|
||||
{
|
||||
"type": "requires_absolute_outlet_mode",
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_outlet_mode",
|
||||
"parameters": {"disabled_feature": feature},
|
||||
"source_span": {
|
||||
"text": disabled_outlet.group(0),
|
||||
"start": disabled_outlet.start(),
|
||||
"end": disabled_outlet.end(),
|
||||
},
|
||||
},
|
||||
]
|
||||
domains = list(result.get("detected_domains", []))
|
||||
if "climate" not in domains:
|
||||
domains.append("climate")
|
||||
result["detected_domains"] = domains
|
||||
result["confidence"] = max(float(result.get("confidence", 0)), 0.99)
|
||||
return result
|
||||
return parsed
|
||||
|
||||
result = dict(parsed)
|
||||
@@ -1391,6 +1701,20 @@ def merge_native_can_intent(
|
||||
}
|
||||
for item in actions
|
||||
)
|
||||
resolved_separate_thermal_and_massage = (
|
||||
any(
|
||||
item["capability"]
|
||||
in {
|
||||
"seat.set_driver_ventilation_native",
|
||||
"seat.set_driver_heating_native",
|
||||
}
|
||||
for item in actions
|
||||
)
|
||||
and any(
|
||||
item["capability"] == "seat.set_driver_massage_native"
|
||||
for item in actions
|
||||
)
|
||||
)
|
||||
result["ambiguities"] = [
|
||||
item
|
||||
for item in result.get("ambiguities", [])
|
||||
@@ -1410,6 +1734,10 @@ def merge_native_can_intent(
|
||||
)
|
||||
and re.search(r"主驾|driver|座椅", str(item.get("question", "")), re.IGNORECASE)
|
||||
)
|
||||
and not (
|
||||
resolved_separate_thermal_and_massage
|
||||
and str(item.get("field", "")) == "passenger.thermal.state"
|
||||
)
|
||||
]
|
||||
existing_summaries = list(result.get("decision_summary", []))
|
||||
if unique_actions and not forbidden_seat_scope and not result.get("actions"):
|
||||
|
||||
@@ -57,6 +57,9 @@ from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
|
||||
MqttOtaSeatExecutor,
|
||||
)
|
||||
from cognitive_agent.application.native_can_intent import merge_native_can_intent # noqa: E402
|
||||
from cognitive_agent.application.direct_can_control import ( # noqa: E402
|
||||
build_direct_can_intent,
|
||||
)
|
||||
from cognitive_agent.application.native_can_relative import ( # noqa: E402
|
||||
NativeCanRelativeResolver,
|
||||
)
|
||||
@@ -1401,6 +1404,47 @@ class SeatDemoService:
|
||||
}
|
||||
return merged
|
||||
|
||||
def process_can_commands(
|
||||
self,
|
||||
commands: object,
|
||||
conversation_id: str | None = None,
|
||||
execution_mode: str = "dry_run",
|
||||
approval_token: str | None = None,
|
||||
vehicle_id: str | None = None,
|
||||
simulation_fault: str = "normal",
|
||||
*,
|
||||
hardware_client_id: str | None = None,
|
||||
hardware_access_bypass: bool = True,
|
||||
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||
) -> dict[str, Any]:
|
||||
"""Compile and execute an exact UI command batch without intent parsing."""
|
||||
|
||||
resolved_conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
|
||||
if not CONVERSATION_ID_PATTERN.fullmatch(resolved_conversation_id):
|
||||
raise ValueError(
|
||||
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
|
||||
)
|
||||
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
|
||||
context = self._context_for_vehicle(
|
||||
self.conversations.snapshot(resolved_conversation_id),
|
||||
vehicle_profile,
|
||||
)
|
||||
direct_can_batch = build_direct_can_intent(commands, context)
|
||||
normalized_commands, _parsed = direct_can_batch
|
||||
return self.process(
|
||||
";".join(normalized_commands),
|
||||
provider="local",
|
||||
conversation_id=resolved_conversation_id,
|
||||
execution_mode=execution_mode,
|
||||
approval_token=approval_token,
|
||||
vehicle_id=vehicle_id,
|
||||
simulation_fault=simulation_fault,
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=hardware_access_bypass,
|
||||
control_transport=control_transport,
|
||||
_direct_can_batch=direct_can_batch,
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
utterance: str,
|
||||
@@ -1420,6 +1464,7 @@ class SeatDemoService:
|
||||
_model_parse_source: str = "cloud_model",
|
||||
_allow_empty_control_result: bool = False,
|
||||
_on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
|
||||
_direct_can_batch: tuple[list[str], 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
|
||||
@@ -1444,6 +1489,28 @@ class SeatDemoService:
|
||||
_model_parse_source,
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
)
|
||||
if control_transport == WIRELESS_MQTT_TRANSPORT:
|
||||
# The head unit owns both physical CAN channels in wireless mode.
|
||||
# Local T2G/ZLG hardware access, firmware profiles and the local
|
||||
# physical-bench lease are unrelated to MQTT relay execution.
|
||||
return self._process_once(
|
||||
utterance,
|
||||
provider,
|
||||
conversation_id,
|
||||
execution_mode,
|
||||
approval_token,
|
||||
vehicle_id,
|
||||
simulation_fault,
|
||||
reasoning_effort,
|
||||
control_transport,
|
||||
_local_preview,
|
||||
_excluded_control_signatures,
|
||||
_model_parse_source,
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
)
|
||||
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
|
||||
control_transport
|
||||
@@ -1464,6 +1531,7 @@ class SeatDemoService:
|
||||
_model_parse_source,
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
)
|
||||
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
||||
with self.hardware_execution.reserve():
|
||||
@@ -1483,6 +1551,7 @@ class SeatDemoService:
|
||||
_model_parse_source,
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
)
|
||||
|
||||
def _process_once(
|
||||
@@ -1501,6 +1570,7 @@ class SeatDemoService:
|
||||
model_parse_source: str = "cloud_model",
|
||||
allow_empty_control_result: bool = False,
|
||||
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
|
||||
direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
control_transport = self._validate_control_transport(control_transport)
|
||||
utterance = utterance.strip()
|
||||
@@ -1569,9 +1639,13 @@ class SeatDemoService:
|
||||
vehicle_profile,
|
||||
)
|
||||
context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id)
|
||||
matched_modes = self.mode_profiles.match_text(
|
||||
utterance,
|
||||
vehicle_id=vehicle_profile.id,
|
||||
matched_modes = (
|
||||
[]
|
||||
if direct_can_batch is not None
|
||||
else self.mode_profiles.match_text(
|
||||
utterance,
|
||||
vehicle_id=vehicle_profile.id,
|
||||
)
|
||||
)
|
||||
traces: list[dict[str, Any]] = []
|
||||
result: dict[str, Any] = {
|
||||
@@ -1614,6 +1688,9 @@ class SeatDemoService:
|
||||
for item in context.get("presets", [])
|
||||
],
|
||||
"trace_events": traces,
|
||||
"can_commands": (
|
||||
list(direct_can_batch[0]) if direct_can_batch is not None else []
|
||||
),
|
||||
}
|
||||
|
||||
started = time.perf_counter()
|
||||
@@ -1652,40 +1729,45 @@ class SeatDemoService:
|
||||
result,
|
||||
traces,
|
||||
)
|
||||
parsed, provider_used, provider_notice = self._interpret(
|
||||
utterance,
|
||||
provider,
|
||||
context,
|
||||
vehicle_profile,
|
||||
reasoning_effort=reasoning_effort,
|
||||
local_preview=local_preview,
|
||||
model_parse_source=model_parse_source,
|
||||
)
|
||||
if self._is_dynamic_mode_stop_request(utterance):
|
||||
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
|
||||
if direct_can_batch is not None:
|
||||
parsed = deepcopy(direct_can_batch[1])
|
||||
provider_used = "direct_can_control"
|
||||
provider_notice = None
|
||||
else:
|
||||
parsed = self._apply_mode_profiles(
|
||||
parsed, matched_modes, context, vehicle_profile
|
||||
parsed, provider_used, provider_notice = self._interpret(
|
||||
utterance,
|
||||
provider,
|
||||
context,
|
||||
vehicle_profile,
|
||||
reasoning_effort=reasoning_effort,
|
||||
local_preview=local_preview,
|
||||
model_parse_source=model_parse_source,
|
||||
)
|
||||
if self._is_dynamic_mode_stop_request(utterance):
|
||||
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
|
||||
else:
|
||||
parsed = self._apply_mode_profiles(
|
||||
parsed, matched_modes, context, vehicle_profile
|
||||
)
|
||||
parsed = self._mark_control_parse_source(parsed, "local_rule")
|
||||
relative_resolution = (
|
||||
self.native_can_relative_resolver.resolve(utterance, context)
|
||||
if self.native_can_relative_resolver.matches(utterance)
|
||||
else None
|
||||
)
|
||||
parsed = merge_native_can_intent(
|
||||
parsed,
|
||||
utterance,
|
||||
relative_actions=(
|
||||
relative_resolution.actions if relative_resolution else None
|
||||
),
|
||||
relative_ambiguities=(
|
||||
relative_resolution.ambiguities if relative_resolution else None
|
||||
),
|
||||
relative_claimed_controls=(
|
||||
relative_resolution.claimed_controls if relative_resolution else None
|
||||
),
|
||||
)
|
||||
parsed = self._mark_control_parse_source(parsed, "local_rule")
|
||||
relative_resolution = (
|
||||
self.native_can_relative_resolver.resolve(utterance, context)
|
||||
if self.native_can_relative_resolver.matches(utterance)
|
||||
else None
|
||||
)
|
||||
parsed = merge_native_can_intent(
|
||||
parsed,
|
||||
utterance,
|
||||
relative_actions=(
|
||||
relative_resolution.actions if relative_resolution else None
|
||||
),
|
||||
relative_ambiguities=(
|
||||
relative_resolution.ambiguities if relative_resolution else None
|
||||
),
|
||||
relative_claimed_controls=(
|
||||
relative_resolution.claimed_controls if relative_resolution else None
|
||||
),
|
||||
)
|
||||
if local_preview:
|
||||
parsed["ambiguities"] = []
|
||||
parsed["unresolved_spans"] = []
|
||||
@@ -2136,7 +2218,8 @@ class SeatDemoService:
|
||||
compile_started = time.perf_counter()
|
||||
try:
|
||||
if hardware_request:
|
||||
self.hardware_settings.validate_function_config(function_config)
|
||||
if control_transport != WIRELESS_MQTT_TRANSPORT:
|
||||
self.hardware_settings.validate_function_config(function_config)
|
||||
command_plan = self.hardware_compiler.compile(
|
||||
function_config, context["devices"]
|
||||
)
|
||||
@@ -2199,7 +2282,11 @@ class SeatDemoService:
|
||||
"safety_check",
|
||||
"succeeded",
|
||||
(
|
||||
(
|
||||
"无线 MQTT CAN0 座椅语义白名单与 seat-ota 报文白名单已通过;"
|
||||
"本地 T2G 固件 profile、USB-CAN 与物理台架连接不参与校验。"
|
||||
if hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
else (
|
||||
"硬件启用、授权令牌、固件 profile 与目标座椅配置门禁已通过;"
|
||||
if self.hardware_settings.approval_required
|
||||
else "硬件启用、调试免令牌、固件 profile 与目标座椅配置门禁已通过;"
|
||||
@@ -2220,15 +2307,26 @@ class SeatDemoService:
|
||||
simulation_fault if simulated_hardware_request else None
|
||||
),
|
||||
"position_calibration_gate": (
|
||||
"runtime_can_preflight" if hardware_request else "not_applicable"
|
||||
"remote_seat_ota_feedback"
|
||||
if hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
else "runtime_can_preflight"
|
||||
if hardware_request
|
||||
else "not_applicable"
|
||||
),
|
||||
"mapping_status": (
|
||||
"protocol_hil_verified_combined_firmware_hil_pending"
|
||||
"mqtt_can0_seat_ota_whitelist"
|
||||
if hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
else "protocol_hil_verified_combined_firmware_hil_pending"
|
||||
if hardware_request
|
||||
else "draft"
|
||||
),
|
||||
"firmware_profiles": (
|
||||
sorted(self.hardware_settings.profiles) if hardware_request else []
|
||||
sorted(self.hardware_settings.profiles)
|
||||
if hardware_request
|
||||
and control_transport != WIRELESS_MQTT_TRANSPORT
|
||||
else []
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -2249,9 +2347,6 @@ class SeatDemoService:
|
||||
command_plan, function_config, context["devices"]
|
||||
)
|
||||
if vehicle_safety_preflight is not None:
|
||||
execution_report["spec"]["vehicle_safety_preflight"] = (
|
||||
vehicle_safety_preflight
|
||||
)
|
||||
for item in execution_report["spec"].get("results", []):
|
||||
item.setdefault("telemetry", {})[
|
||||
"vehicle_safety_preflight"
|
||||
@@ -4045,10 +4140,15 @@ class SeatDemoService:
|
||||
|
||||
compile_started = time.perf_counter()
|
||||
plans: dict[str, dict[str, Any]] = {}
|
||||
wireless_hardware_request = (
|
||||
hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
)
|
||||
for domain, config in configs.items():
|
||||
if domain == "seat":
|
||||
if hardware_request:
|
||||
self.hardware_settings.validate_function_config(config)
|
||||
if not wireless_hardware_request:
|
||||
self.hardware_settings.validate_function_config(config)
|
||||
plans[domain] = self.hardware_compiler.compile(
|
||||
config,
|
||||
context["devices"],
|
||||
@@ -4225,13 +4325,10 @@ class SeatDemoService:
|
||||
"labels": {
|
||||
"simulation": "mixed" if hardware_request else "true",
|
||||
"seat_feedback": (
|
||||
"wireless_mqtt_ch1"
|
||||
if (
|
||||
hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
and native_config_keys
|
||||
and "seat" not in configs
|
||||
)
|
||||
"wireless_mqtt_ch0"
|
||||
if wireless_hardware_request and "seat" in configs
|
||||
else "wireless_mqtt_ch1"
|
||||
if wireless_hardware_request and native_config_keys
|
||||
else "physical_can_ch1"
|
||||
if hardware_request and native_config_keys and "seat" not in configs
|
||||
else "physical_can"
|
||||
@@ -4276,13 +4373,9 @@ class SeatDemoService:
|
||||
"execute",
|
||||
"succeeded" if execution_succeeded and not report_issues else "failed",
|
||||
(
|
||||
"无线 MQTT 白名单控制帧已提交;车辆实际状态以状态话题回读为准。"
|
||||
if (
|
||||
hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
and native_config_keys
|
||||
and execution_succeeded
|
||||
)
|
||||
"无线 MQTT CAN0/CAN1 白名单控制帧已提交;"
|
||||
"车辆实际状态以状态话题回读为准。"
|
||||
if wireless_hardware_request and execution_succeeded
|
||||
else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。"
|
||||
if hardware_request and native_config_keys and execution_succeeded
|
||||
else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。"
|
||||
|
||||
@@ -23,6 +23,22 @@ FUZZY_UNMAPPED_CONTROL = re.compile(
|
||||
r"(?P<text>(?:座椅|靠背|腿托|空调|车窗|风口|灯光?)"
|
||||
r"(?:调|弄|搞)(?:得|到)?(?:舒服|合适|好)(?:一点|点|些)?)"
|
||||
)
|
||||
OUTLET_MODE_DISABLE = re.compile(
|
||||
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
|
||||
r"(?P<feature>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?|"
|
||||
r"(?P<feature_after>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?"
|
||||
r"(?:关闭|关掉|停止|取消)"
|
||||
)
|
||||
POSITIVE_OUTLET_MODE = re.compile(
|
||||
r"(?:打开|开启|启用|切换到|切换为|改为|设为|设置为|调整为|调为)"
|
||||
r"(?:空调)?(?:的)?(?:吹面|吹脸|吹脚|除霜)"
|
||||
)
|
||||
DRIVER_SEAT_THERMAL_MIDDLE = re.compile(
|
||||
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
|
||||
r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
|
||||
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
|
||||
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
|
||||
)
|
||||
|
||||
|
||||
def _span(utterance: str, match: re.Match[str]) -> dict[str, Any]:
|
||||
@@ -114,6 +130,21 @@ def _remove_seat_target(parsed: dict[str, Any], target: str) -> None:
|
||||
]
|
||||
|
||||
|
||||
def _remove_capability(parsed: dict[str, Any], capability: str) -> None:
|
||||
for key in (
|
||||
"actions",
|
||||
"domain_actions",
|
||||
"native_can_actions",
|
||||
"deferred_requests",
|
||||
"model_requests",
|
||||
):
|
||||
parsed[key] = [
|
||||
request
|
||||
for request in parsed.get(key, [])
|
||||
if request.get("capability") != capability
|
||||
]
|
||||
|
||||
|
||||
def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]:
|
||||
"""Preserve negation and unresolved control language without producing CAN."""
|
||||
|
||||
@@ -162,6 +193,100 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
disabled_outlets = list(OUTLET_MODE_DISABLE.finditer(utterance))
|
||||
if disabled_outlets and POSITIVE_OUTLET_MODE.search(utterance) is None:
|
||||
_remove_capability(result, "climate.set_outlet_mode")
|
||||
for match in disabled_outlets:
|
||||
feature = match.group("feature") or match.group("feature_after")
|
||||
constraints.append(
|
||||
{
|
||||
"type": "prohibit_action",
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_outlet_mode",
|
||||
"parameters": {"disabled_feature": feature},
|
||||
"source_span": _span(utterance, match),
|
||||
}
|
||||
)
|
||||
unresolved.append(
|
||||
{
|
||||
"text": match.group(0),
|
||||
"reason": "outlet_feature_disable_requires_absolute_mode",
|
||||
}
|
||||
)
|
||||
result["ambiguities"] = [
|
||||
*result.get("ambiguities", []),
|
||||
{
|
||||
"field": "climate.outlet_mode",
|
||||
"question": (
|
||||
"当前CAN白名单只能设置完整出风模式;请明确改为吹脚、除霜等目标模式。"
|
||||
),
|
||||
"reason": "outlet_feature_disable_requires_absolute_mode",
|
||||
},
|
||||
]
|
||||
result["decision_summary"] = [
|
||||
*result.get("decision_summary", []),
|
||||
"检测到关闭单一出风方向的请求;未将其错误转换为开启该出风模式。",
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE.search(utterance)
|
||||
if thermal_middle is not None and not re.search(
|
||||
r"副驾|副驾驶|乘客|右前", utterance
|
||||
):
|
||||
feature = "通风" if "通风" in thermal_middle.group(0) else "加热"
|
||||
native_capability = (
|
||||
"seat.set_driver_ventilation_native"
|
||||
if feature == "通风"
|
||||
else "seat.set_driver_heating_native"
|
||||
)
|
||||
canonical_capability = (
|
||||
"seat.set_ventilation"
|
||||
if feature == "通风"
|
||||
else "seat.set_heating"
|
||||
)
|
||||
_remove_capability(result, native_capability)
|
||||
_remove_capability(result, canonical_capability)
|
||||
constraint = {
|
||||
"type": "unsupported_discrete_level",
|
||||
"domain": "seat",
|
||||
"capability": native_capability,
|
||||
"parameters": {"requested_level": "medium"},
|
||||
"source_span": _span(utterance, thermal_middle),
|
||||
}
|
||||
if not any(
|
||||
item.get("type") == constraint["type"]
|
||||
and item.get("capability") == native_capability
|
||||
and item.get("parameters") == constraint["parameters"]
|
||||
for item in constraints
|
||||
):
|
||||
constraints.append(constraint)
|
||||
unresolved.append(
|
||||
{
|
||||
"text": thermal_middle.group(0),
|
||||
"reason": "driver_seat_thermal_middle_level_unsupported",
|
||||
}
|
||||
)
|
||||
result["ambiguities"] = [
|
||||
*result.get("ambiguities", []),
|
||||
{
|
||||
"field": (
|
||||
"seat.driver.ventilation.level"
|
||||
if feature == "通风"
|
||||
else "seat.driver.heating.level"
|
||||
),
|
||||
"question": (
|
||||
f"主驾座椅{feature}实车白名单只有低档和高档,"
|
||||
"请明确选择低档或高档。"
|
||||
),
|
||||
"reason": "driver_seat_thermal_middle_level_unsupported",
|
||||
},
|
||||
]
|
||||
result["decision_summary"] = [
|
||||
*result.get("decision_summary", []),
|
||||
f"识别到主驾座椅{feature}中档请求;实车协议不支持中档,未降级为其他档位。",
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
if not result.get("actions") and not result.get("domain_actions"):
|
||||
for match in FUZZY_UNMAPPED_CONTROL.finditer(utterance):
|
||||
if not any(item.get("text") == match.group("text") for item in unresolved):
|
||||
|
||||
Reference in New Issue
Block a user