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>
This commit is contained in:
민헌 2026-07-09 15:14:35 +09:00
parent 61b9e6ae82
commit 69f6641c27
10 changed files with 168 additions and 17 deletions

View File

@ -81,11 +81,18 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
"shipping_fee": 0, "shipping_type": "rocket" }, "shipping_fee": 0, "shipping_type": "rocket" },
"top": [ /* 최저가 상위 N개 */ ], "top": [ /* 최저가 상위 N개 */ ],
"sources": { "naver": {"count": 40}, "coupang": {"count": 40} }, "sources": { "naver": {"count": 40}, "coupang": {"count": 40} },
"stages": [ {"stage":"outlier","in":80,"out":76}, {"stage":"ai_match","in":76,"out":1}, {"stage":"top_n","in":1,"out":1} ] "stages": [ {"stage":"outlier","in":80,"out":76}, {"stage":"ai_match","in":76,"out":1}, {"stage":"top_n","in":1,"out":1} ],
"metrics": { // 이 검색 1건이 쓴 리소스/비용/시간
"duration_ms": 21500,
"ai": { "calls": 2, "prompt_tokens": 5200, "completion_tokens": 180, "est_cost_usd": 0.000888 },
"crawl": { "fetches": 3, "html_bytes": 1560000, "malls_crawled": ["gmarket"] },
"source_ms": { "naver": 480, "coupang": 12300, "gmarket": 8700 }
}
} }
} }
``` ```
- `output.stages` = 각 단계에서 몇 건이 걸러졌는지(디버깅·품질 확인용). - `output.stages` = 각 단계에서 몇 건이 걸러졌는지(디버깅·품질 확인용).
- `output.metrics` = 검색 1건의 원가. `ai`(호출·토큰·추정 비용$), `crawl`(fetch 수·처리 HTML 바이트·크롤한 몰), `source_ms`(소스별 소요), `duration_ms`(전체). ※ `html_bytes`는 처리한 응답 본문 기준(대역폭 근사) — 오픈마켓은 리소스 미차단이라 실제 대역폭보다 작다(하한).
- 없는 job_id/잘못된 형식 → `result.desc = "LPS_JOB_NOT_FOUND"`. - 없는 job_id/잘못된 형식 → `result.desc = "LPS_JOB_NOT_FOUND"`.
> **⚠️ 가격의 의미 (배송비)** > **⚠️ 가격의 의미 (배송비)**

View File

@ -30,6 +30,7 @@ class KeywordGenerator:
def __init__(self, model: str | None = None, api_key: str | None = None): def __init__(self, model: str | None = None, api_key: str | None = None):
self._model = model or openai_config.model self._model = model or openai_config.model
self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key) self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key)
self.last_usage = None # 직전 호출 토큰 usage(계측용)
async def generate(self, target: dict) -> Keywords: async def generate(self, target: dict) -> Keywords:
user = ( user = (
@ -44,6 +45,7 @@ class KeywordGenerator:
response_format=Keywords, response_format=Keywords,
temperature=0, temperature=0,
) )
self.last_usage = getattr(resp, "usage", None)
kw = resp.choices[0].message.parsed or Keywords(precise="", broad="") kw = resp.choices[0].message.parsed or Keywords(precise="", broad="")
LOG.d(f"[ai] 검색어 생성 precise={kw.precise!r} broad={kw.broad!r}") LOG.d(f"[ai] 검색어 생성 precise={kw.precise!r} broad={kw.broad!r}")
return kw return kw

View File

@ -37,9 +37,11 @@ class SimilarityJudge:
def __init__(self, model: str | None = None, api_key: str | None = None): def __init__(self, model: str | None = None, api_key: str | None = None):
self._model = model or openai_config.model self._model = model or openai_config.model
self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key) 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]: async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]:
"""후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환.""" """후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환."""
self.last_usage = None
if not candidates: if not candidates:
return [] return []
@ -60,6 +62,7 @@ class SimilarityJudge:
response_format=JudgmentList, response_format=JudgmentList,
temperature=0, temperature=0,
) )
self.last_usage = getattr(resp, "usage", None)
parsed = resp.choices[0].message.parsed parsed = resp.choices[0].message.parsed
by_idx = {j.index: j for j in (parsed.judgments if parsed else [])} by_idx = {j.index: j for j in (parsed.judgments if parsed else [])}
# 누락된 후보는 보수적으로 불일치 처리 # 누락된 후보는 보수적으로 불일치 처리

