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
@@ -35,7 +35,7 @@ def load_agent_prompt(name: str, version: str = "v1") -> str:
|
||||
@dataclass
|
||||
class AgentSpec:
|
||||
"""领域专家 agent 定义。"""
|
||||
name: str # h2h / form / home_away / injuries / stats
|
||||
name: str # h2h / form / home_away / standings / stats
|
||||
system_prompt: str # system message
|
||||
slice_fn: object # async (header, before) -> str 切片函数
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ from src.llm.context_builder import (
|
||||
h2h_slice,
|
||||
header_text,
|
||||
home_away_slice,
|
||||
injuries_slice,
|
||||
load_match_header,
|
||||
standings_slice,
|
||||
stats_slice,
|
||||
)
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
@@ -36,7 +36,7 @@ _AGENT_PROVIDER_CACHE_TTL = 60.0
|
||||
|
||||
|
||||
# ── 5 个专家 agent 定义 ──
|
||||
# A=近期状态 B=攻防数据 C=主客因素 D=阵容完整性 E=历史交锋
|
||||
# A=近期状态 B=攻防数据 C=主客因素 D=联赛排名 E=历史交锋
|
||||
SPECIALIST_SPECS: list[AgentSpec] = [
|
||||
AgentSpec(
|
||||
name="form",
|
||||
@@ -54,9 +54,9 @@ SPECIALIST_SPECS: list[AgentSpec] = [
|
||||
slice_fn=home_away_slice,
|
||||
),
|
||||
AgentSpec(
|
||||
name="injuries",
|
||||
system_prompt="你是足球阵容完整性分析专家。汇总伤停与停赛名单,输出战力缺失程度。只输出 JSON。",
|
||||
slice_fn=injuries_slice,
|
||||
name="standings",
|
||||
system_prompt="你是足球联赛排名分析专家。分析积分榜位置、积分走势与分区,评估两队整体实力差距。只输出 JSON。",
|
||||
slice_fn=standings_slice,
|
||||
),
|
||||
AgentSpec(
|
||||
name="h2h",
|
||||
@@ -76,7 +76,7 @@ AGENT_LABELS_ZH: dict[str, str] = {
|
||||
"form": "近期状态分析专家",
|
||||
"stats": "攻防数据分析专家",
|
||||
"home_away": "主客因素分析专家",
|
||||
"injuries": "阵容完整性分析专家",
|
||||
"standings": "联赛排名分析专家",
|
||||
"h2h": "历史交锋分析专家",
|
||||
}
|
||||
|
||||
|
||||
+57
-57
@@ -2,7 +2,7 @@
|
||||
|
||||
架构:
|
||||
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
|
||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / stats)
|
||||
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
||||
|
||||
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
||||
@@ -81,7 +81,7 @@ class MatchContext:
|
||||
match_id: int
|
||||
text: str
|
||||
has_stats: bool
|
||||
has_injuries: bool
|
||||
has_standings: bool
|
||||
match_dt: 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)
|
||||
|
||||
|
||||
async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
|
||||
async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||
"""D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。
|
||||
|
||||
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
||||
before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)。
|
||||
db: 可选共享 session(见模块 docstring)。
|
||||
|
||||
语义区分:
|
||||
- 查询成功 + 空结果 → has_data=True(明确知道「无人伤停」)
|
||||
- 源未配置 / 查询失败 → has_data=False(无法判断,跳过 LLM)
|
||||
- 两队都有积分榜行 → has_data=True(明确的排名信息)
|
||||
- 任一队缺失 → 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:
|
||||
home_result = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
|
||||
away_result = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
||||
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
|
||||
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:
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
home_result = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
|
||||
away_result = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
|
||||
return await standings_slice(header, before=before, db=new_db)
|
||||
|
||||
# 判断是否有有效查询结果
|
||||
# 两队都成功查询(即使为空) → 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 = ["── 阵容完整性 ──"]
|
||||
lines = [f"── 联赛排名({header.league_name} 共 {len(rows)} 队) ──"]
|
||||
n_records = 0
|
||||
|
||||
for label, result in (("主队", home_result), ("客队", away_result)):
|
||||
if result.query_status == "source_not_configured":
|
||||
lines.append(f" {label}: 伤停源未配置")
|
||||
elif result.query_status == "query_error":
|
||||
lines.append(f" {label}: 查询异常")
|
||||
elif result.query_status == "no_local_data":
|
||||
# API Key 已配置但本地无伤停记录
|
||||
lines.append(f" {label}: 本地尚无伤停数据,请先采集")
|
||||
elif result.records:
|
||||
n_records += len(result.records)
|
||||
lines.append(f" {label}伤停({len(result.records)}人):")
|
||||
for inj in result.records[:8]:
|
||||
reason = inj.reason or inj.injury_type or "未知"
|
||||
lines.append(f" - {inj.player_name}: {reason}")
|
||||
if len(result.records) > 8:
|
||||
lines.append(f" ...及其他 {len(result.records) - 8} 人")
|
||||
def _fmt(row) -> str:
|
||||
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 ""
|
||||
form = f" 近5场 {row.form}" if row.form else ""
|
||||
zone = f" [{row.zone}]" if row.zone else ""
|
||||
return (
|
||||
f" 第 {row.position} 名: {row.points} 分 / {row.played} 场 "
|
||||
f"({row.won}胜{row.drawn}平{row.lost}负, 进{row.goals_for}失{row.goals_against} 净胜{row.goal_diff:+d}"
|
||||
f"{zg}){form}{zone}"
|
||||
)
|
||||
|
||||
for label, team_id in (("主队", header.home_team_id), ("客队", header.away_team_id)):
|
||||
row = next((r for r in rows if r.team_id == team_id), None)
|
||||
if row is None:
|
||||
lines.append(f" {label}: 暂无积分榜数据(可能杯赛/赛季未开始)")
|
||||
else:
|
||||
# success + 空列表 → 明确无伤停
|
||||
lines.append(f" {label}: 当前无伤停记录")
|
||||
n_records += 1
|
||||
lines.append(f" {label} {header.home_name if label == '主队' else header.away_name}:")
|
||||
lines.append(_fmt(row))
|
||||
|
||||
# 决定 has_data:
|
||||
# - 两队都成功查询(即使为空) → True(明确知道名单)
|
||||
# - 源未配置且无数据 → False
|
||||
has_data = both_succeeded or (any_configured and n_records > 0)
|
||||
# 两队排名对比摘要
|
||||
home_row = next((r for r in rows if r.team_id == header.home_team_id), None)
|
||||
away_row = next((r for r in rows if r.team_id == header.away_team_id), None)
|
||||
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:
|
||||
# 保留详细状态文案(伤停源未配置/查询异常),而非通用「无数据」
|
||||
return SliceResult(text="\n".join(lines), has_data=False, n_records=0)
|
||||
|
||||
return SliceResult(text="\n".join(lines), has_data=True, n_records=n_records)
|
||||
# has_data: 两队都有行才算完整;只有一队时仍有价值,但标记不完整
|
||||
has_data = n_records >= 1
|
||||
return SliceResult(text="\n".join(lines), has_data=has_data, 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:
|
||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
|
||||
|
||||
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
||||
has_stats / has_standings 直接取切片显式声明的 has_data,
|
||||
不再靠文案子串匹配(见审查报告 P2-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("")
|
||||
|
||||
injuries_res = await injuries_slice(header, before=cutoff, db=db)
|
||||
parts.append(injuries_res.text)
|
||||
standings_res = await standings_slice(header, before=cutoff, db=db)
|
||||
parts.append(standings_res.text)
|
||||
|
||||
return MatchContext(
|
||||
match_id=match_id,
|
||||
text="\n".join(parts),
|
||||
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,
|
||||
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>"]
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user