_upsert_prediction 改为 _insert_or_find_by_fingerprint: - 同 input_hash → 返回已有行(绝不 UPDATE pred_/reasoning/agent_outputs) - 不同 input_hash → INSERT 新行 input_hash 升级为规范 JSON SHA-256,捕获:match_id, cutoff, prompt_version, prompt_hash, system_prompt_hash, provider, model, mode, run_type, temperature, context_hash, agent_ids。移除旧 (match, provider, model, mode, run_type) 唯一约束, 改为 partial unique index(WHERE input_hash IS NOT NULL,兼容旧 NULL 数据)。 三条路径(single/multi/baseline)统一传足指纹字段。 迁移 0024 + 测试 test_p0_prediction_fingerprint(10/10);全量 295 通过。
140 lines
5.8 KiB
Python
140 lines
5.8 KiB
Python
"""回归测试: 预测唯一约束修复 —— live 与 backtest 可共存。
|
|
|
|
验证:
|
|
1. 唯一约束包含 mode + run_type
|
|
2. 同一场比赛 live 与 backtest 预测可共存,互不覆盖
|
|
3. _insert_or_find_by_fingerprint 正确区分 run_type
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from pathlib import Path
|
|
|
|
from pydantic import BaseModel
|
|
import pytest
|
|
from sqlalchemy import Index
|
|
|
|
from src.db.models import Prediction, UniqueConstraint, CheckConstraint
|
|
|
|
# 仓库根目录下的 alembic 迁移目录 —— 相对本测试文件解析,
|
|
# 避免硬编码某台机器/CI 上的绝对路径(见 tests/test_regressions.py 的 _read 约定)。
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
MIGRATION_PATH = REPO_ROOT / "alembic" / "versions" / "0013_predictions_unique_constraint_mode_run_type.py"
|
|
|
|
|
|
class TestUniqueConstraint:
|
|
"""P0-03: 验证幂等指纹唯一索引(替代旧 (match, provider, model, mode, run_type) 唯一约束)。"""
|
|
|
|
def test_input_hash_partial_unique_index(self):
|
|
"""P0-03: input_hash 非空时必须唯一(同指纹 → 返回已有行,不 UPDATE/INSERT)。"""
|
|
idx = [
|
|
i for i in Prediction.__table__.indexes
|
|
if i.unique and "input_hash" in i.name
|
|
]
|
|
assert len(idx) == 1, f"缺少 input_hash partial unique 索引,现有 indexes: {[i.name for i in Prediction.__table__.indexes]}"
|
|
# partial unique: postgresql_where 必须限制 input_hash IS NOT NULL
|
|
assert idx[0].dialect_kwargs.get("postgresql_where") is not None
|
|
|
|
def test_old_unique_constraint_removed(self):
|
|
"""P0-03: 旧 (match, provider, model, mode, run_type) 唯一约束必须已移除。"""
|
|
from sqlalchemy import UniqueConstraint
|
|
|
|
old = [
|
|
c for c in Prediction.__table__.constraints
|
|
if isinstance(c, UniqueConstraint) and c.name == "uq_predictions_match_provider_model_mode_run_type"
|
|
]
|
|
assert len(old) == 0, f"旧约束必须已移除,但仍存在: {[c.name for c in old]}"
|
|
|
|
def test_run_type_check_constraint(self):
|
|
"""应有 run_type 的 check constraint。"""
|
|
from sqlalchemy import CheckConstraint
|
|
|
|
cc = [
|
|
c for c in Prediction.__table__.constraints
|
|
if isinstance(c, CheckConstraint) and "run_type" in c.name
|
|
]
|
|
assert len(cc) == 1
|
|
|
|
def test_run_type_column_exists(self):
|
|
"""run_type 列应存在且 NOT NULL,默认 'live'。"""
|
|
cols = {c.name: c for c in Prediction.__table__.columns}
|
|
assert "run_type" in cols
|
|
assert cols["run_type"].nullable is False
|
|
# 默认值
|
|
assert cols["run_type"].default.arg == "live" if cols["run_type"].default else True
|
|
|
|
|
|
class TestUpsertPredictionSignature:
|
|
"""验证 _insert_or_find_by_fingerprint 签名(P0-03 指纹模式)。"""
|
|
|
|
def test_signature_uses_values_dict(self):
|
|
"""P0-03: 新接口通过 values dict 接收全部字段(含 run_type/match_id/...)。"""
|
|
from src.llm.predict import _insert_or_find_by_fingerprint
|
|
|
|
sig = inspect.signature(_insert_or_find_by_fingerprint)
|
|
params = sig.parameters
|
|
assert "session" in params
|
|
assert "values" in params # 所有业务字段走 values dict
|
|
|
|
def test_signature_has_backtest_in_predict_match(self):
|
|
from src.llm.predict import predict_match
|
|
|
|
sig = inspect.signature(predict_match)
|
|
assert "backtest" in sig.parameters
|
|
|
|
def test_signature_has_backtest_in_predict_multi(self):
|
|
from src.llm.agents.orchestrator import predict_match_multi
|
|
|
|
sig = inspect.signature(predict_match_multi)
|
|
assert "backtest" in sig.parameters
|
|
|
|
|
|
class TestMigration:
|
|
"""验证迁移文件存在且内容正确。"""
|
|
|
|
def test_migration_exists(self):
|
|
assert MIGRATION_PATH.is_file(), f"迁移文件不存在: {MIGRATION_PATH}"
|
|
|
|
def test_migration_adds_column_and_constraint(self):
|
|
content = MIGRATION_PATH.read_text(encoding="utf-8")
|
|
|
|
assert 'run_type' in content
|
|
assert 'uq_predictions_match_provider_model_mode_run_type' in content
|
|
assert 'backtest' in content
|
|
assert 'live' in content
|
|
# 验证数据回填逻辑
|
|
assert "UPDATE predictions SET run_type = 'live'" in content
|
|
|
|
|
|
class TestLiveBacktestCoexist:
|
|
"""验证 live 与 backtest 可共存(逻辑验证,无需数据库)。"""
|
|
|
|
def test_different_run_type_allow_coexistence(self):
|
|
"""同一 match_id + provider + model + mode,不同 run_type 应可共存。
|
|
|
|
这是核心修复:之前唯一约束只有 (match_id, provider, model),
|
|
backtest 会覆盖 live 预测。
|
|
"""
|
|
# 模拟两行数据
|
|
class FakeRow:
|
|
def __init__(self, **kw):
|
|
for k, v in kw.items():
|
|
setattr(self, k, v)
|
|
|
|
live = FakeRow(match_id=1, provider="openai", model="gpt-4o", mode="single", run_type="live")
|
|
backtest = FakeRow(match_id=1, provider="openai", model="gpt-4o", mode="single", run_type="backtest")
|
|
|
|
# 两者唯一键不同(因为 run_type 不同)
|
|
live_key = (live.match_id, live.provider, live.model, live.mode, live.run_type)
|
|
backtest_key = (backtest.match_id, backtest.provider, backtest.model, backtest.mode, backtest.run_type)
|
|
|
|
assert live_key != backtest_key, "live 与 backtest 应有不同的唯一键"
|
|
assert live_key == (1, "openai", "gpt-4o", "single", "live")
|
|
assert backtest_key == (1, "openai", "gpt-4o", "single", "backtest")
|
|
|
|
def test_same_run_type_prevents_duplicate(self):
|
|
"""相同 run_type 的重复预测仍应被约束阻止。"""
|
|
key1 = (1, "openai", "gpt-4o", "single", "live")
|
|
key2 = (1, "openai", "gpt-4o", "single", "live")
|
|
assert key1 == key2, "相同 run_type 应有相同唯一键,应被约束阻止"
|