75
lps/services/metrics.py Normal file
View File

@ -0,0 +1,75 @@
"""검색 1건의 리소스/비용/시간 계측(관측용).
한 상품 검색이 소모하는 것을 잡 단위로 집계한다:
- 시간: 전체 소요 + 소스별 소요(ms)
- AI: 호출 수 + 토큰(prompt/completion) + 추정 비용($)
- 크롤: 외부 fetch 수 + 처리 HTML 바이트 + 크롤한 몰
핸들러가 각 호출을 타이밍하고, AI 클라이언트/어댑터가 노출하는 last_usage/last_bytes 를 읽어 누적한다.
결과는 job.result.metrics 로 적재돼 API/FE 에서 검색 원가를 확인할 수 있다.
⚠️ HTML 바이트는 '처리한 응답 본문' 기준(대역폭 근사). 쿠팡은 리소스 차단이라 실제와 근접하지만,
오픈마켓(리소스 미차단)은 CSS/JS/이미지가 빠져 실제 대역폭보다 작다(하한). 정확 대역폭은 백로그(CDP).
"""
import time
# 모델별 1M 토큰당 단가(USD). input=prompt, output=completion. 모르면 0(비용 미추정).
_PRICING = {
"gpt-4o-mini": (0.150, 0.600),
"gpt-4o": (2.50, 10.00),
"gpt-4.1-mini": (0.40, 1.60),
}
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
inp, out = _PRICING.get(model, (0.0, 0.0))
return round(prompt_tokens / 1_000_000 * inp + completion_tokens / 1_000_000 * out, 6)
class SearchMetrics:
"""검색 1건의 누적 계측기. 스레드/코루틴 공유 X — 잡마다 새로 만든다."""
def __init__(self, model: str = ""):
self._start = time.monotonic()
self._model = model
self.ai_calls = 0
self.ai_prompt = 0
self.ai_completion = 0
self.fetches = 0
self.html_bytes = 0
self.source_ms: dict[str, int] = {}
self.crawled_malls: list[str] = []
def add_ai(self, usage):
"""OpenAI resp.usage(prompt_tokens/completion_tokens) 누적. None 이면 무시."""
if not usage:
return
self.ai_calls += 1
self.ai_prompt += getattr(usage, "prompt_tokens", 0) or 0
self.ai_completion += getattr(usage, "completion_tokens", 0) or 0
def add_fetch(self, source: str, html_bytes: int, ms: int, crawl: bool = False):
"""외부 fetch 1건(소스·바이트·소요) 누적. crawl=True 면 오픈마켓 폴백 크롤."""
self.fetches += 1
self.html_bytes += html_bytes or 0
self.source_ms[source] = self.source_ms.get(source, 0) + ms
if crawl and source not in self.crawled_malls:
self.crawled_malls.append(source)
def snapshot(self) -> dict:
return {
"duration_ms": int((time.monotonic() - self._start) * 1000),
"ai": {
"calls": self.ai_calls,
"prompt_tokens": self.ai_prompt,
"completion_tokens": self.ai_completion,
"est_cost_usd": estimate_cost(self._model, self.ai_prompt, self.ai_completion),
},
"crawl": {
"fetches": self.fetches,
"html_bytes": self.html_bytes,
"malls_crawled": self.crawled_malls,
},
"source_ms": self.source_ms,
}

View File

@ -67,6 +67,7 @@ class BrowserSearchAdapter(SearchAdapter):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._ok = 0 self._ok = 0
self._blocked = 0 self._blocked = 0
self.last_bytes = 0 # 직전 search 의 처리 HTML 바이트(계측용) — 호출부가 await 직후 읽는다
# ---- 사이트별 훅 -------------------------------------------------- # ---- 사이트별 훅 --------------------------------------------------
@abstractmethod @abstractmethod
@ -149,6 +150,7 @@ class BrowserSearchAdapter(SearchAdapter):
except Exception as ex: except Exception as ex:
raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex
self.last_bytes = len(html.encode("utf-8"))
products = self._parse(html) products = self._parse(html)
if products: if products:
self._ok += 1 self._ok += 1

