1285 lines
50 KiB
Python
Executable File
1285 lines
50 KiB
Python
Executable File
"""Deterministic fallback parser for supported seat-control vocabulary."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
import unicodedata
|
||
from copy import deepcopy
|
||
from typing import Any
|
||
|
||
|
||
TARGET_PATTERN = re.compile(
|
||
r"主副驾|前排右侧|右前座椅|右前座|副驾驶座椅|副驾驶座|副驾驶|副驾位|"
|
||
r"乘客座椅|乘客座|乘客位|主驾驶座椅|主驾驶座|主驾驶|驾驶位|驾驶座|"
|
||
r"司机位|司机座|主驾|前排|副驾"
|
||
)
|
||
FEATURE_PATTERN = re.compile(r"通风|加热")
|
||
STATE_PATTERN = re.compile(
|
||
r"设置为|调到|调至|设为|开到|关闭|关掉|停止|取消|打开|开启|启动|开"
|
||
)
|
||
LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档")
|
||
POSITION_PATTERN_TEMPLATE = (
|
||
r"{alias}(?:位置)?(?:调到|移动到|移到|设为|设置为|调至|移动至|到|为)?"
|
||
r"(?P<position>[+-]?\d{{1,3}}(?:\.\d+)?)%"
|
||
)
|
||
ANGLE_PATTERN_TEMPLATE = (
|
||
r"{alias}(?:夹角|角度)?(?:调到|放倒到|抬到|移动到|移到|设为|设置为|"
|
||
r"调至|到|为)?(?P<angle>\d{{2,3}})(?:度|°)"
|
||
)
|
||
RELATIVE_ANGLE_PATTERN_TEMPLATE = (
|
||
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
|
||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})(?:度|°)"
|
||
)
|
||
RELATIVE_PERCENT_PATTERN_TEMPLATE = (
|
||
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})%"
|
||
)
|
||
TIMED_MOTION_PATTERN_TEMPLATE = (
|
||
r"{alias}(?:持续|保持)?(?:再|继续|接着|然后)?"
|
||
r"(?P<direction>向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|"
|
||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
|
||
r"(?P<duration>\d+(?:\.\d+)?)(?P<unit>毫秒|ms|秒|s|分钟|分|min)"
|
||
)
|
||
ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位")
|
||
VENTILATION_FEEDBACK_PATTERN = re.compile(r"(?<!不)(?<!加)热|(?<!不)闷|出汗")
|
||
HEATING_FEEDBACK_PATTERN = re.compile(r"(?<!不)冷|(?<!不)凉")
|
||
MILD_PATTERN = re.compile(r"有点|一点|稍微|微微")
|
||
STRONG_PATTERN = re.compile(r"很|太|非常|特别|难受")
|
||
AIRFLOW_CAUSE_PATTERN = re.compile(r"吹|风太大|风吹")
|
||
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
|
||
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
|
||
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
|
||
UNSUPPORTED_SEAT_SCOPE_PATTERN = re.compile(
|
||
r"后排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
|
||
r"右后座椅|右后座|右后|左后座椅|左后座|左后|"
|
||
r"所有座椅|全部座椅|全车座椅"
|
||
)
|
||
UNSUPPORTED_SEAT_SCOPE_QUESTION = (
|
||
"当前台架仅支持副驾座椅,不能把后排、其他方位或全部座椅请求默认到副驾。"
|
||
)
|
||
|
||
TARGETS = {
|
||
"主驾": ["driver"],
|
||
"驾驶位": ["driver"],
|
||
"司机位": ["driver"],
|
||
"主驾驶": ["driver"],
|
||
"主驾驶座": ["driver"],
|
||
"主驾驶座椅": ["driver"],
|
||
"驾驶座": ["driver"],
|
||
"司机座": ["driver"],
|
||
"副驾": ["passenger"],
|
||
"副驾驶": ["passenger"],
|
||
"副驾驶座": ["passenger"],
|
||
"副驾驶座椅": ["passenger"],
|
||
"副驾位": ["passenger"],
|
||
"乘客位": ["passenger"],
|
||
"乘客座": ["passenger"],
|
||
"乘客座椅": ["passenger"],
|
||
"右前座": ["passenger"],
|
||
"右前座椅": ["passenger"],
|
||
"前排右侧": ["passenger"],
|
||
"主副驾": ["driver", "passenger"],
|
||
"前排": ["driver", "passenger"],
|
||
}
|
||
FEATURES = {"通风": "ventilation", "加热": "heating"}
|
||
STATES = {
|
||
"打开": "on",
|
||
"开启": "on",
|
||
"启动": "on",
|
||
"开": "on",
|
||
"开到": "on",
|
||
"调到": "on",
|
||
"调至": "on",
|
||
"设置为": "on",
|
||
"设为": "on",
|
||
"关闭": "off",
|
||
"关掉": "off",
|
||
"停止": "off",
|
||
"取消": "off",
|
||
}
|
||
LEVELS = {
|
||
"一": 1,
|
||
"二": 2,
|
||
"三": 3,
|
||
"四": 4,
|
||
"五": 5,
|
||
"最低": 1,
|
||
"低": 1,
|
||
"中": 2,
|
||
"最高": 3,
|
||
"高": 3,
|
||
}
|
||
MOTION_AXIS_ALIASES = {
|
||
"longitudinal": ("水平", "前后", "纵向", "滑轨"),
|
||
# Internal capability name is retained for compatibility. On the
|
||
# seat-ota CAN0 firmware this board axis is the physical seat-height axis.
|
||
"cushion_tilt": ("座椅高度", "高度", "坐垫倾角", "坐盆", "坐垫"),
|
||
"backrest_recline": ("靠背", "椅背"),
|
||
"legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"),
|
||
}
|
||
MOTION_ANGLE_RANGES = {
|
||
"backrest_recline": (95, 163),
|
||
"legrest_height": (95, 167),
|
||
}
|
||
CHINESE_PERCENT_PATTERN = re.compile(r"百分之([零一二三四五六七八九十百两\d]+)")
|
||
CHINESE_DEGREE_PATTERN = re.compile(r"([零一二三四五六七八九十百两]+)(度)")
|
||
CHINESE_DURATION_PATTERN = re.compile(
|
||
r"([零一二三四五六七八九十百两]+)(毫秒|秒|分钟|分)"
|
||
)
|
||
RELATIVE_DIRECTION_SIGNS = {
|
||
"longitudinal": {
|
||
"increase": {"向后", "往后", "朝后", "后移", "后"},
|
||
"decrease": {"向前", "往前", "朝前", "前移", "前"},
|
||
},
|
||
"cushion_tilt": {
|
||
"increase": {"向下", "往下", "降低", "降下", "下"},
|
||
"decrease": {"向上", "往上", "抬高", "抬起", "升高", "上"},
|
||
},
|
||
"backrest_recline": {
|
||
"increase": {"向后", "往后", "朝后", "后仰", "后"},
|
||
"decrease": {"向前", "往前", "朝前", "前倾", "前"},
|
||
},
|
||
"legrest_height": {
|
||
"increase": {"向上", "往上", "抬高", "抬起", "升高", "上"},
|
||
"decrease": {"向下", "往下", "降低", "降下", "下"},
|
||
},
|
||
}
|
||
TIMED_MOTION_MIN_DURATION_MS = 300
|
||
TIMED_AXIS_MAX_DURATION_MS = {
|
||
"longitudinal": 30_000,
|
||
"cushion_tilt": 20_000,
|
||
"backrest_recline": 30_000,
|
||
"legrest_height": 25_000,
|
||
}
|
||
TIMED_AXIS_MS_PER_PCT = {
|
||
"longitudinal": 120,
|
||
"cushion_tilt": 80,
|
||
"backrest_recline": 120,
|
||
"legrest_height": 100,
|
||
}
|
||
MOTION_AXIS_LABELS = {
|
||
"longitudinal": "水平轴",
|
||
"cushion_tilt": "座椅高度轴",
|
||
"backrest_recline": "靠背轴",
|
||
"legrest_height": "腿托轴",
|
||
}
|
||
MOTION_ENDPOINT_LABELS = {
|
||
"longitudinal": {"increase": "最后方", "decrease": "最前方"},
|
||
"cushion_tilt": {"increase": "最下方", "decrease": "最上方"},
|
||
"backrest_recline": {"increase": "最后方", "decrease": "最前方"},
|
||
"legrest_height": {"increase": "最上方", "decrease": "最下方"},
|
||
}
|
||
|
||
|
||
def _chinese_integer(token: str) -> int | None:
|
||
if token.isdigit():
|
||
return int(token)
|
||
digits = {
|
||
"零": 0,
|
||
"一": 1,
|
||
"二": 2,
|
||
"两": 2,
|
||
"三": 3,
|
||
"四": 4,
|
||
"五": 5,
|
||
"六": 6,
|
||
"七": 7,
|
||
"八": 8,
|
||
"九": 9,
|
||
}
|
||
total = 0
|
||
current = 0
|
||
for char in token:
|
||
if char in digits:
|
||
current = digits[char]
|
||
elif char == "十":
|
||
total += max(current, 1) * 10
|
||
current = 0
|
||
elif char == "百":
|
||
total += max(current, 1) * 100
|
||
current = 0
|
||
else:
|
||
return None
|
||
return total + current
|
||
|
||
|
||
def _normalize_text(value: str) -> str:
|
||
text = unicodedata.normalize("NFKC", value.strip())
|
||
text = text.replace("半分钟", "0.5分钟").replace("半秒", "0.5秒")
|
||
text = re.sub(r"座椅(?:吹风|送风)", "座椅通风", text)
|
||
text = re.sub(r"座椅(?:取暖|加温)", "座椅加热", text)
|
||
text = text.replace("座椅暖一下", "座椅加热打开")
|
||
|
||
def replace_percent(match: re.Match[str]) -> str:
|
||
number = _chinese_integer(match.group(1))
|
||
return match.group(0) if number is None else f"{number}%"
|
||
|
||
text = CHINESE_PERCENT_PATTERN.sub(replace_percent, text)
|
||
|
||
def replace_degree(match: re.Match[str]) -> str:
|
||
number = _chinese_integer(match.group(1))
|
||
return match.group(0) if number is None else f"{number}{match.group(2)}"
|
||
|
||
text = CHINESE_DEGREE_PATTERN.sub(replace_degree, text)
|
||
|
||
def replace_duration(match: re.Match[str]) -> str:
|
||
number = _chinese_integer(match.group(1))
|
||
return match.group(0) if number is None else f"{number}{match.group(2)}"
|
||
|
||
text = CHINESE_DURATION_PATTERN.sub(replace_duration, text)
|
||
return re.sub(r"\s+", "", text)
|
||
|
||
|
||
def _relative_direction(axis: str, token: str) -> str | None:
|
||
directions = RELATIVE_DIRECTION_SIGNS[axis]
|
||
for operation, tokens in directions.items():
|
||
if token in tokens:
|
||
return operation
|
||
return None
|
||
|
||
|
||
def _context_motion_state(
|
||
context: dict[str, Any] | None, axis: str
|
||
) -> dict[str, Any] | None:
|
||
if not context:
|
||
return None
|
||
state = (
|
||
context.get("devices", {})
|
||
.get("passenger", {})
|
||
.get("position", {})
|
||
.get(axis)
|
||
)
|
||
return state if isinstance(state, dict) else None
|
||
|
||
|
||
def _motor_range(
|
||
context: dict[str, Any] | None, axis: str
|
||
) -> tuple[float, tuple[float, float] | None]:
|
||
motor = (
|
||
(context or {})
|
||
.get("vehicle", {})
|
||
.get("seat", {})
|
||
.get("motors", {})
|
||
.get(axis, {})
|
||
)
|
||
configured_range = motor.get("range") if isinstance(motor, dict) else None
|
||
angle_range = MOTION_ANGLE_RANGES.get(axis)
|
||
if angle_range is not None:
|
||
display_angle = motor.get("display_angle") if isinstance(motor, dict) else None
|
||
if isinstance(display_angle, dict):
|
||
zero = display_angle.get("zero_deg")
|
||
maximum = display_angle.get("max_deg")
|
||
if isinstance(zero, (int, float)) and isinstance(maximum, (int, float)):
|
||
angle_range = (float(zero), float(maximum))
|
||
if isinstance(configured_range, (int, float)) and configured_range > 0:
|
||
return float(configured_range), angle_range
|
||
if angle_range is not None:
|
||
return float(angle_range[1] - angle_range[0]), angle_range
|
||
return 100.0, None
|
||
|
||
|
||
def _clean_number(value: float) -> int | float:
|
||
return int(value) if float(value).is_integer() else round(float(value), 1)
|
||
|
||
|
||
def _duration_ms(value: str, unit: str) -> int:
|
||
multiplier = {
|
||
"毫秒": 1,
|
||
"ms": 1,
|
||
"秒": 1_000,
|
||
"s": 1_000,
|
||
"分钟": 60_000,
|
||
"分": 60_000,
|
||
"min": 60_000,
|
||
}[unit]
|
||
return round(float(value) * multiplier)
|
||
|
||
|
||
def _format_seconds(duration_ms: int) -> str:
|
||
seconds = duration_ms / 1_000
|
||
return f"{seconds:g}"
|
||
|
||
|
||
def _round_half_away_from_zero(value: float) -> int:
|
||
"""Use the integer a user normally expects from a .5 position request."""
|
||
|
||
return math.floor(value + 0.5) if value >= 0 else math.ceil(value - 0.5)
|
||
|
||
|
||
def _format_numeric_request(value: float) -> str:
|
||
return f"{value:g}"
|
||
|
||
|
||
def _timed_motion_action(
|
||
axis: str,
|
||
direction_token: str,
|
||
requested_duration_ms: int,
|
||
context: dict[str, Any] | None,
|
||
) -> tuple[dict[str, Any] | None, str | None]:
|
||
operation = _relative_direction(axis, direction_token)
|
||
if operation is None:
|
||
return None, f"{direction_token}不适用于该座椅轴,请确认移动方向。"
|
||
if requested_duration_ms < 0:
|
||
return None, "座椅按时长移动的时长不能为负数。"
|
||
state = _context_motion_state(context, axis)
|
||
if state is None:
|
||
return None, "缺少当前座椅位置,无法安全计算按时长移动的方向和备用端点。"
|
||
current_pct = state.get("position_pct")
|
||
if (
|
||
not isinstance(current_pct, (int, float))
|
||
or isinstance(current_pct, bool)
|
||
or not 0 <= float(current_pct) <= 100
|
||
):
|
||
return None, "当前座椅位置无效,无法安全执行按时长移动。"
|
||
|
||
maximum_duration_ms = TIMED_AXIS_MAX_DURATION_MS[axis]
|
||
applied_duration_ms = min(
|
||
maximum_duration_ms,
|
||
max(TIMED_MOTION_MIN_DURATION_MS, requested_duration_ms),
|
||
)
|
||
duration_clamped = applied_duration_ms != requested_duration_ms
|
||
estimated_delta_pct = applied_duration_ms / TIMED_AXIS_MS_PER_PCT[axis]
|
||
signed_delta = estimated_delta_pct if operation == "increase" else -estimated_delta_pct
|
||
raw_target = float(current_pct) + signed_delta
|
||
target_pct = round(max(0.0, min(100.0, raw_target)))
|
||
endpoint_expected = None
|
||
if raw_target >= 100:
|
||
endpoint_expected = "far"
|
||
elif raw_target <= 0:
|
||
endpoint_expected = "near"
|
||
|
||
motion_intent: dict[str, Any] = {
|
||
"mode": "timed",
|
||
"direction": operation,
|
||
"start_position_pct": round(float(current_pct)),
|
||
"delta_position_pct": abs(target_pct - round(float(current_pct))),
|
||
"requested_duration_ms": requested_duration_ms,
|
||
"applied_duration_ms": applied_duration_ms,
|
||
"max_duration_ms": maximum_duration_ms,
|
||
"min_duration_ms": TIMED_MOTION_MIN_DURATION_MS,
|
||
"duration_clamped": duration_clamped,
|
||
}
|
||
if endpoint_expected is not None:
|
||
motion_intent["endpoint_expected"] = endpoint_expected
|
||
|
||
action: dict[str, Any] = {
|
||
"target": "passenger",
|
||
"action_type": "position",
|
||
"axis": axis,
|
||
"position_pct": target_pct,
|
||
"motion_intent": motion_intent,
|
||
}
|
||
_, angle_range = _motor_range(context, axis)
|
||
if angle_range is not None:
|
||
target_angle = angle_range[0] + (
|
||
angle_range[1] - angle_range[0]
|
||
) * target_pct / 100
|
||
action["angle_deg"] = _clean_number(target_angle)
|
||
|
||
axis_label = MOTION_AXIS_LABELS[axis]
|
||
if requested_duration_ms > maximum_duration_ms:
|
||
action["user_notice"] = (
|
||
f"{axis_label}最大允许时长为{_format_seconds(maximum_duration_ms)}秒;"
|
||
f"已将用户请求的{_format_seconds(requested_duration_ms)}秒钳制为"
|
||
f"{_format_seconds(applied_duration_ms)}秒并继续执行。"
|
||
)
|
||
elif requested_duration_ms < TIMED_MOTION_MIN_DURATION_MS:
|
||
action["user_notice"] = (
|
||
f"{axis_label}最小允许时长为{_format_seconds(TIMED_MOTION_MIN_DURATION_MS)}秒;"
|
||
f"已按{_format_seconds(applied_duration_ms)}秒继续执行。"
|
||
)
|
||
else:
|
||
action["user_notice"] = (
|
||
f"{axis_label}将按时长执行{_format_seconds(applied_duration_ms)}秒。"
|
||
)
|
||
if endpoint_expected is not None:
|
||
endpoint_label = MOTION_ENDPOINT_LABELS[axis][operation]
|
||
action["user_notice"] += f" 若提前触达机械端点,将在{endpoint_label}停止并按成功返回。"
|
||
return action, None
|
||
|
||
|
||
def _relative_motion_action(
|
||
axis: str,
|
||
direction_token: str,
|
||
delta: int,
|
||
unit: str,
|
||
context: dict[str, Any] | None,
|
||
) -> tuple[dict[str, Any] | None, str | None]:
|
||
operation = _relative_direction(axis, direction_token)
|
||
if operation is None:
|
||
return None, f"{direction_token}不适用于该座椅轴,请确认移动方向。"
|
||
if delta <= 0:
|
||
return None, "座椅相对移动量必须大于0。"
|
||
state = _context_motion_state(context, axis)
|
||
if state is None:
|
||
return None, "缺少当前座椅位置,无法安全计算相对移动目标。"
|
||
current_pct = state.get("position_pct")
|
||
if (
|
||
not isinstance(current_pct, (int, float))
|
||
or isinstance(current_pct, bool)
|
||
or not 0 <= float(current_pct) <= 100
|
||
):
|
||
return None, "当前座椅位置无效,无法安全计算相对移动目标。"
|
||
|
||
physical_range, angle_range = _motor_range(context, axis)
|
||
if unit == "degree" and angle_range is None:
|
||
return None, "该座椅轴没有角度标定,不能按角度执行相对移动。"
|
||
delta_pct = float(delta) if unit == "percent" else delta / physical_range * 100
|
||
signed_delta = delta_pct if operation == "increase" else -delta_pct
|
||
raw_target = float(current_pct) + signed_delta
|
||
clamped = not 0 <= raw_target <= 100
|
||
target_pct = round(max(0.0, min(100.0, raw_target)))
|
||
if target_pct == round(float(current_pct)) and not clamped:
|
||
return None, "相对移动量换算后小于该座椅轴的最小可执行步长。"
|
||
|
||
motion_intent: dict[str, Any] = {
|
||
"mode": "relative",
|
||
"direction": operation,
|
||
"start_position_pct": round(float(current_pct)),
|
||
"delta_position_pct": abs(target_pct - round(float(current_pct))),
|
||
}
|
||
action: dict[str, Any] = {
|
||
"target": "passenger",
|
||
"action_type": "position",
|
||
"axis": axis,
|
||
"position_pct": target_pct,
|
||
"motion_intent": motion_intent,
|
||
}
|
||
if unit == "degree" and angle_range is not None:
|
||
current_angle = state.get("angle_deg")
|
||
if not isinstance(current_angle, (int, float)) or isinstance(
|
||
current_angle, bool
|
||
):
|
||
current_angle = angle_range[0] + (
|
||
angle_range[1] - angle_range[0]
|
||
) * float(current_pct) / 100
|
||
signed_angle = float(delta) if operation == "increase" else -float(delta)
|
||
raw_target_angle = float(current_angle) + signed_angle
|
||
target_angle = max(angle_range[0], min(angle_range[1], raw_target_angle))
|
||
clamped = clamped or target_angle != raw_target_angle
|
||
action["angle_deg"] = _clean_number(target_angle)
|
||
motion_intent["start_angle_deg"] = float(current_angle)
|
||
motion_intent["delta_angle_deg"] = abs(target_angle - float(current_angle))
|
||
if clamped:
|
||
motion_intent["requested_delta_angle_deg"] = float(delta)
|
||
if clamped:
|
||
motion_intent["range_clamped"] = True
|
||
motion_intent["requested_delta_position_pct"] = round(abs(delta_pct))
|
||
endpoint_label = MOTION_ENDPOINT_LABELS[axis][operation]
|
||
requested_value = f"{delta}度" if unit == "degree" else f"{delta}%"
|
||
applied_value = (
|
||
f"{_clean_number(motion_intent.get('delta_angle_deg', 0))}度"
|
||
if unit == "degree"
|
||
else f"{motion_intent['delta_position_pct']}%"
|
||
)
|
||
action["user_notice"] = (
|
||
f"{MOTION_AXIS_LABELS[axis]}{direction_token}移动{requested_value}超过剩余行程;"
|
||
f"已采用备用目标,实际移动最多{applied_value}并到达{endpoint_label}。"
|
||
)
|
||
return action, None
|
||
|
||
|
||
def _nearest_preceding_target(clause: str, position: int) -> str | None:
|
||
candidates = [match for match in TARGET_PATTERN.finditer(clause) if match.start() <= position]
|
||
if candidates:
|
||
return candidates[-1].group(0)
|
||
matches = list(TARGET_PATTERN.finditer(clause))
|
||
return matches[0].group(0) if len(matches) == 1 else None
|
||
|
||
|
||
def _state_for_feature(clause: str, position: int) -> str | None:
|
||
states = list(STATE_PATTERN.finditer(clause))
|
||
if not states:
|
||
return None
|
||
following = [match for match in states if match.start() >= position]
|
||
if following and following[0].start() - position <= 8:
|
||
return STATES[following[0].group(0)]
|
||
if len(states) == 1:
|
||
return STATES[states[0].group(0)]
|
||
preceding = [match for match in states if match.start() < position]
|
||
return STATES[preceding[-1].group(0)] if preceding else None
|
||
|
||
|
||
def _level_for_clause(clause: str) -> int | None:
|
||
match = LEVEL_PATTERN.search(clause)
|
||
if not match:
|
||
return None
|
||
token = match.group(1)
|
||
return int(token) if token.isdigit() else LEVELS[token]
|
||
|
||
|
||
def _context_device(
|
||
context: dict[str, Any] | None, target: str, function: str
|
||
) -> dict[str, Any]:
|
||
if not context:
|
||
return {"state": "off", "level": 0}
|
||
return (
|
||
context.get("devices", {})
|
||
.get(target, {})
|
||
.get(function, {"state": "off", "level": 0})
|
||
)
|
||
|
||
|
||
def _feedback_targets(
|
||
text: str, context: dict[str, Any] | None, parsed: dict[str, Any]
|
||
) -> tuple[list[str], str]:
|
||
matches = list(TARGET_PATTERN.finditer(text))
|
||
if matches:
|
||
targets: list[str] = []
|
||
for match in matches:
|
||
for target in TARGETS[match.group(0)]:
|
||
if target not in targets:
|
||
targets.append(target)
|
||
return targets, "explicit"
|
||
parsed_targets = list(
|
||
dict.fromkeys(
|
||
action.get("target")
|
||
for action in parsed.get("actions", [])
|
||
if action.get("target") in {"driver", "passenger"}
|
||
)
|
||
)
|
||
if parsed_targets:
|
||
return parsed_targets, "model"
|
||
last_target = context.get("last_target") if context else None
|
||
if last_target in {"driver", "passenger"}:
|
||
return [last_target], "history"
|
||
if FIRST_PERSON_PATTERN.search(text):
|
||
return ["passenger"], "passenger_default"
|
||
return ["passenger"], "passenger_default"
|
||
|
||
|
||
def _contains_unsupported_driver_target(text: str) -> bool:
|
||
return any(
|
||
"driver" in TARGETS[match.group(0)]
|
||
for match in TARGET_PATTERN.finditer(text)
|
||
)
|
||
|
||
|
||
def _inferred_level(text: str) -> tuple[int, str]:
|
||
explicit_level = _level_for_clause(text)
|
||
if explicit_level is not None:
|
||
return explicit_level, "explicit"
|
||
if STRONG_PATTERN.search(text):
|
||
return 3, "strong"
|
||
if MILD_PATTERN.search(text):
|
||
return 1, "mild"
|
||
return 2, "normal"
|
||
|
||
|
||
def _decrease_action(
|
||
target: str, function: str, current: dict[str, Any]
|
||
) -> tuple[dict[str, Any], int, int]:
|
||
previous_level = int(current.get("level") or 0)
|
||
if current.get("state") == "on" and previous_level > 1:
|
||
next_level = previous_level - 1
|
||
return (
|
||
{
|
||
"target": target,
|
||
"function": function,
|
||
"state": "on",
|
||
"requested_level": next_level,
|
||
},
|
||
previous_level,
|
||
next_level,
|
||
)
|
||
return (
|
||
{
|
||
"target": target,
|
||
"function": function,
|
||
"state": "off",
|
||
"requested_level": None,
|
||
},
|
||
previous_level,
|
||
0,
|
||
)
|
||
|
||
|
||
def resolve_contextual_intent(
|
||
parsed: dict[str, Any], utterance: str, context: dict[str, Any] | None = None
|
||
) -> dict[str, Any]:
|
||
"""Resolve implicit comfort feedback against the current virtual seat state."""
|
||
|
||
result = deepcopy(parsed)
|
||
text = _normalize_text(utterance)
|
||
ventilation_cues = list(dict.fromkeys(VENTILATION_FEEDBACK_PATTERN.findall(text)))
|
||
cold_cues = list(dict.fromkeys(HEATING_FEEDBACK_PATTERN.findall(text)))
|
||
if not ventilation_cues and not cold_cues:
|
||
return result
|
||
|
||
if _contains_unsupported_driver_target(text):
|
||
result["actions"] = []
|
||
result["ambiguities"] = [
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
]
|
||
result["decision_summary"] = [
|
||
"识别到主驾或包含主驾的座椅范围;当前系统不会将其重定向到副驾。"
|
||
]
|
||
result["confidence"] = max(float(result.get("confidence", 0)), 0.99)
|
||
return result
|
||
|
||
if VEHICLE_SCOPE_PATTERN.search(text) and not TARGET_PATTERN.search(text):
|
||
result["actions"] = []
|
||
result["ambiguities"] = [
|
||
{
|
||
"field": "domain",
|
||
"question": "该请求作用于整车舱空气,需要由空调/新风域执行;当前座椅单域不能代替执行。",
|
||
}
|
||
]
|
||
result["decision_summary"] = [
|
||
"“车上/车内”表示整车舱范围,不是主驾或副驾单座位。",
|
||
"“太闷”属于舱内空气质量/新风需求,应路由到 climate 域。",
|
||
"当前未接入空调执行器,未将整车请求错误下发到任一座椅。",
|
||
]
|
||
result["detected_domains"] = ["climate"]
|
||
result["scope"] = "vehicle"
|
||
result["confidence"] = max(float(result.get("confidence", 0)), 0.96)
|
||
return result
|
||
|
||
targets, target_source = _feedback_targets(text, context, result)
|
||
if not targets:
|
||
if not any(item.get("field") == "target" for item in result["ambiguities"]):
|
||
result["ambiguities"].append(
|
||
{"field": "target", "question": "该舒适反馈需要作用于主驾还是副驾?"}
|
||
)
|
||
return result
|
||
|
||
labels = {
|
||
"driver": "主驾",
|
||
"passenger": "副驾",
|
||
"ventilation": "通风",
|
||
"heating": "加热",
|
||
}
|
||
summaries: list[str] = []
|
||
if target_source == "history":
|
||
summaries.append(f"本轮省略了座位,沿用上一轮的{labels[targets[0]]}目标。")
|
||
elif target_source == "passenger_default":
|
||
summaries.append("当前座椅域仅控制副驾,本轮省略座位并安全限定为副驾。")
|
||
|
||
planned_actions: list[dict[str, Any]] = []
|
||
desired_level, intensity = _inferred_level(text)
|
||
airflow_is_cause = bool(AIRFLOW_CAUSE_PATTERN.search(text))
|
||
is_cold = bool(cold_cues)
|
||
is_hot = any(cue in {"热", "出汗"} for cue in ventilation_cues)
|
||
for target in targets:
|
||
ventilation = _context_device(context, target, "ventilation")
|
||
heating = _context_device(context, target, "heating")
|
||
cue_text = "、".join(cold_cues if is_cold else ventilation_cues)
|
||
summaries.append(f"检测到{labels[target]}“{cue_text}”舒适反馈,开始核对当前执行器状态。")
|
||
|
||
if cold_cues and ventilation_cues and not airflow_is_cause:
|
||
balanced_level = min(desired_level, 3)
|
||
planned_actions.extend(
|
||
[
|
||
{
|
||
"target": target,
|
||
"function": "heating",
|
||
"state": "on",
|
||
"requested_level": balanced_level,
|
||
},
|
||
{
|
||
"target": target,
|
||
"function": "ventilation",
|
||
"state": "on",
|
||
"requested_level": balanced_level,
|
||
},
|
||
]
|
||
)
|
||
summaries[-1] = (
|
||
f"检测到{labels[target]}“{'、'.join(cold_cues + ventilation_cues)}”两个并存的舒适维度,"
|
||
"不将其判定为语义冲突。"
|
||
)
|
||
summaries.append(
|
||
f"冷感由{labels[target]}加热{balanced_level}档补偿,闷感由"
|
||
f"{labels[target]}通风{balanced_level}档缓解;生成可审计的组合目标。"
|
||
)
|
||
continue
|
||
|
||
if is_cold and (airflow_is_cause or ventilation.get("state") == "on"):
|
||
action, previous_level, next_level = _decrease_action(
|
||
target, "ventilation", ventilation
|
||
)
|
||
planned_actions.append(action)
|
||
if airflow_is_cause:
|
||
summaries.append(
|
||
f"“{labels[target]}吹得冷”明确将{labels[target]}通风指定为致冷来源,不映射为加热请求。"
|
||
)
|
||
if previous_level > 1:
|
||
summaries.append(
|
||
f"当前{labels[target]}通风为{previous_level}档,先降低到{next_level}档,不同时调整加热。"
|
||
)
|
||
elif previous_level == 1:
|
||
summaries.append(
|
||
f"当前{labels[target]}通风为1档,冷感更可能由已开启通风造成;"
|
||
"先关闭通风,不立即开启加热。"
|
||
)
|
||
else:
|
||
summaries.append(
|
||
f"当前状态显示{labels[target]}通风已关闭,保持关闭;不会因“吹”反向调整加热。"
|
||
)
|
||
continue
|
||
|
||
if is_cold:
|
||
current_level = int(heating.get("level") or 0)
|
||
if heating.get("state") == "on" and current_level > 0:
|
||
next_level = min(max(current_level + 1, desired_level), 3)
|
||
planned_actions.append(
|
||
{
|
||
"target": target,
|
||
"function": "heating",
|
||
"state": "on",
|
||
"requested_level": next_level,
|
||
}
|
||
)
|
||
summaries.append(
|
||
f"{labels[target]}通风已关闭,加热已开启{current_level}档但仍感到冷,"
|
||
f"将加热提升到{next_level}档。"
|
||
)
|
||
else:
|
||
planned_actions.append(
|
||
{
|
||
"target": target,
|
||
"function": "heating",
|
||
"state": "on",
|
||
"requested_level": desired_level,
|
||
}
|
||
)
|
||
summaries.append(
|
||
f"{labels[target]}通风已关闭且加热未开启,冷感仍存在;"
|
||
f"按{'轻微' if intensity == 'mild' else '当前'}强度开启加热{desired_level}档。"
|
||
)
|
||
continue
|
||
|
||
if is_hot and heating.get("state") == "on":
|
||
action, previous_level, next_level = _decrease_action(target, "heating", heating)
|
||
planned_actions.append(action)
|
||
summaries.append(
|
||
f"当前{labels[target]}加热为{previous_level}档,热感优先视为已开启加热的结果;"
|
||
f"先{'关闭加热' if next_level == 0 else f'降低到{next_level}档'},不立即开启通风。"
|
||
)
|
||
continue
|
||
|
||
current_level = int(ventilation.get("level") or 0)
|
||
if ventilation.get("state") == "on" and current_level > 0:
|
||
next_level = min(max(current_level + 1, desired_level), 3)
|
||
summaries.append(
|
||
f"{labels[target]}通风已开启{current_level}档但舒适问题仍存在,提升到{next_level}档。"
|
||
)
|
||
else:
|
||
next_level = desired_level
|
||
summaries.append(
|
||
f"{labels[target]}没有已开启的加热致因,按当前强度开启通风{next_level}档。"
|
||
)
|
||
planned_actions.append(
|
||
{
|
||
"target": target,
|
||
"function": "ventilation",
|
||
"state": "on",
|
||
"requested_level": next_level,
|
||
}
|
||
)
|
||
|
||
result["actions"] = planned_actions
|
||
result["ambiguities"] = [
|
||
item
|
||
for item in result["ambiguities"]
|
||
if item.get("field") not in {"capability", "state", "target"}
|
||
]
|
||
result["decision_summary"] = summaries
|
||
result["confidence"] = max(float(result.get("confidence", 0)), 0.9)
|
||
return result
|
||
|
||
|
||
def parse_local_intent(
|
||
utterance: str, context: dict[str, Any] | None = None
|
||
) -> dict[str, Any]:
|
||
"""Parse common Chinese seat commands without making hardware assumptions."""
|
||
|
||
text = _normalize_text(utterance)
|
||
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
|
||
actions: list[dict[str, Any]] = []
|
||
motion_actions: list[dict[str, Any]] = []
|
||
ambiguities: list[dict[str, str]] = []
|
||
|
||
unsupported_scope = UNSUPPORTED_SEAT_SCOPE_PATTERN.search(text)
|
||
if unsupported_scope is not None:
|
||
return {
|
||
"actions": [],
|
||
"ambiguities": [
|
||
{
|
||
"field": "target",
|
||
"question": UNSUPPORTED_SEAT_SCOPE_QUESTION,
|
||
}
|
||
],
|
||
"decision_summary": [
|
||
f"识别到未支持的座椅目标范围“{unsupported_scope.group(0)}”,未生成副驾动作。"
|
||
],
|
||
"confidence": 0.99,
|
||
}
|
||
|
||
mentioned_axes = [
|
||
axis
|
||
for axis, aliases in MOTION_AXIS_ALIASES.items()
|
||
if any(alias in text for alias in aliases)
|
||
]
|
||
if ZERO_POSITION_PATTERN.search(text) and (mentioned_axes or "四轴" in text):
|
||
reset_axes = list(MOTION_AXIS_ALIASES) if "四轴" in text else mentioned_axes
|
||
if _contains_unsupported_driver_target(text):
|
||
ambiguities.append(
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
)
|
||
else:
|
||
motion_actions.extend(
|
||
{
|
||
"target": "passenger",
|
||
"action_type": "position",
|
||
"axis": axis,
|
||
"position_pct": 0,
|
||
}
|
||
for axis in reset_axes
|
||
)
|
||
|
||
for clause in clauses:
|
||
level = _level_for_clause(clause)
|
||
clause_states = {
|
||
STATES[match.group(0)] for match in STATE_PATTERN.finditer(clause)
|
||
}
|
||
feature_matches = list(FEATURE_PATTERN.finditer(clause))
|
||
contradictory_feature_state = len(clause_states) > 1 and bool(feature_matches)
|
||
if contradictory_feature_state:
|
||
ambiguities.append(
|
||
{
|
||
"field": "passenger.thermal.state",
|
||
"question": "同一条指令同时包含打开和关闭,请确认座椅功能的最终状态。",
|
||
}
|
||
)
|
||
for feature_match in feature_matches:
|
||
if contradictory_feature_state:
|
||
continue
|
||
feature_zh = feature_match.group(0)
|
||
target_zh = _nearest_preceding_target(clause, feature_match.start())
|
||
state = _state_for_feature(clause, feature_match.end())
|
||
if target_zh is None:
|
||
target_zh = "副驾"
|
||
if "driver" in TARGETS[target_zh]:
|
||
ambiguities.append(
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
)
|
||
continue
|
||
if level is not None and level > 3:
|
||
ambiguities.append(
|
||
{
|
||
"field": f"passenger.{FEATURES[feature_zh]}.level",
|
||
"question": f"{feature_zh}档位必须在1到3档之间。",
|
||
}
|
||
)
|
||
continue
|
||
if state is None and level is not None:
|
||
state = "on"
|
||
if state is None:
|
||
ambiguities.append(
|
||
{
|
||
"field": "state",
|
||
"question": f"请确认{target_zh}{feature_zh}是打开还是关闭。",
|
||
}
|
||
)
|
||
continue
|
||
requested_level = level
|
||
if state == "on" and requested_level is None:
|
||
requested_level = 1
|
||
for target in TARGETS[target_zh]:
|
||
actions.append(
|
||
{
|
||
"target": target,
|
||
"function": FEATURES[feature_zh],
|
||
"state": state,
|
||
"requested_level": requested_level,
|
||
}
|
||
)
|
||
|
||
if ZERO_POSITION_PATTERN.search(text):
|
||
continue
|
||
|
||
clause_axes = [
|
||
axis
|
||
for axis, aliases in MOTION_AXIS_ALIASES.items()
|
||
if any(alias in clause for alias in aliases)
|
||
]
|
||
percentages = list(
|
||
re.finditer(r"(?P<position>[+-]?\d{1,3}(?:\.\d+)?)%", clause)
|
||
)
|
||
shared_position = None
|
||
if len(clause_axes) > 1 and len(percentages) == 1 and re.search(
|
||
r"都|全部|均", clause
|
||
):
|
||
requested_shared_value = float(percentages[0].group("position"))
|
||
rounded_shared_position = _round_half_away_from_zero(
|
||
requested_shared_value
|
||
)
|
||
shared_position = max(0, min(100, rounded_shared_position))
|
||
target_zh = (
|
||
_nearest_preceding_target(clause, percentages[0].start()) or "副驾"
|
||
)
|
||
if "driver" in TARGETS[target_zh]:
|
||
ambiguities.append(
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
)
|
||
else:
|
||
motion_actions.extend(
|
||
{
|
||
"target": "passenger",
|
||
"action_type": "position",
|
||
"axis": axis,
|
||
"position_pct": shared_position,
|
||
**(
|
||
{
|
||
"user_notice": (
|
||
f"{MOTION_AXIS_LABELS[axis]}请求位置"
|
||
f"{_format_numeric_request(requested_shared_value)}%;"
|
||
+ (
|
||
f"已取整为{rounded_shared_position}%。"
|
||
if requested_shared_value
|
||
!= rounded_shared_position
|
||
else ""
|
||
)
|
||
+ (
|
||
f" 取整后超过0到100%行程,已采用"
|
||
f"{shared_position}%备用目标并移动到对应机械末端。"
|
||
if rounded_shared_position != shared_position
|
||
else ""
|
||
)
|
||
)
|
||
}
|
||
if requested_shared_value != shared_position
|
||
else {}
|
||
),
|
||
}
|
||
for axis in clause_axes
|
||
)
|
||
|
||
if shared_position is not None:
|
||
continue
|
||
|
||
timed_axes: set[str] = set()
|
||
timed_aliases = {
|
||
**MOTION_AXIS_ALIASES,
|
||
"longitudinal": (*MOTION_AXIS_ALIASES["longitudinal"], "座椅", "座位"),
|
||
}
|
||
for axis, aliases in timed_aliases.items():
|
||
for alias in aliases:
|
||
match = re.search(
|
||
TIMED_MOTION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||
clause,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
if match is None:
|
||
continue
|
||
timed_axes.add(axis)
|
||
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
|
||
if "driver" in TARGETS[target_zh]:
|
||
ambiguities.append(
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
)
|
||
break
|
||
action, question = _timed_motion_action(
|
||
axis,
|
||
match.group("direction"),
|
||
_duration_ms(match.group("duration"), match.group("unit").lower()),
|
||
context,
|
||
)
|
||
if action is None:
|
||
ambiguities.append(
|
||
{
|
||
"field": f"passenger.position.{axis}",
|
||
"question": question or "无法计算座椅按时长移动目标。",
|
||
}
|
||
)
|
||
else:
|
||
motion_actions.append(action)
|
||
break
|
||
|
||
relative_axes: set[str] = set()
|
||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||
if axis in timed_axes:
|
||
continue
|
||
for alias in aliases:
|
||
angle_match = re.search(
|
||
RELATIVE_ANGLE_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||
clause,
|
||
)
|
||
percent_match = re.search(
|
||
RELATIVE_PERCENT_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||
clause,
|
||
)
|
||
match = angle_match or percent_match
|
||
if match is None:
|
||
continue
|
||
relative_axes.add(axis)
|
||
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
|
||
if "driver" in TARGETS[target_zh]:
|
||
ambiguities.append(
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
)
|
||
break
|
||
unit = "degree" if angle_match is not None else "percent"
|
||
action, question = _relative_motion_action(
|
||
axis,
|
||
match.group("direction"),
|
||
int(match.group("delta")),
|
||
unit,
|
||
context,
|
||
)
|
||
if action is None:
|
||
ambiguities.append(
|
||
{
|
||
"field": f"passenger.position.{axis}",
|
||
"question": question or "无法计算座椅相对移动目标。",
|
||
}
|
||
)
|
||
else:
|
||
motion_actions.append(action)
|
||
break
|
||
|
||
endpoint_actions: dict[str, int] = {}
|
||
has_specific_non_longitudinal_axis = any(
|
||
any(alias in clause for alias in MOTION_AXIS_ALIASES[axis])
|
||
for axis in ("cushion_tilt", "backrest_recline", "legrest_height")
|
||
)
|
||
has_longitudinal_axis = any(
|
||
alias in clause for alias in MOTION_AXIS_ALIASES["longitudinal"]
|
||
) or (
|
||
not has_specific_non_longitudinal_axis
|
||
and any(alias in clause for alias in ("座椅", "座位"))
|
||
)
|
||
if has_longitudinal_axis:
|
||
if re.search(r"最前面|最前|向前移到底|推到最前", clause):
|
||
endpoint_actions["longitudinal"] = 0
|
||
elif re.search(r"最后面|最后|向后移到底|移到最后", clause):
|
||
endpoint_actions["longitudinal"] = 100
|
||
cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆|坐垫)"
|
||
if re.search(
|
||
cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)",
|
||
clause,
|
||
):
|
||
endpoint_actions["cushion_tilt"] = 0
|
||
elif re.search(
|
||
cushion_alias + r"(?:降到最下方|调到最下方|移到最下方|最低|降到头|降到底)",
|
||
clause,
|
||
):
|
||
endpoint_actions["cushion_tilt"] = 100
|
||
backrest_alias = r"(?:靠背|椅背)"
|
||
if re.search(backrest_alias + r"(?:调直|立直|竖直)", clause):
|
||
endpoint_actions["backrest_recline"] = 0
|
||
elif re.search(
|
||
backrest_alias
|
||
+ r"(?:放倒到最后|放倒到底|完全放倒|后仰到底|向后移到底|最大角度)",
|
||
clause,
|
||
):
|
||
endpoint_actions["backrest_recline"] = 100
|
||
legrest_alias = r"(?:腿部支撑|小腿托|腿托|脚托)"
|
||
if re.search(
|
||
legrest_alias
|
||
+ r"(?:收起|收回|降到最下方|调到最下方|降到最低|最低|降到头|降到底)",
|
||
clause,
|
||
):
|
||
endpoint_actions["legrest_height"] = 0
|
||
elif re.search(
|
||
legrest_alias
|
||
+ r"(?:升到最上方|抬到最上方|调到最上方|升到最高|抬到最高|最高|完全抬起|升到头|升到底|抬到底)",
|
||
clause,
|
||
):
|
||
endpoint_actions["legrest_height"] = 100
|
||
for axis, position_pct in endpoint_actions.items():
|
||
target_zh = _nearest_preceding_target(clause, len(clause)) or "副驾"
|
||
if "driver" in TARGETS[target_zh]:
|
||
ambiguities.append(
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
)
|
||
continue
|
||
motion_actions.append(
|
||
{
|
||
"target": "passenger",
|
||
"action_type": "position",
|
||
"axis": axis,
|
||
"position_pct": position_pct,
|
||
}
|
||
)
|
||
|
||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||
if axis in endpoint_actions or axis in relative_axes or axis in timed_axes:
|
||
continue
|
||
for alias in aliases:
|
||
position_match = re.search(
|
||
POSITION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||
clause,
|
||
)
|
||
angle_match = None
|
||
if axis in MOTION_ANGLE_RANGES:
|
||
angle_match = re.search(
|
||
ANGLE_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||
clause,
|
||
)
|
||
match = position_match or angle_match
|
||
if match is None:
|
||
continue
|
||
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
|
||
if "driver" in TARGETS[target_zh]:
|
||
ambiguities.append(
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
)
|
||
break
|
||
absolute_angle = None
|
||
if position_match is not None:
|
||
requested_position_value = float(
|
||
position_match.group("position")
|
||
)
|
||
requested_position_pct = _round_half_away_from_zero(
|
||
requested_position_value
|
||
)
|
||
position_pct = max(0, min(100, requested_position_pct))
|
||
else:
|
||
requested_angle = int(angle_match.group("angle"))
|
||
minimum, maximum = MOTION_ANGLE_RANGES[axis]
|
||
angle = max(minimum, min(maximum, requested_angle))
|
||
position_pct = round(
|
||
(angle - minimum) * 100 / (maximum - minimum)
|
||
)
|
||
absolute_angle = angle
|
||
motion_action = {
|
||
"target": "passenger",
|
||
"action_type": "position",
|
||
"axis": axis,
|
||
"position_pct": position_pct,
|
||
}
|
||
if absolute_angle is not None:
|
||
motion_action["angle_deg"] = absolute_angle
|
||
if requested_angle != absolute_angle:
|
||
endpoint_label = (
|
||
MOTION_ENDPOINT_LABELS[axis]["increase"]
|
||
if requested_angle > maximum
|
||
else MOTION_ENDPOINT_LABELS[axis]["decrease"]
|
||
)
|
||
motion_action["user_notice"] = (
|
||
f"{MOTION_AXIS_LABELS[axis]}请求角度{requested_angle}度超过"
|
||
f"{minimum}到{maximum}度行程;已采用备用目标"
|
||
f"{absolute_angle}度并移动到{endpoint_label}。"
|
||
)
|
||
else:
|
||
notices: list[str] = []
|
||
if requested_position_value != requested_position_pct:
|
||
notices.append(
|
||
f"{MOTION_AXIS_LABELS[axis]}请求位置"
|
||
f"{_format_numeric_request(requested_position_value)}%;"
|
||
f"已取整为{requested_position_pct}%。"
|
||
)
|
||
if requested_position_pct != position_pct:
|
||
notices.append(
|
||
f"取整后超过0到100%行程;已采用{position_pct}%的备用目标"
|
||
"并移动到对应机械末端。"
|
||
)
|
||
if notices:
|
||
motion_action["user_notice"] = " ".join(notices)
|
||
motion_actions.append(motion_action)
|
||
break
|
||
|
||
merged: dict[tuple[str, str], dict[str, Any]] = {}
|
||
for action in actions:
|
||
key = (action["target"], action["function"])
|
||
previous = merged.get(key)
|
||
if previous and previous["state"] != action["state"]:
|
||
ambiguities.append(
|
||
{
|
||
"field": f"{key[0]}.{key[1]}.state",
|
||
"question": "同一个座椅功能同时出现打开和关闭指令,请确认最终状态。",
|
||
}
|
||
)
|
||
merged[key] = action
|
||
merged_motion: dict[tuple[str, str], dict[str, Any]] = {}
|
||
for action in motion_actions:
|
||
key = (action["target"], action["axis"])
|
||
previous = merged_motion.get(key)
|
||
if previous and previous["position_pct"] != action["position_pct"]:
|
||
ambiguities.append(
|
||
{
|
||
"field": f"{key[0]}.position.{key[1]}",
|
||
"question": "同一个座椅轴出现多个位置目标,请确认最终位置。",
|
||
}
|
||
)
|
||
merged_motion[key] = action
|
||
actions = [*merged.values(), *merged_motion.values()]
|
||
|
||
parsed = resolve_contextual_intent(
|
||
{
|
||
"actions": actions,
|
||
"ambiguities": ambiguities,
|
||
"decision_summary": [],
|
||
"confidence": 0.96 if actions and not ambiguities else 0.45,
|
||
},
|
||
utterance,
|
||
context,
|
||
)
|
||
actions = parsed["actions"]
|
||
ambiguities = parsed["ambiguities"]
|
||
|
||
if (
|
||
not actions
|
||
and _contains_unsupported_driver_target(text)
|
||
and not any(item.get("field") == "target" for item in ambiguities)
|
||
):
|
||
ambiguities.append(
|
||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||
)
|
||
|
||
if not actions and not ambiguities:
|
||
ambiguities.append(
|
||
{
|
||
"field": "capability",
|
||
"question": "当前仅支持副驾座椅通风、加热、移动和姿态预设能力。",
|
||
}
|
||
)
|
||
|
||
summary = ["识别为副驾座椅功能单域请求。", *parsed["decision_summary"]]
|
||
labels = {
|
||
"driver": "主驾",
|
||
"passenger": "副驾",
|
||
"ventilation": "通风",
|
||
"heating": "加热",
|
||
"on": "打开",
|
||
"off": "关闭",
|
||
}
|
||
axis_labels = {
|
||
"longitudinal": "水平",
|
||
"cushion_tilt": "高度",
|
||
"backrest_recline": "靠背",
|
||
"legrest_height": "腿托",
|
||
}
|
||
for action in actions:
|
||
if action.get("action_type") == "position":
|
||
summary.append(
|
||
f"目标:副驾座椅{axis_labels[action['axis']]}位置"
|
||
f"{action['position_pct']}%。"
|
||
)
|
||
if action.get("user_notice"):
|
||
summary.append(str(action["user_notice"]))
|
||
else:
|
||
summary.append(
|
||
f"目标:{labels[action['target']]}座椅"
|
||
f"{labels[action['function']]}{labels[action['state']]}。"
|
||
)
|
||
if any(action.get("requested_level") is not None for action in actions):
|
||
summary.append("目标档位已写入功能配置;现有 CAN 矩阵仅支持档位虚拟回读。")
|
||
|
||
response = {
|
||
"actions": actions,
|
||
"ambiguities": ambiguities,
|
||
"decision_summary": summary,
|
||
"confidence": max(parsed["confidence"], 0.96) if actions and not ambiguities else 0.45,
|
||
}
|
||
for field in ("detected_domains", "scope"):
|
||
if field in parsed:
|
||
response[field] = parsed[field]
|
||
return response
|