|
|
@@ -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 "首轮内容暂未进入判断"
|