Files
Cognitive_Agent/src/cognitive_agent/application/assistant_plan_guard.py
T

1404 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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",
]