#!/usr/bin/env python3 """正文候选、独立 detector、补证重写和 CAS 的离线测试。""" from __future__ import annotations import copy import json import os import pathlib import sys import tempfile import unittest from unittest import mock PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SKILLS_DIR = PROJECT_ROOT / ".agent" / "skills" TEST_DIR = PROJECT_ROOT / "tests" / "skills" / "写下一章" CHECK_TEST_DIR = PROJECT_ROOT / "tests" / "skills" / "核对内容一致性" SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "work" / "skills" / "generate" / "写下一章" / "scripts" DETECT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "quality" / "skills" / "semantic" / "核对内容一致性" / "scripts" READ_CONTEXT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "context" / "skills" / "准备任务上下文" / "scripts" for path in (READ_CONTEXT_DIR, DETECT_DIR, SCRIPT_DIR, TEST_DIR, CHECK_TEST_DIR): sys.path.insert(0, str(path)) from run_writer_pipeline import ( # noqa: E402 InMemoryCasStateStore, PipelineError, atomic_write_json, run_writer_pipeline, ) from run_writer_semantic_detector import canonical_sha256 # noqa: E402 from test_check_writer_candidate import _candidate_body, _requirements, _valid_pair # noqa: E402 from writer_contract import build_candidate_envelope, retrieval_identity # noqa: E402 def _advance_context(context: dict, attempt: int, *, add_evidence: bool = False) -> dict: advanced = copy.deepcopy(context) advanced["attempt"] = attempt if add_evidence: advanced["factEvidence"].append({ "evidenceId": "fact-gap-1", "fact": "旧徽章属于林澈", "sourceType": "canonical_state", "sourceRef": {"sourceId": "state:gap", "sourceVersion": "v1"}, "contentSha256": "sha256:" + "4" * 64, "riskLevel": "low", }) advanced["contextSnapshot"]["contextSha256"] = retrieval_identity(advanced) return advanced def _writer_output(context: dict, candidate_version: int, *, body: str | None = None) -> dict: return build_candidate_envelope(context, {"candidateBody": body or _candidate_body()}, candidate_version=candidate_version) def _semantic_report(context: dict, candidate: dict, *, status: str = "passed", gaps: list[dict] | None = None, findings: list[dict] | None = None) -> dict: report = { "schemaVersion": "semantic-detection-v3", "runId": context["runId"], "sampleId": "pipeline-sample", "opaqueArmId": "pipeline-candidate", "inputSha256": "sha256:" + "1" * 64, "candidateVersion": candidate["candidateVersion"], "candidateSha256": candidate["candidateSha256"], "contextSnapshotSha256": context["contextSnapshot"]["contextSha256"], "modelReceiptSha256": "sha256:" + "2" * 64, "status": status, "claims": [], "findings": findings or [], "assertionVerdicts": [], "hardConstraintVerdicts": [], "newSettingCandidates": [], "evidenceGaps": gaps or [], } report["reportSha256"] = canonical_sha256(report) return report def _semantic_pass(context: dict, candidate: dict, _mechanical: dict) -> dict: return _semantic_report(context, candidate) class RunWriterPipelineV3Test(unittest.TestCase): def _assert_failure(self, *, context: dict, writer, detector, provider, code: str) -> None: store = InMemoryCasStateStore() with tempfile.TemporaryDirectory() as directory: result_path = pathlib.Path(directory) / "result.json" with self.assertRaises(PipelineError) as caught: run_writer_pipeline( context=context, requirements=_requirements(), writer=writer, evidence_provider=provider, semantic_detector=detector, state_store=store, result_path=result_path, ) self.assertEqual(caught.exception.code, code) self.assertEqual(store.latest(context["runId"]).state, "REJECTED") self.assertEqual(json.loads(result_path.read_text(encoding="utf-8"))["failureCode"], code) def test_writer_returns_candidate_envelope_without_semantic_fields(self) -> None: context, _ = _valid_pair() result = run_writer_pipeline( context=context, requirements=_requirements(), writer=lambda current, version: _writer_output(current, version), evidence_provider=lambda *_args: self.fail("无 gap 不得补证"), semantic_detector=_semantic_pass, state_store=InMemoryCasStateStore(), ) self.assertEqual(result["status"], "PASSED") self.assertEqual(result["candidateArtifact"]["schemaVersion"], "candidate-envelope-v2") for forbidden in ("claimLedger", "evidenceRequests", "newSettingDeclarations"): self.assertNotIn(forbidden, result["candidateArtifact"]) def test_evidence_gaps_stop_at_authorization_terminal(self) -> None: """新合同:补证检索命中后收敛为 AUTHORIZATION_REQUIRED,重写等人授权。""" context, _ = _valid_pair() writer_calls: list[tuple[int, int]] = [] def writer(current: dict, version: int) -> dict: writer_calls.append((current["attempt"], version)) return _writer_output(current, version) def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: return _semantic_report(current, candidate, status="needs_evidence", gaps=[{ "gapId": "gap-1", "query": "旧徽章来源", "reason": "证据不足", "priority": "high", "candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章", "startCodePoint": 11, "endCodePoint": 14, }]) store = InMemoryCasStateStore() with tempfile.TemporaryDirectory() as directory: result_path = pathlib.Path(directory) / "result.json" with self.assertRaises(PipelineError) as caught: run_writer_pipeline( context=context, requirements=_requirements(), writer=writer, evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt, add_evidence=True), semantic_detector=detector, state_store=store, result_path=result_path, ) result = json.loads(result_path.read_text(encoding="utf-8")) self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED") # 只跑单次收敛:写手只被调用一次,无自动第二轮。 self.assertEqual(writer_calls, [(1, 1)]) self.assertEqual(store.latest(context["runId"]).state, "REJECTED") # 授权终态携带结构化缺口报告,供主代理转述给人。 gaps = caught.exception.details["evidenceGaps"] self.assertEqual([item["gapId"] for item in gaps], ["gap-1"]) self.assertEqual(result["failureCode"], "AUTHORIZATION_REQUIRED") self.assertEqual(result["status"], "REJECTED") def test_writer_cannot_trigger_evidence_provider(self) -> None: context, _ = _valid_pair() def writer(current: dict, version: int) -> dict: candidate = _writer_output(current, version) candidate["evidenceRequests"] = [{"gapId": "forged"}] return candidate self._assert_failure( context=context, writer=writer, detector=lambda *_args: self.fail("非法候选不得进入 detector"), provider=lambda *_args: self.fail("writer 字段不得触发补证"), code="MECHANICAL_DETECTION_REJECTED", ) def test_authorization_terminal_publishes_gap_report(self) -> None: """授权终态结果文件必须带缺口与检查快照,编排方据此向人请求授权。""" context, _ = _valid_pair() def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: return _semantic_report(current, candidate, status="needs_evidence", gaps=[{ "gapId": "gap-1", "query": "补证", "reason": "缺口", "priority": "high", "candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章", "startCodePoint": 11, "endCodePoint": 14, }]) store = InMemoryCasStateStore() with tempfile.TemporaryDirectory() as directory: result_path = pathlib.Path(directory) / "result.json" with self.assertRaises(PipelineError) as caught: run_writer_pipeline( context=context, requirements=_requirements(), writer=lambda current, version: _writer_output(current, version), evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt, add_evidence=True), semantic_detector=detector, state_store=store, result_path=result_path, ) result = json.loads(result_path.read_text(encoding="utf-8")) self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED") self.assertEqual(result["candidateSha256"], caught.exception.result["candidateSha256"]) self.assertEqual(caught.exception.details["mechanicalPassed"], True) self.assertEqual(caught.exception.details["semanticStatus"], "needs_evidence") def test_old_semantic_v2_report_fails_closed(self) -> None: context, _ = _valid_pair() self._assert_failure( context=context, writer=lambda current, version: _writer_output(current, version), detector=lambda *_args: {"schemaVersion": "semantic-detector-report-v2", "status": "passed"}, provider=lambda *_args: self.fail("旧报告不得触发补证"), code="SEMANTIC_DETECTOR_INVALID", ) def test_semantic_failure_without_gap_rejects_current_candidate(self) -> None: context, _ = _valid_pair() calls: list[int] = [] def writer(current: dict, version: int) -> dict: calls.append(version) return _writer_output(current, version) def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: finding = { "findingId": "finding-1", "severity": "high", "category": "fact_conflict", "candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章", "startCodePoint": 11, "endCodePoint": 14, "evidenceIds": [], "message": "冲突", } return _semantic_report(current, candidate, status="failed", findings=[finding]) self._assert_failure( context=context, writer=writer, detector=detector, provider=lambda *_args: self.fail("无 gap 不得补证"), code="SEMANTIC_DETECTION_REJECTED", ) self.assertEqual(calls, [1]) def test_cas_rejects_late_result(self) -> None: store = InMemoryCasStateStore() first = store.create("run-cas", attempt=1, candidate_version=1) checking = store.transition(first, "CHECKING") rejected = store.transition(checking, "REJECTED") second = store.start_next(rejected, attempt=2, candidate_version=2) self.assertIsNone(store.transition(checking, "PASSED")) self.assertEqual(store.latest("run-cas"), second) def test_atomic_result_fsyncs_before_replace(self) -> None: events: list[str] = [] with tempfile.TemporaryDirectory() as directory: target = pathlib.Path(directory) / "result.json" real_fsync = os.fsync real_replace = os.replace def recording_fsync(fd: int) -> None: events.append("fsync") real_fsync(fd) def recording_replace(source: str, destination: str) -> None: events.append("replace") real_replace(source, destination) with mock.patch("run_writer_pipeline.os.fsync", side_effect=recording_fsync), mock.patch("run_writer_pipeline.os.replace", side_effect=recording_replace): atomic_write_json(target, {"status": "PASSED"}) self.assertLess(events.index("fsync"), events.index("replace")) def test_initial_candidate_version_offsets_same_chapter_rerun(self) -> None: """同章重跑从既有最大版本接续,避免与留库被拒候选撞版本。""" context, _ = _valid_pair() seen_versions: list[int] = [] def writer(current: dict, version: int) -> dict: seen_versions.append(version) return _writer_output(current, version) result = run_writer_pipeline( context=context, requirements=_requirements(), writer=writer, evidence_provider=lambda *_args: self.fail("无 gap 不得补证"), semantic_detector=_semantic_pass, state_store=InMemoryCasStateStore(), initial_candidate_version=4, ) self.assertEqual(result["status"], "PASSED") self.assertEqual(seen_versions, [4]) self.assertEqual(result["candidateVersion"], 4) self.assertEqual(result["candidateArtifact"]["candidateVersion"], 4) def test_invalid_initial_candidate_version_fails_closed(self) -> None: context, _ = _valid_pair() for bad in (0, -1, True, "1"): with self.subTest(bad=bad), self.assertRaises(PipelineError) as caught: run_writer_pipeline( context=context, requirements=_requirements(), writer=lambda current, version: _writer_output(current, version), evidence_provider=lambda *_args: self.fail("不得补证"), semantic_detector=_semantic_pass, state_store=InMemoryCasStateStore(), initial_candidate_version=bad, ) self.assertEqual(caught.exception.code, "PIPELINE_INPUT_INVALID") if __name__ == "__main__": unittest.main()