"""生成/入库端点的请求/响应 schema(C3 扩 / ARCH §6.5 / §7.2)。 snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。 生成走**即时返回**(预览 → 作者确认 → 入库),非 jobs 长任务(M5-d): - `POST /world/generate` / `POST /characters/generate`:返回**预览**(不持久化)。 - `POST /characters`(入库):作者确认的角色卡 → 过 continuity 预检 gate + 权限白名单 → 写库。 视图字段贴 `ww_agents.WorldEntityCard`/`CharacterCard`(保持生成产物形:traits/speech_tics 是 list、arc 是 str;入库时由写侧 repo 转 DB JSONB 形,见 character_repo)。 """ from __future__ import annotations from pydantic import BaseModel, Field, model_validator from ww_api.schemas.rules import RuleView # 批量生成数量上限(防一次性巨量调用;原型保守值)。 MAX_GENERATE_COUNT = 12 # ---- 世界观生成(预览)---- class WorldGenerateRequest(BaseModel): """POST /projects/:id/world/generate:据需求生成世界观实体(预览)。""" brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)") class WorldEntityCardView(BaseModel): """单个世界观实体卡(贴 ww_agents.WorldEntityCard;预览 + 入库共用形)。""" type: str = Field(description="实体类型(势力 / 地理 / 力量体系 / 物品 / 概念 等)") name: str = Field(description="实体名") rules: list[str] = Field(default_factory=list, description="该实体的硬规则清单") class WorldGenPreviewResponse(BaseModel): """世界观生成预览(不持久化;作者确认后另走入库,本期入库端点为角色)。""" entities: list[WorldEntityCardView] = Field(default_factory=list) # ---- 角色生成(预览)---- class CharacterGenerateRequest(BaseModel): """POST /projects/:id/characters/generate:据需求生成角色卡(预览,群像防雷同)。 `role_mix`(⑦ 群像按定位配比)与 `count` 的关系:`role_mix` 在场时 `count` 取各值之和 (忽略客户端显式 count),每项数量须 ≥ 1、总和须 ≤ `MAX_GENERATE_COUNT`;缺省则退回 单一 `role` + `count` 语义。 """ brief: str = Field(min_length=1, description="角色生成需求") count: int = Field( default=1, ge=1, le=MAX_GENERATE_COUNT, description="生成数量(role_mix 在场时取各值之和)", ) role: str | None = Field(default=None, description="角色定位(主角/CP/对手/导师/工具人 等)") role_mix: dict[str, int] | None = Field( default=None, description="按定位配比生成 {定位: 数量};在场时 count=各值之和(每项≥1、总和≤12)", ) @model_validator(mode="after") def _reconcile_role_mix(self) -> CharacterGenerateRequest: """配比在场时校验并把 count 定为各值之和(构造期归一化,非外部对象变更)。""" if self.role_mix is None: return self if not self.role_mix: raise ValueError("role_mix 不能为空对象") if any(n < 1 for n in self.role_mix.values()): raise ValueError("role_mix 每个定位的数量须 ≥ 1") total = sum(self.role_mix.values()) if total > MAX_GENERATE_COUNT: raise ValueError(f"role_mix 数量总和须 ≤ {MAX_GENERATE_COUNT}") self.count = total return self class CharacterRelationView(BaseModel): """单条人物关系(贴 ww_agents.CharacterRelation)。""" name: str kind: str note: str | None = None class CharacterCardView(BaseModel): """单张角色卡(贴 ww_agents.CharacterCard;预览 + 入库请求共用形)。""" name: str = Field(description="角色名") role: str = Field(description="角色定位") traits: list[str] = Field(default_factory=list, description="性格特质清单(性格三层)") appearance: str = Field(default="", description="外貌形象(缺则空串)") motive: str = Field(default="", description="核心动机/欲望/恐惧(缺则空串)") backstory: str = Field(description="背景故事") arc: str = Field(description="人物弧光(一句话)") speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格") tags: list[str] = Field(default_factory=list, description="人设标签/萌点") relations: list[CharacterRelationView] = Field(default_factory=list, description="关系网") class CharacterGenPreviewResponse(BaseModel): """角色生成预览(不持久化;作者确认后经 POST /characters 入库)。""" cards: list[CharacterCardView] = Field(default_factory=list) # ---- 角色入库(作者确认后;过 continuity gate + 权限白名单)---- class CharacterIngestRequest(BaseModel): """POST /projects/:id/characters:把作者确认的角色卡入库。 `acknowledge_conflicts`:作者已查看并接受预检冲突时置 `true`,越过 continuity gate (仿 accept 的冲突裁决:不静默入库,须作者确认;默认 false → 有冲突即 409,见端点)。 """ cards: list[CharacterCardView] = Field(min_length=1, description="待入库的角色卡(至少 1 张)") acknowledge_conflicts: bool = Field( default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库" ) class IngestConflictView(BaseModel): """入库预检检出的单条 continuity 冲突(贴 ww_agents.Conflict 五类)。""" type: str where: str refs: list[str] = Field(default_factory=list) suggestion: str class CharacterIngestResponse(BaseModel): """角色入库结果(201):写入的角色 + 被白名单丢弃的越权表(审计)。""" created: list[str] = Field(default_factory=list, description="写入的角色名(按入参顺序)") rejected_tables: list[str] = Field( default_factory=list, description="被权限白名单丢弃的越权写表名(审计;正常为空)" ) # ---- 读端点:设定库 Codex(角色 / 世界观全量列表)---- class CharacterListResponse(BaseModel): """GET /projects/:id/characters:已入库角色全量列表(设定库 Codex 真源)。 DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 character_repo): `{"items":[...]}`→list、`{"text":...}`→str;tags/relations 直落 list。 """ characters: list[CharacterCardView] = Field(default_factory=list) class WorldEntityListResponse(BaseModel): """GET /projects/:id/world_entities:已入库世界观实体全量列表(设定库 Codex 真源)。 DB JSONB dict 列 `{"rules":[...]}`→裸 list(worldbuilder 形变的逆向)。 """ world_entities: list[WorldEntityCardView] = Field(default_factory=list) # ---- 读端点:规则列表 + 技能库(T5.6 前端)---- class RuleListResponse(BaseModel): """GET /projects/:id/rules:规则列表(规则页)。""" rules: list[RuleView] = Field(default_factory=list) class SkillView(BaseModel): """单个 skill 的声明式视图(技能库 UI)。""" name: str scope: str = Field(description="builtin / custom / community") tier: str = Field(description="能力档位(writer / analyst / light)") reads: list[str] = Field(default_factory=list) writes: list[str] = Field(default_factory=list) genre: str | None = None class SkillListResponse(BaseModel): """GET /skills:技能库列表(按 name 升序)。""" skills: list[SkillView] = Field(default_factory=list)