主角线:Guspu Frillyknots / Ral Fastenhatchets / Alath Blottedmine 由线索挖掘器自动选定,按因果链切集,经 5 维度自评闸门(≥12 分)后入库。
193 lines
6.7 KiB
Python
193 lines
6.7 KiB
Python
"""因果链切集:把一条人物线索切成"一集一个完整回合"。
|
||
|
||
参数来自访谈决策:
|
||
|
||
- 相邻事件间隔 >2 年即断开(比推荐值更紧,节奏更快)
|
||
- 短链只合并,不设线索总量下限
|
||
- 链路过长按字数预算拆上下集
|
||
- 每集正文预算落在 800–1500 字
|
||
|
||
字数预算用「事件条数 × 每条展开字数」估算。系数 110 是按此前实测校准的:
|
||
12 条史料事件生成的中文正文约 1100–1400 字。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass, field
|
||
|
||
from dfannals.legends import Event, World
|
||
from dfannals.threads import Thread
|
||
|
||
# 相邻事件间隔超过它即断开
|
||
GAP_YEARS = 2
|
||
# 每条史料在正文里大致展开的字数。实测区间约 85–115(同样 12 条事件,
|
||
# 两次生成分别得到 1026 字与 1388 字),取中值偏保守,避免把集拆得过碎。
|
||
CHARS_PER_EVENT = 95
|
||
MIN_CHARS = 800 # 每集预算下限
|
||
MAX_CHARS = 1500 # 每集预算上限
|
||
|
||
|
||
@dataclass
|
||
class Episode:
|
||
index: int
|
||
start_year: int
|
||
end_year: int
|
||
events: list[Event] = field(default_factory=list)
|
||
part: int = 1
|
||
parts: int = 1
|
||
|
||
@property
|
||
def key(self) -> str:
|
||
base = f"{self.index:03d}-{self.start_year}-{self.end_year}"
|
||
return base if self.parts == 1 else f"{base}-p{self.part}"
|
||
|
||
@property
|
||
def span(self) -> str:
|
||
if self.start_year == self.end_year:
|
||
return f"{self.start_year} 年"
|
||
return f"{self.start_year}–{self.end_year} 年"
|
||
|
||
@property
|
||
def estimated_chars(self) -> int:
|
||
return len(self.events) * CHARS_PER_EVENT
|
||
|
||
def in_budget(self) -> bool:
|
||
return MIN_CHARS <= self.estimated_chars <= MAX_CHARS or self.parts > 1
|
||
|
||
|
||
def _min_events() -> int:
|
||
return max(1, math.ceil(MIN_CHARS / CHARS_PER_EVENT))
|
||
|
||
|
||
def _max_events() -> int:
|
||
return max(1, MAX_CHARS // CHARS_PER_EVENT)
|
||
|
||
|
||
def _split_chains(events: list[Event], gap_years: int) -> list[list[Event]]:
|
||
"""按时间间隔把事件串切成因果链。"""
|
||
chains: list[list[Event]] = []
|
||
current: list[Event] = []
|
||
prev_year: int | None = None
|
||
for e in events:
|
||
if current and prev_year is not None and e.year - prev_year > gap_years:
|
||
chains.append(current)
|
||
current = []
|
||
current.append(e)
|
||
prev_year = e.year
|
||
if current:
|
||
chains.append(current)
|
||
return chains
|
||
|
||
|
||
def _merge_short(chains: list[list[Event]], min_events: int) -> list[list[Event]]:
|
||
"""短链只合并:攒够预算就收一集,尾部不足的并入上一集。"""
|
||
merged: list[list[Event]] = []
|
||
buffer: list[Event] = []
|
||
for chain in chains:
|
||
buffer.extend(chain)
|
||
if len(buffer) >= min_events:
|
||
merged.append(buffer)
|
||
buffer = []
|
||
if buffer:
|
||
if merged:
|
||
merged[-1].extend(buffer)
|
||
else:
|
||
merged = [buffer]
|
||
return merged
|
||
|
||
|
||
def _split_long(chain: list[Event]) -> list[list[Event]]:
|
||
"""过长链路按预算拆成若干段(对应正文的上/下集)。
|
||
|
||
注意不能均分:14 条事件均分成 7+7 会得到两个 770 字的碎片,
|
||
两端都跌破 800 字下限。所以先取最小段数,再确保每段不低于下限。
|
||
"""
|
||
n = len(chain)
|
||
max_events = _max_events()
|
||
min_events = _min_events()
|
||
if n <= max_events:
|
||
return [chain]
|
||
parts = math.ceil(n / max_events)
|
||
while parts > 1 and math.ceil(n / parts) < min_events:
|
||
parts -= 1
|
||
size = math.ceil(n / parts)
|
||
return [chain[i:i + size] for i in range(0, n, size)]
|
||
|
||
|
||
def plan_episodes(
|
||
thread: Thread,
|
||
gap_years: int = GAP_YEARS,
|
||
min_chars: int = MIN_CHARS,
|
||
max_chars: int = MAX_CHARS,
|
||
) -> list[Episode]:
|
||
"""给定主角组,切出剧集序列。"""
|
||
if not thread.events:
|
||
return []
|
||
|
||
chains = _split_chains(thread.events, gap_years)
|
||
merged = _merge_short(chains, _min_events())
|
||
|
||
episodes: list[Episode] = []
|
||
for chain in merged:
|
||
segments = _split_long(chain)
|
||
for i, segment in enumerate(segments, 1):
|
||
episodes.append(
|
||
Episode(
|
||
index=len(episodes) + 1,
|
||
start_year=segment[0].year,
|
||
end_year=segment[-1].year,
|
||
events=segment,
|
||
part=i,
|
||
parts=len(segments),
|
||
)
|
||
)
|
||
return episodes
|
||
|
||
|
||
def cast_of(world: World, thread: Thread, episode: Episode) -> tuple[list[int], list[int]]:
|
||
"""返回 (本集出场的主角, 本集出现的对手/外人)。"""
|
||
counter: dict[int, int] = {}
|
||
for e in episode.events:
|
||
for fid in e.figure_ids():
|
||
counter[fid] = counter.get(fid, 0) + 1
|
||
members = set(thread.members)
|
||
heros = sorted((f for f in counter if f in members), key=lambda f: -counter[f])
|
||
others = sorted((f for f in counter if f not in members), key=lambda f: -counter[f])
|
||
return heros, others
|
||
|
||
|
||
def render_plan(world: World, thread: Thread, episodes: list[Episode], top_others: int = 3) -> str:
|
||
"""剧集清单:集号、年份范围、事件条数、主角与对手。"""
|
||
lines = [
|
||
f"线索主角:{'、'.join(world.figure_name(m) for m in thread.members)}",
|
||
f"线索跨度:{thread.label} | 事件 {len(thread.events)} 条 | 规划 {len(episodes)} 集",
|
||
"",
|
||
]
|
||
for ep in episodes:
|
||
heros, others = cast_of(world, thread, ep)
|
||
hero_names = "、".join(world.figure_name(h) for h in heros[:4]) or "(本集无主角出场)"
|
||
other_names = "、".join(world.figure_name(o) for o in others[:top_others])
|
||
budget = f"{ep.estimated_chars} 字估算"
|
||
mark = "" if MIN_CHARS <= ep.estimated_chars <= MAX_CHARS else " ⚠ 超出预算"
|
||
lines.append(
|
||
f" 第 {ep.index:2d} 集({ep.span})| {len(ep.events):2d} 条事件 | {budget}{mark}"
|
||
+ (f" | 上/下:{ep.part}/{ep.parts}" if ep.parts > 1 else "")
|
||
)
|
||
lines.append(f" 主角:{hero_names}")
|
||
if other_names:
|
||
lines.append(f" 对手/外人:{other_names}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def budget_report(episodes: list[Episode]) -> str:
|
||
if not episodes:
|
||
return "无剧集"
|
||
inside = sum(1 for e in episodes if MIN_CHARS <= e.estimated_chars <= MAX_CHARS)
|
||
sizes = [len(e.events) for e in episodes]
|
||
return (
|
||
f"共 {len(episodes)} 集;事件条数 {min(sizes)}–{max(sizes)};"
|
||
f"估算字数 {min(e.estimated_chars for e in episodes)}–{max(e.estimated_chars for e in episodes)};"
|
||
f"落在 {MIN_CHARS}–{MAX_CHARS} 预算内的 {inside}/{len(episodes)} 集"
|
||
"(估算按每条约 95 字,真实字数在生成时会再校正)"
|
||
)
|