"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다.""" 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초)와 같은 값이다. _TIMEOUT = httpx.Timeout(240.0, connect=10.0) # 10개(컨셉당 2개) — 프롬프트가 요구하는 개수와 같다(`prompts.itinerary._TASK`). TARGET_COURSES = 10 # 사장님 지시: 2회로 제한한다. MAX_ATTEMPTS = 2 _CRUD = PlaceItineraryCRUD() def region_label_of(place) -> str: """프롬프트에 넣을 지명("전북특별자치도 군산시").""" 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]: """기간 → 코스 목록.""" 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]: """아직 없는 기간.""" 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]: """저장된 코스 전부.""" 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, ): """기간 하나.""" 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: """없는 기간만 만들어 저장한다.""" 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 만 있다.""" 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)