Compare commits
16 Commits
29c1a1f462
...
ab158be16f
| Author | SHA1 | Date | |
|---|---|---|---|
| ab158be16f | |||
| 8a2964ebf7 | |||
| 6760b38fec | |||
| 6fbd0904fa | |||
| ae4953cf89 | |||
| ea4947bb0a | |||
| 9d4feebb86 | |||
| 1dd2dc773e | |||
| 8dba691274 | |||
| 2824701030 | |||
| 0f3861c1e4 | |||
| d80877fe75 | |||
| 18e86a0d12 | |||
| a61d9a278c | |||
| a5b8701f8b | |||
| 411a4e7d55 |
@ -244,6 +244,26 @@ CREATE TABLE IF NOT EXISTS public.place_faqs (
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- LLM 이 만든 여행 일정. 기간당 한 행, body 에 코스 5개가 통째로 든다.
|
||||
-- ★ 키는 (place_id, duration) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다.
|
||||
-- ★ body 는 렌더러 계약(shared/lib/section-data.ts 의 ItineraryItem[]) 그대로다.
|
||||
CREATE TABLE IF NOT EXISTS public.place_itineraries (
|
||||
place_itinerary_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
place_id uuid NOT NULL,
|
||||
duration VARCHAR(20) NOT NULL, -- '1박 2일' · '2박 3일'
|
||||
body JSONB NOT NULL, -- ItineraryItem[]
|
||||
generated_by SMALLINT NOT NULL, -- SourceType: 4=llm
|
||||
model VARCHAR(100) NULL, -- 'perplexity:sonar'
|
||||
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_place_itineraries_place ON public.place_itineraries (place_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_itineraries
|
||||
ON public.place_itineraries (place_id, duration)
|
||||
WHERE deleted = FALSE;
|
||||
|
||||
-- ============================================================
|
||||
-- 지역 콘텐츠 — 접두어가 없는 유일한 표. **그게 공유물이라는 표시다.**
|
||||
-- ★ 키는 place_id 가 아니다. 축제·관광지·맛집의 유일성은 출처가 준 external_id 이고,
|
||||
|
||||
29
postgres-init/migrations/0011_place_itineraries.sql
Normal file
29
postgres-init/migrations/0011_place_itineraries.sql
Normal file
@ -0,0 +1,29 @@
|
||||
-- 0011 · LLM 여행 일정 저장 표
|
||||
--
|
||||
-- 일정은 지금까지 저장하지 않고 빌드마다 즉석 계산했다(services/itinerary.py).
|
||||
-- LLM 생성으로 바꾸면서 호출이 건당 20~50초·유료가 되어 매 빌드 재생성이 불가능하다 —
|
||||
-- 업장·기간당 한 번 만들어 두고 그대로 쓴다.
|
||||
-- ★ area_contents 에 넣지 않는다: 그 표의 유일성 근거는 지역·출처 기준이고 이 값은 업장에 붙는다.
|
||||
--
|
||||
-- ★ 0010 을 비워 두고 0011 로 붙였다. 같은 시기 FAQ 작업 브랜치가 0009 를 이미 썼고
|
||||
-- (dev DB 의 schema_migrations 에 0009_faq_catalog_columns 가 기록돼 있다) 합칠 때
|
||||
-- 번호가 부딪히지 않게 여지를 남긴 것이다.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.place_itineraries (
|
||||
place_itinerary_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
place_id uuid NOT NULL,
|
||||
duration VARCHAR(20) NOT NULL,
|
||||
body JSONB NOT NULL,
|
||||
generated_by SMALLINT NOT NULL,
|
||||
model VARCHAR(100) NULL,
|
||||
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_place_itineraries_place ON public.place_itineraries (place_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_itineraries
|
||||
ON public.place_itineraries (place_id, duration)
|
||||
WHERE deleted = FALSE;
|
||||
@ -294,6 +294,44 @@ class place_faqs(MainTableMixin, MAIN_BASE):
|
||||
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
|
||||
|
||||
|
||||
class place_itineraries(MainTableMixin, MAIN_BASE):
|
||||
"""LLM 이 만든 여행 일정. **기간당 한 행**이고 `body` 에 코스 5개가 통째로 든다.
|
||||
|
||||
★ 왜 area_contents 가 아닌가
|
||||
그 표의 유일성 근거는 셋 다 지역·출처 기준이다((source, external_id) ·
|
||||
(region_code, kind) · (region_code, content_type)). 이 값은 **업장 하나에 붙는다** —
|
||||
업소 이름이 프롬프트에 들어가고, 같은 지역 옆집이 나눠 쓸 수 없다.
|
||||
넷째 근거를 그 표에 더하면 0004·0007 에서 겪은 "제약이 겹쳐 조용히 틀리는" 사고를
|
||||
다시 만든다(area_contents.__table_args__ 주석).
|
||||
|
||||
★ body 는 렌더러 계약 그대로다(`shared/lib/section-data.ts` 의 ItineraryItem[]).
|
||||
읽는 쪽이 모양을 다시 바꾸지 않아야 사장님이 손으로 붙여넣은 것과 갈리지 않는다 —
|
||||
지역 이야기가 body 에 봉투째 담는 것과 같은 이유다.
|
||||
|
||||
★ 코스마다 한 행으로 쪼개지 않는다. 다시 생성할 때 그 한 행을 덮어쓰면 되고,
|
||||
쪼개면 "5개를 받았는데 3개만 갱신된" 상태가 생긴다.
|
||||
"""
|
||||
|
||||
__tablename__ = "place_itineraries"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_place_itineraries",
|
||||
"place_id", "duration",
|
||||
unique=True,
|
||||
postgresql_where=text("deleted = false"),
|
||||
),
|
||||
)
|
||||
|
||||
place_itinerary_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
# '1박 2일' · '2박 3일'. 화면 탭이 되는 값이라 표기를 바꾸지 않는다(prompts/itinerary.DURATIONS).
|
||||
duration = Column(String(20), nullable=False)
|
||||
body = Column(JSONB, nullable=False) # ItineraryItem[]
|
||||
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM
|
||||
model = Column(String(100), nullable=True) # 'perplexity:sonar'
|
||||
generated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
|
||||
# ============================================================
|
||||
|
||||
41
solution/backend/crud/place_itinerary_crud.py
Normal file
41
solution/backend/crud/place_itinerary_crud.py
Normal file
@ -0,0 +1,41 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import place_itineraries
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
class PlaceItineraryCRUD:
|
||||
async def list_by_place(self, db, place_id):
|
||||
"""업장의 일정 전부(기간별 한 행). 정렬은 기간 이름 순이 아니라 저장 순이 아니다 —
|
||||
화면 탭 순서는 읽는 쪽(`snapshot._local_contents`)이 DURATIONS 순으로 정한다."""
|
||||
return await DB_SESSION_MNG.execute(
|
||||
db,
|
||||
select(place_itineraries).where(
|
||||
place_itineraries.place_id == place_id,
|
||||
place_itineraries.deleted == False, # noqa: E712
|
||||
),
|
||||
)
|
||||
|
||||
async def upsert(self, db, values: dict):
|
||||
"""업장 × 기간 한 행의 삽입/갱신.
|
||||
|
||||
★ `uq_place_itineraries`(place_id, duration — deleted = false)에 태운다.
|
||||
ON CONFLICT 술어는 인덱스 술어와 **글자 그대로** 같아야 한다. 아니면 포스트그레스가
|
||||
"no unique or exclusion constraint matching" 으로 거절한다 — 표도 컬럼도 멀쩡해서
|
||||
눈으로는 원인이 안 보인다(`local_content_crud.upsert_kind` 주석과 같은 함정).
|
||||
"""
|
||||
stmt = pg_insert(place_itineraries).values(**values)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[place_itineraries.place_id, place_itineraries.duration],
|
||||
index_where=(place_itineraries.deleted == False), # noqa: E712
|
||||
set_={
|
||||
"body": stmt.excluded.body,
|
||||
"generated_by": stmt.excluded.generated_by,
|
||||
"model": stmt.excluded.model,
|
||||
"generated_at": stmt.excluded.generated_at,
|
||||
"updated_at": GTime.UTC(),
|
||||
},
|
||||
)
|
||||
return await DB_SESSION_MNG.add(db, stmt)
|
||||
@ -83,6 +83,9 @@ class ResLocalGuide(Res_WebPacketProtocol):
|
||||
restaurants: list[dict[str, Any]] = []
|
||||
festivals: list[dict[str, Any]] = []
|
||||
courses: list[dict[str, Any]] = []
|
||||
# ★ 1박2일·2박3일 각 5개(services/itinerary_llm_service). 발행본 payload.local.itineraries 와
|
||||
# **같은 값**이다 — 캔버스가 다른 목록을 보이면 "미리보기와 다르다"가 된다.
|
||||
itineraries: list[dict[str, Any]] = []
|
||||
synced_at: str | None = None
|
||||
|
||||
|
||||
|
||||
@ -143,6 +143,18 @@ async def run_build(job: dict) -> dict:
|
||||
song_result = {"error": f"{type(ex).__name__}: {ex}"}
|
||||
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}")
|
||||
|
||||
# ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
|
||||
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
|
||||
# 이미 있는 기간은 부르지 않으므로 매 빌드가 유료 호출이 되지는 않는다.
|
||||
try:
|
||||
from services.itinerary_llm_service import ensure_generated
|
||||
|
||||
made = await ensure_generated(place)
|
||||
if made["counts"]:
|
||||
LOG.i(f"[build] place={place_id} 일정 생성 {made['counts']}")
|
||||
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
|
||||
LOG.w(f"[build] place={place_id} 일정 생성 실패(직전 값 사용): {type(ex).__name__}: {ex}")
|
||||
|
||||
snapshot = await build_snapshot(place)
|
||||
|
||||
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
|
||||
|
||||
129
solution/backend/services/grounding/itinerary.py
Normal file
129
solution/backend/services/grounding/itinerary.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""일정 응답 해석 — 모델이 준 JSON 에서 **화면에 설 수 있는 코스만** 남긴다.
|
||||
|
||||
★ 스키마 검증을 하지 않는다
|
||||
항목 모양의 단일 출처는 `shared/lib/section-data.ts` 다. 그 모양을 파이썬에 한 벌 더 적으면
|
||||
프론트가 필드를 하나 늘린 날 서버가 그걸 조용히 떨어뜨린다(`grounding/story.py` 와 같은 판단).
|
||||
여기서 보는 것은 셋뿐이다 — 이름이 있나 · 정거장이 하나라도 있나 · 앞 코스와 같은 코스인가.
|
||||
|
||||
★ 같은 코스를 버린다 (2026-09-11 결정)
|
||||
정거장 **겹침은 허용**이다. 다만 정거장 집합이 완전히 같으면 순서만 바꾼 것이고, 손님 눈에는
|
||||
같은 코스 둘이다. 프롬프트 규칙 7 로도 막지만 그건 부탁이지 보장이 아니다 —
|
||||
실제로 컨셉을 지정하기 전에는 5개 중 4개가 같은 집합이었다(스파이크 실측).
|
||||
|
||||
★ duration 은 우리가 덮어쓴다
|
||||
이 값이 화면 탭을 가른다(`ItinerarySection` 이 `duration` 으로 탭을 세운다).
|
||||
모델이 "반나절" 이라고 적어 버리면 1박 2일을 요청해 받은 코스가 엉뚱한 탭에 선다.
|
||||
|
||||
★ 출처가 없어도 코스는 살린다
|
||||
지역 이야기는 출처 없는 항목을 버린다 — 그건 '사실' 이라서다. 일정은 '제안' 이고,
|
||||
출처를 이유로 버리면 화면이 통째로 빈다. 대신 Perplexity 가 실제로 읽은 첫 출처를 붙여 준다.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
|
||||
from common.logger import LOG
|
||||
|
||||
# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다.
|
||||
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _payload_text(payload: dict) -> str:
|
||||
choices = payload.get("choices") or []
|
||||
if not choices or not isinstance(choices[0], dict):
|
||||
return ""
|
||||
return ((choices[0].get("message") or {}).get("content")) or ""
|
||||
|
||||
|
||||
def _first_source(payload: dict) -> dict | None:
|
||||
"""Perplexity 가 실제로 읽은 첫 출처. 코스에 source 가 없을 때의 대체값."""
|
||||
for row in payload.get("search_results") or []:
|
||||
if isinstance(row, dict) and (row.get("url") or "").startswith("http"):
|
||||
return {"name": row.get("title") or row["url"], "url": row["url"]}
|
||||
return None
|
||||
|
||||
|
||||
def _clean_source(value) -> dict | None:
|
||||
"""모델이 준 source. url 이 http 로 시작하지 않으면 없는 것으로 친다 —
|
||||
"검색결과 참조" 같은 문자열이 그대로 링크가 되면 눌러도 아무 데도 안 간다."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
url = (value.get("url") or "").strip()
|
||||
if not url.startswith("http"):
|
||||
return None
|
||||
return {"name": (value.get("name") or url).strip(), "url": url}
|
||||
|
||||
|
||||
def _stop_names(course: dict) -> list[str]:
|
||||
"""코스의 정거장 이름 — days 를 펴서 모은다."""
|
||||
out = []
|
||||
for day in course.get("days") or []:
|
||||
if not isinstance(day, dict):
|
||||
continue
|
||||
for stop in day.get("stops") or []:
|
||||
if isinstance(stop, dict):
|
||||
name = (stop.get("name") or "").strip() if isinstance(stop.get("name"), str) else ""
|
||||
if name:
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def parse_courses(payload: dict, duration: str) -> tuple[list[dict], list[str]]:
|
||||
"""(쓸 수 있는 코스, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다.
|
||||
|
||||
한 코스가 잘못돼도 나머지를 살린다. 기간당 5개인데 한 줄 때문에 전부 버리면
|
||||
그 업장은 다음 재생성까지 빈 채로 남는다.
|
||||
"""
|
||||
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
|
||||
if not text:
|
||||
return [], ["응답이 비었다"]
|
||||
|
||||
try:
|
||||
envelope = json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError) as ex:
|
||||
LOG.w(f"[itinerary] {duration} JSON 파싱 실패: {ex}")
|
||||
return [], [f"JSON 이 아니다: {ex}"]
|
||||
|
||||
if not isinstance(envelope, dict):
|
||||
return [], ["최상위가 객체가 아니다"]
|
||||
raw_items = envelope.get("items")
|
||||
if not isinstance(raw_items, list):
|
||||
return [], ["items 가 배열이 아니다"]
|
||||
|
||||
fallback = _first_source(payload)
|
||||
out: list[dict] = []
|
||||
dropped: list[str] = []
|
||||
seen_stop_sets: list[frozenset[str]] = []
|
||||
|
||||
for raw in raw_items:
|
||||
if not isinstance(raw, dict):
|
||||
dropped.append("코스가 객체가 아니다")
|
||||
continue
|
||||
name = (raw.get("name") or "").strip() if isinstance(raw.get("name"), str) else ""
|
||||
if not name:
|
||||
dropped.append("name 이 없다")
|
||||
continue
|
||||
|
||||
stops = _stop_names(raw)
|
||||
if not stops:
|
||||
dropped.append(f"{name}: 정거장이 없다")
|
||||
continue
|
||||
|
||||
stop_set = frozenset(stops)
|
||||
if stop_set in seen_stop_sets:
|
||||
dropped.append(f"{name}: 앞 코스와 정거장 집합이 같다")
|
||||
continue
|
||||
|
||||
course = {k: v for k, v in raw.items() if v not in (None, "", [], {})}
|
||||
course["name"] = name
|
||||
course["duration"] = duration
|
||||
|
||||
source = _clean_source(raw.get("source")) or fallback
|
||||
if source is not None:
|
||||
course["source"] = source
|
||||
else:
|
||||
course.pop("source", None)
|
||||
|
||||
seen_stop_sets.append(stop_set)
|
||||
out.append(course)
|
||||
|
||||
return out, dropped
|
||||
192
solution/backend/services/itinerary_llm_service.py
Normal file
192
solution/backend/services/itinerary_llm_service.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다.
|
||||
|
||||
★ 왜 저장하나
|
||||
예전 일정(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
|
||||
|
||||
|
||||
async def _generate_one(client: httpx.AsyncClient, place_name: str, region: str, duration: str):
|
||||
"""기간 하나. 실패는 예외로 올리지 않고 (빈 목록, 이유) 로 돌려준다."""
|
||||
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)
|
||||
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
|
||||
|
||||
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)
|
||||
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)
|
||||
@ -363,7 +363,8 @@ class LocalContentService:
|
||||
★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 —
|
||||
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다.
|
||||
그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다.
|
||||
★ 일정(itineraries)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다.
|
||||
★ 일정(itineraries)도 함께 내려보낸다 — 캔버스 `ItineraryTickets` 가 섹션 데이터가 비었을 때
|
||||
이 값으로 떨어진다(발행본 ItinerarySection 과 같은 폴백). 예전에는 그릴 자리가 없어 뺐다.
|
||||
"""
|
||||
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
|
||||
from services.site_payload import _local
|
||||
@ -399,6 +400,7 @@ class LocalContentService:
|
||||
res.restaurants = local.get("restaurants") or []
|
||||
res.festivals = local.get("festivals") or []
|
||||
res.courses = local.get("courses") or []
|
||||
res.itineraries = local.get("itineraries") or []
|
||||
res.synced_at = synced_at
|
||||
return res
|
||||
|
||||
@ -436,7 +438,7 @@ class LocalContentService:
|
||||
LOG.w(f"[local] place={place_id} 사이트 개인화 저장 실패: {err.name}")
|
||||
|
||||
async def _ensure_region_stories(self, place) -> None:
|
||||
"""지역 데이터가 비어 있으면 잡을 하나 넣는다 — **보험 경로**다.
|
||||
"""지역 데이터·일정이 비어 있으면 잡을 하나 넣는다 — **보험 경로**다.
|
||||
|
||||
★ 정규 경로는 위저드다: 수집이 끝날 때(collect_service)와 생성 단계(place_service)가
|
||||
같은 잡을 걸고, 사장님은 **에디터에 들어가기 전에** 다 채워진 화면을 본다.
|
||||
@ -447,10 +449,18 @@ class LocalContentService:
|
||||
if not perplexity.is_configured():
|
||||
return
|
||||
region_code = str(getattr(place, "region_code", None) or "").strip()
|
||||
# ★ "한 건이라도 있으면 건너뛴다" 가 아니라 **없는 종류가 있으면 돈다.**
|
||||
place_id = getattr(place, "place_id", None)
|
||||
|
||||
# ★ "한 건이라도 있으면 건너뛴다" 가 아니라 **없는 것이 있으면 돈다.**
|
||||
# 종류가 늘어난 날 기존 지역이 옛 목록에 멈추는 것을 막는다(story_service.missing_kinds).
|
||||
if region_code and not await story_service.missing_kinds(region_code):
|
||||
return
|
||||
# ★ 일정도 같은 잡이 채운다(2026-09-11). 이야기만 보면, 이야기가 이미 다 찬 업장은
|
||||
# 일정을 영영 못 받는다 — daily 가 안 들어가던 것과 똑같은 함정이다.
|
||||
stories_done = bool(region_code) and not await story_service.missing_kinds(region_code)
|
||||
if stories_done:
|
||||
from services.itinerary_llm_service import missing_durations
|
||||
|
||||
if place_id is None or not await missing_durations(place_id):
|
||||
return
|
||||
await story_service.enqueue_region_job(place)
|
||||
|
||||
# ── 지역 캐시(area_contents) — 운영자 수기 항목·날씨 ────────────────
|
||||
|
||||
111
solution/backend/services/prompts/itinerary.py
Normal file
111
solution/backend/services/prompts/itinerary.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""여행 일정 프롬프트 — "1박 2일·2박 3일을 각각 5개 컨셉 × 2개씩, 총 10개로".
|
||||
|
||||
★ 왜 shared 에 두지 않나
|
||||
`shared/src/lib/section-prompts.ts` 의 단일 출처 규칙은 "사장님이 복사해 가는 프롬프트와
|
||||
서버가 도는 프롬프트가 **같은 문장**일 때" 의 규칙이다. 이 둘은 하는 일이 다르다 —
|
||||
사장님용(`canvas/dataSpec.ts`)은 "3~5개, 반나절·1박2일·2박3일 섞기" 고, 이쪽은
|
||||
"기간 고정 + 지정된 컨셉 5개 × 2개" 다. 같은 문장을 두 벌 두는 게 아니므로 daily 때의 분기
|
||||
사고와 성격이 다르다. 문장 자체는 사장님용에서 가져와 변형했다.
|
||||
|
||||
★ 컨셉을 **우리가** 준다 (스파이크 2026-09-11, 스테이,머뭄)
|
||||
모델에 맡기면 2박 3일에서 5개 코스 중 **4개가 정거장 집합 100% 동일**이었다(고유 장소 13곳).
|
||||
이름만 '가족 체험'·'사진 골목'·'도보 미식' 이고 내용은 같은 8곳의 재배열이다.
|
||||
컨셉을 지정하자 고유 장소가 24곳으로 늘고 같은 집합이 사라졌다.
|
||||
|
||||
★ 컨셉 축은 **테마**여야 한다
|
||||
'차 없이 걸어서' 를 축으로 넣었더니 '처음 온 손님(대표 명소)' 과 91% 겹쳤다 —
|
||||
원도심이 곧 도보권이라 같은 장소로 수렴한다. 제약은 축이 될 수 없다.
|
||||
|
||||
★ 10개(컨셉당 2개)로 늘렸다 (2026-09-11, 사장님 지시: "중복 허용하고 10개로")
|
||||
컨셉을 5개 더 늘려 서로 안 겹치게 짜는 대신, 기존 5개 테마 각각에서 2개씩 뽑기로 했다 —
|
||||
같은 테마 안에서의 겹침(예: 자연 풍경 코스 둘이 비슷한 종류의 장소를 쓰는 것)은 허용하고,
|
||||
**정거장 집합이 완전히 같은 것만**(규칙 7) 막는다. 장소가 둘째 코스를 못 채우면 그 컨셉은
|
||||
1개만 낸다 — 지어내지 않는다 원칙의 연장이다.
|
||||
`MAX_TOKENS` 도 12000 → 16000 으로 올렸다(완성 토큰이 코스 수에 비례해 늘어난다).
|
||||
"""
|
||||
|
||||
# 화면 탭이 되는 값이다(`ItineraryItem.duration`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다
|
||||
# — `canvas/dataSpec.ts` 의 options(['반나절','1박 2일','2박 3일'])와 같은 문자열이어야 한다.
|
||||
DURATIONS: tuple[str, str] = ("1박 2일", "2박 3일")
|
||||
|
||||
# 2박 3일 5코스가 completion 3,922 토큰까지 갔다(실측). 10개(컨셉당 2개)면 그 두 배 안팎이라
|
||||
# 여유를 넉넉히 둔다 — 기본값 2048 은 물론 12000 도 10개에서는 잘릴 수 있다.
|
||||
MAX_TOKENS = 16000
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"너는 지역 여행 코스 플래너다. 검색으로 확인한 실제 장소만 쓰고, "
|
||||
"확인하지 못한 값은 필드를 통째로 뺀다. JSON 하나만 출력한다."
|
||||
)
|
||||
|
||||
_CONCEPTS = """1. 역사·근대건축 — 박물관·옛 건물·유적 위주
|
||||
2. 자연 풍경 — 바다·산·호수·공원 중 그 지역에 실제로 있는 것 위주
|
||||
3. 미식 — 시장·맛집·지역 음식 위주
|
||||
4. 아이와 함께 — 체험·동물·놀이·넓은 공원 위주
|
||||
5. 야외활동 — 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 것 위주"""
|
||||
|
||||
# ★ verified 를 요구하지 않는다. 화면에 안 나오고(`SourceLine` 이 `void verified`),
|
||||
# 모델은 좌표가 1.7km 틀린 항목에도 "확인" 을 붙였다 — 자기 신고는 믿을 값이 아니다.
|
||||
#
|
||||
# ★ `str.format` 을 쓰지 않고 `%` 치환을 쓴다. 이 문자열은 JSON 이라 리터럴 중괄호가 가득한데,
|
||||
# format 은 그것을 필드명으로 읽고 터진다(`{ "kind":"itinerary"` 를 키로 해석한다).
|
||||
# 중괄호를 `{{`/`}}` 로 이중화하는 길도 있지만, 프롬프트 본문이 눈으로 읽히지 않게 된다 —
|
||||
# 스파이크가 검증한 방식(%)을 그대로 써서 보내는 바이트를 같게 유지한다.
|
||||
_SCHEMA = """{ "kind":"itinerary", "version":1, "title":"추천 일정", "items":[
|
||||
{ "name":"코스 이름(컨셉이 드러나게)",
|
||||
"duration":"%(duration)s",
|
||||
"audience":"누구에게 맞는 일정인가",
|
||||
"why":"왜 이 일정인가 (두 문장 이내)",
|
||||
"days":[
|
||||
{ "label":"첫째 날", "startTime":"14:00", "stops":[
|
||||
{ "name":"장소", "minutes":90, "moveMinutes":12,
|
||||
"note":"한 줄 설명", "searchQuery":"지도 검색어",
|
||||
"latitude":35.9908197, "longitude":126.7121231 } ] } ],
|
||||
"source":{ "name":"출처 이름", "url":"https://..." } } ] }"""
|
||||
|
||||
_TASK = """[업소] %(place)s
|
||||
[지역] %(region)s
|
||||
|
||||
[해야 할 일]
|
||||
[업소]에 숙박하는 손님을 위한 %(duration)s 여행 일정을 **아래 5개 컨셉마다 2개씩, 총 10개** 만든다.
|
||||
|
||||
[컨셉]
|
||||
%(concepts)s
|
||||
|
||||
· 같은 컨셉 안의 두 코스도 서로 다른 일정이어야 한다 — 정거장이 겹치는 것은 괜찮지만
|
||||
(규칙 7 참고), 같은 장소를 그대로 두 번 우려내지 않는다. 컨셉에 맞는 장소가 둘째 코스를
|
||||
못 채울 만큼 모자라면 그 컨셉은 1개만 낸다 — 억지로 채우지 않는다.
|
||||
|
||||
· 모든 일정의 duration 은 "%(duration)s" 이다.
|
||||
· days 로 날짜를 나눈다.
|
||||
· 정거장은 하루에 3~5곳. 여섯 곳부터는 아무도 그대로 못 돈다.
|
||||
· minutes 는 거기서 머무는 시간, moveMinutes 는 앞 칸에서 오는 데 걸리는 시간이다.
|
||||
· 첫 정거장의 moveMinutes 는 **업소에서 나서는 시간**이다.
|
||||
· [업소] 자신은 정거장에 넣지 않는다 — 일정은 업소에서 출발하는 것이다.
|
||||
|
||||
[스키마]
|
||||
%(schema)s
|
||||
|
||||
[규칙]
|
||||
1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.
|
||||
2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.
|
||||
3. 검색으로 실제 존재가 확인된 장소만 쓴다. 폐업·휴업한 곳은 넣지 않는다.
|
||||
4. 순위를 매기지 않는다. rank 같은 칸은 없다.
|
||||
5. 링크를 만들지 않는다. searchQuery 에 지도 검색어만 적는다.
|
||||
6. 모든 정거장에 latitude·longitude 를 적는다.
|
||||
7. ★ 코스 둘이 **같은 정거장 집합**이면 안 된다. 순서만 바꿔 놓은 것은 같은 코스다.
|
||||
각 코스에는 다른 코스들에 없는 정거장이 **최소 두 곳** 들어가야 한다.
|
||||
컨셉에 맞는 장소가 모자라면 그 코스의 정거장을 줄인다 — 다른 코스의 장소를 빌려오지 않는다.
|
||||
8. 밤 9시를 넘기는 칸은 화면에서 빠진다 — 시각을 계산해 보고 넣는다.
|
||||
9. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다."""
|
||||
|
||||
|
||||
def build_prompt(place_name: str, region_label: str, duration: str) -> str:
|
||||
"""업소 하나 × 기간 하나의 프롬프트.
|
||||
|
||||
★ region_label 을 반드시 넣는다. 지역을 모른 채로 물으면 모델이 아무 도시나 고른다
|
||||
(`story_service.region_label_of` 와 같은 판단). 부르는 쪽이 빈 지역을 걸러 준다.
|
||||
"""
|
||||
if duration not in DURATIONS:
|
||||
raise ValueError(f"모르는 기간: {duration}")
|
||||
ctx = {"place": place_name, "region": region_label, "duration": duration}
|
||||
return _TASK % {**ctx, "concepts": _CONCEPTS, "schema": _SCHEMA % ctx}
|
||||
@ -31,7 +31,6 @@ from common.enums import (
|
||||
SourceType,
|
||||
)
|
||||
from common.logger import LOG
|
||||
from services.itinerary import build_itineraries
|
||||
|
||||
# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다.
|
||||
SCHEMA_VERSION = 1
|
||||
@ -649,15 +648,17 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None)
|
||||
fact·사진과 같은 분업이다.
|
||||
★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도
|
||||
payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다.
|
||||
★ itineraries(1박2일·2박3일 일정)는 저장하지 않고 여기서 즉석 계산한다 —
|
||||
재료가 갱신되면 다음 빌드에서 일정도 저절로 최신이 된다(services/itinerary.py).
|
||||
렌더러에 아직 이 필드의 자리가 없다 — 모르는 필드는 무시되므로 화면은 변하지 않는다."""
|
||||
★ itineraries(1박2일·2박3일 각 5개)는 **스냅샷에서 읽는다.** 예전에는 이 자리에서
|
||||
거리 기반으로 즉석 계산했다(services/itinerary.py) — 거리 계산은 공짜라 그게 맞았다.
|
||||
LLM 생성으로 바뀌면서 건당 20~50초·유료가 되어 표에 저장하고 그걸 읽는다
|
||||
(services/itinerary_llm_service · tmp/superpowers/specs/2026-09-11-llm-itinerary-design.md).
|
||||
★ 여기서 DB 를 읽지 않는다. 읽는 곳은 services/snapshot._local_contents 하나다 —
|
||||
이 함수가 순수해야 "스냅샷과 다른 페이지"가 생기지 않는다(파일 상단 원칙).
|
||||
★ services/itinerary.py 는 지우지 않았다. 고도화해서 되살릴 때 이 블록을 되돌린다."""
|
||||
contents = (snapshot_local or {}).get("contents") or []
|
||||
# courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07).
|
||||
local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []}
|
||||
synced_at = None
|
||||
# 일정 계산용 원본 행(좌표가 body 에 있다). payload 항목은 좌표를 싣지 않으므로 따로 모은다.
|
||||
raw = {"attractions": [], "restaurants": [], "festivals": []}
|
||||
|
||||
for row in contents:
|
||||
if not isinstance(row, dict):
|
||||
@ -678,17 +679,14 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None)
|
||||
entry = _festival(row)
|
||||
if entry:
|
||||
local["festivals"].append(entry)
|
||||
raw["festivals"].append(row)
|
||||
elif content_type == LocalContentType.ATTRACTION.value:
|
||||
entry = _local_place(row, "관광지")
|
||||
if entry:
|
||||
local["attractions"].append(entry)
|
||||
raw["attractions"].append(row)
|
||||
elif content_type == LocalContentType.RESTAURANT.value:
|
||||
entry = _local_place(row, "맛집")
|
||||
if entry:
|
||||
local["restaurants"].append(entry)
|
||||
raw["restaurants"].append(row)
|
||||
elif content_type == LocalContentType.COURSE.value:
|
||||
entry = _local_place(row, "여행코스")
|
||||
if entry:
|
||||
@ -704,9 +702,10 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None)
|
||||
local.setdefault("story", {})[kind] = [i for i in items if isinstance(i, dict)]
|
||||
# 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다.
|
||||
|
||||
itineraries = build_itineraries(
|
||||
base_lat, base_lng, raw["attractions"], raw["restaurants"], raw["festivals"]
|
||||
)
|
||||
itineraries = [
|
||||
course for course in ((snapshot_local or {}).get("itineraries") or [])
|
||||
if isinstance(course, dict)
|
||||
]
|
||||
if itineraries:
|
||||
local["itineraries"] = itineraries
|
||||
|
||||
|
||||
@ -299,7 +299,20 @@ async def _local_contents(place) -> dict:
|
||||
[_Row(r, b) for r, b, _ in merged], seen, source=LocalSource.TOUR_API.value
|
||||
)
|
||||
|
||||
return {"region_code": region_code or None, "contents": contents}
|
||||
# ── 여행 일정(place_itineraries) — 업장마다 기간당 한 행 ──────────
|
||||
# ★ 여기서 **읽기만** 한다. 생성은 잡·빌드가 한다(services/itinerary_llm_service).
|
||||
# 스냅샷 조립 안에서 LLM 을 부르면 발행 한 번이 1분 늘고, 캔버스 조회도 같은 길을 탄다.
|
||||
itineraries: list[dict] = []
|
||||
if place_id is not None:
|
||||
from services.itinerary_llm_service import get_itineraries
|
||||
|
||||
itineraries = await get_itineraries(place_id)
|
||||
|
||||
return {
|
||||
"region_code": region_code or None,
|
||||
"contents": contents,
|
||||
"itineraries": itineraries,
|
||||
}
|
||||
|
||||
|
||||
class _Row:
|
||||
|
||||
@ -241,16 +241,17 @@ async def run_local_sync(job: dict) -> dict:
|
||||
|
||||
payload: {place_id?, region_code, region_label, kinds?}
|
||||
|
||||
★ 왜 둘을 한 잡에 묶나
|
||||
업장 반경(TourAPI 맛집·관광지·축제)과 지역 이야기(LLM)는 성격이 다르지만, 사장님에게는
|
||||
"주변 이야기가 채워졌나" 하나다. 잡을 둘로 나누면 위저드가 둘을 따로 기다려야 하고,
|
||||
하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다.
|
||||
★ 왜 셋을 한 잡에 묶나
|
||||
업장 반경(TourAPI 맛집·관광지·축제)·여행 일정(LLM)·지역 이야기(LLM)는 성격이 다르지만,
|
||||
사장님에게는 "주변 이야기가 채워졌나" 하나다. 잡을 나누면 위저드가 여럿을 따로 기다려야
|
||||
하고, 하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다.
|
||||
사진 분석(VISION)을 수집에서 떼어 낸 것과는 사정이 다르다 — 그건 각각 몇 분이라 실패
|
||||
비용이 컸지만, 이 둘은 합쳐 1분대이고 유료 재호출도 아래 가드가 막는다.
|
||||
비용이 컸지만, 이 셋은 합쳐 1~2분대이고 유료 재호출도 아래 가드가 막는다.
|
||||
|
||||
★ 업장 것과 지역 것의 반복 단위가 다르다
|
||||
반경 수집은 **업장마다** 해야 한다(좌표가 다르다). 이야기는 **지역에 한 번**이면 된다 —
|
||||
같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다. 그래서 이야기 쪽만 가드가 붙는다.
|
||||
반경 수집·여행 일정은 **업장마다** 해야 한다(좌표·업소 이름이 다르다). 이야기는
|
||||
**지역에 한 번**이면 된다 — 같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다.
|
||||
그래서 이야기 쪽만 가드가 붙는다.
|
||||
|
||||
★ 멱등하다. 이야기는 순번이 아니라 (region_code, kind) 한 행을 덮어쓰고, 반경 수집은
|
||||
external_id 로 upsert 한다 — lease 만료로 다시 돌아도 행이 늘지 않는다.
|
||||
@ -277,7 +278,19 @@ async def run_local_sync(job: dict) -> dict:
|
||||
if not synced.result.success:
|
||||
LOG.w(f"[story] place={place_id} 반경 수집 실패(이야기는 계속한다): {synced.msg}")
|
||||
|
||||
# ── 2. 지역 이야기(LLM) — 지역에 한 번 ────────────────────────────
|
||||
# ── 2. 여행 일정(LLM) — 업장마다 ──────────────────────────────────
|
||||
# ★ 이야기와 같은 잡에 둔다. 사장님에게는 "주변이 채워졌나" 하나이고, 둘 다 Perplexity 라
|
||||
# 같은 키로 나간다 — 잡을 나누면 두 잡이 동시에 떠서 서로를 429 로 막는다.
|
||||
# ★ 이야기는 지역에 한 번이면 되지만 일정은 **업장마다** 필요하다(업소 이름이 프롬프트에 든다)
|
||||
# — 반경 수집과 같은 반복 단위다.
|
||||
# ★ 이야기 블록 **앞**이다. 저쪽은 "이미 있다" 로 조기 반환하므로, 뒤에 두면 이야기가 다 찬
|
||||
# 업장이 일정을 영영 못 받는다.
|
||||
if place_id:
|
||||
from services.itinerary_llm_service import ensure_generated_by_id
|
||||
|
||||
out["itineraries"] = await ensure_generated_by_id(uuid.UUID(str(place_id)))
|
||||
|
||||
# ── 3. 지역 이야기(LLM) — 지역에 한 번 ────────────────────────────
|
||||
if not perplexity.is_configured():
|
||||
out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
||||
return out
|
||||
|
||||
148
solution/backend/tests/test_itinerary_grounding.py
Normal file
148
solution/backend/tests/test_itinerary_grounding.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""일정 응답 해석 — 모델이 준 JSON 에서 화면에 설 수 있는 코스만 남긴다.
|
||||
|
||||
DB 도 네트워크도 쓰지 않는 순수 단위 테스트다.
|
||||
|
||||
★ 스키마 전체를 검증하지 않는다. 항목 모양의 단일 출처는 `shared/lib/section-data.ts` 이고,
|
||||
그 모양을 파이썬에 한 벌 더 적으면 프론트가 필드를 늘린 날 서버가 조용히 떨어뜨린다
|
||||
(`grounding/story.py` 머리주석과 같은 판단). 여기서는 "화면에 설 수 있는가" 만 본다.
|
||||
"""
|
||||
import json
|
||||
|
||||
from services.grounding import itinerary as grounding
|
||||
|
||||
|
||||
def _payload(items: list, *, fence: bool = False, search: list | None = None) -> dict:
|
||||
envelope = {"kind": "itinerary", "version": 1, "title": "추천 일정", "items": items}
|
||||
text = json.dumps(envelope, ensure_ascii=False)
|
||||
if fence:
|
||||
text = f"```json\n{text}\n```"
|
||||
return {
|
||||
"choices": [{"message": {"content": text}}],
|
||||
"search_results": search if search is not None else [
|
||||
{"title": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _course(name: str, stops: list[str], *, days: int = 2) -> dict:
|
||||
per_day = max(1, len(stops) // days)
|
||||
chunks = [stops[i:i + per_day] for i in range(0, len(stops), per_day)][:days]
|
||||
return {
|
||||
"name": name,
|
||||
"duration": "1박 2일",
|
||||
"audience": "누구에게나",
|
||||
"why": "이유 한 문장.",
|
||||
"days": [
|
||||
{
|
||||
"label": f"{i + 1}일차",
|
||||
"startTime": "14:00",
|
||||
"stops": [
|
||||
{"name": s, "minutes": 60, "moveMinutes": 10, "searchQuery": s,
|
||||
"latitude": 35.99, "longitude": 126.71}
|
||||
for s in chunk
|
||||
],
|
||||
}
|
||||
for i, chunk in enumerate(chunks)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_accepts_five_distinct_courses():
|
||||
"""검증: 정상 응답 5개.
|
||||
기대결과: 전부 채택되고 버린 것이 없다."""
|
||||
items = [_course(f"코스{i}", [f"장소{i}-{j}" for j in range(4)]) for i in range(5)]
|
||||
courses, dropped = grounding.parse_courses(_payload(items), "1박 2일")
|
||||
assert len(courses) == 5
|
||||
assert dropped == []
|
||||
assert [c["name"] for c in courses] == [f"코스{i}" for i in range(5)]
|
||||
|
||||
|
||||
def test_strips_code_fence():
|
||||
"""검증: 코드펜스를 두르고 온 응답.
|
||||
기대결과: 규칙 1 로 금지했지만 모델은 종종 어긴다 — 벗겨서 읽는다."""
|
||||
items = [_course("코스", ["가", "나"])]
|
||||
courses, dropped = grounding.parse_courses(_payload(items, fence=True), "1박 2일")
|
||||
assert len(courses) == 1
|
||||
|
||||
|
||||
def test_forces_duration_even_if_model_wrote_something_else():
|
||||
"""검증: 모델이 duration 을 다르게 적은 코스.
|
||||
기대결과: 우리가 요청한 기간으로 덮어쓴다 — 이 값이 화면 탭을 가르므로 틀리면 탭이 갈린다."""
|
||||
item = _course("코스", ["가", "나"])
|
||||
item["duration"] = "반나절"
|
||||
courses, _ = grounding.parse_courses(_payload([item]), "1박 2일")
|
||||
assert courses[0]["duration"] == "1박 2일"
|
||||
|
||||
|
||||
def test_drops_course_without_name():
|
||||
"""검증: 이름 없는 코스.
|
||||
기대결과: 버린다 — 제목 없는 카드가 된다."""
|
||||
item = _course("", ["가", "나"])
|
||||
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일")
|
||||
assert courses == []
|
||||
assert dropped and "name" in dropped[0]
|
||||
|
||||
|
||||
def test_drops_course_without_stops():
|
||||
"""검증: days 가 있어도 정거장이 하나도 없는 코스.
|
||||
기대결과: 버린다 — 발행본 ItinerarySection 이 어차피 걸러 빈 탭을 만든다."""
|
||||
item = {"name": "빈 코스", "duration": "1박 2일", "days": [{"label": "1일차", "stops": []}]}
|
||||
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일")
|
||||
assert courses == []
|
||||
assert dropped
|
||||
|
||||
|
||||
def test_drops_a_course_whose_stop_set_duplicates_an_earlier_one():
|
||||
"""검증: 앞 코스와 정거장 집합이 완전히 같은 코스(순서만 다름).
|
||||
기대결과: 뒤에 온 것을 버린다 — 손님 눈에는 같은 코스 둘이다.
|
||||
★ 정거장 '겹침' 자체는 허용이다(2026-09-11 결정). 집합이 **같을 때**만 버린다."""
|
||||
first = _course("원도심 코스", ["가", "나", "다", "라"])
|
||||
same = _course("미식 코스", ["라", "다", "나", "가"]) # 순서만 다르다
|
||||
overlap = _course("자연 코스", ["가", "나", "마", "바"]) # 절반 겹치지만 다른 코스다
|
||||
courses, dropped = grounding.parse_courses(_payload([first, same, overlap]), "1박 2일")
|
||||
assert [c["name"] for c in courses] == ["원도심 코스", "자연 코스"]
|
||||
assert dropped and "미식 코스" in dropped[0]
|
||||
|
||||
|
||||
def test_attaches_search_result_as_source_when_model_gave_none():
|
||||
"""검증: 코스에 source 가 없을 때.
|
||||
기대결과: Perplexity 가 실제로 읽은 첫 출처를 붙인다 — 모델 답변보다 이게 실재한다.
|
||||
★ source 가 아예 없으면 발행본 카드 하단에 빈 점선 띠가 남는다(ItinerarySection 껍데기)."""
|
||||
courses, _ = grounding.parse_courses(_payload([_course("코스", ["가", "나"])]), "1박 2일")
|
||||
assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/tour/"
|
||||
|
||||
|
||||
def test_keeps_model_source_when_it_is_a_real_url():
|
||||
"""검증: 모델이 준 source 가 http 로 시작할 때.
|
||||
기대결과: 그걸 쓴다."""
|
||||
item = _course("코스", ["가", "나"])
|
||||
item["source"] = {"name": "군산시", "url": "https://www.gunsan.go.kr/index.gunsan"}
|
||||
courses, _ = grounding.parse_courses(_payload([item]), "1박 2일")
|
||||
assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/index.gunsan"
|
||||
|
||||
|
||||
def test_course_survives_without_any_source():
|
||||
"""검증: 모델 source 도 없고 search_results 도 빈 응답.
|
||||
기대결과: 코스는 살린다 — 출처가 없다고 일정을 버리면 화면이 통째로 빈다.
|
||||
(지역 이야기와 다른 판단이다. 저쪽은 '사실' 이고 이쪽은 '제안' 이다.)"""
|
||||
items = [_course("코스", ["가", "나"])]
|
||||
courses, _ = grounding.parse_courses(_payload(items, search=[]), "1박 2일")
|
||||
assert len(courses) == 1
|
||||
assert "source" not in courses[0]
|
||||
|
||||
|
||||
def test_returns_empty_on_non_json():
|
||||
"""검증: JSON 이 아닌 응답.
|
||||
기대결과: 빈 목록 + 이유. 예외를 던지지 않는다 — 잡이 죽으면 다른 기간도 못 받는다."""
|
||||
payload = {"choices": [{"message": {"content": "죄송합니다, 일정을 만들 수 없습니다."}}]}
|
||||
courses, dropped = grounding.parse_courses(payload, "1박 2일")
|
||||
assert courses == []
|
||||
assert dropped
|
||||
|
||||
|
||||
def test_returns_empty_when_items_is_not_a_list():
|
||||
"""검증: items 가 배열이 아닌 응답."""
|
||||
payload = {"choices": [{"message": {"content": json.dumps({"items": "없음"})}}]}
|
||||
courses, dropped = grounding.parse_courses(payload, "1박 2일")
|
||||
assert courses == []
|
||||
assert dropped
|
||||
246
solution/backend/tests/test_itinerary_llm_service.py
Normal file
246
solution/backend/tests/test_itinerary_llm_service.py
Normal file
@ -0,0 +1,246 @@
|
||||
"""일정 생성 오케스트레이션 — 언제 부르고 언제 안 부르나.
|
||||
|
||||
★ 실제 Perplexity 를 부르지 않는다. 유료·느리고, APP_ENV=test 는 .env 를 안 읽어 키도 없다.
|
||||
`perplexity.call` 을 가로채 호출 횟수와 프롬프트를 본다.
|
||||
|
||||
이 서비스가 지켜야 하는 것:
|
||||
- 이미 있는 기간은 다시 부르지 않는다(같은 업체는 그대로 재사용 — 2026-09-11 결정)
|
||||
- 지역을 모르면 아예 부르지 않는다(모델이 아무 도시나 고른다)
|
||||
- 한 기간이 실패해도 다른 기간은 저장한다
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
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, PlaceCategory, SourceType
|
||||
from crud.place_itinerary_crud import PlaceItineraryCRUD
|
||||
from services import itinerary_llm_service as service
|
||||
from services.llm import perplexity
|
||||
from services.prompts import itinerary as prompts
|
||||
|
||||
|
||||
class _FakePlace:
|
||||
"""ORM 행 대신 쓰는 최소 객체 — 서비스는 getattr 로만 읽는다."""
|
||||
|
||||
def __init__(self, place_id, name="스테이,머뭄", road_address="전북특별자치도 군산시 절골길 18"):
|
||||
self.place_id = place_id
|
||||
self.name = name
|
||||
self.road_address = road_address
|
||||
self.address = road_address
|
||||
|
||||
|
||||
def _response(names: list[str], duration: str) -> dict:
|
||||
items = [
|
||||
{"name": n, "duration": duration, "audience": "누구에게나", "why": "이유.",
|
||||
"days": [{"label": "1일차", "startTime": "14:00",
|
||||
"stops": [{"name": f"{n}-장소{i}", "minutes": 60, "moveMinutes": 10,
|
||||
"searchQuery": f"{n}-장소{i}",
|
||||
"latitude": 35.99, "longitude": 126.71} for i in range(3)]}]}
|
||||
for n in names
|
||||
]
|
||||
return {
|
||||
"choices": [{"message": {"content": json.dumps(
|
||||
{"kind": "itinerary", "version": 1, "items": items}, ensure_ascii=False)}}],
|
||||
"search_results": [{"title": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spy_perplexity(monkeypatch):
|
||||
"""perplexity.call 을 가로채고 (호출 기록, 응답 설정) 을 준다."""
|
||||
calls: list[dict] = []
|
||||
plan: dict = {}
|
||||
|
||||
async def fake_call(body, *, client=None):
|
||||
calls.append(body)
|
||||
duration = "2박 3일" if "2박 3일" in body["messages"][1]["content"] else "1박 2일"
|
||||
outcome = plan.get(duration, "ok")
|
||||
if outcome == "error":
|
||||
raise perplexity.PerplexityError("일부러 실패")
|
||||
if outcome == "garbage":
|
||||
return {"choices": [{"message": {"content": "일정을 만들 수 없습니다."}}]}
|
||||
return _response([f"{duration} 코스{i}" for i in range(5)], duration)
|
||||
|
||||
monkeypatch.setattr(perplexity, "call", fake_call)
|
||||
monkeypatch.setattr(perplexity, "is_configured", lambda: True)
|
||||
return calls, plan
|
||||
|
||||
|
||||
async def _rows(place_id):
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_itineraries.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: PlaceItineraryCRUD().list_by_place(s, place_id),
|
||||
)
|
||||
assert err == ErrorType.SUCCESS
|
||||
return list(rows or [])
|
||||
|
||||
|
||||
async def test_generates_both_durations(db_engine, spy_perplexity):
|
||||
"""검증: 아무것도 없는 업장.
|
||||
기대결과: 기간 둘을 각각 한 번씩 부르고(총 2회) 각각 5개 코스를 저장한다."""
|
||||
calls, _ = spy_perplexity
|
||||
pid = uuid.uuid4()
|
||||
|
||||
out = await service.ensure_generated(_FakePlace(pid))
|
||||
|
||||
assert len(calls) == 2
|
||||
assert out["counts"] == {"1박 2일": 5, "2박 3일": 5}
|
||||
rows = await _rows(pid)
|
||||
assert {r.duration for r in rows} == {"1박 2일", "2박 3일"}
|
||||
assert all(r.generated_by == SourceType.LLM.value for r in rows)
|
||||
assert all(r.model == f"perplexity:{perplexity.DEFAULT_MODEL}" for r in rows)
|
||||
|
||||
|
||||
async def test_does_not_call_again_for_durations_already_stored(db_engine, spy_perplexity):
|
||||
"""검증: 한 번 만든 업장을 다시 부른다.
|
||||
기대결과: 추가 호출 0회 — 유료 호출을 두 번 하지 않는다(요금 가드)."""
|
||||
calls, _ = spy_perplexity
|
||||
pid = uuid.uuid4()
|
||||
await service.ensure_generated(_FakePlace(pid))
|
||||
calls.clear()
|
||||
|
||||
out = await service.ensure_generated(_FakePlace(pid))
|
||||
|
||||
assert calls == []
|
||||
assert out["counts"] == {}
|
||||
assert "이미 있다" in " ".join(out["notes"])
|
||||
|
||||
|
||||
async def test_fills_only_the_missing_duration(db_engine, spy_perplexity):
|
||||
"""검증: 1박2일만 있는 업장.
|
||||
기대결과: 2박3일만 부른다 — missing_durations 가 기준이다."""
|
||||
calls, _ = spy_perplexity
|
||||
pid = uuid.uuid4()
|
||||
await service.ensure_generated(_FakePlace(pid))
|
||||
|
||||
# 2박 3일 행만 지운다(직접 SQL — 서비스에 삭제 창구가 없다)
|
||||
from sqlalchemy import delete
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_itineraries.DBType()],
|
||||
[lambda s: DB_SESSION_MNG.add(s, delete(place_itineraries).where(
|
||||
place_itineraries.place_id == pid, place_itineraries.duration == "2박 3일"))],
|
||||
)
|
||||
calls.clear()
|
||||
|
||||
out = await service.ensure_generated(_FakePlace(pid))
|
||||
|
||||
assert len(calls) == 1
|
||||
assert "2박 3일" in calls[0]["messages"][1]["content"]
|
||||
assert out["counts"] == {"2박 3일": 5}
|
||||
|
||||
|
||||
async def test_skips_when_region_is_unknown(db_engine, spy_perplexity):
|
||||
"""검증: 주소가 없는 업장.
|
||||
기대결과: 호출하지 않는다 — 지역을 모른 채 물으면 모델이 아무 도시나 고른다."""
|
||||
calls, _ = spy_perplexity
|
||||
place = _FakePlace(uuid.uuid4(), road_address="")
|
||||
place.address = ""
|
||||
|
||||
out = await service.ensure_generated(place)
|
||||
|
||||
assert calls == []
|
||||
assert "지역" in " ".join(out["notes"])
|
||||
|
||||
|
||||
async def test_skips_when_key_is_missing(db_engine, monkeypatch):
|
||||
"""검증: PERPLEXITY_API_KEY 미설정.
|
||||
기대결과: 조용히 건너뛴다 — 키가 없다고 빌드나 에디터를 막지 않는다."""
|
||||
monkeypatch.setattr(perplexity, "is_configured", lambda: False)
|
||||
out = await service.ensure_generated(_FakePlace(uuid.uuid4()))
|
||||
assert out["counts"] == {}
|
||||
assert "PERPLEXITY_API_KEY" in " ".join(out["notes"])
|
||||
|
||||
|
||||
async def test_one_duration_failing_does_not_lose_the_other(db_engine, spy_perplexity):
|
||||
"""검증: 1박2일 호출이 실패한다.
|
||||
기대결과: 2박3일은 저장된다 — 한 기간의 실패가 다른 기간을 끌고 내려가지 않는다."""
|
||||
calls, plan = spy_perplexity
|
||||
plan["1박 2일"] = "error"
|
||||
pid = uuid.uuid4()
|
||||
|
||||
out = await service.ensure_generated(_FakePlace(pid))
|
||||
|
||||
assert out["counts"] == {"2박 3일": 5}
|
||||
assert [r.duration for r in await _rows(pid)] == ["2박 3일"]
|
||||
|
||||
|
||||
async def test_unparseable_response_stores_nothing_for_that_duration(db_engine, spy_perplexity):
|
||||
"""검증: JSON 이 아닌 응답.
|
||||
기대결과: 그 기간은 저장하지 않는다(빈 body 로 행을 만들지 않는다)."""
|
||||
_calls, plan = spy_perplexity
|
||||
plan["2박 3일"] = "garbage"
|
||||
pid = uuid.uuid4()
|
||||
|
||||
out = await service.ensure_generated(_FakePlace(pid))
|
||||
|
||||
assert out["counts"] == {"1박 2일": 5}
|
||||
assert [r.duration for r in await _rows(pid)] == ["1박 2일"]
|
||||
|
||||
|
||||
async def test_prompt_carries_place_and_region(db_engine, spy_perplexity):
|
||||
"""검증: 실제로 보낸 프롬프트.
|
||||
기대결과: 상호와 지역(주소 앞 두 토막)이 들어간다."""
|
||||
calls, _ = spy_perplexity
|
||||
await service.ensure_generated(_FakePlace(uuid.uuid4()))
|
||||
|
||||
user_prompt = calls[0]["messages"][1]["content"]
|
||||
assert "스테이,머뭄" in user_prompt
|
||||
assert "전북특별자치도 군산시" in user_prompt
|
||||
assert calls[0]["messages"][0]["content"] == prompts.SYSTEM_PROMPT
|
||||
assert calls[0]["max_tokens"] == prompts.MAX_TOKENS
|
||||
|
||||
|
||||
async def test_get_itineraries_returns_courses_in_duration_order(db_engine, spy_perplexity):
|
||||
"""검증: 읽기.
|
||||
기대결과: DURATIONS 순(1박2일 → 2박3일)으로 이어 붙인 ItineraryItem[] 이다 —
|
||||
화면 탭 순서가 저장 순서에 흔들리지 않아야 한다."""
|
||||
pid = uuid.uuid4()
|
||||
await service.ensure_generated(_FakePlace(pid))
|
||||
|
||||
items = await service.get_itineraries(pid)
|
||||
|
||||
assert len(items) == 10
|
||||
assert [i["duration"] for i in items[:5]] == ["1박 2일"] * 5
|
||||
assert [i["duration"] for i in items[5:]] == ["2박 3일"] * 5
|
||||
|
||||
|
||||
async def test_missing_durations_reports_what_is_absent(db_engine, spy_perplexity):
|
||||
"""검증: missing_durations.
|
||||
기대결과: 비었을 때 둘 다, 채운 뒤엔 빈 목록 — 잡 가드가 이 값을 본다."""
|
||||
pid = uuid.uuid4()
|
||||
assert await service.missing_durations(pid) == list(prompts.DURATIONS)
|
||||
await service.ensure_generated(_FakePlace(pid))
|
||||
assert await service.missing_durations(pid) == []
|
||||
|
||||
|
||||
async def test_ensure_generated_by_id_loads_the_place(db_engine, spy_perplexity, owner_id):
|
||||
"""검증: 잡이 쓰는 입구(place_id 만 있다).
|
||||
기대결과: places 행을 읽어 같은 일을 한다."""
|
||||
calls, _ = spy_perplexity
|
||||
pid = uuid.uuid4()
|
||||
from sqlalchemy import text
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO places (place_id, owner_user_id, name, category, road_address, status) "
|
||||
"VALUES (:p, :o, :n, :c, :r, 1)"),
|
||||
{"p": pid, "o": uuid.UUID(owner_id), "n": "스테이,머뭄", "c": PlaceCategory.LODGING.value,
|
||||
"r": "전북특별자치도 군산시 절골길 18"},
|
||||
)
|
||||
|
||||
out = await service.ensure_generated_by_id(pid)
|
||||
|
||||
assert len(calls) == 2
|
||||
assert out["counts"] == {"1박 2일": 5, "2박 3일": 5}
|
||||
|
||||
|
||||
async def test_ensure_generated_by_id_on_unknown_place(db_engine, spy_perplexity):
|
||||
"""검증: 없는 업장 id.
|
||||
기대결과: 호출하지 않고 이유만 남긴다 — 잡이 죽지 않는다."""
|
||||
calls, _ = spy_perplexity
|
||||
out = await service.ensure_generated_by_id(uuid.uuid4())
|
||||
assert calls == []
|
||||
assert out["counts"] == {}
|
||||
assert out["notes"]
|
||||
69
solution/backend/tests/test_itinerary_payload.py
Normal file
69
solution/backend/tests/test_itinerary_payload.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""스냅샷의 일정이 payload 로 그대로 나가는가.
|
||||
|
||||
★ `site_payload._local` 은 **순수 함수**다(DB 도 파일도 안 건드린다). 그래서 여기서는
|
||||
스냅샷 dict 를 손으로 만들어 넣는다 — 발행본이 스냅샷과 다른 페이지가 되지 않게 하는
|
||||
그 원칙(`site_payload.to_site_payload` 머리주석)을 테스트도 같은 방식으로 따른다.
|
||||
"""
|
||||
from services.site_payload import _local
|
||||
|
||||
|
||||
def _course(name: str, duration: str) -> dict:
|
||||
return {
|
||||
"name": name, "duration": duration, "audience": "누구에게나", "why": "이유.",
|
||||
"days": [{"label": "1일차", "startTime": "14:00",
|
||||
"stops": [{"name": "장소", "minutes": 60, "moveMinutes": 10,
|
||||
"searchQuery": "장소", "latitude": 35.99, "longitude": 126.71}]}],
|
||||
}
|
||||
|
||||
|
||||
def test_itineraries_pass_through_untouched():
|
||||
"""검증: 스냅샷에 일정이 있을 때.
|
||||
기대결과: payload.local.itineraries 로 모양 그대로 나간다 — 렌더러 계약을 여기서 바꾸지 않는다."""
|
||||
snapshot_local = {
|
||||
"region_code": "52군산시",
|
||||
"contents": [],
|
||||
"itineraries": [_course("코스A", "1박 2일"), _course("코스B", "2박 3일")],
|
||||
}
|
||||
local, _synced = _local(snapshot_local, 35.98642, 126.70612)
|
||||
|
||||
assert [i["name"] for i in local["itineraries"]] == ["코스A", "코스B"]
|
||||
assert local["itineraries"][0]["days"][0]["stops"][0]["latitude"] == 35.99
|
||||
|
||||
|
||||
def test_no_itineraries_key_when_empty():
|
||||
"""검증: 일정이 없을 때.
|
||||
기대결과: 키를 만들지 않는다 — 렌더러는 없는 필드를 무시하고, 빈 배열은 '만들었는데 비었다'로 읽힌다."""
|
||||
local, _synced = _local({"region_code": "52군산시", "contents": [], "itineraries": []}, 35.98, 126.70)
|
||||
assert "itineraries" not in local
|
||||
|
||||
|
||||
def test_old_snapshot_without_the_key_does_not_break():
|
||||
"""검증: 이 기능 전에 구운 스냅샷(키가 아예 없다).
|
||||
기대결과: 빈 채로 지나간다 — 다음 빌드에서 채워진다."""
|
||||
local, _synced = _local({"region_code": "52군산시", "contents": []}, 35.98, 126.70)
|
||||
assert "itineraries" not in local
|
||||
|
||||
|
||||
def test_non_dict_entries_are_dropped():
|
||||
"""검증: body 에 dict 가 아닌 것이 섞였을 때.
|
||||
기대결과: 그것만 버리고 나머지는 살린다."""
|
||||
snapshot_local = {"contents": [], "itineraries": [_course("코스A", "1박 2일"), "깨진 값", None]}
|
||||
local, _synced = _local(snapshot_local, 35.98, 126.70)
|
||||
assert [i["name"] for i in local["itineraries"]] == ["코스A"]
|
||||
|
||||
|
||||
def test_distance_based_algorithm_is_not_used_anymore():
|
||||
"""검증: 좌표와 주변 관광지·맛집이 있어도 알고리즘 일정이 끼지 않는다.
|
||||
기대결과: 스냅샷의 itineraries 만 나간다 — services/itinerary.py 는 잠시 쓰지 않는다
|
||||
(2026-09-11 결정). 되살릴 때 이 테스트를 함께 고친다."""
|
||||
snapshot_local = {
|
||||
"contents": [
|
||||
{"content_type": 3, "title": "관광지", "latitude": 35.99, "longitude": 126.71,
|
||||
"body": {"name": "관광지", "category": "관광지"}},
|
||||
{"content_type": 4, "title": "맛집", "latitude": 35.98, "longitude": 126.70,
|
||||
"body": {"name": "맛집", "category": "맛집"}},
|
||||
],
|
||||
"itineraries": [],
|
||||
}
|
||||
local, _synced = _local(snapshot_local, 35.98642, 126.70612)
|
||||
assert "itineraries" not in local
|
||||
72
solution/backend/tests/test_itinerary_prompt.py
Normal file
72
solution/backend/tests/test_itinerary_prompt.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""여행 일정 프롬프트 — 무엇을 반드시 물어야 하는가.
|
||||
|
||||
DB 도 네트워크도 쓰지 않는 순수 단위 테스트다.
|
||||
|
||||
이 파일이 지키는 것(스파이크 2026-09-11 에서 실측으로 얻은 것들):
|
||||
- 컨셉 5개를 **우리가** 준다. 모델에 맡기면 2박 3일에서 5개 중 4개가 정거장 집합 100% 동일이 된다
|
||||
- 컨셉당 2개씩, 총 10개를 요청한다(2026-09-11, 사장님 지시: "중복 허용하고 10개로")
|
||||
- 업소 자신을 정거장에 넣지 말라고 못 박는다. 안 적으면 "스테이,머뭄" 이 정거장으로 들어온다
|
||||
- 지역명을 넣는다. 없으면 모델이 아무 도시나 고른다(story_service.region_label_of 와 같은 이유)
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from services.prompts import itinerary as prompts
|
||||
|
||||
|
||||
def test_durations_are_two_render_contract_strings():
|
||||
"""검증: 기간 문자열.
|
||||
기대결과: 화면 탭·ItineraryItem.duration 이 되는 값이라 표기가 정확해야 한다."""
|
||||
assert prompts.DURATIONS == ("1박 2일", "2박 3일")
|
||||
|
||||
|
||||
def test_prompt_carries_place_region_and_duration():
|
||||
"""검증: 변수 세 개가 프롬프트에 들어간다."""
|
||||
text = prompts.build_prompt("스테이,머뭄", "전북특별자치도 군산시", "1박 2일")
|
||||
assert "스테이,머뭄" in text
|
||||
assert "전북특별자치도 군산시" in text
|
||||
assert '"1박 2일"' in text # 스키마의 duration 고정값
|
||||
assert "1박 2일 여행 일정" in text # 해야 할 일
|
||||
|
||||
|
||||
def test_prompt_fixes_five_concepts():
|
||||
"""검증: 컨셉 5개가 프롬프트에 박혀 있다.
|
||||
기대결과: 모델이 컨셉을 고르지 않는다 — 고르게 하면 장소가 수렴한다."""
|
||||
text = prompts.build_prompt("업소", "지역", "2박 3일")
|
||||
for concept in ("역사·근대건축", "자연 풍경", "미식", "아이와 함께", "야외활동"):
|
||||
assert concept in text
|
||||
|
||||
|
||||
def test_prompt_asks_for_ten_courses_total():
|
||||
"""검증: 컨셉당 2개씩 총 10개를 요청한다(2026-09-11: 5개 → 10개, 중복 허용).
|
||||
기대결과: 컨셉 축 자체는 그대로 5개이고, 개수만 컨셉당 2개로 늘어난다."""
|
||||
text = prompts.build_prompt("업소", "지역", "1박 2일")
|
||||
assert "5개 컨셉마다 2개씩, 총 10개" in text
|
||||
assert "같은 컨셉 안의 두 코스도 서로 다른 일정이어야 한다" in text
|
||||
|
||||
|
||||
def test_prompt_forbids_the_lodging_itself_as_a_stop():
|
||||
"""검증: 업소를 정거장에 넣지 말라는 규칙."""
|
||||
text = prompts.build_prompt("업소", "지역", "1박 2일")
|
||||
assert "[업소] 자신은 정거장에 넣지 않는다" in text
|
||||
|
||||
|
||||
def test_prompt_requires_coordinates_and_forbids_fences():
|
||||
"""검증: 좌표는 항상 요구하고, 코드펜스는 금지한다.
|
||||
기대결과: 좌표를 '아는 곳만' 으로 두면 정거장 30곳 중 8곳이 좌표 없이 온다(실측)."""
|
||||
text = prompts.build_prompt("업소", "지역", "1박 2일")
|
||||
assert "모든 정거장에 latitude·longitude 를 적는다" in text
|
||||
assert "코드펜스를 붙이지 않는다" in text
|
||||
|
||||
|
||||
def test_prompt_forbids_identical_courses():
|
||||
"""검증: 같은 정거장 집합 금지 규칙.
|
||||
기대결과: 정거장 겹침 자체는 허용이고, '같은 코스' 만 막는다(2026-09-11 결정)."""
|
||||
text = prompts.build_prompt("업소", "지역", "2박 3일")
|
||||
assert "같은 정거장 집합" in text
|
||||
|
||||
|
||||
def test_unknown_duration_is_rejected():
|
||||
"""검증: 모르는 기간.
|
||||
기대결과: ValueError — 화면에 없는 탭을 만드는 값이 조용히 저장되면 안 된다."""
|
||||
with pytest.raises(ValueError):
|
||||
prompts.build_prompt("업소", "지역", "3박 4일")
|
||||
115
solution/backend/tests/test_itinerary_trigger.py
Normal file
115
solution/backend/tests/test_itinerary_trigger.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""일정 생성은 **언제** 걸리나.
|
||||
|
||||
★ 에디터 요청 안에서 생성하지 않는다. 두 기간 합쳐 50~100초다 —
|
||||
지역 이야기가 잡으로 도는 이유와 같다(local_content_service._ensure_region_stories L444).
|
||||
캔버스는 잡을 넣고, 이번 응답에는 안 실린다. **다음에 열 때** 보인다.
|
||||
|
||||
★ 가드가 "이야기가 다 찼나" 만 보면 안 된다. 이미 이야기가 찬 업장은 일정을 영영 못 받는다 —
|
||||
story_service 가 has_stories 하나로 판단하다 daily 를 못 받던 것과 같은 함정이다.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from services import story_service
|
||||
from services.llm import perplexity
|
||||
from services.local_content_service import LocalContentService
|
||||
|
||||
|
||||
class _Place:
|
||||
"""`_ensure_region_stories` 와 `run_local_sync` 가 getattr 로만 읽는 최소 객체."""
|
||||
|
||||
def __init__(self):
|
||||
self.place_id = uuid.uuid4()
|
||||
self.region_code = "52군산시"
|
||||
self.name = "스테이,머뭄"
|
||||
self.road_address = "전북특별자치도 군산시 절골길 18"
|
||||
self.address = self.road_address
|
||||
|
||||
|
||||
class _SyncResult:
|
||||
"""`sync_place_by_id` 가 돌려주는 모양(ResSyncPlace)의 최소 대역."""
|
||||
|
||||
class result:
|
||||
success = True
|
||||
|
||||
msg = ""
|
||||
festivals = attractions = restaurants = 0
|
||||
|
||||
|
||||
async def test_local_sync_job_generates_itineraries(monkeypatch, db_engine):
|
||||
"""검증: LOCAL_SYNC 잡이 일정 생성을 부른다.
|
||||
기대결과: place_id 로 ensure_generated_by_id 를 부르고 결과를 잡 결과에 싣는다.
|
||||
|
||||
★ 이야기 블록은 키가 없으면(APP_ENV=test 는 .env 를 안 읽는다) 조기 반환한다 —
|
||||
그래서 일정 블록이 **이야기보다 앞**에 있어야 이 테스트가 통과한다. 그게 의도다."""
|
||||
called: list = []
|
||||
|
||||
async def fake_ensure(place_id):
|
||||
called.append(place_id)
|
||||
return {"place_id": str(place_id), "counts": {"1박 2일": 5, "2박 3일": 5}, "notes": []}
|
||||
|
||||
async def fake_sync(self, place_id):
|
||||
return _SyncResult()
|
||||
|
||||
monkeypatch.setattr("services.itinerary_llm_service.ensure_generated_by_id", fake_ensure)
|
||||
monkeypatch.setattr(LocalContentService, "sync_place_by_id", fake_sync)
|
||||
|
||||
pid = uuid.uuid4()
|
||||
out = await story_service.run_local_sync({
|
||||
"payload": {"place_id": str(pid), "region_code": "52군산시",
|
||||
"region_label": "전북특별자치도 군산시"},
|
||||
})
|
||||
|
||||
assert called == [pid]
|
||||
assert out["itineraries"]["counts"] == {"1박 2일": 5, "2박 3일": 5}
|
||||
|
||||
|
||||
async def test_canvas_enqueues_job_when_only_itineraries_are_missing(monkeypatch, db_engine):
|
||||
"""검증: 이야기는 다 찼지만 일정이 없는 업장이 캔버스를 연다.
|
||||
기대결과: 잡을 넣는다 — 가드가 일정 누락도 보기 때문이다.
|
||||
★ 이 테스트가 없으면 "이야기가 찬 업장은 일정을 영영 못 받는" 함정이 조용히 살아난다."""
|
||||
enqueued: list = []
|
||||
|
||||
async def fake_enqueue(place):
|
||||
enqueued.append(place.place_id)
|
||||
return "job-1"
|
||||
|
||||
async def no_missing_kinds(region_code):
|
||||
return []
|
||||
|
||||
async def two_missing_durations(place_id):
|
||||
return ["1박 2일", "2박 3일"]
|
||||
|
||||
monkeypatch.setattr(perplexity, "is_configured", lambda: True)
|
||||
monkeypatch.setattr(story_service, "missing_kinds", no_missing_kinds)
|
||||
monkeypatch.setattr(story_service, "enqueue_region_job", fake_enqueue)
|
||||
monkeypatch.setattr("services.itinerary_llm_service.missing_durations", two_missing_durations)
|
||||
|
||||
place = _Place()
|
||||
await LocalContentService()._ensure_region_stories(place)
|
||||
|
||||
assert enqueued == [place.place_id]
|
||||
|
||||
|
||||
async def test_canvas_does_not_enqueue_when_everything_is_filled(monkeypatch, db_engine):
|
||||
"""검증: 이야기도 일정도 다 있는 업장.
|
||||
기대결과: 잡을 넣지 않는다 — 유료 호출을 다시 걸지 않는다(요금 가드)."""
|
||||
enqueued: list = []
|
||||
|
||||
async def fake_enqueue(place):
|
||||
enqueued.append(place.place_id)
|
||||
return "job-1"
|
||||
|
||||
async def no_missing_kinds(region_code):
|
||||
return []
|
||||
|
||||
async def no_missing_durations(place_id):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(perplexity, "is_configured", lambda: True)
|
||||
monkeypatch.setattr(story_service, "missing_kinds", no_missing_kinds)
|
||||
monkeypatch.setattr(story_service, "enqueue_region_job", fake_enqueue)
|
||||
monkeypatch.setattr("services.itinerary_llm_service.missing_durations", no_missing_durations)
|
||||
|
||||
await LocalContentService()._ensure_region_stories(_Place())
|
||||
|
||||
assert enqueued == []
|
||||
76
solution/backend/tests/test_local_guide_itinerary.py
Normal file
76
solution/backend/tests/test_local_guide_itinerary.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""캔버스 가이드가 일정을 함께 내려보내는가.
|
||||
|
||||
★ 예전에는 일부러 뺐다 — "캔버스에 그릴 자리가 아직 없다"(get_guide docstring).
|
||||
자리를 만들었으므로(ItineraryTickets 의 폴백) 이제 실어 보낸다.
|
||||
빼 두면 캔버스와 발행본이 다른 목록을 보이고, 그건 이 함수 자신이 경계한 상황이다.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import PlaceCategory, SourceType
|
||||
|
||||
|
||||
async def _seed_place(db_engine, owner_id, name="스테이,머뭄") -> str:
|
||||
pid = uuid.uuid4()
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO places (place_id, owner_user_id, name, category, road_address, "
|
||||
"region_code, latitude, longitude, status) "
|
||||
"VALUES (:p, :o, :n, :c, :r, :rc, :lat, :lng, 1)"),
|
||||
{"p": pid, "o": uuid.UUID(owner_id), "n": name, "c": PlaceCategory.LODGING.value,
|
||||
"r": "전북특별자치도 군산시 절골길 18", "rc": "52군산시",
|
||||
"lat": 35.98642, "lng": 126.70612},
|
||||
)
|
||||
return str(pid)
|
||||
|
||||
|
||||
async def _seed_itinerary(db_engine, place_id, duration="1박 2일", course="원도심 코스"):
|
||||
body = [{
|
||||
"name": course, "duration": duration, "audience": "처음 온 손님", "why": "이유.",
|
||||
"days": [{"label": "첫째 날", "startTime": "14:00",
|
||||
"stops": [{"name": "군산근대역사박물관", "minutes": 90, "moveMinutes": 15,
|
||||
"searchQuery": "군산근대역사박물관",
|
||||
"latitude": 35.9985, "longitude": 126.7107}]}],
|
||||
"source": {"name": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"},
|
||||
}]
|
||||
import json
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO place_itineraries (place_itinerary_id, place_id, duration, body, "
|
||||
"generated_by, model, generated_at) "
|
||||
"VALUES (:i, :p, :d, CAST(:b AS jsonb), :g, :m, now())"),
|
||||
{"i": uuid.uuid4(), "p": uuid.UUID(place_id), "d": duration,
|
||||
"b": json.dumps(body, ensure_ascii=False),
|
||||
"g": SourceType.LLM.value, "m": "perplexity:sonar"},
|
||||
)
|
||||
|
||||
|
||||
async def test_guide_returns_stored_itineraries(client, db_engine, owner_id, monkeypatch):
|
||||
"""검증: 저장된 일정이 있는 업장의 캔버스 가이드.
|
||||
기대결과: itineraries 가 실려 온다 — 캔버스가 발행본과 같은 목록을 본다."""
|
||||
# 생성·수집은 이 테스트의 관심이 아니다. 유료 호출과 잡 등록을 막는다.
|
||||
monkeypatch.setattr("services.llm.perplexity.is_configured", lambda: False)
|
||||
|
||||
pid = await _seed_place(db_engine, owner_id)
|
||||
await _seed_itinerary(db_engine, pid)
|
||||
|
||||
body = (await client.get("/v1/local/guide", params={"place_id": pid})).json()
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
assert [i["name"] for i in body["itineraries"]] == ["원도심 코스"]
|
||||
assert body["itineraries"][0]["duration"] == "1박 2일"
|
||||
assert body["itineraries"][0]["days"][0]["stops"][0]["name"] == "군산근대역사박물관"
|
||||
|
||||
|
||||
async def test_guide_without_itineraries_returns_empty_list(client, db_engine, owner_id, monkeypatch):
|
||||
"""검증: 아직 생성되지 않은 업장.
|
||||
기대결과: 빈 배열 — 캔버스는 PasteHint 를 띄운다(에러가 아니다)."""
|
||||
monkeypatch.setattr("services.llm.perplexity.is_configured", lambda: False)
|
||||
|
||||
pid = await _seed_place(db_engine, owner_id)
|
||||
|
||||
body = (await client.get("/v1/local/guide", params={"place_id": pid})).json()
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
assert body.get("itineraries", []) == []
|
||||
90
solution/backend/tests/test_place_itinerary_crud.py
Normal file
90
solution/backend/tests/test_place_itinerary_crud.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""place_itineraries 읽기·덮어쓰기.
|
||||
|
||||
이 표가 지켜야 하는 것:
|
||||
- 업장 × 기간 = 한 행. 다시 생성하면 그 행을 덮어쓴다(행이 늘지 않는다)
|
||||
- 다른 업장·다른 기간은 서로를 건드리지 않는다
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import place_itineraries
|
||||
from common.enums import DBWRType, ErrorType, SourceType
|
||||
from common.utils.gtime import GTime
|
||||
from crud.place_itinerary_crud import PlaceItineraryCRUD
|
||||
|
||||
_CRUD = PlaceItineraryCRUD()
|
||||
|
||||
|
||||
def _values(place_id, duration: str, names: list[str]) -> dict:
|
||||
return {
|
||||
"place_itinerary_id": uuid.uuid4(),
|
||||
"place_id": place_id,
|
||||
"duration": duration,
|
||||
"body": [{"name": n, "duration": duration, "days": [{"label": "1일차", "stops": [{"name": "가"}]}]}
|
||||
for n in names],
|
||||
"generated_by": SourceType.LLM.value,
|
||||
"model": "perplexity:sonar",
|
||||
"generated_at": GTime.UTC(),
|
||||
}
|
||||
|
||||
|
||||
async def _upsert(values: dict) -> ErrorType:
|
||||
return await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_itineraries.DBType()], [lambda s: _CRUD.upsert(s, values)],
|
||||
)
|
||||
|
||||
|
||||
async def _rows(place_id):
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_itineraries.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: _CRUD.list_by_place(s, place_id),
|
||||
)
|
||||
assert err == ErrorType.SUCCESS
|
||||
return list(rows or [])
|
||||
|
||||
|
||||
async def test_upsert_then_read(db_engine):
|
||||
"""검증: 넣고 읽는다.
|
||||
기대결과: body 가 그대로 돌아온다 — 렌더러 계약을 그 자리에서 담는다."""
|
||||
pid = uuid.uuid4()
|
||||
assert await _upsert(_values(pid, "1박 2일", ["코스A", "코스B"])) == ErrorType.SUCCESS
|
||||
|
||||
rows = await _rows(pid)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].duration == "1박 2일"
|
||||
assert [c["name"] for c in rows[0].body] == ["코스A", "코스B"]
|
||||
assert rows[0].generated_by == SourceType.LLM.value
|
||||
|
||||
|
||||
async def test_upsert_overwrites_same_place_and_duration(db_engine):
|
||||
"""검증: 같은 업장·같은 기간을 다시 넣는다.
|
||||
기대결과: 행이 늘지 않고 body 가 바뀐다(uq_place_itineraries)."""
|
||||
pid = uuid.uuid4()
|
||||
await _upsert(_values(pid, "1박 2일", ["옛 코스"]))
|
||||
await _upsert(_values(pid, "1박 2일", ["새 코스1", "새 코스2"]))
|
||||
|
||||
rows = await _rows(pid)
|
||||
assert len(rows) == 1
|
||||
assert [c["name"] for c in rows[0].body] == ["새 코스1", "새 코스2"]
|
||||
|
||||
|
||||
async def test_two_durations_live_side_by_side(db_engine):
|
||||
"""검증: 같은 업장의 두 기간.
|
||||
기대결과: 각각 한 행 — 1박2일이 2박3일을 덮지 않는다."""
|
||||
pid = uuid.uuid4()
|
||||
await _upsert(_values(pid, "1박 2일", ["짧은 코스"]))
|
||||
await _upsert(_values(pid, "2박 3일", ["긴 코스"]))
|
||||
|
||||
rows = await _rows(pid)
|
||||
assert {r.duration for r in rows} == {"1박 2일", "2박 3일"}
|
||||
|
||||
|
||||
async def test_other_place_is_untouched(db_engine):
|
||||
"""검증: 다른 업장.
|
||||
기대결과: 서로 안 보인다 — 키가 place_id 다."""
|
||||
mine, other = uuid.uuid4(), uuid.uuid4()
|
||||
await _upsert(_values(mine, "1박 2일", ["내 코스"]))
|
||||
await _upsert(_values(other, "1박 2일", ["남의 코스"]))
|
||||
|
||||
assert [c["name"] for c in (await _rows(mine))[0].body] == ["내 코스"]
|
||||
assert [c["name"] for c in (await _rows(other))[0].body] == ["남의 코스"]
|
||||
@ -241,4 +241,6 @@ async def test_place_without_any_region_key_gets_no_local_content(db_engine, own
|
||||
place = _RegionPlace(pid, "")
|
||||
place.road_address = None
|
||||
snap = await build_snapshot(place)
|
||||
assert snap["local"] == {"region_code": None, "contents": []}
|
||||
# ★ itineraries 는 항상 있는 키다(2026-09-11) — 이 업장은 place_id 는 있으니
|
||||
# place_itineraries 를 조회하고, 아직 생성된 게 없어 빈 목록으로 온다.
|
||||
assert snap["local"] == {"region_code": None, "contents": [], "itineraries": []}
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
* ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
|
||||
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
|
||||
* 출발을 당기면 하루가 통째로 밀린다.
|
||||
* ★ 사장님이 붙여넣은 것이 없으면 **서버가 만든 일정**을 쓴다(useLocalGuide). 발행본
|
||||
* ItinerarySection 과 같은 폴백이다 — 둘이 갈리면 "미리보기와 다르다"가 된다.
|
||||
*/
|
||||
import {
|
||||
currentSeasons,
|
||||
@ -15,6 +17,8 @@ import {
|
||||
type ItineraryItem,
|
||||
type PlannedStop,
|
||||
} from '@o2o/shared';
|
||||
import {useLocalGuide} from '@/hooks/useLocalGuide';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {SectionBody, SectionFrame, Rail} from '../../primitives';
|
||||
import type {SectionRenderProps} from '../../types';
|
||||
import {
|
||||
@ -32,11 +36,15 @@ import '../items/items.css';
|
||||
export function ItineraryTickets(props: SectionRenderProps) {
|
||||
const {section, isSelected, onSelect} = props;
|
||||
const parsed = parseSectionData<ItineraryItem>(section.type, section.data);
|
||||
const guide = useLocalGuide();
|
||||
const placeName = useBuilderStore((s) => s.storeName);
|
||||
// 사장님이 적은 것이 언제나 이긴다 — 서버가 만든 건 비었을 때의 기본값이다.
|
||||
const items = parsed.items.length > 0 ? parsed.items : guide.itineraries;
|
||||
const live = currentSeasons();
|
||||
const durations = itineraryDurations(parsed.items);
|
||||
const durations = itineraryDurations(items);
|
||||
const groups: (string | undefined)[] =
|
||||
durations.length > 0
|
||||
? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
|
||||
? [...durations, ...(items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
|
||||
: [undefined];
|
||||
|
||||
return (
|
||||
@ -55,17 +63,17 @@ export function ItineraryTickets(props: SectionRenderProps) {
|
||||
|
||||
{parsed.error ? (
|
||||
<ParseError message={parsed.error} />
|
||||
) : parsed.items.length === 0 ? (
|
||||
) : items.length === 0 ? (
|
||||
<PasteHint label="추천 일정" />
|
||||
) : (
|
||||
<div className="space-y-7">
|
||||
{groups.map((duration) => {
|
||||
const items = parsed.items.filter((item) =>
|
||||
const dayItems = items.filter((item) =>
|
||||
duration === undefined
|
||||
? !item.duration?.trim()
|
||||
: item.duration?.trim() === duration,
|
||||
);
|
||||
if (items.length === 0) return null;
|
||||
if (dayItems.length === 0) return null;
|
||||
return (
|
||||
<div key={duration ?? 'etc'} className="space-y-4">
|
||||
{duration && (
|
||||
@ -73,7 +81,7 @@ export function ItineraryTickets(props: SectionRenderProps) {
|
||||
{duration}
|
||||
</h3>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
{dayItems.map((item, index) => (
|
||||
<div key={`${item.name}-${index}`} className="space-y-2.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>
|
||||
@ -99,6 +107,13 @@ export function ItineraryTickets(props: SectionRenderProps) {
|
||||
|
||||
{itineraryDays(item).map((entry, dayIndex) => (
|
||||
<div key={`${entry.label}-${dayIndex}`} className="space-y-1.5">
|
||||
{/* ★ 출발지 — 첫날에만. 일정 데이터에는 없다(프롬프트가 정거장으로
|
||||
못 넣게 막는다) — 빌더가 이미 아는 상호명을 그 자리에서 얹는다. */}
|
||||
{dayIndex === 0 && placeName && (
|
||||
<p className="text-[11px] opacity-50">
|
||||
{placeName}에서 출발 · {entry.day.from}
|
||||
</p>
|
||||
)}
|
||||
{entry.label && (
|
||||
<p className="text-[11px] font-bold" style={{color: ITEM_ACCENT}}>
|
||||
{entry.label}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import type {ItineraryItem} from '@o2o/shared';
|
||||
import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
|
||||
@ -6,11 +7,16 @@ export interface LocalGuideData {
|
||||
foods: GuideCard[];
|
||||
spots: GuideCard[];
|
||||
festivals: FestivalCard[];
|
||||
/**
|
||||
* 서버가 만든 추천 일정(1박2일·2박3일 각 5개). **모양을 바꾸지 않는다** —
|
||||
* 맛집·명소와 달리 이미 렌더러 계약(ItineraryItem)이라 toCard 를 거치지 않는다.
|
||||
*/
|
||||
itineraries: ItineraryItem[];
|
||||
/** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */
|
||||
syncedAt?: string;
|
||||
}
|
||||
|
||||
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []};
|
||||
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: [], itineraries: []};
|
||||
// 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다.
|
||||
const MAX_PER_TYPE = 20;
|
||||
|
||||
@ -91,6 +97,7 @@ export function useLocalGuide(): LocalGuideData {
|
||||
foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
|
||||
spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
|
||||
festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival),
|
||||
itineraries: Array.isArray(data.itineraries) ? data.itineraries : [],
|
||||
syncedAt: data.synced_at ?? undefined,
|
||||
};
|
||||
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면
|
||||
|
||||
@ -51,12 +51,15 @@ function DayCard({
|
||||
startTime,
|
||||
stops,
|
||||
first,
|
||||
placeName,
|
||||
}: {
|
||||
item: ItineraryItem;
|
||||
badge: string;
|
||||
startTime?: string;
|
||||
stops: PlannerStop[];
|
||||
first: boolean;
|
||||
/** 업소 상호명 — 출발지 표시용. 일정 데이터에는 없다(프롬프트가 정거장으로 못 넣게 막는다). */
|
||||
placeName: string;
|
||||
}) {
|
||||
const day = planDay({name: item.name, startTime, stops});
|
||||
|
||||
@ -102,6 +105,9 @@ function DayCard({
|
||||
{/* ★ why·audience 는 첫 장만 — 다 채우면 같은 문구가 이틀·사흘 반복된다. */}
|
||||
{first && item.audience && <p className="text-[12px] opacity-60">{item.audience}</p>}
|
||||
{first && item.why && <p className="text-[13px] leading-relaxed opacity-80">{item.why}</p>}
|
||||
{/* ★ 출발지 — 첫 장만(위 audience·why 와 같은 규칙). day.from 은 planDay 가 계산한
|
||||
출발 시각(item.startTime)이라 시간표의 첫 줄과 정확히 이어진다. */}
|
||||
{first && <p className="text-[11px] opacity-50">{placeName}에서 출발 · {day.from}</p>}
|
||||
</div>
|
||||
|
||||
<RouteMap stops={day.stops.map((planned) => planned.stop)} />
|
||||
@ -231,6 +237,7 @@ export function ItinerarySection() {
|
||||
key={active}
|
||||
tab={tab}
|
||||
items={items.filter((item) => (item.duration?.trim() || NO_DURATION) === tab)}
|
||||
placeName={payload.place.name}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -254,7 +261,15 @@ export function ItinerarySection() {
|
||||
* ★ `audience`("차 없이 오는 손님")는 카드에만 둔다. 줄에도 적으면 같은 문구가 위아래로
|
||||
* 두 번 나오고, 코스가 하나일 때는 줄이 아예 없어서 그 문구만 사라진다.
|
||||
*/
|
||||
function DurationRail({tab, items}: {tab: string; items: ItineraryItem[]}) {
|
||||
function DurationRail({
|
||||
tab,
|
||||
items,
|
||||
placeName,
|
||||
}: {
|
||||
tab: string;
|
||||
items: ItineraryItem[];
|
||||
placeName: string;
|
||||
}) {
|
||||
/** 코스별 시작 위치와 길이. 레일 인덱스 ↔ 코스를 잇는 유일한 표다. */
|
||||
const courses = useMemo(() => {
|
||||
let cursor = 0;
|
||||
@ -311,6 +326,7 @@ function DurationRail({tab, items}: {tab: string; items: ItineraryItem[]}) {
|
||||
startTime={day.startTime}
|
||||
stops={day.stops}
|
||||
first={dayIndex === 0}
|
||||
placeName={placeName}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user