第 1 卷(人物主线):第 1–3 集 + 人物表

主角线:Guspu Frillyknots / Ral Fastenhatchets / Alath Blottedmine
由线索挖掘器自动选定,按因果链切集,经 5 维度自评闸门(≥12 分)后入库。
This commit is contained in:
Chen Yi
2026-10-05 19:28:49 +08:00
parent 594cf48a1a
commit e0073e90bd
24 changed files with 2070 additions and 79 deletions
+192
View File
@@ -0,0 +1,192 @@
"""因果链切集:把一条人物线索切成"一集一个完整回合"。
参数来自访谈决策:
- 相邻事件间隔 >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 字,真实字数在生成时会再校正)"
)