Преглед изворни кода

Refine web2 scoring and walk ledger views

Sam Lee пре 3 недеља
родитељ
комит
2aa4b336f9

+ 190 - 14
content_agent/flow_ledger_service.py

@@ -45,7 +45,7 @@ ACTION_LABELS = {
     "REJECT_CONTENT": "淘汰",
 }
 MEDIA_STATUS_LABELS = {
-    "oss_uploaded": "已保存到 OSS",
+    "oss_uploaded": "已保存",
     "oss_upload_pending": "待补传",
     "metadata_only": "尚未拿到视频",
     "unavailable": "无可用视频",
@@ -68,8 +68,11 @@ REASON_LABELS = {
     "missing_score": "没有足够信息进入打分",
     "v4_query_and_platform_pass": "搜索词和平台表现都符合要求",
     "v4_query_or_score_below_threshold": "相关性或平台表现未达到要求",
+    "v4_score_review_needed": "分数处在复看区间,需要人工确认",
     "v4_technical_retry_needed": "系统需要重试后再判断",
     "v4_allow_walk_denied": "不适合继续向外扩展",
+    "budget_exhausted": "本轮扩展名额已用完",
+    "query>=70/platform>=65/score>=70": "评分达到继续扩展门槛",
     "no_decision": "暂未产生判断结果",
 }
 
@@ -140,7 +143,7 @@ class FlowLedgerService:
         query = self._query_by_id(bundle, query_id)
         related_ids = self._query_related_content_ids(query_id, bundle)
         actions = [
-            self._walk_action(action, query, debug=debug)
+            self._walk_action(action, query, bundle, debug=debug)
             for action in bundle["walk_actions"]
             if _walk_action_related(action, query_id, related_ids)
         ]
@@ -254,8 +257,13 @@ class FlowLedgerService:
 
     def _ledger_row(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
         query_id = _text(query.get("search_query_id"))
-        videos = [self._video(content, bundle, debug=debug) for content in self._query_content(query_id, bundle, first_round_only=True)]
-        decisions = [video.get("decision") for video in videos if video.get("decision")]
+        contents = self._query_content(query_id, bundle, first_round_only=True)
+        videos = [self._video(content, bundle, debug=debug) for content in contents]
+        decisions = [
+            decision
+            for decision in (_decision_for_content(content, bundle["decisions"]) for content in contents)
+            if decision
+        ]
         related_ids = self._query_related_content_ids(query_id, bundle)
         actions = [action for action in bundle["walk_actions"] if _walk_action_related(action, query_id, related_ids)]
         source = self._source_summary(query, bundle, debug=debug)
@@ -387,6 +395,7 @@ class FlowLedgerService:
             "reason_label": REASON_LABELS.get(reason, "系统原因待补充"),
             "score": score,
             "score_label": f"{round(float(score))} 分" if isinstance(score, (int, float)) else "",
+            "score_items": _decision_score_items(scorecard, score),
             "allow_walk": bool(_record(decision.get("decision_replay_data")).get("allow_walk")),
             "v4_explanation": _record(decision.get("v4_explanation") or _record(decision.get("raw_payload")).get("v4_explanation")),
             "debug": {"decision": decision} if debug else None,
@@ -412,6 +421,7 @@ class FlowLedgerService:
             "reject_count": actions["REJECT_CONTENT"],
             "primary_reason": primary_reason,
             "primary_reason_label": REASON_LABELS.get(primary_reason, "系统原因待补充"),
+            "score_summary": _score_summary(decisions),
             "headline": _rule_headline(actions, len(decisions)),
             "debug": {"decisions": decisions} if debug else None,
         }
@@ -470,21 +480,29 @@ class FlowLedgerService:
             "unavailable_count": media["unavailable"] + media["metadata_only"],
         }
 
-    def _walk_action(self, action: dict[str, Any], query: dict[str, Any] | None, *, debug: bool) -> dict[str, Any]:
+    def _walk_action(self, action: dict[str, Any], query: dict[str, Any] | None, bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
         edge = _walk_lane_id(action)
         reason = _text(action.get("reason_code"))
         status = _text(action.get("walk_status"), "unknown")
+        gate_reason = _text(action.get("walk_gate_reason_code") or action.get("allow_walk_reason") or reason)
+        trigger_label = _walk_trigger_label(action)
+        from_label = _walk_from_label(action, query, bundle)
+        score_items = _walk_score_items(action)
         return {
             "id": _text(action.get("walk_action_id")),
             "edge_id": edge,
             "edge_label": WALK_EDGE_LABELS.get(edge, "系统原因待补充"),
+            "depth": _number(action.get("depth")),
+            "trigger_label": trigger_label,
             "status": status,
             "status_label": _walk_status_label(status),
-            "from_label": _walk_from_label(action, query),
+            "from_label": from_label,
             "result": "已带入后续流程" if status == "success" else "没有继续执行",
             "reason": reason,
-            "reason_label": REASON_LABELS.get(reason, "这一步没有记录额外原因") if reason else "这一步没有记录额外原因",
-            "impact": _walk_impact(status, reason),
+            "reason_label": _walk_reason_label(action, reason),
+            "impact": _walk_impact(status, reason, gate_reason),
+            "score_items": score_items,
+            "detail_lines": _walk_detail_lines(action, edge, from_label, trigger_label, score_items),
             "debug": {"action": action} if debug else None,
         }
 
@@ -658,20 +676,26 @@ def _walk_status_label(status: str) -> str:
     }.get(status, "状态待确认")
 
 
-def _walk_from_label(action: dict[str, Any], query: dict[str, Any] | None) -> str:
+def _walk_from_label(action: dict[str, Any], query: dict[str, Any] | None, bundle: dict[str, Any]) -> str:
     from_type = _text(action.get("from_node_type"))
+    from_node_id = _text(action.get("from_node_id"))
     if "query" in from_type:
-        return f"从搜索词“{_text((query or {}).get('search_query'), '搜索词待确认')}”出发"
+        source_query = next((item for item in bundle["queries"] if _text(item.get("search_query_id")) == from_node_id), None) or query
+        return f"从搜索词“{_text((source_query or {}).get('search_query'), '搜索词待确认')}”触发"
     if "content" in from_type or "video" in from_type:
-        return "从视频内容出发"
+        content = next((item for item in bundle["content_items"] if from_node_id in _content_ids(item)), None)
+        title = _text((content or {}).get("description") or (content or {}).get("title"))
+        return f"从视频“{_short_text(title, 34)}”触发" if title else "从视频内容触发"
     if "tag" in from_type:
