fix(backend): API 文本输入加 max_length + 请求体大小中间件(CR-H9)

This commit is contained in:
Yaojia Wang
2026-07-08 10:53:44 +02:00
parent 26f10d023d
commit e1a24a2c2c
13 changed files with 223 additions and 30 deletions

View File

@@ -182,6 +182,8 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-07-08] @backend**✅ QA-CR 后端整改 5 项TDD逐 fix 提交)**。**CR-C2**`chain_runner.run_chain_job``graph.ainvoke` 传显式 top-level `recursion_limit=count*NODES_PER_CHAPTER+HEADROOM`(防御硬化,命名常量)。**CR-H1**Kimi OAuth 设备轮询移出 DB 会话——新增 `_poll_for_token`(纯 HTTP)/`_make_persist_work`/`_run_kimi_oauth_poll`,仅最终持久化经 `run_job` 开短会话(防连接池饥饿;删 `_make_poll_work`)。**CR-H2**:请求级 httpx 客户端改**生成器依赖** `_http_client``async with ... yield`,请求结束 aclose 修泄漏);`_default_http_client` 保留供后台裸调E2E + 端点测试 override 键跟随改 `_http_client`。**CR-H7**:冻结 `TOOLBOX` 为只读 `MappingProxyType``_TOOLBOX_BY_KEY` 私有底 dict + `Final[Mapping]`,仿 SPECS。**CR-H9**:用户请求字符串字段补 `max_length`providers/generation/projects/style/foreshadow/rules/templates命名常量无魔数样本列表长度+每条双上界)+ 新错误码 `ErrorCode.PAYLOAD_TOO_LARGE`(413) + 应用级 `body_size_limit_middleware`(2 MiB按 Content-Length 拦截自建 413 信封)`main.py` 注册。**门禁全绿**ruff/format 干净 · mypy **229 文件**0 错)· pytest **928**+23recursion_limit 1 / poll-no-session 1 / http-gen 1 / toolbox-readonly 1 / input-size 19[17 参数化 + samples 1 + body413 1])· **无 DDL/无迁移**。记 contracts C3CR-H9新增 `maxLength` + `PAYLOAD_TOO_LARGE`)。→ **@frontend`pnpm gen:api`**(请求字段现有 `maxLength`;本 cluster 未代跑)。守不变量 #3/#8/#9
- [2026-07-06] @backend/@llm/@frontend**✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest``role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系在场时校验非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count缺省退回单一 `role`+`count`。**§0 归属纠偏**`build_character_gen_context`+`run_character_gen``ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix``character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts``sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**schema 校验 7 例总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**+11· 无迁移(未动模型,未跑 alembic前端 gen:api/lint/typecheck 干净 · vitest **543**+6· coverage 95%cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。 - [2026-07-06] @backend/@llm/@frontend**✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest``role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系在场时校验非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count缺省退回单一 `role`+`count`。**§0 归属纠偏**`build_character_gen_context`+`run_character_gen``ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix``character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts``sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**schema 校验 7 例总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**+11· 无迁移(未动模型,未跑 alembic前端 gen:api/lint/typecheck 干净 · vitest **543**+6· coverage 95%cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。
- [2026-07-06] @llm/@db/@backend/@frontend**✅ 灵感③ 人物塑造 advisory 审develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**D2只做人物塑造、不做氛围氛围噪声大折进 style/pace。**新 SPEC** `characterization_spec``tier="analyst"` 不写 model`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`**计数三处 22→23**`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`**JSONB nullable**,迁移 `f6a7b8c9d0e1`nullable 免 backfill。**契约变更** `ReviewHistoryItem`+`ReviewView``characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧motive/appearance 已入 CharacterCard 契约,作客观锚点)。 - [2026-07-06] @llm/@db/@backend/@frontend**✅ 灵感③ 人物塑造 advisory 审develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**D2只做人物塑造、不做氛围氛围噪声大折进 style/pace。**新 SPEC** `characterization_spec``tier="analyst"` 不写 model`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`**计数三处 22→23**`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`**JSONB nullable**,迁移 `f6a7b8c9d0e1`nullable 免 backfill。**契约变更** `ReviewHistoryItem`+`ReviewView``characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧motive/appearance 已入 CharacterCard 契约,作客观锚点)。

