From 1dd2dc773ef28b7f0f6cdba6b5d8bb5fbe528a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 11 Sep 2026 11:51:39 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EC=9D=84=20=EC=9E=A1=EC=97=90=20=EB=B6=99?= =?UTF-8?q?=EC=9D=B8=EB=8B=A4=20=E2=80=94=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=B6=99=EC=9E=A1=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- solution/backend/services/build_service.py | 12 ++ .../backend/services/local_content_service.py | 16 ++- solution/backend/services/story_service.py | 29 +++-- .../backend/tests/test_itinerary_trigger.py | 115 ++++++++++++++++++ 4 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 solution/backend/tests/test_itinerary_trigger.py diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 3ec9952..3fa4611 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -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( diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index 1cff4c1..473ee1e 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -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) — 운영자 수기 항목·날씨 ──────────────── diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index a06b5dc..ec4e48a 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -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 diff --git a/solution/backend/tests/test_itinerary_trigger.py b/solution/backend/tests/test_itinerary_trigger.py new file mode 100644 index 0000000..60c5613 --- /dev/null +++ b/solution/backend/tests/test_itinerary_trigger.py @@ -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 == []