"""把史料切成章节素材。 真实数据(403853 条事件 / 250 年)证明:按"重要事件数量"切章会得到几千章。 所以改为先按时间窗分桶(每窗 10 年),再在窗内按显著度取前 N 条—— 这样一章天然覆盖一段年月,既有大事件链,也不会漏掉时代的推进。 """ from __future__ import annotations from collections import defaultdict from dataclasses import dataclass, field from dfannals import legends as lg from dfannals import score from dfannals.legends import Event, World YEARS_PER_CHAPTER = 10 # 一个时间窗覆盖的年数 EVENTS_PER_CHAPTER = 12 # 每章最多收录的史料条目 MIN_EVENTS_PER_CHAPTER = 3 # 少于这个数量就并入上一章(太平年月) MAX_PER_CATEGORY = 4 # 同一类事件每章上限,避免整章都是讣告 @dataclass class Chapter: index: int start_year: int end_year: int events: list[Event] = field(default_factory=list) quiet_years: list[tuple[int, int]] = field(default_factory=list) @property def key(self) -> str: return f"{self.index:03d}-{self.start_year}-{self.end_year}" @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} 年" def categories(self) -> list[str]: seen: list[str] = [] for e in self.events: c = score.category(e) if c not in seen: seen.append(c) return seen def plan( world: World, years_per_chapter: int = YEARS_PER_CHAPTER, per_chapter: int = EVENTS_PER_CHAPTER, min_events: int = MIN_EVENTS_PER_CHAPTER, max_per_category: int = MAX_PER_CATEGORY, ) -> list[Chapter]: prom = score.prominence(world) scored = [(score.significance(e, prom), e) for e in world.events] scored = [pair for pair in scored if pair[0] > 0] if not scored: lo, hi = world.event_years() return [Chapter(index=1, start_year=lo, end_year=hi)] lo = min(e.year for _, e in scored) hi = max(e.year for _, e in scored) buckets: dict[int, list[tuple[int, Event]]] = defaultdict(list) for sig, e in scored: buckets[e.year // years_per_chapter].append((sig, e)) chapters: list[Chapter] = [] for b in range(lo // years_per_chapter, hi // years_per_chapter + 1): window_start = max(b * years_per_chapter, lo) window_end = min(b * years_per_chapter + years_per_chapter - 1, hi) ranked = sorted(buckets.get(b, []), key=lambda p: (-p[0], p[1].year, p[1].id)) # 按类别限额挑选:让一章里有战事、兴亡、器物,而不是 12 条死亡讣告 picks: list[tuple[int, Event]] = [] used: dict[str, int] = {} for sig, ev in ranked: cat = score.category(ev) if used.get(cat, 0) >= max_per_category: continue used[cat] = used.get(cat, 0) + 1 picks.append((sig, ev)) if len(picks) >= per_chapter: break events = sorted((e for _, e in picks), key=lambda e: (e.year, e.seconds72, e.id)) if not events: if chapters: chapters[-1].quiet_years.append((window_start, window_end)) chapters[-1].end_year = window_end continue if len(events) < min_events and chapters: chapters[-1].events.extend(events) chapters[-1].end_year = window_end continue chapters.append( Chapter(index=len(chapters) + 1, start_year=window_start, end_year=window_end, events=events) ) for i, c in enumerate(chapters, 1): c.index = i return chapters def _figure_line(world: World, fid: int, prom) -> str: fig = world.figures.get(fid) if not fig: return f"- HF#{fid}(史料无记载)" bits = [lg.pretty(fig.name) or f"HF#{fid}", fig.race or "未知种族", fig.alive_span] if fig.profession: bits.append(fig.profession) bits.append(f"史料出现 {prom.get(fid, 0)} 次") return "- " + " | ".join(bits) def material(world: World, chapter: Chapter, max_figures: int = 40) -> str: """把一章的史料渲染成给模型看的素材文本。""" prom = score.prominence(world) lines: list[str] = [ f"## 本章范围:{chapter.span}", "", f"共 {len(chapter.events)} 条史料,类型:{('、'.join(chapter.categories())) or '无'}。", "", "### 事件流水(按时间排序,专名保留游戏原文)", "", ] participation: dict[int, int] = {} for e in chapter.events: # render_refs 会带出双方(比如 slayer_hfid=谁),而不是只剩单方面 hfid chunks = [f"{e.year}年", e.type, *world.render_refs(e)] for i in e.figure_ids(): participation[i] = participation.get(i, 0) + 1 extra = [] for field_name in ("state", "reason", "circumstance"): v = e.text(field_name) if v and v not in ("-1", ""): extra.append(f"{field_name}={v}") if extra: chunks.append("·".join(extra)) lines.append("- " + " · ".join(chunks)) if chapter.quiet_years: spans = "、".join(f"{a}–{b} 年" for a, b in chapter.quiet_years) lines += [ "", "### 太平年月(史料无值得立传的事件)", "", f"- {spans}:这段时期只留下琐碎记录,可一笔带过。", ] lines += ["", "### 本章登场人物(史料中的身份信息)", ""] for fid, _ in sorted(participation.items(), key=lambda kv: -prom.get(kv[0], 0))[:max_figures]: lines.append(_figure_line(world, fid, prom)) sites = sorted({n for e in chapter.events for i in e.ids("site_id") if (n := world.site_name(i))}) if sites: lines += ["", "### 相关地点", "", "- " + "、".join(sites)] ents = sorted({n for e in chapter.events for i in e.ids("entity_id") if (n := world.entity_name(i))}) if ents: lines += ["", "### 相关势力", "", "- " + "、".join(ents)] return "\n".join(lines) def overview(world: World, chapters: list[Chapter], limit: int = 10) -> str: lo, hi = world.event_years() lines = [ f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""), ( f"历史:{lo}–{hi} 年 | 史料事件 {len(world.events)} 条 | " f"人物 {len(world.figures)} | 势力 {len(world.entities)} | 地点 {len(world.sites)}" ), f"规划章节:{len(chapters)} 章(每章约 {YEARS_PER_CHAPTER} 年 / 最多 {EVENTS_PER_CHAPTER} 条史料)", ] for c in chapters[:limit]: lines.append(f" 第 {c.index} 章 {c.span}:{len(c.events)} 条({('、'.join(c.categories())) or '无'})") if len(chapters) > limit: lines.append(f" …… 其余 {len(chapters) - limit} 章略") return "\n".join(lines)