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:
Yaojia Wang
2026-07-08 11:46:24 +02:00
parent 4a09f273d9
commit 6fd637596b
3 changed files with 289 additions and 1 deletions

View File

@@ -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无凭据 → 503dep 解析阶段拦下)。
**只读不写业务表**(不变量 #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, # 不变量 #2analyst 档,不传 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,