阶段F第一部分: 两阶段写手——探索与生成分离(离线绿17项,全量门禁无回归)
This commit is contained in:
parent
78bbcbb6cf
commit
ec6bc50131
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
360
.agent/skills/write-next-chapter/scripts/two_phase_writer.py
Normal file
360
.agent/skills/write-next-chapter/scripts/two_phase_writer.py
Normal 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",
|
||||
]
|
||||
@ -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`。
|
||||
- 边界:对照只在显式对照模式运行;评测候选四层强制不可接受不变;知识草稿须经人确认转正。
|
||||
- 验证:盲评隔离测试(禁看清单越权拒绝);对照产出可比正文与成本数据;退役项零引用后删除。
|
||||
|
||||
|
||||
59
docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md
Normal file
59
docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md
Normal 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 推进。
|
||||
289
tests/skills/write-next-chapter/test_two_phase_writer.py
Normal file
289
tests/skills/write-next-chapter/test_two_phase_writer.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user