o2o-negosium-original/lps/worker/handlers.py
민헌 b0a86b9720 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>
2026-07-09 08:44:57 +09:00

72 lines
3.1 KiB
Python

"""잡 핸들러 — job_type 별 처리. 현재는 SEARCH(검색)만.
검색 핸들러: 여러 소스 어댑터를 동시 검색 → 병합 → 코어 파이프라인(필터·이상치·top-N 최저가).
소스별 실패는 격리한다(한 소스가 죽어도 나머지로 결과 산출). 모든 소스 실패 시에만 잡 실패(재시도).
AI 유사도 판정은 파이프라인 슬롯에 키 준비 시 결합한다.
"""
import asyncio
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 apply_filters, rank_result
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:
if job["job_type"] != JobType.SEARCH.value:
raise ValueError(f"unsupported job_type: {job['job_type']}")
payload = job.get("payload") or {}
query = (payload.get("product_name") or "").strip()
if not query:
raise ValueError("empty product_name")
base_price = parse_price(payload.get("price"))
# 소스 동시 검색 (실패는 예외로 수거해 격리)
results = await asyncio.gather(
*[adapters[s].search(query, limit=limit) for s in use],
return_exceptions=True,
)
products = []
per_source: dict[str, dict] = {}
for src, res in zip(use, results):
if isinstance(res, Exception):
LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}")
per_source[src] = {"error": f"{type(res).__name__}: {res}"}
else:
products.extend(res)
per_source[src] = {"count": len(res)}
if not products and all("error" in v for v in per_source.values()):
raise RuntimeError(f"모든 소스 검색 실패: {per_source}") # 잡 실패 → 재시도
# 필터(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
return handler