添加模式卡片下发
This commit is contained in:
@@ -5,6 +5,7 @@ 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
|
||||
@@ -525,6 +526,7 @@ 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._change_listeners: list[Callable[["ModeProfileRegistry"], None]] = []
|
||||
self._write_lock = RLock()
|
||||
self.reload()
|
||||
|
||||
@@ -547,6 +549,30 @@ class ModeProfileRegistry:
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Project persisted mode cards into the read-only N7 scene catalog contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cognitive_agent.application.mode_profile import (
|
||||
ModeOperation,
|
||||
ModeProfile,
|
||||
ModeProfileRegistry,
|
||||
)
|
||||
|
||||
N7_SCENE_SERVICE = "n7-scenes"
|
||||
N7_SCENE_SCHEMA_VERSION = 1
|
||||
DEFAULT_N7_SCENE_SERVER_ID = "n7-cognitive-agent"
|
||||
DEFAULT_N7_SCENE_SERVER_NAME = "N7 模式卡片目录"
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _background_time(profile: ModeProfile) -> str:
|
||||
"""Choose a compatible visual hint when the mode schema has no time field."""
|
||||
|
||||
text = f"{profile.name} {profile.description}"
|
||||
if any(keyword in text for keyword in ("晨", "早间", "清晨")):
|
||||
return "morning"
|
||||
if any(keyword in text for keyword in ("小憩", "午睡", "睡眠", "夜", "冥想")):
|
||||
return "evening"
|
||||
return "noon"
|
||||
|
||||
|
||||
def _action_type(capability: str) -> str:
|
||||
legacy_types = {
|
||||
"climate.set_power": "climate_power",
|
||||
"climate.set_temperature": "climate_temperature",
|
||||
"climate.set_fan_level": "climate_fan",
|
||||
}
|
||||
if capability in legacy_types:
|
||||
return legacy_types[capability]
|
||||
domain, separator, action = capability.partition(".")
|
||||
if not separator:
|
||||
return capability.replace(".", "_")
|
||||
if action.startswith("set_"):
|
||||
action = action.removeprefix("set_")
|
||||
return f"{domain}_{action}".replace(".", "_")
|
||||
|
||||
|
||||
def _legacy_zone(target_scope: str) -> str:
|
||||
return {
|
||||
"vehicle": "both",
|
||||
"all": "both",
|
||||
"passenger": "passenger",
|
||||
"front_right": "passenger",
|
||||
"driver": "driver",
|
||||
"front_left": "driver",
|
||||
}.get(target_scope, target_scope)
|
||||
|
||||
|
||||
def _scene_action(operation: ModeOperation) -> dict[str, Any]:
|
||||
"""Preserve the complete operation and add fields understood by the v1 demo."""
|
||||
|
||||
parameters = deepcopy(operation.parameters)
|
||||
action: dict[str, Any] = {
|
||||
"type": _action_type(operation.capability),
|
||||
"at_ms": 0,
|
||||
"domain": operation.domain,
|
||||
"capability": operation.capability,
|
||||
"target_scope": operation.target_scope,
|
||||
"operation": operation.operation,
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
# The original demo catalog flattened these three climate actions. Keep
|
||||
# those aliases so an existing v1 client can continue reading them, while
|
||||
# the generic fields above retain every seat/roof/light/custom operation.
|
||||
if operation.capability == "climate.set_power" and "state" in parameters:
|
||||
state = parameters["state"]
|
||||
action["enabled"] = (
|
||||
state
|
||||
if isinstance(state, bool)
|
||||
else str(state).strip().lower() in {"1", "true", "on", "enabled"}
|
||||
)
|
||||
elif (
|
||||
operation.capability == "climate.set_temperature"
|
||||
and "temperature_c" in parameters
|
||||
):
|
||||
action["zone"] = _legacy_zone(operation.target_scope)
|
||||
action["celsius"] = parameters["temperature_c"]
|
||||
elif operation.capability == "climate.set_fan_level" and "level" in parameters:
|
||||
action["level"] = parameters["level"]
|
||||
return action
|
||||
|
||||
|
||||
def _scene_from_profile(
|
||||
registry: ModeProfileRegistry,
|
||||
profile: ModeProfile,
|
||||
public_profile: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
scene = {
|
||||
"id": profile.id,
|
||||
"label": profile.name,
|
||||
"name": profile.name,
|
||||
"background_time": _background_time(profile),
|
||||
"description": profile.description,
|
||||
"icon": profile.icon,
|
||||
"aliases": list(profile.aliases),
|
||||
"origin": public_profile.get("origin", "preset"),
|
||||
"deletable": bool(public_profile.get("deletable", False)),
|
||||
"behavior": public_profile.get("behavior", "static"),
|
||||
"domains": list(public_profile.get("domains") or []),
|
||||
"operation_count": len(profile.operations),
|
||||
"scope": deepcopy(public_profile.get("scope") or {}),
|
||||
"profile_revision": registry.profile_revision(profile),
|
||||
"actions": [_scene_action(operation) for operation in profile.operations],
|
||||
}
|
||||
if public_profile.get("preset_override") is True:
|
||||
scene["preset_override"] = True
|
||||
if profile.timeline is not None:
|
||||
scene["timeline"] = profile.timeline.public_dict()
|
||||
return scene
|
||||
|
||||
|
||||
def build_mode_scene_catalog(
|
||||
registry: ModeProfileRegistry,
|
||||
vehicle_id: str,
|
||||
*,
|
||||
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
|
||||
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
|
||||
generated_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a lossless, client-safe projection of effective mode profiles."""
|
||||
|
||||
clean_vehicle_id = str(vehicle_id).strip()
|
||||
if not clean_vehicle_id:
|
||||
raise ValueError("scene catalog vehicle_id must not be empty")
|
||||
clean_server_id = str(server_id).strip()
|
||||
if not clean_server_id:
|
||||
raise ValueError("scene catalog server_id must not be empty")
|
||||
|
||||
public_profiles = registry.public_modes(clean_vehicle_id)
|
||||
scenes = [
|
||||
_scene_from_profile(
|
||||
registry,
|
||||
registry.get_profile(str(public_profile["id"]), clean_vehicle_id),
|
||||
public_profile,
|
||||
)
|
||||
for public_profile in public_profiles
|
||||
]
|
||||
revision_source = {
|
||||
"service": N7_SCENE_SERVICE,
|
||||
"schema_version": N7_SCENE_SCHEMA_VERSION,
|
||||
"server_id": clean_server_id,
|
||||
"name": str(name).strip() or DEFAULT_N7_SCENE_SERVER_NAME,
|
||||
"vehicle_id": clean_vehicle_id,
|
||||
"scenes": scenes,
|
||||
}
|
||||
canonical = json.dumps(
|
||||
revision_source,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return {
|
||||
**revision_source,
|
||||
"catalog_revision": hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16],
|
||||
"generated_at": generated_at or _utc_now(),
|
||||
"scene_count": len(scenes),
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
|
||||
class ModeSceneCatalogPublisher:
|
||||
"""Atomically maintain the JSON projection consumed by LAN clients."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
vehicle_id: str,
|
||||
*,
|
||||
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
|
||||
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.vehicle_id = str(vehicle_id).strip()
|
||||
self.server_id = str(server_id).strip() or DEFAULT_N7_SCENE_SERVER_ID
|
||||
self.name = str(name).strip() or DEFAULT_N7_SCENE_SERVER_NAME
|
||||
self._lock = threading.RLock()
|
||||
self._catalog: dict[str, Any] | None = None
|
||||
|
||||
def publish(self, registry: ModeProfileRegistry) -> dict[str, Any]:
|
||||
catalog = build_mode_scene_catalog(
|
||||
registry,
|
||||
self.vehicle_id,
|
||||
server_id=self.server_id,
|
||||
name=self.name,
|
||||
)
|
||||
with self._lock:
|
||||
on_disk = self._read_catalog()
|
||||
if (
|
||||
on_disk is not None
|
||||
and on_disk.get("catalog_revision") == catalog["catalog_revision"]
|
||||
and {
|
||||
key: value for key, value in on_disk.items() if key != "generated_at"
|
||||
}
|
||||
== {
|
||||
key: value for key, value in catalog.items() if key != "generated_at"
|
||||
}
|
||||
):
|
||||
self._catalog = on_disk
|
||||
return deepcopy(on_disk)
|
||||
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_name(
|
||||
f".{self.path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
)
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(self.path)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
self._catalog = catalog
|
||||
return deepcopy(catalog)
|
||||
|
||||
def snapshot(self) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
return deepcopy(self._catalog)
|
||||
|
||||
def _read_catalog(self) -> dict[str, Any] | None:
|
||||
if not self.path.is_file():
|
||||
return None
|
||||
try:
|
||||
document = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return document if isinstance(document, dict) else None
|
||||
@@ -51,6 +51,12 @@ from cognitive_agent.application.mode_profile import ( # noqa: E402
|
||||
ModeProfile,
|
||||
ModeProfileRegistry,
|
||||
)
|
||||
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
|
||||
DEFAULT_N7_SCENE_SERVER_ID,
|
||||
DEFAULT_N7_SCENE_SERVER_NAME,
|
||||
ModeSceneCatalogPublisher,
|
||||
build_mode_scene_catalog,
|
||||
)
|
||||
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
|
||||
is_mode_profile_query,
|
||||
normalize_mode_profile_name,
|
||||
@@ -414,6 +420,17 @@ class SeatDemoService:
|
||||
repository_root / "config" / "modes",
|
||||
user_root=self.mode_profile_user_root,
|
||||
)
|
||||
self._mode_scene_catalog_publisher: ModeSceneCatalogPublisher | None = None
|
||||
self._mode_scene_catalog_status: dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"state": "disabled",
|
||||
"path": None,
|
||||
"vehicle_id": self.vehicle_profiles.default().id,
|
||||
"server_id": DEFAULT_N7_SCENE_SERVER_ID,
|
||||
"catalog_revision": None,
|
||||
"scene_count": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
self.scene_templates = SceneTemplateRegistry(
|
||||
repository_root / "config" / "scene_templates"
|
||||
)
|
||||
@@ -432,6 +449,88 @@ class SeatDemoService:
|
||||
if hasattr(self, "conversations"):
|
||||
self.conversations.preset_root = self._artifact_root / "user-presets"
|
||||
|
||||
def configure_mode_scene_catalog(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
|
||||
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
|
||||
) -> dict[str, Any]:
|
||||
"""Enable the derived JSON catalog without changing YAML persistence."""
|
||||
|
||||
catalog_path = Path(path).expanduser()
|
||||
if not catalog_path.is_absolute():
|
||||
catalog_path = self.root / catalog_path
|
||||
vehicle_id = self.vehicle_profiles.default().id
|
||||
self._mode_scene_catalog_publisher = ModeSceneCatalogPublisher(
|
||||
catalog_path,
|
||||
vehicle_id,
|
||||
server_id=server_id,
|
||||
name=name,
|
||||
)
|
||||
self._mode_scene_catalog_status = {
|
||||
"enabled": True,
|
||||
"state": "initializing",
|
||||
"path": str(catalog_path),
|
||||
"vehicle_id": vehicle_id,
|
||||
"server_id": self._mode_scene_catalog_publisher.server_id,
|
||||
"catalog_revision": None,
|
||||
"scene_count": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
# The registry notifies after create, update, delete, restore, and any
|
||||
# explicit reload. The callback only updates a derived projection; YAML
|
||||
# remains the authoritative store used by every existing execution path.
|
||||
self.mode_profiles.add_change_listener(self._synchronize_mode_scene_catalog)
|
||||
return self.mode_scene_catalog_status()
|
||||
|
||||
def _synchronize_mode_scene_catalog(self, registry: ModeProfileRegistry) -> None:
|
||||
publisher = self._mode_scene_catalog_publisher
|
||||
if publisher is None:
|
||||
return
|
||||
try:
|
||||
catalog = publisher.publish(registry)
|
||||
except Exception as exc: # noqa: BLE001 - derived export must not block controls
|
||||
self._mode_scene_catalog_status.update(
|
||||
{
|
||||
"state": "error",
|
||||
"last_error": str(exc),
|
||||
}
|
||||
)
|
||||
return
|
||||
self._mode_scene_catalog_status.update(
|
||||
{
|
||||
"state": "ready",
|
||||
"catalog_revision": catalog["catalog_revision"],
|
||||
"scene_count": catalog["scene_count"],
|
||||
"last_error": None,
|
||||
}
|
||||
)
|
||||
|
||||
def mode_scene_catalog(self, vehicle_id: str | None = None) -> dict[str, Any]:
|
||||
"""Return the current read-only mode-card catalog for an N7 client."""
|
||||
|
||||
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
|
||||
publisher = self._mode_scene_catalog_publisher
|
||||
if publisher is not None and resolved_vehicle_id == publisher.vehicle_id:
|
||||
self._synchronize_mode_scene_catalog(self.mode_profiles)
|
||||
snapshot = publisher.snapshot()
|
||||
if snapshot is not None and self._mode_scene_catalog_status["state"] == "ready":
|
||||
return snapshot
|
||||
return build_mode_scene_catalog(
|
||||
self.mode_profiles,
|
||||
resolved_vehicle_id,
|
||||
server_id=(
|
||||
publisher.server_id
|
||||
if publisher is not None
|
||||
else DEFAULT_N7_SCENE_SERVER_ID
|
||||
),
|
||||
name=(publisher.name if publisher is not None else DEFAULT_N7_SCENE_SERVER_NAME),
|
||||
)
|
||||
|
||||
def mode_scene_catalog_status(self) -> dict[str, Any]:
|
||||
return deepcopy(self._mode_scene_catalog_status)
|
||||
|
||||
@property
|
||||
def driver_only_ch1_execution_active(self) -> bool:
|
||||
"""Return whether deployment policy authorizes only the CH1 vehicle path."""
|
||||
@@ -588,6 +687,7 @@ class SeatDemoService:
|
||||
"scene_templates": self.scene_templates.public_templates(),
|
||||
"mode_profile_storage": "persistent_yaml",
|
||||
"mode_profile_user_root": str(self.mode_profile_user_root),
|
||||
"mode_scene_catalog": self.mode_scene_catalog_status(),
|
||||
"preset_storage": "conversation_durable",
|
||||
"inferred_level_range": [1, 3],
|
||||
"motion_position_range_pct": [0, 100],
|
||||
|
||||
Reference in New Issue
Block a user