全量修复:预测系统正确性、安全性与部署问题

P0 严重问题修复:
- 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误)
- 修复 understat.py httpx 未导入导致的 NameError
- 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5)

预测路径修复:
- multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效
- H2H 切片汇总统计改为从当前主队视角计数
- 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测

伤停管线修复:
- IntegrityError 后不再整批回滚丢数据(改用逐条 flush)
- return_date 正确解析并写入
- retrieved_at 比较统一用 date() 避免当天数据不可见
- 唯一索引改为 partial unique index(排除 NULL 重复)
- HTTP 缓存 TTL 从 7 天改为 6 小时

安全与连接管理:
- /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP)
- 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接

Docker 部署修复:
- 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败
- 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
- 启动时自动执行 alembic upgrade head
- 前端改用多阶段构建(Dockerfile.frontend)

新增测试(5个文件,24+用例):
- test_p0_home_away.py: 主客身份反转回归测试
- test_p0_parse_failure.py: LLM 解析失败回归测试
- test_multi_agent_cutoff.py: multi-agent cutoff 透传测试
- test_h2h_perspective.py: H2H 视角测试
- test_injuries_pipeline.py: 伤停管线 5 项修复测试
- test_predict_protection.py: 限流+短 session 测试
- test_prediction_unique_constraint.py: 唯一约束测试

