Files
meida_backend/packages/kb/repo.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

169 lines
6.2 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 域数据访问层。**只有本模块允许触碰 kb_entity / kb_relation / kb_color。**
``traverse`` 用递归 CTE 而不是在 Python 里逐跳发查询:TD-09 的结论是
知识图谱的查询都是「从某实体出发的浅层有界遍历」(1~3 跳),邻接表 +
CTE 在 PG 内一次往返就能完成,不值得为此引入图数据库的第二套存储与运维。
防环在 CTE 里做(``path`` 数组 + ``@>`` 包含判断),保证有环数据
(A→B→A)也不会让递归发散。
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from sqlalchemy import ColumnElement, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from packages.shared.models import KbColor, KbEntity, KbRelation
#: 递归遍历 SQL(1~3 跳)。``path`` 记录已经过的实体 id,``rels`` 与逐跳
#: rel_type 平行——每行结果本身就是一条完整可解释路径,不需要在 Python
#: 侧再拼接。深度上限由参数 ``:max_hops`` 控制(service 层已校验 1~3)。
#: ``::varchar[]`` 显转型不可省:锚点项推得 varchar(64)[]、递归项推得
#: varchar[],PG 要求两段类型完全一致,不转型直接 DatatypeMismatchError。
_TRAVERSE_SQL = text(
"""
WITH RECURSIVE walk(from_id, to_id, rel_type, hop, path, rels) AS (
SELECT r.from_id, r.to_id, r.rel_type, 1,
ARRAY[r.from_id, r.to_id]::bigint[], ARRAY[r.rel_type]::varchar[]
FROM kb_relation r
WHERE r.from_id = :start_id
UNION ALL
SELECT r.from_id, r.to_id, r.rel_type, w.hop + 1,
w.path || r.to_id, w.rels || ARRAY[r.rel_type]::varchar[]
FROM kb_relation r
JOIN walk w ON r.from_id = w.to_id
WHERE w.hop < :max_hops
AND NOT w.path @> ARRAY[r.to_id]
)
SELECT hop, path, rels FROM walk ORDER BY hop, path, rels
"""
)
@dataclass(frozen=True, slots=True)
class RawPath:
"""CTE 直接吐出的一条路径(防环已由 SQL 保证,len(rel_types)==len(node_ids)-1)。"""
node_ids: tuple[int, ...]
rel_types: tuple[str, ...]
# --- kb_entity ---------------------------------------------------------------
async def get_entity(session: AsyncSession, entity_id: int) -> KbEntity | None:
return await session.get(KbEntity, entity_id)
async def get_entity_for_update(session: AsyncSession, entity_id: int) -> KbEntity | None:
result = await session.execute(
select(KbEntity).where(KbEntity.entity_id == entity_id).with_for_update()
)
return result.scalar_one_or_none()
async def get_entities_by_ids(session: AsyncSession, ids: Iterable[int]) -> dict[int, KbEntity]:
id_list = list(ids)
if not id_list:
return {}
result = await session.execute(select(KbEntity).where(KbEntity.entity_id.in_(id_list)))
return {entity.entity_id: entity for entity in result.scalars()}
async def find_entity_by_name(
session: AsyncSession, *, entity_type: str, name: str
) -> KbEntity | None:
# UNIQUE(entity_type, name) 保证至多一行
result = await session.execute(
select(KbEntity).where(KbEntity.entity_type == entity_type, KbEntity.name == name)
)
return result.scalar_one_or_none()
def add_entity(session: AsyncSession, entity: KbEntity) -> KbEntity:
session.add(entity)
return entity
async def search_entities(
session: AsyncSession,
*,
entity_type: str | None = None,
status: str | None = None,
name_ilike: str | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[KbEntity], int]:
"""过滤 + 分页。返回 ``(当页行, 满足过滤条件的总数)``。
``name_ilike`` 不转义 ``%``/``_``:这是后台运营的模糊搜索框,
通配符被当通配符用符合直觉,也不是注入面(参数化绑定)。
"""
conds: list[ColumnElement[bool]] = []
if entity_type is not None:
conds.append(KbEntity.entity_type == entity_type)
if status is not None:
conds.append(KbEntity.status == status)
if name_ilike:
conds.append(KbEntity.name.ilike(f"%{name_ilike}%"))
total = int(await session.scalar(select(func.count()).select_from(KbEntity).where(*conds)) or 0)
result = await session.execute(
select(KbEntity)
.where(*conds)
.order_by(KbEntity.created_at.desc(), KbEntity.entity_id.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all()), total
# --- kb_relation -------------------------------------------------------------
async def find_relation(
session: AsyncSession, *, from_id: int, rel_type: str, to_id: int
) -> KbRelation | None:
result = await session.execute(
select(KbRelation).where(
KbRelation.from_id == from_id,
KbRelation.rel_type == rel_type,
KbRelation.to_id == to_id,
)
)
return result.scalar_one_or_none()
def add_relation(session: AsyncSession, relation: KbRelation) -> KbRelation:
session.add(relation)
return relation
async def traverse(session: AsyncSession, *, from_id: int, max_hops: int) -> list[RawPath]:
"""从 ``from_id`` 出发沿 from→to 方向做 ``max_hops`` 跳的有界遍历。
深度合法性(1~3)由 service 层校验,这里信任调用方。返回按
(跳数, 路径, 关系序列)排序的全部路径——同一终点多条路径都保留,
「为什么推荐」恰恰需要把每条依据都摆出来。
"""
result = await session.execute(_TRAVERSE_SQL, {"start_id": from_id, "max_hops": max_hops})
return [RawPath(node_ids=tuple(row.path), rel_types=tuple(row.rels)) for row in result]
# --- kb_color ----------------------------------------------------------------
async def list_colors(session: AsyncSession, *, season_code: str | None = None) -> list[KbColor]:
stmt = select(KbColor).order_by(KbColor.season_code, KbColor.color_id)
if season_code is not None:
stmt = stmt.where(KbColor.season_code == season_code)
result = await session.execute(stmt)
return list(result.scalars().all())
def add_color(session: AsyncSession, color: KbColor) -> KbColor:
session.add(color)
return color