"""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