1404 lines
58 KiB
Python
1404 lines
58 KiB
Python
"""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"前排|(?<!当)前座|前舱|front[_ -]?row", re.IGNORECASE),
|
||
),
|
||
(frozenset({"rear", "rear_row"}), re.compile(r"后排|后座|二排|第二排|后舱|后门|rear[_ -]?row", re.IGNORECASE)),
|
||
(frozenset(), re.compile(r"第三排|三排|左侧|右侧|左边|右边", re.IGNORECASE)),
|
||
(frozenset({"middle_left"}), re.compile(r"中排左|middle[_ -]?left", re.IGNORECASE)),
|
||
(frozenset({"middle_right"}), re.compile(r"中排右|middle[_ -]?right", re.IGNORECASE)),
|
||
(
|
||
frozenset({"middle_left_rotary"}),
|
||
re.compile(r"中排左旋钮|middle[_ -]?left[_ -]?rotary", re.IGNORECASE),
|
||
),
|
||
(
|
||
frozenset({"middle_right_rotary"}),
|
||
re.compile(r"中排右旋钮|middle[_ -]?right[_ -]?rotary", re.IGNORECASE),
|
||
),
|
||
)
|
||
_TARGET_ALIASES = {
|
||
"driver": "front_left",
|
||
"left_front": "front_left",
|
||
"passenger": "front_right",
|
||
"right_front": "front_right",
|
||
"left_rear": "rear_left",
|
||
"right_rear": "rear_right",
|
||
}
|
||
_LEVEL_WORDS = {
|
||
"一档": 1,
|
||
"1档": 1,
|
||
"二档": 2,
|
||
"两档": 2,
|
||
"2档": 2,
|
||
"三档": 3,
|
||
"3档": 3,
|
||
"四档": 4,
|
||
"4档": 4,
|
||
"五档": 5,
|
||
"5档": 5,
|
||
"六档": 6,
|
||
"6档": 6,
|
||
"七档": 7,
|
||
"7档": 7,
|
||
}
|
||
_COLOR_TEXT: dict[str, re.Pattern[str]] = {
|
||
"#FF0000": re.compile(r"红色|红灯|正红", re.IGNORECASE),
|
||
"#00FF00": re.compile(r"绿色|绿灯", re.IGNORECASE),
|
||
"#00FFFF": re.compile(r"青色|青灯|cyan", re.IGNORECASE),
|
||
"#0000FF": re.compile(r"蓝色|蓝灯", re.IGNORECASE),
|
||
"#FFFFFF": re.compile(r"(?<!暖)白色|(?<!暖)白灯|white", re.IGNORECASE),
|
||
"#FFF1D6": re.compile(r"暖白色|暖白灯|warm\s+white", re.IGNORECASE),
|
||
"#FFFF00": re.compile(r"黄色|黄灯", re.IGNORECASE),
|
||
"#FFA500": re.compile(r"橙色|橘色", re.IGNORECASE),
|
||
"#800080": re.compile(r"紫色|紫灯", re.IGNORECASE),
|
||
}
|
||
_MODE_TEXT: dict[str, re.Pattern[str]] = {
|
||
"internal": re.compile(r"内循环|internal", re.IGNORECASE),
|
||
"external": re.compile(r"外循环|external", re.IGNORECASE),
|
||
"manual": re.compile(r"手动|manual", re.IGNORECASE),
|
||
"auto": re.compile(r"自动|auto", re.IGNORECASE),
|
||
"face": re.compile(r"吹面|面部|face", re.IGNORECASE),
|
||
"face_feet": re.compile(r"面脚|吹面.{0,4}吹脚|face[_ -]?feet", re.IGNORECASE),
|
||
"feet": re.compile(r"吹脚|脚部|feet", re.IGNORECASE),
|
||
"feet_defrost": re.compile(r"吹脚.{0,4}除(?:雾|霜)|feet[_ -]?defrost", re.IGNORECASE),
|
||
"defrost": re.compile(r"除雾|除霜|defrost", re.IGNORECASE),
|
||
"face_feet_defrost": re.compile(
|
||
r"面脚.{0,4}除(?:雾|霜)|face[_ -]?feet[_ -]?defrost", re.IGNORECASE
|
||
),
|
||
"face_defrost": re.compile(r"吹面.{0,4}除(?:雾|霜)|face[_ -]?defrost", re.IGNORECASE),
|
||
"camping": re.compile(r"露营|camping", re.IGNORECASE),
|
||
"rest": re.compile(r"休息|小憩|rest", re.IGNORECASE),
|
||
"cinema": re.compile(r"观影|影院|cinema", re.IGNORECASE),
|
||
"meditation": re.compile(r"冥想|meditation", re.IGNORECASE),
|
||
"romantic": re.compile(r"浪漫|romantic", re.IGNORECASE),
|
||
"wave": re.compile(r"波浪|wave", re.IGNORECASE),
|
||
"pulse": re.compile(r"脉冲|pulse", re.IGNORECASE),
|
||
"stretch": re.compile(r"舒展|拉伸|stretch", re.IGNORECASE),
|
||
"slack": re.compile(r"松弛|slack", re.IGNORECASE),
|
||
"relax": re.compile(r"放松|relax", re.IGNORECASE),
|
||
"off": re.compile(r"关闭|关掉|停止|off", re.IGNORECASE),
|
||
"on": re.compile(r"打开|开启|启动|on", re.IGNORECASE),
|
||
}
|
||
_AXIS_TEXT: dict[str, re.Pattern[str]] = {
|
||
"horizontal": re.compile(r"前后|水平|纵向|horizontal", re.IGNORECASE),
|
||
"height": re.compile(r"高度|升降|height", re.IGNORECASE),
|
||
"backrest": re.compile(r"靠背|椅背|backrest", re.IGNORECASE),
|
||
"basin": re.compile(r"坐盆|坐垫|盆倾|basin", re.IGNORECASE),
|
||
}
|
||
_DIRECTION_TEXT: dict[str, re.Pattern[str]] = {
|
||
"forward": re.compile(r"向前|前移|forward", re.IGNORECASE),
|
||
"backward": re.compile(r"向后|后移|backward", re.IGNORECASE),
|
||
"up": re.compile(r"向上|升高|抬高|up", re.IGNORECASE),
|
||
"down": re.compile(r"向下|降低|放低|down", re.IGNORECASE),
|
||
"stop": re.compile(r"停止|停下|stop", re.IGNORECASE),
|
||
}
|
||
|
||
|
||
def _safe_text(value: object, *, limit: int) -> 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",
|
||
]
|