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
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand All @@ -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<RecommendResponseDTO> recommend(@RequestBody Map<String, String> 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";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import java.util.List;

// 추천 응답 반환
public record RecommendResponseDTO (
public record RecommendResponseDTO(
List<PlaceRecommend> recommend
) {
public record PlaceRecommend (
public record PlaceRecommend(
String contentId,
String title,
String addr,
Expand All @@ -16,5 +16,6 @@ public record PlaceRecommend (
String acmpyPsblCpam,
String acmpyNeedMtr,
String recommendReason
) {}
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -28,9 +29,9 @@ public class GeminiRerankingService {

private final ObjectMapper objectMapper;

public GeminiRerankResponseDTO rerankGemini(String userPrompt, List<RecommendResponseDTO.PlaceRecommend> candidates) {
public GeminiRerankResponseDTO rerankGemini(String userPrompt, List<Document> documents) {
log.info("GeminiReranking 프롬프트 실행");
String prompt = buildRerankingPrompt(userPrompt, candidates);
String prompt = buildRerankingPrompt(userPrompt, documents);
String response = null;
try {
response = callGemini(prompt);
Expand All @@ -40,7 +41,7 @@ public GeminiRerankResponseDTO rerankGemini(String userPrompt, List<RecommendRes
return parseGemini(response);
}

private String buildRerankingPrompt(String userPrompt, List<RecommendResponseDTO.PlaceRecommend> candidates) {
private String buildRerankingPrompt(String userPrompt, List<Document> documents) {
StringBuilder sb = new StringBuilder();

sb.append("당신은 반려동물 동반 여행지 추천 전문가입니다. 사용자의 요청과 반려동물 정보를 바탕으로 다음 후보지들을 평가하고 순위를 매겨주세요.\n\n");
Expand All @@ -49,15 +50,17 @@ private String buildRerankingPrompt(String userPrompt, List<RecommendResponseDTO
sb.append(userPrompt).append("\n");

sb.append("후보 장소들:\n");
for (int i = 0; i < candidates.size(); i++) {
var place = candidates.get(i);
sb.append(String.format("%d. %s\n", i + 1, place.title()));
sb.append(String.format(" - 주소: %s\n", place.addr()));
sb.append(String.format(" - 설명: %s\n", place.description()));
sb.append(String.format(" - 동반 유형: %s\n", place.acmpyTypeCd()));
sb.append(String.format(" - 동반 가능: %s\n", place.acmpyPsblCpam()));
sb.append(String.format(" - 준비사항: %s\n", place.acmpyNeedMtr()));
sb.append(String.format(" - contentId: %s\n\n", place.contentId()));
for (int i = 0; i < documents.size(); i++) {
Document doc = documents.get(i);
String contentId = (String) doc.getMetadata().get("contentId");
String title = (String) doc.getMetadata().get("title");
String addr = (String) doc.getMetadata().get("address");
String description = doc.getText();

sb.append(String.format("%d. %s\n", i + 1, title));
sb.append(String.format(" - 주소: %s\n", addr));
sb.append(String.format(" - 설명: %s\n", description));
sb.append(String.format(" - contentId: %s\n\n", contentId));
}

sb.append("평가 기준:\n");
Expand All @@ -67,16 +70,17 @@ private String buildRerankingPrompt(String userPrompt, List<RecommendResponseDTO

sb.append("요구사항:\n");
sb.append("1. 사용자 요청사항과 평가 기준에 가장 적합한 순서로 정렬해주세요\n");
sb.append("2. 각 장소별로 사용자 요청에 맞는 구체적인 추천 이유를 50자 이내로 작성해주세요\n");
sb.append("3. 반려동물 동반이 불가능하거나 사용자 조건에 맞지 않는 경우 제외해주세요\n");
sb.append("4. 반드시 아래 JSON 형식으로만 응답해주세요:\n\n");
sb.append("2. 각 장소별로 사용자 요청에 맞는 구체적인 추천 이유를 100자 이내로 작성해주세요. 자연스러운 평문으로 추천해주세요.\n");
sb.append("3. 반려동물 동반이 불가능하거나 사용자 조건에 맞지 않는 장소는 제외해주세요.\n");
sb.append("4. 사용자가 원하는 관광지/숙소/레저/음식점/쇼핑 등 타입이 입력될 경우, 타입에 일치하는 곳만 포함해주세요. 예를 들어 음식점이라고 입력했을 경우 음식점만 출력되게 해주세요.\n");
sb.append("5. 반드시 아래 JSON 형식으로만 응답해주세요:\n\n");


sb.append("{\n");
sb.append(" \"rankedPlaces\": [\n");
sb.append(" {\n");
sb.append(" \"contentId\": \"장소ID\",\n");
sb.append(" \"recommendReason\": \"추천 이유 (50자 이내)\"\n");
sb.append(" \"contentId\": \"장소ID(입력 받은 placeContentId)\",\n");
sb.append(" \"recommendReason\": \"추천 이유 (100자 이내)\"\n");
sb.append(" }\n");
sb.append(" ]\n");
sb.append("}\n\n");
Expand Down
135 changes: 50 additions & 85 deletions src/main/java/io/github/petty/llm/service/RecommendServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.github.petty.llm.dto.GeminiRerankRequestDTO;
import io.github.petty.llm.dto.GeminiRerankResponseDTO;
import io.qdrant.client.grpc.PointsInternalService;
import lombok.extern.slf4j.Slf4j;
import io.github.petty.llm.common.AreaCode;
import io.github.petty.llm.dto.RecommendResponseDTO;
Expand All @@ -11,6 +12,7 @@
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.stereotype.Service;
import io.github.petty.tour.entity.Content;

import java.util.*;
import java.util.stream.Collectors;
Expand All @@ -24,6 +26,7 @@ public class RecommendServiceImpl implements RecommendService {
private final ContentService contentService;
private final GeminiRerankingService geminiRerankingService;


@Override
public RecommendResponseDTO recommend(Map<String, String> promptMap) {
try {
Expand All @@ -42,23 +45,18 @@ public RecommendResponseDTO recommend(Map<String, String> promptMap) {
return new RecommendResponseDTO(new ArrayList<>());
}

List<RecommendResponseDTO.PlaceRecommend> recommendations = buildRecommendResponse(docs);

// Gemini 리랭킹
GeminiRerankResponseDTO rerank = geminiRerankingService.rerankGemini(userPrompt, recommendations);

List<RecommendResponseDTO.PlaceRecommend> finalRecommendations = applyRerankingResults(recommendations, rerank);
GeminiRerankResponseDTO rerank = geminiRerankingService.rerankGemini(userPrompt, docs);
List<RecommendResponseDTO.PlaceRecommend> 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<String, String> promptMap) {
StringBuilder sb = new StringBuilder();

Expand All @@ -69,7 +67,7 @@ private String buildPrompt(Map<String, String> 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");
Expand All @@ -78,7 +76,7 @@ private String buildPrompt(Map<String, String> 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();
Expand Down Expand Up @@ -113,87 +111,54 @@ private Filter.Expression buildRegion(String location) {
}

// 유사도 검색
private List<RecommendResponseDTO.PlaceRecommend> buildRecommendResponse(List<Document> docs) {
List<RecommendResponseDTO.PlaceRecommend> recommends = new ArrayList<>();
Set<String> 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<DetailPetDto> 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<RecommendResponseDTO.PlaceRecommend> buildRecommendResponse(GeminiRerankResponseDTO rerankResult) {
Set<String> seenIds = new HashSet<>();

List<RecommendResponseDTO.PlaceRecommend> resultList = rerankResult.rankedPlaces().stream()
.map(ranked -> {
String contentId = ranked.contentId();

/**
* 리랭킹 결과를 원본 추천 리스트에 적용
*/
private List<RecommendResponseDTO.PlaceRecommend> applyRerankingResults(
List<RecommendResponseDTO.PlaceRecommend> initialRecommends,
GeminiRerankResponseDTO rerankResult) {

// contentId를 키로 하는 맵 생성
Map<String, RecommendResponseDTO.PlaceRecommend> 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<Content> 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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 반환
}

Expand All @@ -44,6 +49,10 @@ public String performAnalysis(
HttpSession session
) {
try {
// 세션 한 번 더 제거
session.removeAttribute("recommendationResult");
session.removeAttribute("visionReport");
session.removeAttribute("lastAccessTime");
// interim 및 visionReport는 시간이 걸리는 작업이므로
// 실제 구현에서는 비동기 처리 또는 로딩 페이지에서 Ajax 호출로 처리하는 것이 일반적입니다.
// 여기서는 단순화를 위해 analyze POST 요청에서 미리 결과를 계산하고 전달합니다.
Expand Down Expand Up @@ -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
) {
Expand Down Expand Up @@ -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 반환
}
Expand Down
Loading