검색 결과를 실제 최저가로 정제하는 파이프라인을 핸들러에 결합한다. 검색→필터→이상치→정렬→top-N. AI 유사도 판정 슬롯은 비워둠(키 대기). - pipeline/filters: keep_only_mall·filter_out_malls·filter_by_price_band(요청 현재가 기준 targeted 컷) - pipeline/outliers: IQR 기반 이상치 제거(z-score 대신 — 분포 가정 없음, stdlib만) - pipeline/core: STAGE in/out 관측 로깅 + top-N 최저가(레퍼런스 pipeline_log 계승) - handler: 검색→run_price_pipeline 결합, 요청 price 를 밴드 기준으로 사용 - tests: 정렬/IQR(극단 저·고가 제거)/밴드/mall/빈입력/STAGE/실 fixture 7건 → 전체 27/27 - 라이브 확인: 스탠리 텀블러 40건→top-5 최저가, STAGE 카운트 노출 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
38 lines
1.5 KiB
Python
38 lines
1.5 KiB
Python
"""잡 핸들러 — job_type 별 처리. 현재는 SEARCH(검색)만.
|
|
|
|
검색 핸들러: 소스 어댑터로 검색 → 코어 파이프라인(필터·이상치·top-N 최저가) → 결과.
|
|
AI 유사도 판정은 파이프라인 슬롯에 키 준비 시 결합한다.
|
|
"""
|
|
|
|
from common.enums import JobType
|
|
from services.search.contract import SearchAdapter
|
|
from services.search.util import parse_price
|
|
from services.pipeline.core import run_price_pipeline
|
|
|
|
|
|
def build_search_handler(adapters: dict[str, SearchAdapter], default_source: str = "coupang", limit: int = 40, top_n: int = 5):
|
|
"""검색 핸들러 생성. adapters = {source: SearchAdapter}."""
|
|
|
|
async def handler(job: dict) -> dict:
|
|
if job["job_type"] != JobType.SEARCH.value:
|
|
raise ValueError(f"unsupported job_type: {job['job_type']}")
|
|
|
|
payload = job.get("payload") or {}
|
|
query = (payload.get("product_name") or "").strip()
|
|
if not query:
|
|
raise ValueError("empty product_name")
|
|
|
|
adapter = adapters.get(default_source)
|
|
if adapter is None:
|
|
raise ValueError(f"no adapter for source: {default_source}")
|
|
|
|
products = await adapter.search(query, limit=limit)
|
|
# 요청 현재가(있으면)를 가격 밴드 기준으로 사용 → 엉뚱한 저가/고가 targeted 컷
|
|
base_price = parse_price(payload.get("price"))
|
|
result = run_price_pipeline(products, base_price=base_price, top_n=top_n)
|
|
result["source"] = adapter.source
|
|
result["query"] = query
|
|
return result
|
|
|
|
return handler
|