阶段F第一部分: 两阶段写手——探索与生成分离(离线绿17项,全量门禁无回归)

This commit is contained in:
zizi 2026-08-23 12:38:45 +08:00
parent 78bbcbb6cf
commit ec6bc50131
6 changed files with 787 additions and 28 deletions

View File

@ -71,10 +71,14 @@ class DispatchWriterReceipt:
dispatch_run_id: str dispatch_run_id: str
def writer_session_paths(work_id: int, target_chapter: int) -> tuple[str, Path]: def writer_session_paths(work_id: int, target_chapter: int, label: str = "writer") -> tuple[str, Path]:
"""一章一个写作智能体会话:会话 ID 与目录按作品/章稳定,跨运行复用。""" """一章一个写作智能体会话:会话 ID 与目录按作品/章稳定,跨运行复用。
session_id = f"writer-work{work_id}-ch{target_chapter}" label 区分会话用途:生成阶段用默认 "writer"(跨版本积累写作连续性),
两阶段写手的探索阶段用 "writer-explore"(探索历史不混入生成上下文)。
"""
session_id = f"{label}-work{work_id}-ch{target_chapter}"
session_dir = SESSION_ROOT / session_id session_dir = SESSION_ROOT / session_id
return session_id, session_dir return session_id, session_dir
@ -85,28 +89,39 @@ def build_writer_dispatch_spec(
target_chapter: int, target_chapter: int,
human_instruction: str, human_instruction: str,
candidate_version: int, candidate_version: int,
task_prompt: str | None = None,
creative_input: Mapping[str, Any] | None = None,
enable_read_tools: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""装配可移植任务包:冻结创作输入原样进 input,不掺框架字段。""" """装配可移植任务包:冻结创作输入原样进 input,不掺框架字段。
task_prompt / creative_input 为覆盖位(缺省保持单阶段派发行为);
两阶段写手的生成阶段用它们注入探索整理的输入并关闭工具。
"""
instruction = (human_instruction or "").strip() or "按冻结创作输入续写本章完整正文。" instruction = (human_instruction or "").strip() or "按冻结创作输入续写本章完整正文。"
default_task_prompt = (
f"为第{target_chapter}章写正文候选(候选版本 {candidate_version})。"
f"人的创作指令:{instruction}"
"先用授权只读工具补齐动笔所需的细纲、人物状态与前文衔接,再写整章;"
"正文中的事实必须来自你实际读到的资料。"
)
return { return {
"specVersion": "agent-task-v1", "specVersion": "agent-task-v1",
"role": "writer", "role": "writer",
"taskPrompt": ( "taskPrompt": task_prompt or default_task_prompt,
f"为第{target_chapter}章写正文候选(候选版本 {candidate_version})。"
f"人的创作指令:{instruction}"
"先用授权只读工具补齐动笔所需的细纲、人物状态与前文衔接,再写整章;"
"正文中的事实必须来自你实际读到的资料。"
),
"input": { "input": {
"workId": context.get("workId"), "workId": context.get("workId"),
"targetChapter": target_chapter, "targetChapter": target_chapter,
"candidateVersion": candidate_version, "candidateVersion": candidate_version,
"creativeInput": build_writer_creative_input(context), "creativeInput": (
dict(creative_input) if creative_input is not None
else build_writer_creative_input(context)
),
}, },
"outputSchema": WRITER_DISPATCH_OUTPUT_SCHEMA, "outputSchema": WRITER_DISPATCH_OUTPUT_SCHEMA,
"outputSchemaId": "writer-candidate-body-v1", "outputSchemaId": "writer-candidate-body-v1",
"toolAllowlist": list(READ_TOOL_ALLOWLIST), "toolAllowlist": list(READ_TOOL_ALLOWLIST) if enable_read_tools else [],
"maxDurationSeconds": DEFAULT_MAX_DURATION_SECONDS, "maxDurationSeconds": DEFAULT_MAX_DURATION_SECONDS,
} }
@ -143,6 +158,10 @@ def run_writer_via_dispatch(
model: str, model: str,
thinking: str | None = None, thinking: str | None = None,
human_instruction: str = "", human_instruction: str = "",
task_prompt: str | None = None,
creative_input: Mapping[str, Any] | None = None,
enable_read_tools: bool = True,
session_label: str = "writer",
spec_path: str | Path | None = None, spec_path: str | Path | None = None,
launcher: Callable[..., Any] | None = None, launcher: Callable[..., Any] | None = None,
connect_factory: Callable[..., Any] | None = None, connect_factory: Callable[..., Any] | None = None,
@ -150,6 +169,7 @@ def run_writer_via_dispatch(
"""派发一次写作智能体并绑定候选信封;返回(信封、回执适配、证据引用)。 """派发一次写作智能体并绑定候选信封;返回(信封、回执适配、证据引用)。
任何失败抛 DispatchWriterError(失败关闭);不产生半绑定候选。 任何失败抛 DispatchWriterError(失败关闭);不产生半绑定候选。
生成阶段(两阶段写手)传 enable_read_tools=False:不带工具单次成稿。
""" """
run_id = str(context.get("runId") or "") run_id = str(context.get("runId") or "")
@ -163,12 +183,15 @@ def run_writer_via_dispatch(
target_chapter=target_chapter, target_chapter=target_chapter,
human_instruction=human_instruction, human_instruction=human_instruction,
candidate_version=candidate_version, candidate_version=candidate_version,
task_prompt=task_prompt,
creative_input=creative_input,
enable_read_tools=enable_read_tools,
) )
spec_file = Path(spec_path) if spec_path is not None else SCRIPT_DIR / f"{run_id}-writer-task-v{candidate_version}.json" spec_file = Path(spec_path) if spec_path is not None else SCRIPT_DIR / f"{run_id}-writer-task-v{candidate_version}.json"
spec_file.parent.mkdir(parents=True, exist_ok=True) spec_file.parent.mkdir(parents=True, exist_ok=True)
spec_file.write_text(json.dumps(spec, ensure_ascii=False, indent=1), encoding="utf-8") spec_file.write_text(json.dumps(spec, ensure_ascii=False, indent=1), encoding="utf-8")
session_id, session_dir = writer_session_paths(work_id, target_chapter) session_id, session_dir = writer_session_paths(work_id, target_chapter, label=session_label)
session_dir.mkdir(parents=True, mode=0o700, exist_ok=True) session_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
dispatch_run_id = f"{run_id}-writer-v{candidate_version}" dispatch_run_id = f"{run_id}-writer-v{candidate_version}"
@ -182,7 +205,7 @@ def run_writer_via_dispatch(
trigger_detail={"stage": "writer-dispatch", "productionRunId": run_id}, trigger_detail={"stage": "writer-dispatch", "productionRunId": run_id},
session_id=session_id, session_id=session_id,
session_dir=session_dir, session_dir=session_dir,
enable_read_tools=True, enable_read_tools=enable_read_tools,
launcher=launcher, launcher=launcher,
connect_factory=connect_factory, connect_factory=connect_factory,
) )

View File

@ -83,6 +83,7 @@ from run_writer_replay import profile_from_mapping # noqa: E402
from persist_llm_call import persist_call as persist_llm_event # noqa: E402 from persist_llm_call import persist_call as persist_llm_event # noqa: E402
from persist_writer_run import persist_writer_execution # noqa: E402 from persist_writer_run import persist_writer_execution # noqa: E402
from dispatch_writer_bridge import DispatchWriterError, run_writer_via_dispatch # noqa: E402 from dispatch_writer_bridge import DispatchWriterError, run_writer_via_dispatch # noqa: E402
from two_phase_writer import ExplorationError, run_two_phase_writer # noqa: E402
from run_registry import finish_run, start_run # noqa: E402 from run_registry import finish_run, start_run # noqa: E402
from check_writer_acceptance import AcceptanceError, check_writer_acceptance # noqa: E402 from check_writer_acceptance import AcceptanceError, check_writer_acceptance # noqa: E402
from acceptance_state import LiveStateError, build_live_acceptance_state # noqa: E402 from acceptance_state import LiveStateError, build_live_acceptance_state # noqa: E402
@ -290,6 +291,10 @@ def main():
dispatch_mode = "--dispatch-writer" in argv dispatch_mode = "--dispatch-writer" in argv
if dispatch_mode: if dispatch_mode:
argv = [arg for arg in argv if arg != "--dispatch-writer"] argv = [arg for arg in argv if arg != "--dispatch-writer"]
two_phase_mode = "--two-phase" in argv
if two_phase_mode:
argv = [arg for arg in argv if arg != "--two-phase"]
dispatch_mode = True
dispatch_provider = _take("--provider") dispatch_provider = _take("--provider")
dispatch_model = _take("--model") dispatch_model = _take("--model")
dispatch_thinking = _take("--thinking") dispatch_thinking = _take("--thinking")
@ -423,6 +428,7 @@ def main():
candidates_by_version: dict[int, dict] = {} candidates_by_version: dict[int, dict] = {}
contexts_by_attempt: dict[int, dict] = {} contexts_by_attempt: dict[int, dict] = {}
writer_raw_refs: dict[int, tuple[Any, Any]] = {} writer_raw_refs: dict[int, tuple[Any, Any]] = {}
explorations_by_version: dict[int, dict] = {}
def _diagnose_candidate(candidate_body: str, candidate_version: int) -> None: def _diagnose_candidate(candidate_body: str, candidate_version: int) -> None:
"""人感技能 3:每个候选先做只读诊断并自动落质量账;不在这里改正文。""" """人感技能 3:每个候选先做只读诊断并自动落质量账;不在这里改正文。"""
@ -440,20 +446,41 @@ def main():
contexts_by_attempt[current_context["attempt"]] = dict(current_context) contexts_by_attempt[current_context["attempt"]] = dict(current_context)
if dispatch_mode: if dispatch_mode:
try: if two_phase_mode:
candidate, receipt, raw_ref = run_writer_via_dispatch( try:
current_context, candidate, receipt, raw_ref, exploration = run_two_phase_writer(
candidate_version=candidate_version, current_context,
repo_root=REPO_ROOT, candidate_version=candidate_version,
provider=dispatch_provider, repo_root=REPO_ROOT,
model=dispatch_model, provider=dispatch_provider,
thinking=dispatch_thinking, model=dispatch_model,
human_instruction=human_instruction, thinking=dispatch_thinking,
spec_path=ARTIFACTS / f"{run_id}-writer-task-v{candidate_version}.json", human_instruction=human_instruction,
) spec_dir=ARTIFACTS,
except DispatchWriterError as exc: )
raise PipelineError(exc.code, f"writer 派发失败: {exc}", except ExplorationError as exc:
details=exc.details) from exc raise PipelineError(exc.code, f"两阶段写手失败: {exc}",
details=exc.details) from exc
explorations_by_version[candidate_version] = exploration
_dump(ARTIFACTS / f"{run_id}-exploration-summary-v{candidate_version}.json",
exploration)
print(f"两阶段写手模式: exploration_run={exploration['explorationRunId']} "
f"材料={exploration['materialCount']} 生成_run={exploration['generationRunId']}")
else:
try:
candidate, receipt, raw_ref = run_writer_via_dispatch(
current_context,
candidate_version=candidate_version,
repo_root=REPO_ROOT,
provider=dispatch_provider,
model=dispatch_model,
thinking=dispatch_thinking,
human_instruction=human_instruction,
spec_path=ARTIFACTS / f"{run_id}-writer-task-v{candidate_version}.json",
)
except DispatchWriterError as exc:
raise PipelineError(exc.code, f"writer 派发失败: {exc}",
details=exc.details) from exc
receipts_by_version[candidate_version] = receipt receipts_by_version[candidate_version] = receipt
candidates_by_version[candidate_version] = candidate candidates_by_version[candidate_version] = candidate
writer_raw_refs[candidate_version] = raw_ref writer_raw_refs[candidate_version] = raw_ref

View File

@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""两阶段写手(阶段 F 第一部分):探索与生成分离。
证据背景:烟测实证单阶段派发下多回合探索循环与长篇一次性产出合同冲突——
正文碎片散落在中间回合,最终消息只剩残稿(909 字候选被机械门正确拦截)。
两阶段形态:
1. 探索阶段:派发写作智能体(只读工具),产出探索清单(小结构化 JSON,
不占用长篇输出空间);探索运行独立记账(事件、raw、依赖清单)。
2. 生成阶段:按清单确定性回放资料(只读工具重放,无模型参与),把写手
实际依赖的上下文整理成冻结生成输入,再派发一次(无工具)单次成稿。
职责分界(边界合同):两个派发运行都记在各自的派发运行账下;生产编排
只绑定候选与质量证据。生成阶段沿用 dispatch_writer_bridge 的信封绑定与
回执适配,写作证据引用仍指向生成运行。
"""
from __future__ import annotations
import json
import sys
from collections import Counter
from pathlib import Path
from typing import Any, Callable, Mapping
SCRIPT_DIR = Path(__file__).resolve().parent
DISPATCH_SCRIPTS = SCRIPT_DIR.parents[1] / "dispatch-agent-task" / "scripts"
ASSEMBLE_SCRIPTS = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
for _path in (DISPATCH_SCRIPTS, ASSEMBLE_SCRIPTS):
if str(_path) not in sys.path:
sys.path.insert(0, str(_path))
from dispatch_agent_task import run_dispatch # noqa: E402
from pi_runner import ExecutionPolicy # noqa: E402
from read_tools import TOOL_REGISTRY, execute_tool # noqa: E402
from writer_contract import build_writer_creative_input # noqa: E402
from dispatch_writer_bridge import ( # noqa: E402
READ_TOOL_ALLOWLIST,
run_writer_via_dispatch,
writer_session_paths,
)
EXPLORATION_SCHEMA_VERSION = "writer-exploration-manifest-v1"
EXPLORATION_MAX_MATERIALS = 20
EXPLORATION_MAX_DURATION_SECONDS = 1200
EXPLORATION_SESSION_LABEL = "writer-explore"
EXPLORATION_OUTPUT_SCHEMA: dict[str, Any] = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": False,
"required": ["schemaVersion", "requiredMaterials"],
"properties": {
"schemaVersion": {"type": "string", "const": EXPLORATION_SCHEMA_VERSION},
"requiredMaterials": {
"type": "array",
"minItems": 1,
"maxItems": EXPLORATION_MAX_MATERIALS,
"items": {
"type": "object",
"additionalProperties": False,
"required": ["tool", "args", "reason"],
"properties": {
"tool": {"type": "string", "enum": list(READ_TOOL_ALLOWLIST)},
"args": {"type": "object"},
"reason": {"type": "string", "minLength": 1},
},
},
},
"styleNotes": {"type": "array", "items": {"type": "string"}},
"continuityNotes": {"type": "string"},
},
}
class ExplorationError(RuntimeError):
"""两阶段写手失败:携带稳定错误码,编排方按失败关闭处理。"""
def __init__(self, code: str, message: str, *, details: Mapping[str, Any] | None = None):
super().__init__(message)
self.code = code
self.details = dict(details or {})
def build_exploration_spec(
context: Mapping[str, Any],
*,
target_chapter: int,
human_instruction: str,
candidate_version: int,
) -> dict[str, Any]:
"""装配探索阶段任务包:只探索不写作,产出探索清单。"""
work_id = context.get("workId")
return {
"specVersion": "agent-task-v1",
"role": "writer",
"taskPrompt": (
f"你是写作智能体的探索阶段。任务:用授权只读工具探索为作品 {work_id} "
f"第{target_chapter}章写正文所需的资料。不要写正文。"
"探索完成后只输出一个 manifest JSON(schema "
f"{EXPLORATION_SCHEMA_VERSION}):requiredMaterials 逐项列出"
"生成阶段需要带入的每份资料(用哪个工具、什么参数、为什么需要);"
"styleNotes 与 continuityNotes 可记录你在探索中观察到的文风与衔接要点。"
"生成阶段正文中的事实只能来自你本次探索实际读到的资料。"
),
"input": {
"workId": work_id,
"targetChapter": target_chapter,
"candidateVersion": candidate_version,
"humanInstruction": (human_instruction or "").strip(),
},
"outputSchema": EXPLORATION_OUTPUT_SCHEMA,
"outputSchemaId": "writer-exploration-manifest-v1",
"toolAllowlist": list(READ_TOOL_ALLOWLIST),
"maxDurationSeconds": EXPLORATION_MAX_DURATION_SECONDS,
}
def parse_exploration_manifest(raw_text: str) -> dict[str, Any]:
"""严格校验探索清单;任何结构偏差失败关闭。"""
try:
data = json.loads(raw_text)
except ValueError as exc:
raise ExplorationError(
"EXPLORATION_MANIFEST_INVALID", f"探索清单不是合法 JSON:{exc}"
) from exc
if not isinstance(data, Mapping):
raise ExplorationError("EXPLORATION_MANIFEST_INVALID", "探索清单必须是 JSON 对象")
if data.get("schemaVersion") != EXPLORATION_SCHEMA_VERSION:
raise ExplorationError(
"EXPLORATION_MANIFEST_INVALID",
f"探索清单 schemaVersion 必须为 {EXPLORATION_SCHEMA_VERSION}",
)
materials = data.get("requiredMaterials")
if not isinstance(materials, list) or not materials:
raise ExplorationError(
"EXPLORATION_MANIFEST_INVALID",
"requiredMaterials 必须是非空列表:生成阶段的事实只能来自探索资料",
)
if len(materials) > EXPLORATION_MAX_MATERIALS:
raise ExplorationError(
"EXPLORATION_MANIFEST_INVALID",
f"requiredMaterials 超过上限 {EXPLORATION_MAX_MATERIALS} 条",
)
for index, item in enumerate(materials):
if not isinstance(item, Mapping):
raise ExplorationError(
"EXPLORATION_MANIFEST_INVALID", f"requiredMaterials[{index}] 必须是对象"
)
tool = item.get("tool")
if not isinstance(tool, str) or tool not in TOOL_REGISTRY:
raise ExplorationError(
"EXPLORATION_MANIFEST_INVALID",
f"requiredMaterials[{index}].tool 不在只读工具登记表:{tool!r}",
)
if not isinstance(item.get("args"), Mapping):
raise ExplorationError(
"EXPLORATION_MANIFEST_INVALID", f"requiredMaterials[{index}].args 必须是对象"
)
reason = item.get("reason")
if not isinstance(reason, str) or not reason.strip():
raise ExplorationError(
"EXPLORATION_MANIFEST_INVALID", f"requiredMaterials[{index}].reason 必须非空"
)
style_notes = data.get("styleNotes")
if style_notes is not None and not (
isinstance(style_notes, list) and all(isinstance(x, str) for x in style_notes)
):
raise ExplorationError("EXPLORATION_MANIFEST_INVALID", "styleNotes 必须是字符串列表")
continuity = data.get("continuityNotes")
if continuity is not None and not isinstance(continuity, str):
raise ExplorationError("EXPLORATION_MANIFEST_INVALID", "continuityNotes 必须是字符串")
return dict(data)
def replay_manifest_materials(
manifest: Mapping[str, Any],
*,
connect_factory: Callable[..., Any] | None = None,
) -> list[dict[str, Any]]:
"""确定性回放探索清单:无模型参与,逐条重放只读工具取回资料。"""
materials: list[dict[str, Any]] = []
for item in manifest["requiredMaterials"]:
try:
result = execute_tool(item["tool"], item["args"], connect=connect_factory)
except Exception as exc: # 工具层异常一律失败关闭,不带残缺资料进生成
raise ExplorationError(
"EXPLORATION_REPLAY_FAILED",
f"资料回放失败:{item['tool']} 参数 {json.dumps(item['args'], ensure_ascii=False)}:{exc}",
details={"tool": item["tool"], "args": dict(item["args"])},
) from exc
materials.append(
{"tool": item["tool"], "args": dict(item["args"]), "reason": item["reason"], "result": result}
)
return materials
def build_generation_creative_input(
context: Mapping[str, Any],
manifest: Mapping[str, Any],
materials: list[dict[str, Any]],
*,
human_instruction: str = "",
) -> dict[str, Any]:
"""整理生成输入:资料来自探索回放,合同部分来自冻结上下文投影。"""
projected = build_writer_creative_input(context)
creative: dict[str, Any] = {
"inputMode": "two-phase-generation-v1",
"humanInstruction": (human_instruction or "").strip() or "基于探索资料续写本章完整正文。",
"explorationMaterials": materials,
"lengthContract": projected["lengthContract"],
"styleConstraints": projected["styleConstraints"],
}
notes: dict[str, Any] = {}
style_notes = manifest.get("styleNotes")
if style_notes:
notes["styleNotes"] = [str(x) for x in style_notes]
continuity = manifest.get("continuityNotes")
if isinstance(continuity, str) and continuity.strip():
notes["continuityNotes"] = continuity.strip()
if notes:
notes["note"] = "探索笔记是模型观察,仅供参考;正文事实必须以 explorationMaterials 为准。"
creative["explorationNotes"] = notes
return creative
def run_two_phase_writer(
context: Mapping[str, Any],
*,
candidate_version: int,
repo_root: str | Path,
provider: str,
model: str,
thinking: str | None = None,
human_instruction: str = "",
spec_dir: str | Path | None = None,
launcher: Callable[..., Any] | None = None,
connect_factory: Callable[..., Any] | None = None,
) -> tuple[dict[str, Any], Any, tuple[Any, Any], dict[str, Any]]:
"""两阶段派发写作:探索(有工具)→ 回放整理 → 生成(无工具单次成稿)。
返回(候选信封、生成回执适配、证据引用、探索摘要)。
任何阶段失败抛 ExplorationError / DispatchWriterError(失败关闭)。
"""
run_id = str(context.get("runId") or "")
work_id = context.get("workId")
target_chapter = context.get("targetChapter")
if not run_id or not isinstance(work_id, int) or not isinstance(target_chapter, int):
raise ExplorationError(
"EXPLORATION_CONTEXT_INVALID", "WriterContext 缺 runId/workId/targetChapter"
)
spec_root = Path(spec_dir) if spec_dir is not None else SCRIPT_DIR
spec_root.mkdir(parents=True, exist_ok=True)
# 阶段一:探索派发(独立派发运行,独立会话)
explore_spec = build_exploration_spec(
context,
target_chapter=target_chapter,
human_instruction=human_instruction,
candidate_version=candidate_version,
)
explore_spec_file = spec_root / f"{run_id}-exploration-task-v{candidate_version}.json"
explore_spec_file.write_text(
json.dumps(explore_spec, ensure_ascii=False, indent=1), encoding="utf-8"
)
explore_session_id, explore_session_dir = writer_session_paths(
work_id, target_chapter, label=EXPLORATION_SESSION_LABEL
)
explore_session_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
explore_run_id = f"{run_id}-explore-v{candidate_version}"
policy = ExecutionPolicy(provider=provider, model=model, thinking=thinking)
receipt, code = run_dispatch(
explore_spec_file,
repo_root=repo_root,
policy=policy,
run_id=explore_run_id,
trigger_source="user",
trigger_detail={"stage": "writer-exploration", "productionRunId": run_id},
session_id=explore_session_id,
session_dir=explore_session_dir,
enable_read_tools=True,
launcher=launcher,
connect_factory=connect_factory,
)
if code != 0 or receipt.get("status") != "completed":
raise ExplorationError(
str(receipt.get("errorCode") or "EXPLORATION_DISPATCH_FAILED"),
f"写作智能体探索派发未成功:{receipt.get('error') or receipt.get('errorCode')}",
details={"explorationRunId": explore_run_id, "exitCode": code},
)
# 探索产出:结构化输出回读派发运行目录,不另造权威
output_file = Path(str(receipt.get("runDir") or "")) / "output.json"
try:
raw_output = output_file.read_text(encoding="utf-8")
except OSError as exc:
raise ExplorationError(
"EXPLORATION_OUTPUT_MISSING",
f"探索运行未落结构化输出:{output_file}",
details={"explorationRunId": explore_run_id},
) from exc
manifest = parse_exploration_manifest(raw_output)
materials = replay_manifest_materials(manifest, connect_factory=connect_factory)
creative_input = build_generation_creative_input(
context, manifest, materials, human_instruction=human_instruction
)
# 阶段二:生成派发(无工具,单次成稿;信封绑定复用派发桥)
generation_task_prompt = (
f"为第{target_chapter}章写正文候选(候选版本 {candidate_version})。"
f"人的创作指令:{(human_instruction or '').strip() or '基于探索资料续写本章完整正文。'}"
"所需资料已由你的探索阶段整理在冻结创作输入中,不要再调用工具;"
"在单条回复里一次写完本章全部正文并输出完整 JSON。"
"正文中的事实必须来自探索资料。"
)
envelope, writer_receipt, raw_ref = run_writer_via_dispatch(
context,
candidate_version=candidate_version,
repo_root=repo_root,
provider=provider,
model=model,
thinking=thinking,
human_instruction=human_instruction,
task_prompt=generation_task_prompt,
creative_input=creative_input,
enable_read_tools=False,
session_label="writer",
spec_path=spec_root / f"{run_id}-writer-task-v{candidate_version}-gen.json",
launcher=launcher,
connect_factory=connect_factory,
)
exploration_summary = {
"explorationRunId": explore_run_id,
"explorationSessionId": explore_session_id,
"manifestSchemaVersion": EXPLORATION_SCHEMA_VERSION,
"materialCount": len(materials),
"tools": dict(Counter(item["tool"] for item in materials)),
"styleNotes": len(manifest.get("styleNotes") or []),
"generationRunId": writer_receipt.dispatch_run_id,
}
return envelope, writer_receipt, raw_ref, exploration_summary
__all__ = [
"EXPLORATION_MAX_MATERIALS",
"EXPLORATION_OUTPUT_SCHEMA",
"EXPLORATION_SCHEMA_VERSION",
"EXPLORATION_SESSION_LABEL",
"ExplorationError",
"build_exploration_spec",
"build_generation_creative_input",
"parse_exploration_manifest",
"replay_manifest_materials",
"run_two_phase_writer",
]

