test_data_modules.py 47 KB

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