第 1 卷(人物主线):第 1–3 集 + 人物表
主角线:Guspu Frillyknots / Ral Fastenhatchets / Alath Blottedmine 由线索挖掘器自动选定,按因果链切集,经 5 维度自评闸门(≥12 分)后入库。
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
"""人物主线写作与编排:选主角组 → 切集 → 贴着人物写 → 过闸门 → 落盘。
|
||||
|
||||
与旧的 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
|
||||
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.span}|身份:{row.identity or '不详'}"
|
||||
f"|本集出场 {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.identity or '不详'}"
|
||||
f"|本集出场 {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 "\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} 条线索都没过闸门,需要人工干预")
|
||||
Reference in New Issue
Block a user