"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다. ★ 왜 저장하나 예전 일정(services/itinerary.py)은 저장하지 않고 빌드마다 즉석 계산했다 — 거리 계산은 공짜니까 그게 맞았다. LLM 은 건당 20~50초·유료다. 매 빌드 재생성은 성립하지 않는다. ★ 왜 에디터 요청 안에서 부르지 않나 두 기간 합쳐 50~100초다. 지역 이야기가 잡으로 도는 이유와 같다 — "에디터가 화면을 그리려고 부른 요청 안에서 1분을 붙잡으면 화면이 멈춘 것으로 보인다" (`local_content_service._ensure_region_stories`). 이 모듈은 **잡과 빌드에서만** 불린다. ★ 기간 둘을 순차로 부른다 동시에 띄우면 같은 키로 나가는 호출이라 429 로 떨어진다 — 한 업장이 자기 자신을 막는다 (story_service 가 다섯 종을 순차로 부르는 것과 같은 실측 근거). ★ 실패는 예외로 올리지 않는다 일정은 업장의 사실이 아니라 곁들이는 정보다. 빌드도 에디터도 이것 때문에 멈추지 않는다. """ import uuid import httpx from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import place_itineraries, places from common.enums import DBWRType, ErrorType, SourceType from common.logger import LOG from common.utils.gtime import GTime from crud.place_itinerary_crud import PlaceItineraryCRUD from services.grounding import itinerary as grounding from services.llm import perplexity from services.prompts import itinerary as prompts # ★ 이야기(240초)와 같은 값이다. 2박 3일이 48초까지 갔고 변동이 크다(실측 2026-09-11). _TIMEOUT = httpx.Timeout(240.0, connect=10.0) # ★ 10개(컨셉당 2개) — 프롬프트가 요구하는 개수와 같다(`prompts.itinerary._TASK`). 프롬프트만으로는 # 보장이 안 돼(모델이 5개로 회귀할 때가 있다, 위 파일 주석 참고) 여기서 재시도로 채운다. TARGET_COURSES = 10 # ★ 사장님 지시(2026-09-14): 2회로 제한한다. 늘릴수록 10개를 채울 확률은 오르지만 건당 # 20~50초가 배로 늘어난다 — 못 채우면 채운 만큼만 저장하고 note 로 남긴다(아래 _generate_one). MAX_ATTEMPTS = 2 _CRUD = PlaceItineraryCRUD() def region_label_of(place) -> str: """프롬프트에 넣을 지명("전북특별자치도 군산시"). ★ 주소 앞 두 토막이 사람이 부르는 이름이다. 주소가 없으면 빈 문자열이고, 그때는 **부르지 않는다** — 지역을 모른 채 물으면 모델이 아무 도시나 고른다 (`story_service.region_label_of` 와 같은 판단). """ address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip() if not address: return "" tokens = address.split() return " ".join(tokens[:2]) if len(tokens) >= 2 else tokens[0] async def _stored(place_id) -> dict[str, list]: """기간 → 코스 목록. 읽지 못하면 빈 dict(모르는 상태로 유료 호출을 걸지 않는다).""" err, rows = await DB_SESSION_MNG.execute_lambda( place_itineraries.DBType(), DBWRType.DB_READ.value, lambda s: _CRUD.list_by_place(s, place_id), ) if err != ErrorType.SUCCESS: LOG.w(f"[itinerary] place={place_id} 조회 실패: {err.name}") return {} out: dict[str, list] = {} for row in rows or []: body = row.body if isinstance(row.body, list) else [] out[str(row.duration)] = [c for c in body if isinstance(c, dict)] return out async def missing_durations(place_id) -> list[str]: """아직 없는 기간. 잡 가드(`_ensure_region_stories`)와 생성이 같은 기준을 본다. ★ "한 행이라도 있으면 건너뛴다" 로 쓰지 않는다. 기간이 늘어난 날 기존 업장이 옛 목록에 멈춘다 — story_service 가 `has_stories` 하나로 판단하다 `daily` 를 영영 못 받던 것과 같은 함정이다(`story_service.missing_kinds` 주석). """ have = await _stored(place_id) return [d for d in prompts.DURATIONS if not have.get(d)] async def get_itineraries(place_id) -> list[dict]: """저장된 코스 전부. **DURATIONS 순**으로 이어 붙인다 — 화면 탭 순서(`ItinerarySection` 은 적힌 순서를 탭 순서로 쓴다)가 저장 순서에 흔들리면 안 된다.""" have = await _stored(place_id) out: list[dict] = [] for duration in prompts.DURATIONS: out += have.get(duration) or [] return out def _as_float(value) -> float | None: try: return float(value) if value is not None else None except (TypeError, ValueError): return None async def _generate_one( client: httpx.AsyncClient, place_name: str, region: str, duration: str, place_lat: float | None = None, place_lng: float | None = None, ): """기간 하나. 실패는 예외로 올리지 않고 (코스 목록, 이유) 로 돌려준다. ★ TARGET_COURSES 개를 채울 때까지 같은 프롬프트로 최대 MAX_ATTEMPTS 번 다시 부른다 — 한 번의 호출로 10개가 안정적으로 안 나온다(`prompts.itinerary` 실측 주석). 이전 시도에서 이미 채택한 코스는 `already_seen` 으로 다음 시도에 넘겨, 재시도가 같은 코스를 또 채택해 개수만 부풀리지 않게 한다(`grounding.stop_signature`). ★ place_lat·place_lng 는 업소를 정거장(출발·복귀)으로 넣을 때 쓴다(`grounding.parse_courses`) — 모델에게 묻지 않는다. 없으면 이름만 들어가고 지도 핀은 안 찍힌다.""" body = { "model": perplexity.DEFAULT_MODEL, "messages": [ {"role": "system", "content": prompts.SYSTEM_PROMPT}, {"role": "user", "content": prompts.build_prompt(place_name, region, duration)}, ], "max_tokens": prompts.MAX_TOKENS, } courses: list[dict] = [] seen: set[frozenset[str]] = set() notes: list[str] = [] total_in = total_out = 0 total_cost = 0.0 for attempt in range(1, MAX_ATTEMPTS + 1): try: payload = await perplexity.call(body, client=client) except perplexity.PerplexityNotConfigured: return [], ["PERPLEXITY_API_KEY 미설정"] except perplexity.PerplexityError as ex: LOG.w(f"[itinerary] {duration} {attempt}차 호출 실패 place={place_name}: {ex}") notes.append(f"{attempt}차 호출 실패: {ex}") break # 같은 오류가 반복될 걸 재시도로 밀어붙이지 않는다 — 지금까지 모은 것만 쓴다 usage = perplexity.read_usage(payload) total_in += usage.input_tokens total_out += usage.output_tokens total_cost += usage.cost new_courses, dropped = grounding.parse_courses( payload, duration, place_name, place_lat, place_lng, already_seen=seen) notes += dropped for course in new_courses: if len(courses) >= TARGET_COURSES: break courses.append(course) seen.add(grounding.stop_signature(course)) if len(courses) >= TARGET_COURSES: break if len(courses) < TARGET_COURSES: notes.append(f"{MAX_ATTEMPTS}차 시도 후에도 {len(courses)}/{TARGET_COURSES}개만 채웠다") LOG.i( f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(notes)}건 버림/안내 · " f"tokens in={total_in} out={total_out} · 약 ${round(total_cost, 6)}" ) return courses, notes async def ensure_generated(place) -> dict: """없는 기간만 만들어 저장한다. 기간별 코스 수를 돌려준다. ★ 이미 있는 기간은 부르지 않는다 — 같은 업체는 그대로 재사용한다(2026-09-11 결정). ★ 기존 행을 먼저 지우지 않는다. 이번 호출이 부실하다고 지난번 결과를 날리지 않는다. """ place_id = getattr(place, "place_id", None) result: dict = {"place_id": str(place_id) if place_id else None, "counts": {}, "notes": []} if place_id is None: result["notes"].append("place_id 가 없다") return result if not perplexity.is_configured(): result["notes"].append("PERPLEXITY_API_KEY 미설정") return result name = str(getattr(place, "name", None) or "").strip() region = region_label_of(place) if not name or not region: result["notes"].append("상호나 지역을 특정할 수 없어 부르지 않는다") return result lat = _as_float(getattr(place, "latitude", None)) lng = _as_float(getattr(place, "longitude", None)) wanted = await missing_durations(place_id) if not wanted: result["notes"].append("이미 있다") return result async with httpx.AsyncClient(timeout=_TIMEOUT) as client: for duration in wanted: courses, dropped = await _generate_one(client, name, region, duration, lat, lng) result["notes"] += [f"{duration}: {d}" for d in dropped] if not courses: continue values = { "place_itinerary_id": uuid.uuid4(), "place_id": place_id, "duration": duration, "body": courses, "generated_by": SourceType.LLM.value, "model": f"perplexity:{perplexity.DEFAULT_MODEL}", "generated_at": GTime.UTC(), } err = await DB_SESSION_MNG.execute_lambda_run( [place_itineraries.DBType()], [lambda s, v=values: _CRUD.upsert(s, v)], ) if err != ErrorType.SUCCESS: LOG.w(f"[itinerary] 저장 실패 place={place_id} {duration}: {err.name}") result["notes"].append(f"{duration}: 저장 실패 {err.name}") continue result["counts"][duration] = len(courses) LOG.i(f"[itinerary] place={place_id} 완료: {result['counts']}") return result async def ensure_generated_by_id(place_id) -> dict: """잡이 쓰는 입구 — payload 에는 place_id 만 있다. ★ 주인(owner_user_id)으로 스코프하지 않는다. 잡은 이미 그 업장의 빌드/수집을 하는 중이고, 여기서 주인을 요구하면 잡 payload 에 주인을 실어 보내야 한다(`sync_place_by_id` 와 같은 판단). """ from sqlalchemy import select # ★ `local_content_service._load_place` 와 같은 방식이다 — 단건 조회 헬퍼는 없고 # execute(...).limit(1) 로 받아 첫 행을 쓴다. err, rows = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute( s, select(places).where( places.place_id == place_id, places.deleted == False # noqa: E712 ).limit(1), ), ) place = (rows[0] if rows else None) if err == ErrorType.SUCCESS else None if place is None: LOG.w(f"[itinerary] place={place_id} 사업장을 찾을 수 없어 건너뛴다") return {"place_id": str(place_id), "counts": {}, "notes": ["사업장을 찾을 수 없다"]} return await ensure_generated(place)