View File

@ -69,6 +69,7 @@ class CoupangAdapter(SearchAdapter):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._ok = 0 self._ok = 0
self._blocked = 0 self._blocked = 0
self.last_bytes = 0 # 직전 search 의 처리 HTML 바이트(계측용)
async def _route(self, route): async def _route(self, route):
if route.request.resource_type in _BLOCKED_RESOURCES: if route.request.resource_type in _BLOCKED_RESOURCES:
@ -130,6 +131,7 @@ class CoupangAdapter(SearchAdapter):
except Exception as ex: except Exception as ex:
raise AdapterError(f"쿠팡 검색 실패: {ex}", source=self.source) from ex raise AdapterError(f"쿠팡 검색 실패: {ex}", source=self.source) from ex
self.last_bytes = len(html.encode("utf-8"))
products = parse_search_html(html, source=self.source) products = parse_search_html(html, source=self.source)
if products: if products:
self._ok += 1 self._ok += 1

View File

@ -33,6 +33,7 @@ class NaverAdapter(SearchAdapter):
self._timeout = timeout self._timeout = timeout
self._ok = 0 self._ok = 0
self._blocked = 0 self._blocked = 0
self.last_bytes = 0 # 직전 search 의 응답 바이트(계측용)
def _headers(self) -> dict: def _headers(self) -> dict:
cid, csec = self._keys[self._idx] cid, csec = self._keys[self._idx]
@ -45,6 +46,7 @@ class NaverAdapter(SearchAdapter):
if not self._keys: if not self._keys:
raise AdapterError("네이버 API 키 없음(config.local.toml [NaverConfig].keys)", source=self.source) raise AdapterError("네이버 API 키 없음(config.local.toml [NaverConfig].keys)", source=self.source)
self.last_bytes = 0
collected: list[dict] = [] collected: list[dict] = []
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
start = 1 start = 1
@ -74,6 +76,7 @@ class NaverAdapter(SearchAdapter):
await self._rl.wait() await self._rl.wait()
r = await client.get(_API, params=params, headers=self._headers()) r = await client.get(_API, params=params, headers=self._headers())
if r.status_code == 200: if r.status_code == 200:
self.last_bytes += len(r.content)
return r.json() return r.json()
if r.status_code in (429, 403): if r.status_code in (429, 403):
self._blocked += 1 self._blocked += 1

View File

@ -24,9 +24,15 @@ class FakeAdapter:
return self._products return self._products
class _Usage:
def __init__(self, prompt, completion):
self.prompt_tokens, self.completion_tokens = prompt, completion
class FakeJudge: class FakeJudge:
def __init__(self, predicate): def __init__(self, predicate, usage=None):
self._pred = predicate self._pred = predicate
self.last_usage = usage # 계측용(핸들러가 judge 후 읽음)
async def judge(self, target, candidates): async def judge(self, target, candidates):
from services.ai.similarity import Judgment from services.ai.similarity import Judgment
@ -37,6 +43,7 @@ class FakeJudge:
class FakeKeywordGen: class FakeKeywordGen:
def __init__(self, precise="", broad=""): def __init__(self, precise="", broad=""):
self._p, self._b = precise, broad self._p, self._b = precise, broad
self.last_usage = None
async def generate(self, target): async def generate(self, target):
from services.ai.keyword import Keywords from services.ai.keyword import Keywords
@ -151,6 +158,33 @@ async def test_fallback_failure_is_isolated():
assert r["outcome"] == "found" and r["lowest"]["price"] == 9000 # 폴백 실패해도 정상 종료 assert r["outcome"] == "found" and r["lowest"]["price"] == 9000 # 폴백 실패해도 정상 종료
# ── 검색 원가 계측(metrics) ────────────────────────────────────────
async def test_metrics_recorded_in_result():
adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 1000), _np("naver", 2000)])}
adapters["naver"].last_bytes = 1234
judge = FakeJudge(lambda c: True, usage=_Usage(500, 40))
r = await build_search_handler(adapters, judge=judge, ai_model="gpt-4o-mini")(_job())
m = r["metrics"]
assert m["ai"]["calls"] == 1 and m["ai"]["prompt_tokens"] == 500 and m["ai"]["completion_tokens"] == 40
assert m["ai"]["est_cost_usd"] == round(500/1e6*0.15 + 40/1e6*0.60, 6) # gpt-4o-mini 단가
assert m["crawl"]["fetches"] == 1 and m["crawl"]["html_bytes"] == 1234
assert "naver" in m["source_ms"] and "duration_ms" in m
async def test_metrics_counts_fallback_crawl():
adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 5000, mall="네이버")])}
st11 = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")])
st11.last_bytes = 9999
r = await build_search_handler(
adapters, judge=FakeJudge(lambda c: True),
fallback_adapters={"st11": st11},
)(_job())
m = r["metrics"]
assert m["crawl"]["fetches"] == 2 # naver + st11 크롤
assert "st11" in m["crawl"]["malls_crawled"] # 폴백 크롤 몰 기록
assert m["crawl"]["html_bytes"] == 9999 # st11 바이트 포함
async def test_fallback_dedup_same_mall_keeps_lowest(): async def test_fallback_dedup_same_mall_keeps_lowest():
# 네이버 매칭에 G마켓 없음 → 크롤. 크롤 G마켓이 네이버 '네이버몰'보다 싸면 최저가 갱신. # 네이버 매칭에 G마켓 없음 → 크롤. 크롤 G마켓이 네이버 '네이버몰'보다 싸면 최저가 갱신.
adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 8000, mall="네이버")])} adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 8000, mall="네이버")])}

