test_data_modules.py 50 KB

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