A/B 실험(상품 8개)에서 2개가 매칭 0~1건으로 사실상 실패했다. 수집은 40건 정상, 가격도 정확했는데 판정이 못 찾았다. 파고드니 두 가지였다. **① 일괄 판정 붕괴(핵심)**: 후보 37건을 한 번에 넣으면 gpt-4o-mini 가 전 항목에 같은 점수(70)를 매기고 **전부 불일치**로 답한다. temperature=0 에서 3회 재현. 10건씩 쪼개면 같은 모델·같은 입력으로 12건이 매칭된다. 상품 3종에서 모두 재현(크리넥스 0→12, 다우니 8→23). → _BATCH=10 으로 쪼개 병렬 판정 후 전체 index 로 복원. 지연은 배치 1개분, 토큰은 합산. **② 프롬프트 규칙 충돌**: "포장(개수/박스) 차이는 동일 상품" vs "용량·규격이 다르면 불일치" 가 함께 있어 '30롤 1팩'과 '30롤 2팩'(=60롤)을 어느 쪽으로 볼지 정할 수 없었다. 최저가 관점에선 수량이 다르면 다른 상품이다(60롤 값을 30롤 최저가로 쓰면 왜곡). 종류 혼동도 실측에서 오탐을 만들었다 — '프라이팬'을 찾는데 볶음팬·웍팬이 매칭됐다. → 규칙을 '무시할 차이 / 불일치로 볼 차이'로 갈라 재작성(수량·종류 명시). 결과(캐시 후보 재판정): 크리넥스 30롤 매칭 0 → 5건, 최저가 **not_found → 15,500원** 다우니 4.1L 매칭 8 → 12건, 최저 21,890 유지 테팔 프라이팬 매칭 2 → 0건 (볶음팬·웍팬 오탐 제거 — 후보에 진짜 프라이팬이 없었다) 테스트 6건 추가(배치 분할 시 index 매핑·누락 보수처리·토큰 합산·빈 후보·상수 상한·규칙 충돌), 전체 237 passed.
88 lines
3.6 KiB
Python
88 lines
3.6 KiB
Python
"""AI 동일상품 판정의 **오케스트레이션** 테스트 (모델 호출은 대역).
|
|
|
|
모델 응답 품질이 아니라 계약을 지킨다:
|
|
- 후보를 배치로 쪼개도 판정 개수·순서가 보존되는가(전체 index 매핑)
|
|
- 배치가 누락 응답을 줘도 보수적으로 불일치 처리되는가
|
|
- 토큰 사용량이 배치 전체로 합산되는가(원가 계측이 배치 수만큼 새면 안 됨)
|
|
|
|
배치로 쪼개는 이유는 속도가 아니라 정확도다 — 후보 37건을 일괄로 넣으면 gpt-4o-mini 가
|
|
전 항목에 같은 점수를 매기고 전부 불일치로 답한다(2026-08-05 실측, 3회 재현).
|
|
"""
|
|
|
|
import services.ai.similarity as sim
|
|
from services.ai.similarity import Judgment, SimilarityJudge
|
|
from services.search.contract import NormalizedProduct
|
|
|
|
|
|
def _cands(n):
|
|
return [NormalizedProduct(source="naver", name=f"상품{i}", price=1000 + i) for i in range(n)]
|
|
|
|
|
|
class _Usage:
|
|
def __init__(self, p, c):
|
|
self.prompt_tokens, self.completion_tokens = p, c
|
|
|
|
|
|
def _judge_with(monkeypatch, batch_impl):
|
|
judge = SimilarityJudge.__new__(SimilarityJudge) # __init__ 은 OpenAI 클라이언트를 만든다
|
|
judge._model = "test"
|
|
judge.last_usage = None
|
|
monkeypatch.setattr(judge, "_judge_batch", batch_impl, raising=False)
|
|
return judge
|
|
|
|
|
|
async def test_batches_preserve_order_and_global_index(monkeypatch):
|
|
seen = []
|
|
|
|
async def fake(target, part):
|
|
seen.append(len(part))
|
|
# 배치 안에서는 항상 1..n 로 번호가 매겨진다 — 전체 번호로 되돌리는 건 judge 의 책임
|
|
return {i + 1: Judgment(index=i + 1, is_match=(part[i].price % 2 == 0), score=50) for i in range(len(part))}, _Usage(10, 2)
|
|
|
|
judge = _judge_with(monkeypatch, fake)
|
|
cands = _cands(25)
|
|
out = await judge.judge({}, cands)
|
|
|
|
assert seen == [10, 10, 5], "10건씩 쪼개져야 한다"
|
|
assert [j.index for j in out] == list(range(1, 26)), "전체 index 가 1..N 로 복원돼야 한다"
|
|
assert [j.is_match for j in out] == [c.price % 2 == 0 for c in cands], "판정이 후보와 어긋나면 안 된다"
|
|
|
|
|
|
async def test_missing_judgment_is_treated_as_no_match(monkeypatch):
|
|
async def fake(target, part):
|
|
return {1: Judgment(index=1, is_match=True, score=90)}, _Usage(5, 1) # 나머지 누락
|
|
|
|
judge = _judge_with(monkeypatch, fake)
|
|
out = await judge.judge({}, _cands(3))
|
|
assert [j.is_match for j in out] == [True, False, False]
|
|
assert out[2].score == 0
|
|
|
|
|
|
async def test_usage_is_summed_across_batches(monkeypatch):
|
|
async def fake(target, part):
|
|
return {}, _Usage(100, 20)
|
|
|
|
judge = _judge_with(monkeypatch, fake)
|
|
await judge.judge({}, _cands(25)) # 3배치
|
|
assert judge.last_usage.prompt_tokens == 300
|
|
assert judge.last_usage.completion_tokens == 60
|
|
|
|
|
|
async def test_empty_candidates_short_circuits(monkeypatch):
|
|
async def fake(target, part):
|
|
raise AssertionError("후보가 없으면 모델을 부르면 안 된다")
|
|
|
|
judge = _judge_with(monkeypatch, fake)
|
|
assert await judge.judge({}, []) == []
|
|
|
|
|
|
def test_batch_size_is_small_enough_to_avoid_degenerate_output():
|
|
"""실측 근거: 37건 일괄 → 전멸(0건). 10건 → 12건 매칭. 이 상수가 커지면 그 실패가 돌아온다."""
|
|
assert sim._BATCH <= 15
|
|
|
|
|
|
def test_prompt_rules_do_not_conflict_on_quantity():
|
|
"""예전 프롬프트는 '포장(개수/박스) 차이는 동일'과 '규격 다르면 불일치'가 충돌했다."""
|
|
assert "수량이 다름" in sim._SYSTEM
|
|
assert "포장(개수/박스)·사은품 차이는 동일" not in sim._SYSTEM
|