수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
241 lines
11 KiB
Python
241 lines
11 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)
|
||
|
||
# ★ 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] = []
|
||
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 # 같은 오류가 반복될 걸 재시도로 밀어붙이지 않는다 — 지금까지 모은 것만 쓴다
|
||
|
||
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)}건 버림/안내")
|
||
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)
|