feat: make mode creation preview-only and cache-safe
This commit is contained in:
@@ -28,6 +28,10 @@ from cognitive_agent.application.mode_profile_intent import (
|
||||
parse_mode_profile_delete,
|
||||
parse_mode_profile_update,
|
||||
)
|
||||
from cognitive_agent.application.mode_creation_seat_policy import (
|
||||
ModeCreationSeatPolicyError,
|
||||
project_seed_plan,
|
||||
)
|
||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
||||
from cognitive_agent.application.mode_schedule_service import public_schedule_status
|
||||
from cognitive_agent.application.scene_composer import (
|
||||
@@ -39,6 +43,8 @@ from cognitive_agent.application.scene_composer import (
|
||||
build_scene_output_schema,
|
||||
normalize_scene_output,
|
||||
scene_context_for_mode_creation,
|
||||
scene_has_explicit_control_object,
|
||||
scene_required_native_fallbacks,
|
||||
scene_required_peripheral_refs,
|
||||
scene_semantic_guidance,
|
||||
)
|
||||
@@ -963,9 +969,40 @@ class CodexAssistantService:
|
||||
)
|
||||
if str(capability_id) in live_scene_capabilities
|
||||
]
|
||||
include_default_bundle = (
|
||||
not needs_mode_schedule(message)
|
||||
and not scene_has_explicit_control_object(
|
||||
message,
|
||||
scene_context,
|
||||
)
|
||||
)
|
||||
required_peripheral_refs = scene_required_peripheral_refs(
|
||||
mode_parts[0],
|
||||
scene_context,
|
||||
include_default_bundle=include_default_bundle,
|
||||
)
|
||||
required_native_fallbacks = scene_required_native_fallbacks(
|
||||
mode_parts[0],
|
||||
scene_context,
|
||||
include_default_bundle=include_default_bundle,
|
||||
)
|
||||
semantic_guidance["required_native_domains"] = list(dict.fromkeys(
|
||||
str(item.get("domain") or "")
|
||||
for item in required_native_fallbacks
|
||||
if str(item.get("domain") or "")
|
||||
))
|
||||
semantic_guidance["required_native_controls"] = [
|
||||
{
|
||||
"capability": str(item.get("capability") or ""),
|
||||
"target_scope": str(item.get("target_scope") or ""),
|
||||
}
|
||||
for item in required_native_fallbacks
|
||||
]
|
||||
semantic_guidance["native_fallback_requests"] = (
|
||||
required_native_fallbacks
|
||||
)
|
||||
semantic_guidance["default_control_bundle_applied"] = (
|
||||
include_default_bundle
|
||||
)
|
||||
raw_peripheral_actions = scene_context.get("peripheral_actions", [])
|
||||
semantic_guidance["peripheral_policy"] = {
|
||||
@@ -981,8 +1018,11 @@ class CodexAssistantService:
|
||||
"one stable state action per device."
|
||||
),
|
||||
"required_reason": (
|
||||
"Romantic and Mid-Autumn related scenes must include the "
|
||||
"authorized flashing-light control."
|
||||
"Name-only scenes must include the available authorized "
|
||||
"flashing-light, ambient-light and light-strip controls."
|
||||
if include_default_bundle and required_peripheral_refs
|
||||
else "Romantic and Mid-Autumn related scenes must include the "
|
||||
"authorized flashing-light and light-strip controls."
|
||||
if required_peripheral_refs
|
||||
else "No peripheral action is mandatory for this name."
|
||||
),
|
||||
@@ -1430,7 +1470,7 @@ class CodexAssistantService:
|
||||
*,
|
||||
maximum_requests: int = MAX_PLAN_REQUESTS,
|
||||
) -> dict[str, object]:
|
||||
"""Add mandatory opaque peripheral refs without exposing binding data."""
|
||||
"""Enforce required native domains and opaque peripheral references."""
|
||||
|
||||
normalized = cls._copy_scene_plan(plan)
|
||||
requests = normalized.get("requests")
|
||||
@@ -1452,10 +1492,131 @@ class CodexAssistantService:
|
||||
}
|
||||
required_refs = cls._required_scene_peripheral_refs(scene_context)
|
||||
missing_refs = [ref for ref in required_refs if ref not in existing_refs]
|
||||
if len(requests) + len(missing_refs) > maximum_requests:
|
||||
raise SceneCompositionError(
|
||||
"required peripheral actions exceed the scene action limit"
|
||||
semantics = scene_context.get("scene_semantics")
|
||||
raw_native_fallbacks = (
|
||||
semantics.get("native_fallback_requests", [])
|
||||
if isinstance(semantics, Mapping)
|
||||
else []
|
||||
)
|
||||
native_fallbacks = [
|
||||
deepcopy(dict(item))
|
||||
for item in (
|
||||
raw_native_fallbacks
|
||||
if isinstance(raw_native_fallbacks, Sequence)
|
||||
and not isinstance(raw_native_fallbacks, (str, bytes, bytearray))
|
||||
else []
|
||||
)
|
||||
if isinstance(item, Mapping)
|
||||
]
|
||||
|
||||
flexible_native_values = {
|
||||
"climate.set_temperature",
|
||||
"climate.set_fan_level",
|
||||
"climate.set_cycle_mode",
|
||||
"roof.set_shade_position",
|
||||
"seat.set_backrest_recline",
|
||||
"seat.set_legrest_height",
|
||||
}
|
||||
|
||||
def parameter_map(request: Mapping[str, object]) -> dict[str, object]:
|
||||
raw_parameters = request.get("parameters", [])
|
||||
return {
|
||||
str(parameter.get("name") or ""): parameter.get("value")
|
||||
for parameter in (
|
||||
raw_parameters
|
||||
if isinstance(raw_parameters, Sequence)
|
||||
and not isinstance(raw_parameters, (str, bytes, bytearray))
|
||||
else []
|
||||
)
|
||||
if isinstance(parameter, Mapping)
|
||||
and str(parameter.get("name") or "")
|
||||
}
|
||||
|
||||
def native_requirement_satisfied(
|
||||
request: Mapping[str, object],
|
||||
fallback: Mapping[str, object],
|
||||
) -> bool:
|
||||
capability = str(fallback.get("capability") or "")
|
||||
target_scope = str(fallback.get("target_scope") or "")
|
||||
if (
|
||||
request.get("capability") != capability
|
||||
or request.get("target_scope") != target_scope
|
||||
):
|
||||
return False
|
||||
if capability in flexible_native_values:
|
||||
return True
|
||||
required_parameters = parameter_map(fallback)
|
||||
actual_parameters = parameter_map(request)
|
||||
return all(
|
||||
actual_parameters.get(name) == value
|
||||
for name, value in required_parameters.items()
|
||||
)
|
||||
|
||||
missing_native: list[dict[str, object]] = []
|
||||
replaced_native = 0
|
||||
for fallback in native_fallbacks:
|
||||
matching_indexes = [
|
||||
index
|
||||
for index, request in enumerate(requests)
|
||||
if isinstance(request, Mapping)
|
||||
and request.get("capability") == fallback.get("capability")
|
||||
and request.get("target_scope") == fallback.get("target_scope")
|
||||
]
|
||||
if any(
|
||||
native_requirement_satisfied(requests[index], fallback)
|
||||
for index in matching_indexes
|
||||
):
|
||||
continue
|
||||
if matching_indexes:
|
||||
requests[matching_indexes[0]] = deepcopy(fallback)
|
||||
replaced_native += 1
|
||||
continue
|
||||
missing_native.append(fallback)
|
||||
native_changed = bool(missing_native or replaced_native)
|
||||
metadata = normalized.get("metadata")
|
||||
if (
|
||||
native_changed
|
||||
and isinstance(metadata, Mapping)
|
||||
and metadata.get("source") == "scene_template"
|
||||
):
|
||||
scope_policy = scene_context.get("seat_scope_policy")
|
||||
raw_selected_scopes = (
|
||||
scope_policy.get("selected_scopes", [])
|
||||
if isinstance(scope_policy, Mapping)
|
||||
else []
|
||||
)
|
||||
selected_scopes = frozenset(
|
||||
str(scope)
|
||||
for scope in (
|
||||
raw_selected_scopes
|
||||
if isinstance(raw_selected_scopes, Sequence)
|
||||
and not isinstance(
|
||||
raw_selected_scopes,
|
||||
(str, bytes, bytearray),
|
||||
)
|
||||
else []
|
||||
)
|
||||
if str(scope)
|
||||
)
|
||||
if selected_scopes:
|
||||
try:
|
||||
normalized = project_seed_plan(normalized, selected_scopes)
|
||||
except ModeCreationSeatPolicyError as exc:
|
||||
raise SceneCompositionError(str(exc)) from exc
|
||||
projected_requests = normalized.get("requests")
|
||||
if not isinstance(projected_requests, list):
|
||||
raise SceneCompositionError(
|
||||
"projected scene template requests must be an array"
|
||||
)
|
||||
requests = projected_requests
|
||||
if (
|
||||
len(requests) + len(missing_native) + len(missing_refs)
|
||||
> maximum_requests
|
||||
):
|
||||
raise SceneCompositionError(
|
||||
"required scene controls exceed the scene action limit"
|
||||
)
|
||||
requests.extend(missing_native)
|
||||
requests.extend(
|
||||
{
|
||||
"domain": "self_extension",
|
||||
@@ -1473,14 +1634,28 @@ class CodexAssistantService:
|
||||
}
|
||||
for capability_ref in missing_refs
|
||||
)
|
||||
if missing_refs:
|
||||
if native_changed or missing_refs:
|
||||
if native_changed:
|
||||
# The trusted template remains the source seed, but adding a
|
||||
# native domain makes this an ordinary fully guarded semantic
|
||||
# composition. First project its seat variants to the user's
|
||||
# selected scope above, then remove metadata because template
|
||||
# metadata requires byte-identical base requests.
|
||||
normalized.pop("metadata", None)
|
||||
domains = normalized.get("detected_domains")
|
||||
if isinstance(domains, list) and "self_extension" not in domains:
|
||||
domains.append("self_extension")
|
||||
if isinstance(domains, list):
|
||||
for fallback in missing_native:
|
||||
domain = str(fallback.get("domain") or "")
|
||||
if domain and domain not in domains:
|
||||
domains.append(domain)
|
||||
if missing_refs and "self_extension" not in domains:
|
||||
domains.append("self_extension")
|
||||
summaries = normalized.get("decision_summary")
|
||||
if isinstance(summaries, list):
|
||||
summaries.append(
|
||||
f"已按模式语义加入 {len(missing_refs)} 项必需的已授权外设动作。"
|
||||
"已按模式语义补齐 "
|
||||
f"{len(missing_native) + replaced_native} 项原生控制和 "
|
||||
f"{len(missing_refs)} 项已授权外设动作。"
|
||||
)
|
||||
return normalized
|
||||
|
||||
@@ -1594,7 +1769,10 @@ class CodexAssistantService:
|
||||
message_id=message_id,
|
||||
emit=emit,
|
||||
execute_control=execute_control,
|
||||
assistant_plan=template_plan,
|
||||
assistant_plan=self._with_required_scene_peripherals(
|
||||
template_plan,
|
||||
scene_context,
|
||||
),
|
||||
response_route="scene_template",
|
||||
)
|
||||
base_requests = template_plan.get("requests", [])
|
||||
@@ -1627,7 +1805,14 @@ class CodexAssistantService:
|
||||
0,
|
||||
MAX_PLAN_REQUESTS
|
||||
- len(base_requests)
|
||||
- len(self._required_scene_peripheral_refs(scene_context)),
|
||||
- len(self._required_scene_peripheral_refs(scene_context))
|
||||
- len(
|
||||
(
|
||||
scene_context.get("scene_semantics") or {}
|
||||
).get("native_fallback_requests", [])
|
||||
if isinstance(scene_context.get("scene_semantics"), Mapping)
|
||||
else []
|
||||
),
|
||||
)
|
||||
augmentation_semantics = augmentation_context.get("scene_semantics")
|
||||
if isinstance(augmentation_semantics, dict):
|
||||
@@ -1654,6 +1839,7 @@ class CodexAssistantService:
|
||||
deadline=deadline,
|
||||
developer_instructions=SCENE_TEMPLATE_AUGMENTATION_INSTRUCTIONS,
|
||||
dynamic_override=False,
|
||||
enforce_required_controls=False,
|
||||
)
|
||||
proposed = augmentation.get("requests", [])
|
||||
if not isinstance(proposed, list) or any(
|
||||
@@ -1670,6 +1856,10 @@ class CodexAssistantService:
|
||||
*deepcopy(base_requests),
|
||||
*deepcopy(proposed),
|
||||
]
|
||||
combined = self._with_required_scene_peripherals(
|
||||
combined,
|
||||
scene_context,
|
||||
)
|
||||
return self._stream_direct_vehicle_control(
|
||||
_VehicleAuthorization(message, "explicit_mode_profile_create"),
|
||||
message_id=message_id,
|
||||
@@ -1850,6 +2040,7 @@ class CodexAssistantService:
|
||||
deadline: float,
|
||||
developer_instructions: str | None = None,
|
||||
dynamic_override: bool | None = None,
|
||||
enforce_required_controls: bool = True,
|
||||
) -> dict[str, object]:
|
||||
start_structured_turn = getattr(self.client, "start_structured_turn", None)
|
||||
if not callable(start_structured_turn):
|
||||
@@ -2063,7 +2254,7 @@ class CodexAssistantService:
|
||||
if not isinstance(raw_output, Mapping):
|
||||
raise SceneCompositionError("scene composer output must be an object")
|
||||
normalized = normalize_scene_output(raw_output, planner_context, dynamic=dynamic)
|
||||
if not dynamic:
|
||||
if not dynamic and enforce_required_controls:
|
||||
normalized = self._with_required_scene_peripherals(
|
||||
normalized,
|
||||
planner_context,
|
||||
@@ -2830,11 +3021,11 @@ class CodexAssistantService:
|
||||
if draft.get("generation_source") == "registered_mode_profile":
|
||||
return (
|
||||
f"工程中已有“{name}”,已按当前车型载入预览并验证"
|
||||
f"{operation_count}项设备动作。可以先试运行,确认后更新现有模式。"
|
||||
f"{operation_count}项设备动作。创建阶段未下发,确认后可更新现有模式。"
|
||||
)
|
||||
return (
|
||||
f"已按你提供的设置为现有“{name}”生成预览并验证"
|
||||
f"{operation_count}项设备动作。可以先试运行,确认后更新现有模式。"
|
||||
f"{operation_count}项设备动作。创建阶段未下发,确认后可更新现有模式。"
|
||||
)
|
||||
if (
|
||||
isinstance(draft, Mapping)
|
||||
@@ -2851,7 +3042,7 @@ class CodexAssistantService:
|
||||
return (
|
||||
f"已从常用模式模板快速生成“{name}”,并按当前工程能力验证了"
|
||||
f"{operation_count}项设备动作。当前只是独立草稿,可以继续调整;"
|
||||
"确认预览后可保存为新的功能模式,也可以先试运行。"
|
||||
"创建阶段不会下发,确认预览后可保存为新的功能模式。"
|
||||
)
|
||||
if (
|
||||
isinstance(draft, Mapping)
|
||||
@@ -2867,9 +3058,9 @@ class CodexAssistantService:
|
||||
)
|
||||
return (
|
||||
f"已理解“{name}”的场景意图,并从当前工程能力中组合、验证了"
|
||||
f"{operation_count}项设备动作。确认预览后可保存为功能模式,也可以先试运行。"
|
||||
f"{operation_count}项设备动作。创建阶段不会下发,确认预览后可保存。"
|
||||
)
|
||||
return f"已为你生成“{name}”预览。确认动作后可保存为功能模式,也可以先试运行。"
|
||||
return f"已为你生成“{name}”预览。创建阶段不会下发,确认动作后可保存。"
|
||||
|
||||
@staticmethod
|
||||
def _mode_profile_management_answer(
|
||||
@@ -4073,7 +4264,9 @@ class CodexAssistantService:
|
||||
if isinstance(intent_ir, dict):
|
||||
spec = intent_ir.get("spec")
|
||||
if isinstance(spec, dict) and isinstance(spec.get("actions"), list):
|
||||
for item in spec["actions"][:96 if result.get("schedule") else 12]:
|
||||
for item in spec["actions"][
|
||||
:96 if result.get("schedule") else MAX_PLAN_REQUESTS
|
||||
]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
safe_action = {
|
||||
@@ -4101,7 +4294,7 @@ class CodexAssistantService:
|
||||
infrared_actions: list[dict[str, object]] = []
|
||||
raw_infrared = result.get("infrared_actions")
|
||||
if isinstance(raw_infrared, list):
|
||||
for item in raw_infrared[:12]:
|
||||
for item in raw_infrared[:MAX_PLAN_REQUESTS]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
infrared_actions.append(
|
||||
@@ -4152,7 +4345,7 @@ class CodexAssistantService:
|
||||
for key in ("domain", "capability", "target_scope", "parameters")
|
||||
if key in item
|
||||
}
|
||||
for item in mode_profile_changes[:12]
|
||||
for item in mode_profile_changes[:MAX_PLAN_REQUESTS]
|
||||
if isinstance(item, Mapping)
|
||||
]
|
||||
deleted_mode_profile = result.get("deleted_mode_profile")
|
||||
|
||||
Reference in New Issue
Block a user