fix: compose named cockpit modes from capabilities

This commit is contained in:
vee
2026-09-07 20:52:19 +08:00
parent cf9b89508c
commit 6b2b644ee6
23 changed files with 2917 additions and 124 deletions
@@ -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 = []