Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions frontend/src/features/feedback/model/useFeedbackLive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
27 changes: 25 additions & 2 deletions frontend/src/features/feedback/model/useFeedbackLive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <payload>, traceId } (useWorkspaceAnalysisStream 과 동일).
type StreamData<T> = { data?: T; traceId?: string | null }

Expand Down Expand Up @@ -56,16 +63,32 @@ export function useFeedbackLive(sessionId: number, getToken: () => Promise<strin
})
}, [])

// 생성 실패 신호. 세션 채널 ERROR 는 꼬리질문(FOLLOWUP) 실패도 흐르므로 scope 로 거른다.
// 데이터가 이미 도착했으면 늦은 실패 이벤트는 소비부(!data 가드)에서 무시된다.
const [failure, setFailure] = useState<FeedbackFailure | null>(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 }
}
39 changes: 33 additions & 6 deletions frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -64,9 +62,38 @@ export default function SessionFeedbackPage() {
}
/>

{isLoading && <FeedbackReportSkeleton progress={progress} />}
{/* 생성 실패 SSE(ERROR, scope=FEEDBACK) — 폴링 예산 소진(≈2분)을 기다리지 않고
즉시 복구 경로를 연다. 그 사이 데이터가 도착했으면 리포트가 우선. */}
{!data && failure && (
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-16 text-center">
<p className="text-body text-fg">{failure.message}</p>
{failure.retriable === false ? (
// 서버가 재시도 무의미(retriable=false)로 명시한 실패 — 재생성 대신 새 면접을 안내.
<p className="text-caption text-fg-muted">
같은 오류가 반복될 수 있어 재생성이 어렵습니다. 상단 버튼으로 다시 도전해 보세요.
</p>
) : (
<>
<p className="text-caption text-fg-muted">
피드백 생성이 실패해 대기를 중단했어요. 다시 생성을 요청해 보세요.
</p>
<Button
onClick={() => {
resetFailure()
regenerate.mutate()
}}
disabled={regenerate.isPending}
>
{regenerate.isPending ? '요청 중…' : '피드백 다시 생성'}
</Button>
</>
)}
</div>
)}

{!failure && isLoading && <FeedbackReportSkeleton progress={progress} />}

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