View File

@@ -0,0 +1,109 @@
"""CR-H9用户请求文本字段 max_length 上界 + 应用级请求体大小中间件(防 DoS/成本)。
- 单元:各请求 schema 的字符串字段在 cap 处可构造、cap+1 触发 ValidationError含样本列表长度
与列表项两处上界——最大 DoS 面)。
- 集成(无 pg超大请求体经中间件直接 413不入路由/DB
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any
import pytest
from cryptography.fernet import Fernet
from pydantic import BaseModel, ValidationError
# create_app() 于 import ww_api.main 时构建,需 CREDENTIAL_ENC_KEY——先于任何 main 导入置好。
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.schemas.foreshadow import ForeshadowRegisterRequest
from ww_api.schemas.generation import CharacterGenerateRequest, WorldGenerateRequest
from ww_api.schemas.projects import (
AcceptRequest,
DraftSaveRequest,
ProjectCreateRequest,
ReviewRequest,
)
from ww_api.schemas.providers import (
ProviderCredentialInput,
TierRoutingInput,
)
from ww_api.schemas.providers import (
TestConnectionRequest as _TestConnectionRequest, # 别名避免 pytest 误采集 Test* 类
)
from ww_api.schemas.rules import RuleCreateRequest
from ww_api.schemas.style import RefineRequest, StyleLearnRequest
from ww_api.schemas.templates import TemplateCreateRequest
@dataclass(frozen=True)
class _StrCase:
"""一个字符串字段的上界用例cap 处可构造、cap+1 触发 ValidationError。"""
schema: type[BaseModel]
field_name: str
cap: int
case_id: str
base: dict[str, Any] = field(default_factory=dict) # 该 schema 其余必填字段
_STR_CASES: list[_StrCase] = [
_StrCase(ProviderCredentialInput, "api_key", 512, "api_key<=512", {"provider": "p"}),
_StrCase(ProviderCredentialInput, "provider", 64, "provider<=64", {"api_key": "k"}),
_StrCase(TierRoutingInput, "model", 128, "model<=128", {"tier": "writer", "provider": "p"}),
_StrCase(
TierRoutingInput, "provider", 64, "routing-provider<=64", {"tier": "writer", "model": "m"}
),
_StrCase(_TestConnectionRequest, "provider", 64, "test-provider<=64"),
_StrCase(WorldGenerateRequest, "brief", 10_000, "world-brief<=10000"),
_StrCase(CharacterGenerateRequest, "brief", 10_000, "char-brief<=10000"),
_StrCase(ProjectCreateRequest, "title", 200, "title<=200"),
_StrCase(AcceptRequest, "final_text", 200_000, "final_text<=200000"),
_StrCase(DraftSaveRequest, "text", 200_000, "draft-text<=200000"),
_StrCase(ReviewRequest, "draft", 200_000, "review-draft<=200000"),
_StrCase(RefineRequest, "segment", 20_000, "segment<=20000"),
_StrCase(ForeshadowRegisterRequest, "code", 100, "code<=100", {"title": "t"}),
_StrCase(ForeshadowRegisterRequest, "title", 500, "foreshadow-title<=500", {"code": "c"}),
_StrCase(RuleCreateRequest, "content", 10_000, "rule-content<=10000", {"level": "project"}),
_StrCase(TemplateCreateRequest, "title", 200, "template-title<=200", {"body": "b"}),
_StrCase(TemplateCreateRequest, "body", 20_000, "template-body<=20000", {"title": "t"}),
]
@pytest.mark.parametrize("case", _STR_CASES, ids=[c.case_id for c in _STR_CASES])
def test_request_str_fields_reject_over_max_length(case: _StrCase) -> None:
# Arrange / Act — 长度 == cap构造成功。
case.schema(**{**case.base, case.field_name: "x" * case.cap})
# Assert — 长度 == cap+1超界即 ValidationError。
with pytest.raises(ValidationError):
case.schema(**{**case.base, case.field_name: "x" * (case.cap + 1)})
def test_style_learn_samples_item_and_list_caps() -> None:
# 单条样本 == 20_000 字符 OK20_001 → ValidationError最大 DoS 面:每条正文都设上界)。
StyleLearnRequest(samples=["x" * 20_000])
with pytest.raises(ValidationError):
StyleLearnRequest(samples=["x" * 20_001])
# 列表 == 20 条 OK21 条 → ValidationError同时约束列表长度
StyleLearnRequest(samples=["s"] * 20)
with pytest.raises(ValidationError):
StyleLearnRequest(samples=["s"] * 21)
def test_oversized_request_body_rejected_413() -> None:
"""超大请求体经 body-size 中间件直接 413不入路由/DB
不以 context manager 进入 TestClient → 不跑 lifespan/seed → 不需 Postgres。中间件在
路由前拦截,故请求根本不触达任何 DB。
"""
from fastapi.testclient import TestClient
from ww_api.main import create_app
from ww_api.middleware import MAX_BODY_BYTES
client = TestClient(create_app())
resp = client.post("/projects", content=b"x" * (MAX_BODY_BYTES + 1))
assert resp.status_code == 413
assert resp.json()["error"]["code"] == "PAYLOAD_TOO_LARGE"

View File

@@ -14,7 +14,11 @@ from ww_db import get_sessionmaker
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
from ww_api.logging_config import configure_logging, get_logger from ww_api.logging_config import configure_logging, get_logger
from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware from ww_api.middleware import (
REQUEST_ID_HEADER,
body_size_limit_middleware,
request_id_middleware,
)
from ww_api.routers import ( from ww_api.routers import (
chain, chain,
foreshadow, foreshadow,
@@ -74,6 +78,8 @@ def create_app() -> FastAPI:
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER], allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
) )
app.middleware("http")(request_id_middleware) app.middleware("http")(request_id_middleware)
# 请求体大小守卫CR-H9在路由前拦截超大 body直接 413。
app.middleware("http")(body_size_limit_middleware)
@app.exception_handler(AppError) @app.exception_handler(AppError)
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse: async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:

View File

@@ -12,10 +12,14 @@ from collections.abc import Awaitable, Callable
import structlog import structlog
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import JSONResponse, Response
from ww_shared import ErrorBody, ErrorCode, ErrorEnvelope
REQUEST_ID_HEADER = "x-request-id" REQUEST_ID_HEADER = "x-request-id"
# 请求体大小上限2 MiB超过即 413防超大 body 撑爆内存/成本CR-H9
MAX_BODY_BYTES = 2 * 1024 * 1024
# 放行安全字符集(字母数字 . _ -1128 位):覆盖 uuid4().hex、常见 trace id 与客户端 # 放行安全字符集(字母数字 . _ -1128 位):覆盖 uuid4().hex、常见 trace id 与客户端
# 自定义短 id含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经 # 自定义短 id含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
# 校验的客户端值原样写进日志/响应头(日志注入防护)。 # 校验的客户端值原样写进日志/响应头(日志注入防护)。
@@ -38,3 +42,29 @@ async def request_id_middleware(
response = await call_next(request) response = await call_next(request)
response.headers[REQUEST_ID_HEADER] = request_id response.headers[REQUEST_ID_HEADER] = request_id
return response return response
async def body_size_limit_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""按 `Content-Length` 拦截超大请求体,超限直接 413CR-H9
用户中间件跑在 FastAPI 的 AppError 处理器**之外**,故必须自建错误信封响应(不能靠抛
AppError。分块编码无 `Content-Length`)无法在此拦截——单用户原型可接受的限制。
"""
content_length = request.headers.get("content-length")
if content_length is not None:
try:
size = int(content_length)
except ValueError:
size = 0 # 非法头防御性视作 0交由下游正常解析/报错。
if size > MAX_BODY_BYTES:
envelope = ErrorEnvelope(
error=ErrorBody(
code=ErrorCode.PAYLOAD_TOO_LARGE,
message="请求体过大",
request_id=_sanitize_request_id(request.headers.get(REQUEST_ID_HEADER)),
)
)
return JSONResponse(status_code=413, content=envelope.model_dump())
return await call_next(request)

