Skip to content
Closed
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
20 changes: 20 additions & 0 deletions src/main/java/io/github/petty/llm/dto/GeminiRerankRequestDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.github.petty.llm.dto;

import java.util.List;

public record GeminiRerankRequestDTO(
String userPrompt,
List<RecommendPlace> place
) {
public record RecommendPlace(
String contentId,
String title,
String addr,
String description,
String acmpyTypeCd,
String acmpyPsblCpam,
String acmpyNeedMtr
) {}
}


12 changes: 12 additions & 0 deletions src/main/java/io/github/petty/llm/dto/GeminiRerankResponseDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.petty.llm.dto;

import java.util.List;

public record GeminiRerankResponseDTO(
List<RerankedPlace> rankedPlaces
) {
public record RerankedPlace(
String contentId,
String recommendReason
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ public record PlaceRecommend (
String imageUrl,
String acmpyTypeCd,
String acmpyPsblCpam,
String acmpyNeedMtr ) {}
String acmpyNeedMtr,
String recommendReason
) {}
}
164 changes: 164 additions & 0 deletions src/main/java/io/github/petty/llm/service/GeminiRerankingService.java
Original file line number Diff line number Diff line change
@@ -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<RecommendResponseDTO.PlaceRecommend> 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<RecommendResponseDTO.PlaceRecommend> 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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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<String, String> promptMap) {
Expand All @@ -41,8 +42,16 @@ 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);
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);
Expand Down Expand Up @@ -103,12 +112,21 @@ private Filter.Expression buildRegion(String location) {
return areaExpr.build();
}

// 유사도 검색으로 dto 바로 생성
private RecommendResponseDTO buildRecommendResponse(List<Document> docs) {
// 유사도 검색
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();
Expand All @@ -133,10 +151,49 @@ private RecommendResponseDTO buildRecommendResponse(List<Document> 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<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
));

// 리랭킹 결과를 점수순으로 정렬하고 상위 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());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ public List<Document> 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;
}
Expand Down