Skip to content

[Feat] Mock 기반 로드맵 생성 전체 워크플로우 구현#23

Merged
zweadfx merged 18 commits into
mainfrom
feat/synthesize-final-roadmap
Feb 6, 2026
Merged

[Feat] Mock 기반 로드맵 생성 전체 워크플로우 구현#23
zweadfx merged 18 commits into
mainfrom
feat/synthesize-final-roadmap

Conversation

@zweadfx

@zweadfx zweadfx commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

1. 개요

Phase 1 (skeleton) 부터 Phase 4 (synthesis) 까지 이어지는 로드맵 생성 전체 워크플로우의 기본 구조를 구현합니다.

외부 API 의존성을 제거하고 기능의 흐름을 검증하기 위해, GooglePlacesService를 Mock Service로 구현하여 적용했습니다. 실제 API 연동은 후속 이슈에서 진행될 예정입니다.

2. 작업 내용

  • Schema 정의: Google Places API 응답을 표준화하기 위한 Place Pydantic 모델 정의 (schemas/place.py)
  • Mock Service 구현: 샘플 데이터를 반환하고, 장소 필터링 및 선택 로직을 포함하는 MockGooglePlacesService 구현 (tests/mocks/mock_places_service.py)
  • Node 구현: 장소 후보를 수집하는 fetch_places_from_slots와 최종 결과를 종합하는 synthesize_final_roadmap 노드 함수 구현
  • State 확장: fetched_placesfinal_roadmap 필드를 RoadmapState에 추가
  • Workflow 연결: generate_skeletonfetch_places_from_slotssynthesize_final_roadmap 순서로 전체 노드를 연결하여 워크플로우 완성

3. AI 활용 및 검증

  • AI가 생성한 코드 포함

  • 100% 직접 작성

  • 검증 방법:

    • 임시 테스트 스크립트를 작성 및 실행하여, 전체 워크플로우가 에러 없이 동작하고 최종 final_roadmap 상태가 CourseResponse 스키마에 맞게 생성됨을 확인했습니다.
    • uv run pre-commit run --all-files 명령어를 실행하여 모든 코드 품질 검사를 통과함을 확인했습니다.

4. 스크린샷 (선택)

  • 해당 없음

5. 체크리스트

  • uv run pre-commit run --all-files를 실행하여 통과했는가?
  • 스스로 코드를 한 번 리뷰했는가? (AI가 짠 코드 맹신 금지)
  • 불필요한 주석이나 print 문을 제거했는가?

Summary by CodeRabbit

  • 새로운 기능

    • LLM 기반 최종 여행 로드맵 자동 합성(제목·태그·코멘터리·다음 제안 포함) 추가.
    • 합성 결과를 보관하는 final_roadmap 필드로 결과 보존 지원.
    • 샘플 최종 결과 파일(final_result.json) 추가 — 2일 도쿄 일정 예시 포함.
  • 데이터 모델 변경

    • 장소 항목에서 category 제거, photo_reference 및 description 추가.
    • 응답 모델에 시작/종료일·여행일수·숙박일수·인원·태그 등 메타데이터 및 LLM 출력용 형식 추가.
  • 워크플로우

    • 로드맵 생성 흐름에 최종 합성 단계 추가 및 종료 연결 변경.
  • 테스트

    • 모의 장소 데이터 일부 명칭을 한국어로 변경 및 좌표 소수점 조정.

   - 최종 로드맵 결과 저장을 위해 그래프 상태(RoadmapState)에 final_roadmap 필드 추가
   - Phase 4에 해당하는 synthesize_final_roadmap 노드 로직 추가
   - 이전 단계 데이터를 조합하여 LLM에 최종 응답 생성을 요청
   - PydanticOutputParser를 사용하여 CourseResponse 스키마 준수
   - synthesize_final_roadmap 노드를 그래프에 추가
   - fetch_places_from_slots → synthesize_final_roadmap 엣지를 연결하여 전체 워크플로우 완성
