feat(lps): 1단계 — 몰별 상태 보존, '없음'과 '못 봄'을 가른다

docs/result-states.md 의 1단계. 어댑터는 이미 구분을 알고 있는데 핸들러가 그 정보를 버리고
있었다(per_source[src] = {"error": 문자열}). 그래서 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'
으로 둔갑했다. 새로 알아낼 정보는 없고, 흘리던 걸 잡아두기만 하면 된다.

- common/enums.py: SourceState 7상태 추가(MATCHED/NO_MATCH/EMPTY/BLOCKED/ENV_BLOCKED/
  UNAVAILABLE/SKIPPED). `.confirmed` 프로퍼티로 **'봤다 vs 못 봤다' 경계를 한곳에** 둔다 —
  이 경계가 무너지면 나머지 판단이 전부 틀어지므로 흩어놓지 않는다.
- AdapterError.state: 어댑터가 아는 구분을 실어 보낸다. blocked/fatal 은 '어떻게 대응할까'
  (회전·재시도)를 위한 값이고 state 는 '사용자에게 뭐라 말할까'를 위한 값이라 쓰임이 다르다.
  특히 blocked=False 하나에 결과0건(EMPTY)과 전송실패(UNAVAILABLE)가 섞여 있어 state 없이는
  갈라낼 수 없었다. **기본값은 UNAVAILABLE** — 모르면 '못 봤다'가 안전하다(EMPTY 로 두면
  확인도 안 한 몰을 '없음'으로 단정한다).
- browser_base: raise 지점 5곳에 상태 부여. 핵심 갈림은 0건 종착 한 곳 —
  blocked=False → EMPTY(정말 없다) / blocked=True → BLOCKED(못 봤다).
- _search_round: {"state": ..., "count"|"error": ...} 로 구조화. EMPTY 는 '정상 응답'으로 세어
  (confirmed) 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않게 한다.
- _finalize_states: 수집만 된 소스를 AI 판정 뒤 MATCHED/NO_MATCH 로 확정한다. 실패 상태는
  이미 확정이라 덮지 않는다.

검증(9조합 실측): empty → partial=False(확정) / blocked·env_blocked·unavailable → partial=True.
테스트 12건 추가, 전체 286 passed. 진행 상황은 docs/result-states.md 4절에 기록.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-08-07 10:47:28 +09:00
parent 1e966f7121
commit b278f58d9c
6 changed files with 208 additions and 14 deletions

View File

@ -69,3 +69,26 @@ class JobType(Enum):
SEARCH = 1 # 최저가 검색(쿠팡=브라우저) — 무거움 SEARCH = 1 # 최저가 검색(쿠팡=브라우저) — 무거움
OUTBOX = 2 # 외부 API 결과 전송(재시도 엔진 공유) — 가벼움 OUTBOX = 2 # 외부 API 결과 전송(재시도 엔진 공유) — 가벼움
class SourceState(Enum):
"""한 상품을 **한 몰에서** 찾은 결과. 정의·표기 규칙은 docs/result-states.md 가 소스다.
가장 중요한 경계는 `확인함` 과 `못 봄` 사이다:
MATCHED·NO_MATCH·EMPTY 그 몰을 실제로 봤다 → "없다"고 말해도 되는 사실
BLOCKED·ENV_BLOCKED·UNAVAILABLE 못 봤다 → "없다"고 말하면 거짓이 된다
이 경계를 잃으면 '차단당해 못 본 것'이 '그 몰엔 없음'으로 둔갑한다(실측 문제).
"""
MATCHED = 1 # 수집·매칭 성공 — 가격 확보
NO_MATCH = 2 # 수집은 됐으나 같은 상품이 없음(액세서리·다른 규격만)
EMPTY = 3 # 그 몰의 검색 결과 자체가 0건
BLOCKED = 4 # 안티봇 차단 — IP 회전으로 회복 가능(자동)
ENV_BLOCKED = 5 # 회전해도 안 되는 차단(환경·게이트웨이 설정) — 사람이 고쳐야 함
UNAVAILABLE = 6 # 전송 실패·가용 IP 없음 등 일시적 — 잠시 후 재시도로 회복
SKIPPED = 7 # 그 소스를 아예 쓰지 않음(폴백 OFF 등)
@property
def confirmed(self) -> bool:
"""그 몰을 **실제로 확인했는지**. False 면 '없다'고 단정하면 안 된다."""
return self in (SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY)

View File

