全量修复:预测系统正确性、安全性与部署问题
P0 严重问题修复: - 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误) - 修复 understat.py httpx 未导入导致的 NameError - 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5) 预测路径修复: - multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效 - H2H 切片汇总统计改为从当前主队视角计数 - 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测 伤停管线修复: - IntegrityError 后不再整批回滚丢数据(改用逐条 flush) - return_date 正确解析并写入 - retrieved_at 比较统一用 date() 避免当天数据不可见 - 唯一索引改为 partial unique index(排除 NULL 重复) - HTTP 缓存 TTL 从 7 天改为 6 小时 安全与连接管理: - /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP) - 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接 Docker 部署修复: - 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败 - 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost) - 启动时自动执行 alembic upgrade head - 前端改用多阶段构建(Dockerfile.frontend) 新增测试(5个文件,24+用例): - test_p0_home_away.py: 主客身份反转回归测试 - test_p0_parse_failure.py: LLM 解析失败回归测试 - test_multi_agent_cutoff.py: multi-agent cutoff 透传测试 - test_h2h_perspective.py: H2H 视角测试 - test_injuries_pipeline.py: 伤停管线 5 项修复测试 - test_predict_protection.py: 限流+短 session 测试 - test_prediction_unique_constraint.py: 唯一约束测试 迁移: - 0012_injuries_partial_unique_and_return_date.py - 0013_predictions_unique_constraint_mode_run_type.py
This commit is contained in:
@@ -144,10 +144,14 @@ async def run_specialists(
|
||||
header: MatchHeader,
|
||||
*,
|
||||
version: str = "v1",
|
||||
before=None,
|
||||
) -> list[AgentReport]:
|
||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
|
||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
|
||||
|
||||
before: 数据截止时间(回测防泄漏)。None 表示不限制。
|
||||
"""
|
||||
tasks = [
|
||||
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version)
|
||||
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version, before=before)
|
||||
for spec in SPECIALIST_SPECS
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -161,10 +165,10 @@ async def run_specialists(
|
||||
return reports
|
||||
|
||||
|
||||
async def _run_one(spec, header, provider, *, version) -> AgentReport:
|
||||
async def _run_one(spec, header, provider, *, version, before=None) -> AgentReport:
|
||||
from src.llm.agents.base import run_agent
|
||||
|
||||
return await run_agent(spec, header, provider, before=header.match_dt, version=version)
|
||||
return await run_agent(spec, header, provider, before=before, version=version)
|
||||
|
||||
|
||||
def _reports_to_json(reports: list[AgentReport]) -> str:
|
||||
@@ -210,18 +214,34 @@ async def predict_match_multi(
|
||||
*,
|
||||
provider: LLMProvider | None = None,
|
||||
version: str = "v1",
|
||||
backtest: bool = False,
|
||||
cutoff_at=None,
|
||||
) -> MultiPredictResult:
|
||||
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。"""
|
||||
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
|
||||
|
||||
backtest: 回测模式。True 时 cutoff 自动设为 match_dt - 1 天。
|
||||
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
|
||||
# 1. 比赛头(各 agent 共享;不存在则 404)
|
||||
header = await load_match_header(match_id)
|
||||
match_kickoff_at = header.match_dt
|
||||
prediction_cutoff_at = header.match_dt # 默认:比赛时间作为数据截止
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 2. 并行专家(各自独立配置)
|
||||
reports = await run_specialists(header, version=version)
|
||||
# 计算真正的数据截止时间(回测防泄漏)
|
||||
# 优先级: 显式 cutoff_at > backtest 自动计算 > 默认(比赛时间)
|
||||
if cutoff_at is not None:
|
||||
cutoff = cutoff_at
|
||||
elif backtest and header.match_dt:
|
||||
from datetime import timedelta
|
||||
cutoff = header.match_dt - timedelta(days=1)
|
||||
else:
|
||||
cutoff = header.match_dt
|
||||
prediction_cutoff_at = cutoff
|
||||
|
||||
# 2. 并行专家(各自独立配置,使用统一 cutoff)
|
||||
reports = await run_specialists(header, version=version, before=cutoff)
|
||||
|
||||
# 3. 终裁
|
||||
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
||||
@@ -257,6 +277,7 @@ async def predict_match_multi(
|
||||
provider_name=settings.LLM_PROVIDER,
|
||||
model=aggregator_provider.model,
|
||||
mode="multi",
|
||||
run_type="backtest" if backtest else "live",
|
||||
values={
|
||||
"prompt_version": f"multi_{version}",
|
||||
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||
|
||||
+57
-26
@@ -71,6 +71,7 @@ class MatchContext:
|
||||
has_stats: bool
|
||||
has_injuries: bool
|
||||
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
||||
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -144,20 +145,38 @@ async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: Asy
|
||||
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
||||
n_with_score = 0
|
||||
if h2h:
|
||||
home_wins = draws = away_wins = 0
|
||||
# 从当前主队视角统计:判断当前主队在每场交锋中是主是客
|
||||
current_home_wins = current_home_draws = current_home_losses = 0
|
||||
for hm in h2h:
|
||||
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
|
||||
if hm.home_goals is not None:
|
||||
n_with_score += 1
|
||||
if hm.home_goals > hm.away_goals: home_wins += 1
|
||||
elif hm.home_goals == hm.away_goals: draws += 1
|
||||
else: away_wins += 1
|
||||
# 判断当前主队当时是主队还是客队
|
||||
if hm.home_team_id == header.home_team_id:
|
||||
# 当前主队当时是主队
|
||||
if hm.home_goals > hm.away_goals:
|
||||
current_home_wins += 1
|
||||
elif hm.home_goals == hm.away_goals:
|
||||
current_home_draws += 1
|
||||
else:
|
||||
current_home_losses += 1
|
||||
else:
|
||||
# 当前主队当时是客队(从客队视角看赛果)
|
||||
if hm.away_goals > hm.home_goals:
|
||||
current_home_wins += 1
|
||||
elif hm.away_goals == hm.home_goals:
|
||||
current_home_draws += 1
|
||||
else:
|
||||
current_home_losses += 1
|
||||
lines.append(f" {d}: {hm.home_team.name} {hm.home_goals}-{hm.away_goals} {hm.away_team.name}")
|
||||
else:
|
||||
lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)")
|
||||
total = home_wins + draws + away_wins
|
||||
total = current_home_wins + current_home_draws + current_home_losses
|
||||
if total:
|
||||
lines.append(f" 总计 {total} 场: 主队 {home_wins}胜 {draws}平 {away_wins}负")
|
||||
lines.append(
|
||||
f" 总计 {total} 场(从当前主队 {header.home_name} 视角): "
|
||||
f"{current_home_wins}胜 {current_home_draws}平 {current_home_losses}负"
|
||||
)
|
||||
else:
|
||||
lines.append(" 无数据")
|
||||
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
|
||||
@@ -178,14 +197,18 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
|
||||
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
||||
lines = []
|
||||
n_scored = 0
|
||||
for label, name, form, side in (
|
||||
("主队", header.home_name, home_form, "home"),
|
||||
("客队", header.away_name, away_form, "away"),
|
||||
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
|
||||
# 不能用本场 side 硬套 —— 否则客场输球会被算成主场赢球。
|
||||
for label, name, form, team_id in (
|
||||
("主队", header.home_name, home_form, header.home_team_id),
|
||||
("客队", header.away_name, away_form, header.away_team_id),
|
||||
):
|
||||
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
|
||||
if form:
|
||||
wins = draws = losses = 0
|
||||
for fm in form:
|
||||
is_home = (fm.home_team_id == team_id)
|
||||
side = "home" if is_home else "away"
|
||||
o = _outcome(fm.home_goals, fm.away_goals, side)
|
||||
if o == "W": wins += 1
|
||||
elif o == "D": draws += 1
|
||||
@@ -195,9 +218,9 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
|
||||
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
||||
xg = ""
|
||||
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
|
||||
own = fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
||||
own = fm.stats.home_xg if is_home else fm.stats.away_xg
|
||||
xg = f" (xG {own:.1f})"
|
||||
opp = fm.away_team.name if side == "home" else fm.home_team.name
|
||||
opp = fm.away_team.name if is_home else fm.home_team.name
|
||||
lines.append(f" {o} {score} vs {opp}{xg}")
|
||||
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
||||
else:
|
||||
@@ -219,30 +242,33 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db:
|
||||
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
||||
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
||||
n_total = 0
|
||||
for label, name, form, side in (
|
||||
("主队", header.home_name, home_form, "home"),
|
||||
("客队", header.away_name, away_form, "away"),
|
||||
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
|
||||
# 不能用本场 side 硬套 —— 否则进球/失球/xG 全部算反。
|
||||
for label, name, form, team_id in (
|
||||
("主队", header.home_name, home_form, header.home_team_id),
|
||||
("客队", header.away_name, away_form, header.away_team_id),
|
||||
):
|
||||
if form:
|
||||
gf = ga = shots = sot = poss = xg = xga = 0
|
||||
n = n_shots = n_poss = n_xg = 0
|
||||
for fm in form:
|
||||
if fm.home_goals is None: continue
|
||||
gf += fm.home_goals if side == "home" else fm.away_goals
|
||||
ga += fm.away_goals if side == "home" else fm.home_goals
|
||||
is_home = (fm.home_team_id == team_id)
|
||||
gf += fm.home_goals if is_home else fm.away_goals
|
||||
ga += fm.away_goals if is_home else fm.home_goals
|
||||
n += 1
|
||||
# 只使用 cutoff 之前已可用的统计数据
|
||||
if fm.stats and _is_stats_available(fm.stats, before):
|
||||
if fm.stats.home_shots is not None:
|
||||
shots += fm.stats.home_shots if side == "home" else fm.stats.away_shots
|
||||
sot += fm.stats.home_shots_on_target if side == "home" else fm.stats.away_shots_on_target
|
||||
shots += fm.stats.home_shots if is_home else fm.stats.away_shots
|
||||
sot += fm.stats.home_shots_on_target if is_home else fm.stats.away_shots_on_target
|
||||
n_shots += 1
|
||||
if fm.stats.home_possession is not None:
|
||||
poss += fm.stats.home_possession if side == "home" else (100 - fm.stats.home_possession)
|
||||
poss += fm.stats.home_possession if is_home else (100 - fm.stats.home_possession)
|
||||
n_poss += 1
|
||||
if fm.stats.home_xg is not None:
|
||||
xg += fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
||||
xga += fm.stats.away_xg if side == "home" else fm.stats.home_xg
|
||||
xg += fm.stats.home_xg if is_home else fm.stats.away_xg
|
||||
xga += fm.stats.away_xg if is_home else fm.stats.home_xg
|
||||
n_xg += 1
|
||||
n_total += n
|
||||
if n > 0:
|
||||
@@ -340,23 +366,27 @@ async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession |
|
||||
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
||||
# ============================================================
|
||||
|
||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False) -> MatchContext:
|
||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
|
||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
|
||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
|
||||
|
||||
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
||||
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||
|
||||
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
||||
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
|
||||
|
||||
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
header = await load_match_header(match_id, db=db)
|
||||
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
|
||||
cutoff = header.match_dt
|
||||
if backtest and header.match_dt:
|
||||
# 计算数据截止时间: 显式 > backtest 自动计算 > 默认(比赛时间)
|
||||
if cutoff_at is not None:
|
||||
cutoff = cutoff_at
|
||||
elif backtest and header.match_dt:
|
||||
from datetime import timedelta
|
||||
cutoff = header.match_dt - timedelta(days=1)
|
||||
else:
|
||||
cutoff = header.match_dt
|
||||
parts = [header_text(header), ""]
|
||||
|
||||
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
|
||||
@@ -384,6 +414,7 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
|
||||
has_stats=form_res.has_data or stats_res.has_data,
|
||||
has_injuries=injuries_res.has_data,
|
||||
match_dt=header.match_dt,
|
||||
cutoff=cutoff,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+29
-7
@@ -112,11 +112,13 @@ async def _upsert_prediction(
|
||||
provider_name: str,
|
||||
model: str,
|
||||
mode: str,
|
||||
run_type: str,
|
||||
values: dict,
|
||||
) -> Prediction:
|
||||
"""按 (match, provider, model) 唯一约束写入预测。
|
||||
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
|
||||
|
||||
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
|
||||
run_type 区分 live/backtest,避免回测覆盖实盘预测。
|
||||
"""
|
||||
existing = (
|
||||
await session.execute(
|
||||
@@ -124,6 +126,8 @@ async def _upsert_prediction(
|
||||
Prediction.match_id == match_id,
|
||||
Prediction.provider == provider_name,
|
||||
Prediction.model == model,
|
||||
Prediction.mode == mode,
|
||||
Prediction.run_type == run_type,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
@@ -134,6 +138,7 @@ async def _upsert_prediction(
|
||||
match_id=match_id, provider=provider_name, model=model,
|
||||
)
|
||||
pred.mode = mode
|
||||
pred.run_type = run_type
|
||||
for k, v in values.items():
|
||||
setattr(pred, k, v)
|
||||
if existing is None:
|
||||
@@ -151,6 +156,7 @@ async def predict_match(
|
||||
mode: str = "multi",
|
||||
use_cache: bool = True,
|
||||
backtest: bool = False,
|
||||
cutoff_at=None,
|
||||
) -> "PredictResult | MultiPredictResult":
|
||||
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
||||
|
||||
@@ -158,7 +164,8 @@ async def predict_match(
|
||||
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
||||
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
||||
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
||||
backtest: 是否回测模式。True 时 build_context 使用 match_date-1天 作为 cutoff。
|
||||
backtest: 是否回测模式。True 时 cutoff 自动设为 match_date-1天。
|
||||
cutoff_at: 显式截止时间,优先级高于 backtest 自动计算。
|
||||
"""
|
||||
if mode == "single":
|
||||
return await _predict_single(
|
||||
@@ -168,10 +175,18 @@ async def predict_match(
|
||||
prompt_version=prompt_version,
|
||||
use_cache=use_cache,
|
||||
backtest=backtest,
|
||||
cutoff_at=cutoff_at,
|
||||
)
|
||||
from src.llm.agents.orchestrator import predict_match_multi
|
||||
|
||||
return await predict_match_multi(match_id, provider=provider, version=(prompt_version or "v1").removeprefix("multi_"))
|
||||
# 回测参数完整传递到 multi-agent 路径
|
||||
return await predict_match_multi(
|
||||
match_id,
|
||||
provider=provider,
|
||||
version=(prompt_version or "v1").removeprefix("multi_"),
|
||||
backtest=backtest,
|
||||
cutoff_at=cutoff_at,
|
||||
)
|
||||
|
||||
|
||||
async def _predict_single(
|
||||
@@ -182,6 +197,7 @@ async def _predict_single(
|
||||
prompt_version: str | None = None,
|
||||
use_cache: bool = True,
|
||||
backtest: bool = False,
|
||||
cutoff_at=None,
|
||||
) -> PredictResult:
|
||||
"""单次调用路径(原有实现)。"""
|
||||
if provider is None:
|
||||
@@ -198,13 +214,14 @@ async def _predict_single(
|
||||
logger.debug("predict cache hit match=%s", match_id)
|
||||
return cached
|
||||
|
||||
# 1. 拼上下文(P2-6: backtest 时使用 match_date-1天 作为 cutoff)
|
||||
ctx = await build_context(match_id, backtest=backtest)
|
||||
# 1. 拼上下文(backtest/cutoff 防泄漏)
|
||||
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
|
||||
|
||||
# 1.5 计算快照元数据(用于可复现性)
|
||||
now = datetime.now(timezone.utc)
|
||||
match_kickoff_at = ctx.match_dt
|
||||
prediction_cutoff_at = ctx.match_dt # 默认:比赛时间作为数据截止
|
||||
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
|
||||
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
|
||||
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
|
||||
|
||||
# 2. 拼 prompt(指定版本)
|
||||
@@ -223,7 +240,11 @@ async def _predict_single(
|
||||
if resp.error:
|
||||
raise RuntimeError(f"LLM error: {resp.error}")
|
||||
|
||||
parsed = resp.parsed or {}
|
||||
# P0-3: json_mode 下 parsed 为 None 说明 JSON 解析失败,不能 fallback 到 {}
|
||||
if resp.parsed is None:
|
||||
raise RuntimeError("LLM 输出 JSON 解析失败,parsed=None")
|
||||
|
||||
parsed = resp.parsed
|
||||
|
||||
# 3.5 严格校验 LLM 输出
|
||||
from src.llm.validation import validate_prediction_output
|
||||
@@ -245,6 +266,7 @@ async def _predict_single(
|
||||
provider_name=settings.LLM_PROVIDER,
|
||||
model=provider.model,
|
||||
mode="single",
|
||||
run_type="backtest" if backtest else "live",
|
||||
values={
|
||||
"prompt_version": version,
|
||||
"prompt_tokens": resp.prompt_tokens,
|
||||
|
||||
@@ -91,6 +91,7 @@ class LLMProvider:
|
||||
+ ("(token 花在推理上,请增大 max_tokens)" if message.get("reasoning_content") else "")
|
||||
)
|
||||
parsed = None
|
||||
parse_error: str | None = None
|
||||
if json_mode:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
@@ -103,6 +104,10 @@ class LLMProvider:
|
||||
parsed = json.loads(m.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if parsed is None:
|
||||
# P0-3: JSON 解析失败必须显式报错,不能静默继续
|
||||
parse_error = f"JSON parse failed: {content[:200]!r}"
|
||||
logger.warning(parse_error)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
parsed=parsed,
|
||||
@@ -110,6 +115,7 @@ class LLMProvider:
|
||||
completion_tokens=usage.get("completion_tokens"),
|
||||
latency_ms=latency,
|
||||
raw=data,
|
||||
error=parse_error if parse_error else None,
|
||||
)
|
||||
except Exception as e:
|
||||
latency = int((time.perf_counter() - start) * 1000)
|
||||
|
||||
+20
-5
@@ -182,7 +182,11 @@ def validate_agent_output(raw: dict) -> AgentReportSchema:
|
||||
|
||||
|
||||
def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
||||
"""校验最终预测输出。"""
|
||||
"""校验最终预测输出。
|
||||
|
||||
P0-3: 必填字段不提供默认值,缺失即校验失败(让 Pydantic 抛出 ValidationError),
|
||||
避免「0-0 平局 + 置信度 0.5」这种静默假预测落库。
|
||||
"""
|
||||
# 优先新字段,旧字段仅兼容并打日志
|
||||
conf = raw.get("subjective_confidence")
|
||||
if conf is None and "confidence" in raw:
|
||||
@@ -198,13 +202,24 @@ def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# P0-3: pred_1x2 不再默认 "X",缺失会触发 Pydantic ValidationError
|
||||
pred_1x2 = raw.get("1x2") or raw.get("pred_1x2")
|
||||
if pred_1x2 is None:
|
||||
raise ValueError("Missing required field: pred_1x2 (or legacy '1x2')")
|
||||
|
||||
# P0-3: subjective_confidence 不再默认 0.5
|
||||
if conf is None:
|
||||
raise ValueError("Missing required field: subjective_confidence")
|
||||
|
||||
return PredictionOutputSchema(
|
||||
pred_home_goals=int(Decimal(str(raw.get("pred_home_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
pred_away_goals=int(Decimal(str(raw.get("pred_away_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
# P0-3: 必填字段用 raw[key] 而非 raw.get(key, default),
|
||||
# 缺失时 KeyError → 被外层 except 捕获 → 预测标记为失败
|
||||
pred_home_goals=int(Decimal(str(raw["pred_home_goals"])).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
pred_away_goals=int(Decimal(str(raw["pred_away_goals"])).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
alt_pred_home_goals=_alt("home"),
|
||||
alt_pred_away_goals=_alt("away"),
|
||||
pred_1x2=raw.get("1x2") or raw.get("pred_1x2", "X"),
|
||||
subjective_confidence=float(conf if conf is not None else 0.5),
|
||||
pred_1x2=pred_1x2,
|
||||
subjective_confidence=float(conf),
|
||||
reasoning=str(raw.get("reasoning", ""))[:1000],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user