Files
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

199 lines
7.1 KiB
Python
Raw Permalink 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 域请求/响应模型(架构文档 §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