第 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
+5
View File
@@ -10,6 +10,7 @@
<historical_figures>
<historical_figure><id>10</id><name>Urist McMiner</name><race>DWARF</race><appeared>12</appeared><birth_year>12</birth_year><death_year>88</death_year><profession>MINER</profession></historical_figure>
<historical_figure><id>11</id><name>Kogan Deathspear</name><race>GOBLIN</race><appeared>4</appeared><birth_year>4</birth_year><profession>AXEMAN</profession></historical_figure>
<historical_figure><id>12</id><name>liri fogbalded</name><race>ELF</race><appeared>20</appeared><birth_year>20</birth_year></historical_figure>
</historical_figures>
<entities>
<entity><id>100</id><name>The Steel Confederacy</name><type>Civilization</type><race>DWARF</race></entity>
@@ -19,5 +20,9 @@
<historical_event><id>1</id><year>30</year><seconds72>5</seconds72><type>hf simple battle event</type><hfid>10</hfid><slayer_hfid>11</slayer_hfid><site_id>1</site_id></historical_event>
<historical_event><id>2</id><year>31</year><seconds72>0</seconds72><type>created site</type><site_id>2</site_id><entity_id>100</entity_id></historical_event>
<historical_event><id>3</id><year>88</year><seconds72>9</seconds72><type>hf died</type><hfid>10</hfid><slayer_hfid>11</slayer_hfid></historical_event>
<historical_event><id>4</id><year>40</year><seconds72>0</seconds72><type>add hf hf link</type><hfid>10</hfid><hfid_target>12</hfid_target></historical_event>
<historical_event><id>5</id><year>41</year><seconds72>0</seconds72><type>hf simple battle event</type><group_1_hfid>10</group_1_hfid><group_2_hfid>11</group_2_hfid><site_id>1</site_id></historical_event>
<historical_event><id>6</id><year>42</year><seconds72>0</seconds72><type>hf abducted</type><snatcher_hfid>11</snatcher_hfid><target_hfid>12</target_hfid><site_id>1</site_id></historical_event>
<historical_event><id>7</id><year>43</year><seconds72>0</seconds72><type>hf relationship denied</type><seeker_hfid>12</seeker_hfid><target_hfid>10</target_hfid><relationship>apprentice</relationship><reason>prefers working alone</reason></historical_event>
</historical_events>
</df_world>
+119
View File
@@ -0,0 +1,119 @@
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
# 说明:LSP 的 Python 环境看不到本项目包,会把包内 import 误报为缺失模块。
"""因果链切集的回归测试。
对应 t3 契约:间隔 >2 年断开;短链只合并、不设总量下限;过长按预算拆上下集;
每集预算 800–1500 字;输出含集号/年份范围/事件条数/主角与对手。
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from collections import Counter
from dfannals import episodes
from dfannals.legends import Event, Figure, World
from dfannals.threads import Thread
def ev(eid: int, year: int, etype: str = "add hf hf link") -> Event:
return Event(id=eid, year=year, seconds72=0, type=etype,
fields={"hfid": ["1"], "hfid_target": ["2"]})
def fake_thread(events: list[Event]) -> Thread:
return Thread(
members=[1, 2], interactions=len(events), turns=0,
span=(events[-1].year - events[0].year) if events else 0,
first_year=events[0].year if events else 0,
last_year=events[-1].year if events else 0,
types=Counter(), races=Counter(), non_person_races=[], events=events,
)
def test_gap_breaks_chain() -> None:
"""间隔 >2 年必须断开成两条链。"""
events = [ev(i, y) for i, y in enumerate([10, 11, 12, 20, 21, 22])]
chains = episodes._split_chains(events, gap_years=2)
assert len(chains) == 2, [len(c) for c in chains]
assert [len(c) for c in chains] == [3, 3]
def test_small_gap_does_not_break() -> None:
"""间隔恰好 2 年不该断开。"""
chains = episodes._split_chains([ev(1, 10), ev(2, 12)], gap_years=2)
assert len(chains) == 1
def test_long_chain_splits_into_budgeted_parts() -> None:
"""过长链路必须拆成上/下集,且每段都不跌破下限。"""
events = [ev(i, 100 + i) for i in range(30)]
parts = episodes._split_long(events)
assert len(parts) >= 2, "30 条事件没有拆集"
for p in parts:
assert episodes._min_events() <= len(p) <= episodes._max_events(), [len(x) for x in parts]
assert sum(len(p) for p in parts) == 30
def test_split_never_creates_undersized_fragments() -> None:
"""回归:14 条事件曾被均分成 7+7,两段都跌破 800 字下限。"""
events = [ev(i, 200 + i) for i in range(14)]
eps = episodes.plan_episodes(fake_thread(events))
assert len(eps) == 1, f"14 条事件被拆成了 {len(eps)} 集"
assert episodes.MIN_CHARS <= eps[0].estimated_chars <= episodes.MAX_CHARS
def test_short_chain_is_merged_not_dropped() -> None:
"""短链只合并,不设线索总量下限:2 条事件也要能成一集。"""
eps = episodes.plan_episodes(fake_thread([ev(1, 50), ev(2, 51)]))
assert len(eps) == 1
assert len(eps[0].events) == 2
def test_episode_key_marks_parts() -> None:
events = [ev(i, 300 + i) for i in range(30)]
eps = episodes.plan_episodes(fake_thread(events))
assert len(eps) >= 2
assert all(e.parts == len(eps) for e in eps)
assert eps[0].key.endswith("-p1") and eps[1].key.endswith("-p2")
assert eps[0].key != eps[1].key
def test_plan_reports_heroes_and_others() -> None:
"""输出必须能区分主角与对手。"""
world = World(name="test")
for fid, name in ((1, "Hero"), (2, "Ally"), (9, "Rival")):
world.figures[fid] = Figure(id=fid, name=name)
events = [ev(i, 400 + i) for i in range(3)]
events.append(Event(id=99, year=403, seconds72=0, type="hf wounded",
fields={"woundee_hfid": ["1"], "wounder_hfid": ["9"]}))
thread = fake_thread(events)
planned = episodes.plan_episodes(thread)
heroes, others = episodes.cast_of(world, thread, planned[0])
assert 1 in heroes and 2 in heroes
assert 9 in others and 9 not in heroes
text = episodes.render_plan(world, thread, planned)
assert "主角" in text and "对手/外人" in text and "Rival" in text
def _main() -> int:
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failed = 0
for fn in tests:
try:
fn()
print(f" ✓ {fn.__name__}")
except AssertionError as exc:
failed += 1
print(f" ✗ {fn.__name__}: {exc}")
print(f"\n{len(tests) - failed}/{len(tests)} 通过")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(_main())
+44 -3
View File
@@ -12,7 +12,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dfannals import factcheck, legends # noqa: E402
from dfannals import factcheck, legends
FIXTURES = Path(__file__).resolve().parent / "fixtures"
@@ -30,8 +30,49 @@ def test_world_parsed() -> None:
assert world.figure_name(10) == "Urist McMiner"
assert world.site_name(1) == "Boatmurdered"
assert world.entity_name(100) == "The Steel Confederacy"
assert len(world.events) == 3
assert [e.year for e in world.events] == [30, 31, 88]
assert len(world.events) == 7
assert [e.year for e in world.events] == [30, 31, 40, 41, 42, 43, 88]
def test_field_classifier_covers_relationship_fields() -> None:
"""回归:旧版只认 4 个人物字段,导致双方关系在素材里被丢掉。"""
from dfannals.legends import kind_of
for tag in ("hfid", "hfid_target", "group_1_hfid", "group_2_hfid", "snatcher_hfid",
"seeker_hfid", "hfid1", "hfid2", "wounder_hfid", "teacher_hfid",
"conspirator_hfid", "slayer_hfid"):
assert kind_of(tag) == "figure", tag
for tag in ("target_enid", "entity_id", "attacker_civ_id"):
assert kind_of(tag) == "entity", tag
for tag in ("identity_id", "master_wcid", "slayer_item_id",
"slayer_race", "reason", "relationship"):
assert kind_of(tag) is None, tag
def test_relationship_events_render_both_sides() -> None:
"""核心契约:四类关系事件必须渲染出双方,而不是只剩单方面 hfid。"""
world = load_world()
rendered = {e.type: world.render_refs(e) for e in world.events}
link = rendered["add hf hf link"]
assert "hfid=Urist McMiner" in link and "hfid_target=Liri Fogbalded" in link
battle = rendered["hf simple battle event"]
assert "group_1_hfid=Urist McMiner" in battle and "group_2_hfid=Kogan Deathspear" in battle
abduct = rendered["hf abducted"]
assert "snatcher_hfid=Kogan Deathspear" in abduct and "target_hfid=Liri Fogbalded" in abduct
denied = rendered["hf relationship denied"]
assert "seeker_hfid=Liri Fogbalded" in denied and "target_hfid=Urist McMiner" in denied
def test_figure_ids_excludes_placeholder_and_dedups() -> None:
world = load_world()
battle = next(e for e in world.events if e.type == "hf simple battle event" and e.year == 41)
assert sorted(battle.figure_ids()) == [10, 11]
# hfid 与 slayer_hfid 同指一人时不应重复计数
assert len(battle.figure_ids()) == len(set(battle.figure_ids()))
def test_fake_names_are_caught() -> None:
+125
View File
@@ -0,0 +1,125 @@
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
# 说明:LSP 的 Python 环境看不到本项目包,会把包内 import 误报为缺失模块。
"""闸门流程的回归测试(不调用真实模型,用假评分器)。
对应 t4 契约:<12 先重写一次、仍不合格才换线索、失败上限、换线原因写入 notes/。
真实模型能否真的判出平淡,由 scripts/gate-selfcheck.py 负责证明。
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dfannals import gate # noqa: E402
def verdict(total: int, reason: str = "测试用") -> gate.Verdict:
"""按总分构造一个评分(维度均分,保持与真实结构一致)。"""
keys = [k for k, _, _ in gate.DIMENSIONS]
per, rest = divmod(total, len(keys))
scores = {k: min(gate.MAX_SCORE, per + (1 if i < rest else 0)) for i, k in enumerate(keys)}
return gate.Verdict(total=sum(scores.values()), scores=scores, reason=reason)
def test_passes_first_try() -> None:
calls: list[int] = []
def write(attempt: int, _prev) -> str:
calls.append(attempt)
return "好稿"
text, run = gate.gate_episode("001", write, evaluate_fn=lambda _t: verdict(20))
assert text == "好稿"
assert run.accepted and run.rewrites == 0
assert calls == [0], "通过后不应重写"
def test_rewrite_once_then_pass() -> None:
"""<12 分先重写一次,重写后合格就通过。"""
seq = [verdict(5), verdict(18)]
seen: list[int] = []
def write(attempt: int, prev) -> str:
seen.append(attempt)
if attempt == 1:
assert prev is not None and prev.total == 5, "重写时要带上上一稿评分"
return f"第{attempt}稿"
text, run = gate.gate_episode("002", write, evaluate_fn=lambda _t: seq.pop(0))
assert text == "第1稿" and run.accepted
assert run.rewrites == 1 and seen == [0, 1]
assert [v.total for v in run.verdicts] == [5, 18]
def test_not_accepted_after_max_rewrites() -> None:
"""一直不合格:重写次数不得越过上限,且标记为未通过。"""
runs: list[int] = []
text, run = gate.gate_episode(
"003",
lambda attempt, _p: (runs.append(attempt), "稿")[1],
evaluate_fn=lambda _t: verdict(3),
)
assert not run.accepted
assert run.rewrites == gate.MAX_REWRITES
assert runs == [0, 1], f"实际尝试次数:{runs}"
assert len(run.verdicts) == gate.MAX_REWRITES + 1
def test_render_shows_weak_dimensions() -> None:
v = verdict(6, "全是重复对战")
text = v.render()
assert "判平淡" in text and "全是重复对战" in text and "6/" in text
assert len(v.weak()) == 2
def test_switch_note_records_reason(tmp_path: Path) -> None:
"""放弃线索必须记下原因,且写入仓库 notes/ 附录。"""
path = gate.append_switch_note(
tmp_path,
world_name="Mon Sagus",
members=["Guspu Frillyknots", "Ral Fastenhatchets"],
span="193–239(46 年)",
verdict=verdict(5, "只有阴谋事件重复,没有立场变化"),
reason="重写一次后仍判平淡",
)
assert path.exists() and path.parent.name == "notes"
body = path.read_text(encoding="utf-8")
assert "Guspu Frillyknots" in body and "重写一次后仍判平淡" in body
assert "只有阴谋事件重复" in body
def test_append_switch_note_is_append_only(tmp_path: Path) -> None:
gate.append_switch_note(tmp_path, world_name="W", members=["A"], span="1–2",
verdict=verdict(4), reason="第一次放弃")
gate.append_switch_note(tmp_path, world_name="W", members=["B"], span="3–4",
verdict=verdict(4), reason="第二次放弃")
body = (tmp_path / "notes" / gate.SWITCH_LOG).read_text(encoding="utf-8")
assert "第一次放弃" in body and "第二次放弃" in body
assert body.count("# 被放弃的线索") == 1, "表头只应写一次"
def _main() -> int:
import tempfile
plain = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failed = 0
for fn in plain:
try:
if fn.__code__.co_argcount:
with tempfile.TemporaryDirectory() as d:
fn(Path(d))
else:
fn()
print(f" ✓ {fn.__name__}")
except AssertionError as exc:
failed += 1
print(f" ✗ {fn.__name__}: {exc}")
print(f"\n{len(plain) - failed}/{len(plain)} 通过")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(_main())