Compare commits

...

16 Commits

Author SHA1 Message Date
ab158be16f Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-11 14:40:07 +09:00
8a2964ebf7 [feat] solution: 추천 일정 카드에 출발지(업소명·출발 시각) 한 줄을 얹는다 2026-09-11 14:07:17 +09:00
6760b38fec Merge branch 'main' into feature/scheduler
# Conflicts:
#	solution/backend/services/build_service.py
2026-09-11 14:03:27 +09:00
6fbd0904fa [feat] solution/backend: 일정 컨셉당 2개씩, 총 10개로 확대 — 컨셉 축은 그대로 5개 2026-09-11 13:31:05 +09:00
ae4953cf89 Merge branch 'main' into feature/scheduler 2026-09-11 13:12:32 +09:00
ea4947bb0a [fix] solution/backend: 스냅샷 지역정보에 itineraries 키가 추가된 것을 테스트에 반영 2026-09-11 13:03:41 +09:00
9d4feebb86 [feat] solution: 캔버스도 서버가 만든 일정을 본다 — 발행본과 같은 목록 2026-09-11 11:58:09 +09:00
1dd2dc773e [feat] solution/backend: 일정 생성을 잡에 붙인다 — 에디터 요청을 붙잡지 않는다 2026-09-11 11:51:39 +09:00
8dba691274 [feat] solution/backend: 일정을 스냅샷에서 읽는다 — 즉석 계산을 잠시 쉬게 한다 2026-09-11 11:49:51 +09:00
2824701030 [feat] solution/backend: 일정 생성 — 없는 기간만 부르고 같은 업체는 그대로 쓴다 2026-09-11 11:44:09 +09:00
0f3861c1e4 [feat] solution/backend: 일정 읽기·덮어쓰기 — 업장×기간 한 행 2026-09-11 11:21:46 +09:00
d80877fe75 [fix] postgres-init: 일정 표 마이그레이션을 0011 로 옮긴다 — FAQ 브랜치와 번호가 겹친다 2026-09-11 11:13:21 +09:00
18e86a0d12 [feat] solution: 일정을 담을 자리 — 업장·기간당 한 행 2026-09-11 11:09:56 +09:00
a61d9a278c [feat] solution/backend: 일정 응답 해석 — 같은 정거장 집합인 코스는 버린다 2026-09-11 10:56:47 +09:00
a5b8701f8b [fix] solution/backend: conftest 를 되돌린다 — 테스트가 안 돈 건 DB 비밀번호 탓이었다 2026-09-11 10:47:33 +09:00
411a4e7d55 [feat] solution/backend: 일정 프롬프트 — 컨셉을 모델에게 맡기지 않는다 2026-09-11 10:41:00 +09:00
24 changed files with 1501 additions and 35 deletions

View File

@ -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 이고,

View 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;

View File

@ -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 : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
# ============================================================

View 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)

View File

@ -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

View File

@ -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(

View 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

View 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)

View File

@ -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) — 운영자 수기 항목·날씨 ────────────────

View 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}

View File

@ -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

View File

@ -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:

View File

@ -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

View 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

View 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"]

View 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

View 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일")

View 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 == []

View 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", []) == []

View 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] == ["남의 코스"]

View File

@ -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": []}

View File

@ -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}

View File

@ -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,
};
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면

View File

@ -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}
/>
)),
)}