@ -157,13 +157,31 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata
- 부분 실패 시 네거티브 캐시 오염 방지 - 부분 실패 시 네거티브 캐시 오염 방지
- 한 소스가 막혀도 살아있는 소스로 잡을 정상 종료 - 한 소스가 막혀도 살아있는 소스로 잡을 정상 종료
**안 된 것** ### 진행 상황
1. `_search_round` 가 `AdapterError.blocked/fatal` 을 버린다 → 몰별 상태를 못 만든다 *(가장 근본)*
**1단계 — 몰별 상태 보존 ✅ 완료** (`feat/source-state`)
- `common/enums.py` 에 `SourceState`(7상태) 추가. `.confirmed` 로 '봤다/못 봤다' 경계를 한곳에 둔다.
- `AdapterError.state` — 어댑터가 이미 알던 구분을 실어 보낸다. `state` 를 안 주면
`blocked/fatal` 에서 유도하고, **모르면 `UNAVAILABLE`**(= 못 봤다)로 둔다.
`EMPTY` 를 기본값으로 하면 확인도 안 한 몰을 '없음'으로 단정하게 되기 때문이다.
- `browser_base` 의 raise 지점 5곳에 상태를 실었다. 갈림은 0건 종착 지점 하나다:
`blocked=False` → `EMPTY`(정말 없다) / `blocked=True` → `BLOCKED`(못 봤다).
- `_search_round` 가 문자열 대신 `{"state": ..., "count"|"error": ...}` 를 남긴다.
`EMPTY` 는 '정상 응답'으로 세므로, 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않는다.
- `_finalize_states` — 수집만 된 소스를 AI 판정 뒤 `MATCHED`/`NO_MATCH` 로 확정한다.
검증(9조합 실측): `empty` → `partial=False`(확정) / `blocked`·`env_blocked`·`unavailable`
→ `partial=True`(미확정). 테스트 12건 추가.
**남은 것**
2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다 2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다
(지금은 `job.result` 에만 있다)
3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만) 3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만)
4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다 4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다
**1 → 2 → (3, 4)** 순서다. 1을 안 고치면 2가 담을 내용이 없고, 2가 없으면 3·4가 읽을 게 없다. **2 → (3, 4)** 순서다. 2가 없으면 3·4가 읽을 게 없다.
3과 4는 같은 데이터에서 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다. 3과 4는 같은 데이터를 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다.
> 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다. > 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.

View File

