Przeglądaj źródła

Show technical retry details in web ledger

Sam Lee 3 tygodni temu
rodzic
commit
b43dd6b3e4

+ 145 - 0
content_agent/flow_ledger_service.py

@@ -406,6 +406,7 @@ class FlowLedgerService:
             "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,
+            "technical_retry_detail": _technical_retry_detail(decision, evidence, media),
             "debug": {"content": content, "media": media, "evidence": evidence} if debug else None,
         }
 
@@ -705,6 +706,150 @@ def _media_failure_reason(media: dict[str, Any] | None) -> str:
     return _text(media.get("failure_reason") or raw.get("upload_failure_reason") or raw.get("failure_reason"))
 
 
+def _technical_retry_detail(
+    decision: dict[str, Any] | None,
+    evidence: dict[str, Any] | None,
+    media: dict[str, Any] | None,
+) -> dict[str, Any] | None:
+    if _decision_action(decision or {}) != "TECHNICAL_RETRY_REQUIRED":
+        return None
+    scorecard = _record((decision or {}).get("scorecard"))
+    replay_data = _record((decision or {}).get("decision_replay_data"))
+    evidence_summary = _record((evidence or {}).get("evidence_summary"))
+    evidence_raw = _record((evidence or {}).get("raw_payload"))
+    media_raw = _record((media or {}).get("raw_payload"))
+    merged = {**scorecard, **replay_data, **evidence_summary, **evidence_raw}
+    failure_type = _text(
+        merged.get("failure_type")
+        or media_raw.get("oss_archive_last_error")
+        or media_raw.get("failure_reason")
+        or _media_failure_reason(media)
+    )
+    if not failure_type:
+        return None
+
+    timing = _record(merged.get("timing_metrics"))
+    video_fetch = _record(timing.get("video_fetch"))
+    gemini_request = _record(timing.get("gemini_request"))
+    attempts = [
+        {
+            "attempt": item.get("attempt"),
+            "status": _text(item.get("status")),
+            "duration_seconds": _seconds(item.get("duration_ms")),
+            "failure_type": _text(item.get("failure_type")),
+            "exception_type": _text(item.get("exception_type")),
+            "http_status_code": item.get("http_status_code"),
+        }
+        for item in _list(gemini_request.get("attempts"))
+    ]
+    detail = {
+        "brief_reason": _technical_retry_brief_reason(failure_type, merged, media_raw),
+        "stage": _technical_retry_stage(failure_type),
+        "stage_label": _technical_retry_stage_label(failure_type),
+        "failure_type": failure_type,
+        "failure_label": _technical_retry_failure_label(failure_type),
+        "exception_type": _text(merged.get("exception_type")),
+        "http_status_code": merged.get("http_status_code"),
+        "error_message": _text(merged.get("error_message")),
+        "retry_count": merged.get("retry_count"),
+        "video_source": _text(video_fetch.get("gemini_video_source")),
+        "timings": {
+            "download_seconds": _seconds(video_fetch.get("download_duration_ms")),
+            "ffmpeg_seconds": _seconds(video_fetch.get("ffmpeg_duration_ms")),
+            "video_fetch_total_seconds": _seconds(video_fetch.get("total_duration_ms")),
+            "gemini_seconds": _seconds(gemini_request.get("total_duration_ms")),
+        },
+        "sizes": {
+            "downloaded_mb": _megabytes(video_fetch.get("downloaded_bytes")),
+            "compressed_mb": _megabytes(video_fetch.get("compressed_bytes")),
+        },
+        "attempts": attempts,
+        "oss": {
+            "status": _text((media or {}).get("content_media_status")),
+            "last_error": _text(media_raw.get("oss_archive_last_error") or media_raw.get("failure_reason")),
+            "last_exception_type": _text(media_raw.get("oss_archive_last_exception_type")),
+            "last_http_status_code": media_raw.get("oss_archive_last_http_status_code"),
+            "attempt_count": media_raw.get("oss_archive_attempt_count"),
+            "duration_seconds": _seconds(_record(media_raw.get("oss_timing_metrics")).get("oss_upload_duration_ms")),
+        },
+    }
+    return detail
+
+
+def _technical_retry_brief_reason(
+    failure_type: str,
+    payload: dict[str, Any],
+    media_raw: dict[str, Any],
+) -> str:
+    if failure_type == "no_play_url":
+        return "平台结果没有返回可用 play_url"
+    if failure_type == "gemini_client_timeout":
+        return "OpenRouter/Gemini 请求写入超时"
+    if failure_type == "gemini_http_error":
+        status = payload.get("http_status_code")
+        return f"OpenRouter/Gemini 返回 HTTP {status}" if status else "OpenRouter/Gemini 请求失败"
+    if failure_type == "gemini_response_invalid":
+        return "OpenRouter/Gemini 返回格式无法解析"
+    if failure_type == "video_fetch_failed":
+        return "视频下载或压缩失败"
+    if failure_type.startswith("oss_"):
+        return "OSS 转存未拿到可用视频地址"
+    if media_raw.get("oss_archive_last_error"):
+        return "OSS 归档失败"
+    return failure_type
+
+
+def _technical_retry_stage(failure_type: str) -> str:
+    if failure_type.startswith("oss_"):
+        return "oss"
+    if failure_type == "no_play_url":
+        return "play_url"
+    if "ffmpeg" in failure_type:
+        return "ffmpeg"
+    if failure_type in {"video_fetch_failed", "video_download_failed"}:
+        return "video_download"
+    if failure_type.startswith("gemini_"):
+        return "openrouter"
+    return "unknown"
+
+
+def _technical_retry_stage_label(failure_type: str) -> str:
+    return {
+        "oss": "OSS",
+        "play_url": "平台 play_url",
+        "video_download": "视频下载",
+        "ffmpeg": "ffmpeg",
+        "openrouter": "OpenRouter/Gemini",
+        "unknown": "未知阶段",
+    }[_technical_retry_stage(failure_type)]
+
+
+def _technical_retry_failure_label(failure_type: str) -> str:
+    return {
+        "gemini_client_timeout": "Gemini 客户端超时",
+        "gemini_http_error": "Gemini HTTP 错误",
+        "gemini_response_invalid": "Gemini 响应无法解析",
+        "video_fetch_failed": "视频获取失败",
+        "no_play_url": "缺少 play_url",
+        "oss_upload_response_invalid": "OSS 响应无效",
+        "oss_upload_http_error": "OSS HTTP 错误",
+    }.get(failure_type, failure_type)
+
+
+def _seconds(value: Any) -> float | None:
+    try:
+        return round(float(value) / 1000.0, 3)
+    except (TypeError, ValueError):
+        return None
+
+
+def _megabytes(value: Any) -> float | None:
+    try:
+        return round(float(value) / 1024.0 / 1024.0, 2)
+    except (TypeError, ValueError):
+        return None
+
+
 def _rule_headline(actions: Counter[str], total: int) -> str:
     if not total:
         return "首轮内容暂未进入判断"

