"""项目(立项)与章节草稿的请求/响应 schema(C3 / ARCH §7.2)。 snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。 """ from __future__ import annotations import uuid from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, Field # 请求文本长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;章正文=整章级。 _TITLE_MAX = 200 _CHAPTER_TEXT_MAX = 200_000 _DIRECTIVE_MAX = 20_000 # CR-H9 收尾:本章指令喂 LLM,加上界(正文级 body 中间件仍兜底) class ProjectCreateRequest(BaseModel): """POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。""" title: str = Field(min_length=1, max_length=_TITLE_MAX) genre: str | None = None logline: str | None = None premise: str | None = None theme: str | None = None selling_points: list[str] = Field(default_factory=list) structure: str | None = None # 立项书级常量(灵感④)——free text + 前端预设,无 Literal(Literal 只留 Verdict)。 tone: str | None = None ending_type: str | None = None narrative_pov: str | None = None class ProjectResponse(BaseModel): """项目视图(创建/列表/详情共用)。""" id: uuid.UUID title: str genre: str | None = None logline: str | None = None premise: str | None = None theme: str | None = None selling_points: list[str] = Field(default_factory=list) structure: str | None = None tone: str | None = None ending_type: str | None = None narrative_pov: str | None = None updated_at: datetime | None = Field(default=None, description="项目最近更新时间") pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数") class ProjectListResponse(BaseModel): """GET /projects:项目列表。""" projects: list[ProjectResponse] = Field(default_factory=list) # ---- AI 立项方案生成(灵感⑤;向导阶段 project 未建,纯预览不写库)---- # 请求字段长度上界(CR-H9:防超长向导草稿撑爆上下文/成本)。短字段=标签级,长字段=段落级。 _PLAN_SHORT_MAX = 200 _PLAN_LONG_MAX = 4000 _PLAN_LIST_MAX = 20 class ProjectPlanGenerateRequest(BaseModel): """POST /skills/project-plan/generate:向导草稿种子(不带 project 前缀,立项前 project 未建)。 **种子门控**:`genre` + `logline` 为必给种子(端点校验非空,否则 422)——空向导不出方案, 防 slop。其余字段有则贴合、无则不臆造(序列化进 project_context,仿 `_project_context`)。 全字段带 `max_length` 上界(CR-H9),`brief` = 作者一句话方向(可空)。 """ genre: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="题材(种子)") logline: str | None = Field( default=None, max_length=_PLAN_LONG_MAX, description="一句话故事(种子)" ) title: str | None = Field( default=None, max_length=_PLAN_SHORT_MAX, description="暂定书名(可空)" ) premise: str | None = Field(default=None, max_length=_PLAN_LONG_MAX, description="立意(可空)") theme: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="主题(可空)") structure: str | None = Field( default=None, max_length=_PLAN_SHORT_MAX, description="故事结构(可空)" ) tone: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="基调(可空)") ending_type: str | None = Field( default=None, max_length=_PLAN_SHORT_MAX, description="结局取向(可空)" ) narrative_pov: str | None = Field( default=None, max_length=_PLAN_SHORT_MAX, description="叙事视角(可空)" ) selling_points: list[str] = Field( default_factory=list, max_length=_PLAN_LIST_MAX, description="核心卖点(可空)" ) # `str | None`(非 `str=""`):codegen 渲染为可选字段,前端种子请求可省略(brief 本就可空)。 brief: str | None = Field( default=None, max_length=_PLAN_LONG_MAX, description="作者一句话方向/需求(可空)" ) class ProjectPlanView(BaseModel): """立项方案生成结果(贴 ww_agents.ProjectPlanResult;结构化预览,不入库)。 全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。 """ title_candidates: list[str] = Field(default_factory=list, description="书名候选清单") setting: str = Field(default="", description="时空背景") narrative_structure: str = Field(default="", description="叙事结构") story_core: str = Field(default="", description="故事核心") ending_design: str = Field(default="", description="结局设计") tone: str = Field(default="", description="基调") class DraftStreamRequest(BaseModel): """POST /projects/:id/chapters/:no/draft:本章生成的可选输入(T4-b)。 `directive` = 作者本章指令(覆盖/补充大纲节拍)。临时输入,不持久化、无迁移; 后端只入 volatile(守缓存前缀不变量 #9)。无 body 时退化为纯按大纲生成(向后兼容)。 """ directive: str | None = Field(default=None, max_length=_DIRECTIVE_MAX) # 整章重写输入上界(守 DoS/成本,CR-H9 方向):意见短、整章草稿大(复用章正文上界,DRY)。 _REWRITE_FEEDBACK_MAX = 4000 _REWRITE_DRAFT_MAX = _CHAPTER_TEXT_MAX class RewriteStreamRequest(BaseModel): """POST /projects/:id/chapters/:no/rewrite:整章再沟通/重写。 `feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆 注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile(守缓存前缀不变量 #9)。 新版停前端/草稿,接受才落库(HITL,不变量 #3)。 """ feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX) prior_draft: str = Field(min_length=1, max_length=_REWRITE_DRAFT_MAX) class RewriteClarifyRequest(BaseModel): """整章重写预检澄清:章级意见 + 可选当前草稿(WFW-9 M2 路线A 两阶段之「问题」阶段)。 与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文** (整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景, 可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检, 不整章入 LLM)。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents,端点直接返回, 供前端 gen:api 生成强类型客户端)。 """ feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX) prior_draft: str | None = Field(default=None, max_length=_REWRITE_DRAFT_MAX) class DraftSaveRequest(BaseModel): """PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。""" text: str = Field(max_length=_CHAPTER_TEXT_MAX) class DraftResponse(BaseModel): """草稿保存结果(脱敏:只回元信息 + 长度,不回灌正文以外的衍生)。""" project_id: uuid.UUID chapter_no: int volume: int status: str version: int length: int class DraftView(BaseModel): """GET .../draft:回灌已保存草稿(含正文,供工作台重载编辑器)。 与 PUT 的 `DraftResponse` 同元信息,**额外带 `content`**——读侧需要正文以重建编辑器, 保存侧不回灌正文故不带。`length` 仍按 content 字符数派生。 """ project_id: uuid.UUID chapter_no: int volume: int status: str version: int content: str length: int # ---- 列章(审稿选章枚举)---- class ChapterListItem(BaseModel): """GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记(snake_case)。 数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。 `has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本; `reviewed_at`=最近一次审稿时间(无则 null)。 """ chapter_no: int has_draft: bool = Field(description="是否有非空草稿正文(可审)") accepted: bool = Field(description="是否已验收(存在 accepted 版本)") reviewed_at: datetime | None = Field(default=None, description="最近一次审稿时间;未审为 null") # ---- 审稿(T2.5)---- class ReviewRequest(BaseModel): """POST /projects/:id/chapters/:no/review:可选携带待审草稿正文。 不传 `draft` 时端点回退到已保存的草稿(chapter_repo)。 """ draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX) class ReviewConflictView(BaseModel): """单条一致性冲突(chapter_reviews.conflicts JSONB 子项;形对齐 SSE `conflict` 事件)。 强类型化此前的裸 `dict[str, Any]`,给 TS 客户端真实字段类型。容忍历史/缺省(默认值)。 """ type: str = "" where: str = "" refs: list[str] = Field(default_factory=list) suggestion: str = "" # 一键采纳补丁对(可缺):留痕里带则前端「采纳改法」可把 original→replacement 改入终稿。 original: str | None = None replacement: str | None = None class ReviewHistoryItem(BaseModel): """单条审稿留痕(GET .../reviews 历史项;snake_case)。""" id: uuid.UUID project_id: uuid.UUID chapter_no: int chapter_version: int | None = None conflicts: list[ReviewConflictView] = Field(default_factory=list) foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list) style: dict[str, Any] | None = None pace: dict[str, Any] | None = None # 灵感③ advisory 人物塑造审留痕(issues 列表 dict);仅建议、不阻断验收。 characterization: dict[str, Any] | None = None health_score: int | None = None decisions: dict[str, Any] | None = None class ReviewHistoryResponse(BaseModel): """GET /projects/:id/chapters/:no/reviews:审稿历史(新→旧)。""" reviews: list[ReviewHistoryItem] = Field(default_factory=list) # ---- 验收(T2.4)---- # 每个冲突的裁决:采纳改法 / 忽略 / 手改(R5)。 Verdict = Literal["accept", "ignore", "manual"] class ConflictDecision(BaseModel): """对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。""" conflict_index: int = Field(ge=0, description="冲突在最近审稿 conflicts 列表中的下标") verdict: Verdict = Field(description="采纳改法 / 忽略 / 手改") note: str | None = Field(default=None, description="可选裁决备注(如手改说明)") class AcceptRequest(BaseModel): """POST /projects/:id/chapters/:no/accept:裁决清单 + 可能改过的终稿。""" final_text: str = Field( min_length=1, max_length=_CHAPTER_TEXT_MAX, description="作者裁决/改稿后的最终验收文本" ) decisions: list[ConflictDecision] = Field( default_factory=list, description="对最近审稿每个冲突的裁决(每冲突必有其一,R5)" ) class AcceptResponse(BaseModel): """验收「本次将更新」清单(ARCH §7.2 写回结果)。""" project_id: uuid.UUID chapter_no: int accepted_version: int = Field(description="晋升到的 accepted 版次(max+1)") digest_added: bool = Field(description="是否新增了一行 chapter_digests") decisions_recorded: int = Field(description="本次写回的裁决条数") review_id: uuid.UUID | None = Field(default=None, description="写回裁决的审稿留痕行 id")