[fix] lps: 네거티브 캐시 히트 시 이력 누락 수정 + force 재검색 지원

1) 캐시 히트가 price_history 를 남기지 않던 문제
   not_found 는 24시간 네거티브 캐시에 들어가는데, 캐시에 걸린 조기 반환 경로만
   _record_history 를 호출하지 않았다(다른 모든 경로는 호출).
   그 결과 잡은 완료인데 price_history 에 새 행이 없어, 이를 폴링하는 소비자
   (negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다.
   → 캐시 히트도 '이 잡의 결과'이므로 이력을 남긴다.

2) force 플래그
   negodata 는 이제 수동 트리거 전용인데 is_negative() 가 job_type 을 보지 않아
   사람이 직접 누른 재검색까지 캐시가 가로막았다. 게다가 캐시 키가 product_code 라
   상품명·모델을 고쳐 재시도해도 동일하게 막힌다.
   → SearchItem.force=true 면 NegativeCache.drop() 으로 기록을 지우고 실제 검색.
   기본 요청은 캐시를 그대로 써서 비용 절감 효과는 유지.

실측: 캐시에 막혀 not_found 만 반복하던 상품이 force 재검색에서 2라운드 만에 found(8,500원).
⚠️ protocol.py 변경은 lps-api 와 lps-worker 를 함께 재빌드해야 반영된다
   (API 만 옛 스키마면 pydantic 이 force 를 조용히 버린다 — 실측으로 확인).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-28 11:55:23 +09:00
parent ca1291708f
commit ba9d34fd31
3 changed files with 26 additions and 2 deletions

View File

@ -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:

View File

@ -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):

View File

@ -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()}