迁移:
- 0012_injuries_partial_unique_and_return_date.py
- 0013_predictions_unique_constraint_mode_run_type.py
This commit is contained in:
Profeto Agent
2026-09-19 06:43:55 +00:00
parent 11efe91ce9
commit bee330f31f
27 changed files with 1666 additions and 137 deletions
+29 -8
View File
@@ -144,10 +144,14 @@ async def run_specialists(
header: MatchHeader,
*,
version: str = "v1",
before=None,
) -> list[AgentReport]:
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
before: 数据截止时间(回测防泄漏)。None 表示不限制。
"""
tasks = [
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version)
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version, before=before)
for spec in SPECIALIST_SPECS
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -161,10 +165,10 @@ async def run_specialists(
return reports
async def _run_one(spec, header, provider, *, version) -> AgentReport:
async def _run_one(spec, header, provider, *, version, before=None) -> AgentReport:
from src.llm.agents.base import run_agent
return await run_agent(spec, header, provider, before=header.match_dt, version=version)
return await run_agent(spec, header, provider, before=before, version=version)
def _reports_to_json(reports: list[AgentReport]) -> str:
@@ -210,18 +214,34 @@ async def predict_match_multi(
*,
provider: LLMProvider | None = None,
version: str = "v1",
backtest: bool = False,
cutoff_at=None,
) -> MultiPredictResult:
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。"""
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
backtest: 回测模式。True 时 cutoff 自动设为 match_dt - 1 天。
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
"""
start = time.perf_counter()
# 1. 比赛头(各 agent 共享;不存在则 404)
header = await load_match_header(match_id)
match_kickoff_at = header.match_dt
prediction_cutoff_at = header.match_dt # 默认:比赛时间作为数据截止
now = datetime.now(timezone.utc)
# 2. 并行专家(各自独立配置)
reports = await run_specialists(header, version=version)
# 计算真正的数据截止时间(回测防泄漏)
# 优先级: 显式 cutoff_at > backtest 自动计算 > 默认(比赛时间)
if cutoff_at is not None:
cutoff = cutoff_at
elif backtest and header.match_dt:
from datetime import timedelta
cutoff = header.match_dt - timedelta(days=1)
else:
cutoff = header.match_dt
prediction_cutoff_at = cutoff
# 2. 并行专家(各自独立配置,使用统一 cutoff)
reports = await run_specialists(header, version=version, before=cutoff)
# 3. 终裁
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
@@ -257,6 +277,7 @@ async def predict_match_multi(
provider_name=settings.LLM_PROVIDER,
model=aggregator_provider.model,
mode="multi",
run_type="backtest" if backtest else "live",
values={
"prompt_version": f"multi_{version}",
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
+57 -26
View File
@@ -71,6 +71,7 @@ class MatchContext:
has_stats: bool
has_injuries: bool
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
@dataclass
@@ -144,20 +145,38 @@ async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: Asy
lines = [f"── 历史交锋(近 {limit} 次) ──"]
n_with_score = 0
if h2h:
home_wins = draws = away_wins = 0
# 从当前主队视角统计:判断当前主队在每场交锋中是主是客
current_home_wins = current_home_draws = current_home_losses = 0
for hm in h2h:
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
if hm.home_goals is not None:
n_with_score += 1
if hm.home_goals > hm.away_goals: home_wins += 1
elif hm.home_goals == hm.away_goals: draws += 1
else: away_wins += 1
# 判断当前主队当时是主队还是客队
if hm.home_team_id == header.home_team_id:
# 当前主队当时是主队
if hm.home_goals > hm.away_goals:
current_home_wins += 1
elif hm.home_goals == hm.away_goals:
current_home_draws += 1
else:
current_home_losses += 1
else:
# 当前主队当时是客队(从客队视角看赛果)
if hm.away_goals > hm.home_goals:
current_home_wins += 1
elif hm.away_goals == hm.home_goals:
current_home_draws += 1
else:
current_home_losses += 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
total = current_home_wins + current_home_draws + current_home_losses
if total:
lines.append(f" 总计 {total} 场: 主队 {home_wins}{draws}{away_wins}")
lines.append(
f" 总计 {total} 场(从当前主队 {header.home_name} 视角): "
f"{current_home_wins}{current_home_draws}{current_home_losses}"
)
else:
lines.append(" 无数据")
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
@@ -178,14 +197,18 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
lines = []
n_scored = 0
for label, name, form, side in (
("主队", header.home_name, home_form, "home"),
("客队", header.away_name, away_form, "away"),
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
# 不能用本场 side 硬套 —— 否则客场输球会被算成主场赢球。
for label, name, form, team_id in (
("主队", header.home_name, home_form, header.home_team_id),
("客队", header.away_name, away_form, header.away_team_id),
):
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
if form:
wins = draws = losses = 0
for fm in form:
is_home = (fm.home_team_id == team_id)
side = "home" if is_home else "away"
o = _outcome(fm.home_goals, fm.away_goals, side)
if o == "W": wins += 1
elif o == "D": draws += 1
@@ -195,9 +218,9 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
xg = ""
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
own = fm.stats.home_xg if side == "home" else fm.stats.away_xg
own = fm.stats.home_xg if is_home else fm.stats.away_xg
xg = f" (xG {own:.1f})"
opp = fm.away_team.name if side == "home" else fm.home_team.name
opp = fm.away_team.name if is_home else fm.home_team.name
lines.append(f" {o} {score} vs {opp}{xg}")
lines.append(f"{len(form)} 场: {wins}{draws}{losses}")
else:
@@ -219,30 +242,33 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db:
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
lines = [f"── 攻防数据(近 {limit} 场) ──"]
n_total = 0
for label, name, form, side in (
("主队", header.home_name, home_form, "home"),
("客队", header.away_name, away_form, "away"),
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
# 不能用本场 side 硬套 —— 否则进球/失球/xG 全部算反。
for label, name, form, team_id in (
("主队", header.home_name, home_form, header.home_team_id),
("客队", header.away_name, away_form, header.away_team_id),
):
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
is_home = (fm.home_team_id == team_id)
gf += fm.home_goals if is_home else fm.away_goals
ga += fm.away_goals if is_home else fm.home_goals
n += 1
# 只使用 cutoff 之前已可用的统计数据
if fm.stats and _is_stats_available(fm.stats, before):
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
shots += fm.stats.home_shots if is_home else fm.stats.away_shots
sot += fm.stats.home_shots_on_target if is_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)
poss += fm.stats.home_possession if is_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
xg += fm.stats.home_xg if is_home else fm.stats.away_xg
xga += fm.stats.away_xg if is_home else fm.stats.home_xg
n_xg += 1
n_total += n
if n > 0:
@@ -340,23 +366,27 @@ async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession |
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
# ============================================================
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False) -> MatchContext:
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
has_stats / has_injuries 直接取切片显式声明的 has_data,
不再靠文案子串匹配(见审查报告 P2-1)。
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。
"""
async with AsyncSessionLocal() as db:
header = await load_match_header(match_id, db=db)
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
cutoff = header.match_dt
if backtest and header.match_dt:
# 计算数据截止时间: 显式 > backtest 自动计算 > 默认(比赛时间)
if cutoff_at is not None:
cutoff = cutoff_at
elif backtest and header.match_dt:
from datetime import timedelta
cutoff = header.match_dt - timedelta(days=1)
else:
cutoff = header.match_dt
parts = [header_text(header), ""]
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
@@ -384,6 +414,7 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
has_stats=form_res.has_data or stats_res.has_data,
has_injuries=injuries_res.has_data,
match_dt=header.match_dt,
cutoff=cutoff,
)
+29 -7
View File
@@ -112,11 +112,13 @@ async def _upsert_prediction(
provider_name: str,
model: str,
mode: str,
run_type: str,
values: dict,
) -> Prediction:
"""按 (match, provider, model) 唯一约束写入预测。
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
run_type 区分 live/backtest,避免回测覆盖实盘预测。
"""
existing = (
await session.execute(
@@ -124,6 +126,8 @@ async def _upsert_prediction(
Prediction.match_id == match_id,
Prediction.provider == provider_name,
Prediction.model == model,
Prediction.mode == mode,
Prediction.run_type == run_type,
)
)
).scalar_one_or_none()
@@ -134,6 +138,7 @@ async def _upsert_prediction(
match_id=match_id, provider=provider_name, model=model,
)
pred.mode = mode
pred.run_type = run_type
for k, v in values.items():
setattr(pred, k, v)
if existing is None:
@@ -151,6 +156,7 @@ async def predict_match(
mode: str = "multi",
use_cache: bool = True,
backtest: bool = False,
cutoff_at=None,
) -> "PredictResult | MultiPredictResult":
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
@@ -158,7 +164,8 @@ async def predict_match(
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
backtest: 是否回测模式。True 时 build_context 使用 match_date-1天 作为 cutoff
backtest: 是否回测模式。True 时 cutoff 自动设为 match_date-1天。
cutoff_at: 显式截止时间,优先级高于 backtest 自动计算。
"""
if mode == "single":
return await _predict_single(
@@ -168,10 +175,18 @@ async def predict_match(
prompt_version=prompt_version,
use_cache=use_cache,
backtest=backtest,
cutoff_at=cutoff_at,
)
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_"))
# 回测参数完整传递到 multi-agent 路径
return await predict_match_multi(
match_id,
provider=provider,
version=(prompt_version or "v1").removeprefix("multi_"),
backtest=backtest,
cutoff_at=cutoff_at,
)
async def _predict_single(
@@ -182,6 +197,7 @@ async def _predict_single(
prompt_version: str | None = None,
use_cache: bool = True,
backtest: bool = False,
cutoff_at=None,
) -> PredictResult:
"""单次调用路径(原有实现)。"""
if provider is None:
@@ -198,13 +214,14 @@ async def _predict_single(
logger.debug("predict cache hit match=%s", match_id)
return cached
# 1. 拼上下文(P2-6: backtest 时使用 match_date-1天 作为 cutoff)
ctx = await build_context(match_id, backtest=backtest)
# 1. 拼上下文(backtest/cutoff 防泄漏)
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
# 1.5 计算快照元数据(用于可复现性)
now = datetime.now(timezone.utc)
match_kickoff_at = ctx.match_dt
prediction_cutoff_at = ctx.match_dt # 默认:比赛时间作为数据截止
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
# 2. 拼 prompt(指定版本)
@@ -223,7 +240,11 @@ async def _predict_single(
if resp.error:
raise RuntimeError(f"LLM error: {resp.error}")
parsed = resp.parsed or {}
# P0-3: json_mode 下 parsed 为 None 说明 JSON 解析失败,不能 fallback 到 {}
if resp.parsed is None:
raise RuntimeError("LLM 输出 JSON 解析失败,parsed=None")
parsed = resp.parsed
# 3.5 严格校验 LLM 输出
from src.llm.validation import validate_prediction_output
@@ -245,6 +266,7 @@ async def _predict_single(
provider_name=settings.LLM_PROVIDER,
model=provider.model,
mode="single",
run_type="backtest" if backtest else "live",
values={
"prompt_version": version,
"prompt_tokens": resp.prompt_tokens,
+6
View File
@@ -91,6 +91,7 @@ class LLMProvider:
+ ("(token 花在推理上,请增大 max_tokens)" if message.get("reasoning_content") else "")
)
parsed = None
parse_error: str | None = None
if json_mode:
try:
parsed = json.loads(content)
@@ -103,6 +104,10 @@ class LLMProvider:
parsed = json.loads(m.group(1))
except json.JSONDecodeError:
pass
if parsed is None:
# P0-3: JSON 解析失败必须显式报错,不能静默继续
parse_error = f"JSON parse failed: {content[:200]!r}"
logger.warning(parse_error)
return LLMResponse(
content=content,
parsed=parsed,
@@ -110,6 +115,7 @@ class LLMProvider:
completion_tokens=usage.get("completion_tokens"),
latency_ms=latency,
raw=data,
error=parse_error if parse_error else None,
)
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
+20 -5
View File
@@ -182,7 +182,11 @@ def validate_agent_output(raw: dict) -> AgentReportSchema:
def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
"""校验最终预测输出。"""
"""校验最终预测输出。
P0-3: 必填字段不提供默认值,缺失即校验失败(让 Pydantic 抛出 ValidationError),
避免「0-0 平局 + 置信度 0.5」这种静默假预测落库。
"""
# 优先新字段,旧字段仅兼容并打日志
conf = raw.get("subjective_confidence")
if conf is None and "confidence" in raw:
@@ -198,13 +202,24 @@ def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
except Exception:
return None
# P0-3: pred_1x2 不再默认 "X",缺失会触发 Pydantic ValidationError
pred_1x2 = raw.get("1x2") or raw.get("pred_1x2")
if pred_1x2 is None:
raise ValueError("Missing required field: pred_1x2 (or legacy '1x2')")
# P0-3: subjective_confidence 不再默认 0.5
if conf is None:
raise ValueError("Missing required field: subjective_confidence")
return PredictionOutputSchema(
pred_home_goals=int(Decimal(str(raw.get("pred_home_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
pred_away_goals=int(Decimal(str(raw.get("pred_away_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
# P0-3: 必填字段用 raw[key] 而非 raw.get(key, default),
# 缺失时 KeyError → 被外层 except 捕获 → 预测标记为失败
pred_home_goals=int(Decimal(str(raw["pred_home_goals"])).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
pred_away_goals=int(Decimal(str(raw["pred_away_goals"])).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
alt_pred_home_goals=_alt("home"),
alt_pred_away_goals=_alt("away"),
pred_1x2=raw.get("1x2") or raw.get("pred_1x2", "X"),
subjective_confidence=float(conf if conf is not None else 0.5),
pred_1x2=pred_1x2,
subjective_confidence=float(conf),
reasoning=str(raw.get("reasoning", ""))[:1000],
)