"""人物主线写作与编排:选主角组 → 切集 → 贴着人物写 → 过闸门 → 落盘。 与旧的 chronicle.py 的区别:那里的主语是"年代",一章按 10 年窗口罗列事件; 这里的主语是"人",一集是主角线上一段完整回合,世界大事只在影响到主角时提及。 """ from __future__ import annotations import json from collections import Counter from collections.abc import Callable from dataclasses import dataclass, replace from pathlib import Path from dfannals import episodes as ep_mod from dfannals import gate, localize from dfannals.cast import collect_rows from dfannals.chronicle import cjk_length from dfannals.config import ( CHAPTER_MAX_CHARS, CHAPTER_MIN_CHARS, MAX_TOKENS_CHAPTER, PROJECT_DIR, PROMPT_DIR, ) from dfannals.legends import Event, World from dfannals.llm import chat from dfannals.threads import SIGNALS, Thread SPEC_FILE = PROMPT_DIR / "biographer.md" TAIL_CHARS = 600 SELECTION_FILE = PROJECT_DIR / "data" / "selection.json" @dataclass class Selection: rank: int thread: Thread reason: str @dataclass class Produced: selection: Selection episode: ep_mod.Episode text: str run: gate.GateRun path: Path | None = None @dataclass class Extended: """主角参与的扩展素材。""" events: list[Event] elided: Counter PER_KEY_CAP = 3 # 同一组人 + 同一类型,每集素材最多保留几次 def expand_thread_events(world: World, thread: Thread, per_key_cap: int = PER_KEY_CAP) -> Extended: """把素材从“成员之间”扩到“主角参与的全部有意义事件”。 实测:某 3 人线索成员之间只有 14 条事件(只能切 1 集),但把他们的对外活动 (构陷、对战、结义、定罪……)算进来有 136 条,足够支撑一卷。 代价是重复:这 136 条里有 64 条是同一类“构陷失败”。所以同一组人 + 同一类型 最多保留 per_key_cap 条,其余计入 elided 供写作时一笔带过——否则又会变流水账。 """ members = set(thread.members) seen: Counter = Counter() keep: list[Event] = [] elided: Counter = Counter() relevant = [ e for e in world.events if e.type in SIGNALS and (members & set(e.figure_ids())) ] relevant.sort(key=lambda e: (e.year, e.seconds72, e.id)) for e in relevant: key = (e.type, tuple(sorted(e.figure_ids()))) seen[key] += 1 if seen[key] <= per_key_cap: keep.append(e) else: elided[e.type] += 1 return Extended(events=keep, elided=elided) def extended_plan(world: World, thread: Thread, per_key_cap: int = PER_KEY_CAP ) -> tuple[Extended, list[ep_mod.Episode]]: """用扩展素材切集(保持切集规则不变)。""" ext = expand_thread_events(world, thread, per_key_cap=per_key_cap) planned = ep_mod.plan_episodes(replace(thread, events=ext.events)) return ext, planned def select_thread(world: World, candidates: list[Thread], rank: int = 1) -> Selection: """按挖掘器排序取第 rank 条,并把选择依据写成可读理由。""" thread = candidates[rank - 1] names = "、".join(world.figure_name(m) for m in thread.members) reason = ( f"挖掘器排序第 {rank} 名(转折为主、互动封顶):" f"{len(thread.members)} 人、有效互动 {thread.interactions}、转折 {thread.turns}、" f"信号 {thread.kinds} 种({thread.signal_line()})、跨度 {thread.label};成员:{names}" ) return Selection(rank=rank, thread=thread, reason=reason) def save_selection(selection: Selection, world: World, path: Path = SELECTION_FILE) -> Path: """把主角组的选定依据落到本地(不入库,供追溯)。""" path.parent.mkdir(parents=True, exist_ok=True) thread = selection.thread path.write_text( json.dumps( { "world": world.name, "rank": selection.rank, "reason": selection.reason, "score": thread.score, "members": [ {"id": m, "name": world.figure_name(m), "race": world.figures[m].race if m in world.figures else ""} for m in thread.members ], "signals": dict(thread.types), "span": thread.label, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) return path def render_material( world: World, thread: Thread, episode: ep_mod.Episode, total_episodes: int, prev_tail: str = "", elided: Counter | None = None, ) -> str: """把一集素材渲染给传记作者:主角是谁、发生了什么、对手是谁。""" rows = {r.fid: r for r in collect_rows(world, thread, [episode], max_others=6)} heros, others = ep_mod.cast_of(world, thread, episode) lines = [ f"## 本集:第 {episode.index}/{total_episodes} 集,{episode.span}", "", "### 主角(本集要写的就是他们)", "", ] for fid in heros: row = rows.get(fid) if row: lines.append(f"- {row.name}|{row.race}|性别:{row.gender or '史料未载'}" f"|{row.span}|身份:{row.identity or '不详'}|本集出场 {row.appearances} 次") else: lines.append(f"- {world.figure_name(fid)}") lines += ["", "### 本集史料(按时间排序;专名保留游戏原文)", ""] for e in episode.events: refs = " · ".join(world.render_refs(e)) extra = [f"{k}={e.text(k)}" for k in ("state", "reason", "circumstance") if e.text(k) and e.text(k) not in ("-1", "")] tail = ("|" + ";".join(extra)) if extra else "" lines.append(f"- {e.year}年 {e.type} · {refs}{tail}") if others: lines += ["", "### 对手/相关者(史料里出现,但不是本卷主角)", ""] for fid in others[:6]: row = rows.get(fid) if row: lines.append(f"- {row.name}|{row.race}|性别:{row.gender or '史料未载'}" f"|身份:{row.identity or '不详'}|本集出场 {row.appearances} 次") else: lines.append(f"- {world.figure_name(fid)}") if elided: hot = "、".join(f"{t}×{n}" for t, n in elided.most_common(6)) lines += [ "", "### 被省略的同类重复事件", "", f"- 本卷还有这些同类事件已被过滤,不要逐条罗列,需要时用一句话带过:{hot}", ] if prev_tail: lines += ["", "### 上一集结尾(只用于承接状态,不要复述)", "", prev_tail] lines += [ "", "### 写作要求", "", f"- 本集 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,中文正文,人名地名用上面给出的中文译名。", "- 贴着主角写:他们的目标、算计、得失做主语;对手要是个具体的人。", "- 性别按上面卡片写,不要默认“他”;史料未载性别时用名字或身份称呼。", "- 史料之外的世界大事不要写;只有影响到主角时才提一句。", ] # 专名本土化:素材里的英文原名换成中文译名,让模型直接用中文写 return localize.for_world(world).render("\n".join(lines)) def build_messages( world: World, thread: Thread, episode: ep_mod.Episode, total_episodes: int, prev_tail: str, feedback: str | None = None, elided: Counter | None = None, ) -> list[dict]: spec = SPEC_FILE.read_text(encoding="utf-8") material = render_material(world, thread, episode, total_episodes, prev_tail, elided) user = [material] if feedback: user += ["", "### 上一稿的问题(重写时必须针对这些改)", "", feedback] return [{"role": "system", "content": spec}, {"role": "user", "content": "\n".join(user)}] def _repair(messages: list[dict], draft: str, length: int, gateway) -> str: target = ( f"内容太短({length} 字),请扩写到 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字," "补充场景与对白,不要注水。" if length < CHAPTER_MIN_CHARS else f"内容太长({length} 字),请压缩到 {CHAPTER_MAX_CHARS} 字以内,删次要枝节。" ) reply = chat(messages + [{"role": "assistant", "content": draft}, {"role": "user", "content": target}], gateway=gateway) return reply.content def write_episode( world: World, thread: Thread, episode: ep_mod.Episode, total_episodes: int, prev_tail: str = "", feedback: str | None = None, *, gateway=None, max_repairs: int = 2, elided: Counter | None = None, ) -> str: """生成一集正文,并把长度校正到 800–1500 字。""" messages = build_messages(world, thread, episode, total_episodes, prev_tail, feedback, elided) text = chat(messages, gateway=gateway, max_tokens=MAX_TOKENS_CHAPTER).content for _ in range(max_repairs): length = cjk_length(text) if CHAPTER_MIN_CHARS <= length <= CHAPTER_MAX_CHARS: break text = _repair(messages, text, length, gateway) return text.strip() def write_file(directory: Path, episode: ep_mod.Episode, text: str) -> Path: directory.mkdir(parents=True, exist_ok=True) path = directory / f"{episode.key}.md" path.write_text(text.strip() + "\n", encoding="utf-8") return path def prev_tail_of(directory: Path, planned: list[ep_mod.Episode], index: int) -> str: """取上一集结尾作为承接线索。""" if index <= 1: return "" path = directory / f"{planned[index - 2].key}.md" if not path.is_file(): return "" return path.read_text(encoding="utf-8").strip()[-TAIL_CHARS:] def produce_episode( world: World, candidates: list[Thread], *, episode_index: int = 1, out_dir: Path | None = None, gateway=None, max_candidates: int = gate.MAX_CANDIDATES, on_event: Callable[[str], None] = print, ) -> Produced: """选线索 → 写这一集 → 过闸门;不过就换下一线索(有上限)。""" if not candidates: raise RuntimeError("没有候选线索可用") for rank in range(1, min(max_candidates, len(candidates)) + 1): selection = select_thread(world, candidates, rank=rank) thread = selection.thread ext, planned = extended_plan(world, thread) if episode_index > len(planned): on_event(f"第 {rank} 条线索只有 {len(planned)} 集,跳过") continue episode = planned[episode_index - 1] directory = out_dir or (PROJECT_DIR / "output" / "volumes" / "v1" / "chapters") tail = prev_tail_of(directory, planned, episode_index) on_event(f"选定线索 {rank}:{'、'.join(world.figure_name(m) for m in thread.members)}") # 把循环变量绑定成默认参数:否则闭包会绑定到循环变量本身(后面还会变) def write( attempt: int, prev: gate.Verdict | None, _thread: Thread = thread, _episode: ep_mod.Episode = episode, _total: int = len(planned), _tail: str = tail, _elided: Counter = ext.elided, ) -> str: feedback = None if prev is not None: feedback = (f"上一稿被判平淡:{prev.render()}。" f"最弱环节是 {'、'.join(prev.weak())},请围绕这些重写," "让人物真正做选择。") return write_episode(world, _thread, _episode, _total, _tail, feedback, gateway=gateway, elided=_elided) text, run = gate.gate_episode(episode.key, write, on_event=on_event) on_event(run.render()) if run.accepted: save_selection(selection, world) path = write_file(directory, episode, text) if out_dir is not None else None return Produced(selection=selection, episode=episode, text=text, run=run, path=path) gate.append_switch_note( PROJECT_DIR, world_name=world.name, members=[world.figure_name(m) for m in thread.members], span=thread.label, verdict=run.final, reason=f"重写 {run.rewrites} 次后仍未过闸门(第 {rank} 条线索)", ) on_event(f"线索 {rank} 未过闸门,换下一条") raise RuntimeError(f"前 {max_candidates} 条线索都没过闸门,需要人工干预")