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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ dependencies {
//blue-green
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// Google Cloud Vertex AI (Gemini)
implementation 'com.google.cloud:google-cloud-vertexai:1.14.0'
Comment on lines +88 to +89
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

google-cloud-vertexai latest version maven central 2025

💡 Result:

Latest Maven Central version (Nov 30, 2025): com.google.cloud:google-cloud-vertexai — 1.40.0 (released Nov 15, 2025). [1][2]

Maven coordinate:

com.google.cloud
google-cloud-vertexai
1.40.0

Sources:
[1] Google Cloud Java docs (google-cloud-vertexai overview, 1.40.0).
[2] Maven Central (repo1.maven.org) listing for com/google/cloud/google-cloud-vertexai showing 1.40.0 (2025-11-15).


Update google-cloud-vertexai to the latest version.

The dependency is outdated. Version 1.14.0 is significantly behind the current latest release (1.40.0, released November 15, 2025). Update the dependency to:

implementation 'com.google.cloud:google-cloud-vertexai:1.40.0'

This 26-version gap includes important updates, security patches, and feature improvements that should be incorporated.

🤖 Prompt for AI Agents
In build.gradle around lines 88 to 89 the google-cloud-vertexai dependency is
pinned to 1.14.0 and needs to be updated; change the implementation coordinate
to use the latest release (replace 1.14.0 with 1.40.0) so the project pulls the
updated library with recent fixes and features, then run a dependency
refresh/build to verify no breaking changes and update any code or tests if the
newer client API requires adjustments.


}

