From b2b5708a6a6315196cee3ec8baccdf796c31c76b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Wed, 5 Aug 2026 15:45:32 +0900 Subject: [PATCH] =?UTF-8?q?fix(lps):=20AI=20=EB=A7=A4=EC=B9=AD=EC=9D=B4=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=20=EB=A7=8E=EC=9C=BC=EB=A9=B4=20=ED=86=B5?= =?UTF-8?q?=EC=A7=B8=EB=A1=9C=20=EB=AC=B4=EB=84=88=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=E2=80=94=20=EB=B0=B0=EC=B9=98=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20+=20=EA=B7=9C=EC=B9=99=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/B 실험(상품 8개)에서 2개가 매칭 0~1건으로 사실상 실패했다. 수집은 40건 정상, 가격도 정확했는데 판정이 못 찾았다. 파고드니 두 가지였다. **① 일괄 판정 붕괴(핵심)**: 후보 37건을 한 번에 넣으면 gpt-4o-mini 가 전 항목에 같은 점수(70)를 매기고 **전부 불일치**로 답한다. temperature=0 에서 3회 재현. 10건씩 쪼개면 같은 모델·같은 입력으로 12건이 매칭된다. 상품 3종에서 모두 재현(크리넥스 0→12, 다우니 8→23). → _BATCH=10 으로 쪼개 병렬 판정 후 전체 index 로 복원. 지연은 배치 1개분, 토큰은 합산. **② 프롬프트 규칙 충돌**: "포장(개수/박스) 차이는 동일 상품" vs "용량·규격이 다르면 불일치" 가 함께 있어 '30롤 1팩'과 '30롤 2팩'(=60롤)을 어느 쪽으로 볼지 정할 수 없었다. 최저가 관점에선 수량이 다르면 다른 상품이다(60롤 값을 30롤 최저가로 쓰면 왜곡). 종류 혼동도 실측에서 오탐을 만들었다 — '프라이팬'을 찾는데 볶음팬·웍팬이 매칭됐다. → 규칙을 '무시할 차이 / 불일치로 볼 차이'로 갈라 재작성(수량·종류 명시). 결과(캐시 후보 재판정): 크리넥스 30롤 매칭 0 → 5건, 최저가 **not_found → 15,500원** 다우니 4.1L 매칭 8 → 12건, 최저 21,890 유지 테팔 프라이팬 매칭 2 → 0건 (볶음팬·웍팬 오탐 제거 — 후보에 진짜 프라이팬이 없었다) 테스트 6건 추가(배치 분할 시 index 매핑·누락 보수처리·토큰 합산·빈 후보·상수 상한·규칙 충돌), 전체 237 passed. --- lps/services/ai/similarity.py | 68 ++++++++++++++++++----- lps/tests/test_similarity_judge.py | 87 ++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 lps/tests/test_similarity_judge.py diff --git a/lps/services/ai/similarity.py b/lps/services/ai/similarity.py index ea66a3b..8094b62 100644 --- a/lps/services/ai/similarity.py +++ b/lps/services/ai/similarity.py @@ -5,6 +5,9 @@ LLM 판단으로 대체 — 액세서리/호환부품/다른 상품/명백히 structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다. """ +import asyncio +from dataclasses import dataclass + from openai import AsyncOpenAI from pydantic import BaseModel, Field @@ -12,16 +15,35 @@ 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" - "- 액세서리·호환부품·부속품(빨대마개·뚜껑·커버·거치대·스트랩·보호필름 등)은 불일치(false).\n" - "- 다른 종류/브랜드/모델은 불일치. 모델명이 주어지면 모델 일치를 우선한다.\n" - "- 용량·규격이 명백히 다르면 불일치.\n" - "- 판매자·색상·포장(개수/박스)·사은품 차이는 동일 상품으로 본다.\n" - "- 확신이 낮으면 score 를 낮게 준다." + "너는 최저가 비교 시스템의 상품 매칭기다. 후보가 '찾는 상품과 동일한 상품'인지 판별하라.\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부터)") @@ -40,11 +62,34 @@ class SimilarityJudge: self.last_usage = None # 직전 호출 토큰 usage(계측용) — 호출부가 await 직후 읽는다 async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]: - """후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 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" @@ -62,10 +107,5 @@ class SimilarityJudge: 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 + return {j.index: j for j in (parsed.judgments if parsed else [])}, getattr(resp, "usage", None) diff --git a/lps/tests/test_similarity_judge.py b/lps/tests/test_similarity_judge.py new file mode 100644 index 0000000..681b9f6 --- /dev/null +++ b/lps/tests/test_similarity_judge.py @@ -0,0 +1,87 @@ +"""AI 동일상품 판정의 **오케스트레이션** 테스트 (모델 호출은 대역). + +모델 응답 품질이 아니라 계약을 지킨다: + - 후보를 배치로 쪼개도 판정 개수·순서가 보존되는가(전체 index 매핑) + - 배치가 누락 응답을 줘도 보수적으로 불일치 처리되는가 + - 토큰 사용량이 배치 전체로 합산되는가(원가 계측이 배치 수만큼 새면 안 됨) + +배치로 쪼개는 이유는 속도가 아니라 정확도다 — 후보 37건을 일괄로 넣으면 gpt-4o-mini 가 +전 항목에 같은 점수를 매기고 전부 불일치로 답한다(2026-08-05 실측, 3회 재현). +""" + +import services.ai.similarity as sim +from services.ai.similarity import Judgment, SimilarityJudge +from services.search.contract import NormalizedProduct + + +def _cands(n): + return [NormalizedProduct(source="naver", name=f"상품{i}", price=1000 + i) for i in range(n)] + + +class _Usage: + def __init__(self, p, c): + self.prompt_tokens, self.completion_tokens = p, c + + +def _judge_with(monkeypatch, batch_impl): + judge = SimilarityJudge.__new__(SimilarityJudge) # __init__ 은 OpenAI 클라이언트를 만든다 + judge._model = "test" + judge.last_usage = None + monkeypatch.setattr(judge, "_judge_batch", batch_impl, raising=False) + return judge + + +async def test_batches_preserve_order_and_global_index(monkeypatch): + seen = [] + + async def fake(target, part): + seen.append(len(part)) + # 배치 안에서는 항상 1..n 로 번호가 매겨진다 — 전체 번호로 되돌리는 건 judge 의 책임 + return {i + 1: Judgment(index=i + 1, is_match=(part[i].price % 2 == 0), score=50) for i in range(len(part))}, _Usage(10, 2) + + judge = _judge_with(monkeypatch, fake) + cands = _cands(25) + out = await judge.judge({}, cands) + + assert seen == [10, 10, 5], "10건씩 쪼개져야 한다" + assert [j.index for j in out] == list(range(1, 26)), "전체 index 가 1..N 로 복원돼야 한다" + assert [j.is_match for j in out] == [c.price % 2 == 0 for c in cands], "판정이 후보와 어긋나면 안 된다" + + +async def test_missing_judgment_is_treated_as_no_match(monkeypatch): + async def fake(target, part): + return {1: Judgment(index=1, is_match=True, score=90)}, _Usage(5, 1) # 나머지 누락 + + judge = _judge_with(monkeypatch, fake) + out = await judge.judge({}, _cands(3)) + assert [j.is_match for j in out] == [True, False, False] + assert out[2].score == 0 + + +async def test_usage_is_summed_across_batches(monkeypatch): + async def fake(target, part): + return {}, _Usage(100, 20) + + judge = _judge_with(monkeypatch, fake) + await judge.judge({}, _cands(25)) # 3배치 + assert judge.last_usage.prompt_tokens == 300 + assert judge.last_usage.completion_tokens == 60 + + +async def test_empty_candidates_short_circuits(monkeypatch): + async def fake(target, part): + raise AssertionError("후보가 없으면 모델을 부르면 안 된다") + + judge = _judge_with(monkeypatch, fake) + assert await judge.judge({}, []) == [] + + +def test_batch_size_is_small_enough_to_avoid_degenerate_output(): + """실측 근거: 37건 일괄 → 전멸(0건). 10건 → 12건 매칭. 이 상수가 커지면 그 실패가 돌아온다.""" + assert sim._BATCH <= 15 + + +def test_prompt_rules_do_not_conflict_on_quantity(): + """예전 프롬프트는 '포장(개수/박스) 차이는 동일'과 '규격 다르면 불일치'가 충돌했다.""" + assert "수량이 다름" in sim._SYSTEM + assert "포장(개수/박스)·사은품 차이는 동일" not in sim._SYSTEM