-        return "从视频标签出发"
+        return "从视频标签发"
     return "从上一环节出发"
 
 
-def _walk_impact(status: str, reason: str) -> str:
-    if reason == "blocked_by_rule_decision":
+def _walk_impact(status: str, reason: str, gate_reason: str) -> str:
+    if reason == "blocked_by_rule_decision" or gate_reason == "v4_allow_walk_denied":
         return "这条内容不会继续带出更多视频。"
+    if reason == "budget_exhausted":
+        return "本轮扩展名额已用完,这一步不会继续新增搜索词。"
     if status == "success":
         return "这一步已经执行,结果会进入后续判断。"
     if status == "skipped":
@@ -679,6 +703,158 @@ def _walk_impact(status: str, reason: str) -> str:
     return "这一步暂不影响最终沉淀。"
 
 
+def _walk_trigger_label(action: dict[str, Any]) -> str:
+    raw = _record(action.get("raw_payload"))
+    hashtag = _text(action.get("hashtag") or raw.get("hashtag"))
+    if hashtag:
+        return f"标签:#{hashtag.lstrip('#')}"
+    author = _text(action.get("author_id") or raw.get("author_id") or action.get("platform_author_id") or raw.get("platform_author_id"))
+    if author:
+        return f"作者:{author}"
+    cursor = _text(action.get("page_cursor") or raw.get("page_cursor") or action.get("cursor") or raw.get("cursor"))
+    if cursor:
+        return f"翻页游标:{cursor}"
+    to_node = _text(action.get("to_node_id"))
+    if to_node and to_node not in {"tag_query_skipped", "author_works_skipped"}:
+        return f"产生结果:{to_node}"
+    return "触发对象待确认"
+
+
+def _walk_reason_label(action: dict[str, Any], reason: str) -> str:
+    gate_reason = _text(action.get("walk_gate_reason_code") or action.get("allow_walk_reason"))
+    if gate_reason == "v4_allow_walk_denied":
+        allow_reason = _text(action.get("allow_walk_reason"))
+        if allow_reason and allow_reason != gate_reason:
+            return f"不适合继续向外扩展:{REASON_LABELS.get(allow_reason, '评分未达到继续扩展门槛')}"
+        return "不适合继续向外扩展"
+    if reason:
+        return REASON_LABELS.get(reason, "这一步没有记录额外原因")
+    return "这一步没有记录额外原因"
+
+
+def _walk_detail_lines(
+    action: dict[str, Any],
+    edge: str,
+    from_label: str,
+    trigger_label: str,
+    score_items: list[dict[str, Any]],
+) -> list[str]:
+    lines = [
+        f"动作:{WALK_EDGE_LABELS.get(edge, '扩展动作')}",
+        f"上一环节:{from_label}",
+    ]
+    if trigger_label != "触发对象待确认":
+        lines.append(f"触发对象:{trigger_label}")
+    if score_items:
+        lines.append("扩展门槛:" + " / ".join(f"{item['label']} {item['score_label']}" for item in score_items[:3]))
+    reason = _walk_reason_label(action, _text(action.get("reason_code")))
+    if reason != "这一步没有记录额外原因":
+        lines.append(f"判断:{reason}")
+    return lines
+
+
+def _walk_score_items(action: dict[str, Any]) -> list[dict[str, Any]]:
+    snapshot = _record(action.get("walk_gate_snapshot") or _record(action.get("raw_payload")).get("walk_gate_snapshot"))
+    return _score_items_from_values(
+        total=snapshot.get("score"),
+        query=snapshot.get("query_relevance_score"),
+        platform=snapshot.get("platform_performance_score"),
+        components=[],
+    )
+
+
+def _decision_score_items(scorecard: dict[str, Any], score: Any) -> list[dict[str, Any]]:
+    return _score_items_from_values(
+        total=score if score is not None else scorecard.get("total_score"),
+        query=scorecard.get("query_relevance_score"),
+        platform=scorecard.get("platform_performance_score"),
+        components=_list(scorecard.get("platform_performance_components")),
+    )
+
+
+def _score_summary(decisions: list[dict[str, Any]]) -> dict[str, Any]:
+    scorecards = [_record(item.get("scorecard")) for item in decisions]
+    return {
+        "items": [
+            _avg_score_item("综合分", [item.get("score") if item.get("score") is not None else _record(item.get("scorecard")).get("total_score") for item in decisions]),
+            _avg_score_item("需求相关", [item.get("query_relevance_score") for item in scorecards]),
+            _avg_score_item("平台表现", [item.get("platform_performance_score") for item in scorecards]),
+        ],
+    }
+
+
+def _score_items_from_values(total: Any, query: Any, platform: Any, components: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    items = [
+        _score_item("综合分", total, "最终用于入池 / 复看 / 淘汰的总分"),
+        _score_item("需求相关", query, "视频内容和本次搜索词、需求的匹配程度"),
+        _score_item("平台表现", platform, "点赞、评论、转发、收藏等互动表现"),
+    ]
+    for component in components:
+        items.append(_score_item(
+            _platform_component_label(_text(component.get("field"))),
+            component.get("normalized_score"),
+            f"原始值 {_text(component.get('value'), '0')},权重 {_percent_text(component.get('weight'))}",
+        ))
+    return [item for item in items if item]
+
+
+def _score_item(label: str, value: Any, detail: str) -> dict[str, Any]:
+    score = _float_or_none(value)
+    if score is None:
+        return {}
+    return {
+        "label": label,
+        "score": round(score, 2),
+        "score_label": f"{round(score, 1):g} 分",
+        "detail": detail,
+    }
+
+
+def _avg_score_item(label: str, values: list[Any]) -> dict[str, Any]:
+    nums = [value for value in (_float_or_none(item) for item in values) if value is not None]
+    if not nums:
+        return {"label": label, "score": None, "score_label": "暂无评分"}
+    avg = sum(nums) / len(nums)
+    return {
+        "label": label,
+        "score": round(avg, 2),
+        "score_label": f"平均 {round(avg, 1):g} 分",
+    }
+
+
+def _platform_component_label(field: str) -> str:
+    return {
+        "statistics.digg_count": "点赞表现",
+        "statistics.like_count": "点赞表现",
+        "statistics.comment_count": "评论表现",
+        "statistics.share_count": "转发表现",
+        "statistics.collect_count": "收藏表现",
+        "statistics.play_count": "播放表现",
+    }.get(field, field or "互动表现")
+
+
+def _percent_text(value: Any) -> str:
+    number = _float_or_none(value)
+    if number is None:
+        return "待确认"
+    return f"{round(number * 100):g}%"
+
+
+def _float_or_none(value: Any) -> float | None:
+    try:
+        if value is None or value == "":
+            return None
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _short_text(value: str, limit: int) -> str:
+    if len(value) <= limit:
+        return value
+    return value[: limit - 1] + "…"
+
+
 def _platform_label(platform: str) -> str:
     return {
         "douyin": "抖音",

+ 32 - 3
tests/test_flow_ledger_api.py

@@ -189,8 +189,23 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "decision_action": "ADD_TO_CONTENT_POOL",
                 "decision_reason_code": "v4_query_and_platform_pass",
                 "score": 82,
-                "scorecard": {"total_score": 82},
-                "decision_replay_data": {"allow_walk": True},
+                "scorecard": {
+                    "total_score": 82,
+                    "query_relevance_score": 90,
+                    "platform_performance_score": 74,
+                    "platform_performance_components": [
+                        {"field": "statistics.digg_count", "value": 100, "weight": 0.4, "normalized_score": 80},
+                        {"field": "statistics.comment_count", "value": 8, "weight": 0.2, "normalized_score": 70},
+                    ],
+                },
+                "decision_replay_data": {
+                    "allow_walk": True,
+                    "walk_gate_snapshot": {
+                        "score": 82,
+                        "query_relevance_score": 90,
+                        "platform_performance_score": 74,
+                    },
+                },
                 "raw_payload": {},
             }
         ],
