Jelajahi Sumber

feat(web): 游走展示重构 — 全run游走图 + 单视频子树(只读展示层)

首页「判断→游走」是死结构,游走展示重做为两个入口:
- 后端只读层:flow_ledger_service 加 run_walk(全 action + 游走预算总览:标签/作者已用vs上限、最深层、带回)
  + video_walk(从某视频 BFS 出的子树,沿 source_video→target_videos 递归);_walk_action 加 full 解除
  target_videos[:4] 采样;每条首轮视频加 walk_out_count;api.py 加两个只读端点。
- 前端:抽共享 WalkTree(buildForest 递归任意深度),删 per-query 游走页;
  新增 RunWalkPage(顶部预算总览 + 按搜索词分组)、VideoWalkPage(单视频根递归、无预算);
  首页表头加「全run游走图」按钮;「看扩展过程」改成 per-video、仅 walkOutCount>0 且非淘汰才显示。

本地 495 passed,web2 tsc 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sam Lee 3 minggu lalu
induk
melakukan
d9eddd5de1

+ 27 - 0
content_agent/api.py

@@ -18,6 +18,7 @@ from content_agent.schemas import (
     DashboardResponse,
     FlowLedgerResponse,
     FlowLedgerVideoResponse,
+    FlowLedgerVideoWalkResponse,
     FlowLedgerVideosResponse,
     FlowLedgerWalkResponse,
     JsonFileResponse,
@@ -256,6 +257,12 @@ def get_flow_ledger(run_id: str, debug: bool = False) -> FlowLedgerResponse:
     return FlowLedgerResponse(**_flow_ledger_service().ledger(run_id, debug=debug))
 
 
+@app.get("/runs/{run_id}/flow-ledger/walk", response_model=FlowLedgerWalkResponse)
+def get_flow_ledger_run_walk(run_id: str, debug: bool = False) -> FlowLedgerWalkResponse:
+    _ensure_web_run_exists(run_id)
+    return FlowLedgerWalkResponse(**_flow_ledger_service().run_walk(run_id, debug=debug))
+
+
 @app.get("/runs/{run_id}/flow-ledger/queries/{query_id}/videos", response_model=FlowLedgerVideosResponse)
 def get_flow_ledger_query_videos(
     run_id: str,
@@ -300,6 +307,26 @@ def get_flow_ledger_video(
     return FlowLedgerVideoResponse(**payload)
 
 
+@app.get("/runs/{run_id}/flow-ledger/videos/{content_id}/walk", response_model=FlowLedgerVideoWalkResponse)
+def get_flow_ledger_video_walk(
+    run_id: str,
+    content_id: str,
+    debug: bool = False,
+) -> FlowLedgerVideoWalkResponse:
+    _ensure_web_run_exists(run_id)
+    payload = _flow_ledger_service().video_walk(run_id, content_id, debug=debug)
+    if payload.get("root_video") is None:
+        raise HTTPException(
+            status_code=404,
+            detail=error_response(
+                ErrorCode.RUN_NOT_FOUND,
+                "video not found",
+                {"run_id": run_id, "content_id": content_id},
+            ),
+        )
+    return FlowLedgerVideoWalkResponse(**payload)
+
+
 @app.get("/runs/{run_id}/runtime-files", response_model=RuntimeFilesResponse)
 def get_run_runtime_files(run_id: str) -> RuntimeFilesResponse:
     _ensure_web_run_exists(run_id)

+ 168 - 4
content_agent/flow_ledger_service.py

@@ -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")),

+ 9 - 0
content_agent/schemas.py

@@ -224,3 +224,12 @@ class FlowLedgerVideoResponse(BaseModel):
     decision: dict[str, Any] | None = None
     data_origin: str
     data_origin_label: str
+
+
+class FlowLedgerVideoWalkResponse(BaseModel):
+    run_id: str
+    root_video: dict[str, Any] | None = None
+    actions: list[dict[str, Any]]
+    summary: dict[str, Any]
+    data_origin: str
+    data_origin_label: str

+ 25 - 0
tests/test_flow_ledger_api.py

@@ -518,6 +518,8 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     assert row["first_round_videos"][0]["decision"]["label"] == "入池"
     assert row["first_round_videos"][0]["media_status_label"] == "已保存"
     assert row["first_round_videos"][0]["platform_url"] == "https://www.douyin.com/video/douyin_001"
+    # 这条视频真正向外走过 1 次(walk_001 hashtag success),首页据此显示「看扩展过程」。
+    assert row["first_round_videos"][0]["walk_out_count"] == 1
     assert row["first_round_videos"][0]["decision"]["score_items"][1]["label"] == "是否契合需求"
     assert row["first_round_videos"][0]["decision"]["score_items"][3]["label"] == "点赞表现"
     piaoquan = next(item for item in ledger["rows"] if item["id"] == "q_003")
@@ -572,6 +574,29 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     assert walk["lanes"]["tag_query"][0]["score_items"][0]["score_label"] == "82 分"
     # M8:query_next_page 边已删,原翻页 lane 断言随之移除。
 
+    # M9+ 全 run 游走图:不按 query 过滤,附游走预算总览。
+    run_walk = client.get(f"/runs/{run_id}/flow-ledger/walk").json()
+    assert run_walk["summary"]["total_actions"] >= 1
+    assert run_walk["summary"]["tag_used"] == 1  # walk_001 hashtag success
+    assert run_walk["summary"]["tag_cap"] == 5
+    assert run_walk["summary"]["author_used"] == 0
+    assert run_walk["summary"]["max_depth_reached"] == 1
+    assert run_walk["summary"]["max_depth_cap"] == 5
+    assert run_walk["summary"]["walk_video_total"] == 1  # douyin_002 是游走带回
+    assert "q_001" in run_walk["summary"]["query_labels"]
+    assert any(a["source_video"]["title"] == "睡前 5 分钟肩颈拉伸" for a in run_walk["lanes"]["tag_query"])
+
+    # M9+ 单视频子树:从 douyin_001 出发,只保留它走出去的那一支并递归。
+    video_walk = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_001/walk").json()
+    assert video_walk["root_video"]["id"] == "douyin_001"
+    assert video_walk["summary"]["total_actions"] >= 1
+    assert video_walk["actions"][0]["edge_id"] == "hashtag_to_query"
+    assert video_walk["actions"][0]["target_videos"][0]["title"] == "扩展搜到的视频"
+    # 没向外走过的视频(douyin_003):子树为空。
+    empty_walk = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_003/walk").json()
+    assert empty_walk["root_video"]["id"] == "douyin_003"
+    assert empty_walk["summary"]["total_actions"] == 0
+
     detail = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_001").json()
     assert detail["video"]["playable_url"] == "https://oss.example.com/video.mp4"
     assert detail["decision"]["label"] == "入池"

+ 0 - 18
web2/app/runs/[runId]/queries/[queryId]/walk/page.tsx

@@ -1,18 +0,0 @@
-import { QueryWalkPage } from "@/features/QueryWalkPage";
-
-type Props = {
-  params: Promise<{ runId: string; queryId: string }>;
-};
-
-function safeDecode(value: string): string {
-  try {
-    return decodeURIComponent(value);
-  } catch {
-    return value;
-  }
-}
-
-export default async function QueryWalkRoute({ params }: Props) {
-  const { runId, queryId } = await params;
-  return <QueryWalkPage runId={safeDecode(runId)} queryId={safeDecode(queryId)} />;
-}

+ 18 - 0
web2/app/runs/[runId]/videos/[contentId]/walk/page.tsx

@@ -0,0 +1,18 @@
+import { VideoWalkPage } from "@/features/VideoWalkPage";
+
+type Props = {
+  params: Promise<{ runId: string; contentId: string }>;
+};
+
+function safeDecode(value: string): string {
+  try {
+    return decodeURIComponent(value);
+  } catch {
+    return value;
+  }
+}
+
+export default async function VideoWalkRoute({ params }: Props) {
+  const { runId, contentId } = await params;
+  return <VideoWalkPage runId={safeDecode(runId)} contentId={safeDecode(contentId)} />;
+}

+ 18 - 0
web2/app/runs/[runId]/walk/page.tsx

@@ -0,0 +1,18 @@
+import { RunWalkPage } from "@/features/RunWalkPage";
+
+type Props = {
+  params: Promise<{ runId: string }>;
+};
+
+function safeDecode(value: string): string {
+  try {
+    return decodeURIComponent(value);
+  } catch {
+    return value;
+  }
+}
+
+export default async function RunWalkRoute({ params }: Props) {
+  const { runId } = await params;
+  return <RunWalkPage runId={safeDecode(runId)} />;
+}

+ 16 - 6
web2/features/LedgerPage.tsx

@@ -2,7 +2,7 @@
 
 import { useState } from "react";
 import Link from "next/link";
-import { ChevronLeft, ChevronRight, GitBranch, Info } from "lucide-react";
+import { ChevronLeft, ChevronRight, GitBranch, Info, Network } from "lucide-react";
 import { AppShell } from "@/components/AppShell";
 import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
 import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
@@ -54,7 +54,15 @@ export function LedgerPage({ runId }: { runId: string }) {
                     <th className="group-source sticky-source">需求 -&gt; query</th>
                     <th className="group-videos">query -&gt; 渠道</th>
                     <th className="group-rules">渠道 -&gt; 判断</th>
-                    <th className="group-walk">判断 -&gt; 游走</th>
+                    <th className="group-walk">
+                      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
+                        <span>判断 -&gt; 游走</span>
+                        <Link className="mini-button" style={{ fontWeight: 400 }} href={`/runs/${encodeURIComponent(runId)}/walk`}>
+                          <Network size={13} />
+                          全run游走图
+                        </Link>
+                      </div>
+                    </th>
                     <th className="group-assets">最终收获</th>
                   </tr>
                 </thead>
@@ -219,7 +227,7 @@ function QueryGroupRows({
           {index === 0 ? <SourceCell row={row} rowSpan={rowSpan} /> : null}
           <VideoDetailCell runId={runId} video={video} />
           <VideoDecisionCell row={row} video={video} onOpenScore={onOpenScore} />
-          <VideoWalkCell runId={runId} row={row} video={video} />
+          <VideoWalkCell runId={runId} video={video} />
           {index === 0 ? <AssetCell row={row} rowSpan={rowSpan} /> : null}
         </tr>
       ))}
@@ -380,16 +388,18 @@ function scoreRuleDrawer(row: FlowLedgerRow, video?: VideoRef | null): BusinessD
   };
 }
 
-function VideoWalkCell({ runId, row, video }: { runId: string; row: FlowLedgerRow; video: VideoRef | null }) {
+function VideoWalkCell({ runId, video }: { runId: string; video: VideoRef | null }) {
   const allowWalk = Boolean(video?.decision?.allow_walk);
   const status = videoWalkStatus(video, allowWalk);
+  // 只有真正向外走过(success 扩展)且未被淘汰的视频,才给「看扩展过程」入口(单视频子树)。
+  const canExpand = Boolean(video) && video!.walkOutCount > 0 && video!.decisionAction !== "REJECT_CONTENT";
   return (
     <td className="walk-cell">
       <div className="cell-stack">
         <strong>{status.title}</strong>
         <span className="muted">{status.detail}</span>
-        {row.walkSummary.total ? (
-          <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(row.id)}/walk`}>
+        {canExpand ? (
+          <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video!.contentDiscoveryId || video!.id)}/walk`}>
             <GitBranch size={13} />
             看扩展过程
           </Link>

+ 98 - 0
web2/features/RunWalkPage.tsx

@@ -0,0 +1,98 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { AppShell } from "@/components/AppShell";
+import { ErrorState, LoadingState } from "@/components/StateBlocks";
+import { getRunWalk } from "@/lib/api/client";
+import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
+import { asRecord, text } from "@/lib/flow-ledger/format";
+import { WalkTree } from "@/features/walk/WalkTree";
+
+export function RunWalkPage({ runId }: { runId: string }) {
+  const [data, setData] = useState<FlowLedgerWalkResponse | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const debug = new URLSearchParams(window.location.search).get("debug") === "1";
+      setData(await getRunWalk(runId, debug));
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err));
+    } finally {
+      setLoading(false);
+    }
+  }, [runId]);
+
+  useEffect(() => {
+    void load();
+  }, [load]);
+
+  const summary = asRecord(data?.summary);
+  const actions = useMemo(
+    () => (data?.actions || []).filter((a): a is RawRecord => typeof a === "object" && a !== null),
+    [data?.actions]
+  );
+  const queryLabels = useMemo(() => {
+    const raw = asRecord(summary.query_labels);
+    const out: Record<string, string> = {};
+    for (const key of Object.keys(raw)) out[key] = text(raw[key], "");
+    return out;
+  }, [summary]);
+  const budget = useMemo(() => budgetStats(summary), [summary]);
+
+  return (
+    <AppShell
+      title="全 run 游走图"
+      subtitle="整条 run 从命中视频向外游走的全部路径"
+      runId={runId}
+      showBack
+      onRefresh={load}
+    >
+      {loading ? <LoadingState /> : null}
+      {error ? <ErrorState message={error} /> : null}
+      {data && !loading && !error ? (
+        <>
+          <header className="wt-overview">
+            <div className="wt-overview-head">
+              <span className="wt-root-chip">游走预算</span>
+              <strong>本 run 总览</strong>
+            </div>
+            <div className="wt-overview-stats">
+              {budget.map((stat) => (
+                <span className={`wt-stat ${stat.tone || ""}`} key={stat.label}>
+                  <b>{stat.value}</b>
+                  {stat.label}
+                </span>
+              ))}
+            </div>
+          </header>
+          <WalkTree
+            actions={actions}
+            runId={runId}
+            groupByQuery
+            queryLabels={queryLabels}
+            emptyLabel="本 run 没有任何向外游走"
+          />
+        </>
+      ) : null}
+    </AppShell>
+  );
+}
+
+function fmtUsed(used: unknown, cap: unknown): string {
+  const u = Number(used) || 0;
+  if (cap === null || cap === undefined || cap === "") return String(u);
+  return `${u} / ${Number(cap) || 0}`;
+}
+
+function budgetStats(summary: RawRecord): { label: string; value: string; tone?: string }[] {
+  return [
+    { label: "游走带回新视频", value: String(Number(summary.walk_video_total) || 0), tone: "go" },
+    { label: "标签→新搜索", value: fmtUsed(summary.tag_used, summary.tag_cap) },
+    { label: "作者→作品", value: fmtUsed(summary.author_used, summary.author_cap) },
+    { label: "最深层数", value: fmtUsed(summary.max_depth_reached, summary.max_depth_cap) }
+  ];
+}

