diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index 5cda220..a49905d 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -10,7 +10,14 @@ import uuid import httpx import pytest from cryptography.fernet import Fernet -from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway +from fakes_projects import ( + FakeChapterRepo, + FakeProjectRepo, + FakeReviewGateway, + FakeSession, + FakeWriterGateway, +) +from fastapi import FastAPI from ww_api.services.credentials import STUB_OWNER_ID from ww_core.domain.project_repo import ProjectView from ww_core.domain.repositories import ( @@ -361,3 +368,191 @@ async def test_get_draft_404_when_no_draft() -> None: resp = await client.get(f"/projects/{pid}/chapters/9/draft") assert resp.status_code == 404 assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +# ---- POST /rewrite/clarify(整章重写预检澄清,WFW-9 M2)---- + +# 摘录上界外的哨兵:喂超长 prior_draft,断言超界正文不进网关 input(不喂全章、不泄漏)。 +_LEAK_SENTINEL = "泄漏哨兵段落禁止外泄" + + +def _need_clarification_decision() -> object: + from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion + + return ClarifyDecision( + need_clarification=True, + questions=[ + ClarifyQuestion( + question="你说本章节奏太慢,是想往哪个方向重写整章?", + options=[ + ClarifyOption(label="砍支线压缩篇幅", value="删掉次要支线,压缩铺陈直入主线"), + ClarifyOption(label="提前引爆冲突", value="把本章高潮冲突提前到前半段"), + ], + allow_free_text=True, + ) + ], + ) + + +def _clear_rewrite_decision() -> object: + from ww_agents import ClarifyDecision + + return ClarifyDecision( + need_clarification=False, + verification="我会把本章高潮提前、删掉开头回忆插叙,直接进冲突,对吗?", + ) + + +def _clarify_app( + *, + project_repo: FakeProjectRepo, + session: FakeSession | None = None, + clarify_gateway: object | None = None, +) -> FastAPI: + """构造只挂 rewrite/clarify 端点依赖的 app(project_repo + session + clarify 网关)。""" + import os + + os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) + from ww_api.main import create_app + from ww_api.services.project_deps import get_clarify_gateway, get_project_repo + from ww_db import get_session + + app = create_app() + app.dependency_overrides[get_project_repo] = lambda: project_repo + app.dependency_overrides[get_session] = lambda: session or FakeSession() + if clarify_gateway is not None: + app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway + return app + + +def _clarify_client(app: FastAPI) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +@pytest.mark.asyncio +async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None: + # (a) 含糊章级意见 → need_clarification True + 恰 1 问;analyst 档 + 末尾 commit 落账。 + project_repo = FakeProjectRepo() + pid = _seed_project(project_repo) + session = FakeSession() + gateway = FakeReviewGateway(parsed=_need_clarification_decision()) + app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway) + + async with _clarify_client(app) as client: + resp = await client.post( + f"/projects/{pid}/chapters/3/rewrite/clarify", + json={"feedback": "节奏太慢", "prior_draft": "本章开头……"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["need_clarification"] is True + assert len(body["questions"]) == 1 + assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅" + assert body["questions"][0]["allow_free_text"] is True + # 不变量 #2:analyst 档;意见折进网关 input;末尾一次 commit(网关 usage_ledger 落库)。 + assert gateway.requests[0].tier == "analyst" + assert "节奏太慢" in str(gateway.requests[0].input) + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None: + # (b) 明确意见 → need_clarification False + verification;只读、无回滚。 + project_repo = FakeProjectRepo() + pid = _seed_project(project_repo) + session = FakeSession() + gateway = FakeReviewGateway(parsed=_clear_rewrite_decision()) + app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway) + + async with _clarify_client(app) as client: + resp = await client.post( + f"/projects/{pid}/chapters/3/rewrite/clarify", + json={"feedback": "把本章高潮提前到前半段,删掉开头回忆插叙。"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["need_clarification"] is False + assert body["questions"] == [] + assert body["verification"] is not None + # 只读不写业务表:仅一次 commit 落网关 usage,无回滚(不变量 #3)。 + assert session.commits == 1 + assert session.rollbacks == 0 + + +@pytest.mark.asyncio +async def test_rewrite_clarify_unknown_project_404() -> None: + # (c) 项目不存在 → 404(触网关前 fail-fast)。 + app = _clarify_app( + project_repo=FakeProjectRepo(), + clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()), + ) + async with _clarify_client(app) as client: + resp = await client.post( + f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify", + json={"feedback": "节奏太慢"}, + ) + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +@pytest.mark.asyncio +async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None: + # (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False(韧性回退,非 500)。 + from ww_agents import WorldGenResult + + project_repo = FakeProjectRepo() + pid = _seed_project(project_repo) + session = FakeSession() + gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[])) + app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway) + + async with _clarify_client(app) as client: + resp = await client.post( + f"/projects/{pid}/chapters/3/rewrite/clarify", + json={"feedback": "重写一遍"}, + ) + + assert resp.status_code == 200 + assert resp.json()["need_clarification"] is False + + +@pytest.mark.asyncio +async def test_rewrite_clarify_empty_feedback_422() -> None: + project_repo = FakeProjectRepo() + pid = _seed_project(project_repo) + app = _clarify_app( + project_repo=project_repo, + clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()), + ) + async with _clarify_client(app) as client: + resp = await client.post( + f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""} + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None: + # 有界摘录:超长 prior_draft 的越界正文不进网关 input,响应也绝不回灌正文(脱敏)。 + project_repo = FakeProjectRepo() + pid = _seed_project(project_repo) + session = FakeSession() + gateway = FakeReviewGateway(parsed=_clear_rewrite_decision()) + app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway) + + # 哨兵放在远超摘录上界处(10k 填充 + 哨兵),确保被截断在摘录外。 + prior_draft = ("正" * 10_000) + _LEAK_SENTINEL + + async with _clarify_client(app) as client: + resp = await client.post( + f"/projects/{pid}/chapters/3/rewrite/clarify", + json={"feedback": "重写本章", "prior_draft": prior_draft}, + ) + + assert resp.status_code == 200 + # 越界正文既不进 LLM 输入,也不出现在响应体(正文只喂有界摘录、绝不回灌)。 + assert _LEAK_SENTINEL not in str(gateway.requests[0].input) + assert _LEAK_SENTINEL not in resp.text diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index 01672fb..66bb003 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -20,6 +20,7 @@ from typing import Annotated, Any from fastapi import APIRouter, BackgroundTasks, Depends, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession +from ww_agents import ClarifyDecision, clarify_rewrite_spec from ww_core.domain.chapter_repo import ChapterRepo from ww_core.domain.digest_repo import DigestAppendRepo from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo @@ -36,6 +37,7 @@ from ww_core.orchestrator import ( error_event, normalize_deltas, normalize_review, + run_clarify, stream_chapter_draft, stream_chapter_rewrite, ) @@ -64,6 +66,7 @@ from ww_api.schemas.projects import ( ReviewHistoryItem, ReviewHistoryResponse, ReviewRequest, + RewriteClarifyRequest, RewriteStreamRequest, ) from ww_api.services.accept_service import ( @@ -76,6 +79,7 @@ from ww_api.services.digest_extraction import extract_digest_facts from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan from ww_api.services.project_deps import ( get_chapter_repo, + get_clarify_gateway, get_digest_append_repo, get_digest_gateway, get_injection_repo, @@ -106,6 +110,7 @@ ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)] ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)] DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)] +ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)] ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)] @@ -370,6 +375,80 @@ async def stream_rewrite( ) +# 整章重写预检的草稿摘录上界:只喂 analyst 预检**开头一小段**供锚定,绝不整章入 LLM +# (守成本/上下文,且预检只需判意见含糊度、不需读全章)。 +_REWRITE_CLARIFY_EXCERPT_MAX = 3000 + + +def _build_rewrite_clarify_input(feedback: str, prior_draft: str | None) -> str: + """组整章重写预检输入:作者章级意见 + 当前章草稿的有界摘录(分块标注,供教条区分)。 + + 只取 `prior_draft` 开头 `_REWRITE_CLARIFY_EXCERPT_MAX` 字符作锚定摘录——预检不需读全章, + 避免把 200k 整章喂给 analyst 预检(成本/上下文/脱敏)。无草稿时只喂意见(退化,仍可判含糊)。 + """ + parts = [f"【作者的整章修改意见】\n{feedback}"] + if prior_draft: + excerpt = prior_draft[:_REWRITE_CLARIFY_EXCERPT_MAX] + parts.append(f"【当前章节草稿(摘录,仅供锚定)】\n{excerpt}") + return "\n\n".join(parts) + + +@router.post( + "/{project_id}/chapters/{chapter_no}/rewrite/clarify", + responses={ + 404: {"model": ErrorEnvelope, "description": "项目不存在"}, + 503: {"model": ErrorEnvelope, "description": "LLM 不可用"}, + }, +) +async def clarify_rewrite( + project_id: uuid.UUID, + chapter_no: int, + body: RewriteClarifyRequest, + request: Request, + project_repo: ProjectRepoDep, + gateway: ClarifyGatewayDep, + session: Annotated[AsyncSession, Depends(get_session)], +) -> ClarifyDecision: + """整章重写预检澄清(WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON,只判不改。 + + 镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」,含糊则反问 1 问 + 给选项, + 清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点)。analyst 档 + (不变量 #2)。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM)。 + + 项目不存在 → 404(触网关前 fail-fast,同 rewrite/draft);无凭据 → 503(dep 解析阶段拦下)。 + **只读不写业务表**(不变量 #3);末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则 + 记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退 + `need_clarification=false`(不阻塞整章重写主链路)。 + """ + request_id = getattr(request.state, "request_id", None) + + if await project_repo.get(STUB_OWNER_ID, project_id) is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + context = _build_rewrite_clarify_input(body.feedback, body.prior_draft) + decision = await run_clarify( + clarify_rewrite_spec, # 不变量 #2:analyst 档,不传 model + context=context, + gateway=gateway, + user_id=STUB_OWNER_ID, + project_id=project_id, + ) + + # 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit(否则记账丢失)。 + await session.commit() + + log.info( + "rewrite_clarify_done", + project_id=str(project_id), + chapter_no=chapter_no, + request_id=request_id, + feedback_len=len(body.feedback), # 脱敏:只记长度,不记正文(不变量/日志纪律) + need_clarification=decision.need_clarification, + question_count=len(decision.questions), + ) + return decision + + @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 2e7d587..b2f735d 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -142,6 +142,20 @@ class RewriteStreamRequest(BaseModel): prior_draft: str = Field(min_length=1, max_length=_REWRITE_DRAFT_MAX) +class RewriteClarifyRequest(BaseModel): + """整章重写预检澄清:章级意见 + 可选当前草稿(WFW-9 M2 路线A 两阶段之「问题」阶段)。 + + 与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文** + (整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景, + 可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检, + 不整章入 LLM)。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents,端点直接返回, + 供前端 gen:api 生成强类型客户端)。 + """ + + feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX) + prior_draft: str | None = Field(default=None, max_length=_REWRITE_DRAFT_MAX) + + class DraftSaveRequest(BaseModel): """PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""