| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- from __future__ import annotations
- def _evidence():
- from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
- return KnowledgeEvidence(
- chunk_id="chunk-1",
- content="客户同步用途",
- score=0.9,
- retriever="lexical+vector",
- object_uid="object-1",
- object_type="DataFlow",
- object_version=2,
- business_domain_uid="domain-a",
- point_keys=("DataFlow/object-1/purpose",),
- point_revisions=(3,),
- generation=1,
- source_updated_at="2026-07-23T00:00:00+00:00",
- )
- 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
- from app.core.knowledge.retrieval.contracts import SearchResult
- class Pipeline:
- def search(self, *_args, **_kwargs):
- return SearchResult((_evidence(),), "semantic")
- monkeypatch.setattr(
- "app.core.system.permissions.authenticate_request",
- lambda: {"id": "user-1", "roles": ["viewer"]},
- )
- monkeypatch.setattr(
- "app.api.knowledge_base.routes.build_access_context",
- lambda *_args, **_kwargs: KnowledgeAccessContext(
- subject_id="user-1",
- roles=frozenset({"viewer"}),
- permissions=frozenset({"governance:read"}),
- business_domain_uids=frozenset({"domain-a"}),
- correlation_id="correlation-1",
- ),
- )
- 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"}
- )
- assert response.status_code == 200
- payload = response.get_json()["data"]
- 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):
- from app import create_app
- monkeypatch.setattr(
- "app.core.system.permissions.authenticate_request",
- lambda: {"id": "user-1", "roles": ["viewer"]},
- )
- app = create_app()
- response = app.test_client().post(
- "/api/knowledge/search",
- json={"query": "用途", "business_domain_uids": "domain-a"},
- )
- assert response.status_code == 400
- def test_search_rejects_non_numeric_limit(monkeypatch):
- from app import create_app
- monkeypatch.setattr(
- "app.core.system.permissions.authenticate_request",
- lambda: {"id": "user-1", "roles": ["viewer"]},
- )
- app = create_app()
- response = app.test_client().post(
- "/api/knowledge/search",
- json={"query": "用途", "limit": "many"},
- )
- 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"] == "知识查询审计写入失败"
|