o2o-negosium-original/lps/services/pipeline/core.py
민헌 b0a86b9720 feat(lps): AI 유사도 판정 — '같은 상품' 매칭으로 액세서리 오염 해결
파이프라인의 비워둔 슬롯(이상치 뒤·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>
2026-07-09 08:44:57 +09:00

84 lines
3.0 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 apply_filters(
products: list[NormalizedProduct],
*,
base_price: int | None = None,
keep_mall: str | None = None,
banned_malls=(),
band_tolerance: float = 0.7,
remove_outliers: bool = True,
) -> tuple[list[NormalizedProduct], list[dict]]:
"""mall → 가격밴드 → 이상치(IQR) 순으로 후보를 좁힌다. (후보, STAGE로그) 반환.
AI 유사도 판정은 이 뒤(top-N 앞)에 결합한다."""
stages: list[dict] = []
cur = list(products)
def stage(name: str, before: list, after: list):
stages.append({"stage": name, "in": len(before), "out": len(after)})
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)
if base_price:
before = cur
cur = filter_by_price_band(cur, base_price, band_tolerance)
stage("price_band", before, cur)
if remove_outliers:
before = cur
cur, _ = remove_price_outliers(cur)
stage("outlier", before, cur)
return cur, stages
def rank_result(products: list[NormalizedProduct], total_found: int, stages: list[dict], top_n: int = 5) -> dict:
"""최저가순 정렬 + top-N + 결과 봉투 조립(top_n STAGE 포함)."""
ranked = sorted(products, key=lambda p: p.price)
top = ranked[:top_n]
stages = stages + [{"stage": "top_n", "in": len(ranked), "out": len(top)}]
return {
"total_found": total_found,
"kept": len(ranked),
"lowest": top[0].model_dump() if top else None,
"top": [p.model_dump() for p in top],
"stages": stages,
}
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:
"""AI 없는 동기 파이프라인(테스트/기본 경로). apply_filters + rank_result 조합."""
cur, stages = apply_filters(
products, base_price=base_price, keep_mall=keep_mall,
banned_malls=banned_malls, band_tolerance=band_tolerance, remove_outliers=remove_outliers,
)
return rank_result(cur, len(products), stages, top_n)