_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 通过。
171 lines
6.3 KiB
Python
171 lines
6.3 KiB
Python
"""P0-03 核心测试: Prediction 幂等指纹。
|
|
|
|
- TestFingerprintLogic:用 mock session 验证同/不同 fingerprint 的 INSERT/返回逻辑(无 PG 依赖)。
|
|
- TestFingerprintDeterminism:纯 hash 稳定性(无 PG 依赖)。
|
|
|
|
运行: pytest tests/test_p0_prediction_fingerprint.py -v
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.db.models import Prediction
|
|
from src.llm.predict import _compute_fingerprint, _insert_or_find_by_fingerprint
|
|
|
|
|
|
def _base_values(match_id, **overrides):
|
|
base = {
|
|
"match_id": match_id,
|
|
"provider": "test-provider",
|
|
"model": "test-model",
|
|
"mode": "single",
|
|
"run_type": "live",
|
|
"prompt_version": "v1",
|
|
"prompt_hash": "ph1",
|
|
"system_prompt_hash": "sh1",
|
|
"temperature": 0.3,
|
|
"context_hash": "ch1",
|
|
"agent_ids": [],
|
|
"prediction_cutoff_at": "2026-01-01T14:00:00+00:00",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
class _FakeSession:
|
|
"""模拟 session:记录 add;execute 返回预设的 existing row。"""
|
|
|
|
def __init__(self, existing=None):
|
|
self._existing = existing
|
|
self.added: list = []
|
|
self.flushed = 0
|
|
|
|
def add(self, obj):
|
|
self.added.append(obj)
|
|
|
|
async def execute(self, stmt):
|
|
existing = self._existing
|
|
|
|
class _R:
|
|
def scalar_one_or_none(inner_self):
|
|
return existing
|
|
|
|
return _R()
|
|
|
|
async def flush(self):
|
|
self.flushed += 1
|
|
|
|
async def refresh(self, obj):
|
|
if getattr(obj, "id", None) is None:
|
|
obj.id = 1
|
|
|
|
|
|
class TestFingerprintLogic:
|
|
"""P0-03:同 fingerprint 返回已有行(不 UPDATE/INSERT);不同 → INSERT。"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_same_fingerprint_returns_existing_without_update(self):
|
|
# 构造一个"已存在"的行
|
|
existing = Prediction(
|
|
id=42, match_id=1, provider="test-provider", model="test-model",
|
|
prompt_version="v1", input_hash="same-hash",
|
|
)
|
|
existing.pred_home_goals = 2.0
|
|
existing.prompt_version = "v1"
|
|
|
|
s = _FakeSession(existing=existing)
|
|
values = _base_values(1, prompt_version="v1") # 与 existing 同 fingerprint 需 input_hash 相同
|
|
|
|
# 但 fingerprint 是动态计算的,existing.input_hash 需匹配。直接让 fake 返回 existing。
|
|
result = await _insert_or_find_by_fingerprint(s, values=values)
|
|
|
|
# 应返回 existing,不 add 新行
|
|
assert result is existing, "同 fingerprint 必须返回已有行"
|
|
assert s.added == [], "同 fingerprint 不应 INSERT"
|
|
assert result.pred_home_goals == 2.0, "返回的应是已有行(字段不变)"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_fingerprint_inserts_new(self):
|
|
# 无已有行 → INSERT
|
|
s = _FakeSession(existing=None)
|
|
values = _base_values(1, prompt_version="v1", context_hash="ch1")
|
|
|
|
result = await _insert_or_find_by_fingerprint(s, values=values)
|
|
|
|
assert len(s.added) == 1, "无已有行时应 INSERT"
|
|
assert isinstance(s.added[0], Prediction)
|
|
# input_hash 应被设为指纹
|
|
assert result.input_hash is not None and len(result.input_hash) == 64 # SHA-256 hex
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fingerprint_computed_from_values(self):
|
|
"""fingerprint 应基于 values 的全部关键字段计算。"""
|
|
s1 = _FakeSession(existing=None)
|
|
s2 = _FakeSession(existing=None)
|
|
|
|
v1 = _base_values(1, prompt_version="v1")
|
|
v2 = _base_values(1, prompt_version="v1") # 同值
|
|
|
|
r1 = await _insert_or_find_by_fingerprint(s1, values=v1)
|
|
r2 = await _insert_or_find_by_fingerprint(s2, values=v2)
|
|
|
|
# 同值 → 同 fingerprint(跨 session 也一致)
|
|
assert r1.input_hash == r2.input_hash
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_never_updated(self):
|
|
"""核心可信度:同 fingerprint 绝不覆盖 pred_/reasoning/agent_outputs。"""
|
|
existing = Prediction(
|
|
id=99, match_id=1, provider="p", model="m",
|
|
prompt_version="v1", input_hash="fixed-hash",
|
|
pred_home_goals=1.0, pred_away_goals=0.0,
|
|
reasoning="original", agent_outputs=[{"agent": "form"}],
|
|
)
|
|
s = _FakeSession(existing=existing)
|
|
|
|
# 即便传入不同的 pred_*,也应返回原行(字段不变)
|
|
values = _base_values(1, prompt_version="v1")
|
|
# 让 fake 返回 existing: 需 fingerprint 匹配。fake.execute 始终返回 existing。
|
|
result = await _insert_or_find_by_fingerprint(s, values=values)
|
|
|
|
assert result is existing
|
|
assert result.pred_home_goals == 1.0, "pred_home_goals 不应被覆盖"
|
|
assert result.reasoning == "original", "reasoning 不应被覆盖"
|
|
assert result.agent_outputs == [{"agent": "form"}], "agent_outputs 不应被覆盖"
|
|
|
|
|
|
class TestFingerprintDeterminism:
|
|
"""fingerprint 必须稳定(同输入 → 同 hash)。"""
|
|
|
|
def test_same_values_same_fingerprint(self):
|
|
v = _base_values(1)
|
|
assert _compute_fingerprint(v) == _compute_fingerprint(dict(v))
|
|
|
|
def test_different_prompt_version_different_fingerprint(self):
|
|
v1 = _base_values(1, prompt_version="v1")
|
|
v2 = _base_values(1, prompt_version="v2")
|
|
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
|
|
|
|
def test_different_agent_ids_different_fingerprint(self):
|
|
v1 = _base_values(1, agent_ids=["form", "stats"])
|
|
v2 = _base_values(1, agent_ids=["form", "h2h"])
|
|
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
|
|
|
|
def test_different_cutoff_different_fingerprint(self):
|
|
v1 = _base_values(1, prediction_cutoff_at="2026-01-01T14:00:00+00:00")
|
|
v2 = _base_values(1, prediction_cutoff_at="2026-01-01T10:00:00+00:00")
|
|
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
|
|
|
|
def test_different_context_different_fingerprint(self):
|
|
v1 = _base_values(1, context_hash="ch1")
|
|
v2 = _base_values(1, context_hash="ch2")
|
|
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
|
|
|
|
def test_agent_ids_order_independent(self):
|
|
"""agent_ids 排序后计算,顺序不影响 hash。"""
|
|
v1 = _base_values(1, agent_ids=["stats", "form"])
|
|
v2 = _base_values(1, agent_ids=["form", "stats"])
|
|
assert _compute_fingerprint(v1) == _compute_fingerprint(v2)
|