Files
Chen Yi e0073e90bd 第 1 卷(人物主线):第 1–3 集 + 人物表
主角线:Guspu Frillyknots / Ral Fastenhatchets / Alath Blottedmine
由线索挖掘器自动选定,按因果链切集,经 5 维度自评闸门(≥12 分)后入库。
2026-10-05 19:28:49 +08:00

288 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""人物线索挖掘:从史料里找出「关系密切、有戏」的 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