Parcourir la source

feat: add evidence-bound device root cause graph

马小龙 il y a 3 mois
Parent
commit
c14ae63aa6
31 fichiers modifiés avec 5114 ajouts et 7 suppressions
  1. 198 0
      app/api/data_development/routes.py
  2. 766 0
      app/core/data_research/device_observability.py
  3. 359 0
      app/core/data_research/device_observability_repository.py
  4. 15 0
      app/core/data_research/errors.py
  5. 7 0
      app/core/system/permissions.py
  6. 122 0
      app/models/data_research.py
  7. 198 0
      deployment/app/api/data_development/routes.py
  8. 766 0
      deployment/app/core/data_research/device_observability.py
  9. 359 0
      deployment/app/core/data_research/device_observability_repository.py
  10. 15 0
      deployment/app/core/data_research/errors.py
  11. 7 0
      deployment/app/core/system/permissions.py
  12. 122 0
      deployment/app/models/data_research.py
  13. 98 0
      deployment/migrations/versions/20260729_350_device_observability.py
  14. 1 0
      docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md
  15. 7 5
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  16. 6 0
      docs/architecture/DATA_MODEL.md
  17. 84 1
      docs/architecture/OPENAPI.yaml
  18. 115 0
      docs/superpowers/plans/2026-07-29-wp09-device-relations-root-cause.md
  19. 17 0
      frontend/src/api/dataDevelopment.js
  20. 12 0
      frontend/src/router/routes.js
  21. 504 0
      frontend/src/views/dataGovernance/development/deviceObservability.vue
  22. 121 0
      frontend/src/views/dataGovernance/development/deviceObservabilityModel.js
  23. 1 0
      frontend/src/views/dataGovernance/development/index.vue
  24. 100 0
      frontend/tests/device-observability-model.test.mjs
  25. 98 0
      migrations/versions/20260729_350_device_observability.py
  26. 433 0
      tests/data_research/test_device_observability.py
  27. 234 0
      tests/data_research/test_device_observability_api.py
  28. 46 0
      tests/data_research/test_device_observability_frontend_contract.py
  29. 265 0
      tests/integration/test_device_observability_postgres.py
  30. 21 1
      tests/test_architecture_artifacts.py
  31. 17 0
      tests/test_permission_matrix.py

+ 198 - 0
app/api/data_development/routes.py

@@ -300,6 +300,21 @@ def get_quality_issue_service():
     )
 
 
+def get_device_observability_service():
+    from app.core.data_research.device_observability import (
+        DeviceObservabilityService,
+    )
+    from app.core.data_research.device_observability_repository import (
+        SqlAlchemyDeviceObservabilityRepository,
+    )
+
+    return DeviceObservabilityService(
+        SqlAlchemyDeviceObservabilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def get_candidate_decision_service():
     from app.core.data_research.candidate_decisions import CandidateDecisionService
     from app.core.data_research.data_elements import DataElementService
@@ -916,6 +931,94 @@ def _quality_issue_timeline(record):
     }
 
 
