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
1 change: 1 addition & 0 deletions app/core/visit_time_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"운영시간 데이터가 없으므로 실제 영업시간을 단정하지 마세요."
Expand Down
30 changes: 15 additions & 15 deletions app/core/visit_time_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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],
*,
Expand All @@ -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")
Expand All @@ -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:
Expand All @@ -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
Expand Down
96 changes: 56 additions & 40 deletions app/graph/roadmap/nodes/finalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 웹훅 알림은 실패해도 로드맵 합성 흐름을 막지 않습니다."""
Expand Down Expand Up @@ -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", [])
Expand All @@ -139,23 +125,37 @@ 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)

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]]:
Expand All @@ -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
Expand Down Expand Up @@ -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,
}
)
Expand All @@ -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]:
Expand Down Expand Up @@ -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}")

Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/roadmap-chat-server-communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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에 대해 재시도합니다.

Expand All @@ -210,4 +210,4 @@ sequenceDiagram

# TODO

- NestJS의 채팅 상태 조회와 대화 이력 저장 정책이 확정되면 Client -> NestJS 구간을 보강합니다.
- NestJS의 채팅 상태 조회와 대화 이력 저장 정책이 확정되면 Client -> NestJS 구간을 보강합
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "로드맵 생성 시간 개선안 보고"
status: "review"
status: "approved"
author: "@codex"
created_at: 2026-04-28
tags: [reports, roadmap, generation, performance, proposal]
Expand Down Expand Up @@ -36,9 +36,8 @@ ai_action: "editable"

- 장소명 정규화 단계는 제거한다.
- 한국어 정보가 없는 장소는 영어 또는 현지어 원본명이 실제 간판명과 일치할 가능성이 높으므로 Google Places 원본명을 유지한다.
- 방문 시간 계산 단계는 제거한다.
- 방문 시간은 skeleton section을 기준으로 `MORNING=09:00`, `LUNCH=12:00` 같은 단순 매핑을 사용한다.
- 목표는 정확한 이동/체류 시간이 아니라 대략적인 시간대 제공이다.
+ 방문 시간 계산 단계는 유지한다. (2026-05-14 결정: 생성/수정 모두 LLM 기반 시간 추론 적용, 30분 단위 제한)
+ 목표는 성능 저하를 감수하더라도 사용자 성향에 맞는 자연스럽고 깔끔한 방문 시각 제공이다.

## 1. 장소명 정규화 제거

Expand All @@ -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 관측성 강화

Expand Down Expand Up @@ -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차 개선 후 재측정 결과를 보고 추가 적용 여부를 결정하는 것이 안전하다.
Expand Down
9 changes: 4 additions & 5 deletions docs/specs/roadmap-chat-modification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -236,4 +235,4 @@ LLM 제안은 다음 `cascade` 단계에서 형식, 허용 범위, 방문 순서
# TODO

- 영업시간 검증이 필요하면 Google Places 상세 응답과 visit_time 정책을 연결하는 별도 검증 단계를 설계합니다.
- 복합 수정 요청을 여러 operation으로 분해해 순차 적용할지 결정합니다.
- 복합 수정 요청을 여러 operation으로 분해해 순차 적용할��
6 changes: 3 additions & 3 deletions docs/specs/roadmap-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 결과를 그대로 쓰지 않고 시스템에서 지원 가능한 문장만 안전하게 주입합니다.
Expand Down
Loading
Loading