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
47 changes: 47 additions & 0 deletions docs/feedback-visibility.md
Original file line number Diff line number Diff line change
@@ -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)
Binary file added docs/images/feedback-visibility/solo-text-pdf.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/feedback-visibility/solo-text.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions src/features/feedback-page/api/getAudioAnalysis.ts
Original file line number Diff line number Diff line change
@@ -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<AudioAnalysis> => {
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 };
};
11 changes: 10 additions & 1 deletion src/features/feedback-page/feedback-list/api/getAllInterviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/features/feedback-page/model/feedbackAudio.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
20 changes: 5 additions & 15 deletions src/features/feedback-page/model/feedbackDisplay.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildFeedbackMetrics } from "./feedbackMetrics";
import type { InterviewAnswer } from "@/features/feedback-page/api/getInterviewAnswers";
import type {
FeedbackData,
Expand Down Expand Up @@ -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: {
Expand All @@ -140,28 +134,24 @@ 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:
persona.imageUrl ??
(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) => ({
Expand Down
28 changes: 28 additions & 0 deletions src/features/feedback-page/model/feedbackMetrics.ts
Original file line number Diff line number Diff line change
@@ -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: "미제공" };
});
};
47 changes: 47 additions & 0 deletions src/features/feedback-page/model/useFeedbackAudioAnalysis.ts
Original file line number Diff line number Diff line change
@@ -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),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -19,6 +20,7 @@ export type InterviewLifecycleStatus =

export interface CreateSoloInterviewParams {
companyId?: number;
answerMode?: InterviewAnswerMode | null;
personaName: string;
major?: InterviewPersonaMajor;
type: CreateInterviewPersonaType;
Expand All @@ -28,6 +30,7 @@ export interface CreateSoloInterviewParams {

export interface CreateMultiInterviewParams {
companyId?: number;
answerMode?: InterviewAnswerMode | null;
personaIds: number[];
}

Expand All @@ -40,6 +43,7 @@ export interface CreatedInterviewData {
interviewId: number;
userId: number;
mode: InterviewMode;
answerMode?: InterviewAnswerMode | null;
personaId: number | null;
personaIds: number[];
sessionId: string;
Expand Down
1 change: 1 addition & 0 deletions src/features/interview-page/interview/api/type/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
CreatedInterviewData,
InterviewLifecycleStatus,
InterviewMode,
InterviewAnswerMode,
InterviewPersonaGender,
InterviewPersonaMajor,
InterviewPersonaRole,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const submitSetupDraft = (draftId: string): Promise<SetupDraft> => {
}
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",
} });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "면접 정보를 확인하지 못했습니다.") });
}
Expand Down
4 changes: 4 additions & 0 deletions src/pages/feedback-page/feedback-overall/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<HTMLElement | null>(null);
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);

Expand Down Expand Up @@ -88,6 +90,8 @@ const FeedbackOverallPage = () => {
<CompanyFeedback feedback={feedback!} interview={interview} />
<FeedbackOverallBottomSection
content={content.bottomSection}
speechAnalysis={audio.view}
onRetrySpeechAnalysis={audio.retry}
isDownloadingPdf={isDownloadingPdf}
onDownloadPdf={handleDownloadPdf}
/>
Expand Down
Loading