"""최저가 코어 파이프라인. 검색 결과(NormalizedProduct[]) → 필터 → 이상치 제거 → 정렬 → top-N 최저가. 각 STAGE 의 in/out 건수를 기록해 관측성을 확보한다(레퍼런스 pipeline_log 패턴 계승). AI 유사도 판정(같은 상품인지)은 키 준비 시 이 사이(이상치 제거 뒤, top-N 앞)에 끼운다 — 지금은 슬롯만 비워두고 규칙 매칭을 하지 않는다(고정 파서 취약성 회피). """ from services.search.contract import NormalizedProduct from services.pipeline.filters import keep_only_mall, filter_out_malls, filter_by_price_band from services.pipeline.outliers import remove_price_outliers def run_price_pipeline( products: list[NormalizedProduct], *, base_price: int | None = None, keep_mall: str | None = None, banned_malls=(), band_tolerance: float = 0.7, remove_outliers: bool = True, top_n: int = 5, ) -> dict: stages: list[dict] = [] cur = list(products) def stage(name: str, before: list, after: list): stages.append({"stage": name, "in": len(before), "out": len(after)}) # 1) mall 필터 (쿠팡 단독이면 사실상 no-op, 네이버 결합 시 오픈마켓 정리용) if keep_mall: before = cur cur = keep_only_mall(cur, keep_mall) stage("keep_mall", before, cur) elif banned_malls: before = cur cur = filter_out_malls(cur, banned_malls) stage("filter_out_malls", before, cur) # 2) 가격 밴드 (요청 현재가 기준 targeted 컷) if base_price: before = cur cur = filter_by_price_band(cur, base_price, band_tolerance) stage("price_band", before, cur) # 3) 이상치 제거 (IQR) if remove_outliers: before = cur cur, _ = remove_price_outliers(cur) stage("outlier", before, cur) # (AI 유사도 판정 슬롯 — 키 준비 시 여기) # 4) 최저가순 정렬 + top-N ranked = sorted(cur, key=lambda p: p.price) top = ranked[:top_n] stage("top_n", ranked, top) return { "total_found": len(products), "kept": len(ranked), "lowest": top[0].model_dump() if top else None, "top": [p.model_dump() for p in top], "stages": stages, }