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:
shangfangjian
2026-09-15 00:42:05 +08:00
parent cb36dc3ef9
commit 483cb956ba
11 changed files with 281 additions and 117 deletions
+22 -23
View File
@@ -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,42 +15,41 @@ 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))
@router.post("/ingest/understat", response_model=IngestSimpleResponse) @router.post("/ingest/understat", response_model=IngestSimpleResponse)
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: async with get_uow() as uow:
result = await source.ingest(db, league=req.league, season=req.season) result = await source.ingest(uow.session, league=req.league, season=req.season)
return IngestSimpleResponse(**result) await uow.commit()
except Exception as e: return IngestSimpleResponse(**result)
await db.rollback() except Exception as e:
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: async with get_uow() as uow:
result = await ingest_injuries(db, date=req.date) result = await ingest_injuries(uow.session, date=req.date)
return IngestSimpleResponse(**result) await uow.commit()
except Exception as e: return IngestSimpleResponse(**result)
await db.rollback() except Exception as e:
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
+16 -24
View File
@@ -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,29 +162,19 @@ 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,所以分批处理 from sqlalchemy import func
date_strs = set() stmt = (
for raw in raw_events: select(Match.home_team_id, Match.away_team_id, func.date(Match.match_date).label("d"))
nm = normalize_bzzoiro(raw, code) .where(Match.league_id == league.id)
if nm and nm.date: )
date_strs.add(nm.date.date().isoformat() if hasattr(nm.date, "date") else str(nm.date)) rows = (await db.execute(stmt)).all()
for row in rows:
if date_strs: existing_match_keys.add((row.home_team_id, row.away_team_id, str(row.d)))
from sqlalchemy import func
stmt = (
select(Match.home_team_id, Match.away_team_id, func.date(Match.match_date).label("d"))
.where(Match.league_id == league.id)
)
rows = (await db.execute(stmt)).all()
for row in rows:
existing_match_keys.add((row.home_team_id, row.away_team_id, str(row.d)))
for raw in raw_events: for raw in raw_events:
try: try:
@@ -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"]
+5 -2
View File
@@ -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
-40
View File
@@ -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
View File
@@ -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
+126
View File
@@ -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()
+63
View File
@@ -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()
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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