fix: compose named cockpit modes from capabilities
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user