添加模式卡片下发

This commit is contained in:
Miayui
2026-09-09 14:37:25 +08:00
parent 76057be044
commit 51ca12b624
10 changed files with 1169 additions and 0 deletions
@@ -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],