o2o-site-AEO/solution/backend/services/itinerary_llm_service.py

209 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다.
★ 왜 저장하나
예전 일정(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)