@ -20,6 +20,7 @@ from abc import abstractmethod
from patchright.async_api import async_playwright from patchright.async_api import async_playwright
from common.logger import LOG from common.logger import LOG
from common.enums import SourceState
from config.server_configs import decodo_config, worker_config from config.server_configs import decodo_config, worker_config
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
from services.search.rate_limiter import RateLimiter from services.search.rate_limiter import RateLimiter
@ -253,7 +254,7 @@ class BrowserSearchAdapter(SearchAdapter):
self._note_result(False) self._note_result(False)
raise AdapterError( raise AdapterError(
f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도", f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도",
source=self.source) source=self.source, state=SourceState.UNAVAILABLE)
kwargs["proxy"] = self._proxy.playwright_proxy() kwargs["proxy"] = self._proxy.playwright_proxy()
await self._begin_ip_session(self._proxy.current_port) await self._begin_ip_session(self._proxy.current_port)
else: else:
@ -409,7 +410,9 @@ class BrowserSearchAdapter(SearchAdapter):
self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}", kind="proxy_error") self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}", kind="proxy_error")
continue continue
self._note_result(False) self._note_result(False)
raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex # 페이지를 못 받았다 = 그 몰을 **못 봤다**. '없음'과 섞이면 안 된다.
raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source,
state=SourceState.UNAVAILABLE) from ex
# 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백 # 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백
self.last_bytes = self._net_bytes if self._cdp is not None else len(html.encode("utf-8")) self.last_bytes = self._net_bytes if self._cdp is not None else len(html.encode("utf-8"))
@ -436,7 +439,8 @@ class BrowserSearchAdapter(SearchAdapter):
f"[{self.source}] 구조적 차단 '{marker}' — IP 회전으로 회복 불가. " f"[{self.source}] 구조적 차단 '{marker}' — IP 회전으로 회복 불가. "
f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)") f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)")
raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요", raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요",
source=self.source, blocked=True, fatal=True) source=self.source, blocked=True, fatal=True,
state=SourceState.ENV_BLOCKED)
# 확신도에 따라 대응을 가른다. # 확신도에 따라 대응을 가른다.
# 알려진 마커 사이트가 대놓고 막았다 → 태울 근거가 있다 # 알려진 마커 사이트가 대놓고 막았다 → 태울 근거가 있다
# short_html 폴백 '0건인데 페이지가 짧다'는 정황일 뿐이다. 진짜 '검색결과 # short_html 폴백 '0건인데 페이지가 짧다'는 정황일 뿐이다. 진짜 '검색결과
@ -459,7 +463,8 @@ class BrowserSearchAdapter(SearchAdapter):
raise AdapterError( raise AdapterError(
f"{self.source} 환경 차단 (IP {len(self._fresh_ip_blocks)}개가 첫 요청부터 차단, " f"{self.source} 환경 차단 (IP {len(self._fresh_ip_blocks)}개가 첫 요청부터 차단, "
f"최근 마커={marker}) — IP 회전으로 회복 불가", f"최근 마커={marker}) — IP 회전으로 회복 불가",
source=self.source, blocked=True, fatal=True) source=self.source, blocked=True, fatal=True,
state=SourceState.ENV_BLOCKED)
if known and self.uses_proxy: if known and self.uses_proxy:
self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀) self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)
@ -471,7 +476,13 @@ class BrowserSearchAdapter(SearchAdapter):
if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기 if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기
self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block") self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block")
self._note_result(False) self._note_result(False)
raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", source=self.source, blocked=blocked) # 여기가 '없음'과 '못 봄'이 갈리는 유일한 지점이다.
# blocked=False → 페이지는 정상인데 상품이 0건 = 그 몰에 **정말 없다**(EMPTY)
# blocked=True → 차단 페이지를 받은 것 = **못 봤다**(BLOCKED)
# 이 구분을 여기서 안 실어 보내면 위쪽에서는 영영 알 수 없다.
raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})",
source=self.source, blocked=blocked,
state=SourceState.BLOCKED if blocked else SourceState.EMPTY)
async def _report_detection(self, query: str, marker: str, html_len: int): async def _report_detection(self, query: str, marker: str, html_len: int):
# 경과는 **IP 세션** 기준 — 'ip_req#N 을 몇 초 만에 쐈나'가 차단 진단의 축이다. # 경과는 **IP 세션** 기준 — 'ip_req#N 을 몇 초 만에 쐈나'가 차단 진단의 축이다.

View File

@ -12,6 +12,8 @@ from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from common.enums import SourceState
class NormalizedProduct(BaseModel): class NormalizedProduct(BaseModel):
"""소스 무관 정규화 상품 스키마. 어댑터의 유일한 출력 계약.""" """소스 무관 정규화 상품 스키마. 어댑터의 유일한 출력 계약."""
@ -55,13 +57,25 @@ class AdapterError(Exception):
fatal=True 는 **재시도해도 절대 안 되는 차단**이다 — IP 를 바꿔도 같은 결과가 나오는 fatal=True 는 **재시도해도 절대 안 되는 차단**이다 — IP 를 바꿔도 같은 결과가 나오는
구조적 원인(예: 네이버 msearch 에 해외 IP 로 접근 = 게이트웨이 설정이 틀림). 구조적 원인(예: 네이버 msearch 에 해외 IP 로 접근 = 게이트웨이 설정이 틀림).
호출부는 회전·재시도를 멈추고 설정을 고쳐야 한다. 호출부는 회전·재시도를 멈추고 설정을 고쳐야 한다.
⚠️ `state` 는 **'그 몰을 봤는가'** 를 담는다. blocked/fatal 은 '어떻게 대응할까'(회전·재시도)를
위한 값이고, state 는 '사용자에게 뭐라고 말할까'를 위한 값이라 쓰임이 다르다.
특히 blocked=False 하나에 두 가지가 섞여 있어 state 없이는 갈라낼 수 없다:
결과 0건(EMPTY) 그 몰을 봤고 정말 없었다 → "없음"이라 말해도 된다
전송 실패(UNAVAILABLE) 그 몰을 못 봤다 → "없음"이라 말하면 거짓
""" """
def __init__(self, message: str, *, source: str, blocked: bool = False, fatal: bool = False): def __init__(self, message: str, *, source: str, blocked: bool = False, fatal: bool = False,
state: SourceState | None = None):
super().__init__(message) super().__init__(message)
self.source = source self.source = source
self.blocked = blocked self.blocked = blocked
self.fatal = fatal self.fatal = fatal
# state 를 안 준 옛 호출부도 맞게 동작하도록 blocked/fatal 에서 유도한다.
# (모르면 UNAVAILABLE — '못 봤다' 쪽이 안전한 기본값이다. EMPTY 로 잘못 넘기면
# 확인도 안 한 몰을 '없음'으로 단정하게 된다)
self.state = state or (SourceState.ENV_BLOCKED if fatal else
SourceState.BLOCKED if blocked else SourceState.UNAVAILABLE)
class SearchAdapter(ABC): class SearchAdapter(ABC):