+ 95 - 0
tests/test_flow_ledger_api.py

@@ -202,6 +202,18 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "statistics": {},
                 "raw_payload": {},
             },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "content_discovery_id": "content_tech",
+                "platform_content_id": "douyin_tech",
+                "search_query_id": "q_003",
+                "platform": "douyin",
+                "description": "技术重试视频",
+                "author_display_name": "技术作者",
+                "statistics": {},
+                "raw_payload": {},
+            },
             {
                 "run_id": run_id,
                 "policy_run_id": policy_run_id,
@@ -231,6 +243,20 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "local_path": None,
                 "content_media_status": "oss_uploaded",
                 "raw_payload": {"oss_object_key": "video.mp4"},
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "platform": "douyin",
+                "platform_content_id": "douyin_tech",
+                "play_url": "https://example.com/raw-tech.mp4",
+                "oss_url": "https://oss.example.com/tech.mp4",
+                "local_path": None,
+                "content_media_status": "oss_uploaded",
+                "raw_payload": {
+                    "oss_archive_attempt_count": 1,
+                    "oss_timing_metrics": {"oss_upload_duration_ms": 1234},
+                },
             }
         ],
     )
@@ -245,6 +271,45 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "platform_content_id": "douyin_001",
                 "decode_status": "ok",
                 "raw_payload": {},
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "recall_evidence_id": "evidence_tech",
+                "platform_content_id": "douyin_tech",
+                "recall_status": "failed",
+                "evidence_summary": {
+                    "schema_version": "v4_gemini_query_relevance.v1",
+                    "failure_type": "gemini_client_timeout",
+                    "exception_type": "WriteTimeout",
+                    "error_message": "The write operation timed out",
+                    "retry_count": 2,
+                    "final_status": "failed",
+                    "timing_metrics": {
+                        "video_fetch": {
+                            "gemini_video_source": "oss_url",
+                            "download_duration_ms": 1500,
+                            "ffmpeg_duration_ms": 2600,
+                            "total_duration_ms": 4100,
+                            "downloaded_bytes": 10485760,
+                            "compressed_bytes": 1048576,
+                        },
+                        "gemini_request": {
+                            "total_duration_ms": 180000,
+                            "attempts": [
+                                {
+                                    "attempt": 1,
+                                    "status": "failed",
+                                    "duration_ms": 90000,
+                                    "failure_type": "gemini_client_timeout",
+                                    "exception_type": "WriteTimeout",
+                                    "http_status_code": None,
+                                }
+                            ],
+                        },
+                    },
+                },
+                "raw_payload": {},
             }
         ],
     )
