feat(lps): 2단계 — price_history 에 몰별 확인 상태 저장

1단계에서 만든 SourceState 가 job.result 에만 있어 화면까지 못 갔다. by_mall 은 **가격이 있는
몰만** 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지 구분할 자리가 없었다.

- price_history.sources (JSONB): 몰별 상태를 그대로 담는다.
  {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
  열린 스키마라 몰이 늘거나 근거를 덧붙여도 마이그레이션이 필요 없다(by_mall 과 같은 방침).
- price_history.partial (bool): 결과가 완전한가. sources 에서 유도 가능하지만 컬럼으로 둔다 —
  소비자가 '어떤 상태가 확인된 것인가'라는 판단 규칙까지 알아야 하면 **상태 정의가 두 곳으로
  흩어진다**. 판단은 LPS 가 끝내고 소비자(negodata·lps-admin)는 사실 하나만 읽는다.
- 부분 인덱스 ix_price_history_partial — partial=true 행만 담아 작게 유지(운영 점검·알림용).
- _record_history 가 per_source 를 받아 partial 을 계산해 기록한다. 네거티브 캐시 히트 경로는
  sources 없이 남긴다(부분 결과는 애초에 캐시하지 않으므로 항상 확정).
- 마이그레이션: postgres-init/dbeaver/7_lps_source_state_dbeaver.sql (재실행 안전, **운영 적용 필요**)

검증(로컬 실 DB): 쿠팡 차단 vs 쿠팡 0건은 by_mall 이 둘 다 ['naver'] 로 같지만
partial(true/false)·sources.coupang.state(blocked/empty)가 두 경우를 갈라낸다.
JSONB 는 ensure_ascii=False 로 한글 사유가 깨지지 않는 것도 테스트로 고정.

테스트 3건 추가, 전체 289 passed. 진행 상황은 docs/result-states.md 4절.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-08-07 10:57:19 +09:00
parent b278f58d9c
commit 74db218410
7 changed files with 120 additions and 13 deletions

View File

@ -105,6 +105,16 @@ class price_history(MAIN_BASE):
# 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...]. # 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...].
# 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지. # 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지.
by_mall = Column(JSONB, nullable=True) by_mall = Column(JSONB, nullable=True)
# ── 몰별 '확인했는가' ─────────────────────────────────────────────────────
# by_mall 은 **가격이 있는 몰만** 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지
# '거기를 못 봤다'인지 알 수 없었다 — 안 본 걸 없다고 말하는 셈이었다.
# {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
# state 값은 common.enums.SourceState (정의·표기 규칙은 docs/result-states.md).
sources = Column(JSONB, nullable=True)
# 결과가 완전한가. True = 못 본 몰이 있어 이 값이 최종이 아니다.
# sources 에서 유도 가능하지만 컬럼으로 둔다 — 소비자가 '어떤 상태가 확인된 것인가'라는
# 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 여기서 끝내고 사실만 넘긴다.
partial = Column(Boolean, nullable=False, server_default=text("false"))
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))

View File

@ -13,6 +13,7 @@ _FIELDS = (
"coupang_lowest", "coupang_name", "coupang_url", "coupang_lowest", "coupang_name", "coupang_url",
"final_lowest", "final_source", "final_rating", "final_review_count", "final_lowest", "final_source", "final_rating", "final_review_count",
"final_shipping_fee", "final_shipping_type", "final_shipping_label", "final_shipping_fee", "final_shipping_type", "final_shipping_label",
"partial",
) )
@ -27,17 +28,20 @@ class PriceHistory:
naver_lowest, naver_name, naver_url, naver_lowest, naver_name, naver_url,
coupang_lowest, coupang_name, coupang_url, coupang_lowest, coupang_name, coupang_url,
final_lowest, final_source, final_rating, final_review_count, final_lowest, final_source, final_rating, final_review_count,
final_shipping_fee, final_shipping_type, final_shipping_label, by_mall) final_shipping_fee, final_shipping_type, final_shipping_label,
by_mall, sources, partial)
VALUES VALUES
(:product_code, :job_id, :outcome, :matched_count, (:product_code, :job_id, :outcome, :matched_count,
:naver_lowest, :naver_name, :naver_url, :naver_lowest, :naver_name, :naver_url,
:coupang_lowest, :coupang_name, :coupang_url, :coupang_lowest, :coupang_name, :coupang_url,
:final_lowest, :final_source, :final_rating, :final_review_count, :final_lowest, :final_source, :final_rating, :final_review_count,
:final_shipping_fee, :final_shipping_type, :final_shipping_label, CAST(:by_mall AS jsonb)) :final_shipping_fee, :final_shipping_type, :final_shipping_label,
CAST(:by_mall AS jsonb), CAST(:sources AS jsonb), COALESCE(:partial, FALSE))
""") """)
params = {k: event.get(k) for k in _FIELDS} params = {k: event.get(k) for k in _FIELDS}
by_mall = event.get("by_mall") for col in ("by_mall", "sources"): # JSONB 는 문자열로 넘겨 CAST 한다
params["by_mall"] = json.dumps(by_mall) if by_mall is not None else None v = event.get(col)
params[col] = json.dumps(v, ensure_ascii=False) if v is not None else None
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
try: try:
await s.execute(sql, params) await s.execute(sql, params)

