- 修复上次提交把 dfannals/cli.py 写成 0 字节的问题(它是唯一入口,导致管道不可运行) - 性别:解析 <caste>,人物表与写作素材带性别(Ral Fastenhatchets 实为女性) - 新增 dfannals/deslop.py:AI 味机械诊断(硬伤词/句式/标点,按千字密度报告) - 新增 dfannals/normalize.py:标点与结构清理(引号配对、重复段落与句子、模型自加的小节标记) - 新增 dfannals/expand.py 与 prompts/literary-expander.md:按年份场景分块大量扩写 - 专名防幻觉:每块附史料专名白名单,事后按段自动修复可疑专名 - episode 命令并进文笔层(骨架稿另存 .skeleton.md),新增 expand 命令做 A/B 对照 - 新增 notes/switched-threads.md 与 test_deslop / test_normalize 回归测试 - 提交前拦截「已跟踪文件为空」,防止上述事故复发
136 lines
5.2 KiB
Python
136 lines
5.2 KiB
Python
"""项目配置:路径、模型、网关、发布目标。
|
|
|
|
原则: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[1] # /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"
|
|
|
|
|
|
def volume_dir(volume: int) -> Path:
|
|
"""每一卷(一个世界)独立的成品目录。"""
|
|
return OUTPUT_DIR / "volumes" / f"v{volume}"
|
|
|
|
|
|
def chapter_dir(volume: int) -> Path:
|
|
return volume_dir(volume) / "chapters"
|
|
|
|
# ---------------------------------------------------------------- 游戏(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"
|
|
# 文笔层(扩写)单独选模型:创作向模型写叙事更贴地,
|
|
# 而推理向模型(deepseek-v4-pro)天然偏总结陈词,容易写成报告。
|
|
LITERARY_MODEL = "cn:minimax-m3"
|
|
# 该模型是推理模型:思考与正文共用 max_tokens,必须留足
|
|
MAX_TOKENS_CHAPTER = 16000
|
|
MAX_TOKENS_UTIL = 4000
|
|
# 凡是「喂长材料 + 要求结构化 JSON 输出」的调用(闸门自评、人物小传)都容易把预算
|
|
# 烧在思考上而返回空正文,实测 4000 不够。
|
|
MAX_TOKENS_STRUCTURED = 12000
|
|
|
|
# ---------------------------------------------------------------- 发布
|
|
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 = 1500 # 严格按访谈定的 800–1500,不留“余量”
|
|
|
|
# ---------------------------------------------------------------- 文笔层(扩写)
|
|
# 用户口径:要的是「大量扩写」——原稿是骨架,文笔层把它逐事件展开成现场。
|
|
EXPAND_TARGET_CHARS = 8000 # 目标字数(用户选定:每集约 8000 字)
|
|
EXPAND_FLOOR = 6000 # 低于此判太短,继续展开
|
|
EXPAND_CEILING = 11000 # 高于此判太长,压缩
|
|
# 一次调用写 8000 字很容易跑偏、漏事件,所以按场景分块展开,每块目标这么多字
|
|
EXPAND_CHUNK_TARGET = 2600
|
|
# 长文 + 可能的思考 tokens,预算要给足
|
|
MAX_TOKENS_EXPAND = 32000
|
|
|
|
|
|
@dataclass
|
|
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)
|