第三批(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)
199 lines
7.1 KiB
Python
199 lines
7.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
|
||
|
||
|
||
class AssetCreate(BaseModel):
|
||
"""素材登记(kb_asset)。cos_key 是 COS 上的对象键(真实文件经 T5 预签名
|
||
直传后,由运营在这里登记元数据);spec_check 记录白底规格校验结果。"""
|
||
|
||
entity_id: str
|
||
asset_type: str = Field(description="display / tryon / detail 等素材类型")
|
||
cos_key: str = Field(min_length=1, max_length=255)
|
||
spec_check: dict[str, Any] = Field(default_factory=dict)
|
||
tryon_ready: bool = False
|
||
license: str | None = None
|
||
|
||
|
||
class AssetReview(BaseModel):
|
||
"""素材审核结论:review_status 与 tryon_ready 一起更新——
|
||
tryon_ready=true 是换装闭环放行的唯一开关,不允许绕过审核单独打开。"""
|
||
|
||
review_status: Literal["approved", "rejected", "pending"]
|
||
tryon_ready: bool = False
|
||
|
||
|
||
class AssetOut(BaseModel):
|
||
asset_id: str
|
||
entity_id: str
|
||
asset_type: str
|
||
cos_key: str
|
||
spec_check: dict[str, Any]
|
||
tryon_ready: bool
|
||
review_status: str
|
||
license: str | None
|
||
created_at: datetime
|