@@ -204,11 +219,18 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "policy_run_id": policy_run_id,
                 "walk_action_id": "walk_001",
                 "edge_id": "hashtag_to_query",
+                "hashtag": "拉伸",
+                "depth": 1,
                 "from_node_type": "content",
                 "from_node_id": "douyin_001",
                 "to_node_id": "q_002",
                 "walk_status": "success",
                 "reason_code": "v4_query_and_platform_pass",
+                "walk_gate_snapshot": {
+                    "score": 82,
+                    "query_relevance_score": 90,
+                    "platform_performance_score": 74,
+                },
                 "raw_payload": {},
             }
         ],
@@ -239,8 +261,12 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     assert row["first_round_video_total"] == 1
     assert row["video_stats"]["oss_uploaded_count"] == 1
     assert row["rule_summary"]["pool_count"] == 1
+    assert row["rule_summary"]["score_summary"]["items"][0]["score_label"] == "平均 82 分"
+    assert row["rule_summary"]["score_summary"]["items"][1]["score_label"] == "平均 90 分"
     assert row["first_round_videos"][0]["decision"]["label"] == "入池"
-    assert row["first_round_videos"][0]["media_status_label"] == "已保存到 OSS"
+    assert row["first_round_videos"][0]["media_status_label"] == "已保存"
+    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")
     assert "帖子 ID:post_888" in piaoquan["source"]["details"]
     assert "内容点 ID:point_456" in piaoquan["source"]["details"]
@@ -256,6 +282,9 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     walk = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/walk").json()
     assert walk["summary"]["total_actions"] == 1
     assert walk["lanes"]["tag_query"][0]["edge_label"] == "根据标签继续搜"
+    assert walk["lanes"]["tag_query"][0]["trigger_label"] == "标签:#拉伸"
+    assert "上一环节:从视频“睡前 5 分钟肩颈拉伸”触发" in walk["lanes"]["tag_query"][0]["detail_lines"]
+    assert walk["lanes"]["tag_query"][0]["score_items"][0]["score_label"] == "82 分"
 
     detail = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_001").json()
     assert detail["video"]["playable_url"] == "https://oss.example.com/video.mp4"

+ 167 - 0
web2/app/globals.css

@@ -771,6 +771,173 @@ a:focus-visible {
   padding: 10px;
 }
 
