Просмотр исходного кода

feat: add authorized device knowledge search

马小龙 3 недель назад
Родитель
Сommit
8015012e7f
30 измененных файлов с 4584 добавлено и 45 удалено
  1. 200 7
      app/api/knowledge_base/routes.py
  2. 146 0
      app/core/knowledge/device_scope.py
  3. 149 0
      app/core/knowledge/query_audit.py
  4. 470 0
      app/core/knowledge/retrieval/device.py
  5. 3 1
      app/core/knowledge/retrieval/pipeline.py
  6. 530 0
      deployment/app/api/knowledge_base/routes.py
  7. 146 0
      deployment/app/core/knowledge/device_scope.py
  8. 149 0
      deployment/app/core/knowledge/query_audit.py
  9. 470 0
      deployment/app/core/knowledge/retrieval/device.py
  10. 74 0
      deployment/app/core/knowledge/retrieval/pipeline.py
  11. 1 0
      docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md
  12. 18 17
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  13. 131 0
      docs/acceptance/WP10_DEVICE_KNOWLEDGE_GOLDEN_SET.json
  14. 16 0
      docs/architecture/DATA_MODEL.md
  15. 398 1
      docs/architecture/OPENAPI.yaml
  16. 125 0
      docs/superpowers/plans/2026-07-29-wp10-device-search-knowledge-qa.md
  17. 3 0
      frontend/src/api/governanceKnowledge.js
  18. 67 0
      frontend/src/views/knowledgeBaseProduct/deviceKnowledgeModel.js
  19. 229 11
      frontend/src/views/knowledgeBaseProduct/index.vue
  20. 78 0
      frontend/tests/device-knowledge-model.test.mjs
  21. 37 7
      scripts/generate_openapi.py
  22. 243 0
      tests/integration/test_device_knowledge_postgres.py
  23. 366 0
      tests/knowledge/test_api.py
  24. 35 0
      tests/knowledge/test_device_knowledge_frontend_contract.py
  25. 154 0
      tests/knowledge/test_device_retrieval.py
  26. 154 0
      tests/knowledge/test_device_scope.py
  27. 116 0
      tests/knowledge/test_query_audit.py
  28. 33 0
      tests/knowledge/test_retrieval.py
  29. 34 1
      tests/test_architecture_artifacts.py
  30. 9 0
      tests/test_permission_matrix.py

+ 200 - 7
app/api/knowledge_base/routes.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import uuid
+from time import perf_counter
 
 from flask import current_app, g, jsonify, request
 from sqlalchemy import text
@@ -16,7 +17,21 @@ from app.core.knowledge.admin import (
     rollback_change_set,
 )
 from app.core.knowledge.audit import run_canonical_audit
+from app.core.knowledge.device_scope import (
+    DeviceSourceScopeError,
+    DeviceSourceScopeService,
+    SqlAlchemyDeviceSourceScopeRepository,
+    source_scope_access,
+)
 from app.core.knowledge.qa import AnswerSynthesizer, DeepSeekAnswerModel
