fix:批量修复了一些问题
This commit is contained in:
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
@@ -28,7 +28,7 @@ from src.core.runtime_config import (
|
||||
set_runtime_value,
|
||||
)
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import Injury, MatchStats
|
||||
from src.db.models import Injury, Match, MatchStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -317,3 +317,123 @@ async def test_datasource(name: str):
|
||||
"https://v3.football.api-sports.io/status",
|
||||
headers={"x-apisports-key": api_key},
|
||||
)
|
||||
|
||||
|
||||
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
|
||||
|
||||
|
||||
@router.get("/ingest/status")
|
||||
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
||||
"""各数据源采集健康概览(只读,不触发任何采集)。
|
||||
|
||||
返回尽量可得的信息;基于现有表近似的数据会标明 approximation。
|
||||
失败追踪目前依赖系统日志缓冲,无专用采集失败表。
|
||||
"""
|
||||
# ── bzzoiro: 落库目标是 matches 表,无专用采集时间戳 ──
|
||||
# 近似:以 matches 表最大 match_date(已覆盖的最远比赛日) 与 created_at 作为参考
|
||||
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
|
||||
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
|
||||
row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
func.max(Match.match_date).label("latest_match_date"),
|
||||
func.max(Match.created_at).label("latest_row_at"),
|
||||
).where(Match.match_status == "finished")
|
||||
)
|
||||
).one()
|
||||
bzzoiro = {
|
||||
"name": "bzzoiro",
|
||||
"label": "Bzzoiro",
|
||||
"key_configured": bool(bzzoiro_key),
|
||||
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
|
||||
"reachable": None, # 不主动探测
|
||||
"last_success_at": row.latest_row_at.isoformat() if row.latest_row_at else None,
|
||||
"latest_match_date": row.latest_match_date.isoformat() if row.latest_match_date else None,
|
||||
"recent_count": row.cnt or 0,
|
||||
"note": "approx:基于 matches.finished 表,last_success_at 为行写入时间而非精确采集完成时间",
|
||||
"last_failure": _last_failure_log("bzzoiro"),
|
||||
}
|
||||
|
||||
# ── understat: 落库到 match_stats(source=understat),有精确 retrieved_at ──
|
||||
row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
||||
).where(MatchStats.source == "understat")
|
||||
)
|
||||
).one()
|
||||
understat = {
|
||||
"name": "understat",
|
||||
"label": "Understat",
|
||||
"key_configured": True, # 无需 Key
|
||||
"reachable": None,
|
||||
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
||||
"recent_count": row.cnt or 0,
|
||||
"note": "基于 match_stats.source=understat 的 retrieved_at",
|
||||
"last_failure": _last_failure_log("understat"),
|
||||
}
|
||||
|
||||
# ── injuries: 落库到 injuries 表,有精确 retrieved_at;区分 Key/无数据/有数据 ──
|
||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
||||
row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
func.max(Injury.retrieved_at).label("latest_retrieved"),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
if not api_key:
|
||||
injuries_status, injuries_note = "key_not_configured", "API_FOOTBALL_KEY 未配置"
|
||||
elif not row.cnt:
|
||||
injuries_status, injuries_note = "no_data", "本地无伤停数据,请先采集"
|
||||
else:
|
||||
injuries_status, injuries_note = "has_data", f"共 {row.cnt} 条伤停记录"
|
||||
injuries = {
|
||||
"name": "injuries",
|
||||
"label": "Injuries (API-Football)",
|
||||
"key_configured": bool(api_key),
|
||||
"reachable": None,
|
||||
"status": injuries_status,
|
||||
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
||||
"recent_count": row.cnt or 0,
|
||||
"note": injuries_note,
|
||||
"last_failure": _last_failure_log("injuries"),
|
||||
}
|
||||
|
||||
return {"sources": [bzzoiro, understat, injuries]}
|
||||
|
||||
|
||||
def _last_failure_log(source: str) -> dict | None:
|
||||
"""从系统日志缓冲中查找某数据源的最近一次错误(仅作参考,非专用失败表)。"""
|
||||
entries = get_entries(min_level="ERROR", keyword=source, limit=5)
|
||||
if not entries:
|
||||
return None
|
||||
e = entries[0]
|
||||
return {
|
||||
"at": datetime.fromtimestamp(e["ts"]).isoformat(),
|
||||
"logger": e["logger"],
|
||||
"detail": e["message"][:200],
|
||||
"note": "approx:来自内存日志缓冲,非专用采集失败表;进程重启后清零",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def admin_stats(db: AsyncSession = Depends(get_db_read)):
|
||||
"""管理区统计(只读):最近预测次数。轻量聚合,无 LLM 调用。"""
|
||||
from sqlalchemy import func, text
|
||||
from src.db.models import Prediction
|
||||
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
r = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(Prediction.created_at >= day_ago).label("last_24h"),
|
||||
func.count().filter(Prediction.created_at >= week_ago).label("last_7d"),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}}
|
||||
|
||||
@@ -28,13 +28,16 @@ async def backtest(req: BacktestRequest):
|
||||
"""对历史比赛运行回测。
|
||||
|
||||
该接口会对每场已完赛比赛各发起一次 LLM 预测,成本高 —— 因此需要
|
||||
`X-API-Key` 鉴权(见审查报告 P2-7)。
|
||||
管理员鉴权(require_admin)。
|
||||
|
||||
对每场已完赛比赛:
|
||||
1. 用比赛之前的数据构建上下文 (防未来信息泄漏)
|
||||
2. 调 LLM 预测
|
||||
3. 用实际比分回填
|
||||
1. 用比赛之前的数据构建上下文 (防未来信息泄漏,cutoff=match_date-1天)
|
||||
2. 调 LLM 预测(强制 use_cache=False,避免缓存命中导致反复 settle 同一行)
|
||||
3. 用实际比分回填(settle)
|
||||
4. 统计准确率 / RMSE / 校准度
|
||||
|
||||
限流:单请求上限 200 场(默认 20),避免一次打爆 LLM 额度。
|
||||
回测写入 run_type='backtest',与实盘(live)互不覆盖(唯一键含 run_type)。
|
||||
"""
|
||||
try:
|
||||
summary = await run_backtest(
|
||||
@@ -53,10 +56,11 @@ async def backtest(req: BacktestRequest):
|
||||
"summary": {
|
||||
"total": summary.total,
|
||||
"scored": summary.scored,
|
||||
"success": summary.success,
|
||||
"degraded": summary.degraded,
|
||||
"accuracy_1x2": summary.accuracy_1x2,
|
||||
"avg_score_rmse": summary.avg_score_rmse,
|
||||
"avg_subjective_confidence": summary.avg_subjective_confidence,
|
||||
"calibration": summary.calibration,
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
|
||||
@@ -19,13 +19,19 @@ router = APIRouter(prefix="/api/v1", tags=["eval"])
|
||||
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""回填实际结果。
|
||||
|
||||
status 为 degraded/failed 的预测无法结算。
|
||||
status 为 degraded/failed 的预测无法结算(返回 400);
|
||||
记录不存在返回 404。
|
||||
"""
|
||||
try:
|
||||
pred = await settle_prediction(req.prediction_id, req.home_goals, req.away_goals)
|
||||
return {"id": pred.id, "settled": pred.settled}
|
||||
except ValueError as e:
|
||||
logger.warning("settle failed: %s", e)
|
||||
msg = str(e)
|
||||
# degraded/failed 拒绝:明确的 400,而非与"未找到"混为一谈
|
||||
if "无法结算" in msg:
|
||||
logger.warning("settle rejected: %s", msg)
|
||||
raise HTTPException(400, msg)
|
||||
logger.warning("settle failed: %s", msg)
|
||||
raise HTTPException(404, "预测记录不存在")
|
||||
except Exception as e:
|
||||
logger.exception("settle error")
|
||||
|
||||
@@ -73,6 +73,7 @@ async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str |
|
||||
logger.warning("bzzoiro 部分联赛存在错误: %s", sample)
|
||||
if merged["errors"]:
|
||||
logger.warning("bzzoiro 采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
||||
logger.debug("bzzoiro 采集明细: leagues=%s", list(merged["leagues"].keys()))
|
||||
except Exception:
|
||||
logger.exception("bzzoiro 采集任务失败")
|
||||
|
||||
|
||||
+108
-4
@@ -4,13 +4,13 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import MatchListOut, MatchOut
|
||||
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import League, Match
|
||||
from src.db.models import League, Match, Prediction
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["data"])
|
||||
|
||||
@@ -114,12 +114,26 @@ async def list_matches(
|
||||
async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
|
||||
.options(
|
||||
selectinload(Match.league),
|
||||
selectinload(Match.home_team),
|
||||
selectinload(Match.away_team),
|
||||
selectinload(Match.stats),
|
||||
)
|
||||
.where(Match.id == match_id)
|
||||
)
|
||||
m = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if m is None:
|
||||
raise HTTPException(404, "match not found")
|
||||
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM
|
||||
preds = (
|
||||
await db.execute(
|
||||
select(Prediction)
|
||||
.where(Prediction.match_id == match_id)
|
||||
.order_by(Prediction.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
).scalars().all()
|
||||
return MatchOut(
|
||||
id=m.id,
|
||||
league_code=m.league.code if m.league else None,
|
||||
@@ -135,4 +149,94 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
match_stage=m.match_stage,
|
||||
home_xg=m.stats.home_xg if m.stats else None,
|
||||
away_xg=m.stats.away_xg if m.stats else None,
|
||||
recent_predictions=[
|
||||
PredictionOut(
|
||||
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
|
||||
prompt_version=p.prompt_version, mode=p.mode or "single",
|
||||
pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals,
|
||||
alt_pred_home_goals=p.alt_pred_home_goals, alt_pred_away_goals=p.alt_pred_away_goals,
|
||||
pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence,
|
||||
reasoning=p.reasoning, status=p.status or "success",
|
||||
agent_outputs=p.agent_outputs, agent_weights=p.agent_weights,
|
||||
created_at=p.created_at, actual_home_goals=p.actual_home_goals,
|
||||
actual_away_goals=p.actual_away_goals, settled=p.settled,
|
||||
)
|
||||
for p in preds
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/matches/{match_id}/context", dependencies=[Depends(require_admin)])
|
||||
async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
"""比赛上下文(只读,不触发 LLM):双方近况 + 历史交锋。
|
||||
|
||||
全部基于现有数据聚合:
|
||||
- recent_home / recent客队:该队最近 5 场已完赛(进球/结果)
|
||||
- h2h:双方最近 5 次交手
|
||||
若数据不足,对应列表为空(前端展示空态)。
|
||||
"""
|
||||
m = (
|
||||
await db.execute(
|
||||
select(Match)
|
||||
.options(selectinload(Match.home_team), selectinload(Match.away_team))
|
||||
.where(Match.id == match_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if m is None:
|
||||
raise HTTPException(404, "match not found")
|
||||
home_id = m.home_team_id
|
||||
away_id = m.away_team_id
|
||||
|
||||
def _row_to_dict(row):
|
||||
return {
|
||||
"match_date": row.match_date.isoformat() if row.match_date else None,
|
||||
"home_team": row.home_team.name_zh or row.home_team.name if row.home_team else None,
|
||||
"away_team": row.away_team.name_zh or row.away_team.name if row.away_team else None,
|
||||
"home_goals": row.home_goals,
|
||||
"away_goals": row.away_goals,
|
||||
}
|
||||
|
||||
# 主队近况(已完赛,含主/客场)
|
||||
home_recent = (
|
||||
await db.execute(
|
||||
select(Match)
|
||||
.where(Match.match_status == "finished", Match.home_team_id == home_id)
|
||||
.order_by(Match.match_date.desc())
|
||||
.limit(5)
|
||||
.options(selectinload(Match.home_team), selectinload(Match.away_team))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# 客队近况
|
||||
away_recent = (
|
||||
await db.execute(
|
||||
select(Match)
|
||||
.where(Match.match_status == "finished", Match.away_team_id == away_id)
|
||||
.order_by(Match.match_date.desc())
|
||||
.limit(5)
|
||||
.options(selectinload(Match.home_team), selectinload(Match.away_team))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# 历史交锋(双方已完赛)
|
||||
h2h = (
|
||||
await db.execute(
|
||||
select(Match)
|
||||
.where(
|
||||
Match.match_status == "finished",
|
||||
or_(
|
||||
(Match.home_team_id == home_id) & (Match.away_team_id == away_id),
|
||||
(Match.home_team_id == away_id) & (Match.away_team_id == home_id),
|
||||
),
|
||||
)
|
||||
.order_by(Match.match_date.desc())
|
||||
.limit(5)
|
||||
.options(selectinload(Match.home_team), selectinload(Match.away_team))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"home_recent": [_row_to_dict(r) for r in home_recent],
|
||||
"away_recent": [_row_to_dict(r) for r in away_recent],
|
||||
"h2h": [_row_to_dict(r) for r in h2h],
|
||||
}
|
||||
|
||||
+71
-18
@@ -42,7 +42,7 @@ async def predict(req: PredictRequest):
|
||||
if m.match_status == "finished":
|
||||
raise HTTPException(400, "该比赛已完赛,不再支持预测")
|
||||
|
||||
# 2. LLM 调用(不持有任何 DB 连接)
|
||||
# 2. 预测调用(不持有任何 DB 连接)
|
||||
try:
|
||||
result = await predict_match(
|
||||
req.match_id,
|
||||
@@ -63,32 +63,77 @@ async def predict(req: PredictRequest):
|
||||
logger.exception("predict unexpected error")
|
||||
raise HTTPException(500, "预测失败,请查看服务器日志")
|
||||
|
||||
# baseline 模式:结果已是 dict,需独立落库(prediction_id)
|
||||
if req.mode == "baseline":
|
||||
prediction_id = await _persist_baseline(req.match_id, result)
|
||||
else:
|
||||
prediction_id = result.prediction_id
|
||||
|
||||
# 3. 结果映射(无 DB 访问)
|
||||
logger.info(
|
||||
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
|
||||
req.match_id, req.mode, result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
|
||||
req.match_id, req.mode,
|
||||
result.get("pred_home_goals") if isinstance(result, dict) else result.pred_home_goals,
|
||||
result.get("pred_away_goals") if isinstance(result, dict) else result.pred_away_goals,
|
||||
result.get("pred_1x2") if isinstance(result, dict) else result.pred_1x2,
|
||||
)
|
||||
|
||||
result_dict = result if isinstance(result, dict) else None
|
||||
|
||||
return PredictOut(
|
||||
prediction_id=result.prediction_id,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
prompt_version=getattr(result, "prompt_version", None),
|
||||
mode=getattr(result, "mode", "single"),
|
||||
pred_home_goals=result.pred_home_goals,
|
||||
pred_away_goals=result.pred_away_goals,
|
||||
alt_pred_home_goals=result.alt_pred_home_goals,
|
||||
alt_pred_away_goals=result.alt_pred_away_goals,
|
||||
pred_1x2=result.pred_1x2,
|
||||
subjective_confidence=result.subjective_confidence,
|
||||
reasoning=result.reasoning,
|
||||
agent_outputs=getattr(result, "agent_outputs", None),
|
||||
agent_weights=getattr(result, "agent_weights", None),
|
||||
context=result.context,
|
||||
latency_ms=result.latency_ms,
|
||||
prediction_id=prediction_id,
|
||||
provider=result.get("provider") if result_dict else result.provider,
|
||||
model=result.get("model") if result_dict else result.model,
|
||||
prompt_version=result.get("prompt_version") if result_dict else getattr(result, "prompt_version", None),
|
||||
mode=req.mode,
|
||||
pred_home_goals=result.get("pred_home_goals") if result_dict else result.pred_home_goals,
|
||||
pred_away_goals=result.get("pred_away_goals") if result_dict else result.pred_away_goals,
|
||||
alt_pred_home_goals=result.get("alt_pred_home_goals") if result_dict else result.alt_pred_home_goals,
|
||||
alt_pred_away_goals=result.get("alt_pred_away_goals") if result_dict else result.alt_pred_away_goals,
|
||||
pred_1x2=result.get("pred_1x2") if result_dict else result.pred_1x2,
|
||||
subjective_confidence=result.get("subjective_confidence") if result_dict else result.subjective_confidence,
|
||||
reasoning=result.get("reasoning") if result_dict else result.reasoning,
|
||||
status=result.get("status", "success") if result_dict else getattr(result, "status", "success"),
|
||||
agent_outputs=result.get("agent_outputs") if result_dict else getattr(result, "agent_outputs", None),
|
||||
agent_weights=result.get("agent_weights") if result_dict else getattr(result, "agent_weights", None),
|
||||
context=result.get("context", "") if result_dict else result.context,
|
||||
latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms,
|
||||
prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None),
|
||||
completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None),
|
||||
rate_limit_remaining=_predict_limiter.remaining(get_client_ip(request)),
|
||||
)
|
||||
|
||||
|
||||
async def _persist_baseline(match_id: int, baseline: dict) -> 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="baseline",
|
||||
values={
|
||||
"prompt_version": "baseline_v1",
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"latency_ms": 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.get("raw", baseline),
|
||||
"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,
|
||||
@@ -110,10 +155,14 @@ async def list_predictions(
|
||||
mode=p.mode or "single",
|
||||
pred_home_goals=p.pred_home_goals,
|
||||
pred_away_goals=p.pred_away_goals,
|
||||
alt_pred_home_goals=p.alt_pred_home_goals,
|
||||
alt_pred_away_goals=p.alt_pred_away_goals,
|
||||
pred_1x2=p.pred_1x2,
|
||||
subjective_confidence=p.subjective_confidence,
|
||||
reasoning=p.reasoning,
|
||||
status=p.status or "success",
|
||||
agent_outputs=p.agent_outputs,
|
||||
agent_weights=p.agent_weights,
|
||||
created_at=p.created_at,
|
||||
actual_home_goals=p.actual_home_goals,
|
||||
actual_away_goals=p.actual_away_goals,
|
||||
@@ -137,10 +186,14 @@ async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_r
|
||||
mode=p.mode or "single",
|
||||
pred_home_goals=p.pred_home_goals,
|
||||
pred_away_goals=p.pred_away_goals,
|
||||
alt_pred_home_goals=p.alt_pred_home_goals,
|
||||
alt_pred_away_goals=p.alt_pred_away_goals,
|
||||
pred_1x2=p.pred_1x2,
|
||||
subjective_confidence=p.subjective_confidence,
|
||||
reasoning=p.reasoning,
|
||||
status=p.status or "success",
|
||||
agent_outputs=p.agent_outputs,
|
||||
agent_weights=p.agent_weights,
|
||||
created_at=p.created_at,
|
||||
actual_home_goals=p.actual_home_goals,
|
||||
actual_away_goals=p.actual_away_goals,
|
||||
|
||||
Reference in New Issue
Block a user