Skip to content

Commit 7f8a0eb

Browse files
authored
Merge pull request #9 from UMC-hackathon-2team/feat/#2-ai-service
Feat: 감정 기억 생성 기능 및 AI 응답 반환 구현
2 parents d897cf9 + 78aaaa4 commit 7f8a0eb

12 files changed

Lines changed: 517 additions & 7 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ dependencies {
3636

3737
// swagger
3838
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
39+
40+
// gpt
41+
implementation 'io.github.flashvayne:chatgpt-spring-boot-starter:1.0.4'
3942
}
4043

4144
tasks.named('test') {

src/main/java/umc/tosee/global/common/ErrorCode.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
public enum ErrorCode implements BaseCode {
1010

1111
// 서버 에러
12-
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON-001", "서버 오류가 발생했습니다."),
13-
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "COMMON-002", "요청 파라미터가 올바르지 않습니다."),
14-
INVALID_DATE(HttpStatus.BAD_REQUEST, "DATE-001", "유효하지 않은 날짜입니다."),
15-
INVALID_FORMAT(HttpStatus.BAD_REQUEST, "FORMAT-001", "형식이 올바르지 않습니다."),
16-
JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "COMMON-003", "JSON 파싱에 실패했습니다.");
12+
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON-500", "서버 오류가 발생했습니다."),
13+
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "COMMON-400", "요청 파라미터가 올바르지 않습니다."),
14+
INVALID_DATE(HttpStatus.BAD_REQUEST, "DATE-400", "유효하지 않은 날짜입니다."),
15+
INVALID_FORMAT(HttpStatus.BAD_REQUEST, "FORMAT-400", "형식이 올바르지 않습니다."),
16+
JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "COMMON-400", "JSON 파싱에 실패했습니다."),
17+
18+
// GPT 에러
19+
GPT_RESPONSE_FORMAT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GPT-500", "GPT 응답 포맷이 올바르지 않습니다.");
1720

1821
private final HttpStatus httpStatus;
1922
private final String code;

