diff --git a/src/main/java/io/github/petty/llm/common/ContentType.java b/src/main/java/io/github/petty/llm/common/ContentType.java index e2a9eb1..e834927 100644 --- a/src/main/java/io/github/petty/llm/common/ContentType.java +++ b/src/main/java/io/github/petty/llm/common/ContentType.java @@ -8,11 +8,11 @@ public enum ContentType { TOUR(12, "관광지"), CULTURE(14, "문화시설"), - FESTIVAL(15, "축제/공연/행사"), - SPORTS(28, "레포츠/레저"), - STAY(32, "숙박/숙소/호텔/모텔/펜션"), + FESTIVAL(15, "축제, 공연, 행사"), + SPORTS(28, "레포츠, 레저"), + STAY(32, "숙박, 숙소, 호텔, 모텔, 펜션"), SHOPPING(38, "쇼핑"), - FOOD(39, "음식점/식당"), + FOOD(39, "음식점, 식당"), ETC(0, "기타"); private final int code; diff --git a/src/main/java/io/github/petty/llm/controller/EmbeddingBatchController.java b/src/main/java/io/github/petty/llm/controller/EmbeddingBatchController.java new file mode 100644 index 0000000..2a1648d --- /dev/null +++ b/src/main/java/io/github/petty/llm/controller/EmbeddingBatchController.java @@ -0,0 +1,31 @@ +package io.github.petty.llm.controller; + +import io.github.petty.llm.service.EmbeddingBatchService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/embedding-batch") +@RequiredArgsConstructor +public class EmbeddingBatchController { + private final EmbeddingBatchService embeddingBatchService; + + // 기본 저장 + @PostMapping("/run") + public String runBatch() { + embeddingBatchService.saveAllContentsInBatch(); + return "Vector DB 구성 완료"; + } + + // 실패 ID 재저장 +// @PostMapping("/retry") +// public String retryFailedEmbeddings(@RequestBody List failedContentIds) { +// embeddingBatchService.retryFailedContents(failedContentIds); +// return "재시도 완료!"; +// } +} 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 fb4d717..ce2926f 100644 --- a/src/main/java/io/github/petty/llm/controller/RecommendController.java +++ b/src/main/java/io/github/petty/llm/controller/RecommendController.java @@ -1,7 +1,7 @@ package io.github.petty.llm.controller; import io.github.petty.llm.common.AreaCode; -import io.github.petty.llm.service.ChatService; +import io.github.petty.llm.dto.RecommendResponseDTO; import io.github.petty.llm.service.RecommendService; import io.github.petty.llm.service.VectorStoreService; import lombok.RequiredArgsConstructor; @@ -23,9 +23,9 @@ public class RecommendController { private final RecommendService recommendService; @PostMapping - public ResponseEntity recommend(@RequestBody Map promptMap) { - String result = recommendService.recommend(promptMap); + public ResponseEntity recommend(@RequestBody Map promptMap) { + RecommendResponseDTO result = recommendService.recommend(promptMap); return ResponseEntity.ok(result); } -} \ No newline at end of file +} diff --git a/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java b/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java new file mode 100644 index 0000000..acc6960 --- /dev/null +++ b/src/main/java/io/github/petty/llm/dto/RecommendResponseDTO.java @@ -0,0 +1,18 @@ +package io.github.petty.llm.dto; + +import java.util.List; + +// 추천 응답 반환 +public record RecommendResponseDTO ( + List recommend +) { + public record PlaceRecommend ( + String contentId, + String title, + String addr, + String description, + String imageUrl, + String acmpyTypeCd, + String acmpyPsblCpam, + String acmpyNeedMtr ) {} +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/llm/service/ChatService.java b/src/main/java/io/github/petty/llm/service/ChatService.java deleted file mode 100644 index c958211..0000000 --- a/src/main/java/io/github/petty/llm/service/ChatService.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.petty.llm.service; - -import org.springframework.ai.document.Document; - -import java.util.List; - -public interface ChatService { - String generateFromPrompt(String prompt, List docs) throws Exception; -} diff --git a/src/main/java/io/github/petty/llm/service/ChatServiceImpl.java b/src/main/java/io/github/petty/llm/service/ChatServiceImpl.java deleted file mode 100644 index 6df3f70..0000000 --- a/src/main/java/io/github/petty/llm/service/ChatServiceImpl.java +++ /dev/null @@ -1,70 +0,0 @@ -package io.github.petty.llm.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.petty.llm.dto.GeminiRequestDTO; -import io.github.petty.llm.dto.GeminiResponseDTO; -import lombok.RequiredArgsConstructor; -import lombok.extern.java.Log; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.document.Document; -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; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -@Log -public class ChatServiceImpl implements ChatService{ - @Value("${gemini.api.key}") - private String apiKey; - - @Override - public String generateFromPrompt(String prompt, List docs) throws Exception { - // 추천 받은 장소 정보 - String recommend = docs.stream() - .map(doc -> doc.getMetadata().get("title")+": "+ doc.getText()) - .collect(Collectors.joining("\n")); - - String finalPrompt = """ - 사용자의 질문은 CONTEXT로 제공됩니다. - 아래 RECOMMEND 섹션은 Vector DB에서 조회한 관련도가 높은 추천 결과입니다. - RECOMMEND와 CONTEXT를 바탕으로 사용자의 질문에 맞는 장소를 순서대로 추천해주세요. - - CONTEXT : %s - - RECOMMEND : %s - - 반환 형식은 아래와 같습니다 - - 장소 - - 주소 - - 장소 설명 - - 반려 동물 관련 정보 - - 추천 이유 - 위와 같은 형태로 추천도가 높은 순서대로 응답해주세요. - """.formatted(prompt, recommend); - - // Gemini API로 - ObjectMapper objectMapper = new ObjectMapper(); - HttpClient httpClient = HttpClient.newHttpClient(); - String url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=%s".formatted(apiKey); - - 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(finalPrompt)))))))) - .build(); - log.info("프롬프트 : " + finalPrompt); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - GeminiResponseDTO resp = objectMapper.readValue(response.body(), GeminiResponseDTO.class); - return resp.candidates().get(0).content().parts().get(0).text(); - } -} - diff --git a/src/main/java/io/github/petty/llm/service/ContentService.java b/src/main/java/io/github/petty/llm/service/ContentService.java new file mode 100644 index 0000000..5c2d0d7 --- /dev/null +++ b/src/main/java/io/github/petty/llm/service/ContentService.java @@ -0,0 +1,109 @@ +package io.github.petty.llm.service; + + +import io.github.petty.tour.dto.DetailPetDto; +import io.github.petty.tour.entity.Content; +import io.github.petty.tour.entity.ContentImage; +import io.github.petty.tour.repository.ContentImageRepository; +import io.github.petty.tour.repository.ContentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContentService { + // content ID 받아서 추가 정보 받기 + private final ContentRepository contentRepository; + private final ContentImageRepository contentImageRepository; + + private static final String DEFAULT_IMAGE_URL = "/assets/noimg.png"; + + public Optional findByContentId(String contentId){ + try { + Long id = Long.parseLong(contentId); + return contentRepository.findById(id); + } catch (NumberFormatException e) { + // contentid가 없을 때 + log.error("Invalid contentId format: {}", contentId, e); + return Optional.empty(); + } + } + + + + + public String getImageUrl(String contentId){ + try { + Long id = Long.parseLong(contentId); + + // 1. Content 엔티티에서 이미지 확인 + Optional contentOpt = contentRepository.findById(id); + if (contentOpt.isPresent()) { + Content content = contentOpt.get(); + + // firstimage 필드가 있다고 가정 (필드명은 실제 엔티티에 맞게 조정 필요) + String contentImage = content.getFirstImage(); + if (contentImage != null && !contentImage.isEmpty()) { + return contentImage; + } + } + + // 2. ContentImageRepository에서 이미지 검색 + List images = contentImageRepository.findByContent_ContentId(id); + if (images != null && !images.isEmpty()) { + // 첫 번째 이미지의 URL 반환 (필드명은 실제 엔티티에 맞게 조정 필요) + return images.get(0).getOriginImgUrl(); + } + + // 3. 이미지가 없는 경우 기본 이미지 반환 + return DEFAULT_IMAGE_URL; + + } catch (NumberFormatException e) { + log.error("Invalid contentId format: {}", contentId, e); + return DEFAULT_IMAGE_URL; + } + } + + + /* + if (pet != null) { + sb.append("반려 동물 정보: "); + if (pet.getAcmpyTypeCd() != null) + sb.append("동반 유형은 ").append(pet.getAcmpyTypeCd()).append(", "); + if (pet.getEtcAcmpyInfo() != null) + sb.append("가능 동물: ").append(pet.getEtcAcmpyInfo()).append(", "); + if (pet.getAcmpyPsblCpam() != null) + sb.append("추가 정보: ").append(pet.getAcmpyPsblCpam()).append(", "); + if (pet.getAcmpyNeedMtr() != null) + sb.append("준비물: ").append(pet.getAcmpyNeedMtr()).append(". "); + sb.append("\n"); + } + */ + public Optional getPetInfo(String contentId) { + try { + Long id = Long.parseLong(contentId); + Optional contentOpt = contentRepository.findById(id); + + if (contentOpt.isPresent()) { + Content content = contentOpt.get(); + if (content.getPetTourInfo() != null) { + DetailPetDto dto = new DetailPetDto(); + dto.setContentId(id); + dto.setAcmpyTypeCd(content.getPetTourInfo().getAcmpyTypeCd()); + dto.setAcmpyPsblCpam(content.getPetTourInfo().getAcmpyPsblCpam()); + dto.setAcmpyNeedMtr(content.getPetTourInfo().getAcmpyNeedMtr()); + return Optional.of(dto); + } + } + return Optional.empty(); + } catch (NumberFormatException e) { + log.error("반려동물 정보 관련 오류: {}", contentId, e); + return Optional.empty(); + } + } +} diff --git a/src/main/java/io/github/petty/llm/service/EmbeddingBatchService.java b/src/main/java/io/github/petty/llm/service/EmbeddingBatchService.java new file mode 100644 index 0000000..6fd8794 --- /dev/null +++ b/src/main/java/io/github/petty/llm/service/EmbeddingBatchService.java @@ -0,0 +1,83 @@ +package io.github.petty.llm.service; + +import io.github.petty.tour.entity.Content; +import io.github.petty.tour.repository.ContentRepository; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EmbeddingBatchService { + private final ContentRepository contentRepository; + private final VectorStoreService vectorStoreService; + + // Content 50개 저장 + public void saveAllContentsInBatch() { + // 1. 상위 50개 Content 조회 + List top50Contents = contentRepository.findTop20ByOrderByContentIdAsc(); + + log.info("Content 20개 가져오기 완료. 저장 시작!"); + + // 2. 바로 저장 (중복 검사 없이) + vectorStoreService.saveContents(top50Contents); + + log.info("Content 20개 저장 완료!"); + } + + // 기본 리스트 저장 +// public void saveAllContentsInBatch() { +// List allContents = contentRepository.findAll(); +// int batchSize = 50; +// int totalSize = allContents.size(); +// +// // 중간에 끊긴 번호부터 시작 +//// int startIndex = 450; +//// +//// // 중복 검사 = 현재 VectorStore에 저장된 contentId 리스트 가져오기 +//// List existingContentIds = vectorStoreService.findAllContentIds(); +//// +//// log.info("총 {}개 데이터, {}번부터 배치 크기 {}로 저장 시작", totalSize, startIndex, batchSize); +//// +//// for (int i = startIndex; i < totalSize; i += batchSize) { +//// int end = Math.min(i + batchSize, totalSize); +//// List batchList = allContents.subList(i, end); +//// +//// log.info("{}번째 batch 시작 ({} ~ {})", (i / batchSize) + 1, i, end); +//// +//// // 중복이면 제거하기 +//// List filteredBatch = batchList.stream() +//// .filter(content -> { +//// String contentId = content.getContentId().toString(); +//// return !existingContentIds.contains(contentId); +//// }) +//// .toList(); +//// +//// if (filteredBatch.isEmpty()) { +//// log.info("{}번째 batch: 저장할 새 데이터 없음 (모두 중복)", (i / batchSize) + 1); +//// continue; +//// } +//// +//// // 3. 저장 +//// vectorStoreService.saveContents(filteredBatch); +//// +//// log.info("{}번째 batch 저장 완료 ({}개 저장)", (i / batchSize) + 1, filteredBatch.size()); +//// } +//// } +// +// // 실패한 리스트 저장 +//// public void retryFailedContents(List failedContentIds) { +//// List failedContents = contentRepository.findAllById(failedContentIds); +//// +//// log.info("재시도할 Content 수: {}", failedContents.size()); +//// +//// vectorStoreService.saveContents(failedContents); +//// +//// log.info("재시도 저장 완료"); +//// } +} + diff --git a/src/main/java/io/github/petty/llm/service/EmbeddingService.java b/src/main/java/io/github/petty/llm/service/EmbeddingService.java index 8574656..fada2bd 100644 --- a/src/main/java/io/github/petty/llm/service/EmbeddingService.java +++ b/src/main/java/io/github/petty/llm/service/EmbeddingService.java @@ -1,5 +1,6 @@ package io.github.petty.llm.service; +import io.github.petty.llm.common.AreaCode; import io.github.petty.llm.common.ContentType; import io.github.petty.llm.dto.EmbeddingResult; import io.github.petty.tour.entity.Content; @@ -19,6 +20,7 @@ public class EmbeddingService { private final ContentRepository contentRepository; // OpenAI 임베딩 모델 private final EmbeddingModel embeddingModel; + private final GeminiPreprocessorService geminiPreprocessorService; /** * 콘텐츠 객체를 기반으로 자연스러운 문장 텍스트를 생성합니다. @@ -30,40 +32,32 @@ public class EmbeddingService { */ // 1. 텍스트 전처리 : 장소 설명들 다 모아서 문장으로 만들기 public String prepareContentText(Content content) { - StringBuilder sb = new StringBuilder(); - - // 유사도 검색을 위하여 자연스러운 문장으로 재생성 - // 가독성 위한 변수 분리 - String title = content.getTitle(); - String addr1 = content.getAddr1() != null ? content.getAddr1() : ""; - String addr2 = content.getAddr2() != null ? content.getAddr2() : ""; - String ContentTypeName = ContentType.fromCode(content.getContentTypeId()).getName(); - - - // 유사도 검색을 높이기 위한 자연스러운 텍스트로 변경 - sb.append("%s은/는 %s %s에 위치한 %s 장소입니다.\n" - .formatted(title, addr1, addr2, ContentTypeName)); - - if (content.getOverview() != null) { - sb.append("이곳의 설명은 ").append(content.getOverview()).append("\n"); - } - PetTourInfo pet = content.getPetTourInfo(); - if (pet != null) { - sb.append("반려 동물 정보: "); - if (pet.getAcmpyTypeCd() != null) - sb.append("동반 유형은 ").append(pet.getAcmpyTypeCd()).append(", "); - if (pet.getEtcAcmpyInfo() != null) - sb.append("가능 동물: ").append(pet.getEtcAcmpyInfo()).append(", "); - if (pet.getAcmpyPsblCpam() != null) - sb.append("추가 정보: ").append(pet.getAcmpyPsblCpam()).append(", "); - if (pet.getAcmpyNeedMtr() != null) - sb.append("준비물: ").append(pet.getAcmpyNeedMtr()).append(". "); - sb.append("\n"); + if (pet != null && "불가능".equals(pet.getAcmpyTypeCd())) { + // 반려동물 동반 불가능이면 아예 텍스트 생성 안 함 + return null; } - return sb.toString(); + + // 동반 가능할 때 생성 +// StringBuilder sb = new StringBuilder(); + + // 유사도 검색용 자연어 생성 +// String title = content.getTitle(); +// String addr1 = content.getAddr1() != null ? content.getAddr1() : ""; +// String addr2 = content.getAddr2() != null ? content.getAddr2() : ""; +// String ContentTypeName = ContentType.fromCode(content.getContentTypeId()).getName(); +// +// sb.append("%s은/는 %s %s에 위치한 %s 장소입니다.\n" +// .formatted(title, addr1, addr2, ContentTypeName)); +// +// if (content.getOverview() != null) { +// sb.append("이곳의 설명은 ").append(content.getOverview()).append("\n"); +// } + String preprocessedText = geminiPreprocessorService.preprocessContent(content); + return preprocessedText; } + /** * 주어진 EmbeddingResult와 Content를 Document 객체로 변환합니다. 이 메서드는 고유한 식별자를 할당하면서 content ID, 제목, 지역 코드, 콘텐츠 유형과 같은 메타데이터를 포함하여 문서를 생성합니다. @@ -83,10 +77,34 @@ public Document toDocument(EmbeddingResult result, Content content){ metadata.put("contentId", result.contentId()); metadata.put("title", content.getTitle()); - // areaCode (지역 관련 추가) - metadata.put("areaCode", content.getAreaCode()); - // sigunguCode (시군구 추가) - metadata.put("sigunguCode", content.getSigunguCode()); + // 1. areaCode 처리 + Integer areaCode = content.getAreaCode(); + if (areaCode == null || areaCode == 0) { + String addr1 = content.getAddr1(); + if (addr1 != null && !addr1.isBlank()) { + String[] parts = addr1.split(" "); + if (parts.length > 0) { + areaCode = AreaCode.fromName(parts[0]).getCode(); + } + } + } + metadata.put("areaCode", areaCode); + + // 2. sigunguCode 처리 + Integer sigunguCode = content.getSigunguCode(); + if (sigunguCode == null || sigunguCode == 0) { + String addr1 = content.getAddr1(); + if (addr1 != null && !addr1.isBlank()) { + String[] parts = addr1.split(" "); + if (parts.length > 1) { + // 간단히 두 번째 단어를 문자열 형태로 저장 + metadata.put("sigungu", parts[1]); + } + } + } else { + // 기존 sigunguCode가 있으면 그대로 + metadata.put("sigunguCode", sigunguCode); + } // 지역 String 추가 metadata.put("address", content.getAddr1()); // contentType (콘텐츠 관련 추가) @@ -110,6 +128,11 @@ public Document toDocument(EmbeddingResult result, Content content){ // 텍스트 정제 -> 임베딩 public EmbeddingResult embedContent(Content content) { String text = prepareContentText(content); + // text 비었을 때 + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("Embedding할 text가 비어있습니다: contentId=" + content.getContentId()); + } + // 임베딩 벡터 생성 EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text)); float[] output = response.getResults().get(0).getOutput(); diff --git a/src/main/java/io/github/petty/llm/service/GeminiPreprocessorService.java b/src/main/java/io/github/petty/llm/service/GeminiPreprocessorService.java new file mode 100644 index 0000000..83adf84 --- /dev/null +++ b/src/main/java/io/github/petty/llm/service/GeminiPreprocessorService.java @@ -0,0 +1,12 @@ +package io.github.petty.llm.service; + +import io.github.petty.tour.entity.Content; + +public interface GeminiPreprocessorService { + /** + * Content(장소 정보)로부터 전처리된 텍스트를 생성합니다. + * @param content 전처리할 Content 엔티티 + * @return Gemini를 활용한 전처리 텍스트 결과 + */ + String preprocessContent(Content content); +} diff --git a/src/main/java/io/github/petty/llm/service/GeminiPreprocessorServiceImpl.java b/src/main/java/io/github/petty/llm/service/GeminiPreprocessorServiceImpl.java new file mode 100644 index 0000000..84c3416 --- /dev/null +++ b/src/main/java/io/github/petty/llm/service/GeminiPreprocessorServiceImpl.java @@ -0,0 +1,106 @@ +package io.github.petty.llm.service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; +import io.github.petty.llm.common.ContentType; +import io.github.petty.llm.dto.GeminiRequestDTO; +import io.github.petty.llm.dto.GeminiResponseDTO; +import io.github.petty.tour.entity.Content; +import lombok.RequiredArgsConstructor; +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 GeminiPreprocessorServiceImpl implements GeminiPreprocessorService { + @Value("${gemini.api.key}") + private String apiKey; + + private final ObjectMapper objectMapper; + + @Override + public String preprocessContent(Content content) { + try { + String overview = content.getOverview(); + if (overview == null || overview.isBlank()) { + log.warn("[전처리] Overview 없음, 기본 텍스트 사용 (contentId: {})", content.getContentId()); + return defaultPrepareText(content); + } + + String finalPrompt = buildPrompt(overview); + + HttpClient httpClient = HttpClient.newHttpClient(); + String url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=%s" + .formatted(apiKey); + log.info("[Gemini 요청] 프롬프트:\n{}", finalPrompt); + 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(finalPrompt) + )) + )) + ) + )) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + GeminiResponseDTO resp = objectMapper.readValue(response.body(), GeminiResponseDTO.class); + String geminiResponseText = resp.candidates().get(0).content().parts().get(0).text(); + return generateFinalText(content, geminiResponseText); + } catch (Exception e) { + return defaultPrepareText(content); + } + } + + private String defaultPrepareText(Content content) { + String ContentTypeName = ContentType.fromCode(content.getContentTypeId()).getName(); + String defaultText = "%s은/는 %s 종류의 장소입니다.\n" + .formatted( + content.getTitle(), ContentTypeName + ); + return defaultText; + } + + private String buildPrompt(String overview) { + String prompt = """ + 당신은 관광지 요약 전문 모델입니다. + + 아래 '장소 설명'을 읽고, 이 장소를 사람들이 관광 목적으로 이해하기 쉽게 자연스럽게 한글 문장으로 요약하세요. + + - 설명이 충분하다면 주요 특징을 1~2문장 정도로 요약해주세요. + - 설명이 부실하거나 정보가 적으면 가능한 범위 내에서 간단히 요약하세요. + - 관광지 방문 목적, 활동 목적, 추천 대상을 포함하여 1문장으로 작성해주세요. 없다면 해당 설명에 맞는 문장을 생성해주세요. + - 결과는 포맷 없이 단순한 한글 문장으로 작성하세요. + - "키워드", "활동 목적", "추천 대상" 등의 제목이나 구분 없이 하나의 자연스러운 문단으로 작성하세요. + - 추가적인 주의사항, 요청사항, 키워드 리스트 등은 포함하지 마세요. + - 답변은 오직 자연스러운 한글 문장만 포함하세요. + + [장소 설명] + %s + """.formatted(overview); + return prompt; + } + + private String generateFinalText(Content content, String geminiResponse) { + String ContentTypeName = ContentType.fromCode(content.getContentTypeId()).getName(); + String prompt = "%s은/는 %s 종류의 장소입니다.\n %s" + .formatted( + content.getTitle(), ContentTypeName, geminiResponse + ); + log.info(prompt); + return prompt; + } +} diff --git a/src/main/java/io/github/petty/llm/service/RecommendService.java b/src/main/java/io/github/petty/llm/service/RecommendService.java index 1c79476..abeebe9 100644 --- a/src/main/java/io/github/petty/llm/service/RecommendService.java +++ b/src/main/java/io/github/petty/llm/service/RecommendService.java @@ -1,7 +1,9 @@ package io.github.petty.llm.service; +import io.github.petty.llm.dto.RecommendResponseDTO; + import java.util.Map; public interface RecommendService { - String recommend(Map promptMap); + RecommendResponseDTO recommend(Map promptMap); } 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 1274e68..fe93844 100644 --- a/src/main/java/io/github/petty/llm/service/RecommendServiceImpl.java +++ b/src/main/java/io/github/petty/llm/service/RecommendServiceImpl.java @@ -1,44 +1,55 @@ package io.github.petty.llm.service; +import lombok.extern.slf4j.Slf4j; import io.github.petty.llm.common.AreaCode; +import io.github.petty.llm.dto.RecommendResponseDTO; +import io.github.petty.tour.dto.DetailPetDto; import lombok.RequiredArgsConstructor; import org.springframework.ai.document.Document; -import org.springframework.http.ResponseEntity; +import org.springframework.ai.vectorstore.filter.Filter; +import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.stereotype.Service; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor public class RecommendServiceImpl implements RecommendService { // RecommendController를 RecommendService로 분리 private final VectorStoreService vectorStoreService; - private final ChatService chatService; - @Override - public String recommend(Map promptMap) { - // 사용자 입력 기반 프롬프트 구성 - String userPrompt = buildPrompt(promptMap); - - // 지역 기반 필터 생성 - String location = promptMap.get("location"); - String filterExpression = buildRegion(location); - - List docs = vectorStoreService.findSimilarWithFilter(userPrompt, 5, filterExpression); - - // Gemini 연결 + private final ContentService contentService; + @Override + public RecommendResponseDTO recommend(Map promptMap) { try { - String result = chatService.generateFromPrompt(userPrompt, docs); - return result; + // 사용자 입력 기반 프롬프트 구성 + String userPrompt = buildPrompt(promptMap); + + // 지역 기반 필터 생성 + String location = promptMap.getOrDefault("location", ""); + Filter.Expression filterExpression = buildRegion(location); + + // 벡터 검색 실행 + List docs = vectorStoreService.findSimilarWithFilter(userPrompt, 10, filterExpression); + if (docs.isEmpty()) { + log.warn("검색 결과가 없습니다: {}", userPrompt); + // 검색 결과가 없는 경우 처리 (빈 결과 반환 또는 기본값 등) + return new RecommendResponseDTO(new ArrayList<>()); + } + + // 결과로 바로 dto + return buildRecommendResponse(docs); } catch (Exception e) { - throw new RuntimeException(e); + log.error("추천 처리 중 오류 발생: {}", e.getMessage(), e); + throw new RuntimeException("추천 생성 실패: " + e.getMessage(), e); } } - // 사용자 입력 기반 프롬프트 생성 + // 사용자 입력 기반 프롬프트 생성 private String buildPrompt(Map promptMap) { StringBuilder sb = new StringBuilder(); @@ -64,16 +75,68 @@ private String buildPrompt(Map promptMap) { return sb.toString(); } + // 지역 필터 조건 생성 - private String buildRegion(String location) { - // ETC (지역 없을 때 대비) - if (location == null || location.isBlank()) return "areaCode == 0"; + private Filter.Expression buildRegion(String location) { + // String이 아닌 FilterExpression API를 이용해서 QureryDSL 그대로 반환 + FilterExpressionBuilder b = new FilterExpressionBuilder(); + + if (location == null || location.isBlank()) + return b.eq("areaCode", 0).build(); String[] parts = location.trim().split(" "); - if (parts.length == 0) return "areaCode == 0"; + if (parts.length == 0) + return b.eq("areaCode", 0).build(); String areaName = parts[0]; AreaCode areaCode = AreaCode.fromName(areaName); - return "areaCode == %d".formatted(areaCode.getCode()); + + // 기본 필터: areaCode + FilterExpressionBuilder.Op areaExpr = b.eq("areaCode", areaCode.getCode()); + + log.info(areaExpr.toString()); + // 시군구까지 필터링 + if (parts.length > 1) { + String sigungu = parts[1]; + return b.and(areaExpr, b.eq("sigungu", sigungu)).build(); + } + return areaExpr.build(); + } + + // 유사도 검색으로 dto 바로 생성 + private RecommendResponseDTO buildRecommendResponse(List docs) { + List recommends = new ArrayList<>(); + + for (Document doc : docs) { + 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(); +// 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 + )); + } + + return new RecommendResponseDTO(recommends); } } 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 1abdf66..7b51510 100644 --- a/src/main/java/io/github/petty/llm/service/VectorStoreService.java +++ b/src/main/java/io/github/petty/llm/service/VectorStoreService.java @@ -1,21 +1,19 @@ package io.github.petty.llm.service; -import groovy.util.logging.Slf4j; import io.github.petty.tour.entity.Content; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.vectorstore.*; +import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.stereotype.Service; import io.github.petty.llm.dto.EmbeddingResult; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import java.util.*; import java.util.stream.Collectors; -@lombok.extern.slf4j.Slf4j @Slf4j @Service @RequiredArgsConstructor @@ -24,16 +22,40 @@ public class VectorStoreService { private final EmbeddingService embeddingService; // 콘텐츠를 벡터 저장소에 저장 +// public void saveContents(List contents) { +// List documents = contents.stream() +// .map(content -> { +// EmbeddingResult result = embeddingService.embedContent(content); +// return embeddingService.toDocument(result, content); +// }) +// .collect(Collectors.toList()); +// log.info("embedding 완료, documents {}개 저장 시작", documents.size()); +// // Qdrant Vectorstore에 문서 추가 +// vectorStore.add(documents); +// log.info("documents 저장 완료"); +// } + public void saveContents(List contents) { List documents = contents.stream() .map(content -> { - EmbeddingResult result = embeddingService.embedContent(content); - return embeddingService.toDocument(result, content); + try { + EmbeddingResult result = embeddingService.embedContent(content); + return embeddingService.toDocument(result, content); + } catch (IllegalArgumentException e) { + log.warn("Embedding 실패 - contentId: {}. 사유: {}", content.getContentId(), e.getMessage()); + return null; // 실패한 건 버림 + } }) + .filter(Objects::nonNull) // null인 건 제외 .collect(Collectors.toList()); - - // Qdrant Vectorstore에 문서 추가 - vectorStore.add(documents); + + if (!documents.isEmpty()) { + log.info("embedding 완료, documents {}개 저장 시작", documents.size()); + vectorStore.add(documents); + log.info("documents 저장 완료"); + } else { + log.info("저장할 documents 없음 (모두 실패)"); + } } // 유사도 검색 @@ -48,7 +70,7 @@ public List findSimilarContents(String query, int k) { } // 필터 조건을 사용한 유사 콘텐츠 검색 - public List findSimilarWithFilter(String query, int k, String filterExpression) { + public List findSimilarWithFilter(String query, int k, Filter.Expression filterExpression) { SearchRequest searchRequest = SearchRequest.builder() .query(query) .topK(k) @@ -67,6 +89,17 @@ public List findSimilarWithFilter(String query, int k, String filterEx return results; } + // ContentID로 중복 제거 + public List findAllContentIds() { + List allDocuments = vectorStore.similaritySearch(SearchRequest.builder() + .query("시") // 더미텍스트 + .topK(10000) + .build()); + + return allDocuments.stream() + .map(doc -> doc.getMetadata().get("contentId").toString()) + .collect(Collectors.toList()); + } // 저장된 벡터 삭제 public void deleteByIds(List ids) { vectorStore.delete(ids); diff --git a/src/main/java/io/github/petty/pipeline/controller/PipelineController.java b/src/main/java/io/github/petty/pipeline/controller/PipelineController.java index 1a2f8a2..7ca4f4a 100644 --- a/src/main/java/io/github/petty/pipeline/controller/PipelineController.java +++ b/src/main/java/io/github/petty/pipeline/controller/PipelineController.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.petty.llm.dto.RecommendResponseDTO; import io.github.petty.llm.service.RecommendService; import io.github.petty.pipeline.support.TogetherPromptBuilder; import io.github.petty.vision.service.VisionServiceImpl; @@ -52,7 +53,7 @@ public String processPipeline( ObjectMapper objectMapper = new ObjectMapper(); Map promptMapper = objectMapper.readValue(jsonPrompt, new TypeReference<>() {}); log.info(promptMapper.toString()); - String prompt = recommendService.recommend(promptMapper); + RecommendResponseDTO prompt = recommendService.recommend(promptMapper); model.addAttribute("recommendation", prompt); return "pipeline"; 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 ed5c39d..7d1a82a 100644 --- a/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java +++ b/src/main/java/io/github/petty/pipeline/controller/UnifiedFlowController.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.petty.llm.dto.RecommendResponseDTO; import io.github.petty.llm.service.RecommendService; import io.github.petty.pipeline.support.TogetherPromptBuilder; import io.github.petty.vision.port.in.VisionUseCase; @@ -51,7 +52,7 @@ public String analyze( String jsonPrompt = togetherPromptBuilder.buildPrompt(visionReport, location); log.info("📌 location = {}", location); Map promptMapper = new ObjectMapper().readValue(jsonPrompt, new TypeReference<>() {}); - String recommendation = recommendService.recommend(promptMapper); + RecommendResponseDTO recommendation = recommendService.recommend(promptMapper); // 4. 화면에 전달 model.addAttribute("interim", interim); diff --git a/src/main/resources/static/assets/noimg.png b/src/main/resources/static/assets/noimg.png new file mode 100644 index 0000000..c6a1432 Binary files /dev/null and b/src/main/resources/static/assets/noimg.png differ diff --git a/src/main/resources/templates/recommend.html b/src/main/resources/templates/recommend.html index d41aa07..e176c74 100644 --- a/src/main/resources/templates/recommend.html +++ b/src/main/resources/templates/recommend.html @@ -43,6 +43,70 @@ color: #f39c12; margin: 10px 0; } + #resultBox { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 20px; + } + .recommend-card { + background-color: #fff; + padding: 15px; + border-radius: 10px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + } + .recommend-card img { + width: 100%; + height: 180px; + object-fit: cover; + border-radius: 8px; + margin-bottom: 10px; + } + .recommend-card h2 { + font-size: 1.2em; + margin: 10px 0 5px; + } + .recommend-card p { + font-size: 0.9em; + margin: 3px 0; + } + #recommendForm { + background-color: #fff; + padding: 20px; + border-radius: 10px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + margin-bottom: 30px; + max-width: 600px; + } + #recommendForm label { + display: block; + margin-bottom: 10px; + font-weight: bold; + } + #recommendForm input, #recommendForm select { + width: 100%; + padding: 8px; + margin-top: 5px; + border-radius: 5px; + border: 1px solid #ccc; + box-sizing: border-box; + } + #recommendForm button { + margin-top: 15px; + background-color: #f39c12; + color: white; + border: none; + padding: 10px 15px; + border-radius: 5px; + cursor: pointer; + width: 100%; + font-size: 1em; + } + #recommendForm button:hover { + background-color: #f1c40f; + } @@ -70,20 +134,31 @@

사용자 정보

-
-
-
-
- +
+