diff --git a/frontend/src/features/feedback/model/useFeedbackLive.test.tsx b/frontend/src/features/feedback/model/useFeedbackLive.test.tsx index f87d2e3..2221a70 100644 --- a/frontend/src/features/feedback/model/useFeedbackLive.test.tsx +++ b/frontend/src/features/feedback/model/useFeedbackLive.test.tsx @@ -134,4 +134,62 @@ describe('useFeedbackLive', () => { act(() => FakeES.last?.emit('FEEDBACK_PROGRESS', {})) expect(result.current.progress).toBeNull() }) + + it('ERROR(scope=FEEDBACK) 수신 시 failure 를 설정한다 — 폴링 예산 소진을 기다리지 않는 복구 신호', async () => { + vi.mocked(getFeedback).mockRejectedValue(notReady()) + + const { result } = setup() + await waitFor(() => expect(FakeES.last).not.toBeNull()) + expect(result.current.failure).toBeNull() + + act(() => + FakeES.last?.emit('ERROR', { + data: { + sessionId: 99, + scope: 'FEEDBACK', + errorCode: 'FEEDBACK_GENERATION_FAILED', + message: '피드백 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.', + retriable: true, + }, + }), + ) + + expect(result.current.failure).toEqual({ + message: '피드백 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.', + retriable: true, + }) + }) + + it('scope 가 FEEDBACK 이 아닌 ERROR(꼬리질문 실패 등)는 무시한다', async () => { + vi.mocked(getFeedback).mockRejectedValue(notReady()) + + const { result } = setup() + await waitFor(() => expect(FakeES.last).not.toBeNull()) + + act(() => + FakeES.last?.emit('ERROR', { + data: { sessionId: 99, scope: 'FOLLOWUP', message: '질문 생성에 실패했습니다.' }, + }), + ) + act(() => FakeES.last?.emit('ERROR', { data: { sessionId: 99 } })) + act(() => FakeES.last?.emit('ERROR', {})) + expect(result.current.failure).toBeNull() + }) + + it('resetFailure 는 대기 상태로 되돌린다 — 재생성 요청 직전에 페이지가 호출', async () => { + vi.mocked(getFeedback).mockRejectedValue(notReady()) + + const { result } = setup() + await waitFor(() => expect(FakeES.last).not.toBeNull()) + + act(() => + FakeES.last?.emit('ERROR', { + data: { sessionId: 99, scope: 'FEEDBACK', message: '피드백 생성에 실패했습니다.' }, + }), + ) + expect(result.current.failure).not.toBeNull() + + act(() => result.current.resetFailure()) + expect(result.current.failure).toBeNull() + }) }) diff --git a/frontend/src/features/feedback/model/useFeedbackLive.ts b/frontend/src/features/feedback/model/useFeedbackLive.ts index ab0a3cc..efd8443 100644 --- a/frontend/src/features/feedback/model/useFeedbackLive.ts +++ b/frontend/src/features/feedback/model/useFeedbackLive.ts @@ -12,6 +12,13 @@ export type FeedbackProgress = { total: number | null } +// AI 피드백 생성 실패 알림(ERROR, scope=FEEDBACK — event-stream.md §3.6 SessionErrorNotice). +// 수신 즉시 폴링 예산(≈2분) 소진을 기다리지 않고 재생성 복구 UI 로 전환하기 위한 상태. +export type FeedbackFailure = { + message: string + retriable: boolean | null +} + // RealTime SSE data 봉투: { data: , traceId } (useWorkspaceAnalysisStream 과 동일). type StreamData = { data?: T; traceId?: string | null } @@ -56,16 +63,32 @@ export function useFeedbackLive(sessionId: number, getToken: () => Promise(null) + const onError = useCallback((raw: unknown) => { + const data = ( + raw as StreamData<{ scope?: string; message?: string; retriable?: boolean }> | null + )?.data + if (!data || data.scope !== 'FEEDBACK' || typeof data.message !== 'string') return + setFailure({ + message: data.message, + retriable: typeof data.retriable === 'boolean' ? data.retriable : null, + }) + }, []) + // 재생성 요청 시 페이지가 호출 — 대기(스켈레톤) 상태로 되돌린다. + const resetFailure = useCallback(() => setFailure(null), []) + // 피드백이 도착하면 스트림을 닫는다 (path: null). const streamStatus = useEventStream({ path: query.data ? null : `/realtime/stream/sessions/${sessionId}`, getToken, - handlers: { FEEDBACK_READY: onReady, FEEDBACK_PROGRESS: onProgress }, + handlers: { FEEDBACK_READY: onReady, FEEDBACK_PROGRESS: onProgress, ERROR: onError }, }) useEffect(() => { statusRef.current = streamStatus }, [streamStatus]) - return { ...query, streamStatus, progress } + return { ...query, streamStatus, progress, failure, resetFailure } } diff --git a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx index 2ce29d6..7c19c6d 100644 --- a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx +++ b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx @@ -24,10 +24,8 @@ export default function SessionFeedbackPage() { // 피드백 조회 + FEEDBACK_READY SSE — 준비 완료 즉시 표시(폴링은 백스톱). // 세션 stream token 은 interview 슬라이스 소유라 페이지가 주입한다. const getToken = useCallback(() => fetchSessionStreamToken(sessionId), [sessionId]) - const { data, isLoading, isError, error, refetch, progress } = useFeedbackLive( - sessionId, - getToken, - ) + const { data, isLoading, isError, error, refetch, progress, failure, resetFailure } = + useFeedbackLive(sessionId, getToken) const regenerate = useRegenerateFeedback(sessionId) // 재도전은 원본 세션의 자료 수를 알아야 "몇 개가 빠졌는지" 안내할 수 있다. const { data: session } = useSession(sessionId) @@ -64,9 +62,38 @@ export default function SessionFeedbackPage() { } /> - {isLoading && } + {/* 생성 실패 SSE(ERROR, scope=FEEDBACK) — 폴링 예산 소진(≈2분)을 기다리지 않고 + 즉시 복구 경로를 연다. 그 사이 데이터가 도착했으면 리포트가 우선. */} + {!data && failure && ( +
+

{failure.message}

+ {failure.retriable === false ? ( + // 서버가 재시도 무의미(retriable=false)로 명시한 실패 — 재생성 대신 새 면접을 안내. +

+ 같은 오류가 반복될 수 있어 재생성이 어렵습니다. 상단 버튼으로 다시 도전해 보세요. +

+ ) : ( + <> +

+ 피드백 생성이 실패해 대기를 중단했어요. 다시 생성을 요청해 보세요. +

+ + + )} +
+ )} + + {!failure && isLoading && } - {isError && + {!failure && isError && (isFeedbackPending(error) ? ( // 백스톱 재시도 예산(모드 무관 ≈2분, useFeedbackLive)을 다 써도 피드백이 없으면 // 생성 요청이 유실됐을 가능성이 높다(브로커 다운·AI 실패). 무한 대기 대신 복구 경로를 연다.