Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions adr/0015-pass-contract-strings-as-asset.md
Original file line number Diff line number Diff line change
@@ -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/<pass>.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/<pass>.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"` 绿。
32 changes: 32 additions & 0 deletions spec/passes/contracts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# SW-03 / ADR-0015:Pass 输出契约文案(资产层 A5 知识)。
#
# 这些是"机械复述给模型的格式契约":描述输出 JSON 的形状与填法,属于规范知识,
# 按 AGENTS.md §2 不得硬编码在 src/ 代码里;它们也不是 GEPA 优化对象
# (优化对象只有 prompts/<pass>.json 的 instructions,prompts/** 禁止手改)。
# 运行时占位符用 ${name}(string.Template),由 Pass 用品牌/预算数据填充。
schema_version: "1.0"

p3_beatsheet:
# setup_payoffs_json 的格式契约(防把下标写成描述)
setup_payoffs: |-
setup_payoffs_json 每个条目形如 {"slug":"小写短标识","setup":<beats_json 的下标 int>,"payoff":<beats_json 的下标 int> 或 "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)。
32 changes: 32 additions & 0 deletions src/nsc/passes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any

import dspy
import yaml
from ulid import ULID

from nsc.runtime.cache import cached_pass
Expand All @@ -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 反馈驱动再生成)。"""
Expand Down
28 changes: 5 additions & 23 deletions src/nsc/passes/p3_beatsheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
PassContext,
PassFailure,
cached_pass,
contract_text,
inner_json,
new_id,
optional_json,
Expand All @@ -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":<beats_json 的下标 int>,'
'"payoff":<beats_json 的下标 int> 或 "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):
Expand Down
44 changes: 28 additions & 16 deletions src/nsc/passes/p5_dialogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@
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
from nsc.revise.revision_brief import BriefSources, build_brief
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 分配。
Expand All @@ -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


Expand All @@ -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
)


Expand Down Expand Up @@ -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)",
Expand Down
96 changes: 96 additions & 0 deletions tests/test_pass_contracts.py
Original file line number Diff line number Diff line change
@@ -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
Loading