From 18e86a0d12b2eac05aeddc797385adbb4d4b7ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 11 Sep 2026 11:09:56 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution:=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EB=8B=B4=EC=9D=84=20=EC=9E=90=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20=EC=97=85=EC=9E=A5=C2=B7=EA=B8=B0=EA=B0=84=EB=8B=B9=20?= =?UTF-8?q?=ED=95=9C=20=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- postgres-init/init-data/init.sql | 20 ++++++++++ .../migrations/0010_place_itineraries.sql | 29 ++++++++++++++ .../backend/common/database/model/models.py | 38 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 postgres-init/migrations/0010_place_itineraries.sql diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index f625d61..37dcace 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -223,6 +223,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 이고, diff --git a/postgres-init/migrations/0010_place_itineraries.sql b/postgres-init/migrations/0010_place_itineraries.sql new file mode 100644 index 0000000..4fd7a7c --- /dev/null +++ b/postgres-init/migrations/0010_place_itineraries.sql @@ -0,0 +1,29 @@ +-- 0010 · LLM 여행 일정 저장 표 +-- +-- 일정은 지금까지 저장하지 않고 빌드마다 즉석 계산했다(services/itinerary.py). +-- LLM 생성으로 바꾸면서 호출이 건당 20~50초·유료가 되어 매 빌드 재생성이 불가능하다 — +-- 업장·기간당 한 번 만들어 두고 그대로 쓴다. +-- ★ area_contents 에 넣지 않는다: 그 표의 유일성 근거는 지역·출처 기준이고 이 값은 업장에 붙는다. +-- +-- ★ 2026-09-11 기준 dev DB 에는 아직 적용하지 않았다. 그 DB 의 schema_migrations 에는 +-- 다른 브랜치의 0009_faq_catalog_columns 가 기록돼 있어, migrate.py 가 이 브랜치의 +-- 0009_align_with_init_sql 까지 함께 적용하려 든다 — 번호 정리가 먼저다. + +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; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 62e7bd4..ce97874 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -255,6 +255,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 : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변 # ============================================================