diff --git a/src/api/routes/matches.py b/src/api/routes/matches.py index 59b51e8..0103823 100644 --- a/src/api/routes/matches.py +++ b/src/api/routes/matches.py @@ -169,15 +169,28 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)): m = (await db.execute(stmt)).scalar_one_or_none() if m is None: raise HTTPException(404, "match not found") - # 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM + # P1-C: 公开预测仅 run_type=live 且 status=success(屏蔽回测/失败预测) preds = ( await db.execute( select(Prediction) .where(Prediction.match_id == match_id) + .where(Prediction.run_type == "live") + .where(Prediction.status == "success") .order_by(Prediction.created_at.desc()) .limit(5) ) ).scalars().all() + # P1-C: 公开接口的预测不含 reasoning/agent_outputs(避免泄露内部推理细节) + recent_predictions = [ + { + "id": p.id, "match_id": p.match_id, "provider": p.provider, "model": p.model, + "prompt_version": p.prompt_version, "mode": p.mode or "single", + "pred_home_goals": p.pred_home_goals, "pred_away_goals": p.pred_away_goals, + "pred_1x2": p.pred_1x2, "subjective_confidence": p.subjective_confidence, + "created_at": p.created_at.isoformat() if p.created_at else None, + } + for p in preds + ] return MatchOut( id=m.id, league_code=m.league.code if m.league else None, @@ -194,20 +207,7 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)): home_xg=m.stats.home_xg if m.stats else None, away_xg=m.stats.away_xg if m.stats else None, stats=_stats_dict(m.stats) if m.stats else None, - recent_predictions=[ - PredictionOut( - id=p.id, match_id=p.match_id, provider=p.provider, model=p.model, - prompt_version=p.prompt_version, mode=p.mode or "single", - pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals, - alt_pred_home_goals=p.alt_pred_home_goals, alt_pred_away_goals=p.alt_pred_away_goals, - pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence, - reasoning=p.reasoning, status=p.status or "success", - agent_outputs=p.agent_outputs, agent_weights=p.agent_weights, - created_at=p.created_at, actual_home_goals=p.actual_home_goals, - actual_away_goals=p.actual_away_goals, settled=p.settled, - ) - for p in preds - ], + recent_predictions=recent_predictions, ) diff --git a/src/api/schemas.py b/src/api/schemas.py index 02035d7..4ec0e2a 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -24,8 +24,8 @@ class MatchOut(BaseModel): away_xg: float | None = None # 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None stats: dict | None = None - # 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空) - recent_predictions: list[PredictionOut] = [] + # P1-C: 公开接口的预测不含 reasoning/agent_outputs;仅 live+success 路由已过滤 + recent_predictions: list[dict] = [] class MatchListOut(BaseModel): diff --git a/tests/test_p1_c_public_predictions.py b/tests/test_p1_c_public_predictions.py new file mode 100644 index 0000000..b1c6640 --- /dev/null +++ b/tests/test_p1_c_public_predictions.py @@ -0,0 +1,135 @@ +"""P1-C 回归测试: 公开预测仅 live+success,且不含 reasoning/agent_outputs。 + +运行: pytest tests/test_p1_c_public_predictions.py -v +(使用 fake DB,无需真实 PG。) +""" +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from src.api.app import app +from src.api.deps import require_admin +from src.db.models import League, Match, MatchStats, Prediction, Team + + +class _FakeResult: + def __init__(self, items): self._items = list(items) + def scalars(self): + class _S: + def __init__(self, items): self._items = items + def all(self): return list(self._items) + return _S(self._items) + def scalar_one_or_none(self): + return self._items[0] if self._items else None + def scalar(self): + return self._items[0] if self._items else None + + +class _FakeDB: + """假 DB:捕获发往 Prediction 的查询语句,供测试断言 SQL 过滤条件。""" + + captured_pred_stmts: list = [] + + def __init__(self, match=None, predictions=()): + self._match = match + self._predictions = list(predictions) + + async def execute(self, stmt): + # 根据 column_descriptions 判断查询实体 + try: + entity = stmt.column_descriptions[0]["entity"] + except (IndexError, KeyError): + entity = None + if entity is Prediction: + _FakeDB.captured_pred_stmts.append(stmt) + return _FakeResult(self._predictions) + return _FakeResult([self._match] if self._match else []) + + async def get(self, cls, mid): + return self._match + + +def _make_match(mid=1): + home = Team(id=10, name="Arsenal", name_zh="阿森纳") + away = Team(id=20, name="Chelsea", name_zh="切尔西") + lg = League(id=1, code="E0", name="Premier", country="EN") + m = Match( + id=mid, league_id=1, home_team_id=10, away_team_id=20, + match_date=datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc), + match_status="finished", + ) + m.league = lg + m.home_team = home + m.away_team = away + m.stats = MatchStats(match_id=mid, home_xg=1.5, away_xg=1.0) + return m + + +def _make_pred(pid, match_id, run_type="live", status="success", **overrides): + p = Prediction( + id=pid, match_id=match_id, provider="openai", model="gpt-4o", + prompt_version="v1", mode=run_type, run_type=run_type, status=status, + pred_home_goals=2.0, pred_away_goals=1.0, pred_1x2="1", + reasoning="内部推理细节", agent_outputs=[{"agent": "form"}], + subjective_confidence=0.7, + created_at=datetime(2026, 1, 2, 12, 0, tzinfo=timezone.utc), + ) + for k, v in overrides.items(): + setattr(p, k, v) + return p + + +from src.db.base import get_db_read + + +@pytest.fixture +def client(): + app.dependency_overrides[require_admin] = lambda: None + return TestClient(app) + + +class TestPublicPredictionsFilter: + """P1-C: GET /matches/{id} 公开预测仅 run_type=live 且 status=success。""" + + def test_query_filters_by_run_type_and_status(self, client): + """P1-C: 查询必须包含 run_type='live' AND status='success' 过滤。""" + _FakeDB.captured_pred_stmts = [] + m = _make_match(1) + fake = _FakeDB(match=m, predictions=[_make_pred(1, 1)]) + + app.dependency_overrides[get_db_read] = lambda: fake + try: + resp = client.get("/api/v1/matches/1") + assert resp.status_code == 200, resp.text + # 验证发往 Prediction 的 SQL 含 run_type 与 status 过滤 + assert _FakeDB.captured_pred_stmts, "未发出 Prediction 查询" + sql = str(_FakeDB.captured_pred_stmts[0]).lower() + assert "run_type" in sql, f"SQL 缺少 run_type 过滤: {sql}" + assert "status" in sql, f"SQL 缺少 status 过滤: {sql}" + finally: + app.dependency_overrides.pop(get_db_read, None) + + def test_no_reasoning_or_agent_outputs(self, client): + """P1-C: 公开预测不得含 reasoning/agent_outputs。""" + m = _make_match(1) + preds = [_make_pred(1, 1, run_type="live", status="success")] + fake = _FakeDB(match=m, predictions=preds) + + app.dependency_overrides[get_db_read] = lambda: fake + try: + resp = client.get("/api/v1/matches/1") + assert resp.status_code == 200, resp.text + body = resp.json() + assert len(body["recent_predictions"]) == 1 + p = body["recent_predictions"][0] + assert "reasoning" not in p, "公开预测不得含 reasoning" + assert "agent_outputs" not in p, "公开预测不得含 agent_outputs" + # 但核心字段保留 + assert p["pred_home_goals"] == 2.0 + assert p["pred_1x2"] == "1" + finally: + app.dependency_overrides.pop(get_db_read, None)