Files
meida_backend/apps/api/routers/kb.py
T
xsl d9249400af feat(kb): entity store with lifecycle, graph traversal and color palette
12 类实体统一表 + 状态机 + version 递增 + kb_relation 递归 CTE 1~3 跳
可解释路径(防环)+ kb_color 色板(T-P2-01/05,TD-09/18)。

实现:
- packages/kb/schemas.py:12 类 EntityType 与状态 Literal 校验、
  EntityCreate/Update/Out、RelationCreate、GraphNode/Hop/Path/QueryResult、
  ColorOut;雪花 ID 全部字符串出参(JS 精度)。
- packages/kb/repo.py:实体 CRUD/按名查重/过滤分页搜索;traverse 递归
  CTE(path 数组 + NOT @> 防环,锚点与递归段 varchar[] 显转型);色板读写。
- packages/kb/service.py:状态机矩阵(draft→reviewing→published→archived
  + draft/reviewing→archived 撤稿,非法跃迁 40900,矩阵单测 16 组全覆盖)、
  内容修改仅 draft/reviewing 且 version+1、published 只读;关系写入
  (自环/三元组重复 400、端点缺失 404);色板 role/hex(#RRGGBB) 校验。
- apps/api/routers/kb.py:GET/POST /kb/items、GET/PATCH /kb/items/{id}、
  GET /kb/items/{id}/graph?max_hops=1..3(超界 422);KbService 装配就近
  放路由文件(apps/api/deps.py 本轮禁改)。

测试:单测 77 例(跃迁矩阵、12 类 Literal、非法类型拒绝、hex/role/hops
校验);集成 5 例(真实 PG:生命周期/改 published 被拒/版本递增、关系
约束、2 跳可解释路径+防环、列表过滤分页、色板读写)。全套门禁绿:
ruff check/format、mypy(指定包列表)、lint-imports、单测 315 通过、
集成 21 通过。

偏差说明:
1. 新增 tests/unit/__init__.py 与 tests/integration/__init__.py(仅注释
   的包标记):任务文档要求单测/集成同名 test_kb_entity.py,pytest
   prepend 导入模式按 basename 注册模块会撞名,加包标记唯一化。
2. 关系与色板未挂 HTTP 路由:文档 §3.4 路由清单仅列 items+graph,
   录入入口属运营后台 T-P2-09,本批在 service 层落地并集成直连验证。
3. published→draft 按文档字面矩阵(其余跃迁非法)拦为 40900:改已发布
   内容的既定路径是撤稿归档后重录新草稿(错误 hint 已说明),未私开
   回退通道。
2026-08-14 20:09:12 +08:00

123 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""kb 实体路由(FR-KB-01/02/05):实体 CRUD + 状态机 + 1~3 跳图谱查询。
写接口暂不挂鉴权以外的前置(后台运营角色属 T-P2-09,先 ``CurrentUserId``,
``updated_by`` 记录操作者)。统一信封见架构文档 §9.2;跨域响应模型在
``packages/kb/schemas.py``(``apps/api/schemas.py`` 本轮禁改)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from apps.api.deps import CurrentUserId, SessionDep
from packages.kb.schemas import (
MAX_GRAPH_HOPS,
EntityCreate,
EntityOut,
EntityStatus,
EntityType,
EntityUpdate,
)
from packages.kb.service import KbService
from packages.shared.models import KbEntity
from packages.shared.response import ok
router = APIRouter(prefix="/kb", tags=["kb"])
def kb_service_dep(session: SessionDep) -> KbService:
"""``apps/api/deps.py`` 本轮禁改,kb 的服务装配放路由文件内就近完成。"""
return KbService(session)
KbServiceDep = Annotated[KbService, Depends(kb_service_dep)]
def _serialize(entity: KbEntity) -> dict[str, object]:
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(
user_id: CurrentUserId,
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, object]:
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_entity(
payload: EntityCreate,
user_id: CurrentUserId,
kb: KbServiceDep,
) -> dict[str, object]:
entity = await kb.create_entity(payload=payload, updated_by=str(user_id))
return ok(_serialize(entity))
@router.get("/items/{entity_id}", summary="实体详情")
async def get_entity(
entity_id: int,
user_id: CurrentUserId,
kb: KbServiceDep,
) -> dict[str, object]:
entity = await kb.get_entity(entity_id=entity_id)
return ok(_serialize(entity))
@router.patch("/items/{entity_id}", summary="修改内容(draft/reviewing)或推进状态(状态机校验)")
async def update_entity(
entity_id: int,
payload: EntityUpdate,
user_id: CurrentUserId,
kb: KbServiceDep,
) -> dict[str, object]:
entity = await kb.update_entity(entity_id=entity_id, payload=payload, updated_by=str(user_id))
return ok(_serialize(entity))
@router.get("/items/{entity_id}/graph", summary="从该实体出发的 1~3 跳可解释路径(T-P2-05)")
async def get_entity_graph(
entity_id: int,
user_id: CurrentUserId,
kb: KbServiceDep,
max_hops: int = Query(
default=MAX_GRAPH_HOPS, ge=1, le=MAX_GRAPH_HOPS, description="遍历深度 1~3,超界 422"
),
) -> dict[str, object]:
result = await kb.traverse_graph(entity_id=entity_id, max_hops=max_hops)
return ok(result.model_dump())