Files
Cognitive_Agent/src/cognitive_agent/application/mode_profile.py
T

1106 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Repository-backed cabin mode profiles."""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from threading import RLock
from typing import Any
import yaml
from cognitive_agent.application.mode_schedule import ModeSchedule
_MATCH_FORMATTING = re.compile(r"[\s()()]+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
_MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_MODE_REFERENCE_VERBS = (
"切换进入",
"切换到",
"切换为",
"帮我进入",
"开始执行",
"进入",
"启动",
"开启",
"打开",
"启用",
"执行",
"运行",
"调用",
"应用",
"使用",
"恢复",
"切换",
"选择",
"退出",
"停止",
"关闭",
"取消",
"结束",
"来一个",
"来个",
)
_MODE_REFERENCE_TAILS = (
"吧",
"呀",
"啊",
"哦",
"呢",
"一下",
"看看",
"试试",
",",
",",
"。",
".",
"!",
"!",
"?",
"?",
";",
";",
":",
":",
"然后",
"接着",
"同时",
"并且",
)
def _normalize_match_text(value: str) -> str:
# Parentheses in display names are presentation-only. For example,
# ``女王副驾(行驶)`` is naturally spoken as ``女王副驾行驶模式``.
# Removing those separators lets the complete name match while the
# short-alias boundary guard below still rejects device compounds.
return _MATCH_FORMATTING.sub("", value.casefold())
def _mode_candidate_matches(utterance: str, candidate: object) -> bool:
"""Match a mode reference without treating device nouns as mode aliases.
Names that explicitly contain ``模式`` are unambiguous enough for normal
substring matching. Short aliases are different: a user-created
``阅读模式`` automatically has the alias ``阅读``, but ``打开阅读灯`` is a
light command, not a request to run that mode. A short alias therefore
requires an invocation verb and a bounded tail, or an explicit ``模式``
suffix. This retains natural requests such as ``启动阅读`` and
``进入阅读模式`` while failing closed for compound device names.
"""
needle = _normalize_match_text(str(candidate).strip())
if not needle:
return False
if "模式" in needle:
return needle in utterance
start = 0
while True:
index = utterance.find(needle, start)
if index < 0:
return False
tail = utterance[index + len(needle) :]
if tail.startswith("模式"):
return True
prefix = utterance[:index]
tail_is_bounded = not tail or any(
tail.startswith(boundary) for boundary in _MODE_REFERENCE_TAILS
)
if any(
prefix.endswith(verb) for verb in _MODE_REFERENCE_VERBS
) and tail_is_bounded:
return True
start = index + 1
def _clean_name(value: str) -> str:
cleaned = value.strip().strip(",。,.!!")
if not cleaned:
raise ValueError("mode name must not be empty")
if len(cleaned) > 64:
raise ValueError("mode name must be at most 64 characters")
return cleaned
def _clean_mode_id(value: str) -> str:
cleaned = str(value).strip()
if not _MODE_ID.fullmatch(cleaned):
raise ValueError(
"mode id must start with a lowercase letter or number and contain "
"only lowercase letters, numbers, underscores, or hyphens"
)
return cleaned
def _clean_instruction(value: str) -> str:
cleaned = str(value).strip()
if len(cleaned) > 1000:
raise ValueError("mode instruction must be at most 1000 characters")
return cleaned
def _clean_command_flow(value: Any) -> dict[str, Any] | None:
if value is None:
return None
if not isinstance(value, dict):
raise ValueError("mode command_flow must be an object")
try:
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True)
except (TypeError, ValueError) as exc:
raise ValueError("mode command_flow must be JSON serializable") from exc
if len(encoded.encode("utf-8")) > 1024 * 1024:
raise ValueError("mode command_flow must be at most 1 MiB")
return json.loads(encoded)
def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
values = [name, *(aliases or [])]
if name.endswith("模式") and len(name) > 2:
values.append(name.removesuffix("模式"))
else:
values.append(f"{name}模式")
normalized: set[str] = set()
unique = []
for value in values:
candidate = str(value).strip()
key = _normalize_match_text(candidate)
if not candidate or key in normalized:
continue
normalized.add(key)
unique.append(candidate)
return unique
def _slugify_mode_id(name: str) -> str:
ascii_slug = _NON_ASCII_SLUG.sub("-", name.casefold()).strip("-")
digest = hashlib.sha256(name.encode("utf-8")).hexdigest()[:10]
if ascii_slug:
return ascii_slug if len(ascii_slug) >= 3 else f"{ascii_slug}-{digest[:6]}"
return f"mode-{digest}"
@dataclass(frozen=True)
class ModeOperation:
domain: str
capability: str
target_scope: str
parameters: dict[str, Any]
operation: str = "set"
reason: str = ""
@classmethod
def from_dict(cls, document: dict[str, Any]) -> "ModeOperation":
parameters = document.get("parameters", {})
if not isinstance(parameters, dict):
raise ValueError("mode operation parameters must be an object")
return cls(
domain=str(document["domain"]),
capability=str(document["capability"]),
target_scope=str(document["target_scope"]),
operation=str(document.get("operation", "set")),
parameters=dict(parameters),
reason=str(document.get("reason", "")),
)
def to_request(self, mode_name: str) -> dict[str, Any]:
target_scope = self.target_scope
if self.domain == "seat" and target_scope == "front_right":
# Compiled seat operations use the physical zone name, while the
# model-plan adapter exposes the logical passenger target. Normalize
# persisted cards so a validated target is not queried again.
target_scope = "passenger"
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": target_scope,
"operation": self.operation,
"parameters": [
{"name": name, "value": value}
for name, value in self.parameters.items()
],
"reason": self.reason or f"Matched mode profile {mode_name}.",
}
def to_yaml(self) -> dict[str, Any]:
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"operation": self.operation,
"parameters": dict(self.parameters),
"reason": self.reason,
}
def public_dict(self) -> dict[str, Any]:
"""Return the executable fields that are safe to expose as UI metadata."""
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"operation": self.operation,
"parameters": dict(self.parameters),
}
@dataclass(frozen=True)
class ModeTimelinePhase:
id: str
name: str
duration_ms: int | None
transition: str
targets: dict[str, Any]
effects: dict[str, Any]
completion: str
timeout_ms: int | None
motion: dict[str, Any]
arrival_grace_ms: int = 0
@classmethod
def from_dict(cls, document: dict[str, Any]) -> "ModeTimelinePhase":
phase_id = str(document.get("id", "")).strip()
if not phase_id:
raise ValueError("mode timeline phase id must not be empty")
duration_ms = document.get("duration_ms")
completion = str(
document.get(
"completion",
"elapsed_time" if duration_ms is not None else "all_targets_reached",
)
).strip()
if completion not in {"elapsed_time", "all_targets_reached"}:
raise ValueError(
f"mode timeline phase {phase_id} completion must be elapsed_time or all_targets_reached"
)
if completion == "elapsed_time":
if (
not isinstance(duration_ms, int)
or isinstance(duration_ms, bool)
or duration_ms <= 0
):
raise ValueError(
f"mode timeline phase {phase_id} duration_ms must be a positive integer"
)
elif duration_ms is not None:
raise ValueError(
f"mode timeline phase {phase_id} duration_ms must be omitted for all_targets_reached"
)
timeout_ms = document.get("timeout_ms")
if timeout_ms is not None and (
not isinstance(timeout_ms, int)
or isinstance(timeout_ms, bool)
or timeout_ms <= 0
):
raise ValueError(
f"mode timeline phase {phase_id} timeout_ms must be a positive integer"
)
if completion == "all_targets_reached" and timeout_ms is None:
raise ValueError(
f"mode timeline phase {phase_id} timeout_ms is required for all_targets_reached"
)
arrival_grace_ms = document.get("arrival_grace_ms", 0)
if (
not isinstance(arrival_grace_ms, int)
or isinstance(arrival_grace_ms, bool)
or arrival_grace_ms < 0
):
raise ValueError(
f"mode timeline phase {phase_id} arrival_grace_ms must be a non-negative integer"
)
targets = document.get("targets", {})
effects = document.get("effects", {})
motion = document.get("motion", {})
if (
not isinstance(targets, dict)
or not isinstance(effects, dict)
or not isinstance(motion, dict)
):
raise ValueError(
f"mode timeline phase {phase_id} targets/effects/motion must be objects"
)
return cls(
id=phase_id,
name=str(document.get("name", phase_id)),
duration_ms=duration_ms,
transition=str(document.get("transition", "hold")),
targets=dict(targets),
effects=dict(effects),
completion=completion,
timeout_ms=timeout_ms,
motion=dict(motion),
arrival_grace_ms=arrival_grace_ms,
)
def to_yaml(self) -> dict[str, Any]:
result = {
"id": self.id,
"name": self.name,
"transition": self.transition,
"targets": dict(self.targets),
"effects": dict(self.effects),
"completion": self.completion,
}
if self.duration_ms is not None:
result["duration_ms"] = self.duration_ms
if self.timeout_ms is not None:
result["timeout_ms"] = self.timeout_ms
if self.motion:
result["motion"] = dict(self.motion)
if self.arrival_grace_ms:
result["arrival_grace_ms"] = self.arrival_grace_ms
return result
@dataclass(frozen=True)
class ModeTimeline:
repeat: str
cycle_duration_ms: int | None
entry: dict[str, Any]
phases: tuple[ModeTimelinePhase, ...]
max_runtime_ms: int | None = None
@classmethod
def from_dict(cls, document: dict[str, Any]) -> "ModeTimeline":
repeat = str(document.get("repeat", "")).strip()
if repeat not in {"once", "infinite", "until_stopped_or_timeout"}:
raise ValueError(
"mode timeline repeat must be once, infinite, or until_stopped_or_timeout"
)
max_runtime_ms = document.get("max_runtime_ms")
if max_runtime_ms is not None and (
not isinstance(max_runtime_ms, int)
or isinstance(max_runtime_ms, bool)
or max_runtime_ms <= 0
):
raise ValueError("mode timeline max_runtime_ms must be a positive integer")
if repeat == "until_stopped_or_timeout" and max_runtime_ms is None:
raise ValueError(
"mode timeline max_runtime_ms is required for until_stopped_or_timeout"
)
duration_ms = document.get("cycle_duration_ms")
if duration_ms is not None and (
not isinstance(duration_ms, int)
or isinstance(duration_ms, bool)
or duration_ms <= 0
):
raise ValueError("mode timeline cycle_duration_ms must be positive")
raw_entry = document.get("entry", {})
raw_phases = document.get("phases", [])
if not isinstance(raw_entry, dict):
raise ValueError("mode timeline entry must be an object")
if not isinstance(raw_phases, list) or not raw_phases:
raise ValueError("mode timeline must contain at least one phase")
phases = tuple(ModeTimelinePhase.from_dict(item) for item in raw_phases)
phase_ids = [phase.id for phase in phases]
if len(set(phase_ids)) != len(phase_ids):
raise ValueError("mode timeline phase ids must be unique")
timed_phases = all(phase.duration_ms is not None for phase in phases)
if timed_phases:
calculated_duration = sum(
int(phase.duration_ms or 0) for phase in phases
)
if calculated_duration != duration_ms:
raise ValueError(
"mode timeline cycle_duration_ms does not equal the sum of phases: "
f"declared={duration_ms}, calculated={calculated_duration}"
)
elif duration_ms is not None:
raise ValueError(
"mode timeline cycle_duration_ms must be omitted when a phase completes by target arrival"
)
return cls(
repeat=repeat,
cycle_duration_ms=duration_ms,
entry=dict(raw_entry),
phases=phases,
max_runtime_ms=max_runtime_ms,
)
def to_yaml(self) -> dict[str, Any]:
result = {
"repeat": self.repeat,
"entry": dict(self.entry),
"phases": [phase.to_yaml() for phase in self.phases],
}
if self.cycle_duration_ms is not None:
result["cycle_duration_ms"] = self.cycle_duration_ms
if self.max_runtime_ms is not None:
result["max_runtime_ms"] = self.max_runtime_ms
return result
def public_dict(self) -> dict[str, Any]:
return self.to_yaml()
@dataclass(frozen=True)
class ModeProfile:
id: str
name: str
aliases: tuple[str, ...]
description: str
icon: str
operations: tuple[ModeOperation, ...]
source: str
common: bool = True
vehicle_ids: tuple[str, ...] = ()
timeline: ModeTimeline | None = None
user_defined: bool = False
instruction: str = ""
command_flow: dict[str, Any] | None = None
schedule: ModeSchedule | None = None
@classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict):
raise ValueError(f"{path} must contain a YAML object")
operations = document.get("operations", [])
if not isinstance(operations, list) or not operations:
raise ValueError(f"{path} must contain at least one operation")
name = _clean_name(str(document["name"]))
common, vehicle_ids = _mode_scope(document, path, profile_root)
aliases = _mode_aliases(
name,
[str(alias) for alias in document.get("aliases", [])],
)
raw_timeline = document.get("timeline")
if raw_timeline is not None and not isinstance(raw_timeline, dict):
raise ValueError(f"{path} timeline must be an object")
return cls(
id=str(document["id"]),
name=name,
aliases=tuple(aliases),
description=str(document.get("description", "")),
icon=str(document.get("icon", "bookmark")),
operations=tuple(ModeOperation.from_dict(item) for item in operations),
source=str(path),
common=common,
vehicle_ids=vehicle_ids,
timeline=(
ModeTimeline.from_dict(raw_timeline)
if isinstance(raw_timeline, dict)
else None
),
user_defined=_is_user_profile_path(path, profile_root),
instruction=_clean_instruction(document.get("instruction", "")),
command_flow=_clean_command_flow(document.get("command_flow")),
schedule=(ModeSchedule.from_dict(document["schedule"])
if document.get("schedule") is not None else None),
)
def public_dict(self) -> dict[str, Any]:
is_cyclic = self.schedule is not None or self.timeline is not None or any(
operation.capability == "seat.start_dynamic_mode"
for operation in self.operations
)
result = {
"id": self.id,
"name": self.name,
"aliases": list(self.aliases),
"description": self.description,
"icon": self.icon,
"domains": sorted({operation.domain for operation in self.operations}),
"operation_count": len(self.operations),
"operations": [operation.public_dict() for operation in self.operations],
"scope": {
"common": self.common,
"vehicle_ids": list(self.vehicle_ids),
},
"behavior": "cyclic" if is_cyclic else "static",
"origin": "user" if self.user_defined else "preset",
"deletable": self.user_defined,
"instruction": self.instruction,
"command_flow": self.command_flow,
}
if self.timeline is not None:
result["timeline"] = self.timeline.public_dict()
if self.schedule is not None:
result["schedule"] = self.schedule.to_dict()
result["operation_count"] = self.schedule.operation_count
return result
class ModeProfileRegistry:
"""Load preset profiles and optionally keep user profiles in a session overlay."""
def __init__(self, profile_root: Path, user_root: Path | None = None):
self.profile_root = Path(profile_root)
self.user_root = Path(user_root) if user_root is not None else self.profile_root
self.session_user_profiles = user_root is not None
self._profiles: list[ModeProfile] = []
self._change_listeners: list[Callable[["ModeProfileRegistry"], None]] = []
self._write_lock = RLock()
self.reload()
def reload(self) -> None:
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
self._notify_change_listeners()
def add_change_listener(
self,
listener: Callable[["ModeProfileRegistry"], None],
*,
notify: bool = True,
) -> None:
"""Run ``listener`` after every successful registry reload.
Mode profiles remain YAML-backed. Listeners are intended for derived,
read-only projections such as the LAN scene catalog; they do not replace
or alter the existing persistence path.
"""
with self._write_lock:
if listener not in self._change_listeners:
self._change_listeners.append(listener)
if notify:
listener(self)
def _notify_change_listeners(self) -> None:
for listener in tuple(self._change_listeners):
listener(self)
def profiles(self) -> list[ModeProfile]:
return list(self._profiles)
def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]:
profiles = self._profiles_for_vehicle(vehicle_id)
profiles.sort(key=lambda profile: 0 if profile.user_defined else 1)
preset_ids = {
profile.id
for profile in self._visible_candidates_for_vehicle(vehicle_id)
if not profile.user_defined
}
public = []
for profile in profiles:
item = profile.public_dict()
if profile.user_defined and profile.id in preset_ids:
item["origin"] = "customized"
item["preset_override"] = True
public.append(item)
return public
def get_profile(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> ModeProfile:
"""Resolve the effective preset or user override by its stable ID."""
clean_mode_id = str(mode_id).strip()
profile = next(
(
item
for item in self._profiles_for_vehicle(vehicle_id)
if item.id == clean_mode_id
),
None,
)
if profile is None:
raise KeyError(clean_mode_id)
return profile
def match_text(
self,
utterance: str,
vehicle_id: str | None = None,
) -> list[ModeProfile]:
haystack = _normalize_match_text(utterance)
selected = {
profile.id: profile
for profile in self._profiles_for_vehicle(vehicle_id)
}
matches = []
matched_ids: set[str] = set()
for profile in self._visible_candidates_for_vehicle(vehicle_id):
candidates = [profile.id, profile.name, *profile.aliases]
if any(
candidate and _mode_candidate_matches(haystack, candidate)
for candidate in candidates
):
if profile.id in matched_ids:
continue
matched_ids.add(profile.id)
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,
"schedule": profile.schedule.to_dict() if profile.schedule 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 [
operation.to_request(profile.name)
for profile in profiles
for operation in profile.operations
]
def save_mode(
self,
name: str,
operations: list[dict[str, Any] | ModeOperation],
mode_id: str | None = None,
aliases: list[str] | None = None,
vehicle_id: str | None = None,
*,
description: str | None = None,
icon: str = "bookmark",
instruction: str = "",
command_flow: dict[str, Any] | None = None,
timeline: ModeTimeline | None = None,
schedule: ModeSchedule | None = None,
expected_revision: str | None = None,
create_only: bool = False,
) -> ModeProfile:
clean_name = _clean_name(name)
canonical_operations = [
operation
if isinstance(operation, ModeOperation)
else ModeOperation.from_dict(operation)
for operation in operations
]
if not canonical_operations:
raise ValueError("mode profile must contain at least one operation")
profile_id = _clean_mode_id(mode_id or _slugify_mode_id(clean_name))
clean_instruction = _clean_instruction(instruction)
clean_command_flow = _clean_command_flow(command_flow)
scope = (
{"vehicle_ids": [vehicle_id]}
if vehicle_id
else {"common": True, "vehicle_ids": []}
)
document = {
"id": profile_id,
"name": clean_name,
"aliases": _mode_aliases(clean_name, aliases),
"description": str(description or "用户固化的长期模式配置。")[:500],
"icon": str(icon or "bookmark")[:64],
"scope": scope,
"operations": [
operation.to_yaml() for operation in canonical_operations
],
}
if clean_instruction:
document["instruction"] = clean_instruction
if clean_command_flow is not None:
document["command_flow"] = clean_command_flow
if timeline is not None:
document["timeline"] = timeline.to_yaml()
if schedule is not None:
document["schedule"] = schedule.to_dict()
path = (
self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id
else self.user_root / "common" / "user" / f"{profile_id}.yaml"
)
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:
raise ValueError("mode id must not be empty")
profile = next(
(
item
for item in self._profiles_for_vehicle(vehicle_id)
if item.id == clean_id
),
None,
)
if profile is None:
raise KeyError(clean_id)
if not profile.user_defined:
raise PermissionError("preset mode profiles cannot be deleted")
path = Path(profile.source).resolve()
relative_parts = None
for root in {self.profile_root.resolve(), self.user_root.resolve()}:
try:
relative_parts = path.relative_to(root).parts
break
except ValueError:
continue
if relative_parts is None:
raise PermissionError("mode profile is outside the configured registry")
if "user" not in relative_parts or path.suffix.lower() != ".yaml":
raise PermissionError("only user-defined mode profiles can be deleted")
if not path.is_file():
raise KeyError(clean_id)
path.unlink()
backup_path = _initial_backup_path(path)
if backup_path.is_file():
backup_path.unlink()
self.reload()
return profile
def update_mode(
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:
raise ValueError("mode profile update must contain at least one operation")
merged_operations = _merge_operations(profile.operations, canonical_updates)
if self.session_user_profiles and not profile.user_defined:
# Runtime edits must never rewrite repository-owned presets. Save
# a same-id user overlay instead; normal registry ranking makes it
# the effective profile, while deleting the overlay reveals the
# untouched preset again.
updated = self.save_mode(
profile.name,
merged_operations,
mode_id=profile.id,
aliases=list(profile.aliases),
vehicle_id=(
profile.vehicle_ids[0]
if len(profile.vehicle_ids) == 1
else None
),
description=profile.description,
icon=profile.icon,
instruction=profile.instruction,
command_flow=profile.command_flow,
timeline=profile.timeline,
)
backup_path = _initial_backup_path(Path(updated.source))
if not backup_path.exists():
_replace_yaml(
backup_path,
_mode_profile_document(profile, list(profile.operations)),
)
return updated
path = Path(profile.source)
if not path.is_file():
raise ValueError(f"mode profile source does not exist: {path}")
backup_path = _initial_backup_path(path)
if not backup_path.exists():
backup_path.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
document = _mode_profile_document(profile, merged_operations)
_replace_yaml(path, document)
self.reload()
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():
raise ValueError(f"no initial mode backup found: {backup_path}")
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8")
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self._root_for_path(path))
def _root_for_path(self, path: Path) -> Path:
resolved = path.resolve()
for root in (self.user_root.resolve(), self.profile_root.resolve()):
try:
resolved.relative_to(root)
return root
except ValueError:
continue
return self.profile_root
def _profiles_for_vehicle(self, vehicle_id: str | None) -> list[ModeProfile]:
selected: dict[str, ModeProfile] = {}
order: list[str] = []
for profile in self._visible_candidates_for_vehicle(vehicle_id):
if profile.id not in selected:
order.append(profile.id)
selected[profile.id] = profile
continue
current = selected[profile.id]
if _profile_rank(profile, vehicle_id) >= _profile_rank(current, vehicle_id):
selected[profile.id] = profile
return [selected[profile_id] for profile_id in order]
def _visible_candidates_for_vehicle(
self,
vehicle_id: str | None,
) -> list[ModeProfile]:
return [
profile
for profile in self._profiles
if _is_visible_for_vehicle(profile, vehicle_id)
]
def _canonical_operations(
operations: list[dict[str, Any] | ModeOperation],
) -> list[ModeOperation]:
return [
operation
if isinstance(operation, ModeOperation)
else ModeOperation.from_dict(operation)
for operation in operations
]
def _operation_signature(operation: ModeOperation) -> tuple[str, str, str]:
return (operation.domain, operation.capability, operation.target_scope)
def _merge_operations(
existing: tuple[ModeOperation, ...],
updates: list[ModeOperation],
) -> list[ModeOperation]:
merged = list(existing)
positions = {
_operation_signature(operation): index
for index, operation in enumerate(merged)
}
for operation in updates:
signature = _operation_signature(operation)
index = positions.get(signature)
if index is None:
positions[signature] = len(merged)
merged.append(operation)
continue
merged[index] = operation
return merged
def _initial_backup_path(path: Path) -> Path:
return path.with_suffix(path.suffix + ".initial.bak")
def _mode_profile_document(
profile: ModeProfile,
operations: list[ModeOperation],
) -> dict[str, Any]:
scope: dict[str, Any] = {
"common": profile.common,
"vehicle_ids": list(profile.vehicle_ids),
}
document = {
"id": profile.id,
"name": profile.name,
"aliases": list(profile.aliases),
"description": profile.description,
"icon": profile.icon,
"scope": scope,
"operations": [operation.to_yaml() for operation in operations],
}
if profile.timeline is not None:
document["timeline"] = profile.timeline.to_yaml()
if profile.schedule is not None:
document["schedule"] = profile.schedule.to_dict()
if profile.instruction:
document["instruction"] = profile.instruction
if profile.command_flow is not None:
document["command_flow"] = profile.command_flow
return document
def _replace_yaml(path: Path, document: dict[str, Any]) -> None:
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)
def _mode_scope(
document: dict[str, Any],
path: Path,
profile_root: Path | None,
) -> tuple[bool, tuple[str, ...]]:
scope = document.get("scope")
if isinstance(scope, dict):
vehicle_ids = tuple(
str(vehicle_id).strip()
for vehicle_id in scope.get("vehicle_ids", [])
if str(vehicle_id).strip()
)
common = bool(scope.get("common", not vehicle_ids))
return common, vehicle_ids
if profile_root is not None:
try:
parts = path.relative_to(profile_root).parts
except ValueError:
parts = ()
if len(parts) >= 3 and parts[0] == "vehicles":
return False, (parts[1],)
if parts and parts[0] == "common":
return True, ()
return True, ()
def _is_user_profile_path(path: Path, profile_root: Path | None) -> bool:
if profile_root is None:
return path.parent.name == "user"
try:
relative_parts = path.resolve().relative_to(profile_root.resolve()).parts
except ValueError:
return False
return "user" in relative_parts
def _is_visible_for_vehicle(
profile: ModeProfile,
vehicle_id: str | None,
) -> bool:
if vehicle_id is None:
return True
return profile.common or vehicle_id in profile.vehicle_ids
def _profile_rank(profile: ModeProfile, vehicle_id: str | None) -> int:
if vehicle_id is not None and vehicle_id in profile.vehicle_ids:
return 2
if profile.common:
return 1
return 0