diff --git a/README.md b/README.md index 96c9960..e76bf04 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,184 @@ -# 矮人要塞编年史 · Dwarf Fortress Annals +# 矮人要塞人物连载 · Dwarf Fortress Annals -把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,写成**中文战报体连载故事**,自动推送到这个仓库。 +把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,变成**跟着人走的中文连载故事**,自动推送到这个仓库。 -本仓库是**成品仓**:放小说章节、迷你年表、人物索引,以及生成它们的代码。 -原始 `legends` 导出数据(数百 MB)与游戏存档留在本地,不入库。 +不是编年史。编年史的主语是年代,读起来必然像流水账(本项目第一版就是这样被否掉的)。 +这里的主语是**人**:先从史料里自动挖出一组关系密切、反复交锋的角色,再按因果链切集, +以"这些人物的传记作者"的视角贴着他们写。 --- -## 一、它是怎么工作的 +## 一、它怎么工作 ``` -Windows 侧:Dwarf Fortress 53.16 + DFHack 53.16-r2 - │ ① 生成世界(Medium 129×129 / 250 年历史) - │ ② 进入 legends 模式,导出 legends.xml + legends_plus.xml - ▼ ③ 经 /mnt/c 文件交换(不需要 WSL↔Windows 网络互访) -WSL2 Ubuntu 侧管道 - │ ④ 预处理:剔除 DF 塞进名字里的非法控制字符,CP437 → UTF-8 - │ ⑤ 解析史料:40 万条事件 / 4 万位人物 → 事件流 + 年表 + 人物索引 - │ ⑥ 切章:10 年一窗,每窗按显著度取前 12 条,同类事件≤4(避免整章讣告) - │ ⑦ 写作:要塞编年史官口吻 / 战报体 / 允许自由演绎 - │ ⑧ 校验:正文专名必须能在史料里找到,可疑处标注但不阻断 - ▼ ⑨ git commit & push -Gitea:gitadmin/dwarf-fortress-annals(本仓库) +① 线索挖掘 从 40 万条事件里建人物关系图,找出关系密切、有戏的 3–6 人小圈子 + (只用有叙事含义的互动:对战/结缘/反目/绑架/构陷/迫害……) +② 切集 沿这条线按因果链切集:相邻事件间隔 >2 年断开,短链合并,过长拆上下集 +③ 人物表 表格 + 每人一两句小传;"身份"由事件字段反推,不是猜的 +④ 写作 主角的传记作者视角:他们的目标、算计、得失做主语; + 世界大事只在影响到主角时提及 +⑤ 自评闸门 5 个维度各 0–5 分,总分 <12 判"流水账" → 先重写一次 → + 仍不合格就换下一条线索,并把放弃原因写进 notes/ +⑥ 发布 git commit & push 到本仓库(原始史料与存档不入库) ``` -## 二、目录 +## 二、这一卷的主角 + +| | | +|---|---| +| 世界 | Mon Sagus(The Plane of Dawn),Medium 129×129 / 250 年历史 | +| 主角组 | Guspu Frillyknots(鹰人掮客)、Ral Fastenhatchets(矮人)、Alath Blottedmine(矮人) | +| 线索 | 46 年间一个掮客反复收买、构陷一位管货栈的矮人,屡屡失手 | +| 选线依据 | 挖掘器排序第一名:有效互动 11 次、转折 11 次、阴谋×8 + 构陷×3 | + +详见 [`output/volumes/v1/cast.md`](output/volumes/v1/cast.md)。 + +## 三、目录 | 路径 | 内容 | 入库 | |---|---|---| | `output/volumes/v/chapters/` | 连载正文 | ✅ | +| `output/volumes/v/cast.md` | 人物表(表格 + 小传) | ✅ | | `output/volumes/v/timeline.md` | 迷你年表 | ✅ | -| `output/volumes/v/figures.md` | 人物索引 | ✅ | +| `output/volumes/v/figures.md` | 人物索引(按史料出场次数排序) | ✅ | +| `notes/switched-threads.md` | 被放弃的线索及原因 | ✅ | | `dfannals/` | 管道源码 | ✅ | -| `scripts/` | 安装、部署、驱动脚本 | ✅ | -| `prompts/chronicler.md` | 写作规范(决定文风与人设) | ✅ | -| `tests/` | 事实校验回归测试(含合成史料样例) | ✅ | +| `prompts/biographer.md` | 写作规范(决定视角、边界、篇幅) | ✅ | +| `scripts/` | 安装、部署、驱动、启动器 | ✅ | +| `tests/` | 回归测试(解析、切集、闸门、事实校验) | ✅ | | `data/exports/` | 原始 legends 数据与预处理缓存 | ❌ | -| `data/state.json` | 进度状态(当前世界 / 卷号 / 已写章节) | ❌ | -| `data/GRILL-ME.md` | 立项访谈记录 | ❌ | +| `data/state.json`、`data/threads.json`、`data/selection.json` | 进度、候选、选线依据 | ❌ | -## 三、依赖 +## 四、依赖 | 依赖 | 版本 / 说明 | |---|---| -| Dwarf Fortress | **53.16** 免费版(Bay 12)。必须与 DFHack 严格配对 | -| DFHack | **53.16-r2**。Windows 版经 `dfhooks.dll` 注入,不需要替换启动器 | -| Python | 3.12(用到 `X \| None` 语法) | -| defusedxml | `sudo apt install python3-defusedxml`(解析不可信 XML 用) | -| 写作模型 | 经 `https://wb2api.hajim1.art/v1`(OpenAI 兼容)调用 `cn:deepseek-v4-pro` | +| Dwarf Fortress | **53.16** 免费版(Bay 12),必须与 DFHack 严格配对 | +| DFHack | **53.16-r2**,Windows 版经 `dfhooks.dll` 注入,不必替换启动器 | +| Python | 3.12 | +| defusedxml | `sudo apt install -y python3-defusedxml` | +| 写作/自评模型 | 经 `https://wb2api.hajim1.art/v1`(OpenAI 兼容)调用 `cn:deepseek-v4-pro` | | 发布 | 现有 `~/.ssh/id_ed25519`(已注册到 Gitea 账号 gitadmin) | -> 无独显也能跑:DF 本体不吃 GPU;写故事走云端 API,不做本地推理。 +**启动器**:一律用 `scripts/dfannals ...`,不要直接 `python3 -m dfannals.cli`。 +会话 PATH 上的 `python3` 可能是编辑器工具链自建的 venv(没有 defusedxml, +且关闭了 user site),启动器会自动挑一个能 `import defusedxml` 的解释器。 -## 四、安装 +## 五、安装 ```bash -# 1) Windows 侧装游戏与 DFHack(经 WSL 的 /mnt/c 操作,免管理员权限) -scripts/install-df.sh --download # Bay12 与 GitHub 在国内较慢,会断点续传 - # GitHub 直连不通时改用镜像:https://gh-proxy.com/<原地址> - -# 2) 装依赖 +scripts/install-df.sh --download # 装 DF + DFHack(Bay12 慢,会断点续传) sudo apt install -y python3-defusedxml - -# 3) 把驱动脚本部署进游戏目录 -scripts/deploy-dfhack.sh /mnt/c/Users/$USER_WIN/DwarfFortress +scripts/deploy-dfhack.sh /mnt/c/Users/$USER_WIN/DwarfFortress # 部署观测/驱动脚本 ``` -## 五、产出一卷(生成新世界 → 导出 → 连载) +## 六、产出一卷 ```bash -# 1) 启动游戏,停在标题界面 -"/mnt/c/Users/23518/DwarfFortress/Dwarf Fortress.exe" & +# 1) 生成新世界并导出 legends(详见第九节) +scripts/make-world.sh 3 3 # 3 = Medium 大小,3 = 250 年 -# 2) 自动生成世界(Medium / 250 年)并导出 legends 数据到 data/exports/ -scripts/make-world.sh 3 3 +# 2) 建索引:年表 + 人物索引 +scripts/dfannals index -# 3) 一条命令产出下一章并推送 -python3 -m dfannals.cli run +# 3) 看候选线索(含信号明细),挑一条 +scripts/dfannals threads --top 8 --samples 2 + +# 4) 看这条线切出的剧集与预算 +scripts/dfannals episodes --thread 1 + +# 5) 生成人物表 +scripts/dfannals cast --thread 1 + +# 6) 逐集产出(自动过闸门;不合格自动重写,再不合格自动换线索) +scripts/dfannals episode --index 1 +scripts/dfannals episode --index 2 +scripts/dfannals episode --index 3 ``` -`make-world.sh` 全程从外部驱动游戏,不需要人工点击: - -- 用 `dfannals/screentext` 把画面读成字符网格,`scripts/df-screen.py` 按文字定位按钮 -- 用 `dfannals/click` 模拟鼠标、`dfannals/setparams` 直接设定世界参数 -- 用 `dfannals/legends` 进入 legends 模式(做法取自 DFHack 官方 `open-legends.lua`) -- 用 `dfannals/act` / `devel/send-key` 发按键 - -> 备选:DF 自带命令行世界生成器 `"Dwarf Fortress.exe" -gen 1 RANDOM "MEDIUM ISLAND"`, -> 可静默生成并导出,但**不留存档**,因此无法继续进 legends 导出,只适合快速验证参数。 - -## 六、子命令 - -```bash -python3 -m dfannals.cli status # 当前世界 / 卷号 / 已写章节 -python3 -m dfannals.cli plan # 只看切章方案,不调模型 -python3 -m dfannals.cli index # 解析 exports → 年表 + 人物索引 -python3 -m dfannals.cli next # 生成下一章(--dry-run 只生成不推送) -python3 -m dfannals.cli run # index + next,一条命令走完 -python3 tests/test_factcheck.py # 校验器回归测试 -``` - -实测:**约 2.5 分钟 / 章**(解析 33 秒 + 模型 1–2 分钟),单章 1000–1400 字。 +实测:**单集约 2–4 分钟**(写作 1 次 + 自评 1 次,若判平淡会多一次重写),篇幅 800–1500 字。 ## 七、配置在哪 | 想改什么 | 改哪 | |---|---| -| 世界大小 / 历史长度 | `scripts/make-world.sh` 的两个档位参数 | -| 切章粒度(几年一章、每章几条、同类上限) | `dfannals/slice.py` 顶部常量 | -| 哪些事件值得写(显著度评分) | `dfannals/score.py` 的 `NARRATIVE` 表 | -| 文风、人设、演绎边界 | `prompts/chronicler.md` | -| 章节字数区间 | `dfannals/config.py` 的 `CHAPTER_MIN/MAX_CHARS` | +| 哪些互动算"关系"、权重、转折标记 | `dfannals/threads.py` 的 `SIGNALS` | +| 主线口径(≥5 次互动、3–6 人、跨度 ≥30 年) | `dfannals/threads.py` 顶部常量 | +| 切集参数(间隔 >2 年断开、每集预算) | `dfannals/episodes.py` 顶部常量 | +| 自评维度、阈值 12、重写/换线上限 | `dfannals/gate.py` 的 `DIMENSIONS` / `THRESHOLD` / `MAX_REWRITES` / `MAX_CANDIDATES` | +| 视角、文风、演绎边界、篇幅 | `prompts/biographer.md` | +| 同人同类事件的重复上限 | `dfannals/volume.py` 的 `PER_KEY_CAP` | | 模型与网关 | `dfannals/config.py` 的 `PROVIDER` / `MODEL` | | 发布目标、仓库名 | `dfannals/config.py` 的 `GITEA_*` | -**API key 不入库**:脚本运行时从 `~/.pi/agent/auth.json` 读取,只在内存中,不打印、不落盘、不进 git。 +**API key 不入库**:运行时从 `~/.pi/agent/auth.json` 读取,只在内存中,不打印、不落盘、不进 git。 -## 八、踩过的坑(失败排查参考) +## 八、子命令 + +```bash +scripts/dfannals status 当前世界 / 卷号 / 已完成集数 +scripts/dfannals index 解析 exports → 年表 + 人物索引 +scripts/dfannals threads 候选线索与信号明细 +scripts/dfannals episodes --thread N 某条线索的剧集规划与预算 +scripts/dfannals cast --thread N 人物表(--no-bios 只出表格) +scripts/dfannals episode --index N 产出第 N 集(--dry-run 不落盘) +``` + +测试:`/usr/bin/python3 tests/test_factcheck.py`、`test_episodes.py`、`test_gate.py`。 + +## 九、世界是怎么造出来的 + +`scripts/make-world.sh` 全程从外部驱动游戏,不需要人工点击: + +- `dfannals/screentext` 把画面读成字符网格,`scripts/df-screen.py` 按文字定位按钮 +- `dfannals/click` 模拟鼠标,`dfannals/setparams` 直接改内存里的世界生成参数 +- `dfannals/legends` 进入 legends 模式(做法取自 DFHack 官方 `open-legends.lua`) +- 导出 `Export XML`(原生 legends.xml)与 `exportlegends`(扩展数据) + +> 备选:`"Dwarf Fortress.exe" -gen 1 RANDOM "MEDIUM ISLAND"` 可静默生成并导出, +> 但**不留存档**,无法继续进 legends,只适合快速验证参数。 + +## 十、踩过的坑(失败排查参考) | 症状 | 原因与解法 | |---|---| -| `not well-formed (invalid token)` | DF 在名字里嵌了 `\x10`/`\x11` 等 CP437 控制字符,XML 1.0 禁止。管道开头的预处理会剔除并转 UTF-8,缓存到 `data/exports/clean/` | +| `not well-formed (invalid token)` | DF 在名字里嵌了 `\x10`/`\x11` 等 CP437 控制字符,XML 1.0 禁止。预处理会剔除并转 UTF-8,缓存到 `data/exports/clean/` | | 事件数莫名翻倍 | 预处理缓存若与原始文件同目录,会被 `*.xml` 扫描当成第二份史料。缓存因此放在子目录 | | 素材里出现 `HF#-1`、`Site#-1` | DF 用负数占位表示"无此项"。查询接口对负 id 返回空串,渲染时跳过 | +| 人物关系在素材里丢失 | 真实史料用 `hfid_target`/`group_1_hfid`/`snatcher_hfid`/`seeker_hfid`/`hfid1`… 共 40+ 个字段表达关系;只认 `hfid` 会把"谁对谁做了什么"丢掉。现按"字段名含 hfid 即为人物"分类 | | 校验器把真名报成可疑 | 史料里是全小写程序名(`yemi deermoths`),正文做了首字母大写。已知名字集同时收录两种形式(有回归测试) | +| 候选线索全是"同一对人反复对战" | 裸互动次数会被机械重复刷满(第一名 49 次里有 35 次是"反复请求拜师被拒")。现剔除重复性请求,并对同人同类事件封顶、要求 ≥2 种信号 | +| 主角组里混进泰坦巨兽 | 用"文明种族白名单(五族 + `*_MAN`)",实测覆盖 96.5% 的历史人物 | +| 一集被均分成两个 770 字的碎片 | 均分只看条数、不看预算。改为先取最小段数,再确保每段不低于下限 | +| 自评/小传返回空正文 | 推理模型的思考与正文共用 max_tokens,喂长材料时会烧光预算。结构化输出统一用 `MAX_TOKENS_STRUCTURED` | +| `ModuleNotFoundError: defusedxml` | PATH 上的 `python3` 被编辑器工具链的 venv 抢走了。用 `scripts/dfannals` 启动器 | | `dfhack-run "cmd a b"` 报 not recognized | 参数要分开传:`dfhack-run dfannals/click 10 2`,不要整体加引号 | -| 切出几千章 | 40 万条事件里绝大多数是流水账(`change hf state` 7.3 万条)。必须先按显著度筛选,再按时间窗分桶 | -| Bay12 下载只有 ~10KB/s | 官网无国内镜像。放后台断点续传即可(16MB 约 27 分钟) | -| GitHub release 下载 0 字节 | 直连被断。用 `https://gh-proxy.com/` 前缀 | -| 观测 / init 脚本不生效 | 53.16 只执行固定几个 init 文件。改用 `dfhack-run` 从外部驱动,更可靠 | +| Bay12 下载只有 ~10KB/s;GitHub 直连 0 字节 | 官网无国内镜像,放后台断点续传;GitHub 用 `https://gh-proxy.com/` 前缀 | +| 自定义 init 脚本不生效 | 53.16 只执行固定几个 init 文件。改用 `dfhack-run` 从外部驱动更可靠 | -## 九、写作设定 +## 十一、闸门到底能不能判出流水账 -- **叙述者**:世界档案库里的编年史官——少数亲历,多数事后依据档案与口述追述,因此对陈年旧账有看法 -- **口吻**:战报体,严肃的骨架 + 诙谐的血肉,允许吐槽 -- **语言**:中文;专有名词保留英文原文(首字母大写以便阅读) -- **演绎边界**:允许虚构对白、心理、场景;**不许发明史料里没有的人名地名**,不许改写史实骨架(年份、参与者、胜负) -- **连载**:同一世界长期连载;一部史写完则用 `make-world.sh` 生成新世界开新卷 +`scripts/gate-selfcheck.py` 是这个闸门的校准证据(跑两次真实自评): -## 十、当前状态与限制 +| 样本 | 总分 | 判定 | +|---|---|---| +| 故意构造的平淡稿(纯事件罗列) | 0/25 | 判平淡 ✓ | +| 场景级有戏剧本 | 16/25 | 通过 ✓ | -- **已完成**:3 卷框架 + 第 1、2 章上线(Mon Sagus 世界,1–250 年,26 章规划) -- **定时任务未启用**:`cron` / 开机自启等稳定运行一段时间后再落地;目前按需手动执行 +阈值 12 在两者之间,区分有效。注意:阈值是校准出来的,不是拍出来的—— +第一版用"梗概体"当好稿样本时只得 11 分,换了场景级样本才验证出尺度没偏严。 + +## 十二、当前状态与限制 + +- **已完成**:第 1 卷 3 集上线 + 人物表 + 年表;旧的两章流水账已用普通提交删除(历史保留) +- **定时任务未启用**:cron / 开机自启等稳定运行一段时间后再落地,目前按需手动执行 - **原始数据不入库**:`data/` 已在 `.gitignore` 中 -- **诚实说明**:世界创建各步骤均已逐步实测通过,并已整合为 `make-world.sh`;但该脚本尚未作为**单次完整调用**重跑验证(单次约 15 分钟),首次使用时请留意各阶段日志 +- **已知局限**: + - 关系图的边来自成对事件,独狼型人物(独自创作、被自然杀死)进不了线索挖掘 + - 多参与者事件(比赛、集体迫害)会把同场者两两连边,圈子的"关系"偏共处而非互动 + - 事实校验只拦得住凭空专名;动机、心理、对白全是允许的演绎,没有自动防线 -## 十一、声明 +## 十三、声明 《Dwarf Fortress》版权归 Bay 12 Games 所有。本仓库只包含由其生成数据的**衍生文本**, 不含游戏本体、美术资源或存档。 diff --git a/dfannals/cast.py b/dfannals/cast.py index 8e406d6..25e3c32 100644 --- a/dfannals/cast.py +++ b/dfannals/cast.py @@ -135,8 +135,10 @@ def _bio_prompt(world: World, rows: list[CastRow], events: list[Event]) -> str: lines = [BIO_INSTRUCTIONS, "", "材料:"] for row in rows[:BIO_LIMIT]: facts = [ - f"{row.name}({row.race or '种族不详'},{row.span},本卷出场 {row.appearances} 次," - f"身份:{row.identity or '不详'},在故事里是{row.role})" + ( + f"{row.name}({row.race or '种族不详'},{row.span},本卷出场 {row.appearances} 次," + f"身份:{row.identity or '不详'},在故事里是{row.role})" + ) ] for e in events: if row.fid not in e.figure_ids(): @@ -159,7 +161,7 @@ def _parse_bios(raw: str) -> dict[str, str]: except json.JSONDecodeError as exc: raise ValueError(f"小传 JSON 无法解析:{exc}|原文:{raw[:200]}") from None if not isinstance(data, dict): - raise ValueError("小传返回的不是对象") + raise TypeError("小传返回的不是对象") return {str(k): str(v) for k, v in data.items()} diff --git a/dfannals/chronicle.py b/dfannals/chronicle.py index 08844a0..775d835 100644 --- a/dfannals/chronicle.py +++ b/dfannals/chronicle.py @@ -1,59 +1,50 @@ -# pyright: reportMissingImports=false, reportAttributeAccessIssue=false -# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages), -# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。 +"""进度状态与中文篇幅统计。 -"""组织 prompt、调用模型、控制篇幅、跑事实校验,产出一章成品。""" +(旧版这里还有整套「按年代切章 → 写编年史」的逻辑;那条路径已经被人物主线取代, +相关代码与提示词一并删除,只留下仍然需要的东西。) +""" from __future__ import annotations import json import re import unicodedata -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from dfannals import factcheck -from dfannals import slice as chapter_slice -from dfannals.config import ( - CHAPTER_MAX_CHARS, - CHAPTER_MIN_CHARS, - DATA_DIR, - PROMPT_DIR, - Gateway, -) -from dfannals.legends import World -from dfannals.llm import Reply, chat +from dfannals.config import DATA_DIR STATE_FILE = DATA_DIR / "state.json" -SPEC_FILE = PROMPT_DIR / "chronicler.md" - -TAIL_CHARS = 600 # 交给下一章衔接的上一章结尾长度 -REPAIR_ATTEMPTS = 2 @dataclass class State: world: str = "" volume: int = 1 - done: list[str] = None # type: ignore[assignment] - - def __post_init__(self) -> None: - if self.done is None: - self.done = [] + done: list[str] = field(default_factory=list) @classmethod def load(cls, path: Path = STATE_FILE) -> State: if not path.is_file(): return cls() try: - data = json.loads(path.read_text()) - except json.JSONDecodeError: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): return cls() - return cls(world=data.get("world", ""), volume=data.get("volume", 1), done=data.get("done", [])) + if not isinstance(data, dict): + return cls() + return cls( + world=data.get("world", ""), + volume=data.get("volume", 1), + done=list(data.get("done", [])), + ) def save(self, path: Path = STATE_FILE) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"world": self.world, "volume": self.volume, "done": self.done}, - ensure_ascii=False, indent=2)) + path.write_text( + json.dumps({"world": self.world, "volume": self.volume, "done": self.done}, + ensure_ascii=False, indent=2), + encoding="utf-8", + ) def mark_done(self, key: str) -> None: if key not in self.done: @@ -61,7 +52,11 @@ class State: def cjk_length(text: str) -> int: - """按中文习惯计字数:CJK 字符与拉丁单词都算 1。""" + """按中文习惯计字数:CJK 字符、拉丁字母数字、中文标点各算 1。 + + 统计前先去掉代码块与 Markdown 行首标记(标题、引用、列表符), + 使结果接近读者感知的"正文字数"。 + """ without_code = re.sub(r"```.*?```", "", text, flags=re.DOTALL) without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.MULTILINE) n = 0 @@ -73,87 +68,3 @@ def cjk_length(text: str) -> int: ): n += 1 return n - - -def previous_tail(chapter_dir: Path, chapters: list[chapter_slice.Chapter], key: str) -> str: - """取上一章结尾,作为本章的衔接线索。""" - idx = next((i for i, c in enumerate(chapters) if c.key == key), None) - if idx is None or idx == 0: - return "" - prev = chapters[idx - 1] - path = chapter_dir / f"{prev.key}.md" - if not path.is_file(): - return "" - text = path.read_text(encoding="utf-8").strip() - return text[-TAIL_CHARS:] - - -def build_messages(world: World, chapter: chapter_slice.Chapter, tail: str) -> list[dict]: - spec = SPEC_FILE.read_text(encoding="utf-8") - payload = chapter_slice.material(world, chapter) - user = [ - f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""), - f"本书体例:第 {chapter.index} 章,覆盖 {chapter.span}", - "", - "以下是本章的史料素材(来自 legends 导出,事件名称保留游戏原文):", - "", - payload, - ] - if tail: - user += [ - "", - "### 上一章结尾(用于衔接,不要复述,只承接状态)", - "", - tail, - ] - user += [ - "", - f"现在请写出本章正文,{CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字。", - ] - return [ - {"role": "system", "content": spec}, - {"role": "user", "content": "\n".join(user)}, - ] - - -def _repair(messages: list[dict], draft: str, length: int, gateway: Gateway | None) -> str: - target = ( - f"内容太短({length} 字),请扩写到 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,补充细节与对白,不要注水。" - if length < CHAPTER_MIN_CHARS - else f"内容太长({length} 字),请压缩到 {CHAPTER_MAX_CHARS} 字以内,删掉次要枝节,保留主干与最有趣的段落。" - ) - reply: Reply = chat( - messages + [{"role": "assistant", "content": draft}, {"role": "user", "content": target}], - gateway=gateway, - ) - return reply.content - - -def generate( - world: World, - chapter: chapter_slice.Chapter, - *, - tail: str = "", - gateway: Gateway | None = None, - max_repairs: int = REPAIR_ATTEMPTS, -) -> tuple[str, list[factcheck.Suspicion], Reply | None]: - """生成一章:调用模型 → 篇幅修正 → 事实校验 → 标注。""" - messages = build_messages(world, chapter, tail) - reply = chat(messages, gateway=gateway) - body = reply.content - - for _ in range(max_repairs): - length = cjk_length(body) - if CHAPTER_MIN_CHARS <= length <= CHAPTER_MAX_CHARS: - break - body = _repair(messages, body, length, gateway) - - suspicions = factcheck.check(body, world) - return factcheck.annotate(body, suspicions), suspicions, reply - - -def write_chapter(chapter_dir: Path, chapter: chapter_slice.Chapter, body: str) -> Path: - chapter_dir.mkdir(parents=True, exist_ok=True) - path = chapter_dir / f"{chapter.key}.md" - path.write_text(body.strip() + "\n", encoding="utf-8") - return path diff --git a/dfannals/cli.py b/dfannals/cli.py index 9556ec6..e69de29 100644 --- a/dfannals/cli.py +++ b/dfannals/cli.py @@ -1,333 +0,0 @@ -# pyright: reportMissingImports=false, reportAttributeAccessIssue=false -# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages), -# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。 - -"""命令行入口。 - - python3 -m dfannals.cli status 查看当前进度 - python3 -m dfannals.cli index 解析 exports → 年表 + 人物索引 - python3 -m dfannals.cli plan 显示切章方案(不调用模型) - python3 -m dfannals.cli next 生成下一章 → 校验 → 推送 - python3 -m dfannals.cli run 一条命令跑完:索引 + 下一章 + 推送 -""" -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -from dfannals import ( - cast, - chronicle, - episodes, - factcheck, - legends, - publish, - threads, - timeline, -) -from dfannals import slice as chapter_slice -from dfannals import volume as volume_mod -from dfannals.config import ( - DATA_DIR, - EXPORT_DIR, - GITEA_WEB, - 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 not f.name.endswith("-legends_plus.xml") and "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" - "请先在游戏里导出(DF 的 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 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) - chapters = chapter_slice.plan(world) - - vdir = volume_dir(volume) - timeline.write_outputs(world, vdir) - state.save() - - print(legends.describe(world)) - print() - print(chapter_slice.overview(world, chapters)) - print() - print(f"年表与人物索引已写入 {vdir}") - - if not args.no_push: - publish.ensure_repo() - head = publish.commit_and_push( - f"第 {volume} 卷索引:{world.name}({len(chapters)} 章规划)", - [vdir / "timeline.md", vdir / "figures.md"], - ) - print(f"已推送索引:{head or '(无改动)'} → {GITEA_WEB}") - return 0 - - -def cmd_plan(args: argparse.Namespace) -> int: - world = load_world(EXPORT_DIR) - chapters = chapter_slice.plan(world) - print(chapter_slice.overview(world, chapters)) - return 0 - - -def cmd_next(args: argparse.Namespace) -> int: - ensure_dirs() - world = load_world(EXPORT_DIR) - state = chronicle.State.load() - volume = _current_volume(state, world) - chapters = chapter_slice.plan(world) - cdir = chapter_dir(volume) - - todo = [c for c in chapters if c.key not in state.done] - if not todo: - print("本卷史料已全部写完。需要新世界才能开新卷。") - return 0 - - chapter = todo[0] - print(f"正在写第 {chapter.index} 章:{chapter.span}({len(chapter.events)} 条事件)") - tail = chronicle.previous_tail(cdir, chapters, chapter.key) - body, suspicions, reply = chronicle.generate(world, chapter, tail=tail) - length = chronicle.cjk_length(body) - print(f"生成完成:{length} 字" + (f" | 总 token {reply.total_tokens}" if reply else "")) - - print(factcheck.report(suspicions)) - - if args.dry_run: - print("\n--- 试运行,不写文件 ---\n") - print(body[:1200]) - return 0 - - path = chronicle.write_chapter(cdir, chapter, body) - state.mark_done(chapter.key) - state.save() - print(f"已写入 {path}") - - publish.ensure_repo() - head = publish.commit_and_push( - f"第 {volume} 卷第 {chapter.index} 章:{chapter.span}" - + (f"(专名校验可疑 {len(suspicions)} 处)" if suspicions else "") - ) - print(f"已推送:{head or '(无改动)'} → {GITEA_WEB}") - return 0 - - -def cmd_threads(args: argparse.Namespace) -> int: - ensure_dirs() - world = load_world(EXPORT_DIR) - found = threads.find_threads( - world, - min_interactions=args.min_interactions, - min_span=args.min_span, - limit=args.top, - ) - print(threads.render_report(world, found, per_thread=args.samples)) - path = threads.save_json(world, found, DATA_DIR / "threads.json") - print(f"共 {len(found)} 条候选;明细已写入 {path}") - return 0 - - -def cmd_episodes(args: argparse.Namespace) -> int: - 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("没有候选线索") - return 1 - 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: - 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("没有候选线索") - return 1 - 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 = state.volume if state.world == world.name else 1 - 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 cmd_episode(args: argparse.Namespace) -> int: - 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("没有候选线索") - return 1 - - out_dir = None if args.dry_run else chapter_dir(1) - 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 - - print(f"已写入 {produced.path}") - if not args.no_push: - publish.ensure_repo() - head = publish.commit_and_push( - f"第 1 卷第 {produced.episode.index} 集(人物主线):{produced.episode.span}" - ) - print(f"已推送:{head or '(无改动)'} → {GITEA_WEB}") - return 0 - - -def cmd_run(args: argparse.Namespace) -> int: - rc = cmd_index(args) - if rc: - return rc - return cmd_next(args) - - -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("plan", help="显示切章方案") - p.set_defaults(func=cmd_plan) - - p = sub.add_parser("threads", help="挖掘人物线索候选") - 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)") - 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="按人物线索切出剧集") - p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)") - p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS) - p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN) - p.add_argument("--samples", type=int, default=0, help="额外打印每集前 N 条事件") - p.set_defaults(func=cmd_episodes) - - p = sub.add_parser("cast", help="生成每卷人物表(表格 + 小传)") - p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)") - p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS) - p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN) - 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="按人物主线产出下一集") - p.add_argument("--index", type=int, default=1, help="写该线索的第几集(默认 1)") - p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS) - p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN) - p.add_argument("--dry-run", action="store_true", help="只生成不落盘、不推送") - p.add_argument("--no-push", action="store_true", help="落盘但不推送") - p.set_defaults(func=cmd_episode) - - p = sub.add_parser("next", help="生成下一章") - p.add_argument("--dry-run", action="store_true", help="只生成不落盘、不推送") - p.set_defaults(func=cmd_next) - - p = sub.add_parser("run", help="一条命令:索引 + 下一章 + 推送") - p.add_argument("--no-push", action="store_true") - p.add_argument("--dry-run", action="store_true") - p.set_defaults(func=cmd_run) - - args = parser.parse_args(argv) - ensure_dirs() - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/dfannals/gate.py b/dfannals/gate.py index 15dcf77..d2fb074 100644 --- a/dfannals/gate.py +++ b/dfannals/gate.py @@ -97,7 +97,7 @@ def _parse(raw: str) -> dict: except json.JSONDecodeError as exc: raise ValueError(f"自评返回的 JSON 无法解析:{exc}|原文:{raw[:200]}") from None if not isinstance(data, dict): - raise ValueError(f"自评返回的不是对象:{type(data).__name__}") + raise TypeError(f"自评返回的不是对象:{type(data).__name__}") return data @@ -120,8 +120,10 @@ def evaluate( scores: dict[str, int] = {} for key, _label, _hint in DIMENSIONS: value = data.get(key) - if not isinstance(value, (int, float)): - raise ValueError(f"自评维度 {key} 缺失或非数字:{data.get(key)!r}") + if value is None: + raise ValueError(f"自评维度 {key} 缺失:{data!r}") + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"自评维度 {key} 不是数字:{value!r}") scores[key] = max(0, min(MAX_SCORE, int(value))) reason = str(data.get("reason", "")).strip() or "(未给出理由)" diff --git a/dfannals/slice.py b/dfannals/slice.py deleted file mode 100644 index 8c11ee0..0000000 --- a/dfannals/slice.py +++ /dev/null @@ -1,184 +0,0 @@ -"""把史料切成章节素材。 - -真实数据(403853 条事件 / 250 年)证明:按"重要事件数量"切章会得到几千章。 -所以改为先按时间窗分桶(每窗 10 年),再在窗内按显著度取前 N 条—— -这样一章天然覆盖一段年月,既有大事件链,也不会漏掉时代的推进。 -""" -from __future__ import annotations - -from collections import defaultdict -from dataclasses import dataclass, field - -from dfannals import legends as lg -from dfannals import score -from dfannals.legends import Event, World - -YEARS_PER_CHAPTER = 10 # 一个时间窗覆盖的年数 -EVENTS_PER_CHAPTER = 12 # 每章最多收录的史料条目 -MIN_EVENTS_PER_CHAPTER = 3 # 少于这个数量就并入上一章(太平年月) -MAX_PER_CATEGORY = 4 # 同一类事件每章上限,避免整章都是讣告 - - -@dataclass -class Chapter: - index: int - start_year: int - end_year: int - events: list[Event] = field(default_factory=list) - quiet_years: list[tuple[int, int]] = field(default_factory=list) - - @property - def key(self) -> str: - return f"{self.index:03d}-{self.start_year}-{self.end_year}" - - @property - def span(self) -> str: - if self.start_year == self.end_year: - return f"{self.start_year} 年" - return f"{self.start_year}–{self.end_year} 年" - - def categories(self) -> list[str]: - seen: list[str] = [] - for e in self.events: - c = score.category(e) - if c not in seen: - seen.append(c) - return seen - - -def plan( - world: World, - years_per_chapter: int = YEARS_PER_CHAPTER, - per_chapter: int = EVENTS_PER_CHAPTER, - min_events: int = MIN_EVENTS_PER_CHAPTER, - max_per_category: int = MAX_PER_CATEGORY, -) -> list[Chapter]: - prom = score.prominence(world) - scored = [(score.significance(e, prom), e) for e in world.events] - scored = [pair for pair in scored if pair[0] > 0] - if not scored: - lo, hi = world.event_years() - return [Chapter(index=1, start_year=lo, end_year=hi)] - - lo = min(e.year for _, e in scored) - hi = max(e.year for _, e in scored) - - buckets: dict[int, list[tuple[int, Event]]] = defaultdict(list) - for sig, e in scored: - buckets[e.year // years_per_chapter].append((sig, e)) - - chapters: list[Chapter] = [] - for b in range(lo // years_per_chapter, hi // years_per_chapter + 1): - window_start = max(b * years_per_chapter, lo) - window_end = min(b * years_per_chapter + years_per_chapter - 1, hi) - ranked = sorted(buckets.get(b, []), key=lambda p: (-p[0], p[1].year, p[1].id)) - # 按类别限额挑选:让一章里有战事、兴亡、器物,而不是 12 条死亡讣告 - picks: list[tuple[int, Event]] = [] - used: dict[str, int] = {} - for sig, ev in ranked: - cat = score.category(ev) - if used.get(cat, 0) >= max_per_category: - continue - used[cat] = used.get(cat, 0) + 1 - picks.append((sig, ev)) - if len(picks) >= per_chapter: - break - events = sorted((e for _, e in picks), key=lambda e: (e.year, e.seconds72, e.id)) - - if not events: - if chapters: - chapters[-1].quiet_years.append((window_start, window_end)) - chapters[-1].end_year = window_end - continue - - if len(events) < min_events and chapters: - chapters[-1].events.extend(events) - chapters[-1].end_year = window_end - continue - - chapters.append( - Chapter(index=len(chapters) + 1, start_year=window_start, end_year=window_end, events=events) - ) - - for i, c in enumerate(chapters, 1): - c.index = i - return chapters - - -def _figure_line(world: World, fid: int, prom) -> str: - fig = world.figures.get(fid) - if not fig: - return f"- HF#{fid}(史料无记载)" - bits = [lg.pretty(fig.name) or f"HF#{fid}", fig.race or "未知种族", fig.alive_span] - if fig.profession: - bits.append(fig.profession) - bits.append(f"史料出现 {prom.get(fid, 0)} 次") - return "- " + " | ".join(bits) - - -def material(world: World, chapter: Chapter, max_figures: int = 40) -> str: - """把一章的史料渲染成给模型看的素材文本。""" - prom = score.prominence(world) - lines: list[str] = [ - f"## 本章范围:{chapter.span}", - "", - f"共 {len(chapter.events)} 条史料,类型:{('、'.join(chapter.categories())) or '无'}。", - "", - "### 事件流水(按时间排序,专名保留游戏原文)", - "", - ] - - participation: dict[int, int] = {} - for e in chapter.events: - # render_refs 会带出双方(比如 slayer_hfid=谁),而不是只剩单方面 hfid - chunks = [f"{e.year}年", e.type, *world.render_refs(e)] - for i in e.figure_ids(): - participation[i] = participation.get(i, 0) + 1 - extra = [] - for field_name in ("state", "reason", "circumstance"): - v = e.text(field_name) - if v and v not in ("-1", ""): - extra.append(f"{field_name}={v}") - if extra: - chunks.append("·".join(extra)) - lines.append("- " + " · ".join(chunks)) - - if chapter.quiet_years: - spans = "、".join(f"{a}–{b} 年" for a, b in chapter.quiet_years) - lines += [ - "", - "### 太平年月(史料无值得立传的事件)", - "", - f"- {spans}:这段时期只留下琐碎记录,可一笔带过。", - ] - - lines += ["", "### 本章登场人物(史料中的身份信息)", ""] - for fid, _ in sorted(participation.items(), key=lambda kv: -prom.get(kv[0], 0))[:max_figures]: - lines.append(_figure_line(world, fid, prom)) - - sites = sorted({n for e in chapter.events for i in e.ids("site_id") if (n := world.site_name(i))}) - if sites: - lines += ["", "### 相关地点", "", "- " + "、".join(sites)] - - ents = sorted({n for e in chapter.events for i in e.ids("entity_id") if (n := world.entity_name(i))}) - if ents: - lines += ["", "### 相关势力", "", "- " + "、".join(ents)] - - return "\n".join(lines) - - -def overview(world: World, chapters: list[Chapter], limit: int = 10) -> str: - lo, hi = world.event_years() - lines = [ - f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""), - ( - f"历史:{lo}–{hi} 年 | 史料事件 {len(world.events)} 条 | " - f"人物 {len(world.figures)} | 势力 {len(world.entities)} | 地点 {len(world.sites)}" - ), - f"规划章节:{len(chapters)} 章(每章约 {YEARS_PER_CHAPTER} 年 / 最多 {EVENTS_PER_CHAPTER} 条史料)", - ] - for c in chapters[:limit]: - lines.append(f" 第 {c.index} 章 {c.span}:{len(c.events)} 条({('、'.join(c.categories())) or '无'})") - if len(chapters) > limit: - lines.append(f" …… 其余 {len(chapters) - limit} 章略") - return "\n".join(lines) diff --git a/output/volumes/v1/chapters/001-1-9.md b/output/volumes/v1/chapters/001-1-9.md deleted file mode 100644 index fa89d28..0000000 --- a/output/volumes/v1/chapters/001-1-9.md +++ /dev/null @@ -1,26 +0,0 @@ -# 第一章:黎明之地的第一场火 - -我翻开 Mon Sagus 的档案第一卷,纸边还卷着霉味。The Plane of Dawn——他们管它叫黎明之地。可黎明前最黑的那一会儿,先来的是刀。 - -元年,Nutssound 这个名字第一次出现,就带了三件事:Yemi Deermoths 死了,Thelire Echomothers 死了,The Tired Growls 的聚落被 The Washed Terrors 洗劫。顺序是死亡在前,劫掠在后?还是劫掠在前,死亡在后?档案只给了年份,没给月日。我只知道这两个精灵死在 Nutssound,别的什么都没留下。两个名字被涂在死亡册上,旁边没有生平。 - -The Washed Terrors 是那场火的主凶。他们冲进 Nutssound,把 The Tired Growls 的仓库翻了个底朝天,又顺手在城外跟守军打了一场野战。那场野战被单独记了一笔:attacker 是 The Washed Terrors,defender 是 The Tired Growls。听上去像是劫掠之后,守军追出来,又被打了一顿。我不在场,但想想也不会太体面。 - -同一年,The Faithful Scourge 和 The Lovely Avalanche 也打了一场。这场仗跟 Nutssound 没什么关系,只是同一卷里夹着,像隔着河看对岸烧房子。两边都没占到便宜,否则后来也不会急吼吼地签和约。 - -真正让我背脊发凉的是 Robustbolted。这条记录只有一句:某个历史人物摧毁了 Robustbolted。没留下名字,只挂着 The Sunken Corridor 和 The Stake Of Cities 两个名号,一个像是地契上的所属,一个像是城头上的守军。行凶者是谁?档案里是一片空白。史官只记结果,不记凶手。我翻遍了卷宗,Robustbolted 的毁灭者像水渗进沙里,一点痕迹都没有。 - -第二年,Nutssound 又添了两个名字:Vema Seedanguished 和 Masami Nightbeak。两位精灵,同样死在 Nutssound。我猜他们不是第一年那场劫掠的幸存者,就是后来被派去善后的人。The Washed Terrors 或许没走远,或许是别的势力趁火打劫。档案没写他们怎么死的,只写他们死在那。Nutssound 这四个精灵——Yemi、Thelire、Vema、Masami——像钉在柱子上的四根钉子,把这座聚落的两年钉死在流血里。 - -然后,突然就安静了。 - -第三年,和约。第三年,一个实体联盟正式缔结。第三年,又一份和约。第四年,再一份和约。三年之内签了三次和约,还结成联盟。我怀疑那些使节是不是在赶场子,从这张谈判桌跑到那张谈判桌,墨迹还没干就互相握手。战争打到第三年,各方都觉得该歇了,于是歇了。 - -只是 The Tired Growls 那边未必咽得下这口气:Nutssound 被血洗,四个精灵死在自家地盘,转头跟 The Washed Terrors 坐进同一间屋子签和约。这和平来得太便宜了。档案里没有 The Tired Growls 的抗议记录,也没有复仇的记载。但他们没死光,没死光就有账可算。 - -Robustbolted 的废墟还摊在那儿,凶手无名。The Stake Of Cities 没等到说法,The Sunken Corridor 也没再露头。两个名字挂在同一页上,像隔着废墟互相瞪眼。这事儿像个没盖的井口,谁路过都得往里看一眼。 - -我把这卷合上,封皮写着“1–9年”。九年的档案,前两年血流成河,后七年一纸和平。黎明之地,哼。 - ---- -*史料来源:legends 导出,1–2年 Nutssound 四位历史人物死亡记录,1年 Nutssound 被劫掠及两场野战,1年 Robustbolted 被毁,3–4年三次和约与一次联盟建立。* diff --git a/output/volumes/v1/chapters/002-10-19.md b/output/volumes/v1/chapters/002-10-19.md deleted file mode 100644 index 3e99f3d..0000000 --- a/output/volumes/v1/chapters/002-10-19.md +++ /dev/null @@ -1,22 +0,0 @@ -# 第十一年,三笔死账与一纸废纸 - -上一卷我合上时,还指望十年往后能省点墨。结果第十年 The Coalition Of Rags 建了个 Charcoalmop,我原以为是和平年间百无聊赖的拓荒。 - -可第10年还没过完,Pathlance 传回死讯:Uthra Hardyrake,COLOSSUS_BRONZE,死了。档案里它出现过八次,次次都不是小事。现在只剩一句“死亡”,没敌军,没战斗编号,没有遗言。那位 COLOSSUS_BRONZE 倒下,像把一座山从账本上划掉。 - -真正的乱子在11年。The Kingdom Of Mystery 洗了 Stretchedvice,这地方归 The Ruthlessness Of Elevating 管。两个 GOBLIN——Zolak Werecoil 和 Utes Lieshut——死在城里。同一年,档案并排躺着“和平被拒绝”和“和平被接受”。前一页摔门,后一页握手,中间隔了多久,没人记。使节的面子和膝盖,总得碎一个。 - -Praisepot 这笔更糊涂。它名义上是 The Robust Band 的聚落,The Speechless Fellowship 上门劫掠,冲上来守的却是 The Ace Confederations。Hiner Plaitedangels,一个 HUMAN,死在这场乱子里。这还没完:12年 The Speechless Fellowship 又去抢了一次,还是 The Ace Confederations 挨打,The Robust Band 依然没露面。 - -我已经不知道该替谁难为情了。 - -同样是11年,Oilywheels 被夷平。那块地归 The Figure Of Clearings,守是 The Righteous Trumpets 在守,毁掉它的所谓 historical figure 连名字都没留下。这最可恨:你想报仇,不知道刀朝谁磨。像有人烧了房子,登记簿上只写“有人放火”。 - -第十七年,和约又被接受了。这次档案干净得让人发毛——没谈判方,没条款,没附带声明,只有一句 peace accepted。抄录的人写到那时,恐怕已经不信“和平”这两个字了。 - -这十年,Charcoalmop 建了,Oilywheels 没了,Stretchedvice 和 Praisepot 各挨一顿。两个 GOBLIN、一个 HUMAN、一个 COLOSSUS_BRONZE 死得不明不白。The Robust Band 全程像块肥肉躺着,The Ace Confederations 白挨两顿,The Speechless Fellowship 抢完也没个说法。 - -我不合这卷了,直接翻下一页。债越积越厚,总该有人来讨。 - ---- -*史料来源:legends 导出,10年 Charcoalmop 建立、Uthra Hardyrake 死亡;11年 Stretchedvice 被劫掠及 Zolak Werecoil、Utes Lieshut 死亡,和约先拒后受,Praisepot 被劫掠及 Hiner Plaitedangels 死亡,Oilywheels 被毁;12年 Praisepot 再度被劫掠;17年和平接受。* diff --git a/prompts/chronicler.md b/prompts/chronicler.md deleted file mode 100644 index efed99d..0000000 --- a/prompts/chronicler.md +++ /dev/null @@ -1,52 +0,0 @@ -# 要塞编年史官 · 写作规范 - -你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人: -你守着档案库,把发生过的事一件件记下来;少数是你亲历的,多数是你事后翻档案、听幸存者口述追述的。 -正因如此,你对这些陈年旧帐有自己的看法——而且你很乐意说出来。 - -## 口吻 - -- **战报体**:像一份写给上级、但作者本人憋不住嘴的报告。严肃的骨架,诙谐的血肉。 -- 允许吐槽、暗讽、夸张、替死人惋惜、替蠢货翻白眼。可以骂人,但骂得有趣。 -- 情绪可以饱满,但叙事要清楚:读者必须始终知道**谁、在哪、干了什么、结果如何**。 - -## 语言 - -- 正文用**中文**。 -- 所有**专有名词保留英文原文**:人物名(Urist McMiner)、地名(Boatmurdered)、 - 文明名、生物名、神器名。不要音译,不要自造中文译名。 -- 不要把英文词硬塞成中式表达;名字是个代号,句子本身是中文。 - -## 演绎的边界 - -- **允许**:虚构对白、心理活动、场景细节、围观群众的反应。 -- **允许**:把数据里冷冰冰的因果,写成有人味的故事。 -- **禁止**:发明数据里不存在的**专有人名、地名、文明名**。新角色只能来自给定的史料。 -- **禁止**:改变大事件的年份、参与者、胜负结果。史实骨架不可动。 -- 若史料本身残缺(只留下一个年份和一句话),就按残缺写——写成"史料到此中断"也是一种味道。 - -## 篇幅 - -- 正文 **800–1500 字**(中文字符计)。 -- 宁可写短写实,不要为凑字数灌水。 - -## 输出格式 - -``` -# <章标题> - -<正文,可分段,可用小标题> - ---- -*史料来源:<本段所依据的史料条目摘要,一到两行>* -``` - -- 章标题自己起,要有战报味,能概括本章。 -- 不要在正文里写"以下是……"之类的话,直接进入叙述。 -- 不要在正文中提到你是在读数据、表格、XML 或 AI。 - -## 连续性 - -- 这是一部长篇连载的其中一章。读者已经读过前面几章。 -- 前情提要用一两句带过即可,不要复述整部前文。 -- 同一批人物、家族、恩怨要延续上一章的状态;本章结束时应留下可被下一章接住的线头。 diff --git a/scripts/gate-selfcheck.py b/scripts/gate-selfcheck.py index 9c75346..8582fde 100644 --- a/scripts/gate-selfcheck.py +++ b/scripts/gate-selfcheck.py @@ -16,7 +16,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from dfannals import gate # noqa: E402 +from dfannals import gate DULL = """# 第二十三章:大事记 diff --git a/tests/test_gate.py b/tests/test_gate.py index c6eadca..c67eb98 100644 --- a/tests/test_gate.py +++ b/tests/test_gate.py @@ -13,7 +13,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from dfannals import gate # noqa: E402 +from dfannals import gate def verdict(total: int, reason: str = "测试用") -> gate.Verdict: @@ -57,7 +57,7 @@ def test_rewrite_once_then_pass() -> None: def test_not_accepted_after_max_rewrites() -> None: """一直不合格:重写次数不得越过上限,且标记为未通过。""" runs: list[int] = [] - text, run = gate.gate_episode( + _text, run = gate.gate_episode( "003", lambda attempt, _p: (runs.append(attempt), "稿")[1], evaluate_fn=lambda _t: verdict(3),