+from app.core.knowledge.query_audit import (
+    SqlKnowledgeQueryAuditRepository,
+    build_query_audit,
+)
+from app.core.knowledge.retrieval.device import (
+    SqlDeviceKnowledgeRepository,
+    SqlDeviceKnowledgeRetriever,
+)
 from app.core.knowledge.retrieval.pipeline import KnowledgeRetrievalPipeline
 from app.core.knowledge.retrieval.sql import (
     SqlLexicalRetriever,
@@ -55,6 +70,7 @@ def _pipeline() -> KnowledgeRetrievalPipeline:
     return KnowledgeRetrievalPipeline(
         lexical=SqlLexicalRetriever(db.session),
         vector=vector,
+        device=SqlDeviceKnowledgeRetriever(_device_repository()),
     )
 
 
@@ -82,6 +98,61 @@ def _answer_synthesizer() -> AnswerSynthesizer:
     return configured or AnswerSynthesizer(DeepSeekAnswerModel())
 
 
+def _device_repository():
+    configured = current_app.extensions.get("device_knowledge_repository")
+    return configured or SqlDeviceKnowledgeRepository(db.session)
+
+
+def _scope_service():
+    configured = current_app.extensions.get("device_source_scope_service")
+    return configured or DeviceSourceScopeService(
+        SqlAlchemyDeviceSourceScopeRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _audit_repository():
+    configured = current_app.extensions.get(
+        "knowledge_query_audit_repository"
+    )
+    return configured or SqlKnowledgeQueryAuditRepository(db.session)
+
+
+def _persist_query_audit(
+    *,
+    query,
+    context,
+    result,
+    cited_points=(),
+    started_at,
+) -> bool:
+    record = build_query_audit(
+        query=query,
+        context=context,
+        mode=result.mode,
+        evidence=result.evidence,
+        cited_points=tuple(cited_points),
+        degraded_components=result.degraded_components,
+        latency_ms=int((perf_counter() - started_at) * 1000),
+    )
+    try:
+        _audit_repository().record(record)
+        return True
+    except Exception:
+        current_app.logger.exception("mandatory knowledge query audit failed")
+        return False
+
+
+def _query(payload) -> str:
+    query = str(payload.get("query") or "").strip()
+    if not query:
+        raise ValueError("query 不能为空")
+    if len(query) > 300:
+        raise ValueError("query 不能超过 300 个字符")
+    return query
+
+
 def _request_context(payload):
     requested_domains = payload.get("business_domain_uids")
     if requested_domains is not None and not isinstance(requested_domains, list):
@@ -99,9 +170,10 @@ def _request_context(payload):
 @bp.post("/search")
 def search():
     payload = request.get_json(silent=True) or {}
-    query = str(payload.get("query") or "").strip()
-    if not query:
-        return jsonify(failed("query 不能为空", code=400)), 400
+    try:
+        query = _query(payload)
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
     mode = str(payload.get("mode") or "auto")
     if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
         return jsonify(failed("不支持的检索模式", code=400)), 400
@@ -112,12 +184,20 @@ def search():
         context, correlation_id = _request_context(payload)
     except ValueError as exc:
         return jsonify(failed(str(exc), code=400)), 400
+    started_at = perf_counter()
     result = _pipeline().search(
         query,
         context=context,
         mode=mode,
         limit=limit,
     )
+    if not _persist_query_audit(
+        query=query,
+        context=context,
+        result=result,
+        started_at=started_at,
+    ):
+        return jsonify(failed("知识查询审计写入失败", code=503)), 503
     return jsonify(
         success(
             {
@@ -134,9 +214,10 @@ def search():
 @bp.post("/ask")
 def ask():
     payload = request.get_json(silent=True) or {}
-    query = str(payload.get("query") or "").strip()
-    if not query:
-        return jsonify(failed("query 不能为空", code=400)), 400
+    try:
+        query = _query(payload)
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
     mode = str(payload.get("mode") or "auto")
     if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
         return jsonify(failed("不支持的检索模式", code=400)), 400
@@ -144,8 +225,34 @@ def ask():
         context, correlation_id = _request_context(payload)
     except ValueError as exc:
         return jsonify(failed(str(exc), code=400)), 400
+    started_at = perf_counter()
     retrieval = _pipeline().search(query, context=context, mode=mode, limit=30)
     answer = _answer_synthesizer().answer(query, retrieval.evidence)
+    cited_points = tuple(
+        (citation.point_key, citation.point_revision)
+        for citation in answer.citations
+    )
+    if not _persist_query_audit(
+        query=query,
+        context=context,
+        result=retrieval,
+        cited_points=cited_points,
+        started_at=started_at,
+    ):
+        return jsonify(failed("知识查询审计写入失败", code=503)), 503
+    evidence_by_chunk = {
+        item.chunk_id: _serialize_evidence(item)
+        for item in retrieval.evidence
+    }
+    citations = []
+    for citation in answer.citations:
+        serialized = dict(citation.__dict__)
+        selected = evidence_by_chunk.get(citation.chunk_id, {})
+        serialized["content"] = selected.get("content")
+        serialized["business_domain_uid"] = selected.get(
+            "business_domain_uid"
+        )
+        citations.append(serialized)
     return jsonify(
         success(
             {
@@ -154,7 +261,8 @@ def ask():
                 "answer": answer.answer,
                 "answer_status": answer.status,
                 "degraded_components": list(retrieval.degraded_components),
-                "citations": [citation.__dict__ for citation in answer.citations],
+                "citations": citations,
+                "evidence": list(evidence_by_chunk.values()),
                 "freshness_status": answer.freshness_status,
             }
         )
@@ -192,6 +300,41 @@ def _source_document(source_uid: str, version: int | None = None):
 @bp.get("/sources/<source_uid>")
 def source(source_uid: str):
     row = _source_document(source_uid)
+    if row is None:
+        context, _correlation_id = _request_context({})
+        device = _device_repository().get_detail(
+            source_uid,
+            global_access=context.global_access,
+            business_domain_uids=tuple(
+                sorted(context.business_domain_uids)
+            ),
+        )
+        if device is not None:
+            row = {
+                "object_uid": device.asset_uid,
+                "object_type": "DeviceAsset",
+                "object_version": device.current_version,
+                "object_name": device.name,
+                "asset_type": device.asset_type,
+                "business_domain_uid": device.business_domain_uid,
+                "location": device.location,
+                "organization": device.organization,
+                "responsible_person": device.responsible_person,
+                "source_codes": list(device.source_codes),
+                "related_events": [
+                    {
+                        "event_type": event_type,
+                        "title": title,
+                        "source_code": source_code,
+                    }
+                    for event_type, title, source_code in device.related_events
+                ],
+                "source_updated_at": (
+                    device.updated_at.isoformat()
+                    if device.updated_at is not None
+                    else None
+                ),
+            }
     if row is None:
         return jsonify(failed("来源不存在或无权访问", code=404)), 404
     return jsonify(success(row))
@@ -223,6 +366,56 @@ def capabilities():
     )
 
 
+@bp.get("/admin/device-sources")
+def admin_device_sources():
+    return jsonify(success(list(_scope_service().list())))
+
+
+@bp.put("/admin/device-sources/<source_uid>/scope")
+def admin_device_source_scope(source_uid: str):
+    payload = request.get_json(silent=True) or {}
+    try:
+        source_record = _scope_service().update(
+            source_uid,
+            payload,
+            actor_is_admin="admin" in g.current_user.get("roles", ()),
+        )
+    except DeviceSourceScopeError as exc:
+        return jsonify(failed(str(exc), code=exc.http_status)), exc.http_status
+    access = source_scope_access(source_record.permission_scope)
+    return jsonify(
+        success(
+            {
+                "uid": source_record.uid,
+                "name": source_record.name,
+                "source_type": source_record.source_type,
+                "status": source_record.status,
+                **access,
+                "updated_at": (
+                    source_record.updated_at.isoformat()
+                    if source_record.updated_at is not None
+                    else None
+                ),
+            }
+        )
+    )
+
+
+@bp.get("/admin/query-audits")
+def admin_query_audits():
+    try:
+        limit = _bounded_int(
+            request.args.get("limit"),
+            field="limit",
+            default=100,
+            lower=1,
+            upper=200,
+        )
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    return jsonify(success(list(_audit_repository().list(limit=limit))))
+
+
 @bp.get("/admin/sync")
 def admin_sync():
     return jsonify(success(knowledge_status(db.session)))

+ 146 - 0
app/core/knowledge/device_scope.py

@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from app.core.common.timezone_utils import now_china_naive
+from app.core.data_research.repository import (
+    SqlAlchemyIngestionSourceRepository,
+)
+from app.models.data_research import DeviceAssetSourceMapping, IngestionSource
+
+MAX_SOURCE_BUSINESS_DOMAINS = 100
+
+
+class DeviceSourceScopeError(ValueError):
+    code = "DEVICE_SOURCE_SCOPE_ERROR"
+    http_status = 422
+
+
+class DeviceSourceScopeInvalid(DeviceSourceScopeError):
+    code = "DEVICE_SOURCE_SCOPE_INVALID"
+
+
+class DeviceSourceScopeNotFound(DeviceSourceScopeError):
+    code = "DEVICE_SOURCE_SCOPE_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceSourceScopeForbidden(DeviceSourceScopeError):
+    code = "DEVICE_SOURCE_SCOPE_FORBIDDEN"
+    http_status = 403
+
+
+def _business_domains(payload: Any) -> tuple[str, ...]:
+    if not isinstance(payload, (list, tuple)):
+        raise DeviceSourceScopeInvalid("business_domains must be an array")
+    if len(payload) > MAX_SOURCE_BUSINESS_DOMAINS:
+        raise DeviceSourceScopeInvalid(
+            f"business_domains cannot exceed {MAX_SOURCE_BUSINESS_DOMAINS}"
+        )
+    normalized: set[str] = set()
+    for value in payload:
+        try:
+            normalized.add(str(UUID(str(value).strip())))
+        except (AttributeError, TypeError, ValueError) as exc:
+            raise DeviceSourceScopeInvalid(
+                "each business_domains value must be a UUID"
+            ) from exc
+    return tuple(sorted(normalized))
+
+
+def source_scope_access(permission_scope: Any) -> dict[str, Any]:
+    scope = permission_scope if isinstance(permission_scope, dict) else {}
+    domains = _business_domains(scope.get("business_domains", []))
+    return {
+        "business_domains": domains,
+        "admin_only": not domains,
+    }
+
+
+class SqlAlchemyDeviceSourceScopeRepository(
+    SqlAlchemyIngestionSourceRepository
+):
+    def list_device_sources(self):
+        source_uids = (
+            self.session.query(DeviceAssetSourceMapping.source_uid)
+            .distinct()
+            .subquery()
+        )
+        models = (
+            self.session.query(IngestionSource)
+            .filter(IngestionSource.uid.in_(source_uids))
+            .order_by(IngestionSource.name.asc(), IngestionSource.uid.asc())
+            .all()
+        )
+        return tuple(self._record(model) for model in models)
+
+
+class DeviceSourceScopeService:
+    def __init__(
+        self,
+        repository,
+        *,
+        clock: Callable[[], datetime] = now_china_naive,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self._repository = repository
+        self._clock = clock
+        self._commit = commit
+        self._rollback = rollback
+
+    def list(self) -> tuple[dict[str, Any], ...]:
+        records = []
+        for source in self._repository.list_device_sources():
+            access = source_scope_access(source.permission_scope)
+            records.append(
+                {
+                    "uid": source.uid,
+                    "name": source.name,
+                    "source_type": source.source_type,
+                    "status": source.status,
+                    **access,
+                    "updated_at": (
+                        source.updated_at.isoformat()
+                        if source.updated_at is not None
+                        else None
+                    ),
+                }
+            )
+        return tuple(records)
+
+    def update(
+        self,
+        source_uid: str,
+        payload: Any,
+        *,
+        actor_is_admin: bool,
+    ):
+        if not actor_is_admin:
+            raise DeviceSourceScopeForbidden(
+                "only administrators can update device source scope"
+            )
+        if not isinstance(payload, dict) or "business_domains" not in payload:
+            raise DeviceSourceScopeInvalid("business_domains is required")
+        source = self._repository.get(str(source_uid))
+        if source is None:
+            raise DeviceSourceScopeNotFound("device source was not found")
+        domains = _business_domains(payload["business_domains"])
+        permission_scope = dict(source.permission_scope or {})
+        permission_scope["business_domains"] = list(domains)
+        updated = replace(
+            source,
+            permission_scope=permission_scope,
+            updated_at=self._clock(),
+        )
+        try:
+            saved = self._repository.save(updated)
+            self._commit()
+            return saved
+        except Exception:
+            self._rollback()
+            raise

+ 149 - 0
app/core/knowledge/query_audit.py

@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from collections import Counter
+from dataclasses import dataclass
+from typing import Any
+from uuid import uuid4
+
+from sqlalchemy import text
+
+from app.core.knowledge.access import KnowledgeAccessContext
+from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
+
+
+@dataclass(frozen=True)
+class KnowledgeQueryAuditRecord:
+    uid: str
+    query_hash: str
+    user_id: str | None
+    roles: tuple[str, ...]
+    business_domain_uids: tuple[str, ...]
+    mode: str
+    retriever_counts: dict[str, int]
+    cited_points: tuple[dict[str, Any], ...]
+    degraded_components: tuple[str, ...]
+    correlation_id: str
+    latency_ms: int
+
+
+def normalize_audited_query(query: str) -> str:
+    return " ".join(str(query).split())
+
+
+def build_query_audit(
+    *,
+    query: str,
+    context: KnowledgeAccessContext,
+    mode: str,
+    evidence: tuple[KnowledgeEvidence, ...] | list[KnowledgeEvidence],
+    cited_points: tuple[tuple[str, int], ...] = (),
+    degraded_components: tuple[str, ...] = (),
+    latency_ms: int,
+) -> KnowledgeQueryAuditRecord:
+    normalized = normalize_audited_query(query)
+    retriever_counts: Counter[str] = Counter()
+    for item in evidence:
+        retriever_counts.update(set(item.retriever.split("+")))
+    minimized_points = tuple(
+        {
+            "point_key": point_key,
+            "point_revision": int(point_revision),
+        }
+        for point_key, point_revision in dict.fromkeys(cited_points)
+    )
+    return KnowledgeQueryAuditRecord(
+        uid=str(uuid4()),
+        query_hash=hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
+        user_id=context.subject_id or None,
+        roles=tuple(sorted(context.roles)),
+        business_domain_uids=tuple(sorted(context.business_domain_uids)),
+        mode=str(mode),
+        retriever_counts=dict(sorted(retriever_counts.items())),
+        cited_points=minimized_points,
+        degraded_components=tuple(sorted(set(degraded_components))),
+        correlation_id=context.correlation_id,
+        latency_ms=max(0, int(latency_ms)),
+    )
+
+
+class SqlKnowledgeQueryAuditRepository:
+    def __init__(self, session):
+        self._session = session
+
+    def record(self, record: KnowledgeQueryAuditRecord) -> None:
+        try:
+            self._session.execute(
+                text(
+                    """
+                    INSERT INTO public.knowledge_query_audits (
+                        id, query_hash, user_id, roles,
+                        business_domain_uids, mode, retriever_counts,
+                        cited_points, degraded_components, correlation_id,
+                        latency_ms
+                    ) VALUES (
+                        CAST(:id AS uuid), :query_hash,
+                        CAST(:user_id AS uuid), CAST(:roles AS jsonb),
+                        CAST(:business_domain_uids AS jsonb), :mode,
+                        CAST(:retriever_counts AS jsonb),
+                        CAST(:cited_points AS jsonb),
+                        CAST(:degraded_components AS jsonb),
+                        CAST(:correlation_id AS uuid), :latency_ms
+                    )
+                    """
+                ),
+                {
+                    "id": record.uid,
+                    "query_hash": record.query_hash,
+                    "user_id": record.user_id,
+                    "roles": json.dumps(record.roles),
+                    "business_domain_uids": json.dumps(
+                        record.business_domain_uids
+                    ),
+                    "mode": record.mode,
+                    "retriever_counts": json.dumps(record.retriever_counts),
+                    "cited_points": json.dumps(record.cited_points),
+                    "degraded_components": json.dumps(
+                        record.degraded_components
+                    ),
+                    "correlation_id": record.correlation_id,
+                    "latency_ms": record.latency_ms,
+                },
+            )
+            self._session.commit()
+        except Exception:
+            self._session.rollback()
+            raise
+
+    def list(self, *, limit: int = 100) -> tuple[dict[str, Any], ...]:
+        rows = (
+            self._session.execute(
+                text(
+                    """
+                    SELECT id::text, query_hash, user_id::text,
+                           roles, business_domain_uids, mode,
+                           retriever_counts, cited_points,
+                           degraded_components, correlation_id::text,
+                           latency_ms, created_at
+                    FROM public.knowledge_query_audits
+                    ORDER BY created_at DESC, id DESC
+                    LIMIT :limit
+                    """
+                ),
+                {"limit": max(1, min(int(limit), 200))},
+            )
+            .mappings()
+            .all()
+        )
+        return tuple(
+            {
+                **dict(row),
+                "created_at": (
+                    row["created_at"].isoformat()
+                    if row["created_at"] is not None
+                    else None
+                ),
+            }
+            for row in rows
+        )

+ 470 - 0
app/core/knowledge/retrieval/device.py

@@ -0,0 +1,470 @@
+from __future__ import annotations
+
+import re
+from collections.abc import Sequence
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Protocol
+
+from sqlalchemy import text
+
+from app.core.knowledge.access import KnowledgeAccessContext
+from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
+
+MAX_DEVICE_QUERY_LENGTH = 300
+MAX_DEVICE_RESULTS = 100
+
+_EVENT_LABELS = {
+    "fault": "故障",
+    "alarm": "告警",
+    "maintenance": "维护",
+    "downtime": "停机",
+}
+_EVENT_ORDER = {
+    event_type: position for position, event_type in enumerate(_EVENT_LABELS)
+}
+_LABELED_IDENTIFIER = re.compile(
+    r"(?:源\s*ID|平台\s*UID)\s*(?:为|是|[::])?\s*"
+    r"([0-9A-Za-z][0-9A-Za-z._:-]{1,})",
+    re.IGNORECASE,
+)
+_QUESTION_ENTITY_PATTERNS = tuple(
+    re.compile(pattern)
+    for pattern in (
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"(?:的)?(?:责任人|负责人)(?:是)?谁[??。]?$",
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"(?:最近)?\s*有哪些\s*(?:故障|告警|维护|停机)"
+        r"(?:记录|事件)?[??。]?$",
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"负责哪些设备[??。]?$",
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"有哪些设备[??。]?$",
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"(?:位于哪里|在哪里)[??。]?$",
+    )
+)
+
+
+@dataclass(frozen=True)
+class DeviceSearchRow:
+    asset_uid: str
+    asset_type: str
+    name: str
+    current_version: int
+    location: str | None
+    organization: str | None
+    responsible_person: str | None
+    source_codes: tuple[str, ...]
+    related_events: tuple[tuple[str, str, str | None], ...]
+    business_domain_uid: str | None
+    updated_at: datetime | None
+    rank: float
+
+
+class DeviceKnowledgeRepository(Protocol):
+    def search(
+        self,
+        *,
+        query: str,
+        global_access: bool,
+        business_domain_uids: tuple[str, ...],
+        limit: int,
+    ) -> Sequence[DeviceSearchRow]: ...
+
+
+def normalize_device_query(query: str) -> str:
+    normalized = query.strip()
+    if not normalized:
+        raise ValueError("设备检索词不能为空")
+    if len(normalized) > MAX_DEVICE_QUERY_LENGTH:
+        raise ValueError(
+            f"设备检索词长度不能超过 {MAX_DEVICE_QUERY_LENGTH} 个字符"
+        )
+    return normalized
+
+
+def device_query_terms(query: str) -> tuple[str, ...]:
+    normalized = normalize_device_query(query)
+    candidates: list[str] = []
+
+    identifier = _LABELED_IDENTIFIER.search(normalized)
+    if identifier:
+        candidates.append(identifier.group(1))
+
+    for pattern in _QUESTION_ENTITY_PATTERNS:
+        match = pattern.match(normalized)
+        if match:
+            candidate = match.group(1).strip(" \t\r\n,,。;;::")
+            if len(candidate) >= 2:
+                candidates.append(candidate)
+            break
+
+    candidates.append(normalized)
+    return tuple(dict.fromkeys(candidates))
+
+
+def _clean_values(values: Sequence[str]) -> tuple[str, ...]:
+    return tuple(sorted({value.strip() for value in values if value.strip()}))
+
+
+def _clean_events(
+    values: Sequence[tuple[str, str, str | None]],
+) -> tuple[tuple[str, str, str | None], ...]:
+    normalized = {
+        (
+            event_type.strip().lower(),
+            title.strip(),
+            source_code.strip() or None if source_code else None,
+        )
+        for event_type, title, source_code in values
+        if event_type.strip().lower() in _EVENT_LABELS and title.strip()
+    }
+    return tuple(
+        sorted(
+            normalized,
+            key=lambda item: (
+                _EVENT_ORDER[item[0]],
+                item[1],
+                item[2] or "",
+            ),
+        )
+    )
+
+
+def build_device_evidence(row: DeviceSearchRow) -> KnowledgeEvidence:
+    source_codes = _clean_values(row.source_codes)
+    events = _clean_events(row.related_events)
+    lines = [
+        f"设备名称:{row.name}",
+        f"平台 UID:{row.asset_uid}",
+    ]
+    if source_codes:
+        lines.append(f"源 ID:{'、'.join(source_codes)}")
+    if row.location:
+        lines.append(f"位置:{row.location}")
+    if row.organization:
+        lines.append(f"所属组织:{row.organization}")
+    if row.responsible_person:
+        lines.append(f"责任人:{row.responsible_person}")
+    for event_type, title, source_code in events:
+        detail = f"{title}({source_code})" if source_code else title
+        lines.append(f"{_EVENT_LABELS[event_type]}:{detail}")
+
+    point_key = f"DeviceAsset/{row.asset_uid}/summary"
+    return KnowledgeEvidence(
+        chunk_id=f"device:{row.asset_uid}:v{row.current_version}",
+        content="\n".join(lines),
+        score=float(row.rank),
+        retriever="device",
+        object_uid=row.asset_uid,
+        object_type="DeviceAsset",
+        object_version=row.current_version,
+        business_domain_uid=row.business_domain_uid,
+        point_keys=(point_key,),
+        point_revisions=(row.current_version,),
+        generation=row.current_version,
+        source_updated_at=(
+            row.updated_at.isoformat() if row.updated_at is not None else None
+        ),
+    )
+
+
+class SqlDeviceKnowledgeRetriever:
+    def __init__(self, repository: DeviceKnowledgeRepository):
+        self._repository = repository
+
+    def retrieve(
+        self,
+        query: str,
+        context: KnowledgeAccessContext,
+        limit: int,
+    ) -> tuple[KnowledgeEvidence, ...]:
+        normalized_query = normalize_device_query(query)
+        bounded_limit = max(1, min(int(limit), MAX_DEVICE_RESULTS))
+        rows = self._repository.search(
+            query=normalized_query,
+            global_access=context.global_access,
+            business_domain_uids=tuple(sorted(context.business_domain_uids)),
+            limit=bounded_limit,
+        )
+        return tuple(build_device_evidence(row) for row in rows)
+
+
+_AUTHORIZED_DEVICE_CTES = """
+    WITH authorized_sources AS (
+        SELECT source.uid,
+               CASE
+                   WHEN :global_access THEN NULL::uuid
+                   ELSE MIN(scope.domain_uid)::uuid
+               END AS business_domain_uid
+        FROM public.ingestion_sources source
+        LEFT JOIN LATERAL (
+            SELECT value AS domain_uid
+            FROM jsonb_array_elements_text(
+                CASE
+                    WHEN jsonb_typeof(
+                        source.permission_scope -> 'business_domains'
+                    ) = 'array'
+                    THEN source.permission_scope -> 'business_domains'
+                    ELSE '[]'::jsonb
+                END
+            ) values(value)
+        ) scope ON TRUE
+        WHERE source.status = 'active'
+          AND (
+              :global_access
+              OR scope.domain_uid = ANY(CAST(:domains AS text[]))
+          )
+        GROUP BY source.uid
+    ),
+    authorized_mappings AS (
+        SELECT mapping.asset_uid,
+               MIN(
+                   authorized.business_domain_uid::text
+               )::uuid AS business_domain_uid,
+               array_agg(
+                   DISTINCT mapping.source_code
+                   ORDER BY mapping.source_code
+               ) AS source_codes,
+               MAX(
+                   COALESCE(
+                       mapping.source_updated_at,
+                       mapping.last_seen_at
+                   )
+               ) AS source_updated_at
+        FROM public.device_asset_source_mappings mapping
+        JOIN authorized_sources authorized
+          ON authorized.uid = mapping.source_uid
+        GROUP BY mapping.asset_uid
+    ),
+    authorized_events AS (
+        SELECT event.asset_uid,
+               jsonb_agg(
+                   DISTINCT jsonb_build_object(
+                       'event_type', event.event_type,
+                       'title', event.title,
+                       'source_code', event.source_code
+                   )
+               ) AS related_events,
+               MAX(event.occurred_at) AS event_updated_at
+        FROM public.device_operational_events event
+        JOIN authorized_sources authorized
+          ON authorized.uid = event.source_uid
+        GROUP BY event.asset_uid
+    )
+"""
+
+_DEVICE_SELECT = """
+    SELECT asset.uid AS asset_uid,
+           asset.asset_type,
+           asset.name,
+           asset.current_version,
+           asset.location,
+           asset.organization,
+           asset.responsible_person,
+           mapping.source_codes,
+           COALESCE(events.related_events, '[]'::jsonb) AS related_events,
+           mapping.business_domain_uid,
+           GREATEST(
+               asset.updated_at,
+               mapping.source_updated_at,
+               events.event_updated_at
+           ) AS updated_at,
+           {rank} AS rank
+    FROM public.device_assets asset
+    JOIN authorized_mappings mapping
+      ON mapping.asset_uid = asset.uid
+    LEFT JOIN authorized_events events
+      ON events.asset_uid = asset.uid
+    WHERE asset.status = 'active'
+      {predicate}
+"""
+
+
+def _device_search_row(row) -> DeviceSearchRow:
+    events = tuple(
+        (
+            str(event["event_type"]),
+            str(event["title"]),
+            (
+                str(event["source_code"])
+                if event.get("source_code") is not None
+                else None
+            ),
+        )
+        for event in (row["related_events"] or ())
+    )
+    return DeviceSearchRow(
+        asset_uid=str(row["asset_uid"]),
+        asset_type=str(row["asset_type"]),
+        name=str(row["name"]),
+        current_version=int(row["current_version"]),
+        location=row["location"],
+        organization=row["organization"],
+        responsible_person=row["responsible_person"],
+        source_codes=tuple(str(value) for value in row["source_codes"]),
+        related_events=_clean_events(events),
+        business_domain_uid=(
+            str(row["business_domain_uid"])
+            if row["business_domain_uid"] is not None
+            else None
+        ),
+        updated_at=row["updated_at"],
+        rank=float(row["rank"]),
+    )
+
+
+class SqlDeviceKnowledgeRepository:
+    def __init__(self, session):
+        self._session = session
+
+    @staticmethod
+    def _access_parameters(
+        *,
+        global_access: bool,
+        business_domain_uids: tuple[str, ...],
+    ) -> dict[str, object]:
+        return {
+            "global_access": bool(global_access),
+            "domains": list(business_domain_uids),
+        }
+
+    def search(
+        self,
+        *,
+        query: str,
+        global_access: bool,
+        business_domain_uids: tuple[str, ...],
+        limit: int,
+    ) -> tuple[DeviceSearchRow, ...]:
+        statement = text(
+            _AUTHORIZED_DEVICE_CTES
+            + _DEVICE_SELECT.format(
+                rank="""
+                    CASE
+                        WHEN EXISTS (
+                            SELECT 1
+                            FROM unnest(CAST(:terms AS text[])) term(value)
+                            WHERE lower(asset.uid::text) = lower(term.value)
+                        ) THEN 1.0
+                        WHEN EXISTS (
+                            SELECT 1
+                            FROM unnest(CAST(:terms AS text[])) term(value)
+                            WHERE lower(asset.name) = lower(term.value)
+                        ) THEN 0.98
+                        WHEN EXISTS (
+                            SELECT 1
+                            FROM unnest(CAST(:terms AS text[])) term(value)
+                            JOIN unnest(mapping.source_codes) code
+                              ON lower(code) = lower(term.value)
+                        ) THEN 0.96
+                        WHEN EXISTS (
+                            SELECT 1
+                            FROM unnest(CAST(:terms AS text[])) term(value)
+                            WHERE left(
+                                lower(asset.name),
+                                char_length(term.value)
+                            ) = lower(term.value)
+                        ) THEN 0.90
+                        ELSE 0.80
+                    END
+                """,
+                predicate="""
+                    AND EXISTS (
+                        SELECT 1
+                        FROM unnest(CAST(:terms AS text[])) term(value)
+                        WHERE
+                            strpos(
+                                lower(asset.uid::text),
+                                lower(term.value)
+                            ) > 0
+                            OR strpos(
+                                lower(asset.name),
+                                lower(term.value)
+                            ) > 0
+                            OR strpos(
+                                lower(COALESCE(asset.location, '')),
+                                lower(term.value)
+                            ) > 0
+                            OR strpos(
+                                lower(COALESCE(asset.organization, '')),
+                                lower(term.value)
+                            ) > 0
+                            OR strpos(
+                                lower(COALESCE(asset.responsible_person, '')),
+                                lower(term.value)
+                            ) > 0
+                            OR EXISTS (
+                                SELECT 1
+                                FROM unnest(mapping.source_codes) code
+                                WHERE strpos(
+                                    lower(code),
+                                    lower(term.value)
+                                ) > 0
+                            )
+                            OR EXISTS (
+                                SELECT 1
+                                FROM jsonb_array_elements(
+                                    COALESCE(
+                                        events.related_events,
+                                        '[]'::jsonb
+                                    )
+                                ) event
+                                WHERE strpos(
+                                    lower(COALESCE(event ->> 'title', '')),
+                                    lower(term.value)
+                                ) > 0
+                                   OR strpos(
+                                       lower(
+                                           COALESCE(
+                                               event ->> 'source_code',
+                                               ''
+                                           )
+                                       ),
+                                       lower(term.value)
+                                   ) > 0
+                            )
+                    )
+                    ORDER BY rank DESC, asset.updated_at DESC, asset.uid
+                    LIMIT :limit
+                """,
+            )
+        )
+        parameters = self._access_parameters(
+            global_access=global_access,
+            business_domain_uids=business_domain_uids,
+        )
+        parameters.update(
+            {
+                "terms": list(device_query_terms(query)),
+                "limit": max(1, min(int(limit), MAX_DEVICE_RESULTS)),
+            }
+        )
+        rows = self._session.execute(statement, parameters).mappings()
+        return tuple(_device_search_row(row) for row in rows)
+
+    def get_detail(
+        self,
+        asset_uid: str,
+        *,
+        global_access: bool,
+        business_domain_uids: tuple[str, ...],
+    ) -> DeviceSearchRow | None:
+        statement = text(
+            _AUTHORIZED_DEVICE_CTES
+            + _DEVICE_SELECT.format(
+                rank="1.0",
+                predicate="""
+                    AND asset.uid = CAST(:asset_uid AS uuid)
+                    LIMIT 1
+                """,
+            )
+        )
+        parameters = self._access_parameters(
+            global_access=global_access,
+            business_domain_uids=business_domain_uids,
+        )
+        parameters["asset_uid"] = str(asset_uid)
+        row = self._session.execute(statement, parameters).mappings().first()
+        return _device_search_row(row) if row is not None else None

+ 3 - 1
app/core/knowledge/retrieval/pipeline.py

@@ -24,12 +24,14 @@ class KnowledgeRetrievalPipeline:
         *,
         lexical: Retriever,
         vector: Retriever,
+        device: Retriever | None = None,
         graph: Retriever | None = None,
         lightrag: Retriever | None = None,
     ):
         self._retrievers = {
             "lexical": lexical,
             "vector": vector,
+            "device": device,
             "graph": graph,
             "lightrag": lightrag,
         }
@@ -43,7 +45,7 @@ class KnowledgeRetrievalPipeline:
         limit: int = 20,
     ) -> SearchResult:
         resolved_mode = route_query(query) if mode == "auto" else mode
-        names = ["lexical", "vector"]
+        names = ["lexical", "vector", "device"]
         if resolved_mode == "relationship" and self._retrievers["graph"] is not None:
             names.append("graph")
         if resolved_mode == "global" and self._retrievers["lightrag"] is not None:

+ 530 - 0
deployment/app/api/knowledge_base/routes.py

@@ -0,0 +1,530 @@
+from __future__ import annotations
+
+import uuid
+from time import perf_counter
+
+from flask import current_app, g, jsonify, request
+from sqlalchemy import text
+
+from app import db
+from app.api.knowledge_base import bp
+from app.core.knowledge.access import build_access_context
+from app.core.knowledge.admin import (
+    change_set_detail,
+    knowledge_status,
+    list_change_sets,
+    retry_change_set,
+    rollback_change_set,
+)
+from app.core.knowledge.audit import run_canonical_audit
+from app.core.knowledge.device_scope import (
+    DeviceSourceScopeError,
+    DeviceSourceScopeService,
+    SqlAlchemyDeviceSourceScopeRepository,
+    source_scope_access,
+)
+from app.core.knowledge.qa import AnswerSynthesizer, DeepSeekAnswerModel
+from app.core.knowledge.query_audit import (
+    SqlKnowledgeQueryAuditRepository,
+    build_query_audit,
+)
+from app.core.knowledge.retrieval.device import (
+    SqlDeviceKnowledgeRepository,
+    SqlDeviceKnowledgeRetriever,
+)
+from app.core.knowledge.retrieval.pipeline import KnowledgeRetrievalPipeline
+from app.core.knowledge.retrieval.sql import (
+    SqlLexicalRetriever,
+    SqlVectorRetriever,
+    UnavailableVectorRetriever,
+)
+from app.models.result import failed, success
+
+
+def _bounded_int(value, *, field: str, default: int, lower: int, upper: int) -> int:
+    try:
+        parsed = int(default if value is None else value)
+    except (TypeError, ValueError) as exc:
+        raise ValueError(f"{field} 必须为整数") from exc
+    return min(max(parsed, lower), upper)
+
+
+def _pipeline() -> KnowledgeRetrievalPipeline:
+    configured = current_app.extensions.get("knowledge_retrieval_pipeline")
+    if configured is not None:
+        return configured
+    api_key = current_app.config.get("QWEN_EMBEDDING_API_KEY", "")
+    base_url = current_app.config.get("QWEN_EMBEDDING_BASE_URL", "")
+    if api_key and base_url:
+        from app.services.embedding.qwen import QwenEmbeddingClient
+
+        embedder = QwenEmbeddingClient(
+            api_key=api_key,
+            base_url=base_url,
+            model=current_app.config["QWEN_EMBEDDING_MODEL"],
+            dimension=current_app.config["QWEN_EMBEDDING_DIMENSION"],
+        )
+        vector = SqlVectorRetriever(db.session, embedder)
+    else:
+        vector = UnavailableVectorRetriever()
+    return KnowledgeRetrievalPipeline(
+        lexical=SqlLexicalRetriever(db.session),
+        vector=vector,
+        device=SqlDeviceKnowledgeRetriever(_device_repository()),
+    )
+
+
+def _serialize_evidence(item) -> dict:
+    return {
+        "chunk_id": item.chunk_id,
+        "content": item.content,
+        "score": item.score,
+        "retriever": item.retriever,
+        "object_uid": item.object_uid,
+        "object_type": item.object_type,
+        "object_version": item.object_version,
+        "business_domain_uid": item.business_domain_uid,
+        "point_keys": list(item.point_keys),
+        "point_revisions": list(item.point_revisions),
+        "index_generation": item.generation,
+        "source_updated_at": item.source_updated_at,
+        "freshness_status": item.freshness_status,
+        "section_path": item.section_path,
+    }
+
+
+def _answer_synthesizer() -> AnswerSynthesizer:
+    configured = current_app.extensions.get("knowledge_answer_synthesizer")
+    return configured or AnswerSynthesizer(DeepSeekAnswerModel())
+
+
+def _device_repository():
+    configured = current_app.extensions.get("device_knowledge_repository")
+    return configured or SqlDeviceKnowledgeRepository(db.session)
+
+
+def _scope_service():
+    configured = current_app.extensions.get("device_source_scope_service")
+    return configured or DeviceSourceScopeService(
+        SqlAlchemyDeviceSourceScopeRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _audit_repository():
+    configured = current_app.extensions.get(
+        "knowledge_query_audit_repository"
+    )
+    return configured or SqlKnowledgeQueryAuditRepository(db.session)
+
+
+def _persist_query_audit(
+    *,
+    query,
+    context,
+    result,
+    cited_points=(),
+    started_at,
+) -> bool:
+    record = build_query_audit(
+        query=query,
+        context=context,
+        mode=result.mode,
+        evidence=result.evidence,
+        cited_points=tuple(cited_points),
+        degraded_components=result.degraded_components,
+        latency_ms=int((perf_counter() - started_at) * 1000),
+    )
+    try:
+        _audit_repository().record(record)
+        return True
+    except Exception:
+        current_app.logger.exception("mandatory knowledge query audit failed")
+        return False
+
+
+def _query(payload) -> str:
+    query = str(payload.get("query") or "").strip()
+    if not query:
+        raise ValueError("query 不能为空")
+    if len(query) > 300:
+        raise ValueError("query 不能超过 300 个字符")
+    return query
+
+
+def _request_context(payload):
+    requested_domains = payload.get("business_domain_uids")
+    if requested_domains is not None and not isinstance(requested_domains, list):
+        raise ValueError("business_domain_uids 必须为数组")
+    correlation_id = request.headers.get("X-Correlation-ID") or str(uuid.uuid4())
+    context = build_access_context(
+        db.session,
+        identity=g.current_user,
+        requested_business_domains=requested_domains,
+        correlation_id=correlation_id,
+    )
+    return context, correlation_id
+
+
+@bp.post("/search")
+def search():
+    payload = request.get_json(silent=True) or {}
+    try:
+        query = _query(payload)
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    mode = str(payload.get("mode") or "auto")
+    if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
+        return jsonify(failed("不支持的检索模式", code=400)), 400
+    try:
+        limit = _bounded_int(
+            payload.get("limit"), field="limit", default=20, lower=1, upper=100
+        )
+        context, correlation_id = _request_context(payload)
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    started_at = perf_counter()
+    result = _pipeline().search(
+        query,
+        context=context,
+        mode=mode,
+        limit=limit,
+    )
+    if not _persist_query_audit(
+        query=query,
+        context=context,
+        result=result,
+        started_at=started_at,
+    ):
+        return jsonify(failed("知识查询审计写入失败", code=503)), 503
+    return jsonify(
+        success(
+            {
+                "query": query,
+                "mode": result.mode,
+                "evidence": [_serialize_evidence(item) for item in result.evidence],
+                "degraded_components": list(result.degraded_components),
+                "correlation_id": correlation_id,
+            }
+        )
+    )
+
+
+@bp.post("/ask")
+def ask():
+    payload = request.get_json(silent=True) or {}
+    try:
+        query = _query(payload)
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    mode = str(payload.get("mode") or "auto")
+    if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
+        return jsonify(failed("不支持的检索模式", code=400)), 400
+    try:
+        context, correlation_id = _request_context(payload)
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    started_at = perf_counter()
+    retrieval = _pipeline().search(query, context=context, mode=mode, limit=30)
+    answer = _answer_synthesizer().answer(query, retrieval.evidence)
+    cited_points = tuple(
+        (citation.point_key, citation.point_revision)
+        for citation in answer.citations
+    )
+    if not _persist_query_audit(
+        query=query,
+        context=context,
+        result=retrieval,
+        cited_points=cited_points,
+        started_at=started_at,
+    ):
+        return jsonify(failed("知识查询审计写入失败", code=503)), 503
+    evidence_by_chunk = {
+        item.chunk_id: _serialize_evidence(item)
+        for item in retrieval.evidence
+    }
+    citations = []
+    for citation in answer.citations:
+        serialized = dict(citation.__dict__)
+        selected = evidence_by_chunk.get(citation.chunk_id, {})
+        serialized["content"] = selected.get("content")
+        serialized["business_domain_uid"] = selected.get(
+            "business_domain_uid"
+        )
+        citations.append(serialized)
+    return jsonify(
+        success(
+            {
+                "query_id": correlation_id,
+                "mode": retrieval.mode,
+                "answer": answer.answer,
+                "answer_status": answer.status,
+                "degraded_components": list(retrieval.degraded_components),
+                "citations": citations,
+                "evidence": list(evidence_by_chunk.values()),
+                "freshness_status": answer.freshness_status,
+            }
+        )
+    )
+
+
+def _source_document(source_uid: str, version: int | None = None):
+    context, _correlation_id = _request_context({})
+    version_clause = (
+        "AND d.object_version = :version"
+        if version is not None
+        else "AND d.status = 'active'"
+    )
+    row = (
+        db.session.execute(
+            text(
+                f"""
+            SELECT d.object_uid::text, d.object_type, d.object_version, d.object_name,
+                   d.business_domain_uid::text, d.content, d.source_updated_at,
+                   d.active_generation, d.status
+            FROM public.governance_documents d
+            WHERE d.object_uid = CAST(:uid AS uuid) {version_clause}
+            """
+            ),
+            {"uid": source_uid, "version": version},
+        )
+        .mappings()
+        .one_or_none()
+    )
+    if row is None or not context.permits_domain(row["business_domain_uid"]):
+        return None
+    return dict(row)
+
+
+@bp.get("/sources/<source_uid>")
+def source(source_uid: str):
+    row = _source_document(source_uid)
+    if row is None:
+        context, _correlation_id = _request_context({})
+        device = _device_repository().get_detail(
+            source_uid,
+            global_access=context.global_access,
+            business_domain_uids=tuple(
+                sorted(context.business_domain_uids)
+            ),
+        )
+        if device is not None:
+            row = {
+                "object_uid": device.asset_uid,
+                "object_type": "DeviceAsset",
+                "object_version": device.current_version,
+                "object_name": device.name,
+                "asset_type": device.asset_type,
+                "business_domain_uid": device.business_domain_uid,
+                "location": device.location,
+                "organization": device.organization,
+                "responsible_person": device.responsible_person,
+                "source_codes": list(device.source_codes),
+                "related_events": [
+                    {
+                        "event_type": event_type,
+                        "title": title,
+                        "source_code": source_code,
+                    }
+                    for event_type, title, source_code in device.related_events
+                ],
+                "source_updated_at": (
+                    device.updated_at.isoformat()
+                    if device.updated_at is not None
+                    else None
+                ),
+            }
+    if row is None:
+        return jsonify(failed("来源不存在或无权访问", code=404)), 404
+    return jsonify(success(row))
+
+
+@bp.get("/sources/<source_uid>/versions/<int:version>")
+def source_version(source_uid: str, version: int):
+    row = _source_document(source_uid, version)
+    if row is None:
+        return jsonify(failed("来源版本不存在或无权访问", code=404)), 404
+    return jsonify(success(row))
+
+
+@bp.get("/capabilities")
+def capabilities():
+    return jsonify(
+        success(
+            {
+                "standard_retrieval": True,
+                "lightrag_enabled": bool(
+                    current_app.config.get("KNOWLEDGE_LIGHTRAG_ENABLED", False)
+                ),
+                "lightrag_shadow_only": bool(
+                    current_app.config.get("KNOWLEDGE_LIGHTRAG_SHADOW_ONLY", True)
+                ),
+                "answer_generation": bool(current_app.config.get("DEEPSEEK_API_KEY")),
+            }
+        )
+    )
+
+
+@bp.get("/admin/device-sources")
+def admin_device_sources():
+    return jsonify(success(list(_scope_service().list())))
+
+
+@bp.put("/admin/device-sources/<source_uid>/scope")
+def admin_device_source_scope(source_uid: str):
+    payload = request.get_json(silent=True) or {}
+    try:
+        source_record = _scope_service().update(
+            source_uid,
+            payload,
+            actor_is_admin="admin" in g.current_user.get("roles", ()),
+        )
+    except DeviceSourceScopeError as exc:
+        return jsonify(failed(str(exc), code=exc.http_status)), exc.http_status
+    access = source_scope_access(source_record.permission_scope)
+    return jsonify(
+        success(
+            {
+                "uid": source_record.uid,
+                "name": source_record.name,
+                "source_type": source_record.source_type,
+                "status": source_record.status,
+                **access,
+                "updated_at": (
+                    source_record.updated_at.isoformat()
+                    if source_record.updated_at is not None
+                    else None
+                ),
+            }
+        )
+    )
+
+
+@bp.get("/admin/query-audits")
+def admin_query_audits():
+    try:
+        limit = _bounded_int(
+            request.args.get("limit"),
+            field="limit",
+            default=100,
+            lower=1,
+            upper=200,
+        )
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    return jsonify(success(list(_audit_repository().list(limit=limit))))
+
+
+@bp.get("/admin/sync")
+def admin_sync():
+    return jsonify(success(knowledge_status(db.session)))
+
+
+@bp.get("/admin/change-sets")
+def admin_change_sets():
+    try:
+        limit = _bounded_int(
+            request.args.get("limit"), field="limit", default=50, lower=1, upper=200
+        )
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    return jsonify(success(list_change_sets(db.session, limit=limit)))
+
+
+@bp.get("/admin/change-sets/<change_set_id>")
+def admin_change_set(change_set_id: str):
+    detail = change_set_detail(db.session, change_set_id)
+    if detail is None:
+        return jsonify(failed("change set 不存在", code=404)), 404
+    return jsonify(success(detail))
+
+
+@bp.post("/admin/change-sets/<change_set_id>/retry")
+def admin_retry_change_set(change_set_id: str):
+    if not retry_change_set(db.session, change_set_id):
+        db.session.rollback()
+        return jsonify(failed("当前状态不允许重试", code=409)), 409
+    db.session.commit()
+    return jsonify(success({"change_set_id": change_set_id, "status": "pending"}))
+
+
+@bp.post("/admin/change-sets/<change_set_id>/rollback")
+def admin_rollback_change_set(change_set_id: str):
+    if not rollback_change_set(db.session, change_set_id):
+        db.session.rollback()
+        return jsonify(failed("没有可安全回退的上一版本", code=409)), 409
+    db.session.commit()
+    return jsonify(success({"change_set_id": change_set_id, "status": "rolled_back"}))
+
+
+@bp.post("/admin/audit")
+def admin_audit():
+    payload = request.get_json(silent=True) or {}
+    repair = payload.get("repair") is True
+    findings = run_canonical_audit(db.session)
+    repaired = 0
+    if repair:
+        repaired = db.session.execute(
+            text(
+                "DELETE FROM public.knowledge_cache_dependencies "
+                "WHERE expires_at <= CURRENT_TIMESTAMP"
+            )
+        ).rowcount
+        db.session.commit()
+    return jsonify(
+        success(
+            {
+                "mode": "repair" if repair else "report",
+                "findings": [finding.__dict__ for finding in findings],
+                "repaired_cache_dependencies": repaired,
+            }
+        )
+    )
+
+
+@bp.post("/admin/retry-projection")
+def admin_retry_projection():
+    projection_id = str(
+        (request.get_json(silent=True) or {}).get("projection_id") or ""
+    )
+    if not projection_id:
+        return jsonify(failed("projection_id 不能为空", code=400)), 400
+    updated = db.session.execute(
+        text(
+            """
+            UPDATE public.knowledge_index_projections
+            SET status = 'pending', last_error = NULL, external_track_id = NULL,
+                available_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
+            WHERE id = CAST(:id AS uuid) AND status IN ('failed','unverified')
+            """
+        ),
+        {"id": projection_id},
+    ).rowcount
+    if updated != 1:
+        db.session.rollback()
+        return jsonify(failed("当前投影状态不允许重试", code=409)), 409
+    db.session.commit()
+    return jsonify(success({"projection_id": projection_id, "status": "pending"}))
+
+
+@bp.get("/admin/evaluations")
+def admin_evaluations():
+    rows = db.session.execute(
+        text(
+            """
+            SELECT run.id::text, evaluation_set.name, run.status, run.configuration,
+                   run.started_at, run.finished_at,
+                   COUNT(result.case_id) AS case_count,
+                   COUNT(result.case_id) FILTER (WHERE result.passed) AS passed_count
+            FROM public.knowledge_evaluation_runs run
+            JOIN public.knowledge_evaluation_sets evaluation_set
+              ON evaluation_set.id = run.evaluation_set_id
+            LEFT JOIN public.knowledge_evaluation_results result ON result.run_id = run.id
+            GROUP BY run.id, evaluation_set.name
+            ORDER BY run.started_at DESC
+            LIMIT 50
+            """
+        )
+    ).mappings()
+    return jsonify(success([dict(row) for row in rows]))

+ 146 - 0
deployment/app/core/knowledge/device_scope.py

@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from app.core.common.timezone_utils import now_china_naive
+from app.core.data_research.repository import (
+    SqlAlchemyIngestionSourceRepository,
+)
+from app.models.data_research import DeviceAssetSourceMapping, IngestionSource
+
+MAX_SOURCE_BUSINESS_DOMAINS = 100
+
+
+class DeviceSourceScopeError(ValueError):
+    code = "DEVICE_SOURCE_SCOPE_ERROR"
+    http_status = 422
+
+
+class DeviceSourceScopeInvalid(DeviceSourceScopeError):
+    code = "DEVICE_SOURCE_SCOPE_INVALID"
+
+
+class DeviceSourceScopeNotFound(DeviceSourceScopeError):
+    code = "DEVICE_SOURCE_SCOPE_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceSourceScopeForbidden(DeviceSourceScopeError):
+    code = "DEVICE_SOURCE_SCOPE_FORBIDDEN"
+    http_status = 403
+
+
+def _business_domains(payload: Any) -> tuple[str, ...]:
+    if not isinstance(payload, (list, tuple)):
+        raise DeviceSourceScopeInvalid("business_domains must be an array")
+    if len(payload) > MAX_SOURCE_BUSINESS_DOMAINS:
+        raise DeviceSourceScopeInvalid(
+            f"business_domains cannot exceed {MAX_SOURCE_BUSINESS_DOMAINS}"
+        )
+    normalized: set[str] = set()
+    for value in payload:
+        try:
+            normalized.add(str(UUID(str(value).strip())))
+        except (AttributeError, TypeError, ValueError) as exc:
+            raise DeviceSourceScopeInvalid(
+                "each business_domains value must be a UUID"
+            ) from exc
+    return tuple(sorted(normalized))
+
+
+def source_scope_access(permission_scope: Any) -> dict[str, Any]:
+    scope = permission_scope if isinstance(permission_scope, dict) else {}
+    domains = _business_domains(scope.get("business_domains", []))
+    return {
+        "business_domains": domains,
+        "admin_only": not domains,
+    }
+
+
+class SqlAlchemyDeviceSourceScopeRepository(
+    SqlAlchemyIngestionSourceRepository
+):
+    def list_device_sources(self):
+        source_uids = (
+            self.session.query(DeviceAssetSourceMapping.source_uid)
+            .distinct()
+            .subquery()
+        )
+        models = (
+            self.session.query(IngestionSource)
+            .filter(IngestionSource.uid.in_(source_uids))
+            .order_by(IngestionSource.name.asc(), IngestionSource.uid.asc())
+            .all()
+        )
+        return tuple(self._record(model) for model in models)
+
+
+class DeviceSourceScopeService:
+    def __init__(
+        self,
+        repository,
+        *,
+        clock: Callable[[], datetime] = now_china_naive,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self._repository = repository
+        self._clock = clock
+        self._commit = commit
+        self._rollback = rollback
+
+    def list(self) -> tuple[dict[str, Any], ...]:
+        records = []
+        for source in self._repository.list_device_sources():
+            access = source_scope_access(source.permission_scope)
+            records.append(
+                {
+                    "uid": source.uid,
+                    "name": source.name,
+                    "source_type": source.source_type,
+                    "status": source.status,
+                    **access,
+                    "updated_at": (
+                        source.updated_at.isoformat()
+                        if source.updated_at is not None
+                        else None
+                    ),
+                }
+            )
+        return tuple(records)
+
+    def update(
+        self,
+        source_uid: str,
+        payload: Any,
+        *,
+        actor_is_admin: bool,
+    ):
+        if not actor_is_admin:
+            raise DeviceSourceScopeForbidden(
+                "only administrators can update device source scope"
+            )
+        if not isinstance(payload, dict) or "business_domains" not in payload:
+            raise DeviceSourceScopeInvalid("business_domains is required")
+        source = self._repository.get(str(source_uid))
+        if source is None:
+            raise DeviceSourceScopeNotFound("device source was not found")
+        domains = _business_domains(payload["business_domains"])
+        permission_scope = dict(source.permission_scope or {})
+        permission_scope["business_domains"] = list(domains)
+        updated = replace(
+            source,
+            permission_scope=permission_scope,
+            updated_at=self._clock(),
+        )
+        try:
+            saved = self._repository.save(updated)
+            self._commit()
+            return saved
+        except Exception:
+            self._rollback()
+            raise

+ 149 - 0
deployment/app/core/knowledge/query_audit.py

@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from collections import Counter
+from dataclasses import dataclass
+from typing import Any
+from uuid import uuid4
+
+from sqlalchemy import text
+
+from app.core.knowledge.access import KnowledgeAccessContext
+from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
+
+
+@dataclass(frozen=True)
+class KnowledgeQueryAuditRecord:
+    uid: str
+    query_hash: str
+    user_id: str | None
+    roles: tuple[str, ...]
+    business_domain_uids: tuple[str, ...]
+    mode: str
+    retriever_counts: dict[str, int]
+    cited_points: tuple[dict[str, Any], ...]
+    degraded_components: tuple[str, ...]
+    correlation_id: str
+    latency_ms: int
+
+
+def normalize_audited_query(query: str) -> str:
+    return " ".join(str(query).split())
+
+
+def build_query_audit(
+    *,
+    query: str,
+    context: KnowledgeAccessContext,
+    mode: str,
+    evidence: tuple[KnowledgeEvidence, ...] | list[KnowledgeEvidence],
+    cited_points: tuple[tuple[str, int], ...] = (),
+    degraded_components: tuple[str, ...] = (),
+    latency_ms: int,
+) -> KnowledgeQueryAuditRecord:
+    normalized = normalize_audited_query(query)
+    retriever_counts: Counter[str] = Counter()
+    for item in evidence:
+        retriever_counts.update(set(item.retriever.split("+")))
+    minimized_points = tuple(
+        {
+            "point_key": point_key,
+            "point_revision": int(point_revision),
+        }
+        for point_key, point_revision in dict.fromkeys(cited_points)
+    )
+    return KnowledgeQueryAuditRecord(
+        uid=str(uuid4()),
+        query_hash=hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
+        user_id=context.subject_id or None,
+        roles=tuple(sorted(context.roles)),
+        business_domain_uids=tuple(sorted(context.business_domain_uids)),
+        mode=str(mode),
+        retriever_counts=dict(sorted(retriever_counts.items())),
+        cited_points=minimized_points,
+        degraded_components=tuple(sorted(set(degraded_components))),
+        correlation_id=context.correlation_id,
+        latency_ms=max(0, int(latency_ms)),
+    )
+
+
+class SqlKnowledgeQueryAuditRepository:
+    def __init__(self, session):
+        self._session = session
+
+    def record(self, record: KnowledgeQueryAuditRecord) -> None:
+        try:
+            self._session.execute(
+                text(
+                    """
+                    INSERT INTO public.knowledge_query_audits (
+                        id, query_hash, user_id, roles,
+                        business_domain_uids, mode, retriever_counts,
+                        cited_points, degraded_components, correlation_id,
+                        latency_ms
+                    ) VALUES (
+                        CAST(:id AS uuid), :query_hash,
+                        CAST(:user_id AS uuid), CAST(:roles AS jsonb),
+                        CAST(:business_domain_uids AS jsonb), :mode,
+                        CAST(:retriever_counts AS jsonb),
+                        CAST(:cited_points AS jsonb),
+                        CAST(:degraded_components AS jsonb),
+                        CAST(:correlation_id AS uuid), :latency_ms
+                    )
+                    """
+                ),
+                {
+                    "id": record.uid,
+                    "query_hash": record.query_hash,
+                    "user_id": record.user_id,
+                    "roles": json.dumps(record.roles),
+                    "business_domain_uids": json.dumps(
+                        record.business_domain_uids
+                    ),
+                    "mode": record.mode,
+                    "retriever_counts": json.dumps(record.retriever_counts),
+                    "cited_points": json.dumps(record.cited_points),
+                    "degraded_components": json.dumps(
+                        record.degraded_components
+                    ),
+                    "correlation_id": record.correlation_id,
+                    "latency_ms": record.latency_ms,
+                },
+            )
+            self._session.commit()
+        except Exception:
+            self._session.rollback()
+            raise
+
+    def list(self, *, limit: int = 100) -> tuple[dict[str, Any], ...]:
+        rows = (
+            self._session.execute(
+                text(
+                    """
+                    SELECT id::text, query_hash, user_id::text,
+                           roles, business_domain_uids, mode,
+                           retriever_counts, cited_points,
+                           degraded_components, correlation_id::text,
+                           latency_ms, created_at
+                    FROM public.knowledge_query_audits
+                    ORDER BY created_at DESC, id DESC
+                    LIMIT :limit
+                    """
+                ),
+                {"limit": max(1, min(int(limit), 200))},
+            )
+            .mappings()
+            .all()
+        )
+        return tuple(
+            {
+                **dict(row),
+                "created_at": (
+                    row["created_at"].isoformat()
+                    if row["created_at"] is not None
+                    else None
+                ),
+            }
+            for row in rows
+        )

+ 470 - 0
deployment/app/core/knowledge/retrieval/device.py

@@ -0,0 +1,470 @@
+from __future__ import annotations
+
+import re
+from collections.abc import Sequence
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Protocol
+
+from sqlalchemy import text
+
+from app.core.knowledge.access import KnowledgeAccessContext
+from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
+
+MAX_DEVICE_QUERY_LENGTH = 300
+MAX_DEVICE_RESULTS = 100
+
+_EVENT_LABELS = {
+    "fault": "故障",
+    "alarm": "告警",
+    "maintenance": "维护",
+    "downtime": "停机",
+}
+_EVENT_ORDER = {
+    event_type: position for position, event_type in enumerate(_EVENT_LABELS)
+}
+_LABELED_IDENTIFIER = re.compile(
+    r"(?:源\s*ID|平台\s*UID)\s*(?:为|是|[::])?\s*"
+    r"([0-9A-Za-z][0-9A-Za-z._:-]{1,})",
+    re.IGNORECASE,
+)
+_QUESTION_ENTITY_PATTERNS = tuple(
+    re.compile(pattern)
+    for pattern in (
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"(?:的)?(?:责任人|负责人)(?:是)?谁[??。]?$",
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"(?:最近)?\s*有哪些\s*(?:故障|告警|维护|停机)"
+        r"(?:记录|事件)?[??。]?$",
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"负责哪些设备[??。]?$",
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"有哪些设备[??。]?$",
+        r"^(?:请问|查询|查找|搜索)?\s*(.+?)\s*"
+        r"(?:位于哪里|在哪里)[??。]?$",
+    )
+)
+
+
+@dataclass(frozen=True)
+class DeviceSearchRow:
+    asset_uid: str
+    asset_type: str
+    name: str
+    current_version: int
+    location: str | None
+    organization: str | None
+    responsible_person: str | None
+    source_codes: tuple[str, ...]
+    related_events: tuple[tuple[str, str, str | None], ...]
+    business_domain_uid: str | None
+    updated_at: datetime | None
+    rank: float
+
+
+class DeviceKnowledgeRepository(Protocol):
+    def search(
+        self,
+        *,
+        query: str,
+        global_access: bool,
+        business_domain_uids: tuple[str, ...],
+        limit: int,
+    ) -> Sequence[DeviceSearchRow]: ...
+
+
+def normalize_device_query(query: str) -> str:
+    normalized = query.strip()
+    if not normalized:
+        raise ValueError("设备检索词不能为空")
+    if len(normalized) > MAX_DEVICE_QUERY_LENGTH:
+        raise ValueError(
+            f"设备检索词长度不能超过 {MAX_DEVICE_QUERY_LENGTH} 个字符"
+        )
+    return normalized
+
+
+def device_query_terms(query: str) -> tuple[str, ...]:
+    normalized = normalize_device_query(query)
+    candidates: list[str] = []
+
+    identifier = _LABELED_IDENTIFIER.search(normalized)
+    if identifier:
+        candidates.append(identifier.group(1))
+
+    for pattern in _QUESTION_ENTITY_PATTERNS:
+        match = pattern.match(normalized)
+        if match:
+            candidate = match.group(1).strip(" \t\r\n,,。;;::")
+            if len(candidate) >= 2:
+                candidates.append(candidate)
+            break
+
+    candidates.append(normalized)
+    return tuple(dict.fromkeys(candidates))
+
+
+def _clean_values(values: Sequence[str]) -> tuple[str, ...]:
+    return tuple(sorted({value.strip() for value in values if value.strip()}))
+
+
+def _clean_events(
+    values: Sequence[tuple[str, str, str | None]],
+) -> tuple[tuple[str, str, str | None], ...]:
+    normalized = {
+        (
+            event_type.strip().lower(),
+            title.strip(),
+            source_code.strip() or None if source_code else None,
+        )
+        for event_type, title, source_code in values
+        if event_type.strip().lower() in _EVENT_LABELS and title.strip()
+    }
+    return tuple(
+        sorted(
+            normalized,
+            key=lambda item: (
+                _EVENT_ORDER[item[0]],
+                item[1],
+                item[2] or "",
+            ),
+        )
+    )
+
+
+def build_device_evidence(row: DeviceSearchRow) -> KnowledgeEvidence:
+    source_codes = _clean_values(row.source_codes)
+    events = _clean_events(row.related_events)
+    lines = [
+        f"设备名称:{row.name}",
+        f"平台 UID:{row.asset_uid}",
+    ]
+    if source_codes:
+        lines.append(f"源 ID:{'、'.join(source_codes)}")
+    if row.location:
+        lines.append(f"位置:{row.location}")
+    if row.organization:
+        lines.append(f"所属组织:{row.organization}")
+    if row.responsible_person:
+        lines.append(f"责任人:{row.responsible_person}")
+    for event_type, title, source_code in events:
+        detail = f"{title}({source_code})" if source_code else title
+        lines.append(f"{_EVENT_LABELS[event_type]}:{detail}")
+
+    point_key = f"DeviceAsset/{row.asset_uid}/summary"
+    return KnowledgeEvidence(
+        chunk_id=f"device:{row.asset_uid}:v{row.current_version}",
+        content="\n".join(lines),
+        score=float(row.rank),
+        retriever="device",
+        object_uid=row.asset_uid,
+        object_type="DeviceAsset",
+        object_version=row.current_version,
+        business_domain_uid=row.business_domain_uid,
+        point_keys=(point_key,),
+        point_revisions=(row.current_version,),
+        generation=row.current_version,
+        source_updated_at=(
+            row.updated_at.isoformat() if row.updated_at is not None else None
+        ),
+    )
+
+
+class SqlDeviceKnowledgeRetriever:
+    def __init__(self, repository: DeviceKnowledgeRepository):
+        self._repository = repository
+
+    def retrieve(
+        self,
+        query: str,
+        context: KnowledgeAccessContext,
+        limit: int,
+    ) -> tuple[KnowledgeEvidence, ...]:
+        normalized_query = normalize_device_query(query)
+        bounded_limit = max(1, min(int(limit), MAX_DEVICE_RESULTS))
+        rows = self._repository.search(
+            query=normalized_query,
+            global_access=context.global_access,
+            business_domain_uids=tuple(sorted(context.business_domain_uids)),
+            limit=bounded_limit,
+        )
+        return tuple(build_device_evidence(row) for row in rows)
+
+
+_AUTHORIZED_DEVICE_CTES = """
+    WITH authorized_sources AS (
+        SELECT source.uid,
+               CASE
+                   WHEN :global_access THEN NULL::uuid
+                   ELSE MIN(scope.domain_uid)::uuid
+               END AS business_domain_uid
+        FROM public.ingestion_sources source
+        LEFT JOIN LATERAL (
+            SELECT value AS domain_uid
+            FROM jsonb_array_elements_text(
+                CASE
+                    WHEN jsonb_typeof(
+                        source.permission_scope -> 'business_domains'
+                    ) = 'array'
+                    THEN source.permission_scope -> 'business_domains'
+                    ELSE '[]'::jsonb
+                END
+            ) values(value)
+        ) scope ON TRUE
+        WHERE source.status = 'active'
+          AND (
+              :global_access
+              OR scope.domain_uid = ANY(CAST(:domains AS text[]))
+          )
+        GROUP BY source.uid
+    ),
+    authorized_mappings AS (
+        SELECT mapping.asset_uid,
+               MIN(
+                   authorized.business_domain_uid::text
+               )::uuid AS business_domain_uid,
+               array_agg(
+                   DISTINCT mapping.source_code
+                   ORDER BY mapping.source_code
+               ) AS source_codes,
+               MAX(
+                   COALESCE(
+                       mapping.source_updated_at,
+                       mapping.last_seen_at
+                   )
+               ) AS source_updated_at
+        FROM public.device_asset_source_mappings mapping
+        JOIN authorized_sources authorized
+          ON authorized.uid = mapping.source_uid
+        GROUP BY mapping.asset_uid
+    ),
+    authorized_events AS (
+        SELECT event.asset_uid,
+               jsonb_agg(
+                   DISTINCT jsonb_build_object(
+                       'event_type', event.event_type,
+                       'title', event.title,
+                       'source_code', event.source_code
+                   )
+               ) AS related_events,
+               MAX(event.occurred_at) AS event_updated_at
+        FROM public.device_operational_events event
+        JOIN authorized_sources authorized
+          ON authorized.uid = event.source_uid
+        GROUP BY event.asset_uid
+    )
+"""
+
+_DEVICE_SELECT = """
+    SELECT asset.uid AS asset_uid,
+           asset.asset_type,
+           asset.name,
+           asset.current_version,
+           asset.location,
+           asset.organization,
+           asset.responsible_person,
+           mapping.source_codes,
+           COALESCE(events.related_events, '[]'::jsonb) AS related_events,
+           mapping.business_domain_uid,
+           GREATEST(
+               asset.updated_at,
+               mapping.source_updated_at,
+               events.event_updated_at
+           ) AS updated_at,
+           {rank} AS rank
+    FROM public.device_assets asset
+    JOIN authorized_mappings mapping
+      ON mapping.asset_uid = asset.uid
+    LEFT JOIN authorized_events events
+      ON events.asset_uid = asset.uid
+    WHERE asset.status = 'active'
+      {predicate}
+"""
+
+
+def _device_search_row(row) -> DeviceSearchRow:
+    events = tuple(
+        (
+            str(event["event_type"]),
+            str(event["title"]),
+            (
+                str(event["source_code"])
+                if event.get("source_code") is not None
+                else None
+            ),
+        )
+        for event in (row["related_events"] or ())
+    )
+    return DeviceSearchRow(
+        asset_uid=str(row["asset_uid"]),
+        asset_type=str(row["asset_type"]),
+        name=str(row["name"]),
+        current_version=int(row["current_version"]),
+        location=row["location"],
+        organization=row["organization"],
+        responsible_person=row["responsible_person"],
+        source_codes=tuple(str(value) for value in row["source_codes"]),
+        related_events=_clean_events(events),
+        business_domain_uid=(
+            str(row["business_domain_uid"])
+            if row["business_domain_uid"] is not None
+            else None
+        ),
+        updated_at=row["updated_at"],
+        rank=float(row["rank"]),
+    )
+
+
+class SqlDeviceKnowledgeRepository:
+    def __init__(self, session):
+        self._session = session
+
+    @staticmethod
+    def _access_parameters(
+        *,
+        global_access: bool,
+        business_domain_uids: tuple[str, ...],
+    ) -> dict[str, object]:
+        return {
+            "global_access": bool(global_access),
+            "domains": list(business_domain_uids),
+        }
+
+    def search(
+        self,
+        *,
+        query: str,
+        global_access: bool,
+        business_domain_uids: tuple[str, ...],
+        limit: int,
+    ) -> tuple[DeviceSearchRow, ...]:
+        statement = text(
+            _AUTHORIZED_DEVICE_CTES
+            + _DEVICE_SELECT.format(
+                rank="""
+                    CASE
+                        WHEN EXISTS (
+                            SELECT 1
+                            FROM unnest(CAST(:terms AS text[])) term(value)
+                            WHERE lower(asset.uid::text) = lower(term.value)
+                        ) THEN 1.0
+                        WHEN EXISTS (
+                            SELECT 1
+                            FROM unnest(CAST(:terms AS text[])) term(value)
+                            WHERE lower(asset.name) = lower(term.value)
+                        ) THEN 0.98
+                        WHEN EXISTS (
+                            SELECT 1
+                            FROM unnest(CAST(:terms AS text[])) term(value)
+                            JOIN unnest(mapping.source_codes) code
+                              ON lower(code) = lower(term.value)
+                        ) THEN 0.96
+                        WHEN EXISTS (
+                            SELECT 1
+                            FROM unnest(CAST(:terms AS text[])) term(value)
+                            WHERE left(
+                                lower(asset.name),
+                                char_length(term.value)
+                            ) = lower(term.value)
+                        ) THEN 0.90
+                        ELSE 0.80
+                    END
+                """,
+                predicate="""
+                    AND EXISTS (
+                        SELECT 1
+                        FROM unnest(CAST(:terms AS text[])) term(value)
+                        WHERE
+                            strpos(
+                                lower(asset.uid::text),
+                                lower(term.value)
+                            ) > 0
+                            OR strpos(
+                                lower(asset.name),
+                                lower(term.value)
+                            ) > 0
+                            OR strpos(
+                                lower(COALESCE(asset.location, '')),
+                                lower(term.value)
+                            ) > 0
+                            OR strpos(
+                                lower(COALESCE(asset.organization, '')),
+                                lower(term.value)
+                            ) > 0
+                            OR strpos(
+                                lower(COALESCE(asset.responsible_person, '')),
+                                lower(term.value)
+                            ) > 0
+                            OR EXISTS (
+                                SELECT 1
+                                FROM unnest(mapping.source_codes) code
+                                WHERE strpos(
+                                    lower(code),
+                                    lower(term.value)
+                                ) > 0
+                            )
+                            OR EXISTS (
+                                SELECT 1
+                                FROM jsonb_array_elements(
+                                    COALESCE(
+                                        events.related_events,
+                                        '[]'::jsonb
+                                    )
+                                ) event
+                                WHERE strpos(
+                                    lower(COALESCE(event ->> 'title', '')),
+                                    lower(term.value)
+                                ) > 0
+                                   OR strpos(
+                                       lower(
+                                           COALESCE(
+                                               event ->> 'source_code',
+                                               ''
+                                           )
+                                       ),
+                                       lower(term.value)
+                                   ) > 0
+                            )
+                    )
+                    ORDER BY rank DESC, asset.updated_at DESC, asset.uid
+                    LIMIT :limit
+                """,
+            )
+        )
+        parameters = self._access_parameters(
+            global_access=global_access,
+            business_domain_uids=business_domain_uids,
+        )
+        parameters.update(
+            {
+                "terms": list(device_query_terms(query)),
+                "limit": max(1, min(int(limit), MAX_DEVICE_RESULTS)),
+            }
+        )
+        rows = self._session.execute(statement, parameters).mappings()
+        return tuple(_device_search_row(row) for row in rows)
+
+    def get_detail(
+        self,
+        asset_uid: str,
+        *,
+        global_access: bool,
+        business_domain_uids: tuple[str, ...],
+    ) -> DeviceSearchRow | None:
+        statement = text(
+            _AUTHORIZED_DEVICE_CTES
+            + _DEVICE_SELECT.format(
+                rank="1.0",
+                predicate="""
+                    AND asset.uid = CAST(:asset_uid AS uuid)
+                    LIMIT 1
+                """,
+            )
+        )
+        parameters = self._access_parameters(
+            global_access=global_access,
+            business_domain_uids=business_domain_uids,
+        )
+        parameters["asset_uid"] = str(asset_uid)
+        row = self._session.execute(statement, parameters).mappings().first()
+        return _device_search_row(row) if row is not None else None

+ 74 - 0
deployment/app/core/knowledge/retrieval/pipeline.py

@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Protocol
+
+from app.core.knowledge.access import KnowledgeAccessContext
+from app.core.knowledge.retrieval.contracts import KnowledgeEvidence, SearchResult
+from app.core.knowledge.retrieval.fusion import reciprocal_rank_fusion
+from app.core.knowledge.retrieval.router import route_query
+
+
+class Retriever(Protocol):
+    def retrieve(
+        self,
+        query: str,
+        context: KnowledgeAccessContext,
+        limit: int,
+    ) -> Sequence[KnowledgeEvidence]: ...
+
+
+class KnowledgeRetrievalPipeline:
+    def __init__(
+        self,
+        *,
+        lexical: Retriever,
+        vector: Retriever,
+        device: Retriever | None = None,
+        graph: Retriever | None = None,
+        lightrag: Retriever | None = None,
+    ):
+        self._retrievers = {
+            "lexical": lexical,
+            "vector": vector,
+            "device": device,
+            "graph": graph,
+            "lightrag": lightrag,
+        }
+
+    def search(
+        self,
+        query: str,
+        *,
+        context: KnowledgeAccessContext,
+        mode: str = "auto",
+        limit: int = 20,
+    ) -> SearchResult:
+        resolved_mode = route_query(query) if mode == "auto" else mode
+        names = ["lexical", "vector", "device"]
+        if resolved_mode == "relationship" and self._retrievers["graph"] is not None:
+            names.append("graph")
+        if resolved_mode == "global" and self._retrievers["lightrag"] is not None:
+            names.append("lightrag")
+        ranked: dict[str, Sequence[KnowledgeEvidence]] = {}
+        degraded: list[str] = []
+        for name in names:
+            retriever = self._retrievers[name]
+            if retriever is None:
+                continue
+            try:
+                ranked[name] = retriever.retrieve(query, context, limit)
+            except Exception:
+                degraded.append(name)
+        fused = reciprocal_rank_fusion(ranked, limit=limit)
+        authorized = tuple(
+            evidence
+            for evidence in fused
+            if context.permits_domain(evidence.business_domain_uid)
+            and evidence.freshness_status != "stale"
+        )
+        return SearchResult(
+            evidence=authorized,
+            mode=resolved_mode,
+            degraded_components=tuple(degraded),
+        )

+ 1 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -162,6 +162,7 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | 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 修复建议、自动修复、预测性维护或维修计划 |
+| WP-10 | 工程完成,待企业授权、模型与黄金集验收 | canonical 设备资产及四类运行事件进入现有混合检索;按设备名称、平台 UID、授权源 ID、位置、组织、责任人和事件检索;数据源业务域 SQL 预过滤与融合后二次授权;安全设备详情;授权证据问答、引用内容、模型不可用和证据不足拒答;最小化问题哈希审计;管理员源范围和审计工作台;十项版本化验收模板;OpenAPI 211 项;真实 PostgreSQL 与页面链路定向验证 | 需要企业配置真实设备源业务域范围,将十项模板绑定真实设备、故障和越权案例,在合规模型环境完成设备专家复核;未完成前不得声称企业验收或生产 K6;不建设 NL2SQL、在线分析、BI 开发、自动根因结论、自动修复或直接 LightRAG 回答 |
 
 ## 7. 12 周执行计划
 

+ 18 - 17
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -158,14 +158,15 @@ flowchart TB
 |---|---|---|---|
 | 治理对象规范化 | 业务域、流程、元数据、标准、标签生成规范化文档/知识点 | 已建设 | `app/core/knowledge/document_builder.py`、`point_builder.py` |
 | 动态同步 | 结构化 Diff、增量发布、依赖影响、缓存失效、全量审计、重试和回滚 | 已建设,本地验收 | `sync.py`、`diff.py`、`impact.py`、`publish.py` |
-| 混合检索 | 精确、词法、pgvector、治理图和可选 LightRAG 候选融合/重排 | 已建设,本地验收 | `/api/knowledge/search`、[ADR-005](architecture/ADR-005-llamaindex-lightrag-knowledge-base.md) |
-| 治理问答 | 只基于授权证据回答;无证据不猜测;返回 UID、版本和引用 | 已建设,本地验收 | `/api/knowledge/ask`、`qa.py` |
-| 来源与版本 | 查看当前来源、指定历史版本和 freshness | 已建设 | `/api/knowledge/sources/*` |
-| 管理运维 | 同步状态、Change Set、重试、回滚、审计、投影重试、评测结果 | 已建设 | `/api/knowledge/admin/*` |
+| 混合检索 | 精确、词法、pgvector、治理图、设备台账/运行事件和可选 LightRAG 候选融合/重排 | 工程完成,待企业检索集验收 | `/api/knowledge/search`;设备源先按业务域做 SQL 预过滤,融合后再次授权 |
+| 治理问答 | 只基于授权证据回答;无证据或模型不可用时不猜测;返回 UID、版本、内容和引用 | 工程完成,待企业模型与黄金集验收 | `/api/knowledge/ask`、`qa.py`、`docs/acceptance/WP10_DEVICE_KNOWLEDGE_GOLDEN_SET.json` |
+| 设备知识检索 | 按设备名称、平台 UID、源 ID、位置、组织、责任人和故障/告警/维修/停机检索 | 工程完成,待企业数据验收 | canonical PostgreSQL 设备检索器;不读取事件原始证据和数据源配置 |
+| 来源与版本 | 查看当前来源、指定历史版本、设备安全详情和 freshness | 工程完成,待企业设备源范围验收 | `/api/knowledge/sources/*` |
+| 管理运维 | 同步状态、Change Set、重试、回滚、查询审计、设备源授权范围、投影重试和评测结果 | 工程完成,待企业配置 | `/api/knowledge/admin/*`;查询审计只保存问题哈希和最小化执行元数据 |
 | LightRAG 图增强 | 独立数据库/Neo4j 的可重建投影,故障不阻断 canonical 索引 | 影子模式 | Docker `knowledge-shadow` profile;不能污染治理 Neo4j |
 | 生产 K6 发布 | 真实 Qwen/DeepSeek/LightRAG、黄金问题集、业务域分区和长期观察 | 待完成 | [K6 验收](validation/knowledge-k6.md)明确“生产 K6 发布门禁尚未批准” |
 
-当前主分支已经包含知识库动态更新和本地工程闭环;但真实模型评测、全部治理写路径接入 Outbox、150–300 条真实黄金问题集、业务域 LightRAG workspace 和生产回滚演练仍属于生产接入工作。
+当前 WP10 分支已经包含设备检索和知识问答工程闭环;但真实设备源授权范围、企业合规模型、十项模板到真实案例的绑定与专家复核仍未完成。生产 K6 所需的 150–300 条真实黄金问题集、全部治理写路径 Outbox、业务域 LightRAG workspace、长期观察和生产回滚演练仍属于后续生产接入工作。
 
 ### 4.4 数据工厂、编排与执行
 
@@ -524,8 +525,8 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 | CAT-08 | 资产目录 / AI 资产 | 模型、提示词、Agent、知识库、向量索引和评测集 | 规划中 |
 | CAT-09 | 资产发现 / 自动采集 | 基于连接器定时发现资产、属性和变更 | 部分建设 |
 | CAT-10 | 资产发现 / 增量采集 | 基于游标、事件或快照差异的增量更新 | 部分建设 |
-| CAT-11 | 资产搜索 / 关键词搜索 | 名称、描述、标签和全文检索 | 已建设 |
-| CAT-12 | 资产搜索 / 自然语言找数 | 自然语言定位资产、解释指标和推荐数据产品 | 部分建设 |
+| CAT-11 | 资产搜索 / 关键词搜索 | 名称、描述、标签和全文检索 | 工程完成;设备名称、平台 UID、授权源 ID、位置、组织、责任人和运行事件已接入,待企业检索集验收 |
+| CAT-12 | 资产搜索 / 自然语言找数 | 自然语言定位资产、解释指标和推荐数据产品 | 部分建设;设备域授权检索和证据问答已形成,不扩展 NL2SQL、在线分析或通用数据产品推荐 |
 | CAT-13 | 资产详情 / 统一档案 | 基本信息、来源、责任人、质量、权限、血缘和版本 | 部分建设 |
 | CAT-14 | 资产标识 / 稳定 UID | 跨系统稳定业务标识和源系统 ID 映射 | 部分建设 |
 | CAT-15 | 资产标签 / 标签体系 | 标签新增、详情、列表、识别、图谱和删除 | 已建设 |
@@ -671,13 +672,13 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 |---|---|---|---|
 | KAI-01 | 治理知识 / 文档构建 | 将治理对象构建为规范化文档和知识点 | 已建设 |
 | KAI-02 | 治理知识 / 动态同步 | Diff、增量发布、影响传播、重试和回滚 | 已建设 |
-| KAI-03 | 治理知识 / 混合检索 | 精确、词法、向量、治理图和 RRF | 已建设 |
-| KAI-04 | 治理知识 / 证据问答 | 只基于授权证据回答并返回来源与版本 | 已建设 |
-| KAI-05 | 治理知识 / 来源版本 | 当前来源、历史版本、freshness 和引用 | 已建设 |
-| KAI-06 | 治理知识 / 知识运维 | Change Set、审计、投影重试和评测 | 已建设 |
-| KAI-07 | 治理知识 / LightRAG | 独立、可重建、影子模式图增强投影 | 已建设 |
+| KAI-03 | 治理知识 / 混合检索 | 精确、词法、向量、治理图、设备证据和 RRF | 工程完成,待企业检索集验收 |
+| KAI-04 | 治理知识 / 证据问答 | 只基于授权证据回答并返回来源、内容与版本 | 工程完成,待企业模型与黄金集验收 |
+| KAI-05 | 治理知识 / 来源版本 | 当前来源、历史版本、设备安全详情、freshness 和引用 | 工程完成,待企业设备数据验收 |
+| KAI-06 | 治理知识 / 知识运维 | Change Set、最小化查询审计、设备源范围、投影重试和评测 | 工程完成,待企业授权配置 |
+| KAI-07 | 治理知识 / LightRAG | 独立、可重建、影子模式图增强投影 | 影子模式;不能绕过 canonical 授权或阻断发布 |
 | KAI-08 | 治理知识 / 生产 K6 | 真实模型、黄金集、分域投影和回滚门禁 | 规划中 |
-| KAI-09 | 业务助手 / 自然语言找数 | 查询资产、解释口径和推荐数据产品 | 部分建设 |
+| KAI-09 | 业务助手 / 自然语言找数 | 查询资产、解释口径和推荐数据产品 | 部分建设;设备域自然语言问答已形成,通用找数与推荐仍未建设 |
 | KAI-10 | 业务助手 / 分析边界 | 不建设 NL2SQL、在线分析和 BI 开发平台 | 明确不扩展 |
 | KAI-11 | Agent 治理 / Agent 注册 | Agent 名称、用途、负责人、版本和状态 | 规划中 |
 | KAI-12 | Agent 治理 / Agent 身份 | 独立机器身份、短期凭证和租户/域绑定 | 部分建设 |
@@ -688,9 +689,9 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | KAI-17 | Agent 治理 / 运行沙箱 | 网络、文件、数据、命令、资源和时间限制 | 部分建设 |
 | KAI-18 | Agent 治理 / 审计回放 | 输入摘要、模型/Prompt、工具、结果、证据和回放 | 部分建设 |
 | KAI-19 | Agent 治理 / 异常处置 | 自动暂停、回滚、降级、人工升级和事故关联 | 规划中 |
-| KAI-20 | Agent 安全 / 提示注入 | 不可信内容隔离、引用约束和越权防护 | 部分建设 |
+| KAI-20 | Agent 安全 / 提示注入 | 不可信内容隔离、引用约束和越权防护 | 部分建设;问答提示隔离证据、校验引用索引并在 SQL 与融合后双重授权 |
 | KAI-21 | Agent 生态 / 插件与 MCP | 受治理的 Agent 工具和 MCP 扩展 | 规划中 |
-| KAI-22 | 设备助手 / 故障根因 | 基于设备关系、告警和维修知识解释根因 | 规划中 |
+| KAI-22 | 设备助手 / 故障根因 | 基于设备关系、告警和维修知识解释根因 | 部分建设;设备运行事件可检索和证据问答,复杂根因结论仍需企业模型、关系语料和专家验收 |
 
 ### 12.10 审批、任务与协同运营
 
@@ -795,8 +796,8 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | 9 | SEM-08~13、SEM-18 | 设备本体工作台 | 通用本体能力及设备标准语义模板已形成;企业责任人仍需确认并完成真实语义发布验收 |
 | 10 | SEM-20、SEM-21 | 故障代码统一 | 故障/原因/措施代码的版本、证据、提交和负责人审批链已形成;企业代码与受治理 AI 建议仍待验收 |
 | 11 | CAT-16、OBS-10 | 设备根因关系图 | 建立设备—部件—告警—故障—维修—停机影响关系 |
-| 12 | CAT-11、KAI-03 | 设备资产搜索 | 按设备名称、源 ID、位置、部件、故障和责任人检索 |
-| 13 | KAI-04、KAI-22 | 设备知识问答 | 基于本体、维修和故障证据回答关系与根因问题并返回来源 |
+| 12 | CAT-11、KAI-03 | 设备资产搜索 | 工程能力已形成;按设备名称、平台 UID、授权源 ID、位置、组织、责任人和故障等运行事件检索,待企业检索集验收 |
+| 13 | KAI-04、KAI-22 | 设备知识问答 | 工程能力已形成;只基于授权证据回答并返回引用,模型不可用或证据不足时明确拒答,待企业模型、黄金集和专家验收 |
 | 14 | DQA-02、OBS-07 | 台账完整性检查 | 检查设备标识、部件、位置、组织和责任人完整率 |
 | 15 | OBS-08 | 故障数据质量检查 | 检查故障代码映射、原因、措施和维修闭环完整性 |
 | 16 | DQA-10、DQA-11 | 质量整改闭环 | 建立问题、责任人、整改、复核、关闭和逾期提醒的最小流程 |

+ 131 - 0
docs/acceptance/WP10_DEVICE_KNOWLEDGE_GOLDEN_SET.json

@@ -0,0 +1,131 @@
+{
+  "set_id": "wp10-device-knowledge-v1",
+  "version": 1,
+  "status": "template_pending_enterprise_binding",
+  "purpose": "验证设备资产搜索、授权证据问答、拒答和业务域隔离。",
+  "binding_requirements": [
+    "将示例设备 UID、源 ID、位置、责任人和故障替换为企业验收数据。",
+    "为每个业务域配置 ingestion_sources.permission_scope.business_domains。",
+    "在企业合规模型环境中执行问答案例并由设备业务专家复核。",
+    "不得用模拟数据通过企业 UAT。"
+  ],
+  "cases": [
+    {
+      "id": "WP10-S01",
+      "type": "search",
+      "title": "按设备名称检索",
+      "query": "{{device_name}}",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "object_type": "DeviceAsset",
+        "object_uid": "{{device_uid}}",
+        "content_contains": ["设备名称:{{device_name}}"]
+      }
+    },
+    {
+      "id": "WP10-S02",
+      "type": "search",
+      "title": "按平台 UID 检索",
+      "query": "{{device_uid}}",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "object_uid": "{{device_uid}}",
+        "content_contains": ["平台 UID:{{device_uid}}"]
+      }
+    },
+    {
+      "id": "WP10-S03",
+      "type": "search",
+      "title": "按源系统 ID 检索",
+      "query": "{{source_device_id}}",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "object_uid": "{{device_uid}}",
+        "content_contains": ["源 ID:", "{{source_device_id}}"]
+      }
+    },
+    {
+      "id": "WP10-S04",
+      "type": "search",
+      "title": "按位置检索",
+      "query": "{{device_location}}",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "object_uid": "{{device_uid}}",
+        "content_contains": ["位置:{{device_location}}"]
+      }
+    },
+    {
+      "id": "WP10-S05",
+      "type": "search",
+      "title": "按责任人检索",
+      "query": "{{responsible_person}}",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "object_uid": "{{device_uid}}",
+        "content_contains": ["责任人:{{responsible_person}}"]
+      }
+    },
+    {
+      "id": "WP10-S06",
+      "type": "search",
+      "title": "按故障检索",
+      "query": "{{fault_title}}",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "object_uid": "{{device_uid}}",
+        "content_contains": ["故障:{{fault_title}}", "{{fault_source_id}}"]
+      }
+    },
+    {
+      "id": "WP10-Q07",
+      "type": "ask",
+      "title": "责任人证据问答",
+      "query": "{{device_name}} 的责任人是谁?",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "answer_status": "grounded",
+        "answer_contains": ["{{responsible_person}}"],
+        "citation_object_uid": "{{device_uid}}",
+        "citation_point_key": "DeviceAsset/{{device_uid}}/summary"
+      }
+    },
+    {
+      "id": "WP10-Q08",
+      "type": "ask",
+      "title": "故障证据问答",
+      "query": "{{device_name}} 有哪些已记录故障?",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "answer_status": "grounded",
+        "answer_contains": ["{{fault_title}}"],
+        "citation_object_uid": "{{device_uid}}",
+        "citation_point_key": "DeviceAsset/{{device_uid}}/summary"
+      }
+    },
+    {
+      "id": "WP10-A09",
+      "type": "authorization",
+      "title": "未授权业务域不得泄漏",
+      "query": "{{domain_b_source_device_id}}",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "evidence_count": 0,
+        "answer": null,
+        "answer_status": "no_answer"
+      }
+    },
+    {
+      "id": "WP10-R10",
+      "type": "refusal",
+      "title": "证据不足时拒答",
+      "query": "{{device_name}} 明年的精确故障时间是什么?",
+      "actor_scope": ["{{domain_a_uid}}"],
+      "expected": {
+        "answer": null,
+        "answer_status": "no_answer",
+        "citations": []
+      }
+    }
+  ]
+}

