209 lines
9.1 KiB
Python
209 lines
9.1 KiB
Python
"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다.
|
||
|
||
★ 왜 저장하나
|
||
예전 일정(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)
|
||
|
||
_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,
|
||
):
|
||
"""기간 하나. 실패는 예외로 올리지 않고 (빈 목록, 이유) 로 돌려준다.
|
||
|
||
★ 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,
|
||
}
|
||
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} 호출 실패 place={place_name}: {ex}")
|
||
return [], [f"호출 실패: {ex}"]
|
||
|
||
courses, dropped = grounding.parse_courses(payload, duration, place_name, place_lat, place_lng)
|
||
LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(dropped)}건 버림")
|
||
return courses, dropped
|
||
|
||
|
||
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)
|