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:
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user