Skip to content

Commit e6e65f8

Browse files
authored
AIM-17-챌린지-루트-생성-로직-구현 (#12)
* feat: langchain4j에 대한 dependency 추가 * feat: gemini 사용을 위한 configuration 설정 * fix: config에 따른 langchain4j dependency 수정 * feat: LLM 호출 결과를 담을 DTO 정의 * feat: LLM 프롬포트 Generator 정의 * refact: Payload 타겟 시간을 integer로 변환 * feat: 챌린지 주차별 내용 생성 로직 구현 * feat: service 테스트 코드 작성
1 parent 162c918 commit e6e65f8

File tree

11 files changed

+611
-1
lines changed

11 files changed

+611
-1
lines changed

build.gradle

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ dependencies {
6767
// Validation
6868
implementation 'org.springframework.boot:spring-boot-starter-validation'
6969

70-
//oauth
70+
// oauth
7171
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
7272

73+
// Langchain4j
74+
implementation 'dev.langchain4j:langchain4j:1.0.0-beta3'
75+
implementation 'dev.langchain4j:langchain4j-spring-boot-starter:1.0.0-beta3'
76+
implementation 'dev.langchain4j:langchain4j-google-ai-gemini:1.0.0-beta3'
7377
}
7478

7579
tasks.named('test') {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package targeter.aim.domain.ai.llm.dto;
2+
3+
import lombok.Data;
4+
5+
import java.util.List;
6+
7+
@Data
8+
public class RoutePayload {
9+
private List<Week> weeks;
10+
11+
@Data
12+
public static class Week {
13+
private Integer weekNumber; // 주차
14+
private String title; // 제목
15+
private String content; // 내용
16+
private Integer targetSeconds; // 목표 시간초
17+
}
18+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package targeter.aim.domain.ai.llm.service;
2+
3+
import dev.langchain4j.service.SystemMessage;
4+
import dev.langchain4j.service.UserMessage;
5+
import dev.langchain4j.service.V;
6+
import dev.langchain4j.service.spring.AiService;
7+
import targeter.aim.domain.ai.llm.dto.RoutePayload;
8+
9+
@AiService
10+
public interface RouteGenerator {
11+
12+
@SystemMessage("""
13+
You are a world-class expert in the requested field and a supportive mentor.
14+
Your goal is to create a highly effective, step-by-step learning path (Challenge Route) that guides the mentee to success.
15+
16+
[YOUR PERSONA]
17+
1. Professional Authority: Deep knowledge of the provided tags and jobs.
18+
2. Mentor's Heart: Encouraging and supportive.
19+
3. Practicality: Focus on actionable items.
20+
21+
[SEASONAL ADAPTATION]
22+
You must consider the 'Start Date' of the challenge.
23+
- If the challenge involves physical activity (e.g., Diet, Workout) or lifestyle:
24+
- Winter: Suggest indoor alternatives (Home training, Gym) instead of outdoor running. Focus on immune system or warming up.
25+
- Summer: Warn about heatstroke, suggest hydration, or early morning/night activities.
26+
- Even for desk jobs (e.g., Coding, Reading):
27+
- New Year (Jan): Leverage "New Year's Resolution" motivation.
28+
- Year-End (Dec): Focus on "Finishing strong" or "Retrospective".
29+
30+
[OUTPUT CONSTRAINT]
31+
1. Output MUST be a valid JSON format matching the provided Java Record structure.
32+
2. Return ONLY the RAW JSON. No markdown blocks.
33+
3. Language: Korean (한국어).
34+
""")
35+
@UserMessage("""
36+
Please design a {{duration}}-week study challenge for a mentee.
37+
38+
[MENTEE CONTEXT]
39+
- Start Date: {{startDate}} (YYYY-MM-DD)
40+
- Challenge Name: {{name}}
41+
- Tags: {{tags}}
42+
- Fields: {{fields}}
43+
- Job: {{job}}
44+
- User Request: {{userRequest}}
45+
46+
[GENERATION RULES]
47+
1. Generate exactly {{duration}} items in the 'weeks' list.
48+
2. 'content': Detailed description. **Must reflect the season/weather of the {{startDate}}**.
49+
3. 'targetSeconds': Realistic study/action time in seconds.
50+
""")
51+
RoutePayload generate(
52+
@V("name") String name,
53+
@V("tags") String tags,
54+
@V("fields") String fields,
55+
@V("job") String job,
56+
@V("duration") int duration,
57+
@V("startDate") String startDate, // 날짜 추가
58+
@V("userRequest") String userRequest
59+
);
60+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package targeter.aim.domain.challenge.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
import targeter.aim.domain.challenge.entity.ChallengeMode;
8+
import targeter.aim.domain.challenge.entity.ChallengeVisibility;
9+
10+
import java.time.LocalDate;
11+
import java.util.List;
12+
13+
public class ChallengeDto {
14+
15+
@Data
16+
@AllArgsConstructor
17+
@NoArgsConstructor
18+
@Builder
19+
public static class ProgressCreateRequest {
20+
private String name;
21+
22+
private LocalDate startedAt;
23+
24+
private Integer duration;
25+
26+
private List<String> tags;
27+
28+
private List<String> fields;
29+
30+
private List<String> jobs;
31+
32+
private String userRequest;
33+
34+
private ChallengeMode mode;
35+
36+
private ChallengeVisibility visibility;
37+
}
38+
}

src/main/java/targeter/aim/domain/challenge/repository/ChallengeRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
import org.springframework.data.jpa.repository.JpaRepository;
44
import targeter.aim.domain.challenge.entity.Challenge;
5+
import targeter.aim.domain.user.entity.User;
6+
7+
import java.time.LocalDate;
8+
import java.util.Optional;
59

610
public interface ChallengeRepository extends JpaRepository<Challenge, Long> {
11+
12+
Optional<Challenge> findByHostAndNameAndStartedAt(User host, String name, LocalDate startedAt);
713
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package targeter.aim.domain.challenge.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.stereotype.Service;
6+
import targeter.aim.domain.ai.llm.dto.RoutePayload;
7+
import targeter.aim.domain.ai.llm.service.RouteGenerator;
8+
import targeter.aim.domain.challenge.dto.ChallengeDto;
9+
10+
import java.time.format.DateTimeFormatter;
11+
import java.util.List;
12+
import java.util.function.Supplier;
13+
14+
@Slf4j
15+
@Service
16+
@RequiredArgsConstructor
17+
public class ChallengeRouteGenerationService {
18+
19+
private final RouteGenerator routeGenerator;
20+
21+
private static final int MAX_TITLE_LENGTH = 100;
22+
private static final int MAX_CONTENT_LENGTH = 500;
23+
24+
public RoutePayload generateRoute(ChallengeDto.ProgressCreateRequest req) {
25+
// 1. AI 호출 (Retry 최대 3회 수행)
26+
RoutePayload payload = retry(() ->
27+
routeGenerator.generate(
28+
req.getName(),
29+
listToString(req.getTags()),
30+
listToString(req.getFields()),
31+
listToString(req.getJobs()),
32+
req.getDuration(),
33+
req.getStartedAt().format(DateTimeFormatter.ISO_LOCAL_DATE),
34+
req.getUserRequest() != null ? req.getUserRequest() : ""
35+
),
36+
3, 1000L // 3회 시도, 1초 대기부터 시작
37+
);
38+
39+
// 2. 데이터 정제 (공백 제거, 길이 자르기 등)
40+
sanitizePayload(payload);
41+
42+
// 3. 결과 검증 (필수값 확인)
43+
validatePayload(payload, req.getDuration());
44+
45+
return payload;
46+
}
47+
48+
// [1] retry 로직
49+
private <T> T retry(Supplier<T> action, int maxAttempts, long initialDelayMs) {
50+
long delay = initialDelayMs;
51+
RuntimeException lastException = null;
52+
53+
for(int i = 1; i <= maxAttempts; i++) {
54+
try{
55+
return action.get();
56+
} catch (RuntimeException ex) {
57+
lastException = ex;
58+
if(i < maxAttempts) {
59+
sleep(delay);
60+
delay = Math.min(delay * 2, 8000);
61+
}
62+
}
63+
}
64+
65+
throw lastException != null ? lastException : new RuntimeException("Retry failed");
66+
}
67+
68+
private void sleep(long ms) {
69+
try { Thread.sleep(ms); }
70+
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
71+
}
72+
73+
// [2] Sanitization 로직
74+
private void sanitizePayload(RoutePayload payload) {
75+
if (payload == null || payload.getWeeks() == null) return;
76+
77+
for (RoutePayload.Week week : payload.getWeeks()) {
78+
// 앞뒤 공백 제거
79+
week.setTitle(normalize(week.getTitle()));
80+
week.setContent(normalize(week.getContent()));
81+
82+
// 안전 장치: 제목이 DB 함량보다 길면 자름
83+
if (week.getTitle().length() > MAX_TITLE_LENGTH) {
84+
week.setTitle(week.getTitle().substring(0, MAX_TITLE_LENGTH));
85+
}
86+
}
87+
}
88+
89+
// [3] Validation 로직
90+
private void validatePayload(RoutePayload payload, int expectedWeeks) {
91+
if (payload == null || payload.getWeeks() == null) {
92+
throw new IllegalStateException("AI 응답 데이터가 비어있습니다.");
93+
}
94+
if (payload.getWeeks().size() != expectedWeeks) {
95+
throw new IllegalStateException(
96+
String.format("AI 생성 기간 불일치: 요청(%d주) != 응답(%d주)", expectedWeeks, payload.getWeeks().size())
97+
);
98+
}
99+
100+
for(RoutePayload.Week week : payload.getWeeks()) {
101+
if(isBlank(week.getTitle()) || isBlank(week.getContent())) {
102+
throw new IllegalStateException("AI가 빈 내용(제목/본문)을 반환했습니다.");
103+
}
104+
}
105+
}
106+
107+
// Help 로직
108+
private String listToString(List<String> list) {
109+
return (list == null || list.isEmpty()) ? "" : String.join(", ", list);
110+
}
111+
112+
private String normalize(String s) {
113+
return s == null ? "" : s.trim().replaceAll("\\s+", " ");
114+
}
115+
116+
private boolean isBlank(String s) {
117+
return s == null || s.trim().isEmpty();
118+
}
119+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package targeter.aim.domain.challenge.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Propagation;
7+
import org.springframework.transaction.annotation.Transactional;
8+
import targeter.aim.domain.ai.llm.dto.RoutePayload;
9+
import targeter.aim.domain.challenge.dto.ChallengeDto;
10+
import targeter.aim.domain.challenge.entity.*;
11+
import targeter.aim.domain.challenge.repository.ChallengeMemberRepository;
12+
import targeter.aim.domain.challenge.repository.ChallengeRepository;
13+
import targeter.aim.domain.challenge.repository.WeeklyProgressRepository;
14+
import targeter.aim.domain.user.entity.User;
15+
import targeter.aim.domain.user.repository.UserRepository;
16+
17+
import java.util.Optional;
18+
19+
@Slf4j
20+
@Service
21+
@RequiredArgsConstructor
22+
public class ChallengeRoutePersistService {
23+
24+
private final ChallengeRepository challengeRepository;
25+
private final ChallengeMemberRepository challengeMemberRepository;
26+
private final WeeklyProgressRepository weeklyProgressRepository;
27+
private final UserRepository userRepository;
28+
29+
// 상위 트랜잭션과 독립적으로 실행
30+
@Transactional(propagation = Propagation.REQUIRES_NEW)
31+
public Long persistAtomic(Long userId, ChallengeDto.ProgressCreateRequest req, RoutePayload payload) {
32+
// 1. Host 유저 조회
33+
User host = userRepository.findById(userId)
34+
.orElseThrow(() -> new IllegalArgumentException("User not found"));
35+
36+
// 2. 멱등성
37+
Optional<Challenge> existing = challengeRepository.findByHostAndNameAndStartedAt(host, req.getName(), req.getStartedAt());
38+
39+
if(existing.isPresent()) {
40+
log.warn("Challenge with id {} already exists", existing.get().getId());
41+
return existing.get().getId();
42+
}
43+
44+
// 3. Challenge 엔티티 생성 및 저장
45+
Challenge challenge = Challenge.builder()
46+
.host(host)
47+
.name(req.getName())
48+
.job(String.join(",", req.getJobs()))
49+
.startedAt(req.getStartedAt())
50+
.durationWeek(req.getDuration())
51+
.mode(req.getMode())
52+
.visibility(req.getVisibility())
53+
.status(ChallengeStatus.IN_PROGRESS)
54+
.build();
55+
56+
Challenge savedChallenge = challengeRepository.save(challenge);
57+
58+
// 4. ChallengeMember 저장
59+
ChallengeMember hostMember = ChallengeMember.builder()
60+
.id(ChallengeMemberId.of(savedChallenge, host))
61+
.role(MemberRole.HOST)
62+
.build();
63+
challengeMemberRepository.save(hostMember);
64+
65+
// 5. WeeklyProgress 저장
66+
for(RoutePayload.Week week : payload.getWeeks()) {
67+
WeeklyProgress progress = WeeklyProgress.builder()
68+
.challenge(savedChallenge)
69+
.user(host)
70+
.weekNumber(week.getWeekNumber())
71+
.title(week.getTitle())
72+
.content(week.getContent())
73+
.stopwatchTimeSeconds(week.getTargetSeconds())
74+
.isComplete(false)
75+
.build();
76+
weeklyProgressRepository.save(progress);
77+
}
78+
79+
return savedChallenge.getId();
80+
}
81+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package targeter.aim.system.configuration.llm;
2+
3+
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
8+
@Configuration
9+
public class GeminiConfig {
10+
@Bean
11+
public GoogleAiGeminiChatModel googleAiGeminiChatModel (
12+
@Value("${gemini.api-key}") String apiKey
13+
) {
14+
return GoogleAiGeminiChatModel.builder()
15+
.apiKey(apiKey)
16+
.modelName("gemini-2.0-flash")
17+
.build();
18+
};
19+
20+
}

src/main/resources/application-remote.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ cache:
5353
expiration-week: ${JWT_REFRESH_TOKEN_EXPIRATION_WEEKS:2}
5454
maximum-size: 10000
5555

56+
gemini:
57+
api-key: ${GEMINI_API_KEY}
58+
5659
app:
5760
cors:
5861
allowed-origins: ${APP_CORS_ORIGINS:}

0 commit comments

Comments
 (0)