P2-1 no_data 门控依赖文案子串(脆弱): - context_builder 新增 SliceResult(text/has_data/n_records), 5 个切片函数改为显式声明 has_data - base._slice_has_data() 优先取结构化结果,str 返回仍走文案回退 (兼容既有测试 mock 与自定义切片) - build_context 的 has_stats/has_injuries 直接取切片声明 P2-2 agent_weights 无校验即落库: - validation 新增 AgentWeightsSchema / validate_agent_weights: 未知专家名丢弃、越界值钳制、总和非 1 时归一化 - orchestrator 落库前对 agent_weights 做校验 P2-3 1x2 与比分不一致被静默修正: - 仍以比分修正,但补 logger.warning 暴露 LLM 自相矛盾 P2-5/P2-6 prompt 缓存不可刷新 + 缓存键不含模板内容: - 新增 clear_prompt_cache() 供改模板后显式失效 - 缓存键纳入模板内容 hash,模板一改缓存自动失效 P2-7 ingest/backtest/settle 接口无鉴权: - 新增 require_admin_key 依赖(X-API-Key), ADMIN_API_KEY 未设置时放行并告警(不破坏本地开发) - 挂到 3 个 ingest 接口 + backtest + eval/settle P2-8 前端请求竞态 + 未使用游标分页: - Matches.tsx 用递增 seq 丢弃过期响应,避免旧筛选结果覆盖新筛选 - 接入后端已有的 cursor 分页 + 「加载更多」按钮 附带: .env.example 补齐 LLM_TIMEOUT / 分档模型 / ADMIN_API_KEY; tests 新增 10 个用例覆盖 P2-1/2/3。
233 lines
7.8 KiB
Python
233 lines
7.8 KiB
Python
"""预测服务:拼上下文 → 调 LLM → 存预测。"""
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import hashlib
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
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.db.unit_of_work import get_uow
|
|
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, tpl_hash: str) -> str:
|
|
"""缓存键:含 prompt 模板内容 hash。
|
|
|
|
仅用 version 做键不够 —— 编辑器里改动 `match_prediction_v1.md` 而版本号
|
|
不变时,进程内缓存仍会返回旧模板产生的旧结果(见审查报告 P2-6)。
|
|
把模板内容 hash 纳入键,模板一改缓存自动失效。
|
|
"""
|
|
return f"{match_id}:{provider}:{model}:{version}:{tpl_hash[:12]}"
|
|
|
|
|
|
def _get_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> PredictResult | None:
|
|
key = _cache_key(match_id, provider, model, version, tpl_hash)
|
|
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, tpl_hash: str, result: PredictResult) -> None:
|
|
key = _cache_key(match_id, provider, model, version, tpl_hash)
|
|
with _cache_lock:
|
|
_cache[key] = (time.time(), result)
|
|
|
|
|
|
def clear_prompt_cache() -> None:
|
|
"""清空 prompt 模板缓存(供开发/热更新时手动调用)。
|
|
|
|
lru_cache 的模板缓存是进程级的,改完 .md 需要重启进程才能生效;
|
|
提供显式清理入口,避免"改了模板却看不到变化"的困惑(见审查报告 P2-5)。
|
|
"""
|
|
_load_prompt_template.cache_clear()
|
|
logger.info("prompt 模板缓存已清空")
|
|
|
|
|
|
@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()
|
|
|
|
|
|
def _prompt_template_hash(version: str) -> str:
|
|
"""prompt 模板内容 hash(用于缓存键,模板变更即失效)。"""
|
|
return hashlib.sha256(_load_prompt_template(version).encode("utf-8")).hexdigest()
|
|
|
|
|
|
@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
|
|
subjective_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",
|
|
use_cache: bool = True,
|
|
) -> "PredictResult | MultiPredictResult":
|
|
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
|
|
|
Args:
|
|
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
|
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
|
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
|
"""
|
|
if mode == "single":
|
|
return await _predict_single(
|
|
match_id,
|
|
provider=provider,
|
|
model=model,
|
|
prompt_version=prompt_version,
|
|
use_cache=use_cache,
|
|
)
|
|
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,
|
|
use_cache: bool = True,
|
|
) -> PredictResult:
|
|
"""单次调用路径(原有实现)。"""
|
|
if provider is None:
|
|
provider = get_default_provider()
|
|
if model:
|
|
provider.model = model
|
|
version = prompt_version or "v1"
|
|
tpl_hash = _prompt_template_hash(version)
|
|
|
|
# 0. 查缓存(同 match+provider+model+version+模板hash 5 分钟内直接返)
|
|
if use_cache:
|
|
cached = _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash)
|
|
if cached is not None:
|
|
logger.debug("predict cache hit match=%s", match_id)
|
|
return cached
|
|
|
|
# 1. 拼上下文
|
|
ctx = await build_context(match_id)
|
|
|
|
# 1.5 计算快照元数据(用于可复现性)
|
|
now = datetime.now(timezone.utc)
|
|
match_kickoff_at = ctx.match_dt
|
|
prediction_cutoff_at = ctx.match_dt # 默认:比赛时间作为数据截止
|
|
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
|
|
|
|
# 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 {}
|
|
|
|
# 3.5 严格校验 LLM 输出
|
|
from src.llm.validation import validate_prediction_output
|
|
try:
|
|
validated = validate_prediction_output(parsed)
|
|
except Exception as e:
|
|
raise RuntimeError(f"LLM 输出校验失败: {e}")
|
|
|
|
# 4. 存预测(使用 UnitOfWork 统一事务)
|
|
async with get_uow() as session:
|
|
# 验证 match 存在
|
|
m = await session.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=validated.pred_home_goals,
|
|
pred_away_goals=validated.pred_away_goals,
|
|
pred_1x2=validated.pred_1x2,
|
|
subjective_confidence=validated.subjective_confidence,
|
|
reasoning=validated.reasoning,
|
|
raw_response=resp.raw,
|
|
status="success",
|
|
match_kickoff_at=match_kickoff_at,
|
|
prediction_cutoff_at=prediction_cutoff_at,
|
|
prediction_created_at=now,
|
|
input_hash=input_hash,
|
|
)
|
|
session.add(pred)
|
|
await session.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,
|
|
subjective_confidence=pred.subjective_confidence,
|
|
reasoning=pred.reasoning,
|
|
context=ctx.text,
|
|
latency_ms=resp.latency_ms,
|
|
raw=resp.raw,
|
|
)
|
|
|
|
# 5. 写入缓存(仅当允许缓存时)
|
|
if use_cache:
|
|
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash, result)
|
|
return result
|