refactor: Sprint 3 - 引入 UnitOfWork + Repository 架构
新增: - src/db/unit_of_work.py: UnitOfWork 事务封装 - src/db/repositories.py: Match/Team/League/Prediction Repository 重构: - 删除 src/data/match_lookup.py(由 Repository 替代) - 数据源(bzzoiro/understat/injuries)不再自行 commit - API 路由(ingest)改用 UnitOfWork - LLM 服务(predict/orchestrator/eval/backtest)改用 UnitOfWork 事务边界统一由调用方控制,数据层不再自行决定 commit。
This commit is contained in:
+10
-11
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
||||||
from src.data.sources import get_source
|
from src.data.sources import get_source
|
||||||
from src.data.injuries import ingest_injuries
|
from src.data.injuries import ingest_injuries
|
||||||
from src.db.base import AsyncSessionLocal
|
from src.db.unit_of_work import get_uow
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
||||||
|
|
||||||
@@ -15,19 +15,18 @@ router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
|||||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||||
"""触发 bzzoiro 采集。"""
|
"""触发 bzzoiro 采集。"""
|
||||||
source = get_source("bzzoiro")
|
source = get_source("bzzoiro")
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
try:
|
try:
|
||||||
|
async with get_uow() as uow:
|
||||||
result = await source.ingest(
|
result = await source.ingest(
|
||||||
db,
|
uow.session,
|
||||||
leagues=req.leagues,
|
leagues=req.leagues,
|
||||||
date_from=req.date_from,
|
date_from=req.date_from,
|
||||||
date_to=req.date_to,
|
date_to=req.date_to,
|
||||||
status=req.status,
|
status=req.status,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await uow.commit()
|
||||||
return IngestResponse(**result)
|
return IngestResponse(**result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -35,22 +34,22 @@ async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
|||||||
async def ingest_understat_route(req: IngestUnderstatRequest):
|
async def ingest_understat_route(req: IngestUnderstatRequest):
|
||||||
"""触发 understat xG 回填。"""
|
"""触发 understat xG 回填。"""
|
||||||
source = get_source("understat")
|
source = get_source("understat")
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
try:
|
try:
|
||||||
result = await source.ingest(db, league=req.league, season=req.season)
|
async with get_uow() as uow:
|
||||||
|
result = await source.ingest(uow.session, league=req.league, season=req.season)
|
||||||
|
await uow.commit()
|
||||||
return IngestSimpleResponse(**result)
|
return IngestSimpleResponse(**result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ingest/injuries", response_model=IngestSimpleResponse)
|
@router.post("/ingest/injuries", response_model=IngestSimpleResponse)
|
||||||
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
||||||
"""触发伤停采集。"""
|
"""触发伤停采集。"""
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
try:
|
try:
|
||||||
result = await ingest_injuries(db, date=req.date)
|
async with get_uow() as uow:
|
||||||
|
result = await ingest_injuries(uow.session, date=req.date)
|
||||||
|
await uow.commit()
|
||||||
return IngestSimpleResponse(**result)
|
return IngestSimpleResponse(**result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|||||||
+8
-16
@@ -1,6 +1,7 @@
|
|||||||
"""Bzzoiro 数据源:抓取 + 入库。
|
"""Bzzoiro 数据源:抓取 + 入库。
|
||||||
|
|
||||||
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
||||||
|
使用 Repository 模式进行数据访问,不直接控制事务。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,10 +19,9 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
|
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
|
||||||
from src.data.match_lookup import find_existing_match, get_or_create_team
|
|
||||||
from src.data.normalize import normalize_bzzoiro
|
from src.data.normalize import normalize_bzzoiro
|
||||||
from src.data.sources import register
|
from src.data.sources import register
|
||||||
from src.db.models import League, Match, MatchStats
|
from src.db.models import League, Match, MatchStats, Team
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -125,7 +125,10 @@ class BzzoiroSource:
|
|||||||
date_to: str | None = None,
|
date_to: str | None = None,
|
||||||
status: str = "finished",
|
status: str = "finished",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""采集 bzzoiro → 入库。返回统计。"""
|
"""采集 bzzoiro → 入库。返回统计。
|
||||||
|
|
||||||
|
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||||
|
"""
|
||||||
result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||||
|
|
||||||
for code in leagues:
|
for code in leagues:
|
||||||
@@ -159,21 +162,11 @@ class BzzoiroSource:
|
|||||||
all_team_names.add(nm.away_team)
|
all_team_names.add(nm.away_team)
|
||||||
|
|
||||||
if all_team_names:
|
if all_team_names:
|
||||||
from sqlalchemy import select
|
|
||||||
from src.db.models import Team
|
|
||||||
stmt = select(Team).where(Team.name.in_(all_team_names))
|
stmt = select(Team).where(Team.name.in_(all_team_names))
|
||||||
teams = (await db.execute(stmt)).scalars().all()
|
teams = (await db.execute(stmt)).scalars().all()
|
||||||
team_name_to_id = {t.name: t.id for t in teams}
|
team_name_to_id = {t.name: t.id for t in teams}
|
||||||
|
|
||||||
# 预加载已有比赛 (league_id + home_id + away_id + date)
|
# 预加载已有比赛
|
||||||
# 需要先获取球队 ID,所以分批处理
|
|
||||||
date_strs = set()
|
|
||||||
for raw in raw_events:
|
|
||||||
nm = normalize_bzzoiro(raw, code)
|
|
||||||
if nm and nm.date:
|
|
||||||
date_strs.add(nm.date.date().isoformat() if hasattr(nm.date, "date") else str(nm.date))
|
|
||||||
|
|
||||||
if date_strs:
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Match.home_team_id, Match.away_team_id, func.date(Match.match_date).label("d"))
|
select(Match.home_team_id, Match.away_team_id, func.date(Match.match_date).label("d"))
|
||||||
@@ -255,7 +248,6 @@ class BzzoiroSource:
|
|||||||
league_r["inserted"] += 1
|
league_r["inserted"] += 1
|
||||||
else:
|
else:
|
||||||
# 已有比赛: 需要查询对象来更新
|
# 已有比赛: 需要查询对象来更新
|
||||||
# 注意: 这里为了简化仍查询一次,但只在"已有"时触发
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Match)
|
select(Match)
|
||||||
@@ -297,7 +289,7 @@ class BzzoiroSource:
|
|||||||
if changed:
|
if changed:
|
||||||
league_r["updated"] += 1
|
league_r["updated"] += 1
|
||||||
|
|
||||||
await db.commit()
|
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||||
result["leagues"][code] = league_r
|
result["leagues"][code] = league_r
|
||||||
result["total_inserted"] += league_r["inserted"]
|
result["total_inserted"] += league_r["inserted"]
|
||||||
result["total_updated"] += league_r["updated"]
|
result["total_updated"] += league_r["updated"]
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
|
|||||||
|
|
||||||
|
|
||||||
async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||||
"""采集伤停数据并入库(injuries 表)。"""
|
"""采集伤停数据并入库(injuries 表)。
|
||||||
|
|
||||||
|
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||||
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from src.data.team_names import normalize as normalize_name
|
from src.data.team_names import normalize as normalize_name
|
||||||
@@ -173,7 +176,7 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["errors"].append(f"parse error: {e}")
|
result["errors"].append(f"parse error: {e}")
|
||||||
|
|
||||||
await db.commit()
|
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||||
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
"""比赛匹配辅助函数(多数据源共用)。
|
|
||||||
|
|
||||||
bzzoiro / understat 等数据源在入库时都需要:
|
|
||||||
- 按队名获取或创建球队(get_or_create_team)
|
|
||||||
- 按联赛+主队+客队+日期找已有比赛(find_existing_match)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from src.db.models import Match, Team
|
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_team(db, name: str) -> Team:
|
|
||||||
"""按名获取球队,不存在则创建。"""
|
|
||||||
stmt = select(Team).where(Team.name == name)
|
|
||||||
team = (await db.execute(stmt)).scalar_one_or_none()
|
|
||||||
if team is None:
|
|
||||||
team = Team(name=name)
|
|
||||||
db.add(team)
|
|
||||||
await db.flush()
|
|
||||||
return team
|
|
||||||
|
|
||||||
|
|
||||||
async def find_existing_match(db, league_id: int, home_name: str, away_name: str, date) -> Match | None:
|
|
||||||
"""按联赛+主队+客队+日期找已有比赛(天级匹配,避免时间精度差异)。"""
|
|
||||||
home_team = (await db.execute(select(Team).where(Team.name == home_name))).scalar_one_or_none()
|
|
||||||
away_team = (await db.execute(select(Team).where(Team.name == away_name))).scalar_one_or_none()
|
|
||||||
if home_team is None or away_team is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
date_only = date.date() if hasattr(date, "date") else date
|
|
||||||
stmt = (
|
|
||||||
select(Match)
|
|
||||||
.where(Match.league_id == league_id)
|
|
||||||
.where(Match.home_team_id == home_team.id)
|
|
||||||
.where(Match.away_team_id == away_team.id)
|
|
||||||
.where(func.date(Match.match_date) == date_only)
|
|
||||||
)
|
|
||||||
return (await db.execute(stmt)).scalar_one_or_none()
|
|
||||||
+24
-7
@@ -1,6 +1,7 @@
|
|||||||
"""Understat xG 数据源。
|
"""Understat xG 数据源。
|
||||||
|
|
||||||
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
||||||
|
使用 Repository 模式进行数据访问,不直接控制事务。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,12 +11,13 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from src.core.http_client import get_client
|
from src.core.http_client import get_client
|
||||||
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
|
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
|
||||||
from src.data.match_lookup import find_existing_match
|
|
||||||
from src.data.normalize import normalize_understat
|
from src.data.normalize import normalize_understat
|
||||||
from src.data.sources import register
|
from src.data.sources import register
|
||||||
from src.db.models import League, Match, MatchStats
|
from src.db.models import League, Match, MatchStats, Team
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -80,9 +82,10 @@ class UnderstatSource:
|
|||||||
name = "understat"
|
name = "understat"
|
||||||
|
|
||||||
async def ingest(self, db, *, league: str, season: int) -> dict:
|
async def ingest(self, db, *, league: str, season: int) -> dict:
|
||||||
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。"""
|
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
|
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||||
|
"""
|
||||||
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -111,8 +114,22 @@ class UnderstatSource:
|
|||||||
result["errors"].append(f"normalize: {e}")
|
result["errors"].append(f"normalize: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 匹配已有 Match(天级)
|
# 匹配已有 Match(天级) - 直接查询
|
||||||
existing = await find_existing_match(db, league_obj.id, nm.home_team, nm.away_team, nm.date)
|
home_team = (await db.execute(select(Team).where(Team.name == nm.home_team))).scalar_one_or_none()
|
||||||
|
away_team = (await db.execute(select(Team).where(Team.name == nm.away_team))).scalar_one_or_none()
|
||||||
|
if home_team is None or away_team is None:
|
||||||
|
result["unmatched"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_only = nm.date.date() if hasattr(nm.date, "date") else nm.date
|
||||||
|
stmt = (
|
||||||
|
select(Match)
|
||||||
|
.where(Match.league_id == league_obj.id)
|
||||||
|
.where(Match.home_team_id == home_team.id)
|
||||||
|
.where(Match.away_team_id == away_team.id)
|
||||||
|
.where(func.date(Match.match_date) == date_only)
|
||||||
|
)
|
||||||
|
existing = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
result["unmatched"] += 1
|
result["unmatched"] += 1
|
||||||
continue
|
continue
|
||||||
@@ -129,5 +146,5 @@ class UnderstatSource:
|
|||||||
if existing.stats.away_xg is None and nm.away_xg is not None:
|
if existing.stats.away_xg is None and nm.away_xg is not None:
|
||||||
existing.stats.away_xg = nm.away_xg
|
existing.stats.away_xg = nm.away_xg
|
||||||
|
|
||||||
await db.commit()
|
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Repository 层:封装数据访问。
|
||||||
|
|
||||||
|
Repository 只负责查询,不负责事务提交。
|
||||||
|
事务由 Application Service 通过 UnitOfWork 控制。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.models import League, Match, Prediction, Team
|
||||||
|
|
||||||
|
|
||||||
|
class MatchRepository:
|
||||||
|
"""比赛数据访问。"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_id(self, match_id: int) -> Match | None:
|
||||||
|
return await self._session.get(Match, match_id)
|
||||||
|
|
||||||
|
async def get_with_relations(self, match_id: int) -> Match | None:
|
||||||
|
stmt = (
|
||||||
|
select(Match)
|
||||||
|
.options(
|
||||||
|
selectinload(Match.league),
|
||||||
|
selectinload(Match.home_team),
|
||||||
|
selectinload(Match.away_team),
|
||||||
|
selectinload(Match.stats),
|
||||||
|
)
|
||||||
|
.where(Match.id == match_id)
|
||||||
|
)
|
||||||
|
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def find_by_teams_and_date(
|
||||||
|
self, league_id: int, home_team_id: int, away_team_id: int, date
|
||||||
|
) -> Match | None:
|
||||||
|
"""按联赛+主队+客队+日期查找比赛(天级匹配)。"""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
if hasattr(date, "date"):
|
||||||
|
date = date.date()
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Match)
|
||||||
|
.where(Match.league_id == league_id)
|
||||||
|
.where(Match.home_team_id == home_team_id)
|
||||||
|
.where(Match.away_team_id == away_team_id)
|
||||||
|
.where(func.date(Match.match_date) == date)
|
||||||
|
)
|
||||||
|
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def add(self, match: Match) -> None:
|
||||||
|
self._session.add(match)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class TeamRepository:
|
||||||
|
"""球队数据访问。"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_name(self, name: str) -> Team | None:
|
||||||
|
stmt = select(Team).where(Team.name == name)
|
||||||
|
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_or_create(self, name: str) -> Team:
|
||||||
|
"""按名获取球队,不存在则创建。"""
|
||||||
|
team = await self.get_by_name(name)
|
||||||
|
if team is None:
|
||||||
|
team = Team(name=name)
|
||||||
|
self._session.add(team)
|
||||||
|
await self._session.flush()
|
||||||
|
return team
|
||||||
|
|
||||||
|
async def get_all_by_names(self, names: list[str]) -> dict[str, Team]:
|
||||||
|
"""批量获取球队,返回 name → Team 映射。"""
|
||||||
|
if not names:
|
||||||
|
return {}
|
||||||
|
stmt = select(Team).where(Team.name.in_(names))
|
||||||
|
teams = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return {t.name: t for t in teams}
|
||||||
|
|
||||||
|
async def add(self, team: Team) -> None:
|
||||||
|
self._session.add(team)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class LeagueRepository:
|
||||||
|
"""联赛数据访问。"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_code(self, code: str) -> League | None:
|
||||||
|
stmt = select(League).where(League.code == code)
|
||||||
|
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_or_create(self, code: str, name: str, country: str | None = None) -> League:
|
||||||
|
league = await self.get_by_code(code)
|
||||||
|
if league is None:
|
||||||
|
league = League(code=code, name=name, country=country)
|
||||||
|
self._session.add(league)
|
||||||
|
await self._session.flush()
|
||||||
|
return league
|
||||||
|
|
||||||
|
async def add(self, league: League) -> None:
|
||||||
|
self._session.add(league)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class PredictionRepository:
|
||||||
|
"""预测记录数据访问。"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_id(self, prediction_id: int) -> Prediction | None:
|
||||||
|
return await self._session.get(Prediction, prediction_id)
|
||||||
|
|
||||||
|
async def add(self, prediction: Prediction) -> None:
|
||||||
|
self._session.add(prediction)
|
||||||
|
await self._session.flush()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""工作单元(Unit of Work):统一事务边界。
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
async with UnitOfWork(db) as uow:
|
||||||
|
await uow.matches.get_by_id(1)
|
||||||
|
await uow.matches.add(new_match)
|
||||||
|
# 退出时自动 commit,异常时 rollback
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.base import AsyncSessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
class UnitOfWork:
|
||||||
|
"""工作单元:封装事务边界。"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
self.committed = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self) -> AsyncSession:
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
await self._session.commit()
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
await self._session.rollback()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "UnitOfWork":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
if exc_type is not None:
|
||||||
|
await self.rollback()
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_uow() -> AsyncGenerator[UnitOfWork, None]:
|
||||||
|
"""创建新的工作单元(用于非路由上下文)。"""
|
||||||
|
session = AsyncSessionLocal()
|
||||||
|
uow = UnitOfWork(session)
|
||||||
|
try:
|
||||||
|
yield uow
|
||||||
|
if not uow.committed:
|
||||||
|
await uow.commit()
|
||||||
|
except Exception:
|
||||||
|
await uow.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await uow.close()
|
||||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
|||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.db.base import AsyncSessionLocal
|
from src.db.base import AsyncSessionLocal
|
||||||
from src.db.models import Match, Prediction
|
from src.db.models import Match, Prediction
|
||||||
|
from src.db.unit_of_work import get_uow
|
||||||
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
|
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
|
||||||
from src.llm.context_builder import (
|
from src.llm.context_builder import (
|
||||||
MatchHeader,
|
MatchHeader,
|
||||||
@@ -184,8 +185,9 @@ async def predict_match_multi(
|
|||||||
_reports_to_json(reports).encode("utf-8")
|
_reports_to_json(reports).encode("utf-8")
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
# 4. 存库
|
# 4. 存库(使用 UnitOfWork)
|
||||||
async with AsyncSessionLocal() as db:
|
async with get_uow() as uow:
|
||||||
|
db = uow.session
|
||||||
m = await db.get(Match, match_id)
|
m = await db.get(Match, match_id)
|
||||||
if m is None:
|
if m is None:
|
||||||
raise ValueError(f"match {match_id} not found")
|
raise ValueError(f"match {match_id} not found")
|
||||||
@@ -218,7 +220,7 @@ async def predict_match_multi(
|
|||||||
input_hash=input_hash,
|
input_hash=input_hash,
|
||||||
)
|
)
|
||||||
db.add(pred)
|
db.add(pred)
|
||||||
await db.commit()
|
await uow.commit()
|
||||||
await db.refresh(pred)
|
await db.refresh(pred)
|
||||||
|
|
||||||
return MultiPredictResult(
|
return MultiPredictResult(
|
||||||
|
|||||||
+8
-8
@@ -11,8 +11,8 @@ from dataclasses import dataclass, field
|
|||||||
|
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, select
|
||||||
|
|
||||||
from src.db.base import AsyncSessionLocal
|
from src.db.models import League, Match
|
||||||
from src.db.models import League, Match, Prediction
|
from src.db.unit_of_work import get_uow
|
||||||
from src.llm.eval import settle_prediction
|
from src.llm.eval import settle_prediction
|
||||||
from src.llm.predict import predict_match
|
from src.llm.predict import predict_match
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ class BacktestMatchResult:
|
|||||||
pred_home: float | None
|
pred_home: float | None
|
||||||
pred_away: float | None
|
pred_away: float | None
|
||||||
pred_1x2: str | None
|
pred_1x2: str | None
|
||||||
confidence: float | None
|
subjective_confidence: float | None
|
||||||
correct_1x2: bool
|
correct_1x2: bool
|
||||||
prediction_id: int
|
prediction_id: int
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class BacktestSummary:
|
|||||||
scored: int
|
scored: int
|
||||||
accuracy_1x2: float | None = None
|
accuracy_1x2: float | None = None
|
||||||
avg_score_rmse: float | None = None
|
avg_score_rmse: float | None = None
|
||||||
avg_confidence: float | None = None
|
avg_subjective_confidence: float | None = None
|
||||||
calibration: list[dict] = field(default_factory=list)
|
calibration: list[dict] = field(default_factory=list)
|
||||||
results: list[BacktestMatchResult] = field(default_factory=list)
|
results: list[BacktestMatchResult] = field(default_factory=list)
|
||||||
|
|
||||||
@@ -108,9 +108,9 @@ async def run_backtest(
|
|||||||
Returns:
|
Returns:
|
||||||
BacktestSummary 含逐场结果 + 汇总统计
|
BacktestSummary 含逐场结果 + 汇总统计
|
||||||
"""
|
"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with get_uow() as uow:
|
||||||
matches = await _get_historical_matches(
|
matches = await _get_historical_matches(
|
||||||
db, league_id=league_id, date_from=date_from, date_to=date_to, limit=limit
|
uow.session, league_id=league_id, date_from=date_from, date_to=date_to, limit=limit
|
||||||
)
|
)
|
||||||
|
|
||||||
summary = BacktestSummary(total=len(matches), scored=0)
|
summary = BacktestSummary(total=len(matches), scored=0)
|
||||||
@@ -163,10 +163,10 @@ async def run_backtest(
|
|||||||
if errors:
|
if errors:
|
||||||
summary.avg_score_rmse = round(sum(errors) / len(errors), 2)
|
summary.avg_score_rmse = round(sum(errors) / len(errors), 2)
|
||||||
|
|
||||||
# 平均置信度
|
# 平均主观置信度
|
||||||
confs = [r.subjective_confidence for r in summary.results if r.subjective_confidence is not None]
|
confs = [r.subjective_confidence for r in summary.results if r.subjective_confidence is not None]
|
||||||
if confs:
|
if confs:
|
||||||
summary.avg_confidence = round(sum(confs) / len(confs), 2)
|
summary.avg_subjective_confidence = round(sum(confs) / len(confs), 2)
|
||||||
|
|
||||||
# 校准:按置信度分桶,看实际准确率是否匹配
|
# 校准:按置信度分桶,看实际准确率是否匹配
|
||||||
summary.calibration = _compute_calibration(summary.results)
|
summary.calibration = _compute_calibration(summary.results)
|
||||||
|
|||||||
+7
-8
@@ -5,23 +5,22 @@ import logging
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from src.db.base import AsyncSessionLocal
|
|
||||||
from src.db.models import Prediction
|
from src.db.models import Prediction
|
||||||
|
from src.db.unit_of_work import get_uow
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def settle_prediction(prediction_id: int, home_goals: int, away_goals: int) -> Prediction:
|
async def settle_prediction(prediction_id: int, home_goals: int, away_goals: int) -> Prediction:
|
||||||
"""回填实际结果。"""
|
"""回填实际结果。"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with get_uow() as uow:
|
||||||
pred = await db.get(Prediction, prediction_id)
|
pred = await uow.session.get(Prediction, prediction_id)
|
||||||
if pred is None:
|
if pred is None:
|
||||||
raise ValueError(f"prediction {prediction_id} not found")
|
raise ValueError(f"prediction {prediction_id} not found")
|
||||||
pred.actual_home_goals = home_goals
|
pred.actual_home_goals = home_goals
|
||||||
pred.actual_away_goals = away_goals
|
pred.actual_away_goals = away_goals
|
||||||
pred.settled = True
|
pred.settled = True
|
||||||
await db.commit()
|
await uow.commit()
|
||||||
await db.refresh(pred)
|
|
||||||
return pred
|
return pred
|
||||||
|
|
||||||
|
|
||||||
@@ -36,12 +35,12 @@ def _actual_1x2(home: int, away: int) -> str:
|
|||||||
|
|
||||||
async def get_eval_summary() -> dict:
|
async def get_eval_summary() -> dict:
|
||||||
"""按 provider × 模型聚合评估。"""
|
"""按 provider × 模型聚合评估。"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with get_uow() as uow:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Prediction)
|
select(Prediction)
|
||||||
.where(Prediction.settled == True)
|
.where(Prediction.settled == True)
|
||||||
)
|
)
|
||||||
result = await db.execute(stmt)
|
result = await uow.session.execute(stmt)
|
||||||
rows = list(result.scalars().all())
|
rows = list(result.scalars().all())
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -76,6 +75,6 @@ async def get_eval_summary() -> dict:
|
|||||||
"total": b["total"],
|
"total": b["total"],
|
||||||
"accuracy_1x2": round(acc, 1),
|
"accuracy_1x2": round(acc, 1),
|
||||||
"avg_score_rmse": round(avg_err, 2) if avg_err is not None else None,
|
"avg_score_rmse": round(avg_err, 2) if avg_err is not None else None,
|
||||||
"avg_confidence": round(avg_conf, 2) if avg_conf is not None else None,
|
"avg_subjective_confidence": round(avg_conf, 2) if avg_conf is not None else None,
|
||||||
})
|
})
|
||||||
return {"summary": summary}
|
return {"summary": summary}
|
||||||
|
|||||||
+5
-2
@@ -13,6 +13,7 @@ from threading import Lock
|
|||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.db.base import AsyncSessionLocal
|
from src.db.base import AsyncSessionLocal
|
||||||
from src.db.models import Match, Prediction
|
from src.db.models import Match, Prediction
|
||||||
|
from src.db.unit_of_work import get_uow
|
||||||
from src.llm.context_builder import build_context
|
from src.llm.context_builder import build_context
|
||||||
from src.llm.provider import LLMProvider, get_default_provider
|
from src.llm.provider import LLMProvider, get_default_provider
|
||||||
|
|
||||||
@@ -143,8 +144,9 @@ async def _predict_single(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"LLM 输出校验失败: {e}")
|
raise RuntimeError(f"LLM 输出校验失败: {e}")
|
||||||
|
|
||||||
# 4. 存预测(独立 session,因为 context 用的是自己的 session)
|
# 4. 存预测(使用 UnitOfWork 统一事务)
|
||||||
async with AsyncSessionLocal() as db:
|
async with get_uow() as uow:
|
||||||
|
db = uow.session
|
||||||
# 验证 match 存在
|
# 验证 match 存在
|
||||||
m = await db.get(Match, match_id)
|
m = await db.get(Match, match_id)
|
||||||
if m is None:
|
if m is None:
|
||||||
@@ -188,4 +190,5 @@ async def _predict_single(
|
|||||||
|
|
||||||
# 5. 写入缓存
|
# 5. 写入缓存
|
||||||
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, result)
|
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, result)
|
||||||
|
await uow.commit()
|
||||||
return result
|
return result
|
||||||
|
|||||||
Reference in New Issue
Block a user