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

393 lines
14 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 域 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,
)