파이프라인의 비워둔 슬롯(이상치 뒤·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>
70 lines
3.2 KiB
Python
70 lines
3.2 KiB
Python
"""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
|