View File

@@ -11,12 +11,18 @@ from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# 请求字符串字段长度上界CR-H9防超长入参 DoS/成本)。代号=短标识;标题=一句话。
_CODE_MAX = 100
_TITLE_MAX = 500
class ForeshadowRegisterRequest(BaseModel): class ForeshadowRegisterRequest(BaseModel):
"""POST /projects/:id/foreshadow作者显式登记一条伏笔status 落 OPEN""" """POST /projects/:id/foreshadow作者显式登记一条伏笔status 落 OPEN"""
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一") code: str = Field(
title: str = Field(min_length=1, description="伏笔标题/一句话描述") min_length=1, max_length=_CODE_MAX, description="伏笔代号,`(project_id, code)` 唯一"
)
title: str = Field(min_length=1, max_length=_TITLE_MAX, description="伏笔标题/一句话描述")
planted_at: int | None = Field(default=None, description="埋设章号") planted_at: int | None = Field(default=None, description="埋设章号")
content: str | None = Field(default=None, description="伏笔正文/线索") content: str | None = Field(default=None, description="伏笔正文/线索")
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章") expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")

View File

@@ -19,6 +19,9 @@ from ww_api.schemas.rules import RuleView
# 批量生成数量上限(防一次性巨量调用;原型保守值)。 # 批量生成数量上限(防一次性巨量调用;原型保守值)。
MAX_GENERATE_COUNT = 12 MAX_GENERATE_COUNT = 12
# 一句话需求文本上界CR-H9防超长入参撑爆上下文/成本)。
_BRIEF_MAX = 10_000
# ---- 世界观生成(预览)---- # ---- 世界观生成(预览)----
@@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12
class WorldGenerateRequest(BaseModel): class WorldGenerateRequest(BaseModel):
"""POST /projects/:id/world/generate据需求生成世界观实体预览""" """POST /projects/:id/world/generate据需求生成世界观实体预览"""
brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)") brief: str = Field(
min_length=1, max_length=_BRIEF_MAX, description="世界观生成需求(题材/设定方向/约束)"
)
class WorldEntityCardView(BaseModel): class WorldEntityCardView(BaseModel):
@@ -54,7 +59,7 @@ class CharacterGenerateRequest(BaseModel):
单一 `role` + `count` 语义。 单一 `role` + `count` 语义。
""" """
brief: str = Field(min_length=1, description="角色生成需求") brief: str = Field(min_length=1, max_length=_BRIEF_MAX, description="角色生成需求")
count: int = Field( count: int = Field(
default=1, default=1,
ge=1, ge=1,

View File

@@ -11,11 +11,15 @@ from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# 请求文本长度上界CR-H9防超长入参 DoS/成本)。标题=标签级;章正文=整章级。
_TITLE_MAX = 200
_CHAPTER_TEXT_MAX = 200_000
class ProjectCreateRequest(BaseModel): class ProjectCreateRequest(BaseModel):
"""POST /projects立项向导字段owner_id 由后端补 stub不入参""" """POST /projects立项向导字段owner_id 由后端补 stub不入参"""
title: str = Field(min_length=1) title: str = Field(min_length=1, max_length=_TITLE_MAX)
genre: str | None = None genre: str | None = None
logline: str | None = None logline: str | None = None
premise: str | None = None premise: str | None = None
@@ -120,9 +124,9 @@ class DraftStreamRequest(BaseModel):
directive: str | None = None directive: str | None = None
# 整章重写输入上界(守 DoS/成本CR-H9 方向):意见短、整章草稿大。 # 整章重写输入上界(守 DoS/成本CR-H9 方向):意见短、整章草稿大复用章正文上界DRY
_REWRITE_FEEDBACK_MAX = 4000 _REWRITE_FEEDBACK_MAX = 4000
_REWRITE_DRAFT_MAX = 200_000 _REWRITE_DRAFT_MAX = _CHAPTER_TEXT_MAX
class RewriteStreamRequest(BaseModel): class RewriteStreamRequest(BaseModel):
@@ -140,7 +144,7 @@ class RewriteStreamRequest(BaseModel):
class DraftSaveRequest(BaseModel): class DraftSaveRequest(BaseModel):
"""PUT /projects/:id/chapters/:no/draft自动保存草稿正文。""" """PUT /projects/:id/chapters/:no/draft自动保存草稿正文。"""
text: str text: str = Field(max_length=_CHAPTER_TEXT_MAX)
class DraftResponse(BaseModel): class DraftResponse(BaseModel):
@@ -179,7 +183,7 @@ class ReviewRequest(BaseModel):
不传 `draft` 时端点回退到已保存的草稿chapter_repo 不传 `draft` 时端点回退到已保存的草稿chapter_repo
""" """
draft: str | None = None draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX)
class ReviewConflictView(BaseModel): class ReviewConflictView(BaseModel):
@@ -237,7 +241,9 @@ class ConflictDecision(BaseModel):
class AcceptRequest(BaseModel): class AcceptRequest(BaseModel):
"""POST /projects/:id/chapters/:no/accept裁决清单 + 可能改过的终稿。""" """POST /projects/:id/chapters/:no/accept裁决清单 + 可能改过的终稿。"""
final_text: str = Field(min_length=1, description="作者裁决/改稿后的最终验收文本") final_text: str = Field(
min_length=1, max_length=_CHAPTER_TEXT_MAX, description="作者裁决/改稿后的最终验收文本"
)
decisions: list[ConflictDecision] = Field( decisions: list[ConflictDecision] = Field(
default_factory=list, description="对最近审稿每个冲突的裁决每冲突必有其一R5" default_factory=list, description="对最近审稿每个冲突的裁决每冲突必有其一R5"
) )

View File

@@ -8,6 +8,11 @@ from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ww_llm_gateway.types import Tier from ww_llm_gateway.types import Tier
# 请求字符串字段长度上界CR-H9防超长入参 DoS/成本)。
_PROVIDER_MAX = 64
_MODEL_MAX = 128
_API_KEY_MAX = 512
class ProviderView(BaseModel): class ProviderView(BaseModel):
"""已配置提供商(掩码视图)。""" """已配置提供商(掩码视图)。"""
@@ -35,8 +40,8 @@ class ProvidersResponse(BaseModel):
class ProviderCredentialInput(BaseModel): class ProviderCredentialInput(BaseModel):
"""单条提供商凭据写入。""" """单条提供商凭据写入。"""
provider: str = Field(min_length=1) provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
api_key: str = Field(min_length=1) # 明文入站,加密入库,绝不回显 api_key: str = Field(min_length=1, max_length=_API_KEY_MAX) # 明文入站,加密入库,绝不回显
class TierRoutingInput(BaseModel): class TierRoutingInput(BaseModel):
@@ -44,8 +49,8 @@ class TierRoutingInput(BaseModel):
# tier 限定已知档位 writer/analyst/light未知档位 → 422QA MEDIUM原接受任意字符串 # tier 限定已知档位 writer/analyst/light未知档位 → 422QA MEDIUM原接受任意字符串
tier: Tier tier: Tier
provider: str = Field(min_length=1) provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
model: str = Field(min_length=1) model: str = Field(min_length=1, max_length=_MODEL_MAX)
fallback: list[str] = Field(default_factory=list) fallback: list[str] = Field(default_factory=list)
@@ -59,7 +64,7 @@ class ProvidersUpsertRequest(BaseModel):
class TestConnectionRequest(BaseModel): class TestConnectionRequest(BaseModel):
"""POST /test最小探测请求。""" """POST /test最小探测请求。"""
provider: str = Field(min_length=1) provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
class CapabilitiesView(BaseModel): class CapabilitiesView(BaseModel):

View File

@@ -14,8 +14,13 @@ from pydantic import BaseModel, Field, StringConstraints
RuleLevel = Literal["global", "genre", "style", "project"] RuleLevel = Literal["global", "genre", "style", "project"]
# 规则正文长度上界CR-H9防超长入参 DoS/成本)。
_RULE_CONTENT_MAX = 10_000
# 先 strip 再校验长度:纯空白内容(" "strip 后为空 → 422QA MEDIUM此前被接受入库 # 先 strip 再校验长度:纯空白内容(" "strip 后为空 → 422QA MEDIUM此前被接受入库
RuleContent = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] RuleContent = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_RULE_CONTENT_MAX)
]
class RuleCreateRequest(BaseModel): class RuleCreateRequest(BaseModel):