View File

@ -62,6 +62,8 @@
| `final_lowest` | 전체 최저가 (**그래프 Y축 핵심**) | | `final_lowest` | 전체 최저가 (**그래프 Y축 핵심**) |
| `final_source` | 최종 최저가가 나온 소스(naver/coupang/gmarket/auction/st11) | | `final_source` | 최종 최저가가 나온 소스(naver/coupang/gmarket/auction/st11) |
| `by_mall` | 몰별 최저가 스냅샷(JSONB, 열린 스키마) — `[{mall, source, price, shipping_fee, shipping_type, url}, …]`. G마켓·옥션·11번가 등이 늘어도 컬럼 추가 없이 담는다 | | `by_mall` | 몰별 최저가 스냅샷(JSONB, 열린 스키마) — `[{mall, source, price, shipping_fee, shipping_type, url}, …]`. G마켓·옥션·11번가 등이 늘어도 컬럼 추가 없이 담는다 |
| `sources` | **몰별 확인 상태**(JSONB) — `{"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}`. `by_mall` 은 가격이 있는 몰만 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는 이 값에만 있다. state 값은 `SourceState`([정의](result-states.md)) |
| `partial` | 결과가 **완전한가**. `true`=못 본 몰이 있어 최종이 아니다. `sources` 에서 유도 가능하지만 컬럼으로 두어, 소비자가 상태 분류 규칙을 몰라도 되게 한다 |
| `job_id` / `created_at` | 검색 잡 연결 / 생성 시각 | | `job_id` / `created_at` | 검색 잡 연결 / 생성 시각 |
> 한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 `null`(그래프 선이 빈다 — 정상). > 한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 `null`(그래프 선이 빈다 — 정상).

View File

@ -174,14 +174,24 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata
검증(9조합 실측): `empty``partial=False`(확정) / `blocked`·`env_blocked`·`unavailable` 검증(9조합 실측): `empty``partial=False`(확정) / `blocked`·`env_blocked`·`unavailable`
`partial=True`(미확정). 테스트 12건 추가. `partial=True`(미확정). 테스트 12건 추가.
**2단계 — 저장할 자리 ✅ 완료** (`feat/source-state`)
- `price_history.sources`(JSONB) — 몰별 상태를 그대로 담는다. 열린 스키마라 몰이 늘거나
상태에 근거를 덧붙여도 마이그레이션이 필요 없다.
- `price_history.partial`(bool) — 결과가 완전한가. `sources` 에서 유도할 수 있지만 굳이 컬럼으로
둔다: 소비자가 '어떤 상태가 확인된 것인가'라는 **판단 규칙까지 알아야 하면 상태 정의가 두 곳으로
흩어진다**. 판단은 LPS 가 끝내고 소비자는 사실 하나만 읽는다.
- 부분 인덱스 `ix_price_history_partial` — '확인 못한 결과'만 뽑는 운영 점검용(작게 유지된다).
- 마이그레이션: `postgres-init/dbeaver/7_lps_source_state_dbeaver.sql` (**운영 적용 필요**)
검증(실 DB): 쿠팡 차단과 쿠팡 0건은 `by_mall` 이 둘 다 `['naver']` 로 같지만
`partial`(true/false)과 `sources.coupang.state`(blocked/empty)가 두 경우를 갈라낸다. 테스트 3건 추가.
**남은 것** **남은 것**
2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다
(지금은 `job.result` 에만 있다)
3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만) 3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만)
4. negodata 가 ``(없음)와 `확인 못함`(미확인)을 구분하지 못한다 4. negodata 가 ``(없음)와 `확인 못함`(미확인)을 구분하지 못한다
**2 → (3, 4)** 순서다. 2가 없으면 3·4가 읽을 게 없다. 이제 읽을 데이터가 생겼으므로 3·4 는 **각자 다르게 접기만** 하면 된다 — 순서 없이 병행 가능하다.
3과 4는 같은 데이터를 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다.
> 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다. > 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.