+ 16 - 0
docs/architecture/DATA_MODEL.md

@@ -133,6 +133,8 @@ flowchart LR
 | `governance_documents` | `object_type`, `object_uid`, `object_version`, `content_hash` | 治理对象文本快照 |
 | `governance_chunks` | `document_id`, `chunk_no`, `content`, `embedding vector` | Qwen Embedding 结果 |
 | `governance_sync_jobs` | `mode`, `cursor`, `status`, `error` | 增量同步和每日全量一致性巡检 |
+| `knowledge_query_audits` | `query_hash`, `user_id`, `roles`, `business_domain_uids`, `mode`, `retriever_counts`, `cited_points`, `degraded_components`, `correlation_id`, `latency_ms` | 最小化知识查询审计;不保存原始问题、回答或证据内容 |
+| `knowledge_evaluation_sets/cases/runs/results` | `case_type`, `allowed_business_domains`, `expected_sources`, `expected_answer_points`, `must_refuse`, `metrics`, `citations` | 版本化检索、问答、授权与拒答验收证据 |
 | `workbench_layouts` | `user_id`, `layout_version`, `widgets jsonb` | 按用户保存有限标准组件布局 |
 | `outbox_events` | `event_id`, `aggregate_type`, `aggregate_id`, `payload`, `published_at` | 跨存储最终一致性 |
 | `datasource_credentials` | `data_source_uid`, `credential_version`, `encrypted_payload`, `nonce`, `key_version`, `status` | 外部数据源不可变加密凭据 |
