test_projection_writers.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. import json
  4. from data_modules.chapter_commit_service import ChapterCommitService
  5. from data_modules.config import DataModulesConfig
  6. from data_modules.index_manager import IndexManager
  7. from data_modules.memory.store import ScratchpadManager
  8. from data_modules.index_projection_writer import IndexProjectionWriter
  9. from data_modules.memory_projection_writer import MemoryProjectionWriter
  10. from data_modules.state_projection_writer import StateProjectionWriter
  11. from data_modules.summary_projection_writer import SummaryProjectionWriter
  12. def test_state_projection_writer_handles_rejected_commit(tmp_path):
  13. (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
  14. (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  15. writer = StateProjectionWriter(tmp_path)
  16. result = writer.apply({"meta": {"status": "rejected", "chapter": 3}, "state_deltas": []})
  17. assert result["applied"] is True
  18. state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  19. assert state["progress"]["chapter_status"]["3"] == "chapter_rejected"
  20. def test_state_projection_writer_applies_accepted_commit(tmp_path):
  21. (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
  22. (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  23. writer = StateProjectionWriter(tmp_path)
  24. result = writer.apply(
  25. {
  26. "meta": {"status": "accepted", "chapter": 3},
  27. "state_deltas": [{"entity_id": "x", "field": "realm", "new": "斗者"}],
  28. }
  29. )
  30. assert result["applied"] is True
  31. payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  32. assert payload["entity_state"]["x"]["realm"] == "斗者"
  33. assert payload["progress"]["chapter_status"]["3"] == "chapter_committed"
  34. assert payload["progress"]["current_chapter"] == 3
  35. assert payload["progress"]["last_updated"]
  36. def test_accepted_chapter_commits_advance_progress_and_word_count(tmp_path):
  37. (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
  38. (tmp_path / ".webnovel" / "state.json").write_text(
  39. json.dumps(
  40. {"progress": {"current_chapter": 0, "total_words": 0, "last_updated": "2026-01-01 00:00:00"}},
  41. ensure_ascii=False,
  42. ),
  43. encoding="utf-8",
  44. )
  45. chapters_dir = tmp_path / "正文"
  46. chapters_dir.mkdir(parents=True, exist_ok=True)
  47. (chapters_dir / "第0001章.md").write_text("第一章正文内容", encoding="utf-8")
  48. (chapters_dir / "第0002章.md").write_text("第二章正文内容更多", encoding="utf-8")
  49. service = ChapterCommitService(tmp_path)
  50. for chapter in (1, 2):
  51. payload = service.build_commit(
  52. chapter=chapter,
  53. review_result={"blocking_count": 0},
  54. fulfillment_result={"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
  55. disambiguation_result={"pending": []},
  56. extraction_result={"state_deltas": [], "entity_deltas": [], "accepted_events": []},
  57. )
  58. service.apply_projections(payload)
  59. state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  60. assert state["progress"]["chapter_status"]["1"] == "chapter_committed"
  61. assert state["progress"]["chapter_status"]["2"] == "chapter_committed"
  62. assert state["progress"]["current_chapter"] == 2
  63. assert state["progress"]["total_words"] > 0
  64. assert state["progress"]["last_updated"] != "2026-01-01 00:00:00"
  65. def test_reapplying_accepted_chapter_commit_does_not_double_count_words(tmp_path):
  66. (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
  67. (tmp_path / ".webnovel" / "state.json").write_text(
  68. json.dumps(
  69. {"progress": {"current_chapter": 0, "total_words": 0}},
  70. ensure_ascii=False,
  71. ),
  72. encoding="utf-8",
  73. )
  74. chapters_dir = tmp_path / "正文"
  75. chapters_dir.mkdir(parents=True, exist_ok=True)
  76. (chapters_dir / "第0001章.md").write_text("第一章正文内容", encoding="utf-8")
  77. payload = {
  78. "meta": {"status": "accepted", "chapter": 1},
  79. "state_deltas": [],
  80. "accepted_events": [],
  81. }
  82. writer = StateProjectionWriter(tmp_path)
  83. writer.apply(payload)
  84. first_state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  85. writer.apply(payload)
  86. second_state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  87. assert second_state["progress"]["current_chapter"] == 1
  88. assert second_state["progress"]["total_words"] == first_state["progress"]["total_words"]
  89. assert second_state["progress"]["last_updated"] == first_state["progress"]["last_updated"]
  90. def test_state_projection_writer_derives_delta_from_power_breakthrough_event(tmp_path):
  91. (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
  92. (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  93. writer = StateProjectionWriter(tmp_path)
  94. result = writer.apply(
  95. {
  96. "meta": {"status": "accepted", "chapter": 3},
  97. "state_deltas": [],
  98. "accepted_events": [
  99. {
  100. "event_id": "evt-001",
  101. "chapter": 3,
  102. "event_type": "power_breakthrough",
  103. "subject": "xiaoyan",
  104. "payload": {"from": "斗者", "to": "斗师"},
  105. }
  106. ],
  107. }
  108. )
  109. payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  110. assert result["applied"] is True
  111. assert payload["entity_state"]["xiaoyan"]["realm"] == "斗师"
  112. def test_state_projection_writer_updates_strand_tracker(tmp_path):
  113. (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
  114. (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  115. writer = StateProjectionWriter(tmp_path)
  116. writer.apply(
  117. {
  118. "meta": {"status": "accepted", "chapter": 3},
  119. "state_deltas": [],
  120. "accepted_events": [],
  121. "dominant_strand": "quest",
  122. }
  123. )
  124. writer.apply(
  125. {
  126. "meta": {"status": "accepted", "chapter": 4},
  127. "state_deltas": [],
  128. "accepted_events": [],
  129. "dominant_strand": "quest",
  130. }
  131. )
  132. payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  133. tracker = payload["strand_tracker"]
  134. assert tracker["current_dominant"] == "quest"
  135. assert tracker["last_quest_chapter"] == 4
  136. assert tracker["chapters_since_switch"] == 2
  137. assert len(tracker["history"]) == 2
  138. def test_state_projection_writer_reapplying_chapter_replaces_strand(tmp_path):
  139. (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
  140. (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  141. writer = StateProjectionWriter(tmp_path)
  142. writer.apply(
  143. {
  144. "meta": {"status": "accepted", "chapter": 3},
  145. "state_deltas": [],
  146. "accepted_events": [],
  147. "dominant_strand": "quest",
  148. }
  149. )
  150. writer.apply(
  151. {
  152. "meta": {"status": "accepted", "chapter": 3},
  153. "state_deltas": [],
  154. "accepted_events": [],
  155. "dominant_strand": "fire",
  156. }
  157. )
  158. payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  159. tracker = payload["strand_tracker"]
  160. assert tracker["current_dominant"] == "fire"
  161. assert tracker["last_quest_chapter"] == 0
  162. assert tracker["last_fire_chapter"] == 3
  163. assert tracker["history"] == [{"chapter": 3, "dominant": "fire"}]
  164. def test_accepted_commit_updates_state_json_end_to_end(tmp_path):
  165. (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
  166. (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  167. service = ChapterCommitService(tmp_path)
  168. commit_payload = service.build_commit(
  169. chapter=3,
  170. review_result={"blocking_count": 0},
  171. fulfillment_result={"planned_nodes": ["发现陷阱"], "covered_nodes": ["发现陷阱"], "missed_nodes": [], "extra_nodes": []},
  172. disambiguation_result={"pending": []},
  173. extraction_result={"state_deltas": [{"entity_id": "x", "field": "realm", "new": "斗者"}], "entity_deltas": [], "accepted_events": []},
  174. )
  175. StateProjectionWriter(tmp_path).apply(commit_payload)
  176. payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
  177. assert payload["entity_state"]["x"]["realm"] == "斗者"
  178. def test_index_projection_writer_applies_entity_delta(tmp_path):
  179. cfg = DataModulesConfig.from_project_root(tmp_path)
  180. cfg.ensure_dirs()
  181. writer = IndexProjectionWriter(tmp_path)
  182. result = writer.apply(
  183. {
  184. "meta": {"status": "accepted", "chapter": 3},
  185. "entity_deltas": [
  186. {
  187. "entity_id": "xiaoyan",
  188. "canonical_name": "萧炎",
  189. "type": "角色",
  190. "current": {"realm": "斗者"},
  191. "chapter": 3,
  192. }
  193. ],
  194. }
  195. )
  196. entity = IndexManager(cfg).get_entity("xiaoyan")
  197. assert result["applied"] is True
  198. assert entity["canonical_name"] == "萧炎"
  199. assert entity["current_json"]["realm"] == "斗者"
  200. def test_index_projection_writer_registers_stable_protagonist_aliases(tmp_path):
  201. cfg = DataModulesConfig.from_project_root(tmp_path)
  202. cfg.ensure_dirs()
  203. writer = IndexProjectionWriter(tmp_path)
  204. result = writer.apply(
  205. {
  206. "meta": {"status": "accepted", "chapter": 1},
  207. "entity_deltas": [
  208. {
  209. "entity_id": "lu_ming",
  210. "canonical_name": "陆鸣",
  211. "type": "角色",
  212. "tier": "核心",
  213. "chapter": 1,
  214. "is_protagonist": True,
  215. }
  216. ],
  217. }
  218. )
  219. manager = IndexManager(cfg)
  220. assert result["applied"] is True
  221. assert manager.get_entity("lu_ming")["canonical_name"] == "陆鸣"
  222. assert manager.get_entity("陆鸣")["id"] == "lu_ming"
  223. assert manager.get_entity("protagonist")["id"] == "lu_ming"
  224. assert manager.get_entity("luming")["id"] == "lu_ming"
  225. def test_entity_delta_without_protagonist_flag_preserves_existing_protagonist(tmp_path):
  226. cfg = DataModulesConfig.from_project_root(tmp_path)
  227. cfg.ensure_dirs()
  228. manager = IndexManager(cfg)
  229. manager.apply_entity_delta(
  230. {
  231. "entity_id": "lu_ming",
  232. "canonical_name": "陆鸣",
  233. "type": "角色",
  234. "tier": "核心",
  235. "chapter": 1,
  236. "is_protagonist": True,
  237. }
  238. )
  239. manager.apply_entity_delta(
  240. {
  241. "entity_id": "lu_ming",
  242. "canonical_name": "陆鸣",
  243. "type": "角色",
  244. "tier": "核心",
  245. "chapter": 2,
  246. "field": "realm",
  247. "new": "炼气二层",
  248. }
  249. )
  250. assert manager.get_protagonist()["id"] == "lu_ming"
  251. assert manager.get_entity("protagonist")["id"] == "lu_ming"
  252. assert manager.get_entity("lu_ming")["is_protagonist"] == 1
  253. def test_index_projection_writer_derives_relationship_from_event(tmp_path):
  254. cfg = DataModulesConfig.from_project_root(tmp_path)
  255. cfg.ensure_dirs()
  256. writer = IndexProjectionWriter(tmp_path)
  257. result = writer.apply(
  258. {
  259. "meta": {"status": "accepted", "chapter": 3},
  260. "entity_deltas": [],
  261. "accepted_events": [
  262. {
  263. "event_id": "evt-001",
  264. "chapter": 3,
  265. "event_type": "relationship_changed",
  266. "subject": "xiaoyan",
  267. "payload": {
  268. "to_entity": "yaolao",
  269. "relationship_type": "师徒",
  270. "description": "关系正式确立",
  271. },
  272. }
  273. ],
  274. }
  275. )
  276. rels = IndexManager(cfg).get_relationship_between("xiaoyan", "yaolao")
  277. assert result["applied"] is True
  278. assert rels[0]["type"] == "师徒"
  279. def test_index_projection_writer_derives_artifact_entity_from_event(tmp_path):
  280. cfg = DataModulesConfig.from_project_root(tmp_path)
  281. cfg.ensure_dirs()
  282. writer = IndexProjectionWriter(tmp_path)
  283. result = writer.apply(
  284. {
  285. "meta": {"status": "accepted", "chapter": 3},
  286. "entity_deltas": [],
  287. "accepted_events": [
  288. {
  289. "event_id": "evt-002",
  290. "chapter": 3,
  291. "event_type": "artifact_obtained",
  292. "subject": "黑戒",
  293. "payload": {
  294. "artifact_id": "black_ring",
  295. "name": "黑戒",
  296. "owner": "xiaoyan",
  297. },
  298. }
  299. ],
  300. }
  301. )
  302. entity = IndexManager(cfg).get_entity("black_ring")
  303. assert result["applied"] is True
  304. assert entity["canonical_name"] == "黑戒"
  305. assert entity["current_json"]["holder"] == "xiaoyan"
  306. def test_accepted_commit_writes_chapter_index_tables(tmp_path):
  307. cfg = DataModulesConfig.from_project_root(tmp_path)
  308. cfg.ensure_dirs()
  309. (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
  310. chapters_dir = tmp_path / "正文"
  311. chapters_dir.mkdir(parents=True, exist_ok=True)
  312. (chapters_dir / "第0003章.md").write_text("第三章正文内容", encoding="utf-8")
  313. service = ChapterCommitService(tmp_path)
  314. payload = service.build_commit(
  315. chapter=3,
  316. review_result={"blocking_count": 0},
  317. fulfillment_result={"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
  318. disambiguation_result={"pending": []},
  319. extraction_result={
  320. "summary_text": "本章摘要",
  321. "state_deltas": [{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}],
  322. "entity_deltas": [],
  323. "entities_appeared": [{"id": "xiaoyan", "mentions": ["萧炎"], "confidence": 0.95}],
  324. "scenes": [
  325. {
  326. "index": 1,
  327. "start_line": 1,
  328. "end_line": 12,
  329. "location": "山门",
  330. "summary": "萧炎完成突破",
  331. "characters": ["xiaoyan"],
  332. }
  333. ],
  334. "accepted_events": [],
  335. },
  336. )
  337. result = service.apply_projections(payload)
  338. manager = IndexManager(cfg)
  339. assert result["projection_status"]["index"] == "done"
  340. assert manager.get_chapter(3)["summary"] == "本章摘要"
  341. assert manager.get_chapter_appearances(3)[0]["entity_id"] == "xiaoyan"
  342. assert manager.get_scenes(3)[0]["location"] == "山门"
  343. changes = manager.get_chapter_state_changes(3)
  344. assert len(changes) == 1
  345. assert changes[0]["entity_id"] == "xiaoyan"
  346. assert changes[0]["field"] == "realm"
  347. def test_index_projection_writer_records_state_change_from_event(tmp_path):
  348. cfg = DataModulesConfig.from_project_root(tmp_path)
  349. cfg.ensure_dirs()
  350. writer = IndexProjectionWriter(tmp_path)
  351. result = writer.apply(
  352. {
  353. "meta": {"status": "accepted", "chapter": 3},
  354. "state_deltas": [],
  355. "entity_deltas": [],
  356. "accepted_events": [
  357. {
  358. "event_id": "evt-001",
  359. "chapter": 3,
  360. "event_type": "character_state_changed",
  361. "subject": "xiaoyan",
  362. "payload": {"field": "mood", "old": "躁动", "new": "冷静"},
  363. }
  364. ],
  365. }
  366. )
  367. changes = IndexManager(cfg).get_chapter_state_changes(3)
  368. assert result["state_changes"] == 1
  369. assert len(changes) == 1
  370. assert changes[0]["entity_id"] == "xiaoyan"
  371. assert changes[0]["field"] == "mood"
  372. def test_summary_projection_writer_writes_summary_markdown(tmp_path):
  373. cfg = DataModulesConfig.from_project_root(tmp_path)
  374. cfg.ensure_dirs()
  375. writer = SummaryProjectionWriter(tmp_path)
  376. result = writer.apply(
  377. {
  378. "meta": {"status": "accepted", "chapter": 3},
  379. "summary_text": "本章主角发现陷阱并决定隐忍。",
  380. }
  381. )
  382. summary_path = tmp_path / ".webnovel" / "summaries" / "ch0003.md"
  383. assert result["applied"] is True
  384. assert summary_path.is_file()
  385. assert "剧情摘要" in summary_path.read_text(encoding="utf-8")
  386. def test_memory_projection_writer_maps_commit_into_scratchpad(tmp_path):
  387. cfg = DataModulesConfig.from_project_root(tmp_path)
  388. cfg.ensure_dirs()
  389. writer = MemoryProjectionWriter(tmp_path)
  390. result = writer.apply(
  391. {
  392. "meta": {"status": "accepted", "chapter": 3},
  393. "state_deltas": [
  394. {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}
  395. ],
  396. "entity_deltas": [],
  397. "accepted_events": [],
  398. }
  399. )
  400. store = ScratchpadManager(cfg)
  401. chars = store.query(category="character_state", status="active")
  402. assert result["applied"] is True
  403. assert any(x.subject == "xiaoyan" and x.field == "realm" for x in chars)
  404. def test_memory_projection_writer_maps_open_loop_event_into_scratchpad(tmp_path):
  405. cfg = DataModulesConfig.from_project_root(tmp_path)
  406. cfg.ensure_dirs()
  407. writer = MemoryProjectionWriter(tmp_path)
  408. result = writer.apply(
  409. {
  410. "meta": {"status": "accepted", "chapter": 3},
  411. "state_deltas": [],
  412. "entity_deltas": [],
  413. "accepted_events": [
  414. {
  415. "event_id": "evt-001",
  416. "chapter": 3,
  417. "event_type": "open_loop_created",
  418. "subject": "三年之约",
  419. "payload": {"content": "三年之约"},
  420. }
  421. ],
  422. }
  423. )
  424. store = ScratchpadManager(cfg)
  425. loops = store.query(category="open_loop", status="active")
  426. assert result["applied"] is True
  427. assert any("三年之约" in x.subject for x in loops)