merge: integrate remote MQTT routing with seat-scoped dynamic modes
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user