muse-agent-example/.claude/skills/assemble-context/scripts/test_assemble_writer_context.py
zizi b0bc7a8745 框架: 技能按动作-对象重组 + 先审后入创作闭环
一、技能重组(动作-对象命名)
- 旧目录 clean/confirm/continuation/db/detect/embed/… 重组为
  clean-book-text/decide-candidate/write-next-chapter/access-database/
  check-content-consistency/embed-knowledge/…(git 识别为 rename,内容保持)
- agents/*.md、AGENTS.md/CLAUDE.md 收编、example_skill 登记表同步新名

二、先审后入创作闭环(本次核心)
正文接受从"机械门一过就写正典"改为"机械门+语义审查双通过+用户批准+单事务原子提交",
DB 级兜底,编排层跳步即被硬拒。
- candidate_cas.py + example_candidate_cas(109):持久化 CAS 状态链
- fact_delta.py + example_fact_delta/example_fact_ledger(106):结构化事实增量,
  模型只提六型闭集增量+正文证据引文,仅用户批准的增量随正文同事务入账本
- projection_registry.py + example_projection_run(107):投影登记与恢复
- acceptance_state.py:接受前置实时状态重读
- lesson_registry.py + example_lesson(108):经验升格链,禁止自动升格
- DDL 105:example_candidate 增 semantic_status/semantic_report_sha256
- write_canonical.accept:语义兜底+同事务合并增量+登记投影;
  run_writer_pipeline/persist_writer_run/run_writer_semantic_detector/step2 接入全链
- claude_runtime:兼容新 CLI modelUsage 信息字段

三、审查修复(独立子代理四维审查后)
- 事实增量 propose→approve 翻态正道,不撞唯一键
- 冻结配置探针重刷(CLI 2.1.211→2.1.231 漂移),profileSha256/adapterVersion 再登记
- 可视化合同悬空路径/五六空间矛盾、 SoT 旧技能名漂移、行尾空白清理

测试:离线 65 套 + 真实库集成 5 套(CAS/接受故障注入/事实增量/投影/经验升格)+ 回放 79 项全绿。
创作内容(docs/design、生成正文 artifacts)按"框架与创作分开"未入本提交。
2026-08-14 10:24:08 +08:00

577 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
"""事实证据与原文证据双线组装测试。"""
from __future__ import annotations
import copy
import hashlib
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from assemble_writer_context import ( # noqa: E402
AssemblyError,
assemble_context,
build_context_allowlist_diff_receipt,
)
from retrieve_writer_sources import retrieve_writer_sources # noqa: E402
from writer_contract import retrieval_identity, validate_writer_context # noqa: E402
def digest(text: str) -> str:
"""生成测试证据的规范 SHA-256。"""
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
def prose(chapter: int, block_id: int, text: str, *, recent: bool = False, purpose: str = "card_source") -> dict:
"""构造块级可引用原文。"""
return {
"evidenceId": f"prose:{chapter}:{block_id}",
"chapter": chapter,
"sourceRef": {
"sourceId": f"chapter:{chapter}:block:{block_id}",
"sourceVersion": f"chapter-{chapter}-block-{block_id}-v1",
"chapter": chapter,
"blockId": block_id,
"startCodePoint": 0,
"endCodePoint": len(text),
},
"contentSha256": digest(text),
"purpose": purpose,
"text": text,
"isRecentBaseline": recent,
}
def fact(fact_id: str, text: str, source_type: str = "formal_setting", risk: str = "medium") -> dict:
"""构造独立权威事实证据。"""
return {
"evidenceId": fact_id,
"fact": text,
"sourceType": source_type,
"sourceRef": {
"sourceId": f"{source_type}:{fact_id}",
"sourceVersion": f"{source_type}-v1",
},
"contentSha256": digest(text),
"riskLevel": risk,
}
def plan(run_id: str = "run-a") -> dict:
"""构造合法固定检索计划。"""
value = {
"planVersion": "writer-retrieval-plan-v1",
"runId": run_id,
"asOf": 6,
"queries": [],
"cardIndexVersion": "cards-v1",
"proseIndexVersion": "prose-v1",
"filters": {
"workId": 8,
"asOfChapter": 6,
"sourceStatus": "active",
"authorizationRequired": True,
},
"tieBreak": "score DESC, sourceVersion ASC, sourceId ASC, sourceOffset ASC",
"tokenBudget": {"maxContextChars": 50000},
}
value["planId"] = retrieval_identity(value)
return value
def fine_outline() -> dict:
"""构造含五类覆盖目标的细纲输入。"""
return {
"sourceRef": {"sourceId": "fine-outline:7", "sourceVersion": "outline-v3", "chapter": 7},
"hardConstraints": ["甲携剑抵达城门"],
"adjustableBeats": ["可调整过渡方式"],
"declaredNewFacts": [],
"entities": [{"id": "character:甲", "type": "character", "name": "甲"}],
"relations": [{"id": "relation:甲乙", "type": "character_relation", "name": "甲乙关系"}],
"items": [{"id": "item:剑", "type": "item", "name": "剑"}],
"locations": [{"id": "location:城门", "type": "location", "name": "城门"}],
"powerSystems": [{"id": "power:灵力", "type": "power_system", "name": "灵力"}],
}
def recent_chapters(last: int = 6) -> list[dict]:
"""构造第一章到冻结章的完整章节原文。"""
return [prose(chapter, 100 + chapter, f"第{chapter}章完整原文。") for chapter in range(1, last + 1)]
def base_kwargs() -> dict:
"""构造 assemble_context 的公共合法输入。"""
return {
"run_id": "run-a",
"attempt": 1,
"mode": "production",
"purpose": "production",
"quality_policy_version": "writer-production-v1",
"work_id": 8,
"target_chapter": 7,
"as_of": 6,
"source_version": "raw-file-v1:sha256:" + "a" * 64,
"authorization_snapshot": {
"snapshotId": "auth-1",
"allowedPurpose": "generation",
"verifiedAt": "2026-07-20T00:00:00Z",
},
"source_status": "active",
"retrieval_plan": plan(),
"retrieval_result": {
"cards": [],
"factEvidence": [],
"proseEvidence": [],
"indexHints": [],
"unverifiedIndexHints": [],
"manifest": {
"manifestVersion": "writer-retrieval-manifest-v1",
"manifestId": "sha256:" + "b" * 64,
"planId": plan()["planId"],
"sources": [],
"omittedSources": [],
},
},
"fine_outline": fine_outline(),
"narrative_state": {
"time": "当日",
"location": "城门",
"characterPositions": {"甲": "城外"},
"immediateSituation": "即将交战",
},
"recent_chapters": recent_chapters(),
"output_contract": {
"targetChars": 4000,
"minChars": 3600,
"maxChars": 4400,
"frontmatterRequired": False,
},
"token_budget": {"maxContextChars": 50000},
"generated_at": "2026-07-20T00:00:00Z",
}
class AssembleWriterContextTest(unittest.TestCase):
def test_previous_four_full_chapters_are_baseline(self):
result = assemble_context(**base_kwargs())
baseline = [item for item in result["context"]["proseEvidence"] if item["isRecentBaseline"]]
self.assertEqual([item["chapter"] for item in baseline], [3, 4, 5, 6])
self.assertTrue(all(item["purpose"] == "recent_full_chapter" for item in baseline))
validate_writer_context(result["context"])
def test_when_fewer_than_four_exist_start_at_chapter_one_without_duplicates(self):
kwargs = base_kwargs()
kwargs.update(
{
"target_chapter": 3,
"as_of": 2,
"recent_chapters": recent_chapters(2),
"retrieval_plan": {**plan(), "asOf": 2, "filters": {**plan()["filters"], "asOfChapter": 2}},
}
)
kwargs["retrieval_plan"]["planId"] = retrieval_identity({key: value for key, value in kwargs["retrieval_plan"].items() if key != "planId"})
kwargs["retrieval_result"]["manifest"]["planId"] = kwargs["retrieval_plan"]["planId"]
result = assemble_context(**kwargs)
chapters = [item["chapter"] for item in result["context"]["proseEvidence"]]
self.assertEqual(chapters, [1, 2])
def test_missing_chapter_in_required_continuous_baseline_fails_closed(self):
kwargs = base_kwargs()
kwargs["recent_chapters"] = [item for item in recent_chapters() if item["chapter"] != 4]
with self.assertRaises(AssemblyError):
assemble_context(**kwargs)
def test_diagnostic_arms_use_frozen_index_hints_without_bypassing_contract(self):
"""A/B/C 均由同一组装入口产生,B 只含提示,A/C 保留近章基线。"""
hint = {
"cardId": "card:甲",
"name": "甲",
"type": "character",
"content": "甲在冻结点仍位于城外",
"sourceId": "card-source:甲",
"sourceVersion": "cards-v1",
"asOf": 6,
}
contexts = {}
for arm, strategy in (
("A", "historical_prose_only"),
("B", "card_index_only"),
("C", "card_index_plus_prose"),
):
kwargs = base_kwargs()
kwargs.update(
{
"mode": "diagnostic_only",
"purpose": "evaluation",
"quality_policy_version": "writer-eval-v1",
"evidence_strategy": strategy,
}
)
kwargs["retrieval_result"]["indexHints"] = [copy.deepcopy(hint)]
contexts[arm] = assemble_context(**kwargs)["context"]
self.assertEqual(contexts["A"]["indexHints"], [])
self.assertEqual(contexts["A"]["factEvidence"], [])
self.assertEqual([item["chapter"] for item in contexts["A"]["proseEvidence"]], [3, 4, 5, 6])
self.assertEqual(contexts["B"]["proseEvidence"], [])
self.assertEqual(contexts["B"]["factEvidence"], [])
self.assertEqual(contexts["B"]["indexHints"], [hint])
self.assertEqual([item["chapter"] for item in contexts["C"]["proseEvidence"]], [3, 4, 5, 6])
self.assertEqual(contexts["C"]["indexHints"], [hint])
self.assertTrue(all(not context["acceptanceEligible"] for context in contexts.values()))
def test_evaluation_ac_writer_projection_hides_orchestration_and_emits_diff_receipt(self):
"""A/C 写手投影只能因等额原文及其来源和派生哈希不同。"""
hint = {
"cardId": "card:甲",
"name": "甲",
"type": "character",
"content": "甲的卡摘要不得进入 A/C 写手输入",
"sourceId": "card-source:甲",
"sourceVersion": "cards-v1",
"asOf": 6,
}
common = base_kwargs()
common["mode"] = "diagnostic_only"
common["purpose"] = "evaluation"
common["quality_policy_version"] = "writer-eval-v1"
common["token_budget"] = {"maxContextChars": 50000, "proseCharBudget": 6}
common["retrieval_result"]["factEvidence"] = [
fact("fact:shared", "甲必须守住城门", risk="high")
]
common["retrieval_result"]["indexHints"] = [hint]
common["retrieval_result"]["proseEvidence"] = [
{**prose(1, 201, "甲守住城门。"), "retrievalArm": "A"},
{**prose(2, 202, "甲战在城外。"), "retrievalArm": "C"},
]
contexts = {}
creative_inputs = {}
for arm, strategy in (
("A", "generic_prose_retrieval"),
("C", "card_indexed_prose_retrieval"),
):
kwargs = copy.deepcopy(common)
kwargs["evidence_strategy"] = strategy
assembled = assemble_context(**kwargs)
contexts[arm] = assembled["context"]
creative_inputs[arm] = assembled["writerCreativeInput"]
for creative_input in creative_inputs.values():
self.assertEqual(
set(creative_input),
{
"fineOutline", "narrativeState", "factConstraints",
"proseExcerpts", "patternReferences", "lengthContract",
"styleConstraints",
},
)
self.assertNotIn("retrievalPlan", creative_input)
self.assertNotIn("evidenceStrategy", creative_input)
self.assertNotIn("甲的卡摘要不得进入 A/C 写手输入", str(creative_input))
supplemental = [
item for item in creative_input["proseExcerpts"] if not item["isRecentBaseline"]
]
self.assertEqual(sum(len(item["text"]) for item in supplemental), 6)
receipt = build_context_allowlist_diff_receipt(
sample_id="sample-7",
context_a=contexts["A"],
context_c=contexts["C"],
prose_char_budget=6,
)
self.assertTrue(receipt["ok"], receipt)
self.assertEqual(receipt["schemaVersion"], "writer-creative-input-allowlist-diff-v2")
self.assertEqual(receipt["proseCharCount"], {"A": 6, "C": 6})
self.assertTrue(receipt["receiptSha256"].startswith("sha256:"))
def test_ac_allowlist_diff_rejects_non_prose_drift(self):
"""细纲等非原文字段漂移时,diff receipt 必须失败关闭。"""
kwargs = base_kwargs()
kwargs.update(
{
"mode": "diagnostic_only",
"purpose": "evaluation",
"quality_policy_version": "writer-eval-v1",
"evidence_strategy": "generic_prose_retrieval",
}
)
context_a = assemble_context(**kwargs)["context"]
context_c_kwargs = copy.deepcopy(kwargs)
context_c_kwargs["fine_outline"]["hardConstraints"] = ["被篡改的约束"]
context_c = assemble_context(**context_c_kwargs)["context"]
with self.assertRaisesRegex(AssemblyError, "allowlist"):
build_context_allowlist_diff_receipt(
sample_id="sample-7",
context_a=context_a,
context_c=context_c,
prose_char_budget=0,
)
def test_ac_exact_prose_budget_fails_when_either_arm_is_short(self):
"""A/C 任一臂历史原文不足预注册字符数时都不得缩小预算。"""
kwargs = base_kwargs()
kwargs.update(
{
"mode": "diagnostic_only",
"purpose": "evaluation",
"quality_policy_version": "writer-eval-v1",
"evidence_strategy": "generic_prose_retrieval",
"token_budget": {"maxContextChars": 50000, "proseCharBudget": 20},
}
)
kwargs["retrieval_result"]["proseEvidence"] = [
{**prose(1, 201, "只有六字。"), "retrievalArm": "A"}
]
with self.assertRaisesRegex(AssemblyError, "proseCharBudget"):
assemble_context(**kwargs)
def test_index_hint_strict_fields_and_freeze_are_enforced(self):
"""组装器不接受检索器夹带字段或越过 asOf 的提示。"""
base_hint = {
"cardId": "card:甲", "name": "甲", "type": "character",
"content": "冻结状态", "sourceId": "card-source:甲",
"sourceVersion": "cards-v1", "asOf": 6,
}
for mutation in ("unknown", "future"):
kwargs = base_kwargs()
kwargs.update(
{
"mode": "diagnostic_only",
"purpose": "diagnostic",
"quality_policy_version": "writer-eval-v1",
"evidence_strategy": "card_index_only",
}
)
hint = copy.deepcopy(base_hint)
if mutation == "unknown":
hint["score"] = 1.0
else:
hint["asOf"] = 7
kwargs["retrieval_result"]["indexHints"] = [hint]
with self.subTest(mutation=mutation), self.assertRaises(AssemblyError):
assemble_context(**kwargs)
def test_retrieve_to_assemble_keeps_all_cards_as_indexes_and_only_c_reads_card_sources(self):
"""真实串联证明卡索引与原文回读独立,A/B/C 只改变约定变量。"""
with_ref = {
"cardId": "card:甲",
"type": "character",
"name": "甲",
"score": 0.9,
"sourceVersion": "cards-v1",
"sourceId": "canonical-entity:甲",
"sourceOffset": 0,
"sourceRefs": [{
"sourceId": "chapter:2:block:202",
"sourceVersion": "chapter-2-block-202-v1",
"chapter": 2,
"blockId": 202,
"startCodePoint": 0,
"endCodePoint": 6,
}],
"milestones": [
{"chapter": 2, "fact": "甲在城门受伤"},
{"chapter": 8, "fact": "未来状态"},
],
"sourceKind": "canonical_entity",
"productionRetrievalEligible": True,
}
without_ref = {
**copy.deepcopy(with_ref),
"cardId": "card:乙",
"name": "乙",
"sourceId": "canonical-entity:乙",
"sourceRefs": [],
"milestones": [{"chapter": 4, "fact": "乙仍留守城内"}],
}
class CardRepository:
"""返回同时含有、缺少原文指针的冻结卡。"""
def search(self, retrieval_plan):
del retrieval_plan
return [copy.deepcopy(with_ref), copy.deepcopy(without_ref)]
class ProseRepository:
"""按卡指针返回补充历史原文。"""
def read_source_refs(self, *, work_id, as_of, source_refs):
self.call = (work_id, as_of, copy.deepcopy(source_refs))
return [{
"sourceRef": copy.deepcopy(source_refs[0]),
"chapter": 2,
"text": "甲在城门受伤。",
"purpose": "card_source",
}]
kwargs = base_kwargs()
retrieval_result = retrieve_writer_sources(
plan=kwargs["retrieval_plan"],
card_repository=CardRepository(),
prose_repository=ProseRepository(),
)
contexts = {}
for arm, strategy in (
("A", "historical_prose_only"),
("B", "card_index_only"),
("C", "card_index_plus_prose"),
):
arm_kwargs = copy.deepcopy(kwargs)
arm_kwargs.update({
"mode": "diagnostic_only",
"purpose": "evaluation",
"quality_policy_version": "writer-eval-v1",
"evidence_strategy": strategy,
"retrieval_result": copy.deepcopy(retrieval_result),
})
contexts[arm] = assemble_context(**arm_kwargs)["context"]
expected_cards = {"card:甲", "card:乙"}
self.assertEqual(contexts["A"]["indexHints"], [])
self.assertEqual([item["chapter"] for item in contexts["A"]["proseEvidence"]], [3, 4, 5, 6])
self.assertEqual({item["cardId"] for item in contexts["B"]["indexHints"]}, expected_cards)
self.assertEqual(contexts["B"]["proseEvidence"], [])
self.assertEqual({item["cardId"] for item in contexts["C"]["indexHints"]}, expected_cards)
card_manifest_sources = [
item
for item in contexts["C"]["retrievalManifest"]["sources"]
if item.get("sourceType") == "diagnostic_card_index"
]
self.assertEqual(
{item["sourceId"] for item in card_manifest_sources},
{"canonical-entity:甲", "canonical-entity:乙"},
)
self.assertEqual(
[item["chapter"] for item in contexts["C"]["proseEvidence"]],
[3, 4, 5, 6, 2],
)
self.assertIn(
{"sourceId": "canonical-entity:乙", "reason": "missing_source_refs"},
contexts["C"]["omittedSources"],
)
self.assertTrue(all(not context["factEvidence"] for context in contexts.values()))
self.assertTrue(all(not context["acceptanceEligible"] for context in contexts.values()))
def test_card_prose_is_deduplicated_and_stably_sorted_after_baseline(self):
kwargs = base_kwargs()
duplicate = prose(6, 106, "第6章完整原文。")
supplemental_b = prose(2, 202, "补充乙。")
supplemental_a = prose(1, 201, "补充甲。")
kwargs["retrieval_result"]["proseEvidence"] = [supplemental_b, duplicate, supplemental_a]
result = assemble_context(**kwargs)
evidence = result["context"]["proseEvidence"]
self.assertEqual(sum(item["sourceRef"]["sourceId"] == duplicate["sourceRef"]["sourceId"] for item in evidence), 1)
supplemental = [item for item in evidence if not item["isRecentBaseline"]]
self.assertEqual([item["sourceRef"]["sourceId"] for item in supplemental], ["chapter:1:block:201", "chapter:2:block:202"])
def test_fact_and_prose_evidence_are_separate_and_five_types_report_coverage(self):
kwargs = base_kwargs()
kwargs["retrieval_result"]["factEvidence"] = [
fact("fact:character", "甲保持警惕", risk="high"),
fact("fact:relation", "甲乙关系稳定"),
fact("fact:item", "剑仍在甲手中"),
fact("fact:location", "城门已经关闭"),
fact("fact:power", "灵力消耗受限"),
]
result = assemble_context(**kwargs)
context = result["context"]
self.assertTrue(all("fact" in item and "text" not in item for item in context["factEvidence"]))
self.assertTrue(all("text" in item and "fact" not in item for item in context["proseEvidence"]))
self.assertEqual(
{item["elementType"] for item in context["evidenceCoverage"]},
{"character", "character_relation", "item", "location", "power_system"},
)
self.assertTrue(all(item["status"] in {"supported", "style_gap"} for item in context["evidenceCoverage"]))
def test_same_snapshot_and_inputs_have_same_manifest_and_context_hash(self):
first = assemble_context(**base_kwargs())
second_kwargs = base_kwargs()
second_kwargs["run_id"] = "run-b"
second_kwargs["retrieval_plan"]["runId"] = "run-b"
second = assemble_context(**second_kwargs)
self.assertEqual(first["context"]["retrievalManifest"]["manifestId"], second["context"]["retrievalManifest"]["manifestId"])
self.assertEqual(first["context"]["contextSnapshot"]["contextSha256"], second["context"]["contextSnapshot"]["contextSha256"])
self.assertEqual(first["manifestMarkdown"], second["manifestMarkdown"])
def test_budget_keeps_hard_outline_recent_prose_and_high_risk_fact_with_trace(self):
kwargs = base_kwargs()
kwargs["retrieval_result"]["factEvidence"] = [
fact("fact:high", "甲的高风险身份事实" + "甲" * 300, risk="high"),
fact("fact:low", "低风险背景" + "乙" * 300, risk="low"),
]
kwargs["retrieval_result"]["proseEvidence"] = [prose(1, 900, "很早的补充原文" + "旧" * 500)]
kwargs["recent_chapters"] = [
prose(chapter, 100 + chapter, f"第{chapter}章" + "近" * 350)
for chapter in range(1, 7)
]
kwargs["token_budget"] = {"maxContextChars": 7500}
result = assemble_context(**kwargs)
context = result["context"]
self.assertIn("甲携剑抵达城门", context["fineOutline"]["hardConstraints"])
self.assertIn("fact:high", {item["evidenceId"] for item in context["factEvidence"]})
baseline = [item["chapter"] for item in context["proseEvidence"] if item["isRecentBaseline"]]
self.assertEqual(baseline, [3, 4, 5, 6])
self.assertLessEqual(context["tokenBudget"]["usedContextChars"], 7500)
self.assertTrue(context["omittedSources"])
self.assertIn("token_budget", {item["reason"] for item in context["omittedSources"]})
def test_budget_that_cannot_hold_four_full_chapters_fails_closed(self):
kwargs = base_kwargs()
kwargs["recent_chapters"] = [
prose(chapter, 100 + chapter, f"第{chapter}章" + "近" * 500)
for chapter in range(1, 7)
]
kwargs["token_budget"] = {"maxContextChars": 3000}
with self.assertRaisesRegex(AssemblyError, "连续前四章全文基线"):
assemble_context(**kwargs)
class StyleInjectionTest(unittest.TestCase):
"""style 真注入:规划期选定的文风约束进冻结上下文并投影给 writer。"""
def test_no_style_means_context_unchanged_and_projection_empty(self):
result = assemble_context(**base_kwargs())
# 未提供文风时整个键省略,上下文逐字节不变(不破坏既有冻结哈希)。
self.assertNotIn("styleConstraints", result["context"])
self.assertEqual(result["writerCreativeInput"]["styleConstraints"], [])
def test_style_constraints_freeze_and_project_to_writer(self):
kwargs = base_kwargs()
kwargs["style_constraints"] = ["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"]
result = assemble_context(**kwargs)
self.assertEqual(
result["context"]["styleConstraints"],
["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"],
)
self.assertEqual(
result["writerCreativeInput"]["styleConstraints"],
["禁用 仿佛/似乎/不禁/眼眸", "句式偏短,节奏紧"],
)
def test_blank_style_rules_are_dropped(self):
kwargs = base_kwargs()
kwargs["style_constraints"] = [" ", "", "冷峻克制"]
result = assemble_context(**kwargs)
self.assertEqual(result["context"]["styleConstraints"], ["冷峻克制"])
if __name__ == "__main__":
unittest.main()