diff --git a/adr/0015-pass-contract-strings-as-asset.md b/adr/0015-pass-contract-strings-as-asset.md new file mode 100644 index 0000000..cd83724 --- /dev/null +++ b/adr/0015-pass-contract-strings-as-asset.md @@ -0,0 +1,47 @@ +# ADR-0015:Pass 契约文案作为资产(spec/passes/contracts.yaml) + +- 状态:proposed +- 日期:2026-08-22 +- 影响层:A5 知识(+ A6 配置) + +## 背景 + +p3_beatsheet 与 p5_dialogue 把"机械复述给模型的输出格式契约"(`_SP_CONTRACT`、 +`_FACT_CONTRACT`、`_SC_CONTRACT`、必现视觉/命名/字数目标文案)以 Python 字符串字面量 +硬编码在 `src/nsc/passes/`。这与 AGENTS.md §2 "禁止在 prompt/代码里硬编码自然语言 +知识"相悖:这些文案是规范知识,不是机制;改一句契约要动代码层,也无法在资产层审阅。 + +它们也不能进 `prompts/.json`:prompts/** 是 GEPA 的生成物(B1),禁止手改, +只有 `nsc optimize` / `nsc compile-prompts` 能写。契约文案需要人工精确维护, +不是优化对象。 + +## 决定 + +把 Pass 的静态契约文案搬进 `spec/passes/contracts.yaml`(资产层),Pass 在组装 +LLM 输入时经 `nsc.passes.contract_text(pass_name, key)` 读取注入;带动态数据的 +文案(品牌名、字数目标等)保留代码侧机械派生,模板用 `${name}` 占位 +(string.Template),模板本体仍在 yaml。 + +## 被否决的替代 + +| 替代 | 为什么否决 | +|---|---| +| 写进 prompts/.json | prompts/** 禁止手改(B1 生成物),契约需要人工精确维护 | +| 写进 signature docstring | docstring 是"种子指令",会被 GEPA 优化漂移,契约必须稳定 | +| 保留在代码里加注释 | 违反 AGENTS.md §2 反模式;文案漂移无资产层审阅 | + +## 对下游的约束 + +- 新增/修改 Pass 输出格式契约 → 改 `spec/passes/contracts.yaml`,不再进 `.py`。 +- 契约文案变更会改变 spec/passes 域指纹 → 经 spec_sha 使该缓存失效(预期行为)。 +- 模板占位符语法固定为 `${name}`(string.Template),避免与 JSON 示例里的花括号冲突。 + +## 迁移 + +纯搬家,文案逐字节不变(见 `tests/test_pass_contracts.py` 与 PR 描述的字节一致性 +验证)。无 schema/IR 变更,无回滚成本:revert 即回代码字面量。 + +## 验证 + +`tests/test_pass_contracts.py`:键完整性、p3 常量与 spec 同源、p5 模板填充结果与 +原 f-string 输出逐字节一致;全量 `pytest -m "not llm"` 绿。 diff --git a/spec/passes/contracts.yaml b/spec/passes/contracts.yaml new file mode 100644 index 0000000..253b397 --- /dev/null +++ b/spec/passes/contracts.yaml @@ -0,0 +1,32 @@ +# SW-03 / ADR-0015:Pass 输出契约文案(资产层 A5 知识)。 +# +# 这些是"机械复述给模型的格式契约":描述输出 JSON 的形状与填法,属于规范知识, +# 按 AGENTS.md §2 不得硬编码在 src/ 代码里;它们也不是 GEPA 优化对象 +# (优化对象只有 prompts/.json 的 instructions,prompts/** 禁止手改)。 +# 运行时占位符用 ${name}(string.Template),由 Pass 用品牌/预算数据填充。 +schema_version: "1.0" + +p3_beatsheet: + # setup_payoffs_json 的格式契约(防把下标写成描述) + setup_payoffs: |- + setup_payoffs_json 每个条目形如 {"slug":"小写短标识","setup":,"payoff": 或 "PENDING:<对方条目 slug>","kind":"prop|line|promise|secret|skill","description":"一句话"}。setup/payoff 只能填整数下标(0 起)或 PENDING 字符串,绝不能填情节描述文字。 + # facts_json 的格式契约(ADR-0012;同集引用用下标,跨集引用用 known_facts 里的 id) + facts: |- + facts_json 每个条目形如 {"content":"一句话事实","type":"character_detail|relationship|backstory|plot_event|foreshadowing|world_rule","status":"active|unresolved|resolved|deprecated","resolves":null 或同集 facts_json 下标 int 或 known_facts 里 fact 的 id 字符串,"episode_no":int,"narrative_weight":"low|medium|high"}。尚未回收的伏笔 resolves 填 null;被回收后的状态翻转由系统统一完成,无需自己改前集状态。 + # state_changes_json 的格式契约(ADR-0012;key 必须来自 declared_state) + state_changes: |- + state_changes_json 每个条目形如 {"key":"declared_state 里已声明的状态变量/暗线 key","delta":number 型变量用数值/string 型用字符串/暗线推进用 int 步数,"reason":"一句话原因"}。 + +p5_dialogue: + # 必现视觉/必提台词契约的静态基底(动态示范行见 brand_must_example) + brand_must_base: |- + must_include_lines 里的每一句必须在某条对白(dialogue)中逐字原文出现;must_include_visuals 里的每一项必须逐字原文写进某条 line_type=action 的动作行,不得改写、不得替换其中任何词(例如不得把'logo'换成'标志')。 + # 有必现视觉时追加的示范动作行(${visual} = 第一项视觉符号) + brand_must_example: |- + 示范动作行:"镜头拉近,${visual}清晰可见。"——动作行里必须出现与该视觉项完全一致的字面子串。 + # 产品命名契约模板(BM-009 真相在 brand 资产;列表由代码机械派生) + product_naming: |- + 产品名唯一规范写法:${canonical}。任何语境(对白、动作行、菜单、招牌、字幕)都不得单独使用简称或变体(如 ${forbidden}),提到产品必须写完整规范名。 + # 本场对白字数目标模板(DLG-006 前置指导;数值由代码按 Beat 时长×语速推算) + dialogue_length_target: |- + 本场对白(dialogue)总字数目标 ${chars_lo}-${chars_hi} 字(按本场 Beat 时长 ${secs}s × ${cps} 字/秒推算);对白太少会导致成片时长不足(DLG-006)。 diff --git a/src/nsc/passes/__init__.py b/src/nsc/passes/__init__.py index 3c6736e..6c9736d 100644 --- a/src/nsc/passes/__init__.py +++ b/src/nsc/passes/__init__.py @@ -12,6 +12,7 @@ from typing import Any import dspy +import yaml from ulid import ULID from nsc.runtime.cache import cached_pass @@ -21,12 +22,43 @@ "PassContext", "PassFailure", "cached_pass", + "contract_text", "generate_json", "new_id", "optional_json", "with_diag", ] +_CONTRACTS_PATH = Path("spec/passes/contracts.yaml") + + +def _contracts() -> dict[str, Any]: + """SW-03 / ADR-0015:Pass 契约文案真相在 spec/passes/contracts.yaml(资产层)。 + + 每次调用重读(文件小、调用频率低):进程内缓存会让同进程的 spec 编辑 + 读到陈旧契约(review 修正)。 + """ + try: + return yaml.safe_load(_CONTRACTS_PATH.read_text("utf-8")) or {} + except OSError as e: + raise PassFailure(None, f"契约资产不可读:{_CONTRACTS_PATH}({e})") from e + + +def contract_text(pass_name: str, key: str) -> str: + """读一个 Pass 的契约文案;含 ${name} 占位(string.Template),由调用方填充。 + + 文件或键缺失即 PassFailure(fail fast,review 修正):契约缺失意味着资产 + 打包/键名损坏,静默降级为空串会把格式约束整个丢给模型。 + """ + section = _contracts().get(pass_name) + if section is None or key not in section: + raise PassFailure( + None, + f"spec/passes/contracts.yaml 缺少 {pass_name}.{key};契约资产不完整," + "请检查文件是否被截断或键名拼写。", + ) + return str(section[key]) + def with_diag(inputs: dict[str, Any], fragment: dict[str, Any]) -> dict[str, Any]: """把重试诊断(_previous_failure)从 fragment 转发进 LLM 输入(D13 反馈驱动再生成)。""" diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index ffa2a6d..c488f55 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -26,6 +26,7 @@ PassContext, PassFailure, cached_pass, + contract_text, inner_json, new_id, optional_json, @@ -38,29 +39,10 @@ Beat, skip=("id", "kind", "parent_id", "order", "provenance_id", "locked", "brand_moment_id") ) -#: setup_payoffs_json 的格式契约(本模块输出契约,机械复述给模型,防把下标写成描述)。 -_SP_CONTRACT = ( - 'setup_payoffs_json 每个条目形如 {"slug":"小写短标识","setup":,' - '"payoff": 或 "PENDING:<对方条目 slug>","kind":"prop|line|promise|secret|skill",' - '"description":"一句话"}。setup/payoff 只能填整数下标(0 起)或 PENDING 字符串,' - "绝不能填情节描述文字。" -) - -#: facts_json 的格式契约(ADR-0012;同集引用用下标,跨集引用用 known_facts 里的 id)。 -_FACT_CONTRACT = ( - 'facts_json 每个条目形如 {"content":"一句话事实",' - '"type":"character_detail|relationship|backstory|plot_event|foreshadowing|world_rule",' - '"status":"active|unresolved|resolved|deprecated",' - '"resolves":null 或同集 facts_json 下标 int 或 known_facts 里 fact 的 id 字符串,' - '"episode_no":int,"narrative_weight":"low|medium|high"}。' - "尚未回收的伏笔 resolves 填 null;被回收后的状态翻转由系统统一完成,无需自己改前集状态。" -) - -#: state_changes_json 的格式契约(ADR-0012;key 必须来自 declared_state)。 -_SC_CONTRACT = ( - 'state_changes_json 每个条目形如 {"key":"declared_state 里已声明的状态变量/暗线 key",' - '"delta":number 型变量用数值/string 型用字符串/暗线推进用 int 步数,"reason":"一句话原因"}。' -) +#: 三条输出格式契约的文案真相在 spec/passes/contracts.yaml(SW-03 / ADR-0015)。 +_SP_CONTRACT = contract_text("p3_beatsheet", "setup_payoffs") +_FACT_CONTRACT = contract_text("p3_beatsheet", "facts") +_SC_CONTRACT = contract_text("p3_beatsheet", "state_changes") class Module(DSPyPass): diff --git a/src/nsc/passes/p5_dialogue.py b/src/nsc/passes/p5_dialogue.py index b8bb2a1..b504a37 100644 --- a/src/nsc/passes/p5_dialogue.py +++ b/src/nsc/passes/p5_dialogue.py @@ -5,6 +5,7 @@ import json from dataclasses import asdict from pathlib import Path +from string import Template from typing import Any, cast from nsc.revise.gate import Counts, decide @@ -12,7 +13,16 @@ from spec.ir.nodes import Line from spec.passes import signatures -from . import DSPyPass, PassContext, PassFailure, cached_pass, inner_json, new_id, with_diag +from . import ( + DSPyPass, + PassContext, + PassFailure, + cached_pass, + contract_text, + inner_json, + new_id, + with_diag, +) from .schema_bridge import allowed_values, schema_hint #: Line 字段真相在 spec/ir;beat_index 是归属下标(Pass 装配用),id 等由 Pass 分配。 @@ -25,15 +35,16 @@ class Module(DSPyPass): pass_name = "p5_dialogue" +#: 契约文案真相在 spec/passes/contracts.yaml(SW-03 / ADR-0015);动态部分用 Template 填充。 + + def _visual_contract(visuals: list[Any]) -> str: """必现视觉契约文案:逐字原文要求 + 用品牌数据动态生成的示范动作行。""" - base = ( - "must_include_lines 里的每一句必须在某条对白(dialogue)中逐字原文出现;" - "must_include_visuals 里的每一项必须逐字原文写进某条 line_type=action 的动作行," - "不得改写、不得替换其中任何词(例如不得把'logo'换成'标志')。" - ) + base = contract_text("p5_dialogue", "brand_must_base") if visuals: - base += f'示范动作行:"镜头拉近,{visuals[0]}清晰可见。"——动作行里必须出现与该视觉项完全一致的字面子串。' + base += Template(contract_text("p5_dialogue", "brand_must_example")).substitute( + visual=visuals[0] + ) return base @@ -50,10 +61,15 @@ def _naming_contract(brand: dict[str, Any]) -> str: ] if not canonical: return "" - return ( - f"产品名唯一规范写法:{canonical}。任何语境(对白、动作行、菜单、招牌、字幕)" - f"都不得单独使用简称或变体(如 {sorted(set(forbidden)) or '别名'})," - "提到产品必须写完整规范名。" + return Template(contract_text("p5_dialogue", "product_naming")).substitute( + canonical=canonical, forbidden=sorted(set(forbidden)) or "别名" + ) + + +def _dialogue_length_target(chars_lo: int, chars_hi: int, scene_secs: float, cps: float) -> str: + """本场对白字数目标文案(DLG-006 的前置指导;数值按 Beat 时长 × 语速推算)。""" + return Template(contract_text("p5_dialogue", "dialogue_length_target")).substitute( + chars_lo=chars_lo, chars_hi=chars_hi, secs=f"{scene_secs:.0f}", cps=cps ) @@ -90,11 +106,7 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: "must_include_visuals": json.dumps(visuals, ensure_ascii=False), "brand_must_contract": _visual_contract(visuals), "product_naming_contract": _naming_contract(ctx.brand), - "dialogue_length_target": ( - f"本场对白(dialogue)总字数目标 {chars_lo}-{chars_hi} 字" - f"(按本场 Beat 时长 {scene_secs:.0f}s × {cps} 字/秒推算);" - "对白太少会导致成片时长不足(DLG-006)。" - ), + "dialogue_length_target": _dialogue_length_target(chars_lo, chars_hi, scene_secs, cps), "profile_json": json.dumps(ctx.profile, ensure_ascii=False), "retrieved_cases": fragment.get("retrieved_cases", ""), "line_schema_hint": _LINE_HINT + ";另需 beat_index: int(归属第几个 Beat,从 0)", diff --git a/tests/test_pass_contracts.py b/tests/test_pass_contracts.py new file mode 100644 index 0000000..c4d2a55 --- /dev/null +++ b/tests/test_pass_contracts.py @@ -0,0 +1,96 @@ +"""SW-03 Pass 契约文案资产化:p3/p5 内嵌的机械契约字符串真相搬到 spec/passes/contracts.yaml。 + +规则依据(AGENTS.md §2):禁止在 prompt/代码里硬编码自然语言知识; +prompts/** 是 GEPA 生成物禁止手改,所以契约文案进 spec/ 资产层、编译时注入。 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +_SPECS = { + "p3_beatsheet": { + "setup_payoffs": ["PENDING:", "下标", "slug"], + "facts": ["resolves", "known_facts", "narrative_weight"], + "state_changes": ["declared_state", "delta"], + }, + "p5_dialogue": { + "brand_must_base": ["逐字原文", "action"], + "brand_must_example": ["${visual}", "示范动作行"], + "product_naming": ["${canonical}", "${forbidden}"], + "dialogue_length_target": ["${chars_lo}", "${chars_hi}", "DLG-006"], + }, +} + + +def test_contracts_yaml_exists_with_all_keys(): + data = yaml.safe_load(Path("spec/passes/contracts.yaml").read_text("utf-8")) + for pass_name, keys in _SPECS.items(): + section = data.get(pass_name, {}) + for key, needles in keys.items(): + assert section.get(key), f"{pass_name}.{key} 缺失" + for needle in needles: + assert needle in section[key], f"{pass_name}.{key} 缺少关键片段 {needle!r}" + + +def test_p3_constants_sourced_from_spec(): + """p3 模块常量必须来自 spec 资产(代码里不得再各存一份漂移副本)。""" + from nsc.passes import contract_text + from nsc.passes import p3_beatsheet as p3 + + assert contract_text("p3_beatsheet", "setup_payoffs") == p3._SP_CONTRACT + assert contract_text("p3_beatsheet", "facts") == p3._FACT_CONTRACT + assert contract_text("p3_beatsheet", "state_changes") == p3._SC_CONTRACT + + +def test_missing_asset_fails_fast(monkeypatch, tmp_path): + """review 修正:契约资产缺失/键缺失必须 PassFailure,不得静默降级为空串。""" + from nsc.passes import PassFailure, contract_text + + monkeypatch.setattr("nsc.passes._CONTRACTS_PATH", tmp_path / "nonexistent.yaml") + with pytest.raises(PassFailure, match="不可读"): + contract_text("p3_beatsheet", "setup_payoffs") + + good = Path("spec/passes/contracts.yaml") + monkeypatch.setattr("nsc.passes._CONTRACTS_PATH", good) + with pytest.raises(PassFailure, match=r"p9_nothing\.missing_key"): + contract_text("p9_nothing", "missing_key") + + +def test_contract_text_rereads_asset(monkeypatch, tmp_path): + """review 修正:同进程内的 spec 编辑必须立刻可见(不做进程级缓存)。""" + import yaml as y + + from nsc.passes import contract_text + + f = tmp_path / "contracts.yaml" + f.write_text(y.safe_dump({"p3_beatsheet": {"setup_payoffs": "v1"}}), "utf-8") + monkeypatch.setattr("nsc.passes._CONTRACTS_PATH", f) + assert contract_text("p3_beatsheet", "setup_payoffs") == "v1" + f.write_text(y.safe_dump({"p3_beatsheet": {"setup_payoffs": "v2"}}), "utf-8") + assert contract_text("p3_beatsheet", "setup_payoffs") == "v2", "进程内不得缓存旧契约" + + +def test_p5_contract_builders_use_spec_templates(): + from nsc.passes import p5_dialogue as p5 + + base = p5._visual_contract([]) + assert "逐字原文" in base and "示范动作行" not in base + with_visual = p5._visual_contract(["特写镜头"]) + assert '示范动作行:"镜头拉近,特写镜头清晰可见。"' in with_visual + + naming = p5._naming_contract( + { + "products": [ + {"name": "元气茶", "canonical_name": "元气满满乌龙茶", "aliases": ["元气茶"]} + ] + } + ) + assert "元气满满乌龙茶" in naming and "元气茶" in naming + assert p5._naming_contract({"products": []}) == "" + + target = p5._dialogue_length_target(100, 200, 50, 4.5) + assert "100-200 字" in target and "50s × 4.5" in target and "DLG-006" in target