feat(backend): AI 反问澄清预检端点——refine 侧结构化 clarify(WFW-9 M1,路线A 两阶段)

润色「再沟通」意见含糊时先反问给选项(路线A:问题走独立非流式 JSON 预检端点,正文仍走
既有 refine 一字不改)。新增:
- ClarifyDecision/ClarifyQuestion/ClarifyOption 结构化 schema(既有 output-schema 处,供
  producer 与端点共用);clarify_refine.md 教条(含糊→need_clarification+≤1问+2–4锚定选项+
  自由输入;明确→verification 放行;防循环);注册 clarify_refine_spec(analyst 档,#24)+
  SCHEMA_CATALOG + 重生成金标准。
- clarify_node:build_clarify_request 纯函数(缓存前缀不含易变) + run_clarify(gateway.run
  结构化,判别/校验失败确定性回退 need_clarification=false,只读不写库)。
- POST /projects/{id}/chapters/{no}/refine/clarify → ClarifyDecision(analyst 网关,404/503,
  末尾 commit 记账)。**既有 refine 端点/RefineRequest/Response 完全未改**。
门禁绿:ruff/mypy 227/pytest 900(+test_clarify_spec/_node/style clarify)/alembic 无漂移。
This commit is contained in:
Yaojia Wang
2026-07-08 08:47:13 +02:00
parent fb5caa3d89
commit 1652ad9d20
15 changed files with 697 additions and 5 deletions

View File

@@ -59,10 +59,12 @@ def _app_with_overrides(
session_factory: FakeSessionFactory | None = None,
extract_gateway: object | None = None,
refine_gateway: object | None = None,
clarify_gateway: object | None = None,
) -> FastAPI:
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_job_repo,
get_project_repo,
get_refine_gateway,
@@ -85,6 +87,8 @@ def _app_with_overrides(
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
if refine_gateway is not None:
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
if clarify_gateway is not None:
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
return app
@@ -394,3 +398,134 @@ async def test_refine_without_credentials_503() -> None:
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0
# ---- POST /refine/clarify润色预检澄清----
def _need_clarification() -> 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_decision() -> object:
from ww_agents import ClarifyDecision
return ClarifyDecision(
need_clarification=False,
verification="我会把被字句改成主动句,其余不动,对吗?",
)
@pytest.mark.asyncio
async def test_clarify_returns_need_clarification_and_commits() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_need_clarification())
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/refine/clarify",
json={"segment": "原始段落。", "instruction": "更有张力"},
)
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
# analyst 档 + 末尾 commit记账落库指令折进输入文本。
assert gateway.requests[0].tier == "analyst"
assert session.commits == 1
assert "更有张力" in str(gateway.requests[0].input)
@pytest.mark.asyncio
async def test_clarify_returns_clear_decision_read_only() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_clear_decision())
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/2/refine/clarify",
json={"segment": "把这句的被字句改成主动句。", "instruction": ""},
)
assert resp.status_code == 200
body = resp.json()
assert body["need_clarification"] is False
assert body["questions"] == []
assert body["verification"] is not None
# 只读不写库:无写侧 repo 参与,仅一次 commit 落网关 usage不变量 #3
assert session.commits == 1
assert session.rollbacks == 0
@pytest.mark.asyncio
async def test_clarify_unknown_project_404() -> None:
app = _app_with_overrides(
project_repo=FakeProjectRepo(),
clarify_gateway=FakeReviewGateway(parsed=_clear_decision()),
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/chapters/1/refine/clarify",
json={"segment": "原段", "instruction": "x"},
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_clarify_empty_segment_422() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(
project_repo=project_repo, clarify_gateway=FakeReviewGateway(parsed=_clear_decision())
)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": ""})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_clarify_without_credentials_503() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = _app_with_overrides(project_repo=project_repo, session=session)
from ww_api.services.project_deps import get_clarify_gateway
app.dependency_overrides[get_clarify_gateway] = _no_creds
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": "原段"}
)
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0

View File

@@ -22,10 +22,10 @@ from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import refiner_spec, style_extract_spec
from ww_agents import ClarifyDecision, clarify_refine_spec, refiner_spec, style_extract_spec
from ww_core.domain import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo
from ww_core.orchestrator import run_style_extraction
from ww_core.orchestrator import run_clarify, run_style_extraction
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
@@ -34,6 +34,7 @@ from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.logging_config import get_logger
from ww_api.schemas.style import (
DimensionEntry,
RefineClarifyRequest,
RefineRequest,
RefineResponse,
StyleFingerprintResponse,
@@ -45,6 +46,7 @@ from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.job_runner import run_job
from ww_api.services.project_deps import (
build_gateway_for_tier,
get_clarify_gateway,
get_job_repo,
get_project_repo,
get_refine_gateway,
@@ -62,6 +64,7 @@ JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
@@ -238,3 +241,59 @@ async def refine_segment(
has_instruction=body.instruction is not None,
)
return RefineResponse(original=body.segment, refined=resp.text)
@router.post(
"/{project_id}/chapters/{chapter_no}/refine/clarify",
responses={
404: {"model": ErrorEnvelope, "description": "项目不存在"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
},
)
async def clarify_refine(
project_id: uuid.UUID,
chapter_no: int,
body: RefineClarifyRequest,
request: Request,
project_repo: ProjectRepoDep,
gateway: ClarifyGatewayDep,
session: SessionDep,
) -> ClarifyDecision:
"""润色预检澄清WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点。analyst 档(不变量 #2
项目不存在 → 404无凭据 → 503dep 解析阶段拦下)。**只读不写库**(不变量 #3
末尾 `commit()` 让网关 usage_ledger 落库add-only否则记账静默丢失同 refine 纪律)。
判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
context = _build_refine_input(body.segment, body.instruction or None)
decision = await run_clarify(
clarify_refine_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(
"refine_clarify_done",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
segment_len=len(body.segment),
instruction_len=len(body.instruction),
need_clarification=decision.need_clarification,
question_count=len(decision.questions),
)
return decision

View File

@@ -56,3 +56,20 @@ class RefineResponse(BaseModel):
original: str
refined: str
# 上界:选段/意见文本长度守卫(防超长入参空跑 LLM预检只看本段无需整章
_MAX_CLARIFY_SEGMENT_LEN = 20000
_MAX_CLARIFY_INSTRUCTION_LEN = 2000
class RefineClarifyRequest(BaseModel):
"""润色预检澄清:选段 + 再沟通意见WFW-9 M1 路线A 两阶段之「问题」阶段)。
与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
(在 ww_agents端点直接返回供前端 gen:api 生成强类型客户端)。
"""
segment: str = Field(min_length=1, max_length=_MAX_CLARIFY_SEGMENT_LEN)
instruction: str = Field(default="", max_length=_MAX_CLARIFY_INSTRUCTION_LEN)

View File

@@ -519,6 +519,18 @@ async def get_refine_gateway(
return await build_gateway_for_tier(session, store, "writer")
async def get_clarify_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""润色预检澄清analyst 档位)的可注入网关缝。
`POST .../refine/clarify` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
503进入端点前拦下。测试经 override 注 mock产 `ClarifyDecision`)。
"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_chain_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway: