[feat] solution/backend: 일정 생성을 잡에 붙인다 — 에디터 요청을 붙잡지 않는다
This commit is contained in:
parent
8dba691274
commit
1dd2dc773e
@ -126,6 +126,18 @@ async def run_build(job: dict) -> dict:
|
||||
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
|
||||
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
|
||||
|
||||
# ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
|
||||
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
|
||||
# 이미 있는 기간은 부르지 않으므로 매 빌드가 유료 호출이 되지는 않는다.
|
||||
try:
|
||||
from services.itinerary_llm_service import ensure_generated
|
||||
|
||||
made = await ensure_generated(place)
|
||||
if made["counts"]:
|
||||
LOG.i(f"[build] place={place_id} 일정 생성 {made['counts']}")
|
||||
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
|
||||
LOG.w(f"[build] place={place_id} 일정 생성 실패(직전 값 사용): {type(ex).__name__}: {ex}")
|
||||
|
||||
snapshot = await build_snapshot(place)
|
||||
|
||||
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
|
||||
|
||||
@ -436,7 +436,7 @@ class LocalContentService:
|
||||
LOG.w(f"[local] place={place_id} 사이트 개인화 저장 실패: {err.name}")
|
||||
|
||||
async def _ensure_region_stories(self, place) -> None:
|
||||
"""지역 데이터가 비어 있으면 잡을 하나 넣는다 — **보험 경로**다.
|
||||
"""지역 데이터·일정이 비어 있으면 잡을 하나 넣는다 — **보험 경로**다.
|
||||
|
||||
★ 정규 경로는 위저드다: 수집이 끝날 때(collect_service)와 생성 단계(place_service)가
|
||||
같은 잡을 걸고, 사장님은 **에디터에 들어가기 전에** 다 채워진 화면을 본다.
|
||||
@ -447,10 +447,18 @@ class LocalContentService:
|
||||
if not perplexity.is_configured():
|
||||
return
|
||||
region_code = str(getattr(place, "region_code", None) or "").strip()
|
||||
# ★ "한 건이라도 있으면 건너뛴다" 가 아니라 **없는 종류가 있으면 돈다.**
|
||||
place_id = getattr(place, "place_id", None)
|
||||
|
||||
# ★ "한 건이라도 있으면 건너뛴다" 가 아니라 **없는 것이 있으면 돈다.**
|
||||
# 종류가 늘어난 날 기존 지역이 옛 목록에 멈추는 것을 막는다(story_service.missing_kinds).
|
||||
if region_code and not await story_service.missing_kinds(region_code):
|
||||
return
|
||||
# ★ 일정도 같은 잡이 채운다(2026-09-11). 이야기만 보면, 이야기가 이미 다 찬 업장은
|
||||
# 일정을 영영 못 받는다 — daily 가 안 들어가던 것과 똑같은 함정이다.
|
||||
stories_done = bool(region_code) and not await story_service.missing_kinds(region_code)
|
||||
if stories_done:
|
||||
from services.itinerary_llm_service import missing_durations
|
||||
|
||||
if place_id is None or not await missing_durations(place_id):
|
||||
return
|
||||
await story_service.enqueue_region_job(place)
|
||||
|
||||
# ── 지역 캐시(area_contents) — 운영자 수기 항목·날씨 ────────────────
|
||||
|
||||
@ -241,16 +241,17 @@ async def run_local_sync(job: dict) -> dict:
|
||||
|
||||
payload: {place_id?, region_code, region_label, kinds?}
|
||||
|
||||
★ 왜 둘을 한 잡에 묶나
|
||||
업장 반경(TourAPI 맛집·관광지·축제)과 지역 이야기(LLM)는 성격이 다르지만, 사장님에게는
|
||||
"주변 이야기가 채워졌나" 하나다. 잡을 둘로 나누면 위저드가 둘을 따로 기다려야 하고,
|
||||
하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다.
|
||||
★ 왜 셋을 한 잡에 묶나
|
||||
업장 반경(TourAPI 맛집·관광지·축제)·여행 일정(LLM)·지역 이야기(LLM)는 성격이 다르지만,
|
||||
사장님에게는 "주변 이야기가 채워졌나" 하나다. 잡을 나누면 위저드가 여럿을 따로 기다려야
|
||||
하고, 하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다.
|
||||
사진 분석(VISION)을 수집에서 떼어 낸 것과는 사정이 다르다 — 그건 각각 몇 분이라 실패
|
||||
비용이 컸지만, 이 둘은 합쳐 1분대이고 유료 재호출도 아래 가드가 막는다.
|
||||
비용이 컸지만, 이 셋은 합쳐 1~2분대이고 유료 재호출도 아래 가드가 막는다.
|
||||
|
||||
★ 업장 것과 지역 것의 반복 단위가 다르다
|
||||
반경 수집은 **업장마다** 해야 한다(좌표가 다르다). 이야기는 **지역에 한 번**이면 된다 —
|
||||
같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다. 그래서 이야기 쪽만 가드가 붙는다.
|
||||
반경 수집·여행 일정은 **업장마다** 해야 한다(좌표·업소 이름이 다르다). 이야기는
|
||||
**지역에 한 번**이면 된다 — 같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다.
|
||||
그래서 이야기 쪽만 가드가 붙는다.
|
||||
|
||||
★ 멱등하다. 이야기는 순번이 아니라 (region_code, kind) 한 행을 덮어쓰고, 반경 수집은
|
||||
external_id 로 upsert 한다 — lease 만료로 다시 돌아도 행이 늘지 않는다.
|
||||
@ -277,7 +278,19 @@ async def run_local_sync(job: dict) -> dict:
|
||||
if not synced.result.success:
|
||||
LOG.w(f"[story] place={place_id} 반경 수집 실패(이야기는 계속한다): {synced.msg}")
|
||||
|
||||
# ── 2. 지역 이야기(LLM) — 지역에 한 번 ────────────────────────────
|
||||
# ── 2. 여행 일정(LLM) — 업장마다 ──────────────────────────────────
|
||||
# ★ 이야기와 같은 잡에 둔다. 사장님에게는 "주변이 채워졌나" 하나이고, 둘 다 Perplexity 라
|
||||
# 같은 키로 나간다 — 잡을 나누면 두 잡이 동시에 떠서 서로를 429 로 막는다.
|
||||
# ★ 이야기는 지역에 한 번이면 되지만 일정은 **업장마다** 필요하다(업소 이름이 프롬프트에 든다)
|
||||
# — 반경 수집과 같은 반복 단위다.
|
||||
# ★ 이야기 블록 **앞**이다. 저쪽은 "이미 있다" 로 조기 반환하므로, 뒤에 두면 이야기가 다 찬
|
||||
# 업장이 일정을 영영 못 받는다.
|
||||
if place_id:
|
||||
from services.itinerary_llm_service import ensure_generated_by_id
|
||||
|
||||
out["itineraries"] = await ensure_generated_by_id(uuid.UUID(str(place_id)))
|
||||
|
||||
# ── 3. 지역 이야기(LLM) — 지역에 한 번 ────────────────────────────
|
||||
if not perplexity.is_configured():
|
||||
out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
||||
return out
|
||||
|
||||
115
solution/backend/tests/test_itinerary_trigger.py
Normal file
115
solution/backend/tests/test_itinerary_trigger.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""일정 생성은 **언제** 걸리나.
|
||||
|
||||
★ 에디터 요청 안에서 생성하지 않는다. 두 기간 합쳐 50~100초다 —
|
||||
지역 이야기가 잡으로 도는 이유와 같다(local_content_service._ensure_region_stories L444).
|
||||
캔버스는 잡을 넣고, 이번 응답에는 안 실린다. **다음에 열 때** 보인다.
|
||||
|
||||
★ 가드가 "이야기가 다 찼나" 만 보면 안 된다. 이미 이야기가 찬 업장은 일정을 영영 못 받는다 —
|
||||
story_service 가 has_stories 하나로 판단하다 daily 를 못 받던 것과 같은 함정이다.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from services import story_service
|
||||
from services.llm import perplexity
|
||||
from services.local_content_service import LocalContentService
|
||||
|
||||
|
||||
class _Place:
|
||||
"""`_ensure_region_stories` 와 `run_local_sync` 가 getattr 로만 읽는 최소 객체."""
|
||||
|
||||
def __init__(self):
|
||||
self.place_id = uuid.uuid4()
|
||||
self.region_code = "52군산시"
|
||||
self.name = "스테이,머뭄"
|
||||
self.road_address = "전북특별자치도 군산시 절골길 18"
|
||||
self.address = self.road_address
|
||||
|
||||
|
||||
class _SyncResult:
|
||||
"""`sync_place_by_id` 가 돌려주는 모양(ResSyncPlace)의 최소 대역."""
|
||||
|
||||
class result:
|
||||
success = True
|
||||
|
||||
msg = ""
|
||||
festivals = attractions = restaurants = 0
|
||||
|
||||
|
||||
async def test_local_sync_job_generates_itineraries(monkeypatch, db_engine):
|
||||
"""검증: LOCAL_SYNC 잡이 일정 생성을 부른다.
|
||||
기대결과: place_id 로 ensure_generated_by_id 를 부르고 결과를 잡 결과에 싣는다.
|
||||
|
||||
★ 이야기 블록은 키가 없으면(APP_ENV=test 는 .env 를 안 읽는다) 조기 반환한다 —
|
||||
그래서 일정 블록이 **이야기보다 앞**에 있어야 이 테스트가 통과한다. 그게 의도다."""
|
||||
called: list = []
|
||||
|
||||
async def fake_ensure(place_id):
|
||||
called.append(place_id)
|
||||
return {"place_id": str(place_id), "counts": {"1박 2일": 5, "2박 3일": 5}, "notes": []}
|
||||
|
||||
async def fake_sync(self, place_id):
|
||||
return _SyncResult()
|
||||
|
||||
monkeypatch.setattr("services.itinerary_llm_service.ensure_generated_by_id", fake_ensure)
|
||||
monkeypatch.setattr(LocalContentService, "sync_place_by_id", fake_sync)
|
||||
|
||||
pid = uuid.uuid4()
|
||||
out = await story_service.run_local_sync({
|
||||
"payload": {"place_id": str(pid), "region_code": "52군산시",
|
||||
"region_label": "전북특별자치도 군산시"},
|
||||
})
|
||||
|
||||
assert called == [pid]
|
||||
assert out["itineraries"]["counts"] == {"1박 2일": 5, "2박 3일": 5}
|
||||
|
||||
|
||||
async def test_canvas_enqueues_job_when_only_itineraries_are_missing(monkeypatch, db_engine):
|
||||
"""검증: 이야기는 다 찼지만 일정이 없는 업장이 캔버스를 연다.
|
||||
기대결과: 잡을 넣는다 — 가드가 일정 누락도 보기 때문이다.
|
||||
★ 이 테스트가 없으면 "이야기가 찬 업장은 일정을 영영 못 받는" 함정이 조용히 살아난다."""
|
||||
enqueued: list = []
|
||||
|
||||
async def fake_enqueue(place):
|
||||
enqueued.append(place.place_id)
|
||||
return "job-1"
|
||||
|
||||
async def no_missing_kinds(region_code):
|
||||
return []
|
||||
|
||||
async def two_missing_durations(place_id):
|
||||
return ["1박 2일", "2박 3일"]
|
||||
|
||||
monkeypatch.setattr(perplexity, "is_configured", lambda: True)
|
||||
monkeypatch.setattr(story_service, "missing_kinds", no_missing_kinds)
|
||||
monkeypatch.setattr(story_service, "enqueue_region_job", fake_enqueue)
|
||||
monkeypatch.setattr("services.itinerary_llm_service.missing_durations", two_missing_durations)
|
||||
|
||||
place = _Place()
|
||||
await LocalContentService()._ensure_region_stories(place)
|
||||
|
||||
assert enqueued == [place.place_id]
|
||||
|
||||
|
||||
async def test_canvas_does_not_enqueue_when_everything_is_filled(monkeypatch, db_engine):
|
||||
"""검증: 이야기도 일정도 다 있는 업장.
|
||||
기대결과: 잡을 넣지 않는다 — 유료 호출을 다시 걸지 않는다(요금 가드)."""
|
||||
enqueued: list = []
|
||||
|
||||
async def fake_enqueue(place):
|
||||
enqueued.append(place.place_id)
|
||||
return "job-1"
|
||||
|
||||
async def no_missing_kinds(region_code):
|
||||
return []
|
||||
|
||||
async def no_missing_durations(place_id):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(perplexity, "is_configured", lambda: True)
|
||||
monkeypatch.setattr(story_service, "missing_kinds", no_missing_kinds)
|
||||
monkeypatch.setattr(story_service, "enqueue_region_job", fake_enqueue)
|
||||
monkeypatch.setattr("services.itinerary_llm_service.missing_durations", no_missing_durations)
|
||||
|
||||
await LocalContentService()._ensure_region_stories(_Place())
|
||||
|
||||
assert enqueued == []
|
||||
Loading…
Reference in New Issue
Block a user