test_config_tooling.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. """V2-M1 config tooling safety net: config_store, validators, Excel↔JSON sync."""
  2. from __future__ import annotations
  3. import importlib.util
  4. import json
  5. from copy import deepcopy
  6. from pathlib import Path
  7. import pytest
  8. from content_agent.integrations import config_store
  9. from content_agent.integrations.policy_json import JsonPolicyBundleStore
  10. ROOT = Path(__file__).resolve().parents[1]
  11. RULE_PACK_JSON = ROOT / "product_documents/规则包/douyin_rule_packs.v1.json"
  12. def _load_script(name: str):
  13. spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / f"{name}.py")
  14. mod = importlib.util.module_from_spec(spec)
  15. spec.loader.exec_module(mod)
  16. return mod
  17. def test_config_store_hash_matches_raw_file_bytes():
  18. parsed, raw = config_store.load_json(RULE_PACK_JSON)
  19. assert config_store.sha256_text(raw) == config_store.sha256_text(RULE_PACK_JSON.read_text("utf-8"))
  20. # policy_bundle_hash must still hash the raw rule-pack text (refactor parity).
  21. bundle = JsonPolicyBundleStore(".").load_policy_bundle("V4")
  22. assert bundle["policy_bundle_hash"] == config_store.sha256_text(raw)
  23. assert bundle["strategy_source_ref"]["content_sha256"] == bundle["policy_bundle_hash"]
  24. def test_rule_pack_fk_validator_has_no_failures():
  25. mod = _load_script("validate_rule_pack_config")
  26. findings = mod.validate_rule_pack_config(json.loads(RULE_PACK_JSON.read_text("utf-8")))
  27. assert [f for f in findings if f["level"] == "fail"] == []
  28. def test_rule_pack_validator_rejects_enabled_dispatch_conflict():
  29. mod = _load_script("validate_rule_pack_config")
  30. pkg = deepcopy(json.loads(RULE_PACK_JSON.read_text("utf-8")))
  31. duplicate = dict(pkg["rule_pack_dispatch"][0])
  32. duplicate["dispatch_id"] = "dispatch_content_duplicate"
  33. pkg["rule_pack_dispatch"].append(duplicate)
  34. findings = mod.validate_rule_pack_config(pkg)
  35. conflicts = [f for f in findings if f["check_id"] == "dispatch_conflict"]
  36. assert len(conflicts) == 1
  37. assert conflicts[0]["level"] == "fail"
  38. assert "CONFIG_RULE_PACK_DISPATCH_CONFLICT" in conflicts[0]["message"]
  39. assert "douyin_content_discovery_rule_pack_v1" in conflicts[0]["message"]
  40. def test_rule_pack_validator_allows_extra_active_dimension():
  41. mod = _load_script("validate_rule_pack_config")
  42. pkg = deepcopy(json.loads(RULE_PACK_JSON.read_text("utf-8")))
  43. scorecard = pkg["rule_packs"][0]["scorecard"]
  44. scorecard["dimensions"].append(
  45. {
  46. "key": "fifty_plus_fit",
  47. "label": "50+ 匹配",
  48. "max_score": 20,
  49. "weight_percent": 20,
  50. "runtime_status": "active",
  51. "evidence_paths": ["content_audience_50plus.score"],
  52. }
  53. )
  54. scorecard["scoring_rules"].append(
  55. {
  56. "scoring_rule_id": "score_fifty_plus_precomputed",
  57. "dimension_key": "fifty_plus_fit",
  58. "field_path": "content_audience_50plus.score",
  59. "operator": "gte",
  60. "expected_value": 0,
  61. "score_value": 20,
  62. "missing_policy": "v4_score_missing_policy",
  63. "priority": 3,
  64. "enabled": True,
  65. }
  66. )
  67. findings = mod.validate_rule_pack_config(pkg)
  68. assert [finding for finding in findings if finding["level"] == "fail"] == []
  69. def test_rule_pack_validator_accepts_m4_score_weight_profiles():
  70. mod = _load_script("validate_rule_pack_config")
  71. pkg = deepcopy(json.loads(RULE_PACK_JSON.read_text("utf-8")))
  72. scorecard = pkg["rule_packs"][0]["scorecard"]
  73. profiles = scorecard["score_weight_profiles"]["profiles"]
  74. aliases = {
  75. alias
  76. for profile in profiles
  77. for alias in profile.get("historical_alias_ids", [])
  78. }
  79. assert aliases == {
  80. "default_two_dimension",
  81. "douyin_fifty_plus_ok",
  82. "douyin_fifty_plus_not_attempted",
  83. }
  84. assert {profile["profile_id"] for profile in profiles} == {
  85. "base_video_two_dimension_v1",
  86. "audience_50plus_available_v1",
  87. "audience_50plus_skipped_by_query_gate_v1",
  88. }
  89. findings = mod.validate_rule_pack_config(pkg)
  90. assert [finding for finding in findings if finding["level"] == "fail"] == []
  91. def test_rule_pack_validator_rejects_invalid_m2_scorecard_fields():
  92. mod = _load_script("validate_rule_pack_config")
  93. pkg = deepcopy(json.loads(RULE_PACK_JSON.read_text("utf-8")))
  94. dimension = pkg["rule_packs"][0]["scorecard"]["dimensions"][0]
  95. dimension["active"] = False
  96. dimension["platform_scope"] = "douyin"
  97. dimension["score_source_path"] = ""
  98. findings = mod.validate_rule_pack_config(pkg)
  99. check_ids = {finding["check_id"] for finding in findings if finding["level"] == "fail"}
  100. assert {
  101. "v4_scorecard_dimension_active",
  102. "v4_scorecard_dimension_platform_scope",
  103. "v4_scorecard_dimension_score_source_path",
  104. } <= check_ids
  105. def test_rule_pack_validator_rejects_invalid_score_weight_profiles():
  106. mod = _load_script("validate_rule_pack_config")
  107. pkg = deepcopy(json.loads(RULE_PACK_JSON.read_text("utf-8")))
  108. scorecard = pkg["rule_packs"][0]["scorecard"]
  109. profiles = scorecard["score_weight_profiles"]["profiles"]
  110. profiles[0]["historical_alias_ids"] = []
  111. profiles[1]["weights"]["made_up_dimension"] = 10
  112. profiles[2]["normalization_reason"] = ""
  113. findings = mod.validate_rule_pack_config(pkg)
  114. check_ids = {finding["check_id"] for finding in findings if finding["level"] == "fail"}
  115. assert "v4_score_weight_profiles" in check_ids
  116. assert "v4_score_weight_profile_dimension" in check_ids
  117. assert "v4_score_weight_profile_normalization" in check_ids
  118. def test_rule_pack_validator_requires_base_dimensions_and_known_rule_refs():
  119. mod = _load_script("validate_rule_pack_config")
  120. pkg = deepcopy(json.loads(RULE_PACK_JSON.read_text("utf-8")))
  121. scorecard = pkg["rule_packs"][0]["scorecard"]
  122. scorecard["dimensions"] = [
  123. row for row in scorecard["dimensions"] if row["key"] != "query_relevance"
  124. ]
  125. missing_base = mod.validate_rule_pack_config(pkg)
  126. assert any(finding["check_id"] == "v4_scorecard_dimensions" for finding in missing_base)
  127. pkg = deepcopy(json.loads(RULE_PACK_JSON.read_text("utf-8")))
  128. pkg["rule_packs"][0]["scorecard"]["scoring_rules"][0]["dimension_key"] = "made_up_dimension"
  129. unknown_ref = mod.validate_rule_pack_config(pkg)
  130. assert any(finding["check_id"] == "scoring_rule_dimension" for finding in unknown_ref)
  131. def test_excel_meta_strategy_id_matches_walk_strategy():
  132. import json
  133. openpyxl = pytest.importorskip("openpyxl")
  134. workbook = openpyxl.load_workbook(
  135. ROOT / "tech_documents/规则包映射/规则包映射配置表.xlsx", read_only=True
  136. )
  137. rows = list(workbook["rule_package_meta"].iter_rows(values_only=True))
  138. meta = dict(zip(rows[0], rows[2])) # row 2 is the data-dictionary row; row 3 is data
  139. rule_pack = json.loads(RULE_PACK_JSON.read_text("utf-8"))
  140. assert meta["strategy_id"] == rule_pack["strategy_binding"]["strategy_id"]
  141. def test_query_prompts_validator_passes_after_m2():
  142. mod = _load_script("validate_query_prompts_config")
  143. assert mod.main() == 0
  144. def test_query_prompts_validator_rejects_invalid_profiles():
  145. mod = _load_script("validate_query_prompts_config")
  146. assert mod.validate_query_prompts_config({}) == [
  147. {"level": "fail", "check_id": "profiles", "message": "no profiles defined"}
  148. ]
  149. findings = mod.validate_query_prompts_config(
  150. {
  151. "profiles": {
  152. "douyin/V1": {
  153. "system": "s",
  154. "user": "u",
  155. "temperature": 3,
  156. "max_tokens": 0,
  157. "variants_per_seed": 0,
  158. }
  159. }
  160. }
  161. )
  162. assert {"level": "fail", "check_id": "missing_field", "message": "douyin/V1 missing evidence_fields"} in findings
  163. assert {"level": "fail", "check_id": "temperature", "message": "douyin/V1 temperature out of [0,2]: 3"} in findings
  164. assert {"level": "fail", "check_id": "max_tokens", "message": "douyin/V1 max_tokens must be > 0"} in findings
  165. assert {"level": "fail", "check_id": "variants_per_seed", "message": "douyin/V1 variants_per_seed must be >= 1"} in findings
  166. def test_excel_matches_json_byte_equal():
  167. pytest.importorskip("openpyxl")
  168. from scripts.build_config_from_excel import build
  169. from scripts.check_config_json_canonical import canonical_dumps
  170. for path_str, obj in build().items():
  171. assert canonical_dumps(obj) == Path(path_str).read_text("utf-8"), f"Excel drift in {path_str}"
  172. def test_m4_runtime_rows_require_approved_and_enabled():
  173. mod = _load_script("build_config_from_excel")
  174. rows = [
  175. {
  176. "profile_id": "approved_profile",
  177. "profile_label": "approved",
  178. "historical_alias_ids": "default_two_dimension",
  179. "platform_scope": "*",
  180. "content_format": "video",
  181. "applies_when": "always",
  182. "dimension_key": "query_relevance",
  183. "weight_percent": 100,
  184. "normalization_policy": "sum_100",
  185. "missing_dimension_policy": "technical_retry",
  186. "critical_dimension": True,
  187. "score_source_path": "pattern_match_result.query_relevance_score",
  188. "priority": 1,
  189. "runtime_enabled": True,
  190. "approval_status": "approved",
  191. "owner": "strategy",
  192. "last_reviewed_at": "2026-06-29",
  193. },
  194. {
  195. "profile_id": "waiting_profile",
  196. "dimension_key": "platform_performance",
  197. "weight_percent": 100,
  198. "runtime_enabled": True,
  199. "approval_status": "waiting_for_human_reivew",
  200. "owner": "strategy",
  201. "last_reviewed_at": "2026-06-29",
  202. },
  203. ]
  204. built = mod._build_score_weight_profiles(rows)
  205. assert [profile["profile_id"] for profile in built["profiles"]] == ["approved_profile"]
  206. def test_m4_threshold_profiles_generate_compact_runtime_json():
  207. mod = _load_script("build_config_from_excel")
  208. base = {"schema_version": "score_thresholds.v1", "_meaning": "x"}
  209. rows = [
  210. {
  211. "platform_scope": "*",
  212. "threshold_key": "pool_total",
  213. "threshold_value": 70,
  214. "runtime_enabled": True,
  215. "approval_status": "approved",
  216. "owner": "strategy",
  217. "last_reviewed_at": "2026-06-29",
  218. },
  219. {
  220. "platform_scope": "douyin",
  221. "threshold_key": "pool_total",
  222. "threshold_value": 65,
  223. "runtime_enabled": False,
  224. "approval_status": "approved",
  225. "owner": "strategy",
  226. "last_reviewed_at": "2026-06-29",
  227. },
  228. ]
  229. built = mod._build_score_thresholds(base, rows)
  230. assert built["default"]["pool_total"] == 70
  231. assert "douyin" not in built
  232. def test_m4_dead_config_archive_validator_passes_and_blocks_runtime_sources(tmp_path):
  233. mod = _load_script("validate_v5_m4_dead_config_archive")
  234. assert mod.validate_archive(ROOT / "archive/v5_m4_dead_config_archive") == []
  235. archive_root = tmp_path / "archive"
  236. batch = archive_root / "batch"
  237. batch.mkdir(parents=True)
  238. (batch / "score_thresholds.json").write_text("{}", encoding="utf-8")
  239. (batch / "manifest.json").write_text(
  240. json.dumps(
  241. {
  242. "items": [
  243. {
  244. "archive_id": "bad",
  245. "source_path": "tech_documents/数据接口与来源/score_thresholds.json",
  246. "archive_path": str((batch / "score_thresholds.json").relative_to(ROOT))
  247. if (batch / "score_thresholds.json").is_relative_to(ROOT)
  248. else "archive/batch/score_thresholds.json",
  249. "archived_at": "2026-06-29T00:00:00Z",
  250. "archived_by": "test",
  251. "proof_no_runtime_read": "none",
  252. "restore_instruction": "restore",
  253. "approval_status": "approved",
  254. }
  255. ]
  256. }
  257. ),
  258. encoding="utf-8",
  259. )
  260. findings = mod.validate_archive(archive_root)
  261. assert any(finding["check_id"] == "archive_forbidden_source" for finding in findings)