From 90113b2e62e191af6ba97f80163e6667a6793309 Mon Sep 17 00:00:00 2001 From: randypanding Date: Sat, 22 Aug 2026 04:57:47 +0800 Subject: [PATCH 01/28] =?UTF-8?q?SW-05=20p3=20fragment=20=E7=BB=84?= =?UTF-8?q?=E6=88=90=E6=95=B0=E6=8D=AE=E5=8C=96=EF=BC=9A=E7=AA=97=E5=8F=A3?= =?UTF-8?q?/=E6=8A=95=E5=BD=B1/Thread=20=E6=B3=A8=E5=85=A5=E8=BF=9B=20prof?= =?UTF-8?q?ile.context=EF=BC=88ADR-0017=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - context.prev_summary_window(缺省 1=原行为):run_pipeline 与 recompile_episode 统一走 _window_join(近端在前、远端逐行追加;window=1 逐字节同原实现) - context.known_fact_fields(缺省原五字段,白名单校验):_known_facts 投影可窄化 - context.inject_threads(缺省 false):p2 Thread 表按 {id,title,status,state} 投影注入 p3 fragment,p3 转发进 LLM 输入 - profiles/_schema.py 新增 ContextSettings(extra=forbid + field_validator) - 测试:tests/test_p3_context_config.py(5 例,先红后绿) --- adr/0017-p3-context-profile-section.md | 48 ++++++++++ profiles/_schema.py | 28 +++++- profiles/short_drama_v1.yaml | 4 +- profiles/short_video_v1.yaml | 4 +- src/nsc/passes/p3_beatsheet.py | 4 + src/nsc/passes/pipeline.py | 88 ++++++++++++++--- tests/test_p3_context_config.py | 127 +++++++++++++++++++++++++ 7 files changed, 285 insertions(+), 18 deletions(-) create mode 100644 adr/0017-p3-context-profile-section.md create mode 100644 tests/test_p3_context_config.py diff --git a/adr/0017-p3-context-profile-section.md b/adr/0017-p3-context-profile-section.md new file mode 100644 index 0000000..ad5980d --- /dev/null +++ b/adr/0017-p3-context-profile-section.md @@ -0,0 +1,48 @@ +# ADR-0017:p3 fragment 组成数据化(profile.context 段) + +- 状态:proposed +- 日期:2026-08-22 +- 影响层:A 资产层(profiles/_schema.py + profile yaml);B 层仅消费 + +## 背景 + +SW-05(上游依赖卡):p3 的跨集上下文组成硬编码在 `pipeline.py`—— +`prev_episode_summary` 窗口恒为 1(只看上一集)、`known_facts` 投影恒为五字段、 +p2 规划的 `threads` 表永不注入 p3。弱模型需要更长的历史窗口与更窄的投影面, +强模型可以相反;这些是 profile 级策略,不是代码常量。 + +## 决定 + +Profile 新增 `context` 段(缺省 = 原行为,零变化): + +| 键 | 语义 | 缺省 | 消费点 | +|---|---|---|---| +| `context.prev_summary_window` | p3 `prev_episode_summary` 看近端 N 集(0=恒空) | 1 | `run_pipeline` p3 循环 + `recompile_episode`(`_window_join`:近端在前、远端逐行追加) | +| `context.known_fact_fields` | `known_facts` 投影字段(白名单子集) | `[id, content, episode_no, status, type]` | `_known_facts` | +| `context.inject_threads` | 是否把 p2 的 Thread 表注入 p3(投影 `{id,title,status,state}`) | false | `run_pipeline`/`recompile_episode` 组装 fragment,p3 转发进 LLM 输入 | + +`profiles/_schema.py` 新增 `ContextSettings`(`extra="forbid"`;`known_fact_fields` +经校验器限制在白名单内)。后续上下文预算旋钮(SW-06)并入同段。 + +## 被否决的替代 + +| 替代 | 为什么否决 | +|---|---| +| 环境变量 | 与 SW-04 同理:策略被运行时稀释,不进缓存键 | +| 每集覆盖(brief 级) | 组成策略是 profile 资产,逐 brief 覆盖会让缓存键粒度爆炸 | +| 直接注入全量 threads/facts | p3 输入预算失控;投影面必须显式声明 | + +## 对下游的约束 + +- 改窗口/投影只动 profile yaml;`_KNOWN_FACT_FIELDS` 白名单新增字段需同步本 ADR 的表。 +- window=1 时 `prev_episode_summary` 与原实现逐字节一致(既有快照/桩测试守护)。 + +## 迁移 + +非 breaking:三键全有缺省;在库 profile(short_drama_v1 / short_video_v1)显式 +写入缺省值以便发现。 + +## 验证 + +`tests/test_p3_context_config.py`:投影字段与缺省回退、窗口=1 回归、窗口=2 含 +祖父集摘要且近端在前、threads 开关注入/缺省不注入;全量 `pytest -m "not llm"` 绿。 diff --git a/profiles/_schema.py b/profiles/_schema.py index 5cfc256..062b9a7 100644 --- a/profiles/_schema.py +++ b/profiles/_schema.py @@ -4,7 +4,7 @@ from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from spec.ir.nodes import Slug @@ -36,6 +36,31 @@ class BeatTemplate(BaseModel): note: str = "" +class ContextSettings(BaseModel): + """SW-05 / ADR-0017:p3 fragment 组成旋钮。缺省值 = 原代码行为(零变化)。""" + + model_config = ConfigDict(extra="forbid") + prev_summary_window: int = Field( + default=1, ge=0, description="p3 prev_episode_summary 的近端窗口(集数)" + ) + known_fact_fields: list[str] = Field( + default_factory=lambda: ["id", "content", "episode_no", "status", "type"], + description="known_facts 投影字段(白名单子集)", + ) + inject_threads: bool = Field( + default=False, description="是否把 p2 的 Thread 表注入 p3 fragment" + ) + + @field_validator("known_fact_fields") + @classmethod + def _within_whitelist(cls, v: list[str]) -> list[str]: + allowed = {"id", "content", "episode_no", "status", "type"} + bad = [x for x in v if x not in allowed] + if bad: + raise ValueError(f"known_fact_fields 白名单外字段:{bad}(允许:{sorted(allowed)})") + return v + + class Profile(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: Literal["1.0"] = "1.0" @@ -62,6 +87,7 @@ class Profile(BaseModel): beat_templates: list[BeatTemplate] = Field(default_factory=list) novel: NovelSettings = Field(default_factory=NovelSettings) + context: ContextSettings = Field(default_factory=ContextSettings) render_targets: list[Slug] = Field(default_factory=lambda: ["novel_docx", "script_fountain"]) enabled_check_domains: list[str] = Field( diff --git a/profiles/short_drama_v1.yaml b/profiles/short_drama_v1.yaml index affd2ab..2d32bdf 100644 --- a/profiles/short_drama_v1.yaml +++ b/profiles/short_drama_v1.yaml @@ -51,4 +51,6 @@ model_tiers: p3_beatsheet: tier_plan p4_scene: tier_draft p5_dialogue: tier_draft - p6_prose: tier_draft \ No newline at end of file + p6_prose: tier_draft +# SW-05 / ADR-0017:p3 跨集上下文组成(缺省即原行为) +context: {prev_summary_window: 1, known_fact_fields: [id, content, episode_no, status, type], inject_threads: false} diff --git a/profiles/short_video_v1.yaml b/profiles/short_video_v1.yaml index 330a5f5..dac3985 100644 --- a/profiles/short_video_v1.yaml +++ b/profiles/short_video_v1.yaml @@ -55,4 +55,6 @@ model_tiers: p3_beatsheet: tier_plan p4_scene: tier_draft p5_dialogue: tier_draft - p6_prose: tier_draft \ No newline at end of file + p6_prose: tier_draft +# SW-05 / ADR-0017:p3 跨集上下文组成(缺省即原行为) +context: {prev_summary_window: 1, known_fact_fields: [id, content, episode_no, status, type], inject_threads: false} diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index ffa2a6d..a4d0967 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -94,6 +94,10 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: revivable = str(fragment.get("revivable_ideas", "") or "") if revivable: inputs["revivable_ideas"] = revivable + # SW-05:Thread 注入(profile.context.inject_threads 开关,pipeline 组装 JSON) + threads = str(fragment.get("threads", "") or "") + if threads: + inputs["threads"] = threads out = Module()(ctx, with_diag(inputs, fragment)) raw_beats = inner_json(out["beats_json"], "p3_beatsheet", "beats_json") raw_sps = inner_json(out["setup_payoffs_json"], "p3_beatsheet", "setup_payoffs_json") diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index 964dce4..38d7f55 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json import os import re import sys @@ -322,6 +323,12 @@ def track() -> None: check_stage(ctx, cur(), "after_p2", "after_p2") episodes = r2["episodes"] + # SW-05:p3 fragment 组成旋钮(profile.context.*,缺省 = 原行为) + p3_ctx = ctx.profile.get("context", {}) + prev_window = max(0, int(p3_ctx.get("prev_summary_window", 1))) + fact_fields = _known_fact_fields_of(ctx.profile) + inject_threads = bool(p3_ctx.get("inject_threads", False)) + ep_summaries: list[str] = [] # p3(逐集)+ p4 + after_p3/p4 检查作为一个相位:L0 拦截(如 BM-002 植入间隔)时 # 带诊断整体重试(D13 反馈驱动再生成,诊断累积);重试前恢复相位前的状态。 diag = "" @@ -331,7 +338,6 @@ def track() -> None: } ep_state_snap = {e["id"]: list(e.get("state_changes", [])) for e in episodes} try: - prev_summary = "" for i, ep in enumerate(episodes): placement = [ p for p in r2["placement_plan"] if int(p.get("episode_no", -1)) == ep["no"] @@ -341,12 +347,12 @@ def track() -> None: "bible": bible, "placement": placement, "required_brand_moment_beats": len(placement), - "prev_episode_summary": prev_summary, + "prev_episode_summary": _window_join(ep_summaries, prev_window), "next_episode_promise": episodes[i + 1]["hook_promise"] if i + 1 < len(episodes) else "", # ADR-0012:跨集回收上下文 + 状态变更声明域(facts/threads 等表见上) - "known_facts": _known_facts(st["facts"]), + "known_facts": _known_facts(st["facts"], fact_fields), "declared_state": _declared_state(st), "retrieved_cases": _retrieved(ctx, "beat_sequence", _ep_query(ep)), } @@ -354,9 +360,11 @@ def track() -> None: frag3["_previous_failure"] = diag if revivable: frag3["revivable_ideas"] = revivable + if inject_threads: + frag3["threads"] = _threads_view(st["threads"]) r3 = _retry_pass(p3_beatsheet.run, ctx, frag3) track() - prev_summary = ";".join(b["summary"] for b in r3["beats"]) + ep_summaries.append(";".join(b["summary"] for b in r3["beats"])) st["beats"] += r3["beats"] st["setup_payoffs"] += r3["setup_payoffs"] st["brand_moments"] += r3["brand_moments"] @@ -474,23 +482,32 @@ def track() -> None: bank_db = _state_db(ctx, pid) # T-41:上次重编译存入 bank 的未复活素材作为可选注入层(空则不加) revivable = _revivable_layer(bank_db, pid) + # SW-05:p3 fragment 组成旋钮(与 run_pipeline 同一 profile.context.* 段) + _p3c = ctx.profile.get("context", {}) + r_window = max(0, int(_p3c.get("prev_summary_window", 1))) frag3 = { "episode": ep, "bible": bible, "placement": _placement_of(raw, ep), "required_brand_moment_beats": len(_placement_of(raw, ep)), - "prev_episode_summary": _episode_digest(raw, ordered[idx - 1]["id"]) if idx > 0 else "", + "prev_episode_summary": _window_join( + [_episode_digest(raw, e["id"]) for e in ordered[max(0, idx - r_window) : idx]], + r_window, + ), "next_episode_promise": ordered[idx + 1]["hook_promise"] if idx + 1 < len(ordered) else "", # ADR-0012:其他集已成立的 facts 可被本集回收;状态声明域来自 IR 表 "known_facts": _known_facts( - [f for f in raw.get("facts", []) if f.get("episode_no") != ep["no"]] + [f for f in raw.get("facts", []) if f.get("episode_no") != ep["no"]], + _known_fact_fields_of(ctx.profile), ), "declared_state": _declared_state(raw), "retrieved_cases": _retrieved(ctx, "beat_sequence", _ep_query(ep)), } if revivable: frag3["revivable_ideas"] = revivable + if _p3c.get("inject_threads", False): + frag3["threads"] = _threads_view(raw.get("threads", [])) r3 = _retry_pass(p3_beatsheet.run, ctx, frag3) track() r4 = _retry_pass(p4_scene.run, ctx, {"episode": ep, "beats": r3["beats"], "bible": bible}) @@ -618,21 +635,62 @@ def _episode_digest(raw: dict[str, Any], ep_id: str) -> str: return ";".join(b["summary"] for b in raw["beats"] if b["parent_id"] in scene_ids) -def _known_facts(facts: list[dict[str, Any]]) -> list[dict[str, Any]]: - """p3 的跨集回收上下文(ADR-0012):已成立 Fact 的最小可见面,供模型引用 id 做回收。""" +# --- SW-05 / ADR-0017:p3 fragment 组成的 profile 旋钮(context.* 段,缺省 = 原行为) --- + +#: known_facts 投影字段全集(值 = 缺省填充,机制映射,非业务规则)。 +_KNOWN_FACT_FIELDS: dict[str, Any] = { + "id": "", + "content": "", + "episode_no": 1, + "status": "active", + "type": "plot_event", +} + + +def _known_fact_fields_of(profile: dict[str, Any]) -> tuple[str, ...]: + """profile.context.known_fact_fields → 投影字段(白名单交集,缺省 = 原五字段)。""" + wanted = profile.get("context", {}).get("known_fact_fields") or list(_KNOWN_FACT_FIELDS) + return tuple(k for k in _KNOWN_FACT_FIELDS if k in wanted) + + +def _known_facts( + facts: list[dict[str, Any]], fields: tuple[str, ...] = tuple(_KNOWN_FACT_FIELDS) +) -> list[dict[str, Any]]: + """p3 的跨集回收上下文(ADR-0012):已成立 Fact 的最小可见面,供模型引用 id 做回收。 + + SW-05:投影字段由 profile.context.known_fact_fields 决定(缺省 = 原五字段)。 + """ return [ - { - "id": f["id"], - "content": f["content"], - "episode_no": f.get("episode_no", 1), - "status": f.get("status", "active"), - "type": f.get("type", "plot_event"), - } + {k: f.get(k, default) for k, default in _KNOWN_FACT_FIELDS.items() if k in fields} for f in facts if isinstance(f, dict) ] +def _window_join(summaries: list[str], window: int) -> str: + """prev_episode_summary 窗口(SW-05):近端在前、远端追加在后,逐行拼接。 + + window=1 与原行为逐字节一致(单元素直接返回);window=0 即恒空串。 + """ + n = max(0, int(window)) + return "\n".join(summaries[-n:]) if n else "" + + +def _threads_view(threads: list[Any]) -> str: + """Thread 注入面(SW-05):p2 规划的叙事线索标题/状态,供 p3 做跨集呼应。""" + view = [ + { + "id": t.get("id", ""), + "title": t.get("title", ""), + "status": t.get("status", "active"), + "state": t.get("state", ""), + } + for t in threads + if isinstance(t, dict) + ] + return json.dumps(view, ensure_ascii=False) + + def _declared_state(st: dict[str, Any]) -> dict[str, Any]: """p3 的状态变更声明域(ADR-0012):只允许对已声明的 StateVariable/DarkThread key 变更。""" return { diff --git a/tests/test_p3_context_config.py b/tests/test_p3_context_config.py new file mode 100644 index 0000000..ae85173 --- /dev/null +++ b/tests/test_p3_context_config.py @@ -0,0 +1,127 @@ +"""SW-05 p3 fragment 组成数据化:prev_summary 窗口 / known_facts 投影 / Thread 注入开关。 + +三个旋钮此前硬编码在 pipeline.py(窗口=1、投影=五字段、threads 永不注入); +现在由 profile 的 context.* 段驱动,缺省 = 原行为(零变化)。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import diskcache +import yaml + +import nsc.runtime.cache as cache_mod +from tests.test_pipeline_stub import FullStubRouter + + +# ---------------------------------------------------------------- known_facts 投影 +def test_known_facts_projection_fields(): + from nsc.passes.pipeline import _known_facts + + facts = [ + { + "id": "f1", + "content": "林晚怕苦", + "episode_no": 2, + "status": "active", + "type": "backstory", + } + ] + # 缺省 = 原五字段(含各字段缺省值填充) + assert _known_facts(facts) == [ + { + "id": "f1", + "content": "林晚怕苦", + "episode_no": 2, + "status": "active", + "type": "backstory", + } + ] + # 窄投影:只保留 profile 指定的字段 + assert _known_facts(facts, ("id", "content")) == [{"id": "f1", "content": "林晚怕苦"}] + # 字段缺省仍生效 + assert _known_facts([{"id": "f2", "content": "x"}])[0]["status"] == "active" + + +def test_known_facts_fields_from_profile(tmp_path): + from nsc.passes.pipeline import _known_fact_fields_of + + assert _known_fact_fields_of({}) == ("id", "content", "episode_no", "status", "type") + ctx_profile = {"context": {"known_fact_fields": ["id", "content"]}} + assert _known_fact_fields_of(ctx_profile) == ("id", "content") + + +# ---------------------------------------------------------------- 窗口与 Thread 注入(全桩管线) +class RecordingRouter(FullStubRouter): + """记录每次 p3 的输入,其余行为与黄金桩一致。""" + + def __init__(self) -> None: + super().__init__() + self.p3_inputs: list[dict] = [] + + def _p3(self, inputs): + self.p3_inputs.append(inputs) + return super()._p3(inputs) + + +def _ctx(tmp_path, monkeypatch, context_cfg: dict | None): + monkeypatch.setenv("NSC_NO_CACHE", "1") + monkeypatch.setattr(cache_mod, "_cache", diskcache.Cache(str(tmp_path / "cache"))) + from nsc.passes import PassContext + from nsc.runtime.provenance import RunsStore + + profile = yaml.safe_load(Path("profiles/short_drama_v1.yaml").read_text("utf-8")) + if context_cfg is not None: + profile["context"] = context_cfg + brand = yaml.safe_load(Path("brands/demo_tea/brand.yaml").read_text("utf-8")) + brief = yaml.safe_load(Path("examples/demo_tea/brief.yaml").read_text("utf-8")) + return PassContext( + profile=profile, + brand=brand, + brief=brief, + router=RecordingRouter(), + store=RunsStore(tmp_path / "runs.db"), + ruleset_ver="test-rules", + spec_sha="test-spec", + out_dir=tmp_path / "out", + ) + + +def _run(ctx): + from nsc.passes.pipeline import run_pipeline + + run_pipeline(ctx) + return ctx.router.p3_inputs + + +def test_prev_summary_window_default_is_one(tmp_path, monkeypatch): + inputs = _run(_ctx(tmp_path, monkeypatch, None)) + summaries = {ep_no: inp["prev_episode_summary"] for ep_no, inp in enumerate(inputs)} + assert summaries[0] == "", "首集为空" + # 原行为:第 N 集只看到第 N-1 集的 Beat 摘要 + for i in range(2, len(inputs)): + assert summaries[i] != summaries[i - 1], "相邻集窗口内容不同(各自只含前一集)" + + +def test_prev_summary_window_two_includes_grandparent(tmp_path, monkeypatch): + inputs = _run(_ctx(tmp_path, monkeypatch, {"prev_summary_window": 2})) + summaries = {ep_no: inp["prev_episode_summary"] for ep_no, inp in enumerate(inputs)} + assert summaries[0] == "" + assert summaries[1] != "", "第二集窗口=第一集摘要" + # 窗口=2:第 3 集的窗口严格包含第 2 集的窗口(多了第 1 集摘要) + assert summaries[2].startswith(summaries[1]), "近端摘要在前,远端追加在后" + assert len(summaries[2]) > len(summaries[1]) + + +def test_threads_injection_switch(tmp_path, monkeypatch): + inputs_off = _run(_ctx(tmp_path, monkeypatch, None)) + assert all("threads" not in inp for inp in inputs_off), "缺省不注入 threads(原行为)" + + inputs_on = _run(_ctx(tmp_path, monkeypatch, {"inject_threads": True})) + assert inputs_on and all("threads" in inp for inp in inputs_on) + threads = json.loads(inputs_on[0]["threads"]) + assert isinstance(threads, list) + if threads: # 黄金桩 p2 可能没产出 threads;有则校验投影面 + assert set(threads[0]) == {"id", "title", "status", "state"} From 10227b01396a473d9f4a4d04dd152a93ea072c75 Mon Sep 17 00:00:00 2001 From: randypanding Date: Sat, 22 Aug 2026 05:40:45 +0800 Subject: [PATCH 02/28] =?UTF-8?q?SW-05=20review=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=9Aknown=5Ffact=5Ffields=20=E6=98=BE=E5=BC=8F=E7=A9=BA?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E4=B8=8D=E5=86=8D=E5=9B=9E=E9=80=80=E7=BC=BA?= =?UTF-8?q?=E7=9C=81=EF=BC=9B=E7=AA=97=E5=8F=A3=E5=BA=8F=E8=A1=A8=E8=BF=B0?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=97=B6=E9=97=B4=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review(PR #13): - _known_fact_fields_of 用 or 判断,显式 [] 与未配置混同 → 改为 is None 判定 - _window_join 实现是时间序(远端在前),初稿 docstring/ADR/测试注释写成 "近端在前" → 统一改为时间序表述(与 compress_history【前情】→【上一集】布局一致) --- adr/0017-p3-context-profile-section.md | 4 +++- src/nsc/passes/pipeline.py | 11 ++++++++--- tests/test_p3_context_config.py | 7 +++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/adr/0017-p3-context-profile-section.md b/adr/0017-p3-context-profile-section.md index ad5980d..79ba280 100644 --- a/adr/0017-p3-context-profile-section.md +++ b/adr/0017-p3-context-profile-section.md @@ -17,7 +17,7 @@ Profile 新增 `context` 段(缺省 = 原行为,零变化): | 键 | 语义 | 缺省 | 消费点 | |---|---|---|---| -| `context.prev_summary_window` | p3 `prev_episode_summary` 看近端 N 集(0=恒空) | 1 | `run_pipeline` p3 循环 + `recompile_episode`(`_window_join`:近端在前、远端逐行追加) | +| `context.prev_summary_window` | p3 `prev_episode_summary` 看近端 N 集(0=恒空) | 1 | `run_pipeline` p3 循环 + `recompile_episode`(`_window_join`:按时间序逐行拼接,远端在前、近端在后,与 compress_history 的【前情】→【上一集】布局一致) | | `context.known_fact_fields` | `known_facts` 投影字段(白名单子集) | `[id, content, episode_no, status, type]` | `_known_facts` | | `context.inject_threads` | 是否把 p2 的 Thread 表注入 p3(投影 `{id,title,status,state}`) | false | `run_pipeline`/`recompile_episode` 组装 fragment,p3 转发进 LLM 输入 | @@ -36,6 +36,8 @@ Profile 新增 `context` 段(缺省 = 原行为,零变化): - 改窗口/投影只动 profile yaml;`_KNOWN_FACT_FIELDS` 白名单新增字段需同步本 ADR 的表。 - window=1 时 `prev_episode_summary` 与原实现逐字节一致(既有快照/桩测试守护)。 +- `known_fact_fields: []` 是合法的显式空投影(有意隐藏全部字段),不等同于未配置(review 修正)。 +- 窗口内多集按时间序排列(远端在前),与 compress_history 输出布局一致(review 澄清,初稿表述有误)。 ## 迁移 diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index 38d7f55..ac8dc5a 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -648,8 +648,12 @@ def _episode_digest(raw: dict[str, Any], ep_id: str) -> str: def _known_fact_fields_of(profile: dict[str, Any]) -> tuple[str, ...]: - """profile.context.known_fact_fields → 投影字段(白名单交集,缺省 = 原五字段)。""" - wanted = profile.get("context", {}).get("known_fact_fields") or list(_KNOWN_FACT_FIELDS) + """profile.context.known_fact_fields → 投影字段(白名单交集,缺省 = 原五字段)。 + + 显式空列表是合法配置(投影面为空,即有意隐藏全部字段),不与"未配置"混同。 + """ + raw = profile.get("context", {}).get("known_fact_fields") + wanted = list(_KNOWN_FACT_FIELDS) if raw is None else raw return tuple(k for k in _KNOWN_FACT_FIELDS if k in wanted) @@ -668,8 +672,9 @@ def _known_facts( def _window_join(summaries: list[str], window: int) -> str: - """prev_episode_summary 窗口(SW-05):近端在前、远端追加在后,逐行拼接。 + """prev_episode_summary 窗口(SW-05):按时间序拼接(远端在前、近端在后)。 + 与 compress_history 的【前情】→【上一集】布局一致(review 澄清:chronological)。 window=1 与原行为逐字节一致(单元素直接返回);window=0 即恒空串。 """ n = max(0, int(window)) diff --git a/tests/test_p3_context_config.py b/tests/test_p3_context_config.py index ae85173..37e93c9 100644 --- a/tests/test_p3_context_config.py +++ b/tests/test_p3_context_config.py @@ -51,6 +51,8 @@ def test_known_facts_fields_from_profile(tmp_path): assert _known_fact_fields_of({}) == ("id", "content", "episode_no", "status", "type") ctx_profile = {"context": {"known_fact_fields": ["id", "content"]}} assert _known_fact_fields_of(ctx_profile) == ("id", "content") + # 显式空列表 = 有意隐藏全部字段(review 修正:不得与"未配置"混同回退缺省) + assert _known_fact_fields_of({"context": {"known_fact_fields": []}}) == () # ---------------------------------------------------------------- 窗口与 Thread 注入(全桩管线) @@ -110,8 +112,9 @@ def test_prev_summary_window_two_includes_grandparent(tmp_path, monkeypatch): summaries = {ep_no: inp["prev_episode_summary"] for ep_no, inp in enumerate(inputs)} assert summaries[0] == "" assert summaries[1] != "", "第二集窗口=第一集摘要" - # 窗口=2:第 3 集的窗口严格包含第 2 集的窗口(多了第 1 集摘要) - assert summaries[2].startswith(summaries[1]), "近端摘要在前,远端追加在后" + # 窗口=2:时间序拼接(远端在前、近端在后,同 compress_history 布局)—— + # 第 3 集的窗口以第 1 集摘要开头、第 2 集摘要收尾,严格长于第 2 集的窗口 + assert summaries[2].startswith(summaries[1]), "窗口扩展只在前端追加远端集" assert len(summaries[2]) > len(summaries[1]) From 69f1e345e9f14e920b5e4af34f50c4f2c89ac1f6 Mon Sep 17 00:00:00 2001 From: sync Date: Sat, 22 Aug 2026 11:56:29 +0800 Subject: [PATCH 03/28] =?UTF-8?q?sync:=20=E8=BF=9C=E7=AB=AF=20main=20?= =?UTF-8?q?=E5=BF=AB=E7=85=A7(API=20tarball;=E8=A7=A3=E5=86=B3=20fetch=20?= =?UTF-8?q?=E9=98=BB=E6=96=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CODEOWNERS | 16 +- adr/0015-pass-contract-strings-as-asset.md | 47 ++++++ ...0016-pipeline-strategy-profile-sections.md | 51 ++++++ profiles/_schema.py | 32 ++++ profiles/short_drama_v1.yaml | 6 +- profiles/short_video_v1.yaml | 6 +- spec/passes/contracts.yaml | 32 ++++ src/nsc/cli.py | 23 ++- src/nsc/eval/gate.py | 22 +-- src/nsc/eval/l1.py | 6 +- src/nsc/passes/__init__.py | 55 ++++++- src/nsc/passes/p3_beatsheet.py | 28 +--- src/nsc/passes/p5_dialogue.py | 67 +++++--- src/nsc/passes/pipeline.py | 47 ++++-- src/nsc/runtime/models.py | 86 +++++++++- src/nsc/runtime/provenance.py | 18 +++ tests/test_judge.py | 28 +++- tests/test_pass_contracts.py | 96 +++++++++++ tests/test_profile_strategy.py | 152 ++++++++++++++++++ tests/test_spec_domains.py | 118 ++++++++++++++ tests/test_transcripts.py | 104 ++++++++++++ 21 files changed, 950 insertions(+), 90 deletions(-) create mode 100644 adr/0015-pass-contract-strings-as-asset.md create mode 100644 adr/0016-pipeline-strategy-profile-sections.md create mode 100644 spec/passes/contracts.yaml create mode 100644 tests/test_pass_contracts.py create mode 100644 tests/test_profile_strategy.py create mode 100644 tests/test_spec_domains.py create mode 100644 tests/test_transcripts.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e4752f0..35924c9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,10 +1,10 @@ # 资产层必须由你本人 review。Agent 可以提 PR,但不能自动合并。 -/spec/ @OWNER -/profiles/ @OWNER -/brands/ @OWNER -/cases/export/ @OWNER -/eval/thresholds.yaml @OWNER -/adr/ @OWNER -/.github/ @OWNER -/COMPLIANCE.md @OWNER +/spec/ @randypanding +/profiles/ @randypanding +/brands/ @randypanding +/cases/export/ @randypanding +/eval/thresholds.yaml @randypanding +/adr/ @randypanding +/.github/ @randypanding +/COMPLIANCE.md @randypanding # src/ 与 tests/ 不设 owner:Agent 自主区 \ No newline at end of file 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/adr/0016-pipeline-strategy-profile-sections.md b/adr/0016-pipeline-strategy-profile-sections.md new file mode 100644 index 0000000..4d7b663 --- /dev/null +++ b/adr/0016-pipeline-strategy-profile-sections.md @@ -0,0 +1,51 @@ +# ADR-0016:管线策略 profile 化(pipeline/retrieval/revise 三段) + +- 状态:proposed +- 日期:2026-08-22 +- 影响层:A 资产层(profiles/_schema.py + 两个 profile yaml);B 层仅消费 + +## 背景 + +SW-07(上游依赖卡):各 phase 重试次数、定向重生成策略、self-check 子步骤开关、 +检索注入条数(rerank n)此前是 `pipeline.py` / `p5_dialogue.py` / `cli.py` 里的 +代码常量(`attempts=2`、`range(3)`、`"lenient"`、`k=3`)。弱模型与强模型需要 +不同的重试预算,改常量就要动代码层,无法按 profile 分道调参。 + +## 决定 + +Profile 新增三段(缺省值 = 原代码常量,既有 profile 行为零变化): + +| 段.键 | 语义 | 缺省 | 消费点 | +|---|---|---|---| +| `pipeline.pass_attempts` | 单 Pass 输出波动重试(D13) | 2 | `pipeline._retry_pass` | +| `pipeline.phase_attempts` | p3/p4、p5、p6 相位级定向重生成 | 3 | `run_pipeline` 三个相位循环 | +| `retrieval.top_k` | 每命中的案例注入条数(rerank top-n) | 3 | `cli._make_retrieval` → `RetrievalService.k` | +| `revise.self_check` | p5 自检子步骤开关(T-31,既有键,入 schema 正名) | true | `p5_dialogue._self_check` | +| `revise.gate_mode` | 定向重生成采纳门槛(`nsc.revise.gate.MODES`) | lenient | `p5_dialogue._self_check` | + +`profiles/_schema.py` 相应新增 `PipelineSettings` / `RetrievalSettings` / +`ReviseSettings`(`extra="forbid"`,与既有段一致)。 + +## 被否决的替代 + +| 替代 | 为什么否决 | +|---|---| +| 环境变量(NSC_PASS_ATTEMPTS 等) | 与 SW-04 同理:策略被运行时环境稀释,且不可进 provenance/缓存键 | +| config/models.yaml 侧配置 | 那是模型路由的生成物配置;策略属于 profile 资产 | +| 每相位独立键(phase_p3_attempts...) | 现实中三个相位同预算;先给粗粒度,需要时再加 | + +## 对下游的约束 + +- 调参只改 profile yaml,不改 `src/`;`_retry_pass(attempts=...)` 显式参数保留 + (测试/特殊路径用),优先级:显式参数 > profile > 代码缺省。 +- `revise.gate_mode` 只允许 `nsc.revise.gate.MODES` 里的值(schema Literal 约束)。 + +## 迁移 + +非 breaking:新段全部有缺省,旧 profile(无三段)行为不变。两个在库 profile +(short_drama_v1 / short_video_v1)显式写入了缺省值以便发现。 + +## 验证 + +`tests/test_profile_strategy.py`:pass_attempts/phase_attempts 生效性与缺省回退、 +phase_attempts=1 时 BM-007 拦截即抛、gate_mode/top_k 读取;全量 `pytest -m "not llm"` 绿。 diff --git a/profiles/_schema.py b/profiles/_schema.py index 5cfc256..1e0089b 100644 --- a/profiles/_schema.py +++ b/profiles/_schema.py @@ -36,6 +36,31 @@ class BeatTemplate(BaseModel): note: str = "" +class PipelineSettings(BaseModel): + """SW-07 / ADR-0016:管线重试策略。缺省值 = 提案前的代码常量(零行为变化)。""" + + model_config = ConfigDict(extra="forbid") + pass_attempts: int = Field(default=2, ge=1, description="单 Pass 输出波动重试次数(D13)") + phase_attempts: int = Field(default=3, ge=1, description="p3/p4、p5、p6 相位级定向重生成次数") + + +class RetrievalSettings(BaseModel): + """SW-07 / ADR-0016:案例检索注入策略。""" + + model_config = ConfigDict(extra="forbid") + top_k: int = Field(default=3, ge=1, description="每 Pass 注入的命中案例条数(rerank top-n)") + + +class ReviseSettings(BaseModel): + """SW-07 / ADR-0016:定向重生成(self-check 修订)策略。""" + + model_config = ConfigDict(extra="forbid") + self_check: bool = Field(default=True, description="p5 自检子步骤开关(T-31)") + gate_mode: Literal["strict", "lenient", "always"] = Field( + default="lenient", description="修订采纳门槛(nsc.revise.gate.MODES)" + ) + + class Profile(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: Literal["1.0"] = "1.0" @@ -63,6 +88,13 @@ class Profile(BaseModel): beat_templates: list[BeatTemplate] = Field(default_factory=list) novel: NovelSettings = Field(default_factory=NovelSettings) + #: SW-07 / ADR-0016:管线策略(重试与定向重生成次数)。缺省 = 原代码常量。 + pipeline: PipelineSettings = Field(default_factory=lambda: PipelineSettings()) + #: SW-07 / ADR-0016:检索注入条数(rerank top-n)。 + retrieval: RetrievalSettings = Field(default_factory=lambda: RetrievalSettings()) + #: SW-07 / ADR-0016:定向重生成(self-check 修订)策略。 + revise: ReviseSettings = Field(default_factory=lambda: ReviseSettings()) + render_targets: list[Slug] = Field(default_factory=lambda: ["novel_docx", "script_fountain"]) enabled_check_domains: list[str] = Field( default_factory=lambda: [ diff --git a/profiles/short_drama_v1.yaml b/profiles/short_drama_v1.yaml index affd2ab..07896bd 100644 --- a/profiles/short_drama_v1.yaml +++ b/profiles/short_drama_v1.yaml @@ -51,4 +51,8 @@ model_tiers: p3_beatsheet: tier_plan p4_scene: tier_draft p5_dialogue: tier_draft - p6_prose: tier_draft \ No newline at end of file + p6_prose: tier_draft +# SW-07 / ADR-0016:管线策略(缺省即原代码常量;弱模型/强模型 profile 可分道调参) +pipeline: {pass_attempts: 2, phase_attempts: 3} +retrieval: {top_k: 3} +revise: {self_check: true, gate_mode: lenient} diff --git a/profiles/short_video_v1.yaml b/profiles/short_video_v1.yaml index 330a5f5..3bb0acf 100644 --- a/profiles/short_video_v1.yaml +++ b/profiles/short_video_v1.yaml @@ -55,4 +55,8 @@ model_tiers: p3_beatsheet: tier_plan p4_scene: tier_draft p5_dialogue: tier_draft - p6_prose: tier_draft \ No newline at end of file + p6_prose: tier_draft +# SW-07 / ADR-0016:管线策略(缺省即原代码常量;弱模型/强模型 profile 可分道调参) +pipeline: {pass_attempts: 2, phase_attempts: 3} +retrieval: {top_k: 3} +revise: {self_check: true, gate_mode: lenient} 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/cli.py b/src/nsc/cli.py index e9a4e92..3f8040a 100644 --- a/src/nsc/cli.py +++ b/src/nsc/cli.py @@ -21,7 +21,7 @@ def _load_assets(profile_id: str, brand_id: str) -> tuple[dict, dict]: def _make_ctx(brief: dict, out_dir: Path, router: Any = None) -> Any: from nsc.passes import PassContext from nsc.runtime.models import ModelRouter - from nsc.runtime.provenance import RunsStore, spec_fingerprint + from nsc.runtime.provenance import RunsStore, spec_domain_fingerprints, spec_fingerprint profile, brand = _load_assets(brief.get("profile", ""), brief.get("brand", "")) spec_files = list(Path("spec").rglob("*.py")) + list(Path("spec").rglob("*.yaml")) @@ -34,11 +34,28 @@ def _make_ctx(brief: dict, out_dir: Path, router: Any = None) -> Any: store=RunsStore(out_dir / "runs.db"), ruleset_ver=spec_fingerprint(list(Path("spec/checks").rglob("*.yaml")))[:12], spec_sha=spec_fingerprint(spec_files)[:12], + spec_shas=spec_domain_fingerprints(), # SW-02:缓存键分域;provenance 仍全量 promptset_ver=spec_fingerprint(prompts)[:12] if prompts else "seed", out_dir=out_dir, ) +def _make_retrieval(ctx: Any) -> Any: + """SW-07:检索服务,注入条数 top_k 读 profile.retrieval(缺省 3=原常量)。""" + import typer + + from nsc.retrieval import RetrievalService + + raw = ctx.profile.get("retrieval", {}).get("top_k", 3) + try: + k = max(1, int(raw)) + except (TypeError, ValueError) as e: + raise typer.BadParameter( + f"profile 的 retrieval.top_k 必须是正整数,当前为 {raw!r}(review 修正:给可读报错)" + ) from e + return RetrievalService(db_path="cases/cases.db", k=k) + + # --- 编译 --- @app.command() def run( @@ -56,9 +73,7 @@ def run( brief_dict = yaml.safe_load(Path(brief).read_text("utf-8")) ctx = _make_ctx(brief_dict, Path(out)) if not no_retrieval: - from nsc.retrieval import RetrievalService - - ctx.retrieval = RetrievalService(db_path="cases/cases.db") + ctx.retrieval = _make_retrieval(ctx) try: ir = run_pipeline(ctx) except PassFailure as e: diff --git a/src/nsc/eval/gate.py b/src/nsc/eval/gate.py index 8b38cbc..0509afc 100644 --- a/src/nsc/eval/gate.py +++ b/src/nsc/eval/gate.py @@ -1,12 +1,13 @@ -"""评测门禁(T-08b):阈值加载 + JUDGE_GATE_ENABLED + 校准结果判定。 +"""评测门禁(T-08b):阈值加载 + 校准结果判定。 D8:未过校准门槛的判官只能出报告,不能参与门禁。 -`judge calibrate` 未过闸时会把仓库变量 JUDGE_GATE_ENABLED 写为 false(协议 §4)。 +`judge calibrate` 未过闸时会把 judge-calibration.yml 的 judge_gate_enabled 写为 false +(协议 §4)。SW-04:门禁真相只在状态文件,环境变量不再有覆盖能力—— +环境开关等于给"未校准判官可门禁"留后门,与 D8 相悖。 """ from __future__ import annotations -import os from pathlib import Path from typing import Any @@ -20,15 +21,8 @@ def load_thresholds(path: str | Path = THRESHOLDS_PATH) -> dict[str, Any]: return yaml.safe_load(Path(path).read_text("utf-8")) or {} -def gate_var_name() -> str: - return str(load_thresholds().get("l1", {}).get("judge_gate_enabled_var", "JUDGE_GATE_ENABLED")) - - def gate_enabled() -> bool: - """判官是否允许参与门禁。优先级:环境变量 > judge-calibration.yml > 默认开启。""" - val = os.environ.get(gate_var_name()) - if val is not None: - return val.strip().lower() not in ("0", "false", "off", "no", "") + """判官是否允许参与门禁。真相:judge-calibration.yml > 默认开启(SW-04:无 env 覆盖)。""" if GATE_STATE_PATH.exists(): data = yaml.safe_load(GATE_STATE_PATH.read_text("utf-8")) or {} return bool(data.get("judge_gate_enabled", True)) @@ -108,10 +102,10 @@ def main(argv: list[str] | None = None) -> int: ev = evaluate_calibration(metrics) else: if not GATE_STATE_PATH.exists(): - # CI 场景(judge-calibration.yml 尚未提交):回退到 gate_enabled() 的 - # 环境变量(JUDGE_GATE_ENABLED)/ 默认开启,避免硬失败。 + # CI 场景(judge-calibration.yml 尚未提交):回退到默认开启,避免硬失败。 + # SW-04:环境变量不再有覆盖能力(D8:未校准判官只能出报告)。 enabled = gate_enabled() - print(f"无校准状态文件;JUDGE_GATE_ENABLED={str(enabled).lower()}") + print(f"无校准状态文件;gate_enabled={str(enabled).lower()}") return 0 if enabled else 1 state = yaml.safe_load(GATE_STATE_PATH.read_text("utf-8")) or {} ev = evaluate_calibration(state.get("metrics") or {}) diff --git a/src/nsc/eval/l1.py b/src/nsc/eval/l1.py index 16a47c3..62a3164 100644 --- a/src/nsc/eval/l1.py +++ b/src/nsc/eval/l1.py @@ -109,7 +109,7 @@ def _compile_brief(brief: dict[str, Any], retrieval_on: bool) -> dict[str, Any]: from nsc.passes.pipeline import run_pipeline from nsc.runtime.ir_io import build_view from nsc.runtime.models import ModelRouter - from nsc.runtime.provenance import RunsStore, spec_fingerprint + from nsc.runtime.provenance import RunsStore, spec_domain_fingerprints, spec_fingerprint profile = yaml.safe_load(Path(f"profiles/{brief.get('profile', '')}.yaml").read_text("utf-8")) brand = yaml.safe_load(Path(f"brands/{brief.get('brand', '')}/brand.yaml").read_text("utf-8")) @@ -122,6 +122,7 @@ def _compile_brief(brief: dict[str, Any], retrieval_on: bool) -> dict[str, Any]: store=RunsStore(Path("out") / "eval_runs.db"), ruleset_ver=spec_fingerprint(list(Path("spec/checks").rglob("*.yaml")))[:12], spec_sha=spec_fingerprint(spec_files)[:12], + spec_shas=spec_domain_fingerprints(), # SW-02:缓存键分域 out_dir=Path("out") / "eval", ) if retrieval_on: @@ -294,7 +295,7 @@ def _compile_for_judge(brief: dict[str, Any]) -> dict[str, Any]: from nsc.passes import PassContext, PassFailure from nsc.passes.pipeline import run_pipeline from nsc.runtime.models import ModelRouter - from nsc.runtime.provenance import RunsStore, spec_fingerprint + from nsc.runtime.provenance import RunsStore, spec_domain_fingerprints, spec_fingerprint profile = yaml.safe_load(Path(f"profiles/{brief.get('profile', '')}.yaml").read_text("utf-8")) brand = yaml.safe_load(Path(f"brands/{brief.get('brand', '')}/brand.yaml").read_text("utf-8")) @@ -307,6 +308,7 @@ def _compile_for_judge(brief: dict[str, Any]) -> dict[str, Any]: store=RunsStore(Path("out") / "eval_runs.db"), ruleset_ver=spec_fingerprint(list(Path("spec/checks").rglob("*.yaml")))[:12], spec_sha=spec_fingerprint(spec_files)[:12], + spec_shas=spec_domain_fingerprints(), # SW-02:缓存键分域 out_dir=Path("out") / "eval", ) try: diff --git a/src/nsc/passes/__init__.py b/src/nsc/passes/__init__.py index 3c6736e..fb8c10e 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 反馈驱动再生成)。""" @@ -47,6 +79,14 @@ def new_id() -> str: return str(ULID()) +#: 进缓存键的 spec 域(SW-02):只含影响生成结构的域;checks 由 ruleset_ver 覆盖。 +CACHE_SPEC_DOMAINS = ("ir", "passes") +#: 个别 Pass 的额外缓存依赖域(review 修正):p5 的 self-check 经 +#: nsc.revise.revision_brief 读 spec/rules/L3_canonical(VOICE RULES 五节), +#: 该域编辑必须使 p5 缓存失效(ruleset_ver 只覆盖 spec/checks,管不到这里)。 +PASS_EXTRA_SPEC_DOMAINS: dict[str, tuple[str, ...]] = {"p5_dialogue": ("rules",)} + + @dataclass class PassContext: """一次编译的运行上下文。所有版本号集中在这里,缓存键由 cache_versions 给出。""" @@ -62,6 +102,8 @@ class PassContext: seed: int | None = 1 out_dir: Path = Path("out") run_id: str = "" + #: SW-02 分域 spec 指纹(domain → sha12)。空 = 旧语义(缓存键用全量 spec_sha)。 + spec_shas: dict[str, str] = field(default_factory=dict) #: T-16 检索服务(None = 禁用检索;set 后 pipeline 会往 p1/p2/p3/p5 注入 retrieved_cases) retrieval: Any = None @@ -73,6 +115,17 @@ def _model_cfg(self, pass_name: str) -> dict[str, Any]: return {} return self.router.resolve(self.tier_of(pass_name)) + def scoped_spec_sha(self, pass_name: str = "") -> str: + """缓存键用 spec 指纹:分域只取相关域(含该 Pass 的额外依赖域)。 + + 任一必需域缺失(半套指纹)时回退全量 spec_sha——宁可多失效,不可少失效 + (review 修正:空域拼出的 "ir:|passes:" 会静默削弱缓存失效条件)。 + """ + domains = CACHE_SPEC_DOMAINS + PASS_EXTRA_SPEC_DOMAINS.get(pass_name, ()) + if not self.spec_shas or any(d not in self.spec_shas for d in domains): + return self.spec_sha + return "|".join(f"{d}:{self.spec_shas[d]}" for d in domains) + def cache_versions(self, pass_name: str) -> dict[str, Any]: cfg = self._model_cfg(pass_name) return { @@ -83,7 +136,7 @@ def cache_versions(self, pass_name: str) -> dict[str, Any]: "model_id": str(cfg.get("model", "none")), "temperature": float(cfg.get("temperature", 0.0)), "seed": self.seed, - "spec_sha": self.spec_sha, + "spec_sha": self.scoped_spec_sha(pass_name), } def record_run( 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..38bbf1f 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)", @@ -235,6 +247,23 @@ def _counts(findings: list[dict[str, Any]]) -> Counts: ) +def _gate_mode(ctx: PassContext) -> str: + """SW-07:定向重生成(self-check 修订)采纳策略(profile.revise.gate_mode,缺省 lenient)。 + + 非法值转 PassFailure(review 修正):裸 dict profile 无 schema 校验兜底, + 不能让 revise.gate.MODES 的 ValueError 直接击穿编排的 PassFailure 捕获链。 + """ + mode = str(ctx.profile.get("revise", {}).get("gate_mode", "lenient")) + from nsc.revise.gate import MODES + + if mode not in MODES: + raise PassFailure( + None, + f"profile.revise.gate_mode 必须是 {MODES} 之一,当前为 {mode!r};请修正 profile。", + ) + return mode + + def _self_check( ctx: PassContext, inputs: dict[str, Any], @@ -247,8 +276,8 @@ def _self_check( """自我修订(默认开,profile.revise.self_check=False 关闭)。 干净路径零 findings → 不调 LLM。有问题时把 revision_brief 五节文本注入重生成; - 修订经 revisionGate(lenient) 判定采纳,未达标或解析失败则回退原稿—— - 残留 findings 由 pipeline 的 check_stage(after_p5) 兜底拦截,不会静默丢失。 + 修订经 revisionGate(策略 profile.revise.gate_mode)判定采纳,未达标或解析失败 + 则回退原稿——残留 findings 由 pipeline 的 check_stage(after_p5) 兜底拦截,不会静默丢失。 """ if not ctx.profile.get("revise", {}).get("self_check", True): return lines, out @@ -270,6 +299,6 @@ def _self_check( except PassFailure: return lines, out findings2 = _scene_findings(ctx, scene, beats, lines2, characters) - if decide(_counts(findings), _counts(findings2), "lenient"): + if decide(_counts(findings), _counts(findings2), _gate_mode(ctx)): return lines2, out2 return lines, out diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index 964dce4..a123a7f 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -81,20 +81,46 @@ def _accum(diag: str, new: str) -> str: return f"{diag}\n---\n{new}" if diag else new -def _retry_pass(fn: Any, ctx: PassContext, fragment: dict[str, Any], *, attempts: int = 2) -> Any: +def _attempts_of(ctx: PassContext, key: str, default: int) -> int: + """SW-07:读 profile.pipeline. 的次数旋钮;坏值转 PassFailure(诊断句,review 修正)。""" + raw = ctx.profile.get("pipeline", {}).get(key, default) + try: + return max(1, int(raw)) + except (TypeError, ValueError) as e: + raise PassFailure( + None, + f"profile.pipeline.{key} 必须是正整数,当前为 {raw!r};请修正 profile 配置后重跑。", + ) from e + + +def _pass_attempts(ctx: PassContext) -> int: + """SW-07:单 Pass 输出波动重试次数(profile.pipeline.pass_attempts,缺省 2)。""" + return _attempts_of(ctx, "pass_attempts", 2) + + +def _phase_attempts(ctx: PassContext) -> int: + """SW-07:p3/p4、p5、p6 相位级定向重生成次数(profile.pipeline.phase_attempts,缺省 3)。""" + return _attempts_of(ctx, "phase_attempts", 3) + + +def _retry_pass( + fn: Any, ctx: PassContext, fragment: dict[str, Any], *, attempts: int | None = None +) -> Any: """生成型 Pass 的输出波动重试:把上次失败诊断注入重试输入(D13 反馈驱动再生成)。 LLM 输出有随机性(漏字段/数错个数),带诊断的重试能显著降低端到端失败率; 失败语义不变(全部失败照样抛 PassFailure,GEPA 反馈信号不受影响),缓存只存成功产物。 + attempts:SW-07 缺省读 profile.pipeline.pass_attempts(原常量 2)。 """ + total = attempts if attempts is not None else _pass_attempts(ctx) last_reason = "" - for i in range(attempts): + for i in range(total): frag = {**fragment, "_previous_failure": last_reason} if last_reason else fragment try: return fn(ctx, frag) except PassFailure as e: last_reason = str(e) - if i == attempts - 1: + if i == total - 1: raise @@ -325,7 +351,8 @@ def track() -> None: # p3(逐集)+ p4 + after_p3/p4 检查作为一个相位:L0 拦截(如 BM-002 植入间隔)时 # 带诊断整体重试(D13 反馈驱动再生成,诊断累积);重试前恢复相位前的状态。 diag = "" - for attempt in range(3): + phase_n = _phase_attempts(ctx) + for attempt in range(phase_n): snapshot = { k: list(st[k]) for k in ("beats", "setup_payoffs", "brand_moments", "scenes", "facts") } @@ -386,12 +413,13 @@ def track() -> None: for e_ in episodes: e_["state_changes"] = ep_state_snap[e_["id"]] diag = _accum(diag, str(e)) - if attempt == 2: + if attempt == phase_n - 1: raise # p5 相位:对白 + after_p5 检查(如 BM-007 必提台词);拦截时带累积诊断整体重试。 diag5 = "" - for attempt in range(3): + phase5_n = _phase_attempts(ctx) + for attempt in range(phase5_n): snapshot_lines = list(st["lines"]) _dbg(f"p5-phase attempt={attempt} scenes={len(st['scenes'])} diag={diag5[:120]!r}") try: @@ -413,14 +441,15 @@ def track() -> None: _dbg(f"p5-phase caught PassFailure: {str(e)[:160]!r}") st["lines"] = snapshot_lines diag5 = _accum(diag5, str(e)) - if attempt == 2: + if attempt == phase5_n - 1: raise if ctx.profile.get("novel", {}).get("enabled"): st["voice"] = _voice(ctx, bible) # p6 相位:小说 + after_p6 检查(如 NOV-001 锚点覆盖);同上带累积诊断重试。 diag6 = "" - for attempt in range(3): + phase6_n = _phase_attempts(ctx) + for attempt in range(phase6_n): snapshot_chapters = list(st["chapters"]) _dbg(f"p6-phase attempt={attempt} diag={diag6[:120]!r}") try: @@ -438,7 +467,7 @@ def track() -> None: _dbg(f"p6-phase caught PassFailure: {str(e)[:160]!r}") st["chapters"] = snapshot_chapters diag6 = _accum(diag6, str(e)) - if attempt == 2: + if attempt == phase6_n - 1: raise ir = cur() diff --git a/src/nsc/runtime/models.py b/src/nsc/runtime/models.py index 71438f5..5be01c4 100644 --- a/src/nsc/runtime/models.py +++ b/src/nsc/runtime/models.py @@ -2,18 +2,37 @@ tier → 模型 的映射在 config/models.yaml(配置是生成物,路由策略是资产)。 成本统计走 litellm.completion_cost;Langfuse trace 在配置缺失时静默降级。 +SW-01:每次调用的 prompt/response 落 SQLite transcripts(Lab ADR-0001 §接口), +best-effort——写库失败静默降级,绝不影响路由本身。 """ from __future__ import annotations +import json import os +import sqlite3 import time from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from typing import Any import yaml +_TRANSCRIPT_SCHEMA = """ +CREATE TABLE IF NOT EXISTS transcripts ( + ts TEXT NOT NULL, + caller TEXT NOT NULL, + model TEXT NOT NULL, + prompt TEXT NOT NULL, + response TEXT NOT NULL, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + cost_usd REAL NOT NULL DEFAULT 0, + experiment_id TEXT NOT NULL DEFAULT '' +) +""" + @dataclass(slots=True) class LLMResult: @@ -36,7 +55,13 @@ def _has_json(text: str) -> bool: class ModelRouter: """按 tier 路由到具体模型,带重试与成本统计。""" - def __init__(self, config_path: str | Path = "config/models.yaml") -> None: + def __init__( + self, + config_path: str | Path = "config/models.yaml", + *, + transcript_db: str | Path | None = None, + experiment_id: str = "", + ) -> None: cfg = yaml.safe_load(Path(config_path).read_text("utf-8")) self.tiers: dict[str, dict[str, Any]] = cfg.get("tiers", {}) self.budgets: dict[str, float] = cfg.get("budgets", {}) @@ -44,6 +69,64 @@ def __init__(self, config_path: str | Path = "config/models.yaml") -> None: self.attempts: int = int(retry.get("attempts", 3)) # litellm 不认识的新模型(如 LongCat-2.0)用配置价兜底成本统计。 self.cost_per_mtok: dict[str, float] = cfg.get("cost_usd_per_mtok", {}) or {} + # SW-01 transcript 台账:库路径/实验号可用环境变量接线(Lab subprocess 场景)。 + self.transcript_db = Path( + transcript_db or os.environ.get("NSC_TRANSCRIPT_DB") or "out/transcripts.db" + ) + self.experiment_id = experiment_id or os.environ.get("NSC_EXPERIMENT_ID", "") + self._tconn: sqlite3.Connection | None = None + + def _transcript_conn(self) -> sqlite3.Connection | None: + """懒建连接;任何 IO 异常 → 返回 None(本功能 best-effort)。""" + if self._tconn is None: + try: + self.transcript_db.parent.mkdir(parents=True, exist_ok=True) + # timeout=0:transcripts 是 best-effort 记账,库被并发写锁住时立刻 + # 失败走静默路径,绝不为它阻塞路由(SW-01 review:busy timeout 会加路由延迟) + self._tconn = sqlite3.connect(str(self.transcript_db), timeout=0.0) + self._tconn.execute(_TRANSCRIPT_SCHEMA) + self._tconn.commit() + except Exception: + self._tconn = None + return self._tconn + + def _record_transcript( + self, + tier: str, + model_id: str, + messages: list[dict[str, str]], + text: str, + tokens_in: int, + tokens_out: int, + cost: float, + ) -> None: + conn = self._transcript_conn() + if conn is None: + return + try: + conn.execute( + "INSERT INTO transcripts (ts, caller, model, prompt, response," + " tokens_in, tokens_out, cost_usd, experiment_id)" + " VALUES (?,?,?,?,?,?,?,?,?)", + ( + datetime.now(UTC).isoformat(), + tier, + model_id, + json.dumps(messages, ensure_ascii=False), + text, + tokens_in, + tokens_out, + cost, + self.experiment_id, + ), + ) + conn.commit() + except Exception: + # best-effort:失败即回滚并弃置连接,防止半开事务长期持锁拖垮后续写入 + try: + conn.rollback() + finally: + self._tconn = None def resolve(self, tier: str) -> dict[str, Any]: if tier not in self.tiers: @@ -119,6 +202,7 @@ def complete( tokens_in * float(self.cost_per_mtok.get("input", 0.0)) + tokens_out * float(self.cost_per_mtok.get("output", 0.0)) ) / 1_000_000 + self._record_transcript(tier, cfg["model"], messages, text, tokens_in, tokens_out, cost) return LLMResult( text=text, model_id=cfg["model"], diff --git a/src/nsc/runtime/provenance.py b/src/nsc/runtime/provenance.py index a46f71e..66e6c8c 100644 --- a/src/nsc/runtime/provenance.py +++ b/src/nsc/runtime/provenance.py @@ -24,6 +24,24 @@ def spec_fingerprint(paths: list[Path]) -> str: return h.hexdigest() +def spec_domain_fingerprints(root: Path = Path("spec")) -> dict[str, str]: + """SW-02 分域指纹:按 spec 顶层子域分别取 sha256[:12]。 + + 任何小编订只让所属域的指纹变化;PassContext 据此把缓存键里的 spec_sha + 缩到影响生成结构的域(ir/passes),避免无关域(rubrics/feedback/...)编辑 + 使全量内容缓存失效。checks 域由既有 ruleset_ver 单独覆盖; + 全量指纹仍走 spec_fingerprint(runs.spec_sha 不弱化)。 + """ + domains: dict[str, list[Path]] = {} + for p in [*root.rglob("*.py"), *root.rglob("*.yaml")]: + rel = p.relative_to(root) + domain = rel.parts[0] if len(rel.parts) > 1 else "root" + if domain == "__pycache__": + continue + domains.setdefault(domain, []).append(p) + return {d: spec_fingerprint(ps)[:12] for d, ps in sorted(domains.items())} + + @dataclass(slots=True) class RunRecord: """对应 runs 表的一行(D20)。""" diff --git a/tests/test_judge.py b/tests/test_judge.py index 5a58a61..df7bee3 100644 --- a/tests/test_judge.py +++ b/tests/test_judge.py @@ -227,16 +227,30 @@ def test_compute_metrics(): # ---------------------------------------------------------------- 门禁 -def test_gate_enabled_respects_env(monkeypatch): +def test_gate_enabled_ignores_env(tmp_path, monkeypatch): + """SW-04:环境变量不得覆盖校准门禁。D8 真相只在 judge-calibration.yml。 + + 取代旧 test_gate_enabled_respects_env(其断言的 env 覆盖语义正是本卡移除对象, + 冲突记录见 PR 描述)。 + """ from nsc.eval import gate as g - monkeypatch.setenv("JUDGE_GATE_ENABLED", "0") - assert g.gate_enabled() is False + state = tmp_path / "state.yml" + monkeypatch.setattr(g, "GATE_STATE_PATH", state) monkeypatch.setenv("JUDGE_GATE_ENABLED", "true") - assert g.gate_enabled() is True - monkeypatch.delenv("JUDGE_GATE_ENABLED") - monkeypatch.setattr(g, "GATE_STATE_PATH", Path("/nonexistent/state.yml")) - assert g.gate_enabled() is True # 默认开 + state.write_text("judge_gate_enabled: false\n", "utf-8") + assert g.gate_enabled() is False, "env=true 不得越过校准关闸" + monkeypatch.setenv("JUDGE_GATE_ENABLED", "0") + state.write_text("judge_gate_enabled: true\n", "utf-8") + assert g.gate_enabled() is True, "env=0 不得关掉已校准的门禁" + + +def test_gate_enabled_defaults_on_without_state(tmp_path, monkeypatch): + from nsc.eval import gate as g + + monkeypatch.setattr(g, "GATE_STATE_PATH", tmp_path / "nonexistent.yml") + monkeypatch.setenv("JUDGE_GATE_ENABLED", "0") + assert g.gate_enabled() is True # 无校准状态文件:默认开 def test_gate_state_file_fallback(tmp_path, monkeypatch): 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 diff --git a/tests/test_profile_strategy.py b/tests/test_profile_strategy.py new file mode 100644 index 0000000..8f494e7 --- /dev/null +++ b/tests/test_profile_strategy.py @@ -0,0 +1,152 @@ +"""SW-07 pipeline 策略 profile 化:重试次数 / 定向重生成策略 / self-check 开关 / 检索 top-k。 + +这些原是 pipeline.py、p5_dialogue.py、cli.py 里的代码常量;现在全部从 +profile 的 pipeline.* / revise.* / retrieval.* 段读取,缺省值 = 原常量(零行为变化)。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import diskcache +import pytest +import yaml + +import nsc.runtime.cache as cache_mod +from tests.test_pipeline_stub import FullStubRouter + + +def _mini_ctx(tmp_path, profile: dict): + """策略读取测试用的最小 PassContext(真实类,防鸭子类型漂移)。""" + from nsc.passes import PassContext + from nsc.runtime.provenance import RunsStore + + return PassContext( + profile=profile, + brand={}, + router=None, + store=RunsStore(tmp_path / "runs.db"), + ruleset_ver="t", + spec_sha="t", + ) + + +def test_retry_pass_attempts_from_profile(tmp_path): + from nsc.passes import PassFailure + from nsc.passes.pipeline import _retry_pass + + calls = [] + + def flaky(ctx, frag): + calls.append(1) + raise PassFailure(None, "boom") + + # 显式 attempts 优先(既有调用方语义不变) + with pytest.raises(PassFailure): + _retry_pass(flaky, _mini_ctx(tmp_path, {}), {}, attempts=1) + assert len(calls) == 1 + + calls.clear() + with pytest.raises(PassFailure): + _retry_pass(flaky, _mini_ctx(tmp_path, {"pipeline": {"pass_attempts": 1}}), {}) + assert len(calls) == 1, "pass_attempts=1 时只允许一次尝试" + + calls.clear() + with pytest.raises(PassFailure): + _retry_pass(flaky, _mini_ctx(tmp_path, {}), {}) + assert len(calls) == 2, "缺省保持原常量 attempts=2" + + +class FlakyP5Router(FullStubRouter): + """首轮 p5 漏掉必提台词(触发 BM-007);输入带 _previous_failure 时修正。""" + + MUST_LINE = "不额外加蔗糖" + + def _p5(self, inputs): + payload = json.loads(super()._p5(inputs)["lines_json"]) + if "_previous_failure" not in inputs: + payload = [ln for ln in payload if self.MUST_LINE not in ln["text"]] + return {"lines_json": json.dumps(payload, ensure_ascii=False)} + + +def _ctx(tmp_path, monkeypatch, profile: dict): + monkeypatch.setenv("NSC_NO_CACHE", "1") + monkeypatch.setattr(cache_mod, "_cache", diskcache.Cache(str(tmp_path / "cache"))) + from nsc.passes import PassContext + from nsc.runtime.provenance import RunsStore + + brand = yaml.safe_load(Path("brands/demo_tea/brand.yaml").read_text("utf-8")) + brief = yaml.safe_load(Path("examples/demo_tea/brief.yaml").read_text("utf-8")) + return PassContext( + profile=profile, + brand=brand, + brief=brief, + router=FlakyP5Router(), + store=RunsStore(tmp_path / "runs.db"), + ruleset_ver="test-rules", + spec_sha="test-spec", + out_dir=tmp_path / "out", + ) + + +def test_phase_attempts_from_profile(tmp_path, monkeypatch): + """phase_attempts=1:p5 相位首轮被 BM-007 拦截后不得重试,整体抛 PassFailure。""" + from nsc.passes import PassFailure + from nsc.passes.pipeline import _phase_attempts, run_pipeline + + profile = yaml.safe_load(Path("profiles/short_drama_v1.yaml").read_text("utf-8")) + profile["pipeline"] = {"phase_attempts": 1} + assert _phase_attempts(_ctx(tmp_path, monkeypatch, profile)) == 1 + + ctx = _ctx(tmp_path, monkeypatch, profile) + with pytest.raises(PassFailure): + run_pipeline(ctx) + + profile2 = yaml.safe_load(Path("profiles/short_drama_v1.yaml").read_text("utf-8")) + assert _phase_attempts(_ctx(tmp_path, monkeypatch, profile2)) == 3, "缺省保持原常量 3" + + +def test_revise_gate_mode_read_from_profile(tmp_path): + from nsc.passes import p5_dialogue + from nsc.passes.pipeline import _pass_attempts + + assert p5_dialogue._gate_mode(_mini_ctx(tmp_path, {})) == "lenient", "缺省保持原常量 lenient" + assert ( + p5_dialogue._gate_mode(_mini_ctx(tmp_path, {"revise": {"gate_mode": "strict"}})) == "strict" + ), "revise.gate_mode 必须可从 profile 读" + assert _pass_attempts(_mini_ctx(tmp_path, {"pipeline": {"pass_attempts": 4}})) == 4 + + +def test_retrieval_top_k_from_profile(tmp_path): + from nsc.cli import _make_retrieval + + base = yaml.safe_load(Path("profiles/short_drama_v1.yaml").read_text("utf-8")) + svc = _make_retrieval(_mini_ctx(tmp_path, {**base, "retrieval": {"top_k": 5}})) + assert svc is not None and svc.k == 5, "retrieval.top_k 必须驱动检索条数" + + svc2 = _make_retrieval(_mini_ctx(tmp_path, dict(base))) + assert svc2 is not None and svc2.k == 3, "缺省保持原常量 k=3" + + +# ---------------------------------------------------------------- review 修正:坏配置的可诊断失败 +def test_bad_profile_values_fail_with_clear_errors(tmp_path): + import typer + + from nsc.cli import _make_retrieval + from nsc.passes import PassFailure, p5_dialogue + from nsc.passes.pipeline import _pass_attempts, _phase_attempts + + # 非整数重试次数 → PassFailure(走编排的失败/诊断链路,不是裸 TypeError) + with pytest.raises(PassFailure, match="pass_attempts"): + _pass_attempts(_mini_ctx(tmp_path, {"pipeline": {"pass_attempts": "many"}})) + with pytest.raises(PassFailure, match="phase_attempts"): + _phase_attempts(_mini_ctx(tmp_path, {"pipeline": {"phase_attempts": None}})) + + # 非法 gate_mode → PassFailure(不让 revise.gate 的 ValueError 击穿捕获链) + with pytest.raises(PassFailure, match="gate_mode"): + p5_dialogue._gate_mode(_mini_ctx(tmp_path, {"revise": {"gate_mode": "loose"}})) + + # 坏 top_k → typer.BadParameter(用户可读,不是裸堆栈) + with pytest.raises(typer.BadParameter, match="top_k"): + _make_retrieval(_mini_ctx(tmp_path, {"retrieval": {"top_k": "3条"}})) diff --git a/tests/test_spec_domains.py b/tests/test_spec_domains.py new file mode 100644 index 0000000..7c78c8f --- /dev/null +++ b/tests/test_spec_domains.py @@ -0,0 +1,118 @@ +"""SW-02 spec_sha 分域哈希:任何 spec 小编订不得使全量内容缓存失效。 + +- provenance.spec_domain_fingerprints:按 spec 顶层子域分别取指纹; +- PassContext.cache_versions 的 spec_sha 只取影响生成结构的域(ir+passes), + checks 域由既有的 ruleset_ver 单独覆盖,rubrics/feedback/rules 等不进缓存键; +- runs 表的 spec_sha 仍是全量指纹(provenance 不弱化)。 +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from nsc.runtime.provenance import spec_domain_fingerprints + + +def _mk_spec(root: Path) -> None: + (root / "checks").mkdir(parents=True) + (root / "rubrics").mkdir() + (root / "passes").mkdir() + (root / "ir").mkdir() + (root / "checks" / "c1.yaml").write_text("a: 1\n", "utf-8") + (root / "rubrics" / "r1.yaml").write_text("b: 1\n", "utf-8") + (root / "passes" / "signatures.py").write_text("x = 1\n", "utf-8") + (root / "ir" / "nodes.py").write_text("y = 1\n", "utf-8") + (root / "BUDGETS.yaml").write_text("lines: {}\n", "utf-8") + + +def test_domain_fingerprints_isolate_edits(tmp_path): + root = tmp_path / "spec" + _mk_spec(root) + before = spec_domain_fingerprints(root) + assert set(before) == {"checks", "rubrics", "passes", "ir", "root"} + + (root / "rubrics" / "r1.yaml").write_text("b: 2\n", "utf-8") # 只动 rubrics + after = spec_domain_fingerprints(root) + changed = {d for d in before if before[d] != after[d]} + assert changed == {"rubrics"}, "小编订必须只让所属域指纹变化" + + +def _ctx(tmp_path, spec_shas=None): + from nsc.passes import PassContext + from nsc.runtime.provenance import RunsStore + + return PassContext( + profile={"version": "1", "model_tiers": {}}, + brand={"version": "1"}, + router=None, + store=RunsStore(tmp_path / "runs.db"), + ruleset_ver="r", + spec_sha="full123", + spec_shas=spec_shas or {}, + ) + + +def test_cache_versions_uses_scoped_domains(tmp_path): + full = {"ir": "ir1", "passes": "pa1", "rubrics": "ru1", "checks": "ck1", "rules": "rl1"} + ctx = _ctx(tmp_path, full) + assert ctx.cache_versions("p3_beatsheet")["spec_sha"] == "ir:ir1|passes:pa1" + # 与生成无关的域变化不进缓存键(同 Pass 前后比对) + ctx2 = _ctx(tmp_path, {**full, "rubrics": "ru2", "checks": "ck2"}) + assert ( + ctx2.cache_versions("p5_dialogue")["spec_sha"] + == ctx.cache_versions("p5_dialogue")["spec_sha"] + ) + assert ( + ctx2.cache_versions("p3_beatsheet")["spec_sha"] + == ctx.cache_versions("p3_beatsheet")["spec_sha"] + ) + # 影响生成结构的域变化必须进缓存键 + ctx3 = _ctx(tmp_path, {**full, "ir": "ir2"}) + assert ( + ctx3.cache_versions("p3_beatsheet")["spec_sha"] + != ctx.cache_versions("p3_beatsheet")["spec_sha"] + ) + + +def test_rules_domain_only_invalidates_p5(tmp_path): + """review 修正:p5 的 self-check 读 spec/rules/L3_canonical(VOICE RULES), + rules 域编辑必须使 p5 缓存失效;不读该域的 pass(p3)不受牵连。""" + full = {"ir": "ir1", "passes": "pa1", "rubrics": "ru1", "checks": "ck1", "rules": "rl1"} + ctx = _ctx(tmp_path, full) + changed = _ctx(tmp_path, {**full, "rules": "rl2"}) + assert ( + changed.cache_versions("p5_dialogue")["spec_sha"] + != ctx.cache_versions("p5_dialogue")["spec_sha"] + ), "rules 域变化必须使 p5 缓存失效" + assert ( + changed.cache_versions("p3_beatsheet")["spec_sha"] + == ctx.cache_versions("p3_beatsheet")["spec_sha"] + ), "rules 域变化不得牵连不读该域的 pass" + assert ctx.cache_versions("p5_dialogue")["spec_sha"] == "ir:ir1|passes:pa1|rules:rl1" + + +def test_partial_domain_map_falls_back_to_full_sha(tmp_path): + """review 修正:半套分域指纹(缺必需域)必须回退全量 spec_sha, + 不得拼出 'ir:|passes:' 之类静默削弱缓存失效条件的键。""" + ctx = _ctx(tmp_path, {"ir": "ir1"}) # 缺 passes(且缺 p5 需要的 rules) + assert ctx.cache_versions("p3_beatsheet")["spec_sha"] == "full123" + assert ctx.cache_versions("p5_dialogue")["spec_sha"] == "full123" + + +def test_cache_versions_falls_back_to_full_sha(tmp_path): + """未提供分域指纹(旧测试/旧调用方)时保持原语义:全量 spec_sha。""" + ctx = _ctx(tmp_path) + assert ctx.cache_versions("p1_bible")["spec_sha"] == "full123" + + +def test_make_ctx_wires_domain_fingerprints(tmp_path): + from nsc.cli import _make_ctx + + brief = yaml.safe_load(Path("examples/demo_tea/brief.yaml").read_text("utf-8")) + ctx = _make_ctx(brief, tmp_path / "out") + assert {"ir", "passes", "checks"} <= set(ctx.spec_shas) + scoped = ctx.cache_versions("p3_beatsheet")["spec_sha"] + assert scoped != ctx.spec_sha, "分域指纹接线后,缓存键不得再混入全量 spec_sha" + assert scoped.startswith("ir:") and "passes:" in scoped diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py new file mode 100644 index 0000000..ef25a9d --- /dev/null +++ b/tests/test_transcripts.py @@ -0,0 +1,104 @@ +"""SW-01 transcript 持久化:ModelRouter 每次 LLM 调用的 prompt/response 落 SQLite。 + +表结构对齐 Lab 仓 ADR-0001 §接口:(ts, caller, model, prompt, response, +tokens_in, tokens_out, cost_usd, experiment_id)。库位置默认 out/transcripts.db, +可用 NSC_TRANSCRIPT_DB / 构造参数改道(Lab 经 subprocess 调 SW 时靠环境变量接线)。 +""" + +from __future__ import annotations + +import json +import sqlite3 +import types +from pathlib import Path + +import pytest + + +class _FakeResp: + """litellm.completion 的最小桩(无网络)。""" + + def __init__(self) -> None: + msg = types.SimpleNamespace(content='{"ok": 1}', reasoning_content="") + self.choices = [types.SimpleNamespace(message=msg)] + self.usage = types.SimpleNamespace(prompt_tokens=11, completion_tokens=7) + + +@pytest.fixture() +def fake_litellm(monkeypatch): + calls: list[dict] = [] + + def _completion(**kwargs): + calls.append(kwargs) + return _FakeResp() + + import litellm + + monkeypatch.setattr(litellm, "completion", _completion) + return calls + + +def _rows(db: Path) -> list[dict]: + conn = sqlite3.connect(str(db)) + try: + cur = conn.execute( + "SELECT ts, caller, model, prompt, response, tokens_in, tokens_out," + " cost_usd, experiment_id FROM transcripts" + ) + cols = [c[0] for c in cur.description or []] + return [dict(zip(cols, r, strict=True)) for r in cur.fetchall()] + finally: + conn.close() + + +def test_complete_writes_transcript_row(tmp_path, fake_litellm, monkeypatch): + from nsc.runtime.models import ModelRouter + + db = tmp_path / "t.db" + monkeypatch.setenv("NSC_TRANSCRIPT_DB", str(db)) + router = ModelRouter(experiment_id="exp-42") + res = router.complete( + "tier_bulk", + [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hello"}, + ], + json_mode=True, + seed=1, + ) + assert res.text == '{"ok": 1}' + + rows = _rows(db) + assert len(rows) == 1 + row = rows[0] + assert row["caller"] == "tier_bulk" + # 不硬编码具体模型 ID(models.yaml 会演化):断言落库值 == 路由实际选中的模型 + assert row["model"] == router.resolve("tier_bulk")["model"] + assert json.loads(row["prompt"]) == [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hello"}, + ] + assert row["response"] == '{"ok": 1}' + assert row["tokens_in"] == 11 + assert row["tokens_out"] == 7 + assert row["experiment_id"] == "exp-42" + assert row["cost_usd"] > 0 # cost_per_mtok 兜底价 + + +def test_transcript_db_via_constructor(tmp_path, fake_litellm, monkeypatch): + from nsc.runtime.models import ModelRouter + + monkeypatch.delenv("NSC_TRANSCRIPT_DB", raising=False) + db = tmp_path / "ctor.db" + router = ModelRouter(transcript_db=db) + router.complete("tier_bulk", [{"role": "user", "content": "hi"}]) + assert len(_rows(db)) == 1 + + +def test_transcript_failure_never_breaks_routing(tmp_path, fake_litellm, monkeypatch): + """transcript 是 best-effort 台账:写库失败不得影响 LLM 路由本身。""" + from nsc.runtime.models import ModelRouter + + router = ModelRouter(transcript_db=tmp_path) # 目录当 db 路径 → 打不开 + res = router.complete("tier_bulk", [{"role": "user", "content": "hi"}]) + assert res.text == '{"ok": 1}' From cbe30e771e29ef8265505822312f428cd66a1f45 Mon Sep 17 00:00:00 2001 From: merge Date: Sat, 22 Aug 2026 12:57:30 +0800 Subject: [PATCH 04/28] =?UTF-8?q?style:=20ruff=20format(union=20=E8=A7=A3?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E6=96=87=E4=BB=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- profiles/_schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/profiles/_schema.py b/profiles/_schema.py index 2d2e5c8..d2a7094 100644 --- a/profiles/_schema.py +++ b/profiles/_schema.py @@ -86,7 +86,6 @@ class ReviseSettings(BaseModel): ) - class Profile(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: Literal["1.0"] = "1.0" From c7f67a5d94b06f164613bff8ada8fb4f1c4c29ec Mon Sep 17 00:00:00 2001 From: randypanding Date: Mon, 24 Aug 2026 09:31:34 +0800 Subject: [PATCH 05/28] =?UTF-8?q?lab=20round10:=20p4=20beat=5Fto=5Fscene?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E7=9F=AB=E6=AD=A3(=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E7=BB=93=E6=9E=84=E6=BC=82=E7=A7=BB)+lab=5Fs?= =?UTF-8?q?moke=5Fv1=20=E4=B8=89=E9=9B=86=E8=AF=84=E6=B5=8B=E5=88=87?= =?UTF-8?q?=E7=89=87+p3=20=E6=8C=87=E4=BB=A4=20v2(round8/9:escalation/?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE/PENDING/=E9=92=A9=E5=AD=90=E7=BA=AA=E5=BE=8B?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- profiles/lab_smoke_v1.yaml | 60 ++++++++++++++++++++++++++++++++ prompts/p3_beatsheet.json | 10 ++++++ src/nsc/passes/p4_scene.py | 27 +++++++++++++- tests/test_p4_assign_coercion.py | 43 +++++++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 profiles/lab_smoke_v1.yaml create mode 100644 prompts/p3_beatsheet.json create mode 100644 tests/test_p4_assign_coercion.py diff --git a/profiles/lab_smoke_v1.yaml b/profiles/lab_smoke_v1.yaml new file mode 100644 index 0000000..fc9d49b --- /dev/null +++ b/profiles/lab_smoke_v1.yaml @@ -0,0 +1,60 @@ +schema_version: "1.0" +id: lab_smoke_v1 +version: "1.0.0" +display_name: Lab 冒烟评测切片(3 集,迭代用) + +layers: {season: false, episode: true, scene: true, beat: true, line: true} + +episode_count: [3, 3] +duration_target_s: 90 +duration_tolerance: 0.15 +beats_per_episode: [4, 7] +max_scenes_per_episode: 3 +max_characters: 5 +max_line_chars: 40 +chars_per_second: 4.5 + +min_emotion_range: 0.7 +require_setup_payoff: true +max_payoff_span_episodes: 2 +min_voice_tic_ratio: 0.15 +location_cost_budget: 3.0 + +beat_templates: + - id: hook_escalate_reveal + source: craft + note: "来源:Save the Cat 的节拍思路裁剪到 90 秒;待用 mined 统计替换(T-21)" + sequence: [hook, setup, escalation, complication, reversal, brand_moment, cliffhanger] + - id: mined_common_6beat + source: mined + note: "逆向标注 214 条样本中最高频序列;由 nsc annotate priors 生成(T-21 后填入真实值)" + sequence: [hook, inciting, escalation, brand_moment, reversal, cliffhanger] + +novel: + enabled: true + styles: [web_novel, warm_realism] + chars_per_episode: [1200, 2200] + default_voice: + person: third_limited + tense: past + style: web_novel + paragraph_max_chars: 180 + interiority: medium + +render_targets: [novel_docx, novel_md, script_fountain, script_docx, storyboard_csv] +enabled_check_domains: [structure, brand, dialogue, novel, compliance, producibility, fact] + +model_tiers: + p0_intake: tier_bulk + p1_bible: tier_plan + p2_arc: tier_plan + p3_beatsheet: tier_plan + p4_scene: tier_draft + p5_dialogue: tier_draft + p6_prose: tier_draft +# SW-05 / ADR-0017:p3 跨集上下文组成(缺省即原行为) +context: {prev_summary_window: 1, known_fact_fields: [id, content, episode_no, status, type], inject_threads: false} +# SW-07 / ADR-0016:管线策略(缺省即原代码常量;弱模型/强模型 profile 可分道调参) +pipeline: {pass_attempts: 2, phase_attempts: 3} +retrieval: {top_k: 3} +revise: {self_check: true, gate_mode: lenient} diff --git a/prompts/p3_beatsheet.json b/prompts/p3_beatsheet.json new file mode 100644 index 0000000..970af2a --- /dev/null +++ b/prompts/p3_beatsheet.json @@ -0,0 +1,10 @@ +{ + "instructions": "为单集写出 Beat 序列。这是整个系统里最关键的一趟:Beat 写得可判定,后面才写得出好台词。\n\n硬约束:\n- Beat 数在 profile 的 beats_per_episode 区间内。\n- 恰好一个 beat_kind=hook,且是第一个或第二个 Beat。\n- 最后一个 Beat 必须是 cliffhanger / resolution / cta。\n- 本集分配到的每个植入必须落成一个 beat_kind=brand_moment 的 Beat,且不得与 hook 相邻或落在 hook 上。\n- 每个 Beat 必须给出 emotion(valence, arousal) 与 est_duration_s,总时长贴近 duration_target_s。\n- 必须声明至少一组 setup→payoff;跨集回收时 payoff 写 \"PENDING:\"。\n 【PENDING 纪律】payoff 写 PENDING: 时,你必须在同一输出里、于后续某一集中\n 用同一个 slug 补一条真实的 payoff 落点——引用一个没有落点的 slug 是编译错误。\n- summary 必须采用事件模板(五要素一句话):地点/人物/行动/冲突/反转,\n 形如\"茶饮店:林晚当众核对配料表,冲突是陈经理的说法相反,反转是标签背面另有代糖来源\";\n 不得是抽象概括(如\"两人产生矛盾\")。\n- 【beat_kind 枚举纪律】beat_kind 只能从封闭集合取值:\n hook / setup / escalation / complication / reversal / brand_moment / payoff / resolution / cliffhanger / cta。\n 不得自造 kind(如\"铺垫\"\"收束\"\"转折\")——那不是合法值。\n- 【冲突升级硬约束】每集必须至少有一个 beat_kind 为 escalation / complication / reversal 的 Beat,\n 位置在 hook 之后、结尾之前:局势必须明确变得更糟或更复杂一步(新阻碍出现/谎言被识破/代价加码/盟友倒戈)。\n 如果某一拍的内容是\"矛盾加深/情况恶化\",它的 beat_kind 就必须标成 escalation/complication/reversal,\n 不许标成 setup。写完自检:逐个数一遍本集的 escalation/complication/reversal,若为 0 个,\n 立即把中间一个铺垫 Beat 改写并重新标注。\n- 【集末钩子回应】若本集 cliffhanger 不是空,你必须在 responds_to 里说明它回应了哪一集的钩子;\n 新开钩子要在后续 1-3 集内安排回应节拍。\n- 叙事状态(ADR-0012,可省略,省略即空表):facts_json 里 resolves 填同集下标、\n 已知前集 fact 的 id(见 known_facts)或 null(尚未回收);state_changes_json 的\n key 只能用已声明的状态变量/暗线 key(见 declared_state)。\n- 输出必须是合法 JSON,不要使用任何 Markdown 代码栅栏或解释性文字。", + "_meta": { + "generated_by": "lab-round2-optimizer", + "pass_name": "p3_beatsheet", + "content_hash": "c9d9516a74f335cc6439bd35360cbbfbe40a70befd2d92b99a96c9d5ac58217b", + "note": "round2: beat_kind 枚举纪律+PENDING 落点纪律+集末钩子回应+JSON 纯净(round1 失败诊断:STR-018 未消/STR-016/PENDING 悬空)", + "created_at": "2026-08-24" + } +} \ No newline at end of file diff --git a/src/nsc/passes/p4_scene.py b/src/nsc/passes/p4_scene.py index 7aa88da..c83947b 100644 --- a/src/nsc/passes/p4_scene.py +++ b/src/nsc/passes/p4_scene.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from typing import Any from spec.ir.nodes import KnowledgeState, Scene @@ -129,17 +130,41 @@ def _knowledge_state(raw: Any) -> dict[str, str] | None: return {k: str(v) for k, v in raw.items() if k in KnowledgeState.model_fields} or None +def _coerce_entry(m: Any) -> tuple[int, int] | None: + """把结构漂移的映射项矫正为 (beat_index, scene_index);矫正不了返回 None。 + + 随机后端实测漂移形态:"0:1" 字符串对 / {"beat":..,"scene":..} 键名变体 / [b,s] 二元组。""" + if isinstance(m, dict): + b = m.get("beat_index", m.get("beat", m.get("b"))) + s = m.get("scene_index", m.get("scene", m.get("s"))) + if b is not None and s is not None: + return int(b), int(s) + return None + if isinstance(m, (list, tuple)) and len(m) == 2: + return int(m[0]), int(m[1]) + if isinstance(m, str): + nums = [p for p in re.split(r"[::,\-–—/ ]+", m.strip()) if p.strip().isdigit()] + if len(nums) == 2: + return int(nums[0]), int(nums[1]) + return None + + def _assign( mapping: Any, beats: list[dict[str, Any]], scenes: list[dict[str, Any]], ep: dict[str, Any], ) -> list[dict[str, Any]]: + if isinstance(mapping, dict): + mapping = [{"beat_index": k, "scene_index": v} for k, v in mapping.items()] if not isinstance(mapping, list): raise PassFailure(ep["id"], "p4_scene 输出的 beat_to_scene 应为列表") beat_to_scene: dict[int, int] = {} for m in mapping: - beat_to_scene[int(m["beat_index"])] = int(m["scene_index"]) + pair = _coerce_entry(m) + if pair is None: + raise PassFailure(ep["id"], f"beat_to_scene 含不可解析的映射项:{str(m)[:60]}") + beat_to_scene[pair[0]] = pair[1] out = [] scene_counters: dict[int, int] = {} for i, b in enumerate(beats): diff --git a/tests/test_p4_assign_coercion.py b/tests/test_p4_assign_coercion.py new file mode 100644 index 0000000..7fa8b39 --- /dev/null +++ b/tests/test_p4_assign_coercion.py @@ -0,0 +1,43 @@ +"""p4_scene._assign 映射项类型矫正(round10:随机后端 beat_to_scene 结构漂移实证)。""" +import pytest + +from nsc.passes.p4_scene import _assign +from nsc.passes import PassFailure + +BEATS = [{"id": f"b{i}"} for i in range(3)] +SCENES = [{"id": "s0"}, {"id": "s1"}] +EP = {"id": "ep1", "no": 1} + + +def test_canonical_entries(): + out = _assign([{"beat_index": 0, "scene_index": 0}, + {"beat_index": 1, "scene_index": 0}, + {"beat_index": 2, "scene_index": 1}], BEATS, SCENES, EP) + assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] + + +def test_string_pair_entries(): + """NPC 输出 "0:0" 式字符串对。""" + out = _assign(["0:0", "1:0", "2:1"], BEATS, SCENES, EP) + assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] + + +def test_alt_key_names(): + """NPC 输出 beat/scene 键名变体。""" + out = _assign([{"beat": 0, "scene": 0}, {"beat": 1, "scene": 0}, {"beat": 2, "scene": 1}], + BEATS, SCENES, EP) + assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] + + +def test_dict_mapping(): + """NPC 输出 {"0": 0, "1": 0, "2": 1} 字典。""" + out = _assign({"0": 0, "1": 0, "2": 1}, BEATS, SCENES, EP) + assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] + + +def test_unsalvageable_raises_with_diagnostic(): + """矫正不了的项必须 PassFailure 且带可喂优化器的诊断。""" + with pytest.raises(PassFailure) as ei: + _assign([{"foo": "bar"}, {"beat_index": 1, "scene_index": 0}, + {"beat_index": 2, "scene_index": 1}], BEATS, SCENES, EP) + assert "beat_to_scene" in str(ei.value) From b2ece6fe35b77884c5b8a95fdc1b1e0b43da0f76 Mon Sep 17 00:00:00 2001 From: randypanding Date: Mon, 24 Aug 2026 11:21:16 +0800 Subject: [PATCH 06/28] =?UTF-8?q?lab=20round10b:=20=5Fto=5Fint=20=E5=AE=BD?= =?UTF-8?q?=E5=AE=B9=E8=BD=AC=E6=8D=A2(=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E4=B8=8B=E6=A0=87/=E5=B5=8C=E5=A5=97=E5=80=BC,TypeError=20?= =?UTF-8?q?=E5=AE=9E=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p4_scene.py | 23 ++++++++++++++++------- tests/test_p4_assign_coercion.py | 8 ++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/nsc/passes/p4_scene.py b/src/nsc/passes/p4_scene.py index c83947b..e8b0d4d 100644 --- a/src/nsc/passes/p4_scene.py +++ b/src/nsc/passes/p4_scene.py @@ -130,18 +130,27 @@ def _knowledge_state(raw: Any) -> dict[str, str] | None: return {k: str(v) for k, v in raw.items() if k in KnowledgeState.model_fields} or None +def _to_int(x: Any) -> int | None: + """宽容转 int:直接转换失败时提取首个数字串;都不行返回 None。""" + try: + return int(x) + except (TypeError, ValueError): + m = re.search(r"\d+", str(x)) + return int(m.group(0)) if m else None + + def _coerce_entry(m: Any) -> tuple[int, int] | None: """把结构漂移的映射项矫正为 (beat_index, scene_index);矫正不了返回 None。 - 随机后端实测漂移形态:"0:1" 字符串对 / {"beat":..,"scene":..} 键名变体 / [b,s] 二元组。""" + 随机后端实测漂移形态:"0:1" 字符串对 / {"beat":..,"scene":..} 键名变体 / [b,s] 二元组 / + 字符串值("beat_0"、"s1")。""" if isinstance(m, dict): - b = m.get("beat_index", m.get("beat", m.get("b"))) - s = m.get("scene_index", m.get("scene", m.get("s"))) - if b is not None and s is not None: - return int(b), int(s) - return None + b = _to_int(m.get("beat_index", m.get("beat", m.get("b")))) + s = _to_int(m.get("scene_index", m.get("scene", m.get("s")))) + return (b, s) if b is not None and s is not None else None if isinstance(m, (list, tuple)) and len(m) == 2: - return int(m[0]), int(m[1]) + b, s = _to_int(m[0]), _to_int(m[1]) + return (b, s) if b is not None and s is not None else None if isinstance(m, str): nums = [p for p in re.split(r"[::,\-–—/ ]+", m.strip()) if p.strip().isdigit()] if len(nums) == 2: diff --git a/tests/test_p4_assign_coercion.py b/tests/test_p4_assign_coercion.py index 7fa8b39..650adaa 100644 --- a/tests/test_p4_assign_coercion.py +++ b/tests/test_p4_assign_coercion.py @@ -41,3 +41,11 @@ def test_unsalvageable_raises_with_diagnostic(): _assign([{"foo": "bar"}, {"beat_index": 1, "scene_index": 0}, {"beat_index": 2, "scene_index": 1}], BEATS, SCENES, EP) assert "beat_to_scene" in str(ei.value) + + +def test_string_valued_indexes(): + """字符串形式的下标("beat_0"/"s1"/"0")也要矫正,不得 TypeError(round10 实证)。""" + out = _assign([{"beat_index": "beat_0", "scene_index": "s0"}, + {"beat_index": "1", "scene_index": "0"}, + {"beat_index": 2, "scene_index": "s1"}], BEATS, SCENES, EP) + assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] From 43d1c7973ca6b10503ab071cb8950f2c4391640f Mon Sep 17 00:00:00 2001 From: randypanding Date: Mon, 24 Aug 2026 14:21:52 +0800 Subject: [PATCH 07/28] =?UTF-8?q?lab=20round12:=20resolve=5Fpending=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E8=AF=AD=E4=B9=89(=E6=97=A0=20donor=20?= =?UTF-8?q?=E8=A7=A3=E9=99=A4=E5=A5=91=E7=BA=A6=E4=B8=8D=E8=87=B4=E5=91=BD?= =?UTF-8?q?;=E9=9A=8F=E6=9C=BA=E5=90=8E=E7=AB=AF=20PENDING=20=E6=82=AC?= =?UTF-8?q?=E7=A9=BA=E6=9C=80=E9=AB=98=E9=A2=91=E6=AD=BB=E6=B3=95=E5=AE=9E?= =?UTF-8?q?=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p3_beatsheet.py | 18 +++++++++----- tests/test_resolve_pending_demote.py | 37 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tests/test_resolve_pending_demote.py diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index 16ddfcd..e83e081 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -382,12 +382,18 @@ def _attach_setup_payoffs( def resolve_pending(setup_payoffs: list[dict[str, Any]]) -> list[dict[str, Any]]: - """全季后处理:解引用 PENDING:。规则:slug 相同的条目互为两端。""" + """全季后处理:解引用 PENDING:。规则:slug 相同的条目互为两端。 + + 降级语义(round12):无 donor 时删除该条目而不是 PassFailure——随机后端几乎从不 + 补 donor,悬空 PENDING 由此成为最高频死法;解除跨集伏笔契约(叙事文本保留) + 优于全管线死亡。""" by_slug: dict[str, list[dict[str, Any]]] = {} for sp in setup_payoffs: by_slug.setdefault(sp["_slug"], []).append(sp) + kept: list[dict[str, Any]] = [] for slug, group in by_slug.items(): for sp in group: + demoted = False for side in ("setup", "payoff"): ref = sp[f"{side}_beat_id"] if isinstance(ref, str) and ref.startswith("PENDING:"): @@ -401,12 +407,12 @@ def resolve_pending(setup_payoffs: list[dict[str, Any]]) -> list[dict[str, Any]] None, ) if donor is None: - raise PassFailure( - sp["_episode_id"], - f"伏笔 {sp['description']} 的 {side} 引用 PENDING:{target_slug} " - "无法解引用(没有对应条目提供真实 Beat)", - ) + demoted = True # 无 donor → 解除契约,不致命 + break sp[f"{side}_beat_id"] = donor[f"{side}_beat_id"] + if not demoted: + kept.append(sp) + return kept if slug: continue return [ diff --git a/tests/test_resolve_pending_demote.py b/tests/test_resolve_pending_demote.py new file mode 100644 index 0000000..9779bfd --- /dev/null +++ b/tests/test_resolve_pending_demote.py @@ -0,0 +1,37 @@ +"""resolve_pending 的降级语义(round12:NPC 从不补 donor,PENDING 悬空是随机后端最高频死法)。""" +import pytest + +from nsc.passes.p3_beatsheet import resolve_pending + + +def _sp(ep: str, slug: str, setup_ref, payoff_ref, desc="测试伏笔"): + return { + "_episode_id": ep, "_slug": slug, "description": desc, + "setup_beat_id": setup_ref, "payoff_beat_id": payoff_ref, + } + + +def test_donor_present_resolves(): + sps = [ + _sp("ep1", "s1", "b1", "PENDING:reveal"), + _sp("ep2", "reveal", "b2", "b3"), # donor:同 slug,真实 Beat + ] + out = resolve_pending(sps) + assert out[0]["payoff_beat_id"] == "b3" + + +def test_missing_donor_demotes_instead_of_raise(): + """无 donor 的 PENDING:删除该 setup_payoff 条目而不是 PassFailure(语义降级: + 跨集伏笔契约解除,保留叙事文本;优于全管线死亡)。""" + sps = [_sp("ep1", "s1", "b1", "PENDING:ghost")] + out = resolve_pending(sps) + assert out == [] + + +def test_missing_donor_keeps_others(): + sps = [ + _sp("ep1", "s1", "b1", "PENDING:ghost"), + _sp("ep2", "s2", "b2", "b3"), + ] + out = resolve_pending(sps) + assert len(out) == 1 and out[0]["_slug"] == "s2" From 3e419c71637491131a61b837fa1cebab05a633d8 Mon Sep 17 00:00:00 2001 From: randypanding Date: Mon, 24 Aug 2026 14:27:40 +0800 Subject: [PATCH 08/28] =?UTF-8?q?lab=20round12:=20resolve=5Fpending=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E8=AF=AD=E4=B9=89(=E6=97=A0=20donor=20?= =?UTF-8?q?=E8=A7=A3=E9=99=A4=E5=A5=91=E7=BA=A6=E4=B8=8D=E8=87=B4=E5=91=BD?= =?UTF-8?q?)+=E4=BF=AE=E6=AD=A3=E6=9C=80=E7=BB=88=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E8=BE=93=E5=85=A5;=E6=B5=8B=E8=AF=95=208=20=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p3_beatsheet.py | 5 +---- tests/test_resolve_pending_demote.py | 3 ++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index e83e081..40773d4 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -412,9 +412,6 @@ def resolve_pending(setup_payoffs: list[dict[str, Any]]) -> list[dict[str, Any]] sp[f"{side}_beat_id"] = donor[f"{side}_beat_id"] if not demoted: kept.append(sp) - return kept - if slug: - continue return [ { "id": sp["id"], @@ -423,5 +420,5 @@ def resolve_pending(setup_payoffs: list[dict[str, Any]]) -> list[dict[str, Any]] "kind": sp["kind"], "description": sp["description"], } - for sp in setup_payoffs + for sp in kept ] diff --git a/tests/test_resolve_pending_demote.py b/tests/test_resolve_pending_demote.py index 9779bfd..acd37b9 100644 --- a/tests/test_resolve_pending_demote.py +++ b/tests/test_resolve_pending_demote.py @@ -6,6 +6,7 @@ def _sp(ep: str, slug: str, setup_ref, payoff_ref, desc="测试伏笔"): return { + "id": f"sp-{ep}-{slug}", "kind": "setup_payoff", "_episode_id": ep, "_slug": slug, "description": desc, "setup_beat_id": setup_ref, "payoff_beat_id": payoff_ref, } @@ -34,4 +35,4 @@ def test_missing_donor_keeps_others(): _sp("ep2", "s2", "b2", "b3"), ] out = resolve_pending(sps) - assert len(out) == 1 and out[0]["_slug"] == "s2" + assert len(out) == 1 and out[0]["id"] == "sp-ep2-s2" # 幽灵条目被解除,健康条目保留 From 2686e09142112c707e93924d743299ab4f72f31f Mon Sep 17 00:00:00 2001 From: randypanding Date: Mon, 24 Aug 2026 14:38:13 +0800 Subject: [PATCH 09/28] =?UTF-8?q?lab=20round12b:=20p1=20Prop=20sku=5Fref?= =?UTF-8?q?=20null=20=E5=BD=92=E4=B8=80(NarrativeIR=20ValidationError=20?= =?UTF-8?q?=E5=AE=9E=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p1_bible.py | 13 ++++++++++++- tests/test_p1_prop_sanitize.py | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/test_p1_prop_sanitize.py diff --git a/src/nsc/passes/p1_bible.py b/src/nsc/passes/p1_bible.py index 9f7edaa..4e434bc 100644 --- a/src/nsc/passes/p1_bible.py +++ b/src/nsc/passes/p1_bible.py @@ -55,7 +55,7 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: locations = _assign_ids( filter_extra(inner_json(out["locations_json"], "p1_bible", "locations_json"), Location) ) - props = _assign_ids(filter_extra(inner_json(out["props_json"], "p1_bible", "props_json"), Prop)) + props = _assign_ids(_sanitize_props(filter_extra(inner_json(out["props_json"], "p1_bible", "props_json"), Prop))) motifs = _assign_ids( filter_extra(inner_json(out["motifs_json"], "p1_bible", "motifs_json"), Motif) ) @@ -75,6 +75,17 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: } +def _sanitize_props(props: Any) -> Any: + """Prop 机械归一:NPC 显式给 sku_ref=null 时归一为 ""(pydantic str 拒 None; + 随机后端 ValidationError props.N.sku_ref 实证)。""" + if not isinstance(props, list): + return props + for p in props: + if isinstance(p, dict) and p.get("sku_ref") is None: + p["sku_ref"] = "" + return props + + def _sanitize_mind(characters: list[dict[str, Any]]) -> list[dict[str, Any]]: """角色心智 OS(ADR-0012)机械归一:省略 → 默认空;嵌套 extra 键过滤、畸形条目丢弃。 diff --git a/tests/test_p1_prop_sanitize.py b/tests/test_p1_prop_sanitize.py new file mode 100644 index 0000000..76dc86c --- /dev/null +++ b/tests/test_p1_prop_sanitize.py @@ -0,0 +1,13 @@ +"""p1_bible Prop 归一(round12b:NPC 显式 sku_ref=null 致 NarrativeIR ValidationError)。""" +from nsc.passes.p1_bible import _sanitize_props + + +def test_sku_ref_none_filled(): + props = [{"name": "茶叶罐", "sku_ref": None}, {"name": "茶壶", "sku_ref": "tea-01"}] + out = _sanitize_props(props) + assert out[0]["sku_ref"] == "" and out[1]["sku_ref"] == "tea-01" + + +def test_non_list_passthrough(): + assert _sanitize_props(None) is None + assert _sanitize_props("x") == "x" From a8e857395b34de2e434224de0df9309299a0c830 Mon Sep 17 00:00:00 2001 From: randypanding Date: Mon, 24 Aug 2026 14:55:45 +0800 Subject: [PATCH 10/28] =?UTF-8?q?lab=20round12c:=20=E9=80=9A=E7=94=A8=20nu?= =?UTF-8?q?ll=E2=86=92=E5=AD=97=E6=AE=B5=E9=BB=98=E8=AE=A4=E5=80=BC?= =?UTF-8?q?=E5=BD=92=E4=B8=80(characters/locations/motifs=20=E5=90=8C?= =?UTF-8?q?=E5=9E=8B=E9=94=99=E8=AF=AF=E4=B8=80=E4=BE=8B=E5=A4=9A=E6=9D=80?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p1_bible.py | 42 ++++++++++++++++++++++++++-------- tests/test_p1_prop_sanitize.py | 11 ++++++++- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/nsc/passes/p1_bible.py b/src/nsc/passes/p1_bible.py index 4e434bc..3d2ae7a 100644 --- a/src/nsc/passes/p1_bible.py +++ b/src/nsc/passes/p1_bible.py @@ -49,15 +49,24 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: ), ) characters = _assign_ids( - filter_extra(inner_json(out["characters_json"], "p1_bible", "characters_json"), Character) + _null_str_fields_to_default( + filter_extra(inner_json(out["characters_json"], "p1_bible", "characters_json"), Character), + Character, + ) ) characters = _sanitize_mind(characters) locations = _assign_ids( - filter_extra(inner_json(out["locations_json"], "p1_bible", "locations_json"), Location) + _null_str_fields_to_default( + filter_extra(inner_json(out["locations_json"], "p1_bible", "locations_json"), Location), + Location, + ) ) props = _assign_ids(_sanitize_props(filter_extra(inner_json(out["props_json"], "p1_bible", "props_json"), Prop))) motifs = _assign_ids( - filter_extra(inner_json(out["motifs_json"], "p1_bible", "motifs_json"), Motif) + _null_str_fields_to_default( + filter_extra(inner_json(out["motifs_json"], "p1_bible", "motifs_json"), Motif), + Motif, + ) ) for m in motifs if isinstance(motifs, list) else []: m.pop("occurrence_beat_ids", None) # p1 阶段 Beat 尚不存在,引用必为伪造 @@ -75,15 +84,30 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: } +def _null_str_fields_to_default(coll: Any, model_cls: Any) -> Any: + """通用归一:NPC 显式给字段 null 时归一为字段默认值/默认工厂产出 + (pydantic str/list 拒 None;随机后端 ValidationError props.sku_ref、 + characters.persona_ref 系列实证)。""" + from pydantic_core import PydanticUndefined + + if not isinstance(coll, list): + return coll + for item in coll: + if not isinstance(item, dict): + continue + for name, f in model_cls.model_fields.items(): + if item.get(name) is None: + if f.default is not None and f.default is not PydanticUndefined: + item[name] = f.default + elif f.default_factory is not None: + item[name] = f.default_factory() + return coll + + def _sanitize_props(props: Any) -> Any: """Prop 机械归一:NPC 显式给 sku_ref=null 时归一为 ""(pydantic str 拒 None; 随机后端 ValidationError props.N.sku_ref 实证)。""" - if not isinstance(props, list): - return props - for p in props: - if isinstance(p, dict) and p.get("sku_ref") is None: - p["sku_ref"] = "" - return props + return _null_str_fields_to_default(props, Prop) def _sanitize_mind(characters: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/tests/test_p1_prop_sanitize.py b/tests/test_p1_prop_sanitize.py index 76dc86c..b38c89a 100644 --- a/tests/test_p1_prop_sanitize.py +++ b/tests/test_p1_prop_sanitize.py @@ -1,5 +1,6 @@ """p1_bible Prop 归一(round12b:NPC 显式 sku_ref=null 致 NarrativeIR ValidationError)。""" -from nsc.passes.p1_bible import _sanitize_props +from nsc.passes.p1_bible import _sanitize_props, _null_str_fields_to_default +from spec.ir.overlays import Character def test_sku_ref_none_filled(): @@ -11,3 +12,11 @@ def test_sku_ref_none_filled(): def test_non_list_passthrough(): assert _sanitize_props(None) is None assert _sanitize_props("x") == "x" + + +def test_generic_null_to_default_character(): + """persona_ref=null 归一为字段默认 "";default_factory 字段 null 归一为工厂产出。""" + chars = [{"name": "林晚", "persona_ref": None}] + out = _null_str_fields_to_default(chars, Character) + assert out[0]["persona_ref"] == "" + From 8c64922d29557f1ed18986138eec9921c894d085 Mon Sep 17 00:00:00 2001 From: randypanding Date: Mon, 24 Aug 2026 18:54:15 +0800 Subject: [PATCH 11/28] =?UTF-8?q?lab=20round13b:=20present=5Fcharacter=5Fi?= =?UTF-8?q?ds=20=E7=A9=BA=E8=A1=A8=E5=85=A8=E9=9B=86=E5=85=9C=E5=BA=95(sce?= =?UTF-8?q?nes.N=3D[]=20ValidationError=20=E5=AE=9E=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p4_scene.py | 7 +++++++ tests/test_p4_assign_coercion.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/nsc/passes/p4_scene.py b/src/nsc/passes/p4_scene.py index e8b0d4d..a77e715 100644 --- a/src/nsc/passes/p4_scene.py +++ b/src/nsc/passes/p4_scene.py @@ -74,6 +74,8 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: f"({sorted(char_ids)})或角色名。", ) present.append(cid) + if not present: # NPC 给空表:pydantic 要求 ≥1(实证 scenes.N present_character_ids=[])→ 全集兜底 + present = _fallback_present(present, char_ids) scenes.append( { "id": new_id(), @@ -139,6 +141,11 @@ def _to_int(x: Any) -> int | None: return int(m.group(0)) if m else None +def _fallback_present(present: list[str], char_ids: set[str]) -> list[str]: + """空 present_character_ids 兜底为全部已知角色(实证 scenes.N=[] 崩 NarrativeIR)。""" + return present if present else sorted(char_ids) + + def _coerce_entry(m: Any) -> tuple[int, int] | None: """把结构漂移的映射项矫正为 (beat_index, scene_index);矫正不了返回 None。 diff --git a/tests/test_p4_assign_coercion.py b/tests/test_p4_assign_coercion.py index 650adaa..04de0b5 100644 --- a/tests/test_p4_assign_coercion.py +++ b/tests/test_p4_assign_coercion.py @@ -49,3 +49,10 @@ def test_string_valued_indexes(): {"beat_index": "1", "scene_index": "0"}, {"beat_index": 2, "scene_index": "s1"}], BEATS, SCENES, EP) assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] + + +def test_empty_present_falls_back_to_all(): + """空 present_character_ids 兜底全集(scenes.N=[] ValidationError 实证)。""" + from nsc.passes.p4_scene import _fallback_present + assert _fallback_present([], {"c1", "c2"}) == ["c1", "c2"] + assert _fallback_present(["c1"], {"c1", "c2"}) == ["c1"] From f7df614bb33dbb4a237866e4437984537123e876 Mon Sep 17 00:00:00 2001 From: randypanding Date: Mon, 24 Aug 2026 22:11:35 +0800 Subject: [PATCH 12/28] =?UTF-8?q?lab=20round14:=20p3/p4=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E6=9C=BA=E6=A2=B0=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?STR-014=20=E6=89=BF=E9=87=8D=E8=8A=82=E6=8B=8D=E5=85=9C?= =?UTF-8?q?=E5=BA=95/BM-002=20=E6=A4=8D=E5=85=A5=E9=97=B4=E8=B7=9D?= =?UTF-8?q?=E9=87=8D=E6=8E=92/STR-010=20=E4=B8=BB=E8=A7=92=E8=A1=A5?= =?UTF-8?q?=E4=BD=8D(attempt4/5=20=E5=90=8C=E9=97=A8=E8=BF=9E=E6=AD=BB?= =?UTF-8?q?=E5=AE=9E=E8=AF=81,=E7=9B=B8=E4=BD=8D=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E5=8F=AA=E5=A4=8D=E8=BF=B0=E8=AF=8A=E6=96=AD=E4=B8=8D=E6=94=B9?= =?UTF-8?q?=E7=BB=93=E6=9E=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p3_beatsheet.py | 64 +++++++++++++- src/nsc/passes/p4_scene.py | 18 ++++ tests/test_p3_structural_repairs.py | 129 ++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 tests/test_p3_structural_repairs.py diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index 40773d4..aec853a 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -113,8 +113,10 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: } ) + setup_payoffs = _attach_setup_payoffs(raw_sps, beats, ep) # 先按下标解引用:下方修复换序不影响 + _repair_load_bearing(beats) + _repair_brand_gap(beats, _min_gap_beats(ctx)) brand_moments = _attach_brand_moments(beats, fragment["placement"], ep) - setup_payoffs = _attach_setup_payoffs(raw_sps, beats, ep) facts = _attach_facts( optional_json(out, "facts_json", "p3_beatsheet"), ep, fragment.get("known_facts", []) ) @@ -134,6 +136,66 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: } +#: 承重/特殊拍:机械修复永不动这些 kind(品牌拍有数量契约、hook/cliffhanger 有首尾语义)。 +_PROTECTED_KINDS = frozenset({"hook", "brand_moment", "cliffhanger", "inciting", "climax"}) + + +def _repair_load_bearing(beats: list[dict[str, Any]]) -> None: + """STR-014 机械兜底:缺 inciting/climax 时把最合适的非保护 Beat 改写之。 + + 随机后端常漏 climax(实证 attempt 4/5 同门连死两轮),相位重试只复述诊断不改结构。 + inciting 取居中且唤起最高者(fix_hint),climax 取后段唤起最高者且不落集末拍。 + """ + n = len(beats) + kinds = {b["beat_kind"] for b in beats} + if "inciting" not in kinds: + pool = [b for b in beats if b["beat_kind"] not in _PROTECTED_KINDS] + if pool: + center = (n - 1) / 2 + pick = max(pool, key=lambda b: (b["emotion"]["arousal"], -abs(b["order"] - center))) + pick["beat_kind"] = "inciting" + if "climax" not in kinds: + pool = [ + b + for b in beats + if b["beat_kind"] not in _PROTECTED_KINDS and b["order"] < n - 1 + ] + if pool: + pick = max(pool, key=lambda b: (b["emotion"]["arousal"], b["order"])) + pick["beat_kind"] = "climax" + + +def _min_gap_beats(ctx: PassContext) -> int: + try: + return int(ctx.brand.get("placement", {}).get("min_gap_beats", 0) or 0) + except (TypeError, ValueError): + return 0 + + +def _repair_brand_gap(beats: list[dict[str, Any]], min_gap: int) -> None: + """BM-002 机械兜底:brand_moment 间距不足时,把后一个植入拍向后移到首个 + 非植入空位(间距达标处)。只换序不改内容;步数有限(防两种排列间振荡死循环, + 无处可挪时保持现状交给检查器报真问题);结束后 order 重排。 + """ + if min_gap <= 1: + return + for _ in range(len(beats) * 2): + idx = [i for i, b in enumerate(beats) if b["beat_kind"] == "brand_moment"] + bad = next(((a, b_) for a, b_ in zip(idx, idx[1:]) if b_ - a < min_gap), None) + if bad is None: + break + a, b_ = bad + target = next( + (t for t in range(a + min_gap, len(beats)) if beats[t]["beat_kind"] != "brand_moment"), + None, + ) + if target is None: # 集长不足/植入过密:修不了,保持原样 + break + beats.insert(target, beats.pop(b_)) + for i, bt in enumerate(beats): + bt["order"] = i + + def _attach_facts( raw: Any, ep: dict[str, Any], known_facts: list[dict[str, Any]] ) -> list[dict[str, Any]]: diff --git a/src/nsc/passes/p4_scene.py b/src/nsc/passes/p4_scene.py index a77e715..d4a795e 100644 --- a/src/nsc/passes/p4_scene.py +++ b/src/nsc/passes/p4_scene.py @@ -102,10 +102,28 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: } ) + _repair_protagonist_present(scenes, bible_chars) assigned = _assign(mapping, beats, scenes, ep) return {"episode_id": ep["id"], "scenes": scenes, "beats": assigned, "_usage": out["_usage"]} +def _repair_protagonist_present( + scenes: list[dict[str, Any]], bible_chars: list[dict[str, Any]] +) -> None: + """STR-010 机械兜底:主角整集缺席时补进在场人数最多的场景(人最多处冲突最密, + 主角最该在)。随机后端会在支线集漏主角(实证第 6 集 STR-010 拦截),相位重试 + 只复述诊断不改结构,机械补位优于烧轮次。""" + pro_ids = [str(c.get("id")) for c in bible_chars if c.get("role") == "protagonist"] + if not pro_ids or not scenes: + return + present = {cid for sc in scenes for cid in sc["present_character_ids"]} + missing = [pid for pid in pro_ids if pid not in present] + if not missing: + return + host = max(scenes, key=lambda sc: len(sc["present_character_ids"])) + host["present_character_ids"].extend(missing) + + def _public_beats(beats: list[dict[str, Any]]) -> list[dict[str, Any]]: return [ { diff --git a/tests/test_p3_structural_repairs.py b/tests/test_p3_structural_repairs.py new file mode 100644 index 0000000..bfbc327 --- /dev/null +++ b/tests/test_p3_structural_repairs.py @@ -0,0 +1,129 @@ +"""p3/p4 结构机械修复(round14):随机后端反复犯同一批结构缺陷——缺 inciting/climax +承重节拍(STR-014)、植入扎堆(BM-002)、支线集主角缺席(STR-010)。相位重试只会 +轮轮复述同一诊断而结构不变(实证 attempt 4/5 各烧 ~1.5h 死于同一批门禁), +机械修复把"指望模型遵守"换成"结构必然成立",优于烧轮次。""" +from nsc.passes.p3_beatsheet import _repair_brand_gap, _repair_load_bearing +from nsc.passes.p4_scene import _repair_protagonist_present + + +def _beat(i, kind, arousal=0.5): + return { + "id": f"b{i}", "order": i, "beat_kind": kind, + "emotion": {"valence": 0.0, "arousal": arousal}, "summary": f"节拍{i}", + } + + +def _kinds(beats): + return [b["beat_kind"] for b in beats] + + +# ---------- _repair_load_bearing(STR-014) ---------- + +def test_missing_climax_converts_highest_arousal_late_beat(): + beats = [ + _beat(0, "hook"), _beat(1, "inciting"), _beat(2, "escalation", 0.6), + _beat(3, "brand_moment"), _beat(4, "escalation", 0.9), _beat(5, "cliffhanger"), + ] + _repair_load_bearing(beats) + assert beats[4]["beat_kind"] == "climax" # 唤起最高的后段非保护拍 + assert "inciting" in _kinds(beats) and beats[1]["beat_kind"] == "inciting" + + +def test_missing_inciting_converts_central_beat(): + beats = [ + _beat(0, "hook"), _beat(1, "setup", 0.3), _beat(2, "escalation", 0.8), + _beat(3, "reversal", 0.4), _beat(4, "climax"), _beat(5, "cliffhanger"), + ] + _repair_load_bearing(beats) + assert beats[2]["beat_kind"] == "inciting" # 居中且唤起最高 + assert _kinds(beats).count("climax") == 1 + + +def test_both_present_is_noop(): + beats = [_beat(0, "hook"), _beat(1, "inciting"), _beat(2, "climax"), _beat(3, "cliffhanger")] + before = _kinds(beats) + _repair_load_bearing(beats) + assert _kinds(beats) == before + + +def test_never_touches_protected_kinds(): + """全保护拍的退化集:无可改写对象时不强行制造,交给检查器报真问题。""" + beats = [_beat(0, "hook"), _beat(1, "brand_moment"), _beat(2, "brand_moment"), _beat(3, "cliffhanger")] + _repair_load_bearing(beats) + assert _kinds(beats) == ["hook", "brand_moment", "brand_moment", "cliffhanger"] + + +def test_climax_not_on_last_beat(): + """fix_hint:climax 紧邻集末终态之前——集末拍不许被改写为 climax。""" + beats = [_beat(0, "hook"), _beat(1, "inciting"), _beat(2, "escalation", 0.7), _beat(3, "escalation", 0.99)] + _repair_load_bearing(beats) + assert beats[2]["beat_kind"] == "climax" + assert beats[3]["beat_kind"] == "escalation" + + +# ---------- _repair_brand_gap(BM-002,min_gap=2) ---------- + +def test_adjacent_brand_beats_get_spaced(): + beats = [_beat(0, "brand_moment"), _beat(1, "brand_moment")] + [_beat(i, "escalation") for i in range(2, 6)] + _repair_brand_gap(beats, 2) + bm_idx = [i for i, b in enumerate(beats) if b["beat_kind"] == "brand_moment"] + assert bm_idx[1] - bm_idx[0] >= 2 + assert [b["order"] for b in beats] == list(range(6)) # order 重排 + assert sorted(b["id"] for b in beats) == [f"b{i}" for i in range(6)] # 一拍不丢 + + +def test_gap_already_ok_is_noop(): + beats = [_beat(0, "brand_moment"), _beat(1, "escalation"), _beat(2, "brand_moment")] + before = [b["id"] for b in beats] + _repair_brand_gap(beats, 2) + assert [b["id"] for b in beats] == before + + +def test_unfixable_gap_terminates_without_oscillation(): + """植入拍多过非植入拍时无处可挪:有限步内退出(历史上朴素 while 会在两种 + 排列间振荡死循环),保持现状交给检查器。""" + beats = [_beat(0, "brand_moment"), _beat(1, "brand_moment"), _beat(2, "brand_moment")] + _repair_brand_gap(beats, 2) # 不死循环即通过 + assert len(beats) == 3 + + +def test_gap_repair_moves_later_brand_beat_not_earlier(): + beats = [ + _beat(0, "hook"), _beat(1, "brand_moment"), _beat(2, "escalation"), + _beat(3, "brand_moment"), _beat(4, "escalation"), _beat(5, "cliffhanger"), + ] + _repair_brand_gap(beats, 3) + bm_idx = [i for i, b in enumerate(beats) if b["beat_kind"] == "brand_moment"] + assert bm_idx[1] - bm_idx[0] >= 3 + + +# ---------- _repair_protagonist_present(STR-010) ---------- + +def _chars(): + return [ + {"id": "c-pro", "role": "protagonist"}, + {"id": "c-sup1", "role": "supporting"}, + {"id": "c-sup2", "role": "supporting"}, + ] + + +def _scene(chars): + return {"id": "sc", "present_character_ids": list(chars)} + + +def test_protagonist_missing_added_to_biggest_scene(): + scenes = [_scene(["c-sup1"]), _scene(["c-sup1", "c-sup2"])] + _repair_protagonist_present(scenes, _chars()) + assert "c-pro" in scenes[1]["present_character_ids"] + assert "c-pro" not in scenes[0]["present_character_ids"] + + +def test_protagonist_present_is_noop(): + scenes = [_scene(["c-pro"]), _scene(["c-sup1"])] + _repair_protagonist_present(scenes, _chars()) + assert scenes[1]["present_character_ids"] == ["c-sup1"] + + +def test_empty_scenes_no_crash(): + _repair_protagonist_present([], _chars()) + _repair_protagonist_present([_scene(["c-sup1"])], []) # 无主角定义也不崩 From cd8c97674ad5616710ddb12d6b9e2dd5c712b4c3 Mon Sep 17 00:00:00 2001 From: randypanding Date: Tue, 25 Aug 2026 03:41:52 +0800 Subject: [PATCH 13/28] =?UTF-8?q?lab=20round15:=20est=5Fduration=5Fs=20?= =?UTF-8?q?=E7=AD=89=E6=AF=94=E7=BC=A9=E6=94=BE=E5=88=B0=E9=9B=86=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=97=B6=E9=95=BF(DLG-006=20=E5=85=AD=E9=9B=86?= =?UTF-8?q?=E5=85=A8=E7=81=AD=E6=A0=B9=E5=9B=A0)+=5Fretry=5Fpass=20?= =?UTF-8?q?=E4=BC=A0=E8=BE=93=E5=AE=B9=E9=94=99(APIConnectionError=20?= =?UTF-8?q?=E6=9D=80=E6=AD=BB=E6=95=B4=E8=BD=AE=E5=AE=9E=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p3_beatsheet.py | 21 ++++++++ src/nsc/passes/pipeline.py | 29 +++++++++++ tests/test_p3_duration_rescale.py | 84 +++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 tests/test_p3_duration_rescale.py diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index aec853a..a89a1fd 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -116,6 +116,7 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: setup_payoffs = _attach_setup_payoffs(raw_sps, beats, ep) # 先按下标解引用:下方修复换序不影响 _repair_load_bearing(beats) _repair_brand_gap(beats, _min_gap_beats(ctx)) + _rescale_durations(beats, ep) brand_moments = _attach_brand_moments(beats, fragment["placement"], ep) facts = _attach_facts( optional_json(out, "facts_json", "p3_beatsheet"), ep, fragment.get("known_facts", []) @@ -196,6 +197,26 @@ def _repair_brand_gap(beats: list[dict[str, Any]], min_gap: int) -> None: bt["order"] = i +def _rescale_durations(beats: list[dict[str, Any]], ep: dict[str, Any]) -> None: + """DLG-006 根因机械归一:NPC 系统性低估 est_duration_s(全集合计 ~70s vs 目标 90s, + 实证 attempt3 六集对白全灭),p5 按它换算对白地板必然欠量。把各拍时长等比缩放到 + 集目标时长(duration_target_s),让下游体量地板算真账;全 0 时均分;无目标不动。""" + try: + target = float(ep.get("duration_target_s") or 0) + except (TypeError, ValueError): + return + if target <= 0 or not beats: + return + total = sum(float(b.get("est_duration_s") or 0) for b in beats) + if total <= 0: + for b in beats: + b["est_duration_s"] = round(target / len(beats), 2) + return + scale = target / total + for b in beats: + b["est_duration_s"] = round(float(b.get("est_duration_s") or 0) * scale, 2) + + def _attach_facts( raw: Any, ep: dict[str, Any], known_facts: list[dict[str, Any]] ) -> list[dict[str, Any]]: diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index a8b5389..e026337 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -104,6 +104,28 @@ def _phase_attempts(ctx: PassContext) -> int: return _attempts_of(ctx, "phase_attempts", 3) +_TRANSIENT_MARKERS = ( + "APIConnectionError", + "APITimeoutError", + "ConnectError", + "ReadTimeout", + "RemoteProtocolError", + "ServiceUnavailableError", +) + + +def _is_transient(exc: Exception) -> bool: + """传输层故障判定(shim 重启/CNB 抖动/网关超时):类名匹配或内建网络异常。 + + 实证 attempt2:shim 重启期间 APIConnectionError 逃过所有重试通道直接杀死整轮—— + 传输故障必须与 PassFailure 走同一条带诊断重试通道,而不是让 2.5h 的跑批陪葬。 + """ + name = type(exc).__name__ + return any(m in name for m in _TRANSIENT_MARKERS) or isinstance( + exc, (ConnectionError, TimeoutError, OSError) + ) + + def _retry_pass( fn: Any, ctx: PassContext, fragment: dict[str, Any], *, attempts: int | None = None ) -> Any: @@ -112,6 +134,7 @@ def _retry_pass( LLM 输出有随机性(漏字段/数错个数),带诊断的重试能显著降低端到端失败率; 失败语义不变(全部失败照样抛 PassFailure,GEPA 反馈信号不受影响),缓存只存成功产物。 attempts:SW-07 缺省读 profile.pipeline.pass_attempts(原常量 2)。 + 传输故障(_is_transient)同样重试;耗尽后落成 PassFailure 让相位重试接管。 """ total = attempts if attempts is not None else _pass_attempts(ctx) last_reason = "" @@ -123,6 +146,12 @@ def _retry_pass( last_reason = str(e) if i == total - 1: raise + except Exception as e: # noqa: BLE001 —— 只放行传输故障,代码 bug 原样上抛 + if not _is_transient(e): + raise + last_reason = f"传输故障:{type(e).__name__} {str(e)[:120]}" + if i == total - 1: + raise PassFailure(None, last_reason) from e def _run_checks( diff --git a/tests/test_p3_duration_rescale.py b/tests/test_p3_duration_rescale.py new file mode 100644 index 0000000..bdc997d --- /dev/null +++ b/tests/test_p3_duration_rescale.py @@ -0,0 +1,84 @@ +"""round15 两个健壮性补丁(8/26 08:00 交付倒排下的止血): + +1. _rescale_durations——DLG-006 六集全灭的根因:NPC 系统性低估 est_duration_s + (合计 ~70s vs 目标 90s),p5 按它换算对白地板必然欠量。把各拍时长等比缩放到 + 集目标时长,下游体量地板才算真账。 +2. _retry_pass 传输容错:shim 重启/CNB 抖动抛 APIConnectionError 直接杀死整轮 + (实证 attempt2 殉爆),传输故障应走与 PassFailure 相同的带诊断重试通道。 +""" +from types import SimpleNamespace + +import pytest + +from nsc.passes.p3_beatsheet import _rescale_durations +from nsc.passes.pipeline import _retry_pass +from nsc.passes import PassFailure + + +def _beat(i, secs): + return {"id": f"b{i}", "order": i, "beat_kind": "escalation", + "emotion": {"valence": 0.0, "arousal": 0.5}, "summary": f"节拍{i}", + "est_duration_s": secs} + + +def test_rescale_sums_to_target_preserving_ratios(): + beats = [_beat(0, 20.0), _beat(1, 30.0), _beat(2, 20.0)] # 合计 70s + _rescale_durations(beats, {"duration_target_s": 90.0, "no": 1}) + total = sum(b["est_duration_s"] for b in beats) + assert abs(total - 90.0) < 0.05 + assert beats[1]["est_duration_s"] > beats[0]["est_duration_s"] # 比例保持 + + +def test_rescale_zero_durations_even_split(): + beats = [_beat(0, 0.0), _beat(1, 0.0), _beat(2, 0.0)] + _rescale_durations(beats, {"duration_target_s": 90.0, "no": 1}) + assert all(abs(b["est_duration_s"] - 30.0) < 0.01 for b in beats) + + +def test_rescale_no_target_is_noop(): + beats = [_beat(0, 20.0)] + _rescale_durations(beats, {"no": 1}) + assert beats[0]["est_duration_s"] == 20.0 + + +# ---------- _retry_pass 传输容错 ---------- + +class APIConnectionError(Exception): # 类名匹配即视为传输故障(与 openai 同名) + pass + + +def _ctx(): + return SimpleNamespace(profile={}) + + +def test_transient_error_retried_then_succeeds(): + calls = {"n": 0} + + def flaky(ctx, frag): + calls["n"] += 1 + if calls["n"] == 1: + raise APIConnectionError("connection reset") + return {"ok": True} + + assert _retry_pass(flaky, _ctx(), {}) == {"ok": True} + assert calls["n"] == 2 + + +def test_transient_error_exhausted_becomes_pass_failure(): + def always(ctx, frag): + raise APIConnectionError("down") + + with pytest.raises(PassFailure): + _retry_pass(always, _ctx(), {}, attempts=3) + + +def test_non_transient_error_propagates_without_retry(): + calls = {"n": 0} + + def buggy(ctx, frag): + calls["n"] += 1 + raise ValueError("代码 bug 不该重试") + + with pytest.raises(ValueError): + _retry_pass(buggy, _ctx(), {}) + assert calls["n"] == 1 From d5bc6a323a861c02be4d338d0916c0cb1aebca41 Mon Sep 17 00:00:00 2001 From: randypanding Date: Tue, 25 Aug 2026 09:41:14 +0800 Subject: [PATCH 14/28] =?UTF-8?q?lab=20round16:=20p5=20=E5=AF=B9=E7=99=BD?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=8C=BA=E9=97=B4=E4=B8=8E=20DLG-006=20?= =?UTF-8?q?=E5=AF=B9=E9=BD=90(=E6=97=A7=20lo=3D0.8x=20=E5=85=A8=E9=A1=BA?= =?UTF-8?q?=E4=BB=8E=E4=B9=9F=E6=AD=BB)+=5Fexpand=5Fif=5Fthin=20=E6=AC=A0?= =?UTF-8?q?=E9=87=8F=E5=BD=93=E5=9C=BA=E5=AE=9A=E7=82=B9=E6=89=A9=E5=86=99?= =?UTF-8?q?(=E7=B3=BB=E7=BB=9F=E6=80=A7=E6=AC=A0=E9=87=8F=2026%=20?= =?UTF-8?q?=E5=AE=9E=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p5_dialogue.py | 61 +++++++++++++++++++++++++++++++++-- tests/test_p5_expand_thin.py | 30 +++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 tests/test_p5_expand_thin.py diff --git a/src/nsc/passes/p5_dialogue.py b/src/nsc/passes/p5_dialogue.py index 38bbf1f..4515aef 100644 --- a/src/nsc/passes/p5_dialogue.py +++ b/src/nsc/passes/p5_dialogue.py @@ -80,11 +80,13 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: visuals = list( fragment.get("must_include_visuals") or ctx.brand.get("must_include_visuals", []) ) - # 本场对白字数目标:按本场 Beat 的 est_duration_s × 语速机械推算(DLG-006 的前置指导) + # 本场对白字数目标:按本场 Beat 的 est_duration_s × 语速机械推算(DLG-006 的前置指导)。 + # round16:区间与门禁对齐(旧 lo=0.8× 低于门禁下限 0.85×,全顺从也会死——实证 attempt3 + # 区间 [324,526] vs 门禁 [344,466],NPC 取区间低端必然欠量),欠量由 _expand_if_thin 兜底。 cps = float(ctx.profile.get("chars_per_second", 4.5)) scene_secs = sum(float(b.get("est_duration_s", 0.0)) for b in beats) - chars_lo = int(scene_secs * cps * 0.8) - chars_hi = int(scene_secs * cps * 1.3) + chars_lo = int(scene_secs * cps * 1.0) + chars_hi = int(scene_secs * cps * 1.15) inputs = with_diag( { "scene_json": json.dumps(_public_scene(scene), ensure_ascii=False), @@ -117,9 +119,62 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: lines = _parse_lines(ctx, scene, beats, out, fragment["characters"]) # T-31 自检子步(默认开):本场 L0 findings → revision_brief 五节 → 一次自我修订 lines, out = _self_check(ctx, inputs, scene, beats, lines, fragment["characters"], out) + # round16:DLG-006 前瞻兜底——对白欠量当场定点扩写(相位整季重生成改不了系统性欠量) + lines, out = _expand_if_thin(ctx, inputs, scene, beats, lines, fragment["characters"], out) return {"scene_id": scene["id"], "lines": lines, "_usage": out["_usage"]} +def _dialogue_chars(lines: list[dict[str, Any]]) -> int: + """DLG-006 的度量单位:只计 line_type==dialogue 的正文字数。""" + return sum(len(ln["text"]) for ln in lines if ln["line_type"] == "dialogue") + + +def _scene_dialogue_floor(ctx: PassContext, beats: list[dict[str, Any]]) -> int: + """本场对白字数下限:scene_secs × cps × (1-tol),与 DLG-006 门禁比率一致。 + + 各场都过此线 → 集级总和必过门禁(p3 已把 est_duration_s 等比缩放到集目标时长)。 + """ + cps = float(ctx.profile.get("chars_per_second", 4.5)) + tol = float(ctx.profile.get("duration_tolerance", 0.15)) + scene_secs = sum(float(b.get("est_duration_s", 0.0)) for b in beats) + return int(scene_secs * cps * (1 - tol)) + + +def _expand_if_thin( + ctx: PassContext, + inputs: dict[str, Any], + scene: dict[str, Any], + beats: list[dict[str, Any]], + lines: list[dict[str, Any]], + characters: list[dict[str, Any]], + out: dict[str, Any], +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """对白欠量的当场定点扩写(一次 LLM 调用,带当前稿与缺口;只接受严格增量的稿子)。 + + 实证:NPC 对白系统性欠量 ~26%(attempt1/3 全季 DLG-006 连灭),相位重试整季 + 重生成三轮也改不了系统性——把测得到的缺口变成当场补的扩写调用,比烧相位便宜 + 且对症。解析失败或无增量都回退原稿,残留缺口由 check_stage(after_p5) 兜底。 + """ + floor = _scene_dialogue_floor(ctx, beats) + have = _dialogue_chars(lines) + if have >= floor: + return lines, out + brief = ( + f"【体量扩写】本场对白当前 {have} 字,低于时长预算下限 {floor} 字" + f"(缺口 {floor - have} 字)。在保留既有台词、节拍归属与 beat_index 的前提下扩写:" + "给角色增加追问、反驳、解释、情绪反应等回合,把动作行承接成对话;" + f"扩写后对白总字数必须 ≥ {floor} 字。只输出完整 lines_json。" + ) + try: + out2 = cast(dict[str, Any], Module()(ctx, {**inputs, "revision_brief": brief})) + lines2 = _parse_lines(ctx, scene, beats, out2, characters) + except PassFailure: + return lines, out + if _dialogue_chars(lines2) > have: + return lines2, out2 + return lines, out + + def _parse_lines( ctx: PassContext, scene: dict[str, Any], diff --git a/tests/test_p5_expand_thin.py b/tests/test_p5_expand_thin.py new file mode 100644 index 0000000..4cec832 --- /dev/null +++ b/tests/test_p5_expand_thin.py @@ -0,0 +1,30 @@ +"""round16:p5 对白体量双补丁(实证 attempt1/3 全季 DLG-006 连灭,NPC 系统性欠量 ~26%): + +1. 目标区间与门禁对齐——旧 chars_lo=0.8× 低于 DLG-006 下限 0.85×,模型全顺从也会死; +2. _expand_if_thin——欠量当场定点扩写,只接受严格增量;此处测纯函数部分。 +""" +from types import SimpleNamespace + +from nsc.passes.p5_dialogue import _dialogue_chars, _scene_dialogue_floor + + +def _ln(t, lt="dialogue"): + return {"line_type": lt, "text": t} + + +def test_dialogue_chars_counts_only_dialogue(): + lines = [_ln("一二三四五"), _ln("动作行不算", "action"), _ln("六七")] + assert _dialogue_chars(lines) == 7 + + +def test_scene_dialogue_floor_matches_gate_ratio(): + ctx = SimpleNamespace(profile={"chars_per_second": 4.5, "duration_tolerance": 0.15}) + beats = [{"est_duration_s": 50.0}, {"est_duration_s": 40.0}] # 90s ≈ 一集 + floor = _scene_dialogue_floor(ctx, beats) + assert floor == int(90 * 4.5 * 0.85) == 344 # 与 DLG-006 下限一致 + + +def test_scene_dialogue_floor_defaults(): + ctx = SimpleNamespace(profile={}) + beats = [{"est_duration_s": 100.0}] + assert _scene_dialogue_floor(ctx, beats) == int(100 * 4.5 * 0.85) From 944c3ac900412b62607ef4f6112b3bd37bf4bf44 Mon Sep 17 00:00:00 2001 From: randypanding Date: Tue, 25 Aug 2026 12:39:27 +0800 Subject: [PATCH 15/28] =?UTF-8?q?lab=20round16b:=20=E5=AF=B9=E7=99=BD?= =?UTF-8?q?=E7=9E=84=E5=87=86=E7=BA=BF=3D=E9=97=A8=E7=A6=81=E7=BA=BF+3pp(?= =?UTF-8?q?=E6=AF=AB=E5=8E=98=E4=B9=8B=E6=AD=BB=E5=AE=9E=E8=AF=81=20341/34?= =?UTF-8?q?2/344=20vs=20344.25)+=E6=89=A9=E5=86=99=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E8=87=B3=E8=BE=BE=E6=A0=87(=E4=B8=8A=E9=99=902=E6=AC=A1,?= =?UTF-8?q?=E5=8F=AA=E7=95=99=E6=9B=B4=E5=8E=9A=E7=A8=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p5_dialogue.py | 43 ++++++++++++++++++++--------------- tests/test_p5_expand_thin.py | 6 +++-- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/nsc/passes/p5_dialogue.py b/src/nsc/passes/p5_dialogue.py index 4515aef..b9b41a4 100644 --- a/src/nsc/passes/p5_dialogue.py +++ b/src/nsc/passes/p5_dialogue.py @@ -130,14 +130,16 @@ def _dialogue_chars(lines: list[dict[str, Any]]) -> int: def _scene_dialogue_floor(ctx: PassContext, beats: list[dict[str, Any]]) -> int: - """本场对白字数下限:scene_secs × cps × (1-tol),与 DLG-006 门禁比率一致。 + """本场对白字数下限:scene_secs × cps × (1-tol+0.03)。 各场都过此线 → 集级总和必过门禁(p3 已把 est_duration_s 等比缩放到集目标时长)。 + +0.03 余量(round16b):实证 NPC 扩写后落在门禁线 ±3 字内(341/342/344 vs 344.25), + int 截断与浮点边界会吃掉最后几个字——瞄准线必须高于门禁线。 """ cps = float(ctx.profile.get("chars_per_second", 4.5)) tol = float(ctx.profile.get("duration_tolerance", 0.15)) scene_secs = sum(float(b.get("est_duration_s", 0.0)) for b in beats) - return int(scene_secs * cps * (1 - tol)) + return int(scene_secs * cps * (1 - tol + 0.03)) def _expand_if_thin( @@ -157,22 +159,27 @@ def _expand_if_thin( """ floor = _scene_dialogue_floor(ctx, beats) have = _dialogue_chars(lines) - if have >= floor: - return lines, out - brief = ( - f"【体量扩写】本场对白当前 {have} 字,低于时长预算下限 {floor} 字" - f"(缺口 {floor - have} 字)。在保留既有台词、节拍归属与 beat_index 的前提下扩写:" - "给角色增加追问、反驳、解释、情绪反应等回合,把动作行承接成对话;" - f"扩写后对白总字数必须 ≥ {floor} 字。只输出完整 lines_json。" - ) - try: - out2 = cast(dict[str, Any], Module()(ctx, {**inputs, "revision_brief": brief})) - lines2 = _parse_lines(ctx, scene, beats, out2, characters) - except PassFailure: - return lines, out - if _dialogue_chars(lines2) > have: - return lines2, out2 - return lines, out + best, best_out = lines, out + for _ in range(2): # 最多两次扩写;只保留严格更厚的稿子,达标即停 + if have >= floor: + break + brief = ( + f"【体量扩写】本场对白当前 {have} 字,低于时长预算下限 {floor} 字" + f"(缺口 {floor - have} 字)。在保留既有台词、节拍归属与 beat_index 的前提下扩写:" + "给角色增加追问、反驳、解释、情绪反应等回合,把动作行承接成对话;" + f"扩写后对白总字数必须 ≥ {floor} 字。只输出完整 lines_json。" + ) + try: + out2 = cast(dict[str, Any], Module()(ctx, {**inputs, "revision_brief": brief})) + lines2 = _parse_lines(ctx, scene, beats, out2, characters) + except PassFailure: + break + chars2 = _dialogue_chars(lines2) + if chars2 > have: + best, best_out, have = lines2, out2, chars2 + else: + break # 无增量:再试也是同一分布,省一次调用 + return best, best_out def _parse_lines( diff --git a/tests/test_p5_expand_thin.py b/tests/test_p5_expand_thin.py index 4cec832..3d35165 100644 --- a/tests/test_p5_expand_thin.py +++ b/tests/test_p5_expand_thin.py @@ -21,10 +21,12 @@ def test_scene_dialogue_floor_matches_gate_ratio(): ctx = SimpleNamespace(profile={"chars_per_second": 4.5, "duration_tolerance": 0.15}) beats = [{"est_duration_s": 50.0}, {"est_duration_s": 40.0}] # 90s ≈ 一集 floor = _scene_dialogue_floor(ctx, beats) - assert floor == int(90 * 4.5 * 0.85) == 344 # 与 DLG-006 下限一致 + # round16b:瞄准线 = 门禁线 + 3pp 余量(实证 NPC 落在 341-344 vs 门禁 344.25 毫厘之死) + assert floor == int(90 * 4.5 * 0.88) == 356 + assert floor > int(90 * 4.5 * 0.85) # 严格高于门禁下限 def test_scene_dialogue_floor_defaults(): ctx = SimpleNamespace(profile={}) beats = [{"est_duration_s": 100.0}] - assert _scene_dialogue_floor(ctx, beats) == int(100 * 4.5 * 0.85) + assert _scene_dialogue_floor(ctx, beats) == int(100 * 4.5 * 0.88) From 4f53dd1686744907f76757d95c1acaae9ff2ad5c Mon Sep 17 00:00:00 2001 From: randypanding Date: Tue, 25 Aug 2026 15:47:50 +0800 Subject: [PATCH 16/28] =?UTF-8?q?lab=20round17:=20p6=20prompt=20=E7=98=A6?= =?UTF-8?q?=E8=BA=AB(=5Fslim=5Fscenes/=5Fslim=5Fprofile/=5Fslim=5Fbible=5F?= =?UTF-8?q?for=5Fepisode=20=E6=8A=95=E5=BD=B1,=E5=AE=9E=E8=AF=81=2046631?= =?UTF-8?q?=20=E5=AD=97=E7=AC=A6=E6=92=9E=E6=8A=A4=E6=A0=8F;id=20=E5=85=A8?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20anchor=5Fmap=20=E5=A5=91=E7=BA=A6=E4=B8=8D?= =?UTF-8?q?=E5=8A=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p6_prose.py | 35 ++++++++++++++++-- src/nsc/passes/pipeline.py | 15 +++++++- tests/test_p6_slim.py | 72 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/test_p6_slim.py diff --git a/src/nsc/passes/p6_prose.py b/src/nsc/passes/p6_prose.py index 5ab5ba5..aa8cf56 100644 --- a/src/nsc/passes/p6_prose.py +++ b/src/nsc/passes/p6_prose.py @@ -25,6 +25,37 @@ class Module(DSPyPass): pass_name = "p6_prose" +# ---------------------------------------------------------------- round17 prompt 瘦身 +# 实证:p6 首达即撞 shim 20000 护栏(prompt 46631 字符,其中 P1 全字段 dump 占大头)。 +# 投影只留散文编织需要的字段;id 一律保留(anchor_map 引用 beat_id/line_ids 是硬契约)。 + +_SCENE_KEYS = ("id", "location_name", "time_of_day", "character_names", "goal", "conflict", "turn", "summary") +_BEAT_KEYS = ("id", "order", "beat_kind", "summary") +_LINE_KEYS = ("id", "line_type", "character_id", "text", "subtext", "delivery", "is_brand_line") +_PROFILE_KEYS = ("novel", "chars_per_second", "duration_tolerance", "genre", "language") + + +def _slim_scenes(scenes_with_lines: list[dict[str, Any]]) -> list[dict[str, Any]]: + """scenes_with_lines 的散文投影:剥掉 IR 管理字段(provenance/locked/knowledge_state 等)。""" + out = [] + for sc in scenes_with_lines: + slim = {k: sc[k] for k in _SCENE_KEYS if k in sc} + slim["beats"] = [ + { + **{k: b[k] for k in _BEAT_KEYS if k in b}, + "lines": [{k: ln[k] for k in _LINE_KEYS if k in ln} for ln in b.get("lines", [])], + } + for b in sc.get("beats", []) + ] + out.append(slim) + return out + + +def _slim_profile(profile: dict[str, Any]) -> dict[str, Any]: + """profile 的散文投影:只留下笔/时长相关的键(全量 profile dump 有数千字管理配置)。""" + return {k: v for k, v in profile.items() if k in _PROFILE_KEYS} + + @cached_pass("p6_prose") def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: ep = fragment["episode"] @@ -33,11 +64,11 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: ("episode_json", json.dumps(fragment["episode"], ensure_ascii=False)), ("bible_json", json.dumps(fragment["bible"], ensure_ascii=False)), ("voice_json", json.dumps(fragment["voice"], ensure_ascii=False)), - ("profile_json", json.dumps(ctx.profile, ensure_ascii=False)), + ("profile_json", json.dumps(_slim_profile(ctx.profile), ensure_ascii=False)), ] assembled = assemble( p0_system="", - p1_current=json.dumps(fragment["scenes_with_lines"], ensure_ascii=False), + p1_current=json.dumps(_slim_scenes(fragment["scenes_with_lines"]), ensure_ascii=False), p2_prev_summary="", p3_facts=[], p4_rag=[], diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index e026337..3cc6a47 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -867,11 +867,24 @@ def _p6_fragment( "episode": ep, "beats": beats, "scenes_with_lines": _scenes_with_lines(scenes, beats, raw), - "bible": bible, + "bible": _slim_bible_for_episode(bible, scenes), "voice": voice, } +def _slim_bible_for_episode( + bible: dict[str, Any], scenes: list[dict[str, Any]] +) -> dict[str, Any]: + """bible 的按集投影(round17 prompt 瘦身):只留本集出场的角色与用到的地点, + 外加 tone/motifs;props 不进(台词文本已含全部实体信息,散文编织不查资产表)。""" + char_ids = {c for sc in scenes for c in sc.get("present_character_ids", [])} + loc_ids = {sc.get("location_id") for sc in scenes} + out = {k: v for k, v in bible.items() if k in ("tone", "motifs")} + out["characters"] = [c for c in bible.get("characters", []) if c.get("id") in char_ids] + out["locations"] = [loc for loc in bible.get("locations", []) if loc.get("id") in loc_ids] + return out + + def _splice_episode( raw: dict[str, Any], ep_id: str, diff --git a/tests/test_p6_slim.py b/tests/test_p6_slim.py new file mode 100644 index 0000000..1fce0f5 --- /dev/null +++ b/tests/test_p6_slim.py @@ -0,0 +1,72 @@ +"""round17:p6 prompt 瘦身投影(实证 p6 首达 prompt 46631 字符撞 shim 护栏): + +- _slim_scenes:剥 IR 管理字段,保留 id(anchor_map 硬契约)与叙事字段; +- _slim_profile:只留下笔/时长相关键; +- _slim_bible_for_episode:角色/地点按集过滤。 +""" +import json + +from nsc.passes.p6_prose import _slim_profile, _slim_scenes +from nsc.passes.pipeline import _slim_bible_for_episode + + +def _scene(): + return { + "id": "sc1", "kind": "scene", "parent_id": "ep1", "order": 0, + "location_id": "loc1", "location_name": "茶店", "time_of_day": "afternoon", + "present_character_ids": ["c1", "c2"], "character_names": ["小满", "阿茶"], + "goal": "g", "conflict": "c", "turn": "t", "summary": "s", + "entry": "e", "exit": "x", "knowledge_state": {"k": "v"}, + "provenance_id": "run", "locked": False, + "beats": [ + { + "id": "b1", "kind": "beat", "parent_id": "sc1", "order": 0, + "beat_kind": "hook", "summary": "开场", "est_duration_s": 12.0, + "emotion": {"valence": 0.1, "arousal": 0.5}, "provenance_id": "run", + "lines": [ + {"id": "l1", "kind": "line", "parent_id": "b1", "order": 0, + "line_type": "dialogue", "character_id": "c1", "text": "台词", + "subtext": "s", "delivery": "d", "is_brand_line": False, + "provenance_id": "run", "locked": False} + ], + } + ], + } + + +def test_slim_scenes_keeps_ids_and_narrative_drops_fat(): + slim = _slim_scenes([_scene()])[0] + assert slim["id"] == "sc1" + assert "knowledge_state" not in slim and "provenance_id" not in slim and "locked" not in slim + beat = slim["beats"][0] + assert beat["id"] == "b1" and "est_duration_s" not in beat and "emotion" not in beat + line = beat["lines"][0] + assert line["id"] == "l1" and line["text"] == "台词" and "provenance_id" not in line + + +def test_slim_scenes_shrinks_size(): + sc = _scene() + assert len(json.dumps(_slim_scenes([sc]), ensure_ascii=False)) < len( + json.dumps([sc], ensure_ascii=False) + ) + + +def test_slim_profile(): + prof = {"novel": {"enabled": True}, "chars_per_second": 4.5, "pipeline": {"x": 1}, + "retrieval": {"y": 2}, "genre": "drama"} + slim = _slim_profile(prof) + assert set(slim) == {"novel", "chars_per_second", "genre"} + + +def test_slim_bible_for_episode(): + bible = { + "characters": [{"id": "c1"}, {"id": "c2"}, {"id": "c3"}], + "locations": [{"id": "loc1"}, {"id": "loc2"}], + "props": [{"id": "p1"}], + "tone": {"register": "warm"}, + "motifs": ["茶"], + } + slim = _slim_bible_for_episode(bible, [_scene()]) + assert {c["id"] for c in slim["characters"]} == {"c1", "c2"} + assert [loc["id"] for loc in slim["locations"]] == ["loc1"] + assert "props" not in slim and slim["tone"] == {"register": "warm"} From b77791452e4c3351560cc37f75b237e18d175c9e Mon Sep 17 00:00:00 2001 From: randypanding Date: Tue, 25 Aug 2026 18:43:06 +0800 Subject: [PATCH 17/28] =?UTF-8?q?lab=20round18:=20=E6=9A=97=E7=BA=BF?= =?UTF-8?q?=E6=AD=A5=E8=BF=9B=E9=92=B3=E5=88=B6(INV-19=20=E6=9C=BA?= =?UTF-8?q?=E6=A2=B0=E5=89=8D=E7=BD=AE,=E5=AE=9E=E8=AF=81=208=20=E7=AB=A0?= =?UTF-8?q?=E5=85=A8=E4=BA=A7=E7=89=A9=E6=AD=BB=E4=BA=8E=20final=20?= =?UTF-8?q?=E9=97=A8=20current=5Fstage=205/7=20=E8=B6=85=E7=95=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/pipeline.py | 30 +++++++++++++++++++ tests/test_dark_thread_clamp.py | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/test_dark_thread_clamp.py diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index 3cc6a47..6d1df7f 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -437,6 +437,7 @@ def track() -> None: st["beats"] = [b for b in st["beats"] if b["_episode_id"] != ep["id"]] + r4["beats"] st["setup_payoffs"] = p3_beatsheet.resolve_pending(st["setup_payoffs"]) st["facts"] = p3_beatsheet.apply_fact_cascade(st["facts"]) + _clamp_dark_thread_deltas(episodes, st["dark_threads"]) ir3 = cur() violations, rep = _run_checks(ctx, ir3, "after_p3", "after_p4") _fail_on_violations(violations, rep) @@ -855,6 +856,35 @@ def _scenes_with_lines( return out +def _clamp_dark_thread_deltas( + episodes: list[dict[str, Any]], dark_threads: list[dict[str, Any]] +) -> None: + """暗线步进钳制(round18,INV-19 的机械前置):按集序累加 int delta, + 累加值越界的步进逐集缩减到恰好顶到 [0, len(stages)-1] 边界。 + + 实证 round17 attempt1:全部 8 章产物死于 final 门——两条暗线累加 5/7 超出 [0,2]。 + NPC 的步进分配系统性地不知道跨集预算,相位重试改不了系统性; + 钳制幂等(相位重试恢复快照后重生成会重新钳),bool/非暗线 key 不动。 + """ + caps = { + str(d.get("key")): max(0, len(d.get("stages") or []) - 1) + for d in dark_threads + if isinstance(d, dict) + } + if not caps: + return + acc = {k: 0 for k in caps} + for ep in sorted(episodes, key=lambda e: e.get("order", 0)): + for ch in ep.get("state_changes", []): + k = str(ch.get("key")) + delta = ch.get("delta") + if k not in caps or not isinstance(delta, int) or isinstance(delta, bool): + continue + clamped = min(max(acc[k] + delta, 0), caps[k]) + ch["delta"] = clamped - acc[k] + acc[k] = clamped + + def _p6_fragment( ir: NarrativeIR, bible: dict[str, Any], voice: dict[str, Any], ep_id: str ) -> dict[str, Any]: diff --git a/tests/test_dark_thread_clamp.py b/tests/test_dark_thread_clamp.py new file mode 100644 index 0000000..ed05f30 --- /dev/null +++ b/tests/test_dark_thread_clamp.py @@ -0,0 +1,51 @@ +"""round18:暗线步进钳制(实证 round17 attempt1 全量产物死于 final 门: +current_stage 5/7 超出 [0,2]——NPC 的 int delta 跨集累加溢出 stages 上限, +相位重试改不了系统性,机械钳制保累加值恒在 [0, len(stages)-1])。""" +from nsc.passes.pipeline import _clamp_dark_thread_deltas + + +def _ep(order, deltas): + return {"order": order, "no": order + 1, + "state_changes": [{"key": k, "delta": d, "reason": "r"} for k, d in deltas]} + + +def test_overflow_clamped_to_cap(): + eps = [_ep(0, [("t1", 2)]), _ep(1, [("t1", 2)]), _ep(2, [("t1", 3)])] + dark = [{"key": "t1", "stages": ["a", "b", "c"]}] # cap = 2 + _clamp_dark_thread_deltas(eps, dark) + deltas = [ch["delta"] for ep in eps for ch in ep["state_changes"]] + assert deltas == [2, 0, 0] # 累加 2→2→2,后续步进被钳到 0 + assert sum(deltas) <= 2 + + +def test_negative_clamped_to_zero(): + eps = [_ep(0, [("t1", -3)]), _ep(1, [("t1", 1)])] + dark = [{"key": "t1", "stages": ["a", "b"]}] + _clamp_dark_thread_deltas(eps, dark) + deltas = [ch["delta"] for ep in eps for ch in ep["state_changes"]] + assert deltas == [0, 1] + + +def test_idempotent(): + eps = [_ep(0, [("t1", 5)]), _ep(1, [("t1", 5)])] + dark = [{"key": "t1", "stages": ["a", "b", "c"]}] + _clamp_dark_thread_deltas(eps, dark) + first = [ch["delta"] for ep in eps for ch in ep["state_changes"]] + _clamp_dark_thread_deltas(eps, dark) + second = [ch["delta"] for ep in eps for ch in ep["state_changes"]] + assert first == second == [2, 0] + + +def test_non_dark_and_bool_untouched(): + eps = [_ep(0, [("other", 99), ("t1", True)])] + dark = [{"key": "t1", "stages": ["a", "b"]}] + _clamp_dark_thread_deltas(eps, dark) + chs = eps[0]["state_changes"] + assert chs[0]["delta"] == 99 # 非暗线 key 不动 + assert chs[1]["delta"] is True # bool 不动 + + +def test_empty_dark_threads_noop(): + eps = [_ep(0, [("t1", 5)])] + _clamp_dark_thread_deltas(eps, []) + assert eps[0]["state_changes"][0]["delta"] == 5 From 8b5babda980e97737c0ba9f5276ef10c33482c47 Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 00:41:44 +0800 Subject: [PATCH 18/28] =?UTF-8?q?lab=20round19:=20CMP-001=20=E7=BB=9D?= =?UTF-8?q?=E5=AF=B9=E5=8C=96=E7=94=A8=E8=AF=AD=E6=9C=BA=E6=A2=B0=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2(=E9=97=A8=E7=A6=81=20fix=20=E8=A6=81=E6=B1=82?= =?UTF-8?q?=E7=9A=84=E7=A1=AE=E5=AE=9A=E6=80=A7=E6=89=A7=E8=A1=8C)+p1=20?= =?UTF-8?q?=E5=BF=85=E5=A1=AB=20str=20=E7=A9=BA=E4=B8=B2=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?(characters.4.need=20=E5=AE=9E=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p1_bible.py | 4 ++ src/nsc/passes/pipeline.py | 67 +++++++++++++++++++++++++++++++ tests/test_compliance_sanitize.py | 58 ++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/test_compliance_sanitize.py diff --git a/src/nsc/passes/p1_bible.py b/src/nsc/passes/p1_bible.py index 3d2ae7a..af9a2d1 100644 --- a/src/nsc/passes/p1_bible.py +++ b/src/nsc/passes/p1_bible.py @@ -101,6 +101,10 @@ def _null_str_fields_to_default(coll: Any, model_cls: Any) -> Any: item[name] = f.default elif f.default_factory is not None: item[name] = f.default_factory() + elif item.get(name) == "" and f.is_required() and f.annotation is str: + # NPC 给空串(实证 round18 attempt1 characters.4.need string_too_short): + # 必填 str 空串必炸校验,占位与 null 归一同哲学——残缺输入宁占位不崩管线 + item[name] = "(未填)" return coll diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index 6d1df7f..0160066 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -508,6 +508,9 @@ def track() -> None: if attempt == phase6_n - 1: raise + n_fixed = _sanitize_absolute_terms(st, _absolute_terms()) + if n_fixed: + _dbg(f"absolute terms sanitized: {n_fixed} 处") ir = cur() p7_render.run(ctx, ir.model_dump()) track() @@ -856,6 +859,70 @@ def _scenes_with_lines( return out +#: CMP-001 绝对化用语的合规替换表(门禁 fix 要求:"必须替换为可证实的相对表述"—— +#: 机械执行这个要求本身,比相位重试碰运气便宜且确定性收敛;词表真相在 spec/checks/ +#: compliance/_absolute_terms.yaml,此处只覆盖有安全对应词的条目)。 +_ABS_TERM_FIX = { + "国家级": "行业级", + "最高级": "高水准", + "最佳": "上佳", + "第一品牌": "头部品牌", + "唯一": "少有", + "绝无": "难有", + "100%有效": "有效", + "永久": "长久", + "彻底解决": "有效缓解", +} + +#: 只在这些键的字符串值上做合规替换(id/枚举/引用键不动)。 +_TEXT_KEYS = frozenset({ + "text", "subtext", "delivery", "summary", "function", "goal", "conflict", "turn", + "entry", "exit", "opening_attractor", "ending_hook", "title", "logline", + "hook_promise", "cliffhanger", "integration_note", "description", "reason", + "content", "paragraphs", +}) + + +def _absolute_terms() -> list[str]: + """绝对化用语词表(真相 spec/checks/compliance/_absolute_terms.yaml;读不到退替换表键)。""" + try: + data = yaml.safe_load(Path("spec/checks/compliance/_absolute_terms.yaml").read_text("utf-8")) + terms = [str(t) for t in (data or {}).get("terms", [])] + except OSError: + terms = [] + return terms or list(_ABS_TERM_FIX) + + +def _sanitize_absolute_terms(st: dict[str, Any], terms: list[str]) -> int: + """CMP-001 机械前置(round19):交付文本里的绝对化用语就地替换为相对表述。 + + 实证 round18 attempt2 全量产物死于 final 门(CMP-001「唯一」)——NPC 对禁用词表 + 的遵守是彩票,相位重试三轮仍复发;合规约束与结构约束同类:机械兜底,门禁复核。 + 返回替换处数(0=无需替换)。 + """ + n = 0 + + def walk(obj: Any, in_text_key: bool) -> Any: + nonlocal n + if isinstance(obj, dict): + for k, v in obj.items(): + obj[k] = walk(v, k in _TEXT_KEYS) + return obj + if isinstance(obj, list): + return [walk(x, in_text_key) for x in obj] + if isinstance(obj, str) and in_text_key: + for t in terms: + repl = _ABS_TERM_FIX.get(t) + if repl and t in obj: + n += obj.count(t) + obj = obj.replace(t, repl) + return obj + + for key in ("lines", "beats", "scenes", "chapters", "episodes"): + walk(st.get(key, []), False) + return n + + def _clamp_dark_thread_deltas( episodes: list[dict[str, Any]], dark_threads: list[dict[str, Any]] ) -> None: diff --git a/tests/test_compliance_sanitize.py b/tests/test_compliance_sanitize.py new file mode 100644 index 0000000..8084c3d --- /dev/null +++ b/tests/test_compliance_sanitize.py @@ -0,0 +1,58 @@ +"""round19:CMP-001 绝对化用语机械替换 + p1 必填 str 空串占位(实证 round18 +attempt2 全量产物死于 final 门「唯一」;round18 attempt1 characters.4.need 空串)。""" +from nsc.passes.p1_bible import _null_str_fields_to_default +from nsc.passes.pipeline import _absolute_terms, _sanitize_absolute_terms +from spec.ir.overlays import Character + + +def _st(): + return { + "lines": [{"id": "l1", "text": "这是唯一不加糖的茶", "character_id": "唯一不动id键"}], + "beats": [{"id": "b1", "summary": "唯一的转折", "beat_kind": "climax"}], + "scenes": [{"id": "s1", "goal": "绝无仅有的目标"}], + "chapters": [{"id": "c1", "title": "唯一的一章", "paragraphs": ["唯一真爱", "普通段落"]}], + "episodes": [{"id": "e1", "title": "最佳下午"}], + } + + +def test_sanitize_replaces_all_text_fields(): + n = _sanitize_absolute_terms(_st(), ["唯一", "绝无", "最佳"]) + st = _st() + n = _sanitize_absolute_terms(st, ["唯一", "绝无", "最佳"]) + assert n == 6 # lines 1 + beats 1 + scenes 1 + chapters(title 1+paragraph 1) + episodes 1 + assert "少有" in st["lines"][0]["text"] + assert "少有" in st["beats"][0]["summary"] + assert "难有" in st["scenes"][0]["goal"] + assert st["chapters"][0]["title"] == "少有的一章" + assert st["chapters"][0]["paragraphs"][0] == "少有真爱" + assert st["episodes"][0]["title"] == "上佳下午" + + +def test_sanitize_never_touches_id_like_keys(): + st = _st() + _sanitize_absolute_terms(st, ["唯一"]) + assert st["lines"][0]["character_id"] == "唯一不动id键" + assert st["lines"][0]["id"] == "l1" + + +def test_sanitize_no_match_returns_zero(): + assert _sanitize_absolute_terms({"lines": [{"text": "普通文本"}]}, ["唯一"]) == 0 + + +def test_absolute_terms_loads_yaml(): + terms = _absolute_terms() + assert "唯一" in terms # spec/checks/compliance/_absolute_terms.yaml 词表 + + +def test_p1_empty_string_required_str_placeholder(): + chars = [{"name": "小满", "need": "", "role": "protagonist"}] + out = _null_str_fields_to_default(chars, Character) + assert out[0]["need"] == "(未填)" # 必填 str 空串 → 占位(string_too_short 实证) + assert out[0]["name"] == "小满" # 非空不动 + + +def test_p1_null_still_default(): + chars = [{"name": "小满", "need": None, "role": "protagonist"}] + out = _null_str_fields_to_default(chars, Character) + # Optional 字段(need 默认 None)的 null 原样保留——pydantic 接受 None,无需归一 + assert out[0]["need"] is None From 5023e392584d2984f2e7799379270eef340f1841 Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 00:59:26 +0800 Subject: [PATCH 19/28] =?UTF-8?q?lab=20round20:=20=E5=8D=97=E6=B5=AA?= =?UTF-8?q?=E4=BB=94(=E6=B5=B7=E5=8D=97=E6=96=87=E6=97=85=20IP)brief+brand?= =?UTF-8?q?=20=E8=B5=84=E4=BA=A7(=E7=94=A8=E6=88=B7=E5=AE=9A=E7=A8=BF?= =?UTF-8?q?=E6=95=85=E4=BA=8B=E4=BA=94=E5=B9=95:=E9=9B=A8=E6=9E=97?= =?UTF-8?q?=E2=86=92=E6=B5=AA=E5=B0=96=E2=86=92=E6=97=85=E7=A8=8B=E2=86=92?= =?UTF-8?q?=E6=9A=97=E7=BA=BF=E2=86=92=E9=99=AA=E4=BC=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brands/hainan_nolan/brand.yaml | 84 ++++++++++++++++++++++++++++++++ examples/hainan_nolan/brief.yaml | 23 +++++++++ 2 files changed, 107 insertions(+) create mode 100644 brands/hainan_nolan/brand.yaml create mode 100644 examples/hainan_nolan/brief.yaml diff --git a/brands/hainan_nolan/brand.yaml b/brands/hainan_nolan/brand.yaml new file mode 100644 index 0000000..d5fc450 --- /dev/null +++ b/brands/hainan_nolan/brand.yaml @@ -0,0 +1,84 @@ +schema_version: "1.0" +brand_id: hainan_nolan +brand_name: 南浪仔 NOLAN(海南文旅 IP) +version: "1.0.0" +industry: tourism + +products: + - id: nolan_ip + name: 南浪仔 + canonical_name: 南浪仔 NOLAN + aliases: [NOLAN, 南浪仔] + category: 文旅 IP 形象 + facts: + prototype: "原型是海南长臂猿,全世界最稀有的灵长类之一,只生活在海南岛霸王岭的热带雨林" + look: "一身暖阳黄,大眼睛,一双天生的长手臂" + slogan: "一身暖阳黄,自在南浪仔" + +selling_points: + - id: free_spirit + claim: 自在松弛的探索方式:不打卡、不赶路,慵懒但认真 + priority: 1 + must_cover: true + proof: "他玩累了半眯眼晒太阳的样子就是答案" + - id: hainan_diversity + claim: 海南的好玩不止一种:浪尖、雨林、椰林、热气球、日落 + priority: 2 + must_cover: true + proof: "他的足迹遍布全岛" + - id: gibbon_care + claim: 海南长臂猿值得被更多人看见和记住 + priority: 3 + must_cover: false + proof: "每多一个人认识南浪仔,雨林里的家族就多一分被看见的机会" + forbidden_phrasings: ["募捐", "救助", "卖惨"] + +audience: + - id: family_kid + label: 亲子家庭与喜欢治愈系内容的年轻旅行者 + age_range: "5-35" + pains: [旅行变成赶场打卡, 孩子对自然无感, 想带走一份有温度的纪念] + triggers: [一只可爱的长臂猿, 一个温柔的故事, 收集与陪伴] + language_notes: 简单、温暖、不说教 + +usage_scenes: + - id: bawangling_canopy + description: 霸王岭热带雨林树冠层 + shootable: true + - id: bay_surf + description: 海湾浪尖与沙滩 + shootable: true + - id: hot_air_balloon + description: 热气球上俯瞰海岸 + shootable: true + - id: coconut_camp + description: 椰林露营篝火 + shootable: true + - id: city_sunset + description: 城市高处看日落 + shootable: true + +tone_words: [自由, 治愈, 松弛, 温柔, 一点点孤独底色] +banned_words: [打卡, 网红, 必去, 灭绝, 募捐, 卖惨] +must_include_lines: ["一身暖阳黄,自在南浪仔"] +must_include_visuals: [暖阳黄的长臂猿身影] + +placement: + max_moments_per_episode: 2 + min_gap_beats: 2 + max_high_intensity_per_episode: 1 + require_high_plot_connection: 1 + forbid_in_beat_kinds: [hook] + +legal: + banned_words: [] + competitor_names: [] + claim_whitelist: ["全世界最稀有的灵长类之一", "只生活在霸王岭"] + ip_assignment: 交付后著作权归甲方所有 + legal_refs: [] + +account_context: > + 海南文旅 IP「南浪仔 NOLAN」的内容阵地,以盲盒、壁纸、短视频传播 IP 形象。 + 希望用连续短剧与小说让大众认识这只从雨林走向大海的长臂猿, + 把"自在"二字变成海南旅游的情感记忆点。 +business_goal: awareness diff --git a/examples/hainan_nolan/brief.yaml b/examples/hainan_nolan/brief.yaml new file mode 100644 index 0000000..c2c3ad2 --- /dev/null +++ b/examples/hainan_nolan/brief.yaml @@ -0,0 +1,23 @@ +project_title: "南浪仔" +profile: short_drama_v1 +brand: hainan_nolan +raw_request: | + 给海南文旅 IP「南浪仔 NOLAN」做一部 6 集连续短剧(同时出小说版)。 + 他的原型是海南长臂猿——全世界最稀有的灵长类之一,只住在霸王岭热带雨林。 + 故事五幕: + 1. 起点:雨林里最小的探险家。他出生在霸王岭树冠层,是家族里最坐不住的那只; + 别的长臂猿一辈子在枝头荡,他总在听一种森林里没有的声音——浪声。 + 2. 出发:有一天他顺着最长的藤蔓一路荡到森林尽头,第一次看见海; + 一只从没接触过海的长臂猿,靠天生的长手臂站上了浪尖。雨林给了他平衡,大海给了他自由。 + 3. 旅程:热气球上举望远镜找下一个海湾、椰林露营烤鱼、瘫在泳池浮排上戴墨镜发呆、 + 傍晚坐在城市高处看日落——不是打卡式旅游,是用慵懒的方式认真探索。 + 4. 暗线(温柔处理):他是世界上最孤独的物种之一,但他不是来卖惨的,他是来交朋友的; + 每多一个人认识他,雨林里那个家族就多一分被看见的机会。 + 5. 落点:陪伴。他成为每个来海南的人(尤其是孩子)带走的那个"岛上的朋友"—— + 替你记住了浪的声音,等你下次回来。 + 气质:自由 + 治愈 + 一点点孤独底色,不是热血冒险。slogan:一身暖阳黄,自在南浪仔。 +episode_count: 6 +notes: + - 暗线不许卖惨、不许说教,温柔克制 + - slogan 要自然融入,不要硬喊 + - 他是长臂猿不是人,动作要有猿的特点(长手臂、荡、攀、挂) From d23135db078b05b542e5116d8419541ed4ce230f Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 01:04:59 +0800 Subject: [PATCH 20/28] =?UTF-8?q?lab=20round20b:=20DLG-006=20=E7=9E=84?= =?UTF-8?q?=E5=87=86=E4=BD=99=E9=87=8F=20+3pp=E2=86=92+6pp+=E6=89=A9?= =?UTF-8?q?=E5=86=99=E4=B8=8A=E9=99=90=202=E2=86=923(ep8=20=E5=B7=AE=2012?= =?UTF-8?q?=20=E5=AD=97=E5=AE=9E=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p5_dialogue.py | 11 ++++++----- tests/test_p5_expand_thin.py | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/nsc/passes/p5_dialogue.py b/src/nsc/passes/p5_dialogue.py index b9b41a4..9d577c2 100644 --- a/src/nsc/passes/p5_dialogue.py +++ b/src/nsc/passes/p5_dialogue.py @@ -130,16 +130,17 @@ def _dialogue_chars(lines: list[dict[str, Any]]) -> int: def _scene_dialogue_floor(ctx: PassContext, beats: list[dict[str, Any]]) -> int: - """本场对白字数下限:scene_secs × cps × (1-tol+0.03)。 + """本场对白字数下限:scene_secs × cps × (1-tol+0.06)。 各场都过此线 → 集级总和必过门禁(p3 已把 est_duration_s 等比缩放到集目标时长)。 - +0.03 余量(round16b):实证 NPC 扩写后落在门禁线 ±3 字内(341/342/344 vs 344.25), - int 截断与浮点边界会吃掉最后几个字——瞄准线必须高于门禁线。 + 余量演进:+0.03(round16b,治 341/342/344 vs 344.25 的毫厘之死)→ +0.06 + (round20:实证 round18 attempt3 第 8 集 332 差 12 字,7/8 已过,余量再抬 3pp, + 仍远低于门禁上限 1.15,无过厚风险)。 """ cps = float(ctx.profile.get("chars_per_second", 4.5)) tol = float(ctx.profile.get("duration_tolerance", 0.15)) scene_secs = sum(float(b.get("est_duration_s", 0.0)) for b in beats) - return int(scene_secs * cps * (1 - tol + 0.03)) + return int(scene_secs * cps * (1 - tol + 0.06)) def _expand_if_thin( @@ -160,7 +161,7 @@ def _expand_if_thin( floor = _scene_dialogue_floor(ctx, beats) have = _dialogue_chars(lines) best, best_out = lines, out - for _ in range(2): # 最多两次扩写;只保留严格更厚的稿子,达标即停 + for _ in range(3): # 最多三次扩写(round20:2 次偶尔够不着,实证 ep8 差 12 字);只留更厚稿,达标即停 if have >= floor: break brief = ( diff --git a/tests/test_p5_expand_thin.py b/tests/test_p5_expand_thin.py index 3d35165..ce4271a 100644 --- a/tests/test_p5_expand_thin.py +++ b/tests/test_p5_expand_thin.py @@ -21,12 +21,12 @@ def test_scene_dialogue_floor_matches_gate_ratio(): ctx = SimpleNamespace(profile={"chars_per_second": 4.5, "duration_tolerance": 0.15}) beats = [{"est_duration_s": 50.0}, {"est_duration_s": 40.0}] # 90s ≈ 一集 floor = _scene_dialogue_floor(ctx, beats) - # round16b:瞄准线 = 门禁线 + 3pp 余量(实证 NPC 落在 341-344 vs 门禁 344.25 毫厘之死) - assert floor == int(90 * 4.5 * 0.88) == 356 + # round16b:瞄准线 = 门禁线 + 6pp 余量(round20:ep8 差 12 字实证,仍远低于上限 1.15) + assert floor == int(90 * 4.5 * 0.91) == 368 assert floor > int(90 * 4.5 * 0.85) # 严格高于门禁下限 def test_scene_dialogue_floor_defaults(): ctx = SimpleNamespace(profile={}) beats = [{"est_duration_s": 100.0}] - assert _scene_dialogue_floor(ctx, beats) == int(100 * 4.5 * 0.88) + assert _scene_dialogue_floor(ctx, beats) == int(100 * 4.5 * 0.91) From 31f44c567e0bfec2a45199b31531a03f8e73e43a Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 06:27:54 +0800 Subject: [PATCH 21/28] =?UTF-8?q?lab=20round20c:=20canonical=5Fname=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA'=E5=8D=97=E6=B5=AA=E4=BB=94'(BM-009=20?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=80=A7=E5=A4=B1=E8=B4=A5=E5=AE=9E=E8=AF=81?= =?UTF-8?q?:slogan=20=E6=9C=AC=E8=BA=AB=E5=B0=B1=E8=BF=9D=E8=A7=84,'?= =?UTF-8?q?=E5=8D=97=E6=B5=AA=E4=BB=94=20NOLAN'=E5=85=A8=E5=90=8D=E5=9C=A8?= =?UTF-8?q?=E5=8F=99=E4=BA=8B=E4=B8=AD=E6=B0=B8=E4=B8=8D=E8=87=AA=E7=84=B6?= =?UTF-8?q?;NOLAN=20=E5=8D=95=E7=94=A8=E9=99=8D=E7=BA=A7=E4=B8=BA=E5=81=B6?= =?UTF-8?q?=E5=8F=91=E5=8F=AF=E9=87=8D=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brands/hainan_nolan/brand.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/brands/hainan_nolan/brand.yaml b/brands/hainan_nolan/brand.yaml index d5fc450..b93ce60 100644 --- a/brands/hainan_nolan/brand.yaml +++ b/brands/hainan_nolan/brand.yaml @@ -7,8 +7,8 @@ industry: tourism products: - id: nolan_ip name: 南浪仔 - canonical_name: 南浪仔 NOLAN - aliases: [NOLAN, 南浪仔] + canonical_name: 南浪仔 + aliases: [南浪仔 NOLAN, NOLAN] category: 文旅 IP 形象 facts: prototype: "原型是海南长臂猿,全世界最稀有的灵长类之一,只生活在海南岛霸王岭的热带雨林" From 2974fcebe314ca3d8d9d6837169c298117b3ffe3 Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 09:33:26 +0800 Subject: [PATCH 22/28] =?UTF-8?q?lab=20round20d:=20ir=5Fio.save=20?= =?UTF-8?q?=E7=94=A8=20mode=3D'json'(datetime=20=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E5=8C=96,=E5=AE=9E=E8=AF=81=E5=85=A8=E7=BB=BF=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E6=AD=BB=E4=BA=8E=20ir.json=20=E5=AF=BC=E5=87=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/runtime/ir_io.py | 4 +++- tests/test_ir_save_datetime.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 tests/test_ir_save_datetime.py diff --git a/src/nsc/runtime/ir_io.py b/src/nsc/runtime/ir_io.py index f9d39d2..2d16ca8 100644 --- a/src/nsc/runtime/ir_io.py +++ b/src/nsc/runtime/ir_io.py @@ -529,7 +529,9 @@ def _flat(ir: NarrativeIR) -> list[Any]: def save(ir: NarrativeIR, path: str | Path) -> None: - Path(path).write_text(json.dumps(ir.model_dump(), ensure_ascii=False, indent=2), "utf-8") + # mode="json":provenance.created_at 是 datetime,python 模式 dump 直接炸 + # (实证 round20 南浪仔 attempt1 全绿产物死于 ir.json 导出 TypeError) + Path(path).write_text(json.dumps(ir.model_dump(mode="json"), ensure_ascii=False, indent=2), "utf-8") def load(path: str | Path) -> NarrativeIR: diff --git a/tests/test_ir_save_datetime.py b/tests/test_ir_save_datetime.py new file mode 100644 index 0000000..b6e2237 --- /dev/null +++ b/tests/test_ir_save_datetime.py @@ -0,0 +1,27 @@ +"""ir_io.save 的 datetime 序列化回归(实证 round20 南浪仔 attempt1:全部门禁通过后 +死于 ir.json 导出 TypeError: Object of type datetime is not JSON serializable)。""" +import json +from datetime import UTC, datetime + +from nsc.runtime.ir_io import save +from spec.ir.container import NarrativeIR, Provenance, Project + + +def _ir_with_provenance() -> NarrativeIR: + project = Project( + id="01M0TEST000000000000000001", title="测试", profile_id="pp", brand_id="bb" + ) + prov = Provenance( + run_id="r1", pass_name="p0_intake", spec_sha="s", profile_ver="1", + brand_ver="1", ruleset_ver="1", promptset_ver="1", model_id="m", + temperature=0.7, seed=1, input_hash="h", created_at=datetime.now(UTC), + ) + return NarrativeIR(project=project, provenance=[prov]) + + +def test_save_serializes_datetime_provenance(tmp_path): + out = tmp_path / "ir.json" + save(_ir_with_provenance(), out) + data = json.loads(out.read_text("utf-8")) + assert data["provenance"][0]["created_at"] # datetime → ISO 字符串,不再 TypeError + assert isinstance(data["provenance"][0]["created_at"], str) From d360726a7f902e594b64293e52b84a97d83b98f7 Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 09:34:13 +0800 Subject: [PATCH 23/28] =?UTF-8?q?test=20=E4=BF=AE=E6=AD=A3:Project=20?= =?UTF-8?q?=E5=BF=85=E5=A1=AB=E5=AD=97=E6=AE=B5=E8=A1=A5=E9=BD=90(provenan?= =?UTF-8?q?ce=5Fid/logline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ir_save_datetime.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_ir_save_datetime.py b/tests/test_ir_save_datetime.py index b6e2237..c05823e 100644 --- a/tests/test_ir_save_datetime.py +++ b/tests/test_ir_save_datetime.py @@ -9,7 +9,8 @@ def _ir_with_provenance() -> NarrativeIR: project = Project( - id="01M0TEST000000000000000001", title="测试", profile_id="pp", brand_id="bb" + id="01M0TEST000000000000000001", title="测试", profile_id="pp", brand_id="bb", + provenance_id="r1", logline="测试故事", ) prov = Provenance( run_id="r1", pass_name="p0_intake", spec_sha="s", profile_ver="1", From beecb645a50e6c21d0f183e6363e76d32d5416dc Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 17:15:36 +0800 Subject: [PATCH 24/28] =?UTF-8?q?docs:=20README=20=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E4=BF=AE=E6=AD=A3(=E5=AE=9E=E9=99=85=20CLI?= =?UTF-8?q?=20=E8=AF=AD=E6=B3=95)+=E7=8B=AC=E7=AB=8B=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AF=B4=E6=98=8E(=E7=AB=AF=E7=82=B9=E9=85=8D=E7=BD=AE/?= =?UTF-8?q?=E6=96=B0=E5=93=81=E7=89=8C/profile/=E6=B5=8B=E8=AF=95/?= =?UTF-8?q?=E6=88=98=E5=BD=B9=E5=88=86=E6=94=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7f770b7..106b6a0 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,40 @@ ## 快速开始 ```bash uv sync -make db-rebuild # 从 cases/export/*.jsonl 重建 SQLite +make db-rebuild # 从 cases/export/*.jsonl 重建 SQLite(案例检索层需要;--no-retrieval 可跳过) make test-fast # 不调用 LLM 的全部门禁 -nsc run --brief examples/demo_tea/brief.yaml --profile short_drama_v1 -nsc check out/demo_tea/ir.json # L0 检查 -nsc render out/demo_tea/ir.json --target novel_docx script_fountain ``` +**配置 LLM 端点(唯一外部依赖)**:`config/models.yaml` 各 tier 的 `api_base` 指向任一 +**OpenAI 兼容端点**(默认 LongCat-2.0),`export OPENAI_API_KEY=`(绝不落盘)。 + +```bash +uv run nsc run examples/demo_tea/brief.yaml --profile short_drama_v1 +# 产物在 out/<标题>/:novel.md(小说) + script.md(剧本) + ir.json(全量 IR) + manifest.json(溯源) +nsc check out/<标题>/ir.json # L0 检查 +nsc render out/<标题>/ir.json # 重新渲染交付物 +``` + +## 独立使用说明(本仓库无外部仓库依赖) + +本仓库即是完整可用的短剧/小说生成器:7 段编译管线(p0 需求归一→p6 小说→p7 渲染)、 +82 条声明式门禁(`spec/checks/`)、相位内带诊断重试、以及一组**后端无关的机械兜底** +(结构修复/时长缩放/对白欠量扩写/暗线钳制/合规词替换等,在 `src/nsc/passes/`, +只对违规形态触发,换任何 LLM 都生效)。 + +- **换后端**:只改 `config/models.yaml` 的 `api_base`(参考 `config/models.yaml.bak`)。 + 2026-08 Lab 战役期间该文件曾指向本地 shim(`127.0.0.1:8400`),独立使用时改回真实端点即可。 +- **新品牌/新故事**:复制 `brands/demo_tea/` 与 `examples/demo_tea/brief.yaml` 改内容。 + 注意:目录名 = `brand_id`;`banned_words` 不得与 `products.facts` 冲突; + `canonical_name` 必须是最自然的写法(它会被 BM-009 当成唯一合法产品名)。 + 现成第二套范例:`brands/hainan_nolan/` + `examples/hainan_nolan/brief.yaml`(海南文旅 IP)。 +- **profile 决定形态**:`profiles/short_drama_v1.yaml`(6-12 集正片)、 + `profiles/lab_smoke_v1.yaml`(迭代切片);`novel.enabled` 控制是否出小说视图。 +- **全量测试**:`uv run pytest tests --ignore=tests/test_pipeline_llm.py`(无 LLM,597 个)。 +- **分支说明**:2026-08 Lab 优化战役的 10 轮 harness 硬化(round14-20d, + 全部带测试)在分支 `sw/lab-campaign-20260825`;配套的判分/游乐场设施在私有仓 + Script_Writer_Lab(非必需,仅质量测量与优化循环用)。 + ## 三条铁律 1. **`spec/` 是唯一真相。** 任何知识若不能落进 `spec/ir | spec/checks | spec/rubrics | spec/rules | profiles | brands`,视为不存在。 2. **`prompts/`、`src/`、`out/` 是生成物。** 手改 `prompts/` = CI 失败。重写 `src/` 必须能通过同一套 `tests/`。 From 943ba516e253e029616fb30f5fa26717f333e7ef Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 17:20:13 +0800 Subject: [PATCH 25/28] =?UTF-8?q?lab=20round23:=20=E6=88=98=E5=BD=B9?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E7=89=88=20prompts=20=E5=85=A5=E5=BA=93(p3?= =?UTF-8?q?=20v3.2/p5=20round13,=E5=85=A8=E7=BB=BF=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0=E6=89=80=E9=9C=80;B1=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=89=A9=E4=BE=8B=E5=A4=96,PR=20=E5=86=85=E5=A3=B0=E6=98=8E)+A?= =?UTF-8?q?DR-0015/16/17=20=E7=8A=B6=E6=80=81=20accepted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adr/0015-pass-contract-strings-as-asset.md | 2 +- adr/0016-pipeline-strategy-profile-sections.md | 2 +- adr/0017-p3-context-profile-section.md | 2 +- prompts/p3_beatsheet.json | 8 ++++---- prompts/p5_dialogue.json | 10 ++++++++++ 5 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 prompts/p5_dialogue.json diff --git a/adr/0015-pass-contract-strings-as-asset.md b/adr/0015-pass-contract-strings-as-asset.md index cd83724..50726fe 100644 --- a/adr/0015-pass-contract-strings-as-asset.md +++ b/adr/0015-pass-contract-strings-as-asset.md @@ -1,6 +1,6 @@ # ADR-0015:Pass 契约文案作为资产(spec/passes/contracts.yaml) -- 状态:proposed +- 状态:accepted - 日期:2026-08-22 - 影响层:A5 知识(+ A6 配置) diff --git a/adr/0016-pipeline-strategy-profile-sections.md b/adr/0016-pipeline-strategy-profile-sections.md index 4d7b663..b79c736 100644 --- a/adr/0016-pipeline-strategy-profile-sections.md +++ b/adr/0016-pipeline-strategy-profile-sections.md @@ -1,6 +1,6 @@ # ADR-0016:管线策略 profile 化(pipeline/retrieval/revise 三段) -- 状态:proposed +- 状态:accepted - 日期:2026-08-22 - 影响层:A 资产层(profiles/_schema.py + 两个 profile yaml);B 层仅消费 diff --git a/adr/0017-p3-context-profile-section.md b/adr/0017-p3-context-profile-section.md index 79ba280..813c639 100644 --- a/adr/0017-p3-context-profile-section.md +++ b/adr/0017-p3-context-profile-section.md @@ -1,6 +1,6 @@ # ADR-0017:p3 fragment 组成数据化(profile.context 段) -- 状态:proposed +- 状态:accepted - 日期:2026-08-22 - 影响层:A 资产层(profiles/_schema.py + profile yaml);B 层仅消费 diff --git a/prompts/p3_beatsheet.json b/prompts/p3_beatsheet.json index 970af2a..071e218 100644 --- a/prompts/p3_beatsheet.json +++ b/prompts/p3_beatsheet.json @@ -1,10 +1,10 @@ { - "instructions": "为单集写出 Beat 序列。这是整个系统里最关键的一趟:Beat 写得可判定,后面才写得出好台词。\n\n硬约束:\n- Beat 数在 profile 的 beats_per_episode 区间内。\n- 恰好一个 beat_kind=hook,且是第一个或第二个 Beat。\n- 最后一个 Beat 必须是 cliffhanger / resolution / cta。\n- 本集分配到的每个植入必须落成一个 beat_kind=brand_moment 的 Beat,且不得与 hook 相邻或落在 hook 上。\n- 每个 Beat 必须给出 emotion(valence, arousal) 与 est_duration_s,总时长贴近 duration_target_s。\n- 必须声明至少一组 setup→payoff;跨集回收时 payoff 写 \"PENDING:\"。\n 【PENDING 纪律】payoff 写 PENDING: 时,你必须在同一输出里、于后续某一集中\n 用同一个 slug 补一条真实的 payoff 落点——引用一个没有落点的 slug 是编译错误。\n- summary 必须采用事件模板(五要素一句话):地点/人物/行动/冲突/反转,\n 形如\"茶饮店:林晚当众核对配料表,冲突是陈经理的说法相反,反转是标签背面另有代糖来源\";\n 不得是抽象概括(如\"两人产生矛盾\")。\n- 【beat_kind 枚举纪律】beat_kind 只能从封闭集合取值:\n hook / setup / escalation / complication / reversal / brand_moment / payoff / resolution / cliffhanger / cta。\n 不得自造 kind(如\"铺垫\"\"收束\"\"转折\")——那不是合法值。\n- 【冲突升级硬约束】每集必须至少有一个 beat_kind 为 escalation / complication / reversal 的 Beat,\n 位置在 hook 之后、结尾之前:局势必须明确变得更糟或更复杂一步(新阻碍出现/谎言被识破/代价加码/盟友倒戈)。\n 如果某一拍的内容是\"矛盾加深/情况恶化\",它的 beat_kind 就必须标成 escalation/complication/reversal,\n 不许标成 setup。写完自检:逐个数一遍本集的 escalation/complication/reversal,若为 0 个,\n 立即把中间一个铺垫 Beat 改写并重新标注。\n- 【集末钩子回应】若本集 cliffhanger 不是空,你必须在 responds_to 里说明它回应了哪一集的钩子;\n 新开钩子要在后续 1-3 集内安排回应节拍。\n- 叙事状态(ADR-0012,可省略,省略即空表):facts_json 里 resolves 填同集下标、\n 已知前集 fact 的 id(见 known_facts)或 null(尚未回收);state_changes_json 的\n key 只能用已声明的状态变量/暗线 key(见 declared_state)。\n- 输出必须是合法 JSON,不要使用任何 Markdown 代码栅栏或解释性文字。", + "instructions": "为单集写出 Beat 序列。这是整个系统里最关键的一趟:Beat 写得可判定,后面才写得出好台词。\n\n硬约束:\n- Beat 数在 profile 的 beats_per_episode 区间内。\n- 恰好一个 beat_kind=hook,且是第一个或第二个 Beat。\n- 最后一个 Beat 必须是 cliffhanger / resolution / cta。\n- 本集分配到的每个植入必须落成一个 beat_kind=brand_moment 的 Beat,且不得与 hook 相邻或落在 hook 上。\n- 每个 Beat 必须给出 emotion(valence, arousal) 与 est_duration_s,总时长贴近 duration_target_s。\n- 必须声明至少一组 setup→payoff;跨集回收时 payoff 写 \"PENDING:\"。\n 【PENDING 纪律】payoff 写 PENDING: 时,你必须在同一季后续集中用同一个 slug 安排真实 payoff 落点;\n 引用没有落点的 slug 是编译错误。\n- summary 必须采用事件模板(五要素一句话):地点/人物/行动/冲突/反转,\n 形如\"茶饮店:林晚当众核对配料表,冲突是陈经理的说法相反,反转是标签背面另有代糖来源\";\n 不得是抽象概括(如\"两人产生矛盾\")。\n- 【beat_kind 枚举纪律】beat_kind 只能取这 13 个值,不得自造:\n hook(开场钩子) / setup(铺垫) / inciting(引爆事件) / escalation(升级) / complication(意外阻碍) /\n reversal(反转) / crisis(至暗) / climax(高潮) / brand_moment(品牌植入) / payoff(伏笔回收) /\n resolution(收束) / cliffhanger(集末悬念) / cta(行动号召)。\n- 【承重节拍硬约束】每集必须至少有一个 inciting 或 climax:\n inciting = 把核心冲突正式推上桌面的那一拍(不是普通铺垫);\n climax = 本集情绪峰值、可被观众转述的记忆点(通常 arousal 最高)。\n 自检:本集序列里若 inciting 与 climax 都不存在,立即把中段一个 escalation/complication 改写为 inciting,\n 并把情绪最高的那一拍标注为 climax。\n- 【冲突升级硬约束】每集必须至少有一个 beat_kind 为 escalation / complication / reversal 的 Beat,\n 位置在 hook 之后、结尾之前:局势必须明确变得更糟或更复杂一步。\n 如果某一拍的内容是\"矛盾加深/情况恶化\",它的 beat_kind 就必须标成 escalation/complication/reversal,\n 不许标成 setup。写完自检:逐个数一遍,若为 0 个立即改写并重标。\n- 【集末钩子回应】若本集 cliffhanger 不是空,你必须在 responds_to 里说明它回应了哪一集的钩子;\n 新开钩子要在后续 1-3 集内安排回应节拍。\n- 【对白体量】本集对白总字数应贴近 时长秒数 × 4.5 字(由 est_duration_s 汇总得出),\n 过短会被门禁判为\"被迫注水\"。\n- 【伏笔回收期限】known_facts 里 status=unresolved 的高权重 fact 超过 3 集未回收是死线:\n 每集必须把最早的一条未回收 fact 安排进本集 facts_json 的 resolves(写出它如何被回应/兑现),\n 或在本集明确将它降级为低权重背景线。自检:逐条数 known_facts 的 unresolved,最早那条本集必须处理。\n- 叙事状态(ADR-0012,可省略,省略即空表):facts_json 里 resolves 填同集下标、\n 已知前集 fact 的 id(见 known_facts)或 null(尚未回收);state_changes_json 的\n key 只能用已声明的状态变量/暗线 key(见 declared_state)。\n- 输出必须是合法 JSON,不要使用任何 Markdown 代码栅栏或解释性文字。", "_meta": { - "generated_by": "lab-round2-optimizer", + "generated_by": "lab-round13-optimizer", "pass_name": "p3_beatsheet", - "content_hash": "c9d9516a74f335cc6439bd35360cbbfbe40a70befd2d92b99a96c9d5ac58217b", - "note": "round2: beat_kind 枚举纪律+PENDING 落点纪律+集末钩子回应+JSON 纯净(round1 失败诊断:STR-018 未消/STR-016/PENDING 悬空)", + "content_hash": "6287a1b9e4106e1a250b43faa3ace3dd9b3cd6dfae36326a28d56d6d86df3c3f", + "note": "round13: 伏笔回收死线(FCT-003×3 实证)", "created_at": "2026-08-24" } } \ No newline at end of file diff --git a/prompts/p5_dialogue.json b/prompts/p5_dialogue.json new file mode 100644 index 0000000..6d2d449 --- /dev/null +++ b/prompts/p5_dialogue.json @@ -0,0 +1,10 @@ +{ + "instructions": "为单个场景写对白与动作。\n\n硬约束:\n- 只能使用 present_character_ids 中的角色说话。\n- 每条对白不超过 max_line_chars 字。\n- 【体量地板】全场对白总字数必须达到 dialogue_length_target 指定的区间(由场景 est_duration_s × chars_per_second 机械换算)。\n 写完自检:逐条数字数并加总,低于区间下限时,必须扩写——给角色增加回合(追问、反驳、解释、情绪反应、\n 把动作描写展开成可被拍摄的连续动作),直到加总达标为止。宁可多 10%,不可少 1 字。\n- 必须体现该场的 turn(场景结束时状态必须已改变)。\n- 若本场含 brand_moment Beat:卖点信息必须由后果或反应体现,禁止角色宣读参数;\n 不得出现 BrandBrief.facts 之外的任何数字或参数。\n- 必提台词(must_include_lines)若分配到本场,必须原文出现。\n- 禁用词零出现。\n- 输出必须是合法 JSON,不要使用任何 Markdown 代码栅栏或解释性文字。", + "_meta": { + "generated_by": "lab-round13-optimizer", + "pass_name": "p5_dialogue", + "content_hash": "d69fac3529d7247c8b2713cb82f2c3926f10b21c68d817a3b0f75cc9022c502d", + "note": "round13: 对白体量地板+逐字自检(DLG-006×6 实证)", + "created_at": "2026-08-24" + } +} \ No newline at end of file From 582c3cdef9e53c096d5a6f641b01cfbbcd72777e Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 17:30:13 +0800 Subject: [PATCH 26/28] =?UTF-8?q?merge=20=E6=94=B6=E5=B0=BE:ruff=20?= =?UTF-8?q?=E5=85=A8=E7=BB=BF(pairwise/values()/=E5=8E=BB=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=20noqa)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p3_beatsheet.py | 5 +++-- src/nsc/passes/pipeline.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index 8eee363..fab2ffd 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +from itertools import pairwise from typing import Any from spec.ir.nodes import Beat @@ -213,7 +214,7 @@ def _repair_brand_gap(beats: list[dict[str, Any]], min_gap: int) -> None: return for _ in range(len(beats) * 2): idx = [i for i, b in enumerate(beats) if b["beat_kind"] == "brand_moment"] - bad = next(((a, b_) for a, b_ in zip(idx, idx[1:]) if b_ - a < min_gap), None) + bad = next(((a, b_) for a, b_ in pairwise(idx) if b_ - a < min_gap), None) if bad is None: break a, b_ = bad @@ -505,7 +506,7 @@ def resolve_pending(setup_payoffs: list[dict[str, Any]]) -> list[dict[str, Any]] for sp in setup_payoffs: by_slug.setdefault(sp["_slug"], []).append(sp) kept: list[dict[str, Any]] = [] - for slug, group in by_slug.items(): + for group in by_slug.values(): for sp in group: demoted = False for side in ("setup", "payoff"): diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index 7527854..6b146a9 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -146,7 +146,7 @@ def _retry_pass( last_reason = str(e) if i == total - 1: raise - except Exception as e: # noqa: BLE001 —— 只放行传输故障,代码 bug 原样上抛 + except Exception as e: # 只放行传输故障(_is_transient 判守),代码 bug 原样上抛 if not _is_transient(e): raise last_reason = f"传输故障:{type(e).__name__} {str(e)[:120]}" From ca7c1b6af1b8e8b69b669c5186cb3583620bc8cf Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 18:04:28 +0800 Subject: [PATCH 27/28] =?UTF-8?q?merge=20=E6=94=B6=E5=B0=BE2:ruff=20format?= =?UTF-8?q?+check=20=E5=85=A8=E4=BB=93=E5=90=88=E8=A7=84(CI=20lint=20&=20t?= =?UTF-8?q?ypecheck=20=E9=97=A8=E7=A6=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nsc/passes/p1_bible.py | 8 +++- src/nsc/passes/p3_beatsheet.py | 6 +-- src/nsc/passes/p4_scene.py | 4 +- src/nsc/passes/p6_prose.py | 11 ++++- src/nsc/passes/pipeline.py | 39 ++++++++++++----- src/nsc/runtime/ir_io.py | 4 +- tests/test_compliance_sanitize.py | 1 + tests/test_dark_thread_clamp.py | 8 +++- tests/test_ir_save_datetime.py | 26 ++++++++--- tests/test_p1_prop_sanitize.py | 4 +- tests/test_p3_duration_rescale.py | 15 +++++-- tests/test_p3_structural_repairs.py | 53 ++++++++++++++++++----- tests/test_p4_assign_coercion.py | 50 +++++++++++++++++----- tests/test_p5_expand_thin.py | 1 + tests/test_p6_slim.py | 64 +++++++++++++++++++++------- tests/test_resolve_pending_demote.py | 11 +++-- 16 files changed, 230 insertions(+), 75 deletions(-) diff --git a/src/nsc/passes/p1_bible.py b/src/nsc/passes/p1_bible.py index af9a2d1..9dea01a 100644 --- a/src/nsc/passes/p1_bible.py +++ b/src/nsc/passes/p1_bible.py @@ -50,7 +50,9 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: ) characters = _assign_ids( _null_str_fields_to_default( - filter_extra(inner_json(out["characters_json"], "p1_bible", "characters_json"), Character), + filter_extra( + inner_json(out["characters_json"], "p1_bible", "characters_json"), Character + ), Character, ) ) @@ -61,7 +63,9 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: Location, ) ) - props = _assign_ids(_sanitize_props(filter_extra(inner_json(out["props_json"], "p1_bible", "props_json"), Prop))) + props = _assign_ids( + _sanitize_props(filter_extra(inner_json(out["props_json"], "p1_bible", "props_json"), Prop)) + ) motifs = _assign_ids( _null_str_fields_to_default( filter_extra(inner_json(out["motifs_json"], "p1_bible", "motifs_json"), Motif), diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index fab2ffd..c36e56a 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -188,11 +188,7 @@ def _repair_load_bearing(beats: list[dict[str, Any]]) -> None: pick = max(pool, key=lambda b: (b["emotion"]["arousal"], -abs(b["order"] - center))) pick["beat_kind"] = "inciting" if "climax" not in kinds: - pool = [ - b - for b in beats - if b["beat_kind"] not in _PROTECTED_KINDS and b["order"] < n - 1 - ] + pool = [b for b in beats if b["beat_kind"] not in _PROTECTED_KINDS and b["order"] < n - 1] if pool: pick = max(pool, key=lambda b: (b["emotion"]["arousal"], b["order"])) pick["beat_kind"] = "climax" diff --git a/src/nsc/passes/p4_scene.py b/src/nsc/passes/p4_scene.py index d4a795e..c803bfb 100644 --- a/src/nsc/passes/p4_scene.py +++ b/src/nsc/passes/p4_scene.py @@ -74,7 +74,9 @@ def run(ctx: PassContext, fragment: dict[str, Any]) -> dict[str, Any]: f"({sorted(char_ids)})或角色名。", ) present.append(cid) - if not present: # NPC 给空表:pydantic 要求 ≥1(实证 scenes.N present_character_ids=[])→ 全集兜底 + if ( + not present + ): # NPC 给空表:pydantic 要求 ≥1(实证 scenes.N present_character_ids=[])→ 全集兜底 present = _fallback_present(present, char_ids) scenes.append( { diff --git a/src/nsc/passes/p6_prose.py b/src/nsc/passes/p6_prose.py index aa8cf56..324447e 100644 --- a/src/nsc/passes/p6_prose.py +++ b/src/nsc/passes/p6_prose.py @@ -29,7 +29,16 @@ class Module(DSPyPass): # 实证:p6 首达即撞 shim 20000 护栏(prompt 46631 字符,其中 P1 全字段 dump 占大头)。 # 投影只留散文编织需要的字段;id 一律保留(anchor_map 引用 beat_id/line_ids 是硬契约)。 -_SCENE_KEYS = ("id", "location_name", "time_of_day", "character_names", "goal", "conflict", "turn", "summary") +_SCENE_KEYS = ( + "id", + "location_name", + "time_of_day", + "character_names", + "goal", + "conflict", + "turn", + "summary", +) _BEAT_KEYS = ("id", "order", "beat_kind", "summary") _LINE_KEYS = ("id", "line_type", "character_id", "text", "subtext", "delivery", "is_brand_line") _PROFILE_KEYS = ("novel", "chars_per_second", "duration_tolerance", "genre", "language") diff --git a/src/nsc/passes/pipeline.py b/src/nsc/passes/pipeline.py index 6b146a9..23a4946 100644 --- a/src/nsc/passes/pipeline.py +++ b/src/nsc/passes/pipeline.py @@ -903,18 +903,39 @@ def _scenes_with_lines( } #: 只在这些键的字符串值上做合规替换(id/枚举/引用键不动)。 -_TEXT_KEYS = frozenset({ - "text", "subtext", "delivery", "summary", "function", "goal", "conflict", "turn", - "entry", "exit", "opening_attractor", "ending_hook", "title", "logline", - "hook_promise", "cliffhanger", "integration_note", "description", "reason", - "content", "paragraphs", -}) +_TEXT_KEYS = frozenset( + { + "text", + "subtext", + "delivery", + "summary", + "function", + "goal", + "conflict", + "turn", + "entry", + "exit", + "opening_attractor", + "ending_hook", + "title", + "logline", + "hook_promise", + "cliffhanger", + "integration_note", + "description", + "reason", + "content", + "paragraphs", + } +) def _absolute_terms() -> list[str]: """绝对化用语词表(真相 spec/checks/compliance/_absolute_terms.yaml;读不到退替换表键)。""" try: - data = yaml.safe_load(Path("spec/checks/compliance/_absolute_terms.yaml").read_text("utf-8")) + data = yaml.safe_load( + Path("spec/checks/compliance/_absolute_terms.yaml").read_text("utf-8") + ) terms = [str(t) for t in (data or {}).get("terms", [])] except OSError: terms = [] @@ -997,9 +1018,7 @@ def _p6_fragment( } -def _slim_bible_for_episode( - bible: dict[str, Any], scenes: list[dict[str, Any]] -) -> dict[str, Any]: +def _slim_bible_for_episode(bible: dict[str, Any], scenes: list[dict[str, Any]]) -> dict[str, Any]: """bible 的按集投影(round17 prompt 瘦身):只留本集出场的角色与用到的地点, 外加 tone/motifs;props 不进(台词文本已含全部实体信息,散文编织不查资产表)。""" char_ids = {c for sc in scenes for c in sc.get("present_character_ids", [])} diff --git a/src/nsc/runtime/ir_io.py b/src/nsc/runtime/ir_io.py index 2d16ca8..176c6fa 100644 --- a/src/nsc/runtime/ir_io.py +++ b/src/nsc/runtime/ir_io.py @@ -531,7 +531,9 @@ def _flat(ir: NarrativeIR) -> list[Any]: def save(ir: NarrativeIR, path: str | Path) -> None: # mode="json":provenance.created_at 是 datetime,python 模式 dump 直接炸 # (实证 round20 南浪仔 attempt1 全绿产物死于 ir.json 导出 TypeError) - Path(path).write_text(json.dumps(ir.model_dump(mode="json"), ensure_ascii=False, indent=2), "utf-8") + Path(path).write_text( + json.dumps(ir.model_dump(mode="json"), ensure_ascii=False, indent=2), "utf-8" + ) def load(path: str | Path) -> NarrativeIR: diff --git a/tests/test_compliance_sanitize.py b/tests/test_compliance_sanitize.py index 8084c3d..4c2bda1 100644 --- a/tests/test_compliance_sanitize.py +++ b/tests/test_compliance_sanitize.py @@ -1,5 +1,6 @@ """round19:CMP-001 绝对化用语机械替换 + p1 必填 str 空串占位(实证 round18 attempt2 全量产物死于 final 门「唯一」;round18 attempt1 characters.4.need 空串)。""" + from nsc.passes.p1_bible import _null_str_fields_to_default from nsc.passes.pipeline import _absolute_terms, _sanitize_absolute_terms from spec.ir.overlays import Character diff --git a/tests/test_dark_thread_clamp.py b/tests/test_dark_thread_clamp.py index ed05f30..a6790d0 100644 --- a/tests/test_dark_thread_clamp.py +++ b/tests/test_dark_thread_clamp.py @@ -1,12 +1,16 @@ """round18:暗线步进钳制(实证 round17 attempt1 全量产物死于 final 门: current_stage 5/7 超出 [0,2]——NPC 的 int delta 跨集累加溢出 stages 上限, 相位重试改不了系统性,机械钳制保累加值恒在 [0, len(stages)-1])。""" + from nsc.passes.pipeline import _clamp_dark_thread_deltas def _ep(order, deltas): - return {"order": order, "no": order + 1, - "state_changes": [{"key": k, "delta": d, "reason": "r"} for k, d in deltas]} + return { + "order": order, + "no": order + 1, + "state_changes": [{"key": k, "delta": d, "reason": "r"} for k, d in deltas], + } def test_overflow_clamped_to_cap(): diff --git a/tests/test_ir_save_datetime.py b/tests/test_ir_save_datetime.py index c05823e..e52a790 100644 --- a/tests/test_ir_save_datetime.py +++ b/tests/test_ir_save_datetime.py @@ -1,21 +1,35 @@ """ir_io.save 的 datetime 序列化回归(实证 round20 南浪仔 attempt1:全部门禁通过后 死于 ir.json 导出 TypeError: Object of type datetime is not JSON serializable)。""" + import json from datetime import UTC, datetime from nsc.runtime.ir_io import save -from spec.ir.container import NarrativeIR, Provenance, Project +from spec.ir.container import NarrativeIR, Project, Provenance def _ir_with_provenance() -> NarrativeIR: project = Project( - id="01M0TEST000000000000000001", title="测试", profile_id="pp", brand_id="bb", - provenance_id="r1", logline="测试故事", + id="01M0TEST000000000000000001", + title="测试", + profile_id="pp", + brand_id="bb", + provenance_id="r1", + logline="测试故事", ) prov = Provenance( - run_id="r1", pass_name="p0_intake", spec_sha="s", profile_ver="1", - brand_ver="1", ruleset_ver="1", promptset_ver="1", model_id="m", - temperature=0.7, seed=1, input_hash="h", created_at=datetime.now(UTC), + run_id="r1", + pass_name="p0_intake", + spec_sha="s", + profile_ver="1", + brand_ver="1", + ruleset_ver="1", + promptset_ver="1", + model_id="m", + temperature=0.7, + seed=1, + input_hash="h", + created_at=datetime.now(UTC), ) return NarrativeIR(project=project, provenance=[prov]) diff --git a/tests/test_p1_prop_sanitize.py b/tests/test_p1_prop_sanitize.py index b38c89a..add2a03 100644 --- a/tests/test_p1_prop_sanitize.py +++ b/tests/test_p1_prop_sanitize.py @@ -1,5 +1,6 @@ """p1_bible Prop 归一(round12b:NPC 显式 sku_ref=null 致 NarrativeIR ValidationError)。""" -from nsc.passes.p1_bible import _sanitize_props, _null_str_fields_to_default + +from nsc.passes.p1_bible import _null_str_fields_to_default, _sanitize_props from spec.ir.overlays import Character @@ -19,4 +20,3 @@ def test_generic_null_to_default_character(): chars = [{"name": "林晚", "persona_ref": None}] out = _null_str_fields_to_default(chars, Character) assert out[0]["persona_ref"] == "" - diff --git a/tests/test_p3_duration_rescale.py b/tests/test_p3_duration_rescale.py index bdc997d..4aeabbe 100644 --- a/tests/test_p3_duration_rescale.py +++ b/tests/test_p3_duration_rescale.py @@ -6,19 +6,25 @@ 2. _retry_pass 传输容错:shim 重启/CNB 抖动抛 APIConnectionError 直接杀死整轮 (实证 attempt2 殉爆),传输故障应走与 PassFailure 相同的带诊断重试通道。 """ + from types import SimpleNamespace import pytest +from nsc.passes import PassFailure from nsc.passes.p3_beatsheet import _rescale_durations from nsc.passes.pipeline import _retry_pass -from nsc.passes import PassFailure def _beat(i, secs): - return {"id": f"b{i}", "order": i, "beat_kind": "escalation", - "emotion": {"valence": 0.0, "arousal": 0.5}, "summary": f"节拍{i}", - "est_duration_s": secs} + return { + "id": f"b{i}", + "order": i, + "beat_kind": "escalation", + "emotion": {"valence": 0.0, "arousal": 0.5}, + "summary": f"节拍{i}", + "est_duration_s": secs, + } def test_rescale_sums_to_target_preserving_ratios(): @@ -43,6 +49,7 @@ def test_rescale_no_target_is_noop(): # ---------- _retry_pass 传输容错 ---------- + class APIConnectionError(Exception): # 类名匹配即视为传输故障(与 openai 同名) pass diff --git a/tests/test_p3_structural_repairs.py b/tests/test_p3_structural_repairs.py index bfbc327..3e4bb99 100644 --- a/tests/test_p3_structural_repairs.py +++ b/tests/test_p3_structural_repairs.py @@ -2,14 +2,18 @@ 承重节拍(STR-014)、植入扎堆(BM-002)、支线集主角缺席(STR-010)。相位重试只会 轮轮复述同一诊断而结构不变(实证 attempt 4/5 各烧 ~1.5h 死于同一批门禁), 机械修复把"指望模型遵守"换成"结构必然成立",优于烧轮次。""" + from nsc.passes.p3_beatsheet import _repair_brand_gap, _repair_load_bearing from nsc.passes.p4_scene import _repair_protagonist_present def _beat(i, kind, arousal=0.5): return { - "id": f"b{i}", "order": i, "beat_kind": kind, - "emotion": {"valence": 0.0, "arousal": arousal}, "summary": f"节拍{i}", + "id": f"b{i}", + "order": i, + "beat_kind": kind, + "emotion": {"valence": 0.0, "arousal": arousal}, + "summary": f"节拍{i}", } @@ -19,10 +23,15 @@ def _kinds(beats): # ---------- _repair_load_bearing(STR-014) ---------- + def test_missing_climax_converts_highest_arousal_late_beat(): beats = [ - _beat(0, "hook"), _beat(1, "inciting"), _beat(2, "escalation", 0.6), - _beat(3, "brand_moment"), _beat(4, "escalation", 0.9), _beat(5, "cliffhanger"), + _beat(0, "hook"), + _beat(1, "inciting"), + _beat(2, "escalation", 0.6), + _beat(3, "brand_moment"), + _beat(4, "escalation", 0.9), + _beat(5, "cliffhanger"), ] _repair_load_bearing(beats) assert beats[4]["beat_kind"] == "climax" # 唤起最高的后段非保护拍 @@ -31,8 +40,12 @@ def test_missing_climax_converts_highest_arousal_late_beat(): def test_missing_inciting_converts_central_beat(): beats = [ - _beat(0, "hook"), _beat(1, "setup", 0.3), _beat(2, "escalation", 0.8), - _beat(3, "reversal", 0.4), _beat(4, "climax"), _beat(5, "cliffhanger"), + _beat(0, "hook"), + _beat(1, "setup", 0.3), + _beat(2, "escalation", 0.8), + _beat(3, "reversal", 0.4), + _beat(4, "climax"), + _beat(5, "cliffhanger"), ] _repair_load_bearing(beats) assert beats[2]["beat_kind"] == "inciting" # 居中且唤起最高 @@ -48,14 +61,24 @@ def test_both_present_is_noop(): def test_never_touches_protected_kinds(): """全保护拍的退化集:无可改写对象时不强行制造,交给检查器报真问题。""" - beats = [_beat(0, "hook"), _beat(1, "brand_moment"), _beat(2, "brand_moment"), _beat(3, "cliffhanger")] + beats = [ + _beat(0, "hook"), + _beat(1, "brand_moment"), + _beat(2, "brand_moment"), + _beat(3, "cliffhanger"), + ] _repair_load_bearing(beats) assert _kinds(beats) == ["hook", "brand_moment", "brand_moment", "cliffhanger"] def test_climax_not_on_last_beat(): """fix_hint:climax 紧邻集末终态之前——集末拍不许被改写为 climax。""" - beats = [_beat(0, "hook"), _beat(1, "inciting"), _beat(2, "escalation", 0.7), _beat(3, "escalation", 0.99)] + beats = [ + _beat(0, "hook"), + _beat(1, "inciting"), + _beat(2, "escalation", 0.7), + _beat(3, "escalation", 0.99), + ] _repair_load_bearing(beats) assert beats[2]["beat_kind"] == "climax" assert beats[3]["beat_kind"] == "escalation" @@ -63,8 +86,11 @@ def test_climax_not_on_last_beat(): # ---------- _repair_brand_gap(BM-002,min_gap=2) ---------- + def test_adjacent_brand_beats_get_spaced(): - beats = [_beat(0, "brand_moment"), _beat(1, "brand_moment")] + [_beat(i, "escalation") for i in range(2, 6)] + beats = [_beat(0, "brand_moment"), _beat(1, "brand_moment")] + [ + _beat(i, "escalation") for i in range(2, 6) + ] _repair_brand_gap(beats, 2) bm_idx = [i for i, b in enumerate(beats) if b["beat_kind"] == "brand_moment"] assert bm_idx[1] - bm_idx[0] >= 2 @@ -89,8 +115,12 @@ def test_unfixable_gap_terminates_without_oscillation(): def test_gap_repair_moves_later_brand_beat_not_earlier(): beats = [ - _beat(0, "hook"), _beat(1, "brand_moment"), _beat(2, "escalation"), - _beat(3, "brand_moment"), _beat(4, "escalation"), _beat(5, "cliffhanger"), + _beat(0, "hook"), + _beat(1, "brand_moment"), + _beat(2, "escalation"), + _beat(3, "brand_moment"), + _beat(4, "escalation"), + _beat(5, "cliffhanger"), ] _repair_brand_gap(beats, 3) bm_idx = [i for i, b in enumerate(beats) if b["beat_kind"] == "brand_moment"] @@ -99,6 +129,7 @@ def test_gap_repair_moves_later_brand_beat_not_earlier(): # ---------- _repair_protagonist_present(STR-010) ---------- + def _chars(): return [ {"id": "c-pro", "role": "protagonist"}, diff --git a/tests/test_p4_assign_coercion.py b/tests/test_p4_assign_coercion.py index 04de0b5..555081f 100644 --- a/tests/test_p4_assign_coercion.py +++ b/tests/test_p4_assign_coercion.py @@ -1,8 +1,9 @@ """p4_scene._assign 映射项类型矫正(round10:随机后端 beat_to_scene 结构漂移实证)。""" + import pytest -from nsc.passes.p4_scene import _assign from nsc.passes import PassFailure +from nsc.passes.p4_scene import _assign BEATS = [{"id": f"b{i}"} for i in range(3)] SCENES = [{"id": "s0"}, {"id": "s1"}] @@ -10,9 +11,16 @@ def test_canonical_entries(): - out = _assign([{"beat_index": 0, "scene_index": 0}, - {"beat_index": 1, "scene_index": 0}, - {"beat_index": 2, "scene_index": 1}], BEATS, SCENES, EP) + out = _assign( + [ + {"beat_index": 0, "scene_index": 0}, + {"beat_index": 1, "scene_index": 0}, + {"beat_index": 2, "scene_index": 1}, + ], + BEATS, + SCENES, + EP, + ) assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] @@ -24,8 +32,12 @@ def test_string_pair_entries(): def test_alt_key_names(): """NPC 输出 beat/scene 键名变体。""" - out = _assign([{"beat": 0, "scene": 0}, {"beat": 1, "scene": 0}, {"beat": 2, "scene": 1}], - BEATS, SCENES, EP) + out = _assign( + [{"beat": 0, "scene": 0}, {"beat": 1, "scene": 0}, {"beat": 2, "scene": 1}], + BEATS, + SCENES, + EP, + ) assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] @@ -38,21 +50,37 @@ def test_dict_mapping(): def test_unsalvageable_raises_with_diagnostic(): """矫正不了的项必须 PassFailure 且带可喂优化器的诊断。""" with pytest.raises(PassFailure) as ei: - _assign([{"foo": "bar"}, {"beat_index": 1, "scene_index": 0}, - {"beat_index": 2, "scene_index": 1}], BEATS, SCENES, EP) + _assign( + [ + {"foo": "bar"}, + {"beat_index": 1, "scene_index": 0}, + {"beat_index": 2, "scene_index": 1}, + ], + BEATS, + SCENES, + EP, + ) assert "beat_to_scene" in str(ei.value) def test_string_valued_indexes(): """字符串形式的下标("beat_0"/"s1"/"0")也要矫正,不得 TypeError(round10 实证)。""" - out = _assign([{"beat_index": "beat_0", "scene_index": "s0"}, - {"beat_index": "1", "scene_index": "0"}, - {"beat_index": 2, "scene_index": "s1"}], BEATS, SCENES, EP) + out = _assign( + [ + {"beat_index": "beat_0", "scene_index": "s0"}, + {"beat_index": "1", "scene_index": "0"}, + {"beat_index": 2, "scene_index": "s1"}, + ], + BEATS, + SCENES, + EP, + ) assert [b["parent_id"] for b in out] == ["s0", "s0", "s1"] def test_empty_present_falls_back_to_all(): """空 present_character_ids 兜底全集(scenes.N=[] ValidationError 实证)。""" from nsc.passes.p4_scene import _fallback_present + assert _fallback_present([], {"c1", "c2"}) == ["c1", "c2"] assert _fallback_present(["c1"], {"c1", "c2"}) == ["c1"] diff --git a/tests/test_p5_expand_thin.py b/tests/test_p5_expand_thin.py index ce4271a..6cd3a0a 100644 --- a/tests/test_p5_expand_thin.py +++ b/tests/test_p5_expand_thin.py @@ -3,6 +3,7 @@ 1. 目标区间与门禁对齐——旧 chars_lo=0.8× 低于 DLG-006 下限 0.85×,模型全顺从也会死; 2. _expand_if_thin——欠量当场定点扩写,只接受严格增量;此处测纯函数部分。 """ + from types import SimpleNamespace from nsc.passes.p5_dialogue import _dialogue_chars, _scene_dialogue_floor diff --git a/tests/test_p6_slim.py b/tests/test_p6_slim.py index 1fce0f5..cd0ecad 100644 --- a/tests/test_p6_slim.py +++ b/tests/test_p6_slim.py @@ -4,6 +4,7 @@ - _slim_profile:只留下笔/时长相关键; - _slim_bible_for_episode:角色/地点按集过滤。 """ + import json from nsc.passes.p6_prose import _slim_profile, _slim_scenes @@ -12,22 +13,50 @@ def _scene(): return { - "id": "sc1", "kind": "scene", "parent_id": "ep1", "order": 0, - "location_id": "loc1", "location_name": "茶店", "time_of_day": "afternoon", - "present_character_ids": ["c1", "c2"], "character_names": ["小满", "阿茶"], - "goal": "g", "conflict": "c", "turn": "t", "summary": "s", - "entry": "e", "exit": "x", "knowledge_state": {"k": "v"}, - "provenance_id": "run", "locked": False, + "id": "sc1", + "kind": "scene", + "parent_id": "ep1", + "order": 0, + "location_id": "loc1", + "location_name": "茶店", + "time_of_day": "afternoon", + "present_character_ids": ["c1", "c2"], + "character_names": ["小满", "阿茶"], + "goal": "g", + "conflict": "c", + "turn": "t", + "summary": "s", + "entry": "e", + "exit": "x", + "knowledge_state": {"k": "v"}, + "provenance_id": "run", + "locked": False, "beats": [ { - "id": "b1", "kind": "beat", "parent_id": "sc1", "order": 0, - "beat_kind": "hook", "summary": "开场", "est_duration_s": 12.0, - "emotion": {"valence": 0.1, "arousal": 0.5}, "provenance_id": "run", + "id": "b1", + "kind": "beat", + "parent_id": "sc1", + "order": 0, + "beat_kind": "hook", + "summary": "开场", + "est_duration_s": 12.0, + "emotion": {"valence": 0.1, "arousal": 0.5}, + "provenance_id": "run", "lines": [ - {"id": "l1", "kind": "line", "parent_id": "b1", "order": 0, - "line_type": "dialogue", "character_id": "c1", "text": "台词", - "subtext": "s", "delivery": "d", "is_brand_line": False, - "provenance_id": "run", "locked": False} + { + "id": "l1", + "kind": "line", + "parent_id": "b1", + "order": 0, + "line_type": "dialogue", + "character_id": "c1", + "text": "台词", + "subtext": "s", + "delivery": "d", + "is_brand_line": False, + "provenance_id": "run", + "locked": False, + } ], } ], @@ -52,8 +81,13 @@ def test_slim_scenes_shrinks_size(): def test_slim_profile(): - prof = {"novel": {"enabled": True}, "chars_per_second": 4.5, "pipeline": {"x": 1}, - "retrieval": {"y": 2}, "genre": "drama"} + prof = { + "novel": {"enabled": True}, + "chars_per_second": 4.5, + "pipeline": {"x": 1}, + "retrieval": {"y": 2}, + "genre": "drama", + } slim = _slim_profile(prof) assert set(slim) == {"novel", "chars_per_second", "genre"} diff --git a/tests/test_resolve_pending_demote.py b/tests/test_resolve_pending_demote.py index acd37b9..649814f 100644 --- a/tests/test_resolve_pending_demote.py +++ b/tests/test_resolve_pending_demote.py @@ -1,14 +1,17 @@ """resolve_pending 的降级语义(round12:NPC 从不补 donor,PENDING 悬空是随机后端最高频死法)。""" -import pytest from nsc.passes.p3_beatsheet import resolve_pending def _sp(ep: str, slug: str, setup_ref, payoff_ref, desc="测试伏笔"): return { - "id": f"sp-{ep}-{slug}", "kind": "setup_payoff", - "_episode_id": ep, "_slug": slug, "description": desc, - "setup_beat_id": setup_ref, "payoff_beat_id": payoff_ref, + "id": f"sp-{ep}-{slug}", + "kind": "setup_payoff", + "_episode_id": ep, + "_slug": slug, + "description": desc, + "setup_beat_id": setup_ref, + "payoff_beat_id": payoff_ref, } From c50edb38b39d46474e34dc60017f1740b05b4cda Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 26 Aug 2026 18:16:32 +0800 Subject: [PATCH 28/28] =?UTF-8?q?merge=20=E6=94=B6=E5=B0=BE3:pyright=20?= =?UTF-8?q?=E5=90=88=E8=A7=84(=E6=B5=8B=E8=AF=95=E6=9B=BF=E8=BA=AB=20cast?= =?UTF-8?q?=20PassContext)+format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_p3_duration_rescale.py | 8 +++++--- tests/test_p5_expand_thin.py | 8 ++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/test_p3_duration_rescale.py b/tests/test_p3_duration_rescale.py index 4aeabbe..df2137d 100644 --- a/tests/test_p3_duration_rescale.py +++ b/tests/test_p3_duration_rescale.py @@ -8,10 +8,11 @@ """ from types import SimpleNamespace +from typing import cast import pytest -from nsc.passes import PassFailure +from nsc.passes import PassContext, PassFailure from nsc.passes.p3_beatsheet import _rescale_durations from nsc.passes.pipeline import _retry_pass @@ -54,8 +55,9 @@ class APIConnectionError(Exception): # 类名匹配即视为传输故障(与 op pass -def _ctx(): - return SimpleNamespace(profile={}) +def _ctx() -> PassContext: + # pyright 门禁:函数只读 ctx.profile,cast 声明这一测试替身的最小契约 + return cast(PassContext, SimpleNamespace(profile={})) def test_transient_error_retried_then_succeeds(): diff --git a/tests/test_p5_expand_thin.py b/tests/test_p5_expand_thin.py index 6cd3a0a..133802b 100644 --- a/tests/test_p5_expand_thin.py +++ b/tests/test_p5_expand_thin.py @@ -5,7 +5,9 @@ """ from types import SimpleNamespace +from typing import cast +from nsc.passes import PassContext from nsc.passes.p5_dialogue import _dialogue_chars, _scene_dialogue_floor @@ -19,7 +21,9 @@ def test_dialogue_chars_counts_only_dialogue(): def test_scene_dialogue_floor_matches_gate_ratio(): - ctx = SimpleNamespace(profile={"chars_per_second": 4.5, "duration_tolerance": 0.15}) + ctx = cast( + PassContext, SimpleNamespace(profile={"chars_per_second": 4.5, "duration_tolerance": 0.15}) + ) beats = [{"est_duration_s": 50.0}, {"est_duration_s": 40.0}] # 90s ≈ 一集 floor = _scene_dialogue_floor(ctx, beats) # round16b:瞄准线 = 门禁线 + 6pp 余量(round20:ep8 差 12 字实证,仍远低于上限 1.15) @@ -28,6 +32,6 @@ def test_scene_dialogue_floor_matches_gate_ratio(): def test_scene_dialogue_floor_defaults(): - ctx = SimpleNamespace(profile={}) + ctx = cast(PassContext, SimpleNamespace(profile={})) beats = [{"est_duration_s": 100.0}] assert _scene_dialogue_floor(ctx, beats) == int(100 * 4.5 * 0.91)