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 已说明),未私开
回退通道。
167 lines
6.1 KiB
Python
167 lines
6.1 KiB
Python
"""kb 域请求/响应模型(架构文档 §6.2,FR-KB-01/02/05).
|
||
|
||
``apps/api/schemas.py`` 是全局共享契约文件,本轮并行开发禁改;kb 接口的
|
||
请求/响应模型统一放本包,由路由层装配进 ``ok(...)`` 信封(NFR-MAINT-01
|
||
「契约先行」:客户端类型从 OpenAPI 生成)。
|
||
|
||
雪花 ID 一律以字符串出现在载荷里:JS Number 只有 2^53 精度,直接给数字
|
||
会被四舍五入成另一个实体。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from typing import Any, Literal, get_args
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
#: 12 类实体(知识库文档 §3.1 本体)。写死成 Literal 而不是普通 str:
|
||
#: 新增类目是需要评审的低频动作,宁可发版,也不给运营侧随手写错值留口子。
|
||
EntityType = Literal[
|
||
"item",
|
||
"color",
|
||
"style",
|
||
"body_shape",
|
||
"face_hair",
|
||
"occasion",
|
||
"material",
|
||
"accessory",
|
||
"makeup",
|
||
"rule",
|
||
"outfit",
|
||
"trend",
|
||
]
|
||
|
||
ENTITY_TYPES: tuple[str, ...] = get_args(EntityType)
|
||
|
||
#: 生命周期四态。合法跃迁矩阵见 :mod:`packages.kb.service`(非法跃迁 40900)。
|
||
EntityStatus = Literal["draft", "reviewing", "published", "archived"]
|
||
|
||
ENTITY_STATUSES: tuple[str, ...] = get_args(EntityStatus)
|
||
|
||
#: kb_color.role:基础色 / 点缀色 / 回避色(TD-18 自建色板)。
|
||
ColorRole = Literal["base", "accent", "avoid"]
|
||
|
||
#: 图谱遍历深度上限(TD-09:浅层有界遍历,1~3 跳,不上图数据库)。
|
||
#: 放在这里是因为路由 Query 与 service 校验必须引用同一个数字,两处硬编码必然漂移。
|
||
MAX_GRAPH_HOPS = 3
|
||
|
||
|
||
class EntityCreate(BaseModel):
|
||
"""``POST /kb/items`` 载荷。新实体固定从 draft/v1 起步,不收客户端指定状态。"""
|
||
|
||
entity_type: EntityType = Field(description="12 类本体之一,见 EntityType")
|
||
name: str = Field(min_length=1, max_length=128, description="同类目内唯一(UNIQUE 守护)")
|
||
attrs: dict[str, Any] = Field(
|
||
default_factory=dict, description="类目差异化字段,由 attrs JSONB 承载(架构 §6.2)"
|
||
)
|
||
source: str | None = Field(default=None, max_length=255, description="知识来源,版权审计用")
|
||
license: str | None = Field(default=None, max_length=64, description="内容许可证标识")
|
||
|
||
|
||
class EntityUpdate(BaseModel):
|
||
"""``PATCH /kb/items/{id}`` 载荷:内容字段与状态可单独或同时提交。
|
||
|
||
- 只出现内容字段(name/attrs/source/license)→ 内容修改,version+1,
|
||
仅 draft/reviewing 状态允许(published 只读,见 service)。
|
||
- 只出现 status → 纯状态跃迁,走状态机校验,version 不变。
|
||
- 与当前状态相同的 status → 幂等重放,视为 no-op 成功(客户端重试友好)。
|
||
"""
|
||
|
||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||
attrs: dict[str, Any] | None = None
|
||
source: str | None = Field(default=None, max_length=255)
|
||
license: str | None = Field(default=None, max_length=64)
|
||
status: EntityStatus | None = None
|
||
|
||
def content_fields(self) -> dict[str, Any]:
|
||
"""本次请求显式提供的内容字段(PATCH 语义:未提供的字段不动)。
|
||
|
||
``exclude_unset`` 保证显式传 ``null``(清空 source/license)与
|
||
「未传」可区分——这是 PATCH 覆盖语义的最低要求。
|
||
"""
|
||
return self.model_dump(exclude_unset=True, exclude={"status"})
|
||
|
||
|
||
class EntityOut(BaseModel):
|
||
"""实体对外视图(``data`` 载荷,单条或列表元素)。"""
|
||
|
||
entity_id: str = Field(description="雪花 ID,字符串形式")
|
||
entity_type: str
|
||
name: str
|
||
attrs: dict[str, Any]
|
||
status: str
|
||
version: int = Field(description="内容版本号,每次内容修改 +1")
|
||
source: str | None
|
||
license: str | None
|
||
updated_by: str | None
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
|
||
class RelationCreate(BaseModel):
|
||
"""``kb_relation`` 三元组写入载荷。
|
||
|
||
本批次未暴露 HTTP 路由(关系录入属运营后台 T-P2-09 的范围),但写入
|
||
校验(自环/重复/端点存在)在 service 层先行落地,集成测试直接驱动。
|
||
"""
|
||
|
||
from_entity_id: int
|
||
to_entity_id: int
|
||
rel_type: str = Field(min_length=1, max_length=64, description="关系类型,如 suits/fits")
|
||
weight: Decimal = Field(default=Decimal("1"), ge=0, le=Decimal("9.999"), description="权重")
|
||
confidence: Decimal = Field(
|
||
default=Decimal("1"), ge=0, le=Decimal("9.999"), description="知识置信度"
|
||
)
|
||
evidence: str | None = Field(default=None, max_length=255, description="证据/出处")
|
||
|
||
|
||
class GraphNode(BaseModel):
|
||
"""路径上的实体摘要:图谱路径要能「读给人听」,只有 ID 不可解释。"""
|
||
|
||
entity_id: str
|
||
entity_type: str
|
||
name: str
|
||
status: str
|
||
|
||
|
||
class GraphHop(BaseModel):
|
||
"""一跳:从哪个实体经什么关系到哪个实体(T-P2-05 可解释路径的最小单位)。"""
|
||
|
||
from_entity_id: str
|
||
rel_type: str
|
||
to_entity_id: str
|
||
|
||
|
||
class GraphPath(BaseModel):
|
||
"""一条从起点出发的完整路径:节点序列 + 每跳 rel_type。
|
||
|
||
``len(rel_types) == len(nodes) - 1``;同一终点可能有多条路径,
|
||
遍历结果不去重合并——「为什么推荐」恰恰需要把每条依据都摆出来。
|
||
"""
|
||
|
||
nodes: list[GraphNode]
|
||
hops: list[GraphHop]
|
||
|
||
|
||
class GraphQueryResult(BaseModel):
|
||
"""``GET /kb/items/{id}/graph`` 载荷。"""
|
||
|
||
from_entity_id: str
|
||
max_hops: int = Field(description="本次遍历深度(1~3)")
|
||
paths: list[GraphPath] = Field(description="按跳数升序的全部可解释路径")
|
||
|
||
|
||
class ColorOut(BaseModel):
|
||
"""色板条目(TD-18 自建色板)。Lab 供后续 ΔE 数值匹配(T8 检索),此处只读展示。"""
|
||
|
||
color_id: str
|
||
season_code: str = Field(description="季型代码,如 spring_warm")
|
||
role: str = Field(description="base=基础色 / accent=点缀色 / avoid=回避色")
|
||
lab_l: float
|
||
lab_a: float
|
||
lab_b: float
|
||
hex: str = Field(description="#RRGGBB")
|
||
name_cn: str
|