View File

@ -0,0 +1,107 @@
"""몰(소스)별 상태 테스트 — '그 몰에 없었다'와 '그 몰을 못 봤다'를 가르는 계약.
정의는 docs/result-states.md 가 소스다. 여기서 지키는 것은 하나다:
**확인한 것(matched/no_match/empty)과 못 본 것(blocked/env_blocked/unavailable)이 섞이지 않는다.**
섞이면 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'으로 둔갑한다(실제로 그랬다).
"""
import pytest
from common.enums import SourceState
from services.search.contract import AdapterError, NormalizedProduct, SearchAdapter
from worker.handlers import _finalize_states, build_search_handler
# ── 상태 자체의 계약 ────────────────────────────────────────────────────
def test_confirmed_separates_seen_from_unseen():
"""이 경계가 무너지면 나머지 로직이 전부 틀어진다."""
assert [s for s in SourceState if s.confirmed] == [
SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY]
for s in (SourceState.BLOCKED, SourceState.ENV_BLOCKED, SourceState.UNAVAILABLE, SourceState.SKIPPED):
assert not s.confirmed, s
# ── AdapterError 가 상태를 싣는가 ───────────────────────────────────────
@pytest.mark.parametrize("kw,expected", [
(dict(), SourceState.UNAVAILABLE), # 아무것도 모르면 '못 봤다'가 안전
(dict(blocked=True), SourceState.BLOCKED),
(dict(blocked=True, fatal=True), SourceState.ENV_BLOCKED),
(dict(state=SourceState.EMPTY), SourceState.EMPTY),
])
def test_error_state_defaults(kw, expected):
"""state 를 안 준 옛 호출부도 맞게 동작해야 한다 — 특히 기본값이 EMPTY 면 안 된다
(확인도 안 한 몰을 '없음'으로 단정하게 된다)."""
assert AdapterError("x", source="s", **kw).state is expected
# ── 수집 뒤 매칭 결과로 확정 ────────────────────────────────────────────
def test_collected_but_unmatched_becomes_no_match():
per_source = {"naver": {"state": "matched"}, "coupang": {"state": "matched"}}
matched = [NormalizedProduct(source="naver", name="x", price=1)]
_finalize_states(per_source, matched)
assert per_source["naver"]["state"] == "matched"
assert per_source["coupang"]["state"] == "no_match" # 가져왔지만 같은 상품이 아니었다
def test_finalize_never_overwrites_a_failure_state():
"""실패 상태는 이미 확정이다 — 매칭 결과로 덮으면 '못 봤다'가 사라진다."""
per_source = {"coupang": {"state": "blocked"}, "naver": {"state": "empty"}}
_finalize_states(per_source, [])
assert per_source["coupang"]["state"] == "blocked"
assert per_source["naver"]["state"] == "empty"
# ── 핸들러 통합: 상태가 결과까지 실려 나가는가 ──────────────────────────
class _A(SearchAdapter):
def __init__(self, source, mode): self.source, self.mode = source, mode
async def search(self, q, limit=40):
if self.mode == "hit":
return [NormalizedProduct(source=self.source, name="생수 2L", price=9000)]
if self.mode == "empty":
raise AdapterError("결과 없음", source=self.source, state=SourceState.EMPTY)
if self.mode == "blocked":
raise AdapterError("차단", source=self.source, blocked=True, state=SourceState.BLOCKED)
raise RuntimeError("우리 코드 버그")
def _job():
return {"job_type": 1, "job_id": "J", "attempts": 1, "max_attempts": 3,
"payload": {"product_code": "P", "product_name": "생수"}}
async def _run(modes):
return await build_search_handler({s: _A(s, m) for s, m in modes.items()})(_job())
async def test_empty_is_a_confirmed_answer_not_a_gap():
"""봤는데 없었으면 결과는 **확정**이다 — partial 로 흐리면 안 된다."""
r = await _run({"naver": "hit", "coupang": "empty"})
assert r["sources"]["coupang"]["state"] == "empty"
assert r["partial"] is False and r["sources_failed"] == []
async def test_blocked_marks_the_result_incomplete():
"""못 본 몰이 있으면 그 결과는 최종이 아니다."""
r = await _run({"naver": "hit", "coupang": "blocked"})
assert r["sources"]["coupang"]["state"] == "blocked"
assert r["partial"] is True and r["sources_failed"] == ["coupang"]
async def test_unknown_exception_counts_as_unseen():
"""우리 코드 버그로 못 본 것도 '없음'이라 말하면 안 된다."""
r = await _run({"naver": "hit", "coupang": "boom"})
assert r["sources"]["coupang"]["state"] == "unavailable"
assert r["partial"] is True
async def test_all_empty_is_a_definitive_not_found():
"""전부 확인했고 없었다 → 확정적 not_found(네거티브 캐시에 넣어도 되는 상태)."""
r = await _run({"naver": "empty", "coupang": "empty"})
assert r["outcome"] == "not_found" and r["partial"] is False
async def test_all_blocked_is_not_an_answer():
"""전부 못 봤으면 '없음'이 아니라 '모름'이다 — 재시도해야 한다."""
with pytest.raises(RuntimeError):
await _run({"naver": "blocked", "coupang": "blocked"})

