Files
dwarf-fortress-annals/dfannals/chronicle.py
T

160 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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
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 = []
@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:
return cls()
return cls(world=data.get("world", ""), volume=data.get("volume", 1), done=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))
def mark_done(self, key: str) -> None:
if key not in self.done:
self.done.append(key)
def cjk_length(text: str) -> int:
"""按中文习惯计字数:CJK 字符与拉丁单词都算 1。"""
without_code = re.sub(r"```.*?```", "", text, flags=re.S)
without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.M)
n = 0
for ch in without_meta:
if (
unicodedata.east_asian_width(ch) in ("W", "F")
or ch.isalnum()
or ch in ",。!?;:、"
):
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