@@ -184,9 +186,22 @@ flowchart LR
 | 元数据定义 | Neo4j `DataMeta` | `metadata:{uid}` | 审核落库后增量同步 |
 | 数据标准/标签 | Neo4j | `standard/label:{uid}` | 变更后增量同步 |
 | 全量校验 | Neo4j + PostgreSQL | 内容哈希 | 每日全量一致性巡检 |
+| 设备资产与运行事件 | PostgreSQL `device_assets`、授权后的来源映射和运行事件 | `device:{uid}:v{version}` | 查询时按数据源业务域直接检索,不复制事件原始证据 |
 
 向量由 Qwen 的 embedding 模型生成;DeepSeek 只负责生成式问答。召回结果必须携带对象类型、业务域、版本、更新时间和访问范围,回答必须返回来源对象。
 
+设备检索以 PostgreSQL canonical 数据为源真相:`ingestion_sources.permission_scope.business_domains`
+为空时仅管理员可见;普通用户查询必须先在 SQL 的 `authorized_sources` 范围内完成来源
+映射、事件聚合和文本匹配,候选进入 RRF 后再次按访问上下文授权。设备证据只包含平台
+UID、名称、授权源 ID、位置、组织、责任人和授权事件类型/标题/源 ID,不包含数据源配置、
+设备扩展属性、事件 `evidence_refs` 或凭据。LightRAG 仍是影子投影,不能绕过 canonical
+授权、直接回答或阻断 canonical 发布。
+
+问答只允许模型选择已召回证据的引用索引;引用无效、证据不足或模型不可用时回答为空,
+页面仍可展示当前用户有权访问的检索证据。每次已执行检索和问答必须成功写入
+`knowledge_query_audits`,但只保存规范化问题的 SHA-256、身份/角色/授权域、检索模式、
+检索器计数、引用知识点身份、降级组件、关联 ID 和耗时。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -200,6 +215,7 @@ flowchart LR
 - 设备质量策略版本、执行结果、规则计数、违规样本和资产评分以 PostgreSQL 为源真相;检查只读设备资产和已发布语义代码,不修改来源台账。
 - 质量问题、整改轮次和处理时间线以 PostgreSQL 为源真相;问题保存 WP-07 违规的安全证据快照,状态变更采用乐观锁,关闭必须由 `quality_issue/DEVICE_QUALITY_ISSUES` 唯一负责的设备资产管理员独立复核。逾期由期限和未关闭状态实时计算,复发由规则、资产和字段的确定性身份统计,不等同于自动根因结论。
 - 设备运行事件和有向证据关系以 PostgreSQL 为源真相;关系图是最多三跳、100 个节点和 200 条边的可重建查询投影。根因分析只沿 `indicates`、`triggered` 和 `evidences` 上游关系返回候选及证据路径;没有持久化路径时必须返回“证据不足,无法确认根因”,结果不触发自动修复或维修计划。
