"""管理员知识发布编排:客服运行期只读,本模块仅供显式发布工具调用。""" from collections import defaultdict from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Protocol from app.core.knowledge_contracts import ALLOWED_KNOWLEDGE_COLLECTIONS VECTOR_DIMENSION = 1024 class KnowledgePublicationError(RuntimeError): """发布前置条件、阶段写入或补偿失败时的明确错误。""" @dataclass(frozen=True) class KnowledgePublicationRecord: """预检清单中一条已经审核、可供管理员发布的公开知识。""" qa_id: str milvus_collection: str retrieval_text: str title: str snippet: str tags: tuple[str, ...] version: str metadata: Mapping[str, object] @dataclass(frozen=True) class KnowledgePublicationResult: """仅返回可审计的业务编号和数据库主键映射,不返回正文或密钥。""" knowledge_ids: dict[str, int] collections: tuple[str, ...] class KnowledgeEmbedder(Protocol): async def embed(self, text: str) -> list[float]: ... class KnowledgePublicationStore(Protocol): async def stage(self, records: tuple[KnowledgePublicationRecord, ...]) -> dict[str, int]: ... async def publish(self, knowledge_ids: tuple[int, ...], reviewer_id: int) -> None: ... async def disable(self, knowledge_ids: tuple[int, ...]) -> None: ... class KnowledgeVectorPublisher(Protocol): async def upsert(self, collection: str, records: tuple[dict[str, object], ...]) -> None: ... async def delete(self, collection: str, knowledge_ids: tuple[str, ...]) -> None: ... class KnowledgePublicationService: """把发布动作拆为可补偿阶段,任何中断都不能让未索引知识对客可见。""" def __init__( self, embedder: KnowledgeEmbedder, store: KnowledgePublicationStore, vectors: KnowledgeVectorPublisher, ) -> None: self._embedder = embedder self._store = store self._vectors = vectors async def publish( self, records: Sequence[KnowledgePublicationRecord], *, reviewer_id: int ) -> KnowledgePublicationResult: immutable_records = tuple(records) self._validate(immutable_records, reviewer_id) embeddings = await self._embeddings(immutable_records) knowledge_ids = await self._store.stage(immutable_records) self._validate_staged_ids(immutable_records, knowledge_ids) payloads = self._payloads(immutable_records, embeddings, knowledge_ids) try: for collection, collection_payloads in payloads.items(): await self._vectors.upsert(collection, tuple(collection_payloads)) except Exception as exc: await self._compensate(payloads, tuple(knowledge_ids.values())) raise KnowledgePublicationError("向量写入失败,知识保持未发布状态") from exc await self._store.publish(tuple(knowledge_ids.values()), reviewer_id) return KnowledgePublicationResult( knowledge_ids=knowledge_ids, collections=tuple(payloads), ) @staticmethod def _validate(records: tuple[KnowledgePublicationRecord, ...], reviewer_id: int) -> None: if reviewer_id <= 0: raise KnowledgePublicationError("reviewer_id 必须是正整数") if not records: raise KnowledgePublicationError("没有可发布的公开知识") qa_ids = [record.qa_id for record in records] if len(qa_ids) != len(set(qa_ids)): raise KnowledgePublicationError("发布清单存在重复 qa_id") for record in records: if record.milvus_collection not in ALLOWED_KNOWLEDGE_COLLECTIONS: raise KnowledgePublicationError("发布清单包含未授权集合") if not record.retrieval_text.strip(): raise KnowledgePublicationError(f"{record.qa_id}: 检索文本不能为空") async def _embeddings( self, records: tuple[KnowledgePublicationRecord, ...] ) -> dict[str, list[float]]: embeddings: dict[str, list[float]] = {} for record in records: vector = await self._embedder.embed(record.retrieval_text) if len(vector) != VECTOR_DIMENSION: raise KnowledgePublicationError( f"{record.qa_id}: 向量维度必须为 {VECTOR_DIMENSION}" ) embeddings[record.qa_id] = vector return embeddings @staticmethod def _validate_staged_ids( records: tuple[KnowledgePublicationRecord, ...], knowledge_ids: dict[str, int] ) -> None: expected = {record.qa_id for record in records} if set(knowledge_ids) != expected or any(value <= 0 for value in knowledge_ids.values()): raise KnowledgePublicationError("MySQL 暂存结果与发布清单不一致") @staticmethod def _payloads( records: tuple[KnowledgePublicationRecord, ...], embeddings: dict[str, list[float]], knowledge_ids: dict[str, int], ) -> dict[str, list[dict[str, object]]]: payloads: dict[str, list[dict[str, object]]] = defaultdict(list) for record in records: payloads[record.milvus_collection].append({ "knowledge_id": str(knowledge_ids[record.qa_id]), "embedding": embeddings[record.qa_id], "title": record.title, "snippet": record.snippet, "tags": list(record.tags), "version": record.version, }) return dict(payloads) async def _compensate( self, payloads: dict[str, list[dict[str, object]]], knowledge_ids: tuple[int, ...] ) -> None: for collection, items in payloads.items(): try: await self._vectors.delete( collection, tuple(str(item["knowledge_id"]) for item in items) ) except Exception: # MySQL 行仍会被停用,因此清理失败的残余向量无法对客返回。 pass await self._store.disable(knowledge_ids)