文笔层:AI 味机检、标点规范化、分块大量扩写(第 1 集成稿 10805 字)
- 修复上次提交把 dfannals/cli.py 写成 0 字节的问题(它是唯一入口,导致管道不可运行) - 性别:解析 <caste>,人物表与写作素材带性别(Ral Fastenhatchets 实为女性) - 新增 dfannals/deslop.py:AI 味机械诊断(硬伤词/句式/标点,按千字密度报告) - 新增 dfannals/normalize.py:标点与结构清理(引号配对、重复段落与句子、模型自加的小节标记) - 新增 dfannals/expand.py 与 prompts/literary-expander.md:按年份场景分块大量扩写 - 专名防幻觉:每块附史料专名白名单,事后按段自动修复可疑专名 - episode 命令并进文笔层(骨架稿另存 .skeleton.md),新增 expand 命令做 A/B 对照 - 新增 notes/switched-threads.md 与 test_deslop / test_normalize 回归测试 - 提交前拦截「已跟踪文件为空」,防止上述事故复发
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
## 一、它怎么工作
|
## 一、它怎么工作
|
||||||
|
|
||||||
```
|
```text
|
||||||
① 线索挖掘 从 40 万条事件里建人物关系图,找出关系密切、有戏的 3–6 人小圈子
|
① 线索挖掘 从 40 万条事件里建人物关系图,找出关系密切、有戏的 3–6 人小圈子
|
||||||
(只用有叙事含义的互动:对战/结缘/反目/绑架/构陷/迫害……)
|
(只用有叙事含义的互动:对战/结缘/反目/绑架/构陷/迫害……)
|
||||||
② 切集 沿这条线按因果链切集:相邻事件间隔 >2 年断开,短链合并,过长拆上下集
|
② 切集 沿这条线按因果链切集:相邻事件间隔 >2 年断开,短链合并,过长拆上下集
|
||||||
|
|||||||
+9
-6
@@ -54,14 +54,15 @@ class CastRow:
|
|||||||
fid: int
|
fid: int
|
||||||
name: str
|
name: str
|
||||||
race: str
|
race: str
|
||||||
|
gender: str # 男/女/空(史料未载)
|
||||||
span: str
|
span: str
|
||||||
role: str # 主角 / 对手 / 配角
|
role: str # 主角 / 对手 / 配角
|
||||||
appearances: int
|
appearances: int
|
||||||
identity: str
|
identity: str
|
||||||
|
|
||||||
def as_row(self) -> str:
|
def as_row(self) -> str:
|
||||||
return (f"| {self.name} | {self.race or '—'} | {self.span} | {self.identity or '—'} | "
|
return (f"| {self.name} | {self.race or '—'} | {self.gender or '—'} | {self.span} | "
|
||||||
f"{self.appearances} | {self.role} |")
|
f"{self.identity or '—'} | {self.appearances} | {self.role} |")
|
||||||
|
|
||||||
|
|
||||||
def _identity_of(world: World, fid: int, events: list[Event]) -> str:
|
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,
|
fid=fid,
|
||||||
name=world.figure_name(fid),
|
name=world.figure_name(fid),
|
||||||
race=fig.race if fig else "",
|
race=fig.race if fig else "",
|
||||||
|
gender=fig.gender if fig else "",
|
||||||
span=fig.alive_span if fig else "生卒不详",
|
span=fig.alive_span if fig else "生卒不详",
|
||||||
role=role,
|
role=role,
|
||||||
appearances=appearances.get(fid, 0),
|
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]:
|
for row in rows[:BIO_LIMIT]:
|
||||||
facts = [
|
facts = [
|
||||||
(
|
(
|
||||||
f"{row.name}({row.race or '种族不详'},{row.span},本卷出场 {row.appearances} 次,"
|
f"{row.name}({row.race or '种族不详'},{row.gender or '性别史料未载'},{row.span},"
|
||||||
f"身份:{row.identity or '不详'},在故事里是{row.role})"
|
f"本卷出场 {row.appearances} 次,身份:{row.identity or '不详'},"
|
||||||
|
f"在故事里是{row.role})"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
for e in events:
|
for e in events:
|
||||||
@@ -197,8 +200,8 @@ def build_cast(
|
|||||||
f"本卷主角线索:{'、'.join(world.figure_name(m) for m in thread.members)}",
|
f"本卷主角线索:{'、'.join(world.figure_name(m) for m in thread.members)}",
|
||||||
f"线索跨度:{_span_of(thread, episodes)}",
|
f"线索跨度:{_span_of(thread, episodes)}",
|
||||||
"",
|
"",
|
||||||
"| 人物 | 种族 | 生卒 | 身份 | 本卷出场 | 角色 |",
|
"| 人物 | 种族 | 性别 | 生卒 | 身份 | 本卷出场 | 角色 |",
|
||||||
"|---|---|---|---|---|---|",
|
"|---|---|---|---|---|---|---|",
|
||||||
]
|
]
|
||||||
lines.extend(row.as_row() for row in rows)
|
lines.extend(row.as_row() for row in rows)
|
||||||
|
|
||||||
|
|||||||
+392
@@ -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())
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ PI_AUTH_JSON = HOME / ".pi/agent/auth.json"
|
|||||||
PI_MODELS_JSON = HOME / ".pi/agent/models.json"
|
PI_MODELS_JSON = HOME / ".pi/agent/models.json"
|
||||||
PROVIDER = "workbuddy"
|
PROVIDER = "workbuddy"
|
||||||
MODEL = "cn:deepseek-v4-pro"
|
MODEL = "cn:deepseek-v4-pro"
|
||||||
|
# 文笔层(扩写)单独选模型:创作向模型写叙事更贴地,
|
||||||
|
# 而推理向模型(deepseek-v4-pro)天然偏总结陈词,容易写成报告。
|
||||||
|
LITERARY_MODEL = "cn:minimax-m3"
|
||||||
# 该模型是推理模型:思考与正文共用 max_tokens,必须留足
|
# 该模型是推理模型:思考与正文共用 max_tokens,必须留足
|
||||||
MAX_TOKENS_CHAPTER = 16000
|
MAX_TOKENS_CHAPTER = 16000
|
||||||
MAX_TOKENS_UTIL = 4000
|
MAX_TOKENS_UTIL = 4000
|
||||||
@@ -66,6 +69,16 @@ WORLD_HISTORY_YEARS = 250
|
|||||||
CHAPTER_MIN_CHARS = 800
|
CHAPTER_MIN_CHARS = 800
|
||||||
CHAPTER_MAX_CHARS = 1500 # 严格按访谈定的 800–1500,不留“余量”
|
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
|
@dataclass
|
||||||
class Gateway:
|
class Gateway:
|
||||||
|
|||||||
@@ -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"——|──|(?<!-)--(?!-)"),
|
||||||
|
("省略号", "正文禁用,用动作或短句表达停顿", r"……|\.\.\."),
|
||||||
|
)
|
||||||
|
|
||||||
|
SAMPLE_WIDTH = 14
|
||||||
|
SAMPLES_PER_RULE = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Hit:
|
||||||
|
"""一条诊断:命中哪个规则、几次、原文长什么样。"""
|
||||||
|
|
||||||
|
rule: str
|
||||||
|
category: str
|
||||||
|
level: str # hard = 硬伤词+句式+标点;soft = 只看密度的词
|
||||||
|
count: int
|
||||||
|
samples: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def line(self) -> 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)
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""文笔层:把已过闸门的编年稿大量扩写成有现场的长文。
|
||||||
|
|
||||||
|
与 volume.write_episode 的分工:
|
||||||
|
- volume 从史料素材写出「骨架正确」的一集(事实优先,1500 字内);
|
||||||
|
- expand 拿那一稿逐事件展开(场景优先,目标 8000 字)。
|
||||||
|
|
||||||
|
**为什么要分块**:一次调用来写 8000 字,模型会跑偏、提前收尾、漏掉后半段事件。
|
||||||
|
所以按「年份场景」把原稿切块,每块目标约 2600 字,逐块展开后用上一块结尾承接,
|
||||||
|
再把各块拼接。每块都只喂它自己那一段史料,避免不同块写出重复内容。
|
||||||
|
|
||||||
|
这是 A/B 层:产物写成 `<key>.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),
|
||||||
|
])
|
||||||
@@ -97,14 +97,19 @@ def _is_candidate(name: str, known: set[str]) -> bool:
|
|||||||
def check(text: str, world: World) -> list[Suspicion]:
|
def check(text: str, world: World) -> list[Suspicion]:
|
||||||
"""返回可疑专名列表(按出现次数降序)。"""
|
"""返回可疑专名列表(按出现次数降序)。"""
|
||||||
known = known_names(world)
|
known = known_names(world)
|
||||||
|
# 大小写不该决定是不是幻觉:史料里是 YETI/EAGLE_MAN,正文写成 Yeti/Eagle_man
|
||||||
|
# 是同一件事(模型从种族字段学来的),不能报成凭空编造。
|
||||||
|
folded = {k.casefold() for k in known}
|
||||||
found: dict[str, list[str]] = {}
|
found: dict[str, list[str]] = {}
|
||||||
|
|
||||||
for match in NAME_RE.finditer(text):
|
for match in NAME_RE.finditer(text):
|
||||||
raw = match.group(0)
|
raw = match.group(0)
|
||||||
|
if raw.casefold() in folded:
|
||||||
|
continue
|
||||||
# 逐级回退:整串不认,就试着拆成更短的已知名字,减少误报
|
# 逐级回退:整串不认,就试着拆成更短的已知名字,减少误报
|
||||||
if not _is_candidate(raw, known):
|
if not _is_candidate(raw, known):
|
||||||
continue
|
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"),不当作凭空编造
|
# 名字里有一部分是史料已知的(例如 "Urist the Bold"),不当作凭空编造
|
||||||
continue
|
continue
|
||||||
start = max(0, match.start() - 20)
|
start = max(0, match.start() - 20)
|
||||||
|
|||||||
@@ -45,12 +45,23 @@ class Figure:
|
|||||||
name: str = ""
|
name: str = ""
|
||||||
race: str = ""
|
race: str = ""
|
||||||
sex: int | None = None
|
sex: int | None = None
|
||||||
|
caste: str = "" # 史料里的性别字段(v53 导出用 <caste>MALE/FEMALE</caste>)
|
||||||
birth_year: int | None = None
|
birth_year: int | None = None
|
||||||
death_year: int | None = None
|
death_year: int | None = None
|
||||||
profession: str = ""
|
profession: str = ""
|
||||||
entities: list[int] = field(default_factory=list)
|
entities: list[int] = field(default_factory=list)
|
||||||
sites: 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
|
@property
|
||||||
def alive_span(self) -> str:
|
def alive_span(self) -> str:
|
||||||
# DF 用负数占位表示“无此项”(未记录/未死),不要当成真实年份显示
|
# DF 用负数占位表示“无此项”(未记录/未死),不要当成真实年份显示
|
||||||
@@ -333,6 +344,7 @@ def _add_figure(world: World, elem) -> None:
|
|||||||
fig = world.figures.setdefault(fid, Figure(id=fid))
|
fig = world.figures.setdefault(fid, Figure(id=fid))
|
||||||
fig.name = fig.name or _first(d, "name")
|
fig.name = fig.name or _first(d, "name")
|
||||||
fig.race = fig.race or _first(d, "race")
|
fig.race = fig.race or _first(d, "race")
|
||||||
|
fig.caste = fig.caste or _first(d, "caste")
|
||||||
fig.profession = fig.profession or _first(d, "profession")
|
fig.profession = fig.profession or _first(d, "profession")
|
||||||
if fig.sex is None:
|
if fig.sex is None:
|
||||||
fig.sex = _int_or_none(_first(d, "sex"))
|
fig.sex = _int_or_none(_first(d, "sex"))
|
||||||
|
|||||||
+6
-3
@@ -48,7 +48,7 @@ def chat(
|
|||||||
max_tokens: int = MAX_TOKENS_CHAPTER,
|
max_tokens: int = MAX_TOKENS_CHAPTER,
|
||||||
temperature: float = 1.0,
|
temperature: float = 1.0,
|
||||||
timeout: int = 900,
|
timeout: int = 900,
|
||||||
retries: int = 3,
|
retries: int = 5,
|
||||||
) -> Reply:
|
) -> Reply:
|
||||||
gw = gateway or load_gateway()
|
gw = gateway or load_gateway()
|
||||||
conn_cls, host, path = _endpoint(gw.base_url)
|
conn_cls, host, path = _endpoint(gw.base_url)
|
||||||
@@ -75,7 +75,8 @@ def chat(
|
|||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
last = f"HTTP {resp.status}: {raw[:400].decode('utf-8', 'replace')}"
|
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
|
break
|
||||||
else:
|
else:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
@@ -101,6 +102,8 @@ def chat(
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
if attempt < retries:
|
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}")
|
raise GatewayError(f"网关调用失败({gw.base_url},model={model}):{last}")
|
||||||
|
|||||||
@@ -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)
|
||||||
+23
-1
@@ -65,8 +65,30 @@ def ensure_repo() -> None:
|
|||||||
_git("config", "user.email", "df-annals@hajim1.art")
|
_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:
|
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:
|
if paths:
|
||||||
for p in paths:
|
for p in paths:
|
||||||
_git("add", "--", str(p.relative_to(PROJECT_DIR)) if p.is_absolute() else str(p))
|
_git("add", "--", str(p.relative_to(PROJECT_DIR)) if p.is_absolute() else str(p))
|
||||||
|
|||||||
+5
-4
@@ -155,8 +155,8 @@ def render_material(
|
|||||||
for fid in heros:
|
for fid in heros:
|
||||||
row = rows.get(fid)
|
row = rows.get(fid)
|
||||||
if row:
|
if row:
|
||||||
lines.append(f"- {row.name}|{row.race}|{row.span}|身份:{row.identity or '不详'}"
|
lines.append(f"- {row.name}|{row.race}|性别:{row.gender or '史料未载'}"
|
||||||
f"|本集出场 {row.appearances} 次")
|
f"|{row.span}|身份:{row.identity or '不详'}|本集出场 {row.appearances} 次")
|
||||||
else:
|
else:
|
||||||
lines.append(f"- {world.figure_name(fid)}")
|
lines.append(f"- {world.figure_name(fid)}")
|
||||||
|
|
||||||
@@ -173,8 +173,8 @@ def render_material(
|
|||||||
for fid in others[:6]:
|
for fid in others[:6]:
|
||||||
row = rows.get(fid)
|
row = rows.get(fid)
|
||||||
if row:
|
if row:
|
||||||
lines.append(f"- {row.name}|{row.race}|身份:{row.identity or '不详'}"
|
lines.append(f"- {row.name}|{row.race}|性别:{row.gender or '史料未载'}"
|
||||||
f"|本集出场 {row.appearances} 次")
|
f"|身份:{row.identity or '不详'}|本集出场 {row.appearances} 次")
|
||||||
else:
|
else:
|
||||||
lines.append(f"- {world.figure_name(fid)}")
|
lines.append(f"- {world.figure_name(fid)}")
|
||||||
|
|
||||||
@@ -196,6 +196,7 @@ def render_material(
|
|||||||
"",
|
"",
|
||||||
f"- 本集 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,中文正文,专名保留英文。",
|
f"- 本集 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,中文正文,专名保留英文。",
|
||||||
"- 贴着主角写:他们的目标、算计、得失做主语;对手要是个具体的人。",
|
"- 贴着主角写:他们的目标、算计、得失做主语;对手要是个具体的人。",
|
||||||
|
"- 性别按上面卡片写,不要默认“他”;史料未载性别时用名字或身份称呼。",
|
||||||
"- 史料之外的世界大事不要写;只有影响到主角时才提一句。",
|
"- 史料之外的世界大事不要写;只有影响到主角时才提一句。",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -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)|冲突逐年重复“没被邀请/输/听说”,对手只是名字几乎全无动机。
|
||||||
+21
-21
@@ -1,37 +1,37 @@
|
|||||||
# Mon Sagus · 人物表
|
# Mon Sagus · 人物表
|
||||||
|
|
||||||
本卷主角线索:Guspu Frillyknots、Ral Fastenhatchets、Alath Blottedmine
|
本卷主角线索:Guspu Frillyknots、Ral Fastenhatchets、Alath Blottedmine
|
||||||
线索跨度:193–239(46 年)
|
线索跨度:181–248
|
||||||
|
|
||||||
| 人物 | 种族 | 生卒 | 身份 | 本卷出场 | 角色 |
|
| 人物 | 种族 | 性别 | 生卒 | 身份 | 本卷出场 | 角色 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| Guspu Frillyknots | EAGLE_MAN | 151–246 | 关系对象×13、构陷发起者×4 | 62 | 主角 |
|
| Guspu Frillyknots | EAGLE_MAN | 男 | 151–246 | 关系对象×13、构陷发起者×4 | 62 | 主角 |
|
||||||
| Ral Fastenhatchets | DWARF | 164–在世/不详 | 被针对者×21、受骗者×6 | 28 | 主角 |
|
| Ral Fastenhatchets | DWARF | 女 | 164–在世/不详 | 被针对者×21、受骗者×6 | 28 | 主角 |
|
||||||
| Alath Blottedmine | DWARF | 182–在世/不详 | 构陷发起者×4、关系对象×1 | 8 | 主角 |
|
| Alath Blottedmine | DWARF | 男 | 182–在世/不详 | 构陷发起者×4、关系对象×1 | 8 | 主角 |
|
||||||
| Tulon Tongswall | DWARF | 132–在世/不详 | 构陷发起者×3 | 3 | 对手/配角 |
|
| Tulon Tongswall | DWARF | 男 | 132–在世/不详 | 构陷发起者×3 | 3 | 对手/配角 |
|
||||||
| Moldath Mirroredfloors | DWARF | 79–在世/不详 | 构陷发起者×3 | 3 | 对手/配角 |
|
| Moldath Mirroredfloors | DWARF | 女 | 79–在世/不详 | 构陷发起者×3 | 3 | 对手/配角 |
|
||||||
| Ineth Wheeleddrinks | DWARF | 165–在世/不详 | 构陷者×3 | 3 | 对手/配角 |
|
| Ineth Wheeleddrinks | DWARF | 男 | 165–在世/不详 | 构陷者×3 | 3 | 对手/配角 |
|
||||||
| Langgud Tradebears The Eviscerated Chill Of Pregnancies | YETI | 生卒不详 | — | 2 | 对手/配角 |
|
| Langgud Tradebears The Eviscerated Chill Of Pregnancies | YETI | 男 | 生卒不详 | — | 2 | 对手/配角 |
|
||||||
| Rigoth Stilledearthen | DWARF | 162–在世/不详 | 当事者×2 | 2 | 对手/配角 |
|
| Rigoth Stilledearthen | DWARF | 男 | 162–在世/不详 | 当事者×2 | 2 | 对手/配角 |
|
||||||
| Perom Crazeowned | HUMAN | 129–在世/不详 | 当事者×1、关系对象×1 | 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年又添一笔,是鹰人关系网中露面有限的配角。
|
||||||
|
|
||||||
|
|||||||
@@ -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说:“走。”
|
||||||
|
|
||||||
|
两个人往回走。过道拐角处,墙上那块石头还卡在缝里,没掉下来。石头边上有一道她刚按出来的手印,手印比石头凉。
|
||||||
@@ -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翻开新的一页。石头上落了一层薄雪。
|
||||||
|
|||||||
@@ -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 入局。”
|
||||||
|
|
||||||
|
字迹很稳,像是早就知道这页会在什么时候被翻开。
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
## 语言与边界
|
## 语言与边界
|
||||||
|
|
||||||
- 中文正文;**专有名词一律保留英文原文**,首字母大写。
|
- 中文正文;**专有名词一律保留英文原文**,首字母大写。
|
||||||
|
- **性别按史料写**:素材里给的“男/女”就是史料记载,不要默认用“他”,也不要把女性写成男性;
|
||||||
|
史料未载性别时,用名字或身份称呼,不要自行指定性别。
|
||||||
- **允许**虚构对白、心理活动、场景细节。
|
- **允许**虚构对白、心理活动、场景细节。
|
||||||
- **禁止**发明史料里没有的人名、地名、组织名。
|
- **禁止**发明史料里没有的人名、地名、组织名。
|
||||||
- **禁止**改动史实骨架:年份、谁参与、结果如何,都必须与史料一致。
|
- **禁止**改动史实骨架:年份、谁参与、结果如何,都必须与史料一致。
|
||||||
|
|||||||
@@ -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气息」、「,他说道」
|
||||||
|
- **标点**:正文禁用破折号「——」和省略号「……」。停顿用句号、逗号、短句或动作断句。
|
||||||
|
**对白一律用中文引号“”**,不要用英文直引号 `"`。
|
||||||
|
|
||||||
|
## 允许且鼓励
|
||||||
|
|
||||||
|
- 虚构对白、心理活动、感官细节、冷峻的吐槽——只要挂得回史料那件事上
|
||||||
|
- 情绪落到**动作、物件、身体后果**上,而不是写"他很愤怒"
|
||||||
|
- 比喻少量、贴视角、能传递信息可以留;成片堆叠或抽象的(命运的齿轮)一律删
|
||||||
|
- 用空行分隔不同场景;同一场景内不要切成一条条短句堆叠
|
||||||
|
|
||||||
|
## 输出
|
||||||
|
|
||||||
|
**只输出正文**。不要标题、不要分集标注、不要写你改了什么,不要任何解释性文字。
|
||||||
|
**也不要写小节标题、编号或分隔线**(不写「## 一」「七」「---」这类标记),
|
||||||
|
直接用段落推进。
|
||||||
Vendored
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
<historical_figures>
|
<historical_figures>
|
||||||
<historical_figure><id>10</id><name>Urist McMiner</name><race>DWARF</race><appeared>12</appeared><birth_year>12</birth_year><death_year>88</death_year><profession>MINER</profession></historical_figure>
|
<historical_figure><id>10</id><name>Urist McMiner</name><race>DWARF</race><appeared>12</appeared><birth_year>12</birth_year><death_year>88</death_year><profession>MINER</profession></historical_figure>
|
||||||
<historical_figure><id>11</id><name>Kogan Deathspear</name><race>GOBLIN</race><appeared>4</appeared><birth_year>4</birth_year><profession>AXEMAN</profession></historical_figure>
|
<historical_figure><id>11</id><name>Kogan Deathspear</name><race>GOBLIN</race><appeared>4</appeared><birth_year>4</birth_year><profession>AXEMAN</profession></historical_figure>
|
||||||
<historical_figure><id>12</id><name>liri fogbalded</name><race>ELF</race><appeared>20</appeared><birth_year>20</birth_year></historical_figure>
|
<historical_figure><id>12</id><name>liri fogbalded</name><race>ELF</race><caste>FEMALE</caste><appeared>20</appeared><birth_year>20</birth_year></historical_figure>
|
||||||
</historical_figures>
|
</historical_figures>
|
||||||
<entities>
|
<entities>
|
||||||
<entity><id>100</id><name>The Steel Confederacy</name><type>Civilization</type><race>DWARF</race></entity>
|
<entity><id>100</id><name>The Steel Confederacy</name><type>Civilization</type><race>DWARF</race></entity>
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -75,6 +75,35 @@ def test_figure_ids_excludes_placeholder_and_dedups() -> None:
|
|||||||
assert len(battle.figure_ids()) == len(set(battle.figure_ids()))
|
assert len(battle.figure_ids()) == len(set(battle.figure_ids()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_caste_parsed_into_gender() -> None:
|
||||||
|
"""回归:v53 导出用 <caste> 而非 <sex>,漏读会把女性主角写成"他"。"""
|
||||||
|
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:
|
def test_fake_names_are_caught() -> None:
|
||||||
"""反例:凭空编造的人名地名必须被抓出来。"""
|
"""反例:凭空编造的人名地名必须被抓出来。"""
|
||||||
world = load_world()
|
world = load_world()
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user