walk_strategy_json.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. from __future__ import annotations
  2. import json
  3. from dataclasses import dataclass
  4. from pathlib import Path
  5. from typing import Any
  6. from content_agent.findings import fail as _fail
  7. WALK_STRATEGY_PATH = Path("product_documents/抖音游走策略/douyin_walk_strategy.v1.json")
  8. RULE_PACK_PATH = Path("product_documents/规则包/douyin_rule_packs.v1.json")
  9. # V3 清理: 13 段收窄到 3 个仍被运行时/校验消费的段——
  10. # walk_edge_catalog(walk_graph.json 边 ID 合法性校验)、walk_rule_pack_binding
  11. # (终端阶段归属包)、walk_fact_contract(runtime 文件契约校验);其余 10 段
  12. # (老预算/停止/重试/触发规则等)已被 walk_graph+walk_policy 取代,随段删除。
  13. REQUIRED_SECTIONS = [
  14. "walk_edge_catalog",
  15. "walk_rule_pack_binding",
  16. "v4_walk_gate",
  17. "walk_fact_contract",
  18. ]
  19. @dataclass(frozen=True)
  20. class WalkStrategyStore:
  21. root_dir: Path = Path(".")
  22. strategy_path: Path = WALK_STRATEGY_PATH
  23. rule_pack_path: Path = RULE_PACK_PATH
  24. def load_walk_strategy(self) -> dict[str, Any]:
  25. from content_agent.integrations import config_store
  26. path = self.root_dir / self.strategy_path
  27. strategy, _ = config_store.load_json(path)
  28. findings = validate_walk_strategy_config(
  29. strategy,
  30. root_dir=self.root_dir,
  31. strategy_path=self.strategy_path,
  32. rule_pack_path=self.rule_pack_path,
  33. )
  34. failures = [finding for finding in findings if finding["level"] == "fail"]
  35. if failures:
  36. raise ValueError(f"invalid walk strategy config: {failures}")
  37. return {
  38. **strategy,
  39. "walk_strategy_version": strategy.get("strategy_version"),
  40. "walk_strategy_source_ref": {
  41. "file": str(self.strategy_path),
  42. "strategy_id": strategy.get("strategy_id"),
  43. "walk_strategy_version": strategy.get("strategy_version"),
  44. "source_of_truth": strategy.get("source_of_truth"),
  45. },
  46. }
  47. def validate_walk_strategy_config(
  48. strategy: dict[str, Any],
  49. *,
  50. root_dir: Path = Path("."),
  51. strategy_path: Path = WALK_STRATEGY_PATH,
  52. rule_pack_path: Path = RULE_PACK_PATH,
  53. ) -> list[dict[str, Any]]:
  54. findings: list[dict[str, Any]] = []
  55. _check_identity(strategy, strategy_path, findings)
  56. _check_required_sections(strategy, findings)
  57. if any(finding["level"] == "fail" for finding in findings):
  58. return findings
  59. edge_ids = _ids(strategy["walk_edge_catalog"], "edge_id")
  60. _check_edge_refs(strategy, edge_ids, findings)
  61. _check_v4_walk_gate(strategy["v4_walk_gate"], edge_ids, findings)
  62. _check_fact_contract(strategy["walk_fact_contract"], findings)
  63. _check_rule_pack_bindings(
  64. strategy["walk_rule_pack_binding"],
  65. root_dir / rule_pack_path,
  66. findings,
  67. )
  68. return findings
  69. def _check_identity(
  70. strategy: dict[str, Any],
  71. strategy_path: Path,
  72. findings: list[dict[str, Any]],
  73. ) -> None:
  74. if strategy.get("strategy_id") != "douyin_walk_strategy_v1":
  75. _fail(findings, "strategy_id", "strategy_id must be douyin_walk_strategy_v1")
  76. if strategy.get("strategy_version") != "V1.0":
  77. _fail(findings, "strategy_version", "walk strategy config version must be V1.0")
  78. if strategy.get("source_of_truth") != str(strategy_path):
  79. _fail(findings, "source_of_truth", f"source_of_truth must be {strategy_path}")
  80. def _check_required_sections(strategy: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  81. for section in REQUIRED_SECTIONS:
  82. value = strategy.get(section)
  83. if not isinstance(value, list) or not value:
  84. _fail(findings, "section_missing", f"{section} must be a non-empty list")
  85. def _check_edge_refs(
  86. strategy: dict[str, Any],
  87. edge_ids: set[str],
  88. findings: list[dict[str, Any]],
  89. ) -> None:
  90. for section in ["walk_rule_pack_binding"]:
  91. for row in strategy.get(section, []):
  92. if row.get("edge_id") not in edge_ids:
  93. _fail(
  94. findings,
  95. "edge_ref",
  96. f"{section} references unknown edge_id: {row.get('edge_id')}",
  97. )
  98. def _check_fact_contract(
  99. contracts: list[dict[str, Any]], findings: list[dict[str, Any]]
  100. ) -> None:
  101. by_file = {contract.get("runtime_file"): contract for contract in contracts}
  102. walk_actions = by_file.get("walk_actions.jsonl")
  103. if not walk_actions:
  104. _fail(findings, "walk_actions_contract", "walk_actions.jsonl contract is required")
  105. else:
  106. unique_key = walk_actions.get("unique_key")
  107. if unique_key != ["run_id", "policy_run_id", "walk_action_id"]:
  108. _fail(findings, "walk_actions_unique_key", "walk_actions unique key is invalid")
  109. search_clues = by_file.get("search_clues.jsonl")
  110. if not search_clues:
  111. _fail(findings, "search_clues_contract", "search_clues.jsonl contract is required")
  112. elif search_clues.get("unique_key") != ["run_id", "policy_run_id", "clue_id"]:
  113. _fail(findings, "search_clues_unique_key", "search_clues unique key must use clue_id")
  114. def _check_v4_walk_gate(
  115. gates: list[dict[str, Any]],
  116. edge_ids: set[str],
  117. findings: list[dict[str, Any]],
  118. ) -> None:
  119. by_id = {gate.get("gate_id"): gate for gate in gates}
  120. gate = by_id.get("allow_walk_required")
  121. if not gate:
  122. _fail(findings, "v4_walk_gate_missing", "allow_walk_required gate is required")
  123. return
  124. if gate.get("requires_allow_walk") is not True:
  125. _fail(findings, "v4_walk_gate_requires_allow_walk", "allow_walk_required must require allow_walk")
  126. if gate.get("deny_reason_code") != "v4_allow_walk_denied":
  127. _fail(findings, "v4_walk_gate_deny_reason", "allow_walk_required deny reason is invalid")
  128. applies_to = gate.get("applies_to_edges")
  129. expected_edges = {"hashtag_to_query", "author_to_works"}
  130. if set(applies_to or []) != expected_edges:
  131. _fail(findings, "v4_walk_gate_edges", "allow_walk_required must cover M4 expansion edges")
  132. for edge_id in applies_to or []:
  133. if edge_id not in edge_ids:
  134. _fail(findings, "v4_walk_gate_edge_ref", f"allow_walk_required unknown edge_id: {edge_id}")
  135. raw_payload_fields = set(gate.get("raw_payload_fields") or [])
  136. expected_fields = {"decision_id", "allow_walk", "allow_walk_reason", "walk_gate_snapshot"}
  137. if not expected_fields <= raw_payload_fields:
  138. _fail(findings, "v4_walk_gate_raw_fields", "allow_walk_required raw payload fields incomplete")
  139. def _check_rule_pack_bindings(
  140. bindings: list[dict[str, Any]],
  141. rule_pack_path: Path,
  142. findings: list[dict[str, Any]],
  143. ) -> None:
  144. rule_package = json.loads(rule_pack_path.read_text(encoding="utf-8"))
  145. enabled_packs = {
  146. (pack.get("rule_pack_id"), pack.get("version"))
  147. for pack in rule_package.get("rule_packs", [])
  148. if pack.get("enabled")
  149. }
  150. for binding in bindings:
  151. key = (binding.get("rule_pack_id"), binding.get("rule_pack_version"))
  152. if key not in enabled_packs:
  153. _fail(
  154. findings,
  155. "rule_pack_binding",
  156. f"{binding.get('binding_id')} references missing enabled rule pack: {key}",
  157. )
  158. def _ids(rows: list[dict[str, Any]], field: str) -> set[str]:
  159. return {str(row[field]) for row in rows if row.get(field)}