diff --git a/src/domains/opic/pages/OPIcPage.jsx b/src/domains/opic/pages/OPIcPage.jsx
index 365d912..af50e00 100644
--- a/src/domains/opic/pages/OPIcPage.jsx
+++ b/src/domains/opic/pages/OPIcPage.jsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
import {
Alert,
Box,
@@ -18,6 +19,8 @@ import {
Chip,
Divider,
IconButton,
+ Grid,
+ Paper
} from '@mui/material'
import {
RecordVoiceOver as VoiceIcon,
@@ -29,10 +32,15 @@ import {
Send as SendIcon,
UploadFile as UploadIcon,
VolumeUp as SpeakerIcon,
+ Home as HomeIcon,
+ TrendingUp as TrendingUpIcon,
+ Warning as WarningIcon,
+ Lightbulb as LightbulbIcon,
+ Email as EmailIcon
} from '@mui/icons-material'
import { useSettings } from '../../../contexts/SettingsContext'
import { useAuth } from '../../../contexts/AuthContext'
-import { sessionService, uploadAudioToS3 } from '../services/opicService'
+import { sessionService, uploadAudioToS3, pollForAnswerResult } from '../services/opicService'
import {
OPIC_TOPICS,
OPIC_TOPIC_LABELS,
@@ -43,6 +51,7 @@ export default function OPIcPage() {
const { t, isKorean } = useSettings()
const { user } = useAuth()
const theme = useTheme()
+ const navigate = useNavigate()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
// Session state
@@ -64,6 +73,7 @@ export default function OPIcPage() {
const [mediaRecorder, setMediaRecorder] = useState(null)
const [recordingTime, setRecordingTime] = useState(0)
const audioChunksRef = useRef([])
+ const [processingStatus, setProcessingStatus] = useState(null)
// Upload state
const [uploadUrl, setUploadUrl] = useState(null)
@@ -72,6 +82,10 @@ export default function OPIcPage() {
// Feedback state
const [feedback, setFeedback] = useState(null)
+ // Report state
+ const [sessionReport, setSessionReport] = useState(null)
+ const [showReport, setShowReport] = useState(false)
+
// UI state
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
@@ -150,6 +164,7 @@ export default function OPIcPage() {
setRecordedUrl(null)
setUploadUrl(null)
setS3Key(null)
+ setProcessingStatus(null)
}
// Get next question
@@ -162,14 +177,19 @@ export default function OPIcPage() {
// 모든 질문 완료 확인
if (data.completed) {
// 세션 완료 처리
- handleCompleteSession()
+ await handleCompleteSession()
return
}
- // 질문 데이터로 화면 업데이트
- displayQuestion(data) // data.question → data로 수정
- setQuestionNumber(data.questionNumber || questionNumber + 1)
- setTotalQuestions(data.totalQuestions || totalQuestions)
+ if (data.question) {
+ displayQuestion(data.question)
+
+ // 질문 번호 업데이트 (백엔드에서 오는 questionNumber가 있으면 사용, 없으면 수동 계산)
+ setQuestionNumber(data.question.questionNumber || questionNumber + 1)
+ } else {
+ console.error("❌ 질문 데이터를 찾을 수 없습니다:", data)
+ setError(isKorean ? '질문 데이터를 불러오지 못했습니다' : 'Failed to load question data')
+ }
} catch (err) {
console.error('Failed to get next question:', err)
setError(isKorean ? '다음 질문을 불러오는데 실패했습니다' : 'Failed to get next question')
@@ -243,18 +263,34 @@ export default function OPIcPage() {
setLoading(true)
setError(null)
setUploadProgress(0)
+ setProcessingStatus(isKorean ? 'S3에 업로드 중...' : 'Uploading to S3...')
- // Upload to S3
+ // 1. S3 업로드
await uploadAudioToS3(uploadUrl, recordedBlob)
- setUploadProgress(50)
+ setUploadProgress(20)
+ setProcessingStatus(isKorean ? '답변 제출 중...' : 'Submitting...')
+
+ // 2. 답변 제출 (비동기 처리 시작 요청)
+ const submitResult = await sessionService.submitAnswer(sessionId, { audioS3Key: s3Key })
+ setUploadProgress(40)
+ setProcessingStatus(isKorean ? 'AI가 분석 중...' : 'AI is analyzing...')
+
+ // 3. 폴링으로 결과 대기 (백엔드 완료될 때까지 반복 확인)
+ const result = await pollForAnswerResult(sessionId, submitResult.questionIndex, {
+ onProgress: ({ attempt }) => {
+ // 진행 상황에 따라 프로그레스 바를 조금씩 채움 (40% ~ 90%)
+ setUploadProgress(prev => Math.min(prev + 1, 90))
+ }
+ })
- // Submit answer
- const data = await sessionService.submitAnswer(sessionId, { audioS3Key: s3Key })
- setFeedback(data)
+ // 4. 최종 결과 세팅
+ setFeedback(result)
setUploadProgress(100)
+ setProcessingStatus(null)
+
} catch (err) {
console.error('Failed to submit answer:', err)
- setError(isKorean ? '답변 제출에 실패했습니다' : 'Failed to submit answer')
+ setError(isKorean ? '분석 중 오류가 발생했습니다. 다시 시도해주세요.' : 'Analysis failed. Please try again.')
} finally {
setLoading(false)
setTimeout(() => setUploadProgress(0), 2000)
@@ -269,18 +305,12 @@ export default function OPIcPage() {
setLoading(true)
setError(null)
const report = await sessionService.complete(sessionId)
- // TODO: Navigate to report page or display report
- console.log('Session completed:', report)
- alert(
- isKorean
- ? '세션이 완료되었습니다! 리포트를 확인하세요.'
- : 'Session completed! Check your report.'
- )
- // Reset for new session
- setSessionId(null)
- setCurrentQuestion(null)
- setQuestionNumber(0)
- setFeedback(null)
+
+ const reportData = report.data || report;
+
+ // 리포트 데이터 저장 및 화면 전환
+ setSessionReport(reportData)
+ setShowReport(true)
} catch (err) {
console.error('Failed to complete session:', err)
setError(isKorean ? '세션 완료에 실패했습니다' : 'Failed to complete session')
@@ -289,6 +319,24 @@ export default function OPIcPage() {
}
}
+ const handleStartNewSession = () => {
+ setSessionId(null)
+ setCurrentQuestion(null)
+ setQuestionNumber(0)
+ setFeedback(null)
+ setShowReport(false)
+ setSessionReport(null)
+ setRecordedBlob(null)
+ setRecordedUrl(null)
+ setUploadUrl(null)
+ setS3Key(null)
+ }
+
+ // Navigate to reports page
+ const handleGoToReports = () => {
+ navigate('/reports')
+ }
+
// Play question audio
const handlePlayQuestionAudio = () => {
if (questionAudioRef.current) {
@@ -315,491 +363,332 @@ export default function OPIcPage() {
}
}
+ const getLevelColor = (level) => {
+ const colors = {
+ 'IM1': '#22c55e', 'IM2': '#3b82f6', 'IM3': '#8b5cf6',
+ 'IH': '#f97316', 'AL': '#ef4444'
+ };
+ return colors[level] || '#3b82f6';
+ }
+
+ // 결과 리포트 화면
+ if (showReport && sessionReport) {
+ return (
+
+
+
+ {/* Header: Score & Level */}
+
+
+ {isKorean ? '테스트 결과 리포트' : 'TEST REPORT'}
+
+
+
+ {/* Level */}
+
+
+ {sessionReport.estimatedLevel}
+
+ LEVEL
+
+
+
+
+ {/* Score */}
+
+
+ {sessionReport.overallScore}
+
+ SCORE
+
+
+
+ {/* Overall Feedback */}
+
+
+ {sessionReport.feedback}
+
+
+
+
+
+
+ {/* Details Grid */}
+
+ {[
+ { title: isKorean ? "잘한 점" : "Strengths", items: sessionReport.strengths, color: "#16a34a", icon: TrendingUpIcon },
+ { title: isKorean ? "아쉬운 점" : "Weaknesses", items: sessionReport.weaknesses, color: "#ea580c", icon: WarningIcon },
+ { title: isKorean ? "학습 추천" : "Tips", items: sessionReport.recommendations, color: "#7c3aed", icon: LightbulbIcon }
+ ].map((section, idx) => (
+
+
+
+
+
+ {section.title}
+
+
+
+ {section.items && section.items.length > 0 ? (
+ section.items.map((item, i) => (
+
+ {item}
+
+ ))
+ ) : (
+ -
+ )}
+
+
+
+ ))}
+
+
+ {/* Email Notification */}
+
+ }
+ label={isKorean ? "결과가 이메일로 발송되었습니다." : "Report sent to your email."}
+ variant="outlined"
+ size="small"
+ sx={{ borderColor: '#e2e8f0', color: '#94a3b8', fontSize: '0.8rem' }}
+ />
+
+
+
+ {/* Bottom Actions */}
+
+
+
+
+
+ );
+ }
+
return (
-
+
+
{/* Header */}
-
-
+
+
-
-
- {isKorean ? 'OPIc 스피킹 테스트' : 'OPIc Speaking Test'}
-
-
- {isKorean
- ? 'AI 기반 OPIc 연습 및 피드백'
- : 'AI-powered English speaking practice & feedback'}
-
+ {isKorean ? 'OPIc 스피킹 테스트' : 'OPIc Speaking Test'}
+ {isKorean ? 'AI 기반 OPIc 연습 및 피드백' : 'AI-powered English speaking practice'}
{/* Main Content */}
-
- {error && (
-
- {error}
-
- )}
+
+ {error && {error}}
- {/* Session Setup */}
+ {/* --- [1] Session Setup --- */}
{!sessionId && (
-
- {isKorean ? 'OPIc 질문 생성' : 'Session Setup'}
-
+ {isKorean ? 'OPIc 질문 생성' : 'Session Setup'}
- {/* 주제 선택 */}
{isKorean ? '질문 유형' : 'Question Type'}
-
- {/* 세부 주제 (소재) 선택 - 업데이트된 목록 사용 */}
{isKorean ? '주제' : 'Topic'}
-
- setSessionSettings((prev) => ({
- ...prev,
- subTopic: e.target.value,
- }))
- }
- MenuProps={{ PaperProps: { sx: { maxHeight: 300 } } }} // 목록이 기니까 스크롤
- >
+ setSessionSettings((prev) => ({ ...prev, subTopic: e.target.value }))} MenuProps={{ PaperProps: { sx: { maxHeight: 300 } } }}>
{OPIC_SUBTOPICS.map((item) => (
-
+
))}
-
- * {isKorean
- ? `프로필에 설정된 나의 레벨(${user?.level})에 맞춰 AI 피드백이 제공됩니다.`
- : `AI feedback will be tailored to your current level (${user?.level || 'IM2'}).`}
+ * {isKorean ? `프로필에 설정된 나의 레벨(${user?.level || 'IM2'})에 맞춰 AI 피드백이 제공됩니다.` : `AI feedback tailored to level (${user?.level || 'IM2'}).`}
-
)}
- {/* Question Area */}
+ {/* --- [2] Question Area --- */}
{sessionId && currentQuestion && (
<>
-
-
+
+
-
+ {/* 질문 카드 */}
+
-
- {currentQuestion.questionText}
-
-
+ {currentQuestion.questionText}
{currentQuestion.audioUrl && (
-
- {isPlayingQuestion ? (
-
- ) : (
-
- )}
+
+ {isPlayingQuestion ? : }
-
- {isKorean ? '질문 듣기' : 'Listen to question'}
-
-
)}
- {/* Recording Area */}
-
+ {/* 녹음 카드 */}
+
-
- {isKorean ? '답변 녹음' : 'Record Answer'}
-
-
-
- : }
- onClick={toggleRecording}
- disabled={feedback !== null}
- sx={{
- borderRadius: '12px',
- textTransform: 'none',
- fontWeight: 600,
- minWidth: 200,
- py: 1.5,
- animation: isRecording
- ? 'pulse 1.5s ease-in-out infinite'
- : 'none',
- '@keyframes pulse': {
- '0%, 100%': { opacity: 1 },
- '50%': { opacity: 0.6 },
- },
- }}
- >
- {isRecording
- ? isKorean
- ? '녹음 중지'
- : 'Stop Recording'
- : isKorean
- ? '녹음 시작'
- : 'Start Recording'}
+ {isKorean ? '답변 녹음' : 'Record Answer'}
+
+ : } onClick={toggleRecording} disabled={feedback !== null} sx={{ borderRadius: '12px', minWidth: 200, py: 1.5, animation: isRecording ? 'pulse 1.5s infinite' : 'none', '@keyframes pulse': { '0%, 100%': { opacity: 1 }, '50%': { opacity: 0.6 } } }}>
+ {isRecording ? (isKorean ? '녹음 중지' : 'Stop') : (isKorean ? '녹음 시작' : 'Start Recording')}
-
- {isRecording && (
-
- {formatTime(recordingTime)}
-
- )}
-
+ {isRecording && {formatTime(recordingTime)}}
{recordedUrl && !isRecording && (
-
-
+
+
{isPlayingRecorded ? : }
-
- {isKorean ? '녹음 완료' : 'Recording Ready'}
-
-
- {isKorean
- ? '재생하여 확인하세요'
- : 'Play to review'}
-
+ {isKorean ? '녹음 완료' : 'Recorded'}
- setIsPlayingRecorded(false)}
- style={{ display: 'none' }}
- />
+ setIsPlayingRecorded(false)} style={{ display: 'none' }} />
)}
- {/* Submit Area */}
+ {/* 제출 버튼 영역 */}
{recordedBlob && !feedback && (
-
+
{!uploadUrl ? (
- }
- onClick={handleGetUploadUrl}
- disabled={loading}
- sx={{
- borderRadius: '12px',
- textTransform: 'none',
- fontWeight: 600,
- py: 1.5,
- }}
- >
- {loading
- ? isKorean
- ? '준비 중...'
- : 'Preparing...'
- : isKorean
- ? 'Upload URL 발급'
- : 'Get Upload URL'}
+ } onClick={handleGetUploadUrl} disabled={loading} sx={{ borderRadius: '12px', py: 1.5 }}>
+ {loading ? 'Preparing...' : isKorean ? '업로드 준비' : 'Prepare Upload'}
) : (
- }
- onClick={handleSubmitAnswer}
- disabled={loading}
- sx={{
- borderRadius: '12px',
- textTransform: 'none',
- fontWeight: 600,
- py: 1.5,
- }}
- >
- {loading
- ? isKorean
- ? '제출 중...'
- : 'Submitting...'
- : isKorean
- ? '답변 제출'
- : 'Submit Answer'}
+ } onClick={handleSubmitAnswer} disabled={loading} sx={{ borderRadius: '12px', py: 1.5 }}>
+ {loading ? (
+
+
+ {processingStatus || (isKorean ? '처리 중...' : 'Processing...')}
+
+ ) : isKorean ? '제출하기' : 'Submit'}
)}
{uploadProgress > 0 && (
-
+
+
+ {processingStatus && (
+
+ {processingStatus}
+
+ )}
+
)}
)}
- {/* Feedback Area */}
+ {/* 피드백 및 다음 질문 버튼 */}
{feedback && (
-
+
-
- {isKorean ? 'AI 피드백' : 'AI Feedback'}
-
+ AI Feedback
- {/* My Answer (STT) */}
{feedback.transcript && (
-
- {isKorean ? '내 답변' : 'Your Answer'}
-
+ {isKorean ? '내 답변' : 'Your Answer'}
{feedback.transcript}
)}
- {/* Corrected Answer */}
{feedback.feedback?.correctedAnswer && (
-
- {isKorean ? '교정된 답변' : 'Corrected Answer'}
-
+ {isKorean ? '교정된 답변' : 'Corrected'}
{feedback.feedback.correctedAnswer}
)}
-
- {/* Model Answer */}
- {feedback.feedback?.sampleAnswer && (
-
-
- {isKorean ? '모범 답변' : 'Model Answer'}
-
- {feedback.feedback.sampleAnswer}
-
- )}
-
- {/* Grammar Errors */}
{feedback.feedback?.errors && feedback.feedback.errors.length > 0 && (
-
-
- {isKorean ? '문법 오류' : 'Grammar Errors'}
+
+
+ {isKorean ? ' 문법 오류 체크' : 'Grammar Errors'}
{feedback.feedback.errors.map((error, index) => (
-
- • {error.type}: {error.original} → {error.corrected}
-
-
- {error.explanation}
+
+
+ • {error.original} → {error.corrected}
-
+ {error.explanation && (
+
+ └ {error.explanation}
+
+ )}
+
))}
)}
+ {(feedback.feedback?.grammarCorrection || feedback.feedback?.feedback) && (
+
+
+
+
+ {isKorean ? ' AI 학습 팁 & 문법 교정' : 'Tips & Grammar Correction'}
+
+
+
+ {/* grammarCorrection이 있으면 우선 보여주고, 없으면 전체 feedback을 보여줌 */}
+ {feedback.feedback.grammarCorrection || feedback.feedback.feedback}
+
+
+ )}
- {/* Action Buttons */}
- {feedback.hasNextQuestion ? (
-
+ {/* 마지막 질문이 아니면 '다음 질문' 버튼, 마지막 질문이면 '세션 완료' 버튼 */}
+ {questionNumber < totalQuestions ? (
+
{isKorean ? '다음 질문' : 'Next Question'}
) : (
- }
- onClick={handleCompleteSession}
- disabled={loading}
- sx={{
- borderRadius: '12px',
- textTransform: 'none',
- fontWeight: 600,
- py: 1.5,
- }}
- >
- {loading
- ? isKorean
- ? '완료 중...'
- : 'Completing...'
- : isKorean
- ? '세션 완료'
- : 'Complete Session'}
+ } onClick={handleCompleteSession} disabled={loading} sx={{ borderRadius: '12px', py: 1.5 }}>
+ {loading ? (isKorean ? '완료 중...' : 'Completing...') : (isKorean ? '결과 보기' : 'View Results')}
)}
@@ -810,5 +699,6 @@ export default function OPIcPage() {
)}
- )
+ );
}
+
diff --git a/src/domains/opic/services/opicService.js b/src/domains/opic/services/opicService.js
index e98ac9f..3bff7b5 100644
--- a/src/domains/opic/services/opicService.js
+++ b/src/domains/opic/services/opicService.js
@@ -64,7 +64,7 @@ export const sessionService = {
},
/**
- * 답변 제출 (STT + AI 피드백)
+ * 답변 제출
* @param {string} sessionId
* @param {Object} data - { audioS3Key }
*/
@@ -76,6 +76,17 @@ export const sessionService = {
return response.data
},
+ /**
+ * 답변 상태 조회 (폴링용 함수 추가)
+ */
+ async getAnswerStatus(sessionId, questionIndex) {
+ const response = await opicApi.get(`/opic/sessions/${sessionId}/answers/${questionIndex}/status`)
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to get answer status')
+ }
+ return response.data; // { status, transcript, feedback 등 }
+ },
+
/**
* 세션 완료
* @param {string} sessionId
@@ -106,7 +117,43 @@ export const uploadAudioToS3 = async (uploadUrl, audioBlob) => {
return true
}
+/**
+ * 답변 처리 결과 폴링 헬퍼 함수
+ */
+export const pollForAnswerResult = async (sessionId, questionIndex, options = {}) => {
+ const {
+ maxAttempts = 90,
+ intervalMs = 2000, // 2초 간격
+ onProgress = null // 진행 상태 보고용 콜백
+ } = options
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const result = await sessionService.getAnswerStatus(sessionId, questionIndex)
+
+ // 1. 완료 시 결과 반환
+ if (result.status === 'COMPLETED') {
+ return result
+ }
+
+ // 2. 실패 시 에러 발생
+ if (result.status === 'FAILED') {
+ throw new Error(result.message || 'AI 분석 중 오류가 발생했습니다.')
+ }
+
+ // 3. 처리 중일 때 (PROCESSING)
+ if (onProgress) {
+ onProgress({ attempt: attempt + 1, status: result.status })
+ }
+
+ // 대기 후 재시도
+ await new Promise(resolve => setTimeout(resolve, intervalMs))
+ }
+
+ throw new Error('분석 시간이 초과되었습니다. 잠시 후 다시 확인해주세요.')
+}
+
export default {
sessionService,
uploadAudioToS3,
+ pollForAnswerResult
}
\ No newline at end of file