"""AI 对话(聊天记录)端点的请求/响应 schema(AC-2 / ai-chat-history-plan §3.2)。 snake_case;前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。 `Literal` 是 kind/role 的**枚举权威**(DB 列自由 Text,边界在此收窄):非法 kind/role → 422。 批级字段(thread_id/chapter_no/kind/tool_key)提到请求顶层,`messages` 是本批各条 bubble。 长文进 `content`(有手稿级上限),`meta` 有序列化上界兜底防绕过 content 上限。 """ from __future__ import annotations import json import uuid from datetime import datetime from typing import Annotated, Any, Literal from pydantic import BaseModel, Field, StringConstraints, field_validator # kind/role 的枚举权威(DB 列自由 Text;加新 kind 改这里 + 零迁移)。 AiKind = Literal["refine", "rewrite", "clarify", "continue", "generator"] AiRole = Literal["author", "ai"] # content 上界 = 手稿上限(rewrite 整章文本级);命名常量非魔法数(CR-H9)。 AI_MESSAGE_CONTENT_MAX = 200_000 # meta 序列化上界兜底:防长文绕过 content 上限塞进 meta。 _META_MAX_SERIALIZED = 50_000 # 一次 append 的最大条数(一次交换的多轮 bubble)。 _MAX_TURNS_PER_CALL = 50 class AiMessageInput(BaseModel): """一条待落库 bubble(角色 + 人读文本 + 结构化 meta)。""" role: AiRole content: Annotated[str, StringConstraints(min_length=1, max_length=AI_MESSAGE_CONTENT_MAX)] meta: dict[str, Any] = Field(default_factory=dict) @field_validator("meta") @classmethod def _bound_meta(cls, v: dict[str, Any]) -> dict[str, Any]: if len(json.dumps(v, ensure_ascii=False)) > _META_MAX_SERIALIZED: raise ValueError("meta too large; put long text in content") return v class AiMessageAppendRequest(BaseModel): """POST /projects/{id}/ai-messages:一次交换的一批 bubble(批级字段提顶层)。""" thread_id: uuid.UUID chapter_no: Annotated[int, Field(ge=1)] | None = None # NULL = 项目级(工具箱) kind: AiKind tool_key: str | None = None messages: Annotated[list[AiMessageInput], Field(min_length=1, max_length=_MAX_TURNS_PER_CALL)] class AiMessageView(BaseModel): """聊天留痕视图(append/list 回显;snake_case,镜像 repo view)。""" id: uuid.UUID project_id: uuid.UUID chapter_no: int | None thread_id: uuid.UUID seq: int kind: str tool_key: str | None role: str content: str meta: dict[str, Any] = Field(default_factory=dict) created_at: datetime class AiMessageListResponse(BaseModel): """append/list 信封:`messages` 复数键(repo 约定,非 `items`)。""" messages: list[AiMessageView]