#!/usr/bin/env python3 """抽取智能体派发桥离线测试(阶段 F 第二部分)。 固定合同:任务包装配(正文注入/白名单/修复提示词)、单轮派发产出合法抽取、 机械校验失败触发一轮修复重派、两轮失败走保守收口、派发失败失败关闭。 """ from __future__ import annotations import contextlib 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" / "extract-chapter-knowledge" / "scripts" for path in (SCRIPT_DIR,): if str(path) not in sys.path: sys.path.insert(0, str(path)) import dispatch_extraction_bridge as bridge # noqa: E402 from dispatch_extraction_bridge import ( # noqa: E402 DispatchExtractionError, build_extraction_task_spec, extractor_session_paths, run_extraction_via_dispatch, ) BODY = "第三章 波纹。茧从实验舰外舱门的裂口挤出去,金属边缘像被剥开的肋骨。林深盯着深渊方向。" def _valid_payload(evidence="茧从实验舰外舱门的裂口挤出去"): return { "entities": [ {"type": "character", "name": "林深", "brief": "试机师", "fields": {}, "evidence": "林深盯着深渊方向"}, {"type": "item", "name": "茧", "brief": "生物机甲", "fields": {}, "evidence": evidence}, ], "relations": [ {"source": "林深", "target": "茧", "type": "神经链接", "description": "链接", "evidence": "茧从实验舰外舱门的裂口挤出去"}, ], "state": {"currentSituation": "出击", "characterStates": {}, "foreshadowing": {"埋": [], "推": [], "收": []}, "handoff": ""}, } def _load_chapter_fake(work_id, chapter_order): return ("深渊机神", 301, "波纹", 901, BODY), ["林深", "茧"] class ExtractionSpecTest(unittest.TestCase): def test_spec_shape(self): spec = build_extraction_task_spec( work_id=12, chapter_order=3, title="深渊机神", chapter_title="波纹", existing_names=["林深"], body=BODY, ) self.assertEqual(spec["role"], "extractor") self.assertEqual(spec["input"]["body"], BODY) self.assertEqual(spec["input"]["existingEntityNames"], ["林深"]) self.assertEqual(spec["toolAllowlist"], ["read_chapter_text", "search_entities"]) self.assertIn("逐字连续片段", spec["taskPrompt"]) self.assertNotIn("机械校验失败", spec["taskPrompt"]) def test_spec_repair_appends_reason(self): spec = build_extraction_task_spec( work_id=12, chapter_order=3, title="深渊机神", chapter_title="波纹", existing_names=[], body=BODY, repair_reason="entities[0] 证据不在正文中", ) self.assertIn("机械校验失败", spec["taskPrompt"]) self.assertIn("不得新增条目", spec["taskPrompt"]) def test_session_paths(self): sid, path = extractor_session_paths(12, 3) self.assertEqual(sid, "extractor-work12-ch3") self.assertIn("extractor-sessions", str(path)) class OrchestrationTest(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.tmp = pathlib.Path(self._tmp.name) self.dispatches = [] def tearDown(self): self._tmp.cleanup() def _fake_dispatch(self, outputs): """outputs: 每次派发依次返回的 JSON(Exception 表示派发失败)。""" outputs = list(outputs) def fake(spec_file, **kwargs): self.dispatches.append(kwargs["run_id"]) run_dir = self.tmp / kwargs["run_id"] run_dir.mkdir(parents=True) out = outputs[len(self.dispatches) - 1] if isinstance(out, Exception): return {"status": "failed", "errorCode": "TIMEOUT"}, 1 (run_dir / "output.json").write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8") return {"status": "completed", "runDir": str(run_dir), "actualModelIds": ["catproxy-anthropic/claude-opus-5"], "usage": {"input_tokens": 10}}, 0 return fake @contextlib.contextmanager def _patched(self, outputs): fake = self._fake_dispatch(outputs) with mock.patch.object(bridge, "run_dispatch", fake), \ mock.patch.object(bridge, "_load_chapter", _load_chapter_fake), \ mock.patch.object(bridge, "start_run", lambda **kw: {"run_id": kw["run_id"]}), \ mock.patch.object(bridge, "finish_run", lambda *a, **kw: None), \ mock.patch.object(bridge, "record_failure", lambda *a, **kw: None), \ mock.patch.object(bridge, "_propose_chapter_extract_lesson", lambda **kw: None), \ mock.patch.object(bridge, "persist_extraction", lambda *a, **kw: {"draft_ids": [1, 2, 3], "state_draft_id": 9}): yield def _run(self, outputs): with self._patched(outputs): return run_extraction_via_dispatch( 12, 3, repo_root=self.tmp, provider="catproxy-anthropic", model="claude-opus-5", run_id="run-extract-test", spec_dir=self.tmp, ) def test_single_dispatch_success(self): summary = self._run([_valid_payload()]) self.assertEqual(summary["run_id"], "run-extract-test") self.assertEqual(len(summary["draft_ids"]), 3) self.assertEqual(self.dispatches, ["run-extract-test-extractor-v1"]) def test_repair_round_when_evidence_invalid(self): bad = _valid_payload(evidence="不在正文里的证据") summary = self._run([bad, _valid_payload()]) self.assertEqual(self.dispatches, ["run-extract-test-extractor-v1", "run-extract-test-extractor-v2"]) self.assertEqual(len(summary["draft_ids"]), 3) def test_salvage_after_two_failures(self): bad = _valid_payload(evidence="不在正文里的证据") summary = self._run([bad, bad]) # salvage:证据无效但实体名逐字在正文 → 保留并以实体名为最小证据;关系缺证据丢弃 self.assertEqual(len(summary["draft_ids"]), 3) self.assertEqual(self.dispatches, ["run-extract-test-extractor-v1", "run-extract-test-extractor-v2"]) def test_dispatch_failure_fails_closed(self): with self.assertRaises(DispatchExtractionError): self._run([TimeoutError()]) if __name__ == "__main__": unittest.main()