-
Notifications
You must be signed in to change notification settings - Fork 0
[GOMO-39] LLM 퀘스트 생성 마이그레이션 #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 9 commits
c124b8b
b505f33
801f692
59a4e37
f83f88f
d53c9e9
db1bd16
826c1b1
a7ccb3f
3c496ad
979f468
6670d71
90628e0
2e3e5e4
81f4f88
b4f3ee8
18c0ca4
bd36080
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
|
||
| @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"), | ||
nurdy-kim marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 { | ||
nurdy-kim marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 { | ||
nurdy-kim marked this conversation as resolved.
Show resolved
Hide resolved
nurdy-kim marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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); | ||
|
||
| } 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( | ||
|
||
| 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){ | ||
nurdy-kim marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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( | ||
nurdy-kim marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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){} | ||
| } | ||
|
|
||
|
|
||
| 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``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
헥사고날 도입을 결정한 만큼, application service가 adapter를 직접 의존하는 것은 피하면 좋을 것 같습니다!
GeminiGenerateTextUseCase는 outbound-port 의존infrastructure/adapter/GeminiApiAdapter가 outbound-port 구현