View File

@ -16,7 +16,7 @@
import asyncio import asyncio
import time import time
from common.enums import JobType from common.enums import JobType, SourceState
from common.logger import LOG from common.logger import LOG
from services.metrics import SearchMetrics from services.metrics import SearchMetrics
from services.search.contract import SearchAdapter, NormalizedProduct from services.search.contract import SearchAdapter, NormalizedProduct
@ -40,6 +40,20 @@ def _reap_abandoned(task: asyncio.Task):
LOG.d(f"[fallback] 데드라인 초과 태스크 종료(무시): {type(ex).__name__}") LOG.d(f"[fallback] 데드라인 초과 태스크 종료(무시): {type(ex).__name__}")
def _finalize_states(per_source: dict, matched: list[NormalizedProduct]) -> dict:
"""수집 성공 소스의 상태를 **매칭 결과로 확정**한다(제자리 수정 후 반환).
수집 단계에선 '가져왔다'까지만 알 수 있다. '같은 상품이었나'는 AI 판정을 거쳐야 알므로
여기서 MATCHED / NO_MATCH 를 가른다. 실패 상태(BLOCKED·EMPTY 등)는 이미 확정이라 건드리지 않는다.
"""
hit = {p.source for p in matched}
collected = SourceState.MATCHED.name.lower()
for src, info in per_source.items():
if info.get("state") == collected and src not in hit:
info["state"] = SourceState.NO_MATCH.name.lower()
return per_source
def _price_snapshot(matched: list[NormalizedProduct]) -> dict: def _price_snapshot(matched: list[NormalizedProduct]) -> dict:
"""매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용).""" """매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용)."""
def lowest(src): def lowest(src):
@ -119,11 +133,17 @@ def build_search_handler(
products, per_source, ok_sources = [], {}, [] products, per_source, ok_sources = [], {}, []
for src, res in zip(use, results): for src, res in zip(use, results):
if isinstance(res, Exception): if isinstance(res, Exception):
per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ⚠️ 예외를 문자열로만 남기면 '그 몰에 없었다'와 '그 몰을 못 봤다'가 같아진다.
LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}") # AdapterError 는 이미 state 로 그걸 알고 있으므로 **구조화해서 보존**한다.
# (AdapterError 가 아닌 예외 = 우리 코드 버그 → 못 본 것으로 본다)
state = getattr(res, "state", None) or SourceState.UNAVAILABLE
per_source[src] = {"state": state.name.lower(), "error": f"{type(res).__name__}: {res}"}
LOG.w(f"[{src}] 검색 실패({state.name}): {type(res).__name__}: {res}")
if state.confirmed:
ok_sources.append(src) # EMPTY = 봤는데 없던 것 → '정상 응답'으로 센다
else: else:
products.extend(res) products.extend(res)
per_source[src] = {"count": len(res)} per_source[src] = {"state": SourceState.MATCHED.name.lower(), "count": len(res)}
ok_sources.append(src) ok_sources.append(src)
return products, per_source, ok_sources return products, per_source, ok_sources
@ -233,6 +253,7 @@ def build_search_handler(
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
_finalize_states(per_source, candidates) # 수집만 된 소스를 matched/no_match 로 확정
last_stages, last_sources = stages, per_source last_stages, last_sources = stages, per_source
last_ok, last_failed = ok_sources, failed_sources last_ok, last_failed = ok_sources, failed_sources