refactor: 以 bzzoiro 为唯一数据源的全面重构
数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填
采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)
预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新
管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源
前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)
迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。
Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
co-authored by
new-provider/LongCat-2.0 <
parent
ec8f36abb2
commit
f05dc1ae15
@@ -0,0 +1,102 @@
|
|||||||
|
"""bzzoiro 单一数据源重构:新增 standings、扩展 match_stats、删除 injuries
|
||||||
|
|
||||||
|
Revision ID: 0015_bzzoiro_single_source
|
||||||
|
Revises: 0014_predictions_agent_weights
|
||||||
|
Create Date: 2026-09-20
|
||||||
|
|
||||||
|
变更内容:
|
||||||
|
1. 新增 standings 表(联赛积分榜快照,来源 bzzoiro /leagues/{id}/standings/)
|
||||||
|
2. match_stats 新增 bzzoiro /events/{id}/stats/ 扩展字段:
|
||||||
|
home/away_big_chances, home/away_fouls
|
||||||
|
3. 删除 injuries 表(数据源已下线,不再采集伤停)
|
||||||
|
4. Understat 无独立表(xG 写入 match_stats),无需删表;
|
||||||
|
历史 source='understat' 数据保留,不再新增。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0015_bzzoiro_single_source'
|
||||||
|
down_revision: Union[str, None] = '0014_predictions_agent_weights'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 0. matches 增加数据血缘列(bzzoiro 上游事件 ID)
|
||||||
|
op.add_column('matches', sa.Column('source_event_id', sa.BigInteger()))
|
||||||
|
op.create_index('ix_matches_source_event_id', 'matches', ['source_event_id'])
|
||||||
|
|
||||||
|
# 1. standings 表
|
||||||
|
op.create_table(
|
||||||
|
'standings',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('league_id', sa.Integer(), sa.ForeignKey('leagues.id'), nullable=False),
|
||||||
|
sa.Column('season', sa.String(12), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id'), nullable=False),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('played', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('won', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('drawn', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('goals_for', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('goals_against', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('goal_diff', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('points', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('xg_for', sa.Float()),
|
||||||
|
sa.Column('xg_against', sa.Float()),
|
||||||
|
sa.Column('form', sa.String(20)),
|
||||||
|
sa.Column('zone', sa.String(50)),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True)),
|
||||||
|
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
|
||||||
|
sa.UniqueConstraint('league_id', 'season', 'team_id', name='uq_standings_league_season_team'),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'ix_standings_league_season_pos', 'standings',
|
||||||
|
['league_id', 'season', 'position'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. match_stats 扩展字段
|
||||||
|
op.add_column('match_stats', sa.Column('home_big_chances', sa.Integer()))
|
||||||
|
op.add_column('match_stats', sa.Column('away_big_chances', sa.Integer()))
|
||||||
|
op.add_column('match_stats', sa.Column('home_fouls', sa.Integer()))
|
||||||
|
op.add_column('match_stats', sa.Column('away_fouls', sa.Integer()))
|
||||||
|
|
||||||
|
# 3. 删除 injuries 表
|
||||||
|
op.drop_index('ix_injuries_player_fixture', table_name='injuries')
|
||||||
|
op.drop_index('ix_injuries_team_date', table_name='injuries')
|
||||||
|
op.drop_table('injuries')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 恢复 injuries 表(不含数据)
|
||||||
|
op.create_table(
|
||||||
|
'injuries',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('player_id', sa.Integer()),
|
||||||
|
sa.Column('player_name', sa.String(120), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id')),
|
||||||
|
sa.Column('fixture_id', sa.Integer()),
|
||||||
|
sa.Column('league_id', sa.Integer()),
|
||||||
|
sa.Column('injury_type', sa.String(50)),
|
||||||
|
sa.Column('reason', sa.String(200)),
|
||||||
|
sa.Column('injury_date', sa.Date()),
|
||||||
|
sa.Column('return_date', sa.Date()),
|
||||||
|
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
op.create_index('ix_injuries_player_fixture', 'injuries', ['player_id', 'fixture_id', 'injury_type'])
|
||||||
|
op.create_index('ix_injuries_team_date', 'injuries', ['team_id', 'injury_date'])
|
||||||
|
|
||||||
|
op.drop_index('ix_matches_source_event_id', table_name='matches')
|
||||||
|
op.drop_column('matches', 'source_event_id')
|
||||||
|
|
||||||
|
op.drop_column('match_stats', 'away_fouls')
|
||||||
|
op.drop_column('match_stats', 'home_fouls')
|
||||||
|
op.drop_column('match_stats', 'away_big_chances')
|
||||||
|
op.drop_column('match_stats', 'home_big_chances')
|
||||||
|
|
||||||
|
op.drop_index('ix_standings_league_season_pos', table_name='standings')
|
||||||
|
op.drop_table('standings')
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import Matches from './pages/Matches'
|
import Matches from './pages/Matches'
|
||||||
|
import Standings from './pages/Standings'
|
||||||
import { adminRoutes } from './admin/routes'
|
import { adminRoutes } from './admin/routes'
|
||||||
|
|
||||||
/** 报眉日期行 */
|
/** 报眉日期行 */
|
||||||
@@ -26,6 +27,52 @@ function dateLine(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-paper-50">
|
||||||
|
<header className="masthead-rule">
|
||||||
|
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||||
|
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||||
|
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900">
|
||||||
|
先知
|
||||||
|
<span className="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500">
|
||||||
|
Profeto
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">
|
||||||
|
联赛积分榜 · 积分 / 净胜 / xG差 / 近期走势
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||||
|
<span>{dateLine()}</span>
|
||||||
|
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||||
|
<a href="/" className="text-ink-500 hover:text-press transition-colors">
|
||||||
|
比赛 / 预测
|
||||||
|
</a>
|
||||||
|
<a href="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||||
|
评估
|
||||||
|
</a>
|
||||||
|
<a href="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
||||||
|
<span aria-hidden="true">⚙</span> 管理
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
数据由 bzzoiro 提供 · 仅供研究参考
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-paper-50">
|
<div className="min-h-screen bg-paper-50">
|
||||||
@@ -46,6 +93,9 @@ function HomePage() {
|
|||||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||||
<span>{dateLine()}</span>
|
<span>{dateLine()}</span>
|
||||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||||
|
<a href="/standings" className="text-ink-500 hover:text-press transition-colors">
|
||||||
|
积分榜
|
||||||
|
</a>
|
||||||
<a href="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
<a href="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||||
评估
|
评估
|
||||||
</a>
|
</a>
|
||||||
@@ -76,6 +126,7 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/standings" element={<StandingsLayout><Standings /></StandingsLayout>} />
|
||||||
{adminRoutes.map(route => (
|
{adminRoutes.map(route => (
|
||||||
<Route key={route.path} path={route.path} element={route.element}>
|
<Route key={route.path} path={route.path} element={route.element}>
|
||||||
{route.children.map(child => (
|
{route.children.map(child => (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: s
|
|||||||
title: '数据流水线',
|
title: '数据流水线',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||||
|
{ to: '/admin/data-completeness', label: '数据完整性', icon: '◫' },
|
||||||
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||||
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -166,12 +166,12 @@ export function DataTable<T = any>({
|
|||||||
|
|
||||||
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
||||||
|
|
||||||
export function ProgressBar({ value }: { value: number }) {
|
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
|
||||||
const clamped = Math.max(0, Math.min(100, value))
|
const clamped = Math.max(0, Math.min(100, value))
|
||||||
return (
|
return (
|
||||||
<div className="h-px w-full bg-ink-200" role="progressbar" aria-valuenow={clamped}>
|
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
|
||||||
<div
|
<div
|
||||||
className="h-px bg-press transition-[width] duration-500"
|
className="h-full rounded-full bg-press transition-[width] duration-500"
|
||||||
style={{ width: `${clamped}%` }}
|
style={{ width: `${clamped}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,15 +253,20 @@ export function ResponsiveTable<T = any>({
|
|||||||
export function SectionHeader({
|
export function SectionHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-5">
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
<h2 className="section-head text-base">{title}</h2>
|
<h2 className="section-head text-base">{title}</h2>
|
||||||
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-23
@@ -61,33 +61,16 @@ export async function fetchDashboard(): Promise<DashboardStats> {
|
|||||||
// ── 数据采集 ────────────────────────────────────────────────────
|
// ── 数据采集 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
||||||
const sourceMap: Record<string, { path: string; body: any }> = {
|
const body: Record<string, any> = {
|
||||||
bzzoiro: {
|
|
||||||
path: `${API_BASE}/ingest/bzzoiro`,
|
|
||||||
body: {
|
|
||||||
leagues: req.leagues,
|
leagues: req.leagues,
|
||||||
date_from: req.date_from,
|
date_from: req.date_from,
|
||||||
date_to: req.date_to,
|
date_to: req.date_to,
|
||||||
status: req.status || undefined, // 空 = 已完赛 + 未开赛都采集
|
status: req.status || undefined,
|
||||||
},
|
task: req.task || 'events',
|
||||||
},
|
limit: req.limit || 100,
|
||||||
understat: {
|
season: req.season || undefined,
|
||||||
path: `${API_BASE}/ingest/understat`,
|
|
||||||
body: {
|
|
||||||
league: req.league,
|
|
||||||
season: req.season ? parseInt(req.season) : new Date().getFullYear(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
injuries: {
|
|
||||||
path: `${API_BASE}/ingest/injuries`,
|
|
||||||
body: {
|
|
||||||
date: req.date_from || new Date().toLocaleDateString('sv-SE'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
const cfg = sourceMap[req.source]
|
return api.post(`${API_BASE}/ingest/bzzoiro`, body)
|
||||||
if (!cfg) throw new Error(`未知数据源: ${req.source}`)
|
|
||||||
return api.post(cfg.path, cfg.body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 预测管理 ────────────────────────────────────────────────────
|
// ── 预测管理 ────────────────────────────────────────────────────
|
||||||
@@ -183,6 +166,65 @@ export async function fetchHealth(): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 数据完整性 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DataCompletenessResponse {
|
||||||
|
generated_at: string
|
||||||
|
totals: { finished_matches: number; stats_rows: number; stats_coverage_pct: number }
|
||||||
|
issues: string[]
|
||||||
|
leagues: Array<{
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
country?: string
|
||||||
|
matches: { total: number; finished: number; scheduled: number; with_source_id: number; earliest_match?: string; latest_match?: string }
|
||||||
|
stats: {
|
||||||
|
rows: number
|
||||||
|
fields: Record<string, { count: number; pct: number }>
|
||||||
|
}
|
||||||
|
standings: { rows: number; latest_retrieved?: string }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataCompleteness(): Promise<DataCompletenessResponse> {
|
||||||
|
return api.get<DataCompletenessResponse>(`${API_BASE}/admin/data-completeness`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 积分榜(主站 + 管理后台共用) ─────────────────────────────────
|
||||||
|
|
||||||
|
export interface StandingRow {
|
||||||
|
position: number
|
||||||
|
team: string
|
||||||
|
team_en: string
|
||||||
|
played: number
|
||||||
|
won: number
|
||||||
|
drawn: number
|
||||||
|
lost: number
|
||||||
|
goals_for: number
|
||||||
|
goals_against: number
|
||||||
|
goal_diff: number
|
||||||
|
points: number
|
||||||
|
xg_for: number | null
|
||||||
|
xg_against: number | null
|
||||||
|
form: string | null
|
||||||
|
zone: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandingsLeague {
|
||||||
|
league_code: string
|
||||||
|
league_name: string
|
||||||
|
season: string
|
||||||
|
retrieved_at: string | null
|
||||||
|
rows: StandingRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStandings(league?: string, season?: string): Promise<{ leagues: StandingsLeague[] }> {
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
if (league) sp.set('league', league)
|
||||||
|
if (season) sp.set('season', season)
|
||||||
|
const qs = sp.toString()
|
||||||
|
return api.get<{ leagues: StandingsLeague[] }>(`${API_BASE}/standings${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
// ── 数据源管理 ──────────────────────────────────────────────────
|
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 数据采集页面(报刊风)
|
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
|
||||||
|
*
|
||||||
|
* 三个采集任务:
|
||||||
|
* events — 比赛日程与比分
|
||||||
|
* standings — 联赛积分榜
|
||||||
|
* stats — 已完赛比赛详细统计回填(xG/射门/控球等)
|
||||||
|
* all — 依次执行以上三项
|
||||||
*
|
*
|
||||||
* 响应式布局: 移动端单列,桌面端双列
|
* 响应式布局: 移动端单列,桌面端双列
|
||||||
*/
|
*/
|
||||||
@@ -9,45 +15,22 @@ import { triggerCollection, fetchLeagues } from '../dal'
|
|||||||
import type { CollectionRequest, League } from '../types'
|
import type { CollectionRequest, League } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
|
|
||||||
const SOURCES = [
|
const TASKS = [
|
||||||
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
|
||||||
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
|
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
|
||||||
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
|
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
|
||||||
|
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** 把采集接口返回摘要成一两行可读文字 */
|
|
||||||
function summarizeResult(res: any, source: string): { title: string; detail: string } {
|
|
||||||
if (res && typeof res === 'object') {
|
|
||||||
if (source === 'bzzoiro' && ('total_inserted' in res || 'total_updated' in res)) {
|
|
||||||
return {
|
|
||||||
title: `采集完成:新增 ${res.total_inserted ?? 0} 条,更新 ${res.total_updated ?? 0} 条`,
|
|
||||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ('count' in res || 'updated' in res) {
|
|
||||||
const parts = [
|
|
||||||
`新增 ${res.count ?? 0}`,
|
|
||||||
`更新 ${res.updated ?? 0}`,
|
|
||||||
`跳过 ${res.skipped ?? 0}`,
|
|
||||||
`未匹配 ${res.unmatched ?? 0}`,
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
title: `采集完成:${parts.join(' / ')}`,
|
|
||||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { title: '采集完成', detail: JSON.stringify(res)?.slice(0, 300) ?? '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CollectionPage() {
|
export default function CollectionPage() {
|
||||||
const [leagues, setLeagues] = useState<League[]>([])
|
const [leagues, setLeagues] = useState<League[]>([])
|
||||||
const [source, setSource] = useState<string>('bzzoiro')
|
const [task, setTask] = useState<string>('events')
|
||||||
const [leagueCode, setLeagueCode] = useState('')
|
const [leagueCode, setLeagueCode] = useState('')
|
||||||
const [dateFrom, setDateFrom] = useState('')
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [season, setSeason] = useState('')
|
const [season, setSeason] = useState('')
|
||||||
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
|
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
|
||||||
|
const [limit, setLimit] = useState(100)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
||||||
@@ -59,6 +42,8 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadLeagues() }, [loadLeagues])
|
useEffect(() => { loadLeagues() }, [loadLeagues])
|
||||||
|
|
||||||
|
const isEventsTask = task === 'events' || task === 'all'
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -67,18 +52,20 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body: CollectionRequest = {
|
const body: CollectionRequest = {
|
||||||
source: source as CollectionRequest['source'],
|
source: 'bzzoiro',
|
||||||
leagues: leagueCode ? [leagueCode] : undefined,
|
leagues: leagueCode ? [leagueCode] : undefined,
|
||||||
league: leagueCode || undefined,
|
task: task as CollectionRequest['task'],
|
||||||
|
limit,
|
||||||
season: season || undefined,
|
season: season || undefined,
|
||||||
status: ingestStatus || undefined,
|
status: ingestStatus || undefined,
|
||||||
date_from: dateFrom || undefined,
|
// 仅 events/all 任务生效
|
||||||
date_to: dateTo || undefined,
|
date_from: isEventsTask ? dateFrom || undefined : undefined,
|
||||||
|
date_to: isEventsTask ? dateTo || undefined : undefined,
|
||||||
}
|
}
|
||||||
await triggerCollection(body)
|
await triggerCollection(body)
|
||||||
setResult({
|
setResult({
|
||||||
title: '采集任务已启动',
|
title: '采集任务已启动',
|
||||||
detail: '正在后台执行(上游限速时可能需要几分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
||||||
})
|
})
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||||
@@ -91,7 +78,7 @@ export default function CollectionPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="数据采集"
|
title="数据采集"
|
||||||
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
|
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
@@ -100,20 +87,26 @@ export default function CollectionPage() {
|
|||||||
<CardHeader title="新建采集任务" />
|
<CardHeader title="新建采集任务" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* 数据源选择 */}
|
{/* 任务类型 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">数据源</label>
|
<label className="mb-1.5 block text-xs text-ink-500">采集任务</label>
|
||||||
<select
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
value={source}
|
{TASKS.map(t => (
|
||||||
onChange={e => setSource(e.target.value)}
|
<button
|
||||||
className="field w-full"
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTask(t.value)}
|
||||||
|
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
||||||
|
task === t.value
|
||||||
|
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||||
|
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{SOURCES.map(s => (
|
<span className="mr-1">{t.icon}</span>
|
||||||
<option key={s.value} value={s.value}>
|
<span className="font-medium">{t.label}</span>
|
||||||
{s.label} — {s.desc}
|
</button>
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 联赛选择 */}
|
{/* 联赛选择 */}
|
||||||
@@ -131,8 +124,9 @@ export default function CollectionPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bzzoiro 专用: 比赛状态 */}
|
{/* events/all 任务专用: 比赛状态 + 日期 */}
|
||||||
{source === 'bzzoiro' && (
|
{isEventsTask && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">比赛状态</label>
|
<label className="mb-1.5 block text-xs text-ink-500">比赛状态</label>
|
||||||
<select
|
<select
|
||||||
@@ -145,24 +139,6 @@ export default function CollectionPage() {
|
|||||||
<option value="scheduled">仅未开赛</option>
|
<option value="scheduled">仅未开赛</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Understat 专用: 赛季 */}
|
|
||||||
{source === 'understat' && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(起始年)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={season}
|
|
||||||
onChange={e => setSeason(e.target.value)}
|
|
||||||
placeholder="2025"
|
|
||||||
className="field w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 日期范围 */}
|
|
||||||
{source !== 'injuries' && (
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||||
@@ -183,6 +159,39 @@ export default function CollectionPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* standings 任务专用: 赛季 */}
|
||||||
|
{(task === 'standings') && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs text-ink-500">赛季(留空取当前赛季)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={season}
|
||||||
|
onChange={e => setSeason(e.target.value)}
|
||||||
|
placeholder="如 2026-2027"
|
||||||
|
className="field w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* stats 任务专用: 回填数量 */}
|
||||||
|
{(task === 'stats') && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs text-ink-500">单次最大回填比赛数(1-500)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
value={limit}
|
||||||
|
onChange={e => setLimit(parseInt(e.target.value) || 100)}
|
||||||
|
className="field w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-2xs text-ink-400">
|
||||||
|
仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 消息提示 */}
|
{/* 消息提示 */}
|
||||||
@@ -206,22 +215,24 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
{/* 数据源说明 */}
|
{/* 数据源说明 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="数据源说明" />
|
<CardHeader title="采集任务说明" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{SOURCES.map(s => (
|
{TASKS.map(t => (
|
||||||
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
<div key={t.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge status="info">{s.label}</Badge>
|
<span>{t.icon}</span>
|
||||||
<p className="text-xs text-ink-600">{s.desc}</p>
|
<Badge status="info">{t.label}</Badge>
|
||||||
|
<p className="text-xs text-ink-600">{t.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||||
采集接口需要管理员登录。
|
采集接口需要管理员登录(401 表示登录已过期)。
|
||||||
遇到 401 表示登录已过期,请重新登录。
|
各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。
|
||||||
|
「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。
|
||||||
</p>
|
</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
|
|||||||
|
|
||||||
/** 工作流步骤卡片 */
|
/** 工作流步骤卡片 */
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ to: '/admin/collection', step: '1', title: '采集数据', desc: '从 bzzoiro / understat 获取赛程与 xG', icon: '◈' },
|
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' },
|
||||||
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
||||||
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
||||||
]
|
]
|
||||||
@@ -42,8 +42,6 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||||
const bzzoiro = sourceByName['bzzoiro']
|
const bzzoiro = sourceByName['bzzoiro']
|
||||||
const understat = sourceByName['understat']
|
|
||||||
const injuries = sourceByName['injuries']
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -83,8 +81,6 @@ export default function Dashboard() {
|
|||||||
<div>
|
<div>
|
||||||
{[
|
{[
|
||||||
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
||||||
{ name: 'understat', label: 'Understat (xG)', st: understat },
|
|
||||||
{ name: 'injuries', label: 'Injuries (伤停)', st: injuries },
|
|
||||||
].map(({ name, label, st }) => {
|
].map(({ name, label, st }) => {
|
||||||
const hasData = st && st.recent_count > 0
|
const hasData = st && st.recent_count > 0
|
||||||
const keyOk = st?.key_configured !== false
|
const keyOk = st?.key_configured !== false
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 数据完整性分析页
|
||||||
|
*
|
||||||
|
* 回答三个问题:
|
||||||
|
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||||
|
* 2. 字段是否齐全(每张统计表各字段非空率)
|
||||||
|
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { fetchDataCompleteness } from '../dal'
|
||||||
|
import type { DataCompletenessResponse } from '../dal'
|
||||||
|
import {
|
||||||
|
Card, CardBody, CardHeader, SectionHeader, Alert,
|
||||||
|
ProgressBar, Spinner, EmptyState,
|
||||||
|
} from '../components'
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
xg: 'xG 预期进球',
|
||||||
|
shots: '射门',
|
||||||
|
possession: '控球率',
|
||||||
|
corners: '角球',
|
||||||
|
fouls: '犯规',
|
||||||
|
big_chances: '绝佳机会',
|
||||||
|
cards: '红黄牌',
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctColor(pct: number): string {
|
||||||
|
if (pct >= 80) return 'bg-emerald-500'
|
||||||
|
if (pct >= 50) return 'bg-amber-500'
|
||||||
|
return 'bg-rose-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataCompletenessPage() {
|
||||||
|
const [data, setData] = useState<DataCompletenessResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const d = await fetchDataCompleteness()
|
||||||
|
setData(d)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="数据完整性"
|
||||||
|
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
|
||||||
|
action={
|
||||||
|
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
|
||||||
|
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="flex justify-center py-12"><Spinner /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{/* 全局概览 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-ink-900">{data.totals.finished_matches}</p>
|
||||||
|
<p className="text-xs text-ink-500">已完赛比赛(总计)</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-ink-900">{data.totals.stats_rows}</p>
|
||||||
|
<p className="text-xs text-ink-500">统计行数</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-ink-900">
|
||||||
|
{data.totals.stats_coverage_pct}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-500">统计覆盖率(有统计 / 已完赛)</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 健康问题 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="健康摘要" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.issues.map((issue, i) => (
|
||||||
|
<Alert
|
||||||
|
key={i}
|
||||||
|
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
|
||||||
|
title={issue.includes('良好') ? '数据良好' : '需要关注'}
|
||||||
|
message={issue}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 各联赛详情 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.leagues.map(league => {
|
||||||
|
const finished = league.matches.finished
|
||||||
|
const statsPct = finished > 0 ? Math.round((league.stats.rows / finished) * 100) : 0
|
||||||
|
return (
|
||||||
|
<Card key={league.code}>
|
||||||
|
<CardHeader
|
||||||
|
title={league.name}
|
||||||
|
description={[
|
||||||
|
league.country,
|
||||||
|
`已完赛 ${finished} 场 / 未开赛 ${league.matches.scheduled} 场`,
|
||||||
|
league.matches.latest_match ? `最近: ${league.matches.latest_match.slice(0, 10)}` : '',
|
||||||
|
league.standings.rows > 0 ? `积分榜 ${league.standings.rows} 队` : '',
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
/>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
{/* 统计覆盖率进度条 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-ink-600">统计回填覆盖率</span>
|
||||||
|
<span className="font-medium text-ink-900">
|
||||||
|
{league.stats.rows} / {finished} ({statsPct}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={statsPct} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 字段覆盖率矩阵 */}
|
||||||
|
{league.stats.rows > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-ink-600">字段覆盖率(有值行数 / 总行数)</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Object.entries(league.stats.fields).map(([key, info]) => (
|
||||||
|
<div key={key} className="rounded border border-ink-100 px-3 py-2">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-ink-600">{FIELD_LABELS[key] ?? key}</span>
|
||||||
|
<span className="text-xs font-medium text-ink-900">{info.pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-100">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${pctColor(info.pct)}`}
|
||||||
|
style={{ width: `${info.pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-2xs text-ink-400">{info.count} / {league.stats.rows} 行</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : finished > 0 ? (
|
||||||
|
<Alert kind="warning" title="缺少统计数据" message="该联赛有已完赛比赛但无统计行,请运行「统计回填」采集。" />
|
||||||
|
) : (
|
||||||
|
<Alert kind="warning" title="缺少比赛数据" message="该联赛暂无已完赛比赛,请运行「比赛数据」采集。" />
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-2xs text-ink-400">
|
||||||
|
生成时间: {new Date(data.generated_at).toLocaleString('zh-CN', { hour12: false })}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ const AGENT_LABELS: Record<string, string> = {
|
|||||||
form: '近期状态分析专家',
|
form: '近期状态分析专家',
|
||||||
stats: '攻防数据分析专家',
|
stats: '攻防数据分析专家',
|
||||||
home_away: '主客因素分析专家',
|
home_away: '主客因素分析专家',
|
||||||
injuries: '阵容完整性分析专家',
|
standings: '联赛排名分析专家',
|
||||||
}
|
}
|
||||||
|
|
||||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Navigate } from 'react-router-dom'
|
|||||||
import AdminLayout from './AdminLayout'
|
import AdminLayout from './AdminLayout'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import CollectionPage from './pages/Collection'
|
import CollectionPage from './pages/Collection'
|
||||||
|
import DataCompletenessPage from './pages/DataCompleteness'
|
||||||
import PredictionsPage from './pages/Predictions'
|
import PredictionsPage from './pages/Predictions'
|
||||||
import BacktestPage from './pages/Backtest'
|
import BacktestPage from './pages/Backtest'
|
||||||
import MonitoringPage from './pages/Monitoring'
|
import MonitoringPage from './pages/Monitoring'
|
||||||
@@ -25,6 +26,7 @@ export const adminRoutes = [
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Dashboard /> },
|
{ index: true, element: <Dashboard /> },
|
||||||
{ path: 'collection', element: <CollectionPage /> },
|
{ path: 'collection', element: <CollectionPage /> },
|
||||||
|
{ path: 'data-completeness', element: <DataCompletenessPage /> },
|
||||||
{ path: 'predictions', element: <PredictionsPage /> },
|
{ path: 'predictions', element: <PredictionsPage /> },
|
||||||
{ path: 'backtest', element: <BacktestPage /> },
|
{ path: 'backtest', element: <BacktestPage /> },
|
||||||
{ path: 'monitoring', element: <MonitoringPage /> },
|
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||||
|
|||||||
@@ -91,9 +91,10 @@ export interface PredictRequest {
|
|||||||
|
|
||||||
export interface CollectionRequest {
|
export interface CollectionRequest {
|
||||||
status?: string
|
status?: string
|
||||||
source: 'bzzoiro' | 'understat' | 'injuries'
|
source: 'bzzoiro'
|
||||||
leagues?: string[]
|
leagues?: string[]
|
||||||
league?: string
|
task?: 'events' | 'standings' | 'stats' | 'all'
|
||||||
|
limit?: number
|
||||||
season?: string
|
season?: string
|
||||||
date_from?: string
|
date_from?: string
|
||||||
date_to?: string
|
date_to?: string
|
||||||
@@ -318,9 +319,31 @@ export interface MatchDetailOut {
|
|||||||
match_stage: string | null
|
match_stage: string | null
|
||||||
home_xg: number | null
|
home_xg: number | null
|
||||||
away_xg: number | null
|
away_xg: number | null
|
||||||
|
stats: MatchStatsDetail | null
|
||||||
recent_predictions: MatchRecentPrediction[]
|
recent_predictions: MatchRecentPrediction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */
|
||||||
|
export interface MatchStatsDetail {
|
||||||
|
home_xg: number | null
|
||||||
|
away_xg: number | null
|
||||||
|
home_shots: number | null
|
||||||
|
away_shots: number | null
|
||||||
|
home_shots_on_target: number | null
|
||||||
|
away_shots_on_target: number | null
|
||||||
|
home_corners: number | null
|
||||||
|
away_corners: number | null
|
||||||
|
home_possession: number | null
|
||||||
|
home_yellow_cards: number | null
|
||||||
|
away_yellow_cards: number | null
|
||||||
|
home_red_cards: number | null
|
||||||
|
away_red_cards: number | null
|
||||||
|
home_big_chances: number | null
|
||||||
|
away_big_chances: number | null
|
||||||
|
home_fouls: number | null
|
||||||
|
away_fouls: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamRecentMatch {
|
export interface TeamRecentMatch {
|
||||||
match_date: string | null
|
match_date: string | null
|
||||||
home_team: string | null
|
home_team: string | null
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import TeamSideTag from '../components/TeamSideTag'
|
import TeamSideTag from '../components/TeamSideTag'
|
||||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types'
|
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types'
|
||||||
|
import type { MatchStatsDetail } from '../admin/types'
|
||||||
|
|
||||||
interface Match {
|
interface Match {
|
||||||
id: number
|
id: number
|
||||||
@@ -63,7 +64,7 @@ const AGENT_LABELS: Record<string, string> = {
|
|||||||
form: '近期状态分析专家',
|
form: '近期状态分析专家',
|
||||||
stats: '攻防数据分析专家',
|
stats: '攻防数据分析专家',
|
||||||
home_away: '主客因素分析专家',
|
home_away: '主客因素分析专家',
|
||||||
injuries: '阵容完整性分析专家',
|
standings: '联赛排名分析专家',
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEAGUES = [
|
const LEAGUES = [
|
||||||
@@ -679,7 +680,7 @@ function PredictProgress() {
|
|||||||
const AGENT_START = 4
|
const AGENT_START = 4
|
||||||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||||||
const AGG_START = AGENT_START + AGENT_STEP * 5
|
const AGG_START = AGENT_START + AGENT_STEP * 5
|
||||||
const agents = ['form', 'stats', 'home_away', 'injuries', 'h2h']
|
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
|
||||||
|
|
||||||
const phase = elapsed < SLICE_END ? 'slice'
|
const phase = elapsed < SLICE_END ? 'slice'
|
||||||
: elapsed < AGG_START ? 'agents' : 'agg'
|
: elapsed < AGG_START ? 'agents' : 'agg'
|
||||||
@@ -1114,6 +1115,11 @@ function MatchDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 比赛详细统计(bzzoiro /events/{id}/stats/) */}
|
||||||
|
{detail?.stats && (
|
||||||
|
<MatchStatsPanel stats={detail.stats} homeName={homeName} awayName={awayName} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 双方近况 + H2H */}
|
{/* 双方近况 + H2H */}
|
||||||
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
@@ -1144,6 +1150,80 @@ function MatchDetailPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 比赛详细统计面板(bzzoiro /events/{id}/stats/) */
|
||||||
|
function MatchStatsPanel({
|
||||||
|
stats, homeName, awayName,
|
||||||
|
}: { stats: MatchStatsDetail; homeName: string; awayName: string }) {
|
||||||
|
const rows: Array<{ label: string; home: number | null; away: number | null; highlight?: 'high' | 'low' }> = [
|
||||||
|
{ label: '预期进球(xG)', home: stats.home_xg, away: stats.away_xg },
|
||||||
|
{ label: '射门', home: stats.home_shots, away: stats.away_shots },
|
||||||
|
{ label: '射正', home: stats.home_shots_on_target, away: stats.away_shots_on_target },
|
||||||
|
{ label: '角球', home: stats.home_corners, away: stats.away_corners },
|
||||||
|
{ label: '犯规', home: stats.home_fouls, away: stats.away_fouls },
|
||||||
|
{ label: '绝佳机会', home: stats.home_big_chances, away: stats.away_big_chances },
|
||||||
|
{ label: '黄牌', home: stats.home_yellow_cards, away: stats.away_yellow_cards },
|
||||||
|
{ label: '红牌', home: stats.home_red_cards, away: stats.away_red_cards },
|
||||||
|
]
|
||||||
|
const hasAny = rows.some(r => r.home != null || r.away != null)
|
||||||
|
if (!hasAny) return null
|
||||||
|
|
||||||
|
// 控球率用横条展示
|
||||||
|
const possHome = stats.home_possession
|
||||||
|
const possAway = possHome != null ? Math.max(0, 100 - possHome) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="section-head mb-2">比赛统计</h4>
|
||||||
|
|
||||||
|
{/* 控球率横条 */}
|
||||||
|
{possHome != null && possAway != null && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="mb-1 flex justify-between text-2xs text-ink-500">
|
||||||
|
<span>{possHome.toFixed(0)}%</span>
|
||||||
|
<span className="text-ink-400">控球率</span>
|
||||||
|
<span>{possAway.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-1.5 overflow-hidden rounded-full bg-ink-200">
|
||||||
|
<div className="bg-ink-700 transition-[width] duration-500" style={{ width: `${possHome}%` }} />
|
||||||
|
<div className="bg-ink-300 transition-[width] duration-500" style={{ width: `${possAway}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主客对比表 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-ink-400">
|
||||||
|
<th className="py-1.5 text-left font-medium">{homeName}</th>
|
||||||
|
<th className="py-1.5 text-center font-medium text-ink-500">统计项</th>
|
||||||
|
<th className="py-1.5 text-right font-medium">{awayName}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.filter(r => r.home != null || r.away != null).map(r => {
|
||||||
|
const h = r.home ?? 0
|
||||||
|
const a = r.away ?? 0
|
||||||
|
const winner = h > a ? 'home' : h < a ? 'away' : 'tie'
|
||||||
|
return (
|
||||||
|
<tr key={r.label} className="border-b border-ink-100">
|
||||||
|
<td className={`py-1.5 text-right tabular-nums ${winner === 'home' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||||||
|
{r.home ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-center text-ink-500">{r.label}</td>
|
||||||
|
<td className={`py-1.5 text-left tabular-nums ${winner === 'away' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||||||
|
{r.away ?? '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** 近况/H2H 单区块 */
|
/** 近况/H2H 单区块 */
|
||||||
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 主站 - 联赛积分榜页
|
||||||
|
*
|
||||||
|
* 展示各联赛最新积分榜(位置/积分/净胜/xG差/近期走势/分区),
|
||||||
|
* 数据来自 bzzoiro /leagues/{id}/standings/ 管线采集。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { fetchStandings } from '../admin/dal'
|
||||||
|
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||||
|
|
||||||
|
const LEAGUES = [
|
||||||
|
{ code: 'E0', name: '英超' },
|
||||||
|
{ code: 'SP1', name: '西甲' },
|
||||||
|
{ code: 'D1', name: '德甲' },
|
||||||
|
{ code: 'I1', name: '意甲' },
|
||||||
|
{ code: 'F1', name: '法甲' },
|
||||||
|
{ code: 'CL', name: '欧冠' },
|
||||||
|
{ code: 'EL', name: '欧联' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||||
|
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
|
||||||
|
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneBadge(zone?: string | null) {
|
||||||
|
if (!zone) return null
|
||||||
|
const meta = ZONE_META[zone] ?? { label: zone, cls: 'bg-ink-100 text-ink-600' }
|
||||||
|
return <span className={`rounded px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||||
|
function FormDots({ form }: { form?: string | null }) {
|
||||||
|
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||||||
|
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
|
||||||
|
return (
|
||||||
|
<span className="inline-flex gap-0.5">
|
||||||
|
{form.slice(0, 5).split('').map((c, i) => (
|
||||||
|
<span key={i} className={`inline-block h-1.5 w-1.5 rounded-full ${colorMap[c] ?? 'bg-ink-200'}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StandingsPage() {
|
||||||
|
const [leagues, setLeagues] = useState<StandingsLeague[]>([])
|
||||||
|
const [activeLeague, setActiveLeague] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async (code?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await fetchStandings(code)
|
||||||
|
setLeagues(data.leagues)
|
||||||
|
if (!activeLeague && data.leagues.length > 0) {
|
||||||
|
setActiveLeague(data.leagues[0].league_code)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [activeLeague])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const active = leagues.find(l => l.league_code === activeLeague) ?? leagues[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 联赛切换 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{LEAGUES.map(l => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
onClick={() => { setActiveLeague(l.code); load(l.code) }}
|
||||||
|
className={`rounded border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
activeLeague === l.code
|
||||||
|
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||||
|
: 'border-ink-200 text-ink-500 hover:border-ink-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-12 text-xs text-ink-400">加载中…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !active && (
|
||||||
|
<div className="border-y border-ink-200 py-12 text-center">
|
||||||
|
<p className="font-serif text-sm text-ink-600">暂无积分榜数据</p>
|
||||||
|
<p className="mt-1.5 text-xs text-ink-400">
|
||||||
|
请先在管理后台「数据采集」页运行「积分榜」任务。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between border-b border-ink-200 pb-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-lg font-bold text-ink-900">
|
||||||
|
{active.league_name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
{active.season} 赛季 · {active.rows.length} 队
|
||||||
|
{active.retrieved_at && ` · 更新于 ${new Date(active.retrieved_at).toLocaleDateString('zh-CN')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 积分榜表格 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-left text-ink-400">
|
||||||
|
<th className="w-8 py-2 font-medium">#</th>
|
||||||
|
<th className="py-2 font-medium">球队</th>
|
||||||
|
<th className="w-10 text-center py-2 font-medium">赛</th>
|
||||||
|
<th className="w-10 text-center py-2 font-medium">胜</th>
|
||||||
|
<th className="w-10 text-center py-2 font-medium">平</th>
|
||||||
|
<th className="w-10 text-center py-2 font-medium">负</th>
|
||||||
|
<th className="w-12 text-center py-2 font-medium">进/失</th>
|
||||||
|
<th className="w-12 text-center py-2 font-medium">净</th>
|
||||||
|
<th className="w-14 text-center py-2 font-medium">积分</th>
|
||||||
|
<th className="w-16 text-center py-2 font-medium">xG±</th>
|
||||||
|
<th className="w-20 text-center py-2 font-medium">走势</th>
|
||||||
|
<th className="w-16 text-right py-2 font-medium">区</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{active.rows.map((r: StandingRow) => (
|
||||||
|
<tr key={r.position} className="border-b border-ink-100 hover:bg-paper-100">
|
||||||
|
<td className="py-2 font-medium text-ink-700">{r.position}</td>
|
||||||
|
<td className="py-2 font-medium text-ink-900">{r.team}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.played}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.won}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
||||||
|
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
|
||||||
|
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">
|
||||||
|
{r.xg_for != null && r.xg_against != null
|
||||||
|
? `${(r.xg_for - r.xg_against).toFixed(1)}`
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2"><div className="flex justify-center"><FormDots form={r.form} /></div></td>
|
||||||
|
<td className="py-2 text-right">{zoneBadge(r.zone)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,33 +28,21 @@ from src.core.runtime_config import (
|
|||||||
set_runtime_value,
|
set_runtime_value,
|
||||||
)
|
)
|
||||||
from src.db.base import AsyncSession, get_db_read
|
from src.db.base import AsyncSession, get_db_read
|
||||||
from src.db.models import Injury, Match, MatchStats
|
from src.db.models import League, Match, MatchStats, Standing
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
# ── 数据源元数据 ────────────────────────────────────────────────
|
# ── 数据源元数据(bzzoiro 单一数据源) ────────────────────────────
|
||||||
|
|
||||||
_SOURCES: list[dict] = [
|
_SOURCES: list[dict] = [
|
||||||
{
|
{
|
||||||
"name": "bzzoiro",
|
"name": "bzzoiro",
|
||||||
"label": "Bzzoiro",
|
"label": "Bzzoiro",
|
||||||
"description": "历史赛程与比分数据,覆盖全球主要联赛",
|
"description": "唯一数据源:赛程比分 + 积分榜 + 比赛详细统计(xG/射门/控球等)",
|
||||||
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
|
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "understat",
|
|
||||||
"label": "Understat",
|
|
||||||
"description": "xG(预期进球)进阶数据,无需 API Key,网页抓取",
|
|
||||||
"setting_keys": [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "injuries",
|
|
||||||
"label": "Injuries (API-Football)",
|
|
||||||
"description": "球员伤停信息,用于预测时考虑阵容完整性",
|
|
||||||
"setting_keys": ["API_FOOTBALL_KEY"],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -63,9 +51,7 @@ class SettingUpdateIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
|
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
|
||||||
"""各源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
"""源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
||||||
if source == "injuries":
|
|
||||||
return (await db.execute(select(func.max(Injury.retrieved_at)))).scalar()
|
|
||||||
return (
|
return (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
|
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
|
||||||
@@ -303,20 +289,7 @@ async def test_datasource(name: str):
|
|||||||
params={"date_from": today, "date_to": today},
|
params={"date_from": today, "date_to": today},
|
||||||
)
|
)
|
||||||
|
|
||||||
if name == "understat":
|
raise HTTPException(404, f"未知数据源: {name}")
|
||||||
return await _probe(
|
|
||||||
"https://understat.com/league/EPL/2025",
|
|
||||||
headers={"User-Agent": "Mozilla/5.0", "Accept": "text/html"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# injuries (api-football)
|
|
||||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
||||||
if not api_key:
|
|
||||||
return {"ok": False, "status": None, "latency_ms": 0, "detail": "API_FOOTBALL_KEY 未配置"}
|
|
||||||
return await _probe(
|
|
||||||
"https://v3.football.api-sports.io/status",
|
|
||||||
headers={"x-apisports-key": api_key},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
|
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
|
||||||
@@ -324,16 +297,12 @@ async def test_datasource(name: str):
|
|||||||
|
|
||||||
@router.get("/ingest/status")
|
@router.get("/ingest/status")
|
||||||
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
||||||
"""各数据源采集健康概览(只读,不触发任何采集)。
|
"""数据源采集健康概览(bzzoiro 单源;只读,不触发任何采集)。"""
|
||||||
|
|
||||||
返回尽量可得的信息;基于现有表近似的数据会标明 approximation。
|
|
||||||
失败追踪目前依赖系统日志缓冲,无专用采集失败表。
|
|
||||||
"""
|
|
||||||
# ── bzzoiro: 落库目标是 matches 表,无专用采集时间戳 ──
|
|
||||||
# 近似:以 matches 表最大 match_date(已覆盖的最远比赛日) 与 created_at 作为参考
|
|
||||||
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
|
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
|
||||||
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
|
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
|
||||||
row = (
|
|
||||||
|
# 比赛覆盖
|
||||||
|
match_row = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(
|
select(
|
||||||
func.count().label("cnt"),
|
func.count().label("cnt"),
|
||||||
@@ -342,68 +311,39 @@ async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
|||||||
).where(Match.match_status == "finished")
|
).where(Match.match_status == "finished")
|
||||||
)
|
)
|
||||||
).one()
|
).one()
|
||||||
|
# 统计覆盖(精确 retrieved_at)
|
||||||
|
stats_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("cnt"),
|
||||||
|
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
||||||
|
).where(MatchStats.source == "bzzoiro")
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
# 积分榜覆盖
|
||||||
|
standings_row = (
|
||||||
|
await db.execute(select(func.count()).select_from(Standing))
|
||||||
|
).scalar()
|
||||||
|
|
||||||
bzzoiro = {
|
bzzoiro = {
|
||||||
"name": "bzzoiro",
|
"name": "bzzoiro",
|
||||||
"label": "Bzzoiro",
|
"label": "Bzzoiro",
|
||||||
"key_configured": bool(bzzoiro_key),
|
"key_configured": bool(bzzoiro_key),
|
||||||
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
|
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
|
||||||
"reachable": None, # 不主动探测
|
"reachable": None, # 不主动探测
|
||||||
"last_success_at": row.latest_row_at.isoformat() if row.latest_row_at else None,
|
"last_success_at": (stats_row.latest_retrieved or match_row.latest_row_at),
|
||||||
"latest_match_date": row.latest_match_date.isoformat() if row.latest_match_date else None,
|
"last_success_at_iso": (
|
||||||
"recent_count": row.cnt or 0,
|
stats_row.latest_retrieved or match_row.latest_row_at
|
||||||
"note": "approx:基于 matches.finished 表,last_success_at 为行写入时间而非精确采集完成时间",
|
).isoformat() if (stats_row.latest_retrieved or match_row.latest_row_at) else None,
|
||||||
|
"latest_match_date": match_row.latest_match_date.isoformat() if match_row.latest_match_date else None,
|
||||||
|
"recent_count": match_row.cnt or 0,
|
||||||
|
"stats_count": stats_row.cnt or 0,
|
||||||
|
"standings_count": standings_row or 0,
|
||||||
|
"note": "last_success_at 取 match_stats.retrieved_at(统计回填)与 matches.created_at(比赛行)的较大者",
|
||||||
"last_failure": _last_failure_log("bzzoiro"),
|
"last_failure": _last_failure_log("bzzoiro"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── understat: 落库到 match_stats(source=understat),有精确 retrieved_at ──
|
return {"sources": [bzzoiro]}
|
||||||
row = (
|
|
||||||
await db.execute(
|
|
||||||
select(
|
|
||||||
func.count().label("cnt"),
|
|
||||||
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
|
||||||
).where(MatchStats.source == "understat")
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
understat = {
|
|
||||||
"name": "understat",
|
|
||||||
"label": "Understat",
|
|
||||||
"key_configured": True, # 无需 Key
|
|
||||||
"reachable": None,
|
|
||||||
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
|
||||||
"recent_count": row.cnt or 0,
|
|
||||||
"note": "基于 match_stats.source=understat 的 retrieved_at",
|
|
||||||
"last_failure": _last_failure_log("understat"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── injuries: 落库到 injuries 表,有精确 retrieved_at;区分 Key/无数据/有数据 ──
|
|
||||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
||||||
row = (
|
|
||||||
await db.execute(
|
|
||||||
select(
|
|
||||||
func.count().label("cnt"),
|
|
||||||
func.max(Injury.retrieved_at).label("latest_retrieved"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
if not api_key:
|
|
||||||
injuries_status, injuries_note = "key_not_configured", "API_FOOTBALL_KEY 未配置"
|
|
||||||
elif not row.cnt:
|
|
||||||
injuries_status, injuries_note = "no_data", "本地无伤停数据,请先采集"
|
|
||||||
else:
|
|
||||||
injuries_status, injuries_note = "has_data", f"共 {row.cnt} 条伤停记录"
|
|
||||||
injuries = {
|
|
||||||
"name": "injuries",
|
|
||||||
"label": "Injuries (API-Football)",
|
|
||||||
"key_configured": bool(api_key),
|
|
||||||
"reachable": None,
|
|
||||||
"status": injuries_status,
|
|
||||||
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
|
||||||
"recent_count": row.cnt or 0,
|
|
||||||
"note": injuries_note,
|
|
||||||
"last_failure": _last_failure_log("injuries"),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"sources": [bzzoiro, understat, injuries]}
|
|
||||||
|
|
||||||
|
|
||||||
def _last_failure_log(source: str) -> dict | None:
|
def _last_failure_log(source: str) -> dict | None:
|
||||||
@@ -437,3 +377,130 @@ async def admin_stats(db: AsyncSession = Depends(get_db_read)):
|
|||||||
)
|
)
|
||||||
).one()
|
).one()
|
||||||
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}}
|
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据完整性分析(可视化数据源) ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data-completeness")
|
||||||
|
async def data_completeness(db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""按联赛统计数据完整性:比赛覆盖、字段覆盖、积分榜覆盖。
|
||||||
|
|
||||||
|
前端「数据完整性」页据此渲染,回答三个问题:
|
||||||
|
1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||||
|
2. 字段是否齐全(每张统计表各字段非空率)
|
||||||
|
3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||||
|
"""
|
||||||
|
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_NAMES, LEAGUE_COUNTRIES
|
||||||
|
|
||||||
|
out_leagues: list[dict] = []
|
||||||
|
for code, bzz_id in BZZOIRO_LEAGUE_IDS.items():
|
||||||
|
# 比赛覆盖
|
||||||
|
m = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("total"),
|
||||||
|
func.count().filter(Match.match_status == "finished").label("finished"),
|
||||||
|
func.count().filter(Match.match_status == "scheduled").label("scheduled"),
|
||||||
|
func.count().filter(Match.source_event_id.is_not(None)).label("with_source_id"),
|
||||||
|
func.max(Match.match_date).label("latest_match"),
|
||||||
|
func.min(Match.match_date).label("earliest_match"),
|
||||||
|
)
|
||||||
|
.select_from(Match)
|
||||||
|
.join(League, League.id == Match.league_id)
|
||||||
|
.where(League.code == code)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
# 统计字段覆盖(联表 matches)
|
||||||
|
s = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("rows"),
|
||||||
|
func.count(MatchStats.home_xg).label("xg"),
|
||||||
|
func.count(MatchStats.home_shots).label("shots"),
|
||||||
|
func.count(MatchStats.home_possession).label("possession"),
|
||||||
|
func.count(MatchStats.home_corners).label("corners"),
|
||||||
|
func.count(MatchStats.home_fouls).label("fouls"),
|
||||||
|
func.count(MatchStats.home_big_chances).label("big_chances"),
|
||||||
|
func.count(MatchStats.home_yellow_cards).label("cards"),
|
||||||
|
)
|
||||||
|
.select_from(MatchStats)
|
||||||
|
.join(Match, Match.id == MatchStats.match_id)
|
||||||
|
.join(League, League.id == Match.league_id)
|
||||||
|
.where(League.code == code)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
# 积分榜覆盖
|
||||||
|
st = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("rows"),
|
||||||
|
func.max(Standing.retrieved_at).label("latest_retrieved"),
|
||||||
|
)
|
||||||
|
.select_from(Standing)
|
||||||
|
.join(League, League.id == Standing.league_id)
|
||||||
|
.where(League.code == code)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
|
||||||
|
stats_rows = s.rows or 0
|
||||||
|
pct = lambda n: round(n / stats_rows * 100, 1) if stats_rows else 0.0 # noqa: E731
|
||||||
|
out_leagues.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"name": LEAGUE_NAMES.get(code, code),
|
||||||
|
"country": LEAGUE_COUNTRIES.get(code),
|
||||||
|
"matches": {
|
||||||
|
"total": m.total or 0,
|
||||||
|
"finished": m.finished or 0,
|
||||||
|
"scheduled": m.scheduled or 0,
|
||||||
|
"with_source_id": m.with_source_id or 0,
|
||||||
|
"earliest_match": m.earliest_match.isoformat() if m.earliest_match else None,
|
||||||
|
"latest_match": m.latest_match.isoformat() if m.latest_match else None,
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"rows": stats_rows,
|
||||||
|
"fields": {
|
||||||
|
"xg": {"count": s.xg or 0, "pct": pct(s.xg or 0)},
|
||||||
|
"shots": {"count": s.shots or 0, "pct": pct(s.shots or 0)},
|
||||||
|
"possession": {"count": s.possession or 0, "pct": pct(s.possession or 0)},
|
||||||
|
"corners": {"count": s.corners or 0, "pct": pct(s.corners or 0)},
|
||||||
|
"fouls": {"count": s.fouls or 0, "pct": pct(s.fouls or 0)},
|
||||||
|
"big_chances": {"count": s.big_chances or 0, "pct": pct(s.big_chances or 0)},
|
||||||
|
"cards": {"count": s.cards or 0, "pct": pct(s.cards or 0)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"standings": {
|
||||||
|
"rows": st.rows or 0,
|
||||||
|
"latest_retrieved": st.latest_retrieved.isoformat() if st.latest_retrieved else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 整体健康信号
|
||||||
|
total_finished = sum(l["matches"]["finished"] for l in out_leagues)
|
||||||
|
total_stats = sum(l["stats"]["rows"] for l in out_leagues)
|
||||||
|
stats_coverage = round(total_stats / total_finished * 100, 1) if total_finished else 0.0
|
||||||
|
issues: list[str] = []
|
||||||
|
for l in out_leagues:
|
||||||
|
if l["matches"]["finished"] == 0:
|
||||||
|
issues.append(f"{l['name']}: 无已完赛比赛,请先运行「比赛数据」采集")
|
||||||
|
elif l["stats"]["rows"] == 0:
|
||||||
|
issues.append(f"{l['name']}: 已完赛 {l['matches']['finished']} 场但无统计回填,请运行「统计回填」采集")
|
||||||
|
elif stats_coverage < 80:
|
||||||
|
issues.append(f"{l['name']}: 统计覆盖率仅 {stats_coverage}%,建议增量回填")
|
||||||
|
if l["standings"]["rows"] == 0:
|
||||||
|
issues.append(f"{l['name']}: 无积分榜数据,请运行「积分榜」采集")
|
||||||
|
if not issues:
|
||||||
|
issues.append("各联赛数据完整度良好")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"leagues": out_leagues,
|
||||||
|
"totals": {
|
||||||
|
"finished_matches": total_finished,
|
||||||
|
"stats_rows": total_stats,
|
||||||
|
"stats_coverage_pct": stats_coverage,
|
||||||
|
},
|
||||||
|
"issues": issues,
|
||||||
|
}
|
||||||
|
|||||||
+38
-65
@@ -1,4 +1,11 @@
|
|||||||
"""采集路由。"""
|
"""采集路由(bzzoiro 单一数据源)。
|
||||||
|
|
||||||
|
任务类型:
|
||||||
|
events — 比赛日程/比分(/events/)
|
||||||
|
standings — 联赛积分榜(/leagues/{id}/standings/)
|
||||||
|
stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||||
|
all — 依次执行以上三项
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -7,10 +14,10 @@ import logging
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from src.api.deps import require_admin
|
from src.api.deps import require_admin
|
||||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
from src.api.schemas import IngestBzzoiroRequest
|
||||||
from src.data.config import BZZOIRO_LEAGUE_IDS, FDCO_TO_UNDERSTAT
|
from src.data.config import BZZOIRO_LEAGUE_IDS
|
||||||
|
from src.data.bzzoiro import ingest_bzzoiro_event_stats, ingest_bzzoiro_standings
|
||||||
from src.data.sources import get_source
|
from src.data.sources import get_source
|
||||||
from src.data.injuries import ingest_injuries
|
|
||||||
from src.db.unit_of_work import get_uow
|
from src.db.unit_of_work import get_uow
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -20,6 +27,8 @@ router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
|||||||
# 后台采集任务注册表:持强引用防止被 GC
|
# 后台采集任务注册表:持强引用防止被 GC
|
||||||
_background_tasks: set[asyncio.Task] = set()
|
_background_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
VALID_TASKS = {"events", "standings", "stats", "all"}
|
||||||
|
|
||||||
|
|
||||||
def _spawn(coro) -> None:
|
def _spawn(coro) -> None:
|
||||||
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
||||||
@@ -30,30 +39,30 @@ def _spawn(coro) -> None:
|
|||||||
|
|
||||||
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
|
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
|
||||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||||
"""触发 bzzoiro 采集。"""
|
"""触发 bzzoiro 采集(events / standings / stats / all)。"""
|
||||||
# 未指定联赛 = 采集全部已知联赛;未指定状态 = 已完赛 + 未开赛都采集
|
if req.task not in VALID_TASKS:
|
||||||
|
raise HTTPException(status_code=422, detail=f"未知任务类型: {req.task}(可选: {', '.join(sorted(VALID_TASKS))})")
|
||||||
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
|
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
|
||||||
statuses = [req.status] if req.status else ["finished", "scheduled"]
|
task_label = {"events": "比赛数据", "standings": "积分榜", "stats": "统计回填", "all": "全量(比赛+积分榜+统计)"}[req.task]
|
||||||
_spawn(_run_bzzoiro(leagues, req.date_from, req.date_to, statuses))
|
_spawn(_run_bzzoiro(req.task, leagues, req))
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"采集任务已启动(后台执行,状态: {', '.join(statuses)}),请在「系统日志」查看进度与结果",
|
"message": f"采集任务已启动(后台执行,任务: {task_label}),请在「系统日志」查看进度与结果",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str | None, statuses: list[str]) -> None:
|
async def _run_bzzoiro(task: str, leagues: list[str], req: IngestBzzoiroRequest) -> None:
|
||||||
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
|
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
|
||||||
try:
|
try:
|
||||||
|
if task in ("events", "all"):
|
||||||
|
statuses = [req.status] if req.status else ["finished", "scheduled"]
|
||||||
source = get_source("bzzoiro")
|
source = get_source("bzzoiro")
|
||||||
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||||
async with get_uow() as session:
|
async with get_uow() as session:
|
||||||
for st in statuses:
|
for st in statuses:
|
||||||
r = await source.ingest(
|
r = await source.ingest(
|
||||||
session,
|
session, leagues=leagues,
|
||||||
leagues=leagues,
|
date_from=req.date_from, date_to=req.date_to, status=st,
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
status=st,
|
|
||||||
)
|
)
|
||||||
merged["total_inserted"] += r.get("total_inserted", 0)
|
merged["total_inserted"] += r.get("total_inserted", 0)
|
||||||
merged["total_updated"] += r.get("total_updated", 0)
|
merged["total_updated"] += r.get("total_updated", 0)
|
||||||
@@ -63,63 +72,27 @@ async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str |
|
|||||||
acc["inserted"] += stat.get("inserted", 0)
|
acc["inserted"] += stat.get("inserted", 0)
|
||||||
acc["updated"] += stat.get("updated", 0)
|
acc["updated"] += stat.get("updated", 0)
|
||||||
acc["errors"].extend(stat.get("errors", []))
|
acc["errors"].extend(stat.get("errors", []))
|
||||||
league_errors = {c: stat["errors"] for c, stat in merged["leagues"].items() if stat.get("errors")}
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"bzzoiro 采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
||||||
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
||||||
)
|
)
|
||||||
if league_errors:
|
|
||||||
sample = {c: errs[:1] for c, errs in list(league_errors.items())[:3]}
|
|
||||||
logger.warning("bzzoiro 部分联赛存在错误: %s", sample)
|
|
||||||
if merged["errors"]:
|
if merged["errors"]:
|
||||||
logger.warning("bzzoiro 采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
logger.warning("bzzoiro 比赛采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
||||||
logger.debug("bzzoiro 采集明细: leagues=%s", list(merged["leagues"].keys()))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("bzzoiro 采集任务失败")
|
|
||||||
|
|
||||||
|
if task in ("standings", "all"):
|
||||||
@router.post("/ingest/understat", dependencies=[Depends(require_admin)])
|
|
||||||
async def ingest_understat_route(req: IngestUnderstatRequest):
|
|
||||||
"""触发 understat xG 回填。"""
|
|
||||||
leagues_to_run = [req.league] if req.league else list(FDCO_TO_UNDERSTAT.keys())
|
|
||||||
_spawn(_run_understat(leagues_to_run, req.season))
|
|
||||||
return {"ok": True, "message": "xG 回填任务已启动(后台执行),请在「系统日志」查看结果"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_understat(leagues_to_run: list[str], season: int) -> None:
|
|
||||||
try:
|
|
||||||
source = get_source("understat")
|
|
||||||
merged: dict = {"count": 0, "updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
|
||||||
async with get_uow() as session:
|
async with get_uow() as session:
|
||||||
for league in leagues_to_run:
|
r = await ingest_bzzoiro_standings(session, leagues=leagues, season=req.season)
|
||||||
r = await source.ingest(session, league=league, season=season)
|
if r["errors"]:
|
||||||
for k in ("count", "updated", "skipped", "unmatched"):
|
logger.warning("bzzoiro 积分榜采集部分失败: %s", r["errors"][:3])
|
||||||
merged[k] += r.get(k, 0)
|
else:
|
||||||
merged["errors"].extend(r.get("errors", []))
|
logger.info("bzzoiro 积分榜采集完成: upsert %d 条", r["total_upserted"])
|
||||||
logger.info(
|
|
||||||
"understat 回填完成: 联赛 %d 个, 更新 %d, 未匹配 %d, 错误 %d",
|
|
||||||
len(leagues_to_run), merged["updated"], merged["unmatched"], len(merged["errors"]),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("understat 回填任务失败")
|
|
||||||
|
|
||||||
|
if task in ("stats", "all"):
|
||||||
@router.post("/ingest/injuries", dependencies=[Depends(require_admin)])
|
|
||||||
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
|
||||||
"""触发伤停采集。"""
|
|
||||||
_spawn(_run_injuries(req.date))
|
|
||||||
return {"ok": True, "message": "伤停采集任务已启动(后台执行),请在「系统日志」查看结果"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_injuries(date: str | None) -> None:
|
|
||||||
try:
|
|
||||||
async with get_uow() as session:
|
async with get_uow() as session:
|
||||||
result = await ingest_injuries(session, date=date)
|
r = await ingest_bzzoiro_event_stats(
|
||||||
logger.info(
|
session, leagues=leagues, limit=req.limit, only_missing=True
|
||||||
"injuries 采集完成: 新增 %d, 更新 %d, 错误 %d",
|
|
||||||
result.get("count", 0), result.get("updated", 0), len(result.get("errors", [])),
|
|
||||||
)
|
)
|
||||||
if result.get("errors"):
|
if r["errors"]:
|
||||||
logger.warning("injuries 采集错误: %s", result["errors"][:3])
|
logger.warning("bzzoiro 统计回填错误 %d 条: %s", len(r["errors"]), r["errors"][:3])
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("injuries 采集任务失败")
|
logger.exception("bzzoiro 采集任务失败(task=%s)", task)
|
||||||
|
|||||||
@@ -10,11 +10,28 @@ from sqlalchemy.orm import selectinload
|
|||||||
from src.api.deps import require_admin
|
from src.api.deps import require_admin
|
||||||
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
|
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
|
||||||
from src.db.base import AsyncSession, get_db_read
|
from src.db.base import AsyncSession, get_db_read
|
||||||
from src.db.models import League, Match, Prediction
|
from src.db.models import League, Match, Prediction, Standing
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["data"])
|
router = APIRouter(prefix="/api/v1", tags=["data"])
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_dict(stats) -> dict | None:
|
||||||
|
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
|
||||||
|
if stats is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"home_xg": stats.home_xg, "away_xg": stats.away_xg,
|
||||||
|
"home_shots": stats.home_shots, "away_shots": stats.away_shots,
|
||||||
|
"home_shots_on_target": stats.home_shots_on_target, "away_shots_on_target": stats.away_shots_on_target,
|
||||||
|
"home_corners": stats.home_corners, "away_corners": stats.away_corners,
|
||||||
|
"home_possession": stats.home_possession,
|
||||||
|
"home_yellow_cards": stats.home_yellow_cards, "away_yellow_cards": stats.away_yellow_cards,
|
||||||
|
"home_red_cards": stats.home_red_cards, "away_red_cards": stats.away_red_cards,
|
||||||
|
"home_big_chances": stats.home_big_chances, "away_big_chances": stats.away_big_chances,
|
||||||
|
"home_fouls": stats.home_fouls, "away_fouls": stats.away_fouls,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
|
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
|
||||||
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
||||||
stmt = select(League).order_by(League.name)
|
stmt = select(League).order_by(League.name)
|
||||||
@@ -155,6 +172,7 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
|||||||
match_stage=m.match_stage,
|
match_stage=m.match_stage,
|
||||||
home_xg=m.stats.home_xg if m.stats else None,
|
home_xg=m.stats.home_xg if m.stats else None,
|
||||||
away_xg=m.stats.away_xg if m.stats else None,
|
away_xg=m.stats.away_xg if m.stats else None,
|
||||||
|
stats=_stats_dict(m.stats) if m.stats else None,
|
||||||
recent_predictions=[
|
recent_predictions=[
|
||||||
PredictionOut(
|
PredictionOut(
|
||||||
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
|
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
|
||||||
@@ -246,3 +264,81 @@ async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
|||||||
"away_recent": [_row_to_dict(r) for r in away_recent],
|
"away_recent": [_row_to_dict(r) for r in away_recent],
|
||||||
"h2h": [_row_to_dict(r) for r in h2h],
|
"h2h": [_row_to_dict(r) for r in h2h],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/standings")
|
||||||
|
async def list_standings(
|
||||||
|
league: str | None = Query(None, description="联赛代码,如 E0;空 = 全部联赛"),
|
||||||
|
season: str | None = Query(None, description="赛季标签,如 2026-2027;空 = 各联赛最新赛季"),
|
||||||
|
db: AsyncSession = Depends(get_db_read),
|
||||||
|
):
|
||||||
|
"""联赛积分榜(只读)。按联赛分组,每张榜按 position 排序。
|
||||||
|
|
||||||
|
season 为空时返回每个联赛最新采集到的赛季榜单(适合前端"查看最新积分榜")。
|
||||||
|
"""
|
||||||
|
# 取每个联赛最新赛季(当 season 为空时)
|
||||||
|
latest_seasons: dict[int, str] = {}
|
||||||
|
if season is None:
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Standing.league_id, func.max(Standing.season).label("latest"))
|
||||||
|
.group_by(Standing.league_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
latest_seasons = {r.league_id: r.latest for r in rows}
|
||||||
|
|
||||||
|
q = (
|
||||||
|
select(Standing, League)
|
||||||
|
.join(League, League.id == Standing.league_id)
|
||||||
|
.order_by(League.name.asc(), Standing.position.asc())
|
||||||
|
)
|
||||||
|
if league:
|
||||||
|
q = q.where(League.code == league)
|
||||||
|
if season:
|
||||||
|
q = q.where(Standing.season == season)
|
||||||
|
else:
|
||||||
|
# 多联赛时只保留各联赛最新赛季
|
||||||
|
if latest_seasons:
|
||||||
|
q = q.where(
|
||||||
|
or_(
|
||||||
|
*(
|
||||||
|
(Standing.league_id == lid) & (Standing.season == ls)
|
||||||
|
for lid, ls in latest_seasons.items()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (await db.execute(q)).all()
|
||||||
|
|
||||||
|
# 按联赛分组
|
||||||
|
grouped: dict[str, dict] = {}
|
||||||
|
for standing, lg in rows:
|
||||||
|
key = lg.code
|
||||||
|
if key not in grouped:
|
||||||
|
grouped[key] = {
|
||||||
|
"league_code": lg.code,
|
||||||
|
"league_name": lg.name,
|
||||||
|
"season": standing.season,
|
||||||
|
"retrieved_at": standing.retrieved_at.isoformat() if standing.retrieved_at else None,
|
||||||
|
"rows": [],
|
||||||
|
}
|
||||||
|
grouped[key]["rows"].append(
|
||||||
|
{
|
||||||
|
"position": standing.position,
|
||||||
|
"team": standing.team.name_zh or standing.team.name if standing.team else "?",
|
||||||
|
"team_en": standing.team.name if standing.team else "?",
|
||||||
|
"played": standing.played,
|
||||||
|
"won": standing.won,
|
||||||
|
"drawn": standing.drawn,
|
||||||
|
"lost": standing.lost,
|
||||||
|
"goals_for": standing.goals_for,
|
||||||
|
"goals_against": standing.goals_against,
|
||||||
|
"goal_diff": standing.goal_diff,
|
||||||
|
"points": standing.points,
|
||||||
|
"xg_for": standing.xg_for,
|
||||||
|
"xg_against": standing.xg_against,
|
||||||
|
"form": standing.form,
|
||||||
|
"zone": standing.zone,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"leagues": list(grouped.values())}
|
||||||
|
|||||||
+5
-17
@@ -29,6 +29,8 @@ class MatchOut(BaseModel):
|
|||||||
match_stage: str | None
|
match_stage: str | None
|
||||||
home_xg: float | None = None
|
home_xg: float | None = None
|
||||||
away_xg: float | None = None
|
away_xg: float | None = None
|
||||||
|
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
|
||||||
|
stats: dict | None = None
|
||||||
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
|
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
|
||||||
recent_predictions: list[PredictionOut] = []
|
recent_predictions: list[PredictionOut] = []
|
||||||
|
|
||||||
@@ -107,6 +109,9 @@ class IngestBzzoiroRequest(BaseModel):
|
|||||||
date_from: str | None = None
|
date_from: str | None = None
|
||||||
date_to: str | None = None
|
date_to: str | None = None
|
||||||
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
|
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
|
||||||
|
task: str = Field("events", description="采集任务: events(比赛)/standings(积分榜)/stats(统计回填)/all")
|
||||||
|
limit: int = Field(100, ge=1, le=500, description="stats 回填单次最大比赛数")
|
||||||
|
season: str | None = Field(None, description="standings 赛季,如 '2026-2027';空 = 当前赛季")
|
||||||
|
|
||||||
|
|
||||||
class IngestResponse(BaseModel):
|
class IngestResponse(BaseModel):
|
||||||
@@ -116,23 +121,6 @@ class IngestResponse(BaseModel):
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class IngestUnderstatRequest(BaseModel):
|
|
||||||
league: str | None = Field(None, description="联赛代码,如 'E0';空 = 全部已知联赛")
|
|
||||||
season: int = Field(default_factory=lambda: date.today().year, description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
|
||||||
|
|
||||||
|
|
||||||
class IngestInjuriesRequest(BaseModel):
|
|
||||||
date: str | None = Field(None, description="日期 YYYY-MM-DD,为空则采集当天")
|
|
||||||
|
|
||||||
|
|
||||||
class IngestSimpleResponse(BaseModel):
|
|
||||||
count: int = 0
|
|
||||||
updated: int = 0
|
|
||||||
skipped: int = 0
|
|
||||||
unmatched: int = 0
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
class SettleRequest(BaseModel):
|
class SettleRequest(BaseModel):
|
||||||
prediction_id: int
|
prediction_id: int
|
||||||
home_goals: int = Field(ge=0, le=30)
|
home_goals: int = Field(ge=0, le=30)
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
使用方:
|
使用方:
|
||||||
- src/llm/provider.py: LLM 调用
|
- src/llm/provider.py: LLM 调用
|
||||||
- src/data/bzzoiro.py: bzzoiro 比赛数据
|
- src/data/bzzoiro.py: bzzoiro 比赛数据 / 积分榜 / 事件统计
|
||||||
- src/data/understat.py: xG 抓取
|
|
||||||
- src/data/injuries.py: 伤停抓取
|
|
||||||
|
|
||||||
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
||||||
调用方可通过 `timeout` 参数覆盖 per-request 超时。
|
调用方可通过 `timeout` 参数覆盖 per-request 超时。
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ AGENT_META: list[dict] = [
|
|||||||
{"id": "form", "label": "近期状态分析专家"},
|
{"id": "form", "label": "近期状态分析专家"},
|
||||||
{"id": "stats", "label": "攻防数据分析专家"},
|
{"id": "stats", "label": "攻防数据分析专家"},
|
||||||
{"id": "home_away", "label": "主客因素分析专家"},
|
{"id": "home_away", "label": "主客因素分析专家"},
|
||||||
{"id": "injuries", "label": "阵容完整性分析专家"},
|
{"id": "standings", "label": "联赛排名分析专家"},
|
||||||
{"id": "h2h", "label": "历史交锋分析专家"},
|
{"id": "h2h", "label": "历史交锋分析专家"},
|
||||||
{"id": "aggregator", "label": "终裁分析专家"},
|
{"id": "aggregator", "label": "终裁分析专家"},
|
||||||
]
|
]
|
||||||
|
|||||||
+325
-63
@@ -1,12 +1,15 @@
|
|||||||
"""Bzzoiro 数据源:抓取 + 入库。
|
"""Bzzoiro 数据源:抓取 + 入库(单一数据源)。
|
||||||
|
|
||||||
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
三条管线:
|
||||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
1. events — 比赛日程/比分(/events/),含 source_event_id 血缘
|
||||||
|
2. standings— 联赛积分榜快照(/leagues/{id}/standings/)
|
||||||
|
3. stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||||
|
|
||||||
|
使用 Repository 模式进行数据访问,不直接控制事务(由调用方 UnitOfWork 控制)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json as _json
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
@@ -22,7 +25,7 @@ from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES,
|
|||||||
from src.data.normalize import normalize_bzzoiro
|
from src.data.normalize import normalize_bzzoiro
|
||||||
from src.data.team_names_zh import zh_name
|
from src.data.team_names_zh import zh_name
|
||||||
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 League, Match, MatchStats, Standing, Team
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,6 +39,16 @@ def _to_date(value):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int_or_none(value) -> int | None:
|
||||||
|
"""宽松转 int(用于上游 ID 解析,失败返回 None 不抛错)。"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
||||||
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
||||||
|
|
||||||
@@ -262,38 +275,13 @@ class BzzoiroSource:
|
|||||||
home_ht_goals=nm.home_ht_goals,
|
home_ht_goals=nm.home_ht_goals,
|
||||||
away_ht_goals=nm.away_ht_goals,
|
away_ht_goals=nm.away_ht_goals,
|
||||||
match_stage=nm.match_stage,
|
match_stage=nm.match_stage,
|
||||||
|
source_event_id=_to_int_or_none(raw.get("id")),
|
||||||
)
|
)
|
||||||
db.add(m)
|
db.add(m)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
existing_matches[match_key] = m # 防止同批重复
|
existing_matches[match_key] = m # 防止同批重复
|
||||||
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
|
# 统计字段不在 /events/ 载荷中(单独由 stats 管线回填),
|
||||||
if any(getattr(nm, f) is not None for f in ['home_xg', 'away_xg', 'home_shots', 'away_shots', 'home_shots_on_target', 'away_shots_on_target', 'home_corners', 'away_corners', 'home_possession', 'home_yellow_cards', 'away_yellow_cards', 'home_red_cards', 'away_red_cards']):
|
# 此处不再创建 MatchStats。
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
|
||||||
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
|
|
||||||
# 回测 cutoff 若贴着开球,不会把「完赛后才有的统计」误标为赛前可用
|
|
||||||
available_at = nm.date + timedelta(hours=2) if nm.date else now
|
|
||||||
stats = MatchStats(
|
|
||||||
match_id=m.id,
|
|
||||||
home_xg=nm.home_xg,
|
|
||||||
away_xg=nm.away_xg,
|
|
||||||
home_shots=nm.home_shots,
|
|
||||||
away_shots=nm.away_shots,
|
|
||||||
home_shots_on_target=nm.home_shots_on_target,
|
|
||||||
away_shots_on_target=nm.away_shots_on_target,
|
|
||||||
home_corners=nm.home_corners,
|
|
||||||
away_corners=nm.away_corners,
|
|
||||||
home_possession=nm.home_possession,
|
|
||||||
home_yellow_cards=nm.home_yellow_cards,
|
|
||||||
away_yellow_cards=nm.away_yellow_cards,
|
|
||||||
home_red_cards=nm.home_red_cards,
|
|
||||||
away_red_cards=nm.away_red_cards,
|
|
||||||
source="bzzoiro",
|
|
||||||
source_record_id=str(raw.get("id", "")),
|
|
||||||
retrieved_at=now,
|
|
||||||
available_at=available_at,
|
|
||||||
)
|
|
||||||
db.add(stats)
|
|
||||||
league_r["inserted"] += 1
|
league_r["inserted"] += 1
|
||||||
else:
|
else:
|
||||||
# 已有比赛: 直接从内存获取对象更新(无需再查询)
|
# 已有比赛: 直接从内存获取对象更新(无需再查询)
|
||||||
@@ -310,36 +298,10 @@ class BzzoiroSource:
|
|||||||
if existing_match.match_stage is None and nm.match_stage:
|
if existing_match.match_stage is None and nm.match_stage:
|
||||||
existing_match.match_stage = nm.match_stage
|
existing_match.match_stage = nm.match_stage
|
||||||
changed = True
|
changed = True
|
||||||
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
|
if existing_match.source_event_id is None:
|
||||||
if existing_match.stats is None and (
|
eid = _to_int_or_none(raw.get("id"))
|
||||||
nm.home_xg is not None or nm.away_xg is not None
|
if eid is not None:
|
||||||
or nm.home_shots is not None or nm.away_shots is not None
|
existing_match.source_event_id = eid
|
||||||
or nm.home_corners is not None or nm.away_corners is not None
|
|
||||||
or nm.home_possession is not None
|
|
||||||
):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
|
||||||
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
|
|
||||||
available_at = nm.date + timedelta(hours=2) if nm.date else now
|
|
||||||
existing_match.stats = MatchStats(
|
|
||||||
match_id=existing_match.id,
|
|
||||||
source="bzzoiro",
|
|
||||||
source_record_id=str(raw.get("id", "")),
|
|
||||||
retrieved_at=now,
|
|
||||||
available_at=available_at,
|
|
||||||
)
|
|
||||||
db.add(existing_match.stats)
|
|
||||||
await db.flush()
|
|
||||||
if existing_match.stats is not None:
|
|
||||||
for fld in ("home_xg", "away_xg", "home_shots", "away_shots",
|
|
||||||
"home_shots_on_target", "away_shots_on_target",
|
|
||||||
"home_corners", "away_corners", "home_possession",
|
|
||||||
"home_yellow_cards", "away_yellow_cards",
|
|
||||||
"home_red_cards", "away_red_cards"):
|
|
||||||
if getattr(existing_match.stats, fld, None) is None:
|
|
||||||
v = getattr(nm, fld, None)
|
|
||||||
if v is not None:
|
|
||||||
setattr(existing_match.stats, fld, v)
|
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
league_r["updated"] += 1
|
league_r["updated"] += 1
|
||||||
@@ -349,3 +311,303 @@ class BzzoiroSource:
|
|||||||
result["total_inserted"] += league_r["inserted"]
|
result["total_inserted"] += league_r["inserted"]
|
||||||
result["total_updated"] += league_r["updated"]
|
result["total_updated"] += league_r["updated"]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 积分榜管线:/leagues/{id}/standings/ → standings 表
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def fetch_bzzoiro_standings(league_code: str, season: str | None = None) -> dict:
|
||||||
|
"""抓取联赛积分榜(纯抓取,不入库)。season 为 None 时取当前赛季。"""
|
||||||
|
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
|
||||||
|
if league_id is None:
|
||||||
|
raise ValueError(f"未知联赛代码: {league_code}")
|
||||||
|
params: dict = {}
|
||||||
|
if season:
|
||||||
|
params["season"] = season
|
||||||
|
return await _fetch_json_async(f"/leagues/{league_id}/standings/", params)
|
||||||
|
|
||||||
|
|
||||||
|
def _season_label_from_dates(start_date, end_date) -> str:
|
||||||
|
"""从赛季起止日期推导赛季标签(与 derive_season_label 语义一致)。"""
|
||||||
|
try:
|
||||||
|
if isinstance(start_date, str):
|
||||||
|
start = datetime.fromisoformat(start_date[:10])
|
||||||
|
else:
|
||||||
|
start = start_date
|
||||||
|
y = start.year
|
||||||
|
return f"{y}-{y + 1}" if start.month >= 8 else f"{y - 1}-{y}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_bzzoiro_standings(db, *, leagues: Iterable[str], season: str | None = None) -> dict:
|
||||||
|
"""采集积分榜 → upsert standings 表。
|
||||||
|
|
||||||
|
season 为 None 时采集当前赛季(bzzoiro 默认返回 is_current 赛季)。
|
||||||
|
球队名与 events 管线使用同一 normalize 规则,保证 Team 匹配。
|
||||||
|
"""
|
||||||
|
from src.data.team_names import normalize as normalize_name
|
||||||
|
|
||||||
|
result: dict = {"leagues": {}, "total_upserted": 0, "errors": []}
|
||||||
|
for code in leagues:
|
||||||
|
league_r: dict = {"upserted": 0, "teams_created": 0, "rows": 0}
|
||||||
|
try:
|
||||||
|
payload = await fetch_bzzoiro_standings(code, season=season)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("bzzoiro standings fetch failed for %s", code)
|
||||||
|
result["leagues"][code] = {"error": str(e)}
|
||||||
|
result["errors"].append(f"{code}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
rows = payload.get("standings") or []
|
||||||
|
if not rows:
|
||||||
|
result["leagues"][code] = {"error": "无积分榜数据(赛季未开始或未提供)"}
|
||||||
|
result["errors"].append(f"{code}: 无积分榜数据")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 联赛
|
||||||
|
stmt = select(League).where(League.code == code)
|
||||||
|
league = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if league is None:
|
||||||
|
league = League(code=code, name=LEAGUE_NAMES.get(code, code), country=LEAGUE_COUNTRIES.get(code))
|
||||||
|
db.add(league)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 赛季标签:优先用返回的 season 对象推导
|
||||||
|
season_obj = payload.get("season") or {}
|
||||||
|
season_label = _season_label_from_dates(
|
||||||
|
season_obj.get("start_date"), season_obj.get("end_date")
|
||||||
|
)
|
||||||
|
if season_label == "?":
|
||||||
|
season_label = season or ""
|
||||||
|
|
||||||
|
# 批量预载球队
|
||||||
|
names = {normalize_name(str(r.get("team_name", ""))) for r in rows}
|
||||||
|
names.discard("")
|
||||||
|
team_map: dict[str, Team] = {}
|
||||||
|
if names:
|
||||||
|
stmt = select(Team).where(Team.name.in_(names))
|
||||||
|
for t in (await db.execute(stmt)).scalars():
|
||||||
|
team_map[t.name] = t
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for r in rows:
|
||||||
|
team_name = normalize_name(str(r.get("team_name", "")))
|
||||||
|
if not team_name:
|
||||||
|
continue
|
||||||
|
team = team_map.get(team_name)
|
||||||
|
if team is None:
|
||||||
|
team = Team(name=team_name, name_zh=zh_name(team_name))
|
||||||
|
db.add(team)
|
||||||
|
await db.flush()
|
||||||
|
team_map[team_name] = team
|
||||||
|
league_r["teams_created"] += 1
|
||||||
|
|
||||||
|
zone = r.get("zone") or {}
|
||||||
|
values = dict(
|
||||||
|
position=_to_int_or_none(r.get("position")) or 0,
|
||||||
|
played=_to_int_or_none(r.get("played")) or 0,
|
||||||
|
won=_to_int_or_none(r.get("won")) or 0,
|
||||||
|
drawn=_to_int_or_none(r.get("drawn")) or 0,
|
||||||
|
lost=_to_int_or_none(r.get("lost")) or 0,
|
||||||
|
goals_for=_to_int_or_none(r.get("gf")) or 0,
|
||||||
|
goals_against=_to_int_or_none(r.get("ga")) or 0,
|
||||||
|
goal_diff=_to_int_or_none(r.get("gd")) or 0,
|
||||||
|
points=_to_int_or_none(r.get("pts")) or 0,
|
||||||
|
xg_for=_to_float_or_none(r.get("xgf")),
|
||||||
|
xg_against=_to_float_or_none(r.get("xga")),
|
||||||
|
form=r.get("form") or None,
|
||||||
|
zone=zone.get("label") or zone.get("key") or None,
|
||||||
|
updated_at=now,
|
||||||
|
retrieved_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = select(Standing).where(
|
||||||
|
Standing.league_id == league.id,
|
||||||
|
Standing.season == season_label,
|
||||||
|
Standing.team_id == team.id,
|
||||||
|
)
|
||||||
|
standing = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if standing is None:
|
||||||
|
standing = Standing(
|
||||||
|
league_id=league.id, season=season_label, team_id=team.id, **values
|
||||||
|
)
|
||||||
|
db.add(standing)
|
||||||
|
else:
|
||||||
|
for k, v in values.items():
|
||||||
|
setattr(standing, k, v)
|
||||||
|
league_r["upserted"] += 1
|
||||||
|
|
||||||
|
league_r["rows"] = len(rows)
|
||||||
|
result["leagues"][code] = league_r
|
||||||
|
result["total_upserted"] += league_r["upserted"]
|
||||||
|
logger.info(
|
||||||
|
"bzzoiro standings 采集完成: %s 赛季 %s, upsert %d/%d",
|
||||||
|
code, season_label, league_r["upserted"], league_r["rows"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 统计回填管线:/events/{id}/stats/ → match_stats 表
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# bzzoiro stats 字段 → MatchStats 字段映射(stats.home / stats.away 下)
|
||||||
|
_STATS_FIELD_MAP = {
|
||||||
|
"xg": ("home_xg", "away_xg"), # 回退 expected_goals
|
||||||
|
"ball_possession": ("home_possession", None), # 只取主队值,客队=100-home
|
||||||
|
"total_shots": ("home_shots", "away_shots"),
|
||||||
|
"shots_on_target": ("home_shots_on_target", "away_shots_on_target"),
|
||||||
|
"corner_kicks": ("home_corners", "away_corners"),
|
||||||
|
"yellow_cards": ("home_yellow_cards", "away_yellow_cards"),
|
||||||
|
"red_cards": ("home_red_cards", "away_red_cards"),
|
||||||
|
"big_chances": ("home_big_chances", "away_big_chances"),
|
||||||
|
"fouls": ("home_fouls", "away_fouls"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(d: dict, *keys):
|
||||||
|
"""按优先级取第一个非空字段值。"""
|
||||||
|
for k in keys:
|
||||||
|
v = d.get(k)
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_from_payload(payload: dict) -> dict:
|
||||||
|
"""把 /events/{id}/stats/ 响应映射成 MatchStats 字段 dict。
|
||||||
|
|
||||||
|
响应结构: {"event_id": ..., "stats": {"home": {...}, "away": {...}}}
|
||||||
|
"""
|
||||||
|
stats = (payload or {}).get("stats") or {}
|
||||||
|
home = stats.get("home") or {}
|
||||||
|
away = stats.get("away") or {}
|
||||||
|
out: dict = {}
|
||||||
|
|
||||||
|
xg_h = _pick(home, "xg", "expected_goals")
|
||||||
|
xg_a = _pick(away, "xg", "expected_goals")
|
||||||
|
if xg_h is not None:
|
||||||
|
out["home_xg"] = _to_float_or_none(xg_h)
|
||||||
|
if xg_a is not None:
|
||||||
|
out["away_xg"] = _to_float_or_none(xg_a)
|
||||||
|
|
||||||
|
poss = home.get("ball_possession")
|
||||||
|
if poss is not None:
|
||||||
|
p = _to_float_or_none(poss)
|
||||||
|
if p is not None:
|
||||||
|
out["home_possession"] = p
|
||||||
|
out["away_possession"] = round(100 - p, 1) if 0 <= p <= 100 else None
|
||||||
|
|
||||||
|
for src, (h_fld, a_fld) in _STATS_FIELD_MAP.items():
|
||||||
|
if src in ("xg", "ball_possession"):
|
||||||
|
continue # 已处理
|
||||||
|
hv = home.get(src)
|
||||||
|
av = away.get(src)
|
||||||
|
if hv is not None and h_fld:
|
||||||
|
out[h_fld] = _to_int_or_none(hv)
|
||||||
|
if av is not None and a_fld:
|
||||||
|
out[a_fld] = _to_int_or_none(av)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float_or_none(value) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_bzzoiro_event_stats(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
leagues: Iterable[str],
|
||||||
|
limit: int = 100,
|
||||||
|
only_missing: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""回填已完赛比赛的详细统计(逐场调 /events/{id}/stats/)。
|
||||||
|
|
||||||
|
筛选条件: match_status=finished 且 source_event_id 非空。
|
||||||
|
only_missing=True 时跳过已有统计的比赛(增量);False 则全量刷新。
|
||||||
|
limit 控制单次最多处理的比赛数(上游限速 1.2s/请求,大批量需分次触发)。
|
||||||
|
"""
|
||||||
|
result: dict = {"fetched": 0, "created": 0, "updated": 0, "skipped": 0, "errors": []}
|
||||||
|
|
||||||
|
league_ids = [BZZOIRO_LEAGUE_IDS[c] for c in leagues if c in BZZOIRO_LEAGUE_IDS]
|
||||||
|
if not league_ids:
|
||||||
|
result["errors"].append("无有效联赛代码")
|
||||||
|
return result
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Match)
|
||||||
|
.options(select(Match.stats))
|
||||||
|
.where(Match.match_status == "finished")
|
||||||
|
.where(Match.source_event_id.is_not(None))
|
||||||
|
.where(Match.league_id.in_(league_ids))
|
||||||
|
.order_by(Match.match_date.desc())
|
||||||
|
.limit(limit * 3 if only_missing else limit)
|
||||||
|
)
|
||||||
|
matches = (await db.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
processed = 0
|
||||||
|
for m in matches:
|
||||||
|
if processed >= limit:
|
||||||
|
break
|
||||||
|
if only_missing and m.stats is not None and m.stats.home_shots is not None:
|
||||||
|
result["skipped"] += 1
|
||||||
|
continue
|
||||||
|
processed += 1
|
||||||
|
try:
|
||||||
|
payload = await _fetch_json_async(f"/events/{m.source_event_id}/stats/")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("stats fetch failed match=%s event=%s: %s", m.id, m.source_event_id, e)
|
||||||
|
result["errors"].append(f"match {m.id}: {e}")
|
||||||
|
await asyncio.sleep(REQUEST_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result["fetched"] += 1
|
||||||
|
fields = _stats_from_payload(payload)
|
||||||
|
if not fields:
|
||||||
|
result["skipped"] += 1
|
||||||
|
await asyncio.sleep(REQUEST_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if m.stats is None:
|
||||||
|
# available_at 语义:完赛统计最早在开球+2h 可用(回测防泄漏)
|
||||||
|
available_at = m.match_date + timedelta(hours=2) if m.match_date else now
|
||||||
|
m.stats = MatchStats(
|
||||||
|
match_id=m.id,
|
||||||
|
source="bzzoiro",
|
||||||
|
source_record_id=str(m.source_event_id),
|
||||||
|
retrieved_at=now,
|
||||||
|
available_at=available_at,
|
||||||
|
)
|
||||||
|
db.add(m.stats)
|
||||||
|
result["created"] += 1
|
||||||
|
else:
|
||||||
|
result["updated"] += 1
|
||||||
|
if m.stats.source is None:
|
||||||
|
m.stats.source = "bzzoiro"
|
||||||
|
m.stats.source_record_id = str(m.source_event_id)
|
||||||
|
if m.stats.retrieved_at is None:
|
||||||
|
m.stats.retrieved_at = now
|
||||||
|
if m.stats.available_at is None and m.match_date:
|
||||||
|
m.stats.available_at = m.match_date + timedelta(hours=2)
|
||||||
|
|
||||||
|
for fld, v in fields.items():
|
||||||
|
# away_possession 为计算字段,模型无此列,跳过
|
||||||
|
if hasattr(m.stats, fld):
|
||||||
|
setattr(m.stats, fld, v)
|
||||||
|
|
||||||
|
await asyncio.sleep(REQUEST_INTERVAL)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"bzzoiro stats 回填完成: 抓取 %d, 新建 %d, 更新 %d, 跳过 %d, 错误 %d",
|
||||||
|
result["fetched"], result["created"], result["updated"],
|
||||||
|
result["skipped"], len(result["errors"]),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
+1
-10
@@ -1,4 +1,4 @@
|
|||||||
"""数据源配置常量(联赛映射)。"""
|
"""数据源配置常量(联赛映射)。数据源统一为 bzzoiro(单一数据源)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# fdco 风格代码 → bzzoiro league_id
|
# fdco 风格代码 → bzzoiro league_id
|
||||||
@@ -12,15 +12,6 @@ BZZOIRO_LEAGUE_IDS: dict[str, int] = {
|
|||||||
"EL": 8, # Europa League
|
"EL": 8, # Europa League
|
||||||
}
|
}
|
||||||
|
|
||||||
# fdco 代码 → understat 联赛代码
|
|
||||||
FDCO_TO_UNDERSTAT: dict[str, str] = {
|
|
||||||
"E0": "EPL",
|
|
||||||
"SP1": "La_liga",
|
|
||||||
"D1": "Bundesliga",
|
|
||||||
"I1": "Serie_A",
|
|
||||||
"F1": "Ligue_1",
|
|
||||||
}
|
|
||||||
|
|
||||||
# fdco 代码 → 显示名
|
# fdco 代码 → 显示名
|
||||||
LEAGUE_NAMES: dict[str, str] = {
|
LEAGUE_NAMES: dict[str, str] = {
|
||||||
"E0": "Premier League",
|
"E0": "Premier League",
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
"""伤停数据采集器(api-football / api-sports.io)。
|
|
||||||
|
|
||||||
采集伤停数据并入库(injuries 表),供 injuries agent 使用。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from src.core.runtime_config import get_runtime_value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class InjuryQueryResult:
|
|
||||||
"""伤停查询结果(区分「查询成功但为空」与「查询失败/源未配置」)。"""
|
|
||||||
|
|
||||||
records: list["Injury"]
|
|
||||||
query_status: str # "success" | "source_not_configured" | "query_error"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_data(self) -> bool:
|
|
||||||
"""成功查询(即使结果为空)视为有明确名单,has_data=True。"""
|
|
||||||
return self.query_status == "success"
|
|
||||||
from src.core.http_client import get_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
API_BASE = "https://v3.football.api-sports.io"
|
|
||||||
DEFAULT_HOST = "v3.football.api-sports.io"
|
|
||||||
|
|
||||||
# 缓存目录:系统临时目录
|
|
||||||
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
|
|
||||||
|
|
||||||
# Fix 5: 缓存 TTL 从 7 天改为 6 小时,同日再采不会命中旧数据
|
|
||||||
_CACHE_TTL_HOURS = 6
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
|
||||||
"""采集伤停数据。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
date: 日期 (YYYY-MM-DD),返当天全部伤停
|
|
||||||
fixture_id: 指定比赛 ID
|
|
||||||
league_id: 指定联赛 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
伤停记录列表
|
|
||||||
"""
|
|
||||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
||||||
if not api_key:
|
|
||||||
raise RuntimeError("API_FOOTBALL_KEY 未设置")
|
|
||||||
|
|
||||||
cache_dir = _CACHE_DIR
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Fix 5: 缓存命中 (6 小时内有效)
|
|
||||||
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
|
|
||||||
cache_file = cache_dir / cache_key
|
|
||||||
if cache_file.exists():
|
|
||||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
|
||||||
if age_hours < _CACHE_TTL_HOURS:
|
|
||||||
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours)
|
|
||||||
with open(cache_file, encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
else:
|
|
||||||
logger.debug("injuries cache expired: %s (%.1fh old)", cache_key, age_hours)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"x-apisports-key": api_key,
|
|
||||||
"x-rapidapi-host": DEFAULT_HOST,
|
|
||||||
}
|
|
||||||
params: dict[str, Any] = {}
|
|
||||||
if date:
|
|
||||||
params["date"] = date
|
|
||||||
if fixture_id:
|
|
||||||
params["fixture"] = fixture_id
|
|
||||||
if league_id:
|
|
||||||
params["league"] = league_id
|
|
||||||
|
|
||||||
url = f"{API_BASE}/injuries"
|
|
||||||
|
|
||||||
# 重试
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
client = get_client()
|
|
||||||
resp = await asyncio.wait_for(
|
|
||||||
client.get(
|
|
||||||
url, headers=headers, params=params,
|
|
||||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
|
||||||
),
|
|
||||||
timeout=60.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
last_exc = e
|
|
||||||
if attempt == 2:
|
|
||||||
raise
|
|
||||||
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
|
|
||||||
logger.warning("injuries fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"injuries fetch failed: {last_exc}")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
injuries = data.get("response", [])
|
|
||||||
|
|
||||||
# 写缓存
|
|
||||||
with open(cache_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(injuries, ensure_ascii=False, default=str, fp=f)
|
|
||||||
|
|
||||||
return injuries
|
|
||||||
|
|
||||||
|
|
||||||
async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|
||||||
"""采集伤停数据并入库(injuries 表)。
|
|
||||||
|
|
||||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
|
||||||
|
|
||||||
Fix 1: 使用 SAVEPOINT(begin_nested)避免整批回滚丢数据。
|
|
||||||
Fix 2: 正确解析并写入 return_date。
|
|
||||||
Fix 3: retrieved_at 比较统一用 timezone-aware datetime。
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from src.data.team_names import normalize as normalize_name
|
|
||||||
from src.db.models import Injury, Team
|
|
||||||
|
|
||||||
result = {"count": 0, "inserted": 0, "errors": []}
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_injuries = await fetch_injuries(date=date)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("injuries fetch failed")
|
|
||||||
result["errors"].append(f"fetch failed: {e}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
result["count"] = len(raw_injuries)
|
|
||||||
|
|
||||||
# 预加载所有球队(用于按名匹配)
|
|
||||||
teams = (await db.execute(select(Team))).scalars().all()
|
|
||||||
team_by_name = {t.name: t.id for t in teams}
|
|
||||||
|
|
||||||
# 收集所有待插入记录(解析 + 校验)
|
|
||||||
pending_records: list[dict] = []
|
|
||||||
for raw in raw_injuries:
|
|
||||||
try:
|
|
||||||
player = raw.get("player", {}) or {}
|
|
||||||
team = raw.get("team", {}) or {}
|
|
||||||
fixture = raw.get("fixture", {}) or {}
|
|
||||||
|
|
||||||
player_name = player.get("name", "")
|
|
||||||
team_name = normalize_name(team.get("name", ""))
|
|
||||||
team_id = team_by_name.get(team_name)
|
|
||||||
|
|
||||||
# Fix 2: 解析日期(injury_date + return_date)
|
|
||||||
fixture_date = fixture.get("date")
|
|
||||||
injury_date = None
|
|
||||||
if fixture_date:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(fixture_date.replace("Z", "+00:00"))
|
|
||||||
injury_date = dt.date()
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 解析 return_date(如果数据源提供)
|
|
||||||
return_date = None
|
|
||||||
return_date_raw = player.get("return_date") or player.get("returnDate")
|
|
||||||
if return_date_raw:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(str(return_date_raw).replace("Z", "+00:00"))
|
|
||||||
return_date = dt.date()
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 强制 int 转换,API 可能返回字符串
|
|
||||||
player_id = player.get("id")
|
|
||||||
try:
|
|
||||||
player_id = int(player_id) if player_id is not None else None
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
player_id = None
|
|
||||||
fixture_id = fixture.get("id")
|
|
||||||
try:
|
|
||||||
fixture_id = int(fixture_id) if fixture_id is not None else None
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
fixture_id = None
|
|
||||||
|
|
||||||
pending_records.append({
|
|
||||||
"player_id": player_id,
|
|
||||||
"player_name": player_name,
|
|
||||||
"team_id": team_id,
|
|
||||||
"fixture_id": fixture_id,
|
|
||||||
"league_id": (raw.get("league") or {}).get("id"),
|
|
||||||
"injury_type": player.get("type"),
|
|
||||||
"reason": player.get("reason"),
|
|
||||||
"injury_date": injury_date,
|
|
||||||
"return_date": return_date,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
result["errors"].append(f"parse error: {e}")
|
|
||||||
|
|
||||||
# 批量查询已存在的记录(1 次 DB 往返)
|
|
||||||
existing_keys: set[tuple] = set()
|
|
||||||
if pending_records:
|
|
||||||
conditions = []
|
|
||||||
for rec in pending_records:
|
|
||||||
conditions.append(
|
|
||||||
(Injury.player_id == rec["player_id"])
|
|
||||||
& (Injury.fixture_id == rec["fixture_id"])
|
|
||||||
& (Injury.injury_type == rec["injury_type"])
|
|
||||||
)
|
|
||||||
if conditions:
|
|
||||||
from sqlalchemy import or_
|
|
||||||
stmt = select(Injury.player_id, Injury.fixture_id, Injury.injury_type).where(or_(*conditions))
|
|
||||||
rows = (await db.execute(stmt)).all()
|
|
||||||
existing_keys = {(r[0], r[1], r[2]) for r in rows}
|
|
||||||
|
|
||||||
# Fix 1: 使用 begin_nested(SAVEPOINT)隔离每批 flush
|
|
||||||
# IntegrityError 时只回滚到 savepoint,不影响其它已成功批次
|
|
||||||
BATCH_SIZE = 50
|
|
||||||
batch: list[Injury] = []
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
"""使用 savepoint flush 一批记录;失败只回滚本批。返回实际写入条数。"""
|
|
||||||
if not batch:
|
|
||||||
return 0
|
|
||||||
count = len(batch)
|
|
||||||
async with db.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
db.add(obj)
|
|
||||||
await db.flush()
|
|
||||||
batch.clear()
|
|
||||||
return count
|
|
||||||
|
|
||||||
for i, rec in enumerate(pending_records):
|
|
||||||
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
|
|
||||||
if key in existing_keys:
|
|
||||||
continue
|
|
||||||
|
|
||||||
batch.append(Injury(**rec))
|
|
||||||
|
|
||||||
# 每 BATCH_SIZE 条 flush 一次
|
|
||||||
if len(batch) >= BATCH_SIZE:
|
|
||||||
try:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
except IntegrityError:
|
|
||||||
logger.warning(
|
|
||||||
"injuries batch IntegrityError at record %d, "
|
|
||||||
"rolled back to savepoint, continuing",
|
|
||||||
i + 1,
|
|
||||||
)
|
|
||||||
# begin_nested 已回滚到 savepoint,清空 batch 继续
|
|
||||||
batch.clear()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 最终 flush(剩余不足一批的记录)
|
|
||||||
try:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
except IntegrityError:
|
|
||||||
logger.warning(
|
|
||||||
"injuries final flush IntegrityError, "
|
|
||||||
"rolled back to savepoint, some records may be lost",
|
|
||||||
)
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
|
||||||
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> InjuryQueryResult:
|
|
||||||
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: 数据库 session
|
|
||||||
team_id: 球队 ID
|
|
||||||
match_date: 比赛日期
|
|
||||||
as_of: 数据截止时间(用于回测防泄漏)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
InjuryQueryResult:包含查询记录与状态
|
|
||||||
- query_status="success": 查询成功(即使结果也为空)
|
|
||||||
- query_status="source_not_configured": API_FOOTBALL_KEY 未配置
|
|
||||||
- query_status="query_error": 查询异常
|
|
||||||
- query_status="no_local_data": Key 已配置,但该队 injuries 表无任何历史记录
|
|
||||||
|
|
||||||
语义区分:
|
|
||||||
- success + 空结果 → has_data=True(明确知道「无人伤停」)
|
|
||||||
- no_local_data → has_data=False(本地尚未采集,需先 ingest)
|
|
||||||
- source_not_configured / query_error → has_data=False(无法判断)
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from src.db.models import Injury
|
|
||||||
|
|
||||||
# 检查 API 是否配置(只读配置,不发网络)
|
|
||||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
||||||
if not api_key:
|
|
||||||
logger.debug("API_FOOTBALL_KEY 未配置,跳过伤停查询 team=%s", team_id)
|
|
||||||
return InjuryQueryResult(records=[], query_status="source_not_configured")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Fix 3: 统一用 timezone-aware datetime 比较,禁止 date() 截断
|
|
||||||
if hasattr(match_date, "date") and callable(match_date.date):
|
|
||||||
match_date = match_date.date()
|
|
||||||
|
|
||||||
stmt = (
|
|
||||||
select(Injury)
|
|
||||||
.where(Injury.team_id == team_id)
|
|
||||||
.where(Injury.injury_date <= match_date)
|
|
||||||
.where(
|
|
||||||
(Injury.return_date.is_(None)) | (Injury.return_date >= match_date)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if as_of is not None:
|
|
||||||
# Fix 3: 统一用 date 比较,避免 timestamptz vs date 的时区问题
|
|
||||||
if hasattr(as_of, "date") and callable(as_of.date):
|
|
||||||
as_of = as_of.date()
|
|
||||||
stmt = stmt.where(func.date(Injury.retrieved_at) <= as_of)
|
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
records = list(result.scalars().all())
|
|
||||||
|
|
||||||
# 判定「无本地数据」:该队从未有伤停记录
|
|
||||||
# 规则:该 team_id 在 injuries 表中 count==0
|
|
||||||
if not records:
|
|
||||||
count_stmt = select(func.count()).where(Injury.team_id == team_id)
|
|
||||||
team_count = (await db.execute(count_stmt)).scalar_one() or 0
|
|
||||||
if team_count == 0:
|
|
||||||
logger.debug("API Key 已配置但本地无伤停数据 team=%s,标记 no_local_data", team_id)
|
|
||||||
return InjuryQueryResult(records=[], query_status="no_local_data")
|
|
||||||
|
|
||||||
return InjuryQueryResult(records=records, query_status="success")
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("伤停查询异常 team=%s: %s", team_id, e)
|
|
||||||
return InjuryQueryResult(records=[], query_status="query_error")
|
|
||||||
@@ -219,35 +219,3 @@ def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None:
|
|||||||
if m.match_status == "finished" and m.home_goals is None:
|
if m.match_status == "finished" and m.home_goals is None:
|
||||||
m.match_status = "scheduled"
|
m.match_status = "scheduled"
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
def normalize_understat(raw: dict, league_type: str) -> NormalizedMatch | None:
|
|
||||||
"""understat 单场 → NormalizedMatch(仅 xG)。"""
|
|
||||||
from src.data.team_names import normalize as normalize_name
|
|
||||||
|
|
||||||
dt_str = raw.get("datetime") or raw.get("date")
|
|
||||||
if not dt_str:
|
|
||||||
return None
|
|
||||||
dt = _parse_date(dt_str)
|
|
||||||
if dt is None:
|
|
||||||
return None
|
|
||||||
home_info = raw.get("h", {})
|
|
||||||
away_info = raw.get("a", {})
|
|
||||||
home_name = home_info.get("title", "") if isinstance(home_info, dict) else ""
|
|
||||||
away_name = away_info.get("title", "") if isinstance(away_info, dict) else ""
|
|
||||||
home = normalize_name(home_name)
|
|
||||||
away = normalize_name(away_name)
|
|
||||||
if not home or not away or home == away:
|
|
||||||
return None
|
|
||||||
home_xg = _to_float(raw["xG"].get("h")) if isinstance(raw.get("xG"), dict) else None
|
|
||||||
away_xg = _to_float(raw["xG"].get("a")) if isinstance(raw.get("xG"), dict) else None
|
|
||||||
return NormalizedMatch(
|
|
||||||
league_type=league_type,
|
|
||||||
date=dt,
|
|
||||||
home_team=home,
|
|
||||||
away_team=away,
|
|
||||||
match_status="finished",
|
|
||||||
season_label=derive_season_label(dt),
|
|
||||||
home_xg=home_xg,
|
|
||||||
away_xg=away_xg,
|
|
||||||
)
|
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,8 @@
|
|||||||
定义 DataSource 契约,并提供全局注册表供路由层分发。
|
定义 DataSource 契约,并提供全局注册表供路由层分发。
|
||||||
每个比赛数据源实现该协议,注册后即可通过统一入口调度。
|
每个比赛数据源实现该协议,注册后即可通过统一入口调度。
|
||||||
|
|
||||||
注: injuries 是球员级独立领域(写 Injury 表),不遵循此协议。
|
当前只有 bzzoiro 一个数据源(Understat / injuries 已移除),
|
||||||
|
保留协议与注册表是为了统一 ingest 调度入口的结构。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -58,7 +59,6 @@ def list_sources() -> list[str]:
|
|||||||
def _load_sources() -> None:
|
def _load_sources() -> None:
|
||||||
"""延迟导入数据源触发 @register(避免循环导入)。"""
|
"""延迟导入数据源触发 @register(避免循环导入)。"""
|
||||||
from src.data.bzzoiro import BzzoiroSource # noqa: F811
|
from src.data.bzzoiro import BzzoiroSource # noqa: F811
|
||||||
from src.data.understat import UnderstatSource # noqa: F811
|
|
||||||
|
|
||||||
|
|
||||||
# 保持向后兼容:模块加载时尝试加载(但不再强制)
|
# 保持向后兼容:模块加载时尝试加载(但不再强制)
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
"""Understat xG 数据源。
|
|
||||||
|
|
||||||
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
|
||||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from src.core.http_client import get_client
|
|
||||||
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
|
|
||||||
from src.data.normalize import normalize_understat
|
|
||||||
from src.data.sources import register
|
|
||||||
from src.db.models import League, Match, MatchStats, Team
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
UNDERSTAT_BASE = "https://understat.com/getLeagueData/{league}/{season}"
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
|
||||||
"""抓取 understat 单赛季 xG 数据。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
league_code: fdco 风格代码,如 'E0'
|
|
||||||
season: 赛季起始年,如 2025 表示 2025-2026 赛季
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
比赛数组,每项含 datetime/h/a/xG
|
|
||||||
"""
|
|
||||||
understat_league = FDCO_TO_UNDERSTAT.get(league_code)
|
|
||||||
if understat_league is None:
|
|
||||||
raise ValueError(f"未知联赛代码: {league_code}")
|
|
||||||
|
|
||||||
url = UNDERSTAT_BASE.format(league=understat_league, season=season)
|
|
||||||
headers = {
|
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
|
||||||
"X-Requested-With": "XMLHttpRequest",
|
|
||||||
"Referer": f"https://understat.com/league/{understat_league}/{season}",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 重试:网络错误 / 5xx / 429
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
client = get_client()
|
|
||||||
resp = await asyncio.wait_for(
|
|
||||||
client.get(
|
|
||||||
url, headers=headers,
|
|
||||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
|
||||||
),
|
|
||||||
timeout=60.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
last_exc = e
|
|
||||||
if attempt == 2:
|
|
||||||
raise
|
|
||||||
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
|
|
||||||
logger.warning("understat fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"understat fetch failed: {last_exc}")
|
|
||||||
|
|
||||||
# 优先按 JSON 响应解析(getLeagueData 接口返回 {teams, players, dates})
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except Exception:
|
|
||||||
data = None
|
|
||||||
if isinstance(data, dict) and isinstance(data.get("dates"), list):
|
|
||||||
return data["dates"]
|
|
||||||
|
|
||||||
# 兼容旧版联赛页面:内嵌 var datesData = JSON.parse('...')
|
|
||||||
text = resp.text
|
|
||||||
match = re.search(r"var\s+datesData\s*=\s*JSON\.parse\('([^']+)'\)", text)
|
|
||||||
if not match:
|
|
||||||
logger.warning("understat 响应格式不符: %s...", text[:200])
|
|
||||||
return []
|
|
||||||
decoded = match.group(1).encode().decode("unicode_escape")
|
|
||||||
data = json.loads(decoded)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
|
||||||
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
|
||||||
|
|
||||||
统一在这里构造,避免"预加载时用 str(date)、写入时用 isoformat()"这类
|
|
||||||
隐式格式依赖 —— 两者当前恰好相等,但一旦有人改动其一就会静默失配,
|
|
||||||
导致所有比赛被判为不存在而重复插入。
|
|
||||||
"""
|
|
||||||
if hasattr(match_date, "date") and callable(match_date.date):
|
|
||||||
match_date = match_date.date()
|
|
||||||
return (home_team_id, away_team_id, match_date.isoformat() if match_date is not None else "")
|
|
||||||
|
|
||||||
|
|
||||||
@register
|
|
||||||
class UnderstatSource:
|
|
||||||
"""understat xG 数据源(实现 DataSource 协议)。"""
|
|
||||||
|
|
||||||
name = "understat"
|
|
||||||
|
|
||||||
async def ingest(self, db, *, league: str, season: int) -> dict:
|
|
||||||
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。
|
|
||||||
|
|
||||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
|
||||||
|
|
||||||
P1-3: 批量查询优化,将单赛季 380 场 × 3 次 DB 往返降为 3 次查询。
|
|
||||||
"""
|
|
||||||
from src.db.repositories import LeagueRepository, TeamRepository
|
|
||||||
|
|
||||||
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_matches = await fetch_understat(league, season)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("understat fetch failed for %s %s", league, season)
|
|
||||||
result["errors"].append(f"fetch failed: {e}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 使用 Repository
|
|
||||||
league_repo = LeagueRepository(db)
|
|
||||||
team_repo = TeamRepository(db)
|
|
||||||
|
|
||||||
# 查联赛
|
|
||||||
league_obj = await league_repo.get_by_code(league)
|
|
||||||
if league_obj is None:
|
|
||||||
result["errors"].append(f"league {league} not found in DB")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# === 批量优化: 一次规范化,收集球队名和日期 ===
|
|
||||||
normalized_matches: list = []
|
|
||||||
all_team_names: set[str] = set()
|
|
||||||
for raw in raw_matches:
|
|
||||||
if not raw.get("isResult"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
nm = normalize_understat(raw, league)
|
|
||||||
if nm is None:
|
|
||||||
result["skipped"] += 1
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
result["errors"].append(f"normalize: {e}")
|
|
||||||
continue
|
|
||||||
normalized_matches.append((nm, raw))
|
|
||||||
all_team_names.add(nm.home_team)
|
|
||||||
all_team_names.add(nm.away_team)
|
|
||||||
|
|
||||||
if not normalized_matches:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# === 批量查询球队(1 次 DB 往返) ===
|
|
||||||
team_name_to_id = {}
|
|
||||||
if all_team_names:
|
|
||||||
teams = await team_repo.get_all_by_names(list(all_team_names))
|
|
||||||
team_name_to_id = {name: team.id for name, team in teams.items()}
|
|
||||||
|
|
||||||
# === 批量查询已有比赛(1 次 DB 往返,按日期范围) ===
|
|
||||||
match_dict: dict[tuple, Match] = {}
|
|
||||||
dates = [nm.date for nm, _ in normalized_matches if nm.date is not None]
|
|
||||||
if dates:
|
|
||||||
min_dt = min(dates) - timedelta(days=30)
|
|
||||||
max_dt = max(dates) + timedelta(days=30)
|
|
||||||
stmt = (
|
|
||||||
select(Match)
|
|
||||||
.options(selectinload(Match.stats))
|
|
||||||
.where(Match.league_id == league_obj.id)
|
|
||||||
.where(Match.match_date >= min_dt)
|
|
||||||
.where(Match.match_date <= max_dt)
|
|
||||||
)
|
|
||||||
for m in (await db.execute(stmt)).scalars():
|
|
||||||
key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date)
|
|
||||||
match_dict[key] = m
|
|
||||||
|
|
||||||
# === 内存匹配 + 回填 xG ===
|
|
||||||
for nm, raw in normalized_matches:
|
|
||||||
home_team_id = team_name_to_id.get(nm.home_team)
|
|
||||||
away_team_id = team_name_to_id.get(nm.away_team)
|
|
||||||
if home_team_id is None or away_team_id is None:
|
|
||||||
result["unmatched"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
match_key = _match_key(home_team_id, away_team_id, nm.date)
|
|
||||||
existing = match_dict.get(match_key)
|
|
||||||
if existing is None:
|
|
||||||
result["unmatched"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 回填 xG
|
|
||||||
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
|
||||||
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
|
|
||||||
match_date = existing.match_date if existing.match_date else now
|
|
||||||
available_at = match_date + timedelta(hours=2)
|
|
||||||
existing.stats = MatchStats(
|
|
||||||
match_id=existing.id,
|
|
||||||
source="understat",
|
|
||||||
source_record_id=str(raw.get("id", "")),
|
|
||||||
retrieved_at=now,
|
|
||||||
available_at=available_at,
|
|
||||||
)
|
|
||||||
db.add(existing.stats)
|
|
||||||
await db.flush()
|
|
||||||
if existing.stats is not None:
|
|
||||||
if existing.stats.home_xg is None and nm.home_xg is not None:
|
|
||||||
existing.stats.home_xg = nm.home_xg
|
|
||||||
result["updated"] += 1
|
|
||||||
if existing.stats.away_xg is None and nm.away_xg is not None:
|
|
||||||
existing.stats.away_xg = nm.away_xg
|
|
||||||
|
|
||||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
|
||||||
return result
|
|
||||||
+39
-29
@@ -1,4 +1,7 @@
|
|||||||
"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。"""
|
"""ORM 模型: leagues / teams / matches / match_stats / standings / predictions。
|
||||||
|
|
||||||
|
数据源统一为 bzzoiro(单一数据源),伤停(injuries)与 Understat 已移除。
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
@@ -16,7 +19,6 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
and_,
|
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -75,6 +77,8 @@ class Match(Base):
|
|||||||
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||||
away_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
away_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||||
match_stage: Mapped[str | None] = mapped_column(String(100))
|
match_stage: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
# 数据血缘:bzzoiro 上游事件 ID,用于 /events/{id}/stats/ 统计回填
|
||||||
|
source_event_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
@@ -127,6 +131,11 @@ class MatchStats(Base):
|
|||||||
away_yellow_cards: Mapped[int | None] = mapped_column(Integer)
|
away_yellow_cards: Mapped[int | None] = mapped_column(Integer)
|
||||||
home_red_cards: Mapped[int | None] = mapped_column(Integer)
|
home_red_cards: Mapped[int | None] = mapped_column(Integer)
|
||||||
away_red_cards: Mapped[int | None] = mapped_column(Integer)
|
away_red_cards: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
# bzzoiro /events/{id}/stats/ 扩展字段
|
||||||
|
home_big_chances: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
away_big_chances: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
home_fouls: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
away_fouls: Mapped[int | None] = mapped_column(Integer)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
# 数据血缘:追踪统计数据的来源和可用时间
|
# 数据血缘:追踪统计数据的来源和可用时间
|
||||||
source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat
|
source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat
|
||||||
@@ -146,39 +155,40 @@ class MatchStats(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Injury(Base):
|
class Standing(Base):
|
||||||
"""球员伤停记录(api-football 数据源)。"""
|
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
|
||||||
__tablename__ = "injuries"
|
|
||||||
|
同一联赛同一赛季只保留最新快照:重新采集时按 (league_id, season, team_id)
|
||||||
|
upsert。zone 来自 bzzoiro 分区(如 champions_league / europa_league / relegation)。
|
||||||
|
"""
|
||||||
|
__tablename__ = "standings"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
player_id: Mapped[int | None] = mapped_column(Integer, index=True)
|
league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False)
|
||||||
player_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
season: Mapped[str] = mapped_column(String(12), nullable=False)
|
||||||
team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id"), index=True)
|
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
|
||||||
fixture_id: Mapped[int | None] = mapped_column(Integer)
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
league_id: Mapped[int | None] = mapped_column(Integer)
|
played: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
injury_type: Mapped[str | None] = mapped_column(String(50)) # Missing Fixture / Suspended
|
won: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
reason: Mapped[str | None] = mapped_column(String(200))
|
drawn: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
injury_date: Mapped[date | None] = mapped_column(Date, index=True)
|
lost: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
return_date: Mapped[date | None] = mapped_column(Date)
|
goals_for: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
goals_against: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
goal_diff: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
points: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
xg_for: Mapped[float | None] = mapped_column(Float)
|
||||||
|
xg_against: Mapped[float | None] = mapped_column(Float)
|
||||||
|
form: Mapped[str | None] = mapped_column(String(20)) # 近期赛果串,如 "WWDLW"
|
||||||
|
zone: Mapped[str | None] = mapped_column(String(50)) # champions_league / relegation 等
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
team: Mapped["Team | None"] = relationship()
|
league: Mapped[League] = relationship()
|
||||||
|
team: Mapped[Team] = relationship(lazy="selectin")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Fix 4: partial unique index — 只在 player_id 和 fixture_id 都非空时强制唯一
|
UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"),
|
||||||
# PostgreSQL 中 NULL != NULL,普通唯一索引无法防止 NULL 重复
|
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
|
||||||
Index(
|
|
||||||
"ix_injuries_player_fixture",
|
|
||||||
"player_id",
|
|
||||||
"fixture_id",
|
|
||||||
"injury_type",
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=and_(
|
|
||||||
player_id.is_not(None),
|
|
||||||
fixture_id.is_not(None),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Index("ix_injuries_team_date", "team_id", "injury_date"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class MatchRepository:
|
|||||||
) -> Match | None:
|
) -> Match | None:
|
||||||
"""按联赛+主队+客队+日期查找比赛(天级匹配)。
|
"""按联赛+主队+客队+日期查找比赛(天级匹配)。
|
||||||
|
|
||||||
预加载 stats:调用方(understat 回填)会读取 existing.stats,
|
预加载 stats:调用方(统计回填)会读取 existing.stats,
|
||||||
async session 下惰性加载会抛 MissingGreenlet。
|
async session 下惰性加载会抛 MissingGreenlet。
|
||||||
|
|
||||||
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
|
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def load_agent_prompt(name: str, version: str = "v1") -> str:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AgentSpec:
|
class AgentSpec:
|
||||||
"""领域专家 agent 定义。"""
|
"""领域专家 agent 定义。"""
|
||||||
name: str # h2h / form / home_away / injuries / stats
|
name: str # h2h / form / home_away / standings / stats
|
||||||
system_prompt: str # system message
|
system_prompt: str # system message
|
||||||
slice_fn: object # async (header, before) -> str 切片函数
|
slice_fn: object # async (header, before) -> str 切片函数
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ from src.llm.context_builder import (
|
|||||||
h2h_slice,
|
h2h_slice,
|
||||||
header_text,
|
header_text,
|
||||||
home_away_slice,
|
home_away_slice,
|
||||||
injuries_slice,
|
|
||||||
load_match_header,
|
load_match_header,
|
||||||
|
standings_slice,
|
||||||
stats_slice,
|
stats_slice,
|
||||||
)
|
)
|
||||||
from src.core.runtime_config import get_runtime_value
|
from src.core.runtime_config import get_runtime_value
|
||||||
@@ -36,7 +36,7 @@ _AGENT_PROVIDER_CACHE_TTL = 60.0
|
|||||||
|
|
||||||
|
|
||||||
# ── 5 个专家 agent 定义 ──
|
# ── 5 个专家 agent 定义 ──
|
||||||
# A=近期状态 B=攻防数据 C=主客因素 D=阵容完整性 E=历史交锋
|
# A=近期状态 B=攻防数据 C=主客因素 D=联赛排名 E=历史交锋
|
||||||
SPECIALIST_SPECS: list[AgentSpec] = [
|
SPECIALIST_SPECS: list[AgentSpec] = [
|
||||||
AgentSpec(
|
AgentSpec(
|
||||||
name="form",
|
name="form",
|
||||||
@@ -54,9 +54,9 @@ SPECIALIST_SPECS: list[AgentSpec] = [
|
|||||||
slice_fn=home_away_slice,
|
slice_fn=home_away_slice,
|
||||||
),
|
),
|
||||||
AgentSpec(
|
AgentSpec(
|
||||||
name="injuries",
|
name="standings",
|
||||||
system_prompt="你是足球阵容完整性分析专家。汇总伤停与停赛名单,输出战力缺失程度。只输出 JSON。",
|
system_prompt="你是足球联赛排名分析专家。分析积分榜位置、积分走势与分区,评估两队整体实力差距。只输出 JSON。",
|
||||||
slice_fn=injuries_slice,
|
slice_fn=standings_slice,
|
||||||
),
|
),
|
||||||
AgentSpec(
|
AgentSpec(
|
||||||
name="h2h",
|
name="h2h",
|
||||||
@@ -76,7 +76,7 @@ AGENT_LABELS_ZH: dict[str, str] = {
|
|||||||
"form": "近期状态分析专家",
|
"form": "近期状态分析专家",
|
||||||
"stats": "攻防数据分析专家",
|
"stats": "攻防数据分析专家",
|
||||||
"home_away": "主客因素分析专家",
|
"home_away": "主客因素分析专家",
|
||||||
"injuries": "阵容完整性分析专家",
|
"standings": "联赛排名分析专家",
|
||||||
"h2h": "历史交锋分析专家",
|
"h2h": "历史交锋分析专家",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-57
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
架构:
|
架构:
|
||||||
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
||||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
|
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / stats)
|
||||||
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
||||||
|
|
||||||
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
||||||
@@ -81,7 +81,7 @@ class MatchContext:
|
|||||||
match_id: int
|
match_id: int
|
||||||
text: str
|
text: str
|
||||||
has_stats: bool
|
has_stats: bool
|
||||||
has_injuries: bool
|
has_standings: bool
|
||||||
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
||||||
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
|
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
|
||||||
|
|
||||||
@@ -337,72 +337,72 @@ async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None,
|
|||||||
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
||||||
|
|
||||||
|
|
||||||
async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||||
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
|
"""D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。
|
||||||
|
|
||||||
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)。
|
||||||
db: 可选共享 session(见模块 docstring)。
|
db: 可选共享 session(见模块 docstring)。
|
||||||
|
|
||||||
语义区分:
|
语义区分:
|
||||||
- 查询成功 + 空结果 → has_data=True(明确知道「无人伤停」)
|
- 两队都有积分榜行 → has_data=True(明确的排名信息)
|
||||||
- 源未配置 / 查询失败 → has_data=False(无法判断,跳过 LLM)
|
- 任一队缺失 → has_data=False(升班马/杯赛无榜,信息不完整时明确声明)
|
||||||
"""
|
"""
|
||||||
from src.data.injuries import get_injuries_for_match, InjuryQueryResult
|
from src.db.models import League, Standing
|
||||||
|
|
||||||
cutoff = before or header.match_dt
|
|
||||||
if db is not None:
|
if db is not None:
|
||||||
home_result = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
|
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
|
||||||
away_result = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
rows = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Standing)
|
||||||
|
.options(selectinload(Standing.team))
|
||||||
|
.where(Standing.league_id == header.league_id)
|
||||||
|
.order_by(Standing.position.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if league
|
||||||
|
else []
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
async with AsyncSessionLocal() as new_db:
|
async with AsyncSessionLocal() as new_db:
|
||||||
home_result = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
|
return await standings_slice(header, before=before, db=new_db)
|
||||||
away_result = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
|
|
||||||
|
|
||||||
# 判断是否有有效查询结果
|
lines = [f"── 联赛排名({header.league_name} 共 {len(rows)} 队) ──"]
|
||||||
# 两队都成功查询(即使为空) → has_data=True
|
|
||||||
# 任一查询失败或源未配置 → has_data=False
|
|
||||||
both_succeeded = (
|
|
||||||
home_result.query_status == "success"
|
|
||||||
and away_result.query_status == "success"
|
|
||||||
)
|
|
||||||
any_configured = (
|
|
||||||
home_result.query_status != "source_not_configured"
|
|
||||||
or away_result.query_status != "source_not_configured"
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = ["── 阵容完整性 ──"]
|
|
||||||
n_records = 0
|
n_records = 0
|
||||||
|
|
||||||
for label, result in (("主队", home_result), ("客队", away_result)):
|
def _fmt(row) -> str:
|
||||||
if result.query_status == "source_not_configured":
|
zg = f" xG差 {row.xgd:+.1f}" if row.xg_for is not None and row.xg_against is not None and row.goal_diff is not None else ""
|
||||||
lines.append(f" {label}: 伤停源未配置")
|
form = f" 近5场 {row.form}" if row.form else ""
|
||||||
elif result.query_status == "query_error":
|
zone = f" [{row.zone}]" if row.zone else ""
|
||||||
lines.append(f" {label}: 查询异常")
|
return (
|
||||||
elif result.query_status == "no_local_data":
|
f" 第 {row.position} 名: {row.points} 分 / {row.played} 场 "
|
||||||
# API Key 已配置但本地无伤停记录
|
f"({row.won}胜{row.drawn}平{row.lost}负, 进{row.goals_for}失{row.goals_against} 净胜{row.goal_diff:+d}"
|
||||||
lines.append(f" {label}: 本地尚无伤停数据,请先采集")
|
f"{zg}){form}{zone}"
|
||||||
elif result.records:
|
)
|
||||||
n_records += len(result.records)
|
|
||||||
lines.append(f" {label}伤停({len(result.records)}人):")
|
for label, team_id in (("主队", header.home_team_id), ("客队", header.away_team_id)):
|
||||||
for inj in result.records[:8]:
|
row = next((r for r in rows if r.team_id == team_id), None)
|
||||||
reason = inj.reason or inj.injury_type or "未知"
|
if row is None:
|
||||||
lines.append(f" - {inj.player_name}: {reason}")
|
lines.append(f" {label}: 暂无积分榜数据(可能杯赛/赛季未开始)")
|
||||||
if len(result.records) > 8:
|
|
||||||
lines.append(f" ...及其他 {len(result.records) - 8} 人")
|
|
||||||
else:
|
else:
|
||||||
# success + 空列表 → 明确无伤停
|
n_records += 1
|
||||||
lines.append(f" {label}: 当前无伤停记录")
|
lines.append(f" {label} {header.home_name if label == '主队' else header.away_name}:")
|
||||||
|
lines.append(_fmt(row))
|
||||||
|
|
||||||
# 决定 has_data:
|
# 两队排名对比摘要
|
||||||
# - 两队都成功查询(即使为空) → True(明确知道名单)
|
home_row = next((r for r in rows if r.team_id == header.home_team_id), None)
|
||||||
# - 源未配置且无数据 → False
|
away_row = next((r for r in rows if r.team_id == header.away_team_id), None)
|
||||||
has_data = both_succeeded or (any_configured and n_records > 0)
|
if home_row and away_row:
|
||||||
|
diff = home_row.position - away_row.position # 正数=主队排名更靠前(名次更小)
|
||||||
|
lead = f"主队排名高 {diff} 位" if diff > 0 else (f"客队排名高 {-diff} 位" if diff < 0 else "两队同排名结构")
|
||||||
|
pts_diff = home_row.points - away_row.points
|
||||||
|
lines.append(f" 排名对比: {lead}, 分差 {pts_diff:+d}")
|
||||||
|
|
||||||
if not has_data:
|
# has_data: 两队都有行才算完整;只有一队时仍有价值,但标记不完整
|
||||||
# 保留详细状态文案(伤停源未配置/查询异常),而非通用「无数据」
|
has_data = n_records >= 1
|
||||||
return SliceResult(text="\n".join(lines), has_data=False, n_records=0)
|
return SliceResult(text="\n".join(lines), has_data=has_data, n_records=n_records)
|
||||||
|
|
||||||
return SliceResult(text="\n".join(lines), has_data=True, n_records=n_records)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -412,7 +412,7 @@ async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession |
|
|||||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
|
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
|
||||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
|
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
|
||||||
|
|
||||||
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
has_stats / has_standings 直接取切片显式声明的 has_data,
|
||||||
不再靠文案子串匹配(见审查报告 P2-1)。
|
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||||
|
|
||||||
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
||||||
@@ -448,14 +448,14 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
|
|||||||
parts.append(home_away_res.text)
|
parts.append(home_away_res.text)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
injuries_res = await injuries_slice(header, before=cutoff, db=db)
|
standings_res = await standings_slice(header, before=cutoff, db=db)
|
||||||
parts.append(injuries_res.text)
|
parts.append(standings_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_standings=standings_res.has_data,
|
||||||
match_dt=header.match_dt,
|
match_dt=header.match_dt,
|
||||||
cutoff=cutoff,
|
cutoff=cutoff,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
你是足球阵容完整性分析专家。分析以下两队的伤停与停赛信息,评估战力缺失程度。
|
|
||||||
|
|
||||||
{{context}}
|
|
||||||
|
|
||||||
分析要点:
|
|
||||||
- 核心球员缺阵影响(射手/组织核心/主力门将/后防中坚)
|
|
||||||
- 缺阵人数与位置分布(前场/中场/后场)
|
|
||||||
- 替补深度:缺阵是否有人可替
|
|
||||||
- 无数据时如实标注 data_sufficiency=none,不猜测
|
|
||||||
- 综合判断:哪支球队战力受损更严重
|
|
||||||
|
|
||||||
严格按此 JSON 输出,不要其他内容:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data_sufficiency": "high|medium|low|none",
|
|
||||||
"analysis": "<150 字内分析,量化战力缺失程度>",
|
|
||||||
"home_edge": <-1.0 到 1.0, 正数=客队伤停更严重(利主队)>,
|
|
||||||
"confidence": <0.0-1.0>,
|
|
||||||
"key_evidence": ["<证据1>", "<证据2>"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
你是足球联赛排名分析专家。分析以下两队在联赛积分榜上的位置、积分与近期走势,评估整体实力差距。
|
||||||
|
|
||||||
|
{{context}}
|
||||||
|
|
||||||
|
分析要点:
|
||||||
|
- 排名与分差:排名差距反映的整体实力层级,是否属于同档球队
|
||||||
|
- 攻防质量:进球/失球/净胜球与 xG 差(xgd)是否匹配,有无虚高或低估
|
||||||
|
- 赛程消耗:已赛场次差异(少赛场次可能反映赛程推迟或杯赛分心)
|
||||||
|
- 近期走势:form 串(如 WWDLL)显示的状态趋势,与排名是否一致
|
||||||
|
- 分区含义:争冠/欧战区/保级区的处境对比赛动机的影响
|
||||||
|
- 无数据时如实标注 data_sufficiency=none,不猜测
|
||||||
|
- 综合判断:哪支球队整体实力与动机占优
|
||||||
|
|
||||||
|
严格按此 JSON 输出,不要其他内容:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data_sufficiency": "high|medium|low|none",
|
||||||
|
"analysis": "<150 字内分析,量化两队实力差距>",
|
||||||
|
"home_edge": <-1.0 到 1.0, 正数=主队实力占优>,
|
||||||
|
"confidence": <0.0-1.0>,
|
||||||
|
"key_evidence": ["<证据1>", "<证据2>"]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -76,7 +76,7 @@ class TestOrchestratorWritesAgentWeights:
|
|||||||
AgentReport(agent="form", status="ok", analysis="good"),
|
AgentReport(agent="form", status="ok", analysis="good"),
|
||||||
AgentReport(agent="stats", status="error", analysis="failed"),
|
AgentReport(agent="stats", status="error", analysis="failed"),
|
||||||
AgentReport(agent="home_away", status="ok", analysis="good"),
|
AgentReport(agent="home_away", status="ok", analysis="good"),
|
||||||
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
|
AgentReport(agent="standings", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="h2h", status="error", analysis="failed"),
|
AgentReport(agent="h2h", status="error", analysis="failed"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class TestNoDataGate:
|
|||||||
|
|
||||||
def test_stub_no_data_report(self):
|
def test_stub_no_data_report(self):
|
||||||
from src.llm.agents.base import _stub_no_data
|
from src.llm.agents.base import _stub_no_data
|
||||||
r = _stub_no_data("injuries")
|
r = _stub_no_data("standings")
|
||||||
assert r.status == "no_data"
|
assert r.status == "no_data"
|
||||||
assert r.data_sufficiency == "none"
|
assert r.data_sufficiency == "none"
|
||||||
assert r.home_edge is None
|
assert r.home_edge is None
|
||||||
@@ -31,7 +31,7 @@ class TestNoDataGate:
|
|||||||
class TestPromptLoading:
|
class TestPromptLoading:
|
||||||
"""agent prompt 模板加载。"""
|
"""agent prompt 模板加载。"""
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", ["form", "stats", "home_away", "injuries", "h2h", "aggregator"])
|
@pytest.mark.parametrize("name", ["form", "stats", "home_away", "standings", "h2h", "aggregator"])
|
||||||
def test_all_prompts_exist(self, name):
|
def test_all_prompts_exist(self, name):
|
||||||
tpl = load_agent_prompt(name, "v1")
|
tpl = load_agent_prompt(name, "v1")
|
||||||
assert "{{context}}" in tpl or "{{agent_reports}}" in tpl
|
assert "{{context}}" in tpl or "{{agent_reports}}" in tpl
|
||||||
@@ -126,7 +126,7 @@ class TestRunAgent:
|
|||||||
async def empty_slice(header, before=None):
|
async def empty_slice(header, before=None):
|
||||||
return "── 伤停 ──\n 无数据"
|
return "── 伤停 ──\n 无数据"
|
||||||
|
|
||||||
spec = AgentSpec(name="injuries", system_prompt="s", slice_fn=empty_slice)
|
spec = AgentSpec(name="standings", system_prompt="s", slice_fn=empty_slice)
|
||||||
header = self._make_header()
|
header = self._make_header()
|
||||||
|
|
||||||
class ExplodingProvider:
|
class ExplodingProvider:
|
||||||
@@ -201,7 +201,7 @@ class TestOrchestratorAggregation:
|
|||||||
|
|
||||||
reports = [
|
reports = [
|
||||||
AgentReport(agent="h2h", status="ok", home_edge=0.5, subjective_confidence=0.8, analysis="a"),
|
AgentReport(agent="h2h", status="ok", home_edge=0.5, subjective_confidence=0.8, analysis="a"),
|
||||||
AgentReport(agent="injuries", status="no_data", data_sufficiency="none"),
|
AgentReport(agent="standings", status="no_data", data_sufficiency="none"),
|
||||||
]
|
]
|
||||||
text = _reports_to_json(reports)
|
text = _reports_to_json(reports)
|
||||||
data = json.loads(text)
|
data = json.loads(text)
|
||||||
@@ -270,7 +270,7 @@ class TestAgentWeightsValidation:
|
|||||||
|
|
||||||
w = validate_agent_weights({"form": 0.4, "h2h": 0.4, "bogus": 0.2, "stats": 0.4})
|
w = validate_agent_weights({"form": 0.4, "h2h": 0.4, "bogus": 0.2, "stats": 0.4})
|
||||||
assert "bogus" not in w
|
assert "bogus" not in w
|
||||||
assert set(w) <= {"form", "stats", "home_away", "injuries", "h2h"}
|
assert set(w) <= {"form", "stats", "home_away", "standings", "h2h"}
|
||||||
|
|
||||||
def test_out_of_range_clamped(self):
|
def test_out_of_range_clamped(self):
|
||||||
from src.llm.validation import validate_agent_weights
|
from src.llm.validation import validate_agent_weights
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
2. _is_stats_available: available_at is None + cutoff is None(实盘) → 可用(兼容旧数据)
|
2. _is_stats_available: available_at is None + cutoff is None(实盘) → 可用(兼容旧数据)
|
||||||
3. _is_stats_available: available_at > cutoff → 不可用
|
3. _is_stats_available: available_at > cutoff → 不可用
|
||||||
4. _is_stats_available: available_at <= cutoff → 可用
|
4. _is_stats_available: available_at <= cutoff → 可用
|
||||||
5. 写入策略: available_at = match_date + 2h 缓冲
|
5. 写入策略: bzzoiro 写入 available_at 使用 match_date + 2h 缓冲
|
||||||
6. cutoff 在缓冲内时不可用(available_at > cutoff → False)
|
6. cutoff 在缓冲内时不可用(available_at > cutoff → False)
|
||||||
|
7. stats 回填(bzzoiro event stats)也使用 2h 缓冲
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -73,37 +74,26 @@ class TestIsStatsAvailable:
|
|||||||
|
|
||||||
|
|
||||||
class TestWriteBufferStrategy:
|
class TestWriteBufferStrategy:
|
||||||
"""验证 bzzoiro/understat 写入 available_at 使用 match_date + 2h 缓冲。"""
|
"""验证 bzzoiro(events + stats 回填)写入 available_at 使用 match_date + 2h 缓冲。"""
|
||||||
|
|
||||||
def test_bzzoirot_new_match_available_at_is_two_hours_after_kickoff(self):
|
def test_bzzoirot_new_match_available_at_is_two_hours_after_kickoff(self):
|
||||||
"""bzzoiro 新建比赛时 available_at 应为开球 + 2 小时。"""
|
"""bzzoiro 新建比赛(stats 回填创建 MatchStats)时 available_at 应为开球 + 2 小时。"""
|
||||||
import inspect
|
import inspect
|
||||||
from src.data import bzzoiro
|
from src.data import bzzoiro
|
||||||
|
|
||||||
source = inspect.getsource(bzzoiro)
|
source = inspect.getsource(bzzoiro)
|
||||||
# 验证:使用 timedelta(hours=2) 作为缓冲
|
|
||||||
assert 'timedelta(hours=2)' in source, \
|
assert 'timedelta(hours=2)' in source, \
|
||||||
"bzzoiro 应使用 match_date + timedelta(hours=2) 作为 available_at"
|
"bzzoiro 应使用 match_date + timedelta(hours=2) 作为 available_at"
|
||||||
|
|
||||||
def test_bzzoirot_existing_match_uses_two_hour_buffer(self):
|
def test_bzzoirot_multiple_writes_use_two_hour_buffer(self):
|
||||||
"""bzzoiro 更新已有比赛时也应使用 2 小时缓冲。"""
|
"""bzzoiro 多处写入(创建/更新)都应使用 2 小时缓冲。"""
|
||||||
import inspect
|
import inspect
|
||||||
from src.data import bzzoiro
|
from src.data import bzzoiro
|
||||||
|
|
||||||
source = inspect.getsource(bzzoiro)
|
source = inspect.getsource(bzzoiro)
|
||||||
# 两处写入都应使用 timedelta(hours=2)
|
|
||||||
count = source.count('timedelta(hours=2)')
|
count = source.count('timedelta(hours=2)')
|
||||||
assert count >= 2, f"期望至少 2 处 timedelta(hours=2),实际 {count} 处"
|
assert count >= 2, f"期望至少 2 处 timedelta(hours=2),实际 {count} 处"
|
||||||
|
|
||||||
def test_understat_uses_two_hour_buffer(self):
|
|
||||||
"""understat 回填 xG 时也应使用 2 小时缓冲。"""
|
|
||||||
import inspect
|
|
||||||
from src.data import understat
|
|
||||||
|
|
||||||
source = inspect.getsource(understat)
|
|
||||||
assert 'timedelta(hours=2)' in source, \
|
|
||||||
"understat 应使用 match_date + timedelta(hours=2) 作为 available_at"
|
|
||||||
|
|
||||||
def test_cutoff_within_buffer_makes_stats_unavailable(self):
|
def test_cutoff_within_buffer_makes_stats_unavailable(self):
|
||||||
"""cutoff 在 2 小时缓冲内时,统计学不可用(回测防泄漏)。
|
"""cutoff 在 2 小时缓冲内时,统计学不可用(回测防泄漏)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
"""回归测试: injuries 入库 IntegrityError 后 inserted 计数准确。
|
|
||||||
|
|
||||||
验证:
|
|
||||||
1. flush 失败的批次不计入 inserted
|
|
||||||
2. 成功的批次正常计数
|
|
||||||
3. 总计数 = 成功批次记录数之和
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from src.data.injuries import ingest_injuries
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
|
||||||
"""模拟 AsyncSession,记录 flush 调用和 begin_nested 使用。"""
|
|
||||||
|
|
||||||
def __init__(self, fail_on_flush_indices: set[int] | None = None):
|
|
||||||
self.flush_count = 0
|
|
||||||
self.nested_count = 0
|
|
||||||
self.added_records = []
|
|
||||||
self.committed_batches = []
|
|
||||||
self.fail_on = fail_on_flush_indices or set()
|
|
||||||
|
|
||||||
async def execute(self, stmt):
|
|
||||||
class Result:
|
|
||||||
def all(self_inner):
|
|
||||||
return []
|
|
||||||
def scalar_one_or_none(self_inner):
|
|
||||||
return None
|
|
||||||
return Result()
|
|
||||||
|
|
||||||
async def get(self, cls, id):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def add(self, obj):
|
|
||||||
self.added_records.append({"player_id": obj.player_id, "fixture_id": obj.fixture_id})
|
|
||||||
|
|
||||||
async def flush(self):
|
|
||||||
self.flush_count += 1
|
|
||||||
if self.flush_count in self.fail_on:
|
|
||||||
raise IntegrityError("mock duplicate", None, None)
|
|
||||||
|
|
||||||
def begin_nested(self):
|
|
||||||
class NestedCtx:
|
|
||||||
async def __aenter__(nested_self):
|
|
||||||
return nested_self
|
|
||||||
async def __aexit__(nested_self, exc_type, exc, tb):
|
|
||||||
return exc_type is not None
|
|
||||||
return NestedCtx()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inserted_count_excludes_failed_batches():
|
|
||||||
"""flush 失败的批次不应计入 inserted。
|
|
||||||
|
|
||||||
场景:6 条记录,每批 2 条(BATCH_SIZE=2),第 2 批 flush 失败。
|
|
||||||
期望:inserted = 2(第 1 批成功) + 0(第 2 批失败) + 2(第 3 批成功) = 4
|
|
||||||
"""
|
|
||||||
session = FakeSession(fail_on_flush_indices={2}) # 第 2 次 flush 失败
|
|
||||||
|
|
||||||
# 构造 6 条待插入记录
|
|
||||||
pending = [
|
|
||||||
{"player_id": i, "player_name": f"Player{i}", "team_id": 1,
|
|
||||||
"fixture_id": 100 + i, "injury_type": "Hamstring",
|
|
||||||
"reason": "strain", "injury_date": None, "return_date": None}
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
|
|
||||||
# 临时覆盖 BATCH_SIZE 为 2
|
|
||||||
original = ingest_injuries.__globals__.get("BATCH_SIZE")
|
|
||||||
|
|
||||||
result = {"count": 0, "inserted": 0, "errors": []}
|
|
||||||
|
|
||||||
# 模拟核心逻辑(与 ingest_injuries 一致)
|
|
||||||
async def run():
|
|
||||||
BATCH_SIZE = 2 # 小批量便于测试
|
|
||||||
batch = []
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
if not batch:
|
|
||||||
return 0
|
|
||||||
count = len(batch)
|
|
||||||
async with session.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
session.add(obj)
|
|
||||||
await db_flush()
|
|
||||||
batch.clear()
|
|
||||||
return count
|
|
||||||
|
|
||||||
async def db_flush():
|
|
||||||
session.flush_count += 1
|
|
||||||
if session.flush_count in session.fail_on:
|
|
||||||
raise IntegrityError("mock", None, None)
|
|
||||||
session.committed_batches.append(count)
|
|
||||||
|
|
||||||
for rec in pending:
|
|
||||||
batch.append(type("Injury", (), rec))
|
|
||||||
if len(batch) >= BATCH_SIZE:
|
|
||||||
try:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
except IntegrityError:
|
|
||||||
batch.clear()
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
except IntegrityError:
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
await run()
|
|
||||||
|
|
||||||
# 第 1 批(0,1)成功,第 2 批(2,3)失败,第 3 批(4,5)成功
|
|
||||||
assert result["inserted"] == 4, f"期望 inserted=4,实际 {result['inserted']}"
|
|
||||||
print(f"PASS: inserted={result['inserted']} (排除失败批次)")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_success_count_is_total(self):
|
|
||||||
"""全部成功时,inserted 应等于总记录数。"""
|
|
||||||
session = FakeSession() # 无失败
|
|
||||||
|
|
||||||
pending = [
|
|
||||||
{"player_id": i, "player_name": f"P{i}", "team_id": 1,
|
|
||||||
"fixture_id": 100 + i, "injury_type": None,
|
|
||||||
"reason": None, "injury_date": None, "return_date": None}
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = {"inserted": 0}
|
|
||||||
BATCH_SIZE = 2
|
|
||||||
batch = []
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
if not batch:
|
|
||||||
return 0
|
|
||||||
count = len(batch)
|
|
||||||
async with session.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
session.add(obj)
|
|
||||||
await db_flush()
|
|
||||||
batch.clear()
|
|
||||||
return count
|
|
||||||
|
|
||||||
async def db_flush():
|
|
||||||
session.flush_count += 1
|
|
||||||
session.committed_batches.append(batch.copy())
|
|
||||||
|
|
||||||
for rec in pending:
|
|
||||||
batch.append(type("Injury", (), rec))
|
|
||||||
if len(batch) >= BATCH_SIZE:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
|
|
||||||
assert result["inserted"] == 6, f"期望 6,实际 {result['inserted']}"
|
|
||||||
print(f"PASS: 全部成功 inserted={result['inserted']}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(test_inserted_count_excludes_failed_batches())
|
|
||||||
asyncio.run(test_all_success_count_is_total())
|
|
||||||
print("\n=== ALL TESTS PASSED ===")
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"""回归测试: injuries IntegrityError 处理不再整批回滚。
|
|
||||||
|
|
||||||
模拟场景:连续插入多条伤停记录,中间一批触发 IntegrityError,
|
|
||||||
断言其它批次记录不会丢失。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.data import injuries as inj_mod
|
|
||||||
|
|
||||||
|
|
||||||
class FakeNestedCtx:
|
|
||||||
"""模拟 SQLAlchemy begin_nested() 上下文。
|
|
||||||
|
|
||||||
__enter__:标记进入 savepoint
|
|
||||||
__exit__:如果有异常,模拟 ROLLBACK TO SAVEPOINT(不清空已 flush 的对象)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, session):
|
|
||||||
self.session = session
|
|
||||||
self.rolled_back = False
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
if exc_type is not None:
|
|
||||||
# ROLLBACK TO SAVEPOINT — 不清空 session 中已存在的对象
|
|
||||||
self.rolled_back = True
|
|
||||||
return True # suppress exception
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
|
||||||
"""模拟 AsyncSession,记录 flush 调用和 begin_nested 使用。"""
|
|
||||||
|
|
||||||
def __init__(self, fail_on_flush_indices: set[int] | None = None):
|
|
||||||
self.flush_count = 0
|
|
||||||
self.nested_count = 0
|
|
||||||
self.flushed_records: list[dict] = []
|
|
||||||
self.added_records: list[dict] = []
|
|
||||||
self.fail_on = fail_on_flush_indices or set()
|
|
||||||
|
|
||||||
async def execute(self, stmt):
|
|
||||||
class Result:
|
|
||||||
def all(self_inner):
|
|
||||||
return []
|
|
||||||
return Result()
|
|
||||||
|
|
||||||
async def get(self, cls, id):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def add(self, obj):
|
|
||||||
self.added_records.append(obj)
|
|
||||||
|
|
||||||
async def flush(self):
|
|
||||||
self.flush_count += 1
|
|
||||||
if self.flush_count in self.fail_on:
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
raise IntegrityError("mock duplicate", None, None)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _nested_ctx(self):
|
|
||||||
return FakeNestedCtx(self)
|
|
||||||
|
|
||||||
def begin_nested(self):
|
|
||||||
self.nested_count += 1
|
|
||||||
return self._nested_ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_integrity_error_does_not_lose_other_batches():
|
|
||||||
"""核心测试:一批触发 IntegrityError,其它批次记录不丢失。
|
|
||||||
|
|
||||||
场景:3 批记录,第 2 批 flush 时 IntegrityError。
|
|
||||||
断言:第 1 批和第 3 批的记录仍存在于 flushed_records 中。
|
|
||||||
"""
|
|
||||||
session = FakeSession(fail_on_flush_indices={2}) # 第 2 次 flush 失败
|
|
||||||
|
|
||||||
# 构造 3 批记录,每批 2 条(BATCH_SIZE 用 2 方便测试)
|
|
||||||
pending = [
|
|
||||||
{"player_id": i, "player_name": f"Player{i}", "team_id": 1,
|
|
||||||
"fixture_id": 100 + i, "injury_type": "Hamstring",
|
|
||||||
"reason": "strain", "injury_date": None, "return_date": None}
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
|
|
||||||
# 临时覆盖 BATCH_SIZE
|
|
||||||
original_batch_size = 50
|
|
||||||
try:
|
|
||||||
inj_mod.ingest_injuries.__globals__['__dict__'] # no-op
|
|
||||||
|
|
||||||
# 手动模拟 ingest_injuries 的核心逻辑
|
|
||||||
batch = []
|
|
||||||
flushed_ids = []
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
if not batch:
|
|
||||||
return
|
|
||||||
async with session.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
session.add(obj)
|
|
||||||
await session.flush()
|
|
||||||
flushed_ids.extend([r["player_id"] for r in batch])
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
for rec in pending:
|
|
||||||
batch.append(rec)
|
|
||||||
if len(batch) >= 2: # BATCH_SIZE = 2
|
|
||||||
try:
|
|
||||||
await _flush_batch()
|
|
||||||
except Exception:
|
|
||||||
batch.clear()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 最终 flush
|
|
||||||
try:
|
|
||||||
await _flush_batch()
|
|
||||||
except Exception:
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 断言:flush 成功的记录是第 1 批(id=0,1)和第 3 批(id=4,5)
|
|
||||||
# 第 2 批(id=2,3)因 IntegrityError 被 savepoint 回滚
|
|
||||||
# 关键:第 1 批和第 3 批的记录必须仍在 flushed_ids 中
|
|
||||||
assert 0 in flushed_ids, "第 1 批记录 0 不应丢失"
|
|
||||||
assert 1 in flushed_ids, "第 1 批记录 1 不应丢失"
|
|
||||||
assert 4 in flushed_ids or 5 in flushed_ids, "第 3 批记录不应丢失"
|
|
||||||
# 第 2 批(flush 失败的)不应在 flushed_ids 中
|
|
||||||
assert 2 not in flushed_ids, "第 2 批应被回滚"
|
|
||||||
assert 3 not in flushed_ids, "第 2 批应被回滚"
|
|
||||||
print("PASS: IntegrityError 只回滚失败批次,其它批次不丢失")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_begin_nested_is_used():
|
|
||||||
"""验证 begin_nested() 被调用(而非全事务 rollback)。"""
|
|
||||||
session = FakeSession()
|
|
||||||
|
|
||||||
batch = [{"player_id": i, "player_name": f"P{i}", "team_id": 1,
|
|
||||||
"fixture_id": 100 + i, "injury_type": "None",
|
|
||||||
"reason": None, "injury_date": None, "return_date": None}
|
|
||||||
for i in range(3)]
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
if not batch:
|
|
||||||
return
|
|
||||||
async with session.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
session.add(obj)
|
|
||||||
await session.flush()
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _flush_batch()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 验证 begin_nested 被调用(说明使用了 savepoint)
|
|
||||||
assert session.nested_count >= 1, "应使用 begin_nested(SAVEPOINT)"
|
|
||||||
print(f"PASS: begin_nested 被调用 {session.nested_count} 次")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(test_integrity_error_does_not_lose_other_batches())
|
|
||||||
asyncio.run(test_begin_nested_is_used())
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""回归测试: 伤停切片区分「本地无数据」与「查询成功但空名单」。
|
|
||||||
|
|
||||||
验证:
|
|
||||||
1. API Key 已配置但 injuries 表无任何记录 → has_data=False
|
|
||||||
2. 有历史伤停记录但当前比赛日无缺阵 → has_data=True
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.data.injuries import InjuryQueryResult, get_injuries_for_match
|
|
||||||
from src.llm.context_builder import MatchHeader, injuries_slice
|
|
||||||
|
|
||||||
|
|
||||||
def _make_header():
|
|
||||||
return MatchHeader(
|
|
||||||
match_id=999, home_name="A", away_name="B",
|
|
||||||
league_name="X", season=None, match_date="?",
|
|
||||||
match_dt=None, stage=None,
|
|
||||||
home_team_id=1, away_team_id=2, league_id=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNoLocalData:
|
|
||||||
"""区分「本地无数据」与「查询成功但空名单」。"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_local_data_yields_has_data_false(self):
|
|
||||||
"""API Key 已配置但 injuries 表无任何记录 → has_data=False。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="no_local_data")
|
|
||||||
|
|
||||||
with patch("src.data.injuries.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is False, "no_local_data 应 has_data=False"
|
|
||||||
assert "本地尚无伤停数据" in result.text
|
|
||||||
print("PASS: no_local_data → has_data=False")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_success_empty_yields_has_data_true(self):
|
|
||||||
"""API Key 已配置且查询成功 + 空名单 → has_data=True。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="success")
|
|
||||||
|
|
||||||
with patch("src.data.injuries.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is True, "success + 空名单应 has_data=True"
|
|
||||||
assert "当前无伤停记录" in result.text
|
|
||||||
print("PASS: success + empty → has_data=True")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mixed_status_uses_has_data_false(self):
|
|
||||||
"""主队 success + 客队 no_local_data → has_data=False(保守)。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
if team_id == 1:
|
|
||||||
return InjuryQueryResult(records=[], query_status="success")
|
|
||||||
return InjuryQueryResult(records=[], query_status="no_local_data")
|
|
||||||
|
|
||||||
with patch("src.data.injuries.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
# 任一 no_local_data → 保守 has_data=False
|
|
||||||
assert result.has_data is False
|
|
||||||
print("PASS: mixed status保守 has_data=False")
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
"""回归测试: 伤停数据管线 5 项正确性修复。
|
|
||||||
|
|
||||||
Fix 1: IntegrityError 后不整批回滚
|
|
||||||
Fix 2: return_date 正确解析
|
|
||||||
Fix 3: retrieved_at 用 date() 比较避免当天不可见
|
|
||||||
Fix 4: partial unique index 防止 NULL 重复
|
|
||||||
Fix 5: 缓存 TTL 从 7 天改为 6 小时
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.data.injuries import _CACHE_TTL_HOURS, fetch_injuries
|
|
||||||
|
|
||||||
|
|
||||||
class TestCacheTTL:
|
|
||||||
"""Fix 5: 缓存 TTL 应为 6 小时。"""
|
|
||||||
|
|
||||||
def test_cache_ttl_is_6_hours(self):
|
|
||||||
assert _CACHE_TTL_HOURS == 6, f"缓存 TTL 应为 6 小时,实际 {_CACHE_TTL_HOURS}"
|
|
||||||
|
|
||||||
def test_cache_expiry_logic(self):
|
|
||||||
"""验证缓存过期逻辑:超过 TTL 返回 None(触发重新采集)。"""
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
cache_file = Path(tmpdir) / "test_cache.json"
|
|
||||||
cache_file.write_text("[]")
|
|
||||||
|
|
||||||
# 模拟 7 小时前写入
|
|
||||||
old_time = time.time() - 7 * 3600
|
|
||||||
import os
|
|
||||||
os.utime(cache_file, (old_time, old_time))
|
|
||||||
|
|
||||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
|
||||||
assert age_hours > _CACHE_TTL_HOURS, "7 小时前的缓存应已过期"
|
|
||||||
|
|
||||||
def test_cache_hit_within_ttl(self):
|
|
||||||
"""验证 TTL 内缓存命中。"""
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
cache_file = Path(tmpdir) / "test_cache.json"
|
|
||||||
cache_file.write_text("[]")
|
|
||||||
|
|
||||||
# 1 小时前写入
|
|
||||||
old_time = time.time() - 3600
|
|
||||||
import os
|
|
||||||
os.utime(cache_file, (old_time, old_time))
|
|
||||||
|
|
||||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
|
||||||
assert age_hours < _CACHE_TTL_HOURS, "1 小时前的缓存应在 TTL 内"
|
|
||||||
|
|
||||||
|
|
||||||
class TestReturnDateParsing:
|
|
||||||
"""Fix 2: return_date 应从 API 响应正确解析并写入。"""
|
|
||||||
|
|
||||||
def test_parse_return_date_iso(self):
|
|
||||||
"""ISO 格式 return_date 应正确解析为 date 对象。"""
|
|
||||||
from datetime import datetime, date
|
|
||||||
raw = "2026-02-15"
|
|
||||||
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
||||||
assert dt.date() == date(2026, 2, 15)
|
|
||||||
|
|
||||||
def test_parse_return_date_with_time(self):
|
|
||||||
"""带时间的 return_date 应截取日期部分。"""
|
|
||||||
from datetime import datetime, date
|
|
||||||
raw = "2026-03-01T00:00:00Z"
|
|
||||||
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
||||||
assert dt.date() == date(2026, 3, 1)
|
|
||||||
|
|
||||||
def test_parse_return_date_none(self):
|
|
||||||
"""None 或空值应返回 None。"""
|
|
||||||
return_date_raw = None
|
|
||||||
return_date = None
|
|
||||||
if return_date_raw:
|
|
||||||
return_date = "should not reach"
|
|
||||||
assert return_date is None
|
|
||||||
|
|
||||||
def test_parse_return_date_invalid(self):
|
|
||||||
"""无效日期应返回 None 而非抛异常。"""
|
|
||||||
from datetime import datetime
|
|
||||||
raw = "invalid-date"
|
|
||||||
return_date = None
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
||||||
return_date = dt.date()
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
assert return_date is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueryDateComparison:
|
|
||||||
"""Fix 3: retrieved_at 比较应使用 date() 避免时区截断。"""
|
|
||||||
|
|
||||||
def test_date_comparison_handles_same_day(self):
|
|
||||||
"""核心 bug: 当天白天采到的数据应对当晚比赛可见。
|
|
||||||
|
|
||||||
retrieved_at = 2026-01-15 14:00:00+00 (timestamptz)
|
|
||||||
as_of = 2026-01-15 (date)
|
|
||||||
|
|
||||||
错误的比较: retrieved_at <= as_of
|
|
||||||
→ PostgreSQL 将 as_of 视为 2026-01-15 00:00:00+00
|
|
||||||
→ 14:00 <= 00:00 → False → 数据不可见!
|
|
||||||
|
|
||||||
正确的比较: date(retrieved_at) <= as_of
|
|
||||||
→ 2026-01-15 <= 2026-01-15 → True → 数据可见
|
|
||||||
"""
|
|
||||||
from datetime import datetime, date, timezone
|
|
||||||
|
|
||||||
retrieved_at = datetime(2026, 1, 15, 14, 0, tzinfo=timezone.utc)
|
|
||||||
as_of_date = date(2026, 1, 15)
|
|
||||||
|
|
||||||
# 错误的比较方式(原 bug)
|
|
||||||
# PostgreSQL 会将 date 转为 timestamptz at midnight
|
|
||||||
as_of_as_datetime = datetime(2026, 1, 15, 0, 0, tzinfo=timezone.utc)
|
|
||||||
wrong_result = retrieved_at <= as_of_as_datetime # False
|
|
||||||
|
|
||||||
# 正确的比较方式(修复后)
|
|
||||||
correct_result = retrieved_at.date() <= as_of_date # True
|
|
||||||
|
|
||||||
assert wrong_result is False, "原 bug 演示: 白天数据对当晚比赛不可见"
|
|
||||||
assert correct_result is True, "修复后: 白天数据对当晚比赛可见"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartialUniqueIndex:
|
|
||||||
"""Fix 4: partial unique index 防止 NULL 重复。"""
|
|
||||||
|
|
||||||
def test_orm_declares_partial_index(self):
|
|
||||||
"""ORM 模型应声明 partial unique index。"""
|
|
||||||
from sqlalchemy import and_
|
|
||||||
from src.db.models import Injury
|
|
||||||
|
|
||||||
# 验证 __table_args__ 包含 partial index
|
|
||||||
found_partial = False
|
|
||||||
for arg in Injury.__table_args__:
|
|
||||||
if hasattr(arg, "name") and arg.name == "ix_injuries_player_fixture":
|
|
||||||
# 验证是 unique 且有 postgresql_where
|
|
||||||
assert arg.unique is True, "应为唯一索引"
|
|
||||||
# postgresql_where 应排除 NULL
|
|
||||||
found_partial = True
|
|
||||||
|
|
||||||
assert found_partial, "Injury 模型应声明 ix_injuries_player_fixture 索引"
|
|
||||||
|
|
||||||
def test_migration_creates_partial_index(self):
|
|
||||||
"""迁移文件应包含 partial index 创建逻辑。"""
|
|
||||||
import os
|
|
||||||
migration_path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0012_injuries_partial_unique_and_return_date.py"
|
|
||||||
assert os.path.exists(migration_path), "迁移文件 0012 应存在"
|
|
||||||
|
|
||||||
with open(migration_path) as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
assert "CREATE UNIQUE INDEX ix_injuries_player_fixture" in content
|
|
||||||
assert "WHERE player_id IS NOT NULL" in content
|
|
||||||
assert "fixture_id IS NOT NULL" in content
|
|
||||||
|
|
||||||
|
|
||||||
class TestInjuriesSliceIntegration:
|
|
||||||
"""验证 injuries_slice 仍正常工作(未被破坏)。"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_injuries_slice_with_cutoff(self):
|
|
||||||
"""injuries_slice 应正确传递 before=cutoff 到 get_injuries_for_match。"""
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from src.llm.context_builder import injuries_slice, MatchHeader
|
|
||||||
|
|
||||||
header = MatchHeader(
|
|
||||||
match_id=999, home_name="A", away_name="B",
|
|
||||||
league_name="X", season=None, match_date="?",
|
|
||||||
match_dt=datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc),
|
|
||||||
stage=None, home_team_id=1, away_team_id=2, league_id=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
cutoff = datetime(2026, 1, 14, 20, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
import src.llm.context_builder as cb
|
|
||||||
orig = cb.get_injuries_for_match
|
|
||||||
|
|
||||||
captured_before = []
|
|
||||||
|
|
||||||
async def mock_get_injuries(db, team_id, match_date, as_of=None):
|
|
||||||
captured_before.append((team_id, match_date, as_of))
|
|
||||||
return []
|
|
||||||
|
|
||||||
cb.get_injuries_for_match = mock_get_injuries
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await injuries_slice(header, before=cutoff)
|
|
||||||
assert str(result) is not None
|
|
||||||
# 验证 before 参数被传递到 get_injuries_for_match
|
|
||||||
assert len(captured_before) == 2 # home + away
|
|
||||||
for team_id, match_date, as_of in captured_before:
|
|
||||||
# as_of 应等于 before (cutoff)
|
|
||||||
assert as_of == cutoff or (hasattr(as_of, 'date') and as_of.date() == cutoff.date()), \
|
|
||||||
f"as_of 应为 cutoff,实际 {as_of}"
|
|
||||||
finally:
|
|
||||||
cb.get_injuries_for_match = orig
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
"""回归测试: 伤停切片区分「查询成功但无人伤停」与「无数据/未接入」。
|
|
||||||
|
|
||||||
验证:
|
|
||||||
1. 查询成功 + 空结果 → has_data=True
|
|
||||||
2. 源未配置 → has_data=False
|
|
||||||
3. 查询异常 → has_data=False
|
|
||||||
4. 查询成功 + 有数据 → has_data=True
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.data.injuries import InjuryQueryResult, get_injuries_for_match
|
|
||||||
from src.llm.context_builder import MatchHeader, injuries_slice
|
|
||||||
|
|
||||||
|
|
||||||
def _make_header():
|
|
||||||
return MatchHeader(
|
|
||||||
match_id=999, home_name="A", away_name="B",
|
|
||||||
league_name="X", season=None, match_date="?",
|
|
||||||
match_date=None, stage=None,
|
|
||||||
home_team_id=1, away_team_id=2, league_id=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInjuryQueryResult:
|
|
||||||
"""InjuryQueryResult 基础属性。"""
|
|
||||||
|
|
||||||
def test_has_data_success(self):
|
|
||||||
result = InjuryQueryResult(records=[], query_status="success")
|
|
||||||
assert result.has_data is True
|
|
||||||
|
|
||||||
def test_has_data_source_not_configured(self):
|
|
||||||
result = InjuryQueryResult(records=[], query_status="source_not_configured")
|
|
||||||
assert result.has_data is False
|
|
||||||
|
|
||||||
def test_has_data_query_error(self):
|
|
||||||
result = InjuryQueryResult(records=[], query_status="query_error")
|
|
||||||
assert result.has_data is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestInjuriesSliceEmptyVsNotConfigured:
|
|
||||||
"""injuries_slice 应区分「查询成功但为空」与「无数据/未接入」。"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_empty_result_has_data_true(self):
|
|
||||||
"""查询成功 + 空结果 → has_data=True,文案显示「当前无伤停记录」。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
# Mock get_injuries_for_match 返回成功但空的结果
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="success")
|
|
||||||
|
|
||||||
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is True, "查询成功+空结果应 has_data=True"
|
|
||||||
assert "当前无伤停记录" in result.text, "文案应表明无伤停"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_source_not_configured_has_data_false(self):
|
|
||||||
"""源未配置 → has_data=False。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="source_not_configured")
|
|
||||||
|
|
||||||
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is False, "源未配置应 has_data=False"
|
|
||||||
assert "伤停源未配置" in result.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_query_error_has_data_false(self):
|
|
||||||
"""查询异常 → has_data=False。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="query_error")
|
|
||||||
|
|
||||||
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is False, "查询异常应 has_data=False"
|
|
||||||
assert "查询异常" in result.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_with_records_has_data_true(self):
|
|
||||||
"""查询成功 + 有数据 → has_data=True。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
mock_inj = MagicMock()
|
|
||||||
mock_inj.reason = "Hamstring"
|
|
||||||
mock_inj.injury_type = None
|
|
||||||
mock_inj.player_name = "Player A"
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
if team_id == 1:
|
|
||||||
return InjuryQueryResult(records=[mock_inj], query_status="success")
|
|
||||||
return InjuryQueryResult(records=[], query_status="success")
|
|
||||||
|
|
||||||
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is True
|
|
||||||
assert "Player A" in result.text
|
|
||||||
@@ -30,7 +30,7 @@ def _all_error_reports():
|
|||||||
AgentReport(agent="form", status="error", analysis="slice failed"),
|
AgentReport(agent="form", status="error", analysis="slice failed"),
|
||||||
AgentReport(agent="stats", status="error", analysis="slice failed"),
|
AgentReport(agent="stats", status="error", analysis="slice failed"),
|
||||||
AgentReport(agent="home_away", status="error", analysis="slice failed"),
|
AgentReport(agent="home_away", status="error", analysis="slice failed"),
|
||||||
AgentReport(agent="injuries", status="error", analysis="slice failed"),
|
AgentReport(agent="standings", status="error", analysis="slice failed"),
|
||||||
AgentReport(agent="h2h", status="error", analysis="slice failed"),
|
AgentReport(agent="h2h", status="error", analysis="slice failed"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ def _all_no_data_reports():
|
|||||||
AgentReport(agent="form", status="no_data", analysis="无数据"),
|
AgentReport(agent="form", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="stats", status="no_data", analysis="无数据"),
|
AgentReport(agent="stats", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
|
AgentReport(agent="standings", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ def _mixed_reports():
|
|||||||
AgentReport(agent="form", status="ok", analysis="good"),
|
AgentReport(agent="form", status="ok", analysis="good"),
|
||||||
AgentReport(agent="stats", status="error", analysis="failed"),
|
AgentReport(agent="stats", status="error", analysis="failed"),
|
||||||
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="injuries", status="error", analysis="failed"),
|
AgentReport(agent="standings", status="error", analysis="failed"),
|
||||||
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user