[feat] solution/backend: 일정 생성을 잡에 붙인다 — 에디터 요청을 붙잡지 않는다

This commit is contained in:
김성경 2026-09-11 11:51:39 +09:00
parent 8dba691274
commit 1dd2dc773e
4 changed files with 160 additions and 12 deletions

View File

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

View File

@ -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) — 운영자 수기 항목·날씨 ────────────────

View File

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

View 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 == []