"""AI 유사도 판정 — 검색 결과가 '찾는 상품과 동일한 상품'인지 OpenAI 로 판별. 파이프라인(이상치 제거 뒤, top-N 앞) 슬롯에 결합한다. 규칙 고정 파싱이 취약한 문제를 LLM 판단으로 대체 — 액세서리/호환부품/다른 상품/명백히 다른 규격을 걸러 최저가 오염을 막는다. structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다. """ import asyncio from dataclasses import dataclass 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 # 규칙은 '무시할 차이'와 '불일치로 볼 차이'로 갈라 쓴다. 예전엔 "포장(개수/박스) 차이는 동일"과 # "용량·규격이 다르면 불일치"가 함께 있어 '30롤 1팩' vs '30롤 2팩'(=60롤)을 어느 쪽으로 볼지 # 모델이 정할 수 없었다. 최저가 관점에선 수량이 다르면 다른 상품이다(60롤 값을 30롤 최저가로 # 쓰면 왜곡). 종류 혼동(프라이팬/볶음팬/웍팬)도 실측에서 오탐을 만들어 명시한다. _SYSTEM = ( "너는 최저가 비교 시스템의 상품 매칭기다. 후보가 '찾는 상품과 동일한 상품'인지 판별하라.\n" "동일하다고 보는 차이(무시할 것):\n" "- 판매자·스토어, 색상/향, 사은품·증정품, 배송 문구, 상품명 수식어(무형광·프리미엄 등)\n" "불일치로 보는 차이:\n" "- 종류가 다름: 프라이팬 / 볶음팬 / 웍팬 / 냄비는 서로 다른 종류다. 세트 구성품도 단품과 다르다\n" "- 브랜드·모델이 다름(모델명이 주어지면 모델 일치를 우선)\n" "- 용량·크기가 다름(ml·L·g·cm)\n" "- 수량이 다름: 개입·롤수·팩수. 예) '30롤 1팩'과 '30롤 2팩'(=60롤)은 다른 상품이다\n" "- 액세서리·호환부품(뚜껑·손잡이·거치대·리필 전용 부속 등)\n" "확신이 낮으면 score 를 낮게 준다." ) # 한 번에 판정할 후보 수. **크면 판정이 통째로 무너진다** — 실측(2026-08-05): 후보 37건을 일괄로 # 넣으면 gpt-4o-mini 가 전 항목에 같은 점수(70)를 매기고 전부 불일치로 답한다(3회 재현). # 10건씩 나누면 같은 입력·같은 모델로 12건이 매칭됐다(0건 → 12건). 상품 3종에서 모두 재현. _BATCH = 10 @dataclass class _Usage: """배치 여러 개의 토큰을 합쳐 계측부(metrics.add_ai)에 하나로 넘긴다.""" prompt_tokens: int = 0 completion_tokens: int = 0 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 리스트 반환. 후보를 _BATCH 단위로 쪼개 **병렬**로 묻고 합친다(지연은 배치 1개분). 쪼개는 이유는 속도가 아니라 정확도다 — 위 _BATCH 주석의 실측 참고. """ self.last_usage = None if not candidates: return [] chunks = [candidates[i:i + _BATCH] for i in range(0, len(candidates), _BATCH)] results = await asyncio.gather(*(self._judge_batch(target, ch) for ch in chunks)) out: list[Judgment] = [] prompt = completion = 0 for offset, (judgments, usage) in zip(range(0, len(candidates), _BATCH), results): for k in range(len(chunks[offset // _BATCH])): j = judgments.get(k + 1) # 배치 안 번호(1..n)를 전체 번호로 되돌린다. 누락은 보수적으로 불일치. out.append(Judgment(index=offset + k + 1, is_match=bool(j and j.is_match), score=j.score if j else 0)) prompt += getattr(usage, "prompt_tokens", 0) or 0 completion += getattr(usage, "completion_tokens", 0) or 0 self.last_usage = _Usage(prompt, completion) LOG.d(f"[ai] 판정 {len(candidates)}건({len(chunks)}배치) 중 매칭 {sum(1 for j in out if j.is_match)}건") return out async def _judge_batch(self, target: dict, candidates: list[NormalizedProduct]): """배치 1개 판정 → ({배치내 index: Judgment}, usage).""" 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 return {j.index: j for j in (parsed.judgments if parsed else [])}, getattr(resp, "usage", None)