clean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package com.umc.linkyou.service.curation.gemini;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.cloud.vertexai.VertexAI;
import com.google.cloud.vertexai.api.GenerateContentResponse;
import com.google.cloud.vertexai.api.GenerationConfig;
import com.google.cloud.vertexai.api.Tool;
import com.google.cloud.vertexai.api.GoogleSearchRetrieval;
import com.google.cloud.vertexai.generativeai.ContentMaker;
import com.google.cloud.vertexai.generativeai.GenerativeModel;
import com.google.cloud.vertexai.generativeai.ResponseHandler;
import com.umc.linkyou.web.dto.curation.RecommendedLinkResponse;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.net.URI;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class GeminiExternalSearchService {

private final ObjectMapper objectMapper;

// application.properties에서 값 가져오기
@Value("${spring.cloud.gcp.project-id}")
private String projectId;

@Value("${spring.cloud.gcp.location}")
private String location;

@Value("${gemini.model.name}")
private String modelName;

private VertexAI vertexAI;
private GenerativeModel model;

/**
* 서버 시작 시 Gemini 모델과 연결 설정 (Client 역할 대체)
*/
@PostConstruct
public void init() {
try {
// 1. Vertex AI 클라이언트 초기화
// (참고: 로컬 개발 시 환경변수 GOOGLE_APPLICATION_CREDENTIALS 설정 필수)
this.vertexAI = new VertexAI(projectId, location);

// 2. 구글 검색 도구(Grounding) 설정
Tool googleSearchTool = Tool.newBuilder()
.setGoogleSearchRetrieval(
GoogleSearchRetrieval.newBuilder().build()
)
.build();

// 3. 생성 설정 (JSON 포맷 강제 등)
GenerationConfig generationConfig = GenerationConfig.newBuilder()
.setMaxOutputTokens(2048)
.setTemperature(0.9f) // 창의성(다양한 검색 결과)을 위해 높임
.setResponseMimeType("application/json") // JSON 응답 강제
.build();

// 4. 모델 생성
this.model = new GenerativeModel.Builder()
.setModelName(modelName)
.setVertexAi(vertexAI)
.setTools(Collections.singletonList(googleSearchTool))
.setGenerationConfig(generationConfig)
.build();

log.info("✅ Gemini Search Service 초기화 완료 (Project: {}, Location: {})", projectId, location);

} catch (Exception e) {
log.error("❌ Gemini 초기화 실패: GCP 인증 파일이나 설정을 확인하세요.", e);
}
}
Comment on lines +49 to +83
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

초기화 실패 시 서비스가 비정상 상태로 유지됩니다.

init() 메서드에서 예외 발생 시 로그만 남기고 계속 진행되어 modelnull인 상태로 서비스가 Spring 컨테이너에 등록됩니다. 이후 searchExternalLinks() 호출 시 NullPointerException이 발생할 수 있습니다.

초기화 실패 시 애플리케이션 시작을 중단하거나, searchExternalLinks()에서 model null 체크를 추가하세요:

     @PostConstruct
     public void init() {
         try {
             // ... 초기화 코드 ...
             log.info("✅ Gemini Search Service 초기화 완료 (Project: {}, Location: {})", projectId, location);

         } catch (Exception e) {
-            log.error("❌ Gemini 초기화 실패: GCP 인증 파일이나 설정을 확인하세요.", e);
+            log.error("❌ Gemini 초기화 실패: GCP 인증 파일이나 설정을 확인하세요.", e);
+            throw new IllegalStateException("Gemini 서비스 초기화 실패", e);
         }
     }

또는 searchExternalLinks() 시작 부분에 방어 코드를 추가하세요:

if (this.model == null) {
    log.warn("Gemini 모델이 초기화되지 않음 - 빈 결과 반환");
    return Collections.emptyList();
}
🤖 Prompt for AI Agents
In
src/main/java/com/umc/linkyou/service/curation/gemini/GeminiExternalSearchService.java
around lines 49–83, the init() catch currently only logs the exception which
leaves the service registered with a null model; update the catch to fail fast
by rethrowing a RuntimeException (wrap the caught Exception with a clear
message) so Spring does not start the app in a broken state, and additionally
add a defensive null-check at the start of searchExternalLinks() that logs a
warning and returns an empty list if this.model is null to avoid
NullPointerException in case initialization still failed.


/**
* 서버 종료 시 리소스 정리
*/
@PreDestroy
public void close() {
if (this.vertexAI != null) {
this.vertexAI.close();
}
}

/**
* 외부 링크 추천 기능 메인 로직
*/
public List<RecommendedLinkResponse> searchExternalLinks(
List<String> recentUrls,
List<String> tagNames,
int limit,
String jobName,
String gender
) {
// 중복 방지를 위해 이미 본 URL에서 도메인만 추출 (예: naver.com, tistory.com)
String excludedDomains = recentUrls.stream()
.map(this::extractDomain)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.joining(", "));

// 1. 시스템 프롬프트 (규칙 정의)
String systemInstruction = """
You are a professional content curator for '%s'.
Target Audience Job: %s
[CRITICAL RULES]
1. Use Google Search to find REAL, LIVE web pages.
2. EXCLUDE content from these domains: [%s] (User already saw them).
3. Find NEW content (Published within the last 1 year preferred).
4. Output must be a pure JSON Array.
5. Fields: "title", "url", "summary".
""".formatted(safe(jobName), safe(jobName), excludedDomains);

// 2. 유저 프롬프트 (실제 요청)
String userPrompt = """
Find %d high-quality, practical links about: %s.
Focus on tutorials, trends, or engineering blogs.
Exclude generic wikis.
""".formatted(limit, String.join(", ", tagNames));

try {
// 3. Gemini에게 질문 (여기가 Client.chat() 역할)
GenerateContentResponse response = model.generateContent(
ContentMaker.fromMultiModalData(systemInstruction + "\n\n" + userPrompt)
);

// 4. 응답 텍스트 추출
String jsonResponse = ResponseHandler.getText(response);
log.info("Gemini 응답: {}", jsonResponse);

// 5. JSON 파싱
List<Map<String, String>> parsed = objectMapper.readValue(jsonResponse, new TypeReference<>() {});

// 6. DTO 변환
return parsed.stream()
.filter(m -> m.get("url") != null && !m.get("url").isBlank())
.limit(limit)
.map(m -> RecommendedLinkResponse.builder()
.title(m.getOrDefault("title", "No Title"))
.url(m.get("url"))
.domain(extractDomain(m.get("url")))
.build())
.collect(Collectors.toList());

} catch (Exception e) {
log.error("Gemini 검색 중 오류 발생", e);
return Collections.emptyList();
}
}

