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, session_factory: FakeSessionFactory | None = None,
extract_gateway: object | None = None, extract_gateway: object | None = None,
refine_gateway: object | None = None, refine_gateway: object | None = None,
clarify_gateway: object | None = None,
) -> FastAPI: ) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app from ww_api.main import create_app
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
get_clarify_gateway,
get_job_repo, get_job_repo,
get_project_repo, get_project_repo,
get_refine_gateway, get_refine_gateway,
@@ -85,6 +87,8 @@ def _app_with_overrides(
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
if refine_gateway is not None: if refine_gateway is not None:
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway 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 return app
@@ -394,3 +398,134 @@ async def test_refine_without_credentials_503() -> None:
assert resp.status_code == 503 assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0 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 fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession 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 import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo 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_db import get_session
from ww_llm_gateway import Gateway from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope 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.logging_config import get_logger
from ww_api.schemas.style import ( from ww_api.schemas.style import (
DimensionEntry, DimensionEntry,
RefineClarifyRequest,
RefineRequest, RefineRequest,
RefineResponse, RefineResponse,
StyleFingerprintResponse, 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.job_runner import run_job
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
build_gateway_for_tier, build_gateway_for_tier,
get_clarify_gateway,
get_job_repo, get_job_repo,
get_project_repo, get_project_repo,
get_refine_gateway, get_refine_gateway,
@@ -62,6 +64,7 @@ JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)] StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)] StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)] RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)] SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
@@ -238,3 +241,59 @@ async def refine_segment(
has_instruction=body.instruction is not None, has_instruction=body.instruction is not None,
) )
return RefineResponse(original=body.segment, refined=resp.text) 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 original: str
refined: 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") 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( async def get_chain_gateway(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway: ) -> Gateway:

View File

@@ -4,6 +4,7 @@
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada", "brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
"character-gen": "e0f97a5d7bb622fdd7582cdeef85ba13b3f434b2633f4d22a8c6daeba48636bf", "character-gen": "e0f97a5d7bb622fdd7582cdeef85ba13b3f434b2633f4d22a8c6daeba48636bf",
"characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f", "characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f",
"clarify_refine": "c2ed53c3a2d6c414c4ae5c92ead45634a56c47cd363a44f83ec1e020099480af",
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763", "continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1", "continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249", "de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",

View File

