From 6ed9c894305f6b68f770d85e7148f759b23ded48 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 8 Jul 2026 07:04:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E6=95=B4=E7=AB=A0=E5=86=8D?= =?UTF-8?q?=E6=B2=9F=E9=80=9A/=E9=87=8D=E5=86=99=20SSE=20=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E2=80=94=E2=80=94=E5=96=82=E5=BD=93=E5=89=8D=E8=8D=89?= =?UTF-8?q?=E7=A8=BF+=E6=84=8F=E8=A7=81+=E5=AE=8C=E6=95=B4=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E6=B3=A8=E5=85=A5=E6=B5=81=E5=BC=8F=E9=87=8D=E5=86=99?= =?UTF-8?q?=E4=B8=80=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)。 --- apps/api/ww_api/routers/projects.py | 60 ++++++++++++ apps/api/ww_api/schemas/projects.py | 17 ++++ packages/agents/tests/test_prompt_loader.py | 5 +- packages/agents/ww_agents/prompts/rewrite.md | 16 ++++ packages/core/tests/test_rewrite_node.py | 95 +++++++++++++++++++ .../core/ww_core/orchestrator/__init__.py | 8 ++ .../core/ww_core/orchestrator/rewrite_node.py | 95 +++++++++++++++++++ 7 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 packages/agents/ww_agents/prompts/rewrite.md create mode 100644 packages/core/tests/test_rewrite_node.py create mode 100644 packages/core/ww_core/orchestrator/rewrite_node.py diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index eeeaa60..01672fb 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -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:新版停前端/草稿,接受才落库(不变量 #3);rewrite 是工具非写节点,不破坏章 + 纯函数性(不变量 #7)。项目不存在 → 404(触网关前 fail-fast,同 draft,QA 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, diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index 3ec27d6..d451907 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -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:自动保存草稿正文。""" diff --git a/packages/agents/tests/test_prompt_loader.py b/packages/agents/tests/test_prompt_loader.py index e39cf83..68ffefd 100644 --- a/packages/agents/tests/test_prompt_loader.py +++ b/packages/agents/tests/test_prompt_loader.py @@ -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,灵感①)与整章重写内核(rewrite,WFW-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 连字符)---- diff --git a/packages/agents/ww_agents/prompts/rewrite.md b/packages/agents/ww_agents/prompts/rewrite.md new file mode 100644 index 0000000..2fb9cda --- /dev/null +++ b/packages/agents/ww_agents/prompts/rewrite.md @@ -0,0 +1,16 @@ +# 整章再沟通 / 重写纪律(品类无关内核) + +你是作者的写作搭档。作者对**本章当前草稿**不满意,给了**修改意见**。你的任务是**依据作者意见,把整章重写成新的一版**。 + +## 硬约束 + +- **只输出重写后的本章正文**,不要任何前言、说明、点评、markdown 标题或「以下是重写版」之类的元话语。作者要的是可直接替换正文的成稿。 +- **严格遵守上文提供的世界观硬规则、设定、角色、大纲节拍与文风指纹**——重写不是重新发明,人物、设定、既定事实、本章在大纲中的功能都要保持一致,不得与前文冲突。 +- **紧扣作者意见**:作者指出的问题(节奏、人物动机、爽点、文风、某段处理等)要在新版里切实改掉;作者要求保留的地方就保留。意见没提到的部分,在保持连贯的前提下沿用原意,不要借机大改。 +- 产出一个**完整的章**:有头有尾、篇幅与原章相当(除非作者明确要求增删),不要只给片段或改动摘要。 + +## 写作要求 + +- 承接上下文语气与人称,保持叙事视角一致。 +- 反注水:少铺垫、多有效信息与冲突推进;对白推动情节,描写为人物与张力服务。 +- 若作者意见与既有设定/大纲冲突,以**不破坏世界观一致性**为先,用作者意见能实现的方式落地;无法两全时,优先设定一致性并在正文里自然化解,不要停下来发问。 diff --git a/packages/core/tests/test_rewrite_node.py b/packages/core/tests/test_rewrite_node.py new file mode 100644 index 0000000..66f979b --- /dev/null +++ b/packages/core/tests/test_rewrite_node.py @@ -0,0 +1,95 @@ +"""整章重写节点单测:纯构造 + 转发网关 Delta(注入 mock 网关,无图运行时、无 DB)。 + +守不变量 ②(只声明 tier=writer)/#9(rewrite 教条 + 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" diff --git a/packages/core/ww_core/orchestrator/__init__.py b/packages/core/ww_core/orchestrator/__init__.py index cd88f45..8239506 100644 --- a/packages/core/ww_core/orchestrator/__init__.py +++ b/packages/core/ww_core/orchestrator/__init__.py @@ -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", diff --git a/packages/core/ww_core/orchestrator/rewrite_node.py b/packages/core/ww_core/orchestrator/rewrite_node.py new file mode 100644 index 0000000..f5492a3 --- /dev/null +++ b/packages/core/ww_core/orchestrator/rewrite_node.py @@ -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。 +- 不变量 #9:rewrite 教条 + `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