+ 59 - 0
web2/features/VideoWalkPage.tsx

@@ -0,0 +1,59 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { AppShell } from "@/components/AppShell";
+import { ErrorState, LoadingState } from "@/components/StateBlocks";
+import { getVideoWalk } from "@/lib/api/client";
+import type { FlowLedgerVideoWalkResponse, RawRecord } from "@/lib/api/types";
+import { asRecord, text } from "@/lib/flow-ledger/format";
+import { WalkTree } from "@/features/walk/WalkTree";
+
+export function VideoWalkPage({ runId, contentId }: { runId: string; contentId: string }) {
+  const [data, setData] = useState<FlowLedgerVideoWalkResponse | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const debug = new URLSearchParams(window.location.search).get("debug") === "1";
+      setData(await getVideoWalk(runId, contentId, debug));
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err));
+    } finally {
+      setLoading(false);
+    }
+  }, [contentId, runId]);
+
+  useEffect(() => {
+    void load();
+  }, [load]);
+
+  const root = asRecord(data?.root_video);
+  const rootTitle = cleanTitle(text(root.title, "这条视频"));
+  const actions = useMemo(
+    () => (data?.actions || []).filter((a): a is RawRecord => typeof a === "object" && a !== null),
+    [data?.actions]
+  );
+
+  return (
+    <AppShell
+      title="这条视频的扩展过程"
+      subtitle={`从《${rootTitle}》开始向外游走`}
+      runId={runId}
+      showBack
+      onRefresh={load}
+    >
+      {loading ? <LoadingState /> : null}
+      {error ? <ErrorState message={error} /> : null}
+      {data && !loading && !error ? (
+        <WalkTree actions={actions} runId={runId} emptyLabel="这条视频没有继续向外游走" />
+      ) : null}
+    </AppShell>
+  );
+}
+
+function cleanTitle(value: string): string {
+  return value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim() || value;
+}

