修五个硬伤,让"前期准备"从文档纪律变成机械门禁,并补齐写作链上游注入: - 细纲合同合一:新建 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)。
577 lines
24 KiB
Python
577 lines
24 KiB
Python
#!/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()
|