o2o-negosium-original/lps/services/ai/similarity.py
민헌 69f6641c27 feat(lps): 검색 1건 원가 계측 — AI 토큰·비용 + 시간 + 크롤 트래픽
검색이 소모하는 리소스/비용/시간을 잡 단위로 집계해 result.metrics 로 적재(API/FE 노출).
지금까진 타임스탬프만 있고 실제 비용 동인(AI 토큰·대역폭)은 버려지고 있었다.

- services/metrics.SearchMetrics: duration_ms + ai(calls/tokens/est_cost_usd, gpt-4o-mini 단가)
  + crawl(fetches/html_bytes/malls_crawled) + source_ms
- AI 클라이언트: resp.usage 를 last_usage 로 노출(그동안 폐기하던 토큰)
- 어댑터: last_bytes(처리 HTML 바이트) 노출 — naver/coupang/browser_base 공통
- handler: 각 fetch 타이밍+바이트, AI 호출 토큰을 metrics 로 누적 → 결과에 스냅샷
- FE: 작업 카드에 원가 4타일(소요/AI비용/토큰/크롤 트래픽)
- 테스트 2종. ⚠️ html_bytes 는 대역폭 근사(오픈마켓 리소스 미차단분 제외=하한), CDP 정확화는 백로그

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:14:35 +09:00

72 lines
3.4 KiB
Python

"""AI 유사도 판정 — 검색 결과가 '찾는 상품과 동일한 상품'인지 OpenAI 로 판별.
파이프라인(이상치 제거 뒤, top-N 앞) 슬롯에 결합한다. 규칙 고정 파싱이 취약한 문제를
LLM 판단으로 대체 — 액세서리/호환부품/다른 상품/명백히 다른 규격을 걸러 최저가 오염을 막는다.
structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다.
"""
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
from common.logger import LOG
from config.server_configs import openai_config
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 | None = None, api_key: str | None = None):
self._model = model or openai_config.model
self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key)
self.last_usage = None # 직전 호출 토큰 usage(계측용) — 호출부가 await 직후 읽는다
async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]:
"""후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환."""
self.last_usage = None
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,
)
self.last_usage = getattr(resp, "usage", None)
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