feat: integrate learned peripherals into mode creation
Expose safe NFC-bound infrared actions to semantic scene composition, require flashing-light actions for romantic and Mid-Autumn modes, and preserve mixed CAN/IR mode flows. Fix NFC device reassignment and retry extension/toy MQTT publication when an active card's binding catalog changes.
This commit is contained in:
@@ -14,10 +14,12 @@ import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from cognitive_agent.application.assistant_plan_guard import MAX_PLAN_REQUESTS
|
||||
from cognitive_agent.application.mode_profile_intent import (
|
||||
is_mode_profile_query,
|
||||
mode_creation_source_name,
|
||||
@@ -31,11 +33,14 @@ from cognitive_agent.application.mode_schedule_service import public_schedule_st
|
||||
from cognitive_agent.application.scene_composer import (
|
||||
SCENE_COMPOSER_INSTRUCTIONS,
|
||||
SCENE_PLANNER_VERSION,
|
||||
SCENE_TEMPLATE_AUGMENTATION_INSTRUCTIONS,
|
||||
SCHEDULE_COMPOSER_INSTRUCTIONS,
|
||||
SceneCompositionError,
|
||||
build_scene_output_schema,
|
||||
normalize_scene_output,
|
||||
scene_context_for_mode_creation,
|
||||
scene_required_peripheral_refs,
|
||||
scene_semantic_guidance,
|
||||
)
|
||||
from cognitive_agent.application.scene_templates import normalize_scene_template_name
|
||||
from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule
|
||||
@@ -936,6 +941,54 @@ class CodexAssistantService:
|
||||
if mode_parts:
|
||||
scene_context = scene_context_for_mode_creation(scene_context, message)
|
||||
model_context = scene_context_for_mode_creation(model_context, message)
|
||||
semantic_guidance = scene_semantic_guidance(mode_parts[0])
|
||||
live_scene_capabilities = {
|
||||
str(item.get("id") or "")
|
||||
for item in scene_context.get("capabilities", [])
|
||||
if isinstance(item, Mapping)
|
||||
}
|
||||
raw_preferred_capabilities = semantic_guidance.get(
|
||||
"preferred_capability_ids", []
|
||||
)
|
||||
semantic_guidance["preferred_capability_ids"] = [
|
||||
str(capability_id)
|
||||
for capability_id in (
|
||||
raw_preferred_capabilities
|
||||
if isinstance(raw_preferred_capabilities, Sequence)
|
||||
and not isinstance(
|
||||
raw_preferred_capabilities,
|
||||
(str, bytes, bytearray),
|
||||
)
|
||||
else []
|
||||
)
|
||||
if str(capability_id) in live_scene_capabilities
|
||||
]
|
||||
required_peripheral_refs = scene_required_peripheral_refs(
|
||||
mode_parts[0],
|
||||
scene_context,
|
||||
)
|
||||
raw_peripheral_actions = scene_context.get("peripheral_actions", [])
|
||||
semantic_guidance["peripheral_policy"] = {
|
||||
"available_action_count": (
|
||||
len(raw_peripheral_actions)
|
||||
if isinstance(raw_peripheral_actions, list)
|
||||
else 0
|
||||
),
|
||||
"required_refs": required_peripheral_refs,
|
||||
"selection_goal": (
|
||||
"Include as many semantically relevant scene-safe peripheral "
|
||||
"devices as are useful; normally choose activation plus at most "
|
||||
"one stable state action per device."
|
||||
),
|
||||
"required_reason": (
|
||||
"Romantic and Mid-Autumn related scenes must include the "
|
||||
"authorized flashing-light control."
|
||||
if required_peripheral_refs
|
||||
else "No peripheral action is mandatory for this name."
|
||||
),
|
||||
}
|
||||
scene_context["scene_semantics"] = semantic_guidance
|
||||
model_context["scene_semantics"] = deepcopy(semantic_guidance)
|
||||
exact_mode_matches = (
|
||||
_catalog_exact_mode_match_count(catalog, mode_parts[0])
|
||||
if mode_parts
|
||||
@@ -947,6 +1000,23 @@ class CodexAssistantService:
|
||||
else None
|
||||
)
|
||||
dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message))
|
||||
scene_peripheral_actions = scene_context.get("peripheral_actions", [])
|
||||
template_augmentation_preview = bool(
|
||||
scene_template_plan is not None
|
||||
and not dynamic_scene_request
|
||||
and isinstance(scene_peripheral_actions, list)
|
||||
and scene_peripheral_actions
|
||||
and self.client.available
|
||||
and callable(getattr(self.client, "start_structured_turn", None))
|
||||
)
|
||||
direct_scene_template_plan = (
|
||||
self._with_required_scene_peripherals(
|
||||
scene_template_plan,
|
||||
scene_context,
|
||||
)
|
||||
if scene_template_plan is not None
|
||||
else None
|
||||
)
|
||||
direct_mode_profile_preview = bool(
|
||||
mode_parts
|
||||
and (
|
||||
@@ -955,7 +1025,10 @@ class CodexAssistantService:
|
||||
# local builder. Only a known-zero match is safe to hand to
|
||||
# semantic scene composition.
|
||||
or exact_mode_matches != 0
|
||||
or scene_template_plan is not None
|
||||
or (
|
||||
scene_template_plan is not None
|
||||
and not template_augmentation_preview
|
||||
)
|
||||
)
|
||||
)
|
||||
direct_mode_profile_update = (
|
||||
@@ -1153,6 +1226,32 @@ class CodexAssistantService:
|
||||
query_capabilities=catalog_query,
|
||||
)
|
||||
|
||||
if template_augmentation_preview and scene_template_plan is not None:
|
||||
try:
|
||||
return self._stream_scene_template_augmentation(
|
||||
message,
|
||||
message_id=message_id,
|
||||
emit=emit,
|
||||
execute_control=execute_control,
|
||||
scene_context=scene_context,
|
||||
template_plan=scene_template_plan,
|
||||
)
|
||||
except _ScenePlannerCancelled:
|
||||
return {"cancelled": True}
|
||||
except Exception as exc: # noqa: BLE001 - trusted base remains usable
|
||||
LOGGER.warning(
|
||||
"Scene template peripheral augmentation failed; using base: %s",
|
||||
exc,
|
||||
)
|
||||
return self._stream_direct_vehicle_control(
|
||||
authorization,
|
||||
message_id=message_id,
|
||||
emit=emit,
|
||||
execute_control=execute_control,
|
||||
assistant_plan=direct_scene_template_plan,
|
||||
response_route="scene_template",
|
||||
)
|
||||
|
||||
if direct_vehicle_control:
|
||||
return self._stream_direct_vehicle_control(
|
||||
(
|
||||
@@ -1166,7 +1265,7 @@ class CodexAssistantService:
|
||||
message_id=message_id,
|
||||
emit=emit,
|
||||
execute_control=execute_control,
|
||||
assistant_plan=scene_template_plan,
|
||||
assistant_plan=direct_scene_template_plan,
|
||||
response_route=(
|
||||
"scene_template"
|
||||
if scene_template_plan is not None
|
||||
@@ -1294,6 +1393,97 @@ class CodexAssistantService:
|
||||
def _copy_scene_plan(plan: Mapping[str, object]) -> dict[str, object]:
|
||||
return json.loads(json.dumps(dict(plan), ensure_ascii=False, allow_nan=False))
|
||||
|
||||
@staticmethod
|
||||
def _required_scene_peripheral_refs(
|
||||
scene_context: Mapping[str, object],
|
||||
) -> list[str]:
|
||||
semantics = scene_context.get("scene_semantics")
|
||||
policy = (
|
||||
semantics.get("peripheral_policy")
|
||||
if isinstance(semantics, Mapping)
|
||||
else None
|
||||
)
|
||||
raw_refs = policy.get("required_refs", []) if isinstance(policy, Mapping) else []
|
||||
available_refs = {
|
||||
str(item.get("ref") or "").strip()
|
||||
for item in scene_context.get("peripheral_actions", [])
|
||||
if isinstance(item, Mapping) and str(item.get("ref") or "").strip()
|
||||
}
|
||||
return list(
|
||||
dict.fromkeys(
|
||||
str(value).strip()
|
||||
for value in (
|
||||
raw_refs
|
||||
if isinstance(raw_refs, Sequence)
|
||||
and not isinstance(raw_refs, (str, bytes, bytearray))
|
||||
else []
|
||||
)
|
||||
if str(value).strip() in available_refs
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _with_required_scene_peripherals(
|
||||
cls,
|
||||
plan: Mapping[str, object],
|
||||
scene_context: Mapping[str, object],
|
||||
*,
|
||||
maximum_requests: int = MAX_PLAN_REQUESTS,
|
||||
) -> dict[str, object]:
|
||||
"""Add mandatory opaque peripheral refs without exposing binding data."""
|
||||
|
||||
normalized = cls._copy_scene_plan(plan)
|
||||
requests = normalized.get("requests")
|
||||
if not isinstance(requests, list):
|
||||
raise SceneCompositionError("scene plan requests must be an array")
|
||||
existing_refs = {
|
||||
str(parameter.get("value") or "").strip()
|
||||
for request in requests
|
||||
if isinstance(request, Mapping)
|
||||
and request.get("domain") == "self_extension"
|
||||
and request.get("capability") == "infrared.emit"
|
||||
for parameter in (
|
||||
request.get("parameters", [])
|
||||
if isinstance(request.get("parameters"), list)
|
||||
else []
|
||||
)
|
||||
if isinstance(parameter, Mapping)
|
||||
and parameter.get("name") == "capability_ref"
|
||||
}
|
||||
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"
|
||||
)
|
||||
requests.extend(
|
||||
{
|
||||
"domain": "self_extension",
|
||||
"capability": "infrared.emit",
|
||||
"target_scope": capability_ref,
|
||||
"operation": "emit",
|
||||
"duration_ms": None,
|
||||
"parameters": [
|
||||
{"name": "capability_ref", "value": capability_ref}
|
||||
],
|
||||
"reason": (
|
||||
"Mode semantics require this currently authorized learned-device "
|
||||
"action; runtime binding resolution is still required."
|
||||
),
|
||||
}
|
||||
for capability_ref in missing_refs
|
||||
)
|
||||
if missing_refs:
|
||||
domains = normalized.get("detected_domains")
|
||||
if isinstance(domains, list) 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)} 项必需的已授权外设动作。"
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _scene_cache_key(
|
||||
self,
|
||||
*,
|
||||
@@ -1385,6 +1575,110 @@ class CodexAssistantService:
|
||||
while len(self._scene_cache) > self._scene_cache_limit:
|
||||
self._scene_cache.popitem(last=False)
|
||||
|
||||
def _stream_scene_template_augmentation(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
message_id: str,
|
||||
emit: EmitEvent,
|
||||
execute_control: ControlExecutor,
|
||||
scene_context: Mapping[str, object],
|
||||
template_plan: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Add only model-selected opaque IR refs to an immutable template base."""
|
||||
|
||||
peripheral_actions = scene_context.get("peripheral_actions", [])
|
||||
if not isinstance(peripheral_actions, list) or not peripheral_actions:
|
||||
return self._stream_direct_vehicle_control(
|
||||
_VehicleAuthorization(message, "explicit_mode_profile_create"),
|
||||
message_id=message_id,
|
||||
emit=emit,
|
||||
execute_control=execute_control,
|
||||
assistant_plan=template_plan,
|
||||
response_route="scene_template",
|
||||
)
|
||||
base_requests = template_plan.get("requests", [])
|
||||
if not isinstance(base_requests, list) or not base_requests:
|
||||
raise _ScenePlannerFallback("scene template base is empty")
|
||||
augmentation_context = {
|
||||
**dict(scene_context),
|
||||
"capabilities": [],
|
||||
"scene_semantics": deepcopy(scene_context.get("scene_semantics")),
|
||||
"device_inventory": [
|
||||
deepcopy(item)
|
||||
for item in scene_context.get("device_inventory", [])
|
||||
if isinstance(item, Mapping)
|
||||
and item.get("kind") == "learned_infrared"
|
||||
],
|
||||
"template_base": {
|
||||
"immutable": True,
|
||||
"actions": [
|
||||
{
|
||||
"capability": request.get("capability"),
|
||||
"target_scope": request.get("target_scope"),
|
||||
"parameters": request.get("parameters"),
|
||||
}
|
||||
for request in base_requests
|
||||
if isinstance(request, Mapping)
|
||||
],
|
||||
},
|
||||
}
|
||||
augmentation_context["maximum_generated_action_count"] = max(
|
||||
0,
|
||||
MAX_PLAN_REQUESTS
|
||||
- len(base_requests)
|
||||
- len(self._required_scene_peripheral_refs(scene_context)),
|
||||
)
|
||||
augmentation_semantics = augmentation_context.get("scene_semantics")
|
||||
if isinstance(augmentation_semantics, dict):
|
||||
augmentation_semantics["grounded"] = False
|
||||
augmentation_semantics["minimum_action_count"] = 0
|
||||
augmentation_semantics["ambiguities_allowed"] = True
|
||||
if (
|
||||
emit(
|
||||
{
|
||||
"type": "work.progress",
|
||||
"message_id": message_id,
|
||||
"display_text": "正在根据已授权外设补充常用模式……",
|
||||
}
|
||||
)
|
||||
is False
|
||||
):
|
||||
raise _ScenePlannerCancelled()
|
||||
deadline = time.monotonic() + self._scene_turn_timeout
|
||||
augmentation = self._request_structured_scene_plan(
|
||||
message,
|
||||
message_id=message_id,
|
||||
emit=emit,
|
||||
scene_context=augmentation_context,
|
||||
deadline=deadline,
|
||||
developer_instructions=SCENE_TEMPLATE_AUGMENTATION_INSTRUCTIONS,
|
||||
dynamic_override=False,
|
||||
)
|
||||
proposed = augmentation.get("requests", [])
|
||||
if not isinstance(proposed, list) or any(
|
||||
not isinstance(request, Mapping)
|
||||
or request.get("domain") != "self_extension"
|
||||
or request.get("capability") != "infrared.emit"
|
||||
for request in proposed
|
||||
):
|
||||
raise _ScenePlannerFallback("template augmentation returned a native mutation")
|
||||
if len(base_requests) + len(proposed) > MAX_PLAN_REQUESTS:
|
||||
raise _ScenePlannerFallback("template augmentation exceeds the scene action limit")
|
||||
combined = self._copy_scene_plan(template_plan)
|
||||
combined["requests"] = [
|
||||
*deepcopy(base_requests),
|
||||
*deepcopy(proposed),
|
||||
]
|
||||
return self._stream_direct_vehicle_control(
|
||||
_VehicleAuthorization(message, "explicit_mode_profile_create"),
|
||||
message_id=message_id,
|
||||
emit=emit,
|
||||
execute_control=execute_control,
|
||||
assistant_plan=combined,
|
||||
response_route="scene_template",
|
||||
)
|
||||
|
||||
def _stream_scene_composition(
|
||||
self,
|
||||
message: str,
|
||||
@@ -1554,17 +1848,49 @@ class CodexAssistantService:
|
||||
emit: EmitEvent,
|
||||
scene_context: Mapping[str, object],
|
||||
deadline: float,
|
||||
developer_instructions: str | None = None,
|
||||
dynamic_override: bool | None = None,
|
||||
) -> dict[str, object]:
|
||||
start_structured_turn = getattr(self.client, "start_structured_turn", None)
|
||||
if not callable(start_structured_turn):
|
||||
raise _ScenePlannerFallback("structured turns are not supported")
|
||||
|
||||
planning_started_at = time.monotonic()
|
||||
dynamic = needs_mode_schedule(message)
|
||||
output_schema = build_scene_output_schema(scene_context, dynamic=dynamic)
|
||||
dynamic = (
|
||||
needs_mode_schedule(message)
|
||||
if dynamic_override is None
|
||||
else dynamic_override
|
||||
)
|
||||
planner_context = deepcopy(dict(scene_context))
|
||||
if dynamic:
|
||||
# Timed IR playback is not yet phase-aware. Keep learned devices in
|
||||
# inventory so the model can explain availability, but do not offer
|
||||
# them as selectable actions in a schedule.
|
||||
planner_context["peripheral_actions"] = []
|
||||
dynamic_semantics = planner_context.get("scene_semantics")
|
||||
dynamic_policy = (
|
||||
dynamic_semantics.get("peripheral_policy")
|
||||
if isinstance(dynamic_semantics, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(dynamic_policy, dict):
|
||||
dynamic_policy["available_action_count"] = 0
|
||||
dynamic_policy["required_refs"] = []
|
||||
dynamic_policy["required_reason"] = (
|
||||
"Timed learned-infrared actions are not supported."
|
||||
)
|
||||
for item in planner_context.get("device_inventory", []):
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and item.get("kind") == "learned_infrared"
|
||||
and item.get("availability") == "available"
|
||||
):
|
||||
item["availability"] = "unavailable"
|
||||
item["availability_reason"] = "timed_ir_not_supported"
|
||||
output_schema = build_scene_output_schema(planner_context, dynamic=dynamic)
|
||||
context_bytes = len(
|
||||
json.dumps(
|
||||
dict(scene_context),
|
||||
planner_context,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
@@ -1590,7 +1916,14 @@ class CodexAssistantService:
|
||||
try:
|
||||
try:
|
||||
thread_id = self.client.start_thread(
|
||||
developer_instructions=SCHEDULE_COMPOSER_INSTRUCTIONS if dynamic else SCENE_COMPOSER_INSTRUCTIONS,
|
||||
developer_instructions=(
|
||||
developer_instructions
|
||||
or (
|
||||
SCHEDULE_COMPOSER_INSTRUCTIONS
|
||||
if dynamic
|
||||
else SCENE_COMPOSER_INSTRUCTIONS
|
||||
)
|
||||
),
|
||||
ephemeral=True,
|
||||
replace_developer_instructions=True,
|
||||
timeout=self._remaining_scene_time(deadline),
|
||||
@@ -1604,7 +1937,7 @@ class CodexAssistantService:
|
||||
thread_id,
|
||||
message,
|
||||
application_context={
|
||||
**dict(scene_context),
|
||||
**planner_context,
|
||||
"natural_language_routing": {
|
||||
"original_utterance": message,
|
||||
"parsed_clauses": [],
|
||||
@@ -1729,7 +2062,12 @@ class CodexAssistantService:
|
||||
raise SceneCompositionError("scene composer output was not valid JSON") from exc
|
||||
if not isinstance(raw_output, Mapping):
|
||||
raise SceneCompositionError("scene composer output must be an object")
|
||||
normalized = normalize_scene_output(raw_output, scene_context, dynamic=dynamic)
|
||||
normalized = normalize_scene_output(raw_output, planner_context, dynamic=dynamic)
|
||||
if not dynamic:
|
||||
normalized = self._with_required_scene_peripherals(
|
||||
normalized,
|
||||
planner_context,
|
||||
)
|
||||
finished_at = time.monotonic()
|
||||
LOGGER.info(
|
||||
"Scene Composer model completed context_bytes=%d schema_bytes=%d "
|
||||
@@ -3617,6 +3955,12 @@ class CodexAssistantService:
|
||||
safe_target = CodexAssistantService._safe_result_value(
|
||||
operation.get("target") or {}, secret=secret
|
||||
)
|
||||
if raw_domain == "self_extension" and isinstance(safe_target, dict):
|
||||
safe_target = {
|
||||
key: safe_target[key]
|
||||
for key in ("device", "name")
|
||||
if key in safe_target
|
||||
}
|
||||
safe_parameters = CodexAssistantService._safe_result_value(
|
||||
operation.get("parameters") or {}, secret=secret
|
||||
)
|
||||
@@ -3732,16 +4076,27 @@ class CodexAssistantService:
|
||||
for item in spec["actions"][:96 if result.get("schedule") else 12]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
actions.append(
|
||||
{
|
||||
safe_action = {
|
||||
key: CodexAssistantService._safe_result_value(
|
||||
item[key],
|
||||
secret=secret,
|
||||
)
|
||||
for key in ("domain", "capability", "target", "parameters")
|
||||
if key in item
|
||||
}
|
||||
if (
|
||||
item.get("domain") == "self_extension"
|
||||
and isinstance(item.get("target"), Mapping)
|
||||
):
|
||||
safe_action["target"] = {
|
||||
key: CodexAssistantService._safe_result_value(
|
||||
item[key],
|
||||
item["target"][key],
|
||||
secret=secret,
|
||||
)
|
||||
for key in ("domain", "capability", "target", "parameters")
|
||||
if key in item
|
||||
for key in ("device", "name")
|
||||
if key in item["target"]
|
||||
}
|
||||
)
|
||||
actions.append(safe_action)
|
||||
safe["actions"] = actions
|
||||
infrared_actions: list[dict[str, object]] = []
|
||||
raw_infrared = result.get("infrared_actions")
|
||||
|
||||
Reference in New Issue
Block a user