fix(P1-C): 公开预测仅 live+success,且不含 reasoning/agent_outputs
GET /matches/{id}:查询加 WHERE run_type='live' AND status='success';
recent_predictions 不再输出 reasoning/agent_outputs(避免泄露内部推理)。
MatchOut.recent_predictions schema 放宽为 list[dict]。
测试 test_p1_c_public_predictions(2/2);全量 309 通过。
This commit is contained in:
+15
-15
@@ -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()
|
m = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
if m is None:
|
if m is None:
|
||||||
raise HTTPException(404, "match not found")
|
raise HTTPException(404, "match not found")
|
||||||
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM
|
# P1-C: 公开预测仅 run_type=live 且 status=success(屏蔽回测/失败预测)
|
||||||
preds = (
|
preds = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Prediction)
|
select(Prediction)
|
||||||
.where(Prediction.match_id == match_id)
|
.where(Prediction.match_id == match_id)
|
||||||
|
.where(Prediction.run_type == "live")
|
||||||
|
.where(Prediction.status == "success")
|
||||||
.order_by(Prediction.created_at.desc())
|
.order_by(Prediction.created_at.desc())
|
||||||
.limit(5)
|
.limit(5)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).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(
|
return MatchOut(
|
||||||
id=m.id,
|
id=m.id,
|
||||||
league_code=m.league.code if m.league else None,
|
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,
|
home_xg=m.stats.home_xg if m.stats else None,
|
||||||
away_xg=m.stats.away_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,
|
stats=_stats_dict(m.stats) if m.stats else None,
|
||||||
recent_predictions=[
|
recent_predictions=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
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -24,8 +24,8 @@ class MatchOut(BaseModel):
|
|||||||
away_xg: float | None = None
|
away_xg: float | None = None
|
||||||
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
|
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
|
||||||
stats: dict | None = None
|
stats: dict | None = None
|
||||||
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
|
# P1-C: 公开接口的预测不含 reasoning/agent_outputs;仅 live+success 路由已过滤
|
||||||
recent_predictions: list[PredictionOut] = []
|
recent_predictions: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
class MatchListOut(BaseModel):
|
class MatchListOut(BaseModel):
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user