+- 设备知识检索直接读取授权后的 PostgreSQL canonical 资产、来源映射和运行事件;不建立第二份设备主数据,不读取来源配置和事件原始证据。问答不能替代 WP-09 的证据路径或设备专家根因结论。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 398 - 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: 195
+x-route-count: 211
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -28,6 +28,8 @@ tags:
     description: "/api/datafactory"
   - name: data_service
     description: "/api/dataservice"
+  - name: knowledge_base
+    description: "/api/knowledge"
 paths:
   "/api/bd/compose":
     post:
@@ -4125,6 +4127,401 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/admin/audit":
+    post:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_audit_post
+      summary: "admin audit"
+      x-source: "app/api/knowledge_base/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/knowledge/admin/change-sets":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_change_sets_get
+      summary: "admin change sets"
+      x-source: "app/api/knowledge_base/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/admin/change-sets/{change_set_id}":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_change_set_get
+      summary: "admin change set"
+      x-source: "app/api/knowledge_base/routes.py"
+      parameters:
+        - name: change_set_id
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/admin/change-sets/{change_set_id}/retry":
+    post:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_retry_change_set_post
+      summary: "admin retry change set"
+      x-source: "app/api/knowledge_base/routes.py"
+      parameters:
+        - name: change_set_id
+          in: path
+          required: true
+          schema:
+            type: string
+      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/knowledge/admin/change-sets/{change_set_id}/rollback":
+    post:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_rollback_change_set_post
+      summary: "admin rollback change set"
+      x-source: "app/api/knowledge_base/routes.py"
+      parameters:
+        - name: change_set_id
+          in: path
+          required: true
+          schema:
+            type: string
+      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/knowledge/admin/device-sources":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_device_sources_get
+      summary: "admin device sources"
+      x-source: "app/api/knowledge_base/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/admin/device-sources/{source_uid}/scope":
+    put:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_device_source_scope_put
+      summary: "admin device source scope"
+      x-source: "app/api/knowledge_base/routes.py"
+      parameters:
+        - name: source_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      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/knowledge/admin/evaluations":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_evaluations_get
+      summary: "admin evaluations"
+      x-source: "app/api/knowledge_base/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/admin/query-audits":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_query_audits_get
+      summary: "admin query audits"
+      x-source: "app/api/knowledge_base/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/admin/retry-projection":
+    post:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_retry_projection_post
+      summary: "admin retry projection"
+      x-source: "app/api/knowledge_base/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/knowledge/admin/sync":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_admin_sync_get
+      summary: "admin sync"
+      x-source: "app/api/knowledge_base/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/ask":
+    post:
+      tags: [knowledge_base]
+      operationId: knowledge_base_ask_post
+      summary: "ask"
+      x-source: "app/api/knowledge_base/routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      x-response-fields: [query_id, mode, answer, answer_status, degraded_components, citations, evidence, freshness_status]
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/capabilities":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_capabilities_get
+      summary: "capabilities"
+      x-source: "app/api/knowledge_base/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/search":
+    post:
+      tags: [knowledge_base]
+      operationId: knowledge_base_search_post
+      summary: "search"
+      x-source: "app/api/knowledge_base/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/knowledge/sources/{source_uid}":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_source_get
+      summary: "source"
+      x-source: "app/api/knowledge_base/routes.py"
+      parameters:
+        - name: source_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/knowledge/sources/{source_uid}/versions/{version}":
+    get:
+      tags: [knowledge_base]
+      operationId: knowledge_base_source_version_get
+      summary: "source version"
+      x-source: "app/api/knowledge_base/routes.py"
+      parameters:
+        - name: source_uid
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: version
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/meta/check":
     get:
       tags: [meta_data]

+ 125 - 0
docs/superpowers/plans/2026-07-29-wp10-device-search-knowledge-qa.md

@@ -0,0 +1,125 @@
+# WP10 Device Search and Knowledge Q&A 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:** Deliver authorized device search and evidence-grounded device Q&A by extending the existing canonical governance knowledge pipeline.
+
+**Architecture:** PostgreSQL device assets, authorized source mappings, and operational events remain authoritative. A bounded device retriever joins the existing knowledge retrieval pipeline before fusion, filters source business-domain scope in SQL, and emits the same stable evidence contract used by lexical/vector retrieval. The existing answer synthesizer remains the only generative path; it must refuse or report model unavailability instead of fabricating. LightRAG remains shadow-only and is not activated by WP10.
+
+**Tech Stack:** Flask, SQLAlchemy/PostgreSQL, existing knowledge retrieval and Q&A services, Vue 2/Vuetify, pytest, Node test runner.
+
+---
+
+## Scope and constraints
+
+- Search platform UID, device name, authorized source ID, location, organization, responsible person, and related alarm/fault/maintenance/downtime records.
+- Derive device data range from each `ingestion_sources.permission_scope.business_domains`. An empty scope is admin-only; non-admin access is default-deny.
+- Apply authorization inside SQL before device candidates are returned, then retain the pipeline's post-fusion authorization check.
+- Return stable object UID, asset version, source time, point key/revision, source IDs, and related event evidence.
+- For Q&A, return only model-selected canonical citations. When the model is unavailable or evidence is insufficient, return no answer and still expose the authorized retrieval evidence for inspection.
+- Persist query audit evidence without storing raw questions, generated answers, secrets, or full retrieved content.
+- Provide an admin-only source-scope configuration path and query-audit view.
+- Do not add NL2SQL, online analysis, BI development, predictive maintenance, automatic repair, generic web search, or direct LightRAG answers.
+- Do not claim enterprise acceptance until a real source scope, device corpus, answer model, and golden set have been validated.
+
+### Task 1: Device evidence contract and deterministic query behavior
+
+**Files:**
+- Create: `app/core/knowledge/retrieval/device.py`
+- Create: `tests/knowledge/test_device_retrieval.py`
+- Modify: `app/core/knowledge/retrieval/pipeline.py`
+- Modify: `tests/knowledge/test_retrieval.py`
+
+- [x] Write failing tests that define query normalization, maximum length, deterministic device scoring, stable point keys, safe readable content, and source/event evidence boundaries.
+- [x] Write a failing pipeline test proving device evidence participates in fusion and is still removed when its business domain is outside the access context.
+- [x] Run only the two tests and confirm failure is caused by the absent device retriever integration.
+- [x] Implement `SqlDeviceKnowledgeRetriever` with a 300-character query boundary, a maximum 100 results, SQL-pre-filtered business-domain authorization, and deterministic ranking.
+- [x] Aggregate only authorized source codes and authorized alarm/fault/maintenance/downtime titles; do not include event evidence JSON or source configuration.
+- [x] Emit `KnowledgeEvidence` with `object_type="DeviceAsset"`, `object_version=current_version`, one stable summary point, source time, and the authorized business-domain UID.
+- [x] Add the optional `device` retriever to `KnowledgeRetrievalPipeline`; include it in standard search modes without changing LightRAG routing.
+- [x] Re-run only device retrieval and pipeline tests until green.
+
+### Task 2: Source scope management and real PostgreSQL authorization
+
+**Files:**
+- Create: `app/core/knowledge/device_scope.py`
+- Create: `tests/knowledge/test_device_scope.py`
+- Create: `tests/integration/test_device_knowledge_postgres.py`
+
+- [x] Write failing service tests for sorted unique UUID scopes, the 100-domain boundary, invalid UUID rejection, missing source rejection, empty-scope admin-only semantics, and preservation of non-domain scope keys.
+- [x] Write a failing PostgreSQL test with two device sources in different business domains plus one unscoped source.
+- [x] Prove an admin can retrieve all three, a domain-A viewer can retrieve only source-A assets/events, source-B IDs do not match, and an unscoped asset does not leak.
+- [x] Implement `DeviceSourceScopeService` and its SQLAlchemy repository against the existing `IngestionSource.permission_scope`.
+- [x] Implement the device retriever query using an `authorized_sources` CTE so filtering occurs before text matching and aggregation.
+- [x] Add a source-detail lookup that applies the same authorization and returns only safe device/source/event fields.
+- [x] Run only the source-scope and PostgreSQL device-knowledge tests until green.
+
+### Task 3: Knowledge API integration, citations, and query audit
+
+**Files:**
+- Create: `app/core/knowledge/query_audit.py`
+- Create: `tests/knowledge/test_query_audit.py`
+- Modify: `app/api/knowledge_base/routes.py`
+- Modify: `tests/knowledge/test_api.py`
+- Modify: `app/core/system/permissions.py`
+- Modify: `tests/test_permission_matrix.py`
+
+- [x] Write failing API tests for device search evidence, grounded device answers with citation content, model-unavailable answers with authorized retrieval evidence, device source detail, admin source-scope list/update, and admin query-audit list.
+- [x] Write failing audit tests proving only a normalized SHA-256 query hash, subject, roles, authorized domains, mode, retriever counts, cited point identities, degraded components, correlation ID, and latency are stored.
+- [x] Assert that raw query text, answer text, evidence content, credentials, and source configuration never enter the audit payload.
+- [x] Instantiate `SqlDeviceKnowledgeRetriever` in the default canonical pipeline and add safe device fallback to `/api/knowledge/sources/<uid>`.
+- [x] Add `evidence` to `/api/knowledge/ask` responses and enrich selected citations with their retrieved content and source metadata.
+- [x] Persist a query audit for every executed search/ask; fail the request if mandatory audit persistence fails.
+- [x] Add admin-only `GET/PUT /api/knowledge/admin/device-sources[/<uid>/scope]` and `GET /api/knowledge/admin/query-audits`.
+- [x] Keep public search/ask under `governance:read` and all scope/audit operations under `knowledge:manage`.
+- [x] Run only knowledge API, audit, and permission tests until green.
+
+### Task 4: Device-focused knowledge workbench
+
+**Files:**
+- Create: `frontend/src/views/knowledgeBaseProduct/deviceKnowledgeModel.js`
+- Create: `frontend/tests/device-knowledge-model.test.mjs`
+- Modify: `frontend/src/views/knowledgeBaseProduct/index.vue`
+- Modify: `frontend/src/api/governanceKnowledge.js`
+- Create: `tests/knowledge/test_device_knowledge_frontend_contract.py`
+
+- [x] Write failing model tests for device object labels, answer-status presentation, citation/evidence fallback, source-scope display, and admin-only controls.
+- [x] Write a failing frontend contract test for device search prompts, source ID/location/responsible/fault coverage, explicit no-answer language, source-scope management, and audit visibility.
+- [x] Add device prompt examples and render readable `DeviceAsset` evidence without parsing or exposing private source configuration.
+- [x] For ask responses, display selected citations when grounded; otherwise display authorized retrieval evidence beneath the explicit refusal/model-unavailable state.
+- [x] Add an admin source-scope dialog accepting newline-separated business-domain UUIDs and show that an empty scope is admin-only.
+- [x] Add an admin query-audit list that displays time, mode, retriever counts, cited-point count, degraded components, and correlation ID but never raw questions.
+- [x] Use the existing `$snackbar` interface for success and failure feedback.
+- [x] Run only the Node model test, frontend contract test, and ESLint on changed frontend files.
+
+### Task 5: Acceptance set, documentation, and release-copy parity
+
+**Files:**
+- Create: `docs/acceptance/WP10_DEVICE_KNOWLEDGE_GOLDEN_SET.json`
+- Modify: `docs/FUNCTION_MODULE_CENSUS_20260726.md`
+- Modify: `docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md`
+- Modify: `docs/architecture/DATA_MODEL.md`
+- Modify: `docs/architecture/OPENAPI.yaml`
+- Modify: `tests/test_architecture_artifacts.py`
+- Mirror changed backend files under: `deployment/app/`
+
+- [x] Add a versioned ten-case acceptance set covering name, platform UID, source ID, location, responsible person, fault, grounded owner/fault questions, unauthorized-domain refusal, and insufficient-evidence refusal.
+- [x] Document source-scope semantics, SQL pre-filtering, post-fusion authorization, audit data minimization, citation behavior, and model-unavailable behavior.
+- [x] Mark WP10 engineering maturity separately from enterprise source-scope, real-model, and golden-set acceptance.
+- [x] Update CAT-11, CAT-12, KAI-03, KAI-04, KAI-05, KAI-06, KAI-08, KAI-09, KAI-20, and KAI-22 without claiming NL2SQL or production K6 completion.
+- [x] Regenerate OpenAPI and assert all new admin endpoints plus ask evidence fields are represented.
+- [x] Copy every changed `app/` backend file to `deployment/app/` and verify byte parity.
+
+### Task 6: Targeted validation and local branch commit
+
+**Files:**
+- Test only WP10-related files and directly affected permission/architecture contracts.
+
+- [x] Run WP10 device retrieval, scope, query audit, 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 run the device knowledge Node model test.
+- [x] Build the frontend production bundle because the existing knowledge workbench changes.
+- [x] Rebuild only local backend/frontend services needed for WP10 browser validation.
+- [x] In the browser, verify authorized device search by source ID and fault, model-unavailable/insufficient evidence does not fabricate an answer, source detail is safe, source-scope management works, query audits contain no raw question, and a clean knowledge page has zero console errors.
+- [x] Confirm the existing device asset and observability pages still load through unchanged navigation.
+- [x] Commit the verified WP10 change on `codex/dataops-phase1-equipment-governance`; do not push or deploy production.

