diff --git a/src/main/java/io/github/petty/llm/controller/RecommendController.java b/src/main/java/io/github/petty/llm/controller/RecommendController.java index ce2926f..99cdde4 100644 --- a/src/main/java/io/github/petty/llm/controller/RecommendController.java +++ b/src/main/java/io/github/petty/llm/controller/RecommendController.java @@ -4,9 +4,13 @@ import io.github.petty.llm.dto.RecommendResponseDTO; import io.github.petty.llm.service.RecommendService; import io.github.petty.llm.service.VectorStoreService; +import io.github.petty.tour.dto.DetailCommonDto; +import io.github.petty.tour.service.TourService; import lombok.RequiredArgsConstructor; import org.springframework.ai.document.Document; import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.*; @@ -15,17 +19,34 @@ import java.util.List; import java.util.Map; -@RestController -@RequestMapping("/api/recommend") +@Controller @RequiredArgsConstructor public class RecommendController { - // RecommendService로 분리 private final RecommendService recommendService; + private final TourService tourService; - @PostMapping + // 기존 API 엔드포인트 (JSON 응답) + @PostMapping("/api/recommend") + @ResponseBody public ResponseEntity recommend(@RequestBody Map promptMap) { RecommendResponseDTO result = recommendService.recommend(promptMap); return ResponseEntity.ok(result); } - + + // 새로운 상세 페이지 엔드포인트 (HTML 응답) + @GetMapping("/recommend/detail/{contentId}") + public String getRecommendDetail(@PathVariable String contentId, Model model) { + try { + Long id = Long.parseLong(contentId); + DetailCommonDto contentDetail = tourService.getContentDetailById(id); + model.addAttribute("contentDetail", contentDetail); + return "recommend_detail"; + } catch (NumberFormatException e) { + model.addAttribute("error", "잘못된 콘텐츠 ID입니다: " + contentId); + return "recommend_detail"; + } catch (Exception e) { + model.addAttribute("error", "콘텐츠 정보를 불러오는데 실패했습니다: " + e.getMessage()); + return "recommend_detail"; + } + } } diff --git a/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java b/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java index 7c6fc86..7299315 100644 --- a/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java +++ b/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java @@ -3,10 +3,10 @@ import java.util.List; // 추천 응답 반환 -public record RecommendResponseDTO ( +public record RecommendResponseDTO( List recommend ) { - public record PlaceRecommend ( + public record PlaceRecommend( String contentId, String title, String addr, @@ -16,5 +16,6 @@ public record PlaceRecommend ( String acmpyPsblCpam, String acmpyNeedMtr, String recommendReason - ) {} + ) { + } } \ No newline at end of file diff --git a/src/main/java/io/github/petty/llm/service/GeminiRerankingService.java b/src/main/java/io/github/petty/llm/service/GeminiRerankingService.java index 16f9dc8..854e6e4 100644 --- a/src/main/java/io/github/petty/llm/service/GeminiRerankingService.java +++ b/src/main/java/io/github/petty/llm/service/GeminiRerankingService.java @@ -9,6 +9,7 @@ import io.qdrant.client.grpc.Points; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -28,9 +29,9 @@ public class GeminiRerankingService { private final ObjectMapper objectMapper; - public GeminiRerankResponseDTO rerankGemini(String userPrompt, List candidates) { + public GeminiRerankResponseDTO rerankGemini(String userPrompt, List documents) { log.info("GeminiReranking 프롬프트 실행"); - String prompt = buildRerankingPrompt(userPrompt, candidates); + String prompt = buildRerankingPrompt(userPrompt, documents); String response = null; try { response = callGemini(prompt); @@ -40,7 +41,7 @@ public GeminiRerankResponseDTO rerankGemini(String userPrompt, List candidates) { + private String buildRerankingPrompt(String userPrompt, List documents) { StringBuilder sb = new StringBuilder(); sb.append("당신은 반려동물 동반 여행지 추천 전문가입니다. 사용자의 요청과 반려동물 정보를 바탕으로 다음 후보지들을 평가하고 순위를 매겨주세요.\n\n"); @@ -49,15 +50,17 @@ private String buildRerankingPrompt(String userPrompt, List promptMap) { try { @@ -42,23 +45,18 @@ public RecommendResponseDTO recommend(Map promptMap) { return new RecommendResponseDTO(new ArrayList<>()); } - List recommendations = buildRecommendResponse(docs); - // Gemini 리랭킹 - GeminiRerankResponseDTO rerank = geminiRerankingService.rerankGemini(userPrompt, recommendations); - - List finalRecommendations = applyRerankingResults(recommendations, rerank); + GeminiRerankResponseDTO rerank = geminiRerankingService.rerankGemini(userPrompt, docs); + List finalRecommendations = buildRecommendResponse(rerank); return new RecommendResponseDTO(finalRecommendations); - // 결과로 바로 dto - // return buildRecommendResponse(docs); } catch (Exception e) { log.error("추천 처리 중 오류 발생: {}", e.getMessage(), e); throw new RuntimeException("추천 생성 실패: " + e.getMessage(), e); } } - // 사용자 입력 기반 프롬프트 생성 + // 사용자 입력 기반 프롬프트 생성 private String buildPrompt(Map promptMap) { StringBuilder sb = new StringBuilder(); @@ -69,7 +67,7 @@ private String buildPrompt(Map promptMap) { sb.append(String.format("%s 정도의 몸무게를 가진 %s 종이에요.\n", weight, species)); - if(isDanger.equals("true")) { + if (isDanger.equals("true")) { sb.append("맹견이에요\n"); } else { sb.append("소형견, 중형견이에요\n"); @@ -78,7 +76,7 @@ private String buildPrompt(Map promptMap) { sb.append(String.format("위치는 %s 근처이고, \n", location)); String info = promptMap.get("info"); - if(info != null) { + if (info != null) { sb.append("추가로 이 장소의 설명은: ").append(info).append("\n"); } return sb.toString(); @@ -113,87 +111,54 @@ private Filter.Expression buildRegion(String location) { } // 유사도 검색 - private List buildRecommendResponse(List docs) { - List recommends = new ArrayList<>(); - Set checkId = new HashSet<>(); - - for (Document doc : docs) { - String contentId = (String) doc.getMetadata().get("contentId"); - - // 중복체크 - if (checkId.contains(contentId)) { - log.debug("중복된 contentId 발견하여 건너뜀: {}", contentId); - continue; - } - checkId.add(contentId); - - String title = (String) doc.getMetadata().get("title"); - String addr = (String) doc.getMetadata().get("address"); - String description = doc.getText(); -// String petInfo = (String) doc.getMetadata().getOrDefault("petTourInfo", ""); - String imageUrl = contentService.getImageUrl(contentId); - - Optional petInfoOpt = contentService.getPetInfo(contentId); - - String acmpyTypeCd = "정보 없음"; - String acmpyPsblCpam = "정보 없음"; - String acmpyNeedMtr = "정보 없음"; - - if (petInfoOpt.isPresent()) { - var petInfo = petInfoOpt.get(); - if (petInfo.getAcmpyTypeCd() != null && !petInfo.getAcmpyTypeCd().isBlank()) - acmpyTypeCd = petInfo.getAcmpyTypeCd(); - if (petInfo.getAcmpyPsblCpam() != null && !petInfo.getAcmpyPsblCpam().isBlank()) - acmpyPsblCpam = petInfo.getAcmpyPsblCpam(); - if (petInfo.getAcmpyNeedMtr() != null && !petInfo.getAcmpyNeedMtr().isBlank()) - acmpyNeedMtr = petInfo.getAcmpyNeedMtr(); - } - - recommends.add(new RecommendResponseDTO.PlaceRecommend( - contentId, title, addr, description, imageUrl, - acmpyTypeCd, acmpyPsblCpam, acmpyNeedMtr, null - )); - } - return recommends; - } + private List buildRecommendResponse(GeminiRerankResponseDTO rerankResult) { + Set seenIds = new HashSet<>(); + List resultList = rerankResult.rankedPlaces().stream() + .map(ranked -> { + String contentId = ranked.contentId(); - /** - * 리랭킹 결과를 원본 추천 리스트에 적용 - */ - private List applyRerankingResults( - List initialRecommends, - GeminiRerankResponseDTO rerankResult) { - - // contentId를 키로 하는 맵 생성 - Map recommendMap = initialRecommends.stream() - .collect(Collectors.toMap( - RecommendResponseDTO.PlaceRecommend::contentId, - recommend -> recommend - )); + // contentId 중복 검사 + if (!seenIds.add(contentId)) { + return null; + } - // 리랭킹 결과를 점수순으로 정렬하고 상위 10개만 선택 - return rerankResult.rankedPlaces().stream() - .map(ranked -> { - RecommendResponseDTO.PlaceRecommend original = recommendMap.get(ranked.contentId()); - if (original != null) { - // 추천 이유와 점수를 포함한 새로운 객체 생성 - return new RecommendResponseDTO.PlaceRecommend( - original.contentId(), - original.title(), - original.addr(), - original.description(), - original.imageUrl(), - original.acmpyTypeCd(), - original.acmpyPsblCpam(), - original.acmpyNeedMtr(), - ranked.recommendReason() - ); + try { + Optional contentOpt = contentService.findByContentId(contentId); + if (contentOpt.isPresent()) { + Content content = contentOpt.get(); + String imageUrl = contentService.getImageUrl(contentId); + + String acmpyTypeCd = ""; + String acmpyPsblCpam = ""; + String acmpyNeedMtr = ""; + + if (content.getPetTourInfo() != null) { + acmpyTypeCd = Optional.ofNullable(content.getPetTourInfo().getAcmpyTypeCd()).orElse(""); + acmpyPsblCpam = Optional.ofNullable(content.getPetTourInfo().getAcmpyPsblCpam()).orElse(""); + acmpyNeedMtr = Optional.ofNullable(content.getPetTourInfo().getAcmpyNeedMtr()).orElse(""); + } + + return new RecommendResponseDTO.PlaceRecommend( + contentId, + Optional.ofNullable(content.getTitle()).orElse(""), + Optional.ofNullable(content.getAddr1()).orElse(""), + Optional.ofNullable(content.getOverview()).orElse(""), + imageUrl, + acmpyTypeCd, + acmpyPsblCpam, + acmpyNeedMtr, + ranked.recommendReason() + ); + } + } catch (Exception e) { + log.error("contentId {}에 대한 정보 조회 중 오류 발생: {}", contentId, e.getMessage()); } + return null; }) - .filter(java.util.Objects::nonNull) + .filter(Objects::nonNull) .collect(Collectors.toList()); + return resultList; } - } diff --git a/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java b/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java index 97a5ad6..b50e347 100644 --- a/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java +++ b/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java @@ -31,7 +31,12 @@ public class UnifiedFlowController { // 1. 반려동물 분석 페이지 (초기 진입) @GetMapping("/analyze") - public String analyzePage() { + public String analyzePage(HttpSession session) { + // 새로운 분석 시작 시 이전 세션 데이터 정리 + log.info("새로운 분석 시작 - 이전 세션 데이터 정리"); + session.removeAttribute("recommendationResult"); + session.removeAttribute("visionReport"); + session.removeAttribute("lastAccessTime"); return "analyze"; // analyze.html 반환 } @@ -44,6 +49,10 @@ public String performAnalysis( HttpSession session ) { try { + // 세션 한 번 더 제거 + session.removeAttribute("recommendationResult"); + session.removeAttribute("visionReport"); + session.removeAttribute("lastAccessTime"); // interim 및 visionReport는 시간이 걸리는 작업이므로 // 실제 구현에서는 비동기 처리 또는 로딩 페이지에서 Ajax 호출로 처리하는 것이 일반적입니다. // 여기서는 단순화를 위해 analyze POST 요청에서 미리 결과를 계산하고 전달합니다. @@ -106,6 +115,7 @@ public String generateRecommendation( @RequestParam("petName") String petName, @RequestParam("location") String location, @RequestParam("info") String info, + // @RequestParam("is_danger") String isDanger, RedirectAttributes redirectAttributes, HttpSession session ) { @@ -169,9 +179,9 @@ public String showRecommendationResult(Model model, HttpSession session) { // 필요에 따라 petName, location 등도 세션에서 가져와 모델에 추가할 수 있습니다. // model.addAttribute("petName", session.getAttribute("petName")); - // 사용 후 세션에서 제거 (선택 사항, 메모리 관리) - session.removeAttribute("recommendationResult"); - session.removeAttribute("visionReport"); +// // 사용 후 세션에서 제거 (선택 사항, 메모리 관리) +// session.removeAttribute("recommendationResult"); +// session.removeAttribute("visionReport"); return "recommendation_result"; // recommendation_result.html 반환 } diff --git a/src/main/java/io/github/petty/tour/repository/PetTourInfoRepository.java b/src/main/java/io/github/petty/tour/repository/PetTourInfoRepository.java index 6c240ac..0f21e5c 100644 --- a/src/main/java/io/github/petty/tour/repository/PetTourInfoRepository.java +++ b/src/main/java/io/github/petty/tour/repository/PetTourInfoRepository.java @@ -5,6 +5,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface PetTourInfoRepository extends JpaRepository { diff --git a/src/main/resources/static/css/result.css b/src/main/resources/static/css/result.css new file mode 100644 index 0000000..7de40e3 --- /dev/null +++ b/src/main/resources/static/css/result.css @@ -0,0 +1,26 @@ +.recommend-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + margin-top: 20px; +} +.recommend-card { + background-color: #fff; + border-radius: 10px; + padding: 15px; + box-shadow: 0 2px 6px rgba(0,0,0,0.1); +} +.recommend-card img { + width: 100%; + height: 180px; + object-fit: cover; + border-radius: 8px; + margin-bottom: 10px; +} +.recommend-card h2 { + margin: 5px 0; +} +.recommend-card p { + margin: 4px 0; + font-size: 0.9em; +} \ No newline at end of file diff --git a/src/main/resources/static/js/flow.js b/src/main/resources/static/js/flow.js index 643afaf..35043d2 100644 --- a/src/main/resources/static/js/flow.js +++ b/src/main/resources/static/js/flow.js @@ -137,4 +137,4 @@ document.addEventListener("DOMContentLoaded", function () { } }); } -}); \ No newline at end of file +}); diff --git a/src/main/resources/templates/recommend_detail.html b/src/main/resources/templates/recommend_detail.html new file mode 100644 index 0000000..0740924 --- /dev/null +++ b/src/main/resources/templates/recommend_detail.html @@ -0,0 +1,294 @@ + + + + + 여행지 상세 정보 + + + + + + +
+ + +
+

오류 발생

+

+
+ +
+ +
+

여행지 이름

+
주소
+
상세주소
+
+ + +
+ 여행지 이미지 +
+
+ 이미지 없음 +
+ + +
+

🏞️ 소개

+
소개 내용
+
+ + +
+

📍 기본 정보

+ +
+
+ 📞 전화번호:
+ 전화번호 +
+
+ 👤 담당자:
+ 담당자명 +
+
+ +
+ 🌐 홈페이지: + + 홈페이지 바로가기 + +
+ +
+ 📮 우편번호: + 우편번호 +
+
+ + +
+

🐾 반려동물 동반 정보

+ +
+ 동반 유형: + 동반 유형 +
+ +
+ 동반 가능 동물: + 동반 가능 동물 +
+ +
+ 동반 필요 조건: + 필요 조건 +
+ +
+ 관련 편의시설: + 편의시설 +
+ +
+ 관련 비품 목록: + 비품 목록 +
+ +
+ 관련 대여 상품: + 대여 상품 +
+ +
+ 관련 구매 상품: + 구매 상품 +
+ +
+ 사고 예방 사항: + 사고 예방 사항 +
+ +
+ 기타 동반 정보: + 기타 정보 +
+
+ + +
+

📷 추가 이미지

+
+
+ +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/recommendation_result.html b/src/main/resources/templates/recommendation_result.html index ae2bd5e..67bc748 100644 --- a/src/main/resources/templates/recommendation_result.html +++ b/src/main/resources/templates/recommendation_result.html @@ -68,6 +68,18 @@ text-align: center; flex-shrink: 0; } + + /* 카드 링크 스타일 추가 */ + .card-link { + text-decoration: none; + color: inherit; + display: block; + } + + .card-link:hover { + text-decoration: none; + color: inherit; + } @@ -76,17 +88,24 @@

🐾 추천 여행지

-