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 已说明),未私开
   回退通道。
This commit is contained in:
xsl
2026-08-14 20:09:12 +08:00
parent d5f366fa4c
commit d9249400af
10 changed files with 1499 additions and 10 deletions
+115 -3
View File
@@ -1,10 +1,122 @@
"""知识库实体路由(骨架,T6 填充实现)。
"""kb 实体路由(FR-KB-01/02/05):实体 CRUD + 状态机 + 1~3 跳图谱查询。
GET /kb/items 等;实现契约见 docs/开发任务包/T6-知识库实体与图谱.md。
写接口暂不挂鉴权以外的前置(后台运营角色属 T-P2-09,先 ``CurrentUserId``,
``updated_by`` 记录操作者)。统一信封见架构文档 §9.2;跨域响应模型在
``packages/kb/schemas.py``(``apps/api/schemas.py`` 本轮禁改)。
"""
from __future__ import annotations
from fastapi import APIRouter
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())