[feat] solution: 캔버스도 서버가 만든 일정을 본다 — 발행본과 같은 목록
This commit is contained in:
parent
1dd2dc773e
commit
9d4feebb86
@ -83,6 +83,9 @@ class ResLocalGuide(Res_WebPacketProtocol):
|
|||||||
restaurants: list[dict[str, Any]] = []
|
restaurants: list[dict[str, Any]] = []
|
||||||
festivals: list[dict[str, Any]] = []
|
festivals: list[dict[str, Any]] = []
|
||||||
courses: 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
|
synced_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -363,7 +363,8 @@ class LocalContentService:
|
|||||||
★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 —
|
★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 —
|
||||||
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다.
|
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다.
|
||||||
그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다.
|
그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다.
|
||||||
★ 일정(itineraries)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다.
|
★ 일정(itineraries)도 함께 내려보낸다 — 캔버스 `ItineraryTickets` 가 섹션 데이터가 비었을 때
|
||||||
|
이 값으로 떨어진다(발행본 ItinerarySection 과 같은 폴백). 예전에는 그릴 자리가 없어 뺐다.
|
||||||
"""
|
"""
|
||||||
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
|
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
|
||||||
from services.site_payload import _local
|
from services.site_payload import _local
|
||||||
@ -399,6 +400,7 @@ class LocalContentService:
|
|||||||
res.restaurants = local.get("restaurants") or []
|
res.restaurants = local.get("restaurants") or []
|
||||||
res.festivals = local.get("festivals") or []
|
res.festivals = local.get("festivals") or []
|
||||||
res.courses = local.get("courses") or []
|
res.courses = local.get("courses") or []
|
||||||
|
res.itineraries = local.get("itineraries") or []
|
||||||
res.synced_at = synced_at
|
res.synced_at = synced_at
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|||||||
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", []) == []
|
||||||
@ -5,6 +5,8 @@
|
|||||||
* ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
|
* ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
|
||||||
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
|
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
|
||||||
* 출발을 당기면 하루가 통째로 밀린다.
|
* 출발을 당기면 하루가 통째로 밀린다.
|
||||||
|
* ★ 사장님이 붙여넣은 것이 없으면 **서버가 만든 일정**을 쓴다(useLocalGuide). 발행본
|
||||||
|
* ItinerarySection 과 같은 폴백이다 — 둘이 갈리면 "미리보기와 다르다"가 된다.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
currentSeasons,
|
currentSeasons,
|
||||||
@ -15,6 +17,7 @@ import {
|
|||||||
type ItineraryItem,
|
type ItineraryItem,
|
||||||
type PlannedStop,
|
type PlannedStop,
|
||||||
} from '@o2o/shared';
|
} from '@o2o/shared';
|
||||||
|
import {useLocalGuide} from '@/hooks/useLocalGuide';
|
||||||
import {SectionBody, SectionFrame, Rail} from '../../primitives';
|
import {SectionBody, SectionFrame, Rail} from '../../primitives';
|
||||||
import type {SectionRenderProps} from '../../types';
|
import type {SectionRenderProps} from '../../types';
|
||||||
import {
|
import {
|
||||||
@ -32,11 +35,14 @@ import '../items/items.css';
|
|||||||
export function ItineraryTickets(props: SectionRenderProps) {
|
export function ItineraryTickets(props: SectionRenderProps) {
|
||||||
const {section, isSelected, onSelect} = props;
|
const {section, isSelected, onSelect} = props;
|
||||||
const parsed = parseSectionData<ItineraryItem>(section.type, section.data);
|
const parsed = parseSectionData<ItineraryItem>(section.type, section.data);
|
||||||
|
const guide = useLocalGuide();
|
||||||
|
// 사장님이 적은 것이 언제나 이긴다 — 서버가 만든 건 비었을 때의 기본값이다.
|
||||||
|
const items = parsed.items.length > 0 ? parsed.items : guide.itineraries;
|
||||||
const live = currentSeasons();
|
const live = currentSeasons();
|
||||||
const durations = itineraryDurations(parsed.items);
|
const durations = itineraryDurations(items);
|
||||||
const groups: (string | undefined)[] =
|
const groups: (string | undefined)[] =
|
||||||
durations.length > 0
|
durations.length > 0
|
||||||
? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
|
? [...durations, ...(items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
|
||||||
: [undefined];
|
: [undefined];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -55,17 +61,17 @@ export function ItineraryTickets(props: SectionRenderProps) {
|
|||||||
|
|
||||||
{parsed.error ? (
|
{parsed.error ? (
|
||||||
<ParseError message={parsed.error} />
|
<ParseError message={parsed.error} />
|
||||||
) : parsed.items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<PasteHint label="추천 일정" />
|
<PasteHint label="추천 일정" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
{groups.map((duration) => {
|
{groups.map((duration) => {
|
||||||
const items = parsed.items.filter((item) =>
|
const dayItems = items.filter((item) =>
|
||||||
duration === undefined
|
duration === undefined
|
||||||
? !item.duration?.trim()
|
? !item.duration?.trim()
|
||||||
: item.duration?.trim() === duration,
|
: item.duration?.trim() === duration,
|
||||||
);
|
);
|
||||||
if (items.length === 0) return null;
|
if (dayItems.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={duration ?? 'etc'} className="space-y-4">
|
<div key={duration ?? 'etc'} className="space-y-4">
|
||||||
{duration && (
|
{duration && (
|
||||||
@ -73,7 +79,7 @@ export function ItineraryTickets(props: SectionRenderProps) {
|
|||||||
{duration}
|
{duration}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
{items.map((item, index) => (
|
{dayItems.map((item, index) => (
|
||||||
<div key={`${item.name}-${index}`} className="space-y-2.5">
|
<div key={`${item.name}-${index}`} className="space-y-2.5">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||||
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>
|
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
|
import type {ItineraryItem} from '@o2o/shared';
|
||||||
import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types';
|
import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types';
|
||||||
import {useBuilderStore} from '@/stores/builder';
|
import {useBuilderStore} from '@/stores/builder';
|
||||||
|
|
||||||
@ -6,11 +7,16 @@ export interface LocalGuideData {
|
|||||||
foods: GuideCard[];
|
foods: GuideCard[];
|
||||||
spots: GuideCard[];
|
spots: GuideCard[];
|
||||||
festivals: FestivalCard[];
|
festivals: FestivalCard[];
|
||||||
|
/**
|
||||||
|
* 서버가 만든 추천 일정(1박2일·2박3일 각 5개). **모양을 바꾸지 않는다** —
|
||||||
|
* 맛집·명소와 달리 이미 렌더러 계약(ItineraryItem)이라 toCard 를 거치지 않는다.
|
||||||
|
*/
|
||||||
|
itineraries: ItineraryItem[];
|
||||||
/** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */
|
/** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */
|
||||||
syncedAt?: string;
|
syncedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []};
|
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: [], itineraries: []};
|
||||||
// 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다.
|
// 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다.
|
||||||
const MAX_PER_TYPE = 20;
|
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),
|
foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
|
||||||
spots: ((data.attractions ?? []) 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),
|
festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival),
|
||||||
|
itineraries: Array.isArray(data.itineraries) ? data.itineraries : [],
|
||||||
syncedAt: data.synced_at ?? undefined,
|
syncedAt: data.synced_at ?? undefined,
|
||||||
};
|
};
|
||||||
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면
|
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user