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>
This commit is contained in:
parent
9557c1b1af
commit
b0a86b9720
@ -14,3 +14,4 @@ selectolax==0.4.10 # 빠른 C 파서(쿠팡 HTML). bs4 대비 최대 30배
|
||||
patchright # 스텔스 Playwright 포크. 쿠팡 Akamai JS 챌린지 통과(실제 Chrome, channel=chrome)
|
||||
# ※ nodriver 는 Python 3.14 소스인코딩 버그로 미채택 → Patchright 로 대체
|
||||
# ※ 실행엔 시스템 Google Chrome 필요(로컬) / 배포 이미지엔 chromium 설치 필요
|
||||
openai # AI 유사도 판정(같은 상품 매칭) — OPENAI_API_KEY(.env). structured output 사용
|
||||
|
||||
69
lps/services/ai/similarity.py
Normal file
69
lps/services/ai/similarity.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""AI 유사도 판정 — 검색 결과가 '찾는 상품과 동일한 상품'인지 OpenAI 로 판별.
|
||||
|
||||
파이프라인(이상치 제거 뒤, top-N 앞) 슬롯에 결합한다. 규칙 고정 파싱이 취약한 문제를
|
||||
LLM 판단으로 대체 — 액세서리/호환부품/다른 상품/명백히 다른 규격을 걸러 최저가 오염을 막는다.
|
||||
structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common.logger import LOG
|
||||
from services.search.contract import NormalizedProduct
|
||||
|
||||
_SYSTEM = (
|
||||
"너는 최저가 비교 시스템의 상품 매칭기다. 검색 결과 후보가 '찾는 상품과 동일한 상품'인지 판별하라.\n"
|
||||
"규칙:\n"
|
||||
"- 액세서리·호환부품·부속품(빨대마개·뚜껑·커버·거치대·스트랩·보호필름 등)은 불일치(false).\n"
|
||||
"- 다른 종류/브랜드/모델은 불일치. 모델명이 주어지면 모델 일치를 우선한다.\n"
|
||||
"- 용량·규격이 명백히 다르면 불일치.\n"
|
||||
"- 판매자·색상·포장(개수/박스)·사은품 차이는 동일 상품으로 본다.\n"
|
||||
"- 확신이 낮으면 score 를 낮게 준다."
|
||||
)
|
||||
|
||||
|
||||
class Judgment(BaseModel):
|
||||
index: int = Field(description="후보 번호(1부터)")
|
||||
is_match: bool = Field(description="찾는 상품과 동일 상품이면 true")
|
||||
score: int = Field(description="동일 확신도 0~100")
|
||||
|
||||
|
||||
class JudgmentList(BaseModel):
|
||||
judgments: list[Judgment]
|
||||
|
||||
|
||||
class SimilarityJudge:
|
||||
def __init__(self, model: str = "gpt-4o-mini", api_key: str | None = None):
|
||||
self._model = model
|
||||
self._client = AsyncOpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
|
||||
|
||||
async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]:
|
||||
"""후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환."""
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
lines = "\n".join(f"{i + 1}. {c.name} ({c.price}원)" for i, c in enumerate(candidates))
|
||||
user = (
|
||||
f"[찾는 상품]\n"
|
||||
f"상품명: {target.get('product_name', '')}\n"
|
||||
f"모델: {target.get('model', '')}\n"
|
||||
f"규격: {target.get('specification', '')}\n"
|
||||
f"제조사/브랜드: {target.get('company', '')}\n\n"
|
||||
f"[검색 결과 후보]\n{lines}\n\n"
|
||||
f"각 후보가 찾는 상품과 동일 상품인지 index 별로 판별하라."
|
||||
)
|
||||
|
||||
resp = await self._client.beta.chat.completions.parse(
|
||||
model=self._model,
|
||||
messages=[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": user}],
|
||||
response_format=JudgmentList,
|
||||
temperature=0,
|
||||
)
|
||||
parsed = resp.choices[0].message.parsed
|
||||
by_idx = {j.index: j for j in (parsed.judgments if parsed else [])}
|
||||
# 누락된 후보는 보수적으로 불일치 처리
|
||||
out = [by_idx.get(i + 1, Judgment(index=i + 1, is_match=False, score=0)) for i in range(len(candidates))]
|
||||
LOG.d(f"[ai] 판정 {len(candidates)}건 중 매칭 {sum(1 for j in out if j.is_match)}건")
|
||||
return out
|
||||
@ -12,6 +12,59 @@ from services.pipeline.filters import keep_only_mall, filter_out_malls, filter_b
|
||||
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],
|
||||
*,
|
||||
@ -22,45 +75,9 @@ def run_price_pipeline(
|
||||
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,
|
||||
}
|
||||
"""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)
|
||||
|
||||
@ -56,3 +56,24 @@ async def test_all_sources_fail_raises():
|
||||
}
|
||||
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
|
||||
|
||||
@ -11,11 +11,18 @@ from common.enums import JobType
|
||||
from common.logger import LOG
|
||||
from services.search.contract import SearchAdapter
|
||||
from services.search.util import parse_price
|
||||
from services.pipeline.core import run_price_pipeline
|
||||
from services.pipeline.core import apply_filters, rank_result
|
||||
|
||||
|
||||
def build_search_handler(adapters: dict[str, SearchAdapter], sources: list[str] | None = None, limit: int = 40, top_n: int = 5):
|
||||
"""검색 핸들러 생성. adapters = {source: SearchAdapter}. sources 미지정 시 전체 사용."""
|
||||
def build_search_handler(
|
||||
adapters: dict[str, SearchAdapter],
|
||||
sources: list[str] | None = None,
|
||||
limit: int = 40,
|
||||
top_n: int = 5,
|
||||
judge=None,
|
||||
):
|
||||
"""검색 핸들러 생성. adapters = {source: SearchAdapter}. sources 미지정 시 전체 사용.
|
||||
judge(SimilarityJudge) 주입 시 필터 뒤·top-N 앞에 '같은 상품' AI 판정을 끼운다(없으면 생략)."""
|
||||
use = list(sources) if sources else list(adapters.keys())
|
||||
|
||||
async def handler(job: dict) -> dict:
|
||||
@ -47,7 +54,16 @@ def build_search_handler(adapters: dict[str, SearchAdapter], sources: list[str]
|
||||
if not products and all("error" in v for v in per_source.values()):
|
||||
raise RuntimeError(f"모든 소스 검색 실패: {per_source}") # 잡 실패 → 재시도
|
||||
|
||||
result = run_price_pipeline(products, base_price=base_price, top_n=top_n)
|
||||
# 필터(mall·밴드·이상치) → [AI 유사도 판정] → top-N 최저가
|
||||
candidates, stages = apply_filters(products, base_price=base_price)
|
||||
if judge is not None and candidates:
|
||||
target = {k: payload.get(k, "") for k in ("product_name", "model", "specification", "company")}
|
||||
verdicts = await judge.judge(target, candidates)
|
||||
matched = [c for c, v in zip(candidates, verdicts) if v.is_match]
|
||||
stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)})
|
||||
candidates = matched
|
||||
|
||||
result = rank_result(candidates, len(products), stages, top_n)
|
||||
result["query"] = query
|
||||
result["sources"] = per_source # 소스별 건수/에러 (관측)
|
||||
return result
|
||||
|
||||
@ -13,6 +13,7 @@ from config.server_configs import web_server_config
|
||||
from crud.job_crud import JobQueue
|
||||
from services.search.coupang.adapter import CoupangAdapter
|
||||
from services.search.naver.adapter import NaverAdapter
|
||||
from services.ai.similarity import SimilarityJudge
|
||||
from worker.handlers import build_search_handler
|
||||
from worker.notify import JobListener
|
||||
from worker.runner import Worker, run_reaper
|
||||
@ -24,7 +25,10 @@ async def main(concurrency: int = 1):
|
||||
queue = JobQueue()
|
||||
# 쿠팡(브라우저, 무거움) + 네이버(오픈API, 가벼움) 동시 검색 → 병합 최저가
|
||||
adapters = {"coupang": CoupangAdapter(headless=False), "naver": NaverAdapter()}
|
||||
handler = build_search_handler(adapters)
|
||||
# OPENAI_API_KEY 있으면 '같은 상품' AI 판정 활성화(없으면 기계적 최저가만)
|
||||
judge = SimilarityJudge() if os.environ.get("OPENAI_API_KEY") else None
|
||||
LOG.i(f"AI 유사도 판정: {'ON' if judge else 'OFF(키 없음)'}")
|
||||
handler = build_search_handler(adapters, judge=judge)
|
||||
|
||||
stop = asyncio.Event()
|
||||
listeners: list[JobListener] = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user