feat: add seat-scoped static and dynamic assistant modes

This commit is contained in:
vee
2026-09-12 14:56:30 +08:00
parent d09b5967f8
commit d8dad22ec0
60 changed files with 5847 additions and 149 deletions
+267 -17
View File
@@ -47,17 +47,27 @@ from cognitive_agent.application.direct_can_control import ( # noqa: E402
build_direct_can_intent,
)
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402
from cognitive_agent.application.mode_creation_seat_policy import ( # noqa: E402
ModeCreationSeatPolicyError,
creation_seat_scopes,
project_seed_operations,
project_seed_plan,
project_seed_schedule,
)
from cognitive_agent.application.mode_profile import ( # noqa: E402
ModeProfile,
ModeProfileRegistry,
)
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
is_mode_profile_query,
mode_creation_source_name,
normalize_mode_profile_name,
parse_mode_profile_create,
parse_mode_profile_delete,
parse_mode_profile_update,
)
from cognitive_agent.application.mode_schedule import ModeSchedule, ModeScheduleRunner # noqa: E402
from cognitive_agent.application.mode_schedule_service import ModeScheduleServiceMixin # noqa: E402
from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
APPLY_PRESET_CAPABILITY,
DYNAMIC_MODE_STOP_CAPABILITY,
@@ -90,6 +100,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402
NativeCanRuntime,
)
from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402
from cognitive_agent.application.schedule_intent import ( # noqa: E402
is_schedule_stop,
needs_mode_schedule,
)
from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402
from cognitive_agent.application.semantic_ir import ( # noqa: E402
apply_language_safety_guards,
@@ -331,8 +345,9 @@ DYNAMIC_MODE_WORDS = (
)
class SeatDemoService:
class SeatDemoService(ModeScheduleServiceMixin):
def __init__(self, repository_root: Path = REPOSITORY_ROOT):
self.mode_schedule_runner = ModeScheduleRunner()
self.root = repository_root
self.domain_root = repository_root / "domains" / "seat"
self.contracts = ContractValidator(repository_root)
@@ -663,7 +678,7 @@ class SeatDemoService:
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
if not profile.command_flow or not profile.command_flow.get("plans"):
if profile.schedule is None and (not profile.command_flow or not profile.command_flow.get("plans")):
raise ValueError("该模式尚未生成可直接执行的控制指令流。")
result = self.process(
profile.instruction or f"进入{profile.name}",
@@ -678,6 +693,8 @@ class SeatDemoService:
control_transport=control_transport,
_direct_mode_profile_id=profile.id,
)
if profile.schedule is not None:
return result
public_profile = profile.public_dict()
result["provider_used"] = "saved_mode_command_flow"
result["utterance"] = profile.name
@@ -796,7 +813,7 @@ class SeatDemoService:
):
raise ValueError("功能模式草稿未通过安全校验,请重新生成。")
return {
trial = {
"request_id": clean_request_id,
"status": "succeeded",
"execution_mode": "dry_run",
@@ -809,6 +826,14 @@ class SeatDemoService:
"validation": deepcopy(validation),
"trial_source": "server_validated_preview_artifact",
}
schedule = self._validated_schedule_artifact(source_result)
if schedule is not None:
trial["schedule"] = schedule.to_dict()
trial["schedule_execution"] = self.mode_schedule_runner.simulate(
schedule, lambda phase: {"status": "succeeded", "phase_id": phase.id,
"operations": deepcopy(list(phase.operations))},
)
return trial
def _persist_mode_profile_card(
self,
@@ -1007,6 +1032,11 @@ class SeatDemoService:
# generation workflow.
"execution_steps": [],
}
schedule = self._validated_schedule_artifact(source_result)
if profile is not None and profile.schedule is not None and schedule is None:
raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。")
if schedule is not None:
operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations]
saved = self.mode_profiles.save_mode(
clean_name,
operations,
@@ -1022,6 +1052,7 @@ class SeatDemoService:
instruction=clean_instruction,
command_flow=command_flow,
timeline=profile.timeline if profile is not None else None,
schedule=schedule,
expected_revision=draft_existing_mode_revision or None,
create_only=not bool(clean_mode_id),
)
@@ -1220,6 +1251,7 @@ class SeatDemoService:
self._vehicle_mqtt_transport.start()
def close(self) -> None:
self.mode_schedule_runner.close()
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.close()
self._vehicle_mqtt_transport.close()
@@ -2076,6 +2108,32 @@ class SeatDemoService:
or mode_profile_query
):
execution_mode = "dry_run"
schedule_requested = "模式" in utterance or _direct_mode_profile_id or is_schedule_stop(utterance)
schedule_vehicle_id = vehicle_id or (self.vehicle_profiles.default().id if schedule_requested else "")
if execution_mode not in {"dry_run", "simulated_hardware", "hardware"}:
raise ValueError(f"unsupported execution mode: {execution_mode}")
if is_schedule_stop(utterance):
return self._cancel_mode_schedule(schedule_vehicle_id, conversation_id, utterance, execution_mode)
schedule_profile = (self.mode_profiles.get_profile(_direct_mode_profile_id, schedule_vehicle_id)
if _direct_mode_profile_id else None)
if schedule_requested and schedule_profile is None and not (draft_update or mode_delete_name or mode_profile_query or parse_mode_profile_create(utterance)):
candidates = self.mode_profiles.match_text(utterance, vehicle_id=schedule_vehicle_id)
if len(candidates) == 1 and candidates[0].schedule is not None:
aliases = "|".join(re.escape(name) for name in (candidates[0].name, *candidates[0].aliases))
if re.fullmatch(
r"(?:(?:请|麻烦|帮我|帮忙|给我|我想|我要|想要)\s*){0,4}"
r"(?:(?:主驾|副驾|驾驶位|副驾驶|主驾驶)(?:座椅)?\s*)?"
r"(?:进入|启动|执行|开启|启用|运行|打开|调用|应用|使用|切换到)\s*"
rf"(?:{aliases})(?:吧|一下)?[。!!\s]*", utterance.strip(),
):
schedule_profile = candidates[0]
if schedule_profile is not None and schedule_profile.schedule is not None:
return self._run_mode_schedule(
schedule_profile, utterance=utterance, conversation_id=conversation_id,
execution_mode=execution_mode, approval_token=approval_token, vehicle_id=schedule_vehicle_id,
simulation_fault=simulation_fault, hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass, control_transport=control_transport,
)
if execution_mode != "hardware":
return self._process_once(
utterance,
@@ -2196,6 +2254,7 @@ class SeatDemoService:
allow_fuzzy_mode_profile_match: bool = True,
allow_scene_composition: bool = False,
scene_scope_utterance: str | None = None,
mode_creation_scopes: frozenset[str] | None = None,
mode_profile_draft_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
@@ -2454,6 +2513,8 @@ class SeatDemoService:
mode_requests = self.mode_profiles.requests_for_profiles(
[direct_mode_profile]
)
if mode_creation_scopes is not None:
mode_requests = project_seed_operations(mode_requests, mode_creation_scopes)
detected_domains = list(
dict.fromkeys(
str(item.get("domain") or "")
@@ -3687,6 +3748,16 @@ class SeatDemoService:
) -> dict[str, Any]:
if not matched_modes:
return parsed
if any(mode.schedule is not None for mode in matched_modes):
rejected = deepcopy(parsed)
for key in ("actions", "domain_actions", "native_can_actions", "preset_actions",
"preset_events", "deferred_requests", "model_requests"):
rejected[key] = []
rejected["ambiguities"] = [{
"field": "mode_profile",
"question": "动态模式必须通过时间轴执行,请独立输入“进入模式名称”;不会将各阶段压平执行。",
}]
return rejected
if self._contains_seat_preset_request(parsed):
return parsed
if len(matched_modes) > 1:
@@ -3767,6 +3838,19 @@ class SeatDemoService:
return parsed
mode_requests = self.mode_profiles.requests_for_profiles(matched_modes)
selected_seat_scope = self._explicit_mode_seat_selection(
matched_modes[0], utterance,
)
if selected_seat_scope is not None:
# A cabin profile can carry independent comfort operations for both
# front seats. An explicit single-seat invocation selects only that
# seat; it must not authorize its sibling or mutate the stored mode.
mode_requests = [
request for request in mode_requests
if request["domain"] != "seat"
or self._normalized_mode_seat_scope(request["target_scope"])
in selected_seat_scope
]
detected_domains = []
for request in mode_requests:
if request["domain"] not in detected_domains:
@@ -3787,6 +3871,24 @@ class SeatDemoService:
context,
vehicle_profile,
)
# Native-only comfort capabilities are represented as deferred items by
# the generic adapter. A verified stored mode must materialize them too;
# otherwise a successful named-mode run silently drops its driver seat.
mode_native_actions = []
for item in mode_parsed.get("deferred_requests", []):
if item.get("execution_backend") != "native_can":
continue
action = {
"domain": item["domain"],
"capability": item["capability"],
"target": item["target_scope"],
"parameters": deepcopy(item.get("parameters", {})),
"operation": item.get("desired_change", "set"),
"reason": item.get("reason", ""),
"execution_backend": "native_can",
}
if self.native_can_runtime.supports(action):
mode_native_actions.append(action)
parsed_has_explicit_request = bool(
parsed.get("actions")
or parsed.get("domain_actions")
@@ -3803,6 +3905,10 @@ class SeatDemoService:
mode_parsed.get("domain_actions", []),
parsed.get("domain_actions", []),
)
merged["native_can_actions"] = self._merge_control_actions(
mode_native_actions,
parsed.get("native_can_actions", []),
)
for key in (
"preset_actions",
"preset_events",
@@ -3834,13 +3940,35 @@ class SeatDemoService:
)
return merged
@staticmethod
@classmethod
def _explicit_mode_target_conflict(
cls,
profile: ModeProfile,
utterance: str,
) -> tuple[str, tuple[str, ...]] | None:
"""Reject a named mode when its persisted seat scope contradicts text."""
requested, normalized_scopes = cls._explicit_mode_seat_scopes(profile, utterance)
if requested is None or requested == normalized_scopes:
return None
if cls._explicit_mode_seat_selection(profile, utterance) is not None:
return None
return ",".join(sorted(requested)), tuple(sorted(normalized_scopes))
@staticmethod
def _normalized_mode_seat_scope(scope: str) -> str:
return (
"driver" if scope == "front_left" else
"passenger" if scope == "front_right" else
"rear" if scope in {"rear", "rear_left", "rear_right"} else scope
)
@classmethod
def _explicit_mode_seat_scopes(
cls,
profile: ModeProfile,
utterance: str,
) -> tuple[frozenset[str] | None, set[str]]:
aliases = (profile.id, profile.name, *profile.aliases)
mode_clause = next(
(
@@ -3856,23 +3984,34 @@ class SeatDemoService:
utterance,
)
requested = resolve_explicit_seat_scopes(
mode_clause,
mode_clause.replace("主副驾", "主驾和副驾"),
assume_seat_context=True,
)
if requested is None:
return None
normalized_scopes = {
"driver" if operation.target_scope == "front_left" else
"passenger" if operation.target_scope == "front_right" else
"rear" if operation.target_scope in {"rear", "rear_left", "rear_right"} else
operation.target_scope
cls._normalized_mode_seat_scope(operation.target_scope)
for operation in profile.operations
if operation.domain == "seat"
}
if requested == normalized_scopes:
return requested, normalized_scopes
@classmethod
def _explicit_mode_seat_selection(
cls,
profile: ModeProfile,
utterance: str,
) -> frozenset[str] | None:
"""Scope a dual-seat mode invocation, never mode creation or editing."""
requested, supported = cls._explicit_mode_seat_scopes(profile, utterance)
if (
requested is None
or len(requested) != 1
or supported != {"driver", "passenger"}
or not requested.issubset(supported)
or cls.is_mode_profile_configuration_request(utterance)
or not re.search(r"进入|开启|启动|执行|启用|运行|切换|打开|调用|应用|使用", utterance)
):
return None
return ",".join(sorted(requested)), tuple(sorted(normalized_scopes))
return requested
@staticmethod
def _apply_implicit_mode_target_policy(
@@ -4205,7 +4344,9 @@ class SeatDemoService:
for issue in validation
):
raise ValueError("待保存功能模式草稿没有通过完整安全校验。")
operations = self._preview_mode_operations(source_result)
schedule = self._validated_schedule_artifact(source_result)
operations = ([deepcopy(op) for phase in schedule.phases for op in phase.operations]
if schedule is not None else self._preview_mode_operations(source_result))
operation_count = draft.get("operation_count")
if (
not isinstance(operation_count, int)
@@ -4335,6 +4476,16 @@ class SeatDemoService:
vehicle_id=vehicle_profile.id,
)
)
if source_draft.get("schedule") is not None:
from cognitive_agent.application.mode_schedule_edit import revise_schedule
return revise_schedule(
self, ModeSchedule.from_dict(source_draft["schedule"]),
change_text=change_text, utterance=utterance, name=mode_name,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
context=context, control_transport=control_transport,
source_draft=source_draft,
)
base_operations = self._absolute_mode_operations(source_operations)
if not change_text:
raise ValueError("请说明要调整的设备和明确目标值。")
@@ -4684,6 +4835,14 @@ class SeatDemoService:
if profile is None:
self._persist(result)
return result
if profile.schedule is not None:
from cognitive_agent.application.mode_schedule_edit import update_saved_schedule
return update_saved_schedule(
self, profile, change_text=change_text, utterance=utterance,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
context=context, result=result,
)
if not change_text:
result["status"] = "needs_confirmation"
result["validation"] = [
@@ -5351,6 +5510,25 @@ class SeatDemoService:
result["scene_config"] = None
result["execution_report"] = None
def _mode_creation_seat_failure(
self, utterance: str, conversation_id: str, vehicle: VehicleProfile, reason: str,
) -> dict[str, Any]:
result = {
"request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
"utterance": utterance, "conversation_id": conversation_id,
"vehicle": vehicle.public_dict(), "status": "validation_failed",
"execution_mode": "dry_run", "hardware_execution_allowed": False,
"orchestration_route": "mode_profile_create_preview", "trace_events": [],
"validation": [{"severity": "error", "code": "MODE_SEAT_SCOPE_UNAVAILABLE",
"path": "mode_profile_draft.seat_scope", "message": reason}],
"mode_creation_failure": {"code": "MODE_SEAT_SCOPE_UNAVAILABLE", "reason": reason,
"next_step": "请选择该模式实际支持的座位,或改用当前座位支持的舒适功能"},
"decision_summary": [reason, "没有保存模式或下发控制。"],
}
self._scrub_failed_scene_preview(result)
self._persist(result)
return result
def _process_mode_profile_create(
self,
utterance: str,
@@ -5385,6 +5563,14 @@ class SeatDemoService:
):
raise ValueError("功能模式名称与创建请求不一致。")
requested_instruction = create_parts[1] if create_parts is not None else ""
selected_seats = creation_seat_scopes(utterance) if source_draft is None else None
if selected_seats is not None and "unsupported" in selected_seats and re.search(
r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩|零重力", utterance
):
return self._mode_creation_seat_failure(
utterance, conversation_id, vehicle_profile,
"当前模式只支持明确的主驾或副驾座椅,不会将未支持的座位替换为其他座位。",
)
raw_plan_metadata = (
assistant_plan.get("metadata")
if isinstance(assistant_plan, dict)
@@ -5411,6 +5597,7 @@ class SeatDemoService:
or requested_instruction
or assistant_plan.get("requests")
!= candidate_template.plan().get("requests")
or assistant_plan.get("schedule") != candidate_template.plan().get("schedule")
):
raise ValueError("内置常用模式模板已更新,请重新提交。")
scene_template = candidate_template
@@ -5441,6 +5628,10 @@ class SeatDemoService:
if not requested_instruction and source_draft is None
else None
)
if source_profile is None and source_draft is None and not requested_instruction and scene_template is None:
source_name = mode_creation_source_name(mode_name)
if source_name != mode_name:
source_profile = self.mode_profiles.resolve_exact_name(source_name, vehicle_id=vehicle_profile.id)
draft_mode_name = (
str(source_draft.get("name") or mode_name).strip()
if isinstance(source_draft, dict)
@@ -5456,7 +5647,7 @@ class SeatDemoService:
else "registered_mode_profile"
if source_profile is not None
else "codex_scene_composition"
if assistant_plan is not None and not requested_instruction
if assistant_plan is not None and (not requested_instruction or needs_mode_schedule(utterance))
else "explicit_scene_requirements"
)
# A name-only request may already describe a repository-backed scene.
@@ -5465,7 +5656,63 @@ class SeatDemoService:
# 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"进入{draft_mode_name}"
if requested_instruction and selected_seats is not None and len(selected_seats) == 1:
# Qualify only explicit seat-device clauses, never an HVAC/window
# zone or unrelated control. General one-shot controls keep their
# existing target policy; this is scoped to new mode construction.
label = "主驾" if selected_seats == {"driver"} else "副驾"
instruction_clauses = self._split_intent_clauses(requested_instruction)
qualified_clauses = [
f"{label}{clause}" if (
re.search(r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩", clause)
and resolve_explicit_seat_scopes(clause, assume_seat_context=True) is None
) else clause
for clause in instruction_clauses
]
# Preserve the user's punctuation and wording unless a seat really
# needs qualification; explicit instructions need no rewriting.
if qualified_clauses != instruction_clauses:
source_utterance = ",".join(qualified_clauses)
raw_schedule = (assistant_plan.get("schedule") if isinstance(assistant_plan, dict) else None)
if source_profile is not None and source_profile.schedule is not None:
raw_schedule = source_profile.schedule.to_dict()
if raw_schedule is not None:
if isinstance(assistant_plan, dict) and assistant_plan.get("ambiguities"):
raise ValueError("动态需求仍有未解决的问题,不能生成部分成功的时间轴,请补充或调整需求。")
if source_profile is None and source_draft is None and scene_template is None and not needs_mode_schedule(utterance):
raise ValueError("用户未请求动态或分阶段模式,不能擅自生成定时任务。")
schedule = ModeSchedule.from_dict(raw_schedule)
if selected_seats is not None and (source_profile is not None or scene_template is not None):
try:
schedule = project_seed_schedule(schedule, selected_seats)
except ModeCreationSeatPolicyError as exc:
return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
return self._preview_mode_schedule(
schedule, name=draft_mode_name, utterance=utterance,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
control_transport=control_transport, generation_source=generation_source,
source_profile=existing_profile, source_template=scene_template, source_draft=source_draft,
scene_scope_utterance="" if source_draft is not None else utterance,
)
if needs_mode_schedule(utterance) and scene_template is None and not (source_profile is not None and source_profile.timeline):
result = {
"request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
"utterance": utterance, "conversation_id": conversation_id,
"vehicle": vehicle_profile.public_dict(), "validation": [], "trace_events": [],
"status": "validation_failed", "execution_mode": "dry_run", "hardware_execution_allowed": False,
"orchestration_route": "mode_profile_create_preview",
}
result["mode_creation_failure"] = {
"code": "MODE_SCHEDULE_REQUIRED",
"reason": "该需求包含时间变化,但没有生成完整的分阶段时间轴",
"next_step": "请重试,或补充每个阶段的设备目标和持续时间;未保存或执行静态替代动作",
}
self._scrub_failed_scene_preview(result)
self._persist(result)
return result
try:
if scene_template is not None and selected_seats is not None:
assistant_plan = project_seed_plan(assistant_plan, selected_seats)
source_result = self._process_once(
source_utterance,
provider,
@@ -5478,6 +5725,7 @@ class SeatDemoService:
direct_mode_profile_id=(
source_profile.id if source_profile is not None else None
),
mode_creation_scopes=selected_seats,
assistant_plan=assistant_plan,
# ``source_utterance`` is synthesized for a name-only, unknown
# scene. It must not fall back to substring matching (for example
@@ -5493,8 +5741,10 @@ class SeatDemoService:
# already passed the ordinary text/target authorization path.
# Its old display name cannot veto an explicitly requested new
# seat. This override is internal, never accepted from payloads.
scene_scope_utterance="" if source_draft is not None else None,
scene_scope_utterance="" if source_draft is not None else utterance,
)
except ModeCreationSeatPolicyError as exc:
return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
except NativeCanCompileError:
# Repository modes and model-composed scenes share the same native
# compiler. A stale mode parameter must become a bounded failed