diff --git a/src/main/java/io/github/petty/llm/dto/GeminiRerankRequestDTO.java b/src/main/java/io/github/petty/llm/dto/GeminiRerankRequestDTO.java new file mode 100644 index 0000000..f76d24d --- /dev/null +++ b/src/main/java/io/github/petty/llm/dto/GeminiRerankRequestDTO.java @@ -0,0 +1,20 @@ +package io.github.petty.llm.dto; + +import java.util.List; + +public record GeminiRerankRequestDTO( + String userPrompt, + List place +) { + public record RecommendPlace( + String contentId, + String title, + String addr, + String description, + String acmpyTypeCd, + String acmpyPsblCpam, + String acmpyNeedMtr + ) {} +} + + diff --git a/src/main/java/io/github/petty/llm/dto/GeminiRerankResponseDTO.java b/src/main/java/io/github/petty/llm/dto/GeminiRerankResponseDTO.java new file mode 100644 index 0000000..470f350 --- /dev/null +++ b/src/main/java/io/github/petty/llm/dto/GeminiRerankResponseDTO.java @@ -0,0 +1,12 @@ +package io.github.petty.llm.dto; + +import java.util.List; + +public record GeminiRerankResponseDTO( + List rankedPlaces +) { + public record RerankedPlace( + String contentId, + String recommendReason + ) {} +} 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 acc6960..7c6fc86 100644 --- a/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java +++ b/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java @@ -14,5 +14,7 @@ public record PlaceRecommend ( String imageUrl, String acmpyTypeCd, String acmpyPsblCpam, - String acmpyNeedMtr ) {} + 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 new file mode 100644 index 0000000..16f9dc8 --- /dev/null +++ b/src/main/java/io/github/petty/llm/service/GeminiRerankingService.java @@ -0,0 +1,164 @@ +package io.github.petty.llm.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.petty.llm.dto.GeminiRequestDTO; +import io.github.petty.llm.dto.GeminiRerankResponseDTO; +import io.github.petty.llm.dto.GeminiResponseDTO; +import io.github.petty.llm.dto.RecommendResponseDTO; +import io.qdrant.client.grpc.Points; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class GeminiRerankingService { + @Value("${gemini.api.key}") + private String apiKey; + + private final ObjectMapper objectMapper; + + public GeminiRerankResponseDTO rerankGemini(String userPrompt, List candidates) { + log.info("GeminiReranking 프롬프트 실행"); + String prompt = buildRerankingPrompt(userPrompt, candidates); + String response = null; + try { + response = callGemini(prompt); + } catch (Exception e) { + throw new RuntimeException(e); + } + return parseGemini(response); + } + + private String buildRerankingPrompt(String userPrompt, List candidates) { + StringBuilder sb = new StringBuilder(); + + sb.append("당신은 반려동물 동반 여행지 추천 전문가입니다. 사용자의 요청과 반려동물 정보를 바탕으로 다음 후보지들을 평가하고 순위를 매겨주세요.\n\n"); + + sb.append("사용자 요청: \n"); + 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())); + } + + sb.append("평가 기준:\n"); + sb.append("1. 사용자의 반려동물 정보(견종, 몸무게, 맹견 여부)와 장소의 동반 조건 일치도\n"); + sb.append("2. 사용자가 원하는 지역과의 근접성\n"); + sb.append("3. 사용자의 추가 요구사항 부합도\n"); + + sb.append("요구사항:\n"); + sb.append("1. 사용자 요청사항과 평가 기준에 가장 적합한 순서로 정렬해주세요\n"); + sb.append("2. 각 장소별로 사용자 요청에 맞는 구체적인 추천 이유를 50자 이내로 작성해주세요\n"); + sb.append("3. 반려동물 동반이 불가능하거나 사용자 조건에 맞지 않는 경우 제외해주세요\n"); + sb.append("4. 반드시 아래 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(" }\n"); + sb.append(" ]\n"); + sb.append("}\n\n"); + + sb.append("다른 설명이나 마크다운 없이 오직 JSON만 응답하세요!"); + + return sb.toString(); + } + + private String callGemini(String prompt) throws Exception { + HttpClient httpClient = HttpClient.newHttpClient(); + String url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=%s" + .formatted(apiKey); + + log.info("[Gemini 리랭킹] 프롬프트 전송 중..."); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + objectMapper.writeValueAsString( + new GeminiRequestDTO(List.of( + new GeminiRequestDTO.Content(List.of( + new GeminiRequestDTO.Part(prompt) + )) + )) + ) + )) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + log.info("Gemini API 응답 상태코드: {}", response.statusCode()); + log.info("Gemini API 응답 본문: {}", response.body()); // 이 줄 추가 + + if (response.statusCode() != 200) { + throw new RuntimeException("Gemini API 호출 실패: " + response.statusCode()); + } + + if (response.statusCode() != 200) { + throw new RuntimeException("Gemini API 호출 실패: " + response.statusCode()); + } + + GeminiResponseDTO resp = objectMapper.readValue(response.body(), GeminiResponseDTO.class); + return resp.candidates().get(0).content().parts().get(0).text(); + } + + + /** + * Gemini 응답 파싱 + */ + private GeminiRerankResponseDTO parseGemini(String response) { + try { + // JSON 부분만 추출 (혹시 다른 텍스트가 포함되어 있을 경우) + String jsonPart = extractJsonFromResponse(response); + return objectMapper.readValue(jsonPart, GeminiRerankResponseDTO.class); + } catch (JsonProcessingException e) { + log.error("Gemini 응답 파싱 실패: {}", response, e); + throw new RuntimeException("Gemini 응답 파싱 실패", e); + } + } + + /** + * 응답에서 JSON 부분만 추출 + */ + private String extractJsonFromResponse(String response) { + // 1. 먼저 코드 블록 제거 + response = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", ""); + + // 2. 앞뒤 공백 제거 + response = response.trim(); + + // 3. JSON 시작과 끝 찾기 + int start = response.indexOf('{'); + int end = response.lastIndexOf('}'); + + if (start != -1 && end != -1 && end > start) { + String jsonResult = response.substring(start, end + 1); + log.info("추출된 JSON: {}", jsonResult); // 디버깅용 + return jsonResult; + } + + log.error("JSON 추출 실패. 원본 응답: {}", response); + throw new RuntimeException("유효한 JSON 형식을 찾을 수 없습니다: " + response); + } +} diff --git a/src/main/java/io/github/petty/llm/service/RecommendServiceImpl.java b/src/main/java/io/github/petty/llm/service/RecommendServiceImpl.java index fe93844..d790df8 100644 --- a/src/main/java/io/github/petty/llm/service/RecommendServiceImpl.java +++ b/src/main/java/io/github/petty/llm/service/RecommendServiceImpl.java @@ -1,5 +1,7 @@ package io.github.petty.llm.service; +import io.github.petty.llm.dto.GeminiRerankRequestDTO; +import io.github.petty.llm.dto.GeminiRerankResponseDTO; import lombok.extern.slf4j.Slf4j; import io.github.petty.llm.common.AreaCode; import io.github.petty.llm.dto.RecommendResponseDTO; @@ -10,10 +12,8 @@ import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; +import java.util.stream.Collectors; @Slf4j @Service @@ -22,6 +22,7 @@ public class RecommendServiceImpl implements RecommendService { // RecommendController를 RecommendService로 분리 private final VectorStoreService vectorStoreService; private final ContentService contentService; + private final GeminiRerankingService geminiRerankingService; @Override public RecommendResponseDTO recommend(Map promptMap) { @@ -41,8 +42,16 @@ 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); + return new RecommendResponseDTO(finalRecommendations); + // 결과로 바로 dto - return buildRecommendResponse(docs); + // return buildRecommendResponse(docs); } catch (Exception e) { log.error("추천 처리 중 오류 발생: {}", e.getMessage(), e); throw new RuntimeException("추천 생성 실패: " + e.getMessage(), e); @@ -103,12 +112,21 @@ private Filter.Expression buildRegion(String location) { return areaExpr.build(); } - // 유사도 검색으로 dto 바로 생성 - private RecommendResponseDTO buildRecommendResponse(List docs) { + // 유사도 검색 + 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(); @@ -133,10 +151,49 @@ private RecommendResponseDTO buildRecommendResponse(List docs) { recommends.add(new RecommendResponseDTO.PlaceRecommend( contentId, title, addr, description, imageUrl, - acmpyTypeCd, acmpyPsblCpam, acmpyNeedMtr + acmpyTypeCd, acmpyPsblCpam, acmpyNeedMtr, null )); } + return recommends; + } - return new RecommendResponseDTO(recommends); + + /** + * 리랭킹 결과를 원본 추천 리스트에 적용 + */ + private List applyRerankingResults( + List initialRecommends, + GeminiRerankResponseDTO rerankResult) { + + // contentId를 키로 하는 맵 생성 + Map recommendMap = initialRecommends.stream() + .collect(Collectors.toMap( + RecommendResponseDTO.PlaceRecommend::contentId, + recommend -> recommend + )); + + // 리랭킹 결과를 점수순으로 정렬하고 상위 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() + ); + } + return null; + }) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toList()); } + } diff --git a/src/main/java/io/github/petty/llm/service/VectorStoreService.java b/src/main/java/io/github/petty/llm/service/VectorStoreService.java index 7b51510..b26f5c7 100644 --- a/src/main/java/io/github/petty/llm/service/VectorStoreService.java +++ b/src/main/java/io/github/petty/llm/service/VectorStoreService.java @@ -83,8 +83,8 @@ public List findSimilarWithFilter(String query, int k, Filter.Expressi log.info("유사 콘텐츠 검색 결과"); for (int i = 0; i < results.size(); i++) { Document doc = results.get(i); - log.info("▶ 결과 {}: ID={}, Metadata={}, Content={}", - i + 1, doc.getId(), doc.getMetadata(), doc.getText()); + log.info("▶ 결과 {}: ID={}, Metadata={}", + i + 1, doc.getId(), doc.getMetadata()); } return results; }