《孢子三部曲创作手记》 那一套 120 篇做的是文本创作本身——小说怎么写、叙事怎么编、文笔与修辞怎么打磨。本章与它划界明确:本套一个字都不评价文本写得好不好,只做文本的结构。本章要处理的是结构里最靠前的一层——文本里的变量占位:台词里出现的角色名与地点名,在规格里其实是一次变量查找。
这张表是本章全部三节共用的边界,先立在这里:
| 本套做(文本的结构) | 本套不做(文本的创作) |
|---|---|
| 模板与变量:占位怎么被规格里的值替换 | 用词是否漂亮、句式是否有节奏 |
| 条件文本与变体:一条记录按状态分流成几条文本 | 台词是否符合人物语言风格 |
| 文本表与键:文本外置成可校验、可预览的表 | 台词的文学质量评价 |
| 变体的一致性:键缺失、键重复、变体退化 | 配音、口型与演出(需真实业务环境,标 X) |
四行左边全部可判定:占位能不能解析可以断言,变体能不能穷举可以数出来,键有没有缺失可以查,变体有没有退化可以算。四行右边全部不可判定,也不该被伪装成可判定。文本创作划界到 《孢子三部曲创作手记》 。
12.1 模板与变量
一条 dialogue 记录里的 line,在规格层面是字面文本;在渲染层面它可以是模板——只要里面出现占位,渲染器就去变量表取值。本节把这一层定义死,因为后面的「文本表」「本地化」都建立在它上面。
模板与变量是同一个东西的两面:模板是带洞的文本,变量表是洞的取值域。洞的名字必须能唯一地映射到规格里的一个命名实体,否则渲染器无从取值。
模板文法
本节只认一族写法,没有第二种:
| 写法 | 含义 | 取值来源 |
|---|---|---|
{char:<id>.name} | 角色显示名 | characters[].name |
{loc:<id>.name} | 地点显示名 | world.locations[].name |
{faction:<id>.name} | 派系名 | world.factions[].name |
{{ 与 }} | 字面花括号 | 渲染器转义 |
其它 {...} | 未知占位 | 原样保留,并计入未命中 |
最后一行是纪律:未命中的占位不静默丢弃。丢弃会让文本悄悄变短,保留并计数才能让「变量表覆盖不到的位置」变成一个可数的量。
变量表:24 个绑定
变量表不是手写的,是从规格三段拼出来的:
| 命名空间 | 前缀 | 来源段 | 绑定数 |
|---|---|---|---|
| 角色 | char: | characters | 6 |
| 地点 | loc: | world.locations | 14 |
| 派系 | faction: | world.factions | 4 |
| 合计 | 24 |
S 层:渲染 48 条 line 与 115 条选项
sim/na_12_1_template_variable.py 做四件事:建变量表并断言显示名唯一;渲染全部 48 条 line 与 115 条选项文本,统计行内占位;用「说话人模板」渲染全部 48 条 utterance,统计字段级占位;扫描正文里的命名实体字面,改写成占位后回渲染。
#!/usr/bin/env python3
# file: sim/na_12_1_template_variable.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_12_1_template_variable.py
"""卷一 12.1 模板与变量(S 层):文本里的占位怎么从规格的命名实体表取值。
模板文法(本节唯一口径):
{<ns>:<id>.name} ns ∈ {char, loc, faction},取值来自规格的三段
{{ / }} 字面花括号转义
未知占位 原样保留,计入未命中
只做文本的**结构**:占位解析、变量表规模、可模板化点计数。不评价文本好坏,
也不对文本做摘要(跨语言摘要只对状态做)。
四步:
1) 由规格三段拼出变量表,打印规模与命名唯一性;
2) 渲染全部 48 条 line 与 115 条选项文本,统计**行内占位**出现次数;
3) 用「说话人模板」渲染全部 48 条 utterance,统计**字段级占位**;
4) 扫描可模板化点(正文里的命名实体字面,最长匹配优先),把字面改写成
占位后回渲染,断言占位全部命中、渲染长度与原字面长度整数一致。
"""
from __future__ import annotations
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/story_spec.json")
OUT = os.path.join(ROOT, "out/na_12_1_template_variable.json")
TOKEN = re.compile(r"\{\{|\}\}|\{([^{}]*)\}")
N_DIALOGUE, N_CHOICES, N_ENV = 48, 115, 24
def load_spec() -> dict:
with open(SPEC, encoding="utf-8") as fh:
return json.load(fh)
def build_env(spec: dict) -> dict:
"""变量表:命名实体(角色 / 地点 / 派系)的显示名,键是占位写法。"""
env: dict[str, str] = {}
for c in spec["characters"]:
env[f"char:{c['id']}.name"] = c["name"]
for loc in spec["world"]["locations"]:
env[f"loc:{loc['id']}.name"] = loc["name"]
for f in spec["world"]["factions"]:
env[f"faction:{f['id']}.name"] = f["name"]
return env
def render(tpl: str, env: dict) -> tuple[str, list, list]:
"""模板渲染:返回 (结果, 命中的占位键, 未命中的占位键)。"""
parts, hits, miss = [], [], []
pos = 0
for m in TOKEN.finditer(tpl):
parts.append(tpl[pos:m.start()])
tok = m.group(0)
if tok == "{{":
parts.append("{")
elif tok == "}}":
parts.append("}")
else:
key = m.group(1).strip()
if key in env:
parts.append(env[key])
hits.append(key)
else:
parts.append(tok)
miss.append(key)
pos = m.end()
parts.append(tpl[pos:])
return "".join(parts), hits, miss
def templatize(text: str, name2key: dict) -> tuple[str, list]:
"""把正文里的命名实体字面改写成占位(最长匹配优先,避免短名吃掉长名)。"""
out, subs, i = [], [], 0
while i < len(text):
best = None
for name in name2key:
if text.startswith(name, i) and (best is None or len(name) > len(best)):
best = name
if best is not None:
out.append("{" + name2key[best] + "}")
subs.append(best)
i += len(best)
else:
out.append(text[i])
i += 1
return "".join(out), subs
def main() -> int:
spec = load_spec()
dlg = spec["dialogue"]
env = build_env(spec)
name2key = {v: k for k, v in env.items()}
n_char = sum(1 for k in env if k.startswith("char:"))
n_loc = sum(1 for k in env if k.startswith("loc:"))
n_fac = sum(1 for k in env if k.startswith("faction:"))
print(f"变量表(命名实体):char={n_char} loc={n_loc} faction={n_fac} 合计 {len(env)} 个绑定")
if len(name2key) != len(env):
print("FAIL 显示名不唯一,无法反查占位键")
return 1
# ---- 1) 行内占位:48 条 line ----
line_hit = line_miss = 0
for n in dlg:
_, h, m = render(n["line"], env)
line_hit += len(h)
line_miss += len(m)
# ---- 2) 行内占位:115 条选项文本 ----
ch_hit = ch_miss = 0
for n in dlg:
for c in (n.get("choices") or []):
_, h, m = render(c["text"], env)
ch_hit += len(h)
ch_miss += len(m)
inline = line_hit + line_miss + ch_hit + ch_miss
print(f"行内占位:48 条 line 里 {line_hit + line_miss} 处,115 条选项文本里 {ch_hit + ch_miss} 处,合计 {inline} 处")
# ---- 3) 字段级占位:说话人模板 ----
utt_hit: list = []
utt_miss: list = []
for n in dlg:
tpl = "{" + f"char:{n['speaker']}.name" + "}:" + n["line"]
_, h, m = render(tpl, env)
utt_hit += h
utt_miss += m
miss_ids = sorted(set(utt_miss))
print(f"字段级说话人占位:{len(utt_hit) + len(utt_miss)} 处(命中 {len(utt_hit)} / 未命中 {len(utt_miss)}),"
f"未命中 id = {miss_ids}")
# ---- 4) 可模板化点:正文里的命名实体字面 ----
rows: list[dict] = []
for n in dlg:
tpl, subs = templatize(n["line"], name2key)
if subs:
rows.append({"kind": "line", "id": n["id"], "tpl": tpl, "subs": len(subs)})
for c in (n.get("choices") or []):
ctpl, csubs = templatize(c["text"], name2key)
if csubs:
rows.append({"kind": "choice", "id": c["id"], "tpl": ctpl, "subs": len(csubs)})
subs_total = sum(r["subs"] for r in rows)
rt_ok = 0
for r in rows:
rendered, h, m = render(r["tpl"], env)
if not m and len(rendered) == len(render_src(spec, r)):
rt_ok += 1
n_line_rows = sum(1 for r in rows if r["kind"] == "line")
n_ch_rows = len(rows) - n_line_rows
print(f"可模板化点:含命名实体字面的行 {len(rows)} 条(line {n_line_rows} / choice {n_ch_rows}),"
f"共 {subs_total} 处字面")
print(f" 改写为占位后回渲染:占位全部命中、长度整数一致 {rt_ok}/{len(rows)} 行")
report = {
"env": {"char": n_char, "loc": n_loc, "faction": n_fac, "total": len(env)},
"inline_placeholders": {"lines": line_hit + line_miss, "choices": ch_hit + ch_miss, "total": inline},
"speaker_placeholders": {"total": len(utt_hit) + len(utt_miss), "hit": len(utt_hit),
"miss": len(utt_miss), "miss_ids": miss_ids},
"templatizable": {"rows": len(rows), "line_rows": n_line_rows, "choice_rows": n_ch_rows,
"substitutions": subs_total, "roundtrip_length_ok": rt_ok},
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
# ---- 整数不变量 ----
if len(dlg) != N_DIALOGUE:
print(f"FAIL 对话节点数 {len(dlg)} != {N_DIALOGUE}")
return 1
n_choices = sum(len(n.get("choices") or []) for n in dlg)
if n_choices != N_CHOICES:
print(f"FAIL 选项数 {n_choices} != {N_CHOICES}")
return 1
if len(env) != N_ENV:
print(f"FAIL 变量表 {len(env)} != {N_ENV}")
return 1
if rt_ok != len(rows):
print("FAIL 有行在回渲染时长度不一致")
return 1
print("OK na_12_1_template_variable:变量表 24 个绑定,48 条 line 无行内占位,"
"字段级占位 48 处,可模板化点 33 处全部可回渲染")
return 0
def render_src(spec: dict, row: dict) -> str:
"""取该行改写前的原始字面(用于长度整数比对)。"""
if row["kind"] == "line":
return next(n["line"] for n in spec["dialogue"] if n["id"] == row["id"])
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
if c["id"] == row["id"]:
return c["text"]
raise KeyError(row["id"])
if __name__ == "__main__":
sys.exit(main())
变量表(命名实体):char=6 loc=14 faction=4 合计 24 个绑定
行内占位:48 条 line 里 0 处,115 条选项文本里 0 处,合计 0 处
字段级说话人占位:48 处(命中 28 / 未命中 20),未命中 id = ['char:narrator.name']
可模板化点:含命名实体字面的行 32 条(line 10 / choice 22),共 33 处字面
改写为占位后回渲染:占位全部命中、长度整数一致 32/32 行
OK na_12_1_template_variable:变量表 24 个绑定,48 条 line 无行内占位,字段级占位 48 处,可模板化点 33 处全部可回渲染
判读:四行输出各自说明了什么
「行内占位 0 处」 是本节最重要的现状描述:48 条 line 与 115 条选项文本里,没有任何一个 {...}。也就是说,规格当前的文本层是字面量——这不是缺陷,而是「可变量还没有被抽出来」。模板层的价值不在于它现在替换了多少,而在于它把「哪些字面将来该变成占位」变成了可数的量。
「字段级占位 48 处(命中 28 / 未命中 20)」 是模板层的第一次真实替换。把 speaker 字段当成变量:48 条 utterance 各产生一次占位解析,6 个角色 id 命中 28 次;narrator 出现 20 次,而它不在 characters 表里,于是未命中——渲染器按文法把占位写法原样留下。这是变量表覆盖不到的位置,修法有两条:要么给旁白补一条变量表条目,要么在渲染器里给它一个字面回落。本节只负责把它数出来。
「可模板化点 32 行 33 处」 是行内变量的账。正文里以字面形式出现的命名实体,最长匹配优先地数了一遍:line 里 10 行 11 处,选项文本里 22 行 22 处。这些字面就是「将来该写成占位」的地方——它们是模板层真正的工作量,而不是凭空设计出来的语法。
「回渲染 32/32 长度整数一致」 是模板引擎的自证:把字面改写成占位再渲染,33 个占位全部命中,且渲染结果与原字面的长度逐行相等。注意这里比的是长度这个整数,不是文本摘要——跨语言的摘要只对状态做,不对文本做。
模板化的三步
| 步 | 输入 | 输出 | 本节的数 |
|---|---|---|---|
| 1 抽变量 | 字面「守望者砚三天前上了塔」 | 模板「守望者{char:yan.name}三天前上了塔」 | 33 处 |
| 2 建表 | 规格三段 | 24 个绑定的命名实体表 | 24 个 |
| 3 渲染 | 模板 + 变量表 | 渲染结果 | 48 条 utterance |
三步的顺序不能颠倒:先有表才能渲染,先有占位才知道表里要什么。这也是为什么「模板」与「变量」必须在同一节里讲——它们是同一次替换的两端。
自检表
| 检查项 | 合格标准 |
|---|---|
| 文法唯一 | 占位只有 {<ns>:<id>.name} 一族,转义只有 {{ 与 }} |
| 值域来自规格 | 变量表由 characters / world.locations / world.factions 三段拼成 |
| 未命中可数 | 未解析的占位被数出来并保留原样,不静默丢弃 |
| 可模板化点可量化 | 正文里的命名实体字面被计数(32 行 / 33 处) |
| 不做文本评价 | 只断言占位命中与长度,不比较文本内容 |
下一节把这条模板放到条件上:同一条记录在不同状态下渲染出不同变体,而规格里已经有一处「声明了条件却没声明另一分支」的欠声明,正好用枚举把它照出来。
阅读导航:上一节:11.3 对话树的遍历与校验 · 下一节:12.2 条件文本与变体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。