160 lines
5.3 KiB
Python
160 lines
5.3 KiB
Python
# 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
|