walk_strategy_json.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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. EDGE_CATALOG_PATH = Path("tech_documents/数据接口与来源/walk_edge_catalog.json")
  10. # V5 M1: 边目录迁到平台无关 catalog;strategy 只保留运行时实际消费的绑定/门/事实契约。
  11. REQUIRED_SECTIONS = [
  12. "walk_rule_pack_binding",
  13. "v4_walk_gate",
  14. "walk_fact_contract",
  15. ]
  16. @dataclass(frozen=True)
  17. class WalkStrategyStore:
  18. root_dir: Path = Path(".")
  19. strategy_path: Path = WALK_STRATEGY_PATH
  20. rule_pack_path: Path = RULE_PACK_PATH
  21. def load_walk_strategy(self) -> dict[str, Any]:
  22. from content_agent.integrations import config_store
  23. path = self.root_dir / self.strategy_path
  24. strategy, _ = config_store.load_json(path)
  25. findings = validate_walk_strategy_config(
  26. strategy,
  27. root_dir=self.root_dir,
  28. strategy_path=self.strategy_path,
  29. rule_pack_path=self.rule_pack_path,
  30. edge_catalog_path=EDGE_CATALOG_PATH,
  31. )
  32. failures = [finding for finding in findings if finding["level"] == "fail"]
  33. if failures:
  34. raise ValueError(f"invalid walk strategy config: {failures}")
  35. return {
  36. **strategy,
  37. "walk_strategy_version": strategy.get("strategy_version"),
  38. "walk_strategy_source_ref": {
  39. "file": str(self.strategy_path),
  40. "strategy_id": strategy.get("strategy_id"),
  41. "walk_strategy_version": strategy.get("strategy_version"),
  42. "source_of_truth": strategy.get("source_of_truth"),
  43. },
  44. }
  45. def validate_walk_strategy_config(
  46. strategy: dict[str, Any],
  47. *,
  48. root_dir: Path = Path("."),
  49. strategy_path: Path = WALK_STRATEGY_PATH,
  50. rule_pack_path: Path = RULE_PACK_PATH,
  51. edge_catalog_path: Path = EDGE_CATALOG_PATH,
  52. ) -> list[dict[str, Any]]:
  53. findings: list[dict[str, Any]] = []
  54. _check_identity(strategy, strategy_path, findings)
  55. _check_required_sections(strategy, findings)
  56. if any(finding["level"] == "fail" for finding in findings):
  57. return findings
  58. edge_ids = _load_edge_catalog_ids(root_dir / edge_catalog_path, findings)
  59. if any(finding["level"] == "fail" for finding in findings):
  60. return findings
  61. _check_edge_refs(strategy, edge_ids, findings)
  62. _check_v4_walk_gate(strategy["v4_walk_gate"], edge_ids, findings)
  63. _check_fact_contract(strategy["walk_fact_contract"], findings)
  64. _check_rule_pack_bindings(
  65. strategy["walk_rule_pack_binding"],
  66. root_dir / rule_pack_path,
  67. findings,
  68. )
  69. return findings
  70. def _check_identity(
  71. strategy: dict[str, Any],
  72. strategy_path: Path,
  73. findings: list[dict[str, Any]],
  74. ) -> None:
  75. if not strategy.get("strategy_id"):
  76. _fail(findings, "strategy_id", "strategy_id must be non-empty")
  77. if strategy.get("strategy_version") != "V1.0":
  78. _fail(findings, "strategy_version", "walk strategy config version must be V1.0")
  79. if strategy.get("source_of_truth") != str(strategy_path):
  80. _fail(findings, "source_of_truth", f"source_of_truth must be {strategy_path}")
  81. def _check_required_sections(strategy: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  82. for section in REQUIRED_SECTIONS:
  83. value = strategy.get(section)
  84. if not isinstance(value, list) or not value:
  85. _fail(findings, "section_missing", f"{section} must be a non-empty list")
  86. def _check_edge_refs(
  87. strategy: dict[str, Any],
  88. edge_ids: set[str],
  89. findings: list[dict[str, Any]],
  90. ) -> None:
  91. for section in ["walk_rule_pack_binding"]:
  92. for row in strategy.get(section, []):
  93. if row.get("edge_id") not in edge_ids:
  94. _fail(
  95. findings,
  96. "edge_ref",
  97. f"{section} references unknown edge_id: {row.get('edge_id')}",
  98. )
  99. def _check_fact_contract(
  100. contracts: list[dict[str, Any]], findings: list[dict[str, Any]]
  101. ) -> None:
  102. by_file = {contract.get("runtime_file"): contract for contract in contracts}
  103. walk_actions = by_file.get("walk_actions.jsonl")
  104. if not walk_actions:
  105. _fail(findings, "walk_actions_contract", "walk_actions.jsonl contract is required")
  106. else:
  107. unique_key = walk_actions.get("unique_key")
  108. if unique_key != ["run_id", "policy_run_id", "walk_action_id"]:
  109. _fail(findings, "walk_actions_unique_key", "walk_actions unique key is invalid")
  110. search_clues = by_file.get("search_clues.jsonl")
  111. if not search_clues:
  112. _fail(findings, "search_clues_contract", "search_clues.jsonl contract is required")
  113. elif search_clues.get("unique_key") != ["run_id", "policy_run_id", "clue_id"]:
  114. _fail(findings, "search_clues_unique_key", "search_clues unique key must use clue_id")
  115. def _check_v4_walk_gate(
  116. gates: list[dict[str, Any]],
  117. edge_ids: set[str],
  118. findings: list[dict[str, Any]],
  119. ) -> None:
  120. by_id = {gate.get("gate_id"): gate for gate in gates}
  121. gate = by_id.get("allow_walk_required")
  122. if not gate:
  123. _fail(findings, "v4_walk_gate_missing", "allow_walk_required gate is required")
  124. return
  125. if gate.get("requires_allow_walk") is not True:
  126. _fail(findings, "v4_walk_gate_requires_allow_walk", "allow_walk_required must require allow_walk")
  127. if gate.get("deny_reason_code") != "v4_allow_walk_denied":
  128. _fail(findings, "v4_walk_gate_deny_reason", "allow_walk_required deny reason is invalid")
  129. applies_to = gate.get("applies_to_edges")
  130. expected_edges = {"hashtag_to_query", "author_to_works"}
  131. if set(applies_to or []) != expected_edges:
  132. _fail(findings, "v4_walk_gate_edges", "allow_walk_required must cover M4 expansion edges")
  133. for edge_id in applies_to or []:
  134. if edge_id not in edge_ids:
  135. _fail(findings, "v4_walk_gate_edge_ref", f"allow_walk_required unknown edge_id: {edge_id}")
  136. raw_payload_fields = set(gate.get("raw_payload_fields") or [])
  137. expected_fields = {"decision_id", "allow_walk", "allow_walk_reason", "walk_gate_snapshot"}
  138. if not expected_fields <= raw_payload_fields:
  139. _fail(findings, "v4_walk_gate_raw_fields", "allow_walk_required raw payload fields incomplete")
  140. def _check_rule_pack_bindings(
  141. bindings: list[dict[str, Any]],
  142. rule_pack_path: Path,
  143. findings: list[dict[str, Any]],
  144. ) -> None:
  145. rule_package = json.loads(rule_pack_path.read_text(encoding="utf-8"))
  146. enabled_packs = {
  147. (pack.get("rule_pack_id"), pack.get("version"))
  148. for pack in rule_package.get("rule_packs", [])
  149. if pack.get("enabled")
  150. }
  151. for binding in bindings:
  152. key = (binding.get("rule_pack_id"), binding.get("rule_pack_version"))
  153. if key not in enabled_packs:
  154. _fail(
  155. findings,
  156. "rule_pack_binding",
  157. f"{binding.get('binding_id')} references missing enabled rule pack: {key}",
  158. )
  159. def _ids(rows: list[dict[str, Any]], field: str) -> set[str]:
  160. return {str(row[field]) for row in rows if row.get(field)}
  161. def _load_edge_catalog_ids(path: Path, findings: list[dict[str, Any]]) -> set[str]:
  162. try:
  163. catalog = json.loads(path.read_text(encoding="utf-8"))
  164. except FileNotFoundError:
  165. _fail(findings, "edge_catalog_missing", f"missing edge catalog: {path}")
  166. return set()
  167. except json.JSONDecodeError as exc:
  168. _fail(findings, "edge_catalog_parse", f"edge catalog cannot parse: {exc}")
  169. return set()
  170. rows = catalog.get("walk_edge_catalog")
  171. if not isinstance(rows, list) or not rows:
  172. _fail(findings, "edge_catalog_invalid", "walk_edge_catalog must be a non-empty list")
  173. return set()
  174. return _ids(rows, "edge_id")