From c56394afdd140ed9a1c4baab94b271a0e00223af Mon Sep 17 00:00:00 2001 From: Chen Yi <466354947@qq.com> Date: Mon, 5 Oct 2026 15:56:11 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E9=AA=A8=E6=9E=B6=EF=BC=9A?= =?UTF-8?q?=E7=AE=A1=E9=81=93=E6=BA=90=E7=A0=81=E3=80=81=E5=86=99=E4=BD=9C?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E4=B8=8E=E8=AF=B4=E6=98=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + GRILL-ME.md | 552 +++++++++++++++++++++++++++++++++++++++ README.md | 54 ++++ prompts/chronicler.md | 51 ++++ src/dfannals/__init__.py | 1 + src/dfannals/config.py | 110 ++++++++ src/dfannals/llm.py | 106 ++++++++ src/dfannals/publish.py | 79 ++++++ 8 files changed, 959 insertions(+) create mode 100644 .gitignore create mode 100644 GRILL-ME.md create mode 100644 README.md create mode 100644 prompts/chronicler.md create mode 100644 src/dfannals/__init__.py create mode 100644 src/dfannals/config.py create mode 100644 src/dfannals/llm.py create mode 100644 src/dfannals/publish.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad8de7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# 原始数据与存档:不入库 +data/ +vendor/ +__pycache__/ +*.pyc +*.log diff --git a/GRILL-ME.md b/GRILL-ME.md new file mode 100644 index 0000000..2be1d5d --- /dev/null +++ b/GRILL-ME.md @@ -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」验收。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c75b27f --- /dev/null +++ b/README.md @@ -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 所有。本仓库仅包含由其生成数据的**衍生文本**, +不含游戏本体、美术资源或存档。 diff --git a/prompts/chronicler.md b/prompts/chronicler.md new file mode 100644 index 0000000..2e666e7 --- /dev/null +++ b/prompts/chronicler.md @@ -0,0 +1,51 @@ +# 要塞编年史官 · 写作规范 + +你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人: +你亲眼见过这些事,或者在事后翻过档案,现在由你把它们写下来。 + +## 口吻 + +- **战报体**:像一份写给上级、但作者本人憋不住嘴的报告。严肃的骨架,诙谐的血肉。 +- 允许吐槽、暗讽、夸张、替死人惋惜、替蠢货翻白眼。可以骂人,但骂得有趣。 +- 情绪可以饱满,但叙事要清楚:读者必须始终知道**谁、在哪、干了什么、结果如何**。 + +## 语言 + +- 正文用**中文**。 +- 所有**专有名词保留英文原文**:人物名(Urist McMiner)、地名(Boatmurdered)、 + 文明名、生物名、神器名。不要音译,不要自造中文译名。 +- 不要把英文词硬塞成中式表达;名字是个代号,句子本身是中文。 + +## 演绎的边界 + +- **允许**:虚构对白、心理活动、场景细节、围观群众的反应。 +- **允许**:把数据里冷冰冰的因果,写成有人味的故事。 +- **禁止**:发明数据里不存在的**专有人名、地名、文明名**。新角色只能来自给定的史料。 +- **禁止**:改变大事件的年份、参与者、胜负结果。史实骨架不可动。 +- 若史料本身残缺(只留下一个年份和一句话),就按残缺写——写成"史料到此中断"也是一种味道。 + +## 篇幅 + +- 正文 **800–1500 字**(中文字符计)。 +- 宁可写短写实,不要为凑字数灌水。 + +## 输出格式 + +``` +# <章标题> + +<正文,可分段,可用小标题> + +--- +*史料来源:<本段所依据的史料条目摘要,一到两行>* +``` + +- 章标题自己起,要有战报味,能概括本章。 +- 不要在正文里写"以下是……"之类的话,直接进入叙述。 +- 不要在正文中提到你是在读数据、表格、XML 或 AI。 + +## 连续性 + +- 这是一部长篇连载的其中一章。读者已经读过前面几章。 +- 前情提要用一两句带过即可,不要复述整部前文。 +- 同一批人物、家族、恩怨要延续上一章的状态;本章结束时应留下可被下一章接住的线头。 diff --git a/src/dfannals/__init__.py b/src/dfannals/__init__.py new file mode 100644 index 0000000..22223d0 --- /dev/null +++ b/src/dfannals/__init__.py @@ -0,0 +1 @@ +"""矮人要塞编年史生成管道。""" diff --git a/src/dfannals/config.py b/src/dfannals/config.py new file mode 100644 index 0000000..2dba36d --- /dev/null +++ b/src/dfannals/config.py @@ -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) diff --git a/src/dfannals/llm.py b/src/dfannals/llm.py new file mode 100644 index 0000000..baf7195 --- /dev/null +++ b/src/dfannals/llm.py @@ -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}") diff --git a/src/dfannals/publish.py b/src/dfannals/publish.py new file mode 100644 index 0000000..0710594 --- /dev/null +++ b/src/dfannals/publish.py @@ -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