fix: compose named cockpit modes from capabilities

This commit is contained in:
vee
2026-09-07 20:52:19 +08:00
parent cf9b89508c
commit 6b2b644ee6
23 changed files with 2917 additions and 124 deletions
@@ -6,6 +6,7 @@
- 只有 `n7_vehicle.execute_cockpit_request` 可以产生车辆、座椅、座舱模式或已授权外设的副作用。该工具不接受模型生成的控制文本或原始 CAN;它只执行服务端绑定到本回合令牌的原始需求或服务端规范化的舒适性动作。禁止用 shell、文件修改、原始 CAN 帧或其他工具绕过它。
- 对目录中的原子设备控制,调用工具时同时提交结构化 `plan`:只能逐字使用本回合能力目录中 `assistant_callable=true` 的 capability、target scope 和参数契约。对于目录中已有的座舱模式或当前 NFC 卡已授权的红外外设,省略 `plan`,由应用按动态注册表解析。结构化计划只是候选数据,应用会再次执行原始文本证据校验、Schema 校验和安全校验;本地确定性解析与计划重叠时,以本地结果为准。
- 用户仅给出一个尚未登记的功能模式名称时,应结合该场景的通常含义,从本回合能力目录中选择少量、明确且相互协调的 `assistant_callable=true` 原子能力,提交一个结构化 `plan`。每项动作必须是可重复的绝对 `set` 状态,不得使用相对增减、时长动作、动态/循环模式、保存或应用操作;同一能力与目标不得重复。若任何动作或参数有歧义,就不要提交残缺计划。不得引用目录外能力,不得生成原始 CAN,不得声称已经保存或真实执行;应用会把整组计划原子校验为 dry-run 动作预览,用户确认后才会保存。已有模式名称不要重新推测动作,应省略 `plan` 复用工程配置。
- 用户明确要求控制车辆,或直接表达当前座舱不适并期待处理时,必须调用该工具;不要只给建议,也不要要求用户先选择“问答/车控”模式。
- “车上有点热”“有点热”“太热了”等当前不适,在没有否定、假设或纯知识提问时,直接调用一次工具;具体规范动作由服务端决定。
- “如果车上有点热应该怎么办”“为什么暴晒后车里会热”“车上有点热,但不要调整设备”等是假设、知识问题或否定请求,不得执行。
@@ -850,7 +850,14 @@ class AssistantCapabilityCatalog:
}
)
compact_modes = [
{"id": item.get("id"), "name": item.get("name"), "aliases": item.get("aliases")}
{
"id": item.get("id"),
"name": item.get("name"),
"aliases": item.get("aliases"),
"description": item.get("description"),
"domains": item.get("domains"),
"operation_ids": item.get("operation_ids"),
}
for item in catalog.get("modes", [])
if isinstance(item, Mapping)
]
@@ -30,6 +30,47 @@ MAX_PLAN_REQUESTS = 12
MAX_PLAN_PARAMETERS = 12
_OPERATIONS = frozenset({"activate", "deactivate", "increase", "decrease", "set", "save", "apply"})
_SCENE_COMPOSABLE_CAPABILITIES = frozenset(
{
"seat.set_longitudinal_position",
"seat.set_cushion_tilt",
"seat.set_backrest_recline",
"seat.set_legrest_height",
"seat.set_legrest_extension",
"seat.set_ventilation",
"seat.set_heating",
"climate.set_power",
"climate.set_temperature",
"climate.set_fan_level",
"climate.set_cycle_mode",
"climate.set_control_mode",
"climate.set_outlet_mode",
"climate.set_front_defrost",
"roof.set_shade_position",
"roof.set_transmittance",
"roof.set_automatic_dimming",
"window.set_position",
"interior_light.set_indoor_power",
"interior_light.set_reading_power",
"interior_light.set_wall_power",
"ambient_light.set_power",
"ambient_light.set_illumination",
"ambient_light.set_mode",
"ambient_light.set_color_index",
"ambient_light.set_brightness_level",
"outlet.set_switch_state",
"outlet.set_position_percent",
}
)
# Reusable scenes are replayed through the native compiler after the user saves
# them. Keep scene-only parameter bounds aligned with that compiler even when
# a broader virtual capability contract accepts additional values. In
# particular, fan level 0 is a virtual shorthand for "off", while the native
# operation is explicitly 1-7; changing it to HVAC power-off here would alter
# the model's proposed intent.
_SCENE_PARAMETER_BOUNDS: dict[tuple[str, str], tuple[float | None, float | None]] = {
("climate.set_fan_level", "level"): (1, 7),
}
_ROOT_FIELDS = frozenset(
{"detected_domains", "requests", "ambiguities", "decision_summary", "confidence"}
)
@@ -404,6 +445,123 @@ def _registered_capabilities(
return registered
def _registered_capability_specs(
capability_context: Mapping[str, Any],
executable_capabilities: AbstractSet[str],
) -> dict[str, tuple[str, Mapping[str, Any]]]:
registered: dict[str, tuple[str, Mapping[str, Any]]] = {}
domains = capability_context.get("domains", [])
if not isinstance(domains, Sequence) or isinstance(
domains,
(str, bytes, bytearray),
):
return registered
for raw_domain in domains:
if not isinstance(raw_domain, Mapping):
continue
domain = _safe_text(raw_domain.get("domain"), limit=64)
capabilities = raw_domain.get("capabilities", [])
if not isinstance(capabilities, Sequence) or isinstance(
capabilities,
(str, bytes, bytearray),
):
continue
for raw_capability in capabilities:
if not isinstance(raw_capability, Mapping):
continue
capability = _safe_text(raw_capability.get("name"), limit=128)
if capability and domain and capability in executable_capabilities:
registered[capability] = (domain, raw_capability)
return registered
def _scene_contract_error(
capability: str,
target_scope: str,
parameters: Sequence[Mapping[str, Any]],
specification: Mapping[str, Any],
) -> str | None:
"""Validate a scene request before adapter/native routing can drop it."""
target_scopes = specification.get("target_scopes", [])
if (
not isinstance(target_scopes, Sequence)
or isinstance(target_scopes, (str, bytes, bytearray))
or target_scope not in {str(item) for item in target_scopes}
):
return f"能力 {capability} 不支持目标范围 {target_scope!r}"
raw_specs = specification.get("parameters", [])
if not isinstance(raw_specs, Sequence) or isinstance(
raw_specs,
(str, bytes, bytearray),
):
return f"能力 {capability} 的参数契约不可用"
specs = {
str(item.get("name")): item
for item in raw_specs
if isinstance(item, Mapping) and str(item.get("name") or "").strip()
}
values = {str(item["name"]): item.get("value") for item in parameters}
unknown = sorted(set(values) - set(specs))
if unknown:
return f"能力 {capability} 不支持参数 {', '.join(unknown)}"
relative = sorted(
name
for name in values
if name in {"distance_mm", "angle_delta_deg"} or "delta" in name
)
if relative:
return f"功能模式不接受相对参数 {', '.join(relative)}"
if capability.startswith("seat.set_") and len(
{
name
for name in ("position_pct", "angle_deg")
if values.get(name) is not None
}
) > 1:
return "座椅绝对位置只能使用 position_pct 或 angle_deg 中的一种表示"
for name, spec in specs.items():
value = values.get(name)
if spec.get("required") and value is None:
return f"能力 {capability} 缺少必填参数 {name}"
if value is None:
continue
expected_type = str(spec.get("type") or "")
numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
type_valid = {
"number": numeric,
"integer": numeric and float(value).is_integer(),
"string": isinstance(value, str),
"boolean": isinstance(value, bool),
}.get(expected_type, False)
if not type_valid:
return f"参数 {name} 的类型必须是 {expected_type}"
minimum = spec.get("minimum")
maximum = spec.get("maximum")
scene_bounds = _SCENE_PARAMETER_BOUNDS.get((capability, name))
if scene_bounds is not None:
minimum, maximum = scene_bounds
if numeric and (
(minimum is not None and float(value) < float(minimum))
or (maximum is not None and float(value) > float(maximum))
):
return (
f"参数 {name} 超出能力 {capability} 的允许范围 "
f"{minimum if minimum is not None else '-'}-"
f"{maximum if maximum is not None else '+'}"
)
allowed = spec.get("allowed_values")
if (
isinstance(allowed, Sequence)
and not isinstance(allowed, (str, bytes, bytearray))
and value not in allowed
):
return f"参数 {name} 不在能力 {capability} 的允许值内"
return None
def _relevant_text(capability: str, text: str) -> str:
"""Narrow semantic checks to clauses mentioning this capability's subject."""
@@ -920,12 +1078,16 @@ def guard_assistant_plan(
executable_capabilities: AbstractSet[str],
fallback_intents: Sequence[Mapping[str, Any]] = (),
trusted_local: Mapping[str, Any] | None = None,
allow_scene_composition: bool = False,
) -> GuardedAssistantPlan:
"""Authorize a Codex plan against the server-owned unresolved text.
Rejected capability requests are omitted and represented as adapter-style
ambiguities. Structurally malformed plans raise ``ValueError`` so callers
can fail the tool call without invoking any executor.
ambiguities. Structurally malformed plans raise ``ValueError`` so callers
can fail the tool call without invoking any executor. ``allow_scene_composition``
is reserved for a name-only mode-creation preview: it relaxes textual value
evidence, but never registry membership, schema/range validation, dry-run,
or the user's explicit save confirmation.
"""
if not isinstance(raw_plan, Mapping) or set(raw_plan) != _ROOT_FIELDS:
@@ -948,6 +1110,10 @@ def guard_assistant_plan(
capability_context,
executable_capabilities,
)
registered_specs = _registered_capability_specs(
capability_context,
executable_capabilities,
)
parser_claims: set[str] = set()
for intent in fallback_intents:
if isinstance(intent, Mapping):
@@ -957,6 +1123,21 @@ def guard_assistant_plan(
accepted: list[dict[str, Any]] = []
rejected: list[dict[str, str]] = []
raw_ambiguities = raw_plan.get("ambiguities", [])
if allow_scene_composition:
if not isinstance(raw_ambiguities, Sequence) or isinstance(
raw_ambiguities,
(str, bytes, bytearray),
):
raise ValueError("assistant scene plan ambiguities must be an array")
if raw_ambiguities:
rejected.append(
{
"field": "ambiguities",
"question": "场景候选计划仍有未解决歧义,已拒绝生成部分模式。",
}
)
scene_keys: set[tuple[str, str, str]] = set()
for index, raw_request in enumerate(requests):
if not isinstance(raw_request, Mapping) or set(raw_request) != _REQUEST_FIELDS:
raise ValueError(f"assistant plan request {index} is invalid")
@@ -974,11 +1155,30 @@ def guard_assistant_plan(
}
)
continue
if allow_scene_composition and capability not in _SCENE_COMPOSABLE_CAPABILITIES:
rejected.append(
{
"field": f"requests[{index}].capability",
"question": "该能力不是可重复功能模式允许组合的绝对状态能力。",
}
)
continue
if allow_scene_composition and operation != "set":
rejected.append(
{
"field": f"requests[{index}].operation",
"question": "功能模式只接受可重复的绝对 set 操作。",
}
)
continue
request_text = _relevant_text(capability, text)
if non_execution or not _has_explicit_evidence(
capability,
request_text,
parser_claims,
if non_execution or (
not allow_scene_composition
and not _has_explicit_evidence(
capability,
request_text,
parser_claims,
)
):
rejected.append(
{
@@ -987,7 +1187,10 @@ def guard_assistant_plan(
}
)
continue
if not _target_is_evidenced(capability, target_scope, request_text):
if (
not allow_scene_composition
and not _target_is_evidenced(capability, target_scope, request_text)
):
rejected.append(
{
"field": f"requests[{index}].target_scope",
@@ -1009,6 +1212,37 @@ def guard_assistant_plan(
names = [str(item["name"]) for item in normalized_parameters]
if len(names) != len(set(names)):
raise ValueError(f"assistant plan request {index} repeats a parameter")
if allow_scene_composition:
registered_spec = registered_specs.get(capability)
contract_error = (
_scene_contract_error(
capability,
target_scope,
normalized_parameters,
registered_spec[1],
)
if registered_spec is not None
else "能力契约不可用"
)
if contract_error is not None:
rejected.append(
{
"field": f"requests[{index}]",
"question": f"{contract_error},已拒绝生成该功能模式。",
}
)
continue
scene_key = (domain, capability, target_scope)
if allow_scene_composition and scene_key in scene_keys:
rejected.append(
{
"field": f"requests[{index}]",
"question": "功能模式包含重复的能力和目标,已拒绝生成部分模式。",
}
)
continue
scene_keys.add(scene_key)
local_key = (domain, capability, target_scope)
if local_key in local_keys or (
@@ -1025,12 +1259,24 @@ def guard_assistant_plan(
or not 0 <= duration <= 600_000
):
raise ValueError(f"assistant plan request {index} has invalid duration_ms")
semantic_error = _semantic_parameter_error(
capability,
operation,
normalized_parameters,
duration,
request_text,
if allow_scene_composition and duration is not None:
rejected.append(
{
"field": f"requests[{index}].duration_ms",
"question": "功能模式只接受无时长的稳定目标状态。",
}
)
continue
semantic_error = (
None
if allow_scene_composition
else _semantic_parameter_error(
capability,
operation,
normalized_parameters,
duration,
request_text,
)
)
if semantic_error is not None:
rejected.append(
@@ -1052,6 +1298,11 @@ def guard_assistant_plan(
}
)
if allow_scene_composition and rejected:
# A reusable mode must be atomic. Never offer a draft that silently
# dropped one hallucinated, conflicting, relative, or transient step.
accepted = []
summaries = raw_plan.get("decision_summary", [])
if not isinstance(summaries, Sequence) or isinstance(summaries, (str, bytes, bytearray)):
summaries = []
@@ -19,6 +19,7 @@ from pathlib import Path
from typing import Protocol
LOGGER = logging.getLogger(__name__)
INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力"
class CodexAssistantError(RuntimeError):
@@ -99,6 +100,7 @@ class _VehicleTurnContext:
capability_queries: int = 0
action_succeeded: bool | None = None
action_state: str | None = None
action_result: Mapping[str, object] | None = None
@dataclass(frozen=True)
@@ -139,9 +141,9 @@ _MODE_PROFILE_CREATE_PATTERN = re.compile(
r"(?:构建|创建|新建|新增|生成|添加)(?:一下)?"
r"(?:一个|个|一套|套)?"
r"(?:名为|叫做|叫)?[“\"'「『]?"
r"[^,。,.!!::\s“”\"'「」『』]{1,64}?模式[”\"'」』]?"
r"(?P<name>[^,。,.!!::\s“”\"'「」『』]{1,64}?模式)[”\"'」』]?"
r"(?:配置)?(?:并(?:添加|加入)(?:到)?功能模式(?:栏)?)?"
r"(?:[,,::].{1,800})?[。.!!]?$"
r"(?:[,,::](?P<instruction>.{1,800}))?[。.!!]?$"
)
_HOT_DISCOMFORT_PATTERN = re.compile(r"热|闷热|好热|太晒")
_COLD_DISCOMFORT_PATTERN = re.compile(r"冷|好冷")
@@ -164,12 +166,114 @@ def _normalize_instruction(value: object) -> str:
return re.sub(r"[\s,。!?!?、;;::]", "", str(value).lower())
def _mode_profile_create_parts(message: str) -> tuple[str, str] | None:
"""Return the requested scene name and optional explicit settings."""
normalized = "".join(str(message).strip().split())
match = _MODE_PROFILE_CREATE_PATTERN.fullmatch(normalized)
if match is None:
return None
return (
str(match.group("name") or "").strip(),
str(match.group("instruction") or "").strip(",,::。.!!"),
)
def is_mode_profile_create_request(message: str) -> bool:
"""Return whether the original user text is a bounded mode-create request."""
return _mode_profile_create_parts(message) is not None
def _catalog_exact_mode_match_count(
catalog: Mapping[str, object],
requested_name: str,
) -> int | None:
"""Count exact visible mode matches; ``None`` means catalog unavailable."""
needle = "".join(str(requested_name).casefold().split())
source_status = catalog.get("source_status")
if "modes" not in catalog or (
isinstance(source_status, Mapping)
and source_status.get("modes") != "ready"
):
return None
matched_ids: set[str] = set()
raw_modes = catalog.get("modes", [])
if not isinstance(raw_modes, Sequence) or isinstance(
raw_modes,
(str, bytes, bytearray),
):
return None
for raw_mode in raw_modes:
if not isinstance(raw_mode, Mapping):
continue
candidates = [raw_mode.get("id"), raw_mode.get("name")]
aliases = raw_mode.get("aliases", [])
if isinstance(aliases, Sequence) and not isinstance(
aliases,
(str, bytes, bytearray),
):
candidates.extend(aliases)
if needle in {
"".join(str(candidate or "").casefold().split())
for candidate in candidates
if str(candidate or "").strip()
}:
matched_ids.add(str(raw_mode.get("id") or ""))
matched_ids.discard("")
return len(matched_ids)
def _vehicle_result_succeeded(result: Mapping[str, object]) -> bool:
"""Only a terminal, fully successful service result completes a tool call."""
return str(result.get("status") or "").lower() == "succeeded"
def _mode_profile_result_succeeded(result: Mapping[str, object]) -> bool:
"""Require a complete, non-physical mode preview before claiming success."""
if (
not _vehicle_result_succeeded(result)
or str(result.get("execution_mode") or "").lower() != "dry_run"
or result.get("hardware_execution_allowed") is not False
or result.get("orchestration_route") != "mode_profile_create_preview"
):
return False
draft = result.get("mode_profile_draft")
if not isinstance(draft, Mapping):
return False
operation_count = draft.get("operation_count")
if (
not isinstance(operation_count, int)
or isinstance(operation_count, bool)
or operation_count <= 0
):
return False
intent_ir = result.get("intent_ir")
spec = intent_ir.get("spec") if isinstance(intent_ir, Mapping) else None
actions = spec.get("actions") if isinstance(spec, Mapping) else None
if not isinstance(actions, Sequence) or isinstance(
actions,
(str, bytes, bytearray),
):
return False
if len(actions) != operation_count or not all(
isinstance(action, Mapping) for action in actions
):
return False
validation = result.get("validation", [])
return not (
isinstance(validation, Sequence)
and not isinstance(validation, (str, bytes, bytearray))
and any(
isinstance(issue, Mapping) and issue.get("severity") == "error"
for issue in validation
)
)
def _vehicle_verification_status(result: Mapping[str, object]) -> str:
"""Normalize the service's execution evidence without inventing feedback."""
@@ -231,7 +335,7 @@ def _authorize_vehicle_request(
):
return _VehicleAuthorization(None, "knowledge_or_non_vehicle_request")
if _MODE_PROFILE_CREATE_PATTERN.fullmatch(normalized):
if is_mode_profile_create_request(normalized):
# Named-mode creation is a bounded configuration operation. The
# vehicle service parses the same original sentence again and always
# validates the generated scene in dry-run before offering it to the
@@ -373,7 +477,27 @@ class CodexAssistantService:
self._fast_implicit_controls and authorization.reason in _FAST_COMFORT_REASONS
)
mode_profile_preview = authorization.reason == "explicit_mode_profile_create"
direct_vehicle_control = fast_comfort_control or mode_profile_preview
mode_parts = _mode_profile_create_parts(message) if mode_profile_preview else None
exact_mode_matches = (
_catalog_exact_mode_match_count(catalog, mode_parts[0])
if mode_parts
else None
)
direct_mode_profile_preview = bool(
mode_parts
and (
mode_parts[1]
# Ambiguous or unavailable mode catalogs fail closed in the
# local builder. Only a known-zero match is safe to hand to
# semantic scene composition.
or exact_mode_matches != 0
)
)
# Explicit device settings and repository-backed scenes remain on the
# zero-model-latency path. A genuinely new name-only scene needs Codex
# to compose a candidate plan from the live capability catalog; the
# vehicle service still limits it to dry-run and requires confirmation.
direct_vehicle_control = fast_comfort_control or direct_mode_profile_preview
route = (
"capability_catalog"
if authoritative_answer
@@ -385,7 +509,7 @@ class CodexAssistantService:
"n7web-capability-registry"
if authoritative_answer
else "n7web-mode-builder"
if mode_profile_preview
if direct_mode_profile_preview
else "n7web-control-router"
if fast_comfort_control
else "codex-app-server"
@@ -414,6 +538,8 @@ class CodexAssistantService:
"正在读取工程能力目录……"
if authoritative_answer
else "正在构建并校验功能模式……"
if direct_mode_profile_preview
else "Codex 正在理解场景并组合工程能力……"
if mode_profile_preview
else "正在快速处理座舱舒适需求……"
if fast_comfort_control
@@ -542,6 +668,8 @@ class CodexAssistantService:
safe_result = self._safe_vehicle_result(result)
action_state, successful, completion_text = _vehicle_action_state(result)
if mode_profile_preview:
successful = _mode_profile_result_succeeded(result)
action_state = "simulated" if successful else "failed"
completion_text = (
"功能模式预览已生成" if successful else "功能模式预览生成失败"
)
@@ -632,6 +760,14 @@ class CodexAssistantService:
reason = clean(failure.get("reason"))
next_step = clean(failure.get("next_step"))
if reason and next_step:
if (
INTERNAL_SCENE_BUILDER_MARKER in reason
or INTERNAL_SCENE_BUILDER_MARKER in next_step
):
return (
"未能根据场景名称生成通过工程能力校验的座舱动作",
"请稍后重试;若该场景尚未登记,可补充希望联动的设备状态",
)
return reason, next_step
unresolved = result.get("unresolved_clauses")
@@ -644,6 +780,8 @@ class CodexAssistantService:
clean(item, limit=120)
for item in unresolved[:3]
if clean(item, limit=120)
and INTERNAL_SCENE_BUILDER_MARKER
not in clean(item, limit=120)
)
)
if clauses:
@@ -716,6 +854,40 @@ class CodexAssistantService:
if isinstance(draft, Mapping)
else "新功能模式"
)
if isinstance(draft, Mapping) and draft.get("existing_mode_id"):
raw_operation_count = draft.get("operation_count")
operation_count = (
raw_operation_count
if isinstance(raw_operation_count, int)
and not isinstance(raw_operation_count, bool)
and raw_operation_count >= 0
else 0
)
if draft.get("generation_source") == "registered_mode_profile":
return (
f"工程中已有“{name}”,已按当前车型载入预览并验证"
f"{operation_count}项设备动作。可以先试运行,确认后更新现有模式。"
)
return (
f"已按你提供的设置为现有“{name}”生成预览并验证"
f"{operation_count}项设备动作。可以先试运行,确认后更新现有模式。"
)
if (
isinstance(draft, Mapping)
and draft.get("generation_source") == "codex_scene_composition"
):
raw_operation_count = draft.get("operation_count")
operation_count = (
raw_operation_count
if isinstance(raw_operation_count, int)
and not isinstance(raw_operation_count, bool)
and raw_operation_count > 0
else 0
)
return (
f"已理解“{name}”的场景意图,并从当前工程能力中组合、验证了"
f"{operation_count}项设备动作。确认预览后可保存为功能模式,也可以先试运行。"
)
return f"已为你生成“{name}”预览。确认动作后可保存为功能模式,也可以先试运行。"
@staticmethod
@@ -765,6 +937,9 @@ class CodexAssistantService:
query_capabilities,
)
vehicle_control_expected = tool_context.authorization.instruction is not None
mode_profile_expected = (
tool_context.authorization.reason == "explicit_mode_profile_create"
)
with self._tool_contexts_lock:
self._tool_contexts[request_token] = tool_context
@@ -938,8 +1113,6 @@ class CodexAssistantService:
raise CodexAssistantError(str(detail or f"Codex turn {status}"))
if not final_answer:
final_answer = streamed_answer.strip()
if not final_answer:
raise CodexAssistantError("Codex produced an empty response")
if vehicle_control_expected:
if not tool_context.called:
tool_context.action_succeeded = False
@@ -955,9 +1128,25 @@ class CodexAssistantService:
}
)
elif tool_context.action_succeeded is not True:
final_answer = "车辆控制未完成,受控执行链未返回成功结果。"
final_answer = (
self._mode_profile_preview_answer(
tool_context.action_result or {},
False,
)
if mode_profile_expected
else "车辆控制未完成,受控执行链未返回成功结果。"
)
elif mode_profile_expected:
final_answer = self._mode_profile_preview_answer(
tool_context.action_result or {},
True,
)
elif tool_context.action_state == "submitted":
final_answer = "控制指令已提交,正在等待车辆状态确认。"
elif not final_answer and tool_context.action_state == "simulated":
final_answer = "车辆控制模拟编排已完成。"
elif not final_answer and tool_context.action_state == "verified":
final_answer = "车辆控制已完成并通过状态确认。"
if (
emit(
{
@@ -969,6 +1158,8 @@ class CodexAssistantService:
is False
):
return {"cancelled": True, "answer": final_answer}
if not final_answer:
raise CodexAssistantError("Codex produced an empty response")
work_succeeded = (
tool_context.called and tool_context.action_succeeded is True
if vehicle_control_expected
@@ -980,7 +1171,11 @@ class CodexAssistantService:
"message_id": message_id,
"success": work_succeeded,
"display_text": (
"车辆控制模拟编排已完成"
"功能模式预览已生成"
if mode_profile_expected and work_succeeded
else "功能模式预览生成失败"
if mode_profile_expected
else "车辆控制模拟编排已完成"
if vehicle_control_expected
and work_succeeded
and tool_context.action_state == "simulated"
@@ -1106,6 +1301,15 @@ class CodexAssistantService:
secret=request_token,
)
action_state, successful, completion_text = _vehicle_action_state(result)
if context.authorization.reason == "explicit_mode_profile_create":
successful = _mode_profile_result_succeeded(result)
action_state = "simulated" if successful else "failed"
completion_text = (
"功能模式预览已生成"
if successful
else "功能模式预览生成失败"
)
context.action_result = result
context.action_succeeded = successful
context.action_state = action_state
context.events.put(
@@ -1158,7 +1362,17 @@ class CodexAssistantService:
if "decision_summary" in normalized_plan:
normalized_plan["decision_summary"] = ["Codex 结构化计划已提交应用安全链复核。"]
if "ambiguities" in normalized_plan:
normalized_plan["ambiguities"] = []
normalized_plan["ambiguities"] = (
[
{
"field": "assistant_plan",
"question": "Codex 候选计划包含未解决歧义。",
}
]
if isinstance(normalized_plan["ambiguities"], list)
and normalized_plan["ambiguities"]
else []
)
requests = normalized_plan.get("requests")
if isinstance(requests, list):
for request in requests:
@@ -1482,6 +1696,9 @@ class CodexAssistantService:
"vehicle_id",
"domains",
"operation_count",
"existing_mode_id",
"existing_mode_revision",
"generation_source",
)
if key in mode_profile_draft
}
@@ -1507,6 +1724,7 @@ class CodexAssistantService:
)
for item in unresolved_items
if isinstance(item, str)
and INTERNAL_SCENE_BUILDER_MARKER not in item
],
}
peripheral = result.get("peripheral")
+142 -26
View File
@@ -7,6 +7,7 @@ import json
import re
from dataclasses import dataclass
from pathlib import Path
from threading import RLock
from typing import Any
import yaml
@@ -419,26 +420,28 @@ 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._write_lock = RLock()
self.reload()
def reload(self) -> None:
profiles: list[ModeProfile] = []
if self.profile_root.exists():
for path in sorted(self.profile_root.rglob("*.yaml")):
if not path.is_file():
continue
if self.session_user_profiles and _is_user_profile_path(
path, self.profile_root
):
continue
profiles.append(ModeProfile.from_yaml(path, self.profile_root))
if self.session_user_profiles and self.user_root.exists():
profiles.extend(
ModeProfile.from_yaml(path, self.user_root)
for path in sorted(self.user_root.rglob("*.yaml"))
if path.is_file()
)
self._profiles = profiles
with self._write_lock:
profiles: list[ModeProfile] = []
if self.profile_root.exists():
for path in sorted(self.profile_root.rglob("*.yaml")):
if not path.is_file():
continue
if self.session_user_profiles and _is_user_profile_path(
path, self.profile_root
):
continue
profiles.append(ModeProfile.from_yaml(path, self.profile_root))
if self.session_user_profiles and self.user_root.exists():
profiles.extend(
ModeProfile.from_yaml(path, self.user_root)
for path in sorted(self.user_root.rglob("*.yaml"))
if path.is_file()
)
self._profiles = profiles
def profiles(self) -> list[ModeProfile]:
return list(self._profiles)
@@ -505,6 +508,61 @@ class ModeProfileRegistry:
matches.append(selected[profile.id])
return matches
def resolve_exact_name(
self,
value: str,
vehicle_id: str | None = None,
) -> ModeProfile | None:
"""Resolve one visible mode by an exact ID, name, or alias.
Scene creation must not use the substring semantics of :meth:`match_text`:
a name can contain another mode alias and accidentally combine two profiles.
Returning ``None`` for both no match and an ambiguous alias keeps that path
fail-closed so callers can choose an explicit fallback without combining modes.
"""
needle = _normalize_match_text(str(value).strip())
if not needle:
return None
matches = [
profile
for profile in self._profiles_for_vehicle(vehicle_id)
if needle
in {
_normalize_match_text(str(candidate))
for candidate in (profile.id, profile.name, *profile.aliases)
if str(candidate).strip()
}
]
return matches[0] if len(matches) == 1 else None
@staticmethod
def profile_revision(profile: ModeProfile) -> str:
"""Return a stable semantic revision for optimistic mode updates."""
document = {
"id": profile.id,
"name": profile.name,
"aliases": list(profile.aliases),
"description": profile.description,
"icon": profile.icon,
"scope": {
"common": profile.common,
"vehicle_ids": list(profile.vehicle_ids),
},
"operations": [operation.to_yaml() for operation in profile.operations],
"timeline": profile.timeline.to_yaml() if profile.timeline is not None else None,
"instruction": profile.instruction,
"command_flow": profile.command_flow,
}
canonical = json.dumps(
document,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
@staticmethod
def requests_for_profiles(profiles: list[ModeProfile]) -> list[dict[str, Any]]:
return [
@@ -526,6 +584,8 @@ class ModeProfileRegistry:
instruction: str = "",
command_flow: dict[str, Any] | None = None,
timeline: ModeTimeline | None = None,
expected_revision: str | None = None,
create_only: bool = False,
) -> ModeProfile:
clean_name = _clean_name(name)
canonical_operations = [
@@ -566,20 +626,64 @@ class ModeProfileRegistry:
if vehicle_id
else self.user_root / "common" / "user" / f"{profile_id}.yaml"
)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self.user_root)
with self._write_lock:
# Refresh inside the same critical section as the compare-and-swap.
# This makes concurrent assistant saves one-winner within the server
# process instead of letting two stale previews overwrite each other.
self.reload()
visible_profiles = self._profiles_for_vehicle(vehicle_id)
current = next(
(profile for profile in visible_profiles if profile.id == profile_id),
None,
)
normalized_name = _normalize_match_text(clean_name)
name_collision = any(
profile.id != profile_id
and normalized_name
in {
_normalize_match_text(str(candidate))
for candidate in (profile.id, profile.name, *profile.aliases)
if str(candidate).strip()
}
for profile in visible_profiles
)
if name_collision:
raise ValueError("该模式名称已被其他模式使用。")
if create_only and current is not None:
raise ValueError("该模式已由其他会话创建,请刷新后重试。")
if expected_revision is not None:
expected = str(expected_revision).strip()
if (
not expected
or current is None
or self.profile_revision(current) != expected
):
raise ValueError(
"功能模式已被其他会话更新,请重新生成预览后再保存。"
)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self.user_root)
def delete_user_mode(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> ModeProfile:
with self._write_lock:
return self._delete_user_mode_locked(mode_id, vehicle_id)
def _delete_user_mode_locked(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> ModeProfile:
clean_id = str(mode_id).strip()
if not clean_id:
@@ -623,6 +727,14 @@ class ModeProfileRegistry:
self,
profile: ModeProfile,
operations: list[dict[str, Any] | ModeOperation],
) -> ModeProfile:
with self._write_lock:
return self._update_mode_locked(profile, operations)
def _update_mode_locked(
self,
profile: ModeProfile,
operations: list[dict[str, Any] | ModeOperation],
) -> ModeProfile:
canonical_updates = _canonical_operations(operations)
if not canonical_updates:
@@ -642,6 +754,10 @@ class ModeProfileRegistry:
return ModeProfile.from_yaml(path, self._root_for_path(path))
def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile:
with self._write_lock:
return self._restore_initial_mode_locked(profile)
def _restore_initial_mode_locked(self, profile: ModeProfile) -> ModeProfile:
path = Path(profile.source)
backup_path = _initial_backup_path(path)
if not backup_path.is_file():
+410 -18
View File
@@ -8,6 +8,7 @@ import re
import sys
import time
import uuid
from collections import Counter
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
@@ -728,6 +729,90 @@ class SeatDemoService:
vehicle_id,
)
def trial_mode_profile_draft(
self,
name: str,
instruction: str,
source_request_id: str,
vehicle_id: str | None = None,
) -> dict[str, Any]:
"""Replay the server-owned dry-run evidence for one assistant draft."""
clean_name = str(name).strip()
clean_instruction = str(instruction).strip()
clean_request_id = str(source_request_id).strip()
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
if not clean_name or not clean_instruction:
raise ValueError("功能模式草稿名称和指令不能为空。")
if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean_request_id):
raise ValueError("功能模式草稿来源请求编号无效。")
result_path = self.artifact_root / clean_request_id / "result.json"
if not result_path.is_file():
raise ValueError("未找到功能模式草稿,请重新生成。")
try:
source_result = json.loads(result_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("功能模式草稿结果无法读取,请重新生成。") from exc
if not isinstance(source_result, dict) or source_result.get(
"request_id"
) != clean_request_id:
raise ValueError("功能模式草稿来源结果不完整,请重新生成。")
if (
source_result.get("status") != "succeeded"
or source_result.get("execution_mode") != "dry_run"
):
raise ValueError("功能模式草稿没有通过完整的模拟校验。")
draft = source_result.get("mode_profile_draft")
if not isinstance(draft, dict):
raise ValueError("来源请求不是可试运行的功能模式草稿。")
if str(draft.get("source_request_id") or "").strip() != clean_request_id:
raise ValueError("功能模式草稿来源请求编号不匹配,请重新生成。")
if str(draft.get("name") or "").strip() != clean_name:
raise ValueError("功能模式草稿名称不匹配,请重新生成。")
if str(draft.get("instruction") or "").strip() != clean_instruction:
raise ValueError("功能模式草稿指令不匹配,请重新生成。")
if str(draft.get("vehicle_id") or "").strip() != resolved_vehicle_id:
raise ValueError("功能模式草稿不属于当前车型。")
intent_ir = source_result.get("intent_ir")
spec = intent_ir.get("spec") if isinstance(intent_ir, dict) else None
actions = spec.get("actions") if isinstance(spec, dict) else None
function_configs = source_result.get("function_configs")
operation_count = draft.get("operation_count")
if (
not isinstance(actions, list)
or not actions
or not isinstance(function_configs, dict)
or not function_configs
or not isinstance(operation_count, int)
or isinstance(operation_count, bool)
or len(actions) != operation_count
):
raise ValueError("功能模式草稿动作证据不完整,请重新生成。")
validation = source_result.get("validation", [])
validation = validation if isinstance(validation, list) else []
if any(
isinstance(issue, dict) and issue.get("severity") == "error"
for issue in validation
):
raise ValueError("功能模式草稿未通过安全校验,请重新生成。")
return {
"request_id": clean_request_id,
"status": "succeeded",
"execution_mode": "dry_run",
"hardware_execution_allowed": False,
"orchestration_route": "mode_profile_draft_trial",
"mode_profile_draft": deepcopy(draft),
"intent_ir": {"spec": {"actions": deepcopy(actions)}},
"function_configs": deepcopy(function_configs),
"execution_report": deepcopy(source_result.get("execution_report")),
"validation": deepcopy(validation),
"trial_source": "server_validated_preview_artifact",
}
def _persist_mode_profile_card(
self,
mode_id: str | None,
@@ -797,11 +882,18 @@ class SeatDemoService:
if source_vehicle_id and source_vehicle_id != resolved_vehicle_id:
raise ValueError("控制指令流不属于当前车型,请重新生成。")
source_draft = source_result.get("mode_profile_draft")
draft_existing_mode_revision = ""
if isinstance(source_draft, dict):
draft_request_id = str(source_draft.get("source_request_id") or "").strip()
draft_vehicle_id = str(source_draft.get("vehicle_id") or "").strip()
draft_instruction = str(source_draft.get("instruction") or "").strip()
draft_name = str(source_draft.get("name") or "").strip()
draft_existing_mode_id = str(
source_draft.get("existing_mode_id") or ""
).strip()
draft_existing_mode_revision = str(
source_draft.get("existing_mode_revision") or ""
).strip()
if draft_request_id != clean_request_id:
raise ValueError("功能模式草稿与控制指令流不匹配,请重新生成。")
if draft_vehicle_id and draft_vehicle_id != resolved_vehicle_id:
@@ -810,6 +902,19 @@ class SeatDemoService:
raise ValueError("自然语言指令已修改,请重新生成模式预览。")
if draft_name and draft_name != clean_name:
raise ValueError("功能模式名称已修改,请重新生成模式预览。")
if clean_mode_id and not draft_existing_mode_id:
raise ValueError("新功能模式预览不能通过已有模式的更新接口保存。")
if draft_existing_mode_id and draft_existing_mode_id != clean_mode_id:
raise ValueError("该预览属于已有功能模式,请通过对应模式的更新接口保存。")
if draft_existing_mode_id and not draft_existing_mode_revision:
raise ValueError("功能模式预览版本过旧,请重新生成后再更新。")
if (
draft_existing_mode_revision
and profile is not None
and draft_existing_mode_revision
!= self.mode_profiles.profile_revision(profile)
):
raise ValueError("功能模式已被其他会话更新,请重新生成预览后再保存。")
recorded_instruction = str(source_result.get("utterance", "")).strip()
if not recorded_instruction:
source_spans = {
@@ -920,6 +1025,8 @@ class SeatDemoService:
instruction=clean_instruction,
command_flow=command_flow,
timeline=profile.timeline if profile is not None else None,
expected_revision=draft_existing_mode_revision or None,
create_only=not bool(clean_mode_id),
)
public_profile = next(
(
@@ -1422,15 +1529,7 @@ class SeatDemoService:
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
)
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
control_plane_request = any(
matcher(utterance) is not None
for matcher in (
self._parse_mode_profile_save_name,
self._parse_mode_profile_create_name,
self._parse_mode_profile_restore_name,
self._parse_mode_profile_update,
)
)
control_plane_request = self.is_mode_profile_configuration_request(utterance)
if control_plane_request:
result = self.process(
utterance,
@@ -2070,6 +2169,8 @@ class SeatDemoService:
direct_mode_profile_id: str | None = None,
speech_understanding: dict[str, Any] | None = None,
assistant_plan: dict[str, Any] | None = None,
allow_fuzzy_mode_profile_match: bool = True,
allow_scene_composition: bool = False,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -2151,7 +2252,11 @@ class SeatDemoService:
[direct_mode_profile]
if direct_mode_profile is not None
else []
if direct_can_batch is not None or speech_understanding is not None
if (
direct_can_batch is not None
or speech_understanding is not None
or not allow_fuzzy_mode_profile_match
)
else self.mode_profiles.match_text(
utterance,
vehicle_id=vehicle_profile.id,
@@ -2325,6 +2430,7 @@ class SeatDemoService:
local_preview=local_preview,
model_parse_source=model_parse_source,
assistant_plan=assistant_plan,
allow_scene_composition=allow_scene_composition,
)
dynamic_mode_stop = self._is_dynamic_mode_stop_request(utterance)
if dynamic_mode_stop:
@@ -3422,6 +3528,21 @@ class SeatDemoService:
)
return generic.group("name").strip() if generic is not None else None
@classmethod
def is_mode_profile_configuration_request(cls, utterance: str) -> bool:
"""Identify mode configuration text that must never reach live executors."""
clean_utterance = str(utterance).strip()
return any(
matcher(clean_utterance) is not None
for matcher in (
cls._parse_mode_profile_save_name,
cls._parse_mode_profile_create_name,
cls._parse_mode_profile_restore_name,
cls._parse_mode_profile_update,
)
)
@staticmethod
def _parse_mode_profile_create_name(utterance: str) -> str | None:
explicit = CREATE_MODE_PROFILE_NAME.search(utterance)
@@ -4378,6 +4499,109 @@ class SeatDemoService:
"issues": issues,
}
@staticmethod
def _scene_plan_signatures(
assistant_plan: dict[str, Any],
) -> Counter[tuple[str, str, str]]:
signatures: Counter[tuple[str, str, str]] = Counter()
requests = assistant_plan.get("requests", [])
if not isinstance(requests, list):
return signatures
for request in requests:
if not isinstance(request, dict):
continue
domain = str(request.get("domain") or "")
capability = str(request.get("capability") or "")
target = str(request.get("target_scope") or "")
if domain == "seat" and target == "front_right":
target = "passenger"
if domain and capability and target:
signatures[(domain, capability, target)] += 1
return signatures
@staticmethod
def _scene_action_signatures(
actions: list[dict[str, Any]],
) -> Counter[tuple[str, str, str]]:
signatures: Counter[tuple[str, str, str]] = Counter()
for action in actions:
domain = str(action.get("domain") or "")
capability = str(action.get("capability") or "")
raw_target = action.get("target")
target = (
str(raw_target.get("instance") or raw_target.get("zone") or "")
if isinstance(raw_target, dict)
else str(action.get("target_scope") or raw_target or "")
)
if domain == "seat" and target == "front_right":
target = "passenger"
if domain and capability and target:
signatures[(domain, capability, target)] += 1
return signatures
@staticmethod
def _scene_parameters_preserved(
assistant_plan: dict[str, Any],
actions: list[dict[str, Any]],
) -> bool:
actual_parameters: dict[tuple[str, str, str], dict[str, Any]] = {}
for action in actions:
domain = str(action.get("domain") or "")
capability = str(action.get("capability") or "")
raw_target = action.get("target")
target = (
str(raw_target.get("instance") or raw_target.get("zone") or "")
if isinstance(raw_target, dict)
else str(action.get("target_scope") or raw_target or "")
)
if domain == "seat" and target == "front_right":
target = "passenger"
parameters = action.get("parameters")
if domain and capability and target and isinstance(parameters, dict):
actual_parameters[(domain, capability, target)] = parameters
requests = assistant_plan.get("requests", [])
if not isinstance(requests, list):
return False
for request in requests:
if not isinstance(request, dict):
return False
domain = str(request.get("domain") or "")
capability = str(request.get("capability") or "")
target = str(request.get("target_scope") or "")
if domain == "seat" and target == "front_right":
target = "passenger"
parameters = request.get("parameters")
if not isinstance(parameters, list):
return False
expected = {
str(parameter.get("name") or ""): parameter.get("value")
for parameter in parameters
if isinstance(parameter, dict)
}
actual = actual_parameters.get((domain, capability, target))
if actual is None or any(actual.get(name) != value for name, value in expected.items()):
return False
return True
@staticmethod
def _scrub_failed_scene_preview(result: dict[str, Any]) -> None:
failed_intent = result.get("intent_ir")
failed_spec = (
failed_intent.get("spec") if isinstance(failed_intent, dict) else None
)
if isinstance(failed_spec, dict):
failed_spec["actions"] = []
failed_spec["requests"] = []
result["deferred_requests"] = []
result["model_requests"] = []
result["function_config"] = None
result["function_configs"] = {}
result["command_plan"] = None
result["command_plans"] = {}
result["scene_config"] = None
result["execution_report"] = None
def _process_mode_profile_create(
self,
utterance: str,
@@ -4405,9 +4629,25 @@ class SeatDemoService:
if create_match is not None
else ""
)
source_utterance = requested_instruction or (
f"进入{mode_name},请根据该场景名称使用当前可控原子能力构建并执行场景"
existing_profile = self.mode_profiles.resolve_exact_name(
mode_name,
vehicle_id=vehicle_profile.id,
)
source_profile = existing_profile if not requested_instruction else None
draft_mode_name = existing_profile.name if existing_profile is not None else mode_name
generation_source = (
"registered_mode_profile"
if source_profile is not None
else "codex_scene_composition"
if assistant_plan is not None and not requested_instruction
else "explicit_scene_requirements"
)
# A name-only request may already describe a repository-backed scene.
# Reuse that vehicle-scoped profile as trusted structured input instead
# of appending a model prompt to the user's utterance. The latter was
# being parsed as a second control clause on the assistant's local fast
# path, turning a valid nine-action mode into an incomplete draft.
source_utterance = requested_instruction or f"进入{mode_name}"
source_result = self._process_once(
source_utterance,
provider,
@@ -4417,7 +4657,20 @@ class SeatDemoService:
vehicle_profile.id,
simulation_fault,
control_transport=control_transport,
direct_mode_profile_id=(
source_profile.id if source_profile is not None else None
),
assistant_plan=assistant_plan,
# ``source_utterance`` is synthesized for a name-only, unknown
# scene. It must not fall back to substring matching (for example
# ``我的小憩模式`` -> ``小憩模式``), otherwise the exact resolver's
# ambiguity and collision checks can be bypassed.
allow_fuzzy_mode_profile_match=bool(requested_instruction),
allow_scene_composition=(
assistant_plan is not None
and source_profile is None
and not requested_instruction
),
)
draft_context = self._context_for_vehicle(
self.conversations.snapshot(conversation_id),
@@ -4434,9 +4687,12 @@ class SeatDemoService:
clause
for clause, _parsed, status in local_clause_results
if status != "complete"
and not self.mode_profiles.match_text(
clause,
vehicle_id=vehicle_profile.id,
and not (
(requested_instruction or source_profile is not None)
and self.mode_profiles.match_text(
clause,
vehicle_id=vehicle_profile.id,
)
)
]
if provider == "local" and assistant_plan is None and unresolved_draft_clauses:
@@ -4468,7 +4724,49 @@ class SeatDemoService:
]
self._persist(source_result)
return source_result
source_intent = source_result.get("intent_ir")
source_spec = (
source_intent.get("spec") if isinstance(source_intent, dict) else None
)
source_actions = (
source_spec.get("actions", []) if isinstance(source_spec, dict) else []
)
typed_source_actions = [
action for action in source_actions if isinstance(action, dict)
] if isinstance(source_actions, list) else []
if (
source_result.get("status") == "succeeded"
and generation_source == "codex_scene_composition"
and assistant_plan is not None
and (
self._scene_plan_signatures(assistant_plan)
!= self._scene_action_signatures(typed_source_actions)
or not self._scene_parameters_preserved(
assistant_plan,
typed_source_actions,
)
)
):
source_result["status"] = "validation_failed"
source_result["validation"] = [
*list(source_result.get("validation", [])),
{
"severity": "error",
"code": "MODE_PROFILE_SCENE_PLAN_INCOMPLETE",
"path": "intent_ir.spec.actions",
"message": (
"Codex 场景候选动作未全部进入当前车型的可复用执行链,"
"已拒绝生成部分功能模式。"
),
},
]
if source_result.get("status") != "succeeded":
if generation_source == "codex_scene_composition":
# Final fail-closed barrier for errors discovered after the
# plan guard (adapter, schema, compiler, or safety validation).
# A failed reusable scene must not expose a trial-able subset.
self._scrub_failed_scene_preview(source_result)
source_result["mode_creation_failure"] = {
"code": "MODE_PROFILE_ACTION_VALIDATION_FAILED",
"reason": "场景动作未通过车辆能力或安全校验",
@@ -4528,12 +4826,23 @@ class SeatDemoService:
)
source_request_id = str(source_result.get("request_id") or "")
source_result["mode_profile_draft"] = {
"name": mode_name,
"name": draft_mode_name,
"instruction": source_utterance,
"source_request_id": source_request_id,
"vehicle_id": vehicle_profile.id,
"domains": domains,
"operation_count": len(reusable_actions),
**(
{
"existing_mode_id": existing_profile.id,
"existing_mode_revision": (
self.mode_profiles.profile_revision(existing_profile)
),
}
if existing_profile is not None
else {}
),
"generation_source": generation_source,
}
source_result["mode_creation"] = {
"requested_utterance": utterance,
@@ -4542,12 +4851,34 @@ class SeatDemoService:
"source_status": source_result.get("status"),
"source_provider": source_result.get("provider_used"),
"operation_count": len(reusable_actions),
**(
{
"existing_mode_id": existing_profile.id,
"existing_mode_revision": (
self.mode_profiles.profile_revision(existing_profile)
),
}
if existing_profile is not None
else {}
),
"generation_source": generation_source,
}
source_result["orchestration_route"] = "mode_profile_create_preview"
source_result["decision_summary"] = [
f"已构建并验证“{mode_name}”场景。",
(
f"已从当前车型的工程模式目录载入并验证“{source_profile.name}”的"
f" {len(reusable_actions)} 项设备动作。"
if source_profile is not None
else f"Codex 已根据场景语义组合并验证“{draft_mode_name}”的工程能力。"
if generation_source == "codex_scene_composition"
else f"已构建并验证“{mode_name}”场景。"
),
*list(source_result.get("decision_summary", [])),
"请确认动作后保存为功能模式,或先进行一次模拟试运行。",
(
"可先进行一次模拟试运行,确认动作后更新现有功能模式。"
if existing_profile is not None
else "请确认动作后保存为功能模式,或先进行一次模拟试运行。"
),
]
# Overwrite the persisted source result with the draft metadata. The
# mode-card endpoint later re-reads this server-owned artifact and
@@ -6236,6 +6567,7 @@ class SeatDemoService:
local_preview: bool = False,
model_parse_source: str = "cloud_model",
assistant_plan: dict[str, Any] | None = None,
allow_scene_composition: bool = False,
) -> tuple[dict[str, Any], str, str | None]:
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
clause_results = self._local_clause_results(utterance, context)
@@ -6282,6 +6614,7 @@ class SeatDemoService:
if status != "complete"
],
trusted_local=trusted_local,
allow_scene_composition=allow_scene_composition,
)
model = self.model_plan_adapter.adapt(
guarded.plan,
@@ -6289,6 +6622,65 @@ class SeatDemoService:
vehicle_profile,
utterance=fallback_utterance,
)
if allow_scene_composition:
promoted_native: list[dict[str, Any]] = []
remaining_domain_actions: list[dict[str, Any]] = []
for action in model.get("domain_actions", []):
if self.native_can_runtime.supports(action):
promoted = dict(action)
promoted["execution_backend"] = "native_can"
promoted_native.append(promoted)
else:
remaining_domain_actions.append(action)
remaining_deferred: list[dict[str, Any]] = []
for deferred in model.get("deferred_requests", []):
candidate = {
"domain": str(deferred.get("domain") or ""),
"capability": str(deferred.get("capability") or ""),
"target": str(
deferred.get("target_scope")
or deferred.get("target")
or ""
),
"operation": str(deferred.get("desired_change") or "set"),
"parameters": dict(deferred.get("parameters") or {}),
"reason": "Codex 场景候选动作已通过工程能力契约校验。",
"execution_backend": "native_can",
}
if self.native_can_runtime.supports(candidate):
promoted_native.append(candidate)
else:
remaining_deferred.append(deferred)
model.setdefault("ambiguities", []).append(
{
"field": "assistant_plan",
"question": (
f"能力 {candidate['capability']} 无法进入当前车型的"
"可复用执行链。"
),
}
)
model["native_can_actions"] = [
*model.get("native_can_actions", []),
*promoted_native,
]
model["domain_actions"] = remaining_domain_actions
model["deferred_requests"] = remaining_deferred
if allow_scene_composition and model.get("ambiguities"):
# Adapter validation is the second half of the scene gate: it
# owns targets, required parameters, types and ranges. If any
# candidate fails there, discard every sibling action so the
# reusable draft can never become a silently partial scene.
for key in (
"actions",
"domain_actions",
"native_can_actions",
"deferred_requests",
"preset_actions",
"preset_events",
"model_requests",
):
model[key] = []
model = self._mark_control_parse_source(
model,
model_parse_source,
+42
View File
@@ -486,6 +486,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path.startswith("/api/self-repair/"):
self._handle_self_repair_post(path)
return
if path == "/api/mode-profile-drafts/trial":
self._handle_mode_profile_draft_trial()
return
if path == "/api/mode-profiles" or path.startswith("/api/mode-profiles/"):
self._handle_mode_profile_post(path)
return
@@ -1201,6 +1204,29 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"""Route IR-only or mixed IR/CAN requests without changing CAN parsing."""
utterance = str(common_arguments.get("utterance") or "").strip()
if self.service.is_mode_profile_configuration_request(utterance):
# Creating, saving, updating, or restoring a reusable mode is
# configuration, never a live-control command. Bypass the
# NFC/peripheral executor so a learned device name embedded in the
# mode sentence cannot claim it before SeatDemoService handles it.
mode_arguments = {
**common_arguments,
"execution_mode": "dry_run",
}
callback = (
on_commands_validated
if callable(on_commands_validated)
else None
)
if path == "/api/requests/preview":
return self.service.preview_native_can(
**mode_arguments,
on_commands_validated=callback,
)
return self.service.process(
**mode_arguments,
_on_commands_validated=callback,
)
catalog = self.extension.list_peripheral_capabilities()
nfc_snapshot = self.nfc.snapshot()
partition = partition_active_peripheral_intent(
@@ -2003,6 +2029,22 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.BAD_REQUEST,
)
def _handle_mode_profile_draft_trial(self) -> None:
try:
payload = self._read_json_body(max_bytes=16 * 1024)
result = self.service.trial_mode_profile_draft(
str(payload.get("name", "")),
str(payload.get("instruction", "")),
str(payload.get("source_request_id", "")),
str(payload.get("vehicle_id", "")) or None,
)
self._json(result, HTTPStatus.OK)
except (ValueError, json.JSONDecodeError) as exc:
self._json(
{"error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
def _saved_mode_infrared_entries(
self,
profile: object,