项目骨架:管道源码、写作规范与说明文档

This commit is contained in:
Chen Yi
2026-10-05 15:56:11 +08:00
commit c56394afdd
8 changed files with 959 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# 原始数据与存档:不入库
data/
vendor/
__pycache__/
*.pyc
*.log
+552
View File
@@ -0,0 +1,552 @@
# Grill Me Results
Generated: 2026-10-05T07:11:32.355Z
## Plan
给我一个手机端个人助手的系统提示词
## Shared Understanding
目标:把矮人要塞(Dwarf Fortress)部署到本地(Windows 侧),并让一条自动化管道持续把游戏世界的历史写成中文连载故事,自动推送到自建 Gitea 公开仓库。
核心取舍:不做 AI 自动游玩(B),只做「世界史编年史」(A)——通过 worldgen 生成世界 + DFHack 导出 legends 历史数据,再由云端 LLM 把结构化历史写成战报体连载。原因:本机无独显(13G 内存 / Radeon 780M),跑不动本地模型;而 AI 操控游戏极其脆弱,被列为第二阶段目标。
三条链路:
1. Windows 侧(/mnt/c/Users/23518):DF 53.16 免费版 + DFHack 53.16-r2 → 生成 Medium 世界 / 250 年历史 → 导出 legends 数据。
2. WSL2 Ubuntu 侧:Python 管道解析 legends → 建年表与人物索引 → 按事件链切片(每章 800–1500 字)→ 组 prompt(要塞编年史官口吻、战报体、自由演绎)→ 调 workbuddy 网关(cn:deepseek-v4-pro)→ 产出 Markdown 章 → 专名事实校验并标注可疑处。
3. Gitea(124.222.29.26:3000,SSH 2222):复用现有 ~/.ssh/id_ed25519 自动 commit + push 到公开仓库,仓库含成品 Markdown + 迷你年表/人物索引。
关键事实(已实测,无需再确认):无任何 API key 环境变量;Pi 的 provider 是用户自建网关 workbuddy(https://wb2api.hajim1.art/v1,39 个模型,/v1/models → 401 表示在线);openai.com 超时,DeepSeek/Moonshot/SiliconFlow 可达;Gitea 返回 200 且 SSH 2222 开放,现有 id_ed25519 已注册为 gitadmin 的 chendy-ubuntu-wsl;Bay 12 当前免费 Windows 包为 df_53_16_win.zip;DFHack 最新为 53.16-r2(有 Windows 官方包);Windows Steam 已装但库中没有矮人要塞。
## Questions and Answers
### 1. 矮人要塞本体跑在哪一侧?
**Recommended answer:** Windows 侧跑 DF + WSL 侧做自动化(已有 Steam、原生 GPU 性能好;WSL 当故事工厂,经 /mnt/c 读存档)
**User answer:** Windows 跑 DF,WSL 跑自动化
**Status:** resolved
**Notes:** 已确认 Windows 有 Steam(但库里无 DF)、WSLg 可用;本机无独显,故写作环节必须离开本机推理。
### 2. 用哪个版本、怎么拿到?
**Recommended answer:** Bay 12 免费 classic v50(免费且 DFHack 主线支持)
**User answer:** Bay 12 免费 classic v50
**Status:** resolved
**Notes:** Windows 版免费 classic v50 + Windows 版 DFHack;无需购买 Steam 版。
### 3. 『不断产出故事』具体指哪种?
**Recommended answer:** A 世界史编年史 + C 人玩机录(先保证持续有内容,B 当第二阶段)
**User answer:** A 世界史编年史
**Status:** resolved
**Notes:** 只做 worldgen/legends 导出叙事,不做 AI 自动游玩。与 continuity 的『同一世界长期连载』结合后,产生一个待澄清矛盾:历史是有限的,写完后如何继续产出。
### 4. 产出节奏和章节长度?
**Recommended answer:** 事件驱动,每章 800–1500 字
**User answer:** 事件驱动,每章 800–1500 字
**Status:** resolved
### 5. 谁来把游戏数据写成故事?
**Recommended answer:** 云端 LLM API(DeepSeek 便宜且本网络可达;本机无独显跑不动本地模型)
**User answer:** 云端 LLM API
**Status:** resolved
**Notes:** 实测:DeepSeek/Moonshot/SiliconFlow 可达,openai.com 超时。
### 6. 若走云端 LLM,每月成本上限?
**Recommended answer:** 20 元以内 / 月
**User answer:** 不限,能跑通就行
**Status:** resolved
**Notes:** 成本不再是约束;模型选择自由度放宽,可用长上下文模型喂完整 legends 数据。
### 7. 故事最终发布到哪?
**Recommended answer:** 先只落本地 git 仓库(Markdown),博客/Gitea 之后接
**User answer:** 推到自建 Gitea(124.222.29.26)
**Status:** resolved
**Notes:** 未选本地 git 仓库与博客,仅 Gitea;需澄清仓库名、可见性与推送认证(SSH 2222 端口)。
### 8. 世界观怎么延续?
**Recommended answer:** 同一世界长期连载(人物家族仇恨跨章延续)
**User answer:** 同一世界长期连载
**Status:** resolved
**Notes:** 与『只做 A』冲突:legends 历史有限,写完即断供,需第二轮定完结策略。
### 9. 什么时候跑?
**Recommended answer:** 先手动一条命令,稳定后再加 cron
**User answer:** 你帮我跑(自定义答案,未选任何选项)
**Status:** resolved
**Notes:** 含义待澄清:可能是『每次我说一声你就跑』或『你替我配好自动化并代跑』。真实约束:agent 无法在会话外自主持续运行,定时执行必须落在 WSL cron 或 Windows 任务计划上。
### 10. 允许我装系统依赖吗?
**Recommended answer:** 可以,sudo apt 随便装
**User answer:** 可以,sudo apt 随便装
**Status:** resolved
**Notes:** WSL 内可装 xvfb、CJK 字体、python 库;Windows 侧 DF+DFHack 仅需解压,不需管理员权限。
### 11. 这次做到哪一步?
**Recommended answer:** 最小 demo:装好 + 跑出 1 章故事
**User answer:** MVP + 定时自动化 + 已发布到某处
**Status:** resolved
**Notes:** 范围比推荐更大,需与 trigger 的澄清一起确定自动化落点。
### 12. 故事要不要配图?
**Recommended answer:** 先不要,先把文字闭环跑通
**User answer:** 纯文字
**Status:** resolved
### 13. 输出语言?
**Recommended answer:** 中文,专名保留英文
**User answer:** 中文(专名保英文,如 Urist、Boatmurdered)
**Status:** resolved
### 14. 故事引擎要不要搬到服务器 124.222.29.26 常驻?
**Recommended answer:** 先本地,之后考虑搬服务器
**User answer:** 先本地,之后考虑搬服务器
**Status:** resolved
### 15. 这个「手机端个人助手」是 softwork1 项目里的助手,还是一个全新的独立助手?
**Recommended answer:** 与 softwork1 项目无关的独立助手
**User answer:** 与 softwork1 无关:只是我独立手机本地的助手
**Status:** resolved
**Notes:** 推翻了第一轮基于 softwork1/sw1-p3 代码事实建立的全部前提(daemon、appendSystemPrompt、memory/rules 注入等都不再适用)。
### 16. 提示词主要给哪类手机助手用?(推荐:云端大模型 App——能力完整、支持自定义系统提示词,兼容性最好)
**Recommended answer:** 云端大模型 App(ChatGPT/Claude/Gemini 等,能填系统提示词)
**User answer:** 云端大模型 App(ChatGPT/Claude/Gemini 等,能填系统提示词)
**Status:** resolved
### 17. 平台范围?(推荐:通用——只写与 iOS/Android 无关的行为规则,以后换 App 也能用)
**Recommended answer:** 通用,不绑定平台
**User answer:** 只 Android
**Status:** resolved
**Notes:** 与推荐不同:仅面向 Android。
### 18. 这个助手有没有「干活」的能力(联网搜索、日历、发消息等)?(推荐:按无工具写——大多数手机 App 只有对话,宁可让它老实说做不到,也不要假装已执行)
**Recommended answer:** 没有工具,只有纯对话
**User answer:** 有工具调用(查网页、日历、笔记、发消息)
**Status:** resolved
**Notes:** 与推荐不同:助手确实有工具,提示词应包含工具使用规则而不是保守回避。
### 19. 你主要用它干什么?可多选(推荐:信息整理 + 写作润色 + 日程待办)
**Recommended answer:** 信息整理与总结、写作与润色、日程待办与计划安排
**User answer:** 信息整理与总结(长文、链接、聊天记录);答疑与学习(技术问题、概念解释);随手记与灵感整理
**Status:** resolved
**Notes:** 与推荐部分不同:不含写作润色与日程待办,含答疑学习与随手记。
### 20. 人格定位?(推荐:干练私人助理)
**Recommended answer:** 干练私人助理(简洁、先结论后细节)
**User answer:** 干练私人助理(简洁、先结论后细节)
**Status:** resolved
### 21. 语言与称呼?(推荐:中文、称「Chen Yi」)
**Recommended answer:** 中文,称呼 Chen Yi
**User answer:** 中文,称呼 Chen Yi
**Status:** resolved
### 22. 单条回复默认多长?(推荐:3–5 行结论 + 要点)
**Recommended answer:** 3–5 行结论 + 要点
**User answer:** 不限长度,完整回答
**Status:** resolved
**Notes:** 与推荐不同,且与「干练私人助理(简洁)」存在张力,已作为第三轮追问点。
### 23. 长回答要不要固定结构?(推荐:结论 / 依据 / 下一步 三段)
**Recommended answer:** 固定三段:结论 / 依据 / 下一步
**User answer:** 不固定(自定义填写,未选任何预设项)
**Status:** resolved
### 24. 信息不足时怎么办?(推荐:先问一个最关键的问题)
**Recommended answer:** 先问一个最关键的问题,等回答
**User answer:** 直接给 2–3 个方案让我选
**Status:** resolved
**Notes:** 与推荐不同,且与「末尾不给下一步建议」存在边界模糊,已作为第三轮追问点。
### 25. 回答末尾要不要带「下一步建议」?(推荐:最多一条)
**Recommended answer:** 最多给一条下一步建议
**User answer:** 不给建议,答完即止
**Status:** resolved
**Notes:** 与「信息不足时给 2–3 个方案」有边界冲突,第三轮澄清。
### 26. 语气约束?(推荐:禁 emoji、禁客套套话)
**Recommended answer:** 禁 emoji、禁客套套话,直接给结论
**User answer:** 允许幽默和闲聊
**Status:** resolved
**Notes:** 与推荐差异较大,且与「干练私人助理」人格、用途清单(未选闲聊)有点张力,第三轮问幽默程度。
### 27. 要不要明确「不许装能力」?(推荐:要)
**Recommended answer:** 明确写:不假装执行任何手机操作或查询,做不到就直说
**User answer:** 只写「不确定就说明」,不专门限制
**Status:** resolved
**Notes:** 与「有工具调用」搭配合理,但与推荐不同;有工具时假装风险较低。
### 28. 隐私口径?(推荐:只用你主动给的信息,不索要敏感信息,不假装记得历史对话)
**Recommended answer:** 只用对话里主动给的信息,不索要敏感信息,不假装记得历史对话
**User answer:** 需要时可以直接追问个人信息
**Status:** resolved
**Notes:** 与推荐不同;因另选了「App 有跨会话记忆」,持久化敏感信息的分寸需在第三轮定。
### 29. 手机 App 一般没有跨会话记忆,怎么处理?(推荐:长任务结尾给一段可复制的上下文摘要)
**Recommended answer:** 长任务结尾给一段可复制的「上下文摘要」
**User answer:** 这个有(自定义填写:App 有跨会话记忆)
**Status:** resolved
**Notes:** 推翻推荐前提:有持久记忆,摘要方案不再必要;改为追问记忆写入机制。
### 30. 讲解深度?(推荐:按计算机专业学生,直接讲机制与结论)
**Recommended answer:** 按技术用户,直接讲机制/结论,不科普
**User answer:** 适度解释术语和取舍
**Status:** resolved
### 31. 提示词本身体量?(推荐:≤1 页 400–800 字)
**Recommended answer:** 精炼,≤1 页(约 400–800 字)
**User answer:** 精炼,≤1 页(约 400–800 字)
**Status:** resolved
### 32. 要不要在提示词里放示例?(推荐:放 1–2 个短范例)
**Recommended answer:** 放 1–2 个短范例
**User answer:** 只写规则,不放例子
**Status:** resolved
**Notes:** 与推荐不同:不带范例,提示词更短。
### 33. 交付验收?(推荐:落一份 md + 聊天贴全文)
**Recommended answer:** md 文件落盘 + 聊天贴全文
**User answer:** md 文件落盘 + 聊天贴全文
**Status:** resolved
### 34. 世界史是有限的历史——同一个世界写到史末之后怎么办?
**Recommended answer:** 按卷连载,写完宣布该世界史完结,再用同一叙述者人设开新世界新一卷
**User answer:** 一部史分卷连载,完结后换新世界开新卷
**Status:** resolved
**Notes:** 第二轮把上一轮『只做 A + 长期连载』的断供矛盾解开了:一个世界分卷连载,完结后换新世界开新卷。
### 35. 『你帮我跑』具体怎么落地?
**Recommended answer:** 先手动跑通,稳定后再开定时
**User answer:** 先手动跑通,稳定后再开定时
**Status:** resolved
**Notes:** 与 deliverable=MVP+定时自动化 不矛盾:定时任务要搭但手动验证后才启用。
### 36. Gitea 上的仓库怎么建?
**Recommended answer:** 新建私有仓库 dwarf-fortress-annals,用 SSH key 自动推
**User answer:** 新建公开仓库
**Status:** resolved
**Notes:** 与 publish_target=Gitea 一致;公开仓库意味着措辞和事实错误会直接对外可见。
### 37. 生成好的一章要不要先给你过目?
**Recommended answer:** 先生成到本地待审,我点头再推
**User answer:** 自动直接推,我事后自己改
**Status:** resolved
**Notes:** 与推荐相反,且仓库为公开:LLM 幻觉会直接进公开『正史』。已列入风险。
### 38. 一章切多大一段历史?
**Recommended answer:** 一个世纪或一条完整事件链
**User answer:** 混合
**Status:** resolved
**Notes:** 与上一轮 events-driven、每章 800–1500 字兼容。
### 39. 世界生成参数倾向?
**Recommended answer:** Medium 世界 + 250 年
**User answer:** Medium 世界 + 250 年
**Status:** resolved
**Notes:** 确定后单次 worldgen 即可支撑数十章素材。
### 40. 用哪家云端 LLM、key 怎么准备?
**Recommended answer:** DeepSeek(便宜且网络可达),key 由你自己申请后写入 WSL 环境变量
**User answer:** 用你的(自定义答案)
**Status:** resolved
**Notes:** 自定义答案『用你的』语义不明:我没有任何可用的 API 密钥,且不能向你索要密钥内容。需第三轮澄清落地方式。
### 41. Windows 侧的 DF + DFHack 由谁装?
**Recommended answer:** 我经 /mnt/c 下载解压放好,你只需最后点开确认
**User answer:** 你(agent)下载解压到 /mnt/c 下
**Status:** resolved
**Notes:** 需确认 Bay 12 下载链路与 Windows 用户目录名(/mnt/c/Users 下有 23518、WsiAccount 等)。
### 42. 叙述口吻用哪种?
**Recommended answer:** 史官编年体(冷静第三人称)
**User answer:** 战报体(诙谐吐槽,像 Boatmurdered)
**Status:** resolved
**Notes:** 口语口吻与『历史数据→叙事』的匹配度低于编年体,但趣味性更高;牵出一新问题:战报体的『我』是谁(因为本方案不玩,没有玩家视角)。
### 43. 『用你的』具体指什么?
**Recommended answer:** 就由我(agent)当写作引擎 / 或你自己导出 key 给脚本
**User answer:** 用 Pi 里已配好的那家(你只需把 key 再导一份给脚本)
**Status:** resolved
**Notes:** 用户选择复用 Pi 已配好的 provider,需确认是哪一家/env 变量名;我不会读取 auth.json 内容。
### 44. 推送到 Gitea 用哪种认证?
**Recommended answer:** 生成新密钥对 + 你贴公钥
**User answer:** 直接复用已有的 ~/.ssh/id_ed25519
**Status:** resolved
**Notes:** 待验证:该公钥是否已在 Gitea 账号注册(已测 2222 端口开放)。
### 45. 战报体里的『我』是谁?
**Recommended answer:** 要塞里的编年史官(在场记录 + 吐槽)
**User answer:** 要塞里的编年史官(在场记录 + 吐槽)
**Status:** resolved
**Notes:** 与自由演绎 + 战报体相洽。
### 46. 公开仓库里放什么?
**Recommended answer:** 成品 + 迷你年表/人物索引
**User answer:** 成品 + 迷你年表/人物索引
**Status:** resolved
**Notes:** 年表/索引同时充当事实核对的参考面。
### 47. 战报体允许多少文学加工?
**Recommended answer:** 自由演绎 + 章末附史料注
**User answer:** 自由演绎:对白、心理、吐槽都写
**Status:** resolved
**Notes:** 与自动专名校验存在张力:校验只能拦住『凭空的人名地名』,拦不住编造的对白与心理活动,但用户已接受此代价。
### 48. 既然自动直推 + 公开仓库,要不要自动事实校验?
**Recommended answer:** 要:自动比对专名,可疑处标注
**User answer:** 要:自动比对专名是否都出现在 legends 数据里,可疑处标注
**Status:** resolved
### 49. 这次做到什么程度算完成?
**Recommended answer:** 第 2、3 章也能稳定产出并自动推上去
**User answer:** DF 能跑 + 第 1 章出现在 Gitea
**Status:** resolved
**Notes:** 本次验收比 deliverable=mvp_plus 窄:定时任务暂不纳入验收,与 automation_owner=先手动跑通一致。
### 50. 用网关里的哪个模型写故事?
**Recommended answer:** cn:deepseek-v4-pro
**User answer:** cn:deepseek-v4-pro(推荐)
**Status:** resolved
**Notes:** 已查明 Pi 配置的 provider 为 workbuddy(baseUrl https://wb2api.hajim1.art/v1,OpenAI 兼容,共 39 个模型),/v1/models 返回 401 证明在线且需鉴权。
### 51. API key 怎么交给脚本?
**Recommended answer:** 脚本运行时自读 ~/.pi/agent/auth.json 的 workbuddy.key
**User answer:** 脚本运行时自己读 ~/.pi/agent/auth.json 里的 workbuddy.key(推荐)
**Status:** resolved
**Notes:** auth.json 结构为 { workbuddy: { type, key } };脚本运行时读取,我不读不打印内容。
## Agreed Decisions
- 部署形态:Windows 侧跑 DF,WSL2 Ubuntu 侧只当「故事工厂」,经 /mnt/c 读写存档与导出数据
- 版本与获取:Bay 12 免费版(当前实为 53.16,df_53_16_win.zip)+ DFHack 53.16-r2 Windows 包;不购买 Steam 版
- 安装执行:由 agent 经 /mnt/c 下载解压到 Windows 用户目录(免管理员权限),用户只需最后点开确认游戏能跑
- 故事形态:只做 A 世界史编年史(worldgen + legends 导出 + LLM 叙事),不做 AI 自动游玩;B 方案列为第二阶段
- 连载完结策略:一个世界分卷连载,写完全部历史即宣布该世界史完结,再生成新世界开新卷,用同一套叙述者人设维持系列感
- 章节切分:混合式——有重大事件就写事件链,太平年份合并成章;每章 800–1500 字,事件驱动而非日更
- 世界参数:Medium 世界 + 250 年历史
- 叙事引擎:复用 Pi 里已配好的 provider,即用户自建网关 workbuddy(base_url https://wb2api.hajim1.art/v1,OpenAI 兼容)
- 写作模型:固定使用 cn:deepseek-v4-pro(1M 上下文可整包喂 legends,固定模型保证跨章文风一致)
- API key 交付:脚本运行时自读 ~/.pi/agent/auth.json 中的 workbuddy.key;不额外落明文副本,agent 只写读取代码、不读不打印内容
- 叙述者身份:要塞里的编年史官(在场记录 + 吐槽),不用现代玩家视角,也不用单个人物第一人称日记
- 叙述口吻:战报体(诙谐吐槽,类比经典 Boatmurdered)
- 演绎边界:自由演绎——对白、心理活动、吐槽都可写(代价是读者无法区分史料与文学)
- 自动事实校验:要。生成后自动比对专名是否都出现在 legends 数据中,可疑处标注(不阻断推送)
- 发布去向:只推自建 Gitea(124.222.29.26),不落本地 git 仓库、不发博客
- Gitea 仓库:新建公开仓库 dwarf-fortress-annals
- 推送认证:直接复用现有 ~/.ssh/id_ed25519(已验证在 Gitea 注册为 chendy-ubuntu-wsl / gitadmin),无需新建密钥或 token
- 仓库内容:成品 Markdown + 迷你年表/人物索引;不入库原始 legends 数据与存档
- 推送门槛:不做人工审核,自动直接推送到 Gitea,用户事后自行修改
- 输出语言:中文,专有名词保留英文(如 Urist、Boatmurdered)
- 配图:纯文字,不做地图/ASCII 渲染配图
- 系统依赖:允许 sudo apt 任意安装 WSL 侧依赖
- 触发方式:先手动一条命令跑通,稳定后再开 WSL cron 定时;当前不启用常驻 daemon 与开机自启
- 运行位置:先全部在本地,后续再考虑搬到服务器 124.222.29.26
- 本次验收标准:DF 能在 Windows 侧跑起来 + 第 1 章中文故事出现在 Gitea 公开仓库(定时任务不纳入本轮验收)
## Open Risks
- 自动直推 + 公开仓库:LLM 幻觉会直接进入对外可见的「正史」。用户接受事后自行修改,但专名校验只能拦住凭空出现的人名地名,拦不住编造的对白与心理活动(自由演绎已明确放开)——这类错误没有任何自动防线
- 唯一外部依赖是用户自建网关 workbuddy(https://wb2api.hajim1.art/v1):它一旦不可用/额度耗尽,整条产出链停摆;且该网关不在本机,排障需要用户侧信息
- cron 定时依赖 WSL 常驻:Windows 关机、WSL 闲置回收都会让产出静默断供;无人值守时失败不易被发现
- 战报体 + 固定「要塞编年史官」人设需要跨章长期一致,仅靠 prompt 约束,长连载下口吻与设定可能漂移(跨章设定漂移检查未列入本次范围)
- 版本必须严格配对:DF 53.16 只能配 DFHack 53.16-r2;DF 自动更新会导致 DFHack 失效,需要锁定版本
- legends 导出的自动化程度尚未实测:53.x 上的导出入口(游戏内 UI / DFHack 命令)能否完全脚本化、免手动点击,需要在实施第一步验证;若必须人工点击,则「无人值守」目标需降级
- WSL2 为 NAT 网络(网关 172.31.48.1),WSL 与 Windows 侧进程的实时通信(若将来需要 DFHack RPC)需额外配置;本方案走文件交换可绕开,但第二阶段 B 方案会直面此问题
- Windows 用户目录疑似 /mnt/c/Users/23518(另有 WsiAccount),实施时需确认哪个是实际登录账户
- 服务器迁移路径已被承认但未设计:服务器无图形界面,未来最多只能承载「世界史生成 + 叙事 + 发布」,游玩相关环节无法搬移
## Next Decision Needed
无待决项。下一步是实施:先验证 legends 导出的可脚本化程度与 Windows 实际用户目录,再搭 WSL 管道,最后以「DF 能跑 + 第 1 章推到 Gitea」验收。
+54
View File
@@ -0,0 +1,54 @@
# 矮人要塞编年史 · Dwarf Fortress Annals
把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,写成**中文战报体连载故事**,自动推送到这个仓库。
本仓库是**成品仓**:只放小说章节、迷你年表与人物索引。
原始 `legends` 导出数据与游戏存档留在本地,不入库。
## 这本"书"是怎么来的
```
Windows 侧 DF 53.16 + DFHack 53.16-r2
│ ① 生成 Medium 世界 / 250 年历史
│ ② 进入 legends 模式,导出 legends.xml + legends_plus.xml
▼ ③ 经 /mnt/c 文件交换(无需网络互访)
WSL2 Ubuntu 侧管道
│ ④ 解析史料 → 构建年表与人物索引
│ ⑤ 按事件链切章(每章 800–1500 字)
│ ⑥ 组 prompt(要塞编年史官口吻 / 战报体)→ 调用 LLM 写作
│ ⑦ 专名事实校验:正文里的专有名词必须出现在史料中,可疑处标注
▼ ⑧ git commit & push
Gitea: gitadmin/dwarf-fortress-annals(本仓库)
```
## 目录
| 路径 | 内容 | 是否入库 |
|---|---|---|
| `output/chapters/` | 连载正文(Markdown) | ✅ |
| `output/timeline.md` | 迷你年表 | ✅ |
| `output/figures.md` | 人物索引 | ✅ |
| `src/dfannals/` | 管道源码 | ✅ |
| `prompts/` | 写作规范(决定文风与人设) | ✅ |
| `data/exports/` | 原始 legends 数据 | ❌ |
| `vendor/` | 游戏与 DFHack 安装包 | ❌ |
## 写作设定
- **叙述者**:要塞里的编年史官,在场记录,负责把事写下来
- **口吻**:战报体——严肃的骨架,诙谐的血肉
- **语言**:中文;专有名词一律保留英文原文(Urist、Boatmurdered……)
- **演绎**:允许虚构对白、心理与吐槽;但**不许发明史料里没有的人名地名**,也不许改写史实骨架
- **连载**:同一世界长期连载;一部史写完则换新世界开新卷,由同一位"编年史官"承接
## 运行
```bash
# 凭据:API key 运行时从 Pi 的 ~/.pi/agent/auth.json 读取,不落本仓库
python3 -m dfannals.cli --help
```
## 声明
《Dwarf Fortress》版权归 Bay 12 Games 所有。本仓库仅包含由其生成数据的**衍生文本**,
不含游戏本体、美术资源或存档。
+51
View File
@@ -0,0 +1,51 @@
# 要塞编年史官 · 写作规范
你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人:
你亲眼见过这些事,或者在事后翻过档案,现在由你把它们写下来。
## 口吻
- **战报体**:像一份写给上级、但作者本人憋不住嘴的报告。严肃的骨架,诙谐的血肉。
- 允许吐槽、暗讽、夸张、替死人惋惜、替蠢货翻白眼。可以骂人,但骂得有趣。
- 情绪可以饱满,但叙事要清楚:读者必须始终知道**谁、在哪、干了什么、结果如何**。
## 语言
- 正文用**中文**。
- 所有**专有名词保留英文原文**:人物名(Urist McMiner)、地名(Boatmurdered)、
文明名、生物名、神器名。不要音译,不要自造中文译名。
- 不要把英文词硬塞成中式表达;名字是个代号,句子本身是中文。
## 演绎的边界
- **允许**:虚构对白、心理活动、场景细节、围观群众的反应。
- **允许**:把数据里冷冰冰的因果,写成有人味的故事。
- **禁止**:发明数据里不存在的**专有人名、地名、文明名**。新角色只能来自给定的史料。
- **禁止**:改变大事件的年份、参与者、胜负结果。史实骨架不可动。
- 若史料本身残缺(只留下一个年份和一句话),就按残缺写——写成"史料到此中断"也是一种味道。
## 篇幅
- 正文 **800–1500 字**(中文字符计)。
- 宁可写短写实,不要为凑字数灌水。
## 输出格式
```
# <章标题>
<正文,可分段,可用小标题>
---
*史料来源:<本段所依据的史料条目摘要,一到两行>*
```
- 章标题自己起,要有战报味,能概括本章。
- 不要在正文里写"以下是……"之类的话,直接进入叙述。
- 不要在正文中提到你是在读数据、表格、XML 或 AI。
## 连续性
- 这是一部长篇连载的其中一章。读者已经读过前面几章。
- 前情提要用一两句带过即可,不要复述整部前文。
- 同一批人物、家族、恩怨要延续上一章的状态;本章结束时应留下可被下一章接住的线头。
+1
View File
@@ -0,0 +1 @@
"""矮人要塞编年史生成管道。"""
+110
View File
@@ -0,0 +1,110 @@
"""项目配置:路径、模型、网关、发布目标。
原则:API key 不落本项目文件,运行时从 Pi 的 auth.json 读取。
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
HOME = Path.home()
# ---------------------------------------------------------------- 目录
PROJECT_DIR = Path(__file__).resolve().parents[2] # /home/chendy/df-annals
DATA_DIR = PROJECT_DIR / "data" # 原始数据,不入库
EXPORT_DIR = DATA_DIR / "exports" # legends 导出原始文件
OUTPUT_DIR = PROJECT_DIR / "output" # 成品,入库
CHAPTER_DIR = OUTPUT_DIR / "chapters"
PROMPT_DIR = PROJECT_DIR / "prompts"
# ---------------------------------------------------------------- 游戏(Windows 侧)
WINDOWS_USER = "23518"
WIN_DF_ROOT = Path(f"/mnt/c/Users/{WINDOWS_USER}/DwarfFortress") # 下载/安装根目录
WIN_DF_DIR_CANDIDATES = [
WIN_DF_ROOT / "df_53_16_win",
WIN_DF_ROOT / "Dwarf Fortress",
WIN_DF_ROOT,
]
DF_VERSION = "53.16"
DFHACK_VERSION = "53.16-r2"
# ---------------------------------------------------------------- LLM 网关
PI_AUTH_JSON = HOME / ".pi/agent/auth.json"
PI_MODELS_JSON = HOME / ".pi/agent/models.json"
PROVIDER = "workbuddy"
MODEL = "cn:deepseek-v4-pro"
# 该模型是推理模型:思考与正文共用 max_tokens,必须留足
MAX_TOKENS_CHAPTER = 16000
MAX_TOKENS_UTIL = 4000
# ---------------------------------------------------------------- 发布
GITEA_SSH_HOST = "124.222.29.26"
GITEA_SSH_PORT = 2222
GITEA_USER = "gitadmin"
GITEA_REPO = "dwarf-fortress-annals"
GITEA_WEB = "http://124.222.29.26:3000"
GITEA_REMOTE = f"ssh://git@{GITEA_SSH_HOST}:{GITEA_SSH_PORT}/{GITEA_USER}/{GITEA_REPO}.git"
SSH_KEY = HOME / ".ssh/id_ed25519"
# ---------------------------------------------------------------- 世界与章节
WORLD_SIZE = "Medium"
WORLD_HISTORY_YEARS = 250
CHAPTER_MIN_CHARS = 800
CHAPTER_MAX_CHARS = 1600 # 契约上限 1500,留出标点/换行余量
@dataclass
class Gateway:
base_url: str
key: str
model: str = MODEL
provider: str = PROVIDER
def load_gateway(provider: str | None = None) -> Gateway:
"""从 Pi 配置读取网关地址与密钥。密钥只驻留内存,不打印、不落盘。"""
provider = provider or PROVIDER
def _read_json(path: Path, label: str) -> dict:
try:
return json.loads(path.read_text())
except FileNotFoundError:
raise SystemExit(f"找不到 Pi 的{label}文件: {path}") from None
except json.JSONDecodeError as exc:
raise SystemExit(f"Pi 的{label}文件不是合法 JSON: {path} ({exc})") from None
auth = _read_json(PI_AUTH_JSON, "凭据")
models = _read_json(PI_MODELS_JSON, "模型配置")
providers = models.get("providers", models)
if provider not in providers:
raise SystemExit(f"Pi 配置里没有 provider: {provider}")
if provider not in auth:
raise SystemExit(f"Pi 未授权 provider: {provider}")
entry = auth[provider]
key = entry.get("key") if isinstance(entry, dict) else entry
if not key:
raise SystemExit(f"provider {provider} 的凭据为空")
return Gateway(base_url=providers[provider]["baseUrl"].rstrip("/"), key=key, provider=provider)
def find_df_dir() -> Path:
"""定位已安装的 DF 目录(含 Dwarf Fortress.exe)。"""
override = os.environ.get("DF_DIR")
if override:
p = Path(override)
if p.is_dir():
return p
raise SystemExit(f"DF_DIR 不存在: {p}")
for cand in WIN_DF_DIR_CANDIDATES:
if (cand / "Dwarf Fortress.exe").is_file():
return cand
raise SystemExit(
"未找到 Dwarf Fortress.exe。候选路径:" + ", ".join(str(c) for c in WIN_DF_DIR_CANDIDATES)
)
def ensure_dirs() -> None:
for d in (DATA_DIR, EXPORT_DIR, OUTPUT_DIR, CHAPTER_DIR, PROMPT_DIR):
d.mkdir(parents=True, exist_ok=True)
+106
View File
@@ -0,0 +1,106 @@
"""调用用户自建网关(OpenAI 兼容)生成文本。
要点:
- 密钥运行时从 Pi 的 auth.json 读取,只在内存中,永不打印或写入产物。
- 目标是推理模型,思考与正文共用 max_tokens,因此默认给足预算。
- 传输层用 http.client(方案显式限定 http/https),不依赖第三方库。
"""
from __future__ import annotations
import http.client
import json
import time
from dataclasses import dataclass
from urllib.parse import urlsplit
from .config import MAX_TOKENS_CHAPTER, MODEL, Gateway, load_gateway
@dataclass
class Reply:
content: str
reasoning_tokens: int
total_tokens: int
model: str
class GatewayError(RuntimeError):
pass
def _endpoint(base_url: str) -> tuple[type[http.client.HTTPConnection], str, str]:
"""解析网关地址,只接受 http/https。"""
parts = urlsplit(base_url)
if parts.scheme not in ("http", "https"):
raise GatewayError(f"网关地址必须是 http(s):{base_url!r}")
if not parts.netloc:
raise GatewayError(f"网关地址缺少主机:{base_url!r}")
cls = http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection
path = parts.path.rstrip("/") + "/chat/completions"
return cls, parts.netloc, path
def chat(
messages: list[dict],
*,
gateway: Gateway | None = None,
model: str = MODEL,
max_tokens: int = MAX_TOKENS_CHAPTER,
temperature: float = 1.0,
timeout: int = 900,
retries: int = 3,
) -> Reply:
gw = gateway or load_gateway()
conn_cls, host, path = _endpoint(gw.base_url)
body = json.dumps(
{
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
).encode()
headers = {
"Content-Type": "application/json",
# 密钥仅在此处作为请求头使用,不落盘、不打印
"Authorization": "Bearer " + gw.key,
}
last: str | None = None
for attempt in range(1, retries + 1):
conn = conn_cls(host, timeout=timeout)
try:
conn.request("POST", path, body=body, headers=headers)
resp = conn.getresponse()
raw = resp.read()
if resp.status != 200:
last = f"HTTP {resp.status}: {raw[:400].decode('utf-8', 'replace')}"
if resp.status in (400, 401, 403, 404):
break
else:
data = json.loads(raw)
choice = data["choices"][0]["message"]
usage = data.get("usage") or {}
content = (choice.get("content") or "").strip()
if not content:
raise GatewayError(
"模型只产出了思考、没有正文;"
f"thinking_tokens={usage.get('completion_thinking_tokens')},请加大 max_tokens"
)
return Reply(
content=content,
reasoning_tokens=int(usage.get("completion_thinking_tokens") or 0),
total_tokens=int(usage.get("total_tokens") or 0),
model=data.get("model", model),
)
except (OSError, http.client.HTTPException, json.JSONDecodeError, KeyError) as exc:
last = f"{type(exc).__name__}: {exc}"
except GatewayError as exc:
last = str(exc)
break
finally:
conn.close()
if attempt < retries:
time.sleep(3 * attempt)
raise GatewayError(f"网关调用失败({gw.base_url},model={model}):{last}")
+79
View File
@@ -0,0 +1,79 @@
"""把成品章节与年表推送到自建 Gitea。
- 推送凭据复用现有 ~/.ssh/id_ed25519(已验证在 Gitea 注册为 gitadmin 的 chendy-ubuntu-wsl)。
- 原始 legends 数据与存档不入库(见 .gitignore)。
- 自动直推,不设人工审核环节(按访谈决策)。
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from .config import GITEA_REMOTE, PROJECT_DIR, SSH_KEY
GITIGNORE = """\
# 原始数据与存档:不入库
data/
vendor/
__pycache__/
*.pyc
*.log
"""
def _git(*args: str, check: bool = True) -> subprocess.CompletedProcess:
env = {
"GIT_SSH_COMMAND": f"ssh -i {SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new",
"GIT_TERMINAL_PROMPT": "0",
}
proc = subprocess.run(
["git", *args],
cwd=PROJECT_DIR,
env={**_base_env(), **env},
capture_output=True,
text=True,
)
if check and proc.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} 失败:{proc.stderr.strip() or proc.stdout.strip()}")
return proc
def _base_env() -> dict:
import os
env = dict(os.environ)
env.pop("GIT_DIR", None)
return env
def ensure_repo() -> None:
"""初始化仓库、写 .gitignore、设置 remote 与提交身份。"""
if not (PROJECT_DIR / ".git").is_dir():
_git("init", "-b", "main")
(PROJECT_DIR / ".gitignore").write_text(GITIGNORE)
remotes = _git("remote").stdout.split()
if "origin" in remotes:
_git("remote", "set-url", "origin", GITEA_REMOTE)
else:
_git("remote", "add", "origin", GITEA_REMOTE)
if not _git("config", "user.name", check=False).stdout.strip():
_git("config", "user.name", "DF Annals Bot")
if not _git("config", "user.email", check=False).stdout.strip():
_git("config", "user.email", "df-annals@hajim1.art")
def commit_and_push(message: str, paths: list[Path] | None = None) -> str:
"""提交并推送。返回提交哈希;无改动时返回空串。"""
if paths:
for p in paths:
_git("add", "--", str(p.relative_to(PROJECT_DIR)) if p.is_absolute() else str(p))
else:
_git("add", "-A")
if not _git("status", "--porcelain").stdout.strip():
return ""
_git("commit", "-m", message)
head = _git("rev-parse", "--short", "HEAD").stdout.strip()
_git("push", "-u", "origin", "HEAD:main")
return head