"""Fail-closed authorization for model-authored cockpit plans. Codex may help normalize a natural-language request into the same structured ``requests`` shape used by :class:`ModelPlanAdapter`, but it is not an authority for adding capabilities. This module keeps that boundary explicit: * every request must name a capability from the runtime registry; * the original, server-owned fallback text must contain evidence for that exact capability (or the deterministic parser must already have claimed it); * a model request cannot replace a locally resolved capability/target pair; * malformed, oversized and non-execution plans fail closed. Parameter types, ranges, targets and execution availability remain owned by ``ModelPlanAdapter`` and the normal compiler/safety pipeline. This guard only decides which registered requests are allowed to reach that adapter. """ from __future__ import annotations import json import math import re from collections.abc import Mapping, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass from typing import Any MAX_PLAN_BYTES = 24 * 1024 MAX_PLAN_REQUESTS = 20 MAX_PLAN_PARAMETERS = 12 _OPERATIONS = frozenset({"activate", "deactivate", "increase", "decrease", "set", "save", "apply"}) SCENE_COMPOSABLE_CAPABILITIES = frozenset( { "seat.set_longitudinal_position", "seat.set_cushion_tilt", "seat.set_backrest_recline", "seat.set_legrest_height", "seat.set_legrest_extension", "seat.set_ventilation", "seat.set_heating", "seat.set_driver_ventilation_native", "seat.set_driver_heating_native", "seat.set_driver_massage_native", "climate.set_power", "climate.set_temperature", "climate.set_fan_level", "climate.set_cycle_mode", "climate.set_control_mode", "climate.set_outlet_mode", "climate.set_front_defrost", "roof.set_shade_position", "roof.set_transmittance", "roof.set_automatic_dimming", "window.set_position", "interior_light.set_indoor_power", "interior_light.set_reading_power", "interior_light.set_wall_power", "ambient_light.set_power", "ambient_light.set_illumination", "ambient_light.set_mode", "ambient_light.set_color_index", "ambient_light.set_brightness_level", "outlet.set_switch_state", "outlet.set_position_percent", } ) # Reusable scenes are replayed through the native compiler after the user saves # them. Keep scene-only parameter bounds aligned with that compiler even when # a broader virtual capability contract accepts additional values. In # particular, fan level 0 is a virtual shorthand for "off", while the native # operation is explicitly 1-7; changing it to HVAC power-off here would alter # the model's proposed intent. SCENE_PARAMETER_BOUNDS: dict[tuple[str, str], tuple[float | None, float | None]] = { ("climate.set_fan_level", "level"): (1, 7), } _ROOT_FIELDS = frozenset( {"detected_domains", "requests", "ambiguities", "decision_summary", "confidence"} ) _OPTIONAL_ROOT_FIELDS = frozenset({"metadata"}) _SCENE_TEMPLATE_METADATA_FIELDS = frozenset( {"source", "template_id", "template_revision"} ) _SCENE_TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _SCENE_TEMPLATE_REVISION = re.compile(r"^[a-f0-9]{16}$") _REQUEST_FIELDS = frozenset( { "domain", "capability", "target_scope", "operation", "duration_ms", "parameters", "reason", } ) _PARAMETER_FIELDS = frozenset({"name", "value"}) _NON_EXECUTION_PATTERN = re.compile( r"不要|别(?:执行|操作|控制|打开|关闭|调整|设置)|无需|不用|禁止|不许|不得|" r"取消操作|只回答|仅回答|为什么|为何|什么是|解释|介绍|分析|科普|教程|" r"如何|怎么做|如果|假如|假设|举例|示例|翻译|改写|总结|复述|引用|" r"造句|故事|剧本|小说|作文|角色|主角|这句话|以下内容|" r"是否支持|支持吗|能否支持|会怎样|会发生什么" ) @dataclass(frozen=True) class GuardedAssistantPlan: """A normalized model plan plus auditable fail-closed rejections.""" plan: dict[str, Any] rejected_requests: tuple[dict[str, str], ...] @dataclass(frozen=True) class _EvidenceRule: subject: re.Pattern[str] intent: re.Pattern[str] def matches(self, text: str) -> bool: return bool(self.subject.search(text) and self.intent.search(text)) def _rule(subject: str, intent: str) -> _EvidenceRule: return _EvidenceRule(re.compile(subject, re.IGNORECASE), re.compile(intent, re.IGNORECASE)) # These are deliberately capability-specific security rules, not a product # catalog. The runtime registry still decides whether a capability exists. # Unknown/new capabilities remain usable through deterministic parser evidence # and otherwise fail closed until an explicit evidence rule is reviewed. _EXPLICIT_EVIDENCE: dict[str, _EvidenceRule] = { "climate.set_power": _rule( r"空调|暖风|冷风|\bhvac\b|\bac\b", r"打开|开启|启动|关闭|关掉|停止|\bon\b|\boff\b" ), "climate.set_temperature": _rule( r"空调|温度|车(?:里|内|上).{0,5}(?:热|冷)|\bac\b", r"温度|冷|热|暖|凉|\d+(?:\.\d+)?\s*(?:度|℃)|\btemp\b", ), "climate.set_fan_level": _rule(r"空调|风量|风速|\bac\b", r"风量|风速|\d+\s*档|\bfan\b"), "climate.set_ac": _rule( r"压缩机|制冷|\ba/?c\b", r"压缩机|制冷|打开|开启|关闭|\bcompressor\b" ), "climate.set_cycle_mode": _rule(r"空调|循环|\bac\b", r"内循环|外循环|循环|\bcycle\b"), "climate.set_control_mode": _rule(r"空调|\bac\b", r"自动|手动|控制模式|\bauto\b|\bmanual\b"), "climate.set_outlet_mode": _rule(r"空调|出风|\bac\b", r"吹面|吹脚|面脚|出风模式|\bmode\b"), "climate.set_front_defrost": _rule(r"前挡|前风挡|挡风玻璃|空调", r"除雾|除霜|defrost"), "roof.set_shade_motion": _rule( r"遮阳帘|遮阳幕|天幕帘|\bshade\b", r"打开|开启|关闭|关上|半开|停止|收回|拉开|拉上|\bopen\b|\bclose\b|\bhalf\b|\bstop\b", ), "roof.set_shade_position": _rule( r"遮阳帘|遮阳幕|天幕帘|\bshade\b", r"位置|开到|关到|调到|小缝|一半|半开|\d+\s*[%%]|\bposition\b", ), "roof.set_transmittance": _rule( r"智能天幕|天幕(?:玻璃)?|顶棚玻璃|\broof\b", r"透光|透明|变暗|变亮|\d+\s*[%%]|transmittance", ), "roof.set_automatic_dimming": _rule( r"智能天幕|天幕(?:玻璃)?|顶棚玻璃|\broof\b", r"自动调光|自动变色|automatic\s+dimming" ), "window.set_motion": _rule( r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|" r"左前排窗|右前排窗|左后排窗|右后排窗|\bwindow\b", r"打开|开启|关闭|关上|升起|降下|停止|\bopen\b|\bclose\b|\bstop\b", ), "window.set_position": _rule( r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|" r"左前排窗|右前排窗|左后排窗|右后排窗|\bwindow\b", r"位置|开到|关到|升到|降到|小缝|一半|半开|\d+\s*[%%]|\bposition\b", ), "door.set_rear_lock": _rule( r"后排车门|后门|后排门锁|后门锁|\bdoor\b", r"上锁|锁上|解锁|开锁|\block\b|\bunlock\b" ), "door.set_vehicle_lock": _rule( r"整车|全车|车辆|锁车|\bvehicle\b", r"锁车|上锁|解锁|开锁|\block\b|\bunlock\b" ), "outlet.set_switch_state": _rule( r"出风口|风口|\boutlet\b", r"打开|开启|关闭|关上|\bopen\b|\bclose\b" ), "outlet.set_position_percent": _rule( r"出风口|风口|\boutlet\b", r"水平|上下|垂直|位置|角度|\d+\s*[%%]|percent" ), "outlet.set_calibrated_position": _rule( r"出风口|风口|\boutlet\b", r"标定|校准|原始值|calibrated" ), "interior_light.set_indoor_power": _rule( r"室内灯|车内灯|顶灯|indoor\s+light", r"打开|开启|关闭|关掉|\bon\b|\boff\b" ), "interior_light.set_reading_power": _rule( r"阅读灯|reading\s+light", r"打开|开启|关闭|关掉|\bon\b|\boff\b" ), "interior_light.set_wall_power": _rule( r"壁灯|wall\s+light", r"打开|开启|关闭|关掉|\bon\b|\boff\b" ), "ambient_light.set_power": _rule( r"氛围灯|氛围照明|ambient\s+light", r"打开|开启|关闭|关掉|\bon\b|\boff\b" ), "ambient_light.set_illumination": _rule( r"氛围灯|氛围照明|ambient\s+light", r"颜色|色彩|亮度|#[0-9a-f]{6}|rgb|illumination" ), "ambient_light.set_mode": _rule(r"氛围灯|氛围照明|ambient\s+light", r"模式|场景|mode"), "ambient_light.set_color_index": _rule( r"氛围灯|氛围照明|ambient\s+light", r"颜色|色号|编号|color" ), "ambient_light.set_brightness_level": _rule( r"氛围灯|氛围照明|ambient\s+light", r"亮度|明暗|\d+\s*档|brightness" ), "seat.set_ventilation": _rule(r"座椅|座位|seat|副驾.{0,4}通风", r"通风|vent"), "seat.set_driver_ventilation_native": _rule( r"座椅|座位|seat|(?:主驾|驾驶位|驾驶员).{0,4}通风", r"通风|vent" ), "seat.set_heating": _rule(r"座椅|座位|seat|副驾.{0,4}加热", r"加热|heat"), "seat.set_driver_heating_native": _rule( r"座椅|座位|seat|(?:主驾|驾驶位|驾驶员).{0,4}加热", r"加热|heat" ), "seat.set_driver_massage_native": _rule( r"座椅|座位|按摩|seat", r"按摩|massage" ), "seat.set_longitudinal_position": _rule( r"座椅|副驾|seat", r"前后|前移|后移|向前|向后|水平|纵向|longitudinal|horizontal" ), "seat.set_cushion_tilt": _rule(r"座椅|副驾|seat", r"坐垫|坐盆|盆倾|高度|cushion|basin"), "seat.set_backrest_recline": _rule(r"座椅|副驾|seat", r"靠背|椅背|放倒|调直|backrest"), "seat.set_legrest_height": _rule( r"座椅|副驾|腿托|小腿托|seat", r"腿托.{0,5}(?:升高|抬高|降低|放低|高度|角度|上抬|下放)|" r"(?:升高|抬高|降低|放低|上抬|下放).{0,5}腿托|" r"legrest.{0,5}(?:height|lift)", ), "seat.set_legrest_extension": _rule( r"座椅|副驾|腿托|小腿托|seat", r"腿托.{0,5}(?:伸出|伸长|加长|展开|向外|收回|缩回|缩短|收起|向内|长度)|" r"legrest.{0,5}(?:extension|extend|retract)", ), "seat.set_driver_axis_native": _rule( r"主驾|驾驶位|驾驶员|driver", r"前后|靠背|高度|坐盆|水平|horizontal|backrest|height|basin" ), "seat.set_driver_position_native": _rule( r"主驾|驾驶位|驾驶员|driver", r"座椅位置|记忆位置|位置档|position", ), "seat.recall_driver_memory_native": _rule( r"主驾|驾驶位|驾驶员|driver", r"记忆|召回|恢复.{0,5}位置|memory|recall" ), "seat.save_posture_preset": _rule(r"座椅|姿态|坐姿|seat", r"保存|记住|固化|save"), "seat.apply_posture_preset": _rule(r"座椅|姿态|坐姿|预设|seat", r"调用|应用|恢复|进入|apply"), "seat.start_dynamic_mode": _rule( r"座椅|木马|摇篮|动态座椅|seat", r"木马|摇篮|动态模式" ), "seat.stop_dynamic_mode": _rule( r"座椅|木马|摇篮|动态座椅|seat", r"木马|摇篮|动态模式" ), } _CLAUSE_SEPARATOR = re.compile(r"[,。;;!?!?]|并(?:且)?|同时|然后", re.IGNORECASE) _NUMBER = r"-?\d+(?:\.\d+)?" _INCREASE_PATTERN = re.compile( r"增加|增大|调高|提高|升高|加大|增强|更高|高一点|大一点|" r"亮一点|暖一点|热一点|开大|往上|向上|向前|前移|伸出|increase|raise", re.IGNORECASE, ) _DECREASE_PATTERN = re.compile( r"减少|减小|调低|降低|下降|减弱|更低|低一点|小一点|" r"暗一点|凉一点|冷一点|开小|往下|向下|向后|后移|收回|decrease|lower", re.IGNORECASE, ) _SEAT_DIRECTION_PATTERNS: dict[ str, tuple[re.Pattern[str], re.Pattern[str]], ] = { # These directions follow each capability's normalized 0-100 axis, not # the passenger's intuitive physical direction. Longitudinal position is # 0=forward and 100=rearward, while backrest recline is 0=upright and # 100=reclined. Treating all occurrences of “向前/向后” alike can make a # model-authored relative plan drive the motor in the opposite direction. "seat.set_longitudinal_position": ( re.compile(r"向后|后移|后退|往后|靠后|back(?:ward)?", re.IGNORECASE), re.compile(r"向前|前移|前进|往前|靠前|forward", re.IGNORECASE), ), "seat.set_backrest_recline": ( re.compile(r"向后|后仰|放倒|躺下|更躺|recline", re.IGNORECASE), re.compile(r"向前|调直|立直|竖直|扶正|更直|upright", re.IGNORECASE), ), # Cushion tilt uses 0=front edge highest and 100=front edge lowest, so # lowering is an increase on the normalized axis and raising is a decrease. "seat.set_cushion_tilt": ( re.compile(r"降低|放低|下调|向下|往下|降下|放下|lower|down", re.IGNORECASE), re.compile(r"升高|抬高|上调|向上|往上|升起|抬起|raise|up", re.IGNORECASE), ), "seat.set_legrest_height": ( re.compile(r"升高|抬高|上抬|向上|往上|升起|抬起|raise|up", re.IGNORECASE), re.compile(r"降低|放低|下放|向下|往下|降下|放下|lower|down", re.IGNORECASE), ), "seat.set_legrest_extension": ( re.compile(r"伸出|伸长|加长|展开|向外|extend|out", re.IGNORECASE), re.compile(r"收回|缩回|缩短|收起|向内|retract|in", re.IGNORECASE), ), } _WHOLE_TARGET_PATTERN = re.compile( r"全车|整车|(?:全部|所有|四个)(?:的)?" r"(?:车窗|窗|阅读灯|灯|座椅|车门|门锁|风口)|\ball\b|\bvehicle\b", re.IGNORECASE, ) _TARGET_MARKERS: tuple[tuple[frozenset[str], re.Pattern[str]], ...] = ( (frozenset({"driver_left"}), re.compile(r"主驾左|驾驶位左|driver[_ -]?left", re.IGNORECASE)), (frozenset({"driver_right"}), re.compile(r"主驾右|驾驶位右|driver[_ -]?right", re.IGNORECASE)), ( frozenset({"passenger_left"}), re.compile(r"副驾左|乘客位左|passenger[_ -]?left", re.IGNORECASE), ), ( frozenset({"passenger_right"}), re.compile(r"副驾右|乘客位右|passenger[_ -]?right", re.IGNORECASE), ), ( frozenset({"rear_left", "left_rear"}), re.compile(r"左后|后排左|rear[_ -]?left|left[_ -]?rear", re.IGNORECASE), ), ( frozenset({"rear_right", "right_rear"}), re.compile(r"右后|后排右|rear[_ -]?right|right[_ -]?rear", re.IGNORECASE), ), ( frozenset({"driver", "front_left"}), re.compile(r"主驾|驾驶位|驾驶员|司机位|左前|driver|front[_ -]?left", re.IGNORECASE), ), ( frozenset({"passenger", "front_right"}), re.compile(r"副驾|乘客位|乘员|右前|passenger|front[_ -]?right", re.IGNORECASE), ), ( frozenset({"front", "front_row"}), re.compile(r"前排|(? str: text = " ".join(str(value or "").replace("\x00", " ").split()) return text[:limit] def _registered_capabilities( capability_context: Mapping[str, Any], executable_capabilities: AbstractSet[str], ) -> dict[str, str]: registered: dict[str, str] = {} domains = capability_context.get("domains", []) if not isinstance(domains, Sequence) or isinstance(domains, (str, bytes, bytearray)): return registered for raw_domain in domains: if not isinstance(raw_domain, Mapping): continue domain = _safe_text(raw_domain.get("domain"), limit=64) capabilities = raw_domain.get("capabilities", []) if not isinstance(capabilities, Sequence) or isinstance( capabilities, (str, bytes, bytearray) ): continue for raw_capability in capabilities: if not isinstance(raw_capability, Mapping): continue capability = _safe_text(raw_capability.get("name"), limit=128) if capability and domain and capability in executable_capabilities: registered[capability] = domain return registered def _registered_capability_specs( capability_context: Mapping[str, Any], executable_capabilities: AbstractSet[str], ) -> dict[str, tuple[str, Mapping[str, Any]]]: registered: dict[str, tuple[str, Mapping[str, Any]]] = {} domains = capability_context.get("domains", []) if not isinstance(domains, Sequence) or isinstance( domains, (str, bytes, bytearray), ): return registered for raw_domain in domains: if not isinstance(raw_domain, Mapping): continue domain = _safe_text(raw_domain.get("domain"), limit=64) capabilities = raw_domain.get("capabilities", []) if not isinstance(capabilities, Sequence) or isinstance( capabilities, (str, bytes, bytearray), ): continue for raw_capability in capabilities: if not isinstance(raw_capability, Mapping): continue capability = _safe_text(raw_capability.get("name"), limit=128) if capability and domain and capability in executable_capabilities: registered[capability] = (domain, raw_capability) return registered def _scene_contract_error( capability: str, target_scope: str, parameters: Sequence[Mapping[str, Any]], specification: Mapping[str, Any], ) -> str | None: """Validate a scene request before adapter/native routing can drop it.""" target_scopes = specification.get("target_scopes", []) if ( not isinstance(target_scopes, Sequence) or isinstance(target_scopes, (str, bytes, bytearray)) or target_scope not in {str(item) for item in target_scopes} ): return f"能力 {capability} 不支持目标范围 {target_scope!r}" raw_specs = specification.get("parameters", []) if not isinstance(raw_specs, Sequence) or isinstance( raw_specs, (str, bytes, bytearray), ): return f"能力 {capability} 的参数契约不可用" specs = { str(item.get("name")): item for item in raw_specs if isinstance(item, Mapping) and str(item.get("name") or "").strip() } values = {str(item["name"]): item.get("value") for item in parameters} unknown = sorted(set(values) - set(specs)) if unknown: return f"能力 {capability} 不支持参数 {', '.join(unknown)}" relative = sorted( name for name in values if name in {"distance_mm", "angle_delta_deg"} or "delta" in name ) if relative: return f"功能模式不接受相对参数 {', '.join(relative)}" if capability.startswith("seat.set_") and len( { name for name in ("position_pct", "angle_deg") if values.get(name) is not None } ) > 1: return "座椅绝对位置只能使用 position_pct 或 angle_deg 中的一种表示" for name, spec in specs.items(): value = values.get(name) if spec.get("required") and value is None: return f"能力 {capability} 缺少必填参数 {name}" if value is None: continue expected_type = str(spec.get("type") or "") numeric = isinstance(value, (int, float)) and not isinstance(value, bool) type_valid = { "number": numeric, "integer": numeric and float(value).is_integer(), "string": isinstance(value, str), "boolean": isinstance(value, bool), }.get(expected_type, False) if not type_valid: return f"参数 {name} 的类型必须是 {expected_type}" minimum = spec.get("minimum") maximum = spec.get("maximum") scene_bounds = SCENE_PARAMETER_BOUNDS.get((capability, name)) if scene_bounds is not None: minimum, maximum = scene_bounds if numeric and ( (minimum is not None and float(value) < float(minimum)) or (maximum is not None and float(value) > float(maximum)) ): return ( f"参数 {name} 超出能力 {capability} 的允许范围 " f"{minimum if minimum is not None else '-'}-" f"{maximum if maximum is not None else '+'}" ) allowed = spec.get("allowed_values") if ( isinstance(allowed, Sequence) and not isinstance(allowed, (str, bytes, bytearray)) and value not in allowed ): return f"参数 {name} 不在能力 {capability} 的允许值内" return None def _relevant_text(capability: str, text: str) -> str: """Narrow semantic checks to clauses mentioning this capability's subject.""" rule = _EXPLICIT_EVIDENCE.get(capability) if rule is None: return text clauses = [item.strip() for item in _CLAUSE_SEPARATOR.split(text) if item.strip()] selected: list[str] = [] for index, clause in enumerate(clauses): if not rule.subject.search(clause): continue selected.append(clause) for continuation in clauses[index + 1 :]: if any( candidate.subject.search(continuation) for candidate in _EXPLICIT_EVIDENCE.values() ): break selected.append(continuation) return ",".join(dict.fromkeys(selected)) if selected else text def _target_is_evidenced(capability: str, target: str, text: str) -> bool: normalized_target = _TARGET_ALIASES.get(target, target) whole_target = bool(_WHOLE_TARGET_PATTERN.search(text)) if whole_target and capability != "interior_light.set_reading_power": return normalized_target in {"vehicle", "all"} found: set[str] = set() explicit_marker_seen = False for targets, pattern in _TARGET_MARKERS: if pattern.search(text): explicit_marker_seen = True found.update(_TARGET_ALIASES.get(item, item) for item in targets) if capability == "interior_light.set_reading_power": # The current CH1 implementation has one verified all-reading-lights # bit only. A named seat/row must not be widened to that global bit. return normalized_target == "all" and (whole_target or not explicit_marker_seen) if explicit_marker_seen: return normalized_target in found # Unscoped cabin controls may use the registry's vehicle/default-driver # scope. Outlet quadrants and passenger seats have no safe implicit scope. if capability.startswith("outlet."): return False if capability.startswith("seat."): if capability in { "seat.apply_posture_preset", "seat.save_posture_preset", "seat.start_dynamic_mode", "seat.stop_dynamic_mode", }: return normalized_target == "front_right" return normalized_target in {"front_left"} return normalized_target in {"vehicle", "all", "front_left"} def _numeric_equal(left: object, right: float) -> bool: return ( isinstance(left, (int, float)) and not isinstance(left, bool) and math.isclose(float(left), right, rel_tol=0, abs_tol=1e-6) ) def _number_matches(text: str, suffix: str) -> list[float]: return [ float(value) for value in re.findall(rf"({_NUMBER})\s*(?:{suffix})", text, re.IGNORECASE) ] def _percent_matches(text: str) -> list[float]: values = _number_matches(text, r"[%%]") if re.search(r"一半|半开|half", text, re.IGNORECASE): values.append(50.0) return values def _duration_matches(text: str) -> list[int]: milliseconds = [round(value) for value in _number_matches(text, r"毫秒|ms")] milliseconds.extend( round(value * 1000) for value in _number_matches(text, r"秒|s(?:ec(?:ond)?s?)?\b") ) return milliseconds def _direction_evidenced(capability: str, operation: str, text: str) -> bool: if operation not in {"increase", "decrease"}: return True direction_patterns = _SEAT_DIRECTION_PATTERNS.get(capability) if direction_patterns is None: increase_pattern, decrease_pattern = _INCREASE_PATTERN, _DECREASE_PATTERN else: increase_pattern, decrease_pattern = direction_patterns increases = bool(increase_pattern.search(text)) decreases = bool(decrease_pattern.search(text)) return (operation == "increase" and increases and not decreases) or ( operation == "decrease" and decreases and not increases ) def _state_values(capability: str, text: str) -> set[str]: if capability.startswith("door."): values = set() if re.search(r"上锁|锁上|锁车|\block\b", text, re.IGNORECASE): values.add("lock") if re.search(r"解锁|开锁|\bunlock\b", text, re.IGNORECASE): values.add("unlock") return values if capability == "outlet.set_switch_state": values = set() if re.search(r"打开|开启|张开|\bopen\b", text, re.IGNORECASE): values.add("open") if re.search(r"关闭|关上|合上|\bclose\b", text, re.IGNORECASE): values.add("close") return values values = set() if re.search( r"打开|开启|启动|启用|点亮|开到\s*[一二两三四五六七\d]+\s*档|\bon\b", text, re.IGNORECASE, ): values.add("on") if re.search(r"关闭|关掉|关上|停用|熄灭|关到\s*0\s*档|\boff\b", text, re.IGNORECASE): values.add("off") return values def _motion_values(capability: str, text: str) -> set[str]: values = set() if capability.startswith("window."): if re.search(r"打开|开启|降下|开窗|\bopen\b", text, re.IGNORECASE): values.add("open") if re.search(r"关闭|关上|升起|关窗|\bclose\b", text, re.IGNORECASE): values.add("close") else: if re.search(r"打开|开启|拉开|收起|\bopen\b", text, re.IGNORECASE): values.add("open") if re.search(r"关闭|关上|拉上|展开|\bclose\b", text, re.IGNORECASE): values.add("close") if re.search(r"半开|一半|\bhalf\b", text, re.IGNORECASE): values.add("half") if re.search(r"停止|停下|\bstop\b", text, re.IGNORECASE): values.add("stop") return values def _level_matches(text: str) -> set[int]: values = {int(value) for value in re.findall(r"([0-9]+)\s*档", text, re.IGNORECASE)} values.update(value for label, value in _LEVEL_WORDS.items() if label in text) return values def _axis_number_matches( text: str, axis: str, *, require_percent: bool = False, ) -> list[float]: """Return numbers bound to one outlet axis without crossing the other axis. A pattern such as ``水平25%垂直75%`` previously let the number before ``垂直`` win, so both axes could be associated with 25. Prefer values written after the requested axis label; only accept the reverse ``25%水平`` form when there is no after-label value. """ keywords = { "horizontal": r"水平|左右|横向|horizontal", "vertical": r"垂直|上下|纵向|vertical", }[axis] other_keywords = { "horizontal": r"垂直|上下|纵向|vertical", "vertical": r"水平|左右|横向|horizontal", }[axis] suffix = r"\s*[%%]" if require_percent else r"(?:\s*[%%])?" after = re.findall( rf"(?:{keywords})(?:(?!(?:{other_keywords})|[,,。;;]).){{0,12}}?" rf"({_NUMBER}){suffix}", text, re.IGNORECASE, ) if after: return [float(value) for value in after] before = re.findall( rf"({_NUMBER}){suffix}(?:(?!(?:{other_keywords})|[,,。;;]).){{0,12}}?" rf"(?:{keywords})", text, re.IGNORECASE, ) return [float(value) for value in before] def _slot_matches(text: str) -> set[int]: matches = re.findall( r"(?:记忆|位置|槽位|预设).{0,8}?([1-3])\s*(?:号|档|槽)?|" r"([1-3])\s*(?:号|档|槽).{0,8}?(?:记忆|位置|预设)", text, re.IGNORECASE, ) return {int(value) for pair in matches for value in pair if value} def _massage_strength_matches(text: str) -> set[int]: strengths = _level_matches(text) & {1, 2, 3} aliases = { 1: re.compile(r"弱档|低档|弱力度|低强度", re.IGNORECASE), 2: re.compile(r"中档|中等力度|中等强度", re.IGNORECASE), 3: re.compile(r"强档|高档|强力度|高强度", re.IGNORECASE), } strengths.update(value for value, pattern in aliases.items() if pattern.search(text)) return strengths def _semantic_parameter_error( capability: str, operation: str, parameters: Sequence[Mapping[str, object]], duration_ms: int | None, text: str, ) -> str | None: """Require model-selected values and directions to be provable from text.""" if not _direction_evidenced(capability, operation, text): return f"操作方向 {operation} 与原始文本不一致" values = {str(item["name"]): item.get("value") for item in parameters} state = values.get("state") if state is not None: evidenced = _state_values(capability, text) if state not in evidenced or len(evidenced) != 1: return f"状态参数 {state!r} 无法由原始文本唯一证明" if operation == "activate" and state not in {"on", "open", "lock"}: return "activate 与关闭类状态冲突" if operation == "deactivate" and state not in {"off", "close", "unlock"}: return "deactivate 与开启类状态冲突" motion = values.get("motion") if motion is not None: evidenced_motion = _motion_values(capability, text) if motion not in evidenced_motion or len(evidenced_motion) != 1: return f"运动方向 {motion!r} 无法由原始文本唯一证明" percent_parameters = { "position_pct", "brightness_pct", "transmittance_pct", "horizontal_pct", "vertical_pct", } for name in percent_parameters & values.keys(): value = values[name] matches: list[float] if name == "horizontal_pct": matches = _axis_number_matches(text, "horizontal", require_percent=True) if not matches: return "horizontal_pct 没有明确的水平百分比依据" elif name == "vertical_pct": matches = _axis_number_matches(text, "vertical", require_percent=True) if not matches: return "vertical_pct 没有明确的垂直百分比依据" else: matches = _percent_matches(text) if matches: if not any(_numeric_equal(value, expected) for expected in matches): return f"百分比参数 {name} 与原始文本数值不一致" else: return f"百分比参数 {name} 没有原始文本数值依据" temperature = values.get("temperature_c") if temperature is not None: temperatures = _number_matches(text, r"度|℃|°\s*[cC]|[cC]\b") if not temperatures: temperatures = [ float(item) for item in re.findall( rf"(?:温度|空调).{{0,10}}?({_NUMBER})(?!\s*[%%档])", text, re.IGNORECASE, ) ] if temperatures: if not any(_numeric_equal(temperature, expected) for expected in temperatures): return "temperature_c 与原始文本温度不一致" else: return "temperature_c 没有原始文本数值依据" for name in {"level", "brightness_level"} & values.keys(): levels = _level_matches(text) value = values[name] if levels: if not any(_numeric_equal(value, expected) for expected in levels): return f"档位参数 {name} 与原始文本不一致" else: return f"档位参数 {name} 没有原始文本依据" color_index = values.get("color_index") if color_index is not None: color_indexes = { int(value) for pair in re.findall( r"(?:色号|颜色(?:编号|索引)?|color[_ -]?index).{0,8}?([0-9]+)|" r"([0-9]+)\s*号色", text, re.IGNORECASE, ) for value in pair if value } if not any(_numeric_equal(color_index, item) for item in color_indexes): return "color_index 没有相同的原始文本编号依据" for name, axis in ( ("horizontal_value", "horizontal"), ("vertical_value", "vertical"), ): value = values.get(name) if value is not None and not any( _numeric_equal(value, item) for item in _axis_number_matches(text, axis) ): return f"{name} 没有相同的原始文本标定值依据" slot = values.get("slot") if slot is not None and not any(_numeric_equal(slot, item) for item in _slot_matches(text)): return "slot 没有相同的原始文本记忆位置依据" for name in {"angle_deg", "angle_delta_deg"} & values.keys(): angles = _number_matches(text, r"度|°") if not any(_numeric_equal(values[name], item) for item in angles): return f"{name} 没有相同的原始文本角度依据" distance = values.get("distance_mm") if distance is not None: distances = _number_matches(text, r"毫米|mm") distances.extend(item * 10 for item in _number_matches(text, r"厘米|cm")) if not any(_numeric_equal(distance, item) for item in distances): return "distance_mm 没有相同的原始文本距离依据" action = values.get("action") if action is not None: action_patterns = { "off": re.compile(r"关闭|关掉|停止|\boff\b", re.IGNORECASE), "strength": re.compile(r"强度|力度|弱档|中档|强档|低档|高档|\d+\s*档", re.IGNORECASE), "mode": re.compile(r"模式|波浪|脉冲|舒展|拉伸|松弛|放松|mode", re.IGNORECASE), } action_text = action_patterns.get(str(action)) if action_text is None or not action_text.search(text): return f"按摩 action {action!r} 没有原始文本依据" strength = values.get("strength") if strength is not None and not any( _numeric_equal(strength, item) for item in _massage_strength_matches(text) ): return "按摩 strength 没有相同的原始文本档位依据" mode_id = values.get("mode_id") if mode_id is not None: evidenced_modes: set[int] = set() if re.search(r"木马", text): evidenced_modes.add(1) if re.search(r"摇篮", text): evidenced_modes.add(2) if not any(_numeric_equal(mode_id, item) for item in evidenced_modes): return "mode_id 与原始文本动态座椅模式不一致" color_hex = values.get("color_hex") if color_hex is not None: normalized_color = str(color_hex).upper() color_text = _COLOR_TEXT.get(normalized_color) if normalized_color.casefold() not in text.casefold() and ( color_text is None or not color_text.search(text) ): return "color_hex 没有原始文本依据" mode = values.get("mode") if mode is not None: mode_text = _MODE_TEXT.get(str(mode)) if mode_text is None or not mode_text.search(text): return f"模式参数 {mode!r} 没有原始文本依据" axis = values.get("axis") if axis is not None: axis_text = _AXIS_TEXT.get(str(axis)) if axis_text is None or not axis_text.search(text): return f"轴参数 {axis!r} 没有原始文本依据" direction = values.get("direction") if direction is not None: direction_text = _DIRECTION_TEXT.get(str(direction)) if direction_text is None or not direction_text.search(text): return f"方向参数 {direction!r} 没有原始文本依据" preset_name = values.get("preset_name") if preset_name is not None and str(preset_name).casefold() not in text.casefold(): return "preset_name 没有原始文本依据" if duration_ms is not None and duration_ms not in _duration_matches(text): return "duration_ms 没有相同的原始文本时长依据" parameter_duration = values.get("duration_ms") if parameter_duration is not None and not any( _numeric_equal(parameter_duration, expected) for expected in _duration_matches(text) ): return "参数 duration_ms 没有相同的原始文本时长依据" return None def _intent_claims(intent: Mapping[str, Any]) -> set[str]: claims: set[str] = set() for key in ( "actions", "domain_actions", "native_can_actions", "deferred_requests", "model_requests", ): raw_items = intent.get(key, []) if not isinstance(raw_items, Sequence) or isinstance(raw_items, (str, bytes, bytearray)): continue for item in raw_items: if isinstance(item, Mapping): capability = _safe_text(item.get("capability"), limit=128) if capability: claims.add(capability) return claims def _resolved_keys(intent: Mapping[str, Any]) -> set[tuple[str, str, str]]: keys: set[tuple[str, str, str]] = set() for field in ("actions", "domain_actions", "native_can_actions", "model_requests"): raw_items = intent.get(field, []) if not isinstance(raw_items, Sequence) or isinstance(raw_items, (str, bytes, bytearray)): continue for item in raw_items: if not isinstance(item, Mapping): continue domain = _safe_text(item.get("domain"), limit=64) capability = _safe_text(item.get("capability"), limit=128) target = item.get("target_scope", item.get("target", "")) if isinstance(target, Mapping): target = target.get("zone", target.get("device", "")) target_text = _safe_text(target, limit=64) if domain and capability: keys.add((domain, capability, target_text)) return keys def _has_explicit_evidence(capability: str, text: str, claims: set[str]) -> bool: if capability.startswith("seat."): has_seat_context = bool( re.search( r"座椅|座位|靠背|椅背|坐垫|坐盆|腿托|按摩|座椅记忆|" r"木马|摇篮|动态座椅|动态模式|\bseat\b", text, re.IGNORECASE, ) ) has_other_device = bool( re.search( r"空调|压缩机|车窗|窗户|出风口|风口|阅读灯|室内灯|" r"氛围灯|遮阳帘|车门|门锁", text, re.IGNORECASE, ) ) # Occupant names describe a target, not a seat device. Thermal # shorthand remains usable only when no other device noun owns it. thermal_shorthand = bool( re.search(r"主驾|驾驶位|驾驶员|副驾|副驾驶|乘客位|乘员", text) and re.search(r"加热|通风|按摩", text) and not has_other_device ) if not has_seat_context and not thermal_shorthand: return False if capability == "climate.set_power" and re.search( r"压缩机|制冷|\ba/?c\b", text, re.IGNORECASE ): explicit_hvac_power = re.search( r"(?:打开|开启|启动|关闭|关掉|停止)空调" r"(?!的?压缩机|压缩机|制冷)|" r"空调(?:电源|开关)(?:打开|开启|关闭|关掉|on|off)", text, re.IGNORECASE, ) if explicit_hvac_power is None: return False if capability in claims: return True if capability.casefold() in text.casefold(): return True rule = _EXPLICIT_EVIDENCE.get(capability) return bool(rule and rule.matches(text)) def _normalize_parameter(raw: object, request_index: int) -> dict[str, object]: if not isinstance(raw, Mapping) or not set(raw).issubset(_PARAMETER_FIELDS): raise ValueError(f"assistant plan request {request_index} has an invalid parameter") name = _safe_text(raw.get("name"), limit=64) if not name: raise ValueError(f"assistant plan request {request_index} has an unnamed parameter") value = raw.get("value") if isinstance(value, float) and not math.isfinite(value): raise ValueError(f"assistant plan request {request_index} has a non-finite value") if value is not None and not isinstance(value, (str, int, float, bool)): raise ValueError(f"assistant plan request {request_index} has a non-scalar value") if isinstance(value, str): value = _safe_text(value, limit=240) return {"name": name, "value": value} def guard_assistant_plan( raw_plan: Mapping[str, Any], *, fallback_text: str, capability_context: Mapping[str, Any], executable_capabilities: AbstractSet[str], fallback_intents: Sequence[Mapping[str, Any]] = (), trusted_local: Mapping[str, Any] | None = None, allow_scene_composition: bool = False, scene_scope_utterance: str | None = None, ) -> GuardedAssistantPlan: """Authorize a Codex plan against the server-owned unresolved text. Rejected capability requests are omitted and represented as adapter-style ambiguities. Structurally malformed plans raise ``ValueError`` so callers can fail the tool call without invoking any executor. ``allow_scene_composition`` is reserved for a name-only mode-creation preview: it relaxes textual value evidence, but never registry membership, schema/range validation, dry-run, or the user's explicit save confirmation. """ if ( not isinstance(raw_plan, Mapping) or not _ROOT_FIELDS.issubset(raw_plan) or set(raw_plan) - _ROOT_FIELDS - _OPTIONAL_ROOT_FIELDS ): raise ValueError("assistant plan must be a structured intent object") try: encoded = json.dumps(raw_plan, ensure_ascii=False, allow_nan=False).encode("utf-8") except (TypeError, ValueError) as exc: raise ValueError("assistant plan must contain JSON values") from exc if len(encoded) > MAX_PLAN_BYTES: raise ValueError("assistant plan is too large") plan_metadata: dict[str, str] | None = None if "metadata" in raw_plan: raw_metadata = raw_plan.get("metadata") if ( not isinstance(raw_metadata, Mapping) or set(raw_metadata) != _SCENE_TEMPLATE_METADATA_FIELDS or raw_metadata.get("source") != "scene_template" or not _SCENE_TEMPLATE_ID.fullmatch( str(raw_metadata.get("template_id") or "") ) or not _SCENE_TEMPLATE_REVISION.fullmatch( str(raw_metadata.get("template_revision") or "") ) ): raise ValueError("assistant plan metadata is invalid") if not allow_scene_composition: raise ValueError("assistant plan metadata is only valid for scene composition") plan_metadata = { "source": "scene_template", "template_id": str(raw_metadata["template_id"]), "template_revision": str(raw_metadata["template_revision"]), } requests = raw_plan.get("requests", []) if not isinstance(requests, Sequence) or isinstance(requests, (str, bytes, bytearray)): raise ValueError("assistant plan requests must be an array") # noqa: TRY004 if len(requests) > MAX_PLAN_REQUESTS: raise ValueError("assistant plan contains too many requests") text = _safe_text(fallback_text, limit=4000) registered = _registered_capabilities( capability_context, executable_capabilities, ) registered_specs = _registered_capability_specs( capability_context, executable_capabilities, ) parser_claims: set[str] = set() for intent in fallback_intents: if isinstance(intent, Mapping): parser_claims.update(_intent_claims(intent)) local_keys = _resolved_keys(trusted_local or {}) non_execution = bool(_NON_EXECUTION_PATTERN.search(text)) # An explicit empty internal scope is reserved for already validated saved # schedules/draft revisions. New scenes use passenger when no seat is named. scene_seat_scopes = None if allow_scene_composition and scene_scope_utterance != "": from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes scene_seat_scopes = creation_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance) accepted: list[dict[str, Any]] = [] rejected: list[dict[str, str]] = [] raw_ambiguities = raw_plan.get("ambiguities", []) if allow_scene_composition: if not isinstance(raw_ambiguities, Sequence) or isinstance( raw_ambiguities, (str, bytes, bytearray), ): raise ValueError("assistant scene plan ambiguities must be an array") if raw_ambiguities: rejected.append( { "field": "ambiguities", "question": "场景候选计划仍有未解决歧义,已拒绝生成部分模式。", } ) scene_keys: set[tuple[str, str, str]] = set() for index, raw_request in enumerate(requests): if not isinstance(raw_request, Mapping) or set(raw_request) != _REQUEST_FIELDS: raise ValueError(f"assistant plan request {index} is invalid") domain = _safe_text(raw_request.get("domain"), limit=64) capability = _safe_text(raw_request.get("capability"), limit=128) target_scope = _safe_text(raw_request.get("target_scope"), limit=64) operation = _safe_text(raw_request.get("operation"), limit=32) if not domain or not capability or not target_scope or operation not in _OPERATIONS: raise ValueError(f"assistant plan request {index} is incomplete") if registered.get(capability) != domain: rejected.append( { "field": f"requests[{index}].capability", "question": "模型计划引用了未登记或领域不一致的能力,已拒绝执行。", } ) continue if allow_scene_composition and capability not in SCENE_COMPOSABLE_CAPABILITIES: rejected.append( { "field": f"requests[{index}].capability", "question": "该能力不是可重复功能模式允许组合的绝对状态能力。", } ) continue if allow_scene_composition and operation != "set": rejected.append( { "field": f"requests[{index}].operation", "question": "功能模式只接受可重复的绝对 set 操作。", } ) continue if ( allow_scene_composition and domain == "seat" and scene_seat_scopes is not None and ( "unsupported" in scene_seat_scopes or {"front_left": "driver", "front_right": "passenger"}.get( target_scope, target_scope ) not in scene_seat_scopes ) ): rejected.append( { "field": f"requests[{index}].target_scope", "question": "场景座椅动作超出了用户明确指定的座位范围,已拒绝生成该功能模式。", } ) continue request_text = _relevant_text(capability, text) if non_execution or ( not allow_scene_composition and not _has_explicit_evidence( capability, request_text, parser_claims, ) ): rejected.append( { "field": f"requests[{index}].capability", "question": (f"原始未解析文本没有明确涉及能力 {capability},已拒绝模型扩展。"), } ) continue if ( not allow_scene_composition and not _target_is_evidenced(capability, target_scope, request_text) ): rejected.append( { "field": f"requests[{index}].target_scope", "question": ( f"目标范围 {target_scope!r} 未由原始未解析文本明确支持,已拒绝执行。" ), } ) continue parameters = raw_request.get("parameters", []) if not isinstance(parameters, Sequence) or isinstance(parameters, (str, bytes, bytearray)): raise ValueError( # noqa: TRY004 f"assistant plan request {index} parameters must be an array" ) if len(parameters) > MAX_PLAN_PARAMETERS: raise ValueError(f"assistant plan request {index} has too many parameters") normalized_parameters = [_normalize_parameter(parameter, index) for parameter in parameters] names = [str(item["name"]) for item in normalized_parameters] if len(names) != len(set(names)): raise ValueError(f"assistant plan request {index} repeats a parameter") if allow_scene_composition: registered_spec = registered_specs.get(capability) contract_error = ( _scene_contract_error( capability, target_scope, normalized_parameters, registered_spec[1], ) if registered_spec is not None else "能力契约不可用" ) if contract_error is not None: rejected.append( { "field": f"requests[{index}]", "question": f"{contract_error},已拒绝生成该功能模式。", } ) continue scene_key = (domain, capability, target_scope) if allow_scene_composition and scene_key in scene_keys: rejected.append( { "field": f"requests[{index}]", "question": "功能模式包含重复的能力和目标,已拒绝生成部分模式。", } ) continue scene_keys.add(scene_key) local_key = (domain, capability, target_scope) if local_key in local_keys or ( target_scope == "unknown" and any(key[:2] == local_key[:2] for key in local_keys) ): # A model may supplement unresolved text, never replace a locally # resolved value for the same capability and target. continue duration = raw_request.get("duration_ms") if duration is not None and ( isinstance(duration, bool) or not isinstance(duration, int) or not 0 <= duration <= 600_000 ): raise ValueError(f"assistant plan request {index} has invalid duration_ms") if allow_scene_composition and duration is not None: rejected.append( { "field": f"requests[{index}].duration_ms", "question": "功能模式只接受无时长的稳定目标状态。", } ) continue semantic_error = ( None if allow_scene_composition else _semantic_parameter_error( capability, operation, normalized_parameters, duration, request_text, ) ) if semantic_error is not None: rejected.append( { "field": f"requests[{index}].parameters", "question": f"{semantic_error},已拒绝模型计划。", } ) continue accepted.append( { "domain": domain, "capability": capability, "target_scope": target_scope, "operation": operation, "duration_ms": duration, "parameters": normalized_parameters, "reason": _safe_text(raw_request.get("reason"), limit=500), } ) if allow_scene_composition and rejected: # A reusable mode must be atomic. Never offer a draft that silently # dropped one hallucinated, conflicting, relative, or transient step. accepted = [] summaries = raw_plan.get("decision_summary", []) if not isinstance(summaries, Sequence) or isinstance(summaries, (str, bytes, bytearray)): summaries = [] decision_summary = [text for item in summaries[:6] if (text := _safe_text(item, limit=240))] if rejected: decision_summary.append( f"安全授权已过滤 {len(rejected)} 条未由原始请求明确支持的模型能力。" ) detected_domains = list(dict.fromkeys(item["domain"] for item in accepted)) or ["seat"] confidence = raw_plan.get("confidence", 0) if isinstance(confidence, bool) or not isinstance(confidence, (int, float)): confidence = 0 confidence = max(0.0, min(float(confidence), 1.0)) normalized = { "detected_domains": detected_domains, "requests": accepted, "ambiguities": [*rejected], "decision_summary": decision_summary or ["小迪生成的计划已通过原始请求能力约束。"], "confidence": confidence, } if plan_metadata is not None: normalized["metadata"] = plan_metadata return GuardedAssistantPlan(normalized, tuple(rejected)) __all__ = [ "MAX_PLAN_BYTES", "MAX_PLAN_PARAMETERS", "MAX_PLAN_REQUESTS", "SCENE_COMPOSABLE_CAPABILITIES", "SCENE_PARAMETER_BOUNDS", "GuardedAssistantPlan", "guard_assistant_plan", ]