fix: compose named cockpit modes from capabilities
This commit is contained in:
@@ -30,6 +30,47 @@ MAX_PLAN_REQUESTS = 12
|
||||
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",
|
||||
"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"}
|
||||
)
|
||||
@@ -404,6 +445,123 @@ def _registered_capabilities(
|
||||
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."""
|
||||
|
||||
@@ -920,12 +1078,16 @@ def guard_assistant_plan(
|
||||
executable_capabilities: AbstractSet[str],
|
||||
fallback_intents: Sequence[Mapping[str, Any]] = (),
|
||||
trusted_local: Mapping[str, Any] | None = None,
|
||||
allow_scene_composition: bool = False,
|
||||
) -> 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.
|
||||
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 set(raw_plan) != _ROOT_FIELDS:
|
||||
@@ -948,6 +1110,10 @@ def guard_assistant_plan(
|
||||
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):
|
||||
@@ -957,6 +1123,21 @@ def guard_assistant_plan(
|
||||
|
||||
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")
|
||||
@@ -974,11 +1155,30 @@ def guard_assistant_plan(
|
||||
}
|
||||
)
|
||||
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
|
||||
request_text = _relevant_text(capability, text)
|
||||
if non_execution or not _has_explicit_evidence(
|
||||
capability,
|
||||
request_text,
|
||||
parser_claims,
|
||||
if non_execution or (
|
||||
not allow_scene_composition
|
||||
and not _has_explicit_evidence(
|
||||
capability,
|
||||
request_text,
|
||||
parser_claims,
|
||||
)
|
||||
):
|
||||
rejected.append(
|
||||
{
|
||||
@@ -987,7 +1187,10 @@ def guard_assistant_plan(
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not _target_is_evidenced(capability, target_scope, request_text):
|
||||
if (
|
||||
not allow_scene_composition
|
||||
and not _target_is_evidenced(capability, target_scope, request_text)
|
||||
):
|
||||
rejected.append(
|
||||
{
|
||||
"field": f"requests[{index}].target_scope",
|
||||
@@ -1009,6 +1212,37 @@ def guard_assistant_plan(
|
||||
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 (
|
||||
@@ -1025,12 +1259,24 @@ def guard_assistant_plan(
|
||||
or not 0 <= duration <= 600_000
|
||||
):
|
||||
raise ValueError(f"assistant plan request {index} has invalid duration_ms")
|
||||
semantic_error = _semantic_parameter_error(
|
||||
capability,
|
||||
operation,
|
||||
normalized_parameters,
|
||||
duration,
|
||||
request_text,
|
||||
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(
|
||||
@@ -1052,6 +1298,11 @@ def guard_assistant_plan(
|
||||
}
|
||||
)
|
||||
|
||||
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 = []
|
||||
|
||||
Reference in New Issue
Block a user