o2o-negosium-original/lps/services/pipeline/core.py
민헌 fb72ab625f feat(lps): 코어 파이프라인 — mall 필터·IQR 이상치·top-N 최저가
검색 결과를 실제 최저가로 정제하는 파이프라인을 핸들러에 결합한다.
검색→필터→이상치→정렬→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>
2026-07-08 17:06:15 +09:00

67 lines
2.2 KiB
Python

"""최저가 코어 파이프라인.
검색 결과(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,
}