walk_graph_json.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. """V3 游走配置入口(M4A):读 walk_graph / walk_policy / platform_profiles 并校验。
  2. 镜像 walk_strategy_json.WalkStrategyStore 的 load+validate+raise 结构。
  3. walk_policy 的拍板值可能带 {value, provenance, tbd} 包裹(留痕),load_policy 解包成纯值。
  4. """
  5. from __future__ import annotations
  6. import os
  7. from dataclasses import dataclass
  8. from pathlib import Path
  9. from typing import Any
  10. from content_agent.findings import fail as _fail
  11. WALK_GRAPH_PATH = Path("tech_documents/数据接口与来源/walk_graph.json")
  12. WALK_POLICY_PATH = Path("tech_documents/数据接口与来源/walk_policy.json")
  13. PROFILE_DIR = Path("tech_documents/数据接口与来源/platform_profiles")
  14. EDGE_CATALOG_PATH = Path("tech_documents/数据接口与来源/walk_edge_catalog.json")
  15. PROFILE_EDGE_STATUSES = {"supported", "blocked", "no_interface", "planned"}
  16. PERMISSION_ACTIONS = [
  17. "ADD_TO_CONTENT_POOL",
  18. "KEEP_CONTENT_FOR_REVIEW",
  19. "TECHNICAL_RETRY_REQUIRED",
  20. "REJECT_CONTENT",
  21. ]
  22. _PERMISSION_META_KEYS = {"_meaning", "_provenance", "note", "tbd"}
  23. def _unwrap(value: Any) -> Any:
  24. if isinstance(value, dict) and "value" in value:
  25. return value["value"]
  26. return value
  27. def low_budget(budget: int) -> int:
  28. """low_budget 档:减半向下取整,至少 1(2026-06-11 拍板,policy 值 halve_min_1)。"""
  29. return max(1, budget // 2)
  30. def edge_permission(policy: dict[str, Any], decision_action: str | None, edge_id: str) -> str:
  31. """判定结果 → 该出边通行证;无判定/未列入的组合一律 deny(从严)。"""
  32. row = policy["edge_permissions"].get(decision_action or "", {})
  33. return row.get(edge_id, "deny")
  34. def edge_budgets_for_platform(policy: dict[str, Any], platform: str | None) -> dict[str, dict[str, Any]]:
  35. """Return edge budgets for a platform, falling back to default then legacy rows."""
  36. selected = dict(policy.get("edge_budgets_by_id") or {})
  37. by_platform = policy.get("platform_edge_budgets_by_platform_edge") or {}
  38. for edge_id in selected:
  39. explicit = by_platform.get((platform or "", edge_id))
  40. default = by_platform.get(("default", edge_id))
  41. if explicit:
  42. selected[edge_id] = {**selected[edge_id], **explicit}
  43. elif default:
  44. selected[edge_id] = {**selected[edge_id], **default}
  45. return selected
  46. def edge_supported(profile: dict[str, Any], edge_id: str) -> bool:
  47. """profile 未列出的边 = 平台无关控制边(终端边),恒 supported。"""
  48. spec = profile["edges"].get(edge_id, {"status": "supported"})
  49. return isinstance(spec, dict) and spec.get("status") == "supported"
  50. @dataclass(frozen=True)
  51. class WalkGraphStore:
  52. root_dir: Path = Path(".")
  53. def load_graph(self) -> dict[str, Any]:
  54. from content_agent.integrations import config_store
  55. graph, _ = config_store.load_json(self.root_dir / WALK_GRAPH_PATH)
  56. catalog, _ = config_store.load_json(self.root_dir / EDGE_CATALOG_PATH)
  57. catalog_ids = _catalog_edge_ids(catalog)
  58. _raise_on_fail(_validate_graph(graph, catalog_ids), "walk graph")
  59. return graph
  60. def load_policy(self) -> dict[str, Any]:
  61. from content_agent.integrations import config_store
  62. raw_policy, _ = config_store.load_json(self.root_dir / WALK_POLICY_PATH)
  63. policy = _unwrap_policy(raw_policy)
  64. edge_ids = {edge["edge_id"] for edge in self.load_graph()["edges"]}
  65. _raise_on_fail(_validate_policy(policy, edge_ids), "walk policy")
  66. return policy
  67. def load_profile(self, platform: str) -> dict[str, Any]:
  68. from content_agent.integrations import config_store
  69. profile, _ = config_store.load_json(self.root_dir / PROFILE_DIR / f"{platform}.json")
  70. edge_ids = {edge["edge_id"] for edge in self.load_graph()["edges"]}
  71. _raise_on_fail(_validate_profile(profile, edge_ids), f"platform profile {platform}")
  72. return profile
  73. def _unwrap_policy(raw: dict[str, Any]) -> dict[str, Any]:
  74. policy = dict(raw)
  75. policy["global"] = {key: _unwrap(value) for key, value in raw["global"].items()}
  76. _apply_global_env_overrides(policy["global"])
  77. policy["edge_permissions"] = {
  78. action: {edge: _unwrap(perm) for edge, perm in row.items() if edge not in _PERMISSION_META_KEYS}
  79. for action, row in raw["edge_permissions"].items()
  80. if action in PERMISSION_ACTIONS
  81. }
  82. policy["budget_tiers"] = {key: _unwrap(value) for key, value in raw["budget_tiers"].items()}
  83. policy["edge_budgets_by_id"] = {row["edge_id"]: row for row in raw["edge_budgets"]}
  84. policy["platform_edge_budgets_by_platform_edge"] = {
  85. (row.get("platform"), row.get("edge_id")): row
  86. for row in raw.get("platform_edge_budgets", [])
  87. if row.get("platform") and row.get("edge_id")
  88. }
  89. return policy
  90. def _apply_global_env_overrides(global_policy: dict[str, Any]) -> None:
  91. overrides = {
  92. "CONTENT_AGENT_WALK_MAX_TOTAL_ACTIONS_PER_RUN": "max_total_actions_per_run",
  93. "CONTENT_AGENT_WALK_MAX_DEPTH": "max_depth",
  94. "CONTENT_AGENT_GEMINI_MAX_WORKERS": "gemini_max_workers",
  95. }
  96. for env_key, policy_key in overrides.items():
  97. value = _optional_positive_int(os.environ.get(env_key))
  98. if value is not None:
  99. global_policy[policy_key] = value
  100. def _optional_positive_int(value: str | None) -> int | None:
  101. if value is None or value == "":
  102. return None
  103. try:
  104. parsed = int(value)
  105. except ValueError:
  106. return None
  107. return parsed if parsed > 0 else None
  108. def _validate_graph(graph: dict[str, Any], catalog_ids: set[str]) -> list[dict[str, Any]]:
  109. findings: list[dict[str, Any]] = []
  110. node_types = {node["node_type"] for node in graph.get("nodes", [])}
  111. graph_edge_ids = set()
  112. for edge in graph.get("edges", []):
  113. edge_id = edge.get("edge_id")
  114. graph_edge_ids.add(edge_id)
  115. if edge_id not in catalog_ids:
  116. _fail(findings, "edge_not_in_catalog", f"{edge_id} not in walk_edge_catalog")
  117. for field in ["from_node", "to_node"]:
  118. if edge.get(field) not in node_types:
  119. _fail(findings, "edge_node_ref", f"{edge_id} unknown {field}: {edge.get(field)}")
  120. for edge_id in sorted(catalog_ids - graph_edge_ids):
  121. _fail(findings, "catalog_edge_missing_from_graph", f"{edge_id} missing from walk_graph.edges")
  122. return findings
  123. def _validate_policy(policy: dict[str, Any], edge_ids: set[str]) -> list[dict[str, Any]]:
  124. findings: list[dict[str, Any]] = []
  125. for row in policy.get("edge_budgets", []):
  126. if row.get("edge_id") not in edge_ids:
  127. _fail(findings, "budget_edge_ref", f"edge_budgets unknown edge_id: {row.get('edge_id')}")
  128. platform_budget_keys = set()
  129. has_default_global = False
  130. has_kuaishou_author = False
  131. for row in policy.get("platform_edge_budgets_by_platform_edge", {}).values():
  132. key = (row.get("platform"), row.get("edge_id"))
  133. if key in platform_budget_keys:
  134. _fail(findings, "platform_budget_duplicate", f"duplicate platform budget: {key}")
  135. platform_budget_keys.add(key)
  136. if row.get("edge_id") != "__global__" and row.get("edge_id") not in edge_ids:
  137. _fail(findings, "platform_budget_edge_ref", f"platform budget unknown edge_id: {row.get('edge_id')}")
  138. has_default_global = has_default_global or key == ("default", "__global__")
  139. has_kuaishou_author = has_kuaishou_author or key == ("kuaishou", "author_to_works")
  140. if policy.get("platform_edge_budgets_by_platform_edge"):
  141. if not has_default_global:
  142. _fail(findings, "platform_budget_default_global", "platform_edge_budgets missing default/__global__")
  143. if not has_kuaishou_author:
  144. _fail(findings, "platform_budget_kuaishou_author", "platform_edge_budgets missing kuaishou/author_to_works")
  145. permission_rows = policy.get("edge_permissions", {})
  146. key_sets = []
  147. for action in PERMISSION_ACTIONS:
  148. if action not in permission_rows:
  149. _fail(findings, "permission_action_missing", f"edge_permissions missing action: {action}")
  150. continue
  151. keys = set(permission_rows[action])
  152. key_sets.append(keys)
  153. for edge_id in keys - edge_ids:
  154. _fail(findings, "permission_edge_ref", f"{action} unknown edge_id: {edge_id}")
  155. if key_sets and any(keys != key_sets[0] for keys in key_sets[1:]):
  156. _fail(findings, "permission_closure", "edge_permissions edge keys differ across actions")
  157. return findings
  158. def _validate_profile(profile: dict[str, Any], edge_ids: set[str]) -> list[dict[str, Any]]:
  159. findings: list[dict[str, Any]] = []
  160. edges = profile.get("edges")
  161. if not isinstance(edges, dict):
  162. _fail(findings, "profile_edges_invalid", "profile edges must be an object")
  163. return findings
  164. for edge_id, spec in edges.items():
  165. if edge_id not in edge_ids:
  166. _fail(findings, "profile_edge_ref", f"profile unknown edge_id: {edge_id}")
  167. if not isinstance(spec, dict):
  168. _fail(findings, "profile_edge_invalid", f"{edge_id} must be an object")
  169. continue
  170. if spec.get("status") not in PROFILE_EDGE_STATUSES:
  171. _fail(findings, "profile_edge_status", f"{edge_id} invalid status: {spec.get('status')}")
  172. return findings
  173. def _catalog_edge_ids(catalog: dict[str, Any]) -> set[str]:
  174. return {str(row["edge_id"]) for row in catalog.get("walk_edge_catalog", []) if row.get("edge_id")}
  175. def _raise_on_fail(findings: list[dict[str, Any]], label: str) -> None:
  176. failures = [finding for finding in findings if finding["level"] == "fail"]
  177. if failures:
  178. raise ValueError(f"invalid {label} config: {failures}")