test_reference_search.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Tests for reference_search.py — BM25 keyword search over CSV reference files.
  5. """
  6. import json
  7. import shutil
  8. import subprocess
  9. import sys
  10. from pathlib import Path
  11. import pytest
  12. SCRIPT = str(Path(__file__).resolve().parents[1] / "reference_search.py")
  13. CSV_DIR = str(Path(__file__).resolve().parents[2] / "references" / "csv")
  14. def run_search(*args: str) -> dict:
  15. """Run reference_search.py as a subprocess and return parsed JSON."""
  16. result = subprocess.run(
  17. [sys.executable, SCRIPT, "--csv-dir", CSV_DIR, *args],
  18. capture_output=True,
  19. text=True,
  20. )
  21. assert result.returncode == 0, f"Script failed: {result.stderr}"
  22. return json.loads(result.stdout)
  23. class TestSkillAndGenreFiltering:
  24. """Test filtering by skill and genre."""
  25. def test_skill_write_genre_xuanhuan_returns_nr001_not_nr002(self):
  26. """--skill write --table 命名规则 --query 角色命名 --genre 玄幻 → NR-001, not NR-002."""
  27. out = run_search(
  28. "--skill", "write",
  29. "--table", "命名规则",
  30. "--query", "角色命名",
  31. "--genre", "玄幻",
  32. )
  33. assert out["status"] == "success"
  34. ids = [r["编号"] for r in out["data"]["results"]]
  35. assert "NR-001" in ids
  36. assert "NR-002" not in ids
  37. def test_skill_write_cross_table_search(self):
  38. """--skill write --query 战斗描写 → SP-001 from 场景写法."""
  39. out = run_search(
  40. "--skill", "write",
  41. "--query", "战斗描写",
  42. )
  43. assert out["status"] == "success"
  44. assert out["data"]["total"] >= 1
  45. ids = [r["编号"] for r in out["data"]["results"]]
  46. assert "SP-001" in ids
  47. # Verify it comes from the right table
  48. tables = [r["表"] for r in out["data"]["results"] if r["编号"] == "SP-001"]
  49. assert tables[0] == "场景写法"
  50. def test_nonexistent_query_returns_empty(self):
  51. """--skill plan --query nonexistent → empty results, no error."""
  52. out = run_search(
  53. "--skill", "plan",
  54. "--query", "nonexistent",
  55. )
  56. assert out["status"] == "success"
  57. assert out["data"]["total"] == 0
  58. assert out["data"]["results"] == []
  59. def test_synonym_query_hits_manual_trigger_terms(self):
  60. """意图与同义词 应能触发命名规则召回。"""
  61. out = run_search(
  62. "--skill", "write",
  63. "--table", "命名规则",
  64. "--query", "名字怎么取",
  65. "--genre", "玄幻",
  66. )
  67. assert out["status"] == "success"
  68. ids = [r["编号"] for r in out["data"]["results"]]
  69. assert "NR-001" in ids
  70. def test_emotion_query_hits_writing_techniques_table(self):
  71. """情感与心理查询应命中 写作技法.csv。"""
  72. out = run_search(
  73. "--skill", "write",
  74. "--table", "写作技法",
  75. "--query", "情感描写 心理",
  76. )
  77. assert out["status"] == "success"
  78. ids = [r["编号"] for r in out["data"]["results"]]
  79. assert "WT-002" in ids
  80. def test_prompt_derived_dialogue_query_hits_new_writing_technique(self):
  81. """基于 prompt 补充的对话技法应可被检索。"""
  82. out = run_search(
  83. "--skill", "write",
  84. "--table", "写作技法",
  85. "--query", "去水词对话",
  86. )
  87. assert out["status"] == "success"
  88. ids = [r["编号"] for r in out["data"]["results"]]
  89. assert "WT-005" in ids
  90. def test_prompt_derived_trope_query_hits_bridge_table(self):
  91. """桥段套路表应能命中退婚流反击条目。"""
  92. out = run_search(
  93. "--skill", "write",
  94. "--table", "桥段套路",
  95. "--query", "退婚流 三年之约",
  96. )
  97. assert out["status"] == "success"
  98. ids = [r["编号"] for r in out["data"]["results"]]
  99. assert "TR-001" in ids
  100. def test_prompt_derived_pacing_query_hits_new_pacing_table(self):
  101. """爽点与节奏表应能命中微反转补刀。"""
  102. out = run_search(
  103. "--skill", "plan",
  104. "--table", "爽点与节奏",
  105. "--query", "微反转补刀",
  106. )
  107. assert out["status"] == "success"
  108. ids = [r["编号"] for r in out["data"]["results"]]
  109. assert "PA-002" in ids
  110. def test_prompt_derived_setting_query_hits_new_system_table(self):
  111. """金手指与设定表应能命中异能副作用边界。"""
  112. out = run_search(
  113. "--skill", "init",
  114. "--table", "金手指与设定",
  115. "--query", "异能副作用 代价",
  116. )
  117. assert out["status"] == "success"
  118. ids = [r["编号"] for r in out["data"]["results"]]
  119. assert "SY-002" in ids
  120. def test_prompt_derived_character_query_hits_new_character_table(self):
  121. """人设与关系表应能命中镜像反派条目。"""
  122. out = run_search(
  123. "--skill", "init",
  124. "--table", "人设与关系",
  125. "--query", "镜像反派",
  126. )
  127. assert out["status"] == "success"
  128. ids = [r["编号"] for r in out["data"]["results"]]
  129. assert "CH-001" in ids
  130. def test_legacy_comma_delimiters_remain_compatible(self):
  131. """迁移过渡期仍兼容旧的逗号分隔技能与题材字段。"""
  132. temp_dir = Path.home() / ".codex" / "memories" / "reference_search_compat"
  133. if temp_dir.exists():
  134. shutil.rmtree(temp_dir)
  135. temp_dir.mkdir(parents=True, exist_ok=True)
  136. csv_path = temp_dir / "兼容测试.csv"
  137. csv_path.write_text(
  138. "\n".join([
  139. "编号,适用技能,分类,层级,关键词,意图与同义词,适用题材,大模型指令,核心摘要,详细展开",
  140. "TS-001,\"write,plan\",测试,提醒,\"旧格式关键词\",\"旧格式查询\",\"玄幻,仙侠\",检查兼容层,兼容摘要,兼容详细展开",
  141. ]),
  142. encoding="utf-8",
  143. )
  144. out = run_search(
  145. "--csv-dir", str(temp_dir),
  146. "--skill", "write",
  147. "--table", "兼容测试",
  148. "--query", "旧格式查询",
  149. "--genre", "玄幻",
  150. )
  151. ids = [r["编号"] for r in out["data"]["results"]]
  152. assert "TS-001" in ids
  153. class TestErrorHandling:
  154. """Test error cases."""
  155. def test_missing_csv_dir_returns_error(self):
  156. """Missing CSV dir → error JSON."""
  157. result = subprocess.run(
  158. [sys.executable, SCRIPT,
  159. "--csv-dir", "/nonexistent/path/that/does/not/exist",
  160. "--skill", "write",
  161. "--query", "test"],
  162. capture_output=True,
  163. text=True,
  164. )
  165. out = json.loads(result.stdout)
  166. assert out["status"] == "error"
  167. assert "CSV_DIR_NOT_FOUND" in out["error"]["code"]
  168. class TestOutputFormat:
  169. """Test output JSON structure."""
  170. def test_result_has_required_fields(self):
  171. """Each result has 编号, 表, 分类, 层级, 适用题材, 内容摘要, 大模型指令."""
  172. out = run_search(
  173. "--skill", "write",
  174. "--table", "命名规则",
  175. "--query", "角色命名",
  176. )
  177. assert out["status"] == "success"
  178. for r in out["data"]["results"]:
  179. assert "编号" in r
  180. assert "表" in r
  181. assert "分类" in r
  182. assert "层级" in r
  183. assert "适用题材" in r
  184. assert "内容摘要" in r
  185. assert "大模型指令" in r
  186. def test_content_summary_prefers_core_summary(self):
  187. """内容摘要优先返回 核心摘要。"""
  188. out = run_search(
  189. "--skill", "write",
  190. "--table", "命名规则",
  191. "--query", "角色命名",
  192. "--genre", "玄幻",
  193. )
  194. row = next(r for r in out["data"]["results"] if r["编号"] == "NR-001")
  195. assert row["内容摘要"] == "玄幻角色命名要保留修仙感与古风意象,避免现代日常姓名直接套入。"
  196. def test_data_envelope_fields(self):
  197. """Data envelope has query, skill, genre, total, results."""
  198. out = run_search(
  199. "--skill", "write",
  200. "--query", "命名",
  201. "--genre", "玄幻",
  202. )
  203. data = out["data"]
  204. assert data["query"] == "命名"
  205. assert data["skill"] == "write"
  206. assert data["genre"] == "玄幻"
  207. assert isinstance(data["total"], int)
  208. assert isinstance(data["results"], list)
  209. def test_max_results_limits_output(self):
  210. """--max-results 1 limits to 1 result."""
  211. out = run_search(
  212. "--skill", "write",
  213. "--query", "命名",
  214. "--max-results", "1",
  215. )
  216. assert out["data"]["total"] <= 1
  217. class TestPerTableSearchCols:
  218. def test_different_tables_use_different_search_weights(self):
  219. out1 = run_search("--skill", "write", "--table", "命名规则", "--query", "角色命名")
  220. out2 = run_search("--skill", "write", "--table", "场景写法", "--query", "战斗描写")
  221. assert out1["status"] == "success"
  222. assert out2["status"] == "success"
  223. assert out1["data"]["total"] >= 1
  224. assert out2["data"]["total"] >= 1
  225. class TestGenreCanonical:
  226. def test_canonical_genres_has_15_entries(self):
  227. from reference_search import GENRE_CANONICAL
  228. assert len(GENRE_CANONICAL) == 15
  229. expected = {
  230. "都市", "玄幻", "仙侠", "奇幻", "科幻",
  231. "历史", "悬疑", "游戏", "古言", "现言",
  232. "幻言", "年代", "种田", "快穿", "衍生",
  233. }
  234. assert GENRE_CANONICAL == expected
  235. def test_platform_to_canonical_maps_all_tags(self):
  236. from reference_search import PLATFORM_TO_CANONICAL
  237. # 34 unique tags (some tags like 科幻末世, 悬疑脑洞, 游戏体育 appear in both male/female)
  238. assert len(PLATFORM_TO_CANONICAL) == 34
  239. # Every value must be a canonical genre
  240. from reference_search import GENRE_CANONICAL
  241. for tag, canonical in PLATFORM_TO_CANONICAL.items():
  242. assert canonical in GENRE_CANONICAL, f"{tag} -> {canonical} not in GENRE_CANONICAL"
  243. def test_platform_to_canonical_spot_checks(self):
  244. from reference_search import PLATFORM_TO_CANONICAL
  245. assert PLATFORM_TO_CANONICAL["都市日常"] == "都市"
  246. assert PLATFORM_TO_CANONICAL["战神赘婿"] == "都市"
  247. assert PLATFORM_TO_CANONICAL["东方仙侠"] == "仙侠"
  248. assert PLATFORM_TO_CANONICAL["西方奇幻"] == "奇幻"
  249. assert PLATFORM_TO_CANONICAL["古风世情"] == "古言"
  250. assert PLATFORM_TO_CANONICAL["豪门总裁"] == "现言"
  251. assert PLATFORM_TO_CANONICAL["快穿"] == "快穿"
  252. assert PLATFORM_TO_CANONICAL["科幻末世"] == "科幻"