Quellcode durchsuchen

Add V4 flow ledger API for web2

Sam Lee vor 1 Monat
Ursprung
Commit
65632989ce

+ 65 - 6
content_agent/api.py

@@ -10,11 +10,16 @@ from fastapi.responses import JSONResponse
 
 from content_agent.dashboard_service import DashboardService
 from content_agent.errors import ErrorCode, error_response, sanitize_error_detail
+from content_agent.flow_ledger_service import FlowLedgerService
 from content_agent.run_service import RunService
 from content_agent.schemas import (
     ConfigFileResponse,
     ContentItemsResponse,
     DashboardResponse,
+    FlowLedgerResponse,
+    FlowLedgerVideoResponse,
+    FlowLedgerVideosResponse,
+    FlowLedgerWalkResponse,
     JsonFileResponse,
     PlatformCatalogResponse,
     PlatformDescriptor,
@@ -227,6 +232,56 @@ def get_run_content_items(run_id: str) -> ContentItemsResponse:
     return ContentItemsResponse(**_dashboard_service().content_items(run_id))
 
 
+@app.get("/runs/{run_id}/flow-ledger", response_model=FlowLedgerResponse)
+def get_flow_ledger(run_id: str, debug: bool = False) -> FlowLedgerResponse:
+    _ensure_web_run_exists(run_id)
+    return FlowLedgerResponse(**_flow_ledger_service().ledger(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,
+    query_id: str,
+    debug: bool = False,
+) -> FlowLedgerVideosResponse:
+    _ensure_web_run_exists(run_id)
+    return FlowLedgerVideosResponse(
+        **_flow_ledger_service().query_videos(run_id, query_id, debug=debug)
+    )
+
+
+@app.get("/runs/{run_id}/flow-ledger/queries/{query_id}/walk", response_model=FlowLedgerWalkResponse)
+def get_flow_ledger_query_walk(
+    run_id: str,
+    query_id: str,
+    debug: bool = False,
+) -> FlowLedgerWalkResponse:
+    _ensure_web_run_exists(run_id)
+    return FlowLedgerWalkResponse(
+        **_flow_ledger_service().query_walk(run_id, query_id, debug=debug)
+    )
+
+
+@app.get("/runs/{run_id}/flow-ledger/videos/{content_id}", response_model=FlowLedgerVideoResponse)
+def get_flow_ledger_video(
+    run_id: str,
+    content_id: str,
+    debug: bool = False,
+) -> FlowLedgerVideoResponse:
+    _ensure_web_run_exists(run_id)
+    payload = _flow_ledger_service().video_detail(run_id, content_id, debug=debug)
+    if payload.get("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 FlowLedgerVideoResponse(**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)
@@ -283,29 +338,29 @@ def get_final_output(run_id: str) -> JsonFileResponse:
 
 @app.get("/runs/{run_id}/strategy-review", response_model=JsonFileResponse)
 def get_strategy_review(run_id: str) -> JsonFileResponse:
-    _ensure_run_exists(run_id)
+    _ensure_web_run_exists(run_id)
     return JsonFileResponse(run_id=run_id, data=service.strategy_review(run_id))
 
 
 @app.post("/runs/{run_id}/strategy-review/regenerate", response_model=JsonFileResponse)
 def regenerate_strategy_review(run_id: str) -> JsonFileResponse:
-    _ensure_run_exists(run_id)
+    _ensure_web_run_exists(run_id)
     return JsonFileResponse(run_id=run_id, data=service.regenerate_strategy_review(run_id))
 
 
 @app.get("/runs/{run_id}/validation", response_model=ValidationResponse)
 def get_validation(run_id: str) -> ValidationResponse:
-    _ensure_run_exists(run_id)
-    return ValidationResponse(**service.validate_run(run_id))
+    _ensure_web_run_exists(run_id)
+    return ValidationResponse(**_dashboard_service()._validate_optional(run_id))
 
 
 def _jsonl_response(run_id: str, filename: str) -> RecordsResponse:
-    _ensure_run_exists(run_id)
+    _ensure_web_run_exists(run_id)
     return RecordsResponse(run_id=run_id, records=service.read_jsonl(run_id, filename))
 
 
 def _json_response(run_id: str, filename: str) -> JsonFileResponse:
-    _ensure_run_exists(run_id)
+    _ensure_web_run_exists(run_id)
     return JsonFileResponse(run_id=run_id, data=service.read_json(run_id, filename))
 
 
@@ -335,3 +390,7 @@ def _ensure_web_run_exists(run_id: str) -> None:
 
 def _dashboard_service() -> DashboardService:
     return DashboardService.from_runtime(service.runtime)
+
+
+def _flow_ledger_service() -> FlowLedgerService:
+    return FlowLedgerService(service.runtime)

+ 745 - 0
content_agent/flow_ledger_service.py

@@ -0,0 +1,745 @@
+from __future__ import annotations
+
+import copy
+import os
+import time
+from collections import Counter
+from typing import Any
+
+from content_agent.dashboard_service import (
+    DATA_ORIGIN_PRODUCTION_DB,
+    DATA_ORIGIN_RUNTIME_EXPORT,
+    DashboardService,
+)
+from content_agent.integrations.database_runtime import DatabaseRuntimeStore
+from content_agent.interfaces import RuntimeStore
+
+
+FIRST_ROUND_QUERY_METHODS = {
+    "seed_term",
+    "piaoquan_topic_point",
+    "category_leaf_element",
+}
+EXTENSION_QUERY_METHODS = {
+    "query_next_page",
+    "tag_query",
+    "author_works",
+}
+
+SOURCE_LABELS = {
+    "seed_term": "Pattern 种子词",
+    "piaoquan_topic_point": "票圈内容点",
+    "category_leaf_element": "分类叶子元素",
+}
+QUERY_METHOD_LABELS = {
+    "seed_term": "Pattern 种子词",
+    "piaoquan_topic_point": "票圈内容点",
+    "category_leaf_element": "分类叶子元素",
+    "query_next_page": "翻页继续找",
+    "tag_query": "根据标签继续搜",
+    "author_works": "查看作者更多作品",
+}
+ACTION_LABELS = {
+    "ADD_TO_CONTENT_POOL": "入池",
+    "KEEP_CONTENT_FOR_REVIEW": "待复看",
+    "REJECT_CONTENT": "淘汰",
+}
+MEDIA_STATUS_LABELS = {
+    "oss_uploaded": "已保存到 OSS",
+    "oss_upload_pending": "待补传",
+    "metadata_only": "尚未拿到视频",
+    "unavailable": "无可用视频",
+}
+WALK_EDGE_LABELS = {
+    "query_next_page": "翻页继续找",
+    "hashtag_to_query": "根据标签继续搜",
+    "tag_query": "根据标签继续搜",
+    "author_to_works": "查看作者更多作品",
+    "video_to_author": "查看作者更多作品",
+    "author_works": "查看作者更多作品",
+    "path_stop": "到这里停止",
+    "terminal": "到这里停止",
+}
+REASON_LABELS = {
+    "content_pattern_recall_required": "内容和本次需求没有形成有效关联",
+    "blocked_by_rule_decision": "上一步内容未通过判断",
+    "missing_content_portrait": "缺少判断所需的内容画像",
+    "category_or_element_binding_required": "没有命中本次需求的类目或要素",
+    "missing_score": "没有足够信息进入打分",
+    "v4_query_and_platform_pass": "搜索词和平台表现都符合要求",
+    "v4_query_or_score_below_threshold": "相关性或平台表现未达到要求",
+    "v4_technical_retry_needed": "系统需要重试后再判断",
+    "v4_allow_walk_denied": "不适合继续向外扩展",
+    "no_decision": "暂未产生判断结果",
+}
+
+_FLOW_LEDGER_CACHE: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
+_DEFAULT_CACHE_TTL_SECONDS = 300
+
+
+class FlowLedgerService:
+    def __init__(self, runtime: RuntimeStore) -> None:
+        dashboard = DashboardService.from_runtime(runtime)
+        self.runtime = dashboard.runtime
+        self.export_runtime = dashboard.export_runtime
+        self.data_origin = (
+            DATA_ORIGIN_PRODUCTION_DB
+            if isinstance(self.runtime, DatabaseRuntimeStore)
+            else DATA_ORIGIN_RUNTIME_EXPORT
+        )
+
+    def ledger(self, run_id: str, *, debug: bool = False) -> dict[str, Any]:
+        cache_key = ("ledger", run_id)
+        if not debug:
+            cached = _cache_get(cache_key)
+            if cached is not None:
+                return cached
+        bundle = self._bundle(run_id, cache=not debug)
+        rows = [
+            self._ledger_row(query, bundle, debug=debug)
+            for query in bundle["queries"]
+            if _query_method(query) in FIRST_ROUND_QUERY_METHODS
+        ]
+        extension_queries = [
+            self._extension_query(query, bundle, debug=debug)
+            for query in bundle["queries"]
+            if _query_method(query) in EXTENSION_QUERY_METHODS
+        ]
+        media_counts = Counter(_media_status(media) for media in bundle["media"])
+        action_counts = Counter(_decision_action(decision) for decision in bundle["decisions"])
+        gemini_counts = Counter(_text(evidence.get("decode_status") or evidence.get("gemini_status"), "unknown") for evidence in bundle["evidence"])
+        payload = {
+            "run_id": run_id,
+            "data_origin": self.data_origin,
+            "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
+            "summary": self._summary(bundle, rows, extension_queries, media_counts, action_counts, gemini_counts),
+            "rows": rows,
+            "extension_queries": extension_queries,
+            "debug": bundle if debug else None,
+        }
+        if not debug:
+            _cache_set(cache_key, payload)
+        return payload
+
+    def query_videos(self, run_id: str, query_id: str, *, debug: bool = False) -> dict[str, Any]:
+        bundle = self._bundle(run_id, cache=not debug)
+        query = self._query_by_id(bundle, query_id)
+        related = self._query_content(query_id, bundle, first_round_only=True)
+        videos = [self._video(content, bundle, debug=debug) for content in related]
+        return {
+            "run_id": run_id,
+            "query": self._query_summary(query, bundle, debug=debug) if query else None,
+            "videos": videos,
+            "summary": self._video_summary(videos),
+            "data_origin": self.data_origin,
+            "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
+        }
+
+    def query_walk(self, run_id: str, query_id: str, *, debug: bool = False) -> dict[str, Any]:
+        bundle = self._bundle(run_id, cache=not debug)
+        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)
+            for action in bundle["walk_actions"]
+            if _walk_action_related(action, query_id, related_ids)
+        ]
+        lanes: dict[str, list[dict[str, Any]]] = {key: [] for key in ["query_next_page", "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
+        ]
+        return {
+            "run_id": run_id,
+            "query": self._query_summary(query, bundle, debug=debug) if query else None,
+            "actions": actions,
+            "lanes": lanes,
+            "extension_queries": extension_queries,
+            "summary": {
+                "total_actions": len(actions),
+                "success_count": sum(1 for item in actions if item["status"] == "success"),
+                "skipped_count": sum(1 for item in actions if item["status"] == "skipped"),
+                "extension_query_count": len(extension_queries),
+            },
+            "data_origin": self.data_origin,
+            "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
+        }
+
+    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(
+            (
+                item
+                for item in bundle["content_items"]
+                if content_id in {
+                    _text(item.get("platform_content_id")),
+                    _text(item.get("content_discovery_id")),
+                }
+            ),
+            None,
+        )
+        if not content:
+            return {
+                "run_id": run_id,
+                "video": None,
+                "data_origin": self.data_origin,
+                "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
+            }
+        video = self._video(content, bundle, debug=debug)
+        platform_id = _text(content.get("platform_content_id"))
+        decision = _decision_for_content(content, bundle["decisions"])
+        source_paths = [
+            path
+            for path in bundle["source_paths"]
+            if platform_id and platform_id in {_text(path.get("from_node_id")), _text(path.get("to_node_id")), _text(path.get("decision_target_id"))}
+        ]
+        return {
+            "run_id": run_id,
+            "video": video,
+            "source_paths": [_source_path(path, debug=debug) for path in source_paths],
+            "decision": self._decision_summary(decision, debug=debug) if decision else None,
+            "data_origin": self.data_origin,
+            "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
+        }
+
+    def _bundle(self, run_id: str, *, cache: bool = True) -> dict[str, Any]:
+        cache_key = ("bundle", run_id)
+        if cache:
+            cached = _cache_get(cache_key)
+            if cached is not None:
+                return cached
+        payload = {
+            "queries": self._read_jsonl(run_id, "search_queries.jsonl"),
+            "content_items": self._read_jsonl(run_id, "discovered_content_items.jsonl"),
+            "media": self._read_jsonl(run_id, "content_media_records.jsonl"),
+            "evidence": self._read_jsonl(run_id, "pattern_recall_evidence.jsonl"),
+            "decisions": self._read_jsonl(run_id, "rule_decisions.jsonl"),
+            "walk_actions": self._read_jsonl(run_id, "walk_actions.jsonl"),
+            "source_paths": self._read_jsonl(run_id, "source_path_records.jsonl"),
+            "final_output": self._read_json(run_id, "final_output.json") or {},
+        }
+        if cache:
+            _cache_set(cache_key, payload)
+        return payload
+
+    def _read_json(self, run_id: str, filename: str) -> dict[str, Any] | None:
+        try:
+            return self.runtime.read_json(run_id, filename)
+        except Exception:
+            if self.export_runtime:
+                try:
+                    return self.export_runtime.read_json(run_id, filename)
+                except Exception:
+                    return None
+            return None
+
+    def _read_jsonl(self, run_id: str, filename: str) -> list[dict[str, Any]]:
+        try:
+            return self.runtime.read_jsonl(run_id, filename)
+        except Exception:
+            if self.export_runtime:
+                try:
+                    return self.export_runtime.read_jsonl(run_id, filename)
+                except Exception:
+                    return []
+            return []
+
+    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]:
+        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")]
+        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)
+        return {
+            "id": query_id,
+            "source": source,
+            "query": self._query_summary(query, bundle, debug=debug),
+            "first_round_videos": videos[:3],
+            "first_round_video_total": len(videos),
+            "video_stats": self._video_summary(videos),
+            "rule_summary": self._rule_summary(decisions, debug=debug),
+            "walk_summary": self._walk_summary(actions),
+            "asset_summary": self._asset_summary(decisions, bundle["final_output"]),
+            "debug": {"query": query} if debug else None,
+        }
+
+    def _query_content(self, query_id: str, bundle: dict[str, Any], *, first_round_only: bool) -> list[dict[str, Any]]:
+        items = [item for item in bundle["content_items"] if _content_belongs_to_query(item, query_id)]
+        if first_round_only:
+            items = [item for item in items if _is_first_round_content(item)]
+        return items
+
+    def _query_related_content_ids(self, query_id: str, bundle: dict[str, Any]) -> set[str]:
+        ids: set[str] = set()
+        for item in self._query_content(query_id, bundle, first_round_only=True):
+            ids.update(_content_ids(item))
+        return {item for item in ids if item}
+
+    def _source_summary(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
+        method = _query_method(query)
+        seed_ref = _record(query.get("pattern_seed_ref"))
+        raw_payload = _record(query.get("raw_payload"))
+        refs = _list(raw_payload.get("query_source_refs")) or _list(seed_ref.get("query_source_refs"))
+        source_text = (
+            _text(seed_ref.get("query_source_text"))
+            or _text(seed_ref.get("seed_term"))
+            or _text(query.get("search_query"))
+        )
+        terms = _text_list(query.get("query_source_terms"))
+        return {
+            "type": method,
+            "label": SOURCE_LABELS.get(method, "来源待确认"),
+            "evidence": source_text or (terms[0] if terms else "来源证据待确认"),
+            "source_ref": _text(seed_ref.get("source_post_id") or seed_ref.get("pattern_execution_id"), "来源编号待确认"),
+            "terms": terms,
+            "query_source_refs": refs,
+            "debug": {"pattern_seed_ref": seed_ref} if debug else None,
+        }
+
+    def _query_summary(self, query: dict[str, Any] | None, bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
+        if not query:
+            return {
+                "id": "",
+                "text": "搜索词待确认",
+                "method": "unknown",
+                "method_label": "生成方式待确认",
+                "source_terms": [],
+                "effect_label": "处理状态待确认",
+            }
+        method = _query_method(query)
+        return {
+            "id": _text(query.get("search_query_id")),
+            "text": _text(query.get("search_query"), "搜索词待确认"),
+            "method": method,
+            "method_label": QUERY_METHOD_LABELS.get(method, "生成方式待确认"),
+            "source_terms": _text_list(query.get("query_source_terms")),
+            "effect_status": _text(query.get("search_query_effect_status"), "pending"),
+            "effect_label": _effect_label(_text(query.get("search_query_effect_status"), "pending")),
+            "debug": {"query": query} if debug else None,
+        }
+
+    def _extension_query(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
+        summary = self._query_summary(query, bundle, debug=debug)
+        raw_payload = _record(query.get("raw_payload"))
+        summary.update({
+            "parent_query_id": _text(raw_payload.get("parent_search_query_id") or query.get("llm_variant_of")),
+            "trigger": _text(raw_payload.get("hashtag") or raw_payload.get("author_id") or raw_payload.get("cursor")),
+        })
+        return summary
+
+    def _video(self, content: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
+        platform_id = _text(content.get("platform_content_id"))
+        media = _media_for_content(content, bundle["media"])
+        evidence = _evidence_for_content(content, bundle["evidence"])
+        decision = _decision_for_content(content, bundle["decisions"])
+        status = _media_status(media)
+        stats = _record(content.get("statistics"))
+        return {
+            "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,
+            "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"),
+            "platform_label": _platform_label(_text(content.get("platform"), "unknown")),
+            "tags": _text_list(content.get("tags")),
+            "statistics": {key: _number(value) for key, value in stats.items()},
+            "published_at": _text(content.get("published_at") or content.get("publish_time")),
+            "oss_url": _text(media.get("oss_url")) if media else "",
+            "playable_url": _text(media.get("oss_url")) if media else "",
+            "media_status": status,
+            "media_status_label": MEDIA_STATUS_LABELS.get(status, "视频状态待确认"),
+            "media_failure_reason": _media_failure_reason(media),
+            "decision": self._decision_summary(decision, debug=debug) if decision else None,
+            "gemini": self._evidence_summary(evidence, debug=debug) if evidence else None,
+            "debug": {"content": content, "media": media, "evidence": evidence} if debug else None,
+        }
+
+    def _decision_summary(self, decision: dict[str, Any] | None, *, debug: bool) -> dict[str, Any]:
+        if not decision:
+            return {
+                "action": "NOT_JUDGED",
+                "label": "未进入判断",
+                "reason": "no_decision",
+                "reason_label": REASON_LABELS["no_decision"],
+            }
+        reason = _text(decision.get("decision_reason_code"), "no_decision")
+        scorecard = _record(decision.get("scorecard"))
+        score = decision.get("score") if decision.get("score") is not None else scorecard.get("total_score")
+        action = _decision_action(decision)
+        return {
+            "id": _text(decision.get("decision_id")),
+            "action": action,
+            "label": ACTION_LABELS.get(action, "待判断"),
+            "reason": reason,
+            "reason_label": REASON_LABELS.get(reason, "系统原因待补充"),
+            "score": score,
+            "score_label": f"{round(float(score))} 分" if isinstance(score, (int, float)) else "",
+            "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,
+        }
+
+    def _evidence_summary(self, evidence: dict[str, Any], *, debug: bool) -> dict[str, Any]:
+        status = _text(evidence.get("decode_status") or evidence.get("gemini_status"), "unknown")
+        return {
+            "status": status,
+            "label": "Gemini 已看过" if status in {"success", "ok", "passed"} else "Gemini 未完成判断",
+            "summary": _text(evidence.get("reason") or evidence.get("evidence_summary") or evidence.get("summary")),
+            "debug": {"evidence": evidence} if debug else None,
+        }
+
+    def _rule_summary(self, decisions: list[dict[str, Any]], *, debug: bool) -> dict[str, Any]:
+        actions = Counter(_decision_action(item) for item in decisions)
+        reasons = Counter(_text(item.get("decision_reason_code") or item.get("reason"), "no_decision") for item in decisions)
+        primary_reason = reasons.most_common(1)[0][0] if reasons else "no_decision"
+        return {
+            "total": len(decisions),
+            "pool_count": actions["ADD_TO_CONTENT_POOL"],
+            "review_count": actions["KEEP_CONTENT_FOR_REVIEW"],
+            "reject_count": actions["REJECT_CONTENT"],
+            "primary_reason": primary_reason,
+            "primary_reason_label": REASON_LABELS.get(primary_reason, "系统原因待补充"),
+            "headline": _rule_headline(actions, len(decisions)),
+            "debug": {"decisions": decisions} if debug else None,
+        }
+
+    def _walk_summary(self, actions: list[dict[str, Any]]) -> dict[str, Any]:
+        edges = Counter(_walk_lane_id(action) for action in actions)
+        reasons = Counter(_text(action.get("reason_code")) for action in actions if _text(action.get("reason_code")))
+        statuses = Counter(_text(action.get("walk_status"), "unknown") for action in actions)
+        return {
+            "total": len(actions),
+            "success_count": statuses["success"],
+            "skipped_count": statuses["skipped"],
+            "edge_counts": dict(edges),
+            "edge_labels": {key: WALK_EDGE_LABELS.get(key, "系统原因待补充") for key in edges},
+            "stop_reason": reasons.most_common(1)[0][0] if reasons else "",
+            "stop_reason_label": REASON_LABELS.get(reasons.most_common(1)[0][0], "本轮没有继续扩展") if reasons else "本轮没有继续扩展",
+            "headline": f"{len(actions)} 次扩展动作" if actions else "本轮没有继续扩展",
+        }
+
+    def _asset_summary(self, decisions: list[dict[str, Any]], final_output: dict[str, Any]) -> dict[str, Any]:
+        decision_ids = {_text(item.get("decision_id") or item.get("id")) for item in decisions}
+        sections = {
+            "content_assets": "入池",
+            "review_records": "待复看",
+            "reject_records": "淘汰",
+        }
+        counts = {}
+        total = 0
+        for section, label in sections.items():
+            rows = [
+                item
+                for item in _list(final_output.get(section))
+                if not decision_ids or _text(item.get("decision_id")) in decision_ids
+            ]
+            counts[label] = len(rows)
+            total += len(rows)
+        return {
+            "total": total,
+            "pool_count": counts.get("入池", 0),
+            "review_count": counts.get("待复看", 0),
+            "reject_count": counts.get("淘汰", 0),
+            "headline": f"已沉淀 {counts.get('入池', 0)} 条内容" if counts.get("入池", 0) else "暂无可沉淀内容",
+        }
+
+    def _video_summary(self, videos: list[dict[str, Any]]) -> dict[str, Any]:
+        actions = Counter(_record(video.get("decision")).get("action") or "NOT_JUDGED" for video in videos)
+        media = Counter(video.get("media_status") or "unknown" for video in videos)
+        return {
+            "total": len(videos),
+            "pool_count": actions["ADD_TO_CONTENT_POOL"],
+            "review_count": actions["KEEP_CONTENT_FOR_REVIEW"],
+            "reject_count": actions["REJECT_CONTENT"],
+            "not_judged_count": actions["NOT_JUDGED"],
+            "oss_uploaded_count": media["oss_uploaded"],
+            "oss_pending_count": media["oss_upload_pending"],
+            "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]:
+        edge = _walk_lane_id(action)
+        reason = _text(action.get("reason_code"))
+        status = _text(action.get("walk_status"), "unknown")
+        return {
+            "id": _text(action.get("walk_action_id")),
+            "edge_id": edge,
+            "edge_label": WALK_EDGE_LABELS.get(edge, "系统原因待补充"),
+            "status": status,
+            "status_label": _walk_status_label(status),
+            "from_label": _walk_from_label(action, query),
+            "result": "已带入后续流程" if status == "success" else "没有继续执行",
+            "reason": reason,
+            "reason_label": REASON_LABELS.get(reason, "这一步没有记录额外原因") if reason else "这一步没有记录额外原因",
+            "impact": _walk_impact(status, reason),
+            "debug": {"action": action} if debug else None,
+        }
+
+    def _summary(
+        self,
+        bundle: dict[str, Any],
+        rows: list[dict[str, Any]],
+        extension_queries: list[dict[str, Any]],
+        media_counts: Counter[str],
+        action_counts: Counter[str],
+        gemini_counts: Counter[str],
+    ) -> dict[str, Any]:
+        return {
+            "first_round_query_count": len(rows),
+            "extension_query_count": len(extension_queries),
+            "all_query_count": len(bundle["queries"]),
+            "content_count": len(bundle["content_items"]),
+            "oss_uploaded_count": media_counts["oss_uploaded"],
+            "oss_pending_count": media_counts["oss_upload_pending"],
+            "gemini_completed_count": gemini_counts["ok"] + gemini_counts["success"] + gemini_counts["passed"],
+            "gemini_failed_count": gemini_counts["failed"],
+            "pool_count": action_counts["ADD_TO_CONTENT_POOL"],
+            "review_count": action_counts["KEEP_CONTENT_FOR_REVIEW"],
+            "reject_count": action_counts["REJECT_CONTENT"],
+        }
+
+
+def _query_method(query: dict[str, Any]) -> str:
+    return _text(query.get("search_query_generation_method"), "unknown")
+
+
+def _content_belongs_to_query(content: dict[str, Any], query_id: str) -> bool:
+    if not query_id:
+        return False
+    if _text(content.get("search_query_id")) == query_id:
+        return True
+    if query_id in _text_list(content.get("matched_search_query_ids")):
+        return True
+    raw_payload = _record(content.get("raw_payload"))
+    if query_id in _text_list(raw_payload.get("matched_search_query_ids")):
+        return True
+    for source in _list(content.get("query_sources")) + _list(raw_payload.get("query_sources")):
+        if _text(source.get("search_query_id")) == query_id:
+            return True
+    return False
+
+
+def _is_first_round_content(content: dict[str, Any]) -> bool:
+    previous = _text(content.get("previous_discovery_step") or _record(content.get("raw_payload")).get("previous_discovery_step"))
+    if not previous:
+        return True
+    return not any(token in previous for token in ["tag", "hashtag", "author", "next_page", "walk", "pagination"])
+
+
+def _content_ids(content: dict[str, Any]) -> set[str]:
+    return {
+        _text(content.get("content_discovery_id")),
+        _text(content.get("platform_content_id")),
+        _text(content.get("decision_target_id")),
+    }
+
+
+def _decision_for_content(content: dict[str, Any], decisions: list[dict[str, Any]]) -> dict[str, Any] | None:
+    ids = _content_ids(content)
+    return next((decision for decision in decisions if _text(decision.get("decision_target_id")) in ids), None)
+
+
+def _media_for_content(content: dict[str, Any], media_rows: list[dict[str, Any]]) -> dict[str, Any] | None:
+    platform_id = _text(content.get("platform_content_id"))
+    return next((media for media in media_rows if _text(media.get("platform_content_id")) == platform_id), None)
+
+
+def _evidence_for_content(content: dict[str, Any], evidence_rows: list[dict[str, Any]]) -> dict[str, Any] | None:
+    platform_id = _text(content.get("platform_content_id"))
+    discovery_id = _text(content.get("content_discovery_id"))
+    return next(
+        (
+            evidence
+            for evidence in evidence_rows
+            if _text(evidence.get("platform_content_id")) == platform_id
+            or _text(evidence.get("content_discovery_id")) == discovery_id
+        ),
+        None,
+    )
+
+
+def _walk_action_related(action: dict[str, Any], query_id: str, related_ids: set[str]) -> bool:
+    values = {
+        _text(action.get("from_node_id")),
+        _text(action.get("to_node_id")),
+        _text(action.get("decision_target_id")),
+    }
+    raw = _record(action.get("raw_payload"))
+    values.update({
+        _text(raw.get("parent_search_query_id")),
+        _text(raw.get("search_query_id")),
+        _text(raw.get("decision_target_id")),
+    })
+    return query_id in values or bool(values & related_ids)
+
+
+def _walk_lane_id(action: dict[str, Any]) -> str:
+    return _text(action.get("edge_id") or action.get("edge_type"), "unknown")
+
+
+def _walk_lane(edge: str) -> str:
+    if edge in {"query_next_page"}:
+        return "query_next_page"
+    if edge in {"hashtag_to_query", "tag_query"}:
+        return "tag_query"
+    if edge in {"author_to_works", "video_to_author", "author_works"}:
+        return "author_works"
+    return "path_stop"
+
+
+def _source_path(path: dict[str, Any], *, debug: bool) -> dict[str, Any]:
+    return {
+        "type": _text(path.get("source_path_type")),
+        "label": "内容来源路径",
+        "status": _text(path.get("status"), "unknown"),
+        "debug": {"source_path": path} if debug else None,
+    }
+
+
+def _decision_action(decision: dict[str, Any]) -> str:
+    action = _text(decision.get("decision_action") or decision.get("action"), "NOT_JUDGED")
+    if action == "ADD_CONTENT_TO_POOL":
+        return "ADD_TO_CONTENT_POOL"
+    return action
+
+
+def _media_status(media: dict[str, Any] | None) -> str:
+    if not media:
+        return "unavailable"
+    return _text(media.get("content_media_status"), "metadata_only")
+
+
+def _media_failure_reason(media: dict[str, Any] | None) -> str:
+    if not media:
+        return ""
+    raw = _record(media.get("raw_payload"))
+    return _text(media.get("failure_reason") or raw.get("upload_failure_reason") or raw.get("failure_reason"))
+
+
+def _rule_headline(actions: Counter[str], total: int) -> str:
+    if not total:
+        return "首轮内容暂未进入判断"
+    if actions["ADD_TO_CONTENT_POOL"]:
+        return f"有 {actions['ADD_TO_CONTENT_POOL']} 条内容入池"
+    if actions["KEEP_CONTENT_FOR_REVIEW"]:
+        return f"有 {actions['KEEP_CONTENT_FOR_REVIEW']} 条内容待复看"
+    return "本轮没有可沉淀内容"
+
+
+def _effect_label(status: str) -> str:
+    return {
+        "success": "已找到可用结果",
+        "rule_blocked": "找到结果,但未通过内容判断",
+        "pending": "处理中",
+        "failed": "搜索失败",
+        "skipped": "已跳过",
+    }.get(status, "处理状态待确认")
+
+
+def _walk_status_label(status: str) -> str:
+    return {
+        "success": "已执行",
+        "skipped": "未继续",
+        "failed": "执行失败",
+        "pending": "处理中",
+    }.get(status, "状态待确认")
+
+
+def _walk_from_label(action: dict[str, Any], query: dict[str, Any] | None) -> str:
+    from_type = _text(action.get("from_node_type"))
+    if "query" in from_type:
+        return f"从搜索词“{_text((query or {}).get('search_query'), '搜索词待确认')}”出发"
+    if "content" in from_type or "video" in from_type:
+        return "从视频内容出发"
+    if "tag" in from_type:
+        return "从视频标签出发"
+    return "从上一环节出发"
+
+
+def _walk_impact(status: str, reason: str) -> str:
+    if reason == "blocked_by_rule_decision":
+        return "这条内容不会继续带出更多视频。"
+    if status == "success":
+        return "这一步已经执行,结果会进入后续判断。"
+    if status == "skipped":
+        return "这一步没有继续执行,不影响最终沉淀。"
+    return "这一步暂不影响最终沉淀。"
+
+
+def _platform_label(platform: str) -> str:
+    return {
+        "douyin": "抖音",
+        "tiktok": "TikTok",
+        "bilibili": "B 站",
+        "kuaishou": "快手",
+        "xhs": "小红书",
+    }.get(platform.lower(), platform or "平台待确认")
+
+
+def _text(value: Any, default: str = "") -> str:
+    if value is None:
+        return default
+    text = str(value).strip()
+    return text or default
+
+
+def _text_list(value: Any) -> list[str]:
+    if isinstance(value, list):
+        return [_text(item) for item in value if _text(item)]
+    if isinstance(value, str) and value.strip():
+        return [value.strip()]
+    return []
+
+
+def _record(value: Any) -> dict[str, Any]:
+    return value if isinstance(value, dict) else {}
+
+
+def _list(value: Any) -> list[dict[str, Any]]:
+    return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
+
+
+def _number(value: Any) -> int:
+    try:
+        return int(float(value))
+    except (TypeError, ValueError):
+        return 0
+
+
+def _cache_ttl_seconds() -> int:
+    raw = os.environ.get("CONTENT_AGENT_FLOW_LEDGER_CACHE_TTL_SECONDS")
+    if raw is None:
+        return _DEFAULT_CACHE_TTL_SECONDS
+    try:
+        return max(0, int(raw))
+    except ValueError:
+        return _DEFAULT_CACHE_TTL_SECONDS
+
+
+def _cache_get(key: tuple[str, str]) -> dict[str, Any] | None:
+    ttl = _cache_ttl_seconds()
+    if ttl <= 0:
+        return None
+    cached = _FLOW_LEDGER_CACHE.get(key)
+    if not cached:
+        return None
+    expires_at, payload = cached
+    if expires_at <= time.monotonic():
+        _FLOW_LEDGER_CACHE.pop(key, None)
+        return None
+    return copy.deepcopy(payload)
+
+
+def _cache_set(key: tuple[str, str], payload: dict[str, Any]) -> None:
+    ttl = _cache_ttl_seconds()
+    if ttl <= 0:
+        return
+    _FLOW_LEDGER_CACHE[key] = (time.monotonic() + ttl, copy.deepcopy(payload))

+ 39 - 0
content_agent/schemas.py

@@ -184,3 +184,42 @@ class ContentItemsResponse(BaseModel):
     items: list[dict[str, Any]]
     total: int
     data_origin: str
+
+
+class FlowLedgerResponse(BaseModel):
+    run_id: str
+    data_origin: str
+    data_origin_label: str
+    summary: dict[str, Any]
+    rows: list[dict[str, Any]]
+    extension_queries: list[dict[str, Any]]
+    debug: dict[str, Any] | None = None
+
+
+class FlowLedgerVideosResponse(BaseModel):
+    run_id: str
+    query: dict[str, Any] | None = None
+    videos: list[dict[str, Any]]
+    summary: dict[str, Any]
+    data_origin: str
+    data_origin_label: str
+
+
+class FlowLedgerWalkResponse(BaseModel):
+    run_id: str
+    query: dict[str, Any] | None = None
+    actions: list[dict[str, Any]]
+    lanes: dict[str, list[dict[str, Any]]]
+    extension_queries: list[dict[str, Any]]
+    summary: dict[str, Any]
+    data_origin: str
+    data_origin_label: str
+
+
+class FlowLedgerVideoResponse(BaseModel):
+    run_id: str
+    video: dict[str, Any] | None = None
+    source_paths: list[dict[str, Any]] = Field(default_factory=list)
+    decision: dict[str, Any] | None = None
+    data_origin: str
+    data_origin_label: str

+ 194 - 0
tests/test_flow_ledger_api.py

@@ -0,0 +1,194 @@
+from fastapi.testclient import TestClient
+
+from content_agent import api
+from content_agent.flow_ledger_service import _FLOW_LEDGER_CACHE
+from content_agent.run_service import RunService
+
+
+def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
+    _FLOW_LEDGER_CACHE.clear()
+    service = RunService(runtime_root=tmp_path / "runtime" / "v1")
+    run_id = "run_flow_ledger"
+    policy_run_id = "policy_flow_ledger"
+    service.runtime.prepare_run(run_id)
+    service.runtime.append_jsonl(
+        run_id,
+        "search_queries.jsonl",
+        [
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "search_query_id": "q_001",
+                "search_query": "睡前拉伸",
+                "search_query_generation_method": "seed_term",
+                "pattern_seed_ref": {"query_source_text": "睡前拉伸", "source_post_id": "seed_001"},
+                "query_source_terms": ["睡前拉伸"],
+                "raw_payload": {"query_source_refs": [{"query_source_type": "seed_term"}]},
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "search_query_id": "q_002",
+                "search_query": "肩颈放松",
+                "search_query_generation_method": "tag_query",
+                "raw_payload": {"parent_search_query_id": "q_001", "hashtag": "肩颈放松"},
+            },
+        ],
+    )
+    service.runtime.append_jsonl(
+        run_id,
+        "discovered_content_items.jsonl",
+        [
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "content_discovery_id": "content_001",
+                "platform_content_id": "douyin_001",
+                "search_query_id": "q_001",
+                "platform": "douyin",
+                "description": "睡前 5 分钟肩颈拉伸",
+                "author_display_name": "健康教练",
+                "tags": ["肩颈", "拉伸"],
+                "statistics": {"digg_count": 100, "comment_count": 8, "share_count": 3},
+                "raw_payload": {},
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "content_discovery_id": "content_002",
+                "platform_content_id": "douyin_002",
+                "search_query_id": "q_002",
+                "previous_discovery_step": "hashtag_to_query",
+                "platform": "douyin",
+                "description": "扩展搜到的视频",
+                "statistics": {},
+                "raw_payload": {},
+            },
+        ],
+    )
+    service.runtime.append_jsonl(
+        run_id,
+        "content_media_records.jsonl",
+        [
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "platform": "douyin",
+                "platform_content_id": "douyin_001",
+                "play_url": "https://example.com/raw.mp4",
+                "oss_url": "https://oss.example.com/video.mp4",
+                "local_path": None,
+                "content_media_status": "oss_uploaded",
+                "raw_payload": {"oss_object_key": "video.mp4"},
+            }
+        ],
+    )
+    service.runtime.append_jsonl(
+        run_id,
+        "pattern_recall_evidence.jsonl",
+        [
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "recall_evidence_id": "evidence_001",
+                "platform_content_id": "douyin_001",
+                "decode_status": "ok",
+                "raw_payload": {},
+            }
+        ],
+    )
+    service.runtime.append_jsonl(
+        run_id,
+        "rule_decisions.jsonl",
+        [
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "decision_id": "decision_001",
+                "search_query_id": "q_001",
+                "decision_target_id": "content_001",
+                "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},
+                "raw_payload": {},
+            }
+        ],
+    )
+    service.runtime.append_jsonl(
+        run_id,
+        "walk_actions.jsonl",
+        [
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "walk_action_id": "walk_001",
+                "edge_id": "hashtag_to_query",
+                "from_node_type": "content",
+                "from_node_id": "douyin_001",
+                "to_node_id": "q_002",
+                "walk_status": "success",
+                "reason_code": "v4_query_and_platform_pass",
+                "raw_payload": {},
+            }
+        ],
+    )
+    service.runtime.write_json(
+        run_id,
+        "final_output.json",
+        {
+            "run_id": run_id,
+            "policy_run_id": policy_run_id,
+            "content_assets": [{"decision_id": "decision_001", "platform_content_id": "douyin_001"}],
+            "review_records": [],
+            "reject_records": [],
+            "summary": {},
+        },
+    )
+    monkeypatch.setattr(api, "service", service)
+    client = TestClient(api.app)
+
+    ledger = client.get(f"/runs/{run_id}/flow-ledger").json()
+    assert ledger["data_origin_label"] == "本地调试缓存"
+    assert len(ledger["rows"]) == 1
+    assert ledger["summary"]["first_round_query_count"] == 1
+    assert ledger["summary"]["extension_query_count"] == 1
+    row = ledger["rows"][0]
+    assert row["query"]["method_label"] == "Pattern 种子词"
+    assert row["first_round_video_total"] == 1
+    assert row["video_stats"]["oss_uploaded_count"] == 1
+    assert row["rule_summary"]["pool_count"] == 1
+    assert row["first_round_videos"][0]["decision"]["label"] == "入池"
+    assert row["first_round_videos"][0]["media_status_label"] == "已保存到 OSS"
+
+    videos = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/videos").json()
+    assert videos["summary"]["total"] == 1
+    assert videos["videos"][0]["oss_url"] == "https://oss.example.com/video.mp4"
+
+    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"] == "根据标签继续搜"
+
+    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"] == "入池"
+
+    service.runtime.append_jsonl(
+        run_id,
+        "search_queries.jsonl",
+        [
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "search_query_id": "q_003",
+                "search_query": "新搜索词",
+                "search_query_generation_method": "seed_term",
+                "raw_payload": {},
+            }
+        ],
+    )
+    cached = client.get(f"/runs/{run_id}/flow-ledger").json()
+    assert cached["summary"]["first_round_query_count"] == 1
+    debug_fresh = client.get(f"/runs/{run_id}/flow-ledger?debug=true").json()
+    assert debug_fresh["summary"]["first_round_query_count"] == 2

