diff --git a/app/graph/roadmap/nodes/finalize.py b/app/graph/roadmap/nodes/finalize.py index 14fbcbe..479ddf2 100644 --- a/app/graph/roadmap/nodes/finalize.py +++ b/app/graph/roadmap/nodes/finalize.py @@ -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) @@ -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 diff --git a/app/graph/roadmap/nodes/skeleton.py b/app/graph/roadmap/nodes/skeleton.py index e2fd753..f82a311 100644 --- a/app/graph/roadmap/nodes/skeleton.py +++ b/app/graph/roadmap/nodes/skeleton.py @@ -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] = [] @@ -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" @@ -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" @@ -322,7 +346,9 @@ 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" @@ -330,6 +356,8 @@ def _build_segment_prompt( "- 각 day의 슬롯 수는 다음 배분을 정확히 따라야 합니다: {slot_targets}\n" "- 각 슬롯은 section, area, keyword를 포함해야 합니다\n" "- section은 다음 중 하나여야 합니다 MORNING, LUNCH, AFTERNOON, DINNER, EVENING, NIGHT\n\n" + "- '최우선 사용자 메모'가 있으면 모든 day에서 그 의도가 보이도록 슬롯을 설계하세요\n" + "- 특히 포함/제외 대상, 분위기, 속도감, 동선 톤 같은 자유 입력 요구를 일반 선호보다 먼저 반영하세요\n\n" "{format_instructions}" ) @@ -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, @@ -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" @@ -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" @@ -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(), diff --git a/tests/test_roadmap_generation_simplified_pipeline.py b/tests/test_roadmap_generation_simplified_pipeline.py index f577b22..ec75abb 100644 --- a/tests/test_roadmap_generation_simplified_pipeline.py +++ b/tests/test_roadmap_generation_simplified_pipeline.py @@ -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: @@ -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