diff --git a/lps/crud/negative_cache.py b/lps/crud/negative_cache.py index f657045..ce323e9 100644 --- a/lps/crud/negative_cache.py +++ b/lps/crud/negative_cache.py @@ -26,6 +26,21 @@ class NegativeCache: finally: await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + async def drop(self, key: str): + """key 의 not_found 기록을 지운다 — 사용자가 강제 재검색(force)을 요청했을 때. + 캐시 키가 product_code 라 상품명·모델을 고쳐 다시 찾는 경우에도 이걸로 풀어줘야 한다.""" + if not key: + return + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) + try: + await s.execute(text("DELETE FROM search_negative WHERE key = :k"), {"k": key}) + await s.commit() + except Exception: + await s.rollback() + raise + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value) + async def put(self, key: str, ttl_sec: int = 86400, reason: str = "not_found"): """key 를 ttl_sec 동안 not_found 로 기록(upsert).""" if not key: diff --git a/lps/router/v1/lps/protocol.py b/lps/router/v1/lps/protocol.py index 4097f2d..8deae0c 100644 --- a/lps/router/v1/lps/protocol.py +++ b/lps/router/v1/lps/protocol.py @@ -17,6 +17,7 @@ class SearchItem(BaseModel): specification: str = Field("", description="규격(용량/개입/수량 등)") company: str = Field("", description="제조사/브랜드") price: str = Field("", description="현재가(참고, 문자열)") + force: bool = Field(False, description="네거티브 캐시(24h not_found)를 무시하고 실제로 재검색할지. 사용자가 '다시 검색'을 누른 경우만 true") class Req_Search(Req_WebPacketProtocol): diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 21be864..523fd27 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -192,8 +192,16 @@ def build_search_handler( cache_key = payload.get("product_code") or base_query metrics = SearchMetrics(ai_model, proxy_cost_per_gb) # 검색 1건의 리소스/비용/시간 계측 - # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략 - if neg_cache is not None and await neg_cache.is_negative(cache_key): + # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략. + # force=True(사용자가 '다시 검색'을 명시적으로 누름)면 기록을 지우고 실제 검색을 돌린다. + # 캐시 키가 product_code 라, 상품명·모델을 고쳐 재시도하는 경우 이 우회가 없으면 영원히 막힌다. + if neg_cache is not None and payload.get("force"): + await neg_cache.drop(cache_key) + if neg_cache is not None and not payload.get("force") and await neg_cache.is_negative(cache_key): + # 캐시 히트도 '이 잡의 결과'이므로 이력을 남긴다. 남기지 않으면 잡은 완료인데 + # price_history 에 새 행이 없어, 이를 폴링하는 소비자(negodata 최저가 모달)가 + # 결과를 영영 못 받고 로딩만 돈다(실측 버그). + await _record_history(cache_key, job.get("job_id"), "not_found", []) return {"outcome": "not_found", "cached": True, "query": base_query, "rounds_tried": 0, "lowest": None, "top": [], "stages": [], "sources": {}, "metrics": metrics.snapshot()}