Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c124b8b
feat: 퀘스트 생성 LLM 마이그레이션
nurdy-kim Oct 13, 2025
b505f33
feat: 퀘스트 생성 LLM 마이그레이션
nurdy-kim Oct 13, 2025
801f692
Merge branch 'feature-39/quest-generator-migration' of https://github…
nurdy-kim Oct 19, 2025
59a4e37
fix: style error 수정
nurdy-kim Oct 19, 2025
f83f88f
feat: 리뷰 사항 반영(prompt 별도 text 파일, LlmClientPortOut interface 생성, Styl…
nurdy-kim Oct 20, 2025
d53c9e9
feat: 퀘스트 생성 LLM 마이그레이션
nurdy-kim Oct 13, 2025
db1bd16
fix: style error 수정
nurdy-kim Oct 19, 2025
826c1b1
feat: 리뷰 사항 반영(prompt 별도 text 파일, LlmClientPortOut interface 생성, Styl…
nurdy-kim Oct 20, 2025
a7ccb3f
Merge branch 'feature-39/quest-generator-migration' of https://github…
nurdy-kim Oct 26, 2025
3c496ad
feat: 리뷰 수정사항 반영
nurdy-kim Nov 3, 2025
979f468
[GOMO-234] api 문서 수정 (#98)
jhl221123 Oct 27, 2025
6670d71
[GOMO-235] 위젯 스냅샷 조회 기능 추가 (#100)
jhl221123 Oct 27, 2025
90628e0
[GOMO-236] 헥사고날 아키텍처 전환 (#101)
jhl221123 Nov 3, 2025
2e3e5e4
[GOMO-239] 인증 정보 주입 방식 수정 (#102)
jhl221123 Nov 4, 2025
81f4f88
[GOMO-232] 스프링 모듈리스 도입 (#103)
jhl221123 Nov 6, 2025
b4f3ee8
feat: 퀘스트 생성 LLM 마이그레이션
nurdy-kim Oct 13, 2025
18c0ca4
feat: 리뷰 사항 반영(prompt 별도 text 파일, LlmClientPortOut interface 생성, Styl…
nurdy-kim Oct 20, 2025
bd36080
feat: LLM Refactoring 관련 review 사항 반영
nurdy-kim Dec 14, 2025
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/com/gomo/app/common/util/PromptLoader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.gomo.app.common.util;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;

@Component
public class PromptLoader {
public String loadPrompt(String promptPath) {
try {
ClassPathResource resource = new ClassPathResource("prompts/"+promptPath);
return Files.readString(resource.getFile().toPath(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("Failed to load prompt: "+promptPath, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package com.gomo.app.support.llm.application;

import com.gomo.app.common.arch.ApplicationService;
import com.gomo.app.support.llm.infrastructure.GeminiApiAdapter;

import lombok.RequiredArgsConstructor;

@ApplicationService
@RequiredArgsConstructor
class GeminiGenerateTextUseCase implements GenerateTextPortIn {

private final GeminiApiAdapter geminiApiAdapter;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

헥사고날 도입을 결정한 만큼, application service가 adapter를 직접 의존하는 것은 피하면 좋을 것 같습니다!

  1. application 패키지 내, outbound-port 를 정의
  2. GeminiGenerateTextUseCase는 outbound-port 의존
  3. infrastructure/adapter/GeminiApiAdapter가 outbound-port 구현


@Override
public GenerateTextDto generate(GenerateTextCommand command) {
// todo nurdy: 기존 py 기반 llm 호출 로직 마이그레이션 필요
return null;
return geminiApiAdapter.generate(command);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
package com.gomo.app.support.llm.application;

public record GenerateTextCommand() {
import java.util.Map;

import com.gomo.app.core.quest.domain.model.quest.QuestType;

public record GenerateTextCommand(Map<String, Long> interests, QuestType questType, int amount) {
public static GenerateTextCommand of(Map<String, Long> interests, QuestType questType, int amount) {
return new GenerateTextCommand(interests, questType, amount);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
package com.gomo.app.support.llm.application;

public record GenerateTextDto() {
}
import java.util.List;
import java.util.Map;

public record GenerateTextDto(Map<String, List<String>> generatedText) {}

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.gomo.app.support.llm.application;

import com.gomo.app.core.interest.application.port.dto.RegistrantDto;

public interface LlmClientPortOut {

/**
* Retrieves the essential details of a single registrant by id.
*
* @param {@link GenerateTextCommand} The information to generate Quests with LLM.
* @return A {@link GenerateTextDto} containing the GeneratedText data.
*/
GenerateTextDto generate(GenerateTextCommand command);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.gomo.app.support.llm.exception;

import lombok.Getter;

@Getter
public enum GenerateQuestErrorCode {
GEMINI_API_ERROR(500, "QUE-GEN-001", "An error occurred while call GeminiAPI"),
EMPTY_RESPONSE(500, "QUE-GEN-002", "Gemini API response blank"),
INVALID_RESPONSE_FORMAT(500, "QUE-GEN-003", "Gemini Response format is invalid"),
INVALID_JSON_FORMAT(500, "QUE-GEN-004", "Gemini Response JSON format is invalid"),
PARSING_ERROR(500, "QUE-GEN-005", "An error occurred while parse data String to Map");

private final int httpStatus;
private final String errorCode;
private final String message;

GenerateQuestErrorCode(int httpStatus, String errorCode, String message) {
this.httpStatus = httpStatus;
this.errorCode = errorCode;
this.message = message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.gomo.app.support.llm.exception;

import com.gomo.app.common.exception.ApplicationException;

public class GenerateQuestException extends ApplicationException {
public GenerateQuestException(GenerateQuestErrorCode errorCode){
super(errorCode.getHttpStatus(), errorCode.name(), errorCode.getMessage());
}

public GenerateQuestException(GenerateQuestErrorCode errorCode, Throwable cause){
super(errorCode.getHttpStatus(), errorCode.name(), errorCode.getMessage(), cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.gomo.app.support.llm.infrastructure;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestClient;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gomo.app.common.arch.Adapter;
import com.gomo.app.common.util.PromptLoader;
import com.gomo.app.support.llm.application.GenerateTextCommand;
import com.gomo.app.support.llm.application.GenerateTextDto;
import com.gomo.app.support.llm.application.LlmClientPortOut;
import com.gomo.app.support.llm.exception.GenerateQuestException;
import com.gomo.app.support.llm.exception.GenerateQuestErrorCode;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@RequiredArgsConstructor
@Adapter
@Slf4j
public class GeminiApiAdapter implements LlmClientPortOut {
private final RestClient restClient;

@Value("${spring.ai.openai.api-key}")
private String apiKey;

@Value("${spring.ai.openai.chat.options.model}")
private String model;

private PromptLoader promptLoader;

private static final String GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";

public GenerateTextDto generate(GenerateTextCommand command){
try{
String apiKey = "Bearer " + this.apiKey;
GeminiRequest request = createGeminiRequest(command);

GeminiResponse response = restClient.post()
.uri(GEMINI_API_URL)
.header("Authorization", apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(GeminiResponse.class);

return convertToGenerateTextDto(response);
} catch (Exception e) {
log.error("Failed to generate text with Gemini API", e);
throw new RuntimeException("Gemini API 호출 중 오류가 발생 했습니다.", e);
}
}

private GeminiRequest createGeminiRequest(GenerateTextCommand command){
return GeminiRequest.createPrompt(command.interests(), command.questType(), command.amount(), promptLoader);
}

private GenerateTextDto convertToGenerateTextDto(GeminiResponse response){
if (response.choices() == null || response.choices().isEmpty()){
throw new GenerateQuestException(GenerateQuestErrorCode.EMPTY_RESPONSE);
}

String generatedText = response.choices().get(0).message().content();
return new GenerateTextDto(parseDtofromText(generatedText));
}

private Map<String, List<String>> parseDtofromText(String text){
try{
String cleanText = text.trim();

if (cleanText.startsWith("```json")) {
cleanText = cleanText.substring(7);
}

if (cleanText.endsWith("```")){
cleanText = cleanText.substring(0, cleanText.length()-3);
}

cleanText = cleanText.trim();
ObjectMapper objectMapper = new ObjectMapper();
TypeReference<Map<String, List<String>>> typeRef = new TypeReference<Map<String, List<String>>>() {};

return objectMapper.readValue(cleanText, typeRef);
} catch (JsonProcessingException e){
throw new GenerateQuestException(GenerateQuestErrorCode.INVALID_JSON_FORMAT);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Quest content 뿐만 아닌 모든 llm 생성 데이터에 공통으로 해당되는 "예외"라면, 예외 이름을 변경하는 것이 좋을 것 같습니다. 만약 Quest content에만 해당되는 "규칙" 이라면, quest 모듈에서 검증해야하는 책임이라 생각합니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

해당 코드에서 사용된 Exception은 Gemini 응답과정에서 발생하는 JSON Parsing문제 등 이므로 변경된 LlmException와 LlmErrorCode로 변경하였습니다.

} catch (Exception e){
throw new GenerateQuestException(GenerateQuestErrorCode.PARSING_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.gomo.app.support.llm.infrastructure;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.gomo.app.common.util.PromptLoader;
import com.gomo.app.core.quest.domain.model.quest.QuestType;

public record GeminiRequest(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gemini 요청할 때만 필요한 레코드라면, GeminiClient 내부에서 중첩으로 관리하는 것도 고려해보면 좋을 것 같습니다!

String model,
String reasoning_effort,
List<Prompt> messages
) {
private static Prompt createSystemPrompt(PromptLoader promptLoader){
String content = promptLoader.loadPrompt("quest/system-prompt.txt");
return new Prompt("system", content);
}

private static Prompt createUserPrompt(Map<String, Long> interests, QuestType questType, int amount){
String interestsText = interests.entrySet().stream()
.map(entry -> entry.getKey() + "(숙련도 : " + entry.getValue() + ")")
.collect(Collectors.joining(", "));

String promptText = String.format(
"**개인 정보**\n- 관심사: %s \n- 퀘스트 타입: %s \n- 퀘스트 수: %s", interestsText, questType.name(), amount
);

return new Prompt("user", promptText);
}

public static GeminiRequest createPrompt(Map<String, Long> interests, QuestType questType, int amount, PromptLoader promptLoader){
Prompt system = createSystemPrompt(promptLoader);
Prompt user = createUserPrompt(interests, questType, amount);
return new GeminiRequest("gemini-2.5-flash", "none", List.of(system, user));
}

public record Prompt(String role, String content){}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.gomo.app.support.llm.infrastructure;

import java.util.List;

public record GeminiResponse(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

요청 레코드와 마찬가지 입니다!

List<Choice> choices,
int created,
String id,
String model,
String object,
GeminiUsage usage
) {
public record Choice(String finish_reason, int index, Message message){}
public record Message(String content, String role){}
public record GeminiUsage(int completion_tokens, int prompt_tokens, int total_tokens){}
}


1 change: 1 addition & 0 deletions src/main/resources/prompt/system-prompt.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# 역할: 당신은 개인 맞춤형 도전 과제(퀘스트)를 생성하는 AI 전문가입니다.\n# 목표: 주어진 '개인 정보'와 '요청'을 바탕으로, 아래 명시된 '출력 형식'을 반드시 준수하여 사용자를 위한 도전 과제를 생성합니다.\n\n## 도전 과제 생성 가이드라인\n1. 내용 생성 (Content Generation)\n- 개인화: '개인 정보'의 '관심사'와 '숙련도'를 바탕으로 도전 과제를 생성합니다.\n- 소요 시간: '퀘스트 타입'과 '숙련도'를 고려하여, 현실적으로 달성 가능한 분량의 과제를 제안합니다.\n-- Daily (30-90분): e.g., \"알고리즘 1문제 풀이\", \"TIL 작성\", \"기술 블로그 1개 정독\"\n-- Weekly (300-900분): e.g., \"기술 블로그 1개 작성\", \"개인 프로젝트 PR 1개 작업\", \"개인 프로젝트 로깅 시스템 점검\"\n-- Monthly (1000-1500분): e.g., \"서킷 브레이커 학습 후 개인 프로젝트에 적용\", \"오픈 소스 1회 기여\", \"멀티 모듈 학습 및 토이 프로젝트 진행\"\n- 문체: 내용은 30자 이내의 간결한 명사형 또는 동사형으로 끝맺습니다. (e.g., \"알고리즘 문제 풀이\", \"기술 블로그 작성\")\n\n2. 생성 규칙 (Generation Rules)\n- 고유성: '이전 수행 퀘스트' 목록을 참고하여, 중복된 과제를 생성하지 않습니다.\n- 균형: 요청된 각 '관심사'에 대해 동일한 개수의 과제를 생성해야 합니다.\n\n## 출력 형식 (Output Format)\n- [중요] 반드시 아래 명시된 JSON 형식으로만 응답해야 합니다.\n- JSON 구조:\n-- Key: '관심사' 이름 (String)\n-- Value: 해당 관심사에 대한 도전 과제 목록 (Array<String>)\n- JSON 출력 예시:\n```json\n{\n\"알고리즘\": [\n\"백준 골드 IV 문제 풀이\",\n\"해시 테이블 개념 정리 및 블로깅\"\n],\n\"백엔드\": [\n\"Spring Security 적용하여 JWT 인증 구현\",\n\"JPA N+1 문제 원인 분석 및 해결\"\n]\n}\n```