@@ -296,6 +361,25 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 },
                 "decision_replay_data": {"allow_walk": False},
                 "raw_payload": {},
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "decision_id": "decision_tech",
+                "search_query_id": "q_003",
+                "decision_target_id": "content_tech",
+                "decision_action": "TECHNICAL_RETRY_REQUIRED",
+                "decision_reason_code": "v4_technical_retry_needed",
+                "score": None,
+                "scorecard": {
+                    "failure_type": "gemini_client_timeout",
+                    "exception_type": "WriteTimeout",
+                    "retry_count": 2,
+                    "final_status": "failed",
+                    "total_score": None,
+                },
+                "decision_replay_data": {"allow_walk": False},
+                "raw_payload": {},
             }
         ],
     )
@@ -403,6 +487,17 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     assert videos["summary"]["total"] == 1
     assert videos["videos"][0]["oss_url"] == "https://oss.example.com/video.mp4"
 
+    tech_videos = client.get(f"/runs/{run_id}/flow-ledger/queries/q_003/videos").json()
+    tech_video = next(item for item in tech_videos["videos"] if item["platform_content_id"] == "douyin_tech")
+    assert tech_video["decision"]["label"] == "技术重试"
+    assert tech_video["technical_retry_detail"]["stage_label"] == "OpenRouter/Gemini"
+    assert tech_video["technical_retry_detail"]["failure_type"] == "gemini_client_timeout"
+    assert tech_video["technical_retry_detail"]["timings"]["download_seconds"] == 1.5
+    assert tech_video["technical_retry_detail"]["timings"]["ffmpeg_seconds"] == 2.6
+    assert tech_video["technical_retry_detail"]["timings"]["gemini_seconds"] == 180.0
+    assert tech_video["technical_retry_detail"]["sizes"]["downloaded_mb"] == 10.0
+    assert tech_video["technical_retry_detail"]["attempts"][0]["exception_type"] == "WriteTimeout"
+
     walk = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/walk").json()
     assert walk["summary"]["total_actions"] == 2
     assert walk["summary"]["extension_query_count"] == 2

+ 74 - 0
web2/app/globals.css

@@ -176,6 +176,80 @@ a:focus-visible {
   background: #eff6ff;
 }
 
