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:
parent
1e966f7121
commit
b278f58d9c
@ -69,3 +69,26 @@ class JobType(Enum):
|
||||
|
||||
SEARCH = 1 # 최저가 검색(쿠팡=브라우저) — 무거움
|
||||
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)
|
||||
|
||||
@ -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` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다
|
||||
(지금은 `job.result` 에만 있다)
|
||||
3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만)
|
||||
4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다
|
||||
|
||||
**1 → 2 → (3, 4)** 순서다. 1을 안 고치면 2가 담을 내용이 없고, 2가 없으면 3·4가 읽을 게 없다.
|
||||
3과 4는 같은 데이터에서 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다.
|
||||
**2 → (3, 4)** 순서다. 2가 없으면 3·4가 읽을 게 없다.
|
||||
3과 4는 같은 데이터를 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다.
|
||||
|
||||
> 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.
|
||||
|
||||
@ -20,6 +20,7 @@ from abc import abstractmethod
|
||||
from patchright.async_api import async_playwright
|
||||
|
||||
from common.logger import LOG
|
||||
from common.enums import SourceState
|
||||
from config.server_configs import decodo_config, worker_config
|
||||
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
|
||||
from services.search.rate_limiter import RateLimiter
|
||||
@ -253,7 +254,7 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
self._note_result(False)
|
||||
raise AdapterError(
|
||||
f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도",
|
||||
source=self.source)
|
||||
source=self.source, state=SourceState.UNAVAILABLE)
|
||||
kwargs["proxy"] = self._proxy.playwright_proxy()
|
||||
await self._begin_ip_session(self._proxy.current_port)
|
||||
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")
|
||||
continue
|
||||
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 크기 폴백
|
||||
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"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)")
|
||||
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건인데 페이지가 짧다'는 정황일 뿐이다. 진짜 '검색결과
|
||||
@ -459,7 +463,8 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
raise AdapterError(
|
||||
f"{self.source} 환경 차단 (IP {len(self._fresh_ip_blocks)}개가 첫 요청부터 차단, "
|
||||
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:
|
||||
self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)
|
||||
|
||||
@ -471,7 +476,13 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기
|
||||
self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block")
|
||||
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):
|
||||
# 경과는 **IP 세션** 기준 — 'ip_req#N 을 몇 초 만에 쐈나'가 차단 진단의 축이다.
|
||||
|
||||
@ -12,6 +12,8 @@ from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common.enums import SourceState
|
||||
|
||||
|
||||
class NormalizedProduct(BaseModel):
|
||||
"""소스 무관 정규화 상품 스키마. 어댑터의 유일한 출력 계약."""
|
||||
@ -55,13 +57,25 @@ class AdapterError(Exception):
|
||||
fatal=True 는 **재시도해도 절대 안 되는 차단**이다 — 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)
|
||||
self.source = source
|
||||
self.blocked = blocked
|
||||
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):
|
||||
|
||||
107
lps/tests/test_source_state.py
Normal file
107
lps/tests/test_source_state.py
Normal 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"})
|
||||
@ -16,7 +16,7 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from common.enums import JobType
|
||||
from common.enums import JobType, SourceState
|
||||
from common.logger import LOG
|
||||
from services.metrics import SearchMetrics
|
||||
from services.search.contract import SearchAdapter, NormalizedProduct
|
||||
@ -40,6 +40,20 @@ def _reap_abandoned(task: asyncio.Task):
|
||||
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:
|
||||
"""매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용)."""
|
||||
def lowest(src):
|
||||
@ -119,11 +133,17 @@ def build_search_handler(
|
||||
products, per_source, ok_sources = [], {}, []
|
||||
for src, res in zip(use, results):
|
||||
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:
|
||||
products.extend(res)
|
||||
per_source[src] = {"count": len(res)}
|
||||
per_source[src] = {"state": SourceState.MATCHED.name.lower(), "count": len(res)}
|
||||
ok_sources.append(src)
|
||||
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]
|
||||
stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)})
|
||||
candidates = matched
|
||||
_finalize_states(per_source, candidates) # 수집만 된 소스를 matched/no_match 로 확정
|
||||
last_stages, last_sources = stages, per_source
|
||||
last_ok, last_failed = ok_sources, failed_sources
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user