test_data_modules.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Data Modules 单元测试
  5. """
  6. import pytest
  7. import asyncio
  8. import json
  9. import tempfile
  10. import sys
  11. from pathlib import Path
  12. from data_modules import (
  13. DataModulesConfig,
  14. EntityLinker,
  15. StateManager,
  16. IndexManager,
  17. RAGAdapter,
  18. StyleSampler,
  19. EntityState,
  20. ChapterMeta,
  21. SceneMeta,
  22. StyleSample,
  23. )
  24. import data_modules.index_manager as index_manager_module
  25. from data_modules.index_manager import (
  26. EntityMeta,
  27. StateChangeMeta,
  28. RelationshipMeta,
  29. OverrideContractMeta,
  30. ChaseDebtMeta,
  31. ChapterReadingPowerMeta,
  32. )
  33. @pytest.fixture
  34. def temp_project():
  35. """创建临时项目目录"""
  36. with tempfile.TemporaryDirectory() as tmpdir:
  37. config = DataModulesConfig.from_project_root(tmpdir)
  38. config.ensure_dirs()
  39. yield config
  40. class TestEntityLinker:
  41. """实体链接器测试"""
  42. def test_register_and_lookup_alias(self, temp_project):
  43. linker = EntityLinker(temp_project)
  44. # 先注册实体,否则 aliases JOIN 不会返回
  45. IndexManager(temp_project).upsert_entity(
  46. EntityMeta(
  47. id="xiaoyan",
  48. type="角色",
  49. canonical_name="萧炎",
  50. current={},
  51. first_appearance=1,
  52. last_appearance=1,
  53. )
  54. )
  55. # 注册别名
  56. assert linker.register_alias("xiaoyan", "萧炎")
  57. assert linker.register_alias("xiaoyan", "小炎子")
  58. # 查找
  59. assert linker.lookup_alias("萧炎") == "xiaoyan"
  60. assert linker.lookup_alias("小炎子") == "xiaoyan"
  61. assert linker.lookup_alias("不存在") is None
  62. def test_alias_one_to_many(self, temp_project):
  63. """v5.0: 同一别名可映射多个实体(一对多)"""
  64. linker = EntityLinker(temp_project)
  65. idx = IndexManager(temp_project)
  66. idx.upsert_entity(
  67. EntityMeta(
  68. id="xiaoyan",
  69. type="角色",
  70. canonical_name="萧炎",
  71. current={},
  72. first_appearance=1,
  73. last_appearance=1,
  74. )
  75. )
  76. idx.upsert_entity(
  77. EntityMeta(
  78. id="other_person",
  79. type="角色",
  80. canonical_name="萧炎",
  81. current={},
  82. first_appearance=1,
  83. last_appearance=1,
  84. )
  85. )
  86. linker.register_alias("xiaoyan", "萧炎", "角色")
  87. # v5.0: 同一别名可绑定不同实体(一对多)
  88. assert linker.register_alias("other_person", "萧炎", "角色")
  89. # 查找所有匹配
  90. entries = linker.lookup_alias_all("萧炎")
  91. assert len(entries) == 2
  92. def test_get_all_aliases(self, temp_project):
  93. linker = EntityLinker(temp_project)
  94. IndexManager(temp_project).upsert_entity(
  95. EntityMeta(
  96. id="xiaoyan",
  97. type="角色",
  98. canonical_name="萧炎",
  99. current={},
  100. first_appearance=1,
  101. last_appearance=1,
  102. )
  103. )
  104. linker.register_alias("xiaoyan", "萧炎")
  105. linker.register_alias("xiaoyan", "小炎子")
  106. linker.register_alias("xiaoyan", "炎哥")
  107. aliases = linker.get_all_aliases("xiaoyan")
  108. assert len(aliases) == 3
  109. assert "萧炎" in aliases
  110. def test_confidence_evaluation(self, temp_project):
  111. linker = EntityLinker(temp_project)
  112. # 高置信度
  113. action, adopt, warning = linker.evaluate_confidence(0.9)
  114. assert action == "auto"
  115. assert adopt is True
  116. assert warning is None
  117. # 中置信度
  118. action, adopt, warning = linker.evaluate_confidence(0.6)
  119. assert action == "warn"
  120. assert adopt is True
  121. assert warning is not None
  122. # 低置信度
  123. action, adopt, warning = linker.evaluate_confidence(0.3)
  124. assert action == "manual"
  125. assert adopt is False
  126. def test_process_uncertain(self, temp_project):
  127. linker = EntityLinker(temp_project)
  128. result = linker.process_uncertain(
  129. mention="那位前辈",
  130. candidates=["yaolao", "elder_zhang"],
  131. suggested="yaolao",
  132. confidence=0.7
  133. )
  134. assert result.mention == "那位前辈"
  135. assert result.entity_id == "yaolao"
  136. assert result.adopted is True
  137. assert result.warning is not None
  138. class TestStateManager:
  139. """状态管理器测试"""
  140. def test_add_and_get_entity(self, temp_project):
  141. manager = StateManager(temp_project)
  142. entity = EntityState(
  143. id="xiaoyan",
  144. name="萧炎",
  145. type="角色",
  146. tier="核心"
  147. )
  148. assert manager.add_entity(entity)
  149. # 获取实体
  150. result = manager.get_entity("xiaoyan")
  151. assert result is not None
  152. assert result["canonical_name"] == "萧炎"
  153. def test_update_entity(self, temp_project):
  154. manager = StateManager(temp_project)
  155. entity = EntityState(id="xiaoyan", name="萧炎", type="角色")
  156. manager.add_entity(entity)
  157. # 更新属性 (v5.0: attributes 存在 current 字段)
  158. manager.update_entity("xiaoyan", {"current": {"realm": "斗师"}})
  159. result = manager.get_entity("xiaoyan")
  160. assert result["current"]["realm"] == "斗师"
  161. def test_record_state_change(self, temp_project):
  162. manager = StateManager(temp_project)
  163. entity = EntityState(id="xiaoyan", name="萧炎", type="角色")
  164. manager.add_entity(entity)
  165. manager.record_state_change(
  166. entity_id="xiaoyan",
  167. field="realm",
  168. old_value="斗者",
  169. new_value="斗师",
  170. reason="突破",
  171. chapter=100
  172. )
  173. changes = manager.get_state_changes("xiaoyan")
  174. assert len(changes) == 1
  175. assert changes[0]["new_value"] == "斗师"
  176. def test_add_relationship(self, temp_project):
  177. manager = StateManager(temp_project)
  178. manager.add_relationship(
  179. from_entity="xiaoyan",
  180. to_entity="yaolao",
  181. rel_type="师徒",
  182. description="药老收萧炎为徒",
  183. chapter=10
  184. )
  185. rels = manager.get_relationships("xiaoyan")
  186. assert len(rels) == 1
  187. assert rels[0]["type"] == "师徒"
  188. def test_process_chapter_result(self, temp_project):
  189. manager = StateManager(temp_project)
  190. result = {
  191. "entities_appeared": [
  192. {"id": "xiaoyan", "mentions": ["萧炎", "他"]}
  193. ],
  194. "entities_new": [
  195. {"suggested_id": "hongyi_girl", "name": "红衣女子", "type": "角色", "tier": "装饰"}
  196. ],
  197. "state_changes": [
  198. {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师", "reason": "突破"}
  199. ],
  200. "relationships_new": [
  201. {"from": "xiaoyan", "to": "hongyi_girl", "type": "相识", "description": "初次见面"}
  202. ]
  203. }
  204. # 先添加萧炎
  205. manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色"))
  206. warnings = manager.process_chapter_result(100, result)
  207. # 验证新实体被添加
  208. assert manager.get_entity("hongyi_girl") is not None
  209. # 验证状态变化
  210. changes = manager.get_state_changes("xiaoyan")
  211. assert len(changes) == 1
  212. # 验证进度更新
  213. assert manager.get_current_chapter() == 100
  214. def test_save_state_with_init_project_schema(self, temp_project):
  215. """回归:init_project 生成的 state.json,StateManager 仍应可写入。(v5.1 SQLite-only)"""
  216. # v5.1: state.json 不再包含 entities_v3/alias_index,实体数据在 SQLite
  217. init_state = {
  218. "project_info": {"title": "测试书名", "genre": "修仙/玄幻", "created_at": "2026-01-01"},
  219. "progress": {"current_chapter": 0, "total_words": 0, "last_updated": "2026-01-01 00:00:00"},
  220. "protagonist_state": {"name": "测试主角"},
  221. "relationships": {},
  222. "world_settings": {"power_system": [], "factions": [], "locations": []},
  223. "plot_threads": {"active_threads": [], "foreshadowing": []},
  224. "review_checkpoints": [],
  225. "strand_tracker": {"current_dominant": "quest", "history": []},
  226. }
  227. temp_project.state_file.write_text(json.dumps(init_state, ensure_ascii=False, indent=2), encoding="utf-8")
  228. manager = StateManager(temp_project)
  229. manager.update_progress(5, words=100)
  230. manager.save_state()
  231. saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
  232. assert "meta" not in saved
  233. assert saved["progress"]["current_chapter"] == 5
  234. assert saved["progress"]["total_words"] == 100
  235. # v5.1: entities_v3/alias_index 不再在 state.json 中
  236. def test_save_state_preserves_unrelated_fields(self, temp_project):
  237. """回归:仅写入增量,不应覆盖/丢失其他模块维护的字段。(v5.1 SQLite-only)"""
  238. init_state = {
  239. "project_info": {"title": "测试书名", "genre": "修仙/玄幻", "created_at": "2026-01-01"},
  240. "progress": {"current_chapter": 10, "total_words": 1000, "last_updated": "2026-01-01 00:00:00"},
  241. "protagonist_state": {"name": "测试主角"},
  242. "relationships": {"allies": ["药老"], "enemies": []},
  243. "world_settings": {"power_system": [], "factions": [], "locations": []},
  244. "plot_threads": {"active_threads": [{"id": "t1", "title": "主线"}], "foreshadowing": []},
  245. "review_checkpoints": [],
  246. "strand_tracker": {"current_dominant": "quest", "history": []},
  247. "custom_field": {"keep": True},
  248. }
  249. temp_project.state_file.write_text(json.dumps(init_state, ensure_ascii=False, indent=2), encoding="utf-8")
  250. manager = StateManager(temp_project)
  251. manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
  252. manager.save_state()
  253. saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
  254. assert saved.get("custom_field", {}).get("keep") is True
  255. assert saved.get("plot_threads", {}).get("active_threads", [])[0].get("id") == "t1"
  256. assert isinstance(saved.get("relationships"), dict)
  257. def test_disambiguation_feedback_persisted(self, temp_project):
  258. """回归:中/低置信度消歧必须对 Writer 可见(写入 state.json)。"""
  259. manager = StateManager(temp_project)
  260. result = {
  261. "entities_appeared": [],
  262. "entities_new": [],
  263. "state_changes": [],
  264. "relationships_new": [],
  265. "uncertain": [
  266. {
  267. "mention": "那位前辈",
  268. "context": "那位前辈看了他一眼",
  269. "candidates": [{"type": "角色", "id": "yaolao"}, {"type": "角色", "id": "elder_zhang"}],
  270. "suggested": "yaolao",
  271. "confidence": 0.6,
  272. },
  273. {
  274. "mention": "宗主",
  275. "context": "宗主出现在血煞秘境",
  276. "candidates": ["xueshazonzhu", "lintian"],
  277. "suggested": "xueshazonzhu",
  278. "confidence": 0.4,
  279. },
  280. ],
  281. }
  282. warnings = manager.process_chapter_result(100, result)
  283. manager.save_state()
  284. state = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
  285. assert isinstance(state.get("disambiguation_warnings"), list)
  286. assert isinstance(state.get("disambiguation_pending"), list)
  287. assert len(state["disambiguation_warnings"]) == 1
  288. assert len(state["disambiguation_pending"]) == 1
  289. warn = state["disambiguation_warnings"][0]
  290. assert warn.get("chapter") == 100
  291. assert warn.get("mention") == "那位前辈"
  292. assert warn.get("chosen_id") == "yaolao"
  293. pending = state["disambiguation_pending"][0]
  294. assert pending.get("chapter") == 100
  295. assert pending.get("mention") == "宗主"
  296. # 返回值也应包含可见警告,便于 CLI/日志透出
  297. assert any("消歧警告" in w for w in warnings)
  298. assert any("需人工确认" in w for w in warnings)
  299. class TestIndexManager:
  300. """索引管理器测试"""
  301. def test_add_and_get_chapter(self, temp_project):
  302. manager = IndexManager(temp_project)
  303. meta = ChapterMeta(
  304. chapter=100,
  305. title="突破",
  306. location="天云宗",
  307. word_count=3500,
  308. characters=["xiaoyan", "yaolao"]
  309. )
  310. manager.add_chapter(meta)
  311. result = manager.get_chapter(100)
  312. assert result is not None
  313. assert result["title"] == "突破"
  314. assert "xiaoyan" in result["characters"]
  315. def test_add_scenes(self, temp_project):
  316. manager = IndexManager(temp_project)
  317. scenes = [
  318. SceneMeta(chapter=100, scene_index=1, start_line=1, end_line=50,
  319. location="天云宗·闭关室", summary="萧炎闭关突破", characters=["xiaoyan"]),
  320. SceneMeta(chapter=100, scene_index=2, start_line=51, end_line=100,
  321. location="天云宗·演武场", summary="展示实力", characters=["xiaoyan", "lintian"])
  322. ]
  323. manager.add_scenes(100, scenes)
  324. result = manager.get_scenes(100)
  325. assert len(result) == 2
  326. assert result[0]["location"] == "天云宗·闭关室"
  327. def test_record_appearance(self, temp_project):
  328. manager = IndexManager(temp_project)
  329. manager.record_appearance("xiaoyan", 100, ["萧炎", "他"], 0.95)
  330. manager.record_appearance("yaolao", 100, ["药老"], 0.92)
  331. appearances = manager.get_chapter_appearances(100)
  332. assert len(appearances) == 2
  333. entity_history = manager.get_entity_appearances("xiaoyan")
  334. assert len(entity_history) == 1
  335. def test_search_scenes_by_location(self, temp_project):
  336. manager = IndexManager(temp_project)
  337. scenes = [
  338. SceneMeta(chapter=100, scene_index=1, start_line=1, end_line=50,
  339. location="天云宗·闭关室", summary="闭关", characters=[]),
  340. SceneMeta(chapter=101, scene_index=1, start_line=1, end_line=50,
  341. location="天云宗·大殿", summary="议事", characters=[])
  342. ]
  343. manager.add_scenes(100, scenes[:1])
  344. manager.add_scenes(101, scenes[1:])
  345. results = manager.search_scenes_by_location("天云宗")
  346. assert len(results) == 2
  347. def test_get_stats(self, temp_project):
  348. manager = IndexManager(temp_project)
  349. manager.upsert_entity(
  350. EntityMeta(
  351. id="xiaoyan",
  352. type="角色",
  353. canonical_name="萧炎",
  354. current={},
  355. first_appearance=1,
  356. last_appearance=1,
  357. )
  358. )
  359. manager.add_chapter(ChapterMeta(chapter=1, title="", location="", word_count=1000, characters=[]))
  360. manager.add_scenes(1, [SceneMeta(chapter=1, scene_index=1, start_line=1, end_line=50,
  361. location="", summary="", characters=[])])
  362. manager.record_appearance("xiaoyan", 1, [], 1.0)
  363. stats = manager.get_stats()
  364. assert stats["chapters"] == 1
  365. assert stats["scenes"] == 1
  366. assert stats["entities"] == 1
  367. def test_entity_alias_and_relationships(self, temp_project):
  368. manager = IndexManager(temp_project)
  369. entity_main = EntityMeta(
  370. id="xiaoyan",
  371. type="角色",
  372. canonical_name="萧炎",
  373. tier="核心",
  374. desc="主角",
  375. current={"realm": "斗者"},
  376. first_appearance=1,
  377. last_appearance=1,
  378. is_protagonist=True,
  379. )
  380. entity_other = EntityMeta(
  381. id="yaolao",
  382. type="角色",
  383. canonical_name="药老",
  384. tier="重要",
  385. current={},
  386. first_appearance=1,
  387. last_appearance=2,
  388. )
  389. assert manager.upsert_entity(entity_main) is True
  390. assert manager.upsert_entity(entity_other) is True
  391. # 更新 current
  392. assert manager.update_entity_current("xiaoyan", {"realm": "斗师"}) is True
  393. entity = manager.get_entity("xiaoyan")
  394. assert entity["current_json"]["realm"] == "斗师"
  395. # 元数据更新
  396. entity_main.desc = "主角(更新)"
  397. entity_main.last_appearance = 3
  398. assert manager.upsert_entity(entity_main, update_metadata=True) is False
  399. # 别名管理
  400. assert manager.register_alias("炎帝", "xiaoyan", "角色")
  401. assert "炎帝" in manager.get_entity_aliases("xiaoyan")
  402. assert manager.get_entities_by_alias("炎帝")[0]["id"] == "xiaoyan"
  403. assert manager.remove_alias("炎帝", "xiaoyan")
  404. assert manager.get_entities_by_alias("炎帝") == []
  405. # 类型/层级/核心/主角查询
  406. assert len(manager.get_entities_by_type("角色")) == 2
  407. assert any(e["id"] == "xiaoyan" for e in manager.get_entities_by_tier("核心"))
  408. assert any(e["id"] == "xiaoyan" for e in manager.get_core_entities())
  409. assert manager.get_protagonist()["id"] == "xiaoyan"
  410. # 归档实体
  411. assert manager.archive_entity("yaolao") is True
  412. assert all(e["id"] != "yaolao" for e in manager.get_entities_by_type("角色"))
  413. assert any(
  414. e["id"] == "yaolao"
  415. for e in manager.get_entities_by_type("角色", include_archived=True)
  416. )
  417. # 关系管理(新建 + 更新)
  418. rel = RelationshipMeta(
  419. from_entity="xiaoyan",
  420. to_entity="yaolao",
  421. type="师徒",
  422. description="收徒",
  423. chapter=1,
  424. )
  425. assert manager.upsert_relationship(rel) is True
  426. rel.description = "收徒(更新)"
  427. rel.chapter = 2
  428. assert manager.upsert_relationship(rel) is False
  429. assert len(manager.get_entity_relationships("xiaoyan", "from")) == 1
  430. assert len(manager.get_entity_relationships("yaolao", "to")) == 1
  431. assert len(manager.get_entity_relationships("xiaoyan", "both")) >= 1
  432. assert len(manager.get_relationship_between("xiaoyan", "yaolao")) == 1
  433. assert len(manager.get_recent_relationships(limit=5)) >= 1
  434. def test_state_changes_and_appearances(self, temp_project):
  435. manager = IndexManager(temp_project)
  436. entity = EntityMeta(
  437. id="xiaoyan",
  438. type="角色",
  439. canonical_name="萧炎",
  440. current={},
  441. first_appearance=1,
  442. last_appearance=1,
  443. )
  444. manager.upsert_entity(entity)
  445. change = StateChangeMeta(
  446. entity_id="xiaoyan",
  447. field="realm",
  448. old_value="斗者",
  449. new_value="斗师",
  450. reason="突破",
  451. chapter=2,
  452. )
  453. change_id = manager.record_state_change(change)
  454. assert change_id > 0
  455. assert len(manager.get_entity_state_changes("xiaoyan")) == 1
  456. assert len(manager.get_recent_state_changes(limit=5)) == 1
  457. assert len(manager.get_chapter_state_changes(2)) == 1
  458. # 出场记录(含 skip_if_exists 分支)
  459. manager.record_appearance("xiaoyan", 2, ["萧炎"], 1.0)
  460. manager.record_appearance("xiaoyan", 2, ["萧炎"], 1.0, skip_if_exists=True)
  461. manager.record_appearance("xiaoyan", 3, ["萧炎"], 1.0)
  462. assert len(manager.get_entity_appearances("xiaoyan")) == 2
  463. assert len(manager.get_recent_appearances(limit=5)) >= 1
  464. assert len(manager.get_chapter_appearances(2)) == 1
  465. def test_chapter_queries_and_bulk(self, temp_project):
  466. manager = IndexManager(temp_project)
  467. manager.add_chapter(
  468. ChapterMeta(
  469. chapter=1,
  470. title="起点",
  471. location="天云宗",
  472. word_count=1000,
  473. characters=["xiaoyan"],
  474. )
  475. )
  476. manager.add_chapter(
  477. ChapterMeta(
  478. chapter=2,
  479. title="突破",
  480. location="天云宗",
  481. word_count=1200,
  482. characters=["xiaoyan", "yaolao"],
  483. )
  484. )
  485. recent = manager.get_recent_chapters()
  486. assert recent[0]["chapter"] == 2
  487. scenes = [
  488. SceneMeta(
  489. chapter=1,
  490. scene_index=1,
  491. start_line=1,
  492. end_line=50,
  493. location="天云宗·闭关室",
  494. summary="闭关",
  495. characters=["xiaoyan"],
  496. ),
  497. SceneMeta(
  498. chapter=1,
  499. scene_index=2,
  500. start_line=51,
  501. end_line=80,
  502. location="天云宗·演武场",
  503. summary="练习",
  504. characters=["xiaoyan"],
  505. ),
  506. ]
  507. manager.add_scenes(1, scenes)
  508. assert len(manager.get_scenes(1)) == 2
  509. results = manager.search_scenes_by_location("天云宗")
  510. assert len(results) >= 2
  511. stats = manager.process_chapter_data(
  512. chapter=10,
  513. title="试炼",
  514. location="秘境",
  515. word_count=1500,
  516. entities=[{"id": "xiaoyan", "type": "角色", "mentions": ["萧炎"]}],
  517. scenes=[{"index": 1, "start_line": 1, "end_line": 20, "location": "秘境", "summary": "开场", "characters": ["xiaoyan"]}],
  518. )
  519. assert stats["chapters"] == 1
  520. assert stats["scenes"] == 1
  521. assert stats["appearances"] == 1
  522. def test_debt_and_override_flow(self, temp_project):
  523. manager = IndexManager(temp_project)
  524. contract = OverrideContractMeta(
  525. chapter=1,
  526. constraint_type="SOFT_MICROPAYOFF",
  527. constraint_id="micropayoff_count",
  528. rationale_type="TRANSITIONAL_SETUP",
  529. rationale_text="铺垫需要",
  530. payback_plan="下章补偿",
  531. due_chapter=3,
  532. status="pending",
  533. )
  534. contract_id = manager.create_override_contract(contract)
  535. assert contract_id > 0
  536. # pending 状态允许更新
  537. contract.rationale_text = "调整理由"
  538. contract.due_chapter = 4
  539. assert manager.create_override_contract(contract) == contract_id
  540. updated = manager.get_chapter_overrides(1)[0]
  541. assert updated["rationale_text"] == "调整理由"
  542. assert updated["due_chapter"] == 4
  543. # 终态冻结
  544. contract.status = "fulfilled"
  545. contract.rationale_text = "终态理由"
  546. contract.due_chapter = 5
  547. manager.create_override_contract(contract)
  548. frozen = manager.get_chapter_overrides(1)[0]
  549. assert frozen["status"] == "fulfilled"
  550. assert frozen["rationale_text"] == "终态理由"
  551. # 试图回写 pending,不应改动终态字段
  552. contract.status = "pending"
  553. contract.rationale_text = "不应生效"
  554. contract.due_chapter = 99
  555. manager.create_override_contract(contract)
  556. frozen_again = manager.get_chapter_overrides(1)[0]
  557. assert frozen_again["status"] == "fulfilled"
  558. assert frozen_again["rationale_text"] == "终态理由"
  559. assert frozen_again["due_chapter"] == 5
  560. debt_contract_id = manager.create_override_contract(
  561. OverrideContractMeta(
  562. chapter=2,
  563. constraint_type="SOFT_HOOK_STRENGTH",
  564. constraint_id="hook_strength",
  565. rationale_type="ARC_TIMING",
  566. rationale_text="节奏安排",
  567. payback_plan="后续补强",
  568. due_chapter=4,
  569. status="pending",
  570. )
  571. )
  572. debt1 = ChaseDebtMeta(
  573. debt_type="hook_strength",
  574. original_amount=1.0,
  575. current_amount=1.0,
  576. interest_rate=0.1,
  577. source_chapter=1,
  578. due_chapter=2,
  579. override_contract_id=debt_contract_id,
  580. status="active",
  581. )
  582. debt2 = ChaseDebtMeta(
  583. debt_type="micropayoff",
  584. original_amount=2.0,
  585. current_amount=2.0,
  586. interest_rate=0.2,
  587. source_chapter=1,
  588. due_chapter=2,
  589. override_contract_id=debt_contract_id,
  590. status="active",
  591. )
  592. debt_id_1 = manager.create_debt(debt1)
  593. debt_id_2 = manager.create_debt(debt2)
  594. assert len(manager.get_active_debts()) == 2
  595. assert manager.get_total_debt_balance() > 0
  596. # 计息与幂等保护
  597. result = manager.accrue_interest(current_chapter=2)
  598. assert result["debts_processed"] == 2
  599. result_again = manager.accrue_interest(current_chapter=2)
  600. assert result_again["skipped_already_processed"] == 2
  601. # 逾期标记
  602. result_overdue = manager.accrue_interest(current_chapter=3)
  603. assert result_overdue["new_overdues"] >= 1
  604. overdue = manager.get_overdue_debts(current_chapter=3)
  605. assert any(d["status"] == "overdue" for d in overdue)
  606. history = manager.get_debt_history(debt_id_1)
  607. assert any(h["event_type"] == "interest_accrued" for h in history)
  608. # 金额校验
  609. error = manager.pay_debt(debt_id_1, 0, chapter=3)
  610. assert "error" in error
  611. # 部分偿还
  612. partial = manager.pay_debt(debt_id_1, 0.5, chapter=3)
  613. assert partial["fully_paid"] is False
  614. # 完全偿还(仍有另一笔债务时不应 fulfilled)
  615. full = manager.pay_debt(debt_id_1, 100, chapter=3)
  616. assert full["fully_paid"] is True
  617. assert full["override_fulfilled"] is False
  618. # 清空最后一笔债务 -> fulfilled
  619. full2 = manager.pay_debt(debt_id_2, 100, chapter=3)
  620. assert full2["fully_paid"] is True
  621. assert full2["override_fulfilled"] is True
  622. def test_reading_power_and_debt_summary(self, temp_project):
  623. manager = IndexManager(temp_project)
  624. # 追读力元数据
  625. manager.save_chapter_reading_power(
  626. ChapterReadingPowerMeta(
  627. chapter=1,
  628. hook_type="渴望钩",
  629. hook_strength="strong",
  630. coolpoint_patterns=["打脸权威", "身份掉马"],
  631. micropayoffs=["能力兑现"],
  632. hard_violations=[],
  633. soft_suggestions=["SOFT_HOOK_STRENGTH"],
  634. is_transition=False,
  635. override_count=1,
  636. debt_balance=1.5,
  637. )
  638. )
  639. manager.save_chapter_reading_power(
  640. ChapterReadingPowerMeta(
  641. chapter=2,
  642. hook_type="悬念钩",
  643. hook_strength="medium",
  644. coolpoint_patterns=["身份掉马"],
  645. micropayoffs=["信息兑现"],
  646. hard_violations=["HARD-004"],
  647. soft_suggestions=[],
  648. is_transition=True,
  649. override_count=0,
  650. debt_balance=0.0,
  651. )
  652. )
  653. record = manager.get_chapter_reading_power(1)
  654. assert record["hook_type"] == "渴望钩"
  655. assert "身份掉马" in record["coolpoint_patterns"]
  656. assert record["is_transition"] == 0 # SQLite 存储为 0/1
  657. assert manager.get_chapter_reading_power(999) is None
  658. recent = manager.get_recent_reading_power(limit=2)
  659. assert len(recent) == 2
  660. pattern_stats = manager.get_pattern_usage_stats(last_n_chapters=5)
  661. assert pattern_stats.get("身份掉马") == 2
  662. hook_stats = manager.get_hook_type_stats(last_n_chapters=5)
  663. assert hook_stats.get("渴望钩") == 1
  664. # 债务汇总
  665. contract_id = manager.create_override_contract(
  666. OverrideContractMeta(
  667. chapter=3,
  668. constraint_type="SOFT_HOOK_STRENGTH",
  669. constraint_id="hook_strength",
  670. rationale_type="ARC_TIMING",
  671. rationale_text="节奏安排",
  672. payback_plan="后续补强",
  673. due_chapter=5,
  674. status="pending",
  675. )
  676. )
  677. manager.create_debt(
  678. ChaseDebtMeta(
  679. debt_type="hook_strength",
  680. original_amount=1.0,
  681. current_amount=1.0,
  682. interest_rate=0.1,
  683. source_chapter=3,
  684. due_chapter=4,
  685. override_contract_id=contract_id,
  686. status="active",
  687. )
  688. )
  689. manager.create_debt(
  690. ChaseDebtMeta(
  691. debt_type="micropayoff",
  692. original_amount=2.0,
  693. current_amount=2.0,
  694. interest_rate=0.1,
  695. source_chapter=3,
  696. due_chapter=4,
  697. override_contract_id=0,
  698. status="overdue",
  699. )
  700. )
  701. summary = manager.get_debt_summary()
  702. assert summary["active_debts"] == 1
  703. assert summary["overdue_debts"] == 1
  704. assert summary["pending_overrides"] >= 1
  705. assert summary["total_balance"] == summary["active_total"] + summary["overdue_total"]
  706. pending = manager.get_pending_overrides()
  707. assert any(o["id"] == contract_id for o in pending)
  708. pending_before = manager.get_pending_overrides(before_chapter=10)
  709. assert any(o["id"] == contract_id for o in pending_before)
  710. overdue_overrides = manager.get_overdue_overrides(current_chapter=6)
  711. assert any(o["id"] == contract_id for o in overdue_overrides)
  712. other_id = manager.create_override_contract(
  713. OverrideContractMeta(
  714. chapter=4,
  715. constraint_type="SOFT_EXPECTATION_OVERLOAD",
  716. constraint_id="expectation_count",
  717. rationale_type="EDITORIAL_INTENT",
  718. rationale_text="作者意图",
  719. payback_plan="后续补足",
  720. due_chapter=6,
  721. status="pending",
  722. )
  723. )
  724. assert manager.fulfill_override(other_id) is True
  725. assert manager.get_chapter_overrides(4)[0]["status"] == "fulfilled"
  726. def test_index_manager_cli(self, temp_project, monkeypatch, capsys):
  727. root = str(temp_project.project_root)
  728. manager = IndexManager(temp_project)
  729. # 基础数据
  730. manager.upsert_entity(
  731. EntityMeta(
  732. id="xiaoyan",
  733. type="角色",
  734. canonical_name="萧炎",
  735. tier="核心",
  736. current={"realm": "斗者"},
  737. first_appearance=1,
  738. last_appearance=1,
  739. is_protagonist=True,
  740. )
  741. )
  742. manager.upsert_entity(
  743. EntityMeta(
  744. id="yaolao",
  745. type="角色",
  746. canonical_name="药老",
  747. tier="重要",
  748. current={},
  749. first_appearance=1,
  750. last_appearance=2,
  751. )
  752. )
  753. manager.register_alias("炎帝", "xiaoyan", "角色")
  754. manager.add_chapter(
  755. ChapterMeta(
  756. chapter=1,
  757. title="起点",
  758. location="天云宗",
  759. word_count=1000,
  760. characters=["xiaoyan"],
  761. )
  762. )
  763. manager.add_scenes(
  764. 1,
  765. [
  766. SceneMeta(
  767. chapter=1,
  768. scene_index=1,
  769. start_line=1,
  770. end_line=20,
  771. location="天云宗·闭关室",
  772. summary="闭关",
  773. characters=["xiaoyan"],
  774. )
  775. ],
  776. )
  777. manager.record_appearance("xiaoyan", 1, ["萧炎"], 1.0)
  778. manager.record_state_change(
  779. StateChangeMeta(
  780. entity_id="xiaoyan",
  781. field="realm",
  782. old_value="斗者",
  783. new_value="斗师",
  784. reason="突破",
  785. chapter=1,
  786. )
  787. )
  788. manager.upsert_relationship(
  789. RelationshipMeta(
  790. from_entity="xiaoyan",
  791. to_entity="yaolao",
  792. type="师徒",
  793. description="收徒",
  794. chapter=1,
  795. )
  796. )
  797. # 追读力与债务
  798. manager.save_chapter_reading_power(
  799. ChapterReadingPowerMeta(
  800. chapter=1,
  801. hook_type="渴望钩",
  802. hook_strength="medium",
  803. coolpoint_patterns=["身份掉马"],
  804. micropayoffs=["能力兑现"],
  805. hard_violations=[],
  806. soft_suggestions=[],
  807. )
  808. )
  809. contract_id = manager.create_override_contract(
  810. OverrideContractMeta(
  811. chapter=1,
  812. constraint_type="SOFT_HOOK_STRENGTH",
  813. constraint_id="hook_strength",
  814. rationale_type="ARC_TIMING",
  815. rationale_text="节奏安排",
  816. payback_plan="后续补强",
  817. due_chapter=2,
  818. status="pending",
  819. )
  820. )
  821. debt_id = manager.create_debt(
  822. ChaseDebtMeta(
  823. debt_type="hook_strength",
  824. original_amount=1.0,
  825. current_amount=1.0,
  826. interest_rate=0.1,
  827. source_chapter=1,
  828. due_chapter=2,
  829. override_contract_id=contract_id,
  830. status="active",
  831. )
  832. )
  833. def run_cli(args):
  834. monkeypatch.setattr(sys, "argv", ["index_manager"] + args)
  835. index_manager_module.main()
  836. # 基础命令
  837. run_cli(["--project-root", root, "stats"])
  838. run_cli(["--project-root", root, "get-chapter", "--chapter", "1"])
  839. run_cli(["--project-root", root, "get-chapter", "--chapter", "99"])
  840. run_cli(["--project-root", root, "recent-appearances", "--limit", "5"])
  841. run_cli(["--project-root", root, "entity-appearances", "--entity", "xiaoyan", "--limit", "5"])
  842. run_cli(["--project-root", root, "search-scenes", "--location", "天云宗", "--limit", "5"])
  843. # 处理章节
  844. run_cli(
  845. [
  846. "--project-root",
  847. root,
  848. "process-chapter",
  849. "--chapter",
  850. "2",
  851. "--title",
  852. "试炼",
  853. "--location",
  854. "秘境",
  855. "--word-count",
  856. "1200",
  857. "--entities",
  858. json.dumps([{"id": "xiaoyan", "mentions": ["萧炎"]}], ensure_ascii=False),
  859. "--scenes",
  860. json.dumps(
  861. [
  862. {
  863. "index": 1,
  864. "start_line": 1,
  865. "end_line": 10,
  866. "location": "秘境",
  867. "summary": "开场",
  868. "characters": ["xiaoyan"],
  869. }
  870. ],
  871. ensure_ascii=False,
  872. ),
  873. ]
  874. )
  875. # v5.1 命令
  876. run_cli(["--project-root", root, "get-entity", "--id", "xiaoyan"])
  877. run_cli(["--project-root", root, "get-entity", "--id", "missing"])
  878. run_cli(["--project-root", root, "get-core-entities"])
  879. run_cli(["--project-root", root, "get-protagonist"])
  880. run_cli(
  881. ["--project-root", root, "get-entities-by-type", "--type", "角色", "--include-archived"]
  882. )
  883. run_cli(["--project-root", root, "get-by-alias", "--alias", "炎帝"])
  884. run_cli(["--project-root", root, "get-by-alias", "--alias", "不存在"])
  885. run_cli(["--project-root", root, "get-aliases", "--entity", "xiaoyan"])
  886. run_cli(["--project-root", root, "register-alias", "--alias", "炎哥", "--entity", "xiaoyan", "--type", "角色"])
  887. run_cli(["--project-root", root, "get-relationships", "--entity", "xiaoyan", "--direction", "from"])
  888. run_cli(["--project-root", root, "get-state-changes", "--entity", "xiaoyan", "--limit", "20"])
  889. run_cli(
  890. [
  891. "--project-root",
  892. root,
  893. "upsert-entity",
  894. "--data",
  895. json.dumps(
  896. {
  897. "id": "lintian",
  898. "type": "角色",
  899. "canonical_name": "林天",
  900. "tier": "装饰",
  901. "current": {"realm": "斗者"},
  902. },
  903. ensure_ascii=False,
  904. ),
  905. ]
  906. )
  907. run_cli(
  908. [
  909. "--project-root",
  910. root,
  911. "upsert-relationship",
  912. "--data",
  913. json.dumps(
  914. {
  915. "from_entity": "xiaoyan",
  916. "to_entity": "lintian",
  917. "type": "相识",
  918. "description": "初见",
  919. "chapter": 2,
  920. },
  921. ensure_ascii=False,
  922. ),
  923. ]
  924. )
  925. run_cli(
  926. [
  927. "--project-root",
  928. root,
  929. "record-state-change",
  930. "--data",
  931. json.dumps(
  932. {
  933. "entity_id": "xiaoyan",
  934. "field": "realm",
  935. "old_value": "斗者",
  936. "new_value": "斗师",
  937. "reason": "突破",
  938. "chapter": 2,
  939. },
  940. ensure_ascii=False,
  941. ),
  942. ]
  943. )
  944. # v5.3 命令
  945. run_cli(["--project-root", root, "get-debt-summary"])
  946. run_cli(["--project-root", root, "get-recent-reading-power", "--limit", "5"])
  947. run_cli(["--project-root", root, "get-chapter-reading-power", "--chapter", "1"])
  948. run_cli(["--project-root", root, "get-chapter-reading-power", "--chapter", "99"])
  949. run_cli(["--project-root", root, "get-pattern-usage-stats", "--last-n", "5"])
  950. run_cli(["--project-root", root, "get-hook-type-stats", "--last-n", "5"])
  951. run_cli(["--project-root", root, "get-pending-overrides"])
  952. run_cli(["--project-root", root, "get-overdue-overrides", "--current-chapter", "3"])
  953. run_cli(["--project-root", root, "get-active-debts"])
  954. run_cli(["--project-root", root, "get-overdue-debts", "--current-chapter", "3"])
  955. run_cli(["--project-root", root, "accrue-interest", "--current-chapter", "3"])
  956. run_cli(["--project-root", root, "pay-debt", "--debt-id", str(debt_id), "--amount", "0", "--chapter", "3"])
  957. run_cli(["--project-root", root, "pay-debt", "--debt-id", str(debt_id), "--amount", "5", "--chapter", "3"])
  958. run_cli(
  959. [
  960. "--project-root",
  961. root,
  962. "create-override-contract",
  963. "--data",
  964. json.dumps(
  965. {
  966. "chapter": 3,
  967. "constraint_type": "SOFT_MICROPAYOFF",
  968. "constraint_id": "micropayoff_count",
  969. "rationale_type": "TRANSITIONAL_SETUP",
  970. "rationale_text": "铺垫",
  971. "payback_plan": "后续补偿",
  972. "due_chapter": 4,
  973. },
  974. ensure_ascii=False,
  975. ),
  976. ]
  977. )
  978. run_cli(
  979. [
  980. "--project-root",
  981. root,
  982. "create-debt",
  983. "--data",
  984. json.dumps(
  985. {
  986. "debt_type": "micropayoff",
  987. "original_amount": 1.0,
  988. "current_amount": 1.0,
  989. "interest_rate": 0.1,
  990. "source_chapter": 3,
  991. "due_chapter": 4,
  992. "override_contract_id": contract_id,
  993. },
  994. ensure_ascii=False,
  995. ),
  996. ]
  997. )
  998. run_cli(["--project-root", root, "fulfill-override", "--contract-id", str(contract_id)])
  999. run_cli(
  1000. [
  1001. "--project-root",
  1002. root,
  1003. "save-chapter-reading-power",
  1004. "--data",
  1005. json.dumps(
  1006. {
  1007. "chapter": 3,
  1008. "hook_type": "悬念钩",
  1009. "hook_strength": "medium",
  1010. "coolpoint_patterns": ["打脸权威"],
  1011. "micropayoffs": ["信息兑现"],
  1012. "hard_violations": [],
  1013. "soft_suggestions": [],
  1014. "is_transition": False,
  1015. "override_count": 0,
  1016. "debt_balance": 0.0,
  1017. },
  1018. ensure_ascii=False,
  1019. ),
  1020. ]
  1021. )
  1022. capsys.readouterr()
  1023. class TestStyleSampler:
  1024. """风格样本测试"""
  1025. def test_add_and_get_sample(self, temp_project):
  1026. sampler = StyleSampler(temp_project)
  1027. sample = StyleSample(
  1028. id="ch100_s1",
  1029. chapter=100,
  1030. scene_type="战斗",
  1031. content="萧炎一拳轰出...",
  1032. score=0.85,
  1033. tags=["战斗", "激烈"]
  1034. )
  1035. assert sampler.add_sample(sample)
  1036. results = sampler.get_samples_by_type("战斗")
  1037. assert len(results) == 1
  1038. assert results[0].id == "ch100_s1"
  1039. def test_extract_candidates(self, temp_project):
  1040. sampler = StyleSampler(temp_project)
  1041. scenes = [
  1042. {"index": 1, "summary": "战斗场景", "content": "萧炎一拳轰出,斗气如虹,直接将对手击退三丈,周围的空气都被震得嗡嗡作响..." + "a" * 200}
  1043. ]
  1044. # 低分不提取
  1045. candidates = sampler.extract_candidates(100, "", 70, scenes)
  1046. assert len(candidates) == 0
  1047. # 高分提取
  1048. candidates = sampler.extract_candidates(100, "", 85, scenes)
  1049. assert len(candidates) == 1
  1050. assert candidates[0].scene_type == "战斗"
  1051. def test_select_samples_for_chapter(self, temp_project):
  1052. sampler = StyleSampler(temp_project)
  1053. # 添加一些样本
  1054. for i in range(3):
  1055. sampler.add_sample(StyleSample(
  1056. id=f"battle_{i}",
  1057. chapter=i,
  1058. scene_type="战斗",
  1059. content=f"战斗内容 {i}",
  1060. score=0.9,
  1061. tags=[]
  1062. ))
  1063. samples = sampler.select_samples_for_chapter("本章有一场激烈的战斗")
  1064. assert len(samples) <= 3
  1065. assert all(s.scene_type == "战斗" for s in samples)
  1066. class TestRAGAdapter:
  1067. """RAG 适配器测试(不包含 API 调用)"""
  1068. def test_bm25_search(self, temp_project):
  1069. adapter = RAGAdapter(temp_project)
  1070. # 手动插入一些测试数据
  1071. with adapter._get_conn() as conn:
  1072. cursor = conn.cursor()
  1073. # 插入向量记录(空向量,只测试 BM25)
  1074. cursor.execute("""
  1075. INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding)
  1076. VALUES (?, ?, ?, ?, ?)
  1077. """, ("ch1_s1", 1, 1, "萧炎在天云宗修炼斗气", b""))
  1078. cursor.execute("""
  1079. INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding)
  1080. VALUES (?, ?, ?, ?, ?)
  1081. """, ("ch1_s2", 1, 2, "药老传授炼药技巧", b""))
  1082. conn.commit()
  1083. # 更新 BM25 索引
  1084. adapter._update_bm25_index(cursor, "ch1_s1", "萧炎在天云宗修炼斗气")
  1085. adapter._update_bm25_index(cursor, "ch1_s2", "药老传授炼药技巧")
  1086. conn.commit()
  1087. # BM25 搜索
  1088. results = adapter.bm25_search("萧炎修炼", top_k=5)
  1089. assert len(results) >= 1
  1090. assert results[0].chunk_id == "ch1_s1"
  1091. def test_tokenize(self, temp_project):
  1092. adapter = RAGAdapter(temp_project)
  1093. tokens = adapter._tokenize("萧炎hello世界world")
  1094. assert "萧" in tokens
  1095. assert "炎" in tokens
  1096. assert "hello" in tokens
  1097. assert "world" in tokens
  1098. if __name__ == "__main__":
  1099. pytest.main([__file__, "-v"])