@zweadfx zweadfx requested a review from iamb0ttle as a code owner February 6, 2026 02:09
@zweadfx zweadfx linked an issue Feb 6, 2026 that may be closed by this pull request
7 tasks
@coderabbitai

coderabbitai Bot commented Feb 6, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@zweadfx has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 0 minutes and 54 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

최종 로드맵 생성 노드 synthesize_final_roadmap 추가 — 상태 유효성 검사, 최종 컨텍스트/일정 항목 생성, LLM 호출 및 PydanticOutputParser(CourseResponseLLMOutput)로 파싱해 RoadmapState.final_roadmap에 병합하고 워크플로우 경로를 갱신합니다.

Changes

Cohort / File(s) Summary
최종 로드맵 종합 노드
app/graph/roadmap/nodes.py
_prepare_final_context()synthesize_final_roadmap() 추가. 상태 키 검증, 시스템/휴먼 프롬프트 구성, LLM 호출, 코드펜스 제거, PydanticOutputParser(CourseResponseLLMOutput)로 파싱, daily places를 itinerary에 병합, 에러 로깅/전파 포함.
상태 스키마 확장
app/graph/roadmap/state.py
RoadmapState에 `final_roadmap: dict
워크플로우 연결 변경
app/graph/roadmap/workflow.py
synthesize_final_roadmap 노드 등록 및 import 추가. fetch_places_from_slots -> synthesize_final_roadmap로 경로 변경하고 새 엣지로 synthesize_final_roadmap -> END 추가.
스키마/모델 변경
app/schemas/course.py
CoursePlace: category 제거, photo_reference: Optional[str], description: str 추가. CourseResponse에 메타데이터 필드(start_date, end_date, trip_days, nights, people_count, tags) 추가. CourseResponseLLMOutput 모델 추가(LLM 파싱용).
테스트/모킹 데이터
tests/mocks/mock_places_service.py
모의 장소 데이터의 일부 문자열을 한국어로 변경 및 한 위도 값 소폭 수정 — 로직/인터페이스 변경 없음.
샘플 결과 데이터
final_result.json
새 JSON 파일 추가 — 2일치 도쿄 일정(메타데이터, itinerary 등) 샘플 페이로드 추가.

Sequence Diagram(s)

sequenceDiagram
    participant Workflow as Roadmap Workflow
    participant Node as synthesize_final_roadmap
    participant LLM as LLM Service
    participant Parser as Pydantic Parser
    participant State as RoadmapState

    Workflow->>Node: invoke(state with skeleton_plan, fetched_places, course_request)
    Node->>Node: _prepare_final_context(state) -> (itinerary_context, daily_places)
    Node->>LLM: send(system_prompt, human_prompt + itinerary_context)
    LLM-->>Node: raw_response (may include code fences)
    Node->>Node: strip code fences
    Node->>Parser: parse(raw_response) using CourseResponseLLMOutput
    Parser-->>Node: parsed_output
    Node->>State: merge parsed_output + daily_places -> final_roadmap
    Node-->>Workflow: return updated state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • iamb0ttle
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 제목이 PR의 주요 변경사항을 명확하게 설명하고 있습니다. Mock 기반으로 전체 로드맵 생성 워크플로우를 구현했다는 핵심 내용을 잘 전달합니다.
Description check ✅ Passed PR 설명이 템플릿의 대부분 섹션을 충실히 작성했으나, 관련 이슈 번호가 '#22'로만 표시되어 완전하지 않고 스크린샷 섹션이 '해당 없음'으로 처리되었습니다.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/synthesize-final-roadmap

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@zweadfx zweadfx changed the title Feat/synthesize final roadmap [Feat] Feb 6, 2026
@zweadfx zweadfx changed the title [Feat] [Feat] Mock 기반 로드맵 생성 전체 워크플로우 구현 Feb 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@app/graph/roadmap/nodes.py`:
- Line 395: The daily_places_for_schema entry stores a Python date object under
the key "daily_date" which will fail JSON serialization; update the code that
appends to daily_places_for_schema (the line building {"day_number": day_number,
"daily_date": current_date, "places": day_places}) to convert current_date to a
string (e.g., current_date.isoformat() or str(current_date)) so that
final_roadmap can be serialized without error; ensure any other usages that
expect a date object are adjusted accordingly.
- Around line 453-456: The LLM output currently includes an "itinerary" that is
both requested in the prompt and then immediately overwritten
(parsed_data["itinerary"] = daily_places), causing wasted tokens and brittle
parsing; create a new LLM-only Pydantic model (e.g., CourseResponseLLMOutput
with fields title, llm_commentary, next_action_suggestion) in
app/schemas/course.py, remove the "itinerary" instruction from the prompt (lines
~430-431), change the parser call in nodes.py to parse into
CourseResponseLLMOutput instead of CourseResponse (replace
parser.parse(content).model_dump() usage), and then compose the final roadmap by
merging the parsed LLM fields with the existing daily_places (instead of
overwriting parsed_data["itinerary"]).
🧹 Nitpick comments (3)
app/graph/roadmap/workflow.py (1)

