"""知识实体路由(FR-ADM-01):维护 + 状态机流转 + 图谱查看。 与客户端 ``apps.api`` 的 kb 路由关键差异: - 鉴权走 ``X-Admin-Token``(共享令牌),操作者没有用户身份,``updated_by`` 固定记录为 ``admin-console``——V1 的操作留证靠 request_id 关联访问日志; - 多出 ``POST /items/{id}/transition``:把状态机流转从 PATCH 的「内容+状态 可混用」里独立出来,发布/撤稿动作在这里挂上跨域缓存失效编排(T-P2-10 的「自然失效」入口)。 """ from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, Query from apps.admin.deps import KbServiceDep, admin_auth from apps.admin.publishing import CACHE_AFFECTING_TARGETS, invalidate_downstream_caches from apps.admin.schemas import TransitionRequest from packages.kb.schemas import ( MAX_GRAPH_HOPS, EntityCreate, EntityOut, EntityStatus, EntityType, EntityUpdate, ) from packages.shared.models import KbEntity from packages.shared.response import ok router = APIRouter(prefix="/kb", tags=["admin:kb"], dependencies=[Depends(admin_auth)]) #: V1 共享令牌鉴权没有操作者身份,字段统一记渠道来源(见模块 docstring)。 ADMIN_OPERATOR = "admin-console" def _serialize(entity: KbEntity) -> dict[str, Any]: return EntityOut( entity_id=str(entity.entity_id), entity_type=entity.entity_type, name=entity.name, attrs=dict(entity.attrs), status=entity.status, version=entity.version, source=entity.source, license=entity.license, updated_by=entity.updated_by, created_at=entity.created_at, updated_at=entity.updated_at, ).model_dump() @router.get("/items", summary="实体列表:entity_type / status 过滤 + 名称模糊 + 分页") async def list_items( kb: KbServiceDep, entity_type: EntityType | None = Query(default=None, description="12 类本体之一"), status: EntityStatus | None = Query(default=None), name: str | None = Query(default=None, max_length=128, description="名称模糊匹配"), limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), ) -> dict[str, Any]: rows, total = await kb.search_entities( entity_type=entity_type, status=status, name_ilike=name, limit=limit, offset=offset, ) return ok( { "items": [_serialize(row) for row in rows], "total": total, "limit": limit, "offset": offset, } ) @router.post("/items", summary="新建实体(draft / version=1 起步)") async def create_item(payload: EntityCreate, kb: KbServiceDep) -> dict[str, Any]: entity = await kb.create_entity(payload=payload, updated_by=ADMIN_OPERATOR) return ok(_serialize(entity)) @router.get("/items/{entity_id}", summary="实体详情") async def get_item(entity_id: int, kb: KbServiceDep) -> dict[str, Any]: entity = await kb.get_entity(entity_id=entity_id) return ok(_serialize(entity)) @router.patch("/items/{entity_id}", summary="修改内容(draft/reviewing;published 只读)") async def update_item(entity_id: int, payload: EntityUpdate, kb: KbServiceDep) -> dict[str, Any]: entity = await kb.update_entity(entity_id=entity_id, payload=payload, updated_by=ADMIN_OPERATOR) return ok(_serialize(entity)) @router.post( "/items/{entity_id}/transition", summary="状态机流转:draft→reviewing→published→archived(发布/撤稿触发缓存失效编排)", ) async def transition_item( entity_id: int, payload: TransitionRequest, kb: KbServiceDep ) -> dict[str, Any]: # 先读当前状态:与目标相同的重放是幂等 no-op(与 service 语义一致), # 不触发失效编排——缓存重建是全量动作,重放不该有副作用。 current = await kb.get_entity(entity_id=entity_id) if current.status == payload.target: return ok(_serialize(current)) entity = await kb.update_entity( entity_id=entity_id, payload=EntityUpdate(status=payload.target), updated_by=ADMIN_OPERATOR, ) if payload.target in CACHE_AFFECTING_TARGETS: invalidate_downstream_caches( domain="kb_entity", key=str(entity.entity_id), target=payload.target ) return ok(_serialize(entity)) @router.get("/items/{entity_id}/graph", summary="从该实体出发的 1~3 跳可解释路径") async def get_item_graph( entity_id: int, kb: KbServiceDep, max_hops: int = Query( default=MAX_GRAPH_HOPS, ge=1, le=MAX_GRAPH_HOPS, description="遍历深度 1~3,超界 422" ), ) -> dict[str, Any]: result = await kb.traverse_graph(entity_id=entity_id, max_hops=max_hops) return ok(result.model_dump())