walk_engine.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970
  1. """有界游走(V3-M4):配置驱动的统一 frontier 流程,取代三段硬编码。
  2. 每条边走同一套闸:platform_profiles 是否 supported(blocked 显式 skip 不调用平台)
  3. → walk_policy.edge_permissions 按判定结果放行(取代 _can_expand_from_decision 硬编码)
  4. → walk_policy.edge_budgets 预算(取代 [:2]/[:3]/>=1 散落硬限;预算耗尽静默,对齐 v1 行为)。
  5. 终端边(commit/downgrade/stop)与 3 类血缘并入本模块终端阶段(原 plan_walk 节点已删)。
  6. """
  7. from __future__ import annotations
  8. import hashlib
  9. import time
  10. from datetime import datetime, timezone
  11. from typing import Any, Callable
  12. from content_agent.business_modules import walk_strategy as walk_terminal
  13. from content_agent.business_modules.progressive_screening import (
  14. SearchCallLimiter,
  15. _ProgressiveContext,
  16. make_search_limiter,
  17. )
  18. from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
  19. from content_agent.integrations.walk_graph_json import (
  20. WalkGraphStore,
  21. edge_budgets_for_platform,
  22. edge_permission,
  23. edge_supported,
  24. )
  25. from content_agent.integrations.walk_strategy_json import WalkStrategyStore
  26. from content_agent.interfaces import (
  27. GeminiVideoClient,
  28. PlatformSearchClient,
  29. RuntimeFileStore,
  30. )
  31. from content_agent.record_payload import with_raw_payload
  32. V4_SCORECARD_SCHEMA_VERSION = "v4_scorecard.v1"
  33. V4_ALLOW_WALK_DENIED_REASON = "v4_allow_walk_denied"
  34. V4_WALK_GATE_EDGES = {"hashtag_to_query", "author_to_works"}
  35. BLOCKED_TAG_TERMS = {
  36. "h" + "ot",
  37. "trend" + "ing",
  38. "热" + "点",
  39. "热" + "榜",
  40. "养" + "号",
  41. "无" + "限",
  42. }
  43. def run_bounded_walk(
  44. *,
  45. run_id: str,
  46. policy_run_id: str,
  47. pattern_seed_pack: dict[str, Any],
  48. source_context: dict[str, Any],
  49. search_queries: list[dict[str, Any]],
  50. discovered_content_items: list[dict[str, Any]],
  51. content_media_records: list[dict[str, Any]],
  52. evidence_bundles: list[dict[str, Any]],
  53. rule_decisions: list[dict[str, Any]],
  54. policy_bundle: dict[str, Any],
  55. platform_client: PlatformSearchClient,
  56. runtime: RuntimeFileStore,
  57. gemini_video_client: GeminiVideoClient,
  58. query_gate: Callable[[str], bool] | None = None,
  59. archive_dispatcher: Any | None = None,
  60. ) -> dict[str, list[dict[str, Any]]]:
  61. created_at = datetime.now(timezone.utc).isoformat()
  62. walk_strategy = WalkStrategyStore().load_walk_strategy()
  63. store = WalkGraphStore()
  64. policy = store.load_policy()
  65. max_depth = int(policy["global"]["max_depth"])
  66. platform = next(
  67. (item["platform"] for item in discovered_content_items if item.get("platform")),
  68. "douyin",
  69. )
  70. max_total_actions = int(policy["global"]["max_total_actions_per_run"])
  71. budgets = edge_budgets_for_platform(policy, platform)
  72. policy = {**policy, "edge_budgets_by_id": budgets}
  73. profile = store.load_profile(platform)
  74. content_pack = {
  75. "rule_pack_id": policy_bundle["rule_pack_id"],
  76. "rule_pack_version": policy_bundle["rule_pack_version"],
  77. }
  78. context = {
  79. "search_queries": list(search_queries),
  80. "discovered_content_items": list(discovered_content_items),
  81. "content_media_records": list(content_media_records),
  82. "evidence_bundles": list(evidence_bundles),
  83. "rule_decisions": list(rule_decisions),
  84. "walk_actions": [],
  85. }
  86. # 全 run content 去重集(首轮 + 跨层),与 walk_ctx 共享同一对象。
  87. seen_content_ids = {
  88. str(item["platform_content_id"])
  89. for item in discovered_content_items
  90. if item.get("platform_content_id")
  91. }
  92. # 游走限速器:real client 才挂;抖音 10-12s 随机,其他平台默认 30s。
  93. # 标签搜索经 walk_ctx._fetch_page 限速,作者作品在 _expand_authors 调 fetch 前限速,共享同一实例。
  94. limiter = (
  95. make_search_limiter(platform)
  96. if getattr(platform_client, "requires_progressive_search_rate_limit", False)
  97. else None
  98. )
  99. # M8C 统一搜索单元:游走自有 _ProgressiveContext(独立累积 + 注入首轮 seen + index 基线)。
  100. walk_ctx = _ProgressiveContext(
  101. run_id=run_id,
  102. policy_run_id=policy_run_id,
  103. source_context=source_context,
  104. policy_bundle=policy_bundle,
  105. platform_client=platform_client,
  106. runtime=runtime,
  107. gemini_video_client=gemini_video_client,
  108. limiter=limiter,
  109. portrait_limiter=None,
  110. archive_dispatcher=archive_dispatcher,
  111. external_seen_content_ids=seen_content_ids,
  112. recall_index_base=len(discovered_content_items),
  113. decision_index_base=len(rule_decisions),
  114. platform=platform,
  115. )
  116. fctx = _FrontierContext(
  117. max_depth=max_depth,
  118. max_total_actions=max_total_actions,
  119. tag_budget=budgets["hashtag_to_query"]["max_total_actions"],
  120. author_budget=budgets["author_to_works"]["max_total_actions"],
  121. seen_content_ids=seen_content_ids,
  122. )
  123. # 初始 frontier = 首轮全部(item, decision):允许的向外游走,被拒的留 skip 留痕。
  124. decision_by_content_id = _decision_by_content_id(context["rule_decisions"])
  125. frontier = [
  126. (item, decision_by_content_id[item["platform_content_id"]])
  127. for item in discovered_content_items
  128. if item.get("platform_content_id") in decision_by_content_id
  129. ]
  130. # M8D 多级 frontier 深游走:标签↔作者任意交替,depth/总闸/各边预算/三 seen 集收口。
  131. while frontier and fctx.depth < max_depth and fctx.global_left():
  132. fctx.depth += 1
  133. tag_back, tag_actions, tag_queries = _expand_hashtags(
  134. frontier, ctx=fctx, walk_ctx=walk_ctx, run_id=run_id, policy_run_id=policy_run_id,
  135. policy=policy, profile=profile, walk_strategy=walk_strategy,
  136. content_pack=content_pack, created_at=created_at, query_gate=query_gate,
  137. )
  138. author_back, author_actions, author_queries = _expand_authors(
  139. frontier, ctx=fctx, walk_ctx=walk_ctx, run_id=run_id, policy_run_id=policy_run_id,
  140. policy=policy, profile=profile, walk_strategy=walk_strategy,
  141. content_pack=content_pack, platform_client=platform_client,
  142. limiter=limiter, created_at=created_at,
  143. )
  144. context["walk_actions"].extend(tag_actions)
  145. context["walk_actions"].extend(author_actions)
  146. new_queries = tag_queries + author_queries
  147. if new_queries:
  148. runtime.append_jsonl(run_id, "search_queries.jsonl", new_queries)
  149. context["search_queries"].extend(new_queries)
  150. frontier = tag_back + author_back
  151. # 游走带回内容统一一次落盘(append 到首轮已写文件),并并入 context 供终端/记录。
  152. # write_runtime 会 drain 已完成的 OSS 归档 + enable 后续异步写;之后 close(shutdown wait=False),
  153. # 与首轮 progressive_screening.run 的生命周期一致——否则游走带回视频拿不到 OSS、Gemini 无源→全技术重试。
  154. walk_ctx.write_runtime()
  155. walk_ctx.close_archive_dispatcher()
  156. context["discovered_content_items"].extend(walk_ctx.discovered_content_items)
  157. context["content_media_records"].extend(walk_ctx.content_media_records)
  158. context["evidence_bundles"].extend(walk_ctx.evidence_bundles)
  159. context["rule_decisions"].extend(walk_ctx.rule_decisions)
  160. terminal = _terminal_stage(
  161. pattern_seed_pack,
  162. context["search_queries"],
  163. context["discovered_content_items"],
  164. context["rule_decisions"],
  165. walk_strategy,
  166. created_at,
  167. )
  168. context["walk_actions"].extend(terminal["walk_actions"])
  169. context["source_path_record_basis"] = terminal["source_path_record_basis"]
  170. return context
  171. class _FrontierContext:
  172. """游走 frontier 的全 run 级状态机(进程内,不落库)。
  173. 三个全 run 去重集 + 各边运行预算 + 总闸 + depth 贯穿所有层,任一闸单独都能收口、不成环。
  174. content_id 去重集与 walk 级 _ProgressiveContext 的 external_seen_content_ids 共享同一对象(单一真相);
  175. recall/decision id 与 index 由 walk_ctx 累积自动推进(消除手动推进事故源)。
  176. """
  177. def __init__(
  178. self,
  179. *,
  180. max_depth: int,
  181. max_total_actions: int,
  182. tag_budget: int,
  183. author_budget: int,
  184. seen_content_ids: set[str],
  185. ) -> None:
  186. self.max_depth = max_depth
  187. self.max_total_actions = max_total_actions
  188. self.remaining_budget = {
  189. "hashtag_to_query": tag_budget,
  190. "author_to_works": author_budget,
  191. }
  192. self.seen_content_ids = seen_content_ids
  193. self.seen_query_keys: set[str] = set()
  194. self.seen_author_ids: set[str] = set()
  195. self.global_walk_action_count = 0
  196. self.depth = 0
  197. def budget_left(self, edge_id: str) -> bool:
  198. return self.remaining_budget.get(edge_id, 0) > 0
  199. def global_left(self) -> bool:
  200. return self.global_walk_action_count < self.max_total_actions
  201. def consume(self, edge_id: str) -> None:
  202. # 成功向外游走一次(标签跳或作者跳):扣边预算 + 累计总闸。失败的跳不调用本方法、不扣预算。
  203. self.remaining_budget[edge_id] -= 1
  204. self.global_walk_action_count += 1
  205. def budget_snapshot(self) -> dict[str, int]:
  206. return dict(self.remaining_budget)
  207. def _frontier_pairs(batch: dict[str, list[dict[str, Any]]]) -> list[tuple[dict[str, Any], dict[str, Any]]]:
  208. """把统一搜索单元带回的新内容配成 (item, decision),供下一层 frontier。"""
  209. decision_by_id = {d["decision_target_id"]: d for d in batch["rule_decisions"]}
  210. pairs: list[tuple[dict[str, Any], dict[str, Any]]] = []
  211. for item in batch["discovered_content_items"]:
  212. decision = decision_by_id.get(item.get("platform_content_id"))
  213. if decision is not None:
  214. pairs.append((item, decision))
  215. return pairs
  216. def _expand_hashtags(
  217. frontier: list[tuple[dict[str, Any], dict[str, Any]]],
  218. *,
  219. ctx: _FrontierContext,
  220. walk_ctx: _ProgressiveContext,
  221. run_id: str,
  222. policy_run_id: str,
  223. policy: dict[str, Any],
  224. profile: dict[str, Any],
  225. walk_strategy: dict[str, Any],
  226. content_pack: dict[str, Any],
  227. created_at: str,
  228. query_gate: Callable[[str], bool] | None = None,
  229. ) -> tuple[
  230. list[tuple[dict[str, Any], dict[str, Any]]],
  231. list[dict[str, Any]],
  232. list[dict[str, Any]],
  233. ]:
  234. """单层标签扩展:对本层每个 allow_walk 视频的标签建 query,走统一搜索单元(前3→翻页≤3页)。
  235. 回灌链 video_to_hashtag → hashtag_to_query:被拒留 skip;预算/总闸/去重读 ctx 跨层运行扣减。
  236. 返回 (带回的 (item, decision) 列表, walk_actions, 新建 search_query 行)。
  237. """
  238. walk_actions: list[dict[str, Any]] = []
  239. brought_back: list[tuple[dict[str, Any], dict[str, Any]]] = []
  240. new_queries: list[dict[str, Any]] = []
  241. if not (edge_supported(profile, "video_to_hashtag") and edge_supported(profile, "hashtag_to_query")):
  242. return brought_back, walk_actions, new_queries
  243. tag_binding, _ = _resolve_edge_binding("hashtag_to_query", walk_strategy)
  244. for item, decision in frontier:
  245. if _edge_permission_for(decision, "video_to_hashtag", policy) == "deny":
  246. if item.get("tags"):
  247. reason_code = (
  248. V4_ALLOW_WALK_DENIED_REASON
  249. if _v4_walk_gate_denied(decision)
  250. else
  251. "review_tag_expansion_disabled"
  252. if decision.get("decision_action") == "KEEP_CONTENT_FOR_REVIEW"
  253. else "blocked_by_rule_decision"
  254. )
  255. walk_actions.append(
  256. _tag_skip_action(
  257. run_id, policy_run_id, item, decision, created_at,
  258. reason_code=reason_code, suffix="tags", depth=ctx.depth,
  259. tag_binding=tag_binding, content_pack=content_pack,
  260. raw_extra=_decision_context(
  261. decision, denied=reason_code == V4_ALLOW_WALK_DENIED_REASON
  262. ),
  263. )
  264. )
  265. continue
  266. for tag in item.get("tags") or []:
  267. normalized = str(tag).lstrip("#").strip()
  268. if not normalized or normalized in ctx.seen_query_keys or _blocked_tag(normalized):
  269. continue
  270. # M9D Gate 2:非抖音标签 query 复用 50+ 判定(query_gate 仅非抖音注入);判否不搜。
  271. if query_gate is not None and not query_gate(normalized):
  272. continue
  273. if not ctx.budget_left("hashtag_to_query"):
  274. # R8:合格标签排不上队,补一条 budget_exhausted skip(每内容至多一条)。
  275. walk_actions.append(
  276. _tag_skip_action(
  277. run_id, policy_run_id, item, decision, created_at,
  278. reason_code="budget_exhausted", suffix="tag_budget", depth=ctx.depth,
  279. tag_binding=tag_binding, content_pack=content_pack,
  280. raw_extra={**_decision_context(decision), "hashtag": normalized},
  281. )
  282. )
  283. break
  284. if not ctx.global_left():
  285. walk_actions.append(
  286. _tag_skip_action(
  287. run_id, policy_run_id, item, decision, created_at,
  288. reason_code="global_action_cap_reached", suffix="tag_global", depth=ctx.depth,
  289. tag_binding=tag_binding, content_pack=content_pack,
  290. raw_extra={**_decision_context(decision), "hashtag": normalized},
  291. )
  292. )
  293. break
  294. ctx.seen_query_keys.add(normalized)
  295. tag_query = _search_query_row(
  296. run_id, policy_run_id, f"tag_{_short_hash(normalized)}", normalized, "tag_query",
  297. item.get("discovery_start_source", "pattern_itemset"), "hashtag_to_query", created_at,
  298. raw_extra={
  299. "hashtag": normalized,
  300. "source_content_id": item.get("platform_content_id"),
  301. **_decision_context(decision),
  302. },
  303. )
  304. new_queries.append(tag_query)
  305. fail_before = len(walk_ctx.query_failures)
  306. batch = walk_ctx.process_query(tag_query)
  307. failed = len(walk_ctx.query_failures) > fail_before
  308. if not failed:
  309. ctx.consume("hashtag_to_query") # 失败的标签游走不扣预算
  310. walk_actions.append(
  311. _tag_query_action(
  312. tag_query, created_at, status="failed" if failed else "success",
  313. depth=ctx.depth, ctx=ctx, walk_strategy=walk_strategy, content_pack=content_pack,
  314. )
  315. )
  316. if not failed:
  317. brought_back.extend(_frontier_pairs(batch))
  318. return brought_back, walk_actions, new_queries
  319. def _tag_skip_action(
  320. run_id: str,
  321. policy_run_id: str,
  322. item: dict[str, Any],
  323. decision: dict[str, Any] | None,
  324. created_at: str,
  325. *,
  326. reason_code: str,
  327. suffix: str,
  328. depth: int,
  329. tag_binding: dict[str, Any],
  330. content_pack: dict[str, Any],
  331. raw_extra: dict[str, Any],
  332. ) -> dict[str, Any]:
  333. return _walk_action(
  334. run_id, policy_run_id,
  335. _walk_action_id(run_id, policy_run_id, "hashtag_to_query", item["platform_content_id"], suffix, depth=depth),
  336. "hashtag_to_query", "tag_query", "Content", item["platform_content_id"],
  337. "SearchQuery", "tag_query_skipped", "create_tag_query", "skipped", created_at,
  338. depth=depth, reason_code=reason_code, budget_tier="blocked",
  339. rule_pack_binding=tag_binding,
  340. rule_pack_execution=_execution_record(decision, content_pack_id=content_pack["rule_pack_id"]),
  341. fallback_rule_pack=content_pack, raw_extra=raw_extra,
  342. )
  343. def _tag_query_action(
  344. tag_query: dict[str, Any],
  345. created_at: str,
  346. *,
  347. status: str,
  348. depth: int,
  349. ctx: _FrontierContext,
  350. walk_strategy: dict[str, Any],
  351. content_pack: dict[str, Any],
  352. ) -> dict[str, Any]:
  353. binding, _ = _resolve_edge_binding("hashtag_to_query", walk_strategy)
  354. raw_extra = dict(_walk_gate_context_from_query_row(tag_query))
  355. raw_extra.update(_walk_runtime_extra(ctx))
  356. return _walk_action(
  357. tag_query["run_id"], tag_query["policy_run_id"],
  358. _walk_action_id(
  359. tag_query["run_id"], tag_query["policy_run_id"], "hashtag_to_query",
  360. tag_query["search_query_id"], "query", depth=depth,
  361. ),
  362. "hashtag_to_query", "query", "SearchQuery",
  363. tag_query.get("raw_payload", {}).get("hashtag") or tag_query["search_query_id"],
  364. "SearchQuery", tag_query["search_query_id"], "create_tag_query", status, created_at,
  365. depth=depth,
  366. rule_pack_binding=binding,
  367. rule_pack_execution={
  368. "executed": True,
  369. "executed_rule_pack_id": content_pack["rule_pack_id"],
  370. "reason": "content_decision_reused_for_walk_gate",
  371. },
  372. fallback_rule_pack=content_pack, raw_extra=raw_extra,
  373. )
  374. def _expand_authors(
  375. frontier: list[tuple[dict[str, Any], dict[str, Any]]],
  376. *,
  377. ctx: _FrontierContext,
  378. walk_ctx: _ProgressiveContext,
  379. run_id: str,
  380. policy_run_id: str,
  381. policy: dict[str, Any],
  382. profile: dict[str, Any],
  383. walk_strategy: dict[str, Any],
  384. content_pack: dict[str, Any],
  385. platform_client: PlatformSearchClient,
  386. limiter: SearchCallLimiter | None,
  387. created_at: str,
  388. ) -> tuple[
  389. list[tuple[dict[str, Any], dict[str, Any]]],
  390. list[dict[str, Any]],
  391. list[dict[str, Any]],
  392. ]:
  393. """单层作者扩展:对本层每个 allow_walk 视频的作者拉作品(列表+截断,不分页),复用判定路径。
  394. 本层按作者去重(首次出现代表),跨层经 ctx.seen_author_ids 去重;预算/总闸/content 去重读 ctx。
  395. 返回 (带回的 (item, decision) 列表, walk_actions, 新建 search_query 行)。
  396. """
  397. author_budgets = policy["edge_budgets_by_id"]["author_to_works"]
  398. binding, _ = _resolve_edge_binding("author_to_works", walk_strategy)
  399. walk_actions: list[dict[str, Any]] = []
  400. brought_back: list[tuple[dict[str, Any], dict[str, Any]]] = []
  401. new_queries: list[dict[str, Any]] = []
  402. unique_frontier = _unique_author_frontier(frontier)
  403. # 平台不支持作者边(如视频号 blogger blocked):本层每个唯一作者 skip 留痕、不调用平台。
  404. if not edge_supported(profile, "author_to_works"):
  405. for item, decision in unique_frontier:
  406. author_id = item.get("platform_author_id")
  407. if not author_id:
  408. continue
  409. walk_actions.append(
  410. _author_walk_action(
  411. run_id, policy_run_id, author_id, "skipped", created_at,
  412. depth=ctx.depth, reason_code="edge_blocked_by_platform_profile",
  413. budget_tier="blocked", binding=binding, decision=decision,
  414. content_pack=content_pack, raw_extra=_author_raw_extra(decision, ctx),
  415. )
  416. )
  417. return brought_back, walk_actions, new_queries
  418. fetch_author_works = getattr(platform_client, "fetch_author_works", None) or getattr(
  419. platform_client, "author_works", None
  420. )
  421. if not callable(fetch_author_works):
  422. return brought_back, walk_actions, new_queries
  423. for item, decision in unique_frontier:
  424. author_id = item.get("platform_author_id")
  425. if not author_id or author_id in ctx.seen_author_ids:
  426. continue
  427. permission = _edge_permission_for(decision, "author_to_works", policy)
  428. if permission == "deny":
  429. reason_code = (
  430. V4_ALLOW_WALK_DENIED_REASON
  431. if _v4_walk_gate_denied(decision)
  432. else "blocked_by_rule_decision"
  433. )
  434. walk_actions.append(
  435. _author_walk_action(
  436. run_id, policy_run_id, author_id, "skipped", created_at,
  437. depth=ctx.depth, reason_code=reason_code, budget_tier="blocked",
  438. binding=binding, decision=decision, content_pack=content_pack,
  439. raw_extra=_author_raw_extra(decision, ctx),
  440. )
  441. )
  442. continue
  443. if not ctx.budget_left("author_to_works"):
  444. walk_actions.append(
  445. _author_walk_action(
  446. run_id, policy_run_id, author_id, "skipped", created_at,
  447. depth=ctx.depth, reason_code="budget_exhausted", budget_tier="blocked",
  448. binding=binding, decision=decision, content_pack=content_pack,
  449. raw_extra=_author_raw_extra(decision, ctx),
  450. )
  451. )
  452. continue
  453. if not ctx.global_left():
  454. walk_actions.append(
  455. _author_walk_action(
  456. run_id, policy_run_id, author_id, "skipped", created_at,
  457. depth=ctx.depth, reason_code="global_action_cap_reached", budget_tier="blocked",
  458. binding=binding, decision=decision, content_pack=content_pack,
  459. raw_extra=_author_raw_extra(decision, ctx),
  460. )
  461. )
  462. break
  463. # 走作者跳:本层标记作者已走,扣预算 + 累计总闸。
  464. ctx.seen_author_ids.add(author_id)
  465. budget_tier = "low_budget" if permission == "allow_low_budget" else "normal"
  466. author_query_id = f"author_{_short_hash(author_id)}"
  467. author_fetch_started_at = time.monotonic()
  468. waited_seconds = limiter.wait() if limiter is not None else 0.0
  469. request_started_at = time.monotonic()
  470. try:
  471. works = fetch_author_works(
  472. {
  473. "platform_author_id": author_id,
  474. "search_query_id": author_query_id,
  475. "discovery_start_source": item.get("discovery_start_source", "pattern_itemset"),
  476. }
  477. )
  478. request_ended_at = time.monotonic()
  479. except Exception as exc:
  480. # 失败的作者游走不扣预算(作者已 seen,不会重试)
  481. walk_actions.append(
  482. _author_walk_action(
  483. run_id, policy_run_id, author_id, "failed", created_at,
  484. depth=ctx.depth, reason_code=type(exc).__name__, budget_tier=budget_tier,
  485. binding=binding, decision=decision, content_pack=content_pack,
  486. raw_extra=_author_raw_extra(decision, ctx),
  487. )
  488. )
  489. continue
  490. ctx.consume("author_to_works")
  491. walk_actions.append(
  492. _author_walk_action(
  493. run_id, policy_run_id, author_id, "success", created_at,
  494. depth=ctx.depth, budget_tier=budget_tier, binding=binding, decision=decision,
  495. content_pack=content_pack, raw_extra=_author_raw_extra(decision, ctx),
  496. )
  497. )
  498. # 作者近期作品天然可能含首轮/已发现的同一条视频;先按 ctx.seen_content_ids 去重再截断 max_works_per_author。
  499. # (不去重会撞 uk_ca_items_run_policy_content 唯一索引,真实 E2E v1_run_e6ba21f7543b 实证。)
  500. discovery_start_source = item.get("discovery_start_source", "pattern_itemset")
  501. fresh_works = [
  502. {
  503. **work,
  504. "search_query_id": work.get("search_query_id") or author_query_id,
  505. "discovery_start_source": work.get("discovery_start_source") or discovery_start_source,
  506. "previous_discovery_step": "author_works",
  507. }
  508. for work in works
  509. if str(work.get("platform_content_id") or "")
  510. and str(work.get("platform_content_id")) not in ctx.seen_content_ids
  511. ][: author_budgets["max_works_per_author"]]
  512. if not fresh_works:
  513. continue
  514. author_query = _search_query_row(
  515. run_id, policy_run_id, author_query_id, f"author:{author_id}", "author_works",
  516. discovery_start_source, "author_to_works", created_at,
  517. raw_extra={"platform_author_id": author_id},
  518. )
  519. batch = walk_ctx.process_prefetched_batch(
  520. author_query,
  521. fresh_works,
  522. search_duration_ms=_elapsed_ms(author_fetch_started_at, request_ended_at),
  523. search_wait_ms=int(waited_seconds * 1000),
  524. search_request_duration_ms=_elapsed_ms(request_started_at, request_ended_at),
  525. search_timing_source="author_works_fetch",
  526. )
  527. if batch["discovered_content_items"]:
  528. # 血缘补全:作者作品内容引用的合成 query id 必须真实存在于 search_queries,
  529. # 否则 validate_run 断链(真实 E2E v1_run_3a3bc9f0d72d 实证:missing_search_query_ref 等 8 条 fail)。
  530. new_queries.append(author_query)
  531. brought_back.extend(_frontier_pairs(batch))
  532. return brought_back, walk_actions, new_queries
  533. def _unique_author_frontier(
  534. frontier: list[tuple[dict[str, Any], dict[str, Any]]]
  535. ) -> list[tuple[dict[str, Any], dict[str, Any]]]:
  536. """本层按 platform_author_id 去重,保留首次出现的 (item, decision)。"""
  537. seen: set[str] = set()
  538. unique: list[tuple[dict[str, Any], dict[str, Any]]] = []
  539. for item, decision in frontier:
  540. author_id = item.get("platform_author_id")
  541. if not author_id or author_id in seen:
  542. continue
  543. seen.add(author_id)
  544. unique.append((item, decision))
  545. return unique
  546. def _author_raw_extra(decision: dict[str, Any] | None, ctx: _FrontierContext) -> dict[str, Any]:
  547. return {**_decision_context(decision), **_walk_runtime_extra(ctx)}
  548. def _walk_runtime_extra(ctx: _FrontierContext) -> dict[str, Any]:
  549. return {
  550. "walk_depth": ctx.depth,
  551. "frontier_round": ctx.depth,
  552. "remaining_budget_snapshot": ctx.budget_snapshot(),
  553. "global_walk_action_count": ctx.global_walk_action_count,
  554. }
  555. def _author_walk_action(
  556. run_id: str,
  557. policy_run_id: str,
  558. author_id: str,
  559. walk_status: str,
  560. created_at: str,
  561. *,
  562. depth: int = 1,
  563. budget_tier: str,
  564. binding: dict[str, Any],
  565. decision: dict[str, Any] | None,
  566. content_pack: dict[str, Any],
  567. reason_code: str | None = None,
  568. raw_extra: dict[str, Any] | None = None,
  569. ) -> dict[str, Any]:
  570. return _walk_action(
  571. run_id,
  572. policy_run_id,
  573. _walk_action_id(run_id, policy_run_id, "author_to_works", author_id, "works", depth=depth),
  574. "author_to_works",
  575. "author",
  576. "Author",
  577. author_id,
  578. "AuthorWorksPage",
  579. author_id,
  580. "fetch_author_works",
  581. walk_status,
  582. created_at,
  583. depth=depth,
  584. reason_code=reason_code,
  585. budget_tier=budget_tier,
  586. rule_pack_binding=binding,
  587. rule_pack_execution=_execution_record(decision, content_pack_id=content_pack["rule_pack_id"]),
  588. fallback_rule_pack=content_pack,
  589. raw_extra=raw_extra if raw_extra is not None else _decision_context(decision),
  590. )
  591. def _terminal_stage(
  592. pattern_seed_pack: dict[str, Any],
  593. search_queries: list[dict[str, Any]],
  594. discovered_content_items: list[dict[str, Any]],
  595. decisions: list[dict[str, Any]],
  596. walk_strategy: dict[str, Any],
  597. created_at: str,
  598. ) -> dict[str, list[dict[str, Any]]]:
  599. """终端边(decision_to_asset/budget_downgrade/path_stop)+ 3 类血缘(原 plan_walk 语义)。"""
  600. binding_by_edge = _binding_by_edge_id(walk_strategy)
  601. decision_by_target_id = {decision["decision_target_id"]: decision for decision in decisions}
  602. walk_actions: list[dict[str, Any]] = []
  603. source_path_record_basis: list[dict[str, Any]] = []
  604. for search_query in search_queries:
  605. query_source_ref = search_query.get("pattern_seed_ref") or {}
  606. source_path_record_basis.append(
  607. {
  608. "policy_run_id": search_query["policy_run_id"],
  609. "record_schema_version": search_query["record_schema_version"],
  610. "from_node_type": "PatternSeed",
  611. "from_node_id": pattern_seed_pack["pattern_execution_id"],
  612. "to_node_type": "SearchQuery",
  613. "to_node_id": search_query["search_query_id"],
  614. "source_path_type": "pattern_to_search_query",
  615. "rule_pack_id": None,
  616. "decision_id": None,
  617. "discovery_start_source": search_query["discovery_start_source"],
  618. "previous_discovery_step": search_query["previous_discovery_step"],
  619. "origin_path_id": f"pattern_to_search_query:{search_query['search_query_id']}",
  620. "source_evidence_ref": "source_context.json#ext_data.evidence_pack",
  621. "query_source_type": query_source_ref.get("query_source_type"),
  622. "query_source_ref_id": query_source_ref.get("query_source_ref_id"),
  623. "query_source_text": query_source_ref.get("query_source_text")
  624. or search_query.get("search_query"),
  625. "query_source_rank": query_source_ref.get("query_source_rank"),
  626. "query_source_refs": search_query.get("query_source_refs")
  627. or search_query.get("raw_payload", {}).get("query_source_refs", []),
  628. }
  629. )
  630. for item in discovered_content_items:
  631. # 无判定内容不产终端动作;判定覆盖完整性由 validate_run 把关,这里不掩盖。
  632. decision = decision_by_target_id.get(item["platform_content_id"])
  633. if not decision:
  634. continue
  635. decision_action = walk_terminal._action_for_decision(decision["decision_action"])
  636. binding = binding_by_edge.get(decision_action["edge_id"]) or {}
  637. execution = {
  638. "executed": True,
  639. "executed_rule_pack_id": decision["rule_pack_id"],
  640. "reason": "content_decision_reused_for_walk_gate",
  641. }
  642. walk_action_id = walk_terminal._walk_action_id(
  643. decision["run_id"],
  644. decision["policy_run_id"],
  645. decision_action["edge_id"],
  646. item["platform_content_id"],
  647. decision["decision_id"],
  648. )
  649. query_sources = item.get("query_sources") or [
  650. {
  651. "search_query_id": item["search_query_id"],
  652. "search_query": item.get("search_query"),
  653. "search_query_generation_method": item.get("search_query_generation_method"),
  654. }
  655. ]
  656. for query_source in query_sources:
  657. search_query_id = query_source["search_query_id"]
  658. source_path_record_basis.append(
  659. {
  660. "policy_run_id": decision["policy_run_id"],
  661. "record_schema_version": decision["record_schema_version"],
  662. "from_node_type": "SearchQuery",
  663. "from_node_id": search_query_id,
  664. "to_node_type": "Content",
  665. "to_node_id": item["platform_content_id"],
  666. "source_path_type": "search_query_to_content",
  667. "rule_pack_id": decision["rule_pack_id"],
  668. "decision_id": decision["decision_id"],
  669. "discovery_start_source": item["discovery_start_source"],
  670. "previous_discovery_step": item["previous_discovery_step"],
  671. "origin_path_id": (
  672. f"search_query_to_content:{search_query_id}:"
  673. f"{item['platform_content_id']}"
  674. ),
  675. "source_evidence_ref": decision["decision_input_snapshot_id"],
  676. "walk_action_id": walk_action_id,
  677. "rule_pack_binding": binding,
  678. "rule_pack_execution": execution,
  679. }
  680. )
  681. walk_actions.append(
  682. walk_terminal._walk_action_row(
  683. decision, item, decision_action, walk_action_id, created_at, binding, execution
  684. )
  685. )
  686. if decision["decision_action"] == "ADD_TO_CONTENT_POOL":
  687. source_path_record_basis.append(
  688. {
  689. "policy_run_id": decision["policy_run_id"],
  690. "record_schema_version": decision["record_schema_version"],
  691. "from_node_type": "RuleDecision",
  692. "from_node_id": decision["decision_id"],
  693. "to_node_type": "ContentAsset",
  694. "to_node_id": item["platform_content_id"],
  695. "source_path_type": "decision_to_asset",
  696. "rule_pack_id": decision["rule_pack_id"],
  697. "decision_id": decision["decision_id"],
  698. "discovery_start_source": item["discovery_start_source"],
  699. "previous_discovery_step": "asset_commit",
  700. "origin_path_id": (
  701. f"decision_to_asset:{decision['decision_id']}:"
  702. f"{item['platform_content_id']}"
  703. ),
  704. "source_evidence_ref": decision["decision_input_snapshot_id"],
  705. "walk_action_id": walk_action_id,
  706. "rule_pack_binding": binding,
  707. "rule_pack_execution": execution,
  708. }
  709. )
  710. return {"walk_actions": walk_actions, "source_path_record_basis": source_path_record_basis}
  711. def _search_query_row(
  712. run_id: str,
  713. policy_run_id: str,
  714. search_query_id: str,
  715. search_query: str,
  716. method: str,
  717. discovery_start_source: str,
  718. previous_discovery_step: str,
  719. created_at: str,
  720. *,
  721. page_cursor: str | None = None,
  722. raw_extra: dict[str, Any] | None = None,
  723. ) -> dict[str, Any]:
  724. row = {
  725. "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
  726. "run_id": run_id,
  727. "policy_run_id": policy_run_id,
  728. "search_query_id": search_query_id,
  729. "search_query": search_query,
  730. "search_query_generation_method": method,
  731. "discovery_start_source": discovery_start_source,
  732. "previous_discovery_step": previous_discovery_step,
  733. "pattern_seed_ref": {},
  734. "page_cursor": page_cursor,
  735. "created_at": created_at,
  736. **(raw_extra or {}),
  737. }
  738. return with_raw_payload(row)
  739. def _walk_action(
  740. run_id: str,
  741. policy_run_id: str,
  742. walk_action_id: str,
  743. edge_id: str,
  744. edge_type: str,
  745. from_node_type: str,
  746. from_node_id: str,
  747. to_node_type: str,
  748. to_node_id: str,
  749. walk_action: str,
  750. walk_status: str,
  751. created_at: str,
  752. *,
  753. depth: int = 1,
  754. page_cursor: str | None = None,
  755. reason_code: str | None = None,
  756. budget_tier: str | None = None,
  757. rule_pack_binding: dict[str, Any] | None = None,
  758. rule_pack_execution: dict[str, Any] | None = None,
  759. fallback_rule_pack: dict[str, Any] | None = None,
  760. raw_extra: dict[str, Any] | None = None,
  761. ) -> dict[str, Any]:
  762. row = with_raw_payload(
  763. {
  764. "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
  765. "run_id": run_id,
  766. "policy_run_id": policy_run_id,
  767. "walk_action_id": walk_action_id,
  768. "edge_id": edge_id,
  769. "edge_type": edge_type,
  770. "from_node_type": from_node_type,
  771. "from_node_id": from_node_id,
  772. "to_node_type": to_node_type,
  773. "to_node_id": to_node_id,
  774. "walk_action": walk_action,
  775. "walk_status": walk_status,
  776. "budget_tier": budget_tier or ("normal" if walk_status == "success" else "low_budget"),
  777. "depth": depth,
  778. "page_cursor": page_cursor,
  779. "reason_code": reason_code,
  780. "rule_pack_id": (rule_pack_binding or {}).get("rule_pack_id")
  781. or (fallback_rule_pack or {}).get("rule_pack_id"),
  782. "rule_pack_version": (rule_pack_binding or {}).get("rule_pack_version")
  783. or (fallback_rule_pack or {}).get("rule_pack_version"),
  784. "created_at": created_at,
  785. }
  786. )
  787. row["raw_payload"]["rule_pack_binding"] = rule_pack_binding or {}
  788. row["raw_payload"]["rule_pack_execution"] = rule_pack_execution or {}
  789. if raw_extra:
  790. row["raw_payload"].update(raw_extra)
  791. return row
  792. def _decision_by_content_id(rule_decisions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
  793. return {row["decision_target_id"]: row for row in rule_decisions}
  794. def _edge_permission_for(
  795. decision: dict[str, Any] | None, edge_id: str, policy: dict[str, Any]
  796. ) -> str:
  797. """判定→边通行证:无判定 / 查询 rule_blocked 一律 deny,其余查 edge_permissions。"""
  798. if not decision or decision.get("search_query_effect_status") == "rule_blocked":
  799. return "deny"
  800. if edge_id in V4_WALK_GATE_EDGES or edge_id == "video_to_hashtag":
  801. if _is_v4_decision(decision) and not _v4_allow_walk_allowed(decision):
  802. return "deny"
  803. return edge_permission(policy, decision.get("decision_action"), edge_id)
  804. def _is_v4_decision(decision: dict[str, Any] | None) -> bool:
  805. scorecard = (decision or {}).get("scorecard") or {}
  806. return isinstance(scorecard, dict) and scorecard.get("schema_version") == V4_SCORECARD_SCHEMA_VERSION
  807. def _v4_allow_walk_allowed(decision: dict[str, Any] | None) -> bool:
  808. if not _is_v4_decision(decision):
  809. return False
  810. replay_data = (decision or {}).get("decision_replay_data") or {}
  811. return replay_data.get("allow_walk") is True
  812. def _v4_walk_gate_denied(decision: dict[str, Any] | None) -> bool:
  813. return _is_v4_decision(decision) and not _v4_allow_walk_allowed(decision)
  814. def _binding_by_edge_id(walk_strategy: dict[str, Any]) -> dict[str, dict[str, Any]]:
  815. return {row["edge_id"]: row for row in walk_strategy.get("walk_rule_pack_binding", [])}
  816. def _resolve_edge_binding(
  817. edge_id: str, walk_strategy: dict[str, Any]
  818. ) -> tuple[dict[str, Any], str | None]:
  819. binding = _binding_by_edge_id(walk_strategy).get(edge_id)
  820. if not binding:
  821. return {}, "edge_binding_missing"
  822. return binding, None
  823. def _execution_record(decision: dict[str, Any] | None, *, content_pack_id: str) -> dict[str, Any]:
  824. if decision:
  825. return {
  826. "executed": True,
  827. "executed_rule_pack_id": decision.get("rule_pack_id") or content_pack_id,
  828. "reason": "content_decision_reused_for_walk_gate",
  829. }
  830. return {
  831. "executed": False,
  832. "executed_rule_pack_id": None,
  833. "reason": "future_pack_not_enabled",
  834. }
  835. def _decision_context(decision: dict[str, Any] | None, *, denied: bool = False) -> dict[str, Any]:
  836. if not decision:
  837. return {"decision_action": None, "search_query_effect_status": None}
  838. context = {
  839. "decision_id": decision.get("decision_id"),
  840. "decision_action": decision.get("decision_action"),
  841. "search_query_effect_status": decision.get("search_query_effect_status"),
  842. }
  843. if _is_v4_decision(decision):
  844. context.update(_v4_walk_gate_context(decision, denied=denied))
  845. return context
  846. def _v4_walk_gate_context(decision: dict[str, Any], *, denied: bool = False) -> dict[str, Any]:
  847. replay_data = decision.get("decision_replay_data") or {}
  848. allow_walk = replay_data.get("allow_walk")
  849. is_denied = denied or allow_walk is not True
  850. return {
  851. "allow_walk": allow_walk,
  852. "allow_walk_reason": replay_data.get("allow_walk_reason"),
  853. "walk_gate_snapshot": replay_data.get("walk_gate_snapshot"),
  854. "walk_gate_status": "denied" if is_denied else "allowed",
  855. "walk_gate_reason_code": V4_ALLOW_WALK_DENIED_REASON if is_denied else None,
  856. }
  857. def _walk_gate_context_from_query_row(row: dict[str, Any]) -> dict[str, Any]:
  858. payload = row.get("raw_payload") or {}
  859. fields = [
  860. "decision_id",
  861. "allow_walk",
  862. "allow_walk_reason",
  863. "walk_gate_snapshot",
  864. "walk_gate_status",
  865. "walk_gate_reason_code",
  866. ]
  867. return {field: payload[field] for field in fields if field in payload}
  868. def _blocked_tag(tag: str) -> bool:
  869. lowered = tag.lower()
  870. return any(term in lowered for term in BLOCKED_TAG_TERMS)
  871. def _elapsed_ms(start: float, end: float) -> int:
  872. return max(0, int((end - start) * 1000))
  873. def _walk_action_id(
  874. run_id: str,
  875. policy_run_id: str,
  876. edge_id: str,
  877. target_id: str,
  878. suffix: str,
  879. *,
  880. depth: int = 1,
  881. ) -> str:
  882. # M8D:并入 depth,防同一 (edge,target,suffix) 跨 frontier 层撞号。
  883. raw = f"{run_id}:{policy_run_id}:{edge_id}:{target_id}:{suffix}:depth_{depth}"
  884. return f"wa_{hashlib.sha1(raw.encode('utf-8')).hexdigest()[:16]}"
  885. def _short_hash(value: str) -> str:
  886. return hashlib.sha1(value.encode("utf-8")).hexdigest()[:10]