from __future__ import annotations import unittest from seat_domain.intent import parse_local_intent class LocalIntentParserTests(unittest.TestCase): @staticmethod def context(level: int = 0) -> dict: return { "last_target": "passenger" if level else None, "devices": { "driver": { "ventilation": {"state": "off", "level": 0}, "heating": {"state": "off", "level": 0}, }, "passenger": { "ventilation": {"state": "on" if level else "off", "level": level}, "heating": {"state": "off", "level": 0}, }, }, } @classmethod def motion_context(cls) -> dict: context = cls.context() context["devices"]["passenger"]["position"] = { "longitudinal": {"position_pct": 75}, "cushion_tilt": {"position_pct": 60}, "backrest_recline": {"position_pct": 51, "angle_deg": 130}, "legrest_height": {"position_pct": 69, "angle_deg": 145}, } return context def test_parses_passenger_ventilation_on(self) -> None: result = parse_local_intent("打开副驾座椅通风") self.assertEqual( result["actions"], [ { "target": "passenger", "function": "ventilation", "state": "on", "requested_level": 1, } ], ) self.assertEqual(result["ambiguities"], []) def test_rejects_scope_that_includes_driver(self) -> None: result = parse_local_intent("主副驾座椅通风都打开") self.assertEqual(result["actions"], []) self.assertEqual(result["ambiguities"][0]["field"], "target") self.assertIn("仅控制副驾", result["ambiguities"][0]["question"]) def test_keeps_requested_level_for_safety_validation(self) -> None: result = parse_local_intent("副驾座椅加热开到三档") self.assertEqual(result["actions"][0]["requested_level"], 3) def test_defaults_missing_target_to_passenger(self) -> None: result = parse_local_intent("打开座椅通风") self.assertEqual(result["actions"][0]["target"], "passenger") self.assertEqual(result["ambiguities"], []) def test_non_seat_spatial_qualifiers_do_not_enter_can0_seat_rules(self) -> None: utterances = ( "打开主驾车窗", "打开副驾车窗", "把主驾和副驾温度都设为24度", "风量调到2档", "设置主驾出风模式为吹脸", ) for utterance in utterances: with self.subTest(utterance=utterance): result = parse_local_intent(utterance) self.assertEqual(result["actions"], []) self.assertEqual(result["ambiguities"], []) def test_parses_four_axis_zero_for_passenger(self) -> None: result = parse_local_intent("把水平、坐盆、靠背、腿托四轴归零") self.assertEqual(result["ambiguities"], []) self.assertEqual( [(action["axis"], action["position_pct"]) for action in result["actions"]], [ ("longitudinal", 0), ("cushion_tilt", 0), ("backrest_recline", 0), ("legrest_height", 0), ], ) self.assertTrue( all(action["target"] == "passenger" for action in result["actions"]) ) def test_parses_explicit_motion_percentages(self) -> None: result = parse_local_intent("副驾水平移到63%,靠背调到54%") self.assertEqual(result["ambiguities"], []) self.assertEqual( [(action["axis"], action["position_pct"]) for action in result["actions"]], [("longitudinal", 63), ("backrest_recline", 54)], ) def test_normalizes_target_level_and_fullwidth_percentage_aliases(self) -> None: thermal = parse_local_intent("右前座加热设置为中档") motion = parse_local_intent("副驾驶滑轨调到百分之五十") self.assertEqual(thermal["actions"][0]["requested_level"], 2) self.assertEqual(motion["actions"][0]["axis"], "longitudinal") self.assertEqual(motion["actions"][0]["position_pct"], 50) def test_converts_backrest_angle_to_calibrated_percentage(self) -> None: result = parse_local_intent("把副驾驶椅背设为130°") self.assertEqual(result["ambiguities"], []) self.assertEqual(result["actions"][0]["axis"], "backrest_recline") self.assertEqual(result["actions"][0]["position_pct"], 51) def test_parses_relative_backrest_angle_from_current_state(self) -> None: result = parse_local_intent("靠背向前移动10度", self.motion_context()) self.assertEqual(result["ambiguities"], []) action = result["actions"][0] self.assertEqual(action["axis"], "backrest_recline") self.assertEqual(action["position_pct"], 36) self.assertEqual(action["angle_deg"], 120) self.assertEqual( action["motion_intent"], { "mode": "relative", "direction": "decrease", "start_position_pct": 51, "delta_position_pct": 15, "start_angle_deg": 130.0, "delta_angle_deg": 10.0, }, ) def test_parses_relative_direction_synonyms_and_chinese_angle(self) -> None: backward = parse_local_intent("椅背后仰十度", self.motion_context()) legrest = parse_local_intent("腿托向下调10度", self.motion_context()) self.assertEqual(backward["actions"][0]["position_pct"], 66) self.assertEqual(backward["actions"][0]["angle_deg"], 140) self.assertEqual(legrest["actions"][0]["position_pct"], 55) self.assertEqual(legrest["actions"][0]["angle_deg"], 135) def test_parses_relative_percentage_for_linear_axis(self) -> None: result = parse_local_intent("副驾滑轨向前移动10%", self.motion_context()) self.assertEqual(result["ambiguities"], []) self.assertEqual(result["actions"][0]["position_pct"], 65) self.assertEqual(result["actions"][0]["motion_intent"]["direction"], "decrease") def test_rejects_relative_motion_without_state_and_clamps_endpoint_overflow(self) -> None: missing = parse_local_intent("靠背向前移动10度") context = self.motion_context() context["devices"]["passenger"]["position"]["backrest_recline"] = { "position_pct": 0, "angle_deg": 95, } overflow = parse_local_intent("靠背向前移动10度", context) self.assertEqual(missing["actions"], []) self.assertIn("缺少当前座椅位置", missing["ambiguities"][0]["question"]) self.assertEqual(overflow["ambiguities"], []) self.assertEqual(overflow["actions"][0]["position_pct"], 0) self.assertTrue(overflow["actions"][0]["motion_intent"]["range_clamped"]) self.assertIn("最前方", overflow["actions"][0]["user_notice"]) def test_clamps_oversized_relative_angle_to_backrest_endpoint(self) -> None: context = self.motion_context() context["devices"]["passenger"]["position"]["backrest_recline"] = { "position_pct": 0, "angle_deg": 95, } result = parse_local_intent("靠背向后移动70度", context) self.assertEqual(result["ambiguities"], []) action = result["actions"][0] self.assertEqual(action["position_pct"], 100) self.assertEqual(action["angle_deg"], 163) self.assertEqual(action["motion_intent"]["requested_delta_angle_deg"], 70) self.assertEqual(action["motion_intent"]["delta_angle_deg"], 68) self.assertIn("备用目标", action["user_notice"]) def test_parses_and_clamps_user_timed_motion(self) -> None: result = parse_local_intent("靠背向后移动70秒", self.motion_context()) self.assertEqual(result["ambiguities"], []) action = result["actions"][0] self.assertEqual(action["position_pct"], 100) self.assertEqual( action["motion_intent"], { "mode": "timed", "direction": "increase", "start_position_pct": 51, "delta_position_pct": 49, "requested_duration_ms": 70_000, "applied_duration_ms": 30_000, "max_duration_ms": 30_000, "min_duration_ms": 300, "duration_clamped": True, "endpoint_expected": "far", }, ) self.assertIn("最大允许时长为30秒", action["user_notice"]) def test_supports_chinese_seconds_minutes_and_minimum_duration(self) -> None: chinese = parse_local_intent("椅背后仰两秒", self.motion_context()) minute = parse_local_intent("座椅向前移动1分钟", self.motion_context()) minimum = parse_local_intent("坐垫向下移动0.1秒", self.motion_context()) self.assertEqual(chinese["actions"][0]["motion_intent"]["applied_duration_ms"], 2_000) self.assertEqual(minute["actions"][0]["axis"], "longitudinal") self.assertEqual(minute["actions"][0]["position_pct"], 0) self.assertEqual(minute["actions"][0]["motion_intent"]["applied_duration_ms"], 30_000) self.assertEqual(minimum["actions"][0]["motion_intent"]["applied_duration_ms"], 300) def test_clamps_absolute_position_and_angle_to_endpoint(self) -> None: percentage = parse_local_intent("副驾水平位置调到101%") angle = parse_local_intent("副驾靠背调到200度") self.assertEqual(percentage["ambiguities"], []) self.assertEqual(percentage["actions"][0]["position_pct"], 100) self.assertIn("备用目标", percentage["actions"][0]["user_notice"]) self.assertEqual(angle["ambiguities"], []) self.assertEqual(angle["actions"][0]["position_pct"], 100) self.assertEqual(angle["actions"][0]["angle_deg"], 163) def test_applies_one_shared_percentage_to_all_named_axes(self) -> None: result = parse_local_intent("副驾驶水平、坐垫、椅背、脚托都调到40%") self.assertEqual(result["ambiguities"], []) self.assertEqual( {(action["axis"], action["position_pct"]) for action in result["actions"]}, { ("longitudinal", 40), ("cushion_tilt", 40), ("backrest_recline", 40), ("legrest_height", 40), }, ) def test_normalizes_common_heating_and_ventilation_synonyms(self) -> None: warming = parse_local_intent("副驾座椅取暖开到2档") brief_warming = parse_local_intent("副驾座椅暖一下") airflow = parse_local_intent("副驾座椅吹风开到2档") self.assertEqual( (warming["actions"][0]["function"], warming["actions"][0]["requested_level"]), ("heating", 2), ) self.assertEqual( ( brief_warming["actions"][0]["function"], brief_warming["actions"][0]["requested_level"], ), ("heating", 1), ) self.assertEqual( (airflow["actions"][0]["function"], airflow["actions"][0]["requested_level"]), ("ventilation", 2), ) def test_rejects_unsupported_seat_scopes_instead_of_defaulting_to_passenger(self) -> None: for utterance in ( "打开后排座椅加热", "打开左前座椅通风", "打开所有座椅加热", ): with self.subTest(utterance=utterance): result = parse_local_intent(utterance) self.assertEqual(result["actions"], []) self.assertEqual(result["ambiguities"][0]["field"], "target") self.assertIn("不能把", result["ambiguities"][0]["question"]) def test_axis_specific_endpoint_phrases_do_not_move_the_longitudinal_axis(self) -> None: cases = { "坐盆抬到最上方": ("cushion_tilt", 0), "坐盆降到最下方": ("cushion_tilt", 100), "靠背放倒到最后": ("backrest_recline", 100), "腿托升到最高": ("legrest_height", 100), "腿托降到最低": ("legrest_height", 0), } for utterance, expected in cases.items(): with self.subTest(utterance=utterance): result = parse_local_intent(utterance) self.assertEqual(result["ambiguities"], []) self.assertEqual( [(item["axis"], item["position_pct"]) for item in result["actions"]], [expected], ) def test_parses_normal_half_second_and_minimum_clamped_timed_motion(self) -> None: normal = parse_local_intent("滑轨向后移动2秒", self.motion_context()) half_second = parse_local_intent("靠背向后移动半秒", self.motion_context()) minimum = parse_local_intent("腿托向上移动200毫秒", self.motion_context()) self.assertEqual(normal["actions"][0]["motion_intent"]["applied_duration_ms"], 2_000) self.assertFalse(normal["actions"][0]["motion_intent"]["duration_clamped"]) self.assertEqual( half_second["actions"][0]["motion_intent"]["requested_duration_ms"], 500, ) self.assertEqual(minimum["actions"][0]["motion_intent"]["applied_duration_ms"], 300) self.assertTrue(minimum["actions"][0]["motion_intent"]["duration_clamped"]) self.assertIn("最小允许时长为0.3秒", minimum["actions"][0]["user_notice"]) def test_timed_motion_accepts_continuation_and_compact_direction_phrases(self) -> None: cases = { "座椅水平再后移动3秒": ("longitudinal", "increase"), "座椅水平往后移3秒": ("longitudinal", "increase"), "座椅水平继续前移3秒": ("longitudinal", "decrease"), "腿托向上调3秒": ("legrest_height", "increase"), "腿托再向上调3秒": ("legrest_height", "increase"), "腿托再下调3秒": ("legrest_height", "decrease"), } for utterance, (axis, direction) in cases.items(): with self.subTest(utterance=utterance): result = parse_local_intent(utterance, self.motion_context()) self.assertEqual(result["ambiguities"], []) self.assertEqual(len(result["actions"]), 1) self.assertEqual(result["actions"][0]["axis"], axis) motion = result["actions"][0]["motion_intent"] self.assertEqual(motion["mode"], "timed") self.assertEqual(motion["direction"], direction) self.assertEqual(motion["requested_duration_ms"], 3_000) def test_rounds_decimal_and_clamps_negative_absolute_positions(self) -> None: decimal = parse_local_intent("副驾座椅水平位置调到50.5%") negative = parse_local_intent("副驾座椅水平位置调到-10%") self.assertEqual(decimal["ambiguities"], []) self.assertEqual(decimal["actions"][0]["position_pct"], 51) self.assertIn("取整为51%", decimal["actions"][0]["user_notice"]) self.assertEqual(negative["ambiguities"], []) self.assertEqual(negative["actions"][0]["position_pct"], 0) self.assertIn("0%的备用目标", negative["actions"][0]["user_notice"]) def test_zero_duration_uses_minimum_safe_motion_instead_of_rejecting(self) -> None: result = parse_local_intent( "副驾靠背向后移动0秒", self.motion_context() ) self.assertEqual(result["ambiguities"], []) motion = result["actions"][0]["motion_intent"] self.assertEqual(motion["requested_duration_ms"], 0) self.assertEqual(motion["applied_duration_ms"], 300) self.assertTrue(motion["duration_clamped"]) self.assertIn("已按0.3秒", result["actions"][0]["user_notice"]) def test_rejects_unsupported_level_and_conflicting_state(self) -> None: unsupported = parse_local_intent("副驾加热开到4档") conflicting = parse_local_intent("打开副驾加热然后关闭副驾加热") self.assertEqual(unsupported["actions"], []) self.assertEqual(unsupported["ambiguities"][0]["field"], "passenger.heating.level") self.assertEqual(conflicting["actions"], []) self.assertEqual(conflicting["ambiguities"][0]["field"], "passenger.thermal.state") def test_rejects_driver_target_even_when_only_a_mode_is_named(self) -> None: result = parse_local_intent("打开主驾零重力模式") self.assertEqual(result["actions"], []) self.assertEqual(result["ambiguities"][0]["field"], "target") def test_rejects_driver_four_axis_zero(self) -> None: result = parse_local_intent("把主驾座椅四轴归零") self.assertEqual(result["actions"], []) self.assertEqual(result["ambiguities"][0]["field"], "target") self.assertIn("仅控制副驾", result["ambiguities"][0]["question"]) def test_maps_mild_hot_and_stuffy_feedback_to_level_one(self) -> None: result = parse_local_intent("副驾有点热,有点闷", self.context()) self.assertEqual( result["actions"], [ { "target": "passenger", "function": "ventilation", "state": "on", "requested_level": 1, } ], ) self.assertIn("热、闷", "".join(result["decision_summary"])) def test_increments_active_level_for_continuing_discomfort(self) -> None: result = parse_local_intent("还有点闷", self.context(level=1)) self.assertEqual(result["actions"][0]["requested_level"], 2) self.assertEqual(result["actions"][0]["target"], "passenger") self.assertIn("提升到2档", "".join(result["decision_summary"])) def test_cold_feedback_reduces_active_ventilation_before_heating(self) -> None: result = parse_local_intent("我有点冷", self.context(level=1)) self.assertEqual( result["actions"], [ { "target": "passenger", "function": "ventilation", "state": "off", "requested_level": None, } ], ) self.assertIn("不立即开启加热", "".join(result["decision_summary"])) def test_airflow_cause_targets_ventilation_not_heating(self) -> None: context = self.context() context["devices"]["passenger"]["ventilation"] = {"state": "on", "level": 2} result = parse_local_intent("副驾吹的我有点冷", context) self.assertEqual( result["actions"], [ { "target": "passenger", "function": "ventilation", "state": "on", "requested_level": 1, } ], ) self.assertNotIn("heating", {action["function"] for action in result["actions"]}) self.assertIn("致冷来源", "".join(result["decision_summary"])) def test_cold_and_stuffy_are_independent_comfort_dimensions(self) -> None: result = parse_local_intent("副驾有点冷、有点闷", self.context()) self.assertEqual( { (action["function"], action["state"], action["requested_level"]) for action in result["actions"] }, {("heating", "on", 1), ("ventilation", "on", 1)}, ) self.assertEqual(result["ambiguities"], []) self.assertIn("不将其判定为语义冲突", "".join(result["decision_summary"])) def test_vehicle_scope_does_not_inherit_a_seat_target(self) -> None: result = parse_local_intent("车上太闷了", self.context(level=1)) self.assertEqual(result["actions"], []) self.assertEqual(result["ambiguities"][0]["field"], "domain") self.assertEqual(result["detected_domains"], ["climate"]) self.assertEqual(result["scope"], "vehicle") self.assertIn("整车舱范围", "".join(result["decision_summary"])) if __name__ == "__main__": unittest.main()