[feat] solution: 캔버스도 서버가 만든 일정을 본다 — 발행본과 같은 목록
This commit is contained in:
parent
1dd2dc773e
commit
9d4feebb86
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
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위인지는 우리가 정할 일이 아니다.
|
||||
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
|
||||
* 출발을 당기면 하루가 통째로 밀린다.
|
||||
* ★ 사장님이 붙여넣은 것이 없으면 **서버가 만든 일정**을 쓴다(useLocalGuide). 발행본
|
||||
* ItinerarySection 과 같은 폴백이다 — 둘이 갈리면 "미리보기와 다르다"가 된다.
|
||||
*/
|
||||
import {
|
||||
currentSeasons,
|
||||
@ -15,6 +17,7 @@ import {
|
||||
type ItineraryItem,
|
||||
type PlannedStop,
|
||||
} from '@o2o/shared';
|
||||
import {useLocalGuide} from '@/hooks/useLocalGuide';
|
||||
import {SectionBody, SectionFrame, Rail} from '../../primitives';
|
||||
import type {SectionRenderProps} from '../../types';
|
||||
import {
|
||||
@ -32,11 +35,14 @@ 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 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 +61,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 +79,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}}>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면
|
||||
|
||||
Loading…
Reference in New Issue
Block a user