This commit is contained in:
orin
2026-07-30 14:09:04 +08:00
parent 25b24ac4cd
commit 49f5c49c53
35 changed files with 4078 additions and 48 deletions
@@ -0,0 +1,435 @@
"""Repository-backed cabin mode profiles."""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
_WHITESPACE = re.compile(r"\s+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
def _normalize_match_text(value: str) -> str:
return _WHITESPACE.sub("", value.casefold())
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 _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]:
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.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,
}
@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, ...] = ()
@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", [])],
)
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,
)
def public_dict(self) -> dict[str, Any]:
return {
"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),
"scope": {
"common": self.common,
"vehicle_ids": list(self.vehicle_ids),
},
}
class ModeProfileRegistry:
"""Load, match, and persist long-term cabin mode profiles."""
def __init__(self, profile_root: Path):
self.profile_root = Path(profile_root)
self._profiles: list[ModeProfile] = []
self.reload()
def reload(self) -> None:
if not self.profile_root.exists():
self._profiles = []
return
self._profiles = [
ModeProfile.from_yaml(path, self.profile_root)
for path in sorted(self.profile_root.rglob("*.yaml"))
if path.is_file()
]
def profiles(self) -> list[ModeProfile]:
return list(self._profiles)
def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]:
return [
profile.public_dict()
for profile in self._profiles_for_vehicle(vehicle_id)
]
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 _normalize_match_text(str(candidate)) in haystack
for candidate in candidates
):
if profile.id in matched_ids:
continue
matched_ids.add(profile.id)
matches.append(selected[profile.id])
return matches
@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,
) -> 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 = mode_id or _slugify_mode_id(clean_name)
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": "用户固化的长期模式配置。",
"icon": "bookmark",
"scope": scope,
"operations": [
operation.to_yaml() for operation in canonical_operations
],
}
path = (
self.profile_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id
else self.profile_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.profile_root)
def update_mode(
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")
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")
merged_operations = _merge_operations(profile.operations, canonical_updates)
document = _mode_profile_document(profile, merged_operations)
_replace_yaml(path, document)
self.reload()
return ModeProfile.from_yaml(path, self.profile_root)
def restore_initial_mode(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.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),
}
return {
"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],
}
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_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