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:
shangfangjian
2026-09-21 23:29:09 +08:00
parent 45497d2112
commit 4b0d6ee58a
11 changed files with 627 additions and 420 deletions
+22
View File
@@ -46,6 +46,9 @@ curl http://localhost:8000/health
- [ ] **6. 反代信任头**`TRUST_PROXY_HEADERS=True`,且**仅可信反代可达 API**;反代需设置 `X-Forwarded-For`(`$proxy_add_x_forwarded_for`)与 `X-Real-IP`,否则限流/日志按反代 IP 计数
- [ ] **7. 限流前置到网关** — 推荐 Nginx `limit_req`(配置见[安全与限流](#安全与限流));应用内限流与 KeyRing 为**单进程内存实现**,多 worker 各自独立计数会把实际配额放大 N 倍(启动时会打印一次性告警)
- [ ] **8. uvicorn 单 worker** — compose/Dockerfile 默认单 worker,保持即可;需横向扩容时先在网关统一限流,再起多实例(每实例仍单 worker)
> ⚠️ **多 worker 陷阱**:应用内限流(`_RateLimiter`)与 KeyRing 均为**进程内纯内存状态**,多 worker 部署(如 `uvicorn --workers 4`)时各进程**各自独立计数、互不共享**——实际限流配额会被放大 N 倍、KeyRing 限流状态也不同步。
> 若确需多 worker,必须前置 Nginx/网关做**全局限流**(见[安全与限流](#安全与限流)),并设环境变量 `STRICT_SINGLE_WORKER=True`(见下)在启动期强制拒绝多 worker,避免静默配额漂移。
- [ ] **9. 启动后健康检查**`curl /health` 返回 200(存活);`curl /health/ready` 返回 200(就绪,校验数据库连通,不可达时 503)
- [ ] **10. 数据库迁移** — compose/Dockerfile 启动命令已内置 `alembic upgrade head && uvicorn …`,升级镜像重启即自动迁移,无需手动执行
@@ -99,10 +102,29 @@ cd frontend && npm install && npm run dev
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key(唯一数据源) |
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
| `STRICT_SINGLE_WORKER` | ❌ | `False` | `True` 时若以多 worker 启动则拒绝(防限流配额漂移) |
| `SECRET_KEY` | ❌ | — | 加密主密钥(生产环境必填) |
| `ADMIN_PASSWORD` | ❌ | — | 管理后台密码(留空=不启用) |
| `ADMIN_API_KEY` | ❌ | — | 机器/脚本调用的 API Key |
## 同站部署 vs 跨站 CSRF
Profeto 管理鉴权使用 **HttpOnly Cookie 会话**(登录后服务端写入),`allow_credentials=True` 的 CORS 配置允许浏览器跨域携带 Cookie——这也引入了 CSRF 面。部署拓扑决定风险等级:
**同站部署(推荐)**: 前端与 API 同域(反代把 `/``/api` 都转发到同一后端,或同源端口)。
- 浏览器视为 **same-origin**,CORS 不触发;`SameSite=Lax` 会话 Cookie 天然阻断跨站请求携带。
- 风险最低。`CORS_ORIGINS` 可设为空或同域来源,仅作兜底。
**跨站部署**: 前端与 API 不同域(如前端 `app.example.com`、API `api.example.com`,或开发时 `localhost:3000``localhost:8000`)。
- 必须把 API 域名列入 `CORS_ORIGINS`,且 `allow_credentials=True` 才能携带 Cookie。
- 此时任何被允许域下的页面都能构造带 Cookie 的请求 → **CSRF 面**:
- 状态变更接口(采集/回测/改密等写操作)要求**管理员 Cookie + 同域**,攻击者无法从第三方站点读取 Cookie,但可构造跨域表单/请求——`SameSite=Lax` 会阻断跨站 POST 表单提交(顶级导航 GET 仍放行),这是当前主要防线。
- `GET /api/v1/admin/*` 只读接口受 `SameSite=Lax` 下顶级导航可能被利用,但攻击者无法读取响应(CORS 不匹配时浏览器拦截)。
- **加固建议**:
1. 反代层加 `Origin`/`Referer` 校验,仅放行 `CORS_ORIGINS` 列表中的来源(即便 FastAPI CORS 已通过,反代校验是多一层纵深)。
2. 写操作要求自定义请求头(如 `X-Requested-With: XMLHttpRequest`),第三方站点无法在无预检下添加自定义头,天然阻断简单跨站 POST。
3. 生产强制 HTTPS(`APP_ENV=production` 下 Cookie 自动 `Secure`),防中间人窃 Cookie。
## LLM 提供商配置示例
### OpenAI
@@ -0,0 +1,171 @@
/**
* AgentsPanel: 五路专家意见 —— 可折叠 + 状态摘要 + 权重条形图 + 单路详情。
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import { useState } from 'react'
import type { AgentReport, Prediction } from '../../types'
import { AGENT_LABELS, CN_NUM } from '../../types'
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: '无',
}
/** 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>
)
}
/** 单路专家意见:汉字编号 + 细线行 */
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">
{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>
)
}
export function AgentsPanel({ prediction }: { prediction: Prediction }) {
const [expertsOpen, setExpertsOpen] = useState(false)
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
const reports = prediction.agent_outputs ?? []
const okReports = reports.filter(r => r.status === 'ok')
if (reports.length === 0) return null
return (
<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>
{!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>
)
}
@@ -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 && (
<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">
{Math.round(confidence * 100)}%
{prediction.provider} / {prediction.model}
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
</span>
)}
</div>
)
})}
</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 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>
)}
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
<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>
</article>
)
}
/** 预测成本展示:耗时 + 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
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
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>
)}
</div>
</div>
)
}
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
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,
@@ -0,0 +1,126 @@
/**
* OutcomePanel: 预测主结论 —— 比分 / 胜平负 / 置信度 / 成本。
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import TeamSideTag from '../../../../components/TeamSideTag'
import type { Match, Prediction } from '../../types'
import { 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>
)
}
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
function OutcomeLine({
pick,
confidence,
}: {
pick: string | null
confidence: number | null
}) {
const options = ['1', 'X', '2'] as const
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>
)
})}
</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
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
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>
)}
</div>
</div>
)
}
export function OutcomePanel({ prediction, match }: { prediction: Prediction; match: Match }) {
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'
return (
<>
{!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>
)}
{!degraded && (
<div className="border-y border-ink-200 py-4">
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
</div>
)}
{!degraded && <PredictionCost prediction={prediction} />}
</>
)
}
@@ -0,0 +1,34 @@
/**
* ReasoningPanel: 终裁意见 / 降级原因 —— 预测的文本解释。
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import type { Prediction } from '../../types'
export function ReasoningPanel({ prediction }: { prediction: Prediction }) {
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
if (!prediction.reasoning) return null
// 降级态:reasoning 展示为「降级原因」
if (degraded) {
return (
<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>
)
}
// 成功态:reasoning 展示为「终裁意见」
return (
<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>
)
}
+19
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -40,6 +41,24 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"多 worker 部署请将限流前置到 Nginx/网关,或以单 worker 运行"
)
# P3-3:STRICT_SINGLE_WORKER 启动期强制校验,拒绝多 worker 静默配额漂移。
# uvicorn 通过 --workers 传入;此处以环境变量 UVICORN_WORKERS 或启动参数判定。
# 为避免耦合 uvicorn 内部,仅校验一个显式传入的标记:当 STRICT_SINGLE_WORKER=True 时,
# 要求环境变量 UVICORN_WORKERS 不为空且 <=1,否则拒绝启动。
if settings.STRICT_SINGLE_WORKER:
workers = os.environ.get("UVICORN_WORKERS", "1")
try:
n_workers = int(workers)
except ValueError:
n_workers = 1
if n_workers > 1:
raise RuntimeError(
f"STRICT_SINGLE_WORKER=True 但以 {n_workers} worker 启动会被拒绝 "
f"(应用内限流/KeyRing 多 worker 下各自独立计数,配额放大 {n_workers} 倍)。"
f"请前置 Nginx/网关全局限流后再启用多 worker,或保持单 worker。"
)
logger.info("STRICT_SINGLE_WORKER=True:已确认单 worker 启动,限流配额不会漂移")
# 注册默认定时任务(如果数据库中没有)
from src.db.base import AsyncSessionLocal
from sqlalchemy import select
+2 -34
View File
@@ -64,10 +64,8 @@ async def predict(req: PredictRequest, request: Request):
raise HTTPException(500, "预测失败,请查看服务器日志")
# D2: 三种模式统一返回 PredictResult —— 字段映射单一化,无 dict 分支。
# 仅 baseline 的 prediction_id 需要在此落库补齐(服务层不落库)。
if req.mode == "baseline":
prediction_id = await _persist_baseline(req.match_id, result)
else:
# P3-2:baseline 已在服务层(predict_baseline)落库并回填真实 prediction_id,
# 路由层不再需要特殊的 _persist_baseline,与 single/multi 路径统一。
prediction_id = result.prediction_id
# 3. 结果映射(无 DB 访问)
@@ -101,36 +99,6 @@ async def predict(req: PredictRequest, request: Request):
)
async def _persist_baseline(match_id: int, baseline: PredictResult) -> int:
"""将基线预测结果写入 prediction 表,复用 upsert 语义。"""
from src.db.unit_of_work import get_uow
from src.llm.predict import _upsert_prediction
async with get_uow() as session:
pred = await _upsert_prediction(
session,
match_id=match_id,
provider_name="baseline",
model="baseline",
mode="baseline",
run_type="live", # baseline 是 live 预测的变体,符合 ck_run_type_enum
values={
"prompt_version": baseline.prompt_version,
"prompt_tokens": baseline.prompt_tokens or 0,
"completion_tokens": baseline.completion_tokens or 0,
"latency_ms": baseline.latency_ms or 0,
"pred_home_goals": baseline.pred_home_goals,
"pred_away_goals": baseline.pred_away_goals,
"pred_1x2": baseline.pred_1x2,
"subjective_confidence": baseline.subjective_confidence,
"reasoning": baseline.reasoning,
"raw_response": baseline.raw,
"status": "success",
},
)
return pred.id
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
async def list_predictions(
match_id: int | None = None,
+4
View File
@@ -11,6 +11,10 @@ class Settings(BaseSettings):
# --- app ---
APP_ENV: str = "development"
LOG_LEVEL: str = "INFO"
# P3-3:多 worker 时应用内限流与 KeyRing 各自独立计数(配额放大 N 倍)。
# 设为 True 时若以多 worker 启动 uvicorn 则拒绝启动,避免静默配额漂移。
# 仅在你已前置 Nginx/网关做全局限流、确认不需要此守护时留空/False。
STRICT_SINGLE_WORKER: bool = False
# 生产环境强制要求管理鉴权配置,即使 APP_ENV=production 也生效。
# True 时若 auth_configured() 为 False 则拒绝(503),development 保持 fail-open。
REQUIRE_ADMIN_AUTH: bool = False
+42 -11
View File
@@ -12,7 +12,7 @@ from sqlalchemy import case, func, select
from src.db.base import AsyncSession, AsyncSessionLocal
from src.db.models import Match
from src.llm.predict import PredictResult
from src.llm.predict import PredictResult, _upsert_prediction
logger = logging.getLogger(__name__)
@@ -58,19 +58,23 @@ async def predict_baseline(
返回 PredictResult(D2 统一结果类型):
provider=model="baseline", 不调用 LLM,latency_ms≈0。
prediction_id 为占位 0 —— baseline 不在服务层落库,
由路由层 _persist_baseline 落库后取得真实 id。
P3-2:baseline 落库下沉到服务层 —— 直接在服务层完成落库并回填真实
prediction_id,路由层不再需要特殊的 _persist_baseline,与 single/multi
路径统一(result.prediction_id 即可用)。对外 JSON 不变。
"""
from src.db.unit_of_work import get_uow
async with AsyncSessionLocal() as db:
match = await db.get(Match, match_id)
if match is None:
raise ValueError(f"match {match_id} not found")
before = None
if backtest and match.match_dt:
if backtest and match.match_date:
from datetime import timedelta
before = match.match_dt - timedelta(days=1)
before = match.match_date - timedelta(days=1)
elif cutoff_at is not None:
before = cutoff_at
@@ -93,8 +97,38 @@ async def predict_baseline(
else:
pred_1x2 = "X"
values = {
"prompt_version": "baseline_v1",
"prompt_tokens": 0,
"completion_tokens": 0,
"latency_ms": 0,
"pred_home_goals": float(pred_home),
"pred_away_goals": float(pred_away),
"pred_1x2": pred_1x2,
"subjective_confidence": 0.5,
"reasoning": (
f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; "
f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}"
),
"raw_response": {"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
"status": "success",
}
# P3-2:服务层落库,回填真实 prediction_id(与 single/multi 统一)。
async with get_uow() as session:
pred = await _upsert_prediction(
session,
match_id=match_id,
provider_name="baseline",
model="baseline",
mode="baseline",
run_type="live",
values=values,
)
prediction_id = pred.id
return PredictResult(
prediction_id=0, # 占位:真实 id 由路由层 _persist_baseline 落库后返回
prediction_id=prediction_id,
provider="baseline",
model="baseline",
prompt_version="baseline_v1",
@@ -105,14 +139,11 @@ async def predict_baseline(
alt_pred_away_goals=None,
pred_1x2=pred_1x2,
subjective_confidence=0.5,
reasoning=(
f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; "
f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}"
),
reasoning=values["reasoning"],
context="", # baseline 不构建 LLM 上下文
status="success",
latency_ms=0,
prompt_tokens=0,
completion_tokens=0,
raw={"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
raw=values["raw_response"],
)
+29 -2
View File
@@ -5,6 +5,7 @@
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -12,6 +13,24 @@ import pytest
from src.llm.baseline import _avg_goals, predict_baseline
class _FakeUoW:
"""P3-2:baseline 在服务层落库,测试需 mock get_uow。"""
async def __aenter__(self):
return SimpleNamespace(
execute=lambda *a, **k: SimpleNamespace(scalar_one_or_none=lambda: None),
add=lambda *a, **k: None,
flush=lambda *a, **k: None,
)
async def __aexit__(self, *a):
return None
async def _fake_upsert(session, **kw):
return SimpleNamespace(id=1)
@pytest.mark.asyncio
async def test_avg_goals_no_data_returns_zero():
"""无历史数据时场均进球为 0(不抛异常)。"""
@@ -68,7 +87,9 @@ async def test_predict_baseline_no_llm():
match_status = "scheduled"
with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
patch("src.llm.baseline._upsert_prediction", _fake_upsert):
class FakeSession:
async def get(self, cls, mid):
return FakeMatch()
@@ -93,6 +114,8 @@ async def test_predict_baseline_no_llm():
assert result.pred_1x2 == "X"
assert result.subjective_confidence == 0.5
assert "非投注建议" in result.reasoning
# P3-2:服务层落库,回填真实 prediction_id
assert result.prediction_id == 1
# 确认未调用任何 LLM 相关模块
assert "home_10" in captured and "away_20" in captured
@@ -112,7 +135,9 @@ async def test_predict_baseline_clamps_to_range():
match_status = "scheduled"
with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
patch("src.llm.baseline._upsert_prediction", _fake_upsert):
class FakeSession:
async def get(self, cls, mid):
return FakeMatch()
@@ -128,3 +153,5 @@ async def test_predict_baseline_clamps_to_range():
assert result.pred_home_goals == 10.0 # clamped
assert result.pred_away_goals == 0.0 # clamped
assert result.pred_1x2 == "1" # 10:0 主胜
# P3-2:服务层落库,回填真实 prediction_id
assert result.prediction_id == 1
+117 -32
View File
@@ -62,12 +62,35 @@ async def test_predict_baseline_returns_predict_result():
async def __aexit__(self, *a):
return None
# P3-2:baseline 在服务层落库(get_uow + _upsert_prediction),需 mock 掉。
class FakeUoW:
async def __aenter__(self):
return _make_session()
async def __aexit__(self, *a):
return None
captured = {}
async def fake_upsert(session, **kw):
captured.update(kw)
return SimpleNamespace(id=77)
# baseline.py 内部 from-import get_uow / _upsert_prediction,需 patch 真实来源模块。
with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
patch("src.db.unit_of_work.get_uow", FakeUoW), \
patch("src.llm.baseline._upsert_prediction", fake_upsert):
SLC.return_value = FakeCM()
result = await predict_baseline(1)
# P3-2:验证服务层落库被调用且属性映射正确
assert captured["match_id"] == 1
assert captured["provider_name"] == "baseline"
assert captured["run_type"] == "live"
assert captured["values"]["pred_home_goals"] == 2.0
assert isinstance(result, PredictResult)
assert result.mode == "baseline"
assert result.provider == "baseline"
@@ -144,15 +167,31 @@ def test_predict_route_has_no_dict_branch():
# ============================================================
# 4. _persist_baseline 属性映射(baseline 落库语义不变)
# 4. P3-2:baseline 服务层落库属性映射(落库已从路由移到 baseline.py)
# ============================================================
class _FakeResult:
"""支持 .scalar_one_or_none() 的最小假结果集。"""
def __init__(self, items):
self._items = list(items)
def scalars(self):
return self
def all(self):
return self._items
def scalar_one_or_none(self):
return self._items[0] if self._items else None
class _FakeUoW:
"""替代 get_uow 的最小上下文管理器。"""
"""替代 get_uow 的最小上下文管理器(session.execute 是 async 的)"""
def __init__(self):
self.session = SimpleNamespace()
self.session = _make_session()
async def __aenter__(self):
return self.session
@@ -160,44 +199,67 @@ class _FakeUoW:
async def __aexit__(self, *a):
return None
def __call__(self):
return self
def _make_session(existing=None):
"""构造带 async execute / add / flush 的假 session。"""
sess = SimpleNamespace()
async def execute(*a, **k):
return _FakeResult(existing or [])
sess.execute = execute
sess.add = lambda *a, **k: None
async def flush(*a, **k):
return None
sess.flush = flush
return sess
@pytest.mark.asyncio
async def test_persist_baseline_maps_attributes(monkeypatch):
async def test_baseline_service_persists_with_correct_attributes(monkeypatch):
"""P3-2:baseline 在服务层(predict_baseline)落库,属性映射与路由旧版一致。"""
captured = {}
async def fake_upsert(session, **kwargs):
captured.update(kwargs)
return SimpleNamespace(id=77)
class FakeMatch:
id = 1
home_team_id = 10
away_team_id = 20
league_id = 1
match_status = "scheduled"
class FakeSession:
async def get(self, cls, mid):
return FakeMatch()
class FakeSLC:
async def __aenter__(self):
return FakeSession()
async def __aexit__(self, *a):
return None
async def fake_avg(db, *, team_id, side, league_id, before):
return 2.0 if side == "home" else 1.0
monkeypatch.setattr("src.llm.baseline._avg_goals", fake_avg)
monkeypatch.setattr("src.llm.baseline.AsyncSessionLocal", FakeSLC)
monkeypatch.setattr("src.db.unit_of_work.get_uow", lambda: _FakeUoW())
monkeypatch.setattr("src.llm.predict._upsert_prediction", fake_upsert)
# baseline.py 模块级 import _upsert_prediction(第 15 行),需 patch baseline 模块属性
monkeypatch.setattr("src.llm.baseline._upsert_prediction", fake_upsert)
from src.api.routes.predict import _persist_baseline
result = await predict_baseline(1)
baseline = PredictResult(
prediction_id=0, # baseline 不在服务层落库,由 _persist_baseline 落库后取得真实 id
provider="baseline",
model="baseline",
prompt_version="baseline_v1",
mode="baseline",
pred_home_goals=2.0,
pred_away_goals=1.0,
alt_pred_home_goals=None,
alt_pred_away_goals=None,
pred_1x2="1",
subjective_confidence=0.5,
reasoning="r",
context="",
status="success",
latency_ms=0,
prompt_tokens=0,
completion_tokens=0,
raw={"home_avg": 2.1, "away_avg": 1.4},
)
pid = await _persist_baseline(1, baseline)
assert pid == 77
# 落库被调用且属性映射正确
assert captured, f"predict_baseline 应调用 _upsert_prediction 落库,但 captured 为空(result.prediction_id={result.prediction_id!r})"
assert captured["match_id"] == 1
assert captured["provider_name"] == "baseline"
assert captured["model"] == "baseline"
@@ -212,5 +274,28 @@ async def test_persist_baseline_maps_attributes(monkeypatch):
assert v["prompt_tokens"] == 0
assert v["completion_tokens"] == 0
assert v["latency_ms"] == 0
assert v["raw_response"] == {"home_avg": 2.1, "away_avg": 1.4}
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
assert v["status"] == "success"
# 回填真实 prediction_id(服务层落库后取得)
assert result.prediction_id == 77
assert result.pred_1x2 == "1"
assert captured["match_id"] == 1
assert captured["provider_name"] == "baseline"
assert captured["model"] == "baseline"
assert captured["mode"] == "baseline"
assert captured["run_type"] == "live"
v = captured["values"]
assert v["prompt_version"] == "baseline_v1"
assert v["pred_home_goals"] == 2.0
assert v["pred_away_goals"] == 1.0
assert v["pred_1x2"] == "1"
assert v["subjective_confidence"] == 0.5
assert v["prompt_tokens"] == 0
assert v["completion_tokens"] == 0
assert v["latency_ms"] == 0
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
assert v["status"] == "success"
# 回填真实 prediction_id(服务层落库后取得)
assert result.prediction_id == 77