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

@@ -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"