fix(backend): API 文本输入加 max_length + 请求体大小中间件(CR-H9)
This commit is contained in:
@@ -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**(+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] @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 契约,作客观锚点)。
|
||||||
|
|||||||
109
apps/api/tests/test_input_size_limits.py
Normal file
109
apps/api/tests/test_input_size_limits.py
Normal 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 字符 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"
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 uuid4().hex、常见 trace id 与客户端
|
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 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` 拦截超大请求体,超限直接 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)
|
||||||
|
|||||||
@@ -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="预期回收窗口起始章")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;未知档位 → 422(QA MEDIUM:原接受任意字符串)。
|
# tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA 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):
|
||||||
|
|||||||
@@ -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 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
# 先 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):
|
class RuleCreateRequest(BaseModel):
|
||||||
|
|||||||
@@ -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 两阶段之「问题」阶段)。
|
||||||
|
|
||||||
|
|||||||
@@ -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=通用)")
|
||||||
|
|
||||||
|
|||||||
@@ -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-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) 待办。
|
- [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) 待办。
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user