@@ -0,0 +1,125 @@
"""WFW-9 M1 clarify_refine 契约测试schema + spec 声明路线A 两阶段之「问题」阶段)。
契约测试——构造符合 schema 的 mock 响应,校验字段/默认值/tier/读写权限。
ClarifyDecision`{need_clarification, questions[≤1], verification?}`
analyst 档、reads=()、writes=()。全字段带默认值守解析韧性LLM 漏产字段降级为
need_clarification=false不阻塞润色。不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
AgentSpec,
ClarifyDecision,
ClarifyOption,
ClarifyQuestion,
clarify_refine_spec,
)
# ---- ClarifyDecision / ClarifyQuestion / ClarifyOption schema ----
def test_clarify_decision_parses_need_clarification_with_one_question() -> None:
# Arrange含糊 → 反问 1 问 + 3 个锚定选项 + 自由输入兜底
mock = {
"need_clarification": True,
"questions": [
{
"question": "你想让这段更有张力,是指哪种方向?",
"options": [
{"label": "加快节奏", "value": "把长句拆短,压缩铺陈,加快推进"},
{"label": "加重冲突", "value": "强化人物对立,增加正面交锋"},
{"label": "收紧对白", "value": "删掉旁白,让张力靠对话推进"},
],
"allow_free_text": True,
}
],
"verification": None,
}
# Act
decision = ClarifyDecision.model_validate(mock)
# Assert
assert decision.need_clarification is True
assert len(decision.questions) == 1
q = decision.questions[0]
assert q.question.startswith("你想让这段")
assert len(q.options) == 3
assert q.options[0].label == "加快节奏"
assert q.options[0].value.startswith("把长句拆短")
assert q.allow_free_text is True
assert decision.verification is None
def test_clarify_decision_parses_clear_with_verification() -> None:
# 明确 → 不问、给确认语
mock = {
"need_clarification": False,
"questions": [],
"verification": "我会把第二句的被字句改成主动句,其余不动,对吗?",
}
decision = ClarifyDecision.model_validate(mock)
assert decision.need_clarification is False
assert decision.questions == []
assert decision.verification is not None
def test_clarify_decision_all_fields_default_for_resilience() -> None:
# 全字段默认值守解析韧性:空对象降级为 need_clarification=false不问、放行
decision = ClarifyDecision.model_validate({})
assert decision.need_clarification is False
assert decision.questions == []
assert decision.verification is None
def test_clarify_question_options_default_empty_and_free_text_true() -> None:
# 凑不出具体选项 → 空 options + 常驻自由输入兜底
q = ClarifyQuestion.model_validate({"question": "你具体想改哪里?"})
assert q.options == []
assert q.allow_free_text is True
def test_clarify_option_requires_label_and_value() -> None:
with pytest.raises(ValidationError):
ClarifyOption.model_validate({"label": "加快节奏"})
def test_clarify_question_requires_question_text() -> None:
with pytest.raises(ValidationError):
ClarifyQuestion.model_validate({"options": []})
# ---- clarify_refine_spec 声明 ----
def test_clarify_refine_spec_is_analyst_tier() -> None:
# 不变量 #2含糊判别 + 反问构造用分析档,只声明 tier
assert clarify_refine_spec.tier == "analyst"
assert clarify_refine_spec.name == "clarify_refine"
def test_clarify_refine_spec_is_read_only() -> None:
# 不变量 #3只读——不读库真相源材料端点注入、不写任何表
assert clarify_refine_spec.reads == ()
assert clarify_refine_spec.writes == ()
def test_clarify_refine_spec_output_schema() -> None:
assert clarify_refine_spec.output_schema is ClarifyDecision
def test_clarify_refine_spec_prompt_documents_at_most_one_question() -> None:
# v1 硬上限 1 问 + 自由输入兜底须在教条里显式约束
prompt = clarify_refine_spec.system_prompt
assert "1 问" in prompt
assert "自由输入" in prompt
assert "防循环" in prompt
def test_clarify_refine_spec_is_agent_spec_and_immutable() -> None:
assert isinstance(clarify_refine_spec, AgentSpec)
with pytest.raises(ValidationError):
clarify_refine_spec.tier = "writer"

View File

@@ -52,8 +52,8 @@ def test_load_prompt_matches_golden(name: str) -> None:
# ---- #1 注册表唯一性 ---- # ---- #1 注册表唯一性 ----
def test_specs_registry_len_is_23() -> None: def test_specs_registry_len_is_24() -> None:
assert len(SPECS) == 23 assert len(SPECS) == 24
# name 即 keydict 已去重;逐项确认 key == spec.name无错位 # name 即 keydict 已去重;逐项确认 key == spec.name无错位
assert all(key == spec.name for key, spec in SPECS.items()) assert all(key == spec.name for key, spec in SPECS.items())

View File

@@ -16,6 +16,9 @@ from .schemas import (
CharacterizationIssue, CharacterizationIssue,
CharacterizationReview, CharacterizationReview,
CharacterRelation, CharacterRelation,
ClarifyDecision,
ClarifyOption,
ClarifyQuestion,
Conflict, Conflict,
ConflictType, ConflictType,
ContinuationResult, ContinuationResult,
@@ -60,6 +63,7 @@ from .specs import (
brainstorm_spec, brainstorm_spec,
character_gen_spec, character_gen_spec,
characterization_spec, characterization_spec,
clarify_refine_spec,
continue_spec, continue_spec,
continuity_spec, continuity_spec,
de_ai_spec, de_ai_spec,
@@ -93,6 +97,9 @@ __all__ = [
"CharacterizationIssue", "CharacterizationIssue",
"CharacterizationReview", "CharacterizationReview",
"CharacterRelation", "CharacterRelation",
"ClarifyDecision",
"ClarifyOption",
"ClarifyQuestion",
"Conflict", "Conflict",
"ConflictType", "ConflictType",
"ContinuationResult", "ContinuationResult",
@@ -133,6 +140,7 @@ __all__ = [
"brainstorm_spec", "brainstorm_spec",
"character_gen_spec", "character_gen_spec",
"characterization_spec", "characterization_spec",
"clarify_refine_spec",
"continue_spec", "continue_spec",
"continuity_spec", "continuity_spec",
"de_ai_spec", "de_ai_spec",

View File

@@ -0,0 +1,50 @@
你是长篇连载小说润色环节的「预检澄清器」clarify-refine——一位懂网文、会揣摩作者意图的资深责编。作者选中了正文里的一个段落想要「再沟通/润色」,但他给出的意见可能含糊、可能指向多种改法。你的唯一职责:在真正动笔改写**之前**,判断「这条意见清不清楚」,含糊就反问一个最关键的问题并给出几种具体走法供作者点选,清楚就确认理解、直接放行。
你不是改写器(绝不输出改后的正文),不是审校(不输出问题清单/评分),不写库。你只做一件事:产出一个结构化的「澄清决策」。
## 输入
注入材料为序列化文本,可能包含:
- **待润色的段落正文**(必有);
- **作者的再沟通意见/指令**(可选;如「改得更燃一点」「这里不对」「换个写法」,也可能为空);
- **周边上下文/文风线索**(可选)。
## 判断:要不要反问
先想清楚——作者这条意见,能不能让改写器**无歧义地**动笔?
**判定为「明确」need_clarification=false** 当且仅当满足其一:
- 意见具体、单一走法(如「把第二句的『被』字句改成主动句」「删掉这段景物描写」「口语化一点」);
- 意见虽简短但结合本段只有一种合理改法;
- **历史里已经澄清过**(材料中已出现作者此前的选择/补充)——此时几乎永远不要再问,避免反复兜圈子。
**判定为「含糊」need_clarification=true** 当且仅当:
- 意见空缺或极短、无法定位要改什么(如「再改改」「不满意」);
- 存在多种明显不同的改法方向,选哪个会显著改变结果(如「更有张力」既可以是加快节奏、也可以是加重冲突、还可以是收紧对白)。
拿不准时**倾向于放行**need_clarification=false——反问的成本是打断作者只有真含糊才值得问。
## 产出纪律(硬约束)
### 明确时need_clarification=false
- `questions` 留空列表;
- `verification` 给**一句**「我这样理解对吗」式确认语,用你自己的话复述你打算怎么改(让作者一眼看出理解是否到位)。不要复述原文,不要输出改后正文。
### 含糊时need_clarification=true
- `questions` **恰好 1 问**(硬上限,绝不超过一个问题);
- `question`:一句话,直指本段的那个分歧点;
- `options`:给 **24 个**锚定**本段/本章**的**具体、可区分**走法。每个选项:
- `label` 是作者看到的简短文案;
- `value` 是选中后要落实的具体走法描述(会被折进改写指令,供改写器执行)——写成可直接照做的一句话,别写空泛的形容词。
- 选项之间必须**互斥、方向不同**,覆盖作者最可能想要的几种真实意图;不要凑数、不要近义重复。
- **凑不出**具体可区分的选项时(比如信息实在太少),把 `options` 留空列表,突出让作者自由输入——不要硬编三个含糊选项充数。
- `allow_free_text` 一律为 `true`:无论有没有选项,自由输入永远兜底(作者可以都不选、自己写)。
- `verification` 可留空(`null`)——含糊阶段还没到确认的时候。
## 防循环
如果注入材料里已经能看到作者此前的澄清选择或补充说明,说明这一轮沟通已经完成,**不要再问同样或类似的问题**——直接 `need_clarification=false`,用 `verification` 复述你综合后的理解,放行改写。反复反问是最糟的体验。
## 产出格式(严格)
只产出符合结构化 schema 的决策对象(`need_clarification` / `questions` / `verification`。不要输出任何正文、解释、markdown 包裹或额外文字。选项要具体锚定本段,问题要短、要准、只问一个。

View File

@@ -18,6 +18,7 @@ from .schemas import (
BookTeardownResult, BookTeardownResult,
CharacterGenResult, CharacterGenResult,
CharacterizationReview, CharacterizationReview,
ClarifyDecision,
ContinuationResult, ContinuationResult,
ContinuityReview, ContinuityReview,
DeAiResult, DeAiResult,
@@ -63,6 +64,7 @@ SCHEMA_CATALOG: Final[dict[str, type[BaseModel] | None]] = {
"de-ai": DeAiResult, "de-ai": DeAiResult,
"teardown": BookTeardownResult, "teardown": BookTeardownResult,
"project-plan": ProjectPlanResult, "project-plan": ProjectPlanResult,
"clarify_refine": ClarifyDecision,
} }

View File

@@ -674,3 +674,58 @@ class ProjectPlanResult(BaseModel):
) )
ending_design: str = Field(default="", description="结局设计(收束方向与情绪落点);缺则空串") ending_design: str = Field(default="", description="结局设计(收束方向与情绪落点);缺则空串")
tone: str = Field(default="", description="基调(全书情绪底色与阅读质感);缺则空串") tone: str = Field(default="", description="基调(全书情绪底色与阅读质感);缺则空串")
# ---- M1 refine · 预检澄清clarify-refineanalyst纯只读 writes=()----
class ClarifyOption(BaseModel):
"""单个澄清选项:展示文案 + 选中回填值(锚定本段/本章的一种具体走法)。
`value` = 作者选中后折进 refine instruction 字符串的内容(零迁移,不落库)。
凑不出具体可区分选项时退化为空 options`ClarifyQuestion.options=[]`),突出自由输入。
"""
label: str = Field(description="展示文案(作者看到的选项标签)")
value: str = Field(description="选中后回填/折进 instruction 的具体走法内容")
class ClarifyQuestion(BaseModel):
"""单条澄清反问:问题 + 24 个锚定选项 + 自由输入兜底。
`options` 常规 24 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表,
退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底作者可不选任一项
"""
question: str = Field(description="反问的澄清问题(一句话,指向本段的分歧点)")
options: list[ClarifyOption] = Field(
default_factory=list,
description="24 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表",
)
allow_free_text: bool = Field(default=True, description="是否允许自由输入(常驻兜底)")
class ClarifyDecision(BaseModel):
"""润色预检澄清决策(结构化 LLM 输出 + API 响应)。
路线A 两阶段之「问题」阶段:仅判定「要不要反问」并给选项,**不改正文**(正文仍走
既有 refine 端点)。纯只读(`clarify_refine_spec.writes=()`,不变量 #3
**全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式LLM 漏产/畸形时
降级为 `need_clarification=false`(不问、直接放行 refine绝不阻塞润色主链路。
- `need_clarification=false` → `questions` 为空、可给一句 `verification` 确认语;
- `need_clarification=true` → `questions` 恰 1 问v1 硬上限)、`verification` 可空。
"""
need_clarification: bool = Field(
default=False, description="是否需要向作者反问澄清(含糊/多走法→true明确→false"
)
questions: list[ClarifyQuestion] = Field(
default_factory=list,
description="反问清单need_clarification=false 时为空v1 硬上限 1 问)",
)
verification: str | None = Field(
default=None,
description="明确时的一句『我这样理解对吗』确认语need_clarification=false 时给,可空)",
)

View File

@@ -31,6 +31,7 @@ __all__ = [
"brainstorm_spec", "brainstorm_spec",
"character_gen_spec", "character_gen_spec",
"characterization_spec", "characterization_spec",
"clarify_refine_spec",
"continue_spec", "continue_spec",
"continuity_spec", "continuity_spec",
"de_ai_spec", "de_ai_spec",
@@ -326,6 +327,18 @@ project_plan_spec = AgentSpec(
scope="builtin", scope="builtin",
) )
# ---- clarify_refine润色预检澄清分析档纯只读 writes=()WFW-9 M1 路线A 两阶段)----
clarify_refine_spec = AgentSpec(
name="clarify_refine",
tier="analyst", # 不变量 #2只声明档位不写 model含糊判别 + 反问构造用分析档)
system_prompt=load_prompt("clarify_refine"),
input_schema=None, # 注入材料为序列化文本(选段 + 再沟通指令),非结构化入参
output_schema=SCHEMA_CATALOG["clarify_refine"],
reads=(), # 只判本段是否含糊;材料经端点序列化注入,不直读库
writes=(), # 只读——只产问题/确认语,不改正文、不写库(不变量 #3
scope="builtin",
)
# 集中注册表name → spec同一实例兼容期 *_spec 与 SPECS[name] 为同对象,不变量) # 集中注册表name → spec同一实例兼容期 *_spec 与 SPECS[name] 为同对象,不变量)
# MappingProxyType只读视图运行时 `SPECS[x] = ...` / `del SPECS[x]` 抛 TypeError # MappingProxyType只读视图运行时 `SPECS[x] = ...` / `del SPECS[x]` 抛 TypeError
@@ -356,9 +369,10 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = {
de_ai_spec, de_ai_spec,
teardown_spec, teardown_spec,
project_plan_spec, project_plan_spec,
clarify_refine_spec,
) )
} }
assert len(_SPECS_BY_NAME) == 23, "SPECS name 冲突或缺失" # 唯一性 + 数量自检import 期) assert len(_SPECS_BY_NAME) == 24, "SPECS name 冲突或缺失" # 唯一性 + 数量自检import 期)
SPECS: Final[Mapping[str, AgentSpec]] = MappingProxyType(_SPECS_BY_NAME) SPECS: Final[Mapping[str, AgentSpec]] = MappingProxyType(_SPECS_BY_NAME)
# 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合) # 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合)

View File

@@ -0,0 +1,127 @@
"""WFW-9 M1 clarify 节点单测build_clarify_request + run_clarify路线A 两阶段)。
注入 mock 网关(产 `ClarifyDecision` parsed无真 LLM、无真 Postgres。clarify 是独立
非流式预检(不在写章/审稿流水线),裸函数;只判不改、**不写库**(不变量 #3。asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
from fakes_orchestrator import FakeRunGateway
from ww_agents import (
ClarifyDecision,
ClarifyOption,
ClarifyQuestion,
WorldGenResult,
clarify_refine_spec,
)
from ww_core.orchestrator import build_clarify_request, run_clarify
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
_NEED = ClarifyDecision(
need_clarification=True,
questions=[
ClarifyQuestion(
question="你想让这段更有张力,是指哪种方向?",
options=[
ClarifyOption(label="加快节奏", value="拆短句、压缩铺陈"),
ClarifyOption(label="加重冲突", value="强化人物对立"),
],
allow_free_text=True,
)
],
)
_CLEAR = ClarifyDecision(
need_clarification=False,
verification="我会把被字句改成主动句,其余不动,对吗?",
)
# ---- build_clarify_requestanalyst 档 + 结构化 + 缓存前缀无易变 ----
def test_build_clarify_request_declares_analyst_tier_and_schema() -> None:
req = build_clarify_request(
clarify_refine_spec,
context="## 待重写段落\n原段。\n\n## 改写指令\n再改改",
user_id=USER,
project_id=PROJECT,
)
# 不变量 #2只声明 analyst 档,不传 model。
assert req.tier == "analyst"
# 结构化预检:非流式 + 带 ClarifyDecision output_schema。
assert req.stream is False
assert req.output_schema is ClarifyDecision
# 注入材料进 input断点后
assert "再改改" in req.input
def test_build_clarify_request_system_prefix_is_doctrine_only_cacheable() -> None:
# 不变量 #9system 前缀只放教条、cache=True无历史/时间戳/UUID可缓存
req = build_clarify_request(
clarify_refine_spec, context="ctx", user_id=USER, project_id=PROJECT
)
assert len(req.system) == 1
block = req.system[0]
assert block.cache is True
assert block.text == clarify_refine_spec.system_prompt
# 易变值绝不进缓存前缀。
assert str(PROJECT) not in block.text
assert "ctx" not in block.text
# ---- run_clarify正常返回 + 校验失败确定性回退 ----
async def test_run_clarify_returns_need_clarification_decision() -> None:
gateway = FakeRunGateway(_NEED)
result = await run_clarify(
clarify_refine_spec,
context="## 待重写段落\n原段。",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert isinstance(result, ClarifyDecision)
assert result.need_clarification is True
assert len(result.questions) == 1
assert result.questions[0].options[0].label == "加快节奏"
assert gateway.call_count == 1
async def test_run_clarify_returns_clear_decision() -> None:
gateway = FakeRunGateway(_CLEAR)
result = await run_clarify(
clarify_refine_spec,
context="ctx",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert result.need_clarification is False
assert result.verification is not None
async def test_run_clarify_falls_back_to_no_clarification_on_wrong_parsed() -> None:
# 判别/校验失败:网关返回非 ClarifyDecision畸形/降级)→ 确定性回退不问、放行。
gateway = FakeRunGateway(WorldGenResult(entities=[]))
result = await run_clarify(
clarify_refine_spec,
context="ctx",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert isinstance(result, ClarifyDecision)
assert result.need_clarification is False
assert result.questions == []

View File

@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
from ._protocols import GatewayRun from ._protocols import GatewayRun
from .clarify_node import build_clarify_request, run_clarify
from .collect import ( from .collect import (
CHARACTERIZATION, CHARACTERIZATION,
CONTINUITY, CONTINUITY,
@@ -119,6 +120,7 @@ __all__ = [
"SseEvent", "SseEvent",
"build_brief_context", "build_brief_context",
"build_character_gen_context", "build_character_gen_context",
"build_clarify_request",
"build_continuation_context", "build_continuation_context",
"build_outline_chapter_context", "build_outline_chapter_context",
"build_outline_request", "build_outline_request",
@@ -148,6 +150,7 @@ __all__ = [
"pace_event", "pace_event",
"precheck_generated_cards", "precheck_generated_cards",
"run_character_gen", "run_character_gen",
"run_clarify",
"run_generator", "run_generator",
"run_outline", "run_outline",
"run_review", "run_review",

View File

@@ -0,0 +1,84 @@
"""润色预检澄清节点WFW-9 M1 · 路线A 两阶段之「问题」阶段)。
`run_clarify` 是**独立非流式**预检——不在写章/审稿流水线(不接进 review 图),仿
`run_generator`。润色端点在真正回炉改写前调它:判「作者这条意见清不清楚」,含糊则反问
一个问题 + 给选项,清楚则确认理解、放行。正文改写仍走既有 refine 端点,本节点**不改稿**。
确定性边界CLAUDE.md「LangGraph」纪律节点逻辑无 LLM 非确定性——不确定性藏在网关后,
节点只做 `AgentSpec + 上下文 → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
注入 mock 网关(产 `ClarifyDecision`)即可单测,无需图运行时、无需真 Postgres。
不变量 #2agent 只声明 `tier`analyst绝不传具体 modelspec.tier 透传)。
不变量 #3节点**只读、不写库**——只产澄清决策返回调用方,绝不改正文/写任何表。
不变量 #9`spec.system_prompt` 进缓存断点前块cache=True注入材料进 `input`(断点后),
system 前缀只放教条、无历史/时间戳/UUID。
韧性降级:判别/校验失败(网关返回非 `ClarifyDecision`**确定性回退**为
`need_clarification=false`(不问、直接放行 refine绝不阻塞润色主链路。网关本体的瞬时
失败/回退/熔断/instructor 重试均归网关§4.5),本节点只感知干净的最终 parsed。
"""
from __future__ import annotations
import uuid
import structlog
from ww_agents import AgentSpec, ClarifyDecision
from ww_llm_gateway.types import Block, LlmRequest, Scope
from ._protocols import GatewayRun
log = structlog.get_logger(__name__)
def build_clarify_request(
spec: AgentSpec,
*,
context: str,
user_id: uuid.UUID,
project_id: uuid.UUID | None,
) -> LlmRequest:
"""据 spec + 注入材料构造预检请求(纯函数)。
`spec.system_prompt` → `system` 缓存断点前块cache=True只放教条不变量 #9
`context`(序列化的选段 + 再沟通意见)→ `input`(断点后,易变内容)。
预检要结构化产物(`ClarifyDecision`)、非流式正文——`stream=False`、带 `output_schema`。
"""
return LlmRequest(
tier=spec.tier, # 不变量 #2只传档位不传 model
system=[Block(text=spec.system_prompt, cache=True)],
input=context,
stream=False,
output_schema=spec.output_schema,
scope=Scope(user_id=user_id, project_id=project_id),
)
async def run_clarify(
spec: AgentSpec,
*,
context: str,
gateway: GatewayRun,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
) -> ClarifyDecision:
"""跑润色预检:构请求 → `gateway.run` → 校验 parsed 为 `ClarifyDecision`。
**判别/校验失败确定性回退**:网关返回非 `ClarifyDecision`instructor 重试上限后仍
畸形 / None时不上抛降级为 `need_clarification=false`(不问、直接放行 refine
守「clarify 是非关键预检、绝不阻塞润色」的产品语义。网关本体失败(瞬时/回退耗尽)
仍上抛给端点(映射 503同 refine 记账/凭据纪律)。**只读、不写库**(不变量 #3
"""
req = build_clarify_request(spec, context=context, user_id=user_id, project_id=project_id)
resp = await gateway.run(req)
parsed = resp.parsed
if not isinstance(parsed, ClarifyDecision):
# 判别失败:确定性回退不问、放行(不阻塞润色主链路)。
log.warning(
"clarify_node_fallback_no_clarification",
spec=spec.name,
project_id=str(project_id) if project_id else None,
parsed_type=type(parsed).__name__,
)
return ClarifyDecision(need_clarification=False)
return parsed