diff --git a/PROGRESS.md b/PROGRESS.md index d6216ca..217a45c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -182,6 +182,8 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [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**(+23:recursion_limit 1 / poll-no-session 1 / http-gen 1 / toolbox-readonly 1 / input-size 19[17 参数化 + samples 1 + body413 1])· **无 DDL/无迁移**。记 contracts C3(CR-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] @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 契约,作客观锚点)。 diff --git a/apps/api/tests/test_input_size_limits.py b/apps/api/tests/test_input_size_limits.py new file mode 100644 index 0000000..9193b38 --- /dev/null +++ b/apps/api/tests/test_input_size_limits.py @@ -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 字符 OK;20_001 → ValidationError(最大 DoS 面:每条正文都设上界)。 + StyleLearnRequest(samples=["x" * 20_000]) + with pytest.raises(ValidationError): + StyleLearnRequest(samples=["x" * 20_001]) + # 列表 == 20 条 OK;21 条 → 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" diff --git a/apps/api/ww_api/main.py b/apps/api/ww_api/main.py index e2e78c1..cd4b5dc 100644 --- a/apps/api/ww_api/main.py +++ b/apps/api/ww_api/main.py @@ -14,7 +14,11 @@ from ww_db import get_sessionmaker from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope 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 ( chain, foreshadow, @@ -74,6 +78,8 @@ def create_app() -> FastAPI: allow_headers=["content-type", "authorization", REQUEST_ID_HEADER], ) app.middleware("http")(request_id_middleware) + # 请求体大小守卫(CR-H9):在路由前拦截超大 body,直接 413。 + app.middleware("http")(body_size_limit_middleware) @app.exception_handler(AppError) async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse: diff --git a/apps/api/ww_api/middleware.py b/apps/api/ww_api/middleware.py index 23102db..8fb5f3c 100644 --- a/apps/api/ww_api/middleware.py +++ b/apps/api/ww_api/middleware.py @@ -12,10 +12,14 @@ from collections.abc import Awaitable, Callable import structlog 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" +# 请求体大小上限(2 MiB):超过即 413,防超大 body 撑爆内存/成本(CR-H9)。 +MAX_BODY_BYTES = 2 * 1024 * 1024 + # 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 uuid4().hex、常见 trace id 与客户端 # 自定义短 id;含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经 # 校验的客户端值原样写进日志/响应头(日志注入防护)。 @@ -38,3 +42,29 @@ async def request_id_middleware( response = await call_next(request) response.headers[REQUEST_ID_HEADER] = request_id return response + + +async def body_size_limit_middleware( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +) -> Response: + """按 `Content-Length` 拦截超大请求体,超限直接 413(CR-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) diff --git a/apps/api/ww_api/schemas/foreshadow.py b/apps/api/ww_api/schemas/foreshadow.py index f221514..7626835 100644 --- a/apps/api/ww_api/schemas/foreshadow.py +++ b/apps/api/ww_api/schemas/foreshadow.py @@ -11,12 +11,18 @@ from typing import Any from pydantic import BaseModel, Field +# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。代号=短标识;标题=一句话。 +_CODE_MAX = 100 +_TITLE_MAX = 500 + class ForeshadowRegisterRequest(BaseModel): """POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。""" - code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一") - title: str = Field(min_length=1, description="伏笔标题/一句话描述") + code: str = Field( + 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="埋设章号") content: str | None = Field(default=None, description="伏笔正文/线索") expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章") diff --git a/apps/api/ww_api/schemas/generation.py b/apps/api/ww_api/schemas/generation.py index d527165..d78c8d4 100644 --- a/apps/api/ww_api/schemas/generation.py +++ b/apps/api/ww_api/schemas/generation.py @@ -19,6 +19,9 @@ from ww_api.schemas.rules import RuleView # 批量生成数量上限(防一次性巨量调用;原型保守值)。 MAX_GENERATE_COUNT = 12 +# 一句话需求文本上界(CR-H9:防超长入参撑爆上下文/成本)。 +_BRIEF_MAX = 10_000 + # ---- 世界观生成(预览)---- @@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12 class WorldGenerateRequest(BaseModel): """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): @@ -54,7 +59,7 @@ class CharacterGenerateRequest(BaseModel): 单一 `role` + `count` 语义。 """ - brief: str = Field(min_length=1, description="角色生成需求") + brief: str = Field(min_length=1, max_length=_BRIEF_MAX, description="角色生成需求") count: int = Field( default=1, ge=1, diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index d451907..a5f6aa3 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -11,11 +11,15 @@ from typing import Any, Literal from pydantic import BaseModel, Field +# 请求文本长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;章正文=整章级。 +_TITLE_MAX = 200 +_CHAPTER_TEXT_MAX = 200_000 + class ProjectCreateRequest(BaseModel): """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 logline: str | None = None premise: str | None = None @@ -120,9 +124,9 @@ class DraftStreamRequest(BaseModel): directive: str | None = None -# 整章重写输入上界(守 DoS/成本,CR-H9 方向):意见短、整章草稿大。 +# 整章重写输入上界(守 DoS/成本,CR-H9 方向):意见短、整章草稿大(复用章正文上界,DRY)。 _REWRITE_FEEDBACK_MAX = 4000 -_REWRITE_DRAFT_MAX = 200_000 +_REWRITE_DRAFT_MAX = _CHAPTER_TEXT_MAX class RewriteStreamRequest(BaseModel): @@ -140,7 +144,7 @@ class RewriteStreamRequest(BaseModel): class DraftSaveRequest(BaseModel): """PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。""" - text: str + text: str = Field(max_length=_CHAPTER_TEXT_MAX) class DraftResponse(BaseModel): @@ -179,7 +183,7 @@ class ReviewRequest(BaseModel): 不传 `draft` 时端点回退到已保存的草稿(chapter_repo)。 """ - draft: str | None = None + draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX) class ReviewConflictView(BaseModel): @@ -237,7 +241,9 @@ class ConflictDecision(BaseModel): class AcceptRequest(BaseModel): """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( default_factory=list, description="对最近审稿每个冲突的裁决(每冲突必有其一,R5)" ) diff --git a/apps/api/ww_api/schemas/providers.py b/apps/api/ww_api/schemas/providers.py index b428d64..84875f0 100644 --- a/apps/api/ww_api/schemas/providers.py +++ b/apps/api/ww_api/schemas/providers.py @@ -8,6 +8,11 @@ from __future__ import annotations from pydantic import BaseModel, Field from ww_llm_gateway.types import Tier +# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。 +_PROVIDER_MAX = 64 +_MODEL_MAX = 128 +_API_KEY_MAX = 512 + class ProviderView(BaseModel): """已配置提供商(掩码视图)。""" @@ -35,8 +40,8 @@ class ProvidersResponse(BaseModel): class ProviderCredentialInput(BaseModel): """单条提供商凭据写入。""" - provider: str = Field(min_length=1) - api_key: str = Field(min_length=1) # 明文入站,加密入库,绝不回显 + provider: str = Field(min_length=1, max_length=_PROVIDER_MAX) + api_key: str = Field(min_length=1, max_length=_API_KEY_MAX) # 明文入站,加密入库,绝不回显 class TierRoutingInput(BaseModel): @@ -44,8 +49,8 @@ class TierRoutingInput(BaseModel): # tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA MEDIUM:原接受任意字符串)。 tier: Tier - provider: str = Field(min_length=1) - model: str = Field(min_length=1) + provider: str = Field(min_length=1, max_length=_PROVIDER_MAX) + model: str = Field(min_length=1, max_length=_MODEL_MAX) fallback: list[str] = Field(default_factory=list) @@ -59,7 +64,7 @@ class ProvidersUpsertRequest(BaseModel): class TestConnectionRequest(BaseModel): """POST /test:最小探测请求。""" - provider: str = Field(min_length=1) + provider: str = Field(min_length=1, max_length=_PROVIDER_MAX) class CapabilitiesView(BaseModel): diff --git a/apps/api/ww_api/schemas/rules.py b/apps/api/ww_api/schemas/rules.py index a7ab307..d77cca1 100644 --- a/apps/api/ww_api/schemas/rules.py +++ b/apps/api/ww_api/schemas/rules.py @@ -14,8 +14,13 @@ from pydantic import BaseModel, Field, StringConstraints RuleLevel = Literal["global", "genre", "style", "project"] +# 规则正文长度上界(CR-H9:防超长入参 DoS/成本)。 +_RULE_CONTENT_MAX = 10_000 + # 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA 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): diff --git a/apps/api/ww_api/schemas/style.py b/apps/api/ww_api/schemas/style.py index 87465fe..26ad154 100644 --- a/apps/api/ww_api/schemas/style.py +++ b/apps/api/ww_api/schemas/style.py @@ -7,15 +7,26 @@ snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客 from __future__ import annotations 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): """学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。""" - 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" @@ -47,7 +58,7 @@ class StyleFingerprintResponse(BaseModel): class RefineRequest(BaseModel): """回炉:重写选中段(可选改写指令)。""" - segment: str = Field(min_length=1) + segment: str = Field(min_length=1, max_length=_SEGMENT_MAX) instruction: str | None = None @@ -58,11 +69,6 @@ class RefineResponse(BaseModel): refined: str -# 上界:选段/意见文本长度守卫(防超长入参空跑 LLM;预检只看本段,无需整章)。 -_MAX_CLARIFY_SEGMENT_LEN = 20000 -_MAX_CLARIFY_INSTRUCTION_LEN = 2000 - - class RefineClarifyRequest(BaseModel): """润色预检澄清:选段 + 再沟通意见(WFW-9 M1 路线A 两阶段之「问题」阶段)。 diff --git a/apps/api/ww_api/schemas/templates.py b/apps/api/ww_api/schemas/templates.py index 0939d43..ce574b1 100644 --- a/apps/api/ww_api/schemas/templates.py +++ b/apps/api/ww_api/schemas/templates.py @@ -12,15 +12,24 @@ from typing import Annotated from pydantic import BaseModel, Field, StringConstraints +# 长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;正文=段落级。 +_TITLE_MAX = 200 +_BODY_MAX = 20_000 + # 先去首尾空白再校验长度:纯空白(" ")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): """POST /templates:新建一条提示词模板。""" - title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422)") - body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text)") + title: NonBlankTitle = Field(description="模板标题(非空,纯空白 → 422)") + body: NonBlankBody = Field(description="模板正文(非空,一键填入生成器的 brief/text)") category: str | None = Field(default=None, description="可选分类") tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)") diff --git a/memory/contracts.md b/memory/contracts.md index 7060792..8b8d1b5 100644 --- a/memory/contracts.md +++ b/memory/contracts.md @@ -374,4 +374,6 @@ - [2026-07-07] @backend/@llm/@frontend 立 C-Rewrite(WFW-8 整章再沟通/重写):新增 **`POST /projects/{project_id}/chapters/{chapter_no}/rewrite`**(SSE,text/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 改 C3(CR-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-Clarify(WFW-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) 待办。 diff --git a/packages/shared/ww_shared/errors.py b/packages/shared/ww_shared/errors.py index 1d7eb8f..c8aa2c6 100644 --- a/packages/shared/ww_shared/errors.py +++ b/packages/shared/ww_shared/errors.py @@ -12,6 +12,7 @@ class ErrorCode(StrEnum): CONFLICT_UNRESOLVED = "CONFLICT_UNRESOLVED" # 未决冲突禁验收 LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # 回退耗尽 RATE_LIMITED = "RATE_LIMITED" + PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE" # 请求体超过大小上限(body-size 中间件) INTERNAL = "INTERNAL" @@ -23,6 +24,7 @@ HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.CONFLICT_UNRESOLVED: 409, ErrorCode.LLM_UNAVAILABLE: 503, ErrorCode.RATE_LIMITED: 429, + ErrorCode.PAYLOAD_TOO_LARGE: 413, ErrorCode.INTERNAL: 500, }