在 C2 扩(AC-1) `ai_messages` 表上落 AC-2 后端(计划 §3):
- repo `SqlAiMessageRepo`:批 append(seq=批内 0 基位置、同 thread_id、只 flush)
+ `list_for_chapter` newest-first(created_at DESC, seq DESC)、chapter_no 过滤含
项目级 NULL union、可选 kind、limit/offset。
- schemas `ai_messages.py`:批级 append 信封 + Literal 枚举权威(kind/role)+ content
200k 上限 + meta 序列化兜底。
- 端点 `POST/GET /projects/{id}/ai-messages`(tag ai-messages,已注册):显式记录写入,
五个生成端点保持只读不变(守 #3);项目 404、校验 422、POST 端点提交。
- 注入缝 `get_ai_message_repo`。
TDD:repo 真 pg 单测(seq/定序/union/分页/flush-only)9 passed;端点集成测试
(round-trip/union/kind/404/422×5/commit 边界)12 passed。门禁全绿:ruff/format/mypy
238 files/alembic check 无漂移/pytest 992 passed;新模块 cov router+schemas 100% / repo 97%。
75 lines
2.7 KiB
Python
75 lines
2.7 KiB
Python
"""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]
|