Skip to content

Commit c6a7bf0

Browse files
authored
Merge pull request #27 from DdoEunYeong/feat/#26-report
feat : report
2 parents 3510e98 + a596306 commit c6a7bf0

File tree

7 files changed

+239
-9
lines changed

7 files changed

+239
-9
lines changed

src/main/java/com/unithon/ddoeunyeong/domain/advice/entity/Advice.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import com.unithon.ddoeunyeong.domain.child.entity.Child;
44
import com.unithon.ddoeunyeong.domain.survey.entity.Survey;
55
import com.unithon.ddoeunyeong.global.time.BaseTimeEntity;
6+
7+
import jakarta.annotation.Nullable;
68
import jakarta.persistence.*;
79
import lombok.*;
810

@@ -34,6 +36,22 @@ public class Advice extends BaseTimeEntity {
3436
// video S3 url
3537
private String videoUrl;
3638

39+
@Setter
40+
private Long socialScore;
41+
42+
@Setter
43+
private Long coopScore;
44+
45+
@Setter
46+
private String summary;
47+
48+
@Setter
49+
private String coreQ;
50+
51+
@Setter
52+
private String childAns;
53+
54+
3755
// 대표 감정 값을 통해 감정
3856
@Enumerated(EnumType.STRING)
3957
private Emotion emotionAvg;
@@ -53,4 +71,13 @@ public void updateStatus (AdviceStatus status) {
5371
public void updateSessionId (String sessionId) {
5472
this.sessionId = sessionId;
5573
}
74+
75+
76+
public void updateAnalysisResult(Long socialScore, Long coopScore, String summary, String coreQ, String childAns){
77+
this.socialScore = socialScore;
78+
this.coopScore = coopScore;
79+
this.summary = summary;
80+
this.coreQ = coreQ;
81+
this.childAns = childAns;
82+
}
5683
}

src/main/java/com/unithon/ddoeunyeong/global/exception/ErrorCode.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ public enum ErrorCode {
1111
ALREADY_SIGNUP(BAD_REQUEST,410,"이미 가입된 회원입니다."),
1212

1313

14+
INVALID_PASSWORD(BAD_REQUEST,411,"잘못된 비밀번호입니다."),
15+
16+
UPLOAD_FAIL(BAD_REQUEST,412,"업로드에 실패하였습니다."),
1417

1518
NO_CHILD(BAD_REQUEST,413,"존재하지 않는 어린이입니다."),
1619
NO_SURVEY(BAD_REQUEST,414,"존재하지 않는 설문입니다."),
@@ -23,11 +26,11 @@ public enum ErrorCode {
2326
OPENAI_COMM_FAIL(BAD_REQUEST,419,"OPENAI 일반적인 오류"),
2427
OPENAI_PARSE_FAIL(BAD_REQUEST,420,"OPENAI 파싱 오류"),
2528

29+
FINAL_ERROR(BAD_REQUEST,421,"상담 결과 생성 오류"),
30+
2631

27-
INVALID_PASSWORD(BAD_REQUEST,411,"잘못된 비밀번호입니다."),
2832

2933

30-
UPLOAD_FAIL(BAD_REQUEST,412,"업로드에 실패하였습니다."),
3134

3235
LOGIN_FAIL(HttpStatus.BAD_REQUEST,400,"로그인에 오류가 발생하였습니다."),
3336
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,500,"서버에 오류가 발생하였습니다."),

src/main/java/com/unithon/ddoeunyeong/global/security/config/SecurityConfig.java

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,20 +56,37 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
5656
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
5757
})
5858
)
59-
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
60-
UsernamePasswordAuthenticationFilter.class);
59+
// ✅ 여기 추가: CSP
60+
.headers(h -> h
61+
.contentSecurityPolicy(csp -> csp.policyDirectives(String.join("; ",
62+
"default-src 'self'",
63+
// fetch/XHR/WebSocket 허용할 도메인들
64+
"connect-src 'self' https://api.v0.dev https://vitals.vercel-insights.com",
65+
"script-src 'self'",
66+
"img-src 'self' data: https:",
67+
"style-src 'self' 'unsafe-inline'",
68+
"object-src 'none'",
69+
"base-uri 'self'",
70+
"frame-ancestors 'self'"
71+
)))
72+
// 필요 시 Report-Only로 먼저 점검하고 싶다면 아래 한 줄 사용
73+
// .contentSecurityPolicy(csp -> csp.policyDirectives("...").reportOnly())
74+
)
75+
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
76+
UsernamePasswordAuthenticationFilter.class);
6177