View File

@@ -7,15 +7,26 @@ snake_case命名契约见 memory/gotchas。前端经 OpenAPI→TS 客
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Literal from typing import Annotated, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, StringConstraints
# 请求文本长度上界CR-H9防超长入参空跑 LLM/DoS。选段/样本 20k 字符,样本列表 ≤20 条。
_SEGMENT_MAX = 20_000
_SAMPLE_MAX = 20_000
_SAMPLES_LIST_MAX = 20
# 预检澄清沿用相同选段上界DRY指令短。
_MAX_CLARIFY_SEGMENT_LEN = _SEGMENT_MAX
_MAX_CLARIFY_INSTRUCTION_LEN = 2000
class StyleLearnRequest(BaseModel): class StyleLearnRequest(BaseModel):
"""学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。""" """学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。"""
samples: list[str] = Field(min_length=1) # 同时约束**列表条数**与**每条样本长度**(最大 DoS 面:多条超长正文)。
samples: list[Annotated[str, StringConstraints(max_length=_SAMPLE_MAX)]] = Field(
min_length=1, max_length=_SAMPLES_LIST_MAX
)
mode: Literal["create", "update"] = "create" mode: Literal["create", "update"] = "create"
@@ -47,7 +58,7 @@ class StyleFingerprintResponse(BaseModel):
class RefineRequest(BaseModel): class RefineRequest(BaseModel):
"""回炉:重写选中段(可选改写指令)。""" """回炉:重写选中段(可选改写指令)。"""
segment: str = Field(min_length=1) segment: str = Field(min_length=1, max_length=_SEGMENT_MAX)
instruction: str | None = None instruction: str | None = None
@@ -58,11 +69,6 @@ class RefineResponse(BaseModel):
refined: str refined: str
# 上界:选段/意见文本长度守卫(防超长入参空跑 LLM预检只看本段无需整章
_MAX_CLARIFY_SEGMENT_LEN = 20000
_MAX_CLARIFY_INSTRUCTION_LEN = 2000
class RefineClarifyRequest(BaseModel): class RefineClarifyRequest(BaseModel):
"""润色预检澄清:选段 + 再沟通意见WFW-9 M1 路线A 两阶段之「问题」阶段)。 """润色预检澄清:选段 + 再沟通意见WFW-9 M1 路线A 两阶段之「问题」阶段)。

