#!/usr/bin/env python3 """CandidateEnvelope v2 机械硬门的离线测试。""" from __future__ import annotations import copy import hashlib import pathlib import sys import unittest PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SKILLS_DIR = PROJECT_ROOT / ".agent" / "skills" SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "quality" / "skills" / "semantic" / "核对内容一致性" / "scripts" CONTINUATION_DIR = PROJECT_ROOT / "muse" / "content" / "work" / "skills" / "generate" / "写下一章" / "scripts" READ_CONTEXT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "context" / "skills" / "准备任务上下文" / "scripts" WRITER_TEST_DIR = PROJECT_ROOT / "tests" / "skills" / "写下一章" for path in (SCRIPT_DIR, CONTINUATION_DIR, READ_CONTEXT_DIR, WRITER_TEST_DIR): if str(path) not in sys.path: sys.path.insert(0, str(path)) from check_writer_candidate import check_writer_candidate # noqa: E402 from test_run_writer import _bound_context # noqa: E402 from writer_contract import build_candidate_envelope, han_count # noqa: E402 def _candidate_body() -> str: prefix = "林澈完成围攻突围,又把旧徽章压回掌心。" hook = "城门忽然打开" return prefix + "文" * (4000 - han_count(prefix) - han_count(hook)) + hook def _requirements() -> dict: return { "requiredEvents": [{"requirementId": "event-1", "anchors": ["完成围攻突围"]}], "requiredCharacters": ["林澈"], "foreshadowingActions": [{"requirementId": "foreshadow-1", "anchors": ["旧徽章"]}], "chapterEndHook": {"requirementId": "hook-1", "anchors": ["城门忽然打开"], "maxDistanceFromEnd": 20}, } def _rehash(output: dict) -> None: output["candidateSha256"] = "sha256:" + hashlib.sha256(output["candidateBody"].encode("utf-8")).hexdigest() def _valid_pair() -> tuple[dict, dict]: context = _bound_context() return context, build_candidate_envelope(context, {"candidateBody": _candidate_body()}) class CheckWriterCandidateTest(unittest.TestCase): def test_missing_outline_anchors_block(self) -> None: context, output = _valid_pair() mutations = { "HARD_EVENT_MISSING": ("完成围攻突围", "完成撤离"), "REQUIRED_CHARACTER_MISSING": ("林澈", "无名人"), "FORESHADOWING_ACTION_MISSING": ("旧徽章", "旧物件"), "CHAPTER_END_HOOK_MISSING": ("城门忽然打开", "风声渐渐平息"), } for expected_code, (old, new) in mutations.items(): candidate = copy.deepcopy(output) candidate["candidateBody"] = candidate["candidateBody"].replace(old, new) _rehash(candidate) report = check_writer_candidate(context, candidate, _requirements()) with self.subTest(expected_code=expected_code): self.assertFalse(report["passed"]) self.assertIn(expected_code, {item["code"] for item in report["blockingFailures"]}) def test_hash_context_and_length_are_mechanical(self) -> None: context, output = _valid_pair() bad_hash = copy.deepcopy(output) bad_hash["candidateSha256"] = "sha256:" + "f" * 64 self.assertIn("CANDIDATE_HASH_MISMATCH", {item["code"] for item in check_writer_candidate(context, bad_hash, _requirements())["blockingFailures"]}) bad_context = copy.deepcopy(output) bad_context["contextSnapshotSha256"] = "sha256:" + "e" * 64 self.assertIn("CONTEXT_BINDING_MISMATCH", {item["code"] for item in check_writer_candidate(context, bad_context, _requirements())["blockingFailures"]}) short = copy.deepcopy(output) short["candidateBody"] = "文" * 1000 _rehash(short) self.assertIn("CANDIDATE_LENGTH_OUT_OF_RANGE", {item["code"] for item in check_writer_candidate(context, short, _requirements())["blockingFailures"]}) def test_writer_owned_semantic_fields_are_rejected_not_consumed(self) -> None: context, output = _valid_pair() for field, value in ( ("claimLedger", []), ("evidenceRequests", []), ("newSettingDeclarations", []), ): candidate = copy.deepcopy(output) candidate[field] = value report = check_writer_candidate(context, candidate, _requirements()) with self.subTest(field=field): self.assertFalse(report["passed"]) self.assertIn("OUTPUT_CONTRACT_INVALID", {item["code"] for item in report["blockingFailures"]}) def test_valid_candidate_passes_and_requests_independent_semantic_detection(self) -> None: context, output = _valid_pair() report = check_writer_candidate(context, output, _requirements()) self.assertTrue(report["passed"]) self.assertEqual(report["candidateSha256"], output["candidateSha256"]) self.assertEqual(report["semanticReview"]["status"], "pending") self.assertEqual(report["semanticReview"]["inputContract"], "WriterContext v1 + CandidateEnvelope v2 -> SemanticDetection v3") def test_outline_phrase_leak_blocks(self) -> None: """细纲硬约束里的描写短句原样进正文 → OUTLINE_PHRASE_LEAKED。""" context, output = _valid_pair() context = copy.deepcopy(context) context["fineOutline"]["hardConstraints"] = [ "出击目标是吞食更强核心", "机甲饥饿直接没有谎言", # ≥4 字描写短句,不得复读 ] leaked = copy.deepcopy(output) # 保留锚点字,再插入泄漏短语 leaked["candidateBody"] = leaked["candidateBody"].replace( "林澈完成围攻突围", "林澈完成围攻突围,机甲饥饿直接没有谎言", 1, ) _rehash(leaked) report = check_writer_candidate(context, leaked, _requirements()) self.assertFalse(report["passed"]) codes = {item["code"] for item in report["blockingFailures"]} self.assertIn("OUTLINE_PHRASE_LEAKED", codes) if __name__ == "__main__": unittest.main()