|
| 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 | +} |
0 commit comments