12.1 模板与变量

台词里出现的角色名与地点名,在规格里其实是一次变量查找。本节给出文本模板的唯一文法(占位、转义与未知占位回落),由规格三段拼出 24 个绑定的命名实体表,并渲染全部 48 条 line 与 115 条选项文本统计占位出现次数:行内占位 0 处、字段级占位 48 处、可模板化点 33 处。只做文本的结构,不评价文本质量。

《孢子三部曲创作手记》 那一套 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:characters6
地点loc:world.locations14
派系faction:world.factions4
合计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 条件文本与变体 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长