chore(P3): baseline 落库下沉 + MatchPredictPanel 拆分 + 多 worker/CSRF 文档
P3-2 baseline 落库从路由下沉到服务层(predict_baseline 内直接落库),
删除路由层 _persist_baseline,三种模式统一 result.prediction_id,对外 JSON 不变。
P3-1 MatchPredictPanel.PredictionPanel 拆为 OutcomePanel/AgentsPanel/ReasoningPanel
三个子组件,本文件保留 PredictModal/PredictProgress/Spinner,对外导出路径不变。
P3-3 docs 加 ⚠️ 多 worker 陷阱红字 + STRICT_SINGLE_WORKER 环境变量(启动期强制拒绝多 worker)。
P3-4 docs 新增「同站部署 vs 跨站 CSRF」节。
This commit is contained in:
@@ -6,37 +6,11 @@
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import type { AgentReport, Match, Prediction } from '../types'
|
||||
import { AGENT_LABELS, CN_NUM, OUTCOME_LABEL } from '../types'
|
||||
|
||||
/** 置信度细线:0~1 数值的低调可视化 */
|
||||
function Meter({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
||||
return (
|
||||
<div className="h-px w-full bg-ink-200" role="presentation">
|
||||
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */
|
||||
function EdgeBar({ value }: { value: number }) {
|
||||
const v = Math.max(-1, Math.min(1, value))
|
||||
const half = Math.abs(v) * 50
|
||||
return (
|
||||
<div className="relative h-px w-full bg-ink-200" role="presentation">
|
||||
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
|
||||
<span
|
||||
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
|
||||
style={
|
||||
v >= 0
|
||||
? { left: '50%', width: `${half}%` }
|
||||
: { right: '50%', width: `${half}%` }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import type { Match, Prediction } from '../types'
|
||||
import { AGENT_LABELS } from '../types'
|
||||
import { AgentsPanel } from './AgentsPanel'
|
||||
import { OutcomePanel } from './OutcomePanel'
|
||||
import { ReasoningPanel } from './ReasoningPanel'
|
||||
|
||||
function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
@@ -53,77 +27,73 @@ function Spinner({ className = '' }: { className?: string }) {
|
||||
}
|
||||
|
||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||
function OutcomeLine({
|
||||
pick,
|
||||
confidence,
|
||||
.**
|
||||
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
|
||||
* OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。
|
||||
* 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。
|
||||
*/
|
||||
function PredictionPanel({
|
||||
prediction,
|
||||
match,
|
||||
embedded = false,
|
||||
}: {
|
||||
pick: string | null
|
||||
confidence: number | null
|
||||
prediction: Prediction
|
||||
match: Match
|
||||
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const options = ['1', 'X', '2'] as const
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
const reports = prediction.agent_outputs ?? []
|
||||
const okReports = reports.filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
|
||||
{options.map(o => {
|
||||
const on = pick === o
|
||||
return (
|
||||
<div key={o} className="flex flex-col items-center gap-1">
|
||||
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
|
||||
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
|
||||
{OUTCOME_LABEL[o]}
|
||||
</span>
|
||||
{on && confidence !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
置信 {Math.round(confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
|
||||
{!embedded && (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
{prediction.provider} / {prediction.model}
|
||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
{pick && confidence !== null && (
|
||||
<div className="mx-auto mt-3 max-w-xs">
|
||||
<Meter value={confidence} />
|
||||
<p className="mt-1 text-center text-2xs text-ink-400">主观置信度,非统计概率</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测成本展示:耗时 + token + 限流余量 */
|
||||
function PredictionCost({ prediction }: { prediction: Prediction }) {
|
||||
const latency = prediction.latency_ms != null ? `${(prediction.latency_ms / 1000).toFixed(1)}s` : null
|
||||
const tokens = prediction.prompt_tokens != null || prediction.completion_tokens != null
|
||||
? `${prediction.prompt_tokens ?? '?'}/${prediction.completion_tokens ?? '?'}`
|
||||
: null
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{degraded && (
|
||||
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
|
||||
<p className="font-serif text-sm font-bold text-press-dark">
|
||||
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
|
||||
</p>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
|
||||
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
||||
|
||||
return (
|
||||
<div className="border-t border-ink-200 pt-3 text-2xs text-ink-500">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
||||
{latency && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">⏱</span>耗时 {latency}
|
||||
</span>
|
||||
)}
|
||||
{tokens && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">Tok</span>prompt/completion: {tokens}
|
||||
</span>
|
||||
)}
|
||||
{prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && (
|
||||
<span className="text-press" title="每分钟最多 10 次预测">
|
||||
剩余配额: {prediction.rate_limit_remaining}/10(分钟)
|
||||
</span>
|
||||
)}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
</p>
|
||||
|
||||
<AgentsPanel prediction={prediction} />
|
||||
|
||||
<ReasoningPanel prediction={prediction} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
||||
function PredictProgress() {
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -199,256 +169,6 @@ function PredictProgress() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
ok: { label: '正常', cls: 'text-ink-500' },
|
||||
no_data: { label: '无数据', cls: 'text-ink-400' },
|
||||
error: { label: '调用失败', cls: 'text-press' },
|
||||
parse_error: { label: '解析失败', cls: 'text-press' },
|
||||
}
|
||||
|
||||
const SUFFICIENCY_LABEL: Record<string, string> = {
|
||||
high: '充分',
|
||||
medium: '一般',
|
||||
low: '偏少',
|
||||
none: '无',
|
||||
}
|
||||
|
||||
/** 单路专家意见:汉字编号 + 细线行 */
|
||||
function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||||
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' }
|
||||
const inactive = r.status !== 'ok'
|
||||
|
||||
return (
|
||||
<details className="group border-b border-ink-200">
|
||||
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
|
||||
<span className="font-serif text-sm text-ink-400">{no}</span>
|
||||
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
|
||||
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
|
||||
{r.status === 'ok' && r.subjective_confidence !== null && (
|
||||
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
{r.status === 'ok' && r.probable_score && (
|
||||
<span className="font-serif font-bold text-ink-800">{r.probable_score}</span>
|
||||
)}
|
||||
<svg viewBox="0 0 20 20" className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="space-y-3 px-1 pb-4 pl-7">
|
||||
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
|
||||
{inactive && (
|
||||
<p className="text-xs leading-relaxed text-ink-500">
|
||||
{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'}
|
||||
{r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'}
|
||||
{r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && r.home_edge !== null && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
|
||||
<span className="text-ink-500">主队优势</span>
|
||||
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
|
||||
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<EdgeBar value={r.home_edge} />
|
||||
<div className="mt-1 flex justify-between text-2xs text-ink-400">
|
||||
<span>利客队</span>
|
||||
<span>利主队</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.analysis && (
|
||||
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
|
||||
)}
|
||||
|
||||
{r.key_evidence.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{r.key_evidence.map((e, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
||||
<span className="flex-shrink-0 text-ink-300" aria-hidden="true">—</span>
|
||||
<span>{e}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||
<p className="text-xs text-ink-500">
|
||||
进球期望 <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && (
|
||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
||||
数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
|
||||
<span className="mx-2 text-ink-200">|</span>
|
||||
<span className="font-mono">{r.model}</span>
|
||||
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function PredictionPanel({
|
||||
prediction,
|
||||
match,
|
||||
embedded = false,
|
||||
}: {
|
||||
prediction: Prediction
|
||||
match: Match
|
||||
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const [expertsOpen, setExpertsOpen] = useState(false)
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
const reports = prediction.agent_outputs ?? []
|
||||
const okReports = reports.filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
|
||||
{!embedded && (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
{prediction.provider} / {prediction.model}
|
||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */}
|
||||
{degraded && (
|
||||
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
|
||||
<p className="font-serif text-sm font-bold text-press-dark">
|
||||
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
|
||||
</p>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 主结论(仅 success 展示) ── */}
|
||||
{!degraded && (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
{prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && (
|
||||
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||
备选{' '}
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
|
||||
{!degraded && (
|
||||
<PredictionCost prediction={prediction} />
|
||||
)}
|
||||
|
||||
{/* ── 元信息 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
</p>
|
||||
|
||||
{/* ── 终裁/降级说明意见 ── */}
|
||||
{prediction.reasoning && degraded && (
|
||||
<section>
|
||||
<h4 className="section-head mb-2">降级原因</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
|
||||
{reports.length > 0 && (
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setExpertsOpen(o => !o)}
|
||||
className="flex w-full items-center justify-between border-b border-ink-200 pb-2 text-left"
|
||||
>
|
||||
<span className="section-head mb-0">五路专家意见({okReports.length}/{reports.length} 路有效)</span>
|
||||
<span className="text-2xs text-ink-400">{expertsOpen ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
|
||||
{/* 权重条形图(仅 success 且有权重时显示) */}
|
||||
{!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<span className="text-2xs text-ink-500">终裁权重分布</span>
|
||||
{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="grid grid-cols-[96px_minmax(0,1fr)_40px] items-center gap-2">
|
||||
<span className="truncate text-2xs text-ink-500">{AGENT_LABELS[k] ?? k}</span>
|
||||
<div className="h-1.5 bg-paper-100">
|
||||
<div className="h-full bg-press" style={{ width: `${Math.round(v * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-right text-2xs tabular-nums text-ink-500">{Math.round(v * 100)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expertsOpen && (
|
||||
<div className="mt-2">
|
||||
{reports.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见(success) ── */}
|
||||
{prediction.reasoning && !degraded && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
|
||||
export function PredictModal({
|
||||
match,
|
||||
predicting,
|
||||
|
||||
Reference in New Issue
Block a user