6278
return http.build();
6379
}
6480

6581
@Bean
6682
public CorsFilter corsFilter() {
67-
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
6883
CorsConfiguration config = new CorsConfiguration();
6984
config.setAllowCredentials(false);
70-
config.setAllowedOrigins(List.of("http://localhost:5500", "*"));
85+
config.setAllowedOrigins(List.of("http://localhost:5500")); // 명시 오리진
86+
config.setAllowedOriginPatterns(List.of("*"));
7187
config.setAllowedHeaders(List.of("*"));
7288
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
89+
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
7390
source.registerCorsConfiguration("/**", config);
7491
return new CorsFilter(source);
7592
}

src/main/java/com/unithon/ddoeunyeong/infra/gptapi/controller/GptController.java

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

33
import java.io.IOException;
44

5+
import com.unithon.ddoeunyeong.global.exception.CustomException;
6+
import com.unithon.ddoeunyeong.global.exception.ErrorCode;
57
import com.unithon.ddoeunyeong.infra.fastapi.stt.STTService;
68
import org.springframework.http.MediaType;
9+
import org.springframework.web.bind.annotation.GetMapping;
710
import org.springframework.web.bind.annotation.PostMapping;
811
import org.springframework.web.bind.annotation.RequestBody;
912
import org.springframework.web.bind.annotation.RequestMapping;
1013
import org.springframework.web.bind.annotation.RequestParam;
1114
import org.springframework.web.bind.annotation.RestController;
1215
import org.springframework.web.multipart.MultipartFile;
1316

17+
import com.unithon.ddoeunyeong.infra.gptapi.dto.GptFinalResponse;
1418
import com.unithon.ddoeunyeong.infra.gptapi.dto.GptResponse;
1519
import com.unithon.ddoeunyeong.infra.gptapi.dto.GptTestResponse;
1620
import com.unithon.ddoeunyeong.infra.gptapi.dto.SttRequest;
1721
import com.unithon.ddoeunyeong.infra.gptapi.service.GptService;
1822
import com.unithon.ddoeunyeong.global.exception.BaseResponse;
1923

2024
import io.swagger.v3.oas.annotations.Operation;
25+
import lombok.Getter;
2126
import lombok.RequiredArgsConstructor;
2227

2328
@RestController
@@ -47,4 +52,19 @@ public BaseResponse<String> makeDoll(@RequestParam("doll") MultipartFile file, @
4752
return gptService.makeDoll(childId,file);
4853
}
4954

