"""人物线索挖掘:从史料里找出「关系密切、有戏」的 3–6 人小圈子。 设计依据全部来自对 Mon Sagus 真实数据(403853 条事件)的实测: 1. **不能用裸互动次数排序。** 实测排名第一的簇 49 次互动里有 35 次是 ``hf relationship denied``——反复请求建立关系、反复被拒的循环,是统计噪声。 因此整类剔除(访谈已定)。 2. **同一对同一类型反复出现也不是故事。** 修掉上一条之后,候选又变成 ``对战×27`` 这种「同一对人反复互殴」的机械循环。因此在计数时对 (人物对, 事件类型) 做封顶,避免刷量取胜——这也正是访谈定的 「转折/冲突多样性为主、互动次数为次」。 3. **主线必须是"人"。** 种族分布实测:ELF/GOBLIN/DWARF/HUMAN/KOBOLD 五族加 ``*_MAN`` 人形族占全部历史人物的 96.5%,其余 400 多种是夜行怪、野兽、 泰坦、实验体。所以这里用白名单,而不是越列越长的黑名单。 4. 强边阈值 ≥5 次反复互动;簇规模 3–6 人;跨度 ≥30 年。 实测:阈值提到 8 会一条候选都不剩,5 是正确档位。 """ from __future__ import annotations import json from collections import Counter, defaultdict from dataclasses import dataclass, field from pathlib import Path from dfannals.legends import Event, World # 事件类型 → (权重, 中文标签, 是否算转折点) SIGNALS: dict[str, tuple[int, str, bool]] = { "hf simple battle event": (1, "对战", False), "add hf hf link": (1, "结缘", False), "hfs formed reputation relationship": (1, "结缘", False), "competition": (1, "竞争", False), "hf wounded": (2, "搏杀", True), "hf confronted": (2, "对峙", True), "hf interrogated": (2, "审讯", True), "failed intrigue corruption": (2, "阴谋", True), "remove hf hf link": (3, "反目", True), "hf abducted": (3, "绑架", True), "hf convicted": (3, "定罪", True), "entity persecuted": (3, "迫害", True), "hf enslaved": (3, "奴役", True), "hf ransomed": (3, "贖金", True), "entity overthrown": (3, "推翻", True), "failed frame attempt": (3, "构陷", True), } # 明确剔除:重复性请求,实测会刷满排序(访谈已定) EXCLUDED_SIGNALS = ("hf relationship denied",) # 文明种族白名单:实测覆盖 96.5% 的历史人物 CIVILIZED_RACES = frozenset({"DWARF", "ELF", "HUMAN", "GOBLIN", "KOBOLD"}) DEFAULT_MIN_INTERACTIONS = 5 # 强边阈值:≥5 次反复互动 DEFAULT_SIZE_RANGE = (3, 6) DEFAULT_MIN_SPAN = 30 # 跨度 ≥30 年才撑得起连载 PAIR_TYPE_CAP = 4 # 同一对、同一类型最多计 4 次 MIN_SIGNAL_KINDS = 2 # 至少两种不同信号,否则只是单一类型的重复 INTERACTION_CAP = 40 # 互动总量封顶,防止刷量取胜 @dataclass class Edge: a: int b: int raw: int = 0 types: Counter = field(default_factory=Counter) first_year: int = 0 last_year: int = 0 @property def effective(self) -> int: """封顶后的有效互动次数。""" return sum(min(n, PAIR_TYPE_CAP) for n in self.types.values()) @property def turns(self) -> int: """封顶后的转折点次数。""" return sum(min(n, PAIR_TYPE_CAP) for t, n in self.types.items() if SIGNALS[t][2]) @dataclass class Thread: members: list[int] interactions: int turns: int span: int first_year: int last_year: int types: Counter races: Counter non_person_races: list[str] events: list[Event] @property def kinds(self) -> int: return len(self.types) @property def score(self) -> int: """转折为主、多样性次之、互动次数封顶计入(访谈定的排序原则)。""" return self.turns * 10 + self.kinds * 6 + min(self.interactions, INTERACTION_CAP) @property def label(self) -> str: return f"{self.first_year}–{self.last_year}({self.span} 年)" def signal_line(self) -> str: return "、".join(f"{SIGNALS[t][1]}×{n}" for t, n in self.types.most_common() if t in SIGNALS) def is_person(race: str) -> bool: """是否属于"可作为主角的人":文明种族或人形族(*_MAN)。""" r = (race or "").upper() return r in CIVILIZED_RACES or r.endswith("_MAN") def build_edges(world: World) -> dict[tuple[int, int], Edge]: """按有叙事含义的信号建立人物之间的边。""" edges: dict[tuple[int, int], Edge] = {} for e in world.events: if e.type not in SIGNALS: continue people = e.figure_ids() if len(people) < 2: continue for i in range(len(people)): for j in range(i + 1, len(people)): key = (min(people[i], people[j]), max(people[i], people[j])) edge = edges.get(key) if edge is None: edge = Edge(a=key[0], b=key[1], first_year=e.year, last_year=e.year) edges[key] = edge edge.raw += 1 edge.types[e.type] += 1 edge.first_year = min(edge.first_year, e.year) edge.last_year = max(edge.last_year, e.year) return edges def find_threads( world: World, min_interactions: int = DEFAULT_MIN_INTERACTIONS, size_range: tuple[int, int] = DEFAULT_SIZE_RANGE, min_span: int = DEFAULT_MIN_SPAN, require_people: bool = True, min_kinds: int = MIN_SIGNAL_KINDS, limit: int = 0, ) -> list[Thread]: """返回按剧情张力排序的候选线索。""" edges = build_edges(world) strong = {k: v for k, v in edges.items() if v.effective >= min_interactions} parent: dict[int, int] = {} def find(x: int) -> int: parent.setdefault(x, x) while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x for (a, b) in strong: ra, rb = find(a), find(b) if ra != rb: parent[ra] = rb members_of: dict[int, set[int]] = defaultdict(set) for x in parent: members_of[find(x)].add(x) events_by_pair: dict[tuple[int, int], list[Event]] = defaultdict(list) for e in world.events: if e.type not in SIGNALS: continue people = e.figure_ids() if len(people) < 2: continue for i in range(len(people)): for j in range(i + 1, len(people)): events_by_pair[(min(people[i], people[j]), max(people[i], people[j]))].append(e) lo_size, hi_size = size_range threads: list[Thread] = [] for members in members_of.values(): if not (lo_size <= len(members) <= hi_size): continue races = Counter(world.figures[m].race for m in members if m in world.figures) if require_people and not all(is_person(r) for r in races): continue inner = {k: v for k, v in strong.items() if k[0] in members and k[1] in members} if not inner: continue types: Counter = Counter() for v in inner.values(): types.update({t: min(n, PAIR_TYPE_CAP) for t, n in v.types.items()}) if len(types) < min_kinds: continue first = min(v.first_year for v in inner.values()) last = max(v.last_year for v in inner.values()) if last - first < min_span: continue evs: list[Event] = [] seen_ids: set[int] = set() for k in inner: for e in events_by_pair.get(k, []): if e.id not in seen_ids: seen_ids.add(e.id) evs.append(e) evs.sort(key=lambda e: (e.year, e.seconds72, e.id)) threads.append( Thread( members=sorted(members), interactions=sum(v.effective for v in inner.values()), turns=sum(v.turns for v in inner.values()), span=last - first, first_year=first, last_year=last, types=types, races=races, non_person_races=sorted(r for r in races if not is_person(r)), events=evs, ) ) threads.sort(key=lambda t: (-t.score, -t.span)) return threads[:limit] if limit else threads def render_report(world: World, threads: list[Thread], per_thread: int = 3) -> str: """给人看的候选明细。""" lines = [ f"候选线索 {len(threads)} 条(转折为主、多样性次之;同一对同类事件已封顶)", "筛选:强边 ≥5 次互动、3–6 人、跨度 ≥30 年、含巨兽的簇已剔除", "", ] for i, t in enumerate(threads, 1): races = "、".join(f"{r}×{n}" for r, n in t.races.most_common()) monsters = "、".join(t.non_person_races) if t.non_person_races else "无" lines.append( f"{i:2d}. 得分 {t.score:4d} | {len(t.members)} 人 | 有效互动 {t.interactions:3d} | " f"转折 {t.turns:2d} | 信号 {t.kinds} 种 | 跨度 {t.label} | 巨兽:{monsters}" ) lines.append(f" 种族:{races}") lines.append(f" 信号:{t.signal_line()}") lines.append(f" 成员:{'、'.join(world.figure_name(m) for m in t.members)}") for e in t.events[:per_thread]: lines.append(f" · {e.year}年 {e.type} " + " · ".join(world.render_refs(e))) lines.append("") return "\n".join(lines) def to_json(world: World, threads: list[Thread]) -> dict: return { "world": world.name, "candidates": [ { "rank": i, "score": t.score, "members": [ {"id": m, "name": world.figure_name(m), "race": world.figures[m].race if m in world.figures else ""} for m in t.members ], "interactions": t.interactions, "turns": t.turns, "kinds": t.kinds, "span": t.span, "first_year": t.first_year, "last_year": t.last_year, "signals": {SIGNALS[k][1]: v for k, v in t.types.items() if k in SIGNALS}, "event_ids": [e.id for e in t.events], } for i, t in enumerate(threads, 1) ], } def save_json(world: World, threads: list[Thread], path: Path) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(to_json(world, threads), ensure_ascii=False, indent=2), encoding="utf-8") return path