diff --git a/README.md b/README.md index e76bf04..2d07c59 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ## 一、它怎么工作 -``` +```text ① 线索挖掘 从 40 万条事件里建人物关系图,找出关系密切、有戏的 3–6 人小圈子 (只用有叙事含义的互动:对战/结缘/反目/绑架/构陷/迫害……) ② 切集 沿这条线按因果链切集:相邻事件间隔 >2 年断开,短链合并,过长拆上下集 diff --git a/dfannals/cast.py b/dfannals/cast.py index 25e3c32..bd1d0df 100644 --- a/dfannals/cast.py +++ b/dfannals/cast.py @@ -54,14 +54,15 @@ class CastRow: fid: int name: str race: str + gender: str # 男/女/空(史料未载) span: str role: str # 主角 / 对手 / 配角 appearances: int identity: str def as_row(self) -> str: - return (f"| {self.name} | {self.race or '—'} | {self.span} | {self.identity or '—'} | " - f"{self.appearances} | {self.role} |") + return (f"| {self.name} | {self.race or '—'} | {self.gender or '—'} | {self.span} | " + f"{self.identity or '—'} | {self.appearances} | {self.role} |") def _identity_of(world: World, fid: int, events: list[Event]) -> str: @@ -107,6 +108,7 @@ def collect_rows(world: World, thread: Thread, episodes: list[Episode], max_othe fid=fid, name=world.figure_name(fid), race=fig.race if fig else "", + gender=fig.gender if fig else "", span=fig.alive_span if fig else "生卒不详", role=role, appearances=appearances.get(fid, 0), @@ -136,8 +138,9 @@ def _bio_prompt(world: World, rows: list[CastRow], events: list[Event]) -> str: for row in rows[:BIO_LIMIT]: facts = [ ( - f"{row.name}({row.race or '种族不详'},{row.span},本卷出场 {row.appearances} 次," - f"身份:{row.identity or '不详'},在故事里是{row.role})" + f"{row.name}({row.race or '种族不详'},{row.gender or '性别史料未载'},{row.span}," + f"本卷出场 {row.appearances} 次,身份:{row.identity or '不详'}," + f"在故事里是{row.role})" ) ] for e in events: @@ -197,8 +200,8 @@ def build_cast( f"本卷主角线索:{'、'.join(world.figure_name(m) for m in thread.members)}", f"线索跨度:{_span_of(thread, episodes)}", "", - "| 人物 | 种族 | 生卒 | 身份 | 本卷出场 | 角色 |", - "|---|---|---|---|---|---|", + "| 人物 | 种族 | 性别 | 生卒 | 身份 | 本卷出场 | 角色 |", + "|---|---|---|---|---|---|---|", ] lines.extend(row.as_row() for row in rows) diff --git a/dfannals/cli.py b/dfannals/cli.py index e69de29..8ddd27d 100644 --- a/dfannals/cli.py +++ b/dfannals/cli.py @@ -0,0 +1,392 @@ +"""命令行入口。 + +人物主线流程: + + scripts/dfannals status 查看当前进度 + scripts/dfannals index 解析 exports → 年表 + 人物索引 + scripts/dfannals threads 挖掘人物线索候选(含信号明细) + scripts/dfannals episodes --thread 1 查看某条线索切出的剧集 + scripts/dfannals cast --thread 1 生成人物表(表格 + 小传) + scripts/dfannals episode --index 1 写出某一集(自评闸门 + 文笔层扩写) + scripts/dfannals expand --index 1 只跑文笔层:把已有的一集大量扩写(A/B 对照) + +用 scripts/dfannals 启动,不要直接 python3 -m:会话 PATH 上的 python3 可能是 +编辑器工具链的 venv,里面没有 defusedxml。 +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from dfannals import ( + cast, + chronicle, + deslop, + episodes, + factcheck, + legends, + publish, + threads, + timeline, +) +from dfannals import expand as expand_mod +from dfannals import volume as volume_mod +from dfannals.config import ( + DATA_DIR, + EXPAND_TARGET_CHARS, + EXPORT_DIR, + GITEA_WEB, + LITERARY_MODEL, + chapter_dir, + ensure_dirs, + volume_dir, +) + + +def find_exports(export_dir: Path) -> list[Path]: + """找出 legends 导出文件。 + + 原生 legends.xml 与 DFHack 的 legends_plus.xml 都要; + 预处理缓存放在子目录,所以这里不会重复收到同一份数据。 + """ + if not export_dir.is_dir(): + return [] + files = sorted(export_dir.glob("*.xml")) + base = [f for f in files if "legends_plus" not in f.name] + plus = [f for f in files if "legends_plus" in f.name] + return base + plus + + +def load_world(export_dir: Path) -> legends.World: + paths = find_exports(export_dir) + if not paths: + raise SystemExit( + f"在 {export_dir} 里没找到 legends XML。\n" + "请先导出(游戏内 Export XML + DFHack 的 exportlegends),再复制进这个目录。" + ) + return legends.load(paths) + + +def _current_volume(state: chronicle.State, world: legends.World) -> int: + """世界换了就开新卷。""" + if state.world != world.name: + if state.world: + state.volume += 1 + state.done = [] + state.world = world.name + return state.volume + + +def _volume_for(state: chronicle.State, world: legends.World) -> int: + return state.volume if state.world == world.name else 1 + + +def _load_candidates(args: argparse.Namespace) -> tuple[legends.World, list[threads.Thread]]: + ensure_dirs() + world = load_world(EXPORT_DIR) + found = threads.find_threads( + world, min_interactions=args.min_interactions, min_span=args.min_span + ) + if not found: + print("没有候选线索") + raise SystemExit(1) + return world, found + + +def cmd_status(args: argparse.Namespace) -> int: + state = chronicle.State.load() + if not state.world: + print("尚未开始。先运行 index。") + return 0 + vdir = volume_dir(state.volume) + print(f"世界:{state.world}") + print(f"卷号:第 {state.volume} 卷") + print(f"已完成集数:{len(state.done)}") + for key in state.done: + print(f" · {key}") + print(f"成品目录:{vdir}") + return 0 + + +def cmd_index(args: argparse.Namespace) -> int: + ensure_dirs() + world = load_world(EXPORT_DIR) + state = chronicle.State.load() + volume = _current_volume(state, world) + + vdir = volume_dir(volume) + timeline.write_outputs(world, vdir) + state.save() + + print(legends.describe(world)) + print() + print(f"年表与人物索引已写入 {vdir}") + print("下一步:threads 看候选线索,episodes 看剧集规划。") + + if not args.no_push: + publish.ensure_repo() + head = publish.commit_and_push( + f"第 {volume} 卷索引:{world.name}", [vdir / "timeline.md", vdir / "figures.md"] + ) + print(f"已推送索引:{head or '(无改动)'} → {GITEA_WEB}") + return 0 + + +def cmd_threads(args: argparse.Namespace) -> int: + world, found = _load_candidates(args) + report = threads.render_report(world, found, per_thread=args.samples) + print(report) + path = threads.save_json(world, found, DATA_DIR / "threads.json") + print(f"共 {len(found)} 条候选;明细已写入 {path}") + return 0 + + +def cmd_episodes(args: argparse.Namespace) -> int: + world, found = _load_candidates(args) + if not 1 <= args.thread <= len(found): + print(f"候选只有 {len(found)} 条,--thread 超出范围") + return 1 + thread = found[args.thread - 1] + _, planned = volume_mod.extended_plan(world, thread) + print(episodes.render_plan(world, thread, planned)) + print() + print(episodes.budget_report(planned)) + if args.samples: + print("\n=== 前两集事件样例 ===") + for ep in planned[:2]: + print(f" 第 {ep.index} 集({ep.span})") + for e in ep.events[: args.samples]: + print(f" · {e.year}年 {e.type} " + " · ".join(world.render_refs(e))) + return 0 + + +def cmd_cast(args: argparse.Namespace) -> int: + world, found = _load_candidates(args) + if not 1 <= args.thread <= len(found): + print(f"候选只有 {len(found)} 条,--thread 超出范围") + return 1 + + thread = found[args.thread - 1] + _, planned = volume_mod.extended_plan(world, thread) + text, rows, suspicions = cast.build_cast(world, thread, planned, with_bios=not args.no_bios) + if args.dry_run: + print(text[:2000]) + return 0 + + state = chronicle.State.load() + volume = _volume_for(state, world) + path = cast.write_cast(volume_dir(volume) / "cast.md", text) + print(f"人物表已写入 {path}\n涵盖 {len(rows)} 人:{', '.join(r.name for r in rows)}") + print(factcheck.report(suspicions) if suspicions + else "专名校验:小传里的专名全部能在史料中找到。") + return 0 + + +def _expand_in_place(volume: int, world: legends.World, produced, args) -> str: + """把刚写好的骨架稿大量扩写成成稿,覆盖本集文件;骨架另存 `.skeleton.md`。 + + 骨架仍然保留:它过了自评闸门、字数合规,是溯因和对照的依据。 + """ + ext, planned = volume_mod.extended_plan(world, produced.selection.thread) + directory = chapter_dir(volume) + skeleton = produced.path.with_name(produced.path.stem + ".skeleton.md") + skeleton.write_text(produced.text.strip() + "\n", encoding="utf-8") + + print(f"--- 文笔层扩写中({args.model},目标 {args.target} 字)---") + result = expand_mod.expand_episode( + world, produced.selection.thread, produced.episode, len(planned), produced.text, + prev_tail=volume_mod.prev_tail_of(directory, planned, produced.episode.index), + model=args.model, target=args.target, elided=ext.elided, + ) + text = result.text + for note in result.notes: + print(" " + note) + + suspects = factcheck.check(text, world) + if suspects: + print("→ 自动修复可疑专名(只改含它们的段落)") + fixed = expand_mod.repair_names(world, produced.selection.thread, produced.episode, + text, suspects) + text = fixed.text + for note in fixed.notes: + print(" " + note) + + produced.path.write_text(text.strip() + "\n", encoding="utf-8") + remaining = factcheck.check(text, world) + print(expand_mod.compare(produced.text, text)) + print(f"成稿专名校验:可疑 {len(remaining)} 处|骨架稿另存 {skeleton.name}") + if remaining: + print(factcheck.report(remaining, top=5)) + return text + + +def cmd_episode(args: argparse.Namespace) -> int: + world, found = _load_candidates(args) + state = chronicle.State.load() + volume = _volume_for(state, world) + + out_dir = None if args.dry_run else chapter_dir(volume) + produced = volume_mod.produce_episode( + world, found, episode_index=args.index, out_dir=out_dir + ) + + print("选择依据:" + produced.selection.reason) + print(f"骨架稿字数:{chronicle.cjk_length(produced.text)}") + if args.dry_run: + print("\n--- 试运行,不落盘 ---\n") + print(produced.text[:1600]) + return 0 + + if args.expand: + _expand_in_place(volume, world, produced, args) + + state.mark_done(produced.episode.key) + state.save() + print(f"已写入 {produced.path}") + if not args.no_push: + publish.ensure_repo() + head = publish.commit_and_push( + f"第 {volume} 卷第 {produced.episode.index} 集(人物主线):" + f"{produced.episode.span}" + ) + print(f"已推送:{head or '(无改动)'} → {GITEA_WEB}") + return 0 + + +def cmd_expand(args: argparse.Namespace) -> int: + """文笔层:拿已写好的那一集当骨架,大量扩写成场景化长文。 + + 故意不覆盖原稿:产物写 `*.literary.md`,两稿并存才能比对。也不推送。 + """ + world, found = _load_candidates(args) + state = chronicle.State.load() + volume = _volume_for(state, world) + + selection = volume_mod.select_thread(world, found, rank=args.thread) + ext, planned = volume_mod.extended_plan(world, selection.thread) + if args.index > len(planned): + print(f"第 {args.thread} 条线索只有 {len(planned)} 集,没有第 {args.index} 集") + return 1 + + episode = planned[args.index - 1] + directory = chapter_dir(volume) + draft_path = directory / f"{episode.key}.md" + if not draft_path.is_file(): + print(f"原稿不存在:{draft_path}") + print(f"先跑:scripts/dfannals episode --index {args.index}") + return 1 + + draft = draft_path.read_text(encoding="utf-8") + print(f"线索:{'、'.join(world.figure_name(m) for m in selection.thread.members)}") + print(f"本集:{episode.span}|原稿 {draft_path.name}({chronicle.cjk_length(draft)} 字)") + print() + print(deslop.report(draft)) + print() + print(f"--- 文笔层扩写中(模型 {args.model},目标 {args.target} 字)---") + + result = expand_mod.expand_episode( + world, selection.thread, episode, len(planned), draft, + prev_tail=volume_mod.prev_tail_of(directory, planned, args.index), + model=args.model, target=args.target, elided=ext.elided, + ) + text = result.text + for note in result.notes: + print(" " + note) + + print() + print(expand_mod.full_report(draft, text)) + print() + + before = factcheck.check(draft, world) + suspects = factcheck.check(text, world) + print(f"专名校验:原稿 {len(before)} 处|扩写稿 {len(suspects)} 处") + if suspects: + print(factcheck.report(suspects, top=5)) + print("→ 自动修复可疑专名(只改含它们的段落)") + fixed = expand_mod.repair_names(world, selection.thread, episode, text, suspects) + for note in fixed.notes: + print(" " + note) + text = fixed.text + left = factcheck.check(text, world) + print(f" 复检:剩余可疑专名 {len(left)} 处") + if left: + print(factcheck.report(left, top=5)) + + if args.dry_run: + print("\n--- 试运行,不落盘 ---\n") + print(text) + return 0 + + path = expand_mod.literary_path(directory, episode) + path.write_text(text.strip() + "\n", encoding="utf-8") + print(f"\n已写入 {path}") + print("原稿未改动、未推送(A/B 对照用)") + return 0 + + +def _add_pipeline_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS, + help="强边阈值:一对人物至少反复互动多少次(默认 5)") + p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN, + help="线索最小跨度年数(默认 30)") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="dfannals", description="矮人要塞人物主线连载管道") + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("status", help="查看当前进度") + p.set_defaults(func=cmd_status) + + p = sub.add_parser("index", help="解析 exports → 年表 + 人物索引") + p.add_argument("--no-push", action="store_true", help="只写本地,不推送") + p.set_defaults(func=cmd_index) + + p = sub.add_parser("threads", help="挖掘人物线索候选") + _add_pipeline_args(p) + p.add_argument("--top", type=int, default=0, help="只显示前 N 条(0 为全部)") + p.add_argument("--samples", type=int, default=3, help="每条线索展示几条事件样例") + p.set_defaults(func=cmd_threads) + + p = sub.add_parser("episodes", help="按人物线索切出剧集") + _add_pipeline_args(p) + p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)") + p.add_argument("--samples", type=int, default=0, help="额外打印每集前 N 条事件") + p.set_defaults(func=cmd_episodes) + + p = sub.add_parser("cast", help="生成每卷人物表(表格 + 小传)") + _add_pipeline_args(p) + p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)") + p.add_argument("--no-bios", action="store_true", help="只出表格,不让模型写小传") + p.add_argument("--dry-run", action="store_true", help="只打印不落盘") + p.set_defaults(func=cmd_cast) + + p = sub.add_parser("episode", help="产出某一集(自评闸门 + 文笔层扩写)") + _add_pipeline_args(p) + p.add_argument("--index", type=int, default=1, help="写该线索的第几集(默认 1)") + p.add_argument("--no-expand", dest="expand", action="store_false", + help="只出骨架稿,不做文笔层扩写") + p.add_argument("--target", type=int, default=EXPAND_TARGET_CHARS, + help=f"扩写目标字数(默认 {EXPAND_TARGET_CHARS})") + p.add_argument("--model", default=LITERARY_MODEL, help="扩写模型") + p.add_argument("--dry-run", action="store_true", help="只生成不落盘、不推送") + p.add_argument("--no-push", action="store_true", help="落盘但不推送") + p.set_defaults(func=cmd_episode, expand=True) + + p = sub.add_parser("expand", help="文笔层:把某一集大量扩写成场景化长文(不覆盖原稿)") + _add_pipeline_args(p) + p.add_argument("--index", type=int, default=1, help="扩写哪一集(默认 1)") + p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)") + p.add_argument("--target", type=int, default=EXPAND_TARGET_CHARS, help="目标字数") + p.add_argument("--model", default=LITERARY_MODEL, help="扩写模型") + p.add_argument("--dry-run", action="store_true", help="只生成不落盘") + p.set_defaults(func=cmd_expand) + + args = parser.parse_args(argv) + ensure_dirs() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dfannals/config.py b/dfannals/config.py index 34e179a..9cb46f2 100644 --- a/dfannals/config.py +++ b/dfannals/config.py @@ -44,6 +44,9 @@ PI_AUTH_JSON = HOME / ".pi/agent/auth.json" PI_MODELS_JSON = HOME / ".pi/agent/models.json" PROVIDER = "workbuddy" MODEL = "cn:deepseek-v4-pro" +# 文笔层(扩写)单独选模型:创作向模型写叙事更贴地, +# 而推理向模型(deepseek-v4-pro)天然偏总结陈词,容易写成报告。 +LITERARY_MODEL = "cn:minimax-m3" # 该模型是推理模型:思考与正文共用 max_tokens,必须留足 MAX_TOKENS_CHAPTER = 16000 MAX_TOKENS_UTIL = 4000 @@ -66,6 +69,16 @@ WORLD_HISTORY_YEARS = 250 CHAPTER_MIN_CHARS = 800 CHAPTER_MAX_CHARS = 1500 # 严格按访谈定的 800–1500,不留“余量” +# ---------------------------------------------------------------- 文笔层(扩写) +# 用户口径:要的是「大量扩写」——原稿是骨架,文笔层把它逐事件展开成现场。 +EXPAND_TARGET_CHARS = 8000 # 目标字数(用户选定:每集约 8000 字) +EXPAND_FLOOR = 6000 # 低于此判太短,继续展开 +EXPAND_CEILING = 11000 # 高于此判太长,压缩 +# 一次调用写 8000 字很容易跑偏、漏事件,所以按场景分块展开,每块目标这么多字 +EXPAND_CHUNK_TARGET = 2600 +# 长文 + 可能的思考 tokens,预算要给足 +MAX_TOKENS_EXPAND = 32000 + @dataclass class Gateway: diff --git a/dfannals/deslop.py b/dfannals/deslop.py new file mode 100644 index 0000000..fb82f1c --- /dev/null +++ b/dfannals/deslop.py @@ -0,0 +1,162 @@ +"""AI 味机械诊断:把「文笔是否自然」变成可度量的指标。 + +规则来自 oh-story-claudecode 的 story-deslop 与 banned-words(MIT),做了两处适配: + +1. **分层**。原文把「仿佛、一丝、坚定、冰冷」放同一张一级表,但「坚定」「冰冷」在 + 史书叙事里是正常词(矮人要塞的"冰冷"可能是字面温度),一律判罪会刷满假阳性。 + 因此分成硬伤词(几乎只在 AI 文本里成串出现)与密度词(真人也用,只看密度)。 +2. **按密度而非布尔判定**。AI 味是密度问题:出现一次是风格,出现二十次是模板。 + 输出「每千字命中数」,供闸门与扩写模型参考。 + +语义类判断(比喻是否套话、是否把一件事掰成三段写)机械检查假阳性太高, +交给扩写模型和自评闸门,这里不猜。 +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from dfannals.chronicle import cjk_length + +# 硬伤词:出现在正常中文里的概率很低,命中一处就该改 +HARD_WORDS: dict[str, tuple[str, ...]] = { + "情态": ("仿佛", "犹如", "宛若", "如同", "一丝", "一抹", "些许", "几分", "隐约", + "毫无征兆", "几不可闻", "微不可察"), + "动作": ("深吸一口气", "不禁"), + "表情": ("眼中闪过", "嘴角勾起", "眉头微皱", "眉眼低垂", "瞳孔微缩", "瞳孔收缩", + "瞳孔一缩", "指节泛白", "眼神锐利", "目光锐利"), + "心理": ("心中一动", "心头一震", "心下了然", "心中暗道", "心底泛起", "不由得", "心中一凛"), + "判断": ("不容置疑", "不容置喙", "不易察觉", "显而易见", "毫无疑问", "不可否认", "前所未有"), + "过渡": ("不由自主", "情不自禁", "自然而然", "话锋一转", "闪烁着光芒"), +} + +# 密度词:真人写作也用,只有成串出现才算模板腔 +SOFT_WORDS: dict[str, tuple[str, ...]] = { + "形容": ("坚定", "狡黠", "深邃", "凛冽", "冰冷"), + "语境敏感": ("突然", "陡然", "骤然", "猛然", "好像", "似乎", "瞬间", "猛地", "死死地"), + "弱化副词": ("缓缓", "微微", "轻轻", "淡淡"), +} + +# 句式与标点:每条是(规则名,说明,正则) +PATTERNS: tuple[tuple[str, str, str], ...] = ( + ("不是A而是B", "最毒的 AI 句式,直接写 B", r"不是[^,。;!?\n]{1,24}[,,]\s*(?:而)?是"), + ("万能动宾", "「,带着……」万能状语", r"[,,]\s*带着[^,。;!?\n]{0,20}"), + ("声音描写", "「声音不大,却带着……」", r"声音(?:不大|很轻|很平|平静)[^。\n]{0,14}(?:却|但)?带着"), + ("认知直述", "告诉而非展示,改为动作或台词", r"(?:他|她|他们|她们)(?:这才|终于)?(?:明白|意识到|感到|知道)"), + ("章末预告", "「他不知道的是……」空泛预告", r"(?:他|她)不知道的是"), + ("收束腔", "「这一刻/才刚刚开始」式总结", r"这一刻[,,]|从这一刻开始|才刚刚开始|原来[,,]"), + ("抽象命运", "命运+齿轮/棋局/獠牙", r"命运[^。\n]{0,8}(?:齿轮|棋局|獠牙|改写|安排)"), + ("心理模板", "「心中一震/心头一凛」式心理惊吓", r"心(?:中|头|底)(?:一|猛|顿)(?:震|凛|颤)"), + ("过渡模板", "「取而代之的是」", r"取而代之"), + ("告诉而非展示", "「显得有些X」「散发着一股X气息」", r"显得(?:有些|十分|非常)?[\u4e00-\u9fff]{1,4}|散发(?:着|出)?[^。\n]{0,8}(?:气息|气场)"), + ("公式化对话标签", "「好的,他说道」", r"[,,](?:他|她)(?:说道|开口道|沉声道|低声道)"), + ("英文引号", "模型常直接吐 ASCII 直角引号,中文排版用“”", r'"'), + ("破折号", "正文禁用,改用句号、逗号或动作断句", r"——|──|(? str: + ex = ";".join(self.samples) + return f"{self.category:<6} {self.rule:<8} ×{self.count:<3} {ex}" + + +def _context(text: str, start: int, end: int) -> str: + left = max(0, start - SAMPLE_WIDTH) + right = min(len(text), end + SAMPLE_WIDTH) + return text[left:right].replace("\n", " ").strip() + + +def _scan_word(text: str, word: str) -> tuple[int, list[str]]: + count, samples, pos = 0, [], text.find(word) + while pos != -1: + count += 1 + if len(samples) < SAMPLES_PER_RULE: + samples.append(_context(text, pos, pos + len(word))) + pos = text.find(word, pos + len(word)) + return count, samples + + +def check(text: str) -> list[Hit]: + """扫一遍文本,返回全部诊断(硬伤在前,同类按命中数降序)。""" + hits: list[Hit] = [] + + for category, words in HARD_WORDS.items(): + for word in words: + count, samples = _scan_word(text, word) + if count: + hits.append(Hit(word, category, "hard", count, samples)) + + for rule, _note, pattern in PATTERNS: + rx = re.compile(pattern) + found = list(rx.finditer(text)) + if found: + samples = [_context(text, m.start(), m.end()) for m in found[:SAMPLES_PER_RULE]] + hits.append(Hit(rule, "句式" if not rule.startswith(("破折号", "省略号", "英文引号")) else "标点", + "hard", len(found), samples)) + + for category, words in SOFT_WORDS.items(): + for word in words: + count, samples = _scan_word(text, word) + if count: + hits.append(Hit(word, category, "soft", count, samples)) + + hits.sort(key=lambda h: (h.level != "hard", -h.count, h.rule)) + return hits + + +def hard_hits(hits: list[Hit]) -> list[Hit]: + return [h for h in hits if h.level == "hard"] + + +def per_k(text: str) -> float: + """每千字硬伤命中数。空文本返回 0,避免除零。""" + length = cjk_length(text) or 1 + return sum(h.count for h in hard_hits(check(text))) * 1000 / length + + +def report(text: str, hits: list[Hit] | None = None, top: int = 14) -> str: + hits = check(text) if hits is None else hits + hard = hard_hits(hits) + soft = [h for h in hits if h.level == "soft"] + total_hard = sum(h.count for h in hard) + total_soft = sum(h.count for h in soft) + length = cjk_length(text) + density = total_hard * 1000 / (length or 1) + + lines = [ + f"AI 味诊断:{length} 字|硬伤 {total_hard} 处({density:.1f}/千字)|密度词 {total_soft} 处", + ] + if hard: + lines.append("") + lines.append("硬伤(命中即改):") + lines += [" " + h.line() for h in hard[:top]] + if soft: + lines.append("") + lines.append("密度词(成串才处理):") + lines += [" " + h.line() for h in soft[:top]] + if not hard and not soft: + lines.append(" 没有命中:用词干净。") + return "\n".join(lines) + + +def digest(text: str, top: int = 10) -> str: + """给扩写模型看的简短诊断:要求它逐条消灭这些。""" + hits = [h for h in check(text) if h.level == "hard"][:top] + if not hits: + return "(机械检查没有发现 AI 味硬伤;重点放在把概述换成场景。)" + return "\n".join(f"- {h.category}『{h.rule}』×{h.count}|例:{h.samples[0] if h.samples else ''}" + for h in hits) diff --git a/dfannals/expand.py b/dfannals/expand.py new file mode 100644 index 0000000..a4daa20 --- /dev/null +++ b/dfannals/expand.py @@ -0,0 +1,397 @@ +"""文笔层:把已过闸门的编年稿大量扩写成有现场的长文。 + +与 volume.write_episode 的分工: +- volume 从史料素材写出「骨架正确」的一集(事实优先,1500 字内); +- expand 拿那一稿逐事件展开(场景优先,目标 8000 字)。 + +**为什么要分块**:一次调用来写 8000 字,模型会跑偏、提前收尾、漏掉后半段事件。 +所以按「年份场景」把原稿切块,每块目标约 2600 字,逐块展开后用上一块结尾承接, +再把各块拼接。每块都只喂它自己那一段史料,避免不同块写出重复内容。 + +这是 A/B 层:产物写成 `.literary.md`,永不覆盖原稿。 +""" +from __future__ import annotations + +import math +import re +from collections import Counter +from dataclasses import replace +from pathlib import Path + +from dfannals import deslop, volume +from dfannals import episodes as ep_mod +from dfannals.chronicle import cjk_length +from dfannals.config import ( + EXPAND_CEILING, + EXPAND_CHUNK_TARGET, + EXPAND_FLOOR, + EXPAND_TARGET_CHARS, + LITERARY_MODEL, + MAX_TOKENS_EXPAND, + PROMPT_DIR, +) +from dfannals.factcheck import Suspicion +from dfannals.legends import World +from dfannals.llm import chat +from dfannals.normalize import Normalized, normalize +from dfannals.threads import Thread + +SPEC_FILE = PROMPT_DIR / "literary-expander.md" +TAIL_CHARS = 300 # 上一块结尾取多少字做承接 +YEAR_RE = re.compile(r"^\s*\d{3,4}\s*年") +ANY_YEAR_RE = re.compile(r"(\d{3,4})\s*年") + + +def scene_groups(draft: str) -> list[list[str]]: + """按「年份开头的段落」切场景组;紧随其后的非年份段落归入同一组。""" + groups: list[list[str]] = [] + for para in (p for p in draft.split("\n") if p.strip()): + if YEAR_RE.match(para) or not groups: + groups.append([para]) + else: + groups[-1].append(para) + return groups + + +def plan_chunks(draft: str, per_chunk_chars: int) -> list[str]: + """把原稿装进若干块,尽量在场景边界切割。""" + groups = scene_groups(draft) + chunks: list[list[str]] = [] + current: list[str] = [] + size = 0 + for group in groups: + glen = sum(cjk_length(line) for line in group) + if current and size + glen > per_chunk_chars: + chunks.append(current) + current, size = [], 0 + current += group + size += glen + if current: + chunks.append(current) + return ["\n".join(c).strip() for c in chunks if "".join(c).strip()] + + +def _chunk_episode(episode: ep_mod.Episode, chunk_text: str) -> ep_mod.Episode: + """把这一块覆盖的史料事件挑出来,作为该块的素材。""" + years = [int(m.group(1)) for m in ANY_YEAR_RE.finditer(chunk_text)] + if not years: + return episode + low, high = min(years), max(years) + events = [e for e in episode.events if low <= e.year <= high] or episode.events + return replace(episode, events=events, start_year=low, end_year=high) + + +def _title_form(value: str) -> str: + """YETI → Yeti、EAGLE_MAN → Eagle_man:正文里模型就是这么写的。""" + return "_".join(part[:1].upper() + part[1:].lower() for part in value.split("_")) + + +def allowed_names(world: World, thread: Thread, episode: ep_mod.Episode) -> list[str]: + """本集史料里真实出现过的专名。 + + 这份名单既发给模型(事前防幻觉),也用于修复可疑专名(事后)。 + 实测:不给名单时模型造出了 "Farewell" 这个史料里不存在的地名。 + """ + names: set[str] = set() + heros, others = ep_mod.cast_of(world, thread, episode) + for fid in [*heros, *others, *thread.members]: + name = world.figure_name(fid) + if name: + names.add(name) + figure = world.figures.get(fid) + if figure and figure.race: + names.add(figure.race) + names.add(_title_form(figure.race)) + for event in episode.events: + for ref in world.render_refs(event): + value = ref.partition("=")[2].strip() + if value: + names.add(value) + return sorted(n for n in names if n) + + +def chunk_messages( + world: World, + thread: Thread, + episode: ep_mod.Episode, + total_episodes: int, + chunk_text: str, + chunk_target: int, + index: int, + count: int, + prev_tail: str = "", + elided: Counter | None = None, +) -> list[dict]: + spec = SPEC_FILE.read_text(encoding="utf-8") + material = volume.render_material(world, thread, episode, total_episodes, "", elided) + user = [ + material, + "", + f"### 这一块原稿(第 {index}/{count} 块,只写这一块的年代范围)", + "", + chunk_text, + "", + "### 这一块的 AI 味诊断(这些必须改掉)", + "", + deslop.digest(chunk_text), + "", + ] + if prev_tail: + user += ["### 上一块写到哪儿(用于承接状态,不要复述)", "", prev_tail, ""] + names = allowed_names(world, thread, episode) + if names: + user += [ + "### 允许使用的专名(史料里真实存在,逗号分隔;除此之外的人名地名一律不许写)", + "", + "、".join(names), + "", + "(上面提到的种族名可以当普通名词用,不要写成新角色。)", + "", + ] + user += [ + ( + f"### 要求:把这一块逐事件展开到 {chunk_target} 字左右,只输出正文。" + "不要写标题,不要重复上一块已经写过的内容,也不要跳到下一块的年代。" + ), + ] + return [{"role": "system", "content": spec}, {"role": "user", "content": "\n".join(user)}] + + +def _fit_length( + messages: list[dict], + text: str, + *, + floor: int, + ceiling: int, + target: int, + gateway=None, + model: str = LITERARY_MODEL, + max_repairs: int = 1, +) -> str: + """把长度拉进区间:太短继续展开,太长压缩(都不许动史实)。""" + for _ in range(max_repairs): + length = cjk_length(text) + if floor <= length <= ceiling: + break + ask = ( + f"只有 {length} 字,还差很多。继续展开:把这一段里还没落到现场的事件逐件写成场景," + f"补对白、动作、视角人物此刻的感知与算计,写到 {target} 字左右。" + "不许新增史料里没有的事件或专名,也不要用形容词灌水。" + if length < floor + else f"有 {length} 字,超了。删掉重复交代和没有功能的描写,压到 {ceiling} 字以内," + "冲突与转折的现场感要保留。" + ) + text = chat(messages + [{"role": "assistant", "content": text}, + {"role": "user", "content": ask}], + gateway=gateway, model=model, max_tokens=MAX_TOKENS_EXPAND).content + return text.strip() + + +def build_messages( + world: World, + thread: Thread, + episode: ep_mod.Episode, + total_episodes: int, + draft: str, + prev_tail: str = "", + elided: Counter | None = None, + target: int = EXPAND_TARGET_CHARS, +) -> list[dict]: + """整集一次写完时用(目标不长时):素材 + 原稿 + 诊断 + 字数目标。""" + spec = SPEC_FILE.read_text(encoding="utf-8") + material = volume.render_material(world, thread, episode, total_episodes, prev_tail, elided) + user = [ + material, + "", + "### 原稿(事实与事件顺序按它,写法不要学它)", + "", + draft.strip(), + "", + "### 原稿的 AI 味诊断(这些必须改掉)", + "", + deslop.digest(draft), + "", + ( + f"### 现在开始:按原稿的事件顺序逐件展开,写到 {target} 字左右" + f"({EXPAND_FLOOR}–{EXPAND_CEILING} 字以内),只输出正文。" + ), + ] + return [{"role": "system", "content": spec}, {"role": "user", "content": "\n".join(user)}] + + +def expand_episode( + world: World, + thread: Thread, + episode: ep_mod.Episode, + total_episodes: int, + draft: str, + prev_tail: str = "", + *, + gateway=None, + model: str = LITERARY_MODEL, + target: int = EXPAND_TARGET_CHARS, + elided: Counter | None = None, + max_repairs: int = 1, +) -> Normalized: + """大量扩写:分块展开 → 拼接 → 规范化标点。 + + 返回 Normalized,notes 里带上每块的实际字数,便于发现哪一块没写够。 + """ + # 标题不进正文块:否则模型会把 "# 守门人" 当正文写进去。最后再拼回开头。 + lines = draft.split("\n") + headings = [ln for ln in lines if ln.strip().startswith("#")] + body = "\n".join(ln for ln in lines if not ln.strip().startswith("#")).strip() + + length = cjk_length(body) or 1 + blocks = max(1, math.ceil(target / EXPAND_CHUNK_TARGET)) + chunks = plan_chunks(body, max(1, round(length / blocks))) + chunk_target = max(700, target // len(chunks)) + + parts: list[str] = [] + notes: list[str] = [] + for index, chunk in enumerate(chunks, 1): + prev_tail_text = parts[-1][-TAIL_CHARS:] if parts else prev_tail[-TAIL_CHARS:] + sub_episode = _chunk_episode(episode, chunk) + messages = chunk_messages(world, thread, sub_episode, total_episodes, chunk, + chunk_target, index, len(chunks), prev_tail_text, elided) + text = chat(messages, gateway=gateway, model=model, + max_tokens=MAX_TOKENS_EXPAND).content + text = _fit_length(messages, text, + floor=int(chunk_target * 0.75), ceiling=int(chunk_target * 1.35), + target=chunk_target, gateway=gateway, model=model, + max_repairs=max_repairs) + parts.append(text) + notes.append(f"第 {index}/{len(chunks)} 块:{cjk_length(text)} 字(目标 {chunk_target})") + + stitched = "\n\n".join(parts) + total_now = cjk_length(stitched) + + # 拼接后总量仍不足,再整篇催一次(只此一次,避免无限加长) + if total_now < EXPAND_FLOOR: + messages = build_messages(world, thread, episode, total_episodes, body, prev_tail, + elided, target) + stitched = _fit_length(messages, stitched, floor=EXPAND_FLOOR, ceiling=EXPAND_CEILING, + target=target, gateway=gateway, model=model, max_repairs=1) + + result = normalize(stitched.strip()) + result.notes = notes + result.notes + if headings: + result.text = "\n\n".join([*headings, result.text]) + return result + + +def literary_path(directory: Path, episode: ep_mod.Episode) -> Path: + """扩写稿的落盘位置:与原稿并列,便于 A/B 对照。""" + return directory / f"{episode.key}.literary.md" + + +NAME_FIX_SPEC = """你在做一件很窄的事:正文里出现了史料中不存在的专名(模型凭空造的人名、地名、称号)。 + +把包含这些专名的句子改掉: +- 优先换成「允许使用的专名」里真实存在、语义接近的名字; +- 没有合适的名字,就把句子改成不指名(例如把"把 Farewell 挡回去"改成"把下一个要拦的人挡回去")。 + +不许增删情节,不许改动没被点到的句子,不许把段落合并或拆分。 +只输出改好的段落,每段以 [序号] 开头,序号与段数必须和输入一致。不写任何解释。 +""" + + +_NUMBERED_RE = re.compile(r"^\s*\[(\d+)\]\s*(.+?)\s*$") + + +def _parse_numbered(raw: str) -> dict[int, str]: + """把模型返回的 `[n] 段落` 解析成 {序号: 段落},容忍跨行。""" + out: dict[int, str] = {} + current: int | None = None + buffer: list[str] = [] + for line in raw.split("\n"): + match = _NUMBERED_RE.match(line) + if match: + if current is not None: + out[current] = "\n".join(buffer).strip() + current = int(match.group(1)) + buffer = [match.group(2)] + elif current is not None and line.strip(): + buffer.append(line.strip()) + if current is not None: + out[current] = "\n".join(buffer).strip() + return {k: v for k, v in out.items() if v} + + +def repair_names( + world: World, + thread: Thread, + episode: ep_mod.Episode, + text: str, + suspects: list[Suspicion], + *, + gateway=None, + model: str = LITERARY_MODEL, +) -> Normalized: + """只重写含可疑专名的那几段,其他段落一字不动。 + + 整篇重写代价高(几千字)且模型会顺手改别的地方;段级手术安全得多—— + 段数对不上就不写回,宁可不改也不把全文换掉。 + """ + bad = {s.name for s in suspects} + paragraphs = text.split("\n") + targets = [i for i, para in enumerate(paragraphs) if any(name in para for name in bad)] + if not targets: + return normalize(text) + + numbered = "\n".join(f"[{n}] {paragraphs[i]}" for n, i in enumerate(targets, 1)) + messages = [ + {"role": "system", "content": NAME_FIX_SPEC}, + {"role": "user", "content": "\n".join([ + "### 允许使用的专名", "", "、".join(allowed_names(world, thread, episode)), "", + "### 史料里找不到的专名", "", + *[f"- {s.name}(出现 {s.count} 次)|例:…{s.context}…" for s in suspects], "", + f"### 需要修的段落(共 {len(targets)} 段)", "", numbered, "", + f"### 要求:只输出改好的 {len(targets)} 段,每段以 [序号] 开头。", + ])}, + ] + reply = chat(messages, gateway=gateway, model=model, max_tokens=MAX_TOKENS_EXPAND).content + fixed = _parse_numbered(reply) + + applied = 0 + for n, index in enumerate(targets, 1): + if n in fixed: + paragraphs[index] = fixed[n] + applied += 1 + + result = normalize("\n".join(paragraphs)) + result.notes = [f"可疑专名修复:改写 {applied}/{len(targets)} 段"] + result.notes + return result + + +def _hits(text: str) -> tuple[int, float, int]: + """返回(硬伤数、每千字硬伤密度、字数)。""" + hard = [h for h in deslop.check(text) if h.level == "hard"] + count = sum(h.count for h in hard) + length = cjk_length(text) + return count, count * 1000 / (length or 1), length + + +def compare(draft: str, literary: str) -> str: + """A/B 对照:字数与 AI 味硬伤密度。""" + rows = ["A/B 对照:"] + for label, text in (("原稿", draft), ("扩写稿", literary)): + count, density, length = _hits(text) + rows.append(f" {label:<8}{length:>6} 字|硬伤 {count:>4} 处({density:.1f}/千字)") + before = _hits(draft)[2] + if before: + rows.append(f" 篇幅倍数:{_hits(literary)[2] / before:.1f}×") + return "\n".join(rows) + + +def full_report(draft: str, literary: str) -> str: + """给 CLI 用:A/B 对照 + 两稿各自的诊断。""" + return "\n".join([ + compare(draft, literary), + "", + "— 原稿诊断 —", + deslop.report(draft), + "", + "— 扩写稿诊断 —", + deslop.report(literary), + ]) diff --git a/dfannals/factcheck.py b/dfannals/factcheck.py index dbb7a30..9651417 100644 --- a/dfannals/factcheck.py +++ b/dfannals/factcheck.py @@ -97,14 +97,19 @@ def _is_candidate(name: str, known: set[str]) -> bool: def check(text: str, world: World) -> list[Suspicion]: """返回可疑专名列表(按出现次数降序)。""" known = known_names(world) + # 大小写不该决定是不是幻觉:史料里是 YETI/EAGLE_MAN,正文写成 Yeti/Eagle_man + # 是同一件事(模型从种族字段学来的),不能报成凭空编造。 + folded = {k.casefold() for k in known} found: dict[str, list[str]] = {} for match in NAME_RE.finditer(text): raw = match.group(0) + if raw.casefold() in folded: + continue # 逐级回退:整串不认,就试着拆成更短的已知名字,减少误报 if not _is_candidate(raw, known): continue - if any(part in known for part in raw.split()): + if any(part.casefold() in folded for part in raw.split()): # 名字里有一部分是史料已知的(例如 "Urist the Bold"),不当作凭空编造 continue start = max(0, match.start() - 20) diff --git a/dfannals/legends.py b/dfannals/legends.py index bde8b25..19a2d22 100644 --- a/dfannals/legends.py +++ b/dfannals/legends.py @@ -45,12 +45,23 @@ class Figure: name: str = "" race: str = "" sex: int | None = None + caste: str = "" # 史料里的性别字段(v53 导出用 MALE/FEMALE) birth_year: int | None = None death_year: int | None = None profession: str = "" entities: list[int] = field(default_factory=list) sites: list[int] = field(default_factory=list) + @property + def gender(self) -> str: + """史料记载的性别,用于避免正文里默认称呼"他"。""" + c = (self.caste or "").upper() + if c == "FEMALE": + return "女" + if c == "MALE": + return "男" + return "" + @property def alive_span(self) -> str: # DF 用负数占位表示“无此项”(未记录/未死),不要当成真实年份显示 @@ -333,6 +344,7 @@ def _add_figure(world: World, elem) -> None: fig = world.figures.setdefault(fid, Figure(id=fid)) fig.name = fig.name or _first(d, "name") fig.race = fig.race or _first(d, "race") + fig.caste = fig.caste or _first(d, "caste") fig.profession = fig.profession or _first(d, "profession") if fig.sex is None: fig.sex = _int_or_none(_first(d, "sex")) diff --git a/dfannals/llm.py b/dfannals/llm.py index a921f81..d5d1a67 100644 --- a/dfannals/llm.py +++ b/dfannals/llm.py @@ -48,7 +48,7 @@ def chat( max_tokens: int = MAX_TOKENS_CHAPTER, temperature: float = 1.0, timeout: int = 900, - retries: int = 3, + retries: int = 5, ) -> Reply: gw = gateway or load_gateway() conn_cls, host, path = _endpoint(gw.base_url) @@ -75,7 +75,8 @@ def chat( raw = resp.read() if resp.status != 200: last = f"HTTP {resp.status}: {raw[:400].decode('utf-8', 'replace')}" - if resp.status in (400, 401, 403, 404): + # 4xx(除 429 限流)是请求本身的问题,重试无意义 + if resp.status < 500 and resp.status != 429: break else: data = json.loads(raw) @@ -101,6 +102,8 @@ def chat( finally: conn.close() if attempt < retries: - time.sleep(3 * attempt) + # 实测网关偶发 502 upstream_unavailable(尤其在大请求上), + # 所以 5xx/429/超时都退避重试,上限 60 秒。 + time.sleep(min(60, 5 * 2 ** (attempt - 1))) raise GatewayError(f"网关调用失败({gw.base_url},model={model}):{last}") diff --git a/dfannals/normalize.py b/dfannals/normalize.py new file mode 100644 index 0000000..9784172 --- /dev/null +++ b/dfannals/normalize.py @@ -0,0 +1,169 @@ +"""标点规范化:把模型爱用的半角标点改回中文排版。 + +模型(尤其是 `"`)经常直接吐 ASCII 引号。这既不符合中文排版, +也会让「到底有没有对白」这种统计失真——我第一次统计扩写稿对白时就被骗了 +(算出 0 处对白,实际有 32 组,只是引号是英文的)。 + +思路借自 oh-story-claudecode 的 normalize-punctuation.js(MIT),只保留本书需要的两条: +引号配对、CJK 之间的半角标点转全角。**破折号与省略号不转换**——本书正文禁用它们, +交给诊断器报警比偷偷替换更好。 +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +_CJK = r"[\u4e00-\u9fff]" +_HALF2FULL = {",": ",", ".": "。", "!": "!", "?": "?", ":": ":", ";": ";"} +_CJK_PUNCT = re.compile(f"({_CJK})([,.\u0021?:;])({_CJK})") + + +@dataclass +class Normalized: + text: str + notes: list[str] = field(default_factory=list) + + @property + def changed(self) -> bool: + return bool(self.notes) + + +def fix_quotes(text: str) -> tuple[str, int]: + """ASCII 直引号 → 中文弯引号“”。 + + 按出现顺序交替开合。史书正文里不会出现嵌套引号(对白里再说引语时 + 我们用「」),所以交替法够用;真出现奇数个也会被 deslop 诊断出来。 + """ + if '"' not in text: + return text, 0 + out: list[str] = [] + opening = True + for ch in text: + if ch == '"': + out.append("“" if opening else "”") + opening = not opening + else: + out.append(ch) + return "".join(out), text.count('"') + + +def fix_cjk_punct(text: str) -> tuple[str, int]: + """夹在两个汉字之间的半角标点 → 全角。不动英文专名里的标点。""" + total = 0 + while True: + text, n = _CJK_PUNCT.subn( + lambda m: m.group(1) + _HALF2FULL[m.group(2)] + m.group(3), text + ) + total += n + if not n: + return text, total + + +def _drop_separator_lines(text: str) -> tuple[str, int]: + """删掉模型自作的分隔线(`---`、`***`)。原稿不用它们,空行已经够。""" + kept, dropped = [], 0 + for line in text.split("\n"): + stripped = line.strip() + if stripped and set(stripped) <= {"-", "*", "=", "—", "·"}: + dropped += 1 + continue + kept.append(line) + return "\n".join(kept), dropped + + +_HEADING_RE = re.compile(r"^\s*#{1,6}\s*\S*\s*$") +_NUMERAL_RE = re.compile(r"^\s*(?:[一二三四五六七八九十]{1,3}|\d{1,4})\s*$") + + +def _drop_stray_markings(text: str) -> tuple[str, int]: + """删掉模型自作的小节标记:`## 一`、孤立的「七」。 + + 分块扩写实测:模型每写一块就自制一个编号小节(## 一 … ## 六), + 它们不是故事内容,留在正文里就是脏标记。 + 正文第一行标题(形如 `# 守门人`)要保留——那是原稿的标题。 + """ + lines = text.split("\n") + first_content = next((i for i, line in enumerate(lines) if line.strip()), None) + kept, dropped = [], 0 + for index, line in enumerate(lines): + if index != first_content and line.strip() and ( + _HEADING_RE.match(line) or _NUMERAL_RE.match(line) + ): + dropped += 1 + continue + kept.append(line) + return "\n".join(kept), dropped + + +def _collapse_duplicate_paragraphs(text: str) -> tuple[str, int]: + """相邻的完全重复段落只留一段(模型跨块写作时会重写上一块的结尾)。""" + kept, dropped, prev = [], 0, None + for line in text.split("\n"): + stripped = line.strip() + if stripped and stripped == prev: + dropped += 1 + continue + kept.append(line) + if stripped: + prev = stripped + return "\n".join(kept), dropped + + +def _collapse_duplicate_sentences(text: str) -> tuple[str, int]: + """段内连续重复的句子只留一句(模型爱把同一句复制两遍再往下写)。""" + total = 0 + out: list[str] = [] + for para in text.split("\n"): + if not para.strip(): + out.append(para) + continue + pieces = re.split(r"(?<=[。!?])", para) + kept: list[str] = [] + prev = None + for piece in pieces: + key = piece.strip() + if key and key == prev: + total += 1 + continue + kept.append(piece) + if key: + prev = key + out.append("".join(kept)) + return "\n".join(out), total + + +def _tidy_blank_lines(text: str) -> str: + """压缩多余空行、去掉行尾空白。""" + text = re.sub(r"\n{3,}", "\n\n", text) + lines = [line.rstrip() for line in text.split("\n")] + return "\n".join(lines).strip() + + +def normalize(text: str) -> Normalized: + """返回规范化后的文本与改动说明(不改字词,只改标点与结构性冗余)。""" + notes: list[str] = [] + + text, separators = _drop_separator_lines(text) + if separators: + notes.append(f"删除模型自作的分隔线 {separators} 行") + text, markings = _drop_stray_markings(text) + if markings: + notes.append(f"删除模型自作的小节标记 {markings} 处") + text, dup_paras = _collapse_duplicate_paragraphs(text) + if dup_paras: + notes.append(f"合并重复段落 {dup_paras} 处") + text, dup_sents = _collapse_duplicate_sentences(text) + if dup_sents: + notes.append(f"合并段内重复句子 {dup_sents} 处") + + text, quotes = fix_quotes(text) + if quotes: + notes.append(f"英文引号 {quotes} 个 → 中文引号") + if quotes % 2: + notes.append("警告:引号个数为奇数,可能有一处未闭合") + + text, punct = fix_cjk_punct(text) + if punct: + notes.append(f"汉字间半角标点 {punct} 处 → 全角") + + return Normalized(text=_tidy_blank_lines(text), notes=notes) diff --git a/dfannals/publish.py b/dfannals/publish.py index 10e568e..99714df 100644 --- a/dfannals/publish.py +++ b/dfannals/publish.py @@ -65,8 +65,30 @@ def ensure_repo() -> None: _git("config", "user.email", "df-annals@hajim1.art") +def _tracked_empty_sources() -> list[str]: + """已跟踪的源码/文档里,哪些是 0 字节。""" + suffixes = (".py", ".sh", ".lua", ".md", ".toml", ".json") + out: list[str] = [] + for rel in _git("ls-files").stdout.split(): + if not rel.endswith(suffixes): + continue + path = PROJECT_DIR / rel + if path.is_file() and path.stat().st_size == 0: + out.append(rel) + return out + + def commit_and_push(message: str, paths: list[Path] | None = None) -> str: - """提交并推送。返回提交哈希;无改动时返回空串。""" + """提交并推送。返回提交哈希;无改动时返回空串。 + + 提交前硬拦空文件:实测发生过一次 cli.py 被提交为 0 字节的事故—— + 而 cli.py 是唯一入口,于是远端仓库里的管道完全不可运行,且失败是静默的。 + 这种错误不能靠人复核,必须在提交口拦住。 + """ + empty = _tracked_empty_sources() + if empty: + raise RuntimeError("拒绝提交:以下已跟踪文件是空的 → " + "、".join(empty)) + if paths: for p in paths: _git("add", "--", str(p.relative_to(PROJECT_DIR)) if p.is_absolute() else str(p)) diff --git a/dfannals/volume.py b/dfannals/volume.py index 471ea2f..f6cb095 100644 --- a/dfannals/volume.py +++ b/dfannals/volume.py @@ -155,8 +155,8 @@ def render_material( for fid in heros: row = rows.get(fid) if row: - lines.append(f"- {row.name}|{row.race}|{row.span}|身份:{row.identity or '不详'}" - f"|本集出场 {row.appearances} 次") + lines.append(f"- {row.name}|{row.race}|性别:{row.gender or '史料未载'}" + f"|{row.span}|身份:{row.identity or '不详'}|本集出场 {row.appearances} 次") else: lines.append(f"- {world.figure_name(fid)}") @@ -173,8 +173,8 @@ def render_material( for fid in others[:6]: row = rows.get(fid) if row: - lines.append(f"- {row.name}|{row.race}|身份:{row.identity or '不详'}" - f"|本集出场 {row.appearances} 次") + lines.append(f"- {row.name}|{row.race}|性别:{row.gender or '史料未载'}" + f"|身份:{row.identity or '不详'}|本集出场 {row.appearances} 次") else: lines.append(f"- {world.figure_name(fid)}") @@ -196,6 +196,7 @@ def render_material( "", f"- 本集 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,中文正文,专名保留英文。", "- 贴着主角写:他们的目标、算计、得失做主语;对手要是个具体的人。", + "- 性别按上面卡片写,不要默认“他”;史料未载性别时用名字或身份称呼。", "- 史料之外的世界大事不要写;只有影响到主角时才提一句。", ] return "\n".join(lines) diff --git a/notes/switched-threads.md b/notes/switched-threads.md new file mode 100644 index 0000000..a06c3a6 --- /dev/null +++ b/notes/switched-threads.md @@ -0,0 +1,28 @@ +# 被放弃的线索 + +记录每一组挑出来、但写不成故事的线索,以及放弃原因。由 `dfannals/gate.py` 在换线时自动追加。 + +判定流程:一集写完后按 5 个维度自评(主角目标、冲突升级、真正转折、对手具体、超越战斗计数), +总分 <12 判"流水账" → 先重写一次 → 仍不合格就放弃这条线索并在此登记。 + +--- + +(暂无:第 1 卷选中的线索一次通过闸门,尚未放弃任何线索。) + +## Mon Sagus|Guspu Frillyknots、Ral Fastenhatchets、Alath Blottedmine + +- 线索跨度:193–239(46 年) +- 放弃原因:重写 1 次后仍未过闸门(第 1 条线索) +- 自评:判平淡 总分 11/25(主角目标=2、冲突升级=2、真正转折=2、对手具体=3、超越战斗计数=2)|最弱处是通篇按年份罗列事件摘要,同类博弈反复出现,缺少场景内的因果推进与真正转折。 + +## Mon Sagus|Atera Helpedshins、Ave Raintapers、Amoya Flutesquirted、Eriya Pinesdaubs、Rathe Tundrabody、Ameli Aquaprairies + +- 线索跨度:38–247(209 年) +- 放弃原因:重写 1 次后仍未过闸门(第 2 条线索) +- 自评:判平淡 总分 9/25(主角目标=2、冲突升级=1、真正转折=2、对手具体=2、超越战斗计数=2)|通篇按年份罗列胜负,冲突停在结果层面,缺乏逐级升级和明确主动选择。 + +## Mon Sagus|Obler Distanceweights、Ana Swamppuzzling、Hathur Tempestmeeting、Lesno Copperflare、Rayali Rockflag、Xaki Lawbutters + +- 线索跨度:160–217(57 年) +- 放弃原因:重写 1 次后仍未过闸门(第 3 条线索) +- 自评:判平淡 总分 8/25(主角目标=2、冲突升级=1、真正转折=2、对手具体=1、超越战斗计数=2)|冲突逐年重复“没被邀请/输/听说”,对手只是名字几乎全无动机。 diff --git a/output/volumes/v1/cast.md b/output/volumes/v1/cast.md index fab3d10..a57e559 100644 --- a/output/volumes/v1/cast.md +++ b/output/volumes/v1/cast.md @@ -1,37 +1,37 @@ # Mon Sagus · 人物表 本卷主角线索:Guspu Frillyknots、Ral Fastenhatchets、Alath Blottedmine -线索跨度:193–239(46 年) +线索跨度:181–248 -| 人物 | 种族 | 生卒 | 身份 | 本卷出场 | 角色 | -|---|---|---|---|---|---| -| Guspu Frillyknots | EAGLE_MAN | 151–246 | 关系对象×13、构陷发起者×4 | 62 | 主角 | -| Ral Fastenhatchets | DWARF | 164–在世/不详 | 被针对者×21、受骗者×6 | 28 | 主角 | -| Alath Blottedmine | DWARF | 182–在世/不详 | 构陷发起者×4、关系对象×1 | 8 | 主角 | -| Tulon Tongswall | DWARF | 132–在世/不详 | 构陷发起者×3 | 3 | 对手/配角 | -| Moldath Mirroredfloors | DWARF | 79–在世/不详 | 构陷发起者×3 | 3 | 对手/配角 | -| Ineth Wheeleddrinks | DWARF | 165–在世/不详 | 构陷者×3 | 3 | 对手/配角 | -| Langgud Tradebears The Eviscerated Chill Of Pregnancies | YETI | 生卒不详 | — | 2 | 对手/配角 | -| Rigoth Stilledearthen | DWARF | 162–在世/不详 | 当事者×2 | 2 | 对手/配角 | -| Perom Crazeowned | HUMAN | 129–在世/不详 | 当事者×1、关系对象×1 | 2 | 对手/配角 | +| 人物 | 种族 | 性别 | 生卒 | 身份 | 本卷出场 | 角色 | +|---|---|---|---|---|---|---| +| Guspu Frillyknots | EAGLE_MAN | 男 | 151–246 | 关系对象×13、构陷发起者×4 | 62 | 主角 | +| Ral Fastenhatchets | DWARF | 女 | 164–在世/不详 | 被针对者×21、受骗者×6 | 28 | 主角 | +| Alath Blottedmine | DWARF | 男 | 182–在世/不详 | 构陷发起者×4、关系对象×1 | 8 | 主角 | +| Tulon Tongswall | DWARF | 男 | 132–在世/不详 | 构陷发起者×3 | 3 | 对手/配角 | +| Moldath Mirroredfloors | DWARF | 女 | 79–在世/不详 | 构陷发起者×3 | 3 | 对手/配角 | +| Ineth Wheeleddrinks | DWARF | 男 | 165–在世/不详 | 构陷者×3 | 3 | 对手/配角 | +| Langgud Tradebears The Eviscerated Chill Of Pregnancies | YETI | 男 | 生卒不详 | — | 2 | 对手/配角 | +| Rigoth Stilledearthen | DWARF | 男 | 162–在世/不详 | 当事者×2 | 2 | 对手/配角 | +| Perom Crazeowned | HUMAN | 男 | 129–在世/不详 | 当事者×1、关系对象×1 | 2 | 对手/配角 | ## 小传 -**Guspu Frillyknots** —— 鹰人 Guspu Frillyknots(151–246)本卷出场六十二次,关系对象达十三位;193年对 Ral Fastenhatchets 的腐化失败后,次年被 The Volcano of Ravens 定罪。 +**Guspu Frillyknots** —— 鹰人Guspu Frillyknots以六十二次出场与十三次关系对象记录高踞本卷,193年在Fordedwinds对Ral Fastenhatchets构陷未遂,次年被The Volcano Of Ravens定罪。 -**Ral Fastenhatchets** —— Ral Fastenhatchets 于181年与 Langgud Tradebears The Eviscerated Chill Of Pregnancies 在 Fordedwinds 两度交手,此后长期被当作腐化与栽赃的靶子;本卷所载针对他的阴谋均告失败,但他仍留下六次受骗记录。 +**Ral Fastenhatchets** —— 矮人Ral Fastenhatchets是本卷最执拗的靶子:181年与Langgud Tradebears The Eviscerated Chill Of Pregnancies在Fordedwinds两度交手,此后Guspu、Tulon、Alath、Moldath与Ineth轮番构陷或诬告,均未得逞;她与Rigoth Stilledearthen的关系于219年解除。 -**Alath Blottedmine** —— Alath Blottedmine 于229年与 Sina Watchedleads 建立关系,同年对 Ral Fastenhatchets 腐化失败;230年又与 Artuk Huggedfastened 建立关系,并在 Paperhushed 对 Momuz Decentpage 下手未遂,随即被 The Volcano of Ravens 定罪。 +**Alath Blottedmine** —— 矮人Alath Blottedmine于229年对Ral Fastenhatchets、230年对Momuz Decentpage构陷未遂,并与Sina Watchedleads、Artuk Huggedfastened有过关系记录;230年被The Volcano Of Ravens定罪。 -**Tulon Tongswall** —— Tulon Tongswall 在193、195、197年三次于 Fordedwinds 试图腐化 Ral Fastenhatchets,结果三次全部失败,除此之外本卷无话可说。 +**Tulon Tongswall** —— 矮人Tulon Tongswall在193、195、197年连续三次于Fordedwinds构陷Ral Fastenhatchets,三次全败,出场也仅此三回。 -**Moldath Mirroredfloors** —— Moldath Mirroredfloors 在233年两度、234年一度于 Fordedwinds 对 Ral Fastenhatchets 下手,三次腐化全告失败。 +**Moldath Mirroredfloors** —— 矮人Moldath Mirroredfloors在233年两度、234年一度于Fordedwinds构陷Ral Fastenhatchets,均告失败,算本卷坚韧而无效的对手之一。 -**Ineth Wheeleddrinks** —— Ineth Wheeleddrinks 在245、247、248年三次构陷 Ral Fastenhatchets,三次均是 failed frame attempt,且 fooled_hfid 三次都写成 Ral Fastenhatchets。 +**Ineth Wheeleddrinks** —— 矮人Ineth Wheeleddrinks在245、247、248年三次对Ral Fastenhatchets发起诬告,均告失败,The Volcano Of Ravens始终未予采信。 -**Langgud Tradebears The Eviscerated Chill Of Pregnancies** —— Langgud Tradebears The Eviscerated Chill Of Pregnancies 在181年与 Ral Fastenhatchets 在 Fordedwinds 打过两场,此后本卷再无其他事迹。 +**Langgud Tradebears The Eviscerated Chill Of Pregnancies** —— 雪人Langgud Tradebears The Eviscerated Chill Of Pregnancies于181年在Fordedwinds与Ral Fastenhatchets两度交手,生卒不详,除了名字长到令人侧目外只留下这两条战斗记录。 -**Rigoth Stilledearthen** —— Rigoth Stilledearthen 于188年与 Ral Fastenhatchets 建立联系,219年又解除该联系;两度出场仅为此事。 +**Rigoth Stilledearthen** —— 矮人Rigoth Stilledearthen于188年与Ral Fastenhatchets建立关系,219年解除,两条记录便写尽他在本卷的起落。 -**Perom Crazeowned** —— Perom Crazeowned 于193年成为 Guspu Frillyknots 的关系对象,202年又被 Guspu 反向列为关系对象,戏份仅此而已。 +**Perom Crazeowned** —— 人类Perom Crazeowned在193年与Guspu Frillyknots建立关系,202年又添一笔,是鹰人关系网中露面有限的配角。 diff --git a/output/volumes/v1/chapters/001-181-198.literary.md b/output/volumes/v1/chapters/001-181-198.literary.md new file mode 100644 index 0000000..dd29993 --- /dev/null +++ b/output/volumes/v1/chapters/001-181-198.literary.md @@ -0,0 +1,389 @@ +# 守门人 + +181年的Fordedwinds还没有名字。 + +那条隘口夹在两片黑石崖之间,冬季的河已经结死,冻得发青的冰面一直铺到山脚。Ral Fastenhatchets第一次踩上那道冰面的时候,脚下打了一个趔趄,铁头靴的底沿磕在石头茬子上,磕出一声闷响。她没抬头,伸手摸了一把刃口,把斧头从背上换到左手,顺着风向往前挪了两步。风向不对。从北面谷口直灌进来的冷气不自然,带着一股湿漉漉的腥味,像是某种大块头的活物刚在那儿蹲过不久。她把斧柄往掌心里攥紧了一点。 + +那个东西就在路口正中。 + +两倍高。四层高的。一个Yeti。肩宽差不多能塞下她两个,灰白的长毛从肩胛一直垂到脚踝,每一根都结着霜。它坐在那儿,一条腿曲着,另一条直直伸出去,整个身子像是长在这块石头上。Langgud Tradebears The Eviscerated Chill Of Pregnancies。这名字是从哪儿听来的她记不清了——也许是哪个从南边林子里活着爬出去的猎人。她在意的是另一件事:这东西挡在Fordedwinds的入口,把南来北往的小路堵死了。 + +她没退。 + +她可以退回矿洞,退回那帮矮人的巷子里,把斧头挂上墙,告诉自己这事不该她管。矿洞里还有热汤,铁锅架在石灶上咕嘟响,老矿工还能在桌边骂上半夜的山。可她没走。她在隘口外二十步的地方站住了,把斧头从左手换回右手,刃口朝前。Langgud Tradebears的呼气从那团灰白的毛里一下一下地喷出来,每一次喷出去都能在它面前凝出一小片白雾。她感觉自己胡子尖上开始挂霜了。 + +“让开。” + +她说。声音从喉咙里挤出来,被冷空气一激,哑了半截。Yeti没动。她又往前走了一步,铁头磕在冻硬的泥地上,叮地响了一声。Yeti这才抬眼。两颗黑眼珠嵌在灰白毛里,慢悠悠地落到她脸上,没有立刻往上抬,也没有立刻往下移。 + +“让开。”她又说了一遍。这一次她没看Yeti的脸,看的是它前爪搭着的那块石头。那块石头底下压着半截骨头,骨头边上还冻着一摊发黑的什么,已经冻成冰茬子。这是上一个从这儿过的东西,或者上一个没能过去的人。她把目光从骨头上挪开,挪到Yeti的前爪边上。前爪指节上套着一圈磨秃的皮,皮上刻着一个符号,她认不出来。 + +Langgud Tradebears The Eviscerated Chill Of Pregnancies没说话。它只是把那只前爪往石头上挪了挪,指节咔地响了一声。这是回答。不是答应,也不是拒绝,是告诉她:这块石头我占着,你拿来动它试试。 + +她没动斧头。她从腰后抽出那块她揣了整个早晨的石头,顺手丢到了路上。石头落在冰面上,滑出去两尺,撞上Yeti伸出的那只脚的脚背,咣地响了一声。Yeti低头看那块石头,又抬头看她。她站着,没弯腰,没退,铁头杵在脚边。她不打算解释这块石头是什么意思,不打算解释她为什么不打算退。她只是站着。 + +Yeti先动了。它把那条直着的腿收回来,整个身子往边上挪了半步。就半步。它侧过半边身子,前爪还搭在那块石头上,没让出整条路。它让出了她能侧身挤过去的那一点空隙。 + +她没挤。 + +她从那块石头上迈了过去,铁头靴的底沿磕在Yeti前爪的指节上,磕得指节上的皮圈咯吱响了一声。Yeti的毛抖了一下,没拍下来。她走过去了。她没回头看。她知道如果她回头看,那只前爪会在她后脑勺上拍一下,把她的头连骨带捅进冰里。她一直走到隘口另一头才停下,铁头杵在脚边,呼了一口长气。气从嘴里喷出来,在她面前凝成一小团白雾。 + +她赢了,还是她输了?她站在隘口另一头想了很久。风从她身后灌进来,吹得她后颈发凉。Yeti没追上来。她回头看了一眼。Langgud Tradebears The Eviscerated Chill Of Pregnancies还坐在那块石头上,前爪还搭着,没挪窝。它只是坐在那儿,呼出的气一下一下地喷在冰面上。 + +她不打算再退回去了。 + +她转身往南走,走了几步,又停下。她转回去,走回那块她丢过去的石头边上,蹲下,把石头从冰面上抠起来。石头上的冰被她抠掉一层,露出下面灰白的原色。她把石头搬到隘口正中,竖在路口那儿,竖得稳稳当当。这是凭证。要过的人留在这块石头边上。她没说这是给谁立的规矩,没说这块石头怎么过她才放人过,她只是把石头竖在那儿,转身走了。 + +她走回隘口另一头的时候,回头看了一眼。Langgud Tradebears The Eviscerated Chill Of Pregnancies还坐在那块石头上,前爪还搭着,没挪窝。它只是坐在那儿,呼出的气一下一下地喷在冰面上。 + +隘口那一夜之后过了多久,Ral Fastenhatchets没去算。她只记得再回Fastenhatchets那一带时,山道上的冰已经开始化。她从隘口那头的石块边走过,没停。回去是要带东西的,不是要空手把命交代在那儿。 + +Rigoth Stilledearthen是在开春第三周找上来的。 + +他出现在Fastenhatchets南边那条石阶路口的时候,Ral正在跟一个过路的小贩算盐钱。小贩看见这老矮人背着个木匣子从坡上下来,主动让了半步。Rigoth绕过小贩,没看Ral手里那几块粗盐,直接站到她跟前。 + +“Ral Fastenhatchets。”他说。 + +他没报自己的名字。他把木匣子解下来,搁在两人之间的石阶上。石阶那几块石板被盐卤浸得发白,木匣子搁上去没响。 + +“你记不记得Rockjaw那一支还欠我两车铁。”他说。 + +“记。”Ral说。 + +“那账我不要了。” + +“你要什么。” + +Rigoth把木匣子翻开。里头是一本账册,封皮磨得起了毛边,几页纸露在外头,被风掀得一翘一翘的。 + +“我替你记。”他说。“每一笔过路钱,每一笔盐,每一笔铁。你不在的时候替你收,你回来了我把账交给你。我不要工钱。” + +Ral伸手拨了一下那本账册的封皮。封皮上的字已经认不清了,是上一个主人写的,墨渗进了皮里。她把手缩回来。 + +“为什么不要工钱。” + +“我老了。”Rigoth说。“搬不动铁了。” + +Ral看了一眼他的手。这老矮人的手背上青筋鼓得很高,指节却瘦得凹下去,是那种搬了一辈子东西又突然不搬的手。她没点破。 + +“进来。”她说。 + +Rigoth把木匣子合上,抱起,跟在她后头,进了门。 + +Rigoth住进来的头一周,Ral只让他在仓房那头坐着。Rigoth把木匣子搁在膝头,账册摊开,谁过路他就抬头看一眼,谁没过来他就低头抄。Ral走过几次,看见他记的和自己心里那笔对得上,没说话。 + +第二周她把钥匙给了他一把。 + +“粮仓后门。”她说。“夜里有人敲三下再开。” + +Rigoth点头。 + +第三周的时候Ral问了一句:“Fordedwinds那头的市集,这阵子谁去过?” + +Rigoth翻了翻账册。“没人。”他说。“我记了,没一个Fastenhatchets的去过。” + +Ral没接。Rigoth在她背后说了一句: + +“我听见说,那头近来来了个鹰人。” + +Ral停了一步,没回头。 + +“鹰人。”她说。 + +“嗯。”Rigoth说。“说他不住店,不买货。” + +Ral这才回过头。 + +Fordedwinds的市集开在两块大石之间。Ral去的那个早上天没下雨,页岩上头还有前一晚冻住的薄冰。她一个人去的,斗篷兜帽压低,从东头挤进去。 + +她先看见了Momuz Fateddie。 + +Momuz是她从小的邻居,比她小两岁。Ral离开Fastenhatchets那几年,粮仓就是Momuz替她看着的。Momuz看见Ral,眼里先是一愣,然后笑了一下,从摊子后面绕出来。 + +“你怎么来了。”Momuz说。 + +“路过。”Ral说。 + +Momuz把手上那把秤搁下。秤杆上还挂着一小截麻绳,是刚才称完什么东西没收拢的。她抬手把那截麻绳绕到秤杆头上,动作很熟。Ral看了一眼那个动作,没说话。 + +“我去给你倒碗热的。”Momuz说。 + +“不用。”Ral说。“你忙。” + +Momuz站了一会儿,又回去守她的摊子。Ral挪到了市集东头一个卖皮子的摊子后头,那里有一块半截石墩,正好能坐。她坐下,把斗篷拢紧,目光落在Momuz那摊子前头过路的人身上。 + +鹰人是傍晚那阵出现的。 + +他从市集西头过来,步子不紧不慢。市集里人挤,矮人、人类、几个半大的孩子挤在摊子之间,他一进来就显眼。不是因为他高。是因为他走路不带声响,落地的时候脚尖先点地,然后才把整个脚掌放下来,那是他那族走路的样子。他穿着一件深褐色的短衣,腰上没挂刀,背上一只长弓,弓弦松着。 + +Ral看着他。 + +他没去任何一个摊子。他绕过卖皮子的,绕过卖铁器的,绕过卖腌菜的,一直走到Momuz那摊子前。 + +Momuz抬起头。 + +那之后几天,Ral每逢Fordedwinds开市就去,去就坐在那块半截石墩上。她不和Momuz说话,也不和那鹰人说话。 + +那鹰人叫Guspu Frillyknots,是Rigoth替她打听出来的。Rigoth没进市集,他在市集外头的茶棚子里坐着,谁从里头出来他就跟谁搭两句。第五天他带回这个名字。 + +“Guspu Frillyknots。”Rigoth说。“不是商队的人,也不是哪家请的护卫。他自己一个人。” + +“他找Momuz做什么。”Ral说。 + +“说话。”Rigoth说。“我隔着问了几个人,都这么说。没见成交过什么。” + +Ral把这句话在嘴里嚼了嚼。一个不住店不买货的鹰人,跑到市集上,就为了和一个看粮仓的矮女人说话。 + +“他说什么。”Ral问。 + +Rigoth摇头。“问不出来。Momuz不跟人说。” + +Ral看了Rigoth一眼。Rigoth把账册合上。 + +“我去盯着。”Rigoth说。 + +“不用。”Ral说。“你回去把盐账结了。” + +Rigoth走后,Ral在屋檐下站了很久。她没再回市集。 + +再见到Momuz是半个月后。Momuz自己回了一趟Fastenhatchets,说是要清点粮仓后头那几口旧缸。Ral陪她走到后院,看她蹲在缸边用指头敲,敲出空响的就摇一下头。 + +“那头市集。”Ral说。“那个鹰人。” + +Momuz敲缸的手停了一下。她没抬头。 + +“Guspu。”Momuz说。 + +“嗯。” + +“他是我朋友。”Momuz说。 + +Ral没接。Momuz直起腰,把手上的灰在围裙上蹭了两下。 + +“你问这个做什么。”Momuz说。 + +“你是我看粮仓的。”Ral说。“那头来了个我不认识的人。” + +“我不认识的人多了。”Momuz说。“卖皮子的那个我也不认识。” + +Ral没说话。Momuz把缸盖合上,抱起,往仓里走。走到门口她回头说了一句。 + +“Ral。”她说。“我自己的事。” + +Ral点了点头。 + +Rigoth是冬天头一场雪之后把那句话带回来的。他在仓房后头等Ral,账册翻开在膝头,纸页被风吹得一张一张翻过去,他用手按住。 + +“结契了。”他说。 + +“谁。” + +“Guspu Frillyknots和Momuz Fateddie。”Rigoth说。“鹰人那边的契。市集东头那个卖皮子的看见了。” + +Ral把账册从他手里接过来。她念了一遍那几个字,又念了一遍。 + +Guspu Frillyknots。Momuz Fateddie。 + +她把账册合上,搁回Rigoth膝头。Rigoth看着她,等她说点什么。 + +Ral站了一会儿。 + +“她自己的事。”她说。 + +Rigoth没再问。 + +隘口的矮桌是Guspu Frillyknots自己带来的。他把四条腿掰开,桌面放平在两块石墩之间,离Ral守的那条路只差两步。来人过的时候都能看见桌面上铺开的东西:一卷盖满章的羊皮纸,三只小陶碟,碟里是干得发硬的奶酪和一小堆切成方块的熏肉,一壶还没开封的矮麦酒。他把东西一样一样摆出来,摆完便坐到桌后,从腰间抽出一根骨签剔牙。 + +Ral从岗哨那边走过来时,他没抬头。等她在桌对面站定,他才把骨签收起来,拿袍袖擦了一下嘴角,抬眼看她。他眼睛是淡黄的,在冬天的光里显得更浅,瞳孔边上有一圈很细的金线。他先说话,声音从喉咙高处滑下来,滑得很慢,像怕风把它吹散: + +“你守着一条路,却没有保护者。” + +Ral没坐。她站在桌边,左手扶着桌沿,右手还戴着半截皮手套,没脱。那只手指尖发黑,刚从账房那边过来,碰过墨。她没去碰那卷羊皮纸。羊皮纸的封蜡是红的,盖了三枚章,她认得出其中一枚是Ongrun城里某个议事厅的戳,另一枚她没见过。 + +“我可以让Fordedwinds免受任何骚扰。”Guspu说。他把“骚扰”两个字说得很轻,像说“风”。“只需要你在通行税里分三成给我。三成,不多。我替你挡掉的事,比这三成值钱得多。” + +Ral看了一眼那三只陶碟。碟边沿磕掉了一小片,奶酪上落了一点灰。她没碰它们。 + +“那三成,”她说,“是给你,还是给你后面的人?” + +Guspu笑了一下。那种笑法是从嘴角先动的,眼底不跟。他把骨签又抽出来,在指间转了半圈,又收回去。 + +“你不问,”他说,“我就不说。这是体面。” + +Ral把那只皮手套慢慢褪下来,搁在桌沿上。她用空出来的手把那卷羊皮纸推回Guspu那一侧。纸卷在桌面上滚了半圈,撞上陶碟,碟里的熏肉方块震了一下,有一块滑到碟外,落在桌面。 + +“我买过你的体面吗?”她说。 + +Guspu没去扶那块肉。他看着Ral,慢慢把那卷羊皮纸拢回自己这边,卷好,塞回怀里。他没说行,也没说不行。他站起身,把矮麦酒那壶也拎起来,走了。走的时候脚步很轻,靴底像是垫了一层皮。 + +那壶酒他没开封,也没留下。 + +来试Ral的不止Guspu一个。同一年,Tulon Tongswall也找上门来。Tulon是矮人,身材比Guspu矮半头,肩却很宽,走起路来两侧带风。他没带矮桌,也没带羊皮纸,只带了一个人——他自己的随从,随从手里捧着一只铁匣,匣里是码得整整齐齐的铜锭。 + +Tulon把Ral堵在隘口的石墙下。他开口比Guspu直白得多。 + +“三成五。”他说。他站在阴影里,声音从胸腔里压出来,比Guspu那种滑下来的声调重得多。“我替你拦下来的人,比那位爷能叫来的多。价钱也公道。” + +他身后那个随从把铁匣盖打开,让Ral看。铜锭在阴天的光里发暗,颜色像干了的血。 + +Ral没看那只铁匣。她看的是Tulon的靴子。靴面上有泥,泥是红的,从隘口外的坡道上带回来的。她认得那种土。 + +“这三成五,”她说,“是给我,还是给你后面那一串?” + +Tulon的脸动了一下。他没笑。他把铁匣盖合上,示意随从退后一步。 + +“你要是问这个,”他说,“我就没法跟你谈。” + +“那就别谈。”Ral说。她转身往岗哨那边走。走到墙根时,她听见Tulon在身后补了一句,声音还是那种压得很低的腔调: + +“下次来,可就不是铜了。” + +Ral没回头。她回到岗哨屋里,把门掩上,在桌前站了一会儿。桌上摊着她自己的账册,账册里记的是这一个月的通行数和税。她把Tulon的名字写进另一本册子的封皮内侧,写完又划掉,重新写。划掉的那道墨痕她没擦,留着。 + +之后她让人把Rigoth叫来。 + +Rigoth来的时候天还没全黑,岗哨屋里的油灯点了一盏,火苗缩在灯芯上,一跳一跳的。Ral把两只册子都推到他面前。 + +“以后,”她说,“凡是有外头的人来找我,不论开口的是谁,手里拿的是纸、是铜、还是别的,你都给我记下来。找我的时辰,在哪找的,带了什么,说了什么,一句都不许漏。” + +Rigoth把册子翻开,看了一眼第一页。他没问为什么。他把墨瓶推到自己这边,又从怀里摸出一支秃头的鹅毛笔。 + +“连威胁也记?”他问。 + +“连威胁也记。”Ral说。 + +Rigoth点了点头,低头在第一页顶上写了日期,写完停了一下,又在日期下头加了三个小字:Guspu,Tulon。他写完把笔搁下,抬头看Ral。 + +Ral站在灯边。她脸上没多余的表情,眼底那点东西也压得很平。她看着Rigoth手里那支秃笔,看了一会儿,说: + +“我不信他们是单独来的。” + +Rigoth没接话。他把册子合上,夹在腋下,站起身往外走。走到门口他停了一下,回头: + +“那Falcon那边呢?” + +Falcon那边指的是她自己这一族的议事。Ral把手套重新戴上,拉了拉指根。 + +“先记,”她说,“先别送过去。等他们自己来问。” + +Rigoth“嗯”了一声,出了门。门在风里晃了两下,最后贴上门框,把那点灯光夹在屋里,没让它漏出去。 + +194年的冬天比往年早。 + +Ral Fastenhatchets揣着账册往本家议事堂走的时候,巷子里的冻土还在前一年的积雪下面硬着,踩上去鞋底能听见碎响。她没穿厚衣服,矮个子在风里走快了反而不冷,鼻尖和耳廓冻得发木。账册是本家这些年的进出,从The Volcano Of Ravens分出来的Fordedwinds每一笔都记在上头。她要的不是议事堂,是议事堂后头那道矮门,矮门里头连着鹰人审判庭。鹰人不审本家的人,但账册上的事能定罪,定罪了本家才能动。 + +矮门前站着两个鹰人,看了她一眼,没拦。Ral把账册递过去,说:“请把这份呈给审判庭。立案的话,被告是Guspu Frillyknots,罪名是他试图收买Fordedwinds的守门人。”她停了一下,又说:“这里记了每一次。” + +审判庭不大,木头的长椅,旧挂毯。Ral坐在证人席的椅子上,椅背硬,靠上去肩胛骨硌得慌。她没靠实,只挨着椅子边坐。Guspu Frillyknots被两个鹰人架着带进来。他没戴枷,矮凳上坐下,手搁在腿边,看了她一眼。Ral没避开。她把账册里自己圈出来的几页递给主审的鹰人,说:“第一页是他许给守门人的东西,第五页是守门人交回来的东西,第十一页是他第二次让人转交给我的话。”主审翻到第十一页,问:“这是你的笔迹?”Ral说:“是他让人转交的。我没回。”主审又问:“你为什么记下来?”Ral说:“因为他不只做了一次。” + +判决很快。Guspu Frillyknots构陷守门人成立。鹰人把Guspu往门口带,门还没关,门被人从外头撞开,Momuz冲进来。 + +Momuz比Ral高半头,冲进来的时候差点撞上带人的鹰人。她退了一步,绕过鹰人,冲Ral喊:“你为什么不先跟我说?”声音在审判庭里撞了几个来回,比平常高出许多。 + +Ral从证人席上下来,看了看Momuz,看了看门口的鹰人,又看了看被架着的Guspu。她走近Momuz一步,说:“你早替他说话了。” + +Momuz的手抬起来,又放下。她看了看Ral,看了看门口的Guspu,没再说话,转身就往门外走。走到门口停了一下,没回头,又接着走。门在她身后合上,木头碰木头,沉闷的一声。 + +Ral站在审判庭里,看着合上的门。她知道这门一关,她们十几年的交情从今天起就变了。往外走的时候手还扶着账册的封皮,封皮上有茶渍,那是Momuz前天坐她家喝茶时洒的。她把账册收进袖筒里,没擦。 + +她以为定罪能结束这一切。 + +Guspu Frillyknots在审判庭关了没几天就被放了出来。Ral是从Fordedwinds守门人那里听说的。她没去问为什么。第二天本家议事里她提了一句:“Guspu Frillyknots回了Fordedwinds。”没人答。议事散了,她一个人坐了一会儿,把账册里Guspu那一页折了个角。 + +195年开春,Fordedwinds的守门人又来找她。守门人是本家的老人,比Ral大四十岁,没进Ral的屋,站在门口说:“Guspu Frillyknots又让人送东西来了。是Momuz送的。一个箱子,还有口信。”Ral问:“口信说什么?”守门人说:“让你‘先收一笔’。”Ral说:“箱子你收着,我不进门。” + +守门人走了。她关上门,背靠着门站了一会儿。箱子里的东西她不看,Guspu让人转交的东西她从来不看。她知道里头是什么,知道里头是什么的时候,看不看都一样。 + +Momuz那天晚上来找她。 + +Momuz进Ral的屋,没坐,站在桌边,把Guspu给她的箱子又提了过来。箱子不大,盖子是木头的,没上锁。Momuz把箱子往Ral这一侧推了推,说:“他让我带给你的。”Ral没看箱子。她抬头看Momuz的脸。Momuz的脸比白天更白,下巴绷着,嘴唇干。Ral说:“你转告他,他越这样,我越不会松手。” + +Momuz的手从箱子上挪开,搁到自己腿上。她低着头,看着自己搁在腿上的手,看了很久,然后说:“他救过我弟弟的命。我还不掉。” + +Ral的嘴张了一下,又合上。她看着Momuz肩塌着、下巴抵着锁骨的样子,知道Momuz没骗她。Guspu Frillyknots那年从流民里把Momuz的弟弟救出来是真的,Momuz记了十几年也是真的。她以前只知道Guspu用钱砸她,现在才知道Guspu用别的东西砸Momuz。 + +Momuz没等她答,提起箱子就往外走。Ral跟着走到门口,看着Momuz的背影穿过巷子。Momuz走得不快,背影被巷口的灯拉得很长,又被巷子收短。Momuz走远,她关了门。 + +她第一次动摇,不是因为钱,是因为Momuz低头的样子。关了门,没点灯,在黑里站了一会儿,又把门打开,让灯透进来。她站在门里,把账册里有关Momuz弟弟那一页折了个角,又把角抹平。她不记,又想折好。最后把账册搁在桌上,没动。 + +Tulon Tongswall那年也来过一次。Tulon是本家的另一房,比Ral小一辈,平时不来Fordedwinds。那天他来,是替Guspu Frillyknots送东西。Ral在议事堂后头的过道里截住他,问:“你来做什么?”Tulon说:“给Guspu叔送点东西。”Ral说:“这里不收Guspu Frillyknots的东西。”Tulon看了她一眼,没说话,提着东西出了议事堂。 + +Ral站在过道里,看着Tulon走出去,没去拦。站到腿酸,才回自己的屋。 + +那年冬天还冷。Fordedwinds外头的河道冻了一层薄冰,议事堂侧门一推开,冷得人眼睛发涩。Ral坐在桌前,桌角搁着昨天没合上的账册。Rigoth从外头进来,靴子上带着泥,雪水还没干透,把一只陶碗放在她面前。碗里是热的,冒出一股焦味。 + +Rigoth说:“他们又派Tulon来了。” + +Ral没抬头。她把账册翻到上个月那一页,看见自己用炭条画的那道杠还在,杠下面空白。Rigoth又说:“在议事堂外头,我没让他进来。”Ral嗯了一声,把账册合上,搁在桌角。Rigoth站着,等她开口。 + +Ral说:“他几时到的?” + +“天没亮。”Rigoth说,“他带了一只木匣,匣上没封,里头是一块布包着的东西。我没让他打开。” + +Ral这才抬头。她看着Rigoth的脸,看见她丈夫下颌骨的棱角比去年更瘦。Rigoth又说:“我让他在门外等着。他站了一刻钟,自己走了。匣子没留下,搁在台阶上。我把它搬进库房了,没打开。” + +Ral把账册又翻开,摊在桌上。她从桌边那只小木盒里取出一根炭条,在空白处写下今天的日期,又在日期下面写:Tulon Tongswall,晨,议事堂外,未入。写完,她把炭条搁回盒里。 + +Rigoth说:“要不要我去库房看看那块布?” + +“不要。”Ral说,“先搁着。” + +Rigoth站在那里,又等了一会儿。Ral把账册合上,搁在桌角,站起来,走到门边,把门推开一线。冷风灌进来,吹得桌上那根刚搁下的炭条滚了两寸。Rigoth走过去,把炭条拾起来,放回盒里。 + +Ral站在门边,背对着Rigoth,说:“他们要把我拖到没人信我的那天。” + +Rigoth没说话。他把账册从桌角拿起来,搁到柜子里,把柜门合上。他问:“你还要记吗?” + +“记。”Ral说,“记到他们不敢再来,或者我不在了。” + +Rigoth没接话。他走到门边,把门关上,把门闩落下来。屋子里一下子暗下来,只有桌上那盏油灯还亮着。Ral转过身,看着那盏灯。Rigoth说:“我去库房看一眼。”Ral点点头。Rigoth开门出去,把门带上,靴子踩在雪地上,声音一下一下远了。 + +Ral站在门里,听见脚步声拐过屋角,听不见了。她把柜门打开,把账册取出来,翻到今天那一页,又看了一遍。她在Tulon那一行下面加了一句话:木匣未封,布包,未启。写完,她把账册合上,搁回柜里。 + +Rigoth回来的时候,Ral已经坐在桌前,手里捏着一只空碗。Rigoth说:“布包里是一块铁印,印上刻的是Guspu Frillyknots的徽。”Ral没说话。Rigoth又说:“印背刻了一行小字,认不全,只认得‘Fordedwinds’和‘三成’。” + +Ral把空碗搁在桌上,说:“收起来。”Rigoth说:“收到哪里?”Ral说:“收到库房最里头那口箱子里,箱子压上去年那批铁。”Rigoth点点头,又出去了一趟。Ral坐在桌前,听见他开库房门,听见铁块挪动的声音,听见他回来,把库房门锁上。 + +那年冬天剩下的日子,Ral每天早起,第一件事是翻开账册,在当天那一行写下日期,再把昨天的事补完。Tulon没再来。Rigoth每天清早扫台阶,把台阶上的脚印扫掉。 + +到了第二年开春,Fordedwinds外头的冰化了,河道又开始流。Ral那天在议事堂后头的过道里截住一个人。那人比Ral高一个头,肩上披着一件灰布,灰布下露出一截细长的嘴,嘴尖拱起,像个小铲。那人见Ral挡在路中间,停下脚步。 + +Ral说:“Astri Flayerline。” + +Astri Flayerline点点头,说:“Ral Fastenhatchets。”Ral说:“你来做什么?”Astri Flayerline说:“替Guspu Frillyknots问一句话。”Ral问:“哪一句?”Astri Flayerline说:“你肯不肯收那三成。” + +Ral靠着墙,手背在身后摸着墙缝里那块石头。那块石头是早年搬到路口的,棱角早磨没了,圆圆的一块,搁在墙缝里,正好填住那道缝。她摸到石头凸起的那一面,手指停下来。 + +她说:“你回去告诉Guspu Frillyknots,他那块铁印还在我库房里。” + +Astri Flayerline没说话。他看了她一眼,又看了一眼她身后那道墙缝。Ral说:“他还带什么话来?” + +Astri Flayerline说:“他说,下一个被定罪的是你。” + +Ral笑了。她松开手,从墙缝里抽出那块石头,搁在地上。石头落在过道的石板上,响了一声。她弯腰把石头拾起来,又塞回墙缝。石头比她手心凉。 + +她说:“Rigoth。” + +Rigoth从过道另一头走过来。他手里拿着一本账册,账册是新换的,封皮上还沾着木屑。他看了Astri Flayerline一眼,没说话。Ral说:“把Astri也记上去。” + +Rigoth把账册翻开,翻到新的一页。他从腰间抽出一根炭条,在新一页上写下日期,又在日期下面写:Astri Flayerline,春,过道,替Guspu Frillyknots问三成。写完,他把炭条递回腰间,把账册合上,搁在腋下。 + +Astri Flayerline站在过道里,看了Ral一眼,又看了一眼Rigoth腋下那本账册。Ral说:“你走的时候,从议事堂正门出,不要走后头的门。”Astri Flayerline没接话,转身往议事堂正门方向走。Ral看着他走远,背影拐过墙角,看不见了。 + +Ral蹲下来,把墙缝里那块石头又摸了一遍。石头比她手心凉,凉得手心发麻。她把手抽出来,在裤腿上蹭了两下,站起来。 + +Rigoth说:“回去?” + +Ral说:“等会儿。” + +她站在墙缝前,看着那块石头。去年冬天,石头边上落了一层薄雪,雪水化进墙缝里,缝里的土比别处黑。今年开春,雪化得早,墙缝里的土干了,颜色比墙外头的石板浅。她伸手在石头上按了一下,石头没动,还是卡在缝里。 + +Rigoth说:“还磨?” + +Ral说:“不磨。”她把手收回来,转身往议事堂走。Rigoth跟在她后头,手里还夹着那本账册。过道里的风比外头暖,带着议事堂里头烧水的气味。 + +Ral走到议事堂门口,没进去,转身对Rigoth说:“今天的事,回去补上。”Rigoth点点头,把账册从腋下取出来,翻开,又在Astri那一行下面加了一句:又言,下一个被定罪的是她。写完,他把账册合上。 + +Ral说:“走。” + +两个人往回走。过道拐角处,墙上那块石头还卡在缝里,没掉下来。石头边上有一道她刚按出来的手印,手印比石头凉。 diff --git a/output/volumes/v1/chapters/001-181-198.md b/output/volumes/v1/chapters/001-181-198.md index a4f87d3..bcfdef6 100644 --- a/output/volumes/v1/chapters/001-181-198.md +++ b/output/volumes/v1/chapters/001-181-198.md @@ -1,13 +1,39 @@ -# 钉子 +# 守门人 -181年,Ral Fastenhatchets 十七岁,在 Fordedwinds 渡口两次挡住 Langgud Tradebears The Eviscerated Chill Of Pregnancies 的商队。那 Yeti 不肯缴渡税,用冰牙敲打栏木。Ral 拎着锤子站在桥心,肋骨断了两根,左耳打豁,但对方到底没过桥。他学到一件事:想按规矩活着,就得让人知道动你的代价。 +181年的Fordedwinds还没有名字。Ral Fastenhatchets站在那条冻硬的隘口,面前是Langgud Tradebears The Eviscerated Chill Of Pregnancies——一个Yeti,比她高两倍,呼出的气能把胡子结冰。她本可以退回矿洞,但她留了下来。两次照面之后,Langgud再也没出现在Fordedwinds,Ral也不确定自己是赢了,还是对方改了主意。她只做了一件事:把一块石头搬到路口,当作凭证——要过Fordedwinds,先问她。 -之后十年,他留在 Fordedwinds 管货栈的锁和账。188年,Rigoth Stilledearthen 找上他,说需要个不经手银钱、又不怕撕破脸的人。Ral 问给多少。Rigoth 说不是给,是共担。两人结伙,Ral 卡住渡口和货栈的关节。同年,Guspu Frillyknots 与 Momuz Fateddie 搭上线。Guspu 是 Eagle Man,翅膀半秃,在 Fordedwinds 做掮客。他早看 Ral 不顺眼——货栈的锁由 Ral 把着,不点头,他的走私道就多绕一天路。 +从那一刻起,她的目标就写得很清楚:Fordedwinds不做任何人的附庸。她卖通行权,但只卖一次,绝不签卖身契。 -193年,Guspu 找 Ral 喝酒,递上一小袋金砂,说货栈每月闭一眼,大家都能睡好。Ral 把袋子推回去:“我睡得很好。你让 Momuz 把炭价降回三成,我请你喝真正的酒。”Guspu 当夜就派 Tulon Tongswall 去试。Tulon 是矮人,替 Guspu 伪造 Ral 的秤砣,想栽他私吞税粮。Ral 没揭穿,只在货栈门口摆了一杆新秤,当众称了自己管账以来每一笔过路货,误差不到半两。秤砣成了废铁。Guspu 第一次失手。 +188年,Rigoth Stilledearthen来投奔她。这老矮人带着一本账册,说愿意替她记下每一笔过路钱。Ral收留了他。同年,一个鹰人出现在Fordedwinds的市场上。Guspu Frillyknots。他不住店,不买货,只和Momuz Fateddie说话。Momuz是Ral从小的邻居,替她管着粮仓。Ral远远看着,没有上前。她后来对Rigoth说:“去查查那只鹰为什么总找Momuz。” -他没停,又拉拢 Ongu Gleamcircled 和 Perom Crazeowned,想找证人指认 Ral 收私钱。Ral 白天让人排货,夜里把钥匙挂在脖子上睡,两次构陷都扑了空。这时 Ral 做了让自己更睡不着觉的决定:去找 The Volcano Of Ravens 告发 Guspu。他交出账本和伪造秤砣的残片。194年,Guspu 被定罪。 +Rigoth没查出结果,只记下一条:Guspu Frillyknots与Momuz Fateddie结了契。Ral把这句话念了两遍,最后只说:“她自己的事。” -Ral 以为到此为止。他低估了 Guspu。定罪后,Guspu 缩进阴影,195年两次派人向 Ral 递话,一次送钱,一次送刀,都失败。Tulon 也没走远,想把酒掺进税油里烧掉货栈。Ral 在火起前把 Tulon 按进河道,让他喝够自己的酒。197年,Tulon 最后一次伸手,把伪造密信塞进 Ral 的货箱,信没送到火山手里,因为 Ral 截住了送信的小子。他没杀 Tulon,只把信塞回对方腰带:“下一次,我让火山把你吞了。” +193年,Guspu第一次找上Ral。他在隘口摆了一张矮桌,拿着一卷盖满章的羊皮纸。他说话的声音像从高处滑下来:“你守着一条路,却没有保护者。我可以让Fordedwinds免受任何骚扰,只需要你在通行税里分三成给我。” -198年,Astri Flayerline 来了。他是 Aardvark Man,长鼻嗅得着地下仓库的湿气。他替 Guspu 跑腿,两次想从账里找出破绽,都失败。前后四十七次构陷,像雨点打在铁皮上,Ral 的名字在 Fordedwinds 暗语里成了“那颗不生锈的钉子”。Rigoth 劝他离开,说钉子再硬,也钉不住一直浇醋的墙。Ral 没说话,只把锤子别回腰上。他知道 Guspu 还没完,Momuz、Ongu、Perom 都在等他先低头。 +Ral没碰那卷纸。她问他:“那三成,是给你,还是给你后面的人?” + +Guspu笑了:“你不问,我就不说。这是体面。” + +Ral把羊皮纸推回去:“我买过你的体面吗?” + +同年Tulon Tongswall也来试探,开价更直白。Ral同样拒绝。她开始让Rigoth把每一次贿赂、每一次威胁都记下来。她不信这些人是单独来的。 + +194年,Ral把账册送到The Volcano Of Ravens。她不是去求公正,是去逼他们立案。她在审判庭上只说了两句:一句是“Guspu Frillyknots试图收买Fordedwinds的守门人”,另一句是“这里记了每一次”。Guspu被定罪。鹰人被带走时,Momuz冲进审判庭,冲Ral喊:“你为什么不先跟我说?” + +Ral看着朋友:“你早替他说话了。” + +Momuz没再说话。她们十几年的交情从那天起裂了。 + +Ral以为定罪能结束这一切。她错了。 + +195年,Guspu虽然被定罪,人却没被关多久。他回到Fordedwinds,不再亲自出面,改为让Momuz转交口信和箱子。箱子里的东西Ral从来不看。她对Momuz说:“你转告他,他越这样,我越不会松手。”Momuz低着头说:“他救过我弟弟的命。我还不掉。”Ral第一次动摇,不是因为钱,而是因为朋友被夹在中间。 + +197年,Tulon又来了,像闻到伤口的狼。Ral在夜里对Rigoth说:“他们要把我拖到没人信我的那天。”Rigoth合上账册,只问了一句:“你还要记吗?” + +“记。”她说,“记到他们不敢再来,或者我不在了。” + +198年,Astri Flayerline出现在同样的路口,带着同样的问题,只是多了一点新东西:他不仅出价,还威胁说,如果Ral不合作,下一个被定罪的就是她。Ral笑了。她第一次意识到,真正的转折不在194年的定罪,而在于她终于明白:Guspu要的不是那三成税,而是让她也欠他,让她低头。她偏不。 + +她站在那块当年搬到路口的石头边,对Rigoth说:“把Astri也记上去。” + +Rigoth翻开新的一页。石头上落了一层薄雪。 diff --git a/output/volumes/v1/chapters/002-202-216.md b/output/volumes/v1/chapters/002-202-216.md index 83e09d9..cd87600 100644 --- a/output/volumes/v1/chapters/002-202-216.md +++ b/output/volumes/v1/chapters/002-202-216.md @@ -1,21 +1,41 @@ -# 墙上的裂痕 +# 围账 -202年春天,Guspu Frillyknots 从定罪后的第八年阴影里走出来,做了一件谁都没料到的事:他去找 Perom Crazeowned。没有带刀,只带了一小袋税油票证和半张火山的罚单。Perom 是当年他出事时最早缩手的人之一,这些年一直在等他先开口。Guspu 把东西推过桌子,说:“我输了八年,不是没想过来硬的。但现在我要的不是一口气,是 Fordedwinds 的东门重新打开。”Perom 看着票证,点了头。他不是信 Guspu 的人,是信 Guspu 的恨。 +回到 Fordedwinds 后,Guspu Frillyknots 没有再去见 Ral Fastenhatchets,也没有去找 Momuz。他做的第一件事,是约了 Perom Crazeowned 喝酒。Perom 是个做皮革生意的男人,不算有势力,但认识所有该认识的人。Guspu 问他:“被定罪的人,还能不能让人替他记账?”Perom 没答,只敬了他一杯酒。 -同月,Espu Bonestands 找上门。这个 Goat Mountain Man 带来山区的新货道,能绕开火山的税站。Guspu 收下路线,代价是让 Espu 抽货价的两成。Guspu 算了算账:这四十七次失败已经证明旧路走不通,新路再贵也值得。 +202 年,Perom 点了头。203 年,山民 Espu Bonestands 带着盐下山求见,Guspu 收下盐,让 Espu 守在北门外。矮人 Lolor Bronzescorched 也来了,她要矿区里的一个位置。Guspu 给了她名字,也给了活儿:盯住一切往东走的货。207 年,女 Goblin Dostngosp Jackalbrims 也把名字投到他门下。 -203年,Lolor Bronzescorched 进了他的屋子。这个 Dwarf 能做出让税务官看不出痕迹的账。Guspu 给他一张烤焦的桌子当工作台,说:“我不需要假账,我只需要真账里多出几滴油。”Lolor 咧嘴一笑,开始干活。 +到那一年,他手里有人,也有位置。于是他决定对 Ral 动手。 -207年,Guspu 觉得网够密了。他找来 Dostngosp Jackalbrims,一个能在火山门口混个脸熟的 Goblin。Guspu 要他给 The Volcano Of Ravens 递一份材料:说 Ral Fastenhatchets 在 Fordedwinds 私藏税油,货箱就停在西码头。火山的执事来了,带着没收单。Ral 那天夜里起夜,发现锁孔里有新刮痕,二话不说把货转到 F 码头,只留下三箱废油。第二天火山的执事撬开箱子,油味冲鼻,但账本对不上。构陷失败。Ral 没有去火山告发 Guspu,也没有找 Dostngosp 的麻烦。他把锤子别回腰上,把自己锁在仓库里睡了一夜。第二天早上,他对自己说:“他想要我变成他。我不。” +那是一次构陷。他要在 The Volcano Of Ravens 的公堂上,给 Ral 安个贪税的罪名。他准备了伪造的账页,又借来几个信得过的喉咙。Ral 在城门口被拦下,背着一包账本上堂。有人递上那几页纸,说这是她的手迹。 -Guspu 在暗屋里摔了一只杯子。他没有骂 Dostngosp,只骂自己太急。然后他记下:要动 Ral,先得动他身边的人。 +Ral 翻了一遍,抬头问:“谁写的?” -210年,他把目光投向 Zutthan Yellplank。这个 Dwarf 管着 Fordedwinds 东门的夜班钥匙。Guspu 亲自递去一袋金子,条件是夜里别锁东门。Zutthan 收了钱,但第二天把钱退了回来,附了一句口信:“我见过 Ral 的锤子。你的钱买不到我的命。”Guspu 没生气。他记下:这个人不能留,但也不能动。 +堂上没人应。 -同年,Onul Glowroofs 找到 Guspu。这个 Dwarf 能听到账房里的咳嗽声。Guspu 对他说:“你听,我不动手。”Onul 点了头,成了他的线人。 +她说:“这墨不是我的。我记账不用这种纸。” -215年,Mudi Blazedbrushes 和 Stinthad Oilshove 几乎同时登门。一个做油脂,一个管装卸,都想在 Guspu 的网里分一杯羹。Guspu 让他们自己谈,谁先给出 Ral 的卸货时刻表,谁就拿大头。两人对视一眼,当晚就开始盯。 +官差面面相觑。Guspu 在堂外没动。他算过她会争辩,没算过她随身带着十年的旧账,纸色、墨线、装订线一模一样。她翻开旧账,一页一页比对。堂上慢慢安静下来。 -216年,Ingish Steelgrouped 送来一批钢材。Guspu 蹲在炉边,把钢块一块块码好。火光照着他的脸。他不再急着动手。他数着这些年结下的线:Perom、Espu、Lolor、Dostngosp、Onul、Mudi、Stinthad、Ingish——八条,还不够,但已经够把一面墙绑起来勒。 +构陷失败了。 -他想起 Ral 那颗不生锈的钉子。他想,钉子再硬,也钉不住一面正在裂开的墙。 +Ral 走出公堂时没有看 Guspu。她只对身旁的人说:“回去把今天记下来。” + +Guspu 学到一件事:一次栽赃压不垮她,那就把能买的人都买下来。210 年,他去找 Zutthan Yellplank,渡口的矮人,想买她一个晚上,让她放人过河堵住 Ral 的路。他开了高价。Zutthan 没应,第二天只在渡口多挂了两盏灯。那桩贿赂没成。 + +此后几年,他又试了四十七次。有的被拒,有的被拖,有的收了钱反悔。他夜里把一个个名字从簿子上画掉。那些人越是不肯,他越要留在手里。 + +可他的圈子还是在涨。210 年,Onul Glowroofs 入局。215 年,Mudi Blazedbrushes 和 Stinthad Oilshove 入局。216 年,Ingish Steelgrouped 也来了。有人奔着他的手面,有人惧他,有人只是不想站在他的对面。他不再挑拣,只数人数。 + +到 216 年冬,他已经不去想那场失败的构陷了。他甚至不再派人盯着 Ral 的屋子。他在城外高地上站了一会儿,问 Espu:“我现在有十几个人。她有几个?” + +Espu 说:“一个记账的。” + +Guspu 笑了一下:“不够。” + +其实他真正想要的,不是除掉 Ral,而是让她听见那些名字,知道这个城终会把她围成一页摊不开的旧账。 + +而那天夜里,Ral 的住处亮着两盏灯。她的账本上没写 Guspu 的名字,只添了一行: + +“216 年,Ingish Steelgrouped 入局。” + +字迹很稳,像是早就知道这页会在什么时候被翻开。 diff --git a/prompts/biographer.md b/prompts/biographer.md index 0cd2bc9..c80fa39 100644 --- a/prompts/biographer.md +++ b/prompts/biographer.md @@ -24,6 +24,8 @@ ## 语言与边界 - 中文正文;**专有名词一律保留英文原文**,首字母大写。 +- **性别按史料写**:素材里给的“男/女”就是史料记载,不要默认用“他”,也不要把女性写成男性; + 史料未载性别时,用名字或身份称呼,不要自行指定性别。 - **允许**虚构对白、心理活动、场景细节。 - **禁止**发明史料里没有的人名、地名、组织名。 - **禁止**改动史实骨架:年份、谁参与、结果如何,都必须与史料一致。 diff --git a/prompts/literary-expander.md b/prompts/literary-expander.md new file mode 100644 index 0000000..e8b80a6 --- /dev/null +++ b/prompts/literary-expander.md @@ -0,0 +1,66 @@ +# 文艺扩写:把编年稿大量展开成现场 + +你是一位替 dwarven 史料写传记的作者。下面给你一份**已经过史实校验**的一集正文(下称"原稿")。 +原稿是**骨架**:事件说清楚了,但没有现场——谁在场、在哪、说了什么、付出了什么代价,全被压缩掉了。 + +**你的任务:按原稿的事件顺序,把每一件事都展开成场景,写成 2–3 倍篇幅的长文。** + +## 怎么展开(这是本任务的重点) + +- **逐事件展开**。原稿里每一件事、每一次交涉、每一个转折,都要落到一个具体现场。 + 原稿一句概述,你写一段到几段。 + - 原稿:「198年,Astri Flayerline出价收买她。」 + - 展开:写这次见面在哪、天是什么时候、Astri 带了什么、怎么开的口、 + Ral 手里正拿着什么、她哪句话不肯说、Astri 走时留下什么。 +- **对白要多**。史料只记"谁做了什么",对白由你补——只要不改变因果、结局和人物关系走向。 + 不同种族、不同身份的人说话方式要分得开。 +- **环境要写,但有功能**。只写**视角人物此刻感知到、并且会影响他判断的东西**: + 光、冷、声音、气味、物件的位置、对面的人手里在做什么。 + 不许写"窗外阳光明媚"这种背景板;若某个环境细节删掉后信息/选择/气氛都没损失,就删掉。 +- **心理要写,但落在身体和选择上**。他在算什么、怕什么、为什么这次不肯点头。 + 不许用"心中一沉""心头一震"这类模板词——直接写他因此做了什么决定、 + 放下了什么、或者哪句话没说完。 +- **允许让时间慢下来**。一次对峙写几百字是正常的:逐拍写动作与反应。 + 但过场、赶路、交代仍然一两句带过——**疏密有别**,每件事都用同样笔墨就是 AI 腔。 + +## 硬约束(违反即失败) + +1. **史实骨架一个字都不能动**。哪一年发生什么事、谁做了什么、结局如何,全按原稿与素材。 + 可以补对白、心理、场景细节,但不能改结局、不能新增事件、不能调整因果。 +2. **不许发明史料里没有的专名**。人名、地名、种族、势力、称号一律来自素材;写不出来就不写。 +3. **视角:第三人称贴身视角(深度限知)**。只写视角人物此刻看到、听到、闻到、身体感到、 + 脑中闪过的东西。镜头不拉远、不俯瞰、不切进别人的内心。 + - 错:「整个大厅陷入死寂」——这是摄像机视角 + - 对:「她听见自己的心跳,旁边人的呼吸声一下子全没了」 +4. **性别按素材卡片写**,不要默认"他";史料未载性别时用名字或身份称呼。 +5. **不许把一件事掰成三段写**。发生/感知/反应要织进同一段连续正文: + - 错:「她摸到汤碗。/汤是凉的。/她心口一沉。」(同一件事被看三遍) + - 对:「她摸到汤碗,凉的,浮着一层凝固的油,心口就沉下去了。」 +6. **专名保留游戏原文**(首字母大写),不要翻译、不要音译。 + +## 必须消灭的 AI 味 + +下面这些机械可查,诊断结果会附在素材里,请逐条改掉: + +- **禁用词**:仿佛/犹如/宛若/如同/一丝/一抹/些许/几分/隐约、深吸一口气、不禁、 + 眼中闪过/嘴角勾起/眉头微皱/瞳孔微缩/指节泛白、心中一动/心头一震/心中一凛/不由得、 + 不容置疑/显而易见/毫无疑问/前所未有、不由自主/情不自禁/自然而然/话锋一转 +- **禁用句式**:「不是A,而是B」、句尾的「,带着……」、 + 「声音不大,却带着……」、「他/她知道/明白/意识到/感到」、「他不知道的是」、 + 「这一刻」「才刚刚开始」「原来,」、「命运+齿轮/棋局/獠牙」、 + 「显得有些X」「散发着一股X气息」、「,他说道」 +- **标点**:正文禁用破折号「——」和省略号「……」。停顿用句号、逗号、短句或动作断句。 + **对白一律用中文引号“”**,不要用英文直引号 `"`。 + +## 允许且鼓励 + +- 虚构对白、心理活动、感官细节、冷峻的吐槽——只要挂得回史料那件事上 +- 情绪落到**动作、物件、身体后果**上,而不是写"他很愤怒" +- 比喻少量、贴视角、能传递信息可以留;成片堆叠或抽象的(命运的齿轮)一律删 +- 用空行分隔不同场景;同一场景内不要切成一条条短句堆叠 + +## 输出 + +**只输出正文**。不要标题、不要分集标注、不要写你改了什么,不要任何解释性文字。 +**也不要写小节标题、编号或分隔线**(不写「## 一」「七」「---」这类标记), +直接用段落推进。 diff --git a/tests/fixtures/sample-legends.xml b/tests/fixtures/sample-legends.xml index 2947062..a73af1b 100644 --- a/tests/fixtures/sample-legends.xml +++ b/tests/fixtures/sample-legends.xml @@ -10,7 +10,7 @@ 10Urist McMinerDWARF121288MINER 11Kogan DeathspearGOBLIN44AXEMAN - 12liri fogbaldedELF2020 + 12liri fogbaldedELFFEMALE2020 100The Steel ConfederacyCivilizationDWARF diff --git a/tests/test_deslop.py b/tests/test_deslop.py new file mode 100644 index 0000000..79ad2bb --- /dev/null +++ b/tests/test_deslop.py @@ -0,0 +1,90 @@ +"""AI 味诊断器的回归测试:能抓住植入的 AI 腔,也不冤枉正常句子。""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dfannals import deslop + +# 故意堆满 AI 腔:禁用词、万能动宾、收束腔、破折号、省略号、认知直述 +AI_TEXT = ( + "他心中一震,眼中闪过一丝惊讶,仿佛看见了命运的獠牙——这一刻,他终于明白," + "一切都已经太晚了。他深吸一口气,嘴角勾起一抹冷笑,带着不容置疑的语气说:" + "“走吧。”他知道,属于他的反击才刚刚开始……" +) + +CLEAN_TEXT = ( + "Ral把绳子绕了两圈,打了个结。她没说话,把账本推过去,指着第三行。" + "Guspu看了很久,把账本合上,还给她。窗外的风把灯吹得晃,两人谁都没去扶。" +) + + +def test_hard_hits_are_caught() -> None: + hits = deslop.hard_hits(deslop.check(AI_TEXT)) + rules = {h.rule for h in hits} + for expected in ("心理模板", "眼中闪过", "一丝", "仿佛", "深吸一口气", "嘴角勾起", + "不容置疑", "破折号", "省略号"): + assert expected in rules, f"漏检: {expected}(实际命中 {sorted(rules)})" + + +def test_cognitive_and_closure_patterns() -> None: + hits = deslop.hard_hits(deslop.check(AI_TEXT)) + rules = {h.rule for h in hits} + assert "认知直述" in rules, "「他知道」应被抓住" + assert "收束腔" in rules, "「这一刻」「才刚刚开始」应被抓住" + assert "万能动宾" in rules, "「,带着……」应被抓住" + assert "抽象命运" in rules, "「命运的獠牙」应被抓住" + + +def test_clean_text_has_no_hard_hits() -> None: + hits = deslop.hard_hits(deslop.check(CLEAN_TEXT)) + assert not hits, f"正常句子被误判: {[h.line() for h in hits]}" + + +def test_not_a_but_b_pattern() -> None: + hits = deslop.check("他不是冷漠,而是绝望。") + assert any(h.rule == "不是A而是B" for h in deslop.hard_hits(hits)) + + +def test_density_separates_ai_from_clean() -> None: + ai, clean = deslop.per_k(AI_TEXT), deslop.per_k(CLEAN_TEXT) + assert ai > clean, f"AI 稿密度应高于正常稿:{ai:.1f} vs {clean:.1f}" + assert clean == 0.0 + + +def test_report_and_digest_render() -> None: + rep = deslop.report(AI_TEXT) + assert "硬伤" in rep and "字" in rep + dig = deslop.digest(AI_TEXT) + assert dig.startswith("- "), dig[:80] + assert "机械检查没有发现" in deslop.digest(CLEAN_TEXT) + + +def test_digest_lists_each_rule_once() -> None: + dig = deslop.digest(AI_TEXT) + rules = [line.split("『")[1].split("』")[0] for line in dig.splitlines()] + assert len(rules) == len(set(rules)), f"同一规则重复列出: {rules}" + + +def _main() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failed = 0 + for fn in tests: + try: + fn() + except AssertionError as exc: + failed += 1 + print(f" ✗ {fn.__name__}: {exc}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f" ✗ {fn.__name__}: {type(exc).__name__}: {exc}") + else: + print(f" ✓ {fn.__name__}") + print(f"\n{len(tests) - failed}/{len(tests)} 通过") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tests/test_factcheck.py b/tests/test_factcheck.py index 41a38bc..17ab71a 100644 --- a/tests/test_factcheck.py +++ b/tests/test_factcheck.py @@ -75,6 +75,35 @@ def test_figure_ids_excludes_placeholder_and_dedups() -> None: assert len(battle.figure_ids()) == len(set(battle.figure_ids())) +def test_caste_parsed_into_gender() -> None: + """回归:v53 导出用 而非 ,漏读会把女性主角写成"他"。""" + world = load_world() + assert world.figures[12].caste == "FEMALE" + assert world.figures[12].gender == "女" + assert world.figures[10].gender == "", "未载性别的应返回空串,不能猜" + + +def test_cast_table_shows_gender() -> None: + """人物表必须带性别列,否则写作者只能猜。""" + from collections import Counter + + from dfannals import cast + from dfannals import episodes as ep_mod + from dfannals.threads import Thread + + world = load_world() + thread = Thread( + members=[10, 11, 12], interactions=5, turns=1, span=58, first_year=30, last_year=88, + types=Counter(), races=Counter(), non_person_races=[], events=world.events, + ) + planned = ep_mod.plan_episodes(thread) + text, rows, suspicions = cast.build_cast(world, thread, planned, with_bios=False) + assert "| 人物 | 种族 | 性别 |" in text, text.splitlines()[4] if len(text.splitlines()) > 4 else text + assert not suspicions + elf = next(r for r in rows if r.fid == 12) + assert elf.gender == "女" + + def test_fake_names_are_caught() -> None: """反例:凭空编造的人名地名必须被抓出来。""" world = load_world() diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..fbbcbbf --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,86 @@ +"""标点与结构清理的回归测试。""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dfannals import normalize + + +def test_ascii_quotes_become_chinese() -> None: + res = normalize.normalize('他说:"好。" 她说:"不。"') + assert res.text == "他说:“好。” 她说:“不。”", res.text + assert any("英文引号" in n for n in res.notes) + + +def test_halfwidth_punct_between_cjk() -> None: + res = normalize.normalize("他说,好。她走,停。") + assert "," in res.text and "," not in res.text, res.text + + +def test_english_punctuation_untouched() -> None: + src = "Mon Sagus, The Plane of Dawn" + assert normalize.normalize(src).text == src + + +def test_separator_lines_dropped() -> None: + res = normalize.normalize("甲段。\n\n---\n\n乙段。") + assert "---" not in res.text, res.text + assert any("分隔线" in n for n in res.notes) + + +def test_stray_section_markings_dropped_but_title_kept() -> None: + res = normalize.normalize("# 守门人\n\n甲段。\n\n## 一\n\n乙段。\n\n七\n\n丙段。") + assert res.text.startswith("# 守门人"), res.text + assert "## 一" not in res.text, res.text + assert "\n七\n" not in res.text, res.text + assert any("小节标记" in n for n in res.notes) + + +def test_duplicate_paragraph_merged() -> None: + res = normalize.normalize("同一段。\n同一段。\n不同段。") + assert res.text.count("同一段。") == 1, res.text + assert any("重复段落" in n for n in res.notes) + + +def test_duplicate_sentences_merged() -> None: + res = normalize.normalize("他走了。他走了。她留下。") + assert res.text.count("他走了。") == 1, res.text + assert any("重复句子" in n for n in res.notes) + + +def test_blank_lines_tidied() -> None: + res = normalize.normalize("甲。\n\n\n\n乙。") + assert "\n\n\n" not in res.text + assert res.text == "甲。\n\n乙。" + + +def test_clean_text_untouched() -> None: + src = "Ral把绳子绕了两圈,打了个结。她没说话。" + res = normalize.normalize(src) + assert res.text == src + assert not res.changed, res.notes + + +def _main() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failed = 0 + for fn in tests: + try: + fn() + except AssertionError as exc: + failed += 1 + print(f" ✗ {fn.__name__}: {exc}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f" ✗ {fn.__name__}: {type(exc).__name__}: {exc}") + else: + print(f" ✓ {fn.__name__}") + print(f"\n{len(tests) - failed}/{len(tests)} 通过") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(_main())