删除旧的事件驱动章节(流水账),并同步文档到人物主线
- 移除 output/volumes/v1/chapters/001-1-9.md 与 002-10-19.md(仅普通提交,git 历史保留) - 移除被取代的写作规范 prompts/chronicler.md 与旧的按年代切章模块 - README 全面改写为人物主线流程、参数位置与踩坑记录 - scripts/dfannals 启动器:自动挑一个装了 defusedxml 的解释器
This commit is contained in:
@@ -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
|
① 线索挖掘 从 40 万条事件里建人物关系图,找出关系密切、有戏的 3–6 人小圈子
|
||||||
│ ① 生成世界(Medium 129×129 / 250 年历史)
|
(只用有叙事含义的互动:对战/结缘/反目/绑架/构陷/迫害……)
|
||||||
│ ② 进入 legends 模式,导出 legends.xml + legends_plus.xml
|
② 切集 沿这条线按因果链切集:相邻事件间隔 >2 年断开,短链合并,过长拆上下集
|
||||||
▼ ③ 经 /mnt/c 文件交换(不需要 WSL↔Windows 网络互访)
|
③ 人物表 表格 + 每人一两句小传;"身份"由事件字段反推,不是猜的
|
||||||
WSL2 Ubuntu 侧管道
|
④ 写作 主角的传记作者视角:他们的目标、算计、得失做主语;
|
||||||
│ ④ 预处理:剔除 DF 塞进名字里的非法控制字符,CP437 → UTF-8
|
世界大事只在影响到主角时提及
|
||||||
│ ⑤ 解析史料:40 万条事件 / 4 万位人物 → 事件流 + 年表 + 人物索引
|
⑤ 自评闸门 5 个维度各 0–5 分,总分 <12 判"流水账" → 先重写一次 →
|
||||||
│ ⑥ 切章:10 年一窗,每窗按显著度取前 12 条,同类事件≤4(避免整章讣告)
|
仍不合格就换下一条线索,并把放弃原因写进 notes/
|
||||||
│ ⑦ 写作:要塞编年史官口吻 / 战报体 / 允许自由演绎
|
⑥ 发布 git commit & push 到本仓库(原始史料与存档不入库)
|
||||||
│ ⑧ 校验:正文专名必须能在史料里找到,可疑处标注但不阻断
|
|
||||||
▼ ⑨ git commit & push
|
|
||||||
Gitea:gitadmin/dwarf-fortress-annals(本仓库)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 二、目录
|
## 二、这一卷的主角
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 世界 | 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<N>/chapters/` | 连载正文 | ✅ |
|
| `output/volumes/v<N>/chapters/` | 连载正文 | ✅ |
|
||||||
|
| `output/volumes/v<N>/cast.md` | 人物表(表格 + 小传) | ✅ |
|
||||||
| `output/volumes/v<N>/timeline.md` | 迷你年表 | ✅ |
|
| `output/volumes/v<N>/timeline.md` | 迷你年表 | ✅ |
|
||||||
| `output/volumes/v<N>/figures.md` | 人物索引 | ✅ |
|
| `output/volumes/v<N>/figures.md` | 人物索引(按史料出场次数排序) | ✅ |
|
||||||
|
| `notes/switched-threads.md` | 被放弃的线索及原因 | ✅ |
|
||||||
| `dfannals/` | 管道源码 | ✅ |
|
| `dfannals/` | 管道源码 | ✅ |
|
||||||
| `scripts/` | 安装、部署、驱动脚本 | ✅ |
|
| `prompts/biographer.md` | 写作规范(决定视角、边界、篇幅) | ✅ |
|
||||||
| `prompts/chronicler.md` | 写作规范(决定文风与人设) | ✅ |
|
| `scripts/` | 安装、部署、驱动、启动器 | ✅ |
|
||||||
| `tests/` | 事实校验回归测试(含合成史料样例) | ✅ |
|
| `tests/` | 回归测试(解析、切集、闸门、事实校验) | ✅ |
|
||||||
| `data/exports/` | 原始 legends 数据与预处理缓存 | ❌ |
|
| `data/exports/` | 原始 legends 数据与预处理缓存 | ❌ |
|
||||||
| `data/state.json` | 进度状态(当前世界 / 卷号 / 已写章节) | ❌ |
|
| `data/state.json`、`data/threads.json`、`data/selection.json` | 进度、候选、选线依据 | ❌ |
|
||||||
| `data/GRILL-ME.md` | 立项访谈记录 | ❌ |
|
|
||||||
|
|
||||||
## 三、依赖
|
## 四、依赖
|
||||||
|
|
||||||
| 依赖 | 版本 / 说明 |
|
| 依赖 | 版本 / 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Dwarf Fortress | **53.16** 免费版(Bay 12)。必须与 DFHack 严格配对 |
|
| Dwarf Fortress | **53.16** 免费版(Bay 12),必须与 DFHack 严格配对 |
|
||||||
| DFHack | **53.16-r2**。Windows 版经 `dfhooks.dll` 注入,不需要替换启动器 |
|
| DFHack | **53.16-r2**,Windows 版经 `dfhooks.dll` 注入,不必替换启动器 |
|
||||||
| Python | 3.12(用到 `X \| None` 语法) |
|
| Python | 3.12 |
|
||||||
| defusedxml | `sudo apt install python3-defusedxml`(解析不可信 XML 用) |
|
| defusedxml | `sudo apt install -y python3-defusedxml` |
|
||||||
| 写作模型 | 经 `https://wb2api.hajim1.art/v1`(OpenAI 兼容)调用 `cn:deepseek-v4-pro` |
|
| 写作/自评模型 | 经 `https://wb2api.hajim1.art/v1`(OpenAI 兼容)调用 `cn:deepseek-v4-pro` |
|
||||||
| 发布 | 现有 `~/.ssh/id_ed25519`(已注册到 Gitea 账号 gitadmin) |
|
| 发布 | 现有 `~/.ssh/id_ed25519`(已注册到 Gitea 账号 gitadmin) |
|
||||||
|
|
||||||
> 无独显也能跑:DF 本体不吃 GPU;写故事走云端 API,不做本地推理。
|
**启动器**:一律用 `scripts/dfannals ...`,不要直接 `python3 -m dfannals.cli`。
|
||||||
|
会话 PATH 上的 `python3` 可能是编辑器工具链自建的 venv(没有 defusedxml,
|
||||||
|
且关闭了 user site),启动器会自动挑一个能 `import defusedxml` 的解释器。
|
||||||
|
|
||||||
## 四、安装
|
## 五、安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1) Windows 侧装游戏与 DFHack(经 WSL 的 /mnt/c 操作,免管理员权限)
|
scripts/install-df.sh --download # 装 DF + DFHack(Bay12 慢,会断点续传)
|
||||||
scripts/install-df.sh --download # Bay12 与 GitHub 在国内较慢,会断点续传
|
|
||||||
# GitHub 直连不通时改用镜像:https://gh-proxy.com/<原地址>
|
|
||||||
|
|
||||||
# 2) 装依赖
|
|
||||||
sudo apt install -y python3-defusedxml
|
sudo apt install -y python3-defusedxml
|
||||||
|
scripts/deploy-dfhack.sh /mnt/c/Users/$USER_WIN/DwarfFortress # 部署观测/驱动脚本
|
||||||
# 3) 把驱动脚本部署进游戏目录
|
|
||||||
scripts/deploy-dfhack.sh /mnt/c/Users/$USER_WIN/DwarfFortress
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 五、产出一卷(生成新世界 → 导出 → 连载)
|
## 六、产出一卷
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1) 启动游戏,停在标题界面
|
# 1) 生成新世界并导出 legends(详见第九节)
|
||||||
"/mnt/c/Users/23518/DwarfFortress/Dwarf Fortress.exe" &
|
scripts/make-world.sh 3 3 # 3 = Medium 大小,3 = 250 年
|
||||||
|
|
||||||
# 2) 自动生成世界(Medium / 250 年)并导出 legends 数据到 data/exports/
|
# 2) 建索引:年表 + 人物索引
|
||||||
scripts/make-world.sh 3 3
|
scripts/dfannals index
|
||||||
|
|
||||||
# 3) 一条命令产出下一章并推送
|
# 3) 看候选线索(含信号明细),挑一条
|
||||||
python3 -m dfannals.cli run
|
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` 全程从外部驱动游戏,不需要人工点击:
|
实测:**单集约 2–4 分钟**(写作 1 次 + 自评 1 次,若判平淡会多一次重写),篇幅 800–1500 字。
|
||||||
|
|
||||||
- 用 `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 字。
|
|
||||||
|
|
||||||
## 七、配置在哪
|
## 七、配置在哪
|
||||||
|
|
||||||
| 想改什么 | 改哪 |
|
| 想改什么 | 改哪 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 世界大小 / 历史长度 | `scripts/make-world.sh` 的两个档位参数 |
|
| 哪些互动算"关系"、权重、转折标记 | `dfannals/threads.py` 的 `SIGNALS` |
|
||||||
| 切章粒度(几年一章、每章几条、同类上限) | `dfannals/slice.py` 顶部常量 |
|
| 主线口径(≥5 次互动、3–6 人、跨度 ≥30 年) | `dfannals/threads.py` 顶部常量 |
|
||||||
| 哪些事件值得写(显著度评分) | `dfannals/score.py` 的 `NARRATIVE` 表 |
|
| 切集参数(间隔 >2 年断开、每集预算) | `dfannals/episodes.py` 顶部常量 |
|
||||||
| 文风、人设、演绎边界 | `prompts/chronicler.md` |
|
| 自评维度、阈值 12、重写/换线上限 | `dfannals/gate.py` 的 `DIMENSIONS` / `THRESHOLD` / `MAX_REWRITES` / `MAX_CANDIDATES` |
|
||||||
| 章节字数区间 | `dfannals/config.py` 的 `CHAPTER_MIN/MAX_CHARS` |
|
| 视角、文风、演绎边界、篇幅 | `prompts/biographer.md` |
|
||||||
|
| 同人同类事件的重复上限 | `dfannals/volume.py` 的 `PER_KEY_CAP` |
|
||||||
| 模型与网关 | `dfannals/config.py` 的 `PROVIDER` / `MODEL` |
|
| 模型与网关 | `dfannals/config.py` 的 `PROVIDER` / `MODEL` |
|
||||||
| 发布目标、仓库名 | `dfannals/config.py` 的 `GITEA_*` |
|
| 发布目标、仓库名 | `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` 扫描当成第二份史料。缓存因此放在子目录 |
|
| 事件数莫名翻倍 | 预处理缓存若与原始文件同目录,会被 `*.xml` 扫描当成第二份史料。缓存因此放在子目录 |
|
||||||
| 素材里出现 `HF#-1`、`Site#-1` | DF 用负数占位表示"无此项"。查询接口对负 id 返回空串,渲染时跳过 |
|
| 素材里出现 `HF#-1`、`Site#-1` | DF 用负数占位表示"无此项"。查询接口对负 id 返回空串,渲染时跳过 |
|
||||||
|
| 人物关系在素材里丢失 | 真实史料用 `hfid_target`/`group_1_hfid`/`snatcher_hfid`/`seeker_hfid`/`hfid1`… 共 40+ 个字段表达关系;只认 `hfid` 会把"谁对谁做了什么"丢掉。现按"字段名含 hfid 即为人物"分类 |
|
||||||
| 校验器把真名报成可疑 | 史料里是全小写程序名(`yemi deermoths`),正文做了首字母大写。已知名字集同时收录两种形式(有回归测试) |
|
| 校验器把真名报成可疑 | 史料里是全小写程序名(`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`,不要整体加引号 |
|
| `dfhack-run "cmd a b"` 报 not recognized | 参数要分开传:`dfhack-run dfannals/click 10 2`,不要整体加引号 |
|
||||||
| 切出几千章 | 40 万条事件里绝大多数是流水账(`change hf state` 7.3 万条)。必须先按显著度筛选,再按时间窗分桶 |
|
| Bay12 下载只有 ~10KB/s;GitHub 直连 0 字节 | 官网无国内镜像,放后台断点续传;GitHub 用 `https://gh-proxy.com/` 前缀 |
|
||||||
| Bay12 下载只有 ~10KB/s | 官网无国内镜像。放后台断点续传即可(16MB 约 27 分钟) |
|
| 自定义 init 脚本不生效 | 53.16 只执行固定几个 init 文件。改用 `dfhack-run` 从外部驱动更可靠 |
|
||||||
| GitHub release 下载 0 字节 | 直连被断。用 `https://gh-proxy.com/` 前缀 |
|
|
||||||
| 观测 / init 脚本不生效 | 53.16 只执行固定几个 init 文件。改用 `dfhack-run` 从外部驱动,更可靠 |
|
|
||||||
|
|
||||||
## 九、写作设定
|
## 十一、闸门到底能不能判出流水账
|
||||||
|
|
||||||
- **叙述者**:世界档案库里的编年史官——少数亲历,多数事后依据档案与口述追述,因此对陈年旧账有看法
|
`scripts/gate-selfcheck.py` 是这个闸门的校准证据(跑两次真实自评):
|
||||||
- **口吻**:战报体,严肃的骨架 + 诙谐的血肉,允许吐槽
|
|
||||||
- **语言**:中文;专有名词保留英文原文(首字母大写以便阅读)
|
|
||||||
- **演绎边界**:允许虚构对白、心理、场景;**不许发明史料里没有的人名地名**,不许改写史实骨架(年份、参与者、胜负)
|
|
||||||
- **连载**:同一世界长期连载;一部史写完则用 `make-world.sh` 生成新世界开新卷
|
|
||||||
|
|
||||||
## 十、当前状态与限制
|
| 样本 | 总分 | 判定 |
|
||||||
|
|---|---|---|
|
||||||
|
| 故意构造的平淡稿(纯事件罗列) | 0/25 | 判平淡 ✓ |
|
||||||
|
| 场景级有戏剧本 | 16/25 | 通过 ✓ |
|
||||||
|
|
||||||
- **已完成**:3 卷框架 + 第 1、2 章上线(Mon Sagus 世界,1–250 年,26 章规划)
|
阈值 12 在两者之间,区分有效。注意:阈值是校准出来的,不是拍出来的——
|
||||||
- **定时任务未启用**:`cron` / 开机自启等稳定运行一段时间后再落地;目前按需手动执行
|
第一版用"梗概体"当好稿样本时只得 11 分,换了场景级样本才验证出尺度没偏严。
|
||||||
|
|
||||||
|
## 十二、当前状态与限制
|
||||||
|
|
||||||
|
- **已完成**:第 1 卷 3 集上线 + 人物表 + 年表;旧的两章流水账已用普通提交删除(历史保留)
|
||||||
|
- **定时任务未启用**:cron / 开机自启等稳定运行一段时间后再落地,目前按需手动执行
|
||||||
- **原始数据不入库**:`data/` 已在 `.gitignore` 中
|
- **原始数据不入库**:`data/` 已在 `.gitignore` 中
|
||||||
- **诚实说明**:世界创建各步骤均已逐步实测通过,并已整合为 `make-world.sh`;但该脚本尚未作为**单次完整调用**重跑验证(单次约 15 分钟),首次使用时请留意各阶段日志
|
- **已知局限**:
|
||||||
|
- 关系图的边来自成对事件,独狼型人物(独自创作、被自然杀死)进不了线索挖掘
|
||||||
|
- 多参与者事件(比赛、集体迫害)会把同场者两两连边,圈子的"关系"偏共处而非互动
|
||||||
|
- 事实校验只拦得住凭空专名;动机、心理、对白全是允许的演绎,没有自动防线
|
||||||
|
|
||||||
## 十一、声明
|
## 十三、声明
|
||||||
|
|
||||||
《Dwarf Fortress》版权归 Bay 12 Games 所有。本仓库只包含由其生成数据的**衍生文本**,
|
《Dwarf Fortress》版权归 Bay 12 Games 所有。本仓库只包含由其生成数据的**衍生文本**,
|
||||||
不含游戏本体、美术资源或存档。
|
不含游戏本体、美术资源或存档。
|
||||||
|
|||||||
+5
-3
@@ -135,8 +135,10 @@ def _bio_prompt(world: World, rows: list[CastRow], events: list[Event]) -> str:
|
|||||||
lines = [BIO_INSTRUCTIONS, "", "材料:"]
|
lines = [BIO_INSTRUCTIONS, "", "材料:"]
|
||||||
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.identity or '不详'},在故事里是{row.role})"
|
f"{row.name}({row.race or '种族不详'},{row.span},本卷出场 {row.appearances} 次,"
|
||||||
|
f"身份:{row.identity or '不详'},在故事里是{row.role})"
|
||||||
|
)
|
||||||
]
|
]
|
||||||
for e in events:
|
for e in events:
|
||||||
if row.fid not in e.figure_ids():
|
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:
|
except json.JSONDecodeError as exc:
|
||||||
raise ValueError(f"小传 JSON 无法解析:{exc}|原文:{raw[:200]}") from None
|
raise ValueError(f"小传 JSON 无法解析:{exc}|原文:{raw[:200]}") from None
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("小传返回的不是对象")
|
raise TypeError("小传返回的不是对象")
|
||||||
return {str(k): str(v) for k, v in data.items()}
|
return {str(k): str(v) for k, v in data.items()}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+26
-115
@@ -1,59 +1,50 @@
|
|||||||
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
|
"""进度状态与中文篇幅统计。
|
||||||
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
|
|
||||||
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
|
|
||||||
|
|
||||||
"""组织 prompt、调用模型、控制篇幅、跑事实校验,产出一章成品。"""
|
(旧版这里还有整套「按年代切章 → 写编年史」的逻辑;那条路径已经被人物主线取代,
|
||||||
|
相关代码与提示词一并删除,只留下仍然需要的东西。)
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dfannals import factcheck
|
from dfannals.config import DATA_DIR
|
||||||
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
|
|
||||||
|
|
||||||
STATE_FILE = DATA_DIR / "state.json"
|
STATE_FILE = DATA_DIR / "state.json"
|
||||||
SPEC_FILE = PROMPT_DIR / "chronicler.md"
|
|
||||||
|
|
||||||
TAIL_CHARS = 600 # 交给下一章衔接的上一章结尾长度
|
|
||||||
REPAIR_ATTEMPTS = 2
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class State:
|
class State:
|
||||||
world: str = ""
|
world: str = ""
|
||||||
volume: int = 1
|
volume: int = 1
|
||||||
done: list[str] = None # type: ignore[assignment]
|
done: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.done is None:
|
|
||||||
self.done = []
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: Path = STATE_FILE) -> State:
|
def load(cls, path: Path = STATE_FILE) -> State:
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return cls()
|
return cls()
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text())
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except json.JSONDecodeError:
|
except (json.JSONDecodeError, OSError):
|
||||||
return cls()
|
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:
|
def save(self, path: Path = STATE_FILE) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(json.dumps({"world": self.world, "volume": self.volume, "done": self.done},
|
path.write_text(
|
||||||
ensure_ascii=False, indent=2))
|
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:
|
def mark_done(self, key: str) -> None:
|
||||||
if key not in self.done:
|
if key not in self.done:
|
||||||
@@ -61,7 +52,11 @@ class State:
|
|||||||
|
|
||||||
|
|
||||||
def cjk_length(text: str) -> int:
|
def cjk_length(text: str) -> int:
|
||||||
"""按中文习惯计字数:CJK 字符与拉丁单词都算 1。"""
|
"""按中文习惯计字数:CJK 字符、拉丁字母数字、中文标点各算 1。
|
||||||
|
|
||||||
|
统计前先去掉代码块与 Markdown 行首标记(标题、引用、列表符),
|
||||||
|
使结果接近读者感知的"正文字数"。
|
||||||
|
"""
|
||||||
without_code = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
without_code = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
||||||
without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.MULTILINE)
|
without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.MULTILINE)
|
||||||
n = 0
|
n = 0
|
||||||
@@ -73,87 +68,3 @@ def cjk_length(text: str) -> int:
|
|||||||
):
|
):
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
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
|
|
||||||
|
|||||||
-333
@@ -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())
|
|
||||||
|
|||||||
+5
-3
@@ -97,7 +97,7 @@ def _parse(raw: str) -> dict:
|
|||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise ValueError(f"自评返回的 JSON 无法解析:{exc}|原文:{raw[:200]}") from None
|
raise ValueError(f"自评返回的 JSON 无法解析:{exc}|原文:{raw[:200]}") from None
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError(f"自评返回的不是对象:{type(data).__name__}")
|
raise TypeError(f"自评返回的不是对象:{type(data).__name__}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -120,8 +120,10 @@ def evaluate(
|
|||||||
scores: dict[str, int] = {}
|
scores: dict[str, int] = {}
|
||||||
for key, _label, _hint in DIMENSIONS:
|
for key, _label, _hint in DIMENSIONS:
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
if not isinstance(value, (int, float)):
|
if value is None:
|
||||||
raise ValueError(f"自评维度 {key} 缺失或非数字:{data.get(key)!r}")
|
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)))
|
scores[key] = max(0, min(MAX_SCORE, int(value)))
|
||||||
|
|
||||||
reason = str(data.get("reason", "")).strip() or "(未给出理由)"
|
reason = str(data.get("reason", "")).strip() or "(未给出理由)"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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年三次和约与一次联盟建立。*
|
|
||||||
@@ -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年和平接受。*
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# 要塞编年史官 · 写作规范
|
|
||||||
|
|
||||||
你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人:
|
|
||||||
你守着档案库,把发生过的事一件件记下来;少数是你亲历的,多数是你事后翻档案、听幸存者口述追述的。
|
|
||||||
正因如此,你对这些陈年旧帐有自己的看法——而且你很乐意说出来。
|
|
||||||
|
|
||||||
## 口吻
|
|
||||||
|
|
||||||
- **战报体**:像一份写给上级、但作者本人憋不住嘴的报告。严肃的骨架,诙谐的血肉。
|
|
||||||
- 允许吐槽、暗讽、夸张、替死人惋惜、替蠢货翻白眼。可以骂人,但骂得有趣。
|
|
||||||
- 情绪可以饱满,但叙事要清楚:读者必须始终知道**谁、在哪、干了什么、结果如何**。
|
|
||||||
|
|
||||||
## 语言
|
|
||||||
|
|
||||||
- 正文用**中文**。
|
|
||||||
- 所有**专有名词保留英文原文**:人物名(Urist McMiner)、地名(Boatmurdered)、
|
|
||||||
文明名、生物名、神器名。不要音译,不要自造中文译名。
|
|
||||||
- 不要把英文词硬塞成中式表达;名字是个代号,句子本身是中文。
|
|
||||||
|
|
||||||
## 演绎的边界
|
|
||||||
|
|
||||||
- **允许**:虚构对白、心理活动、场景细节、围观群众的反应。
|
|
||||||
- **允许**:把数据里冷冰冰的因果,写成有人味的故事。
|
|
||||||
- **禁止**:发明数据里不存在的**专有人名、地名、文明名**。新角色只能来自给定的史料。
|
|
||||||
- **禁止**:改变大事件的年份、参与者、胜负结果。史实骨架不可动。
|
|
||||||
- 若史料本身残缺(只留下一个年份和一句话),就按残缺写——写成"史料到此中断"也是一种味道。
|
|
||||||
|
|
||||||
## 篇幅
|
|
||||||
|
|
||||||
- 正文 **800–1500 字**(中文字符计)。
|
|
||||||
- 宁可写短写实,不要为凑字数灌水。
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
|
|
||||||
```
|
|
||||||
# <章标题>
|
|
||||||
|
|
||||||
<正文,可分段,可用小标题>
|
|
||||||
|
|
||||||
---
|
|
||||||
*史料来源:<本段所依据的史料条目摘要,一到两行>*
|
|
||||||
```
|
|
||||||
|
|
||||||
- 章标题自己起,要有战报味,能概括本章。
|
|
||||||
- 不要在正文里写"以下是……"之类的话,直接进入叙述。
|
|
||||||
- 不要在正文中提到你是在读数据、表格、XML 或 AI。
|
|
||||||
|
|
||||||
## 连续性
|
|
||||||
|
|
||||||
- 这是一部长篇连载的其中一章。读者已经读过前面几章。
|
|
||||||
- 前情提要用一两句带过即可,不要复述整部前文。
|
|
||||||
- 同一批人物、家族、恩怨要延续上一章的状态;本章结束时应留下可被下一章接住的线头。
|
|
||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from dfannals import gate # noqa: E402
|
from dfannals import gate
|
||||||
|
|
||||||
DULL = """# 第二十三章:大事记
|
DULL = """# 第二十三章:大事记
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
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:
|
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:
|
def test_not_accepted_after_max_rewrites() -> None:
|
||||||
"""一直不合格:重写次数不得越过上限,且标记为未通过。"""
|
"""一直不合格:重写次数不得越过上限,且标记为未通过。"""
|
||||||
runs: list[int] = []
|
runs: list[int] = []
|
||||||
text, run = gate.gate_episode(
|
_text, run = gate.gate_episode(
|
||||||
"003",
|
"003",
|
||||||
lambda attempt, _p: (runs.append(attempt), "稿")[1],
|
lambda attempt, _p: (runs.append(attempt), "稿")[1],
|
||||||
evaluate_fn=lambda _t: verdict(3),
|
evaluate_fn=lambda _t: verdict(3),
|
||||||
|
|||||||
Reference in New Issue
Block a user