+.score-list {
+  display: grid;
+  gap: 5px;
+}
+
+.score-list.roomy {
+  gap: 8px;
+  margin-top: 10px;
+}
+
+.score-row {
+  display: grid;
+  grid-template-columns: minmax(72px, 1fr) auto;
+  gap: 6px;
+  align-items: start;
+  padding: 6px 7px;
+  border: 1px solid #dbeafe;
+  border-radius: 4px;
+  background: #f8fbff;
+}
+
+.score-row span {
+  color: #334155;
+  font-size: 11.5px;
+}
+
+.score-row b {
+  color: #1e40af;
+  font-size: 12px;
+  font-variant-numeric: tabular-nums;
+}
+
+.score-row small {
+  grid-column: 1 / -1;
+  color: var(--muted);
+  line-height: 1.45;
+}
+
+.score-mini {
+  max-width: 220px;
+  color: #334155;
+  font-size: 11.5px;
+  line-height: 1.55;
+}
+
+.walk-tree-wrap {
+  margin: 12px 20px;
+  padding: 12px;
+  border: 1px solid #dbeafe;
+  border-radius: 6px;
+  background: #f8fbff;
+}
+
+.walk-tree-root {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+  padding: 8px 10px;
+  border: 1px solid #cbd5e1;
+  border-radius: 4px;
+  background: #fff;
+}
+
+.walk-tree-root strong {
+  color: #0f172a;
+}
+
+.walk-tree-root small {
+  color: var(--muted);
+}
+
+.walk-tree {
+  display: grid;
+  gap: 8px;
+  margin-top: 10px;
+}
+
+.walk-tree-node {
+  --depth: 0;
+  position: relative;
+  display: grid;
+  grid-template-columns: 18px minmax(0, 1fr);
+  margin-left: calc(var(--depth) * 28px);
+}
+
+.walk-node-spine {
+  position: relative;
+  min-height: 100%;
+}
+
+.walk-node-spine::before {
+  content: "";
+  position: absolute;
+  top: -8px;
+  bottom: -8px;
+  left: 8px;
+  width: 2px;
+  background: #bfdbfe;
+}
+
+.walk-node-spine::after {
+  content: "";
+  position: absolute;
+  top: 18px;
+  left: 4px;
+  width: 10px;
+  height: 10px;
+  border: 2px solid #2563eb;
+  border-radius: 999px;
+  background: #fff;
+}
+
+.walk-tree-node.skipped .walk-node-spine::after {
+  border-color: #d97706;
+}
+
+.walk-node-card {
+  display: grid;
+  gap: 7px;
+  padding: 9px 10px;
+  border: 1px solid #bfdbfe;
+  border-radius: 5px;
+  background: #fff;
+}
+
+.walk-tree-node.skipped .walk-node-card {
+  border-color: #fde68a;
+  background: #fffbeb;
+}
+
+.walk-node-title,
+.walk-node-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 7px;
+  align-items: center;
+}
+
+.walk-node-title strong {
+  color: #0f172a;
+}
+
+.walk-node-meta span {
+  color: #475569;
+  font-size: 11.5px;
+}
+
+.walk-node-card ul {
+  display: grid;
+  gap: 3px;
+  margin: 0;
+  padding-left: 16px;
+  color: #334155;
+}
+
+.walk-node-card li,
+.walk-node-card p {
+  overflow-wrap: anywhere;
+  line-height: 1.5;
+}
+
+.walk-node-card p {
+  margin: 0;
+  color: var(--muted);
+}
+
 .video-detail-grid {
   display: grid;
   grid-template-columns: repeat(2, minmax(280px, 1fr));

+ 22 - 21
web2/features/LedgerPage.tsx

@@ -1,22 +1,19 @@
 "use client";
 
 import Link from "next/link";
-import { Eye, GitBranch, ListVideo } from "lucide-react";
+import { GitBranch, ListVideo } from "lucide-react";
 import { useMemo, useState } from "react";
 import { AppShell } from "@/components/AppShell";
-import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
 import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
 import {
   actionLabel,
   assetSummaryText,
   clueText,
-  mediaStatusLabel,
   methodLabel,
   platformLabel,
-  ruleBrief,
-  rowDecisionSections,
   ruleHeadline,
-  rulePrimaryReason,
+  scoreItemRows,
+  scoreItemsText,
   sourceEvidence,
   sourceLabel,
   walkSummaryText
@@ -38,7 +35,6 @@ const GROUPS: Array<{ id: GroupId; label: string; tone: string }> = [
 export function LedgerPage({ runId }: { runId: string }) {
   const { ledger, input, loading, error, reload } = useFlowLedger(runId);
   const [hidden, setHidden] = useState<Set<GroupId>>(new Set());
-  const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
 
   const rows = ledger?.rows || [];
   const tableClass = [
@@ -89,7 +85,7 @@ export function LedgerPage({ runId }: { runId: string }) {
                 </button>
               </span>
             ))}
-            <span className="legend-hint">点击组名显示/隐藏 · 左侧两列固定 · 判断原因在右侧说明里查看</span>
+            <span className="legend-hint">点击组名显示/隐藏 · 左侧两列固定 · 评分详情按 V4 分项展示</span>
           </div>
 
           <section className="table-wrap">
@@ -108,7 +104,7 @@ export function LedgerPage({ runId }: { runId: string }) {
                   {!hidden.has("query") ? <th className="sub-query sticky-query">搜什么 / 怎么来</th> : null}
                   {!hidden.has("videos") ? <th className="sub-videos">预览 / 统计 / 入口</th> : null}
                   {!hidden.has("rules") ? <th className="sub-rules">判断结果</th> : null}
-                  {!hidden.has("rules") ? <th className="sub-rules">原因说明</th> : null}
+                  {!hidden.has("rules") ? <th className="sub-rules">评分详情</th> : null}
                   {!hidden.has("walk") ? <th className="sub-walk">有没有继续找</th> : null}
                   {!hidden.has("assets") ? <th className="sub-assets">留下了什么</th> : null}
                 </tr>
@@ -122,13 +118,7 @@ export function LedgerPage({ runId }: { runId: string }) {
                     {!hidden.has("rules") ? <RuleSummaryCell row={row} /> : null}
                     {!hidden.has("rules") ? (
                       <td className="rules-cell">
-                        <div className="cell-stack">
-                          <span className="muted strong-muted">{ruleBrief(row)}</span>
-                          <button className="mini-button" type="button" onClick={() => setDrawer({ title: "为什么这样判断", sections: rowDecisionSections(row) })}>
-                            <Eye size={13} />
-                            看判断原因
-                          </button>
-                        </div>
+                        <ScoreDetailCell row={row} />
                       </td>
                     ) : null}
                     {!hidden.has("walk") ? <WalkCell runId={runId} row={row} /> : null}
@@ -140,7 +130,6 @@ export function LedgerPage({ runId }: { runId: string }) {
           </section>
         </>
       ) : null}
-      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
     </AppShell>
   );
 }
@@ -193,8 +182,6 @@ function VideoCell({ runId, row }: { runId: string; row: FlowLedgerRow }) {
         <span>入池 {row.ruleSummary.passCount}</span>
         <span>待复看 {row.ruleSummary.reviewCount}</span>
         <span>淘汰 {row.ruleSummary.rejectCount}</span>
-        <span>{mediaStatusLabel("oss_uploaded")} {row.videoStats.oss_uploaded_count || 0}</span>
-        {row.videoStats.oss_pending_count ? <span>{mediaStatusLabel("oss_upload_pending")} {row.videoStats.oss_pending_count}</span> : null}
         {row.duplicateCount ? <span>已合并重复 {row.duplicateCount}</span> : null}
       </div>
       <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(row.id)}/videos`}>
@@ -211,7 +198,7 @@ function VideoMini({ runId, video }: { runId: string; video: VideoRef }) {
       <span className="video-thumb">{platformLabel(video.platform)}</span>
       <span>
         <b>{video.title}</b>
-        <small>{actionLabel(video.decisionAction)} · {video.mediaStatusLabel}</small>
+        <small>{actionLabel(video.decisionAction)} · {scoreItemsText(video.scoreItems)}</small>
       </span>
     </Link>
   );
@@ -227,12 +214,26 @@ function RuleSummaryCell({ row }: { row: FlowLedgerRow }) {
           <span className="chip yellow">待复看 {row.ruleSummary.reviewCount}</span>
           <span className="chip">淘汰 {row.ruleSummary.rejectCount}</span>
         </div>
-        <span className="muted">主要原因:{rulePrimaryReason(row)}</span>
       </div>
     </td>
   );
 }
 
+function ScoreDetailCell({ row }: { row: FlowLedgerRow }) {
+  const items = scoreItemRows(row.ruleSummary.scoreItems);
+  return (
+    <div className="score-list">
+      {items.map((item) => (
+        <div className="score-row" key={item.label}>
+          <span>{item.label}</span>
+          <b>{item.scoreLabel}</b>
+        </div>
+      ))}
+      {!items.length ? <span className="muted">暂无评分</span> : null}
+    </div>
+  );
+}
+
 function WalkCell({ runId, row }: { runId: string; row: FlowLedgerRow }) {
   return (
     <td className="walk-cell">

+ 10 - 17
web2/features/QueryVideosPage.tsx

@@ -1,15 +1,14 @@
 "use client";
 
 import Link from "next/link";
-import { ExternalLink, PanelRightOpen } from "lucide-react";
+import { PanelRightOpen } 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 { getFlowLedgerVideos } from "@/lib/api/client";
 import type { FlowLedgerVideosResponse } from "@/lib/api/types";
 import { videoFromApi } from "@/lib/flow-ledger/build";
-import { actionLabel, mediaStatusLabel, metricLabel, methodLabel, platformLabel, videoDecisionSections } from "@/lib/flow-ledger/business";
+import { actionLabel, mediaStatusLabel, metricLabel, methodLabel, platformLabel, scoreItemsText } from "@/lib/flow-ledger/business";
 import { asRecord, text } from "@/lib/flow-ledger/format";
 import type { VideoRef } from "@/lib/flow-ledger/types";
 
@@ -17,7 +16,6 @@ export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: st
   const [data, setData] = useState<FlowLedgerVideosResponse | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
-  const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
 
   const load = useCallback(async () => {
     setLoading(true);
@@ -67,6 +65,7 @@ export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: st
                   <th>作者 / 平台</th>
                   <th>互动</th>
                   <th>判断</th>
+                  <th>评分详情</th>
                   <th>视频保存</th>
                   <th>操作</th>
                 </tr>
@@ -78,7 +77,6 @@ export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: st
                     index={index}
                     runId={runId}
                     video={video}
-                    onExplain={() => setDrawer({ title: "为什么这样判断", sections: videoDecisionSections(video) })}
                   />
                 ))}
               </tbody>
@@ -86,7 +84,6 @@ export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: st
           </section>
         </>
       ) : null}
-      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
     </AppShell>
   );
 }
@@ -100,7 +97,7 @@ function Summary({ label, value }: { label: string; value: string }) {
   );
 }
 
-function VideoRow({ index, runId, video, onExplain }: { index: number; runId: string; video: VideoRef; onExplain: () => void }) {
+function VideoRow({ index, runId, video }: { index: number; runId: string; video: VideoRef }) {
   return (
     <tr>
       <td>{index + 1}</td>
@@ -118,23 +115,19 @@ function VideoRow({ index, runId, video, onExplain }: { index: number; runId: st
       </td>
       <td>
         <span className="chip blue">{actionLabel(video.decisionAction)}</span>
-        <div className="muted">{video.decisionReasonLabel}</div>
+      </td>
+      <td>
+        <div className="score-mini">{scoreItemsText(video.scoreItems)}</div>
       </td>
       <td>
         <span className={`chip ${video.mediaStatus === "oss_uploaded" ? "green" : "yellow"}`}>{video.mediaStatusLabel}</span>
         {video.mediaFailureReason ? <div className="muted">{video.mediaFailureReason}</div> : null}
       </td>
       <td>
-        <button className="mini-button" type="button" onClick={onExplain}>
+        <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.id)}`}>
           <PanelRightOpen size={13} />
-          看判断原因
-        </button>
-        {video.ossUrl ? (
-          <a className="mini-button ghost" href={video.ossUrl} target="_blank" rel="noreferrer">
-            <ExternalLink size={13} />
-            打开 OSS
-          </a>
-        ) : null}
+          看详情
+        </Link>
       </td>
     </tr>
   );

