Skip to content

Commit f9ecb0b

Browse files
authored
Merge pull request #202 from DDD-Community/feat/200/chat
fix(goalretrospect): ai 연동 완료
2 parents b8ca6ec + c127163 commit f9ecb0b

File tree

11 files changed

+209
-12
lines changed

11 files changed

+209
-12
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.growit.app.common.config;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.fasterxml.jackson.databind.SerializationFeature;
5+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.web.client.RestTemplate;
9+
10+
@Configuration
11+
public class ApiConfig {
12+
13+
@Bean
14+
public RestTemplate restTemplate() {
15+
return new RestTemplate();
16+
}
17+
}
18+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.growit.app.common.config.ai;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
import org.springframework.boot.context.properties.ConfigurationProperties;
6+
7+
@Getter
8+
@Setter
9+
@ConfigurationProperties(prefix = "ai.api")
10+
public class AIProperties {
11+
private String apiKey;
12+
private String baseUrl = "https://api.openai.com";
13+
private String model = "gpt-4o-mini";
14+
private double temperature = 0.3;
15+
}

app/src/main/java/com/growit/app/common/config/jwt/JwtProperties.java

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

33
import lombok.Getter;
44
import lombok.RequiredArgsConstructor;
5+
import lombok.Setter;
56
import org.springframework.boot.context.properties.ConfigurationProperties;
67

