#!/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 / "muse" / "content" / "work" / "skills" / "generate" / "写下一章" / "scripts" for path in (SCRIPT_DIR,): if str(path) not in sys.path: sys.path.insert(0, str(path)) import dispatch_writer_bridge as bridge # noqa: E402 import two_phase_writer # 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-gen") 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()