From 85cff8894b9c6ee1bdb8e06a993beee0583e6afe Mon Sep 17 00:00:00 2001 From: randypanding <66171646+randypanding@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:00:40 +0800 Subject: [PATCH] =?UTF-8?q?SW-05=20rebase=20onto=20main(union=20=E8=A7=A3?= =?UTF-8?q?=E5=86=B2=E7=AA=81=20+=20ruff=20format)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adr/0017-p3-context-profile-section.md | 50 ++++++++++ profiles/_schema.py | 28 +++++- profiles/short_drama_v1.yaml | 2 + profiles/short_video_v1.yaml | 2 + src/nsc/passes/p3_beatsheet.py | 4 + src/nsc/passes/pipeline.py | 93 +++++++++++++++--- tests/test_p3_context_config.py | 130 +++++++++++++++++++++++++ 7 files changed, 293 insertions(+), 16 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..79ba280 --- /dev/null +++ b/adr/0017-p3-context-profile-section.md @@ -0,0 +1,50 @@ +# 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`:按时间序逐行拼接,远端在前、近端在后,与 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 输入 | + +`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` 与原实现逐字节一致(既有快照/桩测试守护)。 +- `known_fact_fields: []` 是合法的显式空投影(有意隐藏全部字段),不等同于未配置(review 修正)。 +- 窗口内多集按时间序排列(远端在前),与 compress_history 输出布局一致(review 澄清,初稿表述有误)。 + +## 迁移 + +非 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 1e0089b..d2a7094 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 PipelineSettings(BaseModel): """SW-07 / ADR-0016:管线重试策略。缺省值 = 提案前的代码常量(零行为变化)。""" @@ -87,6 +112,7 @@ class Profile(BaseModel): beat_templates: list[BeatTemplate] = Field(default_factory=list) novel: NovelSettings = Field(default_factory=NovelSettings) + context: ContextSettings = Field(default_factory=ContextSettings) #: SW-07 / ADR-0016:管线策略(重试与定向重生成次数)。缺省 = 原代码常量。 pipeline: PipelineSettings = Field(default_factory=lambda: PipelineSettings()) diff --git a/profiles/short_drama_v1.yaml b/profiles/short_drama_v1.yaml index 07896bd..815db51 100644 --- a/profiles/short_drama_v1.yaml +++ b/profiles/short_drama_v1.yaml @@ -52,6 +52,8 @@ model_tiers: 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} diff --git a/profiles/short_video_v1.yaml b/profiles/short_video_v1.yaml index 3bb0acf..050ee7a 100644 --- a/profiles/short_video_v1.yaml +++ b/profiles/short_video_v1.yaml @@ -56,6 +56,8 @@ model_tiers: 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} diff --git a/src/nsc/passes/p3_beatsheet.py b/src/nsc/passes/p3_beatsheet.py index c488f55..16ddfcd 100644 --- a/src/nsc/passes/p3_beatsheet.py +++ b/src/nsc/passes/p3_beatsheet.py @@ -76,6 +76,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 a123a7f..a8b5389 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 @@ -348,6 +349,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 = "" @@ -358,7 +365,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"] @@ -368,12 +374,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)), } @@ -381,9 +387,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"] @@ -503,23 +511,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}) @@ -647,21 +664,67 @@ 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 → 投影字段(白名单交集,缺省 = 原五字段)。 + + 显式空列表是合法配置(投影面为空,即有意隐藏全部字段),不与"未配置"混同。 + """ + 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) + + +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):按时间序拼接(远端在前、近端在后)。 + + 与 compress_history 的【前情】→【上一集】布局一致(review 澄清:chronological)。 + 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..37e93c9 --- /dev/null +++ b/tests/test_p3_context_config.py @@ -0,0 +1,130 @@ +"""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") + # 显式空列表 = 有意隐藏全部字段(review 修正:不得与"未配置"混同回退缺省) + assert _known_fact_fields_of({"context": {"known_fact_fields": []}}) == () + + +# ---------------------------------------------------------------- 窗口与 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:时间序拼接(远端在前、近端在后,同 compress_history 布局)—— + # 第 3 集的窗口以第 1 集摘要开头、第 2 集摘要收尾,严格长于第 2 集的窗口 + 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"}