+ 94 - 98
web2/features/QueryWalkPage.tsx → web2/features/walk/WalkTree.tsx

@@ -3,21 +3,20 @@
 import Link from "next/link";
 import { ChevronDown, ChevronRight, ExternalLink, Hash, RotateCw, Search, UserSearch } from "lucide-react";
 import { useCallback, useEffect, useMemo, useState } from "react";
-import { AppShell } from "@/components/AppShell";
 import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
-import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
-import { getFlowLedgerWalk } from "@/lib/api/client";
-import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
-import { methodLabel, type BusinessTone } from "@/lib/flow-ledger/business";
+import { EmptyState } from "@/components/StateBlocks";
+import type { RawRecord } from "@/lib/api/types";
+import type { BusinessTone } from "@/lib/flow-ledger/business";
 import { asArray, asRecord, maybeText, text } from "@/lib/flow-ledger/format";
 
 // ---------------------------------------------------------------------------
 // tree model
 // ---------------------------------------------------------------------------
-type RouteNode = { key: string; action: RawRecord; videos: VideoNode[] };
-type VideoNode = {
+export type RouteNode = { key: string; action: RawRecord; videos: VideoNode[] };
+export type VideoNode = {
   key: string;
   id: string;
+  searchQueryId: string;
   video: RawRecord;
   title: string;
   author: string;
@@ -27,36 +26,31 @@ type VideoNode = {
   tags: string[];
   routes: RouteNode[];
 };
+export type Forest = { rootVideos: VideoNode[]; queryRoutes: RouteNode[] };
 
-type Forest = { rootVideos: VideoNode[]; queryRoutes: RouteNode[] };
+type QueryGroup = { id: string; label: string; videos: VideoNode[] };
 
-export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: string }) {
-  const [data, setData] = useState<FlowLedgerWalkResponse | null>(null);
-  const [loading, setLoading] = useState(true);
-  const [error, setError] = useState<string | null>(null);
+// ---------------------------------------------------------------------------
+// component
+// ---------------------------------------------------------------------------
+export function WalkTree({
+  actions,
+  runId,
+  groupByQuery = false,
+  queryLabels = {},
+  emptyLabel = "本轮没有继续向外扩展"
+}: {
+  actions: RawRecord[];
+  runId: string;
+  groupByQuery?: boolean;
+  queryLabels?: Record<string, string>;
+  emptyLabel?: string;
+}) {
   const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
   const [expanded, setExpanded] = useState<Set<string>>(new Set());
 
-  const load = useCallback(async () => {
-    setLoading(true);
-    setError(null);
-    try {
-      const debug = new URLSearchParams(window.location.search).get("debug") === "1";
-      setData(await getFlowLedgerWalk(runId, queryId, debug));
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err));
-    } finally {
-      setLoading(false);
-    }
-  }, [queryId, runId]);
-
-  useEffect(() => {
-    void load();
-  }, [load]);
-
-  const query = asRecord(data?.query);
-  const actions = useMemo(() => (data?.actions || []).filter(isRecord), [data?.actions]);
-  const forest = useMemo(() => buildForest(actions), [actions]);
+  const safeActions = useMemo(() => actions.filter(isRecord), [actions]);
+  const forest = useMemo(() => buildForest(safeActions), [safeActions]);
   const keyBuckets = useMemo(() => collectKeys(forest), [forest]);
 
   // default: show every video with its routes (the flow), brought-back videos collapsed.
