feat(backend): 整章重写 AI 反问澄清预检端点 rewrite/clarify(WFW-9 M2)
新增 POST /projects/{id}/chapters/{no}/rewrite/clarify(非流式 JSON)→ ClarifyDecision,
镜像 refine 侧 clarify:404 缺项目 → _build_rewrite_clarify_input(章级意见 + prior_draft
有界摘录 3000 字,不整章入 LLM)→ run_clarify(clarify_rewrite_spec, analyst 档) → 末尾
commit 落 usage_ledger → 返回决策。只读不写业务表(不变量 #3);日志只记 feedback_len(脱敏);
畸形 parsed 经 run_clarify 韧性回退 need_clarification=false(非 500)。新增 RewriteClarifyRequest
(feedback 必给、prior_draft 可选)。6 端点用例覆盖含糊/明确/404/fail-open/422/摘录有界不泄漏。
This commit is contained in:
@@ -10,7 +10,14 @@ import uuid
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.fernet import Fernet
|
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_api.services.credentials import STUB_OWNER_ID
|
||||||
from ww_core.domain.project_repo import ProjectView
|
from ww_core.domain.project_repo import ProjectView
|
||||||
from ww_core.domain.repositories import (
|
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")
|
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from typing import Annotated, Any
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.chapter_repo import ChapterRepo
|
||||||
from ww_core.domain.digest_repo import DigestAppendRepo
|
from ww_core.domain.digest_repo import DigestAppendRepo
|
||||||
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
|
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
|
||||||
@@ -36,6 +37,7 @@ from ww_core.orchestrator import (
|
|||||||
error_event,
|
error_event,
|
||||||
normalize_deltas,
|
normalize_deltas,
|
||||||
normalize_review,
|
normalize_review,
|
||||||
|
run_clarify,
|
||||||
stream_chapter_draft,
|
stream_chapter_draft,
|
||||||
stream_chapter_rewrite,
|
stream_chapter_rewrite,
|
||||||
)
|
)
|
||||||
@@ -64,6 +66,7 @@ from ww_api.schemas.projects import (
|
|||||||
ReviewHistoryItem,
|
ReviewHistoryItem,
|
||||||
ReviewHistoryResponse,
|
ReviewHistoryResponse,
|
||||||
ReviewRequest,
|
ReviewRequest,
|
||||||
|
RewriteClarifyRequest,
|
||||||
RewriteStreamRequest,
|
RewriteStreamRequest,
|
||||||
)
|
)
|
||||||
from ww_api.services.accept_service import (
|
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.foreshadow_scan import SessionFactory, run_overdue_scan
|
||||||
from ww_api.services.project_deps import (
|
from ww_api.services.project_deps import (
|
||||||
get_chapter_repo,
|
get_chapter_repo,
|
||||||
|
get_clarify_gateway,
|
||||||
get_digest_append_repo,
|
get_digest_append_repo,
|
||||||
get_digest_gateway,
|
get_digest_gateway,
|
||||||
get_injection_repo,
|
get_injection_repo,
|
||||||
@@ -106,6 +110,7 @@ ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
|||||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
||||||
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
||||||
|
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||||
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
||||||
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_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")
|
@router.put("/{project_id}/chapters/{chapter_no}/draft")
|
||||||
async def save_draft(
|
async def save_draft(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ class RewriteStreamRequest(BaseModel):
|
|||||||
prior_draft: str = Field(min_length=1, max_length=_REWRITE_DRAFT_MAX)
|
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):
|
class DraftSaveRequest(BaseModel):
|
||||||
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user