feat: 足球 LLM 预测服务初始提交

Profeto — 给 LLM 提供数据,让 LLM 预测足球比分。

核心模块:
- FastAPI 后端 + PostgreSQL (SQLAlchemy async)
- 多 Agent LLM 预测 (5 专家 + 终裁)
- 数据采集 (bzzoiro / understat / injuries)
- React 前端 (Vite + Tailwind)

包含:
- 数据源抽象 (DataSource 协议 + 注册表)
- Alembic 数据库迁移
- Prompt 模板 (单/多 Agent)
- 核心路径单元测试
This commit is contained in:
shangfangjian
2026-09-09 02:10:47 +08:00
commit 0a27b18c27
74 changed files with 5667 additions and 0 deletions
View File
+11
View File
@@ -0,0 +1,11 @@
"""多 agent 预测层。"""
from src.llm.agents.base import AgentReport, AgentSpec, run_agent
from src.llm.agents.orchestrator import MultiPredictResult, predict_match_multi
__all__ = [
"AgentReport",
"AgentSpec",
"run_agent",
"MultiPredictResult",
"predict_match_multi",
]
+183
View File
@@ -0,0 +1,183 @@
"""Agent 基础设施: spec 定义 + 执行器。
执行语义:
1. 数据切片为空 / 明确 no_data → 跳过 LLM, 直接返回 stub(省 token 防幻觉)
2. LLM 调用失败 → fail-open, 报告标记 status=error, 不阻断整体
3. 解析失败(LLM 没输出合法 JSON) → status=parse_error
"""
from __future__ import annotations
import functools
import logging
from dataclasses import dataclass, field
from pathlib import Path
from src.llm.context_builder import MatchHeader
from src.llm.provider import LLMProvider, LLMResponse
logger = logging.getLogger(__name__)
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts" / "agents"
NO_DATA_SENTINELS = ("无数据", "no data", "no_data")
@functools.lru_cache(maxsize=16)
def load_agent_prompt(name: str, version: str = "v1") -> str:
"""缓存加载 agent prompt 模板。"""
path = _PROMPT_DIR / f"{name}_{version}.md"
if not path.exists():
raise FileNotFoundError(f"agent prompt 不存在: {path}")
with open(path, encoding="utf-8") as f:
return f.read()
@dataclass
class AgentSpec:
"""领域专家 agent 定义。"""
name: str # h2h / form / standings / injuries / xg
system_prompt: str # system message
slice_fn: object # async (header, before) -> str 切片函数
@dataclass
class AgentReport:
"""专家 agent 统一输出契约。"""
agent: str
status: str = "ok" # ok | no_data | error | parse_error
data_sufficiency: str = "medium" # high | medium | low | none
analysis: str = ""
home_edge: float | None = None # -1.0 ~ 1.0, 正=利主队
confidence: float | None = None # 0.0 ~ 1.0
key_evidence: list[str] = field(default_factory=list)
# xg agent 专属
exp_home_goals: float | None = None
exp_away_goals: float | None = None
probable_score: str | None = None
# 元信息
model: str = ""
latency_ms: int | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
def to_dict(self) -> dict:
return {
"agent": self.agent,
"status": self.status,
"data_sufficiency": self.data_sufficiency,
"analysis": self.analysis,
"home_edge": self.home_edge,
"confidence": self.confidence,
"key_evidence": self.key_evidence,
"exp_home_goals": self.exp_home_goals,
"exp_away_goals": self.exp_away_goals,
"probable_score": self.probable_score,
"model": self.model,
"latency_ms": self.latency_ms,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
}
def _is_no_data(slice_text: str) -> bool:
"""切片是否全无数据(除了标题行全是无数据)。"""
body = [ln.strip() for ln in slice_text.splitlines() if ln.strip()]
# 去掉标题行(── 开头)
content = [ln for ln in body if not ln.startswith("──")]
if not content:
return True
return all(any(s in ln for s in NO_DATA_SENTINELS) for ln in content)
def _stub_no_data(agent: str) -> AgentReport:
return AgentReport(
agent=agent,
status="no_data",
data_sufficiency="none",
analysis="该维度无数据,跳过分析。",
)
def _parse_report(agent: str, parsed: dict, resp: LLMResponse, model: str) -> AgentReport:
"""把 LLM JSON 输出解析为 AgentReport,字段宽容处理。"""
def _f(v, default=None):
try:
return float(v) if v is not None else default
except (TypeError, ValueError):
return default
suff = str(parsed.get("data_sufficiency", "medium")).lower()
if suff not in ("high", "medium", "low", "none"):
suff = "medium"
evidence = parsed.get("key_evidence") or []
if isinstance(evidence, str):
evidence = [evidence]
score = parsed.get("probable_score")
if isinstance(score, dict):
score = f"{score.get('home', '?')}-{score.get('away', '?')}"
return AgentReport(
agent=agent,
status="ok",
data_sufficiency=suff,
analysis=str(parsed.get("analysis", ""))[:600],
home_edge=_f(parsed.get("home_edge")),
confidence=_f(parsed.get("confidence")),
key_evidence=[str(e)[:120] for e in evidence[:5]],
exp_home_goals=_f(parsed.get("exp_home_goals")),
exp_away_goals=_f(parsed.get("exp_away_goals")),
probable_score=score if isinstance(score, str) else None,
model=model,
latency_ms=resp.latency_ms,
prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens,
)
async def run_agent(
spec: AgentSpec,
header: MatchHeader,
provider: LLMProvider,
*,
before=None,
version: str = "v1",
) -> AgentReport:
"""执行单个专家 agent: 切片 → no_data 门控 → 调 LLM → 解析报告。"""
# 1. 数据切片
try:
slice_text = await spec.slice_fn(header, before=before)
except Exception as e:
logger.exception("agent %s slice failed", spec.name)
return AgentReport(agent=spec.name, status="error", analysis=f"数据切片失败: {e}")
# 2. no_data 门控: 切片无数据 → 不调 LLM
if _is_no_data(slice_text):
logger.debug("agent %s: slice is no_data, skipping LLM", spec.name)
return _stub_no_data(spec.name)
# 3. 拼 prompt(模板中 {{context}} 为切片占位)
template = load_agent_prompt(spec.name, version)
user_prompt = template.replace("{{context}}", slice_text)
# 4. 调 LLM
resp = await provider.chat(
system=spec.system_prompt,
user=user_prompt,
json_mode=True,
temperature=0.2,
max_tokens=600,
)
if resp.error:
logger.warning("agent %s LLM failed: %s", spec.name, resp.error)
return AgentReport(agent=spec.name, status="error", analysis=f"LLM 调用失败: {resp.error}")
# 5. 解析
if not resp.parsed:
return AgentReport(
agent=spec.name,
status="parse_error",
analysis=f"LLM 输出无法解析为 JSON: {resp.content[:200]}",
)
return _parse_report(spec.name, resp.parsed, resp, provider.model)
+224
View File
@@ -0,0 +1,224 @@
"""多 agent 预测编排: 并行专家 → 终裁 → 存库。"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from src.core.config import settings
from src.db.base import AsyncSessionLocal
from src.db.models import Match, Prediction
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
from src.llm.context_builder import (
MatchHeader,
form_slice,
h2h_slice,
header_text,
home_away_slice,
injuries_slice,
load_match_header,
stats_slice,
)
from src.llm.provider import LLMProvider, get_default_provider
logger = logging.getLogger(__name__)
# ── 5 个专家 agent 定义 ──
# A=近期状态 B=攻防数据 C=主客因素 D=阵容完整性 E=历史交锋
SPECIALIST_SPECS: list[AgentSpec] = [
AgentSpec(
name="form",
system_prompt="你是足球近期状态分析专家。分析比分与关键事件,输出近期走势判断。只输出 JSON。",
slice_fn=form_slice,
),
AgentSpec(
name="stats",
system_prompt="你是足球攻防数据分析专家。评估进球、射门与控球,输出攻防强度。只输出 JSON。",
slice_fn=stats_slice,
),
AgentSpec(
name="home_away",
system_prompt="你是足球主客因素分析专家。对比主场与客场表现,评估地理优势影响。只输出 JSON。",
slice_fn=home_away_slice,
),
AgentSpec(
name="injuries",
system_prompt="你是足球阵容完整性分析专家。汇总伤停与停赛名单,输出战力缺失程度。只输出 JSON。",
slice_fn=injuries_slice,
),
AgentSpec(
name="h2h",
system_prompt="你是足球历史交锋分析专家。分析过去数年以及近期的交手数据,提取交手规律。只输出 JSON。",
slice_fn=h2h_slice,
),
]
AGGREGATOR_SYSTEM = "你是足球预测终裁专家。综合各领域报告输出最终预测。只输出 JSON。"
@dataclass
class MultiPredictResult:
prediction_id: int
provider: str
model: str
prompt_version: str
mode: str
pred_home_goals: float | None
pred_away_goals: float | None
pred_1x2: str | None
confidence: float | None
reasoning: str | None
agent_outputs: list[dict]
agent_weights: dict | None
context: str
latency_ms: int | None
raw: dict | None
def _get_specialist_provider() -> LLMProvider:
"""专家模型: LLM_SPECIALIST_MODEL 回落 LLM_MODEL。"""
p = get_default_provider()
if settings.LLM_SPECIALIST_MODEL:
p.model = settings.LLM_SPECIALIST_MODEL
return p
def _get_aggregator_provider() -> LLMProvider:
"""终裁模型: LLM_AGGREGATOR_MODEL 回落 LLM_MODEL。"""
p = get_default_provider()
if settings.LLM_AGGREGATOR_MODEL:
p.model = settings.LLM_AGGREGATOR_MODEL
return p
async def run_specialists(
header: MatchHeader,
*,
provider: LLMProvider,
version: str = "v1",
) -> list[AgentReport]:
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
tasks = [
_run_one(spec, header, provider, version=version)
for spec in SPECIALIST_SPECS
]
results = await asyncio.gather(*tasks, return_exceptions=True)
reports: list[AgentReport] = []
for spec, r in zip(SPECIALIST_SPECS, results):
if isinstance(r, Exception):
logger.warning("agent %s raised: %s", spec.name, r)
reports.append(AgentReport(agent=spec.name, status="error", analysis=str(r)[:200]))
else:
reports.append(r)
return reports
async def _run_one(spec, header, provider, *, version) -> AgentReport:
from src.llm.agents.base import run_agent
return await run_agent(spec, header, provider, before=header.match_dt, version=version)
def _reports_to_json(reports: list[AgentReport]) -> str:
return json.dumps([r.to_dict() for r in reports], ensure_ascii=False, indent=1)
async def run_aggregator(
header: MatchHeader,
reports: list[AgentReport],
*,
provider: LLMProvider,
version: str = "v1",
) -> tuple[dict, int, int]:
"""终裁: 汇总报告 → 最终 JSON。返回 (解析结果, prompt_tokens, completion_tokens)。"""
template = load_agent_prompt("aggregator", version)
user_prompt = (
template
.replace("{{match_header}}", header_text(header))
.replace("{{agent_reports}}", _reports_to_json(reports))
)
resp = await provider.chat(
system=AGGREGATOR_SYSTEM,
user=user_prompt,
json_mode=True,
temperature=0.2,
max_tokens=1000,
)
if resp.error:
raise RuntimeError(f"aggregator LLM error: {resp.error}")
if not resp.parsed:
raise RuntimeError(f"aggregator 输出无法解析: {resp.content[:200]}")
return resp.parsed, resp.prompt_tokens or 0, resp.completion_tokens or 0
async def predict_match_multi(
match_id: int,
*,
provider: LLMProvider | None = None,
version: str = "v1",
) -> MultiPredictResult:
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。"""
start = time.perf_counter()
# 1. 比赛头(各 agent 共享;不存在则 404)
header = await load_match_header(match_id)
# 2. 并行专家
specialist_provider = _get_specialist_provider()
reports = await run_specialists(header, provider=specialist_provider, version=version)
# 3. 终裁
aggregator_provider = _get_aggregator_provider()
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
header, reports, provider=aggregator_provider, version=version
)
latency_ms = int((time.perf_counter() - start) * 1000)
# 4. 存库
async with AsyncSessionLocal() as db:
m = await db.get(Match, match_id)
if m is None:
raise ValueError(f"match {match_id} not found")
agent_weights = final.get("agent_weights")
pred = Prediction(
match_id=match_id,
provider=settings.LLM_PROVIDER,
model=aggregator_provider.model,
prompt_version=f"multi_{version}",
mode="multi",
prompt_tokens=sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
completion_tokens=sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
latency_ms=latency_ms,
pred_home_goals=final.get("pred_home_goals"),
pred_away_goals=final.get("pred_away_goals"),
pred_1x2=final.get("1x2"),
confidence=final.get("confidence"),
reasoning=final.get("reasoning"),
raw_response=final,
agent_outputs=[r.to_dict() for r in reports],
)
db.add(pred)
await db.commit()
await db.refresh(pred)
return MultiPredictResult(
prediction_id=pred.id,
provider=pred.provider,
model=pred.model,
prompt_version=pred.prompt_version,
mode="multi",
pred_home_goals=pred.pred_home_goals,
pred_away_goals=pred.pred_away_goals,
pred_1x2=pred.pred_1x2,
confidence=pred.confidence,
reasoning=pred.reasoning,
agent_outputs=pred.agent_outputs,
agent_weights=agent_weights,
context=_reports_to_json(reports),
latency_ms=latency_ms,
raw=final,
)
+365
View File
@@ -0,0 +1,365 @@
"""上下文构建器:数据切片 + 拼接。
架构:
- match_header: 比赛基础信息(对阵双方/联赛/时间)
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.db.base import AsyncSessionLocal
from src.db.models import Match
logger = logging.getLogger(__name__)
def _outcome(home_goals: int, away_goals: int, side: str) -> str:
"""从某队视角看赛果: W/D/L。"""
if home_goals is None or away_goals is None:
return "?"
if side == "home":
return "W" if home_goals > away_goals else ("D" if home_goals == away_goals else "L")
return "W" if away_goals > home_goals else ("D" if away_goals == home_goals else "L")
@dataclass
class MatchContext:
match_id: int
text: str
has_stats: bool
has_injuries: bool
@dataclass
class MatchHeader:
"""比赛基础信息(所有 agent 共享)。"""
match_id: int
home_name: str
away_name: str
league_name: str
season: str | None
match_date: str
match_dt: object # 原始 datetime,回测防泄漏用
stage: str | None
home_team_id: int
away_team_id: int
league_id: int
async def load_match_header(match_id: int) -> MatchHeader:
"""加载比赛头信息(各 agent 共用)。"""
async with AsyncSessionLocal() as db:
m = await _load_match(db, match_id)
return _to_header(m)
def _to_header(m: Match) -> MatchHeader:
return MatchHeader(
match_id=m.id,
home_name=m.home_team.name_zh or m.home_team.name,
away_name=m.away_team.name_zh or m.away_team.name,
league_name=m.league.name if m.league else "?",
season=m.season,
match_date=m.match_date.strftime("%Y-%m-%d %H:%M UTC") if m.match_date else "?",
match_dt=m.match_date,
stage=m.match_stage,
home_team_id=m.home_team_id,
away_team_id=m.away_team_id,
league_id=m.league_id,
)
def header_text(h: MatchHeader) -> str:
stage = f" {h.stage}" if h.stage else ""
return (
f"对阵: {h.home_name} vs {h.away_name} | {h.league_name} {h.season or '?'}{stage} | {h.match_date}"
)
# ============================================================
# 切片函数: 每个领域 agent 一个
# ============================================================
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> str:
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。"""
async with AsyncSessionLocal() as db:
h2h = await _get_h2h(db, header.home_team_id, header.away_team_id, before=before, limit=limit)
lines = [f"── 历史交锋(近 {limit} 次) ──"]
if h2h:
home_wins = draws = away_wins = 0
for hm in h2h:
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
if hm.home_goals is not None:
if hm.home_goals > hm.away_goals: home_wins += 1
elif hm.home_goals == hm.away_goals: draws += 1
else: away_wins += 1
lines.append(f" {d}: {hm.home_team.name} {hm.home_goals}-{hm.away_goals} {hm.away_team.name}")
else:
lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)")
total = home_wins + draws + away_wins
if total:
lines.append(f" 总计 {total} 场: 主队 {home_wins}{draws}{away_wins}")
else:
lines.append(" 无数据")
return "\n".join(lines)
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> str:
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。"""
async with AsyncSessionLocal() as db:
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
lines = []
for label, name, form, side in (
("主队", header.home_name, home_form, "home"),
("客队", header.away_name, away_form, "away"),
):
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
if form:
wins = draws = losses = 0
for fm in form:
o = _outcome(fm.home_goals, fm.away_goals, side)
if o == "W": wins += 1
elif o == "D": draws += 1
else: losses += 1
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
xg = ""
if fm.stats and fm.stats.home_xg is not None:
own = fm.stats.home_xg if side == "home" else fm.stats.away_xg
xg = f" (xG {own:.1f})"
opp = fm.away_team.name if side == "home" else fm.home_team.name
lines.append(f" {o} {score} vs {opp}{xg}")
lines.append(f"{len(form)} 场: {wins}{draws}{losses}")
else:
lines.append(" 无数据")
return "\n".join(lines)
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> str:
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。"""
async with AsyncSessionLocal() as db:
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
lines = [f"── 攻防数据(近 {limit} 场) ──"]
for label, name, form, side in (
("主队", header.home_name, home_form, "home"),
("客队", header.away_name, away_form, "away"),
):
if form:
gf = ga = shots = sot = poss = xg = xga = 0
n = n_shots = n_poss = n_xg = 0
for fm in form:
if fm.home_goals is None: continue
gf += fm.home_goals if side == "home" else fm.away_goals
ga += fm.away_goals if side == "home" else fm.home_goals
n += 1
if fm.stats:
if fm.stats.home_shots is not None:
shots += fm.stats.home_shots if side == "home" else fm.stats.away_shots
sot += fm.stats.home_shots_on_target if side == "home" else fm.stats.away_shots_on_target
n_shots += 1
if fm.stats.home_possession is not None:
poss += fm.stats.home_possession if side == "home" else (100 - fm.stats.home_possession)
n_poss += 1
if fm.stats.home_xg is not None:
xg += fm.stats.home_xg if side == "home" else fm.stats.away_xg
xga += fm.stats.away_xg if side == "home" else fm.stats.home_xg
n_xg += 1
if n > 0:
lines.append(f" {label} {name}:")
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
if n_shots: lines.append(f" 场均射门 {shots/n_shots:.1f}, 射正 {sot/n_shots:.1f}")
if n_poss: lines.append(f" 平均控球 {poss/n_poss:.1f}%")
if n_xg: lines.append(f" 场均 xG {xg/n_xg:.2f}, 场均被 xG {xga/n_xg:.2f}")
else:
lines.append(f" {label} {name}: 无比分数据")
else:
lines.append(f" {label} {name}: 无数据")
return "\n".join(lines)
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None) -> str:
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。"""
async with AsyncSessionLocal() as db:
home_home = await _get_home_away(db, header.home_team_id, "home", before=before, limit=limit)
away_away = await _get_home_away(db, header.away_team_id, "away", before=before, limit=limit)
lines = ["── 主客因素 ──"]
for label, name, matches, side in (
("主队主场", header.home_name, home_home, "home"),
("客队客场", header.away_name, away_away, "away"),
):
if matches:
wins = draws = losses = gf = ga = 0
for m in matches:
if m.home_goals is None: continue
o = _outcome(m.home_goals, m.away_goals, side)
if o == "W": wins += 1
elif o == "D": draws += 1
else: losses += 1
gf += m.home_goals if side == "home" else m.away_goals
ga += m.away_goals if side == "home" else m.home_goals
n = wins + draws + losses
if n > 0:
pct = wins / n * 100
lines.append(f" {label} {name}(近 {n} 场): {wins}{draws}{losses}负, 胜率 {pct:.0f}%")
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
else:
lines.append(f" {label} {name}: 无比分数据")
else:
lines.append(f" {label} {name}: 无数据")
return "\n".join(lines)
async def injuries_slice(header: MatchHeader, *, before=None) -> str:
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。"""
from src.data.injuries import get_injuries_for_match
async with AsyncSessionLocal() as db:
home_injuries = await get_injuries_for_match(db, header.home_team_id, before or header.match_dt)
away_injuries = await get_injuries_for_match(db, header.away_team_id, before or header.match_dt)
lines = ["── 阵容完整性 ──"]
has_data = False
for label, injuries in (("主队", home_injuries), ("客队", away_injuries)):
if injuries:
has_data = True
lines.append(f" {label}伤停({len(injuries)}人):")
for inj in injuries[:8]: # 最多显示 8 条
reason = inj.reason or inj.injury_type or "未知"
lines.append(f" - {inj.player_name}: {reason}")
if len(injuries) > 8:
lines.append(f" ...及其他 {len(injuries) - 8}")
else:
lines.append(f" {label}: 无伤停数据")
if not has_data:
return "── 阵容完整性 ──\n 无数据"
return "\n".join(lines)
# ============================================================
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
# ============================================================
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5) -> MatchContext:
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。"""
header = await load_match_header(match_id)
parts = [header_text(header), ""]
has_stats = False
has_injuries = False
form_text = await form_slice(header, limit=form_last, before=header.match_dt)
if "无数据" not in form_text:
has_stats = True
parts.append(form_text)
parts.append("")
h2h_text = await h2h_slice(header, limit=h2h_last, before=header.match_dt)
parts.append(h2h_text)
parts.append("")
stats_text = await stats_slice(header, before=header.match_dt)
if "无数据" not in stats_text:
has_stats = True
parts.append(stats_text)
parts.append("")
home_away_text = await home_away_slice(header, before=header.match_dt)
parts.append(home_away_text)
parts.append("")
injuries_text = await injuries_slice(header, before=header.match_dt)
if "无数据" not in injuries_text:
has_injuries = True
parts.append(injuries_text)
return MatchContext(
match_id=match_id,
text="\n".join(parts),
has_stats=has_stats,
has_injuries=has_injuries,
)
# ============================================================
# 底层查询(切片函数共用)
# ============================================================
async def _load_match(db, match_id: int) -> Match:
stmt = (
select(Match)
.where(Match.id == match_id)
.options(
selectinload(Match.league),
selectinload(Match.home_team),
selectinload(Match.away_team),
selectinload(Match.stats),
)
)
m = (await db.execute(stmt)).scalar_one_or_none()
if m is None:
raise ValueError(f"match {match_id} not found")
return m
async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]:
"""某队近 N 场(已完赛)。before=None 表示不限制(预测赛前的场景由调用方保证)。"""
stmt = (
select(Match)
.where(Match.match_status == "finished")
.where(Match.home_goals.is_not(None))
.where((Match.home_team_id == team_id) | (Match.away_team_id == team_id))
.order_by(Match.match_date.desc())
.limit(limit)
)
if before is not None:
stmt = stmt.where(Match.match_date < before)
result = await db.execute(stmt)
return list(result.scalars().all())
async def _get_h2h(db, home_id: int, away_id: int, before, *, limit: int = 5) -> list[Match]:
"""两队交锋史。"""
stmt = (
select(Match)
.where(Match.match_status == "finished")
.where(Match.home_goals.is_not(None))
.where(
((Match.home_team_id == home_id) & (Match.away_team_id == away_id))
| ((Match.home_team_id == away_id) & (Match.away_team_id == home_id))
)
.order_by(Match.match_date.desc())
.limit(limit)
)
if before is not None:
stmt = stmt.where(Match.match_date < before)
result = await db.execute(stmt)
return list(result.scalars().all())
async def _get_home_away(db, team_id: int, side: str, before, *, limit: int = 10) -> list[Match]:
"""某队主场/客场近 N 场。side='home' 取主场,'away' 取客场。"""
stmt = (
select(Match)
.where(Match.match_status == "finished")
.where(Match.home_goals.is_not(None))
.order_by(Match.match_date.desc())
.limit(limit)
)
if side == "home":
stmt = stmt.where(Match.home_team_id == team_id)
else:
stmt = stmt.where(Match.away_team_id == team_id)
if before is not None:
stmt = stmt.where(Match.match_date < before)
result = await db.execute(stmt)
return list(result.scalars().all())
+81
View File
@@ -0,0 +1,81 @@
"""评估:赛后回填 + 统计。"""
from __future__ import annotations
import logging
from sqlalchemy import select
from src.db.base import AsyncSessionLocal
from src.db.models import Prediction
logger = logging.getLogger(__name__)
async def settle_prediction(prediction_id: int, home_goals: int, away_goals: int) -> Prediction:
"""回填实际结果。"""
async with AsyncSessionLocal() as db:
pred = await db.get(Prediction, prediction_id)
if pred is None:
raise ValueError(f"prediction {prediction_id} not found")
pred.actual_home_goals = home_goals
pred.actual_away_goals = away_goals
pred.settled = True
await db.commit()
await db.refresh(pred)
return pred
def _actual_1x2(home: int, away: int) -> str:
"""根据实际比分返胜平负。"""
if home > away:
return "1"
if home == away:
return "X"
return "2"
async def get_eval_summary() -> dict:
"""按 provider × 模型聚合评估。"""
async with AsyncSessionLocal() as db:
stmt = (
select(Prediction)
.where(Prediction.settled == True)
)
result = await db.execute(stmt)
rows = list(result.scalars().all())
from collections import defaultdict
buckets: dict[tuple[str, str], dict] = defaultdict(lambda: {
"total": 0, "correct_1x2": 0, "score_errors": [], "conf_sum": 0.0, "conf_count": 0,
})
for p in rows:
key = (p.provider, p.model)
b = buckets[key]
b["total"] += 1
if p.actual_home_goals is None or p.actual_away_goals is None:
continue
actual = _actual_1x2(p.actual_home_goals, p.actual_away_goals)
if p.pred_1x2 == actual:
b["correct_1x2"] += 1
if p.pred_home_goals is not None and p.pred_away_goals is not None:
err = ((p.pred_home_goals - p.actual_home_goals) ** 2 +
(p.pred_away_goals - p.actual_away_goals) ** 2) ** 0.5
b["score_errors"].append(err)
if p.confidence is not None:
b["conf_sum"] += p.confidence
b["conf_count"] += 1
summary = []
for (prov, model), b in sorted(buckets.items()):
acc = (b["correct_1x2"] / b["total"] * 100) if b["total"] else 0
avg_err = (sum(b["score_errors"]) / len(b["score_errors"])) if b["score_errors"] else None
avg_conf = (b["conf_sum"] / b["conf_count"]) if b["conf_count"] else None
summary.append({
"provider": prov,
"model": model,
"total": b["total"],
"accuracy_1x2": round(acc, 1),
"avg_score_rmse": round(avg_err, 2) if avg_err is not None else None,
"avg_confidence": round(avg_conf, 2) if avg_conf is not None else None,
})
return {"summary": summary}
+176
View File
@@ -0,0 +1,176 @@
"""预测服务:拼上下文 → 调 LLM → 存预测。"""
from __future__ import annotations
import functools
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from threading import Lock
from src.core.config import settings
from src.db.base import AsyncSessionLocal
from src.db.models import Match, Prediction
from src.llm.context_builder import build_context
from src.llm.provider import LLMProvider, get_default_provider
logger = logging.getLogger(__name__)
_PROMPT_DIR = Path(__file__).resolve().parent / "prompts"
# ── LLM 响应缓存(match+provider+model+version → 结果) ──
_CACHE_TTL_SEC = 300 # 5 分钟
_cache: dict[str, tuple[float, PredictResult]] = {}
_cache_lock = Lock()
def _cache_key(match_id: int, provider: str, model: str, version: str) -> str:
return f"{match_id}:{provider}:{model}:{version}"
def _get_cached(match_id: int, provider: str, model: str, version: str) -> PredictResult | None:
key = _cache_key(match_id, provider, model, version)
with _cache_lock:
if key in _cache:
ts, result = _cache[key]
if time.time() - ts < _CACHE_TTL_SEC:
return result
del _cache[key]
return None
def _set_cached(match_id: int, provider: str, model: str, version: str, result: PredictResult) -> None:
key = _cache_key(match_id, provider, model, version)
with _cache_lock:
_cache[key] = (time.time(), result)
@functools.lru_cache(maxsize=8)
def _load_prompt_template(version: str = "v1") -> str:
"""缓存 prompt 模板(进程生命周期内每个版本只读一次)。"""
path = _PROMPT_DIR / f"match_prediction_{version}.md"
if not path.exists():
raise FileNotFoundError(f"prompt 模板不存在: {path}")
with open(path, encoding="utf-8") as f:
return f.read()
@dataclass
class PredictResult:
prediction_id: int
provider: str
model: str
prompt_version: str
pred_home_goals: float | None
pred_away_goals: float | None
pred_1x2: str | None
confidence: float | None
reasoning: str | None
context: str
latency_ms: int | None
raw: dict | None
async def predict_match(
match_id: int,
*,
provider: LLMProvider | None = None,
model: str | None = None,
prompt_version: str | None = None,
mode: str = "multi",
) -> "PredictResult | MultiPredictResult":
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。"""
if mode == "single":
return await _predict_single(
match_id, provider=provider, model=model, prompt_version=prompt_version
)
from src.llm.agents.orchestrator import predict_match_multi
return await predict_match_multi(match_id, provider=provider, version=(prompt_version or "v1").removeprefix("multi_"))
async def _predict_single(
match_id: int,
*,
provider: LLMProvider | None = None,
model: str | None = None,
prompt_version: str | None = None,
) -> PredictResult:
"""单次调用路径(原有实现)。"""
if provider is None:
provider = get_default_provider()
if model:
provider.model = model
version = prompt_version or "v1"
# 0. 查缓存(同 match+provider+model+version 5 分钟内直接返)
cached = _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version)
if cached is not None:
logger.debug("predict cache hit match=%s", match_id)
return cached
# 1. 拼上下文
ctx = await build_context(match_id)
# 2. 拼 prompt(指定版本)
template = _load_prompt_template(version)
user_prompt = template.replace("{{context}}", ctx.text)
# 3. 调 LLM
resp = await provider.chat(
system="你是一个严谨的足球预测专家。只输出 JSON。",
user=user_prompt,
json_mode=True,
temperature=0.3,
max_tokens=800,
)
if resp.error:
raise RuntimeError(f"LLM error: {resp.error}")
parsed = resp.parsed or {}
# 4. 存预测(独立 session,因为 context 用的是自己的 session)
async with AsyncSessionLocal() as db:
# 验证 match 存在
m = await db.get(Match, match_id)
if m is None:
raise ValueError(f"match {match_id} not found")
pred = Prediction(
match_id=match_id,
provider=settings.LLM_PROVIDER,
model=provider.model,
prompt_version=version,
prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens,
latency_ms=resp.latency_ms,
pred_home_goals=parsed.get("pred_home_goals"),
pred_away_goals=parsed.get("pred_away_goals"),
pred_1x2=parsed.get("1x2"),
confidence=parsed.get("confidence"),
reasoning=parsed.get("reasoning"),
raw_response=resp.raw,
)
db.add(pred)
await db.commit()
await db.refresh(pred)
result = PredictResult(
prediction_id=pred.id,
provider=pred.provider,
model=pred.model,
prompt_version=version,
pred_home_goals=pred.pred_home_goals,
pred_away_goals=pred.pred_away_goals,
pred_1x2=pred.pred_1x2,
confidence=pred.confidence,
reasoning=pred.reasoning,
context=ctx.text,
latency_ms=resp.latency_ms,
raw=resp.raw,
)
# 5. 写入缓存
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, result)
return result
+27
View File
@@ -0,0 +1,27 @@
你是足球预测终裁专家。以下是 5 位领域专家对同一场比赛的分析报告(JSON),以及比赛基本信息。
比赛: {{match_header}}
专家报告:
{{agent_reports}}
你的任务: 综合权衡各报告,输出最终预测。
裁决规则:
- 各报告的 confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
- 5 个专家维度: form(近期状态) / stats(攻防数据) / home_away(主客因素) / injuries(阵容完整性) / h2h(历史交锋)
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
- reasoning 需引用具体报告的证据
严格按此 JSON 输出,不要其他内容:
```json
{
"pred_home_goals": <float, >,
"pred_away_goals": <float, >,
"1x2": "<'1'|'X'|'2'>",
"confidence": <0.0-1.0>,
"reasoning": "<250 字内推理,引用各报告证据>",
"agent_weights": {"form": <0-1>, "stats": <0-1>, "home_away": <0-1>, "injuries": <0-1>, "h2h": <0-1>}
}
```
+21
View File
@@ -0,0 +1,21 @@
你是足球近期状态分析专家。分析以下两队近期比赛数据,判断当前状态走势。
{{context}}
分析要点:
- 近期 W/D/L 序列与趋势(连胜/连败/起伏)
- 关键事件:大胜/惨败/逆转等标志性比分
- 进攻火力与防守稳固度
- 动量:最近 2-3 场 vs 更早的表现变化
- 综合判断:哪支球队近期状态更好,走势向上还是向下
严格按此 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>"]
}
```
+22
View File
@@ -0,0 +1,22 @@
你是足球历史交锋分析专家。分析以下两队过去数年以及近期的交手数据,提取交手规律。
{{context}}
分析要点:
- 总体交锋倾向:谁赢的多,胜率差距
- 主客场交锋差异:有些球队只在主场赢/输
- 比分模式:大球还是小球,常见比分
- 近期 vs 远期的变化:交锋格局是否发生逆转
- 样本量评估:1-2 次交锋的参考价值低
- 综合判断:历史交锋揭示的规律与心理优势
严格按此 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>"]
}
```
+21
View File
@@ -0,0 +1,21 @@
你是足球主客因素分析专家。分析以下两队的主客场表现差异,评估地理优势影响。
{{context}}
分析要点:
- 主队主场战绩:主场胜率、主场攻防数据
- 客队客场战绩:客场胜率、客场攻防数据
- 主客场差异:有些球队主场龙/客场虫,有些相反
- 地理与旅途因素:客场旅途、时差、气候(如有信息)
- 综合判断:主场优势对本场的影响程度
严格按此 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>"]
}
```
+21
View File
@@ -0,0 +1,21 @@
你是足球阵容完整性分析专家。分析以下两队的伤停与停赛信息,评估战力缺失程度。
{{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>"]
}
```
+22
View File
@@ -0,0 +1,22 @@
你是足球攻防数据分析专家。分析以下两队的进球、射门、控球数据,评估攻防强度。
{{context}}
分析要点:
- 场均进球:进攻火力强弱
- 场均失球:防守稳固度
- 场均射门/射正:进攻威胁与效率
- 控球率:场面控制力
- xG(期望进球):进攻质量 vs 实际进球的转化效率
- 综合判断:哪支球队攻防更均衡,哪端(攻/防)是优势端
严格按此 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>"]
}
```
+15
View File
@@ -0,0 +1,15 @@
你是足球分析专家。根据以下数据预测比赛结果。只输出 JSON,不要解释。
{{context}}
严格按此 JSON 输出:
```json
{
"pred_home_goals": "<float, 预测主队进球>",
"pred_away_goals": "<float, 预测客队进球>",
"1x2": "<'1'|'X'|'2'>",
"confidence": "<0.0-1.0>",
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
"reasoning": "<200 字内推理>"
}
```
+21
View File
@@ -0,0 +1,21 @@
你是足球分析专家。根据以下数据预测比赛结果。
{{context}}
请分析:
1. 主客队近期状态差异
2. 主客场因素
3. 历史交锋心理优势
4. 联赛排名差距
严格按此 JSON 输出,不要其他内容:
```json
{
"pred_home_goals": "<float, 预测主队进球>",
"pred_away_goals": "<float, 预测客队进球>",
"1x2": "<'1'|'X'|'2'>",
"confidence": "<0.0-1.0>",
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
"reasoning": "<200 字内推理,需引用具体数据>"
}
```
+114
View File
@@ -0,0 +1,114 @@
"""多提供商 LLM 抽象(OpenAI-compatible 接口)。
支持: OpenAI / Deepseek / Ollama / 任何 OpenAI-compatible 网关。
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any
from src.core.config import settings
from src.core.http_client import get_client
logger = logging.getLogger(__name__)
@dataclass
class LLMResponse:
content: str
parsed: dict | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
latency_ms: int | None = None
raw: dict | None = None
error: str | None = None
@dataclass
class LLMProvider:
"""OpenAI-compatible async provider。"""
api_key: str = ""
base_url: str = "https://api.openai.com/v1"
model: str = "gpt-4o"
timeout: int = 60
extra_headers: dict = field(default_factory=dict)
async def chat(
self,
system: str,
user: str,
*,
json_mode: bool = True,
temperature: float = 0.3,
max_tokens: int = 1000,
) -> LLMResponse:
"""发请求,返回结构化响应。"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
**self.extra_headers,
}
payload: dict[str, Any] = {
"model": self.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
start = time.perf_counter()
try:
client = get_client()
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
latency = int((time.perf_counter() - start) * 1000)
usage = data.get("usage", {})
content = data["choices"][0]["message"]["content"]
parsed = None
if json_mode:
try:
parsed = json.loads(content)
except json.JSONDecodeError:
# 尝试从代码块提取
import re
m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
if m:
try:
parsed = json.loads(m.group(1))
except json.JSONDecodeError:
pass
return LLMResponse(
content=content,
parsed=parsed,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
latency_ms=latency,
raw=data,
)
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
logger.error("LLM request failed: %s", e)
return LLMResponse(content="", error=str(e), latency_ms=latency)
def get_default_provider() -> LLMProvider:
return LLMProvider(
api_key=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
model=settings.LLM_MODEL,
timeout=settings.LLM_TIMEOUT,
)