@@ -82,47 +76,46 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
     [keyBuckets]
   );
 
-  const overview = useMemo(() => buildOverview(data?.summary, forest), [data?.summary, forest]);
   const hasTree = forest.rootVideos.length > 0 || forest.queryRoutes.length > 0;
+  const groups = useMemo(
+    () => (groupByQuery ? groupRoots(forest.rootVideos, queryLabels) : null),
+    [groupByQuery, forest.rootVideos, queryLabels]
+  );
+
+  if (!hasTree) return <EmptyState label={emptyLabel} />;
 
   return (
-    <AppShell
-      title="游走路线图"
-      subtitle={query.text ? `搜索词:${text(query.text)}` : "看系统从这个搜索词向外找了哪些内容"}
-      runId={runId}
-      showBack
-      onRefresh={load}
-    >
-      {loading ? <LoadingState /> : null}
-      {error ? <ErrorState message={error} /> : null}
-      {!loading && !error && !hasTree ? <EmptyState label="这个搜索词本轮没有继续向外扩展" /> : null}
-
-      {data && hasTree ? (
-        <section className="wt-page">
-          <header className="wt-overview">
-            <div className="wt-overview-head">
-              <span className="wt-root-chip">搜索词</span>
-              <strong>{text(query.text, "搜索词待确认")}</strong>
-              <small>{methodLabel(text(query.method, ""))}</small>
-            </div>
-            <div className="wt-overview-stats">
-              {overview.map((stat) => (
-                <span className={`wt-stat ${stat.tone || ""}`} key={stat.label}>
-                  <b>{stat.value}</b>
-                  {stat.label}
-                </span>
-              ))}
-            </div>
-            <div className="wt-controls">
-              <span className="wt-controls-label">展开</span>
-              <button type="button" onClick={() => setDepth("collapse")}>只看起点</button>
-              <button type="button" onClick={() => setDepth("routes")}>看到路线</button>
-              <button type="button" onClick={() => setDepth("all")}>全部展开</button>
-            </div>
-          </header>
-
-          <div className="wt-tree" role="tree">
-            {forest.rootVideos.map((node, index) => (
+    <section className="wt-page">
+      <div className="wt-controls">
+        <span className="wt-controls-label">展开</span>
+        <button type="button" onClick={() => setDepth("collapse")}>只看起点</button>
+        <button type="button" onClick={() => setDepth("routes")}>看到路线</button>
+        <button type="button" onClick={() => setDepth("all")}>全部展开</button>
+      </div>
+
+      <div className="wt-tree" role="tree">
+        {groups
+          ? groups.map((group) => (
+              <div className="wt-group" key={group.id || "ungrouped"}>
+                <div className="wt-overview-head">
+                  <span className="wt-root-chip">搜索词</span>
+                  <strong>{group.label}</strong>
+                </div>
+                {group.videos.map((node, index) => (
+                  <VideoRow
+                    node={node}
+                    depth={0}
+                    index={index}
+                    runId={runId}
+                    expanded={expanded}
+                    onToggle={toggle}
+                    onOpenDrawer={setDrawer}
+                    key={node.key}
+                  />
+                ))}
+              </div>
+            ))
+          : forest.rootVideos.map((node, index) => (
               <VideoRow
                 node={node}
                 depth={0}
@@ -134,22 +127,20 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
                 key={node.key}
               />
             ))}
-            {forest.queryRoutes.map((node) => (
-              <RouteRow
-                node={node}
-                depth={0}
-                runId={runId}
-                expanded={expanded}
-                onToggle={toggle}
-                onOpenDrawer={setDrawer}
-                key={node.key}
-              />
-            ))}
-          </div>
-          <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
-        </section>
-      ) : null}
-    </AppShell>
+        {forest.queryRoutes.map((node) => (
+          <RouteRow
+            node={node}
+            depth={0}
+            runId={runId}
+            expanded={expanded}
+            onToggle={toggle}
+            onOpenDrawer={setDrawer}
+            key={node.key}
+          />
+        ))}
+      </div>
+      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
+    </section>
   );
 }
 
@@ -293,7 +284,7 @@ function Toggle({ open, disabled, onClick }: { open: boolean; disabled: boolean;
   );
 }
 
-function RowLinks({ video, runId, }: { video: RawRecord; runId: string }) {
+function RowLinks({ video, runId }: { video: RawRecord; runId: string }) {
   const id = text(video.id, "");
   if (!id) return null;
   // platform content ids (视频号 finder ids) can contain / + = which break path routing;
@@ -319,7 +310,7 @@ function RowLinks({ video, runId, }: { video: RawRecord; runId: string }) {
 // ---------------------------------------------------------------------------
 // build
 // ---------------------------------------------------------------------------
-function buildForest(actions: RawRecord[]): Forest {
+export function buildForest(actions: RawRecord[]): Forest {
   const routable = actions.filter((a) => !isTerminalEdge(text(a.edge_id, "")));
   const routesBySource = new Map<string, RawRecord[]>();
   const sourceIds = new Set<string>();
@@ -371,6 +362,7 @@ function buildForest(actions: RawRecord[]): Forest {
     return {
       key,
       id,
+      searchQueryId: text(video.search_query_id, ""),
       video,
       title: cleanTitle(text(video.title, "无标题视频")),
       author: text(video.author, "未知作者"),
@@ -392,6 +384,20 @@ function buildForest(actions: RawRecord[]): Forest {
   return { rootVideos, queryRoutes };
 }
 
+function groupRoots(rootVideos: VideoNode[], queryLabels: Record<string, string>): QueryGroup[] {
+  const map = new Map<string, VideoNode[]>();
+  rootVideos.forEach((node) => {
+    const qid = node.searchQueryId || "";
+    if (!map.has(qid)) map.set(qid, []);
+    map.get(qid)!.push(node);
+  });
+  return [...map.entries()].map(([id, videos]) => ({
+    id,
+    label: queryLabels[id] || (id ? id : "其它来源"),
+    videos
+  }));
+}
+
 function collectKeys(forest: Forest): { videoKeys: Set<string>; routeKeys: Set<string>; allKeys: Set<string> } {
   const videoKeys = new Set<string>();
   const routeKeys = new Set<string>();
@@ -408,16 +414,6 @@ function collectKeys(forest: Forest): { videoKeys: Set<string>; routeKeys: Set<s
   return { videoKeys, routeKeys, allKeys: new Set([...videoKeys, ...routeKeys]) };
 }
 
-function buildOverview(summary: unknown, forest: Forest): { label: string; value: string; tone?: string }[] {
-  const s = asRecord(summary);
-  return [
-    { label: "起点视频", value: String(forest.rootVideos.length) },
-    { label: "已游走", value: String(Number(s.success_count || 0)), tone: "go" },
-    { label: "未游走", value: String(Number(s.skipped_count || 0)), tone: "stop" },
-    { label: "新增搜索词", value: String(Number(s.extension_query_count || 0)) }
-  ];
-}
-
 // ---------------------------------------------------------------------------
 // drawers
 // ---------------------------------------------------------------------------

+ 13 - 0
web2/lib/api/client.ts

@@ -3,6 +3,7 @@ import type {
   DashboardResponse,
   FlowLedgerApiResponse,
   FlowLedgerVideoResponse,
+  FlowLedgerVideoWalkResponse,
   FlowLedgerVideosResponse,
   FlowLedgerWalkResponse,
   QueryListResponse,
@@ -97,3 +98,15 @@ export function getFlowLedgerVideo(runId: string, contentId: string, debug = fal
     `/runs/${encodeURIComponent(runId)}/flow-ledger/videos/${encodeURIComponent(contentId)}${debugSuffix(debug)}`
   );
 }
+
+export function getRunWalk(runId: string, debug = false) {
+  return request<FlowLedgerWalkResponse>(
+    `/runs/${encodeURIComponent(runId)}/flow-ledger/walk${debugSuffix(debug)}`
+  );
+}
+
+export function getVideoWalk(runId: string, contentId: string, debug = false) {
+  return request<FlowLedgerVideoWalkResponse>(
+    `/runs/${encodeURIComponent(runId)}/flow-ledger/videos/${encodeURIComponent(contentId)}/walk${debugSuffix(debug)}`
+  );
+}

+ 9 - 0
web2/lib/api/types.ts

@@ -134,3 +134,12 @@ export type FlowLedgerVideoResponse = {
   data_origin: DataOrigin;
   data_origin_label: string;
 };
+
+export type FlowLedgerVideoWalkResponse = {
+  run_id: string;
+  root_video?: RawRecord | null;
+  actions: RawRecord[];
+  summary: RawRecord;
+  data_origin: DataOrigin;
+  data_origin_label: string;
+};

+ 1 - 0
web2/lib/flow-ledger/build.ts

@@ -78,6 +78,7 @@ function videoRef(row: RawRecord): VideoRef {
     decisionLabel: text(decision.label, "未进入判断"),
     decisionReason: text(decision.reason, "no_decision"),
     decisionReasonLabel: text(decision.reason_label, "暂未产生判断结果"),
+    walkOutCount: Number(row.walk_out_count || 0),
     score: scoreLabel(decision.score),
     scoreItems: scoreItems(decision.score_items),
     raw: row,

+ 1 - 0
web2/lib/flow-ledger/types.ts

@@ -54,6 +54,7 @@ export type VideoRef = {
   decisionLabel: string;
   decisionReason: string;
   decisionReasonLabel: string;
+  walkOutCount: number;
   score?: string;
   scoreItems: ScoreItem[];
   raw: RawRecord;