diff --git a/docs/feedback-visibility.md b/docs/feedback-visibility.md new file mode 100644 index 00000000..a2aca289 --- /dev/null +++ b/docs/feedback-visibility.md @@ -0,0 +1,47 @@ +# 종합 피드백 점수·섹션 표시 (#168) + +`scoreBreakdown.axes`를 축 이름으로 연결합니다. 순서는 의도 충족(INTENT), 깊이(DEPTH), 구체성(SPECIFICITY), 정확성(ACCURACY)이며, 종합 점수는 기존 `totalScore`를 그대로 씁니다. + +- 0점은 0점입니다. `score:null, weight:null`이 명시된 축은 “해당 없음”, 누락·잘못된 값·중복 축은 “미제공”입니다. +- 옛 응답의 의도 점수만 `intentAlignmentScore`로 보완합니다. 해당 없음은 덮어쓰지 않습니다. +- 분석 완료 결과에 고정 “분석 대기”를 만들지 않습니다. 점수 없는 결과를 조회한다고 재채점하지 않습니다. + +| 유형 | 답변 방식 | 면접관별 코멘트 | 음성 영역 | +|---|---|---|---| +| SOLO | TEXT | 숨김 | 숨김·음성 요청 없음 | +| SOLO | VOICE | 숨김 | 표시 | +| MULTI | TEXT | 표시 | 숨김·음성 요청 없음 | +| MULTI | VOICE | 표시 | 표시 | + +면접 유형은 면접 조회의 mode 우선, 누락 시 피드백 mode를 씁니다. 둘 다 없으면 면접관별 섹션을 숨깁니다. 빈 personas 개수로 유형을 추측하지 않습니다. + +답변 방식은 [API 이슈 #107](https://github.com/Alpha-origin/Repit-api-server/issues/107)의 `answerMode: TEXT | VOICE | null` 계약입니다. 새 제출에만 설정 값을 추가하며 저장된 UNKNOWN/SUBMITTING 본문·요청키에는 새 필드를 주입하지 않습니다. 생성·재진입에서도 서버 값을 보존합니다. + +기존 null 기록은 `/api/interviews/{id}/audio-analysis`의 녹음 답변 목록을 보조 근거로 씁니다. 완료 응답의 빈 녹음 목록은 섹션을 숨기지만 기록 자체를 TEXT로 보정하지 않습니다. 업로드가 전부 실패한 옛 VOICE 기록도 구분할 수 없는 한계가 있습니다. 조회 오류는 텍스트로 단정하지 않고 재확인 버튼을 표시합니다. + +음성 조회는 PENDING에만 5초 간격·최대 5분 진행하며 화면 이탈·ID 변경 시 취소합니다. 완료/부분/결과 없음/실패/조회 오류를 구분합니다. 원시 지표에서 임의 100점 점수를 만들지 않고 음성 점수·코멘트는 준비 중으로 안내합니다. 본문 채점은 음성 조회를 기다리지 않습니다. 키워드·신뢰성·PDF 기능은 TEXT에서도 유지합니다. + +## 검증 (2026-10-19) + +- `node --test tests/*.test.mjs`: 40개 통과. +- `npm run lint`: 오류 0, 기존 router.tsx unused eslint-disable 경고 1개. +- `npm run build`: 통과. +- 실제 브라우저 + 독립 HTTP fixture: SOLO/MULTI × TEXT/VOICE, 새로고침, null 기록의 녹음 있음/없음, 503 및 재확인, PENDING→READY 자동 갱신, PENDING 도중 이탈 확인. +- fixture 요청 기록: TEXT 4201·4203의 음성 API 0회, VOICE 4202·4204 각 1회, 오류 4207 재확인 포함 2회, PENDING→READY 4208은 2회, 화면 이탈한 4209는 1회에서 중단. +- SOLO TEXT PDF 다운로드 후 Poppler로 A4 1쪽 렌더링: 네 축과 키워드·신뢰성 유지, 면접관·음성 섹션 없음, 겹침·잘림 없음. +- 자체 리뷰: 축 null/0 구분, 필드 없는 구형 제출 본문 불변, effect 취소와 stale 응답 차단, PDF의 숨김 조건 확인. + +fixture 실행: + +```sh +node tests/feedback-visibility-fixture.mjs +# http://127.0.0.1:5183/__feedback-fixture +``` + +실제 서버·유료 모델을 사용하지 않는 테스트 데이터입니다. 실서버를 연결한 별도 포트에서 기존 56번 결과를 열었으나 로그인 화면으로 이동하여 **수정 후 실서버 재면접·실모델 연동은 미검증**입니다. 실행 중 localhost:5173과 DB를 변경하지 않았습니다. + +선행 Web #167, API #106·#107 변경 통합 후 실제 환경에서 새 텍스트 면접을 확인해야 합니다. 점수 생성·가중치·Analysis·Chat 계약은 변경하지 않습니다. + +![SOLO TEXT 화면](images/feedback-visibility/solo-text.png) + +![내보낸 PDF 렌더링](images/feedback-visibility/solo-text-pdf.png) diff --git a/docs/images/feedback-visibility/solo-text-pdf.png b/docs/images/feedback-visibility/solo-text-pdf.png new file mode 100644 index 00000000..76f72c93 Binary files /dev/null and b/docs/images/feedback-visibility/solo-text-pdf.png differ diff --git a/docs/images/feedback-visibility/solo-text.png b/docs/images/feedback-visibility/solo-text.png new file mode 100644 index 00000000..590c4665 Binary files /dev/null and b/docs/images/feedback-visibility/solo-text.png differ diff --git a/src/features/feedback-page/api/getAudioAnalysis.ts b/src/features/feedback-page/api/getAudioAnalysis.ts new file mode 100644 index 00000000..84e6b9f1 --- /dev/null +++ b/src/features/feedback-page/api/getAudioAnalysis.ts @@ -0,0 +1,16 @@ +import { apiInstance } from "@/shared/api/axiosInstance"; + +export type AudioAnalysisStatus = "PENDING" | "READY" | "PARTIAL" | "UNAVAILABLE" | "FAILED"; +export interface AudioAnalysis { + status: AudioAnalysisStatus; + answers: unknown[]; +} + +export const getAudioAnalysis = async (interviewId: number, signal: AbortSignal): Promise => { + const { data } = await apiInstance.get(`/api/interviews/${interviewId}/audio-analysis`, { signal, timeout: 15_000 }); + const result = data?.data; + if (data?.success !== true || !result || + !["PENDING", "READY", "PARTIAL", "UNAVAILABLE", "FAILED"].includes(result.status) || + !Array.isArray(result.answers)) throw new Error("음성 분석 정보를 확인하지 못했습니다."); + return { status: result.status, answers: result.answers }; +}; diff --git a/src/features/feedback-page/feedback-list/api/getAllInterviews.ts b/src/features/feedback-page/feedback-list/api/getAllInterviews.ts index 58207196..c4612259 100644 --- a/src/features/feedback-page/feedback-list/api/getAllInterviews.ts +++ b/src/features/feedback-page/feedback-list/api/getAllInterviews.ts @@ -24,7 +24,8 @@ export interface InterviewSummary { id?: number; interviewId?: number; userId?: number; - mode?: "SOLO" | "MULTI"; + mode?: "SOLO" | "MULTI" | null; + answerMode?: "TEXT" | "VOICE" | null; personaId?: number | null; personaIds?: number[]; persona?: InterviewPersona | null; @@ -71,7 +72,15 @@ export interface FeedbackPersona { questionCount?: number; } +export interface ScoreBreakdown { + scoringVersion?: string; + axes?: { axis: string; score: number | null; weight: number | null }[]; + consistencyScore?: number | null; +} + export interface FeedbackData { + mode?: "SOLO" | "MULTI" | null; + scoreBreakdown?: ScoreBreakdown | null; company?: CompanyBadge | null; companyFit?: unknown; feedbackId?: number; diff --git a/src/features/feedback-page/model/feedbackAudio.ts b/src/features/feedback-page/model/feedbackAudio.ts new file mode 100644 index 00000000..41962f98 --- /dev/null +++ b/src/features/feedback-page/model/feedbackAudio.ts @@ -0,0 +1,18 @@ +import type { AudioAnalysis } from "../api/getAudioAnalysis"; +import type { FeedbackSpeechAnalysis } from "@/widgets/feedback-page/feedback-overall/type"; + +export const HIDDEN_SPEECH: FeedbackSpeechAnalysis = { visible: false, message: "", canRetry: false }; +export const LOADING_SPEECH: FeedbackSpeechAnalysis = { visible: true, message: "음성 분석 정보를 확인하고 있어요.", canRetry: false }; + +export const buildSpeechAnalysis = (result: AudioAnalysis, answerMode?: "TEXT" | "VOICE" | null): FeedbackSpeechAnalysis => { + if (answerMode === "TEXT") return HIDDEN_SPEECH; + if (answerMode !== "VOICE" && result.status !== "PENDING" && result.answers.length === 0) return HIDDEN_SPEECH; + const messages = { + PENDING: "음성을 분석 중입니다. 완료되면 자동으로 표시됩니다.", + READY: "음성 분석이 완료되었습니다. 음성 점수와 코멘트 제공은 준비 중입니다.", + PARTIAL: "일부 답변의 음성 분석 결과만 있습니다. 음성 점수와 코멘트 제공은 준비 중입니다.", + UNAVAILABLE: "제공할 수 있는 음성 분석 결과가 없습니다.", + FAILED: "음성 분석에 실패했습니다.", + }; + return { visible: true, message: messages[result.status], canRetry: false }; +}; diff --git a/src/features/feedback-page/model/feedbackDisplay.ts b/src/features/feedback-page/model/feedbackDisplay.ts index 79373715..1452f49c 100644 --- a/src/features/feedback-page/model/feedbackDisplay.ts +++ b/src/features/feedback-page/model/feedbackDisplay.ts @@ -1,3 +1,4 @@ +import { buildFeedbackMetrics } from "./feedbackMetrics"; import type { InterviewAnswer } from "@/features/feedback-page/api/getInterviewAnswers"; import type { FeedbackData, @@ -113,14 +114,7 @@ export const buildFeedbackOverallContent = ( score: totalScore, description: summarySentences.slice(0, 2).map((sentence) => sentence.trim()), }, - // The current feedback API only supplies the intent score; other criteria - // remain unscored until dedicated analysis results are available. - metrics: [ - { label: "의도 충족", score: getOptionalScore(feedback.intentAlignmentScore) }, - { label: "깊이", score: null }, - { label: "구체성", score: null }, - { label: "정확성", score: null }, - ], + metrics: buildFeedbackMetrics(feedback), evaluation: summary, }, middleSection: { @@ -140,6 +134,7 @@ export const buildFeedbackOverallContent = ( previousScore: getOptionalScore(previousFeedback?.totalScore), currentScore: totalScore, }, + showPersonaFeedback: (interview?.mode ?? feedback.mode) === "MULTI", personaFeedbacks: (feedback.personas ?? []).map((persona, index) => ({ title: persona.personaRole?.trim() || `면접관 ${index + 1}`, imageUrl: @@ -147,21 +142,16 @@ export const buildFeedbackOverallContent = ( (persona.personaId === interview?.persona?.id ? interview?.persona?.imageUrl : undefined), - comment: persona.comment?.trim() || "면접관 코멘트가 아직 등록되지 않았습니다.", + comment: persona.comment?.trim() || "면접관 코멘트가 제공되지 않았습니다.", score: getOptionalScore(persona.score), hasNoAnswer: persona.answeredCount === 0, })), }, bottomSection: { - speechMetrics: ["발화 속도", "발음 명료도", "침묵 비율", "음량 안정성"].map((label) => ({ - label, - score: null, - description: "음성 분석 결과가 아직 없습니다.", - })), reliability: { title: "응답 신뢰성", scoreLabel: reliabilityScore === null ? "—" : `${reliabilityScore}%`, - levelLabel: reliabilityScore === null ? "분석 대기" : getReliabilityLevel(reliabilityScore), + levelLabel: reliabilityScore === null ? "미제공" : getReliabilityLevel(reliabilityScore), }, keywords: { items: keywords.map((item, index) => ({ diff --git a/src/features/feedback-page/model/feedbackMetrics.ts b/src/features/feedback-page/model/feedbackMetrics.ts new file mode 100644 index 00000000..ebd66981 --- /dev/null +++ b/src/features/feedback-page/model/feedbackMetrics.ts @@ -0,0 +1,28 @@ +import type { FeedbackData } from "../feedback-list/api/getAllInterviews"; +import type { FeedbackOverallMetric } from "@/widgets/feedback-page/feedback-overall/type"; + +const AXES = [ + ["INTENT", "의도 충족"], ["DEPTH", "깊이"], + ["SPECIFICITY", "구체성"], ["ACCURACY", "정확성"], +] as const; + +const validScore = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100; + +export const buildFeedbackMetrics = (feedback: FeedbackData): FeedbackOverallMetric[] => { + const axes = Array.isArray(feedback.scoreBreakdown?.axes) ? feedback.scoreBreakdown.axes : []; + return AXES.map(([axis, label]) => { + const entries = axes.filter((entry) => entry?.axis === axis); + if (entries.length === 1) { + const entry = entries[0]; + if (validScore(entry.score)) return { label, score: entry.score }; + if (entry.score === null && entry.weight === null) { + return { label, score: null, emptyLabel: "해당 없음" }; + } + } + if (entries.length === 0 && axis === "INTENT" && validScore(feedback.intentAlignmentScore)) { + return { label, score: feedback.intentAlignmentScore }; + } + return { label, score: null, emptyLabel: "미제공" }; + }); +}; diff --git a/src/features/feedback-page/model/useFeedbackAudioAnalysis.ts b/src/features/feedback-page/model/useFeedbackAudioAnalysis.ts new file mode 100644 index 00000000..f08d3f41 --- /dev/null +++ b/src/features/feedback-page/model/useFeedbackAudioAnalysis.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { getAudioAnalysis } from "../api/getAudioAnalysis"; +import { buildSpeechAnalysis, HIDDEN_SPEECH, LOADING_SPEECH } from "./feedbackAudio"; +import type { FeedbackSpeechAnalysis } from "@/widgets/feedback-page/feedback-overall/type"; + +export const useFeedbackAudioAnalysis = ( + interviewId: number | undefined, + answerMode: "TEXT" | "VOICE" | null | undefined, + enabled: boolean, +) => { + const [attempt, setAttempt] = useState(0); + const [result, setResult] = useState<{ key: string; view: FeedbackSpeechAnalysis } | null>(null); + const key = JSON.stringify([interviewId, answerMode ?? null, enabled, attempt]); + const shouldLoad = enabled && Number.isSafeInteger(interviewId) && (interviewId ?? 0) > 0 && answerMode !== "TEXT"; + + useEffect(() => { + if (!shouldLoad || interviewId === undefined) return; + const controller = new AbortController(); + const deadline = Date.now() + 5 * 60_000; + let timer: number; + const update = (view: FeedbackSpeechAnalysis) => { + if (!controller.signal.aborted) setResult({ key, view }); + }; + const poll = async () => { + if (controller.signal.aborted) return; + if (Date.now() >= deadline) { + update({ visible: true, message: "음성 분석이 지연되고 있습니다. 잠시 후 다시 확인해주세요.", canRetry: true }); + return; + } + try { + const data = await getAudioAnalysis(interviewId, controller.signal); + if (controller.signal.aborted) return; + update(buildSpeechAnalysis(data, answerMode)); + if (data.status === "PENDING") timer = window.setTimeout(() => void poll(), Math.min(5_000, Math.max(0, deadline - Date.now()))); + } catch { + update({ visible: true, message: "음성 분석 정보를 확인하지 못했습니다.", canRetry: true }); + } + }; + timer = window.setTimeout(() => void poll(), 0); + return () => { controller.abort(); window.clearTimeout(timer); }; + }, [interviewId, answerMode, key, shouldLoad]); + + return { + view: !shouldLoad ? HIDDEN_SPEECH : result?.key === key ? result.view : LOADING_SPEECH, + retry: () => setAttempt((value) => value + 1), + }; +}; diff --git a/src/features/interview-page/interview/api/create-interview/index.ts b/src/features/interview-page/interview/api/create-interview/index.ts index 825d62ff..507d96f3 100644 --- a/src/features/interview-page/interview/api/create-interview/index.ts +++ b/src/features/interview-page/interview/api/create-interview/index.ts @@ -142,6 +142,8 @@ const normalizeCreatedInterview = ( personaId, personaIds: normalizedPersonaIds, mode, + answerMode: sourceRecord?.answerMode === "TEXT" || sourceRecord?.answerMode === "VOICE" + ? sourceRecord.answerMode : null, sessionId, status: isInterviewLifecycleStatus(sourceRecord?.status) ? sourceRecord.status diff --git a/src/features/interview-page/interview/api/type/create-interview.ts b/src/features/interview-page/interview/api/type/create-interview.ts index 1b3977c3..01888bd6 100644 --- a/src/features/interview-page/interview/api/type/create-interview.ts +++ b/src/features/interview-page/interview/api/type/create-interview.ts @@ -11,6 +11,7 @@ export type CreateInterviewPersonaType = InterviewPersonality; export type InterviewPersonaGender = "MALE" | "FEMALE"; export type InterviewPersonaRole = "TECH" | "HR" | "CEO" | "PM" | "DESIGN"; export type InterviewTone = "GENTLE" | "DIRECT" | "PRESSURING"; +export type InterviewAnswerMode = "TEXT" | "VOICE"; export type InterviewMode = "SOLO" | "MULTI"; export type InterviewLifecycleStatus = | "IN_PROGRESS" @@ -19,6 +20,7 @@ export type InterviewLifecycleStatus = export interface CreateSoloInterviewParams { companyId?: number; + answerMode?: InterviewAnswerMode | null; personaName: string; major?: InterviewPersonaMajor; type: CreateInterviewPersonaType; @@ -28,6 +30,7 @@ export interface CreateSoloInterviewParams { export interface CreateMultiInterviewParams { companyId?: number; + answerMode?: InterviewAnswerMode | null; personaIds: number[]; } @@ -40,6 +43,7 @@ export interface CreatedInterviewData { interviewId: number; userId: number; mode: InterviewMode; + answerMode?: InterviewAnswerMode | null; personaId: number | null; personaIds: number[]; sessionId: string; diff --git a/src/features/interview-page/interview/api/type/index.ts b/src/features/interview-page/interview/api/type/index.ts index a39e52a2..8dbc204c 100644 --- a/src/features/interview-page/interview/api/type/index.ts +++ b/src/features/interview-page/interview/api/type/index.ts @@ -6,6 +6,7 @@ export type { CreatedInterviewData, InterviewLifecycleStatus, InterviewMode, + InterviewAnswerMode, InterviewPersonaGender, InterviewPersonaMajor, InterviewPersonaRole, diff --git a/src/features/interview-page/setting-company/model/draftStore.ts b/src/features/interview-page/setting-company/model/draftStore.ts index e7302e79..c9e5d17f 100644 --- a/src/features/interview-page/setting-company/model/draftStore.ts +++ b/src/features/interview-page/setting-company/model/draftStore.ts @@ -33,10 +33,12 @@ export const currentOwnerKey = () => { const validBody = (body: unknown, draft: SetupDraft): body is CreateInterviewParams => { if (!record(body) || (draft.companyId === null && "companyId" in body) || (body.companyId !== undefined && body.companyId !== draft.companyId) || (draft.companyId !== null && body.companyId !== draft.companyId)) return false; - if (draft.mode === "MULTI") return Object.keys(body).every((key) => ["personaIds", "companyId"].includes(key)) && + if (body.answerMode !== undefined && body.answerMode !== null && + body.answerMode !== (draft.settings.answerMode === "text" ? "TEXT" : "VOICE")) return false; + if (draft.mode === "MULTI") return Object.keys(body).every((key) => ["personaIds", "companyId", "answerMode"].includes(key)) && Array.isArray(body.personaIds) && body.personaIds.length === (draft.settings.mode === "MULTI" ? draft.settings.slots.length : 0) && body.personaIds.every(positiveId) && new Set(body.personaIds).size === body.personaIds.length; - return Object.keys(body).every((key) => ["personaName", "major", "type", "career", "gender", "companyId"].includes(key)) && + return Object.keys(body).every((key) => ["personaName", "major", "type", "career", "gender", "companyId", "answerMode"].includes(key)) && typeof body.personaName === "string" && body.personaName.length > 0 && body.personaName.length < 200 && ["FRIENDLY", "REALISTIC", "METICULOUS"].includes(String(body.type)) && typeof body.career === "number" && Number.isFinite(body.career) && body.career >= 0 && diff --git a/src/features/interview-page/setting-company/model/prepareView.ts b/src/features/interview-page/setting-company/model/prepareView.ts index 11616513..53b3930e 100644 --- a/src/features/interview-page/setting-company/model/prepareView.ts +++ b/src/features/interview-page/setting-company/model/prepareView.ts @@ -13,7 +13,7 @@ export const buildPreparedView = (settings: InterviewDraft, data: CreatedIntervi const first = interviewers[0]; return { interviewId: data.interviewId, userId: data.userId, sessionId: data.sessionId, - company: data.company, mode: data.mode, answerMode: settings.answerMode, + company: data.company, mode: data.mode, answerMode: data.answerMode === "TEXT" ? "text" : data.answerMode === "VOICE" ? "voice" : settings.answerMode, personaId: data.personaIds[0], personaName: first.name, role: first.role, major: null, type: first.personality, personaType: first.personality, level: LEVEL_BY_DIFFICULTY[settings.difficulty], career: settings.mode === "SOLO" ? ({ 쉬움: 0, 보통: 3, 어려움: 5 }[settings.difficulty]) : first.career, diff --git a/src/features/interview-page/setting-company/model/submitDraft.ts b/src/features/interview-page/setting-company/model/submitDraft.ts index 33bfaab3..33ce79c7 100644 --- a/src/features/interview-page/setting-company/model/submitDraft.ts +++ b/src/features/interview-page/setting-company/model/submitDraft.ts @@ -28,7 +28,7 @@ export const submitSetupDraft = (draftId: string): Promise => { } draft = saveSetupDraft({ ...latest, updatedAt: new Date().toISOString(), submission: { idempotencyKey: crypto.randomUUID(), - requestBody: { ...body, ...(latest.companyId !== null ? { companyId: latest.companyId } : {}) }, + requestBody: { ...body, answerMode: latest.settings.answerMode === "text" ? "TEXT" : "VOICE", ...(latest.companyId !== null ? { companyId: latest.companyId } : {}) }, interviewId: null, state: "SUBMITTING", } }); } diff --git a/src/features/interview-page/setting-company/model/useInterviewEntry.ts b/src/features/interview-page/setting-company/model/useInterviewEntry.ts index edefc471..37ca69a4 100644 --- a/src/features/interview-page/setting-company/model/useInterviewEntry.ts +++ b/src/features/interview-page/setting-company/model/useInterviewEntry.ts @@ -32,7 +32,7 @@ export const useInterviewEntry = ( return; } if (data.status === "ABANDONED") throw new Error("종료된 면접입니다. 새 면접을 만들어주세요."); - setEntry({ key, data: { ...prepared, sessionId: data.sessionId, company: data.company, status: data.status }, error: null }); + setEntry({ key, data: { ...prepared, answerMode: data.answerMode === "TEXT" ? "text" : data.answerMode === "VOICE" ? "voice" : prepared.answerMode, sessionId: data.sessionId, company: data.company, status: data.status }, error: null }); } catch (error) { if (!controller.signal.aborted) setEntry({ key, data: null, error: extractErrorMessage(error, "면접 정보를 확인하지 못했습니다.") }); } diff --git a/src/pages/feedback-page/feedback-overall/index.tsx b/src/pages/feedback-page/feedback-overall/index.tsx index 2697f73f..e6d87a9f 100644 --- a/src/pages/feedback-page/feedback-overall/index.tsx +++ b/src/pages/feedback-page/feedback-overall/index.tsx @@ -1,3 +1,4 @@ +import { useFeedbackAudioAnalysis } from "@/features/feedback-page/model/useFeedbackAudioAnalysis"; import CompanyFeedback from "@/widgets/feedback-page/company-feedback"; import { buildFeedbackOverallContent } from "@/features/feedback-page/model/feedbackDisplay"; import { useFeedbackInterview } from "@/features/feedback-page/model/useFeedbackInterview"; @@ -29,6 +30,7 @@ const FeedbackOverallPage = () => { feedback && !isAnalysisPending && !isAnalysisFailed ? buildFeedbackOverallContent(feedback, interview, previousFeedback) : null; + const audio = useFeedbackAudioAnalysis(interview?.interviewId ?? interview?.id, interview?.answerMode, content !== null); const panelRef = useRef(null); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); @@ -88,6 +90,8 @@ const FeedbackOverallPage = () => { diff --git a/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx b/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx index 242a43b5..f2226e07 100644 --- a/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx +++ b/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx @@ -3,30 +3,22 @@ import type { FeedbackOverallBottomSectionProps } from "../type"; const FeedbackOverallBottomSection = ({ content, + speechAnalysis, + onRetrySpeechAnalysis, isDownloadingPdf = false, onDownloadPdf, }: FeedbackOverallBottomSectionProps) => { - const { keywords, reliability, speechMetrics } = content; + const { keywords, reliability } = content; return ( - - 음성 분석 결과 - - {speechMetrics.map((metric) => ( - - - {metric.label} - {metric.score === null ? "분석 대기" : `${metric.score}점`} - - - {metric.description} - - ))} - - + {speechAnalysis.visible ? ( + + 음성 분석 결과 + {speechAnalysis.message} + {speechAnalysis.canRetry ? 음성 정보 다시 확인 : null} + + ) : null} diff --git a/src/widgets/feedback-page/feedback-overall/middle-section/index.tsx b/src/widgets/feedback-page/feedback-overall/middle-section/index.tsx index 9669600f..d9b0f209 100644 --- a/src/widgets/feedback-page/feedback-overall/middle-section/index.tsx +++ b/src/widgets/feedback-page/feedback-overall/middle-section/index.tsx @@ -70,7 +70,7 @@ const FeedbackOverallMiddleSection = ({ content }: FeedbackOverallMiddleSectionP - + {content.showPersonaFeedback ? {content.personaFeedbacks.length > 0 ? content.personaFeedbacks.map((persona, index) => ( {persona.comment} - {persona.hasNoAnswer ? "담당 답변 없음" : persona.score === null ? "분석 대기" : `${persona.score}점`} + {persona.hasNoAnswer ? "담당 답변 없음" : persona.score === null ? "미제공" : `${persona.score}점`} )) : ( - 면접관별 코멘트가 아직 없습니다. + 면접관별 코멘트가 제공되지 않았습니다. )} - + : null} ); }; diff --git a/src/widgets/feedback-page/feedback-overall/style.ts b/src/widgets/feedback-page/feedback-overall/style.ts index 3b8d9bb7..576b1583 100644 --- a/src/widgets/feedback-page/feedback-overall/style.ts +++ b/src/widgets/feedback-page/feedback-overall/style.ts @@ -419,6 +419,19 @@ export const PersonaEmpty = styled(EmptyText)` padding: 1.5rem; `; +export const SpeechRetryButton = styled.button` + align-self: flex-start; + padding: 0.5rem 0.75rem; + border: 1px solid currentColor; + border-radius: 0.375rem; + color: ${({ theme }) => theme.colors.brand.blue}; + background: ${({ theme }) => theme.colors.white}; + font: inherit; + cursor: pointer; + + &:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +`; + export const SpeechCard = styled.article` ${cardSurface} display: flex; diff --git a/src/widgets/feedback-page/feedback-overall/top-section/index.tsx b/src/widgets/feedback-page/feedback-overall/top-section/index.tsx index 6b1b8f94..9bec3ec7 100644 --- a/src/widgets/feedback-page/feedback-overall/top-section/index.tsx +++ b/src/widgets/feedback-page/feedback-overall/top-section/index.tsx @@ -41,16 +41,16 @@ const FeedbackOverallTopSection = ({ content }: FeedbackOverallTopSectionProps)
종합 점수 - 아래 4가지 평가 기준을 종합해 산출한 최종 점수입니다. + 평가 가능한 항목의 점수와 가중치를 반영한 결과입니다.
{metrics.map((metric) => ( {metric.label} - {metric.score === null ? "분석 대기" : `${metric.score}점`} - : null} ))} diff --git a/src/widgets/feedback-page/feedback-overall/type.ts b/src/widgets/feedback-page/feedback-overall/type.ts index 156dcb86..e31c806c 100644 --- a/src/widgets/feedback-page/feedback-overall/type.ts +++ b/src/widgets/feedback-page/feedback-overall/type.ts @@ -2,6 +2,7 @@ export interface FeedbackOverallMetric { label: string; score: number | null; description?: string; + emptyLabel?: "해당 없음" | "미제공"; } export interface FeedbackOverallPersonaFeedback { @@ -44,11 +45,11 @@ export interface FeedbackOverallMiddleSectionData { previousScore: number | null; currentScore: number | null; }; + showPersonaFeedback: boolean; personaFeedbacks: readonly FeedbackOverallPersonaFeedback[]; } export interface FeedbackOverallBottomSectionData { - speechMetrics: readonly FeedbackOverallMetric[]; reliability: { title: string; scoreLabel: string; @@ -68,7 +69,15 @@ export interface FeedbackOverallMiddleSectionProps { content: FeedbackOverallMiddleSectionData; } +export interface FeedbackSpeechAnalysis { + visible: boolean; + message: string; + canRetry: boolean; +} + export interface FeedbackOverallBottomSectionProps { + speechAnalysis: FeedbackSpeechAnalysis; + onRetrySpeechAnalysis: () => void; content: FeedbackOverallBottomSectionData; isDownloadingPdf?: boolean; onDownloadPdf?: () => void; diff --git a/tests/feedback-visibility-fixture.mjs b/tests/feedback-visibility-fixture.mjs new file mode 100644 index 00000000..ab6389a4 --- /dev/null +++ b/tests/feedback-visibility-fixture.mjs @@ -0,0 +1,39 @@ +// 브라우저 수동 회귀용 독립 fixture. 실제 서버/로그인/유료 모델을 사용하지 않는다. +import { createServer } from "vite"; + +const port = Number(process.env.FEEDBACK_FIXTURE_PORT ?? 5183); +process.env.VITE_AUTH_URL = `http://127.0.0.1:${port}`; +process.env.VITE_API_URL = `http://127.0.0.1:${port}`; +const calls = new Map(); +const scenarios = [ + [4201,"SOLO","TEXT"], [4202,"SOLO","VOICE"], [4203,"MULTI","TEXT"], [4204,"MULTI","VOICE"], + [4205,"SOLO",null], [4206,"MULTI",null], [4207,"SOLO",null], [4208,"SOLO","VOICE"], [4209,"SOLO",null], +]; +const interviews = scenarios.map(([interviewId,mode,answerMode]) => ({interviewId,userId:7,mode,answerMode,status:"COMPLETED",createdAt:"2026-10-19T01:00:00Z",personaIds:mode==="MULTI"?[1,2]:[1]})); +const feedback = (id) => ({ interviewId:id,mode:interviews.find(x=>x.interviewId===id)?.mode,status:"SUCCEEDED",totalScore:89,intentAlignmentScore:100,reliabilityScore:98, + summary:"테스트 답변에서 기술 선택의 근거와 대안을 확인했습니다.",scoreBreakdown:{scoringVersion:"axis-v1",axes:[{axis:"INTENT",score:100,weight:35},{axis:"DEPTH",score:60,weight:25},{axis:"SPECIFICITY",score:95,weight:25},{axis:"ACCURACY",score:100,weight:15}]}, + strengths:["테스트 강점"],improvements:["테스트 보완점"],frequentWords:[{word:"서버",count:3}], + personas:[{personaId:1,personaRole:"TECH",score:90,comment:"기술 면접관의 테스트 코멘트",answeredCount:1},{personaId:2,personaRole:"HR",score:88,comment:"인사 면접관의 테스트 코멘트",answeredCount:1}], + feedbacks:[{questionId:21,questionContent:"테스트 질문",intention:"테스트 의도",userAnswer:"테스트 답변",modelAnswer:"테스트 모범 답변",comment:"테스트 문항 코멘트"}], +}); +const server = await createServer({server:{host:"127.0.0.1",port,strictPort:true},plugins:[{name:"feedback-visibility-fixture",configureServer(vite){vite.middlewares.use((req,res,next)=>{ + const url = new URL(req.url,"http://fixture.invalid"); + const json=(data,code=200)=>{res.statusCode=code;res.setHeader("Content-Type","application/json");res.end(JSON.stringify(data));}; + if(url.pathname==="/__feedback-fixture") {res.setHeader("Content-Type","text/html; charset=utf-8");res.end('

피드백 표시 회귀 fixture

'+scenarios.map(([id,mode,answer])=>`

${id} ${mode} ${answer??"LEGACY"}

`).join(''));return;} + if(url.pathname==="/__feedback-calls") return json(Object.fromEntries(calls)); + if(!url.pathname.startsWith("/api/")) return next(); + calls.set(url.pathname,(calls.get(url.pathname)??0)+1); + if(url.pathname.endsWith("/refresh")) return json({accessToken:"fixture-only-token"}); + if(url.pathname.endsWith("/me")) return json({data:{id:7,name:"회귀 테스트",nickname:"회귀 테스트",email:"test@example.com"}}); + if(url.pathname==="/api/interviews/getAll") return json({success:true,data:interviews}); + if(url.pathname==="/api/feedbacks/getAll") return json({success:true,data:[]}); + if(url.pathname==="/api/answer/getAll") return json({success:true,data:[]}); + if(url.pathname==="/api/feedbacks") return json({success:true,data:feedback(Number(url.searchParams.get("interviewId")))}); + const match=url.pathname.match(/^\/api\/interviews\/(\d+)\/audio-analysis$/); + if(match){const id=Number(match[1]);if(id===4207)return json({message:"fixture 조회 실패"},503); + const reads=calls.get(url.pathname);const status=id===4208?(reads===1?"PENDING":"READY"):id===4209?"PENDING":[4202,4205].includes(id)?"UNAVAILABLE":"READY"; + return json({success:true,data:{status,answers:[4202,4205,4209].includes(id)?[]:[{questionId:"21",recordingId:"1"}],canRetry:false}});} + return json({message:"fixture에서 정의하지 않은 API"},404); +});}}]}); +await server.listen(); +console.log(`Fixture: http://127.0.0.1:${port}/__feedback-fixture`); diff --git a/tests/feedback-visibility.test.mjs b/tests/feedback-visibility.test.mjs new file mode 100644 index 00000000..b8a53811 --- /dev/null +++ b/tests/feedback-visibility.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import ts from "typescript"; + +const load = async (file, replacements = {}) => { + let source = readFileSync(new URL(`../src/${file}`, import.meta.url), "utf8"); + for (const [from, to] of Object.entries(replacements)) source = source.replace(from, to); + const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext } }); + const url = `data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`; + return { url, module: await import(url) }; +}; +const metrics = await load("features/feedback-page/model/feedbackMetrics.ts"); +const { buildFeedbackMetrics } = metrics.module; +const { module: { buildFeedbackOverallContent } } = await load("features/feedback-page/model/feedbackDisplay.ts", { '"./feedbackMetrics"': JSON.stringify(metrics.url) }); +const { module: { buildSpeechAnalysis } } = await load("features/feedback-page/model/feedbackAudio.ts"); +const axes = [ + { axis: "INTENT", score: 100, weight: 35 }, { axis: "DEPTH", score: 60, weight: 25 }, + { axis: "SPECIFICITY", score: 95, weight: 25 }, { axis: "ACCURACY", score: 100, weight: 15 }, +]; + +test("서버 점수는 축 이름으로 연결하고 종합 89점을 유지한다", () => { + const feedback = { totalScore: 89, scoreBreakdown: { axes: [...axes].reverse() } }; + const result = buildFeedbackOverallContent(feedback); + assert.deepEqual(result.topSection.metrics.map(x => [x.label, x.score]), [["의도 충족",100],["깊이",60],["구체성",95],["정확성",100]]); + assert.equal(result.topSection.summary.score, 89); +}); +test("0점·해당 없음·누락·잘못된 값·중복을 구분한다", () => { + const result = buildFeedbackMetrics({ intentAlignmentScore: 90, scoreBreakdown: { axes: [ + { axis: "INTENT", score: 0, weight: 35 }, { axis: "DEPTH", score: null, weight: null }, + { axis: "ACCURACY", score: 50, weight: 15 }, { axis: "ACCURACY", score: 80, weight: 15 }, + ] } }); + assert.deepEqual(result.map(x => [x.score, x.emptyLabel]), [[0,undefined],[null,"해당 없음"],[null,"미제공"],[null,"미제공"]]); + for (const score of [-1, 101, NaN, Infinity, "60", undefined]) { + assert.equal(buildFeedbackMetrics({ scoreBreakdown: { axes: [{ axis: "DEPTH", score, weight: 25 }] } })[1].emptyLabel, "미제공"); + } +}); +test("옛 결과는 의도만 보완하고 명시적 null은 덮어쓰지 않는다", () => { + assert.deepEqual(buildFeedbackMetrics({ intentAlignmentScore: 80 }).map(x => x.score), [80,null,null,null]); + assert.equal(buildFeedbackMetrics({ intentAlignmentScore: 80, scoreBreakdown: { axes: [{ axis:"INTENT",score:null,weight:null }] } })[0].emptyLabel,"해당 없음"); +}); +test("면접관 코멘트는 면접 mode를 우선해 MULTI에서만 노출한다", () => { + const feedback = { mode:"MULTI", personas:[{score:80,comment:"기술 코멘트"}] }; + assert.equal(buildFeedbackOverallContent(feedback,{mode:"SOLO"}).middleSection.showPersonaFeedback,false); + assert.equal(buildFeedbackOverallContent(feedback,{mode:"MULTI"}).middleSection.showPersonaFeedback,true); + assert.equal(buildFeedbackOverallContent(feedback).middleSection.showPersonaFeedback,true); + assert.equal(buildFeedbackOverallContent({}).middleSection.showPersonaFeedback,false); +}); +test("TEXT 음성 영역은 항상 숨기며 VOICE는 녹음 실패에도 표시한다", () => { + for (const status of ["PENDING","READY","PARTIAL","UNAVAILABLE","FAILED"]) { + assert.equal(buildSpeechAnalysis({status,answers:[]},"TEXT").visible,false); + assert.equal(buildSpeechAnalysis({status,answers:[]},"VOICE").visible,true); + } +}); +test("옛 기록은 녹음 없음·분석 중을 구분한다", () => { + assert.equal(buildSpeechAnalysis({status:"UNAVAILABLE",answers:[]},null).visible,false); + assert.equal(buildSpeechAnalysis({status:"PENDING",answers:[]},null).visible,true); + assert.equal(buildSpeechAnalysis({status:"FAILED",answers:[{}]},null).visible,true); +}); + +const stubUrl = `data:text/javascript;base64,${Buffer.from(` +export let draft; +export const calls=[]; +export const reset = value => {draft=value;calls.length=0;}; +export const currentOwnerKey=()=>"7"; +export const loadSetupDraft=()=>draft; +export const saveSetupDraft=value=>(draft=value); +export const buildSoloInterviewPayload=async()=>({personaName:"test",type:"FRIENDLY",career:0,gender:"MALE"}); +export const buildMultiInterviewPayload=async()=>({personaIds:[1,2]}); +export const createInterview=async(body,key)=>{calls.push({body:structuredClone(body),key});return {data:{interviewId:56,userId:7}};}; +`).toString("base64")}`; +const stub = await import(stubUrl); +const { module: { submitSetupDraft } } = await load("features/interview-page/setting-company/model/submitDraft.ts", Object.fromEntries([ + '"@/features/interview-page/interview/api"', + '"@/features/interview-page/setting-interview/model/submitSoloInterview"', + '"@/features/interview-page/setting-multi-interview/model/submitMultiInterview"', + '"./draftStore"', +].map(path => [path, JSON.stringify(stubUrl)]))); + +test("새 SOLO/MULTI 제출은 선택한 답변 방식을 본문에 고정한다", async () => { + for (const mode of ["SOLO","MULTI"]) for (const answerMode of ["text","voice"]) { + const draftId=crypto.randomUUID(); + stub.reset({draftId,ownerKey:"7",mode,settings:{mode,answerMode},companyId:null,submission:null}); + await submitSetupDraft(draftId); + assert.equal(stub.calls[0].body.answerMode,answerMode==="text"?"TEXT":"VOICE"); + } +}); +test("옛 UNKNOWN 본문·요청키는 답변 방식 주입 없이 그대로 재전송한다", async () => { + const draftId=crypto.randomUUID(); + const body={personaName:"old",type:"FRIENDLY",career:0,gender:"MALE"}; + const idempotencyKey=crypto.randomUUID(); + stub.reset({draftId,ownerKey:"7",mode:"SOLO",settings:{mode:"SOLO",answerMode:"text"},companyId:null, + submission:{requestBody:body,idempotencyKey,interviewId:null,state:"UNKNOWN"}}); + await submitSetupDraft(draftId); + assert.deepEqual(stub.calls,[{body,key:idempotencyKey}]); + assert.equal("answerMode" in stub.calls[0].body,false); +});