test_extract_chapter_context.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. import json
  4. import sys
  5. from pathlib import Path
  6. def test_extract_state_summary_accepts_dominant_key(tmp_path):
  7. scripts_dir = Path(__file__).resolve().parents[2]
  8. if str(scripts_dir) not in sys.path:
  9. sys.path.insert(0, str(scripts_dir))
  10. from extract_chapter_context import extract_state_summary
  11. state = {
  12. "progress": {"current_chapter": 12, "total_words": 12345},
  13. "protagonist_state": {
  14. "power": {"realm": "筑基", "layer": 2},
  15. "location": "宗门",
  16. "golden_finger": {"name": "系统", "level": 1},
  17. },
  18. "strand_tracker": {
  19. "history": [
  20. {"chapter": 10, "dominant": "quest"},
  21. {"chapter": 11, "dominant": "fire"},
  22. ]
  23. },
  24. }
  25. webnovel_dir = tmp_path / ".webnovel"
  26. webnovel_dir.mkdir(parents=True, exist_ok=True)
  27. (webnovel_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
  28. text = extract_state_summary(tmp_path)
  29. assert "Ch10:quest" in text
  30. assert "Ch11:fire" in text
  31. def test_extract_chapter_outline_supports_hyphen_filename(tmp_path):
  32. scripts_dir = Path(__file__).resolve().parents[2]
  33. if str(scripts_dir) not in sys.path:
  34. sys.path.insert(0, str(scripts_dir))
  35. from extract_chapter_context import extract_chapter_outline
  36. outline_dir = tmp_path / "大纲"
  37. outline_dir.mkdir(parents=True, exist_ok=True)
  38. (outline_dir / "第1卷-详细大纲.md").write_text("### 第1章:测试标题\n测试大纲", encoding="utf-8")
  39. outline = extract_chapter_outline(tmp_path, 1)
  40. assert "### 第1章:测试标题" in outline
  41. assert "测试大纲" in outline
  42. def test_extract_chapter_outline_prefers_state_volume_mapping(tmp_path):
  43. scripts_dir = Path(__file__).resolve().parents[2]
  44. if str(scripts_dir) not in sys.path:
  45. sys.path.insert(0, str(scripts_dir))
  46. from extract_chapter_context import extract_chapter_outline
  47. webnovel_dir = tmp_path / ".webnovel"
  48. webnovel_dir.mkdir(parents=True, exist_ok=True)
  49. state = {
  50. "progress": {
  51. "volumes_planned": [
  52. {"volume": 1, "chapters_range": "1-10"},
  53. {"volume": 2, "chapters_range": "11-20"},
  54. ]
  55. }
  56. }
  57. (webnovel_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
  58. outline_dir = tmp_path / "大纲"
  59. outline_dir.mkdir(parents=True, exist_ok=True)
  60. (outline_dir / "第2卷-详细大纲.md").write_text("### 第12章:V2标题\nV2大纲", encoding="utf-8")
  61. outline = extract_chapter_outline(tmp_path, 12)
  62. assert "### 第12章:V2标题" in outline
  63. assert "V2大纲" in outline
  64. def test_extract_chapter_outline_falls_back_when_state_has_no_match(tmp_path):
  65. scripts_dir = Path(__file__).resolve().parents[2]
  66. if str(scripts_dir) not in sys.path:
  67. sys.path.insert(0, str(scripts_dir))
  68. from extract_chapter_context import extract_chapter_outline
  69. webnovel_dir = tmp_path / ".webnovel"
  70. webnovel_dir.mkdir(parents=True, exist_ok=True)
  71. state = {"progress": {"volumes_planned": [{"volume": 1, "chapters_range": "1-10"}]}}
  72. (webnovel_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
  73. outline_dir = tmp_path / "大纲"
  74. outline_dir.mkdir(parents=True, exist_ok=True)
  75. (outline_dir / "第2卷-详细大纲.md").write_text("### 第60章:V2标题\nV2大纲", encoding="utf-8")
  76. outline = extract_chapter_outline(tmp_path, 60)
  77. assert "### 第60章:V2标题" in outline
  78. assert "V2大纲" in outline
  79. def test_build_chapter_context_payload_includes_contract_sections(tmp_path):
  80. scripts_dir = Path(__file__).resolve().parents[2]
  81. if str(scripts_dir) not in sys.path:
  82. sys.path.insert(0, str(scripts_dir))
  83. from extract_chapter_context import build_chapter_context_payload
  84. from data_modules.config import DataModulesConfig
  85. from data_modules.index_manager import IndexManager, ChapterReadingPowerMeta, ReviewMetrics
  86. cfg = DataModulesConfig.from_project_root(tmp_path)
  87. cfg.ensure_dirs()
  88. state = {
  89. "project": {"genre": "xuanhuan"},
  90. "progress": {"current_chapter": 3, "total_words": 9000},
  91. "protagonist_state": {
  92. "power": {"realm": "筑基", "layer": 2},
  93. "location": "宗门",
  94. "golden_finger": {"name": "系统", "level": 1},
  95. },
  96. "strand_tracker": {"history": [{"chapter": 2, "dominant": "quest"}]},
  97. "chapter_meta": {},
  98. "disambiguation_warnings": [],
  99. "disambiguation_pending": [],
  100. }
  101. (cfg.webnovel_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
  102. summaries_dir = cfg.webnovel_dir / "summaries"
  103. summaries_dir.mkdir(parents=True, exist_ok=True)
  104. (summaries_dir / "ch0002.md").write_text("## 剧情摘要\n上一章总结", encoding="utf-8")
  105. outline_dir = tmp_path / "大纲"
  106. outline_dir.mkdir(parents=True, exist_ok=True)
  107. (outline_dir / "第1卷 详细大纲.md").write_text("### 第3章:测试标题\n测试大纲", encoding="utf-8")
  108. refs_dir = tmp_path / ".claude" / "references"
  109. refs_dir.mkdir(parents=True, exist_ok=True)
  110. (refs_dir / "genre-profiles.md").write_text("## xuanhuan\n- 升级线清晰", encoding="utf-8")
  111. (refs_dir / "reading-power-taxonomy.md").write_text("## xuanhuan\n- 悬念钩优先", encoding="utf-8")
  112. idx = IndexManager(cfg)
  113. idx.save_chapter_reading_power(
  114. ChapterReadingPowerMeta(chapter=2, hook_type="悬念钩", hook_strength="strong", coolpoint_patterns=["身份掉马"])
  115. )
  116. idx.save_review_metrics(
  117. ReviewMetrics(start_chapter=1, end_chapter=2, overall_score=71, dimension_scores={"plot": 71})
  118. )
  119. payload = build_chapter_context_payload(tmp_path, 3)
  120. assert payload["context_contract_version"] == "v2"
  121. assert payload.get("context_weight_stage") in {"early", "mid", "late"}
  122. assert "writing_guidance" in payload
  123. assert isinstance(payload["writing_guidance"].get("guidance_items"), list)
  124. assert isinstance(payload["writing_guidance"].get("checklist"), list)
  125. assert isinstance(payload["writing_guidance"].get("checklist_score"), dict)
  126. assert payload["genre_profile"].get("genre") == "xuanhuan"
  127. assert "rag_assist" in payload
  128. assert isinstance(payload["rag_assist"], dict)
  129. assert payload["rag_assist"].get("invoked") is False
  130. def test_render_text_contains_writing_guidance_section(tmp_path):
  131. scripts_dir = Path(__file__).resolve().parents[2]
  132. if str(scripts_dir) not in sys.path:
  133. sys.path.insert(0, str(scripts_dir))
  134. from extract_chapter_context import _render_text
  135. payload = {
  136. "chapter": 10,
  137. "outline": "测试大纲",
  138. "previous_summaries": ["### 第9章摘要\n上一章"],
  139. "state_summary": "状态",
  140. "context_contract_version": "v2",
  141. "context_weight_stage": "early",
  142. "reader_signal": {"review_trend": {"overall_avg": 72}, "low_score_ranges": [{"start_chapter": 8, "end_chapter": 9}]},
  143. "genre_profile": {
  144. "genre": "xuanhuan",
  145. "genres": ["xuanhuan", "realistic"],
  146. "composite_hints": ["以玄幻主线推进,同时保留现实议题表达"],
  147. "reference_hints": ["升级线清晰"],
  148. },
  149. "writing_guidance": {
  150. "guidance_items": ["先修低分", "钩子差异化"],
  151. "checklist": [
  152. {
  153. "id": "fix_low_score_range",
  154. "label": "修复低分区间问题",
  155. "weight": 1.4,
  156. "required": True,
  157. "source": "reader_signal.low_score_ranges",
  158. "verify_hint": "至少完成1处冲突升级",
  159. }
  160. ],
  161. "checklist_score": {
  162. "score": 81.5,
  163. "completion_rate": 0.66,
  164. "required_completion_rate": 0.75,
  165. },
  166. "methodology": {
  167. "enabled": True,
  168. "framework": "digital-serial-v1",
  169. "pilot": "xianxia",
  170. "genre_profile_key": "xianxia",
  171. "chapter_stage": "confront",
  172. "observability": {
  173. "next_reason_clarity": 78.0,
  174. "anchor_effectiveness": 74.0,
  175. "rhythm_naturalness": 72.0,
  176. },
  177. "signals": {"risk_flags": ["pattern_overuse_watch"]},
  178. },
  179. },
  180. }
  181. text = _render_text(payload)
  182. assert "## 写作执行建议" in text
  183. assert "先修低分" in text
  184. assert "## Contract (v2)" in text
  185. assert "- 上下文阶段权重: early" in text
  186. assert "### 执行检查清单(可评分)" in text
  187. assert "- 总权重: 1.40" in text
  188. assert "[必做][w=1.4] 修复低分区间问题" in text
  189. assert "### 执行评分" in text
  190. assert "- 评分: 81.5" in text
  191. assert "- 复合题材: xuanhuan + realistic" in text
  192. assert "## 长篇方法论策略" in text
  193. assert "- 适用题材: xianxia" in text
  194. assert "next_reason=78.0" in text
  195. def test_render_text_contains_rag_assist_section_when_hits_exist(tmp_path):
  196. scripts_dir = Path(__file__).resolve().parents[2]
  197. if str(scripts_dir) not in sys.path:
  198. sys.path.insert(0, str(scripts_dir))
  199. from extract_chapter_context import _render_text
  200. payload = {
  201. "chapter": 12,
  202. "outline": "测试大纲",
  203. "previous_summaries": [],
  204. "state_summary": "状态",
  205. "context_contract_version": "v2",
  206. "reader_signal": {},
  207. "genre_profile": {},
  208. "writing_guidance": {},
  209. "rag_assist": {
  210. "invoked": True,
  211. "mode": "auto",
  212. "intent": "relationship",
  213. "query": "第12章 人物关系与动机:萧炎与药老发生冲突",
  214. "hits": [
  215. {
  216. "chapter": 9,
  217. "scene_index": 2,
  218. "source": "graph_hybrid",
  219. "score": 0.91,
  220. "content": "萧炎与药老在修炼方向上发生分歧。",
  221. }
  222. ],
  223. },
  224. }
  225. text = _render_text(payload)
  226. assert "## RAG 检索线索" in text
  227. assert "- 模式: auto" in text
  228. assert "[graph_hybrid]" in text
  229. assert "萧炎与药老" in text