// null 방지용
private String safe(String s) {
return (s == null || s.isBlank()) ? "Technology" : s;
}

// URL에서 도메인 추출 (예: https://www.naver.com/news -> naver.com)
private String extractDomain(String url) {
try {
URI uri = new URI(url);
String domain = uri.getHost();
if (domain != null && domain.startsWith("www.")) {
return domain.substring(4);
}
return domain;
} catch (Exception e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import com.umc.linkyou.repository.classification.domainRepository.DomainRepositoryCustom;
import com.umc.linkyou.service.curation.gpt.GptService;
import com.umc.linkyou.domain.log.CurationTopLog;
import com.umc.linkyou.service.curation.perplexity.PerplexityExternalSearchService;
// import com.umc.linkyou.service.curation.perplexity.PerplexityExternalSearchService;
import com.umc.linkyou.service.curation.gemini.GeminiExternalSearchService;
import com.umc.linkyou.utils.UrlValidUtils;
import com.umc.linkyou.web.dto.curation.RecommendedLinkResponse;
import lombok.RequiredArgsConstructor;
Expand All @@ -25,7 +26,8 @@ public class ExternalRecommendServiceImpl implements ExternalRecommendService {
private final GptService gptService;
private final DomainRepositoryCustom domainRepository;
private final LinkToImageService linkToImageService;
private final PerplexityExternalSearchService perplexityExternalSearchService;
// private final PerplexityExternalSearchService perplexityExternalSearchService; // Perplexity 사용
private final GeminiExternalSearchService geminiExternalSearchService; // Gemini 사용
private final UserRepository userRepository;

@Override
Expand Down Expand Up @@ -54,7 +56,7 @@ public List<RecommendedLinkResponse> getExternalRecommendations(Long userId, Lon
// Perplexity 기반 외부 추천 받기
List<RecommendedLinkResponse> external;
try {
external = perplexityExternalSearchService.searchExternalLinks(
external = geminiExternalSearchService.searchExternalLinks(
recentUrls,
tagNames,
externalLimit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import com.umc.linkyou.repository.LogRepository.CurationTopLogRepository;
import com.umc.linkyou.repository.userRepository.UserRepository;
import com.umc.linkyou.repository.curationLinkuRepository.CurationLinkuRepository;
import com.umc.linkyou.service.curation.perplexity.PerplexityExternalSearchService;
// import com.umc.linkyou.service.curation.perplexity.PerplexityExternalSearchService; //Perplexity 사용
import com.umc.linkyou.service.curation.gemini.GeminiExternalSearchService;
import com.umc.linkyou.web.dto.curation.RecommendedLinkResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -27,7 +28,8 @@ public class ExternalRecommendWorker {
private final CurationLinkuRepository curationLinkuRepository;
private final InternalLinkCandidateService internalLinkCandidateService;
private final CurationTopLogRepository curationTopLogRepository;
private final PerplexityExternalSearchService perplexityExternalSearchService;
// private final PerplexityExternalSearchService perplexityExternalSearchService; // Perplexity 사용
private final GeminiExternalSearchService geminiExternalSearchService;
private final UserRepository userRepository;
private final LinkToImageService linkToImageService; // 저장 시점에만 사용

Expand Down Expand Up @@ -61,7 +63,7 @@ public int generateAndStoreExternal(Long curationId) {
List<RecommendedLinkResponse> external;
try {
long t0 = System.currentTimeMillis();
external = perplexityExternalSearchService.searchExternalLinks(
external = geminiExternalSearchService.searchExternalLinks(
recentUrls, topTags, externalLimit, jobName, gender
);
log.info("[Perplexity] elapsed={}ms", System.currentTimeMillis() - t0);
Expand Down