第 1 卷第 1 章:1–9 年
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user