|
|
@@ -1,6 +1,7 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import copy
|
|
|
+import json
|
|
|
import os
|
|
|
import time
|
|
|
from collections import Counter
|
|
|
@@ -102,8 +103,9 @@ class FlowLedgerService:
|
|
|
if cached is not None:
|
|
|
return cached
|
|
|
bundle = self._bundle(run_id, cache=not debug)
|
|
|
+ walk_out = _walk_out_counter(bundle)
|
|
|
rows = [
|
|
|
- self._ledger_row(query, bundle, debug=debug)
|
|
|
+ self._ledger_row(query, bundle, walk_out, debug=debug)
|
|
|
for query in bundle["queries"]
|
|
|
if _query_method(query) in FIRST_ROUND_QUERY_METHODS
|
|
|
]
|
|
|
@@ -183,6 +185,97 @@ class FlowLedgerService:
|
|
|
"data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
|
|
|
}
|
|
|
|
|
|
+ def run_walk(self, run_id: str, *, debug: bool = False) -> dict[str, Any]:
|
|
|
+ # 全 run 游走图:不按 query 过滤,富化全部 walk_actions(full=True 不采样带回视频),并给出游走预算总览。
|
|
|
+ bundle = self._bundle(run_id, cache=not debug)
|
|
|
+ actions = [self._walk_action(action, None, bundle, debug=debug, full=True) for action in bundle["walk_actions"]]
|
|
|
+ lanes: dict[str, list[dict[str, Any]]] = {key: [] for key in ["tag_query", "author_works", "path_stop"]}
|
|
|
+ for action in actions:
|
|
|
+ lanes[_walk_lane(action["edge_id"])].append(action)
|
|
|
+ extension_queries = [
|
|
|
+ self._extension_query(item, bundle, debug=debug)
|
|
|
+ for item in bundle["queries"]
|
|
|
+ if _query_method(item) in EXTENSION_QUERY_METHODS
|
|
|
+ ]
|
|
|
+ summary = _walk_budget_overview(bundle, actions, extension_queries)
|
|
|
+ # 全run游走图按所属搜索词分组用:首轮 query id → 文案。
|
|
|
+ summary["query_labels"] = {
|
|
|
+ _text(item.get("search_query_id")): _text(item.get("search_query"), "搜索词待确认")
|
|
|
+ for item in bundle["queries"]
|
|
|
+ if _query_method(item) in FIRST_ROUND_QUERY_METHODS and _text(item.get("search_query_id"))
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ "run_id": run_id,
|
|
|
+ "actions": actions,
|
|
|
+ "lanes": lanes,
|
|
|
+ "extension_queries": extension_queries,
|
|
|
+ "summary": summary,
|
|
|
+ "data_origin": self.data_origin,
|
|
|
+ "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
|
|
|
+ }
|
|
|
+
|
|
|
+ def video_walk(self, run_id: str, content_id: str, *, debug: bool = False) -> dict[str, Any]:
|
|
|
+ # 单视频游走子树:从该视频出发 BFS,只保留从它向外走出去的那一支(沿 source_video.id → target_videos.id 递归)。
|
|
|
+ bundle = self._bundle(run_id, cache=not debug)
|
|
|
+ root_content = next(
|
|
|
+ (
|
|
|
+ item
|
|
|
+ for item in bundle["content_items"]
|
|
|
+ if content_id in {_text(item.get("platform_content_id")), _text(item.get("content_discovery_id"))}
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ origin_label = "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存"
|
|
|
+ if not root_content:
|
|
|
+ return {
|
|
|
+ "run_id": run_id,
|
|
|
+ "root_video": None,
|
|
|
+ "actions": [],
|
|
|
+ "summary": {"total_actions": 0, "success_count": 0, "skipped_count": 0},
|
|
|
+ "data_origin": self.data_origin,
|
|
|
+ "data_origin_label": origin_label,
|
|
|
+ }
|
|
|
+ root_video = self._video(root_content, bundle, debug=debug)
|
|
|
+ enriched = [self._walk_action(action, None, bundle, debug=debug, full=True) for action in bundle["walk_actions"]]
|
|
|
+ by_source: dict[str, list[dict[str, Any]]] = {}
|
|
|
+ for item in enriched:
|
|
|
+ source_id = _text(_record(item.get("source_video")).get("id"))
|
|
|
+ if source_id:
|
|
|
+ by_source.setdefault(source_id, []).append(item)
|
|
|
+ root_id = _text(root_video.get("id"))
|
|
|
+ seen_videos = {root_id}
|
|
|
+ seen_actions: set[str] = set()
|
|
|
+ subtree: list[dict[str, Any]] = []
|
|
|
+ frontier = [root_id]
|
|
|
+ while frontier:
|
|
|
+ nxt: list[str] = []
|
|
|
+ for video_id in frontier:
|
|
|
+ for item in by_source.get(video_id, []):
|
|
|
+ action_id = _text(item.get("id")) or str(id(item))
|
|
|
+ if action_id in seen_actions:
|
|
|
+ continue
|
|
|
+ seen_actions.add(action_id)
|
|
|
+ subtree.append(item)
|
|
|
+ for target in item.get("target_videos") or []:
|
|
|
+ target_id = _text(_record(target).get("id"))
|
|
|
+ if target_id and target_id not in seen_videos:
|
|
|
+ seen_videos.add(target_id)
|
|
|
+ nxt.append(target_id)
|
|
|
+ frontier = nxt
|
|
|
+ statuses = Counter(_text(item.get("status"), "unknown") for item in subtree)
|
|
|
+ return {
|
|
|
+ "run_id": run_id,
|
|
|
+ "root_video": root_video,
|
|
|
+ "actions": subtree,
|
|
|
+ "summary": {
|
|
|
+ "total_actions": len(subtree),
|
|
|
+ "success_count": statuses["success"],
|
|
|
+ "skipped_count": statuses["skipped"],
|
|
|
+ },
|
|
|
+ "data_origin": self.data_origin,
|
|
|
+ "data_origin_label": origin_label,
|
|
|
+ }
|
|
|
+
|
|
|
def video_detail(self, run_id: str, content_id: str, *, debug: bool = False) -> dict[str, Any]:
|
|
|
bundle = self._bundle(run_id, cache=not debug)
|
|
|
content = next(
|
|
|
@@ -267,10 +360,13 @@ class FlowLedgerService:
|
|
|
def _query_by_id(self, bundle: dict[str, Any], query_id: str) -> dict[str, Any] | None:
|
|
|
return next((query for query in bundle["queries"] if _text(query.get("search_query_id")) == query_id), None)
|
|
|
|
|
|
- def _ledger_row(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ def _ledger_row(self, query: dict[str, Any], bundle: dict[str, Any], walk_out: dict[str, int], *, debug: bool) -> dict[str, Any]:
|
|
|
query_id = _text(query.get("search_query_id"))
|
|
|
contents = self._query_content(query_id, bundle, first_round_only=True)
|
|
|
videos = [self._video(content, bundle, debug=debug) for content in contents]
|
|
|
+ # 每条首轮视频是否真正向外走过(success 扩展边),供首页「看扩展过程」按钮判断显示。
|
|
|
+ for video in videos:
|
|
|
+ video["walk_out_count"] = walk_out.get(_text(video.get("platform_content_id")), 0)
|
|
|
decisions = [
|
|
|
decision
|
|
|
for decision in (_decision_for_content(content, bundle["decisions"]) for content in contents)
|
|
|
@@ -390,6 +486,7 @@ class FlowLedgerService:
|
|
|
"id": platform_id or _text(content.get("content_discovery_id"), "content"),
|
|
|
"content_discovery_id": _text(content.get("content_discovery_id")),
|
|
|
"platform_content_id": platform_id,
|
|
|
+ "search_query_id": _text(content.get("search_query_id")),
|
|
|
"title": _text(content.get("description") or content.get("title"), "无标题视频"),
|
|
|
"author": _text(content.get("author_display_name") or content.get("platform_author_id"), "未知作者"),
|
|
|
"platform": _text(content.get("platform"), "unknown"),
|
|
|
@@ -528,7 +625,7 @@ class FlowLedgerService:
|
|
|
"unavailable_count": media["unavailable"] + media["metadata_only"] + media["oss_upload_failed"],
|
|
|
}
|
|
|
|
|
|
- def _walk_action(self, action: dict[str, Any], query: dict[str, Any] | None, bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
|
|
|
+ def _walk_action(self, action: dict[str, Any], query: dict[str, Any] | None, bundle: dict[str, Any], *, debug: bool, full: bool = False) -> dict[str, Any]:
|
|
|
edge = _walk_lane_id(action)
|
|
|
reason = _text(action.get("reason_code"))
|
|
|
status = _text(action.get("walk_status"), "unknown")
|
|
|
@@ -538,7 +635,9 @@ class FlowLedgerService:
|
|
|
target_query = _target_query_for_action(action, bundle)
|
|
|
target_query_summary = self._query_summary(target_query, bundle, debug=debug) if target_query else None
|
|
|
target_contents = _target_contents_for_action(action, bundle, source_content, target_query)
|
|
|
- target_videos = [self._video(content, bundle, debug=debug) for content in target_contents[:4]]
|
|
|
+ # full=True(全run游走图 / 单视频子树)不再采样,展示全部带回视频;per-query 视图仍取前 4 条。
|
|
|
+ shown_contents = target_contents if full else target_contents[:4]
|
|
|
+ target_videos = [self._video(content, bundle, debug=debug) for content in shown_contents]
|
|
|
trigger_label = _walk_trigger_label(action, source_content, target_query_summary)
|
|
|
from_label = _walk_from_label(action, query, bundle, source_content)
|
|
|
score_items = _walk_score_items(action)
|
|
|
@@ -689,6 +788,71 @@ def _evidence_for_content(content: dict[str, Any], evidence_rows: list[dict[str,
|
|
|
)
|
|
|
|
|
|
|
|
|
+def _walk_out_counter(bundle: dict[str, Any]) -> dict[str, int]:
|
|
|
+ # 每个源视频 platform_content_id → 它成功向外游走(标签/作者)的次数;0 表示这条没真正走过。
|
|
|
+ counter: Counter[str] = Counter()
|
|
|
+ for action in bundle["walk_actions"]:
|
|
|
+ if _text(action.get("edge_id")) not in _WALK_EXPANSION_EDGES:
|
|
|
+ continue
|
|
|
+ if _text(action.get("walk_status")) != "success":
|
|
|
+ continue
|
|
|
+ source = _content_for_action(action, bundle)
|
|
|
+ platform_id = _text((source or {}).get("platform_content_id"))
|
|
|
+ if platform_id:
|
|
|
+ counter[platform_id] += 1
|
|
|
+ return dict(counter)
|
|
|
+
|
|
|
+
|
|
|
+def _load_walk_caps() -> dict[str, Any]:
|
|
|
+ # 游走预算上限(best-effort,只读 walk_policy.json;env 覆盖时数值可能与真实 run 略有出入)。
|
|
|
+ path = os.path.join("tech_documents", "数据接口与来源", "walk_policy.json")
|
|
|
+ try:
|
|
|
+ with open(path, encoding="utf-8") as handle:
|
|
|
+ data = json.load(handle)
|
|
|
+ except Exception:
|
|
|
+ return {}
|
|
|
+
|
|
|
+ def _value(node: Any) -> Any:
|
|
|
+ return node.get("value") if isinstance(node, dict) else node
|
|
|
+
|
|
|
+ global_caps = data.get("global", {}) if isinstance(data.get("global"), dict) else {}
|
|
|
+ edges = {
|
|
|
+ _text(edge.get("edge_id")): edge
|
|
|
+ for edge in data.get("edge_budgets", [])
|
|
|
+ if isinstance(edge, dict)
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ "max_depth": _value(global_caps.get("max_depth")),
|
|
|
+ "hashtag_to_query": (edges.get("hashtag_to_query") or {}).get("max_total_actions"),
|
|
|
+ "author_to_works": (edges.get("author_to_works") or {}).get("max_total_actions"),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _walk_budget_overview(
|
|
|
+ bundle: dict[str, Any],
|
|
|
+ actions: list[dict[str, Any]],
|
|
|
+ extension_queries: list[dict[str, Any]],
|
|
|
+) -> dict[str, Any]:
|
|
|
+ caps = _load_walk_caps()
|
|
|
+ statuses = Counter(_text(action.get("status"), "unknown") for action in actions)
|
|
|
+ tag_used = sum(1 for a in actions if a.get("edge_id") == "hashtag_to_query" and a.get("status") == "success")
|
|
|
+ author_used = sum(1 for a in actions if a.get("edge_id") == "author_to_works" and a.get("status") == "success")
|
|
|
+ walk_items = [c for c in bundle["content_items"] if not _is_first_round_content(c)]
|
|
|
+ return {
|
|
|
+ "total_actions": len(actions),
|
|
|
+ "success_count": statuses["success"],
|
|
|
+ "skipped_count": statuses["skipped"],
|
|
|
+ "extension_query_count": len(extension_queries),
|
|
|
+ "tag_used": tag_used,
|
|
|
+ "tag_cap": caps.get("hashtag_to_query"),
|
|
|
+ "author_used": author_used,
|
|
|
+ "author_cap": caps.get("author_to_works"),
|
|
|
+ "max_depth_reached": max((_number(a.get("depth")) or 0 for a in actions), default=0),
|
|
|
+ "max_depth_cap": caps.get("max_depth"),
|
|
|
+ "walk_video_total": len(walk_items),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
def _walk_action_related(action: dict[str, Any], query_id: str, related_ids: set[str]) -> bool:
|
|
|
values = {
|
|
|
_text(action.get("from_node_id")),
|