Files
Profeto/src/db/models.py
T
WorkBuddy 17e99a4f9a debt(D5): agent_outputs 类型标注对齐真实 JSON 形状(list[dict]|dict|None)
- Mapped[list[dict] | dict | None]:multi 模式存专家报告列表,
  历史数据/兼容路径可能存 dict;加注释说明形状来源
- 仅类型标注修正,列类型(JSONB)与数据、迁移均不变
2026-09-21 19:51:38 +08:00

407 lines
20 KiB
Python

"""ORM 模型: leagues / teams / matches / match_stats / standings / predictions。
数据源统一为 bzzoiro(单一数据源),伤停(injuries)与 Understat 已移除。
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Date,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class League(Base):
__tablename__ = "leagues"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
country: Mapped[str | None] = mapped_column(String(50))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
matches: Mapped[list["Match"]] = relationship(back_populates="league")
class Team(Base):
__tablename__ = "teams"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
name_zh: Mapped[str | None] = mapped_column(String(60))
team_type: Mapped[str] = mapped_column(String(20), default="club")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
home_matches: Mapped[list["Match"]] = relationship(foreign_keys="Match.home_team_id", back_populates="home_team")
away_matches: Mapped[list["Match"]] = relationship(foreign_keys="Match.away_team_id", back_populates="away_team")
class Match(Base):
__tablename__ = "matches"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False)
season: Mapped[str | None] = mapped_column(String(12))
home_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
away_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
match_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
match_date_date: Mapped[date] = mapped_column(
"match_date_date",
Date,
nullable=False,
index=True,
)
match_status: Mapped[str] = mapped_column(String(20), default="scheduled")
home_goals: Mapped[int | None] = mapped_column(Integer)
away_goals: Mapped[int | None] = mapped_column(Integer)
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)
league: Mapped[League] = relationship(back_populates="matches", lazy="selectin")
# lazy="selectin": 这些关系在业务里几乎总是一起读取(切片/回测/展示)。
# 默认的 lazy="select" 在 async SQLAlchemy 下,于 session 之外或未显式
# eager-load 时访问会抛 MissingGreenlet —— 已因此导致 form/stats/h2h
# 三个专家切片静默失败。统一改为预加载,从根上消除这类问题。
home_team: Mapped[Team] = relationship(
foreign_keys=[home_team_id], back_populates="home_matches", lazy="selectin"
)
away_team: Mapped[Team] = relationship(
foreign_keys=[away_team_id], back_populates="away_matches", lazy="selectin"
)
stats: Mapped["MatchStats | None"] = relationship(
back_populates="match", cascade="all, delete-orphan", lazy="select"
)
predictions: Mapped[list["Prediction"]] = relationship(back_populates="match", cascade="all, delete-orphan")
__table_args__ = (
Index("ix_matches_league_date", "league_id", match_date.desc()),
Index("ix_matches_home_date", "home_team_id", match_date.desc()),
Index("ix_matches_away_date", "away_team_id", match_date.desc()),
Index("ix_matches_status_date", "match_status", match_date.desc()),
Index(
"ix_matches_unique",
"league_id",
"home_team_id",
"away_team_id",
"match_date_date",
unique=True,
),
# DB-5: 数据库级约束 — 已完赛比赛必须有比分
CheckConstraint(
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
name="ck_matches_finished_has_score",
),
CheckConstraint(
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
name="ck_matches_status_enum",
),
# 半场进球 ≤ 全场进球
CheckConstraint(
"home_ht_goals IS NULL OR home_goals IS NULL OR home_ht_goals <= home_goals",
name="ck_matches_home_ht_le_full",
),
CheckConstraint(
"away_ht_goals IS NULL OR away_goals IS NULL OR away_ht_goals <= away_goals",
name="ck_matches_away_ht_le_full",
),
)
class MatchStats(Base):
__tablename__ = "match_stats"
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), primary_key=True)
home_xg: Mapped[float | None] = mapped_column(Float)
away_xg: Mapped[float | None] = mapped_column(Float)
home_shots: Mapped[int | None] = mapped_column(Integer)
away_shots: Mapped[int | None] = mapped_column(Integer)
home_shots_on_target: Mapped[int | None] = mapped_column(Integer)
away_shots_on_target: Mapped[int | None] = mapped_column(Integer)
home_corners: Mapped[int | None] = mapped_column(Integer)
away_corners: Mapped[int | None] = mapped_column(Integer)
home_possession: Mapped[float | None] = mapped_column(Float)
home_yellow_cards: Mapped[int | None] = mapped_column(Integer)
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
source_record_id: Mapped[str | None] = mapped_column(String(100))
retrieved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# xG 数据血缘:单独追踪 xG 字段的来源与更新时间(xG 可能独立于其他统计被更新)
xg_source: Mapped[str | None] = mapped_column(String(30))
xg_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
xg_source_record_id: Mapped[str | None] = mapped_column(String(100))
match: Mapped[Match] = relationship(back_populates="stats")
__table_args__ = (
# 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计)
Index("ix_match_stats_available_at", "available_at"),
)
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)
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)
league: Mapped[League] = relationship()
team: Mapped[Team] = relationship(lazy="selectin")
__table_args__ = (
UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"),
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
)
class Prediction(Base):
__tablename__ = "predictions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), nullable=False)
provider: Mapped[str] = mapped_column(String(30), nullable=False)
model: Mapped[str] = mapped_column(String(80), nullable=False)
prompt_version: Mapped[str] = mapped_column(String(20), nullable=False, default="v1")
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
latency_ms: Mapped[int | None] = mapped_column(Integer)
pred_home_goals: Mapped[float | None] = mapped_column(Float)
pred_away_goals: Mapped[float | None] = mapped_column(Float)
# 备选比分(次可能比分,可空)
alt_pred_home_goals: Mapped[int | None] = mapped_column(Integer)
alt_pred_away_goals: Mapped[int | None] = mapped_column(Integer)
pred_1x2: Mapped[str | None] = mapped_column(String(3))
subjective_confidence: Mapped[float | None] = mapped_column(Float) # LLM 主观置信度,非概率
reasoning: Mapped[str | None] = mapped_column(Text)
raw_response: Mapped[dict | None] = mapped_column(JSONB)
# multi-agent 模式: 各专家报告
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="single")
# D5: multi-agent 模式存各专家报告列表(list[dict]);历史数据/兼容路径可能存 dict。
# 仅修正类型标注与真实 JSON 形状一致,列类型(JSONB)与数据不变。
agent_outputs: Mapped[list[dict] | dict | None] = mapped_column(JSONB)
# Fix: agent_weights 独立持久化到列(原本只在 raw_response 中)
agent_weights: Mapped[dict | None] = mapped_column(JSONB)
# 预测状态: success / failed / degraded
status: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
# Fix: run_type 区分实盘(live)与回测(backtest),避免回测覆盖实盘预测
run_type: Mapped[str] = mapped_column(String(10), nullable=False, default="live")
# 时间语义:区分比赛时间、预测创建时间、数据截止时间
match_kickoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
prediction_created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
prediction_cutoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
input_hash: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
actual_home_goals: Mapped[int | None] = mapped_column(Integer)
actual_away_goals: Mapped[int | None] = mapped_column(Integer)
settled: Mapped[bool] = mapped_column(Boolean, default=False)
match: Mapped[Match] = relationship(back_populates="predictions")
__table_args__ = (
# Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存
# 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据)
UniqueConstraint(
"match_id", "provider", "model", "mode", "run_type",
name="uq_predictions_match_provider_model_mode_run_type",
),
Index("ix_predictions_match", "match_id"),
Index("ix_predictions_provider_model", "provider", "model"),
# 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测)
Index("ix_predictions_cutoff_at", "prediction_cutoff_at"),
# 数据库级约束:最后一道防线
CheckConstraint("pred_home_goals >= 0", name="ck_pred_home_goals_nonneg"),
CheckConstraint("pred_away_goals >= 0", name="ck_pred_away_goals_nonneg"),
CheckConstraint("subjective_confidence >= 0 AND subjective_confidence <= 1", name="ck_confidence_range"),
CheckConstraint("pred_1x2 IN ('1', 'X', '2')", name="ck_pred_1x2_enum"),
CheckConstraint("mode IN ('single', 'multi', 'baseline')", name="ck_mode_enum"),
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
CheckConstraint("run_type IN ('live', 'backtest')", name="ck_run_type_enum"),
)
class AppSetting(Base):
"""后台管理的运行时设置(如数据源 API Key),读取时优先于 .env 默认值。"""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
class Schedule(Base):
"""定时采集任务配置。"""
__tablename__ = "schedules"
id: Mapped[str] = mapped_column(String(50), primary_key=True)
task: Mapped[str] = mapped_column(String(20), nullable=False) # events / standings / stats / all
cron: Mapped[str] = mapped_column(String(100), nullable=False) # cron 表达式
leagues: Mapped[str | None] = mapped_column(Text) # 逗号分隔的联赛代码,空=全部
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_status: Mapped[str | None] = mapped_column(String(20)) # success / failed
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
# ── 数据管线基础设施(对应迁移 0008) ──────────────────────────────────
# Bronze 层、死信、质量监控、血缘追踪 4 张表。
class RawEvent(Base):
"""Bronze 层:采集到的原始事件存档,便于重放与审计。
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
未来接线计划:events 采集成功后写入 raw_payload,支持重放与审计。
"""
__tablename__ = "raw_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
source_system: Mapped[str] = mapped_column(String(50), nullable=False)
source_record_id: Mapped[str] = mapped_column(String(100), nullable=False)
raw_payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
ingest_batch_id: Mapped[str | None] = mapped_column(String(36))
__table_args__ = (
UniqueConstraint("source_system", "source_record_id", name="uq_raw_event"),
Index("ix_raw_event_batch", "ingest_batch_id"),
)
class IngestFailure(Base):
"""采集失败死信:记录失败原因、重试次数与下次重试时间。
bzzoiro 三条管线(events / standings / stats)抓取失败时经由
bzzoiro._safe_write_ingest_failure 写入本表(尽力而为,写入失败
不影响采集主流程)。admin 可通过 /admin/schedules/ingest-failures
查看与重试。
"""
__tablename__ = "ingest_failures"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
source_system: Mapped[str] = mapped_column(String(50), nullable=False)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_record_id: Mapped[str | None] = mapped_column(String(100))
error_type: Mapped[str] = mapped_column(String(50), nullable=False)
error_detail: Mapped[str | None] = mapped_column(Text)
raw_payload: Mapped[dict | None] = mapped_column(JSONB)
retry_count: Mapped[int] = mapped_column(Integer, server_default="0")
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String(20), server_default="pending")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
Index("ix_ingest_failure_status", "status", "next_retry_at"),
CheckConstraint(
"status IN ('pending', 'retrying', 'resolved', 'abandoned')",
name="ck_ingest_failure_status",
),
)
class DataQualityCheck(Base):
"""数据质量监控:记录每次质量检查的结果。
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
未来接线计划:定时检查比赛/统计/积分榜完整性,写入检查结果。
"""
__tablename__ = "data_quality_checks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
check_name: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[int | None] = mapped_column(Integer)
expected_value: Mapped[float | None] = mapped_column(Float)
actual_value: Mapped[float] = mapped_column(Float, nullable=False)
passed: Mapped[bool] = mapped_column(Boolean, nullable=False)
severity: Mapped[str] = mapped_column(String(10), server_default="warning")
detail: Mapped[dict | None] = mapped_column(JSONB)
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
Index("ix_dqc_checked_at", "checked_at"),
Index("ix_dqc_entity", "entity_type", "entity_id"),
)
class DataLineage(Base):
"""ETL 血缘追踪:记录从源到目标的转换过程。
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
未来接线计划:每次采集写入 source_record_id → target_table/id 映射。
"""
__tablename__ = "data_lineage"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
source_system: Mapped[str] = mapped_column(String(50), nullable=False)
source_record_id: Mapped[str] = mapped_column(String(100), nullable=False)
target_table: Mapped[str] = mapped_column(String(50), nullable=False)
target_id: Mapped[int | None] = mapped_column(Integer)
transform_name: Mapped[str] = mapped_column(String(50), nullable=False)
transform_detail: Mapped[dict | None] = mapped_column(JSONB)
batch_id: Mapped[str | None] = mapped_column(String(36))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
Index("ix_lineage_source", "source_system", "source_record_id"),
Index("ix_lineage_target", "target_table", "target_id"),
Index("ix_lineage_batch", "batch_id"),
)