20-23: 에러 발생 시 불필요한 노드 실행이 계속됩니다.

현재 워크플로우는 모든 노드가 무조건 순차 실행됩니다. 각 노드 내부에서 state.get("error") 체크 후 early return하는 방식은 작동하지만, 에러 발생 시에도 후속 노드가 호출되어 불필요한 오버헤드가 발생합니다.

LangGraph의 add_conditional_edges를 활용하면 에러 상태에서 즉시 END로 라우팅할 수 있습니다.

app/graph/roadmap/nodes.py (2)

382-394: 첫 번째 장소만 사용하는 하드코딩 — 실제 서비스 전환 시 문제 발생 가능.

주석에 "Mock 서비스에서 1개만 반환하므로"라고 명시되어 있지만, 실제 PlacesService는 여러 장소를 반환할 수 있습니다. 현재 코드는 places[0]만 사용하고 나머지를 무시합니다.

장소 선택(랭킹/필터링) 로직을 분리하거나, 최소한 TODO 주석을 남겨 실제 서비스 전환 시 대응해야 할 부분임을 명확히 해주세요.


460-462: 로거에서 f-string 대신 % 포매팅을 사용하세요.

logger.error(f"...") 형태는 로그 레벨이 비활성화되어도 항상 문자열 포매팅이 실행됩니다. 기존 코드(예: Line 187, 225)에서 %s 포매팅을 사용하고 있으므로 일관성을 유지하세요.

수정 제안
-        logger.error(f"최종 로드맵 생성 실패: {e}", exc_info=True)
-        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}
+        logger.error("최종 로드맵 생성 실패: %s", e, exc_info=True)
+        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}

Comment thread app/graph/roadmap/nodes.py Outdated
Comment thread app/graph/roadmap/nodes.py Outdated
   - _prepare_final_context 함수에서 daily_date를 isoformat() 문자열로 변환하여 TypeError 발생 가능성 제거
- LLM의 역할을 텍스트 생성으로 명확히 하고, 불안정한 itinerary 파싱 로직 제거
- LLM 출력 전용 Pydantic 모델(CourseResponseLLMOutput) 추가

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/graph/roadmap/nodes.py (1)

296-303: ⚠️ Potential issue | 🟡 Minor

프로덕션 코드에서 tests.mocks를 import하고 있습니다.

TODO 주석으로 의도를 문서화했으나, 배포 방식에 따라 tests/ 패키지가 제외될 수 있습니다. 특히 wheel 빌드나 특정 CI/CD 파이프라인에서 테스트 디렉토리를 제외하면 런타임에 ModuleNotFoundError가 발생합니다. #22 이슈에서 실제 서비스로 교체될 때까지, Mock 클래스를 app/services/ 하위로 이동하거나 환경 변수 기반 분기를 추가하는 것을 권장합니다.