src/main/java/umc/tosee/global/config/SwaggerConfig.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ public class SwaggerConfig {
1313
@Bean
1414
public OpenAPI swagger() {
1515
Info info = new Info()
16-
.title("Tosee API 명세서")
17-
.description("Tosee 백엔드 API 명세서입니다.")
16+
.title("Tosea API 명세서")
17+
.description("Tosea 백엔드 API 명세서입니다.")
1818
.version("0.0.1");
1919

2020
return new OpenAPI()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package umc.tosee.gpt.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.util.List;
9+
10+
public class GptDTO {
11+
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
public static class GptRequestDTO {
15+
16+
@NotBlank(message = "채팅 내용은 필수입니다.")
17+
String message;
18+
}
19+
20+
@NoArgsConstructor
21+
@AllArgsConstructor
22+
@Getter
23+
public static class GptResponseDTO {
24+
25+
private String category;
26+
private String letter;
27+
private String summary;
28+
29+
}
30+
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package umc.tosee.gpt.service;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.DeserializationFeature;
5+
import com.fasterxml.jackson.databind.JsonNode;
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.http.*;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.web.client.RestTemplate;
13+
import umc.tosee.global.common.ErrorCode;
14+
import umc.tosee.global.exception.CustomException;
15+
import umc.tosee.gpt.dto.GptDTO;
16+
17+
import java.nio.charset.StandardCharsets;
18+
import java.util.*;
19+
import java.util.regex.Matcher;
20+
import java.util.regex.Pattern;
21+
22+
@Service
23+
@Slf4j
24+
@RequiredArgsConstructor
25+
public class GptService {
26+
27+
@Value("${openai.api.key}")
28+
private String apikey;
29+
30+
// openai url
31+
final String url = "https://api.openai.com/v1/chat/completions";
32+
33+
// 프롬프트
34+
String instruction =
35+
"당신은 사용자의 감정을 섬세하게 이해하고,\n" +
36+
"그 감정이 ‘이상하지 않으며 충분히 이해될 수 있음’을\n" +
37+
"차분하고 따뜻한 어투와 단정한 문장으로 전달하는 AI입니다.\n" +
38+
"사용자가 어떤 상황이나 감정을 입력하면,\n" +
39+
"당신은 다음 원칙을 반드시 따릅니다.\n\n" +
40+
41+
"1. 감정을 판단하거나 해결하려 들지 않습니다.\n" +
42+
"2. 조언, 위로를 넘어서는 충고, 긍정 강요는 하지 않습니다.\n" +
43+
"3. \"이 감정은 충분히 이해될 수 있습니다\", \"이상하지 않습니다\"와 같은\n" +
44+
" 감정의 정당성을 먼저 인정합니다.\n" +
45+
"4. 문체는 차분하고 담담하며, 과장되거나 시적인 표현은 사용하지 않습니다.\n" +
46+
"5. 2~4개의 짧은 문단으로 구성된 편지 형식으로 작성합니다.\n" +
47+
"6. 욕설, 비속어, 혐오 표현이나 특정 인물 또는 집단에 대한\n" +
48+
" 과도한 비난이나 공격적인 표현은 사용하지 않습니다.\n\n" +
49+
50+
"출력 요구 사항:\n" +
51+
"입력된 글을 바탕으로 아래 작업을 수행하십시오.\n\n" +
52+
53+
"1. 감정/상황을 대표하는 카테고리 1개만 선택합니다.\n" +
54+
" - 카테고리는 2~15자 이내\n" +
55+
" - 추상적이며 감정 중심의 단어\n" +
56+
" - 예: 슬픔, 상실감, 기대, 자존감, 변화, 애착, 좌절 등\n\n" +
57+
58+
"2. 사용자에게 보내는 공감 편지를 작성합니다.\n" +
59+
" - 감정의 맥락을 자연스럽게 반영합니다.\n" +
60+
" - \"당신이 느끼는 감정은 충분히 이해될 수 있습니다\" 또는\n" +
61+
" \"이상하지 않습니다\"와 같은 감정 인정 문장을 반드시 포함합니다.\n" +
62+
" - 편지 전체 분량은 200자 이내로 작성합니다.\n\n" +
63+
64+
"3. 편지 내용을 한 줄로 요약합니다.\n" +
65+
" - 감정을 요약하는 문장\n" +
66+
" - 조언이나 결론형 표현 금지\n" +
67+
" - 18자 이내로 작성합니다.\n\n" +
68+
69+
"출력 형식:\n\n" +
70+
71+
"아래 JSON 형식으로만 출력하십시오.\n" +
72+
"설명, 인사, 코드 블록, 추가 텍스트는 절대 포함하지 마십시오.\n\n" +
73+
74+
"{\n" +
75+
" \"category\": \"카테고리\",\n" +
76+
" \"letter\": \"편지 내용 (200자 이내)\",\n" +
77+
" \"summary\": \"한 줄 요약 (18자 이내)\"\n" +
78+
"}";
79+
80+
81+
82+
83+
// JSON 블록 추출용 정규식
84+
private static final Pattern JSON_PATTERN = Pattern.compile(
85+
"(?s)(\\{(?:[^{}]|\\{[^{}]*\\})*\\}|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])"
86+
);
87+
88+
private String abbreviate(String s, int max) {
89+
if (s == null) return "null";
90+
if (s.length() <= max) return s;
91+
return s.substring(0, max) + "...(truncated)";
92+
}
93+
94+
/**
95+
* LLM이 설명이나 프롤로그를 붙여도 첫 번째 JSON 블록만 안전 추출해서 파싱
96+
*/
97+
private Optional<JsonNode> extractJsonSafely(String raw, ObjectMapper om) {
98+
if (raw == null) return Optional.empty();
99+
String trimmed = raw.trim();
100+
101+
// 처음부터 JSON이면 바로 시도
102+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
103+
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
104+
try {
105+
return Optional.of(om.readTree(trimmed));
106+
} catch (Exception ignore) {
107+
// 아래 패턴 시도로 넘어감
108+
}
109+
}
110+
111+
// 본문에서 첫 JSON 블록 추출
112+
Matcher m = JSON_PATTERN.matcher(trimmed);
113+
if (m.find()) {
114+
String json = m.group(1);
115+
try {
116+
return Optional.of(om.readTree(json));
117+
} catch (Exception ignore) {
118+
// fall through
119+
}
120+
}
121+
return Optional.empty();
122+
}
123+
124+
125+
public JsonNode callChatGpt(String content) throws JsonProcessingException {
126+
127+
ObjectMapper objectMapper = new ObjectMapper();
128+
// header 설정
129+
HttpHeaders headers = new HttpHeaders();
130+
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
131+
headers.setContentType(new MediaType("application", "json", StandardCharsets.UTF_8));
132+
headers.setBearerAuth(apikey);
133+
134+
// system 프롬프트
135+
String systemContent = String.format(instruction);
136+
137+
List<Map<String, String>> messages = new ArrayList<>();
138+
messages.add(Map.of("role", "system", "content", systemContent));
139+
140+
// 사용자 입력
141+
messages.add(Map.of("role", "user", "content", content));
142+
143+
// 요청 바디
144+
Map<String, Object> bodyMap = new HashMap<>();
145+
bodyMap.put("model", "gpt-4o-mini");
146+
bodyMap.put("messages", messages);
147+
bodyMap.put("temperature", 0.2);
148+
bodyMap.put("max_tokens", 512);
149+
150+
Map<String, String> responseFormat = new HashMap<>();
151+
responseFormat.put("type", "json_object");
152+
bodyMap.put("response_format", responseFormat);
153+
154+
HttpEntity<String> request = new HttpEntity<>(objectMapper.writeValueAsString(bodyMap), headers);
155+
RestTemplate restTemplate = new RestTemplate();
156+
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, request, String.class);
157+
158+
return objectMapper.readTree(response.getBody());
159+
160+
}
161+
162+
public GptDTO.GptResponseDTO getAssistantMsg(String content) {
163+
try {
164+
JsonNode responseNode = callChatGpt(content);
165+
166+
String rawContent = responseNode
167+
.path("choices").get(0)
168+
.path("message")
169+
.path("content")
170+
.asText(null);
171+
if (rawContent == null) {
172+
log.error("GPT 응답에서 content가 null입니다. head={}", abbreviate(responseNode.toString(), 600));
173+
throw new CustomException(ErrorCode.GPT_RESPONSE_FORMAT_ERROR);
174+
}
175+
176+
// json 파싱 시도
177+
ObjectMapper safeMapper = new ObjectMapper()
178+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
179+
180+
Optional<JsonNode> parsedOpt = extractJsonSafely(rawContent, safeMapper);
181+
if (parsedOpt.isEmpty()) {
182+
log.error("LLM 응답에서 JSON 블록을 찾지 못함. rawHead={}", abbreviate(rawContent, 300));
183+
throw new CustomException(ErrorCode.GPT_RESPONSE_FORMAT_ERROR);
184+
}
185+
186+
JsonNode parsed = parsedOpt.get();
187+
188+
// 스키마 최소 검증
189+
if (!parsed.has("category") || !parsed.has("letter") || !parsed.has("summary")) {
190+
log.error("필수 필드 누락 jsonHead={}", abbreviate(parsed.toString(), 300));
191+
throw new CustomException(ErrorCode.GPT_RESPONSE_FORMAT_ERROR);
192+
}
193+
194+
// DTO 변환
195+
return safeMapper.treeToValue(parsed, GptDTO.GptResponseDTO.class);
196+
197+
} catch (JsonProcessingException e) {
198+
log.error("GPT 응답 파싱 실패: {}", e.getMessage(), e);
199+
throw new CustomException(ErrorCode.GPT_RESPONSE_FORMAT_ERROR);
200+
} catch (NullPointerException | IndexOutOfBoundsException e) {
201+
log.error("GPT 응답 구조 이상(choices/message 누락 가능). head={}", e.getMessage(), e);
202+
throw new CustomException(ErrorCode.GPT_RESPONSE_FORMAT_ERROR);
203+
}
204+
}
205+
206+
207+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package umc.tosee.memory.controller;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import io.swagger.v3.oas.annotations.Operation;
5+
import jakarta.servlet.http.HttpServletResponse;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.web.bind.annotation.PostMapping;
8+
import org.springframework.web.bind.annotation.RequestBody;
9+
import org.springframework.web.bind.annotation.RequestMapping;
10+
import org.springframework.web.bind.annotation.RestController;
11+
import umc.tosee.global.common.ApiResponse;
12+
import umc.tosee.global.common.SuccessCode;
13+
import umc.tosee.gpt.dto.GptDTO;
14+
import umc.tosee.memory.dto.MemoryDTO;
15+
import umc.tosee.memory.service.MemoryService;
16+
17+
@RestController
18+
@RequiredArgsConstructor
19+
@RequestMapping("/api/memories")
20+
public class MemoryController {
21+
22+
private final MemoryService memoryService;
23+
24+
@Operation(
25+
summary = "기억 입력",
26+
description = """
27+
사용자가 입력한 기억 내용을 저장합니다.
28+
29+
입력된 텍스트를 기반으로 GPT를 호출하여
30+
감정 카테고리 분류, 공감 편지, 한 줄 요약을 생성합니다.
31+
32+
생성된 카테고리 ID 목록은 쿠키(memoryCategories)에 저장됩니다.
33+
"""
34+
)
35+
@io.swagger.v3.oas.annotations.responses.ApiResponses({
36+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
37+
responseCode = "201",
38+
description = "기억 저장 및 GPT 응답 생성 성공",
39+
content = @io.swagger.v3.oas.annotations.media.Content(
40+
schema = @io.swagger.v3.oas.annotations.media.Schema(
41+
implementation = GptDTO.GptResponseDTO.class
42+
)
43+
)
44+
),
45+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
46+
responseCode = "400",
47+
description = "잘못된 요청 (요청 본문 누락 또는 형식 오류)"
48+
),
49+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
50+
responseCode = "500",
51+
description = "서버 오류 또는 GPT 처리 실패"
52+
)
53+
})
54+
@PostMapping
55+
public ApiResponse<GptDTO.GptResponseDTO> saveMemory(
56+
57+
@io.swagger.v3.oas.annotations.parameters.RequestBody(
58+
description = "저장할 기억 내용",
59+
required = true,
60+
content = @io.swagger.v3.oas.annotations.media.Content(
61+
schema = @io.swagger.v3.oas.annotations.media.Schema(
62+
implementation = MemoryDTO.MemoryRequestDTO.class
63+
),
64+
examples = @io.swagger.v3.oas.annotations.media.ExampleObject(
65+
value = """
66+
{
67+
"content": "열심히 준비한 일이 부정적인 평가를 받았을 때 느낀 감정"
68+
}
69+
"""
70+
)
71+
)
72+
)
73+
@RequestBody
74+
MemoryDTO.MemoryRequestDTO requestDTO,
75+
76+
@io.swagger.v3.oas.annotations.Parameter(
77+
description = "카테고리 ID를 쿠키로 저장하기 위한 HttpServletResponse",
78+
hidden = true
79+
)
80+
HttpServletResponse response
81+
82+
) throws JsonProcessingException {
83+
84+
GptDTO.GptResponseDTO result =
85+
memoryService.saveMemory(requestDTO, response);
86+
87+
return ApiResponse.onSuccess(result, SuccessCode.CREATED);
88+
}
89+
}
90+

0 commit comments

Comments
 (0)