+ 65 - 42
web2/features/QueryWalkPage.tsx

@@ -1,26 +1,17 @@
 "use client";
 
-import { useCallback, useEffect, useState } from "react";
+import { type CSSProperties, useCallback, useEffect, 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 BusinessSection } from "@/lib/flow-ledger/business";
+import { methodLabel } from "@/lib/flow-ledger/business";
 import { asRecord, text } from "@/lib/flow-ledger/format";
 
-const LANES = [
-  { id: "query_next_page", label: "翻页继续找" },
-  { id: "tag_query", label: "根据标签继续搜" },
-  { id: "author_works", label: "查看作者更多作品" },
-  { id: "path_stop", label: "到这里停止" }
-];
-
 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);
-  const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
 
   const load = useCallback(async () => {
     setLoading(true);
@@ -40,7 +31,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
   }, [load]);
 
   const query = asRecord(data?.query);
-  const actions = data?.actions || [];
+  const actions = [...(data?.actions || [])].sort((a, b) => actionDepth(a) - actionDepth(b));
 
   return (
     <AppShell title="继续扩展过程" subtitle={query.text ? `搜索词:${text(query.text)}` : "查看系统有没有继续向外找内容"} runId={runId} showBack onRefresh={load}>
@@ -61,23 +52,17 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
               <Summary label="数据来源" value={data.data_origin_label} />
             </div>
           </details>
-          <section className="walk-lanes">
-            {LANES.map((lane) => {
-              const laneActions = data.lanes?.[lane.id] || [];
-              return (
-                <div className="walk-lane" key={lane.id}>
-                  <div className="lane-head">{lane.label} · {laneActions.length}</div>
-                  {laneActions.slice(0, 12).map((action, index) => (
-                    <button className="walk-node" type="button" key={`${lane.id}-${index}`} onClick={() => setDrawer({ title: text(action.edge_label, lane.label), sections: actionSections(action) })}>
-                      <b>{text(action.status_label, "状态待确认")}</b>
-                      <span>{text(action.reason_label, "这一步没有记录额外原因")}</span>
-                      <small>{text(action.impact, "这一步暂不影响最终沉淀")}</small>
-                    </button>
-                  ))}
-                  {!laneActions.length ? <span className="muted lane-empty">无动作</span> : null}
-                </div>
-              );
-            })}
+          <section className="walk-tree-wrap">
+            <div className="walk-tree-root">
+              <span className="chip dark">首轮搜索词</span>
+              <strong>{text(query.text, "搜索词待确认")}</strong>
+              <small>{methodLabel(text(query.method, ""))}</small>
+            </div>
+            <div className="walk-tree">
+              {actions.map((action, index) => (
+                <WalkTreeNode action={action} index={index} key={`${text(action.id, "walk")}-${index}`} />
+              ))}
+            </div>
           </section>
           <section className="detail-table-wrap">
             <table className="detail-table">
@@ -88,7 +73,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
                   <th>结果</th>
                   <th>为什么</th>
                   <th>对产出有什么影响</th>
-                  <th>说明</th>
+                  <th>评分门槛</th>
                 </tr>
               </thead>
               <tbody>
@@ -99,11 +84,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
                     <td><span className="chip blue">{text(action.status_label, "状态待确认")}</span></td>
                     <td>{text(action.reason_label, "这一步没有记录额外原因")}</td>
                     <td>{text(action.impact, "这一步暂不影响最终沉淀")}</td>
-                    <td>
-                      <button className="mini-button" type="button" onClick={() => setDrawer({ title: text(action.edge_label, "扩展动作"), sections: actionSections(action) })}>
-                        看说明
-                      </button>
-                    </td>
+                    <td>{scoreLine(action)}</td>
                   </tr>
                 ))}
               </tbody>