+.technical-detail {
+  display: block;
+  margin-top: 6px;
+  max-width: 520px;
+}
+
+.technical-detail.compact {
+  max-width: 360px;
+}
+
+.technical-detail summary {
+  display: inline-flex;
+  min-height: 24px;
+  align-items: center;
+  justify-content: center;
+  padding: 0 7px;
+  border: 1px solid #fecaca;
+  border-radius: 4px;
+  color: #991b1b;
+  background: #fff1f2;
+  cursor: pointer;
+  font-size: 12px;
+  font-weight: 700;
+  list-style: none;
+}
+
+.technical-detail summary::-webkit-details-marker {
+  display: none;
+}
+
+.technical-detail-body {
+  margin-top: 6px;
+  padding: 8px;
+  border: 1px solid #fecaca;
+  border-radius: 6px;
+  background: #fff7f7;
+}
+
+.technical-detail-row,
+.technical-attempt {
+  display: grid;
+  grid-template-columns: 84px minmax(0, 1fr);
+  gap: 8px;
+  padding: 3px 0;
+  font-size: 12px;
+  line-height: 1.45;
+}
+
+.technical-detail-row span,
+.technical-attempts > span {
+  color: #6b7280;
+  font-weight: 600;
+}
+
+.technical-detail-row b,
+.technical-attempt {
+  min-width: 0;
+  color: #111827;
+  font-weight: 600;
+  overflow-wrap: anywhere;
+}
+
+.technical-attempts {
+  margin-top: 6px;
+  padding-top: 6px;
+  border-top: 1px solid #fecaca;
+}
+
+.technical-attempt {
+  display: block;
+  color: #334155;
+  font-weight: 500;
+}
+
 .source-block,
 .declarations {
   margin: 10px 20px 0;

+ 65 - 0
web2/components/TechnicalRetryDetails.tsx

@@ -0,0 +1,65 @@
+import type { TechnicalRetryDetail } from "@/lib/flow-ledger/types";
+
+export function TechnicalRetryDetails({ detail, compact = false }: { detail?: TechnicalRetryDetail | null; compact?: boolean }) {
+  if (!detail) return null;
+  const rows = [
+    ["接口/阶段", detail.stageLabel],
+    ["简短原因", detail.briefReason],
+    ["错误类型", [detail.failureLabel, detail.failureType].filter(Boolean).join(" / ")],
+    ["异常", detail.exceptionType || "无"],
+    ["HTTP", detail.httpStatusCode ? String(detail.httpStatusCode) : "无"],
+    ["原始错误", detail.errorMessage || "无"],
+    ["视频源", detail.videoSource || "未进入视频处理"],
+    ["下载", formatSeconds(detail.timings.download_seconds)],
+    ["ffmpeg", formatSeconds(detail.timings.ffmpeg_seconds)],
+    ["Gemini", formatSeconds(detail.timings.gemini_seconds)],
+    ["原视频", formatMb(detail.sizes.downloaded_mb)],
+    ["压缩后", formatMb(detail.sizes.compressed_mb)],
+    ["OSS", ossText(detail)]
+  ].filter(([, value]) => value && value !== "无");
+
+  return (
+    <details className={`technical-detail ${compact ? "compact" : ""}`}>
+      <summary>技术详情</summary>
+      <div className="technical-detail-body">
+        {rows.map(([label, value]) => (
+          <div className="technical-detail-row" key={label}>
+            <span>{label}</span>
+            <b>{value}</b>
+          </div>
+        ))}
+        {detail.attempts.length ? (
+          <div className="technical-attempts">
+            <span>Gemini attempts</span>
+            {detail.attempts.map((attempt, index) => (
+              <div className="technical-attempt" key={`${attempt.attempt || index}-${index}`}>
+                #{attempt.attempt || index + 1} · {attempt.status || "failed"} · {formatSeconds(attempt.durationSeconds)}
+                {attempt.failureType ? ` · ${attempt.failureType}` : ""}
+                {attempt.exceptionType ? ` · ${attempt.exceptionType}` : ""}
+                {attempt.httpStatusCode ? ` · HTTP ${attempt.httpStatusCode}` : ""}
+              </div>
+            ))}
+          </div>
+        ) : null}
+      </div>
+    </details>
+  );
+}
+
+function formatSeconds(value?: number | null): string {
+  if (value === null || value === undefined || Number.isNaN(value)) return "无";
+  return `${value}s`;
+}
+
+function formatMb(value?: number | null): string {
+  if (value === null || value === undefined || Number.isNaN(value)) return "无";
+  return `${value}MB`;
+}
+
+function ossText(detail: TechnicalRetryDetail): string {
+  const oss = detail.oss || {};
+  const status = typeof oss.status === "string" ? oss.status : "";
+  const error = typeof oss.last_error === "string" ? oss.last_error : "";
+  const duration = typeof oss.duration_seconds === "number" ? ` · ${oss.duration_seconds}s` : "";
+  return [status, error].filter(Boolean).join(" / ") + duration;
+}

+ 2 - 0
web2/features/LedgerPage.tsx

@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, GitBranch, Info } from "lucide-react";
 import { AppShell } from "@/components/AppShell";
 import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
 import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
