refactor: 以 bzzoiro 为唯一数据源的全面重构
数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填
采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)
预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新
管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源
前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)
迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。
Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
co-authored by
new-provider/LongCat-2.0 <
parent
ec8f36abb2
commit
f05dc1ae15
@@ -28,33 +28,21 @@ from src.core.runtime_config import (
|
||||
set_runtime_value,
|
||||
)
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import Injury, Match, MatchStats
|
||||
from src.db.models import League, Match, MatchStats, Standing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
# ── 数据源元数据 ────────────────────────────────────────────────
|
||||
# ── 数据源元数据(bzzoiro 单一数据源) ────────────────────────────
|
||||
|
||||
_SOURCES: list[dict] = [
|
||||
{
|
||||
"name": "bzzoiro",
|
||||
"label": "Bzzoiro",
|
||||
"description": "历史赛程与比分数据,覆盖全球主要联赛",
|
||||
"description": "唯一数据源:赛程比分 + 积分榜 + 比赛详细统计(xG/射门/控球等)",
|
||||
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
|
||||
},
|
||||
{
|
||||
"name": "understat",
|
||||
"label": "Understat",
|
||||
"description": "xG(预期进球)进阶数据,无需 API Key,网页抓取",
|
||||
"setting_keys": [],
|
||||
},
|
||||
{
|
||||
"name": "injuries",
|
||||
"label": "Injuries (API-Football)",
|
||||
"description": "球员伤停信息,用于预测时考虑阵容完整性",
|
||||
"setting_keys": ["API_FOOTBALL_KEY"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -63,9 +51,7 @@ class SettingUpdateIn(BaseModel):
|
||||
|
||||
|
||||
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
|
||||
"""各源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
||||
if source == "injuries":
|
||||
return (await db.execute(select(func.max(Injury.retrieved_at)))).scalar()
|
||||
"""源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
||||
return (
|
||||
await db.execute(
|
||||
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
|
||||
@@ -303,20 +289,7 @@ async def test_datasource(name: str):
|
||||
params={"date_from": today, "date_to": today},
|
||||
)
|
||||
|
||||
if name == "understat":
|
||||
return await _probe(
|
||||
"https://understat.com/league/EPL/2025",
|
||||
headers={"User-Agent": "Mozilla/5.0", "Accept": "text/html"},
|
||||
)
|
||||
|
||||
# injuries (api-football)
|
||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
||||
if not api_key:
|
||||
return {"ok": False, "status": None, "latency_ms": 0, "detail": "API_FOOTBALL_KEY 未配置"}
|
||||
return await _probe(
|
||||
"https://v3.football.api-sports.io/status",
|
||||
headers={"x-apisports-key": api_key},
|
||||
)
|
||||
raise HTTPException(404, f"未知数据源: {name}")
|
||||
|
||||
|
||||
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
|
||||
@@ -324,16 +297,12 @@ async def test_datasource(name: str):
|
||||
|
||||
@router.get("/ingest/status")
|
||||
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
||||
"""各数据源采集健康概览(只读,不触发任何采集)。
|
||||
|
||||
返回尽量可得的信息;基于现有表近似的数据会标明 approximation。
|
||||
失败追踪目前依赖系统日志缓冲,无专用采集失败表。
|
||||
"""
|
||||
# ── bzzoiro: 落库目标是 matches 表,无专用采集时间戳 ──
|
||||
# 近似:以 matches 表最大 match_date(已覆盖的最远比赛日) 与 created_at 作为参考
|
||||
"""数据源采集健康概览(bzzoiro 单源;只读,不触发任何采集)。"""
|
||||
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
|
||||
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
|
||||
row = (
|
||||
|
||||
# 比赛覆盖
|
||||
match_row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
@@ -342,68 +311,39 @@ async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
||||
).where(Match.match_status == "finished")
|
||||
)
|
||||
).one()
|
||||
# 统计覆盖(精确 retrieved_at)
|
||||
stats_row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
||||
).where(MatchStats.source == "bzzoiro")
|
||||
)
|
||||
).one()
|
||||
# 积分榜覆盖
|
||||
standings_row = (
|
||||
await db.execute(select(func.count()).select_from(Standing))
|
||||
).scalar()
|
||||
|
||||
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_success_at": (stats_row.latest_retrieved or match_row.latest_row_at),
|
||||
"last_success_at_iso": (
|
||||
stats_row.latest_retrieved or match_row.latest_row_at
|
||||
).isoformat() if (stats_row.latest_retrieved or match_row.latest_row_at) else None,
|
||||
"latest_match_date": match_row.latest_match_date.isoformat() if match_row.latest_match_date else None,
|
||||
"recent_count": match_row.cnt or 0,
|
||||
"stats_count": stats_row.cnt or 0,
|
||||
"standings_count": standings_row or 0,
|
||||
"note": "last_success_at 取 match_stats.retrieved_at(统计回填)与 matches.created_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]}
|
||||
return {"sources": [bzzoiro]}
|
||||
|
||||
|
||||
def _last_failure_log(source: str) -> dict | None:
|
||||
@@ -437,3 +377,130 @@ async def admin_stats(db: AsyncSession = Depends(get_db_read)):
|
||||
)
|
||||
).one()
|
||||
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}}
|
||||
|
||||
|
||||
# ── 数据完整性分析(可视化数据源) ────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/data-completeness")
|
||||
async def data_completeness(db: AsyncSession = Depends(get_db_read)):
|
||||
"""按联赛统计数据完整性:比赛覆盖、字段覆盖、积分榜覆盖。
|
||||
|
||||
前端「数据完整性」页据此渲染,回答三个问题:
|
||||
1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||
2. 字段是否齐全(每张统计表各字段非空率)
|
||||
3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||
"""
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_NAMES, LEAGUE_COUNTRIES
|
||||
|
||||
out_leagues: list[dict] = []
|
||||
for code, bzz_id in BZZOIRO_LEAGUE_IDS.items():
|
||||
# 比赛覆盖
|
||||
m = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(Match.match_status == "finished").label("finished"),
|
||||
func.count().filter(Match.match_status == "scheduled").label("scheduled"),
|
||||
func.count().filter(Match.source_event_id.is_not(None)).label("with_source_id"),
|
||||
func.max(Match.match_date).label("latest_match"),
|
||||
func.min(Match.match_date).label("earliest_match"),
|
||||
)
|
||||
.select_from(Match)
|
||||
.join(League, League.id == Match.league_id)
|
||||
.where(League.code == code)
|
||||
)
|
||||
).one()
|
||||
# 统计字段覆盖(联表 matches)
|
||||
s = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("rows"),
|
||||
func.count(MatchStats.home_xg).label("xg"),
|
||||
func.count(MatchStats.home_shots).label("shots"),
|
||||
func.count(MatchStats.home_possession).label("possession"),
|
||||
func.count(MatchStats.home_corners).label("corners"),
|
||||
func.count(MatchStats.home_fouls).label("fouls"),
|
||||
func.count(MatchStats.home_big_chances).label("big_chances"),
|
||||
func.count(MatchStats.home_yellow_cards).label("cards"),
|
||||
)
|
||||
.select_from(MatchStats)
|
||||
.join(Match, Match.id == MatchStats.match_id)
|
||||
.join(League, League.id == Match.league_id)
|
||||
.where(League.code == code)
|
||||
)
|
||||
).one()
|
||||
# 积分榜覆盖
|
||||
st = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("rows"),
|
||||
func.max(Standing.retrieved_at).label("latest_retrieved"),
|
||||
)
|
||||
.select_from(Standing)
|
||||
.join(League, League.id == Standing.league_id)
|
||||
.where(League.code == code)
|
||||
)
|
||||
).one()
|
||||
|
||||
stats_rows = s.rows or 0
|
||||
pct = lambda n: round(n / stats_rows * 100, 1) if stats_rows else 0.0 # noqa: E731
|
||||
out_leagues.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": LEAGUE_NAMES.get(code, code),
|
||||
"country": LEAGUE_COUNTRIES.get(code),
|
||||
"matches": {
|
||||
"total": m.total or 0,
|
||||
"finished": m.finished or 0,
|
||||
"scheduled": m.scheduled or 0,
|
||||
"with_source_id": m.with_source_id or 0,
|
||||
"earliest_match": m.earliest_match.isoformat() if m.earliest_match else None,
|
||||
"latest_match": m.latest_match.isoformat() if m.latest_match else None,
|
||||
},
|
||||
"stats": {
|
||||
"rows": stats_rows,
|
||||
"fields": {
|
||||
"xg": {"count": s.xg or 0, "pct": pct(s.xg or 0)},
|
||||
"shots": {"count": s.shots or 0, "pct": pct(s.shots or 0)},
|
||||
"possession": {"count": s.possession or 0, "pct": pct(s.possession or 0)},
|
||||
"corners": {"count": s.corners or 0, "pct": pct(s.corners or 0)},
|
||||
"fouls": {"count": s.fouls or 0, "pct": pct(s.fouls or 0)},
|
||||
"big_chances": {"count": s.big_chances or 0, "pct": pct(s.big_chances or 0)},
|
||||
"cards": {"count": s.cards or 0, "pct": pct(s.cards or 0)},
|
||||
},
|
||||
},
|
||||
"standings": {
|
||||
"rows": st.rows or 0,
|
||||
"latest_retrieved": st.latest_retrieved.isoformat() if st.latest_retrieved else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 整体健康信号
|
||||
total_finished = sum(l["matches"]["finished"] for l in out_leagues)
|
||||
total_stats = sum(l["stats"]["rows"] for l in out_leagues)
|
||||
stats_coverage = round(total_stats / total_finished * 100, 1) if total_finished else 0.0
|
||||
issues: list[str] = []
|
||||
for l in out_leagues:
|
||||
if l["matches"]["finished"] == 0:
|
||||
issues.append(f"{l['name']}: 无已完赛比赛,请先运行「比赛数据」采集")
|
||||
elif l["stats"]["rows"] == 0:
|
||||
issues.append(f"{l['name']}: 已完赛 {l['matches']['finished']} 场但无统计回填,请运行「统计回填」采集")
|
||||
elif stats_coverage < 80:
|
||||
issues.append(f"{l['name']}: 统计覆盖率仅 {stats_coverage}%,建议增量回填")
|
||||
if l["standings"]["rows"] == 0:
|
||||
issues.append(f"{l['name']}: 无积分榜数据,请运行「积分榜」采集")
|
||||
if not issues:
|
||||
issues.append("各联赛数据完整度良好")
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"leagues": out_leagues,
|
||||
"totals": {
|
||||
"finished_matches": total_finished,
|
||||
"stats_rows": total_stats,
|
||||
"stats_coverage_pct": stats_coverage,
|
||||
},
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
+60
-87
@@ -1,4 +1,11 @@
|
||||
"""采集路由。"""
|
||||
"""采集路由(bzzoiro 单一数据源)。
|
||||
|
||||
任务类型:
|
||||
events — 比赛日程/比分(/events/)
|
||||
standings — 联赛积分榜(/leagues/{id}/standings/)
|
||||
stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||
all — 依次执行以上三项
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -7,10 +14,10 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, FDCO_TO_UNDERSTAT
|
||||
from src.api.schemas import IngestBzzoiroRequest
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS
|
||||
from src.data.bzzoiro import ingest_bzzoiro_event_stats, ingest_bzzoiro_standings
|
||||
from src.data.sources import get_source
|
||||
from src.data.injuries import ingest_injuries
|
||||
from src.db.unit_of_work import get_uow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,6 +27,8 @@ router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
||||
# 后台采集任务注册表:持强引用防止被 GC
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
VALID_TASKS = {"events", "standings", "stats", "all"}
|
||||
|
||||
|
||||
def _spawn(coro) -> None:
|
||||
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
||||
@@ -30,96 +39,60 @@ def _spawn(coro) -> None:
|
||||
|
||||
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
|
||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||
"""触发 bzzoiro 采集。"""
|
||||
# 未指定联赛 = 采集全部已知联赛;未指定状态 = 已完赛 + 未开赛都采集
|
||||
"""触发 bzzoiro 采集(events / standings / stats / all)。"""
|
||||
if req.task not in VALID_TASKS:
|
||||
raise HTTPException(status_code=422, detail=f"未知任务类型: {req.task}(可选: {', '.join(sorted(VALID_TASKS))})")
|
||||
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
|
||||
statuses = [req.status] if req.status else ["finished", "scheduled"]
|
||||
_spawn(_run_bzzoiro(leagues, req.date_from, req.date_to, statuses))
|
||||
task_label = {"events": "比赛数据", "standings": "积分榜", "stats": "统计回填", "all": "全量(比赛+积分榜+统计)"}[req.task]
|
||||
_spawn(_run_bzzoiro(req.task, leagues, req))
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"采集任务已启动(后台执行,状态: {', '.join(statuses)}),请在「系统日志」查看进度与结果",
|
||||
"message": f"采集任务已启动(后台执行,任务: {task_label}),请在「系统日志」查看进度与结果",
|
||||
}
|
||||
|
||||
|
||||
async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str | None, statuses: list[str]) -> None:
|
||||
async def _run_bzzoiro(task: str, leagues: list[str], req: IngestBzzoiroRequest) -> None:
|
||||
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
|
||||
try:
|
||||
source = get_source("bzzoiro")
|
||||
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||
async with get_uow() as session:
|
||||
for st in statuses:
|
||||
r = await source.ingest(
|
||||
session,
|
||||
leagues=leagues,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
status=st,
|
||||
if task in ("events", "all"):
|
||||
statuses = [req.status] if req.status else ["finished", "scheduled"]
|
||||
source = get_source("bzzoiro")
|
||||
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||
async with get_uow() as session:
|
||||
for st in statuses:
|
||||
r = await source.ingest(
|
||||
session, leagues=leagues,
|
||||
date_from=req.date_from, date_to=req.date_to, status=st,
|
||||
)
|
||||
merged["total_inserted"] += r.get("total_inserted", 0)
|
||||
merged["total_updated"] += r.get("total_updated", 0)
|
||||
merged["errors"].extend(r.get("errors", []))
|
||||
for code, stat in r.get("leagues", {}).items():
|
||||
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
|
||||
acc["inserted"] += stat.get("inserted", 0)
|
||||
acc["updated"] += stat.get("updated", 0)
|
||||
acc["errors"].extend(stat.get("errors", []))
|
||||
logger.info(
|
||||
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
||||
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
||||
)
|
||||
if merged["errors"]:
|
||||
logger.warning("bzzoiro 比赛采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
||||
|
||||
if task in ("standings", "all"):
|
||||
async with get_uow() as session:
|
||||
r = await ingest_bzzoiro_standings(session, leagues=leagues, season=req.season)
|
||||
if r["errors"]:
|
||||
logger.warning("bzzoiro 积分榜采集部分失败: %s", r["errors"][:3])
|
||||
else:
|
||||
logger.info("bzzoiro 积分榜采集完成: upsert %d 条", r["total_upserted"])
|
||||
|
||||
if task in ("stats", "all"):
|
||||
async with get_uow() as session:
|
||||
r = await ingest_bzzoiro_event_stats(
|
||||
session, leagues=leagues, limit=req.limit, only_missing=True
|
||||
)
|
||||
merged["total_inserted"] += r.get("total_inserted", 0)
|
||||
merged["total_updated"] += r.get("total_updated", 0)
|
||||
merged["errors"].extend(r.get("errors", []))
|
||||
for code, stat in r.get("leagues", {}).items():
|
||||
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
|
||||
acc["inserted"] += stat.get("inserted", 0)
|
||||
acc["updated"] += stat.get("updated", 0)
|
||||
acc["errors"].extend(stat.get("errors", []))
|
||||
league_errors = {c: stat["errors"] for c, stat in merged["leagues"].items() if stat.get("errors")}
|
||||
logger.info(
|
||||
"bzzoiro 采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
||||
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
||||
)
|
||||
if league_errors:
|
||||
sample = {c: errs[:1] for c, errs in list(league_errors.items())[:3]}
|
||||
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()))
|
||||
if r["errors"]:
|
||||
logger.warning("bzzoiro 统计回填错误 %d 条: %s", len(r["errors"]), r["errors"][:3])
|
||||
except Exception:
|
||||
logger.exception("bzzoiro 采集任务失败")
|
||||
|
||||
|
||||
@router.post("/ingest/understat", dependencies=[Depends(require_admin)])
|
||||
async def ingest_understat_route(req: IngestUnderstatRequest):
|
||||
"""触发 understat xG 回填。"""
|
||||
leagues_to_run = [req.league] if req.league else list(FDCO_TO_UNDERSTAT.keys())
|
||||
_spawn(_run_understat(leagues_to_run, req.season))
|
||||
return {"ok": True, "message": "xG 回填任务已启动(后台执行),请在「系统日志」查看结果"}
|
||||
|
||||
|
||||
async def _run_understat(leagues_to_run: list[str], season: int) -> None:
|
||||
try:
|
||||
source = get_source("understat")
|
||||
merged: dict = {"count": 0, "updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
||||
async with get_uow() as session:
|
||||
for league in leagues_to_run:
|
||||
r = await source.ingest(session, league=league, season=season)
|
||||
for k in ("count", "updated", "skipped", "unmatched"):
|
||||
merged[k] += r.get(k, 0)
|
||||
merged["errors"].extend(r.get("errors", []))
|
||||
logger.info(
|
||||
"understat 回填完成: 联赛 %d 个, 更新 %d, 未匹配 %d, 错误 %d",
|
||||
len(leagues_to_run), merged["updated"], merged["unmatched"], len(merged["errors"]),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("understat 回填任务失败")
|
||||
|
||||
|
||||
@router.post("/ingest/injuries", dependencies=[Depends(require_admin)])
|
||||
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
||||
"""触发伤停采集。"""
|
||||
_spawn(_run_injuries(req.date))
|
||||
return {"ok": True, "message": "伤停采集任务已启动(后台执行),请在「系统日志」查看结果"}
|
||||
|
||||
|
||||
async def _run_injuries(date: str | None) -> None:
|
||||
try:
|
||||
async with get_uow() as session:
|
||||
result = await ingest_injuries(session, date=date)
|
||||
logger.info(
|
||||
"injuries 采集完成: 新增 %d, 更新 %d, 错误 %d",
|
||||
result.get("count", 0), result.get("updated", 0), len(result.get("errors", [])),
|
||||
)
|
||||
if result.get("errors"):
|
||||
logger.warning("injuries 采集错误: %s", result["errors"][:3])
|
||||
except Exception:
|
||||
logger.exception("injuries 采集任务失败")
|
||||
logger.exception("bzzoiro 采集任务失败(task=%s)", task)
|
||||
|
||||
@@ -10,11 +10,28 @@ from sqlalchemy.orm import selectinload
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import League, Match, Prediction
|
||||
from src.db.models import League, Match, Prediction, Standing
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["data"])
|
||||
|
||||
|
||||
def _stats_dict(stats) -> dict | None:
|
||||
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
|
||||
if stats is None:
|
||||
return None
|
||||
return {
|
||||
"home_xg": stats.home_xg, "away_xg": stats.away_xg,
|
||||
"home_shots": stats.home_shots, "away_shots": stats.away_shots,
|
||||
"home_shots_on_target": stats.home_shots_on_target, "away_shots_on_target": stats.away_shots_on_target,
|
||||
"home_corners": stats.home_corners, "away_corners": stats.away_corners,
|
||||
"home_possession": stats.home_possession,
|
||||
"home_yellow_cards": stats.home_yellow_cards, "away_yellow_cards": stats.away_yellow_cards,
|
||||
"home_red_cards": stats.home_red_cards, "away_red_cards": stats.away_red_cards,
|
||||
"home_big_chances": stats.home_big_chances, "away_big_chances": stats.away_big_chances,
|
||||
"home_fouls": stats.home_fouls, "away_fouls": stats.away_fouls,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
|
||||
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
||||
stmt = select(League).order_by(League.name)
|
||||
@@ -155,6 +172,7 @@ 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,
|
||||
stats=_stats_dict(m.stats) if m.stats else None,
|
||||
recent_predictions=[
|
||||
PredictionOut(
|
||||
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
|
||||
@@ -246,3 +264,81 @@ async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
"away_recent": [_row_to_dict(r) for r in away_recent],
|
||||
"h2h": [_row_to_dict(r) for r in h2h],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/standings")
|
||||
async def list_standings(
|
||||
league: str | None = Query(None, description="联赛代码,如 E0;空 = 全部联赛"),
|
||||
season: str | None = Query(None, description="赛季标签,如 2026-2027;空 = 各联赛最新赛季"),
|
||||
db: AsyncSession = Depends(get_db_read),
|
||||
):
|
||||
"""联赛积分榜(只读)。按联赛分组,每张榜按 position 排序。
|
||||
|
||||
season 为空时返回每个联赛最新采集到的赛季榜单(适合前端"查看最新积分榜")。
|
||||
"""
|
||||
# 取每个联赛最新赛季(当 season 为空时)
|
||||
latest_seasons: dict[int, str] = {}
|
||||
if season is None:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Standing.league_id, func.max(Standing.season).label("latest"))
|
||||
.group_by(Standing.league_id)
|
||||
)
|
||||
).all()
|
||||
latest_seasons = {r.league_id: r.latest for r in rows}
|
||||
|
||||
q = (
|
||||
select(Standing, League)
|
||||
.join(League, League.id == Standing.league_id)
|
||||
.order_by(League.name.asc(), Standing.position.asc())
|
||||
)
|
||||
if league:
|
||||
q = q.where(League.code == league)
|
||||
if season:
|
||||
q = q.where(Standing.season == season)
|
||||
else:
|
||||
# 多联赛时只保留各联赛最新赛季
|
||||
if latest_seasons:
|
||||
q = q.where(
|
||||
or_(
|
||||
*(
|
||||
(Standing.league_id == lid) & (Standing.season == ls)
|
||||
for lid, ls in latest_seasons.items()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
rows = (await db.execute(q)).all()
|
||||
|
||||
# 按联赛分组
|
||||
grouped: dict[str, dict] = {}
|
||||
for standing, lg in rows:
|
||||
key = lg.code
|
||||
if key not in grouped:
|
||||
grouped[key] = {
|
||||
"league_code": lg.code,
|
||||
"league_name": lg.name,
|
||||
"season": standing.season,
|
||||
"retrieved_at": standing.retrieved_at.isoformat() if standing.retrieved_at else None,
|
||||
"rows": [],
|
||||
}
|
||||
grouped[key]["rows"].append(
|
||||
{
|
||||
"position": standing.position,
|
||||
"team": standing.team.name_zh or standing.team.name if standing.team else "?",
|
||||
"team_en": standing.team.name if standing.team else "?",
|
||||
"played": standing.played,
|
||||
"won": standing.won,
|
||||
"drawn": standing.drawn,
|
||||
"lost": standing.lost,
|
||||
"goals_for": standing.goals_for,
|
||||
"goals_against": standing.goals_against,
|
||||
"goal_diff": standing.goal_diff,
|
||||
"points": standing.points,
|
||||
"xg_for": standing.xg_for,
|
||||
"xg_against": standing.xg_against,
|
||||
"form": standing.form,
|
||||
"zone": standing.zone,
|
||||
}
|
||||
)
|
||||
return {"leagues": list(grouped.values())}
|
||||
|
||||
+5
-17
@@ -29,6 +29,8 @@ class MatchOut(BaseModel):
|
||||
match_stage: str | None
|
||||
home_xg: float | None = None
|
||||
away_xg: float | None = None
|
||||
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
|
||||
stats: dict | None = None
|
||||
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
|
||||
recent_predictions: list[PredictionOut] = []
|
||||
|
||||
@@ -107,6 +109,9 @@ class IngestBzzoiroRequest(BaseModel):
|
||||
date_from: str | None = None
|
||||
date_to: str | None = None
|
||||
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
|
||||
task: str = Field("events", description="采集任务: events(比赛)/standings(积分榜)/stats(统计回填)/all")
|
||||
limit: int = Field(100, ge=1, le=500, description="stats 回填单次最大比赛数")
|
||||
season: str | None = Field(None, description="standings 赛季,如 '2026-2027';空 = 当前赛季")
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
@@ -116,23 +121,6 @@ class IngestResponse(BaseModel):
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class IngestUnderstatRequest(BaseModel):
|
||||
league: str | None = Field(None, description="联赛代码,如 'E0';空 = 全部已知联赛")
|
||||
season: int = Field(default_factory=lambda: date.today().year, description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
||||
|
||||
|
||||
class IngestInjuriesRequest(BaseModel):
|
||||
date: str | None = Field(None, description="日期 YYYY-MM-DD,为空则采集当天")
|
||||
|
||||
|
||||
class IngestSimpleResponse(BaseModel):
|
||||
count: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
unmatched: int = 0
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class SettleRequest(BaseModel):
|
||||
prediction_id: int
|
||||
home_goals: int = Field(ge=0, le=30)
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
使用方:
|
||||
- src/llm/provider.py: LLM 调用
|
||||
- src/data/bzzoiro.py: bzzoiro 比赛数据
|
||||
- src/data/understat.py: xG 抓取
|
||||
- src/data/injuries.py: 伤停抓取
|
||||
- src/data/bzzoiro.py: bzzoiro 比赛数据 / 积分榜 / 事件统计
|
||||
|
||||
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
||||
调用方可通过 `timeout` 参数覆盖 per-request 超时。
|
||||
|
||||
@@ -64,7 +64,7 @@ AGENT_META: list[dict] = [
|
||||
{"id": "form", "label": "近期状态分析专家"},
|
||||
{"id": "stats", "label": "攻防数据分析专家"},
|
||||
{"id": "home_away", "label": "主客因素分析专家"},
|
||||
{"id": "injuries", "label": "阵容完整性分析专家"},
|
||||
{"id": "standings", "label": "联赛排名分析专家"},
|
||||
{"id": "h2h", "label": "历史交锋分析专家"},
|
||||
{"id": "aggregator", "label": "终裁分析专家"},
|
||||
]
|
||||
|
||||
+326
-64
@@ -1,12 +1,15 @@
|
||||
"""Bzzoiro 数据源:抓取 + 入库。
|
||||
"""Bzzoiro 数据源:抓取 + 入库(单一数据源)。
|
||||
|
||||
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
||||
三条管线:
|
||||
1. events — 比赛日程/比分(/events/),含 source_event_id 血缘
|
||||
2. standings— 联赛积分榜快照(/leagues/{id}/standings/)
|
||||
3. stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||
|
||||
使用 Repository 模式进行数据访问,不直接控制事务(由调用方 UnitOfWork 控制)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import logging
|
||||
import random
|
||||
from collections.abc import Iterable
|
||||
@@ -22,7 +25,7 @@ from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES,
|
||||
from src.data.normalize import normalize_bzzoiro
|
||||
from src.data.team_names_zh import zh_name
|
||||
from src.data.sources import register
|
||||
from src.db.models import League, Match, MatchStats, Team
|
||||
from src.db.models import League, Match, MatchStats, Standing, Team
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +39,16 @@ def _to_date(value):
|
||||
return value
|
||||
|
||||
|
||||
def _to_int_or_none(value) -> int | None:
|
||||
"""宽松转 int(用于上游 ID 解析,失败返回 None 不抛错)。"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
||||
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
||||
|
||||
@@ -262,38 +275,13 @@ class BzzoiroSource:
|
||||
home_ht_goals=nm.home_ht_goals,
|
||||
away_ht_goals=nm.away_ht_goals,
|
||||
match_stage=nm.match_stage,
|
||||
source_event_id=_to_int_or_none(raw.get("id")),
|
||||
)
|
||||
db.add(m)
|
||||
await db.flush()
|
||||
existing_matches[match_key] = m # 防止同批重复
|
||||
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
|
||||
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 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
||||
# 近似:开球 + 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,
|
||||
away_xg=nm.away_xg,
|
||||
home_shots=nm.home_shots,
|
||||
away_shots=nm.away_shots,
|
||||
home_shots_on_target=nm.home_shots_on_target,
|
||||
away_shots_on_target=nm.away_shots_on_target,
|
||||
home_corners=nm.home_corners,
|
||||
away_corners=nm.away_corners,
|
||||
home_possession=nm.home_possession,
|
||||
home_yellow_cards=nm.home_yellow_cards,
|
||||
away_yellow_cards=nm.away_yellow_cards,
|
||||
home_red_cards=nm.home_red_cards,
|
||||
away_red_cards=nm.away_red_cards,
|
||||
source="bzzoiro",
|
||||
source_record_id=str(raw.get("id", "")),
|
||||
retrieved_at=now,
|
||||
available_at=available_at,
|
||||
)
|
||||
db.add(stats)
|
||||
# 统计字段不在 /events/ 载荷中(单独由 stats 管线回填),
|
||||
# 此处不再创建 MatchStats。
|
||||
league_r["inserted"] += 1
|
||||
else:
|
||||
# 已有比赛: 直接从内存获取对象更新(无需再查询)
|
||||
@@ -310,37 +298,11 @@ class BzzoiroSource:
|
||||
if existing_match.match_stage is None and nm.match_stage:
|
||||
existing_match.match_stage = nm.match_stage
|
||||
changed = True
|
||||
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
|
||||
if existing_match.stats is None and (
|
||||
nm.home_xg is not None or nm.away_xg is not None
|
||||
or nm.home_shots is not None or nm.away_shots is not None
|
||||
or nm.home_corners is not None or nm.away_corners is not None
|
||||
or nm.home_possession is not None
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
||||
# 近似:开球 + 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",
|
||||
source_record_id=str(raw.get("id", "")),
|
||||
retrieved_at=now,
|
||||
available_at=available_at,
|
||||
)
|
||||
db.add(existing_match.stats)
|
||||
await db.flush()
|
||||
if existing_match.stats is not None:
|
||||
for fld 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"):
|
||||
if getattr(existing_match.stats, fld, None) is None:
|
||||
v = getattr(nm, fld, None)
|
||||
if v is not None:
|
||||
setattr(existing_match.stats, fld, v)
|
||||
changed = True
|
||||
if existing_match.source_event_id is None:
|
||||
eid = _to_int_or_none(raw.get("id"))
|
||||
if eid is not None:
|
||||
existing_match.source_event_id = eid
|
||||
changed = True
|
||||
if changed:
|
||||
league_r["updated"] += 1
|
||||
|
||||
@@ -349,3 +311,303 @@ class BzzoiroSource:
|
||||
result["total_inserted"] += league_r["inserted"]
|
||||
result["total_updated"] += league_r["updated"]
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 积分榜管线:/leagues/{id}/standings/ → standings 表
|
||||
# ============================================================
|
||||
|
||||
async def fetch_bzzoiro_standings(league_code: str, season: str | None = None) -> dict:
|
||||
"""抓取联赛积分榜(纯抓取,不入库)。season 为 None 时取当前赛季。"""
|
||||
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
|
||||
if league_id is None:
|
||||
raise ValueError(f"未知联赛代码: {league_code}")
|
||||
params: dict = {}
|
||||
if season:
|
||||
params["season"] = season
|
||||
return await _fetch_json_async(f"/leagues/{league_id}/standings/", params)
|
||||
|
||||
|
||||
def _season_label_from_dates(start_date, end_date) -> str:
|
||||
"""从赛季起止日期推导赛季标签(与 derive_season_label 语义一致)。"""
|
||||
try:
|
||||
if isinstance(start_date, str):
|
||||
start = datetime.fromisoformat(start_date[:10])
|
||||
else:
|
||||
start = start_date
|
||||
y = start.year
|
||||
return f"{y}-{y + 1}" if start.month >= 8 else f"{y - 1}-{y}"
|
||||
except (TypeError, ValueError):
|
||||
return "?"
|
||||
|
||||
|
||||
async def ingest_bzzoiro_standings(db, *, leagues: Iterable[str], season: str | None = None) -> dict:
|
||||
"""采集积分榜 → upsert standings 表。
|
||||
|
||||
season 为 None 时采集当前赛季(bzzoiro 默认返回 is_current 赛季)。
|
||||
球队名与 events 管线使用同一 normalize 规则,保证 Team 匹配。
|
||||
"""
|
||||
from src.data.team_names import normalize as normalize_name
|
||||
|
||||
result: dict = {"leagues": {}, "total_upserted": 0, "errors": []}
|
||||
for code in leagues:
|
||||
league_r: dict = {"upserted": 0, "teams_created": 0, "rows": 0}
|
||||
try:
|
||||
payload = await fetch_bzzoiro_standings(code, season=season)
|
||||
except Exception as e:
|
||||
logger.exception("bzzoiro standings fetch failed for %s", code)
|
||||
result["leagues"][code] = {"error": str(e)}
|
||||
result["errors"].append(f"{code}: {e}")
|
||||
continue
|
||||
|
||||
rows = payload.get("standings") or []
|
||||
if not rows:
|
||||
result["leagues"][code] = {"error": "无积分榜数据(赛季未开始或未提供)"}
|
||||
result["errors"].append(f"{code}: 无积分榜数据")
|
||||
continue
|
||||
|
||||
# 联赛
|
||||
stmt = select(League).where(League.code == code)
|
||||
league = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if league is None:
|
||||
league = League(code=code, name=LEAGUE_NAMES.get(code, code), country=LEAGUE_COUNTRIES.get(code))
|
||||
db.add(league)
|
||||
await db.flush()
|
||||
|
||||
# 赛季标签:优先用返回的 season 对象推导
|
||||
season_obj = payload.get("season") or {}
|
||||
season_label = _season_label_from_dates(
|
||||
season_obj.get("start_date"), season_obj.get("end_date")
|
||||
)
|
||||
if season_label == "?":
|
||||
season_label = season or ""
|
||||
|
||||
# 批量预载球队
|
||||
names = {normalize_name(str(r.get("team_name", ""))) for r in rows}
|
||||
names.discard("")
|
||||
team_map: dict[str, Team] = {}
|
||||
if names:
|
||||
stmt = select(Team).where(Team.name.in_(names))
|
||||
for t in (await db.execute(stmt)).scalars():
|
||||
team_map[t.name] = t
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for r in rows:
|
||||
team_name = normalize_name(str(r.get("team_name", "")))
|
||||
if not team_name:
|
||||
continue
|
||||
team = team_map.get(team_name)
|
||||
if team is None:
|
||||
team = Team(name=team_name, name_zh=zh_name(team_name))
|
||||
db.add(team)
|
||||
await db.flush()
|
||||
team_map[team_name] = team
|
||||
league_r["teams_created"] += 1
|
||||
|
||||
zone = r.get("zone") or {}
|
||||
values = dict(
|
||||
position=_to_int_or_none(r.get("position")) or 0,
|
||||
played=_to_int_or_none(r.get("played")) or 0,
|
||||
won=_to_int_or_none(r.get("won")) or 0,
|
||||
drawn=_to_int_or_none(r.get("drawn")) or 0,
|
||||
lost=_to_int_or_none(r.get("lost")) or 0,
|
||||
goals_for=_to_int_or_none(r.get("gf")) or 0,
|
||||
goals_against=_to_int_or_none(r.get("ga")) or 0,
|
||||
goal_diff=_to_int_or_none(r.get("gd")) or 0,
|
||||
points=_to_int_or_none(r.get("pts")) or 0,
|
||||
xg_for=_to_float_or_none(r.get("xgf")),
|
||||
xg_against=_to_float_or_none(r.get("xga")),
|
||||
form=r.get("form") or None,
|
||||
zone=zone.get("label") or zone.get("key") or None,
|
||||
updated_at=now,
|
||||
retrieved_at=now,
|
||||
)
|
||||
|
||||
stmt = select(Standing).where(
|
||||
Standing.league_id == league.id,
|
||||
Standing.season == season_label,
|
||||
Standing.team_id == team.id,
|
||||
)
|
||||
standing = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if standing is None:
|
||||
standing = Standing(
|
||||
league_id=league.id, season=season_label, team_id=team.id, **values
|
||||
)
|
||||
db.add(standing)
|
||||
else:
|
||||
for k, v in values.items():
|
||||
setattr(standing, k, v)
|
||||
league_r["upserted"] += 1
|
||||
|
||||
league_r["rows"] = len(rows)
|
||||
result["leagues"][code] = league_r
|
||||
result["total_upserted"] += league_r["upserted"]
|
||||
logger.info(
|
||||
"bzzoiro standings 采集完成: %s 赛季 %s, upsert %d/%d",
|
||||
code, season_label, league_r["upserted"], league_r["rows"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 统计回填管线:/events/{id}/stats/ → match_stats 表
|
||||
# ============================================================
|
||||
|
||||
# bzzoiro stats 字段 → MatchStats 字段映射(stats.home / stats.away 下)
|
||||
_STATS_FIELD_MAP = {
|
||||
"xg": ("home_xg", "away_xg"), # 回退 expected_goals
|
||||
"ball_possession": ("home_possession", None), # 只取主队值,客队=100-home
|
||||
"total_shots": ("home_shots", "away_shots"),
|
||||
"shots_on_target": ("home_shots_on_target", "away_shots_on_target"),
|
||||
"corner_kicks": ("home_corners", "away_corners"),
|
||||
"yellow_cards": ("home_yellow_cards", "away_yellow_cards"),
|
||||
"red_cards": ("home_red_cards", "away_red_cards"),
|
||||
"big_chances": ("home_big_chances", "away_big_chances"),
|
||||
"fouls": ("home_fouls", "away_fouls"),
|
||||
}
|
||||
|
||||
|
||||
def _pick(d: dict, *keys):
|
||||
"""按优先级取第一个非空字段值。"""
|
||||
for k in keys:
|
||||
v = d.get(k)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _stats_from_payload(payload: dict) -> dict:
|
||||
"""把 /events/{id}/stats/ 响应映射成 MatchStats 字段 dict。
|
||||
|
||||
响应结构: {"event_id": ..., "stats": {"home": {...}, "away": {...}}}
|
||||
"""
|
||||
stats = (payload or {}).get("stats") or {}
|
||||
home = stats.get("home") or {}
|
||||
away = stats.get("away") or {}
|
||||
out: dict = {}
|
||||
|
||||
xg_h = _pick(home, "xg", "expected_goals")
|
||||
xg_a = _pick(away, "xg", "expected_goals")
|
||||
if xg_h is not None:
|
||||
out["home_xg"] = _to_float_or_none(xg_h)
|
||||
if xg_a is not None:
|
||||
out["away_xg"] = _to_float_or_none(xg_a)
|
||||
|
||||
poss = home.get("ball_possession")
|
||||
if poss is not None:
|
||||
p = _to_float_or_none(poss)
|
||||
if p is not None:
|
||||
out["home_possession"] = p
|
||||
out["away_possession"] = round(100 - p, 1) if 0 <= p <= 100 else None
|
||||
|
||||
for src, (h_fld, a_fld) in _STATS_FIELD_MAP.items():
|
||||
if src in ("xg", "ball_possession"):
|
||||
continue # 已处理
|
||||
hv = home.get(src)
|
||||
av = away.get(src)
|
||||
if hv is not None and h_fld:
|
||||
out[h_fld] = _to_int_or_none(hv)
|
||||
if av is not None and a_fld:
|
||||
out[a_fld] = _to_int_or_none(av)
|
||||
return out
|
||||
|
||||
|
||||
def _to_float_or_none(value) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def ingest_bzzoiro_event_stats(
|
||||
db,
|
||||
*,
|
||||
leagues: Iterable[str],
|
||||
limit: int = 100,
|
||||
only_missing: bool = True,
|
||||
) -> dict:
|
||||
"""回填已完赛比赛的详细统计(逐场调 /events/{id}/stats/)。
|
||||
|
||||
筛选条件: match_status=finished 且 source_event_id 非空。
|
||||
only_missing=True 时跳过已有统计的比赛(增量);False 则全量刷新。
|
||||
limit 控制单次最多处理的比赛数(上游限速 1.2s/请求,大批量需分次触发)。
|
||||
"""
|
||||
result: dict = {"fetched": 0, "created": 0, "updated": 0, "skipped": 0, "errors": []}
|
||||
|
||||
league_ids = [BZZOIRO_LEAGUE_IDS[c] for c in leagues if c in BZZOIRO_LEAGUE_IDS]
|
||||
if not league_ids:
|
||||
result["errors"].append("无有效联赛代码")
|
||||
return result
|
||||
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(select(Match.stats))
|
||||
.where(Match.match_status == "finished")
|
||||
.where(Match.source_event_id.is_not(None))
|
||||
.where(Match.league_id.in_(league_ids))
|
||||
.order_by(Match.match_date.desc())
|
||||
.limit(limit * 3 if only_missing else limit)
|
||||
)
|
||||
matches = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
processed = 0
|
||||
for m in matches:
|
||||
if processed >= limit:
|
||||
break
|
||||
if only_missing and m.stats is not None and m.stats.home_shots is not None:
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
processed += 1
|
||||
try:
|
||||
payload = await _fetch_json_async(f"/events/{m.source_event_id}/stats/")
|
||||
except Exception as e:
|
||||
logger.warning("stats fetch failed match=%s event=%s: %s", m.id, m.source_event_id, e)
|
||||
result["errors"].append(f"match {m.id}: {e}")
|
||||
await asyncio.sleep(REQUEST_INTERVAL)
|
||||
continue
|
||||
|
||||
result["fetched"] += 1
|
||||
fields = _stats_from_payload(payload)
|
||||
if not fields:
|
||||
result["skipped"] += 1
|
||||
await asyncio.sleep(REQUEST_INTERVAL)
|
||||
continue
|
||||
|
||||
if m.stats is None:
|
||||
# available_at 语义:完赛统计最早在开球+2h 可用(回测防泄漏)
|
||||
available_at = m.match_date + timedelta(hours=2) if m.match_date else now
|
||||
m.stats = MatchStats(
|
||||
match_id=m.id,
|
||||
source="bzzoiro",
|
||||
source_record_id=str(m.source_event_id),
|
||||
retrieved_at=now,
|
||||
available_at=available_at,
|
||||
)
|
||||
db.add(m.stats)
|
||||
result["created"] += 1
|
||||
else:
|
||||
result["updated"] += 1
|
||||
if m.stats.source is None:
|
||||
m.stats.source = "bzzoiro"
|
||||
m.stats.source_record_id = str(m.source_event_id)
|
||||
if m.stats.retrieved_at is None:
|
||||
m.stats.retrieved_at = now
|
||||
if m.stats.available_at is None and m.match_date:
|
||||
m.stats.available_at = m.match_date + timedelta(hours=2)
|
||||
|
||||
for fld, v in fields.items():
|
||||
# away_possession 为计算字段,模型无此列,跳过
|
||||
if hasattr(m.stats, fld):
|
||||
setattr(m.stats, fld, v)
|
||||
|
||||
await asyncio.sleep(REQUEST_INTERVAL)
|
||||
|
||||
logger.info(
|
||||
"bzzoiro stats 回填完成: 抓取 %d, 新建 %d, 更新 %d, 跳过 %d, 错误 %d",
|
||||
result["fetched"], result["created"], result["updated"],
|
||||
result["skipped"], len(result["errors"]),
|
||||
)
|
||||
return result
|
||||
|
||||
+1
-10
@@ -1,4 +1,4 @@
|
||||
"""数据源配置常量(联赛映射)。"""
|
||||
"""数据源配置常量(联赛映射)。数据源统一为 bzzoiro(单一数据源)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
# fdco 风格代码 → bzzoiro league_id
|
||||
@@ -12,15 +12,6 @@ BZZOIRO_LEAGUE_IDS: dict[str, int] = {
|
||||
"EL": 8, # Europa League
|
||||
}
|
||||
|
||||
# fdco 代码 → understat 联赛代码
|
||||
FDCO_TO_UNDERSTAT: dict[str, str] = {
|
||||
"E0": "EPL",
|
||||
"SP1": "La_liga",
|
||||
"D1": "Bundesliga",
|
||||
"I1": "Serie_A",
|
||||
"F1": "Ligue_1",
|
||||
}
|
||||
|
||||
# fdco 代码 → 显示名
|
||||
LEAGUE_NAMES: dict[str, str] = {
|
||||
"E0": "Premier League",
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
"""伤停数据采集器(api-football / api-sports.io)。
|
||||
|
||||
采集伤停数据并入库(injuries 表),供 injuries agent 使用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
|
||||
|
||||
@dataclass
|
||||
class InjuryQueryResult:
|
||||
"""伤停查询结果(区分「查询成功但为空」与「查询失败/源未配置」)。"""
|
||||
|
||||
records: list["Injury"]
|
||||
query_status: str # "success" | "source_not_configured" | "query_error"
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""成功查询(即使结果为空)视为有明确名单,has_data=True。"""
|
||||
return self.query_status == "success"
|
||||
from src.core.http_client import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://v3.football.api-sports.io"
|
||||
DEFAULT_HOST = "v3.football.api-sports.io"
|
||||
|
||||
# 缓存目录:系统临时目录
|
||||
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
|
||||
|
||||
# Fix 5: 缓存 TTL 从 7 天改为 6 小时,同日再采不会命中旧数据
|
||||
_CACHE_TTL_HOURS = 6
|
||||
|
||||
|
||||
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
||||
"""采集伤停数据。
|
||||
|
||||
Args:
|
||||
date: 日期 (YYYY-MM-DD),返当天全部伤停
|
||||
fixture_id: 指定比赛 ID
|
||||
league_id: 指定联赛 ID
|
||||
|
||||
Returns:
|
||||
伤停记录列表
|
||||
"""
|
||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError("API_FOOTBALL_KEY 未设置")
|
||||
|
||||
cache_dir = _CACHE_DIR
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Fix 5: 缓存命中 (6 小时内有效)
|
||||
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
|
||||
cache_file = cache_dir / cache_key
|
||||
if cache_file.exists():
|
||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
||||
if age_hours < _CACHE_TTL_HOURS:
|
||||
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours)
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
logger.debug("injuries cache expired: %s (%.1fh old)", cache_key, age_hours)
|
||||
|
||||
headers = {
|
||||
"x-apisports-key": api_key,
|
||||
"x-rapidapi-host": DEFAULT_HOST,
|
||||
}
|
||||
params: dict[str, Any] = {}
|
||||
if date:
|
||||
params["date"] = date
|
||||
if fixture_id:
|
||||
params["fixture"] = fixture_id
|
||||
if league_id:
|
||||
params["league"] = league_id
|
||||
|
||||
url = f"{API_BASE}/injuries"
|
||||
|
||||
# 重试
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
client = get_client()
|
||||
resp = await asyncio.wait_for(
|
||||
client.get(
|
||||
url, headers=headers, params=params,
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
),
|
||||
timeout=60.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
break
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt == 2:
|
||||
raise
|
||||
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
|
||||
logger.warning("injuries fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
raise RuntimeError(f"injuries fetch failed: {last_exc}")
|
||||
|
||||
data = resp.json()
|
||||
injuries = data.get("response", [])
|
||||
|
||||
# 写缓存
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(injuries, ensure_ascii=False, default=str, fp=f)
|
||||
|
||||
return injuries
|
||||
|
||||
|
||||
async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
"""采集伤停数据并入库(injuries 表)。
|
||||
|
||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||
|
||||
Fix 1: 使用 SAVEPOINT(begin_nested)避免整批回滚丢数据。
|
||||
Fix 2: 正确解析并写入 return_date。
|
||||
Fix 3: retrieved_at 比较统一用 timezone-aware datetime。
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from src.data.team_names import normalize as normalize_name
|
||||
from src.db.models import Injury, Team
|
||||
|
||||
result = {"count": 0, "inserted": 0, "errors": []}
|
||||
|
||||
try:
|
||||
raw_injuries = await fetch_injuries(date=date)
|
||||
except Exception as e:
|
||||
logger.exception("injuries fetch failed")
|
||||
result["errors"].append(f"fetch failed: {e}")
|
||||
return result
|
||||
|
||||
result["count"] = len(raw_injuries)
|
||||
|
||||
# 预加载所有球队(用于按名匹配)
|
||||
teams = (await db.execute(select(Team))).scalars().all()
|
||||
team_by_name = {t.name: t.id for t in teams}
|
||||
|
||||
# 收集所有待插入记录(解析 + 校验)
|
||||
pending_records: list[dict] = []
|
||||
for raw in raw_injuries:
|
||||
try:
|
||||
player = raw.get("player", {}) or {}
|
||||
team = raw.get("team", {}) or {}
|
||||
fixture = raw.get("fixture", {}) or {}
|
||||
|
||||
player_name = player.get("name", "")
|
||||
team_name = normalize_name(team.get("name", ""))
|
||||
team_id = team_by_name.get(team_name)
|
||||
|
||||
# Fix 2: 解析日期(injury_date + return_date)
|
||||
fixture_date = fixture.get("date")
|
||||
injury_date = None
|
||||
if fixture_date:
|
||||
try:
|
||||
dt = datetime.fromisoformat(fixture_date.replace("Z", "+00:00"))
|
||||
injury_date = dt.date()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# 解析 return_date(如果数据源提供)
|
||||
return_date = None
|
||||
return_date_raw = player.get("return_date") or player.get("returnDate")
|
||||
if return_date_raw:
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(return_date_raw).replace("Z", "+00:00"))
|
||||
return_date = dt.date()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# 强制 int 转换,API 可能返回字符串
|
||||
player_id = player.get("id")
|
||||
try:
|
||||
player_id = int(player_id) if player_id is not None else None
|
||||
except (ValueError, TypeError):
|
||||
player_id = None
|
||||
fixture_id = fixture.get("id")
|
||||
try:
|
||||
fixture_id = int(fixture_id) if fixture_id is not None else None
|
||||
except (ValueError, TypeError):
|
||||
fixture_id = None
|
||||
|
||||
pending_records.append({
|
||||
"player_id": player_id,
|
||||
"player_name": player_name,
|
||||
"team_id": team_id,
|
||||
"fixture_id": fixture_id,
|
||||
"league_id": (raw.get("league") or {}).get("id"),
|
||||
"injury_type": player.get("type"),
|
||||
"reason": player.get("reason"),
|
||||
"injury_date": injury_date,
|
||||
"return_date": return_date,
|
||||
})
|
||||
except Exception as e:
|
||||
result["errors"].append(f"parse error: {e}")
|
||||
|
||||
# 批量查询已存在的记录(1 次 DB 往返)
|
||||
existing_keys: set[tuple] = set()
|
||||
if pending_records:
|
||||
conditions = []
|
||||
for rec in pending_records:
|
||||
conditions.append(
|
||||
(Injury.player_id == rec["player_id"])
|
||||
& (Injury.fixture_id == rec["fixture_id"])
|
||||
& (Injury.injury_type == rec["injury_type"])
|
||||
)
|
||||
if conditions:
|
||||
from sqlalchemy import or_
|
||||
stmt = select(Injury.player_id, Injury.fixture_id, Injury.injury_type).where(or_(*conditions))
|
||||
rows = (await db.execute(stmt)).all()
|
||||
existing_keys = {(r[0], r[1], r[2]) for r in rows}
|
||||
|
||||
# Fix 1: 使用 begin_nested(SAVEPOINT)隔离每批 flush
|
||||
# IntegrityError 时只回滚到 savepoint,不影响其它已成功批次
|
||||
BATCH_SIZE = 50
|
||||
batch: list[Injury] = []
|
||||
|
||||
async def _flush_batch():
|
||||
"""使用 savepoint flush 一批记录;失败只回滚本批。返回实际写入条数。"""
|
||||
if not batch:
|
||||
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"])
|
||||
if key in existing_keys:
|
||||
continue
|
||||
|
||||
batch.append(Injury(**rec))
|
||||
|
||||
# 每 BATCH_SIZE 条 flush 一次
|
||||
if len(batch) >= BATCH_SIZE:
|
||||
try:
|
||||
result["inserted"] += await _flush_batch()
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
"injuries batch IntegrityError at record %d, "
|
||||
"rolled back to savepoint, continuing",
|
||||
i + 1,
|
||||
)
|
||||
# begin_nested 已回滚到 savepoint,清空 batch 继续
|
||||
batch.clear()
|
||||
continue
|
||||
|
||||
# 最终 flush(剩余不足一批的记录)
|
||||
try:
|
||||
result["inserted"] += await _flush_batch()
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
"injuries final flush IntegrityError, "
|
||||
"rolled back to savepoint, some records may be lost",
|
||||
)
|
||||
batch.clear()
|
||||
|
||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
||||
return result
|
||||
|
||||
|
||||
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> InjuryQueryResult:
|
||||
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
|
||||
|
||||
Args:
|
||||
db: 数据库 session
|
||||
team_id: 球队 ID
|
||||
match_date: 比赛日期
|
||||
as_of: 数据截止时间(用于回测防泄漏)
|
||||
|
||||
Returns:
|
||||
InjuryQueryResult:包含查询记录与状态
|
||||
- query_status="success": 查询成功(即使结果也为空)
|
||||
- query_status="source_not_configured": API_FOOTBALL_KEY 未配置
|
||||
- query_status="query_error": 查询异常
|
||||
- query_status="no_local_data": Key 已配置,但该队 injuries 表无任何历史记录
|
||||
|
||||
语义区分:
|
||||
- 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
|
||||
|
||||
# 检查 API 是否配置(只读配置,不发网络)
|
||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
||||
if not api_key:
|
||||
logger.debug("API_FOOTBALL_KEY 未配置,跳过伤停查询 team=%s", team_id)
|
||||
return InjuryQueryResult(records=[], query_status="source_not_configured")
|
||||
|
||||
try:
|
||||
# Fix 3: 统一用 timezone-aware datetime 比较,禁止 date() 截断
|
||||
if hasattr(match_date, "date") and callable(match_date.date):
|
||||
match_date = match_date.date()
|
||||
|
||||
stmt = (
|
||||
select(Injury)
|
||||
.where(Injury.team_id == team_id)
|
||||
.where(Injury.injury_date <= match_date)
|
||||
.where(
|
||||
(Injury.return_date.is_(None)) | (Injury.return_date >= match_date)
|
||||
)
|
||||
)
|
||||
if as_of is not None:
|
||||
# Fix 3: 统一用 date 比较,避免 timestamptz vs date 的时区问题
|
||||
if hasattr(as_of, "date") and callable(as_of.date):
|
||||
as_of = as_of.date()
|
||||
stmt = stmt.where(func.date(Injury.retrieved_at) <= as_of)
|
||||
|
||||
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)
|
||||
return InjuryQueryResult(records=[], query_status="query_error")
|
||||
@@ -219,35 +219,3 @@ def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None:
|
||||
if m.match_status == "finished" and m.home_goals is None:
|
||||
m.match_status = "scheduled"
|
||||
return m
|
||||
|
||||
|
||||
def normalize_understat(raw: dict, league_type: str) -> NormalizedMatch | None:
|
||||
"""understat 单场 → NormalizedMatch(仅 xG)。"""
|
||||
from src.data.team_names import normalize as normalize_name
|
||||
|
||||
dt_str = raw.get("datetime") or raw.get("date")
|
||||
if not dt_str:
|
||||
return None
|
||||
dt = _parse_date(dt_str)
|
||||
if dt is None:
|
||||
return None
|
||||
home_info = raw.get("h", {})
|
||||
away_info = raw.get("a", {})
|
||||
home_name = home_info.get("title", "") if isinstance(home_info, dict) else ""
|
||||
away_name = away_info.get("title", "") if isinstance(away_info, dict) else ""
|
||||
home = normalize_name(home_name)
|
||||
away = normalize_name(away_name)
|
||||
if not home or not away or home == away:
|
||||
return None
|
||||
home_xg = _to_float(raw["xG"].get("h")) if isinstance(raw.get("xG"), dict) else None
|
||||
away_xg = _to_float(raw["xG"].get("a")) if isinstance(raw.get("xG"), dict) else None
|
||||
return NormalizedMatch(
|
||||
league_type=league_type,
|
||||
date=dt,
|
||||
home_team=home,
|
||||
away_team=away,
|
||||
match_status="finished",
|
||||
season_label=derive_season_label(dt),
|
||||
home_xg=home_xg,
|
||||
away_xg=away_xg,
|
||||
)
|
||||
|
||||
+2
-2
@@ -3,7 +3,8 @@
|
||||
定义 DataSource 契约,并提供全局注册表供路由层分发。
|
||||
每个比赛数据源实现该协议,注册后即可通过统一入口调度。
|
||||
|
||||
注: injuries 是球员级独立领域(写 Injury 表),不遵循此协议。
|
||||
当前只有 bzzoiro 一个数据源(Understat / injuries 已移除),
|
||||
保留协议与注册表是为了统一 ingest 调度入口的结构。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -58,7 +59,6 @@ def list_sources() -> list[str]:
|
||||
def _load_sources() -> None:
|
||||
"""延迟导入数据源触发 @register(避免循环导入)。"""
|
||||
from src.data.bzzoiro import BzzoiroSource # noqa: F811
|
||||
from src.data.understat import UnderstatSource # noqa: F811
|
||||
|
||||
|
||||
# 保持向后兼容:模块加载时尝试加载(但不再强制)
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Understat xG 数据源。
|
||||
|
||||
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.core.http_client import get_client
|
||||
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
|
||||
from src.data.normalize import normalize_understat
|
||||
from src.data.sources import register
|
||||
from src.db.models import League, Match, MatchStats, Team
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDERSTAT_BASE = "https://understat.com/getLeagueData/{league}/{season}"
|
||||
|
||||
|
||||
async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
||||
"""抓取 understat 单赛季 xG 数据。
|
||||
|
||||
Args:
|
||||
league_code: fdco 风格代码,如 'E0'
|
||||
season: 赛季起始年,如 2025 表示 2025-2026 赛季
|
||||
|
||||
Returns:
|
||||
比赛数组,每项含 datetime/h/a/xG
|
||||
"""
|
||||
understat_league = FDCO_TO_UNDERSTAT.get(league_code)
|
||||
if understat_league is None:
|
||||
raise ValueError(f"未知联赛代码: {league_code}")
|
||||
|
||||
url = UNDERSTAT_BASE.format(league=understat_league, season=season)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": f"https://understat.com/league/{understat_league}/{season}",
|
||||
}
|
||||
|
||||
# 重试:网络错误 / 5xx / 429
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
client = get_client()
|
||||
resp = await asyncio.wait_for(
|
||||
client.get(
|
||||
url, headers=headers,
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
),
|
||||
timeout=60.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
break
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt == 2:
|
||||
raise
|
||||
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
|
||||
logger.warning("understat fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
raise RuntimeError(f"understat fetch failed: {last_exc}")
|
||||
|
||||
# 优先按 JSON 响应解析(getLeagueData 接口返回 {teams, players, dates})
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = None
|
||||
if isinstance(data, dict) and isinstance(data.get("dates"), list):
|
||||
return data["dates"]
|
||||
|
||||
# 兼容旧版联赛页面:内嵌 var datesData = JSON.parse('...')
|
||||
text = resp.text
|
||||
match = re.search(r"var\s+datesData\s*=\s*JSON\.parse\('([^']+)'\)", text)
|
||||
if not match:
|
||||
logger.warning("understat 响应格式不符: %s...", text[:200])
|
||||
return []
|
||||
decoded = match.group(1).encode().decode("unicode_escape")
|
||||
data = json.loads(decoded)
|
||||
return data
|
||||
|
||||
|
||||
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
||||
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
||||
|
||||
统一在这里构造,避免"预加载时用 str(date)、写入时用 isoformat()"这类
|
||||
隐式格式依赖 —— 两者当前恰好相等,但一旦有人改动其一就会静默失配,
|
||||
导致所有比赛被判为不存在而重复插入。
|
||||
"""
|
||||
if hasattr(match_date, "date") and callable(match_date.date):
|
||||
match_date = match_date.date()
|
||||
return (home_team_id, away_team_id, match_date.isoformat() if match_date is not None else "")
|
||||
|
||||
|
||||
@register
|
||||
class UnderstatSource:
|
||||
"""understat xG 数据源(实现 DataSource 协议)。"""
|
||||
|
||||
name = "understat"
|
||||
|
||||
async def ingest(self, db, *, league: str, season: int) -> dict:
|
||||
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。
|
||||
|
||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||
|
||||
P1-3: 批量查询优化,将单赛季 380 场 × 3 次 DB 往返降为 3 次查询。
|
||||
"""
|
||||
from src.db.repositories import LeagueRepository, TeamRepository
|
||||
|
||||
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
||||
|
||||
try:
|
||||
raw_matches = await fetch_understat(league, season)
|
||||
except Exception as e:
|
||||
logger.exception("understat fetch failed for %s %s", league, season)
|
||||
result["errors"].append(f"fetch failed: {e}")
|
||||
return result
|
||||
|
||||
# 使用 Repository
|
||||
league_repo = LeagueRepository(db)
|
||||
team_repo = TeamRepository(db)
|
||||
|
||||
# 查联赛
|
||||
league_obj = await league_repo.get_by_code(league)
|
||||
if league_obj is None:
|
||||
result["errors"].append(f"league {league} not found in DB")
|
||||
return result
|
||||
|
||||
# === 批量优化: 一次规范化,收集球队名和日期 ===
|
||||
normalized_matches: list = []
|
||||
all_team_names: set[str] = set()
|
||||
for raw in raw_matches:
|
||||
if not raw.get("isResult"):
|
||||
continue
|
||||
try:
|
||||
nm = normalize_understat(raw, league)
|
||||
if nm is None:
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
result["errors"].append(f"normalize: {e}")
|
||||
continue
|
||||
normalized_matches.append((nm, raw))
|
||||
all_team_names.add(nm.home_team)
|
||||
all_team_names.add(nm.away_team)
|
||||
|
||||
if not normalized_matches:
|
||||
return result
|
||||
|
||||
# === 批量查询球队(1 次 DB 往返) ===
|
||||
team_name_to_id = {}
|
||||
if all_team_names:
|
||||
teams = await team_repo.get_all_by_names(list(all_team_names))
|
||||
team_name_to_id = {name: team.id for name, team in teams.items()}
|
||||
|
||||
# === 批量查询已有比赛(1 次 DB 往返,按日期范围) ===
|
||||
match_dict: dict[tuple, Match] = {}
|
||||
dates = [nm.date for nm, _ in normalized_matches if nm.date is not None]
|
||||
if dates:
|
||||
min_dt = min(dates) - timedelta(days=30)
|
||||
max_dt = max(dates) + timedelta(days=30)
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(selectinload(Match.stats))
|
||||
.where(Match.league_id == league_obj.id)
|
||||
.where(Match.match_date >= min_dt)
|
||||
.where(Match.match_date <= max_dt)
|
||||
)
|
||||
for m in (await db.execute(stmt)).scalars():
|
||||
key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date)
|
||||
match_dict[key] = m
|
||||
|
||||
# === 内存匹配 + 回填 xG ===
|
||||
for nm, raw in normalized_matches:
|
||||
home_team_id = team_name_to_id.get(nm.home_team)
|
||||
away_team_id = team_name_to_id.get(nm.away_team)
|
||||
if home_team_id is None or away_team_id is None:
|
||||
result["unmatched"] += 1
|
||||
continue
|
||||
|
||||
match_key = _match_key(home_team_id, away_team_id, nm.date)
|
||||
existing = match_dict.get(match_key)
|
||||
if existing is None:
|
||||
result["unmatched"] += 1
|
||||
continue
|
||||
|
||||
# 回填 xG
|
||||
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 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
||||
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
|
||||
match_date = existing.match_date if existing.match_date else now
|
||||
available_at = match_date + timedelta(hours=2)
|
||||
existing.stats = MatchStats(
|
||||
match_id=existing.id,
|
||||
source="understat",
|
||||
source_record_id=str(raw.get("id", "")),
|
||||
retrieved_at=now,
|
||||
available_at=available_at,
|
||||
)
|
||||
db.add(existing.stats)
|
||||
await db.flush()
|
||||
if existing.stats is not None:
|
||||
if existing.stats.home_xg is None and nm.home_xg is not None:
|
||||
existing.stats.home_xg = nm.home_xg
|
||||
result["updated"] += 1
|
||||
if existing.stats.away_xg is None and nm.away_xg is not None:
|
||||
existing.stats.away_xg = nm.away_xg
|
||||
|
||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||
return result
|
||||
+39
-29
@@ -1,4 +1,7 @@
|
||||
"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。"""
|
||||
"""ORM 模型: leagues / teams / matches / match_stats / standings / predictions。
|
||||
|
||||
数据源统一为 bzzoiro(单一数据源),伤停(injuries)与 Understat 已移除。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
@@ -16,7 +19,6 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
@@ -75,6 +77,8 @@ class Match(Base):
|
||||
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
away_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
match_stage: Mapped[str | None] = mapped_column(String(100))
|
||||
# 数据血缘:bzzoiro 上游事件 ID,用于 /events/{id}/stats/ 统计回填
|
||||
source_event_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
@@ -127,6 +131,11 @@ class MatchStats(Base):
|
||||
away_yellow_cards: Mapped[int | None] = mapped_column(Integer)
|
||||
home_red_cards: Mapped[int | None] = mapped_column(Integer)
|
||||
away_red_cards: Mapped[int | None] = mapped_column(Integer)
|
||||
# bzzoiro /events/{id}/stats/ 扩展字段
|
||||
home_big_chances: Mapped[int | None] = mapped_column(Integer)
|
||||
away_big_chances: Mapped[int | None] = mapped_column(Integer)
|
||||
home_fouls: Mapped[int | None] = mapped_column(Integer)
|
||||
away_fouls: Mapped[int | None] = mapped_column(Integer)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
# 数据血缘:追踪统计数据的来源和可用时间
|
||||
source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat
|
||||
@@ -146,39 +155,40 @@ class MatchStats(Base):
|
||||
)
|
||||
|
||||
|
||||
class Injury(Base):
|
||||
"""球员伤停记录(api-football 数据源)。"""
|
||||
__tablename__ = "injuries"
|
||||
class Standing(Base):
|
||||
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
|
||||
|
||||
同一联赛同一赛季只保留最新快照:重新采集时按 (league_id, season, team_id)
|
||||
upsert。zone 来自 bzzoiro 分区(如 champions_league / europa_league / relegation)。
|
||||
"""
|
||||
__tablename__ = "standings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
player_id: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
player_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id"), index=True)
|
||||
fixture_id: Mapped[int | None] = mapped_column(Integer)
|
||||
league_id: Mapped[int | None] = mapped_column(Integer)
|
||||
injury_type: Mapped[str | None] = mapped_column(String(50)) # Missing Fixture / Suspended
|
||||
reason: Mapped[str | None] = mapped_column(String(200))
|
||||
injury_date: Mapped[date | None] = mapped_column(Date, index=True)
|
||||
return_date: Mapped[date | None] = mapped_column(Date)
|
||||
league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False)
|
||||
season: Mapped[str] = mapped_column(String(12), nullable=False)
|
||||
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
played: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
won: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
drawn: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
lost: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
goals_for: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
goals_against: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
goal_diff: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
points: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
xg_for: Mapped[float | None] = mapped_column(Float)
|
||||
xg_against: Mapped[float | None] = mapped_column(Float)
|
||||
form: Mapped[str | None] = mapped_column(String(20)) # 近期赛果串,如 "WWDLW"
|
||||
zone: Mapped[str | None] = mapped_column(String(50)) # champions_league / relegation 等
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
team: Mapped["Team | None"] = relationship()
|
||||
league: Mapped[League] = relationship()
|
||||
team: Mapped[Team] = relationship(lazy="selectin")
|
||||
|
||||
__table_args__ = (
|
||||
# Fix 4: partial unique index — 只在 player_id 和 fixture_id 都非空时强制唯一
|
||||
# PostgreSQL 中 NULL != NULL,普通唯一索引无法防止 NULL 重复
|
||||
Index(
|
||||
"ix_injuries_player_fixture",
|
||||
"player_id",
|
||||
"fixture_id",
|
||||
"injury_type",
|
||||
unique=True,
|
||||
postgresql_where=and_(
|
||||
player_id.is_not(None),
|
||||
fixture_id.is_not(None),
|
||||
),
|
||||
),
|
||||
Index("ix_injuries_team_date", "team_id", "injury_date"),
|
||||
UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"),
|
||||
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class MatchRepository:
|
||||
) -> Match | None:
|
||||
"""按联赛+主队+客队+日期查找比赛(天级匹配)。
|
||||
|
||||
预加载 stats:调用方(understat 回填)会读取 existing.stats,
|
||||
预加载 stats:调用方(统计回填)会读取 existing.stats,
|
||||
async session 下惰性加载会抛 MissingGreenlet。
|
||||
|
||||
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
|
||||
|
||||
@@ -35,7 +35,7 @@ def load_agent_prompt(name: str, version: str = "v1") -> str:
|
||||
@dataclass
|
||||
class AgentSpec:
|
||||
"""领域专家 agent 定义。"""
|
||||
name: str # h2h / form / home_away / injuries / stats
|
||||
name: str # h2h / form / home_away / standings / stats
|
||||
system_prompt: str # system message
|
||||
slice_fn: object # async (header, before) -> str 切片函数
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ from src.llm.context_builder import (
|
||||
h2h_slice,
|
||||
header_text,
|
||||
home_away_slice,
|
||||
injuries_slice,
|
||||
load_match_header,
|
||||
standings_slice,
|
||||
stats_slice,
|
||||
)
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
@@ -36,7 +36,7 @@ _AGENT_PROVIDER_CACHE_TTL = 60.0
|
||||
|
||||
|
||||
# ── 5 个专家 agent 定义 ──
|
||||
# A=近期状态 B=攻防数据 C=主客因素 D=阵容完整性 E=历史交锋
|
||||
# A=近期状态 B=攻防数据 C=主客因素 D=联赛排名 E=历史交锋
|
||||
SPECIALIST_SPECS: list[AgentSpec] = [
|
||||
AgentSpec(
|
||||
name="form",
|
||||
@@ -54,9 +54,9 @@ SPECIALIST_SPECS: list[AgentSpec] = [
|
||||
slice_fn=home_away_slice,
|
||||
),
|
||||
AgentSpec(
|
||||
name="injuries",
|
||||
system_prompt="你是足球阵容完整性分析专家。汇总伤停与停赛名单,输出战力缺失程度。只输出 JSON。",
|
||||
slice_fn=injuries_slice,
|
||||
name="standings",
|
||||
system_prompt="你是足球联赛排名分析专家。分析积分榜位置、积分走势与分区,评估两队整体实力差距。只输出 JSON。",
|
||||
slice_fn=standings_slice,
|
||||
),
|
||||
AgentSpec(
|
||||
name="h2h",
|
||||
@@ -76,7 +76,7 @@ AGENT_LABELS_ZH: dict[str, str] = {
|
||||
"form": "近期状态分析专家",
|
||||
"stats": "攻防数据分析专家",
|
||||
"home_away": "主客因素分析专家",
|
||||
"injuries": "阵容完整性分析专家",
|
||||
"standings": "联赛排名分析专家",
|
||||
"h2h": "历史交锋分析专家",
|
||||
}
|
||||
|
||||
|
||||
+57
-57
@@ -2,7 +2,7 @@
|
||||
|
||||
架构:
|
||||
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
|
||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / stats)
|
||||
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
||||
|
||||
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
||||
@@ -81,7 +81,7 @@ class MatchContext:
|
||||
match_id: int
|
||||
text: str
|
||||
has_stats: bool
|
||||
has_injuries: bool
|
||||
has_standings: bool
|
||||
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
||||
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
|
||||
|
||||
@@ -337,72 +337,72 @@ async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None,
|
||||
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
||||
|
||||
|
||||
async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
|
||||
async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||
"""D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。
|
||||
|
||||
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
||||
before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)。
|
||||
db: 可选共享 session(见模块 docstring)。
|
||||
|
||||
语义区分:
|
||||
- 查询成功 + 空结果 → has_data=True(明确知道「无人伤停」)
|
||||
- 源未配置 / 查询失败 → has_data=False(无法判断,跳过 LLM)
|
||||
- 两队都有积分榜行 → has_data=True(明确的排名信息)
|
||||
- 任一队缺失 → has_data=False(升班马/杯赛无榜,信息不完整时明确声明)
|
||||
"""
|
||||
from src.data.injuries import get_injuries_for_match, InjuryQueryResult
|
||||
from src.db.models import League, Standing
|
||||
|
||||
cutoff = before or header.match_dt
|
||||
if db is not None:
|
||||
home_result = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
|
||||
away_result = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
||||
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
|
||||
rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Standing)
|
||||
.options(selectinload(Standing.team))
|
||||
.where(Standing.league_id == header.league_id)
|
||||
.order_by(Standing.position.asc())
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if league
|
||||
else []
|
||||
)
|
||||
else:
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
home_result = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
|
||||
away_result = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
|
||||
return await standings_slice(header, before=before, db=new_db)
|
||||
|
||||
# 判断是否有有效查询结果
|
||||
# 两队都成功查询(即使为空) → has_data=True
|
||||
# 任一查询失败或源未配置 → has_data=False
|
||||
both_succeeded = (
|
||||
home_result.query_status == "success"
|
||||
and away_result.query_status == "success"
|
||||
)
|
||||
any_configured = (
|
||||
home_result.query_status != "source_not_configured"
|
||||
or away_result.query_status != "source_not_configured"
|
||||
)
|
||||
|
||||
lines = ["── 阵容完整性 ──"]
|
||||
lines = [f"── 联赛排名({header.league_name} 共 {len(rows)} 队) ──"]
|
||||
n_records = 0
|
||||
|
||||
for label, result in (("主队", home_result), ("客队", away_result)):
|
||||
if result.query_status == "source_not_configured":
|
||||
lines.append(f" {label}: 伤停源未配置")
|
||||
elif result.query_status == "query_error":
|
||||
lines.append(f" {label}: 查询异常")
|
||||
elif result.query_status == "no_local_data":
|
||||
# API Key 已配置但本地无伤停记录
|
||||
lines.append(f" {label}: 本地尚无伤停数据,请先采集")
|
||||
elif result.records:
|
||||
n_records += len(result.records)
|
||||
lines.append(f" {label}伤停({len(result.records)}人):")
|
||||
for inj in result.records[:8]:
|
||||
reason = inj.reason or inj.injury_type or "未知"
|
||||
lines.append(f" - {inj.player_name}: {reason}")
|
||||
if len(result.records) > 8:
|
||||
lines.append(f" ...及其他 {len(result.records) - 8} 人")
|
||||
def _fmt(row) -> str:
|
||||
zg = f" xG差 {row.xgd:+.1f}" if row.xg_for is not None and row.xg_against is not None and row.goal_diff is not None else ""
|
||||
form = f" 近5场 {row.form}" if row.form else ""
|
||||
zone = f" [{row.zone}]" if row.zone else ""
|
||||
return (
|
||||
f" 第 {row.position} 名: {row.points} 分 / {row.played} 场 "
|
||||
f"({row.won}胜{row.drawn}平{row.lost}负, 进{row.goals_for}失{row.goals_against} 净胜{row.goal_diff:+d}"
|
||||
f"{zg}){form}{zone}"
|
||||
)
|
||||
|
||||
for label, team_id in (("主队", header.home_team_id), ("客队", header.away_team_id)):
|
||||
row = next((r for r in rows if r.team_id == team_id), None)
|
||||
if row is None:
|
||||
lines.append(f" {label}: 暂无积分榜数据(可能杯赛/赛季未开始)")
|
||||
else:
|
||||
# success + 空列表 → 明确无伤停
|
||||
lines.append(f" {label}: 当前无伤停记录")
|
||||
n_records += 1
|
||||
lines.append(f" {label} {header.home_name if label == '主队' else header.away_name}:")
|
||||
lines.append(_fmt(row))
|
||||
|
||||
# 决定 has_data:
|
||||
# - 两队都成功查询(即使为空) → True(明确知道名单)
|
||||
# - 源未配置且无数据 → False
|
||||
has_data = both_succeeded or (any_configured and n_records > 0)
|
||||
# 两队排名对比摘要
|
||||
home_row = next((r for r in rows if r.team_id == header.home_team_id), None)
|
||||
away_row = next((r for r in rows if r.team_id == header.away_team_id), None)
|
||||
if home_row and away_row:
|
||||
diff = home_row.position - away_row.position # 正数=主队排名更靠前(名次更小)
|
||||
lead = f"主队排名高 {diff} 位" if diff > 0 else (f"客队排名高 {-diff} 位" if diff < 0 else "两队同排名结构")
|
||||
pts_diff = home_row.points - away_row.points
|
||||
lines.append(f" 排名对比: {lead}, 分差 {pts_diff:+d}")
|
||||
|
||||
if not has_data:
|
||||
# 保留详细状态文案(伤停源未配置/查询异常),而非通用「无数据」
|
||||
return SliceResult(text="\n".join(lines), has_data=False, n_records=0)
|
||||
|
||||
return SliceResult(text="\n".join(lines), has_data=True, n_records=n_records)
|
||||
# has_data: 两队都有行才算完整;只有一队时仍有价值,但标记不完整
|
||||
has_data = n_records >= 1
|
||||
return SliceResult(text="\n".join(lines), has_data=has_data, n_records=n_records)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -412,7 +412,7 @@ async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession |
|
||||
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,
|
||||
has_stats / has_standings 直接取切片显式声明的 has_data,
|
||||
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||
|
||||
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
||||
@@ -448,14 +448,14 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
|
||||
parts.append(home_away_res.text)
|
||||
parts.append("")
|
||||
|
||||
injuries_res = await injuries_slice(header, before=cutoff, db=db)
|
||||
parts.append(injuries_res.text)
|
||||
standings_res = await standings_slice(header, before=cutoff, db=db)
|
||||
parts.append(standings_res.text)
|
||||
|
||||
return MatchContext(
|
||||
match_id=match_id,
|
||||
text="\n".join(parts),
|
||||
has_stats=form_res.has_data or stats_res.has_data,
|
||||
has_injuries=injuries_res.has_data,
|
||||
has_standings=standings_res.has_data,
|
||||
match_dt=header.match_dt,
|
||||
cutoff=cutoff,
|
||||
)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
你是足球阵容完整性分析专家。分析以下两队的伤停与停赛信息,评估战力缺失程度。
|
||||
|
||||
{{context}}
|
||||
|
||||
分析要点:
|
||||
- 核心球员缺阵影响(射手/组织核心/主力门将/后防中坚)
|
||||
- 缺阵人数与位置分布(前场/中场/后场)
|
||||
- 替补深度:缺阵是否有人可替
|
||||
- 无数据时如实标注 data_sufficiency=none,不猜测
|
||||
- 综合判断:哪支球队战力受损更严重
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"data_sufficiency": "high|medium|low|none",
|
||||
"analysis": "<150 字内分析,量化战力缺失程度>",
|
||||
"home_edge": <-1.0 到 1.0, 正数=客队伤停更严重(利主队)>,
|
||||
"confidence": <0.0-1.0>,
|
||||
"key_evidence": ["<证据1>", "<证据2>"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
你是足球联赛排名分析专家。分析以下两队在联赛积分榜上的位置、积分与近期走势,评估整体实力差距。
|
||||
|
||||
{{context}}
|
||||
|
||||
分析要点:
|
||||
- 排名与分差:排名差距反映的整体实力层级,是否属于同档球队
|
||||
- 攻防质量:进球/失球/净胜球与 xG 差(xgd)是否匹配,有无虚高或低估
|
||||
- 赛程消耗:已赛场次差异(少赛场次可能反映赛程推迟或杯赛分心)
|
||||
- 近期走势:form 串(如 WWDLL)显示的状态趋势,与排名是否一致
|
||||
- 分区含义:争冠/欧战区/保级区的处境对比赛动机的影响
|
||||
- 无数据时如实标注 data_sufficiency=none,不猜测
|
||||
- 综合判断:哪支球队整体实力与动机占优
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"data_sufficiency": "high|medium|low|none",
|
||||
"analysis": "<150 字内分析,量化两队实力差距>",
|
||||
"home_edge": <-1.0 到 1.0, 正数=主队实力占优>,
|
||||
"confidence": <0.0-1.0>,
|
||||
"key_evidence": ["<证据1>", "<证据2>"]
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user