完善评估能力:筛选参数 + degraded 排除 + 前端评估页

后端:
- settle_prediction 拒绝 degraded/failed(明确错误信息)
- get_eval_summary 支持 provider/model/prompt_version/mode 筛选
- 返回 filtered_settled/evaluated/skipped_degraded 等计数
- matches 游标分页方向修复(scheduled ASC 用 > 条件)
- available_at 加 2h 缓冲(近似完赛时间)
- bzzziro 统计字段映射注释(待真实响应验证)
- injuries 区分 no_local_data 与 success 空名单

前端:
- 新增 EvalPage(筛选控件 + 汇总卡片 + 准确率表格)
- 挂载 /admin/eval 路由与导航

测试:
- test_matches_cursor.py:游标方向
- test_available_at.py:2h 缓冲与回测防泄漏
- test_bzzoirot_stats.py:统计字段映射
- test_injuries_no_local_data.py:no_local_data vs success
- test_injuries_inserted_count.py:失败批不计入
- test_eval_excludes_degraded.py:degraded 排除准确率
This commit is contained in:
Profeto Agent
2026-09-19 09:40:24 +00:00
parent c2c4752856
commit 835d7217d0
21 changed files with 888 additions and 67 deletions
+6 -5
View File
@@ -10,7 +10,7 @@ import json as _json
import logging
import random
from collections.abc import Iterable
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
@@ -270,8 +270,9 @@ class BzzoiroSource:
if any(getattr(nm, f) is not None for f in ['home_xg', 'away_xg', 'home_shots', 'away_shots', 'home_shots_on_target', 'away_shots_on_target', 'home_corners', 'away_corners', 'home_possession', 'home_yellow_cards', 'away_yellow_cards', 'home_red_cards', 'away_red_cards']):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 局限:用 match_date(开球时间)近似,实际完赛时间约为 +2 小时
available_at = nm.date if nm.date else now
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
# 回测 cutoff 若贴着开球,不会把「完赛后才有的统计」误标为赛前可用
available_at = nm.date + timedelta(hours=2) if nm.date else now
stats = MatchStats(
match_id=m.id,
home_xg=nm.home_xg,
@@ -318,8 +319,8 @@ class BzzoiroSource:
):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 局限:用 match_date(开球时间)近似,实际完赛时间约为 +2 小时
available_at = nm.date if nm.date else now
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
available_at = nm.date + timedelta(hours=2) if nm.date else now
existing_match.stats = MatchStats(
match_id=existing_match.id,
source="bzzoiro",
+20 -7
View File
@@ -233,14 +233,16 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
batch: list[Injury] = []
async def _flush_batch():
"""使用 savepoint flush 一批记录;失败只回滚本批。"""
"""使用 savepoint flush 一批记录;失败只回滚本批。返回实际写入条数。"""
if not batch:
return
return 0
count = len(batch)
async with db.begin_nested():
for obj in batch:
db.add(obj)
await db.flush()
batch.clear()
return count
for i, rec in enumerate(pending_records):
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
@@ -248,12 +250,11 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
continue
batch.append(Injury(**rec))
result["inserted"] += 1
# 每 BATCH_SIZE 条 flush 一次
if len(batch) >= BATCH_SIZE:
try:
await _flush_batch()
result["inserted"] += await _flush_batch()
except IntegrityError:
logger.warning(
"injuries batch IntegrityError at record %d, "
@@ -266,7 +267,7 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
# 最终 flush(剩余不足一批的记录)
try:
await _flush_batch()
result["inserted"] += await _flush_batch()
except IntegrityError:
logger.warning(
"injuries final flush IntegrityError, "
@@ -293,10 +294,12 @@ async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> In
- query_status="success": 查询成功(即使结果也为空)
- query_status="source_not_configured": API_FOOTBALL_KEY 未配置
- query_status="query_error": 查询异常
- query_status="no_local_data": Key 已配置,但该队 injuries 表无任何历史记录
语义区分:
- 成功查询 + 空结果 → has_data=True(明确知道「无人伤停」)
- 源未配置 / 查询失败 → has_data=False(无法判断,跳过 LLM)
- success + 空结果 → has_data=True(明确知道「无人伤停」)
- no_local_data → has_data=False(本地尚未采集,需先 ingest)
- source_not_configured / query_error → has_data=False(无法判断)
"""
from sqlalchemy import select, func
from src.db.models import Injury
@@ -328,6 +331,16 @@ async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> In
result = await db.execute(stmt)
records = list(result.scalars().all())
# 判定「无本地数据」:该队从未有伤停记录
# 规则:该 team_id 在 injuries 表中 count==0
if not records:
count_stmt = select(func.count()).where(Injury.team_id == team_id)
team_count = (await db.execute(count_stmt)).scalar_one() or 0
if team_count == 0:
logger.debug("API Key 已配置但本地无伤停数据 team=%s,标记 no_local_data", team_id)
return InjuryQueryResult(records=[], query_status="no_local_data")
return InjuryQueryResult(records=records, query_status="success")
except Exception as e:
logger.exception("伤停查询异常 team=%s: %s", team_id, e)
+17 -1
View File
@@ -145,7 +145,23 @@ def _to_float(v) -> float | None:
def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None:
"""bzzoiro event → NormalizedMatch。"""
"""bzzoiro event → NormalizedMatch。
统计字段映射说明:
当前字段名基于常见足球 API 模式推测(home_shots/away_shots 等),
未经真实 bzzoiro 响应校验。若真实字段不同,映射结果将为 None。
⚠️ 待用真实响应核对的字段清单(请提供一份 event 样例验证):
- 射门: home_shots / away_shots(或 shots_home / shots_away)
- 射正: home_shots_on_target / away_shots_on_target(或 sot_home / sot_away)
- 角球: home_corners / away_corners(或 corners_home / corners_away)
- 控球: home_possession(或 possession,仅主队值)
- xG: home_xg / away_xg(或 xg_home / xg_away / expected_goals_home / expected_goals_away)
- 黄牌: home_yellow_cards / away_yellow_cards(或 yellow_cards_home / yellow_cards_away)
- 红牌: home_red_cards / away_red_cards(或 red_cards_home / red_cards_away)
映射策略:优先查主字段名,回退到别名。所有字段缺失时保持 None,不伪造。
"""
from src.data.team_names import normalize as normalize_name
date = _parse_date(raw.get("event_date"))
+2 -2
View File
@@ -200,9 +200,9 @@ class UnderstatSource:
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 局限:用 match_date(开球时间)近似,实际完赛时间约为 +2 小时
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
match_date = existing.match_date if existing.match_date else now
available_at = match_date
available_at = match_date + timedelta(hours=2)
existing.stats = MatchStats(
match_id=existing.id,
source="understat",