muse-agent-example/.claude/skills/read-context/scripts/test_assemble_writer_context.py
zizi 952485a506 框架: 前期创作流程代码——细纲统一合同+字段覆盖门禁+文风/范式注入+取数端统一
修五个硬伤,让"前期准备"从文档纪律变成机械门禁,并补齐写作链上游注入:
- 细纲合同合一:新建 meta/schemas/fine_outline.yaml 作唯一字段权威(必填9+推荐7),
  统一此前 planning 产出形与 writer 装配消费形两套不相交字段;fine-outline 技能对齐。
- 落库字段覆盖门禁:persist_planning 按 schema 校验必填字段,缺必填失败关闭,
  「字段存疑」逃生口;只对声明「字段覆盖门禁:强制」的型生效(fine_outline 已强制)。
- 文风真注入:WriterContext 增可选 styleConstraints(空则省键、上下文逐字节不变),
  装配投影给 writer,不再写死为空。
- 范式规划期绑定:read-context load_confirmed_pattern_bindings 只读已确认 assembly 绑定
  (实验仓承载,确认即绑定,不改主仓表);生产脚本注入已绑定范式。
- 取数端统一:read-context 三个一等取数端(细纲/范式/文风),生产编排 step2 接线,
  已确认细纲不再人肉读库传参。
- harness:AGENTS.md 第9节新增「提交前必须独立子代理形而上四维审查」硬规则。
(writer_contract/retrieve 含上一会话 asOf=0 开篇冻结线改动,同属创作链修复,随本笔提交。)

测试:9 套全绿(合同6/门禁8/装配17/writer_contract21/检索11/统一3/细纲读取4/范式读取5/文风读取6)。
2026-08-02 02:17:14 +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()