Files
meida_backend/packages/kb/service.py
T
xsl 9c1802130d chore(prep): shared groundwork for batch-3 (admin/golden/cache)
第三批(T12 后台/T13 黄金集/T14 检索缓存)协调提交:
- kb:KbAssetService(登记/审核,tryon_ready 只能随 approved 打开)+ Asset 模型与 repo
- retrieval:list_gaps 缺口看板查询 + invalidate_cache() 钩子(T14 背后实现真缓存)
- 配置:ADMIN_TOKEN(空=后台 404)+ 检索缓存三参数
- pytest golden marker(默认跳过,CI 强制跑)+ Makefile golden + CI 防线 5c
- 测试隔离修复:test_auth_sms 加 autouse 清 sms:* 键夹具(频控计数跨运行
  累积会毒化后续运行——随机 IP 空间仅 254 且部分用例固定 device_id)
2026-08-14 20:57:52 +08:00

454 lines
17 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,
AssetCreate,
AssetReview,
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 KbAsset, 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,
)
class KbAssetService:
"""素材登记与审核(kb_asset)。与实体/图谱分开放:素材由运营流水线
(T-P2-07/15)高频写入,审核是唯一能把 tryon_ready 打开的入口。"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def register_asset(self, payload: AssetCreate) -> KbAsset:
entity = await repo.get_entity(self._session, int(payload.entity_id))
if entity is None:
raise AppError(ErrorCode.NOT_FOUND, message="实体不存在,先建实体再登记素材")
asset = repo.add_asset(
self._session,
KbAsset(
asset_id=next_id(),
entity_id=entity.entity_id,
asset_type=payload.asset_type,
cos_key=payload.cos_key,
spec_check=payload.spec_check,
tryon_ready=False, # 登记时恒为 False,只有审核能打开
review_status="pending",
license=payload.license,
),
)
await self._session.flush()
return asset
async def review_asset(self, asset_id: int, payload: AssetReview) -> KbAsset:
asset = await repo.get_asset(self._session, asset_id)
if asset is None:
raise AppError(ErrorCode.NOT_FOUND, message="素材不存在")
# tryon_ready 只能随 approved 一起打开:审核是唯一放行开关
if payload.tryon_ready and payload.review_status != "approved":
raise AppError(
ErrorCode.BAD_REQUEST,
message="tryon_ready 只能在审核通过时开启",
)
asset.review_status = payload.review_status
asset.tryon_ready = payload.tryon_ready
await self._session.flush()
return asset
async def list_assets(
self,
*,
entity_id: int | None = None,
tryon_ready: bool | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[KbAsset], int]:
return await repo.list_assets(
self._session,
entity_id=entity_id,
tryon_ready=tryon_ready,
limit=limit,
offset=offset,
)