View File

@ -113,3 +113,42 @@ async def test_snapshot_carries_trust_of_the_lowest_offer():
e = rec.events[0] e = rec.events[0]
assert e["final_lowest"] == 900 and e["final_source"] == "naver" assert e["final_lowest"] == 900 and e["final_source"] == "naver"
assert e["final_rating"] is None and e["final_review_count"] is None # 미검증 오퍼임이 드러난다 assert e["final_rating"] is None and e["final_review_count"] is None # 미검증 오퍼임이 드러난다
# ── 몰별 확인 상태 (2026-08-07, 2단계) ──────────────────────────────────
# by_mall 은 '가격이 있는 몰'만 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지
# '거기를 못 봤다'인지 구분되지 않았다 — sources/partial 이 그 자리를 메운다.
async def test_records_source_states_and_partial(ph, db_engine):
await ph.record({
"product_code": "SRC1", "outcome": "found", "final_lowest": 9000, "partial": True,
"sources": {"naver": {"state": "matched", "count": 40},
"coupang": {"state": "blocked", "error": "AdapterError: 차단"}},
})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT partial, sources FROM price_history WHERE product_code='SRC1'"))).first()
assert row.partial is True
assert row.sources["coupang"]["state"] == "blocked"
assert row.sources["naver"]["count"] == 40
async def test_partial_defaults_to_false_when_absent(ph, db_engine):
"""옛 호출부(값을 안 주는 경로)도 깨지지 않아야 한다 — 기본은 '완전한 결과'."""
await ph.record({"product_code": "SRC2", "outcome": "not_found"})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT partial, sources FROM price_history WHERE product_code='SRC2'"))).first()
assert row.partial is False and row.sources is None
async def test_source_state_survives_korean_text(ph, db_engine):
"""error 메시지에 한글이 섞여도 JSONB 가 깨지지 않아야 한다(ensure_ascii=False)."""
await ph.record({
"product_code": "SRC3", "outcome": "found", "partial": True,
"sources": {"coupang": {"state": "env_blocked", "error": "사용권한이 제한된 페이지"}},
})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT sources FROM price_history WHERE product_code='SRC3'"))).first()
assert "사용권한이 제한된" in row.sources["coupang"]["error"]

View File

