fix(P0): 修复三处静默失效的阻断缺陷
P0-1 backtest 会话生命周期: - _get_historical_matches 加 selectinload(league/home_team/away_team), 并在 session 内物化为 BacktestCandidate 纯数据快照,避免 session 关闭后访问惰性关系抛 MissingGreenlet - except 收窄并改用 logger.exception 保留堆栈 - 移除未使用的 and_ / League 导入 P0-2 切片函数关系属性 MissingGreenlet: - models.py 为 Match.league/home_team/away_team/stats 声明 lazy=selectin - context_builder 的 _get_form/_get_h2h/_get_home_away 显式 selectinload (修复 form_slice/h2h_slice 恒定失败,被 fail-open 掩盖的问题) - repositories.find_by_teams_and_date 加 selectinload(Match.stats) P0-3 bzzoiro raw 变量泄漏导致血缘错乱: - normalized_matches 改为携带 (nm, raw) 元组,内层循环解包 - source_event_id / source_record_id 现在取到正确 event id - 已有比赛补建 stats 时补齐 source/source_event_id/retrieved_at/available_at - 抽取 _match_key()/_to_date() 统一日期键构造 (P1-6)
This commit is contained in:
+65
-20
@@ -8,10 +8,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.db.models import League, Match
|
||||
from src.db.models import Match
|
||||
from src.db.unit_of_work import get_uow
|
||||
from src.llm.eval import settle_prediction
|
||||
from src.llm.predict import predict_match
|
||||
@@ -38,6 +40,23 @@ class BacktestMatchResult:
|
||||
prediction_id: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestCandidate:
|
||||
"""回测候选比赛(字段快照,不持有 ORM 对象)。
|
||||
|
||||
session 关闭后仍可安全读取:所有需要的关系字段已在查询时物化为普通值,
|
||||
避免在 session 之外访问惰性加载的关系属性(会抛 MissingGreenlet)。
|
||||
"""
|
||||
match_id: int
|
||||
league_code: str | None
|
||||
home_team: str
|
||||
away_team: str
|
||||
match_date: datetime
|
||||
home_goals: int
|
||||
away_goals: int
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestSummary:
|
||||
"""回测汇总统计。"""
|
||||
@@ -66,10 +85,20 @@ async def _get_historical_matches(
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[Match]:
|
||||
"""查询已完赛且有比分的比赛(回测候选)。"""
|
||||
) -> list[BacktestCandidate]:
|
||||
"""查询已完赛且有比分的比赛(回测候选)。
|
||||
|
||||
返回普通值快照而非 ORM 对象:调用方在 session 关闭后仍需使用这些字段,
|
||||
而 league / home_team / away_team 是惰性加载关系,在 async 下于 session
|
||||
之外访问会抛 MissingGreenlet。这里用 selectinload 预加载后立即物化。
|
||||
"""
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(
|
||||
selectinload(Match.league),
|
||||
selectinload(Match.home_team),
|
||||
selectinload(Match.away_team),
|
||||
)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(Match.home_goals.is_not(None))
|
||||
.where(Match.away_goals.is_not(None))
|
||||
@@ -83,7 +112,19 @@ async def _get_historical_matches(
|
||||
|
||||
stmt = stmt.order_by(Match.match_date.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
# 在 session 内物化为纯数据,切断与 ORM 会话的耦合
|
||||
return [
|
||||
BacktestCandidate(
|
||||
match_id=m.id,
|
||||
league_code=m.league.code if m.league else None,
|
||||
home_team=m.home_team.name if m.home_team else "?",
|
||||
away_team=m.away_team.name if m.away_team else "?",
|
||||
match_date=m.match_date,
|
||||
home_goals=m.home_goals,
|
||||
away_goals=m.away_goals,
|
||||
)
|
||||
for m in result.scalars().all()
|
||||
]
|
||||
|
||||
|
||||
async def run_backtest(
|
||||
@@ -109,32 +150,34 @@ async def run_backtest(
|
||||
BacktestSummary 含逐场结果 + 汇总统计
|
||||
"""
|
||||
async with get_uow() as session:
|
||||
matches = await _get_historical_matches(
|
||||
candidates = await _get_historical_matches(
|
||||
session, league_id=league_id, date_from=date_from, date_to=date_to, limit=limit
|
||||
)
|
||||
|
||||
summary = BacktestSummary(total=len(matches), scored=0)
|
||||
summary = BacktestSummary(total=len(candidates), scored=0)
|
||||
|
||||
for m in matches:
|
||||
for c in candidates:
|
||||
try:
|
||||
# 预测 (build_context 内部已用 before=match_date 防泄漏,
|
||||
# injuries_slice 也使用 as_of=match_date 过滤 retrieved_at)
|
||||
result = await predict_match(m.id, mode=mode, model=model)
|
||||
# 回测必须禁用结果缓存: 否则命中缓存会复用同一 prediction_id,
|
||||
# 导致 settle 反复覆盖同一条记录(见 P1-3)。
|
||||
result = await predict_match(c.match_id, mode=mode, model=model, use_cache=False)
|
||||
|
||||
# 用实际比分 settle
|
||||
await settle_prediction(result.prediction_id, m.home_goals, m.away_goals)
|
||||
await settle_prediction(result.prediction_id, c.home_goals, c.away_goals)
|
||||
|
||||
actual = _actual_1x2(m.home_goals, m.away_goals)
|
||||
actual = _actual_1x2(c.home_goals, c.away_goals)
|
||||
correct = result.pred_1x2 == actual
|
||||
|
||||
bt = BacktestMatchResult(
|
||||
match_id=m.id,
|
||||
league_code=m.league.code if m.league else None,
|
||||
home_team=m.home_team.name if m.home_team else "?",
|
||||
away_team=m.away_team.name if m.away_team else "?",
|
||||
match_date=m.match_date.strftime("%Y-%m-%d") if m.match_date else "?",
|
||||
actual_home=m.home_goals,
|
||||
actual_away=m.away_goals,
|
||||
match_id=c.match_id,
|
||||
league_code=c.league_code,
|
||||
home_team=c.home_team,
|
||||
away_team=c.away_team,
|
||||
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
|
||||
actual_home=c.home_goals,
|
||||
actual_away=c.away_goals,
|
||||
actual_1x2=actual,
|
||||
pred_home=result.pred_home_goals,
|
||||
pred_away=result.pred_away_goals,
|
||||
@@ -146,8 +189,10 @@ async def run_backtest(
|
||||
summary.results.append(bt)
|
||||
summary.scored += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("backtest match %s failed: %s", m.id, e)
|
||||
except Exception:
|
||||
# 用 exception 而非 warning:保留堆栈,否则集成层缺陷(如惰性加载
|
||||
# 在 session 外触发)会只剩一行无堆栈的 warning,极难定位。
|
||||
logger.exception("backtest match %s failed", c.match_id)
|
||||
|
||||
# 汇总统计
|
||||
if summary.scored > 0:
|
||||
|
||||
@@ -328,9 +328,19 @@ async def _load_match(db, match_id: int) -> Match:
|
||||
|
||||
|
||||
async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]:
|
||||
"""某队近 N 场(已完赛)。before=None 表示不限制(预测赛前的场景由调用方保证)。"""
|
||||
"""某队近 N 场(已完赛)。before=None 表示不限制(预测赛前的场景由调用方保证)。
|
||||
|
||||
必须预加载 stats / home_team / away_team:切片函数会读取这些关系,
|
||||
而 async session 下惰性加载会抛 MissingGreenlet。
|
||||
(models.py 已声明 lazy="selectin",此处显式声明以固化查询意图。)
|
||||
"""
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(
|
||||
selectinload(Match.stats),
|
||||
selectinload(Match.home_team),
|
||||
selectinload(Match.away_team),
|
||||
)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(Match.home_goals.is_not(None))
|
||||
.where((Match.home_team_id == team_id) | (Match.away_team_id == team_id))
|
||||
@@ -344,9 +354,13 @@ async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]:
|
||||
|
||||
|
||||
async def _get_h2h(db, home_id: int, away_id: int, before, *, limit: int = 5) -> list[Match]:
|
||||
"""两队交锋史。"""
|
||||
"""两队交锋史。需预加载 home_team / away_team(切片输出队名)。"""
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(
|
||||
selectinload(Match.home_team),
|
||||
selectinload(Match.away_team),
|
||||
)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(Match.home_goals.is_not(None))
|
||||
.where(
|
||||
@@ -363,9 +377,17 @@ async def _get_h2h(db, home_id: int, away_id: int, before, *, limit: int = 5) ->
|
||||
|
||||
|
||||
async def _get_home_away(db, team_id: int, side: str, before, *, limit: int = 10) -> list[Match]:
|
||||
"""某队主场/客场近 N 场。side='home' 取主场,'away' 取客场。"""
|
||||
"""某队主场/客场近 N 场。side='home' 取主场,'away' 取客场。
|
||||
|
||||
当前只用标量字段,但统一预加载以免后续扩展时踩坑。
|
||||
"""
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(
|
||||
selectinload(Match.stats),
|
||||
selectinload(Match.home_team),
|
||||
selectinload(Match.away_team),
|
||||
)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(Match.home_goals.is_not(None))
|
||||
.order_by(Match.match_date.desc())
|
||||
|
||||
+22
-8
@@ -81,11 +81,22 @@ async def predict_match(
|
||||
model: str | None = None,
|
||||
prompt_version: str | None = None,
|
||||
mode: str = "multi",
|
||||
use_cache: bool = True,
|
||||
) -> "PredictResult | MultiPredictResult":
|
||||
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。"""
|
||||
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
||||
|
||||
Args:
|
||||
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
||||
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
||||
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
||||
"""
|
||||
if mode == "single":
|
||||
return await _predict_single(
|
||||
match_id, provider=provider, model=model, prompt_version=prompt_version
|
||||
match_id,
|
||||
provider=provider,
|
||||
model=model,
|
||||
prompt_version=prompt_version,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
from src.llm.agents.orchestrator import predict_match_multi
|
||||
|
||||
@@ -98,6 +109,7 @@ async def _predict_single(
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
prompt_version: str | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> PredictResult:
|
||||
"""单次调用路径(原有实现)。"""
|
||||
if provider is None:
|
||||
@@ -107,10 +119,11 @@ async def _predict_single(
|
||||
version = prompt_version or "v1"
|
||||
|
||||
# 0. 查缓存(同 match+provider+model+version 5 分钟内直接返)
|
||||
cached = _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version)
|
||||
if cached is not None:
|
||||
logger.debug("predict cache hit match=%s", match_id)
|
||||
return cached
|
||||
if use_cache:
|
||||
cached = _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version)
|
||||
if cached is not None:
|
||||
logger.debug("predict cache hit match=%s", match_id)
|
||||
return cached
|
||||
|
||||
# 1. 拼上下文
|
||||
ctx = await build_context(match_id)
|
||||
@@ -191,6 +204,7 @@ async def _predict_single(
|
||||
raw=resp.raw,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, result)
|
||||
# 5. 写入缓存(仅当允许缓存时)
|
||||
if use_cache:
|
||||
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, result)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user