muse-agent-example/tests/skills/write-next-chapter/test_run_writer_pipeline.py
zizi 091b66a9bb 重构: 收敛 Agent/Skill 运行时与创作质量闭环
将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。

同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
2026-08-22 02:12:32 +08:00

293 lines
13 KiB
Python

#!/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" / "write-next-chapter"
CHECK_TEST_DIR = PROJECT_ROOT / "tests" / "skills" / "check-content-consistency"
SCRIPT_DIR = SKILLS_DIR / "write-next-chapter" / "scripts"
DETECT_DIR = SKILLS_DIR / "check-content-consistency" / "scripts"
READ_CONTEXT_DIR = SKILLS_DIR / "assemble-context" / "scripts"
for path in (READ_CONTEXT_DIR, DETECT_DIR, SCRIPT_DIR, TEST_DIR, CHECK_TEST_DIR):
sys.path.insert(0, str(path))
from test_check_writer_candidate import _candidate_body, _requirements, _valid_pair # noqa: E402
from run_writer_pipeline import CasToken, InMemoryCasStateStore, PipelineError, atomic_write_json, run_writer_pipeline # noqa: E402
from run_writer_semantic_detector import canonical_sha256 # 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_only_detector_evidence_gaps_trigger_fresh_attempt(self) -> None:
context, _ = _valid_pair()
writer_calls: list[tuple[int, int, str]] = []
detector_calls: list[int] = []
provider_calls: list[list[str]] = []
def writer(current: dict, version: int) -> dict:
writer_calls.append((current["attempt"], version, current["contextSnapshot"]["contextSha256"]))
return _writer_output(current, version)
def detector(current: dict, candidate: dict, _mechanical: dict) -> dict:
detector_calls.append(candidate["candidateVersion"])
if candidate["candidateVersion"] == 1:
return _semantic_report(current, candidate, status="needs_evidence", gaps=[{
"gapId": "gap-1", "query": "旧徽章来源", "reason": "证据不足", "priority": "high",
"candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章",
"startCodePoint": 11, "endCodePoint": 14,
}])
return _semantic_report(current, candidate)
def provider(current: dict, gaps: list[dict], attempt: int) -> dict:
provider_calls.append([item["gapId"] for item in gaps])
return _advance_context(current, attempt, add_evidence=True)
result = run_writer_pipeline(
context=context,
requirements=_requirements(),
writer=writer,
evidence_provider=provider,
semantic_detector=detector,
state_store=InMemoryCasStateStore(),
)
self.assertEqual(result["status"], "PASSED")
self.assertEqual(writer_calls[0][:2], (1, 1))
self.assertEqual(writer_calls[1][:2], (2, 2))
self.assertNotEqual(writer_calls[0][2], writer_calls[1][2])
self.assertEqual(detector_calls, [1, 2])
self.assertEqual(provider_calls, [["gap-1"]])
self.assertEqual(result["evidenceRequestCount"], 1)
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_reassembled_context_must_have_new_snapshot_and_attempt(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,
}])
same_snapshot = copy.deepcopy(context)
same_snapshot["attempt"] = 2
self._assert_failure(
context=context,
writer=lambda current, version: _writer_output(current, version),
detector=detector,
provider=lambda *_args: same_snapshot,
code="REASSEMBLED_CONTEXT_INVALID",
)
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()