"""知识分类 API:GET /api/v1/knowledge/categories、GET /api/v1/knowledge/stats""" from functools import lru_cache from typing import Any import structlog from fastapi import APIRouter from app.api.response import ApiError, ok from app.config import settings from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory, load_taxonomy from app.services.qdrant import ALL_COLLECTIONS, COLLECTION_L1, QdrantService logger = structlog.get_logger() router = APIRouter(prefix="/api/v1", tags=["knowledge"]) # 类目分布统计的分页大小 _STATS_SCROLL_PAGE_SIZE = 100 # 模块级懒加载单例,避免每请求重建 QdrantService _qdrant: QdrantService | None = None def _get_qdrant() -> QdrantService: global _qdrant if _qdrant is None: _qdrant = QdrantService() return _qdrant @lru_cache(maxsize=1) def _get_taxonomy() -> list[TaxonomyCategory]: """加载并缓存 taxonomy 类目集(进程内只加载一次)""" return load_taxonomy(settings.taxonomy_path) @router.get("/knowledge/categories") async def list_categories() -> dict[str, Any]: """返回完整知识分类类目集""" categories = _get_taxonomy() return ok({"categories": [c.model_dump() for c in categories], "count": len(categories)}) @router.get("/knowledge/stats") async def knowledge_stats() -> dict[str, Any]: """返回四层集合规模与 L1 类目分布统计""" service = _get_qdrant() try: collections = {collection: await service.count(collection) for collection in ALL_COLLECTIONS} categories = await _aggregate_l1_categories(service) except Exception as exc: logger.error("获取知识库统计失败", error=str(exc)) raise ApiError(2000, f"获取知识库统计失败: {exc}") from exc return ok( { "collections": collections, "categories": categories, "uncategorized_count": categories.get(UNCATEGORIZED, 0), "documents_total": collections[COLLECTION_L1], } ) async def _aggregate_l1_categories(service: QdrantService) -> dict[str, int]: """分页遍历 L1 文档,按 category 聚合文档数(空类目归入 uncategorized)""" categories: dict[str, int] = {} offset: str | None = None while True: items, offset = await service.scroll_l1(limit=_STATS_SCROLL_PAGE_SIZE, offset=offset) for item in items: category = item.get("category") or UNCATEGORIZED categories[category] = categories.get(category, 0) + 1 if offset is None: return categories