|
12 | 12 | import com.unithon.ddoeunyeong.domain.child.repository.ChildRepository; |
13 | 13 | import com.unithon.ddoeunyeong.domain.utterance.dto.QuestionAndAnser; |
14 | 14 | import com.unithon.ddoeunyeong.global.exception.BaseResponse; |
| 15 | +import com.unithon.ddoeunyeong.infra.gptapi.dto.GptFinalRequest; |
| 16 | +import com.unithon.ddoeunyeong.infra.gptapi.dto.GptFinalResponse; |
15 | 17 | import com.unithon.ddoeunyeong.infra.s3.service.S3Service; |
16 | 18 | import org.springframework.beans.factory.annotation.Value; |
17 | 19 | import org.springframework.core.io.ByteArrayResource; |
@@ -89,6 +91,7 @@ public String makeFirstQuestionWithSurvey(Survey survey){ |
89 | 91 |
|
90 | 92 | String systemPrompt = """ |
91 | 93 | 당신은 심리 상담 AI입니다. 그리고 어린아이를 대상으로 말한다는 것을 고려해주세요.또한 민감한 주제에 대한 질문은 피해주세요. |
| 94 | + 또한 제공하는 값중에서 knowAboutChild는 오늘 답변을 듣기 궁금한 질문입니다. 그리고 getKnowInfo는 오늘 대화에서 참조해줬으면 좋겠는 사항입니다. |
92 | 95 | 사용자에 대한 정보가 입력되면 이를 반영해서 첫번째 질문을 생성해주세요. |
93 | 96 | """; |
94 | 97 |
|
@@ -163,6 +166,7 @@ public GptResponse askGptAfterSurvey(String userText, Long adviceId) throws IOEx |
163 | 166 | 당신은 감정 분석과 꼬리질문을 수행하는 감성 상담 AI입니다. 그리고 어린아이를 대상으로 말한다는 것을 고려해주세요. |
164 | 167 | 사용자 정보와 발화 이력, 이전 질문이 주어지면, 다음과 같은 JSON 응답을 반환하세요: |
165 | 168 | 이때 emotion은 angry, disgust, fear, happy, sad, surprise, neutral 중에 가장 연관될 것을 선택해주세요. |
| 169 | + 또한 제공하는 값중에서 knowAboutChild는 오늘 답변을 듣기 궁금한 질문입니다. 그리고 getKnowInfo는 오늘 대화에서 참조해줬으면 좋겠는 사항입니다. |
166 | 170 | 또한 이전 질문을 고려하여 반복적인 내용이 반영되는 질문이 나오지 않도록 해주세요. |
167 | 171 | { |
168 | 172 | "emotion": "...", |
@@ -216,6 +220,139 @@ public GptResponse askGptAfterSurvey(String userText, Long adviceId) throws IOEx |
216 | 220 | } |
217 | 221 |
|
218 | 222 |
|
| 223 | + public GptFinalResponse askGptMakeFinalReport(Long adviceId) throws IOException { |
| 224 | + |
| 225 | + Advice advice = adviceRepository.findById(adviceId).orElseThrow(() -> new CustomException(ErrorCode.NO_ADVICE)); |
| 226 | + |
| 227 | + Child child = advice.getChild(); |
| 228 | + |
| 229 | + ChildProfile childProfile = ChildProfile.builder() |
| 230 | + .age(child.getAge()) |
| 231 | + .characterType(child.getCharacterType()) |
| 232 | + .name(child.getName()) |
| 233 | + .build(); |
| 234 | + |
| 235 | + List<QuestionAndAnser> rawUserUtterances = userUtteranceRepository |
| 236 | + .findTop5ByAdviceIdOrderByCreatedAtDesc(adviceId).stream() |
| 237 | + .map(u -> new QuestionAndAnser(u.getQuestion(), u.getUtterance())) |
| 238 | + .toList(); |
| 239 | + |
| 240 | + Survey survey = surveyRepository.findByAdviceId(adviceId) |
| 241 | + .orElseThrow(() -> new CustomException(ErrorCode.NO_SURVEY)); |
| 242 | + |
| 243 | + SurveyDto surveyDto = new SurveyDto(survey.getKnowAboutChild(), survey.getKnowInfo()); |
| 244 | + |
| 245 | + GptFinalRequest gptRequest = new GptFinalRequest(childProfile, rawUserUtterances, surveyDto); |
| 246 | + |
| 247 | + String userMessageJson; |
| 248 | + try { |
| 249 | + userMessageJson = mapper.writeValueAsString(gptRequest); |
| 250 | + } catch (JsonProcessingException e) { |
| 251 | + throw new CustomException(ErrorCode.JSON_SERIALIZE_FAIL); |
| 252 | + } |
| 253 | + String systemPrompt = """ |
| 254 | + 당신은 감정 분석을 수행하는 감성 상담 AI입니다. |
| 255 | + 대상은 어린아이이며, 친절하고 이해하기 쉬운 말투를 사용해야 합니다. |
| 256 | + |
| 257 | + 입력으로 다음 정보가 함께 제공됩니다: |
| 258 | + - knowAboutChild: 오늘 반드시 알고 싶은 핵심 질문(예: "학교에서 가장 재밌었던 일은?") |
| 259 | + - getKnowInfo: 오늘 대화에서 참고해야 할 맥락/주의사항(예: "새로운 반에 전학 옴") |
| 260 | + |
| 261 | + 다음 두 가지 지표를 반드시 분석하여 0~100점으로 반환하세요. |
| 262 | + 점수는 아래 규칙에 **엄격히** 따르되, 긍정적 신호가 있으면 짜지 않게 반영하세요. |
| 263 | + |
| 264 | + 1) 사회참조 점수 (socialReferenceScore) |
| 265 | + - 의미: 대화 중 ‘타인’을 언급한 빈도를 100점 만점으로 환산 |
| 266 | + - 카운트 규칙: '친구', '선생님', '반 친구', '엄마', '아빠', '형', '누나', '동생', '사촌', '이모', '삼촌' 등 타인 지칭이 등장할 때마다 +1 |
| 267 | + - 점수화(캡 규칙): |
| 268 | + 0회=20, 1회=40, 2회=60, 3회=80, 4회 이상=100 |
| 269 | + (동일 문장 내 여러 타인 단어가 있으면 각각 카운트) |
| 270 | + |
| 271 | + 2) 협력·배려 점수 (cooperationKindnessScore) |
| 272 | + - 의미: 협력/배려 의도가 담긴 발화 빈도를 100점 만점으로 환산 |
| 273 | + - 카운트 규칙 키워드 예: '같이', '함께', '도와줄게', '도와줘', '도와주다', '고마워', '미안해', '괜찮아', '잘했어', '수고했어' |
| 274 | + - 점수화(캡 규칙): |
| 275 | + 0회=20, 1회=40, 2회=60, 3회=80, 4회 이상=100 |
| 276 | + |
| 277 | + 출력 필드 (반드시 모두 포함): |
| 278 | + - socialReferenceScore: 0~100 정수 |
| 279 | + - cooperationKindnessScore: 0~100 정수 |
| 280 | + - summary: 전체 대화 요지를 부모님에게 제공해주는 것으로 2문장으로 요약 |
| 281 | + - coreQuestion: 지금까지 맥락에서 가장 중요한 집중 질문 1개 (가능하면 knowAboutChild를 반영) |
| 282 | + - childAnswer: knowAboutChild에 대한 아이의 실제 발화 기반 답변. 아이의 발화에서 근거가 명확할 때만 작성. |
| 283 | + 근거가 없으면 "해당 주제에 대해서 말하지 않았어요."로 둔다(추측 금지). |
| 284 | + |
| 285 | + 중요 지침: |
| 286 | + - getKnowInfo는 summary와 coreQuestion의 어투/맥락 반영에만 사용한다(점수엔 직접 가중치 주지 말 것). |
| 287 | + - 점수 계산은 오직 카운트 기반 규칙으로 일관되게 수행한다. |
| 288 | + - 출력은 JSON 객체 **한 개만** 반환하고, JSON 외 텍스트/주석/설명은 절대 포함하지 말 것. |
| 289 | + - JSON 키 이름과 자료형을 정확히 지킬 것. 불필요한 필드 추가 금지. |
| 290 | + |
| 291 | + 출력 예시: |
| 292 | + { |
| 293 | + "socialReferenceScore": 80, |
| 294 | + "cooperationKindnessScore": 100, |
| 295 | + "summary": "오늘 너는 친구와 선생님 이야기를 들려줬어. 같이 해 보자는 마음과 고마운 마음도 잘 표현했구나!", |
| 296 | + "coreQuestion": "오늘 가장 같이 하고 싶었던 놀이는 뭐였어?", |
| 297 | + "childAnswer": "블록 놀이를 친구랑 같이 하고 싶다고 했어." |
| 298 | + } |
| 299 | + """; |
| 300 | + |
| 301 | + Map<String, Object> body = new HashMap<>(); |
| 302 | + body.put("model", "gpt-4o"); |
| 303 | + body.put("messages", List.of( |
| 304 | + Map.of("role", "system", "content", systemPrompt), |
| 305 | + Map.of("role", "user", "content", userMessageJson) |
| 306 | + )); |
| 307 | + |
| 308 | + Map<String, Object> responseBody; |
| 309 | + try { |
| 310 | + responseBody = openAiClient.post() |
| 311 | + .uri(CHAT_COMPLETIONS_URI) |
| 312 | + .bodyValue(body) |
| 313 | + .retrieve() |
| 314 | + .onStatus(s -> !s.is2xxSuccessful(), |
| 315 | + resp -> resp.bodyToMono(String.class) |
| 316 | + .defaultIfEmpty("") |
| 317 | + .map(e-> new CustomException(ErrorCode.OPENAI_HTTP_ERROR))) |
| 318 | + .bodyToMono(Map.class) |
| 319 | + .block(); |
| 320 | + |
| 321 | + if (responseBody == null) throw new CustomException(ErrorCode.OPENAI_EMPTY_BODY); |
| 322 | + } catch (Exception e) { |
| 323 | + throw new CustomException(ErrorCode.OPENAI_COMM_FAIL); |
| 324 | + } |
| 325 | + |
| 326 | + String content = extractContentSafely(responseBody); |
| 327 | + String jsonOnly = extractFirstJsonObject(content); |
| 328 | + if (jsonOnly == null || jsonOnly.isBlank()) { |
| 329 | + throw new CustomException(ErrorCode.OPENAI_PARSE_FAIL); |
| 330 | + } |
| 331 | + |
| 332 | + try { |
| 333 | + |
| 334 | + GptFinalResponse gpt = mapper.readValue(jsonOnly, GptFinalResponse.class); |
| 335 | + |
| 336 | + Long socialScore = gpt.socialReferenceScore() == null ? 20 : Math.max(0, Math.min(100, gpt.socialReferenceScore())); |
| 337 | + Long coopScore = gpt.cooperationKindnessScore() == null ? 20 : Math.max(0, Math.min(100, gpt.cooperationKindnessScore())); |
| 338 | + String summary = gpt.summary() == null ? "" : gpt.summary(); |
| 339 | + String coreQ = gpt.coreQuestion() == null ? "" : gpt.coreQuestion(); |
| 340 | + String childAns = gpt.childAnswer() == null ? "" : gpt.childAnswer(); |
| 341 | + |
| 342 | + // 4) Advice 엔티티 업데이트 후 저장 |
| 343 | + advice.updateAnalysisResult(socialScore, coopScore, summary,coreQ,childAns); |
| 344 | + |
| 345 | + |
| 346 | + adviceRepository.save(advice); |
| 347 | + |
| 348 | + |
| 349 | + return gpt; |
| 350 | + |
| 351 | + } catch (IOException e) { |
| 352 | + throw new CustomException(ErrorCode.OPENAI_PARSE_FAIL); |
| 353 | + } |
| 354 | + } |
| 355 | + |
219 | 356 |
|
220 | 357 | @SuppressWarnings("unchecked") |
221 | 358 | private String extractContentSafely(Map<String, Object> responseBody) { |
@@ -355,9 +492,6 @@ private String stripExt(String name) { |
355 | 492 | } |
356 | 493 |
|
357 | 494 |
|
358 | | - |
359 | | - |
360 | | - |
361 | 495 | /** ```json ... ``` 형태여도 첫 번째 JSON 오브젝트만 추출 */ |
362 | 496 | private String extractFirstJsonObject(String content) { |
363 | 497 | if (content == null || content.isBlank()) |
|
0 commit comments