🤖 Fix all issues with AI agents
In `@app/graph/roadmap/nodes.py`:
- Around line 367-404: The function _prepare_final_context currently reads
state["skeleton_plan"] and state["fetched_places"] directly and can produce an
empty LLM context if those values are present but empty; add explicit validation
at the start of _prepare_final_context to ensure skeleton_plan is a non-empty
list and fetched_places is a non-empty dict (and that course_request.start_date
exists after CourseRequest.model_validate), and if any are missing/empty raise a
clear ValueError (or return a well-defined empty context marker) with a message
referencing the failing keys so the caller (synthesize_final_roadmap) can handle
it; this ensures you don’t pass empty context to the LLM and makes failures
observable.
🧹 Nitpick comments (4)
app/schemas/course.py (1)

120-125: CourseResponse와의 필드 중복을 상속으로 줄일 수 있습니다.

CourseResponseLLMOutput의 세 필드가 CourseResponse와 동일합니다. 향후 필드 설명이나 타입이 변경될 때 두 곳을 동기화해야 하는 부담이 생깁니다.

♻️ 상속 활용 제안
-class CourseResponseLLMOutput(BaseModel):
-    """LLM이 생성할 필드만 포함하는 파싱 전용 모델."""
-
-    title: str = Field(..., description="여행 로드맵의 제목")
-    llm_commentary: str = Field(..., description="코스 선정 이유 및 전체 흐름 설명")
-    next_action_suggestion: str = Field(..., description="사용자에게 제안할 다음 액션")
+class CourseResponseLLMOutput(BaseModel):
+    """LLM이 생성할 필드만 포함하는 파싱 전용 모델.
+
+    CourseResponse에서 itinerary를 제외한 텍스트 필드만 포함합니다.
+    """
+
+    title: str = Field(..., description="여행 로드맵의 제목")
+    llm_commentary: str = Field(..., description="코스 선정 이유 및 전체 흐름 설명")
+    next_action_suggestion: str = Field(..., description="사용자에게 제안할 다음 액션")

현재 필드가 3개로 적어 상속 도입의 실익이 크지 않으므로, 현 상태를 유지해도 무방합니다. 다만 필드가 늘어날 경우 CourseResponseLLMOutput을 기본 클래스로 삼고 CourseResponse가 이를 상속하는 구조를 고려해 주세요.

app/graph/roadmap/nodes.py (3)

467-469: logger.error에서 f-string 대신 % 포매팅을 사용하세요.

파일 내 다른 로깅 호출(예: Line 192, 230)은 %s 포매팅을 사용하는데, 여기만 f-string을 쓰고 있습니다. f-string은 로그 레벨과 무관하게 즉시 평가되며, Sentry 등 로그 집계 도구에서 동일 에러를 그룹핑하지 못하게 됩니다.

♻️ 수정 제안
-        logger.error(f"최종 로드맵 생성 실패: {e}", exc_info=True)
-        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}
+        logger.error("최종 로드맵 생성 실패: %s", e, exc_info=True)
+        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}

447-451: course_request가 raw dict로 프롬프트에 삽입되어 LLM이 Python repr을 받게 됩니다.

Line 448에서 state["course_request"] (dict)가 그대로 {course_request} 자리에 들어갑니다. str(dict)datetime.date(2026, 1, 15) 같은 Python repr을 포함하므로 LLM 입력 품질이 떨어질 수 있습니다.

_prepare_final_context에서 이미 CourseRequest.model_validate()를 하고 있으므로, 해당 객체의 .model_dump_json()을 활용하거나 별도로 사람이 읽기 좋은 요약 문자열을 만들어 전달하는 것을 권장합니다.

♻️ 예시
-        # 1. LLM에 전달할 컨텍스트 데이터 준비
-        itinerary_context, daily_places = _prepare_final_context(state)
-        course_request = state["course_request"]
+        # 1. LLM에 전달할 컨텍스트 데이터 준비
+        itinerary_context, daily_places = _prepare_final_context(state)
+        course_req = CourseRequest.model_validate(state["course_request"])
+        course_request_json = course_req.model_dump_json(indent=2)

