backend
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user