|
|
@@ -0,0 +1,969 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from collections.abc import Callable
|
|
|
+from dataclasses import dataclass
|
|
|
+from datetime import datetime
|
|
|
+from typing import Any
|
|
|
+from uuid import UUID
|
|
|
+
|
|
|
+from app.core.common.identifiers import new_governance_uid
|
|
|
+from app.core.common.timezone_utils import now_china
|
|
|
+from app.core.data_research.errors import (
|
|
|
+ DeviceSemanticConflict,
|
|
|
+ DeviceSemanticForbidden,
|
|
|
+ DeviceSemanticInvalid,
|
|
|
+ DeviceSemanticNotFound,
|
|
|
+)
|
|
|
+
|
|
|
+DEVICE_ONTOLOGY_CODE = "DEVICE_SEMANTIC"
|
|
|
+REQUIRED_CLASS_UIDS = (
|
|
|
+ "action",
|
|
|
+ "alarm",
|
|
|
+ "cause",
|
|
|
+ "component",
|
|
|
+ "device",
|
|
|
+ "fault",
|
|
|
+ "location",
|
|
|
+ "maintenance_record",
|
|
|
+ "measurement_point",
|
|
|
+ "organization",
|
|
|
+ "person",
|
|
|
+)
|
|
|
+REQUIRED_RELATION_UIDS = (
|
|
|
+ "alarm.classified_as",
|
|
|
+ "alarm.raised_by",
|
|
|
+ "component.part_of",
|
|
|
+ "device.located_at",
|
|
|
+ "device.owned_by",
|
|
|
+ "device.responsible_to",
|
|
|
+ "fault.caused_by",
|
|
|
+ "maintenance_record.maintains",
|
|
|
+ "maintenance_record.uses_action",
|
|
|
+ "measurement_point.monitors",
|
|
|
+)
|
|
|
+REQUIRED_MAPPING_UIDS = (
|
|
|
+ "device.platform_uid",
|
|
|
+ "device.source_code",
|
|
|
+)
|
|
|
+CODE_TYPES = frozenset({"fault", "cause", "action"})
|
|
|
+CODE_STATUSES = frozenset(
|
|
|
+ {"draft", "in_review", "published", "rejected", "retired"}
|
|
|
+)
|
|
|
+SUGGESTION_SOURCES = frozenset({"manual", "rule", "ai"})
|
|
|
+SEMANTIC_SECRET_KEYS = frozenset(
|
|
|
+ {
|
|
|
+ "password",
|
|
|
+ "passwd",
|
|
|
+ "credential",
|
|
|
+ "credentials",
|
|
|
+ "token",
|
|
|
+ "api_key",
|
|
|
+ "authorization",
|
|
|
+ "connection_string",
|
|
|
+ "connection_url",
|
|
|
+ "encrypted_payload",
|
|
|
+ "phone",
|
|
|
+ "mobile",
|
|
|
+ "email",
|
|
|
+ "contact",
|
|
|
+ "contact_phone",
|
|
|
+ "contact_email",
|
|
|
+ "id_number",
|
|
|
+ "personal_address",
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceSemanticProfile:
|
|
|
+ ready_to_publish: bool
|
|
|
+ class_count: int
|
|
|
+ relation_count: int
|
|
|
+ mapping_count: int
|
|
|
+ missing_classes: tuple[str, ...]
|
|
|
+ missing_relations: tuple[str, ...]
|
|
|
+ missing_mappings: tuple[str, ...]
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceSemanticBootstrapResult:
|
|
|
+ ontology: Any
|
|
|
+ version: Any
|
|
|
+ profile: DeviceSemanticProfile
|
|
|
+ created: bool
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceSemanticCodeRecord:
|
|
|
+ uid: str
|
|
|
+ ontology_uid: str
|
|
|
+ code_type: str
|
|
|
+ canonical_code: str
|
|
|
+ canonical_name: str
|
|
|
+ definition: str | None
|
|
|
+ status: str
|
|
|
+ current_version: int
|
|
|
+ source_mappings: tuple[dict[str, str], ...]
|
|
|
+ evidence_uids: tuple[str, ...]
|
|
|
+ suggestion_source: str
|
|
|
+ confidence: float | None
|
|
|
+ created_by: str | None
|
|
|
+ updated_by: str | None
|
|
|
+ created_at: datetime | None = None
|
|
|
+ updated_at: datetime | None = None
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceSemanticCodeVersionRecord:
|
|
|
+ uid: str
|
|
|
+ code_uid: str
|
|
|
+ version: int
|
|
|
+ snapshot: dict[str, Any]
|
|
|
+ created_by: str | None
|
|
|
+ created_at: datetime | None = None
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceSemanticCodeReviewRecord:
|
|
|
+ uid: str
|
|
|
+ code_uid: str
|
|
|
+ version: int
|
|
|
+ decision: str
|
|
|
+ reason: str
|
|
|
+ actor_uid: str
|
|
|
+ created_at: datetime | None = None
|
|
|
+
|
|
|
+
|
|
|
+def _uuid(value: Any, field: str) -> str:
|
|
|
+ try:
|
|
|
+ return str(UUID(str(value)))
|
|
|
+ except (AttributeError, TypeError, ValueError) as exc:
|
|
|
+ raise DeviceSemanticInvalid(f"{field} must be a UUID") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _required_text(
|
|
|
+ payload: dict[str, Any],
|
|
|
+ field: str,
|
|
|
+ *,
|
|
|
+ maximum: int,
|
|
|
+) -> str:
|
|
|
+ value = str(payload.get(field) or "").strip()
|
|
|
+ if not value:
|
|
|
+ raise DeviceSemanticInvalid(f"{field} is required")
|
|
|
+ if len(value) > maximum:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ f"{field} exceeds {maximum} characters"
|
|
|
+ )
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _optional_text(
|
|
|
+ payload: dict[str, Any],
|
|
|
+ field: str,
|
|
|
+ *,
|
|
|
+ maximum: int,
|
|
|
+) -> str | None:
|
|
|
+ value = str(payload.get(field) or "").strip()
|
|
|
+ if not value:
|
|
|
+ return None
|
|
|
+ if len(value) > maximum:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ f"{field} exceeds {maximum} characters"
|
|
|
+ )
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _reject_semantic_secrets(value: Any) -> None:
|
|
|
+ if isinstance(value, dict):
|
|
|
+ for key, item in value.items():
|
|
|
+ if str(key).strip().casefold() in SEMANTIC_SECRET_KEYS:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "secret values are not allowed in semantic codes"
|
|
|
+ )
|
|
|
+ _reject_semantic_secrets(item)
|
|
|
+ elif isinstance(value, (list, tuple)):
|
|
|
+ for item in value:
|
|
|
+ _reject_semantic_secrets(item)
|
|
|
+
|
|
|
+
|
|
|
+def _source_mappings(value: Any) -> tuple[dict[str, str], ...]:
|
|
|
+ raw_items = value or ()
|
|
|
+ if not isinstance(raw_items, (list, tuple)):
|
|
|
+ raise DeviceSemanticInvalid("source_mappings must be a list")
|
|
|
+ if len(raw_items) > 100:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "source_mappings exceed the 100 item boundary"
|
|
|
+ )
|
|
|
+ _reject_semantic_secrets(raw_items)
|
|
|
+ items = []
|
|
|
+ identities = set()
|
|
|
+ for raw in raw_items:
|
|
|
+ if not isinstance(raw, dict):
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "source mapping must be an object"
|
|
|
+ )
|
|
|
+ item = {
|
|
|
+ "source_uid": _uuid(raw.get("source_uid"), "source_uid"),
|
|
|
+ "source_code": _required_text(
|
|
|
+ raw,
|
|
|
+ "source_code",
|
|
|
+ maximum=300,
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ identity = (item["source_uid"], item["source_code"])
|
|
|
+ if identity in identities:
|
|
|
+ raise DeviceSemanticInvalid("duplicate source mapping")
|
|
|
+ identities.add(identity)
|
|
|
+ items.append(item)
|
|
|
+ return tuple(items)
|
|
|
+
|
|
|
+
|
|
|
+def _evidence(value: Any) -> tuple[str, ...]:
|
|
|
+ raw_items = value or ()
|
|
|
+ if not isinstance(raw_items, (list, tuple)):
|
|
|
+ raise DeviceSemanticInvalid("evidence_uids must be a list")
|
|
|
+ if len(raw_items) > 100:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "evidence_uids exceed the 100 item boundary"
|
|
|
+ )
|
|
|
+ items = tuple(str(item).strip() for item in raw_items)
|
|
|
+ if any(not item or len(item) > 120 for item in items):
|
|
|
+ raise DeviceSemanticInvalid("evidence uid is invalid")
|
|
|
+ if len(set(items)) != len(items):
|
|
|
+ raise DeviceSemanticInvalid("duplicate evidence uid")
|
|
|
+ return items
|
|
|
+
|
|
|
+
|
|
|
+def _proposal(payload: dict[str, Any]):
|
|
|
+ suggestion_source = str(
|
|
|
+ payload.get("suggestion_source") or "manual"
|
|
|
+ ).strip()
|
|
|
+ if suggestion_source not in SUGGESTION_SOURCES:
|
|
|
+ raise DeviceSemanticInvalid("suggestion_source is not supported")
|
|
|
+ evidence_uids = _evidence(payload.get("evidence_uids"))
|
|
|
+ if suggestion_source != "manual" and not evidence_uids:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "rule and AI suggestions require evidence"
|
|
|
+ )
|
|
|
+ raw_confidence = payload.get("confidence")
|
|
|
+ confidence = None
|
|
|
+ if raw_confidence not in (None, ""):
|
|
|
+ try:
|
|
|
+ confidence = float(raw_confidence)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "confidence must be numeric"
|
|
|
+ ) from exc
|
|
|
+ if confidence < 0 or confidence > 1:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "confidence must be between 0 and 1"
|
|
|
+ )
|
|
|
+ if suggestion_source == "ai" and confidence is None:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "AI suggestion confidence is required"
|
|
|
+ )
|
|
|
+ return suggestion_source, evidence_uids, confidence
|
|
|
+
|
|
|
+
|
|
|
+def _semantic_snapshot(
|
|
|
+ payload: dict[str, Any],
|
|
|
+ *,
|
|
|
+ existing: DeviceSemanticCodeRecord | None = None,
|
|
|
+):
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise DeviceSemanticInvalid("payload must be an object")
|
|
|
+ _reject_semantic_secrets(payload)
|
|
|
+ if existing is None:
|
|
|
+ ontology_uid = _uuid(
|
|
|
+ payload.get("ontology_uid"),
|
|
|
+ "ontology_uid",
|
|
|
+ )
|
|
|
+ code_type = str(payload.get("code_type") or "").strip()
|
|
|
+ if code_type not in CODE_TYPES:
|
|
|
+ raise DeviceSemanticInvalid("code_type is not supported")
|
|
|
+ canonical_code = _required_text(
|
|
|
+ payload,
|
|
|
+ "canonical_code",
|
|
|
+ maximum=120,
|
|
|
+ ).upper()
|
|
|
+ else:
|
|
|
+ ontology_uid = existing.ontology_uid
|
|
|
+ code_type = existing.code_type
|
|
|
+ canonical_code = existing.canonical_code
|
|
|
+ effective_payload = dict(payload)
|
|
|
+ if existing is not None:
|
|
|
+ effective_payload.setdefault(
|
|
|
+ "canonical_name",
|
|
|
+ existing.canonical_name,
|
|
|
+ )
|
|
|
+ effective_payload.setdefault("definition", existing.definition)
|
|
|
+ suggestion_source, evidence_uids, confidence = _proposal(
|
|
|
+ {
|
|
|
+ "suggestion_source": payload.get(
|
|
|
+ "suggestion_source",
|
|
|
+ existing.suggestion_source if existing else "manual",
|
|
|
+ ),
|
|
|
+ "evidence_uids": payload.get(
|
|
|
+ "evidence_uids",
|
|
|
+ list(existing.evidence_uids) if existing else [],
|
|
|
+ ),
|
|
|
+ "confidence": payload.get(
|
|
|
+ "confidence",
|
|
|
+ existing.confidence if existing else None,
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ source_mappings = _source_mappings(
|
|
|
+ payload.get(
|
|
|
+ "source_mappings",
|
|
|
+ list(existing.source_mappings) if existing else [],
|
|
|
+ )
|
|
|
+ )
|
|
|
+ snapshot = {
|
|
|
+ "ontology_uid": ontology_uid,
|
|
|
+ "code_type": code_type,
|
|
|
+ "canonical_code": canonical_code,
|
|
|
+ "canonical_name": _required_text(
|
|
|
+ effective_payload,
|
|
|
+ "canonical_name",
|
|
|
+ maximum=300,
|
|
|
+ ),
|
|
|
+ "definition": _optional_text(
|
|
|
+ effective_payload,
|
|
|
+ "definition",
|
|
|
+ maximum=2000,
|
|
|
+ ),
|
|
|
+ "source_mappings": [dict(item) for item in source_mappings],
|
|
|
+ "evidence_uids": list(evidence_uids),
|
|
|
+ "suggestion_source": suggestion_source,
|
|
|
+ "confidence": confidence,
|
|
|
+ }
|
|
|
+ return snapshot
|
|
|
+
|
|
|
+
|
|
|
+def _version(value: Any, field: str = "expected_version") -> int:
|
|
|
+ try:
|
|
|
+ version = int(value)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceSemanticInvalid(f"{field} must be an integer") from exc
|
|
|
+ if version < 0:
|
|
|
+ raise DeviceSemanticInvalid(f"{field} must not be negative")
|
|
|
+ return version
|
|
|
+
|
|
|
+
|
|
|
+def _record_from_snapshot(
|
|
|
+ *,
|
|
|
+ uid: str,
|
|
|
+ snapshot: dict[str, Any],
|
|
|
+ status: str,
|
|
|
+ current_version: int,
|
|
|
+ actor_uid: str | None,
|
|
|
+ created_at: datetime,
|
|
|
+ updated_at: datetime,
|
|
|
+ created_by: str | None = None,
|
|
|
+) -> DeviceSemanticCodeRecord:
|
|
|
+ return DeviceSemanticCodeRecord(
|
|
|
+ uid=uid,
|
|
|
+ ontology_uid=snapshot["ontology_uid"],
|
|
|
+ code_type=snapshot["code_type"],
|
|
|
+ canonical_code=snapshot["canonical_code"],
|
|
|
+ canonical_name=snapshot["canonical_name"],
|
|
|
+ definition=snapshot["definition"],
|
|
|
+ status=status,
|
|
|
+ current_version=current_version,
|
|
|
+ source_mappings=tuple(
|
|
|
+ dict(item) for item in snapshot["source_mappings"]
|
|
|
+ ),
|
|
|
+ evidence_uids=tuple(snapshot["evidence_uids"]),
|
|
|
+ suggestion_source=snapshot["suggestion_source"],
|
|
|
+ confidence=snapshot["confidence"],
|
|
|
+ created_by=created_by if created_by is not None else actor_uid,
|
|
|
+ updated_by=actor_uid,
|
|
|
+ created_at=created_at,
|
|
|
+ updated_at=updated_at,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class DeviceSemanticCodeService:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ repository,
|
|
|
+ *,
|
|
|
+ uid_factory: Callable[[], str] = new_governance_uid,
|
|
|
+ now_factory: Callable[[], datetime] = now_china,
|
|
|
+ review_authorizer: Callable[[str, str], Any] = (
|
|
|
+ lambda _ontology_uid, _actor_uid: None
|
|
|
+ ),
|
|
|
+ commit: Callable[[], Any] = lambda: None,
|
|
|
+ rollback: Callable[[], Any] = lambda: None,
|
|
|
+ ):
|
|
|
+ self.repository = repository
|
|
|
+ self.uid_factory = uid_factory
|
|
|
+ self.now_factory = now_factory
|
|
|
+ self.review_authorizer = review_authorizer
|
|
|
+ self.commit = commit
|
|
|
+ self.rollback = rollback
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _assert_expected_version(record, expected_version):
|
|
|
+ expected = _version(expected_version)
|
|
|
+ if record.current_version != expected:
|
|
|
+ raise DeviceSemanticConflict(
|
|
|
+ "semantic code version has changed"
|
|
|
+ )
|
|
|
+
|
|
|
+ def _get(self, code_uid, *, for_update=False):
|
|
|
+ uid = _uuid(code_uid, "code_uid")
|
|
|
+ record = self.repository.get_code(uid, for_update=for_update)
|
|
|
+ if record is None:
|
|
|
+ raise DeviceSemanticNotFound("semantic code was not found")
|
|
|
+ return record
|
|
|
+
|
|
|
+ def create(self, payload, actor_uid):
|
|
|
+ snapshot = _semantic_snapshot(payload)
|
|
|
+ if not self.repository.ontology_exists(snapshot["ontology_uid"]):
|
|
|
+ raise DeviceSemanticNotFound("ontology was not found")
|
|
|
+ if not self.repository.is_device_ontology(
|
|
|
+ snapshot["ontology_uid"]
|
|
|
+ ):
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "semantic codes must belong to the device ontology"
|
|
|
+ )
|
|
|
+ existing = self.repository.find_code(
|
|
|
+ snapshot["ontology_uid"],
|
|
|
+ snapshot["code_type"],
|
|
|
+ snapshot["canonical_code"],
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ raise DeviceSemanticConflict("semantic code already exists")
|
|
|
+ created_at = self.now_factory()
|
|
|
+ code_uid = self.uid_factory()
|
|
|
+ record = _record_from_snapshot(
|
|
|
+ uid=code_uid,
|
|
|
+ snapshot=snapshot,
|
|
|
+ status="draft",
|
|
|
+ current_version=1,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ created_at=created_at,
|
|
|
+ updated_at=created_at,
|
|
|
+ )
|
|
|
+ version = DeviceSemanticCodeVersionRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ code_uid=code_uid,
|
|
|
+ version=1,
|
|
|
+ snapshot=dict(snapshot),
|
|
|
+ created_by=actor_uid,
|
|
|
+ created_at=created_at,
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ created = self.repository.create_code(record, version)
|
|
|
+ self.commit()
|
|
|
+ return created
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+
|
|
|
+ def revise(
|
|
|
+ self,
|
|
|
+ code_uid,
|
|
|
+ payload,
|
|
|
+ *,
|
|
|
+ expected_version,
|
|
|
+ actor_uid,
|
|
|
+ ):
|
|
|
+ record = self._get(code_uid, for_update=True)
|
|
|
+ self._assert_expected_version(record, expected_version)
|
|
|
+ if record.status not in {"draft", "rejected"}:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "only draft or rejected semantic codes can be revised"
|
|
|
+ )
|
|
|
+ snapshot = _semantic_snapshot(payload, existing=record)
|
|
|
+ changed_at = self.now_factory()
|
|
|
+ next_version = record.current_version + 1
|
|
|
+ revised = _record_from_snapshot(
|
|
|
+ uid=record.uid,
|
|
|
+ snapshot=snapshot,
|
|
|
+ status="draft",
|
|
|
+ current_version=next_version,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ created_by=record.created_by,
|
|
|
+ created_at=record.created_at,
|
|
|
+ updated_at=changed_at,
|
|
|
+ )
|
|
|
+ version = DeviceSemanticCodeVersionRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ code_uid=record.uid,
|
|
|
+ version=next_version,
|
|
|
+ snapshot=dict(snapshot),
|
|
|
+ created_by=actor_uid,
|
|
|
+ created_at=changed_at,
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ result = self.repository.revise_code(revised, version)
|
|
|
+ self.commit()
|
|
|
+ return result
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+
|
|
|
+ def submit(self, code_uid, *, expected_version, actor_uid):
|
|
|
+ record = self._get(code_uid, for_update=True)
|
|
|
+ self._assert_expected_version(record, expected_version)
|
|
|
+ if record.status != "draft":
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "only draft semantic codes can enter in_review"
|
|
|
+ )
|
|
|
+ changed_at = self.now_factory()
|
|
|
+ try:
|
|
|
+ result = self.repository.set_status(
|
|
|
+ record,
|
|
|
+ "in_review",
|
|
|
+ updated_at=changed_at,
|
|
|
+ updated_by=actor_uid,
|
|
|
+ )
|
|
|
+ self.commit()
|
|
|
+ return result
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+
|
|
|
+ def review(self, code_uid, payload, *, actor_uid):
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise DeviceSemanticInvalid("payload must be an object")
|
|
|
+ decision = str(payload.get("decision") or "").strip()
|
|
|
+ if decision not in {"approve", "reject"}:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "decision must be approve or reject"
|
|
|
+ )
|
|
|
+ reason = _optional_text(payload, "reason", maximum=1000) or ""
|
|
|
+ if decision == "reject" and not reason:
|
|
|
+ raise DeviceSemanticInvalid("rejection reason is required")
|
|
|
+ record = self._get(code_uid, for_update=True)
|
|
|
+ self._assert_expected_version(
|
|
|
+ record,
|
|
|
+ payload.get("expected_version"),
|
|
|
+ )
|
|
|
+ if record.status != "in_review":
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "only in_review semantic codes can be reviewed"
|
|
|
+ )
|
|
|
+ self.review_authorizer(record.ontology_uid, str(actor_uid))
|
|
|
+ reviewed_at = self.now_factory()
|
|
|
+ review = DeviceSemanticCodeReviewRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ code_uid=record.uid,
|
|
|
+ version=record.current_version,
|
|
|
+ decision=decision,
|
|
|
+ reason=reason,
|
|
|
+ actor_uid=str(actor_uid),
|
|
|
+ created_at=reviewed_at,
|
|
|
+ )
|
|
|
+ status = "published" if decision == "approve" else "rejected"
|
|
|
+ try:
|
|
|
+ result = self.repository.review_code(record, review, status)
|
|
|
+ self.commit()
|
|
|
+ return result
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+
|
|
|
+ def search(self, filters=None, *, page=1, page_size=20):
|
|
|
+ filters = dict(filters or {})
|
|
|
+ if filters.get("ontology_uid"):
|
|
|
+ filters["ontology_uid"] = _uuid(
|
|
|
+ filters["ontology_uid"],
|
|
|
+ "ontology_uid",
|
|
|
+ )
|
|
|
+ if filters.get("code_type") not in (None, "", *CODE_TYPES):
|
|
|
+ raise DeviceSemanticInvalid("code_type is not supported")
|
|
|
+ if filters.get("status") not in (None, "", *CODE_STATUSES):
|
|
|
+ raise DeviceSemanticInvalid("status is not supported")
|
|
|
+ keyword = str(filters.get("keyword") or "").strip()
|
|
|
+ if len(keyword) > 300:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "keyword exceeds 300 characters"
|
|
|
+ )
|
|
|
+ filters["keyword"] = keyword
|
|
|
+ try:
|
|
|
+ page_value = int(page)
|
|
|
+ page_size_value = int(page_size)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "page and page_size must be integers"
|
|
|
+ ) from exc
|
|
|
+ if page_value <= 0 or not 1 <= page_size_value <= 100:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "page must be positive and page_size must be 1..100"
|
|
|
+ )
|
|
|
+ return self.repository.search(
|
|
|
+ filters,
|
|
|
+ page=page_value,
|
|
|
+ page_size=page_size_value,
|
|
|
+ )
|
|
|
+
|
|
|
+ def get(self, code_uid):
|
|
|
+ return self._get(code_uid)
|
|
|
+
|
|
|
+ def versions(self, code_uid):
|
|
|
+ record = self._get(code_uid)
|
|
|
+ return self.repository.list_versions(record.uid)
|
|
|
+
|
|
|
+ def reviews(self, code_uid):
|
|
|
+ record = self._get(code_uid)
|
|
|
+ return self.repository.list_reviews(record.uid)
|
|
|
+
|
|
|
+
|
|
|
+def _class(uid: str, name: str, description: str):
|
|
|
+ return {
|
|
|
+ "uid": uid,
|
|
|
+ "name": name,
|
|
|
+ "description": description,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _property(
|
|
|
+ uid: str,
|
|
|
+ name: str,
|
|
|
+ class_uid: str,
|
|
|
+ *,
|
|
|
+ required=False,
|
|
|
+ cardinality="0..1",
|
|
|
+):
|
|
|
+ return {
|
|
|
+ "uid": uid,
|
|
|
+ "name": name,
|
|
|
+ "class_uid": class_uid,
|
|
|
+ "cardinality": cardinality,
|
|
|
+ "required": bool(required),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _relation(uid: str, name: str, from_uid: str, to_uid: str):
|
|
|
+ return {
|
|
|
+ "uid": uid,
|
|
|
+ "name": name,
|
|
|
+ "from_class_uid": from_uid,
|
|
|
+ "to_class_uid": to_uid,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def build_device_graph(owner_domain_uid: str) -> dict[str, list[dict]]:
|
|
|
+ domain_uid = _uuid(owner_domain_uid, "owner_domain_uid")
|
|
|
+ classes = [
|
|
|
+ _class("device", "设备", "具有稳定平台标识的可治理设备主对象"),
|
|
|
+ _class("component", "部件", "组成设备并可独立维护的部件"),
|
|
|
+ _class("measurement_point", "测点", "观测设备状态的测量位置或信号"),
|
|
|
+ _class("alarm", "告警", "设备或测点产生的异常事件"),
|
|
|
+ _class("maintenance_record", "维护记录", "设备检查、维修与保养活动"),
|
|
|
+ _class("location", "位置", "设备安装或运行的空间位置"),
|
|
|
+ _class("organization", "组织", "设备所属或运维责任组织"),
|
|
|
+ _class("person", "责任人", "承担设备治理或运维责任的人员"),
|
|
|
+ _class("fault", "故障", "经治理确认的故障分类"),
|
|
|
+ _class("cause", "原因", "故障形成的原因分类"),
|
|
|
+ _class("action", "措施", "消除故障或降低影响的处置措施"),
|
|
|
+ ]
|
|
|
+ properties = [
|
|
|
+ _property(
|
|
|
+ "device.platform_uid",
|
|
|
+ "平台设备UID",
|
|
|
+ "device",
|
|
|
+ required=True,
|
|
|
+ cardinality="1",
|
|
|
+ ),
|
|
|
+ _property(
|
|
|
+ "device.source_code",
|
|
|
+ "源系统设备编码",
|
|
|
+ "device",
|
|
|
+ required=True,
|
|
|
+ cardinality="1..*",
|
|
|
+ ),
|
|
|
+ _property(
|
|
|
+ "device.name",
|
|
|
+ "设备名称",
|
|
|
+ "device",
|
|
|
+ required=True,
|
|
|
+ cardinality="1",
|
|
|
+ ),
|
|
|
+ _property("device.status", "设备状态", "device"),
|
|
|
+ _property("device.location", "设备位置", "device"),
|
|
|
+ _property("component.platform_uid", "平台部件UID", "component"),
|
|
|
+ _property(
|
|
|
+ "measurement_point.platform_uid",
|
|
|
+ "平台测点UID",
|
|
|
+ "measurement_point",
|
|
|
+ ),
|
|
|
+ _property("alarm.platform_uid", "平台告警UID", "alarm"),
|
|
|
+ _property(
|
|
|
+ "maintenance_record.platform_uid",
|
|
|
+ "平台维护记录UID",
|
|
|
+ "maintenance_record",
|
|
|
+ ),
|
|
|
+ _property("fault.code", "故障代码", "fault", required=True),
|
|
|
+ _property("cause.code", "原因代码", "cause", required=True),
|
|
|
+ _property("action.code", "措施代码", "action", required=True),
|
|
|
+ ]
|
|
|
+ relations = [
|
|
|
+ _relation("component.part_of", "部件属于设备", "component", "device"),
|
|
|
+ _relation(
|
|
|
+ "measurement_point.monitors",
|
|
|
+ "测点监测设备",
|
|
|
+ "measurement_point",
|
|
|
+ "device",
|
|
|
+ ),
|
|
|
+ _relation("alarm.raised_by", "告警由设备产生", "alarm", "device"),
|
|
|
+ _relation(
|
|
|
+ "maintenance_record.maintains",
|
|
|
+ "维护记录作用于设备",
|
|
|
+ "maintenance_record",
|
|
|
+ "device",
|
|
|
+ ),
|
|
|
+ _relation("device.located_at", "设备位于", "device", "location"),
|
|
|
+ _relation("device.owned_by", "设备归属组织", "device", "organization"),
|
|
|
+ _relation("device.responsible_to", "设备责任人", "device", "person"),
|
|
|
+ _relation("alarm.classified_as", "告警归类为故障", "alarm", "fault"),
|
|
|
+ _relation("fault.caused_by", "故障由原因导致", "fault", "cause"),
|
|
|
+ _relation(
|
|
|
+ "maintenance_record.uses_action",
|
|
|
+ "维护记录采用措施",
|
|
|
+ "maintenance_record",
|
|
|
+ "action",
|
|
|
+ ),
|
|
|
+ ]
|
|
|
+ mappings = [
|
|
|
+ {
|
|
|
+ "property_uid": "device.platform_uid",
|
|
|
+ "data_element_uid": "platform:device_assets.uid",
|
|
|
+ "source": "device_assets.uid",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "property_uid": "device.source_code",
|
|
|
+ "data_element_uid": "platform:device_asset_source_mappings.source_code",
|
|
|
+ "source": "device_asset_source_mappings.source_code",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "property_uid": "device.name",
|
|
|
+ "data_element_uid": "platform:device_assets.name",
|
|
|
+ "source": "device_assets.name",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "property_uid": "fault.code",
|
|
|
+ "data_element_uid": "platform:device_semantic_codes.fault",
|
|
|
+ "source": "device_semantic_codes.canonical_code",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "property_uid": "cause.code",
|
|
|
+ "data_element_uid": "platform:device_semantic_codes.cause",
|
|
|
+ "source": "device_semantic_codes.canonical_code",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "property_uid": "action.code",
|
|
|
+ "data_element_uid": "platform:device_semantic_codes.action",
|
|
|
+ "source": "device_semantic_codes.canonical_code",
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ return {
|
|
|
+ "classes": classes,
|
|
|
+ "properties": properties,
|
|
|
+ "relations": relations,
|
|
|
+ "constraints": [
|
|
|
+ {
|
|
|
+ "uid": "device.platform_uid.unique",
|
|
|
+ "name": "平台设备UID唯一",
|
|
|
+ "expression": "unique(device.platform_uid)",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "uid": "device.source_identity.unique",
|
|
|
+ "name": "来源设备身份唯一",
|
|
|
+ "expression": (
|
|
|
+ "unique(source_uid,source_entity,asset_type,source_code)"
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ "domain_links": [
|
|
|
+ {"domain_uid": domain_uid, "role": "owner"},
|
|
|
+ ],
|
|
|
+ "element_mappings": mappings,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def assess_device_graph(payload: Any) -> DeviceSemanticProfile:
|
|
|
+ graph = dict(payload or {})
|
|
|
+ classes = {
|
|
|
+ str(item.get("uid"))
|
|
|
+ for item in graph.get("classes") or ()
|
|
|
+ if isinstance(item, dict)
|
|
|
+ }
|
|
|
+ relations = {
|
|
|
+ str(item.get("uid"))
|
|
|
+ for item in graph.get("relations") or ()
|
|
|
+ if isinstance(item, dict)
|
|
|
+ }
|
|
|
+ mappings = {
|
|
|
+ str(item.get("property_uid"))
|
|
|
+ for item in graph.get("element_mappings") or ()
|
|
|
+ if isinstance(item, dict)
|
|
|
+ }
|
|
|
+ missing_classes = tuple(
|
|
|
+ uid for uid in REQUIRED_CLASS_UIDS if uid not in classes
|
|
|
+ )
|
|
|
+ missing_relations = tuple(
|
|
|
+ uid for uid in REQUIRED_RELATION_UIDS if uid not in relations
|
|
|
+ )
|
|
|
+ missing_mappings = tuple(
|
|
|
+ uid for uid in REQUIRED_MAPPING_UIDS if uid not in mappings
|
|
|
+ )
|
|
|
+ return DeviceSemanticProfile(
|
|
|
+ ready_to_publish=not (
|
|
|
+ missing_classes or missing_relations or missing_mappings
|
|
|
+ ),
|
|
|
+ class_count=len(classes),
|
|
|
+ relation_count=len(relations),
|
|
|
+ mapping_count=len(mappings),
|
|
|
+ missing_classes=missing_classes,
|
|
|
+ missing_relations=missing_relations,
|
|
|
+ missing_mappings=missing_mappings,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class DeviceSemanticService:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ ontology_repository,
|
|
|
+ *,
|
|
|
+ commit: Callable[[], Any] = lambda: None,
|
|
|
+ rollback: Callable[[], Any] = lambda: None,
|
|
|
+ ):
|
|
|
+ self.ontology_repository = ontology_repository
|
|
|
+ self.commit = commit
|
|
|
+ self.rollback = rollback
|
|
|
+
|
|
|
+ def bootstrap(self, payload, actor_uid) -> DeviceSemanticBootstrapResult:
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise DeviceSemanticInvalid("payload must be an object")
|
|
|
+ existing = self.ontology_repository.find_by_code(
|
|
|
+ DEVICE_ONTOLOGY_CODE
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ version = self.ontology_repository.latest_version(existing.uid)
|
|
|
+ if version is None:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "device ontology exists without a draft"
|
|
|
+ )
|
|
|
+ return DeviceSemanticBootstrapResult(
|
|
|
+ ontology=existing,
|
|
|
+ version=version,
|
|
|
+ profile=assess_device_graph(
|
|
|
+ version.graph_document.to_dict()
|
|
|
+ ),
|
|
|
+ created=False,
|
|
|
+ )
|
|
|
+ domain_uid = _uuid(
|
|
|
+ payload.get("owner_domain_uid"),
|
|
|
+ "owner_domain_uid",
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ ontology = self.ontology_repository.create(
|
|
|
+ code=DEVICE_ONTOLOGY_CODE,
|
|
|
+ name="设备语义本体",
|
|
|
+ owner_uid=str(payload.get("owner_uid") or actor_uid),
|
|
|
+ domain_links=[
|
|
|
+ {"domain_uid": domain_uid, "role": "owner"}
|
|
|
+ ],
|
|
|
+ created_by=actor_uid,
|
|
|
+ )
|
|
|
+ version = self.ontology_repository.save_draft(
|
|
|
+ ontology.uid,
|
|
|
+ build_device_graph(domain_uid),
|
|
|
+ expected_revision=0,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ )
|
|
|
+ self.commit()
|
|
|
+ return DeviceSemanticBootstrapResult(
|
|
|
+ ontology=self.ontology_repository.get(ontology.uid),
|
|
|
+ version=version,
|
|
|
+ profile=assess_device_graph(
|
|
|
+ version.graph_document.to_dict()
|
|
|
+ ),
|
|
|
+ created=True,
|
|
|
+ )
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+
|
|
|
+ def profile(self) -> DeviceSemanticBootstrapResult | None:
|
|
|
+ ontology = self.ontology_repository.find_by_code(
|
|
|
+ DEVICE_ONTOLOGY_CODE
|
|
|
+ )
|
|
|
+ if ontology is None:
|
|
|
+ return None
|
|
|
+ version = self.ontology_repository.latest_version(ontology.uid)
|
|
|
+ if version is None:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "device ontology exists without a draft"
|
|
|
+ )
|
|
|
+ return DeviceSemanticBootstrapResult(
|
|
|
+ ontology=ontology,
|
|
|
+ version=version,
|
|
|
+ profile=assess_device_graph(version.graph_document.to_dict()),
|
|
|
+ created=False,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class DeviceOntologyPublicationAuthorizer:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ ontology_repository,
|
|
|
+ *,
|
|
|
+ responsibility_lookup: Callable[[str, str], dict],
|
|
|
+ ):
|
|
|
+ self.ontology_repository = ontology_repository
|
|
|
+ self.responsibility_lookup = responsibility_lookup
|
|
|
+
|
|
|
+ def __call__(self, version, actor_uid):
|
|
|
+ ontology = self.ontology_repository.get(version.ontology_uid)
|
|
|
+ if ontology is None:
|
|
|
+ raise DeviceSemanticInvalid("ontology was not found")
|
|
|
+ if ontology.code != DEVICE_ONTOLOGY_CODE:
|
|
|
+ return
|
|
|
+ if not assess_device_graph(
|
|
|
+ version.graph_document.to_dict()
|
|
|
+ ).ready_to_publish:
|
|
|
+ raise DeviceSemanticInvalid(
|
|
|
+ "device semantic profile is incomplete"
|
|
|
+ )
|
|
|
+ self.assert_accountable(ontology.uid, actor_uid)
|
|
|
+
|
|
|
+ def assert_accountable(self, ontology_uid, actor_uid):
|
|
|
+ matrix = self.responsibility_lookup(
|
|
|
+ "device_ontology",
|
|
|
+ str(ontology_uid),
|
|
|
+ )
|
|
|
+ accountable = {
|
|
|
+ str(item.get("user_id"))
|
|
|
+ for item in matrix.get("assignments") or ()
|
|
|
+ if item.get("responsibility_role") == "asset_manager"
|
|
|
+ and item.get("raci_role") == "accountable"
|
|
|
+ }
|
|
|
+ if str(actor_uid) not in accountable:
|
|
|
+ raise DeviceSemanticForbidden(
|
|
|
+ "accountable device asset manager approval is required"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+__all__ = [
|
|
|
+ "CODE_STATUSES",
|
|
|
+ "CODE_TYPES",
|
|
|
+ "DEVICE_ONTOLOGY_CODE",
|
|
|
+ "DeviceOntologyPublicationAuthorizer",
|
|
|
+ "DeviceSemanticBootstrapResult",
|
|
|
+ "DeviceSemanticCodeRecord",
|
|
|
+ "DeviceSemanticCodeReviewRecord",
|
|
|
+ "DeviceSemanticCodeService",
|
|
|
+ "DeviceSemanticCodeVersionRecord",
|
|
|
+ "DeviceSemanticForbidden",
|
|
|
+ "DeviceSemanticInvalid",
|
|
|
+ "DeviceSemanticProfile",
|
|
|
+ "DeviceSemanticService",
|
|
|
+ "assess_device_graph",
|
|
|
+ "build_device_graph",
|
|
|
+]
|