From 81db791f7ccbd8b1fea3335da81438ecc2030951 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 03:35:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B0=99=EC=9D=80=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EB=A9=B4=EC=A0=91=20?= =?UTF-8?q?(=EC=9E=AC=EB=8F=84=EC=A0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 면접이 끝나거나 중단된 뒤 다음 행동이 "같은 조건으로 한 번 더"인데, 그러려면 설정 화면에서 모드·직군·질문 수·자료·JD 를 처음부터 다시 고르게 했다. 버튼 하나로 줄인다. ## 왜 서버 엔드포인트인가 `SessionResponse` 가 생성에 필요한 필드를 이미 전부 노출하고 있어서 프론트만으로도 가능해 보였지만, `linkContexts` 가 삭제된 문서에 `DOC_NOT_FOUND`(404), 분석 미완료에 `DOC_NOT_ANALYZED` 를 던진다. 원본 설정을 그대로 재전송하면 **그 사이 이력서 하나 지운 사용자는 재도전이 이유 없이 실패**한다. 무엇이 아직 살아있는지 아는 건 서버라서 거기서 정리한다. - `POST /api/sessions/{id}/retry` → 201. 설정 복사 + 살아있고 ANALYZED 인 자료만 재연결 - 빠진 자료는 응답 `contextDocumentIds` 를 원본과 비교하면 드러난다 → 프론트가 "삭제된 자료 N개는 제외하고 시작합니다" 로 알린다. 모르고 시작하면 질문 근거가 달라진 걸 알 수 없다 ## 프론트 - `useRetrySession(sourceContextCount?)` — 성공 시 새 세션으로 이동, 자료가 줄었으면 안내 - CTA 위치: 종료 패널(완료·중단 모두)과 피드백 화면 헤더. 중단 세션에서는 primary 로 — 피드백이 없으니 재도전이 사실상 유일한 다음 행동이다 ## 테스트 - `SessionServiceTest` +3 — 설정 복사(JOB_TAILORED 의 JD 포함), 삭제·재분석 중 자료 제외, 타인 세션 거부 - `SessionEndedPanel.test.tsx` 신설 (5) — 완료/중단/취소별 기록·링크 노출, 재도전 호출, 자료 수 전달 backend `test` 통과, frontend vitest 86/86 · eslint 0 · tsc · build 통과. --- backend/CLAUDE.md | 6 ++ backend/openapi.json | 49 +++++++++++ .../session/application/SessionService.java | 37 ++++++++ .../presentation/SessionController.java | 22 +++++ .../application/SessionServiceTest.java | 85 +++++++++++++++++++ .../src/features/interview/api/sessionApi.ts | 5 ++ frontend/src/features/interview/index.ts | 2 + .../interview/model/useRetrySession.ts | 33 +++++++ .../interview/ui/live/LiveInterview.tsx | 8 +- .../ui/live/SessionEndedPanel.test.tsx | 80 +++++++++++++++++ .../interview/ui/live/SessionEndedPanel.tsx | 15 +++- .../ui/SessionFeedbackPage.tsx | 16 +++- frontend/src/shared/api/generated.ts | 60 +++++++++++++ 13 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 frontend/src/features/interview/model/useRetrySession.ts create mode 100644 frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index fff53edf..91eacaaa 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -440,6 +440,12 @@ docker compose up -d IP 로 해석되는 공개 도메인도 막힌다). 거부 응답에 내부 주소는 노출하지 않는다. DNS 해석기는 주입 가능(테스트가 네트워크 미의존). 여긴 첫 관문이고 실질 방어선은 AI 쪽 `url_guard.py` 다. - 같은 URL 재등록은 409(`RESUME_URL_DUPLICATE`) — 임베딩 중복으로 질문이 쏠리는 것 방지. +- **같은 설정으로 다시 면접 본 구현**: `POST /api/sessions/{id}/retry` → `SessionService.retry` 가 + 원본 세션의 설정(모드·직군·질문 수·JD 등)을 복사해 `create` 를 호출한다. **연결 자료는 지금도 + 살아있고 ANALYZED 인 것만** 다시 잇는다 — `linkContexts` 는 삭제된 문서에 `DOC_NOT_FOUND`(404), + 분석 미완료에 `DOC_NOT_ANALYZED` 를 던지므로, 원본 설정을 그대로 재전송하면 그 사이 자료 하나 + 지운 사용자는 재도전 자체가 막힌다. 빠진 자료는 응답 `contextDocumentIds` 를 원본과 비교해 + 프론트가 안내한다. - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/openapi.json b/backend/openapi.json index 1a25bc37..fcb66c30 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -371,6 +371,55 @@ } } }, + "/api/sessions/{sessionId}/retry" : { + "post" : { + "tags" : [ "Sessions" ], + "summary" : "같은 설정으로 다시 면접 (US-13 재도전)", + "description" : "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다.", + "operationId" : "retrySession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "201" : { + "description" : "새 세션 생성 완료", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "원본 세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, "/api/sessions/{sessionId}/messages" : { "get" : { "tags" : [ "Session Messages" ], diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index 612eccfd..0c44572e 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -92,6 +92,43 @@ public SessionResult create(Long userId, SessionCreateCommand command) { return SessionResult.of(session, linkedIds); } + /** + * 같은 설정으로 새 면접을 만든다(US-13 재도전). + * + *

세션 설정을 그대로 복사하되 **자료(context)는 지금도 살아있는 것만** 잇는다. 원본 설정을 + * 그대로 재전송하면 그 사이 삭제된 이력서 하나 때문에 {@code DOC_NOT_FOUND}(404)로 전체가 + * 막힌다 — 사용자 입장에선 "다시 하기"가 이유 없이 실패하는 것으로 보인다. 빠진 자료는 + * 응답의 {@code contextDocumentIds} 로 드러나므로 호출자가 원본과 비교해 안내할 수 있다. + */ + @Transactional + public SessionResult retry(Long userId, Long sourceSessionId) { + InterviewSession source = loadOwned(userId, sourceSessionId); + List reusableDocumentIds = contextDocumentIds(sourceSessionId).stream() + .filter(id -> isReusableContext(id, userId)) + .toList(); + + return create(userId, new SessionCreateCommand( + source.getTitle(), + source.getMemo(), + source.getMode(), + new ArrayList<>(source.getJobCategories()), + source.getMaxQuestions(), + source.getMaxDurationMinutes(), + source.getGeneralQuestionCount(), + source.getMaxFollowupsPerQuestion(), + reusableDocumentIds, + source.getTargetCompanyName(), + source.getTargetJobDescription() + )); + } + + // 삭제됐거나 아직 분석이 끝나지 않은 자료는 조용히 제외한다(linkContexts 는 둘 다 예외를 던진다). + private boolean isReusableContext(Long documentId, Long userId) { + return documentRepository.findActiveByIdAndOwner(documentId, userId) + .filter(doc -> doc.getAnalysisStatus() == AnalysisStatus.ANALYZED) + .isPresent(); + } + public Page listPaged(Long userId, Pageable pageable) { loadUser(userId); return sessionRepository.findByUser_IdAndDeletedFalse(userId, pageable) diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java index 03f1f894..3e72762f 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java @@ -87,6 +87,28 @@ public SessionResponse get( return SessionResponse.from(sessionService.get(principal.userId(), sessionId)); } + @Operation( + operationId = "retrySession", + summary = "같은 설정으로 다시 면접 (US-13 재도전)", + description = "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. " + + "연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 " + + "재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 " + + "무엇이 빠졌는지 알 수 있다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "새 세션 생성 완료"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "원본 세션 없음") + }) + @PostMapping("/{sessionId}/retry") + @ResponseStatus(HttpStatus.CREATED) + public SessionResponse retry( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return SessionResponse.from(sessionService.retry(principal.userId(), sessionId)); + } + @Operation(operationId = "updateSessionMeta", summary = "세션 제목/메모 수정") @ApiResponses({ @ApiResponse(responseCode = "200", description = "갱신 완료"), diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java index 42dc1827..7ec5ad74 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java @@ -176,6 +176,87 @@ void create_jobTailoredStoresCompanyAndJd() { assertThat(result.title()).isEqualTo("토스 백엔드 직무 맞춤 면접"); } + // ── retry (같은 설정으로 다시) ───────────────────────────────────────────── + + @Test + void retry_copiesSettingsIntoNewSession() { + User user = userFixture(1L); + InterviewSession source = InterviewSession.create( + user, "백엔드 모의면접", "메모", SessionMode.JOB_TAILORED, + List.of(JobCategory.BACKEND, JobCategory.INFRA), 8, 45, 4, 3 + ); + ReflectionTestUtils.setField(source, "id", 50L); + source.assignTargetRole("스택업", "JD 본문"); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.of(source)); + when(contextRepository.findBySession_Id(50L)).thenReturn(List.of()); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> { + InterviewSession s = inv.getArgument(0); + ReflectionTestUtils.setField(s, "id", 101L); + return s; + }); + + SessionResult result = service.retry(1L, 50L); + + assertThat(result.id()).isEqualTo(101L); + assertThat(result.title()).isEqualTo("백엔드 모의면접"); + assertThat(result.memo()).isEqualTo("메모"); + assertThat(result.mode()).isEqualTo(SessionMode.JOB_TAILORED); + assertThat(result.jobCategories()) + .containsExactly(JobCategory.BACKEND, JobCategory.INFRA); + assertThat(result.maxQuestions()).isEqualTo(8); + assertThat(result.maxDurationMinutes()).isEqualTo(45); + assertThat(result.generalQuestionCount()).isEqualTo(4); + assertThat(result.maxFollowupsPerQuestion()).isEqualTo(3); + // JOB_TAILORED 의 JD 가 빠지면 새 세션이 SESSION_JD_REQUIRED 로 막힌다. + assertThat(result.targetCompanyName()).isEqualTo("스택업"); + assertThat(result.targetJobDescription()).isEqualTo("JD 본문"); + assertThat(result.status()).isEqualTo(SessionStatus.READY); + verify(events).publishEvent(any(SessionCreatedEvent.class)); + } + + // 원본 설정을 그대로 재전송하면 그 사이 삭제된 자료 하나 때문에 404 로 전체가 막힌다. + @Test + void retry_skipsDeletedOrUnanalyzedContextDocuments() { + User user = userFixture(1L); + InterviewSession source = sessionFixture(50L); + // mock 생성·스터빙은 when(...) 바깥에서 먼저 끝낸다(중첩 스터빙 금지). + AnalyzedDocument alive = analyzedDocFixture(7L, AnalysisStatus.ANALYZED); + AnalyzedDocument deleted = analyzedDocFixture(8L, AnalysisStatus.ANALYZED); + AnalyzedDocument reanalyzing = analyzedDocFixture(9L, AnalysisStatus.PROCESSING); + List contexts = List.of( + contextFixture(source, alive), + contextFixture(source, deleted), // 그 사이 삭제됨 + contextFixture(source, reanalyzing) // 재분석 중 + ); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.of(source)); + when(contextRepository.findBySession_Id(50L)).thenReturn(contexts); + when(documentRepository.findActiveByIdAndOwner(7L, 1L)).thenReturn(Optional.of(alive)); + when(documentRepository.findActiveByIdAndOwner(8L, 1L)).thenReturn(Optional.empty()); + when(documentRepository.findActiveByIdAndOwner(9L, 1L)).thenReturn(Optional.of(reanalyzing)); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> { + InterviewSession s = inv.getArgument(0); + ReflectionTestUtils.setField(s, "id", 101L); + return s; + }); + + SessionResult result = service.retry(1L, 50L); + + // 살아있는 7L 만 다시 연결 — 나머지는 조용히 제외한다(호출자가 원본과 비교해 안내). + assertThat(result.contextDocumentIds()).containsExactly(7L); + } + + @Test + void retry_rejectsSessionOfAnotherUser() { + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.retry(1L, 50L)) + .isInstanceOf(DomainException.class); + } + @Test void start_transitionsReadyToInProgress() { InterviewSession session = sessionFixture(50L); @@ -290,6 +371,10 @@ private InterviewSession sessionFixture(Long id) { return s; } + private SessionContext contextFixture(InterviewSession session, AnalyzedDocument doc) { + return SessionContext.link(session, doc); + } + private AnalyzedDocument analyzedDocFixture(Long id, AnalysisStatus status) { AnalyzedDocument doc = mock(AnalyzedDocument.class); org.mockito.Mockito.lenient().when(doc.getId()).thenReturn(id); diff --git a/frontend/src/features/interview/api/sessionApi.ts b/frontend/src/features/interview/api/sessionApi.ts index 2d405e29..f818c190 100644 --- a/frontend/src/features/interview/api/sessionApi.ts +++ b/frontend/src/features/interview/api/sessionApi.ts @@ -11,6 +11,11 @@ export async function getSession(id: number): Promise { return (await apiClient.get(`/api/sessions/${id}`)).data } +// 같은 설정으로 새 세션 생성. 삭제된 자료는 서버가 알아서 빼고 잇는다(응답의 contextDocumentIds). +export async function retrySession(id: number): Promise { + return (await apiClient.post(`/api/sessions/${id}/retry`)).data +} + export async function startSession(id: number): Promise { return (await apiClient.patch(`/api/sessions/${id}/start`)).data } diff --git a/frontend/src/features/interview/index.ts b/frontend/src/features/interview/index.ts index 37eab52b..be884065 100644 --- a/frontend/src/features/interview/index.ts +++ b/frontend/src/features/interview/index.ts @@ -3,4 +3,6 @@ export { InterviewTranscript } from './ui/InterviewTranscript' export { InterviewSetupForm } from './ui/setup/InterviewSetupForm' export type { DocOption } from './ui/setup/ContextDocumentPicker' export { useCreateSession } from './model/useCreateSession' +export { useRetrySession } from './model/useRetrySession' +export { useSession } from './model/useSession' export { useLiveInterview } from './model/useLiveInterview' diff --git a/frontend/src/features/interview/model/useRetrySession.ts b/frontend/src/features/interview/model/useRetrySession.ts new file mode 100644 index 00000000..dde8c9b7 --- /dev/null +++ b/frontend/src/features/interview/model/useRetrySession.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { toast } from '@/shared/ui' +import { retrySession } from '../api/sessionApi' +import { sessionKeys } from './useSession' + +/** + * 같은 설정으로 다시 면접. 서버가 설정을 복사하고, 그 사이 삭제·재분석 중인 자료는 빼고 잇는다. + * + * @param sourceContextCount 원본 세션이 연결하고 있던 자료 수. 새 세션에 붙은 수와 다르면 + * 조용히 빠진 것이므로 사용자에게 알린다 — 모르고 시작하면 질문 근거가 달라진 걸 알 수 없다. + */ +export function useRetrySession(sourceContextCount?: number) { + const navigate = useNavigate() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: retrySession, + onSuccess: (session) => { + void queryClient.invalidateQueries({ queryKey: sessionKeys.all }) + const linked = session.contextDocumentIds?.length ?? 0 + if (sourceContextCount != null && linked < sourceContextCount) { + toast.info( + `삭제된 자료 ${sourceContextCount - linked}개는 제외하고 시작합니다.`, + ) + } + navigate(`/sessions/${session.id}`) + }, + onError: () => { + toast.error('면접을 다시 만들지 못했어요. 잠시 후 다시 시도해 주세요.') + }, + }) +} diff --git a/frontend/src/features/interview/ui/live/LiveInterview.tsx b/frontend/src/features/interview/ui/live/LiveInterview.tsx index c355a842..8da8d818 100644 --- a/frontend/src/features/interview/ui/live/LiveInterview.tsx +++ b/frontend/src/features/interview/ui/live/LiveInterview.tsx @@ -59,7 +59,13 @@ export function LiveInterview({ sessionId }: { sessionId: number }) { return } if (status !== 'IN_PROGRESS') { - return + return ( + + ) } // 면접은 시작됐지만 첫 질문이 아직 안 왔으면 스테이지 진입 전 대기 화면을 보여준다. if (!firstQuestionReady) { diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx new file mode 100644 index 00000000..ee7d01df --- /dev/null +++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { SessionEndedPanel } from './SessionEndedPanel' + +const retryMutate = vi.fn() +const lastSourceCount = vi.fn() +vi.mock('../../model/useRetrySession', () => ({ + useRetrySession: (count?: number) => { + lastSourceCount(count) + return { mutate: retryMutate, isPending: false } + }, +})) +vi.mock('../InterviewTranscript', () => ({ + InterviewTranscript: () =>

, +})) + +beforeEach(() => { + retryMutate.mockClear() + lastSourceCount.mockClear() +}) + +function renderPanel(status: 'COMPLETED' | 'INTERRUPTED' | 'CANCELLED') { + return render( + + + , + ) +} + +describe('SessionEndedPanel', () => { + it('완료 세션은 피드백 링크와 기록을 함께 보여준다', () => { + renderPanel('COMPLETED') + + expect(screen.getByRole('link', { name: '피드백 보기' })).toHaveAttribute( + 'href', + '/sessions/7/feedback', + ) + expect(screen.getByTestId('transcript')).toBeInTheDocument() + }) + + // 중단된 면접은 피드백이 없다 — 문답 기록이 유일한 결과물이다. + it('중단 세션은 피드백 링크 없이 기록을 보여준다', () => { + renderPanel('INTERRUPTED') + + expect(screen.queryByRole('link', { name: '피드백 보기' })).not.toBeInTheDocument() + expect(screen.getByTestId('transcript')).toBeInTheDocument() + }) + + // 시작 전 취소라 보여줄 문답이 없다. + it('취소 세션은 기록을 보여주지 않는다', () => { + renderPanel('CANCELLED') + + expect(screen.queryByTestId('transcript')).not.toBeInTheDocument() + }) + + it('같은 설정으로 다시 누르면 원본 세션 id 로 재도전한다', async () => { + renderPanel('INTERRUPTED') + + await userEvent.click(screen.getByRole('button', { name: '같은 설정으로 다시' })) + + expect(retryMutate).toHaveBeenCalledWith(7) + }) + + // 자료 수를 넘겨야 "삭제된 자료 N개 제외" 안내가 가능하다. + it('원본 세션의 자료 수를 재도전 훅에 넘긴다', () => { + render( + + + , + ) + + expect(lastSourceCount).toHaveBeenCalledWith(3) + }) +}) diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx index 7f9f2118..8025df9c 100644 --- a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx +++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx @@ -1,7 +1,8 @@ import { Link } from 'react-router-dom' import { Button } from '@/shared/ui/Button' import { Eyebrow } from '@/shared/ui' -import type { SessionStatus } from '@/domain/session' +import type { Session, SessionStatus } from '@/domain/session' +import { useRetrySession } from '../../model/useRetrySession' import { InterviewTranscript } from '../InterviewTranscript' const messageByStatus: Partial> = { @@ -19,10 +20,14 @@ const showsTranscript = (status: SessionStatus) => export function SessionEndedPanel({ status, sessionId, + session, }: { status: SessionStatus sessionId: number + session?: Session }) { + const retry = useRetrySession(session?.contextDocumentIds?.length) + return (
@@ -39,6 +44,14 @@ export function SessionEndedPanel({ )} + {/* 중단됐든 끝났든, 같은 조건으로 한 번 더 해보는 게 다음 행동이다. */} + diff --git a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx index 30cd78a8..ca6bbd60 100644 --- a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx +++ b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx @@ -9,13 +9,16 @@ import { useFeedback, useRegenerateFeedback, } from '@/features/feedback' -import { InterviewTranscript } from '@/features/interview' +import { InterviewTranscript, useRetrySession, useSession } from '@/features/interview' export default function SessionFeedbackPage() { const { id } = useParams<{ id: string }>() const sessionId = Number(id) const { data, isLoading, isError, error, refetch } = useFeedback(sessionId) const regenerate = useRegenerateFeedback(sessionId) + // 재도전은 원본 세션의 자료 수를 알아야 "몇 개가 빠졌는지" 안내할 수 있다. + const { data: session } = useSession(sessionId) + const retry = useRetrySession(session?.contextDocumentIds?.length) return (
@@ -26,9 +29,14 @@ export default function SessionFeedbackPage() { title="면접 피드백" description="점수 옆에 그렇게 매긴 근거가 함께 붙습니다." actions={ - - - +
+ + + + +
} /> diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 4860eb61..6ed68880 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -80,6 +80,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/sessions/{sessionId}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 같은 설정으로 다시 면접 (US-13 재도전) + * @description 기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다. + */ + post: operations["retrySession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/sessions/{sessionId}/messages": { parameters: { query?: never; @@ -1711,6 +1731,46 @@ export interface operations { }; }; }; + retrySession: { + parameters: { + query?: never; + header?: never; + path: { + sessionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 새 세션 생성 완료 */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SessionResponse"]; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SessionResponse"]; + }; + }; + /** @description 원본 세션 없음 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SessionResponse"]; + }; + }; + }; + }; listSessionMessages: { parameters: { query?: never;