"""Read-only, repository-backed templates for common cabin scenes. Scene templates are deliberately separate from saved mode profiles. Matching a template produces a new, unsaved draft; it never makes the template executable as a persisted mode and never lets a later edit overwrite repository YAML. """ from __future__ import annotations import hashlib import json import math import re from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import Any import yaml from cognitive_agent.application.assistant_plan_guard import ( MAX_PLAN_PARAMETERS, MAX_PLAN_REQUESTS, SCENE_COMPOSABLE_CAPABILITIES, ) from cognitive_agent.application.mode_profile import ModeOperation _TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _SURROUNDING_PUNCTUATION = " \t\r\n,。,.!!??;;::'\"‘’“”《》<>【】[]()()" _DOCUMENT_FIELDS = frozenset( {"id", "name", "aliases", "description", "icon", "operations"} ) def normalize_scene_template_name(value: object) -> str: """Normalize an exact user-facing name without enabling fuzzy matching.""" return "".join(str(value or "").strip(_SURROUNDING_PUNCTUATION).casefold().split()) def _clean_text(value: object, *, limit: int, field: str) -> str: text = " ".join(str(value or "").replace("\x00", " ").split()) if not text: raise ValueError(f"scene template {field} must not be empty") if len(text) > limit: raise ValueError(f"scene template {field} must be at most {limit} characters") return text def _json_scalar(value: object) -> bool: return ( isinstance(value, (str, bool, int)) or (isinstance(value, float) and math.isfinite(value)) ) @dataclass(frozen=True) class SceneTemplate: """One immutable common-scene seed made only from absolute set actions.""" id: str name: str aliases: tuple[str, ...] description: str icon: str operations: tuple[ModeOperation, ...] source: str revision: str @classmethod def from_yaml(cls, path: Path) -> "SceneTemplate": document = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(document, Mapping): raise ValueError(f"{path} must contain a YAML object") unknown_fields = set(document) - _DOCUMENT_FIELDS if unknown_fields: raise ValueError( f"{path} contains unsupported fields: {', '.join(sorted(unknown_fields))}" ) template_id = str(document.get("id") or "").strip() if not _TEMPLATE_ID.fullmatch(template_id): raise ValueError(f"{path} contains an invalid scene template id") name = _clean_text(document.get("name"), limit=64, field="name") raw_aliases = document.get("aliases", []) if not isinstance(raw_aliases, list): raise ValueError(f"{path} aliases must be an array") if any(not isinstance(raw_alias, str) for raw_alias in raw_aliases): raise ValueError(f"{path} aliases must contain only strings") alias_values = [name, *raw_aliases] if name.endswith("模式") and len(name) > 2: alias_values.append(name.removesuffix("模式")) aliases: list[str] = [] alias_keys: set[str] = set() for raw_alias in alias_values: alias = _clean_text(raw_alias, limit=64, field="alias") key = normalize_scene_template_name(alias) if not key or key in alias_keys: continue alias_keys.add(key) aliases.append(alias) raw_operations = document.get("operations") if not isinstance(raw_operations, list) or not raw_operations: raise ValueError(f"{path} must contain at least one operation") if len(raw_operations) > MAX_PLAN_REQUESTS: raise ValueError( f"{path} contains more than {MAX_PLAN_REQUESTS} scene operations" ) operations: list[ModeOperation] = [] operation_keys: set[tuple[str, str, str]] = set() for index, raw_operation in enumerate(raw_operations): if not isinstance(raw_operation, dict): raise ValueError(f"{path} operation {index} must be an object") operation = ModeOperation.from_dict(raw_operation) if operation.operation != "set": raise ValueError(f"{path} operation {index} must use absolute set") if not operation.domain or not operation.capability or not operation.target_scope: raise ValueError(f"{path} operation {index} is incomplete") if operation.capability not in SCENE_COMPOSABLE_CAPABILITIES: raise ValueError( f"{path} operation {index} uses a non-composable capability" ) if operation.domain != operation.capability.partition(".")[0]: raise ValueError( f"{path} operation {index} has a capability/domain mismatch" ) if not operation.parameters or len(operation.parameters) > MAX_PLAN_PARAMETERS: raise ValueError(f"{path} operation {index} has invalid parameters") if any( not isinstance(parameter_name, str) or not parameter_name.strip() or not _json_scalar(parameter_value) for parameter_name, parameter_value in operation.parameters.items() ): raise ValueError(f"{path} operation {index} has non-scalar parameters") key = (operation.domain, operation.capability, operation.target_scope) if key in operation_keys: raise ValueError(f"{path} repeats capability and target {key!r}") operation_keys.add(key) operations.append(operation) semantic_document = { "id": template_id, "name": name, "aliases": sorted(aliases, key=normalize_scene_template_name), "description": str(document.get("description") or ""), "icon": str(document.get("icon") or "sparkles"), "operations": sorted( (operation.to_yaml() for operation in operations), key=lambda item: ( str(item.get("domain") or ""), str(item.get("capability") or ""), str(item.get("target_scope") or ""), ), ), } canonical = json.dumps( semantic_document, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) revision = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] return cls( id=template_id, name=name, aliases=tuple(aliases), description=str(document.get("description") or "")[:500], icon=str(document.get("icon") or "sparkles")[:64], operations=tuple(operations), source=str(path), revision=revision, ) def plan(self) -> dict[str, Any]: requests = [] for operation in self.operations: request = operation.to_request(self.name) request["duration_ms"] = None requests.append(request) return { "detected_domains": list( dict.fromkeys(operation.domain for operation in self.operations) ), "requests": requests, "ambiguities": [], "decision_summary": [ f"已命中内置常用模式模板“{self.name}”,等待工程安全链复核。" ], "confidence": 1.0, "metadata": { "source": "scene_template", "template_id": self.id, "template_revision": self.revision, }, } def public_dict(self, *, include_plan: bool = False) -> dict[str, Any]: result: dict[str, Any] = { "id": self.id, "name": self.name, "aliases": list(self.aliases), "description": self.description, "icon": self.icon, "domains": list(dict.fromkeys(item.domain for item in self.operations)), "operation_count": len(self.operations), "template_revision": self.revision, "origin": "builtin_scene_template", "read_only": True, } if include_plan: result["plan"] = self.plan() return result class SceneTemplateRegistry: """Load and exactly resolve the current project's common scene templates.""" def __init__(self, template_root: Path): self.template_root = Path(template_root) self._templates: tuple[SceneTemplate, ...] = () self._by_id: dict[str, SceneTemplate] = {} self._by_name: dict[str, SceneTemplate] = {} self.reload() def reload(self) -> None: templates = tuple( SceneTemplate.from_yaml(path) for path in sorted(self.template_root.rglob("*.yaml")) if path.is_file() ) if self.template_root.exists() else () by_id: dict[str, SceneTemplate] = {} by_name: dict[str, SceneTemplate] = {} for template in templates: if template.id in by_id: raise ValueError(f"duplicate scene template id: {template.id}") by_id[template.id] = template for value in (template.id, template.name, *template.aliases): key = normalize_scene_template_name(value) current = by_name.get(key) if current is not None and current.id != template.id: raise ValueError( f"ambiguous scene template name {value!r}: " f"{current.id}, {template.id}" ) by_name[key] = template self._templates = templates self._by_id = by_id self._by_name = by_name def templates(self) -> list[SceneTemplate]: return list(self._templates) def public_templates(self, *, include_plan: bool = False) -> list[dict[str, Any]]: return [template.public_dict(include_plan=include_plan) for template in self._templates] def resolve_exact_name(self, value: object) -> SceneTemplate | None: key = normalize_scene_template_name(value) return self._by_name.get(key) if key else None def get_template(self, template_id: str) -> SceneTemplate: try: return self._by_id[str(template_id).strip()] except KeyError as exc: raise KeyError(str(template_id).strip()) from exc __all__ = [ "SceneTemplate", "SceneTemplateRegistry", "normalize_scene_template_name", ]