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 已说明),未私开
回退通道。
393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""kb 域 service:实体生命周期、关系写入、图谱遍历、自建色板(FR-KB-01/02/05).
|
||
|
||
状态机与格式校验拆成模块级纯函数(不碰 DB,单测直接覆盖矩阵);
|
||
``KbService`` 只负责组装、落库与跨表编排。所有写操作只 ``flush`` 不
|
||
``commit``——事务边界交给 ``session_dep`` 的统一提交/回滚,与 task /
|
||
billing 域一致。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import UTC, datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from packages.domain.ids import next_id
|
||
from packages.kb import repo
|
||
from packages.kb.schemas import (
|
||
MAX_GRAPH_HOPS,
|
||
ColorOut,
|
||
EntityCreate,
|
||
EntityUpdate,
|
||
GraphHop,
|
||
GraphNode,
|
||
GraphPath,
|
||
GraphQueryResult,
|
||
RelationCreate,
|
||
)
|
||
from packages.shared.errors import AppError, ErrorCode, NotFoundError
|
||
from packages.shared.logging import get_logger
|
||
from packages.shared.models import KbColor, KbEntity, KbRelation
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
#: 合法跃迁矩阵(知识库文档「发布回滚」语义):
|
||
#: draft → reviewing → published → archived 主线;
|
||
#: draft/reviewing → archived 撤稿支线;
|
||
#: archived 终态。其余(含 published→draft、reviewing→draft、自跃迁)
|
||
#: 一律非法——想改已发布内容,就撤稿归档后以新草稿重录,让新版本重走评审。
|
||
LEGAL_TRANSITIONS: frozenset[tuple[str, str]] = frozenset(
|
||
{
|
||
("draft", "reviewing"),
|
||
("reviewing", "published"),
|
||
("published", "archived"),
|
||
("draft", "archived"),
|
||
("reviewing", "archived"),
|
||
}
|
||
)
|
||
|
||
#: 内容可编辑的状态。published 内容只读(版本语义的基石),archived 终态。
|
||
CONTENT_EDITABLE_STATUSES: frozenset[str] = frozenset({"draft", "reviewing"})
|
||
|
||
_HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||
_COLOR_ROLES: frozenset[str] = frozenset({"base", "accent", "avoid"})
|
||
|
||
|
||
# --- 纯校验函数(单测直接覆盖,不触碰 DB) ----------------------------------
|
||
|
||
|
||
def can_transition(current: str, target: str) -> bool:
|
||
return (current, target) in LEGAL_TRANSITIONS
|
||
|
||
|
||
def assert_transition(current: str, target: str) -> None:
|
||
"""非法跃迁 → ``STATE_CONFLICT``(409)。矩阵见 :data:`LEGAL_TRANSITIONS`。"""
|
||
if not can_transition(current, target):
|
||
raise AppError(
|
||
ErrorCode.STATE_CONFLICT,
|
||
message="实体状态不允许该跃迁",
|
||
data={"current": current, "target": target},
|
||
)
|
||
|
||
|
||
def ensure_content_editable(status: str) -> None:
|
||
"""published/archived 的内容不可直接修改(version 递增的前提)。"""
|
||
if status not in CONTENT_EDITABLE_STATUSES:
|
||
raise AppError(
|
||
ErrorCode.STATE_CONFLICT,
|
||
message=f"{status} 状态的实体内容不可修改",
|
||
hint="已发布内容要改:先撤稿归档,再以新草稿重录,新版本重走评审",
|
||
data={"status": status},
|
||
)
|
||
|
||
|
||
def ensure_valid_hex(hex_code: str) -> None:
|
||
"""色板 hex 必须 ``#RRGGBB``(大小写不敏感,入库前不归一化,展示原样保留)。"""
|
||
if _HEX_COLOR_PATTERN.fullmatch(hex_code) is None:
|
||
raise AppError(
|
||
ErrorCode.BAD_REQUEST,
|
||
message="色板 hex 格式非法,应为 #RRGGBB",
|
||
data={"hex": hex_code},
|
||
)
|
||
|
||
|
||
def ensure_valid_color_role(role: str) -> None:
|
||
if role not in _COLOR_ROLES:
|
||
raise AppError(
|
||
ErrorCode.BAD_REQUEST,
|
||
message="色板 role 非法,应为 base/accent/avoid",
|
||
data={"role": role},
|
||
)
|
||
|
||
|
||
def ensure_valid_hops(max_hops: int) -> None:
|
||
"""遍历深度 1~3(TD-09 有界遍历;上限同时也是防环的保险丝)。"""
|
||
if not 1 <= max_hops <= MAX_GRAPH_HOPS:
|
||
raise AppError(
|
||
ErrorCode.BAD_REQUEST,
|
||
message=f"max_hops 超出允许范围 1~{MAX_GRAPH_HOPS}",
|
||
data={"max_hops": max_hops},
|
||
)
|
||
|
||
|
||
# --- service -----------------------------------------------------------------
|
||
|
||
|
||
class KbService:
|
||
def __init__(self, session: AsyncSession) -> None:
|
||
self._session = session
|
||
|
||
# -- 实体 -----------------------------------------------------------------
|
||
|
||
async def create_entity(
|
||
self, *, payload: EntityCreate, updated_by: str | None = None
|
||
) -> KbEntity:
|
||
"""新建实体:固定 draft / version=1 起步。同名同类目拒绝(400)。"""
|
||
existing = await repo.find_entity_by_name(
|
||
self._session, entity_type=payload.entity_type, name=payload.name
|
||
)
|
||
if existing is not None:
|
||
raise AppError(
|
||
ErrorCode.BAD_REQUEST,
|
||
message="同名同类目的实体已存在",
|
||
data={"entity_type": payload.entity_type, "name": payload.name},
|
||
)
|
||
entity = repo.add_entity(
|
||
self._session,
|
||
KbEntity(
|
||
entity_id=next_id(),
|
||
entity_type=payload.entity_type,
|
||
name=payload.name,
|
||
attrs=payload.attrs,
|
||
status="draft",
|
||
version=1,
|
||
source=payload.source,
|
||
license=payload.license,
|
||
updated_by=updated_by,
|
||
),
|
||
)
|
||
try:
|
||
await self._session.flush()
|
||
except IntegrityError as exc:
|
||
# 并发同名是唯一预期内的约束冲突(UNIQUE(entity_type,name) 兜底)
|
||
raise AppError(
|
||
ErrorCode.BAD_REQUEST,
|
||
message="同名同类目的实体已存在",
|
||
data={"entity_type": payload.entity_type, "name": payload.name},
|
||
) from exc
|
||
logger.info(
|
||
"kb_entity_created",
|
||
entity_id=entity.entity_id,
|
||
entity_type=entity.entity_type,
|
||
name=entity.name,
|
||
)
|
||
return entity
|
||
|
||
async def get_entity(self, *, entity_id: int) -> KbEntity:
|
||
entity = await repo.get_entity(self._session, entity_id)
|
||
if entity is None:
|
||
raise NotFoundError("实体不存在")
|
||
return entity
|
||
|
||
async def search_entities(
|
||
self,
|
||
*,
|
||
entity_type: str | None = None,
|
||
status: str | None = None,
|
||
name_ilike: str | None = None,
|
||
limit: int = 20,
|
||
offset: int = 0,
|
||
) -> tuple[list[KbEntity], int]:
|
||
return await repo.search_entities(
|
||
self._session,
|
||
entity_type=entity_type,
|
||
status=status,
|
||
name_ilike=name_ilike,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
|
||
async def update_entity(
|
||
self, *, entity_id: int, payload: EntityUpdate, updated_by: str | None = None
|
||
) -> KbEntity:
|
||
"""内容修改与状态跃迁共用一个 PATCH 入口,规则:
|
||
|
||
- 内容字段仅 draft/reviewing 可改,每次内容修改 version+1;
|
||
- 状态变化必须走合法跃迁矩阵;与当前相同的 status 是幂等 no-op;
|
||
- 先全部校验、后统一变更——半个请求不会落库。
|
||
"""
|
||
entity = await repo.get_entity_for_update(self._session, entity_id)
|
||
if entity is None:
|
||
raise NotFoundError("实体不存在")
|
||
|
||
content = payload.content_fields()
|
||
if content:
|
||
ensure_content_editable(entity.status)
|
||
if payload.status is not None and payload.status != entity.status:
|
||
assert_transition(entity.status, payload.status)
|
||
|
||
if content:
|
||
for field, value in content.items():
|
||
setattr(entity, field, value)
|
||
entity.version += 1
|
||
if payload.status is not None:
|
||
entity.status = payload.status
|
||
if content or payload.status is not None:
|
||
entity.updated_by = updated_by or entity.updated_by
|
||
entity.updated_at = datetime.now(UTC)
|
||
try:
|
||
await self._session.flush()
|
||
except IntegrityError as exc:
|
||
# 改名撞上另一条同类目实体
|
||
raise AppError(
|
||
ErrorCode.BAD_REQUEST,
|
||
message="同名同类目的实体已存在",
|
||
data={"entity_id": str(entity_id)},
|
||
) from exc
|
||
logger.info(
|
||
"kb_entity_updated",
|
||
entity_id=entity_id,
|
||
version=entity.version,
|
||
status=entity.status,
|
||
content_changed=bool(content),
|
||
)
|
||
return entity
|
||
|
||
# -- 关系 -----------------------------------------------------------------
|
||
|
||
async def add_relation(self, *, payload: RelationCreate) -> KbRelation:
|
||
"""写入三元组。自环与重复(UNIQUE 三元组)拒绝(400),端点缺失 404。"""
|
||
if payload.from_entity_id == payload.to_entity_id:
|
||
raise AppError(
|
||
ErrorCode.BAD_REQUEST,
|
||
message="不允许自环关系",
|
||
data={"entity_id": str(payload.from_entity_id)},
|
||
)
|
||
for end_id in (payload.from_entity_id, payload.to_entity_id):
|
||
if await repo.get_entity(self._session, end_id) is None:
|
||
raise NotFoundError("关联实体不存在")
|
||
duplicate = await repo.find_relation(
|
||
self._session,
|
||
from_id=payload.from_entity_id,
|
||
rel_type=payload.rel_type,
|
||
to_id=payload.to_entity_id,
|
||
)
|
||
if duplicate is not None:
|
||
raise AppError(
|
||
ErrorCode.BAD_REQUEST,
|
||
message="该 from/rel_type/to 三元组已存在",
|
||
data={
|
||
"from_entity_id": str(payload.from_entity_id),
|
||
"rel_type": payload.rel_type,
|
||
"to_entity_id": str(payload.to_entity_id),
|
||
},
|
||
)
|
||
relation = repo.add_relation(
|
||
self._session,
|
||
KbRelation(
|
||
relation_id=next_id(),
|
||
from_id=payload.from_entity_id,
|
||
rel_type=payload.rel_type,
|
||
to_id=payload.to_entity_id,
|
||
weight=payload.weight,
|
||
confidence=payload.confidence,
|
||
evidence=payload.evidence,
|
||
),
|
||
)
|
||
await self._session.flush()
|
||
logger.info(
|
||
"kb_relation_added",
|
||
from_id=payload.from_entity_id,
|
||
rel_type=payload.rel_type,
|
||
to_id=payload.to_entity_id,
|
||
)
|
||
return relation
|
||
|
||
# -- 图谱 -----------------------------------------------------------------
|
||
|
||
async def traverse_graph(
|
||
self, *, entity_id: int, max_hops: int = MAX_GRAPH_HOPS
|
||
) -> GraphQueryResult:
|
||
"""1~3 跳可解释路径(T-P2-05)。
|
||
|
||
CTE 只回 id 与 rel_type 序列;节点摘要(类型/名称/状态)由第二次
|
||
查询补齐——遍历有界,涉及的实体集合很小,两次往返远比在递归 SQL
|
||
里 JOIN 实体表好维护。
|
||
"""
|
||
ensure_valid_hops(max_hops)
|
||
await self.get_entity(entity_id=entity_id) # 起点 404 语义保持一致
|
||
|
||
raw_paths = await repo.traverse(self._session, from_id=entity_id, max_hops=max_hops)
|
||
node_ids: set[int] = set()
|
||
for raw in raw_paths:
|
||
node_ids.update(raw.node_ids)
|
||
nodes_map = await repo.get_entities_by_ids(self._session, node_ids)
|
||
|
||
paths: list[GraphPath] = []
|
||
for raw in raw_paths:
|
||
nodes = [_node_summary(nodes_map, node_id) for node_id in raw.node_ids]
|
||
hops = [
|
||
GraphHop(
|
||
from_entity_id=str(hop_from),
|
||
rel_type=rel_type,
|
||
to_entity_id=str(hop_to),
|
||
)
|
||
# node_ids 比 rel_types 多一个元素(n 个节点 n-1 跳),首尾
|
||
# 错位 zip 即得「第 i 跳 = nodes[i] --rels[i]--> nodes[i+1]」
|
||
for hop_from, rel_type, hop_to in zip(
|
||
raw.node_ids[:-1], raw.rel_types, raw.node_ids[1:], strict=True
|
||
)
|
||
]
|
||
paths.append(GraphPath(nodes=nodes, hops=hops))
|
||
return GraphQueryResult(from_entity_id=str(entity_id), max_hops=max_hops, paths=paths)
|
||
|
||
# -- 色板(TD-18 自建色板) ------------------------------------------------
|
||
|
||
async def add_color(
|
||
self,
|
||
*,
|
||
season_code: str,
|
||
role: str,
|
||
lab_l: float,
|
||
lab_a: float,
|
||
lab_b: float,
|
||
hex_code: str,
|
||
name_cn: str,
|
||
) -> ColorOut:
|
||
"""录入色板条目。role/hex 校验在 service 层(运营后台 T-P2-09 直接复用)。"""
|
||
ensure_valid_color_role(role)
|
||
ensure_valid_hex(hex_code)
|
||
color = repo.add_color(
|
||
self._session,
|
||
KbColor(
|
||
color_id=next_id(),
|
||
season_code=season_code,
|
||
role=role,
|
||
lab_l=Decimal(str(lab_l)),
|
||
lab_a=Decimal(str(lab_a)),
|
||
lab_b=Decimal(str(lab_b)),
|
||
hex=hex_code,
|
||
name_cn=name_cn,
|
||
),
|
||
)
|
||
await self._session.flush()
|
||
logger.info("kb_color_added", season_code=season_code, role=role, hex=hex_code)
|
||
return _color_out(color)
|
||
|
||
async def list_colors(self, *, season_code: str | None = None) -> list[ColorOut]:
|
||
rows = await repo.list_colors(self._session, season_code=season_code)
|
||
return [_color_out(row) for row in rows]
|
||
|
||
|
||
def _node_summary(nodes_map: dict[int, KbEntity], node_id: int) -> GraphNode:
|
||
"""路径节点摘要。关系端点在写入时校验过存在,理论必中;万一实体行
|
||
被运维手工删除,退化为占位摘要而不是让整个图查询 500。"""
|
||
entity = nodes_map.get(node_id)
|
||
if entity is None:
|
||
logger.warning("kb_graph_node_missing", entity_id=node_id)
|
||
return GraphNode(
|
||
entity_id=str(node_id), entity_type="unknown", name="unknown", status="unknown"
|
||
)
|
||
return GraphNode(
|
||
entity_id=str(entity.entity_id),
|
||
entity_type=entity.entity_type,
|
||
name=entity.name,
|
||
status=entity.status,
|
||
)
|
||
|
||
|
||
def _color_out(color: KbColor) -> ColorOut:
|
||
return ColorOut(
|
||
color_id=str(color.color_id),
|
||
season_code=color.season_code,
|
||
role=color.role,
|
||
lab_l=float(color.lab_l),
|
||
lab_a=float(color.lab_a),
|
||
lab_b=float(color.lab_b),
|
||
hex=color.hex,
|
||
name_cn=color.name_cn,
|
||
)
|