+ 3 - 0
frontend/src/api/governanceKnowledge.js

@@ -6,5 +6,8 @@ export const getKnowledgeCapabilities = () => http.get('/knowledge/capabilities'
 export const getKnowledgeSource = uid => http.get(`/knowledge/sources/${uid}`)
 export const getKnowledgeSync = () => http.get('/knowledge/admin/sync')
 export const getKnowledgeChangeSets = limit => http.get('/knowledge/admin/change-sets', { limit })
+export const getDeviceKnowledgeSources = () => http.get('/knowledge/admin/device-sources')
+export const updateDeviceKnowledgeSourceScope = (uid, payload) => http.put(`/knowledge/admin/device-sources/${uid}/scope`, payload)
+export const getKnowledgeQueryAudits = limit => http.get('/knowledge/admin/query-audits', { limit })
 export const retryKnowledgeChangeSet = id => http.post(`/knowledge/admin/change-sets/${id}/retry`, {})
 export const rollbackKnowledgeChangeSet = id => http.post(`/knowledge/admin/change-sets/${id}/rollback`, {})

+ 67 - 0
frontend/src/views/knowledgeBaseProduct/deviceKnowledgeModel.js

@@ -0,0 +1,67 @@
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+
+export function deviceObjectLabel (objectType) {
+  return objectType === 'DeviceAsset' ? '设备资产' : (objectType || '未知对象')
+}
+
+export function answerPresentation (status) {
+  const values = {
+    grounded: {
+      color: 'success',
+      title: '已基于授权证据回答',
+      detail: '回答仅引用下方已选中的有效证据。'
+    },
+    model_unavailable: {
+      color: 'warning',
+      title: '回答模型暂不可用',
+      detail: '未生成推测性答案;以下仅展示你有权访问的检索证据。'
+    },
+    no_answer: {
+      color: 'info',
+      title: '当前证据不足',
+      detail: '知识库未生成推测性答案;可检查下方授权证据并调整问题。'
+    },
+    invalid_citations: {
+      color: 'warning',
+      title: '回答引用未通过校验',
+      detail: '未展示未通过引用校验的答案;以下仅展示授权检索证据。'
+    }
+  }
+  return values[status] || values.no_answer
+}
+
+export function resultEvidence (response) {
+  if (
+    response?.answer_status === 'grounded' &&
+    Array.isArray(response.citations) &&
+    response.citations.length
+  ) {
+    return response.citations
+  }
+  return Array.isArray(response?.evidence) ? response.evidence : []
+}
+
+export function parseBusinessDomainScope (value) {
+  const domains = String(value || '')
+    .split(/\r?\n/)
+    .map(item => item.trim())
+    .filter(Boolean)
+  if (domains.length > 100) {
+    throw new Error('业务域范围不能超过 100 个 UUID')
+  }
+  if (domains.some(item => !UUID_PATTERN.test(item))) {
+    throw new Error('每行必须是一个有效的业务域 UUID')
+  }
+  return [...new Set(domains)].sort()
+}
+
+export function scopePresentation (domains) {
+  const count = Array.isArray(domains) ? domains.length : 0
+  return count
+    ? { color: 'success', label: `${count} 个业务域` }
+    : { color: 'warning', label: '仅管理员可见' }
+}
+
+export function canManageKnowledge (permissions) {
+  return Array.isArray(permissions) && permissions.includes('knowledge:manage')
+}

+ 229 - 11
frontend/src/views/knowledgeBaseProduct/index.vue

@@ -25,7 +25,14 @@
     <main class="workbench-grid">
       <section class="query-panel">
         <div class="mode-switch mb-5">
-          <v-btn-toggle v-model="action" mandatory dense color="primary">
+          <v-btn-toggle
+            v-model="action"
+            mandatory
+            dense
+            color="primary"
+            :disabled="loading"
+            @change="resetResult"
+          >
             <v-btn value="ask">问答</v-btn>
             <v-btn value="search">仅检索</v-btn>
           </v-btn-toggle>
@@ -43,13 +50,24 @@
         </div>
 
         <label class="text-subtitle-2 d-block mb-2" for="knowledge-query">治理问题</label>
+        <div class="prompt-examples mb-3">
+          <button
+            v-for="item in devicePrompts"
+            :key="item.label"
+            type="button"
+            class="prompt-chip"
+            @click="applyExample(item.query)"
+          >
+            {{ item.label }}
+          </button>
+        </div>
         <v-textarea
           id="knowledge-query"
           v-model="query"
           outlined
           auto-grow
           rows="3"
-          :placeholder="action === 'ask' ? '例如:客户同步流程由谁负责?它依赖哪些上游数据?' : '输入对象名、字段名、编码或治理概念'"
+          :placeholder="action === 'ask' ? '例如:源 ID 为 EQ-001 的设备责任人是谁?最近有哪些故障?' : '输入设备名称、平台 UID、源 ID、位置、责任人或故障'"
           :error-messages="queryError"
           @keydown.ctrl.enter="submit"
         />
@@ -79,8 +97,23 @@
               <v-chip small :color="freshnessColor" outlined>{{ freshnessLabel }}</v-chip>
             </div>
             <p v-if="answer" class="answer-copy">{{ answer }}</p>
-            <v-alert v-else type="info" outlined dense class="mb-0">
-              当前有效证据不足,知识库没有生成推测性答案。你仍可查看下方检索来源。
+            <v-alert
+              v-else
+              :type="answerState.color"
+              outlined
+              dense
+              class="mb-0"
+            >
+              <strong v-if="answerStatus === 'model_unavailable'">回答模型暂不可用</strong>
+              <strong v-else-if="answerStatus === 'no_answer'">当前证据不足</strong>
+              <strong v-else>{{ answerState.title }}</strong>
+              <div v-if="answerStatus === 'model_unavailable'" class="mt-1">
+                未生成推测性答案;以下仅展示你有权访问的检索证据。
+              </div>
+              <div v-else-if="answerStatus === 'no_answer'" class="mt-1">
+                知识库未生成推测性答案;可检查下方授权证据并调整问题。
+              </div>
+              <div v-else class="mt-1">{{ answerState.detail }}</div>
             </v-alert>
           </div>
 
@@ -99,7 +132,7 @@
               <span class="evidence-index">{{ String(index + 1).padStart(2, '0') }}</span>
               <span class="evidence-main">
                 <span class="d-flex align-center flex-wrap mb-2">
-                  <strong class="mr-2">{{ item.object_type }}</strong>
+                  <strong class="mr-2">{{ objectLabel(item.object_type) }}</strong>
                   <v-chip x-small outlined class="mr-2">v{{ item.object_version }}</v-chip>
                   <v-chip x-small outlined color="primary">point r{{ firstRevision(item) }}</v-chip>
                 </span>
@@ -164,6 +197,50 @@
           </div>
           <div v-else class="text-caption grey--text">暂无变更记录</div>
         </div>
+
+        <div v-if="isAdmin" class="context-section admin-section">
+          <div class="d-flex align-center justify-space-between mb-3">
+            <div>
+              <h2 class="text-subtitle-1 font-weight-bold mb-0">数据源授权范围</h2>
+              <div class="text-caption grey--text mt-1">空范围仅管理员可见</div>
+            </div>
+            <v-icon small color="primary">mdi-shield-account-outline</v-icon>
+          </div>
+          <div v-if="deviceSources.length" class="change-list">
+            <div v-for="item in deviceSources" :key="item.uid" class="change-row">
+              <div class="min-width-0">
+                <div class="text-body-2 font-weight-medium text-truncate">{{ item.name }}</div>
+                <v-chip x-small outlined :color="scopeState(item).color">
+                  {{ scopeState(item).label }}
+                </v-chip>
+              </div>
+              <v-btn icon small :aria-label="`编辑 ${item.name} 授权范围`" @click="openScope(item)">
+                <v-icon small>mdi-pencil-outline</v-icon>
+              </v-btn>
+            </div>
+          </div>
+          <div v-else class="text-caption grey--text">暂无已接入设备的数据源</div>
+        </div>
+
+        <div v-if="isAdmin" class="context-section admin-section">
+          <div class="d-flex align-center justify-space-between mb-2">
+            <h2 class="text-subtitle-1 font-weight-bold mb-0">查询审计</h2>
+            <v-chip x-small outlined>最近 {{ queryAudits.length }} 条</v-chip>
+          </div>
+          <div class="text-caption grey--text mb-3">原始问题不会写入审计</div>
+          <div v-if="queryAudits.length" class="audit-list">
+            <div v-for="item in queryAudits.slice(0, 8)" :key="item.id" class="audit-row">
+              <div class="d-flex justify-space-between">
+                <strong>{{ item.mode }}</strong>
+                <span>{{ auditTime(item.created_at) }}</span>
+              </div>
+              <div>检索器 {{ retrieverSummary(item.retriever_counts) }} · 引用 {{ (item.cited_points || []).length }}</div>
+              <div v-if="(item.degraded_components || []).length">降级:{{ item.degraded_components.join('、') }}</div>
+              <div class="audit-correlation">ID {{ item.correlation_id }}</div>
+            </div>
+          </div>
+          <div v-else class="text-caption grey--text">暂无查询审计</div>
+        </div>
       </aside>
     </main>
 
@@ -181,25 +258,66 @@
               <div><span>对象</span><strong>{{ sourceDetail.object_type }}</strong></div>
               <div><span>名称</span><strong>{{ sourceDetail.object_name }}</strong></div>
               <div><span>版本</span><strong>v{{ sourceDetail.object_version }}</strong></div>
-              <div><span>Generation</span><strong>{{ sourceDetail.active_generation }}</strong></div>
+              <div v-if="sourceDetail.active_generation"><span>Generation</span><strong>{{ sourceDetail.active_generation }}</strong></div>
+              <div v-if="sourceDetail.location"><span>位置</span><strong>{{ sourceDetail.location }}</strong></div>
+              <div v-if="sourceDetail.responsible_person"><span>责任人</span><strong>{{ sourceDetail.responsible_person }}</strong></div>
+            </div>
+            <pre v-if="sourceDetail.content" class="source-content">{{ sourceDetail.content }}</pre>
+            <div v-else class="source-content">
+              <div v-if="sourceDetail.source_codes && sourceDetail.source_codes.length">源 ID:{{ sourceDetail.source_codes.join('、') }}</div>
+              <div v-for="event in sourceDetail.related_events || []" :key="`${event.event_type}-${event.source_code}`">
+                {{ event.event_type }}:{{ event.title }}<template v-if="event.source_code">({{ event.source_code }})</template>
+              </div>
             </div>
-            <pre class="source-content">{{ sourceDetail.content }}</pre>
           </template>
         </v-card-text>
       </v-card>
     </v-dialog>
+
+    <v-dialog v-model="scopeDialog" max-width="620">
+      <v-card>
+        <v-card-title>配置设备数据源业务域</v-card-title>
+        <v-card-text>
+          <div class="text-body-2 mb-3">{{ selectedSource && selectedSource.name }}</div>
+          <v-textarea
+            v-model="scopeText"
+            outlined
+            rows="7"
+            label="业务域 UUID(每行一个)"
+            hint="留空表示空范围;空范围仅管理员可见"
+            persistent-hint
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="scopeDialog = false">取消</v-btn>
+          <v-btn color="primary" depressed :loading="scopeSaving" @click="saveScope">保存授权范围</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
   </div>
 </template>
 
 <script>
 import {
   askKnowledge,
+  getDeviceKnowledgeSources,
   getKnowledgeCapabilities,
   getKnowledgeChangeSets,
+  getKnowledgeQueryAudits,
   getKnowledgeSource,
   getKnowledgeSync,
-  searchKnowledge
+  searchKnowledge,
+  updateDeviceKnowledgeSourceScope
 } from '@/api/governanceKnowledge'
+import {
+  answerPresentation,
+  canManageKnowledge,
+  deviceObjectLabel,
+  parseBusinessDomainScope,
+  resultEvidence,
+  scopePresentation
+} from './deviceKnowledgeModel'
 
 export default {
   name: 'governance-knowledge-base',
@@ -219,10 +337,24 @@ export default {
       capabilities: {},
       adminStatus: {},
       changeSets: [],
+      deviceSources: [],
+      queryAudits: [],
       adminLoading: false,
       sourceDialog: false,
       sourceLoading: false,
       sourceDetail: null,
+      scopeDialog: false,
+      scopeSaving: false,
+      selectedSource: null,
+      scopeText: '',
+      devicePrompts: [
+        { label: '设备名称', query: '循环泵' },
+        { label: '平台 UID', query: '输入设备平台 UID' },
+        { label: '源 ID', query: '输入设备源 ID' },
+        { label: '位置', query: '动力车间有哪些设备?' },
+        { label: '责任人', query: '张工负责哪些设备?' },
+        { label: '故障', query: '循环泵最近有哪些故障?' }
+      ],
       modes: [
         { label: '自动判断', value: 'auto' },
         { label: '精确', value: 'exact' },
@@ -235,7 +367,7 @@ export default {
   computed: {
     isAdmin () {
       const permissions = this.$store.getters.userInfo?.permissions || []
-      return permissions.includes('knowledge:manage')
+      return canManageKnowledge(permissions)
     },
     hasResult () {
       return Boolean(this.answerStatus || this.evidence.length)
@@ -245,6 +377,9 @@ export default {
     },
     freshnessColor () {
       return { fresh: 'success', updating: 'warning', degraded: 'error' }[this.freshness] || 'grey'
+    },
+    answerState () {
+      return answerPresentation(this.answerStatus)
     }
   },
   created () {
@@ -252,6 +387,13 @@ export default {
     if (this.isAdmin) this.loadAdmin()
   },
   methods: {
+    resetResult () {
+      this.answer = null
+      this.answerStatus = ''
+      this.evidence = []
+      this.degradedComponents = []
+      this.error = ''
+    },
     async submit () {
       if (!this.query.trim()) {
         this.queryError = '请输入治理问题'
@@ -272,7 +414,7 @@ export default {
         if (this.action === 'ask') {
           this.answer = data.answer
           this.answerStatus = data.answer_status
-          this.evidence = data.citations || []
+          this.evidence = resultEvidence(data)
           this.freshness = data.freshness_status || 'degraded'
         } else {
           this.answerStatus = 'retrieved'
@@ -281,6 +423,7 @@ export default {
         }
       } catch (error) {
         this.error = typeof error === 'string' ? error : '知识检索暂时不可用,请稍后重试。'
+        this.$snackbar.error(this.error)
       } finally {
         this.loading = false
       }
@@ -288,11 +431,19 @@ export default {
     async loadAdmin () {
       this.adminLoading = true
       try {
-        const [status, changes] = await Promise.all([getKnowledgeSync(), getKnowledgeChangeSets(20)])
+        const [status, changes, sources, audits] = await Promise.all([
+          getKnowledgeSync(),
+          getKnowledgeChangeSets(20),
+          getDeviceKnowledgeSources(),
+          getKnowledgeQueryAudits(50)
+        ])
         this.adminStatus = status.data || {}
         this.changeSets = changes.data || []
+        this.deviceSources = sources.data || []
+        this.queryAudits = audits.data || []
       } catch (error) {
         this.error = '管理员状态加载失败。'
+        this.$snackbar.error(this.error)
       } finally {
         this.adminLoading = false
       }
@@ -307,6 +458,7 @@ export default {
       } catch (error) {
         this.sourceDialog = false
         this.error = '来源已更新、删除或当前无权访问。'
+        this.$snackbar.error(this.error)
       } finally {
         this.sourceLoading = false
       }
@@ -317,6 +469,61 @@ export default {
     firstRevision (item) {
       return item.point_revision || (item.point_revisions && item.point_revisions[0]) || '-'
     },
+    applyExample (query) {
+      this.query = query
+      this.queryError = ''
+    },
+    objectLabel (objectType) {
+      return deviceObjectLabel(objectType)
+    },
+    scopeState (source) {
+      return scopePresentation(source.business_domains || [])
+    },
+    openScope (source) {
+      this.selectedSource = source
+      this.scopeText = (source.business_domains || []).join('\n')
+      this.scopeDialog = true
+    },
+    async saveScope () {
+      if (!this.selectedSource) return
+      let domains
+      try {
+        domains = parseBusinessDomainScope(this.scopeText)
+      } catch (error) {
+        this.$snackbar.error(error.message)
+        return
+      }
+      this.scopeSaving = true
+      try {
+        const response = await updateDeviceKnowledgeSourceScope(
+          this.selectedSource.uid,
+          { business_domains: domains }
+        )
+        const updated = response.data || {}
+        this.deviceSources = this.deviceSources.map(item => (
+          item.uid === updated.uid ? { ...item, ...updated } : item
+        ))
+        this.scopeDialog = false
+        this.$snackbar.success(
+          domains.length
+            ? '设备数据源授权范围已更新'
+            : '设备数据源已设为空范围,仅管理员可见'
+        )
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '授权范围保存失败')
+      } finally {
+        this.scopeSaving = false
+      }
+    },
+    retrieverSummary (counts) {
+      const entries = Object.entries(counts || {})
+      return entries.length
+        ? entries.map(([name, count]) => `${name} ${count}`).join('、')
+        : '无'
+    },
+    auditTime (value) {
+      return value ? new Date(value).toLocaleString() : '-'
+    },
     statusCount (status) {
       return (this.adminStatus.change_sets && this.adminStatus.change_sets[status]) || 0
     },
@@ -366,6 +573,13 @@ export default {
 .context-section { padding: 20px; }
 .mode-switch { display: flex; justify-content: space-between; gap: 16px; }
 .mode-select { max-width: 180px; }
+.prompt-examples { display: flex; flex-wrap: wrap; gap: 8px; }
+.prompt-chip {
+  border: 1px solid #cbd8e8; border-radius: 999px; padding: 5px 11px;
+  background: #f8fafc; color: #315b86; font-size: 12px; cursor: pointer;
+}
+.prompt-chip:hover { border-color: #1976d2; background: #edf5ff; }
+.prompt-chip:focus-visible { outline: 2px solid #1976d2; outline-offset: 2px; }
 .answer-block { padding: 20px; background: #f3f7fd; border-left: 3px solid #1976d2; border-radius: 8px; }
 .answer-copy { white-space: pre-wrap; line-height: 1.8; margin: 0; }
 .evidence-header { display: flex; justify-content: space-between; align-items: center; margin: 26px 0 10px; }
@@ -385,6 +599,10 @@ export default {
 .boundary-row, .metric-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 0; }
 .boundary-row { justify-content: flex-start; color: #475569; }
 .change-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid #edf0f4; }
+.min-width-0 { min-width: 0; }
+.audit-list { border-top: 1px solid #edf0f4; }
+.audit-row { padding: 10px 0; border-bottom: 1px solid #edf0f4; color: #536273; font-size: 12px; line-height: 1.6; }
+.audit-correlation { overflow-wrap: anywhere; color: #77869a; }
 .source-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
 .source-facts > div { display: flex; flex-direction: column; gap: 3px; }
 .source-facts span { color: #637083; font-size: 12px; }

+ 78 - 0
frontend/tests/device-knowledge-model.test.mjs

@@ -0,0 +1,78 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+  answerPresentation,
+  canManageKnowledge,
+  deviceObjectLabel,
+  parseBusinessDomainScope,
+  resultEvidence,
+  scopePresentation
+} from '../src/views/knowledgeBaseProduct/deviceKnowledgeModel.js'
+
+test('presents device knowledge objects and explicit answer states', () => {
+  assert.equal(deviceObjectLabel('DeviceAsset'), '设备资产')
+  assert.equal(deviceObjectLabel('DataFlow'), 'DataFlow')
+  assert.deepEqual(answerPresentation('model_unavailable'), {
+    color: 'warning',
+    title: '回答模型暂不可用',
+    detail: '未生成推测性答案;以下仅展示你有权访问的检索证据。'
+  })
+  assert.deepEqual(answerPresentation('no_answer'), {
+    color: 'info',
+    title: '当前证据不足',
+    detail: '知识库未生成推测性答案;可检查下方授权证据并调整问题。'
+  })
+})
+
+test('uses selected citations only for grounded answers and evidence otherwise', () => {
+  const citations = [{ chunk_id: 'citation-1', content: '责任人:张工' }]
+  const evidence = [{ chunk_id: 'evidence-1', content: '设备名称:循环泵' }]
+
+  assert.deepEqual(
+    resultEvidence({
+      answer_status: 'grounded',
+      citations,
+      evidence
+    }),
+    citations
+  )
+  assert.deepEqual(
+    resultEvidence({
+      answer_status: 'model_unavailable',
+      citations: [],
+      evidence
+    }),
+    evidence
+  )
+})
+
+test('normalizes source scope and makes empty scope admin-only', () => {
+  const domainA = '11111111-1111-4111-8111-111111111111'
+  const domainB = '22222222-2222-4222-8222-222222222222'
+
+  assert.deepEqual(
+    parseBusinessDomainScope(`${domainB}\n${domainA}\n${domainA}`),
+    [domainA, domainB]
+  )
+  assert.throws(
+    () => parseBusinessDomainScope('not-a-uuid'),
+    /UUID/
+  )
+  assert.deepEqual(scopePresentation([]), {
+    color: 'warning',
+    label: '仅管理员可见'
+  })
+  assert.deepEqual(scopePresentation([domainA]), {
+    color: 'success',
+    label: '1 个业务域'
+  })
+})
+
+test('shows scope and audit controls only with knowledge manage permission', () => {
+  assert.equal(canManageKnowledge(['governance:read']), false)
+  assert.equal(
+    canManageKnowledge(['governance:read', 'knowledge:manage']),
+    true
+  )
+})

+ 37 - 7
scripts/generate_openapi.py

@@ -3,14 +3,13 @@
 
 from __future__ import annotations
 
-import ast
 import argparse
+import ast
 import json
 import re
 from collections import defaultdict
 from pathlib import Path
 
-
 ROOT = Path(__file__).resolve().parents[1]
 OUTPUT = ROOT / "docs" / "architecture" / "OPENAPI.yaml"
 PREFIXES = {
@@ -24,6 +23,21 @@ PREFIXES = {
     "business_domain": "/api/bd",
     "data_factory": "/api/datafactory",
     "data_service": "/api/dataservice",
+    "knowledge_base": "/api/knowledge",
+}
+
+SHORTHAND_METHODS = {"get", "post", "put", "patch", "delete"}
+RESPONSE_FIELDS = {
+    ("knowledge_base", "ask"): [
+        "query_id",
+        "mode",
+        "answer",
+        "answer_status",
+        "degraded_components",
+        "citations",
+        "evidence",
+        "freshness_status",
+    ],
 }
 
 
@@ -60,17 +74,24 @@ def extract_routes() -> list[dict[str, object]]:
                 function = decorator.func
                 if not (
                     isinstance(function, ast.Attribute)
-                    and function.attr == "route"
                     and isinstance(function.value, ast.Name)
                     and function.value.id == "bp"
                     and decorator.args
                 ):
                     continue
+                if function.attr == "route":
+                    methods = ["GET"]
+                    for keyword in decorator.keywords:
+                        if keyword.arg == "methods":
+                            methods = ast.literal_eval(keyword.value)
+                elif (
+                    module == "knowledge_base"
+                    and function.attr in SHORTHAND_METHODS
+                ):
+                    methods = [function.attr.upper()]
+                else:
+                    continue
                 raw_path = ast.literal_eval(decorator.args[0])
-                methods = ["GET"]
-                for keyword in decorator.keywords:
-                    if keyword.arg == "methods":
-                        methods = ast.literal_eval(keyword.value)
                 path, parameters = route_path(prefix + raw_path)
                 summary = (ast.get_docstring(node) or node.name.replace("_", " ")).splitlines()[0]
                 for method in methods:
@@ -145,6 +166,15 @@ def render(routes: list[dict[str, object]]) -> str:
                         "              additionalProperties: true",
                     ]
                 )
+            response_fields = RESPONSE_FIELDS.get(
+                (str(operation["tag"]), str(operation["operation_id"]).split("_")[-2])
+            )
+            if response_fields:
+                lines.append(
+                    "      x-response-fields: ["
+                    + ", ".join(response_fields)
+                    + "]"
+                )
             lines.extend(
                 [
                     "      responses:",

+ 243 - 0
tests/integration/test_device_knowledge_postgres.py

@@ -0,0 +1,243 @@
+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_knowledge_prefilters_source_scope_before_matching(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.knowledge.retrieval.device import (
+        SqlDeviceKnowledgeRepository,
+    )
+    from app.models.data_research import (
+        DeviceAsset,
+        DeviceAssetSourceMapping,
+        DeviceOperationalEvent,
+        IngestionSource,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    suffix = uuid.uuid4().hex[:10]
+    actor_uid = str(uuid.uuid4())
+    domain_a = str(uuid.uuid4())
+    domain_b = str(uuid.uuid4())
+    source_a = str(uuid.uuid4())
+    source_b = str(uuid.uuid4())
+    source_unscoped = str(uuid.uuid4())
+    asset_a = str(uuid.uuid4())
+    asset_b = str(uuid.uuid4())
+    asset_unscoped = str(uuid.uuid4())
+    source_uids = (source_a, source_b, source_unscoped)
+    asset_uids = (asset_a, asset_b, asset_unscoped)
+    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"wp10-actor-{suffix}",
+                },
+            )
+            for uid, label, scope in (
+                (source_a, "A", {"business_domains": [domain_a]}),
+                (source_b, "B", {"business_domains": [domain_b]}),
+                (source_unscoped, "U", {}),
+            ):
+                db.session.add(
+                    IngestionSource(
+                        uid=uid,
+                        source_type="database",
+                        name=f"WP10 source {label} {suffix}",
+                        config={"password": f"secret-{label}"},
+                        permission_scope=scope,
+                        status="active",
+                        created_by=actor_uid,
+                    )
+                )
+            for uid, label, owner in (
+                (asset_a, "A", "张工"),
+                (asset_b, "B", "李工"),
+                (asset_unscoped, "U", "王工"),
+            ):
+                db.session.add(
+                    DeviceAsset(
+                        uid=uid,
+                        asset_type="device",
+                        name=f"WP10循环泵-{suffix}-{label}",
+                        status="active",
+                        current_version=1,
+                        content_hash=label.lower() * 64,
+                        location=f"{label}动力车间",
+                        organization="设备动力部",
+                        responsible_person=owner,
+                        attributes={"password": f"asset-secret-{label}"},
+                        created_by=actor_uid,
+                        updated_by=actor_uid,
+                        updated_at=now,
+                    )
+                )
+            db.session.flush()
+            for uid, asset_uid, source_uid, label in (
+                (str(uuid.uuid4()), asset_a, source_a, "A"),
+                (str(uuid.uuid4()), asset_b, source_b, "B"),
+                (
+                    str(uuid.uuid4()),
+                    asset_unscoped,
+                    source_unscoped,
+                    "U",
+                ),
+            ):
+                db.session.add(
+                    DeviceAssetSourceMapping(
+                        uid=uid,
+                        asset_uid=asset_uid,
+                        source_uid=source_uid,
+                        source_entity="asset.equipment",
+                        asset_type="device",
+                        source_code=f"EQ-WP10-{suffix}-{label}",
+                        source_updated_at=now,
+                        first_seen_at=now,
+                        last_seen_at=now,
+                    )
+                )
+                db.session.add(
+                    DeviceOperationalEvent(
+                        uid=str(uuid.uuid4()),
+                        source_uid=source_uid,
+                        source_entity="fault.events",
+                        source_code=f"FT-WP10-{suffix}-{label}",
+                        event_type="fault",
+                        asset_uid=asset_uid,
+                        title=f"{label}域轴承故障-{suffix}",
+                        severity="error",
+                        status="observed",
+                        occurred_at=now,
+                        evidence_refs={
+                            "password": f"event-secret-{label}"
+                        },
+                        content_hash=(label.lower() + "f") * 32,
+                        created_by=actor_uid,
+                    )
+                )
+            db.session.commit()
+
+            repository = SqlDeviceKnowledgeRepository(db.session)
+            admin_rows = repository.search(
+                query=f"WP10循环泵-{suffix}",
+                global_access=True,
+                business_domain_uids=(),
+                limit=20,
+            )
+            domain_a_rows = repository.search(
+                query=f"WP10循环泵-{suffix}",
+                global_access=False,
+                business_domain_uids=(domain_a,),
+                limit=20,
+            )
+            forbidden_source = repository.search(
+                query=f"EQ-WP10-{suffix}-B",
+                global_access=False,
+                business_domain_uids=(domain_a,),
+                limit=20,
+            )
+            unscoped_source = repository.search(
+                query=f"EQ-WP10-{suffix}-U",
+                global_access=False,
+                business_domain_uids=(domain_a,),
+                limit=20,
+            )
+            fault_rows = repository.search(
+                query=f"A域轴承故障-{suffix}",
+                global_access=False,
+                business_domain_uids=(domain_a,),
+                limit=20,
+            )
+            natural_question_rows = repository.search(
+                query=f"WP10循环泵-{suffix}-A最近有哪些故障?",
+                global_access=False,
+                business_domain_uids=(domain_a,),
+                limit=20,
+            )
+            detail = repository.get_detail(
+                asset_a,
+                global_access=False,
+                business_domain_uids=(domain_a,),
+            )
+            forbidden_detail = repository.get_detail(
+                asset_b,
+                global_access=False,
+                business_domain_uids=(domain_a,),
+            )
+
+            assert {row.asset_uid for row in admin_rows} == set(asset_uids)
+            assert [row.asset_uid for row in domain_a_rows] == [asset_a]
+            assert domain_a_rows[0].business_domain_uid == domain_a
+            assert forbidden_source == ()
+            assert unscoped_source == ()
+            assert [row.asset_uid for row in fault_rows] == [asset_a]
+            assert [row.asset_uid for row in natural_question_rows] == [
+                asset_a
+            ]
+            assert fault_rows[0].related_events == (
+                ("fault", f"A域轴承故障-{suffix}", f"FT-WP10-{suffix}-A"),
+            )
+            assert detail is not None
+            assert detail.source_codes == (f"EQ-WP10-{suffix}-A",)
+            assert forbidden_detail is None
+            serialized = repr((domain_a_rows, fault_rows, detail))
+            assert "secret-" not in serialized
+            assert "permission_scope" not in serialized
+    finally:
+        with app.app_context():
+            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 = ANY(CAST(:uids AS uuid[]))"
+                ),
+                {"uids": list(asset_uids)},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.ingestion_sources "
+                    "WHERE uid = ANY(CAST(:uids AS uuid[]))"
+                ),
+                {"uids": list(source_uids)},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.users "
+                    "WHERE id = CAST(:id AS uuid)"
+                ),
+                {"id": actor_uid},
+            )
+            db.session.commit()

+ 366 - 0
tests/knowledge/test_api.py

@@ -20,6 +20,18 @@ def _evidence():
     )
 
 
+class _AuditRepository:
+    def __init__(self, records=()):
+        self.records = list(records)
+        self.recorded = []
+
+    def record(self, record):
+        self.recorded.append(record)
+
+    def list(self, *, limit):
+        return tuple(self.records[:limit])
+
+
 def test_viewer_can_post_search_and_receives_stable_canonical_contract(monkeypatch):
     from app import create_app
     from app.core.knowledge.access import KnowledgeAccessContext
@@ -45,6 +57,8 @@ def test_viewer_can_post_search_and_receives_stable_canonical_contract(monkeypat
     )
     app = create_app()
     app.extensions["knowledge_retrieval_pipeline"] = Pipeline()
+    audit = _AuditRepository()
+    app.extensions["knowledge_query_audit_repository"] = audit
 
     response = app.test_client().post(
         "/api/knowledge/search", json={"query": "用途", "mode": "semantic"}
@@ -55,6 +69,7 @@ def test_viewer_can_post_search_and_receives_stable_canonical_contract(monkeypat
     assert payload["evidence"][0]["object_uid"] == "object-1"
     assert payload["evidence"][0]["point_revisions"] == [3]
     assert payload["evidence"][0]["index_generation"] == 1
+    assert len(audit.recorded) == 1
 
 
 def test_search_rejects_client_domain_filter_shape_before_retrieval(monkeypatch):
@@ -88,3 +103,354 @@ def test_search_rejects_non_numeric_limit(monkeypatch):
 
     assert response.status_code == 400
     assert response.get_json()["message"] == "limit 必须为整数"
+
+
+def test_device_search_and_grounded_answer_return_safe_evidence_and_citation_content(
+    monkeypatch,
+):
+    from app import create_app
+    from app.core.knowledge.access import KnowledgeAccessContext
+    from app.core.knowledge.qa import AnswerResult, Citation
+    from app.core.knowledge.retrieval.contracts import (
+        KnowledgeEvidence,
+        SearchResult,
+    )
+
+    evidence = KnowledgeEvidence(
+        chunk_id="device:asset-1:v2",
+        content="设备名称:循环泵\n源 ID:EQ-001\n责任人:张工",
+        score=0.95,
+        retriever="device",
+        object_uid="asset-1",
+        object_type="DeviceAsset",
+        object_version=2,
+        business_domain_uid="domain-a",
+        point_keys=("DeviceAsset/asset-1/summary",),
+        point_revisions=(2,),
+        generation=2,
+        source_updated_at="2026-07-29T08:00:00+00:00",
+    )
+
+    class Pipeline:
+        def search(self, *_args, **_kwargs):
+            return SearchResult((evidence,), "exact", ("vector",))
+
+    class Synthesizer:
+        def answer(self, _query, _evidence):
+            return AnswerResult(
+                "grounded",
+                "循环泵的责任人是张工。",
+                (
+                    Citation(
+                        object_uid="asset-1",
+                        object_type="DeviceAsset",
+                        object_version=2,
+                        point_key="DeviceAsset/asset-1/summary",
+                        point_revision=2,
+                        chunk_id="device:asset-1:v2",
+                        section_path=None,
+                        source_updated_at="2026-07-29T08:00:00+00:00",
+                        index_generation=2,
+                        retrievers=("device",),
+                        score=0.95,
+                        freshness_status="fresh",
+                    ),
+                ),
+                "fresh",
+            )
+
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {
+            "id": "11111111-1111-4111-8111-111111111111",
+            "roles": ["viewer"],
+        },
+    )
+    monkeypatch.setattr(
+        "app.api.knowledge_base.routes.build_access_context",
+        lambda *_args, **_kwargs: KnowledgeAccessContext(
+            subject_id="11111111-1111-4111-8111-111111111111",
+            roles=frozenset({"viewer"}),
+            permissions=frozenset({"governance:read"}),
+            business_domain_uids=frozenset({"domain-a"}),
+            correlation_id="22222222-2222-4222-8222-222222222222",
+        ),
+    )
+    app = create_app()
+    app.extensions["knowledge_retrieval_pipeline"] = Pipeline()
+    app.extensions["knowledge_answer_synthesizer"] = Synthesizer()
+    audit = _AuditRepository()
+    app.extensions["knowledge_query_audit_repository"] = audit
+
+    client = app.test_client()
+    search_response = client.post(
+        "/api/knowledge/search",
+        json={"query": "EQ-001", "mode": "exact"},
+    )
+    ask_response = client.post(
+        "/api/knowledge/ask",
+        json={"query": "循环泵的责任人是谁?"},
+    )
+
+    assert search_response.status_code == 200
+    assert (
+        search_response.get_json()["data"]["evidence"][0]["object_type"]
+        == "DeviceAsset"
+    )
+    assert ask_response.status_code == 200
+    answer = ask_response.get_json()["data"]
+    assert answer["evidence"][0]["content"].endswith("责任人:张工")
+    assert answer["citations"][0]["content"].endswith("责任人:张工")
+    assert answer["citations"][0]["object_uid"] == "asset-1"
+    assert len(audit.recorded) == 2
+    assert audit.recorded[1].cited_points == (
+        {
+            "point_key": "DeviceAsset/asset-1/summary",
+            "point_revision": 2,
+        },
+    )
+
+
+def test_model_unavailable_returns_no_answer_but_keeps_authorized_evidence(
+    monkeypatch,
+):
+    from app import create_app
+    from app.core.knowledge.access import KnowledgeAccessContext
+    from app.core.knowledge.qa import AnswerResult
+    from app.core.knowledge.retrieval.contracts import SearchResult
+
+    class Pipeline:
+        def search(self, *_args, **_kwargs):
+            return SearchResult((_evidence(),), "semantic")
+
+    class Synthesizer:
+        def answer(self, _query, _evidence):
+            return AnswerResult(
+                "model_unavailable",
+                None,
+                (),
+                "degraded",
+            )
+
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {
+            "id": "11111111-1111-4111-8111-111111111111",
+            "roles": ["viewer"],
+        },
+    )
+    monkeypatch.setattr(
+        "app.api.knowledge_base.routes.build_access_context",
+        lambda *_args, **_kwargs: KnowledgeAccessContext(
+            subject_id="11111111-1111-4111-8111-111111111111",
+            roles=frozenset({"viewer"}),
+            permissions=frozenset({"governance:read"}),
+            business_domain_uids=frozenset({"domain-a"}),
+            correlation_id="22222222-2222-4222-8222-222222222222",
+        ),
+    )
+    app = create_app()
+    app.extensions["knowledge_retrieval_pipeline"] = Pipeline()
+    app.extensions["knowledge_answer_synthesizer"] = Synthesizer()
+    app.extensions["knowledge_query_audit_repository"] = _AuditRepository()
+
+    response = app.test_client().post(
+        "/api/knowledge/ask",
+        json={"query": "用途是什么?"},
+    )
+
+    assert response.status_code == 200
+    data = response.get_json()["data"]
+    assert data["answer"] is None
+    assert data["answer_status"] == "model_unavailable"
+    assert data["citations"] == []
+    assert data["evidence"][0]["content"] == "客户同步用途"
+
+
+def test_device_source_detail_is_authorized_and_contains_no_private_payload(
+    monkeypatch,
+):
+    from datetime import UTC, datetime
+
+    from app import create_app
+    from app.core.knowledge.access import KnowledgeAccessContext
+    from app.core.knowledge.retrieval.device import DeviceSearchRow
+
+    class Repository:
+        def get_detail(self, asset_uid, **_kwargs):
+            if asset_uid != "11111111-1111-4111-8111-111111111111":
+                return None
+            return DeviceSearchRow(
+                asset_uid=asset_uid,
+                asset_type="device",
+                name="循环泵",
+                current_version=2,
+                location="动力车间",
+                organization="设备动力部",
+                responsible_person="张工",
+                source_codes=("EQ-001",),
+                related_events=(("fault", "轴承故障", "FT-001"),),
+                business_domain_uid="domain-a",
+                updated_at=datetime(2026, 7, 29, tzinfo=UTC),
+                rank=1.0,
+            )
+
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {
+            "id": "11111111-1111-4111-8111-111111111111",
+            "roles": ["viewer"],
+        },
+    )
+    monkeypatch.setattr(
+        "app.api.knowledge_base.routes._source_document",
+        lambda *_args, **_kwargs: None,
+    )
+    monkeypatch.setattr(
+        "app.api.knowledge_base.routes._request_context",
+        lambda _payload: (
+            KnowledgeAccessContext(
+                subject_id="11111111-1111-4111-8111-111111111111",
+                roles=frozenset({"viewer"}),
+                permissions=frozenset({"governance:read"}),
+                business_domain_uids=frozenset({"domain-a"}),
+                correlation_id="22222222-2222-4222-8222-222222222222",
+            ),
+            "22222222-2222-4222-8222-222222222222",
+        ),
+    )
+    app = create_app()
+    app.extensions["device_knowledge_repository"] = Repository()
+
+    response = app.test_client().get(
+        "/api/knowledge/sources/11111111-1111-4111-8111-111111111111"
+    )
+
+    assert response.status_code == 200
+    data = response.get_json()["data"]
+    assert data["object_type"] == "DeviceAsset"
+    assert data["source_codes"] == ["EQ-001"]
+    assert data["related_events"][0]["title"] == "轴承故障"
+    assert "config" not in repr(data)
+    assert "permission_scope" not in repr(data)
+
+
+def test_admin_can_manage_device_source_scope_and_list_minimized_query_audits(
+    monkeypatch,
+):
+    from app import create_app
+    from app.core.data_research.sources import IngestionSourceRecord
+
+    domain_uid = "33333333-3333-4333-8333-333333333333"
+    source_uid = "44444444-4444-4444-8444-444444444444"
+
+    class ScopeService:
+        def list(self):
+            return (
+                {
+                    "uid": source_uid,
+                    "name": "设备源",
+                    "business_domains": (),
+                    "admin_only": True,
+                },
+            )
+
+        def update(self, uid, payload, *, actor_is_admin):
+            assert uid == source_uid
+            assert payload == {"business_domains": [domain_uid]}
+            assert actor_is_admin is True
+            return IngestionSourceRecord(
+                uid=uid,
+                source_type="database",
+                name="设备源",
+                config={"password": "private"},
+                permission_scope={"business_domains": [domain_uid]},
+                status="active",
+                created_by="admin-1",
+            )
+
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {
+            "id": "11111111-1111-4111-8111-111111111111",
+            "roles": ["admin"],
+        },
+    )
+    audit_row = {
+        "id": "audit-1",
+        "query_hash": "a" * 64,
+        "mode": "exact",
+        "retriever_counts": {"device": 1},
+        "cited_points": [],
+        "degraded_components": ["vector"],
+        "correlation_id": "correlation-1",
+        "created_at": "2026-07-29T08:00:00+00:00",
+    }
+    app = create_app()
+    app.extensions["device_source_scope_service"] = ScopeService()
+    app.extensions["knowledge_query_audit_repository"] = _AuditRepository(
+        (audit_row,)
+    )
+    client = app.test_client()
+
+    listed = client.get("/api/knowledge/admin/device-sources")
+    updated = client.put(
+        f"/api/knowledge/admin/device-sources/{source_uid}/scope",
+        json={"business_domains": [domain_uid]},
+    )
+    audits = client.get("/api/knowledge/admin/query-audits")
+
+    assert listed.status_code == 200
+    assert listed.get_json()["data"][0]["admin_only"] is True
+    assert updated.status_code == 200
+    updated_data = updated.get_json()["data"]
+    assert updated_data["business_domains"] == [domain_uid]
+    assert "config" not in repr(updated_data)
+    assert audits.status_code == 200
+    assert audits.get_json()["data"][0]["query_hash"] == "a" * 64
+    assert "query" not in audits.get_json()["data"][0]
+
+
+def test_executed_search_fails_when_mandatory_audit_persistence_fails(
+    monkeypatch,
+):
+    from app import create_app
+    from app.core.knowledge.access import KnowledgeAccessContext
+    from app.core.knowledge.retrieval.contracts import SearchResult
+
+    class Pipeline:
+        def search(self, *_args, **_kwargs):
+            return SearchResult((_evidence(),), "semantic")
+
+    class Audit:
+        def record(self, _record):
+            raise RuntimeError("audit unavailable")
+
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {
+            "id": "11111111-1111-4111-8111-111111111111",
+            "roles": ["viewer"],
+        },
+    )
+    monkeypatch.setattr(
+        "app.api.knowledge_base.routes.build_access_context",
+        lambda *_args, **_kwargs: KnowledgeAccessContext(
+            subject_id="11111111-1111-4111-8111-111111111111",
+            roles=frozenset({"viewer"}),
+            permissions=frozenset({"governance:read"}),
+            business_domain_uids=frozenset({"domain-a"}),
+            correlation_id="22222222-2222-4222-8222-222222222222",
+        ),
+    )
+    app = create_app()
+    app.extensions["knowledge_retrieval_pipeline"] = Pipeline()
+    app.extensions["knowledge_query_audit_repository"] = Audit()
+
+    response = app.test_client().post(
+        "/api/knowledge/search",
+        json={"query": "用途"},
+    )
+
+    assert response.status_code == 503
+    assert response.get_json()["message"] == "知识查询审计写入失败"

+ 35 - 0
tests/knowledge/test_device_knowledge_frontend_contract.py

@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+VIEW = ROOT / "frontend/src/views/knowledgeBaseProduct/index.vue"
+API = ROOT / "frontend/src/api/governanceKnowledge.js"
+
+
+def test_device_knowledge_workbench_exposes_search_refusal_scope_and_audit():
+    view = VIEW.read_text(encoding="utf-8")
+    api = API.read_text(encoding="utf-8")
+
+    for prompt in ("设备名称", "平台 UID", "源 ID", "位置", "责任人", "故障"):
+        assert prompt in view
+    for explicit_state in (
+        "回答模型暂不可用",
+        "当前证据不足",
+        "未生成推测性答案",
+    ):
+        assert explicit_state in view
+    assert "数据源授权范围" in view
+    assert "空范围仅管理员可见" in view
+    assert "查询审计" in view
+    assert "原始问题不会写入审计" in view
+    assert '@change="resetResult"' in view
+    assert "resetResult ()" in view
+    assert "$snackbar" in view
+    assert "$message" not in view
+
+    for endpoint in (
+        "/knowledge/admin/device-sources",
+        "/knowledge/admin/query-audits",
+    ):
+        assert endpoint in api

+ 154 - 0
tests/knowledge/test_device_retrieval.py

@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import pytest
+
+
+def _row(**overrides):
+    from app.core.knowledge.retrieval.device import DeviceSearchRow
+
+    values = {
+        "asset_uid": "11111111-1111-4111-8111-111111111111",
+        "asset_type": "device",
+        "name": "一号循环泵",
+        "current_version": 3,
+        "location": "动力车间",
+        "organization": "设备动力部",
+        "responsible_person": "张工",
+        "source_codes": ("EQ-001", "ERP-PUMP-01"),
+        "related_events": (
+            ("fault", "轴承润滑故障", "FT-001"),
+            ("alarm", "轴承温度持续升高", "AL-001"),
+        ),
+        "business_domain_uid": "22222222-2222-4222-8222-222222222222",
+        "updated_at": datetime(2026, 7, 29, 8, 0, tzinfo=UTC),
+        "rank": 0.95,
+    }
+    values.update(overrides)
+    return DeviceSearchRow(**values)
+
+
+def test_device_query_is_trimmed_bounded_and_required():
+    from app.core.knowledge.retrieval.device import normalize_device_query
+
+    assert normalize_device_query("  EQ-001  ") == "EQ-001"
+    with pytest.raises(ValueError, match="不能为空"):
+        normalize_device_query("   ")
+    with pytest.raises(ValueError, match="300"):
+        normalize_device_query("x" * 301)
+
+
+@pytest.mark.parametrize(
+    ("query", "expected_term"),
+    [
+        ("循环泵最近有哪些故障?", "循环泵"),
+        ("动力车间有哪些设备?", "动力车间"),
+        ("张工负责哪些设备?", "张工"),
+        ("一号循环泵的责任人是谁?", "一号循环泵"),
+        ("源 ID 为 EQ-001 的设备责任人是谁?", "EQ-001"),
+        (
+            "平台 UID:11111111-1111-4111-8111-111111111111",
+            "11111111-1111-4111-8111-111111111111",
+        ),
+    ],
+)
+def test_device_query_terms_extract_searchable_entity_from_common_questions(
+    query,
+    expected_term,
+):
+    from app.core.knowledge.retrieval.device import device_query_terms
+
+    terms = device_query_terms(query)
+
+    assert terms[0] == expected_term
+    assert normalize_device_query_for_assertion(query) in terms
+
+
+def normalize_device_query_for_assertion(query):
+    from app.core.knowledge.retrieval.device import normalize_device_query
+
+    return normalize_device_query(query)
+
+
+def test_device_evidence_is_stable_readable_and_does_not_expose_raw_payloads():
+    from app.core.knowledge.retrieval.device import build_device_evidence
+
+    evidence = build_device_evidence(_row())
+
+    assert evidence.chunk_id == (
+        "device:11111111-1111-4111-8111-111111111111:v3"
+    )
+    assert evidence.object_type == "DeviceAsset"
+    assert evidence.object_version == 3
+    assert evidence.point_keys == (
+        "DeviceAsset/11111111-1111-4111-8111-111111111111/summary",
+    )
+    assert evidence.point_revisions == (3,)
+    assert evidence.score == 0.95
+    assert "设备名称:一号循环泵" in evidence.content
+    assert "源 ID:EQ-001、ERP-PUMP-01" in evidence.content
+    assert "位置:动力车间" in evidence.content
+    assert "责任人:张工" in evidence.content
+    assert "故障:轴承润滑故障(FT-001)" in evidence.content
+    assert "告警:轴承温度持续升高(AL-001)" in evidence.content
+    for forbidden in ("password", "permission_scope", "source_config"):
+        assert forbidden not in evidence.content
+
+
+def test_device_evidence_is_deterministic_for_unsorted_duplicate_inputs():
+    from app.core.knowledge.retrieval.device import build_device_evidence
+
+    first = build_device_evidence(
+        _row(
+            source_codes=("ERP-PUMP-01", "EQ-001", "EQ-001"),
+            related_events=(
+                ("alarm", "轴承温度持续升高", "AL-001"),
+                ("fault", "轴承润滑故障", "FT-001"),
+                ("fault", "轴承润滑故障", "FT-001"),
+            ),
+        )
+    )
+    second = build_device_evidence(_row())
+
+    assert first.content == second.content
+    assert first.point_keys == second.point_keys
+
+
+def test_device_result_limit_is_bounded_before_repository_query():
+    from app.core.knowledge.access import KnowledgeAccessContext
+    from app.core.knowledge.retrieval.device import SqlDeviceKnowledgeRetriever
+
+    class Repository:
+        def __init__(self):
+            self.calls = []
+
+        def search(self, **kwargs):
+            self.calls.append(kwargs)
+            return [_row()]
+
+    repository = Repository()
+    retriever = SqlDeviceKnowledgeRetriever(repository)
+    context = KnowledgeAccessContext(
+        subject_id="viewer-1",
+        roles=frozenset({"viewer"}),
+        permissions=frozenset({"governance:read"}),
+        business_domain_uids=frozenset(
+            {"22222222-2222-4222-8222-222222222222"}
+        ),
+        correlation_id="correlation-1",
+    )
+
+    result = retriever.retrieve("循环泵", context, 999)
+
+    assert len(result) == 1
+    assert repository.calls == [
+        {
+            "query": "循环泵",
+            "global_access": False,
+            "business_domain_uids": (
+                "22222222-2222-4222-8222-222222222222",
+            ),
+            "limit": 100,
+        }
+    ]

+ 154 - 0
tests/knowledge/test_device_scope.py

@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from uuid import uuid4
+
+import pytest
+
+
+def _source(**overrides):
+    from app.core.data_research.sources import IngestionSourceRecord
+
+    values = {
+        "uid": str(uuid4()),
+        "source_type": "database",
+        "name": "设备源",
+        "config": {"database_type": "postgresql"},
+        "permission_scope": {"departments": ["equipment"]},
+        "status": "active",
+        "created_by": "admin-1",
+        "created_at": datetime(2026, 7, 29, tzinfo=UTC),
+        "updated_at": datetime(2026, 7, 29, tzinfo=UTC),
+    }
+    values.update(overrides)
+    return IngestionSourceRecord(**values)
+
+
+class Repository:
+    def __init__(self, records=()):
+        self.records = {record.uid: record for record in records}
+        self.saved = []
+
+    def get(self, uid):
+        return self.records.get(uid)
+
+    def save(self, record):
+        self.records[record.uid] = record
+        self.saved.append(record)
+        return record
+
+    def list_device_sources(self):
+        return tuple(self.records.values())
+
+
+def test_device_source_scope_is_sorted_unique_and_preserves_other_keys():
+    from app.core.knowledge.device_scope import DeviceSourceScopeService
+
+    source = _source()
+    repository = Repository((source,))
+    service = DeviceSourceScopeService(
+        repository,
+        clock=lambda: datetime(2026, 7, 30, tzinfo=UTC),
+    )
+    domain_a = str(uuid4())
+    domain_b = str(uuid4())
+
+    updated = service.update(
+        source.uid,
+        {"business_domains": [domain_b, domain_a, domain_b]},
+        actor_is_admin=True,
+    )
+
+    assert updated.permission_scope == {
+        "departments": ["equipment"],
+        "business_domains": sorted([domain_a, domain_b]),
+    }
+    assert repository.saved == [updated]
+    assert updated.updated_at == datetime(2026, 7, 30, tzinfo=UTC)
+
+
+def test_device_source_scope_rejects_invalid_uuid_and_more_than_100_domains():
+    from app.core.knowledge.device_scope import (
+        DeviceSourceScopeInvalid,
+        DeviceSourceScopeService,
+    )
+
+    source = _source()
+    service = DeviceSourceScopeService(Repository((source,)))
+
+    with pytest.raises(DeviceSourceScopeInvalid, match="UUID"):
+        service.update(
+            source.uid,
+            {"business_domains": ["not-a-uuid"]},
+            actor_is_admin=True,
+        )
+    with pytest.raises(DeviceSourceScopeInvalid, match="100"):
+        service.update(
+            source.uid,
+            {"business_domains": [str(uuid4()) for _ in range(101)]},
+            actor_is_admin=True,
+        )
+
+
+def test_device_source_scope_missing_source_and_payload_are_rejected():
+    from app.core.knowledge.device_scope import (
+        DeviceSourceScopeInvalid,
+        DeviceSourceScopeNotFound,
+        DeviceSourceScopeService,
+    )
+
+    service = DeviceSourceScopeService(Repository())
+
+    with pytest.raises(DeviceSourceScopeNotFound):
+        service.update(
+            str(uuid4()),
+            {"business_domains": []},
+            actor_is_admin=True,
+        )
+    with pytest.raises(DeviceSourceScopeInvalid, match="business_domains"):
+        service.update(
+            str(uuid4()),
+            {},
+            actor_is_admin=True,
+        )
+
+
+def test_empty_scope_is_explicitly_admin_only_and_updates_require_admin():
+    from app.core.knowledge.device_scope import (
+        DeviceSourceScopeForbidden,
+        DeviceSourceScopeService,
+        source_scope_access,
+    )
+
+    source = _source(permission_scope={"business_domains": []})
+    service = DeviceSourceScopeService(Repository((source,)))
+
+    assert source_scope_access(source.permission_scope) == {
+        "business_domains": (),
+        "admin_only": True,
+    }
+    with pytest.raises(DeviceSourceScopeForbidden):
+        service.update(
+            source.uid,
+            {"business_domains": [str(uuid4())]},
+            actor_is_admin=False,
+        )
+
+
+def test_list_exposes_scope_metadata_but_never_source_configuration():
+    from app.core.knowledge.device_scope import DeviceSourceScopeService
+
+    source = _source(
+        config={"password": "must-not-leak"},
+        permission_scope={"business_domains": [str(uuid4())]},
+    )
+    service = DeviceSourceScopeService(Repository((source,)))
+
+    records = service.list()
+
+    assert records[0]["uid"] == source.uid
+    assert records[0]["business_domains"] == tuple(
+        source.permission_scope["business_domains"]
+    )
+    assert "config" not in records[0]
+    assert "permission_scope" not in records[0]

+ 116 - 0
tests/knowledge/test_query_audit.py

@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import hashlib
+
+
+def _evidence():
+    from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
+
+    return KnowledgeEvidence(
+        chunk_id="device:asset-1:v2",
+        content="设备名称:循环泵\n责任人:张工",
+        score=0.91,
+        retriever="device+lexical",
+        object_uid="asset-1",
+        object_type="DeviceAsset",
+        object_version=2,
+        business_domain_uid="domain-a",
+        point_keys=("DeviceAsset/asset-1/summary",),
+        point_revisions=(2,),
+        generation=2,
+        source_updated_at="2026-07-29T08:00:00+00:00",
+    )
+
+
+def test_query_audit_normalizes_hash_and_persists_only_minimized_metadata():
+    from app.core.knowledge.access import KnowledgeAccessContext
+    from app.core.knowledge.query_audit import build_query_audit
+
+    context = KnowledgeAccessContext(
+        subject_id="11111111-1111-4111-8111-111111111111",
+        roles=frozenset({"viewer"}),
+        permissions=frozenset({"governance:read"}),
+        business_domain_uids=frozenset({"domain-b", "domain-a"}),
+        correlation_id="22222222-2222-4222-8222-222222222222",
+    )
+    record = build_query_audit(
+        query="  循环泵   的责任人  ",
+        context=context,
+        mode="semantic",
+        evidence=(_evidence(),),
+        cited_points=(("DeviceAsset/asset-1/summary", 2),),
+        degraded_components=("vector",),
+        latency_ms=17,
+    )
+
+    assert record.query_hash == hashlib.sha256(
+        "循环泵 的责任人".encode()
+    ).hexdigest()
+    assert record.roles == ("viewer",)
+    assert record.business_domain_uids == ("domain-a", "domain-b")
+    assert record.retriever_counts == {"device": 1, "lexical": 1}
+    assert record.cited_points == (
+        {
+            "point_key": "DeviceAsset/asset-1/summary",
+            "point_revision": 2,
+        },
+    )
+    assert record.degraded_components == ("vector",)
+    assert record.latency_ms == 17
+    serialized = repr(record)
+    for forbidden in (
+        "循环泵",
+        "张工",
+        "设备名称",
+        "password",
+        "source_config",
+    ):
+        assert forbidden not in serialized
+
+
+def test_query_audit_repository_records_and_lists_safe_fields_only():
+    from app.core.knowledge.query_audit import (
+        KnowledgeQueryAuditRecord,
+        SqlKnowledgeQueryAuditRepository,
+    )
+
+    class Session:
+        def __init__(self):
+            self.statements = []
+            self.committed = False
+            self.rolled_back = False
+
+        def execute(self, statement, parameters=None):
+            self.statements.append((str(statement), parameters))
+
+        def commit(self):
+            self.committed = True
+
+        def rollback(self):
+            self.rolled_back = True
+
+    session = Session()
+    repository = SqlKnowledgeQueryAuditRepository(session)
+    record = KnowledgeQueryAuditRecord(
+        uid="33333333-3333-4333-8333-333333333333",
+        query_hash="a" * 64,
+        user_id="11111111-1111-4111-8111-111111111111",
+        roles=("viewer",),
+        business_domain_uids=("domain-a",),
+        mode="semantic",
+        retriever_counts={"device": 1},
+        cited_points=(),
+        degraded_components=("vector",),
+        correlation_id="22222222-2222-4222-8222-222222222222",
+        latency_ms=9,
+    )
+
+    repository.record(record)
+
+    statement, parameters = session.statements[0]
+    assert "knowledge_query_audits" in statement
+    assert parameters["query_hash"] == "a" * 64
+    assert "query" not in parameters
+    assert "answer" not in parameters
+    assert "content" not in parameters
+    assert session.committed is True

+ 33 - 0
tests/knowledge/test_retrieval.py

@@ -69,3 +69,36 @@ def test_pipeline_reauthorizes_all_candidates_and_degrades_failed_retriever():
 
     assert [item.chunk_id for item in result.evidence] == ["chunk-allowed"]
     assert result.degraded_components == ("vector",)
+
+
+def test_pipeline_fuses_device_evidence_and_reauthorizes_it_after_retrieval():
+    from app.core.knowledge.access import KnowledgeAccessContext
+    from app.core.knowledge.retrieval.pipeline import KnowledgeRetrievalPipeline
+
+    class Retriever:
+        def __init__(self, values=()):
+            self.values = values
+
+        def retrieve(self, _query, _context, _limit):
+            return self.values
+
+    context = KnowledgeAccessContext(
+        subject_id="user-1",
+        roles=frozenset({"viewer"}),
+        permissions=frozenset({"governance:read"}),
+        business_domain_uids=frozenset({"domain-a"}),
+        correlation_id="correlation-1",
+    )
+    allowed = _evidence("device-a", 0.95, "domain-a")
+    denied = _evidence("device-b", 0.99, "domain-b")
+    pipeline = KnowledgeRetrievalPipeline(
+        lexical=Retriever(),
+        vector=Retriever(),
+        device=Retriever((denied, allowed)),
+    )
+
+    result = pipeline.search("循环泵", context=context, mode="exact")
+
+    assert [item.chunk_id for item in result.evidence] == [
+        "chunk-device-a"
+    ]

+ 34 - 1
tests/test_architecture_artifacts.py

@@ -20,9 +20,16 @@ def _route_count() -> int:
                 function = decorator.func
                 if (
                     isinstance(function, ast.Attribute)
-                    and function.attr == "route"
                     and isinstance(function.value, ast.Name)
                     and function.value.id == "bp"
+                    and (
+                        function.attr == "route"
+                        or (
+                            route_file.parent.name == "knowledge_base"
+                            and function.attr
+                            in {"get", "post", "put", "patch", "delete"}
+                        )
+                    )
                 ):
                     count += 1
     return count
@@ -91,6 +98,32 @@ def test_wp09_contract_and_migration_are_documented():
     assert "证据不足,无法确认根因" in data_model
 
 
+def test_wp10_device_knowledge_contract_and_boundaries_are_documented():
+    contract = (ARCH / "OPENAPI.yaml").read_text(encoding="utf-8")
+    data_model = (ARCH / "DATA_MODEL.md").read_text(encoding="utf-8")
+    golden_set = (
+        ROOT
+        / "docs/acceptance/WP10_DEVICE_KNOWLEDGE_GOLDEN_SET.json"
+    )
+
+    for path in (
+        "/api/knowledge/search",
+        "/api/knowledge/ask",
+        "/api/knowledge/sources/{source_uid}",
+        "/api/knowledge/admin/device-sources",
+        "/api/knowledge/admin/device-sources/{source_uid}/scope",
+        "/api/knowledge/admin/query-audits",
+    ):
+        assert path in contract
+    assert "x-response-fields: [" in contract
+    assert "citations" in contract
+    assert "evidence" in contract
+    assert "knowledge_query_audits" in data_model
+    assert "authorized_sources" in data_model
+    assert "LightRAG 仍是影子投影" in data_model
+    assert golden_set.exists()
+
+
 def test_contract_ci_regenerates_and_checks_the_committed_inventory():
     workflow = (ROOT / ".github" / "workflows" / "contracts.yml").read_text(
         encoding="utf-8"

+ 9 - 0
tests/test_permission_matrix.py

@@ -201,6 +201,15 @@ def test_knowledge_admin_routes_require_dedicated_manage_permission():
     assert permission_for_request(
         "/api/knowledge/admin/change-sets/id/retry", "POST"
     ) == (KNOWLEDGE_MANAGE,)
+    assert permission_for_request(
+        "/api/knowledge/admin/device-sources", "GET"
+    ) == (KNOWLEDGE_MANAGE,)
+    assert permission_for_request(
+        "/api/knowledge/admin/device-sources/id/scope", "PUT"
+    ) == (KNOWLEDGE_MANAGE,)
+    assert permission_for_request(
+        "/api/knowledge/admin/query-audits", "GET"
+    ) == (KNOWLEDGE_MANAGE,)
 
 
 def test_data_rule_routes_separate_read_authoring_and_release_permissions():