78
@Getter
9+
@Setter
810
@RequiredArgsConstructor
911
@ConfigurationProperties(prefix = "jwt")
1012
public class JwtProperties {
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package com.growit.app.retrospect.domain.goalretrospect.service;
22

3-
import com.growit.app.goal.domain.goal.Goal;
43
import com.growit.app.retrospect.domain.goalretrospect.vo.Analysis;
5-
import com.growit.app.todo.domain.ToDo;
6-
import java.util.List;
4+
import com.growit.app.retrospect.infrastructure.engine.dto.AnalysisDto;
75

86
public interface AIAnalysis {
9-
Analysis generate(Goal goal, List<ToDo> todos);
7+
Analysis generate(AnalysisDto request);
108
}

app/src/main/java/com/growit/app/retrospect/domain/goalretrospect/service/GoalRetrospectService.java

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
@Service
1515
@RequiredArgsConstructor
16-
public class GoalRetrospectService implements GoalRetrospectQuery, AIAnalysis {
16+
public class GoalRetrospectService implements GoalRetrospectQuery {
1717
private final GoalRetrospectRepository goalRetrospectRepository;
1818

1919
@Override
@@ -22,9 +22,4 @@ public GoalRetrospect getMyGoalRetrospect(String id, String userId) throws NotFo
2222
.findById(id)
2323
.orElseThrow(() -> new NotFoundException(ErrorCode.RETROSPECT_NOT_FOUND.getCode()));
2424
}
25-
26-
@Override
27-
public Analysis generate(Goal goal, List<ToDo> todos) {
28-
return new Analysis("summary", "advice");
29-
}
3025
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package com.growit.app.retrospect.infrastructure.engine;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fasterxml.jackson.databind.SerializationFeature;
6+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
7+
import com.growit.app.common.config.ai.AIProperties;
8+
import com.growit.app.common.exception.BadRequestException;
9+
import com.growit.app.retrospect.domain.goalretrospect.service.AIAnalysis;
10+
import com.growit.app.retrospect.domain.goalretrospect.vo.Analysis;
11+
import com.growit.app.retrospect.infrastructure.engine.dto.AnalysisDto;
12+
import java.nio.charset.StandardCharsets;
13+
import java.util.HashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
import lombok.RequiredArgsConstructor;
17+
import org.springframework.http.HttpEntity;
18+
import org.springframework.http.HttpHeaders;
19+
import org.springframework.http.HttpMethod;
20+
import org.springframework.http.MediaType;
21+
import org.springframework.http.ResponseEntity;
22+
import org.springframework.stereotype.Component;
23+
import org.springframework.web.client.RestTemplate;
24+
25+
@Component
26+
@RequiredArgsConstructor
27+
public class ChatGptAIAnalysis implements AIAnalysis {
28+
29+
private final ObjectMapper objectMapper = new ObjectMapper()
30+
.registerModule(new JavaTimeModule())
31+
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
32+
33+
private final RestTemplate restTemplate;
34+
private final AIProperties aiProperties;
35+
36+
37+
private static final String SYSTEM_MESSAGE =
38+
"""
39+
당신은 목표 달성을 위해 미루지 않고 끝까지 달성할 수 있도록 돕는 전문가입니다.
40+
사용자가 제공한 JSON 데이터(goal, retrospects, todos)를 바탕으로 아래 기준에 맞추어 분석하고 요약하세요.
41+
42+
summary:
43+
[조건]
44+
- 100자 이상
45+
- 문장 어미는 "~니다"로 통일
46+
- 2~3문장 구성
47+
[내용]
48+
- 첫 문장은 Copywriting이 될만한 핵심적인 목표 전반 요약
49+
- 이어서 세부적인 목표 진행 과정과 맥락 서술
50+
51+
advice:
52+
[조건]
53+
- 100자 이상
54+
- 문장 어미는 "~다냥"으로 통일
55+
- 2~3문장 구성
56+
[내용]
57+
- 목표 진행 과정에서 잘한 점(강점) 분석
58+
- 개선해야 할 점(약점) 분석
59+
- 사례와 근거 기반
60+
- 문장을 깔끔하고 가독성 있게 구성
61+
62+
출력 포맷(JSON):
63+
{
64+
"summary": "...",
65+
"advice": "..."
66+
}
67+
""";
68+
69+
@Override
70+
public Analysis generate(AnalysisDto request) {
71+
try {
72+
// 1) 사용자 메시지에 실제 JSON 데이터 삽입
73+
Map<String, Object> payload = new HashMap<>();
74+
payload.put("goal", request.goal());
75+
payload.put("retrospects", request.retrospects());
76+
payload.put("todos", request.todos());
77+
String userInput =
78+
"goal = "
79+
+ objectMapper.writeValueAsString(payload.get("goal"))
80+
+ "\n"
81+
+ "retrospects = "
82+
+ objectMapper.writeValueAsString(payload.get("retrospects"))
83+
+ "\n"
84+
+ "todos = "
85+
+ objectMapper.writeValueAsString(payload.get("todos"))
86+
+ "\n\n"
87+
+ "위 데이터를 기반으로 summary와 advice를 작성해주세요.";
88+
89+
// 2) Chat Completions 요청 바디 구성
90+
Map<String, Object> sysMsg = Map.of("role", "system", "content", SYSTEM_MESSAGE);
91+
Map<String, Object> usrMsg = Map.of("role", "user", "content", userInput);
92+
Map<String, Object> body = new HashMap<>();
93+
body.put("model", aiProperties.getModel());
94+
body.put("temperature", aiProperties.getTemperature());
95+
body.put("messages", List.of(sysMsg, usrMsg));
96+
97+
// 3) 호출 using RestTemplate
98+
HttpHeaders headers = new HttpHeaders();
99+
headers.setContentType(MediaType.APPLICATION_JSON);
100+
headers.setBearerAuth(aiProperties.getApiKey());
101+
102+
HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
103+
104+
ResponseEntity<String> responseEntity =
105+
restTemplate.exchange(
106+
aiProperties.getBaseUrl() + "/v1/chat/completions", HttpMethod.POST, entity, String.class);
107+
108+
String response = responseEntity.getBody();
109+
110+
if (response == null) {
111+
throw new BadRequestException("OpenAI 응답이 비어 있습니다.");
112+
}
113+
114+
// 4) 응답 파싱 (choices[0].message.content)
115+
JsonNode root = objectMapper.readTree(response);
116+
JsonNode choices = root.path("choices");
117+
if (!choices.isArray() || choices.isEmpty()) {
118+
throw new BadRequestException("OpenAI 응답에 choices가 없습니다: " + response);
119+
}
120+
String content = choices.get(0).path("message").path("content").asText("");
121+
if (content.isEmpty()) {
122+
throw new BadRequestException("OpenAI 응답에 content가 없습니다: " + response);
123+
}
124+
125+
// 5) 모델이 반환한 JSON 텍스트 파싱
126+
// 불필요한 앞뒤 공백/코드펜스 제거
127+
String cleaned = content.replace("```json", "").replace("```", "").trim();
128+
129+
JsonNode json = objectMapper.readTree(cleaned.getBytes(StandardCharsets.UTF_8));
130+
String summary = json.path("summary").asText("");
131+
String advice = json.path("advice").asText("");
132+
133+
if (summary.isEmpty() || advice.isEmpty()) {
134+
throw new IllegalStateException("요청 포맷(JSON)에 맞는 summary/advice가 없습니다: " + cleaned);
135+
}
136+
137+
return new Analysis(summary, advice);
138+
} catch (Exception e) {
139+
// 실패 시, 에러 메시지를 포함한 최소 응답 반환
140+
String fallbackSummary = "AI 분석을 생성하는 중 오류가 발생했습니다. 시스템 로그를 확인해 주십시오.";
141+
String fallbackAdvice = "예외: " + e.getMessage() + " 다냥";
142+
return new Analysis(fallbackSummary, fallbackAdvice);
143+
}
144+
}
145+
}
146+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.growit.app.retrospect.infrastructure.engine.dto;
2+
3+
import com.growit.app.goal.domain.goal.Goal;
4+
import com.growit.app.retrospect.domain.retrospect.Retrospect;
5+
import com.growit.app.todo.domain.ToDo;
6+
import java.util.List;
7+
8+
public record AnalysisDto(Goal goal, List<Retrospect> retrospects, List<ToDo> todos) {}

app/src/main/java/com/growit/app/retrospect/usecase/goalretrospect/CreateGoalRetrospectUseCase.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
import com.growit.app.retrospect.domain.goalretrospect.dto.CreateGoalRetrospectCommand;
1010
import com.growit.app.retrospect.domain.goalretrospect.service.AIAnalysis;
1111
import com.growit.app.retrospect.domain.goalretrospect.vo.Analysis;
12+
import com.growit.app.retrospect.domain.retrospect.Retrospect;
13+
import com.growit.app.retrospect.domain.retrospect.service.RetrospectQuery;
14+
import com.growit.app.retrospect.infrastructure.engine.dto.AnalysisDto;
1215
import com.growit.app.todo.domain.ToDo;
1316
import com.growit.app.todo.domain.service.ToDoQuery;
1417
import com.growit.app.todo.domain.util.ToDoUtils;
@@ -24,6 +27,7 @@ public class CreateGoalRetrospectUseCase {
2427
private final ToDoQuery toDoQuery;
2528
private final AIAnalysis aiAnalysis;
2629
private final GoalRetrospectRepository goalRetrospectRepository;
30+
private final RetrospectQuery retrospectQuery;
2731

2832
@Transactional
2933
public String execute(CreateGoalRetrospectCommand command) {
@@ -33,7 +37,9 @@ public String execute(CreateGoalRetrospectCommand command) {
3337
}
3438
final List<ToDo> todos = toDoQuery.getToDosByGoalId(command.goalId());
3539
final int todoCompletedRate = ToDoUtils.calculateToDoCompletedRate(todos);
36-
final Analysis analysis = aiAnalysis.generate(goal, todos);
40+
final List<Retrospect> retrospects = retrospectQuery.getRetrospectsByGoalId(command.goalId(), command.userId());
41+
42+
final Analysis analysis = aiAnalysis.generate(new AnalysisDto(goal, retrospects, todos));
3743

3844
final GoalRetrospect goalRetrospect =
3945
GoalRetrospect.create(goal.getId(), todoCompletedRate, analysis, "");

app/src/main/resources/application-local.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,6 @@ app:
4242
security:
4343
api-keys:
4444
- ${GOAL_API_KEY_PRIMARY:}
45+
ai:
46+
api:
47+
api-key: ${OPEN_API_KEY}

app/src/main/resources/application.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,6 @@ app:
4141
security:
4242
api-keys:
4343
- ${GOAL_API_KEY_PRIMARY:}
44+
ai:
45+
api:
46+
api-key: ${OPEN_API_KEY}

0 commit comments

Comments
 (0)