muse-agent-example/tests/skills/decide-candidate/test_writer_acceptance.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

629 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""正文候选 Shadow 准入、用户三决策与接受前置检查测试。
夹具自包含:本文件内自行构造合法的 WriterContext v1 与 CandidateEnvelope v2,
不跨 skill 借用其它测试文件的夹具,避免上游合同变化时本测试静默腐烂。
只依赖生产模块(writer_contract / check_writer_acceptance / run_writer_pipeline)。
"""
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 = SKILLS_DIR / "decide-candidate" / "scripts"
CONTINUATION_DIR = SKILLS_DIR / "write-next-chapter" / "scripts"
READ_CONTEXT_DIR = SKILLS_DIR / "assemble-context" / "scripts"
for path in (SCRIPT_DIR, CONTINUATION_DIR, READ_CONTEXT_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from check_writer_acceptance import ( # noqa: E402
AcceptanceError,
check_shadow_ready,
check_writer_acceptance,
)
from run_writer_pipeline import InMemoryCasStateStore, run_writer_pipeline # noqa: E402
from writer_contract import ( # noqa: E402
build_candidate_envelope,
canonical_json,
han_count,
retrieval_identity,
)
def _valid_context() -> dict:
"""构造覆盖全部必填字段、哈希自绑定的最小合法生产 WriterContext v1。
检索计划、manifest 与上下文快照的身份哈希都用生产 `retrieval_identity`
重新计算并自绑定,保证通过 `validate_writer_context` 的失败关闭校验。
"""
prose_evidence = []
for chapter in range(485, 489):
text = f"第{chapter}章冻结原文。"
prose_evidence.append(
{
"evidenceId": f"prose:{chapter}",
"chapter": chapter,
"sourceRef": {
"sourceId": f"chapter:{chapter}",
"sourceVersion": f"chapter-{chapter}-v1",
"chapter": chapter,
"blockId": 1,
"startCodePoint": 0,
"endCodePoint": len(text),
},
"contentSha256": "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest(),
"purpose": "recent_full_chapter",
"text": text,
"isRecentBaseline": True,
}
)
context = {
"schemaVersion": "writer-context-v1",
"runId": "writer-run-001",
"attempt": 1,
"mode": "production",
"purpose": "production",
"qualityPolicyVersion": "writer-production-v1",
"workId": 8,
"targetChapter": 489,
"asOf": 488,
"contextSnapshot": {
"manifestId": "sha256:" + "1" * 64,
"contextSha256": "sha256:" + "2" * 64,
"generatedAt": "2026-07-20T00:00:00Z",
},
"sourceVersion": "raw-file-v1:sha256:" + "3" * 64,
"authorizationSnapshot": {
"snapshotId": "auth-001",
"allowedPurpose": "generation",
"verifiedAt": "2026-07-20T00:00:00Z",
},
"sourceStatus": "active",
"retrievalPlan": {
"planVersion": "writer-retrieval-plan-v1",
"planId": "sha256:" + "4" * 64,
"runId": "writer-run-001",
"asOf": 488,
"queries": [],
"cardIndexVersion": "card-v1",
"proseIndexVersion": "prose-v1",
"filters": {
"workId": 8,
"asOfChapter": 488,
"sourceStatus": "active",
"authorizationRequired": True,
},
"tieBreak": "score DESC, sourceVersion ASC, sourceId ASC, sourceOffset ASC",
"tokenBudget": {"maxContextChars": 20000},
},
"retrievalManifest": {
"manifestVersion": "writer-retrieval-manifest-v1",
"manifestId": "sha256:" + "1" * 64,
"planId": "sha256:" + "4" * 64,
"sources": [],
"omittedSources": [],
},
"fineOutline": {
"sourceRef": {
"sourceId": "fine-outline:489",
"sourceVersion": "fine-outline-v3",
"chapter": 489,
},
"hardConstraints": ["必须完成围攻突围"],
"adjustableBeats": [],
"declaredNewFacts": [],
},
"narrativeState": {
"time": "围攻当日",
"location": "圣蒂曼",
"characterPositions": {},
"immediateSituation": "战斗持续",
},
"factEvidence": [],
"proseEvidence": prose_evidence,
"patternReferences": [],
"evidenceCoverage": [],
"outputContract": {
"targetChars": 4000,
"minChars": 3600,
"maxChars": 4400,
"frontmatterRequired": False,
},
"tokenBudget": {"maxContextChars": 20000, "usedContextChars": 0},
"omittedSources": [],
"acceptanceEligible": True,
}
plan_payload = {key: value for key, value in context["retrievalPlan"].items() if key != "planId"}
context["retrievalPlan"]["planId"] = retrieval_identity(plan_payload)
context["retrievalManifest"]["planId"] = context["retrievalPlan"]["planId"]
manifest_payload = {key: value for key, value in context["retrievalManifest"].items() if key != "manifestId"}
context["retrievalManifest"]["manifestId"] = retrieval_identity(manifest_payload)
context["contextSnapshot"]["manifestId"] = context["retrievalManifest"]["manifestId"]
context["contextSnapshot"]["contextSha256"] = retrieval_identity(context)
return context
def _candidate_body() -> str:
"""构造满足机械门锚点与动态篇幅合同的 4000 汉字正文。
含硬事件「完成围攻突围」、角色「林澈」、伏笔「旧徽章」,并以章末钩子
「城门忽然打开」收尾(距结尾 20 字以内),正好落在 [3600, 4400] 区间。
"""
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 _candidate(context: dict, *, candidate_version: int = 1, body: str | None = None) -> dict:
"""经可信 adapter 合同 `build_candidate_envelope` 构造合法 CandidateEnvelope v2。"""
return build_candidate_envelope(
context,
{"candidateBody": body or _candidate_body()},
candidate_version=candidate_version,
)
def _valid_pair() -> tuple[dict, dict]:
"""返回一对自绑定的合法上下文与候选。"""
context = _valid_context()
return context, _candidate(context)
def _detector_result(candidate: dict) -> dict:
"""构造同时通过机械门和语义接口的最终 detector 结果。"""
return {
"schemaVersion": "writer-pipeline-result-v1",
"runId": candidate["runId"],
"status": "PASSED",
"attempt": candidate["attempt"],
"candidateVersion": candidate["candidateVersion"],
"candidateSha256": candidate["candidateSha256"],
"failureCode": None,
"evidenceRequestCount": 0,
"rewriteCount": 0,
"trace": [
{
"attempt": candidate["attempt"],
"candidateVersion": candidate["candidateVersion"],
"candidateSha256": candidate["candidateSha256"],
"mechanicalPassed": True,
"semanticStatus": "passed",
"failureCodes": [],
}
],
}
def _semantic_report(context: dict, candidate: dict, *, status: str = "passed") -> dict:
"""构造绑定当前候选、字段与哈希自洽的 SemanticDetection v3 报告。
reportSha256 用与生产 `_validate_semantic_report` 完全一致的算法计算
(对剔除 reportSha256 后的报告取规范 JSON 的 SHA-256),保证不漂移。
"""
report = {
"schemaVersion": "semantic-detection-v3",
"runId": context["runId"],
"sampleId": "acceptance-sample",
"opaqueArmId": "acceptance-candidate",
"inputSha256": "sha256:" + "1" * 64,
"candidateVersion": candidate["candidateVersion"],
"candidateSha256": candidate["candidateSha256"],
"contextSnapshotSha256": context["contextSnapshot"]["contextSha256"],
"modelReceiptSha256": "sha256:" + "2" * 64,
"status": status,
"claims": [],
"findings": [],
"assertionVerdicts": [],
"hardConstraintVerdicts": [],
"newSettingCandidates": [],
"evidenceGaps": [],
}
report["reportSha256"] = "sha256:" + hashlib.sha256(
canonical_json({key: value for key, value in report.items() if key != "reportSha256"}).encode("utf-8")
).hexdigest()
return report
def _semantic_pass(context: dict, candidate: dict, _mechanical: dict) -> dict:
"""语义 detector 桩:返回无阻塞项的 passed 报告。"""
return _semantic_report(context, candidate)
def _live_state(context: dict) -> dict:
"""构造接受时重新读取的可信实时状态。"""
return {
"qualityPolicyVersion": context["qualityPolicyVersion"],
"contextSnapshotId": context["contextSnapshot"]["manifestId"],
"contextSnapshotSha256": context["contextSnapshot"]["contextSha256"],
"authorizationSnapshotId": context["authorizationSnapshot"]["snapshotId"],
"authorizationValid": True,
"sourceStatus": "active",
"candidateExpiresAt": "2026-07-20T12:00:00Z",
"checkedAt": "2026-07-20T11:00:00Z",
"canonicalRevision": 7,
}
def _valid_inputs() -> tuple[dict, dict, dict, dict]:
"""返回合法上下文、候选、detector 终态和实时状态。"""
context, candidate = _valid_pair()
return context, candidate, _detector_result(candidate), _live_state(context)
def _rehash(candidate: dict) -> None:
"""按规范正文重算 candidateSha256,保持信封自洽。"""
candidate["candidateSha256"] = "sha256:" + hashlib.sha256(
candidate["candidateBody"].encode("utf-8")
).hexdigest()
def _edited_candidate(candidate: dict) -> dict:
"""模拟用户编辑后生成严格递增、重新哈希的新候选。
CandidateEnvelope v2 只绑定正文哈希,不含 claimLedger 等写手侧字段,
因此编辑只需替换正文并重算 candidateSha256。
"""
edited = copy.deepcopy(candidate)
edited["candidateVersion"] += 1
edited["candidateBody"] = "章" + edited["candidateBody"][1:]
_rehash(edited)
return edited
class WriterAcceptanceTest(unittest.TestCase):
def _assert_error(self, expected_code: str, function, **kwargs) -> None:
"""断言纯函数失败关闭并返回稳定错误码。"""
with self.assertRaises(AcceptanceError) as raised:
function(**kwargs)
self.assertEqual(raised.exception.code, expected_code)
self.assertFalse(raised.exception.acceptance_eligible)
def test_acceptance_eligible_false_is_hard_rejected(self):
context, candidate, detector, live = _valid_inputs()
candidate["acceptanceEligible"] = False
self._assert_error(
"ACCEPTANCE_NOT_ELIGIBLE",
check_shadow_ready,
context=context,
candidate=candidate,
detector_result=detector,
live_state=live,
)
def test_shadow_requires_detector_context_hash_policy_authorization_and_freshness(self):
context, candidate, detector, live = _valid_inputs()
cases = []
rejected_detector = copy.deepcopy(detector)
rejected_detector["status"] = "REJECTED"
rejected_detector["failureCode"] = "REWRITE_LIMIT_REACHED"
cases.append(("DETECTOR_NOT_PASSED", candidate, rejected_detector, live))
wrong_detector_schema = copy.deepcopy(detector)
wrong_detector_schema["schemaVersion"] = "writer-detector-report-v1"
cases.append(("DETECTOR_REPORT_INVALID", candidate, wrong_detector_schema, live))
stale_detector = copy.deepcopy(detector)
stale_detector["candidateSha256"] = "sha256:" + "f" * 64
cases.append(("DETECTOR_BINDING_MISMATCH", candidate, stale_detector, live))
mechanical_red = copy.deepcopy(detector)
mechanical_red["trace"][-1]["mechanicalPassed"] = False
cases.append(("DETECTOR_NOT_PASSED", candidate, mechanical_red, live))
semantic_pending = copy.deepcopy(detector)
semantic_pending["trace"][-1]["semanticStatus"] = "pending"
cases.append(("DETECTOR_NOT_PASSED", candidate, semantic_pending, live))
trace_has_failure = copy.deepcopy(detector)
trace_has_failure["trace"][-1]["failureCodes"] = ["HARD_EVENT_MISSING"]
cases.append(("DETECTOR_NOT_PASSED", candidate, trace_has_failure, live))
stale_trace = copy.deepcopy(detector)
stale_trace["trace"][-1]["candidateVersion"] = 0
cases.append(("DETECTOR_BINDING_MISMATCH", candidate, stale_trace, live))
bad_hash = copy.deepcopy(candidate)
bad_hash["candidateSha256"] = "sha256:" + "e" * 64
cases.append(("CANDIDATE_CONTRACT_INVALID", bad_hash, detector, live))
too_short = copy.deepcopy(candidate)
too_short["candidateBody"] = "文" * 1000
_rehash(too_short)
cases.append(
(
"CANDIDATE_LENGTH_OUT_OF_RANGE",
too_short,
_detector_result(too_short),
live,
)
)
stale_context = copy.deepcopy(live)
stale_context["contextSnapshotSha256"] = "sha256:" + "d" * 64
cases.append(("CONTEXT_STALE", candidate, detector, stale_context))
stale_policy = copy.deepcopy(live)
stale_policy["qualityPolicyVersion"] = "writer-production-v2"
cases.append(("QUALITY_POLICY_STALE", candidate, detector, stale_policy))
stale_authorization = copy.deepcopy(live)
stale_authorization["authorizationValid"] = False
cases.append(("AUTHORIZATION_STALE", candidate, detector, stale_authorization))
stale_source = copy.deepcopy(live)
stale_source["sourceStatus"] = "revoked"
cases.append(("SOURCE_STALE", candidate, detector, stale_source))
expired = copy.deepcopy(live)
expired["checkedAt"] = expired["candidateExpiresAt"]
cases.append(("CANDIDATE_EXPIRED", candidate, detector, expired))
for expected_code, current_candidate, current_detector, current_live in cases:
with self.subTest(expected_code=expected_code):
self._assert_error(
expected_code,
check_shadow_ready,
context=copy.deepcopy(context),
candidate=copy.deepcopy(current_candidate),
detector_result=copy.deepcopy(current_detector),
live_state=copy.deepcopy(current_live),
)
def test_valid_candidate_enters_shadow_without_writing_canonical(self):
context, candidate, detector, live = _valid_inputs()
result = check_shadow_ready(
context=context,
candidate=candidate,
detector_result=detector,
live_state=live,
)
self.assertEqual(result["status"], "SHADOW_READY")
self.assertEqual(result["candidateVersion"], candidate["candidateVersion"])
self.assertFalse(result["canonicalMutationPerformed"])
def test_pipeline_passed_result_is_accepted_by_shadow_contract(self):
"""直接验证生产管线产出与 Shadow 消费合同一致。"""
context, candidate, _detector, live = _valid_inputs()
pipeline_result = run_writer_pipeline(
context=context,
requirements=_requirements(),
writer=lambda current, version: _candidate(current, candidate_version=version),
evidence_provider=lambda *_args: self.fail("通过候选不得补证"),
semantic_detector=_semantic_pass,
state_store=InMemoryCasStateStore(),
)
pipeline_candidate = pipeline_result["candidateArtifact"]
result = check_shadow_ready(
context=context,
candidate=pipeline_candidate,
detector_result=pipeline_result,
live_state=live,
)
self.assertEqual(pipeline_candidate, candidate)
self.assertEqual(
pipeline_result["trace"][-1]["mechanicalReport"]["schemaVersion"],
"writer-detector-report-v1",
)
self.assertEqual(
pipeline_result["trace"][-1]["semanticReport"]["status"], "passed"
)
self.assertEqual(result["status"], "SHADOW_READY")
def test_accept_merge_and_discard_all_require_explicit_confirmation(self):
context, candidate, detector, live = _valid_inputs()
for decision in ("accept", "merge", "discard"):
kwargs = {
"decision": decision,
"confirmed": False,
"context": context,
"candidate": candidate,
"detector_result": detector,
"live_state": live,
"expected_revision": 7,
}
if decision == "merge":
edited = _edited_candidate(candidate)
kwargs["candidate"] = edited
kwargs["detector_result"] = _detector_result(edited)
kwargs["previous_candidate"] = candidate
with self.subTest(decision=decision):
self._assert_error("CONFIRMATION_REQUIRED", check_writer_acceptance, **kwargs)
def test_expected_revision_conflict_never_returns_command_intent(self):
context, candidate, detector, live = _valid_inputs()
self._assert_error(
"REVISION_CONFLICT",
check_writer_acceptance,
decision="accept",
confirmed=True,
context=context,
candidate=candidate,
detector_result=detector,
live_state=live,
expected_revision=6,
)
def test_merge_requires_next_candidate_version_and_rerun_detector(self):
context, candidate, _detector, live = _valid_inputs()
edited = _edited_candidate(candidate)
same_version = copy.deepcopy(edited)
same_version["candidateVersion"] = candidate["candidateVersion"]
self._assert_error(
"EDIT_VERSION_INVALID",
check_writer_acceptance,
decision="merge",
confirmed=True,
context=context,
candidate=same_version,
previous_candidate=candidate,
detector_result=_detector_result(same_version),
live_state=live,
expected_revision=7,
)
stale_detector = _detector_result(candidate)
self._assert_error(
"DETECTOR_BINDING_MISMATCH",
check_writer_acceptance,
decision="merge",
confirmed=True,
context=context,
candidate=edited,
previous_candidate=candidate,
detector_result=stale_detector,
live_state=live,
expected_revision=7,
)
def test_merge_rejects_version_jump_unchanged_body_and_invalid_base(self):
"""编辑候选必须是有实际变更的严格下一版本,并绑定合法基线。"""
context, candidate, _detector, live = _valid_inputs()
jumped = _edited_candidate(candidate)
jumped["candidateVersion"] += 1
unchanged = copy.deepcopy(candidate)
unchanged["candidateVersion"] += 1
# 旧合同用删除 selfCheck 制造非法基线;CandidateEnvelope v2 已无该字段,
# 改为删除现行合同必填字段 acceptanceEligible,触发同等的 EDIT_BASE_INVALID。
invalid_base = copy.deepcopy(candidate)
del invalid_base["acceptanceEligible"]
cases = (
("EDIT_VERSION_INVALID", jumped, candidate),
("EDIT_BODY_UNCHANGED", unchanged, candidate),
("EDIT_BASE_REQUIRED", _edited_candidate(candidate), None),
("EDIT_BASE_INVALID", _edited_candidate(candidate), invalid_base),
)
for expected_code, current, previous in cases:
with self.subTest(expected_code=expected_code):
self._assert_error(
expected_code,
check_writer_acceptance,
decision="merge",
confirmed=True,
context=context,
candidate=current,
previous_candidate=previous,
detector_result=_detector_result(current),
live_state=live,
expected_revision=7,
)
def test_accept_and_merge_only_return_async_extraction_command_intent(self):
context, candidate, detector, live = _valid_inputs()
edited = _edited_candidate(candidate)
cases = (
("accept", candidate, detector, None),
("merge", edited, _detector_result(edited), candidate),
)
for decision, current, current_detector, previous in cases:
with self.subTest(decision=decision):
result = check_writer_acceptance(
decision=decision,
confirmed=True,
context=context,
candidate=current,
previous_candidate=previous,
detector_result=current_detector,
live_state=live,
expected_revision=7,
)
self.assertEqual(result["status"], "ACCEPTANCE_INTENT_READY")
self.assertFalse(result["canonicalMutationPerformed"])
self.assertEqual(result["commandIntent"]["command"], "queue_chapter_extraction")
self.assertEqual(result["commandIntent"]["dispatch"], "async")
self.assertEqual(result["commandIntent"]["executeAfter"], "canonical_commit")
self.assertFalse(result["commandIntent"]["allowed"])
self.assertTrue(result["commandIntent"]["requiresCanonicalCommit"])
def test_discard_returns_no_extraction_intent_and_performs_no_write(self):
context, candidate, detector, live = _valid_inputs()
result = check_writer_acceptance(
decision="discard",
confirmed=True,
context=context,
candidate=candidate,
detector_result=detector,
live_state=live,
expected_revision=7,
)
self.assertEqual(result["status"], "DISCARD_INTENT_READY")
self.assertEqual(result["runId"], candidate["runId"])
self.assertEqual(result["candidateVersion"], candidate["candidateVersion"])
self.assertEqual(result["candidateSha256"], candidate["candidateSha256"])
self.assertEqual(result["commandIntent"]["command"], "close_shadow_candidate")
self.assertEqual(
result["commandIntent"]["expectedCandidateVersion"],
candidate["candidateVersion"],
)
self.assertEqual(
result["commandIntent"]["expectedCandidateSha256"],
candidate["candidateSha256"],
)
self.assertFalse(result["canonicalMutationPerformed"])
def test_discard_rejects_malformed_candidate_instead_of_closing_unknown_target(self):
"""丢弃也必须绑定合法候选身份,不能返回无目标关闭意图。"""
context, candidate, detector, live = _valid_inputs()
# 旧合同用删除 selfCheck 制造非法候选;CandidateEnvelope v2 已无该字段,
# 改为删除现行合同必填字段 acceptanceEligible,触发同等的 CANDIDATE_CONTRACT_INVALID。
del candidate["acceptanceEligible"]
self._assert_error(
"CANDIDATE_CONTRACT_INVALID",
check_writer_acceptance,
decision="discard",
confirmed=True,
context=context,
candidate=candidate,
detector_result=detector,
live_state=live,
expected_revision=7,
)
if __name__ == "__main__":
unittest.main()