diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/AnswerProcessHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/AnswerProcessHandler.java new file mode 100644 index 0000000..294c9c8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/AnswerProcessHandler.java @@ -0,0 +1,213 @@ +package com.mzc.secondproject.serverless.domain.opic.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.SNSEvent; +import com.google.gson.Gson; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.opic.dto.response.FeedbackResponse; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcAnswer; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcQuestion; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcSession; +import com.mzc.secondproject.serverless.domain.opic.repository.OPIcRepository; +import com.mzc.secondproject.serverless.domain.opic.service.FeedbackService; +import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.Map; + +/** + * SNS 트리거로 답변 비동기 처리 + * - Transcribe (STT) + * - Bedrock 피드백 생성 + * - Answer 상태 업데이트 + */ +public class AnswerProcessHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(AnswerProcessHandler.class); + private static final String OPIC_BUCKET = System.getenv("OPIC_BUCKET_NAME"); + + private final Gson gson = new Gson(); + private final OPIcRepository repository = new OPIcRepository(); + private final TranscribeProxyService transcribeService = new TranscribeProxyService(); + private final FeedbackService feedbackService = new FeedbackService(); + + @Override + public Void handleRequest(SNSEvent event, Context context) { + for (SNSEvent.SNSRecord record : event.getRecords()) { + processMessage(record.getSNS().getMessage()); + } + return null; + } + + @SuppressWarnings("unchecked") + private void processMessage(String messageBody) { + Map message = gson.fromJson(messageBody, Map.class); + + String sessionId = (String) message.get("sessionId"); + String questionId = (String) message.get("questionId"); + String audioS3Key = (String) message.get("audioS3Key"); + String targetLevel = (String) message.get("targetLevel"); + int currentIndex = ((Number) message.get("currentIndex")).intValue(); + int totalQuestions = ((Number) message.get("totalQuestions")).intValue(); + + logger.info("비동기 처리 시작: sessionId={}, questionIndex={}", sessionId, currentIndex); + + try { + // Answer 조회 (sessionId + questionIndex로 조회) + OPIcAnswer answer = repository.findAnswer(sessionId, currentIndex) + .orElseThrow(() -> new RuntimeException( + String.format("Answer not found: sessionId=%s, questionIndex=%d", sessionId, currentIndex))); + + // Question 조회 + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("Question not found: " + questionId)); + + // 1. S3에서 오디오 로드 + logger.info("S3에서 오디오 파일 로드: {}", audioS3Key); + byte[] audioBytes = AwsClients.s3().getObjectAsBytes( + software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() + .bucket(OPIC_BUCKET) + .key(audioS3Key) + .build() + ).asByteArray(); + + String audioBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes); + logger.info("오디오 Base64 변환: {} bytes → {} chars", audioBytes.length, audioBase64.length()); + + // 2. Transcribe (STT) + TranscribeProxyService.TranscribeResult transcribeResult = + transcribeService.transcribe(audioBase64, sessionId); + + String transcript = transcribeResult.transcript(); + logger.info("STT 완료: transcript 길이={}", transcript.length()); + + // 3. Bedrock 피드백 + FeedbackResponse feedback = feedbackService.generateFeedback( + question.getQuestionText(), + transcript, + targetLevel + ); + logger.info("피드백 생성 완료"); + + // 4. Answer 업데이트 (COMPLETED) + answer.setQuestionText(question.getQuestionText()); + answer.setTranscript(transcript); + answer.setTranscriptConfidence(transcribeResult.confidence()); + answer.setGrammarFeedback(gson.toJson(feedback.errors())); + answer.setContentFeedback(feedback.correctedAnswer()); + answer.setSampleAnswer(feedback.sampleAnswer()); + answer.setStatus(OPIcAnswer.AnswerStatus.COMPLETED); + answer.setAttemptCount(answer.getAttemptCount() + 1); + answer.setCompletedAt(Instant.now()); + + repository.saveAnswer(answer); + + // 5. 세션 업데이트 (currentQuestionIndex 증가) + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session != null) { + session.setCurrentQuestionIndex(currentIndex + 1); + repository.updateSession(session); + } + + logger.info("비동기 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + + } catch (Exception e) { + logger.error("비동기 처리 실패: sessionId={}, questionIndex={}, error={}", + sessionId, currentIndex, e.getMessage(), e); + + // 실패 상태로 업데이트 + try { + OPIcAnswer answer = repository.findAnswer(sessionId, currentIndex).orElse(null); + if (answer != null) { + answer.setStatus(OPIcAnswer.AnswerStatus.FAILED); + answer.setAttemptCount(answer.getAttemptCount() + 1); + repository.saveAnswer(answer); + logger.info("Answer 상태 FAILED로 업데이트: sessionId={}, questionIndex={}", sessionId, currentIndex); + } + } catch (Exception ex) { + logger.error("실패 상태 업데이트 실패", ex); + } + } + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java index 659ba64..c79576d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java @@ -26,6 +26,7 @@ import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import software.amazon.awssdk.services.sns.model.PublishRequest; import java.lang.reflect.Type; import java.time.Duration; @@ -37,26 +38,26 @@ * OPIc 세션 통합 Handler * - 세션 생성/조회 * - 질문 조회 (Polly 음성 URL 포함) - * - 답변 제출 (Transcribe + Bedrock 피드백) + * - 답변 제출 (비동기: SNS → AnswerProcessHandler) + * - 답변 상태 조회 (폴링) * - 세션 완료 (종합 리포트) */ public class OPIcSessionHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(OPIcSessionHandler.class); private static final Gson gson = new GsonBuilder() .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") .registerTypeAdapter(Instant.class, new InstantTypeAdapter()) .create(); - + private static final String OPIC_BUCKET = System.getenv("OPIC_BUCKET_NAME"); - + private final OPIcRepository repository; private final PollyService pollyService; private final TranscribeProxyService transcribeService; private final FeedbackService feedbackService; - private final EmailService emailService; - + public OPIcSessionHandler() { this.repository = new OPIcRepository(); this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); @@ -64,62 +65,65 @@ public OPIcSessionHandler() { this.feedbackService = new FeedbackService(); this.emailService = new EmailService(); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { String httpMethod = event.getHttpMethod(); String path = event.getPath(); - + try { - String userId = extractUserId(event); - - + // POST /opic/sessions - 세션 생성 if ("POST".equals(httpMethod) && path.equals("/opic/sessions")) { return createSession(event, userId); } - + // GET /opic/sessions - 세션 목록 조회 if ("GET".equals(httpMethod) && path.equals("/opic/sessions")) { return getSessions(userId); } - + // GET /opic/sessions/{sessionId} - 세션 상세 조회 if ("GET".equals(httpMethod) && path.matches("/opic/sessions/[^/]+") - && !path.contains("/questions") && !path.contains("/upload-url")) { + && !path.contains("/questions") && !path.contains("/upload-url") && !path.contains("/answers")) { return getSession(event, userId); } - + // GET /opic/sessions/{sessionId}/questions/next - 다음 질문 조회 if ("GET".equals(httpMethod) && path.contains("/questions/next")) { return getNextQuestion(event, userId); } - + // GET /opic/sessions/{sessionId}/upload-url - Presigned URL 발급 if ("GET".equals(httpMethod) && path.contains("/upload-url")) { return getUploadUrl(event, userId); } - - // POST /opic/sessions/{sessionId}/answers - 답변 제출 + + // POST /opic/sessions/{sessionId}/answers - 답변 제출 (비동기) if ("POST".equals(httpMethod) && path.contains("/answers")) { return submitAnswer(event, userId); } - + + // GET /opic/sessions/{sessionId}/answers/{questionIndex}/status - 답변 상태 조회 (폴링) + if ("GET".equals(httpMethod) && path.matches(".*/answers/\\d+/status")) { + return getAnswerStatus(event, userId); + } + // POST /opic/sessions/{sessionId}/complete - 세션 완료 if ("POST".equals(httpMethod) && path.contains("/complete")) { return completeSession(event, userId); } - + return ResponseGenerator.badRequest("지원하지 않는 요청입니다: " + httpMethod + " " + path); - + } catch (Exception e) { logger.error("OPIc Handler 에러", e); return ResponseGenerator.serverError(e.getMessage()); } } - - + + /** * POST /opic/sessions * 세션 생성 + 첫 질문 반환 @@ -132,8 +136,8 @@ private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent e // 질문 세트 조회 (주제+소주제로 조회) List questions = repository.findQuestionsByTopicAndSubTopic( - request.topic(), // 예: "DESCRIPTION" - request.subTopic() // 예: "HOMES" + request.topic(), + request.subTopic() ); // 질문 데이터 없음 예외 처리 @@ -160,7 +164,7 @@ private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent e userId, request.topic(), request.subTopic(), - request.targetLevel(), // 사용자가 선택한 레벨 저장 (AL, IM2 등) + request.targetLevel(), questionIds ); @@ -172,157 +176,159 @@ private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent e firstQuestion.getQuestionId(), firstQuestion.getQuestionText(), audioUrl, - 1, // 현재 질문 번호 - 3 // 총 질문 수 + 1, + 3 ); return ResponseGenerator.ok( new CreateSessionResponse(session.getSessionId(), questionResponse, 3) ); } - + /** * GET /opic/sessions * 사용자의 세션 목록 조회 */ private APIGatewayProxyResponseEvent getSessions(String userId) { List sessions = repository.findSessionsByUserId(userId, 20); - + Map responseBody = new LinkedHashMap<>(); responseBody.put("isSuccess", true); responseBody.put("data", sessions); - + return new APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(Map.of("Content-Type", "application/json")) .withBody(gson.toJson(responseBody)); } - + /** * GET /opic/sessions/{sessionId} * 세션 상세 조회 */ private APIGatewayProxyResponseEvent getSession(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); - + OPIcSession session = repository.findSessionById(sessionId).orElse(null); - + if (session == null) { return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); } - + if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - + // 세션에 포함된 답변들도 조회 List answers = repository.findAnswersBySessionId(sessionId); - + Map response = new LinkedHashMap<>(); response.put("session", session); response.put("answers", answers); - + return ResponseGenerator.ok(response); } - + /** * GET /opic/sessions/{sessionId}/questions/next * 다음 질문 조회 (Polly 음성 URL 포함) */ private APIGatewayProxyResponseEvent getNextQuestion(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); - + OPIcSession session = repository.findSessionById(sessionId).orElse(null); - + if (session == null) { return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); } - + if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - - // 모든 질문 완료 확인 + int currentIndex = session.getCurrentQuestionIndex(); + + // 모든 질문 완료 확인 if (currentIndex >= session.getTotalQuestions()) { - return ResponseGenerator.ok(Map.of( - "completed", true, - "message", "모든 질문이 완료되었습니다. 세션을 완료해주세요.", - "sessionId", sessionId - )); + Map response = new LinkedHashMap<>(); + response.put("completed", true); + response.put("message", "모든 질문이 완료되었습니다. 세션을 완료해주세요."); + return ResponseGenerator.ok(response); } - + // 다음 질문 조회 String questionId = session.getQuestionIds().get(currentIndex); OPIcQuestion question = repository.findQuestionById(questionId) - .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); - - // Polly 음성 URL + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다.")); + String audioUrl = generateQuestionAudioUrl(question); - + + QuestionResponse questionResponse = new QuestionResponse( + question.getQuestionId(), + question.getQuestionText(), + audioUrl, + currentIndex + 1, + session.getTotalQuestions() + ); + Map response = new LinkedHashMap<>(); - response.put("questionId", question.getQuestionId()); - response.put("questionText", question.getQuestionText()); - response.put("audioUrl", audioUrl); - response.put("questionNumber", currentIndex + 1); - response.put("totalQuestions", session.getTotalQuestions()); - response.put("completed", false); - + response.put("question", questionResponse); + response.put("hasNextQuestion", (currentIndex + 1) < session.getTotalQuestions()); + return ResponseGenerator.ok(response); } - + /** * GET /opic/sessions/{sessionId}/upload-url - * S3 Presigned URL 발급 (음성 업로드용) + * Presigned URL 발급 */ private APIGatewayProxyResponseEvent getUploadUrl(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); - - // 세션 검증 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); - if (session == null || !session.getUserId().equals(userId)) { + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - - // S3 키 생성 - String s3Key = String.format("opic/answers/%s/%s/%s.webm", - userId, - sessionId, - UUID.randomUUID().toString() - ); - - // Presigned URL 생성 (5분 유효) - PutObjectRequest putRequest = PutObjectRequest.builder() + + String fileId = UUID.randomUUID().toString(); + String s3Key = String.format("opic/answers/%s/%s/%s.webm", userId, sessionId, fileId); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(OPIC_BUCKET) .key(s3Key) .contentType("audio/webm") .build(); - - String presignedUrl = AwsClients.s3Presigner() - .presignPutObject(PutObjectPresignRequest.builder() - .putObjectRequest(putRequest) - .signatureDuration(Duration.ofMinutes(5)) - .build()) + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .putObjectRequest(putObjectRequest) + .signatureDuration(Duration.ofMinutes(10)) + .build(); + + String uploadUrl = AwsClients.s3Presigner() + .presignPutObject(presignRequest) .url() .toString(); - - return ResponseGenerator.ok(Map.of( - "uploadUrl", presignedUrl, - "s3Key", s3Key, - "expiresIn", 300 - )); + + Map response = new LinkedHashMap<>(); + response.put("uploadUrl", uploadUrl); + response.put("s3Key", s3Key); + + return ResponseGenerator.ok(response); } - + /** * POST /opic/sessions/{sessionId}/answers - * 답변 제출 → STT → AI 피드백 + * 답변 제출 (비동기 - SNS로 처리 위임) */ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); SubmitAnswerRequest request = gson.fromJson(event.getBody(), SubmitAnswerRequest.class); - - logger.info("답변 제출: sessionId={}, s3Key={}", sessionId, request.audioS3Key()); - + + logger.info("답변 제출 (비동기): sessionId={}, s3Key={}", sessionId, request.audioS3Key()); + // 세션 검증 OPIcSession session = repository.findSessionById(sessionId).orElse(null); if (session == null) { @@ -331,95 +337,154 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent ev if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - - // 현재 질문 조회 + + // 현재 질문 인덱스 확인 int currentIndex = session.getCurrentQuestionIndex(); if (currentIndex >= session.getTotalQuestions()) { return ResponseGenerator.badRequest("이미 모든 질문에 답변했습니다."); } - + String questionId = session.getQuestionIds().get(currentIndex); - OPIcQuestion question = repository.findQuestionById(questionId) - .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다.")); - - // Transcribe Proxy 호출 (음성 → 텍스트) - logger.info("S3에서 오디오 파일 로드: {}", request.audioS3Key()); - - byte[] audioBytes = AwsClients.s3().getObjectAsBytes( - software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() - .bucket(OPIC_BUCKET) - .key(request.audioS3Key()) - .build() - ).asByteArray(); - - String audioBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes); - logger.info("오디오 파일 Base64 변환 완료: {} bytes → {} chars", - audioBytes.length, audioBase64.length()); - - // 4. Transcribe Proxy 호출 (Base64 데이터 전송) - TranscribeProxyService.TranscribeResult transcribeResult = - transcribeService.transcribe(audioBase64, sessionId); - - String transcript = transcribeResult.transcript(); - logger.info("STT 변환 완료: transcript 길이={}", transcript.length()); - - // Bedrock 피드백 생성 - FeedbackResponse feedback = feedbackService.generateFeedback( - question.getQuestionText(), - transcript, - session.getTargetLevel() - ); - - // Answer 저장 - 개별 필드로 분리 저장 + + // Answer 레코드 생성 (PROCESSING 상태) OPIcAnswer answer = new OPIcAnswer(); answer.setSessionId(sessionId); answer.setQuestionId(questionId); answer.setQuestionIndex(currentIndex); - answer.setQuestionText(question.getQuestionText()); // 비정규화 answer.setAudioS3Key(request.audioS3Key()); - answer.setTranscript(transcript); - answer.setTranscriptConfidence(transcribeResult.confidence()); - - // 피드백 개별 필드 저장 - answer.setGrammarFeedback(gson.toJson(feedback.errors())); // errors → grammarFeedback - answer.setContentFeedback(feedback.correctedAnswer()); // correctedAnswer → contentFeedback - answer.setSampleAnswer(feedback.sampleAnswer()); // 모범 답변 - answer.setStatus(OPIcAnswer.AnswerStatus.COMPLETED); - answer.setAttemptCount(1); + answer.setStatus(OPIcAnswer.AnswerStatus.PROCESSING); + answer.setAttemptCount(0); answer.setCreatedAt(Instant.now()); - answer.setCompletedAt(Instant.now()); - + repository.saveAnswer(answer); - - // 세션 진행 상태 업데이트 - session.setCurrentQuestionIndex(currentIndex + 1); - repository.updateSession(session); - - // Response - boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); - + logger.info("Answer 생성 (PROCESSING): sessionId={}, questionIndex={}", sessionId, currentIndex); + + // SNS로 비동기 처리 요청 + publishToSNS(sessionId, questionId, request.audioS3Key(), + session.getTargetLevel(), currentIndex, session.getTotalQuestions()); + + // 즉시 응답 (HTTP 202 Accepted) Map response = new LinkedHashMap<>(); - response.put("transcript", transcript); + response.put("sessionId", sessionId); + response.put("questionIndex", currentIndex); + response.put("status", "PROCESSING"); + response.put("message", "답변을 처리 중입니다. 잠시 후 결과를 확인하세요."); + response.put("pollingUrl", String.format("/opic/sessions/%s/answers/%d/status", sessionId, currentIndex)); + + return ResponseGenerator.ok(response); + } + + /** + * SNS 발행 (비동기 처리 요청) + */ + private void publishToSNS(String sessionId, String questionId, String audioS3Key, + String targetLevel, int currentIndex, int totalQuestions) { + try { + String topicArn = System.getenv("ANSWER_PROCESS_TOPIC_ARN"); + + if (topicArn == null || topicArn.isEmpty()) { + logger.error("ANSWER_PROCESS_TOPIC_ARN 환경변수가 설정되지 않았습니다."); + return; + } + + Map message = new LinkedHashMap<>(); + message.put("sessionId", sessionId); + message.put("questionId", questionId); + message.put("audioS3Key", audioS3Key); + message.put("targetLevel", targetLevel); + message.put("currentIndex", currentIndex); + message.put("totalQuestions", totalQuestions); + + AwsClients.sns().publish(PublishRequest.builder() + .topicArn(topicArn) + .message(gson.toJson(message)) + .build()); + + logger.info("SNS 발행 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + } catch (Exception e) { + logger.error("SNS 발행 실패: {}", e.getMessage(), e); + // 실패해도 일단 진행 (폴링에서 PROCESSING 상태로 계속 보임) + } + } + + /** + * GET /opic/sessions/{sessionId}/answers/{questionIndex}/status + * 답변 상태 조회 (폴링용) + */ + private APIGatewayProxyResponseEvent getAnswerStatus(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + String questionIndexStr = event.getPathParameters().get("questionIndex"); + int questionIndex = Integer.parseInt(questionIndexStr); + + logger.info("답변 상태 조회: sessionId={}, questionIndex={}", sessionId, questionIndex); + + // 세션 권한 확인 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 답변 조회 + OPIcAnswer answer = repository.findAnswer(sessionId, questionIndex).orElse(null); + if (answer == null) { + return ResponseGenerator.notFound("답변을 찾을 수 없습니다."); + } + + Map response = new LinkedHashMap<>(); + response.put("sessionId", sessionId); + response.put("questionIndex", questionIndex); + response.put("status", answer.getStatus().name()); + + if (answer.getStatus() == OPIcAnswer.AnswerStatus.PROCESSING) { + response.put("message", "아직 처리 중입니다..."); + return ResponseGenerator.ok(response); + } + + if (answer.getStatus() == OPIcAnswer.AnswerStatus.FAILED) { + response.put("message", "처리에 실패했습니다. 다시 시도해주세요."); + return ResponseGenerator.ok(response); + } + + // COMPLETED - 전체 결과 반환 + response.put("transcript", answer.getTranscript()); + + // feedback 객체 구성 + Map feedback = new LinkedHashMap<>(); + if (answer.getGrammarFeedback() != null && !answer.getGrammarFeedback().isEmpty()) { + try { + feedback.put("errors", gson.fromJson(answer.getGrammarFeedback(), List.class)); + } catch (Exception e) { + feedback.put("errors", new ArrayList<>()); + } + } else { + feedback.put("errors", new ArrayList<>()); + } + feedback.put("correctedAnswer", answer.getContentFeedback()); + feedback.put("sampleAnswer", answer.getSampleAnswer()); response.put("feedback", feedback); + + boolean hasNext = (questionIndex + 1) < session.getTotalQuestions(); response.put("hasNextQuestion", hasNext); - response.put("currentQuestion", currentIndex + 1); + response.put("currentQuestion", questionIndex + 1); response.put("totalQuestions", session.getTotalQuestions()); - + if (hasNext) { - response.put("nextQuestionNumber", currentIndex + 2); + response.put("nextQuestionNumber", questionIndex + 2); } - - logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); - return ResponseGenerator.ok("피드백이 생성되었습니다.", response); + + return ResponseGenerator.ok(response); } - + /** * POST /opic/sessions/{sessionId}/complete * 세션 완료 + 종합 리포트 생성 */ private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent event, String userId) { String sessionId = event.getPathParameters().get("sessionId"); - + OPIcSession session = repository.findSessionById(sessionId).orElse(null); if (session == null) { return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); @@ -427,29 +492,35 @@ private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent if (!session.getUserId().equals(userId)) { return ResponseGenerator.forbidden("접근 권한이 없습니다."); } - + // 모든 질문 답변 완료 확인 List answers = repository.findAnswersBySessionId(sessionId); - if (answers.size() < session.getTotalQuestions()) { + + // COMPLETED 상태인 답변만 카운트 + long completedAnswers = answers.stream() + .filter(a -> a.getStatus() == OPIcAnswer.AnswerStatus.COMPLETED) + .count(); + + if (completedAnswers < session.getTotalQuestions()) { return ResponseGenerator.badRequest( String.format("아직 %d개의 질문에 답변하지 않았습니다.", - session.getTotalQuestions() - answers.size()) + session.getTotalQuestions() - completedAnswers) ); } - + // 세션 요약 생성 (피드백용) StringBuilder summaryBuilder = new StringBuilder(); for (int i = 0; i < answers.size(); i++) { OPIcAnswer answer = answers.get(i); OPIcQuestion question = repository.findQuestionById(answer.getQuestionId()).orElse(null); - + summaryBuilder.append(String.format("### Question %d\n", i + 1)); if (question != null) { summaryBuilder.append("Q: ").append(question.getQuestionText()).append("\n"); } summaryBuilder.append("A: ").append(answer.getTranscript()).append("\n\n"); } - + // 종합 리포트 생성 (Bedrock) var sessionReport = feedbackService.generateSessionReport( summaryBuilder.toString(), @@ -469,22 +540,22 @@ private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent // 이메일 실패해도 세션 완료는 성공 처리 logger.warn("리포트 이메일 발송 실패 (무시됨): {}", e.getMessage()); } - + // 세션 완료 처리 repository.completeSession( session, sessionReport.estimatedLevel(), gson.toJson(sessionReport) ); - + logger.info("세션 완료: sessionId={}, estimatedLevel={}", sessionId, sessionReport.estimatedLevel()); - + return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); } - + // ==================== 유틸리티 ==================== - + /** * 질문 음성 URL 생성 (Polly + S3 캐싱) */ @@ -501,27 +572,27 @@ private String generateQuestionAudioUrl(OPIcQuestion question) { return null; } } - + /** * JWT 토큰에서 userId 추출 */ private String extractUserId(APIGatewayProxyRequestEvent event) { String authHeader = event.getHeaders().get("Authorization"); - + if (authHeader == null || authHeader.isEmpty()) { authHeader = event.getHeaders().get("authorization"); } - + return JwtUtil.extractUserId(authHeader) .orElseThrow(() -> new RuntimeException("인증 정보를 찾을 수 없습니다.")); } - + private static class InstantTypeAdapter implements JsonSerializer, JsonDeserializer { @Override public JsonElement serialize(Instant src, Type typeOfSrc, JsonSerializationContext context) { return new JsonPrimitive(src.toString()); } - + @Override public Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7b0e54b..1e26aec 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1512,6 +1512,16 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # OPIc SNS Topics + ############################################# + + AnswerProcessTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Sub "${AWS::StackName}-opic-answer-process" + DisplayName: "OPIc Answer Processing Topic" + ############################################# # OPIc Lambda Functions ############################################# @@ -1523,7 +1533,7 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions - Timeout: 180 + Timeout: 30 MemorySize: 1024 SnapStart: ApplyOn: PublishedVersions @@ -1531,11 +1541,14 @@ Resources: Variables: TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic + ANSWER_PROCESS_TOPIC_ARN: !Ref AnswerProcessTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt AnswerProcessTopic.TopicName - Statement: - Effect: Allow Action: @@ -1608,6 +1621,15 @@ Resources: Method: POST Auth: Authorizer: CognitoAuthV2 + # 답변 상태 조회 (폴링용) ← 새로 추가! + GetAnswerStatus: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/answers/{questionIndex}/status + Method: GET + Auth: + Authorizer: CognitoAuthV2 # 세션 완료 CompleteSession: Type: Api @@ -1627,6 +1649,43 @@ Resources: Auth: Authorizer: CognitoAuthV2 + # 비동기 답변 처리 Lambda + OPIcAnswerProcessFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-opic-answer-processor" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.opic.handler.AnswerProcessHandler::handleRequest + Description: Process OPIc answers asynchronously (STT + AI Feedback) + Timeout: 600 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref OPIcTable + - S3ReadPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + SNSTrigger: + Type: SNS + Properties: + Topic: !Ref AnswerProcessTopic + ############################################# # Speaking Lambda Functions #############################################