+import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
 import {
   assetDetailText,
   assetSummaryText,
@@ -264,6 +265,7 @@ function VideoDecisionCell({
     <td className="rules-cell video-decision-cell">
       <div className="cell-stack">
         <span className={`chip ${decisionChipClass(video.decisionAction)}`}>{video.decisionLabel}</span>
+        {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? <TechnicalRetryDetails detail={video.technicalRetryDetail} compact /> : null}
         <strong>{compactScoreItemsText(video.scoreItems)}</strong>
         <span className="muted">{video.decisionReasonLabel}</span>
         <button className="mini-button score-rule-button" type="button" onClick={() => onOpenScore(scoreRuleDrawer(row, video))}>

+ 2 - 0
web2/features/QueryVideosPage.tsx

@@ -5,6 +5,7 @@ import { PanelRightOpen } from "lucide-react";
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { AppShell } from "@/components/AppShell";
 import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
+import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
 import { getFlowLedgerVideos } from "@/lib/api/client";
 import type { FlowLedgerVideosResponse } from "@/lib/api/types";
 import { videoFromApi } from "@/lib/flow-ledger/build";
@@ -115,6 +116,7 @@ function VideoRow({ index, runId, video }: { index: number; runId: string; video
       </td>
       <td>
         <span className="chip blue">{actionLabel(video.decisionAction)}</span>
+        {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? <TechnicalRetryDetails detail={video.technicalRetryDetail} compact /> : null}
       </td>
       <td>
         <div className="score-mini">{scoreItemsText(video.scoreItems)}</div>

+ 6 - 0
web2/features/QueryWalkPage.tsx

@@ -6,8 +6,10 @@ 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 { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
 import { getFlowLedgerWalk } from "@/lib/api/client";
 import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
+import { videoFromApi } from "@/lib/flow-ledger/build";
 import { methodLabel } from "@/lib/flow-ledger/business";
 import { asRecord, text } from "@/lib/flow-ledger/format";
 
@@ -278,11 +280,15 @@ function WalkResultVideoCard({
   const label = text(decision.label, "未判断");
   const scores = videoScoreItems(video);
   const titleParts = splitTitleAndTags(text(video.title, "无标题视频"));
+  const detail = text(decision.action, "") === "TECHNICAL_RETRY_REQUIRED"
+    ? videoFromApi(video).technicalRetryDetail
+    : null;
 
   return (
     <article className={`walk-result-video-card ${decisionTone(label)} ${child ? "has-child" : ""}`}>
       <div className="walk-result-video-top">
         <span className={`walk-result-status ${decisionTone(label)}`}>{label}</span>
+        {detail ? <TechnicalRetryDetails detail={detail} compact /> : null}
         {child ? <span className="walk-result-continue">继续游走</span> : <span className="walk-result-stop">{stopLabel(label)}</span>}
       </div>
       <Link className="walk-result-video-title" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>

+ 8 - 0
web2/features/VideoDetailPage.tsx

@@ -3,6 +3,7 @@
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { AppShell } from "@/components/AppShell";
 import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
+import { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
 import { getFlowLedgerVideo } from "@/lib/api/client";
 import type { FlowLedgerVideoResponse } from "@/lib/api/types";
 import { videoFromApi } from "@/lib/flow-ledger/build";
@@ -53,6 +54,7 @@ export function VideoDetailPage({ runId, contentId }: { runId: string; contentId
               <Summary label="判断" value={actionLabel(video.decisionAction)} />
               <Summary label="视频保存" value={`${video.mediaStatusLabel || mediaStatusLabel(video.mediaStatus)}${video.mediaFailureReason ? ` · ${video.mediaFailureReason}` : ""}`} />
               <Summary label="标签" value={video.tags.length ? video.tags.join(" / ") : "无标签"} />
+              {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" ? <TechnicalRetryDetails detail={video.technicalRetryDetail} compact /> : null}
             </div>
           </details>
           <section className="video-detail-grid">
@@ -78,6 +80,12 @@ export function VideoDetailPage({ runId, contentId }: { runId: string; contentId
                 <span className="muted">视频暂未保存到 OSS,等待补传后可打开。</span>
               )}
             </div>
+            {video.decisionAction === "TECHNICAL_RETRY_REQUIRED" && video.technicalRetryDetail ? (
+              <div className="video-panel">
+                <h2>技术重试详情</h2>
+                <TechnicalRetryDetails detail={video.technicalRetryDetail} />
+              </div>
+            ) : null}
             <div className="video-panel">
               <h2>评分详情</h2>
               <p className="score-rule-lead">

+ 41 - 2
web2/lib/flow-ledger/build.ts

@@ -1,5 +1,5 @@
 import type { FlowLedgerApiResponse, RawRecord } from "@/lib/api/types";
-import type { DemandSummary, FlowLedger, FlowLedgerRow, ScoreItem, VideoRef } from "./types";
+import type { DemandSummary, FlowLedger, FlowLedgerRow, ScoreItem, TechnicalRetryAttempt, TechnicalRetryDetail, VideoRef } from "./types";
 import { asRecord, maybeText, scoreLabel, text, textList } from "./format";
 
 function countMap(value: unknown): Record<string, number> {
@@ -19,6 +19,44 @@ function scoreItems(value: unknown): ScoreItem[] {
     }));
 }
 
+function technicalRetryDetail(value: unknown): TechnicalRetryDetail | null {
+  const row = asRecord(value);
+  if (!Object.keys(row).length) return null;
+  const attempts = Array.isArray(row.attempts) ? row.attempts : [];
+  return {
+    briefReason: text(row.brief_reason, ""),
+    stage: text(row.stage, ""),
+    stageLabel: text(row.stage_label, ""),
+    failureType: text(row.failure_type, ""),
+    failureLabel: text(row.failure_label, ""),
+    exceptionType: text(row.exception_type, ""),
+    httpStatusCode: row.http_status_code === null || row.http_status_code === undefined ? null : Number(row.http_status_code),
+    errorMessage: text(row.error_message, ""),
+    retryCount: row.retry_count === null || row.retry_count === undefined ? null : Number(row.retry_count),
+    videoSource: text(row.video_source, ""),
+    timings: countMapNullable(row.timings),
+    sizes: countMapNullable(row.sizes),
+    attempts: attempts
+      .filter((item): item is RawRecord => typeof item === "object" && item !== null)
+      .map((item): TechnicalRetryAttempt => ({
+        attempt: item.attempt === null || item.attempt === undefined ? null : Number(item.attempt),
+        status: text(item.status, ""),
+        durationSeconds: item.duration_seconds === null || item.duration_seconds === undefined ? null : Number(item.duration_seconds),
+        failureType: text(item.failure_type, ""),
+        exceptionType: text(item.exception_type, ""),
+        httpStatusCode: item.http_status_code === null || item.http_status_code === undefined ? null : Number(item.http_status_code)
+      })),
+    oss: asRecord(row.oss)
+  };
+}
+
+function countMapNullable(value: unknown): Record<string, number | null> {
+  const row = asRecord(value);
+  return Object.fromEntries(
+    Object.entries(row).map(([key, item]) => [key, item === null || item === undefined || item === "" ? null : Number(item)])
+  );
+}
+
 function videoRef(row: RawRecord): VideoRef {
   const decision = asRecord(row.decision);
   return {
@@ -44,7 +82,8 @@ function videoRef(row: RawRecord): VideoRef {
     scoreItems: scoreItems(decision.score_items),
     raw: row,
     decision,
-    gemini: asRecord(row.gemini)
+    gemini: asRecord(row.gemini),
+    technicalRetryDetail: technicalRetryDetail(row.technical_retry_detail)
   };
 }
 

+ 2 - 0
web2/lib/flow-ledger/business.ts

@@ -16,6 +16,7 @@ const ACTION_LABELS: Record<string, string> = {
   ADD_TO_CONTENT_POOL: "入池",
   KEEP_CONTENT_FOR_REVIEW: "待复看",
   REJECT_CONTENT: "淘汰",
+  TECHNICAL_RETRY_REQUIRED: "技术重试",
   NOT_JUDGED: "未进入判断",
   未判断: "未进入判断"
 };
@@ -89,6 +90,7 @@ const PLATFORM_LABELS: Record<string, string> = {
   tiktok: "TikTok",
   bilibili: "B 站",
   kuaishou: "快手",
+  shipinhao: "视频号",
   xhs: "小红书"
 };
 

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

@@ -9,6 +9,32 @@ export type ScoreItem = {
   detail?: string;
 };
 
+export type TechnicalRetryAttempt = {
+  attempt?: number | null;
+  status: string;
+  durationSeconds?: number | null;
+  failureType: string;
+  exceptionType: string;
+  httpStatusCode?: number | null;
+};
+
+export type TechnicalRetryDetail = {
+  briefReason: string;
+  stage: string;
+  stageLabel: string;
+  failureType: string;
+  failureLabel: string;
+  exceptionType: string;
+  httpStatusCode?: number | null;
+  errorMessage: string;
+  retryCount?: number | null;
+  videoSource: string;
+  timings: Record<string, number | null>;
+  sizes: Record<string, number | null>;
+  attempts: TechnicalRetryAttempt[];
+  oss: RawRecord;
+};
+
 export type VideoRef = {
   id: string;
   contentDiscoveryId: string;
@@ -33,6 +59,7 @@ export type VideoRef = {
   raw: RawRecord;
   decision?: RawRecord;
   gemini?: RawRecord;
+  technicalRetryDetail?: TechnicalRetryDetail | null;
 };
 
 export type SourceSummary = {