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