# 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.DOTALL) without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.MULTILINE) 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