Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 59 additions & 32 deletions app/graph/roadmap/nodes/finalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,60 @@ def _safe_next_action_suggestions(trip_days: int) -> list[str]:
return suggestions


def _format_priority_notes(notes: str | None) -> str:
normalized = str(notes or "").strip()
return normalized if normalized else "없음"


def _build_summary_prompt_messages(
course_request_payload: dict,
itinerary_context: str,
priority_notes: str,
parser: PydanticOutputParser,
) -> list:
system_prompt = (
"당신은 전문 여행 플래너입니다. 주어진 여행 정보와 확정된 장소 목록을 바탕으로 "
"사용자를 위한 최종 여행 로드맵을 완성하는 역할을 합니다\n"
"창의적인 여행 제목과 매력적인 코스가 사용자에게 전달될 수 있도록 설명을 포함하세요\n"
"'최우선 사용자 메모'가 있으면 title, summary, tags, llm_commentary 전반에서 가장 먼저 반영하세요\n"
"다른 일반 선호와 충돌하면 최우선 사용자 메모를 우선 해석하고, 누락 없이 결과에 드러나게 하세요\n"
"출력은 반드시 제공된 JSON 스키마를 엄격히 따라야 합니다"
)
human_prompt_template = (
"## 최우선 사용자 메모\n"
"{priority_notes}\n\n"
"## 사용자 요청 전체 정보\n"
"{course_request}\n\n"
"## 확정된 일자별 장소 목록\n"
"{itinerary_context}\n\n"
"## 생성 작업 가이드\n"
"0. '최우선 사용자 메모'가 있으면 가장 중요한 제약으로 간주하고, "
"제목/요약/태그/코멘터리에 반복적으로 드러나게 해주세요.\n"
"1. '최우선 사용자 메모'와 '사용자 요청 전체 정보'를 참고하여 "
"전체 여행을 아우르는 창의적이고 매력적인 `title`을 생성해주세요. "
"(반드시 한국어로 작성하되 10자 이내로 간결히 작성하고, 여행지 또는 도시명을 포함해주세요)\n"
"2. 로드맵을 한 줄로 요약한 `summary`를 한국어로 작성해주세요. (1문장)\n"
"3. 전체 일정에서 연상되는 핵심 키워드 3~5개를 `tags`에 한국어로 작성해주세요.\n"
"4. '최우선 사용자 메모', 사용자 요청, 확정된 장소 목록을 모두 고려하여 왜 이 코스가 사용자에게 최적인지 "
"설명하는 `llm_commentary`를 작성해주세요. (2-3문장)\n"
"5. 사용자가 바로 입력할 수 있는 다음 행동 문장을 `next_action_suggestion` JSON 배열로 작성해주세요. "
"(2~3개, 반드시 우리 시스템이 가능한 작업만 포함해야 합니다. "
"가능한 작업: 로드맵 생성/재생성/일정 조정 요청, 로드맵 내용에 대한 질문. "
"불가능한 작업: 숙소/항공/렌터카/티켓 예약, 결제, 구매, 환전, 보험, 외부 서비스 연동. "
"예시 문장은 그대로 출력하지 마세요.)\n\n"
"## 출력 형식\n"
"{format_instructions}"
)

prompt = ChatPromptTemplate.from_messages([("system", system_prompt), ("human", human_prompt_template)])
return prompt.format_messages(
priority_notes=priority_notes,
course_request=course_request_payload,
itinerary_context=itinerary_context,
format_instructions=parser.get_format_instructions(),
)


async def _fill_place_descriptions_with_llm(daily_places: list[dict]) -> list[dict]:
"""LLM을 통해 장소 description을 채웁니다."""
parser = PydanticOutputParser(pydantic_object=PlaceDetailPlan)
Expand Down Expand Up @@ -360,41 +414,14 @@ async def synthesize_final_roadmap(state: RoadmapState) -> RoadmapState:
append_job_log("finalize_time", f"strategy=visit_time_policy preference={course_request.planning_preference}")

course_request_payload = course_request.model_dump(mode="json")
priority_notes = _format_priority_notes(course_request.notes)

parser = PydanticOutputParser(pydantic_object=CourseResponseLLMOutput)