+ 15 - 9
web2/features/LedgerPage.tsx

@@ -10,6 +10,7 @@ import {
   actionLabel,
   assetSummaryText,
   clueText,
+  mediaStatusLabel,
   methodLabel,
   platformLabel,
   ruleBrief,
@@ -56,9 +57,10 @@ export function LedgerPage({ runId }: { runId: string }) {
 
   const headerSubtitle = useMemo(() => {
     if (!input) return "查看搜索词如何一路产生内容";
-    const videoCount = rows.reduce((sum, row) => sum + row.firstRoundVideos.length, 0);
-    const assetCount = rows.reduce((sum, row) => sum + row.finalAssets.assetCount, 0);
-    return `本次共生成 ${rows.length} 个搜索词,首轮找到 ${videoCount} 条视频,沉淀 ${assetCount} 条内容`;
+    const firstRound = Number(input.summary.first_round_query_count || rows.length);
+    const extension = Number(input.summary.extension_query_count || 0);
+    const videoCount = rows.reduce((sum, row) => sum + row.firstRoundVideoTotal, 0);
+    return `首轮 ${firstRound} 个搜索词,继续扩展 ${extension} 个搜索词,首轮找到 ${videoCount} 条视频`;
   }, [input, rows]);
 
   return (
@@ -72,9 +74,10 @@ export function LedgerPage({ runId }: { runId: string }) {
             <summary>本次流程摘要</summary>
             <div className="source-body">
               <span className="chip blue">搜索词 {rows.length}</span>
-              <span className="chip green">首轮视频 {rows.reduce((sum, row) => sum + row.firstRoundVideos.length, 0)}</span>
+              <span className="chip green">首轮视频 {rows.reduce((sum, row) => sum + row.firstRoundVideoTotal, 0)}</span>
               <span className="chip yellow">待复看 {rows.reduce((sum, row) => sum + row.ruleSummary.reviewCount, 0)}</span>
-              <span className="chip">已沉淀 {rows.reduce((sum, row) => sum + row.finalAssets.assetCount, 0)}</span>
+              <span className="chip">入池 {rows.reduce((sum, row) => sum + row.finalAssets.assetCount, 0)}</span>
+              <span className="chip blue">{ledger.dataOriginLabel}</span>
             </div>
           </details>
 
@@ -181,9 +184,12 @@ function VideoCell({ runId, row }: { runId: string; row: FlowLedgerRow }) {
         {!top.length ? <span className="muted">首轮没有搜到可展示的视频</span> : null}
       </div>
       <div className="cell-footer">
-        <span>共 {row.firstRoundVideos.length} 条</span>
-        <span>入选 {row.ruleSummary.passCount}</span>
+        <span>共 {row.firstRoundVideoTotal} 条</span>
+        <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`}>
@@ -200,7 +206,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)}</small>
+        <small>{actionLabel(video.decisionAction)} · {video.mediaStatusLabel}</small>
       </span>
     </Link>
   );
@@ -212,7 +218,7 @@ function RuleSummaryCell({ row }: { row: FlowLedgerRow }) {
       <div className="cell-stack">
         <strong>{ruleHeadline(row)}</strong>
         <div className="chip-row">
-          <span className="chip green">入 {row.ruleSummary.passCount}</span>
+          <span className="chip green">入 {row.ruleSummary.passCount}</span>
           <span className="chip yellow">待复看 {row.ruleSummary.reviewCount}</span>
           <span className="chip">淘汰 {row.ruleSummary.rejectCount}</span>
         </div>

+ 97 - 56
web2/features/QueryVideosPage.tsx

@@ -2,45 +2,60 @@
 
 import Link from "next/link";
 import { ExternalLink, PanelRightOpen } from "lucide-react";
-import { useState } from "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 { findLedgerRow } from "@/lib/flow-ledger/build";
-import { actionLabel, metricLabel, platformLabel, rowDecisionSections, sourceEvidence, videoDecisionSections, walkSummaryText } from "@/lib/flow-ledger/business";
-import { useFlowLedger } from "./useFlowLedger";
+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 { asRecord, text } from "@/lib/flow-ledger/format";
+import type { VideoRef } from "@/lib/flow-ledger/types";
 
 export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: string }) {
-  const { ledger, loading, error, reload } = useFlowLedger(runId);
+  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 row = ledger ? findLedgerRow(ledger, queryId) : undefined;
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const debug = new URLSearchParams(window.location.search).get("debug") === "1";
+      setData(await getFlowLedgerVideos(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 videos = useMemo(() => (data?.videos || []).map(videoFromApi), [data]);
 
   return (
-    <AppShell title="首轮搜到的视频" subtitle={row ? `搜索词:${row.query.text}` : "查看这个搜索词搜到了哪些内容"} runId={runId} showBack onRefresh={reload}>
+    <AppShell title="首轮搜到的视频" subtitle={query.text ? `搜索词:${text(query.text)}` : "查看这个搜索词搜到了哪些内容"} runId={runId} showBack onRefresh={load}>
       {loading ? <LoadingState /> : null}
       {error ? <ErrorState message={error} /> : null}
-      {!loading && !error && !row ? <EmptyState label="没有找到这个搜索词的首轮视频" /> : null}
-      {row ? (
+      {!loading && !error && !videos.length ? <EmptyState label="这个搜索词首轮没有搜到可展示的视频" /> : null}
+      {data ? (
         <>
           <details className="declarations" open>
             <summary>
-              <span className="kw">搜索词</span> <b>{row.query.text}</b>
-              <span className="decl-purpose">来源:{sourceEvidence(row)}</span>
-              <span className="tag-mini">首轮视频 {row.firstRoundVideos.length}</span>
+              <span className="kw">搜索词</span> <b>{text(query.text, "搜索词待确认")}</b>
+              <span className="decl-purpose">来源:{methodLabel(text(query.method, ""))}</span>
+              <span className="tag-mini">首轮视频 {Number(data.summary.total || videos.length)}</span>
             </summary>
             <div className="decl-body">
-              <div className="decl-section">
-                <div className="decl-label">判断汇总</div>
-                <div className="decl-row">入选 {row.ruleSummary.passCount} · 待复看 {row.ruleSummary.reviewCount} · 淘汰 {row.ruleSummary.rejectCount}</div>
-              </div>
-              <div className="decl-section">
-                <div className="decl-label">继续扩展</div>
-                <div className="decl-row">{walkSummaryText(row)}</div>
-              </div>
-              <button className="mini-button" type="button" onClick={() => setDrawer({ title: "本轮判断说明", sections: rowDecisionSections(row) })}>
-                <PanelRightOpen size={13} />
-                看整体原因
-              </button>
+              <Summary label="判断汇总" value={`入池 ${Number(data.summary.pool_count || 0)} · 待复看 ${Number(data.summary.review_count || 0)} · 淘汰 ${Number(data.summary.reject_count || 0)}`} />
+              <Summary label="视频保存" value={`${mediaStatusLabel("oss_uploaded")} ${Number(data.summary.oss_uploaded_count || 0)} · ${mediaStatusLabel("oss_upload_pending")} ${Number(data.summary.oss_pending_count || 0)}`} />
+              <Summary label="数据来源" value={data.data_origin_label} />
             </div>
           </details>
           <section className="detail-table-wrap">
@@ -52,42 +67,19 @@ export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: st
                   <th>作者 / 平台</th>
                   <th>互动</th>
                   <th>判断</th>
+                  <th>视频保存</th>
                   <th>操作</th>
                 </tr>
               </thead>
               <tbody>
-                {row.firstRoundVideos.map((video, index) => (
-                  <tr key={`${video.id}-${index}`}>
-                    <td>{index + 1}</td>
-                    <td>
-                      <Link className="title-link" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.id)}`}>
-                        {video.title}
-                      </Link>
-                    </td>
-                    <td>
-                      <strong>{video.author}</strong>
-                      <div className="muted">{platformLabel(video.platform)}</div>
-                    </td>
-                    <td>
-                      <span className="chip">{metricLabel(video)}</span>
-                    </td>
-                    <td>
-                      <span className="chip blue">{actionLabel(video.decisionAction)}</span>
-                      <div className="muted">点击右侧查看原因</div>
-                    </td>
-                    <td>
-                      <button className="mini-button" type="button" onClick={() => setDrawer({ title: "为什么这样判断", sections: videoDecisionSections(video) })}>
-                        <PanelRightOpen size={13} />
-                        看判断原因
-                      </button>
-                      {video.url ? (
-                        <a className="mini-button ghost" href={video.url} target="_blank" rel="noreferrer">
-                          <ExternalLink size={13} />
-                          原链
-                        </a>
-                      ) : null}
-                    </td>
-                  </tr>
+                {videos.map((video, index) => (
+                  <VideoRow
+                    key={`${video.id}-${index}`}
+                    index={index}
+                    runId={runId}
+                    video={video}
+                    onExplain={() => setDrawer({ title: "为什么这样判断", sections: videoDecisionSections(video) })}
+                  />
                 ))}
               </tbody>
             </table>
@@ -98,3 +90,52 @@ export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: st
     </AppShell>
   );
 }
+
+function Summary({ label, value }: { label: string; value: string }) {
+  return (
+    <div className="decl-section">
+      <div className="decl-label">{label}</div>
+      <div className="decl-row">{value}</div>
+    </div>
+  );
+}
+
+function VideoRow({ index, runId, video, onExplain }: { index: number; runId: string; video: VideoRef; onExplain: () => void }) {
+  return (
+    <tr>
+      <td>{index + 1}</td>
+      <td>
+        <Link className="title-link" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.id)}`}>
+          {video.title}
+        </Link>
+      </td>
+      <td>
+        <strong>{video.author}</strong>
+        <div className="muted">{platformLabel(video.platform)}</div>
+      </td>
+      <td>
+        <span className="chip">{metricLabel(video)}</span>
+      </td>
+      <td>
+        <span className="chip blue">{actionLabel(video.decisionAction)}</span>
+        <div className="muted">{video.decisionReasonLabel}</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}>
+          <PanelRightOpen size={13} />
+          看判断原因
+        </button>
+        {video.ossUrl ? (
+          <a className="mini-button ghost" href={video.ossUrl} target="_blank" rel="noreferrer">
+            <ExternalLink size={13} />
+            打开 OSS
+          </a>
+        ) : null}
+      </td>
+    </tr>
+  );
+}

+ 80 - 55
web2/features/QueryWalkPage.tsx

@@ -1,74 +1,80 @@
 "use client";
 
-import { useState } from "react";
+import { 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 { findLedgerRow, queryWalkActions } from "@/lib/flow-ledger/build";
-import {
-  reasonLabel,
-  sourceEvidence,
-  walkActionImpact,
-  walkActionReason,
-  walkActionSections,
-  walkActionSource,
-  walkActionTitle,
-  walkEdgeLabel,
-  WALK_LANE_KEYS,
-  walkStatusLabel,
-  walkSummaryText
-} from "@/lib/flow-ledger/business";
-import { text } from "@/lib/flow-ledger/format";
-import { useFlowLedger } from "./useFlowLedger";
+import { getFlowLedgerWalk } from "@/lib/api/client";
+import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
+import { methodLabel, type BusinessSection } 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 { ledger, loading, error, reload } = useFlowLedger(runId);
+  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 row = ledger ? findLedgerRow(ledger, queryId) : undefined;
-  const actions = row ? queryWalkActions(row) : [];
+
+  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 = data?.actions || [];
 
   return (
-    <AppShell title="继续扩展过程" subtitle={row ? `搜索词:${row.query.text}` : "查看系统有没有继续向外找内容"} runId={runId} showBack onRefresh={reload}>
+    <AppShell title="继续扩展过程" subtitle={query.text ? `搜索词:${text(query.text)}` : "查看系统有没有继续向外找内容"} runId={runId} showBack onRefresh={load}>
       {loading ? <LoadingState /> : null}
       {error ? <ErrorState message={error} /> : null}
-      {!loading && !error && !row ? <EmptyState label="没有找到这个搜索词的扩展过程" /> : null}
-      {row ? (
+      {!loading && !error && !actions.length ? <EmptyState label="这个搜索词本轮没有继续扩展" /> : null}
+      {data ? (
         <>
           <details className="declarations" open>
             <summary>
-              <span className="kw">扩展</span> <b>{row.query.text}</b>
-              <span className="decl-purpose">{sourceEvidence(row)}</span>
-              <span className="tag-mini">{row.walkSummary.total ? `动作 ${row.walkSummary.total}` : "没有继续扩展"}</span>
+              <span className="kw">扩展</span> <b>{text(query.text, "搜索词待确认")}</b>
+              <span className="decl-purpose">{methodLabel(text(query.method, ""))}</span>
+              <span className="tag-mini">{actions.length ? `动作 ${actions.length}` : "没有继续扩展"}</span>
             </summary>
             <div className="decl-body">
-              <div className="decl-section">
-                <div className="decl-label">扩展结论</div>
-                <div className="decl-row">{walkSummaryText(row)}</div>
-              </div>
-              <div className="decl-section">
-                <div className="decl-label">主要停止原因</div>
-                <div className="decl-row">
-                  {Object.entries(row.walkSummary.stopReasons).length
-                    ? Object.entries(row.walkSummary.stopReasons).map(([reason, count]) => `${reasonLabel(reason)} ${count} 次`).join(" · ")
-                    : "本轮没有记录停止原因"}
-                </div>
-              </div>
+              <Summary label="扩展结论" value={`${Number(data.summary.total_actions || 0)} 次动作,产生 ${Number(data.summary.extension_query_count || 0)} 个扩展搜索词`} />
+              <Summary label="执行情况" value={`已执行 ${Number(data.summary.success_count || 0)} · 未继续 ${Number(data.summary.skipped_count || 0)}`} />
+              <Summary label="数据来源" value={data.data_origin_label} />
             </div>
           </details>
           <section className="walk-lanes">
-            {WALK_LANE_KEYS.map((edge) => {
-              const lane = actions.filter((action) => text(action.edge_id || action.edge_type, "") === edge);
+            {LANES.map((lane) => {
+              const laneActions = data.lanes?.[lane.id] || [];
               return (
-                <div className="walk-lane" key={edge}>
-                  <div className="lane-head">{walkEdgeLabel(edge)} · {lane.length}</div>
-                  {lane.slice(0, 12).map((action, index) => (
-                    <button className="walk-node" type="button" key={`${edge}-${index}`} onClick={() => setDrawer({ title: walkActionTitle(action), sections: walkActionSections(action, row) })}>
-                      <b>{walkStatusLabel(text(action.walk_status, ""))}</b>
-                      <span>{walkActionReason(action)}</span>
-                      <small>{walkActionImpact(action)}</small>
+                <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>
                   ))}
-                  {!lane.length ? <span className="muted lane-empty">无动作</span> : null}
+                  {!laneActions.length ? <span className="muted lane-empty">无动作</span> : null}
                 </div>
               );
             })}
@@ -79,7 +85,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
                 <tr>
                   <th>发生了什么</th>
                   <th>从哪里来</th>
-                  <th>状态</th>
+                  <th>结果</th>
                   <th>为什么</th>
                   <th>对产出有什么影响</th>
                   <th>说明</th>
@@ -88,13 +94,13 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
               <tbody>
                 {actions.map((action, index) => (
                   <tr key={`walk-${index}`}>
-                    <td>{walkActionTitle(action)}</td>
-                    <td>{walkActionSource(action, row)}</td>
-                    <td><span className="chip blue">{walkStatusLabel(text(action.walk_status, ""))}</span></td>
-                    <td>{walkActionReason(action)}</td>
-                    <td>{walkActionImpact(action)}</td>
+                    <td>{text(action.edge_label, "扩展动作")}</td>
+                    <td>{text(action.from_label, "从上一环节出发")}</td>
+                    <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: walkActionTitle(action), sections: walkActionSections(action, row) })}>
+                      <button className="mini-button" type="button" onClick={() => setDrawer({ title: text(action.edge_label, "扩展动作"), sections: actionSections(action) })}>
                         看说明
                       </button>
                     </td>
@@ -109,3 +115,22 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
     </AppShell>
   );
 }
+
+function Summary({ label, value }: { label: string; value: string }) {
+  return (
+    <div className="decl-section">
+      <div className="decl-label">{label}</div>
+      <div className="decl-row">{value}</div>
+    </div>
+  );
+}
+
+function actionSections(action: RawRecord): BusinessSection[] {
+  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, "这一步暂不影响最终沉淀") }
+  ];
+}

+ 55 - 27
web2/features/VideoDetailPage.tsx

@@ -1,21 +1,44 @@
 "use client";
 
 import { ExternalLink, PanelRightOpen } from "lucide-react";
-import { useState } from "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 { findVideo } from "@/lib/flow-ledger/build";
-import { actionLabel, metricLabel, platformLabel, reasonLabel, scoreBusinessText, videoDecisionSections } from "@/lib/flow-ledger/business";
-import { useFlowLedger } from "./useFlowLedger";
+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";
 
 export function VideoDetailPage({ runId, contentId }: { runId: string; contentId: string }) {
-  const { input, loading, error, reload } = useFlowLedger(runId);
+  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 video = input ? findVideo(input, contentId) : undefined;
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const debug = new URLSearchParams(window.location.search).get("debug") === "1";
+      setData(await getFlowLedgerVideo(runId, contentId, debug));
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err));
+    } finally {
+      setLoading(false);
+    }
+  }, [contentId, runId]);
+
+  useEffect(() => {
+    void load();
+  }, [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={reload}>
+    <AppShell title="视频详情" subtitle="查看这条内容为什么入、淘汰或需要复看" runId={runId} showBack onRefresh={load}>
       {loading ? <LoadingState /> : null}
       {error ? <ErrorState message={error} /> : null}
       {!loading && !error && !video ? <EmptyState label="没有找到这个视频" /> : null}
@@ -28,21 +51,13 @@ export function VideoDetailPage({ runId, contentId }: { runId: string; contentId
               <span className="tag-mini">{actionLabel(video.decisionAction)}</span>
             </summary>
             <div className="decl-body">
-              <div className="decl-section">
-                <div className="decl-label">互动</div>
-                <div className="decl-row">{metricLabel(video)}</div>
-              </div>
-              <div className="decl-section">
-                <div className="decl-label">判断</div>
-                <div className="decl-row">
-                  {reasonLabel(video.decisionReason)}
-                  {scoreBusinessText(video.score) ? ` · ${scoreBusinessText(video.score)}` : ""}
-                </div>
-              </div>
-              <div className="decl-section">
-                <div className="decl-label">标签</div>
-                <div className="decl-row">{video.tags.length ? video.tags.join(" / ") : "无标签"}</div>
-              </div>
+              <Summary label="互动" value={metricLabel(video)} />
+              <Summary
+                label="判断"
+                value={`${reasonLabel(video.decisionReason)}${scoreBusinessText(video.score) ? ` · ${scoreBusinessText(video.score)}` : ""}`}
+              />
+              <Summary label="视频保存" value={`${video.mediaStatusLabel || mediaStatusLabel(video.mediaStatus)}${video.mediaFailureReason ? ` · ${video.mediaFailureReason}` : ""}`} />
+              <Summary label="标签" value={video.tags.length ? video.tags.join(" / ") : "无标签"} />
             </div>
           </details>
           <section className="video-detail-grid">
@@ -52,17 +67,21 @@ export function VideoDetailPage({ runId, contentId }: { runId: string; contentId
               <div className="chip-row">
                 <span className="chip">作者:{video.author}</span>
                 <span className="chip blue">{metricLabel(video)}</span>
+                <span className={`chip ${video.mediaStatus === "oss_uploaded" ? "green" : "yellow"}`}>{video.mediaStatusLabel}</span>
               </div>
-              {video.url ? (
-                <a className="mini-button" href={video.url} target="_blank" rel="noreferrer">
+              {video.ossUrl ? (
+                <a className="mini-button" href={video.ossUrl} target="_blank" rel="noreferrer">
                   <ExternalLink size={13} />
-                  打开原链
+                  打开 OSS 视频
                 </a>
-              ) : null}
+              ) : (
+                <span className="muted">视频暂未保存到 OSS,等待补传后可打开。</span>
+              )}
             </div>
             <div className="video-panel">
               <h2>判断证据</h2>
-              <p>{actionLabel(video.decisionAction)} · {reasonLabel(video.decisionReason)}</p>
+              <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} />
                 看判断原因
@@ -75,3 +94,12 @@ export function VideoDetailPage({ runId, contentId }: { runId: string; contentId
     </AppShell>
   );
 }
+
+function Summary({ label, value }: { label: string; value: string }) {
+  return (
+    <div className="decl-section">
+      <div className="decl-label">{label}</div>
+      <div className="decl-row">{value}</div>
+    </div>
+  );
+}

+ 4 - 4
web2/features/useFlowLedger.ts

@@ -1,13 +1,13 @@
 "use client";
 
 import { useCallback, useEffect, useMemo, useState } from "react";
-import { getFlowInput } from "@/lib/api/client";
-import type { FlowInput } from "@/lib/api/types";
+import { getFlowLedger } from "@/lib/api/client";
+import type { FlowLedgerApiResponse } from "@/lib/api/types";
 import { buildFlowLedger } from "@/lib/flow-ledger/build";
 import type { FlowLedger } from "@/lib/flow-ledger/types";
 
 export function useFlowLedger(runId: string) {
-  const [input, setInput] = useState<FlowInput | null>(null);
+  const [input, setInput] = useState<FlowLedgerApiResponse | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
 
@@ -15,7 +15,7 @@ export function useFlowLedger(runId: string) {
     setLoading(true);
     setError(null);
     try {
-      setInput(await getFlowInput(runId));
+      setInput(await getFlowLedger(runId, new URLSearchParams(window.location.search).get("debug") === "1"));
     } catch (err) {
       setError(err instanceof Error ? err.message : String(err));
     } finally {

+ 27 - 50
web2/lib/api/client.ts

@@ -1,14 +1,16 @@
 import type {
   ContentItemsResponse,
   DashboardResponse,
+  FlowLedgerApiResponse,
+  FlowLedgerVideoResponse,
+  FlowLedgerVideosResponse,
+  FlowLedgerWalkResponse,
   QueryListResponse,
-  RawRecord,
   RunListResponse,
-  RuntimeFileResponse,
   TimelineResponse
 } from "./types";
 
-const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
+const DEFAULT_API_BASE_URL = "http://47.245.103.121:8000";
 
 export class ApiError extends Error {
   status: number;
@@ -47,16 +49,6 @@ async function request<T>(path: string): Promise<T> {
   return response.json() as Promise<T>;
 }
 
-async function requestOptionalRuntimeFile(runId: string, filename: string, limit = 500): Promise<RawRecord[]> {
-  try {
-    const file = await getRuntimeFile(runId, filename, limit);
-    return file.records || [];
-  } catch (error) {
-    if (error instanceof ApiError && error.status === 404) return [];
-    throw error;
-  }
-}
-
 export function listRuns(params = new URLSearchParams()) {
   const query = params.toString();
   return request<RunListResponse>(`/runs${query ? `?${query}` : ""}`);
@@ -78,45 +70,30 @@ export function getContentItems(runId: string) {
   return request<ContentItemsResponse>(`/runs/${encodeURIComponent(runId)}/content-items`);
 }
 
-export function getRuntimeFile(runId: string, filename: string, limit = 500) {
-  const safeFilename = encodeURIComponent(filename);
-  return request<RuntimeFileResponse>(
-    `/runs/${encodeURIComponent(runId)}/runtime-files/${safeFilename}?limit=${limit}`
+function debugSuffix(debug = false) {
+  return debug ? "?debug=true" : "";
+}
+
+export function getFlowLedger(runId: string, debug = false) {
+  return request<FlowLedgerApiResponse>(
+    `/runs/${encodeURIComponent(runId)}/flow-ledger${debugSuffix(debug)}`
   );
 }
 
-export async function getFlowInput(runId: string) {
-  const [
-    dashboard,
-    queries,
-    contentItems,
-    timeline,
-    searchQueries,
-    searchClues,
-    ruleDecisions,
-    walkActions,
-    sourcePathRecords
-  ] = await Promise.all([
-    getDashboard(runId),
-    getQueries(runId),
-    getContentItems(runId),
-    getTimeline(runId),
-    requestOptionalRuntimeFile(runId, "search_queries.jsonl"),
-    requestOptionalRuntimeFile(runId, "search_clues.jsonl"),
-    requestOptionalRuntimeFile(runId, "rule_decisions.jsonl"),
-    requestOptionalRuntimeFile(runId, "walk_actions.jsonl"),
-    requestOptionalRuntimeFile(runId, "source_path_records.jsonl")
-  ]);
+export function getFlowLedgerVideos(runId: string, queryId: string, debug = false) {
+  return request<FlowLedgerVideosResponse>(
+    `/runs/${encodeURIComponent(runId)}/flow-ledger/queries/${encodeURIComponent(queryId)}/videos${debugSuffix(debug)}`
+  );
+}
 
-  return {
-    dashboard,
-    queries,
-    contentItems,
-    timeline,
-    searchQueries,
-    searchClues,
-    ruleDecisions,
-    walkActions,
-    sourcePathRecords
-  };
+export function getFlowLedgerWalk(runId: string, queryId: string, debug = false) {
+  return request<FlowLedgerWalkResponse>(
+    `/runs/${encodeURIComponent(runId)}/flow-ledger/queries/${encodeURIComponent(queryId)}/walk${debugSuffix(debug)}`
+  );
+}
+
+export function getFlowLedgerVideo(runId: string, contentId: string, debug = false) {
+  return request<FlowLedgerVideoResponse>(
+    `/runs/${encodeURIComponent(runId)}/flow-ledger/videos/${encodeURIComponent(contentId)}${debugSuffix(debug)}`
+  );
 }

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

@@ -94,3 +94,42 @@ export type FlowInput = {
   walkActions: RawRecord[];
   sourcePathRecords: RawRecord[];
 };
+
+export type FlowLedgerApiResponse = {
+  run_id: string;
+  data_origin: DataOrigin;
+  data_origin_label: string;
+  summary: RawRecord;
+  rows: RawRecord[];
+  extension_queries: RawRecord[];
+  debug?: RawRecord | null;
+};
+
+export type FlowLedgerVideosResponse = {
+  run_id: string;
+  query?: RawRecord | null;
+  videos: RawRecord[];
+  summary: RawRecord;
+  data_origin: DataOrigin;
+  data_origin_label: string;
+};
+
+export type FlowLedgerWalkResponse = {
+  run_id: string;
+  query?: RawRecord | null;
+  actions: RawRecord[];
+  lanes: Record<string, RawRecord[]>;
+  extension_queries: RawRecord[];
+  summary: RawRecord;
+  data_origin: DataOrigin;
+  data_origin_label: string;
+};
+
+export type FlowLedgerVideoResponse = {
+  run_id: string;
+  video?: RawRecord | null;
+  source_paths: RawRecord[];
+  decision?: RawRecord | null;
+  data_origin: DataOrigin;
+  data_origin_label: string;
+};

+ 103 - 225
web2/lib/flow-ledger/build.ts

@@ -1,217 +1,118 @@
-import type { FlowInput, RawRecord } from "@/lib/api/types";
-import type { AssetSummary, FlowLedger, FlowLedgerRow, QuerySummary, RuleSummary, SourceSummary, VideoRef, WalkSummary } from "./types";
-import { asRecord, countBy, maybeText, scoreLabel, text, textList } from "./format";
+import type { FlowLedgerApiResponse, RawRecord } from "@/lib/api/types";
+import type { FlowLedger, FlowLedgerRow, VideoRef } from "./types";
+import { asRecord, maybeText, scoreLabel, text, textList } from "./format";
 
-function uniqueRecords(records: RawRecord[], getId: (row: RawRecord) => string): RawRecord[] {
-  const seen = new Set<string>();
-  const result: RawRecord[] = [];
-  records.forEach((row) => {
-    const id = getId(row);
-    if (!id || seen.has(id)) return;
-    seen.add(id);
-    result.push(row);
-  });
-  return result;
+function countMap(value: unknown): Record<string, number> {
+  const row = asRecord(value);
+  return Object.fromEntries(Object.entries(row).map(([key, item]) => [key, Number(item) || 0]));
 }
 
-function queryId(row: RawRecord): string {
-  return text(row.search_query_id, "");
-}
-
-function contentIds(row: RawRecord): string[] {
-  return [
-    maybeText(row.content_discovery_id),
-    maybeText(row.platform_content_id),
-    maybeText(row.decision_target_id)
-  ].filter(Boolean) as string[];
-}
-
-function isFirstRoundContent(row: RawRecord, qid: string): boolean {
-  const own = row.search_query_id === qid || textList(row.matched_search_query_ids).includes(qid);
-  if (!own) return false;
-  const previous = text(row.previous_discovery_step, "");
-  if (!previous) return true;
-  return !/tag|author|next_page|walk|pagination/i.test(previous);
-}
-
-function queryRelatedContentIds(contents: RawRecord[], qid: string): Set<string> {
-  const ids = new Set<string>();
-  contents.filter((row) => isFirstRoundContent(row, qid)).forEach((row) => {
-    contentIds(row).forEach((id) => ids.add(id));
-  });
-  return ids;
-}
-
-function decisionBelongsToQuery(decision: RawRecord, qid: string, relatedContentIds: Set<string>): boolean {
-  if (decision.search_query_id === qid) return true;
-  const sourceEvidence = asRecord(decision.source_evidence);
-  if (sourceEvidence.search_query_id === qid) return true;
-  const target = text(decision.decision_target_id, "");
-  return Boolean(target && relatedContentIds.has(target));
-}
-
-function decisionForContent(decisions: RawRecord[], content: RawRecord): RawRecord | undefined {
-  const ids = new Set(contentIds(content));
-  return decisions.find((decision) => {
-    const target = text(decision.decision_target_id, "");
-    return Boolean(target && ids.has(target));
-  });
-}
-
-function sourceSummary(query: RawRecord, sourcePaths: RawRecord[]): SourceSummary {
-  const seedRef = asRecord(query.pattern_seed_ref);
-  const path = sourcePaths.find(
-    (row) => row.source_path_type === "pattern_to_search_query" && row.to_node_id === query.search_query_id
-  );
-  const sourceKind = text(query.discovery_start_source || seedRef.source_kind || path?.discovery_start_source, "来源待确认");
-  const sourceRef =
-    maybeText(seedRef.source_post_id) ||
-    maybeText(seedRef.pattern_execution_id) ||
-    maybeText(path?.source_evidence_ref) ||
-    "source ref 待确认";
-  const terms = textList(query.query_source_terms);
-  const evidenceParts = [
-    maybeText(seedRef.seed_term),
-    terms.length ? terms.join(" / ") : undefined
-  ].filter(Boolean);
+function videoRef(row: RawRecord): VideoRef {
+  const decision = asRecord(row.decision);
   return {
-    sourceKind,
-    evidenceLabel: evidenceParts.join(" / ") || "来源证据待确认",
-    sourceRef,
-    terms,
-    raw: seedRef
+    id: text(row.id, "content"),
+    contentDiscoveryId: text(row.content_discovery_id, ""),
+    platformContentId: text(row.platform_content_id, ""),
+    title: text(row.title, "无标题视频"),
+    author: text(row.author, "未知作者"),
+    platform: text(row.platform, "unknown"),
+    platformLabel: text(row.platform_label, ""),
+    url: maybeText(row.playable_url) || maybeText(row.oss_url) || null,
+    ossUrl: maybeText(row.oss_url) || null,
+    mediaStatus: text(row.media_status, "unavailable"),
+    mediaStatusLabel: text(row.media_status_label, "视频状态待确认"),
+    mediaFailureReason: maybeText(row.media_failure_reason),
+    tags: textList(row.tags),
+    statistics: countMap(row.statistics),
+    decisionAction: text(decision.action, "NOT_JUDGED"),
+    decisionLabel: text(decision.label, "未进入判断"),
+    decisionReason: text(decision.reason, "no_decision"),
+    decisionReasonLabel: text(decision.reason_label, "暂未产生判断结果"),
+    score: scoreLabel(decision.score),
+    raw: row,
+    decision,
+    gemini: asRecord(row.gemini)
   };
 }
 
-function querySummary(query: RawRecord, clue?: RawRecord): QuerySummary {
+function rowFromApi(row: RawRecord): FlowLedgerRow {
+  const source = asRecord(row.source);
+  const query = asRecord(row.query);
+  const rule = asRecord(row.rule_summary);
+  const walk = asRecord(row.walk_summary);
+  const asset = asRecord(row.asset_summary);
+  const videoStats = countMap(row.video_stats);
+  const videos = (Array.isArray(row.first_round_videos) ? row.first_round_videos : [])
+    .filter((item): item is RawRecord => typeof item === "object" && item !== null)
+    .map(videoRef);
   return {
-    id: text(query.search_query_id, ""),
-    text: text(query.search_query, "搜索词待确认"),
-    method: text(query.search_query_generation_method, "unknown"),
-    sourceTerms: textList(query.query_source_terms),
-    effectStatus: text(clue?.search_query_effect_status || query.search_query_effect_status, "pending"),
-    raw: query,
-    clue
-  };
-}
-
-function videoRef(content: RawRecord, decision?: RawRecord): VideoRef {
-  const stats = asRecord(content.statistics);
-  return {
-    id: text(content.platform_content_id || content.content_discovery_id, "content"),
-    contentDiscoveryId: text(content.content_discovery_id, ""),
-    platformContentId: text(content.platform_content_id, ""),
-    title: text(content.description || content.title, "无标题视频"),
-    author: text(content.author_display_name || content.platform_author_id, "未知作者"),
-    platform: text(content.platform, "unknown"),
-    url: maybeText(content.platform_content_url) || null,
-    tags: textList(content.tags),
-    statistics: Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, Number(value) || 0])),
-    decisionAction: text(decision?.decision_action, "未判断"),
-    decisionReason: text(decision?.decision_reason_code, "no_decision"),
-    score: scoreLabel(decision?.score ?? asRecord(decision?.scorecard).total_score),
-    raw: content,
-    decision
-  };
-}
-
-function ruleSummary(decisions: RawRecord[]): RuleSummary {
-  const actionCounts = countBy(decisions, (row) => text(row.decision_action, "unknown"));
-  const reasons = countBy(decisions, (row) => maybeText(row.decision_reason_code));
-  const primaryReason = Object.entries(reasons).sort((a, b) => b[1] - a[1])[0]?.[0] || "无判断原因";
-  const scores = decisions.map((row) => row.score ?? asRecord(row.scorecard).total_score).filter((value) => value != null);
-  const numeric = scores.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
-  const scoreText = numeric.length ? `${Math.round(numeric.reduce((a, b) => a + b, 0) / numeric.length)} 平均分` : "无分数";
-  return {
-    total: decisions.length,
-    passCount: actionCounts.ADD_CONTENT_TO_POOL || 0,
-    reviewCount: actionCounts.KEEP_CONTENT_FOR_REVIEW || 0,
-    rejectCount: actionCounts.REJECT_CONTENT || 0,
-    unknownCount: actionCounts.unknown || 0,
-    primaryReason,
-    scoreLabel: scoreText,
-    decisions
-  };
-}
-
-function walkSummary(qid: string, relatedContentIds: Set<string>, actions: RawRecord[]): WalkSummary {
-  const related = actions.filter((action) => {
-    if (action.from_node_id === qid || action.to_node_id === qid) return true;
-    const from = text(action.from_node_id, "");
-    const to = text(action.to_node_id, "");
-    const target = text(action.decision_target_id, "");
-    return Boolean((from && relatedContentIds.has(from)) || (to && relatedContentIds.has(to)) || (target && relatedContentIds.has(target)));
-  });
-  const depths = related.map((row) => Number(row.depth)).filter((n) => Number.isFinite(n));
-  return {
-    total: related.length,
-    maxDepth: depths.length ? Math.max(...depths) : 0,
-    edgeCounts: countBy(related, (row) => text(row.edge_id || row.edge_type, "unknown")),
-    statusCounts: countBy(related, (row) => text(row.walk_status, "unknown")),
-    stopReasons: countBy(related, (row) => maybeText(row.reason_code)),
-    actions: related
-  };
-}
-
-function assetSummary(decisions: RawRecord[], sourcePaths: RawRecord[], finalSummary: RawRecord): AssetSummary {
-  const decisionIds = new Set(decisions.map((row) => text(row.decision_id, "")).filter(Boolean));
-  const paths = sourcePaths.filter((path) => {
-    const type = text(path.source_path_type, "");
-    if (type !== "decision_to_asset") return false;
-    const from = text(path.from_node_id, "");
-    const decision = text(path.decision_id, "");
-    return Boolean((from && decisionIds.has(from)) || (decision && decisionIds.has(decision)));
-  });
-  const finalCount = Number(finalSummary.asset_count || finalSummary.content_asset_count || finalSummary.record_count || 0) || 0;
-  return {
-    assetCount: paths.length || decisions.filter((row) => row.decision_action === "ADD_CONTENT_TO_POOL").length,
-    finalCount,
-    pathCount: paths.length,
-    paths
+    id: text(row.id, text(query.id, "")),
+    source: {
+      sourceKind: text(source.type, "来源待确认"),
+      evidenceLabel: text(source.evidence, "来源证据待确认"),
+      sourceRef: text(source.source_ref, "来源编号待确认"),
+      terms: textList(source.terms),
+      raw: source
+    },
+    query: {
+      id: text(query.id, ""),
+      text: text(query.text, "搜索词待确认"),
+      method: text(query.method, "unknown"),
+      sourceTerms: textList(query.source_terms),
+      effectStatus: text(query.effect_status, "pending"),
+      raw: query
+    },
+    firstRoundVideos: videos,
+    firstRoundVideoTotal: Number(row.first_round_video_total || videos.length),
+    videoStats,
+    duplicateCount: Math.max(0, Number(row.first_round_video_total || 0) - Number(videoStats.total || videos.length)),
+    ruleSummary: {
+      total: Number(rule.total || 0),
+      passCount: Number(rule.pool_count || 0),
+      reviewCount: Number(rule.review_count || 0),
+      rejectCount: Number(rule.reject_count || 0),
+      unknownCount: 0,
+      primaryReason: text(rule.primary_reason, "no_decision"),
+      primaryReasonLabel: text(rule.primary_reason_label, "暂未产生判断结果"),
+      scoreLabel: "",
+      decisions: Array.isArray(asRecord(rule.debug).decisions) ? (asRecord(rule.debug).decisions as RawRecord[]) : [],
+      headline: text(rule.headline, "")
+    },
+    walkSummary: {
+      total: Number(walk.total || 0),
+      maxDepth: 0,
+      edgeCounts: countMap(walk.edge_counts),
+      statusCounts: {},
+      stopReasons: walk.stop_reason ? { [text(walk.stop_reason, "")]: 1 } : {},
+      actions: []
+    },
+    finalAssets: {
+      assetCount: Number(asset.pool_count || 0),
+      finalCount: Number(asset.total || 0),
+      pathCount: 0,
+      paths: [],
+      reviewCount: Number(asset.review_count || 0),
+      rejectCount: Number(asset.reject_count || 0),
+      headline: text(asset.headline, "")
+    }
   };
 }
 
-export function buildFlowLedger(input: FlowInput): FlowLedger {
-  const queryRecords = uniqueRecords(
-    [...input.searchQueries, ...input.queries.items],
-    (row) => queryId(row)
-  ).filter((row) => queryId(row));
-  const cluesByQuery = new Map(input.searchClues.map((row) => [text(row.search_query_id, ""), row]));
-  input.queries.items.forEach((row) => {
-    const clue = asRecord(row.search_clue);
-    const id = text(row.search_query_id, "");
-    if (id && Object.keys(clue).length && !cluesByQuery.has(id)) cluesByQuery.set(id, clue);
-  });
-
-  const rows: FlowLedgerRow[] = queryRecords.map((query) => {
-    const id = queryId(query);
-    const relatedContentIds = queryRelatedContentIds(input.contentItems.items, id);
-    const videos = input.contentItems.items
-      .filter((content) => isFirstRoundContent(content, id))
-      .map((content) => videoRef(content, decisionForContent(input.ruleDecisions, content)));
-    const decisions = input.ruleDecisions.filter((decision) => decisionBelongsToQuery(decision, id, relatedContentIds));
-    const duplicateCount = Math.max(0, videos.length - new Set(videos.map((video) => video.platformContentId || video.contentDiscoveryId)).size);
-    return {
-      id,
-      source: sourceSummary(query, input.sourcePathRecords),
-      query: querySummary(query, cluesByQuery.get(id)),
-      firstRoundVideos: videos,
-      duplicateCount,
-      ruleSummary: ruleSummary(decisions),
-      walkSummary: walkSummary(id, relatedContentIds, input.walkActions),
-      finalAssets: assetSummary(decisions, input.sourcePathRecords, input.dashboard.final_output_summary || {})
-    };
-  });
-
+export function buildFlowLedger(input: FlowLedgerApiResponse): FlowLedger {
+  const rows = (input.rows || []).map(rowFromApi);
   return {
-    runId: input.dashboard.run_id,
+    runId: input.run_id,
+    dataOrigin: input.data_origin,
+    dataOriginLabel: input.data_origin_label,
+    summary: input.summary || {},
+    extensionQueries: input.extension_queries || [],
     rows,
     raw: {
-      queries: queryRecords,
-      contentItems: input.contentItems.items,
-      decisions: input.ruleDecisions,
-      walkActions: input.walkActions,
-      sourcePaths: input.sourcePathRecords
+      queries: [],
+      contentItems: [],
+      decisions: [],
+      walkActions: [],
+      sourcePaths: []
     }
   };
 }
@@ -220,29 +121,6 @@ export function findLedgerRow(ledger: FlowLedger, queryId: string): FlowLedgerRo
   return ledger.rows.find((row) => row.id === queryId);
 }
 
-export function findVideo(input: FlowInput, contentId: string): VideoRef | undefined {
-  const content = input.contentItems.items.find((item) => {
-    return [item.platform_content_id, item.content_discovery_id].some((id) => text(id, "") === contentId);
-  });
-  if (!content) return undefined;
-  return videoRef(content, decisionForContent(input.ruleDecisions, content));
-}
-
-export function queryWalkActions(row: FlowLedgerRow): RawRecord[] {
-  return row.walkSummary.actions;
-}
-
-export function evidenceObject(row: FlowLedgerRow): RawRecord {
-  return {
-    source: row.source.raw,
-    query: row.query.raw,
-    clue: row.query.clue,
-    first_round_videos: row.firstRoundVideos.map((video) => ({
-      content: video.raw,
-      decision: video.decision
-    })),
-    decisions: row.ruleSummary.decisions,
-    walk_actions: row.walkSummary.actions,
-    asset_paths: row.finalAssets.paths
-  };
+export function videoFromApi(row: RawRecord): VideoRef {
+  return videoRef(row);
 }

+ 27 - 29
web2/lib/flow-ledger/business.ts

@@ -14,43 +14,27 @@ export type BusinessSection = {
 const UNKNOWN = "系统原因待补充";
 
 const ACTION_LABELS: Record<string, string> = {
-  ADD_CONTENT_TO_POOL: "入选",
+  ADD_TO_CONTENT_POOL: "入池",
   KEEP_CONTENT_FOR_REVIEW: "待复看",
   REJECT_CONTENT: "淘汰",
+  NOT_JUDGED: "未进入判断",
   未判断: "未进入判断"
 };
 
 const ACTION_RESULT: Record<string, string> = {
-  ADD_CONTENT_TO_POOL: "这条内容可以进入后续沉淀。",
+  ADD_TO_CONTENT_POOL: "这条内容可以进入后续沉淀。",
   KEEP_CONTENT_FOR_REVIEW: "这条内容需要人工再看一眼。",
   REJECT_CONTENT: "这条内容不建议继续使用。",
+  NOT_JUDGED: "系统还没有给这条内容做判断。",
   未判断: "系统还没有给这条内容做判断。"
 };
 
 const REASON_LABELS: Record<string, string> = {
-  content_pattern_recall_required: "内容和本次需求没有形成有效关联",
-  blocked_by_rule_decision: "上一步内容未通过判断",
-  missing_content_portrait: "缺少判断所需的内容画像",
-  category_or_element_binding_required: "没有命中本次需求的类目或要素",
-  missing_score: "没有足够信息进入打分",
-  v4_query_and_platform_pass: "搜索词和平台表现都符合要求",
-  v4_query_or_score_below_threshold: "相关性或平台表现未达到要求",
-  v4_technical_retry_needed: "系统需要重试后再判断",
-  v4_allow_walk_denied: "不适合继续向外扩展",
   no_decision: "暂未产生判断结果",
   无原因: "暂未产生判断原因"
 };
 
 const REASON_IMPACT: Record<string, string> = {
-  content_pattern_recall_required: "不会用它继续扩展,也不会进入最终沉淀。",
-  blocked_by_rule_decision: "这条扩展链路到这里停止,避免把低相关内容继续放大。",
-  missing_content_portrait: "需要补齐互动、作者或画像数据后,才能做稳定判断。",
-  category_or_element_binding_required: "建议回到需求词或分类条件,检查本次搜索方向是否过宽。",
-  missing_score: "系统先被基础条件拦下,所以没有展示分数。",
-  v4_query_and_platform_pass: "可以作为候选内容继续流转。",
-  v4_query_or_score_below_threshold: "建议换搜索词或降低对这类内容的依赖。",
-  v4_technical_retry_needed: "建议稍后重跑或交给研发排查采集状态。",
-  v4_allow_walk_denied: "本条内容不再带出更多视频。",
   no_decision: "当前只作为发现记录展示,暂不影响最终沉淀。",
   无原因: "当前只作为发现记录展示,暂不影响最终沉淀。"
 };
@@ -66,8 +50,13 @@ const SOURCE_LABELS: Record<string, string> = {
 };
 
 const METHOD_LABELS: Record<string, string> = {
+  seed_term: "Pattern 种子词",
+  piaoquan_topic_point: "票圈内容点",
+  category_leaf_element: "分类叶子元素",
+  query_next_page: "翻页继续找",
+  tag_query: "根据标签继续搜",
+  author_works: "查看作者更多作品",
   item_single: "使用原始需求词",
-  llm_variant: "系统扩写后的搜索词",
   manual: "人工指定搜索词",
   unknown: "生成方式待确认"
 };
@@ -82,8 +71,6 @@ const EFFECT_LABELS: Record<string, string> = {
 
 const WALK_EDGE_LABELS: Record<string, string> = {
   query_next_page: "翻下一页找更多",
-  hashtag_to_query: "根据视频标签继续搜",
-  video_to_author: "查看作者更多作品",
   path_stop: "到这里停止",
   tag_query: "根据视频标签继续搜",
   author_works: "查看作者更多作品",
@@ -92,8 +79,8 @@ const WALK_EDGE_LABELS: Record<string, string> = {
 
 export const WALK_LANE_KEYS = [
   "query_next_page",
-  "hashtag_to_query",
-  "video_to_author",
+  "tag_query",
+  "author_works",
   "path_stop"
 ];
 
@@ -117,7 +104,7 @@ export function actionLabel(value: string): string {
 }
 
 export function actionTone(value: string): BusinessTone {
-  if (value === "ADD_CONTENT_TO_POOL") return "good";
+  if (value === "ADD_TO_CONTENT_POOL") return "good";
   if (value === "KEEP_CONTENT_FOR_REVIEW") return "warn";
   if (value === "REJECT_CONTENT") return "bad";
   return "neutral";
@@ -161,6 +148,16 @@ export function platformLabel(value?: string | null): string {
   return PLATFORM_LABELS[value.toLowerCase()] || value;
 }
 
+export function mediaStatusLabel(value?: string | null): string {
+  const labels: Record<string, string> = {
+    oss_uploaded: "已保存到 OSS",
+    oss_upload_pending: "待补传",
+    metadata_only: "尚未拿到视频",
+    unavailable: "无可用视频"
+  };
+  return value ? labels[value] || "视频状态待确认" : "视频状态待确认";
+}
+
 export function metricLabel(video: VideoRef): string {
   const digg = video.statistics.digg_count || video.statistics.like_count || 0;
   const comment = video.statistics.comment_count || 0;
@@ -184,7 +181,7 @@ export function ruleHeadline(row: FlowLedgerRow): string {
 
 export function rulePrimaryReason(row: FlowLedgerRow): string {
   if (!row.ruleSummary.total) return "还没有产生判断结果";
-  return reasonLabel(row.ruleSummary.primaryReason);
+  return row.ruleSummary.primaryReasonLabel || reasonLabel(row.ruleSummary.primaryReason);
 }
 
 export function ruleBrief(row: FlowLedgerRow): string {
@@ -204,7 +201,7 @@ export function videoDecisionSections(video: VideoRef): BusinessSection[] {
     },
     {
       label: "为什么这样判断",
-      value: reasonLabel(video.decisionReason)
+      value: video.decisionReasonLabel || reasonLabel(video.decisionReason)
     },
     {
       label: "对流程的影响",
@@ -216,6 +213,7 @@ export function videoDecisionSections(video: VideoRef): BusinessSection[] {
         `视频标题:${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[]
@@ -241,7 +239,7 @@ export function rowDecisionSections(row: FlowLedgerRow): BusinessSection[] {
     {
       label: "判断分布",
       items: [
-        `入 ${row.ruleSummary.passCount} 条`,
+        `入 ${row.ruleSummary.passCount} 条`,
         `待复看 ${row.ruleSummary.reviewCount} 条`,
         `淘汰 ${row.ruleSummary.rejectCount} 条`
       ]

+ 4 - 2
web2/lib/flow-ledger/format.ts

@@ -42,15 +42,17 @@ export function compactCountMap(counts: Record<string, number>, limit = 3): stri
 
 export function label(value: string): string {
   const labels: Record<string, string> = {
+    seed_term: "Pattern 种子词",
+    piaoquan_topic_point: "票圈内容点",
+    category_leaf_element: "分类叶子元素",
     item_single: "使用原始需求词",
-    llm_variant: "系统扩写搜索词",
     tag_query: "根据标签继续搜",
     author_works: "作者作品",
     query_next_page: "翻页",
     search_query_direct: "首轮搜索",
     pattern_itemset: "来自需求 Pattern",
     pattern_search_query: "来自需求 Pattern",
-    ADD_CONTENT_TO_POOL: "入选",
+    ADD_TO_CONTENT_POOL: "入池",
     KEEP_CONTENT_FOR_REVIEW: "待复看",
     REJECT_CONTENT: "淘汰",
     success: "成功",

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

@@ -9,14 +9,22 @@ export type VideoRef = {
   title: string;
   author: string;
   platform: string;
+  platformLabel: string;
   url?: string | null;
+  ossUrl?: string | null;
+  mediaStatus: string;
+  mediaStatusLabel: string;
+  mediaFailureReason?: string;
   tags: string[];
   statistics: CountMap;
   decisionAction: string;
+  decisionLabel: string;
   decisionReason: string;
+  decisionReasonLabel: string;
   score?: string;
   raw: RawRecord;
   decision?: RawRecord;
+  gemini?: RawRecord;
 };
 
 export type SourceSummary = {
@@ -44,8 +52,10 @@ export type RuleSummary = {
   rejectCount: number;
   unknownCount: number;
   primaryReason: string;
+  primaryReasonLabel: string;
   scoreLabel: string;
   decisions: RawRecord[];
+  headline?: string;
 };
 
 export type WalkSummary = {
@@ -62,6 +72,9 @@ export type AssetSummary = {
   finalCount: number;
   pathCount: number;
   paths: RawRecord[];
+  reviewCount?: number;
+  rejectCount?: number;
+  headline?: string;
 };
 
 export type FlowLedgerRow = {
@@ -69,6 +82,8 @@ export type FlowLedgerRow = {
   source: SourceSummary;
   query: QuerySummary;
   firstRoundVideos: VideoRef[];
+  firstRoundVideoTotal: number;
+  videoStats: CountMap;
   duplicateCount: number;
   ruleSummary: RuleSummary;
   walkSummary: WalkSummary;
@@ -77,6 +92,10 @@ export type FlowLedgerRow = {
 
 export type FlowLedger = {
   runId: string;
+  dataOrigin: string;
+  dataOriginLabel: string;
+  summary: RawRecord;
+  extensionQueries: RawRecord[];
   rows: FlowLedgerRow[];
   raw: {
     queries: RawRecord[];