Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
90113b2
SW-05 p3 fragment 组成数据化:窗口/投影/Thread 注入进 profile.context(ADR-0017)
randypanding Aug 21, 2026
10227b0
SW-05 review 修正:known_fact_fields 显式空列表不再回退缺省;窗口序表述改为时间序
randypanding Aug 21, 2026
69f1e34
sync: 远端 main 快照(API tarball;解决 fetch 阻断)
Aug 22, 2026
68431d7
merge: 解 SW-05 与已合 PR(#8-#12)的加性冲突(union:context + pipeline/retrieval…
Aug 22, 2026
cbe30e7
style: ruff format(union 解冲突文件)
Aug 22, 2026
c7f67a5
lab round10: p4 beat_to_scene 类型矫正(随机后端结构漂移)+lab_smoke_v1 三集评测切片+p3 指…
randypanding Aug 24, 2026
b2ece6f
lab round10b: _to_int 宽容转换(字符串下标/嵌套值,TypeError 实证)
randypanding Aug 24, 2026
43d1c79
lab round12: resolve_pending 降级语义(无 donor 解除契约不致命;随机后端 PENDING 悬空最高频死…
randypanding Aug 24, 2026
3e419c7
lab round12: resolve_pending 降级语义(无 donor 解除契约不致命)+修正最终返回输入;测试 8 绿
randypanding Aug 24, 2026
2686e09
lab round12b: p1 Prop sku_ref null 归一(NarrativeIR ValidationError 实证)
randypanding Aug 24, 2026
a8e8573
lab round12c: 通用 null→字段默认值归一(characters/locations/motifs 同型错误一例多杀)
randypanding Aug 24, 2026
8c64922
lab round13b: present_character_ids 空表全集兜底(scenes.N=[] ValidationErro…
randypanding Aug 24, 2026
f7df614
lab round14: p3/p4 结构机械修复——STR-014 承重节拍兜底/BM-002 植入间距重排/STR-010 主角补位(…
randypanding Aug 24, 2026
cd8c976
lab round15: est_duration_s 等比缩放到集目标时长(DLG-006 六集全灭根因)+_retry_pass 传输…
randypanding Aug 24, 2026
d5bc6a3
lab round16: p5 对白目标区间与 DLG-006 对齐(旧 lo=0.8x 全顺从也死)+_expand_if_thin 欠…
randypanding Aug 25, 2026
944c3ac
lab round16b: 对白瞄准线=门禁线+3pp(毫厘之死实证 341/342/344 vs 344.25)+扩写循环至达标(上限2…
randypanding Aug 25, 2026
4f53dd1
lab round17: p6 prompt 瘦身(_slim_scenes/_slim_profile/_slim_bible_for_…
randypanding Aug 25, 2026
b777914
lab round18: 暗线步进钳制(INV-19 机械前置,实证 8 章全产物死于 final 门 current_stage 5/7…
randypanding Aug 25, 2026
8b5babd
lab round19: CMP-001 绝对化用语机械替换(门禁 fix 要求的确定性执行)+p1 必填 str 空串占位(charac…
Aug 25, 2026
5023e39
lab round20: 南浪仔(海南文旅 IP)brief+brand 资产(用户定稿故事五幕:雨林→浪尖→旅程→暗线→陪伴)
Aug 25, 2026
d23135d
lab round20b: DLG-006 瞄准余量 +3pp→+6pp+扩写上限 2→3(ep8 差 12 字实证)
Aug 25, 2026
31f44c5
lab round20c: canonical_name 改为'南浪仔'(BM-009 系统性失败实证:slogan 本身就违规,'南浪仔…
Aug 25, 2026
2974fce
lab round20d: ir_io.save 用 mode='json'(datetime 序列化,实证全绿产物死于 ir.json 导出)
Aug 26, 2026
d360726
test 修正:Project 必填字段补齐(provenance_id/logline)
Aug 26, 2026
beecb64
docs: README 快速开始修正(实际 CLI 语法)+独立使用说明(端点配置/新品牌/profile/测试/战役分支)
Aug 26, 2026
943ba51
lab round23: 战役实测版 prompts 入库(p3 v3.2/p5 round13,全绿产物复现所需;B1 生成物例外,PR…
Aug 26, 2026
16d04e7
Merge remote-tracking branch 'origin/main' into sw/sw-05-p3-context-c…
Aug 26, 2026
582c3cd
merge 收尾:ruff 全绿(pairwise/values()/去过期 noqa)
Aug 26, 2026
ca7c1b6
merge 收尾2:ruff format+check 全仓合规(CI lint & typecheck 门禁)
Aug 26, 2026
c50edb3
merge 收尾3:pyright 合规(测试替身 cast PassContext)+format
Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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/`。
Expand Down
2 changes: 1 addition & 1 deletion adr/0015-pass-contract-strings-as-asset.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0015:Pass 契约文案作为资产(spec/passes/contracts.yaml)

- 状态:proposed
- 状态:accepted
- 日期:2026-08-22
- 影响层:A5 知识(+ A6 配置)

Expand Down
2 changes: 1 addition & 1 deletion adr/0016-pipeline-strategy-profile-sections.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0016:管线策略 profile 化(pipeline/retrieval/revise 三段)

- 状态:proposed
- 状态:accepted
- 日期:2026-08-22
- 影响层:A 资产层(profiles/_schema.py + 两个 profile yaml);B 层仅消费

Expand Down
2 changes: 1 addition & 1 deletion adr/0017-p3-context-profile-section.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0017:p3 fragment 组成数据化(profile.context 段)

- 状态:proposed
- 状态:accepted
- 日期:2026-08-22
- 影响层:A 资产层(profiles/_schema.py + profile yaml);B 层仅消费

Expand Down
84 changes: 84 additions & 0 deletions brands/hainan_nolan/brand.yaml
Original file line number Diff line number Diff line change
@@ -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: 南浪仔
aliases: [南浪仔 NOLAN, 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
23 changes: 23 additions & 0 deletions examples/hainan_nolan/brief.yaml
Original file line number Diff line number Diff line change
@@ -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 要自然融入,不要硬喊
- 他是长臂猿不是人,动作要有猿的特点(长手臂、荡、攀、挂)
60 changes: 60 additions & 0 deletions profiles/lab_smoke_v1.yaml
Original file line number Diff line number Diff line change
@@ -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}
10 changes: 10 additions & 0 deletions prompts/p3_beatsheet.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions prompts/p5_dialogue.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 47 additions & 4 deletions src/nsc/passes/p1_bible.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,28 @@ 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))
)
props = _assign_ids(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 尚不存在,引用必为伪造
Expand All @@ -75,6 +88,36 @@ 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()
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


def _sanitize_props(props: Any) -> Any:
"""Prop 机械归一:NPC 显式给 sku_ref=null 时归一为 ""(pydantic str 拒 None;
随机后端 ValidationError props.N.sku_ref 实证)。"""
return _null_str_fields_to_default(props, Prop)


def _sanitize_mind(characters: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""角色心智 OS(ADR-0012)机械归一:省略 → 默认空;嵌套 extra 键过滤、畸形条目丢弃。

Expand Down
Loading
Loading