system_prompt = (
"당신은 전문 여행 플래너입니다. 주어진 여행 정보와 확정된 장소 목록을 바탕으로 "
"사용자를 위한 최종 여행 로드맵을 완성하는 역할을 합니다\n"
"창의적인 여행 제목과 매력적인 코스가 사용자에게 전달될 수 있도록 설명을 포함하세요\n"
"출력은 반드시 제공된 JSON 스키마를 엄격히 따라야 합니다"
)
human_prompt_template = (
"## 사용자 요청\n"
"{course_request}\n\n"
"## 확정된 일자별 장소 목록\n"
"{itinerary_context}\n\n"
"## 생성 작업 가이드\n"
"1. '사용자 요청'을 참고하여 전체 여행을 아우르는 창의적이고 매력적인 `title`을 생성해주세요. "
"(반드시 한국어로 작성하되 10자 이내로 간결히 작성하고, 여행지 또는 도시명을 포함해주세요)\n"
"2. 로드맵을 한 줄로 요약한 `summary`를 한국어로 작성해주세요. (1문장)\n"
"3. 전체 일정에서 연상되는 핵심 키워드 3~5개를 `tags`에 한국어로 작성해주세요.\n"
"4. 사용자 요청과 확정된 장소 목록을 모두 고려하여 왜 이 코스가 사용자에게 최적인지 "
"설명하는 `llm_commentary`를 작성해주세요. (2-3문장)\n"
"5. 사용자가 바로 입력할 수 있는 다음 행동 문장을 `next_action_suggestion` JSON 배열로 작성해주세요. "
"(2~3개, 반드시 우리 시스템이 가능한 작업만 포함해야 합니다. "
"가능한 작업: 로드맵 생성/재생성/일정 조정 요청, 로드맵 내용에 대한 질문. "
"불가능한 작업: 숙소/항공/렌터카/티켓 예약, 결제, 구매, 환전, 보험, 외부 서비스 연동. "
"예시 문장은 그대로 출력하지 마세요.)\n\n"
"## 출력 형식\n"
"{format_instructions}"
)

