from typing import Any from app.core.errors import ForbiddenAgentError, RecoverableAgentError from app.core.knowledge_contracts import ALLOWED_KNOWLEDGE_COLLECTIONS class MilvusKnowledgeClient: def __init__(self, uri: str, token: str | None = None) -> None: self._uri = uri self._token = token self._client: Any | None = None async def _ensure_client(self) -> Any: if self._client is None: from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] self._client = AsyncMilvusClient(uri=self._uri, token=self._token) return self._client async def search( self, collection: str, vector: list[float], top_k: int ) -> list[dict[str, Any]]: if collection not in ALLOWED_KNOWLEDGE_COLLECTIONS: raise ForbiddenAgentError("未授权的知识集合") if len(vector) != 1024 or not 1 <= top_k <= 20: raise RecoverableAgentError("知识检索参数无效") try: client = await self._ensure_client() batches = await client.search( collection_name=collection, data=[vector], limit=top_k, output_fields=["knowledge_id", "title", "snippet", "tags", "version"], search_params={"metric_type": "COSINE"}, ) except Exception as exc: raise RecoverableAgentError("知识检索不可用") from exc return [ normalized for batch in batches for hit in batch if (normalized := self._normalize_hit(hit)) is not None ] @staticmethod def _normalize_hit(hit: Any) -> dict[str, Any] | None: """统一 Milvus SDK 的平铺与 entity 包装命中格式。""" raw = dict(hit) entity = raw.get("entity") fields = entity if isinstance(entity, dict) else raw knowledge_id = fields.get("knowledge_id") snippet = fields.get("snippet") score = raw.get("score", raw.get("distance", fields.get("score"))) if ( not isinstance(knowledge_id, str) or not isinstance(snippet, str) or not isinstance(score, (int, float)) or isinstance(score, bool) ): return None normalized: dict[str, Any] = { "knowledge_id": knowledge_id, "snippet": snippet, "score": float(score), } for field in ("title", "tags", "version"): value = fields.get(field) if value is not None: normalized[field] = value return normalized