@ -101,10 +101,20 @@ def build_search_handler(
use = list(sources) if sources else list(adapters.keys()) use = list(sources) if sources else list(adapters.keys())
fallbacks = fallback_adapters or {} fallbacks = fallback_adapters or {}
async def _record_history(product_code: str, job_id, outcome: str, matched: list): async def _record_history(product_code: str, job_id, outcome: str, matched: list,
sources: dict | None = None):
"""price_history 1행 기록.
sources 함께 남기는 중요하다 by_mall **가격이 있는 몰만** 담으므로, 어떤 몰이
빠졌을 '거기엔 없더라'인지 '거기를 못 봤다'인지 없이는 없다.
partial 여기서 판단해 사실로 넘긴다(소비자가 상태 분류 규칙을 필요 없게).
"""
if history is None: if history is None:
return return
event = {"product_code": product_code, "job_id": job_id, "outcome": outcome, **_price_snapshot(matched)} confirmed = {s.name.lower() for s in SourceState if s.confirmed}
partial = any((i or {}).get("state") not in confirmed for i in (sources or {}).values())
event = {"product_code": product_code, "job_id": job_id, "outcome": outcome,
"sources": sources or None, "partial": partial, **_price_snapshot(matched)}
try: try:
await history.record(event) await history.record(event)
except Exception as ex: except Exception as ex:
@ -268,7 +278,7 @@ def build_search_handler(
partial=bool(failed_sources), metrics=metrics.snapshot()) partial=bool(failed_sources), metrics=metrics.snapshot())
if failed_sources: if failed_sources:
LOG.w(f"[partial] {failed_sources} 없이 결과를 냈습니다 — 그 몰의 더 싼 값은 못 봤을 수 있습니다") LOG.w(f"[partial] {failed_sources} 없이 결과를 냈습니다 — 그 몰의 더 싼 값은 못 봤을 수 있습니다")
await _record_history(cache_key, job.get("job_id"), "found", candidates) await _record_history(cache_key, job.get("job_id"), "found", candidates, per_source)
return result return result
# 0매칭 + 일부 소스 실패. **살아있는 소스가 하나라도 있으면 그 결과로 진행한다** — # 0매칭 + 일부 소스 실패. **살아있는 소스가 하나라도 있으면 그 결과로 진행한다** —
@ -287,7 +297,7 @@ def build_search_handler(
result.update(outcome="error", query=query, rounds_tried=rounds_done, result.update(outcome="error", query=query, rounds_tried=rounds_done,
sources=per_source, sources_ok=[], sources_failed=failed_sources, sources=per_source, sources_ok=[], sources_failed=failed_sources,
partial=True, metrics=metrics.snapshot()) partial=True, metrics=metrics.snapshot())
await _record_history(cache_key, job.get("job_id"), "error", []) await _record_history(cache_key, job.get("job_id"), "error", [], per_source)
return result return result
raise RuntimeError(f"모든 소스 실패로 0매칭(round={label}) — 잡 재시도: {per_source}") raise RuntimeError(f"모든 소스 실패로 0매칭(round={label}) — 잡 재시도: {per_source}")
@ -303,7 +313,7 @@ def build_search_handler(
partial=partial, metrics=metrics.snapshot()) partial=partial, metrics=metrics.snapshot())
if partial: if partial:
LOG.w(f"[partial] {last_failed} 없이 not_found — 네거티브 캐시는 건너뜁니다(그 몰엔 있었을 수 있음)") LOG.w(f"[partial] {last_failed} 없이 not_found — 네거티브 캐시는 건너뜁니다(그 몰엔 있었을 수 있음)")
await _record_history(cache_key, job.get("job_id"), "not_found", []) await _record_history(cache_key, job.get("job_id"), "not_found", [], last_sources)
return result return result
return handler return handler

View File

@ -0,0 +1,32 @@
-- LPS 몰별 확인 상태 — **lps_db 에 연결해서 실행**. 재실행 안전(IF NOT EXISTS).
--
-- 왜 필요한가: '그 몰에 더 싼 게 없었다'와 '그 몰이 막혀서 못 봤다'가 지금 화면에서 똑같이
-- '' 로 보인다. 사용자는 앞쪽으로 읽지만 실제로는 뒤쪽일 수 있다 — 안 본 걸 없다고 말하는 셈이다.
-- 크롤러는 그 차이를 이미 알고 있는데(SourceState), 담을 자리가 없어 화면까지 못 갔다.
--
-- 상태 정의와 표기 규칙은 lps/docs/result-states.md 가 소스다.
-- 몰별 확인 상태 — {"naver": {"state": "matched", "count": 40},
-- "coupang": {"state": "blocked", "error": "AdapterError: ..."}}
-- 열린 스키마(JSONB)로 둔다: 몰이 늘거나 상태에 근거를 덧붙여도 마이그레이션이 필요 없다.
-- state 값: matched / no_match / empty / blocked / env_blocked / unavailable / skipped
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS sources JSONB;
-- 결과가 **완전한가**. true = 못 본 몰이 있어 이 값이 최종이 아니다.
-- sources 에서 유도할 수 있지만 굳이 컬럼으로 둔다 — 소비자(negodata)가 '어떤 상태가 확인된
-- 것인가'라는 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 LPS 가 하고,
-- 소비자는 사실 하나만 읽게 한다.
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS partial BOOLEAN NOT NULL DEFAULT FALSE;
-- '확인 못한 결과'만 빠르게 뽑기 위한 부분 인덱스(운영 점검·알림용).
-- 전체가 아니라 partial=true 행만 담아 인덱스가 작게 유지된다.
CREATE INDEX IF NOT EXISTS ix_price_history_partial
ON price_history (triggered_at) WHERE partial;
-- ── 검증 ──────────────────────────────────────────────────────────────────────
SELECT column_name, data_type,
CASE WHEN column_name IN ('sources','partial') THEN '이번 추가' ELSE '' END AS note
FROM information_schema.columns
WHERE table_name = 'price_history'
AND column_name IN ('outcome','by_mall','sources','partial')
ORDER BY column_name;