prompt = ChatPromptTemplate.from_messages([("system", system_prompt), ("human", human_prompt_template)])
messages = prompt.format_messages(
course_request=course_request_payload,
messages = _build_summary_prompt_messages(
course_request_payload=course_request_payload,
itinerary_context=itinerary_context,
format_instructions=parser.get_format_instructions(),
priority_notes=priority_notes,
parser=parser,
)

timeout_seconds = get_timeout_policy().llm_timeout_seconds
Expand Down
39 changes: 36 additions & 3 deletions app/graph/roadmap/nodes/skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ def _normalize_text(value: Any) -> str:
return str(value or "").strip()


def _format_priority_notes(notes: Any) -> str:
normalized = _normalize_text(notes)
return normalized if normalized else "없음"


def _build_priority_notes_rules(notes: Any) -> str:
normalized = _normalize_text(notes)
if not normalized:
return "- 최우선 사용자 메모는 없습니다\n"
return (
"- 아래 '최우선 사용자 메모'는 사용자가 자유 입력으로 남긴 핵심 요구사항입니다\n"
"- 다른 일반 선호값보다 우선해서 해석하고, 슬롯 area/keyword에 직접 반영하세요\n"
"- 메모와 다른 선호가 충돌하면 메모를 우선하고, 누락 없이 일정 전반에 녹여내세요\n"
)


def _dedupe_ordered(items: list[str]) -> list[str]:
seen: set[str] = set()
deduped: list[str] = []
Expand Down Expand Up @@ -290,6 +306,7 @@ def _build_segment_prompt(
parser: PydanticOutputParser,
) -> list:
slot_targets_text = _format_slot_targets(slot_targets)
priority_notes = _format_priority_notes(request.notes)
system_prompt = (
"당신은 검색을 위한 여행 일정 스켈레톤을 설계하는 전문 여행 플래너입니다.\n"
"제약 조건:\n"
Expand All @@ -306,8 +323,15 @@ def _build_segment_prompt(
"- keyword는 8~40자 사이로 작성하고, 한 단어 대신 구체 맥락(활동+대상+분위기)을 포함하세요\n"
"- 예시: '한강 야경 산책 코스', '로컬 해산물 저녁 식사', '현대미술 전시 관람'\n"
"- '맛집', '카페', '쇼핑' 같은 단일 범주형 단어만 쓰지 마세요\n"
"{priority_notes_rules}"
"- 출력은 스키마를 정확히 따라야 하며 추가 텍스트는 금지합니다\n"
).format(region=segment.region, slot_min=slot_min, slot_max=slot_max, slot_targets=slot_targets_text)
).format(
region=segment.region,
slot_min=slot_min,
slot_max=slot_max,
slot_targets=slot_targets_text,
priority_notes_rules=_build_priority_notes_rules(request.notes),
)

user_prompt = (
"여행 정보(지역 구간 기준):\n"
Expand All @@ -322,14 +346,18 @@ def _build_segment_prompt(
"- 목적지 성향: {destination_preference}\n"
"- 활동 성향: {activity_preference}\n"
"- 우선순위: {priority_preference}\n"
"- 추가 메모: {notes}\n\n"
"\n"
"## 최우선 사용자 메모\n"
"{priority_notes}\n\n"
"요구사항:\n"
"- 정확히 {segment_days}일치 DayPlan을 생성하세요\n"
"- 각 day의 region 필드는 반드시 '{region}' 값이어야 합니다\n"
"- 각 day는 반드시 {slot_min}~{slot_max}개의 범위 내에서 슬롯을 포함해야 합니다\n"
"- 각 day의 슬롯 수는 다음 배분을 정확히 따라야 합니다: {slot_targets}\n"
"- 각 슬롯은 section, area, keyword를 포함해야 합니다\n"
"- section은 다음 중 하나여야 합니다 MORNING, LUNCH, AFTERNOON, DINNER, EVENING, NIGHT\n\n"
"- '최우선 사용자 메모'가 있으면 모든 day에서 그 의도가 보이도록 슬롯을 설계하세요\n"
"- 특히 포함/제외 대상, 분위기, 속도감, 동선 톤 같은 자유 입력 요구를 일반 선호보다 먼저 반영하세요\n\n"
"{format_instructions}"
)

Expand All @@ -350,7 +378,7 @@ def _build_segment_prompt(
destination_preference=request.destination_preference,
activity_preference=request.activity_preference,
priority_preference=request.priority_preference,
notes=request.notes or "none",
priority_notes=priority_notes,
slot_min=slot_min,
slot_max=slot_max,
slot_targets=slot_targets_text,
Expand All @@ -370,6 +398,7 @@ def _build_repair_prompt(
validation_errors: list[str],
) -> list:
slot_targets_text = _format_slot_targets(slot_targets)
priority_notes = _format_priority_notes(request.notes)
system_prompt = (
"당신은 여행 스켈레톤 JSON 복구 전문가입니다.\n"
"입력으로 주어진 JSON의 형식/제약 위반만 수정하고 의도는 최대한 유지하세요.\n"
Expand All @@ -390,6 +419,9 @@ def _build_repair_prompt(
"- 동행자: {companion_type}\n"
"- 테마: {travel_themes}\n"
"- 페이스: {pace_preference}\n\n"
"최우선 사용자 메모:\n"
"{priority_notes}\n"
"- 위 메모가 있으면 복구 후에도 다른 선호보다 우선 반영된 상태를 유지하세요\n\n"
"검증 오류 목록:\n"
"{validation_errors}\n\n"
"문제 있는 JSON:\n"
Expand All @@ -409,6 +441,7 @@ def _build_repair_prompt(
companion_type=_join_values(request.companion_type),
travel_themes=_join_values(request.travel_themes),
pace_preference=request.pace_preference,
priority_notes=priority_notes,
validation_errors="\n".join([f"- {item}" for item in validation_errors]) if validation_errors else "- 없음",
invalid_output=invalid_output or "{}",
format_instructions=parser.get_format_instructions(),
Expand Down
59 changes: 58 additions & 1 deletion tests/test_roadmap_generation_simplified_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@

from __future__ import annotations

from app.graph.roadmap.nodes.finalize import _prepare_final_context, _visit_time_from_section
from langchain_core.output_parsers import PydanticOutputParser

from app.graph.roadmap.nodes.finalize import (
_build_summary_prompt_messages,
_prepare_final_context,
_visit_time_from_section,
)
from app.graph.roadmap.nodes.skeleton import _build_segment_prompt
from app.graph.roadmap.workflow import compiled_roadmap_graph
from app.schemas.course import CourseRequest, CourseResponseLLMOutput, RegionDateRange
from app.schemas.skeleton import SkeletonPlan


def _base_course_request() -> dict:
Expand Down Expand Up @@ -229,3 +238,51 @@ def test_prepare_final_context_uses_sequence_labels_for_spontaneous_visit_time()
place = daily_places[0]["places"][0]
assert place["place_category"] == "FOOD"
assert place["visit_time"] == "MORNING"


def test_segment_prompt_prioritizes_user_notes_when_present() -> None:
request = CourseRequest.model_validate(
{
**_base_course_request(),
"notes": "조용한 동선으로 구성하고 붐비는 관광지는 제외해 주세요.",
}
)
segment = RegionDateRange.model_validate(request.regions[0].model_dump())
parser = PydanticOutputParser(pydantic_object=SkeletonPlan)

messages = _build_segment_prompt(
request=request,
segment=segment,
segment_days=2,
slot_min=4,
slot_max=5,
slot_targets=[5, 4],
parser=parser,
)

system_content = messages[0].content
user_content = messages[1].content

assert "최우선 사용자 메모" in user_content
assert "조용한 동선으로 구성하고 붐비는 관광지는 제외해 주세요." in user_content
assert "다른 일반 선호값보다 우선해서 해석" in system_content
assert "일반 선호보다 먼저 반영" in user_content


def test_finalize_summary_prompt_prioritizes_user_notes() -> None:
parser = PydanticOutputParser(pydantic_object=CourseResponseLLMOutput)

messages = _build_summary_prompt_messages(
course_request_payload={**_base_course_request(), "notes": "전통시장과 로컬 식당을 꼭 포함해 주세요."},
itinerary_context="\nDay 1 (2026-02-01):\n- #1 09:00 경복궁",
priority_notes="전통시장과 로컬 식당을 꼭 포함해 주세요.",
parser=parser,
)

system_content = messages[0].content
user_content = messages[1].content

assert "최우선 사용자 메모" in user_content
assert "전통시장과 로컬 식당을 꼭 포함해 주세요." in user_content
assert "title, summary, tags, llm_commentary 전반에서 가장 먼저 반영" in system_content
assert "가장 중요한 제약으로 간주" in user_content
Loading