diff --git a/app/core/visit_time_llm.py b/app/core/visit_time_llm.py index 1228c13..f11e321 100644 --- a/app/core/visit_time_llm.py +++ b/app/core/visit_time_llm.py @@ -115,6 +115,7 @@ async def propose_visit_times_for_days( "24:00은 하루 종료 시각으로만 사용할 수 있고 24:01, 25:00 같은 값은 금지입니다.\n" "visit_sequence 순서를 지키고 시간이 감소하지 않도록 동일하거나 점진적으로 증가해야 합니다.\n" "동일한 일차에서 최대 10개 장소까지 자연스럽고 현실적으로 배치하세요.\n" + "visit_time의 분(MM)은 반드시 '00' 또는 '30' 중 하나를 사용하여 30분 단위로 배치하세요.\n" "section_hint, time_hint는 참고용이며 이동 동선을 고려해 자연스럽게 배치하세요.\n" "입력에 없는 장소를 추가하거나 순서를 바꾸지 마세요.\n" "운영시간 데이터가 없으므로 실제 영업시간을 단정하지 마세요." diff --git a/app/core/visit_time_policy.py b/app/core/visit_time_policy.py index 2244c9d..66959c8 100644 --- a/app/core/visit_time_policy.py +++ b/app/core/visit_time_policy.py @@ -104,6 +104,17 @@ def _normalize_output_mode(output_mode: VisitTimeOutputMode | str) -> VisitTimeO return VisitTimeOutputMode.HHMM +def _build_baseline_minutes(total_count: int, start_minutes: int) -> list[int]: + """장소 수에 맞춰 하루 종료 시각까지 균등하게 시간을 배분합니다.""" + if total_count <= 0: + return [] + if total_count == 1: + return [start_minutes] + + step = max(1, (_FALLBACK_END_MINUTES - start_minutes) // total_count) + return [min(start_minutes + step * index, _MAX_VISIT_TIME_MINUTES) for index in range(total_count)] + + def build_visit_time_policy_config(settings: Settings | None = None) -> VisitTimePolicyConfig: """설정값으로 visit_time 정책 구성을 만듭니다.""" resolved_settings = settings or get_settings() @@ -147,17 +158,6 @@ def _parse_valid_visit_time(value: str | None) -> int | None: return parsed -def _fallback_minutes_for_position(index: int, total_count: int, config: VisitTimePolicyConfig) -> int: - start = max(_MIN_VISIT_TIME_MINUTES, min(config.start_minutes, _MAX_VISIT_TIME_MINUTES)) - count = max(1, total_count) - if count == 1: - return start - - available_minutes = max(0, _FALLBACK_END_MINUTES - start) - step = max(1, available_minutes // count) - return min(_MAX_VISIT_TIME_MINUTES, start + max(0, index) * step) - - def apply_visit_time_policy( places: list[dict], *, @@ -172,11 +172,11 @@ def apply_visit_time_policy( resolved_config = config or build_visit_time_policy_config() resolved_output_mode = _normalize_output_mode(output_mode) - proposals_provided = llm_proposals_by_sequence is not None + proposals_provided = llm_proposals_by_sequence is not None and resolved_output_mode == VisitTimeOutputMode.HHMM proposals = llm_proposals_by_sequence or {} warnings: list[str] = [] previous_minutes: int | None = None - total_count = len(places) + baseline_minutes = _build_baseline_minutes(len(places), resolved_config.start_minutes) for index, place in enumerate(places): sequence_raw = place.get("visit_sequence") @@ -186,7 +186,7 @@ def apply_visit_time_policy( sequence = index + 1 assigned_time: int | None = None - if resolved_output_mode == VisitTimeOutputMode.HHMM and proposals_provided: + if proposals_provided: proposed_time = proposals.get(sequence) proposed_minutes = _parse_valid_visit_time(proposed_time) if proposed_time and proposed_minutes is None: @@ -204,7 +204,7 @@ def apply_visit_time_policy( warnings.append(f"day={day_number} sequence={sequence} missing_visit_time; fallback applied") if assigned_time is None: - assigned_time = _fallback_minutes_for_position(index, total_count, resolved_config) + assigned_time = baseline_minutes[index] if previous_minutes is not None and assigned_time < previous_minutes: assigned_time = previous_minutes diff --git a/app/graph/roadmap/nodes/finalize.py b/app/graph/roadmap/nodes/finalize.py index 479ddf2..f9c753e 100644 --- a/app/graph/roadmap/nodes/finalize.py +++ b/app/graph/roadmap/nodes/finalize.py @@ -18,25 +18,19 @@ from app.core.region_bbox import get_region_bbox from app.core.route_optimizer import is_food_anchor, optimize_daily_route from app.core.timeout_policy import get_timeout_policy -from app.core.visit_time_policy import VisitTimeOutputMode, apply_visit_time_policy, build_visit_time_policy_config +from app.core.visit_time_llm import propose_visit_times_for_days +from app.core.visit_time_policy import ( + VisitTimeOutputMode, + apply_visit_time_policy, + build_visit_time_policy_config, +) from app.graph.roadmap.state import RoadmapState from app.graph.roadmap.utils import build_slot_key, strip_code_fence from app.schemas.course import CourseRequest, CourseResponseLLMOutput -from app.schemas.enums import PlanningPreference from app.services.webhook_notification import notify_pipeline_event logger = get_logger(__name__) -_SECTION_VISIT_TIME_MAP = { - "MORNING": "09:00", - "LUNCH": "12:00", - "AFTERNOON": "14:00", - "DINNER": "18:00", - "EVENING": "20:00", - "NIGHT": "22:00", -} -_DEFAULT_VISIT_TIME = "09:00" - async def _notify_pipeline_event_best_effort(**kwargs) -> None: """Discord 웹훅 알림은 실패해도 로드맵 합성 흐름을 막지 않습니다.""" @@ -100,25 +94,17 @@ def _fallback_grace_timeout(timeout_seconds: int) -> int: return max(1, int(timeout_seconds) * 2) -def _visit_time_from_section(section: str | None) -> str: - """스켈레톤 section을 대략적인 방문 시각으로 변환합니다.""" - key = str(section or "").strip().upper() - return _SECTION_VISIT_TIME_MAP.get(key, _DEFAULT_VISIT_TIME) - - -def _resolve_visit_time_output_mode(planning_preference: PlanningPreference) -> VisitTimeOutputMode: - if planning_preference == PlanningPreference.PLANNED: - return VisitTimeOutputMode.HHMM - return VisitTimeOutputMode.SECTION_EN - - -def _apply_route_and_visit_time_policy(daily_places: list[dict], course_request: CourseRequest) -> list[dict]: +async def _apply_route_and_visit_time_policy( + daily_places: list[dict], + *, + llm_proposals_by_day: dict[int, dict[int, str]] | None = None, +) -> list[dict]: """일자별 장소 순서를 최적화하고 visit_sequence/visit_time을 재계산합니다.""" - output_mode = _resolve_visit_time_output_mode(course_request.planning_preference) policy_config = build_visit_time_policy_config() total_before = sum(len(day.get("places", [])) for day in daily_places) changed_days: list[int] = [] warnings: list[str] = [] + llm_proposals: dict[int, dict[int, str]] = llm_proposals_by_day or {} for day in daily_places: places = day.get("places", []) @@ -139,11 +125,13 @@ def _apply_route_and_visit_time_policy(daily_places: list[dict], course_request: place["section_hint"] = section place.pop("section", None) + day_number = day.get("day_number", 1) resolved_places, new_warnings = apply_visit_time_policy( optimized_places, - day_number=day.get("day_number"), + day_number=day_number, config=policy_config, - output_mode=output_mode, + output_mode=VisitTimeOutputMode.HHMM, + llm_proposals_by_sequence=llm_proposals.get(day_number), ) day["places"] = resolved_places warnings.extend(new_warnings) @@ -151,11 +139,23 @@ def _apply_route_and_visit_time_policy(daily_places: list[dict], course_request: append_job_log( "route_optimize", f"days_changed={changed_days} total_places={total_before} " - f"visit_time_mode={output_mode.value} warnings={len(warnings)}", + f"visit_time_mode={VisitTimeOutputMode.HHMM.value} warnings={len(warnings)}", ) return daily_places +def _build_itinerary_context(daily_places: list[dict]) -> str: + """일자별 장소 목록을 summary 프롬프트용 텍스트로 변환합니다.""" + context_lines: list[str] = [] + for day in daily_places: + context_lines.append(f"\nDay {day['day_number']} ({day['daily_date']}):") + for place in day.get("places", []): + context_lines.append( + f"- #{place.get('visit_sequence')} {place.get('visit_time')}: {place.get('place_name')}" + ) + return "\n".join(context_lines) + + def _prepare_final_context( state: RoadmapState, ) -> tuple[str, list[dict]]: @@ -176,14 +176,12 @@ def _prepare_final_context( except Exception as exc: raise ValueError(f"CourseRequest 모델 유효성 검증에 실패했습니다: {exc}") from exc - context_lines = [] daily_places_for_schema = [] for day_plan in skeleton_plan: day_number = day_plan["day_number"] day_region = day_plan.get("region") day_region_bbox = get_region_bbox(day_region) current_date = course_request.start_date + timedelta(days=day_number - 1) - context_lines.append(f"\nDay {day_number} ({current_date.strftime('%Y-%m-%d')}):") day_places = [] visit_sequence_counter = 1 @@ -218,7 +216,6 @@ def _prepare_final_context( or resolve_place_category(place.get("primary_type"), place.get("types") or []).value, "description": f"{display_name}에서 즐기는 대표 활동입니다.", "visit_sequence": visit_sequence_counter, - "visit_time": _visit_time_from_section(section), "section": section, } ) @@ -228,16 +225,28 @@ def _prepare_final_context( {"day_number": day_number, "daily_date": current_date.isoformat(), "places": day_places} ) - daily_places_for_schema = _apply_route_and_visit_time_policy(daily_places_for_schema, course_request) - context_lines = [] + policy_config = build_visit_time_policy_config() for day in daily_places_for_schema: - context_lines.append(f"\nDay {day['day_number']} ({day['daily_date']}):") - for place in day.get("places", []): - context_lines.append( - f"- #{place.get('visit_sequence')} {place.get('visit_time')}: {place.get('place_name')}" - ) + places = day.get("places", []) + if not places: + continue - return "\n".join(context_lines), daily_places_for_schema + day_number = day.get("day_number", 1) + optimized_places = optimize_daily_route(places) + for index, place in enumerate(optimized_places, start=1): + place["visit_sequence"] = index + place.pop("visit_time", None) + place.pop("section", None) + + resolved_places, _warnings = apply_visit_time_policy( + optimized_places, + day_number=day_number, + config=policy_config, + output_mode=VisitTimeOutputMode.HHMM, + ) + day["places"] = resolved_places + + return _build_itinerary_context(daily_places_for_schema), daily_places_for_schema def _safe_next_action_suggestions(trip_days: int) -> list[str]: @@ -408,6 +417,13 @@ async def synthesize_final_roadmap(state: RoadmapState) -> RoadmapState: itinerary_context, daily_places = _prepare_final_context(state) course_request = CourseRequest.model_validate(state["course_request"]) daily_places = await _fill_place_descriptions_with_llm(daily_places) + visit_time_proposals = await propose_visit_times_for_days(daily_places) + daily_places = await _apply_route_and_visit_time_policy( + daily_places, + llm_proposals_by_day=visit_time_proposals, + output_mode=VisitTimeOutputMode.HHMM, + ) + itinerary_context = _build_itinerary_context(daily_places) desc_count = sum(1 for d in daily_places for p in d.get("places", []) if p.get("description")) append_job_log("finalize_desc", f"place_descriptions_filled={desc_count}") diff --git a/docs/architecture/roadmap-chat-server-communication.md b/docs/architecture/roadmap-chat-server-communication.md index 9b3aad1..da225a2 100644 --- a/docs/architecture/roadmap-chat-server-communication.md +++ b/docs/architecture/roadmap-chat-server-communication.md @@ -197,8 +197,8 @@ sequenceDiagram - 일차 간 재배치 후 영향을 받은 모든 일차의 `visit_sequence`는 1부터 순차 정렬합니다. - `diff_keys`는 기존 장소 카드 단위 형식인 `dayN_placeM`만 사용하며 day 단위 key는 추가하지 않습니다. - `planning_preference == PLANNED`이면 수정된 일차에 대해 LLM visit_time 제안을 생성하고, 서버가 `08:00` 이상 `24:00` 이하 `HH:MM` 형식과 시간 비감소 조건을 검증한 뒤 반영합니다. -- LLM 제안이 누락되거나 유효하지 않아도 수정 결과는 실패시키지 않고 공용 fallback 분배 정책으로 보정합니다. -- `planning_preference == SPONTANEOUS`이면 visit_time LLM 제안은 호출하지 않고 section label 기반 값을 사용합니다. +- LLM 제안이 누락되거나 유효하지 않아도 수정 결과는 실패시키지 않고 시간 역전 방지 로직을 거쳐 반영합니다. +- `planning_preference == SPONTANEOUS`인 경우에는 visit_time LLM 제안을 호출하지 않고, 스켈레톤의 섹션 라벨(SECTION_EN)을 그대로 사용합니다. - 콜백 전송 기본 타임아웃은 `CALLBACK_TIMEOUT_SECONDS`이며 기본값은 10초입니다. - 콜백 전송은 timeout, connection error, HTTP 429, HTTP 5xx에 대해 재시도합니다. @@ -210,4 +210,4 @@ sequenceDiagram # TODO -- NestJS의 채팅 상태 조회와 대화 이력 저장 정책이 확정되면 Client -> NestJS 구간을 보강합니다. +- NestJS의 채팅 상태 조회와 대화 이력 저장 정책이 확정되면 Client -> NestJS 구간을 보강합 \ No newline at end of file diff --git a/docs/reports/roadmap-generation-performance-improvement-proposal.md b/docs/reports/roadmap-generation-performance-improvement-proposal.md index 08f0e2a..e9713c5 100644 --- a/docs/reports/roadmap-generation-performance-improvement-proposal.md +++ b/docs/reports/roadmap-generation-performance-improvement-proposal.md @@ -1,6 +1,6 @@ --- title: "로드맵 생성 시간 개선안 보고" -status: "review" +status: "approved" author: "@codex" created_at: 2026-04-28 tags: [reports, roadmap, generation, performance, proposal] @@ -36,9 +36,8 @@ ai_action: "editable" - 장소명 정규화 단계는 제거한다. - 한국어 정보가 없는 장소는 영어 또는 현지어 원본명이 실제 간판명과 일치할 가능성이 높으므로 Google Places 원본명을 유지한다. -- 방문 시간 계산 단계는 제거한다. -- 방문 시간은 skeleton section을 기준으로 `MORNING=09:00`, `LUNCH=12:00` 같은 단순 매핑을 사용한다. -- 목표는 정확한 이동/체류 시간이 아니라 대략적인 시간대 제공이다. ++ 방문 시간 계산 단계는 유지한다. (2026-05-14 결정: 생성/수정 모두 LLM 기반 시간 추론 적용, 30분 단위 제한) ++ 목표는 성능 저하를 감수하더라도 사용자 성향에 맞는 자연스럽고 깔끔한 방문 시각 제공이다. ## 1. 장소명 정규화 제거 @@ -52,11 +51,9 @@ ai_action: "editable" ## 2. 방문 시간 계산 제거 - 대상: `synthesize_final_roadmap` -- 내용: 방문 시간 제안 LLM과 이동/체류 시간 계산을 제거하고 section 기반 정적 시각을 사용한다. -- 구현 방식: `MORNING=09:00`, `LUNCH=12:00`, `AFTERNOON=14:00`, `DINNER=18:00`, `EVENING=20:00`, `NIGHT=22:00`으로 매핑한다. -- 예상 효과: 평균 73.300초 병목을 줄일 수 있다. -- 리스크: 방문 시간이 정밀하지 않고 같은 section의 장소가 같은 시간으로 표시될 수 있다. -- 필요 테스트: `CourseResponse` 스키마 유지, description/visit_time 누락 방지, PLANNED/SPONTANEOUS 출력 유지. ++ 내용: 반려됨. 성능보다 결과물의 품질(현실적인 시각 배치)을 우선하여 LLM 호출을 유지하기로 함. ++ 상태: **REJECTED** ++ 사유: 정적 매핑은 동선 최적화 결과와 어긋나는 경우가 많아 사용자 경험을 저해함. ## 3. Timeout 정책 분리 및 stage 관측성 강화 @@ -86,7 +83,7 @@ ai_action: "editable" 1차 개선으로 다음 2개를 적용한다. 1. `normalize_place_names` 제거 -2. 방문 시간 계산 제거 및 section 기반 정적 매핑 적용 +2. (추가) 생성 단계에서도 `propose_visit_times_for_days`를 통한 LLM 시간 제안 적용 이 조합은 가장 큰 병목 2개를 직접 줄인다. `fetch_places_from_slots`와 skeleton 경량화는 1차 개선 후 재측정 결과를 보고 추가 적용 여부를 결정하는 것이 안전하다. diff --git a/docs/specs/roadmap-chat-modification.md b/docs/specs/roadmap-chat-modification.md index cd881e6..dbd9f05 100644 --- a/docs/specs/roadmap-chat-modification.md +++ b/docs/specs/roadmap-chat-modification.md @@ -128,17 +128,16 @@ REPLACE/ADD 검색어에는 현재 일자의 주소에서 추출한 지역 힌 ### 4. `propose_visit_time` -`planning_preference == PLANNED`인 경우에만 수정된 일자의 장소 목록을 기준으로 LLM이 방문 시간 후보를 제안합니다. +`planning_preference == PLANNED`인 경우 생성/수정 파이프라인 모두 수정된 일자의 장소 목록을 기준으로 LLM이 30분 단위의 방문 시간 후보를 제안합니다. LLM 제안은 다음 `cascade` 단계에서 형식, 허용 범위, 방문 순서 기준 시간 비감소 조건을 검증한 뒤 최종 `visit_time` 결정값으로 사용됩니다. -`SPONTANEOUS` 로드맵에서는 이 노드가 LLM을 호출하지 않고 빈 proposal을 반환합니다. ### 5. `cascade` 변경된 일자만 대상으로 방문 시간과 순서를 재정렬하고 제약을 검증합니다. 단일 일차 동선 최적화, 일차 간 MOVE, 일차 일정 묶음 교체처럼 하나 이상의 일차가 바뀌는 경우, `diff_keys`에서 추출한 모든 일차에 같은 정책을 적용합니다. -`planning_preference`가 `PLANNED`이면 `08:00` 이상 `24:00` 이하의 `HH:MM` 형식을 사용하고, LLM 제안이 누락되거나 유효하지 않은 구간은 서버 fallback으로 보정합니다. +`planning_preference`가 `PLANNED`이면 LLM이 제안한 `08:00` 이상 `24:00` 이하의 `HH:MM` 형식을 사용하고, LLM 제안이 누락되거나 유효하지 않은 구간은 이전 시각을 유지하는 방식으로 보정합니다. `24:00`은 유효한 종료 시각으로 허용하지만 `24:01` 이상은 거부합니다. -`SPONTANEOUS`이면 LLM 제안 없이 section 기반 값을 사용합니다. +`SPONTANEOUS`이면 내부적으로 LLM이 계산한 시각을 바탕으로 `MORNING`/`LUNCH` 등 섹션 영문 라벨로 변환하여 출력합니다. 시간 검증, fallback, 경고 생성은 공용 visit time policy를 사용합니다. ### 6. `respond` @@ -236,4 +235,4 @@ LLM 제안은 다음 `cascade` 단계에서 형식, 허용 범위, 방문 순서 # TODO - 영업시간 검증이 필요하면 Google Places 상세 응답과 visit_time 정책을 연결하는 별도 검증 단계를 설계합니다. -- 복합 수정 요청을 여러 operation으로 분해해 순차 적용할지 결정합니다. +- 복합 수정 요청을 여러 operation으로 분해해 순차 적용할�� \ No newline at end of file diff --git a/docs/specs/roadmap-generation.md b/docs/specs/roadmap-generation.md index 2524059..ba1cdd6 100644 --- a/docs/specs/roadmap-generation.md +++ b/docs/specs/roadmap-generation.md @@ -131,9 +131,9 @@ LLM은 특정 상호명 대신 `section`, `area`, `keyword` 중심의 검색용 - 설명 생성 실패 또는 타임아웃 시 기본 설명을 적용합니다. - 최적화된 순서 기준으로 `visit_sequence`를 1부터 다시 부여합니다. - `visit_time`은 최종 `visit_sequence`와 `planning_preference` 기준으로 `app/core/visit_time_policy.py` 정책을 적용해 재계산합니다. -- 로드맵 생성 파이프라인은 별도 visit_time LLM 제안을 호출하지 않고, 공용 fallback 분배 정책으로 `08:00` 이상 `24:00` 이하의 값을 생성합니다. -- `planning_preference == PLANNED`이면 fallback 분배 결과를 `HH:MM`으로 출력합니다. -- `planning_preference == SPONTANEOUS`이면 fallback 분배 결과를 `MORNING`/`LUNCH` 같은 section label로 변환해 출력합니다. +- `planning_preference == PLANNED`인 경우 로드맵 생성 및 수정 파이프라인 모두 `propose_visit_times_for_days` LLM 제안을 호출하여 `08:00` 이상 `24:00` 이하의 30분 단위(00분, 30분) 방문 시각을 결정합니다. +- `planning_preference == PLANNED`이면 LLM이 제안한 구체적인 시각을 `HH:MM`으로 출력합니다. +- `planning_preference == SPONTANEOUS`이면 내부 계산 결과를 `MORNING`/`LUNCH` 같은 section label로 변환해 출력합니다. - `visit_time` 재계산은 좌표 기반 이동시간, 체류시간, 자정 초과 검증을 수행하지 않습니다. - LLM으로 `title`, `summary`, `tags`, `llm_commentary`를 생성합니다. - `next_action_suggestion`은 LLM 결과를 그대로 쓰지 않고 시스템에서 지원 가능한 문장만 안전하게 주입합니다. diff --git a/tests/test_roadmap_generation_simplified_pipeline.py b/tests/test_roadmap_generation_simplified_pipeline.py index ec75abb..ceb3df8 100644 --- a/tests/test_roadmap_generation_simplified_pipeline.py +++ b/tests/test_roadmap_generation_simplified_pipeline.py @@ -7,7 +7,6 @@ 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 @@ -47,16 +46,6 @@ def test_roadmap_workflow_skips_place_name_normalization() -> None: assert ("fetch_places_from_slots", "synthesize_final_roadmap") in edges -def test_visit_time_from_section_uses_static_hhmm_mapping() -> None: - assert _visit_time_from_section("MORNING") == "09:00" - assert _visit_time_from_section("LUNCH") == "12:00" - assert _visit_time_from_section("AFTERNOON") == "14:00" - assert _visit_time_from_section("DINNER") == "18:00" - assert _visit_time_from_section("EVENING") == "20:00" - assert _visit_time_from_section("NIGHT") == "22:00" - assert _visit_time_from_section("unknown") == "09:00" - - def test_prepare_final_context_uses_original_place_name_and_visit_time_policy() -> None: state = { "course_request": _base_course_request(), @@ -204,7 +193,7 @@ def test_prepare_final_context_reorders_places_and_recalculates_planned_visit_ti assert "C" in itinerary_context -def test_prepare_final_context_uses_sequence_labels_for_spontaneous_visit_time() -> None: +def test_prepare_final_context_uses_hhmm_baseline_for_spontaneous_visit_time() -> None: request = _base_course_request() request["planning_preference"] = "SPONTANEOUS" state = { @@ -237,7 +226,7 @@ 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" + assert place["visit_time"] == "09:00" def test_segment_prompt_prioritizes_user_notes_when_present() -> None: