merge: integrate remote MQTT routing with seat-scoped dynamic modes

This commit is contained in:
vee
2026-09-12 15:10:16 +08:00
43 changed files with 5726 additions and 492 deletions
@@ -146,8 +146,14 @@ _DOMAIN_PRESENTATION: dict[str, dict[str, object]] = {
},
"window": {
"label": "车窗",
"aliases": ["车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗"],
"control_aliases": ["车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗"],
"aliases": [
"车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗",
"左前排窗", "右前排窗", "左后排窗", "右后排窗",
],
"control_aliases": [
"车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗",
"左前排窗", "右前排窗", "左后排窗", "右后排窗",
],
},
"door": {
"label": "后排车门锁",
@@ -161,11 +161,13 @@ _EXPLICIT_EVIDENCE: dict[str, _EvidenceRule] = {
r"智能天幕|天幕(?:玻璃)?|顶棚玻璃|\broof\b", r"自动调光|自动变色|automatic\s+dimming"
),
"window.set_motion": _rule(
r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|\bwindow\b",
r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|"
r"左前排窗|右前排窗|左后排窗|右后排窗|\bwindow\b",
r"打开|开启|关闭|关上|升起|降下|停止|\bopen\b|\bclose\b|\bstop\b",
),
"window.set_position": _rule(
r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|\bwindow\b",
r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|"
r"左前排窗|右前排窗|左后排窗|右后排窗|\bwindow\b",
r"位置|开到|关到|升到|降到|小缝|一半|半开|\d+\s*[%%]|\bposition\b",
),
"door.set_rear_lock": _rule(
@@ -852,6 +852,8 @@ class CodexAssistantService:
history: Sequence[dict[str, str]],
emit: EmitEvent,
execute_control: ControlExecutor,
preview_control: ControlExecutor | None = None,
supplement_control: ControlExecutor | None = None,
capability_snapshot: Mapping[str, object] | None = None,
capability_context: Mapping[str, object] | None = None,
scene_capability_context: Mapping[str, object] | None = None,
@@ -918,6 +920,14 @@ class CodexAssistantService:
authorization.reason == "explicit_registered_control"
and _normalize_instruction(message) in normalized_quick_controls
)
explicit_local_control = (
authorization.reason == "explicit_registered_control"
)
local_first_control = bool(
explicit_local_control
and preview_control is not None
and supplement_control is not None
)
mode_profile_preview = authorization.reason in {
"explicit_mode_profile_create",
"explicit_mode_profile_draft_update",
@@ -971,6 +981,7 @@ class CodexAssistantService:
fast_comfort_control
or authorization.reason == "explicit_schedule_stop"
or exact_quick_control
or local_first_control
or saved_mode_execution_id is not None
or direct_mode_profile_preview
or authorization.reason == "explicit_mode_profile_draft_update"
@@ -1007,7 +1018,7 @@ class CodexAssistantService:
else "codex-app-server-scene-composer"
if scene_composition_preview
else "n7web-control-router"
if fast_comfort_control or exact_quick_control
if fast_comfort_control or exact_quick_control or local_first_control
else "codex-app-server"
)
@@ -1053,6 +1064,8 @@ class CodexAssistantService:
if fast_comfort_control
else "正在执行快捷车控……"
if exact_quick_control
else "正在本地解析并下发明确控制指令……"
if local_first_control
else "小迪正在理解并处理……"
),
}
@@ -1121,6 +1134,25 @@ class CodexAssistantService:
),
}
if (
local_first_control
and preview_control is not None
and supplement_control is not None
):
return self._stream_local_first_vehicle_control(
authorization,
message_id=message_id,
emit=emit,
preview_control=preview_control,
supplement_control=supplement_control,
conversation_id=conversation_id,
principal=principal,
capability_snapshot=catalog,
capability_context=model_context,
control_terms=control_terms,
query_capabilities=catalog_query,
)
if direct_vehicle_control:
return self._stream_direct_vehicle_control(
(
@@ -1571,7 +1603,16 @@ class CodexAssistantService:
active = start_structured_turn(
thread_id,
message,
application_context=scene_context,
application_context={
**dict(scene_context),
"natural_language_routing": {
"original_utterance": message,
"parsed_clauses": [],
"resolved_clauses": [],
"unresolved_clauses": [message],
"cloud_input_utterance": message,
},
},
output_schema=output_schema,
timeout=self._remaining_scene_time(deadline),
)
@@ -1766,6 +1807,285 @@ class CodexAssistantService:
except (RuntimeError, TypeError) as exc:
LOGGER.debug("Scene Composer thread cleanup failed", exc_info=exc)
def _stream_local_first_vehicle_control(
self,
authorization: _VehicleAuthorization,
*,
message_id: str,
emit: EmitEvent,
preview_control: ControlExecutor,
supplement_control: ControlExecutor,
conversation_id: str,
principal: str,
capability_snapshot: Mapping[str, object],
capability_context: Mapping[str, object],
control_terms: Sequence[str],
query_capabilities: CapabilityQuery,
) -> dict[str, object]:
"""Publish deterministic controls, then delegate only unresolved text."""
instruction = authorization.instruction
if instruction is None:
raise CodexAssistantError("local-first vehicle control was not authorized")
if (
emit(
{
"type": "action.started",
"message_id": message_id,
"display_text": "正在通过本地规则解析明确控制指令……",
}
)
is False
):
return {"cancelled": True}
stream_open = True
def on_progress(payload: dict[str, object]) -> None:
nonlocal stream_open
if not stream_open:
return
validated_payload = self._validated_control_payload(payload)
if validated_payload is not None:
stream_open = (
emit(
{
"type": "validated_control_commands",
"message_id": message_id,
"payload": validated_payload,
}
)
is not False
)
if not stream_open:
return
stream_open = (
emit(
{
"type": "action.progress",
"message_id": message_id,
"display_text": "本地控制指令已解析并进入下发链路……",
"payload": self._safe_vehicle_result(payload),
}
)
is not False
)
try:
preview = preview_control(instruction, on_progress)
if not isinstance(preview, dict):
raise TypeError("vehicle preview returned an invalid result")
except Exception: # noqa: BLE001 - stable browser-facing failure
answer = "车辆控制未完成,本地安全执行链没有返回有效结果。"
for event in (
{
"type": "action.completed",
"message_id": message_id,
"success": False,
"state": "failed",
"display_text": "本地控制未完成",
},
{
"type": "answer.delta",
"message_id": message_id,
"delta": answer,
},
{
"type": "work.completed",
"message_id": message_id,
"success": False,
"display_text": "车辆控制未完成",
},
{
"type": "answer.completed",
"message_id": message_id,
"route": "vehicle_control",
"answer": answer,
},
):
if emit(event) is False:
return {"cancelled": True, "answer": answer}
return {
"route": "vehicle_control",
"answer": answer,
"success": False,
}
if not stream_open:
return {"cancelled": True}
routing_value = preview.get("natural_language_routing")
routing = dict(routing_value) if isinstance(routing_value, Mapping) else {}
raw_unresolved = routing.get("unresolved_clauses")
if not isinstance(raw_unresolved, Sequence) or isinstance(
raw_unresolved,
(str, bytes, bytearray),
):
raw_unresolved = preview.get("unresolved_clauses")
unresolved_clauses = list(
dict.fromkeys(
text
for item in (
list(raw_unresolved)[:64]
if isinstance(raw_unresolved, Sequence)
and not isinstance(raw_unresolved, (str, bytes, bytearray))
else []
)
for text in [str(item).strip()[:1000]]
if text
)
)
safe_preview = self._safe_vehicle_result(preview)
if not unresolved_clauses:
action_state, successful, completion_text = _vehicle_action_state(preview)
answer = self._fast_comfort_answer(
"explicit_registered_control",
preview,
successful,
)
if not stream_open:
return {"cancelled": True, "answer": answer}
for event in (
{
"type": "action.completed",
"message_id": message_id,
"success": successful,
"state": action_state,
"display_text": completion_text,
"payload": safe_preview,
},
{
"type": "answer.delta",
"message_id": message_id,
"delta": answer,
},
{
"type": "work.completed",
"message_id": message_id,
"success": successful,
"state": action_state,
"display_text": completion_text,
},
{
"type": "answer.completed",
"message_id": message_id,
"route": "vehicle_control",
"answer": answer,
},
):
if emit(event) is False:
return {"cancelled": True, "answer": answer}
return {
"route": "vehicle_control",
"answer": answer,
"success": successful,
"result": safe_preview,
}
local_has_commands = bool(
preview.get("function_configs") and preview.get("command_plans")
)
local_status = str(preview.get("status") or "")
local_succeeded = local_has_commands and local_status in {
"succeeded",
"partially_succeeded",
"no_action",
}
if stream_open and emit(
{
"type": "action.completed",
"message_id": message_id,
"success": local_succeeded,
"state": "submitted" if local_succeeded else "failed",
"display_text": (
"本地明确控制已先行处理"
if local_succeeded
else "本地阶段未生成控制指令"
),
"payload": safe_preview,
}
) is False:
return {"cancelled": True}
if emit(
{
"type": "work.progress",
"message_id": message_id,
"display_text": "本地明确子句已处理,正在补充未解析子句……",
}
) is False:
return {"cancelled": True}
cloud_message = ",".join(unresolved_clauses)
routing.update(
{
"original_utterance": str(
routing.get("original_utterance") or instruction
)[:1000],
"parsed_clauses": list(routing.get("parsed_clauses") or []),
"resolved_clauses": list(routing.get("resolved_clauses") or []),
"unresolved_clauses": unresolved_clauses,
"cloud_input_utterance": cloud_message,
}
)
if not self.client.available:
answer = (
"已先行处理本地可解析的控制;其余子句暂无法连接云端模型,"
"未生成额外控制。"
)
emit(
{
"type": "answer.delta",
"message_id": message_id,
"delta": answer,
}
)
emit(
{
"type": "work.completed",
"message_id": message_id,
"success": local_succeeded,
"display_text": "本地控制已处理,云端补充不可用",
}
)
emit(
{
"type": "answer.completed",
"message_id": message_id,
"route": "vehicle_control",
"answer": answer,
}
)
return {
"route": "vehicle_control",
"answer": answer,
"success": local_succeeded,
"result": safe_preview,
}
conversation, conversation_key = self._conversation(
principal,
conversation_id,
)
try:
with conversation.lock:
return self._stream_locked(
conversation,
cloud_message,
message_id=message_id,
emit=emit,
execute_control=supplement_control,
capability_snapshot=capability_snapshot,
capability_context={
**dict(capability_context),
"natural_language_routing": routing,
},
control_terms=control_terms,
query_capabilities=query_capabilities,
)
finally:
self._release_conversation(conversation_key, conversation)
def _stream_direct_vehicle_control(
self,
authorization: _VehicleAuthorization,
@@ -1789,6 +2109,10 @@ class CodexAssistantService:
authorization.reason == "explicit_mode_profile_draft_update"
)
exact_quick_control = authorization.reason == "exact_quick_control"
explicit_local_control = authorization.reason in {
"exact_quick_control",
"explicit_registered_control",
}
saved_mode_execution = authorization.reason == "explicit_saved_mode_execute"
mode_profile_management_route = {
"explicit_mode_profile_update": "mode_profile_update",
@@ -1812,6 +2136,8 @@ class CodexAssistantService:
if saved_mode_execution
else "正在通过车辆安全链执行快捷控制……"
if exact_quick_control
else "正在通过本地规则解析并下发控制……"
if explicit_local_control
else "正在通过车辆安全链执行舒适性调节……"
),
}
@@ -1826,6 +2152,20 @@ class CodexAssistantService:
nonlocal stream_open
if not stream_open:
return
validated_payload = self._validated_control_payload(payload)
if validated_payload is not None:
stream_open = (
emit(
{
"type": "validated_control_commands",
"message_id": message_id,
"payload": validated_payload,
}
)
is not False
)
if not stream_open:
return
stream_open = (
emit(
{
@@ -1838,6 +2178,8 @@ class CodexAssistantService:
if saved_mode_execution
else "功能模式存储请求已解析,正在校验……"
if mode_profile_management_route is not None
else "本地控制指令已通过校验并进入下发链路……"
if explicit_local_control
else "控制指令已通过校验,正在执行……"
),
"payload": self._safe_vehicle_result(payload),
@@ -1947,6 +2289,14 @@ class CodexAssistantService:
if exact_quick_control and successful
else "快捷控制未完成"
if exact_quick_control
else "明确控制已提交,等待车辆状态确认"
if explicit_local_control and action_state == "submitted"
else "明确控制模拟编排已完成"
if explicit_local_control and action_state == "simulated"
else "明确控制已完成"
if explicit_local_control and successful
else "明确控制未完成"
if explicit_local_control
else "舒适性调节已提交,等待车辆状态确认"
if action_state == "submitted"
else "舒适性调节模拟编排已完成"
@@ -2308,7 +2658,11 @@ class CodexAssistantService:
return ("动态模式时间轴已启动,将按卡片中的时长逐阶段调节。尚未完成所有阶段;输入“停止动态模式”可取消后续阶段。"
if result.get("status") == "running"
else "已按虚拟时钟模拟验证动态模式的全部阶段,未向实际车辆下发操作。")
if reason in {"exact_quick_control", "explicit_saved_mode_execute"}:
if reason in {
"exact_quick_control",
"explicit_registered_control",
"explicit_saved_mode_execute",
}:
instruction = re.sub(
r"\s+",
" ",
@@ -2317,7 +2671,7 @@ class CodexAssistantService:
or (
"功能模式"
if reason == "explicit_saved_mode_execute"
else "快捷控制指令"
else "控制指令"
)
),
).strip()[:160]
@@ -2389,12 +2743,24 @@ class CodexAssistantService:
answer_redactor = _StreamingSecretRedactor(request_token)
cancelled = False
deadline = time.monotonic() + self._turn_timeout
application_context = dict(capability_context)
if not isinstance(
application_context.get("natural_language_routing"),
Mapping,
):
application_context["natural_language_routing"] = {
"original_utterance": message,
"parsed_clauses": [],
"resolved_clauses": [],
"unresolved_clauses": [message],
"cloud_input_utterance": message,
}
try:
active = self.client.start_turn(
conversation.thread_id,
message,
request_token,
application_context=capability_context,
application_context=application_context,
)
terminal: dict[str, object] | None = None
while terminal is None:
@@ -2699,6 +3065,17 @@ class CodexAssistantService:
)
def on_progress(payload: dict[str, object]) -> None:
validated_payload = self._validated_control_payload(
payload,
secret=request_token,
)
if validated_payload is not None:
context.events.put(
{
"type": "validated_control_commands",
"payload": validated_payload,
}
)
context.events.put(
{
"type": "action.progress",
@@ -3017,8 +3394,9 @@ class CodexAssistantService:
for raw_key, raw_value in list(value.items())[:24]:
key = str(raw_key)[:80]
if re.search(
r"token|secret|password|authorization|approval|raw.?can|"
r"can.?frame|data.?hex|payload.?hex",
r"token|secret|password|authorization|approval|api.?key|"
r"credential|cookie|bearer|private.?key|access.?key|"
r"raw.?can|can.?frame|data.?hex|payload.?hex",
key,
re.IGNORECASE,
):
@@ -3041,7 +3419,290 @@ class CodexAssistantService:
)
for item in list(value)[:24]
]
return str(value).replace(secret, "[内部令牌已隐藏]")[:240]
text = str(value)
if secret:
text = text.replace(secret, "[内部令牌已隐藏]")
return text[:240]
@staticmethod
def _validated_control_payload(
result: Mapping[str, object],
*,
secret: str = "",
) -> dict[str, object] | None:
"""Build a browser-only allowlist projection of compiled controls.
MCP tool results intentionally exclude command plans and raw CAN
material. The web console still needs a deterministic view after
validation, so copy only semantic fields, canonical debug commands,
reconstructed IR payloads, display-safe CAN bytes, and stage timings.
"""
raw_configurations = result.get("function_configs")
configuration_entries: list[tuple[str, Mapping[str, object]]] = []
if isinstance(raw_configurations, Mapping):
configuration_entries.extend(
(str(key), value)
for key, value in raw_configurations.items()
if isinstance(value, Mapping)
)
if not configuration_entries and isinstance(
result.get("function_config"), Mapping
):
configuration_entries.append(("default", result["function_config"]))
raw_plans = result.get("command_plans")
plan_entries: list[tuple[str, Mapping[str, object]]] = []
if isinstance(raw_plans, Mapping):
plan_entries.extend(
(str(key), value)
for key, value in raw_plans.items()
if isinstance(value, Mapping)
)
if not plan_entries and isinstance(result.get("command_plan"), Mapping):
plan_entries.append(("default", result["command_plan"]))
if not configuration_entries or not plan_entries:
return None
def safe_text(value: object, limit: int) -> str:
"""Return bounded display text without copying the request secret."""
text = str(value or "")
if secret and secret in text:
return ""
return text[:limit]
def command_refs(command: Mapping[str, object]) -> tuple[str, ...]:
raw_refs = command.get("operation_ref")
if isinstance(raw_refs, Sequence) and not isinstance(
raw_refs, (str, bytes, bytearray)
):
values = raw_refs
else:
values = str(raw_refs or "").split(",")
return tuple(str(value).strip() for value in values if str(value).strip())
def matching_commands(
entries: Sequence[tuple[str, Mapping[str, object]]],
operation_id: str,
) -> list[tuple[Mapping[str, object], Mapping[str, object]]]:
matches: list[tuple[Mapping[str, object], Mapping[str, object]]] = []
for _plan_key, plan in entries:
spec = plan.get("spec")
if not isinstance(spec, Mapping):
continue
commands = spec.get("commands")
if not isinstance(commands, Sequence) or isinstance(
commands, (str, bytes, bytearray)
):
continue
matches.extend(
(command, plan)
for command in commands
if isinstance(command, Mapping)
and operation_id in command_refs(command)
)
return matches
presentations: list[dict[str, object]] = []
for config_key, configuration in configuration_entries[:16]:
spec = configuration.get("spec")
if not isinstance(spec, Mapping):
continue
operations = spec.get("operations")
if not isinstance(operations, Sequence) or isinstance(
operations, (str, bytes, bytearray)
):
continue
raw_domain = str(spec.get("domain") or "")
domain = safe_text(raw_domain, 80)
preferred_plans = [
entry for entry in plan_entries if entry[0] == config_key
]
for operation in operations[:24]:
if not isinstance(operation, Mapping):
continue
raw_operation_id = str(operation.get("id") or "").strip()[:160]
operation_id = safe_text(raw_operation_id, 160).strip()
capability = safe_text(operation.get("capability"), 160).strip()
if not raw_operation_id or not operation_id or not capability:
continue
compiled = matching_commands(preferred_plans, raw_operation_id)
if (
not preferred_plans
and len(configuration_entries) == 1
and len(plan_entries) == 1
):
# Keep the legacy function_config/command_plan pair working,
# but never search globally across multi-config results. A
# repeated operation id must fail closed instead of borrowing
# a compiled command from another domain.
compiled = matching_commands(plan_entries, raw_operation_id)
if not compiled:
continue
channel = "IR" if raw_domain == "self_extension" else "CAN1"
actual_commands: list[str] = []
frames: list[dict[str, str]] = []
for command, plan in compiled[:16]:
signals = command.get("signals")
signals = signals if isinstance(signals, Mapping) else {}
plan_spec = plan.get("spec")
plan_domain = (
str(plan_spec.get("domain") or "")
if isinstance(plan_spec, Mapping)
else ""
)
message = str(command.get("message") or "")
bus = str(command.get("bus") or "").lower()
if message == "infrared.emit" or plan_domain == "self_extension":
command_channel = "IR"
elif "seat" in bus or "can0" in bus:
command_channel = "CAN0"
else:
command_channel = "CAN1"
channel = command_channel
debug_command = " ".join(
safe_text(signals.get("debug_command"), 256).split()
)
if re.fullmatch(r"[A-Za-z0-9_.:+% -]{1,256}", debug_command):
if debug_command not in actual_commands:
actual_commands.append(debug_command)
slot_value = signals.get("slot")
slot = (
int(slot_value)
if isinstance(slot_value, int)
and not isinstance(slot_value, bool)
and 1 <= slot_value <= 248
else None
)
if message == "infrared.emit" and slot is not None:
actual = f"infrared emit {slot}"
if actual not in actual_commands:
actual_commands.append(actual)
frame = {
"label": "GSCU1B TCP",
"value": json.dumps(
{
"action": "emit",
"data": {"no": slot},
"type": "infrared",
},
ensure_ascii=False,
separators=(",", ":"),
),
}
if frame not in frames:
frames.append(frame)
continue
frame_id = str(signals.get("frame_id") or "").strip()
data_bytes = " ".join(
str(signals.get("data_hex") or "").strip().split()
).upper()
if re.fullmatch(
r"0[xX][0-9A-Fa-f]{1,8}", frame_id
) and re.fullmatch(
r"[0-9A-F]{2}(?: [0-9A-F]{2}){0,63}", data_bytes
):
frame = {
"label": f"{command_channel} 帧",
"value": f"0x{frame_id[2:].upper()} · {data_bytes}",
}
if frame not in frames:
frames.append(frame)
safe_target = CodexAssistantService._safe_result_value(
operation.get("target") or {}, secret=secret
)
safe_parameters = CodexAssistantService._safe_result_value(
operation.get("parameters") or {}, secret=secret
)
presentations.append(
{
"operation": {
"id": operation_id,
"capability": capability,
"target": safe_target if isinstance(safe_target, dict) else {},
"parameters": (
safe_parameters
if isinstance(safe_parameters, dict)
else {}
),
**(
{
"parse_source": safe_text(
operation.get("parse_source"), 80
)
}
if safe_text(operation.get("parse_source"), 80)
else {}
),
},
"domain": domain,
"channel": channel,
"actual_command": ";".join(actual_commands),
"frames": frames,
}
)
if not presentations:
return None
allowed_trace_stages = {
"intent_parse",
"context_resolve",
"domain_route",
"function_generate",
"schema_validate",
"domain_validate",
"compile",
"nfc_peripheral_route",
"local_peripheral_plan",
"nfc_peripheral_sequence",
}
safe_trace: list[dict[str, object]] = []
raw_trace = result.get("trace_events")
if isinstance(raw_trace, Sequence) and not isinstance(
raw_trace, (str, bytes, bytearray)
):
for item in raw_trace[:32]:
if not isinstance(item, Mapping):
continue
stage = str(item.get("stage") or "")
duration = item.get("duration_ms")
if (
stage in allowed_trace_stages
and isinstance(duration, (int, float))
and not isinstance(duration, bool)
and duration >= 0
):
safe_trace.append({"stage": stage, "duration_ms": duration})
payload: dict[str, object] = {
"validated_control_commands": presentations,
"trace_events": safe_trace,
}
for key in (
"request_id",
"preview_request_id",
"supplement_of",
"utterance",
"provider_used",
"execution_mode",
"control_transport",
"local_control_budget_ms",
"local_control_elapsed_ms",
"local_control_within_budget",
"natural_language_routing",
):
if result.get(key) is not None:
payload[key] = CodexAssistantService._safe_result_value(
result[key], secret=secret
)
return payload
@staticmethod
def _safe_vehicle_result(
@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from threading import RLock
@@ -533,6 +534,7 @@ class ModeProfileRegistry:
self.user_root = Path(user_root) if user_root is not None else self.profile_root
self.session_user_profiles = user_root is not None
self._profiles: list[ModeProfile] = []
self._change_listeners: list[Callable[["ModeProfileRegistry"], None]] = []
self._write_lock = RLock()
self.reload()
@@ -555,6 +557,30 @@ class ModeProfileRegistry:
if path.is_file()
)
self._profiles = profiles
self._notify_change_listeners()
def add_change_listener(
self,
listener: Callable[["ModeProfileRegistry"], None],
*,
notify: bool = True,
) -> None:
"""Run ``listener`` after every successful registry reload.
Mode profiles remain YAML-backed. Listeners are intended for derived,
read-only projections such as the LAN scene catalog; they do not replace
or alter the existing persistence path.
"""
with self._write_lock:
if listener not in self._change_listeners:
self._change_listeners.append(listener)
if notify:
listener(self)
def _notify_change_listeners(self) -> None:
for listener in tuple(self._change_listeners):
listener(self)
def profiles(self) -> list[ModeProfile]:
return list(self._profiles)
@@ -0,0 +1,283 @@
"""Project persisted mode cards into the read-only N7 scene catalog contract."""
from __future__ import annotations
import hashlib
import json
import os
import threading
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from cognitive_agent.application.mode_profile import (
ModeOperation,
ModeProfile,
ModeProfileRegistry,
)
N7_SCENE_SERVICE = "n7-scenes"
N7_SCENE_SCHEMA_VERSION = 1
DEFAULT_N7_SCENE_SERVER_ID = "n7-cognitive-agent"
DEFAULT_N7_SCENE_SERVER_NAME = "N7 模式卡片目录"
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _background_time(profile: ModeProfile) -> str:
"""Choose a compatible visual hint when the mode schema has no time field."""
text = f"{profile.name} {profile.description}"
if any(keyword in text for keyword in ("晨", "早间", "清晨")):
return "morning"
if any(keyword in text for keyword in ("小憩", "午睡", "睡眠", "夜", "冥想")):
return "evening"
return "noon"
def _action_type(capability: str) -> str:
legacy_types = {
"climate.set_power": "climate_power",
"climate.set_temperature": "climate_temperature",
"climate.set_fan_level": "climate_fan",
}
if capability in legacy_types:
return legacy_types[capability]
domain, separator, action = capability.partition(".")
if not separator:
return capability.replace(".", "_")
if action.startswith("set_"):
action = action.removeprefix("set_")
return f"{domain}_{action}".replace(".", "_")
def _legacy_zone(target_scope: str) -> str:
return {
"vehicle": "both",
"all": "both",
"passenger": "passenger",
"front_right": "passenger",
"driver": "driver",
"front_left": "driver",
}.get(target_scope, target_scope)
def _scene_action(operation: ModeOperation) -> dict[str, Any]:
"""Preserve the complete operation and add fields understood by the v1 demo."""
parameters = deepcopy(operation.parameters)
action: dict[str, Any] = {
"type": _action_type(operation.capability),
"at_ms": 0,
"domain": operation.domain,
"capability": operation.capability,
"target_scope": operation.target_scope,
"operation": operation.operation,
"parameters": parameters,
}
# The original demo catalog flattened these three climate actions. Keep
# those aliases so an existing v1 client can continue reading them, while
# the generic fields above retain every seat/roof/light/custom operation.
if operation.capability == "climate.set_power" and "state" in parameters:
state = parameters["state"]
action["enabled"] = (
state
if isinstance(state, bool)
else str(state).strip().lower() in {"1", "true", "on", "enabled"}
)
elif (
operation.capability == "climate.set_temperature"
and "temperature_c" in parameters
):
action["zone"] = _legacy_zone(operation.target_scope)
action["celsius"] = parameters["temperature_c"]
elif operation.capability == "climate.set_fan_level" and "level" in parameters:
action["level"] = parameters["level"]
return action
def _scene_from_profile(
registry: ModeProfileRegistry,
profile: ModeProfile,
public_profile: dict[str, Any],
) -> dict[str, Any]:
scene = {
"id": profile.id,
"label": profile.name,
"name": profile.name,
"background_time": _background_time(profile),
"description": profile.description,
"icon": profile.icon,
"aliases": list(profile.aliases),
"origin": public_profile.get("origin", "preset"),
"deletable": bool(public_profile.get("deletable", False)),
"behavior": public_profile.get("behavior", "static"),
"domains": list(public_profile.get("domains") or []),
"operation_count": len(profile.operations),
"scope": deepcopy(public_profile.get("scope") or {}),
"profile_revision": registry.profile_revision(profile),
"actions": [_scene_action(operation) for operation in profile.operations],
}
if public_profile.get("preset_override") is True:
scene["preset_override"] = True
if profile.timeline is not None:
scene["timeline"] = profile.timeline.public_dict()
if profile.schedule is not None:
schedule = profile.schedule
preview_actions = []
elapsed_ms = 0
for phase in schedule.phases:
for raw_operation in phase.operations:
action = _scene_action(ModeOperation.from_dict(raw_operation))
action.update(
at_ms=elapsed_ms,
phase_id=phase.id,
phase_name=phase.name,
phase_duration_ms=phase.duration_ms,
)
preview_actions.append(action)
elapsed_ms += phase.duration_ms
# A v1 client may ignore timestamps and immediately send every action.
# Keep its executable list empty rather than flattening a new timeline.
# Schedule-aware clients can render the complete contract and ask the
# authenticated application to execute the saved mode by its identity.
scene.update(
behavior="cyclic",
operation_count=schedule.operation_count,
domains=sorted({op["domain"] for phase in schedule.phases for op in phase.operations}),
actions=[],
schedule=schedule.to_dict(),
preview_actions=preview_actions,
preview_actions_scope="single_cycle",
execution={
"kind": "server_schedule",
"mode_id": profile.id,
"client_actions_supported": False,
"requires_authentication": True,
},
execution_notice="分阶段动态模式需通过工程服务端时间轴执行,不支持按静态动作列表下发。",
)
return scene
def build_mode_scene_catalog(
registry: ModeProfileRegistry,
vehicle_id: str,
*,
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
generated_at: str | None = None,
) -> dict[str, Any]:
"""Build a lossless, client-safe projection of effective mode profiles."""
clean_vehicle_id = str(vehicle_id).strip()
if not clean_vehicle_id:
raise ValueError("scene catalog vehicle_id must not be empty")
clean_server_id = str(server_id).strip()
if not clean_server_id:
raise ValueError("scene catalog server_id must not be empty")
public_profiles = registry.public_modes(clean_vehicle_id)
scenes = [
_scene_from_profile(
registry,
registry.get_profile(str(public_profile["id"]), clean_vehicle_id),
public_profile,
)
for public_profile in public_profiles
]
revision_source = {
"service": N7_SCENE_SERVICE,
"schema_version": N7_SCENE_SCHEMA_VERSION,
"server_id": clean_server_id,
"name": str(name).strip() or DEFAULT_N7_SCENE_SERVER_NAME,
"vehicle_id": clean_vehicle_id,
"scenes": scenes,
}
canonical = json.dumps(
revision_source,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return {
**revision_source,
"catalog_revision": hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16],
"generated_at": generated_at or _utc_now(),
"scene_count": len(scenes),
"read_only": True,
}
class ModeSceneCatalogPublisher:
"""Atomically maintain the JSON projection consumed by LAN clients."""
def __init__(
self,
path: Path,
vehicle_id: str,
*,
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
) -> None:
self.path = Path(path)
self.vehicle_id = str(vehicle_id).strip()
self.server_id = str(server_id).strip() or DEFAULT_N7_SCENE_SERVER_ID
self.name = str(name).strip() or DEFAULT_N7_SCENE_SERVER_NAME
self._lock = threading.RLock()
self._catalog: dict[str, Any] | None = None
def publish(self, registry: ModeProfileRegistry) -> dict[str, Any]:
catalog = build_mode_scene_catalog(
registry,
self.vehicle_id,
server_id=self.server_id,
name=self.name,
)
with self._lock:
on_disk = self._read_catalog()
if (
on_disk is not None
and on_disk.get("catalog_revision") == catalog["catalog_revision"]
and {
key: value for key, value in on_disk.items() if key != "generated_at"
}
== {
key: value for key, value in catalog.items() if key != "generated_at"
}
):
self._catalog = on_disk
return deepcopy(on_disk)
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(
f".{self.path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
temporary.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(self.path)
finally:
if temporary.exists():
temporary.unlink()
self._catalog = catalog
return deepcopy(catalog)
def snapshot(self) -> dict[str, Any] | None:
with self._lock:
return deepcopy(self._catalog)
def _read_catalog(self) -> dict[str, Any] | None:
if not self.path.is_file():
return None
try:
document = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return document if isinstance(document, dict) else None
@@ -20,6 +20,10 @@ WINDOW_TARGETS = {
"所有": "all",
"四个": "all",
"全车": "all",
"左前排": "front_left",
"右前排": "front_right",
"左后排": "rear_left",
"右后排": "rear_right",
"前排": "front",
"前座": "front",
"后排": "rear",
@@ -88,7 +92,8 @@ LIGHT_TARGETS = {
_EXPLICIT_LOCATION_HINT = re.compile(
r"全部|所有|四个|全车|整车|前排|前座|后排|后座|二排|第二排|"
r"中排|第三排|主驾|主驾驶|驾驶位|驾驶员|司机位|副驾|副驾驶|"
r"乘客位|乘员|左前|前左|右前|前右|左后|后左|右后|后右|"
r"乘客位|乘员|左前排|右前排|左后排|右后排|"
r"左前|前左|右前|前右|左后|后左|右后|后右|"
r"左侧|右侧|左边|右边"
)
_WHOLE_VEHICLE_HINT = re.compile(r"全部|所有|四个|全车|整车")
@@ -339,6 +344,7 @@ CH1_DEFAULT_TARGETS = {
_EXPLICIT_TARGET_PATTERNS = {
"window": re.compile(
r"(?:全部|所有|四个|全车|前排|前座|后排|后座|二排|第二排|"
r"左前排|右前排|左后排|右后排|"
r"左前|前左|右前|前右|左后|后左|右后|后右|左侧|右侧|"
r"主驾驶|驾驶位|驾驶员|司机位|主驾|副驾驶|乘客位|乘员|副驾)"
r"(?:的)?(?:车窗|窗)|window\s+(?:all|front|rear|front-left|front-right|rear-left|rear-right)",
@@ -1094,11 +1100,16 @@ def _parse_chinese_window_commands(compact: str) -> list[dict[str, Any]]:
clauses = [clause for clause in CLAUSE_SEPARATOR.split(compact) if clause]
exclusion_pattern = re.compile(
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
r"左前排|右前排|左后排|右后排|"
r"左前|前左|右前|前右|左后|后左|右后|后右)"
r"(?:车窗)?(?:除外|除去|不包括|不含)$"
r"(?:车窗|窗)?(?:除外|除去|不包括|不含)$"
)
for index, clause in enumerate(clauses):
if "车窗" not in clause:
if (
"车窗" not in clause
and "窗户" not in clause
and not _EXPLICIT_TARGET_PATTERNS["window"].search(clause)
):
continue
window_text = clause
if index + 1 < len(clauses) and exclusion_pattern.search(
@@ -2007,7 +2018,8 @@ def merge_native_can_intent(
]
constraints = list(result.get("semantic_constraints", []))
for match in re.finditer(
r"(?P<target>左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗)?"
r"(?P<target>左前排|右前排|左后排|右后排|"
r"左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗|窗)?"
r"(?:除外|除去|不包括|不含)",
utterance,
):
@@ -34,6 +34,8 @@ CHINESE_DIGITS = {
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10,
}
WINDOW_TARGET_LABELS = {
"左前排": ("front_left",), "右前排": ("front_right",),
"左后排": ("rear_left",), "右后排": ("rear_right",),
"后排左侧": ("rear_left",), "后座左侧": ("rear_left",),
"二排左侧": ("rear_left",), "第二排左侧": ("rear_left",),
"后排右侧": ("rear_right",), "后座右侧": ("rear_right",),
@@ -57,6 +59,7 @@ WINDOW_TARGET_LABELS = {
NARROW_LOCATION = re.compile(
r"主驾|主驾驶|驾驶位|驾驶员|司机位|副驾|副驾驶|乘客位|乘员|"
r"前排|前座|后排|后座|二排|第二排|中排|第三排|三排|前舱|后舱|"
r"左前排|右前排|左后排|右后排|"
r"左前|前左|右前|前右|左后|后左|右后|后右|左侧|右侧|左边|右边"
)
UNSUPPORTED_SINGLE_HVAC_ZONE = re.compile(
+276 -7
View File
@@ -66,6 +66,12 @@ from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
parse_mode_profile_delete,
parse_mode_profile_update,
)
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
DEFAULT_N7_SCENE_SERVER_ID,
DEFAULT_N7_SCENE_SERVER_NAME,
ModeSceneCatalogPublisher,
build_mode_scene_catalog,
)
from cognitive_agent.application.mode_schedule import ModeSchedule, ModeScheduleRunner # noqa: E402
from cognitive_agent.application.mode_schedule_service import ModeScheduleServiceMixin # noqa: E402
from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
@@ -179,6 +185,7 @@ MODE_PROFILE_UPDATE_EXPLICIT_TARGET_PATTERN = re.compile(
)
WIRED_CAN_TRANSPORT = "wired_can"
WIRELESS_MQTT_TRANSPORT = "wireless_mqtt"
LOCAL_CONTROL_BUDGET_MS = 1000
VEHICLE_CONTROL_TRANSPORTS = frozenset(
{WIRED_CAN_TRANSPORT, WIRELESS_MQTT_TRANSPORT}
)
@@ -300,6 +307,7 @@ CONTROL_CLAUSE_SUBJECT_PATTERN = re.compile(
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|滑轨|"
r"通风|加热|按摩|零重力|半躺|女王副驾|休闲躺椅|"
r"遮阳帘|天幕帘|天幕|车窗|窗户|"
r"(?:左前|前左|右前|前右|左后|后左|右后|后右)(?:排)?窗|"
r"灯光|室内灯|车内灯|阅读灯|氛围灯|"
r"空调|压缩机|温度|风量|风速|内循环|外循环|"
r"车门|门锁|后备箱|前大灯|近光灯|远光灯|雾灯|转向灯|双闪|"
@@ -310,10 +318,17 @@ CONTROL_CLAUSE_PREDICATE_PATTERN = re.compile(
r"调整|调节|调到|调成|调为|调至|设置|设为|设到|切换|"
r"移动|移到|移至|前移|后移|上移|下移|升高|降低|抬起|放倒|"
r"调直|立直|竖直|归零|复位|锁上|上锁|落锁|解锁|"
r"零重力|半躺|最大|最小|最高|最低|最前|最后|到底|"
r"零重力|半躺|一半|半开|半关|最大|最小|最高|最低|最前|最后|到底|"
r"(?:\d+(?:\.\d+)?|[零一二三四五六七八九十百两半]+)"
r"(?:%|%|度|℃|档|级|秒|毫秒)"
)
# These verbs identify an independent request that may be outside the local
# vehicle registry. They are used only to split mixed sentences so an
# already deterministic clause never has to be repeated to a hosted model.
CLOUD_REQUEST_CLAUSE_PATTERN = re.compile(
r"播放|暂停播放|停止播放|导航|搜索|查找|查询|拨打|呼叫|发送|"
r"提醒|推荐|讲解|介绍|告诉|规划"
)
RESTORE_MODE_PROFILE_PREFIX = re.compile(
r"^(?:请(?:帮我)?|帮我)?(?:恢复|还原|重置|初始化)"
r"(?P<name>[^,。,.!!\s]{1,64}?模式)"
@@ -429,6 +444,17 @@ class SeatDemoService(ModeScheduleServiceMixin):
repository_root / "config" / "modes",
user_root=self.mode_profile_user_root,
)
self._mode_scene_catalog_publisher: ModeSceneCatalogPublisher | None = None
self._mode_scene_catalog_status: dict[str, Any] = {
"enabled": False,
"state": "disabled",
"path": None,
"vehicle_id": self.vehicle_profiles.default().id,
"server_id": DEFAULT_N7_SCENE_SERVER_ID,
"catalog_revision": None,
"scene_count": 0,
"last_error": None,
}
self.scene_templates = SceneTemplateRegistry(
repository_root / "config" / "scene_templates"
)
@@ -447,6 +473,88 @@ class SeatDemoService(ModeScheduleServiceMixin):
if hasattr(self, "conversations"):
self.conversations.preset_root = self._artifact_root / "user-presets"
def configure_mode_scene_catalog(
self,
path: Path,
*,
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
) -> dict[str, Any]:
"""Enable the derived JSON catalog without changing YAML persistence."""
catalog_path = Path(path).expanduser()
if not catalog_path.is_absolute():
catalog_path = self.root / catalog_path
vehicle_id = self.vehicle_profiles.default().id
self._mode_scene_catalog_publisher = ModeSceneCatalogPublisher(
catalog_path,
vehicle_id,
server_id=server_id,
name=name,
)
self._mode_scene_catalog_status = {
"enabled": True,
"state": "initializing",
"path": str(catalog_path),
"vehicle_id": vehicle_id,
"server_id": self._mode_scene_catalog_publisher.server_id,
"catalog_revision": None,
"scene_count": 0,
"last_error": None,
}
# The registry notifies after create, update, delete, restore, and any
# explicit reload. The callback only updates a derived projection; YAML
# remains the authoritative store used by every existing execution path.
self.mode_profiles.add_change_listener(self._synchronize_mode_scene_catalog)
return self.mode_scene_catalog_status()
def _synchronize_mode_scene_catalog(self, registry: ModeProfileRegistry) -> None:
publisher = self._mode_scene_catalog_publisher
if publisher is None:
return
try:
catalog = publisher.publish(registry)
except Exception as exc: # noqa: BLE001 - derived export must not block controls
self._mode_scene_catalog_status.update(
{
"state": "error",
"last_error": str(exc),
}
)
return
self._mode_scene_catalog_status.update(
{
"state": "ready",
"catalog_revision": catalog["catalog_revision"],
"scene_count": catalog["scene_count"],
"last_error": None,
}
)
def mode_scene_catalog(self, vehicle_id: str | None = None) -> dict[str, Any]:
"""Return the current read-only mode-card catalog for an N7 client."""
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
publisher = self._mode_scene_catalog_publisher
if publisher is not None and resolved_vehicle_id == publisher.vehicle_id:
self._synchronize_mode_scene_catalog(self.mode_profiles)
snapshot = publisher.snapshot()
if snapshot is not None and self._mode_scene_catalog_status["state"] == "ready":
return snapshot
return build_mode_scene_catalog(
self.mode_profiles,
resolved_vehicle_id,
server_id=(
publisher.server_id
if publisher is not None
else DEFAULT_N7_SCENE_SERVER_ID
),
name=(publisher.name if publisher is not None else DEFAULT_N7_SCENE_SERVER_NAME),
)
def mode_scene_catalog_status(self) -> dict[str, Any]:
return deepcopy(self._mode_scene_catalog_status)
@property
def driver_only_ch1_execution_active(self) -> bool:
"""Return whether deployment policy authorizes only the CH1 vehicle path."""
@@ -603,6 +711,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
"scene_templates": self.scene_templates.public_templates(),
"mode_profile_storage": "persistent_yaml",
"mode_profile_user_root": str(self.mode_profile_user_root),
"mode_scene_catalog": self.mode_scene_catalog_status(),
"preset_storage": "conversation_durable",
"inferred_level_range": [1, 3],
"motion_position_range_pct": [0, 100],
@@ -1545,9 +1654,11 @@ class SeatDemoService(ModeScheduleServiceMixin):
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
assistant_plan: dict[str, Any] | None = None,
mode_profile_draft_context: dict[str, Any] | None = None,
_cloud_instruction_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Validate and execute deterministic controls before hosted parsing."""
local_stage_started = time.perf_counter()
utterance = utterance.strip()
if not utterance:
raise ValueError("utterance must not be empty")
@@ -1576,6 +1687,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
_on_commands_validated=on_commands_validated,
_assistant_plan=assistant_plan,
_mode_profile_draft_context=mode_profile_draft_context,
_cloud_instruction_context=_cloud_instruction_context,
)
result.update(
{
@@ -1594,14 +1706,71 @@ class SeatDemoService(ModeScheduleServiceMixin):
vehicle_profile,
)
clause_results = self._local_clause_results(utterance, context)
unresolved_clauses = [
locally_parsed_clauses = [
clause
for clause, _, status in clause_results
if status != "unparsed"
]
locally_resolved_clauses = [
clause
for clause, _, status in clause_results
if status == "complete"
]
locally_unresolved_clauses = [
clause
for clause, _, status in clause_results
if status != "complete"
]
started = time.perf_counter()
supplied_routing = (
_cloud_instruction_context
if isinstance(_cloud_instruction_context, dict)
else {}
)
def routing_clauses(key: str) -> list[str]:
raw = supplied_routing.get(key)
if not isinstance(raw, list):
return []
return [
text
for item in raw[:64]
for text in [str(item).strip()[:1000]]
if text
]
def unique_clauses(*groups: list[str]) -> list[str]:
return list(dict.fromkeys(item for group in groups for item in group))
parsed_clauses = unique_clauses(
routing_clauses("parsed_clauses"),
locally_parsed_clauses,
)
resolved_clauses = unique_clauses(
routing_clauses("resolved_clauses"),
locally_resolved_clauses,
)
unresolved_clauses = locally_unresolved_clauses
routing_unresolved_clauses = unique_clauses(
routing_clauses("unresolved_clauses"),
locally_unresolved_clauses,
)
original_utterance = str(
supplied_routing.get("original_utterance") or utterance
).strip()[:1000]
natural_language_routing = {
"original_utterance": original_utterance,
"parsed_clauses": parsed_clauses,
"resolved_clauses": resolved_clauses,
"unresolved_clauses": routing_unresolved_clauses,
}
local_dispatch_elapsed_ms: float | None = None
def publish_local_commands(validated: dict[str, Any]) -> None:
nonlocal local_dispatch_elapsed_ms
local_dispatch_elapsed_ms = round(
(time.perf_counter() - local_stage_started) * 1000,
3,
)
if on_commands_validated is None:
return
event = deepcopy(validated)
@@ -1613,6 +1782,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
"supplement_required": bool(
provider != "local" and unresolved_clauses
),
"natural_language_routing": deepcopy(
natural_language_routing
),
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
"local_control_elapsed_ms": local_dispatch_elapsed_ms,
"local_control_within_budget": (
local_dispatch_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
),
}
)
on_commands_validated(event)
@@ -1634,8 +1811,17 @@ class SeatDemoService(ModeScheduleServiceMixin):
_on_commands_validated=publish_local_commands,
_assistant_plan=assistant_plan,
_mode_profile_draft_context=mode_profile_draft_context,
_cloud_instruction_context=natural_language_routing,
)
elapsed_ms = round(
(time.perf_counter() - local_stage_started) * 1000,
3,
)
local_control_elapsed_ms = (
local_dispatch_elapsed_ms
if local_dispatch_elapsed_ms is not None
else elapsed_ms
)
elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
supplement_required = bool(
provider != "local" and unresolved_clauses
)
@@ -1645,7 +1831,17 @@ class SeatDemoService(ModeScheduleServiceMixin):
"provider_requested": provider,
"preview_request_id": result["request_id"],
"preview_elapsed_ms": elapsed_ms,
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
"local_control_elapsed_ms": local_control_elapsed_ms,
"local_control_within_budget": (
local_control_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
),
"parsed_clauses": parsed_clauses,
"resolved_clauses": resolved_clauses,
"unresolved_clauses": unresolved_clauses,
"natural_language_routing": deepcopy(
natural_language_routing
),
"supplement_required": supplement_required,
}
)
@@ -1669,7 +1865,10 @@ class SeatDemoService(ModeScheduleServiceMixin):
"hardware_client_id": hardware_client_id,
"reasoning_effort": reasoning_effort,
"control_transport": control_transport,
"parsed_clauses": parsed_clauses,
"resolved_clauses": resolved_clauses,
"unresolved_clauses": unresolved_clauses,
"natural_language_routing": natural_language_routing,
"preview_control_signatures": result.get(
"parsed_control_signatures", []
),
@@ -1734,6 +1933,34 @@ class SeatDemoService(ModeScheduleServiceMixin):
for item in manifest.get("preview_control_signatures", [])
if isinstance(item, list)
}
manifest_routing = manifest.get("natural_language_routing")
natural_language_routing = (
deepcopy(manifest_routing)
if isinstance(manifest_routing, dict)
else {
"original_utterance": str(manifest.get("utterance") or ""),
"parsed_clauses": list(manifest.get("parsed_clauses") or []),
"resolved_clauses": list(manifest.get("resolved_clauses") or []),
"unresolved_clauses": unresolved_clauses,
}
)
routing_unresolved = natural_language_routing.get("unresolved_clauses")
natural_language_routing["unresolved_clauses"] = list(
dict.fromkeys(
[
str(item).strip()
for item in (
routing_unresolved
if isinstance(routing_unresolved, list)
else []
)
if str(item).strip()
]
+ unresolved_clauses
)
)
cloud_utterance = ",".join(unresolved_clauses)
natural_language_routing["cloud_input_utterance"] = cloud_utterance
def publish_cloud_commands(validated: dict[str, Any]) -> None:
if on_commands_validated is None:
@@ -1750,7 +1977,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
on_commands_validated(event)
supplement = self.process(
",".join(unresolved_clauses),
cloud_utterance,
provider=selected_provider,
conversation_id=str(manifest["conversation_id"]),
execution_mode=str(manifest.get("execution_mode") or "dry_run"),
@@ -1769,6 +1996,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
_model_parse_source="cloud_supplement",
_allow_empty_control_result=True,
_on_commands_validated=publish_cloud_commands,
_cloud_instruction_context=natural_language_routing,
)
merged = self._merge_native_can_phases(preview, supplement)
merged.update(
@@ -1785,6 +2013,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
"supplement_request_id": supplement["request_id"],
"supplement_required": False,
"unresolved_clauses": unresolved_clauses,
"natural_language_routing": natural_language_routing,
}
)
self._persist(merged)
@@ -2081,6 +2310,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
_speech_understanding: dict[str, Any] | None = None,
_assistant_plan: dict[str, Any] | None = None,
_mode_profile_draft_context: dict[str, Any] | None = None,
_cloud_instruction_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -2155,6 +2385,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
_speech_understanding,
assistant_plan=_assistant_plan,
mode_profile_draft_context=_mode_profile_draft_context,
cloud_instruction_context=_cloud_instruction_context,
)
if control_transport == WIRELESS_MQTT_TRANSPORT:
# The head unit owns both physical CAN channels in wireless mode.
@@ -2180,6 +2411,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
_speech_understanding,
assistant_plan=_assistant_plan,
mode_profile_draft_context=_mode_profile_draft_context,
cloud_instruction_context=_cloud_instruction_context,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -2205,6 +2437,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
_speech_understanding,
assistant_plan=_assistant_plan,
mode_profile_draft_context=_mode_profile_draft_context,
cloud_instruction_context=_cloud_instruction_context,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -2229,6 +2462,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
_speech_understanding,
assistant_plan=_assistant_plan,
mode_profile_draft_context=_mode_profile_draft_context,
cloud_instruction_context=_cloud_instruction_context,
)
def _process_once(
@@ -2256,6 +2490,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
scene_scope_utterance: str | None = None,
mode_creation_scopes: frozenset[str] | None = None,
mode_profile_draft_context: dict[str, Any] | None = None,
cloud_instruction_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -2589,6 +2824,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
assistant_plan=assistant_plan,
allow_scene_composition=allow_scene_composition,
scene_scope_utterance=scene_scope_utterance,
cloud_instruction_context=cloud_instruction_context,
)
scene_label_only = assistant_plan is not None and allow_scene_composition
dynamic_mode_stop = (
@@ -7450,6 +7686,11 @@ class SeatDemoService(ModeScheduleServiceMixin):
and CONTROL_CLAUSE_PREDICATE_PATTERN.search(value)
)
def looks_like_independent_request(value: str) -> bool:
return looks_like_control(value) or bool(
CLOUD_REQUEST_CLAUSE_PATTERN.search(value)
)
def split_independent_controls(
value: str,
separator_pattern: str,
@@ -7460,7 +7701,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
for index in range(1, len(tokens), 2):
separator = tokens[index]
next_item = tokens[index + 1].strip()
if looks_like_control(current) and looks_like_control(next_item):
if (
looks_like_independent_request(current)
and looks_like_independent_request(next_item)
and (
looks_like_control(current)
or looks_like_control(next_item)
)
):
if current:
separated.append(current)
current = next_item
@@ -7771,6 +8019,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
assistant_plan: dict[str, Any] | None = None,
allow_scene_composition: bool = False,
scene_scope_utterance: str | None = None,
cloud_instruction_context: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str, str | None]:
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
if assistant_plan is not None and allow_scene_composition:
@@ -7936,9 +8185,29 @@ class SeatDemoService(ModeScheduleServiceMixin):
interpret_options = {}
if reasoning_effort is not None:
interpret_options["reasoning_effort"] = reasoning_effort
hosted_context = deepcopy(context)
if isinstance(cloud_instruction_context, dict):
routing_context = deepcopy(cloud_instruction_context)
else:
routing_context = {
"original_utterance": utterance,
"parsed_clauses": [
clause
for clause, _, status in clause_results
if status != "unparsed"
],
"resolved_clauses": [
clause
for clause, _, status in clause_results
if status == "complete"
],
"unresolved_clauses": fallback_clauses,
}
routing_context["cloud_input_utterance"] = fallback_utterance
hosted_context["natural_language_routing"] = routing_context
plan = self.openai.interpret(
fallback_utterance,
context,
hosted_context,
self.capability_context,
**interpret_options,
)
+179 -64
View File
@@ -15,7 +15,7 @@ import uuid
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
@@ -2387,6 +2387,7 @@ class SelfExtensionService:
conversation_turn: int = 0,
execution_mode: str = "dry_run",
provider_requested: str = "auto",
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a live-NFC peripheral match through the main request API."""
@@ -2398,41 +2399,8 @@ class SelfExtensionService:
request_id = f"req-extension-{uuid.uuid4().hex}"
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
hardware_request = execution_mode == "hardware"
if hardware_request:
gateway_result = self.execute_gateway("emit", {"slot": slot})
transport = str(gateway_result.get("transport") or self.gateway.transport)
gateway_acknowledged = gateway_result.get("gateway_acknowledged") is True
command = gateway_result.get("request")
operation_message = (
f"{capability.get('device_name')} · {capability.get('action')} 已发送至 "
"GSCU1B;网关已确认接收,外设物理效果待独立确认。"
)
delivery_status = "published"
else:
command = build_gateway_command("emit", {"slot": slot})
transport = "dry_run"
gateway_acknowledged = False
operation_message = (
f"{capability.get('device_name')} · {capability.get('action')} 已完成红外路由校验;"
"当前为 dry-run,未向 GSCU1B 发射。"
)
delivery_status = "planned"
transport = self.gateway.transport if hardware_request else "dry_run"
operation_ref = str(capability.get("id") or f"ir-slot-{slot}")
operation = {
"ref": operation_ref,
"domain": "self_extension",
"status": "succeeded",
"message": operation_message,
"telemetry": {
"transport": transport,
"slot": slot,
"delivery_status": delivery_status,
"gateway_acknowledged": gateway_acknowledged,
"physical_effect_confirmed": False,
"command": command,
},
}
function_config = {
"apiVersion": "cognitive-agent.io/v1alpha1",
"kind": "FunctionConfig",
@@ -2455,7 +2423,9 @@ class SelfExtensionService:
"action": capability.get("action"),
"slot": slot,
},
"parse_source": capability.get("parse_source", "local_rule"),
"parse_source": capability.get(
"parse_source", "local_rule"
),
"timeout_ms": int(self.command_timeout * 1000),
"failure_policy": "abort",
}
@@ -2479,6 +2449,78 @@ class SelfExtensionService:
],
},
}
if on_commands_validated is not None:
on_commands_validated(
{
"request_id": request_id,
"utterance": utterance,
"conversation_id": conversation_id,
"conversation_turn": conversation_turn,
"status": "processing",
"phase": "commands_validated",
"orchestration_route": "nfc_peripheral",
"provider_requested": provider_requested,
"provider_used": "nfc_peripheral_router",
"execution_mode": execution_mode,
"control_transport": (
"wireless_mqtt"
if transport == "mqtt"
else f"gscu_{transport}"
),
"trace_events": [
{
"stage": "nfc_peripheral_route",
"status": "succeeded",
"summary": "本地规则已完成红外能力路由与指令编译",
"duration_ms": 0,
}
],
"validation": [],
"function_config": function_config,
"function_configs": {"self_extension": function_config},
"command_plan": command_plan,
"command_plans": {"self_extension": command_plan},
"execution_report": None,
"operation_results": [],
"deferred_requests": [],
"domain_state": {},
"actuator_state": {},
}
)
if hardware_request:
gateway_result = self.execute_gateway("emit", {"slot": slot})
transport = str(gateway_result.get("transport") or self.gateway.transport)
gateway_acknowledged = gateway_result.get("gateway_acknowledged") is True
command = gateway_result.get("request")
operation_message = (
f"{capability.get('device_name')} · {capability.get('action')} 已发送至 "
"GSCU1B;网关已确认接收,外设物理效果待独立确认。"
)
delivery_status = "published"
else:
command = build_gateway_command("emit", {"slot": slot})
transport = "dry_run"
gateway_acknowledged = False
operation_message = (
f"{capability.get('device_name')} · {capability.get('action')} 已完成红外路由校验;"
"当前为 dry-run,未向 GSCU1B 发射。"
)
delivery_status = "planned"
operation = {
"ref": operation_ref,
"domain": "self_extension",
"status": "succeeded",
"message": operation_message,
"telemetry": {
"transport": transport,
"slot": slot,
"delivery_status": delivery_status,
"gateway_acknowledged": gateway_acknowledged,
"physical_effect_confirmed": False,
"command": command,
},
}
control_transport = (
"wireless_mqtt" if transport == "mqtt" else f"gscu_{transport}"
)
@@ -2548,6 +2590,7 @@ class SelfExtensionService:
conversation_turn: int = 0,
execution_mode: str = "dry_run",
provider_requested: str = "auto",
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a validated ordered peripheral/delay plan for the live NFC."""
@@ -2642,6 +2685,76 @@ class SelfExtensionService:
}
)
function_config = {
"apiVersion": "cognitive-agent.io/v1alpha1",
"kind": "FunctionConfig",
"metadata": {
"name": f"{request_id}-sequence",
"labels": {"execution_scope": "nfc_bound_ir_peripheral"},
},
"spec": {
"domain": "self_extension",
"operations": function_operations,
},
}
command_plan = {
"apiVersion": "cognitive-agent.io/v1alpha1",
"kind": "CommandPlan",
"metadata": {"name": f"{request_id}-gscu-sequence"},
"spec": {
"domain": "self_extension",
"platform": "gscu1b",
"commands": commands,
},
}
if on_commands_validated is not None:
on_commands_validated(
{
"request_id": request_id,
"utterance": utterance,
"conversation_id": conversation_id,
"conversation_turn": conversation_turn,
"status": "processing",
"phase": "commands_validated",
"orchestration_route": "nfc_peripheral_sequence",
"provider_requested": provider_requested,
"provider_used": str(
plan_result.get("provider_used")
or f"gpt:{plan_result.get('model') or self.openai_model}"
),
"execution_mode": execution_mode,
"control_transport": (
"wireless_mqtt"
if transport == "mqtt"
else f"gscu_{transport}"
),
"trace_events": [
{
"stage": (
"local_peripheral_plan"
if str(plan_result.get("provider_used") or "").startswith(
"infrared_local"
)
else "gpt_peripheral_plan"
),
"status": "succeeded",
"summary": f"已编译并校验 {len(steps)} 个有序步骤",
"duration_ms": 0,
}
],
"validation": [],
"function_config": function_config,
"function_configs": {"self_extension": function_config},
"command_plan": command_plan,
"command_plans": {"self_extension": command_plan},
"execution_report": None,
"operation_results": [],
"deferred_requests": [],
"domain_state": {},
"actuator_state": {},
}
)
for index, step in enumerate(steps):
if step.get("kind") == "delay":
delay_ms = int(step.get("delay_ms", 0))
@@ -2781,28 +2894,6 @@ class SelfExtensionService:
)
report_status = "succeeded" if succeeded else "failed"
first_capability = next(iter(catalog_by_id.values()))
function_config = {
"apiVersion": "cognitive-agent.io/v1alpha1",
"kind": "FunctionConfig",
"metadata": {
"name": f"{request_id}-sequence",
"labels": {"execution_scope": "nfc_bound_ir_peripheral"},
},
"spec": {
"domain": "self_extension",
"operations": function_operations,
},
}
command_plan = {
"apiVersion": "cognitive-agent.io/v1alpha1",
"kind": "CommandPlan",
"metadata": {"name": f"{request_id}-gscu-sequence"},
"spec": {
"domain": "self_extension",
"platform": "gscu1b",
"commands": commands,
},
}
control_transport = (
"wireless_mqtt" if transport == "mqtt" else f"gscu_{transport}"
)
@@ -2988,12 +3079,22 @@ class SelfExtensionService:
item for item in vehicle_devices[:32] if isinstance(item, dict)
]
allowed_vehicle_commands = set(_vehicle_catalog_commands(vehicle_devices))
response_text = self._request_gpt_plan(
utterance,
planner_peripherals,
vehicle_devices,
api_key,
)
raw_routing = payload.get("natural_language_routing")
if isinstance(raw_routing, dict):
response_text = self._request_gpt_plan(
utterance,
planner_peripherals,
vehicle_devices,
api_key,
natural_language_routing=dict(raw_routing),
)
else:
response_text = self._request_gpt_plan(
utterance,
planner_peripherals,
vehicle_devices,
api_key,
)
try:
proposed = json.loads(response_text)
except json.JSONDecodeError as exc:
@@ -3120,6 +3221,8 @@ class SelfExtensionService:
peripherals: list[dict[str, Any]],
vehicle_devices: list[dict[str, Any]],
api_key: str,
*,
natural_language_routing: dict[str, Any] | None = None,
) -> str:
peripheral_ids = [
str(item.get("id"))
@@ -3172,6 +3275,9 @@ class SelfExtensionService:
"required": ["summary", "needs_confirmation", "question", "steps"],
}
system_prompt = f"""你是车载外设自扩展工作台的意图规划器。只输出 JSON 对象。
输入中的 natural_language_routing 是可信路由数据:original_utterance 为完整原句,
parsed_clauses/resolved_clauses 已由本地规则处理,只能为 unresolved_clauses 和当前 utterance
补充计划,禁止重复、替换或重新执行已解析子句。
peripheral 只能逐字引用目录中 status=learned 的短 capability_id,绝不能编造或改写 ID。
仅当同类存在多台可匹配的外设玩具且用户未说明目标时,设置 needs_confirmation=true 并追问;
此追问规则不适用于车窗、空调、座椅、电动风口等车端能力。
@@ -3218,6 +3324,15 @@ delay 步骤的 capability_id 和 command 必须为空字符串,不能位于
"text": json.dumps(
{
"utterance": utterance,
"natural_language_routing": (
natural_language_routing or {
"original_utterance": utterance,
"parsed_clauses": [],
"resolved_clauses": [],
"unresolved_clauses": [utterance],
"cloud_input_utterance": utterance,
}
),
"learned_peripheral_capabilities": peripherals,
"can_dev_vehicle_capabilities": vehicle_devices,
},