feat(backend): 整章再沟通/重写 SSE 端点——喂当前草稿+意见+完整记忆注入流式重写一版

WFW-8 整章级再沟通(后端)。新增 POST /projects/{id}/chapters/{no}/rewrite(SSE):
- rewrite.md 教条(品类无关内核,非 SPEC,仿 write_craft 经 load_prompt 读盘);
- rewrite_node:build_rewrite_request 纯函数(system=[rewrite教条,stable_core] cache 前缀;
  input=近况+当前草稿+作者意见,断点后不入缓存前缀——守不变量 #9)+ stream_chapter_rewrite;
- RewriteStreamRequest{feedback,prior_draft}(带 max_length 上界);端点复用 assemble 记忆注入
  + normalize_deltas SSE,只读不写库(HITL,新版停前端接受才落,不变量 #3),是工具非写节点
  (不破坏 章=f(outline,state) 纯函数,不变量 #7),项目不存在触网关前 404。

门禁绿:ruff / mypy 224 files / pytest(新增 test_rewrite_node 5 例 + 更新 doctrine 一一对应)/
alembic 无漂移。OpenAPI 含新路径 + schema(前端待 gen:api)。
This commit is contained in:
Yaojia Wang
2026-07-08 07:04:42 +02:00
parent 4977c760d8
commit 6ed9c89430
7 changed files with 294 additions and 2 deletions

View File

@@ -37,6 +37,7 @@ from ww_core.orchestrator import (
normalize_deltas,
normalize_review,
stream_chapter_draft,
stream_chapter_rewrite,
)
from ww_db import get_session
from ww_llm_gateway import Gateway
@@ -63,6 +64,7 @@ from ww_api.schemas.projects import (
ReviewHistoryItem,
ReviewHistoryResponse,
ReviewRequest,
RewriteStreamRequest,
)
from ww_api.services.accept_service import (
AcceptOutcome,
@@ -310,6 +312,64 @@ async def stream_draft(
)
@router.post("/{project_id}/chapters/{chapter_no}/rewrite")
async def stream_rewrite(
project_id: uuid.UUID,
chapter_no: int,
request: Request,
repos: MemoryReposDep,
gateway: GatewayDep,
injection_repo: InjectionRepoDep,
project_repo: ProjectRepoDep,
session: Annotated[AsyncSession, Depends(get_session)],
body: RewriteStreamRequest,
) -> StreamingResponse:
"""整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
HITL新版停前端/草稿,接受才落库(不变量 #3rewrite 是工具非写节点,不破坏章
纯函数性(不变量 #7。项目不存在 → 404触网关前 fail-fast同 draftQA C1
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
request_id = getattr(request.state, "request_id", None)
override = await injection_repo.get(project_id, chapter_no)
context = await assemble(repos, project_id, chapter_no, override=override)
log.info(
"rewrite_stream_start",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
stable_core_len=len(context.stable_core),
volatile_len=len(context.volatile),
prior_draft_len=len(body.prior_draft),
feedback_len=len(body.feedback),
)
deltas = stream_chapter_rewrite(
gateway,
stable_core=context.stable_core,
volatile=context.volatile,
prior_draft=body.prior_draft,
feedback=body.feedback,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
async def _frames() -> AsyncIterator[str]:
async for event in normalize_deltas(deltas, request_id=request_id):
yield _encode_sse(event)
try:
await session.commit()
except Exception: # noqa: BLE001 — 流已发完commit 失败不能再改响应;至少记错误。
log.error("sse_commit_failed", request_id=request_id, endpoint="rewrite")
return StreamingResponse(
_frames(),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
@router.put("/{project_id}/chapters/{chapter_no}/draft")
async def save_draft(
project_id: uuid.UUID,

View File

@@ -120,6 +120,23 @@ class DraftStreamRequest(BaseModel):
directive: str | None = None
# 整章重写输入上界(守 DoS/成本CR-H9 方向):意见短、整章草稿大。
_REWRITE_FEEDBACK_MAX = 4000
_REWRITE_DRAFT_MAX = 200_000
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 DraftSaveRequest(BaseModel):
"""PUT /projects/:id/chapters/:no/draft自动保存草稿正文。"""

View File

@@ -58,10 +58,11 @@ def test_specs_registry_len_is_23() -> None:
assert all(key == spec.name for key, spec in SPECS.items())
# 非 SPEC 教条 prompt写章 craft 内核(灵感①)。它是散文教条、不是结构化产物 spec
# 非 SPEC 教条 prompt写章 craft 内核(write_craft灵感①与整章重写内核rewriteWFW-8
# 都是散文教条、不是结构化产物 spec经 load_prompt/load_craft_doctrine 直接读盘用于流式端点,
# 故不入 SPECS/SCHEMA_CATALOG、不进 prompt_hashes 金标准——但仍是 prompts/*.md需在
# md↔spec↔catalog 一一对应校验里显式排除,否则集合等式误报。
_DOCTRINE_STEMS = {"write_craft"}
_DOCTRINE_STEMS = {"write_craft", "rewrite"}
# ---- #2 md ↔ spec ↔ catalog 一一对应(覆盖 style.md / character-gen.md 连字符)----

View File

@@ -0,0 +1,16 @@
# 整章再沟通 / 重写纪律(品类无关内核)
你是作者的写作搭档。作者对**本章当前草稿**不满意,给了**修改意见**。你的任务是**依据作者意见,把整章重写成新的一版**。
## 硬约束
- **只输出重写后的本章正文**不要任何前言、说明、点评、markdown 标题或「以下是重写版」之类的元话语。作者要的是可直接替换正文的成稿。
- **严格遵守上文提供的世界观硬规则、设定、角色、大纲节拍与文风指纹**——重写不是重新发明,人物、设定、既定事实、本章在大纲中的功能都要保持一致,不得与前文冲突。
- **紧扣作者意见**:作者指出的问题(节奏、人物动机、爽点、文风、某段处理等)要在新版里切实改掉;作者要求保留的地方就保留。意见没提到的部分,在保持连贯的前提下沿用原意,不要借机大改。
- 产出一个**完整的章**:有头有尾、篇幅与原章相当(除非作者明确要求增删),不要只给片段或改动摘要。
## 写作要求
- 承接上下文语气与人称,保持叙事视角一致。
- 反注水:少铺垫、多有效信息与冲突推进;对白推动情节,描写为人物与张力服务。
- 若作者意见与既有设定/大纲冲突,以**不破坏世界观一致性**为先,用作者意见能实现的方式落地;无法两全时,优先设定一致性并在正文里自然化解,不要停下来发问。

View File

@@ -0,0 +1,95 @@
"""整章重写节点单测:纯构造 + 转发网关 Delta注入 mock 网关,无图运行时、无 DB
守不变量 ②(只声明 tier=writer/#9rewrite 教条 + stable_core 进缓存前缀,草稿/意见进
断点后 input不入缓存前缀。asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from ww_core.orchestrator import (
build_rewrite_input,
build_rewrite_request,
stream_chapter_rewrite,
)
from ww_llm_gateway.types import Delta, LlmRequest
class _FakeGateway:
"""记录收到的请求并按给定文本吐 Delta。"""
def __init__(self, texts: list[str]) -> None:
self._texts = texts
self.last_req: LlmRequest | None = None
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
self.last_req = req
for text in self._texts:
yield Delta(text=text)
def test_build_rewrite_input_orders_volatile_draft_feedback() -> None:
out = build_rewrite_input(volatile="近况X", prior_draft="草稿Y", feedback="意见Z")
assert "【当前整章草稿】" in out
assert "【作者意见】" in out
# 顺序:近况 → 草稿 → 意见。
assert out.index("近况X") < out.index("草稿Y") < out.index("意见Z")
def test_build_rewrite_input_omits_empty_volatile() -> None:
out = build_rewrite_input(volatile=" ", prior_draft="D", feedback="F")
assert out.startswith("【当前整章草稿】")
assert "D" in out and "F" in out
def test_build_rewrite_request_declares_writer_tier_and_streams() -> None:
req = build_rewrite_request(
stable_core="SC",
volatile="V",
prior_draft="DRAFT",
feedback="FEEDBACK",
user_id=uuid.uuid4(),
project_id=uuid.uuid4(),
)
assert req.tier == "writer" # 不变量 ②
assert req.stream is True
def test_rewrite_cached_prefix_excludes_draft_and_feedback() -> None:
"""不变量 #9草稿/意见是逐轮易变,绝不进 system 缓存前缀,只进断点后 input。"""
req = build_rewrite_request(
stable_core="STABLE_CORE",
volatile="V",
prior_draft="THE_DRAFT_BODY",
feedback="THE_FEEDBACK",
user_id=uuid.uuid4(),
project_id=uuid.uuid4(),
)
cached_prefix = "".join(block.text for block in req.system)
assert all(block.cache for block in req.system)
assert "STABLE_CORE" in cached_prefix
assert "THE_DRAFT_BODY" not in cached_prefix
assert "THE_FEEDBACK" not in cached_prefix
assert "THE_DRAFT_BODY" in req.input
assert "THE_FEEDBACK" in req.input
async def test_stream_chapter_rewrite_forwards_gateway_deltas() -> None:
gateway = _FakeGateway(["新版一", "新版二"])
out = [
delta.text
async for delta in stream_chapter_rewrite(
gateway,
stable_core="SC",
volatile="V",
prior_draft="D",
feedback="F",
user_id=uuid.uuid4(),
project_id=uuid.uuid4(),
)
]
assert out == ["新版一", "新版二"]
assert gateway.last_req is not None
assert gateway.last_req.tier == "writer"

View File

@@ -53,6 +53,11 @@ from .review_node import (
make_review_node,
run_review,
)
from .rewrite_node import (
build_rewrite_input,
build_rewrite_request,
stream_chapter_rewrite,
)
from .sse import (
EVENT_CHARACTERIZATION,
EVENT_CONFLICT,
@@ -150,6 +155,9 @@ __all__ = [
"run_worldbuilder",
"section_event",
"setup_checkpointer",
"build_rewrite_input",
"build_rewrite_request",
"stream_chapter_rewrite",
"stream_chapter_draft",
"style_event",
"token_event",

View File

@@ -0,0 +1,95 @@
"""整章再沟通 / 重写节点ARCH §5.2 writer 行的工具变体)。
把当前整章草稿 + 作者意见流式重写成新一版。与 write 节点的关系(不变量纪律):
- rewrite 是**独立工具,不是写图节点**:吃「当前草稿 + 作者意见」,不参与
`章=f(outline, state, fingerprint)` 纯函数写章(不变量 #7也不写库
HITL新版停前端/草稿,接受才落,不变量 #3
- 与 write 一样:节点逻辑无 LLM 非确定性(藏在网关后),`build_rewrite_request` 是纯函数、
`stream_chapter_rewrite` 注入 mock 网关即可单测。
- 不变量 ②:只传 `tier="writer"`,绝不传具体 model。
- 不变量 #9rewrite 教条 + `stable_core` 进 `system` 缓存断点前cache=True易变的
`volatile` + 当前草稿 + 作者意见进 `input`(断点后,绝不进缓存前缀)。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from typing import Protocol
from ww_agents import load_prompt
from ww_llm_gateway.types import Block, Delta, LlmRequest, Scope
# 整章重写教条品类无关内核import 期读盘 → 缺失即 fail-fast崩 import
# 字节稳定的稳定内容,作为最前的 cache=True system 块(跨调用前缀复用,不变量 #9
_REWRITE_DOCTRINE = load_prompt("rewrite")
class GatewayStream(Protocol):
"""rewrite 节点对网关的最小依赖——只需 `stream`(注入真网关或 mock"""
def stream(self, req: LlmRequest) -> AsyncIterator[Delta]: ...
def build_rewrite_input(*, volatile: str, prior_draft: str, feedback: str) -> str:
"""组重写输入(断点后 volatile 段):近况 + 当前整章草稿 + 作者意见。
三段都进 `input`(不进缓存前缀):草稿/意见逐轮变,是最易变的部分。
"""
parts: list[str] = []
if volatile.strip():
parts.append(volatile)
parts.append(f"【当前整章草稿】\n{prior_draft}")
parts.append(f"【作者意见】\n{feedback}")
return "\n\n".join(parts)
def build_rewrite_request(
*,
stable_core: str,
volatile: str,
prior_draft: str,
feedback: str,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> LlmRequest:
"""据组装上下文 + 当前草稿 + 作者意见构造重写请求(纯函数)。
`system` = [rewrite 教条(cache), stable_core(cache)]`input` = 近况 + 草稿 + 意见(断点后)。
"""
return LlmRequest(
tier="writer",
system=[
Block(text=_REWRITE_DOCTRINE, cache=True),
Block(text=stable_core, cache=True),
],
input=build_rewrite_input(volatile=volatile, prior_draft=prior_draft, feedback=feedback),
stream=True,
scope=Scope(user_id=user_id, project_id=project_id),
)
async def stream_chapter_rewrite(
gateway: GatewayStream,
*,
stable_core: str,
volatile: str,
prior_draft: str,
feedback: str,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> AsyncIterator[Delta]:
"""流式重写本章增量——构造请求 → 转发网关 `Delta`。
本身不累积/不落库;瞬时失败已在网关重试,此处异常向上抛给 SSE 归一层(发 `error` 事件)。
"""
req = build_rewrite_request(
stable_core=stable_core,
volatile=volatile,
prior_draft=prior_draft,
feedback=feedback,
user_id=user_id,
project_id=project_id,
)
async for delta in gateway.stream(req):
yield delta