+def _device_operational_event(record):
+    return {
+        "uid": str(record.uid),
+        "source_uid": str(record.source_uid),
+        "source_entity": record.source_entity,
+        "source_code": record.source_code,
+        "event_type": record.event_type,
+        "asset_uid": str(record.asset_uid),
+        "component_uid": (
+            str(record.component_uid) if record.component_uid else None
+        ),
+        "title": record.title,
+        "severity": record.severity,
+        "status": record.status,
+        "occurred_at": _iso(record.occurred_at),
+        "ended_at": _iso(record.ended_at),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "content_hash": record.content_hash,
+        "created_by": str(record.created_by),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_evidence_relation(record):
+    return {
+        "uid": str(record.uid),
+        "from_kind": record.from_kind,
+        "from_uid": str(record.from_uid),
+        "relation_type": record.relation_type,
+        "to_kind": record.to_kind,
+        "to_uid": str(record.to_uid),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "source": record.source,
+        "created_by": str(record.created_by),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_graph_node(record):
+    return {
+        "kind": record.kind,
+        "uid": str(record.uid),
+        "node_type": record.node_type,
+        "label": record.label,
+        "occurred_at": _iso(record.occurred_at),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+    }
+
+
+def _device_evidence_graph(record):
+    return {
+        "anchor_kind": record.anchor_kind,
+        "anchor_uid": str(record.anchor_uid),
+        "nodes": [_device_graph_node(item) for item in record.nodes],
+        "relations": [
+            _device_evidence_relation(item) for item in record.relations
+        ],
+        "truncated": bool(record.truncated),
+    }
+
+
+def _root_cause_analysis(record):
+    return {
+        "analysis_status": record.analysis_status,
+        "conclusion": record.conclusion,
+        "anchor": _device_graph_node(record.anchor),
+        "candidates": [
+            {
+                "event_uid": str(item.event_uid),
+                "event_type": item.event_type,
+                "title": item.title,
+                "occurred_at": _iso(item.occurred_at),
+                "support_level": item.support_level,
+                "path_node_uids": list(item.path_node_uids),
+                "path_relation_uids": list(item.path_relation_uids),
+                "path_relation_types": list(item.path_relation_types),
+                "evidence_refs": [
+                    dict(evidence) for evidence in item.evidence_refs
+                ],
+            }
+            for item in record.candidates
+        ],
+        "graph": _device_evidence_graph(record.graph),
+        "limitations": list(record.limitations),
+        "generated_at": _iso(record.generated_at),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -1948,6 +2051,101 @@ def reopen_quality_issue(issue_uid):
         return _error(error)
 
 
+@bp.route("/device-observability/events", methods=["GET"])
+def list_device_operational_events():
+    filters = {
+        name: request.args.get(name)
+        for name in (
+            "event_type",
+            "severity",
+            "status",
+            "asset_uid",
+            "source_uid",
+            "keyword",
+        )
+        if request.args.get(name)
+    }
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_observability_service().events(
+            filters,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_operational_event(item)
+                        for item in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/import", methods=["POST"])
+def import_device_operational_evidence():
+    try:
+        result = get_device_observability_service().import_evidence(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        data = {
+            "events": [
+                _device_operational_event(item) for item in result.events
+            ],
+            "relations": [
+                _device_evidence_relation(item)
+                for item in result.relations
+            ],
+            "created_events": int(result.created_events),
+            "existing_events": int(result.existing_events),
+            "created_relations": int(result.created_relations),
+            "existing_relations": int(result.existing_relations),
+        }
+        status = (
+            201
+            if result.created_events or result.created_relations
+            else 200
+        )
+        return jsonify(success(data)), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/graph", methods=["GET"])
+def get_device_evidence_graph():
+    try:
+        record = get_device_observability_service().graph(
+            request.args.get("anchor_kind"),
+            request.args.get("anchor_uid"),
+            max_hops=request.args.get("max_hops", 2),
+        )
+        return jsonify(success(_device_evidence_graph(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/root-cause", methods=["GET"])
+def get_device_root_cause():
+    try:
+        record = get_device_observability_service().root_cause(
+            request.args.get("anchor_kind"),
+            request.args.get("anchor_uid"),
+            max_hops=request.args.get("max_hops", 3),
+        )
+        return jsonify(success(_root_cause_analysis(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 766 - 0
app/core/data_research/device_observability.py

@@ -0,0 +1,766 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from collections import defaultdict, deque
+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 (
+    DeviceObservabilityConflict,
+    DeviceObservabilityInvalid,
+    DeviceObservabilityNotFound,
+)
+
+EVENT_TYPES = frozenset({"alarm", "fault", "maintenance", "downtime"})
+EVENT_SEVERITIES = frozenset({"info", "warning", "error", "critical"})
+EVENT_STATUSES = frozenset({"observed", "resolved"})
+NODE_KINDS = frozenset({"asset", "event", "quality_issue"})
+RELATION_TYPES = frozenset(
+    {
+        "part_of",
+        "occurred_on",
+        "indicates",
+        "triggered",
+        "addresses",
+        "impacts",
+        "evidences",
+        "related_to",
+    }
+)
+CAUSAL_RELATION_TYPES = frozenset({"indicates", "triggered", "evidences"})
+SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "encrypted_payload",
+        "phone",
+        "mobile",
+        "email",
+        "id_number",
+    }
+)
+
+
+@dataclass(frozen=True)
+class OperationalEventRecord:
+    uid: str
+    source_uid: str
+    source_entity: str
+    source_code: str
+    event_type: str
+    asset_uid: str
+    component_uid: str | None
+    title: str
+    severity: str
+    status: str
+    occurred_at: datetime
+    ended_at: datetime | None
+    evidence_refs: tuple[dict[str, Any], ...]
+    content_hash: str
+    created_by: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class EvidenceRelationRecord:
+    uid: str
+    from_kind: str
+    from_uid: str
+    relation_type: str
+    to_kind: str
+    to_uid: str
+    evidence_refs: tuple[dict[str, Any], ...]
+    source: str
+    created_by: str
+    created_at: datetime | None = None
+
+    @property
+    def identity(self) -> str:
+        raw = "|".join(
+            (
+                self.from_kind,
+                self.from_uid,
+                self.relation_type,
+                self.to_kind,
+                self.to_uid,
+            )
+        )
+        return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+@dataclass(frozen=True)
+class GraphNode:
+    kind: str
+    uid: str
+    node_type: str
+    label: str
+    occurred_at: datetime | None
+    evidence_refs: tuple[dict[str, Any], ...]
+
+    @property
+    def key(self) -> str:
+        return f"{self.kind}:{self.uid}"
+
+
+@dataclass(frozen=True)
+class GraphProjection:
+    anchor_kind: str
+    anchor_uid: str
+    nodes: tuple[GraphNode, ...]
+    relations: tuple[EvidenceRelationRecord, ...]
+    truncated: bool
+
+
+@dataclass(frozen=True)
+class RootCauseCandidate:
+    event_uid: str
+    event_type: str
+    title: str
+    occurred_at: datetime | None
+    support_level: str
+    path_node_uids: tuple[str, ...]
+    path_relation_uids: tuple[str, ...]
+    path_relation_types: tuple[str, ...]
+    evidence_refs: tuple[dict[str, Any], ...]
+
+
+@dataclass(frozen=True)
+class RootCauseAnalysis:
+    analysis_status: str
+    conclusion: str
+    anchor: GraphNode
+    candidates: tuple[RootCauseCandidate, ...]
+    graph: GraphProjection
+    limitations: tuple[str, ...]
+    generated_at: datetime
+
+
+@dataclass(frozen=True)
+class ObservabilityImportResult:
+    events: tuple[OperationalEventRecord, ...]
+    relations: tuple[EvidenceRelationRecord, ...]
+    created_events: int
+    existing_events: int
+    created_relations: int
+    existing_relations: int
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(UUID(str(value)))
+    except (AttributeError, TypeError, ValueError) as exc:
+        raise DeviceObservabilityInvalid(f"{field} must be a UUID") from exc
+
+
+def _text(value: Any, field: str, maximum: int) -> str:
+    normalized = str(value or "").strip()
+    if not normalized:
+        raise DeviceObservabilityInvalid(f"{field} is required")
+    if len(normalized) > maximum:
+        raise DeviceObservabilityInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return normalized
+
+
+def _optional_text(value: Any, field: str, maximum: int) -> str | None:
+    normalized = str(value or "").strip()
+    if not normalized:
+        return None
+    if len(normalized) > maximum:
+        raise DeviceObservabilityInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return normalized
+
+
+def _timestamp(value: Any, field: str, *, required: bool) -> datetime | None:
+    if value in (None, ""):
+        if required:
+            raise DeviceObservabilityInvalid(f"{field} is required")
+        return None
+    if isinstance(value, datetime):
+        parsed = value
+    else:
+        try:
+            parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                f"{field} must be ISO-8601"
+            ) from exc
+    if parsed.tzinfo is None:
+        raise DeviceObservabilityInvalid(f"{field} must include a timezone")
+    return parsed
+
+
+def _reject_secrets(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().casefold() in SECRET_KEYS:
+                raise DeviceObservabilityInvalid(
+                    "evidence contains a secret or direct identifier"
+                )
+            _reject_secrets(item)
+    elif isinstance(value, (list, tuple)):
+        if len(value) > 100:
+            raise DeviceObservabilityInvalid(
+                "evidence arrays cannot exceed 100 items"
+            )
+        for item in value:
+            _reject_secrets(item)
+
+
+def _evidence(value: Any) -> tuple[dict[str, Any], ...]:
+    if value in (None, {}):
+        return ()
+    if not isinstance(value, dict):
+        raise DeviceObservabilityInvalid("evidence must be an object")
+    _reject_secrets(value)
+    serialized = json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    if len(serialized.encode("utf-8")) > 20_000:
+        raise DeviceObservabilityInvalid("evidence exceeds 20000 bytes")
+    return ({"type": "source_evidence", "data": value},)
+
+
+def _event_hash(payload: dict[str, Any]) -> str:
+    serialized = json.dumps(
+        payload,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+
+
+class DeviceObservabilityService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory=new_governance_uid,
+        commit=lambda: None,
+        rollback=lambda: None,
+        clock=now_china,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.commit = commit
+        self.rollback = rollback
+        self.clock = clock
+
+    def import_evidence(
+        self,
+        payload: Any,
+        actor_uid: Any,
+    ) -> ObservabilityImportResult:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid("payload must be an object")
+        source_uid = _uuid(payload.get("source_uid"), "source_uid")
+        actor = _uuid(actor_uid, "actor_uid")
+        if not self.repository.source_exists(source_uid):
+            raise DeviceObservabilityInvalid(
+                "source_uid must reference an active source"
+            )
+        events_payload = payload.get("events", [])
+        relations_payload = payload.get("relations", [])
+        if not isinstance(events_payload, list) or not isinstance(
+            relations_payload, list
+        ):
+            raise DeviceObservabilityInvalid(
+                "events and relations must be arrays"
+            )
+        if not events_payload and not relations_payload:
+            raise DeviceObservabilityInvalid(
+                "at least one event or relation is required"
+            )
+        if len(events_payload) > 200:
+            raise DeviceObservabilityInvalid("events cannot exceed 200 items")
+        if len(relations_payload) > 500:
+            raise DeviceObservabilityInvalid(
+                "relations cannot exceed 500 items"
+            )
+
+        events = []
+        relations = []
+        created_events = existing_events = 0
+        created_relations = existing_relations = 0
+        try:
+            for item in events_payload:
+                normalized = self._normalize_event(
+                    item,
+                    source_uid=source_uid,
+                    actor_uid=actor,
+                )
+                current = self.repository.get_event_by_identity(
+                    source_uid,
+                    normalized.source_entity,
+                    normalized.source_code,
+                )
+                if current is not None:
+                    if current.content_hash != normalized.content_hash:
+                        raise DeviceObservabilityConflict(
+                            "event source identity already exists "
+                            "with different content"
+                        )
+                    events.append(current)
+                    existing_events += 1
+                    continue
+                self.repository.add_event(normalized)
+                events.append(normalized)
+                created_events += 1
+
+            for item in relations_payload:
+                normalized = self._normalize_relation(
+                    item,
+                    source_uid=source_uid,
+                    actor_uid=actor,
+                )
+                current = self.repository.get_relation(normalized)
+                if current is not None:
+                    if (
+                        current.evidence_refs != normalized.evidence_refs
+                        or current.source != normalized.source
+                    ):
+                        raise DeviceObservabilityConflict(
+                            "relation identity already exists "
+                            "with different evidence"
+                        )
+                    relations.append(current)
+                    existing_relations += 1
+                    continue
+                self.repository.add_relation(normalized)
+                relations.append(normalized)
+                created_relations += 1
+            self.commit()
+        except Exception:
+            self.rollback()
+            raise
+        return ObservabilityImportResult(
+            events=tuple(events),
+            relations=tuple(relations),
+            created_events=created_events,
+            existing_events=existing_events,
+            created_relations=created_relations,
+            existing_relations=existing_relations,
+        )
+
+    def _normalize_event(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        actor_uid: str,
+    ) -> OperationalEventRecord:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(
+                "each event must be an object"
+            )
+        event_type = _text(payload.get("event_type"), "event_type", 30)
+        if event_type not in EVENT_TYPES:
+            raise DeviceObservabilityInvalid("event_type is not supported")
+        severity = _text(
+            payload.get("severity", "warning"),
+            "severity",
+            20,
+        )
+        if severity not in EVENT_SEVERITIES:
+            raise DeviceObservabilityInvalid("severity is not supported")
+        status = _text(payload.get("status", "observed"), "status", 20)
+        if status not in EVENT_STATUSES:
+            raise DeviceObservabilityInvalid("status is not supported")
+        occurred_at = _timestamp(
+            payload.get("occurred_at"),
+            "occurred_at",
+            required=True,
+        )
+        ended_at = _timestamp(
+            payload.get("ended_at"),
+            "ended_at",
+            required=False,
+        )
+        if ended_at is not None and ended_at < occurred_at:
+            raise DeviceObservabilityInvalid(
+                "ended_at cannot be before occurred_at"
+            )
+        asset_uid = _uuid(payload.get("asset_uid"), "asset_uid")
+        if not self.repository.node_exists("asset", asset_uid):
+            raise DeviceObservabilityInvalid("asset_uid does not exist")
+        component_uid = None
+        if payload.get("component_uid"):
+            component_uid = _uuid(
+                payload.get("component_uid"),
+                "component_uid",
+            )
+            if not self.repository.node_exists("asset", component_uid):
+                raise DeviceObservabilityInvalid(
+                    "component_uid does not exist"
+                )
+        evidence_refs = _evidence(payload.get("evidence"))
+        source_entity = _text(
+            payload.get("source_entity"),
+            "source_entity",
+            300,
+        )
+        source_code = _text(
+            payload.get("source_code"),
+            "source_code",
+            300,
+        )
+        title = _text(payload.get("title"), "title", 500)
+        hash_payload = {
+            "source_uid": source_uid,
+            "source_entity": source_entity,
+            "source_code": source_code,
+            "event_type": event_type,
+            "asset_uid": asset_uid,
+            "component_uid": component_uid,
+            "title": title,
+            "severity": severity,
+            "status": status,
+            "occurred_at": occurred_at.isoformat(),
+            "ended_at": ended_at.isoformat() if ended_at else None,
+            "evidence_refs": evidence_refs,
+        }
+        return OperationalEventRecord(
+            uid=str(self.uid_factory()),
+            source_uid=source_uid,
+            source_entity=source_entity,
+            source_code=source_code,
+            event_type=event_type,
+            asset_uid=asset_uid,
+            component_uid=component_uid,
+            title=title,
+            severity=severity,
+            status=status,
+            occurred_at=occurred_at,
+            ended_at=ended_at,
+            evidence_refs=evidence_refs,
+            created_by=actor_uid,
+            created_at=self.clock(),
+            content_hash=_event_hash(hash_payload),
+        )
+
+    def _endpoint(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        field: str,
+    ) -> tuple[str, str]:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(f"{field} must be an object")
+        kind = _text(payload.get("kind"), f"{field}.kind", 30)
+        if kind not in NODE_KINDS:
+            raise DeviceObservabilityInvalid(
+                f"{field}.kind is not supported"
+            )
+        if kind == "event" and not payload.get("uid"):
+            source_entity = _text(
+                payload.get("source_entity"),
+                f"{field}.source_entity",
+                300,
+            )
+            source_code = _text(
+                payload.get("source_code"),
+                f"{field}.source_code",
+                300,
+            )
+            uid = self.repository.resolve_event_identity(
+                source_uid,
+                source_entity,
+                source_code,
+            )
+            if not uid:
+                raise DeviceObservabilityInvalid(
+                    f"{field} event identity does not exist"
+                )
+        else:
+            uid = _uuid(payload.get("uid"), f"{field}.uid")
+        if not self.repository.node_exists(kind, uid):
+            raise DeviceObservabilityInvalid(f"{field} node does not exist")
+        return kind, uid
+
+    def _normalize_relation(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        actor_uid: str,
+    ) -> EvidenceRelationRecord:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(
+                "each relation must be an object"
+            )
+        from_kind, from_uid = self._endpoint(
+            payload.get("from"),
+            source_uid=source_uid,
+            field="from",
+        )
+        to_kind, to_uid = self._endpoint(
+            payload.get("to"),
+            source_uid=source_uid,
+            field="to",
+        )
+        if from_kind == to_kind and from_uid == to_uid:
+            raise DeviceObservabilityInvalid(
+                "relation endpoints must be different"
+            )
+        relation_type = _text(
+            payload.get("relation_type"),
+            "relation_type",
+            40,
+        )
+        if relation_type not in RELATION_TYPES:
+            raise DeviceObservabilityInvalid(
+                "relation_type is not supported"
+            )
+        source = _optional_text(payload.get("source"), "source", 30)
+        if source is None:
+            source = "imported"
+        if source not in {"imported", "manual", "derived"}:
+            raise DeviceObservabilityInvalid("source is not supported")
+        return EvidenceRelationRecord(
+            uid=str(self.uid_factory()),
+            from_kind=from_kind,
+            from_uid=from_uid,
+            relation_type=relation_type,
+            to_kind=to_kind,
+            to_uid=to_uid,
+            evidence_refs=_evidence(payload.get("evidence")),
+            source=source,
+            created_by=actor_uid,
+            created_at=self.clock(),
+        )
+
+    def graph(
+        self,
+        anchor_kind: Any,
+        anchor_uid: Any,
+        *,
+        max_hops: Any = 2,
+    ) -> GraphProjection:
+        kind = _text(anchor_kind, "anchor_kind", 30)
+        if kind not in NODE_KINDS:
+            raise DeviceObservabilityInvalid(
+                "anchor_kind is not supported"
+            )
+        uid = _uuid(anchor_uid, "anchor_uid")
+        try:
+            hops = int(max_hops)
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                "max_hops must be an integer"
+            ) from exc
+        if hops < 1 or hops > 3:
+            raise DeviceObservabilityInvalid(
+                "max_hops must be between 1 and 3"
+            )
+        if not self.repository.node_exists(kind, uid):
+            raise DeviceObservabilityNotFound("anchor node does not exist")
+        return self.repository.graph(
+            kind,
+            uid,
+            max_hops=hops,
+            max_nodes=100,
+            max_edges=200,
+        )
+
+    def events(
+        self,
+        filters: Any,
+        *,
+        page: Any = 1,
+        page_size: Any = 20,
+    ):
+        if not isinstance(filters, dict):
+            raise DeviceObservabilityInvalid("filters must be an object")
+        normalized = {}
+        event_type = _optional_text(
+            filters.get("event_type"),
+            "event_type",
+            30,
+        )
+        if event_type:
+            if event_type not in EVENT_TYPES:
+                raise DeviceObservabilityInvalid(
+                    "event_type is not supported"
+                )
+            normalized["event_type"] = event_type
+        severity = _optional_text(
+            filters.get("severity"),
+            "severity",
+            20,
+        )
+        if severity:
+            if severity not in EVENT_SEVERITIES:
+                raise DeviceObservabilityInvalid(
+                    "severity is not supported"
+                )
+            normalized["severity"] = severity
+        status = _optional_text(filters.get("status"), "status", 20)
+        if status:
+            if status not in EVENT_STATUSES:
+                raise DeviceObservabilityInvalid("status is not supported")
+            normalized["status"] = status
+        for field in ("asset_uid", "source_uid"):
+            if filters.get(field):
+                normalized[field] = _uuid(filters[field], field)
+        keyword = _optional_text(filters.get("keyword"), "keyword", 300)
+        if keyword:
+            normalized["keyword"] = keyword
+        try:
+            current_page = int(page)
+            current_page_size = int(page_size)
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                "page and page_size must be integers"
+            ) from exc
+        if current_page < 1:
+            raise DeviceObservabilityInvalid("page must be positive")
+        if current_page_size < 1 or current_page_size > 100:
+            raise DeviceObservabilityInvalid(
+                "page_size must be between 1 and 100"
+            )
+        return self.repository.search_events(
+            normalized,
+            page=current_page,
+            page_size=current_page_size,
+        )
+
+    def root_cause(
+        self,
+        anchor_kind: Any,
+        anchor_uid: Any,
+        *,
+        max_hops: Any = 3,
+    ) -> RootCauseAnalysis:
+        graph = self.graph(
+            anchor_kind,
+            anchor_uid,
+            max_hops=max_hops,
+        )
+        nodes = {item.key: item for item in graph.nodes}
+        anchor_key = f"{graph.anchor_kind}:{graph.anchor_uid}"
+        anchor = nodes[anchor_key]
+        incoming = defaultdict(list)
+        for relation in graph.relations:
+            if relation.relation_type in CAUSAL_RELATION_TYPES:
+                incoming[f"{relation.to_kind}:{relation.to_uid}"].append(
+                    relation
+                )
+
+        candidates = []
+        queue = deque([(anchor_key, (), (anchor.uid,))])
+        visited_depth = {anchor_key: 0}
+        while queue:
+            current_key, path, node_path = queue.popleft()
+            current_node = nodes.get(current_key)
+            for relation in incoming.get(current_key, ()):
+                source_key = f"{relation.from_kind}:{relation.from_uid}"
+                source_node = nodes.get(source_key)
+                if source_node is None:
+                    continue
+                if (
+                    current_node is not None
+                    and current_node.occurred_at is not None
+                    and source_node.occurred_at is not None
+                    and source_node.occurred_at > current_node.occurred_at
+                ):
+                    continue
+                next_path = (relation, *path)
+                next_nodes = (source_node.uid, *node_path)
+                depth = len(next_path)
+                if (
+                    source_node.kind == "event"
+                    and source_node.uid != anchor.uid
+                ):
+                    evidence_refs = []
+                    for item in next_path:
+                        evidence_refs.extend(item.evidence_refs)
+                    evidence_refs.extend(source_node.evidence_refs)
+                    candidates.append(
+                        RootCauseCandidate(
+                            event_uid=source_node.uid,
+                            event_type=source_node.node_type,
+                            title=source_node.label,
+                            occurred_at=source_node.occurred_at,
+                            support_level=(
+                                "direct" if depth == 1 else "indirect"
+                            ),
+                            path_node_uids=next_nodes,
+                            path_relation_uids=tuple(
+                                item.uid for item in next_path
+                            ),
+                            path_relation_types=tuple(
+                                item.relation_type for item in next_path
+                            ),
+                            evidence_refs=tuple(evidence_refs[:20]),
+                        )
+                    )
+                if depth < int(max_hops) and (
+                    source_key not in visited_depth
+                    or depth < visited_depth[source_key]
+                ):
+                    visited_depth[source_key] = depth
+                    queue.append((source_key, next_path, next_nodes))
+
+        unique = []
+        seen = set()
+        for candidate in candidates:
+            if candidate.event_uid in seen:
+                continue
+            seen.add(candidate.event_uid)
+            unique.append(candidate)
+        if unique:
+            status = "supported_candidates"
+            conclusion = "发现有证据支持的根因候选,仍需设备专家确认"
+        else:
+            status = "insufficient_evidence"
+            conclusion = "证据不足,无法确认根因"
+        return RootCauseAnalysis(
+            analysis_status=status,
+            conclusion=conclusion,
+            anchor=anchor,
+            candidates=tuple(unique),
+            graph=graph,
+            limitations=(
+                "候选来自持久化关系证据,不等同于已确认根因。",
+                "未接入或未关联的运行事件不会出现在分析结果中。",
+                "结果不触发自动修复、维修计划或来源数据改写。",
+            ),
+            generated_at=self.clock(),
+        )
+
+
+__all__ = [
+    "DeviceObservabilityConflict",
+    "DeviceObservabilityInvalid",
+    "DeviceObservabilityNotFound",
+    "DeviceObservabilityService",
+    "EvidenceRelationRecord",
+    "GraphNode",
+    "GraphProjection",
+    "ObservabilityImportResult",
+    "OperationalEventRecord",
+    "RootCauseAnalysis",
+    "RootCauseCandidate",
+]

+ 359 - 0
app/core/data_research/device_observability_repository.py

@@ -0,0 +1,359 @@
+"""Canonical PostgreSQL persistence for WP09 device evidence graphs."""
+
+from __future__ import annotations
+
+from sqlalchemy import and_, func, or_
+
+from app.core.data_research.device_observability import (
+    EvidenceRelationRecord,
+    GraphNode,
+    GraphProjection,
+    OperationalEventRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceEvidenceRelation,
+    DeviceOperationalEvent,
+    DeviceQualityIssue,
+    IngestionSource,
+)
+
+
+class SqlAlchemyDeviceObservabilityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _event(model):
+        return OperationalEventRecord(
+            uid=str(model.uid),
+            source_uid=str(model.source_uid),
+            source_entity=model.source_entity,
+            source_code=model.source_code,
+            event_type=model.event_type,
+            asset_uid=str(model.asset_uid),
+            component_uid=(
+                str(model.component_uid) if model.component_uid else None
+            ),
+            title=model.title,
+            severity=model.severity,
+            status=model.status,
+            occurred_at=model.occurred_at,
+            ended_at=model.ended_at,
+            evidence_refs=tuple(
+                dict(item) for item in (model.evidence_refs or [])
+            ),
+            content_hash=model.content_hash,
+            created_by=str(model.created_by),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _relation(model):
+        return EvidenceRelationRecord(
+            uid=str(model.uid),
+            from_kind=model.from_kind,
+            from_uid=str(model.from_uid),
+            relation_type=model.relation_type,
+            to_kind=model.to_kind,
+            to_uid=str(model.to_uid),
+            evidence_refs=tuple(
+                dict(item) for item in (model.evidence_refs or [])
+            ),
+            source=model.source,
+            created_by=str(model.created_by),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _event_model(record):
+        return DeviceOperationalEvent(
+            uid=record.uid,
+            source_uid=record.source_uid,
+            source_entity=record.source_entity,
+            source_code=record.source_code,
+            event_type=record.event_type,
+            asset_uid=record.asset_uid,
+            component_uid=record.component_uid,
+            title=record.title,
+            severity=record.severity,
+            status=record.status,
+            occurred_at=record.occurred_at,
+            ended_at=record.ended_at,
+            evidence_refs=[dict(item) for item in record.evidence_refs],
+            content_hash=record.content_hash,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _relation_model(record):
+        return DeviceEvidenceRelation(
+            uid=record.uid,
+            from_kind=record.from_kind,
+            from_uid=record.from_uid,
+            relation_type=record.relation_type,
+            to_kind=record.to_kind,
+            to_uid=record.to_uid,
+            evidence_refs=[dict(item) for item in record.evidence_refs],
+            source=record.source,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    def source_exists(self, source_uid):
+        return bool(
+            self.session.query(IngestionSource.uid)
+            .filter_by(uid=source_uid, status="active")
+            .first()
+        )
+
+    def get_event_by_identity(
+        self,
+        source_uid,
+        source_entity,
+        source_code,
+    ):
+        model = (
+            self.session.query(DeviceOperationalEvent)
+            .filter_by(
+                source_uid=source_uid,
+                source_entity=source_entity,
+                source_code=source_code,
+            )
+            .first()
+        )
+        return self._event(model) if model is not None else None
+
+    def resolve_event_identity(
+        self,
+        source_uid,
+        source_entity,
+        source_code,
+    ):
+        row = (
+            self.session.query(DeviceOperationalEvent.uid)
+            .filter_by(
+                source_uid=source_uid,
+                source_entity=source_entity,
+                source_code=source_code,
+            )
+            .first()
+        )
+        return str(row[0]) if row is not None else None
+
+    def add_event(self, record):
+        model = self._event_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._event(model)
+
+    def get_relation(self, record):
+        model = (
+            self.session.query(DeviceEvidenceRelation)
+            .filter_by(
+                from_kind=record.from_kind,
+                from_uid=record.from_uid,
+                relation_type=record.relation_type,
+                to_kind=record.to_kind,
+                to_uid=record.to_uid,
+            )
+            .first()
+        )
+        return self._relation(model) if model is not None else None
+
+    def add_relation(self, record):
+        model = self._relation_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._relation(model)
+
+    def node_exists(self, kind, uid):
+        model_by_kind = {
+            "asset": DeviceAsset,
+            "event": DeviceOperationalEvent,
+            "quality_issue": DeviceQualityIssue,
+        }
+        model = model_by_kind.get(kind)
+        if model is None:
+            return False
+        return bool(self.session.query(model.uid).filter_by(uid=uid).first())
+
+    def _node(self, kind, uid):
+        if kind == "asset":
+            model = self.session.get(DeviceAsset, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="asset",
+                uid=str(model.uid),
+                node_type=model.asset_type,
+                label=model.name,
+                occurred_at=None,
+                evidence_refs=(
+                    {
+                        "type": "device_asset",
+                        "uid": str(model.uid),
+                        "version": int(model.current_version),
+                    },
+                ),
+            )
+        if kind == "event":
+            model = self.session.get(DeviceOperationalEvent, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="event",
+                uid=str(model.uid),
+                node_type=model.event_type,
+                label=model.title,
+                occurred_at=model.occurred_at,
+                evidence_refs=tuple(
+                    dict(item) for item in (model.evidence_refs or [])
+                ),
+            )
+        if kind == "quality_issue":
+            model = self.session.get(DeviceQualityIssue, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="quality_issue",
+                uid=str(model.uid),
+                node_type="quality_issue",
+                label=model.issue_code,
+                occurred_at=model.created_at,
+                evidence_refs=(
+                    {
+                        "type": "quality_issue",
+                        "uid": str(model.uid),
+                        "issue_code": model.issue_code,
+                        "rule_code": model.rule_code,
+                    },
+                ),
+            )
+        return None
+
+    @staticmethod
+    def _endpoint_filter(endpoints):
+        clauses = []
+        for kind, uid in endpoints:
+            clauses.extend(
+                (
+                    and_(
+                        DeviceEvidenceRelation.from_kind == kind,
+                        DeviceEvidenceRelation.from_uid == uid,
+                    ),
+                    and_(
+                        DeviceEvidenceRelation.to_kind == kind,
+                        DeviceEvidenceRelation.to_uid == uid,
+                    ),
+                )
+            )
+        return or_(*clauses)
+
+    def graph(
+        self,
+        anchor_kind,
+        anchor_uid,
+        *,
+        max_hops,
+        max_nodes,
+        max_edges,
+    ):
+        anchor = (str(anchor_kind), str(anchor_uid))
+        frontier = {anchor}
+        visited = {anchor}
+        nodes = {anchor: self._node(*anchor)}
+        relations = []
+        relation_uids = set()
+        truncated = False
+        for _ in range(max_hops):
+            if not frontier:
+                break
+            models = (
+                self.session.query(DeviceEvidenceRelation)
+                .filter(self._endpoint_filter(frontier))
+                .order_by(
+                    DeviceEvidenceRelation.created_at.asc(),
+                    DeviceEvidenceRelation.uid.asc(),
+                )
+                .all()
+            )
+            next_frontier = set()
+            for model in models:
+                if str(model.uid) in relation_uids:
+                    continue
+                if len(relations) >= max_edges:
+                    truncated = True
+                    break
+                relation = self._relation(model)
+                relation_uids.add(relation.uid)
+                relations.append(relation)
+                for endpoint in (
+                    (relation.from_kind, relation.from_uid),
+                    (relation.to_kind, relation.to_uid),
+                ):
+                    if endpoint in visited:
+                        continue
+                    if len(nodes) >= max_nodes:
+                        truncated = True
+                        continue
+                    node = self._node(*endpoint)
+                    if node is None:
+                        continue
+                    visited.add(endpoint)
+                    next_frontier.add(endpoint)
+                    nodes[endpoint] = node
+            frontier = next_frontier
+        return GraphProjection(
+            anchor_kind=anchor[0],
+            anchor_uid=anchor[1],
+            nodes=tuple(item for item in nodes.values() if item is not None),
+            relations=tuple(relations),
+            truncated=truncated,
+        )
+
+    def search_events(self, filters, *, page, page_size):
+        query = self.session.query(DeviceOperationalEvent)
+        if filters.get("event_type"):
+            query = query.filter(
+                DeviceOperationalEvent.event_type == filters["event_type"]
+            )
+        if filters.get("severity"):
+            query = query.filter(
+                DeviceOperationalEvent.severity == filters["severity"]
+            )
+        if filters.get("status"):
+            query = query.filter(
+                DeviceOperationalEvent.status == filters["status"]
+            )
+        if filters.get("asset_uid"):
+            query = query.filter(
+                DeviceOperationalEvent.asset_uid == filters["asset_uid"]
+            )
+        if filters.get("source_uid"):
+            query = query.filter(
+                DeviceOperationalEvent.source_uid == filters["source_uid"]
+            )
+        if filters.get("keyword"):
+            pattern = f"%{filters['keyword']}%"
+            query = query.filter(
+                or_(
+                    DeviceOperationalEvent.title.ilike(pattern),
+                    DeviceOperationalEvent.source_code.ilike(pattern),
+                )
+            )
+        total = int(
+            query.with_entities(func.count(DeviceOperationalEvent.uid)).scalar()
+            or 0
+        )
+        models = (
+            query.order_by(
+                DeviceOperationalEvent.occurred_at.desc(),
+                DeviceOperationalEvent.uid.asc(),
+            )
+            .offset((page - 1) * page_size)
+            .limit(page_size)
+            .all()
+        )
+        return [self._event(model) for model in models], total

+ 15 - 0
app/core/data_research/errors.py

@@ -138,3 +138,18 @@ class QualityIssueNotFound(DataResearchError):
 class QualityIssueConflict(DataResearchError):
     code = "QUALITY_ISSUE_CONFLICT"
     http_status = 409
+
+
+class DeviceObservabilityInvalid(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_INVALID"
+    http_status = 422
+
+
+class DeviceObservabilityNotFound(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceObservabilityConflict(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_CONFLICT"
+    http_status = 409

+ 7 - 0
app/core/system/permissions.py

@@ -43,6 +43,7 @@ DEVICE_QUALITY_EXECUTE = "device-quality:execute"
 DEVICE_QUALITY_PUBLISH = "device-quality:publish"
 QUALITY_ISSUES_EDIT = "quality-issues:edit"
 QUALITY_ISSUES_REVIEW = "quality-issues:review"
+DEVICE_OBSERVABILITY_EDIT = "device-observability:edit"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -67,6 +68,7 @@ ROLE_PERMISSIONS = {
             DEVICE_QUALITY_EDIT,
             DEVICE_QUALITY_EXECUTE,
             QUALITY_ISSUES_EDIT,
+            DEVICE_OBSERVABILITY_EDIT,
         }
     ),
     "admin": frozenset(
@@ -107,6 +109,7 @@ ROLE_PERMISSIONS = {
             DEVICE_QUALITY_PUBLISH,
             QUALITY_ISSUES_EDIT,
             QUALITY_ISSUES_REVIEW,
+            DEVICE_OBSERVABILITY_EDIT,
         }
     ),
 }
@@ -183,6 +186,10 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path.endswith("/review") or path.endswith("/rollback"):
             return (DEVICE_ENTITIES_REVIEW,)
         return (DEVICE_ENTITIES_EDIT,)
+    if path.startswith("/api/development/v1/device-observability"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        return (DEVICE_OBSERVABILITY_EDIT,)
     if path.startswith("/api/development/v1/device-quality"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 122 - 0
app/models/data_research.py

@@ -1162,6 +1162,128 @@ class DeviceQualityIssueTimeline(db.Model):
     created_at = db.Column(db.DateTime(timezone=True), nullable=False)
 
 
+class DeviceOperationalEvent(db.Model):
+    __tablename__ = "device_operational_events"
+    __table_args__ = (
+        db.CheckConstraint(
+            "event_type IN ('alarm','fault','maintenance','downtime')",
+            name="ck_device_operational_event_type",
+        ),
+        db.CheckConstraint(
+            "severity IN ('info','warning','error','critical')",
+            name="ck_device_operational_event_severity",
+        ),
+        db.CheckConstraint(
+            "status IN ('observed','resolved')",
+            name="ck_device_operational_event_status",
+        ),
+        db.CheckConstraint(
+            "ended_at IS NULL OR ended_at >= occurred_at",
+            name="ck_device_operational_event_window",
+        ),
+        db.UniqueConstraint(
+            "source_uid",
+            "source_entity",
+            "source_code",
+            name="uq_device_operational_event_source_identity",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    source_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.ingestion_sources.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    source_entity = db.Column(db.String(300), nullable=False)
+    source_code = db.Column(db.String(300), nullable=False)
+    event_type = db.Column(db.String(30), nullable=False)
+    asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    component_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+    )
+    title = db.Column(db.String(500), nullable=False)
+    severity = db.Column(db.String(20), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    occurred_at = db.Column(db.DateTime(timezone=True), nullable=False)
+    ended_at = db.Column(db.DateTime(timezone=True))
+    evidence_refs = db.Column(JSONB, nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceEvidenceRelation(db.Model):
+    __tablename__ = "device_evidence_relations"
+    __table_args__ = (
+        db.CheckConstraint(
+            "from_kind IN ('asset','event','quality_issue')",
+            name="ck_device_evidence_relation_from_kind",
+        ),
+        db.CheckConstraint(
+            "to_kind IN ('asset','event','quality_issue')",
+            name="ck_device_evidence_relation_to_kind",
+        ),
+        db.CheckConstraint(
+            "relation_type IN ("
+            "'part_of','occurred_on','indicates','triggered',"
+            "'addresses','impacts','evidences','related_to'"
+            ")",
+            name="ck_device_evidence_relation_type",
+        ),
+        db.CheckConstraint(
+            "source IN ('imported','manual','derived')",
+            name="ck_device_evidence_relation_source",
+        ),
+        db.CheckConstraint(
+            "NOT (from_kind = to_kind AND from_uid = to_uid)",
+            name="ck_device_evidence_relation_distinct_nodes",
+        ),
+        db.UniqueConstraint(
+            "from_kind",
+            "from_uid",
+            "relation_type",
+            "to_kind",
+            "to_uid",
+            name="uq_device_evidence_relation_identity",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    from_kind = db.Column(db.String(30), nullable=False)
+    from_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    relation_type = db.Column(db.String(40), nullable=False)
+    to_kind = db.Column(db.String(30), nullable=False)
+    to_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    evidence_refs = db.Column(JSONB, nullable=False)
+    source = db.Column(db.String(30), nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 198 - 0
deployment/app/api/data_development/routes.py

@@ -300,6 +300,21 @@ def get_quality_issue_service():
     )
 
 
+def get_device_observability_service():
+    from app.core.data_research.device_observability import (
+        DeviceObservabilityService,
+    )
+    from app.core.data_research.device_observability_repository import (
+        SqlAlchemyDeviceObservabilityRepository,
+    )
+
+    return DeviceObservabilityService(
+        SqlAlchemyDeviceObservabilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def get_candidate_decision_service():
     from app.core.data_research.candidate_decisions import CandidateDecisionService
     from app.core.data_research.data_elements import DataElementService
@@ -916,6 +931,94 @@ def _quality_issue_timeline(record):
     }
 
 
+def _device_operational_event(record):
+    return {
+        "uid": str(record.uid),
+        "source_uid": str(record.source_uid),
+        "source_entity": record.source_entity,
+        "source_code": record.source_code,
+        "event_type": record.event_type,
+        "asset_uid": str(record.asset_uid),
+        "component_uid": (
+            str(record.component_uid) if record.component_uid else None
+        ),
+        "title": record.title,
+        "severity": record.severity,
+        "status": record.status,
+        "occurred_at": _iso(record.occurred_at),
+        "ended_at": _iso(record.ended_at),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "content_hash": record.content_hash,
+        "created_by": str(record.created_by),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_evidence_relation(record):
+    return {
+        "uid": str(record.uid),
+        "from_kind": record.from_kind,
+        "from_uid": str(record.from_uid),
+        "relation_type": record.relation_type,
+        "to_kind": record.to_kind,
+        "to_uid": str(record.to_uid),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "source": record.source,
+        "created_by": str(record.created_by),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_graph_node(record):
+    return {
+        "kind": record.kind,
+        "uid": str(record.uid),
+        "node_type": record.node_type,
+        "label": record.label,
+        "occurred_at": _iso(record.occurred_at),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+    }
+
+
+def _device_evidence_graph(record):
+    return {
+        "anchor_kind": record.anchor_kind,
+        "anchor_uid": str(record.anchor_uid),
+        "nodes": [_device_graph_node(item) for item in record.nodes],
+        "relations": [
+            _device_evidence_relation(item) for item in record.relations
+        ],
+        "truncated": bool(record.truncated),
+    }
+
+
+def _root_cause_analysis(record):
+    return {
+        "analysis_status": record.analysis_status,
+        "conclusion": record.conclusion,
+        "anchor": _device_graph_node(record.anchor),
+        "candidates": [
+            {
+                "event_uid": str(item.event_uid),
+                "event_type": item.event_type,
+                "title": item.title,
+                "occurred_at": _iso(item.occurred_at),
+                "support_level": item.support_level,
+                "path_node_uids": list(item.path_node_uids),
+                "path_relation_uids": list(item.path_relation_uids),
+                "path_relation_types": list(item.path_relation_types),
+                "evidence_refs": [
+                    dict(evidence) for evidence in item.evidence_refs
+                ],
+            }
+            for item in record.candidates
+        ],
+        "graph": _device_evidence_graph(record.graph),
+        "limitations": list(record.limitations),
+        "generated_at": _iso(record.generated_at),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -1948,6 +2051,101 @@ def reopen_quality_issue(issue_uid):
         return _error(error)
 
 
+@bp.route("/device-observability/events", methods=["GET"])
+def list_device_operational_events():
+    filters = {
+        name: request.args.get(name)
+        for name in (
+            "event_type",
+            "severity",
+            "status",
+            "asset_uid",
+            "source_uid",
+            "keyword",
+        )
+        if request.args.get(name)
+    }
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_observability_service().events(
+            filters,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_operational_event(item)
+                        for item in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/import", methods=["POST"])
+def import_device_operational_evidence():
+    try:
+        result = get_device_observability_service().import_evidence(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        data = {
+            "events": [
+                _device_operational_event(item) for item in result.events
+            ],
+            "relations": [
+                _device_evidence_relation(item)
+                for item in result.relations
+            ],
+            "created_events": int(result.created_events),
+            "existing_events": int(result.existing_events),
+            "created_relations": int(result.created_relations),
+            "existing_relations": int(result.existing_relations),
+        }
+        status = (
+            201
+            if result.created_events or result.created_relations
+            else 200
+        )
+        return jsonify(success(data)), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/graph", methods=["GET"])
+def get_device_evidence_graph():
+    try:
+        record = get_device_observability_service().graph(
+            request.args.get("anchor_kind"),
+            request.args.get("anchor_uid"),
+            max_hops=request.args.get("max_hops", 2),
+        )
+        return jsonify(success(_device_evidence_graph(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/root-cause", methods=["GET"])
+def get_device_root_cause():
+    try:
+        record = get_device_observability_service().root_cause(
+            request.args.get("anchor_kind"),
+            request.args.get("anchor_uid"),
+            max_hops=request.args.get("max_hops", 3),
+        )
+        return jsonify(success(_root_cause_analysis(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 766 - 0
deployment/app/core/data_research/device_observability.py

@@ -0,0 +1,766 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from collections import defaultdict, deque
+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 (
+    DeviceObservabilityConflict,
+    DeviceObservabilityInvalid,
+    DeviceObservabilityNotFound,
+)
+
+EVENT_TYPES = frozenset({"alarm", "fault", "maintenance", "downtime"})
+EVENT_SEVERITIES = frozenset({"info", "warning", "error", "critical"})
+EVENT_STATUSES = frozenset({"observed", "resolved"})
+NODE_KINDS = frozenset({"asset", "event", "quality_issue"})
+RELATION_TYPES = frozenset(
+    {
+        "part_of",
+        "occurred_on",
+        "indicates",
+        "triggered",
+        "addresses",
+        "impacts",
+        "evidences",
+        "related_to",
+    }
+)
+CAUSAL_RELATION_TYPES = frozenset({"indicates", "triggered", "evidences"})
+SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "encrypted_payload",
+        "phone",
+        "mobile",
+        "email",
+        "id_number",
+    }
+)
+
+
+@dataclass(frozen=True)
+class OperationalEventRecord:
+    uid: str
+    source_uid: str
+    source_entity: str
+    source_code: str
+    event_type: str
+    asset_uid: str
+    component_uid: str | None
+    title: str
+    severity: str
+    status: str
+    occurred_at: datetime
+    ended_at: datetime | None
+    evidence_refs: tuple[dict[str, Any], ...]
+    content_hash: str
+    created_by: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class EvidenceRelationRecord:
+    uid: str
+    from_kind: str
+    from_uid: str
+    relation_type: str
+    to_kind: str
+    to_uid: str
+    evidence_refs: tuple[dict[str, Any], ...]
+    source: str
+    created_by: str
+    created_at: datetime | None = None
+
+    @property
+    def identity(self) -> str:
+        raw = "|".join(
+            (
+                self.from_kind,
+                self.from_uid,
+                self.relation_type,
+                self.to_kind,
+                self.to_uid,
+            )
+        )
+        return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+@dataclass(frozen=True)
+class GraphNode:
+    kind: str
+    uid: str
+    node_type: str
+    label: str
+    occurred_at: datetime | None
+    evidence_refs: tuple[dict[str, Any], ...]
+
+    @property
+    def key(self) -> str:
+        return f"{self.kind}:{self.uid}"
+
+
+@dataclass(frozen=True)
+class GraphProjection:
+    anchor_kind: str
+    anchor_uid: str
+    nodes: tuple[GraphNode, ...]
+    relations: tuple[EvidenceRelationRecord, ...]
+    truncated: bool
+
+
+@dataclass(frozen=True)
+class RootCauseCandidate:
+    event_uid: str
+    event_type: str
+    title: str
+    occurred_at: datetime | None
+    support_level: str
+    path_node_uids: tuple[str, ...]
+    path_relation_uids: tuple[str, ...]
+    path_relation_types: tuple[str, ...]
+    evidence_refs: tuple[dict[str, Any], ...]
+
+
+@dataclass(frozen=True)
+class RootCauseAnalysis:
+    analysis_status: str
+    conclusion: str
+    anchor: GraphNode
+    candidates: tuple[RootCauseCandidate, ...]
+    graph: GraphProjection
+    limitations: tuple[str, ...]
+    generated_at: datetime
+
+
+@dataclass(frozen=True)
+class ObservabilityImportResult:
+    events: tuple[OperationalEventRecord, ...]
+    relations: tuple[EvidenceRelationRecord, ...]
+    created_events: int
+    existing_events: int
+    created_relations: int
+    existing_relations: int
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(UUID(str(value)))
+    except (AttributeError, TypeError, ValueError) as exc:
+        raise DeviceObservabilityInvalid(f"{field} must be a UUID") from exc
+
+
+def _text(value: Any, field: str, maximum: int) -> str:
+    normalized = str(value or "").strip()
+    if not normalized:
+        raise DeviceObservabilityInvalid(f"{field} is required")
+    if len(normalized) > maximum:
+        raise DeviceObservabilityInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return normalized
+
+
+def _optional_text(value: Any, field: str, maximum: int) -> str | None:
+    normalized = str(value or "").strip()
+    if not normalized:
+        return None
+    if len(normalized) > maximum:
+        raise DeviceObservabilityInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return normalized
+
+
+def _timestamp(value: Any, field: str, *, required: bool) -> datetime | None:
+    if value in (None, ""):
+        if required:
+            raise DeviceObservabilityInvalid(f"{field} is required")
+        return None
+    if isinstance(value, datetime):
+        parsed = value
+    else:
+        try:
+            parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                f"{field} must be ISO-8601"
+            ) from exc
+    if parsed.tzinfo is None:
+        raise DeviceObservabilityInvalid(f"{field} must include a timezone")
+    return parsed
+
+
+def _reject_secrets(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().casefold() in SECRET_KEYS:
+                raise DeviceObservabilityInvalid(
+                    "evidence contains a secret or direct identifier"
+                )
+            _reject_secrets(item)
+    elif isinstance(value, (list, tuple)):
+        if len(value) > 100:
+            raise DeviceObservabilityInvalid(
+                "evidence arrays cannot exceed 100 items"
+            )
+        for item in value:
+            _reject_secrets(item)
+
+
+def _evidence(value: Any) -> tuple[dict[str, Any], ...]:
+    if value in (None, {}):
+        return ()
+    if not isinstance(value, dict):
+        raise DeviceObservabilityInvalid("evidence must be an object")
+    _reject_secrets(value)
+    serialized = json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    if len(serialized.encode("utf-8")) > 20_000:
+        raise DeviceObservabilityInvalid("evidence exceeds 20000 bytes")
+    return ({"type": "source_evidence", "data": value},)
+
+
+def _event_hash(payload: dict[str, Any]) -> str:
+    serialized = json.dumps(
+        payload,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+
+
+class DeviceObservabilityService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory=new_governance_uid,
+        commit=lambda: None,
+        rollback=lambda: None,
+        clock=now_china,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.commit = commit
+        self.rollback = rollback
+        self.clock = clock
+
+    def import_evidence(
+        self,
+        payload: Any,
+        actor_uid: Any,
+    ) -> ObservabilityImportResult:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid("payload must be an object")
+        source_uid = _uuid(payload.get("source_uid"), "source_uid")
+        actor = _uuid(actor_uid, "actor_uid")
+        if not self.repository.source_exists(source_uid):
+            raise DeviceObservabilityInvalid(
+                "source_uid must reference an active source"
+            )
+        events_payload = payload.get("events", [])
+        relations_payload = payload.get("relations", [])
+        if not isinstance(events_payload, list) or not isinstance(
+            relations_payload, list
+        ):
+            raise DeviceObservabilityInvalid(
+                "events and relations must be arrays"
+            )
+        if not events_payload and not relations_payload:
+            raise DeviceObservabilityInvalid(
+                "at least one event or relation is required"
+            )
+        if len(events_payload) > 200:
+            raise DeviceObservabilityInvalid("events cannot exceed 200 items")
+        if len(relations_payload) > 500:
+            raise DeviceObservabilityInvalid(
+                "relations cannot exceed 500 items"
+            )
+
+        events = []
+        relations = []
+        created_events = existing_events = 0
+        created_relations = existing_relations = 0
+        try:
+            for item in events_payload:
+                normalized = self._normalize_event(
+                    item,
+                    source_uid=source_uid,
+                    actor_uid=actor,
+                )
+                current = self.repository.get_event_by_identity(
+                    source_uid,
+                    normalized.source_entity,
+                    normalized.source_code,
+                )
+                if current is not None:
+                    if current.content_hash != normalized.content_hash:
+                        raise DeviceObservabilityConflict(
+                            "event source identity already exists "
+                            "with different content"
+                        )
+                    events.append(current)
+                    existing_events += 1
+                    continue
+                self.repository.add_event(normalized)
+                events.append(normalized)
+                created_events += 1
+
+            for item in relations_payload:
+                normalized = self._normalize_relation(
+                    item,
+                    source_uid=source_uid,
+                    actor_uid=actor,
+                )
+                current = self.repository.get_relation(normalized)
+                if current is not None:
+                    if (
+                        current.evidence_refs != normalized.evidence_refs
+                        or current.source != normalized.source
+                    ):
+                        raise DeviceObservabilityConflict(
+                            "relation identity already exists "
+                            "with different evidence"
+                        )
+                    relations.append(current)
+                    existing_relations += 1
+                    continue
+                self.repository.add_relation(normalized)
+                relations.append(normalized)
+                created_relations += 1
+            self.commit()
+        except Exception:
+            self.rollback()
+            raise
+        return ObservabilityImportResult(
+            events=tuple(events),
+            relations=tuple(relations),
+            created_events=created_events,
+            existing_events=existing_events,
+            created_relations=created_relations,
+            existing_relations=existing_relations,
+        )
+
+    def _normalize_event(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        actor_uid: str,
+    ) -> OperationalEventRecord:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(
+                "each event must be an object"
+            )
+        event_type = _text(payload.get("event_type"), "event_type", 30)
+        if event_type not in EVENT_TYPES:
+            raise DeviceObservabilityInvalid("event_type is not supported")
+        severity = _text(
+            payload.get("severity", "warning"),
+            "severity",
+            20,
+        )
+        if severity not in EVENT_SEVERITIES:
+            raise DeviceObservabilityInvalid("severity is not supported")
+        status = _text(payload.get("status", "observed"), "status", 20)
+        if status not in EVENT_STATUSES:
+            raise DeviceObservabilityInvalid("status is not supported")
+        occurred_at = _timestamp(
+            payload.get("occurred_at"),
+            "occurred_at",
+            required=True,
+        )
+        ended_at = _timestamp(
+            payload.get("ended_at"),
+            "ended_at",
+            required=False,
+        )
+        if ended_at is not None and ended_at < occurred_at:
+            raise DeviceObservabilityInvalid(
+                "ended_at cannot be before occurred_at"
+            )
+        asset_uid = _uuid(payload.get("asset_uid"), "asset_uid")
+        if not self.repository.node_exists("asset", asset_uid):
+            raise DeviceObservabilityInvalid("asset_uid does not exist")
+        component_uid = None
+        if payload.get("component_uid"):
+            component_uid = _uuid(
+                payload.get("component_uid"),
+                "component_uid",
+            )
+            if not self.repository.node_exists("asset", component_uid):
+                raise DeviceObservabilityInvalid(
+                    "component_uid does not exist"
+                )
+        evidence_refs = _evidence(payload.get("evidence"))
+        source_entity = _text(
+            payload.get("source_entity"),
+            "source_entity",
+            300,
+        )
+        source_code = _text(
+            payload.get("source_code"),
+            "source_code",
+            300,
+        )
+        title = _text(payload.get("title"), "title", 500)
+        hash_payload = {
+            "source_uid": source_uid,
+            "source_entity": source_entity,
+            "source_code": source_code,
+            "event_type": event_type,
+            "asset_uid": asset_uid,
+            "component_uid": component_uid,
+            "title": title,
+            "severity": severity,
+            "status": status,
+            "occurred_at": occurred_at.isoformat(),
+            "ended_at": ended_at.isoformat() if ended_at else None,
+            "evidence_refs": evidence_refs,
+        }
+        return OperationalEventRecord(
+            uid=str(self.uid_factory()),
+            source_uid=source_uid,
+            source_entity=source_entity,
+            source_code=source_code,
+            event_type=event_type,
+            asset_uid=asset_uid,
+            component_uid=component_uid,
+            title=title,
+            severity=severity,
+            status=status,
+            occurred_at=occurred_at,
+            ended_at=ended_at,
+            evidence_refs=evidence_refs,
+            created_by=actor_uid,
+            created_at=self.clock(),
+            content_hash=_event_hash(hash_payload),
+        )
+
+    def _endpoint(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        field: str,
+    ) -> tuple[str, str]:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(f"{field} must be an object")
+        kind = _text(payload.get("kind"), f"{field}.kind", 30)
+        if kind not in NODE_KINDS:
+            raise DeviceObservabilityInvalid(
+                f"{field}.kind is not supported"
+            )
+        if kind == "event" and not payload.get("uid"):
+            source_entity = _text(
+                payload.get("source_entity"),
+                f"{field}.source_entity",
+                300,
+            )
+            source_code = _text(
+                payload.get("source_code"),
+                f"{field}.source_code",
+                300,
+            )
+            uid = self.repository.resolve_event_identity(
+                source_uid,
+                source_entity,
+                source_code,
+            )
+            if not uid:
+                raise DeviceObservabilityInvalid(
+                    f"{field} event identity does not exist"
+                )
+        else:
+            uid = _uuid(payload.get("uid"), f"{field}.uid")
+        if not self.repository.node_exists(kind, uid):
+            raise DeviceObservabilityInvalid(f"{field} node does not exist")
+        return kind, uid
+
+    def _normalize_relation(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        actor_uid: str,
+    ) -> EvidenceRelationRecord:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(
+                "each relation must be an object"
+            )
+        from_kind, from_uid = self._endpoint(
+            payload.get("from"),
+            source_uid=source_uid,
+            field="from",
+        )
+        to_kind, to_uid = self._endpoint(
+            payload.get("to"),
+            source_uid=source_uid,
+            field="to",
+        )
+        if from_kind == to_kind and from_uid == to_uid:
+            raise DeviceObservabilityInvalid(
+                "relation endpoints must be different"
+            )
+        relation_type = _text(
+            payload.get("relation_type"),
+            "relation_type",
+            40,
+        )
+        if relation_type not in RELATION_TYPES:
+            raise DeviceObservabilityInvalid(
+                "relation_type is not supported"
+            )
+        source = _optional_text(payload.get("source"), "source", 30)
+        if source is None:
+            source = "imported"
+        if source not in {"imported", "manual", "derived"}:
+            raise DeviceObservabilityInvalid("source is not supported")
+        return EvidenceRelationRecord(
+            uid=str(self.uid_factory()),
+            from_kind=from_kind,
+            from_uid=from_uid,
+            relation_type=relation_type,
+            to_kind=to_kind,
+            to_uid=to_uid,
+            evidence_refs=_evidence(payload.get("evidence")),
+            source=source,
+            created_by=actor_uid,
+            created_at=self.clock(),
+        )
+
+    def graph(
+        self,
+        anchor_kind: Any,
+        anchor_uid: Any,
+        *,
+        max_hops: Any = 2,
+    ) -> GraphProjection:
+        kind = _text(anchor_kind, "anchor_kind", 30)
+        if kind not in NODE_KINDS:
+            raise DeviceObservabilityInvalid(
+                "anchor_kind is not supported"
+            )
+        uid = _uuid(anchor_uid, "anchor_uid")
+        try:
+            hops = int(max_hops)
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                "max_hops must be an integer"
+            ) from exc
+        if hops < 1 or hops > 3:
+            raise DeviceObservabilityInvalid(
+                "max_hops must be between 1 and 3"
+            )
+        if not self.repository.node_exists(kind, uid):
+            raise DeviceObservabilityNotFound("anchor node does not exist")
+        return self.repository.graph(
+            kind,
+            uid,
+            max_hops=hops,
+            max_nodes=100,
+            max_edges=200,
+        )
+
+    def events(
+        self,
+        filters: Any,
+        *,
+        page: Any = 1,
+        page_size: Any = 20,
+    ):
+        if not isinstance(filters, dict):
+            raise DeviceObservabilityInvalid("filters must be an object")
+        normalized = {}
+        event_type = _optional_text(
+            filters.get("event_type"),
+            "event_type",
+            30,
+        )
+        if event_type:
+            if event_type not in EVENT_TYPES:
+                raise DeviceObservabilityInvalid(
+                    "event_type is not supported"
+                )
+            normalized["event_type"] = event_type
+        severity = _optional_text(
+            filters.get("severity"),
+            "severity",
+            20,
+        )
+        if severity:
+            if severity not in EVENT_SEVERITIES:
+                raise DeviceObservabilityInvalid(
+                    "severity is not supported"
+                )
+            normalized["severity"] = severity
+        status = _optional_text(filters.get("status"), "status", 20)
+        if status:
+            if status not in EVENT_STATUSES:
+                raise DeviceObservabilityInvalid("status is not supported")
+            normalized["status"] = status
+        for field in ("asset_uid", "source_uid"):
+            if filters.get(field):
+                normalized[field] = _uuid(filters[field], field)
+        keyword = _optional_text(filters.get("keyword"), "keyword", 300)
+        if keyword:
+            normalized["keyword"] = keyword
+        try:
+            current_page = int(page)
+            current_page_size = int(page_size)
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                "page and page_size must be integers"
+            ) from exc
+        if current_page < 1:
+            raise DeviceObservabilityInvalid("page must be positive")
+        if current_page_size < 1 or current_page_size > 100:
+            raise DeviceObservabilityInvalid(
+                "page_size must be between 1 and 100"
+            )
+        return self.repository.search_events(
+            normalized,
+            page=current_page,
+            page_size=current_page_size,
+        )
+
+    def root_cause(
+        self,
+        anchor_kind: Any,
+        anchor_uid: Any,
+        *,
+        max_hops: Any = 3,
+    ) -> RootCauseAnalysis:
+        graph = self.graph(
+            anchor_kind,
+            anchor_uid,
+            max_hops=max_hops,
+        )
+        nodes = {item.key: item for item in graph.nodes}
+        anchor_key = f"{graph.anchor_kind}:{graph.anchor_uid}"
+        anchor = nodes[anchor_key]
+        incoming = defaultdict(list)
+        for relation in graph.relations:
+            if relation.relation_type in CAUSAL_RELATION_TYPES:
+                incoming[f"{relation.to_kind}:{relation.to_uid}"].append(
+                    relation
+                )
+
+        candidates = []
+        queue = deque([(anchor_key, (), (anchor.uid,))])
+        visited_depth = {anchor_key: 0}
+        while queue:
+            current_key, path, node_path = queue.popleft()
+            current_node = nodes.get(current_key)
+            for relation in incoming.get(current_key, ()):
+                source_key = f"{relation.from_kind}:{relation.from_uid}"
+                source_node = nodes.get(source_key)
+                if source_node is None:
+                    continue
+                if (
+                    current_node is not None
+                    and current_node.occurred_at is not None
+                    and source_node.occurred_at is not None
+                    and source_node.occurred_at > current_node.occurred_at
+                ):
+                    continue
+                next_path = (relation, *path)
+                next_nodes = (source_node.uid, *node_path)
+                depth = len(next_path)
+                if (
+                    source_node.kind == "event"
+                    and source_node.uid != anchor.uid
+                ):
+                    evidence_refs = []
+                    for item in next_path:
+                        evidence_refs.extend(item.evidence_refs)
+                    evidence_refs.extend(source_node.evidence_refs)
+                    candidates.append(
+                        RootCauseCandidate(
+                            event_uid=source_node.uid,
+                            event_type=source_node.node_type,
+                            title=source_node.label,
+                            occurred_at=source_node.occurred_at,
+                            support_level=(
+                                "direct" if depth == 1 else "indirect"
+                            ),
+                            path_node_uids=next_nodes,
+                            path_relation_uids=tuple(
+                                item.uid for item in next_path
+                            ),
+                            path_relation_types=tuple(
+                                item.relation_type for item in next_path
+                            ),
+                            evidence_refs=tuple(evidence_refs[:20]),
+                        )
+                    )
+                if depth < int(max_hops) and (
+                    source_key not in visited_depth
+                    or depth < visited_depth[source_key]
+                ):
+                    visited_depth[source_key] = depth
+                    queue.append((source_key, next_path, next_nodes))
+
+        unique = []
+        seen = set()
+        for candidate in candidates:
+            if candidate.event_uid in seen:
+                continue
+            seen.add(candidate.event_uid)
+            unique.append(candidate)
+        if unique:
+            status = "supported_candidates"
+            conclusion = "发现有证据支持的根因候选,仍需设备专家确认"
+        else:
+            status = "insufficient_evidence"
+            conclusion = "证据不足,无法确认根因"
+        return RootCauseAnalysis(
+            analysis_status=status,
+            conclusion=conclusion,
+            anchor=anchor,
+            candidates=tuple(unique),
+            graph=graph,
+            limitations=(
+                "候选来自持久化关系证据,不等同于已确认根因。",
+                "未接入或未关联的运行事件不会出现在分析结果中。",
+                "结果不触发自动修复、维修计划或来源数据改写。",
+            ),
+            generated_at=self.clock(),
+        )
+
+
+__all__ = [
+    "DeviceObservabilityConflict",
+    "DeviceObservabilityInvalid",
+    "DeviceObservabilityNotFound",
+    "DeviceObservabilityService",
+    "EvidenceRelationRecord",
+    "GraphNode",
+    "GraphProjection",
+    "ObservabilityImportResult",
+    "OperationalEventRecord",
+    "RootCauseAnalysis",
+    "RootCauseCandidate",
+]

+ 359 - 0
deployment/app/core/data_research/device_observability_repository.py

@@ -0,0 +1,359 @@
+"""Canonical PostgreSQL persistence for WP09 device evidence graphs."""
+
+from __future__ import annotations
+
+from sqlalchemy import and_, func, or_
+
+from app.core.data_research.device_observability import (
+    EvidenceRelationRecord,
+    GraphNode,
+    GraphProjection,
+    OperationalEventRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceEvidenceRelation,
+    DeviceOperationalEvent,
+    DeviceQualityIssue,
+    IngestionSource,
+)
+
+
+class SqlAlchemyDeviceObservabilityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _event(model):
+        return OperationalEventRecord(
+            uid=str(model.uid),
+            source_uid=str(model.source_uid),
+            source_entity=model.source_entity,
+            source_code=model.source_code,
+            event_type=model.event_type,
+            asset_uid=str(model.asset_uid),
+            component_uid=(
+                str(model.component_uid) if model.component_uid else None
+            ),
+            title=model.title,
+            severity=model.severity,
+            status=model.status,
+            occurred_at=model.occurred_at,
+            ended_at=model.ended_at,
+            evidence_refs=tuple(
+                dict(item) for item in (model.evidence_refs or [])
+            ),
+            content_hash=model.content_hash,
+            created_by=str(model.created_by),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _relation(model):
+        return EvidenceRelationRecord(
+            uid=str(model.uid),
+            from_kind=model.from_kind,
+            from_uid=str(model.from_uid),
+            relation_type=model.relation_type,
+            to_kind=model.to_kind,
+            to_uid=str(model.to_uid),
+            evidence_refs=tuple(
+                dict(item) for item in (model.evidence_refs or [])
+            ),
+            source=model.source,
+            created_by=str(model.created_by),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _event_model(record):
+        return DeviceOperationalEvent(
+            uid=record.uid,
+            source_uid=record.source_uid,
+            source_entity=record.source_entity,
+            source_code=record.source_code,
+            event_type=record.event_type,
+            asset_uid=record.asset_uid,
+            component_uid=record.component_uid,
+            title=record.title,
+            severity=record.severity,
+            status=record.status,
+            occurred_at=record.occurred_at,
+            ended_at=record.ended_at,
+            evidence_refs=[dict(item) for item in record.evidence_refs],
+            content_hash=record.content_hash,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _relation_model(record):
+        return DeviceEvidenceRelation(
+            uid=record.uid,
+            from_kind=record.from_kind,
+            from_uid=record.from_uid,
+            relation_type=record.relation_type,
+            to_kind=record.to_kind,
+            to_uid=record.to_uid,
+            evidence_refs=[dict(item) for item in record.evidence_refs],
+            source=record.source,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    def source_exists(self, source_uid):
+        return bool(
+            self.session.query(IngestionSource.uid)
+            .filter_by(uid=source_uid, status="active")
+            .first()
+        )
+
+    def get_event_by_identity(
+        self,
+        source_uid,
+        source_entity,
+        source_code,
+    ):
+        model = (
+            self.session.query(DeviceOperationalEvent)
+            .filter_by(
+                source_uid=source_uid,
+                source_entity=source_entity,
+                source_code=source_code,
+            )
+            .first()
+        )
+        return self._event(model) if model is not None else None
+
+    def resolve_event_identity(
+        self,
+        source_uid,
+        source_entity,
+        source_code,
+    ):
+        row = (
+            self.session.query(DeviceOperationalEvent.uid)
+            .filter_by(
+                source_uid=source_uid,
+                source_entity=source_entity,
+                source_code=source_code,
+            )
+            .first()
+        )
+        return str(row[0]) if row is not None else None
+
+    def add_event(self, record):
+        model = self._event_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._event(model)
+
+    def get_relation(self, record):
+        model = (
+            self.session.query(DeviceEvidenceRelation)
+            .filter_by(
+                from_kind=record.from_kind,
+                from_uid=record.from_uid,
+                relation_type=record.relation_type,
+                to_kind=record.to_kind,
+                to_uid=record.to_uid,
+            )
+            .first()
+        )
+        return self._relation(model) if model is not None else None
+
+    def add_relation(self, record):
+        model = self._relation_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._relation(model)
+
+    def node_exists(self, kind, uid):
+        model_by_kind = {
+            "asset": DeviceAsset,
+            "event": DeviceOperationalEvent,
+            "quality_issue": DeviceQualityIssue,
+        }
+        model = model_by_kind.get(kind)
+        if model is None:
+            return False
+        return bool(self.session.query(model.uid).filter_by(uid=uid).first())
+
+    def _node(self, kind, uid):
+        if kind == "asset":
+            model = self.session.get(DeviceAsset, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="asset",
+                uid=str(model.uid),
+                node_type=model.asset_type,
+                label=model.name,
+                occurred_at=None,
+                evidence_refs=(
+                    {
+                        "type": "device_asset",
+                        "uid": str(model.uid),
+                        "version": int(model.current_version),
+                    },
+                ),
+            )
+        if kind == "event":
+            model = self.session.get(DeviceOperationalEvent, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="event",
+                uid=str(model.uid),
+                node_type=model.event_type,
+                label=model.title,
+                occurred_at=model.occurred_at,
+                evidence_refs=tuple(
+                    dict(item) for item in (model.evidence_refs or [])
+                ),
+            )
+        if kind == "quality_issue":
+            model = self.session.get(DeviceQualityIssue, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="quality_issue",
+                uid=str(model.uid),
+                node_type="quality_issue",
+                label=model.issue_code,
+                occurred_at=model.created_at,
+                evidence_refs=(
+                    {
+                        "type": "quality_issue",
+                        "uid": str(model.uid),
+                        "issue_code": model.issue_code,
+                        "rule_code": model.rule_code,
+                    },
+                ),
+            )
+        return None
+
+    @staticmethod
+    def _endpoint_filter(endpoints):
+        clauses = []
+        for kind, uid in endpoints:
+            clauses.extend(
+                (
+                    and_(
+                        DeviceEvidenceRelation.from_kind == kind,
+                        DeviceEvidenceRelation.from_uid == uid,
+                    ),
+                    and_(
+                        DeviceEvidenceRelation.to_kind == kind,
+                        DeviceEvidenceRelation.to_uid == uid,
+                    ),
+                )
+            )
+        return or_(*clauses)
+
+    def graph(
+        self,
+        anchor_kind,
+        anchor_uid,
+        *,
+        max_hops,
+        max_nodes,
+        max_edges,
+    ):
+        anchor = (str(anchor_kind), str(anchor_uid))
+        frontier = {anchor}
+        visited = {anchor}
+        nodes = {anchor: self._node(*anchor)}
+        relations = []
+        relation_uids = set()
+        truncated = False
+        for _ in range(max_hops):
+            if not frontier:
+                break
+            models = (
+                self.session.query(DeviceEvidenceRelation)
+                .filter(self._endpoint_filter(frontier))
+                .order_by(
+                    DeviceEvidenceRelation.created_at.asc(),
+                    DeviceEvidenceRelation.uid.asc(),
+                )
+                .all()
+            )
+            next_frontier = set()
+            for model in models:
+                if str(model.uid) in relation_uids:
+                    continue
+                if len(relations) >= max_edges:
+                    truncated = True
+                    break
+                relation = self._relation(model)
+                relation_uids.add(relation.uid)
+                relations.append(relation)
+                for endpoint in (
+                    (relation.from_kind, relation.from_uid),
+                    (relation.to_kind, relation.to_uid),
+                ):
+                    if endpoint in visited:
+                        continue
+                    if len(nodes) >= max_nodes:
+                        truncated = True
+                        continue
+                    node = self._node(*endpoint)
+                    if node is None:
+                        continue
+                    visited.add(endpoint)
+                    next_frontier.add(endpoint)
+                    nodes[endpoint] = node
+            frontier = next_frontier
+        return GraphProjection(
+            anchor_kind=anchor[0],
+            anchor_uid=anchor[1],
+            nodes=tuple(item for item in nodes.values() if item is not None),
+            relations=tuple(relations),
+            truncated=truncated,
+        )
+
+    def search_events(self, filters, *, page, page_size):
+        query = self.session.query(DeviceOperationalEvent)
+        if filters.get("event_type"):
+            query = query.filter(
+                DeviceOperationalEvent.event_type == filters["event_type"]
+            )
+        if filters.get("severity"):
+            query = query.filter(
+                DeviceOperationalEvent.severity == filters["severity"]
+            )
+        if filters.get("status"):
+            query = query.filter(
+                DeviceOperationalEvent.status == filters["status"]
+            )
+        if filters.get("asset_uid"):
+            query = query.filter(
+                DeviceOperationalEvent.asset_uid == filters["asset_uid"]
+            )
+        if filters.get("source_uid"):
+            query = query.filter(
+                DeviceOperationalEvent.source_uid == filters["source_uid"]
+            )
+        if filters.get("keyword"):
+            pattern = f"%{filters['keyword']}%"
+            query = query.filter(
+                or_(
+                    DeviceOperationalEvent.title.ilike(pattern),
+                    DeviceOperationalEvent.source_code.ilike(pattern),
+                )
+            )
+        total = int(
+            query.with_entities(func.count(DeviceOperationalEvent.uid)).scalar()
+            or 0
+        )
+        models = (
+            query.order_by(
+                DeviceOperationalEvent.occurred_at.desc(),
+                DeviceOperationalEvent.uid.asc(),
+            )
+            .offset((page - 1) * page_size)
+            .limit(page_size)
+            .all()
+        )
+        return [self._event(model) for model in models], total

+ 15 - 0
deployment/app/core/data_research/errors.py

@@ -138,3 +138,18 @@ class QualityIssueNotFound(DataResearchError):
 class QualityIssueConflict(DataResearchError):
     code = "QUALITY_ISSUE_CONFLICT"
     http_status = 409
+
+
+class DeviceObservabilityInvalid(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_INVALID"
+    http_status = 422
+
+
+class DeviceObservabilityNotFound(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceObservabilityConflict(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_CONFLICT"
+    http_status = 409

+ 7 - 0
deployment/app/core/system/permissions.py

@@ -43,6 +43,7 @@ DEVICE_QUALITY_EXECUTE = "device-quality:execute"
 DEVICE_QUALITY_PUBLISH = "device-quality:publish"
 QUALITY_ISSUES_EDIT = "quality-issues:edit"
 QUALITY_ISSUES_REVIEW = "quality-issues:review"
+DEVICE_OBSERVABILITY_EDIT = "device-observability:edit"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -67,6 +68,7 @@ ROLE_PERMISSIONS = {
             DEVICE_QUALITY_EDIT,
             DEVICE_QUALITY_EXECUTE,
             QUALITY_ISSUES_EDIT,
+            DEVICE_OBSERVABILITY_EDIT,
         }
     ),
     "admin": frozenset(
@@ -107,6 +109,7 @@ ROLE_PERMISSIONS = {
             DEVICE_QUALITY_PUBLISH,
             QUALITY_ISSUES_EDIT,
             QUALITY_ISSUES_REVIEW,
+            DEVICE_OBSERVABILITY_EDIT,
         }
     ),
 }
@@ -183,6 +186,10 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path.endswith("/review") or path.endswith("/rollback"):
             return (DEVICE_ENTITIES_REVIEW,)
         return (DEVICE_ENTITIES_EDIT,)
+    if path.startswith("/api/development/v1/device-observability"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        return (DEVICE_OBSERVABILITY_EDIT,)
     if path.startswith("/api/development/v1/device-quality"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 122 - 0
deployment/app/models/data_research.py

@@ -1162,6 +1162,128 @@ class DeviceQualityIssueTimeline(db.Model):
     created_at = db.Column(db.DateTime(timezone=True), nullable=False)
 
 
+class DeviceOperationalEvent(db.Model):
+    __tablename__ = "device_operational_events"
+    __table_args__ = (
+        db.CheckConstraint(
+            "event_type IN ('alarm','fault','maintenance','downtime')",
+            name="ck_device_operational_event_type",
+        ),
+        db.CheckConstraint(
+            "severity IN ('info','warning','error','critical')",
+            name="ck_device_operational_event_severity",
+        ),
+        db.CheckConstraint(
+            "status IN ('observed','resolved')",
+            name="ck_device_operational_event_status",
+        ),
+        db.CheckConstraint(
+            "ended_at IS NULL OR ended_at >= occurred_at",
+            name="ck_device_operational_event_window",
+        ),
+        db.UniqueConstraint(
+            "source_uid",
+            "source_entity",
+            "source_code",
+            name="uq_device_operational_event_source_identity",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    source_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.ingestion_sources.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    source_entity = db.Column(db.String(300), nullable=False)
+    source_code = db.Column(db.String(300), nullable=False)
+    event_type = db.Column(db.String(30), nullable=False)
+    asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    component_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+    )
+    title = db.Column(db.String(500), nullable=False)
+    severity = db.Column(db.String(20), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    occurred_at = db.Column(db.DateTime(timezone=True), nullable=False)
+    ended_at = db.Column(db.DateTime(timezone=True))
+    evidence_refs = db.Column(JSONB, nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceEvidenceRelation(db.Model):
+    __tablename__ = "device_evidence_relations"
+    __table_args__ = (
+        db.CheckConstraint(
+            "from_kind IN ('asset','event','quality_issue')",
+            name="ck_device_evidence_relation_from_kind",
+        ),
+        db.CheckConstraint(
+            "to_kind IN ('asset','event','quality_issue')",
+            name="ck_device_evidence_relation_to_kind",
+        ),
+        db.CheckConstraint(
+            "relation_type IN ("
+            "'part_of','occurred_on','indicates','triggered',"
+            "'addresses','impacts','evidences','related_to'"
+            ")",
+            name="ck_device_evidence_relation_type",
+        ),
+        db.CheckConstraint(
+            "source IN ('imported','manual','derived')",
+            name="ck_device_evidence_relation_source",
+        ),
+        db.CheckConstraint(
+            "NOT (from_kind = to_kind AND from_uid = to_uid)",
+            name="ck_device_evidence_relation_distinct_nodes",
+        ),
+        db.UniqueConstraint(
+            "from_kind",
+            "from_uid",
+            "relation_type",
+            "to_kind",
+            "to_uid",
+            name="uq_device_evidence_relation_identity",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    from_kind = db.Column(db.String(30), nullable=False)
+    from_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    relation_type = db.Column(db.String(40), nullable=False)
+    to_kind = db.Column(db.String(30), nullable=False)
+    to_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    evidence_refs = db.Column(JSONB, nullable=False)
+    source = db.Column(db.String(30), nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 98 - 0
deployment/migrations/versions/20260729_350_device_observability.py

@@ -0,0 +1,98 @@
+"""Add canonical device operational events and evidence relations."""
+
+from alembic import op
+
+revision = "20260729_350"
+down_revision = "20260729_340"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_operational_events (
+            uid UUID PRIMARY KEY,
+            source_uid UUID NOT NULL
+                REFERENCES public.ingestion_sources(uid) ON DELETE RESTRICT,
+            source_entity VARCHAR(300) NOT NULL,
+            source_code VARCHAR(300) NOT NULL,
+            event_type VARCHAR(30) NOT NULL
+                CHECK (
+                    event_type IN (
+                        'alarm','fault','maintenance','downtime'
+                    )
+                ),
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            component_uid UUID
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            title VARCHAR(500) NOT NULL,
+            severity VARCHAR(20) NOT NULL
+                CHECK (severity IN ('info','warning','error','critical')),
+            status VARCHAR(20) NOT NULL
+                CHECK (status IN ('observed','resolved')),
+            occurred_at TIMESTAMPTZ NOT NULL,
+            ended_at TIMESTAMPTZ
+                CHECK (ended_at IS NULL OR ended_at >= occurred_at),
+            evidence_refs JSONB NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            created_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (source_uid, source_entity, source_code)
+        );
+        CREATE INDEX idx_device_operational_event_asset_time
+            ON public.device_operational_events(
+                asset_uid, occurred_at DESC, uid
+            );
+        CREATE INDEX idx_device_operational_event_worklist
+            ON public.device_operational_events(
+                event_type, severity, status, occurred_at DESC
+            );
+
+        CREATE TABLE public.device_evidence_relations (
+            uid UUID PRIMARY KEY,
+            from_kind VARCHAR(30) NOT NULL
+                CHECK (
+                    from_kind IN ('asset','event','quality_issue')
+                ),
+            from_uid UUID NOT NULL,
+            relation_type VARCHAR(40) NOT NULL
+                CHECK (
+                    relation_type IN (
+                        'part_of','occurred_on','indicates','triggered',
+                        'addresses','impacts','evidences','related_to'
+                    )
+                ),
+            to_kind VARCHAR(30) NOT NULL
+                CHECK (
+                    to_kind IN ('asset','event','quality_issue')
+                ),
+            to_uid UUID NOT NULL,
+            evidence_refs JSONB NOT NULL,
+            source VARCHAR(30) NOT NULL
+                CHECK (source IN ('imported','manual','derived')),
+            created_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (NOT (from_kind = to_kind AND from_uid = to_uid)),
+            UNIQUE (
+                from_kind, from_uid, relation_type, to_kind, to_uid
+            )
+        );
+        CREATE INDEX idx_device_evidence_relation_from
+            ON public.device_evidence_relations(
+                from_kind, from_uid, created_at, uid
+            );
+        CREATE INDEX idx_device_evidence_relation_to
+            ON public.device_evidence_relations(
+                to_kind, to_uid, created_at, uid
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Operational evidence is retained during application rollback.
+    pass

+ 1 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -161,6 +161,7 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | WP-06 | 工程完成,待企业匹配阈值验收 | 跨来源同类型资产候选;名称、位置、组织、责任人、型号和来源编码逐项评分解释;规则与受治理 AI 候选;权限与唯一设备映射负责人双门禁;非破坏性主资产关联;不可变审核、合并快照和追加式回滚证据;OpenAPI 170 项;真实 PostgreSQL 定向验证 | 需要企业提供跨系统同一/不同设备标注集、确认候选阈值和唯一设备映射负责人;自动合并默认关闭且只允许确定性规则严格高置信度候选,AI 自动合并不在首期;属性幸存规则、物理删除、质量评分和下游图谱投影不在 WP-06 |
 | WP-07 | 工程完成,待企业质量数据验收 | 七类封闭设备质量规则;不可变策略版本和唯一生效发布;设备资产负责人发布门禁;绑定策略哈希的质量执行;规则级精确计数、违规样本和资产评分;查看者、编辑者、发布者权限分离;OpenAPI 180 项;真实 PostgreSQL 和页面链路定向验证 | 需要企业提供设备、部件、告警、维修样本并确认字段映射、规则权重和质量阈值;每条规则只保留最多 100 条脱敏样本且 30 天后清理;通用画像、Schema 漂移、趋势和质量问题整改闭环仍分别保留为后续能力,WP-08 承接整改与复验 |
 | WP-08 | 工程完成,待企业整改流程验收 | WP-07 违规证据快照转问题;同类未关闭问题幂等去重;有效用户分派和期限;整改提交、独立责任人复核、关闭、退回与重开;乐观锁和追加式处理时间线;逾期、发生次数、复发问题组及复发率;编辑者与复核者权限分离;OpenAPI 191 项;真实 PostgreSQL 定向验证 | 需要企业提供实际质量问题、整改负责人、唯一质量问题复核负责人、整改时限和通过标准并完成端到端运营验收;复验检查可关联但不把违规抽样未命中当成自动通过;通知升级、通用工单集成、自动修复、跨资产趋势和根因分析不在 WP-08 |
+| WP-09 | 工程完成,待企业运行关系与专家验收 | 四类运行事件按来源身份不可变幂等接入;资产、事件和质量问题的八类有向证据关系;三跳、100 节点、200 边的有界关系图;只沿持久化上游证据关系返回根因候选和路径;证据不足时明确无法确认;编辑者导入与查看者只读分离;OpenAPI 195 项;真实 PostgreSQL 定向验证 | 需要企业接入告警、故障、维修和停机事件,确认关系方向、时间窗口和专家判定标准;当前不融合通用血缘与变更事件,不覆盖产品、报表、Agent 和业务域影响,不生成 AI 修复建议、自动修复、预测性维护或维修计划 |
 
 ## 7. 12 周执行计划
 

+ 7 - 5
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -549,6 +549,8 @@ WP-07 已补齐设备质量工程链:七类封闭规则覆盖台账身份和
 
 WP-08 已补齐设备质量问题的工程闭环:可从 WP-07 违规样本批量创建带安全证据快照的问题,同一未关闭规则/资产/字段问题幂等复用;问题支持有效用户分派、期限、整改提交、独立责任人复核、关闭、退回和重开,所有动作形成追加式时间线并以版本号防止并发覆盖。工作台展示逾期、发生次数、复发问题组和复发率。企业真实问题、人员分工、整改时限和复核标准仍待现场运营验收;通知升级、自动修复、通用工单集成和根因分析不在 WP-08。
 
+WP-09 已形成设备关系与根因的最小工程链:告警、故障、维修和停机事件按来源身份不可变、幂等接入,设备资产、运行事件和质量问题通过带证据的有向关系连接;工作台最多展示三跳、100 个节点和 200 条关系。根因分析只沿已保存的上游因果证据关系返回候选及路径,证据不足时明确返回“无法确认根因”,不会生成确定性结论、自动修复或维修计划。企业真实运行事件、关系口径和设备专家结论仍待现场验收;跨数据产品、报表、Agent 和业务域的影响分析及 AI 修复建议不在 WP-09。
+
 ### 12.5 数据标准、语义与本体
 
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
@@ -592,9 +594,9 @@ WP-08 已补齐设备质量问题的工程闭环:可从 WP-07 违规样本批
 | DQA-10 | 质量运营 / 质量问题 | 问题创建、分类、影响、责任人和优先级 | 工程完成,待企业质量问题验收 |
 | DQA-11 | 质量运营 / 整改工单 | 分派、整改、复核、关闭、重开和逾期 | 工程完成,待企业整改流程验收 |
 | DQA-12 | 质量运营 / 复发分析 | 重复问题识别、复发率和治理效果 | 部分建设;确定性复发身份、次数、问题组和复发率已形成,跨资产趋势及治理效果分析待建设 |
-| DQA-13 | 质量智能 / 根因分析 | 结合血缘、变更和运行证据分析根因 | 规划中 |
-| DQA-14 | 质量智能 / 影响分析 | 识别受影响资产、产品、报表、Agent 和业务域 | 部分建设 |
-| DQA-15 | 质量智能 / 修复建议 | AI 生成受证据约束的修复建议 | 规划中 |
+| DQA-13 | 质量智能 / 根因分析 | 结合血缘、变更和运行证据分析根因 | 部分建设;设备运行事件和关系证据路径已形成,通用血缘、变更证据融合及专家验收待建设 |
+| DQA-14 | 质量智能 / 影响分析 | 识别受影响资产、产品、报表、Agent 和业务域 | 部分建设;设备资产、运行事件和质量问题的关系影响已形成,产品、报表、Agent 和业务域影响待建设 |
+| DQA-15 | 质量智能 / 修复建议 | AI 生成受证据约束的修复建议 | 规划中;WP-09 只返回根因候选,不生成修复动作 |
 | DQA-16 | 质量智能 / 自动修复 | 低风险受控执行,高风险人工审批和回滚 | 规划中 |
 | OBS-01 | 数据可观测 / 任务运行 | 工作流执行、状态、失败、重试和日志 | 已建设 |
 | OBS-02 | 数据可观测 / 生产线 | DataFlow/Deployment、Runner 和制品运行证据 | 工程完成,受门禁 |
@@ -604,8 +606,8 @@ WP-08 已补齐设备质量问题的工程闭环:可从 WP-07 违规样本批
 | OBS-06 | 数据可观测 / 业务影响 | 将技术异常映射到数据产品、业务域和用户影响 | 规划中 |
 | OBS-07 | 设备质量 / 台账完整性 | 设备关系、位置、责任人和标识完整性检查 | 工程完成,待企业设备台账数据验收 |
 | OBS-08 | 设备质量 / 故障数据 | 故障代码映射、原因完整性和维修闭环检查 | 工程完成,待企业告警与维修数据验收 |
-| OBS-09 | 设备可观测 / 运行明细 | 近期运行明细、告警、停机和维修事件关联 | 规划中 |
-| OBS-10 | 设备可观测 / 根因图谱 | 设备—部件—告警—故障—维修—影响链路分析 | 规划中 |
+| OBS-09 | 设备可观测 / 运行明细 | 近期运行明细、告警、停机和维修事件关联 | 工程完成,待企业运行事件接入与保留策略验收 |
+| OBS-10 | 设备可观测 / 根因图谱 | 设备—部件—告警—故障—维修—影响链路分析 | 工程完成,待企业关系口径和设备专家验收 |
 
 ### 12.7 数据市场、授权与可信交付
 

+ 6 - 0
docs/architecture/DATA_MODEL.md

@@ -162,6 +162,11 @@ flowchart LR
 | `device_quality_rule_results` | `run_uid`, `rule_code`, `evaluated_count`, `violation_count`, `pass_rate`, `weighted_score` | 每条启用规则的精确计数和得分贡献 |
 | `device_quality_violation_samples` | `run_uid`, `rule_code`, `asset_uid`, `field_name`, `source_mapping_uid`, `evidence`, `expires_at` | 每条规则最多 100 条脱敏违规样本,保留 30 天 |
 | `device_quality_asset_scores` | `run_uid`, `asset_uid`, `evaluated_rule_count`, `violation_count`, `score` | 按适用规则计算的资产级质量评分 |
+| `device_quality_issues` | `source_violation_uid`, `asset_uid`, `recurrence_key`, `status`, `assignee_uid`, `due_at`, `current_version` | 从违规证据形成的整改问题、状态、责任和复发身份 |
+| `device_quality_issue_remediations` | `issue_uid`, `round_number`, `summary`, `evidence_refs`, `review_status`, `reviewed_by` | 追加式整改轮次和独立复核结果 |
+| `device_quality_issue_timeline` | `issue_uid`, `action`, `from_status`, `to_status`, `actor_uid`, `payload` | 质量问题不可变处理时间线 |
+| `device_operational_events` | `source_uid`, `source_entity`, `source_code`, `event_type`, `asset_uid`, `component_uid`, `occurred_at`, `evidence_refs`, `content_hash` | 告警、故障、维修和停机事件的不可变来源证据 |
+| `device_evidence_relations` | `from_kind`, `from_uid`, `relation_type`, `to_kind`, `to_uid`, `evidence_refs`, `source` | 设备资产、运行事件和质量问题之间的有向证据关系 |
 | `ontologies` | `code`, `owner_uid`, `draft_revision`, `active_version_uid` | 本体稳定身份和生效版本 |
 | `ontology_versions` | `ontology_uid`, `version`, `parent_version_uid`, `graph_document`, `content_hash` | 不可变本体版本 |
 | `ontology_domain_links` | `ontology_uid`, `domain_uid`, `role` | 多业务域 owner/contributor/consumer 关系 |
@@ -194,6 +199,7 @@ flowchart LR
 - 规则自动合并默认关闭,只允许显式开闸后的确定性规则高置信度候选;AI 候选始终进入人工审核。
 - 设备质量策略版本、执行结果、规则计数、违规样本和资产评分以 PostgreSQL 为源真相;检查只读设备资产和已发布语义代码,不修改来源台账。
 - 质量问题、整改轮次和处理时间线以 PostgreSQL 为源真相;问题保存 WP-07 违规的安全证据快照,状态变更采用乐观锁,关闭必须由 `quality_issue/DEVICE_QUALITY_ISSUES` 唯一负责的设备资产管理员独立复核。逾期由期限和未关闭状态实时计算,复发由规则、资产和字段的确定性身份统计,不等同于自动根因结论。
+- 设备运行事件和有向证据关系以 PostgreSQL 为源真相;关系图是最多三跳、100 个节点和 200 条边的可重建查询投影。根因分析只沿 `indicates`、`triggered` 和 `evidences` 上游关系返回候选及证据路径;没有持久化路径时必须返回“证据不足,无法确认根因”,结果不触发自动修复或维修计划。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 84 - 1
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 191
+x-route-count: 195
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -2073,6 +2073,89 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-observability/events":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_operational_events_get
+      summary: "list device operational events"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-observability/graph":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_device_evidence_graph_get
+      summary: "get device evidence graph"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-observability/import":
+    post:
+      tags: [data_development]
+      operationId: data_development_import_device_operational_evidence_post
+      summary: "import device operational evidence"
+      x-source: "app/api/data_development/routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-observability/root-cause":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_device_root_cause_get
+      summary: "get device root cause"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/development/v1/device-quality/bootstrap":
     post:
       tags: [data_development]

+ 115 - 0
docs/superpowers/plans/2026-07-29-wp09-device-relations-root-cause.md

@@ -0,0 +1,115 @@
+# WP09 Device Relations and Evidence-Bound Root Cause Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build an auditable device relationship graph that connects assets, operational events, and quality issues, then returns root-cause candidates only when persisted evidence paths support them.
+
+**Architecture:** PostgreSQL remains the canonical source for operational events and typed evidence relations. Graph and root-cause responses are bounded projections calculated from canonical rows; Neo4j is not required for correctness. Root-cause analysis is deterministic and evidence-bound: it returns candidate paths and limitations, never an automatic causal verdict, repair, maintenance plan, or prediction.
+
+**Tech Stack:** Flask, SQLAlchemy/PostgreSQL, Alembic, Vue 2/Vuetify, ECharts, pytest, Node test runner.
+
+---
+
+## Scope and constraints
+
+- Cover `CAT-16`, `DQA-13`, the device-only portion of `DQA-14`, `OBS-09`, and `OBS-10`. `DQA-15` remains planned because WP09 does not generate repair actions.
+- Supported node kinds are `asset`, `event`, and `quality_issue`.
+- Supported event types are `alarm`, `fault`, `maintenance`, and `downtime`.
+- Supported directed relation types are `part_of`, `occurred_on`, `indicates`, `triggered`, `addresses`, `impacts`, `evidences`, and `related_to`.
+- Cause-to-effect relations are `indicates`, `triggered`, and `evidences`. Root-cause traversal follows those edges upstream and includes other relations only as context.
+- Import is immutable and idempotent by source identity. Reusing an identity with different content is a conflict, not an update.
+- Limit one import to 200 events and 500 relations; graph traversal to 3 hops, 100 nodes, and 200 edges.
+- Evidence is bounded and secret fields are rejected. Raw source rows, credentials, prompts, and unrestricted attributes are never returned.
+- Do not add predictive maintenance, remaining-life prediction, automatic repair, maintenance-plan generation, generic ticket integration, or an LLM causal conclusion.
+- Use targeted WP09 tests only; do not run the full repository regression suite.
+
+## Task 1: Domain contract and deterministic analysis
+
+**Files:**
+- Create: `app/core/data_research/device_observability.py`
+- Modify: `app/core/data_research/errors.py`
+- Create: `tests/data_research/test_device_observability.py`
+
+- [x] Write failing tests for event normalization, secret rejection, immutable source identity, node existence, graph bounds, upstream cause traversal, supported candidates, and `insufficient_evidence`.
+- [x] Run `PYTHONPATH=. .venv/bin/pytest -q tests/data_research/test_device_observability.py` and confirm failures are caused by the missing WP09 service.
+- [x] Implement `OperationalEventRecord`, `EvidenceRelationRecord`, `GraphProjection`, `RootCauseAnalysis`, and `DeviceObservabilityService`.
+- [x] Return `analysis_status="supported_candidates"` only when at least one persisted upstream causal path exists; otherwise return `analysis_status="insufficient_evidence"` and the fixed conclusion `证据不足,无法确认根因`.
+- [x] Re-run the domain test until it passes.
+
+## Task 2: Canonical persistence and migration
+
+**Files:**
+- Create: `app/core/data_research/device_observability_repository.py`
+- Modify: `app/models/data_research.py`
+- Create: `migrations/versions/20260729_350_device_observability.py`
+- Create: `tests/integration/test_device_observability_postgres.py`
+
+- [x] Write a failing PostgreSQL integration test for idempotent event import, conflicting identity rejection, validated polymorphic relations, bounded graph traversal, quality-issue anchors, and deterministic root-cause paths.
+- [x] Add `device_operational_events` with source identity, event type, asset/component references, severity, status, occurrence window, safe evidence, and content hash.
+- [x] Add `device_evidence_relations` with typed endpoints, relation type, safe evidence, source, and a unique edge identity.
+- [x] Implement repository lookups, bulk inserts, paginated event search, bounded adjacency reads, and node summaries.
+- [x] Apply migration `20260729_350` to the local PostgreSQL test database and run only the WP09 integration test.
+
+## Task 3: Governed API and permission boundary
+
+**Files:**
+- Modify: `app/api/data_development/routes.py`
+- Modify: `app/core/system/permissions.py`
+- Modify: `tests/test_permission_matrix.py`
+- Create: `tests/data_research/test_device_observability_api.py`
+
+- [x] Write failing tests for the following contract:
+  - `GET /api/development/v1/device-observability/events`
+  - `POST /api/development/v1/device-observability/import`
+  - `GET /api/development/v1/device-observability/graph`
+  - `GET /api/development/v1/device-observability/root-cause`
+- [x] Add `device-observability:edit` to editor and admin roles. All reads require `governance:read`; import requires the new edit permission.
+- [x] Add serializers that expose bounded event, relation, graph, candidate, evidence-reference, and limitation fields.
+- [x] Keep root-cause analysis read-only and deterministic; do not persist a generated conclusion.
+- [x] Run only WP09 API and permission tests.
+
+## Task 4: Device relationship and root-cause workbench
+
+**Files:**
+- Modify: `frontend/src/api/dataDevelopment.js`
+- Modify: `frontend/src/router/routes.js`
+- Modify: `frontend/src/views/dataGovernance/development/index.vue`
+- Create: `frontend/src/views/dataGovernance/development/deviceObservabilityModel.js`
+- Create: `frontend/src/views/dataGovernance/development/deviceObservability.vue`
+- Create: `frontend/tests/device-observability-model.test.mjs`
+- Create: `tests/data_research/test_device_observability_frontend_contract.py`
+
+- [x] Write failing model tests for event labels, node categories, graph conversion, analysis status, candidate path labels, and permission visibility.
+- [x] Add a viewer-safe page with event filters, anchor selection, bounded relationship graph, evidence table, root-cause candidate cards, and an explicit insufficient-evidence state.
+- [x] Add an editor-only import dialog for bounded event and relation evidence; present source identity and evidence fields without exposing credentials.
+- [x] Render the relationship graph with the existing ECharts graph component and show directed edge labels.
+- [x] Add the hidden route and the data-research-center entry.
+- [x] Run the Node model test and frontend contract test.
+
+## Task 5: Release copy, architecture, and ledger
+
+**Files:**
+- Mirror backend changes under: `deployment/app/`
+- Mirror migration under: `deployment/migrations/versions/`
+- Modify: `docs/architecture/DATA_MODEL.md`
+- Regenerate: `docs/architecture/OPENAPI.yaml`
+- Modify: `docs/FUNCTION_MODULE_CENSUS_20260726.md`
+- Modify: `docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md`
+- Modify: `tests/test_architecture_artifacts.py`
+
+- [x] Document PostgreSQL authority, graph projection bounds, evidence-path semantics, and the prohibition on deterministic causal claims without evidence.
+- [x] Mark WP09 engineering status separately from enterprise operating-event and expert validation.
+- [x] Update `DQA-13`, device-only `DQA-14`, `DQA-15`, `OBS-09`, and `OBS-10` without overstating cross-product impact or AI completion.
+- [x] Regenerate OpenAPI and assert the WP09 endpoints and migration are represented.
+- [x] Verify each changed `app/` file is identical to its `deployment/app/` copy.
+
+## Task 6: Targeted verification and stage commit
+
+- [x] Run WP09 domain, API, frontend contract, PostgreSQL integration, permission, and architecture tests only.
+- [x] Run Ruff only on changed Python files.
+- [x] Run ESLint only on changed frontend files and the Node model test.
+- [x] Build the frontend production bundle because the workbench and route changed.
+- [x] Apply migration through the container and verify `20260729_350 (head)`.
+- [x] Use the local browser to import a bounded alarm/fault/downtime evidence chain, render its graph, verify supported candidates, and verify a disconnected event returns `证据不足,无法确认根因`.
+- [x] Confirm browser console has no errors and the issue/quality pages still load through the unchanged navigation.
+- [x] Commit the verified WP09 change on `codex/dataops-phase1-equipment-governance`; do not push or deploy.

+ 17 - 0
frontend/src/api/dataDevelopment.js

@@ -6,6 +6,7 @@ const DEVICE_ASSET_BASE = '/development/v1/device-assets'
 const DEVICE_SEMANTIC_BASE = '/development/v1/device-semantics'
 const DEVICE_ENTITY_BASE = '/development/v1/device-entities'
 const DEVICE_QUALITY_BASE = '/development/v1/device-quality'
+const DEVICE_OBSERVABILITY_BASE = '/development/v1/device-observability'
 
 export const createIngestionJob = params => http.post(BASE, params)
 export const getIngestionJobs = params => http.get(BASE, params)
@@ -220,6 +221,22 @@ export const reopenQualityIssue = (uid, params) => http.post(
   `${DEVICE_QUALITY_BASE}/issues/${uid}/reopen`,
   params
 )
+export const getDeviceOperationalEvents = params => http.get(
+  `${DEVICE_OBSERVABILITY_BASE}/events`,
+  params
+)
+export const importDeviceOperationalEvidence = params => http.post(
+  `${DEVICE_OBSERVABILITY_BASE}/import`,
+  params
+)
+export const getDeviceEvidenceGraph = params => http.get(
+  `${DEVICE_OBSERVABILITY_BASE}/graph`,
+  params
+)
+export const getDeviceRootCause = params => http.get(
+  `${DEVICE_OBSERVABILITY_BASE}/root-cause`,
+  params
+)
 
 export default {
   createIngestionJob,

+ 12 - 0
frontend/src/router/routes.js

@@ -252,6 +252,18 @@ export default {
           name: 'deviceQuality',
           alwaysShow: 0
         },
+        {
+          hidden: 1,
+          type: 1,
+          title: '设备关系与根因',
+          path: '/data-governance/development/device-observability',
+          children: [],
+          label: '设备关系与根因',
+          component: 'dataGovernance/development/deviceObservability',
+          meta: { roles: ['viewer', 'editor', 'admin'], title: '设备关系与根因', readOnly: 'viewer' },
+          name: 'deviceObservability',
+          alwaysShow: 0
+        },
         {
           hidden: 1,
           type: 1,

+ 504 - 0
frontend/src/views/dataGovernance/development/deviceObservability.vue

@@ -0,0 +1,504 @@
+<template>
+  <div class="pa-6 device-observability">
+    <div class="d-flex flex-wrap align-start justify-space-between mb-5">
+      <div>
+        <h1 class="text-h4 mb-2">设备关系与根因</h1>
+        <p class="text--secondary mb-0">
+          关联设备、部件、告警、故障、维修和停机事件,只基于已保存证据给出根因候选。
+        </p>
+      </div>
+      <v-btn
+        v-if="canEdit"
+        color="primary"
+        class="mt-2"
+        @click="openImport"
+      >
+        <v-icon left>mdi-database-import-outline</v-icon>
+        运行证据导入
+      </v-btn>
+    </div>
+
+    <v-alert type="info" outlined class="mb-5">
+      关系路径用于辅助判断,不等同于已确认根因;结果不会自动修改台账、创建维修计划或触发修复。
+    </v-alert>
+
+    <v-card outlined class="mb-5">
+      <v-card-title class="pb-2">运行事件</v-card-title>
+      <v-card-text>
+        <v-row dense>
+          <v-col cols="12" md="3">
+            <v-text-field
+              v-model.trim="filters.keyword"
+              label="事件名称或来源编码"
+              outlined
+              dense
+              clearable
+              @keyup.enter="loadEvents"
+            />
+          </v-col>
+          <v-col cols="12" md="2">
+            <v-select
+              v-model="filters.eventType"
+              :items="eventTypeOptions"
+              label="事件类型"
+              outlined
+              dense
+              clearable
+            />
+          </v-col>
+          <v-col cols="12" md="2">
+            <v-select
+              v-model="filters.severity"
+              :items="severityOptions"
+              label="严重度"
+              outlined
+              dense
+              clearable
+            />
+          </v-col>
+          <v-col cols="12" md="3">
+            <v-text-field
+              v-model.trim="filters.assetUid"
+              label="设备 UID"
+              outlined
+              dense
+              clearable
+            />
+          </v-col>
+          <v-col cols="12" md="2" class="d-flex align-start">
+            <v-btn color="primary" outlined block @click="loadEvents">
+              查询
+            </v-btn>
+          </v-col>
+        </v-row>
+        <v-data-table
+          :headers="eventHeaders"
+          :items="events"
+          :loading="loadingEvents"
+          :server-items-length="eventTotal"
+          :page.sync="eventPage"
+          :items-per-page="20"
+          item-key="uid"
+          class="elevation-0"
+          @update:page="loadEvents"
+        >
+          <template v-slot:[`item.title`]="{ item }">
+            <strong>{{ item.title }}</strong>
+            <div class="caption text--secondary">{{ item.source_code }}</div>
+          </template>
+          <template v-slot:[`item.event_type`]="{ item }">
+            <v-chip small outlined>{{ eventTypeLabel(item.event_type) }}</v-chip>
+          </template>
+          <template v-slot:[`item.severity`]="{ item }">
+            <v-chip small :color="severityColor(item.severity)" dark>
+              {{ severityLabel(item.severity) }}
+            </v-chip>
+          </template>
+          <template v-slot:[`item.status`]="{ item }">
+            {{ eventStatusLabel(item.status) }}
+          </template>
+          <template v-slot:[`item.occurred_at`]="{ item }">
+            {{ formatTime(item.occurred_at) }}
+          </template>
+          <template v-slot:[`item.actions`]="{ item }">
+            <v-btn text small color="primary" @click="selectAnchor(item, false)">
+              查看关系
+            </v-btn>
+            <v-btn text small color="warning" @click="selectAnchor(item, true)">
+              分析根因
+            </v-btn>
+          </template>
+          <template #no-data>
+            <div class="py-8 text--secondary">
+              暂无运行事件。请先从企业告警、故障、维修或停机记录导入证据。
+            </div>
+          </template>
+        </v-data-table>
+      </v-card-text>
+    </v-card>
+
+    <v-row v-if="selectedEvent">
+      <v-col cols="12" lg="7">
+        <v-card outlined height="100%">
+          <v-card-title class="d-flex justify-space-between">
+            <span>设备关系图</span>
+            <v-chip small outlined>
+              锚点:{{ selectedEvent.title }}
+            </v-chip>
+          </v-card-title>
+          <v-card-subtitle>
+            最多展示三跳、100 个节点和 200 条关系;箭头表示证据关系方向。
+          </v-card-subtitle>
+          <v-card-text>
+            <div
+              v-if="loadingGraph"
+              class="graph-placeholder d-flex align-center justify-center"
+            >
+              <v-progress-circular indeterminate color="primary" />
+            </div>
+            <chart-graph
+              v-else-if="graphData.nodes.length"
+              h="470"
+              w="100%"
+              layout="force"
+              :nodes="graphData.nodes"
+              :links="graphData.links"
+              :categories="graphData.categories"
+              :showlegend="true"
+              :force="{ repulsion: 380, edgeLength: 150, gravity: 0.08 }"
+              :edge-symbol="['none', 'arrow']"
+              :edge-symbol-size="[0, 10]"
+              :label="{ show: true, position: 'right', formatter: '{b}' }"
+              :line-style="{ color: 'source', width: 2, curveness: 0.08 }"
+            />
+            <div v-else class="graph-placeholder d-flex align-center justify-center text--secondary">
+              当前事件没有已保存的关系证据。
+            </div>
+            <v-alert v-if="graph.truncated" type="warning" outlined dense>
+              关系数量超过展示边界,当前图已截断。
+            </v-alert>
+          </v-card-text>
+        </v-card>
+      </v-col>
+
+      <v-col cols="12" lg="5">
+        <v-card outlined height="100%">
+          <v-card-title>根因候选</v-card-title>
+          <v-card-text>
+            <div v-if="loadingAnalysis" class="py-10 text-center">
+              <v-progress-circular indeterminate color="warning" />
+            </div>
+            <template v-else-if="analysis">
+              <v-alert
+                :type="analysisView.color"
+                :icon="analysisView.icon"
+                outlined
+              >
+                <strong>{{ analysisView.title }}</strong>
+                <div class="mt-1">{{ analysis.conclusion }}</div>
+              </v-alert>
+              <v-card
+                v-for="candidate in analysis.candidates"
+                :key="candidate.event_uid"
+                outlined
+                class="mb-3 candidate-card"
+              >
+                <v-card-title class="subtitle-1 pb-1">
+                  {{ candidate.title }}
+                  <v-chip small class="ml-2" outlined>
+                    {{ eventTypeLabel(candidate.event_type) }}
+                  </v-chip>
+                </v-card-title>
+                <v-card-text>
+                  <div class="mb-2">
+                    证据路径:
+                    <strong>{{ relationPathLabel(candidate.path_relation_types) }}</strong>
+                  </div>
+                  <div class="caption text--secondary mb-2">
+                    {{ candidate.support_level === 'direct' ? '直接关系证据' : '间接关系证据' }}
+                  </div>
+                  <div class="caption">
+                    关系证据 {{ candidate.evidence_refs.length }} 项
+                  </div>
+                </v-card-text>
+              </v-card>
+              <v-alert
+                v-if="!analysis.candidates.length"
+                type="info"
+                outlined
+                class="mt-3"
+              >
+                证据不足,无法确认根因
+              </v-alert>
+              <v-divider class="my-4" />
+              <div class="subtitle-2 mb-2">约束说明</div>
+              <ul class="pl-5">
+                <li
+                  v-for="item in analysis.limitations"
+                  :key="item"
+                  class="mb-1"
+                >
+                  {{ item }}
+                </li>
+              </ul>
+            </template>
+            <div v-else class="py-10 text-center text--secondary">
+              选择运行事件后点击“分析根因”。
+            </div>
+          </v-card-text>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-dialog v-model="importDialog" max-width="820">
+      <v-card>
+        <v-card-title>运行证据导入</v-card-title>
+        <v-card-subtitle>
+          面向数据接入管理员的批量入口。单次最多 200 个事件、500 条关系。
+        </v-card-subtitle>
+        <v-card-text>
+          <v-text-field
+            v-model.trim="importForm.sourceUid"
+            label="数据源 UID"
+            outlined
+            dense
+          />
+          <v-textarea
+            v-model="importForm.eventsJson"
+            label="运行事件清单(JSON 数组)"
+            outlined
+            rows="9"
+            hint="支持 alarm、fault、maintenance、downtime;每条记录必须包含来源身份、设备 UID、发生时间和安全证据。"
+            persistent-hint
+            class="mb-4"
+          />
+          <v-textarea
+            v-model="importForm.relationsJson"
+            label="关系证据清单(JSON 数组)"
+            outlined
+            rows="7"
+            hint="关系必须指向已存在节点或本批次事件的来源身份,并保存关系证据。"
+            persistent-hint
+          />
+          <v-alert type="warning" outlined dense>
+            禁止提交密码、令牌、连接串和直接个人标识。来源身份已存在但内容不同的事件会被拒绝。
+          </v-alert>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="importDialog = false">取消</v-btn>
+          <v-btn
+            color="primary"
+            :loading="savingImport"
+            :disabled="!importForm.sourceUid"
+            @click="submitImport"
+          >
+            导入证据
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import ChartGraph from '@/charts/graph.vue'
+import {
+  getDeviceEvidenceGraph,
+  getDeviceOperationalEvents,
+  getDeviceRootCause,
+  importDeviceOperationalEvidence
+} from '@/api/dataDevelopment'
+import {
+  analysisPresentation,
+  canEditDeviceObservability,
+  eventStatusLabel,
+  eventTypeLabel,
+  relationPathLabel,
+  toGraphData
+} from './deviceObservabilityModel'
+
+export default {
+  name: 'DeviceObservability',
+  components: { ChartGraph },
+  data: () => ({
+    loadingEvents: false,
+    loadingGraph: false,
+    loadingAnalysis: false,
+    savingImport: false,
+    events: [],
+    eventTotal: 0,
+    eventPage: 1,
+    selectedEvent: null,
+    graph: {},
+    analysis: null,
+    importDialog: false,
+    filters: {
+      keyword: '',
+      eventType: null,
+      severity: null,
+      assetUid: ''
+    },
+    importForm: {
+      sourceUid: '',
+      eventsJson: '[]',
+      relationsJson: '[]'
+    },
+    eventTypeOptions: [
+      { text: '告警', value: 'alarm' },
+      { text: '故障', value: 'fault' },
+      { text: '维修', value: 'maintenance' },
+      { text: '停机', value: 'downtime' }
+    ],
+    severityOptions: [
+      { text: '提示', value: 'info' },
+      { text: '警告', value: 'warning' },
+      { text: '错误', value: 'error' },
+      { text: '严重', value: 'critical' }
+    ],
+    eventHeaders: [
+      { text: '事件', value: 'title', sortable: false },
+      { text: '类型', value: 'event_type', sortable: false },
+      { text: '严重度', value: 'severity', sortable: false },
+      { text: '状态', value: 'status', sortable: false },
+      { text: '发生时间', value: 'occurred_at', sortable: false },
+      { text: '设备 UID', value: 'asset_uid', sortable: false },
+      { text: '操作', value: 'actions', sortable: false, width: 190 }
+    ]
+  }),
+  computed: {
+    permissions () {
+      return (this.$store.state.user.userInfo || {}).permissions || []
+    },
+    canEdit () {
+      return canEditDeviceObservability(this.permissions)
+    },
+    graphData () {
+      return toGraphData(this.graph)
+    },
+    analysisView () {
+      return analysisPresentation(this.analysis?.analysis_status)
+    }
+  },
+  created () {
+    this.loadEvents()
+  },
+  methods: {
+    eventTypeLabel,
+    eventStatusLabel,
+    relationPathLabel,
+    severityLabel (value) {
+      return {
+        info: '提示',
+        warning: '警告',
+        error: '错误',
+        critical: '严重'
+      }[value] || value
+    },
+    severityColor (value) {
+      return {
+        info: 'blue-grey',
+        warning: 'orange',
+        error: 'deep-orange',
+        critical: 'red darken-2'
+      }[value] || 'grey'
+    },
+    formatTime (value) {
+      return value ? new Date(value).toLocaleString() : '-'
+    },
+    async loadEvents () {
+      this.loadingEvents = true
+      try {
+        const response = await getDeviceOperationalEvents({
+          keyword: this.filters.keyword || undefined,
+          event_type: this.filters.eventType || undefined,
+          severity: this.filters.severity || undefined,
+          asset_uid: this.filters.assetUid || undefined,
+          page: this.eventPage,
+          page_size: 20
+        })
+        this.events = response.data.records || []
+        this.eventTotal = Number(response.data.total || 0)
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '运行事件加载失败')
+      } finally {
+        this.loadingEvents = false
+      }
+    },
+    async selectAnchor (item, analyze) {
+      this.selectedEvent = item
+      this.analysis = null
+      await this.loadGraph()
+      if (analyze) await this.loadAnalysis()
+    },
+    async loadGraph () {
+      this.loadingGraph = true
+      try {
+        const response = await getDeviceEvidenceGraph({
+          anchor_kind: 'event',
+          anchor_uid: this.selectedEvent.uid,
+          max_hops: 3
+        })
+        this.graph = response.data || {}
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '关系图加载失败')
+      } finally {
+        this.loadingGraph = false
+      }
+    },
+    async loadAnalysis () {
+      this.loadingAnalysis = true
+      try {
+        const response = await getDeviceRootCause({
+          anchor_kind: 'event',
+          anchor_uid: this.selectedEvent.uid,
+          max_hops: 3
+        })
+        this.analysis = response.data
+        this.graph = response.data.graph || this.graph
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '根因分析失败')
+      } finally {
+        this.loadingAnalysis = false
+      }
+    },
+    openImport () {
+      this.importDialog = true
+      this.importForm.eventsJson = '[]'
+      this.importForm.relationsJson = '[]'
+    },
+    parseArray (value, label) {
+      let parsed
+      try {
+        parsed = JSON.parse(value || '[]')
+      } catch (error) {
+        throw new Error(`${label}不是有效 JSON`)
+      }
+      if (!Array.isArray(parsed)) {
+        throw new Error(`${label}必须是数组`)
+      }
+      return parsed
+    },
+    async submitImport () {
+      this.savingImport = true
+      try {
+        const events = this.parseArray(this.importForm.eventsJson, '运行事件清单')
+        const relations = this.parseArray(this.importForm.relationsJson, '关系证据清单')
+        const response = await importDeviceOperationalEvidence({
+          source_uid: this.importForm.sourceUid,
+          events,
+          relations
+        })
+        this.$snackbar.success(
+          `已导入 ${response.data.created_events} 个事件、${response.data.created_relations} 条关系`
+        )
+        this.importDialog = false
+        this.eventPage = 1
+        await this.loadEvents()
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '运行证据导入失败')
+      } finally {
+        this.savingImport = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.device-observability {
+  max-width: 1600px;
+  margin: 0 auto;
+}
+
+.graph-placeholder {
+  min-height: 470px;
+  border: 1px dashed rgba(0, 0, 0, 0.18);
+  border-radius: 8px;
+  background: #fafbff;
+}
+
+.candidate-card {
+  border-left: 4px solid #f5a623 !important;
+}
+</style>

+ 121 - 0
frontend/src/views/dataGovernance/development/deviceObservabilityModel.js

@@ -0,0 +1,121 @@
+const EVENT_LABELS = {
+  alarm: '告警',
+  fault: '故障',
+  maintenance: '维修',
+  downtime: '停机'
+}
+
+const STATUS_LABELS = {
+  observed: '已发生',
+  resolved: '已恢复'
+}
+
+const RELATION_LABELS = {
+  part_of: '属于',
+  occurred_on: '发生于',
+  indicates: '表征',
+  triggered: '触发',
+  addresses: '处置',
+  impacts: '影响',
+  evidences: '佐证',
+  related_to: '相关'
+}
+
+const CATEGORY_DEFINITIONS = [
+  { key: 'device', name: '设备', color: '#3157d5' },
+  { key: 'component', name: '部件', color: '#5b77d8' },
+  { key: 'measurement_point', name: '测点', color: '#7f8fa6' },
+  { key: 'alarm', name: '告警', color: '#f5a623' },
+  { key: 'fault', name: '故障', color: '#e25555' },
+  { key: 'maintenance', name: '维修', color: '#31a36b' },
+  { key: 'downtime', name: '停机', color: '#842f63' },
+  { key: 'quality_issue', name: '质量问题', color: '#7b61c9' },
+  { key: 'asset', name: '资产', color: '#607d8b' }
+]
+
+export function eventTypeLabel (value) {
+  return EVENT_LABELS[value] || value || '未知事件'
+}
+
+export function eventStatusLabel (value) {
+  return STATUS_LABELS[value] || value || '-'
+}
+
+export function relationTypeLabel (value) {
+  return RELATION_LABELS[value] || value || '关系'
+}
+
+export function relationPathLabel (values = []) {
+  return values.map(relationTypeLabel).join(' → ')
+}
+
+export function canEditDeviceObservability (permissions = []) {
+  return permissions.includes('device-observability:edit')
+}
+
+export function analysisPresentation (status) {
+  if (status === 'supported_candidates') {
+    return {
+      color: 'warning',
+      icon: 'mdi-alert-decagram-outline',
+      title: '发现有证据支持的候选,等待专家确认'
+    }
+  }
+  return {
+    color: 'info',
+    icon: 'mdi-information-outline',
+    title: '证据不足,无法确认根因'
+  }
+}
+
+function categoryKey (node) {
+  if (node.kind === 'quality_issue') return 'quality_issue'
+  if (node.kind === 'event') return node.node_type
+  return node.node_type || 'asset'
+}
+
+export function toGraphData (graph = {}) {
+  const usedKeys = new Set(
+    (graph.nodes || []).map(categoryKey)
+  )
+  const definitions = CATEGORY_DEFINITIONS.filter(item => usedKeys.has(item.key))
+  const categories = definitions.map(item => ({
+    name: item.name,
+    itemStyle: { color: item.color }
+  }))
+  const categoryIndexes = new Map(
+    definitions.map((item, index) => [item.key, index])
+  )
+  const anchorKey = `${graph.anchor_kind}:${graph.anchor_uid}`
+  const nodes = (graph.nodes || []).map(node => {
+    const id = `${node.kind}:${node.uid}`
+    const isAnchor = id === anchorKey
+    return {
+      id,
+      name: node.label,
+      value: node.node_type,
+      category: categoryIndexes.get(categoryKey(node)) || 0,
+      symbolSize: isAnchor ? 62 : 46,
+      itemStyle: {
+        borderColor: isAnchor ? '#172554' : '#ffffff',
+        borderWidth: isAnchor ? 4 : 2
+      },
+      label: { show: true },
+      raw: node
+    }
+  })
+  const links = (graph.relations || []).map(relation => ({
+    id: relation.uid,
+    source: `${relation.from_kind}:${relation.from_uid}`,
+    target: `${relation.to_kind}:${relation.to_uid}`,
+    name: relationTypeLabel(relation.relation_type),
+    value: relation.relation_type,
+    label: {
+      show: true,
+      formatter: relationTypeLabel(relation.relation_type),
+      fontSize: 11
+    },
+    raw: relation
+  }))
+  return { nodes, links, categories }
+}

+ 1 - 0
frontend/src/views/dataGovernance/development/index.vue

@@ -27,6 +27,7 @@ export default {
       { title: '设备台账', description: '设备、部件、测点、告警与维护记录统一追溯', icon: 'mdi-factory', path: '/data-governance/development/device-assets' },
       { title: '实体匹配', description: '跨来源匹配候选、审核、非破坏性合并与回滚', icon: 'mdi-vector-link', path: '/data-governance/development/entity-resolution' },
       { title: '设备质量', description: '规则版本、质量检查、违规样本与资产评分', icon: 'mdi-shield-check-outline', path: '/data-governance/development/device-quality' },
+      { title: '设备关系与根因', description: '追溯设备运行事件关系,查看有证据约束的根因候选', icon: 'mdi-vector-polyline', path: '/data-governance/development/device-observability' },
       { title: '本体中心', description: '跨业务域本体定义、校验、发布与回滚', icon: 'mdi-graph-outline', path: '/data-governance/ontology' }
     ]
   })

+ 100 - 0
frontend/tests/device-observability-model.test.mjs

@@ -0,0 +1,100 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+  analysisPresentation,
+  canEditDeviceObservability,
+  eventStatusLabel,
+  eventTypeLabel,
+  relationPathLabel,
+  relationTypeLabel,
+  toGraphData
+} from '../src/views/dataGovernance/development/deviceObservabilityModel.js'
+
+test('presents operational event and relation labels for business users', () => {
+  assert.equal(eventTypeLabel('alarm'), '告警')
+  assert.equal(eventTypeLabel('fault'), '故障')
+  assert.equal(eventTypeLabel('maintenance'), '维修')
+  assert.equal(eventTypeLabel('downtime'), '停机')
+  assert.equal(eventStatusLabel('resolved'), '已恢复')
+  assert.equal(relationTypeLabel('triggered'), '触发')
+  assert.equal(
+    relationPathLabel(['indicates', 'triggered']),
+    '表征 → 触发'
+  )
+})
+
+test('converts bounded canonical graph into directed chart data', () => {
+  const result = toGraphData({
+    anchor_kind: 'event',
+    anchor_uid: 'event-downtime',
+    nodes: [
+      {
+        kind: 'event',
+        uid: 'event-alarm',
+        node_type: 'alarm',
+        label: '轴承温度高'
+      },
+      {
+        kind: 'event',
+        uid: 'event-downtime',
+        node_type: 'downtime',
+        label: '一号泵停机'
+      },
+      {
+        kind: 'asset',
+        uid: 'asset-pump',
+        node_type: 'device',
+        label: '一号泵'
+      }
+    ],
+    relations: [
+      {
+        uid: 'relation-1',
+        from_kind: 'event',
+        from_uid: 'event-alarm',
+        relation_type: 'triggered',
+        to_kind: 'event',
+        to_uid: 'event-downtime'
+      }
+    ]
+  })
+
+  assert.equal(result.nodes.length, 3)
+  assert.equal(result.links[0].source, 'event:event-alarm')
+  assert.equal(result.links[0].target, 'event:event-downtime')
+  assert.equal(result.links[0].name, '触发')
+  assert.equal(
+    result.nodes.find(item => item.id === 'event:event-downtime').itemStyle
+      .borderWidth,
+    4
+  )
+  assert.ok(result.categories.some(item => item.name === '设备'))
+})
+
+test('makes evidence sufficiency explicit and never presents a verdict', () => {
+  assert.deepEqual(analysisPresentation('supported_candidates'), {
+    color: 'warning',
+    icon: 'mdi-alert-decagram-outline',
+    title: '发现有证据支持的候选,等待专家确认'
+  })
+  assert.deepEqual(analysisPresentation('insufficient_evidence'), {
+    color: 'info',
+    icon: 'mdi-information-outline',
+    title: '证据不足,无法确认根因'
+  })
+})
+
+test('separates observability import permission from read access', () => {
+  assert.equal(
+    canEditDeviceObservability(['governance:read']),
+    false
+  )
+  assert.equal(
+    canEditDeviceObservability([
+      'governance:read',
+      'device-observability:edit'
+    ]),
+    true
+  )
+})

+ 98 - 0
migrations/versions/20260729_350_device_observability.py

@@ -0,0 +1,98 @@
+"""Add canonical device operational events and evidence relations."""
+
+from alembic import op
+
+revision = "20260729_350"
+down_revision = "20260729_340"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_operational_events (
+            uid UUID PRIMARY KEY,
+            source_uid UUID NOT NULL
+                REFERENCES public.ingestion_sources(uid) ON DELETE RESTRICT,
+            source_entity VARCHAR(300) NOT NULL,
+            source_code VARCHAR(300) NOT NULL,
+            event_type VARCHAR(30) NOT NULL
+                CHECK (
+                    event_type IN (
+                        'alarm','fault','maintenance','downtime'
+                    )
+                ),
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            component_uid UUID
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            title VARCHAR(500) NOT NULL,
+            severity VARCHAR(20) NOT NULL
+                CHECK (severity IN ('info','warning','error','critical')),
+            status VARCHAR(20) NOT NULL
+                CHECK (status IN ('observed','resolved')),
+            occurred_at TIMESTAMPTZ NOT NULL,
+            ended_at TIMESTAMPTZ
+                CHECK (ended_at IS NULL OR ended_at >= occurred_at),
+            evidence_refs JSONB NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            created_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (source_uid, source_entity, source_code)
+        );
+        CREATE INDEX idx_device_operational_event_asset_time
+            ON public.device_operational_events(
+                asset_uid, occurred_at DESC, uid
+            );
+        CREATE INDEX idx_device_operational_event_worklist
+            ON public.device_operational_events(
+                event_type, severity, status, occurred_at DESC
+            );
+
+        CREATE TABLE public.device_evidence_relations (
+            uid UUID PRIMARY KEY,
+            from_kind VARCHAR(30) NOT NULL
+                CHECK (
+                    from_kind IN ('asset','event','quality_issue')
+                ),
+            from_uid UUID NOT NULL,
+            relation_type VARCHAR(40) NOT NULL
+                CHECK (
+                    relation_type IN (
+                        'part_of','occurred_on','indicates','triggered',
+                        'addresses','impacts','evidences','related_to'
+                    )
+                ),
+            to_kind VARCHAR(30) NOT NULL
+                CHECK (
+                    to_kind IN ('asset','event','quality_issue')
+                ),
+            to_uid UUID NOT NULL,
+            evidence_refs JSONB NOT NULL,
+            source VARCHAR(30) NOT NULL
+                CHECK (source IN ('imported','manual','derived')),
+            created_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (NOT (from_kind = to_kind AND from_uid = to_uid)),
+            UNIQUE (
+                from_kind, from_uid, relation_type, to_kind, to_uid
+            )
+        );
+        CREATE INDEX idx_device_evidence_relation_from
+            ON public.device_evidence_relations(
+                from_kind, from_uid, created_at, uid
+            );
+        CREATE INDEX idx_device_evidence_relation_to
+            ON public.device_evidence_relations(
+                to_kind, to_uid, created_at, uid
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Operational evidence is retained during application rollback.
+    pass

+ 433 - 0
tests/data_research/test_device_observability.py

@@ -0,0 +1,433 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import UTC, datetime
+
+import pytest
+
+from app.core.data_research.device_observability import (
+    DeviceObservabilityConflict,
+    DeviceObservabilityInvalid,
+    DeviceObservabilityNotFound,
+    DeviceObservabilityService,
+    EvidenceRelationRecord,
+    GraphNode,
+    GraphProjection,
+)
+
+SOURCE_UID = "11111111-1111-4111-8111-111111111111"
+DEVICE_UID = "22222222-2222-4222-8222-222222222222"
+ISSUE_UID = "33333333-3333-4333-8333-333333333333"
+ACTOR_UID = "44444444-4444-4444-8444-444444444444"
+
+
+class MemoryRepository:
+    def __init__(self):
+        self.events = {}
+        self.identities = {}
+        self.relations = {}
+        self.nodes = {
+            ("asset", DEVICE_UID): GraphNode(
+                kind="asset",
+                uid=DEVICE_UID,
+                node_type="device",
+                label="一号泵",
+                occurred_at=None,
+                evidence_refs=(),
+            ),
+            ("quality_issue", ISSUE_UID): GraphNode(
+                kind="quality_issue",
+                uid=ISSUE_UID,
+                node_type="quality_issue",
+                label="QI-001",
+                occurred_at=None,
+                evidence_refs=(),
+            ),
+        }
+
+    def source_exists(self, source_uid):
+        return source_uid == SOURCE_UID
+
+    def get_event_by_identity(self, source_uid, source_entity, source_code):
+        uid = self.identities.get((source_uid, source_entity, source_code))
+        return self.events.get(uid)
+
+    def add_event(self, record):
+        self.events[record.uid] = record
+        self.identities[
+            (record.source_uid, record.source_entity, record.source_code)
+        ] = record.uid
+        self.nodes[("event", record.uid)] = GraphNode(
+            kind="event",
+            uid=record.uid,
+            node_type=record.event_type,
+            label=record.title,
+            occurred_at=record.occurred_at,
+            evidence_refs=record.evidence_refs,
+        )
+
+    def resolve_event_identity(self, source_uid, source_entity, source_code):
+        return self.identities.get((source_uid, source_entity, source_code))
+
+    def node_exists(self, kind, uid):
+        return (kind, uid) in self.nodes
+
+    def get_relation(self, record):
+        return self.relations.get(record.identity)
+
+    def add_relation(self, record):
+        self.relations[record.identity] = record
+
+    def graph(self, anchor_kind, anchor_uid, max_hops, max_nodes, max_edges):
+        assert max_hops <= 3
+        nodes = {f"{anchor_kind}:{anchor_uid}": self.nodes[(anchor_kind, anchor_uid)]}
+        edges = []
+        frontier = {(anchor_kind, anchor_uid)}
+        visited = set(frontier)
+        for _ in range(max_hops):
+            next_frontier = set()
+            for relation in self.relations.values():
+                source = (relation.from_kind, relation.from_uid)
+                target = (relation.to_kind, relation.to_uid)
+                if source not in frontier and target not in frontier:
+                    continue
+                if len(edges) >= max_edges:
+                    break
+                edges.append(relation)
+                for endpoint in (source, target):
+                    if endpoint not in visited and len(nodes) < max_nodes:
+                        visited.add(endpoint)
+                        next_frontier.add(endpoint)
+                        nodes[f"{endpoint[0]}:{endpoint[1]}"] = self.nodes[endpoint]
+            frontier = next_frontier
+            if not frontier:
+                break
+        return GraphProjection(
+            anchor_kind=anchor_kind,
+            anchor_uid=anchor_uid,
+            nodes=tuple(nodes.values()),
+            relations=tuple(edges),
+            truncated=False,
+        )
+
+
+def service(repository=None, uid_values=None):
+    values = iter(
+        uid_values
+        or (
+            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
+            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
+            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3",
+            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4",
+            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5",
+            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa6",
+            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa7",
+            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa8",
+        )
+    )
+    return DeviceObservabilityService(
+        repository or MemoryRepository(),
+        uid_factory=lambda: next(values),
+    )
+
+
+def event(source_code, event_type, title, **extra):
+    return {
+        "source_entity": f"{event_type}_events",
+        "source_code": source_code,
+        "event_type": event_type,
+        "asset_uid": DEVICE_UID,
+        "title": title,
+        "severity": extra.pop("severity", "warning"),
+        "status": extra.pop("status", "observed"),
+        "occurred_at": extra.pop(
+            "occurred_at",
+            datetime(2026, 7, 29, 8, 0, tzinfo=UTC).isoformat(),
+        ),
+        "evidence": extra.pop("evidence", {"source_row": source_code}),
+        **extra,
+    }
+
+
+def event_endpoint(event_type, source_code):
+    return {
+        "kind": "event",
+        "source_entity": f"{event_type}_events",
+        "source_code": source_code,
+    }
+
+
+def test_import_is_idempotent_by_source_identity_and_rejects_conflicts():
+    repository = MemoryRepository()
+    observability = service(repository)
+    payload = {
+        "source_uid": SOURCE_UID,
+        "events": [event("AL-1", "alarm", "轴承温度高")],
+        "relations": [],
+    }
+
+    created = observability.import_evidence(payload, ACTOR_UID)
+    duplicate = observability.import_evidence(payload, ACTOR_UID)
+
+    assert created.created_events == 1
+    assert isinstance(created.events[0].occurred_at, datetime)
+    assert created.events[0].occurred_at.tzinfo is not None
+    assert duplicate.existing_events == 1
+    with pytest.raises(DeviceObservabilityConflict):
+        observability.import_evidence(
+            {
+                **payload,
+                "events": [event("AL-1", "alarm", "轴承振动高")],
+            },
+            ACTOR_UID,
+        )
+
+
+def test_import_rejects_secrets_and_invalid_event_windows():
+    observability = service()
+    with pytest.raises(DeviceObservabilityInvalid, match="secret"):
+        observability.import_evidence(
+            {
+                "source_uid": SOURCE_UID,
+                "events": [
+                    event(
+                        "AL-2",
+                        "alarm",
+                        "高温告警",
+                        evidence={"password": "must-not-persist"},
+                    )
+                ],
+                "relations": [],
+            },
+            ACTOR_UID,
+        )
+    with pytest.raises(DeviceObservabilityInvalid, match="ended_at"):
+        observability.import_evidence(
+            {
+                "source_uid": SOURCE_UID,
+                "events": [
+                    event(
+                        "DT-1",
+                        "downtime",
+                        "设备停机",
+                        ended_at="2026-07-29T07:00:00+00:00",
+                    )
+                ],
+                "relations": [],
+            },
+            ACTOR_UID,
+        )
+
+
+def test_relations_resolve_new_event_identities_and_require_existing_nodes():
+    repository = MemoryRepository()
+    observability = service(repository)
+    result = observability.import_evidence(
+        {
+            "source_uid": SOURCE_UID,
+            "events": [
+                event("AL-3", "alarm", "压力告警"),
+                event("FT-3", "fault", "泵体故障"),
+            ],
+            "relations": [
+                {
+                    "from": event_endpoint("alarm", "AL-3"),
+                    "relation_type": "indicates",
+                    "to": event_endpoint("fault", "FT-3"),
+                    "evidence": {"rule": "same-device-window"},
+                }
+            ],
+        },
+        ACTOR_UID,
+    )
+    assert result.created_relations == 1
+    relation = next(iter(repository.relations.values()))
+    assert relation.from_kind == "event"
+    assert relation.to_kind == "event"
+
+    with pytest.raises(DeviceObservabilityConflict):
+        observability.import_evidence(
+            {
+                "source_uid": SOURCE_UID,
+                "events": [],
+                "relations": [
+                    {
+                        "from": event_endpoint("alarm", "AL-3"),
+                        "relation_type": "indicates",
+                        "to": event_endpoint("fault", "FT-3"),
+                        "evidence": {"rule": "different-evidence"},
+                    }
+                ],
+            },
+            ACTOR_UID,
+        )
+
+    with pytest.raises(DeviceObservabilityInvalid, match="does not exist"):
+        observability.import_evidence(
+            {
+                "source_uid": SOURCE_UID,
+                "events": [],
+                "relations": [
+                    {
+                        "from": {"kind": "asset", "uid": DEVICE_UID},
+                        "relation_type": "part_of",
+                        "to": {
+                            "kind": "asset",
+                            "uid": "99999999-9999-4999-8999-999999999999",
+                        },
+                        "evidence": {},
+                    }
+                ],
+            },
+            ACTOR_UID,
+        )
+
+
+def test_graph_limits_hops_and_requires_a_real_anchor():
+    observability = service()
+    with pytest.raises(DeviceObservabilityInvalid, match="max_hops"):
+        observability.graph("asset", DEVICE_UID, max_hops=4)
+    with pytest.raises(DeviceObservabilityNotFound, match="does not exist"):
+        observability.graph(
+            "asset",
+            "99999999-9999-4999-8999-999999999999",
+            max_hops=2,
+        )
+
+
+def test_root_cause_returns_evidence_paths_without_claiming_a_verdict():
+    repository = MemoryRepository()
+    observability = service(repository)
+    observability.import_evidence(
+        {
+            "source_uid": SOURCE_UID,
+            "events": [
+                event("AL-4", "alarm", "轴承温度高"),
+                event("FT-4", "fault", "轴承故障"),
+                event("DT-4", "downtime", "一号泵停机"),
+            ],
+            "relations": [
+                {
+                    "from": event_endpoint("alarm", "AL-4"),
+                    "relation_type": "indicates",
+                    "to": event_endpoint("fault", "FT-4"),
+                    "evidence": {"window_minutes": 5},
+                },
+                {
+                    "from": event_endpoint("fault", "FT-4"),
+                    "relation_type": "triggered",
+                    "to": event_endpoint("downtime", "DT-4"),
+                    "evidence": {"work_order": "WO-4"},
+                },
+            ],
+        },
+        ACTOR_UID,
+    )
+    anchor_uid = repository.resolve_event_identity(
+        SOURCE_UID,
+        "downtime_events",
+        "DT-4",
+    )
+
+    result = observability.root_cause("event", anchor_uid, max_hops=3)
+
+    assert result.analysis_status == "supported_candidates"
+    assert result.conclusion == "发现有证据支持的根因候选,仍需设备专家确认"
+    assert [item.event_type for item in result.candidates] == ["fault", "alarm"]
+    assert result.candidates[0].path_relation_types == ("triggered",)
+    assert result.candidates[1].path_relation_types == (
+        "indicates",
+        "triggered",
+    )
+    assert "不等同于已确认根因" in result.limitations[0]
+
+
+def test_root_cause_explicitly_reports_insufficient_evidence():
+    repository = MemoryRepository()
+    observability = service(repository)
+    imported = observability.import_evidence(
+        {
+            "source_uid": SOURCE_UID,
+            "events": [event("DT-5", "downtime", "二号泵停机")],
+            "relations": [
+                {
+                    "from": event_endpoint("downtime", "DT-5"),
+                    "relation_type": "occurred_on",
+                    "to": {"kind": "asset", "uid": DEVICE_UID},
+                    "evidence": {"source": "downtime-log"},
+                }
+            ],
+        },
+        ACTOR_UID,
+    )
+    anchor_uid = imported.events[0].uid
+
+    result = observability.root_cause("event", anchor_uid)
+
+    assert result.analysis_status == "insufficient_evidence"
+    assert result.conclusion == "证据不足,无法确认根因"
+    assert result.candidates == ()
+
+
+def test_root_cause_does_not_treat_a_future_event_as_a_candidate_cause():
+    repository = MemoryRepository()
+    observability = service(repository)
+    imported = observability.import_evidence(
+        {
+            "source_uid": SOURCE_UID,
+            "events": [
+                event(
+                    "DT-6",
+                    "downtime",
+                    "三号泵停机",
+                    occurred_at="2026-07-29T08:00:00+00:00",
+                ),
+                event(
+                    "FT-6",
+                    "fault",
+                    "停机后补录故障",
+                    occurred_at="2026-07-29T09:00:00+00:00",
+                ),
+            ],
+            "relations": [
+                {
+                    "from": event_endpoint("fault", "FT-6"),
+                    "relation_type": "triggered",
+                    "to": event_endpoint("downtime", "DT-6"),
+                    "evidence": {"source": "late-manual-entry"},
+                }
+            ],
+        },
+        ACTOR_UID,
+    )
+    anchor_uid = next(
+        item.uid
+        for item in imported.events
+        if item.event_type == "downtime"
+    )
+
+    result = observability.root_cause("event", anchor_uid)
+
+    assert result.analysis_status == "insufficient_evidence"
+    assert result.candidates == ()
+
+
+def test_relation_identity_includes_direction_and_type():
+    base = EvidenceRelationRecord(
+        uid="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+        from_kind="event",
+        from_uid="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+        relation_type="indicates",
+        to_kind="event",
+        to_uid="cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+        evidence_refs=(),
+        source="imported",
+        created_by=ACTOR_UID,
+        created_at=None,
+    )
+    assert base.identity != replace(base, relation_type="triggered").identity
+    assert base.identity != replace(
+        base,
+        from_uid=base.to_uid,
+        to_uid=base.from_uid,
+    ).identity

+ 234 - 0
tests/data_research/test_device_observability_api.py

@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import pytest
+
+SOURCE_UID = "01900000-0000-7000-8000-000000000911"
+DEVICE_UID = "01900000-0000-7000-8000-000000000912"
+EVENT_UID = "01900000-0000-7000-8000-000000000913"
+FAULT_UID = "01900000-0000-7000-8000-000000000914"
+RELATION_UID = "01900000-0000-7000-8000-000000000915"
+
+
+class FakeDeviceObservabilityService:
+    def __init__(self):
+        from app.core.data_research.device_observability import (
+            EvidenceRelationRecord,
+            GraphNode,
+            GraphProjection,
+            OperationalEventRecord,
+            RootCauseAnalysis,
+            RootCauseCandidate,
+        )
+
+        now = datetime(2026, 7, 29, 11, tzinfo=UTC)
+        self.event = OperationalEventRecord(
+            uid=EVENT_UID,
+            source_uid=SOURCE_UID,
+            source_entity="downtime_events",
+            source_code="DT-001",
+            event_type="downtime",
+            asset_uid=DEVICE_UID,
+            component_uid=None,
+            title="一号泵停机",
+            severity="critical",
+            status="resolved",
+            occurred_at=now,
+            ended_at=now,
+            evidence_refs=(
+                {"type": "source_evidence", "data": {"duration": 18}},
+            ),
+            content_hash="a" * 64,
+            created_by="editor-1",
+            created_at=now,
+        )
+        self.fault_node = GraphNode(
+            kind="event",
+            uid=FAULT_UID,
+            node_type="fault",
+            label="轴承故障",
+            occurred_at=now,
+            evidence_refs=(),
+        )
+        self.anchor_node = GraphNode(
+            kind="event",
+            uid=EVENT_UID,
+            node_type="downtime",
+            label="一号泵停机",
+            occurred_at=now,
+            evidence_refs=self.event.evidence_refs,
+        )
+        self.relation = EvidenceRelationRecord(
+            uid=RELATION_UID,
+            from_kind="event",
+            from_uid=FAULT_UID,
+            relation_type="triggered",
+            to_kind="event",
+            to_uid=EVENT_UID,
+            evidence_refs=(
+                {"type": "source_evidence", "data": {"work_order": "WO-1"}},
+            ),
+            source="imported",
+            created_by="editor-1",
+            created_at=now,
+        )
+        self.graph_result = GraphProjection(
+            anchor_kind="event",
+            anchor_uid=EVENT_UID,
+            nodes=(self.anchor_node, self.fault_node),
+            relations=(self.relation,),
+            truncated=False,
+        )
+        self.analysis = RootCauseAnalysis(
+            analysis_status="supported_candidates",
+            conclusion="发现有证据支持的根因候选,仍需设备专家确认",
+            anchor=self.anchor_node,
+            candidates=(
+                RootCauseCandidate(
+                    event_uid=FAULT_UID,
+                    event_type="fault",
+                    title="轴承故障",
+                    occurred_at=now,
+                    support_level="direct",
+                    path_node_uids=(FAULT_UID, EVENT_UID),
+                    path_relation_uids=(RELATION_UID,),
+                    path_relation_types=("triggered",),
+                    evidence_refs=self.relation.evidence_refs,
+                ),
+            ),
+            graph=self.graph_result,
+            limitations=("不等同于已确认根因",),
+            generated_at=now,
+        )
+        self.actions = []
+
+    def events(self, filters, *, page, page_size):
+        self.actions.append(("events", filters, page, page_size))
+        return [self.event], 1
+
+    def import_evidence(self, payload, actor_uid):
+        from app.core.data_research.device_observability import (
+            ObservabilityImportResult,
+        )
+
+        self.actions.append(("import", payload, actor_uid))
+        return ObservabilityImportResult(
+            events=(self.event,),
+            relations=(self.relation,),
+            created_events=1,
+            existing_events=0,
+            created_relations=1,
+            existing_relations=0,
+        )
+
+    def graph(self, anchor_kind, anchor_uid, *, max_hops):
+        self.actions.append(("graph", anchor_kind, anchor_uid, max_hops))
+        return self.graph_result
+
+    def root_cause(self, anchor_kind, anchor_uid, *, max_hops):
+        self.actions.append(
+            ("root_cause", anchor_kind, anchor_uid, max_hops)
+        )
+        return self.analysis
+
+
+@pytest.fixture()
+def client(monkeypatch):
+    from flask import request
+
+    from app import create_app
+    from app.api.data_development import routes
+    from app.core.system import permissions
+
+    service = FakeDeviceObservabilityService()
+
+    def identity():
+        role = request.headers.get(
+            "Authorization", ""
+        ).removeprefix("Bearer ")
+        if role not in {"viewer", "editor", "admin"}:
+            return None
+        return {"id": f"{role}-1", "roles": [role]}
+
+    monkeypatch.setattr(permissions, "authenticate_request", identity)
+    monkeypatch.setattr(
+        routes,
+        "get_device_observability_service",
+        lambda: service,
+        raising=False,
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_viewer_reads_events_graph_and_evidence_bound_analysis(client):
+    http, service = client
+    headers = {"Authorization": "Bearer viewer"}
+
+    listing = http.get(
+        "/api/development/v1/device-observability/events"
+        f"?event_type=downtime&asset_uid={DEVICE_UID}&page=1&page_size=20",
+        headers=headers,
+    )
+    graph = http.get(
+        "/api/development/v1/device-observability/graph"
+        f"?anchor_kind=event&anchor_uid={EVENT_UID}&max_hops=2",
+        headers=headers,
+    )
+    analysis = http.get(
+        "/api/development/v1/device-observability/root-cause"
+        f"?anchor_kind=event&anchor_uid={EVENT_UID}&max_hops=3",
+        headers=headers,
+    )
+    denied = http.post(
+        "/api/development/v1/device-observability/import",
+        headers=headers,
+        json={"source_uid": SOURCE_UID, "events": [], "relations": []},
+    )
+
+    assert listing.status_code == 200
+    assert listing.get_json()["data"]["records"][0]["event_type"] == (
+        "downtime"
+    )
+    assert graph.status_code == 200
+    assert graph.get_json()["data"]["relations"][0]["relation_type"] == (
+        "triggered"
+    )
+    assert analysis.status_code == 200
+    data = analysis.get_json()["data"]
+    assert data["analysis_status"] == "supported_candidates"
+    assert data["candidates"][0]["path_relation_types"] == ["triggered"]
+    assert denied.status_code == 403
+    assert service.actions[0][0] == "events"
+
+
+def test_editor_imports_bounded_operational_evidence(client):
+    http, service = client
+    response = http.post(
+        "/api/development/v1/device-observability/import",
+        headers={"Authorization": "Bearer editor"},
+        json={
+            "source_uid": SOURCE_UID,
+            "events": [
+                {
+                    "source_entity": "downtime_events",
+                    "source_code": "DT-001",
+                    "event_type": "downtime",
+                    "asset_uid": DEVICE_UID,
+                    "title": "一号泵停机",
+                    "severity": "critical",
+                    "status": "resolved",
+                    "occurred_at": "2026-07-29T11:00:00+00:00",
+                    "evidence": {"duration": 18},
+                }
+            ],
+            "relations": [],
+        },
+    )
+
+    assert response.status_code == 201
+    assert response.get_json()["data"]["created_events"] == 1
+    assert service.actions[-1][0] == "import"
+    assert service.actions[-1][2] == "editor-1"

+ 46 - 0
tests/data_research/test_device_observability_frontend_contract.py

@@ -0,0 +1,46 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_device_observability_workbench_exposes_evidence_bound_contract():
+    page = (
+        ROOT
+        / "frontend/src/views/dataGovernance/development/"
+        "deviceObservability.vue"
+    ).read_text(encoding="utf-8")
+    api = (ROOT / "frontend/src/api/dataDevelopment.js").read_text(
+        encoding="utf-8"
+    )
+    routes = (ROOT / "frontend/src/router/routes.js").read_text(
+        encoding="utf-8"
+    )
+    center = (
+        ROOT / "frontend/src/views/dataGovernance/development/index.vue"
+    ).read_text(encoding="utf-8")
+    model = (
+        ROOT
+        / "frontend/src/views/dataGovernance/development/"
+        "deviceObservabilityModel.js"
+    ).read_text(encoding="utf-8")
+
+    for text in (
+        "设备关系与根因",
+        "证据不足,无法确认根因",
+        "根因候选",
+        "关系证据",
+        "运行证据导入",
+        "不等同于已确认根因",
+    ):
+        assert text in page
+    assert "this.$snackbar.success" in page
+    assert "this.$snackbar.error" in page
+    assert "this.$message" not in page
+    assert "device-observability:edit" in model
+    assert "getDeviceOperationalEvents" in api
+    assert "getDeviceEvidenceGraph" in api
+    assert "getDeviceRootCause" in api
+    assert "importDeviceOperationalEvidence" in api
+    assert "/data-governance/development/device-observability" in routes
+    assert "deviceObservability" in routes
+    assert "设备关系与根因" in center

+ 265 - 0
tests/integration/test_device_observability_postgres.py

@@ -0,0 +1,265 @@
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import UTC, datetime
+
+import pytest
+from sqlalchemy import text
+
+pytestmark = pytest.mark.integration
+
+
+def test_device_observability_persists_graph_and_root_cause(monkeypatch):
+    platform_url = os.environ.get("TEST_DATABASE_URL")
+    if not platform_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+
+    monkeypatch.setenv("DATABASE_URL", platform_url)
+    from app import create_app, db
+    from app.core.data_research.device_observability import (
+        DeviceObservabilityConflict,
+        DeviceObservabilityService,
+    )
+    from app.core.data_research.device_observability_repository import (
+        SqlAlchemyDeviceObservabilityRepository,
+    )
+    from app.models.data_research import DeviceAsset, IngestionSource
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    suffix = uuid.uuid4().hex[:10]
+    actor_uid = str(uuid.uuid4())
+    source_uid = str(uuid.uuid4())
+    device_uid = str(uuid.uuid4())
+    now = datetime.now(UTC).replace(microsecond=0)
+
+    try:
+        with app.app_context():
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.users (
+                        id, username, display_name, password_hash, status
+                    ) VALUES (
+                        CAST(:id AS uuid), :username, :username,
+                        'integration-test', 'active'
+                    )
+                    """
+                ),
+                {
+                    "id": actor_uid,
+                    "username": f"wp09-actor-{suffix}",
+                },
+            )
+            db.session.add(
+                IngestionSource(
+                    uid=source_uid,
+                    source_type="database",
+                    name=f"WP09 source {suffix}",
+                    config={"database_type": "postgresql"},
+                    permission_scope={},
+                    status="active",
+                    created_by=actor_uid,
+                )
+            )
+            db.session.add(
+                DeviceAsset(
+                    uid=device_uid,
+                    asset_type="device",
+                    name=f"WP09 pump {suffix}",
+                    status="active",
+                    current_version=1,
+                    content_hash="9" * 64,
+                    attributes={},
+                    created_by=actor_uid,
+                    updated_by=actor_uid,
+                )
+            )
+            db.session.commit()
+
+            repository = SqlAlchemyDeviceObservabilityRepository(db.session)
+            service = DeviceObservabilityService(
+                repository,
+                commit=db.session.commit,
+                rollback=db.session.rollback,
+            )
+            imported = service.import_evidence(
+                {
+                    "source_uid": source_uid,
+                    "events": [
+                        {
+                            "source_entity": "alarm_events",
+                            "source_code": f"AL-{suffix}",
+                            "event_type": "alarm",
+                            "asset_uid": device_uid,
+                            "title": "轴承温度高",
+                            "severity": "warning",
+                            "status": "observed",
+                            "occurred_at": now.isoformat(),
+                            "evidence": {"sample_ref": f"S-{suffix}"},
+                        },
+                        {
+                            "source_entity": "fault_events",
+                            "source_code": f"FT-{suffix}",
+                            "event_type": "fault",
+                            "asset_uid": device_uid,
+                            "title": "轴承故障",
+                            "severity": "error",
+                            "status": "observed",
+                            "occurred_at": now.isoformat(),
+                            "evidence": {"fault_code": "F-001"},
+                        },
+                        {
+                            "source_entity": "downtime_events",
+                            "source_code": f"DT-{suffix}",
+                            "event_type": "downtime",
+                            "asset_uid": device_uid,
+                            "title": "泵停机",
+                            "severity": "critical",
+                            "status": "resolved",
+                            "occurred_at": now.isoformat(),
+                            "ended_at": now.isoformat(),
+                            "evidence": {"duration_minutes": 18},
+                        },
+                    ],
+                    "relations": [
+                        {
+                            "from": {
+                                "kind": "event",
+                                "source_entity": "alarm_events",
+                                "source_code": f"AL-{suffix}",
+                            },
+                            "relation_type": "indicates",
+                            "to": {
+                                "kind": "event",
+                                "source_entity": "fault_events",
+                                "source_code": f"FT-{suffix}",
+                            },
+                            "evidence": {"window_minutes": 5},
+                        },
+                        {
+                            "from": {
+                                "kind": "event",
+                                "source_entity": "fault_events",
+                                "source_code": f"FT-{suffix}",
+                            },
+                            "relation_type": "triggered",
+                            "to": {
+                                "kind": "event",
+                                "source_entity": "downtime_events",
+                                "source_code": f"DT-{suffix}",
+                            },
+                            "evidence": {"operator_log_ref": f"OP-{suffix}"},
+                        },
+                    ],
+                },
+                actor_uid,
+            )
+            assert imported.created_events == 3
+            assert imported.created_relations == 2
+
+            duplicate = service.import_evidence(
+                {
+                    "source_uid": source_uid,
+                    "events": [
+                        {
+                            "source_entity": "alarm_events",
+                            "source_code": f"AL-{suffix}",
+                            "event_type": "alarm",
+                            "asset_uid": device_uid,
+                            "title": "轴承温度高",
+                            "severity": "warning",
+                            "status": "observed",
+                            "occurred_at": now.isoformat(),
+                            "evidence": {"sample_ref": f"S-{suffix}"},
+                        }
+                    ],
+                    "relations": [],
+                },
+                actor_uid,
+            )
+            assert duplicate.existing_events == 1
+            with pytest.raises(DeviceObservabilityConflict):
+                service.import_evidence(
+                    {
+                        "source_uid": source_uid,
+                        "events": [
+                            {
+                                "source_entity": "alarm_events",
+                                "source_code": f"AL-{suffix}",
+                                "event_type": "alarm",
+                                "asset_uid": device_uid,
+                                "title": "冲突告警标题",
+                                "severity": "warning",
+                                "status": "observed",
+                                "occurred_at": now.isoformat(),
+                                "evidence": {},
+                            }
+                        ],
+                        "relations": [],
+                    },
+                    actor_uid,
+                )
+
+            records, total = repository.search_events(
+                {"event_type": "downtime", "asset_uid": device_uid},
+                page=1,
+                page_size=20,
+            )
+            assert total == 1
+            assert records[0].title == "泵停机"
+            graph = service.graph("event", records[0].uid, max_hops=3)
+            assert len(graph.nodes) == 3
+            assert [item.relation_type for item in graph.relations] == [
+                "triggered",
+                "indicates",
+            ]
+            analysis = service.root_cause(
+                "event",
+                records[0].uid,
+                max_hops=3,
+            )
+            assert analysis.analysis_status == "supported_candidates"
+            assert [item.event_type for item in analysis.candidates] == [
+                "fault",
+                "alarm",
+            ]
+    finally:
+        with app.app_context():
+            db.session.execute(
+                text(
+                    "DELETE FROM public.device_evidence_relations "
+                    "WHERE created_by = CAST(:actor_uid AS uuid)"
+                ),
+                {"actor_uid": actor_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.device_operational_events "
+                    "WHERE created_by = CAST(:actor_uid AS uuid)"
+                ),
+                {"actor_uid": actor_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.device_assets "
+                    "WHERE uid = CAST(:uid AS uuid)"
+                ),
+                {"uid": device_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.ingestion_sources "
+                    "WHERE uid = CAST(:uid AS uuid)"
+                ),
+                {"uid": source_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.users "
+                    "WHERE id = CAST(:id AS uuid)"
+                ),
+                {"id": actor_uid},
+            )
+            db.session.commit()

+ 21 - 1
tests/test_architecture_artifacts.py

@@ -3,7 +3,6 @@ import subprocess
 import sys
 from pathlib import Path
 
-
 ROOT = Path(__file__).resolve().parents[1]
 ARCH = ROOT / "docs" / "architecture"
 
@@ -71,6 +70,27 @@ def test_openapi_generator_is_reproducible(tmp_path):
     assert generated.read_bytes() == (ARCH / "OPENAPI.yaml").read_bytes()
 
 
+def test_wp09_contract_and_migration_are_documented():
+    contract = (ARCH / "OPENAPI.yaml").read_text(encoding="utf-8")
+    data_model = (ARCH / "DATA_MODEL.md").read_text(encoding="utf-8")
+    migration = (
+        ROOT
+        / "migrations/versions/20260729_350_device_observability.py"
+    )
+
+    for path in (
+        "/api/development/v1/device-observability/events",
+        "/api/development/v1/device-observability/import",
+        "/api/development/v1/device-observability/graph",
+        "/api/development/v1/device-observability/root-cause",
+    ):
+        assert path in contract
+    assert migration.exists()
+    assert "device_operational_events" in data_model
+    assert "device_evidence_relations" in data_model
+    assert "证据不足,无法确认根因" in data_model
+
+
 def test_contract_ci_regenerates_and_checks_the_committed_inventory():
     workflow = (ROOT / ".github" / "workflows" / "contracts.yml").read_text(
         encoding="utf-8"

+ 17 - 0
tests/test_permission_matrix.py

@@ -33,6 +33,7 @@ def test_fixed_role_permission_matrix_is_monotonic():
     assert "quality-issues:edit" in editor
     assert "quality-issues:review" not in editor
     assert "quality-issues:review" in admin
+    assert "device-observability:edit" in editor
 
 
 def test_data_development_paths_have_specific_write_policies():
@@ -125,6 +126,22 @@ def test_data_development_paths_have_specific_write_policies():
         "/api/development/v1/device-quality/issues/issue-1/review",
         "POST",
     ) == ("quality-issues:review",)
+    assert permission_for_request(
+        "/api/development/v1/device-observability/events",
+        "GET",
+    ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/development/v1/device-observability/graph",
+        "GET",
+    ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/development/v1/device-observability/root-cause",
+        "GET",
+    ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/development/v1/device-observability/import",
+        "POST",
+    ) == ("device-observability:edit",)
 
 
 def test_business_domain_read_endpoints_are_available_to_viewers():