Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ce2e2a6a | ||
|
|
5de8ffb09d | ||
|
|
2fd8a80ad9 | ||
|
|
5676198392 | ||
|
|
ff0045ad93 | ||
|
|
983b620659 | ||
|
|
fa69795d69 | ||
|
|
c5f92c9a54 | ||
|
|
7df46544b8 | ||
|
|
d847f4f3f4 |
@@ -3,6 +3,10 @@ APP_ENV=development
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# ---- 数据库 ----
|
# ---- 数据库 ----
|
||||||
|
POSTGRES_USER=football
|
||||||
|
POSTGRES_PASSWORD=football
|
||||||
|
POSTGRES_DB=football
|
||||||
|
POSTGRES_PORT=5432
|
||||||
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
|
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
|
||||||
|
|
||||||
# ---- LLM (OpenAI-compatible,必填一个) ----
|
# ---- LLM (OpenAI-compatible,必填一个) ----
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""修复 injuries 表约束命名与 ORM 声明不一致
|
||||||
|
|
||||||
|
Revision ID: 0007_injuries_constraint_naming_align
|
||||||
|
Revises: 0006_schema_model_drift_cleanup
|
||||||
|
Create Date: 2026-09-16
|
||||||
|
|
||||||
|
背景(见代码审查报告 P2-5):
|
||||||
|
0003 迁移使用 sa.UniqueConstraint 创建唯一约束,
|
||||||
|
而 ORM models.py 中声明为 Index(..., unique=True)。
|
||||||
|
虽然 PostgreSQL 中两者效果相同(都保证唯一性),
|
||||||
|
但 pg_catalog 中表示不同,会导致:
|
||||||
|
- alembic autogenerate 持续报告漂移
|
||||||
|
- 约束命名约定不一致(uc_ 前缀 vs ix_ 前缀)
|
||||||
|
|
||||||
|
本迁移将 UniqueConstraint 替换为唯一索引,与 ORM 声明对齐。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0007_injuries_constraint_naming_align'
|
||||||
|
down_revision: Union[str, None] = '0006_schema_model_drift_cleanup'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
# 检查当前约束类型
|
||||||
|
constraints = {
|
||||||
|
c["name"]: c
|
||||||
|
for c in inspector.get_unique_constraints("injuries")
|
||||||
|
}
|
||||||
|
indexes = {
|
||||||
|
i["name"]: i
|
||||||
|
for i in inspector.get_indexes("injuries")
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果存在 UniqueConstraint 形式的 ix_injuries_player_fixture,替换为唯一索引
|
||||||
|
if "ix_injuries_player_fixture" in constraints:
|
||||||
|
# 删除唯一约束
|
||||||
|
op.drop_constraint("ix_injuries_player_fixture", "injuries", type_="unique")
|
||||||
|
|
||||||
|
# 如果不存在同名唯一索引,创建它(与 ORM 声明一致)
|
||||||
|
if "ix_injuries_player_fixture" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_injuries_player_fixture",
|
||||||
|
"injuries",
|
||||||
|
["player_id", "fixture_id", "injury_type"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
indexes = {
|
||||||
|
i["name"]: i
|
||||||
|
for i in inspector.get_indexes("injuries")
|
||||||
|
}
|
||||||
|
constraints = {
|
||||||
|
c["name"]: c
|
||||||
|
for c in inspector.get_unique_constraints("injuries")
|
||||||
|
}
|
||||||
|
|
||||||
|
# 恢复为 UniqueConstraint 形式
|
||||||
|
if "ix_injuries_player_fixture" in indexes:
|
||||||
|
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
|
||||||
|
|
||||||
|
if "ix_injuries_player_fixture" not in constraints:
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"ix_injuries_player_fixture",
|
||||||
|
"injuries",
|
||||||
|
["player_id", "fixture_id", "injury_type"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""为 predictions 表添加 match_id+provider+model 唯一约束
|
||||||
|
|
||||||
|
Revision ID: 0007_predictions_unique_constraint
|
||||||
|
Revises: 0007_injuries_constraint_naming_align
|
||||||
|
Create Date: 2026-09-16
|
||||||
|
|
||||||
|
背景(见代码审查报告 P1-6):
|
||||||
|
同一 match_id + provider + model 组合不应产生重复预测。
|
||||||
|
当前缺少数据库级唯一约束,回测多次运行或并发采集可能产生重复记录,
|
||||||
|
导致统计偏差。
|
||||||
|
|
||||||
|
先清理已存在的重复记录(保留最早创建的那条),再添加唯一约束。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0007_predictions_unique_constraint'
|
||||||
|
down_revision: Union[str, None] = '0007_injuries_constraint_naming_align'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. 清理已存在的重复记录(保留 id 最小的)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM predictions
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT MIN(id)
|
||||||
|
FROM predictions
|
||||||
|
GROUP BY match_id, provider, model
|
||||||
|
)
|
||||||
|
AND match_id IN (
|
||||||
|
SELECT match_id
|
||||||
|
FROM predictions
|
||||||
|
GROUP BY match_id, provider, model
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 添加唯一约束
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_predictions_match_provider_model",
|
||||||
|
"predictions",
|
||||||
|
["match_id", "provider", "model"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_predictions_match_provider_model",
|
||||||
|
"predictions",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""新增 Bronze 层 + 死信表 + 数据质量表 + 血缘表
|
||||||
|
|
||||||
|
Revision ID: 0008_raw_event_and_ingest_failure
|
||||||
|
Revises: 0007_predictions_unique_constraint
|
||||||
|
Create Date: 2026-09-17
|
||||||
|
|
||||||
|
架构审查报告 P1 实施:
|
||||||
|
- raw_events: Bronze 层,不可变原始采集记录
|
||||||
|
- ingest_failures: 采集失败死信表
|
||||||
|
- data_quality_checks: 数据质量检查结果记录
|
||||||
|
- data_lineage: ETL 全过程元数据血缘追踪
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0008_raw_event_and_ingest_failure'
|
||||||
|
down_revision: Union[str, None] = '0007_predictions_unique_constraint'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── raw_events: Bronze 层原始记录 ──
|
||||||
|
op.create_table(
|
||||||
|
"raw_events",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("source_system", sa.String(30), nullable=False),
|
||||||
|
sa.Column("source_record_id", sa.String(100), nullable=False),
|
||||||
|
sa.Column("raw_payload", JSONB(), nullable=False),
|
||||||
|
sa.Column("ingested_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("ingest_batch_id", sa.String(64), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_raw_events_batch", "raw_events", ["ingest_batch_id"])
|
||||||
|
op.create_index("ix_raw_events_source_ingested", "raw_events", ["source_system", "ingested_at"])
|
||||||
|
op.create_unique_constraint("uq_raw_events_source_record", "raw_events", ["source_system", "source_record_id"])
|
||||||
|
|
||||||
|
# ── ingest_failures: 采集失败死信表 ──
|
||||||
|
op.create_table(
|
||||||
|
"ingest_failures",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("source_system", sa.String(30), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(30), nullable=False),
|
||||||
|
sa.Column("source_record_id", sa.String(100), nullable=True),
|
||||||
|
sa.Column("error_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("error_detail", sa.Text(), nullable=True),
|
||||||
|
sa.Column("raw_payload", JSONB(), nullable=True),
|
||||||
|
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ingest_failures_status_next_retry", "ingest_failures", ["status", "next_retry_at"])
|
||||||
|
op.create_index("ix_ingest_failures_source", "ingest_failures", ["source_system", "entity_type"])
|
||||||
|
op.create_check_constraint("ck_ingest_failures_status", "ingest_failures",
|
||||||
|
"status IN ('pending', 'retrying', 'resolved', 'abandoned')")
|
||||||
|
op.create_check_constraint("ck_ingest_failures_retry_nonneg", "ingest_failures", "retry_count >= 0")
|
||||||
|
|
||||||
|
# ── data_quality_checks: 数据质量检查记录 ──
|
||||||
|
op.create_table(
|
||||||
|
"data_quality_checks",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("check_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(30), nullable=False),
|
||||||
|
sa.Column("entity_id", sa.String(50), nullable=True),
|
||||||
|
sa.Column("expected_value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("actual_value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("passed", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(10), nullable=False, server_default="warning"),
|
||||||
|
sa.Column("detail", sa.Text(), nullable=True),
|
||||||
|
sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_dqc_check_time", "data_quality_checks", ["check_name", "checked_at"])
|
||||||
|
op.create_index("ix_dqc_entity", "data_quality_checks", ["entity_type", "entity_id"])
|
||||||
|
op.create_index("ix_dqc_severity_passed", "data_quality_checks", ["severity", "passed"])
|
||||||
|
op.create_check_constraint("ck_dqc_severity", "data_quality_checks",
|
||||||
|
"severity IN ('info', 'warning', 'critical')")
|
||||||
|
|
||||||
|
# ── data_lineage: ETL 血缘追踪 ──
|
||||||
|
op.create_table(
|
||||||
|
"data_lineage",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("source_system", sa.String(30), nullable=False),
|
||||||
|
sa.Column("source_record_id", sa.String(100), nullable=False),
|
||||||
|
sa.Column("target_table", sa.String(50), nullable=False),
|
||||||
|
sa.Column("target_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("transform_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("transform_detail", sa.Text(), nullable=True),
|
||||||
|
sa.Column("batch_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_lineage_source", "data_lineage", ["source_system", "source_record_id"])
|
||||||
|
op.create_index("ix_lineage_target", "data_lineage", ["target_table", "target_id"])
|
||||||
|
op.create_index("ix_lineage_batch", "data_lineage", ["batch_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 逆序删除
|
||||||
|
op.drop_index("ix_lineage_batch", table_name="data_lineage")
|
||||||
|
op.drop_index("ix_lineage_target", table_name="data_lineage")
|
||||||
|
op.drop_index("ix_lineage_source", table_name="data_lineage")
|
||||||
|
op.drop_table("data_lineage")
|
||||||
|
|
||||||
|
op.drop_index("ix_dqc_severity_passed", table_name="data_quality_checks")
|
||||||
|
op.drop_index("ix_dqc_entity", table_name="data_quality_checks")
|
||||||
|
op.drop_index("ix_dqc_check_time", table_name="data_quality_checks")
|
||||||
|
op.drop_table("data_quality_checks")
|
||||||
|
|
||||||
|
op.drop_index("ix_ingest_failures_source", table_name="ingest_failures")
|
||||||
|
op.drop_index("ix_ingest_failures_status_next_retry", table_name="ingest_failures")
|
||||||
|
op.drop_table("ingest_failures")
|
||||||
|
|
||||||
|
op.drop_constraint("uq_raw_events_source_record", table_name="raw_events", type_="unique")
|
||||||
|
op.drop_index("ix_raw_events_source_ingested", table_name="raw_events")
|
||||||
|
op.drop_index("ix_raw_events_batch", table_name="raw_events")
|
||||||
|
op.drop_table("raw_events")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""MatchStats 新增 xG 追踪字段
|
||||||
|
|
||||||
|
Revision ID: 0009_match_stats_xg_fields
|
||||||
|
Revises: 0008_raw_event_and_ingest_failure
|
||||||
|
Create Date: 2026-09-17
|
||||||
|
|
||||||
|
架构审查报告 P1-4 实施:
|
||||||
|
understat 允许纠正旧 xG 值。新增字段追踪 xG 具体来源和更新时间,
|
||||||
|
实现全量覆盖模式:当 understat 数据更新时覆盖旧值而非跳过。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0009_match_stats_xg_fields'
|
||||||
|
down_revision: Union[str, None] = '0008_raw_event_and_ingest_failure'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("match_stats", sa.Column("xg_source", sa.String(30), nullable=True))
|
||||||
|
op.add_column("match_stats", sa.Column("xg_updated_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("match_stats", sa.Column("xg_source_record_id", sa.String(100), nullable=True))
|
||||||
|
op.create_index("ix_match_stats_xg_source", "match_stats", ["xg_source", "xg_updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_match_stats_xg_source", table_name="match_stats")
|
||||||
|
op.drop_column("match_stats", "xg_source_record_id")
|
||||||
|
op.drop_column("match_stats", "xg_updated_at")
|
||||||
|
op.drop_column("match_stats", "xg_source")
|
||||||
+6
-6
@@ -2,15 +2,15 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: football
|
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER 未设置}
|
||||||
POSTGRES_PASSWORD: football
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}
|
||||||
POSTGRES_DB: football
|
POSTGRES_DB: ${POSTGRES_DB:-football}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U football"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:?POSTGRES_USER 未设置}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
command: uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
+34
-45
@@ -1,64 +1,53 @@
|
|||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import Matches from './pages/Matches'
|
import Matches from './pages/Matches'
|
||||||
|
|
||||||
function Logo() {
|
/** 报眉日期行:2026年9月15日 星期二 */
|
||||||
// 纯 SVG 队徽/足球图形,替代 emoji ⚽(跨平台渲染不一致)
|
function dateLine(): string {
|
||||||
return (
|
return new Date().toLocaleDateString('zh-CN', {
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 shadow-sm">
|
year: 'numeric',
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
|
month: 'long',
|
||||||
<circle cx="12" cy="12" r="9" stroke="white" strokeWidth="1.6" />
|
day: 'numeric',
|
||||||
<path
|
weekday: 'long',
|
||||||
d="M12 6.2l3.1 2.2-1.2 3.6h-3.8L8.9 8.4 12 6.2z"
|
})
|
||||||
fill="white"
|
|
||||||
fillOpacity="0.95"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 3v3.2M4.2 9.6l4.7-1.2M7.1 19.4l2.9-4.6M16.9 19.4l-2.9-4.6M19.8 9.6l-4.7-1.2"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="1.4"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeOpacity="0.85"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="min-h-screen bg-ink-100">
|
<div className="min-h-screen bg-paper-50">
|
||||||
<header className="sticky top-0 z-20 border-b border-ink-200 bg-white/85 backdrop-blur-sm">
|
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-3">
|
<header className="masthead-rule">
|
||||||
<div className="flex items-center gap-3">
|
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||||
<Logo />
|
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||||
<div className="leading-tight">
|
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900">
|
||||||
<h1 className="text-[15px] font-semibold tracking-tight text-ink-900">
|
先知
|
||||||
先知 <span className="font-normal text-ink-400">Profeto</span>
|
<span className="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500">
|
||||||
</h1>
|
Profeto
|
||||||
<p className="text-2xs text-ink-500">
|
</span>
|
||||||
LLM 多专家协作 · 足球比分预测
|
</h1>
|
||||||
</p>
|
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">
|
||||||
</div>
|
足球比分预测 · 五路专家 · 终裁汇总
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden items-center gap-1.5 sm:inline-flex">
|
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||||
<span className="relative flex h-2 w-2">
|
<span>{dateLine()}</span>
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" />
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
<span className="inline-block h-1.5 w-1.5 bg-emerald-600" aria-hidden="true" />
|
||||||
|
服务运行中
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xs font-medium text-ink-500">服务运行中</span>
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-6xl px-6 py-6">
|
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||||
<Matches />
|
<Matches />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="mx-auto max-w-6xl px-6 pb-8 pt-2">
|
{/* ── 版底 ── */}
|
||||||
<p className="text-center text-2xs text-ink-400">
|
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||||
预测结果由大语言模型生成,仅供研究参考,不构成任何投注建议
|
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||||
</p>
|
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
+43
-39
@@ -9,13 +9,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-ink-100 text-ink-800 antialiased;
|
@apply bg-paper-50 text-ink-800 font-sans antialiased;
|
||||||
font-feature-settings: 'tnum' 1; /* 数字等宽:比分/百分比不跳动 */
|
font-feature-settings: 'tnum' 1; /* 数字等宽:比分/百分比不跳动 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 统一焦点环,键盘可达性 */
|
::selection {
|
||||||
|
@apply bg-press-wash text-press;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统一焦点环:印报红细线,键盘可达 */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@apply outline-none ring-2 ring-brand-500/40 ring-offset-1 ring-offset-white;
|
outline: 2px solid #9e1b1b;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 细滚动条 */
|
/* 细滚动条 */
|
||||||
@@ -27,7 +32,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply rounded-pill bg-ink-300/70;
|
@apply rounded-full bg-ink-300;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
@apply bg-ink-400;
|
@apply bg-ink-400;
|
||||||
@@ -46,53 +51,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* ── 卡片 ── */
|
/* ── 报头双线:粗线在上、细线在下 ── */
|
||||||
.card {
|
.masthead-rule {
|
||||||
@apply rounded-card border border-ink-200 bg-white shadow-card;
|
border-top: 3px solid #17140f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 按钮 ── */
|
/* ── 按钮:方正边框式,悬停反白 ── */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg px-3.5 py-2
|
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
|
||||||
text-sm font-medium transition-colors duration-150
|
text-sm text-ink-700 transition-colors duration-150
|
||||||
disabled:cursor-not-allowed disabled:opacity-50;
|
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
|
||||||
}
|
disabled:cursor-not-allowed disabled:opacity-40
|
||||||
.btn-primary {
|
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
|
||||||
@apply btn bg-brand-600 text-white shadow-sm hover:bg-brand-700 active:bg-brand-800;
|
|
||||||
}
|
|
||||||
.btn-ghost {
|
|
||||||
@apply btn border border-ink-200 bg-white text-ink-700 hover:bg-ink-50 hover:border-ink-300;
|
|
||||||
}
|
}
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@apply px-3 py-1.5 text-xs;
|
@apply px-2.5 py-1 text-xs;
|
||||||
|
}
|
||||||
|
.btn-solid {
|
||||||
|
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 表单控件 ── */
|
/* ── 表单控件:方正、无圆角 ── */
|
||||||
.field {
|
.field {
|
||||||
@apply rounded-lg border border-ink-200 bg-white px-3 py-2 text-sm text-ink-800
|
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
|
||||||
transition-colors hover:border-ink-300
|
transition-colors hover:border-ink-400
|
||||||
focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20;
|
focus:border-press focus:outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 状态胶囊 ── */
|
/* ── 版面切换文字标签(联赛/状态/模式) ── */
|
||||||
.pill {
|
.tab {
|
||||||
@apply inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-2xs font-medium;
|
@apply relative whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
|
||||||
|
}
|
||||||
|
.tab-on {
|
||||||
|
@apply font-medium text-press;
|
||||||
|
}
|
||||||
|
.tab-on::after {
|
||||||
|
content: '';
|
||||||
|
@apply absolute inset-x-0 bottom-0 h-0.5 bg-press;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 骨架屏 ── */
|
/* ── 小节标题:宋体加粗 + 墨色底线 ── */
|
||||||
|
.section-head {
|
||||||
|
@apply border-b border-ink-900 pb-1.5 font-serif text-sm font-bold text-ink-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 骨架占位:低调脉动,不用渐变扫光 ── */
|
||||||
.skeleton {
|
.skeleton {
|
||||||
@apply rounded bg-gradient-to-r from-ink-200 via-ink-100 to-ink-200
|
@apply animate-pulse rounded-none bg-ink-200;
|
||||||
bg-[length:200%_100%] animate-shimmer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 分段控件(模式切换) ── */
|
|
||||||
.segment {
|
|
||||||
@apply rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150;
|
|
||||||
}
|
|
||||||
.segment-on {
|
|
||||||
@apply bg-white text-brand-700 shadow-sm;
|
|
||||||
}
|
|
||||||
.segment-off {
|
|
||||||
@apply text-ink-500 hover:text-ink-700;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+285
-354
@@ -65,68 +65,37 @@ const LEAGUES = [
|
|||||||
{ code: 'F1', name: '法甲' },
|
{ code: 'F1', name: '法甲' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 汉字编号,给专家意见排版用 */
|
||||||
|
const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
|
||||||
|
|
||||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||||
finished: { label: '已完赛', cls: 'bg-ink-100 text-ink-600' },
|
finished: { label: '已完赛', cls: 'text-ink-400' },
|
||||||
scheduled: { label: '未开赛', cls: 'bg-brand-50 text-brand-700' },
|
scheduled: { label: '未开赛', cls: 'text-ink-600' },
|
||||||
live: { label: '进行中', cls: 'bg-emerald-50 text-emerald-700' },
|
live: { label: '进行中', cls: 'text-press font-medium' },
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 1x2 → 中文标签 */
|
/** 1x2 → 中文标签 */
|
||||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||||
|
|
||||||
/** 队名取首字作视觉标记(替代队徽图片,避免额外资源与 404) */
|
/** 置信度细线:0~1 数值的低调可视化 */
|
||||||
function initial(name: string): string {
|
function Meter({ value }: { value: number }) {
|
||||||
return (name || '?').trim().charAt(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 依据队名生成一个稳定的色相,让不同球队有可区分的淡色底 */
|
|
||||||
function hueOf(name: string): number {
|
|
||||||
let h = 0
|
|
||||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
function TeamMark({ name, size = 40 }: { name: string; size?: number }) {
|
|
||||||
const h = hueOf(name)
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="flex flex-shrink-0 items-center justify-center rounded-lg font-semibold"
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
fontSize: size * 0.4,
|
|
||||||
background: `hsl(${h} 70% 96%)`,
|
|
||||||
color: `hsl(${h} 55% 38%)`,
|
|
||||||
border: `1px solid hsl(${h} 60% 88%)`,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{initial(name)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 置信度条:把 0~1 的数值可视化,比纯数字更易读 */
|
|
||||||
function Meter({ value, tone = 'brand' }: { value: number; tone?: 'brand' | 'emerald' | 'amber' }) {
|
|
||||||
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
||||||
const bar =
|
|
||||||
tone === 'emerald' ? 'bg-emerald-500' : tone === 'amber' ? 'bg-amber-500' : 'bg-brand-500'
|
|
||||||
return (
|
return (
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-pill bg-ink-200" role="presentation">
|
<div className="h-px w-full bg-ink-200" role="presentation">
|
||||||
<div className={`h-full rounded-pill ${bar} transition-[width] duration-500`} style={{ width: `${pct}%` }} />
|
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向条 */
|
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */
|
||||||
function EdgeBar({ value }: { value: number }) {
|
function EdgeBar({ value }: { value: number }) {
|
||||||
const v = Math.max(-1, Math.min(1, value))
|
const v = Math.max(-1, Math.min(1, value))
|
||||||
const half = Math.abs(v) * 50
|
const half = Math.abs(v) * 50
|
||||||
return (
|
return (
|
||||||
<div className="relative h-1.5 w-full overflow-hidden rounded-pill bg-ink-200" role="presentation">
|
<div className="relative h-px w-full bg-ink-200" role="presentation">
|
||||||
<span className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-ink-300" />
|
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
|
||||||
<span
|
<span
|
||||||
className={`absolute top-0 h-full transition-all duration-500 ${v >= 0 ? 'bg-brand-500' : 'bg-amber-500'}`}
|
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
|
||||||
style={
|
style={
|
||||||
v >= 0
|
v >= 0
|
||||||
? { left: '50%', width: `${half}%` }
|
? { left: '50%', width: `${half}%` }
|
||||||
@@ -137,24 +106,76 @@ function EdgeBar({ value }: { value: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 骨架屏行,替代 emoji spinner */
|
/** 骨架占位行:低调脉动灰块 */
|
||||||
function SkeletonRows({ n = 4 }: { n?: number }) {
|
function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: n }).map((_, i) => (
|
{Array.from({ length: n }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-4 rounded-card border border-ink-200 bg-white p-4">
|
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||||
<div className="skeleton h-10 w-10 rounded-lg" />
|
<div className="skeleton h-3 w-16" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="skeleton h-3 flex-1" />
|
||||||
<div className="skeleton h-3.5 w-40" />
|
<div className="skeleton h-3 w-10" />
|
||||||
<div className="skeleton h-3 w-24" />
|
<div className="skeleton h-3 flex-1" />
|
||||||
</div>
|
<div className="skeleton h-3 w-16" />
|
||||||
<div className="skeleton h-8 w-20 rounded-lg" />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Spinner({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||||
|
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||||
|
function OutcomeLine({
|
||||||
|
pick,
|
||||||
|
confidence,
|
||||||
|
}: {
|
||||||
|
pick: string | null
|
||||||
|
confidence: number | null
|
||||||
|
}) {
|
||||||
|
const options = ['1', 'X', '2'] as const
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
|
||||||
|
{options.map(o => {
|
||||||
|
const on = pick === o
|
||||||
|
return (
|
||||||
|
<div key={o} className="flex flex-col items-center gap-1">
|
||||||
|
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
|
||||||
|
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
|
||||||
|
{OUTCOME_LABEL[o]}
|
||||||
|
</span>
|
||||||
|
{on && confidence !== null && (
|
||||||
|
<span className="text-2xs tabular-nums text-ink-500">
|
||||||
|
置信 {Math.round(confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{pick && confidence !== null && (
|
||||||
|
<div className="mx-auto mt-3 max-w-xs">
|
||||||
|
<Meter value={confidence} />
|
||||||
|
<p className="mt-1 text-center text-2xs text-ink-400">主观置信度,非统计概率</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Matches() {
|
export default function Matches() {
|
||||||
const [league, setLeague] = useState('E0')
|
const [league, setLeague] = useState('E0')
|
||||||
const [status, setStatus] = useState('scheduled')
|
const [status, setStatus] = useState('scheduled')
|
||||||
@@ -196,8 +217,7 @@ export default function Matches() {
|
|||||||
}
|
}
|
||||||
}, [league, status])
|
}, [league, status])
|
||||||
|
|
||||||
// 加载下一页(游标分页)。后端已支持 cursor,前端此前未使用,
|
// 加载下一页(游标分页)
|
||||||
// 导致 limit=50 之后的数据永远看不到。
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (!nextCursor || loadingMore) return
|
if (!nextCursor || loadingMore) return
|
||||||
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
|
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
|
||||||
@@ -256,69 +276,86 @@ export default function Matches() {
|
|||||||
|
|
||||||
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
||||||
|
|
||||||
return (
|
/** 状态/模式一组的文字切换 */
|
||||||
<div className="space-y-4">
|
const Switch = ({ value, onChange, items }: {
|
||||||
{/* ── 工具栏 ── */}
|
value: string
|
||||||
<div className="card flex flex-wrap items-center gap-3 p-3">
|
onChange: (v: string) => void
|
||||||
<div className="flex items-center gap-2">
|
items: { v: string; label: string; title?: string }[]
|
||||||
<label className="text-2xs font-medium text-ink-500">联赛</label>
|
}) => (
|
||||||
<select value={league} onChange={e => setLeague(e.target.value)} className="field w-28">
|
<span className="inline-flex items-center gap-2.5">
|
||||||
{LEAGUES.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
|
{items.map((it, i) => (
|
||||||
</select>
|
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||||
</div>
|
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||||
|
<button
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => onChange(it.v)}
|
||||||
<label className="text-2xs font-medium text-ink-500">状态</label>
|
title={it.title}
|
||||||
<select value={status} onChange={e => setStatus(e.target.value)} className="field w-28">
|
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||||
<option value="scheduled">未开赛</option>
|
>
|
||||||
<option value="finished">已完赛</option>
|
{it.label}
|
||||||
<option value="">全部</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-2xs font-medium text-ink-500">预测模式</label>
|
|
||||||
<div className="inline-flex rounded-lg bg-ink-100 p-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('single')}
|
|
||||||
className={`segment ${mode === 'single' ? 'segment-on' : 'segment-off'}`}
|
|
||||||
title="单次调用,快但只有一个模型看全部数据"
|
|
||||||
>单次</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('multi')}
|
|
||||||
className={`segment ${mode === 'multi' ? 'segment-on' : 'segment-off'}`}
|
|
||||||
title="5 个专家并行分析后由终裁汇总,质量更高"
|
|
||||||
>多 Agent</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3">
|
|
||||||
<span className="text-2xs text-ink-500">
|
|
||||||
共 <span className="font-semibold text-ink-700">{matches.length}</span> 场
|
|
||||||
</span>
|
|
||||||
<button onClick={load} disabled={loading} className="btn-ghost btn-sm">
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Spinner /> 加载中
|
|
||||||
</>
|
|
||||||
) : '刷新'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* ── 联赛版面切换 ── */}
|
||||||
|
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
|
||||||
|
{LEAGUES.map(l => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
onClick={() => setLeague(l.code)}
|
||||||
|
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ── 第二行:状态 / 模式 / 计数 / 刷新 ── */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-ink-500">
|
||||||
|
<span className="inline-flex items-center gap-2.5">
|
||||||
|
<span className="text-2xs text-ink-400">状态</span>
|
||||||
|
<Switch
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
items={[
|
||||||
|
{ v: 'scheduled', label: '未开赛' },
|
||||||
|
{ v: 'finished', label: '已完赛' },
|
||||||
|
{ v: '', label: '全部' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="inline-flex items-center gap-2.5">
|
||||||
|
<span className="text-2xs text-ink-400">模式</span>
|
||||||
|
<Switch
|
||||||
|
value={mode}
|
||||||
|
onChange={v => setMode(v as 'single' | 'multi')}
|
||||||
|
items={[
|
||||||
|
{ v: 'single', label: '单次', title: '单次调用,快但只有一个模型看全部数据' },
|
||||||
|
{ v: 'multi', label: '多专家', title: '5 个专家并行分析后由终裁汇总,质量更高' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-auto inline-flex items-center gap-3">
|
||||||
|
<span className="tabular-nums">共 {matches.length} 场</span>
|
||||||
|
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||||
|
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 错误提示 ── */}
|
{/* ── 错误提示 ── */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start justify-between gap-3 rounded-card border border-red-200 bg-red-50 px-4 py-3">
|
<div className="flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3">
|
||||||
<div className="flex items-start gap-2.5">
|
<div>
|
||||||
<svg viewBox="0 0 20 20" className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-500" fill="currentColor" aria-hidden="true">
|
<p className="text-sm font-medium text-press">请求失败</p>
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm1-4a1 1 0 100 2 1 1 0 000-2z" clipRule="evenodd" />
|
<p className="mt-0.5 text-xs text-ink-600">{error}</p>
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-red-800">请求失败</p>
|
|
||||||
<p className="mt-0.5 text-xs text-red-600">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setError(null)} className="text-red-400 transition-colors hover:text-red-600" aria-label="关闭">
|
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900" aria-label="关闭">
|
||||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -326,23 +363,19 @@ export default function Matches() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 比赛列表 ── */}
|
{/* ── 赛程栏:表格化,行间细线 ── */}
|
||||||
<div className="space-y-2.5">
|
<section aria-label="赛程">
|
||||||
{loading && <SkeletonRows n={4} />}
|
{loading && <SkeletonRows n={4} />}
|
||||||
|
|
||||||
{!loading && matches.length === 0 && (
|
{!loading && matches.length === 0 && (
|
||||||
<div className="card flex flex-col items-center justify-center gap-2 py-16">
|
<div className="border-y border-ink-200 py-14 text-center">
|
||||||
<svg viewBox="0 0 24 24" className="h-10 w-10 text-ink-300" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
|
<p className="font-serif text-sm text-ink-600">本版暂无赛程</p>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<p className="mt-1.5 text-xs text-ink-400">请先通过采集接口导入 {leagueName} 的比赛数据</p>
|
||||||
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm font-medium text-ink-600">暂无比赛数据</p>
|
|
||||||
<p className="text-xs text-ink-400">请先通过采集接口导入 {leagueName} 的赛程</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && matches.map(m => {
|
{!loading && matches.map(m => {
|
||||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'bg-ink-100 text-ink-600' }
|
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||||
const homeName = m.home_team_zh || m.home_team
|
const homeName = m.home_team_zh || m.home_team
|
||||||
const awayName = m.away_team_zh || m.away_team
|
const awayName = m.away_team_zh || m.away_team
|
||||||
const busy = predictingId === m.id
|
const busy = predictingId === m.id
|
||||||
@@ -351,70 +384,58 @@ export default function Matches() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className={`group card p-4 transition-all duration-200 hover:shadow-raise ${
|
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
|
||||||
active ? 'ring-1 ring-brand-300' : ''
|
active ? 'bg-press-wash/50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
|
||||||
{/* 对阵 */}
|
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
<div className="flex items-center justify-between sm:contents">
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:gap-2.5">
|
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
|
||||||
<span className="truncate text-sm font-medium text-ink-800">{homeName}</span>
|
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
|
||||||
<TeamMark name={homeName} size={36} />
|
</div>
|
||||||
|
|
||||||
|
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
|
||||||
|
<div className="flex items-center gap-2 sm:contents">
|
||||||
|
{/* 主队(右对齐) */}
|
||||||
|
<div className="flex min-w-0 flex-1 items-center justify-end">
|
||||||
|
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-16">
|
{/* 比分 / VS */}
|
||||||
|
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
|
||||||
{m.home_goals !== null && m.away_goals !== null ? (
|
{m.home_goals !== null && m.away_goals !== null ? (
|
||||||
<span className="text-base font-semibold tabular-nums text-ink-900">
|
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
|
||||||
{m.home_goals}<span className="mx-0.5 text-ink-300">:</span>{m.away_goals}
|
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-medium text-ink-300">VS</span>
|
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
||||||
)}
|
)}
|
||||||
{m.home_xg !== null && m.away_xg !== null && (
|
{m.home_xg !== null && m.away_xg !== null && (
|
||||||
<span className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
<span className="text-2xs tabular-nums text-ink-400">
|
||||||
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-2.5">
|
{/* 客队(左对齐) */}
|
||||||
<TeamMark name={awayName} size={36} />
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
<span className="truncate text-sm font-medium text-ink-800">{awayName}</span>
|
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 sm:flex-shrink-0">
|
{/* 状态列(桌面) */}
|
||||||
{/* 元信息:桌面端独立成列,移动端与按钮同排 */}
|
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
|
||||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-1 sm:w-40 sm:flex-none">
|
|
||||||
<span className={`pill ${st.cls}`}>
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-60" />
|
|
||||||
{st.label}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-2xs text-ink-400">
|
|
||||||
{m.league_code ?? '—'} · {fmtDate(m.match_date)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作:主功能提升为实心按钮 */}
|
{/* 预测按钮 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => predict(m)}
|
onClick={() => predict(m)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="btn-primary btn-sm w-[88px] flex-shrink-0"
|
className="btn btn-sm w-[76px]"
|
||||||
title={`以${mode === 'multi' ? '多 Agent' : '单次'}模式预测这场`}
|
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||||
<>
|
|
||||||
<Spinner /> 预测中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M10 2l1.9 5.1L17 9l-5.1 1.9L10 16l-1.9-5.1L3 9l5.1-1.9L10 2z" />
|
|
||||||
</svg>
|
|
||||||
预测
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,96 +444,39 @@ export default function Matches() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{!loading && nextCursor && (
|
{!loading && nextCursor && (
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex justify-center pt-4">
|
||||||
<button onClick={loadMore} disabled={loadingMore} className="btn-ghost">
|
<button onClick={loadMore} disabled={loadingMore} className="btn btn-sm">
|
||||||
{loadingMore ? (
|
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多'}
|
||||||
<>
|
|
||||||
<Spinner /> 加载中
|
|
||||||
</>
|
|
||||||
) : '加载更多'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* ── 预测中:占位卡片(替代 emoji 提示条) ── */}
|
{/* ── 预测中占位 ── */}
|
||||||
{predictingId && !prediction && (
|
{predictingId && !prediction && (
|
||||||
<div className="card animate-fade-up overflow-hidden">
|
<div className="border border-ink-900">
|
||||||
<div className="flex items-center gap-2 border-b border-ink-200 bg-ink-50 px-5 py-3">
|
<div className="flex items-center gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5">
|
||||||
<Spinner className="text-brand-600" />
|
<Spinner className="text-press" />
|
||||||
<span className="text-sm font-medium text-ink-700">正在生成预测</span>
|
<span className="text-sm font-medium text-ink-800">正在生成预测</span>
|
||||||
<span className="text-xs text-ink-400">
|
<span className="text-2xs text-ink-500">
|
||||||
{mode === 'multi' ? '· 5 个专家并行分析后由终裁汇总,约需 20-60 秒' : '· 单次调用,约需 5-15 秒'}
|
{mode === 'multi' ? '五路专家并行分析后终裁,约需 20-60 秒' : '单次调用,约需 5-15 秒'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 p-5">
|
<div className="space-y-4 px-4 py-6">
|
||||||
<div className="flex items-center justify-center gap-6">
|
<div className="flex items-center justify-center gap-6">
|
||||||
<div className="skeleton h-3.5 w-24" />
|
<div className="skeleton h-4 w-20" />
|
||||||
<div className="skeleton h-10 w-20 rounded-lg" />
|
<div className="skeleton h-10 w-24" />
|
||||||
<div className="skeleton h-3.5 w-24" />
|
<div className="skeleton h-4 w-20" />
|
||||||
</div>
|
|
||||||
<div className="skeleton h-2 w-full rounded-pill" />
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div className="skeleton h-20 rounded-card" />
|
|
||||||
<div className="skeleton h-20 rounded-card" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="skeleton mx-auto h-px w-64" />
|
||||||
|
<div className="skeleton h-16 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 预测结果 ── */}
|
{/* ── 预测版 ── */}
|
||||||
{prediction && predictionFor && (
|
{prediction && predictionFor && (
|
||||||
<PredictionPanel
|
<PredictionPanel prediction={prediction} match={predictionFor} mode={mode} />
|
||||||
prediction={prediction}
|
|
||||||
match={predictionFor}
|
|
||||||
mode={mode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Spinner({ className = '' }: { className?: string }) {
|
|
||||||
// 纯 SVG 转圈,替代 emoji ⏳(各平台渲染不一致)
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="2" strokeOpacity="0.25" />
|
|
||||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 胜平负概率条:LLM 只给主观置信度,这里把它按 1x2 结果做成单条高亮,
|
|
||||||
* 并显式标注「主观」避免用户误当概率 */
|
|
||||||
function OutcomeCard({
|
|
||||||
pick,
|
|
||||||
confidence,
|
|
||||||
}: {
|
|
||||||
pick: string | null
|
|
||||||
confidence: number | null
|
|
||||||
}) {
|
|
||||||
const label = pick ? OUTCOME_LABEL[pick] ?? pick : '—'
|
|
||||||
const tone = pick === '1' ? 'brand' : pick === 'X' ? 'amber' : 'emerald'
|
|
||||||
const color =
|
|
||||||
tone === 'brand' ? 'text-brand-700' : tone === 'amber' ? 'text-amber-700' : 'text-emerald-700'
|
|
||||||
const bg = tone === 'brand' ? 'bg-brand-50' : tone === 'amber' ? 'bg-amber-50' : 'bg-emerald-50'
|
|
||||||
const border =
|
|
||||||
tone === 'brand' ? 'border-brand-100' : tone === 'amber' ? 'border-amber-100' : 'border-emerald-100'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`rounded-card border ${border} ${bg} p-4`}>
|
|
||||||
<p className="text-2xs font-medium text-ink-500">胜平负</p>
|
|
||||||
<p className={`mt-1 text-2xl font-semibold ${color}`}>{label}</p>
|
|
||||||
{confidence !== null && (
|
|
||||||
<div className="mt-2.5 space-y-1.5">
|
|
||||||
<Meter value={confidence} tone={tone === 'brand' ? 'brand' : tone === 'amber' ? 'amber' : 'emerald'} />
|
|
||||||
<p className="text-2xs text-ink-400">主观置信度 {Math.round(confidence * 100)}%</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -532,129 +496,98 @@ function PredictionPanel({
|
|||||||
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
|
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card animate-fade-up overflow-hidden">
|
<article className="border border-ink-900 bg-paper-50">
|
||||||
{/* 面板头 */}
|
{/* 版头 */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ink-200 bg-ink-50 px-5 py-3">
|
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="font-serif text-sm font-bold text-ink-900">
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-brand-600">
|
预测版 · {homeName} 对 {awayName}
|
||||||
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 text-white" fill="currentColor" aria-hidden="true">
|
</h3>
|
||||||
<path d="M10 2l1.9 5.1L17 9l-5.1 1.9L10 16l-1.9-5.1L3 9l5.1-1.9L10 2z" />
|
<span className="text-2xs tabular-nums text-ink-500">
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<h3 className="text-sm font-semibold text-ink-800">LLM 预测结果</h3>
|
|
||||||
{prediction.mode === 'multi' && (
|
|
||||||
<span className="pill bg-brand-50 text-brand-700">
|
|
||||||
多 Agent · {okReports.length}/{prediction.agent_outputs?.length ?? 0} 专家有效
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-2xs text-ink-400">
|
|
||||||
{prediction.provider} / {prediction.model}
|
{prediction.provider} / {prediction.model}
|
||||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5 p-5">
|
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||||
{/* ── 对阵 + 预测比分(核心) ── */}
|
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
||||||
<div className="flex items-center justify-center gap-4 sm:gap-8">
|
<div className="text-center">
|
||||||
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 sm:flex-row sm:justify-end">
|
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||||
<span className="order-2 truncate text-sm font-medium text-ink-800 sm:order-1 sm:text-right">{homeName}</span>
|
{prediction.pred_home_goals ?? '-'}
|
||||||
<span className="order-1 sm:order-2"><TeamMark name={homeName} size={44} /></span>
|
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||||
</div>
|
{prediction.pred_away_goals ?? '-'}
|
||||||
|
</p>
|
||||||
<div className="flex flex-shrink-0 flex-col items-center">
|
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="rounded-lg bg-ink-100 px-3 py-1.5 text-2xl font-semibold tabular-nums text-ink-900">
|
|
||||||
{prediction.pred_home_goals ?? '-'}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg text-ink-300">:</span>
|
|
||||||
<span className="rounded-lg bg-ink-100 px-3 py-1.5 text-2xl font-semibold tabular-nums text-ink-900">
|
|
||||||
{prediction.pred_away_goals ?? '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="mt-1.5 text-2xs text-ink-400">预测比分</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 sm:flex-row">
|
|
||||||
<span className="order-1"><TeamMark name={awayName} size={44} /></span>
|
|
||||||
<span className="order-2 truncate text-sm font-medium text-ink-800 sm:text-left">{awayName}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 指标卡 ── */}
|
{/* ── 胜平负 ── */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="border-y border-ink-200 py-4">
|
||||||
<OutcomeCard pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||||
<div className="rounded-card border border-ink-200 p-4">
|
|
||||||
<p className="text-2xs font-medium text-ink-500">预测模式</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-ink-800">{mode === 'multi' ? '多 Agent' : '单次'}</p>
|
|
||||||
<p className="mt-2.5 text-2xs text-ink-400">
|
|
||||||
prompt {prediction.prompt_version ?? '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-card border border-ink-200 p-4">
|
|
||||||
<p className="text-2xs font-medium text-ink-500">模型耗时</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold tabular-nums text-ink-800">
|
|
||||||
{prediction.latency_ms !== null ? (prediction.latency_ms / 1000).toFixed(1) : '—'}
|
|
||||||
<span className="ml-0.5 text-sm font-normal text-ink-400">s</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-2.5 text-2xs text-ink-400">含专家并行 + 终裁汇总</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 专家报告 ── */}
|
{/* ── 元信息一行 ── */}
|
||||||
|
<p className="text-center text-2xs text-ink-500">
|
||||||
|
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
||||||
|
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||||
|
{prediction.latency_ms !== null && ` · 终裁耗时 ${(prediction.latency_ms / 1000).toFixed(1)} 秒`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── 专家意见 ── */}
|
||||||
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
||||||
<div>
|
<section>
|
||||||
<div className="mb-2.5 flex items-center gap-2">
|
<div className="section-head flex flex-wrap items-baseline justify-between gap-1">
|
||||||
<h4 className="text-xs font-semibold text-ink-700">专家 Agent 报告</h4>
|
<span>五路专家意见</span>
|
||||||
{prediction.agent_weights && (
|
{prediction.agent_weights && (
|
||||||
<span className="text-2xs text-ink-400">
|
<span className="font-sans text-2xs font-normal text-ink-500">
|
||||||
终裁权重:
|
终裁权重:{Object.entries(prediction.agent_weights)
|
||||||
{Object.entries(prediction.agent_weights)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
|
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
|
||||||
.join(' · ')}
|
.join(' / ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
|
<div>
|
||||||
{prediction.agent_outputs.map(r => (
|
{prediction.agent_outputs.map((r, i) => (
|
||||||
<AgentCard key={r.agent} report={r} />
|
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 推理过程 ── */}
|
{/* ── 终裁意见:引文式,红竖线 ── */}
|
||||||
{prediction.reasoning && (
|
{prediction.reasoning && (
|
||||||
<div className="rounded-card border border-ink-200 bg-ink-50 p-4">
|
<section>
|
||||||
<p className="mb-2 text-2xs font-semibold uppercase tracking-wide text-ink-500">终裁推理</p>
|
<h4 className="section-head mb-3">终裁意见</h4>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-700">{prediction.reasoning}</p>
|
<blockquote className="border-l-2 border-press pl-4">
|
||||||
</div>
|
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
|
||||||
|
{prediction.reasoning}
|
||||||
|
</p>
|
||||||
|
</blockquote>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 原始上下文 ── */}
|
{/* ── 原始上下文 ── */}
|
||||||
<details className="group">
|
<details className="group">
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-ink-500 transition-colors hover:text-ink-700">
|
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-ink-500 transition-colors hover:text-ink-800">
|
||||||
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
<svg viewBox="0 0 20 20" className="h-3 w-3 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||||
</svg>
|
</svg>
|
||||||
查看喂给模型的完整数据切片
|
查看喂给模型的完整数据切片
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 max-h-80 overflow-auto rounded-card border border-ink-700 bg-ink-900 p-4 font-mono text-2xs leading-relaxed text-ink-300">
|
<pre className="mt-2 max-h-80 overflow-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-600">
|
||||||
{prediction.context}
|
{prediction.context}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||||
ok: { label: '正常', cls: 'bg-emerald-50 text-emerald-700 border-emerald-100' },
|
ok: { label: '正常', cls: 'text-ink-500' },
|
||||||
no_data: { label: '无数据', cls: 'bg-ink-100 text-ink-500 border-ink-200' },
|
no_data: { label: '无数据', cls: 'text-ink-400' },
|
||||||
error: { label: '调用失败', cls: 'bg-red-50 text-red-600 border-red-100' },
|
error: { label: '调用失败', cls: 'text-press' },
|
||||||
parse_error: { label: '解析失败', cls: 'bg-amber-50 text-amber-700 border-amber-100' },
|
parse_error: { label: '解析失败', cls: 'text-press' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUFFICIENCY_LABEL: Record<string, string> = {
|
const SUFFICIENCY_LABEL: Record<string, string> = {
|
||||||
@@ -664,32 +597,32 @@ const SUFFICIENCY_LABEL: Record<string, string> = {
|
|||||||
none: '无',
|
none: '无',
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentCard({ report: r }: { report: AgentReport }) {
|
/** 单路专家意见:汉字编号 + 细线行 */
|
||||||
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'bg-ink-100 text-ink-500 border-ink-200' }
|
function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||||||
|
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' }
|
||||||
const inactive = r.status !== 'ok'
|
const inactive = r.status !== 'ok'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className="group rounded-card border border-ink-200 bg-white transition-colors open:border-ink-300 hover:border-ink-300">
|
<details className="group border-b border-ink-200">
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-3">
|
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
|
||||||
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 flex-shrink-0 text-ink-400 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
<span className="font-serif text-sm text-ink-400">{no}</span>
|
||||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
||||||
</svg>
|
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-ink-800">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
|
||||||
|
|
||||||
<span className={`pill border ${badge.cls}`}>{badge.label}</span>
|
|
||||||
|
|
||||||
<span className="ml-auto flex items-center gap-3 text-2xs tabular-nums text-ink-500">
|
|
||||||
{r.status === 'ok' && r.subjective_confidence !== null && (
|
{r.status === 'ok' && r.subjective_confidence !== null && (
|
||||||
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
||||||
)}
|
)}
|
||||||
{r.status === 'ok' && r.probable_score && (
|
{r.status === 'ok' && r.probable_score && (
|
||||||
<span className="rounded bg-ink-100 px-1.5 py-0.5 font-medium text-ink-600">{r.probable_score}</span>
|
<span className="font-serif font-bold text-ink-800">{r.probable_score}</span>
|
||||||
)}
|
)}
|
||||||
|
<svg viewBox="0 0 20 20" className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div className="space-y-3 border-t border-ink-100 px-3.5 pb-3.5 pt-3">
|
<div className="space-y-3 px-1 pb-4 pl-7">
|
||||||
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
|
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
|
||||||
{inactive && (
|
{inactive && (
|
||||||
<p className="text-xs leading-relaxed text-ink-500">
|
<p className="text-xs leading-relaxed text-ink-500">
|
||||||
@@ -701,9 +634,9 @@ function AgentCard({ report: r }: { report: AgentReport }) {
|
|||||||
|
|
||||||
{!inactive && r.home_edge !== null && (
|
{!inactive && r.home_edge !== null && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 flex items-center justify-between text-2xs">
|
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
|
||||||
<span className="font-medium text-ink-500">主队优势</span>
|
<span className="text-ink-500">主队优势</span>
|
||||||
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-brand-600' : r.home_edge < 0 ? 'text-amber-600' : 'text-ink-500'}`}>
|
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
|
||||||
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -716,14 +649,14 @@ function AgentCard({ report: r }: { report: AgentReport }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{r.analysis && (
|
{r.analysis && (
|
||||||
<p className="text-sm leading-relaxed text-ink-700">{r.analysis}</p>
|
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{r.key_evidence.length > 0 && (
|
{r.key_evidence.length > 0 && (
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{r.key_evidence.map((e, i) => (
|
{r.key_evidence.map((e, i) => (
|
||||||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
||||||
<span className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-ink-300" />
|
<span className="flex-shrink-0 text-ink-300" aria-hidden="true">—</span>
|
||||||
<span>{e}</span>
|
<span>{e}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -731,20 +664,18 @@ function AgentCard({ report: r }: { report: AgentReport }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-ink-50 px-3 py-2">
|
<p className="text-xs text-ink-500">
|
||||||
<span className="text-2xs font-medium text-ink-500">进球期望</span>
|
进球期望 <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
|
||||||
<span className="text-sm font-semibold tabular-nums text-ink-800">
|
</p>
|
||||||
{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!inactive && (
|
{!inactive && (
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
||||||
<span>数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}</span>
|
数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
|
||||||
|
<span className="mx-2 text-ink-200">|</span>
|
||||||
<span className="font-mono">{r.model}</span>
|
<span className="font-mono">{r.model}</span>
|
||||||
{r.latency_ms !== null && <span className="tabular-nums">{r.latency_ms}ms</span>}
|
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
|
||||||
</div>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+35
-57
@@ -4,79 +4,57 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// 品牌主色:偏冷的蓝,比默认 blue-600 更沉稳,适合数据产品
|
// 纸白:微暖底色,像新闻纸而不是纯白画布
|
||||||
brand: {
|
paper: {
|
||||||
50: '#EFF6FF',
|
50: '#FDFCF8',
|
||||||
100: '#DBEAFE',
|
100: '#F7F4EC',
|
||||||
200: '#BFDBFE',
|
200: '#EDE9DE',
|
||||||
300: '#93C5FD',
|
300: '#DDD7C7',
|
||||||
400: '#60A5FA',
|
|
||||||
500: '#3B82F6',
|
|
||||||
600: '#2563EB',
|
|
||||||
700: '#1D4ED8',
|
|
||||||
800: '#1E40AF',
|
|
||||||
900: '#1E3A8A',
|
|
||||||
950: '#172554',
|
|
||||||
},
|
},
|
||||||
// 中性色:带一点冷调,避免纯灰显得脏
|
// 墨色:暖黑灰阶,替代冷调 slate
|
||||||
ink: {
|
ink: {
|
||||||
50: '#F8FAFC',
|
50: '#FAF9F7',
|
||||||
100: '#F1F5F9',
|
100: '#F0EEE9',
|
||||||
200: '#E2E8F0',
|
200: '#E2DFD7',
|
||||||
300: '#CBD5E1',
|
300: '#C9C4B8',
|
||||||
400: '#94A3B8',
|
400: '#9C9587',
|
||||||
500: '#64748B',
|
500: '#6E675B',
|
||||||
600: '#475569',
|
600: '#524C42',
|
||||||
700: '#334155',
|
700: '#3B362E',
|
||||||
800: '#1E293B',
|
800: '#282420',
|
||||||
900: '#0F172A',
|
900: '#17140F',
|
||||||
|
},
|
||||||
|
// 印报红:全站唯一强调色,克制使用
|
||||||
|
press: {
|
||||||
|
DEFAULT: '#9E1B1B',
|
||||||
|
dark: '#7C1414',
|
||||||
|
wash: '#F7E9E4',
|
||||||
},
|
},
|
||||||
// 语义色:状态徽章 / 提示条统一走这套
|
|
||||||
win: { bg: '#ECFDF5', fg: '#047857', line: '#A7F3D0' },
|
|
||||||
draw: { bg: '#FFFBEB', fg: '#B45309', line: '#FDE68A' },
|
|
||||||
loss: { bg: '#FEF2F2', fg: '#B91C1C', line: '#FECACA' },
|
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
// 标题与比分:宋体血统,报纸版面的骨架
|
||||||
|
serif: [
|
||||||
|
'Georgia',
|
||||||
|
'"Times New Roman"',
|
||||||
|
'"Songti SC"',
|
||||||
|
'STSong',
|
||||||
|
'SimSun',
|
||||||
|
'"Noto Serif CJK SC"',
|
||||||
|
'serif',
|
||||||
|
],
|
||||||
|
// 正文:中文黑体栈
|
||||||
sans: [
|
sans: [
|
||||||
'ui-sans-serif',
|
|
||||||
'-apple-system',
|
|
||||||
'BlinkMacSystemFont',
|
|
||||||
'"Segoe UI"',
|
|
||||||
'"PingFang SC"',
|
'"PingFang SC"',
|
||||||
'"Hiragino Sans GB"',
|
'"Hiragino Sans GB"',
|
||||||
'"Microsoft YaHei"',
|
'"Microsoft YaHei"',
|
||||||
|
'"Noto Sans CJK SC"',
|
||||||
'sans-serif',
|
'sans-serif',
|
||||||
],
|
],
|
||||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
|
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
// 多一档 2xs,给徽章/元信息用
|
|
||||||
'2xs': ['11px', { lineHeight: '16px' }],
|
'2xs': ['11px', { lineHeight: '16px' }],
|
||||||
},
|
},
|
||||||
borderRadius: {
|
|
||||||
card: '12px',
|
|
||||||
pill: '9999px',
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
// 卡片阴影:极浅,靠层次而非重影提升
|
|
||||||
card: '0 1px 2px 0 rgb(15 23 42 / 0.04), 0 1px 3px 0 rgb(15 23 42 / 0.06)',
|
|
||||||
raise: '0 4px 12px -2px rgb(15 23 42 / 0.08), 0 2px 6px -2px rgb(15 23 42 / 0.04)',
|
|
||||||
focus: '0 0 0 3px rgb(37 99 235 / 0.15)',
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
'fade-up': {
|
|
||||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
|
||||||
},
|
|
||||||
shimmer: {
|
|
||||||
'0%': { backgroundPosition: '-200% 0' },
|
|
||||||
'100%': { backgroundPosition: '200% 0' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'fade-up': 'fade-up 0.22s ease-out both',
|
|
||||||
shimmer: 'shimmer 1.4s linear infinite',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
+7
-2
@@ -16,6 +16,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
from src.db.base import init_db
|
from src.db.base import init_db
|
||||||
from src.core.http_client import close_client
|
from src.core.http_client import close_client
|
||||||
await init_db() # 验证连接,不建表
|
await init_db() # 验证连接,不建表
|
||||||
|
# P2-5: 启动时执行一次 ingest failure 重试清理
|
||||||
|
from src.data.retry_worker import run_retry_worker
|
||||||
|
await run_retry_worker()
|
||||||
yield
|
yield
|
||||||
await close_client()
|
await close_client()
|
||||||
|
|
||||||
@@ -29,12 +32,14 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||||
|
methods = [m.strip() for m in settings.CORS_METHODS.split(",") if m.strip()]
|
||||||
|
headers = [h.strip() for h in settings.CORS_HEADERS.split(",") if h.strip()]
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=origins,
|
allow_origins=origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=methods,
|
||||||
allow_headers=["*"],
|
allow_headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.api.routes.matches import router as matches_router
|
from src.api.routes.matches import router as matches_router
|
||||||
|
|||||||
+4
-10
@@ -19,24 +19,18 @@ from src.core.config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_warned_unset = False
|
|
||||||
|
|
||||||
|
|
||||||
async def require_admin_key(x_api_key: str | None = Header(None, alias="X-API-Key")) -> None:
|
async def require_admin_key(x_api_key: str | None = Header(None, alias="X-API-Key")) -> None:
|
||||||
"""保护「写入型 / 高成本」接口的依赖。
|
"""保护「写入型 / 高成本」接口的依赖。
|
||||||
|
|
||||||
用法: `@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin_key)])`
|
用法: `@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin_key)])`
|
||||||
"""
|
"""
|
||||||
global _warned_unset
|
|
||||||
|
|
||||||
expected = settings.ADMIN_API_KEY
|
expected = settings.ADMIN_API_KEY
|
||||||
if not expected:
|
if not expected:
|
||||||
if not _warned_unset:
|
logger.warning(
|
||||||
logger.warning(
|
"ADMIN_API_KEY 未设置,采集/回测接口当前【无鉴权】。"
|
||||||
"ADMIN_API_KEY 未设置,采集/回测接口当前【无鉴权】。"
|
"生产环境请设置该环境变量。"
|
||||||
"生产环境请设置该环境变量。"
|
)
|
||||||
)
|
|
||||||
_warned_unset = True
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
|
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# --- CORS ---
|
# --- CORS ---
|
||||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
CORS_METHODS: str = "GET,POST,PUT,DELETE,OPTIONS"
|
||||||
|
CORS_HEADERS: str = "Authorization,Content-Type,X-API-Key,Accept"
|
||||||
|
|
||||||
|
# --- HTTP ---
|
||||||
|
HTTP_DEFAULT_TIMEOUT: int = 30
|
||||||
|
|
||||||
|
# --- database pool ---
|
||||||
|
DB_POOL_SIZE: int = 5
|
||||||
|
DB_MAX_OVERFLOW: int = 10
|
||||||
|
DB_POOL_TIMEOUT: int = 30
|
||||||
|
DB_POOL_RECYCLE: int = 1800
|
||||||
|
|
||||||
# --- 管理接口鉴权 ---
|
# --- 管理接口鉴权 ---
|
||||||
# 采集 / 回测等高成本或写入型接口需要此 Key(请求头 X-API-Key)。
|
# 采集 / 回测等高成本或写入型接口需要此 Key(请求头 X-API-Key)。
|
||||||
|
|||||||
@@ -2,24 +2,27 @@
|
|||||||
|
|
||||||
使用方:
|
使用方:
|
||||||
- src/llm/provider.py: LLM 调用
|
- src/llm/provider.py: LLM 调用
|
||||||
|
- src/data/bzzoiro.py: bzzoiro 比赛数据
|
||||||
- src/data/understat.py: xG 抓取
|
- src/data/understat.py: xG 抓取
|
||||||
- src/data/injuries.py: 伤停抓取
|
- src/data/injuries.py: 伤停抓取
|
||||||
|
|
||||||
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
||||||
|
调用方可通过 `timeout` 参数覆盖 per-request 超时。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from src.core.config import settings
|
||||||
|
|
||||||
_shared_client: httpx.AsyncClient | None = None
|
_shared_client: httpx.AsyncClient | None = None
|
||||||
_default_timeout = 30
|
|
||||||
|
|
||||||
|
|
||||||
def get_client() -> httpx.AsyncClient:
|
def get_client() -> httpx.AsyncClient:
|
||||||
"""获取共享客户端(懒初始化)。"""
|
"""获取共享客户端(懒初始化)。"""
|
||||||
global _shared_client
|
global _shared_client
|
||||||
if _shared_client is None or _shared_client.is_closed:
|
if _shared_client is None or _shared_client.is_closed:
|
||||||
_shared_client = httpx.AsyncClient(timeout=_default_timeout)
|
_shared_client = httpx.AsyncClient(timeout=settings.HTTP_DEFAULT_TIMEOUT)
|
||||||
return _shared_client
|
return _shared_client
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
"""重试工具:带指数退避的瞬态错误重试。
|
|
||||||
|
|
||||||
NOTE(审查报告 P3):当前全项目**无调用点** —— bzzoiro 在 `_fetch_json_sync`
|
|
||||||
里自带了一套重试逻辑,understat/injuries 各自也有。这里保留是作为后续统一
|
|
||||||
重试策略的落点,但请勿误以为它已在生效。
|
|
||||||
|
|
||||||
如果决定不引入统一重试,建议删除本文件以避免"看起来有重试、实际没有"的误判。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import functools
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from typing import Callable, Iterable, TypeVar
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
def with_retry(
|
|
||||||
*,
|
|
||||||
max_retries: int = 3,
|
|
||||||
base_delay: float = 1.0,
|
|
||||||
max_delay: float = 30.0,
|
|
||||||
retryable_exceptions: Iterable[type[BaseException]] = (Exception,),
|
|
||||||
on_retry: Callable[[Exception, int], None] | None = None,
|
|
||||||
) -> Callable:
|
|
||||||
"""重试装饰器(同步/异步通用,指数退避 + 抖动)。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_retries: 最大重试次数
|
|
||||||
base_delay: 基础延迟(秒)
|
|
||||||
max_delay: 最大延迟(秒)
|
|
||||||
retryable_exceptions: 触发重试的异常类型
|
|
||||||
on_retry: 重试回调(exception, attempt)
|
|
||||||
"""
|
|
||||||
retryable = tuple(retryable_exceptions)
|
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
|
||||||
@functools.wraps(func)
|
|
||||||
async def async_wrapper(*args, **kwargs):
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(max_retries + 1):
|
|
||||||
try:
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
except retryable as e:
|
|
||||||
last_exc = e
|
|
||||||
if attempt == max_retries:
|
|
||||||
break
|
|
||||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
|
||||||
delay += random.uniform(0, delay * 0.1) # 抖动
|
|
||||||
logger.warning(
|
|
||||||
"%s failed (attempt %d/%d), retry in %.1fs: %s",
|
|
||||||
func.__name__, attempt + 1, max_retries, delay, e,
|
|
||||||
)
|
|
||||||
if on_retry:
|
|
||||||
on_retry(e, attempt + 1)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
raise last_exc # type: ignore[misc]
|
|
||||||
|
|
||||||
@functools.wraps(func)
|
|
||||||
def sync_wrapper(*args, **kwargs):
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(max_retries + 1):
|
|
||||||
try:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
except retryable as e:
|
|
||||||
last_exc = e
|
|
||||||
if attempt == max_retries:
|
|
||||||
break
|
|
||||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
|
||||||
delay += random.uniform(0, delay * 0.1)
|
|
||||||
logger.warning(
|
|
||||||
"%s failed (attempt %d/%d), retry in %.1fs: %s",
|
|
||||||
func.__name__, attempt + 1, max_retries, delay, e,
|
|
||||||
)
|
|
||||||
if on_retry:
|
|
||||||
on_retry(e, attempt + 1)
|
|
||||||
time.sleep(delay)
|
|
||||||
raise last_exc # type: ignore[misc]
|
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
|
||||||
return async_wrapper
|
|
||||||
return sync_wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
+280
-42
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
||||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
使用 Repository 模式进行数据访问,不直接控制事务。
|
||||||
|
|
||||||
|
改进(P1):
|
||||||
|
- Bronze 层集成:采集后先存 RawEvent,再规范化
|
||||||
|
- 死信表集成:采集/规范化失败写 IngestFailure
|
||||||
|
- 令牌桶限流:替换固定 REQUEST_INTERVAL sleep
|
||||||
|
- 数据血缘:记录 ETL 全过程
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,23 +15,36 @@ import asyncio
|
|||||||
import json as _json
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time as _time
|
import uuid
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
|
from src.core.http_client import get_client
|
||||||
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.normalize import normalize_bzzoiro
|
from src.data.normalize import normalize_bzzoiro
|
||||||
|
from src.data.rate_limiter import RateLimitedClient, TokenBucket
|
||||||
from src.data.sources import register
|
from src.data.sources import register
|
||||||
from src.db.models import League, Match, MatchStats, Team
|
from src.db.models import DataLineage, DataQualityCheck, IngestFailure, League, Match, MatchStats, RawEvent, Team
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 令牌桶限流器:替代固定 sleep,rate 根据 REQUEST_INTERVAL 计算
|
||||||
|
_bzzoiro_limiter = TokenBucket(rate=1.0 / REQUEST_INTERVAL, capacity=3)
|
||||||
|
# P2-3: RateLimitedClient 包装,在 HTTP 调用层面透明限流
|
||||||
|
_bzzoiro_rl_client: RateLimitedClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bzzoiro_rl_client() -> RateLimitedClient:
|
||||||
|
"""惰性初始化 RateLimitedClient(避免模块导入时创建 client)。"""
|
||||||
|
global _bzzoiro_rl_client
|
||||||
|
if _bzzoiro_rl_client is None:
|
||||||
|
client = get_client()
|
||||||
|
_bzzoiro_rl_client = RateLimitedClient(client, _bzzoiro_limiter)
|
||||||
|
return _bzzoiro_rl_client
|
||||||
|
|
||||||
|
|
||||||
def _to_date(value):
|
def _to_date(value):
|
||||||
"""把 datetime / date / str 统一成 `date`。"""
|
"""把 datetime / date / str 统一成 `date`。"""
|
||||||
@@ -47,43 +66,47 @@ def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, i
|
|||||||
return (home_team_id, away_team_id, d.isoformat() if d is not None else "")
|
return (home_team_id, away_team_id, d.isoformat() if d is not None else "")
|
||||||
|
|
||||||
|
|
||||||
def _fetch_json_sync(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
|
async def _fetch_json_async(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
|
||||||
"""同步 HTTP(bzzoiro 客户端保持同步,在 async 函数里 run_in_executor)。"""
|
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。"""
|
||||||
base = settings.BZZOIRO_BASE.rstrip("/")
|
base = settings.BZZOIRO_BASE.rstrip("/")
|
||||||
url = f"{base}/{path.lstrip('/')}"
|
url = f"{base}/{path.lstrip('/')}"
|
||||||
if params:
|
|
||||||
url += "?" + urllib.parse.urlencode(params)
|
|
||||||
key = settings.BZZOIRO_KEY
|
key = settings.BZZOIRO_KEY
|
||||||
if not key:
|
if not key:
|
||||||
raise RuntimeError("BZZOIRO_KEY 未设置")
|
raise RuntimeError("BZZOIRO_KEY 未设置")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Token {key}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url)
|
# P2-3: 使用 RateLimitedClient 包装 HTTP 调用,透明限流
|
||||||
req.add_header("Authorization", f"Token {key}")
|
rl_client = _get_bzzoiro_rl_client()
|
||||||
req.add_header("Accept", "application/json")
|
resp = await rl_client.get(url, headers=headers, params=params, timeout=30)
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
resp.raise_for_status()
|
||||||
return _json.loads(resp.read().decode("utf-8"))
|
return resp.json()
|
||||||
except urllib.error.HTTPError as e:
|
except Exception as e:
|
||||||
last_exc = e
|
last_exc = e
|
||||||
if e.code == 429:
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||||
# 指数退避: 429 通常意味着限速
|
if status == 429:
|
||||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||||
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
|
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
|
||||||
_time.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
continue
|
continue
|
||||||
if 500 <= e.code < 600:
|
if 500 <= (status or 0) < 600:
|
||||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||||
logger.warning("bzzoiro %d, retry %d in %.1fs", e.code, attempt + 1, delay)
|
logger.warning("bzzoiro %d, retry %d in %.1fs", status, attempt + 1, delay)
|
||||||
_time.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
continue
|
continue
|
||||||
raise # 4xx 直接抛
|
# 网络错误(连接失败/超时)也退避重试
|
||||||
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
if isinstance(e, (TimeoutError, ConnectionError, OSError)):
|
||||||
last_exc = e
|
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
logger.warning("bzzoiro network error, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
||||||
logger.warning("bzzoiro network error, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
await asyncio.sleep(delay)
|
||||||
_time.sleep(delay)
|
continue
|
||||||
|
raise
|
||||||
raise RuntimeError(f"bzzoiro request failed after {max_retries} attempts: {last_exc}")
|
raise RuntimeError(f"bzzoiro request failed after {max_retries} attempts: {last_exc}")
|
||||||
|
|
||||||
|
|
||||||
@@ -95,15 +118,13 @@ async def fetch_bzzoiro_events(
|
|||||||
date_to: str | None = None,
|
date_to: str | None = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""抓取 bzzoiro 原始事件(异步包装)。"""
|
"""抓取 bzzoiro 原始事件(纯异步,无需 run_in_executor)。"""
|
||||||
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
|
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
|
||||||
if league_id is None:
|
if league_id is None:
|
||||||
raise ValueError(f"未知联赛代码: {league_code}")
|
raise ValueError(f"未知联赛代码: {league_code}")
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
offset = 0
|
offset = 0
|
||||||
payload: dict | list = {}
|
|
||||||
while True:
|
while True:
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"league_id": league_id,
|
"league_id": league_id,
|
||||||
@@ -115,8 +136,7 @@ async def fetch_bzzoiro_events(
|
|||||||
params["date_from"] = str(date_from)[:10]
|
params["date_from"] = str(date_from)[:10]
|
||||||
if date_to:
|
if date_to:
|
||||||
params["date_to"] = str(date_to)[:10]
|
params["date_to"] = str(date_to)[:10]
|
||||||
# 显式位置参数,避免 lambda 闭包捕获循环变量
|
payload = await _fetch_json_async("/events/", params)
|
||||||
payload = await loop.run_in_executor(None, _fetch_json_sync, "/events/", params)
|
|
||||||
batch = payload.get("results") or []
|
batch = payload.get("results") or []
|
||||||
if not batch:
|
if not batch:
|
||||||
break
|
break
|
||||||
@@ -127,10 +147,141 @@ async def fetch_bzzoiro_events(
|
|||||||
break
|
break
|
||||||
if len(batch) < limit:
|
if len(batch) < limit:
|
||||||
break
|
break
|
||||||
await asyncio.sleep(REQUEST_INTERVAL)
|
# 令牌桶已在 _fetch_json_async 内部处理,这里不再需要固定 sleep
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_bronze_events(db, raw_events: list[dict], batch_id: str) -> None:
|
||||||
|
"""将原始事件写入 Bronze 层(RawEvent 表)。
|
||||||
|
|
||||||
|
P2-1: 写入血缘记录,追踪 Bronze 层摄取过程。
|
||||||
|
"""
|
||||||
|
for raw in raw_events:
|
||||||
|
try:
|
||||||
|
source_record_id = str(raw.get("id", ""))
|
||||||
|
if not source_record_id:
|
||||||
|
# P1-fix: 不再静默跳过,记录到死信表
|
||||||
|
logger.warning("bzzoiro raw event missing id: %s", str(raw)[:200])
|
||||||
|
await _write_ingest_failure(
|
||||||
|
db, "bzzoiro", "match", "",
|
||||||
|
"normalize_error", "raw event missing id", raw,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
bronze = RawEvent(
|
||||||
|
source_system="bzzoiro",
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
raw_payload=raw,
|
||||||
|
ingest_batch_id=batch_id,
|
||||||
|
)
|
||||||
|
db.add(bronze)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("bzzoiro raw_event write failed: %s", e)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("bzzoiro raw_events flush failed: %s", e)
|
||||||
|
|
||||||
|
# P2-1: 写入 Bronze 层血缘记录
|
||||||
|
for raw in raw_events:
|
||||||
|
source_record_id = str(raw.get("id", ""))
|
||||||
|
if not source_record_id:
|
||||||
|
continue
|
||||||
|
await _write_data_lineage(
|
||||||
|
db,
|
||||||
|
source_system="bzzoiro",
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
target_table="raw_events",
|
||||||
|
transform_name="bronze_ingest",
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_ingest_failure(
|
||||||
|
db,
|
||||||
|
source_system: str,
|
||||||
|
entity_type: str,
|
||||||
|
source_record_id: str,
|
||||||
|
error_type: str,
|
||||||
|
error_detail: str,
|
||||||
|
raw_payload: dict | None,
|
||||||
|
) -> None:
|
||||||
|
"""写入采集失败到死信表(IngestFailure)。"""
|
||||||
|
try:
|
||||||
|
from datetime import timedelta
|
||||||
|
failure = IngestFailure(
|
||||||
|
source_system=source_system,
|
||||||
|
entity_type=entity_type,
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
error_type=error_type,
|
||||||
|
error_detail=error_detail[:2000] if error_detail else None,
|
||||||
|
raw_payload=raw_payload,
|
||||||
|
status="pending",
|
||||||
|
next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
db.add(failure)
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("bzzoiro ingest_failure write failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_data_lineage(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
source_system: str,
|
||||||
|
source_record_id: str,
|
||||||
|
target_table: str,
|
||||||
|
target_id: int | None = None,
|
||||||
|
transform_name: str,
|
||||||
|
transform_detail: str | None = None,
|
||||||
|
batch_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""写入数据血缘记录(DataLineage 表)。"""
|
||||||
|
try:
|
||||||
|
lineage = DataLineage(
|
||||||
|
source_system=source_system,
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
target_table=target_table,
|
||||||
|
target_id=target_id,
|
||||||
|
transform_name=transform_name,
|
||||||
|
transform_detail=transform_detail,
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
|
db.add(lineage)
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("bzzoiro data_lineage write failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_data_quality_check(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
check_name: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
expected_value: str | None = None,
|
||||||
|
actual_value: str | None = None,
|
||||||
|
passed: bool,
|
||||||
|
severity: str = "warning",
|
||||||
|
detail: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""写入数据质量检查结果(DataQualityCheck 表)。"""
|
||||||
|
try:
|
||||||
|
check = DataQualityCheck(
|
||||||
|
check_name=check_name,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
expected_value=expected_value,
|
||||||
|
actual_value=actual_value,
|
||||||
|
passed=passed,
|
||||||
|
severity=severity,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
db.add(check)
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("bzzoiro data_quality_check write failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class BzzoiroSource:
|
class BzzoiroSource:
|
||||||
"""bzzoiro 数据源(实现 DataSource 协议)。"""
|
"""bzzoiro 数据源(实现 DataSource 协议)。"""
|
||||||
@@ -151,6 +302,7 @@ class BzzoiroSource:
|
|||||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||||
"""
|
"""
|
||||||
result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||||
|
batch_id = uuid.uuid4().hex[:16]
|
||||||
|
|
||||||
for code in leagues:
|
for code in leagues:
|
||||||
league_r: dict = {"inserted": 0, "updated": 0, "errors": []}
|
league_r: dict = {"inserted": 0, "updated": 0, "errors": []}
|
||||||
@@ -159,9 +311,17 @@ class BzzoiroSource:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("bzzoiro fetch failed for %s", code)
|
logger.exception("bzzoiro fetch failed for %s", code)
|
||||||
league_r["errors"].append(f"fetch failed: {e}")
|
league_r["errors"].append(f"fetch failed: {e}")
|
||||||
|
# 写死信表
|
||||||
|
await _write_ingest_failure(
|
||||||
|
db, "bzzoiro", "match", f"fetch_{code}_{batch_id}",
|
||||||
|
"fetch_error", str(e), {"league": code, "date_from": date_from, "date_to": date_to},
|
||||||
|
)
|
||||||
result["leagues"][code] = league_r
|
result["leagues"][code] = league_r
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# === Bronze 层:先存 RawEvent ===
|
||||||
|
await _write_bronze_events(db, raw_events, batch_id)
|
||||||
|
|
||||||
# 获取或创建联赛
|
# 获取或创建联赛
|
||||||
stmt = select(League).where(League.code == code)
|
stmt = select(League).where(League.code == code)
|
||||||
league = (await db.execute(stmt)).scalar_one_or_none()
|
league = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
@@ -186,8 +346,13 @@ class BzzoiroSource:
|
|||||||
try:
|
try:
|
||||||
nm.validate()
|
nm.validate()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("normalize skip: %s", e)
|
# P1-3: 统一使用 warning,不追加到 errors(仅运行时错误入 errors)
|
||||||
league_r["errors"].append(f"normalize: {e}")
|
logger.warning("normalize skip: %s", e)
|
||||||
|
# 写死信表:规范化失败
|
||||||
|
await _write_ingest_failure(
|
||||||
|
db, "bzzoiro", "match", str(raw.get("id", "")),
|
||||||
|
"normalize_error", str(e), raw,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
normalized_matches.append((nm, raw))
|
normalized_matches.append((nm, raw))
|
||||||
all_team_names.add(nm.home_team)
|
all_team_names.add(nm.home_team)
|
||||||
@@ -198,11 +363,25 @@ class BzzoiroSource:
|
|||||||
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}
|
||||||
|
|
||||||
# 预加载已有比赛(完整对象)
|
# P1-2: 按需加载,只加载 raw_events 涉及日期范围的比赛(加 30 天缓冲)
|
||||||
stmt = select(Match).where(Match.league_id == league.id)
|
# 避免加载联赛全部历史比赛到内存(多赛季采集时内存溢出)
|
||||||
for m in (await db.execute(stmt)).scalars():
|
if normalized_matches:
|
||||||
key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date)
|
from datetime import timedelta
|
||||||
existing_matches[key] = m
|
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)
|
||||||
|
.where(Match.league_id == league.id)
|
||||||
|
.where(Match.match_date >= min_dt)
|
||||||
|
.where(Match.match_date <= max_dt)
|
||||||
|
)
|
||||||
|
existing_matches = {
|
||||||
|
_match_key(m.home_team_id, m.away_team_id, m.match_date_date): m
|
||||||
|
for m in (await db.execute(stmt)).scalars()
|
||||||
|
}
|
||||||
|
# else: existing_matches 保持空 dict(全量新比赛)
|
||||||
|
|
||||||
for nm, raw in normalized_matches:
|
for nm, raw in normalized_matches:
|
||||||
# 球队: 内存查找 + 按需创建
|
# 球队: 内存查找 + 按需创建
|
||||||
@@ -244,6 +423,16 @@ class BzzoiroSource:
|
|||||||
db.add(m)
|
db.add(m)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
existing_matches[match_key] = m # 防止同批重复
|
existing_matches[match_key] = m # 防止同批重复
|
||||||
|
# P2-1: Silver 层血缘 — Match 创建
|
||||||
|
await _write_data_lineage(
|
||||||
|
db,
|
||||||
|
source_system="bzzoiro",
|
||||||
|
source_record_id=str(raw.get("id", "")),
|
||||||
|
target_table="matches",
|
||||||
|
target_id=m.id,
|
||||||
|
transform_name="silver_upsert",
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
if nm.home_xg is not None or nm.away_xg is not None:
|
if nm.home_xg is not None or nm.away_xg is not None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
stats = MatchStats(
|
stats = MatchStats(
|
||||||
@@ -262,11 +451,22 @@ class BzzoiroSource:
|
|||||||
home_red_cards=nm.home_red_cards,
|
home_red_cards=nm.home_red_cards,
|
||||||
away_red_cards=nm.away_red_cards,
|
away_red_cards=nm.away_red_cards,
|
||||||
source="bzzoiro",
|
source="bzzoiro",
|
||||||
source_event_id=str(raw.get("id", "")),
|
source_record_id=str(raw.get("id", "")),
|
||||||
retrieved_at=now,
|
retrieved_at=now,
|
||||||
available_at=now,
|
available_at=now,
|
||||||
)
|
)
|
||||||
db.add(stats)
|
db.add(stats)
|
||||||
|
await db.flush()
|
||||||
|
# P2-1: Silver 层血缘 — MatchStats 创建
|
||||||
|
await _write_data_lineage(
|
||||||
|
db,
|
||||||
|
source_system="bzzoiro",
|
||||||
|
source_record_id=str(raw.get("id", "")),
|
||||||
|
target_table="match_stats",
|
||||||
|
target_id=m.id,
|
||||||
|
transform_name="silver_upsert",
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
league_r["inserted"] += 1
|
league_r["inserted"] += 1
|
||||||
else:
|
else:
|
||||||
# 已有比赛: 直接从内存获取对象更新(无需再查询)
|
# 已有比赛: 直接从内存获取对象更新(无需再查询)
|
||||||
@@ -287,13 +487,36 @@ class BzzoiroSource:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
existing_match.stats = MatchStats(
|
existing_match.stats = MatchStats(
|
||||||
match_id=existing_match.id,
|
match_id=existing_match.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="bzzoiro",
|
||||||
source_event_id=str(raw.get("id", "")),
|
source_record_id=str(raw.get("id", "")),
|
||||||
retrieved_at=now,
|
retrieved_at=now,
|
||||||
available_at=now,
|
available_at=now,
|
||||||
)
|
)
|
||||||
db.add(existing_match.stats)
|
db.add(existing_match.stats)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
# P2-1: Silver 层血缘 — MatchStats 创建(更新路径)
|
||||||
|
await _write_data_lineage(
|
||||||
|
db,
|
||||||
|
source_system="bzzoiro",
|
||||||
|
source_record_id=str(raw.get("id", "")),
|
||||||
|
target_table="match_stats",
|
||||||
|
target_id=existing_match.id,
|
||||||
|
transform_name="silver_upsert",
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
if existing_match.stats is not None:
|
if existing_match.stats is not None:
|
||||||
for fld in ("home_xg", "away_xg", "home_shots", "away_shots",
|
for fld in ("home_xg", "away_xg", "home_shots", "away_shots",
|
||||||
"home_shots_on_target", "away_shots_on_target",
|
"home_shots_on_target", "away_shots_on_target",
|
||||||
@@ -312,4 +535,19 @@ class BzzoiroSource:
|
|||||||
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"]
|
||||||
|
|
||||||
|
# P2-2: 数据质量检查 — 行计数合理性(某联赛比赛数不应为 0)
|
||||||
|
total_records = league_r["inserted"] + league_r["updated"]
|
||||||
|
await _write_data_quality_check(
|
||||||
|
db,
|
||||||
|
check_name="bzzoiro_row_count",
|
||||||
|
entity_type="match",
|
||||||
|
entity_id=code,
|
||||||
|
expected_value=">=1",
|
||||||
|
actual_value=str(total_records),
|
||||||
|
passed=total_records > 0,
|
||||||
|
severity="critical" if total_records == 0 else "info",
|
||||||
|
detail=f"league={code} inserted={league_r['inserted']} updated={league_r['updated']}",
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
+346
-51
@@ -1,15 +1,25 @@
|
|||||||
"""伤停数据采集器(api-football / api-sports.io)。
|
"""伤停数据采集器(api-football / api-sports.io)。
|
||||||
|
|
||||||
采集伤停数据并入库(injuries 表),供 injuries agent 使用。
|
采集伤停数据并入库(injuries 表),供 injuries agent 使用。
|
||||||
|
|
||||||
|
改进(P0):
|
||||||
|
- 原子写缓存(tempfile + os.replace)
|
||||||
|
- TTL 分级:未来比赛 1h,当天 5min,历史 7day
|
||||||
|
- 文件锁防止并发写缓存冲突
|
||||||
|
- Bronze 层集成(RawEvent)
|
||||||
|
- 死信表集成(IngestFailure)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,14 +27,147 @@ import httpx
|
|||||||
|
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.core.http_client import get_client
|
from src.core.http_client import get_client
|
||||||
|
from src.db.models import DataQualityCheck
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
API_BASE = "https://v3.football.api-sports.io"
|
API_BASE = "https://v3.football.api-sports.io"
|
||||||
DEFAULT_HOST = "v3.football.api-sports.io"
|
DEFAULT_HOST = "v3.football.api-sports.io"
|
||||||
|
|
||||||
# 缓存目录
|
# P2-3: 缓存目录改用系统临时目录,避免源码树内写入
|
||||||
_CACHE_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "injuries_cache"
|
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
|
||||||
|
|
||||||
|
# 模块级锁:防止并发写同一缓存文件
|
||||||
|
_cache_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_cache_component(value: str | int | None) -> str:
|
||||||
|
"""消毒缓存键组件,防止路径遍历。
|
||||||
|
|
||||||
|
只允许 [A-Za-z0-9_.-],其余字符替换为 '_'。
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
s = str(value) if value is not None else "none"
|
||||||
|
return re.sub(r'[^A-Za-z0-9_.]', '_', s)[:60]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cache_key(prefix: str, *components: str | int | None) -> str:
|
||||||
|
"""构造安全的缓存文件名。"""
|
||||||
|
parts = [_sanitize_cache_component(c) for c in components]
|
||||||
|
return f"{prefix}_{'_'.join(parts)}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_ttl_hours(date_str: str | None, fixture_id: int | None, league_id: int | None) -> float:
|
||||||
|
"""根据查询参数计算缓存 TTL(小时)。
|
||||||
|
|
||||||
|
TTL 分级策略:
|
||||||
|
- 未来比赛(date > now): 1 小时(赛前伤停变化频繁)
|
||||||
|
- 当天比赛(date == today): 5 分钟(赛中实时更新)
|
||||||
|
- 历史比赛(date < now): 7 天(历史数据不变)
|
||||||
|
- 无日期参数: 1 小时(保守策略)
|
||||||
|
"""
|
||||||
|
if date_str is None:
|
||||||
|
# 无日期参数(按 fixture_id 或 league_id 查询),保守 TTL
|
||||||
|
return 1.0
|
||||||
|
try:
|
||||||
|
query_date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
today = now.date()
|
||||||
|
if query_date.date() > today:
|
||||||
|
return 1.0 # 未来
|
||||||
|
elif query_date.date() == today:
|
||||||
|
return 5.0 / 60 # 当天:5 分钟
|
||||||
|
else:
|
||||||
|
return 168.0 # 历史:7 天
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 1.0 # 解析失败,保守 TTL
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cache_atomic(cache_file: Path, data: Any) -> None:
|
||||||
|
"""原子写缓存文件。
|
||||||
|
|
||||||
|
使用 tempfile + os.replace 实现原子写,防止读到写了一半的文件。
|
||||||
|
配合模块级 asyncio.Lock,杜绝并发写冲突。
|
||||||
|
"""
|
||||||
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# 在同一目录创建临时文件(保证 os.replace 是原子操作)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
|
dir=str(cache_file.parent),
|
||||||
|
prefix=f".{cache_file.name}.tmp_",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, default=str)
|
||||||
|
os.replace(tmp_path, cache_file)
|
||||||
|
except BaseException:
|
||||||
|
# 失败时清理临时文件
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_expired_cache() -> int:
|
||||||
|
"""P2-4: 扫描缓存目录,删除过期的 .json 文件。
|
||||||
|
|
||||||
|
TTL 策略(保守取最大值,避免误删有效缓存):
|
||||||
|
- 最短 TTL 为 5 分钟(当天比赛),但清理阈值用 1 小时
|
||||||
|
- 超过 1 小时的缓存文件视为过期
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除的文件数。
|
||||||
|
"""
|
||||||
|
import glob as _glob
|
||||||
|
|
||||||
|
cache_dir = _CACHE_DIR
|
||||||
|
if not cache_dir.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
max_ttl_seconds = 3600 # 1 小时(保守阈值,最短实际 TTL 5 分钟)
|
||||||
|
now_ts = time.time()
|
||||||
|
for cache_file in _glob.glob(str(cache_dir / "*.json")):
|
||||||
|
try:
|
||||||
|
file_age = now_ts - os.path.getmtime(cache_file)
|
||||||
|
if file_age > max_ttl_seconds:
|
||||||
|
os.unlink(cache_file)
|
||||||
|
removed += 1
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if removed > 0:
|
||||||
|
logger.info("injuries cache cleanup: removed %d expired files", removed)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_data_quality_check(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
check_name: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
expected_value: str | None = None,
|
||||||
|
actual_value: str | None = None,
|
||||||
|
passed: bool,
|
||||||
|
severity: str = "warning",
|
||||||
|
detail: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""写入数据质量检查结果(DataQualityCheck 表)。"""
|
||||||
|
try:
|
||||||
|
check = DataQualityCheck(
|
||||||
|
check_name=check_name,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
expected_value=expected_value,
|
||||||
|
actual_value=actual_value,
|
||||||
|
passed=passed,
|
||||||
|
severity=severity,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
db.add(check)
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("injuries data_quality_check write failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
||||||
@@ -42,20 +185,25 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
raise RuntimeError("API_FOOTBALL_KEY 未设置")
|
raise RuntimeError("API_FOOTBALL_KEY 未设置")
|
||||||
|
|
||||||
|
# P2-4: 清理过期缓存文件
|
||||||
|
_cleanup_expired_cache()
|
||||||
|
|
||||||
cache_dir = _CACHE_DIR
|
cache_dir = _CACHE_DIR
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 缓存命中 (7 天内有效)
|
# TTL 分级缓存
|
||||||
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
|
ttl_hours = _compute_ttl_hours(date, fixture_id, league_id)
|
||||||
|
# P0-fix: 消毒缓存键,防止路径遍历(date 参数可能含 '../' 等)
|
||||||
|
cache_key = _build_cache_key("injuries", date, fixture_id, league_id)
|
||||||
cache_file = cache_dir / cache_key
|
cache_file = cache_dir / cache_key
|
||||||
if cache_file.exists():
|
if cache_file.exists():
|
||||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
||||||
if age_hours < 168: # 7 天
|
if age_hours < ttl_hours:
|
||||||
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours)
|
logger.debug("injuries cache hit: %s (%.1fh old, ttl=%.2fh)", cache_key, age_hours, ttl_hours)
|
||||||
with open(cache_file, encoding="utf-8") as f:
|
with open(cache_file, encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
else:
|
else:
|
||||||
logger.debug("injuries cache expired: %s (%.1fh old)", cache_key, age_hours)
|
logger.debug("injuries cache expired: %s (%.1fh old, ttl=%.2fh)", cache_key, age_hours, ttl_hours)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"x-apisports-key": api_key,
|
"x-apisports-key": api_key,
|
||||||
@@ -92,9 +240,9 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
injuries = data.get("response", [])
|
injuries = data.get("response", [])
|
||||||
|
|
||||||
# 写缓存
|
# 原子写缓存 + 文件锁(防止并发写冲突)
|
||||||
with open(cache_file, "w", encoding="utf-8") as f:
|
async with _cache_lock:
|
||||||
json.dump(injuries, ensure_ascii=False, default=str, fp=f)
|
_write_cache_atomic(cache_file, injuries)
|
||||||
|
|
||||||
return injuries
|
return injuries
|
||||||
|
|
||||||
@@ -103,27 +251,74 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
"""采集伤停数据并入库(injuries 表)。
|
"""采集伤停数据并入库(injuries 表)。
|
||||||
|
|
||||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||||
|
|
||||||
|
P1-4: 批量幂等检查,避免逐条查询的竞态条件(并发采集时 IntegrityError)。
|
||||||
|
P1 Bronze: 采集成功后先存 RawEvent,再规范化。
|
||||||
|
P1 死信: 采集/规范化失败时写 IngestFailure。
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.data.team_names import normalize as normalize_name
|
from src.data.team_names import normalize as normalize_name
|
||||||
from src.db.models import Injury, Team
|
from src.db.models import DataQualityCheck, Injury, IngestFailure, RawEvent, Team
|
||||||
|
|
||||||
result = {"count": 0, "inserted": 0, "errors": []}
|
result = {"count": 0, "inserted": 0, "errors": []}
|
||||||
|
batch_id = uuid.uuid4().hex[:16]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_injuries = await fetch_injuries(date=date)
|
raw_injuries = await fetch_injuries(date=date)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("injuries fetch failed")
|
logger.exception("injuries fetch failed")
|
||||||
result["errors"].append(f"fetch failed: {e}")
|
result["errors"].append(f"fetch failed: {e}")
|
||||||
|
# 写死信表(IngestFailure)
|
||||||
|
try:
|
||||||
|
failure = IngestFailure(
|
||||||
|
source_system="api-football",
|
||||||
|
entity_type="injury",
|
||||||
|
source_record_id=f"fetch_{batch_id}",
|
||||||
|
error_type="fetch_error",
|
||||||
|
error_detail=str(e)[:2000],
|
||||||
|
raw_payload={"date": date},
|
||||||
|
status="pending",
|
||||||
|
next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
db.add(failure)
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("failed to write fetch error to ingest_failures", exc_info=True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result["count"] = len(raw_injuries)
|
result["count"] = len(raw_injuries)
|
||||||
|
|
||||||
|
# === Bronze 层:写 RawEvent ===
|
||||||
|
for raw in raw_injuries:
|
||||||
|
try:
|
||||||
|
player = raw.get("player", {}) or {}
|
||||||
|
fixture = raw.get("fixture", {}) or {}
|
||||||
|
source_record_id = f"inj_{player.get('id')}_{fixture.get('id')}"
|
||||||
|
bronze = RawEvent(
|
||||||
|
source_system="api-football",
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
raw_payload=raw,
|
||||||
|
ingest_batch_id=batch_id,
|
||||||
|
)
|
||||||
|
db.add(bronze)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("raw_event write failed for injury: %s", e)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("raw_events flush failed: %s", e)
|
||||||
|
|
||||||
# 预加载所有球队(用于按名匹配)
|
# 预加载所有球队(用于按名匹配)
|
||||||
teams = (await db.execute(select(Team))).scalars().all()
|
teams = (await db.execute(select(Team))).scalars().all()
|
||||||
team_by_name = {t.name: t.id for t in teams}
|
team_by_name = {t.name: t.id for t in teams}
|
||||||
|
|
||||||
|
# P1-4: 收集所有待插入记录的键,批量查询已存在的记录
|
||||||
|
# 避免逐条查询 + 插入的竞态条件(两个并发请求同时通过检查 → IntegrityError)
|
||||||
|
pending_records: list[dict] = []
|
||||||
|
parse_failures: list[dict] = [] # 规范化失败的原始数据,用于写死信表
|
||||||
for raw in raw_injuries:
|
for raw in raw_injuries:
|
||||||
try:
|
try:
|
||||||
player = raw.get("player", {}) or {}
|
player = raw.get("player", {}) or {}
|
||||||
@@ -144,43 +339,145 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 强制 int 转换,API 可能返回字符串
|
||||||
player_id = player.get("id")
|
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")
|
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({
|
||||||
existing = (
|
"player_id": player_id,
|
||||||
await db.execute(
|
"player_name": player_name,
|
||||||
select(Injury).where(
|
"team_id": team_id,
|
||||||
Injury.player_id == player_id,
|
"fixture_id": fixture_id,
|
||||||
Injury.fixture_id == fixture_id,
|
"league_id": (raw.get("league") or {}).get("id"),
|
||||||
Injury.injury_type == player.get("type"),
|
"injury_type": player.get("type"),
|
||||||
)
|
"reason": player.get("reason"),
|
||||||
)
|
"injury_date": injury_date,
|
||||||
).scalar_one_or_none()
|
})
|
||||||
|
|
||||||
if existing is not None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
injury = Injury(
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
db.add(injury)
|
|
||||||
result["inserted"] += 1
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["errors"].append(f"parse error: {e}")
|
result["errors"].append(f"parse error: {e}")
|
||||||
|
parse_failures.append({"raw": raw, "error": str(e)})
|
||||||
|
|
||||||
|
# 写规范化失败到死信表
|
||||||
|
for fail in parse_failures:
|
||||||
|
try:
|
||||||
|
raw = fail["raw"]
|
||||||
|
player = raw.get("player", {}) or {}
|
||||||
|
failure = IngestFailure(
|
||||||
|
source_system="api-football",
|
||||||
|
entity_type="injury",
|
||||||
|
source_record_id=f"inj_{player.get('id', 'unknown')}",
|
||||||
|
error_type="normalize_error",
|
||||||
|
error_detail=fail["error"][:2000],
|
||||||
|
raw_payload=raw,
|
||||||
|
status="pending",
|
||||||
|
next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
db.add(failure)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("failed to write parse error to ingest_failures", exc_info=True)
|
||||||
|
if parse_failures:
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("ingest_failures flush failed", exc_info=True)
|
||||||
|
|
||||||
|
# P1-4: 批量查询已存在的记录(1 次 DB 往返)
|
||||||
|
existing_keys: set[tuple] = set()
|
||||||
|
if pending_records:
|
||||||
|
# 构造查询条件:所有 (player_id, fixture_id, injury_type) 组合
|
||||||
|
# 使用 OR 条件批量查询
|
||||||
|
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}
|
||||||
|
|
||||||
|
# P1-4: 批量插入(跳过已存在的)
|
||||||
|
for rec in pending_records:
|
||||||
|
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
|
||||||
|
if key in existing_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
injury = Injury(**rec)
|
||||||
|
db.add(injury)
|
||||||
|
result["inserted"] += 1
|
||||||
|
|
||||||
|
# 每 50 条 flush 一次,减少内存压力,同时捕获 IntegrityError
|
||||||
|
if result["inserted"] % 50 == 0:
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
# P1-4: 并发采集时可能仍有竞态,回退到逐条插入
|
||||||
|
await db.rollback()
|
||||||
|
logger.warning("injuries batch IntegrityError, falling back to per-record insert")
|
||||||
|
return await _ingest_injuries_fallback(db, pending_records, result)
|
||||||
|
|
||||||
|
# 最终 flush
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
logger.warning("injuries final flush IntegrityError, falling back to per-record insert")
|
||||||
|
return await _ingest_injuries_fallback(db, pending_records, result)
|
||||||
|
|
||||||
|
# P2-2: 数据质量检查 — 行计数 / 新增比例
|
||||||
|
total = result["count"]
|
||||||
|
inserted = result["inserted"]
|
||||||
|
# 某日期伤停数不应为 0(除非历史日期)
|
||||||
|
await _write_data_quality_check(
|
||||||
|
db,
|
||||||
|
check_name="injuries_row_count",
|
||||||
|
entity_type="injury",
|
||||||
|
entity_id=date or "unknown",
|
||||||
|
expected_value=">=0",
|
||||||
|
actual_value=str(total),
|
||||||
|
passed=True,
|
||||||
|
severity="info",
|
||||||
|
detail=f"date={date} fetched={total} inserted={inserted}",
|
||||||
|
)
|
||||||
|
|
||||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
# 注意: 不在此处 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
|
||||||
|
|
||||||
|
|
||||||
|
async def _ingest_injuries_fallback(db, pending_records: list[dict], result: dict) -> dict:
|
||||||
|
"""P1-4: 逐条插入回退,捕获每条 IntegrityError 避免整批回滚。"""
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from src.db.models import Injury
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
for rec in pending_records:
|
||||||
|
injury = Injury(**rec)
|
||||||
|
db.add(injury)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
inserted += 1
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
# 已存在或其他冲突,跳过
|
||||||
|
continue
|
||||||
|
|
||||||
|
result["inserted"] = inserted
|
||||||
|
logger.info("injuries fallback: inserted %d records", inserted)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> list[Injury]:
|
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> list[Injury]:
|
||||||
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
|
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
|
||||||
|
|
||||||
@@ -188,31 +485,29 @@ async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> li
|
|||||||
db: 数据库 session
|
db: 数据库 session
|
||||||
team_id: 球队 ID
|
team_id: 球队 ID
|
||||||
match_date: 比赛日期
|
match_date: 比赛日期
|
||||||
as_of: 截止时间(cutoff)。只返回 retrieved_at <= as_of 的记录。
|
as_of: 数据截止时间(用于回测防泄漏)
|
||||||
用于回测时防止"未来采集的数据"泄漏到历史预测。
|
|
||||||
必须保持 timezone-aware datetime,不会截断为 date。
|
|
||||||
"""
|
|
||||||
from sqlalchemy import or_, select
|
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
伤停记录列表
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
from src.db.models import Injury
|
from src.db.models import Injury
|
||||||
|
|
||||||
# 只处理 match_date:去掉时间部分,仅比较日期
|
if hasattr(match_date, "date") and callable(match_date.date):
|
||||||
if hasattr(match_date, "date"):
|
|
||||||
match_date = match_date.date()
|
match_date = match_date.date()
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Injury)
|
select(Injury)
|
||||||
.where(Injury.team_id == team_id)
|
.where(Injury.team_id == team_id)
|
||||||
.where(Injury.injury_date <= match_date)
|
.where(Injury.injury_date <= match_date)
|
||||||
.where(or_(Injury.return_date.is_(None), Injury.return_date >= match_date))
|
.where(
|
||||||
|
(Injury.return_date.is_(None)) | (Injury.return_date >= match_date)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 回测防泄漏: 只使用 as_of 时间点之前已采集的数据
|
|
||||||
# 注意: as_of 保持 datetime,不截断为 date,避免错误排除同日合法数据
|
|
||||||
if as_of is not None:
|
if as_of is not None:
|
||||||
stmt = stmt.where(Injury.retrieved_at.is_not(None))
|
if hasattr(as_of, "date") and callable(as_of.date):
|
||||||
|
as_of = as_of.date()
|
||||||
stmt = stmt.where(Injury.retrieved_at <= as_of)
|
stmt = stmt.where(Injury.retrieved_at <= as_of)
|
||||||
|
|
||||||
stmt = stmt.order_by(Injury.injury_date.desc())
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|||||||
+11
-6
@@ -1,9 +1,9 @@
|
|||||||
"""数据规范化:任意数据源原始记录 → NormalizedMatch。
|
"""数据规范化:任意数据源原始记录 → NormalizedMatch。
|
||||||
|
|
||||||
迁移自旧项目 app/data/normalize.py,简化:
|
迁移自旧项目 app/data/normalize.py,简化:
|
||||||
- 去掉 XGBackfill 双轨(不再需要独立回填)
|
- 去掉 XGBackoff 双轨(不再需要独立回填)
|
||||||
- 去掉 PIT 时间契约(无训练集要防泄漏)
|
- 去掉 PIT 时间契约(无训练集要防泄漏)
|
||||||
- 保留核心清洗契约(队名归一、日期解析、数值范围)
|
- 保留核心清洗契约(队名归一、日期解析、数值范围)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -90,7 +90,10 @@ def derive_season_label(date: datetime) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_date(value) -> datetime | None:
|
def _parse_date(value) -> datetime | None:
|
||||||
"""日期解析 → UTC datetime(带 tzinfo)。"""
|
"""日期解析 → UTC datetime(带 tzinfo)。
|
||||||
|
|
||||||
|
P2-2: 解析失败时记录 warning,避免静默丢数据而无感知。
|
||||||
|
"""
|
||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
return None
|
return None
|
||||||
if isinstance(value, (int, float)):
|
if isinstance(value, (int, float)):
|
||||||
@@ -111,6 +114,8 @@ def _parse_date(value) -> datetime | None:
|
|||||||
return datetime.strptime(s[:19], fmt).replace(tzinfo=timezone.utc)
|
return datetime.strptime(s[:19], fmt).replace(tzinfo=timezone.utc)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
# P2-2 修复: 记录被丢弃的原始值,便于排查数据源格式变更
|
||||||
|
logger.warning("_parse_date failed, dropping record: %r", value)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -204,6 +209,6 @@ def normalize_understat(raw: dict, league_type: str) -> NormalizedMatch | None:
|
|||||||
away_team=away,
|
away_team=away,
|
||||||
match_status="finished",
|
match_status="finished",
|
||||||
season_label=derive_season_label(dt),
|
season_label=derive_season_label(dt),
|
||||||
home_xg=_to_float(home_xg),
|
home_xg=home_xg,
|
||||||
away_xg=_to_float(away_xg),
|
away_xg=away_xg,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""令牌桶限流器。
|
||||||
|
|
||||||
|
替代 bzzoiro 中固定的 REQUEST_INTERVAL sleep,提供更精细的速率控制。
|
||||||
|
支持突发流量和平滑限流。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenBucket:
|
||||||
|
"""异步令牌桶限流器。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
limiter = TokenBucket(rate=5.0, capacity=10)
|
||||||
|
await limiter.acquire() # 等待直到有可用令牌
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rate: 每秒补充的令牌数
|
||||||
|
capacity: 桶容量(允许的最大突发量)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, rate: float, capacity: int | None = None) -> None:
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError(f"rate must be positive, got {rate}")
|
||||||
|
self._rate = rate
|
||||||
|
self._capacity = capacity or max(1, int(rate * 2))
|
||||||
|
self._tokens: float = self._capacity
|
||||||
|
self._last_refill = time.monotonic()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def acquire(self, tokens: int = 1) -> None:
|
||||||
|
"""获取指定数量的令牌,不足时等待。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tokens: 需要获取的令牌数
|
||||||
|
"""
|
||||||
|
if tokens <= 0:
|
||||||
|
return
|
||||||
|
if tokens > self._capacity:
|
||||||
|
raise ValueError(f"requested {tokens} exceeds capacity {self._capacity}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
async with self._lock:
|
||||||
|
self._refill()
|
||||||
|
if self._tokens >= tokens:
|
||||||
|
self._tokens -= tokens
|
||||||
|
return
|
||||||
|
|
||||||
|
# 计算需要等待的时间
|
||||||
|
wait_time = (tokens - self._tokens) / self._rate
|
||||||
|
logger.debug("token bucket: waiting %.2fs for %d tokens", wait_time, tokens)
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
|
||||||
|
def _refill(self) -> None:
|
||||||
|
"""补充令牌(基于经过的时间)。"""
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self._last_refill
|
||||||
|
if elapsed > 0:
|
||||||
|
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
|
||||||
|
self._last_refill = now
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tokens(self) -> float:
|
||||||
|
"""当前可用令牌数(近似)。"""
|
||||||
|
self._refill()
|
||||||
|
return self._tokens
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitedClient:
|
||||||
|
"""HTTP 客户端限流包装器。
|
||||||
|
|
||||||
|
在 httpx 客户端之上添加令牌桶限流,透明地控制请求速率。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
client = get_client()
|
||||||
|
limiter = TokenBucket(rate=5.0)
|
||||||
|
wrapper = RateLimitedClient(client, limiter)
|
||||||
|
resp = await wrapper.get(url)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, limiter: TokenBucket) -> None:
|
||||||
|
self._client = client
|
||||||
|
self._limiter = limiter
|
||||||
|
|
||||||
|
async def get(self, url: str, **kwargs):
|
||||||
|
"""限流的 GET 请求。"""
|
||||||
|
await self._limiter.acquire()
|
||||||
|
return await self._client.get(url, **kwargs)
|
||||||
|
|
||||||
|
async def post(self, url: str, **kwargs):
|
||||||
|
"""限流的 POST 请求。"""
|
||||||
|
await self._limiter.acquire()
|
||||||
|
return await self._client.post(url, **kwargs)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""P2-5: IngestFailure 重试 Worker。
|
||||||
|
|
||||||
|
查询死信表中 status='pending' AND next_retry_at <= now() 的记录,
|
||||||
|
根据 source_system 和 error_type 决定重试策略。
|
||||||
|
作为 FastAPI startup 事件注册,在应用启动时执行一次清理。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.db.base import AsyncSessionLocal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 最大重试次数上限
|
||||||
|
_MAX_RETRIES = 5
|
||||||
|
|
||||||
|
|
||||||
|
async def _retry_ingest_failures(db) -> dict:
|
||||||
|
"""重试待处理的采集失败记录。
|
||||||
|
|
||||||
|
查询条件: status='pending' AND next_retry_at <= now()
|
||||||
|
重试策略:
|
||||||
|
- retry_count >= MAX_RETRIES → 标记 abandoned
|
||||||
|
- error_type=fetch_error → 退避重试(更新 next_retry_at)
|
||||||
|
- error_type=normalize_error → 规范化失败通常是数据问题,退避重试
|
||||||
|
- error_type=db_error → 数据库问题,退避重试
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy async session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统计 dict: {"retried": int, "abandoned": int, "errors": list[str]}
|
||||||
|
"""
|
||||||
|
result = {"retried": 0, "abandoned": 0, "errors": []}
|
||||||
|
|
||||||
|
from src.db.models import IngestFailure
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# 查询待重试记录
|
||||||
|
stmt = (
|
||||||
|
select(IngestFailure)
|
||||||
|
.where(IngestFailure.status == "pending")
|
||||||
|
.where(IngestFailure.next_retry_at <= now)
|
||||||
|
.order_by(IngestFailure.next_retry_at)
|
||||||
|
.limit(50) # 每批最多处理 50 条,避免长时间持有事务
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = (await db.execute(stmt)).scalars().all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("retry_worker: query failed: %s", e)
|
||||||
|
result["errors"].append(f"query failed: {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return result
|
||||||
|
|
||||||
|
logger.info("retry_worker: found %d pending failures to retry", len(rows))
|
||||||
|
|
||||||
|
for failure in rows:
|
||||||
|
try:
|
||||||
|
# 超过最大重试次数 → 放弃
|
||||||
|
if failure.retry_count >= _MAX_RETRIES:
|
||||||
|
failure.status = "abandoned"
|
||||||
|
failure.resolved_at = now
|
||||||
|
await db.flush()
|
||||||
|
result["abandoned"] += 1
|
||||||
|
logger.info(
|
||||||
|
"retry_worker: abandoned %s/%s after %d retries",
|
||||||
|
failure.source_system, failure.source_record_id, failure.retry_count,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 退避计算: 5min * 2^retry_count,最大 24 小时
|
||||||
|
backoff_minutes = min(5 * (2 ** failure.retry_count), 1440)
|
||||||
|
next_retry = now + timedelta(minutes=backoff_minutes)
|
||||||
|
|
||||||
|
# 更新重试状态
|
||||||
|
failure.retry_count += 1
|
||||||
|
failure.next_retry_at = next_retry
|
||||||
|
failure.status = "retrying"
|
||||||
|
await db.flush()
|
||||||
|
result["retried"] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"retry_worker: scheduled retry %d/%d for %s/%s (next: %s)",
|
||||||
|
failure.retry_count, _MAX_RETRIES,
|
||||||
|
failure.source_system, failure.source_record_id,
|
||||||
|
next_retry.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
error_msg = f"retry {failure.source_system}/{failure.source_record_id}: {e}"
|
||||||
|
result["errors"].append(error_msg)
|
||||||
|
logger.warning("retry_worker: %s", error_msg)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def run_retry_worker() -> None:
|
||||||
|
"""启动入口: 获取 DB session 并执行重试逻辑。
|
||||||
|
|
||||||
|
设计为幂等操作 — 多次运行不会产生副作用(受 next_retry_at 约束)。
|
||||||
|
"""
|
||||||
|
logger.info("retry_worker: starting ingest failure retry sweep")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
stats = await _retry_ingest_failures(db)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
"retry_worker: sweep complete — retried=%d abandoned=%d errors=%d",
|
||||||
|
stats["retried"], stats["abandoned"], len(stats["errors"]),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.warning("retry_worker: session failed: %s", e)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("retry_worker: failed to get DB session: %s", e)
|
||||||
+270
-21
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
||||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
使用 Repository 模式进行数据访问,不直接控制事务。
|
||||||
|
|
||||||
|
改进(P1):
|
||||||
|
- Bronze 层集成:采集后先存 RawEvent,再规范化
|
||||||
|
- 死信表集成:采集/规范化失败写 IngestFailure
|
||||||
|
- xG 覆盖更新:当 understat 数据更新时覆盖旧值(全量覆盖模式)
|
||||||
|
- xG 追踪字段:xg_source / xg_updated_at / xg_source_record_id
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,15 +16,17 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
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.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, Team
|
from src.db.models import DataLineage, DataQualityCheck, IngestFailure, League, Match, MatchStats, RawEvent, Team
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,6 +54,7 @@ async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
|||||||
"Referer": f"https://understat.com/league/{understat_league}/{season}",
|
"Referer": f"https://understat.com/league/{understat_league}/{season}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# 重试:网络错误 / 5xx / 429
|
# 重试:网络错误 / 5xx / 429
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
@@ -57,12 +66,10 @@ async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_exc = e
|
last_exc = e
|
||||||
if attempt == 2:
|
if attempt == 2:
|
||||||
raise
|
raise RuntimeError(f"understat fetch failed: {e}") from e
|
||||||
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
|
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
|
||||||
logger.warning("understat fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
logger.warning("understat fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
else:
|
|
||||||
raise RuntimeError(f"understat fetch failed: {last_exc}")
|
|
||||||
|
|
||||||
# understat 返回 JS 对象,需要提取 JSON
|
# understat 返回 JS 对象,需要提取 JSON
|
||||||
text = resp.text
|
text = resp.text
|
||||||
@@ -76,6 +83,148 @@ async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
|||||||
return data
|
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 "")
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_understat_bronze(db, raw_matches: list[dict], batch_id: str) -> None:
|
||||||
|
"""将 understat 原始数据写入 Bronze 层(RawEvent 表)。
|
||||||
|
|
||||||
|
P2-1: 写入血缘记录,追踪 Bronze 层摄取过程。
|
||||||
|
"""
|
||||||
|
for raw in raw_matches:
|
||||||
|
try:
|
||||||
|
source_record_id = str(raw.get("id", ""))
|
||||||
|
if not source_record_id:
|
||||||
|
# P1-fix: 不再静默跳过,记录到死信表
|
||||||
|
logger.warning("understat raw match missing id: %s", str(raw)[:200])
|
||||||
|
await _write_ingest_failure(
|
||||||
|
db, "understat", "match", "",
|
||||||
|
"normalize_error", "raw match missing id", raw,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
bronze = RawEvent(
|
||||||
|
source_system="understat",
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
raw_payload=raw,
|
||||||
|
ingest_batch_id=batch_id,
|
||||||
|
)
|
||||||
|
db.add(bronze)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("understat raw_event write failed: %s", e)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("understat raw_events flush failed: %s", e)
|
||||||
|
|
||||||
|
# P2-1: 写入 Bronze 层血缘记录
|
||||||
|
for raw in raw_matches:
|
||||||
|
source_record_id = str(raw.get("id", ""))
|
||||||
|
if not source_record_id:
|
||||||
|
continue
|
||||||
|
await _write_data_lineage(
|
||||||
|
db,
|
||||||
|
source_system="understat",
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
target_table="raw_events",
|
||||||
|
transform_name="bronze_ingest",
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_ingest_failure(
|
||||||
|
db,
|
||||||
|
source_system: str,
|
||||||
|
entity_type: str,
|
||||||
|
source_record_id: str,
|
||||||
|
error_type: str,
|
||||||
|
error_detail: str,
|
||||||
|
raw_payload: dict | None,
|
||||||
|
) -> None:
|
||||||
|
"""写入采集失败到死信表(IngestFailure)。"""
|
||||||
|
try:
|
||||||
|
failure = IngestFailure(
|
||||||
|
source_system=source_system,
|
||||||
|
entity_type=entity_type,
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
error_type=error_type,
|
||||||
|
error_detail=error_detail[:2000] if error_detail else None,
|
||||||
|
raw_payload=raw_payload,
|
||||||
|
status="pending",
|
||||||
|
next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
db.add(failure)
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("understat ingest_failure write failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_data_lineage(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
source_system: str,
|
||||||
|
source_record_id: str,
|
||||||
|
target_table: str,
|
||||||
|
target_id: int | None = None,
|
||||||
|
transform_name: str,
|
||||||
|
transform_detail: str | None = None,
|
||||||
|
batch_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""写入数据血缘记录(DataLineage 表)。"""
|
||||||
|
try:
|
||||||
|
lineage = DataLineage(
|
||||||
|
source_system=source_system,
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
target_table=target_table,
|
||||||
|
target_id=target_id,
|
||||||
|
transform_name=transform_name,
|
||||||
|
transform_detail=transform_detail,
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
|
db.add(lineage)
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("understat data_lineage write failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_data_quality_check(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
check_name: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
expected_value: str | None = None,
|
||||||
|
actual_value: str | None = None,
|
||||||
|
passed: bool,
|
||||||
|
severity: str = "warning",
|
||||||
|
detail: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""写入数据质量检查结果(DataQualityCheck 表)。"""
|
||||||
|
try:
|
||||||
|
check = DataQualityCheck(
|
||||||
|
check_name=check_name,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
expected_value=expected_value,
|
||||||
|
actual_value=actual_value,
|
||||||
|
passed=passed,
|
||||||
|
severity=severity,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
db.add(check)
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("understat data_quality_check write failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class UnderstatSource:
|
class UnderstatSource:
|
||||||
"""understat xG 数据源(实现 DataSource 协议)。"""
|
"""understat xG 数据源(实现 DataSource 协议)。"""
|
||||||
@@ -83,25 +232,36 @@ 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。
|
||||||
|
|
||||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||||
|
|
||||||
|
P1-3: 批量查询优化,将单赛季 380 场 × 3 次 DB 往返降为 3 次查询。
|
||||||
|
P1-4: xG 覆盖更新模式,当 understat 数据更新时覆盖旧值。
|
||||||
"""
|
"""
|
||||||
from src.db.repositories import LeagueRepository, MatchRepository, TeamRepository
|
from src.db.repositories import LeagueRepository, TeamRepository
|
||||||
|
|
||||||
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
||||||
|
batch_id = uuid.uuid4().hex[:16]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_matches = await fetch_understat(league, season)
|
raw_matches = await fetch_understat(league, season)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("understat fetch failed for %s %s", league, season)
|
logger.exception("understat fetch failed for %s %s", league, season)
|
||||||
result["errors"].append(f"fetch failed: {e}")
|
result["errors"].append(f"fetch failed: {e}")
|
||||||
|
# 写死信表
|
||||||
|
await _write_ingest_failure(
|
||||||
|
db, "understat", "match", f"fetch_{league}_{season}_{batch_id}",
|
||||||
|
"fetch_error", str(e), {"league": league, "season": season},
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# === Bronze 层:先存 RawEvent ===
|
||||||
|
await _write_understat_bronze(db, raw_matches, batch_id)
|
||||||
|
|
||||||
# 使用 Repository
|
# 使用 Repository
|
||||||
league_repo = LeagueRepository(db)
|
league_repo = LeagueRepository(db)
|
||||||
team_repo = TeamRepository(db)
|
team_repo = TeamRepository(db)
|
||||||
match_repo = MatchRepository(db)
|
|
||||||
|
|
||||||
# 查联赛
|
# 查联赛
|
||||||
league_obj = await league_repo.get_by_code(league)
|
league_obj = await league_repo.get_by_code(league)
|
||||||
@@ -109,6 +269,9 @@ class UnderstatSource:
|
|||||||
result["errors"].append(f"league {league} not found in DB")
|
result["errors"].append(f"league {league} not found in DB")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# === 批量优化: 一次规范化,收集球队名和日期 ===
|
||||||
|
normalized_matches: list = []
|
||||||
|
all_team_names: set[str] = set()
|
||||||
for raw in raw_matches:
|
for raw in raw_matches:
|
||||||
if not raw.get("isResult"):
|
if not raw.get("isResult"):
|
||||||
continue
|
continue
|
||||||
@@ -119,40 +282,126 @@ class UnderstatSource:
|
|||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["errors"].append(f"normalize: {e}")
|
result["errors"].append(f"normalize: {e}")
|
||||||
|
# 写死信表:规范化失败
|
||||||
|
await _write_ingest_failure(
|
||||||
|
db, "understat", "match", str(raw.get("id", "")),
|
||||||
|
"normalize_error", str(e), raw,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
normalized_matches.append((nm, raw))
|
||||||
|
all_team_names.add(nm.home_team)
|
||||||
|
all_team_names.add(nm.away_team)
|
||||||
|
|
||||||
# 匹配已有 Match(天级) - 使用 Repository
|
if not normalized_matches:
|
||||||
home_team = await team_repo.get_by_name(nm.home_team)
|
return result
|
||||||
away_team = await team_repo.get_by_name(nm.away_team)
|
|
||||||
if home_team is None or away_team is None:
|
# === 批量查询球队(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
|
result["unmatched"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing = await match_repo.find_by_teams_and_date(
|
match_key = _match_key(home_team_id, away_team_id, nm.date)
|
||||||
league_obj.id, home_team.id, away_team.id, nm.date
|
existing = match_dict.get(match_key)
|
||||||
)
|
|
||||||
if existing is None:
|
if existing is None:
|
||||||
result["unmatched"] += 1
|
result["unmatched"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 回填 xG
|
now = datetime.now(timezone.utc)
|
||||||
|
source_record_id = str(raw.get("id", ""))
|
||||||
|
|
||||||
|
# 创建 stats 记录(如果不存在)
|
||||||
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
|
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
existing.stats = MatchStats(
|
existing.stats = MatchStats(
|
||||||
match_id=existing.id,
|
match_id=existing.id,
|
||||||
source="understat",
|
source="understat",
|
||||||
source_event_id=str(raw.get("id", "")),
|
source_record_id=source_record_id,
|
||||||
retrieved_at=now,
|
retrieved_at=now,
|
||||||
available_at=now,
|
available_at=now,
|
||||||
|
xg_source="understat",
|
||||||
|
xg_updated_at=now,
|
||||||
|
xg_source_record_id=source_record_id,
|
||||||
)
|
)
|
||||||
db.add(existing.stats)
|
db.add(existing.stats)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
# P2-1: Silver 层血缘 — MatchStats 创建
|
||||||
|
await _write_data_lineage(
|
||||||
|
db,
|
||||||
|
source_system="understat",
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
target_table="match_stats",
|
||||||
|
target_id=existing.id,
|
||||||
|
transform_name="silver_upsert",
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# xG 覆盖更新模式:当 understat 数据更新时覆盖旧值
|
||||||
if existing.stats is not None:
|
if existing.stats is not None:
|
||||||
if existing.stats.home_xg is None and nm.home_xg is not None:
|
xg_updated = False
|
||||||
|
if nm.home_xg is not None:
|
||||||
existing.stats.home_xg = nm.home_xg
|
existing.stats.home_xg = nm.home_xg
|
||||||
|
existing.stats.xg_source = "understat"
|
||||||
|
existing.stats.xg_updated_at = now
|
||||||
|
existing.stats.xg_source_record_id = source_record_id
|
||||||
result["updated"] += 1
|
result["updated"] += 1
|
||||||
if existing.stats.away_xg is None and nm.away_xg is not None:
|
xg_updated = True
|
||||||
|
if nm.away_xg is not None:
|
||||||
existing.stats.away_xg = nm.away_xg
|
existing.stats.away_xg = nm.away_xg
|
||||||
|
existing.stats.xg_source = "understat"
|
||||||
|
existing.stats.xg_updated_at = now
|
||||||
|
existing.stats.xg_source_record_id = source_record_id
|
||||||
|
xg_updated = True
|
||||||
|
# P2-1: Silver 层血缘 — xG 覆盖更新
|
||||||
|
if xg_updated:
|
||||||
|
await _write_data_lineage(
|
||||||
|
db,
|
||||||
|
source_system="understat",
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
target_table="match_stats",
|
||||||
|
target_id=existing.id,
|
||||||
|
transform_name="silver_xg_update",
|
||||||
|
batch_id=batch_id,
|
||||||
|
transform_detail=f"home_xg={nm.home_xg} away_xg={nm.away_xg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# P2-2: 数据质量检查 — 行计数合理性
|
||||||
|
total_processed = result["updated"] + result["skipped"] + result["unmatched"]
|
||||||
|
await _write_data_quality_check(
|
||||||
|
db,
|
||||||
|
check_name="understat_row_count",
|
||||||
|
entity_type="match",
|
||||||
|
entity_id=f"{league}_{season}",
|
||||||
|
expected_value=">=1",
|
||||||
|
actual_value=str(total_processed),
|
||||||
|
passed=total_processed > 0,
|
||||||
|
severity="critical" if total_processed == 0 else "info",
|
||||||
|
detail=f"league={league} season={season} updated={result['updated']} skipped={result['skipped']} unmatched={result['unmatched']}",
|
||||||
|
)
|
||||||
|
|
||||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||||
return result
|
return result
|
||||||
|
|||||||
+4
-2
@@ -17,8 +17,10 @@ engine = create_async_engine(
|
|||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_size=10,
|
pool_size=settings.DB_POOL_SIZE,
|
||||||
max_overflow=20,
|
max_overflow=settings.DB_MAX_OVERFLOW,
|
||||||
|
pool_timeout=settings.DB_POOL_TIMEOUT,
|
||||||
|
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
|||||||
+118
-1
@@ -1,4 +1,4 @@
|
|||||||
"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。"""
|
"""ORM 模型: leagues / teams / matches / match_stats / predictions / injuries / raw_events / ingest_failures / data_quality_checks / data_lineage。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
@@ -14,6 +14,7 @@ from sqlalchemy import (
|
|||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -130,12 +131,18 @@ class MatchStats(Base):
|
|||||||
source_record_id: Mapped[str | None] = mapped_column(String(100))
|
source_record_id: Mapped[str | None] = mapped_column(String(100))
|
||||||
retrieved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
retrieved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# xG 数据追踪:支持 understat 纠正旧 xG 值
|
||||||
|
xg_source: Mapped[str | None] = mapped_column(String(30)) # 具体 xG 数据源
|
||||||
|
xg_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # xG 最后更新时间
|
||||||
|
xg_source_record_id: Mapped[str | None] = mapped_column(String(100)) # xG 对应的源记录 ID
|
||||||
|
|
||||||
match: Mapped[Match] = relationship(back_populates="stats")
|
match: Mapped[Match] = relationship(back_populates="stats")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计)
|
# 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计)
|
||||||
Index("ix_match_stats_available_at", "available_at"),
|
Index("ix_match_stats_available_at", "available_at"),
|
||||||
|
# xG 数据源追踪查询用
|
||||||
|
Index("ix_match_stats_xg_source", "xg_source", "xg_updated_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -198,6 +205,11 @@ class Prediction(Base):
|
|||||||
match: Mapped[Match] = relationship(back_populates="predictions")
|
match: Mapped[Match] = relationship(back_populates="predictions")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
# P1-6: 数据库级唯一约束,防止同一 match+provider+model 产生重复预测
|
||||||
|
UniqueConstraint(
|
||||||
|
"match_id", "provider", "model",
|
||||||
|
name="uq_predictions_match_provider_model",
|
||||||
|
),
|
||||||
Index("ix_predictions_match", "match_id"),
|
Index("ix_predictions_match", "match_id"),
|
||||||
Index("ix_predictions_provider_model", "provider", "model"),
|
Index("ix_predictions_provider_model", "provider", "model"),
|
||||||
# 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测)
|
# 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测)
|
||||||
@@ -210,3 +222,108 @@ class Prediction(Base):
|
|||||||
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
|
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
|
||||||
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
|
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bronze 层:原始事件记录 ──────────────────────────────────────────────
|
||||||
|
class RawEvent(Base):
|
||||||
|
"""Bronze 层:不可变的原始采集记录。
|
||||||
|
|
||||||
|
每个采集到的原始事件先写入此表,再规范化到 Silver 层(matches / match_stats)。
|
||||||
|
提供完整的数据血缘回溯能力。
|
||||||
|
"""
|
||||||
|
__tablename__ = "raw_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
source_system: Mapped[str] = mapped_column(String(30), nullable=False) # bzzoiro / understat / api-football
|
||||||
|
source_record_id: Mapped[str] = mapped_column(String(100), nullable=False) # 源系统记录 ID
|
||||||
|
raw_payload: Mapped[dict] = mapped_column(JSONB, nullable=False) # 完整原始 JSON
|
||||||
|
ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
ingest_batch_id: Mapped[str | None] = mapped_column(String(64)) # 批次 ID,用于关联同次采集
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source_system", "source_record_id", name="uq_raw_events_source_record"),
|
||||||
|
Index("ix_raw_events_batch", "ingest_batch_id"),
|
||||||
|
Index("ix_raw_events_source_ingested", "source_system", "ingested_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 死信表:采集失败记录 ─────────────────────────────────────────────────
|
||||||
|
class IngestFailure(Base):
|
||||||
|
"""采集失败死信表。
|
||||||
|
|
||||||
|
当采集或规范化失败时,写入此表而非仅内存 dict。
|
||||||
|
支持自动重试和人工排查。
|
||||||
|
"""
|
||||||
|
__tablename__ = "ingest_failures"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
source_system: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(30), nullable=False) # match / injury / team
|
||||||
|
source_record_id: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
error_type: Mapped[str] = mapped_column(String(50), nullable=False) # fetch_error / normalize_error / db_error / validation_error
|
||||||
|
error_detail: Mapped[str | None] = mapped_column(Text)
|
||||||
|
raw_payload: Mapped[dict | None] = mapped_column(JSONB) # 失败时的原始数据,用于重试
|
||||||
|
retry_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending") # pending / retrying / resolved / abandoned
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ingest_failures_status_next_retry", "status", "next_retry_at"),
|
||||||
|
Index("ix_ingest_failures_source", "source_system", "entity_type"),
|
||||||
|
CheckConstraint("status IN ('pending', 'retrying', 'resolved', 'abandoned')", name="ck_ingest_failures_status"),
|
||||||
|
CheckConstraint("retry_count >= 0", name="ck_ingest_failures_retry_nonneg"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据质量检查表 ──────────────────────────────────────────────────────
|
||||||
|
class DataQualityCheck(Base):
|
||||||
|
"""数据质量检查结果记录。
|
||||||
|
|
||||||
|
每次运行数据质量检查时,将结果写入此表用于趋势分析和告警。
|
||||||
|
"""
|
||||||
|
__tablename__ = "data_quality_checks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
check_name: Mapped[str] = mapped_column(String(100), nullable=False) # 检查名称
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(30), nullable=False) # match / team / prediction
|
||||||
|
entity_id: Mapped[str | None] = mapped_column(String(50)) # 具体实体 ID
|
||||||
|
expected_value: Mapped[str | None] = mapped_column(Text) # 期望值(描述)
|
||||||
|
actual_value: Mapped[str | None] = mapped_column(Text) # 实际值
|
||||||
|
passed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
severity: Mapped[str] = mapped_column(String(10), nullable=False, default="warning") # info / warning / critical
|
||||||
|
detail: Mapped[str | None] = mapped_column(Text) # 详细描述
|
||||||
|
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_dqc_check_time", "check_name", "checked_at"),
|
||||||
|
Index("ix_dqc_entity", "entity_type", "entity_id"),
|
||||||
|
Index("ix_dqc_severity_passed", "severity", "passed"),
|
||||||
|
CheckConstraint("severity IN ('info', 'warning', 'critical')", name="ck_dqc_severity"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据血缘表 ──────────────────────────────────────────────────────────
|
||||||
|
class DataLineage(Base):
|
||||||
|
"""ETL 全过程元数据记录。
|
||||||
|
|
||||||
|
追踪从 Bronze → Silver → Gold 的完整转换链路。
|
||||||
|
"""
|
||||||
|
__tablename__ = "data_lineage"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
source_system: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
source_record_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
target_table: Mapped[str] = mapped_column(String(50), nullable=False) # matches / match_stats / predictions
|
||||||
|
target_id: Mapped[int | None] = mapped_column(Integer) # 目标表记录 ID
|
||||||
|
transform_name: Mapped[str] = mapped_column(String(100), nullable=False) # 转换步骤名称
|
||||||
|
transform_detail: Mapped[str | None] = mapped_column(Text) # 转换详情
|
||||||
|
batch_id: Mapped[str | None] = mapped_column(String(64)) # 批次 ID
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
|
||||||
|
__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"),
|
||||||
|
)
|
||||||
|
|||||||
+12
-3
@@ -5,7 +5,8 @@ Repository 只负责查询,不负责事务提交。
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -41,9 +42,17 @@ class MatchRepository:
|
|||||||
|
|
||||||
预加载 stats:调用方(understat 回填)会读取 existing.stats,
|
预加载 stats:调用方(understat 回填)会读取 existing.stats,
|
||||||
async session 下惰性加载会抛 MissingGreenlet。
|
async session 下惰性加载会抛 MissingGreenlet。
|
||||||
|
|
||||||
|
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
|
||||||
|
导致的全表扫描。
|
||||||
"""
|
"""
|
||||||
if hasattr(date, "date"):
|
if isinstance(date, datetime):
|
||||||
date = date.date()
|
date = date.date()
|
||||||
|
elif hasattr(date, "date"):
|
||||||
|
date = date.date()
|
||||||
|
else:
|
||||||
|
# 字符串等其它格式,尝试转换
|
||||||
|
date = datetime.fromisoformat(str(date)).date()
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Match)
|
select(Match)
|
||||||
@@ -51,7 +60,7 @@ class MatchRepository:
|
|||||||
.where(Match.league_id == league_id)
|
.where(Match.league_id == league_id)
|
||||||
.where(Match.home_team_id == home_team_id)
|
.where(Match.home_team_id == home_team_id)
|
||||||
.where(Match.away_team_id == away_team_id)
|
.where(Match.away_team_id == away_team_id)
|
||||||
.where(func.date(Match.match_date) == date)
|
.where(Match.match_date_date == date)
|
||||||
)
|
)
|
||||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src.db.base import AsyncSessionLocal
|
from src.db.base import AsyncSessionLocal
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def get_uow() -> AsyncIterator[AsyncSessionLocal]:
|
async def get_uow() -> AsyncIterator[AsyncSession]:
|
||||||
"""创建新的工作单元(用于非路由上下文)。
|
"""创建新的工作单元(用于非路由上下文)。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
|
|||||||
+35
-43
@@ -3,9 +3,11 @@
|
|||||||
核心机制:
|
核心机制:
|
||||||
- build_context 已内置 before=match_date,天然防未来信息泄漏
|
- build_context 已内置 before=match_date,天然防未来信息泄漏
|
||||||
- 对历史比赛跑预测 → 用实际比分 settle → 统计准确率
|
- 对历史比赛跑预测 → 用实际比分 settle → 统计准确率
|
||||||
|
- 并发控制: asyncio.Semaphore 限制同时 LLM 调用数
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -17,6 +19,7 @@ from src.db.models import Match
|
|||||||
from src.db.unit_of_work import get_uow
|
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
|
||||||
|
from src.llm.utils import actual_1x2
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,15 +72,6 @@ class BacktestSummary:
|
|||||||
results: list[BacktestMatchResult] = field(default_factory=list)
|
results: list[BacktestMatchResult] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def _actual_1x2(home: int, away: int) -> str:
|
|
||||||
"""实际比分 → 胜平负。"""
|
|
||||||
if home > away:
|
|
||||||
return "1"
|
|
||||||
if home == away:
|
|
||||||
return "X"
|
|
||||||
return "2"
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_historical_matches(
|
async def _get_historical_matches(
|
||||||
db,
|
db,
|
||||||
*,
|
*,
|
||||||
@@ -156,44 +150,42 @@ async def run_backtest(
|
|||||||
|
|
||||||
summary = BacktestSummary(total=len(candidates), scored=0)
|
summary = BacktestSummary(total=len(candidates), scored=0)
|
||||||
|
|
||||||
for c in candidates:
|
# P2: 并发控制,同时最多 3 场预测(避免 LLM API 限流,保护下游服务)
|
||||||
try:
|
sem = asyncio.Semaphore(3)
|
||||||
# 预测 (build_context 内部已用 before=match_date 防泄漏,
|
|
||||||
# injuries_slice 也使用 as_of=match_date 过滤 retrieved_at)
|
|
||||||
# 回测必须禁用结果缓存: 否则命中缓存会复用同一 prediction_id,
|
|
||||||
# 导致 settle 反复覆盖同一条记录(见 P1-3)。
|
|
||||||
result = await predict_match(c.match_id, mode=mode, model=model, use_cache=False)
|
|
||||||
|
|
||||||
# 用实际比分 settle
|
async def _one(c: BacktestCandidate) -> BacktestMatchResult | None:
|
||||||
await settle_prediction(result.prediction_id, c.home_goals, c.away_goals)
|
async with sem:
|
||||||
|
try:
|
||||||
|
result = await predict_match(c.match_id, mode=mode, model=model, use_cache=False, backtest=True)
|
||||||
|
await settle_prediction(result.prediction_id, c.home_goals, c.away_goals)
|
||||||
|
actual = actual_1x2(c.home_goals, c.away_goals)
|
||||||
|
return BacktestMatchResult(
|
||||||
|
match_id=c.match_id,
|
||||||
|
league_code=c.league_code,
|
||||||
|
home_team=c.home_team,
|
||||||
|
away_team=c.away_team,
|
||||||
|
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
|
||||||
|
actual_home=c.home_goals,
|
||||||
|
actual_away=c.away_goals,
|
||||||
|
actual_1x2=actual,
|
||||||
|
pred_home=result.pred_home_goals,
|
||||||
|
pred_away=result.pred_away_goals,
|
||||||
|
pred_1x2=result.pred_1x2,
|
||||||
|
subjective_confidence=result.subjective_confidence,
|
||||||
|
correct_1x2=result.pred_1x2 == actual,
|
||||||
|
prediction_id=result.prediction_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("backtest match %s failed", c.match_id)
|
||||||
|
return None
|
||||||
|
|
||||||
actual = _actual_1x2(c.home_goals, c.away_goals)
|
# 并行执行,保持结果顺序
|
||||||
correct = result.pred_1x2 == actual
|
results = await asyncio.gather(*[_one(c) for c in candidates])
|
||||||
|
for r in results:
|
||||||
bt = BacktestMatchResult(
|
if r is not None:
|
||||||
match_id=c.match_id,
|
summary.results.append(r)
|
||||||
league_code=c.league_code,
|
|
||||||
home_team=c.home_team,
|
|
||||||
away_team=c.away_team,
|
|
||||||
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
|
|
||||||
actual_home=c.home_goals,
|
|
||||||
actual_away=c.away_goals,
|
|
||||||
actual_1x2=actual,
|
|
||||||
pred_home=result.pred_home_goals,
|
|
||||||
pred_away=result.pred_away_goals,
|
|
||||||
pred_1x2=result.pred_1x2,
|
|
||||||
subjective_confidence=result.subjective_confidence,
|
|
||||||
correct_1x2=correct,
|
|
||||||
prediction_id=result.prediction_id,
|
|
||||||
)
|
|
||||||
summary.results.append(bt)
|
|
||||||
summary.scored += 1
|
summary.scored += 1
|
||||||
|
|
||||||
except Exception:
|
|
||||||
# 用 exception 而非 warning:保留堆栈,否则集成层缺陷(如惰性加载
|
|
||||||
# 在 session 外触发)会只剩一行无堆栈的 warning,极难定位。
|
|
||||||
logger.exception("backtest match %s failed", c.match_id)
|
|
||||||
|
|
||||||
# 汇总统计
|
# 汇总统计
|
||||||
if summary.scored > 0:
|
if summary.scored > 0:
|
||||||
correct_count = sum(1 for r in summary.results if r.correct_1x2)
|
correct_count = sum(1 for r in summary.results if r.correct_1x2)
|
||||||
|
|||||||
+103
-44
@@ -1,16 +1,22 @@
|
|||||||
"""上下文构建器:数据切片 + 拼接。
|
"""上下文构建器:数据切片 + 拼接。
|
||||||
|
|
||||||
架构:
|
架构:
|
||||||
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
||||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
|
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
|
||||||
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
||||||
|
|
||||||
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
||||||
|
|
||||||
|
性能说明:
|
||||||
|
build_context 创建一个共享 session 并传给所有切片函数,
|
||||||
|
避免每个切片独立创建 session —— 回测 20 场并发时,
|
||||||
|
5 个切片 × 20 场 = 100 个连接会耗尽连接池(pool_size=15)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -18,6 +24,9 @@ from sqlalchemy.orm import selectinload
|
|||||||
from src.db.base import AsyncSessionLocal
|
from src.db.base import AsyncSessionLocal
|
||||||
from src.db.models import Match
|
from src.db.models import Match
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -80,11 +89,19 @@ class MatchHeader:
|
|||||||
league_id: int
|
league_id: int
|
||||||
|
|
||||||
|
|
||||||
async def load_match_header(match_id: int) -> MatchHeader:
|
async def load_match_header(match_id: int, db: AsyncSession | None = None) -> MatchHeader:
|
||||||
"""加载比赛头信息(各 agent 共用)。"""
|
"""加载比赛头信息(各 agent 共用)。
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
|
Args:
|
||||||
|
match_id: 比赛 ID
|
||||||
|
db: 可选的共享 session。不传则自建(向后兼容)。
|
||||||
|
"""
|
||||||
|
if db is not None:
|
||||||
m = await _load_match(db, match_id)
|
m = await _load_match(db, match_id)
|
||||||
return _to_header(m)
|
return _to_header(m)
|
||||||
|
async with AsyncSessionLocal() as new_db:
|
||||||
|
m = await _load_match(new_db, match_id)
|
||||||
|
return _to_header(m)
|
||||||
|
|
||||||
|
|
||||||
def _to_header(m: Match) -> MatchHeader:
|
def _to_header(m: Match) -> MatchHeader:
|
||||||
@@ -114,10 +131,16 @@ def header_text(h: MatchHeader) -> str:
|
|||||||
# 切片函数: 每个领域 agent 一个
|
# 切片函数: 每个领域 agent 一个
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> SliceResult:
|
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||||
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。"""
|
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
|
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)。
|
||||||
|
"""
|
||||||
|
if db is not None:
|
||||||
h2h = await _get_h2h(db, header.home_team_id, header.away_team_id, before=before, limit=limit)
|
h2h = await _get_h2h(db, header.home_team_id, header.away_team_id, before=before, limit=limit)
|
||||||
|
else:
|
||||||
|
async with AsyncSessionLocal() as new_db:
|
||||||
|
h2h = await _get_h2h(new_db, header.home_team_id, header.away_team_id, before=before, limit=limit)
|
||||||
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
||||||
n_with_score = 0
|
n_with_score = 0
|
||||||
if h2h:
|
if h2h:
|
||||||
@@ -141,11 +164,18 @@ async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> Slic
|
|||||||
return SliceResult(text="\n".join(lines), has_data=n_with_score > 0, n_records=n_with_score)
|
return SliceResult(text="\n".join(lines), has_data=n_with_score > 0, n_records=n_with_score)
|
||||||
|
|
||||||
|
|
||||||
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> SliceResult:
|
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||||
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。"""
|
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
|
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)。
|
||||||
|
"""
|
||||||
|
if db is not None:
|
||||||
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
||||||
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
||||||
|
else:
|
||||||
|
async with AsyncSessionLocal() as new_db:
|
||||||
|
home_form = await _get_form(new_db, header.home_team_id, before=before, limit=limit)
|
||||||
|
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
||||||
lines = []
|
lines = []
|
||||||
n_scored = 0
|
n_scored = 0
|
||||||
for label, name, form, side in (
|
for label, name, form, side in (
|
||||||
@@ -175,11 +205,18 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> Sli
|
|||||||
return SliceResult(text="\n".join(lines), has_data=n_scored > 0, n_records=n_scored)
|
return SliceResult(text="\n".join(lines), has_data=n_scored > 0, n_records=n_scored)
|
||||||
|
|
||||||
|
|
||||||
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> SliceResult:
|
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||||
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。"""
|
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
|
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)。
|
||||||
|
"""
|
||||||
|
if db is not None:
|
||||||
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
||||||
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
||||||
|
else:
|
||||||
|
async with AsyncSessionLocal() as new_db:
|
||||||
|
home_form = await _get_form(new_db, header.home_team_id, before=before, limit=limit)
|
||||||
|
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
||||||
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
||||||
n_total = 0
|
n_total = 0
|
||||||
for label, name, form, side in (
|
for label, name, form, side in (
|
||||||
@@ -221,11 +258,18 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> S
|
|||||||
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
||||||
|
|
||||||
|
|
||||||
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None) -> SliceResult:
|
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||||
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。"""
|
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
|
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)。
|
||||||
|
"""
|
||||||
|
if db is not None:
|
||||||
home_home = await _get_home_away(db, header.home_team_id, "home", before=before, limit=limit)
|
home_home = await _get_home_away(db, header.home_team_id, "home", before=before, limit=limit)
|
||||||
away_away = await _get_home_away(db, header.away_team_id, "away", before=before, limit=limit)
|
away_away = await _get_home_away(db, header.away_team_id, "away", before=before, limit=limit)
|
||||||
|
else:
|
||||||
|
async with AsyncSessionLocal() as new_db:
|
||||||
|
home_home = await _get_home_away(new_db, header.home_team_id, "home", before=before, limit=limit)
|
||||||
|
away_away = await _get_home_away(new_db, header.away_team_id, "away", before=before, limit=limit)
|
||||||
lines = ["── 主客因素 ──"]
|
lines = ["── 主客因素 ──"]
|
||||||
n_total = 0
|
n_total = 0
|
||||||
for label, name, matches, side in (
|
for label, name, matches, side in (
|
||||||
@@ -255,17 +299,22 @@ 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)
|
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
||||||
|
|
||||||
|
|
||||||
async def injuries_slice(header: MatchHeader, *, before=None) -> SliceResult:
|
async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||||
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
|
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
|
||||||
|
|
||||||
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
||||||
|
db: 可选共享 session(见模块 docstring)。
|
||||||
"""
|
"""
|
||||||
from src.data.injuries import get_injuries_for_match
|
from src.data.injuries import get_injuries_for_match
|
||||||
|
|
||||||
cutoff = before or header.match_dt
|
cutoff = before or header.match_dt
|
||||||
async with AsyncSessionLocal() as db:
|
if db is not None:
|
||||||
home_injuries = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
|
home_injuries = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
|
||||||
away_injuries = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
away_injuries = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
||||||
|
else:
|
||||||
|
async with AsyncSessionLocal() as new_db:
|
||||||
|
home_injuries = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
|
||||||
|
away_injuries = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
|
||||||
|
|
||||||
lines = ["── 阵容完整性 ──"]
|
lines = ["── 阵容完整性 ──"]
|
||||||
n_records = 0
|
n_records = 0
|
||||||
@@ -291,41 +340,51 @@ async def injuries_slice(header: MatchHeader, *, before=None) -> SliceResult:
|
|||||||
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5) -> MatchContext:
|
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False) -> MatchContext:
|
||||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
|
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
|
||||||
|
|
||||||
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
||||||
不再靠文案子串匹配(见审查报告 P2-1)。
|
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||||
|
|
||||||
|
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
||||||
|
|
||||||
|
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。
|
||||||
"""
|
"""
|
||||||
header = await load_match_header(match_id)
|
async with AsyncSessionLocal() as db:
|
||||||
parts = [header_text(header), ""]
|
header = await load_match_header(match_id, db=db)
|
||||||
|
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
|
||||||
|
cutoff = header.match_dt
|
||||||
|
if backtest and header.match_dt:
|
||||||
|
from datetime import timedelta
|
||||||
|
cutoff = header.match_dt - timedelta(days=1)
|
||||||
|
parts = [header_text(header), ""]
|
||||||
|
|
||||||
form_res = await form_slice(header, limit=form_last, before=header.match_dt)
|
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
|
||||||
parts.append(form_res.text)
|
parts.append(form_res.text)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
h2h_res = await h2h_slice(header, limit=h2h_last, before=header.match_dt)
|
h2h_res = await h2h_slice(header, limit=h2h_last, before=cutoff, db=db)
|
||||||
parts.append(h2h_res.text)
|
parts.append(h2h_res.text)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
stats_res = await stats_slice(header, before=header.match_dt)
|
stats_res = await stats_slice(header, before=cutoff, db=db)
|
||||||
parts.append(stats_res.text)
|
parts.append(stats_res.text)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
home_away_res = await home_away_slice(header, before=header.match_dt)
|
home_away_res = await home_away_slice(header, before=cutoff, db=db)
|
||||||
parts.append(home_away_res.text)
|
parts.append(home_away_res.text)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
injuries_res = await injuries_slice(header, before=header.match_dt)
|
injuries_res = await injuries_slice(header, before=cutoff, db=db)
|
||||||
parts.append(injuries_res.text)
|
parts.append(injuries_res.text)
|
||||||
|
|
||||||
return MatchContext(
|
return MatchContext(
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
text="\n".join(parts),
|
text="\n".join(parts),
|
||||||
has_stats=form_res.has_data or stats_res.has_data,
|
has_stats=form_res.has_data or stats_res.has_data,
|
||||||
has_injuries=injuries_res.has_data,
|
has_injuries=injuries_res.has_data,
|
||||||
match_dt=header.match_dt,
|
match_dt=header.match_dt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
+17
-11
@@ -23,8 +23,9 @@ _PROMPT_DIR = Path(__file__).resolve().parent / "prompts"
|
|||||||
|
|
||||||
# ── LLM 响应缓存(match+provider+model+version → 结果) ──
|
# ── LLM 响应缓存(match+provider+model+version → 结果) ──
|
||||||
_CACHE_TTL_SEC = 300 # 5 分钟
|
_CACHE_TTL_SEC = 300 # 5 分钟
|
||||||
|
# P1-5: 缓存仅在 asyncio 协程内同步访问(dict 操作 GIL 原子),无需 threading.Lock。
|
||||||
|
# 删除 _cache_lock,避免同步锁阻塞事件循环;dict 的 get/set 在 CPython 下原子。
|
||||||
_cache: dict[str, tuple[float, PredictResult]] = {}
|
_cache: dict[str, tuple[float, PredictResult]] = {}
|
||||||
_cache_lock = Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_key(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> str:
|
def _cache_key(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> str:
|
||||||
@@ -38,20 +39,21 @@ def _cache_key(match_id: int, provider: str, model: str, version: str, tpl_hash:
|
|||||||
|
|
||||||
|
|
||||||
def _get_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> PredictResult | None:
|
def _get_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> PredictResult | None:
|
||||||
|
# P1-5: 无锁访问。dict get/del 在 CPython GIL 下原子,且无 await 穿插。
|
||||||
key = _cache_key(match_id, provider, model, version, tpl_hash)
|
key = _cache_key(match_id, provider, model, version, tpl_hash)
|
||||||
with _cache_lock:
|
entry = _cache.get(key)
|
||||||
if key in _cache:
|
if entry is not None:
|
||||||
ts, result = _cache[key]
|
ts, result = entry
|
||||||
if time.time() - ts < _CACHE_TTL_SEC:
|
if time.time() - ts < _CACHE_TTL_SEC:
|
||||||
return result
|
return result
|
||||||
del _cache[key]
|
_cache.pop(key, None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _set_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str, result: PredictResult) -> None:
|
def _set_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str, result: PredictResult) -> None:
|
||||||
|
# P1-5: 无锁写入。同上,dict set 原子。
|
||||||
key = _cache_key(match_id, provider, model, version, tpl_hash)
|
key = _cache_key(match_id, provider, model, version, tpl_hash)
|
||||||
with _cache_lock:
|
_cache[key] = (time.time(), result)
|
||||||
_cache[key] = (time.time(), result)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_prompt_cache() -> None:
|
def clear_prompt_cache() -> None:
|
||||||
@@ -103,6 +105,7 @@ async def predict_match(
|
|||||||
prompt_version: str | None = None,
|
prompt_version: str | None = None,
|
||||||
mode: str = "multi",
|
mode: str = "multi",
|
||||||
use_cache: bool = True,
|
use_cache: bool = True,
|
||||||
|
backtest: bool = False,
|
||||||
) -> "PredictResult | MultiPredictResult":
|
) -> "PredictResult | MultiPredictResult":
|
||||||
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
||||||
|
|
||||||
@@ -110,6 +113,7 @@ async def predict_match(
|
|||||||
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
||||||
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
||||||
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
||||||
|
backtest: 是否回测模式。True 时 build_context 使用 match_date-1天 作为 cutoff。
|
||||||
"""
|
"""
|
||||||
if mode == "single":
|
if mode == "single":
|
||||||
return await _predict_single(
|
return await _predict_single(
|
||||||
@@ -118,6 +122,7 @@ async def predict_match(
|
|||||||
model=model,
|
model=model,
|
||||||
prompt_version=prompt_version,
|
prompt_version=prompt_version,
|
||||||
use_cache=use_cache,
|
use_cache=use_cache,
|
||||||
|
backtest=backtest,
|
||||||
)
|
)
|
||||||
from src.llm.agents.orchestrator import predict_match_multi
|
from src.llm.agents.orchestrator import predict_match_multi
|
||||||
|
|
||||||
@@ -131,6 +136,7 @@ async def _predict_single(
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
prompt_version: str | None = None,
|
prompt_version: str | None = None,
|
||||||
use_cache: bool = True,
|
use_cache: bool = True,
|
||||||
|
backtest: bool = False,
|
||||||
) -> PredictResult:
|
) -> PredictResult:
|
||||||
"""单次调用路径(原有实现)。"""
|
"""单次调用路径(原有实现)。"""
|
||||||
if provider is None:
|
if provider is None:
|
||||||
@@ -147,8 +153,8 @@ async def _predict_single(
|
|||||||
logger.debug("predict cache hit match=%s", match_id)
|
logger.debug("predict cache hit match=%s", match_id)
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# 1. 拼上下文
|
# 1. 拼上下文(P2-6: backtest 时使用 match_date-1天 作为 cutoff)
|
||||||
ctx = await build_context(match_id)
|
ctx = await build_context(match_id, backtest=backtest)
|
||||||
|
|
||||||
# 1.5 计算快照元数据(用于可复现性)
|
# 1.5 计算快照元数据(用于可复现性)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""LLM 模块共享工具函数。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def actual_1x2(home: int, away: int) -> str:
|
||||||
|
"""实际比分 → 胜平负。
|
||||||
|
|
||||||
|
单一权威源: backtest.py 和 eval.py 共用,避免重复定义。
|
||||||
|
"""
|
||||||
|
if home > away:
|
||||||
|
return "1"
|
||||||
|
if home == away:
|
||||||
|
return "X"
|
||||||
|
return "2"
|
||||||
|
|
||||||
|
|
||||||
|
def is_correct_1x2(pred: str | None, actual: str) -> bool:
|
||||||
|
"""预测是否命中胜平负。"""
|
||||||
|
return pred == actual
|
||||||
@@ -10,8 +10,10 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 已知的 5 个专家 agent 名(与 orchestrator.SPECIALIST_SPECS 保持一致)
|
# 单一权威源:从 orchestrator.SPECIALIST_SPECS 派生,避免两端独立定义导致静默偏离
|
||||||
KNOWN_AGENT_NAMES: tuple[str, ...] = ("form", "stats", "home_away", "injuries", "h2h")
|
from src.llm.agents.orchestrator import SPECIALIST_SPECS
|
||||||
|
|
||||||
|
KNOWN_AGENT_NAMES: tuple[str, ...] = tuple(spec.name for spec in SPECIALIST_SPECS)
|
||||||
|
|
||||||
|
|
||||||
class AgentReportSchema(BaseModel):
|
class AgentReportSchema(BaseModel):
|
||||||
@@ -74,7 +76,7 @@ class PredictionOutputSchema(BaseModel):
|
|||||||
"""
|
"""
|
||||||
expected = _score_to_1x2(self.pred_home_goals, self.pred_away_goals)
|
expected = _score_to_1x2(self.pred_home_goals, self.pred_away_goals)
|
||||||
if self.pred_1x2 != expected:
|
if self.pred_1x2 != expected:
|
||||||
logger.warning(
|
logger.debug(
|
||||||
"1x2 与比分不一致: 比分 %.1f-%.1f 推出 '%s',但 LLM 给出 '%s';以比分修正",
|
"1x2 与比分不一致: 比分 %.1f-%.1f 推出 '%s',但 LLM 给出 '%s';以比分修正",
|
||||||
self.pred_home_goals, self.pred_away_goals, expected, self.pred_1x2,
|
self.pred_home_goals, self.pred_away_goals, expected, self.pred_1x2,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user