View File

@@ -12,15 +12,24 @@ from typing import Annotated
from pydantic import BaseModel, Field, StringConstraints from pydantic import BaseModel, Field, StringConstraints
# 长度上界CR-H9防超长入参 DoS/成本)。标题=标签级;正文=段落级。
_TITLE_MAX = 200
_BODY_MAX = 20_000
# 先去首尾空白再校验长度:纯空白(" "strip 后为空 → min_length=1 不满足 → 422。 # 先去首尾空白再校验长度:纯空白(" "strip 后为空 → min_length=1 不满足 → 422。
NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] NonBlankTitle = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_TITLE_MAX)
]
NonBlankBody = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_BODY_MAX)
]
class TemplateCreateRequest(BaseModel): class TemplateCreateRequest(BaseModel):
"""POST /templates新建一条提示词模板。""" """POST /templates新建一条提示词模板。"""
title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422") title: NonBlankTitle = Field(description="模板标题(非空,纯空白 → 422")
body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text") body: NonBlankBody = Field(description="模板正文(非空,一键填入生成器的 brief/text")
category: str | None = Field(default=None, description="可选分类") category: str | None = Field(default=None, description="可选分类")
tool_key: str | None = Field(default=None, description="可选关联生成器NULL=通用)") tool_key: str | None = Field(default=None, description="可选关联生成器NULL=通用)")

View File

@@ -374,4 +374,6 @@
- [2026-07-07] @backend/@llm/@frontend 立 C-RewriteWFW-8 整章再沟通/重写):新增 **`POST /projects/{project_id}/chapters/{chapter_no}/rewrite`**SSEtext/event-stream复用 token/done/error 帧契约)← `RewriteStreamRequest{feedback:str(1..4000), prior_draft:str(1..200000)}` → 流式重写整章一版。实现:`prompts/rewrite.md` 教条(非 SPEC仿 write_craft 经 `load_prompt` 读盘,`test_prompt_loader` doctrine 排除集加 `rewrite`+ `orchestrator/rewrite_node.py``build_rewrite_request` 纯函数 system=[rewrite教条,stable_core] cache 前缀 / input=近况+当前草稿+作者意见 断点后,守不变量 #9`stream_chapter_rewrite`+ 端点复用 `assemble()` 记忆注入 + `normalize_deltas`。**只读不写库**HITL新版停前端接受才落不变量 #3)、**工具非写节点**(不破坏 章=f(outline,state) 纯函数,不变量 #7)、项目不存在触网关前 404。**无 DDL/无迁移**。门禁绿ruff/mypy(224)/pytest(+test_rewrite_node 5 例)/alembic 无漂移。→ 影响 @frontend(已 `pnpm gen:api``useChapterRewrite` SSE hook + `ChapterRewritePanel` 版本栈 + Workbench「整章重写」入口 - [2026-07-07] @backend/@llm/@frontend 立 C-RewriteWFW-8 整章再沟通/重写):新增 **`POST /projects/{project_id}/chapters/{chapter_no}/rewrite`**SSEtext/event-stream复用 token/done/error 帧契约)← `RewriteStreamRequest{feedback:str(1..4000), prior_draft:str(1..200000)}` → 流式重写整章一版。实现:`prompts/rewrite.md` 教条(非 SPEC仿 write_craft 经 `load_prompt` 读盘,`test_prompt_loader` doctrine 排除集加 `rewrite`+ `orchestrator/rewrite_node.py``build_rewrite_request` 纯函数 system=[rewrite教条,stable_core] cache 前缀 / input=近况+当前草稿+作者意见 断点后,守不变量 #9`stream_chapter_rewrite`+ 端点复用 `assemble()` 记忆注入 + `normalize_deltas`。**只读不写库**HITL新版停前端接受才落不变量 #3)、**工具非写节点**(不破坏 章=f(outline,state) 纯函数,不变量 #7)、项目不存在触网关前 404。**无 DDL/无迁移**。门禁绿ruff/mypy(224)/pytest(+test_rewrite_node 5 例)/alembic 无漂移。→ 影响 @frontend(已 `pnpm gen:api``useChapterRewrite` SSE hook + `ChapterRewritePanel` 版本栈 + Workbench「整章重写」入口
- [2026-07-08] @backend 改 C3CR-H9 输入大小上界 + 请求体守卫):给用户请求字符串字段补 `max_length``Field(...,max_length=)` / `StringConstraints(max_length=)`,保留既有 `min_length`,命名常量无魔数)——`providers.py`{provider≤64,model≤128,api_key≤512}、`generation.py`{brief≤10_000}、`projects.py`{title≤200,final_text/draft/text≤200_000}、`style.py`{segment≤20_000,samples 列表≤20 且每条≤20_000}、`foreshadow.py`{code≤100,title≤500}、`rules.py`{content≤10_000}、`templates.py`{`NonBlankStr``NonBlankTitle`(≤200)+`NonBlankBody`(≤20_000)}`ProjectPlanGenerateRequest`/`RewriteStreamRequest`/`RefineClarifyRequest` 早前已有上界,未动。**新增错误码** `ErrorCode.PAYLOAD_TOO_LARGE`(413)`ww_shared.errors``ErrorBody.code` 为裸 str → **OpenAPI schema 不变**+ 应用级 `body_size_limit_middleware``apps/api/ww_api/middleware.py``MAX_BODY_BYTES=2 MiB`,按 `Content-Length` 拦超大 body 直接自建 413 信封——用户中间件跑在 AppError 处理器外chunked 无 Content-Length 为原型可接受限制)+ `main.py``request_id_middleware` 后注册。**无 DDL/无迁移**。超界 → 422字段 ValidationError/ 413整体 body。→ 影响 @frontend:请求字段现有 `maxLength`,须 **`pnpm gen:api`** 重生成 TS 客户端(**本 cluster 不代跑**)。
- [2026-07-08] @backend/@llm/@frontend 立 C-ClarifyWFW-9 M1 refine 侧 AI 反问澄清路线A 两阶段):新增 **`POST /projects/{project_id}/chapters/{chapter_no}/refine/clarify`****非流式** JSON 预检)← `RefineClarifyRequest{segment:str(1..20000), instruction:str(默认"",0..2000)}`**`ClarifyDecision`**{need_clarification:bool, questions:[ClarifyQuestion{question, options:[ClarifyOption{label,value}](0..4), allow_free_text:bool}](0或1,v1硬上限1问), verification:str|null}。tier=**analyst**,结构化输出(instructor)**只读不写库**(末尾 commit 仅记 usage_ledger不变量 #3);判别/校验失败**确定性回退 need_clarification=false**(放行)。`ClarifyDecision` 定义在 `ww_agents`(供 producer+端点共用),注册 `clarify_refine_spec`(SPECS #24)+SCHEMA_CATALOG+金标准。**既有 `POST .../refine`(RefineRequest/RefineResponse)完全未改**。→ 影响 @frontend(已 gen:api`useClarify` 映射 snake→VM + `ChoiceChips` 选项芯片 + `RefinePanel` 门控预检:意见<10字或点按钮预检needClarification 则渲染选项答案 foldClarifications 折进 instruction走既有 refine)。** DDL/无迁移**。门禁绿后端 ruff/mypy227/pytest900/alembic前端 tsc/lint/vitest654/build/cov95.36%。M2(rewrite 侧两阶段)/M3(UX+E2E) 待办 - [2026-07-08] @backend/@llm/@frontend 立 C-ClarifyWFW-9 M1 refine 侧 AI 反问澄清路线A 两阶段):新增 **`POST /projects/{project_id}/chapters/{chapter_no}/refine/clarify`****非流式** JSON 预检)← `RefineClarifyRequest{segment:str(1..20000), instruction:str(默认"",0..2000)}`**`ClarifyDecision`**{need_clarification:bool, questions:[ClarifyQuestion{question, options:[ClarifyOption{label,value}](0..4), allow_free_text:bool}](0或1,v1硬上限1问), verification:str|null}。tier=**analyst**,结构化输出(instructor)**只读不写库**(末尾 commit 仅记 usage_ledger不变量 #3);判别/校验失败**确定性回退 need_clarification=false**(放行)。`ClarifyDecision` 定义在 `ww_agents`(供 producer+端点共用),注册 `clarify_refine_spec`(SPECS #24)+SCHEMA_CATALOG+金标准。**既有 `POST .../refine`(RefineRequest/RefineResponse)完全未改**。→ 影响 @frontend(已 gen:api`useClarify` 映射 snake→VM + `ChoiceChips` 选项芯片 + `RefinePanel` 门控预检:意见<10字或点按钮预检needClarification 则渲染选项答案 foldClarifications 折进 instruction走既有 refine)。** DDL/无迁移**。门禁绿后端 ruff/mypy227/pytest900/alembic前端 tsc/lint/vitest654/build/cov95.36%。M2(rewrite 侧两阶段)/M3(UX+E2E) 待办

View File

@@ -12,6 +12,7 @@ class ErrorCode(StrEnum):
CONFLICT_UNRESOLVED = "CONFLICT_UNRESOLVED" # 未决冲突禁验收 CONFLICT_UNRESOLVED = "CONFLICT_UNRESOLVED" # 未决冲突禁验收
LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # 回退耗尽 LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # 回退耗尽
RATE_LIMITED = "RATE_LIMITED" RATE_LIMITED = "RATE_LIMITED"
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE" # 请求体超过大小上限body-size 中间件)
INTERNAL = "INTERNAL" INTERNAL = "INTERNAL"
@@ -23,6 +24,7 @@ HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.CONFLICT_UNRESOLVED: 409, ErrorCode.CONFLICT_UNRESOLVED: 409,
ErrorCode.LLM_UNAVAILABLE: 503, ErrorCode.LLM_UNAVAILABLE: 503,
ErrorCode.RATE_LIMITED: 429, ErrorCode.RATE_LIMITED: 429,
ErrorCode.PAYLOAD_TOO_LARGE: 413,
ErrorCode.INTERNAL: 500, ErrorCode.INTERNAL: 500,
} }