@@ -111,7 +92,6 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
           </section>
         </>
       ) : null}
-      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
     </AppShell>
   );
 }
@@ -125,12 +105,55 @@ function Summary({ label, value }: { label: string; value: string }) {
   );
 }
 
-function actionSections(action: RawRecord): BusinessSection[] {
+function WalkTreeNode({ action, index }: { action: RawRecord; index: number }) {
+  const depth = actionDepth(action);
+  const lines = detailLines(action);
+  return (
+    <article className={`walk-tree-node ${text(action.status, "") === "success" ? "success" : "skipped"}`} style={{ "--depth": String(Math.min(depth, 6)) } as CSSProperties}>
+      <div className="walk-node-spine" />
+      <div className="walk-node-card">
+        <div className="walk-node-title">
+          <span className="idx">第 {index + 1} 步</span>
+          <strong>{text(action.edge_label, "扩展动作")}</strong>
+          <span className={`chip ${text(action.status, "") === "success" ? "green" : "yellow"}`}>{text(action.status_label, "状态待确认")}</span>
+        </div>
+        <div className="walk-node-meta">
+          <span>{text(action.from_label, "从上一环节触发")}</span>
+          <span>{text(action.trigger_label, "触发对象待确认")}</span>
+        </div>
+        <ul>
+          {lines.map((line) => (
+            <li key={line}>{line}</li>
+          ))}
+        </ul>
+        <p>{text(action.impact, "这一步暂不影响最终沉淀")}</p>
+      </div>
+    </article>
+  );
+}
+
+function actionDepth(action: RawRecord): number {
+  const value = Number(action.depth || 0);
+  return Number.isFinite(value) ? value : 0;
+}
+
+function detailLines(action: RawRecord): string[] {
+  const value = action.detail_lines;
+  if (Array.isArray(value)) {
+    return value.map((item) => text(item)).filter(Boolean);
+  }
   return [
-    { label: "发生了什么", value: text(action.edge_label, "扩展动作"), tone: action.status === "success" ? "good" : "neutral" },
-    { label: "从哪里来", value: text(action.from_label, "从上一环节出发") },
-    { label: "结果", value: text(action.status_label, "状态待确认") },
-    { label: "为什么", value: text(action.reason_label, "这一步没有记录额外原因") },
-    { label: "对产出的影响", value: text(action.impact, "这一步暂不影响最终沉淀") }
+    `动作:${text(action.edge_label, "扩展动作")}`,
+    `上一环节:${text(action.from_label, "从上一环节触发")}`,
+    `触发对象:${text(action.trigger_label, "触发对象待确认")}`,
+    `判断:${text(action.reason_label, "这一步没有记录额外原因")}`
   ];
 }
+
+function scoreLine(action: RawRecord): string {
+  const items = Array.isArray(action.score_items) ? action.score_items : [];
+  const labels = items
+    .filter((item): item is RawRecord => typeof item === "object" && item !== null)
+    .map((item) => `${text(item.label, "评分")} ${text(item.score_label, "暂无评分")}`);
+  return labels.length ? labels.join(" · ") : "暂无评分";
+}

+ 14 - 18
web2/features/VideoDetailPage.tsx

@@ -1,21 +1,17 @@
 "use client";
 
-import { PanelRightOpen } 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 { getFlowLedgerVideo } from "@/lib/api/client";
 import type { FlowLedgerVideoResponse } from "@/lib/api/types";
 import { videoFromApi } from "@/lib/flow-ledger/build";
-import { actionLabel, mediaStatusLabel, metricLabel, platformLabel, reasonLabel, scoreBusinessText, videoDecisionSections } from "@/lib/flow-ledger/business";
-import { asRecord } from "@/lib/flow-ledger/format";
+import { actionLabel, mediaStatusLabel, metricLabel, platformLabel, scoreItemRows, scoreItemsText } from "@/lib/flow-ledger/business";
 
 export function VideoDetailPage({ runId, contentId }: { runId: string; contentId: string }) {
   const [data, setData] = useState<FlowLedgerVideoResponse | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
-  const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
 
   const load = useCallback(async () => {
     setLoading(true);
@@ -35,7 +31,6 @@ export function VideoDetailPage({ runId, contentId }: { runId: string; contentId
   }, [load]);
 
   const video = useMemo(() => (data?.video ? videoFromApi(data.video) : undefined), [data]);
-  const decision = asRecord(data?.decision || video?.decision);
 
   return (
     <AppShell title="视频详情" subtitle="查看这条内容为什么入池、淘汰或需要复看" runId={runId} showBack onRefresh={load}>
@@ -52,10 +47,7 @@ export function VideoDetailPage({ runId, contentId }: { runId: string; contentId
             </summary>
             <div className="decl-body">
               <Summary label="互动" value={metricLabel(video)} />
-              <Summary
-                label="判断"
-                value={`${reasonLabel(video.decisionReason)}${scoreBusinessText(video.score) ? ` · ${scoreBusinessText(video.score)}` : ""}`}
-              />
+              <Summary label="判断" value={`${actionLabel(video.decisionAction)} · ${scoreItemsText(video.scoreItems)}`} />
               <Summary label="视频保存" value={`${video.mediaStatusLabel || mediaStatusLabel(video.mediaStatus)}${video.mediaFailureReason ? ` · ${video.mediaFailureReason}` : ""}`} />
               <Summary label="标签" value={video.tags.length ? video.tags.join(" / ") : "无标签"} />
             </div>
@@ -84,18 +76,22 @@ export function VideoDetailPage({ runId, contentId }: { runId: string; contentId
               )}
             </div>
             <div className="video-panel">
-              <h2>判断证据</h2>
-              <p>{actionLabel(video.decisionAction)} · {video.decisionReasonLabel || reasonLabel(video.decisionReason)}</p>
-              {decision.score_label ? <p>综合分:{String(decision.score_label)}</p> : null}
-              <button className="mini-button" type="button" onClick={() => setDrawer({ title: "为什么这样判断", sections: videoDecisionSections(video) })}>
-                <PanelRightOpen size={13} />
-                看判断原因
-              </button>
+              <h2>评分详情</h2>
+              <p>{actionLabel(video.decisionAction)} · {scoreItemsText(video.scoreItems)}</p>
+              <div className="score-list roomy">
+                {scoreItemRows(video.scoreItems).map((item) => (
+                  <div className="score-row" key={item.label}>
+                    <span>{item.label}</span>
+                    <b>{item.scoreLabel}</b>
+                    {item.detail ? <small>{item.detail}</small> : null}
+                  </div>
+                ))}
+                {!scoreItemRows(video.scoreItems).length ? <span className="muted">暂无评分</span> : null}
+              </div>
             </div>
           </section>
         </>
       ) : null}
-      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
     </AppShell>
   );
 }

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

@@ -1,5 +1,5 @@
 import type { FlowLedgerApiResponse, RawRecord } from "@/lib/api/types";
-import type { FlowLedger, FlowLedgerRow, VideoRef } from "./types";
+import type { FlowLedger, FlowLedgerRow, ScoreItem, VideoRef } from "./types";
 import { asRecord, maybeText, scoreLabel, text, textList } from "./format";
 
 function countMap(value: unknown): Record<string, number> {
@@ -7,6 +7,18 @@ function countMap(value: unknown): Record<string, number> {
   return Object.fromEntries(Object.entries(row).map(([key, item]) => [key, Number(item) || 0]));
 }
 
+function scoreItems(value: unknown): ScoreItem[] {
+  if (!Array.isArray(value)) return [];
+  return value
+    .filter((item): item is RawRecord => typeof item === "object" && item !== null)
+    .map((item) => ({
+      label: text(item.label, "评分项"),
+      score: item.score === null || item.score === undefined ? null : Number(item.score),
+      scoreLabel: text(item.score_label, "暂无评分"),
+      detail: maybeText(item.detail)
+    }));
+}
+
 function videoRef(row: RawRecord): VideoRef {
   const decision = asRecord(row.decision);
   return {
@@ -29,6 +41,7 @@ function videoRef(row: RawRecord): VideoRef {
     decisionReason: text(decision.reason, "no_decision"),
     decisionReasonLabel: text(decision.reason_label, "暂未产生判断结果"),
     score: scoreLabel(decision.score),
+    scoreItems: scoreItems(decision.score_items),
     raw: row,
     decision,
     gemini: asRecord(row.gemini)
@@ -76,6 +89,7 @@ function rowFromApi(row: RawRecord): FlowLedgerRow {
       primaryReason: text(rule.primary_reason, "no_decision"),
       primaryReasonLabel: text(rule.primary_reason_label, "暂未产生判断结果"),
       scoreLabel: "",
+      scoreItems: scoreItems(asRecord(rule.score_summary).items),
       decisions: Array.isArray(asRecord(rule.debug).decisions) ? (asRecord(rule.debug).decisions as RawRecord[]) : [],
       headline: text(rule.headline, "")
     },

+ 13 - 142
web2/lib/flow-ledger/business.ts

@@ -1,6 +1,5 @@
-import type { RawRecord } from "@/lib/api/types";
-import type { FlowLedgerRow, VideoRef } from "./types";
-import { asRecord, maybeText, text, textList } from "./format";
+import type { FlowLedgerRow, ScoreItem, VideoRef } from "./types";
+import { asRecord, textList } from "./format";
 
 export type BusinessTone = "neutral" | "good" | "warn" | "bad" | "info";
 
@@ -21,24 +20,11 @@ const ACTION_LABELS: Record<string, string> = {
   未判断: "未进入判断"
 };
 
-const ACTION_RESULT: Record<string, string> = {
-  ADD_TO_CONTENT_POOL: "这条内容可以进入后续沉淀。",
-  KEEP_CONTENT_FOR_REVIEW: "这条内容需要人工再看一眼。",
-  REJECT_CONTENT: "这条内容不建议继续使用。",
-  NOT_JUDGED: "系统还没有给这条内容做判断。",
-  未判断: "系统还没有给这条内容做判断。"
-};
-
 const REASON_LABELS: Record<string, string> = {
   no_decision: "暂未产生判断结果",
   无原因: "暂未产生判断原因"
 };
 
-const REASON_IMPACT: Record<string, string> = {
-  no_decision: "当前只作为发现记录展示,暂不影响最终沉淀。",
-  无原因: "当前只作为发现记录展示,暂不影响最终沉淀。"
-};
-
 const SOURCE_LABELS: Record<string, string> = {
   seed_term: "Pattern 种子词",
   piaoquan_topic_point: "票圈内容点",
@@ -117,10 +103,6 @@ export function reasonLabel(value: string): string {
   return REASON_LABELS[value] || UNKNOWN;
 }
 
-export function reasonImpact(value: string): string {
-  return REASON_IMPACT[value] || "这条原因还没有配置运营解释,当前不会展示内部字段。";
-}
-
 export function sourceLabel(value: string): string {
   return SOURCE_LABELS[value] || SOURCE_LABELS.来源待确认;
 }
@@ -153,7 +135,7 @@ export function platformLabel(value?: string | null): string {
 
 export function mediaStatusLabel(value?: string | null): string {
   const labels: Record<string, string> = {
-    oss_uploaded: "已保存到 OSS",
+    oss_uploaded: "已保存",
     oss_upload_pending: "待补传",
     metadata_only: "尚未拿到视频",
     unavailable: "无可用视频"
@@ -161,6 +143,16 @@ export function mediaStatusLabel(value?: string | null): string {
   return value ? labels[value] || "视频状态待确认" : "视频状态待确认";
 }
 
+export function scoreItemsText(items: ScoreItem[]): string {
+  const visible = items.filter((item) => item.scoreLabel && item.scoreLabel !== "暂无评分");
+  if (!visible.length) return "暂无评分";
+  return visible.slice(0, 3).map((item) => `${item.label} ${item.scoreLabel}`).join(" · ");
+}
+
+export function scoreItemRows(items: ScoreItem[]): ScoreItem[] {
+  return items.filter((item) => item.scoreLabel && item.scoreLabel !== "暂无评分");
+}
+
 export function metricLabel(video: VideoRef): string {
   const digg = video.statistics.digg_count || video.statistics.like_count || 0;
   const comment = video.statistics.comment_count || 0;
@@ -183,74 +175,6 @@ export function ruleHeadline(row: FlowLedgerRow): string {
   return "本轮没有可沉淀内容";
 }
 
-export function rulePrimaryReason(row: FlowLedgerRow): string {
-  if (!row.ruleSummary.total) return "还没有产生判断结果";
-  return row.ruleSummary.primaryReasonLabel || reasonLabel(row.ruleSummary.primaryReason);
-}
-
-export function ruleBrief(row: FlowLedgerRow): string {
-  if (!row.ruleSummary.total) return "简要:首轮内容还没有进入判断。";
-  if (row.ruleSummary.passCount) return `简要:有 ${row.ruleSummary.passCount} 条内容可以继续使用。`;
-  if (row.ruleSummary.reviewCount) return `简要:有 ${row.ruleSummary.reviewCount} 条内容需要人工复看,先别直接沉淀。`;
-  return `简要:本轮内容不建议沉淀,主要因为${rulePrimaryReason(row)}。`;
-}
-
-export function videoDecisionSections(video: VideoRef): BusinessSection[] {
-  const score = scoreBusinessText(video.score);
-  return [
-    {
-      label: "判断结论",
-      value: actionLabel(video.decisionAction),
-      tone: actionTone(video.decisionAction)
-    },
-    {
-      label: "为什么这样判断",
-      value: video.decisionReasonLabel || reasonLabel(video.decisionReason)
-    },
-    {
-      label: "对流程的影响",
-      value: ACTION_RESULT[video.decisionAction] || reasonImpact(video.decisionReason)
-    },
-    {
-      label: "关键证据",
-      items: [
-        `视频标题:${video.title}`,
-        `作者:${video.author}`,
-        metricLabel(video),
-        `视频保存:${video.mediaStatusLabel || mediaStatusLabel(video.mediaStatus)}`,
-        score,
-        video.tags.length ? `标签:${video.tags.slice(0, 8).join(" / ")}` : undefined
-      ].filter(Boolean) as string[]
-    }
-  ];
-}
-
-export function rowDecisionSections(row: FlowLedgerRow): BusinessSection[] {
-  return [
-    {
-      label: "本轮结论",
-      value: ruleHeadline(row),
-      tone: row.ruleSummary.passCount ? "good" : row.ruleSummary.reviewCount ? "warn" : row.ruleSummary.total ? "bad" : "neutral"
-    },
-    {
-      label: "主要原因",
-      value: rulePrimaryReason(row)
-    },
-    {
-      label: "运营影响",
-      value: row.ruleSummary.total ? reasonImpact(row.ruleSummary.primaryReason) : "当前只完成搜索发现,还没有进入内容判断。"
-    },
-    {
-      label: "判断分布",
-      items: [
-        `入池 ${row.ruleSummary.passCount} 条`,
-        `待复看 ${row.ruleSummary.reviewCount} 条`,
-        `淘汰 ${row.ruleSummary.rejectCount} 条`
-      ]
-    }
-  ];
-}
-
 export function walkSummaryText(row: FlowLedgerRow): string {
   if (!row.walkSummary.total) return "本轮没有继续扩展";
   const stop = Object.entries(row.walkSummary.stopReasons).sort((a, b) => b[1] - a[1])[0]?.[0];
@@ -265,59 +189,6 @@ export function assetSummaryText(row: FlowLedgerRow): string {
   return "暂无可沉淀内容";
 }
 
-export function walkActionTitle(action: RawRecord): string {
-  return walkEdgeLabel(text(action.edge_id || action.edge_type, ""));
-}
-
-export function walkActionReason(action: RawRecord): string {
-  const reason = maybeText(action.reason_code);
-  if (reason) return reasonLabel(reason);
-  return "这一步没有记录额外原因";
-}
-
-export function walkActionImpact(action: RawRecord): string {
-  const reason = maybeText(action.reason_code);
-  if (reason) return reasonImpact(reason);
-  const status = text(action.walk_status, "");
-  if (status === "success") return "这一步已经执行,并把结果带入后续流程。";
-  if (status === "skipped") return "这一步没有继续执行。";
-  return "这一步暂不影响最终沉淀。";
-}
-
-export function walkActionSource(action: RawRecord, row: FlowLedgerRow): string {
-  const fromType = text(action.from_node_type, "");
-  if (fromType.includes("query")) return `从搜索词“${row.query.text}”出发`;
-  if (fromType.includes("content") || fromType.includes("video")) return "从首轮视频出发";
-  if (fromType.includes("tag")) return "从视频标签出发";
-  return "从上一环节出发";
-}
-
-export function walkActionSections(action: RawRecord, row: FlowLedgerRow): BusinessSection[] {
-  return [
-    {
-      label: "发生了什么",
-      value: walkActionTitle(action),
-      tone: text(action.walk_status, "") === "success" ? "good" : "neutral"
-    },
-    {
-      label: "从哪里来",
-      value: walkActionSource(action, row)
-    },
-    {
-      label: "结果",
-      value: walkStatusLabel(text(action.walk_status, ""))
-    },
-    {
-      label: "为什么",
-      value: walkActionReason(action)
-    },
-    {
-      label: "对产出的影响",
-      value: walkActionImpact(action)
-    }
-  ];
-}
-
 export function clueText(row: FlowLedgerRow): string {
   const clue = asRecord(row.query.clue);
   const queries = textList(clue.generated_queries);

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

@@ -2,6 +2,13 @@ import type { RawRecord } from "@/lib/api/types";
 
 export type CountMap = Record<string, number>;
 
+export type ScoreItem = {
+  label: string;
+  score?: number | null;
+  scoreLabel: string;
+  detail?: string;
+};
+
 export type VideoRef = {
   id: string;
   contentDiscoveryId: string;
@@ -22,6 +29,7 @@ export type VideoRef = {
   decisionReason: string;
   decisionReasonLabel: string;
   score?: string;
+  scoreItems: ScoreItem[];
   raw: RawRecord;
   decision?: RawRecord;
   gemini?: RawRecord;
@@ -55,6 +63,7 @@ export type RuleSummary = {
   primaryReason: string;
   primaryReasonLabel: string;
   scoreLabel: string;
+  scoreItems: ScoreItem[];
   decisions: RawRecord[];
   headline?: string;
 };