55+
@GetMapping("/final")
56+
@Operation(summary = "마지막 상담 결과를 생성하는 API입니다.", description = "상담이 끝나면 해당 API를 호출해서 마지막 결과를 출력해주세요.")
57+
public BaseResponse<GptFinalResponse> makeFinalAnswer(@RequestParam Long adviceId){
58+
try {
59+
return BaseResponse.<GptFinalResponse>builder()
60+
.code(200)
61+
.message("상담 결과가 생성되었습니다.")
62+
.data(gptService.askGptMakeFinalReport(adviceId))
63+
.isSuccess(true)
64+
.build();
65+
} catch (IOException e) {
66+
throw new CustomException(ErrorCode.FINAL_ERROR);
67+
}
68+
}
69+
5070
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.unithon.ddoeunyeong.infra.gptapi.dto;
2+
3+
import java.util.List;
4+
5+
import com.unithon.ddoeunyeong.domain.child.dto.ChildProfile;
6+
import com.unithon.ddoeunyeong.domain.survey.dto.SurveyDto;
7+
import com.unithon.ddoeunyeong.domain.utterance.dto.QuestionAndAnser;
8+
9+
import lombok.AllArgsConstructor;
10+
import lombok.Getter;
11+
import lombok.Setter;
12+
13+
@Setter
14+
@Getter
15+
@AllArgsConstructor
16+
public class GptFinalRequest {
17+
private ChildProfile childProfile;
18+
private List<QuestionAndAnser> history;
19+
private SurveyDto survey;
20+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.unithon.ddoeunyeong.infra.gptapi.dto;
2+
3+
public record GptFinalResponse(Long socialReferenceScore,
4+
Long cooperationKindnessScore,
5+
String summary,
6+
String coreQuestion,
7+
String childAnswer
8+
) {
9+
}

src/main/java/com/unithon/ddoeunyeong/infra/gptapi/service/GptService.java

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import com.unithon.ddoeunyeong.domain.child.repository.ChildRepository;
1313
import com.unithon.ddoeunyeong.domain.utterance.dto.QuestionAndAnser;
1414
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;
1517
import com.unithon.ddoeunyeong.infra.s3.service.S3Service;
1618
import org.springframework.beans.factory.annotation.Value;
1719
import org.springframework.core.io.ByteArrayResource;
@@ -89,6 +91,7 @@ public String makeFirstQuestionWithSurvey(Survey survey){
8991

9092
String systemPrompt = """
9193
당신은 심리 상담 AI입니다. 그리고 어린아이를 대상으로 말한다는 것을 고려해주세요.또한 민감한 주제에 대한 질문은 피해주세요.
94+
또한 제공하는 값중에서 knowAboutChild는 오늘 답변을 듣기 궁금한 질문입니다. 그리고 getKnowInfo는 오늘 대화에서 참조해줬으면 좋겠는 사항입니다.
9295
사용자에 대한 정보가 입력되면 이를 반영해서 첫번째 질문을 생성해주세요.
9396
""";
9497

@@ -163,6 +166,7 @@ public GptResponse askGptAfterSurvey(String userText, Long adviceId) throws IOEx
163166
당신은 감정 분석과 꼬리질문을 수행하는 감성 상담 AI입니다. 그리고 어린아이를 대상으로 말한다는 것을 고려해주세요.
164167
사용자 정보와 발화 이력, 이전 질문이 주어지면, 다음과 같은 JSON 응답을 반환하세요:
165168
이때 emotion은 angry, disgust, fear, happy, sad, surprise, neutral 중에 가장 연관될 것을 선택해주세요.
169+
또한 제공하는 값중에서 knowAboutChild는 오늘 답변을 듣기 궁금한 질문입니다. 그리고 getKnowInfo는 오늘 대화에서 참조해줬으면 좋겠는 사항입니다.
166170
또한 이전 질문을 고려하여 반복적인 내용이 반영되는 질문이 나오지 않도록 해주세요.
167171
{
168172
"emotion": "...",
@@ -216,6 +220,139 @@ public GptResponse askGptAfterSurvey(String userText, Long adviceId) throws IOEx
216220
}
217221

218222

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+
219356

220357
@SuppressWarnings("unchecked")
221358
private String extractContentSafely(Map<String, Object> responseBody) {
@@ -355,9 +492,6 @@ private String stripExt(String name) {
355492
}
356493

357494

358-
359-
360-
361495
/** ```json ... ``` 형태여도 첫 번째 JSON 오브젝트만 추출 */
362496
private String extractFirstJsonObject(String content) {
363497
if (content == null || content.isBlank())

0 commit comments

Comments
 (0)