feat: integrate production cockpit AI assistant
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user