파이프라인의 비워둔 슬롯(이상치 뒤·top-N 앞)에 OpenAI 유사도 판정을 결합. "스탠리 텀블러" 검색 시 빨대마개·커버 등 호환 액세서리가 최저가로 올라오던 문제를 해결한다(기계적 최저가 → 같은 상품 최저가). - ai/similarity: SimilarityJudge(OpenAI structured output). 액세서리/부품/다른규격 불일치 판별 - pipeline/core: apply_filters + rank_result 로 분리(AI 를 그 사이에 끼움), run_price_pipeline 동작 불변 - handler: judge 주입 시 ai_match STAGE 추가(필터 후 후보만 판정 → 토큰 절약), 미주입 시 생략 - worker_main: OPENAI_API_KEY 있으면 판정 ON - requirements: openai / tests: fake judge 필터링 검증 → 전체 32/32 - 라이브: '스탠리 퀜처 887ml' → ai_match(60→15) → 실제 텀블러 top-6(액세서리 제거) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 lines
3.0 KiB
Python
80 lines
3.0 KiB
Python
"""다중 소스 검색 핸들러 테스트 — 병합·소스별 실패 격리·전체 실패 시 잡 실패 (fake 어댑터)."""
|
|
|
|
import pytest
|
|
|
|
from common.enums import JobType
|
|
from services.search.contract import NormalizedProduct, AdapterError
|
|
from worker.handlers import build_search_handler
|
|
|
|
|
|
class FakeAdapter:
|
|
def __init__(self, source, products=None, fail=False):
|
|
self.source = source
|
|
self._products = products or []
|
|
self._fail = fail
|
|
|
|
async def search(self, query, limit=40):
|
|
if self._fail:
|
|
raise AdapterError("boom", source=self.source, blocked=True)
|
|
return self._products
|
|
|
|
|
|
def _np(source, price):
|
|
return NormalizedProduct(source=source, name=f"{source}-{price}", price=price)
|
|
|
|
|
|
def _job():
|
|
return {"job_type": JobType.SEARCH.value, "attempts": 1, "payload": {"product_name": "x"}}
|
|
|
|
|
|
async def test_merges_and_ranks_across_sources():
|
|
adapters = {
|
|
"coupang": FakeAdapter("coupang", [_np("coupang", 3000), _np("coupang", 1000)]),
|
|
"naver": FakeAdapter("naver", [_np("naver", 2000), _np("naver", 500)]),
|
|
}
|
|
r = await build_search_handler(adapters, top_n=3)(_job())
|
|
assert r["lowest"]["price"] == 500 and r["lowest"]["source"] == "naver"
|
|
assert [p["price"] for p in r["top"]] == [500, 1000, 2000]
|
|
assert r["sources"]["coupang"]["count"] == 2 and r["sources"]["naver"]["count"] == 2
|
|
|
|
|
|
async def test_isolates_single_source_failure():
|
|
adapters = {
|
|
"coupang": FakeAdapter("coupang", fail=True),
|
|
"naver": FakeAdapter("naver", [_np("naver", 900)]),
|
|
}
|
|
r = await build_search_handler(adapters)(_job())
|
|
assert "error" in r["sources"]["coupang"] # 실패 격리
|
|
assert r["sources"]["naver"]["count"] == 1
|
|
assert r["lowest"]["price"] == 900 # 성공 소스로 결과 산출
|
|
|
|
|
|
async def test_all_sources_fail_raises():
|
|
adapters = {
|
|
"coupang": FakeAdapter("coupang", fail=True),
|
|
"naver": FakeAdapter("naver", fail=True),
|
|
}
|
|
with pytest.raises(RuntimeError):
|
|
await build_search_handler(adapters)(_job())
|
|
|
|
|
|
class FakeJudge:
|
|
"""가격 조건으로 매칭을 흉내내는 판정기(실제 OpenAI 호출 없음)."""
|
|
|
|
def __init__(self, predicate):
|
|
self._pred = predicate
|
|
|
|
async def judge(self, target, candidates):
|
|
from services.ai.similarity import Judgment
|
|
return [Judgment(index=i + 1, is_match=self._pred(c), score=100 if self._pred(c) else 0)
|
|
for i, c in enumerate(candidates)]
|
|
|
|
|
|
async def test_ai_judge_filters_non_matches():
|
|
adapters = {"naver": FakeAdapter("naver", [_np("naver", 1000), _np("naver", 2000), _np("naver", 3000)])}
|
|
judge = FakeJudge(lambda c: c.price == 2000) # 2000 만 '같은 상품'
|
|
r = await build_search_handler(adapters, judge=judge)(_job())
|
|
assert [p["price"] for p in r["top"]] == [2000] # 비매칭 제거됨
|
|
ai_stage = next(s for s in r["stages"] if s["stage"] == "ai_match")
|
|
assert ai_stage["in"] == 3 and ai_stage["out"] == 1
|