From ec6bc501312a3cac727441aca012a3cbadda453f Mon Sep 17 00:00:00 2001 From: zizi Date: Sun, 23 Aug 2026 12:38:45 +0800 Subject: [PATCH] =?UTF-8?q?=E9=98=B6=E6=AE=B5F=E7=AC=AC=E4=B8=80=E9=83=A8?= =?UTF-8?q?=E5=88=86:=20=E4=B8=A4=E9=98=B6=E6=AE=B5=E5=86=99=E6=89=8B?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=8E=A2=E7=B4=A2=E4=B8=8E=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=88=86=E7=A6=BB=EF=BC=88=E7=A6=BB=E7=BA=BF=E7=BB=BF17?= =?UTF-8?q?=E9=A1=B9=EF=BC=8C=E5=85=A8=E9=87=8F=E9=97=A8=E7=A6=81=E6=97=A0?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/dispatch_writer_bridge.py | 51 ++- .../scripts/produce_next_chapter.py | 55 ++- .../scripts/two_phase_writer.py | 360 ++++++++++++++++++ .../2026-08-22-agent-example整体收敛总plan.md | 1 + ...˜¶段F-第一部分-两阶段写手-探索与生成分离.md | 59 +++ .../test_two_phase_writer.py | 289 ++++++++++++++ 6 files changed, 787 insertions(+), 28 deletions(-) create mode 100644 .agent/skills/write-next-chapter/scripts/two_phase_writer.py create mode 100644 docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md create mode 100644 tests/skills/write-next-chapter/test_two_phase_writer.py diff --git a/.agent/skills/write-next-chapter/scripts/dispatch_writer_bridge.py b/.agent/skills/write-next-chapter/scripts/dispatch_writer_bridge.py index 2b72cf4..d4c5c3e 100644 --- a/.agent/skills/write-next-chapter/scripts/dispatch_writer_bridge.py +++ b/.agent/skills/write-next-chapter/scripts/dispatch_writer_bridge.py @@ -71,10 +71,14 @@ class DispatchWriterReceipt: dispatch_run_id: str -def writer_session_paths(work_id: int, target_chapter: int) -> tuple[str, Path]: - """一章一个写作智能体会话:会话 ID 与目录按作品/章稳定,跨运行复用。""" +def writer_session_paths(work_id: int, target_chapter: int, label: str = "writer") -> tuple[str, Path]: + """一章一个写作智能体会话:会话 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 return session_id, session_dir @@ -85,28 +89,39 @@ def build_writer_dispatch_spec( target_chapter: int, human_instruction: str, candidate_version: int, + task_prompt: str | None = None, + creative_input: Mapping[str, Any] | None = None, + enable_read_tools: bool = True, ) -> dict[str, Any]: - """装配可移植任务包:冻结创作输入原样进 input,不掺框架字段。""" + """装配可移植任务包:冻结创作输入原样进 input,不掺框架字段。 + + task_prompt / creative_input 为覆盖位(缺省保持单阶段派发行为); + 两阶段写手的生成阶段用它们注入探索整理的输入并关闭工具。 + """ instruction = (human_instruction or "").strip() or "按冻结创作输入续写本章完整正文。" + default_task_prompt = ( + f"为第{target_chapter}章写正文候选(候选版本 {candidate_version})。" + f"人的创作指令:{instruction}" + "先用授权只读工具补齐动笔所需的细纲、人物状态与前文衔接,再写整章;" + "正文中的事实必须来自你实际读到的资料。" + ) return { "specVersion": "agent-task-v1", "role": "writer", - "taskPrompt": ( - f"为第{target_chapter}章写正文候选(候选版本 {candidate_version})。" - f"人的创作指令:{instruction}" - "先用授权只读工具补齐动笔所需的细纲、人物状态与前文衔接,再写整章;" - "正文中的事实必须来自你实际读到的资料。" - ), + "taskPrompt": task_prompt or default_task_prompt, "input": { "workId": context.get("workId"), "targetChapter": target_chapter, "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, "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, } @@ -143,6 +158,10 @@ def run_writer_via_dispatch( model: str, thinking: str | None = None, 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, launcher: Callable[..., Any] | None = None, connect_factory: Callable[..., Any] | None = None, @@ -150,6 +169,7 @@ def run_writer_via_dispatch( """派发一次写作智能体并绑定候选信封;返回(信封、回执适配、证据引用)。 任何失败抛 DispatchWriterError(失败关闭);不产生半绑定候选。 + 生成阶段(两阶段写手)传 enable_read_tools=False:不带工具单次成稿。 """ run_id = str(context.get("runId") or "") @@ -163,12 +183,15 @@ def run_writer_via_dispatch( target_chapter=target_chapter, human_instruction=human_instruction, 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.parent.mkdir(parents=True, exist_ok=True) 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) 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}, session_id=session_id, session_dir=session_dir, - enable_read_tools=True, + enable_read_tools=enable_read_tools, launcher=launcher, connect_factory=connect_factory, ) diff --git a/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py b/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py index 5371a6d..824e724 100644 --- a/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py +++ b/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py @@ -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_writer_run import persist_writer_execution # 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 check_writer_acceptance import AcceptanceError, check_writer_acceptance # 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 if dispatch_mode: 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_model = _take("--model") dispatch_thinking = _take("--thinking") @@ -423,6 +428,7 @@ def main(): candidates_by_version: dict[int, dict] = {} contexts_by_attempt: dict[int, dict] = {} writer_raw_refs: dict[int, tuple[Any, Any]] = {} + explorations_by_version: dict[int, dict] = {} def _diagnose_candidate(candidate_body: str, candidate_version: int) -> None: """人感技能 3:每个候选先做只读诊断并自动落质量账;不在这里改正文。""" @@ -440,20 +446,41 @@ def main(): contexts_by_attempt[current_context["attempt"]] = dict(current_context) if dispatch_mode: - 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 + if two_phase_mode: + try: + candidate, receipt, raw_ref, exploration = run_two_phase_writer( + current_context, + candidate_version=candidate_version, + repo_root=REPO_ROOT, + provider=dispatch_provider, + model=dispatch_model, + thinking=dispatch_thinking, + human_instruction=human_instruction, + spec_dir=ARTIFACTS, + ) + except ExplorationError as 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 candidates_by_version[candidate_version] = candidate writer_raw_refs[candidate_version] = raw_ref diff --git a/.agent/skills/write-next-chapter/scripts/two_phase_writer.py b/.agent/skills/write-next-chapter/scripts/two_phase_writer.py new file mode 100644 index 0000000..ecf9e73 --- /dev/null +++ b/.agent/skills/write-next-chapter/scripts/two_phase_writer.py @@ -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", +] diff --git a/docs/plans/2026-08-22-agent-example整体收敛总plan.md b/docs/plans/2026-08-22-agent-example整体收敛总plan.md index aed6567..0b02357 100644 --- a/docs/plans/2026-08-22-agent-example整体收敛总plan.md +++ b/docs/plans/2026-08-22-agent-example整体收敛总plan.md @@ -172,6 +172,7 @@ A、B 可并行;C 依赖 A;E 依赖 D;F 依赖 E;G 依赖 D(链路透 ### 阶段 F:评测链与知识链切换 + 终态裁决 - 意图:评委与抽取智能体接入框架派发(评委保持圈定授权隔离);用对照实验数据裁决直调链去留;处理写手探索冗余——生产链改为只给最小冻结输入(任务提示词 + 授权范围),由写作智能体真正自主探索取材,预组装完整上下文降为对照模式专用(2026-08-22 烟测实证:预组装下写手 0 次工具调用,探索无发生空间)。 +- 第一部分(已完成,离线绿):两阶段写手落地——探索阶段(只读工具,产出探索清单)与生成阶段(无工具,按回放资料单次成稿)分离;`--two-phase` 旗标;详见 `docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md`。 - 边界:对照只在显式对照模式运行;评测候选四层强制不可接受不变;知识草稿须经人确认转正。 - 验证:盲评隔离测试(禁看清单越权拒绝);对照产出可比正文与成本数据;退役项零引用后删除。 diff --git a/docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md b/docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md new file mode 100644 index 0000000..85ee67b --- /dev/null +++ b/docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md @@ -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 推进。 diff --git a/tests/skills/write-next-chapter/test_two_phase_writer.py b/tests/skills/write-next-chapter/test_two_phase_writer.py new file mode 100644 index 0000000..4f6bf63 --- /dev/null +++ b/tests/skills/write-next-chapter/test_two_phase_writer.py @@ -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()