删除旧的事件驱动章节(流水账),并同步文档到人物主线

- 移除 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:
Chen Yi
2026-10-05 19:32:41 +08:00
parent e0073e90bd
commit 08a032120a
11 changed files with 164 additions and 833 deletions
+26 -115
View File
@@ -1,59 +1,50 @@
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
"""进度状态与中文篇幅统计。
"""组织 prompt、调用模型、控制篇幅、跑事实校验,产出一章成品。"""
(旧版这里还有整套「按年代切章 → 写编年史」的逻辑;那条路径已经被人物主线取代,
相关代码与提示词一并删除,只留下仍然需要的东西。)
"""
from __future__ import annotations
import json
import re
import unicodedata
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from dfannals import factcheck
from dfannals import slice as chapter_slice
from dfannals.config import (
CHAPTER_MAX_CHARS,
CHAPTER_MIN_CHARS,
DATA_DIR,
PROMPT_DIR,
Gateway,
)
from dfannals.legends import World
from dfannals.llm import Reply, chat
from dfannals.config import DATA_DIR
STATE_FILE = DATA_DIR / "state.json"
SPEC_FILE = PROMPT_DIR / "chronicler.md"
TAIL_CHARS = 600 # 交给下一章衔接的上一章结尾长度
REPAIR_ATTEMPTS = 2
@dataclass
class State:
world: str = ""
volume: int = 1
done: list[str] = None # type: ignore[assignment]
def __post_init__(self) -> None:
if self.done is None:
self.done = []
done: list[str] = field(default_factory=list)
@classmethod
def load(cls, path: Path = STATE_FILE) -> State:
if not path.is_file():
return cls()
try:
data = json.loads(path.read_text())
except json.JSONDecodeError:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return cls()
return cls(world=data.get("world", ""), volume=data.get("volume", 1), done=data.get("done", []))
if not isinstance(data, dict):
return cls()
return cls(
world=data.get("world", ""),
volume=data.get("volume", 1),
done=list(data.get("done", [])),
)
def save(self, path: Path = STATE_FILE) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"world": self.world, "volume": self.volume, "done": self.done},
ensure_ascii=False, indent=2))
path.write_text(
json.dumps({"world": self.world, "volume": self.volume, "done": self.done},
ensure_ascii=False, indent=2),
encoding="utf-8",
)
def mark_done(self, key: str) -> None:
if key not in self.done:
@@ -61,7 +52,11 @@ class State:
def cjk_length(text: str) -> int:
"""按中文习惯计字数:CJK 字符与拉丁单词都算 1。"""
"""按中文习惯计字数:CJK 字符、拉丁字母数字、中文标点各算 1。
统计前先去掉代码块与 Markdown 行首标记(标题、引用、列表符),
使结果接近读者感知的"正文字数"。
"""
without_code = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.MULTILINE)
n = 0
@@ -73,87 +68,3 @@ def cjk_length(text: str) -> int:
):
n += 1
return n
def previous_tail(chapter_dir: Path, chapters: list[chapter_slice.Chapter], key: str) -> str:
"""取上一章结尾,作为本章的衔接线索。"""
idx = next((i for i, c in enumerate(chapters) if c.key == key), None)
if idx is None or idx == 0:
return ""
prev = chapters[idx - 1]
path = chapter_dir / f"{prev.key}.md"
if not path.is_file():
return ""
text = path.read_text(encoding="utf-8").strip()
return text[-TAIL_CHARS:]
def build_messages(world: World, chapter: chapter_slice.Chapter, tail: str) -> list[dict]:
spec = SPEC_FILE.read_text(encoding="utf-8")
payload = chapter_slice.material(world, chapter)
user = [
f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""),
f"本书体例:第 {chapter.index} 章,覆盖 {chapter.span}",
"",
"以下是本章的史料素材(来自 legends 导出,事件名称保留游戏原文):",
"",
payload,
]
if tail:
user += [
"",
"### 上一章结尾(用于衔接,不要复述,只承接状态)",
"",
tail,
]
user += [
"",
f"现在请写出本章正文,{CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字。",
]
return [
{"role": "system", "content": spec},
{"role": "user", "content": "\n".join(user)},
]
def _repair(messages: list[dict], draft: str, length: int, gateway: Gateway | None) -> str:
target = (
f"内容太短({length} 字),请扩写到 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,补充细节与对白,不要注水。"
if length < CHAPTER_MIN_CHARS
else f"内容太长({length} 字),请压缩到 {CHAPTER_MAX_CHARS} 字以内,删掉次要枝节,保留主干与最有趣的段落。"
)
reply: Reply = chat(
messages + [{"role": "assistant", "content": draft}, {"role": "user", "content": target}],
gateway=gateway,
)
return reply.content
def generate(
world: World,
chapter: chapter_slice.Chapter,
*,
tail: str = "",
gateway: Gateway | None = None,
max_repairs: int = REPAIR_ATTEMPTS,
) -> tuple[str, list[factcheck.Suspicion], Reply | None]:
"""生成一章:调用模型 → 篇幅修正 → 事实校验 → 标注。"""
messages = build_messages(world, chapter, tail)
reply = chat(messages, gateway=gateway)
body = reply.content
for _ in range(max_repairs):
length = cjk_length(body)
if CHAPTER_MIN_CHARS <= length <= CHAPTER_MAX_CHARS:
break
body = _repair(messages, body, length, gateway)
suspicions = factcheck.check(body, world)
return factcheck.annotate(body, suspicions), suspicions, reply
def write_chapter(chapter_dir: Path, chapter: chapter_slice.Chapter, body: str) -> Path:
chapter_dir.mkdir(parents=True, exist_ok=True)
path = chapter_dir / f"{chapter.key}.md"
path.write_text(body.strip() + "\n", encoding="utf-8")
return path