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
+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,
)