프롬프트에서도:

         messages = prompt.format_messages(
-            course_request=course_request,
+            course_request=course_request_json,
             itinerary_context=itinerary_context,

참고: _prepare_final_context 내에서도 Line 373에서 이미 CourseRequest.model_validate()를 수행하고 있어 중복 파싱이 발생합니다. 컨텍스트 준비 시 함께 반환하면 효율적입니다.


388-399: places[0] 하드코딩은 Mock 전용임을 인지하되, 실 API 전환 시 선택 로직이 필요합니다.

현재 Mock 서비스가 1개만 반환하므로 places[0]이 합리적이지만, 실제 Google Places API 연동(#22) 시에는 여러 후보 중 최적 장소를 선택하는 로직이 필요합니다. 현재 코드 구조에서는 이 선택 로직을 _prepare_final_context 안에 넣게 되는데, 관심사 분리 차원에서 별도 함수로 분리하는 것이 좋습니다.

Comment thread app/graph/roadmap/nodes.py
   - LLM 프롬프트를 수정하여 title이 한국어로 생성되도록 변경
   - Mock 서비스의 장소 이름을 한국어로 수정
   - planning_preference에 따라 visit_time을 시간 또는 시간대로 분기 처리

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@app/graph/roadmap/nodes.py`:
- Around line 393-415: The visit_sequence uses the loop index i (global slot
index) which becomes discontinuous when some slots have no places; change to a
per-day counter (e.g., initialize sequence = 1 at start of the day loop or
compute len(day_places)+1) and increment it only when you append a place to
day_places so visit_sequence is continuous; update the block that builds
day_places (references: day_places, i, slot, visit_sequence, _build_slot_key,
fetched_places) to assign visit_sequence from that counter instead of i+1 and
reset the counter for each new day.
🧹 Nitpick comments (2)
app/graph/roadmap/nodes.py (2)

434-468: course_request를 프롬프트에 전달할 때 원시 dict가 그대로 문자열화될 수 있습니다.

Line 435에서 state["course_request"]를 그대로 가져오고, Line 465에서 프롬프트 템플릿에 전달합니다. state["course_request"]가 dict인 경우 Python의 dict repr({'key': 'value'})로 렌더링되어 LLM이 파싱하기 어려운 형태가 됩니다. _prepare_final_context에서는 이미 CourseRequest.model_validate()를 수행하므로(Line 374), 그 결과를 함께 반환하거나 여기서도 동일하게 변환 후 전달하는 것이 좋습니다.

♻️ 수정 제안
         # 1. LLM에 전달할 컨텍스트 데이터 준비
         itinerary_context, daily_places = _prepare_final_context(state)
-        course_request = state["course_request"]
+        course_request = (
+            state["course_request"]
+            if isinstance(state["course_request"], CourseRequest)
+            else CourseRequest.model_validate(state["course_request"])
+        )

484-486: logger.error에서 f-string 대신 % 포맷팅을 사용하세요.

파일 내 다른 로거 호출(Line 193, 231, 237, 352)은 모두 %s lazy formatting을 사용합니다. f-string은 로그 레벨과 무관하게 즉시 평가되며, 일관성도 떨어집니다.

♻️ 수정 제안
-        logger.error(f"최종 로드맵 생성 실패: {e}", exc_info=True)
-        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}
+        logger.error("최종 로드맵 생성 실패: %s", e, exc_info=True)
+        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}

Comment thread app/graph/roadmap/nodes.py
   - LLM 프롬프트를 수정하여 title이 한국어로 생성되도록 변경
   - Mock 서비스의 장소 이름을 한국어로 수정
   - planning_preference에 따라 visit_time을 시간 또는 시간대로 분기 처리
   - CoursePlace 스키마에 photo_reference 및 description 필드 추가
   - 최종 데이터 생성 시 photo_reference와 임시 description을 포함하도록 수정
   - CoursePlace 스키마의 category 필드를 tags: list[str]로 변경
   - Mock 데이터의 types를 한글 태그로 변환하여 tags 필드에 저장하도록 로직 수정
   - 함수 초반에 입력 데이터(state)에 대한 유효성 검증 로직 추가

   - 비어있거나 유효하지 않은 데이터가 LLM 컨텍스트로 전달되는 것을 방지
- _prepare_final_context 함수에 일자별 카운터를 추가하여 visit_sequence가 항상 1부터 시작하도록 수정
- 빈 슬롯이 있을 경우 visit_sequence 번호가 건너뛰어지던 버그 해결
- CourseResponse 스키마에 start_date, end_date, trip_days, nights, people_count 필드 추가
- synthesize_final_roadmap 노드에서 해당 필드들을 채우도록 로직 수정
- _create_roadmap_workflow 함수에 docstring 추가
- 요구사항 변경에 따라, CoursePlace의 tags를 CourseResponse의 최상위 필드로 이동
- 사용자 입력(travel_themes)을 기반으로 여행 전체 태그를 생성하도록 로직 수정

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/schemas/course.py (1)

93-101: ⚠️ Potential issue | 🟡 Minor

CoursePlace 스키마의 description 필드 — 향후 검증 단계 추가 필요.

category 필드가 제거되고 photo_referencedescription이 추가되었습니다. description은 required 필드로, DailyItineraryCourseResponse로 이어지는 검증 체인에서 누락되면 ValidationError가 발생합니다.

현재 코드는 nodes.py 라인 427에서 모든 장소에 placeholder 설명을 제공하므로 문제없으나, 향후 외부 API 데이터를 직접 사용하거나 스키마 검증(CourseResponse.model_validate())을 도입할 때는 description 필드가 항상 채워지도록 보장해야 합니다.

🤖 Fix all issues with AI agents
In `@app/graph/roadmap/nodes.py`:
- Around line 515-526: final_roadmap currently inserts
course_request_data["start_date"] and ["end_date"] (datetime.date objects)
directly which will fail JSON serialization; convert both to strings using
.isoformat() when building final_roadmap (same approach used for daily_date) so
start_date and end_date are JSON-serializable while preserving trip_days/nights
and other fields from llm_output and daily_places.
🧹 Nitpick comments (5)
app/schemas/course.py (1)

115-127: CourseResponse 메타데이터 필드 — 타이핑 스타일 혼용.

tags: list[str](Line 121)은 소문자 list를 사용하고, itinerary: List[DailyItinerary](Line 125)와 places: List[CoursePlace](Line 109) 등은 typing.List를 사용하고 있습니다. Python 3.9+ 기준 기능상 차이는 없지만, 파일 내 일관성을 유지하면 가독성이 높아집니다.

app/graph/roadmap/nodes.py (4)

17-23: PlanningPreferenceapp.schemas.course에서 간접 임포트하고 있습니다.

PlanningPreferenceapp.schemas.enums에 정의되어 있고, course.py가 이를 재수출(re-export)하는 형태입니다. 동작상 문제는 없지만, enum의 정의 출처(app.schemas.enums)에서 직접 가져오는 것이 의존 관계를 더 명확하게 합니다. PacePreference(Line 20)도 마찬가지입니다.

♻️ 제안
 from app.schemas.course import (
     CourseRequest,
     CourseResponseLLMOutput,
-    PacePreference,
-    PlanningPreference,
     RegionDateRange,
 )
+from app.schemas.enums import PacePreference, PlanningPreference

450-453: course_request 변수가 중복 할당되고 있습니다.

Line 453에서 course_request = state["course_request"]를 할당하고, Line 494에서 course_request_data = state["course_request"]를 다시 할당합니다. 동일한 데이터에 대해 이름이 다른 두 변수가 존재하여 가독성이 떨어집니다. 하나로 통합하는 것이 좋습니다.

♻️ 제안
         # 1. LLM에 전달할 컨텍스트 데이터 준비
         itinerary_context, daily_places = _prepare_final_context(state)
-        course_request = state["course_request"]
+        course_request_data = state["course_request"]
 
         # 2. LLM 출력 파서 설정 (itinerary 제외)
         parser = PydanticOutputParser(pydantic_object=CourseResponseLLMOutput)

Line 483도 함께 수정:

         messages = prompt.format_messages(
-            course_request=course_request,
+            course_request=course_request_data,
             itinerary_context=itinerary_context,

그리고 Line 494의 중복 할당 제거:

-        course_request_data = state["course_request"]
         trip_days = state["trip_days"]

Also applies to: 494-494


530-531: logger.error에서 f-string 대신 %s 포매팅을 사용하세요.

Line 531에서 f"최종 로드맵 생성 실패: {e}"는 로깅 시점에 항상 문자열 포매팅이 수행됩니다. logger.error("최종 로드맵 생성 실패: %s", e, exc_info=True) 형태가 lazy evaluation 측면에서 더 적절하며, 파일 내 다른 logger.error 호출(Line 193, 231)과도 일관성이 유지됩니다.

♻️ 제안
-        logger.error(f"최종 로드맵 생성 실패: {e}", exc_info=True)
-        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}
+        logger.error("최종 로드맵 생성 실패: %s", e, exc_info=True)
+        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}

499-512: theme_map_ko는 현재 TravelTheme enum과 동기화되어 있지만, 향후 enum 확장 시 불일치 위험이 있습니다.

현재 코드는 모든 enum 값이 딕셔너리 키로 존재하기 때문에 동기화 상태입니다. 그러나 13번 줄의 theme_map_ko.get(theme, theme) 구문에서 폴백 동작으로 인해, 새 테마가 enum에 추가되면 한글 번역 대신 원본 enum 값(예: "NEW_THEME")이 태그로 노출됩니다. 이는 동기화 부족을 침묵적으로 처리하므로 유지보수 단계에서 발견하기 어려울 수 있습니다.

enum 기반 매핑이나 동적 생성 같은 구조로 개선하는 것을 권장합니다.

Comment thread app/graph/roadmap/nodes.py
- LLM 프롬프트에 최종 일정 기반의 태그를 생성하도록 요청 추가
- CourseResponseLLMOutput 스키마에 tags 필드를 추가하여 LLM 응답 파싱
- 기존 travel_themes 기반의 수동 태그 생성 로직 제거
- CourseResponse의 start_date, end_date를 isoformat() 문자열로 변환하여 JSON 직렬화 오류 수정
- synthesize_final_roadmap에서 CourseRequest를 검증하여 date 타입을 보장하도록 수정
- 문자열에 .isoformat()을 호출하여 발생하던 AttributeError 해결
- 모든 수정사항이 반영된 그래프 실행 결과를 final_result.json으로 저장
iamb0ttle
iamb0ttle previously approved these changes Feb 6, 2026

@iamb0ttle iamb0ttle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@final_result.json`:
- Around line 22-37: Day-level duplicate places are appearing (same place_id
appears multiple times with different visit_sequence/visit_time); update either
fetch_places_from_slots or _prepare_final_context to deduplicate entries per day
by place_id: when combining slot results, collapse duplicates by keeping the
earliest visit_time (or first occurrence), merge/concatenate descriptions/photos
if desired, and reassign visit_sequence sequentially for that day's list; ensure
you reference and operate on the place_id, visit_time, visit_sequence fields so
the output JSON no longer contains repeated place_id entries within a single
day.
🧹 Nitpick comments (5)
final_result.json (1)

1-111: 테스트 스냅샷 파일이 프로젝트 루트에 위치해 있습니다.

final_result.json은 워크플로우 실행 결과의 스냅샷으로, tests/ 디렉토리(예: tests/fixtures/final_result.json 또는 tests/snapshots/)로 이동하는 것이 프로젝트 구조상 적절합니다. 루트에 두면 프로덕션 코드와 혼동될 수 있고, .gitignore에서 관리하기도 어렵습니다.

app/graph/roadmap/nodes.py (4)

422-432: description 필드가 하드코딩된 플레이스홀더입니다.

현재 f"{place['name']}에 대한 한 줄 설명입니다."로 고정되어 있습니다. Mock 단계에서는 문제 없지만, 실제 API 연동 시 Google Places의 editorial_summary 등 실제 데이터로 교체해야 합니다. TODO 주석을 남겨두면 추적하기 좋겠습니다.

💡 TODO 주석 추가 제안
                 day_places.append(
                     {
                         "place_name": place["name"],
                         "place_id": place.get("place_id"),
                         "photo_reference": place.get("photo_reference"),
-                        "description": f"{place['name']}에 대한 한 줄 설명입니다.",
+                        # TODO: Google Places API 연동 시 실제 장소 설명으로 교체
+                        "description": place.get("description", f"{place['name']}에 대한 한 줄 설명입니다."),
                         "visit_sequence": visit_sequence_counter,
                         "visit_time": visit_time,
                     }
                 )

489-493: CourseRequest.model_validate가 중복 호출됩니다.

Line 491에서 CourseRequest.model_validate(state["course_request"])를 호출하지만, 이미 _prepare_final_context 내부(Line 385)에서 동일한 검증이 수행되었습니다. _prepare_final_context에서 검증된 CourseRequest 인스턴스를 반환값에 포함시키면 중복 검증을 제거할 수 있습니다.

♻️ _prepare_final_context 반환값 확장 제안

_prepare_final_contextCourseRequest도 함께 반환하도록 변경:

 def _prepare_final_context(
     state: RoadmapState,
-) -> tuple[str, list[dict]]:
+) -> tuple[str, list[dict], CourseRequest]:
     ...
     course_request = CourseRequest.model_validate(raw_request)
     ...
-    return "\n".join(context_lines), daily_places_for_schema
+    return "\n".join(context_lines), daily_places_for_schema, course_request

synthesize_final_roadmap에서:

-        itinerary_context, daily_places = _prepare_final_context(state)
-        course_request = state["course_request"]
+        itinerary_context, daily_places, course_request = _prepare_final_context(state)
         ...
-        course_request = CourseRequest.model_validate(state["course_request"])
         trip_days = state["trip_days"]

509-511: logger.error에서 f-string 대신 % 포매팅을 사용하세요.

이 파일의 다른 로깅 호출(Lines 193, 231, 237, 352)은 모두 % 스타일 지연 포매팅을 사용하고 있습니다. f-string은 로그 레벨과 무관하게 항상 문자열을 평가하므로 성능상 불리하고, 일관성도 떨어집니다.

♻️ 수정 제안
-        logger.error(f"최종 로드맵 생성 실패: {e}", exc_info=True)
-        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}
+        logger.error("최종 로드맵 생성 실패: %s", e, exc_info=True)
+        return {**state, "error": f"최종 로드맵 생성에 실패했습니다: {e}"}

492-492: state["trip_days"] 직접 접근 시 KeyError 가능성이 있습니다.

_prepare_final_context에서 skeleton_plan, fetched_places, course_request는 검증하지만 trip_days는 검증하지 않습니다. 현재 워크플로우 순서상 generate_skeleton이 항상 먼저 실행되므로 실질적으로 안전하지만, 방어적으로 state.get("trip_days")를 사용하거나 _prepare_final_context의 검증 대상에 추가하는 것이 좋습니다.

🛡️ 방어적 접근 제안
-        trip_days = state["trip_days"]
+        trip_days = state.get("trip_days")
+        if not trip_days:
+            raise ValueError("최종 합성을 위한 `trip_days` 데이터가 없습니다.")

Comment thread final_result.json Outdated

@iamb0ttle iamb0ttle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@zweadfx zweadfx merged commit 6b054e1 into main Feb 6, 2026
2 checks passed
@zweadfx zweadfx deleted the feat/synthesize-final-roadmap branch February 6, 2026 07:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Final Synthesis 노드 구현 (LLM 기반 최종 로드맵 생성)

2 participants