"""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