## 1. Gemini -> OpenAI 공급자 추상화
Gemini 쿼터/인증 실패로 COPY 잡(소개문·FAQ 생성)이 반복 DEAD 되는 걸 보고, 공급자를
OpenAI로 바꾸되 설정 하나로 되돌릴 수 있게 했다.
- services/llm/errors.py·types.py(신규): 공급자 무관 예외·Usage·ImagePart·LlmResult
- services/llm/gemini.py: 기존 call() 은 그대로 두고 generate() 인터페이스 추가
- services/llm/openai.py(신규): OpenAI Chat Completions 구현. 실측(2026-09-16):
gpt-5.6-luna 는 temperature 커스텀 값을 거부한다("Only the default (1) value is
supported") — 아예 안 보낸다.
- services/llm/provider.py(신규): LLM_PROVIDER 설정(기본 openai, 모르는 값은 gemini)으로
둘 중 하나를 고른다.
- gemini_text.py·gemini.py(vision)·gemini_extract.py: 공개 함수 이름은 그대로 두고
내부만 provider.active() 로 배선 — vision_service.py 등 6개 호출부는 무변경.
단 model 선택 로직(vision_service.py·copy_steps.py)은 공급자에 맞는 모델명을 고르도록 한 줄씩 고쳤다.
- config_models.py: llm_provider·openai_api_key·openai_text_model·openai_vision_model 추가.
## 2. Perplexity 실비용 계측 추가
OpenAI 전환 김에 실제 발행 파이프라인(스테이,머뭄 기준)을 끝까지 돌려 LLM 비용을 재보니,
services/llm/perplexity.py 에는 애초에 토큰·비용 계측이 없었다. 추가하는 과정에서
실측(2026-09-16, 실제 API 응답): `usage.cost` 는 문서 예시(평평한 숫자)와 달리
`{input_tokens_cost, output_tokens_cost, request_cost, total_cost}` 객체였다 — 그대로
가정하고 배포했다가 지역 이야기 생성(LOCAL_SYNC) 잡이 재시도 3회 후 DEAD 로 떨어지는 걸
라이브에서 확인하고 고쳤다. 어떤 모양이 와도 예외를 던지지 않게 방어했다.
- services/llm/perplexity.py: Usage·read_usage() 추가(usage.cost.total_cost 를 그대로 읽는다
— 토큰 단가표로 역산하지 않는다. 검색 컨텍스트 요금까지 포함된 진짜 값이라서다)
- external/perplexity.py·place_research.py·story_service.py·itinerary_llm_service.py·
external/restaurant_discovery.py: 각 호출부에 tokens/비용 로그 추가
실측(스테이,머뭄 1건 발행, 지역 콘텐츠는 캐시): Perplexity $0.050(일정 생성이 절반 이상),
OpenAI $0.019(비전 $0.015 + 소개문·FAQ $0.003 + 가사 $0.0006).
검증: 신규/영향받은 테스트 전부 통과(services/llm 신규 3파일, gemini_extract 최초 HTTP
계층 테스트, perplexity 비용 계측 등). 실 OpenAI/Perplexity API로 사업장 수집→비전→
소개문·FAQ→발행까지 라이브로 왕복 확인.
## 3. site/EssentialInfoSection.tsx
미확인 항목 개수 안내 문구 제거(별도 작업, 스테이징된 상태 그대로 포함).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
11 KiB
Python
251 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] = []
|
||
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)
|