diff --git a/platform/web/app/recommendation-readiness/page.tsx b/platform/web/app/recommendation-readiness/page.tsx index 58a84bc..8645bb9 100644 --- a/platform/web/app/recommendation-readiness/page.tsx +++ b/platform/web/app/recommendation-readiness/page.tsx @@ -2,8 +2,40 @@ import Link from 'next/link'; const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; -type MarketStatus = { market_type: string; label?: string; match_count?: number; bookmaker_count?: number; odds_rows?: number; passes_minimum?: boolean }; -type ReadinessResponse = { formal_recommendations_allowed?: boolean; headline?: string; market_status?: MarketStatus[]; blocking_reasons?: string[]; required_actions?: string[] }; +type MarketStatus = { + market_type: string; + label?: string; + match_count?: number; + bookmaker_count?: number; + odds_rows?: number; + passes_minimum?: boolean; +}; + +type FormalProviderStatus = { + provider?: string; + status?: string; + api_key_configured?: boolean; + api_key_placeholder?: boolean; + message?: string; +}; + +type ReadinessSource = { + name?: string; + worker_status?: string; + formal_provider_status?: FormalProviderStatus; +}; + +type ReadinessResponse = { + status?: string; + mode?: string; + formal_recommendations_allowed?: boolean; + headline?: string; + source?: ReadinessSource; + market_status?: MarketStatus[]; + blocking_reasons?: string[]; + required_actions?: string[]; + warnings?: string[]; +}; async function getReadiness(): Promise { const response = await fetch(`${ANALYTICS_BACKEND}/analytics/recommendation-readiness`, { cache: 'no-store' }); @@ -11,21 +43,109 @@ async function getReadiness(): Promise { return response.json() as Promise; } +function providerLabel(status?: string) { + if (status === 'ok') return '正式 provider 可用'; + if (status === 'placeholder_key') return '正式 key 仍是 placeholder'; + if (status === 'missing_key') return '正式 key 尚未設定'; + if (status === 'empty_events') return '正式 provider 暫無世界盃盤口'; + if (status === 'error') return '正式 provider 抓取失敗'; + return '正式 provider 狀態未知'; +} + export default async function RecommendationReadinessPage() { let data: ReadinessResponse | null = null; let error = ''; - try { data = await getReadiness(); } catch (err) { error = err instanceof Error ? err.message : '推薦就緒度暫時無法讀取'; } + + try { + data = await getReadiness(); + } catch (err) { + error = err instanceof Error ? err.message : '推薦就緒度暫時無法讀取'; + } + const marketStatus = data?.market_status ?? []; const blockers = data?.blocking_reasons ?? []; const actions = data?.required_actions ?? []; + const warnings = data?.warnings ?? []; + const provider = data?.source?.formal_provider_status; return (
-

推薦就緒度

現在能不能稱為正式下注推薦?

這裡是推薦閘門。若盤口來源不足或核心玩法缺盤,就只能顯示監控候選,不能用高勝率語氣誤導使用者。

{data?.headline ?? '就緒度同步中'}

- {error ?

{error}

: null} -
{marketStatus.map((item) =>

{item.label ?? item.market_type}

賽事 {item.match_count ?? 0} 場|莊家 {item.bookmaker_count ?? 0} 家

最新賠率列 {item.odds_rows ?? 0}

{item.passes_minimum ? '通過最低門檻' : '未通過最低門檻'}

)}
-

阻擋原因

    {blockers.map((item) =>
  • ・{item}
  • )}

下一步

    {actions.map((item) =>
  • ・{item}
  • )}
- 回每日作戰室 +
+

推薦就緒度

+

現在能不能稱為正式下注推薦?

+

+ 這裡是推薦閘門。若盤口來源不足、正式 provider 未接上,或核心玩法缺盤,就只能顯示監控候選,不能用高勝率語氣誤導使用者。 +

+

+ {data?.headline ?? '就緒度同步中'} +

+
+ + {error ? ( +

{error}

+ ) : null} + +
+
+

正式 provider

+

{providerLabel(provider?.status)}

+

+ 來源:{data?.source?.name ?? '尚未回報'}。Key 已設定:{provider?.api_key_configured ? '是' : '否'};Key 是否 placeholder:{provider?.api_key_placeholder ? '是' : '否'}。 +

+
+
+

正式推薦狀態

+

{data?.formal_recommendations_allowed ? '允許進入下注前檢查' : '只允許監控候選'}

+

+ 目前模式:{data?.mode ?? 'unknown'}。正式推薦必須有多來源盤口、核心玩法覆蓋與至少兩家莊家可比價。 +

+
+
+

阻塞數量

+

{blockers.length} 項

+

+ 只要任一核心阻塞存在,首頁推薦就必須標示為監控與預掛條件,不可宣稱正式下注訊號。 +

+
+
+ +
+ {marketStatus.map((item) => ( +
+

{item.label ?? item.market_type}

+

賽事 {item.match_count ?? 0} 場|莊家 {item.bookmaker_count ?? 0} 家

+

最新賠率列 {item.odds_rows ?? 0}

+

+ {item.passes_minimum ? '通過最低門檻' : '未通過最低門檻'} +

+
+ ))} +
+ +
+
+

阻擋原因

+
    + {blockers.length ? blockers.map((item) =>
  • ・{item}
  • ) :
  • ・目前沒有核心阻塞。
  • } +
+
+
+

警示

+
    + {warnings.length ? warnings.map((item) =>
  • ・{item}
  • ) :
  • ・目前沒有額外警示。
  • } +
+
+
+

下一步

+
    + {actions.map((item) =>
  • ・{item}
  • )} +
+
+
+ + + 回每日作戰室 +
); } diff --git a/platform/web/app/source-health/page.tsx b/platform/web/app/source-health/page.tsx index 845a1aa..4ff7fde 100644 --- a/platform/web/app/source-health/page.tsx +++ b/platform/web/app/source-health/page.tsx @@ -2,8 +2,48 @@ import Link from 'next/link'; const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; -type WorkerStatus = { status?: string; run_at?: string; message?: string }; -type SourceHealth = { status?: string; odds_rows?: number; matches?: number; finished_matches?: number; venues?: number; high_altitude_venues?: number; stale_unsettled_matches?: number; latest_odds_recorded_at?: string | null; latest_result_synced_at?: string | null; ingestion_status?: WorkerStatus | null; fixtures_status?: WorkerStatus | null; news_status?: WorkerStatus | null }; +type WorkerStatus = { + status?: string; + run_at?: string; + message?: string; + source?: string; + primary_provider?: FormalProviderStatus; +}; + +type FormalProviderStatus = { + provider?: string; + status?: string; + api_key_configured?: boolean; + api_key_placeholder?: boolean; + sport_key?: string; + regions?: string; + markets?: string[]; + message?: string; +}; + +type ProviderRequirements = { + primary_odds_provider?: string; + formal_provider_status?: FormalProviderStatus; + formal_provider_blocker?: string | null; + current_limitation?: string; +}; + +type SourceHealth = { + status?: string; + odds_coverage_status?: string; + odds_rows?: number; + matches?: number; + finished_matches?: number; + venues?: number; + high_altitude_venues?: number; + stale_unsettled_matches?: number; + latest_odds_recorded_at?: string | null; + latest_result_synced_at?: string | null; + ingestion_status?: WorkerStatus | null; + fixtures_status?: WorkerStatus | null; + news_status?: WorkerStatus | null; + provider_requirements?: ProviderRequirements; +}; async function getSourceHealth(): Promise { const response = await fetch(`${ANALYTICS_BACKEND}/analytics/source-health`, { cache: 'no-store' }); @@ -13,22 +53,127 @@ async function getSourceHealth(): Promise { function dateText(value?: string | null) { if (!value) return '尚無時間'; - return new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)); + return new Intl.DateTimeFormat('zh-TW', { + timeZone: 'Asia/Taipei', + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} + +function providerLabel(status?: string) { + if (status === 'ok') return '正式 provider 已回傳'; + if (status === 'placeholder_key') return '正式 key 仍是 placeholder'; + if (status === 'missing_key') return '正式 key 尚未設定'; + if (status === 'empty_events') return '正式 provider 暫無盤口'; + if (status === 'error') return '正式 provider 抓取失敗'; + return '正式 provider 狀態未知'; +} + +function coverageLabel(status?: string) { + if (status === 'full_market') return '正式多來源盤口'; + if (status === 'reference_market') return '台灣/單一來源參考盤'; + if (status === 'limited_scoreboard_fallback') return '比分備援'; + if (status === 'no_upcoming_market') return '未來賽事無盤口'; + return '盤口不足'; } export default async function SourceHealthPage() { let data: SourceHealth | null = null; let error = ''; - try { data = await getSourceHealth(); } catch (err) { error = err instanceof Error ? err.message : '資料健康暫時無法讀取'; } - const cards = [['賽事總數', data?.matches ?? '-', '目前資料庫可見賽事'], ['已完賽', data?.finished_matches ?? '-', '可用於賽後校準'], ['賠率列數', data?.odds_rows ?? '-', '盤口歷史資料量'], ['逾時賽果', data?.stale_unsettled_matches ?? '-', '不為 0 時不升級正式推薦']]; + + try { + data = await getSourceHealth(); + } catch (err) { + error = err instanceof Error ? err.message : '資料健康暫時無法讀取'; + } + + const provider = data?.provider_requirements?.formal_provider_status; + const blocker = data?.provider_requirements?.formal_provider_blocker || data?.provider_requirements?.current_limitation; + const cards = [ + ['賽事總數', data?.matches ?? '-', '目前資料庫可見賽事'], + ['已完賽', data?.finished_matches ?? '-', '可用於賽後校準'], + ['賠率列數', data?.odds_rows ?? '-', '盤口歷史資料量'], + ['逾時賽果', data?.stale_unsettled_matches ?? '-', '不為 0 時不升級正式推薦'], + ]; + const workerCards = [ + [ + '賠率同步', + data?.ingestion_status?.status ?? data?.status, + data?.ingestion_status?.message ?? `最近更新:${dateText(data?.latest_odds_recorded_at)}`, + ], + [ + '賽程同步', + data?.fixtures_status?.status ?? 'unknown', + data?.fixtures_status?.message ?? `最近排程:${dateText(data?.fixtures_status?.run_at)}`, + ], + [ + '新聞同步', + data?.news_status?.status ?? 'unknown', + data?.news_status?.message ?? `最近排程:${dateText(data?.news_status?.run_at)}`, + ], + ]; return (
-

資料健康總覽

先確認資料新鮮,再看投注候選

這頁檢查賽程、賽果、盤口、新聞與排程是否正常。只要資料延遲或盤口不足,首頁就只能顯示監控候選,不能包裝成正式高勝率推薦。

- {error ?

{error}

: null} -
{cards.map(([label, value, helper]) =>

{helper}

{value}

{label}

)}
-
{[['賠率同步', data?.ingestion_status?.status ?? data?.status, data?.ingestion_status?.message ?? `最近更新:${dateText(data?.latest_odds_recorded_at)}`], ['賽程同步', data?.fixtures_status?.status ?? 'unknown', data?.fixtures_status?.message ?? `最近排程:${dateText(data?.fixtures_status?.run_at)}`], ['新聞同步', data?.news_status?.status ?? 'unknown', data?.news_status?.message ?? `最近排程:${dateText(data?.news_status?.run_at)}`]].map(([title, status, detail]) =>

{title}

{status || 'unknown'}

{detail}

)}
- 回每日作戰室 +
+

資料健康總覽

+

先確認資料新鮮,再看投注候選

+

+ 這頁檢查賽程、賽果、盤口、新聞與排程是否正常。只要資料延遲或盤口不足,首頁就只能顯示監控候選,不能包裝成正式高勝率推薦。 +

+
+ + {error ? ( +

{error}

+ ) : null} + +
+

正式盤口 Provider

+
+
+

{providerLabel(provider?.status)}

+

+ 目前盤口狀態:{coverageLabel(data?.odds_coverage_status)}。正式下注推薦至少需要有效 provider、核心玩法覆蓋,且每個核心玩法有兩家以上莊家可比價。 +

+
+
+

Provider:{provider?.provider ?? data?.provider_requirements?.primary_odds_provider ?? 'the-odds-api'}

+

Key 已設定:{provider?.api_key_configured ? '是' : '否'}

+

Key 是否 placeholder:{provider?.api_key_placeholder ? '是,目前不能當正式盤口' : '否'}

+

Sport Key:{provider?.sport_key ?? '尚未回報'}

+

Regions:{provider?.regions ?? '尚未回報'}

+
+
+ {blocker ? ( +

+ 正式盤口阻塞:{blocker} +

+ ) : null} +
+ +
+ {cards.map(([label, value, helper]) => ( +
+

{helper}

+

{value}

+

{label}

+
+ ))} +
+ +
+ {workerCards.map(([title, status, detail]) => ( +
+

{title}

+

{status || 'unknown'}

+

{detail}

+
+ ))} +
+ + + 回每日作戰室 +
); }