View File

@ -172,6 +172,7 @@ A、B 可并行;C 依赖 A;E 依赖 D;F 依赖 E;G 依赖 D(链路透
### 阶段 F:评测链与知识链切换 + 终态裁决 ### 阶段 F:评测链与知识链切换 + 终态裁决
- 意图:评委与抽取智能体接入框架派发(评委保持圈定授权隔离);用对照实验数据裁决直调链去留;处理写手探索冗余——生产链改为只给最小冻结输入(任务提示词 + 授权范围),由写作智能体真正自主探索取材,预组装完整上下文降为对照模式专用(2026-08-22 烟测实证:预组装下写手 0 次工具调用,探索无发生空间)。 - 意图:评委与抽取智能体接入框架派发(评委保持圈定授权隔离);用对照实验数据裁决直调链去留;处理写手探索冗余——生产链改为只给最小冻结输入(任务提示词 + 授权范围),由写作智能体真正自主探索取材,预组装完整上下文降为对照模式专用(2026-08-22 烟测实证:预组装下写手 0 次工具调用,探索无发生空间)。
- 第一部分(已完成,离线绿):两阶段写手落地——探索阶段(只读工具,产出探索清单)与生成阶段(无工具,按回放资料单次成稿)分离;`--two-phase` 旗标;详见 `docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md`。
- 边界:对照只在显式对照模式运行;评测候选四层强制不可接受不变;知识草稿须经人确认转正。 - 边界:对照只在显式对照模式运行;评测候选四层强制不可接受不变;知识草稿须经人确认转正。
- 验证:盲评隔离测试(禁看清单越权拒绝);对照产出可比正文与成本数据;退役项零引用后删除。 - 验证:盲评隔离测试(禁看清单越权拒绝);对照产出可比正文与成本数据;退役项零引用后删除。

View File

@ -0,0 +1,59 @@
# 阶段 F 第一部分:两阶段写手(探索与生成分离)
日期:2026-08-22
状态:已完成(离线绿)
上游证据:两次生产烟测(`run-prod-work12-ch3-e7829416` / `run-prod-work12-ch3-59f43855`)
## 1. 问题(烟测实证)
单阶段派发下,多回合探索循环与长篇一次性产出合同冲突:
- 第一次烟测(medium,带元标签指令):写手 0 次工具调用(预组装上下文让探索冗余),正文复读细纲原句「链接加深」,机械门 `OUTLINE_PHRASE_LEAKED` 拦截。
- 第二次烟测(high,指令留空):写手真实探索(26 次工具调用、6 回合、依赖清单落盘),但正文碎片散落在中间回合,最终消息只剩 909 字残稿,机械门 `CANDIDATE_LENGTH_OUT_OF_RANGE` + `HARD_EVENT_MISSING` 拦截。
结论:探索与生成必须分离——探索走多回合框架循环,生成走单次成稿。
## 2. 设计
```text
阶段一(探索):派发写作智能体(只读工具,独立探索会话)
产出 = 探索清单(小结构化 JSON,writer-exploration-manifest-v1)
独立派发运行记账(事件、raw、依赖清单)
↓
确定性回放:按清单逐条重放只读工具(无模型参与)
产出 = 写手实际依赖的资料(含来源工具与参数)
↓
阶段二(生成):派发写作智能体(无工具,写作会话)
冻结生成输入 = 探索资料 + 篇幅/文风合同(来自冻结上下文投影)
单条回复一次写完本章全部正文
↓
信封绑定:复用 dispatch_writer_bridge(身份、哈希、版本由桥绑定)
```
关键取舍:
- 生成输入不含预组装(细纲/基线/事实摘录/范式卡不进生成输入);写手事实只来自它探索到的资料。合同部分(篇幅、文风约束)仍由生产编排冻结注入。
- 探索清单回放是确定性的:同一清单重放得到同一份资料;资料与清单一并构成链路透视数据。
- 探索失败、清单非法、回放失败、生成失败一律失败关闭,不产生半绑定候选。
- 探索会话(`writer-explore-work{W}-ch{T}`)与生成会话(`writer-work{W}-ch{T}`)分离:探索历史不混入生成上下文,写作连续性跨版本保留。
- 证据归属不变:探索运行与生成运行各自记模型事件/raw;生产运行只绑候选与质量证据;`writer_raw_ref` 指向生成运行。
## 3. 改动台账(逐项)
| 文件 | 动作 | 原因 |
|---|---|---|
| `.agent/skills/write-next-chapter/scripts/two_phase_writer.py` | 新增 | 两阶段编排:探索任务包装配、清单严格校验、确定性回放、生成输入装配、两阶段派发编排 |
| `.agent/skills/write-next-chapter/scripts/dispatch_writer_bridge.py` | 修改 | 增加覆盖位(任务提示词、冻结创作输入、工具开关、会话标签);缺省行为完全不变 |
| `.agent/skills/write-next-chapter/scripts/produce_next_chapter.py` | 修改 | 新增 `--two-phase` 旗标(隐式派发模式);探索摘要落工件与终端 |
| `tests/skills/write-next-chapter/test_two_phase_writer.py` | 新增 | 17 项离线测试:清单校验 7、回放 2、生成输入 2、编排 3、任务包 1、桥扩展 2 |
## 4. 验证
- 新增测试 17/17 通过。
- 全量离线门禁:82 个标准 `OK` + 19 个自定义输出通过(逐一核实无真失败),0 失败;桥扩展未破坏既有用例。
- 真实验证留待下一步:`produce_next_chapter.py 3 --two-phase --provider catproxy-anthropic --model claude-opus-5 --thinking high`。
## 5. 未决(下一阶段)
- 两阶段真实烟测通过后:两阶段成为生产默认形态,单阶段派发降级为对照模式;角色合同与领域文档同步改写(写手 = 探索阶段 + 生成阶段)。
- 评委与抽取智能体的框架派发接入、直调链终态裁决仍按总 plan 阶段 F 推进。

View File

@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""两阶段写手离线测试(阶段 F 第一部分):探索与生成分离。
固定合同:探索清单严格校验(失败关闭)、资料确定性回放(无模型参与)、
生成输入只含探索资料与合同部分(不含预组装)、编排两阶段各记各的派发运行、
探索失败/清单非法一律失败关闭、派发桥的覆盖位与工具开关。
"""
from __future__ import annotations
import json
import pathlib
import sys
import tempfile
import unittest
from unittest import mock
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
SCRIPT_DIR = PROJECT_ROOT / ".agent" / "skills" / "write-next-chapter" / "scripts"
for path in (SCRIPT_DIR,):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
import two_phase_writer # noqa: E402
import dispatch_writer_bridge as bridge # noqa: E402
from two_phase_writer import ( # noqa: E402
EXPLORATION_MAX_MATERIALS,
EXPLORATION_SCHEMA_VERSION,
ExplorationError,
build_exploration_spec,
build_generation_creative_input,
parse_exploration_manifest,
replay_manifest_materials,
run_two_phase_writer,
)
def _valid_manifest(materials=None, **extra):
if materials is None:
materials = [
{"tool": "read_fine_outline", "args": {"work_id": 12, "target_chapter": 3}, "reason": "硬事件与约束"},
{"tool": "read_chapter_text", "args": {"work_id": 12, "chapter_order": 2}, "reason": "上一章衔接"},
]
data = {
"schemaVersion": EXPLORATION_SCHEMA_VERSION,
"requiredMaterials": materials,
}
data.update(extra)
return data
def _fake_projected():
return {
"lengthContract": {"targetChars": 7000, "minChars": 4000, "maxChars": 10000, "frontmatterRequired": False},
"styleConstraints": ["保留第一人称"],
}
class ParseManifestTest(unittest.TestCase):
def test_parse_ok(self):
data = parse_exploration_manifest(json.dumps(_valid_manifest(), ensure_ascii=False))
self.assertEqual(len(data["requiredMaterials"]), 2)
def test_rejects_bad_schema_version(self):
bad = _valid_manifest()
bad["schemaVersion"] = "other-v1"
with self.assertRaises(ExplorationError) as ctx:
parse_exploration_manifest(json.dumps(bad, ensure_ascii=False))
self.assertEqual(ctx.exception.code, "EXPLORATION_MANIFEST_INVALID")
def test_rejects_unknown_tool(self):
bad = _valid_manifest(materials=[{"tool": "drop_table", "args": {}, "reason": "x"}])
with self.assertRaises(ExplorationError) as ctx:
parse_exploration_manifest(json.dumps(bad, ensure_ascii=False))
self.assertIn("不在只读工具登记表", str(ctx.exception))
def test_rejects_empty_materials(self):
bad = _valid_manifest(materials=[])
with self.assertRaises(ExplorationError):
parse_exploration_manifest(json.dumps(bad, ensure_ascii=False))
def test_rejects_too_many_materials(self):
many = [{"tool": "read_fine_outline", "args": {"work_id": 12, "target_chapter": 3}, "reason": "x"}
for _ in range(EXPLORATION_MAX_MATERIALS + 1)]
with self.assertRaises(ExplorationError):
parse_exploration_manifest(json.dumps(_valid_manifest(materials=many), ensure_ascii=False))
def test_rejects_blank_reason(self):
bad = _valid_manifest(materials=[{"tool": "read_fine_outline", "args": {"work_id": 12, "target_chapter": 3}, "reason": " "}])
with self.assertRaises(ExplorationError):
parse_exploration_manifest(json.dumps(bad, ensure_ascii=False))
def test_rejects_non_json(self):
with self.assertRaises(ExplorationError):
parse_exploration_manifest("not json")
class ReplayTest(unittest.TestCase):
def test_calls_registry_and_preserves_order(self):
calls = []
def fake_execute(tool, args, connect=None):
calls.append((tool, dict(args)))
return {"ok": True, "tool": tool}
with mock.patch.object(two_phase_writer, "execute_tool", fake_execute):
materials = replay_manifest_materials(_valid_manifest())
self.assertEqual(calls, [
("read_fine_outline", {"work_id": 12, "target_chapter": 3}),
("read_chapter_text", {"work_id": 12, "chapter_order": 2}),
])
self.assertEqual([m["tool"] for m in materials], ["read_fine_outline", "read_chapter_text"])
self.assertTrue(all(m["result"]["ok"] is True for m in materials))
self.assertEqual(materials[0]["reason"], "硬事件与约束")
def test_fails_closed_on_tool_error(self):
def boom(tool, args, connect=None):
raise RuntimeError("db down")
with mock.patch.object(two_phase_writer, "execute_tool", boom):
with self.assertRaises(ExplorationError) as ctx:
replay_manifest_materials(_valid_manifest())
self.assertEqual(ctx.exception.code, "EXPLORATION_REPLAY_FAILED")
class GenerationInputTest(unittest.TestCase):
def test_uses_explored_materials_not_preassembly(self):
materials = [{"tool": "read_fine_outline", "args": {"work_id": 12, "target_chapter": 3},
"reason": "硬事件", "result": {"outline": "x"}}]
manifest = _valid_manifest(styleNotes=["节奏偏快"], continuityNotes="上一章结尾钩子在尾句")
with mock.patch.object(two_phase_writer, "build_writer_creative_input", lambda context: _fake_projected()):
creative = build_generation_creative_input(
{"runId": "r", "workId": 12, "targetChapter": 3},
manifest, materials, human_instruction="往污染线推进",
)
self.assertEqual(creative["inputMode"], "two-phase-generation-v1")
self.assertEqual(creative["humanInstruction"], "往污染线推进")
self.assertEqual(creative["explorationMaterials"], materials)
self.assertEqual(creative["lengthContract"]["targetChars"], 7000)
self.assertEqual(creative["styleConstraints"], ["保留第一人称"])
# 预组装字段不得进入生成输入(写手事实只来自探索资料)
self.assertNotIn("fineOutline", creative)
self.assertEqual(creative["explorationNotes"]["styleNotes"], ["节奏偏快"])
self.assertIn("仅供参考", creative["explorationNotes"]["note"])
def test_default_instruction(self):
with mock.patch.object(two_phase_writer, "build_writer_creative_input", lambda context: _fake_projected()):
creative = build_generation_creative_input({}, _valid_manifest(), [], human_instruction="")
self.assertEqual(creative["humanInstruction"], "基于探索资料续写本章完整正文。")
self.assertNotIn("explorationNotes", creative)
class OrchestrationTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp_path = pathlib.Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def _run(self, fake_dispatch, fake_generation=None, fake_execute=None):
context = {"runId": "run-test", "workId": 12, "targetChapter": 3}
with mock.patch.object(two_phase_writer, "run_dispatch", fake_dispatch), \
mock.patch.object(two_phase_writer, "execute_tool",
fake_execute or (lambda tool, args, connect=None: {"ok": True})), \
mock.patch.object(two_phase_writer, "build_writer_creative_input",
lambda context: _fake_projected()):
if fake_generation is not None:
with mock.patch.object(two_phase_writer, "run_writer_via_dispatch", fake_generation):
return run_two_phase_writer(
context, candidate_version=9, repo_root=self.tmp_path,
provider="catproxy-anthropic", model="claude-opus-5", thinking="high",
human_instruction="", spec_dir=self.tmp_path,
)
return run_two_phase_writer(
context, candidate_version=9, repo_root=self.tmp_path,
provider="catproxy-anthropic", model="claude-opus-5", thinking="high",
human_instruction="", spec_dir=self.tmp_path,
)
def test_two_phase_orchestration(self):
manifest = _valid_manifest(styleNotes=["短句"])
dispatched = []
def fake_dispatch(spec_file, **kwargs):
dispatched.append({"spec": json.loads(pathlib.Path(spec_file).read_text(encoding="utf-8")), "kwargs": kwargs})
run_dir = self.tmp_path / kwargs["run_id"]
run_dir.mkdir(parents=True)
(run_dir / "output.json").write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
return {"status": "completed", "runDir": str(run_dir)}, 0
captured = {}
class _GenReceipt:
dispatch_run_id = "run-test-writer-v9"
def fake_generation(context, **kwargs):
captured.update(kwargs)
return {"candidateBody": "正文"}, _GenReceipt(), (7, 8)
envelope, receipt, raw_ref, exploration = self._run(fake_dispatch, fake_generation)
# 探索阶段:独立运行与会话、只读工具开启
self.assertEqual(dispatched[0]["kwargs"]["run_id"], "run-test-explore-v9")
self.assertEqual(dispatched[0]["kwargs"]["session_id"], "writer-explore-work12-ch3")
self.assertTrue(dispatched[0]["kwargs"]["enable_read_tools"])
self.assertEqual(dispatched[0]["spec"]["outputSchemaId"], "writer-exploration-manifest-v1")
# 生成阶段:无工具、输入为探索整理
self.assertFalse(captured["enable_read_tools"])
self.assertEqual(captured["session_label"], "writer")
self.assertEqual(captured["creative_input"]["inputMode"], "two-phase-generation-v1")
self.assertEqual(captured["creative_input"]["explorationMaterials"][0]["tool"], "read_fine_outline")
self.assertIn("不要再调用工具", captured["task_prompt"])
self.assertEqual(envelope["candidateBody"], "正文")
self.assertEqual(raw_ref, (7, 8))
self.assertEqual(exploration["explorationRunId"], "run-test-explore-v9")
self.assertEqual(exploration["materialCount"], 2)
self.assertEqual(exploration["generationRunId"], "run-test-writer-v9")
self.assertEqual(exploration["tools"], {"read_fine_outline": 1, "read_chapter_text": 1})
def test_fails_closed_when_exploration_dispatch_fails(self):
def fake_dispatch(spec_file, **kwargs):
return {"status": "failed", "errorCode": "TIMEOUT"}, 1
with self.assertRaises(ExplorationError) as ctx:
self._run(fake_dispatch)
self.assertEqual(ctx.exception.code, "TIMEOUT")
def test_fails_closed_when_manifest_invalid(self):
def fake_dispatch(spec_file, **kwargs):
run_dir = self.tmp_path / kwargs["run_id"]
run_dir.mkdir(parents=True)
(run_dir / "output.json").write_text('{"schemaVersion": "wrong"}', encoding="utf-8")
return {"status": "completed", "runDir": str(run_dir)}, 0
with self.assertRaises(ExplorationError) as ctx:
self._run(fake_dispatch)
self.assertEqual(ctx.exception.code, "EXPLORATION_MANIFEST_INVALID")
class ExplorationSpecTest(unittest.TestCase):
def test_spec_shape(self):
spec = build_exploration_spec(
{"workId": 12}, target_chapter=3, human_instruction="推进污染线", candidate_version=2,
)
self.assertEqual(spec["role"], "writer")
self.assertEqual(spec["input"], {"workId": 12, "targetChapter": 3, "candidateVersion": 2, "humanInstruction": "推进污染线"})
self.assertIn("不要写正文", spec["taskPrompt"])
self.assertTrue(spec["toolAllowlist"])
self.assertEqual(spec["maxDurationSeconds"], two_phase_writer.EXPLORATION_MAX_DURATION_SECONDS)
class BridgeExtensionTest(unittest.TestCase):
def test_spec_overrides_and_tool_switch(self):
context = {"workId": 12, "runId": "r", "targetChapter": 3}
with mock.patch.object(bridge, "build_writer_creative_input", lambda context: {"fineOutline": {}}):
spec = bridge.build_writer_dispatch_spec(
context, target_chapter=3, human_instruction="", candidate_version=1,
task_prompt="自定义任务", creative_input={"inputMode": "two-phase-generation-v1"},
enable_read_tools=False,
)
self.assertEqual(spec["taskPrompt"], "自定义任务")
self.assertEqual(spec["input"]["creativeInput"], {"inputMode": "two-phase-generation-v1"})
self.assertEqual(spec["toolAllowlist"], [])
with mock.patch.object(bridge, "build_writer_creative_input", lambda context: {"fineOutline": {}}):
default_spec = bridge.build_writer_dispatch_spec(
context, target_chapter=3, human_instruction="", candidate_version=1,
)
self.assertTrue(default_spec["toolAllowlist"]) # 缺省保持单阶段行为:带工具
self.assertIn("先用授权只读工具", default_spec["taskPrompt"])
def test_session_labels(self):
sid, _ = bridge.writer_session_paths(12, 3)
explore_sid, _ = bridge.writer_session_paths(12, 3, label="writer-explore")
self.assertEqual(sid, "writer-work12-ch3")
self.assertEqual(explore_sid, "writer-explore-work12-ch3")
self.assertNotEqual(explore_sid, sid)
if __name__ == "__main__":
unittest.main()