View File

@ -14,9 +14,11 @@
""" """
import asyncio import asyncio
import time
from common.enums import JobType from common.enums import JobType
from common.logger import LOG from common.logger import LOG
from services.metrics import SearchMetrics
from services.search.contract import SearchAdapter, NormalizedProduct from services.search.contract import SearchAdapter, NormalizedProduct
from services.search.card_parser import canonical_mall, MALL_BY_SOURCE from services.search.card_parser import canonical_mall, MALL_BY_SOURCE
from services.search.util import parse_price from services.search.util import parse_price
@ -52,6 +54,7 @@ def build_search_handler(
neg_cache=None, neg_cache=None,
history=None, history=None,
fallback_adapters: dict[str, SearchAdapter] | None = None, fallback_adapters: dict[str, SearchAdapter] | None = None,
ai_model: str = "",
): ):
"""검색 핸들러 생성. """검색 핸들러 생성.
judge: SimilarityJudge(같은 상품 판정) / keyword_gen: KeywordGenerator(정밀·광역 재검색어) / judge: SimilarityJudge(같은 상품 판정) / keyword_gen: KeywordGenerator(정밀·광역 재검색어) /
@ -70,9 +73,21 @@ def build_search_handler(
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(f"[history] 스냅샷 기록 실패(무시): {ex}") LOG.e_no_callstack(f"[history] 스냅샷 기록 실패(무시): {ex}")
async def _search_round(query: str): async def _timed_search(adapter, query: str, source: str, metrics: SearchMetrics, crawl: bool):
"""한 라운드: 모든 소스 동시 검색 → (products, per_source, tech_failed).""" """어댑터 검색 1건을 타이밍+바이트 계측하며 실행. 예외는 그대로 전파(호출부가 처리)."""
results = await asyncio.gather(*[adapters[s].search(query, limit=limit) for s in use], return_exceptions=True) t0 = time.monotonic()
try:
res = await adapter.search(query, limit=limit)
metrics.add_fetch(source, getattr(adapter, "last_bytes", 0), int((time.monotonic() - t0) * 1000), crawl=crawl)
return res
except Exception:
metrics.add_fetch(source, 0, int((time.monotonic() - t0) * 1000), crawl=crawl)
raise
async def _search_round(query: str, metrics: SearchMetrics):
"""한 라운드: 모든 소스 동시 검색 → (products, per_source, tech_failed). 소스별 시간/바이트 계측."""
results = await asyncio.gather(*[_timed_search(adapters[s], query, s, metrics, False) for s in use],
return_exceptions=True)
products, per_source, tech_failed = [], {}, False products, per_source, tech_failed = [], {}, False
for src, res in zip(use, results): for src, res in zip(use, results):
if isinstance(res, Exception): if isinstance(res, Exception):
@ -84,15 +99,16 @@ def build_search_handler(
per_source[src] = {"count": len(res)} per_source[src] = {"count": len(res)}
return products, per_source, tech_failed return products, per_source, tech_failed
async def _match(target: dict, products: list, base_price): async def _match(target: dict, products: list, base_price, metrics: SearchMetrics):
"""필터 → (있으면) AI 같은상품 판정 → 매칭 후보. 폴백 크롤 결과 판정에도 재사용.""" """필터 → (있으면) AI 같은상품 판정 → 매칭 후보. AI 토큰은 metrics 에 누적."""
candidates, _ = apply_filters(products, base_price=base_price) candidates, _ = apply_filters(products, base_price=base_price)
if judge is not None and candidates: if judge is not None and candidates:
verdicts = await judge.judge(target, candidates) verdicts = await judge.judge(target, candidates)
metrics.add_ai(judge.last_usage)
candidates = [c for c, v in zip(candidates, verdicts) if v.is_match] candidates = [c for c, v in zip(candidates, verdicts) if v.is_match]
return candidates return candidates
async def _enrich_with_fallback(target: dict, query: str, matched: list, base_price): async def _enrich_with_fallback(target: dict, query: str, matched: list, base_price, metrics: SearchMetrics):
"""네이버가 커버 못 한 오픈마켓만 직접 크롤(폴백) → 같은상품 판정 후 병합. """네이버가 커버 못 한 오픈마켓만 직접 크롤(폴백) → 같은상품 판정 후 병합.
사용자 규칙: '네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 실사이트 크롤'.""" 사용자 규칙: '네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 실사이트 크롤'."""
if not fallbacks: if not fallbacks:
@ -103,21 +119,22 @@ def build_search_handler(
if mall in covered: # 네이버가 이미 그 몰 최저가 확보 → 크롤 생략 if mall in covered: # 네이버가 이미 그 몰 최저가 확보 → 크롤 생략
continue continue
try: try:
crawled = await adapter.search(query, limit=limit) crawled = await _timed_search(adapter, query, src, metrics, crawl=True)
except Exception as ex: except Exception as ex:
LOG.w(f"[fallback:{src}] 크롤 실패(무시): {type(ex).__name__}: {ex}") LOG.w(f"[fallback:{src}] 크롤 실패(무시): {type(ex).__name__}: {ex}")
continue continue
hits = await _match(target, crawled, base_price) hits = await _match(target, crawled, base_price, metrics)
if hits: if hits:
LOG.d(f"[fallback:{src}] 크롤 {len(crawled)}건 중 같은상품 {len(hits)}건 병합") LOG.d(f"[fallback:{src}] 크롤 {len(crawled)}건 중 같은상품 {len(hits)}건 병합")
matched = matched + hits matched = matched + hits
return matched return matched
async def _round_queries(base_query: str, target: dict): async def _round_queries(base_query: str, target: dict, metrics: SearchMetrics):
"""라운드 쿼리 지연 생성: 원본 → (0매칭 시에만 LLM 호출로) 정밀 → 광역.""" """라운드 쿼리 지연 생성: 원본 → (0매칭 시에만 LLM 호출로) 정밀 → 광역."""
yield ("original", base_query) yield ("original", base_query)
if keyword_gen is not None: if keyword_gen is not None:
kw = await keyword_gen.generate(target) # 원본이 실패해 여기까지 온 경우에만 호출됨 kw = await keyword_gen.generate(target) # 원본이 실패해 여기까지 온 경우에만 호출됨
metrics.add_ai(keyword_gen.last_usage)
seen = {base_query} seen = {base_query}
for label, q in (("precise", kw.precise), ("broad", kw.broad)): for label, q in (("precise", kw.precise), ("broad", kw.broad)):
q = (q or "").strip() q = (q or "").strip()
@ -136,23 +153,26 @@ def build_search_handler(
target = {k: payload.get(k, "") for k in ("product_name", "model", "specification", "company")} target = {k: payload.get(k, "") for k in ("product_name", "model", "specification", "company")}
base_price = parse_price(payload.get("price")) base_price = parse_price(payload.get("price"))
cache_key = payload.get("product_code") or base_query cache_key = payload.get("product_code") or base_query
metrics = SearchMetrics(ai_model) # 검색 1건의 리소스/비용/시간 계측
# 0) 네거티브 캐시 — 최근 not_found면 재검색 생략 # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략
if neg_cache is not None and await neg_cache.is_negative(cache_key): if neg_cache is not None and await neg_cache.is_negative(cache_key):
return {"outcome": "not_found", "cached": True, "query": base_query, return {"outcome": "not_found", "cached": True, "query": base_query,
"rounds_tried": 0, "lowest": None, "top": [], "stages": [], "sources": {}} "rounds_tried": 0, "lowest": None, "top": [], "stages": [], "sources": {},
"metrics": metrics.snapshot()}
rounds_done = 0 rounds_done = 0
last_stages, last_sources = [], {} last_stages, last_sources = [], {}
async for label, query in _round_queries(base_query, target): async for label, query in _round_queries(base_query, target, metrics):
if rounds_done >= max_rounds: if rounds_done >= max_rounds:
break break
rounds_done += 1 rounds_done += 1
products, per_source, tech_failed = await _search_round(query) products, per_source, tech_failed = await _search_round(query, metrics)
candidates, stages = apply_filters(products, base_price=base_price) candidates, stages = apply_filters(products, base_price=base_price)
if judge is not None and candidates: if judge is not None and candidates:
verdicts = await judge.judge(target, candidates) verdicts = await judge.judge(target, candidates)
metrics.add_ai(judge.last_usage)
matched = [c for c, v in zip(candidates, verdicts) if v.is_match] matched = [c for c, v in zip(candidates, verdicts) if v.is_match]
stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)}) stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)})
candidates = matched candidates = matched
@ -160,11 +180,12 @@ def build_search_handler(
if candidates: # 찾음 → 오픈마켓 폴백 보강 후 종료 if candidates: # 찾음 → 오픈마켓 폴백 보강 후 종료
before = len(candidates) before = len(candidates)
candidates = await _enrich_with_fallback(target, query, candidates, base_price) candidates = await _enrich_with_fallback(target, query, candidates, base_price, metrics)
if len(candidates) > before: if len(candidates) > before:
stages.append({"stage": "fallback_crawl", "in": before, "out": len(candidates)}) stages.append({"stage": "fallback_crawl", "in": before, "out": len(candidates)})
result = rank_result(candidates, len(products), stages, top_n) result = rank_result(candidates, len(products), stages, top_n)
result.update(outcome="found", query=query, round=label, rounds_tried=rounds_done, sources=per_source) result.update(outcome="found", query=query, round=label, rounds_tried=rounds_done,
sources=per_source, metrics=metrics.snapshot())
await _record_history(cache_key, job.get("job_id"), "found", candidates) await _record_history(cache_key, job.get("job_id"), "found", candidates)
return result return result
@ -175,7 +196,8 @@ def build_search_handler(
if neg_cache is not None: if neg_cache is not None:
await neg_cache.put(cache_key, reason=f"not_found after {rounds_done} rounds") await neg_cache.put(cache_key, reason=f"not_found after {rounds_done} rounds")
result = rank_result([], 0, last_stages, top_n) result = rank_result([], 0, last_stages, top_n)
result.update(outcome="not_found", query=base_query, rounds_tried=rounds_done, sources=last_sources) result.update(outcome="not_found", query=base_query, rounds_tried=rounds_done,
sources=last_sources, metrics=metrics.snapshot())
await _record_history(cache_key, job.get("job_id"), "not_found", []) await _record_history(cache_key, job.get("job_id"), "not_found", [])
return result return result

View File

@ -57,6 +57,7 @@ async def main(concurrency: int = 1):
adapters, judge=judge, keyword_gen=keyword_gen, adapters, judge=judge, keyword_gen=keyword_gen,
neg_cache=NegativeCache(), history=PriceHistory(), neg_cache=NegativeCache(), history=PriceHistory(),
fallback_adapters=fallback_adapters, fallback_adapters=fallback_adapters,
ai_model=openai_config.model,
) )
stop = asyncio.Event() stop = asyncio.Event()